Compare commits

...
Sign in to create a new pull request.

12 commits

Author SHA1 Message Date
f6b68b6eca Merge pull request 'feat(tradein/caddy): снять basic_auth с /trade-in — своя авторизация (#2558)' (#2569) from feat/tradein-caddy-cutover into main
All checks were successful
Deploy / changes (push) Successful in 8s
Deploy / build-frontend (push) Successful in 35s
Deploy / build-backend (push) Successful in 36s
Deploy / build-worker (push) Successful in 36s
Deploy / deploy (push) Successful in 1m8s
2026-07-30 21:52:00 +00:00
bot-backend
2b24408c3f docs(tradein/caddy): исправить обоснование scrub + scrub в ui-preview (#2558)
All checks were successful
CI / changes (pull_request) Successful in 14s
CI Trade-In / changes (pull_request) Successful in 14s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
Deep-review PR #2569 (echo-стенд на caddy:2, caddy adapt): комментарий у
/trade-in/api* и @tradein утверждал, что старая Set-форма
(header_up X-Authenticated-User {http.auth.user.id}) пропустила бы клиентский
заголовок насквозь — измерено, что это неверно. Caddy подставляет
неразрешённый плейсхолдер как литеральную строку (ReplaceKnown), т.е. старая
форма была бы fail-closed-but-сломанной (get_role("{http.auth.user.id}") ->
403 для всех), а не bypass. Решение (-Field вместо Set) остаётся правильным
по другой причине: это единственная форма с документированной delete-
семантикой, не зависящая от трактовки пустого/нерезолвленного плейсхолдера.

Также: @uipreview (единственный блок в trade-in-периметре без scrub) теперь
тоже явно удаляет X-Authenticated-User - единообразие периметра, хоть
сейчас и инертно (статичная страница без секрета).

И: комментарий у @saleshare уточнён - снятие Caddy basic_auth с
/trade-in/sale-share не делает страницу публичной, она по-прежнему за
RouteGuard + session-required API (редирект на /login без валидной сессии).

Провалидировано: caddy validate --config Caddyfile --adapter caddyfile
(caddy:2, локальный docker) -> "Valid configuration".
2026-07-31 00:19:26 +03:00
69fd0ba260 Merge pull request 'fix(tradein/ui): вход в «Команду» из v2-навигации + понятный 401 (#2556)' (#2570) from fix/tradein-team-nav-and-401 into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 13s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m23s
Deploy Trade-In / deploy (push) Successful in 1m3s
2026-07-30 21:16:23 +00:00
bot-backend
a3628843c3 fix(tradein/ui): вход в «Команду» из v2-навигации + понятный 401 (#2556)
All checks were successful
CI / changes (pull_request) Successful in 10s
CI Trade-In / changes (pull_request) Successful in 10s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m11s
`/trade-in/` редиректит на `/trade-in/v2`, а v2-навигация (TopNav.tsx)
не знала про team-дашборд вообще — пункт «Команда» был только в legacy
Topbar.tsx (NAV_ITEMS), который на v2-страницах не рендерится. Дашборд
существовал, но был недостижим кликом.

- app/v2/page.tsx: showTeamNavItem — тот же двойной гейт, что и
  Topbar.NAV_ITEMS "team" (isPathAllowed(/api/v1/team) + роль
  admin/manager), передаётся в TopNav.
- v2/TopNav.tsx: пункт «Команда» в user-меню рядом с «Выйти» (не таб
  SectionOverlay — /team отдельный роут, а не секция текущей страницы).

Второй дефект: team-API требует session-cookie, легаси Caddy-роль
(/me 200) через него не проходит → GET /api/v1/team/employees отдаёт
401, UI показывал невнятную красную плашку. app/team/page.tsx теперь
ловит 401 отдельно от 403 и уводит на /login?next=/team (тот же
redirect-паттерн, что RouteGuard.tsx для 401 от /me; в dev — сообщение
с кнопкой «Войти» вместо авто-редиректа, тот же NODE_ENV-гейт что и в
RouteGuard).
2026-07-31 00:13:46 +03:00
bot-backend
01bf4a91e4 feat(tradein/caddy): снять basic_auth с /trade-in — своя авторизация (#2558)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
Оба trade-in блока (`handle /trade-in/api/*`, `@tradein`) + связанный
`@saleshare` редирект перенесены ВЫШЕ `import caddy/users.caddy.snippet`
в route{} — Caddy матчит handle-блоки сверху вниз, поэтому они теперь
short-circuit'ят до basic_auth-гейта (тот же механизм, что уже применён
к /health, /preview/*, @uipreview). Site Finder (/, /api/*, /analytics)
остаётся под basic_auth без изменений.

Trade-in уже имеет собственную авторизацию (форма входа + opaque
session-cookie, #2552) поверх RBAC внутри tradein-backend — Caddy
basic_auth для него стал избыточным легаси-слоем.

Security: после переноса плейсхолдер {http.auth.user.id} для этих
блоков никогда не резолвится (basic_auth больше не выполняется на
пути к ним). Вместо `header_up X-Authenticated-User {http.auth.user.id}`
(Set-операция с пустым значением — недокументированно надёжна как
удаление заголовка) используется явное `header_up -X-Authenticated-User`
(документированная delete-семантика `-<field>`). Это исключает проход
клиентского X-Authenticated-User насквозь к tradein-backend, который
в auth_mode="dual" всё ещё принимает legacy trusted-header путь —
без явного удаления внешний запрос с подделанным заголовком мог бы
выдать себя за другого пользователя. X-Internal-Auth-Secret не тронут
(#2213, всегда перезаписывается из env).

Провалидировано: `caddy validate --config Caddyfile --adapter caddyfile`
(caddy:2 образ, локальный docker) → "Valid configuration".
2026-07-31 00:05:33 +03:00
80d54cb5cd Merge pull request 'feat(tradein/ui): team-дашборд менеджера — сотрудники, квоты, история (#2556)' (#2565) from feat/tradein-team-dashboard into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 15s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 2m27s
Deploy Trade-In / deploy (push) Successful in 1m1s
2026-07-30 19:27:30 +00:00
a916ceea52 Merge pull request 'feat(tradein/db): seed существующих юзеров + ASCII-CHECK на username (#2557)' (#2564) from feat/tradein-users-seed into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 2m4s
Deploy Trade-In / build-backend (push) Successful in 1m12s
Deploy Trade-In / deploy (push) Successful in 1m23s
2026-07-30 19:17:34 +00:00
1364cd8e7a Merge pull request 'fix(tradein/team): устойчивая сортировка списка + семантика unlimited в батч-квотах (#2554)' (#2566) from fix/tradein-team-list-order into main
Some checks failed
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 15s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Has been cancelled
2026-07-30 19:17:10 +00:00
bot-backend
df943ea1c7 fix(tradein/team): \Z вместо $ в username-regex — 422 вместо 500 на trailing newline (#2554)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 13s
CI / changes (pull_request) Successful in 13s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 2m18s
Deep-review seed'а (#2564) нашёл смежный дефект в уже смерженном коде (#2563):
Python `$` матчит перед trailing newline (re.match(r'...\$', 'admin\n') -> True),
а Postgres `~` в CHECK tradein_users_username_ascii_ck (миграция 193) - False.
username="admin\n" проходил Pydantic-валидацию и падал уже в БД -> 500 вместо
честного 422. `\Z` - конец строки без поблажки на trailing newline, совпадает
с семантикой Postgres `~`.

Grep по app/schemas/ (pattern=/regex=/re.compile/re.match/re.fullmatch) -
других regex-валидаторов с `$` в схемах trade-in нет, team.py - единственный
файл с regex-based полем.
2026-07-30 22:10:52 +03:00
bot-backend
f915e1ee4d fix(tradein/db): не затирать manager_id при повторном прогоне сида (#2557)
All checks were successful
CI / changes (pull_request) Successful in 12s
CI Trade-In / changes (pull_request) Successful in 12s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 2m0s
Deep-review #2564: manager_id синкался из EXCLUDED безусловно — повторный прогон сида
тихо обнулял связь сотрудник->менеджер, назначенную через team-API (#2563), сотрудник
выпадал из _LIST_EMPLOYEES_BY_MANAGER_SQL. Защищён COALESCE, как остальные UI-managed
поля.

is_active убран из ON CONFLICT DO UPDATE SET вовсе (не COALESCE — колонка NOT NULL
DEFAULT true делала бы COALESCE-ветку недостижимой, мёртвый код вводил в заблуждение
симметрией с реально работающими COALESCE-полями). Open/close доступа — решение
владельца продукта через UI (#2556), не повторный прогон seed-файла.
2026-07-30 22:08:14 +03:00
bot-backend
2e05a16a14 fix(tradein/team): устойчивая сортировка списка + семантика unlimited в батч-квотах (#2554)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI / changes (pull_request) Successful in 11s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 2m0s
Deep-review PR #2563 follow-up (после merge+deploy):
1. ORDER BY created_at DESC, id DESC в _LIST_EMPLOYEES_BY_MANAGER_SQL /
   _LIST_EMPLOYEES_ALL_SQL. created_at DEFAULT now() — время транзакции, bulk-seed
   (#2557) вставляет много юзеров одной транзакцией -> идентичный timestamp у N+
   строк -> без тай-брейкера порядок между LIMIT/OFFSET страницами на PostgreSQL
   для строк-близнецов не гарантирован (сотрудники пропадали/дублировались бы
   при листании). id (BIGINT IDENTITY, монотонный) — детерминированный tie-break.
2. _batch_quota_status: unlimited теперь честно совпадает с
   account_quota.is_unlimited — override.unlimited=true честится ТОЛЬКО для
   username, присутствующего в roles.yaml (KeyError -> unlimited=False всегда,
   override даже не читается). Раньше батч всегда читал override независимо от
   roles.yaml -> список мог показать "unlimited" для квоты, которую реальный
   enforcement (check_and_raise/increment, тот же is_unlimited) не признаёт.
   Сегодня недостижимо (unlimited есть только у kopylov/praktika, оба в
   roles.yaml), но станет достижимым при расширении ролевки.
2026-07-30 22:02:19 +03:00
bot-backend
e7a658c7fa feat(tradein/db): seed существующих юзеров + ASCII-CHECK на username (#2557)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI / changes (pull_request) Successful in 10s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 1m56s
Migration 193: переносит org-карту (admin/kopylov/praktika/user1-10), утверждённую
владельцем продукта, из auth/roles.yaml в tradein_users (Foundation — миграция 192).
password_hash=NULL для всех — пароли админ проставит вручную через team-UI (#2556).
ASCII-CHECK на username (deep-review #2561): rbac кодирует session-username через
encode("latin-1","replace"), кириллические логины одинаковой длины схлопываются
в общий downstream-identity (IDOR) — constraint запрещает это fail-closed.

ON CONFLICT DO UPDATE защищает password_hash/is_active/display_name/org_name/email
через COALESCE — повторный прогон (recovery / staging без _schema_migrations
tracking) не затирает то, что менеджер поменял через UI.
2026-07-30 21:56:16 +03:00
8 changed files with 491 additions and 28 deletions

View file

@ -11,6 +11,13 @@
# Users managed via caddy/users.caddy.snippet (git history = audit trail).
# Public exclusions: /health (liveness probe), /preview/* (static mockups).
#
# #2558: с 2026-07 basic_auth гейтит ТОЛЬКО Site Finder (`/`, `/api/*`,
# `/analytics` и т.д.). `/trade-in/*` (+ `/sale-share` redirect) вынесены ВЫШЕ
# import'ау trade-in своя авторизация (форма входа + opaque session-cookie,
# см. #2552) поверх RBAC (`tradein-mvp/backend/app/core/rbac.py`). Site Finder
# всё ещё легаси-пилотный basic_auth (roles.yaml dual-mode остаётся живым для
# него — НЕ трогать caddy/users.caddy.snippet).
#
# IMPORTANT: route { } block is required to preserve directive order.
# Without route { }, Caddy executes directives in hard-coded default order
# (basic_auth runs before handle), making /health and /preview/* exclusions
@ -70,26 +77,56 @@ gendsgn.ru {
# Оба ДО auth-import, иначе ассеты страницы уходят в @tradein (под auth) → 401 → без CSS.
@uipreview path /trade-in/ui-preview/* /trade-in/_next/static/*
handle @uipreview {
reverse_proxy tradein-frontend:3000
reverse_proxy tradein-frontend:3000 {
# #2558 review: тот же периметр-scrub, что и у /trade-in/api/* и
# @tradein ниже — этот блок тоже теперь ДО basic_auth, клиент
# мог бы прислать свой X-Authenticated-User. Сейчас инертно
# (страница статична, у tradein-frontend нет секрета для
# X-Internal-Auth-Secret), но убираем ради единообразия периметра,
# а не полагаясь на то, что downstream ничего не делает с заголовком.
header_up -X-Authenticated-User
}
}
# Auth gate (applies to all routes below within this route block).
import caddy/users.caddy.snippet
# Trade-In MVP subproject (tradein-mvp/) — gendesign-tradein docker stack,
# подключен через gendesign_shared network. Routes ДО универсального handle
# потому что Caddy матчит handle-блоки сверху вниз.
# #2558: Trade-In MVP subproject (tradein-mvp/) — gendesign-tradein docker
# stack, подключен через gendesign_shared network. Секция ЦЕЛИКОМ ДО
# `import caddy/users.caddy.snippet` ниже — /trade-in имеет собственную
# авторизацию (форма входа + opaque session-cookie, #2552; RBAC-проверка
# роли внутри tradein-backend, `app/core/rbac.py`), Site Finder basic_auth
# ей больше не нужен и не должен применяться (short-circuit сверху вниз,
# как /health и /preview/* выше).
#
# X-Authenticated-User — ЯВНОЕ УДАЛЕНИЕ (`header_up -X-Authenticated-User`),
# НЕ `header_up X-Authenticated-User {http.auth.user.id}`. Причина: этот
# блок больше не идёт ПОСЛЕ basic_auth, поэтому `{http.auth.user.id}`
# никогда не резолвится авторизованным юзером на этом пути.
# Проверено эмпирически (echo-стенд на образе caddy:2, `caddy adapt`):
# старая Set-форма (`header_up X-Authenticated-User {http.auth.user.id}`)
# НЕ пропустила бы клиентский заголовок насквозь и НЕ оставила бы поле
# пустым — Caddy подставляет НЕРАЗРЕШЁННЫЙ плейсхолдер как ЛИТЕРАЛЬНУЮ
# строку (`ReplaceKnown`), т.е. upstream получил бы буквально
# `X-Authenticated-User: {http.auth.user.id}`. Для backend (auth_mode=
# "dual", `app/core/config.py`) это НЕ подмена личности — legacy path
# (`rbac.py:186`) сделал бы `get_role("{http.auth.user.id}")`, юзер не
# найден в roles.yaml → 403 для всех. Т.е. старая форма была бы не
# security-дырой, а fail-closed-but-сломанной (все trade-in запросы без
# session-cookie получали бы 403 вместо ожидаемого 401/редиректа на логин).
# `-Field` остаётся правильным выбором не потому что Set был бы дырой, а
# потому что это ЕДИНСТВЕННАЯ форма с явно задокументированной семантикой
# "удалить заголовок" (Caddyfile reverse_proxy directive: `-<field>` =
# delete) — корректное поведение не должно зависеть от того, как именно
# Caddy трактует нерезолвленный/пустой плейсхолдер в Set-операции.
# X-Internal-Auth-Secret НЕ трогаем — #2213-секрет всегда перезаписывается
# из env (Set-операция с непустым значением, никак не связана с auth-гейтом
# basic_auth), это единственное, что теперь отсекает подделку заголовков
# изнутри gendesign_shared network для legacy dual-mode пути.
handle /trade-in/api/* {
# `handle_path /trade-in/api/*` стрипал бы целиком /trade-in/api;
# FastAPI router замаунтен на /api/v1/trade-in/* — нужен strip только
# префикса basePath /trade-in (Next.js basePath leak).
uri strip_prefix /trade-in
reverse_proxy tradein-backend:8000 {
header_up X-Authenticated-User {http.auth.user.id}
# #2213 defense-in-depth: общий секрет Caddy↔tradein-backend. header_up
# с value ПЕРЕЗАПИСЫВАЕТ (стирает) любой клиентский X-Internal-Auth-Secret —
# тот же механизм, что защищает X-Authenticated-User выше. Пусто пока
# TRADEIN_INTERNAL_AUTH_SECRET не задан в .env (fail-open, backend не проверяет).
header_up -X-Authenticated-User
header_up X-Internal-Auth-Secret {env.TRADEIN_INTERNAL_AUTH_SECRET}
}
}
@ -98,6 +135,16 @@ gendsgn.ru {
# Next basePath=/trade-in → редиректим на канонический /trade-in/sale-share
# (тот же tradein-frontend контейнер; query-string сохраняется). True vanity-URL
# в адресной строке требует отдельного Next-app с basePath=/sale-share.
# #2558: перенесён ВЫШЕ auth-import вместе с trade-in — редирект ведёт на
# /trade-in/sale-share, для которого теперь нет Caddy basic_auth (как и
# для остального /trade-in). Это НЕ делает страницу публичной: она всё
# ещё за собственной авторизацией trade-in — `RouteGuard` во фронте
# (`app/layout.tsx`, «Доступ: pilot + admin») и сессия для
# `/api/v1/buildings/sale-share*` на бэке; без валидной сессии юзер
# получит редирект на /login, а не контент. Смысл переноса — не открыть
# страницу всем, а убрать несогласованность: короткий URL не должен быть
# строже (Caddy basic_auth) целевого адреса, к которому и так уже нет
# basic_auth-барьера (только собственный login trade-in).
@saleshare path /sale-share /sale-share/
handle @saleshare {
redir /trade-in/sale-share permanent
@ -110,13 +157,17 @@ gendsgn.ru {
handle @tradein {
# Next.js basePath=/trade-in — фронт сам ждёт префикса в URL
reverse_proxy tradein-frontend:3000 {
header_up X-Authenticated-User {http.auth.user.id}
# #2213: симметрично с /trade-in/api/* — перезаписываем секрет из env
# (стирает клиентский), на случай SSR-forwardʼa фронтом в backend.
# См. комментарий над /trade-in/api/* выше — та же логика (явное
# удаление вместо Set с пустым {http.auth.user.id}).
header_up -X-Authenticated-User
header_up X-Internal-Auth-Secret {env.TRADEIN_INTERNAL_AUTH_SECRET}
}
}
# Auth gate — с #2558 применяется ТОЛЬКО к Site Finder (handle /api/* и
# handle {} ниже). Trade-In уже отработал и short-circuit'нул выше.
import caddy/users.caddy.snippet
handle /api/* {
reverse_proxy backend:8000 {
header_up X-Authenticated-User {http.auth.user.id}

View file

@ -205,10 +205,25 @@ def _batch_quota_status(db: Session, usernames: list[str]) -> dict[str, dict[str
"""Батч-версия `account_quota.get_status` для N сотрудников — 2 SQL-запроса
вместо 2N (было 2N+3 на GET /employees, HIGH/Medium2 review PR #2563).
Семантика ИДЕНТИЧНА `account_quota.is_unlimited`/`user_limit`/`get_status`:
unlimited = admin-роль (roles.yaml, in-memory, без похода в БД) ИЛИ
`account_quota_overrides.unlimited=true`; limit = override.monthly_limit,
иначе глобальный `account_quota.MONTHLY_LIMIT`.
Семантика ИДЕНТИЧНА `account_quota.is_unlimited`/`user_limit`/`get_status`
(follow-up review PR #2563 п.2 — предыдущая версия расходилась: батч ВСЕГДА
читал `account_quota_overrides.unlimited`, а `is_unlimited` ТОЛЬКО для
username, присутствующего в roles.yaml):
- username НЕ в roles.yaml (`get_role` KeyError) unlimited=False ВСЕГДА,
`account_quota_overrides.unlimited` даже не проверяется (roles.yaml
источник правды "кто вообще может быть unlimited", override "у кого
именно из известных roles.yaml-юзеров"). Сегодня недостижимо для DB-only
сотрудников team-API (`_upsert_quota_override` всегда пишет
`unlimited=false`), но станет достижимым при ручном UPDATE
`account_quota_overrides` или расширении roles.yaml расхождение с
реальным enforcement (`check_and_raise`/`increment`, тот же `is_unlimited`)
было бы честной ложью в списке: "без лимита", который движок всё равно
считает.
- username в roles.yaml и role == admin unlimited=True (без похода в БД).
- username в roles.yaml, role != admin unlimited = override.unlimited.
limit = override.monthly_limit (читается для ЛЮБОГО username, без gate по
roles.yaml так же ведёт себя `account_quota.user_limit`), иначе глобальный
`account_quota.MONTHLY_LIMIT`.
"""
if not usernames:
return {}
@ -250,10 +265,17 @@ def _batch_quota_status(db: Session, usernames: list[str]) -> dict[str, dict[str
for username in usernames:
override = override_by_username.get(username)
try:
is_admin_role = get_role(username) == "admin"
role = get_role(username)
except KeyError:
is_admin_role = False
unlimited = is_admin_role or bool(override is not None and override["unlimited"])
role = None
if role == "admin":
unlimited = True
elif role is not None:
unlimited = bool(override is not None and override["unlimited"])
else:
# username не в roles.yaml — is_unlimited() короткое замыкание на
# False, override НЕ проверяется (см. докстринг выше).
unlimited = False
limit = (
int(override["monthly_limit"])
if override is not None and override["monthly_limit"] is not None
@ -521,12 +543,21 @@ async def update_employee(
# Два статических варианта WHERE (НЕ f-string/динамическая сборка — Medium/
# "заодно" review PR #2563: значения биндятся параметрами и без того безопасны,
# но статические ветки не провоцируют будущие правки в сторону конкатенации SQL).
#
# ORDER BY created_at DESC, id DESC — тай-брейкер по `id` ОБЯЗАТЕЛЕН (follow-up
# review PR #2563 п.1): `created_at DEFAULT now()` — время ТРАНЗАКЦИИ, а bulk-seed
# (#2557) вставляет много юзеров одной транзакцией → идентичный timestamp у N строк.
# Без тай-брейкера порядок между страницами (LIMIT/OFFSET) на PostgreSQL для
# строк-«близнецов» не гарантирован — сотрудники пропадали/дублировались бы при
# постраничном листании. `id` монотонно растёт (BIGINT IDENTITY) — детерминированный
# tie-break без доп. индекса (созданные позже = бОльший id, тот же порядок что и
# намерение DESC-сортировки по времени).
_LIST_EMPLOYEES_BY_MANAGER_SQL = text(
"""
SELECT id, username, display_name, org_name, email, is_active, manager_id, created_at
FROM tradein_users
WHERE role = 'employee' AND manager_id = :manager_id
ORDER BY created_at DESC
ORDER BY created_at DESC, id DESC
LIMIT :limit OFFSET :offset
"""
)
@ -536,7 +567,7 @@ _LIST_EMPLOYEES_ALL_SQL = text(
SELECT id, username, display_name, org_name, email, is_active, manager_id, created_at
FROM tradein_users
WHERE role = 'employee'
ORDER BY created_at DESC
ORDER BY created_at DESC, id DESC
LIMIT :limit OFFSET :offset
"""
)

View file

@ -16,7 +16,14 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator
# (`app.core.rbac._propagate_authenticated_user` кодирует latin-1 с
# errors="replace"), поэтому валидация формы обязательна на границе API,
# а не только на уровне БД.
_USERNAME_RE = re.compile(r"^[A-Za-z0-9._-]{3,64}$")
#
# `\Z`, НЕ `$` — deep-review seed #2564: в Python `$` матчит перед trailing
# newline (`re.match(r'...\$', 'admin\n')` → True), а Postgres `~` в CHECK
# tradein_users_username_ascii_ck (миграция 193) — False. С `$` строка
# "admin\n" проходила бы Pydantic-валидацию и падала уже в БД → 500 вместо
# честного 422. `\Z` — конец строки БЕЗ поблажки на trailing newline, совпадает
# с семантикой Postgres `~`.
_USERNAME_RE = re.compile(r"^[A-Za-z0-9._-]{3,64}\Z")
class QuotaStatusOut(BaseModel):

View file

@ -0,0 +1,127 @@
-- Migration 193: seed существующих юзеров в tradein_users + ASCII-CHECK на username
-- (issue #2557, эпик #2549)
--
-- WHY:
-- Migration 192 создала schema (tradein_users/tradein_sessions), но без данных —
-- DB-backed auth не может заработать, пока реальные аккаунты (сейчас живущие только
-- в auth/roles.yaml + caddy/users.caddy.snippet, legacy Caddy basic-auth) не отражены
-- в таблице. Эта миграция переносит org-карту, утверждённую владельцем продукта,
-- в данные — без единого пароля (см. WHAT.2) и без Python-кода (backend wiring — #2556,
-- team-UI для проставления паролей — отдельная задача, тоже #2556).
--
-- ASCII-CHECK (deep-review #2561, обязательное требование ДО прод-данных):
-- rbac кодирует session-username через `encode("latin-1", "replace")`. Кириллические
-- логины ОДИНАКОВОЙ длины схлопываются в одну и ту же byte-строку под этой кодировкой
-- ("иванов" и "петров" оба 6 кириллических символов -> оба превращаются в одинаковую
-- строку из '?' одной длины) -> общий downstream-identity между разными людьми, общая
-- квота, взаимный IDOR (один видит сессии/данные другого). Все текущие org-логины уже
-- ASCII (admin/kopylov/praktika/userN), поэтому constraint не конфликтует с seed'ом
-- ниже; он существует, чтобы navsegda запретить будущим кириллическим логинам попасть
-- в таблицу — fail-closed на уровне схемы, а не на уровне доверия к тому, что кто-то
-- не забудет проверить в UI/API layer.
--
-- WHAT:
-- 1. ASCII-CHECK: tradein_users_username_ascii_ck CHECK (username ~ '^[A-Za-z0-9._-]{3,64}$').
-- Добавлен ДО seed-инсертов ниже для читаемости файла (CHECK — immediate constraint,
-- Postgres валидирует им и ROW-строки транзакции независимо от того, в каком месте
-- файла он объявлен относительно INSERT, так что порядок сам по себе не критичен).
-- 2. Seed — org-карта, утверждённая владельцем продукта (2026-07-30):
-- admin role=admin, manager_id=NULL, is_active=true (владелец)
-- kopylov role=manager, manager_id=NULL, is_active=true (отдельный клиент)
-- praktika role=manager, manager_id=NULL, is_active=true (ГК «Практика»)
-- user1, user3-10 role=employee, manager_id=NULL, is_active=true (свободные слоты, без org)
-- user2 role=employee, manager_id=NULL, is_active=false («Брусника», доступ
-- закрыт 2026-07-30)
-- password_hash = NULL для ВСЕХ — пароли админ проставит вручную через team-UI (#2556).
-- NULL-hash делает password-логин невозможным для этой строки, но НЕ снимает доступ:
-- в переходный период работает только legacy Caddy basic-auth fallback (dual-mode,
-- см. комментарий password_hash в 192_tradein_users_auth.sql) — никто не теряет доступ
-- из-за этой миграции.
-- display_name = 'Копылов' для kopylov (источник — auth.py::_USERNAME_PROFILE, уже
-- задокументированная фамилия). Для остальных — NULL, реальных данных нет, не выдумываем.
-- НЕ мигрируем admintest/pilottest/analysttest/expiredtest — temp QA-фикстуры
-- (auth/roles.yaml), остаются только там, в DB-backed auth не нужны.
--
-- IDEMPOTENCY:
-- - ADD CONSTRAINT через DO-блок с проверкой pg_constraint (Postgres не поддерживает
-- `ADD CONSTRAINT IF NOT EXISTS` для CHECK) — паттерн из
-- 189_account_estimate_usage_nonnegative.sql.
-- - INSERT ... ON CONFLICT (username) DO UPDATE, но НЕ безусловно: password_hash,
-- manager_id, display_name, org_name, email защищены COALESCE(текущее, EXCLUDED) —
-- если админ уже проставил пароль / назначил manager_id (team-API #2563 пишет
-- manager_id при создании сотрудника менеджером) / поменял display_name вручную
-- через team-UI (#2556) между двумя прогонами этого файла (например ручной re-apply
-- при recovery — обычный auto-apply тречит filename в _schema_migrations и не
-- запускает файл дважды на одном окружении, но scratch/staging БД такого
-- трекинга не имеют), повторный прогон НЕ должен затереть это состояние NULL-ом /
-- seed-дефолтом. Deep-review #2564 нашёл это живым багом: manager_id, назначенный
-- через #2563, тихо обнулялся повторным прогоном сида — employee выпадал из
-- `_LIST_EMPLOYEES_BY_MANAGER_SQL`, менеджер переставал видеть его в дашборде.
-- role намеренно синкается с EXCLUDED (не защищён) — это и есть источник истины
-- org-карты из этой миграции; если владелец продукта поправит эту таблицу новой
-- миграцией поверх, DO UPDATE-ветка должна донести исправление роли, а не
-- заморозить первый прогон навсегда.
-- - is_active НАМЕРЕННО отсутствует в SET (не COALESCE — колонка NOT NULL DEFAULT
-- true, COALESCE(NOT NULL, x) никогда не берёт x, это была бы мёртвая, вводящая в
-- заблуждение симметрия с password_hash/manager_id, deep-review #2564 medium).
-- Открытие/закрытие доступа (is_active) — решение владельца продукта, принимается
-- через UI (#2556), НЕ повторным прогоном этого seed-файла: после первой вставки
-- колонка сознательно «замораживается» на текущем значении в БД, seed её больше
-- не трогает.
--
-- Dependencies: 192_tradein_users_auth.sql (создаёт tradein_users, tradein_sessions).
-- Deploy order: после 192 (Foundation). Backend auth-код (login/password-verify) и
-- team-UI (#2556) — отдельные PR'ы ПОСЛЕ этой миграции (SQL-схема+данные первыми, см.
-- .claude/rules/sql.md "Migration order").
BEGIN;
-- Часть 1: ASCII-CHECK (immediate constraint — валидирует и вставляемые ниже строки).
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'tradein_users_username_ascii_ck'
) THEN
ALTER TABLE tradein_users
ADD CONSTRAINT tradein_users_username_ascii_ck
CHECK (username ~ '^[A-Za-z0-9._-]{3,64}$');
END IF;
END $$;
COMMENT ON CONSTRAINT tradein_users_username_ascii_ck ON tradein_users IS
'Fail-closed защита от кириллических/не-ASCII логинов (deep-review #2561): '
'rbac кодирует session-username через encode("latin-1","replace"), не-ASCII '
'логины одинаковой длины схлопываются в общий downstream-identity (IDOR).';
-- Часть 2: seed org-карты (владелец продукта, 2026-07-30).
INSERT INTO tradein_users
(username, password_hash, role, manager_id, display_name, org_name, email, is_active)
VALUES
('admin', NULL, 'admin', NULL, NULL, NULL, NULL, true),
('kopylov', NULL, 'manager', NULL, 'Копылов', NULL, NULL, true),
('praktika', NULL, 'manager', NULL, NULL, NULL, NULL, true),
('user1', NULL, 'employee', NULL, NULL, NULL, NULL, true),
('user2', NULL, 'employee', NULL, NULL, NULL, NULL, false),
('user3', NULL, 'employee', NULL, NULL, NULL, NULL, true),
('user4', NULL, 'employee', NULL, NULL, NULL, NULL, true),
('user5', NULL, 'employee', NULL, NULL, NULL, NULL, true),
('user6', NULL, 'employee', NULL, NULL, NULL, NULL, true),
('user7', NULL, 'employee', NULL, NULL, NULL, NULL, true),
('user8', NULL, 'employee', NULL, NULL, NULL, NULL, true),
('user9', NULL, 'employee', NULL, NULL, NULL, NULL, true),
('user10', NULL, 'employee', NULL, NULL, NULL, NULL, true)
ON CONFLICT (username) DO UPDATE SET
role = EXCLUDED.role,
-- manager_id защищён COALESCE: team-API (#2563) пишет manager_id при назначении
-- сотрудника менеджером, повторный прогон seed'а не должен тихо обнулять эту связь.
manager_id = COALESCE(tradein_users.manager_id, EXCLUDED.manager_id),
password_hash = COALESCE(tradein_users.password_hash, EXCLUDED.password_hash),
display_name = COALESCE(tradein_users.display_name, EXCLUDED.display_name),
org_name = COALESCE(tradein_users.org_name, EXCLUDED.org_name),
email = COALESCE(tradein_users.email, EXCLUDED.email),
-- is_active НЕ в SET: NOT NULL DEFAULT true колонка, COALESCE был бы мёртвым кодом
-- (см. IDEMPOTENCY выше) — open/close доступа решается через UI (#2556), не seed'ом.
updated_at = now();
COMMIT;

View file

@ -61,6 +61,7 @@ class _Store:
display_name: str | None = None,
org_name: str | None = None,
email: str | None = None,
created_at: datetime | None = None,
) -> int:
uid = self._next_id
self._next_id += 1
@ -74,7 +75,7 @@ class _Store:
"org_name": org_name,
"email": email,
"is_active": is_active,
"created_at": datetime.now(UTC),
"created_at": created_at or datetime.now(UTC),
}
return uid
@ -259,7 +260,10 @@ class _FakeDB:
rows = [u for u in s.users.values() if u["role"] == "employee"]
if "manager_id" in p:
rows = [u for u in rows if u["manager_id"] == p["manager_id"]]
rows = sorted(rows, key=lambda u: u["created_at"], reverse=True)
# Mirrors real SQL `ORDER BY created_at DESC, id DESC` — `id` tiebreak
# is REQUIRED for deterministic paging when created_at ties (follow-up
# review PR #2563 п.1, bulk-seed #2557 inserts many rows in one tx).
rows = sorted(rows, key=lambda u: (u["created_at"], u["id"]), reverse=True)
offset, limit = p.get("offset", 0), p.get("limit", len(rows))
rows = rows[offset : offset + limit]
return _Result(
@ -515,6 +519,26 @@ def test_create_employee_non_ascii_username_422(client: TestClient, store: _Stor
assert resp.status_code == 422
@pytest.mark.parametrize("username", ["admin\n", "user1\n"])
def test_create_employee_trailing_newline_username_422_not_500(
client: TestClient, store: _Store, username: str
) -> None:
"""Deep-review seed #2564: Python `$` matches BEFORE a trailing newline
(`re.match(r'...\\$', 'admin\\n')` True), but Postgres `~` (CHECK
tradein_users_username_ascii_ck, migration 193) does NOT a username with a
trailing "\\n" used to pass Pydantic validation and crash in the DB (500)
instead of a clean 422. `_USERNAME_RE` now uses `\\Z`, matching Postgres `~`
semantics exactly."""
store.add_user("mgr_a", hash_password("Secret123!"), role="manager")
_login(client, "mgr_a", "Secret123!")
resp = client.post(
"/api/v1/team/employees",
json={"username": username, "password": "Secret123!"},
)
assert resp.status_code == 422, resp.text
def test_create_employee_duplicate_username_409(client: TestClient, store: _Store) -> None:
store.add_user("mgr_a", hash_password("Secret123!"), role="manager")
store.add_user("emp_dup", hash_password("Secret123!"), role="employee")
@ -787,6 +811,51 @@ def test_list_employees_limit_max_200(client: TestClient, store: _Store) -> None
assert resp.status_code == 422
def test_list_employees_pagination_stable_with_identical_created_at(
client: TestClient, store: _Store
) -> None:
"""Follow-up review PR #2563 п.1: `created_at DEFAULT now()` — время ТРАНЗАКЦИИ,
bulk-seed (#2557) вставляет много юзеров одной транзакцией → идентичный
timestamp у N+ строк. Без `id DESC` тай-брейкера порядок между страницами
на PostgreSQL для строк-«близнецов» не гарантирован сотрудники пропадали/
дублировались бы при постраничном листании. Вставляем 5 сотрудников с
ОДИНАКОВЫМ created_at, листаем limit=2 постранично объединение страниц
обязано дать полный набор без дублей и пропусков."""
store.add_user("admin1", hash_password("Secret123!"), role="admin")
mgr_id = store.add_user("mgr_a", hash_password("Secret123!"), role="manager")
same_ts = datetime.now(UTC)
expected_usernames = set()
for i in range(5):
username = f"emp_tie_{i}"
store.add_user(
username,
hash_password("Secret123!"),
role="employee",
manager_id=mgr_id,
created_at=same_ts,
)
expected_usernames.add(username)
_login(client, "admin1", "Secret123!")
seen: list[str] = []
offset = 0
while True:
resp = client.get("/api/v1/team/employees", params={"limit": 2, "offset": offset})
assert resp.status_code == 200, resp.text
page = [e["username"] for e in resp.json()]
if not page:
break
seen.extend(page)
offset += 2
assert len(seen) == len(expected_usernames), (
f"page union has {len(seen)} entries (dupes or gaps), expected "
f"{len(expected_usernames)}: {seen}"
)
assert set(seen) == expected_usernames
# ---------------------------------------------------------------------------
# CSRF defense-in-depth — Origin/Referer check on state-changing team routes
# ---------------------------------------------------------------------------
@ -846,6 +915,65 @@ def test_patch_employee_origin_mismatch_403(client: TestClient, store: _Store) -
assert store.users["emp_a"]["display_name"] != "hacked"
# ---------------------------------------------------------------------------
# _batch_quota_status unlimited semantics — must match account_quota.is_unlimited
# ---------------------------------------------------------------------------
def test_batch_quota_unlimited_ignored_for_non_roles_yaml_username(
client: TestClient, store: _Store
) -> None:
"""Follow-up review PR #2563 п.2: `account_quota.is_unlimited` short-circuits
to False for a username NOT in roles.yaml it never even reads
`account_quota_overrides.unlimited`. The batch quota status used by
GET /employees must agree, or the list would show "unlimited" for a quota
that real enforcement (check_and_raise/increment, same is_unlimited) does
NOT honor a misleading display. `emp_ghost_unlimited` is a fresh DB-only
username guaranteed absent from roles.yaml."""
store.add_user("admin1", hash_password("Secret123!"), role="admin")
mgr_id = store.add_user("mgr_a", hash_password("Secret123!"), role="manager")
store.add_user(
"emp_ghost_unlimited", hash_password("Secret123!"), role="employee", manager_id=mgr_id
)
store.quota_overrides["emp_ghost_unlimited"] = {
"monthly_limit": 15,
"unlimited": True,
"note": "manual grant via SQL runbook (not through team-api)",
}
_login(client, "admin1", "Secret123!")
resp = client.get("/api/v1/team/employees")
assert resp.status_code == 200, resp.text
entry = next(e for e in resp.json() if e["username"] == "emp_ghost_unlimited")
# DB override says unlimited=true, but username is NOT in roles.yaml — real
# enforcement would never see it, so the list must NOT claim "unlimited".
assert entry["quota"]["unlimited"] is False
assert entry["quota"]["limit"] == 15
def test_batch_quota_unlimited_honored_for_roles_yaml_username(
client: TestClient, store: _Store
) -> None:
"""Symmetric positive case: a username actually present in roles.yaml
(non-admin role) `account_quota_overrides.unlimited=true` IS honored, same
as `account_quota.is_unlimited`. Uses `kopylov` real prod pilot-role entry
in auth/roles.yaml (see app/core/auth.py module docstring)."""
store.add_user("admin1", hash_password("Secret123!"), role="admin")
mgr_id = store.add_user("mgr_a", hash_password("Secret123!"), role="manager")
store.add_user("kopylov", hash_password("Secret123!"), role="employee", manager_id=mgr_id)
store.quota_overrides["kopylov"] = {
"monthly_limit": 999,
"unlimited": True,
"note": "existing prod grant",
}
_login(client, "admin1", "Secret123!")
resp = client.get("/api/v1/team/employees")
assert resp.status_code == 200, resp.text
entry = next(e for e in resp.json() if e["username"] == "kopylov")
assert entry["quota"]["unlimited"] is True
# ---------------------------------------------------------------------------
# GET /employees/{id}/history
# ---------------------------------------------------------------------------

View file

@ -9,9 +9,19 @@
* доомed 403 round-trip), но HTTPError 403 от самого списка сотрудников тоже
* ловится тот же defense-in-depth паттерн, что и `app/admin/audit/page.tsx`
* (прямой заход на URL не крашит страницу, даже если nav её уже прячет).
*
* #2556 fix (эпик #2549, live-audit): team-роуты требуют session-cookie
* (см. team-api.ts header comment) легаси Caddy-заголовок (`/me` 200 с
* ролью admin/manager) через него НЕ проходит. Юзер, прошедший только Caddy
* basic_auth (без входа в форму `/login`), видел здесь непонятную красную
* плашку «Не удалось выполнить запрос». Ловим именно 401 от employeesQ
* ОТДЕЛЬНО от 403 (403 = «нет прав», это ожидаемо и есть у другой роли; 401
* = «нет сессии вообще», это решается логином) и уводим на /login?next=/team
* тот же redirect-паттерн, что RouteGuard.tsx использует для 401 от /me.
*/
import { useState } from "react";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import "@/components/trade-in/trade-in.css";
import { NoAccessScreen } from "@/components/auth/NoAccessScreen";
@ -26,9 +36,11 @@ import { teamErrorMessage, useEmployees } from "@/lib/team-api";
import { useMe } from "@/lib/useMe";
const PAGE_LIMIT = 50;
const TEAM_LOGIN_NEXT = `/login?next=${encodeURIComponent("/team")}`;
export default function TeamPage() {
const meQ = useMe();
const router = useRouter();
// Вычисляем ДО useEmployees (не после) — иначе для employee/analyst/pilot
// при прямом заходе на /team улетает обречённый GET (401/403) ещё до того,
// как ниже отрисуется role-gate. Порядок хуков не меняется — это просто
@ -43,6 +55,23 @@ export default function TeamPage() {
const [quotaEmployee, setQuotaEmployee] = useState<Employee | null>(null);
const [historyEmployee, setHistoryEmployee] = useState<Employee | null>(null);
// 401 = легаси Caddy-роль прошла role-gate выше, но team-эндпоинт требует
// session-cookie, которой у юзера нет (см. header comment). Отдельно от 403
// (та ветка — «роль есть, прав недостаточно», ниже).
const sessionRequired =
employeesQ.error instanceof HTTPError && employeesQ.error.status === 401;
// Редирект — побочный эффект (нельзя router.push во время рендера), тот же
// паттерн, что RouteGuard.tsx использует для 401 от /me. NODE_ENV-гейт
// зеркалит RouteGuard: в dev без Caddy/логина 401 — обычное дело, авто-
// редирект там мешал бы локальной разработке; вместо него — сообщение с
// кнопкой ниже.
useEffect(() => {
if (!sessionRequired) return;
if (process.env.NODE_ENV !== "production") return;
router.push(TEAM_LOGIN_NEXT);
}, [sessionRequired, router]);
// Роль ещё не загружена — не решаем ничего, RouteGuard уже отрисовал
// страницу (401/403 верхнего уровня он ловит сам), здесь только role-gate.
if (meQ.isPending) {
@ -60,6 +89,30 @@ export default function TeamPage() {
return <NoAccessScreen variant="user" />;
}
if (sessionRequired) {
// Prod: редирект уже запущен эффектом выше — не мигаем error-текстом,
// пока навигация не завершится (тот же приём, что RouteGuard.tsx).
if (process.env.NODE_ENV === "production") return null;
return (
<>
<Topbar active="team" />
<main className="page scraper-page" style={{ maxWidth: 1180 }}>
<h1 className="scraper-h1">Команда</h1>
<p className="scraper-result scraper-result--error">
Войдите в систему, чтобы управлять командой.
</p>
<a
href={TEAM_LOGIN_NEXT}
className="team-btn-primary"
style={{ display: "inline-block", textDecoration: "none" }}
>
Войти
</a>
</main>
</>
);
}
// Backend 403 регардлесс роли фронта (напр. session истекла между /me и
// /employees) — тот же fallback, что и в admin/audit.
if (employeesQ.error instanceof HTTPError && employeesQ.error.status === 403) {

View file

@ -53,6 +53,7 @@ import type {
TradeInEstimateInput,
} from "@/types/trade-in";
import { HTTPError } from "@/lib/api";
import { isPathAllowed } from "@/lib/isPathAllowed";
import {
useEstimate,
useEstimateHistory,
@ -666,6 +667,21 @@ export default function TradeInV2Page() {
};
}, [me.data]);
// #2556 (эпик #2549): вход в раздел «Команда» из v2-навигации — раньше
// TopNav (v2) не знал про team-дашборд вообще, он был достижим только из
// legacy Topbar.tsx (пункт в NAV_ITEMS), а `/trade-in/` редиректит на
// `/trade-in/v2` → дашборд существовал, но был недостижим кликом. Тот же
// двойной гейт, что и Topbar.NAV_ITEMS "team": isPathAllowed(scopePath) —
// DB-роль `manager` получает голый `/api/v1/team/**` в allowed_paths (см.
// DB_ROLE_PATHS, tradein-mvp/backend/app/services/auth_session.py) — ПЛЮС
// roleGate поверх, потому что legacy-роль `analyst` (auth/roles.yaml,
// paths: "/**", без явного deny на /api/v1/team) тоже прошла бы
// isPathAllowed, но не должна видеть «Команда» (scope: только admin/manager).
const showTeamNavItem =
me.data != null &&
isPathAllowed(me.data.allowed_paths, me.data.deny_paths, "/api/v1/team") &&
(me.data.role === "admin" || me.data.role === "manager");
// Analog price pins for the 01 map, projected from the real estimate. No
// estimate → mapMarkers(null) → [] → ParamsPanel renders only the subject pin
// (Finding #2: never the static fixture price markers).
@ -978,6 +994,7 @@ export default function TradeInV2Page() {
reports={reportsCount ?? 0}
user={topNavUser}
onLogout={() => logoutMutation.mutate()}
showTeamLink={showTeamNavItem}
/>
</nav>
<main

View file

@ -10,6 +10,8 @@
import { useState } from "react";
import type { CSSProperties } from "react";
import { API_BASE_URL } from "@/lib/api";
import { tokens } from "./tokens";
import { navLabels, version } from "./fixtures";
import { useSupportChat } from "./SupportChatContext";
@ -35,6 +37,12 @@ interface TopNavProps {
user?: TopNavUser;
// Sign out — page wires this to logout(); closes the menu first.
onLogout?: () => void;
// #2556 (эпик #2549): показывать пункт «Команда» в user-меню — вычисляется
// страницей из useMe() (роль admin/manager + isPathAllowed на /api/v1/team,
// тот же двойной гейт, что и Topbar.tsx NAV_ITEMS "team"). false/undefined
// → пункт не рендерится (роль employee/pilot/analyst/expired или /me ещё
// не загрузился).
showTeamLink?: boolean;
}
// Neutral fallback when the user prop is absent (loading / unauthenticated).
@ -57,6 +65,24 @@ const menuItemStyle: CSSProperties = {
transition: "background .12s",
};
/** Inline Users icon (lucide-react `Users` SVG path, stroke 1.2) для
пункта «Команда» в user-меню (#2556). Тот же inline-SVG паттерн (15x15,
tokens.muted), что и остальные иконки этого дропдауна. */
function UsersIcon() {
return (
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" aria-hidden="true">
<circle cx="5.3" cy="5" r="2.2" stroke={tokens.muted} strokeWidth="1.2" />
<path
d="M1 13c0-2.6 1.9-3.9 4.3-3.9S9.6 10.4 9.6 13"
stroke={tokens.muted}
strokeWidth="1.2"
/>
<path d="M9.3 1.8a2.1 2.1 0 0 1 0 4" stroke={tokens.muted} strokeWidth="1.2" />
<path d="M11 9.5c1.9.4 3 1.6 3 3.5" stroke={tokens.muted} strokeWidth="1.2" />
</svg>
);
}
// Профиль / Настройки have no pages yet — render them dimmed and
// non-interactive (no hover class, default cursor) so they read as disabled.
// «Помощь» used to be in this group too, then linked out to the Telegram
@ -74,6 +100,7 @@ export default function TopNav({
reports = 0,
user,
onLogout,
showTeamLink = false,
}: TopNavProps) {
const [userOpen, setUserOpen] = useState(false);
const { openChat } = useSupportChat();
@ -489,6 +516,28 @@ export default function TopNav({
Помощь
</button>
{/* «Команда» (#2556, эпик #2549) team-дашборд (`/team`) отдельный
роут, не таб SectionOverlay, поэтому это ссылка в user-меню
рядом с «Выйти», а не пункт в tnav-tab-ряду выше: v2-табы
это разделы ОДНОЙ страницы (переключают `active`/onNavigate),
а «Команда» уводит на другой URL. Гейт (showTeamLink) считает
страница из useMe() здесь только рендер. */}
{showTeamLink && (
<a
role="menuitem"
href={`${API_BASE_URL}/team`}
className="tnav-menuitem"
style={{
...menuItemStyle,
textDecoration: "none",
}}
onClick={() => setUserOpen(false)}
>
<UsersIcon />
Команда
</a>
)}
<div
style={{
height: "1px",