feat(pass reset): request a pass change on 1st login

This commit is contained in:
Edouard Vanbelle
2026-08-04 21:05:08 +02:00
parent 6965855388
commit 2de476d281
13 changed files with 731 additions and 19 deletions
+60 -1
View File
@@ -48,6 +48,13 @@ export interface ApiClientDeps {
rawFetch: FetchFn;
/** Invoked once when a refresh definitively fails (clear session + redirect). */
onSessionExpired: () => void;
/**
* Invoked when the server returns `403 { error_type: "PasswordChangeRequired" }`.
* Typically routes the SPA to `/profile?forcePasswordChange=1` — the same
* destination the root layout's nav-guard uses for a fresh navigation. Default
* is a no-op; the app wires the real handler at startup.
*/
onPasswordChangeRequired?: () => void;
/** Test seam for `window.location.origin`. */
origin?: string;
}
@@ -77,6 +84,10 @@ function bypassesRetry(urlStr: string): boolean {
*/
export function createApiFetch(deps: ApiClientDeps): FetchFn {
const { rawFetch, onSessionExpired } = deps;
// Default no-op keeps existing test callers that don't wire this
// dep from crashing on a 403 PasswordChangeRequired — they'd just
// see the raw 403 flow through, which is what they already assert.
const onPasswordChangeRequired = deps.onPasswordChangeRequired ?? (() => {});
let refreshInFlight: Promise<boolean> | null = null;
async function refresh(): Promise<boolean> {
@@ -114,6 +125,32 @@ export function createApiFetch(deps: ApiClientDeps): FetchFn {
// refresh doesn't accidentally clear a live banner.
updateFromHeader(response.headers.get(SERVER_STATUS_HEADER));
// Backend `require_no_password_change_pending_layer` returns 403
// `PasswordChangeRequired` on every non-allowlisted endpoint
// while the caller's `force_password_change_at_next_login` flag
// is set (admin picked a temporary password). Intercepting here
// short-circuits any stale-tab request that outran the SPA's
// nav-guard — the user is bounced to `/profile` in mandatory
// mode, matching what the guard would do on a fresh navigation.
//
// Clones the body so downstream callers can still consume the
// response after we've peeked at the error_type. Skipped for
// non-JSON responses (WebDAV, etc.) — the check silently
// falls through and returns the original 403 to the caller,
// which will surface its own error the usual way.
if (response.status === 403) {
const clone = response.clone();
try {
const body = (await clone.json()) as { error_type?: unknown };
if (body?.error_type === 'PasswordChangeRequired') {
onPasswordChangeRequired();
}
} catch {
/* not JSON or parse failed — pass through as normal 403 */
}
return response;
}
if (response.status !== 401) return response;
const urlStr = urlString(input as RequestInfo | URL);
@@ -146,13 +183,35 @@ export function setSessionExpiredHandler(fn: () => void): void {
sessionExpiredHandler = fn;
}
// Same shape as `sessionExpiredHandler` — mutable so the app can install
// the real behaviour post-mount, and a fallback for the (rare) case
// where no handler is wired yet (bootstrap, tests). The fallback does
// a hard `window.location` navigation so a stale tab that outran the
// SPA's nav-guard still lands the user on the mandatory form.
let passwordChangeRequiredHandler: () => void = () => {
if (typeof window !== 'undefined') {
const here = encodeURIComponent(window.location.pathname + window.location.search);
window.location.href = `/profile?forcePasswordChange=1&next=${here}`;
}
};
/**
* Wire the SPA's mandatory-mode handler. Called once from the root
* layout: uses `goto()` for a soft nav so `next=` preserves the
* intended destination without triggering a full page reload.
*/
export function setPasswordChangeRequiredHandler(fn: () => void): void {
passwordChangeRequiredHandler = fn;
}
const rawFetch: FetchFn =
typeof globalThis.fetch === 'function' ? globalThis.fetch.bind(globalThis) : (undefined as never);
/** App-wide fetch — route every API call through this. */
export const apiFetch: FetchFn = createApiFetch({
rawFetch,
onSessionExpired: () => sessionExpiredHandler()
onSessionExpired: () => sessionExpiredHandler(),
onPasswordChangeRequired: () => passwordChangeRequiredHandler()
});
/** Convenience: fetch JSON, throwing on non-2xx. */