diff --git a/src/food-market.api/Infrastructure/Tenancy/HttpContextTenantContext.cs b/src/food-market.api/Infrastructure/Tenancy/HttpContextTenantContext.cs index 6380156..c5cbd86 100644 --- a/src/food-market.api/Infrastructure/Tenancy/HttpContextTenantContext.cs +++ b/src/food-market.api/Infrastructure/Tenancy/HttpContextTenantContext.cs @@ -7,6 +7,10 @@ public class HttpContextTenantContext : ITenantContext { public const string OrganizationClaim = "org_id"; public const string SuperAdminRole = "SuperAdmin"; + /// HTTP-заголовок переключения tenant'а для SuperAdmin'а: «открыть как…» + /// конкретную организацию. Без этого header'а супер-админ не привязан к + /// конкретной орге (видит всё через query-filter bypass). + public const string OrgOverrideHeader = "X-Org-Override"; // Override для background задач (например, импорт из MoySklad): сохраняем tenant // в AsyncLocal на время выполнения фонового Task. HttpContext там отсутствует, @@ -54,7 +58,17 @@ public bool IsSuperAdmin get { if (_override.Value is { OrgId: var o }) return o; - var claim = _accessor.HttpContext?.User?.FindFirst(OrganizationClaim)?.Value; + var ctx = _accessor.HttpContext; + // SuperAdmin может прислать X-Org-Override чтобы посмотреть данные + // конкретной организации в режиме «открыть как…». Блокируем мутации + // через ReadonlyOverrideMiddleware — здесь только подменяем tenant. + if (ctx is not null && ctx.User?.IsInRole(SuperAdminRole) == true + && ctx.Request.Headers.TryGetValue(OrgOverrideHeader, out var headerVal) + && Guid.TryParse(headerVal.ToString(), out var override_)) + { + return override_; + } + var claim = ctx?.User?.FindFirst(OrganizationClaim)?.Value; return Guid.TryParse(claim, out var id) ? id : null; } } diff --git a/src/food-market.api/Infrastructure/Tenancy/ReadonlyOverrideMiddleware.cs b/src/food-market.api/Infrastructure/Tenancy/ReadonlyOverrideMiddleware.cs new file mode 100644 index 0000000..cff3e22 --- /dev/null +++ b/src/food-market.api/Infrastructure/Tenancy/ReadonlyOverrideMiddleware.cs @@ -0,0 +1,38 @@ +using foodmarket.Application.Common.Tenancy; + +namespace foodmarket.Api.Infrastructure.Tenancy; + +/// Когда SuperAdmin прислал X-Org-Override — режим «открыть как…» +/// должен быть строго read-only (Phase 2). Любая мутация (PUT/POST/DELETE/PATCH) +/// на любой endpoint, кроме самих /api/super-admin/* (управление орг) +/// и /api/auth/* (refresh tokens) — отбивается 403 с понятным сообщением. +/// Phase 3 (edit-mode с reason + audit-trail) будет ослаблять ограничение. +public class ReadonlyOverrideMiddleware +{ + private readonly RequestDelegate _next; + public ReadonlyOverrideMiddleware(RequestDelegate next) => _next = next; + + public async Task InvokeAsync(HttpContext ctx) + { + if (!ctx.Request.Headers.ContainsKey(HttpContextTenantContext.OrgOverrideHeader)) + { + await _next(ctx); return; + } + var method = ctx.Request.Method.ToUpperInvariant(); + if (method == "GET" || method == "HEAD" || method == "OPTIONS") + { + await _next(ctx); return; + } + var path = ctx.Request.Path.Value ?? ""; + if (path.StartsWith("/api/super-admin/", StringComparison.OrdinalIgnoreCase) + || path.StartsWith("/connect/", StringComparison.OrdinalIgnoreCase)) + { + await _next(ctx); return; + } + ctx.Response.StatusCode = StatusCodes.Status403Forbidden; + await ctx.Response.WriteAsJsonAsync(new + { + error = "Read-only mode: вы в режиме «Супер-админ → открыть как…», мутации запрещены. Чтобы редактировать — выйдите из режима или включите edit-mode (Phase 3).", + }); + } +} diff --git a/src/food-market.api/Program.cs b/src/food-market.api/Program.cs index e1431f4..9885a30 100644 --- a/src/food-market.api/Program.cs +++ b/src/food-market.api/Program.cs @@ -150,6 +150,9 @@ app.UseCors(CorsPolicy); app.UseAuthentication(); app.UseAuthorization(); + // SuperAdmin «открыть как…» — тот же tenant как у выбранной орги, но + // только GET. Любая мутация → 403, кроме /api/super-admin/* и /connect/*. + app.UseMiddleware(); // Статика товарных изображений: физически /app/uploads (volume в compose), // публичный URL /uploads/... — раздаются public, без auth. diff --git a/src/food-market.web/src/components/AppLayout.tsx b/src/food-market.web/src/components/AppLayout.tsx index 7122b29..533d67c 100644 --- a/src/food-market.web/src/components/AppLayout.tsx +++ b/src/food-market.web/src/components/AppLayout.tsx @@ -10,6 +10,7 @@ import { Boxes, History, TruckIcon, ShoppingCart, Settings, Menu, X, } from 'lucide-react' import { Logo } from './Logo' +import { SuperAdminAsOrgBanner } from './SuperAdminAsOrgBanner' interface MeResponse { sub: string @@ -184,6 +185,7 @@ export function AppLayout() { )}
+
diff --git a/src/food-market.web/src/components/SuperAdminAsOrgBanner.tsx b/src/food-market.web/src/components/SuperAdminAsOrgBanner.tsx new file mode 100644 index 0000000..3c48d5a --- /dev/null +++ b/src/food-market.web/src/components/SuperAdminAsOrgBanner.tsx @@ -0,0 +1,26 @@ +import { ShieldAlert, X } from 'lucide-react' +import { getOrgOverride, setOrgOverride } from '@/lib/api' + +/** Полоса сверху страницы, видна только когда SuperAdmin вошёл в режим + * «открыть как…» — все запросы летят с X-Org-Override, мутации заблокированы + * на стороне API (ReadonlyOverrideMiddleware). Кнопка X — выход из режима. */ +export function SuperAdminAsOrgBanner() { + const ov = getOrgOverride() + if (!ov) return null + return ( +
+
+ + + 🛡️ ВЫ В РЕЖИМЕ СУПЕР-АДМИНА — ОРГАНИЗАЦИЯ «{ov.name}» — ТОЛЬКО ПРОСМОТР + +
+ +
+ ) +} diff --git a/src/food-market.web/src/lib/api.ts b/src/food-market.web/src/lib/api.ts index 332adf5..d47d4bf 100644 --- a/src/food-market.web/src/lib/api.ts +++ b/src/food-market.web/src/lib/api.ts @@ -11,9 +11,31 @@ api.interceptors.request.use((config: InternalAxiosRequestConfig) => { if (token) { config.headers.set('Authorization', `Bearer ${token}`) } + // SuperAdmin «открыть как…»: добавляем X-Org-Override на каждый запрос если + // в localStorage активна выбранная орга (и эндпоинт не сам super-admin). + const asOrg = getOrgOverride() + if (asOrg && !(config.url ?? '').startsWith('/api/super-admin/')) { + config.headers.set('X-Org-Override', asOrg.id) + } return config }) +const ORG_OVERRIDE_KEY = 'superAdminAsOrg' +export interface OrgOverride { id: string; name: string } +export function getOrgOverride(): OrgOverride | null { + try { + const raw = localStorage.getItem(ORG_OVERRIDE_KEY) + return raw ? JSON.parse(raw) as OrgOverride : null + } catch { return null } +} +export function setOrgOverride(value: OrgOverride | null) { + if (value) localStorage.setItem(ORG_OVERRIDE_KEY, JSON.stringify(value)) + else localStorage.removeItem(ORG_OVERRIDE_KEY) + // Силой обновляем все вкладки/страницы — кэш TanStack Query построен по + // tenant'у, нужен hard reload чтобы снять старые данные. + if (typeof window !== 'undefined') window.location.reload() +} + let refreshing: Promise | null = null api.interceptors.response.use( diff --git a/src/food-market.web/src/pages/SuperAdminOrganizationsPage.tsx b/src/food-market.web/src/pages/SuperAdminOrganizationsPage.tsx index 29080ca..a9d5ee5 100644 --- a/src/food-market.web/src/pages/SuperAdminOrganizationsPage.tsx +++ b/src/food-market.web/src/pages/SuperAdminOrganizationsPage.tsx @@ -1,6 +1,7 @@ import { useState } from 'react' import { useNavigate } from 'react-router-dom' -import { Plus, Archive, RotateCcw, Trash2, Eye } from 'lucide-react' +import { Plus, Archive, RotateCcw, Trash2, LogIn } from 'lucide-react' +import { setOrgOverride } from '@/lib/api' import { ListPageShell } from '@/components/ListPageShell' import { DataTable } from '@/components/DataTable' import { Pagination } from '@/components/Pagination' @@ -97,7 +98,13 @@ export function SuperAdminOrganizationsPage() { : Активна }, { header: '', width: '160px', cell: (r) => (
e.stopPropagation()}> - + {!r.isArchived && ( + + )} {!r.isArchived ? (