praktika pilot→expired role in roles.yaml (paths:[], deny:/**); RouteGuard short-circuits on role=expired with a dedicated trial-ended screen (not the generic path-deny path). NoAccessScreen gains variant="trial" with title «Пробный доступ закончился» and a Telegram link to @ArtemKopylov87. Backend Role Literal and frontend Role type extended to include "expired". kopylov (pilot) unaffected.
76 lines
3.1 KiB
TypeScript
76 lines
3.1 KiB
TypeScript
"use client";
|
||
|
||
/**
|
||
* MIRROR of main frontend `frontend/src/components/auth/RouteGuard.tsx` —
|
||
* keep in sync manually.
|
||
*
|
||
* Особенность tradein-mvp: Next.js basePath=/trade-in (см. `next.config.ts`).
|
||
* `usePathname()` возвращает путь БЕЗ basePath — например на странице
|
||
* `gendsgn.ru/trade-in/scrapers/avito` хук вернёт `/scrapers/avito`.
|
||
* RBAC config (`auth/roles.yaml`) использует абсолютные пути сайта
|
||
* (`/trade-in/**`, `/trade-in/api/v1/admin/**`), поэтому перед проверкой
|
||
* isPathAllowed мы префиксим pathname через NEXT_PUBLIC_BASE_PATH.
|
||
*/
|
||
|
||
import { usePathname } from "next/navigation";
|
||
|
||
import { NoAccessScreen } from "@/components/auth/NoAccessScreen";
|
||
import { HTTPError } from "@/lib/api";
|
||
import { isPathAllowed } from "@/lib/isPathAllowed";
|
||
import { useMe } from "@/lib/useMe";
|
||
|
||
const BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH ?? "";
|
||
// #801: dev/CI-only preview-маршрут (a11y/lighthouse) рендерится оффлайн без RBAC.
|
||
const ENABLE_PREVIEW = process.env.NEXT_PUBLIC_ENABLE_PREVIEW === "1";
|
||
|
||
interface RouteGuardProps {
|
||
children: React.ReactNode;
|
||
}
|
||
|
||
export function RouteGuard({ children }: RouteGuardProps) {
|
||
const rawPath = usePathname() ?? "/";
|
||
// Абсолютный путь сайта: BASE_PATH + rawPath. Аккуратно с двойным слэшем
|
||
// на `/`: `BASE_PATH = "/trade-in"` + `"/"` → `/trade-in/` (ок).
|
||
const absolutePath = BASE_PATH
|
||
? `${BASE_PATH}${rawPath === "/" ? "/" : rawPath}`
|
||
: rawPath;
|
||
const { data, isLoading, error } = useMe();
|
||
|
||
// #801: preview-страница самодостаточна (свой QueryClient с фейковым me),
|
||
// RBAC к ней не применяем. Только под флагом — в проде по умолчанию выключено.
|
||
if (ENABLE_PREVIEW && rawPath.startsWith("/ui-preview")) {
|
||
return <>{children}</>;
|
||
}
|
||
|
||
if (isLoading) return null;
|
||
|
||
if (error instanceof HTTPError && error.status === 401) {
|
||
// Dev without Caddy: 401 is normal, mount the app so local dev works.
|
||
// Prod: mounting children on 401 causes TanStack Query re-subscribe storm
|
||
// (each new observer on errored query triggers a refetch). Show session screen
|
||
// instead — prevents the subtree from mounting, kills the loop.
|
||
if (process.env.NODE_ENV !== "production") return <>{children}</>;
|
||
return <NoAccessScreen variant="session" />;
|
||
}
|
||
|
||
if (error instanceof HTTPError && error.status === 403) {
|
||
return <NoAccessScreen variant="user" />;
|
||
}
|
||
|
||
if (error) {
|
||
return <NoAccessScreen variant="user" />;
|
||
}
|
||
|
||
if (!data) return null;
|
||
|
||
// Пробный доступ закончился (#praktika): role=expired → спец-экран, а не generic path-deny.
|
||
if (data.role === "expired") {
|
||
return <NoAccessScreen variant="trial" />;
|
||
}
|
||
|
||
if (!isPathAllowed(data.allowed_paths, data.deny_paths, absolutePath)) {
|
||
return <NoAccessScreen variant="path" path={absolutePath} />;
|
||
}
|
||
|
||
return <>{children}</>;
|
||
}
|