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. */
+11
View File
@@ -216,6 +216,17 @@ export interface User {
* delete it from the bag.
*/
ui_preferences: Record<string, unknown>;
/**
* 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
+12
View File
@@ -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
+44
View File
@@ -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<OidcProviders | null>(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 });
});
</script>
{#if isPublic(page.url.pathname)}
+161 -5
View File
@@ -1,7 +1,12 @@
<script lang="ts">
import { errorToast } from '$lib/utils/errors';
import { relativeTimeAgo } from '$lib/utils/time';
import { onMount } from 'svelte';
import { onMount, tick } from 'svelte';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import type { Pathname } from '$app/types';
import { page } from '$app/state';
import { ApiError } from '$lib/api/client';
import {
changePassword,
createAppPassword,
@@ -13,7 +18,7 @@
type AppPassword,
type ProfilePatch
} from '$lib/api/endpoints/profile';
import { getOidcProviders } from '$lib/api/endpoints/auth';
import { fetchMe, getOidcProviders } from '$lib/api/endpoints/auth';
import { SUPPORTED_LOCALES, setLocale, t, type Locale } from '$lib/i18n/index.svelte';
import Icon from '$lib/icons/Icon.svelte';
import { confirmDialog } from '$lib/stores/dialogs.svelte';
@@ -68,6 +73,37 @@
const canEditImage = $derived(session.user?.can_edit_image === true && isLocal);
const showPasswordCard = $derived(isLocal && passwordLoginEnabled);
/**
* Mandatory change-password mode. TRUE when the backend has
* flagged the account (`session.mustChangePassword`) OR the URL
* carries `?forcePasswordChange=1` (arrived here from the login
* form / layout guard). Either signal locks the page into a
* single-purpose form: banner + password card only, other cards
* hidden. The URL param is a belt-and-braces alongside the store
* — a stale-tab session that lost the flag momentarily still
* shows the mandatory UI if the URL says so, and the layout guard
* will bounce a non-flagged user back off `/profile` naturally.
*/
const forceModeQueryParam = $derived(page.url.searchParams.get('forcePasswordChange') === '1');
const mandatoryMode = $derived(session.mustChangePassword || forceModeQueryParam);
/**
* Destination to bounce back to after a successful change. Only
* consulted in mandatory mode; caller-supplied via `?next=<encoded>`
* (added by the layout guard). Falls back to `/files` — the
* standard SPA landing point — when absent or when the value
* isn't a same-origin path (`startsWith('/')`).
*/
const nextAfterChange = $derived.by(() => {
const raw = page.url.searchParams.get('next');
if (!raw) return '/files';
try {
const decoded = decodeURIComponent(raw);
return decoded.startsWith('/') ? decoded : '/files';
} catch {
return '/files';
}
});
const storagePct = $derived(
session.user && session.user.storage_quota_bytes > 0
? Math.min(
@@ -161,18 +197,72 @@
);
return;
}
if (newPw === currentPw) {
// Fast client-side reject — the backend also enforces this
// (400 `PasswordUnchanged`) but the SPA can save the round-
// trip. Load-bearing in mandatory mode: silently accepting
// same-as-current would clear the force flag without a real
// rotation, defeating the "temporary password" pattern.
ui.notify(
t('profile.password_unchanged', 'New password must differ from the current one.'),
'error'
);
return;
}
savingPassword = true;
try {
await changePassword(currentPw, newPw);
currentPw = newPw = confirmPw = '';
ui.notify(t('profile.password_updated', 'Password updated'), 'success');
// If we're in mandatory mode, the backend just cleared the
// force flag AND revoked all sessions. Refresh the session
// so the layout guard lifts, then bounce to the intended
// destination the layout captured on entry. Refresh order
// matters: goto() before the session refresh would race
// the layout's `mustChangePassword` derived and re-redirect
// us right back to /profile.
if (mandatoryMode) {
try {
const me = await fetchMe();
if (me) session.setUser(me);
} catch {
/* stale session state is recoverable — the next request refreshes it */
}
await goto(resolve(nextAfterChange as Pathname), { replaceState: true });
}
} catch (err) {
errorToast(err);
// Remap the backend's `PasswordUnchanged` error_type to a
// specific, translatable message — the generic errorToast
// would show the raw server string. Every other error
// path still flows through errorToast.
if (err instanceof ApiError && err.errorType === 'PasswordUnchanged') {
ui.notify(
t('profile.password_unchanged', 'New password must differ from the current one.'),
'error'
);
} else {
errorToast(err);
}
} finally {
savingPassword = false;
}
}
// In mandatory mode, focus the current-password input as soon as
// the DOM is ready so the user can type without scrolling / clicking
// around to find the form. `tick()` waits for the reactive render;
// the null-check tolerates the (rare) case where the form isn't
// mounted yet on first paint.
onMount(async () => {
if (!mandatoryMode) return;
await tick();
const el = document.querySelector<HTMLInputElement>(
'[data-testid="profile-current-password-input"]'
);
el?.focus();
});
// ── Avatar edit panel ──────────────────────────────────────────────────
function openAvatarEdit() {
avatarEditOpen = true;
@@ -304,9 +394,41 @@
<svelte:head><title>{t('nav.profile', 'Profile')} · OxiCloud</title></svelte:head>
<main class="profile">
<main class="profile" class:profile--mandatory={mandatoryMode}>
<h1>{t('nav.profile', 'Profile')}</h1>
{#if mandatoryMode}
<!--
Mandatory-mode banner. Rendered above every other section
whenever `session.mustChangePassword` is TRUE or the URL
carries `?forcePasswordChange=1`. Explains WHY the user
landed here (an admin picked a temporary password) and
what they need to do (rotate before continuing). Backend
also refuses every non-allowlisted endpoint with 403
PasswordChangeRequired — so a user who dismisses the
banner via URL manipulation still can't reach any file /
DAV / admin endpoint until the change lands.
-->
<div
class="mandatory-banner"
role="alert"
data-testid="profile-mandatory-change-password-banner"
>
<Icon name="shield-alt" />
<div class="mandatory-banner__body">
<strong>
{t('profile.mandatory_change_title', 'Please change your password to continue.')}
</strong>
<p>
{t(
'profile.mandatory_change_body',
'An administrator has set a temporary password for your account. Choose your own password below before you can access the rest of the application.'
)}
</p>
</div>
</div>
{/if}
{#if session.user}
<!-- Avatar / identity -->
<div class="card avatar-card">
@@ -738,7 +860,7 @@
<!-- Change password -->
{#if showPasswordCard}
<form class="card" data-testid="profile-password-form" onsubmit={savePassword}>
<form class="card password-card" data-testid="profile-password-form" onsubmit={savePassword}>
<h2><Icon name="key" /> {t('profile.change_password', 'Change Password')}</h2>
<label>
<span>{t('profile.current_password', 'Current Password')}</span>
@@ -800,6 +922,40 @@
border-radius: var(--radius-lg);
}
/*
* Mandatory-mode: hide every card except the identity header
* (`.avatar-card` keeps context — who am I?) and the password
* form. Backend blocks non-allowlisted endpoints with 403 anyway;
* this is the UX side of that lock so the user sees exactly one
* form to fill in. Ergonomically loud banner + a single card.
*/
.profile--mandatory :global(.card):not(.avatar-card, .password-card) {
display: none;
}
.mandatory-banner {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 1rem 1.25rem;
background: var(--color-bg-warning-subtle, var(--color-bg-surface));
border: 1px solid var(--color-border-warning, var(--color-border));
border-left: 4px solid var(--color-accent-warning, var(--color-accent));
border-radius: var(--radius-md, var(--radius-lg));
color: var(--color-text);
}
.mandatory-banner__body strong {
display: block;
margin-bottom: 0.25rem;
}
.mandatory-banner__body p {
margin: 0;
font-size: 0.9rem;
color: var(--color-text-muted);
}
.card h2 {
margin: 0 0 0.25rem;
font-size: 1.125rem;