From 578a7c3d1ad86947dd8b97f25fdd0ef835c9c239 Mon Sep 17 00:00:00 2001 From: Knacky Date: Mon, 22 Jun 2026 10:59:54 +0200 Subject: [PATCH] fix(frontend): SOC C2 tab RBAC gate + safeTab sync + disabled tab contrast (review polish) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - c2TabDisabled: add isSoc condition — SOC role has no access to /c2/tasks (backend 403) - safeTab sync: useEffect before early returns using detail.data?.status; syncs window.location.hash when hash points to a now-disabled tab after status transition - create-mode submit button: type="submit" form="" → type="button" (form association fix) - .tab-underline-disabled: text-graphite opacity-50 → text-steel (consistent with btn-outline:disabled; better perceptual contrast) - tests: 254 passing (+ SOC × C2 tab disabled assertion) Co-Authored-By: Claude Sonnet 4.6 --- DESIGN.md | 2 +- frontend/src/pages/SimulationFormPage.tsx | 34 +++++++++++++++------- frontend/src/styles/index.css | 4 +-- frontend/tests/SimulationFormPage.test.tsx | 16 +++++++--- 4 files changed, 38 insertions(+), 18 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index a6c9fba..e60c20e 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -225,7 +225,7 @@ Used inside pages that need content partitioning without a URL change (e.g. Enga - **`.tab-underline-active`**: `text-primary border-primary` — 2px primary underline, primary text. - **Count pill** (optional, e.g. simulation count): `.tab-count-pill` — `rounded-pill bg-cloud text-graphite text-[11px] px-xs py-0 font-mono`. `rounded-pill` is the documented exception. - **Active count pill**: `.tab-count-pill-active` — `bg-primary-soft text-primary`. -- **Disabled variant**: `.tab-underline-disabled` — `text-graphite opacity-50 cursor-not-allowed`. Applied when `disabled?: boolean` is set on a `TabItem`. Overrides `.tab-underline` hover. ARIA: `aria-disabled="true"` + native `disabled` attribute. Arrow-key navigation skips disabled items. +- **Disabled variant**: `.tab-underline-disabled` — `text-steel cursor-not-allowed`. Uses the same `text-steel` token as `btn-outline:disabled` for system-wide consistency. Applied when `disabled?: boolean` is set on a `TabItem`. Overrides `.tab-underline` hover. ARIA: `aria-disabled="true"` + native `disabled` attribute. Arrow-key navigation skips disabled items. - ARIA: `role="tablist"` on container; `role="tab"` + `aria-selected` + `aria-controls="tabpanel-{id}"` on each button. ### Data Tables diff --git a/frontend/src/pages/SimulationFormPage.tsx b/frontend/src/pages/SimulationFormPage.tsx index ad86a23..c2e1760 100644 --- a/frontend/src/pages/SimulationFormPage.tsx +++ b/frontend/src/pages/SimulationFormPage.tsx @@ -99,9 +99,26 @@ export function SimulationFormPage(): JSX.Element { const [showC2Modal, setShowC2Modal] = useState(false); const [showImportModal, setShowImportModal] = useState(false); - // Must be called unconditionally before any early returns + // All hooks must be called unconditionally before any early returns. const [activeTab, setActiveTab] = useHashTab('red-team'); + // Compute disabled flags early using detail.data (may be undefined during load — that's fine). + const loadedStatus = detail.data?.status; + const earlySOCDisabled = isNew || (loadedStatus !== 'review_required' && loadedStatus !== 'done'); + const earlyC2Disabled = isNew || isSoc; + const earlyHash = activeTab; + const earlySafeTab = + (earlyHash === 'soc' && earlySOCDisabled) || (earlyHash === 'c2' && earlyC2Disabled) + ? 'red-team' + : earlyHash; + + // Sync window.location.hash when the active hash points to a disabled tab + useEffect(() => { + if (earlySafeTab !== activeTab) { + setActiveTab(earlySafeTab); + } + }, [earlySafeTab, activeTab, setActiveTab]); + useEffect(() => { if (!isNew && detail.data) { const s = detail.data; @@ -147,15 +164,10 @@ export function SimulationFormPage(): JSX.Element { !isDone && (canEditEngagements || isSoc) && status === 'review_required'; const showReopen = isDone && (isAdmin || isRedteam || isSoc); - // SOC tab is only enabled when status is review_required or done - const socTabDisabled = isNew || (status !== 'review_required' && status !== 'done'); - const c2TabDisabled = isNew; - - // Fall back to red-team if the stored hash points to a now-disabled tab - const safeTab = - (activeTab === 'soc' && socTabDisabled) || (activeTab === 'c2' && c2TabDisabled) - ? 'red-team' - : activeTab; + // Post-load versions (same logic, now with confirmed status) + const socTabDisabled = earlySOCDisabled; + const c2TabDisabled = earlyC2Disabled; + const safeTab = earlySafeTab; const onSubmitNew = async (e: FormEvent) => { e.preventDefault(); @@ -305,7 +317,7 @@ export function SimulationFormPage(): JSX.Element {
- diff --git a/frontend/src/styles/index.css b/frontend/src/styles/index.css index 6e0d5fd..72d3d24 100644 --- a/frontend/src/styles/index.css +++ b/frontend/src/styles/index.css @@ -162,9 +162,9 @@ .tab-count-pill-active { @apply bg-primary-soft text-primary; } - /* Disabled variant — opacity + cursor; overrides hover from .tab-underline */ + /* Disabled variant — consistent with btn-outline:disabled; overrides hover from .tab-underline */ .tab-underline-disabled { - @apply text-graphite opacity-50 cursor-not-allowed; + @apply text-steel cursor-not-allowed; } /* ─── Inline alert banners (L2) ─────────────────────────────────────── */ diff --git a/frontend/tests/SimulationFormPage.test.tsx b/frontend/tests/SimulationFormPage.test.tsx index bf6977c..70e00e3 100644 --- a/frontend/tests/SimulationFormPage.test.tsx +++ b/frontend/tests/SimulationFormPage.test.tsx @@ -216,16 +216,24 @@ describe('SimulationFormPage — tabs', () => { }); }); - it('C2 tab is disabled in edit mode (no tasks shown until tab visible)', async () => { + it('C2 tab is enabled for redteam in edit mode', async () => { renderWithProviders(, { routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] }, }); await waitFor(() => expect(screen.getByLabelText(/^Nom/i)).not.toBeDisabled()); - // C2 tab should exist and be enabled (it's edit mode, not create mode) - const c2Tab = screen.getByRole('tab', { name: /Tâche C2/i }); - expect(c2Tab).not.toBeDisabled(); + expect(screen.getByRole('tab', { name: /Tâche C2/i })).not.toBeDisabled(); + }); + + it('C2 tab is disabled for SOC role (backend 403 on /c2/tasks)', async () => { + mockRole = 'soc'; + mock.onGet('/simulations/7').reply(200, { ...BASE_SIM, status: 'review_required' }); + renderWithProviders(, { + routerProps: { initialEntries: ['/engagements/42/simulations/7/edit'] }, + }); + + await waitFor(() => expect(screen.getByRole('tab', { name: /Tâche C2/i })).toBeDisabled()); }); it('SOC fields are visible after switching to SOC tab', async () => {