gendesign/frontend/src/lib/useMe.ts
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

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