fix(tradein/team): admin управляет менеджерами — сброс пароля и блокировка (#2556) #2575
7 changed files with 265 additions and 41 deletions
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 <span className="team-quota-unlimited">без лимита</span>;
|
||||
|
|
@ -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) => (
|
||||
<Fragment key={employee.id}>
|
||||
<tr>
|
||||
<td>{employee.username}</td>
|
||||
<td>
|
||||
{employee.username}
|
||||
{employee.role === "manager" ? (
|
||||
<span className="team-role-badge" title="Менеджер организации">
|
||||
менеджер
|
||||
</span>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="run-muted">
|
||||
{employee.display_name ?? "—"}
|
||||
{employee.org_name ? ` · ${employee.org_name}` : ""}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
>
|
||||
<h2>Изменить сотрудника</h2>
|
||||
<h2>Изменить {who}</h2>
|
||||
<p className="team-modal-subtitle">{employee.username}</p>
|
||||
|
||||
{updateMutation.isError ? (
|
||||
|
|
@ -121,7 +124,7 @@ export function QuotaEditor({ employee, onClose }: QuotaEditorProps) {
|
|||
{showPassword ? "Скрыть" : "Показать"}
|
||||
</button>
|
||||
<p className="team-field-hint">
|
||||
Смена пароля завершает все текущие сессии сотрудника.
|
||||
Смена пароля завершает все текущие сессии {who}.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue