Compare commits

..

No commits in common. "main" and "feat/tradein-login-ui" have entirely different histories.

16 changed files with 18 additions and 3445 deletions

View file

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

View file

@ -1,663 +0,0 @@
"""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,7 +31,6 @@ 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
@ -184,4 +183,3 @@ 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

@ -1,110 +0,0 @@
"""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

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

File diff suppressed because it is too large Load diff

View file

@ -1,180 +0,0 @@
"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 её уже прячет).
*
* #2556 fix (эпик #2549, live-audit): team-роуты требуют session-cookie
* (см. team-api.ts header comment) легаси Caddy-заголовок (`/me` 200 с
* ролью admin/manager) через него НЕ проходит. Юзер, прошедший только Caddy
* basic_auth (без входа в форму `/login`), видел здесь непонятную красную
* плашку «Не удалось выполнить запрос». Ловим именно 401 от employeesQ
* ОТДЕЛЬНО от 403 (403 = «нет прав», это ожидаемо и есть у другой роли; 401
* = «нет сессии вообще», это решается логином) и уводим на /login?next=/team
* тот же redirect-паттерн, что RouteGuard.tsx использует для 401 от /me.
*/
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
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;
const TEAM_LOGIN_NEXT = `/login?next=${encodeURIComponent("/team")}`;
export default function TeamPage() {
const meQ = useMe();
const router = useRouter();
// Вычисляем ДО useEmployees (не после) — иначе для employee/analyst/pilot
// при прямом заходе на /team улетает обречённый GET (401/403) ещё до того,
// как ниже отрисуется role-gate. Порядок хуков не меняется — это просто
// производное значение, не условный вызов хука.
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);
// 401 = легаси Caddy-роль прошла role-gate выше, но team-эндпоинт требует
// session-cookie, которой у юзера нет (см. header comment). Отдельно от 403
// (та ветка — «роль есть, прав недостаточно», ниже).
const sessionRequired =
employeesQ.error instanceof HTTPError && employeesQ.error.status === 401;
// Редирект — побочный эффект (нельзя router.push во время рендера), тот же
// паттерн, что RouteGuard.tsx использует для 401 от /me. NODE_ENV-гейт
// зеркалит RouteGuard: в dev без Caddy/логина 401 — обычное дело, авто-
// редирект там мешал бы локальной разработке; вместо него — сообщение с
// кнопкой ниже.
useEffect(() => {
if (!sessionRequired) return;
if (process.env.NODE_ENV !== "production") return;
router.push(TEAM_LOGIN_NEXT);
}, [sessionRequired, router]);
// Роль ещё не загружена — не решаем ничего, RouteGuard уже отрисовал
// страницу (401/403 верхнего уровня он ловит сам), здесь только role-gate.
if (meQ.isPending) {
return (
<>
<Topbar active="team" />
<main className="page scraper-page" style={{ maxWidth: 1180 }}>
<p className="scraper-hint">Загрузка</p>
</main>
</>
);
}
if (!isAllowedRole) {
return <NoAccessScreen variant="user" />;
}
if (sessionRequired) {
// Prod: редирект уже запущен эффектом выше — не мигаем error-текстом,
// пока навигация не завершится (тот же приём, что RouteGuard.tsx).
if (process.env.NODE_ENV === "production") return null;
return (
<>
<Topbar active="team" />
<main className="page scraper-page" style={{ maxWidth: 1180 }}>
<h1 className="scraper-h1">Команда</h1>
<p className="scraper-result scraper-result--error">
Войдите в систему, чтобы управлять командой.
</p>
<a
href={TEAM_LOGIN_NEXT}
className="team-btn-primary"
style={{ display: "inline-block", textDecoration: "none" }}
>
Войти
</a>
</main>
</>
);
}
// Backend 403 регардлесс роли фронта (напр. session истекла между /me и
// /employees) — тот же fallback, что и в admin/audit.
if (employeesQ.error instanceof HTTPError && employeesQ.error.status === 403) {
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

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

View file

@ -1,219 +0,0 @@
"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

@ -1,94 +0,0 @@
"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

@ -1,205 +0,0 @@
"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

@ -1,144 +0,0 @@
"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,7 +7,6 @@ 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 пусто до тех
@ -85,8 +84,7 @@ export type ActiveTab =
| "cian" | "cian"
| "yandex" | "yandex"
| "audit" | "audit"
| "analytics" | "analytics";
| "team";
interface TopbarProps { interface TopbarProps {
active: ActiveTab; active: ActiveTab;
@ -113,13 +111,6 @@ 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/**).
@ -171,16 +162,6 @@ 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) {
@ -195,10 +176,8 @@ 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( ? NAV_ITEMS.filter((item) =>
(item) => isPathAllowed(data.allowed_paths, data.deny_paths, item.scopePath),
isPathAllowed(data.allowed_paths, data.deny_paths, item.scopePath) &&
(item.roleGate ? item.roleGate(data.role) : true),
) )
: NAV_ITEMS; : NAV_ITEMS;

View file

@ -2820,322 +2820,3 @@ 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

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

View file

@ -1,195 +0,0 @@
"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 "Не удалось выполнить запрос. Попробуйте ещё раз";
}