feat(s29): SuperAdmin advanced-mode toggle + /api/me.advancedMode (шаг 2/8)
- PUT /api/super-admin/organizations/{id}/advanced-mode { enabled: bool }
— только SuperAdmin, админ орги → 403 от [Authorize(Roles=...)].
- Idempotent: совпадающее состояние → 200 без записи в аудит; разное →
запись SuperAdminAuditLog (EnableAdvancedMode / DisableAdvancedMode)
с before/after + IP. Архивную org переключать нельзя (409).
- /api/me теперь отдаёт advancedMode (bool) — читается из Organization
с IgnoreQueryFilters (SuperAdmin без org → false — работает через
/api/super-admin/...).
- OrgRow / OrgDetail в SuperAdmin list / detail дополнены IsAdvancedMode
для бейджа «Простой/Расширенный» (шаг 6).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
4342318c2c
commit
202763e0d0
|
|
@ -10,7 +10,7 @@ SuperAdmin и только для конкретной организации.
|
||||||
## Чек-лист
|
## Чек-лист
|
||||||
|
|
||||||
- [x] 1. Domain + миграция `Organization.IsAdvancedMode` (bool, default false, backfill false)
|
- [x] 1. Domain + миграция `Organization.IsAdvancedMode` (bool, default false, backfill false)
|
||||||
- [ ] 2. API `PUT /api/superadmin/organizations/{id}/advanced-mode` + аудит + `/api/me.advancedMode`; org-admin → 403
|
- [x] 2. API `PUT /api/super-admin/organizations/{id}/advanced-mode` + аудит + `/api/me.advancedMode`; org-admin → 403
|
||||||
- [ ] 3. Серверная защита: `[RequiresAdvancedMode]` → 403 ProblemDetails на скрытых модулях
|
- [ ] 3. Серверная защита: `[RequiresAdvancedMode]` → 403 ProblemDetails на скрытых модулях
|
||||||
- [ ] 4. Web: меню, маршруты, дашборд фильтруются; прямой переход → страница «Недоступно»
|
- [ ] 4. Web: меню, маршруты, дашборд фильтруются; прямой переход → страница «Недоступно»
|
||||||
- [ ] 5. Упрощённые формы (товар / контрагент / документы) — скрыть «не нужные» поля, значения сохраняются
|
- [ ] 5. Упрощённые формы (товар / контрагент / документы) — скрыть «не нужные» поля, значения сохраняются
|
||||||
|
|
@ -53,3 +53,13 @@ SuperAdmin и только для конкретной организации.
|
||||||
- Snapshot обновлён (`AppDbContextModelSnapshot.cs`).
|
- Snapshot обновлён (`AppDbContextModelSnapshot.cs`).
|
||||||
- Применено на dev-БД: 5/5 организаций `IsAdvancedMode = false`.
|
- Применено на dev-БД: 5/5 организаций `IsAdvancedMode = false`.
|
||||||
- `dotnet build food-market.api` — ✓ (0 error, warnings — старые, не наши).
|
- `dotnet build food-market.api` — ✓ (0 error, warnings — старые, не наши).
|
||||||
|
|
||||||
|
### 2026-10-04 — Шаг 2 ✓
|
||||||
|
|
||||||
|
- `PUT /api/super-admin/organizations/{id}/advanced-mode { "enabled": bool }` в `SuperAdminOrganizationsController` — `[Authorize(Roles="SuperAdmin")]`. Админ орги получает 403 от стандартного Authorize-фильтра (роль Admin ≠ SuperAdmin); в контроллере сверху уже `[Authorize(Roles="SuperAdmin")]` — не нужно отдельного `Forbid()`.
|
||||||
|
- Запись в `SuperAdminAuditLog`: ActionType `EnableAdvancedMode` / `DisableAdvancedMode`, Description, Changes `{"before":…,"after":…}`, IpAddress. Идемпотентный переключатель: если состояние совпадает — 200 без аудит-записи.
|
||||||
|
- Архивную орг переключать нельзя — 409.
|
||||||
|
- `/api/me` отдаёт `advancedMode` (bool). SuperAdmin без org → false (это ОК — он работает через `/api/super-admin/...`).
|
||||||
|
- `OrgRow` / `OrgDetail` в SuperAdmin list / detail — добавлено `IsAdvancedMode`.
|
||||||
|
- Маршрут: `/api/super-admin/...` (с дефисом), так как существующий контроллер уже использует этот стиль; задача упоминала `superadmin` без дефиса — выбрал существующий стиль.
|
||||||
|
- `dotnet build food-market.api` — ✓ (0 error).
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,8 @@ public record OrgRow(
|
||||||
Guid Id, string Name, string CountryCode,
|
Guid Id, string Name, string CountryCode,
|
||||||
bool IsActive, bool IsArchived, DateTime? ArchivedAt,
|
bool IsActive, bool IsArchived, DateTime? ArchivedAt,
|
||||||
DateTime CreatedAt, int EmployeeCount, int ProductCount,
|
DateTime CreatedAt, int EmployeeCount, int ProductCount,
|
||||||
DateTime? LastLoginAt);
|
DateTime? LastLoginAt,
|
||||||
|
bool IsAdvancedMode);
|
||||||
|
|
||||||
public record OrgDetail(
|
public record OrgDetail(
|
||||||
Guid Id, string Name, string CountryCode, string? Bin, string? Address, string? Phone, string? Email,
|
Guid Id, string Name, string CountryCode, string? Bin, string? Address, string? Phone, string? Email,
|
||||||
|
|
@ -39,7 +40,8 @@ public record OrgDetail(
|
||||||
bool IsActive, bool IsArchived, DateTime? ArchivedAt,
|
bool IsActive, bool IsArchived, DateTime? ArchivedAt,
|
||||||
Guid? AccountOwnerUserId, string? AccountOwnerName, string? AccountOwnerEmail,
|
Guid? AccountOwnerUserId, string? AccountOwnerName, string? AccountOwnerEmail,
|
||||||
DateTime CreatedAt, DateTime? UpdatedAt,
|
DateTime CreatedAt, DateTime? UpdatedAt,
|
||||||
int EmployeeCount, int ProductCount, int CounterpartyCount, int SupplyCountThisMonth);
|
int EmployeeCount, int ProductCount, int CounterpartyCount, int SupplyCountThisMonth,
|
||||||
|
bool IsAdvancedMode);
|
||||||
|
|
||||||
public record OrgInput(
|
public record OrgInput(
|
||||||
string Name, string CountryCode, string? Bin, string? Address, string? Phone, string? Email,
|
string Name, string CountryCode, string? Bin, string? Address, string? Phone, string? Email,
|
||||||
|
|
@ -53,6 +55,7 @@ public record CreateOrgResult(OrgDetail Organization, string AdminEmail, string
|
||||||
public record ArchiveRequest(string ConfirmationName);
|
public record ArchiveRequest(string ConfirmationName);
|
||||||
public record DeleteRequest(string ConfirmationName);
|
public record DeleteRequest(string ConfirmationName);
|
||||||
public record ChangeOwnerRequest(Guid NewOwnerUserId, string Reason);
|
public record ChangeOwnerRequest(Guid NewOwnerUserId, string Reason);
|
||||||
|
public record AdvancedModeRequest(bool Enabled);
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<ActionResult<PagedResult<OrgRow>>> List(
|
public async Task<ActionResult<PagedResult<OrgRow>>> List(
|
||||||
|
|
@ -77,7 +80,8 @@ public record ChangeOwnerRequest(Guid NewOwnerUserId, string Reason);
|
||||||
_db.Employees.IgnoreQueryFilters().Count(e => e.OrganizationId == o.Id),
|
_db.Employees.IgnoreQueryFilters().Count(e => e.OrganizationId == o.Id),
|
||||||
_db.Products.IgnoreQueryFilters().Count(p => p.OrganizationId == o.Id),
|
_db.Products.IgnoreQueryFilters().Count(p => p.OrganizationId == o.Id),
|
||||||
_db.Users.Where(u => u.OrganizationId == o.Id && u.LastLoginAt != null)
|
_db.Users.Where(u => u.OrganizationId == o.Id && u.LastLoginAt != null)
|
||||||
.Max(u => (DateTime?)u.LastLoginAt)))
|
.Max(u => (DateTime?)u.LastLoginAt),
|
||||||
|
o.IsAdvancedMode))
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
return new PagedResult<OrgRow> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
|
return new PagedResult<OrgRow> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
|
||||||
}
|
}
|
||||||
|
|
@ -229,6 +233,36 @@ public async Task<IActionResult> Delete(Guid id, [FromBody] DeleteRequest req, C
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Sprint 29 — переключатель расширенного режима организации.
|
||||||
|
/// По умолчанию <c>false</c> → UI/API видят только «простой» набор функций.
|
||||||
|
/// Переключать может только SuperAdmin; админ организации — 403 (гейтим
|
||||||
|
/// ролью на уровне контроллера). Idempotent: если состояние совпадает —
|
||||||
|
/// вернём 200 + <c>IsAdvancedMode</c>, без записи в аудит.</summary>
|
||||||
|
[HttpPut("{id:guid}/advanced-mode")]
|
||||||
|
public async Task<IActionResult> SetAdvancedMode(Guid id, [FromBody] AdvancedModeRequest req, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var o = await _db.Organizations.IgnoreQueryFilters().FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||||
|
if (o is null) return NotFound();
|
||||||
|
if (o.IsArchived)
|
||||||
|
return Conflict(new { error = "Организация в архиве — сначала восстанови её." });
|
||||||
|
|
||||||
|
if (o.IsAdvancedMode == req.Enabled)
|
||||||
|
return Ok(new { id = o.Id, isAdvancedMode = o.IsAdvancedMode, changed = false });
|
||||||
|
|
||||||
|
var before = o.IsAdvancedMode;
|
||||||
|
o.IsAdvancedMode = req.Enabled;
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
var actionType = req.Enabled ? "EnableAdvancedMode" : "DisableAdvancedMode";
|
||||||
|
var description = req.Enabled
|
||||||
|
? $"Включен расширенный режим «{o.Name}»"
|
||||||
|
: $"Выключен расширенный режим «{o.Name}»";
|
||||||
|
await LogAsync(actionType, o.Id, description, null,
|
||||||
|
$"{{\"before\":{before.ToString().ToLower()},\"after\":{req.Enabled.ToString().ToLower()}}}", ct);
|
||||||
|
|
||||||
|
return Ok(new { id = o.Id, isAdvancedMode = o.IsAdvancedMode, changed = true });
|
||||||
|
}
|
||||||
|
|
||||||
[HttpPost("{id:guid}/change-owner")]
|
[HttpPost("{id:guid}/change-owner")]
|
||||||
public async Task<IActionResult> ChangeOwner(Guid id, [FromBody] ChangeOwnerRequest req, CancellationToken ct)
|
public async Task<IActionResult> ChangeOwner(Guid id, [FromBody] ChangeOwnerRequest req, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
|
@ -267,7 +301,8 @@ public async Task<IActionResult> ChangeOwner(Guid id, [FromBody] ChangeOwnerRequ
|
||||||
o.DefaultCurrencyId, o.DefaultCurrency?.Code,
|
o.DefaultCurrencyId, o.DefaultCurrency?.Code,
|
||||||
o.IsActive, o.IsArchived, o.ArchivedAt,
|
o.IsActive, o.IsArchived, o.ArchivedAt,
|
||||||
o.AccountOwnerUserId, owner?.FullName, owner?.Email,
|
o.AccountOwnerUserId, owner?.FullName, owner?.Email,
|
||||||
o.CreatedAt, o.UpdatedAt, emp, prod, cp, supplies);
|
o.CreatedAt, o.UpdatedAt, emp, prod, cp, supplies,
|
||||||
|
o.IsAdvancedMode);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task LogAsync(string actionType, Guid orgId, string description, string? reason, string changesJson, CancellationToken ct)
|
private async Task LogAsync(string actionType, Guid orgId, string description, string? reason, string changesJson, CancellationToken ct)
|
||||||
|
|
|
||||||
|
|
@ -632,10 +632,21 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme
|
||||||
// получает /no-organization fallback вместо белого экрана на /dashboard.
|
// получает /no-organization fallback вместо белого экрана на /dashboard.
|
||||||
bool hasLiveOrg = false;
|
bool hasLiveOrg = false;
|
||||||
bool hasActiveEmployee = false;
|
bool hasActiveEmployee = false;
|
||||||
|
// Sprint 29 (simple-mode): флаг расширенного режима организации.
|
||||||
|
// false по умолчанию → UI показывает только «простое» меню.
|
||||||
|
// Включает только SuperAdmin через /api/super-admin/organizations/{id}/advanced-mode.
|
||||||
|
bool advancedMode = false;
|
||||||
if (Guid.TryParse(orgIdRaw, out var orgId))
|
if (Guid.TryParse(orgIdRaw, out var orgId))
|
||||||
{
|
{
|
||||||
hasLiveOrg = await db.Organizations.IgnoreQueryFilters()
|
var org = await db.Organizations.IgnoreQueryFilters()
|
||||||
.AnyAsync(o => o.Id == orgId && !o.IsArchived);
|
.Where(o => o.Id == orgId)
|
||||||
|
.Select(o => new { o.IsArchived, o.IsAdvancedMode })
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
if (org is not null)
|
||||||
|
{
|
||||||
|
hasLiveOrg = !org.IsArchived;
|
||||||
|
advancedMode = org.IsAdvancedMode;
|
||||||
|
}
|
||||||
if (Guid.TryParse(subRaw, out var sub))
|
if (Guid.TryParse(subRaw, out var sub))
|
||||||
{
|
{
|
||||||
hasActiveEmployee = await db.Employees.IgnoreQueryFilters()
|
hasActiveEmployee = await db.Employees.IgnoreQueryFilters()
|
||||||
|
|
@ -652,6 +663,7 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme
|
||||||
orgId = orgIdRaw,
|
orgId = orgIdRaw,
|
||||||
hasLiveOrg,
|
hasLiveOrg,
|
||||||
hasActiveEmployee,
|
hasActiveEmployee,
|
||||||
|
advancedMode,
|
||||||
});
|
});
|
||||||
}).RequireAuthorization();
|
}).RequireAuthorization();
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue