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 не доступны локально).
79 lines
3.3 KiB
TypeScript
79 lines
3.3 KiB
TypeScript
/**
|
|
* JS-порт `backend/app/core/auth.py::is_path_allowed`.
|
|
*
|
|
* Backend — source of truth: глобы из `auth/roles.yaml` парсятся в regex
|
|
* по тем же правилам что и здесь. Фронт использует это только для UI-gating
|
|
* (фильтр top-nav, route-guard перед навигацией) — реальная проверка
|
|
* по-прежнему происходит в backend middleware на каждый /api/v1/* request.
|
|
*
|
|
* Семантика glob (mirror of `_glob_to_regex`):
|
|
* `/**` → matches everything (admin scope)
|
|
* `/foo/**` → matches `/foo`, `/foo/`, `/foo/bar`, `/foo/bar/baz`, …
|
|
* `/foo/*` → matches one segment after `/foo/` (no slashes)
|
|
* `/foo` → exact match only
|
|
*
|
|
* Path allowed iff (a) matches >=1 allow-pattern AND (b) doesn't match any
|
|
* deny-pattern. Deny final (admin uses empty deny → всегда побеждает).
|
|
*/
|
|
|
|
const REGEX_META = /[.+^${}()|[\]\\]/g;
|
|
|
|
function escapeRegex(s: string): string {
|
|
return s.replace(REGEX_META, "\\$&");
|
|
}
|
|
|
|
function globToRegex(pattern: string): RegExp {
|
|
// Sentinels — никогда не встречаются в реальном path или в glob-метасимволах.
|
|
const dstar = "\x00DSTAR\x00";
|
|
const sstar = "\x00SSTAR\x00";
|
|
|
|
const work = pattern.replace(/\*\*/g, dstar).replace(/\*/g, sstar);
|
|
let regex = escapeRegex(work);
|
|
regex = regex.replace(new RegExp(escapeRegex(dstar), "g"), ".*");
|
|
regex = regex.replace(new RegExp(escapeRegex(sstar), "g"), "[^/]*");
|
|
|
|
// Special-case `/foo/**` → также матчит `/foo` (без trailing-сегмента),
|
|
// чтобы `/admin/**` покрывал bare `/admin` request — что и ждут callers.
|
|
if (regex.endsWith("/.*")) {
|
|
regex = regex.slice(0, -"/.*".length) + "(?:/.*)?";
|
|
}
|
|
return new RegExp(`^${regex}$`);
|
|
}
|
|
|
|
// Кэш скомпилированных regex — UI часто гоняет isPathAllowed на каждый рендер
|
|
// nav. patterns массив маленький и стабильный per-session, поэтому Map по
|
|
// pattern-строке достаточно.
|
|
const REGEX_CACHE = new Map<string, RegExp>();
|
|
|
|
function compileGlob(pattern: string): RegExp {
|
|
const cached = REGEX_CACHE.get(pattern);
|
|
if (cached) return cached;
|
|
const compiled = globToRegex(pattern);
|
|
REGEX_CACHE.set(pattern, compiled);
|
|
return compiled;
|
|
}
|
|
|
|
/**
|
|
* Mirror of backend `is_path_allowed(role, path)`.
|
|
*
|
|
* @param allowedPaths — глобы из `/api/v1/me` `allowed_paths`
|
|
* @param denyPaths — глобы из `/api/v1/me` `deny_paths`
|
|
* @param path — путь который пытаемся открыть (например `/admin/scrape`)
|
|
*
|
|
* Note: role-аргумент не нужен — allow/deny уже резолвлены backend'ом для
|
|
* текущего юзера и приехали в /me. Этот хелпер чистый list-matcher.
|
|
*/
|
|
export function isPathAllowed(
|
|
allowedPaths: readonly string[],
|
|
denyPaths: readonly string[],
|
|
path: string,
|
|
): boolean {
|
|
// Deny overrides allow — даже если path в allowed, deny-match блокирует.
|
|
for (const p of denyPaths) {
|
|
if (compileGlob(p).test(path)) return false;
|
|
}
|
|
for (const p of allowedPaths) {
|
|
if (compileGlob(p).test(path)) return true;
|
|
}
|
|
return false;
|
|
}
|