Files
ttstd-infinite-canvas/web/src/pages/auth/register.tsx

260 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { useNavigate, useSearchParams } from "react-router";
import { getAuthSession, getAuthSettings, linuxDOLoginURL, register, sendRegistrationEmailCode } from "@/services/api/auth";
import { LinuxDOIcon } from "./auth-scene";
import { ApiError } from "@/services/api/request";
type AuthSettings = Awaited<ReturnType<typeof getAuthSettings>>;
// TODO: 由维护者填写正式的影策服务协议条款后再发布。
const SERVICE_AGREEMENT: { title: string; content: string }[] = [];
export default function RegisterPage() {
const navigate = useNavigate();
const [params] = useSearchParams();
const { message } = App.useApp();
const [settings, setSettings] = useState<AuthSettings | null>(null);
const [username, setUsername] = useState("");
const [email, setEmail] = useState("");
const [emailCode, setEmailCode] = useState("");
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 next = safeNext(params.get("next"));
useEffect(() => {
let cancelled = false;
void getAuthSettings()
.then((value) => !cancelled && setSettings(value))
.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;
if (!agreementAccepted) {
message.warning("请先同意影策服务协议");
return;
}
if (password !== confirmPassword) {
message.error("两次输入的密码不一致");
return;
}
registering.current = true;
setSubmitting(true);
try {
await register({ username, email, emailCode, 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");
message.success(settings?.firstUser ? "管理员账号已创建" : "注册成功");
navigate(next, { replace: true });
} catch (error) {
if (error instanceof ApiError && error.status === 429) setRegisterCountdown(Math.max(1, Math.ceil((error.retryAfterMs ?? 60000) / 1000)));
message.error(error instanceof Error ? error.message : "注册失败");
} finally {
registering.current = false;
setSubmitting(false);
}
};
useEffect(() => {
if (registerCountdown <= 0) return;
const timer = window.setInterval(() => setRegisterCountdown((value) => Math.max(0, value - 1)), 1000);
return () => window.clearInterval(timer);
}, [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);
return (
<form onSubmit={submit} className="space-y-4">
{settings?.firstUser ? (
<Notice icon={<Info className="size-3.5" />} tone="blue">
首个账号自动成为管理员,邮箱验证码暂不要求。
</Notice>
) : null}
{registrationClosed ? (
<Notice icon={<TriangleAlert className="size-3.5" />} tone="amber">
当前已关闭普通注册,请联系管理员创建账号。
</Notice>
) : null}
{mailUnavailable ? (
<Notice icon={<TriangleAlert className="size-3.5" />} tone="amber">
管理员尚未配置注册邮件,普通邮箱注册暂不可用。
</Notice>
) : null}
<div className="grid gap-4 sm:grid-cols-2">
<AuthField label="用户名">
<Input size="large" prefix={<UserRound className="size-4 text-white/35" />} value={username} onChange={(event) => setUsername(event.target.value)} placeholder="3-32 位字符" autoComplete="username" required disabled={disabled} />
</AuthField>
<AuthField label="显示名称">
<Input size="large" value={displayName} onChange={(event) => setDisplayName(event.target.value)} placeholder="不填则使用用户名" disabled={disabled} />
</AuthField>
</div>
<AuthField label="邮箱">
<Input
size="large"
prefix={<Mail className="size-4 text-white/35" />}
value={email}
onChange={(event) => setEmail(event.target.value)}
placeholder="用于登录与安全验证"
autoComplete="email"
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}
<div className="grid gap-4 sm:grid-cols-2">
<AuthField label="密码">
<Input.Password
size="large"
prefix={<LockKeyhole className="size-4 text-white/35" />}
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="至少 8 位"
autoComplete="new-password"
required
disabled={disabled}
/>
</AuthField>
<AuthField label="确认密码">
<Input.Password
size="large"
prefix={<LockKeyhole className="size-4 text-white/35" />}
value={confirmPassword}
onChange={(event) => setConfirmPassword(event.target.value)}
placeholder="再次输入密码"
autoComplete="new-password"
required
disabled={disabled}
/>
</AuthField>
</div>
<div className="flex items-start gap-2 text-xs leading-5 text-white/55">
<Checkbox checked={agreementAccepted} onChange={(event) => setAgreementAccepted(event.target.checked)}>
<span className="text-xs leading-5 text-white/55">我已阅读并同意</span>
</Checkbox>
<button type="button" className="-ml-1 text-xs leading-5 text-blue-300/85 transition hover:text-blue-200" onClick={() => setAgreementOpen(true)}>
《影策服务协议》
</button>
</div>
<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 ? (
<>
<Divider plain className="!border-white/10 !text-white/30">
或
</Divider>
<Button size="large" block disabled={!agreementAccepted} icon={<LinuxDOIcon />} href={agreementAccepted ? linuxDOLoginURL(next, true) : undefined}>
使用 Linux.do 注册 / 登录
</Button>
</>
) : null}
<Modal className="workspace-modal workspace-modal-compact" title="影策服务协议" open={agreementOpen} onCancel={() => setAgreementOpen(false)} footer={null} destroyOnHidden>
<div className="max-h-96 space-y-4 overflow-y-auto pr-1 text-sm leading-6 text-foreground/68">
{SERVICE_AGREEMENT.length === 0 ? <p className="m-0">服务协议内容待补充。</p> : null}
{SERVICE_AGREEMENT.map((item) => (
<section key={item.title}>
<h3 className="m-0 text-sm font-semibold text-foreground">{item.title}</h3>
<p className="mt-1.5 mb-0">{item.content}</p>
</section>
))}
</div>
</Modal>
</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 Notice({ icon, tone, children }: { icon: ReactNode; tone: "blue" | "amber"; children: ReactNode }) {
return (
<div className={`flex items-start gap-2 rounded-lg border px-3 py-2.5 text-xs leading-5 ${tone === "blue" ? "border-blue-300/15 bg-blue-300/[0.06] text-blue-100/78" : "border-amber-300/15 bg-amber-300/[0.06] 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 "/";
return value;
}