fix(frontend): SOC C2 tab RBAC gate + safeTab sync + disabled tab contrast (review polish)

- 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 <noreply@anthropic.com>
This commit is contained in:
Knacky
2026-06-22 10:59:54 +02:00
parent 0378792dc4
commit 578a7c3d1a
4 changed files with 38 additions and 18 deletions

View File

@@ -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

View File

@@ -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 {
</form>
<div className="sticky bottom-0 bg-canvas border-t border-hairline flex items-center gap-md flex-wrap py-md">
<button type="submit" form="" className="btn-primary" disabled={creating} data-testid="create-sim-btn" onClick={onSubmitNew}>
<button type="button" className="btn-primary" disabled={creating} data-testid="create-sim-btn" onClick={onSubmitNew}>
{creating ? t('simulation.form.btn.creating') : t('simulation.form.btn.create')}
</button>
<Link to={`/engagements/${engagementId}`} className="btn-outline-ink">

View File

@@ -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) ─────────────────────────────────────── */

View File

@@ -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(<EditPage />, {
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(<EditPage />, {
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 () => {