feat(pass reset): request a pass change on 1st login
This commit is contained in:
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user