feat: implement AccessDeniedScreen and enhance error handling
- Introduced AccessDeniedScreen component for improved user experience when access is denied, replacing the previous AccessDenied component. - Updated CurrentDutyView and CalendarPage to handle access denied scenarios, displaying the new screen appropriately. - Enhanced tests for CurrentDutyView and AccessDeniedScreen to ensure correct rendering and functionality under access denied conditions. - Refactored localization messages to include new labels for access denied scenarios in both English and Russian.
This commit is contained in:
@@ -4,17 +4,14 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
import { render, screen, waitFor } from "@testing-library/react";
|
import { render, screen, waitFor, act } from "@testing-library/react";
|
||||||
import Page from "./page";
|
import Page from "./page";
|
||||||
import { resetAppStore } from "@/test/test-utils";
|
import { resetAppStore } from "@/test/test-utils";
|
||||||
import { useAppStore } from "@/store/app-store";
|
import { useAppStore } from "@/store/app-store";
|
||||||
|
import { useTelegramAuth } from "@/hooks/use-telegram-auth";
|
||||||
|
|
||||||
vi.mock("@/hooks/use-telegram-auth", () => ({
|
vi.mock("@/hooks/use-telegram-auth", () => ({
|
||||||
useTelegramAuth: () => ({
|
useTelegramAuth: vi.fn(),
|
||||||
initDataRaw: "test-init",
|
|
||||||
startParam: undefined,
|
|
||||||
isLocalhost: true,
|
|
||||||
}),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/hooks/use-month-data", () => ({
|
vi.mock("@/hooks/use-month-data", () => ({
|
||||||
@@ -26,6 +23,11 @@ vi.mock("@/hooks/use-month-data", () => ({
|
|||||||
describe("Page", () => {
|
describe("Page", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
resetAppStore();
|
resetAppStore();
|
||||||
|
vi.mocked(useTelegramAuth).mockReturnValue({
|
||||||
|
initDataRaw: "test-init",
|
||||||
|
startParam: undefined,
|
||||||
|
isLocalhost: true,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders calendar and header when store has default state", async () => {
|
it("renders calendar and header when store has default state", async () => {
|
||||||
@@ -51,4 +53,27 @@ describe("Page", () => {
|
|||||||
expect(document.title).toBe("Календарь дежурств");
|
expect(document.title).toBe("Календарь дежурств");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders AccessDeniedScreen when not allowed and delay has passed", async () => {
|
||||||
|
const { RETRY_DELAY_MS } = await import("@/lib/constants");
|
||||||
|
vi.mocked(useTelegramAuth).mockReturnValue({
|
||||||
|
initDataRaw: undefined,
|
||||||
|
startParam: undefined,
|
||||||
|
isLocalhost: false,
|
||||||
|
});
|
||||||
|
vi.useFakeTimers();
|
||||||
|
render(<Page />);
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(RETRY_DELAY_MS);
|
||||||
|
});
|
||||||
|
vi.useRealTimers();
|
||||||
|
expect(
|
||||||
|
await screen.findByText(/Access denied|Доступ запрещён/i, {}, { timeout: 2000 })
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText(/Open the app again from Telegram|Откройте приложение снова из Telegram/i)
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: /Reload|Обновить/i })).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("grid", { name: "Calendar" })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,12 +6,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect } from "react";
|
import { useCallback, useEffect } from "react";
|
||||||
import { useAppStore } from "@/store/app-store";
|
import { useAppStore, type AppState } from "@/store/app-store";
|
||||||
import { useShallow } from "zustand/react/shallow";
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import { useTelegramTheme } from "@/hooks/use-telegram-theme";
|
import { useTelegramTheme } from "@/hooks/use-telegram-theme";
|
||||||
import { useTelegramAuth } from "@/hooks/use-telegram-auth";
|
import { useTelegramAuth } from "@/hooks/use-telegram-auth";
|
||||||
import { useAppInit } from "@/hooks/use-app-init";
|
import { useAppInit } from "@/hooks/use-app-init";
|
||||||
import { callMiniAppReadyOnce } from "@/lib/telegram-ready";
|
import { callMiniAppReadyOnce } from "@/lib/telegram-ready";
|
||||||
|
import { AccessDeniedScreen } from "@/components/states/AccessDeniedScreen";
|
||||||
import { CurrentDutyView } from "@/components/current-duty/CurrentDutyView";
|
import { CurrentDutyView } from "@/components/current-duty/CurrentDutyView";
|
||||||
import { CalendarPage } from "@/components/CalendarPage";
|
import { CalendarPage } from "@/components/CalendarPage";
|
||||||
|
|
||||||
@@ -23,9 +24,10 @@ export default function Home() {
|
|||||||
|
|
||||||
useAppInit({ isAllowed, startParam });
|
useAppInit({ isAllowed, startParam });
|
||||||
|
|
||||||
const { currentView, setCurrentView, setSelectedDay, appContentReady } =
|
const { accessDenied, currentView, setCurrentView, setSelectedDay, appContentReady } =
|
||||||
useAppStore(
|
useAppStore(
|
||||||
useShallow((s) => ({
|
useShallow((s: AppState) => ({
|
||||||
|
accessDenied: s.accessDenied,
|
||||||
currentView: s.currentView,
|
currentView: s.currentView,
|
||||||
setCurrentView: s.setCurrentView,
|
setCurrentView: s.setCurrentView,
|
||||||
setSelectedDay: s.setSelectedDay,
|
setSelectedDay: s.setSelectedDay,
|
||||||
@@ -45,8 +47,9 @@ export default function Home() {
|
|||||||
setSelectedDay(null);
|
setSelectedDay(null);
|
||||||
}, [setCurrentView, setSelectedDay]);
|
}, [setCurrentView, setSelectedDay]);
|
||||||
|
|
||||||
const content =
|
const content = accessDenied ? (
|
||||||
currentView === "currentDuty" ? (
|
<AccessDeniedScreen primaryAction="reload" />
|
||||||
|
) : currentView === "currentDuty" ? (
|
||||||
<div className="mx-auto flex min-h-screen w-full max-w-[var(--max-width-app)] flex-col bg-background px-3 pb-6 pt-safe">
|
<div className="mx-auto flex min-h-screen w-full max-w-[var(--max-width-app)] flex-col bg-background px-3 pb-6 pt-safe">
|
||||||
<CurrentDutyView
|
<CurrentDutyView
|
||||||
onBack={handleBackFromCurrentDuty}
|
onBack={handleBackFromCurrentDuty}
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import { CalendarGrid } from "@/components/calendar/CalendarGrid";
|
|||||||
import { DutyList } from "@/components/duty/DutyList";
|
import { DutyList } from "@/components/duty/DutyList";
|
||||||
import { DayDetail, type DayDetailHandle } from "@/components/day-detail";
|
import { DayDetail, type DayDetailHandle } from "@/components/day-detail";
|
||||||
import { ErrorState } from "@/components/states/ErrorState";
|
import { ErrorState } from "@/components/states/ErrorState";
|
||||||
import { AccessDenied } from "@/components/states/AccessDenied";
|
|
||||||
|
|
||||||
/** Fallback height (px) until ResizeObserver reports the sticky block size. Matches --calendar-block-min-height + pb-2 (260 + 8). */
|
/** Fallback height (px) until ResizeObserver reports the sticky block size. Matches --calendar-block-min-height + pb-2 (260 + 8). */
|
||||||
const STICKY_HEIGHT_FALLBACK_PX = 268;
|
const STICKY_HEIGHT_FALLBACK_PX = 268;
|
||||||
@@ -51,7 +50,6 @@ export function CalendarPage({ isAllowed, initDataRaw }: CalendarPageProps) {
|
|||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
accessDenied,
|
accessDenied,
|
||||||
accessDeniedDetail,
|
|
||||||
duties,
|
duties,
|
||||||
calendarEvents,
|
calendarEvents,
|
||||||
selectedDay,
|
selectedDay,
|
||||||
@@ -67,7 +65,6 @@ export function CalendarPage({ isAllowed, initDataRaw }: CalendarPageProps) {
|
|||||||
loading: s.loading,
|
loading: s.loading,
|
||||||
error: s.error,
|
error: s.error,
|
||||||
accessDenied: s.accessDenied,
|
accessDenied: s.accessDenied,
|
||||||
accessDeniedDetail: s.accessDeniedDetail,
|
|
||||||
duties: s.duties,
|
duties: s.duties,
|
||||||
calendarEvents: s.calendarEvents,
|
calendarEvents: s.calendarEvents,
|
||||||
selectedDay: s.selectedDay,
|
selectedDay: s.selectedDay,
|
||||||
@@ -155,13 +152,10 @@ export function CalendarPage({ isAllowed, initDataRaw }: CalendarPageProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{accessDenied && (
|
{error && (
|
||||||
<AccessDenied serverDetail={accessDeniedDetail} className="my-3" />
|
|
||||||
)}
|
|
||||||
{!accessDenied && error && (
|
|
||||||
<ErrorState message={error} onRetry={retry} className="my-3" />
|
<ErrorState message={error} onRetry={retry} className="my-3" />
|
||||||
)}
|
)}
|
||||||
{!accessDenied && !error && (
|
{!error && (
|
||||||
<DutyList
|
<DutyList
|
||||||
scrollMarginTop={stickyBlockHeight}
|
scrollMarginTop={stickyBlockHeight}
|
||||||
className="mt-2"
|
className="mt-2"
|
||||||
|
|||||||
@@ -127,4 +127,25 @@ describe("CurrentDutyView", () => {
|
|||||||
expect(buttons[0]).toHaveAccessibleName(/Retry|Повторить/i);
|
expect(buttons[0]).toHaveAccessibleName(/Retry|Повторить/i);
|
||||||
vi.mocked(fetchDuties).mockResolvedValue([]);
|
vi.mocked(fetchDuties).mockResolvedValue([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("403 shows AccessDeniedScreen with Back button and no Retry", async () => {
|
||||||
|
const { fetchDuties, AccessDeniedError } = await import("@/lib/api");
|
||||||
|
vi.mocked(fetchDuties).mockRejectedValue(
|
||||||
|
new AccessDeniedError("ACCESS_DENIED", "Custom 403 message")
|
||||||
|
);
|
||||||
|
const onBack = vi.fn();
|
||||||
|
render(<CurrentDutyView onBack={onBack} />);
|
||||||
|
await screen.findByText(/Access denied|Доступ запрещён/i, {}, { timeout: 3000 });
|
||||||
|
expect(
|
||||||
|
screen.getByText(/Open the app again from Telegram|Откройте приложение снова из Telegram/i)
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Custom 403 message")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: /Back to calendar|Назад к календарю/i })
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: /Retry|Повторить/i })).not.toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /Back to calendar|Назад к календарю/i }));
|
||||||
|
expect(onBack).toHaveBeenCalled();
|
||||||
|
vi.mocked(fetchDuties).mockResolvedValue([]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
CardTitle,
|
CardTitle,
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { AccessDeniedScreen } from "@/components/states/AccessDeniedScreen";
|
||||||
import type { DutyWithUser } from "@/types";
|
import type { DutyWithUser } from "@/types";
|
||||||
|
|
||||||
export interface CurrentDutyViewProps {
|
export interface CurrentDutyViewProps {
|
||||||
@@ -39,7 +40,7 @@ export interface CurrentDutyViewProps {
|
|||||||
openedFromPin?: boolean;
|
openedFromPin?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ViewState = "loading" | "error" | "ready";
|
type ViewState = "loading" | "error" | "accessDenied" | "ready";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Full-screen current duty view with Telegram BackButton and auto-updating remaining time.
|
* Full-screen current duty view with Telegram BackButton and auto-updating remaining time.
|
||||||
@@ -53,6 +54,7 @@ export function CurrentDutyView({ onBack, openedFromPin = false }: CurrentDutyVi
|
|||||||
const [state, setState] = useState<ViewState>("loading");
|
const [state, setState] = useState<ViewState>("loading");
|
||||||
const [duty, setDuty] = useState<DutyWithUser | null>(null);
|
const [duty, setDuty] = useState<DutyWithUser | null>(null);
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
|
const [accessDeniedDetail, setAccessDeniedDetail] = useState<string | null>(null);
|
||||||
const [remaining, setRemaining] = useState<{ hours: number; minutes: number } | null>(null);
|
const [remaining, setRemaining] = useState<{ hours: number; minutes: number } | null>(null);
|
||||||
|
|
||||||
const loadTodayDuties = useCallback(
|
const loadTodayDuties = useCallback(
|
||||||
@@ -74,14 +76,17 @@ export function CurrentDutyView({ onBack, openedFromPin = false }: CurrentDutyVi
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (signal?.aborted) return;
|
if (signal?.aborted) return;
|
||||||
setState("error");
|
if (e instanceof AccessDeniedError) {
|
||||||
const msg =
|
setState("accessDenied");
|
||||||
e instanceof AccessDeniedError && e.serverDetail
|
setAccessDeniedDetail(e.serverDetail ?? null);
|
||||||
? e.serverDetail
|
|
||||||
: t("error_generic");
|
|
||||||
setErrorMessage(msg);
|
|
||||||
setDuty(null);
|
setDuty(null);
|
||||||
setRemaining(null);
|
setRemaining(null);
|
||||||
|
} else {
|
||||||
|
setState("error");
|
||||||
|
setErrorMessage(t("error_generic"));
|
||||||
|
setDuty(null);
|
||||||
|
setRemaining(null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[initDataRaw, lang, t]
|
[initDataRaw, lang, t]
|
||||||
@@ -195,6 +200,17 @@ export function CurrentDutyView({ onBack, openedFromPin = false }: CurrentDutyVi
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (state === "accessDenied") {
|
||||||
|
return (
|
||||||
|
<AccessDeniedScreen
|
||||||
|
serverDetail={accessDeniedDetail}
|
||||||
|
primaryAction="back"
|
||||||
|
onBack={handlePrimaryAction}
|
||||||
|
openedFromPin={openedFromPin}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (state === "error") {
|
if (state === "error") {
|
||||||
const handleRetry = () => {
|
const handleRetry = () => {
|
||||||
setState("loading");
|
setState("loading");
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
/**
|
|
||||||
* Unit tests for AccessDenied. Ported from webapp/js/ui.test.js showAccessDenied.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, it, expect, beforeEach } from "vitest";
|
|
||||||
import { render, screen } from "@testing-library/react";
|
|
||||||
import { AccessDenied } from "./AccessDenied";
|
|
||||||
import { resetAppStore } from "@/test/test-utils";
|
|
||||||
|
|
||||||
describe("AccessDenied", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
resetAppStore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders translated access denied message", () => {
|
|
||||||
render(<AccessDenied serverDetail={null} />);
|
|
||||||
expect(screen.getByText(/Access denied|Доступ запрещён/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("appends serverDetail when provided", () => {
|
|
||||||
render(<AccessDenied serverDetail="Custom 403 message" />);
|
|
||||||
expect(screen.getByText("Custom 403 message")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
/**
|
|
||||||
* Access denied state: message and optional server detail.
|
|
||||||
* Ported from webapp/js/ui.js showAccessDenied and states.css .access-denied.
|
|
||||||
*/
|
|
||||||
|
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useTranslation } from "@/i18n/use-translation";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
export interface AccessDeniedProps {
|
|
||||||
/** Optional detail from API 403 response, shown below the main message. */
|
|
||||||
serverDetail?: string | null;
|
|
||||||
/** Optional class for the container. */
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Displays access denied message; optional second paragraph for server detail.
|
|
||||||
*/
|
|
||||||
export function AccessDenied({ serverDetail, className }: AccessDeniedProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const hasDetail = Boolean(serverDetail && String(serverDetail).trim());
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"rounded-xl bg-surface py-6 px-4 my-3 text-center text-muted-foreground shadow-sm transition-opacity duration-200",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
role="alert"
|
|
||||||
>
|
|
||||||
<p className="m-0 mb-2 font-semibold text-error">
|
|
||||||
{t("access_denied")}
|
|
||||||
</p>
|
|
||||||
{hasDetail && (
|
|
||||||
<p className="mt-2 m-0 text-sm text-muted">
|
|
||||||
{serverDetail}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<p className="mt-2 m-0 text-sm text-muted">
|
|
||||||
{t("access_denied.hint")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
/**
|
||||||
|
* Unit tests for AccessDeniedScreen: full-screen access denied, reload and back modes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
import { AccessDeniedScreen } from "./AccessDeniedScreen";
|
||||||
|
import { resetAppStore } from "@/test/test-utils";
|
||||||
|
|
||||||
|
describe("AccessDeniedScreen", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
resetAppStore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders translated access denied title and hint", () => {
|
||||||
|
render(<AccessDeniedScreen primaryAction="reload" />);
|
||||||
|
expect(screen.getByText(/Access denied|Доступ запрещён/i)).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText(/Open the app again from Telegram|Откройте приложение снова из Telegram/i)
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows serverDetail when provided", () => {
|
||||||
|
render(
|
||||||
|
<AccessDeniedScreen primaryAction="reload" serverDetail="Custom 403 message" />
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Custom 403 message")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reload mode shows Reload button", () => {
|
||||||
|
render(<AccessDeniedScreen primaryAction="reload" />);
|
||||||
|
const button = screen.getByRole("button", { name: /Reload|Обновить/i });
|
||||||
|
expect(button).toBeInTheDocument();
|
||||||
|
const reloadFn = vi.fn();
|
||||||
|
Object.defineProperty(window, "location", {
|
||||||
|
value: { ...window.location, reload: reloadFn },
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
|
fireEvent.click(button);
|
||||||
|
expect(reloadFn).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("back mode shows Back to calendar and calls onBack on click", () => {
|
||||||
|
const onBack = vi.fn();
|
||||||
|
render(
|
||||||
|
<AccessDeniedScreen
|
||||||
|
primaryAction="back"
|
||||||
|
onBack={onBack}
|
||||||
|
openedFromPin={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const button = screen.getByRole("button", {
|
||||||
|
name: /Back to calendar|Назад к календарю/i,
|
||||||
|
});
|
||||||
|
expect(button).toBeInTheDocument();
|
||||||
|
fireEvent.click(button);
|
||||||
|
expect(onBack).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("back mode with openedFromPin shows Close button", () => {
|
||||||
|
const onBack = vi.fn();
|
||||||
|
render(
|
||||||
|
<AccessDeniedScreen
|
||||||
|
primaryAction="back"
|
||||||
|
onBack={onBack}
|
||||||
|
openedFromPin={true}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const button = screen.getByRole("button", { name: /Close|Закрыть/i });
|
||||||
|
expect(button).toBeInTheDocument();
|
||||||
|
fireEvent.click(button);
|
||||||
|
expect(onBack).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
84
webapp-next/src/components/states/AccessDeniedScreen.tsx
Normal file
84
webapp-next/src/components/states/AccessDeniedScreen.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
/**
|
||||||
|
* Full-screen access denied view. Used when the user is not allowed (no initData / not localhost)
|
||||||
|
* or when API returns 403. Matches global-error and not-found layout: no extra chrome, one action.
|
||||||
|
*/
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { getLang, translate } from "@/i18n/messages";
|
||||||
|
import { useAppStore } from "@/store/app-store";
|
||||||
|
|
||||||
|
export interface AccessDeniedScreenProps {
|
||||||
|
/** Optional detail from API 403 response, shown below the hint. */
|
||||||
|
serverDetail?: string | null;
|
||||||
|
/** Primary button: reload (main page) or back/close (deep link). */
|
||||||
|
primaryAction: "reload" | "back";
|
||||||
|
/** Called when primaryAction is "back" (e.g. Back to calendar or Close). */
|
||||||
|
onBack?: () => void;
|
||||||
|
/** When true and primaryAction is "back", button label is "Close" instead of "Back to calendar". */
|
||||||
|
openedFromPin?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-screen access denied: title, hint, optional server detail, single action button.
|
||||||
|
* Calls setAppContentReady(true) on mount so Telegram receives ready().
|
||||||
|
*/
|
||||||
|
export function AccessDeniedScreen({
|
||||||
|
serverDetail,
|
||||||
|
primaryAction,
|
||||||
|
onBack,
|
||||||
|
openedFromPin = false,
|
||||||
|
}: AccessDeniedScreenProps) {
|
||||||
|
const lang = getLang();
|
||||||
|
const setAppContentReady = useAppStore((s) => s.setAppContentReady);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setAppContentReady(true);
|
||||||
|
}, [setAppContentReady]);
|
||||||
|
|
||||||
|
const hasDetail = Boolean(serverDetail && String(serverDetail).trim());
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
if (primaryAction === "reload") {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
onBack?.();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const buttonLabel =
|
||||||
|
primaryAction === "reload"
|
||||||
|
? translate(lang, "access_denied.reload")
|
||||||
|
: openedFromPin
|
||||||
|
? translate(lang, "current_duty.close")
|
||||||
|
: translate(lang, "current_duty.back");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex min-h-screen flex-col items-center justify-center gap-4 bg-background px-4 text-foreground"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
<h1 className="text-xl font-semibold">
|
||||||
|
{translate(lang, "access_denied")}
|
||||||
|
</h1>
|
||||||
|
<p className="text-center text-muted-foreground">
|
||||||
|
{translate(lang, "access_denied.hint")}
|
||||||
|
</p>
|
||||||
|
{hasDetail && (
|
||||||
|
<p className="text-center text-sm text-muted-foreground">
|
||||||
|
{serverDetail}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClick}
|
||||||
|
className="rounded-lg bg-primary px-4 py-2 text-primary-foreground hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
{buttonLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,4 +4,4 @@
|
|||||||
|
|
||||||
export { LoadingState } from "./LoadingState";
|
export { LoadingState } from "./LoadingState";
|
||||||
export { ErrorState } from "./ErrorState";
|
export { ErrorState } from "./ErrorState";
|
||||||
export { AccessDenied } from "./AccessDenied";
|
export { AccessDeniedScreen } from "./AccessDeniedScreen";
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ export const MESSAGES: Record<Lang, Record<string, string>> = {
|
|||||||
"not_found.description": "The page you are looking for does not exist.",
|
"not_found.description": "The page you are looking for does not exist.",
|
||||||
"not_found.open_calendar": "Open calendar",
|
"not_found.open_calendar": "Open calendar",
|
||||||
"access_denied.hint": "Open the app again from Telegram.",
|
"access_denied.hint": "Open the app again from Telegram.",
|
||||||
|
"access_denied.reload": "Reload",
|
||||||
},
|
},
|
||||||
ru: {
|
ru: {
|
||||||
"app.title": "Календарь дежурств",
|
"app.title": "Календарь дежурств",
|
||||||
@@ -157,6 +158,7 @@ export const MESSAGES: Record<Lang, Record<string, string>> = {
|
|||||||
"not_found.description": "Запрашиваемая страница не существует.",
|
"not_found.description": "Запрашиваемая страница не существует.",
|
||||||
"not_found.open_calendar": "Открыть календарь",
|
"not_found.open_calendar": "Открыть календарь",
|
||||||
"access_denied.hint": "Откройте приложение снова из Telegram.",
|
"access_denied.hint": "Откройте приложение снова из Telegram.",
|
||||||
|
"access_denied.reload": "Обновить",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export interface AppState {
|
|||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
accessDenied: boolean;
|
accessDenied: boolean;
|
||||||
/** Server detail from API 403 response; shown in AccessDenied component. */
|
/** Server detail from API 403 response; shown in AccessDeniedScreen. */
|
||||||
accessDeniedDetail: string | null;
|
accessDeniedDetail: string | null;
|
||||||
currentView: CurrentView;
|
currentView: CurrentView;
|
||||||
selectedDay: string | null;
|
selectedDay: string | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user