From 7a0b300a400fef8296a8dfca83b40a0ff6b247b4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 05:11:20 +0000 Subject: [PATCH] feat(app-shell): "why can this user access?" explain panel in the Studio Access pillar (ADR-0090 D6, framework#2696) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Right-side sheet opened from the Access pillar header: pick a user (defaults to the calling principal), an object and an operation; calls the new backend POST /api/v1/security/explain and renders the ExplainDecision trace — allowed/denied verdict banner, resolved principal chain (positions → permission sets with via attribution), the nine evaluation-pipeline layers with per-verdict badges, and the composed row filter for reads. A 403 from the manage_users / delegated-admin-scope gate (D12) renders as a friendly localized message. EN + ZH copy in the metadata-admin string tables. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WTubNprerCmJoU1VtUWA93 --- .changeset/access-explain-panel.md | 18 + .../AccessExplainPanel.test.tsx | 126 ++++++ .../metadata-admin/AccessExplainPanel.tsx | 388 ++++++++++++++++++ .../src/views/metadata-admin/i18n.ts | 100 +++++ .../studio-design/StudioDesignSurface.tsx | 16 +- 5 files changed, 647 insertions(+), 1 deletion(-) create mode 100644 .changeset/access-explain-panel.md create mode 100644 packages/app-shell/src/views/metadata-admin/AccessExplainPanel.test.tsx create mode 100644 packages/app-shell/src/views/metadata-admin/AccessExplainPanel.tsx diff --git a/.changeset/access-explain-panel.md b/.changeset/access-explain-panel.md new file mode 100644 index 0000000000..9a6d379124 --- /dev/null +++ b/.changeset/access-explain-panel.md @@ -0,0 +1,18 @@ +--- +"@object-ui/app-shell": minor +--- + +ADR-0090 D6 — "why can this user access?" panel in the Studio Access pillar +(framework#2696). + +New `AccessExplainPanel` (right-side sheet, opened from the Access pillar +header next to the permission matrix): pick a user (defaults to the calling +principal), an object and an operation, and it calls the new backend +`POST /api/v1/security/explain`, rendering the `ExplainDecision` trace — the +allowed/denied verdict banner, the resolved principal chain (positions → +permission sets with their `via` attribution), all nine evaluation-pipeline +layers (required capabilities, object CRUD, FLS, OWD baseline, depth, sharing, +VAMA bypass, RLS) with per-verdict badges, and the composed row filter for +reads. A 403 from the manage_users / delegated-admin-scope gate (D12) renders +as a friendly localized message. Copy ships in EN + ZH via the metadata-admin +string tables. diff --git a/packages/app-shell/src/views/metadata-admin/AccessExplainPanel.test.tsx b/packages/app-shell/src/views/metadata-admin/AccessExplainPanel.test.tsx new file mode 100644 index 0000000000..ae2a42e332 --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/AccessExplainPanel.test.tsx @@ -0,0 +1,126 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ADR-0090 D6 — integration test for the "why can this user access?" panel. + * Drives the REAL AccessExplainPanel against a faked authenticated fetch: + * 1. Submits { object, operation } to POST /api/v1/security/explain and + * renders the ExplainDecision — verdict banner, principal chain, the + * nine pipeline layers with verdict badges, and the composed readFilter. + * 2. A 403 (manage_users / delegated-scope gate, D12) renders the friendly + * localized message instead of a raw error. + */ + +import '@testing-library/jest-dom/vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; + +const fetchSpy = vi.fn(); + +vi.mock('@object-ui/auth', () => ({ + createAuthenticatedFetch: () => fetchSpy, +})); +vi.mock('@object-ui/react', () => ({ + useAdapter: () => ({ find: vi.fn(async () => []) }), +})); +// The user picker has its own coverage; a stub keeps this suite focused on +// the explain round-trip. +vi.mock('@object-ui/fields', () => ({ + RecordPickerDialog: () => null, +})); + +import { AccessExplainPanel, type ExplainDecision } from './AccessExplainPanel'; + +afterEach(() => { + cleanup(); + fetchSpy.mockReset(); +}); + +const DECISION: ExplainDecision = { + allowed: true, + object: 'crm_lead', + operation: 'read', + principal: { + userId: 'u_1', + positions: ['sales_rep', 'everyone'], + permissionSets: ['sales_user', 'member_default'], + }, + layers: [ + { + layer: 'principal', + verdict: 'neutral', + detail: 'Principal u_1 holds position(s) [sales_rep, everyone] …', + contributors: [ + { kind: 'position', name: 'sales_rep' }, + { kind: 'permission_set', name: 'sales_user', via: 'position:sales_rep' }, + ], + }, + { layer: 'object_crud', verdict: 'grants', detail: "read on 'crm_lead' is granted by [sales_user].", contributors: [{ kind: 'permission_set', name: 'sales_user', via: 'position:sales_rep' }] }, + { layer: 'rls', verdict: 'narrows', detail: 'Row-level security narrows the row set.', contributors: [] }, + ], + readFilter: { owner_id: 'u_1' }, +}; + +const jsonResponse = (status: number, body: unknown) => ({ + ok: status >= 200 && status < 300, + status, + statusText: '', + json: async () => body, +}); + +function renderPanel() { + return render( {}} />); +} + +async function submit(object = 'crm_lead') { + fireEvent.change(screen.getByLabelText('Object'), { target: { value: object } }); + fireEvent.click(screen.getByRole('button', { name: /explain$/i })); +} + +describe('AccessExplainPanel (ADR-0090 D6)', () => { + it('posts the explain request and renders the decision trace', async () => { + fetchSpy.mockResolvedValue(jsonResponse(200, DECISION)); + renderPanel(); + await submit(); + + await waitFor(() => expect(screen.getByTestId('explain-verdict')).toBeInTheDocument()); + + // request shape: POST /api/v1/security/explain with { object, operation } + const [url, init] = fetchSpy.mock.calls[0]; + expect(String(url)).toMatch(/\/api\/v1\/security\/explain$/); + expect(init.method).toBe('POST'); + expect(JSON.parse(init.body)).toEqual({ object: 'crm_lead', operation: 'read' }); + + // verdict + principal chain + expect(screen.getByTestId('explain-verdict').textContent).toMatch(/ALLOWED/i); + expect(screen.getByText('u_1')).toBeInTheDocument(); + expect(screen.getAllByText('sales_rep').length).toBeGreaterThan(0); + expect(screen.getAllByText('sales_user').length).toBeGreaterThan(0); + + // pipeline layers render with verdict badges + expect(screen.getByTestId('explain-layer-object_crud').textContent).toMatch(/grants/i); + expect(screen.getByTestId('explain-layer-rls').textContent).toMatch(/narrows/i); + + // machine artifact — composed read filter + expect(screen.getByText(/"owner_id": "u_1"/)).toBeInTheDocument(); + }); + + it('renders a denied decision with the destructive banner', async () => { + fetchSpy.mockResolvedValue(jsonResponse(200, { ...DECISION, allowed: false, layers: [{ layer: 'object_crud', verdict: 'denies', detail: 'No resolved permission set grants read.', contributors: [] }] })); + renderPanel(); + await submit(); + + await waitFor(() => expect(screen.getByTestId('explain-verdict')).toBeInTheDocument()); + expect(screen.getByTestId('explain-verdict').textContent).toMatch(/DENIED/i); + expect(screen.getByTestId('explain-layer-object_crud').textContent).toMatch(/denies/i); + }); + + it('renders the friendly D12 message on 403 instead of a raw error', async () => { + fetchSpy.mockResolvedValue(jsonResponse(403, { code: 'PERMISSION_DENIED', message: '[Security] Access denied: …' })); + renderPanel(); + await submit(); + + await waitFor(() => expect(screen.getByRole('alert')).toBeInTheDocument()); + expect(screen.getByRole('alert').textContent).toMatch(/manage_users|delegated admin scope/i); + expect(screen.queryByTestId('explain-verdict')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/app-shell/src/views/metadata-admin/AccessExplainPanel.tsx b/packages/app-shell/src/views/metadata-admin/AccessExplainPanel.tsx new file mode 100644 index 0000000000..6d372e4c5a --- /dev/null +++ b/packages/app-shell/src/views/metadata-admin/AccessExplainPanel.tsx @@ -0,0 +1,388 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * AccessExplainPanel — "why can this user access?" (ADR-0090 D6). + * + * Right-side sheet in the Studio Access pillar that asks the backend explain + * engine (`GET/POST /api/v1/security/explain`) why a principal can — or cannot + * — perform an operation on an object, and renders the `ExplainDecision` + * trace: the resolved principal (position → permission-set binding chain) and + * the nine evaluation-pipeline layers with their verdicts (required + * capabilities, object CRUD, FLS, OWD baseline, depth, sharing, VAMA bypass, + * RLS). The report is produced by the SAME code paths enforcement runs, so + * what this panel shows is what the middleware does. + * + * Explaining ANOTHER user is authorized server-side: `manage_users` or a + * delegated adminScope covering that user (D12) — a 403 here renders as a + * friendly localized message, not a raw error. + */ + +import * as React from 'react'; +import { + Badge, + Button, + Input, + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from '@object-ui/components'; +import { RecordPickerDialog } from '@object-ui/fields'; +import { useAdapter } from '@object-ui/react'; +import { createAuthenticatedFetch } from '@object-ui/auth'; +import { + AlertTriangle, + CheckCircle2, + HelpCircle, + Loader2, + ShieldQuestion, + User as UserIcon, + X, + XCircle, +} from 'lucide-react'; +import { t, tFormat, useMetadataLocale } from './i18n'; + +/** Mirrors `ExplainOperationSchema` in `@objectstack/spec/security`. */ +const OPERATIONS = ['read', 'create', 'update', 'delete', 'transfer', 'restore', 'purge'] as const; +type ExplainOperation = (typeof OPERATIONS)[number]; + +/** Mirrors `ExplainDecisionSchema` in `@objectstack/spec/security` (ADR-0090 D6). */ +export interface ExplainLayer { + layer: + | 'principal' + | 'required_permissions' + | 'object_crud' + | 'fls' + | 'owd_baseline' + | 'depth' + | 'sharing' + | 'vama_bypass' + | 'rls'; + verdict: 'grants' | 'denies' | 'narrows' | 'widens' | 'neutral' | 'not_applicable'; + detail: string; + contributors?: Array<{ kind: 'permission_set' | 'position' | 'system'; name: string; via?: string }>; +} +export interface ExplainDecision { + allowed: boolean; + object: string; + operation: ExplainOperation; + principal: { + userId: string | null; + positions?: string[]; + permissionSets?: string[]; + principalKind?: string; + onBehalfOf?: { userId: string }; + }; + layers: ExplainLayer[]; + readFilter?: unknown; +} + +const VERDICT_BADGE: Record = { + grants: 'border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-400', + denies: 'border-destructive/40 bg-destructive/10 text-destructive', + narrows: 'border-amber-400/50 bg-amber-400/10 text-amber-700 dark:text-amber-300', + widens: 'border-sky-400/50 bg-sky-400/10 text-sky-700 dark:text-sky-300', + neutral: 'border-border bg-muted/40 text-muted-foreground', + not_applicable: 'border-border bg-transparent text-muted-foreground/60', +}; + +const personLabel = (u: unknown): string => { + const r = (u ?? {}) as Record; + return String(r.full_name || r.name || r.display_name || r.email || r.id || ''); +}; + +export interface AccessExplainPanelProps { + open: boolean; + onOpenChange: (open: boolean) => void; + /** Optional prefill for the object input (e.g. from the matrix). */ + defaultObject?: string; +} + +export function AccessExplainPanel({ open, onOpenChange, defaultObject }: AccessExplainPanelProps): React.ReactElement { + const locale = useMetadataLocale(); + const adapter = useAdapter() as any; + const authFetch = React.useMemo(() => createAuthenticatedFetch(), []); + + const [objectName, setObjectName] = React.useState(defaultObject ?? ''); + const [operation, setOperation] = React.useState('read'); + const [user, setUser] = React.useState<{ id: string; label: string } | null>(null); + const [pickerOpen, setPickerOpen] = React.useState(false); + const [busy, setBusy] = React.useState(false); + const [error, setError] = React.useState(null); + const [decision, setDecision] = React.useState(null); + + React.useEffect(() => { + if (defaultObject) setObjectName((v) => v || defaultObject); + }, [defaultObject]); + + const run = React.useCallback(async () => { + const object = objectName.trim(); + if (!object) return; + setBusy(true); + setError(null); + setDecision(null); + try { + // Split SPA + backend dev: promote `/api/...` to the backend origin so + // the request (and its auth cookie) reaches :3000 — same convention as + // MetadataTypeActions. + const apiBase = ((import.meta as { env?: Record }).env?.VITE_SERVER_URL || '').replace(/\/+$/, ''); + const res = await authFetch(`${apiBase}/api/v1/security/explain`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ object, operation, ...(user ? { userId: user.id } : {}) }), + }); + let data: Record | null = null; + try { + data = (await res.json()) as Record; + } catch { + /* non-JSON body — fall through to the status check */ + } + if (res.status === 403) { + setError(t('engine.studio.access.explain.forbidden', locale)); + return; + } + if (!res.ok) { + const detail = + (data?.message as string) || (data?.error as string) || `HTTP ${res.status} ${res.statusText}`.trim(); + setError(detail); + return; + } + setDecision(data as unknown as ExplainDecision); + } catch (err) { + setError((err as Error)?.message ?? String(err)); + } finally { + setBusy(false); + } + }, [authFetch, objectName, operation, user, locale]); + + const opLabel = (op: string) => t(`engine.studio.access.explain.op.${op}`, locale); + + return ( + + + + + {t('engine.studio.access.explain.title', locale)} + + {t('engine.studio.access.explain.description', locale)} + + + {/* ── request form ── */} +
+
+ +
+ + {user && ( + + )} +
+
+ +
+
+ + setObjectName(e.target.value)} + placeholder={t('engine.studio.access.explain.objectPlaceholder', locale)} + className="h-8 text-xs" + /> +
+
+ + +
+
+ + +
+ + {/* ── result ── */} +
+ {error && !busy && ( +
+ + + {t('engine.studio.access.explain.failed', locale)} — {error} + +
+ )} + + {!decision && !error && !busy && ( +

{t('engine.studio.access.explain.empty', locale)}

+ )} + + {decision && !busy && ( + <> + {/* verdict banner */} +
+ {decision.allowed ? : } + + {tFormat( + decision.allowed ? 'engine.studio.access.explain.allowedLine' : 'engine.studio.access.explain.deniedLine', + locale, + { operation: opLabel(decision.operation), object: decision.object }, + )} + +
+ + {/* principal: position → permission-set chain */} +
+

+ {t('engine.studio.access.explain.principal', locale)} +

+

+ {decision.principal.userId ?? '(anonymous)'} + {decision.principal.onBehalfOf?.userId ? ` ⇄ ${decision.principal.onBehalfOf.userId}` : ''} +

+
+
+ + {t('engine.studio.access.explain.positions', locale)}: + + {(decision.principal.positions ?? []).map((p) => ( + + {p} + + ))} +
+
+ + {t('engine.studio.access.explain.permissionSets', locale)}: + + {(decision.principal.permissionSets ?? []).map((p) => ( + + {p} + + ))} +
+
+
+ + {/* the nine pipeline layers */} +
+

+ {t('engine.studio.access.explain.layers', locale)} +

+
    + {decision.layers.map((l) => ( +
  1. +
    + + {t(`engine.studio.access.explain.layer.${l.layer}`, locale)} + + + {t(`engine.studio.access.explain.verdict.${l.verdict}`, locale)} + +
    +

    {l.detail}

    + {(l.contributors ?? []).length > 0 && ( +
    + + {t('engine.studio.access.explain.contributors', locale)}: + + {(l.contributors ?? []).map((c, i) => ( + + {c.name} + + ))} +
    + )} +
  2. + ))} +
+
+ + {/* machine artifact — the composed read filter */} + {decision.operation === 'read' && decision.readFilter !== undefined && ( +
+

+ {t('engine.studio.access.explain.readFilter', locale)} +

+
+                    {JSON.stringify(decision.readFilter, null, 2)}
+                  
+
+ )} + + )} +
+ + setPickerOpen(o)} + dataSource={adapter} + objectName="sys_user" + title={t('engine.studio.access.explain.pickUserTitle', locale)} + onSelect={() => {}} + onSelectRecords={(records: any[]) => { + const u = records?.[0]; + if (u?.id != null) setUser({ id: String(u.id), label: personLabel(u) }); + setPickerOpen(false); + }} + /> +
+
+ ); +} + +export default AccessExplainPanel; diff --git a/packages/app-shell/src/views/metadata-admin/i18n.ts b/packages/app-shell/src/views/metadata-admin/i18n.ts index e7a2d39d9f..39136c16dd 100644 --- a/packages/app-shell/src/views/metadata-admin/i18n.ts +++ b/packages/app-shell/src/views/metadata-admin/i18n.ts @@ -1234,6 +1234,56 @@ const ENGINE_STRINGS_EN: Record = { 'engine.studio.access.emptyMain': 'Create a permission set to start configuring', 'engine.studio.access.pick': 'Select a permission set', + // ── Access explain panel (ADR-0090 D6 — "why can this user access?") ──── + 'engine.studio.access.explain.open': 'Explain access', + 'engine.studio.access.explain.title': 'Why can this user access?', + 'engine.studio.access.explain.description': + 'Runs the same evaluation pipeline as enforcement and reports what each layer contributed — permission set → position → binding chain, OWD/sharing verdicts.', + 'engine.studio.access.explain.user': 'User', + 'engine.studio.access.explain.self': 'Me (calling principal)', + 'engine.studio.access.explain.pickUser': 'Select user…', + 'engine.studio.access.explain.clearUser': 'Reset to me', + 'engine.studio.access.explain.pickUserTitle': 'Select the user to explain', + 'engine.studio.access.explain.object': 'Object', + 'engine.studio.access.explain.objectPlaceholder': 'Object name (e.g. crm_lead)', + 'engine.studio.access.explain.operation': 'Operation', + 'engine.studio.access.explain.run': 'Explain', + 'engine.studio.access.explain.running': 'Explaining…', + 'engine.studio.access.explain.allowedLine': '{operation} on “{object}” is ALLOWED for this principal.', + 'engine.studio.access.explain.deniedLine': '{operation} on “{object}” is DENIED for this principal.', + 'engine.studio.access.explain.principal': 'Principal', + 'engine.studio.access.explain.positions': 'Positions', + 'engine.studio.access.explain.permissionSets': 'Permission sets', + 'engine.studio.access.explain.layers': 'Evaluation pipeline', + 'engine.studio.access.explain.contributors': 'Contributed by', + 'engine.studio.access.explain.readFilter': 'Composed row filter (read)', + 'engine.studio.access.explain.empty': 'Pick an object and operation, then click “Explain”.', + 'engine.studio.access.explain.failed': 'Explain failed', + 'engine.studio.access.explain.forbidden': + 'You cannot explain that user — it requires the manage_users capability or a delegated admin scope covering them (ADR-0090 D6/D12).', + 'engine.studio.access.explain.layer.principal': 'Principal resolution', + 'engine.studio.access.explain.layer.required_permissions': 'Required capabilities', + 'engine.studio.access.explain.layer.object_crud': 'Object CRUD grant', + 'engine.studio.access.explain.layer.fls': 'Field-level security', + 'engine.studio.access.explain.layer.owd_baseline': 'Record baseline (OWD)', + 'engine.studio.access.explain.layer.depth': 'Scope depth', + 'engine.studio.access.explain.layer.sharing': 'Sharing', + 'engine.studio.access.explain.layer.vama_bypass': 'View/Modify All bypass', + 'engine.studio.access.explain.layer.rls': 'Row-level security', + 'engine.studio.access.explain.verdict.grants': 'grants', + 'engine.studio.access.explain.verdict.denies': 'denies', + 'engine.studio.access.explain.verdict.narrows': 'narrows', + 'engine.studio.access.explain.verdict.widens': 'widens', + 'engine.studio.access.explain.verdict.neutral': 'neutral', + 'engine.studio.access.explain.verdict.not_applicable': 'n/a', + 'engine.studio.access.explain.op.read': 'Read', + 'engine.studio.access.explain.op.create': 'Create', + 'engine.studio.access.explain.op.update': 'Update', + 'engine.studio.access.explain.op.delete': 'Delete', + 'engine.studio.access.explain.op.transfer': 'Transfer', + 'engine.studio.access.explain.op.restore': 'Restore', + 'engine.studio.access.explain.op.purge': 'Purge', + // ── AppNavCanvas (nav-tree editor, used by Studio + AppPreview) ───────── 'engine.appNav.heading': 'Navigation', 'engine.appNav.addItem': 'Add nav item', @@ -2331,6 +2381,56 @@ const ENGINE_STRINGS_ZH: Record = { 'engine.studio.access.emptyMain': '新建一个权限集开始配置', 'engine.studio.access.pick': '选择一个权限集', + // ── 访问解释面板(ADR-0090 D6 —「为什么该用户可以访问?」)──────────── + 'engine.studio.access.explain.open': '解释访问权限', + 'engine.studio.access.explain.title': '为什么该用户可以访问?', + 'engine.studio.access.explain.description': + '与运行时强制执行走同一条评估管线,逐层报告各层的贡献 —— 权限集 → 岗位 → 绑定链,以及 OWD/共享判定。', + 'engine.studio.access.explain.user': '用户', + 'engine.studio.access.explain.self': '我(当前调用者)', + 'engine.studio.access.explain.pickUser': '选择用户…', + 'engine.studio.access.explain.clearUser': '重置为我', + 'engine.studio.access.explain.pickUserTitle': '选择要解释的用户', + 'engine.studio.access.explain.object': '对象', + 'engine.studio.access.explain.objectPlaceholder': '对象名(如:crm_lead)', + 'engine.studio.access.explain.operation': '操作', + 'engine.studio.access.explain.run': '解释', + 'engine.studio.access.explain.running': '解释中…', + 'engine.studio.access.explain.allowedLine': '该主体对「{object}」执行 {operation} 将被【允许】。', + 'engine.studio.access.explain.deniedLine': '该主体对「{object}」执行 {operation} 将被【拒绝】。', + 'engine.studio.access.explain.principal': '主体', + 'engine.studio.access.explain.positions': '岗位', + 'engine.studio.access.explain.permissionSets': '权限集', + 'engine.studio.access.explain.layers': '评估管线', + 'engine.studio.access.explain.contributors': '贡献来源', + 'engine.studio.access.explain.readFilter': '合成的行过滤器(读取)', + 'engine.studio.access.explain.empty': '选择对象和操作,然后点击「解释」。', + 'engine.studio.access.explain.failed': '解释失败', + 'engine.studio.access.explain.forbidden': + '无法解释该用户 —— 需要 manage_users 能力,或一个覆盖该用户的委托管理范围(ADR-0090 D6/D12)。', + 'engine.studio.access.explain.layer.principal': '主体解析', + 'engine.studio.access.explain.layer.required_permissions': '前置能力', + 'engine.studio.access.explain.layer.object_crud': '对象 CRUD 授权', + 'engine.studio.access.explain.layer.fls': '字段级安全', + 'engine.studio.access.explain.layer.owd_baseline': '记录基线(OWD)', + 'engine.studio.access.explain.layer.depth': '范围深度', + 'engine.studio.access.explain.layer.sharing': '共享', + 'engine.studio.access.explain.layer.vama_bypass': '查看/修改全部数据旁路', + 'engine.studio.access.explain.layer.rls': '行级安全', + 'engine.studio.access.explain.verdict.grants': '授予', + 'engine.studio.access.explain.verdict.denies': '拒绝', + 'engine.studio.access.explain.verdict.narrows': '收窄', + 'engine.studio.access.explain.verdict.widens': '放宽', + 'engine.studio.access.explain.verdict.neutral': '中性', + 'engine.studio.access.explain.verdict.not_applicable': '不适用', + 'engine.studio.access.explain.op.read': '读取', + 'engine.studio.access.explain.op.create': '新建', + 'engine.studio.access.explain.op.update': '更新', + 'engine.studio.access.explain.op.delete': '删除', + 'engine.studio.access.explain.op.transfer': '转移', + 'engine.studio.access.explain.op.restore': '恢复', + 'engine.studio.access.explain.op.purge': '清除', + // ── AppNavCanvas (nav-tree editor, used by Studio + AppPreview) ───────── 'engine.appNav.heading': '导航', 'engine.appNav.addItem': '添加导航项', diff --git a/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx b/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx index af764a4559..368a86ae00 100644 --- a/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx +++ b/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx @@ -61,11 +61,13 @@ import { ExternalLink, Home as HomeIcon, Shield, + ShieldQuestion, Menu, type LucideIcon, } from 'lucide-react'; import { getMetadataPreview, type MetadataSelection } from '../metadata-admin/preview-registry'; import { PermissionMatrixEditPage } from '../metadata-admin/PermissionMatrixEditor'; +import { AccessExplainPanel } from '../metadata-admin/AccessExplainPanel'; import { getMetadataInspector } from '../metadata-admin/inspector-registry'; import { getMetadataDefaultInspector } from '../metadata-admin/default-inspector-registry'; import { useMetadataClient, useMetadataTypes } from '../metadata-admin/useMetadata'; @@ -2879,6 +2881,8 @@ function AccessPillar({ const [creating, setCreating] = React.useState(false); const [createErr, setCreateErr] = React.useState(null); const [busy, setBusy] = React.useState(false); + // [ADR-0090 D6] "why can this user access?" — right-side explain sheet. + const [explainOpen, setExplainOpen] = React.useState(false); const load = React.useCallback(async () => { try { @@ -2974,9 +2978,17 @@ function AccessPillar({ {t('engine.studio.access.title', locale)} {t('engine.studio.access.subtitle', locale)} + {t('engine.studio.access.banner', locale)} @@ -3078,6 +3090,8 @@ function AccessPillar({ + +