feat(rbac): frontend route-guard + nav filter (PR B — frontend) #586

Merged
Light1YT merged 1 commit from feat/rbac-prb-frontend into main 2026-05-26 06:51:45 +00:00
14 changed files with 834 additions and 91 deletions

View file

@ -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 (
<html lang="ru">
<body>
<Providers>{children}</Providers>
<Providers>
<RouteGuard>{children}</RouteGuard>
</Providers>
</body>
</html>
);

View file

@ -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 ────────────────────────────────────────────────────────── */}
<nav
style={{
background: "var(--bg-card)",
borderBottom: "1px solid var(--border-card)",
padding: "0 24px",
height: 56,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
position: "sticky",
top: 0,
zIndex: 100,
}}
>
<Link
href="/"
style={{
fontWeight: 700,
fontSize: 17,
color: "var(--fg-primary)",
textDecoration: "none",
}}
>
GenDesign
</Link>
<div style={{ display: "flex", gap: 24, alignItems: "center" }}>
<Link
href="/analytics"
style={{
fontSize: 14,
color: "var(--fg-secondary)",
textDecoration: "none",
}}
>
Аналитика
</Link>
<Link
href="/site-finder"
style={{
fontSize: 14,
color: "var(--fg-secondary)",
textDecoration: "none",
}}
>
Site Finder
</Link>
<Link
href="/trade-in/"
style={{
fontSize: 14,
color: "var(--fg-secondary)",
textDecoration: "none",
}}
>
Trade-In
</Link>
<Link
href="/concept"
style={{
fontSize: 14,
color: "var(--fg-secondary)",
textDecoration: "none",
}}
>
Концепция
</Link>
<Link
href="/admin/scrape/all"
style={{
fontSize: 14,
color: "var(--fg-secondary)",
textDecoration: "none",
}}
>
Админ
</Link>
<TopNav
rightSlot={
<button
onClick={() => setModalOpen(true)}
style={{
@ -236,8 +161,9 @@ export default function HomePage() {
>
Запросить пилот
</button>
</div>
</nav>
}
/>
{/* ── Hero ───────────────────────────────────────────────────────── */}
<section

View file

@ -0,0 +1,106 @@
"use client";
/**
* Полноэкранный «доступа нет» показывается в двух случаях:
* 1. /api/v1/me вернул 403 (юзер аутентифицирован Caddy basic_auth,
* но его нет в `auth/roles.yaml`). Variant: "user".
* 2. Юзер открыл URL, которого нет в его allowed_paths (или есть в
* deny_paths). Variant: "path".
*
* Tone-of-voice (см. `.claude/rules/ui-microcopy.md`): нейтральный business,
* без эмодзи, без «Ой!». Просто констатация.
*/
import { Lock } from "lucide-react";
interface NoAccessScreenProps {
/** "user" — юзер вне ролей; "path" — путь вне scope */
variant: "user" | "path";
/** Текущий путь — используется только для variant="path" в caption */
path?: string;
}
export function NoAccessScreen({ variant, path }: NoAccessScreenProps) {
const title = "Доступа нет";
const subtitle =
variant === "user"
? "Учётная запись не привязана к роли. Обратитесь к администратору GenDesign — kopylov."
: "У вашей роли нет доступа к этому разделу. Вернитесь на главную или обратитесь к администратору.";
return (
<main
role="alert"
aria-live="polite"
style={{
minHeight: "100vh",
background: "var(--bg-app)",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: 24,
fontFamily: "Inter, -apple-system, 'Segoe UI', system-ui, sans-serif",
}}
>
<div
style={{
background: "var(--bg-card)",
border: "1px solid var(--border-card)",
borderRadius: 12,
padding: "32px 28px",
maxWidth: 480,
width: "100%",
textAlign: "left",
}}
>
<div
style={{
width: 40,
height: 40,
background: "var(--accent-soft)",
borderRadius: 8,
display: "flex",
alignItems: "center",
justifyContent: "center",
marginBottom: 16,
}}
>
<Lock size={20} strokeWidth={1.5} color="var(--accent)" />
</div>
<h1
style={{
margin: "0 0 8px",
fontSize: 22,
fontWeight: 600,
color: "var(--fg-primary)",
lineHeight: 1.25,
}}
>
{title}
</h1>
<p
style={{
margin: 0,
fontSize: 14,
color: "var(--fg-secondary)",
lineHeight: 1.6,
}}
>
{subtitle}
</p>
{variant === "path" && path ? (
<p
style={{
margin: "12px 0 0",
fontSize: 12,
color: "var(--fg-tertiary)",
fontFamily: "ui-monospace, SFMono-Regular, monospace",
wordBreak: "break-all",
}}
>
{path}
</p>
) : null}
</div>
</main>
);
}

View file

@ -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 <NoAccessScreen variant="user" />;
}
// Другая сетевая ошибка — fail-open в dev / показываем NoAccess в prod-like.
// Безопаснее закрыть UI чем случайно пустить юзера на admin при transient
// network fail. NoAccessScreen("user") универсальный fallback.
if (error) {
return <NoAccessScreen variant="user" />;
}
// Нет 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 <NoAccessScreen variant="path" path={pathname} />;
}
return <>{children}</>;
}

View file

@ -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 (
<nav
style={{
background: "var(--bg-card)",
borderBottom: "1px solid var(--border-card)",
padding: "0 24px",
height: 56,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
position: "sticky",
top: 0,
zIndex: 100,
}}
>
<Link
href="/"
style={{
fontWeight: 700,
fontSize: 17,
color: "var(--fg-primary)",
textDecoration: "none",
}}
>
GenDesign
</Link>
<div style={{ display: "flex", gap: 24, alignItems: "center" }}>
{items.map((item) => (
<Link
key={item.href}
href={item.href}
style={{
fontSize: 14,
color: "var(--fg-secondary)",
textDecoration: "none",
}}
>
{item.label}
</Link>
))}
{rightSlot}
</div>
</nav>
);
}

View file

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

View file

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

63
frontend/src/lib/useMe.ts Normal file
View file

@ -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<UserScope> {
const { body } = await apiFetchWithStatus<UserScope>("/api/v1/me");
return body;
}
export function useMe() {
return useQuery<UserScope, Error>({
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;
},
});
}

View file

@ -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 (
<html lang="ru">
<body>
<Providers>{children}</Providers>
<Providers>
<RouteGuard>{children}</RouteGuard>
</Providers>
</body>
</html>
);

View file

@ -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 (
<main
role="alert"
aria-live="polite"
style={{
minHeight: "100vh",
background: "var(--bg-app)",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: 24,
fontFamily:
"Inter, -apple-system, 'Segoe UI', system-ui, sans-serif",
}}
>
<div
style={{
background: "var(--bg-card)",
border: "1px solid var(--border-card)",
borderRadius: 12,
padding: "32px 28px",
maxWidth: 480,
width: "100%",
}}
>
<h1
style={{
margin: "0 0 8px",
fontSize: 22,
fontWeight: 600,
color: "var(--fg-primary)",
lineHeight: 1.25,
}}
>
Доступа нет
</h1>
<p
style={{
margin: 0,
fontSize: 14,
color: "var(--fg-secondary)",
lineHeight: 1.6,
}}
>
{subtitle}
</p>
{variant === "path" && path ? (
<p
style={{
margin: "12px 0 0",
fontSize: 12,
color: "var(--fg-tertiary)",
fontFamily: "ui-monospace, SFMono-Regular, monospace",
wordBreak: "break-all",
}}
>
{path}
</p>
) : null}
</div>
</main>
);
}

View file

@ -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 <NoAccessScreen variant="user" />;
}
if (error) {
return <NoAccessScreen variant="user" />;
}
if (!data) return null;
if (!isPathAllowed(data.allowed_paths, data.deny_paths, absolutePath)) {
return <NoAccessScreen variant="path" path={absolutePath} />;
}
return <>{children}</>;
}

View file

@ -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 (
<header className="topbar">
<div className="topbar-inner">
@ -38,7 +91,7 @@ export function Topbar({ active }: TopbarProps) {
<span className="brand-product">Trade-In</span>
</div>
<nav className="top-nav">
{NAV_ITEMS.map((item) => (
{items.map((item) => (
<a
key={item.key}
href={`${API_BASE_URL}${item.href}`}

View file

@ -0,0 +1,57 @@
/**
* MIRROR of main frontend `frontend/src/lib/isPathAllowed.ts` keep in sync
* manually. Rationale: tradein-mvp отдельный Next.js bundle с basePath=/trade-in,
* нет общего workspace package, поэтому дублируем (как делают backend mirrors
* `backend/app/core/auth.py` `tradein-mvp/backend/app/core/auth.py`).
*
* JS-порт `backend/app/core/auth.py::is_path_allowed`. Семантика glob:
* `/**` matches everything (admin scope)
* `/foo/**` matches `/foo`, `/foo/`, `/foo/bar`,
* `/foo/*` один сегмент после `/foo/`
* `/foo` exact match
*/
const REGEX_META = /[.+^${}()|[\]\\]/g;
function escapeRegex(s: string): string {
return s.replace(REGEX_META, "\\$&");
}
function globToRegex(pattern: string): RegExp {
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"), "[^/]*");
if (regex.endsWith("/.*")) {
regex = regex.slice(0, -"/.*".length) + "(?:/.*)?";
}
return new RegExp(`^${regex}$`);
}
const REGEX_CACHE = new Map<string, RegExp>();
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;
}
export function isPathAllowed(
allowedPaths: readonly string[],
denyPaths: readonly string[],
path: string,
): boolean {
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;
}

View file

@ -0,0 +1,49 @@
"use client";
/**
* MIRROR of main frontend `frontend/src/lib/useMe.ts` keep in sync manually.
*
* Tradein-mvp отдельный Next.js bundle с basePath=/trade-in. `apiFetch`
* автоматически префиксит `API_BASE_URL` (= `/trade-in` в проде, "" в dev),
* поэтому путь `/api/v1/me` в проде превращается в `/trade-in/api/v1/me`
* Caddy `uri strip_prefix /trade-in` tradein-backend `/api/v1/me`.
* Tradein-backend имеет mirror endpoint `/me` (см. `tradein-mvp/backend/app/api/v1/me.py`).
*/
import { useQuery } from "@tanstack/react-query";
import { apiFetchWithStatus, HTTPError } from "@/lib/api";
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<UserScope> {
const { body } = await apiFetchWithStatus<UserScope>("/api/v1/me");
return body;
}
export function useMe() {
return useQuery<UserScope, Error>({
queryKey: ME_QUERY_KEY,
queryFn: fetchMe,
staleTime: Infinity,
gcTime: Infinity,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: false,
retry: (failureCount, error) => {
if (error instanceof HTTPError && (error.status === 401 || error.status === 403)) {
return false;
}
return failureCount < 2;
},
});
}