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:
nns 2026-10-04 11:32:12 +05:00
parent 4342318c2c
commit 202763e0d0
3 changed files with 64 additions and 7 deletions

View file

@ -10,7 +10,7 @@ SuperAdmin и только для конкретной организации.
## Чек-лист
- [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 на скрытых модулях
- [ ] 4. Web: меню, маршруты, дашборд фильтруются; прямой переход → страница «Недоступно»
- [ ] 5. Упрощённые формы (товар / контрагент / документы) — скрыть «не нужные» поля, значения сохраняются
@ -53,3 +53,13 @@ SuperAdmin и только для конкретной организации.
- Snapshot обновлён (`AppDbContextModelSnapshot.cs`).
- Применено на dev-БД: 5/5 организаций `IsAdvancedMode = false`.
- `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).

View file

@ -31,7 +31,8 @@ public record OrgRow(
Guid Id, string Name, string CountryCode,
bool IsActive, bool IsArchived, DateTime? ArchivedAt,
DateTime CreatedAt, int EmployeeCount, int ProductCount,
DateTime? LastLoginAt);
DateTime? LastLoginAt,
bool IsAdvancedMode);
public record OrgDetail(
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,
Guid? AccountOwnerUserId, string? AccountOwnerName, string? AccountOwnerEmail,
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(
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 DeleteRequest(string ConfirmationName);
public record ChangeOwnerRequest(Guid NewOwnerUserId, string Reason);
public record AdvancedModeRequest(bool Enabled);
[HttpGet]
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.Products.IgnoreQueryFilters().Count(p => p.OrganizationId == o.Id),
_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);
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();
}
/// <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")]
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.IsActive, o.IsArchived, o.ArchivedAt,
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)

View file

@ -632,10 +632,21 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme
// получает /no-organization fallback вместо белого экрана на /dashboard.
bool hasLiveOrg = 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))
{
hasLiveOrg = await db.Organizations.IgnoreQueryFilters()
.AnyAsync(o => o.Id == orgId && !o.IsArchived);
var org = await db.Organizations.IgnoreQueryFilters()
.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))
{
hasActiveEmployee = await db.Employees.IgnoreQueryFilters()
@ -652,6 +663,7 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme
orgId = orgIdRaw,
hasLiveOrg,
hasActiveEmployee,
advancedMode,
});
}).RequireAuthorization();