Compare commits

..

16 commits

Author SHA1 Message Date
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
1ed0338b95 fix(tradein/ui): пагинация на границе страницы + запрос списка только для admin/manager (#2556)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 14s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 14s
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 1m30s
EmployeeTable: убран ранний return при пустом списке — на offset>0 (ровно
50/100/150 сотрудников) пейджер и кнопка «Назад» теперь остаются доступны;
текст различает «сотрудников вообще нет» (offset=0) и «страница за концом
списка» (offset>0).

team/page.tsx: useEmployees получает enabled=isAllowedRole, вычисленный ДО
вызова хука — прямой заход employee/analyst/pilot на /team больше не шлёт
обречённый GET до отрисовки role-gate.
2026-07-30 22:12:18 +03: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
f112a4affb feat(tradein/ui): team-дашборд менеджера — сотрудники, квоты, история (#2556)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 9s
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 1m9s
Единая страница /team для ролей admin/manager (backend сам скоупит список
по org-изоляции) — таблица сотрудников с пагинацией, создание сотрудника
с ручным паролем, изменение месячной квоты + сброс пароля одним PATCH,
drawer с историей оценок. Nav-пункт «Команда» в Topbar виден только
admin/manager (доп. roleGate поверх isPathAllowed — legacy analyst-роль
иначе тоже прошла бы path-фильтр).
2026-07-30 22:02:41 +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
4128564341 Merge pull request 'feat(tradein/team): team-management API — employees CRUD, quotas, stats (#2554)' (#2563) from feat/tradein-team-api into main
All checks were successful
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) Successful in 1m53s
Deploy Trade-In / build-backend (push) Successful in 1m42s
Deploy Trade-In / deploy (push) Successful in 1m16s
2026-07-30 18:38:26 +00:00
bot-backend
4ac3971061 fix(tradein/team): revoke сессий при смене пароля + Origin-check + батч-квоты (#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 1m56s
Deep-review PR #2563:
- HIGH: PATCH new_password не ревокал старые сессии сотрудника — sliding-refresh
  продлевал скомпрометированную/чужую сессию бесконечно. Теперь revoke_user_sessions
  вызывается при is_active=False ИЛИ new_password (self-lockout невозможен —
  _fetch_employee_row фильтрует role='employee').
- Medium1: Origin/Referer-check (защита CSRF) на POST/PATCH team-роутах против
  settings.cors_origins; оба заголовка отсутствуют (curl-смоук внутри контейнера) —
  пропускаем, не ломаем прод-смоук.
- Medium2: батч-квота для GET /employees (_batch_quota_status) — было 2N+3 SQL-запросов
  на N сотрудников, стало 5 плоско (замерено: N=10 -> 5 запросов). Добавлена пагинация
  limit(default 50, max 200)/offset.
- Заодно: статические WHERE-ветки вместо f-string-сборки в list_employees;
  monthly_limit теперь сбрасывает account_quota_overrides.unlimited=false и не
  затирает существующий note.
2026-07-30 21:23:32 +03:00
acba900f21 Merge pull request 'feat(tradein/ui): login-форма + session-aware RouteGuard (#2555)' (#2562) from feat/tradein-login-ui into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 12s
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 1m59s
Deploy Trade-In / deploy (push) Successful in 53s
2026-07-30 18:10:46 +00:00
bot-backend
e6d68c349e fix(tradein/ui): харденинг sanitizeNext + сохранение query в next (#2555)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 9s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m1s
PR #2562 review, 3 однострочника:
1. sanitizeNext обходился: WHATWG URL-парсер (router.push) вырезает ASCII
   tab/CR/LF из ВСЕЙ строки перед парсингом, так что "/\t//evil" проходил
   regex (позиция 1 — таб, не "/"/"\\"), а после навигации резолвился в
   protocol-relative "//evil" → чужой origin. Теперь сначала strip
   [\t\r\n], потом валидация — regex видит ту же строку, что увидит парсер.
2. next=/login (или /login?...) кидал юзера обратно на форму входа
   (RouteGuard не гейтит /login) — dead-end. Фолбэк на "/".
3. RouteGuard брал next= только из usePathname(), без query — сессия,
   истёкшая на deep-link (/v2?id=<uuid>), теряла отчёт после релогина.
   Добавлен window.location.search в next (effect всегда client-side).
2026-07-30 20:57:54 +03:00
bot-backend
712c56f456 feat(tradein/team): team-management API — employees CRUD, quotas, stats (#2554)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 10s
CI / changes (pull_request) Successful in 8s
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 1m47s
Session-only identity (current_team_actor, admin|manager) поверх tradein_users/
tradein_sessions (#2552 foundation). Org-изоляция manager <-> employee через
manager_id: чужой/несуществующий employee_id -> 404 (не 403 — не палим
существование), POST с чужим manager_id в теле от manager игнорируется
(принудительно свой id). Квота — upsert в account_quota_overrides (существующий
паттерн, без правки account_quota.py). История оценок — user_events LEFT JOIN
trade_in_estimates. Team-события (employee_created/blocked/unblocked/
password_reset/quota_changed) без пароля в payload.
2026-07-30 20:51:31 +03:00
bot-backend
15d506b7dd feat(tradein/ui): login-форма + session-aware RouteGuard (#2555)
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 1m4s
POST /api/v1/auth/login/logout уже в main (DB-backed session, httponly
cookie tradein_session). Фронт: /login-форма (username+password, ошибки
401/429 по-русски, next= redirect с open-redirect guard), RouteGuard
редиректит на /login при 401 вместо NoAccessScreen variant=session
(prod-only, dev-режим без Caddy не трогаем), useLogout хук чистит
/me-кэш и уходит на /login. Role расширена admin|manager|employee (новые)
+ pilot|analyst|expired (legacy dual-mode resolver на бэке).
2026-07-30 20:46:33 +03:00
d3e0aa296c Merge pull request 'feat(tradein/auth): auth-core — login/logout, sessions, dual-mode rbac (#2552)' (#2561) from feat/tradein-auth-core into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 14s
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 1m23s
Deploy Trade-In / build-backend (push) Successful in 1m3s
Deploy Trade-In / deploy (push) Successful in 1m27s
2026-07-30 17:19:44 +00:00
18 changed files with 3650 additions and 11 deletions

View file

@ -0,0 +1,663 @@
"""Team-management API — CRUD сотрудников, квоты, история (#2554, эпик #2549).
Mounted at `/api/v1/team`; через Caddy `uri strip_prefix /trade-in` это
`/trade-in/api/v1/team/*` снаружи. `app.services.auth_session.DB_ROLE_PATHS`
уже закладывает `/api/v1/team/**` в scope роли `manager` (и `admin` через `/**`)
для `rbac_guard` (см. `app.core.rbac`) этот роутер добавляет ВТОРОЙ,
более узкий барьер именно на identity:
- `current_team_actor` резолвит юзера ТОЛЬКО из session-cookie
(`app.services.auth_session.get_session_user`). Legacy
`X-Authenticated-User` (Caddy trusted-header, dual-mode) НЕ принимается
здесь team-API новый, не участвует в переходном dual-mode auth. Без
валидной cookie 401, даже если `rbac_guard` пропустил запрос по
legacy-заголовку (напр. admin через roles.yaml).
- Роль должна быть `admin` или `manager` иначе 403.
Org-изоляция (главный инвариант фичи): manager видит/меняет ТОЛЬКО своих
employee (`tradein_users.manager_id = actor.user_id`). Чужой/несуществующий
employee_id 404 (НЕ 403) не подтверждаем/не опровергаем существование
чужого сотрудника перед manager'ом. См. `_authorize_employee`.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Annotated, Any
from urllib.parse import urlparse
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy import text
from sqlalchemy.engine import RowMapping
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.core.auth import get_role
from app.core.config import settings
from app.core.db import get_db
from app.core.password import hash_password
from app.schemas.team import (
EmployeeCreateRequest,
EmployeeHistoryEntry,
EmployeeOut,
EmployeeUpdateRequest,
QuotaStatusOut,
)
from app.services import account_quota
from app.services.auth_session import get_session_user, revoke_user_sessions
from app.services.user_events import schedule_event
logger = logging.getLogger(__name__)
router = APIRouter()
@dataclass
class TeamActor:
"""Резолвленный из session-cookie актёр team-API — admin или manager."""
user_id: int
username: str
role: str # "admin" | "manager"
async def current_team_actor(
request: Request,
db: Annotated[Session, Depends(get_db)],
) -> TeamActor:
"""Dependency: session-only identity, роль admin|manager, иначе 401/403.
Намеренно НЕ читает `X-Authenticated-User` см. модульный docstring.
"""
token = request.cookies.get(settings.session_cookie_name)
if not token:
raise HTTPException(status_code=401, detail="valid session required")
try:
session_user = get_session_user(db, token)
except Exception:
logger.exception("team: session lookup failed")
raise HTTPException(status_code=401, detail="valid session required") from None
if session_user is None:
raise HTTPException(status_code=401, detail="valid session required")
role = session_user["role"]
if role not in ("admin", "manager"):
raise HTTPException(status_code=403, detail="admin or manager role required")
return TeamActor(
user_id=session_user["user_id"],
username=session_user["username"],
role=role,
)
def _origin_host_allowed(candidate: str) -> bool:
"""True если scheme://netloc *candidate* совпадает с одним из `settings.cors_origins`.
`cors_origins` уже является источником правды для «какие origin'ы это наш
фронт» (см. CORSMiddleware в app/main.py, ENV CORS_ORIGINS) переиспользуем
его вместо нового хардкода."""
try:
parsed = urlparse(candidate)
except ValueError:
return False
if not parsed.scheme or not parsed.netloc:
return False
origin = f"{parsed.scheme}://{parsed.netloc}"
return origin in settings.cors_origins
def _require_same_origin(request: Request) -> None:
"""CSRF defense-in-depth (issue #2554 DoD) для state-changing team-роутов
(POST/PATCH): `Origin` (или `Referer` как fallback) обязан матчить один из
`settings.cors_origins`, иначе 403.
Оба заголовка отсутствуют ПРОПУСКАЕМ (не 403). Причина: это единственный
надёжный сигнал non-browser клиента в этом стеке curl-смоуки внутри
контейнера (см. `.claude/rules/tradein.md` "Тестировать HTTP только ВНУТРИ
контейнера", `docker exec tradein-backend curl ...`) не шлют ни один из этих
заголовков, а реальный браузер (fetch/XHR/form) ВСЕГДА прикладывает Origin
на unsafe-методах (POST/PATCH) так что "оба отсутствуют" практически
невозможно для настоящего кросс-сайтового CSRF через браузер. Session-cookie
уже стоит на `SameSite=Lax` (см. `app.api.v1.auth.login`) это первый рубеж
против CSRF, Origin-check второй.
"""
candidate = request.headers.get("origin") or request.headers.get("referer")
if candidate is None:
return
if not _origin_host_allowed(candidate):
logger.warning(
"team: Origin/Referer mismatch %r on %s — possible CSRF", candidate, request.url.path
)
raise HTTPException(status_code=403, detail="origin not allowed")
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _fetch_employee_row(db: Session, employee_id: int) -> RowMapping | None:
return (
db.execute(
text(
"""
SELECT id, username, display_name, org_name, email, is_active,
manager_id, created_at
FROM tradein_users
WHERE id = :id AND role = 'employee'
"""
),
{"id": employee_id},
)
.mappings()
.fetchone()
)
def _authorize_employee(actor: TeamActor, row: RowMapping | None) -> RowMapping:
"""404 (НЕ 403) если сотрудник не найден ИЛИ принадлежит другому manager'у.
Org-изоляция: manager может видеть/менять только `manager_id == actor.user_id`.
404 вместо 403 не палим существование чужого employee_id.
"""
if row is None:
raise HTTPException(status_code=404, detail="employee not found")
if actor.role == "manager" and row["manager_id"] != actor.user_id:
raise HTTPException(status_code=404, detail="employee not found")
return row
def _upsert_quota_override(
db: Session, username: str, monthly_limit: int, actor_username: str
) -> None:
"""Upsert персонального лимита. Явная установка monthly_limit — сигнал "хочу
numeric-квоту", поэтому ВСЕГДА сбрасывает `unlimited=false` (иначе лимит может
молча не применяться прежний unlimited-грант выигрывал бы у нового limit).
`note` НЕ затирается, если уже задан (`COALESCE`): не перезаписываем
человеко-читаемую причину прошлого гранта (напр. "пилот, грант ...") молча
сгенерированной строкой; note проставляется только при первом upsert записи.
"""
db.execute(
text(
"""
INSERT INTO account_quota_overrides (username, monthly_limit, unlimited, note)
VALUES (:username, CAST(:monthly_limit AS integer), false, :note)
ON CONFLICT (username) DO UPDATE SET
monthly_limit = EXCLUDED.monthly_limit,
unlimited = false,
note = COALESCE(account_quota_overrides.note, EXCLUDED.note),
updated_at = now()
"""
),
{
"username": username,
"monthly_limit": monthly_limit,
"note": f"team-api: set by {actor_username}",
},
)
def _batch_quota_status(db: Session, usernames: list[str]) -> dict[str, dict[str, Any]]:
"""Батч-версия `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`
(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 {}
overrides = (
db.execute(
text(
"""
SELECT username, monthly_limit, unlimited
FROM account_quota_overrides
WHERE username = ANY(CAST(:usernames AS text[]))
"""
),
{"usernames": usernames},
)
.mappings()
.all()
)
override_by_username = {r["username"]: r for r in overrides}
period = account_quota.current_period()
usage_rows = (
db.execute(
text(
"""
SELECT username, used
FROM account_estimate_usage
WHERE username = ANY(CAST(:usernames AS text[])) AND period_month = :period
"""
),
{"usernames": usernames, "period": period},
)
.mappings()
.all()
)
used_by_username = {r["username"]: r["used"] for r in usage_rows}
result: dict[str, dict[str, Any]] = {}
for username in usernames:
override = override_by_username.get(username)
try:
role = get_role(username)
except KeyError:
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
else account_quota.MONTHLY_LIMIT
)
used = used_by_username.get(username, 0)
if unlimited:
result[username] = {
"limit": limit,
"used": used,
"remaining": limit,
"unlimited": True,
}
else:
remaining = max(0, limit - max(0, used))
result[username] = {
"limit": limit,
"used": used,
"remaining": remaining,
"unlimited": False,
}
return result
def _employee_out(row: RowMapping, quota: dict[str, Any]) -> EmployeeOut:
return EmployeeOut(
id=row["id"],
username=row["username"],
display_name=row["display_name"],
org_name=row["org_name"],
email=row["email"],
is_active=row["is_active"],
manager_id=row["manager_id"],
created_at=row["created_at"],
quota=QuotaStatusOut(**quota),
)
# ---------------------------------------------------------------------------
# POST /employees
# ---------------------------------------------------------------------------
@router.post("/employees", response_model=EmployeeOut, status_code=201)
async def create_employee(
body: EmployeeCreateRequest,
actor: Annotated[TeamActor, Depends(current_team_actor)],
db: Annotated[Session, Depends(get_db)],
_origin_check: Annotated[None, Depends(_require_same_origin)],
) -> EmployeeOut:
"""Создать сотрудника. Роль всегда `employee`.
manager_id: для actor.role == manager принудительно свой id (любое
значение из тела ИГНОРИРУЕТСЯ, org-изоляция инвариант #2554). Для
actor.role == admin опционально из тела, валидируется что указанный id
существует и role='manager' (иначе 422).
"""
existing = db.execute(
text("SELECT id FROM tradein_users WHERE username = :u"),
{"u": body.username},
).fetchone()
if existing is not None:
raise HTTPException(status_code=409, detail="username already exists")
try:
password_hash = hash_password(body.password)
except ValueError as e:
raise HTTPException(status_code=422, detail=str(e)) from None
manager_id: int | None
if actor.role == "manager":
# Инвариант org-изоляции: manager не может создать сотрудника под
# чужим manager_id — любое значение из тела игнорируется молча.
manager_id = actor.user_id
else:
manager_id = body.manager_id
if manager_id is not None:
mgr = db.execute(
text("SELECT id FROM tradein_users WHERE id = :id AND role = 'manager'"),
{"id": manager_id},
).fetchone()
if mgr is None:
raise HTTPException(
status_code=422,
detail="manager_id does not reference an existing manager",
)
try:
row = (
db.execute(
text(
"""
INSERT INTO tradein_users
(username, password_hash, role, manager_id, display_name, org_name,
email, is_active)
VALUES
(:username, :password_hash, 'employee', :manager_id, :display_name,
:org_name, :email, true)
RETURNING id, username, display_name, org_name, email, is_active,
manager_id, created_at
"""
),
{
"username": body.username,
"password_hash": password_hash,
"manager_id": manager_id,
"display_name": body.display_name,
"org_name": body.org_name,
"email": body.email,
},
)
.mappings()
.fetchone()
)
except IntegrityError:
# TOCTOU: два конкурентных POST с одинаковым username между pre-check
# выше и этим INSERT — UNIQUE-констрейнт на tradein_users.username ловит.
db.rollback()
raise HTTPException(status_code=409, detail="username already exists") from None
assert row is not None # RETURNING на успешный INSERT всегда отдаёт строку
if body.monthly_limit is not None:
_upsert_quota_override(db, body.username, body.monthly_limit, actor.username)
db.commit()
schedule_event(
event_type="employee_created",
username=actor.username,
payload={
"employee_id": row["id"],
"employee_username": row["username"],
"manager_id": manager_id,
},
)
quota = account_quota.get_status(db, body.username)
return _employee_out(row, quota)
# ---------------------------------------------------------------------------
# PATCH /employees/{id}
# ---------------------------------------------------------------------------
@router.patch("/employees/{employee_id}", response_model=EmployeeOut)
async def update_employee(
employee_id: int,
body: EmployeeUpdateRequest,
actor: Annotated[TeamActor, Depends(current_team_actor)],
db: Annotated[Session, Depends(get_db)],
_origin_check: Annotated[None, Depends(_require_same_origin)],
) -> EmployeeOut:
"""Частичное обновление сотрудника — block/unblock, лимит, профиль, пароль.
manager может патчить ТОЛЬКО своих (manager_id == actor.user_id), иначе 404.
При is_active=False ИЛИ смене пароля (new_password) обязательно revoke всех
сессий (HIGH, deep-review PR #2563): без этого блокировка/reset не подействуют
до истечения TTL текущей сессии сотрудника хуже того, sliding-refresh
(`app.services.auth_session.get_session_user`) продлевает `expires_at` на
КАЖДОМ запросе, так что скомпрометированная/чужая сессия живёт неограниченно
долго, а не «до TTL». `revoke_user_sessions` сам называет смену пароля своим
use-case см. его докстринг.
"""
row = _fetch_employee_row(db, employee_id)
row = _authorize_employee(actor, row)
new_password_hash: str | None = None
if body.new_password is not None:
try:
new_password_hash = hash_password(body.new_password)
except ValueError as e:
raise HTTPException(status_code=422, detail=str(e)) from None
db.execute(
text(
"""
UPDATE tradein_users
SET display_name = COALESCE(:display_name, display_name),
org_name = COALESCE(:org_name, org_name),
email = COALESCE(:email, email),
is_active = COALESCE(CAST(:is_active AS boolean), is_active),
password_hash = COALESCE(:password_hash, password_hash),
updated_at = now()
WHERE id = :id
"""
),
{
"display_name": body.display_name,
"org_name": body.org_name,
"email": body.email,
"is_active": body.is_active,
"password_hash": new_password_hash,
"id": employee_id,
},
)
if body.monthly_limit is not None:
_upsert_quota_override(db, row["username"], body.monthly_limit, actor.username)
if body.is_active is False or body.new_password is not None:
# Обязательно ПОСЛЕ UPDATE, ДО финального commit — revoke_user_sessions
# коммитит сам (см. app.services.auth_session), это флашит и наш
# предшествующий UPDATE/quota-upsert в той же сессии. Self-lockout
# невозможен: _fetch_employee_row фильтрует role='employee', actor
# (admin|manager) никогда не может патчить сам себя через этот роут.
revoke_user_sessions(db, employee_id)
db.commit()
changed_profile_fields = [
f
for f, v in (
("display_name", body.display_name),
("org_name", body.org_name),
("email", body.email),
)
if v is not None
]
if changed_profile_fields:
schedule_event(
event_type="employee_updated",
username=actor.username,
payload={
"employee_id": employee_id,
"employee_username": row["username"],
"fields": changed_profile_fields,
},
)
if body.new_password is not None:
schedule_event(
event_type="employee_password_reset",
username=actor.username,
payload={"employee_id": employee_id, "employee_username": row["username"]},
)
if body.is_active is not None:
schedule_event(
event_type="employee_blocked" if body.is_active is False else "employee_unblocked",
username=actor.username,
payload={"employee_id": employee_id, "employee_username": row["username"]},
)
if body.monthly_limit is not None:
schedule_event(
event_type="quota_changed",
username=actor.username,
payload={
"employee_id": employee_id,
"employee_username": row["username"],
"monthly_limit": body.monthly_limit,
},
)
updated_row = _fetch_employee_row(db, employee_id)
assert updated_row is not None # только что успешно обновили эту же строку
quota = account_quota.get_status(db, updated_row["username"])
return _employee_out(updated_row, quota)
# ---------------------------------------------------------------------------
# GET /employees
# ---------------------------------------------------------------------------
# Два статических варианта 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, id DESC
LIMIT :limit OFFSET :offset
"""
)
_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, id DESC
LIMIT :limit OFFSET :offset
"""
)
@router.get("/employees", response_model=list[EmployeeOut])
async def list_employees(
actor: Annotated[TeamActor, Depends(current_team_actor)],
db: Annotated[Session, Depends(get_db)],
manager_id: Annotated[int | None, Query()] = None,
limit: Annotated[int, Query(ge=1, le=200)] = 50,
offset: Annotated[int, Query(ge=0)] = 0,
) -> list[EmployeeOut]:
"""Список сотрудников. manager видит только своих; admin — всех, опц. ?manager_id=.
Квота ОДИН батч-запрос на всю страницу (`_batch_quota_status`), не N+1
(Medium2, review PR #2563: было 2N+3 SQL-запросов на N сотрудников).
"""
if actor.role == "manager":
rows = (
db.execute(
_LIST_EMPLOYEES_BY_MANAGER_SQL,
{"manager_id": actor.user_id, "limit": limit, "offset": offset},
)
.mappings()
.all()
)
elif manager_id is not None:
rows = (
db.execute(
_LIST_EMPLOYEES_BY_MANAGER_SQL,
{"manager_id": manager_id, "limit": limit, "offset": offset},
)
.mappings()
.all()
)
else:
rows = (
db.execute(_LIST_EMPLOYEES_ALL_SQL, {"limit": limit, "offset": offset}).mappings().all()
)
quota_by_username = _batch_quota_status(db, [row["username"] for row in rows])
return [_employee_out(row, quota_by_username[row["username"]]) for row in rows]
# ---------------------------------------------------------------------------
# GET /employees/{id}/history
# ---------------------------------------------------------------------------
@router.get("/employees/{employee_id}/history", response_model=list[EmployeeHistoryEntry])
async def employee_history(
employee_id: int,
actor: Annotated[TeamActor, Depends(current_team_actor)],
db: Annotated[Session, Depends(get_db)],
limit: Annotated[int, Query(ge=1, le=200)] = 50,
offset: Annotated[int, Query(ge=0)] = 0,
) -> list[EmployeeHistoryEntry]:
"""История оценок сотрудника (адрес/дата/результат) — из `user_events`,
LEFT JOIN `trade_in_estimates` за фактическим результатом.
Та же org-проверка что и в PATCH: чужой employee_id 404.
"""
row = _fetch_employee_row(db, employee_id)
row = _authorize_employee(actor, row)
rows = (
db.execute(
text(
"""
SELECT
CAST(ue.estimate_id AS text) AS estimate_id,
ue.payload ->> 'address' AS address,
ue.payload ->> 'area_m2' AS area_m2,
ue.payload ->> 'rooms' AS rooms,
te.median_price,
te.confidence,
te.n_analogs,
ue.created_at
FROM user_events ue
LEFT JOIN trade_in_estimates te ON te.id = ue.estimate_id
WHERE ue.username = :username AND ue.event_type = 'estimate_request'
ORDER BY ue.created_at DESC
LIMIT :limit OFFSET :offset
"""
),
{"username": row["username"], "limit": limit, "offset": offset},
)
.mappings()
.all()
)
return [EmployeeHistoryEntry.model_validate(dict(r)) for r in rows]

View file

@ -31,6 +31,7 @@ from app.api.v1 import (
me, me,
search, search,
support, support,
team,
trade_in, trade_in,
) )
from app.core.config import settings from app.core.config import settings
@ -183,3 +184,4 @@ app.include_router(support.router, prefix="/api/v1/trade-in", tags=["trade-in-su
app.include_router(buildings.router, prefix="/api/v1/buildings", tags=["buildings"]) app.include_router(buildings.router, prefix="/api/v1/buildings", tags=["buildings"])
app.include_router(search.router, prefix="/api/v1", tags=["search"]) app.include_router(search.router, prefix="/api/v1", tags=["search"])
app.include_router(me.router, prefix="/api/v1", tags=["me"]) app.include_router(me.router, prefix="/api/v1", tags=["me"])
app.include_router(team.router, prefix="/api/v1/team", tags=["team"])

View file

@ -0,0 +1,110 @@
"""Pydantic-схемы team-management API (#2554, эпик #2549).
CRUD сотрудников (`tradein_users.role = 'employee'`), квоты, история оценок.
Org-изоляция (manager видит/меняет только своих employee) реализована в
`app.api.v1.team`, эти схемы только форма запросов/ответов.
"""
from __future__ import annotations
import re
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field, field_validator
# ASCII-only — не-ASCII username ломает downstream identity-пропагацию
# (`app.core.rbac._propagate_authenticated_user` кодирует latin-1 с
# errors="replace"), поэтому валидация формы обязательна на границе API,
# а не только на уровне БД.
#
# `\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):
"""Статус месячной квоты оценок — вложен в `EmployeeOut`."""
model_config = ConfigDict(from_attributes=True)
limit: int
used: int
remaining: int
unlimited: bool
class EmployeeCreateRequest(BaseModel):
"""`POST /employees` — создать сотрудника. Роль всегда `employee` (не в теле)."""
username: str
password: str
display_name: str | None = None
org_name: str | None = None
email: str | None = None
monthly_limit: int | None = Field(default=None, ge=1)
# Только для actor.role == admin — опциональная привязка к конкретному manager.
# Для actor.role == manager это поле ИГНОРИРУЕТСЯ (принудительно свой id) —
# см. app.api.v1.team.create_employee.
manager_id: int | None = None
@field_validator("username")
@classmethod
def _validate_username(cls, v: str) -> str:
if not _USERNAME_RE.match(v):
raise ValueError(
"username must be 3-64 ASCII chars: letters, digits, dot, underscore, hyphen"
)
return v
class EmployeeUpdateRequest(BaseModel):
"""`PATCH /employees/{id}` — частичное обновление, все поля опциональны."""
is_active: bool | None = None
monthly_limit: int | None = Field(default=None, ge=1)
display_name: str | None = None
org_name: str | None = None
email: str | None = None
new_password: str | None = None
class EmployeeOut(BaseModel):
"""Одна строка в `GET /employees` + ответ `POST`/`PATCH /employees/{id}`."""
model_config = ConfigDict(from_attributes=True)
id: int
username: str
display_name: str | None = None
org_name: str | None = None
email: str | None = None
is_active: bool
manager_id: int | None = None
created_at: datetime
quota: QuotaStatusOut
class EmployeeHistoryEntry(BaseModel):
"""Одна строка истории оценок сотрудника — `GET /employees/{id}/history`.
Источник `user_events` (event_type='estimate_request', паттерн
`app.api.v1.audit.account_drilldown`), LEFT JOIN на `trade_in_estimates`
за фактическим результатом (median_price/confidence/n_analogs) join
может не сматчиться (старая запись без estimate_id / оценка insufficient_data),
поэтому все result-поля nullable.
"""
model_config = ConfigDict(from_attributes=True)
estimate_id: str | None = None
address: str | None = None
area_m2: str | None = None
rooms: str | None = None
median_price: int | None = None
confidence: str | None = None
n_analogs: int | None = None
created_at: datetime

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;

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,288 @@
"use client";
/**
* #2555 (эпик #2549) login-форма для новой DB-backed session auth
* (POST /api/v1/auth/login, см. `tradein-mvp/backend/app/api/v1/auth.py`).
*
* Не гейтится RouteGuard'ом (см. `components/auth/RouteGuard.tsx`
* `isLoginPage` bypass) иначе редирект-петля: 401 от /me на /login тоже
* пытался бы увести на /login.
*
* `next=` читаем вручную из `window.location.search` (SSR-guard), а НЕ
* через `next/navigation` `useSearchParams()` тот форсит Suspense boundary
* и ломает `next build` (см. `app/v2/page.tsx: readUrlId` тот же паттерн,
* уже принятый в этом репо).
*/
import { useState } from "react";
import type { CSSProperties, FormEvent } from "react";
import { useRouter } from "next/navigation";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiFetch, HTTPError } from "@/lib/api";
import { ME_QUERY_KEY } from "@/lib/useMe";
interface LoginInput {
username: string;
password: string;
}
async function loginRequest(input: LoginInput): Promise<void> {
await apiFetch<{ ok: boolean }>("/api/v1/auth/login", {
method: "POST",
body: JSON.stringify(input),
});
}
function readNextParam(): string | null {
if (typeof window === "undefined") return null;
return new URLSearchParams(window.location.search).get("next");
}
/**
* Open-redirect guard: принимаем только внутренний путь, начинающийся
* ровно с одного "/" не "//host" (protocol-relative URL) и не "/\host"
* (браузеры местами трактуют backslash как forward slash в URL-парсинге).
*
* PR #2562 review finding 1: WHATWG URL-парсер (который `router.push`
* использует под капотом) убирает ВСЕ ASCII tab/CR/LF из строки ПЕРЕД
* парсингом так `"/\t//evil"` для наивного regex выглядит как безопасный
* путь с одним leading slash (символ в позиции 1 таб, не "/" и не "\"),
* а после навигации превращается в `"//evil"` (protocol-relative чужой
* origin). Убираем те же символы ДО валидации, чтобы regex видел ту же
* строку, что увидит парсер.
*
* PR #2562 review finding 2: `next=/login` (или `/login?...`) после успешного
* логина кидал бы юзера обратно на форму входа (RouteGuard не гейтит
* `/login`) dead-end. Фолбэк на "/" в этом случае.
*/
function sanitizeNext(next: string | null): string {
if (!next) return "/";
const cleaned = next.replace(/[\t\r\n]/g, "");
if (!/^\/(?!\/|\\)/.test(cleaned)) return "/";
if (
cleaned === "/login" ||
cleaned.startsWith("/login?") ||
cleaned.startsWith("/login#")
) {
return "/";
}
return cleaned;
}
function loginErrorMessage(error: unknown): string {
if (error instanceof HTTPError) {
if (error.status === 401) return "Неверный логин или пароль";
if (error.status === 429) {
return "Слишком много попыток. Попробуйте через несколько минут";
}
}
return "Не удалось войти. Проверьте подключение и попробуйте ещё раз";
}
const cardStyle: CSSProperties = {
background: "var(--bg-card)",
border: "1px solid var(--border-card)",
borderRadius: 12,
padding: "32px 28px",
maxWidth: 380,
width: "100%",
};
const labelStyle: CSSProperties = {
display: "block",
fontSize: 12,
fontWeight: 500,
color: "var(--fg-secondary)",
marginBottom: 6,
};
const inputStyle: CSSProperties = {
width: "100%",
boxSizing: "border-box",
height: 40,
padding: "0 12px",
border: "1px solid var(--border-card)",
borderRadius: 8,
fontSize: 14,
color: "var(--fg-primary)",
background: "var(--bg-card)",
fontFamily: "inherit",
};
export default function LoginPage() {
const router = useRouter();
const queryClient = useQueryClient();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const loginMutation = useMutation({
mutationFn: loginRequest,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ME_QUERY_KEY });
router.push(sanitizeNext(readNextParam()));
},
});
function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
if (loginMutation.isPending) return;
loginMutation.mutate({ username: username.trim(), password });
}
return (
<main
style={{
minHeight: "100vh",
background: "var(--bg-app)",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: 24,
fontFamily: "Inter, -apple-system, 'Segoe UI', system-ui, sans-serif",
}}
>
<style>{`
@keyframes login-spin { to { transform: rotate(360deg); } }
.login-spinner { animation: login-spin .7s linear infinite; }
.login-input:focus-visible {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-soft);
}
.login-submit:hover:not(:disabled) { background: var(--accent-hover); }
`}</style>
<form onSubmit={handleSubmit} style={cardStyle} aria-label="Вход в Меру">
<h1
style={{
margin: "0 0 8px",
fontSize: 22,
fontWeight: 600,
color: "var(--fg-primary)",
lineHeight: 1.25,
}}
>
Вход
</h1>
<p
style={{
margin: "0 0 24px",
fontSize: 14,
color: "var(--fg-secondary)",
lineHeight: 1.5,
}}
>
Войдите, чтобы продолжить работу с Мерой.
</p>
<div style={{ marginBottom: 16 }}>
<label style={labelStyle} htmlFor="login-username">
Логин
</label>
<input
id="login-username"
name="username"
type="text"
autoComplete="username"
required
autoFocus
className="login-input"
style={inputStyle}
value={username}
onChange={(e) => setUsername(e.target.value)}
disabled={loginMutation.isPending}
/>
</div>
<div style={{ marginBottom: 24 }}>
<label style={labelStyle} htmlFor="login-password">
Пароль
</label>
<input
id="login-password"
name="password"
type="password"
autoComplete="current-password"
required
className="login-input"
style={inputStyle}
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={loginMutation.isPending}
/>
</div>
{loginMutation.isError ? (
<p
role="alert"
style={{
margin: "0 0 16px",
padding: "8px 12px",
borderRadius: 8,
background: "var(--danger-soft)",
color: "var(--danger)",
fontSize: 13,
lineHeight: 1.4,
}}
>
{loginErrorMessage(loginMutation.error)}
</p>
) : null}
<button
type="submit"
className="login-submit"
disabled={loginMutation.isPending}
style={{
width: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 8,
background: "var(--accent)",
color: "#FFFFFF",
border: "none",
borderRadius: 8,
padding: "10px 16px",
fontSize: 14,
fontWeight: 500,
cursor: loginMutation.isPending ? "wait" : "pointer",
opacity: loginMutation.isPending ? 0.75 : 1,
fontFamily: "inherit",
}}
>
{loginMutation.isPending ? (
<>
<svg
className="login-spinner"
width={16}
height={16}
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
>
<circle
cx="12"
cy="12"
r="9"
stroke="rgba(255,255,255,0.35)"
strokeWidth="3"
/>
<path
d="M21 12a9 9 0 0 0-9-9"
stroke="#FFFFFF"
strokeWidth="3"
strokeLinecap="round"
/>
</svg>
Входим
</>
) : (
"Войти"
)}
</button>
</form>
</main>
);
}

View file

@ -0,0 +1,127 @@
"use client";
/**
* Team-дашборд менеджера (#2556, эпик #2549) единая страница для ролей
* `admin` и `manager` (backend сам скоупит список по org-изоляции, см.
* `app/api/v1/team.py::list_employees`), UI не дублируется под роли.
*
* Доступ: `useMe().role` проверяется на клиенте ДО первого запроса (не ждём
* доомed 403 round-trip), но HTTPError 403 от самого списка сотрудников тоже
* ловится тот же defense-in-depth паттерн, что и `app/admin/audit/page.tsx`
* (прямой заход на URL не крашит страницу, даже если nav её уже прячет).
*/
import { useState } from "react";
import "@/components/trade-in/trade-in.css";
import { NoAccessScreen } from "@/components/auth/NoAccessScreen";
import { CreateEmployeeForm } from "@/components/team/CreateEmployeeForm";
import { EmployeeHistoryDrawer } from "@/components/team/EmployeeHistoryDrawer";
import { EmployeeTable } from "@/components/team/EmployeeTable";
import { QuotaEditor } from "@/components/team/QuotaEditor";
import { Topbar } from "@/components/trade-in/Topbar";
import { HTTPError } from "@/lib/api";
import type { Employee } from "@/lib/team-api";
import { teamErrorMessage, useEmployees } from "@/lib/team-api";
import { useMe } from "@/lib/useMe";
const PAGE_LIMIT = 50;
export default function TeamPage() {
const meQ = useMe();
// Вычисляем ДО useEmployees (не после) — иначе для employee/analyst/pilot
// при прямом заходе на /team улетает обречённый GET (401/403) ещё до того,
// как ниже отрисуется role-gate. Порядок хуков не меняется — это просто
// производное значение, не условный вызов хука.
const role = meQ.data?.role;
const isAllowedRole = role === "admin" || role === "manager";
const [offset, setOffset] = useState(0);
const employeesQ = useEmployees(PAGE_LIMIT, offset, isAllowedRole);
const [showCreateForm, setShowCreateForm] = useState(false);
const [quotaEmployee, setQuotaEmployee] = useState<Employee | null>(null);
const [historyEmployee, setHistoryEmployee] = useState<Employee | null>(null);
// Роль ещё не загружена — не решаем ничего, RouteGuard уже отрисовал
// страницу (401/403 верхнего уровня он ловит сам), здесь только role-gate.
if (meQ.isPending) {
return (
<>
<Topbar active="team" />
<main className="page scraper-page" style={{ maxWidth: 1180 }}>
<p className="scraper-hint">Загрузка</p>
</main>
</>
);
}
if (!isAllowedRole) {
return <NoAccessScreen variant="user" />;
}
// Backend 403 регардлесс роли фронта (напр. session истекла между /me и
// /employees) — тот же fallback, что и в admin/audit.
if (employeesQ.error instanceof HTTPError && employeesQ.error.status === 403) {
return <NoAccessScreen variant="user" />;
}
const employees = employeesQ.data ?? [];
const hasNextPage = employees.length === PAGE_LIMIT;
return (
<>
<Topbar active="team" />
<main className="page scraper-page" style={{ maxWidth: 1180 }}>
<h1 className="scraper-h1">Команда</h1>
<p className="scraper-subtitle">
Сотрудники, их доступ и месячные квоты на оценку. История оценок по клику
«История».
</p>
<section className="scraper-section">
<div className="team-toolbar">
<h2 style={{ margin: 0 }}>Сотрудники</h2>
<button
type="button"
className="team-btn-primary"
onClick={() => setShowCreateForm(true)}
>
+ Добавить сотрудника
</button>
</div>
{employeesQ.isPending ? <p className="scraper-hint">Загрузка</p> : null}
{employeesQ.isError && !(employeesQ.error instanceof HTTPError && employeesQ.error.status === 403) ? (
<p className="scraper-result scraper-result--error">
{teamErrorMessage(employeesQ.error)}
</p>
) : null}
{employeesQ.data ? (
<EmployeeTable
employees={employees}
onEditQuota={setQuotaEmployee}
onViewHistory={setHistoryEmployee}
limit={PAGE_LIMIT}
offset={offset}
onOffsetChange={setOffset}
hasNextPage={hasNextPage}
/>
) : null}
</section>
</main>
{showCreateForm ? <CreateEmployeeForm onClose={() => setShowCreateForm(false)} /> : null}
{quotaEmployee ? (
<QuotaEditor employee={quotaEmployee} onClose={() => setQuotaEmployee(null)} />
) : null}
{historyEmployee ? (
<EmployeeHistoryDrawer
employee={historyEmployee}
onClose={() => setHistoryEmployee(null)}
/>
) : null}
</>
);
}

View file

@ -66,7 +66,7 @@ import {
} from "@/lib/trade-in-api"; } from "@/lib/trade-in-api";
import { useQuota } from "@/lib/useQuota"; import { useQuota } from "@/lib/useQuota";
import { useMe } from "@/lib/useMe"; import { useMe } from "@/lib/useMe";
import { logout } from "@/lib/logout"; import { useLogout } from "@/lib/useLogout";
// OUTER HUD FRAME + 4 corner brackets (design lines 31-37). Decorative, // OUTER HUD FRAME + 4 corner brackets (design lines 31-37). Decorative,
// non-interactive overlay drawn over the artboard gradient. The frame has // non-interactive overlay drawn over the artboard gradient. The frame has
@ -524,6 +524,10 @@ export default function TradeInV2Page() {
// (#2046) — known profile fields fall back to username / brand ?? role / "" // (#2046) — known profile fields fall back to username / brand ?? role / ""
// when absent (never invented). undefined while loading → TopNav «Гость». // when absent (never invented). undefined while loading → TopNav «Гость».
const me = useMe(); const me = useMe();
// #2555: session-logout (POST /api/v1/auth/logout + local /me-cache
// invalidate + redirect на /login) — replaces the legacy basic_auth-cache
// -bust `logout()` for the v2 TopNav (new /login-form users).
const logoutMutation = useLogout();
// Dashboard sub-hooks — each resolves independently; failure degrades its // Dashboard sub-hooks — each resolves independently; failure degrades its
// section via the mappers (null input) rather than blanking the page. // section via the mappers (null input) rather than blanking the page.
@ -973,7 +977,7 @@ export default function TradeInV2Page() {
onNavigate={setNav} onNavigate={setNav}
reports={reportsCount ?? 0} reports={reportsCount ?? 0}
user={topNavUser} user={topNavUser}
onLogout={logout} onLogout={() => logoutMutation.mutate()}
/> />
</nav> </nav>
<main <main

View file

@ -10,9 +10,17 @@
* RBAC config (`auth/roles.yaml`) использует абсолютные пути сайта * RBAC config (`auth/roles.yaml`) использует абсолютные пути сайта
* (`/trade-in/**`, `/trade-in/api/v1/admin/**`), поэтому перед проверкой * (`/trade-in/**`, `/trade-in/api/v1/admin/**`), поэтому перед проверкой
* isPathAllowed мы префиксим pathname через NEXT_PUBLIC_BASE_PATH. * isPathAllowed мы префиксим pathname через NEXT_PUBLIC_BASE_PATH.
*
* #2555 login redirect: `router.push()` (как и `usePathname()`) работает в
* пространстве путей БЕЗ basePath Next сам префиксит basePath на навигации
* (см. `next.config.ts` комментарий `basePath`). Поэтому `next=` в query
* строится из `rawPath` (БЕЗ basePath), а не `absolutePath` иначе
* `/login/page.tsx` сделал бы `router.push("/trade-in/history")`, и Next
* задвоил бы префикс в `/trade-in/trade-in/history`.
*/ */
import { usePathname } from "next/navigation"; import { useRouter, usePathname } from "next/navigation";
import { useEffect } from "react";
import { NoAccessScreen } from "@/components/auth/NoAccessScreen"; import { NoAccessScreen } from "@/components/auth/NoAccessScreen";
import { HTTPError } from "@/lib/api"; import { HTTPError } from "@/lib/api";
@ -29,6 +37,7 @@ interface RouteGuardProps {
export function RouteGuard({ children }: RouteGuardProps) { export function RouteGuard({ children }: RouteGuardProps) {
const rawPath = usePathname() ?? "/"; const rawPath = usePathname() ?? "/";
const router = useRouter();
// Абсолютный путь сайта: BASE_PATH + rawPath. Аккуратно с двойным слэшем // Абсолютный путь сайта: BASE_PATH + rawPath. Аккуратно с двойным слэшем
// на `/`: `BASE_PATH = "/trade-in"` + `"/"` → `/trade-in/` (ок). // на `/`: `BASE_PATH = "/trade-in"` + `"/"` → `/trade-in/` (ок).
const absolutePath = BASE_PATH const absolutePath = BASE_PATH
@ -36,21 +45,51 @@ export function RouteGuard({ children }: RouteGuardProps) {
: rawPath; : rawPath;
const { data, isLoading, error } = useMe(); const { data, isLoading, error } = useMe();
// #2555: /login сам себя не гейтит — иначе редирект-петля (401 на /me →
// редирект на /login → RouteGuard на /login опять видит 401 → редирект…).
const isLoginPage = rawPath === "/login";
// Prod-only: сессия истекла/отсутствует → уводим на логин вместо старого
// NoAccessScreen variant="session". Редирект — побочный эффект (нельзя
// router.push во время рендера), поэтому useEffect; пока он не сработал,
// рендерим null (см. return ниже), чтобы не мигал старый contents.
const shouldRedirectToLogin =
!isLoginPage &&
process.env.NODE_ENV === "production" &&
error instanceof HTTPError &&
error.status === 401;
useEffect(() => {
if (!shouldRedirectToLogin) return;
// PR #2562 review finding 3: deep-links carry их state в query (`/v2?id=
// <uuid>` — см. next.config.ts redirect comment про restore-by-id). Без
// `window.location.search` юзер, чья сессия истекла mid-session на такой
// ссылке, после логина попадал бы на голый `/v2` и терял отчёт. Effect
// — гарантированно client-side (useEffect тело никогда не бежит на SSR),
// поэтому `window` тут безопасен без typeof-guard.
const next = `${rawPath}${window.location.search}`;
router.push(`/login?next=${encodeURIComponent(next)}`);
}, [shouldRedirectToLogin, rawPath, router]);
// #801: preview-страница самодостаточна (свой QueryClient с фейковым me), // #801: preview-страница самодостаточна (свой QueryClient с фейковым me),
// RBAC к ней не применяем. Только под флагом — в проде по умолчанию выключено. // RBAC к ней не применяем. Только под флагом — в проде по умолчанию выключено.
if (ENABLE_PREVIEW && rawPath.startsWith("/ui-preview")) { if (ENABLE_PREVIEW && rawPath.startsWith("/ui-preview")) {
return <>{children}</>; return <>{children}</>;
} }
if (isLoginPage) {
return <>{children}</>;
}
if (isLoading) return null; if (isLoading) return null;
if (error instanceof HTTPError && error.status === 401) { if (error instanceof HTTPError && error.status === 401) {
// Dev without Caddy: 401 is normal, mount the app so local dev works. // Dev without Caddy: 401 is normal, mount the app so local dev works.
// Prod: mounting children on 401 causes TanStack Query re-subscribe storm
// (each new observer on errored query triggers a refetch). Show session screen
// instead — prevents the subtree from mounting, kills the loop.
if (process.env.NODE_ENV !== "production") return <>{children}</>; if (process.env.NODE_ENV !== "production") return <>{children}</>;
return <NoAccessScreen variant="session" />; // Prod: редирект уже запущен эффектом выше — ничего не рендерим, пока
// навигация не завершится (mounting children on 401 causes TanStack
// Query re-subscribe storm, см. историю до #2555 в git blame).
return null;
} }
if (error instanceof HTTPError && error.status === 403) { if (error instanceof HTTPError && error.status === 403) {

View file

@ -0,0 +1,219 @@
"use client";
/**
* Модалка создания сотрудника (#2556, эпик #2549) `POST /api/v1/team/employees`.
*
* Пароль задаётся вручную менеджером/админом email-рассылки нет (см. issue
* DoD), поэтому после успешного создания показываем явное «передайте пароль
* сотруднику» вместо тихого закрытия модалки.
*/
import { useState } from "react";
import type { FormEvent } from "react";
import {
USERNAME_HINT,
USERNAME_PATTERN,
teamErrorMessage,
useCreateEmployee,
} from "@/lib/team-api";
interface CreateEmployeeFormProps {
onClose: () => void;
}
export function CreateEmployeeForm({ onClose }: CreateEmployeeFormProps) {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [displayName, setDisplayName] = useState("");
const [orgName, setOrgName] = useState("");
const [email, setEmail] = useState("");
const [monthlyLimit, setMonthlyLimit] = useState("");
const [usernameTouched, setUsernameTouched] = useState(false);
const createMutation = useCreateEmployee();
const usernameValid = USERNAME_PATTERN.test(username);
const usernameInvalid = usernameTouched && username.length > 0 && !usernameValid;
function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
setUsernameTouched(true);
if (createMutation.isPending) return;
if (!USERNAME_PATTERN.test(username)) return;
if (password.length === 0) return;
const parsedLimit = monthlyLimit.trim() === "" ? undefined : Number(monthlyLimit);
createMutation.mutate({
username: username.trim(),
password,
display_name: displayName.trim() || undefined,
org_name: orgName.trim() || undefined,
email: email.trim() || undefined,
monthly_limit:
parsedLimit !== undefined && Number.isFinite(parsedLimit) && parsedLimit >= 1
? Math.trunc(parsedLimit)
: undefined,
});
}
if (createMutation.isSuccess) {
return (
<div className="team-modal-backdrop" role="presentation" onClick={onClose}>
<div
className="team-modal"
role="dialog"
aria-modal="true"
aria-label="Сотрудник создан"
onClick={(e) => e.stopPropagation()}
>
<h2>Сотрудник создан</h2>
<p className="team-form-success">
Логин «{createMutation.data.username}» готов. Передайте пароль сотруднику
он не сохраняется в системе и не отправляется автоматически.
</p>
<div className="team-modal-actions">
<button type="button" className="team-btn-primary" onClick={onClose}>
Готово
</button>
</div>
</div>
</div>
);
}
return (
<div className="team-modal-backdrop" role="presentation" onClick={onClose}>
<form
className="team-modal"
role="dialog"
aria-modal="true"
aria-label="Новый сотрудник"
onClick={(e) => e.stopPropagation()}
onSubmit={handleSubmit}
>
<h2>Новый сотрудник</h2>
<p className="team-modal-subtitle">
Пароль задаётся вручную передайте его сотруднику лично, рассылки нет.
</p>
{createMutation.isError ? (
<p role="alert" className="team-form-error">
{teamErrorMessage(createMutation.error)}
</p>
) : null}
<div className="team-field">
<label htmlFor="team-new-username">Логин</label>
<input
id="team-new-username"
name="username"
type="text"
autoComplete="off"
required
value={username}
onChange={(e) => setUsername(e.target.value)}
onBlur={() => setUsernameTouched(true)}
disabled={createMutation.isPending}
aria-invalid={usernameInvalid}
/>
<p className="team-field-hint">{USERNAME_HINT}</p>
{usernameInvalid ? (
<p className="team-field-hint" style={{ color: "var(--danger, #b3261e)" }}>
Логин не соответствует формату
</p>
) : null}
</div>
<div className="team-field team-password-row">
<label htmlFor="team-new-password">Пароль</label>
<input
id="team-new-password"
name="password"
type={showPassword ? "text" : "password"}
autoComplete="new-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={createMutation.isPending}
/>
<button
type="button"
className="team-password-toggle"
onClick={() => setShowPassword((v) => !v)}
tabIndex={-1}
>
{showPassword ? "Скрыть" : "Показать"}
</button>
</div>
<div className="team-field">
<label htmlFor="team-new-display-name">Имя (необязательно)</label>
<input
id="team-new-display-name"
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
disabled={createMutation.isPending}
/>
</div>
<div className="team-field">
<label htmlFor="team-new-org">Организация (необязательно)</label>
<input
id="team-new-org"
type="text"
value={orgName}
onChange={(e) => setOrgName(e.target.value)}
disabled={createMutation.isPending}
/>
</div>
<div className="team-field">
<label htmlFor="team-new-email">Email (необязательно)</label>
<input
id="team-new-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={createMutation.isPending}
/>
</div>
<div className="team-field">
<label htmlFor="team-new-limit">Месячный лимит оценок (необязательно)</label>
<input
id="team-new-limit"
type="number"
min={1}
step={1}
placeholder="по умолчанию"
value={monthlyLimit}
onChange={(e) => setMonthlyLimit(e.target.value)}
disabled={createMutation.isPending}
/>
</div>
<div className="team-modal-actions">
<button
type="button"
className="team-btn-secondary"
onClick={onClose}
disabled={createMutation.isPending}
>
Отмена
</button>
<button
type="submit"
className="team-btn-primary"
disabled={createMutation.isPending || password.length === 0 || !usernameValid}
>
{createMutation.isPending ? "Создаём…" : "Создать"}
</button>
</div>
</form>
</div>
);
}

View file

@ -0,0 +1,94 @@
"use client";
/**
* Drawer с историей оценок сотрудника (#2556, эпик #2549)
* `GET /api/v1/team/employees/{id}/history`.
*/
import { teamErrorMessage, useEmployeeHistory } from "@/lib/team-api";
import type { Employee } from "@/lib/team-api";
function formatDateTime(iso: string): string {
try {
return new Date(iso).toLocaleString("ru-RU", {
day: "2-digit",
month: "2-digit",
year: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
} catch {
return iso;
}
}
function formatPrice(value: number | null): string {
if (value === null) return "—";
return `${value.toLocaleString("ru-RU")}`;
}
interface EmployeeHistoryDrawerProps {
employee: Employee;
onClose: () => void;
}
export function EmployeeHistoryDrawer({ employee, onClose }: EmployeeHistoryDrawerProps) {
const historyQ = useEmployeeHistory(employee.id, 50, 0);
return (
<>
<div className="team-drawer-backdrop" role="presentation" onClick={onClose} />
<aside
className="team-drawer"
role="dialog"
aria-modal="true"
aria-label={`История оценок сотрудника ${employee.username}`}
>
<div className="team-drawer-header">
<div>
<h2>История оценок</h2>
<p className="scraper-hint" style={{ margin: 0 }}>
{employee.display_name ?? employee.username}
</p>
</div>
<button
type="button"
className="team-drawer-close"
onClick={onClose}
aria-label="Закрыть"
>
×
</button>
</div>
{historyQ.isPending ? <p className="scraper-hint">Загрузка</p> : null}
{historyQ.isError ? (
<p className="scraper-result scraper-result--error">
{teamErrorMessage(historyQ.error)}
</p>
) : null}
{historyQ.data && historyQ.data.length === 0 ? (
<p className="scraper-hint">У сотрудника пока нет оценок.</p>
) : null}
{historyQ.data && historyQ.data.length > 0 ? (
<div>
{historyQ.data.map((entry, i) => (
<div className="team-history-row" key={entry.estimate_id ?? i}>
<p className="team-history-address">{entry.address ?? "Адрес не указан"}</p>
<p className="team-history-meta">
{formatDateTime(entry.created_at)}
{entry.rooms ? ` · ${entry.rooms}к` : ""}
{entry.area_m2 ? ` · ${entry.area_m2} м²` : ""}
{entry.median_price !== null ? ` · ${formatPrice(entry.median_price)}` : ""}
{entry.confidence ? ` · точность: ${entry.confidence}` : ""}
{entry.n_analogs !== null ? ` · аналогов: ${entry.n_analogs}` : ""}
</p>
</div>
))}
</div>
) : null}
</aside>
</>
);
}

View file

@ -0,0 +1,205 @@
"use client";
/**
* Таблица сотрудников для team-дашборда менеджера (#2556, эпик #2549).
*
* Блокировка/разблокировка прямое действие в строке (один PATCH-запрос
* с `is_active`), с `window.confirm` перед деструктивным изменением (спек
* DoD: «действия деструктивные с подтверждением»). Квота + сброс пароля
* вынесены в отдельный модал (`QuotaEditor`) они бьют в тот же PATCH
* эндпоинт одним запросом, см. `app/api/v1/team.py::update_employee`.
*/
import { Fragment, useState } from "react";
import type { Employee } from "@/lib/team-api";
import { teamErrorMessage, useUpdateEmployee } from "@/lib/team-api";
function formatDate(iso: string): string {
try {
return new Date(iso).toLocaleDateString("ru-RU", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
} catch {
return iso;
}
}
function QuotaCell({ quota }: { quota: Employee["quota"] }) {
if (quota.unlimited) {
return <span className="team-quota-unlimited">без лимита</span>;
}
const low = quota.remaining <= 0;
return (
<span className={low ? "team-quota-low" : undefined}>
{quota.used}/{quota.limit}
</span>
);
}
interface EmployeeTableProps {
employees: Employee[];
onEditQuota: (employee: Employee) => void;
onViewHistory: (employee: Employee) => void;
limit: number;
offset: number;
onOffsetChange: (offset: number) => void;
hasNextPage: boolean;
}
export function EmployeeTable({
employees,
onEditQuota,
onViewHistory,
limit,
offset,
onOffsetChange,
hasNextPage,
}: EmployeeTableProps) {
const updateMutation = useUpdateEmployee();
const [pendingId, setPendingId] = useState<number | null>(null);
const [rowError, setRowError] = useState<{ id: number; message: string } | null>(null);
function handleToggleActive(employee: Employee) {
const nextActive = !employee.is_active;
const confirmed = window.confirm(
nextActive
? `Разблокировать сотрудника «${employee.username}»?`
: `Заблокировать сотрудника «${employee.username}»? Все его текущие сессии будут завершены.`,
);
if (!confirmed) return;
setRowError(null);
setPendingId(employee.id);
updateMutation.mutate(
{ employeeId: employee.id, patch: { is_active: nextActive } },
{
onSettled: () => setPendingId(null),
onError: (error) => setRowError({ id: employee.id, message: teamErrorMessage(error) }),
},
);
}
const isEmpty = employees.length === 0;
// Пустая ПЕРВАЯ страница = сотрудников вообще нет (предлагаем создать).
// Пустая страница при offset>0 = перешли за границу списка (напр. ровно
// 50/100/150 сотрудников и клик «Далее» после последней полной страницы)
// — это НЕ «сотрудников нет», нужен путь назад, а не тупик без пейджера.
const isEmptyPastEnd = isEmpty && offset > 0;
return (
<>
{isEmpty ? (
<p className="scraper-hint">
{isEmptyPastEnd
? "На этой странице пусто — вы перешли за конец списка."
: "Сотрудников пока нет. Добавьте первого через «+ Добавить сотрудника»."}
</p>
) : (
<table className="runs-table team-table">
<thead>
<tr>
<th scope="col">Логин</th>
<th scope="col">Имя</th>
<th scope="col">Статус</th>
<th scope="col">Квота</th>
<th scope="col">Создан</th>
<th scope="col">Действия</th>
</tr>
</thead>
<tbody>
{employees.map((employee) => (
<Fragment key={employee.id}>
<tr>
<td>{employee.username}</td>
<td className="run-muted">
{employee.display_name ?? "—"}
{employee.org_name ? ` · ${employee.org_name}` : ""}
</td>
<td>
<span
className={
employee.is_active
? "team-status-badge team-status-badge--active"
: "team-status-badge team-status-badge--blocked"
}
>
{employee.is_active ? "Активен" : "Заблокирован"}
</span>
</td>
<td>
<QuotaCell quota={employee.quota} />
</td>
<td className="run-muted">{formatDate(employee.created_at)}</td>
<td>
<div className="team-actions">
<button
type="button"
className="team-action-btn"
onClick={() => onViewHistory(employee)}
>
История
</button>
<button
type="button"
className="team-action-btn"
onClick={() => onEditQuota(employee)}
>
Изменить
</button>
<button
type="button"
className={
employee.is_active
? "team-action-btn team-action-btn--danger"
: "team-action-btn"
}
disabled={pendingId === employee.id}
onClick={() => handleToggleActive(employee)}
>
{employee.is_active ? "Заблокировать" : "Разблокировать"}
</button>
</div>
</td>
</tr>
{rowError?.id === employee.id ? (
<tr>
<td colSpan={6} style={{ padding: "0 10px 8px" }}>
<p className="team-form-error" style={{ margin: 0 }}>
{rowError.message}
</p>
</td>
</tr>
) : null}
</Fragment>
))}
</tbody>
</table>
)}
<div className="team-pager">
<button
type="button"
disabled={offset === 0}
onClick={() => onOffsetChange(Math.max(0, offset - limit))}
>
Назад
</button>
{!isEmpty ? (
<span>
{offset + 1}{offset + employees.length}
</span>
) : null}
<button
type="button"
disabled={isEmpty || !hasNextPage}
onClick={() => onOffsetChange(offset + limit)}
>
Далее
</button>
</div>
</>
);
}

View file

@ -0,0 +1,144 @@
"use client";
/**
* Модалка изменения сотрудника (#2556, эпик #2549) месячная квота + (опц.)
* сброс пароля. Один PATCH-запрос `/api/v1/team/employees/{id}` backend
* принимает `monthly_limit` и `new_password` в одном теле (см.
* `app/api/v1/team.py::update_employee`), поэтому оба поля живут в одной
* форме вместо двух раздельных round-trip'ов.
*
* Явная установка `monthly_limit` ВСЕГДА сбрасывает `unlimited=false` на
* бэкенде (см. `_upsert_quota_override`) предупреждаем об этом в тексте,
* если у сотрудника сейчас безлимит.
*/
import { useState } from "react";
import type { FormEvent } from "react";
import type { Employee, UpdateEmployeeInput } from "@/lib/team-api";
import { teamErrorMessage, useUpdateEmployee } from "@/lib/team-api";
interface QuotaEditorProps {
employee: Employee;
onClose: () => void;
}
export function QuotaEditor({ employee, onClose }: QuotaEditorProps) {
const [monthlyLimit, setMonthlyLimit] = useState(
employee.quota.unlimited ? "" : String(employee.quota.limit),
);
const [newPassword, setNewPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const updateMutation = useUpdateEmployee();
function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
if (updateMutation.isPending) return;
const patch: UpdateEmployeeInput = {};
const trimmedLimit = monthlyLimit.trim();
if (trimmedLimit !== "") {
const parsed = Number(trimmedLimit);
if (Number.isFinite(parsed) && parsed >= 1) {
patch.monthly_limit = Math.trunc(parsed);
}
}
if (newPassword.length > 0) {
patch.new_password = newPassword;
}
if (Object.keys(patch).length === 0) {
onClose();
return;
}
updateMutation.mutate(
{ employeeId: employee.id, patch },
{ onSuccess: onClose },
);
}
return (
<div className="team-modal-backdrop" role="presentation" onClick={onClose}>
<form
className="team-modal"
role="dialog"
aria-modal="true"
aria-label={`Изменить сотрудника ${employee.username}`}
onClick={(e) => e.stopPropagation()}
onSubmit={handleSubmit}
>
<h2>Изменить сотрудника</h2>
<p className="team-modal-subtitle">{employee.username}</p>
{updateMutation.isError ? (
<p role="alert" className="team-form-error">
{teamErrorMessage(updateMutation.error)}
</p>
) : null}
<div className="team-field">
<label htmlFor="team-quota-limit">Месячный лимит оценок</label>
<input
id="team-quota-limit"
type="number"
min={1}
step={1}
value={monthlyLimit}
onChange={(e) => setMonthlyLimit(e.target.value)}
disabled={updateMutation.isPending}
/>
<p className="team-field-hint">
Сейчас использовано {employee.quota.used}
{employee.quota.unlimited
? " · без лимита"
: ` из ${employee.quota.limit} (осталось ${employee.quota.remaining})`}
{employee.quota.unlimited
? ". Заполнение поля снимет безлимитный статус."
: ""}
</p>
</div>
<div className="team-field team-password-row">
<label htmlFor="team-quota-password">Новый пароль (необязательно)</label>
<input
id="team-quota-password"
type={showPassword ? "text" : "password"}
autoComplete="new-password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
disabled={updateMutation.isPending}
/>
<button
type="button"
className="team-password-toggle"
onClick={() => setShowPassword((v) => !v)}
tabIndex={-1}
>
{showPassword ? "Скрыть" : "Показать"}
</button>
<p className="team-field-hint">
Смена пароля завершает все текущие сессии сотрудника.
</p>
</div>
<div className="team-modal-actions">
<button
type="button"
className="team-btn-secondary"
onClick={onClose}
disabled={updateMutation.isPending}
>
Отмена
</button>
<button type="submit" className="team-btn-primary" disabled={updateMutation.isPending}>
{updateMutation.isPending ? "Сохраняем…" : "Сохранить"}
</button>
</div>
</form>
</div>
);
}

View file

@ -7,6 +7,7 @@ import { API_BASE_URL, HTTPError } from "@/lib/api";
import { isPathAllowed } from "@/lib/isPathAllowed"; import { isPathAllowed } from "@/lib/isPathAllowed";
import { safeUrl } from "@/lib/safeUrl"; import { safeUrl } from "@/lib/safeUrl";
import { useBrand } from "@/lib/useBrand"; import { useBrand } from "@/lib/useBrand";
import type { Role } from "@/lib/useMe";
import { useMe } from "@/lib/useMe"; import { useMe } from "@/lib/useMe";
/** Telegram-канал обратной связи для пилота. Build-time env пусто до тех /** Telegram-канал обратной связи для пилота. Build-time env пусто до тех
@ -84,7 +85,8 @@ export type ActiveTab =
| "cian" | "cian"
| "yandex" | "yandex"
| "audit" | "audit"
| "analytics"; | "analytics"
| "team";
interface TopbarProps { interface TopbarProps {
active: ActiveTab; active: ActiveTab;
@ -111,6 +113,13 @@ const NAV_ITEMS: Array<{
href: string; href: string;
scopePath: string; scopePath: string;
label: string; label: string;
// Доп. gate ПОВЕРХ isPathAllowed — нужен для "team": DB-роль `manager`
// получает `/api/v1/team/**` в allowed_paths (см. DB_ROLE_PATHS,
// tradein-mvp/backend/app/services/auth_session.py), но legacy-роль
// `analyst` (auth/roles.yaml, paths: "/**", БЕЗ явного deny на /api/v1/team)
// тоже прошла бы обычный isPathAllowed-фильтр — analyst не должен видеть
// пункт «Команда» (#2556 scope: только admin/manager).
roleGate?: (role: Role) => boolean;
}> = [ }> = [
{ key: "estimate", href: "/", scopePath: "/trade-in/", label: "Оценка" }, { key: "estimate", href: "/", scopePath: "/trade-in/", label: "Оценка" },
// Доля квартир дома в продаже — доступно pilot (scopePath под /trade-in/**). // Доля квартир дома в продаже — доступно pilot (scopePath под /trade-in/**).
@ -162,6 +171,16 @@ const NAV_ITEMS: Array<{
scopePath: "/trade-in/api/v1/admin/analytics", scopePath: "/trade-in/api/v1/admin/analytics",
label: "Активность", label: "Активность",
}, },
// Team-дашборд менеджера (#2556, эпик #2549) — CRUD сотрудников/квоты/история.
// scopePath БЕЗ /trade-in-префикса — DB_ROLE_PATHS отдаёт голый /api/v1/team/**
// (не /trade-in/api/v1/team/**), см. комментарий у roleGate выше.
{
key: "team",
href: "/team",
scopePath: "/api/v1/team",
label: "Команда",
roleGate: (role) => role === "admin" || role === "manager",
},
]; ];
export function Topbar({ active }: TopbarProps) { export function Topbar({ active }: TopbarProps) {
@ -176,8 +195,10 @@ export function Topbar({ active }: TopbarProps) {
const isDev401 = error instanceof HTTPError && error.status === 401; const isDev401 = error instanceof HTTPError && error.status === 401;
const items = const items =
data && !isDev401 data && !isDev401
? NAV_ITEMS.filter((item) => ? NAV_ITEMS.filter(
isPathAllowed(data.allowed_paths, data.deny_paths, item.scopePath), (item) =>
isPathAllowed(data.allowed_paths, data.deny_paths, item.scopePath) &&
(item.roleGate ? item.roleGate(data.role) : true),
) )
: NAV_ITEMS; : NAV_ITEMS;

View file

@ -2820,3 +2820,322 @@ html, body { overflow-x: clip; }
grid-template-columns: repeat(auto-fit, minmax(360px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
gap: 16px; gap: 16px;
} }
/* ── Team dashboard (#2556, эпик #2549) ── */
.team-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.team-status-badge {
padding: 3px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
white-space: nowrap;
}
.team-status-badge--active {
background: var(--success-soft, #dcfce7);
color: var(--success, #0a7a3a);
}
.team-status-badge--blocked {
background: var(--danger-soft, #fee2e2);
color: var(--danger, #b3261e);
}
.team-quota-unlimited {
color: var(--fg-secondary, #5b6066);
font-size: 11px;
}
.team-quota-low {
color: var(--danger, #b3261e);
font-weight: 600;
}
.team-actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.team-action-btn {
padding: 4px 10px;
background: var(--bg-card-alt, #fafbfc);
color: var(--fg-primary, #111111);
border: 1px solid var(--border-strong, #d1d5db);
border-radius: 4px;
font-size: 11px;
cursor: pointer;
}
.team-action-btn:hover:not(:disabled) {
background: var(--accent-soft, #dbeafe);
border-color: var(--accent, #1d4ed8);
}
.team-action-btn:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.team-action-btn--danger {
color: var(--danger, #b3261e);
border-color: var(--danger, #b3261e);
}
.team-action-btn--danger:hover:not(:disabled) {
background: var(--danger-soft, #fee2e2);
}
.team-pager {
display: flex;
align-items: center;
gap: 12px;
margin-top: 16px;
font-size: 13px;
color: var(--fg-secondary, #5b6066);
}
.team-pager button {
padding: 6px 12px;
background: var(--bg-card, #ffffff);
border: 1px solid var(--border-strong, #d1d5db);
border-radius: 6px;
font-size: 13px;
cursor: pointer;
}
.team-pager button:hover:not(:disabled) {
background: var(--accent-soft, #dbeafe);
border-color: var(--accent, #1d4ed8);
}
.team-pager button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
/* Modal (CreateEmployeeForm / QuotaEditor) box-shadow допустим для
modals/popovers per .claude/rules/ui-tokens.md. */
.team-modal-backdrop {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.45);
display: flex;
align-items: flex-start;
justify-content: center;
padding: 48px 16px;
z-index: 100;
overflow-y: auto;
}
.team-modal {
background: var(--bg-card, #ffffff);
border: 1px solid var(--border-card, #e6e8ec);
border-radius: 12px;
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.2);
padding: 24px;
max-width: 440px;
width: 100%;
}
.team-modal h2 {
font-size: 18px;
font-weight: 600;
margin: 0 0 4px;
color: var(--fg-primary, #111111);
}
.team-modal p.team-modal-subtitle {
font-size: 13px;
color: var(--fg-secondary, #5b6066);
margin: 0 0 20px;
}
.team-field {
margin-bottom: 14px;
}
.team-field label {
display: block;
font-size: 12px;
font-weight: 500;
color: var(--fg-secondary, #5b6066);
margin-bottom: 6px;
}
.team-field input {
width: 100%;
box-sizing: border-box;
height: 38px;
padding: 0 12px;
border: 1px solid var(--border-card, #e6e8ec);
border-radius: 8px;
font-size: 14px;
color: var(--fg-primary, #111111);
background: var(--bg-card, #ffffff);
font-family: inherit;
}
.team-field input:focus-visible {
outline: none;
border-color: var(--accent, #1d4ed8);
box-shadow: 0 0 0 2px var(--accent-soft, #dbeafe);
}
.team-field-hint {
margin: 6px 0 0;
font-size: 11px;
color: var(--fg-tertiary, #73767e);
}
.team-password-row {
position: relative;
}
.team-password-toggle {
position: absolute;
right: 8px;
top: 30px;
background: none;
border: none;
color: var(--fg-secondary, #5b6066);
font-size: 11px;
cursor: pointer;
padding: 4px 6px;
}
.team-form-error {
margin: 0 0 16px;
padding: 8px 12px;
border-radius: 8px;
background: var(--danger-soft, #fee2e2);
color: var(--danger, #b3261e);
font-size: 13px;
line-height: 1.4;
}
.team-form-success {
margin: 0 0 16px;
padding: 8px 12px;
border-radius: 8px;
background: var(--success-soft, #dcfce7);
color: var(--success, #0a7a3a);
font-size: 13px;
line-height: 1.4;
}
.team-modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 20px;
}
.team-btn-primary {
padding: 9px 16px;
background: var(--accent, #1d4ed8);
color: #fff;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
}
.team-btn-primary:hover:not(:disabled) {
background: var(--accent-hover, #1e40af);
}
.team-btn-primary:disabled {
background: var(--border-strong, #d1d5db);
cursor: not-allowed;
}
.team-btn-secondary {
padding: 9px 16px;
background: var(--bg-card, #ffffff);
color: var(--fg-primary, #111111);
border: 1px solid var(--border-strong, #d1d5db);
border-radius: 8px;
font-size: 14px;
cursor: pointer;
}
.team-btn-secondary:hover:not(:disabled) {
background: var(--bg-card-alt, #fafbfc);
}
/* Drawer (EmployeeHistoryDrawer) */
.team-drawer-backdrop {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.35);
z-index: 100;
}
.team-drawer {
position: fixed;
top: 0;
right: 0;
bottom: 0;
width: min(480px, 100vw);
background: var(--bg-card, #ffffff);
border-left: 1px solid var(--border-card, #e6e8ec);
box-shadow: -16px 0 40px rgba(15, 23, 42, 0.15);
z-index: 101;
overflow-y: auto;
padding: 24px;
}
.team-drawer-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
}
.team-drawer-header h2 {
font-size: 18px;
font-weight: 600;
margin: 0 0 4px;
color: var(--fg-primary, #111111);
}
.team-drawer-close {
background: none;
border: none;
font-size: 20px;
line-height: 1;
color: var(--fg-secondary, #5b6066);
cursor: pointer;
padding: 4px 6px;
}
.team-history-row {
padding: 10px 0;
border-bottom: 1px solid var(--border-soft, #eef0f3);
}
.team-history-row:last-child {
border-bottom: none;
}
.team-history-address {
font-size: 13px;
color: var(--fg-primary, #111111);
margin: 0 0 2px;
}
.team-history-meta {
font-size: 11px;
color: var(--fg-secondary, #5b6066);
}

View file

@ -0,0 +1,195 @@
"use client";
/**
* Team-management API client (#2556, эпик #2549) CRUD сотрудников, квоты,
* история. Mirrors `app/api/v1/team.py` + `app/schemas/team.py` (tradein-mvp
* backend, PR #2554/#2563). Hand-written types tradein-mvp/frontend has no
* `codegen` script (см. `src/lib/admin-audit-api.ts` для того же паттерна).
*
* Auth: team-роуты требуют session-cookie (`current_team_actor` в team.py
* читает ТОЛЬКО cookie, не legacy X-Authenticated-User). `apiFetch` не задаёт
* `credentials` явно запросы same-origin (basePath `/trade-in` за тем же
* Caddy), браузер по умолчанию шлёт cookie на same-origin fetch.
*/
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiFetch, HTTPError } from "./api";
const BASE = "/api/v1/team";
// Тот же regex что и `app.schemas.team._USERNAME_RE` — клиентская валидация
// зеркалит серверную, чтобы показывать понятную подсказку ДО round-trip.
export const USERNAME_PATTERN = /^[A-Za-z0-9._-]{3,64}$/;
export const USERNAME_HINT =
"Латиница, цифры, точка, дефис, подчёркивание; 364 символа";
// ---- Types (mirror app/schemas/team.py) ------------------------------------
export interface QuotaStatus {
limit: number;
used: number;
remaining: number;
unlimited: boolean;
}
export interface Employee {
id: number;
username: string;
display_name: string | null;
org_name: string | null;
email: string | null;
is_active: boolean;
manager_id: number | null;
created_at: string;
quota: QuotaStatus;
}
export interface EmployeeHistoryEntry {
estimate_id: string | null;
address: string | null;
area_m2: string | null;
rooms: string | null;
median_price: number | null;
confidence: string | null;
n_analogs: number | null;
created_at: string;
}
export interface CreateEmployeeInput {
username: string;
password: string;
display_name?: string;
org_name?: string;
email?: string;
monthly_limit?: number;
}
export interface UpdateEmployeeInput {
is_active?: boolean;
monthly_limit?: number;
display_name?: string;
org_name?: string;
email?: string;
new_password?: string;
}
// ---- Hooks -------------------------------------------------------------------
const EMPLOYEES_LIST_KEY = ["team", "employees"] as const;
/**
* GET /api/v1/team/employees?limit=&offset=
* Backend сам скоупит по роли (manager только свои, admin все).
*
* `enabled` (default true) вызывающая страница обязана передать `false`
* для ролей, которым эндпоинт всё равно ответит 401/403 (employee/analyst/
* pilot и т.п.): без этого прямой заход на /team шлёт обречённый round-trip
* ДО отрисовки role-gate, который лишний и палит наличие эндпоинта.
*/
export function useEmployees(limit: number, offset: number, enabled = true) {
return useQuery<Employee[]>({
queryKey: [...EMPLOYEES_LIST_KEY, limit, offset],
queryFn: () =>
apiFetch<Employee[]>(`${BASE}/employees?limit=${limit}&offset=${offset}`),
enabled,
staleTime: 15_000,
retry: false,
});
}
/**
* GET /api/v1/team/employees/{id}/history?limit=&offset=
* `enabled: false` пока employeeId === null ленивая загрузка при открытии drawer.
*/
export function useEmployeeHistory(
employeeId: number | null,
limit = 50,
offset = 0,
) {
return useQuery<EmployeeHistoryEntry[]>({
queryKey: ["team", "employees", employeeId, "history", limit, offset],
queryFn: () =>
apiFetch<EmployeeHistoryEntry[]>(
`${BASE}/employees/${employeeId}/history?limit=${limit}&offset=${offset}`,
),
enabled: employeeId !== null,
staleTime: 15_000,
retry: false,
});
}
/** POST /api/v1/team/employees — создать сотрудника. */
export function useCreateEmployee() {
const queryClient = useQueryClient();
return useMutation<Employee, Error, CreateEmployeeInput>({
mutationFn: (input) =>
apiFetch<Employee>(`${BASE}/employees`, {
method: "POST",
body: JSON.stringify(input),
}),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: EMPLOYEES_LIST_KEY });
},
});
}
/**
* PATCH /api/v1/team/employees/{id} частичное обновление (блокировка,
* квота, профиль, сброс пароля любая комбинация полей в одном запросе,
* зеркалит `EmployeeUpdateRequest`).
*/
export function useUpdateEmployee() {
const queryClient = useQueryClient();
return useMutation<
Employee,
Error,
{ employeeId: number; patch: UpdateEmployeeInput }
>({
mutationFn: ({ employeeId, patch }) =>
apiFetch<Employee>(`${BASE}/employees/${employeeId}`, {
method: "PATCH",
body: JSON.stringify(patch),
}),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: EMPLOYEES_LIST_KEY });
},
});
}
// ---- Error mapping -----------------------------------------------------------
/**
* Достаёт человеко-читаемый текст из FastAPI error body либо `{detail:
* string}` (наши ручные `HTTPException(...)`), либо Pydantic-валидация
* `{detail: [{msg: string, ...}]}` (напр. username не прошёл field_validator
* на этапе парсинга тела запроса, ДО хендлера).
*/
function extractDetailMessage(body: unknown): string | null {
if (body === null || typeof body !== "object" || !("detail" in body)) {
return null;
}
const detail = (body as { detail?: unknown }).detail;
if (typeof detail === "string") return detail;
if (Array.isArray(detail) && detail.length > 0) {
const first: unknown = detail[0];
if (first !== null && typeof first === "object" && "msg" in first) {
const msg = (first as { msg?: unknown }).msg;
if (typeof msg === "string") return msg;
}
}
return null;
}
/** Русский текст ошибки для team-мутаций/запросов — по коду статуса. */
export function teamErrorMessage(error: unknown): string {
if (error instanceof HTTPError) {
if (error.status === 409) return "Логин уже занят";
if (error.status === 403) return "Недостаточно прав";
if (error.status === 404) return "Сотрудник не найден";
if (error.status === 422) {
return extractDetailMessage(error.body) ?? "Проверьте правильность заполнения формы";
}
}
return "Не удалось выполнить запрос. Попробуйте ещё раз";
}

View file

@ -0,0 +1,40 @@
"use client";
/**
* #2555: session-logout POST /api/v1/auth/logout (revoke DB session +
* очистка httponly cookie tradein_session), затем чистим локальный
* TanStack Query /me-кэш и уходим на /login.
*
* NB: это НЕ замена legacy `@/lib/logout.ts` (Caddy basic_auth cache-bust +
* hard reload) тот остаётся для страниц/пользователей на старом
* trusted-header механизме (см. `app.core.rbac` dual-mode resolver).
* useLogout для юзеров, залогиненных через новую /login форму (#2552).
*
* Backend logout best-effort по духу (revoke конкретной сессии), поэтому
* локальный logout (кэш + редирект) выполняется в `onSettled`, а не только
* `onSuccess`: сетевой сбой / уже-протухшая сессия не должны запирать юзера
* на странице без возможности разлогиниться.
*/
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { ME_QUERY_KEY } from "@/lib/useMe";
async function logoutRequest(): Promise<void> {
await apiFetch<{ ok: boolean }>("/api/v1/auth/logout", { method: "POST" });
}
export function useLogout() {
const queryClient = useQueryClient();
const router = useRouter();
return useMutation({
mutationFn: logoutRequest,
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ME_QUERY_KEY });
router.push("/login");
},
});
}

View file

@ -14,7 +14,18 @@ import { useQuery } from "@tanstack/react-query";
import { apiFetchWithStatus, HTTPError } from "@/lib/api"; import { apiFetchWithStatus, HTTPError } from "@/lib/api";
export type Role = "admin" | "pilot" | "expired"; // #2555: session-auth (POST /api/v1/auth/login) вводит новые роли
// admin|manager|employee. Legacy Caddy trusted-header роли (pilot|analyst|
// expired) остаются — backend `/api/v1/me` может отдать любую из обеих
// групп в зависимости от того, каким механизмом пришёл юзер (dual-mode
// resolver, см. `tradein-mvp/backend/app/core/rbac.py`).
export type Role =
| "admin"
| "manager"
| "employee"
| "pilot"
| "analyst"
| "expired";
export interface UserScope { export interface UserScope {
username: string; username: string;