mirror of
https://github.com/JetSprow/J-Board-Lite.git
synced 2026-05-01 01:14:10 +05:30
feat: add email verification and dark mode
This commit is contained in:
22
package-lock.json
generated
22
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<typeof settingsSchema>, current: Awaited<ReturnType<typeof getAppConfig>>) {
|
||||
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);
|
||||
}
|
||||
|
||||
101
src/actions/auth/email.ts
Normal file
101
src/actions/auth/email.ts
Normal 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);
|
||||
}
|
||||
@@ -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<string> {
|
||||
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));
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Bell, Clock3, Gift, Settings2, ShieldAlert, ShieldCheck } from "lucide-react";
|
||||
import { Bell, Clock3, Gift, Mail, Send, Settings2, ShieldAlert, ShieldCheck } from "lucide-react";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { saveAppSettings } from "@/actions/admin/settings";
|
||||
import { saveAppSettings, sendSmtpTestMessage } from "@/actions/admin/settings";
|
||||
import { toast } from "sonner";
|
||||
import { getErrorMessage } from "@/lib/errors";
|
||||
|
||||
@@ -17,6 +17,7 @@ interface AppConfig {
|
||||
maintenanceNotice: string | null;
|
||||
siteNotice: string | null;
|
||||
allowRegistration: boolean;
|
||||
emailVerificationRequired: boolean;
|
||||
requireInviteCode: boolean;
|
||||
autoReminderDispatchEnabled: boolean;
|
||||
reminderDispatchIntervalMinutes: number;
|
||||
@@ -27,6 +28,13 @@ interface AppConfig {
|
||||
inviteRewardCouponId: string | null;
|
||||
turnstileSiteKey: string | null;
|
||||
turnstileSecretKey: string | null;
|
||||
smtpEnabled: boolean;
|
||||
smtpHost: string | null;
|
||||
smtpPort: number;
|
||||
smtpSecure: boolean;
|
||||
smtpUser: string | null;
|
||||
smtpFromName: string | null;
|
||||
smtpFromEmail: string | null;
|
||||
}
|
||||
|
||||
interface CouponOption {
|
||||
@@ -39,6 +47,7 @@ const selectClassName = "premium-input w-full appearance-none px-3.5 py-2 text-s
|
||||
|
||||
export function SettingsForm({ config, coupons }: { config: AppConfig; coupons: CouponOption[] }) {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testingEmail, setTestingEmail] = useState(false);
|
||||
|
||||
async function handleSubmit(formData: FormData) {
|
||||
setSaving(true);
|
||||
@@ -52,8 +61,23 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTestEmail() {
|
||||
const form = document.getElementById("app-settings-form") as HTMLFormElement | null;
|
||||
if (!form) return;
|
||||
|
||||
setTestingEmail(true);
|
||||
try {
|
||||
await sendSmtpTestMessage(new FormData(form));
|
||||
toast.success("测试邮件已发送");
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, "测试邮件发送失败"));
|
||||
} finally {
|
||||
setTestingEmail(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form action={handleSubmit} className="form-panel space-y-6">
|
||||
<form id="app-settings-form" action={handleSubmit} className="form-panel space-y-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="flex size-11 shrink-0 items-center justify-center rounded-lg border border-primary/15 bg-primary/10 text-primary">
|
||||
<Settings2 className="size-5" />
|
||||
@@ -163,9 +187,80 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
|
||||
<option value="true">是</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<Label htmlFor="emailVerificationRequired">注册邮箱验证</Label>
|
||||
<select
|
||||
id="emailVerificationRequired"
|
||||
name="emailVerificationRequired"
|
||||
defaultValue={String(config.emailVerificationRequired)}
|
||||
className={selectClassName}
|
||||
>
|
||||
<option value="false">关闭</option>
|
||||
<option value="true">开启,注册后必须验证邮箱</option>
|
||||
</select>
|
||||
<p className="text-xs leading-5 text-muted-foreground">开启后,新用户注册会先收到验证邮件,完成验证后才能登录。</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4 rounded-lg border border-border bg-muted/25 p-3">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
<Mail className="size-4 text-primary" /> SMTP 邮件服务
|
||||
</div>
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
用于注册邮箱验证、忘记密码和账户邮箱变更。密码留空会保留当前配置;测试邮件会使用已保存的配置。
|
||||
</p>
|
||||
<div className="grid gap-5 md:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="smtpEnabled">邮件服务</Label>
|
||||
<select id="smtpEnabled" name="smtpEnabled" defaultValue={String(config.smtpEnabled)} className={selectClassName}>
|
||||
<option value="false">关闭</option>
|
||||
<option value="true">开启</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="smtpHost">SMTP 主机</Label>
|
||||
<Input id="smtpHost" name="smtpHost" defaultValue={config.smtpHost ?? ""} placeholder="smtp.example.com" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="smtpPort">SMTP 端口</Label>
|
||||
<Input id="smtpPort" name="smtpPort" type="number" min={1} max={65535} defaultValue={config.smtpPort} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="smtpSecure">TLS / SSL</Label>
|
||||
<select id="smtpSecure" name="smtpSecure" defaultValue={String(config.smtpSecure)} className={selectClassName}>
|
||||
<option value="false">STARTTLS / 普通连接</option>
|
||||
<option value="true">SSL 直连</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="smtpUser">SMTP 用户名</Label>
|
||||
<Input id="smtpUser" name="smtpUser" defaultValue={config.smtpUser ?? ""} autoComplete="username" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="smtpPassword">SMTP 密码</Label>
|
||||
<Input id="smtpPassword" name="smtpPassword" type="password" placeholder="留空保持不变" autoComplete="new-password" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="smtpFromName">发件名称</Label>
|
||||
<Input id="smtpFromName" name="smtpFromName" defaultValue={config.smtpFromName ?? ""} placeholder={config.siteName} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="smtpFromEmail">发件邮箱</Label>
|
||||
<Input id="smtpFromEmail" name="smtpFromEmail" type="email" defaultValue={config.smtpFromEmail ?? ""} placeholder="noreply@example.com" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="smtpTestEmail">测试收件邮箱</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input id="smtpTestEmail" name="smtpTestEmail" type="email" placeholder="you@example.com" />
|
||||
<Button type="button" variant="outline" onClick={handleTestEmail} disabled={testingEmail}>
|
||||
<Send className="size-4" />
|
||||
{testingEmail ? "发送中" : "测试"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4 rounded-lg border border-border bg-muted/25 p-3">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
|
||||
60
src/app/(auth)/forgot-password/forgot-password-client.tsx
Normal file
60
src/app/(auth)/forgot-password/forgot-password-client.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
11
src/app/(auth)/forgot-password/page.tsx
Normal file
11
src/app/(auth)/forgot-password/page.tsx
Normal 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 />;
|
||||
}
|
||||
@@ -34,7 +34,7 @@ export function LoginPageClient({ siteKey }: { siteKey?: string | null }) {
|
||||
});
|
||||
setLoading(false);
|
||||
if (result?.error) {
|
||||
setError("邮箱或密码错误");
|
||||
setError(result.error === "EMAIL_NOT_VERIFIED" ? "邮箱尚未验证,请先查收验证邮件" : "邮箱或密码错误");
|
||||
} else {
|
||||
router.push("/");
|
||||
router.refresh();
|
||||
@@ -59,12 +59,26 @@ export function LoginPageClient({ siteKey }: { siteKey?: string | null }) {
|
||||
{loading ? "登录中..." : "登录"}
|
||||
</Button>
|
||||
</form>
|
||||
<p className="mt-4 text-center text-sm text-muted-foreground">
|
||||
没有账户?{" "}
|
||||
<Link href="/register" className="font-medium text-primary hover:underline">
|
||||
注册
|
||||
<div className="mt-4 flex flex-wrap items-center justify-center gap-x-3 gap-y-2 text-sm text-muted-foreground">
|
||||
<Link href="/forgot-password" className="font-medium text-primary hover:underline">
|
||||
忘记密码
|
||||
</Link>
|
||||
</p>
|
||||
<span className="h-1 w-1 rounded-full bg-muted-foreground/30" aria-hidden />
|
||||
<span>
|
||||
没有账户?{" "}
|
||||
<Link href="/register" className="font-medium text-primary hover:underline">
|
||||
注册
|
||||
</Link>
|
||||
</span>
|
||||
{error === "邮箱尚未验证,请先查收验证邮件" && (
|
||||
<>
|
||||
<span className="h-1 w-1 rounded-full bg-muted-foreground/30" aria-hidden />
|
||||
<Link href="/verify-email-request" className="font-medium text-primary hover:underline">
|
||||
重发验证邮件
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</AuthCard>
|
||||
</AuthShell>
|
||||
);
|
||||
|
||||
@@ -13,6 +13,7 @@ export function RegisterPageClient({ siteKey }: { siteKey?: string | null }) {
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [requiresEmailVerification, setRequiresEmailVerification] = useState(false);
|
||||
const [turnstileToken, setTurnstileToken] = useState("");
|
||||
|
||||
async function onSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
@@ -47,6 +48,7 @@ export function RegisterPageClient({ siteKey }: { siteKey?: string | null }) {
|
||||
if (!response.ok) {
|
||||
setError(data.error || "注册失败");
|
||||
} else {
|
||||
setRequiresEmailVerification(Boolean(data.requiresEmailVerification));
|
||||
setSuccess(true);
|
||||
}
|
||||
} finally {
|
||||
@@ -60,10 +62,12 @@ export function RegisterPageClient({ siteKey }: { siteKey?: string | null }) {
|
||||
<AuthCard>
|
||||
<div className="space-y-4 py-3 text-center">
|
||||
<div className="text-4xl" aria-hidden="true">🎉</div>
|
||||
<h1 className="text-xl font-semibold tracking-tight">注册成功</h1>
|
||||
<p className="text-sm text-muted-foreground">账户已创建,请登录。</p>
|
||||
<Link href="/login" className={buttonVariants({ size: "lg", className: "w-full" })}>
|
||||
去登录
|
||||
<h1 className="text-xl font-semibold tracking-tight">{requiresEmailVerification ? "验证邮件已发送" : "注册成功"}</h1>
|
||||
<p className="text-sm leading-6 text-muted-foreground">
|
||||
{requiresEmailVerification ? "请查收邮箱并完成验证,验证后即可登录。" : "账户已创建,请登录。"}
|
||||
</p>
|
||||
<Link href={requiresEmailVerification ? "/verify-email-request" : "/login"} className={buttonVariants({ size: "lg", className: "w-full" })}>
|
||||
{requiresEmailVerification ? "没有收到?重新发送" : "去登录"}
|
||||
</Link>
|
||||
</div>
|
||||
</AuthCard>
|
||||
|
||||
16
src/app/(auth)/reset-password/page.tsx
Normal file
16
src/app/(auth)/reset-password/page.tsx
Normal 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 ?? ""} />;
|
||||
}
|
||||
62
src/app/(auth)/reset-password/reset-password-client.tsx
Normal file
62
src/app/(auth)/reset-password/reset-password-client.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
11
src/app/(auth)/verify-email-request/page.tsx
Normal file
11
src/app/(auth)/verify-email-request/page.tsx
Normal 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 />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ShieldCheck, UserRound } from "lucide-react";
|
||||
import { MailCheck, ShieldCheck, UserRound } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
@@ -14,12 +14,20 @@ import type { AccountPanelUser } from "../account-types";
|
||||
type AccountFormAction = (formData: FormData) => void | Promise<void>;
|
||||
|
||||
interface AccountProfileCardProps {
|
||||
user: Pick<AccountPanelUser, "email" | "name">;
|
||||
user: Pick<AccountPanelUser, "email" | "emailVerifiedAt" | "name">;
|
||||
isSaving: boolean;
|
||||
isEmailSaving: boolean;
|
||||
onSubmit: AccountFormAction;
|
||||
onEmailSubmit: AccountFormAction;
|
||||
}
|
||||
|
||||
export function AccountProfileCard({ user, isSaving, onSubmit }: AccountProfileCardProps) {
|
||||
export function AccountProfileCard({
|
||||
user,
|
||||
isSaving,
|
||||
isEmailSaving,
|
||||
onSubmit,
|
||||
onEmailSubmit,
|
||||
}: AccountProfileCardProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-1">
|
||||
@@ -29,31 +37,43 @@ export function AccountProfileCard({ user, isSaving, onSubmit }: AccountProfileC
|
||||
</span>
|
||||
<div className="min-w-0 space-y-1">
|
||||
<CardTitle>账户资料</CardTitle>
|
||||
<CardDescription>让昵称和登录邮箱保持清晰,减少账号识别成本。</CardDescription>
|
||||
<CardDescription>昵称立即保存;邮箱变更会先发送确认邮件,新邮箱确认后才会生效。</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="space-y-5">
|
||||
<form action={onSubmit} className="space-y-5">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2 rounded-lg border border-border bg-muted/30 p-3">
|
||||
<Label htmlFor="name">昵称</Label>
|
||||
<Input id="name" name="name" defaultValue={user.name ?? ""} required />
|
||||
</div>
|
||||
<div className="space-y-2 rounded-lg border border-border bg-muted/30 p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Label htmlFor="email">邮箱</Label>
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/10 px-2 py-1 text-[0.68rem] font-semibold text-emerald-700 dark:text-emerald-300">
|
||||
<ShieldCheck className="size-3" /> 已绑定
|
||||
</span>
|
||||
</div>
|
||||
<Input id="email" value={user.email} disabled />
|
||||
</div>
|
||||
<div className="space-y-2 rounded-lg border border-border bg-muted/30 p-3">
|
||||
<Label htmlFor="name">昵称</Label>
|
||||
<Input id="name" name="name" defaultValue={user.name ?? ""} required />
|
||||
</div>
|
||||
<Button type="submit" size="lg" disabled={isSaving} className="w-full sm:w-auto">
|
||||
{isSaving ? "保存中..." : "保存资料"}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<form id="account-email-form" action={onEmailSubmit} className="space-y-3 rounded-lg border border-border bg-muted/30 p-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<Label htmlFor="accountEmail">当前邮箱</Label>
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/10 px-2 py-1 text-[0.68rem] font-semibold text-emerald-700 dark:text-emerald-300">
|
||||
<ShieldCheck className="size-3" /> {user.emailVerifiedAt ? "已验证" : "已绑定"}
|
||||
</span>
|
||||
</div>
|
||||
<Input id="accountEmail" value={user.email} disabled />
|
||||
{user.emailVerifiedAt && (
|
||||
<p className="text-xs leading-5 text-muted-foreground">验证时间:{user.emailVerifiedAt}</p>
|
||||
)}
|
||||
<div className="space-y-2 pt-2">
|
||||
<Label htmlFor="email">新邮箱</Label>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<Input id="email" name="email" type="email" autoComplete="email" placeholder="new@example.com" required />
|
||||
<Button type="submit" variant="outline" disabled={isEmailSaving} className="sm:w-auto">
|
||||
<MailCheck className="size-4" />
|
||||
{isEmailSaving ? "发送中" : "发送确认"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useState } from "react";
|
||||
import {
|
||||
changeAccountPassword,
|
||||
generateInviteCode,
|
||||
requestAccountEmailChange,
|
||||
updateAccountProfile,
|
||||
} from "@/actions/user/account";
|
||||
import { getErrorMessage } from "@/lib/errors";
|
||||
@@ -22,6 +23,7 @@ export function AccountPanel({ user }: Props) {
|
||||
const router = useRouter();
|
||||
const [profileSaving, setProfileSaving] = useState(false);
|
||||
const [passwordSaving, setPasswordSaving] = useState(false);
|
||||
const [emailSaving, setEmailSaving] = useState(false);
|
||||
const [inviteCode, setInviteCode] = useState(user.inviteCode);
|
||||
const [inviteLoading, setInviteLoading] = useState(false);
|
||||
|
||||
@@ -38,6 +40,19 @@ export function AccountPanel({ user }: Props) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEmailSubmit(formData: FormData) {
|
||||
setEmailSaving(true);
|
||||
try {
|
||||
await requestAccountEmailChange(formData);
|
||||
toast.success("确认邮件已发送,请查收新邮箱");
|
||||
(document.getElementById("account-email-form") as HTMLFormElement | null)?.reset();
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error, "发送确认邮件失败"));
|
||||
} finally {
|
||||
setEmailSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePasswordSubmit(formData: FormData) {
|
||||
setPasswordSaving(true);
|
||||
try {
|
||||
@@ -71,7 +86,9 @@ export function AccountPanel({ user }: Props) {
|
||||
<AccountProfileCard
|
||||
user={user}
|
||||
isSaving={profileSaving}
|
||||
isEmailSaving={emailSaving}
|
||||
onSubmit={handleProfileSubmit}
|
||||
onEmailSubmit={handleEmailSubmit}
|
||||
/>
|
||||
<AccountPasswordCard email={user.email} isSaving={passwordSaving} onSubmit={handlePasswordSubmit} />
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export interface AccountPanelUser {
|
||||
email: string;
|
||||
emailVerifiedAt: string | null;
|
||||
name: string | null;
|
||||
inviteCode: string | null;
|
||||
createdAt: string;
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { ThemeProvider } from "@/components/shared/theme-provider";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -16,10 +17,12 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="zh-CN" className="h-full antialiased">
|
||||
<html lang="zh-CN" className="h-full antialiased" suppressHydrationWarning>
|
||||
<body className="min-h-full flex flex-col">
|
||||
{children}
|
||||
<Toaster />
|
||||
<ThemeProvider>
|
||||
{children}
|
||||
<Toaster />
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
16
src/app/verify-email/page.tsx
Normal file
16
src/app/verify-email/page.tsx
Normal 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 ?? ""} />;
|
||||
}
|
||||
56
src/app/verify-email/verify-email-client.tsx
Normal file
56
src/app/verify-email/verify-email-client.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
<span className="text-sm font-semibold">{title}</span>
|
||||
</div>
|
||||
<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}
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { cn } from "@/lib/utils";
|
||||
import { signOut } from "next-auth/react";
|
||||
import { ChevronDown, LogOut, PanelLeftClose, PanelLeftOpen } from "lucide-react";
|
||||
import { useMemo, useState, type ReactNode } from "react";
|
||||
import { ThemeToggle } from "./theme-toggle";
|
||||
|
||||
export interface SidebarLink {
|
||||
href: string;
|
||||
@@ -96,6 +97,7 @@ export function Sidebar({
|
||||
)}
|
||||
</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}
|
||||
{railCollapsible && (
|
||||
<button
|
||||
@@ -197,7 +199,8 @@ export function Sidebar({
|
||||
</div>
|
||||
))}
|
||||
</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
|
||||
type="button"
|
||||
onClick={handleSignOut}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { GitFork } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ThemeToggle } from "./theme-toggle";
|
||||
|
||||
const GITHUB_URL = "https://github.com/JetSprow/J-Board";
|
||||
|
||||
@@ -13,6 +14,8 @@ export function SiteFooter({ className }: { className?: string }) {
|
||||
>
|
||||
<span>J-Board</span>
|
||||
<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
|
||||
href={GITHUB_URL}
|
||||
target="_blank"
|
||||
|
||||
12
src/components/shared/theme-provider.tsx
Normal file
12
src/components/shared/theme-provider.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
25
src/components/shared/theme-toggle.tsx
Normal file
25
src/components/shared/theme-toggle.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
},
|
||||
}),
|
||||
|
||||
131
src/services/email-templates.ts
Normal file
131
src/services/email-templates.ts
Normal 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("&", "&")
|
||||
.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 = `<!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
285
src/services/email.ts
Normal 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 });
|
||||
}
|
||||
Reference in New Issue
Block a user