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;