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) => (