feat(rbac): frontend route-guard + nav filter (#586)
All checks were successful
Deploy / changes (push) Successful in 5s
Deploy Trade-In / changes (push) Successful in 5s
Deploy Trade-In / build-backend (push) Has been skipped
Deploy / build-backend (push) Has been skipped
Deploy / build-worker (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 1m42s
Deploy Trade-In / deploy (push) Successful in 39s
Deploy / build-frontend (push) Successful in 2m55s
Deploy / deploy (push) Successful in 1m1s
All checks were successful
Deploy / changes (push) Successful in 5s
Deploy Trade-In / changes (push) Successful in 5s
Deploy Trade-In / build-backend (push) Has been skipped
Deploy / build-backend (push) Has been skipped
Deploy / build-worker (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 1m42s
Deploy Trade-In / deploy (push) Successful in 39s
Deploy / build-frontend (push) Successful in 2m55s
Deploy / deploy (push) Successful in 1m1s
PR B (frontend) to backend RBAC #585. RouteGuard wraps app, NoAccessScreen on 403 from /me, TopNav filtered by role.
This commit is contained in:
parent
88cdfd6adb
commit
7a2b055b35
14 changed files with 834 additions and 91 deletions
|
|
@ -1,5 +1,7 @@
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
import { RouteGuard } from "@/components/auth/RouteGuard";
|
||||||
|
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { Providers } from "./providers";
|
import { Providers } from "./providers";
|
||||||
|
|
||||||
|
|
@ -16,7 +18,9 @@ export default function RootLayout({
|
||||||
return (
|
return (
|
||||||
<html lang="ru">
|
<html lang="ru">
|
||||||
<body>
|
<body>
|
||||||
<Providers>{children}</Providers>
|
<Providers>
|
||||||
|
<RouteGuard>{children}</RouteGuard>
|
||||||
|
</Providers>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,6 @@
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
BarChart3,
|
BarChart3,
|
||||||
Building2,
|
Building2,
|
||||||
|
|
@ -15,6 +13,7 @@ import {
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import { PilotRequestModal } from "@/components/landing/PilotRequestModal";
|
import { PilotRequestModal } from "@/components/landing/PilotRequestModal";
|
||||||
|
import { TopNav } from "@/components/landing/TopNav";
|
||||||
import { HeadlineBar } from "@/components/ui/HeadlineBar";
|
import { HeadlineBar } from "@/components/ui/HeadlineBar";
|
||||||
import { useLandingStats } from "@/lib/api/landing";
|
import { useLandingStats } from "@/lib/api/landing";
|
||||||
|
|
||||||
|
|
@ -145,82 +144,8 @@ export default function HomePage() {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* ── Nav ────────────────────────────────────────────────────────── */}
|
{/* ── Nav ────────────────────────────────────────────────────────── */}
|
||||||
<nav
|
<TopNav
|
||||||
style={{
|
rightSlot={
|
||||||
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>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setModalOpen(true)}
|
onClick={() => setModalOpen(true)}
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -236,8 +161,9 @@ export default function HomePage() {
|
||||||
>
|
>
|
||||||
Запросить пилот
|
Запросить пилот
|
||||||
</button>
|
</button>
|
||||||
</div>
|
}
|
||||||
</nav>
|
/>
|
||||||
|
|
||||||
|
|
||||||
{/* ── Hero ───────────────────────────────────────────────────────── */}
|
{/* ── Hero ───────────────────────────────────────────────────────── */}
|
||||||
<section
|
<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 type { Metadata } from "next";
|
||||||
|
|
||||||
|
import { RouteGuard } from "@/components/auth/RouteGuard";
|
||||||
|
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { Providers } from "./providers";
|
import { Providers } from "./providers";
|
||||||
|
|
||||||
|
|
@ -16,7 +18,9 @@ export default function RootLayout({
|
||||||
return (
|
return (
|
||||||
<html lang="ru">
|
<html lang="ru">
|
||||||
<body>
|
<body>
|
||||||
<Providers>{children}</Providers>
|
<Providers>
|
||||||
|
<RouteGuard>{children}</RouteGuard>
|
||||||
|
</Providers>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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";
|
"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 =
|
type ActiveTab =
|
||||||
| "estimate"
|
| "estimate"
|
||||||
|
|
@ -15,21 +17,72 @@ interface TopbarProps {
|
||||||
active: ActiveTab;
|
active: ActiveTab;
|
||||||
}
|
}
|
||||||
|
|
||||||
const NAV_ITEMS: Array<{ key: ActiveTab; href: string; label: string }> = [
|
/**
|
||||||
{ key: "estimate", href: "/", label: "Оценка" },
|
* Nav items с двумя путями:
|
||||||
{ key: "history", href: "/history", label: "История" },
|
* `href` — относительный путь, который рендерится в URL (basePath
|
||||||
{ key: "cache", href: "/cache", label: "Кэш" },
|
* `/trade-in` префиксится через `API_BASE_URL`).
|
||||||
{ key: "avito", href: "/scrapers/avito", label: "Авито" },
|
* `scopePath` — абсолютный путь как его видит backend RBAC config
|
||||||
{ key: "cian", href: "/scrapers/cian", label: "Циан" },
|
* (`auth/roles.yaml`).
|
||||||
{ key: "yandex", href: "/scrapers/yandex", label: "Яндекс" },
|
*
|
||||||
|
* 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",
|
key: "cian-cookies",
|
||||||
href: "/scrapers/cian-cookies",
|
href: "/scrapers/cian-cookies",
|
||||||
|
scopePath: "/trade-in/api/v1/admin/scrapers/cian-cookies",
|
||||||
label: "Cian Cookies",
|
label: "Cian Cookies",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export function Topbar({ active }: TopbarProps) {
|
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 (
|
return (
|
||||||
<header className="topbar">
|
<header className="topbar">
|
||||||
<div className="topbar-inner">
|
<div className="topbar-inner">
|
||||||
|
|
@ -38,7 +91,7 @@ export function Topbar({ active }: TopbarProps) {
|
||||||
<span className="brand-product">Trade-In</span>
|
<span className="brand-product">Trade-In</span>
|
||||||
</div>
|
</div>
|
||||||
<nav className="top-nav">
|
<nav className="top-nav">
|
||||||
{NAV_ITEMS.map((item) => (
|
{items.map((item) => (
|
||||||
<a
|
<a
|
||||||
key={item.key}
|
key={item.key}
|
||||||
href={`${API_BASE_URL}${item.href}`}
|
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