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 (