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