diff --git a/deploy/Dockerfile.api b/deploy/Dockerfile.api index eed668f..ebb8663 100644 --- a/deploy/Dockerfile.api +++ b/deploy/Dockerfile.api @@ -11,10 +11,12 @@ COPY src/food-market.api/food-market.api.csproj src/food-market.api/ COPY src/food-market.pos.core/food-market.pos.core.csproj src/food-market.pos.core/ COPY src/food-market.pos/food-market.pos.csproj src/food-market.pos/ -RUN dotnet restore src/food-market.api/food-market.api.csproj - COPY src/ src/ -RUN dotnet publish src/food-market.api/food-market.api.csproj -c Release -o /app --no-restore +# Single-step restore + publish — раздельные шаги в multi-stage cache +# роняли publish с NETSDK1064 (Microsoft.CodeAnalysis.Analyzers 3.3.3 not +# found) когда в csproj добавлялись новые transitive analyzer-зависимости, +# а первый restore не покрывал их. Теперь restore выполняется внутри publish. +RUN dotnet publish src/food-market.api/food-market.api.csproj -c Release -o /app FROM ${LOCAL_REGISTRY}/mirror/dotnet-aspnet:8.0 AS runtime WORKDIR /app diff --git a/deploy/recovery-restore-orphan-owners.sql b/deploy/recovery-restore-orphan-owners.sql new file mode 100644 index 0000000..3f0fa0e --- /dev/null +++ b/deploy/recovery-restore-orphan-owners.sql @@ -0,0 +1,51 @@ +-- Recovery: orphan AppUser cleanup. +-- +-- Применяется один раз вручную на стейдже/проде после деплоя +-- AuthorizationController + SuperAdminOrganizationsController фиксов +-- (audit 2026-04-27 #1, #2, #7). +-- +-- Что делает: +-- 1. Находит users у которых OrganizationId указывает на отсутствующую +-- или архивированную организацию. +-- 2. Деактивирует таких users (IsActive=false), сбрасывает OrganizationId. +-- 3. Отзывает все OpenIddict refresh/access токены этих users +-- (Status='revoked') чтобы существующие сессии оборвались. +-- +-- Идемпотентен: повторный запуск ничего не ломает. +-- Не удаляет данные — только статусы. Юзер при необходимости может +-- быть восстановлен ручным UPDATE users SET "IsActive"=true. + +BEGIN; + +WITH orphan_users AS ( + SELECT u."Id" + FROM users u + LEFT JOIN organizations o ON o."Id" = u."OrganizationId" + WHERE u."IsActive" = true + AND ( + u."OrganizationId" IS NULL + OR o."Id" IS NULL + OR o."IsArchived" = true + ) + AND NOT EXISTS ( + -- Не трогаем SuperAdmin'ов — у них org=null это норма. + SELECT 1 + FROM "AspNetUserRoles" ur + JOIN roles r ON r."Id" = ur."RoleId" + WHERE ur."UserId" = u."Id" AND r."NormalizedName" = 'SUPERADMIN' + ) +) +UPDATE users + SET "IsActive" = false, + "OrganizationId" = NULL + WHERE "Id" IN (SELECT "Id" FROM orphan_users); + +UPDATE "OpenIddictTokens" t + SET "Status" = 'revoked' + WHERE t."Status" = 'valid' + AND t."Subject" IN ( + SELECT u."Id"::text FROM users u + WHERE u."IsActive" = false + ); + +COMMIT; diff --git a/docs/audit-2026-04-27.md b/docs/audit-2026-04-27.md new file mode 100644 index 0000000..80eadea --- /dev/null +++ b/docs/audit-2026-04-27.md @@ -0,0 +1,134 @@ +# Системный аудит — 2026-04-27 + +Полный обход auth, tenant isolation, удаления сущностей, override-режима, локализации, валидации форм. Запущен после прямой жалобы юзера: «удалил себя — могу зайти», «зашёл в SuperAdmin консоль будучи tenant-юзером». + +## Корневая диагностика nurnetps@gmail.com + +Состояние БД на момент аудита (см. SQL-скрипты в этом отчёте): + +``` +users.Id = fbe4255a-c1ad-4355-88c1-ef21dfcd6db2 +users.IsActive = true +users.OrganizationId = 6237ef17-b720-4076-86d0-0f543023b31a ← удалённая +users.LockoutEnd = null +roles = ['Admin'] ← глобальная Identity-роль +employees = 0 rows +organizations(id) = 0 rows ← удалена +OpenIddictTokens = 3 valid refresh + 3 valid access (TTL до 2026-05-27) +``` + +**Гипотеза А (`/signup` даёт SuperAdmin) — отклонена.** В `AuthSignupController.cs:79` назначается роль `Admin`, не `SuperAdmin`. + +**Гипотеза Г подтверждена:** при удалении Organization из SuperAdmin консоли: +1. Связанные `users` НЕ деактивируются и сохраняют `OrganizationId` указывающий на удалённую org (orphan reference, нет FK с CASCADE). +2. OpenIddict refresh/access tokens НЕ отзываются. +3. `Employees` либо удаляются (вручную перед DELETE org), либо остаются orphan — в любом случае на `/connect/token` это не влияет. + +Login повторно проходит, потому что: +- `users.IsActive=true` (поле есть, но никто не сбрасывает на DELETE org). +- Пароль валиден. +- Identity-роль `Admin` глобальная. +- На бэке нет проверки «AppUser.OrganizationId должен указывать на живую Organization». +- На фронте после login нет проверки «активный Employee в орге». + +Override-баннер видит обычный tenant-юзер потому что (см. фикс #6) `SuperAdminLayout` рендерится по факту наличия любых Identity-ролей в JWT, а не строго `SuperAdmin`. + +## Найденные проблемы + +### #1 — DELETE Organization не каскадирует на AppUser/Employees/токены +**Категория:** security / data-integrity +**Серьёзность:** critical +**Воспроизведение:** SuperAdmin удаляет архивированную org → AppUser-ы этой org остаются `IsActive=true` с валидными refresh-tokens; могут логиниться; JWT содержит `org_id` указывающий в никуда. +**Корневая причина:** `SuperAdminOrganizationsController.Delete` (api/Controllers/SuperAdmin) делает `_db.Organizations.Remove(o)` без побочных эффектов; FK от `users.OrganizationId` к `organizations.Id` отсутствует на уровне БД. +**Фикс:** перед `Remove(org)` — `users.IsActive=false` + `Employees.IsActive=false` + revoke всех refresh-tokens юзеров через `IOpenIddictTokenManager`. + +### #2 — `/connect/token` не проверяет наличие живой organization +**Категория:** security / auth +**Серьёзность:** critical +**Воспроизведение:** см. nurnetps — login проходит при удалённой org. +**Фикс:** в кастомизации token endpoint (или сразу после signin) проверять что `User.OrganizationId IS NOT NULL` и существует не-архивная Organization, иначе reject с понятным сообщением «Организация не найдена или удалена. Обратитесь к владельцу». + +### #3 — `EmployeesController.Delete` — hard-delete без гардов +**Категория:** security / UX +**Серьёзность:** high +**Воспроизведение:** Admin может удалить себя или владельца org через DELETE /api/employees/{id} без сопротивления. +**Фикс:** проверки `e.UserId == currentUserId` → 403, `e.UserId == org.AccountOwnerUserId` → 403, soft-delete (`IsActive=false`) вместо `Remove`. + +### #4 — Tenant guard не проверяет активный Employee +**Категория:** security / multi-tenancy +**Серьёзность:** high +**Воспроизведение:** orphan AppUser с `OrganizationId` указывающим на удалённую/несоответствующую org попадает на `/dashboard` и любые tenant-API. +**Фикс:** middleware/filter после `[Authorize]` — `EXISTS(Employee WHERE UserId=@uid AND OrganizationId=@oid AND IsActive=true)`. SuperAdmin override обходит проверку (ему так и надо). Если нет — 403 + специфический код `NoActiveEmployee`, фронт ловит и редиректит на `/no-organization`. + +### #5 — Override-баннер показывается не-SuperAdmin +**Категория:** UX / security perception +**Серьёзность:** high +**Воспроизведение:** orphan AppUser с Identity-ролью `Admin` логинится → видит SuperAdmin консоль / override-баннер. +**Фикс:** `SuperAdminLayout` и `OverrideBanner` рендерятся только если в `/api/me` есть `roles` содержащая `SuperAdmin`. Все остальные — на `/dashboard` или `/no-organization`. + +### #6 — Logout не отзывает refresh-tokens +**Категория:** security +**Серьёзность:** medium +**Воспроизведение:** юзер выходит, но refresh-token остаётся valid в БД 30 дней. +**Фикс:** POST `/api/auth/logout` — revoke всех refresh-tokens текущего пользователя через OpenIddict; фронт чистит localStorage; LoginPage предупреждает «Вы уже вошли как X» если есть активная сессия. + +### #7 — Нет recovery для orphan AppUser +**Категория:** data-integrity +**Серьёзность:** medium +**Воспроизведение:** nurnetps@gmail.com висит в БД с указателем на удалённую org. +**Фикс:** SQL-скрипт `deploy/recovery-restore-orphan-owners.sql` (идемпотентный) — для каждого `users` с `OrganizationId` указывающим на отсутствующую/архивную org → `IsActive=false`, всем refresh-tokens поставить `Status='revoked'`. + +### #8 — Эмpty-state «нет активных организаций» отсутствует +**Категория:** UX +**Серьёзность:** medium +**Воспроизведение:** AppUser без активного Employee — после login падает на `/dashboard` и видит белый экран / 403. +**Фикс:** страница `/no-organization` с CTA «Создать организацию» (ведёт на /signup) и «Попросить инвайт» (mailto на support). + +## Что было сделано в предыдущих коммитах (не в этом аудите) + +- Email validation + i18n native-tooltip (`feat(validation)`, коммит `ff991a7`) +- Russian-names patch — placeholder в SignupForm заменён (`fix(public)`, коммит `1f2cf2a`) +- Чистка имён конкурентов и Масса-К (несколько коммитов в Phase 6) +- Live-наполнение публичного сайта (скриншоты + Unsplash + OG, `dcc3f9d`) + +## Решения, принятые без подтверждения юзера + +1. **Soft-delete vs hard-delete для Employee:** soft (`IsActive=false`). История операций сохраняется. +2. **Хранение Owner-маркера:** уже есть `Organization.AccountOwnerUserId` — использую его, новой колонки `Employee.IsOwner` не нужно. +3. **Tenant guard и SuperAdmin:** SuperAdmin без override может зайти только на `/super-admin/*`; на tenant-страницы — только через override или прямой URL с tenant data. SuperAdmin override обходит guard «активный Employee». +4. **Logout revoke:** только refresh-tokens; access-tokens живут 15 минут, не парю руки. +5. **Recovery скрипт:** идемпотентный, безопасный к повторному запуску. Не рушит данные — только деактивирует orphan AppUser. +6. **Account page (transfer owner / leave org / delete account):** **не делал в этом раунде** — отдельная задача после критических auth-фиксов. +7. **Onboarding flow (sticky-баннер на шагах):** **не делал** — отдельная задача после auth-фиксов. + +## Открытые вопросы (требуют решения юзера) + +1. **Employee-маркер «Владелец» в UI:** показывать как бейдж рядом с ФИО на `/employees`? Сейчас Owner определяется через `org.AccountOwnerUserId == employee.UserId` — флаг `IsOwner` на Employee делать **не предлагаю**, чтобы не плодить duplicate state. +2. **Что делать если AppUser стал orphan и пытается логиниться:** мой выбор — отказывать в `/connect/token` с сообщением «Организация удалена». Альтернатива — впускать на `/no-organization` с возможностью создать новую org через wizard (как в Notion). Если нужен второй вариант — потребует UX-проектирования. +3. **Inviting flow** (юзер без org попросил доступ к чужой): не реализовано, не в скоупе аудита. + +## Финальные коммиты этого аудита + +- `feat(auth)`: `/connect/token` отказывает в login orphan AppUser-у (нет org / архивная org); `SuperAdmin` обходит проверку. Файлы: `AuthorizationController.cs`. +- `fix(super-admin)`: DELETE Organization деактивирует связанных AppUser, обнуляет `OrganizationId`, revoke всех refresh/access OpenIddict-токенов. Файлы: `SuperAdminOrganizationsController.cs`. +- `feat(employees)`: DELETE — soft (IsActive=false, FiredAt) + 403 для self-delete + 403 для удаления Owner (`org.AccountOwnerUserId == employee.UserId`). Файлы: `EmployeesController.cs`. +- `feat(api)`: `/api/me` возвращает `hasLiveOrg` и `hasActiveEmployee` для frontend-fallback'а. +- `feat(web)`: `/no-organization` страница + `TenantRouteGuard` редиректит туда orphan'а (не SuperAdmin без живой org / без активного Employee). Файлы: `App.tsx`, `pages/NoOrganizationPage.tsx`, `components/TenantRouteGuard.tsx`. +- `fix(web)`: `clearTokens()` чистит `superAdminAsOrg` и `superAdminEditMode`; `login()` чистит токены перед запросом; `SuperAdminAsOrgBanner` рендерится только для SuperAdmin. Файлы: `lib/auth.ts`, `lib/api.ts`, `components/SuperAdminAsOrgBanner.tsx`. +- `chore(recovery)`: `deploy/recovery-restore-orphan-owners.sql` — деактивирует orphan AppUser, revoke токены. Применён на стейдже. + +### Smoke после фикса +- `nurnetps@gmail.com` → POST /connect/token → `invalid_grant` «Неверный логин или пароль». +- `admin@food-market.local` (SuperAdmin) → login проходит. +- Публичный сайт + админка отдают 200. +- В БД: `users.IsActive=false`, 9 OpenIddict tokens у nurnetps теперь `revoked`. + +## Не сделано в рамках аудита (отдельные задачи) + +- Серверный middleware tenant-guard (двойная проверка активного Employee на каждом запросе) — текущая защита через `/connect/token` + frontend-redirect закрывает основной вектор; middleware желателен на отдельный коммит. +- Account page (Settings → Аккаунт + смена пароля + удаление аккаунта + покинуть org). +- Transfer-owner UI с модалом передачи прав. +- Onboarding sticky-баннер на шагах. +- Убран `Employee.IsOwner` поле — используем существующий `Organization.AccountOwnerUserId`. + +Эти задачи описаны в task-листе и могут быть реализованы отдельной серией коммитов после того как юзер просмотрит результаты текущего аудита. diff --git a/src/food-market.api/Controllers/AuthorizationController.cs b/src/food-market.api/Controllers/AuthorizationController.cs index 24494b3..bab91df 100644 --- a/src/food-market.api/Controllers/AuthorizationController.cs +++ b/src/food-market.api/Controllers/AuthorizationController.cs @@ -2,10 +2,12 @@ using System.Security.Claims; using foodmarket.Api.Infrastructure.Tenancy; using foodmarket.Infrastructure.Identity; +using foodmarket.Infrastructure.Persistence; using Microsoft.AspNetCore; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; using OpenIddict.Abstractions; using OpenIddict.Server.AspNetCore; using static OpenIddict.Abstractions.OpenIddictConstants; @@ -17,11 +19,13 @@ public class AuthorizationController : ControllerBase { private readonly SignInManager _signInManager; private readonly UserManager _userManager; + private readonly AppDbContext _db; - public AuthorizationController(SignInManager signInManager, UserManager userManager) + public AuthorizationController(SignInManager signInManager, UserManager userManager, AppDbContext db) { _signInManager = signInManager; _userManager = userManager; + _db = db; } [HttpPost("~/connect/token"), Produces("application/json")] @@ -44,6 +48,9 @@ public async Task Exchange() return BadRequestError(Errors.InvalidGrant, "Неверный логин или пароль."); } + var rejection = await CheckUserStillBelongsToLiveOrgAsync(user); + if (rejection is not null) return rejection; + user.LastLoginAt = DateTime.UtcNow; await _userManager.UpdateAsync(user); @@ -60,6 +67,9 @@ public async Task Exchange() return BadRequestError(Errors.InvalidGrant, "Аккаунт недоступен."); } + var rejection = await CheckUserStillBelongsToLiveOrgAsync(user); + if (rejection is not null) return rejection; + var principal = await CreatePrincipal(user, request.GetScopes()); return SignIn(principal, OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); } @@ -67,6 +77,31 @@ public async Task Exchange() return BadRequestError(Errors.UnsupportedGrantType, "Этот grant type не поддерживается."); } + /// SuperAdmin-у org не нужна — он работает на уровне платформы. + /// Любой другой пользователь обязан указывать на живую (не архивную) + /// Organization. Если связь оборвана (org удалена / архивирована) — + /// reject входа с понятным сообщением, чтобы юзер не попадал в админку + /// с orphan-claim'ом. + private async Task CheckUserStillBelongsToLiveOrgAsync(User user) + { + var roles = await _userManager.GetRolesAsync(user); + if (roles.Contains(HttpContextTenantContext.SuperAdminRole)) return null; + + if (user.OrganizationId is null) + { + return BadRequestError(Errors.InvalidGrant, + "У аккаунта нет привязки к организации. Обратитесь к администратору."); + } + var orgAlive = await _db.Organizations.IgnoreQueryFilters() + .AnyAsync(o => o.Id == user.OrganizationId.Value && !o.IsArchived); + if (!orgAlive) + { + return BadRequestError(Errors.InvalidGrant, + "Организация удалена или архивирована. Обратитесь к владельцу."); + } + return null; + } + private async Task CreatePrincipal(User user, ImmutableArray scopes) { var identity = new ClaimsIdentity( diff --git a/src/food-market.api/Controllers/Organizations/EmployeesController.cs b/src/food-market.api/Controllers/Organizations/EmployeesController.cs index 75ece46..e7e4969 100644 --- a/src/food-market.api/Controllers/Organizations/EmployeesController.cs +++ b/src/food-market.api/Controllers/Organizations/EmployeesController.cs @@ -1,9 +1,11 @@ +using System.Security.Claims; using foodmarket.Application.Common; using foodmarket.Application.Common.Tenancy; using foodmarket.Domain.Organizations; using foodmarket.Infrastructure.Identity; using foodmarket.Infrastructure.Persistence; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -174,11 +176,43 @@ public async Task Delete(Guid id, CancellationToken ct) { var e = await _db.Employees.FirstOrDefaultAsync(x => x.Id == id, ct); if (e is null) return NotFound(); - _db.Employees.Remove(e); + + // Soft-delete c гардами: владельца — нельзя удалить, себя — нельзя удалить. + // Если кому-то критично hard-delete (cleanup при удалении org) — это идёт + // через SuperAdmin консоль, а не через этот эндпоинт. + var currentUserId = ParseUserId(User.FindFirst(ClaimTypes.NameIdentifier)?.Value + ?? User.FindFirst("sub")?.Value); + if (currentUserId is not null && e.UserId == currentUserId) + { + return StatusCode(StatusCodes.Status403Forbidden, new + { + error = "Нельзя удалить себя. Покинуть организацию можно через Настройки → Аккаунт → Покинуть организацию.", + }); + } + + if (e.UserId is not null) + { + var ownerUserId = await _db.Organizations.IgnoreQueryFilters() + .Where(o => o.Id == e.OrganizationId) + .Select(o => o.AccountOwnerUserId) + .FirstOrDefaultAsync(ct); + if (ownerUserId == e.UserId) + { + return StatusCode(StatusCodes.Status403Forbidden, new + { + error = "Нельзя удалить владельца организации. Сначала передайте права владельца другому сотруднику.", + }); + } + } + + e.IsActive = false; + e.FiredAt ??= DateTime.UtcNow; await _db.SaveChangesAsync(ct); return NoContent(); } + private static Guid? ParseUserId(string? raw) => Guid.TryParse(raw, out var g) ? g : null; + private async Task ProjectAsync(Guid id, CancellationToken ct) { return await _db.Employees.AsNoTracking() diff --git a/src/food-market.api/Controllers/SuperAdmin/SuperAdminOrganizationsController.cs b/src/food-market.api/Controllers/SuperAdmin/SuperAdminOrganizationsController.cs index 089fdd2..896cd3f 100644 --- a/src/food-market.api/Controllers/SuperAdmin/SuperAdminOrganizationsController.cs +++ b/src/food-market.api/Controllers/SuperAdmin/SuperAdminOrganizationsController.cs @@ -197,6 +197,24 @@ public async Task Delete(Guid id, [FromBody] DeleteRequest req, C if (req.ConfirmationName != o.Name) return BadRequest(new { error = "Введи название организации точно." }); await LogAsync("DeleteOrg", o.Id, $"Удалена навсегда «{o.Name}»", null, $"{{\"name\":\"{o.Name}\"}}", ct); + + // Деактивируем всех AppUser привязанных к этой org (orphan reference после Remove). + // Чистим OpenIddict tokens — иначе у юзера остаются valid refresh-tokens 30 дней. + var orphanUsers = await _db.Users.IgnoreQueryFilters() + .Where(u => u.OrganizationId == o.Id).ToListAsync(ct); + foreach (var u in orphanUsers) + { + u.IsActive = false; + u.OrganizationId = null; + } + var userIds = orphanUsers.Select(u => u.Id.ToString()).ToList(); + if (userIds.Count > 0) + { + await _db.Database.ExecuteSqlRawAsync( + "UPDATE \"OpenIddictTokens\" SET \"Status\" = 'revoked' WHERE \"Subject\" = ANY({0})", + new object[] { userIds.ToArray() }); + } + // Cascade delete domain entities is up to FK config; здесь просто Remove, // EF выкинет ошибку если есть restrict-связи — оператор увидит и решит. _db.Organizations.Remove(o); diff --git a/src/food-market.api/Program.cs b/src/food-market.api/Program.cs index b27b5db..58a8843 100644 --- a/src/food-market.api/Program.cs +++ b/src/food-market.api/Program.cs @@ -195,16 +195,38 @@ }); }).RequireAuthorization(); - app.MapGet("/api/me", (HttpContext ctx) => + app.MapGet("/api/me", async (HttpContext ctx, AppDbContext db) => { var user = ctx.User; + var roles = user.FindAll(Claims.Role).Select(c => c.Value).ToList(); + var orgIdRaw = user.FindFirst(HttpContextTenantContext.OrganizationClaim)?.Value; + var subRaw = user.FindFirst(Claims.Subject)?.Value; + + // Дополнительные сигналы, чтобы фронт точно знал в каком состоянии юзер + // находится: orphan AppUser без живой org или без активного Employee + // получает /no-organization fallback вместо белого экрана на /dashboard. + bool hasLiveOrg = false; + bool hasActiveEmployee = false; + if (Guid.TryParse(orgIdRaw, out var orgId)) + { + hasLiveOrg = await db.Organizations.IgnoreQueryFilters() + .AnyAsync(o => o.Id == orgId && !o.IsArchived); + if (Guid.TryParse(subRaw, out var sub)) + { + hasActiveEmployee = await db.Employees.IgnoreQueryFilters() + .AnyAsync(e => e.OrganizationId == orgId && e.UserId == sub && e.IsActive); + } + } + return Results.Ok(new { - sub = user.FindFirst(Claims.Subject)?.Value, + sub = subRaw, name = user.Identity?.Name, email = user.FindFirst(Claims.Email)?.Value, - roles = user.FindAll(Claims.Role).Select(c => c.Value), - orgId = user.FindFirst(HttpContextTenantContext.OrganizationClaim)?.Value, + roles, + orgId = orgIdRaw, + hasLiveOrg, + hasActiveEmployee, }); }).RequireAuthorization(); diff --git a/src/food-market.web/src/App.tsx b/src/food-market.web/src/App.tsx index b74255c..2562825 100644 --- a/src/food-market.web/src/App.tsx +++ b/src/food-market.web/src/App.tsx @@ -33,6 +33,7 @@ import { AppLayout } from '@/components/AppLayout' import { SuperAdminLayout } from '@/components/SuperAdminLayout' import { TenantRouteGuard } from '@/components/TenantRouteGuard' import { ProtectedRoute } from '@/components/ProtectedRoute' +import { NoOrganizationPage } from '@/pages/NoOrganizationPage' const queryClient = new QueryClient({ defaultOptions: { @@ -51,6 +52,10 @@ export default function App() { } /> } /> }> + {/* Fallback для orphan AppUser без активной org / Employee. + * Без layout'а — full-screen, оттуда CTA на /signup или mailto. */} + } /> + {/* SuperAdmin консоль — отдельный layout c индиго-сайдбаром, * системными разделами и быстрым «Открыть организацию» в topbar. * Setup wizard вне layout'а — full-screen onboarding. */} diff --git a/src/food-market.web/src/components/SuperAdminAsOrgBanner.tsx b/src/food-market.web/src/components/SuperAdminAsOrgBanner.tsx index 38829e9..3fe4d2b 100644 --- a/src/food-market.web/src/components/SuperAdminAsOrgBanner.tsx +++ b/src/food-market.web/src/components/SuperAdminAsOrgBanner.tsx @@ -1,18 +1,34 @@ -import { useState } from 'react' +import { useEffect, useState } from 'react' import { ShieldAlert, X, Edit3, Lock } from 'lucide-react' import { getOrgOverride, setOrgOverride, getEditMode, enableEditMode, disableEditMode } from '@/lib/api' +import { useMe } from '@/lib/useMe' /** Полоса сверху страницы, видна только когда SuperAdmin вошёл в режим * «открыть как…» — все запросы летят с X-Org-Override, мутации заблокированы * на стороне API (ReadonlyOverrideMiddleware). По умолчанию read-only; * Phase 3: можно включить edit-mode на 30 минут с указанием причины — все - * мутации в этом режиме пишутся в SuperAdminAuditLog. */ + * мутации в этом режиме пишутся в SuperAdminAuditLog. + * + * Защита от orphan-override: если в localStorage остался ключ + * superAdminAsOrg от прошлого SuperAdmin-сеанса, а текущий юзер не + * SuperAdmin — баннер не рендерим и сам override чистим из localStorage. */ export function SuperAdminAsOrgBanner() { + const me = useMe() const ov = getOrgOverride() const edit = getEditMode() const [askReason, setAskReason] = useState(false) const [reason, setReason] = useState('') - if (!ov) return null + const isSuper = !!me.data?.roles?.includes('SuperAdmin') + + useEffect(() => { + if (!me.data) return + if (!isSuper && ov) { + // Юзер не SuperAdmin, но lingering override остался — чистим тихо. + setOrgOverride(null) + } + }, [isSuper, ov, me.data]) + + if (!ov || !isSuper) return null const baseColor = edit ? 'bg-red-600' : 'bg-amber-500' const minutesLeft = edit ? Math.max(1, Math.round((edit.expiresAt - Date.now()) / 60000)) : 0 diff --git a/src/food-market.web/src/components/TenantRouteGuard.tsx b/src/food-market.web/src/components/TenantRouteGuard.tsx index 9a775c3..901516c 100644 --- a/src/food-market.web/src/components/TenantRouteGuard.tsx +++ b/src/food-market.web/src/components/TenantRouteGuard.tsx @@ -3,13 +3,16 @@ import { useNavigate } from 'react-router-dom' import { useMe } from '@/lib/useMe' import { getOrgOverride } from '@/lib/api' -/** Не пускает SuperAdmin'а в tenant-роуты без активного override. - * Защита от race: между window.location.assign('/dashboard') и тем - * моментом когда сюда докатилось me.data — localStorage уже точно - * установлен. На всякий случай делаем повторную проверку через rAF - * перед редиректом — раньше были репорты что после Phase 2c сценарий - * клика «Открыть как» снова приводит к alert'у, оказалось это был - * цикл редиректа в SuperAdminLayout (убран отдельным фиксом). */ +/** Защита tenant-роутов на двух уровнях: + * - SuperAdmin без активного override → /super-admin/organizations. + * - Не-SuperAdmin без живой org или без активного Employee → /no-organization. + * Это срабатывает для orphan AppUser (его org удалили или его Employee + * деактивировали) — иначе он бы видел AppLayout с пустым сайдбаром + * или 403-ошибкой на каждом запросе. + * + * Защита от race: между window.location.assign('/dashboard') и тем моментом + * когда сюда докатилось me.data — localStorage уже точно установлен. + * Повторная проверка через rAF страхует от гонок инициализации. */ export function TenantRouteGuard({ children }: { children: React.ReactNode }) { const me = useMe() const navigate = useNavigate() @@ -17,20 +20,25 @@ export function TenantRouteGuard({ children }: { children: React.ReactNode }) { useEffect(() => { if (!me.data) return const isSuper = me.data.roles?.includes('SuperAdmin') - if (!isSuper) return - // Re-read через rAF чтобы перебить любые гонки с инициализацией localStorage. - requestAnimationFrame(() => { - const ov = getOrgOverride() - if (!ov) { - if (checkedOnceRef.current) return - checkedOnceRef.current = true - // eslint-disable-next-line no-console - console.warn('[TenantRouteGuard] SuperAdmin без override → redirect /super-admin/organizations', - { hasLocalStorage: !!localStorage.getItem('superAdminAsOrg') }) - try { sessionStorage.setItem('tenant-guard-msg', 'Откройте конкретную организацию через "Открыть как…"') } catch { /* ignore */ } - navigate('/super-admin/organizations', { replace: true }) - } - }) + if (isSuper) { + // Re-read через rAF чтобы перебить любые гонки с инициализацией localStorage. + requestAnimationFrame(() => { + const ov = getOrgOverride() + if (!ov) { + if (checkedOnceRef.current) return + checkedOnceRef.current = true + // eslint-disable-next-line no-console + console.warn('[TenantRouteGuard] SuperAdmin без override → redirect /super-admin/organizations', + { hasLocalStorage: !!localStorage.getItem('superAdminAsOrg') }) + try { sessionStorage.setItem('tenant-guard-msg', 'Откройте конкретную организацию через "Открыть как…"') } catch { /* ignore */ } + navigate('/super-admin/organizations', { replace: true }) + } + }) + return + } + if (me.data.hasLiveOrg === false || me.data.hasActiveEmployee === false) { + navigate('/no-organization', { replace: true }) + } }, [me.data, navigate]) return <>{children} } diff --git a/src/food-market.web/src/lib/auth.ts b/src/food-market.web/src/lib/auth.ts index 727cda9..dc160f1 100644 --- a/src/food-market.web/src/lib/auth.ts +++ b/src/food-market.web/src/lib/auth.ts @@ -1,6 +1,11 @@ const ACCESS = 'fm.access_token' const REFRESH = 'fm.refresh_token' const CLIENT_ID = 'food-market-web' +// SuperAdmin override живёт в localStorage и не сбрасывается при logout — +// иначе после login другого юзера orphan-override провоцирует "Я в режиме +// супер-админа" под обычным аккаунтом. Чистим явно при login/logout. +const ORG_OVERRIDE = 'superAdminAsOrg' +const EDIT_MODE = 'superAdminEditMode' export interface TokenResponse { access_token: string @@ -28,9 +33,15 @@ export function storeTokens(tokens: TokenResponse) { export function clearTokens() { localStorage.removeItem(ACCESS) localStorage.removeItem(REFRESH) + localStorage.removeItem(ORG_OVERRIDE) + localStorage.removeItem(EDIT_MODE) } export async function login(username: string, password: string): Promise { + // Перед login сбрасываем все клиентские флаги предыдущей сессии — иначе + // Override и edit-mode прошлого SuperAdmin'а останутся активными для + // новoго юзера и он окажется в "режиме супер-админа" без на то прав. + clearTokens() const body = new URLSearchParams({ grant_type: 'password', username, diff --git a/src/food-market.web/src/lib/useMe.ts b/src/food-market.web/src/lib/useMe.ts index 8c54760..6a0f10d 100644 --- a/src/food-market.web/src/lib/useMe.ts +++ b/src/food-market.web/src/lib/useMe.ts @@ -6,7 +6,12 @@ export interface MeResponse { name: string email: string roles: string[] - orgId: string + orgId: string | null + /** AppUser.OrganizationId ссылается на живую (не архивную) Organization. + * Если false для не-SuperAdmin — UI редиректит на /no-organization. */ + hasLiveOrg: boolean + /** Активный Employee текущего AppUser в его Organization. False → orphan. */ + hasActiveEmployee: boolean } /** Текущий залогиненный юзер с ролями. Используется для гейтов diff --git a/src/food-market.web/src/pages/NoOrganizationPage.tsx b/src/food-market.web/src/pages/NoOrganizationPage.tsx new file mode 100644 index 0000000..822475a --- /dev/null +++ b/src/food-market.web/src/pages/NoOrganizationPage.tsx @@ -0,0 +1,51 @@ +import { Logo } from '@/components/Logo' +import { logout } from '@/lib/auth' +import { useMe } from '@/lib/useMe' + +const PUBLIC_SITE = (import.meta.env.VITE_PUBLIC_SITE_URL as string | undefined) ?? 'https://food-market.zat.kz' + +/** Fallback-экран для AppUser без активного Employee в живой Organization. + * Возможные причины: его org удалена SuperAdmin'ом, его Employee + * деактивирован (уволен), либо аккаунт никогда не был привязан к орге. + * + * Не делаем тут wizard «создай новую org» — публичный /signup на маркетинговом + * сайте уже умеет создавать org для существующего email через тот же + * AuthSignupController. Просто отправляем туда. */ +export function NoOrganizationPage() { + const me = useMe() + const email = me.data?.email + return ( +
+
+ +
+

У вас нет активных организаций

+

+ Аккаунт {email ? {email} : null} не связан ни с одной активной организацией. + Это происходит если организация удалена или администратор отозвал ваш доступ. +

+
+ + +
+
+ ) +}