feat: add email verification and dark mode

This commit is contained in:
JetSprow
2026-04-29 10:55:20 +10:00
parent 2a50d789dd
commit 5215850bac
32 changed files with 1244 additions and 61 deletions

View File

@@ -29,6 +29,7 @@ export default async function AdminSettingsPage() {
maintenanceNotice: config.maintenanceNotice,
siteNotice: config.siteNotice,
allowRegistration: config.allowRegistration,
emailVerificationRequired: config.emailVerificationRequired,
requireInviteCode: config.requireInviteCode,
autoReminderDispatchEnabled: config.autoReminderDispatchEnabled,
reminderDispatchIntervalMinutes: config.reminderDispatchIntervalMinutes,
@@ -39,6 +40,13 @@ export default async function AdminSettingsPage() {
inviteRewardCouponId: config.inviteRewardCouponId,
turnstileSiteKey: config.turnstileSiteKey,
turnstileSecretKey: config.turnstileSecretKey,
smtpEnabled: config.smtpEnabled,
smtpHost: config.smtpHost,
smtpPort: config.smtpPort,
smtpSecure: config.smtpSecure,
smtpUser: config.smtpUser,
smtpFromName: config.smtpFromName,
smtpFromEmail: config.smtpFromEmail,
}}
coupons={coupons}
/>

View File

@@ -1,12 +1,12 @@
"use client";
import { useState } from "react";
import { Bell, Clock3, Gift, Settings2, ShieldAlert, ShieldCheck } from "lucide-react";
import { Bell, Clock3, Gift, Mail, Send, Settings2, ShieldAlert, ShieldCheck } from "lucide-react";
import { Button, buttonVariants } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { saveAppSettings } from "@/actions/admin/settings";
import { saveAppSettings, sendSmtpTestMessage } from "@/actions/admin/settings";
import { toast } from "sonner";
import { getErrorMessage } from "@/lib/errors";
@@ -17,6 +17,7 @@ interface AppConfig {
maintenanceNotice: string | null;
siteNotice: string | null;
allowRegistration: boolean;
emailVerificationRequired: boolean;
requireInviteCode: boolean;
autoReminderDispatchEnabled: boolean;
reminderDispatchIntervalMinutes: number;
@@ -27,6 +28,13 @@ interface AppConfig {
inviteRewardCouponId: string | null;
turnstileSiteKey: string | null;
turnstileSecretKey: string | null;
smtpEnabled: boolean;
smtpHost: string | null;
smtpPort: number;
smtpSecure: boolean;
smtpUser: string | null;
smtpFromName: string | null;
smtpFromEmail: string | null;
}
interface CouponOption {
@@ -39,6 +47,7 @@ const selectClassName = "premium-input w-full appearance-none px-3.5 py-2 text-s
export function SettingsForm({ config, coupons }: { config: AppConfig; coupons: CouponOption[] }) {
const [saving, setSaving] = useState(false);
const [testingEmail, setTestingEmail] = useState(false);
async function handleSubmit(formData: FormData) {
setSaving(true);
@@ -52,8 +61,23 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
}
}
async function handleTestEmail() {
const form = document.getElementById("app-settings-form") as HTMLFormElement | null;
if (!form) return;
setTestingEmail(true);
try {
await sendSmtpTestMessage(new FormData(form));
toast.success("测试邮件已发送");
} catch (error) {
toast.error(getErrorMessage(error, "测试邮件发送失败"));
} finally {
setTestingEmail(false);
}
}
return (
<form action={handleSubmit} className="form-panel space-y-6">
<form id="app-settings-form" action={handleSubmit} className="form-panel space-y-6">
<div className="flex items-start gap-3">
<span className="flex size-11 shrink-0 items-center justify-center rounded-lg border border-primary/15 bg-primary/10 text-primary">
<Settings2 className="size-5" />
@@ -163,9 +187,80 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
<option value="true"></option>
</select>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="emailVerificationRequired"></Label>
<select
id="emailVerificationRequired"
name="emailVerificationRequired"
defaultValue={String(config.emailVerificationRequired)}
className={selectClassName}
>
<option value="false"></option>
<option value="true"></option>
</select>
<p className="text-xs leading-5 text-muted-foreground"></p>
</div>
</div>
</section>
<section className="space-y-4 rounded-lg border border-border bg-muted/25 p-3">
<div className="flex items-center gap-2 text-sm font-semibold">
<Mail className="size-4 text-primary" /> SMTP
</div>
<p className="text-xs leading-5 text-muted-foreground">
使
</p>
<div className="grid gap-5 md:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="smtpEnabled"></Label>
<select id="smtpEnabled" name="smtpEnabled" defaultValue={String(config.smtpEnabled)} className={selectClassName}>
<option value="false"></option>
<option value="true"></option>
</select>
</div>
<div className="space-y-2">
<Label htmlFor="smtpHost">SMTP </Label>
<Input id="smtpHost" name="smtpHost" defaultValue={config.smtpHost ?? ""} placeholder="smtp.example.com" />
</div>
<div className="space-y-2">
<Label htmlFor="smtpPort">SMTP </Label>
<Input id="smtpPort" name="smtpPort" type="number" min={1} max={65535} defaultValue={config.smtpPort} />
</div>
<div className="space-y-2">
<Label htmlFor="smtpSecure">TLS / SSL</Label>
<select id="smtpSecure" name="smtpSecure" defaultValue={String(config.smtpSecure)} className={selectClassName}>
<option value="false">STARTTLS / </option>
<option value="true">SSL </option>
</select>
</div>
<div className="space-y-2">
<Label htmlFor="smtpUser">SMTP </Label>
<Input id="smtpUser" name="smtpUser" defaultValue={config.smtpUser ?? ""} autoComplete="username" />
</div>
<div className="space-y-2">
<Label htmlFor="smtpPassword">SMTP </Label>
<Input id="smtpPassword" name="smtpPassword" type="password" placeholder="留空保持不变" autoComplete="new-password" />
</div>
<div className="space-y-2">
<Label htmlFor="smtpFromName"></Label>
<Input id="smtpFromName" name="smtpFromName" defaultValue={config.smtpFromName ?? ""} placeholder={config.siteName} />
</div>
<div className="space-y-2">
<Label htmlFor="smtpFromEmail"></Label>
<Input id="smtpFromEmail" name="smtpFromEmail" type="email" defaultValue={config.smtpFromEmail ?? ""} placeholder="noreply@example.com" />
</div>
<div className="space-y-2">
<Label htmlFor="smtpTestEmail"></Label>
<div className="flex gap-2">
<Input id="smtpTestEmail" name="smtpTestEmail" type="email" placeholder="you@example.com" />
<Button type="button" variant="outline" onClick={handleTestEmail} disabled={testingEmail}>
<Send className="size-4" />
{testingEmail ? "发送中" : "测试"}
</Button>
</div>
</div>
</div>
</section>
<section className="space-y-4 rounded-lg border border-border bg-muted/25 p-3">
<div className="flex items-center gap-2 text-sm font-semibold">

View File

@@ -0,0 +1,60 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import { Mail } from "lucide-react";
import { requestPasswordReset } from "@/actions/auth/email";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { getErrorMessage } from "@/lib/errors";
import { AuthCard, AuthErrorMessage, AuthShell } from "../_components/auth-shell";
export function ForgotPasswordClient() {
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const [sent, setSent] = useState(false);
async function handleSubmit(formData: FormData) {
setLoading(true);
setError("");
try {
await requestPasswordReset(formData);
setSent(true);
} catch (error) {
setError(getErrorMessage(error, "发送失败"));
} finally {
setLoading(false);
}
}
return (
<AuthShell>
<AuthCard title="找回密码" description="输入注册邮箱,我们会发送一封密码重设邮件。">
{sent ? (
<div className="space-y-4 py-3 text-center">
<div className="mx-auto flex size-11 items-center justify-center rounded-xl bg-primary/10 text-primary">
<Mail className="size-5" />
</div>
<p className="text-sm leading-6 text-muted-foreground"> 20 </p>
<Link href="/login" className="text-sm font-medium text-primary hover:underline"></Link>
</div>
) : (
<form action={handleSubmit} className="space-y-4">
<AuthErrorMessage message={error} />
<div className="space-y-2">
<Label htmlFor="email"></Label>
<Input id="email" name="email" type="email" autoComplete="email" required />
</div>
<Button type="submit" className="w-full" size="lg" disabled={loading}>
{loading ? "发送中..." : "发送重设邮件"}
</Button>
<p className="text-center text-sm text-muted-foreground">
<Link href="/login" className="font-medium text-primary hover:underline"></Link>
</p>
</form>
)}
</AuthCard>
</AuthShell>
);
}

View File

@@ -0,0 +1,11 @@
import type { Metadata } from "next";
import { ForgotPasswordClient } from "./forgot-password-client";
export const metadata: Metadata = {
title: "找回密码",
description: "通过邮箱重设 J-Board 账户密码。",
};
export default function ForgotPasswordPage() {
return <ForgotPasswordClient />;
}

View File

@@ -34,7 +34,7 @@ export function LoginPageClient({ siteKey }: { siteKey?: string | null }) {
});
setLoading(false);
if (result?.error) {
setError("邮箱或密码错误");
setError(result.error === "EMAIL_NOT_VERIFIED" ? "邮箱尚未验证,请先查收验证邮件" : "邮箱或密码错误");
} else {
router.push("/");
router.refresh();
@@ -59,12 +59,26 @@ export function LoginPageClient({ siteKey }: { siteKey?: string | null }) {
{loading ? "登录中..." : "登录"}
</Button>
</form>
<p className="mt-4 text-center text-sm text-muted-foreground">
{" "}
<Link href="/register" className="font-medium text-primary hover:underline">
<div className="mt-4 flex flex-wrap items-center justify-center gap-x-3 gap-y-2 text-sm text-muted-foreground">
<Link href="/forgot-password" className="font-medium text-primary hover:underline">
</Link>
</p>
<span className="h-1 w-1 rounded-full bg-muted-foreground/30" aria-hidden />
<span>
{" "}
<Link href="/register" className="font-medium text-primary hover:underline">
</Link>
</span>
{error === "邮箱尚未验证,请先查收验证邮件" && (
<>
<span className="h-1 w-1 rounded-full bg-muted-foreground/30" aria-hidden />
<Link href="/verify-email-request" className="font-medium text-primary hover:underline">
</Link>
</>
)}
</div>
</AuthCard>
</AuthShell>
);

View File

@@ -13,6 +13,7 @@ export function RegisterPageClient({ siteKey }: { siteKey?: string | null }) {
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
const [requiresEmailVerification, setRequiresEmailVerification] = useState(false);
const [turnstileToken, setTurnstileToken] = useState("");
async function onSubmit(event: FormEvent<HTMLFormElement>) {
@@ -47,6 +48,7 @@ export function RegisterPageClient({ siteKey }: { siteKey?: string | null }) {
if (!response.ok) {
setError(data.error || "注册失败");
} else {
setRequiresEmailVerification(Boolean(data.requiresEmailVerification));
setSuccess(true);
}
} finally {
@@ -60,10 +62,12 @@ export function RegisterPageClient({ siteKey }: { siteKey?: string | null }) {
<AuthCard>
<div className="space-y-4 py-3 text-center">
<div className="text-4xl" aria-hidden="true">🎉</div>
<h1 className="text-xl font-semibold tracking-tight"></h1>
<p className="text-sm text-muted-foreground"></p>
<Link href="/login" className={buttonVariants({ size: "lg", className: "w-full" })}>
<h1 className="text-xl font-semibold tracking-tight">{requiresEmailVerification ? "验证邮件已发送" : "注册成功"}</h1>
<p className="text-sm leading-6 text-muted-foreground">
{requiresEmailVerification ? "请查收邮箱并完成验证,验证后即可登录。" : "账户已创建,请登录。"}
</p>
<Link href={requiresEmailVerification ? "/verify-email-request" : "/login"} className={buttonVariants({ size: "lg", className: "w-full" })}>
{requiresEmailVerification ? "没有收到?重新发送" : "去登录"}
</Link>
</div>
</AuthCard>

View File

@@ -0,0 +1,16 @@
import type { Metadata } from "next";
import { ResetPasswordClient } from "./reset-password-client";
export const metadata: Metadata = {
title: "重设密码",
description: "设置新的 J-Board 账户密码。",
};
export default async function ResetPasswordPage({
searchParams,
}: {
searchParams: Promise<{ token?: string }>;
}) {
const params = await searchParams;
return <ResetPasswordClient token={params.token ?? ""} />;
}

View File

@@ -0,0 +1,62 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import { KeyRound } from "lucide-react";
import { resetPasswordByEmail } from "@/actions/auth/email";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { getErrorMessage } from "@/lib/errors";
import { AuthCard, AuthErrorMessage, AuthShell } from "../_components/auth-shell";
export function ResetPasswordClient({ token }: { token: string }) {
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const [done, setDone] = useState(false);
async function handleSubmit(formData: FormData) {
setLoading(true);
setError("");
try {
await resetPasswordByEmail(formData);
setDone(true);
} catch (error) {
setError(getErrorMessage(error, "重设失败"));
} finally {
setLoading(false);
}
}
return (
<AuthShell>
<AuthCard title="设置新密码" description="为你的账户设置一个新的登录密码。">
{done ? (
<div className="space-y-4 py-3 text-center">
<div className="mx-auto flex size-11 items-center justify-center rounded-xl bg-primary/10 text-primary">
<KeyRound className="size-5" />
</div>
<p className="text-sm leading-6 text-muted-foreground">使</p>
<Link href="/login" className="font-medium text-primary hover:underline"></Link>
</div>
) : (
<form action={handleSubmit} className="space-y-4">
<AuthErrorMessage message={error} />
<input type="hidden" name="token" value={token} />
<div className="space-y-2">
<Label htmlFor="password"></Label>
<Input id="password" name="password" type="password" autoComplete="new-password" minLength={6} required />
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword"></Label>
<Input id="confirmPassword" name="confirmPassword" type="password" autoComplete="new-password" minLength={6} required />
</div>
<Button type="submit" className="w-full" size="lg" disabled={loading || !token}>
{loading ? "保存中..." : "更新密码"}
</Button>
</form>
)}
</AuthCard>
</AuthShell>
);
}

View File

@@ -0,0 +1,11 @@
import type { Metadata } from "next";
import { VerifyEmailRequestClient } from "./verify-email-request-client";
export const metadata: Metadata = {
title: "重新发送验证邮件",
description: "重新发送 J-Board 注册邮箱验证邮件。",
};
export default function VerifyEmailRequestPage() {
return <VerifyEmailRequestClient />;
}

View File

@@ -0,0 +1,57 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import { MailCheck } from "lucide-react";
import { requestRegistrationVerification } from "@/actions/auth/email";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { getErrorMessage } from "@/lib/errors";
import { AuthCard, AuthErrorMessage, AuthShell } from "../_components/auth-shell";
export function VerifyEmailRequestClient() {
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const [sent, setSent] = useState(false);
async function handleSubmit(formData: FormData) {
setLoading(true);
setError("");
try {
await requestRegistrationVerification(formData);
setSent(true);
} catch (error) {
setError(getErrorMessage(error, "发送失败"));
} finally {
setLoading(false);
}
}
return (
<AuthShell>
<AuthCard title="重新发送验证邮件" description="没有收到邮件时,可以重新发送一次。">
{sent ? (
<div className="space-y-4 py-3 text-center">
<div className="mx-auto flex size-11 items-center justify-center rounded-xl bg-primary/10 text-primary">
<MailCheck className="size-5" />
</div>
<p className="text-sm leading-6 text-muted-foreground"></p>
<Link href="/login" className="font-medium text-primary hover:underline"></Link>
</div>
) : (
<form action={handleSubmit} className="space-y-4">
<AuthErrorMessage message={error} />
<div className="space-y-2">
<Label htmlFor="email"></Label>
<Input id="email" name="email" type="email" autoComplete="email" required />
</div>
<Button type="submit" className="w-full" size="lg" disabled={loading}>
{loading ? "发送中..." : "重新发送"}
</Button>
</form>
)}
</AuthCard>
</AuthShell>
);
}

View File

@@ -1,4 +1,4 @@
import { ShieldCheck, UserRound } from "lucide-react";
import { MailCheck, ShieldCheck, UserRound } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Card,
@@ -14,12 +14,20 @@ import type { AccountPanelUser } from "../account-types";
type AccountFormAction = (formData: FormData) => void | Promise<void>;
interface AccountProfileCardProps {
user: Pick<AccountPanelUser, "email" | "name">;
user: Pick<AccountPanelUser, "email" | "emailVerifiedAt" | "name">;
isSaving: boolean;
isEmailSaving: boolean;
onSubmit: AccountFormAction;
onEmailSubmit: AccountFormAction;
}
export function AccountProfileCard({ user, isSaving, onSubmit }: AccountProfileCardProps) {
export function AccountProfileCard({
user,
isSaving,
isEmailSaving,
onSubmit,
onEmailSubmit,
}: AccountProfileCardProps) {
return (
<Card>
<CardHeader className="pb-1">
@@ -29,31 +37,43 @@ export function AccountProfileCard({ user, isSaving, onSubmit }: AccountProfileC
</span>
<div className="min-w-0 space-y-1">
<CardTitle></CardTitle>
<CardDescription></CardDescription>
<CardDescription></CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<CardContent className="space-y-5">
<form action={onSubmit} className="space-y-5">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2 rounded-lg border border-border bg-muted/30 p-3">
<Label htmlFor="name"></Label>
<Input id="name" name="name" defaultValue={user.name ?? ""} required />
</div>
<div className="space-y-2 rounded-lg border border-border bg-muted/30 p-3">
<div className="flex items-center justify-between gap-2">
<Label htmlFor="email"></Label>
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/10 px-2 py-1 text-[0.68rem] font-semibold text-emerald-700 dark:text-emerald-300">
<ShieldCheck className="size-3" />
</span>
</div>
<Input id="email" value={user.email} disabled />
</div>
<div className="space-y-2 rounded-lg border border-border bg-muted/30 p-3">
<Label htmlFor="name"></Label>
<Input id="name" name="name" defaultValue={user.name ?? ""} required />
</div>
<Button type="submit" size="lg" disabled={isSaving} className="w-full sm:w-auto">
{isSaving ? "保存中..." : "保存资料"}
</Button>
</form>
<form id="account-email-form" action={onEmailSubmit} className="space-y-3 rounded-lg border border-border bg-muted/30 p-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<Label htmlFor="accountEmail"></Label>
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/10 px-2 py-1 text-[0.68rem] font-semibold text-emerald-700 dark:text-emerald-300">
<ShieldCheck className="size-3" /> {user.emailVerifiedAt ? "已验证" : "已绑定"}
</span>
</div>
<Input id="accountEmail" value={user.email} disabled />
{user.emailVerifiedAt && (
<p className="text-xs leading-5 text-muted-foreground">{user.emailVerifiedAt}</p>
)}
<div className="space-y-2 pt-2">
<Label htmlFor="email"></Label>
<div className="flex flex-col gap-2 sm:flex-row">
<Input id="email" name="email" type="email" autoComplete="email" placeholder="new@example.com" required />
<Button type="submit" variant="outline" disabled={isEmailSaving} className="sm:w-auto">
<MailCheck className="size-4" />
{isEmailSaving ? "发送中" : "发送确认"}
</Button>
</div>
</div>
</form>
</CardContent>
</Card>
);

View File

@@ -12,6 +12,7 @@ export async function getAccountPageData(userId: string): Promise<{
where: { id: userId },
select: {
email: true,
emailVerifiedAt: true,
name: true,
inviteCode: true,
createdAt: true,
@@ -33,6 +34,7 @@ export async function getAccountPageData(userId: string): Promise<{
return {
user: {
email: user.email,
emailVerifiedAt: user.emailVerifiedAt ? formatDate(user.emailVerifiedAt) : null,
name: user.name,
inviteCode: user.inviteCode,
createdAt: formatDate(user.createdAt),

View File

@@ -5,6 +5,7 @@ import { useState } from "react";
import {
changeAccountPassword,
generateInviteCode,
requestAccountEmailChange,
updateAccountProfile,
} from "@/actions/user/account";
import { getErrorMessage } from "@/lib/errors";
@@ -22,6 +23,7 @@ export function AccountPanel({ user }: Props) {
const router = useRouter();
const [profileSaving, setProfileSaving] = useState(false);
const [passwordSaving, setPasswordSaving] = useState(false);
const [emailSaving, setEmailSaving] = useState(false);
const [inviteCode, setInviteCode] = useState(user.inviteCode);
const [inviteLoading, setInviteLoading] = useState(false);
@@ -38,6 +40,19 @@ export function AccountPanel({ user }: Props) {
}
}
async function handleEmailSubmit(formData: FormData) {
setEmailSaving(true);
try {
await requestAccountEmailChange(formData);
toast.success("确认邮件已发送,请查收新邮箱");
(document.getElementById("account-email-form") as HTMLFormElement | null)?.reset();
} catch (error) {
toast.error(getErrorMessage(error, "发送确认邮件失败"));
} finally {
setEmailSaving(false);
}
}
async function handlePasswordSubmit(formData: FormData) {
setPasswordSaving(true);
try {
@@ -71,7 +86,9 @@ export function AccountPanel({ user }: Props) {
<AccountProfileCard
user={user}
isSaving={profileSaving}
isEmailSaving={emailSaving}
onSubmit={handleProfileSubmit}
onEmailSubmit={handleEmailSubmit}
/>
<AccountPasswordCard email={user.email} isSaving={passwordSaving} onSubmit={handlePasswordSubmit} />
</div>

View File

@@ -1,5 +1,6 @@
export interface AccountPanelUser {
email: string;
emailVerifiedAt: string | null;
name: string | null;
inviteCode: string | null;
createdAt: string;

View File

@@ -5,6 +5,7 @@ import { z } from "zod";
import { getAppConfig } from "@/services/app-config";
import { verifyTurnstile } from "@/lib/turnstile";
import { rateLimit } from "@/lib/rate-limit";
import { normalizeEmailAddress, sendRegistrationVerificationEmail } from "@/services/email";
const schema = z.object({
email: z.string().email(),
@@ -38,7 +39,8 @@ export async function POST(req: Request) {
return NextResponse.json({ error: "参数错误" }, { status: 400 });
}
const { email, password, name, inviteCode, turnstileToken } = parsed.data;
const { password, name, inviteCode, turnstileToken } = parsed.data;
const email = normalizeEmailAddress(parsed.data.email);
const config = await getAppConfig();
if (config.turnstileSecretKey) {
@@ -69,14 +71,33 @@ export async function POST(req: Request) {
}
const hashedPassword = await bcrypt.hash(password, 12);
await prisma.user.create({
const user = await prisma.user.create({
data: {
email,
emailVerifiedAt: config.emailVerificationRequired ? null : new Date(),
password: hashedPassword,
name: name || null,
invitedById: inviterId,
},
select: { id: true, email: true },
});
return NextResponse.json({ ok: true });
if (config.emailVerificationRequired) {
try {
await sendRegistrationVerificationEmail({
userId: user.id,
email: user.email,
headers: req.headers,
requestUrl: req.url,
});
} catch (error) {
await prisma.user.delete({ where: { id: user.id } }).catch(() => null);
return NextResponse.json(
{ error: error instanceof Error ? error.message : "验证邮件发送失败" },
{ status: 500 },
);
}
}
return NextResponse.json({ ok: true, requiresEmailVerification: config.emailVerificationRequired });
}

View File

@@ -1,5 +1,6 @@
import type { Metadata } from "next";
import { Toaster } from "@/components/ui/sonner";
import { ThemeProvider } from "@/components/shared/theme-provider";
import "./globals.css";
export const metadata: Metadata = {
@@ -16,10 +17,12 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="zh-CN" className="h-full antialiased">
<html lang="zh-CN" className="h-full antialiased" suppressHydrationWarning>
<body className="min-h-full flex flex-col">
{children}
<Toaster />
<ThemeProvider>
{children}
<Toaster />
</ThemeProvider>
</body>
</html>
);

View File

@@ -0,0 +1,16 @@
import type { Metadata } from "next";
import { VerifyEmailClient } from "./verify-email-client";
export const metadata: Metadata = {
title: "邮箱验证",
description: "确认 J-Board 账户邮箱。",
};
export default async function VerifyEmailPage({
searchParams,
}: {
searchParams: Promise<{ token?: string }>;
}) {
const params = await searchParams;
return <VerifyEmailClient token={params.token ?? ""} />;
}

View File

@@ -0,0 +1,56 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import { CheckCircle2, MailCheck, XCircle } from "lucide-react";
import { confirmEmailToken } from "@/actions/auth/email";
import { Button } from "@/components/ui/button";
import { AuthCard, AuthErrorMessage, AuthShell } from "@/app/(auth)/_components/auth-shell";
import { getErrorMessage } from "@/lib/errors";
export function VerifyEmailClient({ token }: { token: string }) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [result, setResult] = useState<{ ok: boolean; message: string } | null>(null);
const Icon = result?.ok ? CheckCircle2 : result ? XCircle : MailCheck;
async function handleConfirm(formData: FormData) {
setLoading(true);
setError("");
try {
setResult(await confirmEmailToken(formData));
} catch (error) {
setError(getErrorMessage(error, "验证失败"));
} finally {
setLoading(false);
}
}
return (
<AuthShell>
<AuthCard
title={result ? (result.ok ? "验证完成" : "验证失败") : "确认邮箱操作"}
description={result?.message ?? "为了避免邮件客户端预览误触发,请点击按钮完成确认。"}
>
<div className="space-y-4 py-3 text-center">
<div className={result && !result.ok ? "mx-auto flex size-12 items-center justify-center rounded-xl bg-destructive/10 text-destructive" : "mx-auto flex size-12 items-center justify-center rounded-xl bg-primary/10 text-primary"}>
<Icon className="size-6" />
</div>
<AuthErrorMessage message={error} />
{!result ? (
<form action={handleConfirm}>
<input type="hidden" name="token" value={token} />
<Button type="submit" size="lg" disabled={loading || !token} className="w-full">
{loading ? "确认中..." : "确认邮箱"}
</Button>
</form>
) : (
<Link href="/login" className="font-medium text-primary hover:underline">
</Link>
)}
</div>
</AuthCard>
</AuthShell>
);
}