feat(auth): 登录注册 - 添加短信通知与组合验证支持
This commit is contained in:
45
web/src/components/auth/verification-fields.tsx
Normal file
45
web/src/components/auth/verification-fields.tsx
Normal 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>;
|
||||
}
|
||||
@@ -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">
|
||||
或
|
||||
|
||||
@@ -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() {
|
||||
|
||||
35
web/src/services/api/verification.ts
Normal file
35
web/src/services/api/verification.ts
Normal 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);
|
||||
Reference in New Issue
Block a user