feat(employees): главный администратор — терминология + защита роли/активности
Some checks are pending
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 45s
CI / Web (React + Vite) (push) Successful in 40s
Docker API / Build + push API (push) Successful in 1m7s
Docker Web / Build + push Web (push) Successful in 32s
Docker API / Deploy API on stage (push) Successful in 17s
Docker Web / Deploy Web on stage (push) Successful in 12s

— «Владелец» переименован в «Главный администратор» — терминологически
  у нас не «собственник», а тот кто управляет организацией.
  Бейдж в таблице, тексты модалок, серверные сообщения 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-ответе.
This commit is contained in:
nns 2026-04-27 19:12:33 +05:00
parent 2691b7d78b
commit c0824518ab
3 changed files with 99 additions and 23 deletions

View file

@ -48,4 +48,25 @@ UPDATE "OpenIddictTokens" t
WHERE u."IsActive" = false 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; COMMIT;

View file

@ -155,6 +155,37 @@ public async Task<IActionResult> Update(Guid id, [FromBody] EmployeeInput input,
var e = await _db.Employees.Include(x => x.RetailPointAssignments) var e = await _db.Employees.Include(x => x.RetailPointAssignments)
.FirstOrDefaultAsync(x => x.Id == id, ct); .FirstOrDefaultAsync(x => x.Id == id, ct);
if (e is null) return NotFound(); 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.LastName = input.LastName;
e.FirstName = input.FirstName; e.FirstName = input.FirstName;
e.MiddleName = input.MiddleName; e.MiddleName = input.MiddleName;
@ -188,7 +219,8 @@ public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
var e = await _db.Employees.FirstOrDefaultAsync(x => x.Id == id, ct); var e = await _db.Employees.FirstOrDefaultAsync(x => x.Id == id, ct);
if (e is null) return NotFound(); if (e is null) return NotFound();
// Soft-delete c гардами: владельца — нельзя удалить, себя — нельзя удалить. // Soft-delete c гардами: главного администратора — нельзя, себя — нельзя.
// Главный администратор удаляется только Супер-администратором платформы.
// Если кому-то критично hard-delete (cleanup при удалении org) — это идёт // Если кому-то критично hard-delete (cleanup при удалении org) — это идёт
// через SuperAdmin консоль, а не через этот эндпоинт. // через SuperAdmin консоль, а не через этот эндпоинт.
var currentUserId = ParseUserId(User.FindFirst(ClaimTypes.NameIdentifier)?.Value var currentUserId = ParseUserId(User.FindFirst(ClaimTypes.NameIdentifier)?.Value
@ -211,7 +243,8 @@ public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{ {
return StatusCode(StatusCodes.Status403Forbidden, new return StatusCode(StatusCodes.Status403Forbidden, new
{ {
error = "Нельзя удалить владельца организации. Сначала передайте права владельца другому сотруднику.", error = "Нельзя удалить главного администратора организации. " +
"Это действие выполняет только Супер-администратор платформы.",
}); });
} }
} }

View file

@ -33,9 +33,10 @@ interface EmployeeDto {
isActive: boolean isActive: boolean
firedAt: string | null firedAt: string | null
retailPointIds: string[] retailPointIds: string[]
/** Владелец организации (Organization.AccountOwnerUserId == Employee.UserId). /** Главный администратор организации (Organization.AccountOwnerUserId ==
* Удалить такого через DELETE /employees/{id} нельзя; UI должен это * Employee.UserId). Любые изменения этой записи (роль, активность,
* показывать (бейдж + disabled-кнопка + объяснение). */ * удаление) обычным админам недоступны только Супер-администратор
* платформы может это сделать. UI показывает бейдж и блокирует поля. */
isOwner: boolean isOwner: boolean
/** Текущий вошедший пользователь — это сам этот Employee. Нельзя удалить. */ /** Текущий вошедший пользователь — это сам этот Employee. Нельзя удалить. */
isSelf: boolean isSelf: boolean
@ -78,8 +79,9 @@ export function EmployeesPage() {
// Текущий открытый сотрудник — нужен чтобы знать isOwner/isSelf для футера // Текущий открытый сотрудник — нужен чтобы знать isOwner/isSelf для футера
// модалки (form содержит только редактируемые поля и не знает про маркеры). // модалки (form содержит только редактируемые поля и не знает про маркеры).
const [activeEmployee, setActiveEmployee] = useState<EmployeeDto | null>(null) const [activeEmployee, setActiveEmployee] = useState<EmployeeDto | null>(null)
// Объяснение почему нельзя удалить — показывается вместо confirm() когда // Объяснение почему действие заблокировано — показывается вместо confirm()
// пытаешься удалить владельца или себя. Текст с инструкцией. // при попытке удалить главного администратора или себя, либо как обёртка
// над любой ошибкой 4xx/5xx с сервера.
const [blockedDelete, setBlockedDelete] = useState<{ title: string; body: string } | null>(null) const [blockedDelete, setBlockedDelete] = useState<{ title: string; body: string } | null>(null)
const roles = useQuery({ const roles = useQuery({
@ -118,17 +120,23 @@ export function EmployeesPage() {
retailPointIds: form.retailPointIds, retailPointIds: form.retailPointIds,
createAccount: !form.id && form.createAccount, createAccount: !form.id && form.createAccount,
} }
try {
if (form.id) { if (form.id) {
await update.mutateAsync({ id: form.id, input: payload }) await update.mutateAsync({ id: form.id, input: payload })
setForm(null) setForm(null); setActiveEmployee(null)
} else { } else {
const res = await api.post<{ employee: EmployeeDto; generatedPassword: string | null }>(URL, payload) const res = await api.post<{ employee: EmployeeDto; generatedPassword: string | null }>(URL, payload)
setForm(null) setForm(null); setActiveEmployee(null)
// Если сервер вернул password — показываем модалку one-shot. // Если сервер вернул password — показываем модалку one-shot.
if (res.data.generatedPassword && res.data.employee.email) { if (res.data.generatedPassword && res.data.employee.email) {
setCreatedAccount({ email: res.data.employee.email, password: res.data.generatedPassword }) 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 })
}
} }
const toggleRP = (id: string) => { const toggleRP = (id: string) => {
@ -182,7 +190,7 @@ export function EmployeesPage() {
<span>{r.lastName} {r.firstName} {r.middleName ?? ''}</span> <span>{r.lastName} {r.firstName} {r.middleName ?? ''}</span>
{r.isOwner && ( {r.isOwner && (
<span className="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300"> <span className="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300">
Владелец Главный администратор
</span> </span>
)} )}
</div> </div>
@ -216,7 +224,7 @@ export function EmployeesPage() {
disabled={activeEmployee?.isOwner || activeEmployee?.isSelf} disabled={activeEmployee?.isOwner || activeEmployee?.isSelf}
title={ title={
activeEmployee?.isOwner activeEmployee?.isOwner
? 'Владельца удалить нельзя — нужно передать управление другому пользователю' ? 'Главного администратора может удалить только Супер-администратор платформы'
: activeEmployee?.isSelf : activeEmployee?.isSelf
? 'Нельзя удалить себя' ? 'Нельзя удалить себя'
: undefined : undefined
@ -224,11 +232,11 @@ export function EmployeesPage() {
onClick={async () => { onClick={async () => {
if (activeEmployee?.isOwner) { if (activeEmployee?.isOwner) {
setBlockedDelete({ setBlockedDelete({
title: 'Нельзя удалить владельца магазина', title: 'Действие заблокировано',
body: body:
'Чтобы удалить администратора магазина, сначала передайте управление другому пользователю. ' + 'Главного администратора организации может изменить или удалить только Супер-администратор платформы. ' +
'Организация не может остаться без владельца.\n\n' + 'Чтобы передать роль другому сотруднику или удалить аккаунт — отправьте запрос в поддержку.\n\n' +
'Удалить или деактивировать саму организацию может только Супер-администратор платформы.', 'Организация не может остаться без главного администратора, поэтому в обычной админке это действие недоступно.',
}) })
return return
} }
@ -298,13 +306,20 @@ export function EmployeesPage() {
</Field> </Field>
<div> <div>
<div className="text-sm font-medium mb-2">Роль *</div> <div className="text-sm font-medium mb-2">Роль *</div>
<div className="border border-slate-200 dark:border-slate-700 rounded-md divide-y divide-slate-100 dark:divide-slate-800 max-h-72 overflow-auto"> {activeEmployee?.isOwner && (
<p className="text-xs text-amber-700 dark:text-amber-300 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-md px-2.5 py-2 mb-2">
Это главный администратор организации роль фиксирована как «Администратор».
Изменить роль может только Супер-администратор платформы.
</p>
)}
<div className={`border border-slate-200 dark:border-slate-700 rounded-md divide-y divide-slate-100 dark:divide-slate-800 max-h-72 overflow-auto ${activeEmployee?.isOwner ? 'opacity-60 pointer-events-none' : ''}`}>
{/* Системные сначала, потом кастомные. */} {/* Системные сначала, потом кастомные. */}
{roles.data?.slice().sort((a, b) => Number(b.isSystem) - Number(a.isSystem)).map((r) => ( {roles.data?.slice().sort((a, b) => Number(b.isSystem) - Number(a.isSystem)).map((r) => (
<label key={r.id} className="flex items-start gap-2 p-2.5 cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-800/40"> <label key={r.id} className="flex items-start gap-2 p-2.5 cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-800/40">
<input <input
type="radio" type="radio"
name="role" name="role"
disabled={activeEmployee?.isOwner}
checked={form.roleId === r.id} checked={form.roleId === r.id}
onChange={() => setForm({ ...form, roleId: r.id })} onChange={() => setForm({ ...form, roleId: r.id })}
className="mt-1" className="mt-1"
@ -343,8 +358,15 @@ export function EmployeesPage() {
<Checkbox <Checkbox
label="Активен" label="Активен"
checked={form.isActive} checked={form.isActive}
disabled={activeEmployee?.isOwner}
onChange={(v) => setForm({ ...form, isActive: v })} onChange={(v) => setForm({ ...form, isActive: v })}
/> />
{activeEmployee?.isOwner && (
<p className="text-xs text-slate-500 -mt-1">
Главного администратора нельзя деактивировать в обычной админке.
Это действие выполняет Супер-администратор платформы.
</p>
)}
{!form.id && ( {!form.id && (
<Checkbox <Checkbox
label="Создать учётную запись (выдать логин)" label="Создать учётную запись (выдать логин)"