From 5817e0b31aa36ea0cd75630c9d7253728879c6ab Mon Sep 17 00:00:00 2001 From: bot-backend Date: Fri, 31 Jul 2026 15:51:50 +0300 Subject: [PATCH] =?UTF-8?q?fix(tradein/team):=20admin=20=D1=83=D0=BF=D1=80?= =?UTF-8?q?=D0=B0=D0=B2=D0=BB=D1=8F=D0=B5=D1=82=20=D0=BC=D0=B5=D0=BD=D0=B5?= =?UTF-8?q?=D0=B4=D0=B6=D0=B5=D1=80=D0=B0=D0=BC=D0=B8=20=E2=80=94=20=D1=81?= =?UTF-8?q?=D0=B1=D1=80=D0=BE=D1=81=20=D0=BF=D0=B0=D1=80=D0=BE=D0=BB=D1=8F?= =?UTF-8?q?=20=D0=B8=20=D0=B1=D0=BB=D0=BE=D0=BA=D0=B8=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=BA=D0=B0=20(#2556)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit После cutover'а на DB-auth (#2558) аккаунты `kopylov` и `praktika` живут с `role='manager'`, а team-API жёстко фильтровал `role='employee'` — сбросить менеджеру пароль или заблокировать его было НЕЧЕМ, кроме ручного psql на проде. Всплыло 2026-07-31: «Практика» весь день билась в логин (5 failed, 0 успешных), а восстановить доступ через UI админ не мог. Что меняется: - `_fetch_employee_row` берёт actor: admin → `role IN ('employee','manager')`, manager → по-прежнему только `role='employee'` + свои по `manager_id`. - `GET /employees` без фильтра отдаёт admin'у и менеджеров (`?manager_id=` — без изменений, только сотрудники этого менеджера). - `EmployeeOut.role` — новое поле, UI показывает бейдж «менеджер» и склоняет тексты («Заблокировать менеджера ...» вместо «сотрудника»). Инвариант self-lockout сохранён и усилен тестом: строки `role='admin'` недостижимы через этот роутер ни для кого, включая самого админа, поэтому ни block, ни смена пароля с `revoke_user_sessions` не могут вырубить действующего админа. Раздача роли admin остаётся вне API. Тесты: 6 новых (список с менеджерами, сброс пароля менеджеру + отзыв сессий, блокировка, manager не достаёт до чужого менеджера, admin не достаёт до admin-строки), 3 существующих обновлены под новое ожидание списка. --- tradein-mvp/backend/app/api/v1/team.py | 98 +++++++++--- tradein-mvp/backend/app/schemas/team.py | 10 +- tradein-mvp/backend/tests/test_team_api.py | 146 ++++++++++++++++-- .../src/components/team/EmployeeTable.tsx | 23 ++- .../src/components/team/QuotaEditor.tsx | 9 +- .../src/components/trade-in/trade-in.css | 13 ++ tradein-mvp/frontend/src/lib/team-api.ts | 7 + 7 files changed, 265 insertions(+), 41 deletions(-) diff --git a/tradein-mvp/backend/app/api/v1/team.py b/tradein-mvp/backend/app/api/v1/team.py index e3e30ffc..592f4f13 100644 --- a/tradein-mvp/backend/app/api/v1/team.py +++ b/tradein-mvp/backend/app/api/v1/team.py @@ -18,6 +18,25 @@ Org-изоляция (главный инвариант фичи): manager ви employee (`tradein_users.manager_id = actor.user_id`). Чужой/несуществующий employee_id → 404 (НЕ 403) — не подтверждаем/не опровергаем существование чужого сотрудника перед manager'ом. См. `_authorize_employee`. + +Кого именно можно менять через этот роутер (`_MANAGEABLE_ROLES_BY_ACTOR`): + - actor manager → только `role='employee'` И только своих (как было). + - actor admin → `role IN ('employee','manager')`. + +Почему admin'у отдали и менеджеров (инцидент 2026-07-31): после cutover'а на +DB-auth (#2558) аккаунты `kopylov`/`praktika` сидят с `role='manager'`, а этот +роутер жёстко фильтровал `role='employee'` — сбросить менеджеру пароль или +заблокировать его было НЕЧЕМ, кроме ручного psql на проде. Роль manager вводилась +как «владелец своей организации», а не как «неприкасаемый аккаунт». + +`role='admin'` НЕ входит ни в один набор, и это несущий инвариант, а не +экономия: он один держит невозможность self-lockout'а. Актёр этого роутера — +всегда admin или manager (`current_team_actor`); manager до admin-строки не +дотянется по своей ветке фильтра, а admin не дотянется до admin-строки вообще — +в том числе до собственной. Поэтому ни один путь ниже (block, смена пароля + +`revoke_user_sessions`) не может вырубить самого действующего админа или +разжаловать другого. Раздача/отзыв роли admin остаётся операцией уровня +миграции/psql — сознательно вне API. """ from __future__ import annotations @@ -140,22 +159,40 @@ def _require_same_origin(request: Request) -> None: # --------------------------------------------------------------------------- -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() - ) +# Два статических варианта — НЕ динамическая сборка WHERE (та же мотивация, что +# у `_LIST_EMPLOYEES_*_SQL` ниже: значения и так биндятся параметрами, но +# статические ветки не провоцируют будущие правки в сторону конкатенации SQL). +# Роль 'admin' не встречается ни в одной ветке — см. модульный docstring. +_FETCH_MANAGED_EMPLOYEE_SQL = text( + """ + SELECT id, username, role, display_name, org_name, email, is_active, + manager_id, created_at + FROM tradein_users + WHERE id = :id AND role = 'employee' + """ +) + +_FETCH_MANAGED_ANY_SQL = text( + """ + SELECT id, username, role, display_name, org_name, email, is_active, + manager_id, created_at + FROM tradein_users + WHERE id = :id AND role IN ('employee', 'manager') + """ +) + + +def _fetch_employee_row(db: Session, employee_id: int, actor: TeamActor) -> RowMapping | None: + """Строка управляемого юзера в пределах прав *actor* — иначе None (→ 404). + + Фильтр по роли делается ЗДЕСЬ, в SQL, а не в `_authorize_employee` ниже: + для manager'а строка менеджера/админа не должна даже доехать до + вызывающего кода. `None` для обоих случаев («нет такого id» и «этот id + тебе не по зубам») — тот же принцип, что и 404-вместо-403 в + `_authorize_employee`: не палим существование чужой строки. + """ + sql = _FETCH_MANAGED_ANY_SQL if actor.role == "admin" else _FETCH_MANAGED_EMPLOYEE_SQL + return db.execute(sql, {"id": employee_id}).mappings().fetchone() def _authorize_employee(actor: TeamActor, row: RowMapping | None) -> RowMapping: @@ -163,6 +200,11 @@ def _authorize_employee(actor: TeamActor, row: RowMapping | None) -> RowMapping: Org-изоляция: manager может видеть/менять только `manager_id == actor.user_id`. 404 вместо 403 — не палим существование чужого employee_id. + + Для admin'а доп. проверки нет: набор строк, до которых он вообще может + дотянуться, уже ограничен ролью в `_fetch_employee_row` (employee|manager, + без admin). У менеджерских строк `manager_id` штатно NULL — сравнивать его + с чем-либо здесь нечего. """ if row is None: raise HTTPException(status_code=404, detail="employee not found") @@ -304,6 +346,7 @@ def _employee_out(row: RowMapping, quota: dict[str, Any]) -> EmployeeOut: return EmployeeOut( id=row["id"], username=row["username"], + role=row["role"], display_name=row["display_name"], org_name=row["org_name"], email=row["email"], @@ -374,7 +417,7 @@ async def create_employee( VALUES (:username, :password_hash, 'employee', :manager_id, :display_name, :org_name, :email, true) - RETURNING id, username, display_name, org_name, email, is_active, + RETURNING id, username, role, display_name, org_name, email, is_active, manager_id, created_at """ ), @@ -441,7 +484,7 @@ async def update_employee( долго, а не «до TTL». `revoke_user_sessions` сам называет смену пароля своим use-case — см. его докстринг. """ - row = _fetch_employee_row(db, employee_id) + row = _fetch_employee_row(db, employee_id, actor) row = _authorize_employee(actor, row) new_password_hash: str | None = None @@ -481,7 +524,8 @@ async def update_employee( # Обязательно ПОСЛЕ UPDATE, ДО финального commit — revoke_user_sessions # коммитит сам (см. app.services.auth_session), это флашит и наш # предшествующий UPDATE/quota-upsert в той же сессии. Self-lockout - # невозможен: _fetch_employee_row фильтрует role='employee', actor + # невозможен: _fetch_employee_row не отдаёт строки с role='admin' + # НИКОМУ, а manager'у — ещё и только role='employee'; т.е. actor # (admin|manager) никогда не может патчить сам себя через этот роут. revoke_user_sessions(db, employee_id) @@ -529,7 +573,7 @@ async def update_employee( }, ) - updated_row = _fetch_employee_row(db, employee_id) + updated_row = _fetch_employee_row(db, employee_id, actor) assert updated_row is not None # только что успешно обновили эту же строку quota = account_quota.get_status(db, updated_row["username"]) return _employee_out(updated_row, quota) @@ -554,7 +598,7 @@ async def update_employee( # намерение DESC-сортировки по времени). _LIST_EMPLOYEES_BY_MANAGER_SQL = text( """ - SELECT id, username, display_name, org_name, email, is_active, manager_id, created_at + SELECT id, username, role, 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 @@ -562,11 +606,17 @@ _LIST_EMPLOYEES_BY_MANAGER_SQL = text( """ ) +# Admin-ветка: сюда попадают И менеджеры (см. модульный docstring — иначе admin +# не видит в UI строку, которой должен уметь сбросить пароль). `role='admin'` +# по-прежнему невидим и неуправляем. Сортировка по (created_at, id) общая для +# обеих ролей — намеренно: seed (#2557) вставил всех одной транзакцией, так что +# группировка «сначала менеджеры» дала бы ложное ощущение иерархии там, где её +# в данных нет; роль показывается колонкой (`EmployeeOut.role`). _LIST_EMPLOYEES_ALL_SQL = text( """ - SELECT id, username, display_name, org_name, email, is_active, manager_id, created_at + SELECT id, username, role, display_name, org_name, email, is_active, manager_id, created_at FROM tradein_users - WHERE role = 'employee' + WHERE role IN ('employee', 'manager') ORDER BY created_at DESC, id DESC LIMIT :limit OFFSET :offset """ @@ -631,7 +681,7 @@ async def employee_history( Та же org-проверка что и в PATCH: чужой employee_id → 404. """ - row = _fetch_employee_row(db, employee_id) + row = _fetch_employee_row(db, employee_id, actor) row = _authorize_employee(actor, row) rows = ( diff --git a/tradein-mvp/backend/app/schemas/team.py b/tradein-mvp/backend/app/schemas/team.py index fe037389..6ed84249 100644 --- a/tradein-mvp/backend/app/schemas/team.py +++ b/tradein-mvp/backend/app/schemas/team.py @@ -1,7 +1,8 @@ """Pydantic-схемы team-management API (#2554, эпик #2549). -CRUD сотрудников (`tradein_users.role = 'employee'`), квоты, история оценок. -Org-изоляция (manager видит/меняет только своих employee) реализована в +CRUD управляемых аккаунтов (`tradein_users.role IN ('employee','manager')` — +manager'ы доступны только actor'у-admin, см. `app.api.v1.team`), квоты, история +оценок. Org-изоляция (manager видит/меняет только своих employee) реализована в `app.api.v1.team`, эти схемы — только форма запросов/ответов. """ @@ -9,6 +10,7 @@ from __future__ import annotations import re from datetime import datetime +from typing import Literal from pydantic import BaseModel, ConfigDict, Field, field_validator @@ -79,6 +81,10 @@ class EmployeeOut(BaseModel): id: int username: str + # 'employee' | 'manager' — admin управляет обоими, manager видит только + # employee (см. app.api.v1.team, модульный docstring). Строки role='admin' + # через этот API не отдаются никогда, поэтому в Literal их нет. + role: Literal["employee", "manager"] display_name: str | None = None org_name: str | None = None email: str | None = None diff --git a/tradein-mvp/backend/tests/test_team_api.py b/tradein-mvp/backend/tests/test_team_api.py index 2f1e40ce..3487e2c3 100644 --- a/tradein-mvp/backend/tests/test_team_api.py +++ b/tradein-mvp/backend/tests/test_team_api.py @@ -255,9 +255,19 @@ class _FakeDB: match = user is not None and user["role"] == "manager" return _Result([{"id": user["id"]}] if match else []) - # ---- tradein_users: list employees (has explicit ORDER BY) ---- - if "role = 'employee'" in sql and "ORDER BY created_at DESC" in sql: - rows = [u for u in s.users.values() if u["role"] == "employee"] + # ---- tradein_users: list managed rows (has explicit ORDER BY) ---- + # Две ветки реального кода: `role = 'employee'` (manager, либо admin с + # ?manager_id=) и `role IN ('employee','manager')` (admin без фильтра — + # ему нужны и менеджеры, иначе некому сбросить пароль, см. team.py). + if ("role = 'employee'" in sql or "role IN ('employee', 'manager')" in sql) and ( + "ORDER BY created_at DESC" in sql + ): + managed = ( + ("employee", "manager") + if "role IN ('employee', 'manager')" in sql + else ("employee",) + ) + rows = [u for u in s.users.values() if u["role"] in managed] if "manager_id" in p: rows = [u for u in rows if u["manager_id"] == p["manager_id"]] # Mirrors real SQL `ORDER BY created_at DESC, id DESC` — `id` tiebreak @@ -271,6 +281,7 @@ class _FakeDB: { "id": u["id"], "username": u["username"], + "role": u["role"], "display_name": u["display_name"], "org_name": u["org_name"], "email": u["email"], @@ -282,16 +293,22 @@ class _FakeDB: ] ) - # ---- tradein_users: fetch single employee by id ---- - if "role = 'employee'" in sql: + # ---- tradein_users: fetch single managed row by id ---- + if "role = 'employee'" in sql or "role IN ('employee', 'manager')" in sql: + managed = ( + ("employee", "manager") + if "role IN ('employee', 'manager')" in sql + else ("employee",) + ) user = s.user_by_id(p["id"]) - if user is None or user["role"] != "employee": + if user is None or user["role"] not in managed: return _Result([]) return _Result( [ { "id": user["id"], "username": user["username"], + "role": user["role"], "display_name": user["display_name"], "org_name": user["org_name"], "email": user["email"], @@ -577,6 +594,112 @@ def test_employee_role_403_on_all_team_routes(client: TestClient, store: _Store) assert client.get("/api/v1/team/employees/1/history").status_code == 403 +# --------------------------------------------------------------------------- +# Admin управляет менеджерами (инцидент 2026-07-31: kopylov/praktika — role +# 'manager', сбросить им пароль через UI было нечем) +# --------------------------------------------------------------------------- + + +def test_admin_list_includes_managers(client: TestClient, store: _Store) -> None: + store.add_user("admin1", hash_password("Secret123!"), role="admin") + mgr_id = store.add_user("mgr_a", hash_password("Secret123!"), role="manager") + store.add_user("emp_a", hash_password("Secret123!"), role="employee", manager_id=mgr_id) + + _login(client, "admin1", "Secret123!") + resp = client.get("/api/v1/team/employees") + assert resp.status_code == 200 + by_username = {e["username"]: e for e in resp.json()} + # Менеджер виден; сам admin — нет (role='admin' не отдаётся никогда). + assert set(by_username) == {"mgr_a", "emp_a"} + assert by_username["mgr_a"]["role"] == "manager" + assert by_username["emp_a"]["role"] == "employee" + + +def test_admin_resets_manager_password_and_revokes_sessions( + client: TestClient, store: _Store +) -> None: + """Главный сценарий инцидента: admin выдаёт менеджеру новый пароль.""" + store.add_user("admin1", hash_password("Secret123!"), role="admin") + mgr_id = store.add_user("mgr_a", hash_password("OldSecret1!"), role="manager") + + # У менеджера есть живая сессия — после сброса она обязана умереть. + _login(client, "mgr_a", "OldSecret1!") + assert any(sess["user_id"] == mgr_id for sess in store.sessions.values()) + client.cookies.clear() + + _login(client, "admin1", "Secret123!") + resp = client.patch(f"/api/v1/team/employees/{mgr_id}", json={"new_password": "NewSecret1!"}) + assert resp.status_code == 200 + assert resp.json()["role"] == "manager" + assert not any(sess["user_id"] == mgr_id for sess in store.sessions.values()) + + # Новый пароль реально работает, старый — нет. + client.cookies.clear() + assert ( + client.post( + "/api/v1/auth/login", json={"username": "mgr_a", "password": "OldSecret1!"} + ).status_code + == 401 + ) + assert ( + client.post( + "/api/v1/auth/login", json={"username": "mgr_a", "password": "NewSecret1!"} + ).status_code + == 200 + ) + + +def test_admin_blocks_manager(client: TestClient, store: _Store) -> None: + store.add_user("admin1", hash_password("Secret123!"), role="admin") + mgr_id = store.add_user("mgr_a", hash_password("Secret123!"), role="manager") + + _login(client, "admin1", "Secret123!") + resp = client.patch(f"/api/v1/team/employees/{mgr_id}", json={"is_active": False}) + assert resp.status_code == 200 + assert resp.json()["is_active"] is False + + +def test_manager_cannot_patch_another_manager_404(client: TestClient, store: _Store) -> None: + """Расширение прав дано ТОЛЬКО admin'у — manager до чужой строки не достаёт.""" + store.add_user("mgr_a", hash_password("Secret123!"), role="manager") + mgr_b_id = store.add_user("mgr_b", hash_password("Secret123!"), role="manager") + + _login(client, "mgr_a", "Secret123!") + resp = client.patch(f"/api/v1/team/employees/{mgr_b_id}", json={"new_password": "Hacked123!"}) + assert resp.status_code == 404 + # И в списке чужого менеджера тоже нет. + assert client.get("/api/v1/team/employees").json() == [] + + +def test_admin_cannot_patch_admin_row_404(client: TestClient, store: _Store) -> None: + """Инвариант отсутствия self-lockout: строки role='admin' недостижимы.""" + admin_id = store.add_user("admin1", hash_password("Secret123!"), role="admin") + other_admin_id = store.add_user("admin2", hash_password("Secret123!"), role="admin") + + _login(client, "admin1", "Secret123!") + assert ( + client.patch(f"/api/v1/team/employees/{admin_id}", json={"is_active": False}).status_code + == 404 + ) + assert ( + client.patch( + f"/api/v1/team/employees/{other_admin_id}", json={"new_password": "Nope12345!"} + ).status_code + == 404 + ) + + +def test_manager_cannot_see_managers_in_own_list(client: TestClient, store: _Store) -> None: + mgr_a = store.add_user("mgr_a", hash_password("Secret123!"), role="manager") + store.add_user("mgr_b", hash_password("Secret123!"), role="manager") + store.add_user("emp_a", hash_password("Secret123!"), role="employee", manager_id=mgr_a) + + _login(client, "mgr_a", "Secret123!") + rows = client.get("/api/v1/team/employees").json() + assert {e["username"] for e in rows} == {"emp_a"} + assert {e["role"] for e in rows} == {"employee"} + + # --------------------------------------------------------------------------- # Org isolation — manager A vs manager B # --------------------------------------------------------------------------- @@ -766,8 +889,11 @@ def test_admin_sees_all_employees_and_filters_by_manager(client: TestClient, sto _login(client, "admin1", "Secret123!") resp_all = client.get("/api/v1/team/employees") assert resp_all.status_code == 200 - assert {e["username"] for e in resp_all.json()} == {"emp_a", "emp_b"} + # Без фильтра admin видит и менеджеров — иначе им нечем сбросить пароль + # (инцидент 2026-07-31). Сам admin в выдачу не попадает. + assert {e["username"] for e in resp_all.json()} == {"emp_a", "emp_b", "mgr_a", "mgr_b"} + # ?manager_id= — по-прежнему ТОЛЬКО сотрудники этого менеджера. resp_filtered = client.get("/api/v1/team/employees", params={"manager_id": mgr_a}) assert resp_filtered.status_code == 200 assert {e["username"] for e in resp_filtered.json()} == {"emp_a"} @@ -786,7 +912,7 @@ def test_list_employees_query_count_is_not_n_plus_1(client: TestClient, store: _ store.query_count = 0 # reset after login's own DB traffic resp = client.get("/api/v1/team/employees") assert resp.status_code == 200 - assert len(resp.json()) == 10 + assert len(resp.json()) == 11 # 10 сотрудников + mgr_a (admin видит менеджеров) # rbac_guard session lookup (1) + current_team_actor session lookup (1) + # list query (1) + 2 batch quota queries = 5, flat regardless of N. assert store.query_count <= 5, f"expected O(1) queries for N=10, got {store.query_count}" @@ -824,7 +950,9 @@ def test_list_employees_pagination_stable_with_identical_created_at( store.add_user("admin1", hash_password("Secret123!"), role="admin") mgr_id = store.add_user("mgr_a", hash_password("Secret123!"), role="manager") same_ts = datetime.now(UTC) - expected_usernames = set() + # Менеджер тоже в выдаче admin'а (см. test_admin_list_includes_managers) — + # он такая же строка для пейджинга, тай-брейкер обязан покрывать и её. + expected_usernames = {"mgr_a"} for i in range(5): username = f"emp_tie_{i}" store.add_user( diff --git a/tradein-mvp/frontend/src/components/team/EmployeeTable.tsx b/tradein-mvp/frontend/src/components/team/EmployeeTable.tsx index ff2177b8..1b454fb3 100644 --- a/tradein-mvp/frontend/src/components/team/EmployeeTable.tsx +++ b/tradein-mvp/frontend/src/components/team/EmployeeTable.tsx @@ -27,6 +27,15 @@ function formatDate(iso: string): string { } } +/** + * Винительный падеж для confirm-текста: список admin'а содержит и менеджеров + * (см. `Employee.role`), а «Заблокировать сотрудника «praktika»?» про владельца + * организации — прямая дезинформация в самом опасном диалоге дашборда. + */ +function roleAccusative(role: Employee["role"]): string { + return role === "manager" ? "менеджера" : "сотрудника"; +} + function QuotaCell({ quota }: { quota: Employee["quota"] }) { if (quota.unlimited) { return без лимита; @@ -64,10 +73,11 @@ export function EmployeeTable({ function handleToggleActive(employee: Employee) { const nextActive = !employee.is_active; + const who = roleAccusative(employee.role); const confirmed = window.confirm( nextActive - ? `Разблокировать сотрудника «${employee.username}»?` - : `Заблокировать сотрудника «${employee.username}»? Все его текущие сессии будут завершены.`, + ? `Разблокировать ${who} «${employee.username}»?` + : `Заблокировать ${who} «${employee.username}»? Все его текущие сессии будут завершены.`, ); if (!confirmed) return; @@ -113,7 +123,14 @@ export function EmployeeTable({ {employees.map((employee) => ( - {employee.username} + + {employee.username} + {employee.role === "manager" ? ( + + менеджер + + ) : null} + {employee.display_name ?? "—"} {employee.org_name ? ` · ${employee.org_name}` : ""} diff --git a/tradein-mvp/frontend/src/components/team/QuotaEditor.tsx b/tradein-mvp/frontend/src/components/team/QuotaEditor.tsx index 3eabd3eb..94b2db38 100644 --- a/tradein-mvp/frontend/src/components/team/QuotaEditor.tsx +++ b/tradein-mvp/frontend/src/components/team/QuotaEditor.tsx @@ -29,6 +29,9 @@ export function QuotaEditor({ employee, onClose }: QuotaEditorProps) { ); const [newPassword, setNewPassword] = useState(""); const [showPassword, setShowPassword] = useState(false); + // Список admin'а смешанный (сотрудники + менеджеры организаций) — «Изменить + // сотрудника» над карточкой владельца организации вводит в заблуждение. + const who = employee.role === "manager" ? "менеджера" : "сотрудника"; const updateMutation = useUpdateEmployee(); @@ -67,11 +70,11 @@ export function QuotaEditor({ employee, onClose }: QuotaEditorProps) { className="team-modal" role="dialog" aria-modal="true" - aria-label={`Изменить сотрудника ${employee.username}`} + aria-label={`Изменить ${who} ${employee.username}`} onClick={(e) => e.stopPropagation()} onSubmit={handleSubmit} > -

Изменить сотрудника

+

Изменить {who}

{employee.username}

{updateMutation.isError ? ( @@ -121,7 +124,7 @@ export function QuotaEditor({ employee, onClose }: QuotaEditorProps) { {showPassword ? "Скрыть" : "Показать"}

- Смена пароля завершает все текущие сессии сотрудника. + Смена пароля завершает все текущие сессии {who}.

diff --git a/tradein-mvp/frontend/src/components/trade-in/trade-in.css b/tradein-mvp/frontend/src/components/trade-in/trade-in.css index b4bc3ec7..a7c6e9e0 100644 --- a/tradein-mvp/frontend/src/components/trade-in/trade-in.css +++ b/tradein-mvp/frontend/src/components/trade-in/trade-in.css @@ -2849,6 +2849,19 @@ html, body { overflow-x: clip; } color: var(--danger, #b3261e); } +/* Пометка «менеджер» рядом с логином в списке admin'а: список смешанный + (сотрудники + менеджеры организаций), без метки строки неразличимы. */ +.team-role-badge { + margin-left: 6px; + padding: 2px 6px; + border-radius: 4px; + font-size: 10px; + font-weight: 500; + white-space: nowrap; + background: var(--accent-soft, #e0edff); + color: var(--accent, #0d6fd6); +} + .team-quota-unlimited { color: var(--fg-secondary, #5b6066); font-size: 11px; diff --git a/tradein-mvp/frontend/src/lib/team-api.ts b/tradein-mvp/frontend/src/lib/team-api.ts index cbe43b1a..3f60ec10 100644 --- a/tradein-mvp/frontend/src/lib/team-api.ts +++ b/tradein-mvp/frontend/src/lib/team-api.ts @@ -36,6 +36,13 @@ export interface QuotaStatus { export interface Employee { id: number; username: string; + /** + * Кем является строка в списке. Бэкенд отдаёт менеджеров ТОЛЬКО актору-admin + * (`app/api/v1/team.py`, `_LIST_EMPLOYEES_ALL_SQL`) — менеджер в своём списке + * видит по-прежнему только `"employee"`. Строк `role: "admin"` этот API не + * отдаёт никогда, поэтому в юнионе их нет. + */ + role: "employee" | "manager"; display_name: string | null; org_name: string | null; email: string | null;