feat(auth): 登录注册 - 添加短信通知与组合验证支持
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
或
|
||||
|
||||
Reference in New Issue
Block a user