From c0824518ab6ea2f9722de874b333956f9aad8a92 Mon Sep 17 00:00:00 2001 From: nns <278048682+nurdotnet@users.noreply.github.com> Date: Mon, 27 Apr 2026 19:12:33 +0500 Subject: [PATCH] =?UTF-8?q?feat(employees):=20=D0=B3=D0=BB=D0=B0=D0=B2?= =?UTF-8?q?=D0=BD=D1=8B=D0=B9=20=D0=B0=D0=B4=D0=BC=D0=B8=D0=BD=D0=B8=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=B0=D1=82=D0=BE=D1=80=20=E2=80=94=20=D1=82=D0=B5?= =?UTF-8?q?=D1=80=D0=BC=D0=B8=D0=BD=D0=BE=D0=BB=D0=BE=D0=B3=D0=B8=D1=8F=20?= =?UTF-8?q?+=20=D0=B7=D0=B0=D1=89=D0=B8=D1=82=D0=B0=20=D1=80=D0=BE=D0=BB?= =?UTF-8?q?=D0=B8/=D0=B0=D0=BA=D1=82=D0=B8=D0=B2=D0=BD=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit — «Владелец» переименован в «Главный администратор» — терминологически у нас не «собственник», а тот кто управляет организацией. Бейдж в таблице, тексты модалок, серверные сообщения 403 — везде единая формулировка. — PUT /api/organization/employees/{id}: добавлен гард для главного администратора: · Смена RoleId на не-«Администратор» → 403 «Нельзя сменить роль…» · IsActive=false → 403 «Нельзя деактивировать…» Раньше юзер мог поменять себе роль на Кладовщик и получить бейдж «Владелец» с ролью кладовщика — несостыковка. — EmployeesPage: при редактировании главного администратора · Селект ролей disabled + amber-плашка-объяснение «роль фиксирована» · Чекбокс «Активен» disabled + текст «нельзя деактивировать» · save() ловит ошибки и показывает их в общей модалке (раньше 403 «тихо проваливалось» — модалка зависала). — recovery-restore-orphan-owners.sql добавлен блок: для всех Organizations где главный администратор имеет роль не-«Администратор» или IsActive=false → восстанавливает «Администратор» и активирует. Идемпотентен. Применён на стейдже (0 пострадавших — текущая БД ОК). Все изменения главного администратора (роль, ФИО, удаление, передача управления) по архитектурному решению юзера должны идти через очередь запросов к Супер-администратору платформы. Эта подсистема — отдельная большая фича (RequestType / RequestQueue / SuperAdmin approval UI), её план описан в TG-ответе. --- deploy/recovery-restore-orphan-owners.sql | 21 ++++++ .../Organizations/EmployeesController.cs | 37 ++++++++++- .../src/pages/EmployeesPage.tsx | 64 +++++++++++++------ 3 files changed, 99 insertions(+), 23 deletions(-) diff --git a/deploy/recovery-restore-orphan-owners.sql b/deploy/recovery-restore-orphan-owners.sql index 3f0fa0e..acd5202 100644 --- a/deploy/recovery-restore-orphan-owners.sql +++ b/deploy/recovery-restore-orphan-owners.sql @@ -48,4 +48,25 @@ UPDATE "OpenIddictTokens" t WHERE u."IsActive" = false ); +-- Owner-Employee должен оставаться в роли «Администратор» и быть IsActive=true. +-- Если кто-то сменил роль владельца на Кладовщика/Менеджера/Кассира или +-- деактивировал — возвращаем «Администратор» и активируем. +WITH admin_role_per_org AS ( + SELECT r."OrganizationId", r."Id" AS role_id + FROM employee_roles r + WHERE r."IsSystem" = true AND r."Name" = 'Администратор' +) +UPDATE employees e + SET "RoleId" = ar.role_id, + "IsActive" = true, + "FiredAt" = NULL + FROM organizations o + JOIN admin_role_per_org ar ON ar."OrganizationId" = o."Id" + WHERE e."OrganizationId" = o."Id" + AND e."UserId" = o."AccountOwnerUserId" + AND ( + e."RoleId" <> ar.role_id + OR e."IsActive" = false + ); + COMMIT; diff --git a/src/food-market.api/Controllers/Organizations/EmployeesController.cs b/src/food-market.api/Controllers/Organizations/EmployeesController.cs index 80a7839..e2a6659 100644 --- a/src/food-market.api/Controllers/Organizations/EmployeesController.cs +++ b/src/food-market.api/Controllers/Organizations/EmployeesController.cs @@ -155,6 +155,37 @@ public async Task Update(Guid id, [FromBody] EmployeeInput input, var e = await _db.Employees.Include(x => x.RetailPointAssignments) .FirstOrDefaultAsync(x => x.Id == id, ct); if (e is null) return NotFound(); + + // Гард для главного администратора организации (Organization.AccountOwnerUserId). + // По требованию: его роль и активность может менять только Супер-администратор + // платформы. В обычной tenant-админке — отказ. Удаление главного администратора + // обрабатывается отдельной веткой в DELETE. + var ownerUserId = await _db.Organizations.IgnoreQueryFilters() + .Where(o => o.Id == orgId) + .Select(o => o.AccountOwnerUserId) + .FirstOrDefaultAsync(ct); + var isOwner = e.UserId is not null && ownerUserId == e.UserId; + if (isOwner) + { + if (input.RoleId != e.RoleId) + { + var newRole = await _db.EmployeeRoles.AsNoTracking() + .FirstOrDefaultAsync(r => r.Id == input.RoleId, ct); + if (newRole is null || newRole.Name != "Администратор") + return StatusCode(StatusCodes.Status403Forbidden, new + { + error = "Нельзя сменить роль главного администратора организации. " + + "Это действие выполняет только Супер-администратор платформы.", + }); + } + if (!input.IsActive) + return StatusCode(StatusCodes.Status403Forbidden, new + { + error = "Нельзя деактивировать главного администратора организации. " + + "Это действие выполняет только Супер-администратор платформы.", + }); + } + e.LastName = input.LastName; e.FirstName = input.FirstName; e.MiddleName = input.MiddleName; @@ -188,7 +219,8 @@ public async Task Delete(Guid id, CancellationToken ct) var e = await _db.Employees.FirstOrDefaultAsync(x => x.Id == id, ct); if (e is null) return NotFound(); - // Soft-delete c гардами: владельца — нельзя удалить, себя — нельзя удалить. + // Soft-delete c гардами: главного администратора — нельзя, себя — нельзя. + // Главный администратор удаляется только Супер-администратором платформы. // Если кому-то критично hard-delete (cleanup при удалении org) — это идёт // через SuperAdmin консоль, а не через этот эндпоинт. var currentUserId = ParseUserId(User.FindFirst(ClaimTypes.NameIdentifier)?.Value @@ -211,7 +243,8 @@ public async Task Delete(Guid id, CancellationToken ct) { return StatusCode(StatusCodes.Status403Forbidden, new { - error = "Нельзя удалить владельца организации. Сначала передайте права владельца другому сотруднику.", + error = "Нельзя удалить главного администратора организации. " + + "Это действие выполняет только Супер-администратор платформы.", }); } } diff --git a/src/food-market.web/src/pages/EmployeesPage.tsx b/src/food-market.web/src/pages/EmployeesPage.tsx index 00e393f..eafba17 100644 --- a/src/food-market.web/src/pages/EmployeesPage.tsx +++ b/src/food-market.web/src/pages/EmployeesPage.tsx @@ -33,9 +33,10 @@ interface EmployeeDto { isActive: boolean firedAt: string | null retailPointIds: string[] - /** Владелец организации (Organization.AccountOwnerUserId == Employee.UserId). - * Удалить такого через DELETE /employees/{id} — нельзя; UI должен это - * показывать (бейдж + disabled-кнопка + объяснение). */ + /** Главный администратор организации (Organization.AccountOwnerUserId == + * Employee.UserId). Любые изменения этой записи (роль, активность, + * удаление) обычным админам недоступны — только Супер-администратор + * платформы может это сделать. UI показывает бейдж и блокирует поля. */ isOwner: boolean /** Текущий вошедший пользователь — это сам этот Employee. Нельзя удалить. */ isSelf: boolean @@ -78,8 +79,9 @@ export function EmployeesPage() { // Текущий открытый сотрудник — нужен чтобы знать isOwner/isSelf для футера // модалки (form содержит только редактируемые поля и не знает про маркеры). const [activeEmployee, setActiveEmployee] = useState(null) - // Объяснение почему нельзя удалить — показывается вместо confirm() когда - // пытаешься удалить владельца или себя. Текст с инструкцией. + // Объяснение почему действие заблокировано — показывается вместо confirm() + // при попытке удалить главного администратора или себя, либо как обёртка + // над любой ошибкой 4xx/5xx с сервера. const [blockedDelete, setBlockedDelete] = useState<{ title: string; body: string } | null>(null) const roles = useQuery({ @@ -118,16 +120,22 @@ export function EmployeesPage() { retailPointIds: form.retailPointIds, createAccount: !form.id && form.createAccount, } - if (form.id) { - await update.mutateAsync({ id: form.id, input: payload }) - setForm(null) - } else { - const res = await api.post<{ employee: EmployeeDto; generatedPassword: string | null }>(URL, payload) - setForm(null) - // Если сервер вернул password — показываем модалку one-shot. - if (res.data.generatedPassword && res.data.employee.email) { - setCreatedAccount({ email: res.data.employee.email, password: res.data.generatedPassword }) + try { + if (form.id) { + await update.mutateAsync({ id: form.id, input: payload }) + setForm(null); setActiveEmployee(null) + } else { + const res = await api.post<{ employee: EmployeeDto; generatedPassword: string | null }>(URL, payload) + setForm(null); setActiveEmployee(null) + // Если сервер вернул password — показываем модалку one-shot. + if (res.data.generatedPassword && res.data.employee.email) { + setCreatedAccount({ email: res.data.employee.email, password: res.data.generatedPassword }) + } } + } catch (e) { + const err = e as { response?: { data?: { error?: string } }, message?: string } + const msg = err.response?.data?.error ?? err.message ?? 'Не удалось сохранить' + setBlockedDelete({ title: 'Не удалось сохранить', body: msg }) } } @@ -182,7 +190,7 @@ export function EmployeesPage() { {r.lastName} {r.firstName} {r.middleName ?? ''} {r.isOwner && ( - Владелец + Главный администратор )} @@ -216,7 +224,7 @@ export function EmployeesPage() { disabled={activeEmployee?.isOwner || activeEmployee?.isSelf} title={ activeEmployee?.isOwner - ? 'Владельца удалить нельзя — нужно передать управление другому пользователю' + ? 'Главного администратора может удалить только Супер-администратор платформы' : activeEmployee?.isSelf ? 'Нельзя удалить себя' : undefined @@ -224,11 +232,11 @@ export function EmployeesPage() { onClick={async () => { if (activeEmployee?.isOwner) { setBlockedDelete({ - title: 'Нельзя удалить владельца магазина', + title: 'Действие заблокировано', body: - 'Чтобы удалить администратора магазина, сначала передайте управление другому пользователю. ' + - 'Организация не может остаться без владельца.\n\n' + - 'Удалить или деактивировать саму организацию может только Супер-администратор платформы.', + 'Главного администратора организации может изменить или удалить только Супер-администратор платформы. ' + + 'Чтобы передать роль другому сотруднику или удалить аккаунт — отправьте запрос в поддержку.\n\n' + + 'Организация не может остаться без главного администратора, поэтому в обычной админке это действие недоступно.', }) return } @@ -298,13 +306,20 @@ export function EmployeesPage() {
Роль *
-
+ {activeEmployee?.isOwner && ( +

+ Это главный администратор организации — роль фиксирована как «Администратор». + Изменить роль может только Супер-администратор платформы. +

+ )} +
{/* Системные сначала, потом кастомные. */} {roles.data?.slice().sort((a, b) => Number(b.isSystem) - Number(a.isSystem)).map((r) => (