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 не доступны локально).
107 lines
3.8 KiB
TypeScript
107 lines
3.8 KiB
TypeScript
"use client";
|
||
|
||
import { API_BASE_URL, HTTPError } from "@/lib/api";
|
||
import { isPathAllowed } from "@/lib/isPathAllowed";
|
||
import { useMe } from "@/lib/useMe";
|
||
|
||
type ActiveTab =
|
||
| "estimate"
|
||
| "history"
|
||
| "cache"
|
||
| "avito"
|
||
| "cian"
|
||
| "yandex"
|
||
| "cian-cookies";
|
||
|
||
interface TopbarProps {
|
||
active: ActiveTab;
|
||
}
|
||
|
||
/**
|
||
* 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">
|
||
<div className="brand">
|
||
<span className="brand-mark">TI</span>
|
||
<span className="brand-product">Trade-In</span>
|
||
</div>
|
||
<nav className="top-nav">
|
||
{items.map((item) => (
|
||
<a
|
||
key={item.key}
|
||
href={`${API_BASE_URL}${item.href}`}
|
||
className={item.key === active ? "is-active" : undefined}
|
||
>
|
||
{item.label}
|
||
</a>
|
||
))}
|
||
</nav>
|
||
</div>
|
||
</header>
|
||
);
|
||
}
|