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

22
package-lock.json generated
View File

@@ -21,6 +21,7 @@
"next": "16.2.4", "next": "16.2.4",
"next-auth": "^4.24.14", "next-auth": "^4.24.14",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"nodemailer": "^7.0.13",
"pg": "^8.20.0", "pg": "^8.20.0",
"qrcode.react": "^4.2.0", "qrcode.react": "^4.2.0",
"react": "19.2.4", "react": "19.2.4",
@@ -37,6 +38,7 @@
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/node": "^20", "@types/node": "^20",
"@types/nodemailer": "^8.0.0",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
@@ -3438,6 +3440,16 @@
"undici-types": "~6.21.0" "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": { "node_modules/@types/pg": {
"version": "8.20.0", "version": "8.20.0",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz",
@@ -9861,6 +9873,16 @@
"integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==",
"license": "MIT" "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": { "node_modules/npm-run-path": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-6.0.0.tgz",

View File

@@ -24,6 +24,7 @@
"next": "16.2.4", "next": "16.2.4",
"next-auth": "^4.24.14", "next-auth": "^4.24.14",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"nodemailer": "^7.0.13",
"pg": "^8.20.0", "pg": "^8.20.0",
"qrcode.react": "^4.2.0", "qrcode.react": "^4.2.0",
"react": "19.2.4", "react": "19.2.4",
@@ -40,6 +41,7 @@
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/node": "^20", "@types/node": "^20",
"@types/nodemailer": "^8.0.0",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",

View File

@@ -17,6 +17,12 @@ enum UserStatus {
BANNED BANNED
} }
enum EmailTokenPurpose {
REGISTRATION_VERIFY
PASSWORD_RESET
EMAIL_CHANGE
}
enum SubscriptionType { enum SubscriptionType {
STREAMING STREAMING
PROXY PROXY
@@ -127,6 +133,7 @@ enum SupportTicketPriority {
model User { model User {
id String @id @default(cuid()) id String @id @default(cuid())
email String @unique email String @unique
emailVerifiedAt DateTime? @default(now())
password String password String
name String? name String?
role Role @default(USER) role Role @default(USER)
@@ -153,6 +160,24 @@ model User {
taskRuns TaskRun[] @relation("TaskTriggeredBy") taskRuns TaskRun[] @relation("TaskTriggeredBy")
supportTickets SupportTicket[] supportTickets SupportTicket[]
supportReplies SupportTicketReply[] 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 { model SubscriptionCategory {
@@ -630,6 +655,7 @@ model AppConfig {
siteName String @default("J-Board") siteName String @default("J-Board")
siteUrl String? siteUrl String?
allowRegistration Boolean @default(true) allowRegistration Boolean @default(true)
emailVerificationRequired Boolean @default(false)
requireInviteCode Boolean @default(false) requireInviteCode Boolean @default(false)
supportContact String? supportContact String?
maintenanceNotice String? maintenanceNotice String?
@@ -643,6 +669,14 @@ model AppConfig {
inviteRewardEnabled Boolean @default(false) inviteRewardEnabled Boolean @default(false)
turnstileSiteKey String? turnstileSiteKey String?
turnstileSecretKey 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()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }

View File

@@ -7,6 +7,8 @@ import { requireAdmin } from "@/lib/require-auth";
import { actorFromSession, recordAuditLog } from "@/services/audit"; import { actorFromSession, recordAuditLog } from "@/services/audit";
import { getAppConfig } from "@/services/app-config"; import { getAppConfig } from "@/services/app-config";
import { normalizeSiteUrl } from "@/services/site-url"; import { normalizeSiteUrl } from "@/services/site-url";
import { encrypt } from "@/lib/crypto";
import { sendSmtpTestEmail } from "@/services/email";
const settingsSchema = z.object({ const settingsSchema = z.object({
siteName: z.string().trim().min(1, "站点名称不能为空"), siteName: z.string().trim().min(1, "站点名称不能为空"),
@@ -15,6 +17,7 @@ const settingsSchema = z.object({
maintenanceNotice: z.string().trim().optional(), maintenanceNotice: z.string().trim().optional(),
siteNotice: z.string().trim().optional(), siteNotice: z.string().trim().optional(),
allowRegistration: z.string().optional(), allowRegistration: z.string().optional(),
emailVerificationRequired: z.string().optional(),
requireInviteCode: z.string().optional(), requireInviteCode: z.string().optional(),
autoReminderDispatchEnabled: z.string().optional(), autoReminderDispatchEnabled: z.string().optional(),
reminderDispatchIntervalMinutes: z.coerce.number().int().positive().optional(), reminderDispatchIntervalMinutes: z.coerce.number().int().positive().optional(),
@@ -25,12 +28,22 @@ const settingsSchema = z.object({
inviteRewardCouponId: z.string().trim().optional(), inviteRewardCouponId: z.string().trim().optional(),
turnstileSiteKey: z.string().trim().optional(), turnstileSiteKey: z.string().trim().optional(),
turnstileSecretKey: 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) { function buildSettingsUpdate(parsed: z.infer<typeof settingsSchema>, current: Awaited<ReturnType<typeof getAppConfig>>) {
const session = await requireAdmin(); const smtpEnabled = parsed.smtpEnabled === "true";
const parsed = settingsSchema.parse(Object.fromEntries(formData)); const emailVerificationRequired = parsed.emailVerificationRequired === "true";
const current = await getAppConfig(); const smtpPassword = parsed.smtpPassword?.trim()
? encrypt(parsed.smtpPassword.trim())
: current.smtpPassword;
const next = { const next = {
siteName: parsed.siteName, siteName: parsed.siteName,
@@ -39,6 +52,7 @@ export async function saveAppSettings(formData: FormData) {
maintenanceNotice: parsed.maintenanceNotice || null, maintenanceNotice: parsed.maintenanceNotice || null,
siteNotice: parsed.siteNotice || null, siteNotice: parsed.siteNotice || null,
allowRegistration: parsed.allowRegistration === "true", allowRegistration: parsed.allowRegistration === "true",
emailVerificationRequired,
requireInviteCode: parsed.requireInviteCode === "true", requireInviteCode: parsed.requireInviteCode === "true",
autoReminderDispatchEnabled: parsed.autoReminderDispatchEnabled === "true", autoReminderDispatchEnabled: parsed.autoReminderDispatchEnabled === "true",
reminderDispatchIntervalMinutes: reminderDispatchIntervalMinutes:
@@ -51,8 +65,34 @@ export async function saveAppSettings(formData: FormData) {
inviteRewardCouponId: parsed.inviteRewardCouponId || null, inviteRewardCouponId: parsed.inviteRewardCouponId || null,
turnstileSiteKey: parsed.turnstileSiteKey || null, turnstileSiteKey: parsed.turnstileSiteKey || null,
turnstileSecretKey: parsed.turnstileSecretKey || 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({ await prisma.appConfig.upsert({
where: { id: current.id }, where: { id: current.id },
create: { id: current.id, ...next }, create: { id: current.id, ...next },
@@ -77,3 +117,14 @@ export async function saveAppSettings(formData: FormData) {
revalidatePath("/account"); revalidatePath("/account");
revalidatePath("/admin/commerce"); 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);
}

101
src/actions/auth/email.ts Normal file
View File

@@ -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);
}

View File

@@ -2,9 +2,11 @@
import bcrypt from "bcryptjs"; import bcrypt from "bcryptjs";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { headers } from "next/headers";
import { randomBytes } from "crypto"; import { randomBytes } from "crypto";
import { z } from "zod"; import { z } from "zod";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { normalizeEmailAddress, sendEmailChangeConfirmation } from "@/services/email";
import { requireAuth } from "@/lib/require-auth"; import { requireAuth } from "@/lib/require-auth";
const profileSchema = z.object({ const profileSchema = z.object({
@@ -17,6 +19,10 @@ const passwordSchema = z.object({
confirmPassword: z.string().min(6, "确认密码至少 6 位"), confirmPassword: z.string().min(6, "确认密码至少 6 位"),
}); });
const emailChangeSchema = z.object({
email: z.string().trim().email("请输入正确的新邮箱"),
});
async function generateUniqueInviteCode(): Promise<string> { async function generateUniqueInviteCode(): Promise<string> {
for (let i = 0; i < 10; i += 1) { for (let i = 0; i < 10; i += 1) {
const code = randomBytes(4).toString("hex").toUpperCase(); const code = randomBytes(4).toString("hex").toUpperCase();
@@ -44,6 +50,35 @@ export async function updateAccountProfile(formData: FormData) {
revalidatePath("/account"); 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) { export async function changeAccountPassword(formData: FormData) {
const session = await requireAuth(); const session = await requireAuth();
const data = passwordSchema.parse(Object.fromEntries(formData)); const data = passwordSchema.parse(Object.fromEntries(formData));

View File

@@ -29,6 +29,7 @@ export default async function AdminSettingsPage() {
maintenanceNotice: config.maintenanceNotice, maintenanceNotice: config.maintenanceNotice,
siteNotice: config.siteNotice, siteNotice: config.siteNotice,
allowRegistration: config.allowRegistration, allowRegistration: config.allowRegistration,
emailVerificationRequired: config.emailVerificationRequired,
requireInviteCode: config.requireInviteCode, requireInviteCode: config.requireInviteCode,
autoReminderDispatchEnabled: config.autoReminderDispatchEnabled, autoReminderDispatchEnabled: config.autoReminderDispatchEnabled,
reminderDispatchIntervalMinutes: config.reminderDispatchIntervalMinutes, reminderDispatchIntervalMinutes: config.reminderDispatchIntervalMinutes,
@@ -39,6 +40,13 @@ export default async function AdminSettingsPage() {
inviteRewardCouponId: config.inviteRewardCouponId, inviteRewardCouponId: config.inviteRewardCouponId,
turnstileSiteKey: config.turnstileSiteKey, turnstileSiteKey: config.turnstileSiteKey,
turnstileSecretKey: config.turnstileSecretKey, 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} coupons={coupons}
/> />

View File

@@ -1,12 +1,12 @@
"use client"; "use client";
import { useState } from "react"; 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 { Button, buttonVariants } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { saveAppSettings } from "@/actions/admin/settings"; import { saveAppSettings, sendSmtpTestMessage } from "@/actions/admin/settings";
import { toast } from "sonner"; import { toast } from "sonner";
import { getErrorMessage } from "@/lib/errors"; import { getErrorMessage } from "@/lib/errors";
@@ -17,6 +17,7 @@ interface AppConfig {
maintenanceNotice: string | null; maintenanceNotice: string | null;
siteNotice: string | null; siteNotice: string | null;
allowRegistration: boolean; allowRegistration: boolean;
emailVerificationRequired: boolean;
requireInviteCode: boolean; requireInviteCode: boolean;
autoReminderDispatchEnabled: boolean; autoReminderDispatchEnabled: boolean;
reminderDispatchIntervalMinutes: number; reminderDispatchIntervalMinutes: number;
@@ -27,6 +28,13 @@ interface AppConfig {
inviteRewardCouponId: string | null; inviteRewardCouponId: string | null;
turnstileSiteKey: string | null; turnstileSiteKey: string | null;
turnstileSecretKey: 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 { 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[] }) { export function SettingsForm({ config, coupons }: { config: AppConfig; coupons: CouponOption[] }) {
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [testingEmail, setTestingEmail] = useState(false);
async function handleSubmit(formData: FormData) { async function handleSubmit(formData: FormData) {
setSaving(true); 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 ( 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"> <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"> <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" /> <Settings2 className="size-5" />
@@ -163,9 +187,80 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
<option value="true"></option> <option value="true"></option>
</select> </select>
</div> </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> </div>
</section> </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"> <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"> <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); setLoading(false);
if (result?.error) { if (result?.error) {
setError("邮箱或密码错误"); setError(result.error === "EMAIL_NOT_VERIFIED" ? "邮箱尚未验证,请先查收验证邮件" : "邮箱或密码错误");
} else { } else {
router.push("/"); router.push("/");
router.refresh(); router.refresh();
@@ -59,12 +59,26 @@ export function LoginPageClient({ siteKey }: { siteKey?: string | null }) {
{loading ? "登录中..." : "登录"} {loading ? "登录中..." : "登录"}
</Button> </Button>
</form> </form>
<p className="mt-4 text-center text-sm text-muted-foreground"> <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>
<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 href="/register" className="font-medium text-primary hover:underline">
</Link> </Link>
</p> </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> </AuthCard>
</AuthShell> </AuthShell>
); );

View File

@@ -13,6 +13,7 @@ export function RegisterPageClient({ siteKey }: { siteKey?: string | null }) {
const [error, setError] = useState(""); const [error, setError] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false); const [success, setSuccess] = useState(false);
const [requiresEmailVerification, setRequiresEmailVerification] = useState(false);
const [turnstileToken, setTurnstileToken] = useState(""); const [turnstileToken, setTurnstileToken] = useState("");
async function onSubmit(event: FormEvent<HTMLFormElement>) { async function onSubmit(event: FormEvent<HTMLFormElement>) {
@@ -47,6 +48,7 @@ export function RegisterPageClient({ siteKey }: { siteKey?: string | null }) {
if (!response.ok) { if (!response.ok) {
setError(data.error || "注册失败"); setError(data.error || "注册失败");
} else { } else {
setRequiresEmailVerification(Boolean(data.requiresEmailVerification));
setSuccess(true); setSuccess(true);
} }
} finally { } finally {
@@ -60,10 +62,12 @@ export function RegisterPageClient({ siteKey }: { siteKey?: string | null }) {
<AuthCard> <AuthCard>
<div className="space-y-4 py-3 text-center"> <div className="space-y-4 py-3 text-center">
<div className="text-4xl" aria-hidden="true">🎉</div> <div className="text-4xl" aria-hidden="true">🎉</div>
<h1 className="text-xl font-semibold tracking-tight"></h1> <h1 className="text-xl font-semibold tracking-tight">{requiresEmailVerification ? "验证邮件已发送" : "注册成功"}</h1>
<p className="text-sm text-muted-foreground"></p> <p className="text-sm leading-6 text-muted-foreground">
<Link href="/login" className={buttonVariants({ size: "lg", className: "w-full" })}> {requiresEmailVerification ? "请查收邮箱并完成验证,验证后即可登录。" : "账户已创建,请登录。"}
</p>
<Link href={requiresEmailVerification ? "/verify-email-request" : "/login"} className={buttonVariants({ size: "lg", className: "w-full" })}>
{requiresEmailVerification ? "没有收到?重新发送" : "去登录"}
</Link> </Link>
</div> </div>
</AuthCard> </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 { Button } from "@/components/ui/button";
import { import {
Card, Card,
@@ -14,12 +14,20 @@ import type { AccountPanelUser } from "../account-types";
type AccountFormAction = (formData: FormData) => void | Promise<void>; type AccountFormAction = (formData: FormData) => void | Promise<void>;
interface AccountProfileCardProps { interface AccountProfileCardProps {
user: Pick<AccountPanelUser, "email" | "name">; user: Pick<AccountPanelUser, "email" | "emailVerifiedAt" | "name">;
isSaving: boolean; isSaving: boolean;
isEmailSaving: boolean;
onSubmit: AccountFormAction; onSubmit: AccountFormAction;
onEmailSubmit: AccountFormAction;
} }
export function AccountProfileCard({ user, isSaving, onSubmit }: AccountProfileCardProps) { export function AccountProfileCard({
user,
isSaving,
isEmailSaving,
onSubmit,
onEmailSubmit,
}: AccountProfileCardProps) {
return ( return (
<Card> <Card>
<CardHeader className="pb-1"> <CardHeader className="pb-1">
@@ -29,31 +37,43 @@ export function AccountProfileCard({ user, isSaving, onSubmit }: AccountProfileC
</span> </span>
<div className="min-w-0 space-y-1"> <div className="min-w-0 space-y-1">
<CardTitle></CardTitle> <CardTitle></CardTitle>
<CardDescription></CardDescription> <CardDescription></CardDescription>
</div> </div>
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="space-y-5">
<form action={onSubmit} 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"> <div className="space-y-2 rounded-lg border border-border bg-muted/30 p-3">
<Label htmlFor="name"></Label> <Label htmlFor="name"></Label>
<Input id="name" name="name" defaultValue={user.name ?? ""} required /> <Input id="name" name="name" defaultValue={user.name ?? ""} required />
</div> </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>
<Button type="submit" size="lg" disabled={isSaving} className="w-full sm:w-auto"> <Button type="submit" size="lg" disabled={isSaving} className="w-full sm:w-auto">
{isSaving ? "保存中..." : "保存资料"} {isSaving ? "保存中..." : "保存资料"}
</Button> </Button>
</form> </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> </CardContent>
</Card> </Card>
); );

View File

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

View File

@@ -5,6 +5,7 @@ import { useState } from "react";
import { import {
changeAccountPassword, changeAccountPassword,
generateInviteCode, generateInviteCode,
requestAccountEmailChange,
updateAccountProfile, updateAccountProfile,
} from "@/actions/user/account"; } from "@/actions/user/account";
import { getErrorMessage } from "@/lib/errors"; import { getErrorMessage } from "@/lib/errors";
@@ -22,6 +23,7 @@ export function AccountPanel({ user }: Props) {
const router = useRouter(); const router = useRouter();
const [profileSaving, setProfileSaving] = useState(false); const [profileSaving, setProfileSaving] = useState(false);
const [passwordSaving, setPasswordSaving] = useState(false); const [passwordSaving, setPasswordSaving] = useState(false);
const [emailSaving, setEmailSaving] = useState(false);
const [inviteCode, setInviteCode] = useState(user.inviteCode); const [inviteCode, setInviteCode] = useState(user.inviteCode);
const [inviteLoading, setInviteLoading] = useState(false); 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) { async function handlePasswordSubmit(formData: FormData) {
setPasswordSaving(true); setPasswordSaving(true);
try { try {
@@ -71,7 +86,9 @@ export function AccountPanel({ user }: Props) {
<AccountProfileCard <AccountProfileCard
user={user} user={user}
isSaving={profileSaving} isSaving={profileSaving}
isEmailSaving={emailSaving}
onSubmit={handleProfileSubmit} onSubmit={handleProfileSubmit}
onEmailSubmit={handleEmailSubmit}
/> />
<AccountPasswordCard email={user.email} isSaving={passwordSaving} onSubmit={handlePasswordSubmit} /> <AccountPasswordCard email={user.email} isSaving={passwordSaving} onSubmit={handlePasswordSubmit} />
</div> </div>

View File

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

View File

@@ -5,6 +5,7 @@ import { z } from "zod";
import { getAppConfig } from "@/services/app-config"; import { getAppConfig } from "@/services/app-config";
import { verifyTurnstile } from "@/lib/turnstile"; import { verifyTurnstile } from "@/lib/turnstile";
import { rateLimit } from "@/lib/rate-limit"; import { rateLimit } from "@/lib/rate-limit";
import { normalizeEmailAddress, sendRegistrationVerificationEmail } from "@/services/email";
const schema = z.object({ const schema = z.object({
email: z.string().email(), email: z.string().email(),
@@ -38,7 +39,8 @@ export async function POST(req: Request) {
return NextResponse.json({ error: "参数错误" }, { status: 400 }); 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(); const config = await getAppConfig();
if (config.turnstileSecretKey) { if (config.turnstileSecretKey) {
@@ -69,14 +71,33 @@ export async function POST(req: Request) {
} }
const hashedPassword = await bcrypt.hash(password, 12); const hashedPassword = await bcrypt.hash(password, 12);
await prisma.user.create({ const user = await prisma.user.create({
data: { data: {
email, email,
emailVerifiedAt: config.emailVerificationRequired ? null : new Date(),
password: hashedPassword, password: hashedPassword,
name: name || null, name: name || null,
invitedById: inviterId, 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 type { Metadata } from "next";
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
import { ThemeProvider } from "@/components/shared/theme-provider";
import "./globals.css"; import "./globals.css";
export const metadata: Metadata = { export const metadata: Metadata = {
@@ -16,10 +17,12 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
return ( 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"> <body className="min-h-full flex flex-col">
<ThemeProvider>
{children} {children}
<Toaster /> <Toaster />
</ThemeProvider>
</body> </body>
</html> </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>
);
}

View File

@@ -4,6 +4,7 @@ import { useState, type ReactNode } from "react";
import { Menu } from "lucide-react"; import { Menu } from "lucide-react";
import { MobileDrawer } from "./mobile-drawer"; import { MobileDrawer } from "./mobile-drawer";
import { Sidebar, type SidebarGroup, type SidebarLink } from "./sidebar"; import { Sidebar, type SidebarGroup, type SidebarLink } from "./sidebar";
import { ThemeToggle } from "./theme-toggle";
interface MobileHeaderProps { interface MobileHeaderProps {
title: string; title: string;
@@ -26,6 +27,7 @@ export function MobileHeader({ title, subtitle, links, groups, matchMode, collap
<span className="text-sm font-semibold">{title}</span> <span className="text-sm font-semibold">{title}</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ThemeToggle className="border-sidebar-border bg-sidebar-accent/35 text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-foreground" />
{actions} {actions}
<button <button
onClick={() => setOpen(true)} onClick={() => setOpen(true)}

View File

@@ -6,6 +6,7 @@ import { cn } from "@/lib/utils";
import { signOut } from "next-auth/react"; import { signOut } from "next-auth/react";
import { ChevronDown, LogOut, PanelLeftClose, PanelLeftOpen } from "lucide-react"; import { ChevronDown, LogOut, PanelLeftClose, PanelLeftOpen } from "lucide-react";
import { useMemo, useState, type ReactNode } from "react"; import { useMemo, useState, type ReactNode } from "react";
import { ThemeToggle } from "./theme-toggle";
export interface SidebarLink { export interface SidebarLink {
href: string; href: string;
@@ -96,6 +97,7 @@ export function Sidebar({
)} )}
</div> </div>
)} )}
{!shouldCollapseRail && <ThemeToggle className="border-sidebar-border bg-sidebar-accent/35 text-sidebar-foreground/62 hover:bg-sidebar-accent hover:text-sidebar-foreground" />}
{!shouldCollapseRail && headerAction} {!shouldCollapseRail && headerAction}
{railCollapsible && ( {railCollapsible && (
<button <button
@@ -197,7 +199,8 @@ export function Sidebar({
</div> </div>
))} ))}
</nav> </nav>
<div className={cn("border-t border-sidebar-border py-3", shouldCollapseRail ? "px-2" : "px-3")}> <div className={cn("space-y-2 border-t border-sidebar-border py-3", shouldCollapseRail ? "px-2" : "px-3")}>
{shouldCollapseRail && <ThemeToggle className="w-full border-sidebar-border bg-sidebar-accent/35 text-sidebar-foreground/62 hover:bg-sidebar-accent hover:text-sidebar-foreground" />}
<button <button
type="button" type="button"
onClick={handleSignOut} onClick={handleSignOut}

View File

@@ -1,5 +1,6 @@
import { GitFork } from "lucide-react"; import { GitFork } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { ThemeToggle } from "./theme-toggle";
const GITHUB_URL = "https://github.com/JetSprow/J-Board"; const GITHUB_URL = "https://github.com/JetSprow/J-Board";
@@ -13,6 +14,8 @@ export function SiteFooter({ className }: { className?: string }) {
> >
<span>J-Board</span> <span>J-Board</span>
<span className="h-1 w-1 rounded-full bg-muted-foreground/30" aria-hidden /> <span className="h-1 w-1 rounded-full bg-muted-foreground/30" aria-hidden />
<ThemeToggle className="size-7 rounded-md border-transparent bg-transparent" />
<span className="h-1 w-1 rounded-full bg-muted-foreground/30" aria-hidden />
<a <a
href={GITHUB_URL} href={GITHUB_URL}
target="_blank" target="_blank"

View File

@@ -0,0 +1,12 @@
"use client";
import { ThemeProvider as NextThemesProvider } from "next-themes";
import type { ReactNode } from "react";
export function ThemeProvider({ children }: { children: ReactNode }) {
return (
<NextThemesProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
{children}
</NextThemesProvider>
);
}

View File

@@ -0,0 +1,25 @@
"use client";
import { Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes";
import { cn } from "@/lib/utils";
export function ThemeToggle({ className }: { className?: string }) {
const { resolvedTheme, setTheme } = useTheme();
const isDark = resolvedTheme === "dark";
return (
<button
type="button"
className={cn(
"btn-base inline-flex size-8 items-center justify-center rounded-lg border border-border bg-card text-muted-foreground hover:bg-muted hover:text-foreground",
className,
)}
aria-label={isDark ? "切换到日间模式" : "切换到夜间模式"}
title={isDark ? "日间模式" : "夜间模式"}
onClick={() => setTheme(isDark ? "light" : "dark")}
>
{isDark ? <Sun className="size-4" /> : <Moon className="size-4" />}
</button>
);
}

View File

@@ -25,11 +25,14 @@ export const authOptions: NextAuthOptions = {
} }
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { email: credentials.email }, where: { email: credentials.email.trim().toLowerCase() },
}); });
if (!user || user.status !== "ACTIVE") return null; if (!user || user.status !== "ACTIVE") return null;
const valid = await bcrypt.compare(credentials.password, user.password); const valid = await bcrypt.compare(credentials.password, user.password);
if (!valid) return null; 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 }; return { id: user.id, email: user.email, name: user.name, role: user.role };
}, },
}), }),

View File

@@ -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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
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 = `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>${safeTitle}</title>
</head>
<body style="margin:0;background:#f6f3ed;color:#24211c;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC','Microsoft YaHei',sans-serif;">
<div style="display:none;max-height:0;overflow:hidden;opacity:0;">${safeIntro}</div>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#f6f3ed;padding:32px 16px;">
<tr>
<td align="center">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width:560px;background:#fffefa;border:1px solid #e8e1d4;border-radius:18px;overflow:hidden;box-shadow:0 14px 40px rgba(58,48,31,.08);">
<tr>
<td style="padding:28px 28px 18px;border-bottom:1px solid #eee7da;">
<div style="display:inline-flex;align-items:center;gap:10px;">
<span style="display:inline-block;width:34px;height:34px;line-height:34px;text-align:center;border-radius:10px;background:#15957f;color:#fff;font-weight:700;">S</span>
<span style="font-size:14px;color:#746b5e;font-weight:600;">${safeSiteName}</span>
</div>
<h1 style="margin:22px 0 0;font-size:24px;line-height:1.25;letter-spacing:-.02em;color:#24211c;">${safeTitle}</h1>
</td>
</tr>
<tr>
<td style="padding:26px 28px 28px;">
<p style="margin:0;font-size:15px;line-height:1.8;color:#4b453b;">${safeIntro}</p>
<div style="margin:26px 0 24px;">
<a href="${safeActionUrl}" style="display:inline-block;border-radius:12px;background:#15957f;color:#ffffff;text-decoration:none;font-size:14px;font-weight:700;padding:12px 18px;">${safeActionLabel}</a>
</div>
<p style="margin:0 0 18px;font-size:12px;line-height:1.8;color:#8a8172;">如果按钮无法打开,请复制下面的链接到浏览器:</p>
<p style="margin:0;word-break:break-all;border-radius:12px;background:#f3efe7;padding:12px;font-size:12px;line-height:1.6;color:#5f574b;">${safeActionUrl}</p>
${safeNote ? `<p style="margin:18px 0 0;font-size:13px;line-height:1.7;color:#746b5e;">${safeNote}</p>` : ""}
<p style="margin:22px 0 0;font-size:12px;line-height:1.7;color:#9b9285;">${safeClosing}</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
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: "测试完成后,无需回复这封邮件。",
});
}

285
src/services/email.ts Normal file
View File

@@ -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 });
}