This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
export { default } from "./ChangePasswordPage";
|
||||
export * from "./ChangePasswordPage";
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default } from "./LoginPage";
|
||||
export * from "./LoginPage";
|
||||
@@ -0,0 +1,128 @@
|
||||
import { useState } from "react";
|
||||
import { Alert, Button, Checkbox, Form } from "tdesign-react";
|
||||
import { useAppDispatch, useAppSelector, clearSessionError, loginThunk } from "../../store";
|
||||
import type { Admin } from "../../types/auth";
|
||||
import { LoginUserIcon, StarterPasswordInput } from "./StarterAuth";
|
||||
import AccessibleInput from "../../components/AccessibleInput";
|
||||
|
||||
const REMEMBERED_USERNAME_KEY = "tallynote.login.username";
|
||||
|
||||
export interface LoginPageProps {
|
||||
notice?: string;
|
||||
onSuccess?: (admin: Admin) => void;
|
||||
}
|
||||
|
||||
function getRememberedUsername(): string {
|
||||
if (typeof window === "undefined") return "";
|
||||
try {
|
||||
return window.localStorage.getItem(REMEMBERED_USERNAME_KEY) ?? "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export default function LoginPage({ notice, onSuccess }: LoginPageProps) {
|
||||
const dispatch = useAppDispatch();
|
||||
const { status, error } = useAppSelector((state) => state.session);
|
||||
const [username, setUsername] = useState(getRememberedUsername);
|
||||
const [password, setPassword] = useState("");
|
||||
const [rememberAccount, setRememberAccount] = useState(Boolean(username));
|
||||
const [fieldErrors, setFieldErrors] = useState<{ username?: string; password?: string }>({});
|
||||
const busy = status === "loading";
|
||||
|
||||
const updateUsername = (value: string) => {
|
||||
setUsername(value);
|
||||
setFieldErrors(current => ({ ...current, username: undefined }));
|
||||
if (error) dispatch(clearSessionError());
|
||||
};
|
||||
|
||||
const updatePassword = (value: string) => {
|
||||
setPassword(value);
|
||||
setFieldErrors(current => ({ ...current, password: undefined }));
|
||||
if (error) dispatch(clearSessionError());
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
const normalizedUsername = username.trim();
|
||||
const nextErrors: { username?: string; password?: string } = {};
|
||||
if (!normalizedUsername) nextErrors.username = "请输入用户名";
|
||||
if (!password) nextErrors.password = "请输入密码";
|
||||
if (Object.keys(nextErrors).length) {
|
||||
setFieldErrors(nextErrors);
|
||||
const focusInvalid = () => document.querySelector<HTMLElement>("[aria-invalid='true'], [aria-invalid='true'] input, [aria-invalid='true'] textarea")?.focus();
|
||||
requestAnimationFrame(focusInvalid);
|
||||
window.setTimeout(focusInvalid, 60);
|
||||
return;
|
||||
}
|
||||
setFieldErrors({});
|
||||
try {
|
||||
if (rememberAccount) {
|
||||
window.localStorage.setItem(REMEMBERED_USERNAME_KEY, normalizedUsername);
|
||||
} else {
|
||||
window.localStorage.removeItem(REMEMBERED_USERNAME_KEY);
|
||||
}
|
||||
} catch {
|
||||
// Private browsing or a locked-down browser may reject localStorage.
|
||||
}
|
||||
try {
|
||||
const admin = await dispatch(loginThunk({ username: normalizedUsername, password })).unwrap();
|
||||
onSuccess?.(admin);
|
||||
} catch {
|
||||
// The rejected thunk exposes its server message through session.error.
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="tn-login-page" data-page="login">
|
||||
<section className="tn-login-container" aria-labelledby="login-title">
|
||||
<div className="tn-login-heading">
|
||||
<h1 id="login-title" className="tn-login-title">登录到 <span className="tn-login-title-brand">TallyNote</span></h1>
|
||||
</div>
|
||||
|
||||
<Form
|
||||
className="tn-login-form"
|
||||
labelWidth={0}
|
||||
onSubmit={(context) => { context.e?.preventDefault(); void submit(); }}
|
||||
>
|
||||
{notice && <div role="status" aria-live="polite"><Alert className="tn-login-alert" theme="info" message={notice} /></div>}
|
||||
{error && <div role="alert" aria-live="assertive"><Alert className="tn-login-alert" theme="error" message={error} /></div>}
|
||||
<Form.FormItem label="用户名" requiredMark={false} name="username" status={fieldErrors.username ? "error" : undefined} help={fieldErrors.username}>
|
||||
<AccessibleInput
|
||||
inputAriaLabel="用户名"
|
||||
inputAriaInvalid={Boolean(fieldErrors.username)}
|
||||
inputAriaDescribedby={fieldErrors.username ? "login-username-error" : undefined}
|
||||
size="large"
|
||||
value={username}
|
||||
onChange={(value) => updateUsername(String(value))}
|
||||
clearable
|
||||
disabled={busy}
|
||||
autocomplete="username"
|
||||
autofocus
|
||||
prefixIcon={<LoginUserIcon />}
|
||||
placeholder="请输入用户名"
|
||||
/>
|
||||
{fieldErrors.username && <span id="login-username-error" className="tn-sr-only">{fieldErrors.username}</span>}
|
||||
</Form.FormItem>
|
||||
<Form.FormItem label="密码" requiredMark={false} name="password" status={fieldErrors.password ? "error" : undefined} help={fieldErrors.password}>
|
||||
<StarterPasswordInput
|
||||
ariaLabel="密码"
|
||||
ariaInvalid={Boolean(fieldErrors.password)}
|
||||
ariaDescribedby={fieldErrors.password ? "login-password-error" : undefined}
|
||||
value={password}
|
||||
onChange={updatePassword}
|
||||
autocomplete="current-password"
|
||||
disabled={busy}
|
||||
placeholder="请输入登录密码"
|
||||
/>
|
||||
{fieldErrors.password && <span id="login-password-error" className="tn-sr-only">{fieldErrors.password}</span>}
|
||||
</Form.FormItem>
|
||||
<div className="tn-login-options"><Checkbox checked={rememberAccount} onChange={setRememberAccount} disabled={busy}>记住账号</Checkbox></div>
|
||||
<Form.FormItem className="tn-login-submit">
|
||||
<Button block size="large" theme="primary" type="submit" loading={busy} disabled={busy}>登录</Button>
|
||||
</Form.FormItem>
|
||||
</Form>
|
||||
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useState } from "react";
|
||||
import AccessibleInput from "../../components/AccessibleInput";
|
||||
import { Eye, EyeOff, LockKeyhole, UserRound, X } from "lucide-react";
|
||||
|
||||
export interface StarterPasswordInputProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder: string;
|
||||
ariaLabel: string;
|
||||
autocomplete?: string;
|
||||
disabled?: boolean;
|
||||
ariaInvalid?: boolean;
|
||||
ariaDescribedby?: string;
|
||||
}
|
||||
|
||||
/** Starter-style password field with clear and show/hide affordances. */
|
||||
export function StarterPasswordInput({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
ariaLabel,
|
||||
autocomplete,
|
||||
disabled = false,
|
||||
ariaInvalid,
|
||||
ariaDescribedby,
|
||||
}: StarterPasswordInputProps) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const toggle = () => setVisible((current) => !current);
|
||||
|
||||
return (
|
||||
<AccessibleInput
|
||||
inputAriaLabel={ariaLabel}
|
||||
inputAriaInvalid={ariaInvalid}
|
||||
inputAriaDescribedby={ariaDescribedby}
|
||||
size="large"
|
||||
value={value}
|
||||
onChange={(next) => onChange(String(next))}
|
||||
type={visible ? "text" : "password"}
|
||||
disabled={disabled}
|
||||
autocomplete={autocomplete}
|
||||
prefixIcon={<LockKeyhole className="tn-login-input-icon" size={18} />}
|
||||
// A defined (empty) suffix prevents TDesign's password icon from being
|
||||
// added in addition to the Starter-style control below.
|
||||
suffixIcon={<span className="tn-login-suffix-placeholder" aria-hidden="true" />}
|
||||
suffix={<span className="tn-login-suffix-actions">
|
||||
{value && <button type="button" className="tn-login-clear" aria-label="清空密码" onMouseDown={(event) => event.preventDefault()} onClick={() => onChange("")}><X size={15} /></button>}
|
||||
<button type="button" className="tn-login-password-toggle" aria-label={visible ? "隐藏密码" : "显示密码"} aria-pressed={visible} onMouseDown={(event) => event.preventDefault()} onClick={toggle}>
|
||||
{visible ? <Eye size={17} /> : <EyeOff size={17} />}
|
||||
</button>
|
||||
</span>}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function LoginUserIcon() {
|
||||
return <UserRound className="tn-login-prefix-icon" aria-hidden="true" size={18} />;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { default as LoginPage } from "./LoginPage";
|
||||
export { default as ChangePasswordPage } from "./ChangePasswordPage";
|
||||
export type { LoginPageProps } from "./LoginPage";
|
||||
export type { ChangePasswordPageProps } from "./ChangePasswordPage";
|
||||
Reference in New Issue
Block a user