Compare commits
18 commits
feat/trade
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f6b68b6eca | |||
|
|
2b24408c3f | ||
| 69fd0ba260 | |||
|
|
a3628843c3 | ||
|
|
01bf4a91e4 | ||
| 80d54cb5cd | |||
| a916ceea52 | |||
| 1364cd8e7a | |||
|
|
1ed0338b95 | ||
|
|
df943ea1c7 | ||
|
|
f915e1ee4d | ||
|
|
f112a4affb | ||
|
|
2e05a16a14 | ||
|
|
e7a658c7fa | ||
| 4128564341 | |||
|
|
4ac3971061 | ||
| acba900f21 | |||
|
|
712c56f456 |
16 changed files with 3445 additions and 18 deletions
81
Caddyfile
81
Caddyfile
|
|
@ -11,6 +11,13 @@
|
||||||
# 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
|
||||||
|
|
@ -70,26 +77,56 @@ 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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# Auth gate (applies to all routes below within this route block).
|
# #2558: Trade-In MVP subproject (tradein-mvp/) — gendesign-tradein docker
|
||||||
import caddy/users.caddy.snippet
|
# stack, подключен через gendesign_shared network. Секция ЦЕЛИКОМ ДО
|
||||||
|
# `import caddy/users.caddy.snippet` ниже — /trade-in имеет собственную
|
||||||
# Trade-In MVP subproject (tradein-mvp/) — gendesign-tradein docker stack,
|
# авторизацию (форма входа + opaque session-cookie, #2552; RBAC-проверка
|
||||||
# подключен через gendesign_shared network. Routes ДО универсального handle
|
# роли внутри tradein-backend, `app/core/rbac.py`), Site Finder basic_auth
|
||||||
# потому что Caddy матчит handle-блоки сверху вниз.
|
# ей больше не нужен и не должен применяться (short-circuit сверху вниз,
|
||||||
|
# как /health и /preview/* выше).
|
||||||
|
#
|
||||||
|
# X-Authenticated-User — ЯВНОЕ УДАЛЕНИЕ (`header_up -X-Authenticated-User`),
|
||||||
|
# НЕ `header_up X-Authenticated-User {http.auth.user.id}`. Причина: этот
|
||||||
|
# блок больше не идёт ПОСЛЕ basic_auth, поэтому `{http.auth.user.id}`
|
||||||
|
# никогда не резолвится авторизованным юзером на этом пути.
|
||||||
|
# Проверено эмпирически (echo-стенд на образе caddy:2, `caddy adapt`):
|
||||||
|
# старая Set-форма (`header_up X-Authenticated-User {http.auth.user.id}`)
|
||||||
|
# НЕ пропустила бы клиентский заголовок насквозь и НЕ оставила бы поле
|
||||||
|
# пустым — Caddy подставляет НЕРАЗРЕШЁННЫЙ плейсхолдер как ЛИТЕРАЛЬНУЮ
|
||||||
|
# строку (`ReplaceKnown`), т.е. upstream получил бы буквально
|
||||||
|
# `X-Authenticated-User: {http.auth.user.id}`. Для backend (auth_mode=
|
||||||
|
# "dual", `app/core/config.py`) это НЕ подмена личности — legacy path
|
||||||
|
# (`rbac.py:186`) сделал бы `get_role("{http.auth.user.id}")`, юзер не
|
||||||
|
# найден в roles.yaml → 403 для всех. Т.е. старая форма была бы не
|
||||||
|
# security-дырой, а fail-closed-but-сломанной (все trade-in запросы без
|
||||||
|
# session-cookie получали бы 403 вместо ожидаемого 401/редиректа на логин).
|
||||||
|
# `-Field` остаётся правильным выбором не потому что Set был бы дырой, а
|
||||||
|
# потому что это ЕДИНСТВЕННАЯ форма с явно задокументированной семантикой
|
||||||
|
# "удалить заголовок" (Caddyfile reverse_proxy directive: `-<field>` =
|
||||||
|
# delete) — корректное поведение не должно зависеть от того, как именно
|
||||||
|
# Caddy трактует нерезолвленный/пустой плейсхолдер в Set-операции.
|
||||||
|
# X-Internal-Auth-Secret НЕ трогаем — #2213-секрет всегда перезаписывается
|
||||||
|
# из env (Set-операция с непустым значением, никак не связана с auth-гейтом
|
||||||
|
# basic_auth), это единственное, что теперь отсекает подделку заголовков
|
||||||
|
# изнутри gendesign_shared network для legacy dual-mode пути.
|
||||||
handle /trade-in/api/* {
|
handle /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 {http.auth.user.id}
|
header_up -X-Authenticated-User
|
||||||
# #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}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -98,6 +135,16 @@ 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
|
||||||
|
|
@ -110,13 +157,17 @@ 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 {
|
||||||
header_up X-Authenticated-User {http.auth.user.id}
|
# См. комментарий над /trade-in/api/* выше — та же логика (явное
|
||||||
# #2213: симметрично с /trade-in/api/* — перезаписываем секрет из env
|
# удаление вместо Set с пустым {http.auth.user.id}).
|
||||||
# (стирает клиентский), на случай SSR-forwardʼa фронтом в backend.
|
header_up -X-Authenticated-User
|
||||||
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}
|
||||||
|
|
|
||||||
663
tradein-mvp/backend/app/api/v1/team.py
Normal file
663
tradein-mvp/backend/app/api/v1/team.py
Normal 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]
|
||||||
|
|
@ -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"])
|
||||||
|
|
|
||||||
110
tradein-mvp/backend/app/schemas/team.py
Normal file
110
tradein-mvp/backend/app/schemas/team.py
Normal 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
|
||||||
127
tradein-mvp/backend/data/sql/193_tradein_users_seed.sql
Normal file
127
tradein-mvp/backend/data/sql/193_tradein_users_seed.sql
Normal 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;
|
||||||
1031
tradein-mvp/backend/tests/test_team_api.py
Normal file
1031
tradein-mvp/backend/tests/test_team_api.py
Normal file
File diff suppressed because it is too large
Load diff
180
tradein-mvp/frontend/src/app/team/page.tsx
Normal file
180
tradein-mvp/frontend/src/app/team/page.tsx
Normal file
|
|
@ -0,0 +1,180 @@
|
||||||
|
"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}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -53,6 +53,7 @@ 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,
|
||||||
|
|
@ -666,6 +667,21 @@ 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).
|
||||||
|
|
@ -978,6 +994,7 @@ 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
|
||||||
|
|
|
||||||
219
tradein-mvp/frontend/src/components/team/CreateEmployeeForm.tsx
Normal file
219
tradein-mvp/frontend/src/components/team/CreateEmployeeForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
205
tradein-mvp/frontend/src/components/team/EmployeeTable.tsx
Normal file
205
tradein-mvp/frontend/src/components/team/EmployeeTable.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
144
tradein-mvp/frontend/src/components/team/QuotaEditor.tsx
Normal file
144
tradein-mvp/frontend/src/components/team/QuotaEditor.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@
|
||||||
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";
|
||||||
|
|
@ -35,6 +37,12 @@ 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).
|
||||||
|
|
@ -57,6 +65,24 @@ 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
|
||||||
|
|
@ -74,6 +100,7 @@ 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();
|
||||||
|
|
@ -489,6 +516,28 @@ 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",
|
||||||
|
|
|
||||||
195
tradein-mvp/frontend/src/lib/team-api.ts
Normal file
195
tradein-mvp/frontend/src/lib/team-api.ts
Normal 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 =
|
||||||
|
"Латиница, цифры, точка, дефис, подчёркивание; 3–64 символа";
|
||||||
|
|
||||||
|
// ---- 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 "Не удалось выполнить запрос. Попробуйте ещё раз";
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue