feat(super-admin): Phase 2 — read-only «открыть как…» context switch
Some checks failed
CI / POS (WPF, Windows) (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions
CI / Backend (.NET 8) (push) Successful in 1m7s
CI / Web (React + Vite) (push) Successful in 39s
Docker API / Build + push API (push) Successful in 58s
Docker Web / Build + push Web (push) Successful in 28s
Docker API / Deploy API on stage (push) Has been cancelled
Some checks failed
CI / POS (WPF, Windows) (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions
CI / Backend (.NET 8) (push) Successful in 1m7s
CI / Web (React + Vite) (push) Successful in 39s
Docker API / Build + push API (push) Successful in 58s
Docker Web / Build + push Web (push) Successful in 28s
Docker API / Deploy API on stage (push) Has been cancelled
SuperAdmin может зайти в данные конкретной орги в режиме просмотра
(аналог «view as customer» в SaaS). Все запросы летят с tenant'ом
выбранной орги, но любая мутация запрещена — Phase 3 (edit-mode +
audit-trail) будет ослаблять ограничение по reason'у.
API:
- HttpContextTenantContext.OrganizationId: если у юзера роль SuperAdmin
И header X-Org-Override присутствует — возвращаем его как tenant
(вместо org_id из JWT).
- ReadonlyOverrideMiddleware (после UseAuthorization): когда заголовок
активен, отбивает 403 любую non-GET операцию, кроме /api/super-admin/*
(управление орг) и /connect/* (refresh tokens).
- Безопасность: проверка SuperAdmin-роли — без неё header игнорируется,
обычный юзер ничего подменить не может.
Web:
- api.ts: localStorage 'superAdminAsOrg' = {id, name}; axios interceptor
добавляет X-Org-Override на каждый запрос. setOrgOverride(...) делает
hard reload чтобы сбросить TanStack Query-кэш.
- SuperAdminAsOrgBanner — жёлтая полоса сверху main-area с названием
орги и кнопкой «Выйти». Подключена в AppLayout перед <Outlet/>.
- В таблице /super-admin/organizations добавлена кнопка LogIn (синяя)
в actions; клик → setOrgOverride → reload в режим просмотра.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4acb51c270
commit
ef32dac0a6
|
|
@ -7,6 +7,10 @@ public class HttpContextTenantContext : ITenantContext
|
|||
{
|
||||
public const string OrganizationClaim = "org_id";
|
||||
public const string SuperAdminRole = "SuperAdmin";
|
||||
/// <summary>HTTP-заголовок переключения tenant'а для SuperAdmin'а: «открыть как…»
|
||||
/// конкретную организацию. Без этого header'а супер-админ не привязан к
|
||||
/// конкретной орге (видит всё через query-filter bypass).</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
using foodmarket.Application.Common.Tenancy;
|
||||
|
||||
namespace foodmarket.Api.Infrastructure.Tenancy;
|
||||
|
||||
/// <summary>Когда 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) будет ослаблять ограничение.</summary>
|
||||
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).",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -150,6 +150,9 @@
|
|||
app.UseCors(CorsPolicy);
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
// SuperAdmin «открыть как…» — тот же tenant как у выбранной орги, но
|
||||
// только GET. Любая мутация → 403, кроме /api/super-admin/* и /connect/*.
|
||||
app.UseMiddleware<foodmarket.Api.Infrastructure.Tenancy.ReadonlyOverrideMiddleware>();
|
||||
|
||||
// Статика товарных изображений: физически /app/uploads (volume в compose),
|
||||
// публичный URL /uploads/... — раздаются public, без auth.
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
)}
|
||||
|
||||
<main className="flex-1 min-w-0 min-h-0 flex flex-col overflow-hidden">
|
||||
<SuperAdminAsOrgBanner />
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
|
|
|||
26
src/food-market.web/src/components/SuperAdminAsOrgBanner.tsx
Normal file
26
src/food-market.web/src/components/SuperAdminAsOrgBanner.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="bg-amber-500 text-white text-sm flex items-center justify-between gap-3 px-4 py-2 shadow">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<ShieldAlert className="w-4 h-4 flex-shrink-0" />
|
||||
<span className="truncate">
|
||||
🛡️ ВЫ В РЕЖИМЕ СУПЕР-АДМИНА — ОРГАНИЗАЦИЯ <strong>«{ov.name}»</strong> — ТОЛЬКО ПРОСМОТР
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setOrgOverride(null)}
|
||||
className="flex items-center gap-1 px-2 py-0.5 rounded bg-white/20 hover:bg-white/30 flex-shrink-0"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" /> Выйти
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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<string | null> | null = null
|
||||
|
||||
api.interceptors.response.use(
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
: <span className="text-xs text-emerald-600">Активна</span> },
|
||||
{ header: '', width: '160px', cell: (r) => (
|
||||
<div className="flex gap-1.5" onClick={(e) => e.stopPropagation()}>
|
||||
<button title="Просмотр" disabled className="p-1.5 text-slate-300 cursor-not-allowed"><Eye className="w-4 h-4" /></button>
|
||||
{!r.isArchived && (
|
||||
<button title="Открыть как… (read-only)"
|
||||
onClick={() => setOrgOverride({ id: r.id, name: r.name })}
|
||||
className="p-1.5 text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded">
|
||||
<LogIn className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
{!r.isArchived ? (
|
||||
<button title="Архивировать" onClick={() => { setArchiveOf(r); setConfirmName('') }}
|
||||
className="p-1.5 text-amber-600 hover:bg-amber-50 dark:hover:bg-amber-900/20 rounded">
|
||||
|
|
|
|||
Loading…
Reference in a new issue