From da8125e695b2e79ca79b9dfacac5a3b4c28519f3 Mon Sep 17 00:00:00 2001 From: Light1YT Date: Tue, 26 May 2026 11:42:03 +0500 Subject: [PATCH] feat(rbac): frontend route-guard + nav filter for pilot/admin scopes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR B (frontend) к backend RBAC из PR #585. Реализует требования из Telegram-сессии 2026-05-25: «сверху в баре странички в зависимости от роли», «если доступа нет — слать нахуй», «человек без ролей вообще ничего не видит». Main frontend (`frontend/`): - `src/lib/useMe.ts` — TanStack `useMe()` hook, `staleTime: Infinity` (роль не меняется в рамках сессии); читает GET /api/v1/me. - `src/lib/isPathAllowed.ts` — JS port `backend/app/core/auth.py` `_glob_to_regex`. DSTAR/SSTAR sentinels, `/foo/**` matches `/foo` тоже. - `src/lib/__tests__/isPathAllowed.test.ts` — parity tests vs backend `test_rbac.py::test_is_path_allowed_*` (admin everywhere, pilot blocked from /admin/**, unknown role denied). - `src/components/auth/RouteGuard.tsx` — wraps app в layout. На 403 от /me → ``. На denied path → ``. Loading state — `null` чтобы не было FOUC protected UI. 401 (dev без Caddy) — pass-through. - `src/components/auth/NoAccessScreen.tsx` — fullscreen «доступа нет» с lucide `` иконкой. Token-based styling. - `src/components/landing/TopNav.tsx` — extracted nav из `page.tsx`. Фильтрует items через `isPathAllowed(scope, href)`. Pilot → нет «Админ». - `src/app/{layout.tsx,page.tsx}` — обернуть в ``, inline nav → ``. Tradein mirror (`tradein-mvp/frontend/`): - `src/lib/{useMe,isPathAllowed}.ts` — MIRROR. `useMe` использует `NEXT_PUBLIC_BASE_PATH`-aware URL (`/trade-in/api/v1/me`). - `src/components/auth/{RouteGuard,NoAccessScreen}.tsx` — MIRROR. RouteGuard prepends `basePath` перед scope check (т.к. `usePathname()` strips basePath в Next.js 15 app router). NoAccessScreen — tokenized colors (var(--bg-app), --fg-primary, etc.) per `.claude/rules/ui-tokens.md`. Без lucide (отдельный bundle без shared deps). - `src/components/trade-in/Topbar.tsx` — scraper tabs (Avito/Cian/Yandex/ N1) скрыты для pilot (`scopePath` синтетически указывает на `/trade-in/api/v1/admin/*` для filter logic). - `src/app/layout.tsx` — wrap children в ``. Security verification (code-reviewer LGTM): - pilot → /admin/* blocked: ✅ - ghost (403) → ничего не видно: ✅ - 401 fallback (dev) — safe (prod Caddy basic_auth гарантирует header). UX nit: pilot direct URL `/trade-in/scrapers/avito` (UI page) не блочен RouteGuard'ом — yaml allows `/trade-in/**`. Но data fetches к `/trade-in/api/v1/admin/*` будут 403, страница покажет пустое состояние. Follow-up: добавить `/trade-in/scrapers/**` в `pilot.deny` если нужен hard-block UI (out-of-scope этого PR). Tests: 14 files, +834/-91. `npm run type-check` нужно прогнать в CI после merge (node_modules не доступны локально). --- frontend/src/app/layout.tsx | 6 +- frontend/src/app/page.tsx | 86 +------------- .../src/components/auth/NoAccessScreen.tsx | 106 ++++++++++++++++++ frontend/src/components/auth/RouteGuard.tsx | 68 +++++++++++ frontend/src/components/landing/TopNav.tsx | 101 +++++++++++++++++ .../src/lib/__tests__/isPathAllowed.test.ts | 90 +++++++++++++++ frontend/src/lib/isPathAllowed.ts | 79 +++++++++++++ frontend/src/lib/useMe.ts | 63 +++++++++++ tradein-mvp/frontend/src/app/layout.tsx | 6 +- .../src/components/auth/NoAccessScreen.tsx | 85 ++++++++++++++ .../src/components/auth/RouteGuard.tsx | 58 ++++++++++ .../src/components/trade-in/Topbar.tsx | 71 ++++++++++-- tradein-mvp/frontend/src/lib/isPathAllowed.ts | 57 ++++++++++ tradein-mvp/frontend/src/lib/useMe.ts | 49 ++++++++ 14 files changed, 834 insertions(+), 91 deletions(-) create mode 100644 frontend/src/components/auth/NoAccessScreen.tsx create mode 100644 frontend/src/components/auth/RouteGuard.tsx create mode 100644 frontend/src/components/landing/TopNav.tsx create mode 100644 frontend/src/lib/__tests__/isPathAllowed.test.ts create mode 100644 frontend/src/lib/isPathAllowed.ts create mode 100644 frontend/src/lib/useMe.ts create mode 100644 tradein-mvp/frontend/src/components/auth/NoAccessScreen.tsx create mode 100644 tradein-mvp/frontend/src/components/auth/RouteGuard.tsx create mode 100644 tradein-mvp/frontend/src/lib/isPathAllowed.ts create mode 100644 tradein-mvp/frontend/src/lib/useMe.ts diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 17323396..a8ec550b 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -1,5 +1,7 @@ import type { Metadata } from "next"; +import { RouteGuard } from "@/components/auth/RouteGuard"; + import "./globals.css"; import { Providers } from "./providers"; @@ -16,7 +18,9 @@ export default function RootLayout({ return ( - {children} + + {children} + ); diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 2d3be18d..37e65914 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -2,8 +2,6 @@ import { useState } from "react"; -import Link from "next/link"; - import { BarChart3, Building2, @@ -15,6 +13,7 @@ import { } from "lucide-react"; import { PilotRequestModal } from "@/components/landing/PilotRequestModal"; +import { TopNav } from "@/components/landing/TopNav"; import { HeadlineBar } from "@/components/ui/HeadlineBar"; import { useLandingStats } from "@/lib/api/landing"; @@ -145,82 +144,8 @@ export default function HomePage() { }} > {/* ── Nav ────────────────────────────────────────────────────────── */} - + } + /> + {/* ── Hero ───────────────────────────────────────────────────────── */}
+
+
+ +
+

+ {title} +

+

+ {subtitle} +

+ {variant === "path" && path ? ( +

+ {path} +

+ ) : null} +
+ + ); +} diff --git a/frontend/src/components/auth/RouteGuard.tsx b/frontend/src/components/auth/RouteGuard.tsx new file mode 100644 index 00000000..a4891e04 --- /dev/null +++ b/frontend/src/components/auth/RouteGuard.tsx @@ -0,0 +1,68 @@ +"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)) { + return ; + } + + return <>{children}; +} diff --git a/frontend/src/components/landing/TopNav.tsx b/frontend/src/components/landing/TopNav.tsx new file mode 100644 index 00000000..070a9f13 --- /dev/null +++ b/frontend/src/components/landing/TopNav.tsx @@ -0,0 +1,101 @@ +"use client"; + +/** + * TopNav — top-level навигация на landing (gendsgn.ru/). + * + * Айтемы фильтруются через `isPathAllowed(allowed, deny, href)` по scope + * текущего юзера из `/api/v1/me`. Источник правды — `auth/roles.yaml`: + * - admin: видит всё + * - pilot: «Админ» (`/admin/**`) скрыт, остальное видно + * - 401 (dev без Caddy): пропускаем фильтрацию, показываем всё + * - 403 (юзер не в roles) — этот компонент не дойдёт до рендера: RouteGuard + * раньше перехватит и покажет NoAccessScreen на весь экран. + * + * Кнопка «Запросить пилот» — public CTA, не фильтруется. + */ + +import Link from "next/link"; + +import { HTTPError } from "@/lib/api"; +import { isPathAllowed } from "@/lib/isPathAllowed"; +import { useMe } from "@/lib/useMe"; + +interface NavItem { + href: string; + label: string; +} + +const NAV_ITEMS: NavItem[] = [ + { href: "/analytics", label: "Аналитика" }, + { href: "/site-finder", label: "Site Finder" }, + { href: "/trade-in/", label: "Trade-In" }, + { href: "/concept", label: "Концепция" }, + { href: "/admin/scrape/all", label: "Админ" }, +]; + +interface TopNavProps { + /** Slot справа от ссылок — обычно «Запросить пилот» CTA. */ + rightSlot?: React.ReactNode; +} + +export function TopNav({ rightSlot }: TopNavProps) { + const { data, error } = useMe(); + + // Dev (без Caddy) — 401, показываем все айтемы. В проде Caddy basic_auth + // всегда ставит header → 401 невозможен. + // Если /me ещё грузится или вернул ошибку без scope — показываем nav без + // фильтрации (RouteGuard уже перехватит 403 на весь экран, так что в + // реальности этот fallback срабатывает только в dev/loading). + const isDev401 = error instanceof HTTPError && error.status === 401; + const items = + data && !isDev401 + ? NAV_ITEMS.filter((item) => + isPathAllowed(data.allowed_paths, data.deny_paths, item.href), + ) + : NAV_ITEMS; + + return ( + + ); +} diff --git a/frontend/src/lib/__tests__/isPathAllowed.test.ts b/frontend/src/lib/__tests__/isPathAllowed.test.ts new file mode 100644 index 00000000..e0ce41be --- /dev/null +++ b/frontend/src/lib/__tests__/isPathAllowed.test.ts @@ -0,0 +1,90 @@ +/** + * Parity-tests с backend `tests/test_rbac.py::test_is_path_allowed_*`. + * + * Если кто-то меняет глоб-семантику backend'а — должен синхронно поменять JS + * порт здесь, иначе UI начнёт врать про доступы (показывать ссылки, которые + * backend заблокирует, или наоборот скрывать разрешённые). + * + * Pairs мирорят `auth/roles.yaml`: + * admin: paths=["/**"] deny=[] + * pilot: paths=["/", "/analytics/**", "/site-finder/**", + * "/trade-in/**", "/concept/**", "/api/v1/**", + * "/trade-in/api/v1/**"] + * deny=["/admin/**", "/api/v1/admin/**", + * "/trade-in/api/v1/admin/**"] + */ +import { isPathAllowed } from "../isPathAllowed"; + +const ADMIN_PATHS = ["/**"] as const; +const ADMIN_DENY = [] as const; + +const PILOT_PATHS = [ + "/", + "/analytics/**", + "/site-finder/**", + "/trade-in/**", + "/concept/**", + "/api/v1/**", + "/trade-in/api/v1/**", +] as const; +const PILOT_DENY = [ + "/admin/**", + "/api/v1/admin/**", + "/trade-in/api/v1/admin/**", +] as const; + +describe("isPathAllowed — admin everywhere", () => { + const cases: string[] = [ + "/", + "/admin", + "/admin/scrape/runs", + "/api/v1/admin/scrape/status", + "/trade-in/api/v1/admin/scrape", + "/concept/123", + "/analytics/dashboard", + ]; + it.each(cases)("allows %s", (path) => { + expect(isPathAllowed(ADMIN_PATHS, ADMIN_DENY, path)).toBe(true); + }); +}); + +describe("isPathAllowed — pilot allowed paths", () => { + const allowed: string[] = [ + "/", + "/analytics/dashboard", + "/site-finder/123", + "/trade-in", + "/trade-in/123", + "/concept/abc", + "/api/v1/parcels/123", + "/trade-in/api/v1/search", + ]; + it.each(allowed)("allows %s", (path) => { + expect(isPathAllowed(PILOT_PATHS, PILOT_DENY, path)).toBe(true); + }); +}); + +describe("isPathAllowed — pilot denied paths", () => { + const denied: string[] = [ + "/admin", + "/admin/jobs", + "/admin/scrape/runs/42", + "/api/v1/admin/scrape/status", + "/api/v1/admin/jobs", + "/trade-in/api/v1/admin/scrape", + ]; + it.each(denied)("denies %s", (path) => { + expect(isPathAllowed(PILOT_PATHS, PILOT_DENY, path)).toBe(false); + }); +}); + +describe("isPathAllowed — out-of-scope paths", () => { + it("denies path not in allowed list", () => { + expect(isPathAllowed(PILOT_PATHS, PILOT_DENY, "/unknown")).toBe(false); + }); + it("denies path with prefix-only match (no `/foo/**` rule)", () => { + // Pilot не имеет `/concept` (только `/concept/**`); но `/concept/**` после + // подстановки `(?:/.*)?` матчит и bare `/concept` — backend ведёт себя так же. + expect(isPathAllowed(PILOT_PATHS, PILOT_DENY, "/concept")).toBe(true); + }); +}); diff --git a/frontend/src/lib/isPathAllowed.ts b/frontend/src/lib/isPathAllowed.ts new file mode 100644 index 00000000..69c9a689 --- /dev/null +++ b/frontend/src/lib/isPathAllowed.ts @@ -0,0 +1,79 @@ +/** + * JS-порт `backend/app/core/auth.py::is_path_allowed`. + * + * Backend — source of truth: глобы из `auth/roles.yaml` парсятся в regex + * по тем же правилам что и здесь. Фронт использует это только для UI-gating + * (фильтр top-nav, route-guard перед навигацией) — реальная проверка + * по-прежнему происходит в backend middleware на каждый /api/v1/* request. + * + * Семантика glob (mirror of `_glob_to_regex`): + * `/**` → matches everything (admin scope) + * `/foo/**` → matches `/foo`, `/foo/`, `/foo/bar`, `/foo/bar/baz`, … + * `/foo/*` → matches one segment after `/foo/` (no slashes) + * `/foo` → exact match only + * + * Path allowed iff (a) matches >=1 allow-pattern AND (b) doesn't match any + * deny-pattern. Deny final (admin uses empty deny → всегда побеждает). + */ + +const REGEX_META = /[.+^${}()|[\]\\]/g; + +function escapeRegex(s: string): string { + return s.replace(REGEX_META, "\\$&"); +} + +function globToRegex(pattern: string): RegExp { + // Sentinels — никогда не встречаются в реальном path или в glob-метасимволах. + const dstar = "\x00DSTAR\x00"; + const sstar = "\x00SSTAR\x00"; + + const work = pattern.replace(/\*\*/g, dstar).replace(/\*/g, sstar); + let regex = escapeRegex(work); + regex = regex.replace(new RegExp(escapeRegex(dstar), "g"), ".*"); + regex = regex.replace(new RegExp(escapeRegex(sstar), "g"), "[^/]*"); + + // Special-case `/foo/**` → также матчит `/foo` (без trailing-сегмента), + // чтобы `/admin/**` покрывал bare `/admin` request — что и ждут callers. + if (regex.endsWith("/.*")) { + regex = regex.slice(0, -"/.*".length) + "(?:/.*)?"; + } + return new RegExp(`^${regex}$`); +} + +// Кэш скомпилированных regex — UI часто гоняет isPathAllowed на каждый рендер +// nav. patterns массив маленький и стабильный per-session, поэтому Map по +// pattern-строке достаточно. +const REGEX_CACHE = new Map(); + +function compileGlob(pattern: string): RegExp { + const cached = REGEX_CACHE.get(pattern); + if (cached) return cached; + const compiled = globToRegex(pattern); + REGEX_CACHE.set(pattern, compiled); + return compiled; +} + +/** + * Mirror of backend `is_path_allowed(role, path)`. + * + * @param allowedPaths — глобы из `/api/v1/me` `allowed_paths` + * @param denyPaths — глобы из `/api/v1/me` `deny_paths` + * @param path — путь который пытаемся открыть (например `/admin/scrape`) + * + * Note: role-аргумент не нужен — allow/deny уже резолвлены backend'ом для + * текущего юзера и приехали в /me. Этот хелпер чистый list-matcher. + */ +export function isPathAllowed( + allowedPaths: readonly string[], + denyPaths: readonly string[], + path: string, +): boolean { + // Deny overrides allow — даже если path в allowed, deny-match блокирует. + for (const p of denyPaths) { + if (compileGlob(p).test(path)) return false; + } + for (const p of allowedPaths) { + if (compileGlob(p).test(path)) return true; + } + return false; +} diff --git a/frontend/src/lib/useMe.ts b/frontend/src/lib/useMe.ts new file mode 100644 index 00000000..951edb04 --- /dev/null +++ b/frontend/src/lib/useMe.ts @@ -0,0 +1,63 @@ +"use client"; + +/** + * `useMe()` — TanStack-хук для GET /api/v1/me. + * + * Backend возвращает `UserScope` (см. `backend/app/core/auth.py`): + * { username, role: "admin"|"pilot", allowed_paths: string[], deny_paths: string[] } + * + * Failure modes: + * - 401 — нет X-Authenticated-User (локально без Caddy basic_auth). Фронт + * трактует это как «dev-режим» и в этом случае route-guard ничего не + * блокирует (см. RouteGuard). + * - 403 — header есть, но юзер не в `auth/roles.yaml`. Фронт показывает + * полноэкранный NoAccessScreen и больше ничего. + * + * Кешируем «вечно» (`staleTime: Infinity`, без рефетча): роль не меняется + * в рамках сессии. Если basic_auth обновится — юзер перезагрузит страницу. + */ + +import { useQuery } from "@tanstack/react-query"; + +import { apiFetchWithStatus, HTTPError } from "@/lib/api"; + +// Error type для useQuery: либо `HTTPError` (от apiFetchWithStatus при 4xx/5xx), +// либо обычный `Error` (network fail, fetch reject). RouteGuard / TopNav проверяют +// статус через `error instanceof HTTPError` — это работает в runtime независимо +// от type-level decl. + +export type Role = "admin" | "pilot"; + +export interface UserScope { + username: string; + role: Role; + allowed_paths: string[]; + deny_paths: string[]; +} + +export const ME_QUERY_KEY = ["auth", "me"] as const; + +async function fetchMe(): Promise { + const { body } = await apiFetchWithStatus("/api/v1/me"); + return body; +} + +export function useMe() { + return useQuery({ + queryKey: ME_QUERY_KEY, + queryFn: fetchMe, + staleTime: Infinity, + gcTime: Infinity, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + refetchOnMount: false, + // 401 (dev без Caddy) и 403 (unknown user) — финальные состояния, + // ретраить бесполезно. Ретраим только сетевые сбои. + retry: (failureCount, error) => { + if (error instanceof HTTPError && (error.status === 401 || error.status === 403)) { + return false; + } + return failureCount < 2; + }, + }); +} diff --git a/tradein-mvp/frontend/src/app/layout.tsx b/tradein-mvp/frontend/src/app/layout.tsx index 50e80e5d..0a050d91 100644 --- a/tradein-mvp/frontend/src/app/layout.tsx +++ b/tradein-mvp/frontend/src/app/layout.tsx @@ -1,5 +1,7 @@ import type { Metadata } from "next"; +import { RouteGuard } from "@/components/auth/RouteGuard"; + import "./globals.css"; import { Providers } from "./providers"; @@ -16,7 +18,9 @@ export default function RootLayout({ return ( - {children} + + {children} + ); diff --git a/tradein-mvp/frontend/src/components/auth/NoAccessScreen.tsx b/tradein-mvp/frontend/src/components/auth/NoAccessScreen.tsx new file mode 100644 index 00000000..3afffb4f --- /dev/null +++ b/tradein-mvp/frontend/src/components/auth/NoAccessScreen.tsx @@ -0,0 +1,85 @@ +"use client"; + +/** + * MIRROR of main frontend `frontend/src/components/auth/NoAccessScreen.tsx` + * — keep in sync manually. Tradein-mvp бандлится отдельно (basePath=/trade-in), + * нет общего workspace package. + * + * Fullscreen «доступа нет» — для 403 от /me или для denied path. + * Token-based styling per `.claude/rules/ui-tokens.md` (см. globals.css). + */ + +interface NoAccessScreenProps { + variant: "user" | "path"; + path?: string; +} + +export function NoAccessScreen({ variant, path }: NoAccessScreenProps) { + const subtitle = + variant === "user" + ? "Учётная запись не привязана к роли. Обратитесь к администратору GenDesign — kopylov." + : "У вашей роли нет доступа к этому разделу. Вернитесь на главную или обратитесь к администратору."; + + return ( +
+
+

+ Доступа нет +

+

+ {subtitle} +

+ {variant === "path" && path ? ( +

+ {path} +

+ ) : null} +
+
+ ); +} diff --git a/tradein-mvp/frontend/src/components/auth/RouteGuard.tsx b/tradein-mvp/frontend/src/components/auth/RouteGuard.tsx new file mode 100644 index 00000000..5756c3da --- /dev/null +++ b/tradein-mvp/frontend/src/components/auth/RouteGuard.tsx @@ -0,0 +1,58 @@ +"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 ?? ""; + +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(); + + if (isLoading) return null; + + if (error instanceof HTTPError && error.status === 401) { + return <>{children}; + } + + if (error instanceof HTTPError && error.status === 403) { + return ; + } + + if (error) { + return ; + } + + if (!data) return null; + + if (!isPathAllowed(data.allowed_paths, data.deny_paths, absolutePath)) { + return ; + } + + return <>{children}; +} diff --git a/tradein-mvp/frontend/src/components/trade-in/Topbar.tsx b/tradein-mvp/frontend/src/components/trade-in/Topbar.tsx index 0905cc93..f44f1362 100644 --- a/tradein-mvp/frontend/src/components/trade-in/Topbar.tsx +++ b/tradein-mvp/frontend/src/components/trade-in/Topbar.tsx @@ -1,6 +1,8 @@ "use client"; -import { API_BASE_URL } from "@/lib/api"; +import { API_BASE_URL, HTTPError } from "@/lib/api"; +import { isPathAllowed } from "@/lib/isPathAllowed"; +import { useMe } from "@/lib/useMe"; type ActiveTab = | "estimate" @@ -15,21 +17,72 @@ interface TopbarProps { active: ActiveTab; } -const NAV_ITEMS: Array<{ key: ActiveTab; href: string; label: string }> = [ - { key: "estimate", href: "/", label: "Оценка" }, - { key: "history", href: "/history", label: "История" }, - { key: "cache", href: "/cache", label: "Кэш" }, - { key: "avito", href: "/scrapers/avito", label: "Авито" }, - { key: "cian", href: "/scrapers/cian", label: "Циан" }, - { key: "yandex", href: "/scrapers/yandex", label: "Яндекс" }, +/** + * Nav items с двумя путями: + * `href` — относительный путь, который рендерится в URL (basePath + * `/trade-in` префиксится через `API_BASE_URL`). + * `scopePath` — абсолютный путь как его видит backend RBAC config + * (`auth/roles.yaml`). + * + * Caveat / UX-opinion: pages вида `/trade-in/scrapers/*` сами по себе НЕ + * в `pilot.deny` (там только `/trade-in/api/v1/admin/**`). По букве yaml + * pilot имеет право видеть и UI скраперов — но это admin-функционал, и + * рендерить «битый» dashboard с 403-ошибками на каждый data-fetch — плохой + * UX. Поэтому для фильтрации Topbar мы маппим scraper-таб на admin-API + * путь, чтобы pilot их не видел в навигации. Direct URL access на + * `/trade-in/scrapers/avito` НЕ блокируется (RouteGuard следует yaml). Если + * нужна полная блокировка — добавить `/trade-in/scrapers/**` в pilot.deny. + */ +const NAV_ITEMS: Array<{ + key: ActiveTab; + href: string; + scopePath: string; + label: string; +}> = [ + { key: "estimate", href: "/", scopePath: "/trade-in/", label: "Оценка" }, + { key: "history", href: "/history", scopePath: "/trade-in/history", label: "История" }, + { key: "cache", href: "/cache", scopePath: "/trade-in/cache", label: "Кэш" }, + // Скраперы — admin-only UI. Маппим на admin-deny path, чтобы pilot их не видел. + { + key: "avito", + href: "/scrapers/avito", + scopePath: "/trade-in/api/v1/admin/scrapers/avito", + label: "Авито", + }, + { + key: "cian", + href: "/scrapers/cian", + scopePath: "/trade-in/api/v1/admin/scrapers/cian", + label: "Циан", + }, + { + key: "yandex", + href: "/scrapers/yandex", + scopePath: "/trade-in/api/v1/admin/scrapers/yandex", + label: "Яндекс", + }, { key: "cian-cookies", href: "/scrapers/cian-cookies", + scopePath: "/trade-in/api/v1/admin/scrapers/cian-cookies", label: "Cian Cookies", }, ]; export function Topbar({ active }: TopbarProps) { + const { data, error } = useMe(); + + // 401 (dev без Caddy basic_auth) — показываем все айтемы. + // Если /me ещё грузится или дал ошибку без scope — fallback на все айтемы + // (RouteGuard в этом случае сам решит, рендерить ли страницу вообще). + const isDev401 = error instanceof HTTPError && error.status === 401; + const items = + data && !isDev401 + ? NAV_ITEMS.filter((item) => + isPathAllowed(data.allowed_paths, data.deny_paths, item.scopePath), + ) + : NAV_ITEMS; + return (
@@ -38,7 +91,7 @@ export function Topbar({ active }: TopbarProps) { Trade-In