129 lines
5.5 KiB
TypeScript
129 lines
5.5 KiB
TypeScript
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>
|
|
);
|
|
}
|