feat(access): add guarded local password lifecycle and recovery

This commit is contained in:
2026-09-08 07:47:18 +02:00
parent 8f8072b4ae
commit 7eb67b9fa7
31 changed files with 2377 additions and 33 deletions
@@ -0,0 +1,74 @@
import { useState, type FormEvent } from "react";
import {
Button, DismissibleAlert, FormField, FormLayout, PasswordField, i18nMessage, usePlatformLanguage,
type ApiSettings, type AuthInfo, type AuthUpdate
} from "@govoplan/core-webui";
import { changePassword, passwordErrorMessage } from "../../api/passwords";
import { usePasswordPolicy } from "./usePasswordPolicy";
export default function PasswordChangePanel({ settings, auth, onAuthChange }: {
settings: ApiSettings;
auth: AuthInfo;
onAuthChange: (auth: AuthUpdate | null, accessToken?: string) => void;
}) {
const { translateText } = usePlatformLanguage();
const { policy, error: policyError, reload } = usePasswordPolicy(settings);
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmation, setConfirmation] = useState("");
const [error, setError] = useState("");
const [success, setSuccess] = useState(false);
const [busy, setBusy] = useState(false);
const required = auth.user.required_auth_action === "change_password";
const localSession = auth.principal?.auth_method === "session" && auth.user.local_password === true;
const complete = Boolean(policy && currentPassword && Array.from(currentPassword).length <= 1024 && newPassword === confirmation
&& Array.from(newPassword).length >= policy.min_length
&& Array.from(newPassword).length <= policy.max_length);
async function submit(event: FormEvent) {
event.preventDefault();
if (busy || !complete || !localSession) return;
setBusy(true);
setError("");
setSuccess(false);
try {
const response = await changePassword(settings, currentPassword, newPassword);
onAuthChange(response, "");
setSuccess(true);
} catch (reason) {
setError(passwordErrorMessage(reason));
} finally {
setCurrentPassword("");
setNewPassword("");
setConfirmation("");
setBusy(false);
}
}
return <section>
<h1>{required ? "i18n:govoplan-access.password.required_title" : "i18n:govoplan-access.password.change_title"}</h1>
{required && <p>i18n:govoplan-access.password.required</p>}
{!localSession ? <DismissibleAlert tone="info" dismissible={false}>i18n:govoplan-access.password.local_only</DismissibleAlert> : <>
<p>i18n:govoplan-access.password.change_consequences</p>
{policyError && <DismissibleAlert tone="warning" dismissible={false}>{policyError}<Button onClick={reload} helpContextId="access.password.change" helpModuleId="access">i18n:govoplan-access.reload.cce71553</Button></DismissibleAlert>}
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
{success && <DismissibleAlert tone="success">i18n:govoplan-access.password.changed</DismissibleAlert>}
<FormLayout columns={1} collapseAt="standard" onSubmit={submit}>
<FormField label="i18n:govoplan-access.current_password.5e551021" helpContextId="access.password.change" helpModuleId="access">
<PasswordField aria-label={translateText("i18n:govoplan-access.current_password.5e551021")} value={currentPassword} onValueChange={setCurrentPassword} autoComplete="current-password" maxLength={2048} required disabled={busy} />
</FormField>
<FormField label="i18n:govoplan-access.password.new" help={policy ? i18nMessage("i18n:govoplan-access.password.length", { value0: policy.min_length, value1: policy.max_length }) : undefined} helpContextId="access.password.change" helpModuleId="access">
<PasswordField aria-label={translateText("i18n:govoplan-access.password.new")} value={newPassword} onValueChange={setNewPassword} autoComplete="new-password" minLength={policy?.min_length} maxLength={2 * (policy?.max_length ?? 1024)} required disabled={busy} generator />
</FormField>
<FormField label="i18n:govoplan-access.password.confirm" helpContextId="access.password.change" helpModuleId="access">
<PasswordField aria-label={translateText("i18n:govoplan-access.password.confirm")} value={confirmation} onValueChange={setConfirmation} autoComplete="new-password" maxLength={2 * (policy?.max_length ?? 1024)} required disabled={busy} />
</FormField>
{confirmation && newPassword !== confirmation && <p role="status">i18n:govoplan-access.password.mismatch</p>}
<Button type="submit" variant="primary" disabled={busy || !complete} helpContextId="access.password.change" helpModuleId="access"
disabledReason={busy ? "i18n:govoplan-access.password.saving" : !complete ? "i18n:govoplan-access.password.complete_fields" : undefined}>
{busy ? "i18n:govoplan-access.password.saving" : "i18n:govoplan-access.password.change_title"}
</Button>
</FormLayout>
</>}
</section>;
}
@@ -0,0 +1,10 @@
import { Link } from "react-router";
import type { ApiSettings } from "@govoplan/core-webui";
import { usePasswordPolicy } from "./usePasswordPolicy";
export default function PasswordLoginHelp({ settings, onNavigate }: { settings: ApiSettings; onNavigate: () => void }) {
const { policy } = usePasswordPolicy(settings);
return policy?.recovery_enabled
? <p><Link to="/password-recovery" onClick={onNavigate} data-help-context-id="access.password.recover" data-help-module-id="access">i18n:govoplan-access.password.forgot</Link></p>
: null;
}
@@ -0,0 +1,59 @@
import { useId, useState, type FormEvent } from "react";
import {
Button, Dialog, DismissibleAlert, FormField, FormLayout, PasswordField,
i18nMessage, usePlatformLanguage, type ApiSettings
} from "@govoplan/core-webui";
import { issuePasswordRecovery, passwordErrorMessage, type PasswordRecoveryCode } from "../../api/passwords";
export default function PasswordRecoveryIssueDialog({ settings, account, onClose }: {
settings: ApiSettings;
account: { account_id: string; email: string };
onClose: () => void;
}) {
const { translateText } = usePlatformLanguage();
const formId = useId();
const [password, setPassword] = useState("");
const [verified, setVerified] = useState(false);
const [result, setResult] = useState<PasswordRecoveryCode | null>(null);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const ready = verified && Boolean(password) && Array.from(password).length <= 1024;
async function submit(event: FormEvent) {
event.preventDefault();
if (busy || !ready || result) return;
setBusy(true);
setError("");
try {
setResult(await issuePasswordRecovery(settings, account.account_id, password, true));
} catch (reason) { setError(passwordErrorMessage(reason)); }
finally {
setPassword("");
setVerified(false);
setBusy(false);
}
}
return <Dialog open variant="administration" size="large"
title="i18n:govoplan-access.password.issue_title" onClose={() => { if (!busy) onClose(); }}
footer={<>
<Button onClick={onClose} disabled={busy} helpContextId="access.password.issue-recovery" helpModuleId="access">{result ? "i18n:govoplan-access.close.bbfa773e" : "i18n:govoplan-access.cancel.77dfd213"}</Button>
{!result && <Button type="submit" form={formId} variant="primary" disabled={busy || !ready} helpContextId="access.password.issue-recovery" helpModuleId="access"
disabledReason={busy ? "i18n:govoplan-access.password.saving" : !ready ? "i18n:govoplan-access.password.issue_requirements" : undefined}>
i18n:govoplan-access.password.issue_title
</Button>}
</>}>
<p>{i18nMessage("i18n:govoplan-access.password.issue_for", { value0: account.email })}</p>
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
{result ? <>
<p>i18n:govoplan-access.password.code_once</p>
<FormField label="i18n:govoplan-access.password.recovery_code" helpContextId="access.password.issue-recovery" helpModuleId="access"><input value={result.recovery_code} readOnly autoComplete="off" /></FormField>
<p>{i18nMessage("i18n:govoplan-access.password.code_expires", { value0: new Date(result.expires_at).toLocaleString() })}</p>
<p>i18n:govoplan-access.password.code_delivery</p>
</> : <FormLayout columns={1} collapseAt="standard" id={formId} onSubmit={submit}>
<p>i18n:govoplan-access.password.issue_consequences</p>
<FormField label="i18n:govoplan-access.current_password.5e551021" helpContextId="access.password.issue-recovery" helpModuleId="access"><PasswordField aria-label={translateText("i18n:govoplan-access.current_password.5e551021")} value={password} onValueChange={setPassword} autoComplete="current-password" maxLength={2048} required disabled={busy} /></FormField>
<label className="checkbox-field" data-help-context-id="access.password.issue-recovery" data-help-module-id="access"><input type="checkbox" checked={verified} onChange={(event) => setVerified(event.target.checked)} disabled={busy} required /> <span>i18n:govoplan-access.password.identity_verified</span></label>
</FormLayout>}
</Dialog>;
}
@@ -0,0 +1,58 @@
import { useState, type FormEvent } from "react";
import { Link } from "react-router";
import { Button, DismissibleAlert, FormField, FormLayout, PasswordField, i18nMessage, usePlatformLanguage, type ApiSettings } from "@govoplan/core-webui";
import { passwordErrorMessage, recoverPassword } from "../../api/passwords";
import { usePasswordPolicy } from "./usePasswordPolicy";
export default function PasswordRecoveryPage({ settings }: { settings: ApiSettings }) {
const { translateText } = usePlatformLanguage();
const { policy, error: policyError, reload } = usePasswordPolicy(settings);
const [email, setEmail] = useState("");
const [code, setCode] = useState("");
const [password, setPassword] = useState("");
const [confirmation, setConfirmation] = useState("");
const [error, setError] = useState("");
const [complete, setComplete] = useState(false);
const [busy, setBusy] = useState(false);
const ready = Boolean(policy?.recovery_enabled && email.trim() && code.trim()
&& password === confirmation && Array.from(password).length >= policy.min_length
&& Array.from(password).length <= policy.max_length);
async function submit(event: FormEvent) {
event.preventDefault();
if (busy || !ready) return;
setBusy(true);
setError("");
try {
await recoverPassword(settings, email.trim(), code.trim(), password);
setComplete(true);
setEmail("");
} catch (reason) { setError(passwordErrorMessage(reason)); }
finally {
setCode("");
setPassword("");
setConfirmation("");
setBusy(false);
}
}
return <div className="public-landing auth-action-page"><section className="public-card">
<h1>i18n:govoplan-access.password.recover_title</h1>
{complete ? <DismissibleAlert tone="success" dismissible={false}>i18n:govoplan-access.password.recovered</DismissibleAlert> : <>
<p>i18n:govoplan-access.password.recovery_instructions</p>
<p>i18n:govoplan-access.password.recovery_consequences</p>
{policyError && <DismissibleAlert tone="warning" dismissible={false}>{policyError}<Button onClick={reload} helpContextId="access.password.recover" helpModuleId="access">i18n:govoplan-access.reload.cce71553</Button></DismissibleAlert>}
{policy && !policy.recovery_enabled && <DismissibleAlert tone="info" dismissible={false}>i18n:govoplan-access.password.recovery_disabled</DismissibleAlert>}
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
{policy?.recovery_enabled && <FormLayout columns={1} collapseAt="standard" onSubmit={submit}>
<FormField label="i18n:govoplan-access.email.84add5b2" helpContextId="access.password.recover" helpModuleId="access"><input type="email" autoComplete="username" value={email} onChange={(event) => setEmail(event.target.value)} required maxLength={320} disabled={busy} /></FormField>
<FormField label="i18n:govoplan-access.password.recovery_code" helpContextId="access.password.recover" helpModuleId="access"><PasswordField aria-label={translateText("i18n:govoplan-access.password.recovery_code")} value={code} onValueChange={setCode} autoComplete="off" maxLength={256} required disabled={busy} /></FormField>
<FormField label="i18n:govoplan-access.password.new" help={i18nMessage("i18n:govoplan-access.password.length", { value0: policy.min_length, value1: policy.max_length })} helpContextId="access.password.recover" helpModuleId="access"><PasswordField aria-label={translateText("i18n:govoplan-access.password.new")} value={password} onValueChange={setPassword} autoComplete="new-password" minLength={policy.min_length} maxLength={2 * policy.max_length} required disabled={busy} generator /></FormField>
<FormField label="i18n:govoplan-access.password.confirm" helpContextId="access.password.recover" helpModuleId="access"><PasswordField aria-label={translateText("i18n:govoplan-access.password.confirm")} value={confirmation} onValueChange={setConfirmation} autoComplete="new-password" maxLength={2 * policy.max_length} required disabled={busy} /></FormField>
{confirmation && confirmation !== password && <p role="status">i18n:govoplan-access.password.mismatch</p>}
<Button type="submit" variant="primary" helpContextId="access.password.recover" helpModuleId="access" disabled={busy || !ready} disabledReason={busy ? "i18n:govoplan-access.password.saving" : !ready ? "i18n:govoplan-access.password.complete_fields" : undefined}>{busy ? "i18n:govoplan-access.password.saving" : "i18n:govoplan-access.password.recover_title"}</Button>
</FormLayout>}
</>}
<p><Link to="/" data-help-context-id="access.password.recover" data-help-module-id="access">i18n:govoplan-access.password.return_sign_in</Link></p>
</section></div>;
}
@@ -0,0 +1,21 @@
import { useEffect, useState } from "react";
import type { ApiSettings } from "@govoplan/core-webui";
import { fetchPasswordPolicy, passwordErrorMessage, type PasswordPolicy } from "../../api/passwords";
export function usePasswordPolicy(settings: ApiSettings) {
const [policy, setPolicy] = useState<PasswordPolicy | null>(null);
const [error, setError] = useState("");
const [revision, reload] = useState(0);
useEffect(() => {
let current = true;
setPolicy(null);
setError("");
fetchPasswordPolicy(settings).then((value) => {
if (current) setPolicy(value);
}).catch((reason) => {
if (current) setError(passwordErrorMessage(reason));
});
return () => { current = false; };
}, [settings.apiBaseUrl, revision]);
return { policy, error, reload: () => reload((value) => value + 1) };
}