From 1ddbd6989fcd75240091ef299305cda3af6c297d Mon Sep 17 00:00:00 2001 From: kitos Date: Tue, 7 Jul 2026 13:42:54 +0200 Subject: [PATCH] fix(frontend): hide viewer-only risk-compute UI, proactively renew session - Executive Dashboard no longer auto-triggers POST /risk/compute or shows the Refresh scores button for roles the backend rejects (viewer), which was causing a 403 on load - AuthContext now proactively renews the session cookie every 20 minutes while the app is open, so an actively-used session doesn't rely solely on the reactive on-401 refresh racing against its own token's expiry --- frontend/src/api/auth.ts | 5 +++ frontend/src/context/AuthContext.tsx | 16 ++++++++ frontend/src/pages/ExecutiveDashboardPage.tsx | 40 ++++++++++++------- 3 files changed, 46 insertions(+), 15 deletions(-) diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index 5c3e474..446f07b 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -35,6 +35,11 @@ export async function getMe(): Promise { return data; } +/** Silently renew the session cookie. Throws if there is nothing to renew. */ +export async function refreshToken(): Promise { + await client.post("/auth/refresh"); +} + /** Change the current user's password. */ export async function changePassword( currentPassword: string, diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx index 914e474..c785f33 100644 --- a/frontend/src/context/AuthContext.tsx +++ b/frontend/src/context/AuthContext.tsx @@ -10,6 +10,7 @@ import { login as apiLogin, logout as apiLogout, getMe, + refreshToken as apiRefreshToken, } from "../api/auth"; import type { User } from "../types/models"; import ChangePasswordModal from "../components/ChangePasswordModal"; @@ -45,6 +46,21 @@ export function AuthProvider({ children }: { children: ReactNode }) { refreshUser().finally(() => setIsLoading(false)); }, [refreshUser]); + // Proactively renew the session cookie while the app is open, so an + // actively-used session never actually reaches the hard 8-hour expiry + // (relying solely on the reactive on-401 refresh risks losing the race + // against the token's own expiry — see api/client.ts). + useEffect(() => { + if (!user) return; + const PROACTIVE_REFRESH_INTERVAL_MS = 20 * 60 * 1000; + const id = setInterval(() => { + apiRefreshToken().catch(() => { + // Reactive on-401 handling in api/client.ts takes over from here. + }); + }, PROACTIVE_REFRESH_INTERVAL_MS); + return () => clearInterval(id); + }, [user]); + const login = useCallback(async (username: string, password: string) => { await apiLogin(username, password); const me = await getMe(); diff --git a/frontend/src/pages/ExecutiveDashboardPage.tsx b/frontend/src/pages/ExecutiveDashboardPage.tsx index ab7966a..c8f3c6c 100644 --- a/frontend/src/pages/ExecutiveDashboardPage.tsx +++ b/frontend/src/pages/ExecutiveDashboardPage.tsx @@ -32,6 +32,11 @@ import { getCoverageByTactic } from "../api/metrics"; import { getThreatActors } from "../api/threat-actors"; import { getTechniques, type TechniqueSummary } from "../api/techniques"; import { getRiskProfiles, computeRiskScores, type RiskProfile } from "../api/risk"; +import { useAuth } from "../context/AuthContext"; + +// Roles allowed to trigger a risk-score recompute (matches the backend's +// POST /risk/compute role gate) — viewer must never call this endpoint. +const CAN_COMPUTE_RISK_ROLES = ["admin", "red_lead", "blue_lead"]; // ── Score Gauge Component ──────────────────────────────────────────── @@ -159,6 +164,8 @@ function sinceDate(days: number | null): string | undefined { export default function ExecutiveDashboardPage() { const navigate = useNavigate(); + const { user } = useAuth(); + const canComputeRisk = CAN_COMPUTE_RISK_ROLES.includes(user?.role ?? ""); const [timeRange, setTimeRange] = useState("all"); const rangeOption = TIME_RANGE_OPTIONS.find((o) => o.value === timeRange)!; @@ -214,9 +221,10 @@ export default function ExecutiveDashboardPage() { }, }); - // Auto-compute on first load if no profiles exist + // Auto-compute on first load if no profiles exist — only for roles the + // backend actually allows to call POST /risk/compute (viewer would 403). useEffect(() => { - if (!loadingRisk && riskProfiles !== undefined && riskProfiles.length === 0) { + if (canComputeRisk && !loadingRisk && riskProfiles !== undefined && riskProfiles.length === 0) { computeMutation.mutate(); } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -708,19 +716,21 @@ export default function ExecutiveDashboardPage() { Critical Gaps — Top 10 by Risk Priority - + {canComputeRisk && ( + + )}