379 lines
16 KiB
TypeScript
379 lines
16 KiB
TypeScript
/**
|
|
* 认证表单岛:登录 / 注册 / 找回 / 重置 / 改密,按 `mode` 切换。
|
|
* 默认登录方式:用户名 + 密码。
|
|
* Provider 包在组件内部。
|
|
*/
|
|
import { useState } from "react";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import { trpc, createTRPCReactClient } from "../lib/trpc";
|
|
import { go } from "../lib/nav";
|
|
import type { Locale } from "@app/i18n";
|
|
import { t } from "@app/i18n";
|
|
import {
|
|
Button,
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
Input,
|
|
Label,
|
|
toast,
|
|
} from "@app/ui";
|
|
|
|
const queryClient = new QueryClient({
|
|
defaultOptions: {
|
|
queries: { retry: false, refetchOnWindowFocus: false },
|
|
},
|
|
});
|
|
const trpcClient = createTRPCReactClient();
|
|
|
|
export type AuthFormMode = "login" | "register" | "forgot" | "reset" | "change";
|
|
|
|
export interface AuthFormProps {
|
|
locale: Locale;
|
|
mode: AuthFormMode;
|
|
/** 登录成功后的跳转地址 */
|
|
next?: string | undefined;
|
|
/** 重置页预填令牌 */
|
|
defaultToken?: string | undefined;
|
|
}
|
|
|
|
export default function AuthForm(props: AuthFormProps) {
|
|
return (
|
|
<QueryClientProvider client={queryClient}>
|
|
<trpc.Provider client={trpcClient} queryClient={queryClient}>
|
|
<AuthFormInner {...props} />
|
|
</trpc.Provider>
|
|
</QueryClientProvider>
|
|
);
|
|
}
|
|
|
|
function AuthFormInner({ locale, mode, next, defaultToken }: AuthFormProps) {
|
|
const [username, setUsername] = useState("");
|
|
const [name, setName] = useState("");
|
|
const [email, setEmail] = useState("");
|
|
const [password, setPassword] = useState("");
|
|
const [currentPassword, setCurrentPassword] = useState("");
|
|
const [newPassword, setNewPassword] = useState("");
|
|
const [token, setToken] = useState(defaultToken ?? "");
|
|
const [resetToken, setResetToken] = useState<string | null>(null);
|
|
|
|
const login = trpc.auth.login.useMutation({
|
|
onSuccess: () => {
|
|
toast.success(t(locale, "auth.msg.loginOk"));
|
|
go(next?.startsWith("/") ? next : home(locale));
|
|
},
|
|
onError: (err) => toast.error(err.message || t(locale, "auth.err.generic")),
|
|
});
|
|
|
|
const register = trpc.auth.register.useMutation({
|
|
onSuccess: () => {
|
|
toast.success(t(locale, "auth.msg.registerOk"));
|
|
go(next?.startsWith("/") ? next : home(locale));
|
|
},
|
|
onError: (err) => toast.error(err.message || t(locale, "auth.err.generic")),
|
|
});
|
|
|
|
const requestReset = trpc.auth.requestPasswordReset.useMutation({
|
|
onSuccess: (data) => {
|
|
toast.success(t(locale, "auth.msg.resetRequested"));
|
|
if (data.resetToken) {
|
|
setResetToken(data.resetToken);
|
|
setToken(data.resetToken);
|
|
}
|
|
},
|
|
onError: (err) => toast.error(err.message || t(locale, "auth.err.generic")),
|
|
});
|
|
|
|
const resetPassword = trpc.auth.resetPassword.useMutation({
|
|
onSuccess: () => {
|
|
toast.success(t(locale, "auth.msg.resetOk"));
|
|
go(`${home(locale)}/auth/login`);
|
|
},
|
|
onError: (err) => toast.error(err.message || t(locale, "auth.err.generic")),
|
|
});
|
|
|
|
const changePassword = trpc.auth.changePassword.useMutation({
|
|
onSuccess: () => {
|
|
toast.success(t(locale, "auth.msg.passwordOk"));
|
|
setCurrentPassword("");
|
|
setNewPassword("");
|
|
},
|
|
onError: (err) => toast.error(err.message || t(locale, "auth.err.generic")),
|
|
});
|
|
|
|
const busy =
|
|
login.isPending ||
|
|
register.isPending ||
|
|
requestReset.isPending ||
|
|
resetPassword.isPending ||
|
|
changePassword.isPending;
|
|
|
|
function onSubmit(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
if (busy) return;
|
|
if (mode === "login") {
|
|
login.mutate({ username, password });
|
|
} else if (mode === "register") {
|
|
register.mutate({
|
|
username,
|
|
password,
|
|
name: name.trim() || undefined,
|
|
email: email.trim() || undefined,
|
|
});
|
|
} else if (mode === "forgot") {
|
|
requestReset.mutate({ email });
|
|
} else if (mode === "reset") {
|
|
resetPassword.mutate({ token, newPassword });
|
|
} else if (mode === "change") {
|
|
changePassword.mutate({ currentPassword, newPassword });
|
|
}
|
|
}
|
|
|
|
const titles: Record<AuthFormMode, { title: string; desc: string }> = {
|
|
login: {
|
|
title: t(locale, "auth.login.heading"),
|
|
desc: t(locale, "auth.login.subtitle"),
|
|
},
|
|
register: {
|
|
title: t(locale, "auth.register.heading"),
|
|
desc: t(locale, "auth.register.subtitle"),
|
|
},
|
|
forgot: {
|
|
title: t(locale, "auth.forgot.heading"),
|
|
desc: t(locale, "auth.forgot.subtitle"),
|
|
},
|
|
reset: {
|
|
title: t(locale, "auth.reset.heading"),
|
|
desc: t(locale, "auth.reset.subtitle"),
|
|
},
|
|
change: {
|
|
title: t(locale, "auth.settings.changeHeading"),
|
|
desc: t(locale, "auth.settings.hint"),
|
|
},
|
|
};
|
|
|
|
const homeBase = home(locale);
|
|
|
|
return (
|
|
<Card className={`w-full ${mode === "change" ? "max-w-2xl" : "max-w-md"}`}>
|
|
<CardHeader>
|
|
<CardTitle>{titles[mode].title}</CardTitle>
|
|
<CardDescription>{titles[mode].desc}</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<form onSubmit={onSubmit} className="space-y-4">
|
|
{(mode === "login" || mode === "register") && (
|
|
<div className="space-y-2">
|
|
<Label htmlFor="username">{t(locale, "auth.field.username")}</Label>
|
|
<Input
|
|
id="username"
|
|
name="username"
|
|
autoComplete="username"
|
|
required
|
|
minLength={3}
|
|
maxLength={32}
|
|
value={username}
|
|
onChange={(e) => setUsername(e.target.value)}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{mode === "register" && (
|
|
<div className="space-y-2">
|
|
<Label htmlFor="name">{t(locale, "auth.field.name")}</Label>
|
|
<Input
|
|
id="name"
|
|
name="name"
|
|
autoComplete="name"
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{(mode === "register" || mode === "forgot") && (
|
|
<div className="space-y-2">
|
|
<Label htmlFor="email">
|
|
{mode === "forgot"
|
|
? t(locale, "auth.forgot.emailLabel")
|
|
: t(locale, "auth.field.email")}
|
|
</Label>
|
|
<Input
|
|
id="email"
|
|
name="email"
|
|
type="email"
|
|
autoComplete="email"
|
|
required={mode === "forgot"}
|
|
value={email}
|
|
onChange={(e) => setEmail(e.target.value)}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{mode === "login" && (
|
|
<div className="space-y-2">
|
|
<Label htmlFor="password">{t(locale, "auth.field.password")}</Label>
|
|
<Input
|
|
id="password"
|
|
name="password"
|
|
type="password"
|
|
autoComplete="current-password"
|
|
required
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{mode === "register" && (
|
|
<div className="space-y-2">
|
|
<Label htmlFor="password">{t(locale, "auth.field.password")}</Label>
|
|
<Input
|
|
id="password"
|
|
name="password"
|
|
type="password"
|
|
autoComplete="new-password"
|
|
minLength={8}
|
|
required
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
/>
|
|
<p className="text-xs text-muted-foreground">
|
|
{t(locale, "auth.passwordHint")}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{mode === "change" && (
|
|
<>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="currentPassword">
|
|
{t(locale, "auth.field.currentPassword")}
|
|
</Label>
|
|
<Input
|
|
id="currentPassword"
|
|
name="currentPassword"
|
|
type="password"
|
|
autoComplete="current-password"
|
|
required
|
|
value={currentPassword}
|
|
onChange={(e) => setCurrentPassword(e.target.value)}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="newPassword">
|
|
{t(locale, "auth.field.newPassword")}
|
|
</Label>
|
|
<Input
|
|
id="newPassword"
|
|
name="newPassword"
|
|
type="password"
|
|
autoComplete="new-password"
|
|
minLength={8}
|
|
required
|
|
value={newPassword}
|
|
onChange={(e) => setNewPassword(e.target.value)}
|
|
/>
|
|
<p className="text-xs text-muted-foreground">
|
|
{t(locale, "auth.passwordHint")}
|
|
</p>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{mode === "reset" && (
|
|
<>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="token">{t(locale, "auth.field.token")}</Label>
|
|
<Input
|
|
id="token"
|
|
name="token"
|
|
required
|
|
value={token}
|
|
onChange={(e) => setToken(e.target.value)}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="newPassword">
|
|
{t(locale, "auth.field.newPassword")}
|
|
</Label>
|
|
<Input
|
|
id="newPassword"
|
|
name="newPassword"
|
|
type="password"
|
|
autoComplete="new-password"
|
|
minLength={8}
|
|
required
|
|
value={newPassword}
|
|
onChange={(e) => setNewPassword(e.target.value)}
|
|
/>
|
|
<p className="text-xs text-muted-foreground">
|
|
{t(locale, "auth.passwordHint")}
|
|
</p>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
<Button type="submit" className="w-full" disabled={busy}>
|
|
{mode === "login" && t(locale, "auth.login.submit")}
|
|
{mode === "register" && t(locale, "auth.register.submit")}
|
|
{mode === "forgot" && t(locale, "auth.forgot.submit")}
|
|
{mode === "reset" && t(locale, "auth.reset.submit")}
|
|
{mode === "change" && t(locale, "auth.settings.changeSubmit")}
|
|
</Button>
|
|
</form>
|
|
|
|
{mode === "forgot" && resetToken && (
|
|
<div className="mt-4 space-y-2 rounded-md border border-[var(--color-border)] p-3">
|
|
<p className="text-xs text-[var(--color-muted-foreground)]">
|
|
{t(locale, "auth.forgot.tokenHint")}
|
|
</p>
|
|
<code className="u-mono block break-all text-[11px]">{resetToken}</code>
|
|
<a
|
|
className="u-linelink text-sm"
|
|
href={`${homeBase}/auth/reset-password?token=${encodeURIComponent(resetToken)}`}
|
|
>
|
|
{t(locale, "auth.forgot.toReset")}
|
|
</a>
|
|
</div>
|
|
)}
|
|
|
|
<div className="mt-4 flex flex-wrap justify-between gap-2 text-sm">
|
|
{mode === "login" && (
|
|
<>
|
|
<a className="u-linelink" href={`${homeBase}/auth/forgot-password`}>
|
|
{t(locale, "auth.forgot.title")}
|
|
</a>
|
|
<span className="text-[var(--color-muted-foreground)]">
|
|
{t(locale, "auth.login.noAccount")}{" "}
|
|
<a className="u-linelink" href={`${homeBase}/auth/register`}>
|
|
{t(locale, "auth.login.toRegister")}
|
|
</a>
|
|
</span>
|
|
</>
|
|
)}
|
|
{mode === "register" && (
|
|
<span className="text-[var(--color-muted-foreground)]">
|
|
{t(locale, "auth.register.hasAccount")}{" "}
|
|
<a className="u-linelink" href={`${homeBase}/auth/login`}>
|
|
{t(locale, "auth.register.toLogin")}
|
|
</a>
|
|
</span>
|
|
)}
|
|
{(mode === "forgot" || mode === "reset") && (
|
|
<a className="u-linelink" href={`${homeBase}/auth/login`}>
|
|
{t(locale, "auth.forgot.toLogin")}
|
|
</a>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
function home(locale: Locale): string {
|
|
return locale === "zh-CN" ? "/zh-CN" : "/en";
|
|
}
|