"use client"; /** * RouteGuard — клиентский гард, оборачивающий весь app в layout.tsx. * * Логика: * - useMe() loading → null (короткое окно; основной UI вернётся когда * /me ответит, обычно <50 ms из браузер-cache после первого захода) * - useMe() 403 (юзер не в roles.yaml) → fullscreen NoAccessScreen("user") * - useMe() 401 (dev без Caddy basic_auth) → пропускаем (dev escape-hatch). * Это критично — без этого локально без Caddy фронт станет полностью * белым. В проде Caddy всегда ставит X-Authenticated-User → 401 невозможен. * - useMe() ok + pathname ∉ allowed_paths (или ∈ deny_paths) → fullscreen * NoAccessScreen("path"). Не редиректим — у user намеренный direct-URL * hit на /admin (например), и redirect замаскирует, что у него нет * доступа. Прямо говорим «нет». * - useMe() ok + pathname разрешён → пропускаем children. * * Backend всё равно сам блокирует /api/v1/* — этот гард только для UX * («не показывать ссылки и не позволять открыть страницу через URL»). */ 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"; interface RouteGuardProps { children: React.ReactNode; } export function RouteGuard({ children }: RouteGuardProps) { const pathname = usePathname() ?? "/"; const { data, isLoading, error } = useMe(); // Initial load — короткое окно, рендерим пустоту чтобы не моргать UI. if (isLoading) return null; // 401 — dev без Caddy basic_auth. Пропускаем как «нет RBAC, all allowed». if (error instanceof HTTPError && error.status === 401) { return <>{children}; } // 403 — юзер не в roles.yaml. Прям ничего не показываем кроме «доступа нет». if (error instanceof HTTPError && error.status === 403) { return ; } // Другая сетевая ошибка — fail-open в dev / показываем NoAccess в prod-like. // Безопаснее закрыть UI чем случайно пустить юзера на admin при transient // network fail. NoAccessScreen("user") универсальный fallback. if (error) { return ; } // Нет scope (теоретически unreachable — либо error либо data) — заглушка. if (!data) return null; // Path-level guard на прямой URL. Backend всё равно 403 на /api/v1/admin/*, // но мы не хотим показать «битый admin UI» — лучше сразу stop. if (!isPathAllowed(data.allowed_paths, data.deny_paths, pathname)) { // #799: pilot-only юзер (единственная доступная зона — trade-in) на // запрещённом пути главного фронта (корень `/` и пр.) → авто-redirect в // /trade-in/ вместо дед-энда «Доступа нет». Условия: // - роль pilot (trade-in — её ЕДИНСТВЕННАЯ рабочая зона; paths scoped // только на /trade-in/**). #1439: без этой проверки multi-zone роли // (напр. analyst с paths="/**") на запрещённом /admin/* молча // редиректились в /trade-in/ вместо честного NoAccessScreen; // - текущий путь НЕ под /trade-in (иначе цикл; /trade-in — отдельный // bundle за Caddy, этот гард его не обслуживает, но guard на всякий); // - у роли есть доступ к /trade-in/ (т.е. trade-in — её рабочая зона). // admin / multi-scope с разрешённым `/` сюда не попадают (isPathAllowed=true). if ( typeof window !== "undefined" && data.role === "pilot" && !pathname.startsWith("/trade-in") && isPathAllowed(data.allowed_paths, data.deny_paths, "/trade-in/") ) { window.location.replace("/trade-in/"); return null; } return ; } return <>{children}; }