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 не доступны локально).
101 lines
3.3 KiB
TypeScript
101 lines
3.3 KiB
TypeScript
"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>
|
||
);
|
||
}
|