feat(auth): 登录注册 - 添加短信通知与组合验证支持

This commit is contained in:
ddCat
2026-09-22 23:44:23 +08:00
parent 6c013ebf7e
commit bc0d109428
55 changed files with 4013 additions and 103 deletions

View File

@@ -1,11 +1,13 @@
import { type FormEvent, useEffect, useState, type ReactNode } from "react";
import { App, Button, Divider, Input } from "antd";
import { App, Button, Divider, Input, Segmented } from "antd";
import { ArrowRight, LockKeyhole, UserRound } from "lucide-react";
import { Link, useNavigate, useSearchParams } from "react-router";
import { getAuthSession, getAuthSettings, linuxDOLoginURL, login } from "@/services/api/auth";
import { useUserStore } from "@/stores/use-user-store";
import { LinuxDOIcon } from "./auth-scene";
import { VerificationFields } from "@/components/auth/verification-fields";
import { emptyVerification, loginVerification, methodLabels, verificationMethods, type VerificationMethod } from "@/services/api/verification";
export default function LoginPage() {
const navigate = useNavigate();
@@ -15,6 +17,9 @@ export default function LoginPage() {
const [password, setPassword] = useState("");
const [submitting, setSubmitting] = useState(false);
const [linuxdoEnabled, setLinuxdoEnabled] = useState(false);
const [methods, setMethods] = useState<VerificationMethod[]>([]);
const [method, setMethod] = useState<"password" | VerificationMethod>("password");
const [verification, setVerification] = useState({ ...emptyVerification });
const next = safeNext(params.get("next"));
const forgotPasswordURL = `/forgot-password?next=${encodeURIComponent(next)}`;
const user = useUserStore((state) => state.user);
@@ -29,7 +34,7 @@ export default function LoginPage() {
useEffect(() => {
void getAuthSettings()
.then((settings) => setLinuxdoEnabled(settings.linuxdoEnabled))
.then((settings) => { setLinuxdoEnabled(settings.linuxdoEnabled); setMethods(verificationMethods(settings, "login")); })
.catch((error) => {
// 这是登录页的展示配置读取:失败时明确隐藏第三方入口,
// 账号密码登录仍可用;不能无痕地把配置读取失败当成成功。
@@ -43,7 +48,11 @@ export default function LoginPage() {
event.preventDefault();
setSubmitting(true);
try {
await login({ username, password });
if (method === "password") await login({ username, password });
else {
if (!verification.ticket) throw new Error("请先获取本次登录验证码");
await loginVerification(verification);
}
const { applyUserSession } = await import("@/lib/user-session");
await applyUserSession(await getAuthSession());
message.success("登录成功");
@@ -57,6 +66,8 @@ export default function LoginPage() {
return (
<form onSubmit={submit} className="space-y-5">
{methods.length > 0 && <Segmented block aria-label="登录方式" value={method} disabled={submitting} options={[{ value: "password", label: "密码登录" }, ...methods.map((value) => ({ value, label: methodLabels[value] }))]} onChange={(value) => { setMethod(value as typeof method); setVerification({ ...emptyVerification }); }} />}
{method !== "password" ? <VerificationFields key={method} purpose="login" method={method} value={verification} onChange={setVerification} disabled={submitting} /> : <>
<AuthField label="用户名 / 邮箱" htmlFor="login-account">
<Input id="login-account" size="large" prefix={<UserRound className="size-4 text-white/35" />} value={username} onChange={(event) => setUsername(event.target.value)} placeholder="用户名或邮箱" autoComplete="username" required />
</AuthField>
@@ -83,6 +94,7 @@ export default function LoginPage() {
required
/>
</AuthField>
</>}
<Button type="primary" htmlType="submit" size="large" block loading={submitting} icon={<ArrowRight className="size-4" />} iconPlacement="end">
登录
</Button>

View File

@@ -1,11 +1,13 @@
import { type FormEvent, useEffect, useRef, useState, type ReactNode } from "react";
import { App, Button, Checkbox, Divider, Input, Modal } from "antd";
import { ArrowRight, Info, LockKeyhole, Mail, ShieldCheck, TriangleAlert, UserRound } from "lucide-react";
import { App, Button, Checkbox, Divider, Input, Modal, Segmented } from "antd";
import { ArrowRight, Info, LockKeyhole, Mail, TriangleAlert, UserRound } from "lucide-react";
import { useNavigate, useSearchParams } from "react-router";
import { getAuthSession, getAuthSettings, linuxDOLoginURL, register, sendRegistrationEmailCode } from "@/services/api/auth";
import { getAuthSession, getAuthSettings, linuxDOLoginURL, register } from "@/services/api/auth";
import { LinuxDOIcon } from "./auth-scene";
import { ApiError } from "@/services/api/request";
import { VerificationFields } from "@/components/auth/verification-fields";
import { emptyVerification, methodLabels, verificationMethods, type VerificationMethod } from "@/services/api/verification";
type AuthSettings = Awaited<ReturnType<typeof getAuthSettings>>;
@@ -19,57 +21,28 @@ export default function RegisterPage() {
const [settings, setSettings] = useState<AuthSettings | null>(null);
const [username, setUsername] = useState("");
const [email, setEmail] = useState("");
const [emailCode, setEmailCode] = useState("");
const [verification, setVerification] = useState({ ...emptyVerification });
const [method, setMethod] = useState<VerificationMethod>("email");
const [displayName, setDisplayName] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [agreementAccepted, setAgreementAccepted] = useState(false);
const [agreementOpen, setAgreementOpen] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [sendingCode, setSendingCode] = useState(false);
const [countdown, setCountdown] = useState(0);
const [registerCountdown, setRegisterCountdown] = useState(0);
const sending = useRef(false),
registering = useRef(false);
const registering = useRef(false);
const next = safeNext(params.get("next"));
useEffect(() => {
let cancelled = false;
void getAuthSettings()
.then((value) => !cancelled && setSettings(value))
.then((value) => { if (!cancelled) { setSettings(value); setMethod(verificationMethods(value, "register")[0] ?? "email"); } })
.catch((error) => !cancelled && message.error(error instanceof Error ? error.message : "读取注册设置失败"));
return () => {
cancelled = true;
};
}, [message]);
useEffect(() => {
if (countdown <= 0) return;
const timer = window.setInterval(() => setCountdown((value) => Math.max(0, value - 1)), 1000);
return () => window.clearInterval(timer);
}, [countdown]);
const sendCode = async () => {
if (sending.current || countdown > 0) return;
if (!email.trim()) {
message.warning("请先输入邮箱");
return;
}
sending.current = true;
setSendingCode(true);
try {
await sendRegistrationEmailCode(email.trim());
setCountdown(60);
message.success("验证码已发送,请检查邮箱");
} catch (error) {
if (error instanceof ApiError && error.status === 429) setCountdown(Math.max(1, Math.ceil((error.retryAfterMs ?? 60000) / 1000)));
message.error(error instanceof Error ? error.message : "发送验证码失败");
} finally {
sending.current = false;
setSendingCode(false);
}
};
const submit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (registering.current || registerCountdown > 0) return;
@@ -84,7 +57,8 @@ export default function RegisterPage() {
registering.current = true;
setSubmitting(true);
try {
await register({ username, email, emailCode, displayName, password, acceptedTerms: agreementAccepted });
if (!settings?.firstUser && !verification.ticket) throw new Error("请先获取本次注册验证码");
await register({ username, ...(settings?.firstUser ? { email } : verification), displayName, password, acceptedTerms: agreementAccepted });
const { applyUserSession } = await import("@/lib/user-session");
await applyUserSession(await getAuthSession());
if (!settings?.firstUser) window.sessionStorage.setItem("infinite-canvas:model-setup-guide", "1");
@@ -106,9 +80,9 @@ export default function RegisterPage() {
}, [registerCountdown]);
const registrationClosed = settings?.registrationEnabled === false;
const mailUnavailable = Boolean(settings && !settings.firstUser && settings.emailCodeRequired && !settings.emailEnabled);
const disabled = registrationClosed || mailUnavailable;
const requireCode = Boolean(settings && !settings.firstUser && settings.emailCodeRequired);
const methods = settings ? verificationMethods(settings, "register") : [];
const verificationUnavailable = Boolean(settings && !settings.firstUser && methods.length === 0);
const disabled = !settings || registrationClosed || verificationUnavailable;
return (
<form onSubmit={submit} className="space-y-4">
@@ -122,9 +96,9 @@ export default function RegisterPage() {
当前已关闭普通注册,请联系管理员创建账号。
</Notice>
) : null}
{mailUnavailable ? (
{verificationUnavailable ? (
<Notice icon={<TriangleAlert className="size-3.5" />} tone="amber">
管理员尚未配置注册邮件,普通邮箱注册暂不可用。
当前没有可用的注册验证方式,请联系管理员检查短信及邮件配置。
</Notice>
) : null}
@@ -137,7 +111,7 @@ export default function RegisterPage() {
</AuthField>
</div>
<AuthField label="邮箱">
{settings?.firstUser ? <AuthField label="邮箱(可选)">
<Input
size="large"
prefix={<Mail className="size-4 text-white/35" />}
@@ -148,28 +122,10 @@ export default function RegisterPage() {
required={!settings?.firstUser}
disabled={disabled}
/>
</AuthField>
{requireCode ? (
<AuthField label="邮箱验证码">
<div className="grid grid-cols-[minmax(0,1fr)_116px] gap-2">
<Input
size="large"
prefix={<ShieldCheck className="size-4 text-white/35" />}
value={emailCode}
onChange={(event) => setEmailCode(event.target.value.replace(/\D/g, "").slice(0, 6))}
placeholder="6 位验证码"
inputMode="numeric"
autoComplete="one-time-code"
required
disabled={disabled}
/>
<Button size="large" loading={sendingCode} disabled={disabled || countdown > 0} onClick={() => void sendCode()}>
{countdown > 0 ? `${countdown}s` : "获取验证码"}
</Button>
</div>
</AuthField>
) : null}
</AuthField> : <>
{methods.length > 1 && <Segmented block aria-label="注册验证方式" options={methods.map((value) => ({ value, label: methodLabels[value] }))} value={method} disabled={submitting} onChange={(value) => { setMethod(value as VerificationMethod); setVerification({ ...emptyVerification }); }} />}
{methods.length > 0 && <VerificationFields key={method} purpose="register" method={method} value={verification} onChange={setVerification} disabled={disabled || submitting} />}
</>}
<div className="grid gap-4 sm:grid-cols-2">
<AuthField label="密码">
@@ -210,7 +166,7 @@ export default function RegisterPage() {
<Button type="primary" htmlType="submit" size="large" block loading={submitting} disabled={disabled || registerCountdown > 0 || !agreementAccepted} icon={<ArrowRight className="size-4" />} iconPlacement="end">
{registerCountdown > 0 ? `${registerCountdown} 秒后可重试` : "创建账号"}
</Button>
{settings?.linuxdoEnabled ? (
{settings?.linuxdoEnabled && !settings.smsAndEmailRegistration ? (
<>
<Divider plain className="!border-white/10 !text-white/30">
或