app-template/app/web/src/components/AuthForm.tsx

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";
}