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,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>;
}