feat: add password recovery flow (#358)

This commit is contained in:
MasterChiefCN
2026-08-31 09:58:36 +08:00
committed by GitHub
parent 8ebceecbb6
commit e124a9ea04
19 changed files with 875 additions and 52 deletions

View File

@@ -141,7 +141,7 @@ export default function EmailSettingsPanel() {
form.setFieldsValue(toEmailFormValues(result.setting));
setDraftEnabled(result.setting.enabled);
setDirty(false);
message.success("注册邮件配置已保存");
message.success("邮件服务配置已保存");
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "保存邮件配置失败";
setSaveError(`${errorMessage}。未自动重试,请重新读取当前配置后再决定是否保存。`);
@@ -219,7 +219,7 @@ export default function EmailSettingsPanel() {
<MailCheck className="size-4" aria-hidden="true" />
</span>
<div>
<strong>{dirty ? "有未保存的邮件调整" : `注册验证码:${setting.enabled ? "已启用" : "未启用"}`}</strong>
<strong>{dirty ? "有未保存的邮件调整" : `账户邮件:${setting.enabled ? "已启用" : "未启用"}`}</strong>
<p>{dirty ? "完成当前配置后保存生效。" : formatSettingTime(setting.updatedAt, "使用系统默认值")}</p>
</div>
</div>
@@ -246,15 +246,15 @@ export default function EmailSettingsPanel() {
<SettingsSectionCard
className="admin-email-section admin-email-delivery-section"
icon={<Send className="size-4" aria-hidden="true" />}
title="1. 是否发送注册邮箱验证码"
description="这是邮件服务的主开关。关闭时不需要配置 SMTP;开启后再填写连接与发件信息。"
title="1. 是否发送账户安全邮件"
description="这是注册验证码和密码找回邮件的主开关。关闭时不需要配置 SMTP;开启后再填写连接与发件信息。"
status={<AdminStatusBadge label={draftEnabled ? (dirty && !setting.enabled ? "待启用" : "已启用") : dirty && setting.enabled ? "待停用" : "未启用"} tone={dirty ? "warning" : draftEnabled ? "success" : "neutral"} />}
footer={
!draftEnabled ? (
<>
<div className="admin-email-footer-note">
<BadgeCheck className="size-4" aria-hidden="true" />
<span>关闭后,普通邮箱注册不再要求邮箱验证码。</span>
<span>关闭后,普通邮箱注册和密码找回都无法发送验证码。</span>
</div>
<div className="flex flex-wrap items-center gap-2">
{dirty ? (
@@ -276,13 +276,13 @@ export default function EmailSettingsPanel() {
</span>
<div className="admin-email-policy-copy">
<div className="flex flex-wrap items-center gap-2">
<strong>发送注册邮箱验证码</strong>
<strong>发送注册与密码找回验证码</strong>
<AdminStatusBadge label="保存后生效" tone="info" />
</div>
<p>启用后,普通邮箱注册需要获取并校验 6 位验证码;邮件发送失败时不会创建可用验证码。</p>
<span>关闭只停止后续验证码邮件,不改变新用户注册开关,也不影响已有账号。</span>
<p>启用后,普通邮箱注册和密码找回使用 6 位验证码;邮件发送失败时不会保留可用验证码。</p>
<span>关闭只停止后续账户邮件,不改变新用户注册开关,也不影响已有登录会话。</span>
</div>
<Switch checked={draftEnabled} disabled={loading || refreshing || saving} aria-label="发送注册邮箱验证码" onChange={toggleEnabled} />
<Switch checked={draftEnabled} disabled={loading || refreshing || saving} aria-label="发送账户安全邮件" onChange={toggleEnabled} />
</div>
</SettingsSectionCard>
</div>
@@ -372,7 +372,7 @@ export default function EmailSettingsPanel() {
</div>
<div className="admin-email-form-section">
<FormSectionTitle icon={<AtSign className="size-4" />} title="发件人身份" description="这组名称和地址会显示在注册验证码邮件的 From 信息中。" />
<FormSectionTitle icon={<AtSign className="size-4" />} title="发件人身份" description="这组名称和地址会显示在注册与密码重置邮件的 From 信息中。" />
<div className="admin-email-form-grid">
<Form.Item
name="fromEmail"
@@ -458,7 +458,7 @@ function validateEmailDraft(values: EmailFormValues, setting: EmailSetting | nul
const draft = normalizeEmailFormValues(values);
if (!draft.enabled) return "";
if (/\r|\n/.test(draft.fromName)) return "发件人名称不能包含换行";
if (!draft.host || draft.port < 1 || draft.port > 65535 || !draft.fromEmail) return "启用注册邮件前请完整填写 SMTP 主机、端口和发件邮箱";
if (!draft.host || draft.port < 1 || draft.port > 65535 || !draft.fromEmail) return "启用邮件服务前请完整填写 SMTP 主机、端口和发件邮箱";
if (!isValidEmail(draft.fromEmail)) return "发件邮箱格式不正确";
if (draft.username && !draft.password && !setting?.hasPassword) return "SMTP 用户名已填写,请同时填写密码或服务商授权码";
return "";

View File

@@ -24,6 +24,11 @@ const authCopy = {
title: "建立你的创作空间",
description: "一个账号管理画布、素材、技能和模型偏好。",
},
recovery: {
eyebrow: "ACCOUNT RECOVERY",
title: "重新设置密码",
description: "验证账号邮箱后,设置一个新的登录密码。",
},
} as const;
export function LinuxDOIcon() {
@@ -43,23 +48,15 @@ export function AuthScene() {
const location = useLocation();
const navigate = useNavigate();
const reducedMotion = useReducedMotion();
const recovery = location.pathname === "/forgot-password";
const activeTab = location.pathname === "/register" ? "register" : "login";
const copy = activeTab === "register" ? authCopy.register : authCopy.login;
const copy = recovery ? authCopy.recovery : activeTab === "register" ? authCopy.register : authCopy.login;
return (
<main className="h-dvh min-h-0 overflow-y-auto bg-[#08090c] text-white lg:overflow-hidden">
<div className="grid min-h-full lg:h-full lg:grid-cols-[minmax(0,1.32fr)_minmax(520px,1fr)]">
<section className="relative min-h-[250px] overflow-hidden sm:min-h-[320px] lg:min-h-0" aria-label="影策品牌影片">
<video
className="absolute inset-0 size-full object-cover"
src={AUTH_VIDEO_URL}
poster={AUTH_VIDEO_POSTER}
autoPlay
muted
loop
playsInline
preload="metadata"
/>
<video className="absolute inset-0 size-full object-cover" src={AUTH_VIDEO_URL} poster={AUTH_VIDEO_POSTER} autoPlay muted loop playsInline preload="metadata" />
<div aria-hidden className="absolute inset-0 bg-[linear-gradient(180deg,rgba(4,5,8,.58),transparent_42%,rgba(4,5,8,.74))]" />
<div aria-hidden className="absolute inset-y-0 right-0 hidden w-[clamp(120px,14vw,240px)] bg-[linear-gradient(90deg,transparent_0%,rgba(11,12,16,.68)_58%,#0b0c10_100%)] lg:block" />
<div className="absolute inset-x-0 top-0 flex items-center justify-between gap-4 p-5 sm:p-7 lg:p-9">
@@ -80,14 +77,18 @@ export function AuthScene() {
>
<p className="text-xs font-semibold tracking-[0.18em] text-white/58">YINGCE STUDIO</p>
<h1 className="mt-3 max-w-xl text-3xl font-semibold leading-tight sm:text-4xl lg:text-5xl">
让一个故事,<br className="hidden sm:inline" />
让一个故事,
<br className="hidden sm:inline" />
从文字走向银幕。
</h1>
</motion.div>
</section>
<section className="relative flex min-h-[620px] items-start justify-center overflow-y-auto bg-[#0b0c10] px-4 pb-8 pt-20 sm:px-8 lg:min-h-0 lg:px-10 lg:pb-10 lg:pt-20">
<Link to="/" className="absolute right-5 top-5 z-20 inline-flex h-9 items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 text-xs text-white/58 backdrop-blur-xl transition hover:border-white/20 hover:bg-white/[0.08] hover:text-white lg:right-8 lg:top-8">
<Link
to="/"
className="absolute right-5 top-5 z-20 inline-flex h-9 items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 text-xs text-white/58 backdrop-blur-xl transition hover:border-white/20 hover:bg-white/[0.08] hover:text-white lg:right-8 lg:top-8"
>
<ArrowLeft className="size-3.5" />
返回首页
</Link>
@@ -101,20 +102,17 @@ export function AuthScene() {
>
<ConfigProvider theme={getAntThemeConfig(true)}>
<div className="auth-card-dark h-auto overflow-hidden rounded-lg bg-[#121318]/94 shadow-[0_28px_80px_rgba(0,0,0,.34)] backdrop-blur-2xl">
<section aria-label={copy.title} className={`flex flex-col ${activeTab === "login" ? "min-h-[500px]" : "min-h-[620px] sm:min-h-[640px]"}`}>
<section aria-label={copy.title} className={`flex flex-col ${recovery ? "min-h-[600px]" : activeTab === "login" ? "min-h-[500px]" : "min-h-[620px] sm:min-h-[640px]"}`}>
<header className="px-6 pb-5 pt-6 sm:px-8 sm:pt-7">
<p className="text-xs font-semibold tracking-[0.18em] text-blue-300/80">{copy.eyebrow}</p>
<h2 className="mt-2 text-3xl font-semibold">{copy.title}</h2>
<p className="mt-2 text-sm leading-6 text-white/45">{copy.description}</p>
</header>
<div className="px-6 sm:px-8">
<Tabs
className="auth-card-tabs"
activeKey={activeTab}
items={AUTH_TABS}
onChange={(key) => navigate({ pathname: key === "register" ? "/register" : "/login", search: location.search })}
/>
</div>
{!recovery ? (
<div className="px-6 sm:px-8">
<Tabs className="auth-card-tabs" activeKey={activeTab} items={AUTH_TABS} onChange={(key) => navigate({ pathname: key === "register" ? "/register" : "/login", search: location.search })} />
</div>
) : null}
<div key={location.pathname} className="flex-1 px-6 py-6 sm:px-8 sm:py-7">
<Outlet />
</div>

View File

@@ -0,0 +1,217 @@
import { type FormEvent, useEffect, useState, type ReactNode } from "react";
import { App, Button, Input } from "antd";
import { ArrowLeft, ArrowRight, LockKeyhole, Mail, ShieldCheck, TriangleAlert } from "lucide-react";
import { Link, useNavigate, useSearchParams } from "react-router";
import { getAuthSettings, resetPassword, sendPasswordResetEmailCode } from "@/services/api/auth";
type RecoveryStage = "request" | "reset";
export default function ForgotPasswordPage() {
const navigate = useNavigate();
const [params] = useSearchParams();
const { message } = App.useApp();
const [stage, setStage] = useState<RecoveryStage>("request");
const [email, setEmail] = useState("");
const [emailCode, setEmailCode] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [emailEnabled, setEmailEnabled] = useState<boolean | null>(null);
const [sendingCode, setSendingCode] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [countdown, setCountdown] = useState(0);
const next = safeNext(params.get("next"));
const loginURL = `/login?next=${encodeURIComponent(next)}`;
useEffect(() => {
let cancelled = false;
void getAuthSettings()
.then((settings) => !cancelled && setEmailEnabled(settings.emailEnabled))
.catch(() => !cancelled && setEmailEnabled(null));
return () => {
cancelled = true;
};
}, []);
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 (advance: boolean) => {
const normalizedEmail = email.trim();
if (!normalizedEmail) {
message.warning("请先输入邮箱");
return;
}
setSendingCode(true);
try {
await sendPasswordResetEmailCode(normalizedEmail);
setEmail(normalizedEmail);
setCountdown(60);
if (advance) setStage("reset");
message.success("如果该邮箱已绑定可找回的账号,验证码将发送到邮箱");
} catch (error) {
message.error(error instanceof Error ? error.message : "发送验证码失败");
} finally {
setSendingCode(false);
}
};
const requestCode = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
void sendCode(true);
};
const submitReset = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (password !== confirmPassword) {
message.error("两次输入的密码不一致");
return;
}
setSubmitting(true);
try {
await resetPassword({ email: email.trim(), emailCode, password });
message.success("密码已重置,请使用新密码登录");
navigate(loginURL, { replace: true });
} catch (error) {
message.error(error instanceof Error ? error.message : "密码重置失败");
} finally {
setSubmitting(false);
}
};
const editEmail = () => {
setStage("request");
setEmailCode("");
setPassword("");
setConfirmPassword("");
setCountdown(0);
};
if (stage === "request") {
return (
<form onSubmit={requestCode} className="space-y-5">
{emailEnabled === false ? <Notice icon={<TriangleAlert className="size-3.5" />}>管理员尚未启用密码找回,请联系管理员处理。</Notice> : null}
<AuthField label="账号邮箱" htmlFor="recovery-email">
<Input
id="recovery-email"
size="large"
prefix={<Mail className="size-4 text-white/35" />}
value={email}
onChange={(event) => setEmail(event.target.value)}
placeholder="请输入绑定邮箱"
autoComplete="email"
inputMode="email"
required
disabled={emailEnabled === false}
/>
</AuthField>
<Button type="primary" htmlType="submit" size="large" block loading={sendingCode} disabled={emailEnabled === false} icon={<ArrowRight className="size-4" />} iconPlacement="end">
发送验证码
</Button>
<BackToLogin to={loginURL} />
</form>
);
}
return (
<form onSubmit={submitReset} className="space-y-4">
<AuthField label="账号邮箱" htmlFor="recovery-email-confirm">
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-2">
<Input id="recovery-email-confirm" size="large" prefix={<Mail className="size-4 text-white/35" />} value={email} readOnly autoComplete="email" />
<Button htmlType="button" size="large" onClick={editEmail}>
修改邮箱
</Button>
</div>
</AuthField>
<AuthField label="邮箱验证码" htmlFor="recovery-code">
<div className="grid grid-cols-[minmax(0,1fr)_116px] gap-2">
<Input
id="recovery-code"
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
/>
<Button htmlType="button" size="large" loading={sendingCode} disabled={countdown > 0} onClick={() => void sendCode(false)}>
{countdown > 0 ? `${countdown}s` : "重新发送"}
</Button>
</div>
</AuthField>
<div className="grid gap-4 sm:grid-cols-2">
<AuthField label="新密码" htmlFor="recovery-password">
<Input.Password
id="recovery-password"
size="large"
prefix={<LockKeyhole className="size-4 text-white/35" />}
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="至少 8 位"
autoComplete="new-password"
minLength={8}
required
/>
</AuthField>
<AuthField label="确认密码" htmlFor="recovery-confirm-password">
<Input.Password
id="recovery-confirm-password"
size="large"
prefix={<LockKeyhole className="size-4 text-white/35" />}
value={confirmPassword}
onChange={(event) => setConfirmPassword(event.target.value)}
placeholder="再次输入密码"
autoComplete="new-password"
minLength={8}
required
/>
</AuthField>
</div>
<Button type="primary" htmlType="submit" size="large" block loading={submitting} icon={<ArrowRight className="size-4" />} iconPlacement="end">
重置密码
</Button>
<BackToLogin to={loginURL} />
</form>
);
}
function AuthField({ label, htmlFor, children }: { label: string; htmlFor: string; children: ReactNode }) {
return (
<div className="space-y-2">
<label htmlFor={htmlFor} className="block text-xs font-medium text-white/62">
{label}
</label>
{children}
</div>
);
}
function BackToLogin({ to }: { to: string }) {
return (
<div className="text-center">
<Link to={to} className="inline-flex min-h-8 items-center gap-1.5 rounded-sm text-xs text-white/50 transition-colors hover:text-white/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-300/45">
<ArrowLeft className="size-3.5" />
返回登录
</Link>
</div>
);
}
function Notice({ icon, children }: { icon: ReactNode; children: ReactNode }) {
return (
<div className="flex items-start gap-2 rounded-lg border border-amber-300/15 bg-amber-300/[0.06] px-3 py-2.5 text-xs leading-5 text-amber-100/78">
<span className="mt-0.5 shrink-0">{icon}</span>
{children}
</div>
);
}
function safeNext(value: string | null) {
if (!value || !value.startsWith("/") || value.startsWith("//")) return "/create";
return value;
}

View File

@@ -1,7 +1,7 @@
import { type FormEvent, useEffect, useState, type ReactNode } from "react";
import { App, Button, Divider, Input } from "antd";
import { ArrowRight, LockKeyhole, UserRound } from "lucide-react";
import { useNavigate, useSearchParams } from "react-router";
import { Link, useNavigate, useSearchParams } from "react-router";
import { applyUserSession } from "@/lib/user-session";
import { getAuthSession, getAuthSettings, linuxDOLoginURL, login } from "@/services/api/auth";
@@ -17,6 +17,7 @@ export default function LoginPage() {
const [submitting, setSubmitting] = useState(false);
const [linuxdoEnabled, setLinuxdoEnabled] = useState(false);
const next = safeNext(params.get("next"));
const forgotPasswordURL = `/forgot-password?next=${encodeURIComponent(next)}`;
const user = useUserStore((state) => state.user);
const hydrated = useUserStore((state) => state.hydrated);
@@ -28,7 +29,9 @@ export default function LoginPage() {
}, [hydrated, user, next, navigate]);
useEffect(() => {
void getAuthSettings().then((settings) => setLinuxdoEnabled(settings.linuxdoEnabled)).catch(() => undefined);
void getAuthSettings()
.then((settings) => setLinuxdoEnabled(settings.linuxdoEnabled))
.catch(() => undefined);
const oauthError = params.get("oauth_error");
if (oauthError) message.error(oauthError);
}, [message, params]);
@@ -50,16 +53,61 @@ export default function LoginPage() {
return (
<form onSubmit={submit} className="space-y-5">
<AuthField label="用户名 / 邮箱"><Input size="large" prefix={<UserRound className="size-4 text-white/35" />} value={username} onChange={(event) => setUsername(event.target.value)} placeholder="用户名或邮箱" autoComplete="username" required /></AuthField>
<AuthField label="密码"><Input.Password size="large" prefix={<LockKeyhole className="size-4 text-white/35" />} value={password} onChange={(event) => setPassword(event.target.value)} placeholder="请输入密码" autoComplete="current-password" required /></AuthField>
<Button type="primary" htmlType="submit" size="large" block loading={submitting} icon={<ArrowRight className="size-4" />} iconPlacement="end">登录</Button>
{linuxdoEnabled ? <><Divider plain className="!border-white/10 !text-white/30">或</Divider><Button size="large" block icon={<LinuxDOIcon />} href={linuxDOLoginURL(next)}>使用 Linux.do 登录</Button></> : null}
<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>
<AuthField
label="密码"
htmlFor="login-password"
action={
<Link
to={forgotPasswordURL}
className="-my-2 inline-flex min-h-8 items-center rounded-sm text-xs font-medium text-blue-300/80 transition-colors hover:text-blue-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-300/45"
>
忘记密码?
</Link>
}
>
<Input.Password
id="login-password"
size="large"
prefix={<LockKeyhole className="size-4 text-white/35" />}
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="请输入密码"
autoComplete="current-password"
required
/>
</AuthField>
<Button type="primary" htmlType="submit" size="large" block loading={submitting} icon={<ArrowRight className="size-4" />} iconPlacement="end">
登录
</Button>
{linuxdoEnabled ? (
<>
<Divider plain className="!border-white/10 !text-white/30">
或
</Divider>
<Button size="large" block icon={<LinuxDOIcon />} href={linuxDOLoginURL(next)}>
使用 Linux.do 登录
</Button>
</>
) : null}
</form>
);
}
function AuthField({ label, children }: { label: string; children: ReactNode }) {
return <label className="block space-y-2"><span className="text-xs font-medium text-white/62">{label}</span>{children}</label>;
function AuthField({ label, htmlFor, action, children }: { label: string; htmlFor: string; action?: ReactNode; children: ReactNode }) {
return (
<div className="space-y-2">
<div className="flex items-center justify-between gap-3">
<label htmlFor={htmlFor} className="text-xs font-medium text-white/62">
{label}
</label>
{action}
</div>
{children}
</div>
);
}
function safeNext(value: string | null) {

View File

@@ -34,6 +34,7 @@ const UsersPage = lazy(() => import("@/pages/admin/users/users-page"));
const AssetsPage = lazy(loadAssetsPage);
const LoginPage = lazy(() => import("@/pages/auth/login"));
const RegisterPage = lazy(() => import("@/pages/auth/register"));
const ForgotPasswordPage = lazy(() => import("@/pages/auth/forgot-password"));
const CanvasPage = lazy(loadCanvasPage);
const CanvasProjectPage = lazy(() => import("@/pages/canvas/project"));
const SharedCanvasPage = lazy(() => import("@/pages/canvas/shared"));
@@ -82,6 +83,7 @@ export const router = createBrowserRouter([
children: [
{ path: "/login", element: fullScreenDeferred(<LoginPage />) },
{ path: "/register", element: fullScreenDeferred(<RegisterPage />) },
{ path: "/forgot-password", element: fullScreenDeferred(<ForgotPasswordPage />) },
],
},
{ path: "/share/canvas/:token", element: fullScreenDeferred(<SharedCanvasPage />), errorElement: <RouteErrorPage /> },
@@ -107,8 +109,22 @@ export const router = createBrowserRouter([
},
{ path: "/assets", element: <RequireAuth>{deferred(<AssetsPage />)}</RequireAuth> },
{ path: "/skills", element: <RequireAuth>{deferred(<SkillsPage />)}</RequireAuth> },
{ path: "/plugins", element: <RequireAuth><RequireFeature feature="pluginCenterEnabled">{deferred(<PluginsPage />)}</RequireFeature></RequireAuth> },
{ path: "/plugins/eagle", element: <RequireAuth><RequireFeature feature="pluginCenterEnabled">{deferred(<EagleLibraryPage />)}</RequireFeature></RequireAuth> },
{
path: "/plugins",
element: (
<RequireAuth>
<RequireFeature feature="pluginCenterEnabled">{deferred(<PluginsPage />)}</RequireFeature>
</RequireAuth>
),
},
{
path: "/plugins/eagle",
element: (
<RequireAuth>
<RequireFeature feature="pluginCenterEnabled">{deferred(<EagleLibraryPage />)}</RequireFeature>
</RequireAuth>
),
},
{
path: "/wallet",
element: (

View File

@@ -417,6 +417,14 @@ export function sendRegistrationEmailCode(email: string) {
return request<{ sent: boolean }>(api.post("/auth/email-code", { email }));
}
export function sendPasswordResetEmailCode(email: string) {
return request<{ sent: boolean }>(api.post("/auth/password-reset-code", { email }));
}
export function resetPassword(input: { email: string; emailCode: string; password: string }) {
return request<{ reset: boolean }>(api.post("/auth/password-reset", input));
}
export function register(input: { username: string; email?: string; emailCode?: string; displayName?: string; password: string }) {
return request<{ user: LocalUser }>(api.post("/auth/register", input));
}