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:
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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) ─────────────────────────────────────── */
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user