feat(access): add guarded local password lifecycle and recovery
This commit is contained in:
@@ -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) };
|
||||
}
|
||||
Reference in New Issue
Block a user