www_site/components/auth-form.tsx

138 lines
9.4 KiB
TypeScript
Raw Normal View History

"use client";
import Link from "next/link";
import { FormEvent, useEffect, useState } from "react";
import { PreviewNotice } from "@/components/ui";
import { BrandIcon } from "@/components/brand-icon";
const apiBase = process.env.NEXT_PUBLIC_API_BASE_URL ?? "/api/v1";
export function AuthForm({ mode }: { mode: "login" | "register" }) {
const isLogin = mode === "login";
const [csrf, setCsrf] = useState("");
const [email, setEmail] = useState("");
const [username, setUsername] = useState("");
const [phone, setPhone] = useState("");
const [channel, setChannel] = useState<"email" | "phone">("email");
const [password, setPassword] = useState("");
const [confirmation, setConfirmation] = useState("");
const [verificationId, setVerificationId] = useState("");
const [verificationCode, setVerificationCode] = useState("");
const [captchaId, setCaptchaId] = useState("");
const [captchaImage, setCaptchaImage] = useState("");
const [captchaCode, setCaptchaCode] = useState("");
const [message, setMessage] = useState("");
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
useEffect(() => {
fetch(`${apiBase}/auth/csrf`, { credentials: "include" })
.then((response) => response.json())
.then((body) => setCsrf(body.csrf_token ?? ""))
.catch(() => setError("暂时无法连接认证服务"));
}, []);
async function refreshCaptcha() {
setCaptchaCode("");
const response = await fetch(`${apiBase}/auth/captcha`, { credentials: "include" });
const body = await response.json();
setCaptchaId(body.captcha_id ?? "");
setCaptchaImage(body.image ?? "");
}
useEffect(() => { refreshCaptcha().catch(() => setError("暂时无法加载图形验证码")); }, []);
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setError("");
setMessage("");
if (!isLogin && password !== confirmation) {
setError("两次输入的密码不一致");
return;
}
setBusy(true);
try {
const response = await fetch(`${apiBase}/auth/${isLogin ? "login" : "register"}`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json", ...(csrf ? { "X-CSRF-Token": csrf } : {}) },
body: JSON.stringify(isLogin ? { identifier: email, password, captcha_id: captchaId, captcha_code: captchaCode } : { username, email: channel === "email" ? email : email || null, phone: phone || null, verification_channel: channel, password, captcha_id: captchaId, captcha_code: captchaCode }),
});
const body = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(body.error?.message ?? "请求失败,请稍后重试");
if (isLogin) {
window.location.assign("/account");
} else {
setVerificationId(body.challenge_id ?? "");
setMessage(`注册成功,验证码已发送到你的${channel === "email" ? "邮箱" : "手机"}`);
}
} catch (submitError) {
setError(submitError instanceof Error ? submitError.message : "请求失败,请稍后重试");
refreshCaptcha().catch(() => undefined);
} finally {
setBusy(false);
}
}
async function confirmVerification(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setError("");
setBusy(true);
try {
const response = await fetch(`${apiBase}/auth/verification/confirm-registration`, {
method: "POST", credentials: "include", headers: { "Content-Type": "application/json", ...(csrf ? { "X-CSRF-Token": csrf } : {}) },
body: JSON.stringify({ challenge_id: verificationId, code: verificationCode }),
});
const body = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(body.error?.message ?? "验证码错误,请重试");
setMessage("验证成功,请登录");
window.setTimeout(() => window.location.assign("/login"), 500);
} catch (confirmError) {
setError(confirmError instanceof Error ? confirmError.message : "验证失败,请重试");
} finally { setBusy(false); }
}
async function resendVerification() {
setError("");
setBusy(true);
try {
const response = await fetch(`${apiBase}/auth/verification/resend`, {
method: "POST", credentials: "include", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ challenge_id: verificationId }),
});
const body = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(body.error?.message ?? "验证码发送失败,请稍后重试");
setMessage("新的验证码已发送");
} catch (resendError) {
setError(resendError instanceof Error ? resendError.message : "验证码发送失败,请稍后重试");
} finally { setBusy(false); }
}
return (
<div className="w-full max-w-md rounded-2xl border border-line bg-panel/70 p-6 sm:p-8">
<PreviewNotice></PreviewNotice>
{verificationId ? <form className="mt-8 space-y-5" onSubmit={confirmVerification}>
<div><label className="field-label" htmlFor="verification-code"></label><input className="field-input" id="verification-code" inputMode="numeric" maxLength={6} onChange={(event) => setVerificationCode(event.target.value)} placeholder="输入 6 位验证码" required value={verificationCode} /></div>
{error ? <p className="rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm leading-6 text-danger" role="alert">{error}</p> : null}
<button className="button-primary w-full" disabled={busy || !csrf} type="submit"><BrandIcon />{busy ? "验证中…" : "验证邮箱"}</button>
<button className="w-full text-sm text-muted hover:text-copy" disabled={busy} onClick={resendVerification} type="button"></button>
</form> : <form className="mt-8 space-y-5" onSubmit={submit}>
{isLogin ? <div><label className="field-label" htmlFor="email"></label><input className="field-input" id="email" onChange={(event) => setEmail(event.target.value)} placeholder="name@example.com / 13800138000" required value={email} /></div> : <>
<div className="grid grid-cols-2 gap-2 rounded-lg border border-line p-1"><button className={`rounded-md px-3 py-2 text-sm ${channel === "email" ? "bg-cyan text-slate-950" : "text-muted"}`} onClick={() => setChannel("email")} type="button"></button><button className={`rounded-md px-3 py-2 text-sm ${channel === "phone" ? "bg-cyan text-slate-950" : "text-muted"}`} onClick={() => setChannel("phone")} type="button"></button></div>
<div><label className="field-label" htmlFor="username"></label><input className="field-input" id="username" onChange={(event) => setUsername(event.target.value)} placeholder="3-32 位字母、数字、下划线或短横线" required value={username} /></div>
<div><label className="field-label" htmlFor="email">{channel === "email" ? "(必填)" : "(可选)"}</label><input className="field-input" id="email" onChange={(event) => setEmail(event.target.value)} placeholder="name@example.com" required={channel === "email"} type="email" value={email} /></div>
<div><label className="field-label" htmlFor="phone">{channel === "phone" ? "(必填)" : "(可选)"}</label><input className="field-input" id="phone" onChange={(event) => setPhone(event.target.value)} placeholder="中国大陆手机号" required={channel === "phone"} type="tel" value={phone} /></div>
</>}
<div className="flex items-center gap-3"><img alt="图形验证码" className="h-16 w-[170px] rounded-lg border border-line bg-slate-950" src={captchaImage ? `data:image/svg+xml,${encodeURIComponent(captchaImage)}` : undefined} /><button className="text-sm text-muted hover:text-copy" onClick={refreshCaptcha} type="button"></button></div>
<input className="field-input" onChange={(event) => setCaptchaCode(event.target.value)} placeholder="输入图形验证码" required value={captchaCode} />
<div><label className="field-label" htmlFor="password"></label><input className="field-input" id="password" minLength={8} onChange={(event) => setPassword(event.target.value)} placeholder="至少 8 位字符" required type="password" value={password} /></div>
{!isLogin ? <div><label className="field-label" htmlFor="password-confirm"></label><input className="field-input" id="password-confirm" minLength={8} onChange={(event) => setConfirmation(event.target.value)} placeholder="再次输入密码" required type="password" value={confirmation} /></div> : null}
{error ? <p className="rounded-lg border border-danger/30 bg-danger/10 px-3 py-2 text-sm leading-6 text-danger" role="alert">{error}</p> : null}
{message ? <p className="rounded-lg border border-success/30 bg-success/10 px-3 py-2 text-sm leading-6 text-success" role="status">{message}</p> : null}
<button className="button-primary w-full" disabled={busy || !csrf} type="submit"><BrandIcon />{busy ? "处理中…" : isLogin ? "登录" : "注册"}</button>
</form>}
<p className="mt-6 text-center text-sm text-muted">{isLogin ? "还没有账户?" : "已有账户?"}{" "}<Link className="text-cyan hover:text-copy" href={isLogin ? "/register" : "/login"}>{isLogin ? "去注册" : "去登录"}</Link></p>
</div>
);
}