M54: harden operations and demo resilience
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user