Compare commits
No commits in common. "main" and "feat/tradein-auth-core" have entirely different histories.
main
...
feat/trade
10 changed files with 8 additions and 2323 deletions
|
|
@ -1,663 +0,0 @@
|
|||
"""Team-management API — CRUD сотрудников, квоты, история (#2554, эпик #2549).
|
||||
|
||||
Mounted at `/api/v1/team`; через Caddy `uri strip_prefix /trade-in` это
|
||||
`/trade-in/api/v1/team/*` снаружи. `app.services.auth_session.DB_ROLE_PATHS`
|
||||
уже закладывает `/api/v1/team/**` в scope роли `manager` (и `admin` через `/**`)
|
||||
для `rbac_guard` (см. `app.core.rbac`) — этот роутер добавляет ВТОРОЙ,
|
||||
более узкий барьер именно на identity:
|
||||
|
||||
- `current_team_actor` резолвит юзера ТОЛЬКО из session-cookie
|
||||
(`app.services.auth_session.get_session_user`). Legacy
|
||||
`X-Authenticated-User` (Caddy trusted-header, dual-mode) НЕ принимается
|
||||
здесь — team-API новый, не участвует в переходном dual-mode auth. Без
|
||||
валидной cookie — 401, даже если `rbac_guard` пропустил запрос по
|
||||
legacy-заголовку (напр. admin через roles.yaml).
|
||||
- Роль должна быть `admin` или `manager` — иначе 403.
|
||||
|
||||
Org-изоляция (главный инвариант фичи): manager видит/меняет ТОЛЬКО своих
|
||||
employee (`tradein_users.manager_id = actor.user_id`). Чужой/несуществующий
|
||||
employee_id → 404 (НЕ 403) — не подтверждаем/не опровергаем существование
|
||||
чужого сотрудника перед manager'ом. См. `_authorize_employee`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Annotated, Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.engine import RowMapping
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.auth import get_role
|
||||
from app.core.config import settings
|
||||
from app.core.db import get_db
|
||||
from app.core.password import hash_password
|
||||
from app.schemas.team import (
|
||||
EmployeeCreateRequest,
|
||||
EmployeeHistoryEntry,
|
||||
EmployeeOut,
|
||||
EmployeeUpdateRequest,
|
||||
QuotaStatusOut,
|
||||
)
|
||||
from app.services import account_quota
|
||||
from app.services.auth_session import get_session_user, revoke_user_sessions
|
||||
from app.services.user_events import schedule_event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@dataclass
|
||||
class TeamActor:
|
||||
"""Резолвленный из session-cookie актёр team-API — admin или manager."""
|
||||
|
||||
user_id: int
|
||||
username: str
|
||||
role: str # "admin" | "manager"
|
||||
|
||||
|
||||
async def current_team_actor(
|
||||
request: Request,
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
) -> TeamActor:
|
||||
"""Dependency: session-only identity, роль admin|manager, иначе 401/403.
|
||||
|
||||
Намеренно НЕ читает `X-Authenticated-User` — см. модульный docstring.
|
||||
"""
|
||||
token = request.cookies.get(settings.session_cookie_name)
|
||||
if not token:
|
||||
raise HTTPException(status_code=401, detail="valid session required")
|
||||
|
||||
try:
|
||||
session_user = get_session_user(db, token)
|
||||
except Exception:
|
||||
logger.exception("team: session lookup failed")
|
||||
raise HTTPException(status_code=401, detail="valid session required") from None
|
||||
|
||||
if session_user is None:
|
||||
raise HTTPException(status_code=401, detail="valid session required")
|
||||
|
||||
role = session_user["role"]
|
||||
if role not in ("admin", "manager"):
|
||||
raise HTTPException(status_code=403, detail="admin or manager role required")
|
||||
|
||||
return TeamActor(
|
||||
user_id=session_user["user_id"],
|
||||
username=session_user["username"],
|
||||
role=role,
|
||||
)
|
||||
|
||||
|
||||
def _origin_host_allowed(candidate: str) -> bool:
|
||||
"""True если scheme://netloc *candidate* совпадает с одним из `settings.cors_origins`.
|
||||
|
||||
`cors_origins` уже является источником правды для «какие origin'ы это наш
|
||||
фронт» (см. CORSMiddleware в app/main.py, ENV CORS_ORIGINS) — переиспользуем
|
||||
его вместо нового хардкода."""
|
||||
try:
|
||||
parsed = urlparse(candidate)
|
||||
except ValueError:
|
||||
return False
|
||||
if not parsed.scheme or not parsed.netloc:
|
||||
return False
|
||||
origin = f"{parsed.scheme}://{parsed.netloc}"
|
||||
return origin in settings.cors_origins
|
||||
|
||||
|
||||
def _require_same_origin(request: Request) -> None:
|
||||
"""CSRF defense-in-depth (issue #2554 DoD) для state-changing team-роутов
|
||||
(POST/PATCH): `Origin` (или `Referer` как fallback) обязан матчить один из
|
||||
`settings.cors_origins`, иначе 403.
|
||||
|
||||
Оба заголовка отсутствуют → ПРОПУСКАЕМ (не 403). Причина: это единственный
|
||||
надёжный сигнал non-browser клиента в этом стеке — curl-смоуки внутри
|
||||
контейнера (см. `.claude/rules/tradein.md` "Тестировать HTTP только ВНУТРИ
|
||||
контейнера", `docker exec tradein-backend curl ...`) не шлют ни один из этих
|
||||
заголовков, а реальный браузер (fetch/XHR/form) ВСЕГДА прикладывает Origin
|
||||
на unsafe-методах (POST/PATCH) — так что "оба отсутствуют" практически
|
||||
невозможно для настоящего кросс-сайтового CSRF через браузер. Session-cookie
|
||||
уже стоит на `SameSite=Lax` (см. `app.api.v1.auth.login`) — это первый рубеж
|
||||
против CSRF, Origin-check — второй.
|
||||
"""
|
||||
candidate = request.headers.get("origin") or request.headers.get("referer")
|
||||
if candidate is None:
|
||||
return
|
||||
if not _origin_host_allowed(candidate):
|
||||
logger.warning(
|
||||
"team: Origin/Referer mismatch %r on %s — possible CSRF", candidate, request.url.path
|
||||
)
|
||||
raise HTTPException(status_code=403, detail="origin not allowed")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _fetch_employee_row(db: Session, employee_id: int) -> RowMapping | None:
|
||||
return (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT id, username, display_name, org_name, email, is_active,
|
||||
manager_id, created_at
|
||||
FROM tradein_users
|
||||
WHERE id = :id AND role = 'employee'
|
||||
"""
|
||||
),
|
||||
{"id": employee_id},
|
||||
)
|
||||
.mappings()
|
||||
.fetchone()
|
||||
)
|
||||
|
||||
|
||||
def _authorize_employee(actor: TeamActor, row: RowMapping | None) -> RowMapping:
|
||||
"""404 (НЕ 403) если сотрудник не найден ИЛИ принадлежит другому manager'у.
|
||||
|
||||
Org-изоляция: manager может видеть/менять только `manager_id == actor.user_id`.
|
||||
404 вместо 403 — не палим существование чужого employee_id.
|
||||
"""
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="employee not found")
|
||||
if actor.role == "manager" and row["manager_id"] != actor.user_id:
|
||||
raise HTTPException(status_code=404, detail="employee not found")
|
||||
return row
|
||||
|
||||
|
||||
def _upsert_quota_override(
|
||||
db: Session, username: str, monthly_limit: int, actor_username: str
|
||||
) -> None:
|
||||
"""Upsert персонального лимита. Явная установка monthly_limit — сигнал "хочу
|
||||
numeric-квоту", поэтому ВСЕГДА сбрасывает `unlimited=false` (иначе лимит может
|
||||
молча не применяться — прежний unlimited-грант выигрывал бы у нового limit).
|
||||
`note` — НЕ затирается, если уже задан (`COALESCE`): не перезаписываем
|
||||
человеко-читаемую причину прошлого гранта (напр. "пилот, грант ...") молча
|
||||
сгенерированной строкой; note проставляется только при первом upsert записи.
|
||||
"""
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO account_quota_overrides (username, monthly_limit, unlimited, note)
|
||||
VALUES (:username, CAST(:monthly_limit AS integer), false, :note)
|
||||
ON CONFLICT (username) DO UPDATE SET
|
||||
monthly_limit = EXCLUDED.monthly_limit,
|
||||
unlimited = false,
|
||||
note = COALESCE(account_quota_overrides.note, EXCLUDED.note),
|
||||
updated_at = now()
|
||||
"""
|
||||
),
|
||||
{
|
||||
"username": username,
|
||||
"monthly_limit": monthly_limit,
|
||||
"note": f"team-api: set by {actor_username}",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _batch_quota_status(db: Session, usernames: list[str]) -> dict[str, dict[str, Any]]:
|
||||
"""Батч-версия `account_quota.get_status` для N сотрудников — 2 SQL-запроса
|
||||
вместо 2N (было 2N+3 на GET /employees, HIGH/Medium2 review PR #2563).
|
||||
|
||||
Семантика ИДЕНТИЧНА `account_quota.is_unlimited`/`user_limit`/`get_status`
|
||||
(follow-up review PR #2563 п.2 — предыдущая версия расходилась: батч ВСЕГДА
|
||||
читал `account_quota_overrides.unlimited`, а `is_unlimited` — ТОЛЬКО для
|
||||
username, присутствующего в roles.yaml):
|
||||
- username НЕ в roles.yaml (`get_role` → KeyError) → unlimited=False ВСЕГДА,
|
||||
`account_quota_overrides.unlimited` даже не проверяется (roles.yaml —
|
||||
источник правды "кто вообще может быть unlimited", override — "у кого
|
||||
именно из известных roles.yaml-юзеров"). Сегодня недостижимо для DB-only
|
||||
сотрудников team-API (`_upsert_quota_override` всегда пишет
|
||||
`unlimited=false`), но станет достижимым при ручном UPDATE
|
||||
`account_quota_overrides` или расширении roles.yaml — расхождение с
|
||||
реальным enforcement (`check_and_raise`/`increment`, тот же `is_unlimited`)
|
||||
было бы честной ложью в списке: "без лимита", который движок всё равно
|
||||
считает.
|
||||
- username в roles.yaml и role == admin → unlimited=True (без похода в БД).
|
||||
- username в roles.yaml, role != admin → unlimited = override.unlimited.
|
||||
limit = override.monthly_limit (читается для ЛЮБОГО username, без gate по
|
||||
roles.yaml — так же ведёт себя `account_quota.user_limit`), иначе глобальный
|
||||
`account_quota.MONTHLY_LIMIT`.
|
||||
"""
|
||||
if not usernames:
|
||||
return {}
|
||||
|
||||
overrides = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT username, monthly_limit, unlimited
|
||||
FROM account_quota_overrides
|
||||
WHERE username = ANY(CAST(:usernames AS text[]))
|
||||
"""
|
||||
),
|
||||
{"usernames": usernames},
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
override_by_username = {r["username"]: r for r in overrides}
|
||||
|
||||
period = account_quota.current_period()
|
||||
usage_rows = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT username, used
|
||||
FROM account_estimate_usage
|
||||
WHERE username = ANY(CAST(:usernames AS text[])) AND period_month = :period
|
||||
"""
|
||||
),
|
||||
{"usernames": usernames, "period": period},
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
used_by_username = {r["username"]: r["used"] for r in usage_rows}
|
||||
|
||||
result: dict[str, dict[str, Any]] = {}
|
||||
for username in usernames:
|
||||
override = override_by_username.get(username)
|
||||
try:
|
||||
role = get_role(username)
|
||||
except KeyError:
|
||||
role = None
|
||||
if role == "admin":
|
||||
unlimited = True
|
||||
elif role is not None:
|
||||
unlimited = bool(override is not None and override["unlimited"])
|
||||
else:
|
||||
# username не в roles.yaml — is_unlimited() короткое замыкание на
|
||||
# False, override НЕ проверяется (см. докстринг выше).
|
||||
unlimited = False
|
||||
limit = (
|
||||
int(override["monthly_limit"])
|
||||
if override is not None and override["monthly_limit"] is not None
|
||||
else account_quota.MONTHLY_LIMIT
|
||||
)
|
||||
used = used_by_username.get(username, 0)
|
||||
if unlimited:
|
||||
result[username] = {
|
||||
"limit": limit,
|
||||
"used": used,
|
||||
"remaining": limit,
|
||||
"unlimited": True,
|
||||
}
|
||||
else:
|
||||
remaining = max(0, limit - max(0, used))
|
||||
result[username] = {
|
||||
"limit": limit,
|
||||
"used": used,
|
||||
"remaining": remaining,
|
||||
"unlimited": False,
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
def _employee_out(row: RowMapping, quota: dict[str, Any]) -> EmployeeOut:
|
||||
return EmployeeOut(
|
||||
id=row["id"],
|
||||
username=row["username"],
|
||||
display_name=row["display_name"],
|
||||
org_name=row["org_name"],
|
||||
email=row["email"],
|
||||
is_active=row["is_active"],
|
||||
manager_id=row["manager_id"],
|
||||
created_at=row["created_at"],
|
||||
quota=QuotaStatusOut(**quota),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /employees
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/employees", response_model=EmployeeOut, status_code=201)
|
||||
async def create_employee(
|
||||
body: EmployeeCreateRequest,
|
||||
actor: Annotated[TeamActor, Depends(current_team_actor)],
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
_origin_check: Annotated[None, Depends(_require_same_origin)],
|
||||
) -> EmployeeOut:
|
||||
"""Создать сотрудника. Роль всегда `employee`.
|
||||
|
||||
manager_id: для actor.role == manager — принудительно свой id (любое
|
||||
значение из тела ИГНОРИРУЕТСЯ, org-изоляция инвариант #2554). Для
|
||||
actor.role == admin — опционально из тела, валидируется что указанный id
|
||||
существует и role='manager' (иначе 422).
|
||||
"""
|
||||
existing = db.execute(
|
||||
text("SELECT id FROM tradein_users WHERE username = :u"),
|
||||
{"u": body.username},
|
||||
).fetchone()
|
||||
if existing is not None:
|
||||
raise HTTPException(status_code=409, detail="username already exists")
|
||||
|
||||
try:
|
||||
password_hash = hash_password(body.password)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=422, detail=str(e)) from None
|
||||
|
||||
manager_id: int | None
|
||||
if actor.role == "manager":
|
||||
# Инвариант org-изоляции: manager не может создать сотрудника под
|
||||
# чужим manager_id — любое значение из тела игнорируется молча.
|
||||
manager_id = actor.user_id
|
||||
else:
|
||||
manager_id = body.manager_id
|
||||
if manager_id is not None:
|
||||
mgr = db.execute(
|
||||
text("SELECT id FROM tradein_users WHERE id = :id AND role = 'manager'"),
|
||||
{"id": manager_id},
|
||||
).fetchone()
|
||||
if mgr is None:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="manager_id does not reference an existing manager",
|
||||
)
|
||||
|
||||
try:
|
||||
row = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO tradein_users
|
||||
(username, password_hash, role, manager_id, display_name, org_name,
|
||||
email, is_active)
|
||||
VALUES
|
||||
(:username, :password_hash, 'employee', :manager_id, :display_name,
|
||||
:org_name, :email, true)
|
||||
RETURNING id, username, display_name, org_name, email, is_active,
|
||||
manager_id, created_at
|
||||
"""
|
||||
),
|
||||
{
|
||||
"username": body.username,
|
||||
"password_hash": password_hash,
|
||||
"manager_id": manager_id,
|
||||
"display_name": body.display_name,
|
||||
"org_name": body.org_name,
|
||||
"email": body.email,
|
||||
},
|
||||
)
|
||||
.mappings()
|
||||
.fetchone()
|
||||
)
|
||||
except IntegrityError:
|
||||
# TOCTOU: два конкурентных POST с одинаковым username между pre-check
|
||||
# выше и этим INSERT — UNIQUE-констрейнт на tradein_users.username ловит.
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=409, detail="username already exists") from None
|
||||
|
||||
assert row is not None # RETURNING на успешный INSERT всегда отдаёт строку
|
||||
|
||||
if body.monthly_limit is not None:
|
||||
_upsert_quota_override(db, body.username, body.monthly_limit, actor.username)
|
||||
|
||||
db.commit()
|
||||
|
||||
schedule_event(
|
||||
event_type="employee_created",
|
||||
username=actor.username,
|
||||
payload={
|
||||
"employee_id": row["id"],
|
||||
"employee_username": row["username"],
|
||||
"manager_id": manager_id,
|
||||
},
|
||||
)
|
||||
|
||||
quota = account_quota.get_status(db, body.username)
|
||||
return _employee_out(row, quota)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PATCH /employees/{id}
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.patch("/employees/{employee_id}", response_model=EmployeeOut)
|
||||
async def update_employee(
|
||||
employee_id: int,
|
||||
body: EmployeeUpdateRequest,
|
||||
actor: Annotated[TeamActor, Depends(current_team_actor)],
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
_origin_check: Annotated[None, Depends(_require_same_origin)],
|
||||
) -> EmployeeOut:
|
||||
"""Частичное обновление сотрудника — block/unblock, лимит, профиль, пароль.
|
||||
|
||||
manager может патчить ТОЛЬКО своих (manager_id == actor.user_id), иначе 404.
|
||||
При is_active=False ИЛИ смене пароля (new_password) — обязательно revoke всех
|
||||
сессий (HIGH, deep-review PR #2563): без этого блокировка/reset не подействуют
|
||||
до истечения TTL текущей сессии сотрудника — хуже того, sliding-refresh
|
||||
(`app.services.auth_session.get_session_user`) продлевает `expires_at` на
|
||||
КАЖДОМ запросе, так что скомпрометированная/чужая сессия живёт неограниченно
|
||||
долго, а не «до TTL». `revoke_user_sessions` сам называет смену пароля своим
|
||||
use-case — см. его докстринг.
|
||||
"""
|
||||
row = _fetch_employee_row(db, employee_id)
|
||||
row = _authorize_employee(actor, row)
|
||||
|
||||
new_password_hash: str | None = None
|
||||
if body.new_password is not None:
|
||||
try:
|
||||
new_password_hash = hash_password(body.new_password)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=422, detail=str(e)) from None
|
||||
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE tradein_users
|
||||
SET display_name = COALESCE(:display_name, display_name),
|
||||
org_name = COALESCE(:org_name, org_name),
|
||||
email = COALESCE(:email, email),
|
||||
is_active = COALESCE(CAST(:is_active AS boolean), is_active),
|
||||
password_hash = COALESCE(:password_hash, password_hash),
|
||||
updated_at = now()
|
||||
WHERE id = :id
|
||||
"""
|
||||
),
|
||||
{
|
||||
"display_name": body.display_name,
|
||||
"org_name": body.org_name,
|
||||
"email": body.email,
|
||||
"is_active": body.is_active,
|
||||
"password_hash": new_password_hash,
|
||||
"id": employee_id,
|
||||
},
|
||||
)
|
||||
|
||||
if body.monthly_limit is not None:
|
||||
_upsert_quota_override(db, row["username"], body.monthly_limit, actor.username)
|
||||
|
||||
if body.is_active is False or body.new_password is not None:
|
||||
# Обязательно ПОСЛЕ UPDATE, ДО финального commit — revoke_user_sessions
|
||||
# коммитит сам (см. app.services.auth_session), это флашит и наш
|
||||
# предшествующий UPDATE/quota-upsert в той же сессии. Self-lockout
|
||||
# невозможен: _fetch_employee_row фильтрует role='employee', actor
|
||||
# (admin|manager) никогда не может патчить сам себя через этот роут.
|
||||
revoke_user_sessions(db, employee_id)
|
||||
|
||||
db.commit()
|
||||
|
||||
changed_profile_fields = [
|
||||
f
|
||||
for f, v in (
|
||||
("display_name", body.display_name),
|
||||
("org_name", body.org_name),
|
||||
("email", body.email),
|
||||
)
|
||||
if v is not None
|
||||
]
|
||||
if changed_profile_fields:
|
||||
schedule_event(
|
||||
event_type="employee_updated",
|
||||
username=actor.username,
|
||||
payload={
|
||||
"employee_id": employee_id,
|
||||
"employee_username": row["username"],
|
||||
"fields": changed_profile_fields,
|
||||
},
|
||||
)
|
||||
if body.new_password is not None:
|
||||
schedule_event(
|
||||
event_type="employee_password_reset",
|
||||
username=actor.username,
|
||||
payload={"employee_id": employee_id, "employee_username": row["username"]},
|
||||
)
|
||||
if body.is_active is not None:
|
||||
schedule_event(
|
||||
event_type="employee_blocked" if body.is_active is False else "employee_unblocked",
|
||||
username=actor.username,
|
||||
payload={"employee_id": employee_id, "employee_username": row["username"]},
|
||||
)
|
||||
if body.monthly_limit is not None:
|
||||
schedule_event(
|
||||
event_type="quota_changed",
|
||||
username=actor.username,
|
||||
payload={
|
||||
"employee_id": employee_id,
|
||||
"employee_username": row["username"],
|
||||
"monthly_limit": body.monthly_limit,
|
||||
},
|
||||
)
|
||||
|
||||
updated_row = _fetch_employee_row(db, employee_id)
|
||||
assert updated_row is not None # только что успешно обновили эту же строку
|
||||
quota = account_quota.get_status(db, updated_row["username"])
|
||||
return _employee_out(updated_row, quota)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /employees
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
# Два статических варианта WHERE (НЕ f-string/динамическая сборка — Medium/
|
||||
# "заодно" review PR #2563: значения биндятся параметрами и без того безопасны,
|
||||
# но статические ветки не провоцируют будущие правки в сторону конкатенации SQL).
|
||||
#
|
||||
# ORDER BY created_at DESC, id DESC — тай-брейкер по `id` ОБЯЗАТЕЛЕН (follow-up
|
||||
# review PR #2563 п.1): `created_at DEFAULT now()` — время ТРАНЗАКЦИИ, а bulk-seed
|
||||
# (#2557) вставляет много юзеров одной транзакцией → идентичный timestamp у N строк.
|
||||
# Без тай-брейкера порядок между страницами (LIMIT/OFFSET) на PostgreSQL для
|
||||
# строк-«близнецов» не гарантирован — сотрудники пропадали/дублировались бы при
|
||||
# постраничном листании. `id` монотонно растёт (BIGINT IDENTITY) — детерминированный
|
||||
# tie-break без доп. индекса (созданные позже = бОльший id, тот же порядок что и
|
||||
# намерение DESC-сортировки по времени).
|
||||
_LIST_EMPLOYEES_BY_MANAGER_SQL = text(
|
||||
"""
|
||||
SELECT id, username, display_name, org_name, email, is_active, manager_id, created_at
|
||||
FROM tradein_users
|
||||
WHERE role = 'employee' AND manager_id = :manager_id
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT :limit OFFSET :offset
|
||||
"""
|
||||
)
|
||||
|
||||
_LIST_EMPLOYEES_ALL_SQL = text(
|
||||
"""
|
||||
SELECT id, username, display_name, org_name, email, is_active, manager_id, created_at
|
||||
FROM tradein_users
|
||||
WHERE role = 'employee'
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT :limit OFFSET :offset
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
@router.get("/employees", response_model=list[EmployeeOut])
|
||||
async def list_employees(
|
||||
actor: Annotated[TeamActor, Depends(current_team_actor)],
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
manager_id: Annotated[int | None, Query()] = None,
|
||||
limit: Annotated[int, Query(ge=1, le=200)] = 50,
|
||||
offset: Annotated[int, Query(ge=0)] = 0,
|
||||
) -> list[EmployeeOut]:
|
||||
"""Список сотрудников. manager видит только своих; admin — всех, опц. ?manager_id=.
|
||||
|
||||
Квота — ОДИН батч-запрос на всю страницу (`_batch_quota_status`), не N+1
|
||||
(Medium2, review PR #2563: было 2N+3 SQL-запросов на N сотрудников).
|
||||
"""
|
||||
if actor.role == "manager":
|
||||
rows = (
|
||||
db.execute(
|
||||
_LIST_EMPLOYEES_BY_MANAGER_SQL,
|
||||
{"manager_id": actor.user_id, "limit": limit, "offset": offset},
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
elif manager_id is not None:
|
||||
rows = (
|
||||
db.execute(
|
||||
_LIST_EMPLOYEES_BY_MANAGER_SQL,
|
||||
{"manager_id": manager_id, "limit": limit, "offset": offset},
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
else:
|
||||
rows = (
|
||||
db.execute(_LIST_EMPLOYEES_ALL_SQL, {"limit": limit, "offset": offset}).mappings().all()
|
||||
)
|
||||
|
||||
quota_by_username = _batch_quota_status(db, [row["username"] for row in rows])
|
||||
return [_employee_out(row, quota_by_username[row["username"]]) for row in rows]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /employees/{id}/history
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/employees/{employee_id}/history", response_model=list[EmployeeHistoryEntry])
|
||||
async def employee_history(
|
||||
employee_id: int,
|
||||
actor: Annotated[TeamActor, Depends(current_team_actor)],
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
limit: Annotated[int, Query(ge=1, le=200)] = 50,
|
||||
offset: Annotated[int, Query(ge=0)] = 0,
|
||||
) -> list[EmployeeHistoryEntry]:
|
||||
"""История оценок сотрудника (адрес/дата/результат) — из `user_events`,
|
||||
LEFT JOIN `trade_in_estimates` за фактическим результатом.
|
||||
|
||||
Та же org-проверка что и в PATCH: чужой employee_id → 404.
|
||||
"""
|
||||
row = _fetch_employee_row(db, employee_id)
|
||||
row = _authorize_employee(actor, row)
|
||||
|
||||
rows = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT
|
||||
CAST(ue.estimate_id AS text) AS estimate_id,
|
||||
ue.payload ->> 'address' AS address,
|
||||
ue.payload ->> 'area_m2' AS area_m2,
|
||||
ue.payload ->> 'rooms' AS rooms,
|
||||
te.median_price,
|
||||
te.confidence,
|
||||
te.n_analogs,
|
||||
ue.created_at
|
||||
FROM user_events ue
|
||||
LEFT JOIN trade_in_estimates te ON te.id = ue.estimate_id
|
||||
WHERE ue.username = :username AND ue.event_type = 'estimate_request'
|
||||
ORDER BY ue.created_at DESC
|
||||
LIMIT :limit OFFSET :offset
|
||||
"""
|
||||
),
|
||||
{"username": row["username"], "limit": limit, "offset": offset},
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
|
||||
return [EmployeeHistoryEntry.model_validate(dict(r)) for r in rows]
|
||||
|
|
@ -31,7 +31,6 @@ from app.api.v1 import (
|
|||
me,
|
||||
search,
|
||||
support,
|
||||
team,
|
||||
trade_in,
|
||||
)
|
||||
from app.core.config import settings
|
||||
|
|
@ -184,4 +183,3 @@ app.include_router(support.router, prefix="/api/v1/trade-in", tags=["trade-in-su
|
|||
app.include_router(buildings.router, prefix="/api/v1/buildings", tags=["buildings"])
|
||||
app.include_router(search.router, prefix="/api/v1", tags=["search"])
|
||||
app.include_router(me.router, prefix="/api/v1", tags=["me"])
|
||||
app.include_router(team.router, prefix="/api/v1/team", tags=["team"])
|
||||
|
|
|
|||
|
|
@ -1,110 +0,0 @@
|
|||
"""Pydantic-схемы team-management API (#2554, эпик #2549).
|
||||
|
||||
CRUD сотрудников (`tradein_users.role = 'employee'`), квоты, история оценок.
|
||||
Org-изоляция (manager видит/меняет только своих employee) реализована в
|
||||
`app.api.v1.team`, эти схемы — только форма запросов/ответов.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
# ASCII-only — не-ASCII username ломает downstream identity-пропагацию
|
||||
# (`app.core.rbac._propagate_authenticated_user` кодирует latin-1 с
|
||||
# errors="replace"), поэтому валидация формы обязательна на границе API,
|
||||
# а не только на уровне БД.
|
||||
#
|
||||
# `\Z`, НЕ `$` — deep-review seed #2564: в Python `$` матчит перед trailing
|
||||
# newline (`re.match(r'...\$', 'admin\n')` → True), а Postgres `~` в CHECK
|
||||
# tradein_users_username_ascii_ck (миграция 193) — False. С `$` строка
|
||||
# "admin\n" проходила бы Pydantic-валидацию и падала уже в БД → 500 вместо
|
||||
# честного 422. `\Z` — конец строки БЕЗ поблажки на trailing newline, совпадает
|
||||
# с семантикой Postgres `~`.
|
||||
_USERNAME_RE = re.compile(r"^[A-Za-z0-9._-]{3,64}\Z")
|
||||
|
||||
|
||||
class QuotaStatusOut(BaseModel):
|
||||
"""Статус месячной квоты оценок — вложен в `EmployeeOut`."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
limit: int
|
||||
used: int
|
||||
remaining: int
|
||||
unlimited: bool
|
||||
|
||||
|
||||
class EmployeeCreateRequest(BaseModel):
|
||||
"""`POST /employees` — создать сотрудника. Роль всегда `employee` (не в теле)."""
|
||||
|
||||
username: str
|
||||
password: str
|
||||
display_name: str | None = None
|
||||
org_name: str | None = None
|
||||
email: str | None = None
|
||||
monthly_limit: int | None = Field(default=None, ge=1)
|
||||
# Только для actor.role == admin — опциональная привязка к конкретному manager.
|
||||
# Для actor.role == manager это поле ИГНОРИРУЕТСЯ (принудительно свой id) —
|
||||
# см. app.api.v1.team.create_employee.
|
||||
manager_id: int | None = None
|
||||
|
||||
@field_validator("username")
|
||||
@classmethod
|
||||
def _validate_username(cls, v: str) -> str:
|
||||
if not _USERNAME_RE.match(v):
|
||||
raise ValueError(
|
||||
"username must be 3-64 ASCII chars: letters, digits, dot, underscore, hyphen"
|
||||
)
|
||||
return v
|
||||
|
||||
|
||||
class EmployeeUpdateRequest(BaseModel):
|
||||
"""`PATCH /employees/{id}` — частичное обновление, все поля опциональны."""
|
||||
|
||||
is_active: bool | None = None
|
||||
monthly_limit: int | None = Field(default=None, ge=1)
|
||||
display_name: str | None = None
|
||||
org_name: str | None = None
|
||||
email: str | None = None
|
||||
new_password: str | None = None
|
||||
|
||||
|
||||
class EmployeeOut(BaseModel):
|
||||
"""Одна строка в `GET /employees` + ответ `POST`/`PATCH /employees/{id}`."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
username: str
|
||||
display_name: str | None = None
|
||||
org_name: str | None = None
|
||||
email: str | None = None
|
||||
is_active: bool
|
||||
manager_id: int | None = None
|
||||
created_at: datetime
|
||||
quota: QuotaStatusOut
|
||||
|
||||
|
||||
class EmployeeHistoryEntry(BaseModel):
|
||||
"""Одна строка истории оценок сотрудника — `GET /employees/{id}/history`.
|
||||
|
||||
Источник — `user_events` (event_type='estimate_request', паттерн
|
||||
`app.api.v1.audit.account_drilldown`), LEFT JOIN на `trade_in_estimates`
|
||||
за фактическим результатом (median_price/confidence/n_analogs) — join
|
||||
может не сматчиться (старая запись без estimate_id / оценка insufficient_data),
|
||||
поэтому все result-поля nullable.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
estimate_id: str | None = None
|
||||
address: str | None = None
|
||||
area_m2: str | None = None
|
||||
rooms: str | None = None
|
||||
median_price: int | None = None
|
||||
confidence: str | None = None
|
||||
n_analogs: int | None = None
|
||||
created_at: datetime
|
||||
|
|
@ -1,127 +0,0 @@
|
|||
-- Migration 193: seed существующих юзеров в tradein_users + ASCII-CHECK на username
|
||||
-- (issue #2557, эпик #2549)
|
||||
--
|
||||
-- WHY:
|
||||
-- Migration 192 создала schema (tradein_users/tradein_sessions), но без данных —
|
||||
-- DB-backed auth не может заработать, пока реальные аккаунты (сейчас живущие только
|
||||
-- в auth/roles.yaml + caddy/users.caddy.snippet, legacy Caddy basic-auth) не отражены
|
||||
-- в таблице. Эта миграция переносит org-карту, утверждённую владельцем продукта,
|
||||
-- в данные — без единого пароля (см. WHAT.2) и без Python-кода (backend wiring — #2556,
|
||||
-- team-UI для проставления паролей — отдельная задача, тоже #2556).
|
||||
--
|
||||
-- ASCII-CHECK (deep-review #2561, обязательное требование ДО прод-данных):
|
||||
-- rbac кодирует session-username через `encode("latin-1", "replace")`. Кириллические
|
||||
-- логины ОДИНАКОВОЙ длины схлопываются в одну и ту же byte-строку под этой кодировкой
|
||||
-- ("иванов" и "петров" оба 6 кириллических символов -> оба превращаются в одинаковую
|
||||
-- строку из '?' одной длины) -> общий downstream-identity между разными людьми, общая
|
||||
-- квота, взаимный IDOR (один видит сессии/данные другого). Все текущие org-логины уже
|
||||
-- ASCII (admin/kopylov/praktika/userN), поэтому constraint не конфликтует с seed'ом
|
||||
-- ниже; он существует, чтобы navsegda запретить будущим кириллическим логинам попасть
|
||||
-- в таблицу — fail-closed на уровне схемы, а не на уровне доверия к тому, что кто-то
|
||||
-- не забудет проверить в UI/API layer.
|
||||
--
|
||||
-- WHAT:
|
||||
-- 1. ASCII-CHECK: tradein_users_username_ascii_ck CHECK (username ~ '^[A-Za-z0-9._-]{3,64}$').
|
||||
-- Добавлен ДО seed-инсертов ниже для читаемости файла (CHECK — immediate constraint,
|
||||
-- Postgres валидирует им и ROW-строки транзакции независимо от того, в каком месте
|
||||
-- файла он объявлен относительно INSERT, так что порядок сам по себе не критичен).
|
||||
-- 2. Seed — org-карта, утверждённая владельцем продукта (2026-07-30):
|
||||
-- admin role=admin, manager_id=NULL, is_active=true (владелец)
|
||||
-- kopylov role=manager, manager_id=NULL, is_active=true (отдельный клиент)
|
||||
-- praktika role=manager, manager_id=NULL, is_active=true (ГК «Практика»)
|
||||
-- user1, user3-10 role=employee, manager_id=NULL, is_active=true (свободные слоты, без org)
|
||||
-- user2 role=employee, manager_id=NULL, is_active=false («Брусника», доступ
|
||||
-- закрыт 2026-07-30)
|
||||
-- password_hash = NULL для ВСЕХ — пароли админ проставит вручную через team-UI (#2556).
|
||||
-- NULL-hash делает password-логин невозможным для этой строки, но НЕ снимает доступ:
|
||||
-- в переходный период работает только legacy Caddy basic-auth fallback (dual-mode,
|
||||
-- см. комментарий password_hash в 192_tradein_users_auth.sql) — никто не теряет доступ
|
||||
-- из-за этой миграции.
|
||||
-- display_name = 'Копылов' для kopylov (источник — auth.py::_USERNAME_PROFILE, уже
|
||||
-- задокументированная фамилия). Для остальных — NULL, реальных данных нет, не выдумываем.
|
||||
-- НЕ мигрируем admintest/pilottest/analysttest/expiredtest — temp QA-фикстуры
|
||||
-- (auth/roles.yaml), остаются только там, в DB-backed auth не нужны.
|
||||
--
|
||||
-- IDEMPOTENCY:
|
||||
-- - ADD CONSTRAINT через DO-блок с проверкой pg_constraint (Postgres не поддерживает
|
||||
-- `ADD CONSTRAINT IF NOT EXISTS` для CHECK) — паттерн из
|
||||
-- 189_account_estimate_usage_nonnegative.sql.
|
||||
-- - INSERT ... ON CONFLICT (username) DO UPDATE, но НЕ безусловно: password_hash,
|
||||
-- manager_id, display_name, org_name, email защищены COALESCE(текущее, EXCLUDED) —
|
||||
-- если админ уже проставил пароль / назначил manager_id (team-API #2563 пишет
|
||||
-- manager_id при создании сотрудника менеджером) / поменял display_name вручную
|
||||
-- через team-UI (#2556) между двумя прогонами этого файла (например ручной re-apply
|
||||
-- при recovery — обычный auto-apply тречит filename в _schema_migrations и не
|
||||
-- запускает файл дважды на одном окружении, но scratch/staging БД такого
|
||||
-- трекинга не имеют), повторный прогон НЕ должен затереть это состояние NULL-ом /
|
||||
-- seed-дефолтом. Deep-review #2564 нашёл это живым багом: manager_id, назначенный
|
||||
-- через #2563, тихо обнулялся повторным прогоном сида — employee выпадал из
|
||||
-- `_LIST_EMPLOYEES_BY_MANAGER_SQL`, менеджер переставал видеть его в дашборде.
|
||||
-- role намеренно синкается с EXCLUDED (не защищён) — это и есть источник истины
|
||||
-- org-карты из этой миграции; если владелец продукта поправит эту таблицу новой
|
||||
-- миграцией поверх, DO UPDATE-ветка должна донести исправление роли, а не
|
||||
-- заморозить первый прогон навсегда.
|
||||
-- - is_active НАМЕРЕННО отсутствует в SET (не COALESCE — колонка NOT NULL DEFAULT
|
||||
-- true, COALESCE(NOT NULL, x) никогда не берёт x, это была бы мёртвая, вводящая в
|
||||
-- заблуждение симметрия с password_hash/manager_id, deep-review #2564 medium).
|
||||
-- Открытие/закрытие доступа (is_active) — решение владельца продукта, принимается
|
||||
-- через UI (#2556), НЕ повторным прогоном этого seed-файла: после первой вставки
|
||||
-- колонка сознательно «замораживается» на текущем значении в БД, seed её больше
|
||||
-- не трогает.
|
||||
--
|
||||
-- Dependencies: 192_tradein_users_auth.sql (создаёт tradein_users, tradein_sessions).
|
||||
-- Deploy order: после 192 (Foundation). Backend auth-код (login/password-verify) и
|
||||
-- team-UI (#2556) — отдельные PR'ы ПОСЛЕ этой миграции (SQL-схема+данные первыми, см.
|
||||
-- .claude/rules/sql.md "Migration order").
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- Часть 1: ASCII-CHECK (immediate constraint — валидирует и вставляемые ниже строки).
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'tradein_users_username_ascii_ck'
|
||||
) THEN
|
||||
ALTER TABLE tradein_users
|
||||
ADD CONSTRAINT tradein_users_username_ascii_ck
|
||||
CHECK (username ~ '^[A-Za-z0-9._-]{3,64}$');
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
COMMENT ON CONSTRAINT tradein_users_username_ascii_ck ON tradein_users IS
|
||||
'Fail-closed защита от кириллических/не-ASCII логинов (deep-review #2561): '
|
||||
'rbac кодирует session-username через encode("latin-1","replace"), не-ASCII '
|
||||
'логины одинаковой длины схлопываются в общий downstream-identity (IDOR).';
|
||||
|
||||
-- Часть 2: seed org-карты (владелец продукта, 2026-07-30).
|
||||
INSERT INTO tradein_users
|
||||
(username, password_hash, role, manager_id, display_name, org_name, email, is_active)
|
||||
VALUES
|
||||
('admin', NULL, 'admin', NULL, NULL, NULL, NULL, true),
|
||||
('kopylov', NULL, 'manager', NULL, 'Копылов', NULL, NULL, true),
|
||||
('praktika', NULL, 'manager', NULL, NULL, NULL, NULL, true),
|
||||
('user1', NULL, 'employee', NULL, NULL, NULL, NULL, true),
|
||||
('user2', NULL, 'employee', NULL, NULL, NULL, NULL, false),
|
||||
('user3', NULL, 'employee', NULL, NULL, NULL, NULL, true),
|
||||
('user4', NULL, 'employee', NULL, NULL, NULL, NULL, true),
|
||||
('user5', NULL, 'employee', NULL, NULL, NULL, NULL, true),
|
||||
('user6', NULL, 'employee', NULL, NULL, NULL, NULL, true),
|
||||
('user7', NULL, 'employee', NULL, NULL, NULL, NULL, true),
|
||||
('user8', NULL, 'employee', NULL, NULL, NULL, NULL, true),
|
||||
('user9', NULL, 'employee', NULL, NULL, NULL, NULL, true),
|
||||
('user10', NULL, 'employee', NULL, NULL, NULL, NULL, true)
|
||||
ON CONFLICT (username) DO UPDATE SET
|
||||
role = EXCLUDED.role,
|
||||
-- manager_id защищён COALESCE: team-API (#2563) пишет manager_id при назначении
|
||||
-- сотрудника менеджером, повторный прогон seed'а не должен тихо обнулять эту связь.
|
||||
manager_id = COALESCE(tradein_users.manager_id, EXCLUDED.manager_id),
|
||||
password_hash = COALESCE(tradein_users.password_hash, EXCLUDED.password_hash),
|
||||
display_name = COALESCE(tradein_users.display_name, EXCLUDED.display_name),
|
||||
org_name = COALESCE(tradein_users.org_name, EXCLUDED.org_name),
|
||||
email = COALESCE(tradein_users.email, EXCLUDED.email),
|
||||
-- is_active НЕ в SET: NOT NULL DEFAULT true колонка, COALESCE был бы мёртвым кодом
|
||||
-- (см. IDEMPOTENCY выше) — open/close доступа решается через UI (#2556), не seed'ом.
|
||||
updated_at = now();
|
||||
|
||||
COMMIT;
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,288 +0,0 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* #2555 (эпик #2549) — login-форма для новой DB-backed session auth
|
||||
* (POST /api/v1/auth/login, см. `tradein-mvp/backend/app/api/v1/auth.py`).
|
||||
*
|
||||
* Не гейтится RouteGuard'ом (см. `components/auth/RouteGuard.tsx` —
|
||||
* `isLoginPage` bypass) — иначе редирект-петля: 401 от /me на /login тоже
|
||||
* пытался бы увести на /login.
|
||||
*
|
||||
* `next=` читаем вручную из `window.location.search` (SSR-guard), а НЕ
|
||||
* через `next/navigation` `useSearchParams()` — тот форсит Suspense boundary
|
||||
* и ломает `next build` (см. `app/v2/page.tsx: readUrlId` — тот же паттерн,
|
||||
* уже принятый в этом репо).
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import type { CSSProperties, FormEvent } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { apiFetch, HTTPError } from "@/lib/api";
|
||||
import { ME_QUERY_KEY } from "@/lib/useMe";
|
||||
|
||||
interface LoginInput {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
async function loginRequest(input: LoginInput): Promise<void> {
|
||||
await apiFetch<{ ok: boolean }>("/api/v1/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
}
|
||||
|
||||
function readNextParam(): string | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
return new URLSearchParams(window.location.search).get("next");
|
||||
}
|
||||
|
||||
/**
|
||||
* Open-redirect guard: принимаем только внутренний путь, начинающийся
|
||||
* ровно с одного "/" — не "//host" (protocol-relative URL) и не "/\host"
|
||||
* (браузеры местами трактуют backslash как forward slash в URL-парсинге).
|
||||
*
|
||||
* PR #2562 review finding 1: WHATWG URL-парсер (который `router.push`
|
||||
* использует под капотом) убирает ВСЕ ASCII tab/CR/LF из строки ПЕРЕД
|
||||
* парсингом — так `"/\t//evil"` для наивного regex выглядит как безопасный
|
||||
* путь с одним leading slash (символ в позиции 1 — таб, не "/" и не "\"),
|
||||
* а после навигации превращается в `"//evil"` (protocol-relative → чужой
|
||||
* origin). Убираем те же символы ДО валидации, чтобы regex видел ту же
|
||||
* строку, что увидит парсер.
|
||||
*
|
||||
* PR #2562 review finding 2: `next=/login` (или `/login?...`) после успешного
|
||||
* логина кидал бы юзера обратно на форму входа (RouteGuard не гейтит
|
||||
* `/login`) — dead-end. Фолбэк на "/" в этом случае.
|
||||
*/
|
||||
function sanitizeNext(next: string | null): string {
|
||||
if (!next) return "/";
|
||||
const cleaned = next.replace(/[\t\r\n]/g, "");
|
||||
if (!/^\/(?!\/|\\)/.test(cleaned)) return "/";
|
||||
if (
|
||||
cleaned === "/login" ||
|
||||
cleaned.startsWith("/login?") ||
|
||||
cleaned.startsWith("/login#")
|
||||
) {
|
||||
return "/";
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
function loginErrorMessage(error: unknown): string {
|
||||
if (error instanceof HTTPError) {
|
||||
if (error.status === 401) return "Неверный логин или пароль";
|
||||
if (error.status === 429) {
|
||||
return "Слишком много попыток. Попробуйте через несколько минут";
|
||||
}
|
||||
}
|
||||
return "Не удалось войти. Проверьте подключение и попробуйте ещё раз";
|
||||
}
|
||||
|
||||
const cardStyle: CSSProperties = {
|
||||
background: "var(--bg-card)",
|
||||
border: "1px solid var(--border-card)",
|
||||
borderRadius: 12,
|
||||
padding: "32px 28px",
|
||||
maxWidth: 380,
|
||||
width: "100%",
|
||||
};
|
||||
|
||||
const labelStyle: CSSProperties = {
|
||||
display: "block",
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
color: "var(--fg-secondary)",
|
||||
marginBottom: 6,
|
||||
};
|
||||
|
||||
const inputStyle: CSSProperties = {
|
||||
width: "100%",
|
||||
boxSizing: "border-box",
|
||||
height: 40,
|
||||
padding: "0 12px",
|
||||
border: "1px solid var(--border-card)",
|
||||
borderRadius: 8,
|
||||
fontSize: 14,
|
||||
color: "var(--fg-primary)",
|
||||
background: "var(--bg-card)",
|
||||
fontFamily: "inherit",
|
||||
};
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
|
||||
const loginMutation = useMutation({
|
||||
mutationFn: loginRequest,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ME_QUERY_KEY });
|
||||
router.push(sanitizeNext(readNextParam()));
|
||||
},
|
||||
});
|
||||
|
||||
function handleSubmit(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
if (loginMutation.isPending) return;
|
||||
loginMutation.mutate({ username: username.trim(), password });
|
||||
}
|
||||
|
||||
return (
|
||||
<main
|
||||
style={{
|
||||
minHeight: "100vh",
|
||||
background: "var(--bg-app)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: 24,
|
||||
fontFamily: "Inter, -apple-system, 'Segoe UI', system-ui, sans-serif",
|
||||
}}
|
||||
>
|
||||
<style>{`
|
||||
@keyframes login-spin { to { transform: rotate(360deg); } }
|
||||
.login-spinner { animation: login-spin .7s linear infinite; }
|
||||
.login-input:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 2px var(--accent-soft);
|
||||
}
|
||||
.login-submit:hover:not(:disabled) { background: var(--accent-hover); }
|
||||
`}</style>
|
||||
|
||||
<form onSubmit={handleSubmit} style={cardStyle} aria-label="Вход в Меру">
|
||||
<h1
|
||||
style={{
|
||||
margin: "0 0 8px",
|
||||
fontSize: 22,
|
||||
fontWeight: 600,
|
||||
color: "var(--fg-primary)",
|
||||
lineHeight: 1.25,
|
||||
}}
|
||||
>
|
||||
Вход
|
||||
</h1>
|
||||
<p
|
||||
style={{
|
||||
margin: "0 0 24px",
|
||||
fontSize: 14,
|
||||
color: "var(--fg-secondary)",
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
Войдите, чтобы продолжить работу с Мерой.
|
||||
</p>
|
||||
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<label style={labelStyle} htmlFor="login-username">
|
||||
Логин
|
||||
</label>
|
||||
<input
|
||||
id="login-username"
|
||||
name="username"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
required
|
||||
autoFocus
|
||||
className="login-input"
|
||||
style={inputStyle}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
disabled={loginMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<label style={labelStyle} htmlFor="login-password">
|
||||
Пароль
|
||||
</label>
|
||||
<input
|
||||
id="login-password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
className="login-input"
|
||||
style={inputStyle}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={loginMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{loginMutation.isError ? (
|
||||
<p
|
||||
role="alert"
|
||||
style={{
|
||||
margin: "0 0 16px",
|
||||
padding: "8px 12px",
|
||||
borderRadius: 8,
|
||||
background: "var(--danger-soft)",
|
||||
color: "var(--danger)",
|
||||
fontSize: 13,
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
{loginErrorMessage(loginMutation.error)}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="login-submit"
|
||||
disabled={loginMutation.isPending}
|
||||
style={{
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 8,
|
||||
background: "var(--accent)",
|
||||
color: "#FFFFFF",
|
||||
border: "none",
|
||||
borderRadius: 8,
|
||||
padding: "10px 16px",
|
||||
fontSize: 14,
|
||||
fontWeight: 500,
|
||||
cursor: loginMutation.isPending ? "wait" : "pointer",
|
||||
opacity: loginMutation.isPending ? 0.75 : 1,
|
||||
fontFamily: "inherit",
|
||||
}}
|
||||
>
|
||||
{loginMutation.isPending ? (
|
||||
<>
|
||||
<svg
|
||||
className="login-spinner"
|
||||
width={16}
|
||||
height={16}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="9"
|
||||
stroke="rgba(255,255,255,0.35)"
|
||||
strokeWidth="3"
|
||||
/>
|
||||
<path
|
||||
d="M21 12a9 9 0 0 0-9-9"
|
||||
stroke="#FFFFFF"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
Входим…
|
||||
</>
|
||||
) : (
|
||||
"Войти"
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
@ -66,7 +66,7 @@ import {
|
|||
} from "@/lib/trade-in-api";
|
||||
import { useQuota } from "@/lib/useQuota";
|
||||
import { useMe } from "@/lib/useMe";
|
||||
import { useLogout } from "@/lib/useLogout";
|
||||
import { logout } from "@/lib/logout";
|
||||
|
||||
// OUTER HUD FRAME + 4 corner brackets (design lines 31-37). Decorative,
|
||||
// non-interactive overlay drawn over the artboard gradient. The frame has
|
||||
|
|
@ -524,10 +524,6 @@ export default function TradeInV2Page() {
|
|||
// (#2046) — known profile fields fall back to username / brand ?? role / ""
|
||||
// when absent (never invented). undefined while loading → TopNav «Гость».
|
||||
const me = useMe();
|
||||
// #2555: session-logout (POST /api/v1/auth/logout + local /me-cache
|
||||
// invalidate + redirect на /login) — replaces the legacy basic_auth-cache
|
||||
// -bust `logout()` for the v2 TopNav (new /login-form users).
|
||||
const logoutMutation = useLogout();
|
||||
|
||||
// Dashboard sub-hooks — each resolves independently; failure degrades its
|
||||
// section via the mappers (null input) rather than blanking the page.
|
||||
|
|
@ -977,7 +973,7 @@ export default function TradeInV2Page() {
|
|||
onNavigate={setNav}
|
||||
reports={reportsCount ?? 0}
|
||||
user={topNavUser}
|
||||
onLogout={() => logoutMutation.mutate()}
|
||||
onLogout={logout}
|
||||
/>
|
||||
</nav>
|
||||
<main
|
||||
|
|
|
|||
|
|
@ -10,17 +10,9 @@
|
|||
* RBAC config (`auth/roles.yaml`) использует абсолютные пути сайта
|
||||
* (`/trade-in/**`, `/trade-in/api/v1/admin/**`), поэтому перед проверкой
|
||||
* isPathAllowed мы префиксим pathname через NEXT_PUBLIC_BASE_PATH.
|
||||
*
|
||||
* #2555 login redirect: `router.push()` (как и `usePathname()`) работает в
|
||||
* пространстве путей БЕЗ basePath — Next сам префиксит basePath на навигации
|
||||
* (см. `next.config.ts` комментарий `basePath`). Поэтому `next=` в query
|
||||
* строится из `rawPath` (БЕЗ basePath), а не `absolutePath` — иначе
|
||||
* `/login/page.tsx` сделал бы `router.push("/trade-in/history")`, и Next
|
||||
* задвоил бы префикс в `/trade-in/trade-in/history`.
|
||||
*/
|
||||
|
||||
import { useRouter, usePathname } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import { NoAccessScreen } from "@/components/auth/NoAccessScreen";
|
||||
import { HTTPError } from "@/lib/api";
|
||||
|
|
@ -37,7 +29,6 @@ interface RouteGuardProps {
|
|||
|
||||
export function RouteGuard({ children }: RouteGuardProps) {
|
||||
const rawPath = usePathname() ?? "/";
|
||||
const router = useRouter();
|
||||
// Абсолютный путь сайта: BASE_PATH + rawPath. Аккуратно с двойным слэшем
|
||||
// на `/`: `BASE_PATH = "/trade-in"` + `"/"` → `/trade-in/` (ок).
|
||||
const absolutePath = BASE_PATH
|
||||
|
|
@ -45,51 +36,21 @@ export function RouteGuard({ children }: RouteGuardProps) {
|
|||
: rawPath;
|
||||
const { data, isLoading, error } = useMe();
|
||||
|
||||
// #2555: /login сам себя не гейтит — иначе редирект-петля (401 на /me →
|
||||
// редирект на /login → RouteGuard на /login опять видит 401 → редирект…).
|
||||
const isLoginPage = rawPath === "/login";
|
||||
|
||||
// Prod-only: сессия истекла/отсутствует → уводим на логин вместо старого
|
||||
// NoAccessScreen variant="session". Редирект — побочный эффект (нельзя
|
||||
// router.push во время рендера), поэтому useEffect; пока он не сработал,
|
||||
// рендерим null (см. return ниже), чтобы не мигал старый contents.
|
||||
const shouldRedirectToLogin =
|
||||
!isLoginPage &&
|
||||
process.env.NODE_ENV === "production" &&
|
||||
error instanceof HTTPError &&
|
||||
error.status === 401;
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldRedirectToLogin) return;
|
||||
// PR #2562 review finding 3: deep-links carry их state в query (`/v2?id=
|
||||
// <uuid>` — см. next.config.ts redirect comment про restore-by-id). Без
|
||||
// `window.location.search` юзер, чья сессия истекла mid-session на такой
|
||||
// ссылке, после логина попадал бы на голый `/v2` и терял отчёт. Effect
|
||||
// — гарантированно client-side (useEffect тело никогда не бежит на SSR),
|
||||
// поэтому `window` тут безопасен без typeof-guard.
|
||||
const next = `${rawPath}${window.location.search}`;
|
||||
router.push(`/login?next=${encodeURIComponent(next)}`);
|
||||
}, [shouldRedirectToLogin, rawPath, router]);
|
||||
|
||||
// #801: preview-страница самодостаточна (свой QueryClient с фейковым me),
|
||||
// RBAC к ней не применяем. Только под флагом — в проде по умолчанию выключено.
|
||||
if (ENABLE_PREVIEW && rawPath.startsWith("/ui-preview")) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
if (isLoginPage) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
if (isLoading) return null;
|
||||
|
||||
if (error instanceof HTTPError && error.status === 401) {
|
||||
// Dev without Caddy: 401 is normal, mount the app so local dev works.
|
||||
// Prod: mounting children on 401 causes TanStack Query re-subscribe storm
|
||||
// (each new observer on errored query triggers a refetch). Show session screen
|
||||
// instead — prevents the subtree from mounting, kills the loop.
|
||||
if (process.env.NODE_ENV !== "production") return <>{children}</>;
|
||||
// Prod: редирект уже запущен эффектом выше — ничего не рендерим, пока
|
||||
// навигация не завершится (mounting children on 401 causes TanStack
|
||||
// Query re-subscribe storm, см. историю до #2555 в git blame).
|
||||
return null;
|
||||
return <NoAccessScreen variant="session" />;
|
||||
}
|
||||
|
||||
if (error instanceof HTTPError && error.status === 403) {
|
||||
|
|
|
|||
|
|
@ -1,40 +0,0 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* #2555: session-logout — POST /api/v1/auth/logout (revoke DB session +
|
||||
* очистка httponly cookie tradein_session), затем чистим локальный
|
||||
* TanStack Query /me-кэш и уходим на /login.
|
||||
*
|
||||
* NB: это НЕ замена legacy `@/lib/logout.ts` (Caddy basic_auth cache-bust +
|
||||
* hard reload) — тот остаётся для страниц/пользователей на старом
|
||||
* trusted-header механизме (см. `app.core.rbac` dual-mode resolver).
|
||||
* useLogout — для юзеров, залогиненных через новую /login форму (#2552).
|
||||
*
|
||||
* Backend logout — best-effort по духу (revoke конкретной сессии), поэтому
|
||||
* локальный logout (кэш + редирект) выполняется в `onSettled`, а не только
|
||||
* `onSuccess`: сетевой сбой / уже-протухшая сессия не должны запирать юзера
|
||||
* на странице без возможности разлогиниться.
|
||||
*/
|
||||
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { ME_QUERY_KEY } from "@/lib/useMe";
|
||||
|
||||
async function logoutRequest(): Promise<void> {
|
||||
await apiFetch<{ ok: boolean }>("/api/v1/auth/logout", { method: "POST" });
|
||||
}
|
||||
|
||||
export function useLogout() {
|
||||
const queryClient = useQueryClient();
|
||||
const router = useRouter();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: logoutRequest,
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ME_QUERY_KEY });
|
||||
router.push("/login");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -14,18 +14,7 @@ import { useQuery } from "@tanstack/react-query";
|
|||
|
||||
import { apiFetchWithStatus, HTTPError } from "@/lib/api";
|
||||
|
||||
// #2555: session-auth (POST /api/v1/auth/login) вводит новые роли
|
||||
// admin|manager|employee. Legacy Caddy trusted-header роли (pilot|analyst|
|
||||
// expired) остаются — backend `/api/v1/me` может отдать любую из обеих
|
||||
// групп в зависимости от того, каким механизмом пришёл юзер (dual-mode
|
||||
// resolver, см. `tradein-mvp/backend/app/core/rbac.py`).
|
||||
export type Role =
|
||||
| "admin"
|
||||
| "manager"
|
||||
| "employee"
|
||||
| "pilot"
|
||||
| "analyst"
|
||||
| "expired";
|
||||
export type Role = "admin" | "pilot" | "expired";
|
||||
|
||||
export interface UserScope {
|
||||
username: string;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue