feat: add password recovery flow (#358)
This commit is contained in:
@@ -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 "";
|
||||
|
||||
@@ -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>
|
||||
|
||||
217
web/src/pages/auth/forgot-password.tsx
Normal file
217
web/src/pages/auth/forgot-password.tsx
Normal 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;
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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: (
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user