134 lines
7.8 KiB
TypeScript
134 lines
7.8 KiB
TypeScript
import { useMemo, useState } from "react";
|
|
import { ArrowLeft, Check, KeyRound, ShieldCheck } from "lucide-react";
|
|
import { Alert, Button, Form } from "tdesign-react";
|
|
import { changePasswordThunk, clearSessionError, useAppDispatch, useAppSelector } from "../../store";
|
|
import type { Admin } from "../../types/auth";
|
|
import { StarterPasswordInput } from "./StarterAuth";
|
|
import { Page } from "../common";
|
|
import { useUnsavedChanges } from "../../contexts/UnsavedChanges";
|
|
|
|
export interface ChangePasswordPageProps {
|
|
admin?: Admin | null;
|
|
onSuccess?: (admin: Admin) => void;
|
|
onCancel?: () => void;
|
|
returnLabel?: string;
|
|
firstLogin?: boolean;
|
|
}
|
|
|
|
export default function ChangePasswordPage({ admin, onSuccess, onCancel, returnLabel = "返回当前页面", firstLogin = false }: ChangePasswordPageProps) {
|
|
const dispatch = useAppDispatch();
|
|
const sessionAdmin = useAppSelector((state) => state.session.admin);
|
|
const { status, error } = useAppSelector((state) => state.session);
|
|
const [currentPassword, setCurrentPassword] = useState("");
|
|
const [newPassword, setNewPassword] = useState("");
|
|
const [confirmation, setConfirmation] = useState("");
|
|
const [formError, setFormError] = useState<string | null>(null);
|
|
const [fieldErrors, setFieldErrors] = useState<{ newPassword?: string; confirmation?: string }>({});
|
|
const busy = status === "loading";
|
|
const dirty = Boolean(currentPassword || newPassword || confirmation);
|
|
useUnsavedChanges(dirty);
|
|
const displayAdmin = admin ?? sessionAdmin;
|
|
const isFirstLogin = firstLogin || Boolean(displayAdmin?.mustChangePassword);
|
|
const displayName = displayAdmin?.displayName || displayAdmin?.username || "当前管理员";
|
|
const requirements = useMemo(() => [
|
|
{ label: "至少 12 个字符", valid: newPassword.length >= 12 },
|
|
{ label: "两次输入保持一致", valid: Boolean(confirmation) && newPassword === confirmation },
|
|
], [confirmation, newPassword]);
|
|
|
|
const submit = async () => {
|
|
setFormError(null);
|
|
setFieldErrors({});
|
|
if (newPassword.length < 12) {
|
|
setFieldErrors({ newPassword: "新密码至少需要 12 位" });
|
|
focusPasswordField("新密码");
|
|
return;
|
|
}
|
|
if (newPassword !== confirmation) {
|
|
setFieldErrors({ confirmation: "两次输入的新密码不一致" });
|
|
focusPasswordField("确认新密码");
|
|
return;
|
|
}
|
|
try {
|
|
const updated = await dispatch(changePasswordThunk({ currentPassword, newPassword })).unwrap();
|
|
// Clear the local draft before notifying the parent. This keeps the
|
|
// unsaved-changes guard correct even when a host keeps this page mounted.
|
|
setCurrentPassword("");
|
|
setNewPassword("");
|
|
setConfirmation("");
|
|
setFieldErrors({});
|
|
onSuccess?.(updated);
|
|
} catch {
|
|
// The rejected thunk exposes its server message through session.error.
|
|
}
|
|
};
|
|
|
|
const form = <Form
|
|
className="tn-password-form"
|
|
labelAlign="top"
|
|
labelWidth={0}
|
|
onSubmit={(context) => { if (context.validateResult === true) void submit(); }}
|
|
>
|
|
<Form.FormItem label="当前密码" name="currentPassword" rules={[{ required: true, message: "请输入当前密码", type: "error" }]}>
|
|
<StarterPasswordInput ariaLabel="当前密码" value={currentPassword} onChange={value => { setCurrentPassword(value); setFormError(null); dispatch(clearSessionError()); }} autocomplete="current-password" disabled={busy} placeholder="请输入当前密码" />
|
|
</Form.FormItem>
|
|
<Form.FormItem label="新密码" name="newPassword" status={fieldErrors.newPassword ? "error" : undefined} help={fieldErrors.newPassword} rules={[{ required: true, min: 12, message: "新密码至少需要 12 位", type: "error" }]}>
|
|
<StarterPasswordInput ariaLabel="新密码" ariaInvalid={Boolean(fieldErrors.newPassword)} ariaDescribedby={fieldErrors.newPassword ? "password-new-error" : undefined} value={newPassword} onChange={value => { setNewPassword(value); setFieldErrors(current => ({ ...current, newPassword: undefined })); setFormError(null); dispatch(clearSessionError()); }} autocomplete="new-password" disabled={busy} placeholder="请输入至少 12 位的新密码" />
|
|
{fieldErrors.newPassword && <span id="password-new-error" className="tn-sr-only">{fieldErrors.newPassword}</span>}
|
|
</Form.FormItem>
|
|
<Form.FormItem label="确认新密码" name="confirmation" status={fieldErrors.confirmation ? "error" : undefined} help={fieldErrors.confirmation} rules={[{ required: true, message: "请再次输入新密码", type: "error" }]}>
|
|
<StarterPasswordInput ariaLabel="确认新密码" ariaInvalid={Boolean(fieldErrors.confirmation)} ariaDescribedby={fieldErrors.confirmation ? "password-confirmation-error" : undefined} value={confirmation} onChange={value => { setConfirmation(value); setFieldErrors(current => ({ ...current, confirmation: undefined })); setFormError(null); dispatch(clearSessionError()); }} autocomplete="new-password" disabled={busy} placeholder="请再次输入新密码" />
|
|
{fieldErrors.confirmation && <span id="password-confirmation-error" className="tn-sr-only">{fieldErrors.confirmation}</span>}
|
|
</Form.FormItem>
|
|
<Form.FormItem className="tn-password-submit">
|
|
<div className="tn-password-actions">
|
|
<Button className="tn-password-submit-button" theme="primary" type="submit" loading={busy} disabled={busy}>更新密码</Button>
|
|
</div>
|
|
</Form.FormItem>
|
|
</Form>;
|
|
|
|
const panel = <section className={`tn-password-card${isFirstLogin ? " tn-password-card-first" : ""}`} aria-labelledby="password-card-title">
|
|
<header className="tn-password-card-head">
|
|
<div className="tn-password-card-icon" aria-hidden="true"><KeyRound size={20} /></div>
|
|
<div>
|
|
<h2 id="password-card-title">{isFirstLogin ? "设置登录密码" : "修改登录密码"}</h2>
|
|
<p>{isFirstLogin ? "首次登录需要先设置一个新的登录密码。" : `当前账号:${displayName}`}</p>
|
|
</div>
|
|
</header>
|
|
{(formError || error) && <div role="alert" aria-live="assertive"><Alert className="tn-password-alert" theme="error" message={formError || error || "密码更新失败"} /></div>}
|
|
<div className="tn-password-card-body">
|
|
<div>{form}</div>
|
|
<aside className="tn-password-guidance" aria-label="密码要求">
|
|
<div className="tn-password-guidance-title"><ShieldCheck size={18} />密码要求</div>
|
|
<p>请使用不易猜测且未在其他服务重复使用的密码。</p>
|
|
<ul>
|
|
{requirements.map((item) => <li key={item.label} className={item.valid ? "is-valid" : ""}><Check size={14} />{item.label}</li>)}
|
|
</ul>
|
|
</aside>
|
|
</div>
|
|
</section>;
|
|
|
|
if (!isFirstLogin) {
|
|
return <Page title="修改密码" subtitle="定期更新管理员账户登录密码,保障财务数据访问安全。" actions={onCancel ? <Button variant="text" type="button" onClick={onCancel} icon={<ArrowLeft size={16} />}>{returnLabel}</Button> : undefined} className="tn-password-page">{panel}</Page>;
|
|
}
|
|
|
|
return <main className="tn-login-page" data-page="change-password">
|
|
<section className="tn-login-container tn-password-container">
|
|
<div className="tn-login-heading">
|
|
<h1 id="password-title" className="tn-login-title">初始安全设置</h1>
|
|
<p className="tn-login-subtitle">欢迎使用系统,管理员 {displayName},为保障账户安全,首次登录请先设置新密码。</p>
|
|
</div>
|
|
{panel}
|
|
</section>
|
|
</main>;
|
|
}
|
|
|
|
function focusPasswordField(label: string) {
|
|
const focus = () => {
|
|
const nodes = Array.from(document.querySelectorAll<HTMLElement>("[aria-invalid='true'], [aria-invalid='true'] input, [aria-invalid='true'] textarea"));
|
|
const target = nodes.find(node => node.closest(".t-form__item")?.textContent?.includes(label));
|
|
target?.focus();
|
|
};
|
|
requestAnimationFrame(focus);
|
|
window.setTimeout(focus, 60);
|
|
}
|