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. */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user