gendesign/frontend/src/components/landing/TopNav.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

101 lines
3.3 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";
/**
* 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>
);
}