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

@@ -0,0 +1,45 @@
import { useEffect, useRef, useState } from "react";
import { App, Button, Input } from "antd";
import { startVerification, type VerificationDraft, type VerificationMethod, type VerificationPurpose } from "@/services/api/verification";
export function VerificationFields({ purpose, method, value, onChange, disabled = false, password }: {
purpose: VerificationPurpose; method: VerificationMethod; value: VerificationDraft;
onChange: (value: VerificationDraft) => void; disabled?: boolean; password?: string;
}) {
const { message } = App.useApp();
const [sending, setSending] = useState(false);
const [remaining, setRemaining] = useState(0);
const inFlight = useRef(false);
const sms = method !== "email", email = method !== "sms";
useEffect(() => {
if (!remaining) return;
const timer = window.setTimeout(() => setRemaining((n) => Math.max(0, n - 1)), 1000);
return () => window.clearTimeout(timer);
}, [remaining]);
const changeTarget = (key: "email" | "phone", target: string) => onChange({ ...value, [key]: target, ticket: "", emailCode: "", smsCode: "" });
const send = async () => {
if (inFlight.current || remaining > 0) return;
if ((sms && !value.phone.trim()) || (email && !value.email.trim()) || (purpose === "bind" && !password)) {
message.warning("请先填写联系方式及必要的当前密码"); return;
}
inFlight.current = true; setSending(true);
// Start cooldown even on ambiguous failures; never encourage instant resends.
setRemaining(60);
onChange({ ...value, ticket: "", emailCode: "", smsCode: "" });
try {
const result = await startVerification({ purpose, method, email: email ? value.email.trim() : undefined, phone: sms ? value.phone.trim() : undefined, password });
onChange({ ...value, email: email ? value.email.trim().toLowerCase() : "", phone: sms ? value.phone.trim() : "", emailCode: "", smsCode: "", ticket: result.ticket });
setRemaining(result.retryAfter);
message.success(purpose === "login" ? "如该联系方式已验证且账号可用,验证码将发送至该联系方式" : "验证码已发送,10 分钟内有效");
} catch (error) { message.error(error instanceof Error ? error.message : "发送失败,请稍后重试"); }
finally { setSending(false); inFlight.current = false; }
};
return <div className="space-y-4">
{sms && <label className="block space-y-2"><span className="text-sm">手机号</span><Input size="large" value={value.phone} onChange={(e) => changeTarget("phone", e.target.value)} autoComplete="tel" inputMode="tel" placeholder="中国大陆手机号,支持 +86" required disabled={disabled || sending} /></label>}
{email && <label className="block space-y-2"><span className="text-sm">邮箱</span><Input size="large" type="email" value={value.email} onChange={(e) => changeTarget("email", e.target.value)} autoComplete="email" placeholder="请输入邮箱" required disabled={disabled || sending} /></label>}
<Button block size="large" loading={sending} disabled={disabled || remaining > 0} onClick={() => void send()}>{remaining > 0 ? `${remaining} 秒后可重新获取` : method === "sms_email" ? "获取短信及邮件验证码" : "获取验证码"}</Button>
{sms && <label className="block space-y-2"><span className="text-sm">短信验证码</span><Input size="large" value={value.smsCode} onChange={(e) => onChange({ ...value, smsCode: e.target.value.replace(/\D/g, "").slice(0, 6) })} autoComplete="one-time-code" inputMode="numeric" placeholder="6 位短信验证码" required disabled={disabled || sending} /></label>}
{email && <label className="block space-y-2"><span className="text-sm">邮件验证码</span><Input size="large" value={value.emailCode} onChange={(e) => onChange({ ...value, emailCode: e.target.value.replace(/\D/g, "").slice(0, 6) })} autoComplete="one-time-code" inputMode="numeric" placeholder="6 位邮件验证码" required disabled={disabled || sending} /></label>}
{method === "sms_email" && <p className="text-xs opacity-65">两项验证必须在本次注册中同时通过;修改联系方式后需要重新获取。</p>}
</div>;
}

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">
或

View File

@@ -6,12 +6,13 @@ import type { FeatureAvailability } from "@/stores/use-user-store";
import { http, apiBaseURL } from "@/services/api/request";
import type { PublicLogicalModel } from "@/services/api/logical-models";
import type { OSSConnectionTestInput, OSSConnectionTestResult, OSSProvider, S3Preset } from "@/lib/oss-settings";
import type { VerificationPolicy } from "./verification";
let authSessionRequest: Promise<AuthSessionPayload> | null = null;
let authSessionCache: { payload: AuthSessionPayload; expiresAt: number } | null = null;
function invalidateAuthSessionCache() {
export function invalidateAuthSessionCache() {
authSessionCache = null;
}
@@ -19,6 +20,9 @@ export type LocalUser = {
id: string;
username: string;
email?: string;
phone?: string;
emailVerifiedAt?: string;
phoneVerifiedAt?: string;
displayName: string;
avatarUrl?: string;
identityProvider?: string;
@@ -376,7 +380,7 @@ export type RuntimePolicySetting = {
};
export function getAuthSettings() {
return http.get<{ firstUser: boolean; registrationEnabled: boolean; linuxdoEnabled: boolean; emailEnabled: boolean; emailCodeRequired: boolean }>("/auth/settings");
return http.get<VerificationPolicy & { firstUser: boolean; registrationEnabled: boolean; linuxdoEnabled: boolean; emailEnabled: boolean; emailCodeRequired: boolean; smsBindingAvailable: boolean; emailBindingAvailable: boolean }>("/auth/settings");
}
export function linuxDOLoginURL(next: string, acceptedTerms?: boolean) {
@@ -435,8 +439,10 @@ export function resetPassword(input: { email: string; emailCode: string; passwor
return http.post<{ reset: boolean }>("/auth/password-reset", input);
}
export function register(input: { username: string; email?: string; emailCode?: string; displayName?: string; password: string; acceptedTerms: boolean }) {
return http.post<{ user: LocalUser }>("/auth/register", input);
export async function register(input: { username: string; email?: string; emailCode?: string; phone?: string; smsCode?: string; ticket?: string; displayName?: string; password: string; acceptedTerms: boolean }) {
const result = await http.post<{ user: LocalUser }>("/auth/register", input);
invalidateAuthSessionCache();
return result;
}
export async function logout() {

View File

@@ -0,0 +1,35 @@
import { http } from "./request";
import { invalidateAuthSessionCache, type LocalUser } from "./auth";
export type VerificationPolicy = {
smsLogin: boolean;
emailLogin: boolean;
smsRegistration: boolean;
emailRegistration: boolean;
smsAndEmailRegistration: boolean;
};
export type VerificationMethod = "sms" | "email" | "sms_email";
export type VerificationPurpose = "login" | "register" | "bind";
export type VerificationDraft = { ticket: string; email: string; phone: string; emailCode: string; smsCode: string };
export const emptyVerification: VerificationDraft = { ticket: "", email: "", phone: "", emailCode: "", smsCode: "" };
export const methodLabels: Record<VerificationMethod, string> = { sms: "短信验证码", email: "邮件验证码", sms_email: "短信 + 邮箱双重验证" };
export function verificationMethods(policy: VerificationPolicy, purpose: "login" | "register"): VerificationMethod[] {
if (purpose === "login") return [...(policy.smsLogin ? ["sms" as const] : []), ...(policy.emailLogin ? ["email" as const] : [])];
if (policy.smsAndEmailRegistration) return ["sms_email"];
return [...(policy.smsRegistration ? ["sms" as const] : []), ...(policy.emailRegistration ? ["email" as const] : [])];
}
export const startVerification = (input: { purpose: VerificationPurpose; method: VerificationMethod; email?: string; phone?: string; password?: string }) =>
http.post<{ ticket: string; expiresIn: number; retryAfter: number }>("/auth/verification", input);
export async function loginVerification(input: VerificationDraft) {
const result = await http.post<{ user: LocalUser }>("/auth/verification/login", input);
invalidateAuthSessionCache();
return result;
}
export async function bindVerification(input: VerificationDraft) {
const result = await http.post<{ user: LocalUser }>("/auth/verification/bind", input);
invalidateAuthSessionCache();
return result;
}
export const getVerificationPolicy = () => http.get<VerificationPolicy>("/admin/settings/auth-policy");
export const saveVerificationPolicy = (input: VerificationPolicy) => http.put<VerificationPolicy>("/admin/settings/auth-policy", input);