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
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:
parent
2691b7d78b
commit
c0824518ab
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -155,6 +155,37 @@ public async Task<IActionResult> 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<IActionResult> 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<IActionResult> Delete(Guid id, CancellationToken ct)
|
|||
{
|
||||
return StatusCode(StatusCodes.Status403Forbidden, new
|
||||
{
|
||||
error = "Нельзя удалить владельца организации. Сначала передайте права владельца другому сотруднику.",
|
||||
error = "Нельзя удалить главного администратора организации. " +
|
||||
"Это действие выполняет только Супер-администратор платформы.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<EmployeeDto | null>(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() {
|
|||
<span>{r.lastName} {r.firstName} {r.middleName ?? ''}</span>
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -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() {
|
|||
</Field>
|
||||
<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) => (
|
||||
<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
|
||||
type="radio"
|
||||
name="role"
|
||||
disabled={activeEmployee?.isOwner}
|
||||
checked={form.roleId === r.id}
|
||||
onChange={() => setForm({ ...form, roleId: r.id })}
|
||||
className="mt-1"
|
||||
|
|
@ -343,8 +358,15 @@ export function EmployeesPage() {
|
|||
<Checkbox
|
||||
label="Активен"
|
||||
checked={form.isActive}
|
||||
disabled={activeEmployee?.isOwner}
|
||||
onChange={(v) => setForm({ ...form, isActive: v })}
|
||||
/>
|
||||
{activeEmployee?.isOwner && (
|
||||
<p className="text-xs text-slate-500 -mt-1">
|
||||
Главного администратора нельзя деактивировать в обычной админке.
|
||||
Это действие выполняет Супер-администратор платформы.
|
||||
</p>
|
||||
)}
|
||||
{!form.id && (
|
||||
<Checkbox
|
||||
label="Создать учётную запись (выдать логин)"
|
||||
|
|
|
|||
Loading…
Reference in a new issue