feat(rbac): frontend route-guard + nav filter for pilot/admin scopes
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 → `<NoAccessScreen variant="user">`. На denied path → `<NoAccessScreen variant="path">`. Loading state — `null` чтобы не было FOUC protected UI. 401 (dev без Caddy) — pass-through. - `src/components/auth/NoAccessScreen.tsx` — fullscreen «доступа нет» с lucide `<Lock>` иконкой. Token-based styling. - `src/components/landing/TopNav.tsx` — extracted nav из `page.tsx`. Фильтрует items через `isPathAllowed(scope, href)`. Pilot → нет «Админ». - `src/app/{layout.tsx,page.tsx}` — обернуть в `<RouteGuard>`, inline nav → `<TopNav rightSlot=…>`. 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 в `<RouteGuard>`. 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 не доступны локально).
This commit is contained in:
parent
88cdfd6adb
commit
da8125e695
14 changed files with 834 additions and 91 deletions
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
106
frontend/src/components/auth/NoAccessScreen.tsx
Normal file
106
frontend/src/components/auth/NoAccessScreen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
68
frontend/src/components/auth/RouteGuard.tsx
Normal file
68
frontend/src/components/auth/RouteGuard.tsx
Normal 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}</>;
|
||||
}
|
||||
101
frontend/src/components/landing/TopNav.tsx
Normal file
101
frontend/src/components/landing/TopNav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
90
frontend/src/lib/__tests__/isPathAllowed.test.ts
Normal file
90
frontend/src/lib/__tests__/isPathAllowed.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
79
frontend/src/lib/isPathAllowed.ts
Normal file
79
frontend/src/lib/isPathAllowed.ts
Normal 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
63
frontend/src/lib/useMe.ts
Normal 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;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
85
tradein-mvp/frontend/src/components/auth/NoAccessScreen.tsx
Normal file
85
tradein-mvp/frontend/src/components/auth/NoAccessScreen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
58
tradein-mvp/frontend/src/components/auth/RouteGuard.tsx
Normal file
58
tradein-mvp/frontend/src/components/auth/RouteGuard.tsx
Normal 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}</>;
|
||||
}
|
||||
|
|
@ -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}`}
|
||||
|
|
|
|||
57
tradein-mvp/frontend/src/lib/isPathAllowed.ts
Normal file
57
tradein-mvp/frontend/src/lib/isPathAllowed.ts
Normal 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;
|
||||
}
|
||||
49
tradein-mvp/frontend/src/lib/useMe.ts
Normal file
49
tradein-mvp/frontend/src/lib/useMe.ts
Normal 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;
|
||||
},
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue