diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts index d9024c67..8bffc24d 100644 --- a/frontend/src/lib/api/client.ts +++ b/frontend/src/lib/api/client.ts @@ -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 | null = null; async function refresh(): Promise { @@ -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. */ diff --git a/frontend/src/lib/api/types.ts b/frontend/src/lib/api/types.ts index b3907220..3b2c03c6 100644 --- a/frontend/src/lib/api/types.ts +++ b/frontend/src/lib/api/types.ts @@ -216,6 +216,17 @@ export interface User { * delete it from the bag. */ ui_preferences: Record; + /** + * Mirrors `auth.users.force_password_change_at_next_login`. Only + * populated by `GET /api/auth/me` (see the backend UserDto doc for + * why other UserDto call-sites default to false). When true, the + * SPA MUST lock navigation to the password-change surface — the + * root layout's guard + the backend's `require_no_password_change_pending` + * middleware together enforce this. Optional on the wire because + * older backend builds omit it and `#[serde(default)]` maps + * missing → `false`. + */ + force_password_change?: boolean; } /** Fields rendered by the paginated admin table. Full account details remain diff --git a/frontend/src/lib/stores/session.svelte.ts b/frontend/src/lib/stores/session.svelte.ts index 574df8b9..8a78e6cc 100644 --- a/frontend/src/lib/stores/session.svelte.ts +++ b/frontend/src/lib/stores/session.svelte.ts @@ -19,6 +19,18 @@ class SessionStore { isExternalUser = $derived(this.user?.is_external ?? false); isAuthenticated = $derived(this.user !== null); + /** + * TRUE when the backend has set `force_password_change_at_next_login` + * on this account — an admin picked a temporary password and the + * user MUST change it before doing anything else. Drives the root + * layout's mandatory-mode redirect: any protected route other than + * `/profile` bounces back until the flag flips to false. + * + * Set to false by default so an older backend that predates the + * flag (or a malformed `/me` response) doesn't accidentally + * quarantine every user. + */ + mustChangePassword = $derived(this.user?.force_password_change === true); /** * Resolve the session once. Probes /api/auth/me; on 401 it makes a single diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 2052001f..0c847322 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -8,6 +8,7 @@ import AppShell from '$lib/components/AppShell.svelte'; import DialogHost from '$lib/components/DialogHost.svelte'; import Toaster from '$lib/components/Toaster.svelte'; + import { setPasswordChangeRequiredHandler } from '$lib/api/client'; import { session } from '$lib/stores/session.svelte'; import { ui } from '$lib/stores/ui.svelte'; import { hashUrlToPath } from '$lib/utils/hashRedirect'; @@ -44,6 +45,21 @@ // the guard waits for it before deciding. let providers = $state(null); + // Wire the fetch-interceptor's mandatory-mode handler to a soft + // `goto()` so a stale-tab request that surfaces a 403 + // `PasswordChangeRequired` routes to `/profile` without a full + // page reload — preserving the SPA session, drives cache, etc. + // The `next=` carries the intended destination so the profile + // page can bounce back after the change lands. Falls back to + // `window.location` if no `next` context (see the default handler + // in client.ts). + setPasswordChangeRequiredHandler(() => { + const path = page.url.pathname + page.url.search; + void goto(resolve(`/profile?forcePasswordChange=1&next=${encodeURIComponent(path)}`), { + replaceState: true + }); + }); + onMount(async () => { await killLegacyServiceWorker(); @@ -84,6 +100,34 @@ } void goto(resolve(`/login?redirect=${encodeURIComponent(path)}`), { replaceState: true }); }); + + // Mandatory change-password guard. When the backend has set + // `force_password_change_at_next_login` (admin reset), the SPA MUST + // keep the user on the profile page until they pick a new password. + // The backend also refuses every non-allowlisted endpoint with 403 + // PasswordChangeRequired — this guard is the UX side of that lock, + // so the user sees the password form instead of a wall of 403s + // wherever they clicked. + // + // Runs AFTER the unauthenticated guard so we don't misroute a + // still-loading session. Skips the guard on `/login` too — a + // signed-out user on the login form doesn't yet have a session + // state to consult, and if `session.mustChangePassword` is true + // on `/login` (rare — happens if the user reloaded post-login + // but before the profile navigation completed), the login-form + // success handler will route to `/profile?forcePasswordChange=1` + // on its own. + $effect(() => { + if (!ready) return; + if (!session.isAuthenticated) return; + if (!session.mustChangePassword) return; + const path = page.url.pathname; + if (path === '/profile' || isPublic(path)) return; + // Preserve the intended destination so the profile page can + // bounce back once the password is successfully changed. + const next = encodeURIComponent(path); + void goto(resolve(`/profile?forcePasswordChange=1&next=${next}`), { replaceState: true }); + }); {#if isPublic(page.url.pathname)} diff --git a/frontend/src/routes/profile/+page.svelte b/frontend/src/routes/profile/+page.svelte index ffbe5298..0d8e64ef 100644 --- a/frontend/src/routes/profile/+page.svelte +++ b/frontend/src/routes/profile/+page.svelte @@ -1,7 +1,12 @@