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

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