gendesign/tradein-mvp/frontend/src/components/trade-in/Topbar.tsx
Light1YT da8125e695 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 не доступны локально).
2026-05-26 11:42:03 +05:00

107 lines
3.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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