diff --git a/package-lock.json b/package-lock.json index f4f7cc0..70e2ac4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "next": "16.2.4", "next-auth": "^4.24.14", "next-themes": "^0.4.6", + "nodemailer": "^7.0.13", "pg": "^8.20.0", "qrcode.react": "^4.2.0", "react": "19.2.4", @@ -37,6 +38,7 @@ "devDependencies": { "@tailwindcss/postcss": "^4", "@types/node": "^20", + "@types/nodemailer": "^8.0.0", "@types/react": "^19", "@types/react-dom": "^19", "dotenv": "^17.4.2", @@ -3438,6 +3440,16 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/nodemailer": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.0.tgz", + "integrity": "sha512-fyf8jWULsCo0d0BuoQ75i6IeoHs47qcqxWc7yUdUcV0pOZGjUTTOvwdG1PRXUDqN/8A64yQdQdnA2pZgcdi+cA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/pg": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", @@ -9861,6 +9873,16 @@ "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", "license": "MIT" }, + "node_modules/nodemailer": { + "version": "7.0.13", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz", + "integrity": "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==", + "license": "MIT-0", + "peer": true, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/npm-run-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", diff --git a/package.json b/package.json index 5f40daf..a3cbad1 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "next": "16.2.4", "next-auth": "^4.24.14", "next-themes": "^0.4.6", + "nodemailer": "^7.0.13", "pg": "^8.20.0", "qrcode.react": "^4.2.0", "react": "19.2.4", @@ -40,6 +41,7 @@ "devDependencies": { "@tailwindcss/postcss": "^4", "@types/node": "^20", + "@types/nodemailer": "^8.0.0", "@types/react": "^19", "@types/react-dom": "^19", "dotenv": "^17.4.2", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 1e00f45..82c19ba 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -17,6 +17,12 @@ enum UserStatus { BANNED } +enum EmailTokenPurpose { + REGISTRATION_VERIFY + PASSWORD_RESET + EMAIL_CHANGE +} + enum SubscriptionType { STREAMING PROXY @@ -125,16 +131,17 @@ enum SupportTicketPriority { } model User { - id String @id @default(cuid()) - email String @unique - password String - name String? - role Role @default(USER) - status UserStatus @default(ACTIVE) - inviteCode String? @unique - invitedById String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + email String @unique + emailVerifiedAt DateTime? @default(now()) + password String + name String? + role Role @default(USER) + status UserStatus @default(ACTIVE) + inviteCode String? @unique + invitedById String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt subscriptions UserSubscription[] orders Order[] @@ -153,6 +160,24 @@ model User { taskRuns TaskRun[] @relation("TaskTriggeredBy") supportTickets SupportTicket[] supportReplies SupportTicketReply[] + emailTokens EmailToken[] +} + +model EmailToken { + id String @id @default(cuid()) + userId String? + email String + tokenHash String @unique + purpose EmailTokenPurpose + expiresAt DateTime + consumedAt DateTime? + createdAt DateTime @default(now()) + + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([email, purpose, createdAt]) + @@index([userId, purpose, createdAt]) + @@index([expiresAt]) } model SubscriptionCategory { @@ -284,16 +309,16 @@ model StreamingSlot { } model NodeServer { - id String @id @default(cuid()) - name String + id String @id @default(cuid()) + name String panelUrl String? panelUsername String? panelPassword String? - panelType String @default("3x-ui") - agentToken String? - status String @default("active") - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + panelType String @default("3x-ui") + agentToken String? + status String @default("active") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt inbounds NodeInbound[] plans SubscriptionPlan[] @@ -630,6 +655,7 @@ model AppConfig { siteName String @default("J-Board") siteUrl String? allowRegistration Boolean @default(true) + emailVerificationRequired Boolean @default(false) requireInviteCode Boolean @default(false) supportContact String? maintenanceNotice String? @@ -643,6 +669,14 @@ model AppConfig { inviteRewardEnabled Boolean @default(false) turnstileSiteKey String? turnstileSecretKey String? + smtpEnabled Boolean @default(false) + smtpHost String? + smtpPort Int @default(587) + smtpSecure Boolean @default(false) + smtpUser String? + smtpPassword String? + smtpFromName String? + smtpFromEmail String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } diff --git a/src/actions/admin/settings.ts b/src/actions/admin/settings.ts index 3ffdbfc..2e5b744 100644 --- a/src/actions/admin/settings.ts +++ b/src/actions/admin/settings.ts @@ -7,6 +7,8 @@ import { requireAdmin } from "@/lib/require-auth"; import { actorFromSession, recordAuditLog } from "@/services/audit"; import { getAppConfig } from "@/services/app-config"; import { normalizeSiteUrl } from "@/services/site-url"; +import { encrypt } from "@/lib/crypto"; +import { sendSmtpTestEmail } from "@/services/email"; const settingsSchema = z.object({ siteName: z.string().trim().min(1, "站点名称不能为空"), @@ -15,6 +17,7 @@ const settingsSchema = z.object({ maintenanceNotice: z.string().trim().optional(), siteNotice: z.string().trim().optional(), allowRegistration: z.string().optional(), + emailVerificationRequired: z.string().optional(), requireInviteCode: z.string().optional(), autoReminderDispatchEnabled: z.string().optional(), reminderDispatchIntervalMinutes: z.coerce.number().int().positive().optional(), @@ -25,12 +28,22 @@ const settingsSchema = z.object({ inviteRewardCouponId: z.string().trim().optional(), turnstileSiteKey: z.string().trim().optional(), turnstileSecretKey: z.string().trim().optional(), + smtpEnabled: z.string().optional(), + smtpHost: z.string().trim().optional(), + smtpPort: z.coerce.number().int().min(1).max(65535).optional(), + smtpSecure: z.string().optional(), + smtpUser: z.string().trim().optional(), + smtpPassword: z.string().optional(), + smtpFromName: z.string().trim().optional(), + smtpFromEmail: z.string().trim().email("发件邮箱格式不正确").optional().or(z.literal("")), }); -export async function saveAppSettings(formData: FormData) { - const session = await requireAdmin(); - const parsed = settingsSchema.parse(Object.fromEntries(formData)); - const current = await getAppConfig(); +function buildSettingsUpdate(parsed: z.infer, current: Awaited>) { + const smtpEnabled = parsed.smtpEnabled === "true"; + const emailVerificationRequired = parsed.emailVerificationRequired === "true"; + const smtpPassword = parsed.smtpPassword?.trim() + ? encrypt(parsed.smtpPassword.trim()) + : current.smtpPassword; const next = { siteName: parsed.siteName, @@ -39,6 +52,7 @@ export async function saveAppSettings(formData: FormData) { maintenanceNotice: parsed.maintenanceNotice || null, siteNotice: parsed.siteNotice || null, allowRegistration: parsed.allowRegistration === "true", + emailVerificationRequired, requireInviteCode: parsed.requireInviteCode === "true", autoReminderDispatchEnabled: parsed.autoReminderDispatchEnabled === "true", reminderDispatchIntervalMinutes: @@ -51,8 +65,34 @@ export async function saveAppSettings(formData: FormData) { inviteRewardCouponId: parsed.inviteRewardCouponId || null, turnstileSiteKey: parsed.turnstileSiteKey || null, turnstileSecretKey: parsed.turnstileSecretKey || null, + smtpEnabled, + smtpHost: parsed.smtpHost || null, + smtpPort: parsed.smtpPort ?? current.smtpPort, + smtpSecure: parsed.smtpSecure === "true", + smtpUser: parsed.smtpUser || null, + smtpPassword, + smtpFromName: parsed.smtpFromName || null, + smtpFromEmail: parsed.smtpFromEmail || null, }; + if (next.smtpEnabled || next.emailVerificationRequired) { + if (!next.smtpHost || !next.smtpPort || !next.smtpFromEmail) { + throw new Error("启用邮件服务或注册邮箱验证前,请完整填写 SMTP 主机、端口和发件邮箱"); + } + } + if (next.emailVerificationRequired && !next.smtpEnabled) { + throw new Error("注册邮箱验证需要先开启 SMTP 邮件服务"); + } + + return next; +} + +export async function saveAppSettings(formData: FormData) { + const session = await requireAdmin(); + const parsed = settingsSchema.parse(Object.fromEntries(formData)); + const current = await getAppConfig(); + const next = buildSettingsUpdate(parsed, current); + await prisma.appConfig.upsert({ where: { id: current.id }, create: { id: current.id, ...next }, @@ -77,3 +117,14 @@ export async function saveAppSettings(formData: FormData) { revalidatePath("/account"); revalidatePath("/admin/commerce"); } + + +const smtpTestSchema = z.object({ + smtpTestEmail: z.string().trim().email("请输入正确的测试邮箱"), +}); + +export async function sendSmtpTestMessage(formData: FormData) { + await requireAdmin(); + const parsed = smtpTestSchema.parse(Object.fromEntries(formData)); + await sendSmtpTestEmail(parsed.smtpTestEmail); +} diff --git a/src/actions/auth/email.ts b/src/actions/auth/email.ts new file mode 100644 index 0000000..c906095 --- /dev/null +++ b/src/actions/auth/email.ts @@ -0,0 +1,101 @@ +"use server"; + +import bcrypt from "bcryptjs"; +import { headers } from "next/headers"; +import { z } from "zod"; +import { prisma } from "@/lib/prisma"; +import { rateLimit } from "@/lib/rate-limit"; +import { getAppConfig } from "@/services/app-config"; +import { isSmtpConfigured, normalizeEmailAddress, sendPasswordResetEmail, sendRegistrationVerificationEmail, consumePasswordResetToken, verifyEmailToken } from "@/services/email"; + +const emailSchema = z.object({ + email: z.string().trim().email("请输入正确的邮箱"), +}); + +const resetPasswordSchema = z.object({ + token: z.string().trim().min(20, "重设链接无效"), + password: z.string().min(6, "新密码至少 6 位"), + confirmPassword: z.string().min(6, "确认密码至少 6 位"), +}); + +async function requestContext() { + const headerList = await headers(); + return { headers: headerList }; +} + +async function assertMailAvailable() { + const config = await getAppConfig(); + if (!isSmtpConfigured(config)) { + throw new Error("站点尚未启用邮件服务,请联系管理员"); + } +} + +export async function requestPasswordReset(formData: FormData) { + const parsed = emailSchema.parse(Object.fromEntries(formData)); + const email = normalizeEmailAddress(parsed.email); + const { success } = await rateLimit(`ratelimit:password-reset:${email}`, 3, 10 * 60); + if (!success) { + throw new Error("请求过于频繁,请稍后再试"); + } + + await assertMailAvailable(); + const user = await prisma.user.findUnique({ + where: { email }, + select: { id: true, email: true, status: true }, + }); + + if (user?.status === "ACTIVE") { + await sendPasswordResetEmail({ + userId: user.id, + email: user.email, + ...(await requestContext()), + }); + } +} + +export async function requestRegistrationVerification(formData: FormData) { + const parsed = emailSchema.parse(Object.fromEntries(formData)); + const email = normalizeEmailAddress(parsed.email); + const { success } = await rateLimit(`ratelimit:email-verify:${email}`, 3, 10 * 60); + if (!success) { + throw new Error("请求过于频繁,请稍后再试"); + } + + await assertMailAvailable(); + const user = await prisma.user.findUnique({ + where: { email }, + select: { id: true, email: true, status: true, emailVerifiedAt: true }, + }); + + if (user?.status === "ACTIVE" && !user.emailVerifiedAt) { + await sendRegistrationVerificationEmail({ + userId: user.id, + email: user.email, + ...(await requestContext()), + }); + } +} + +export async function resetPasswordByEmail(formData: FormData) { + const parsed = resetPasswordSchema.parse(Object.fromEntries(formData)); + if (parsed.password !== parsed.confirmPassword) { + throw new Error("两次输入的新密码不一致"); + } + + const record = await consumePasswordResetToken(parsed.token); + const hashedPassword = await bcrypt.hash(parsed.password, 12); + await prisma.user.update({ + where: { id: record.userId }, + data: { password: hashedPassword }, + }); +} + + +const confirmEmailSchema = z.object({ + token: z.string().trim().min(20, "验证链接无效"), +}); + +export async function confirmEmailToken(formData: FormData) { + const parsed = confirmEmailSchema.parse(Object.fromEntries(formData)); + return verifyEmailToken(parsed.token); +} diff --git a/src/actions/user/account.ts b/src/actions/user/account.ts index 4527e07..091d16b 100644 --- a/src/actions/user/account.ts +++ b/src/actions/user/account.ts @@ -2,9 +2,11 @@ import bcrypt from "bcryptjs"; import { revalidatePath } from "next/cache"; +import { headers } from "next/headers"; import { randomBytes } from "crypto"; import { z } from "zod"; import { prisma } from "@/lib/prisma"; +import { normalizeEmailAddress, sendEmailChangeConfirmation } from "@/services/email"; import { requireAuth } from "@/lib/require-auth"; const profileSchema = z.object({ @@ -17,6 +19,10 @@ const passwordSchema = z.object({ confirmPassword: z.string().min(6, "确认密码至少 6 位"), }); +const emailChangeSchema = z.object({ + email: z.string().trim().email("请输入正确的新邮箱"), +}); + async function generateUniqueInviteCode(): Promise { for (let i = 0; i < 10; i += 1) { const code = randomBytes(4).toString("hex").toUpperCase(); @@ -44,6 +50,35 @@ export async function updateAccountProfile(formData: FormData) { revalidatePath("/account"); } +export async function requestAccountEmailChange(formData: FormData) { + const session = await requireAuth(); + const data = emailChangeSchema.parse(Object.fromEntries(formData)); + const email = normalizeEmailAddress(data.email); + + const current = await prisma.user.findUniqueOrThrow({ + where: { id: session.user.id }, + select: { email: true }, + }); + if (current.email === email) { + throw new Error("新邮箱不能与当前邮箱相同"); + } + + const existing = await prisma.user.findUnique({ + where: { email }, + select: { id: true }, + }); + if (existing) { + throw new Error("这个邮箱已经被其他账户使用"); + } + + const headerList = await headers(); + await sendEmailChangeConfirmation({ + userId: session.user.id, + email, + headers: headerList, + }); +} + export async function changeAccountPassword(formData: FormData) { const session = await requireAuth(); const data = passwordSchema.parse(Object.fromEntries(formData)); diff --git a/src/app/(admin)/admin/settings/page.tsx b/src/app/(admin)/admin/settings/page.tsx index 0427dc3..784bbee 100644 --- a/src/app/(admin)/admin/settings/page.tsx +++ b/src/app/(admin)/admin/settings/page.tsx @@ -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} /> diff --git a/src/app/(admin)/admin/settings/settings-form.tsx b/src/app/(admin)/admin/settings/settings-form.tsx index b64c307..6dcce1f 100644 --- a/src/app/(admin)/admin/settings/settings-form.tsx +++ b/src/app/(admin)/admin/settings/settings-form.tsx @@ -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 ( -
+
@@ -163,9 +187,80 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
+
+ + +

开启后,新用户注册会先收到验证邮件,完成验证后才能登录。

+
+
+
+ SMTP 邮件服务 +
+

+ 用于注册邮箱验证、忘记密码和账户邮箱变更。密码留空会保留当前配置;测试邮件会使用已保存的配置。 +

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+
+
+
diff --git a/src/app/(auth)/forgot-password/forgot-password-client.tsx b/src/app/(auth)/forgot-password/forgot-password-client.tsx new file mode 100644 index 0000000..7590c31 --- /dev/null +++ b/src/app/(auth)/forgot-password/forgot-password-client.tsx @@ -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 ( + + + {sent ? ( +
+
+ +
+

如果邮箱存在且状态正常,重设链接已经发送。请在 20 分钟内完成操作。

+ 返回登录 +
+ ) : ( + + +
+ + +
+ +

+ 想起密码了? 去登录 +

+ + )} +
+
+ ); +} diff --git a/src/app/(auth)/forgot-password/page.tsx b/src/app/(auth)/forgot-password/page.tsx new file mode 100644 index 0000000..8c8e5ba --- /dev/null +++ b/src/app/(auth)/forgot-password/page.tsx @@ -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 ; +} diff --git a/src/app/(auth)/login/login-page-client.tsx b/src/app/(auth)/login/login-page-client.tsx index 8b83ece..17c4bc3 100644 --- a/src/app/(auth)/login/login-page-client.tsx +++ b/src/app/(auth)/login/login-page-client.tsx @@ -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 ? "登录中..." : "登录"} -

- 没有账户?{" "} - - 注册 +

+ + 忘记密码 -

+ + + 没有账户?{" "} + + 注册 + + + {error === "邮箱尚未验证,请先查收验证邮件" && ( + <> + + + 重发验证邮件 + + + )} +
); diff --git a/src/app/(auth)/register/register-page-client.tsx b/src/app/(auth)/register/register-page-client.tsx index 409f142..6426fe1 100644 --- a/src/app/(auth)/register/register-page-client.tsx +++ b/src/app/(auth)/register/register-page-client.tsx @@ -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) { @@ -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 }) {
-

注册成功

-

账户已创建,请登录。

- - 去登录 +

{requiresEmailVerification ? "验证邮件已发送" : "注册成功"}

+

+ {requiresEmailVerification ? "请查收邮箱并完成验证,验证后即可登录。" : "账户已创建,请登录。"} +

+ + {requiresEmailVerification ? "没有收到?重新发送" : "去登录"}
diff --git a/src/app/(auth)/reset-password/page.tsx b/src/app/(auth)/reset-password/page.tsx new file mode 100644 index 0000000..3c44139 --- /dev/null +++ b/src/app/(auth)/reset-password/page.tsx @@ -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 ; +} diff --git a/src/app/(auth)/reset-password/reset-password-client.tsx b/src/app/(auth)/reset-password/reset-password-client.tsx new file mode 100644 index 0000000..5029eab --- /dev/null +++ b/src/app/(auth)/reset-password/reset-password-client.tsx @@ -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 ( + + + {done ? ( +
+
+ +
+

密码已更新,请使用新密码登录。

+ 返回登录 +
+ ) : ( +
+ + +
+ + +
+
+ + +
+ + + )} +
+
+ ); +} diff --git a/src/app/(auth)/verify-email-request/page.tsx b/src/app/(auth)/verify-email-request/page.tsx new file mode 100644 index 0000000..dcac0f5 --- /dev/null +++ b/src/app/(auth)/verify-email-request/page.tsx @@ -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 ; +} diff --git a/src/app/(auth)/verify-email-request/verify-email-request-client.tsx b/src/app/(auth)/verify-email-request/verify-email-request-client.tsx new file mode 100644 index 0000000..bba03af --- /dev/null +++ b/src/app/(auth)/verify-email-request/verify-email-request-client.tsx @@ -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 ( + + + {sent ? ( +
+
+ +
+

如果账户存在且尚未验证,新的验证邮件已经发送。

+ 返回登录 +
+ ) : ( +
+ +
+ + +
+ + + )} +
+
+ ); +} diff --git a/src/app/(user)/account/_components/account-profile-card.tsx b/src/app/(user)/account/_components/account-profile-card.tsx index 940cf1f..9281f5a 100644 --- a/src/app/(user)/account/_components/account-profile-card.tsx +++ b/src/app/(user)/account/_components/account-profile-card.tsx @@ -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; interface AccountProfileCardProps { - user: Pick; + user: Pick; 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 ( @@ -29,31 +37,43 @@ export function AccountProfileCard({ user, isSaving, onSubmit }: AccountProfileC
账户资料 - 让昵称和登录邮箱保持清晰,减少账号识别成本。 + 昵称立即保存;邮箱变更会先发送确认邮件,新邮箱确认后才会生效。
- +
-
-
- - -
-
-
- - - 已绑定 - -
- -
+
+ +
+ +
+
+ + + {user.emailVerifiedAt ? "已验证" : "已绑定"} + +
+ + {user.emailVerifiedAt && ( +

验证时间:{user.emailVerifiedAt}

+ )} +
+ +
+ + +
+
+
); diff --git a/src/app/(user)/account/account-data.ts b/src/app/(user)/account/account-data.ts index 700077a..15bf187 100644 --- a/src/app/(user)/account/account-data.ts +++ b/src/app/(user)/account/account-data.ts @@ -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), diff --git a/src/app/(user)/account/account-panel.tsx b/src/app/(user)/account/account-panel.tsx index 6d83a44..f6220a7 100644 --- a/src/app/(user)/account/account-panel.tsx +++ b/src/app/(user)/account/account-panel.tsx @@ -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) {
diff --git a/src/app/(user)/account/account-types.ts b/src/app/(user)/account/account-types.ts index c0be777..56db040 100644 --- a/src/app/(user)/account/account-types.ts +++ b/src/app/(user)/account/account-types.ts @@ -1,5 +1,6 @@ export interface AccountPanelUser { email: string; + emailVerifiedAt: string | null; name: string | null; inviteCode: string | null; createdAt: string; diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts index e16587e..908f858 100644 --- a/src/app/api/auth/register/route.ts +++ b/src/app/api/auth/register/route.ts @@ -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 }); } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 55cbfa3..efa3788 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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 ( - + - {children} - + + {children} + + ); diff --git a/src/app/verify-email/page.tsx b/src/app/verify-email/page.tsx new file mode 100644 index 0000000..d2310d4 --- /dev/null +++ b/src/app/verify-email/page.tsx @@ -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 ; +} diff --git a/src/app/verify-email/verify-email-client.tsx b/src/app/verify-email/verify-email-client.tsx new file mode 100644 index 0000000..84941ff --- /dev/null +++ b/src/app/verify-email/verify-email-client.tsx @@ -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 ( + + +
+
+ +
+ + {!result ? ( +
+ + +
+ ) : ( + + 返回登录 + + )} +
+
+
+ ); +} diff --git a/src/components/shared/mobile-header.tsx b/src/components/shared/mobile-header.tsx index 8586c26..91732a9 100644 --- a/src/components/shared/mobile-header.tsx +++ b/src/components/shared/mobile-header.tsx @@ -4,6 +4,7 @@ import { useState, type ReactNode } from "react"; import { Menu } from "lucide-react"; import { MobileDrawer } from "./mobile-drawer"; import { Sidebar, type SidebarGroup, type SidebarLink } from "./sidebar"; +import { ThemeToggle } from "./theme-toggle"; interface MobileHeaderProps { title: string; @@ -26,6 +27,7 @@ export function MobileHeader({ title, subtitle, links, groups, matchMode, collap {title}
+ {actions}
)} + {!shouldCollapseRail && } {!shouldCollapseRail && headerAction} {railCollapsible && ( + ); +} diff --git a/src/lib/auth.ts b/src/lib/auth.ts index e1d3b30..7a8ec74 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -25,11 +25,14 @@ export const authOptions: NextAuthOptions = { } const user = await prisma.user.findUnique({ - where: { email: credentials.email }, + where: { email: credentials.email.trim().toLowerCase() }, }); if (!user || user.status !== "ACTIVE") return null; const valid = await bcrypt.compare(credentials.password, user.password); if (!valid) return null; + if (config?.emailVerificationRequired && user.role !== "ADMIN" && !user.emailVerifiedAt) { + throw new Error("EMAIL_NOT_VERIFIED"); + } return { id: user.id, email: user.email, name: user.name, role: user.role }; }, }), diff --git a/src/services/email-templates.ts b/src/services/email-templates.ts new file mode 100644 index 0000000..a36ef73 --- /dev/null +++ b/src/services/email-templates.ts @@ -0,0 +1,131 @@ +interface EmailTemplateInput { + siteName: string; + title: string; + intro: string; + actionLabel: string; + actionUrl: string; + note?: string; + closing?: string; +} + +function escapeHtml(value: string) { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); +} + +export function renderActionEmail({ + siteName, + title, + intro, + actionLabel, + actionUrl, + note, + closing = "如果这不是你的操作,可以忽略这封邮件。", +}: EmailTemplateInput) { + const safeSiteName = escapeHtml(siteName); + const safeTitle = escapeHtml(title); + const safeIntro = escapeHtml(intro); + const safeActionLabel = escapeHtml(actionLabel); + const safeActionUrl = escapeHtml(actionUrl); + const safeNote = note ? escapeHtml(note) : ""; + const safeClosing = escapeHtml(closing); + + const text = [ + `${siteName} - ${title}`, + intro, + `${actionLabel}: ${actionUrl}`, + note, + closing, + ].filter(Boolean).join("\n\n"); + + const html = ` + + + + + ${safeTitle} + + +
${safeIntro}
+ + + + +
+ + + + + + + +
+
+ S + ${safeSiteName} +
+

${safeTitle}

+
+

${safeIntro}

+ +

如果按钮无法打开,请复制下面的链接到浏览器:

+

${safeActionUrl}

+ ${safeNote ? `

${safeNote}

` : ""} +

${safeClosing}

+
+
+ +`; + + return { html, text }; +} + +export function renderRegistrationEmail(siteName: string, actionUrl: string) { + return renderActionEmail({ + siteName, + title: "验证你的邮箱", + intro: "欢迎来到 J-Board。点击下方按钮完成邮箱验证,验证后即可使用你的账户。", + actionLabel: "完成邮箱验证", + actionUrl, + note: "链接 30 分钟内有效。为了账户安全,请不要转发这封邮件。", + }); +} + +export function renderPasswordResetEmail(siteName: string, actionUrl: string) { + return renderActionEmail({ + siteName, + title: "重设账户密码", + intro: "我们收到了你的密码重设请求。点击下方按钮设置一个新密码。", + actionLabel: "重设密码", + actionUrl, + note: "链接 20 分钟内有效。如果不是你本人发起,请忽略这封邮件。", + }); +} + +export function renderEmailChangeEmail(siteName: string, actionUrl: string) { + return renderActionEmail({ + siteName, + title: "确认新的登录邮箱", + intro: "你正在把 J-Board 账户绑定到这个邮箱。点击下方按钮确认变更。", + actionLabel: "确认邮箱变更", + actionUrl, + note: "链接 30 分钟内有效。确认后,新邮箱会成为你的登录邮箱。", + }); +} + +export function renderSmtpTestEmail(siteName: string) { + return renderActionEmail({ + siteName, + title: "SMTP 测试邮件", + intro: "这是一封来自 J-Board 的测试邮件。收到它说明当前 SMTP 配置可以正常发信。", + actionLabel: "返回 J-Board", + actionUrl: "https://github.com/JetSprow/J-Board", + note: "你可以回到后台继续配置邮箱验证、密码找回和账户邮箱变更流程。", + closing: "测试完成后,无需回复这封邮件。", + }); +} diff --git a/src/services/email.ts b/src/services/email.ts new file mode 100644 index 0000000..efd7f97 --- /dev/null +++ b/src/services/email.ts @@ -0,0 +1,285 @@ +import crypto from "crypto"; +import nodemailer from "nodemailer"; +import type { AppConfig, EmailToken, EmailTokenPurpose, Prisma } from "@prisma/client"; +import { prisma, type DbClient } from "@/lib/prisma"; +import { decrypt } from "@/lib/crypto"; +import { getAppConfig } from "@/services/app-config"; +import { + renderEmailChangeEmail, + renderPasswordResetEmail, + renderRegistrationEmail, + renderSmtpTestEmail, +} from "@/services/email-templates"; +import { getSiteBaseUrl } from "@/services/site-url"; + +const TOKEN_BYTES = 32; +const REGISTRATION_TTL_MINUTES = 30; +const EMAIL_CHANGE_TTL_MINUTES = 30; +const PASSWORD_RESET_TTL_MINUTES = 20; + +type EmailPurpose = EmailTokenPurpose; + +type MailContent = { + subject: string; + html: string; + text: string; +}; + +export function normalizeEmailAddress(email: string) { + return email.trim().toLowerCase(); +} + +function hashToken(token: string) { + return crypto.createHash("sha256").update(token).digest("hex"); +} + +function addMinutes(minutes: number) { + return new Date(Date.now() + minutes * 60 * 1000); +} + +function tokenTtl(purpose: EmailPurpose) { + if (purpose === "PASSWORD_RESET") return PASSWORD_RESET_TTL_MINUTES; + if (purpose === "EMAIL_CHANGE") return EMAIL_CHANGE_TTL_MINUTES; + return REGISTRATION_TTL_MINUTES; +} + +function smtpPassword(config: AppConfig) { + if (!config.smtpPassword) return undefined; + try { + return decrypt(config.smtpPassword); + } catch { + return config.smtpPassword; + } +} + +export function isSmtpConfigured(config: AppConfig) { + return Boolean( + config.smtpEnabled && + config.smtpHost && + config.smtpPort && + config.smtpFromEmail, + ); +} + +function assertSmtpConfigured(config: AppConfig) { + if (!isSmtpConfigured(config)) { + throw new Error("邮件服务尚未配置,请联系管理员"); + } +} + +async function sendMail(config: AppConfig, to: string, content: MailContent) { + assertSmtpConfigured(config); + + const user = config.smtpUser?.trim() || undefined; + const pass = smtpPassword(config); + const transporter = nodemailer.createTransport({ + host: config.smtpHost!, + port: config.smtpPort, + secure: config.smtpSecure, + auth: user ? { user, pass } : undefined, + }); + + await transporter.sendMail({ + from: { + name: config.smtpFromName || config.siteName, + address: config.smtpFromEmail!, + }, + to, + subject: content.subject, + html: content.html, + text: content.text, + }); +} + +async function createEmailToken(input: { + email: string; + purpose: EmailPurpose; + userId?: string | null; + db?: DbClient; +}) { + const db = input.db ?? prisma; + const email = normalizeEmailAddress(input.email); + const token = crypto.randomBytes(TOKEN_BYTES).toString("hex"); + const now = new Date(); + + await db.emailToken.updateMany({ + where: { + email, + purpose: input.purpose, + userId: input.userId ?? null, + consumedAt: null, + }, + data: { consumedAt: now }, + }); + + await db.emailToken.create({ + data: { + email, + userId: input.userId ?? null, + purpose: input.purpose, + tokenHash: hashToken(token), + expiresAt: addMinutes(tokenTtl(input.purpose)), + }, + }); + + return token; +} + +async function buildActionUrl(pathname: string, token: string, options: { headers?: Headers; requestUrl?: string } = {}) { + const baseUrl = await getSiteBaseUrl({ + headers: options.headers, + requestUrl: options.requestUrl, + allowRequestFallback: true, + }); + if (!baseUrl) { + throw new Error("请先在系统设置中填写站点域名"); + } + + const url = new URL(pathname, baseUrl); + url.searchParams.set("token", token); + return url.toString(); +} + +export async function sendRegistrationVerificationEmail(input: { + userId: string; + email: string; + headers?: Headers; + requestUrl?: string; +}) { + const config = await getAppConfig(); + const token = await createEmailToken({ + userId: input.userId, + email: input.email, + purpose: "REGISTRATION_VERIFY", + }); + const url = await buildActionUrl("/verify-email", token, input); + const template = renderRegistrationEmail(config.siteName, url); + + await sendMail(config, input.email, { + subject: `验证你的 ${config.siteName} 邮箱`, + ...template, + }); +} + +export async function sendPasswordResetEmail(input: { + userId: string; + email: string; + headers?: Headers; + requestUrl?: string; +}) { + const config = await getAppConfig(); + const token = await createEmailToken({ + userId: input.userId, + email: input.email, + purpose: "PASSWORD_RESET", + }); + const url = await buildActionUrl("/reset-password", token, input); + const template = renderPasswordResetEmail(config.siteName, url); + + await sendMail(config, input.email, { + subject: `${config.siteName} 密码重设`, + ...template, + }); +} + +export async function sendEmailChangeConfirmation(input: { + userId: string; + email: string; + headers?: Headers; + requestUrl?: string; +}) { + const config = await getAppConfig(); + const token = await createEmailToken({ + userId: input.userId, + email: input.email, + purpose: "EMAIL_CHANGE", + }); + const url = await buildActionUrl("/verify-email", token, input); + const template = renderEmailChangeEmail(config.siteName, url); + + await sendMail(config, input.email, { + subject: `${config.siteName} 邮箱变更确认`, + ...template, + }); +} + +export async function sendSmtpTestEmail(to: string) { + const config = await getAppConfig(); + const template = renderSmtpTestEmail(config.siteName); + await sendMail(config, normalizeEmailAddress(to), { + subject: `${config.siteName} SMTP 测试`, + ...template, + }); +} + +export async function consumeEmailToken(token: string, purpose?: EmailPurpose) { + const tokenHash = hashToken(token.trim()); + const record = await prisma.emailToken.findUnique({ where: { tokenHash } }); + + if (!record || record.consumedAt || record.expiresAt <= new Date()) { + return null; + } + if (purpose && record.purpose !== purpose) { + return null; + } + + const result = await prisma.emailToken.updateMany({ + where: { + id: record.id, + consumedAt: null, + expiresAt: { gt: new Date() }, + ...(purpose ? { purpose } : {}), + }, + data: { consumedAt: new Date() }, + }); + + return result.count === 1 ? record : null; +} + +export async function verifyEmailToken(token: string) { + const record = await consumeEmailToken(token); + if (!record) return { ok: false as const, message: "验证链接无效或已过期" }; + + if (record.purpose === "REGISTRATION_VERIFY") { + if (!record.userId) return { ok: false as const, message: "验证链接缺少账户信息" }; + await prisma.user.update({ + where: { id: record.userId }, + data: { emailVerifiedAt: new Date() }, + }); + return { ok: true as const, message: "邮箱验证完成,现在可以登录账户。" }; + } + + if (record.purpose === "EMAIL_CHANGE") { + if (!record.userId) return { ok: false as const, message: "验证链接缺少账户信息" }; + try { + await prisma.user.update({ + where: { id: record.userId }, + data: { + email: record.email, + emailVerifiedAt: new Date(), + }, + }); + } catch (error) { + if ((error as { code?: string }).code === "P2002") { + return { ok: false as const, message: "这个邮箱已经被其他账户使用" }; + } + throw error; + } + return { ok: true as const, message: "邮箱变更已确认,之后请使用新邮箱登录。" }; + } + + return { ok: false as const, message: "这个链接不能用于邮箱验证" }; +} + +export async function consumePasswordResetToken(token: string) { + const record = await consumeEmailToken(token, "PASSWORD_RESET"); + if (!record?.userId) { + throw new Error("重设链接无效或已过期"); + } + + return record as EmailToken & { userId: string }; +} + +export async function deleteEmailTokens(where: Prisma.EmailTokenWhereInput, db: DbClient = prisma) { + await db.emailToken.deleteMany({ where }); +}