diff --git a/webapp-next/src/app/page.test.tsx b/webapp-next/src/app/page.test.tsx index 0ff1065..17740ec 100644 --- a/webapp-next/src/app/page.test.tsx +++ b/webapp-next/src/app/page.test.tsx @@ -4,17 +4,14 @@ */ 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 { resetAppStore } from "@/test/test-utils"; import { useAppStore } from "@/store/app-store"; +import { useTelegramAuth } from "@/hooks/use-telegram-auth"; vi.mock("@/hooks/use-telegram-auth", () => ({ - useTelegramAuth: () => ({ - initDataRaw: "test-init", - startParam: undefined, - isLocalhost: true, - }), + useTelegramAuth: vi.fn(), })); vi.mock("@/hooks/use-month-data", () => ({ @@ -26,6 +23,11 @@ vi.mock("@/hooks/use-month-data", () => ({ describe("Page", () => { beforeEach(() => { resetAppStore(); + vi.mocked(useTelegramAuth).mockReturnValue({ + initDataRaw: "test-init", + startParam: undefined, + isLocalhost: true, + }); }); it("renders calendar and header when store has default state", async () => { @@ -51,4 +53,27 @@ describe("Page", () => { 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(); + 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(); + }); }); diff --git a/webapp-next/src/app/page.tsx b/webapp-next/src/app/page.tsx index 52bea0a..7691a28 100644 --- a/webapp-next/src/app/page.tsx +++ b/webapp-next/src/app/page.tsx @@ -6,12 +6,13 @@ "use client"; 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 { useTelegramTheme } from "@/hooks/use-telegram-theme"; import { useTelegramAuth } from "@/hooks/use-telegram-auth"; import { useAppInit } from "@/hooks/use-app-init"; import { callMiniAppReadyOnce } from "@/lib/telegram-ready"; +import { AccessDeniedScreen } from "@/components/states/AccessDeniedScreen"; import { CurrentDutyView } from "@/components/current-duty/CurrentDutyView"; import { CalendarPage } from "@/components/CalendarPage"; @@ -23,9 +24,10 @@ export default function Home() { useAppInit({ isAllowed, startParam }); - const { currentView, setCurrentView, setSelectedDay, appContentReady } = + const { accessDenied, currentView, setCurrentView, setSelectedDay, appContentReady } = useAppStore( - useShallow((s) => ({ + useShallow((s: AppState) => ({ + accessDenied: s.accessDenied, currentView: s.currentView, setCurrentView: s.setCurrentView, setSelectedDay: s.setSelectedDay, @@ -45,17 +47,18 @@ export default function Home() { setSelectedDay(null); }, [setCurrentView, setSelectedDay]); - const content = - currentView === "currentDuty" ? ( -
- -
- ) : ( - - ); + const content = accessDenied ? ( + + ) : currentView === "currentDuty" ? ( +
+ +
+ ) : ( + + ); return (
- {accessDenied && ( - - )} - {!accessDenied && error && ( + {error && ( )} - {!accessDenied && !error && ( + {!error && ( { expect(buttons[0]).toHaveAccessibleName(/Retry|Повторить/i); 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(); + 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([]); + }); }); diff --git a/webapp-next/src/components/current-duty/CurrentDutyView.tsx b/webapp-next/src/components/current-duty/CurrentDutyView.tsx index 08ed618..943c02a 100644 --- a/webapp-next/src/components/current-duty/CurrentDutyView.tsx +++ b/webapp-next/src/components/current-duty/CurrentDutyView.tsx @@ -30,6 +30,7 @@ import { CardTitle, } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; +import { AccessDeniedScreen } from "@/components/states/AccessDeniedScreen"; import type { DutyWithUser } from "@/types"; export interface CurrentDutyViewProps { @@ -39,7 +40,7 @@ export interface CurrentDutyViewProps { 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. @@ -53,6 +54,7 @@ export function CurrentDutyView({ onBack, openedFromPin = false }: CurrentDutyVi const [state, setState] = useState("loading"); const [duty, setDuty] = useState(null); const [errorMessage, setErrorMessage] = useState(null); + const [accessDeniedDetail, setAccessDeniedDetail] = useState(null); const [remaining, setRemaining] = useState<{ hours: number; minutes: number } | null>(null); const loadTodayDuties = useCallback( @@ -74,14 +76,17 @@ export function CurrentDutyView({ onBack, openedFromPin = false }: CurrentDutyVi } } catch (e) { if (signal?.aborted) return; - setState("error"); - const msg = - e instanceof AccessDeniedError && e.serverDetail - ? e.serverDetail - : t("error_generic"); - setErrorMessage(msg); - setDuty(null); - setRemaining(null); + if (e instanceof AccessDeniedError) { + setState("accessDenied"); + setAccessDeniedDetail(e.serverDetail ?? null); + setDuty(null); + setRemaining(null); + } else { + setState("error"); + setErrorMessage(t("error_generic")); + setDuty(null); + setRemaining(null); + } } }, [initDataRaw, lang, t] @@ -195,6 +200,17 @@ export function CurrentDutyView({ onBack, openedFromPin = false }: CurrentDutyVi ); } + if (state === "accessDenied") { + return ( + + ); + } + if (state === "error") { const handleRetry = () => { setState("loading"); diff --git a/webapp-next/src/components/states/AccessDenied.test.tsx b/webapp-next/src/components/states/AccessDenied.test.tsx deleted file mode 100644 index 765d6fd..0000000 --- a/webapp-next/src/components/states/AccessDenied.test.tsx +++ /dev/null @@ -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(); - expect(screen.getByText(/Access denied|Доступ запрещён/i)).toBeInTheDocument(); - }); - - it("appends serverDetail when provided", () => { - render(); - expect(screen.getByText("Custom 403 message")).toBeInTheDocument(); - }); -}); diff --git a/webapp-next/src/components/states/AccessDenied.tsx b/webapp-next/src/components/states/AccessDenied.tsx deleted file mode 100644 index 72514d1..0000000 --- a/webapp-next/src/components/states/AccessDenied.tsx +++ /dev/null @@ -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 ( -
-

- {t("access_denied")} -

- {hasDetail && ( -

- {serverDetail} -

- )} -

- {t("access_denied.hint")} -

-
- ); -} diff --git a/webapp-next/src/components/states/AccessDeniedScreen.test.tsx b/webapp-next/src/components/states/AccessDeniedScreen.test.tsx new file mode 100644 index 0000000..9a51270 --- /dev/null +++ b/webapp-next/src/components/states/AccessDeniedScreen.test.tsx @@ -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(); + 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( + + ); + expect(screen.getByText("Custom 403 message")).toBeInTheDocument(); + }); + + it("reload mode shows Reload button", () => { + render(); + 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( + + ); + 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( + + ); + const button = screen.getByRole("button", { name: /Close|Закрыть/i }); + expect(button).toBeInTheDocument(); + fireEvent.click(button); + expect(onBack).toHaveBeenCalled(); + }); +}); diff --git a/webapp-next/src/components/states/AccessDeniedScreen.tsx b/webapp-next/src/components/states/AccessDeniedScreen.tsx new file mode 100644 index 0000000..0793e92 --- /dev/null +++ b/webapp-next/src/components/states/AccessDeniedScreen.tsx @@ -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 ( +
+

+ {translate(lang, "access_denied")} +

+

+ {translate(lang, "access_denied.hint")} +

+ {hasDetail && ( +

+ {serverDetail} +

+ )} + +
+ ); +} diff --git a/webapp-next/src/components/states/index.ts b/webapp-next/src/components/states/index.ts index ff2701d..14b257a 100644 --- a/webapp-next/src/components/states/index.ts +++ b/webapp-next/src/components/states/index.ts @@ -4,4 +4,4 @@ export { LoadingState } from "./LoadingState"; export { ErrorState } from "./ErrorState"; -export { AccessDenied } from "./AccessDenied"; +export { AccessDeniedScreen } from "./AccessDeniedScreen"; diff --git a/webapp-next/src/i18n/messages.ts b/webapp-next/src/i18n/messages.ts index 427da3a..f005021 100644 --- a/webapp-next/src/i18n/messages.ts +++ b/webapp-next/src/i18n/messages.ts @@ -81,6 +81,7 @@ export const MESSAGES: Record> = { "not_found.description": "The page you are looking for does not exist.", "not_found.open_calendar": "Open calendar", "access_denied.hint": "Open the app again from Telegram.", + "access_denied.reload": "Reload", }, ru: { "app.title": "Календарь дежурств", @@ -157,6 +158,7 @@ export const MESSAGES: Record> = { "not_found.description": "Запрашиваемая страница не существует.", "not_found.open_calendar": "Открыть календарь", "access_denied.hint": "Откройте приложение снова из Telegram.", + "access_denied.reload": "Обновить", }, }; diff --git a/webapp-next/src/store/app-store.ts b/webapp-next/src/store/app-store.ts index 3e65910..74dd8ed 100644 --- a/webapp-next/src/store/app-store.ts +++ b/webapp-next/src/store/app-store.ts @@ -24,7 +24,7 @@ export interface AppState { loading: boolean; error: string | null; accessDenied: boolean; - /** Server detail from API 403 response; shown in AccessDenied component. */ + /** Server detail from API 403 response; shown in AccessDeniedScreen. */ accessDeniedDetail: string | null; currentView: CurrentView; selectedDay: string | null;