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
|
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;
|
||||||
|
|
|
||||||
|
|
@ -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 = "Нельзя удалить главного администратора организации. " +
|
||||||
|
"Это действие выполняет только Супер-администратор платформы.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,16 +120,22 @@ export function EmployeesPage() {
|
||||||
retailPointIds: form.retailPointIds,
|
retailPointIds: form.retailPointIds,
|
||||||
createAccount: !form.id && form.createAccount,
|
createAccount: !form.id && form.createAccount,
|
||||||
}
|
}
|
||||||
if (form.id) {
|
try {
|
||||||
await update.mutateAsync({ id: form.id, input: payload })
|
if (form.id) {
|
||||||
setForm(null)
|
await update.mutateAsync({ id: form.id, input: payload })
|
||||||
} else {
|
setForm(null); setActiveEmployee(null)
|
||||||
const res = await api.post<{ employee: EmployeeDto; generatedPassword: string | null }>(URL, payload)
|
} else {
|
||||||
setForm(null)
|
const res = await api.post<{ employee: EmployeeDto; generatedPassword: string | null }>(URL, payload)
|
||||||
// Если сервер вернул password — показываем модалку one-shot.
|
setForm(null); setActiveEmployee(null)
|
||||||
if (res.data.generatedPassword && res.data.employee.email) {
|
// Если сервер вернул password — показываем модалку one-shot.
|
||||||
setCreatedAccount({ email: res.data.employee.email, password: res.data.generatedPassword })
|
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>
|
<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="Создать учётную запись (выдать логин)"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue