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:
2026-03-04 17:51:30 +03:00
parent 3244fbe505
commit 33359f589a
12 changed files with 258 additions and 109 deletions

View File

@@ -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();
});
}); });

View File

@@ -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}

View File

@@ -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"

View File

@@ -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([]);
});
}); });

View File

@@ -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");

View File

@@ -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();
});
});

View File

@@ -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>
);
}

View File

@@ -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();
});
});

View 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>
);
}

View File

@@ -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";

View File

@@ -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": "Обновить",
}, },
}; };

View File

@@ -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;