release: 1.1.0
TallyNote release / linux-x64 (push) Successful in 6m24s

This commit is contained in:
Qiufeng
2026-08-31 20:11:34 +08:00
parent cbe0d65320
commit 12495fb6a4
66 changed files with 4315 additions and 77 deletions
@@ -0,0 +1,133 @@
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);
}