M54: harden operations and demo resilience
MobilityOps acceptance / backend (push) Failing after 19s
MobilityOps acceptance / frontend (push) Successful in 25s
MobilityOps acceptance / e2e (push) Skipped

This commit is contained in:
NuklearRabbit
2026-08-24 03:31:03 +02:00
parent b0706989db
commit 81e3fd63bd
101 changed files with 5641 additions and 828 deletions
+29 -16
View File
@@ -29,10 +29,16 @@ function readCachedUser(): CurrentUser | null {
}
function cacheUser(user: CurrentUser | null) {
if (user) {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(user));
} else {
sessionStorage.removeItem(STORAGE_KEY);
try {
if (user) {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(user));
} else {
sessionStorage.removeItem(STORAGE_KEY);
}
} catch {
// Session storage is only a paint optimisation. Browser privacy settings or a full
// storage quota must never turn a successful server-side login/logout into a client
// failure; the HttpOnly session cookie remains authoritative.
}
}
@@ -47,32 +53,39 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const [oidcProviderName, setOidcProviderName] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
api.get<SystemStatus>("/api/v1/system/status")
const controller = new AbortController();
// System capabilities and session identity are independent reads. In particular, a
// slow public status endpoint must not hold an already valid session (or the login
// controls) behind its timeout.
void api.get<SystemStatus>("/api/v1/system/status", { signal: controller.signal })
.then((system) => {
if (controller.signal.aborted) return;
setDemoMode(system.demo_mode);
setOidcEnabled(system.oidc_enabled);
setOidcProviderName(system.oidc_provider_name);
})
.catch(() => setDemoMode(true))
.finally(() => api
.get<CurrentUser>("/api/v1/auth/session")
.catch(() => {
if (!controller.signal.aborted) setDemoMode(true);
});
void api
.get<CurrentUser>("/api/v1/auth/session", { signal: controller.signal })
.then((confirmed) => {
if (cancelled) return;
if (controller.signal.aborted) return;
setUser(confirmed);
cacheUser(confirmed);
})
.catch(() => {
if (cancelled) return;
if (controller.signal.aborted) return;
setUser(null);
cacheUser(null);
})
.finally(() => {
if (!cancelled) setLoading(false);
}));
return () => {
cancelled = true;
};
if (!controller.signal.aborted) setLoading(false);
});
return () => controller.abort();
}, []);
useEffect(
+16 -3
View File
@@ -12,15 +12,28 @@ function readProgress(): StoredProgress {
try {
const raw = sessionStorage.getItem(STORAGE_KEY);
if (!raw) return { currentIndex: 0, completed: [] };
const parsed = JSON.parse(raw) as StoredProgress;
return { currentIndex: parsed.currentIndex ?? 0, completed: parsed.completed ?? [] };
const parsed = JSON.parse(raw) as Partial<StoredProgress>;
const validStepIds = new Set(DEMO_GUIDE_STEPS.map((step) => step.id));
const currentIndex = Number.isInteger(parsed.currentIndex)
? Math.max(0, Math.min(parsed.currentIndex as number, DEMO_GUIDE_STEPS.length - 1))
: 0;
const completed = Array.isArray(parsed.completed)
? parsed.completed.filter(
(stepId): stepId is string => typeof stepId === "string" && validStepIds.has(stepId),
)
: [];
return { currentIndex, completed: Array.from(new Set(completed)) };
} catch {
return { currentIndex: 0, completed: [] };
}
}
function writeProgress(progress: StoredProgress) {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(progress));
try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(progress));
} catch {
// Progress is a convenience. Blocked or full storage must never break the guide.
}
}
interface DemoGuideState {
+12 -9
View File
@@ -5,6 +5,7 @@ import type { DemoManifest } from "../api/types";
interface DemoManifestState {
manifest: DemoManifest | null;
loading: boolean;
error: boolean;
refresh: () => void;
}
@@ -13,32 +14,34 @@ const DemoManifestContext = createContext<DemoManifestState | undefined>(undefin
export function DemoManifestProvider({ children }: { children: ReactNode }) {
const [manifest, setManifest] = useState<DemoManifest | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const [version, setVersion] = useState(0);
useEffect(() => {
let cancelled = false;
const controller = new AbortController();
setLoading(true);
setError(false);
// Public endpoint by design: the demo-entry screen needs this before any session
// exists, so it is never gated behind auth.
api
.get<DemoManifest>("/api/v1/demo/manifest")
.get<DemoManifest>("/api/v1/demo/manifest", { signal: controller.signal })
.then((result) => {
if (!cancelled) setManifest(result);
if (!controller.signal.aborted) setManifest(result);
})
.catch(() => {
if (!cancelled) setManifest(null);
if (controller.signal.aborted) return;
setManifest(null);
setError(true);
})
.finally(() => {
if (!cancelled) setLoading(false);
if (!controller.signal.aborted) setLoading(false);
});
return () => {
cancelled = true;
};
return () => controller.abort();
}, [version]);
return (
<DemoManifestContext.Provider
value={{ manifest, loading, refresh: () => setVersion((v) => v + 1) }}
value={{ manifest, loading, error, refresh: () => setVersion((v) => v + 1) }}
>
{children}
</DemoManifestContext.Provider>