Compare commits
No commits in common. "main" and "feat/tradein-team-dashboard" have entirely different histories.
main
...
feat/trade
8 changed files with 28 additions and 491 deletions
81
Caddyfile
81
Caddyfile
|
|
@ -11,13 +11,6 @@
|
||||||
# Users managed via caddy/users.caddy.snippet (git history = audit trail).
|
# Users managed via caddy/users.caddy.snippet (git history = audit trail).
|
||||||
# Public exclusions: /health (liveness probe), /preview/* (static mockups).
|
# 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.
|
# IMPORTANT: route { } block is required to preserve directive order.
|
||||||
# Without route { }, Caddy executes directives in hard-coded default order
|
# Without route { }, Caddy executes directives in hard-coded default order
|
||||||
# (basic_auth runs before handle), making /health and /preview/* exclusions
|
# (basic_auth runs before handle), making /health and /preview/* exclusions
|
||||||
|
|
@ -77,56 +70,26 @@ gendsgn.ru {
|
||||||
# Оба ДО auth-import, иначе ассеты страницы уходят в @tradein (под auth) → 401 → без CSS.
|
# Оба ДО auth-import, иначе ассеты страницы уходят в @tradein (под auth) → 401 → без CSS.
|
||||||
@uipreview path /trade-in/ui-preview/* /trade-in/_next/static/*
|
@uipreview path /trade-in/ui-preview/* /trade-in/_next/static/*
|
||||||
handle @uipreview {
|
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# #2558: Trade-In MVP subproject (tradein-mvp/) — gendesign-tradein docker
|
# Auth gate (applies to all routes below within this route block).
|
||||||
# stack, подключен через gendesign_shared network. Секция ЦЕЛИКОМ ДО
|
import caddy/users.caddy.snippet
|
||||||
# `import caddy/users.caddy.snippet` ниже — /trade-in имеет собственную
|
|
||||||
# авторизацию (форма входа + opaque session-cookie, #2552; RBAC-проверка
|
# Trade-In MVP subproject (tradein-mvp/) — gendesign-tradein docker stack,
|
||||||
# роли внутри tradein-backend, `app/core/rbac.py`), Site Finder basic_auth
|
# подключен через gendesign_shared network. Routes ДО универсального handle
|
||||||
# ей больше не нужен и не должен применяться (short-circuit сверху вниз,
|
# потому что Caddy матчит handle-блоки сверху вниз.
|
||||||
# как /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 /trade-in/api/* {
|
||||||
# `handle_path /trade-in/api/*` стрипал бы целиком /trade-in/api;
|
# `handle_path /trade-in/api/*` стрипал бы целиком /trade-in/api;
|
||||||
# FastAPI router замаунтен на /api/v1/trade-in/* — нужен strip только
|
# FastAPI router замаунтен на /api/v1/trade-in/* — нужен strip только
|
||||||
# префикса basePath /trade-in (Next.js basePath leak).
|
# префикса basePath /trade-in (Next.js basePath leak).
|
||||||
uri strip_prefix /trade-in
|
uri strip_prefix /trade-in
|
||||||
reverse_proxy tradein-backend:8000 {
|
reverse_proxy tradein-backend:8000 {
|
||||||
header_up -X-Authenticated-User
|
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-Internal-Auth-Secret {env.TRADEIN_INTERNAL_AUTH_SECRET}
|
header_up X-Internal-Auth-Secret {env.TRADEIN_INTERNAL_AUTH_SECRET}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -135,16 +98,6 @@ gendsgn.ru {
|
||||||
# Next basePath=/trade-in → редиректим на канонический /trade-in/sale-share
|
# Next basePath=/trade-in → редиректим на канонический /trade-in/sale-share
|
||||||
# (тот же tradein-frontend контейнер; query-string сохраняется). True vanity-URL
|
# (тот же tradein-frontend контейнер; query-string сохраняется). True vanity-URL
|
||||||
# в адресной строке требует отдельного Next-app с basePath=/sale-share.
|
# в адресной строке требует отдельного 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/
|
@saleshare path /sale-share /sale-share/
|
||||||
handle @saleshare {
|
handle @saleshare {
|
||||||
redir /trade-in/sale-share permanent
|
redir /trade-in/sale-share permanent
|
||||||
|
|
@ -157,17 +110,13 @@ gendsgn.ru {
|
||||||
handle @tradein {
|
handle @tradein {
|
||||||
# Next.js basePath=/trade-in — фронт сам ждёт префикса в URL
|
# Next.js basePath=/trade-in — фронт сам ждёт префикса в URL
|
||||||
reverse_proxy tradein-frontend:3000 {
|
reverse_proxy tradein-frontend:3000 {
|
||||||
# См. комментарий над /trade-in/api/* выше — та же логика (явное
|
header_up X-Authenticated-User {http.auth.user.id}
|
||||||
# удаление вместо Set с пустым {http.auth.user.id}).
|
# #2213: симметрично с /trade-in/api/* — перезаписываем секрет из env
|
||||||
header_up -X-Authenticated-User
|
# (стирает клиентский), на случай SSR-forwardʼa фронтом в backend.
|
||||||
header_up X-Internal-Auth-Secret {env.TRADEIN_INTERNAL_AUTH_SECRET}
|
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/* {
|
handle /api/* {
|
||||||
reverse_proxy backend:8000 {
|
reverse_proxy backend:8000 {
|
||||||
header_up X-Authenticated-User {http.auth.user.id}
|
header_up X-Authenticated-User {http.auth.user.id}
|
||||||
|
|
|
||||||
|
|
@ -205,25 +205,10 @@ def _batch_quota_status(db: Session, usernames: list[str]) -> dict[str, dict[str
|
||||||
"""Батч-версия `account_quota.get_status` для N сотрудников — 2 SQL-запроса
|
"""Батч-версия `account_quota.get_status` для N сотрудников — 2 SQL-запроса
|
||||||
вместо 2N (было 2N+3 на GET /employees, HIGH/Medium2 review PR #2563).
|
вместо 2N (было 2N+3 на GET /employees, HIGH/Medium2 review PR #2563).
|
||||||
|
|
||||||
Семантика ИДЕНТИЧНА `account_quota.is_unlimited`/`user_limit`/`get_status`
|
Семантика ИДЕНТИЧНА `account_quota.is_unlimited`/`user_limit`/`get_status`:
|
||||||
(follow-up review PR #2563 п.2 — предыдущая версия расходилась: батч ВСЕГДА
|
unlimited = admin-роль (roles.yaml, in-memory, без похода в БД) ИЛИ
|
||||||
читал `account_quota_overrides.unlimited`, а `is_unlimited` — ТОЛЬКО для
|
`account_quota_overrides.unlimited=true`; limit = override.monthly_limit,
|
||||||
username, присутствующего в roles.yaml):
|
иначе глобальный `account_quota.MONTHLY_LIMIT`.
|
||||||
- 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:
|
if not usernames:
|
||||||
return {}
|
return {}
|
||||||
|
|
@ -265,17 +250,10 @@ def _batch_quota_status(db: Session, usernames: list[str]) -> dict[str, dict[str
|
||||||
for username in usernames:
|
for username in usernames:
|
||||||
override = override_by_username.get(username)
|
override = override_by_username.get(username)
|
||||||
try:
|
try:
|
||||||
role = get_role(username)
|
is_admin_role = get_role(username) == "admin"
|
||||||
except KeyError:
|
except KeyError:
|
||||||
role = None
|
is_admin_role = False
|
||||||
if role == "admin":
|
unlimited = is_admin_role or bool(override is not None and override["unlimited"])
|
||||||
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 = (
|
limit = (
|
||||||
int(override["monthly_limit"])
|
int(override["monthly_limit"])
|
||||||
if override is not None and override["monthly_limit"] is not None
|
if override is not None and override["monthly_limit"] is not None
|
||||||
|
|
@ -543,21 +521,12 @@ async def update_employee(
|
||||||
# Два статических варианта WHERE (НЕ f-string/динамическая сборка — Medium/
|
# Два статических варианта WHERE (НЕ f-string/динамическая сборка — Medium/
|
||||||
# "заодно" review PR #2563: значения биндятся параметрами и без того безопасны,
|
# "заодно" review PR #2563: значения биндятся параметрами и без того безопасны,
|
||||||
# но статические ветки не провоцируют будущие правки в сторону конкатенации SQL).
|
# но статические ветки не провоцируют будущие правки в сторону конкатенации 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(
|
_LIST_EMPLOYEES_BY_MANAGER_SQL = text(
|
||||||
"""
|
"""
|
||||||
SELECT id, username, display_name, org_name, email, is_active, manager_id, created_at
|
SELECT id, username, display_name, org_name, email, is_active, manager_id, created_at
|
||||||
FROM tradein_users
|
FROM tradein_users
|
||||||
WHERE role = 'employee' AND manager_id = :manager_id
|
WHERE role = 'employee' AND manager_id = :manager_id
|
||||||
ORDER BY created_at DESC, id DESC
|
ORDER BY created_at DESC
|
||||||
LIMIT :limit OFFSET :offset
|
LIMIT :limit OFFSET :offset
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
@ -567,7 +536,7 @@ _LIST_EMPLOYEES_ALL_SQL = text(
|
||||||
SELECT id, username, display_name, org_name, email, is_active, manager_id, created_at
|
SELECT id, username, display_name, org_name, email, is_active, manager_id, created_at
|
||||||
FROM tradein_users
|
FROM tradein_users
|
||||||
WHERE role = 'employee'
|
WHERE role = 'employee'
|
||||||
ORDER BY created_at DESC, id DESC
|
ORDER BY created_at DESC
|
||||||
LIMIT :limit OFFSET :offset
|
LIMIT :limit OFFSET :offset
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -16,14 +16,7 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||||
# (`app.core.rbac._propagate_authenticated_user` кодирует latin-1 с
|
# (`app.core.rbac._propagate_authenticated_user` кодирует latin-1 с
|
||||||
# errors="replace"), поэтому валидация формы обязательна на границе API,
|
# 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):
|
class QuotaStatusOut(BaseModel):
|
||||||
|
|
|
||||||
|
|
@ -1,127 +0,0 @@
|
||||||
-- 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;
|
|
||||||
|
|
@ -61,7 +61,6 @@ class _Store:
|
||||||
display_name: str | None = None,
|
display_name: str | None = None,
|
||||||
org_name: str | None = None,
|
org_name: str | None = None,
|
||||||
email: str | None = None,
|
email: str | None = None,
|
||||||
created_at: datetime | None = None,
|
|
||||||
) -> int:
|
) -> int:
|
||||||
uid = self._next_id
|
uid = self._next_id
|
||||||
self._next_id += 1
|
self._next_id += 1
|
||||||
|
|
@ -75,7 +74,7 @@ class _Store:
|
||||||
"org_name": org_name,
|
"org_name": org_name,
|
||||||
"email": email,
|
"email": email,
|
||||||
"is_active": is_active,
|
"is_active": is_active,
|
||||||
"created_at": created_at or datetime.now(UTC),
|
"created_at": datetime.now(UTC),
|
||||||
}
|
}
|
||||||
return uid
|
return uid
|
||||||
|
|
||||||
|
|
@ -260,10 +259,7 @@ class _FakeDB:
|
||||||
rows = [u for u in s.users.values() if u["role"] == "employee"]
|
rows = [u for u in s.users.values() if u["role"] == "employee"]
|
||||||
if "manager_id" in p:
|
if "manager_id" in p:
|
||||||
rows = [u for u in rows if u["manager_id"] == p["manager_id"]]
|
rows = [u for u in rows if u["manager_id"] == p["manager_id"]]
|
||||||
# Mirrors real SQL `ORDER BY created_at DESC, id DESC` — `id` tiebreak
|
rows = sorted(rows, key=lambda u: u["created_at"], reverse=True)
|
||||||
# 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))
|
offset, limit = p.get("offset", 0), p.get("limit", len(rows))
|
||||||
rows = rows[offset : offset + limit]
|
rows = rows[offset : offset + limit]
|
||||||
return _Result(
|
return _Result(
|
||||||
|
|
@ -519,26 +515,6 @@ def test_create_employee_non_ascii_username_422(client: TestClient, store: _Stor
|
||||||
assert resp.status_code == 422
|
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:
|
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("mgr_a", hash_password("Secret123!"), role="manager")
|
||||||
store.add_user("emp_dup", hash_password("Secret123!"), role="employee")
|
store.add_user("emp_dup", hash_password("Secret123!"), role="employee")
|
||||||
|
|
@ -811,51 +787,6 @@ def test_list_employees_limit_max_200(client: TestClient, store: _Store) -> None
|
||||||
assert resp.status_code == 422
|
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
|
# CSRF defense-in-depth — Origin/Referer check on state-changing team routes
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -915,65 +846,6 @@ def test_patch_employee_origin_mismatch_403(client: TestClient, store: _Store) -
|
||||||
assert store.users["emp_a"]["display_name"] != "hacked"
|
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
|
# GET /employees/{id}/history
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -9,19 +9,9 @@
|
||||||
* доомed 403 round-trip), но HTTPError 403 от самого списка сотрудников тоже
|
* доомed 403 round-trip), но HTTPError 403 от самого списка сотрудников тоже
|
||||||
* ловится — тот же defense-in-depth паттерн, что и `app/admin/audit/page.tsx`
|
* ловится — тот же defense-in-depth паттерн, что и `app/admin/audit/page.tsx`
|
||||||
* (прямой заход на URL не крашит страницу, даже если nav её уже прячет).
|
* (прямой заход на 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 { useEffect, useState } from "react";
|
import { useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
|
|
||||||
import "@/components/trade-in/trade-in.css";
|
import "@/components/trade-in/trade-in.css";
|
||||||
import { NoAccessScreen } from "@/components/auth/NoAccessScreen";
|
import { NoAccessScreen } from "@/components/auth/NoAccessScreen";
|
||||||
|
|
@ -36,11 +26,9 @@ import { teamErrorMessage, useEmployees } from "@/lib/team-api";
|
||||||
import { useMe } from "@/lib/useMe";
|
import { useMe } from "@/lib/useMe";
|
||||||
|
|
||||||
const PAGE_LIMIT = 50;
|
const PAGE_LIMIT = 50;
|
||||||
const TEAM_LOGIN_NEXT = `/login?next=${encodeURIComponent("/team")}`;
|
|
||||||
|
|
||||||
export default function TeamPage() {
|
export default function TeamPage() {
|
||||||
const meQ = useMe();
|
const meQ = useMe();
|
||||||
const router = useRouter();
|
|
||||||
// Вычисляем ДО useEmployees (не после) — иначе для employee/analyst/pilot
|
// Вычисляем ДО useEmployees (не после) — иначе для employee/analyst/pilot
|
||||||
// при прямом заходе на /team улетает обречённый GET (401/403) ещё до того,
|
// при прямом заходе на /team улетает обречённый GET (401/403) ещё до того,
|
||||||
// как ниже отрисуется role-gate. Порядок хуков не меняется — это просто
|
// как ниже отрисуется role-gate. Порядок хуков не меняется — это просто
|
||||||
|
|
@ -55,23 +43,6 @@ export default function TeamPage() {
|
||||||
const [quotaEmployee, setQuotaEmployee] = useState<Employee | null>(null);
|
const [quotaEmployee, setQuotaEmployee] = useState<Employee | null>(null);
|
||||||
const [historyEmployee, setHistoryEmployee] = 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 уже отрисовал
|
// Роль ещё не загружена — не решаем ничего, RouteGuard уже отрисовал
|
||||||
// страницу (401/403 верхнего уровня он ловит сам), здесь только role-gate.
|
// страницу (401/403 верхнего уровня он ловит сам), здесь только role-gate.
|
||||||
if (meQ.isPending) {
|
if (meQ.isPending) {
|
||||||
|
|
@ -89,30 +60,6 @@ export default function TeamPage() {
|
||||||
return <NoAccessScreen variant="user" />;
|
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 и
|
// Backend 403 регардлесс роли фронта (напр. session истекла между /me и
|
||||||
// /employees) — тот же fallback, что и в admin/audit.
|
// /employees) — тот же fallback, что и в admin/audit.
|
||||||
if (employeesQ.error instanceof HTTPError && employeesQ.error.status === 403) {
|
if (employeesQ.error instanceof HTTPError && employeesQ.error.status === 403) {
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,6 @@ import type {
|
||||||
TradeInEstimateInput,
|
TradeInEstimateInput,
|
||||||
} from "@/types/trade-in";
|
} from "@/types/trade-in";
|
||||||
import { HTTPError } from "@/lib/api";
|
import { HTTPError } from "@/lib/api";
|
||||||
import { isPathAllowed } from "@/lib/isPathAllowed";
|
|
||||||
import {
|
import {
|
||||||
useEstimate,
|
useEstimate,
|
||||||
useEstimateHistory,
|
useEstimateHistory,
|
||||||
|
|
@ -667,21 +666,6 @@ export default function TradeInV2Page() {
|
||||||
};
|
};
|
||||||
}, [me.data]);
|
}, [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
|
// Analog price pins for the 01 map, projected from the real estimate. No
|
||||||
// estimate → mapMarkers(null) → [] → ParamsPanel renders only the subject pin
|
// estimate → mapMarkers(null) → [] → ParamsPanel renders only the subject pin
|
||||||
// (Finding #2: never the static fixture price markers).
|
// (Finding #2: never the static fixture price markers).
|
||||||
|
|
@ -994,7 +978,6 @@ export default function TradeInV2Page() {
|
||||||
reports={reportsCount ?? 0}
|
reports={reportsCount ?? 0}
|
||||||
user={topNavUser}
|
user={topNavUser}
|
||||||
onLogout={() => logoutMutation.mutate()}
|
onLogout={() => logoutMutation.mutate()}
|
||||||
showTeamLink={showTeamNavItem}
|
|
||||||
/>
|
/>
|
||||||
</nav>
|
</nav>
|
||||||
<main
|
<main
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,6 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
|
|
||||||
import { API_BASE_URL } from "@/lib/api";
|
|
||||||
|
|
||||||
import { tokens } from "./tokens";
|
import { tokens } from "./tokens";
|
||||||
import { navLabels, version } from "./fixtures";
|
import { navLabels, version } from "./fixtures";
|
||||||
import { useSupportChat } from "./SupportChatContext";
|
import { useSupportChat } from "./SupportChatContext";
|
||||||
|
|
@ -37,12 +35,6 @@ interface TopNavProps {
|
||||||
user?: TopNavUser;
|
user?: TopNavUser;
|
||||||
// Sign out — page wires this to logout(); closes the menu first.
|
// Sign out — page wires this to logout(); closes the menu first.
|
||||||
onLogout?: () => void;
|
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).
|
// Neutral fallback when the user prop is absent (loading / unauthenticated).
|
||||||
|
|
@ -65,24 +57,6 @@ const menuItemStyle: CSSProperties = {
|
||||||
transition: "background .12s",
|
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
|
// Профиль / Настройки have no pages yet — render them dimmed and
|
||||||
// non-interactive (no hover class, default cursor) so they read as disabled.
|
// 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
|
// «Помощь» used to be in this group too, then linked out to the Telegram
|
||||||
|
|
@ -100,7 +74,6 @@ export default function TopNav({
|
||||||
reports = 0,
|
reports = 0,
|
||||||
user,
|
user,
|
||||||
onLogout,
|
onLogout,
|
||||||
showTeamLink = false,
|
|
||||||
}: TopNavProps) {
|
}: TopNavProps) {
|
||||||
const [userOpen, setUserOpen] = useState(false);
|
const [userOpen, setUserOpen] = useState(false);
|
||||||
const { openChat } = useSupportChat();
|
const { openChat } = useSupportChat();
|
||||||
|
|
@ -516,28 +489,6 @@ export default function TopNav({
|
||||||
Помощь
|
Помощь
|
||||||
</button>
|
</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
|
<div
|
||||||
style={{
|
style={{
|
||||||
height: "1px",
|
height: "1px",
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue