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:
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user