diff --git a/app/(admin)/settings/admin-settings-client.tsx b/app/(admin)/settings/admin-settings-client.tsx index 2cf8c4495..47c3ec98a 100644 --- a/app/(admin)/settings/admin-settings-client.tsx +++ b/app/(admin)/settings/admin-settings-client.tsx @@ -308,6 +308,11 @@ type AuthSettings = { isDingTalkEnabled: boolean; isGoogleEnabled: boolean; isRegistrationEnabled?: boolean; + isEmailVerificationEnabled?: boolean; + emailVerificationExpiresMinutes?: number; + resendApiKey?: string | null; + resendFromEmail?: string | null; + isEmailServiceConfigured?: boolean; dingTalkOrganizations: DingTalkOrganization[]; }; @@ -1027,6 +1032,11 @@ export function AdminSettingsClient() { isDingTalkEnabled: false, isGoogleEnabled: false, isRegistrationEnabled: true, + isEmailVerificationEnabled: false, + emailVerificationExpiresMinutes: 10, + resendApiKey: "", + resendFromEmail: "", + isEmailServiceConfigured: false, dingTalkOrganizations: [], }); const [isGoogleConfigured, setIsGoogleConfigured] = useState(false); @@ -1060,6 +1070,13 @@ export function AdminSettingsClient() { isDingTalkEnabled: authSettingsData.isDingTalkEnabled || false, isGoogleEnabled: authSettingsData.isGoogleEnabled || false, isRegistrationEnabled: authSettingsData.isRegistrationEnabled ?? true, + isEmailVerificationEnabled: + authSettingsData.isEmailVerificationEnabled === true, + emailVerificationExpiresMinutes: + Number(authSettingsData.emailVerificationExpiresMinutes) || 10, + resendApiKey: authSettingsData.resendApiKey || "", + resendFromEmail: authSettingsData.resendFromEmail || "", + isEmailServiceConfigured: authSettingsData.isEmailServiceConfigured === true, dingTalkOrganizations: normalizedOrgs, }); setIsGoogleConfigured(Boolean(authSettingsData.isGoogleConfigured)); @@ -5531,6 +5548,84 @@ export function AdminSettingsClient() { onCheckedChange={(checked) => setAuthSettings({ ...authSettings, isRegistrationEnabled: checked })} /> +
+
+
+ +

+ 开启后,用户注册时必须先收取邮箱验证码。 +

+
+ + setAuthSettings({ + ...authSettings, + isEmailVerificationEnabled: checked, + }) + } + /> +
+
+ + + setAuthSettings({ + ...authSettings, + emailVerificationExpiresMinutes: Math.min( + Math.max(Number(event.target.value) || 10, 3), + 30 + ), + }) + } + /> +
+
+ + + setAuthSettings({ + ...authSettings, + resendFromEmail: event.target.value, + }) + } + placeholder="noreply@your-domain.ai" + /> +
+
+ + + setAuthSettings({ + ...authSettings, + resendApiKey: event.target.value, + }) + } + placeholder="re_xxxxxxxxxxxxxxxxx" + /> +
+

+ 当前状态: + {Boolean( + String(authSettings.resendApiKey || "").trim() && + String(authSettings.resendFromEmail || "").trim() + ) + ? " 已配置" + : " 未配置"} +

+

钉钉登录

diff --git a/app/(auth)/actions.ts b/app/(auth)/actions.ts index 071f2e587..2699069d9 100644 --- a/app/(auth)/actions.ts +++ b/app/(auth)/actions.ts @@ -1,8 +1,15 @@ "use server"; import { z } from "zod"; +import { and, desc, eq } from "drizzle-orm"; import { createUser, getUser, getAuthSettings } from "@/lib/db/queries"; +import { + EMAIL_VERIFICATION_PURPOSE_REGISTER, + hashEmailVerificationCode, +} from "@/lib/auth/email-verification"; +import { db } from "@/lib/db"; +import { emailVerificationCode } from "@/lib/db/schema"; import { signIn } from "./auth"; @@ -11,6 +18,18 @@ const authFormSchema = z.object({ password: z.string().min(6), }); +const registerFormSchema = z + .object({ + email: z.string().email(), + password: z.string().min(6), + confirmPassword: z.string().min(6), + verificationCode: z.string().optional(), + }) + .refine((data) => data.password === data.confirmPassword, { + path: ["confirmPassword"], + message: "Password mismatch", + }); + export type LoginActionState = { status: "idle" | "in_progress" | "success" | "failed" | "invalid_data"; }; @@ -69,6 +88,10 @@ export type RegisterActionState = { | "success" | "failed" | "user_exists" + | "password_mismatch" + | "verification_code_required" + | "verification_code_invalid" + | "verification_code_expired" | "invalid_data"; }; @@ -83,11 +106,64 @@ export const register = async ( return { status: "failed" }; } - const validatedData = authFormSchema.parse({ + const validatedData = registerFormSchema.parse({ email: formData.get("email"), password: formData.get("password"), + confirmPassword: formData.get("confirmPassword"), + verificationCode: + typeof formData.get("verificationCode") === "string" + ? String(formData.get("verificationCode")) + : undefined, }); const normalizedEmail = validatedData.email.trim().toLowerCase(); + const verificationCodeInput = String( + validatedData.verificationCode || "" + ).trim(); + + if (settings.isEmailVerificationEnabled === true) { + if (!verificationCodeInput) { + return { status: "verification_code_required" }; + } + if (!/^\d{6}$/.test(verificationCodeInput)) { + return { status: "verification_code_invalid" }; + } + + const [latestCode] = await db + .select() + .from(emailVerificationCode) + .where( + and( + eq(emailVerificationCode.email, normalizedEmail), + eq( + emailVerificationCode.purpose, + EMAIL_VERIFICATION_PURPOSE_REGISTER + ) + ) + ) + .orderBy(desc(emailVerificationCode.createdAt)) + .limit(1); + + if (!latestCode) { + return { status: "verification_code_invalid" }; + } + + if (new Date(latestCode.expiresAt) <= new Date()) { + await db + .delete(emailVerificationCode) + .where(eq(emailVerificationCode.id, latestCode.id)); + return { status: "verification_code_expired" }; + } + + const expectedHash = hashEmailVerificationCode({ + email: normalizedEmail, + purpose: EMAIL_VERIFICATION_PURPOSE_REGISTER, + code: verificationCodeInput, + }); + + if (latestCode.codeHash !== expectedHash) { + return { status: "verification_code_invalid" }; + } + } const [user] = await getUser(normalizedEmail); @@ -101,9 +177,24 @@ export const register = async ( redirect: false, }); + await db + .delete(emailVerificationCode) + .where( + and( + eq(emailVerificationCode.email, normalizedEmail), + eq(emailVerificationCode.purpose, EMAIL_VERIFICATION_PURPOSE_REGISTER) + ) + ); + return { status: "success" }; } catch (error) { if (error instanceof z.ZodError) { + const passwordMismatch = error.issues.some( + (issue) => issue.path?.[0] === "confirmPassword" + ); + if (passwordMismatch) { + return { status: "password_mismatch" }; + } return { status: "invalid_data" }; } diff --git a/app/(auth)/register/page.tsx b/app/(auth)/register/page.tsx index 0ea84f28c..76aedb4b6 100644 --- a/app/(auth)/register/page.tsx +++ b/app/(auth)/register/page.tsx @@ -6,6 +6,9 @@ import { useActionState, useEffect, useRef, useState } from "react"; import { AuthForm } from "@/components/auth-form"; import { SubmitButton } from "@/components/submit-button"; import { toast } from "@/components/toast"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; import { type RegisterActionState, register } from "../actions"; export default function Page() { @@ -15,6 +18,10 @@ export default function Page() { const [isSuccessful, setIsSuccessful] = useState(false); const [siteName, setSiteName] = useState("AI Chatbot"); const [siteLogo, setSiteLogo] = useState(null); + const [isEmailVerificationEnabled, setIsEmailVerificationEnabled] = useState(false); + const [emailVerificationExpiresMinutes, setEmailVerificationExpiresMinutes] = useState(10); + const [isSendingCode, setIsSendingCode] = useState(false); + const [codeCooldown, setCodeCooldown] = useState(0); const redirectTimerRef = useRef(null); useEffect(() => { @@ -26,6 +33,9 @@ export default function Page() { toast({ type: "error", description: "当前系统未开放注册" }); router.push("/login"); } + setIsEmailVerificationEnabled(data.isEmailVerificationEnabled === true); + const minutes = Number(data.emailVerificationExpiresMinutes) || 10; + setEmailVerificationExpiresMinutes(Math.min(Math.max(minutes, 3), 30)); }) .catch(console.error); @@ -39,6 +49,16 @@ export default function Page() { .catch(console.error); }, []); + useEffect(() => { + if (codeCooldown <= 0) { + return; + } + const timer = window.setInterval(() => { + setCodeCooldown((prev) => (prev > 0 ? prev - 1 : 0)); + }, 1000); + return () => window.clearInterval(timer); + }, [codeCooldown]); + const [state, formAction] = useActionState( register, { @@ -56,6 +76,18 @@ export default function Page() { if (state.status === "user_exists") { setIsSuccessful(false); toast({ type: "error", description: "账号已存在" }); + } else if (state.status === "password_mismatch") { + setIsSuccessful(false); + toast({ type: "error", description: "两次输入的密码不一致" }); + } else if (state.status === "verification_code_required") { + setIsSuccessful(false); + toast({ type: "error", description: "请先输入邮箱验证码" }); + } else if (state.status === "verification_code_invalid") { + setIsSuccessful(false); + toast({ type: "error", description: "邮箱验证码不正确" }); + } else if (state.status === "verification_code_expired") { + setIsSuccessful(false); + toast({ type: "error", description: "邮箱验证码已过期,请重新发送" }); } else if (state.status === "failed") { setIsSuccessful(false); toast({ type: "error", description: "创建账号失败" }); @@ -87,6 +119,44 @@ export default function Page() { formAction(formData); }; + const handleSendCode = async () => { + const normalizedEmail = email.trim().toLowerCase(); + if (!normalizedEmail) { + toast({ type: "error", description: "请先输入邮箱地址" }); + return; + } + if (codeCooldown > 0 || isSendingCode) { + return; + } + + setIsSendingCode(true); + try { + const response = await fetch("/api/auth/email/send-code", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: normalizedEmail }), + }); + const data = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(data?.error || "验证码发送失败"); + } + + const cooldownSeconds = Number(data?.cooldownSeconds) || 60; + setCodeCooldown(cooldownSeconds); + toast({ + type: "success", + description: `验证码已发送,请在 ${emailVerificationExpiresMinutes} 分钟内完成验证`, + }); + } catch (error) { + toast({ + type: "error", + description: error instanceof Error ? error.message : "验证码发送失败", + }); + } finally { + setIsSendingCode(false); + } + }; + return (
@@ -99,7 +169,45 @@ export default function Page() { 使用邮箱与密码创建新账号

- + + {isEmailVerificationEnabled ? ( +
+ +
+ + +
+
+ ) : null} 注册

已经有账号? diff --git a/app/api/auth-settings/route.ts b/app/api/auth-settings/route.ts index 872a3a5f8..8d211dc04 100644 --- a/app/api/auth-settings/route.ts +++ b/app/api/auth-settings/route.ts @@ -34,6 +34,11 @@ export async function GET() { authUrl: process.env.AUTH_URL || "", isGoogleConfigured, isRegistrationEnabled: true, + isEmailVerificationEnabled: false, + emailVerificationExpiresMinutes: 10, + resendApiKey: "", + resendFromEmail: "", + isEmailServiceConfigured: false, }); } @@ -45,6 +50,13 @@ export async function GET() { authUrl: resolvedAuthUrl, isGoogleConfigured, dingTalkOrganizations: settings.dingTalkOrganizations ?? [], + isEmailVerificationEnabled: settings.isEmailVerificationEnabled, + emailVerificationExpiresMinutes: settings.emailVerificationExpiresMinutes, + resendApiKey: settings.resendApiKey || "", + resendFromEmail: settings.resendFromEmail || "", + isEmailServiceConfigured: Boolean( + (settings.resendApiKey || "").trim() && (settings.resendFromEmail || "").trim() + ), }); } @@ -61,6 +73,11 @@ export async function GET() { authUrl: resolvedAuthUrl, isGoogleConfigured, isRegistrationEnabled: settings.isRegistrationEnabled, + isEmailVerificationEnabled: settings.isEmailVerificationEnabled, + emailVerificationExpiresMinutes: settings.emailVerificationExpiresMinutes, + isEmailServiceConfigured: Boolean( + (settings.resendApiKey || "").trim() && (settings.resendFromEmail || "").trim() + ), organizations, // 不返回 dingTalkAppSecret 以及各组织的密钥 }); @@ -89,6 +106,10 @@ const updateSchema = z.object({ googleClientSecret: z.string().optional(), authUrl: z.string().optional(), isRegistrationEnabled: z.boolean().optional(), + isEmailVerificationEnabled: z.boolean().optional(), + emailVerificationExpiresMinutes: z.number().int().min(3).max(30).optional(), + resendApiKey: z.string().optional(), + resendFromEmail: z.string().optional(), }); // PUT /api/auth-settings - 更新认证配置(仅管理员) @@ -124,6 +145,12 @@ export async function PUT(request: NextRequest) { const resolvedGoogleClientSecret = data.googleClientSecret ?? existing?.googleClientSecret ?? ""; const resolvedAuthUrl = data.authUrl ?? existing?.authUrl ?? ""; + const resolvedResendApiKey = String( + data.resendApiKey ?? existing?.resendApiKey ?? "" + ).trim(); + const resolvedResendFromEmail = String( + data.resendFromEmail ?? existing?.resendFromEmail ?? "" + ).trim(); if (existing) { // 更新现有配置 @@ -141,6 +168,13 @@ export async function PUT(request: NextRequest) { isGoogleEnabled: data.isGoogleEnabled ?? existing.isGoogleEnabled, isRegistrationEnabled: data.isRegistrationEnabled ?? existing.isRegistrationEnabled, + isEmailVerificationEnabled: + data.isEmailVerificationEnabled ?? existing.isEmailVerificationEnabled, + emailVerificationExpiresMinutes: + data.emailVerificationExpiresMinutes ?? + existing.emailVerificationExpiresMinutes, + resendApiKey: resolvedResendApiKey, + resendFromEmail: resolvedResendFromEmail, updatedAt: new Date(), updatedBy: session.user.id, }) @@ -163,6 +197,11 @@ export async function PUT(request: NextRequest) { isDingTalkEnabled: data.isDingTalkEnabled || false, isGoogleEnabled: data.isGoogleEnabled || false, isRegistrationEnabled: data.isRegistrationEnabled ?? true, + isEmailVerificationEnabled: data.isEmailVerificationEnabled ?? false, + emailVerificationExpiresMinutes: + data.emailVerificationExpiresMinutes ?? 10, + resendApiKey: resolvedResendApiKey, + resendFromEmail: resolvedResendFromEmail, updatedBy: session.user.id, }) .returning(); diff --git a/app/api/auth/email/send-code/route.ts b/app/api/auth/email/send-code/route.ts new file mode 100644 index 000000000..2af232c15 --- /dev/null +++ b/app/api/auth/email/send-code/route.ts @@ -0,0 +1,189 @@ +import { and, desc, eq, gt } from "drizzle-orm"; +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { + createEmailVerificationCode, + EMAIL_VERIFICATION_PURPOSE_REGISTER, + hashEmailVerificationCode, + resolveResendConfig, + sendVerificationCodeByResend, +} from "@/lib/auth/email-verification"; +import { db } from "@/lib/db"; +import { emailVerificationCode, siteSettings, user } from "@/lib/db/schema"; +import { getAuthSettings } from "@/lib/db/queries"; +import { checkRateLimit, getClientIdentifier, rateLimiters } from "@/lib/security/rate-limiter"; + +const payloadSchema = z.object({ + email: z.string().email(), +}); + +const SEND_CODE_COOLDOWN_SECONDS = 60; + +const emailCooldownMap = new Map(); + +function getCooldownSeconds(email: string): number { + const expiredAt = emailCooldownMap.get(email.toLowerCase()); + if (!expiredAt) { + return 0; + } + const remaining = Math.ceil((expiredAt - Date.now()) / 1000); + return remaining > 0 ? remaining : 0; +} + +function setCooldown(email: string): void { + emailCooldownMap.set( + email.toLowerCase(), + Date.now() + SEND_CODE_COOLDOWN_SECONDS * 1000 + ); +} + +export async function POST(request: Request) { + try { + const rateLimit = checkRateLimit(request, rateLimiters.auth); + if (!rateLimit.allowed) { + return NextResponse.json({ error: "请求过于频繁,请稍后重试" }, { status: 429 }); + } + + const body = await request.json(); + const parsed = payloadSchema.safeParse(body); + + if (!parsed.success) { + return NextResponse.json({ error: "邮箱格式不正确" }, { status: 400 }); + } + + const email = parsed.data.email.trim().toLowerCase(); + const clientId = getClientIdentifier(request); + + const localCooldown = getCooldownSeconds(`${clientId}:${email}`); + if (localCooldown > 0) { + return NextResponse.json( + { error: `验证码发送过于频繁,请 ${localCooldown}s 后重试` }, + { status: 429 } + ); + } + + const authSettings = await getAuthSettings(); + if (authSettings.isRegistrationEnabled === false) { + return NextResponse.json({ error: "当前系统未开放注册" }, { status: 403 }); + } + + if (authSettings.isEmailVerificationEnabled !== true) { + return NextResponse.json({ error: "邮箱验证未启用" }, { status: 400 }); + } + + const resendConfig = resolveResendConfig({ + apiKey: authSettings.resendApiKey, + fromEmail: authSettings.resendFromEmail, + }); + + if (!resendConfig) { + return NextResponse.json( + { error: "邮箱服务未配置,请在认证管理中配置 Resend API Key 和发件邮箱" }, + { status: 500 } + ); + } + + const [existingUser] = await db + .select({ id: user.id }) + .from(user) + .where(eq(user.email, email)) + .limit(1); + + if (existingUser) { + return NextResponse.json({ error: "该邮箱已注册" }, { status: 400 }); + } + + const now = new Date(); + const [latestCode] = await db + .select({ createdAt: emailVerificationCode.createdAt }) + .from(emailVerificationCode) + .where( + and( + eq(emailVerificationCode.email, email), + eq(emailVerificationCode.purpose, EMAIL_VERIFICATION_PURPOSE_REGISTER), + gt(emailVerificationCode.expiresAt, now) + ) + ) + .orderBy(desc(emailVerificationCode.createdAt)) + .limit(1); + + if (latestCode?.createdAt) { + const createdAtMs = new Date(latestCode.createdAt).getTime(); + const waitSeconds = Math.ceil((createdAtMs + SEND_CODE_COOLDOWN_SECONDS * 1000 - Date.now()) / 1000); + if (waitSeconds > 0) { + return NextResponse.json( + { error: `验证码发送过于频繁,请 ${waitSeconds}s 后重试` }, + { status: 429 } + ); + } + } + + const expiresInMinutes = Math.min( + Math.max(Number(authSettings.emailVerificationExpiresMinutes) || 10, 3), + 30 + ); + + const [siteSettingRecord] = await db + .select({ siteName: siteSettings.siteName }) + .from(siteSettings) + .limit(1); + + const code = createEmailVerificationCode(); + const codeHash = hashEmailVerificationCode({ + email, + purpose: EMAIL_VERIFICATION_PURPOSE_REGISTER, + code, + }); + const expiresAt = new Date(Date.now() + expiresInMinutes * 60 * 1000); + + await db + .delete(emailVerificationCode) + .where( + and( + eq(emailVerificationCode.email, email), + eq(emailVerificationCode.purpose, EMAIL_VERIFICATION_PURPOSE_REGISTER) + ) + ); + + await db.insert(emailVerificationCode).values({ + email, + purpose: EMAIL_VERIFICATION_PURPOSE_REGISTER, + codeHash, + expiresAt, + }); + + try { + await sendVerificationCodeByResend({ + to: email, + code, + siteName: siteSettingRecord?.siteName || "AI Chatbot", + expiresInMinutes, + config: resendConfig, + }); + } catch (sendError) { + await db + .delete(emailVerificationCode) + .where( + and( + eq(emailVerificationCode.email, email), + eq(emailVerificationCode.purpose, EMAIL_VERIFICATION_PURPOSE_REGISTER) + ) + ); + + console.error("[Email Verification] Send code failed:", sendError); + return NextResponse.json({ error: "验证码发送失败,请稍后重试" }, { status: 500 }); + } + + setCooldown(`${clientId}:${email}`); + + return NextResponse.json({ + success: true, + message: "验证码已发送", + cooldownSeconds: SEND_CODE_COOLDOWN_SECONDS, + expiresInMinutes, + }); + } catch (error) { + console.error("[Email Verification] Send code error:", error); + return NextResponse.json({ error: "发送验证码失败" }, { status: 500 }); + } +} diff --git a/components/auth-form.tsx b/components/auth-form.tsx index 535ee85a4..26fa91a14 100644 --- a/components/auth-form.tsx +++ b/components/auth-form.tsx @@ -8,6 +8,8 @@ export function AuthForm({ children, defaultEmail = "", className = "", + showConfirmPassword = false, + onEmailChange, }: { action: NonNullable< string | ((formData: FormData) => void | Promise) | undefined @@ -15,6 +17,8 @@ export function AuthForm({ children: React.ReactNode; defaultEmail?: string; className?: string; + showConfirmPassword?: boolean; + onEmailChange?: (email: string) => void; }) { return (

@@ -33,6 +37,7 @@ export function AuthForm({ defaultValue={defaultEmail} id="email" name="email" + onChange={(event) => onEmailChange?.(event.target.value)} placeholder="user@example.com" required type="email" @@ -56,6 +61,25 @@ export function AuthForm({ />
+ {showConfirmPassword ? ( +
+ + + +
+ ) : null} + {children} ); diff --git a/lib/auth/email-verification.ts b/lib/auth/email-verification.ts new file mode 100644 index 000000000..e3ca5d47b --- /dev/null +++ b/lib/auth/email-verification.ts @@ -0,0 +1,90 @@ +import crypto from "node:crypto"; + +export const EMAIL_VERIFICATION_PURPOSE_REGISTER = "register"; + +export type ResendConfig = { + apiKey: string; + fromEmail: string; +}; + +function getVerificationSecret(): string { + return ( + process.env.EMAIL_VERIFICATION_SECRET || + process.env.AUTH_SECRET || + "email-verification-secret" + ); +} + +export function hashEmailVerificationCode(params: { + email: string; + purpose: string; + code: string; +}): string { + const payload = `${params.email.toLowerCase()}:${params.purpose}:${params.code}:${getVerificationSecret()}`; + return crypto.createHash("sha256").update(payload).digest("hex"); +} + +export function createEmailVerificationCode(): string { + return String(crypto.randomInt(0, 1000000)).padStart(6, "0"); +} + +export function isResendConfigured(): boolean { + return Boolean(process.env.RESEND_API_KEY && process.env.RESEND_FROM_EMAIL); +} + +export function resolveResendConfig(config?: { + apiKey?: string | null; + fromEmail?: string | null; +}): ResendConfig | null { + const apiKey = String(config?.apiKey || process.env.RESEND_API_KEY || "").trim(); + const fromEmail = String(config?.fromEmail || process.env.RESEND_FROM_EMAIL || "").trim(); + if (!apiKey || !fromEmail) { + return null; + } + return { apiKey, fromEmail }; +} + +export async function sendVerificationCodeByResend(params: { + to: string; + code: string; + siteName: string; + expiresInMinutes: number; + config?: ResendConfig; +}): Promise { + const resolvedConfig = params.config || resolveResendConfig(); + if (!resolvedConfig) { + throw new Error("Resend is not configured"); + } + + const subject = `【${params.siteName}】邮箱验证码`; + const html = ` +
+

邮箱验证码

+

你正在注册 ${params.siteName},验证码如下:

+
${params.code}
+

验证码 ${params.expiresInMinutes} 分钟内有效,请勿泄露给他人。

+
+ `; + + const text = `你正在注册 ${params.siteName},验证码:${params.code}。验证码 ${params.expiresInMinutes} 分钟内有效。`; + + const response = await fetch("https://api.resend.com/emails", { + method: "POST", + headers: { + Authorization: `Bearer ${resolvedConfig.apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + from: resolvedConfig.fromEmail, + to: params.to, + subject, + html, + text, + }), + }); + + if (!response.ok) { + const detail = await response.text(); + throw new Error(`Resend request failed: ${response.status} ${detail}`); + } +} diff --git a/lib/db/migrations/0043_add_email_verification.sql b/lib/db/migrations/0043_add_email_verification.sql new file mode 100644 index 000000000..cb3c3ad26 --- /dev/null +++ b/lib/db/migrations/0043_add_email_verification.sql @@ -0,0 +1,20 @@ +ALTER TABLE "AuthSettings" + ADD COLUMN IF NOT EXISTS "isEmailVerificationEnabled" boolean NOT NULL DEFAULT false; + +ALTER TABLE "AuthSettings" + ADD COLUMN IF NOT EXISTS "emailVerificationExpiresMinutes" integer NOT NULL DEFAULT 10; + +CREATE TABLE IF NOT EXISTS "EmailVerificationCode" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "email" varchar(64) NOT NULL, + "purpose" varchar(32) NOT NULL DEFAULT 'register', + "codeHash" varchar(128) NOT NULL, + "expiresAt" timestamp NOT NULL, + "createdAt" timestamp DEFAULT now() NOT NULL +); + +CREATE INDEX IF NOT EXISTS "EmailVerificationCode_email_purpose_createdAt_idx" + ON "EmailVerificationCode" ("email", "purpose", "createdAt"); + +CREATE INDEX IF NOT EXISTS "EmailVerificationCode_expiresAt_idx" + ON "EmailVerificationCode" ("expiresAt"); diff --git a/lib/db/migrations/0044_add_resend_config.sql b/lib/db/migrations/0044_add_resend_config.sql new file mode 100644 index 000000000..15fdfc49b --- /dev/null +++ b/lib/db/migrations/0044_add_resend_config.sql @@ -0,0 +1,5 @@ +ALTER TABLE "AuthSettings" + ADD COLUMN IF NOT EXISTS "resendApiKey" text; + +ALTER TABLE "AuthSettings" + ADD COLUMN IF NOT EXISTS "resendFromEmail" text; diff --git a/lib/db/migrations/meta/_journal.json b/lib/db/migrations/meta/_journal.json index 696a93bb0..c9709062e 100644 --- a/lib/db/migrations/meta/_journal.json +++ b/lib/db/migrations/meta/_journal.json @@ -204,6 +204,20 @@ "when": 1772280000000, "tag": "0042_add_realtime_canvas_toggle", "breakpoints": true + }, + { + "idx": 29, + "version": "7", + "when": 1771820533320, + "tag": "0043_add_email_verification", + "breakpoints": true + }, + { + "idx": 30, + "version": "7", + "when": 1771821011606, + "tag": "0044_add_resend_config", + "breakpoints": true } ] } diff --git a/lib/db/queries.ts b/lib/db/queries.ts index 3836b10b8..5340fa207 100644 --- a/lib/db/queries.ts +++ b/lib/db/queries.ts @@ -1982,6 +1982,10 @@ export async function getAuthSettings() { isDingTalkEnabled: false, isGoogleEnabled: false, isRegistrationEnabled: true, + isEmailVerificationEnabled: false, + emailVerificationExpiresMinutes: 10, + resendApiKey: "", + resendFromEmail: "", }; } catch (_error) { return { @@ -1994,6 +1998,10 @@ export async function getAuthSettings() { isDingTalkEnabled: false, isGoogleEnabled: false, isRegistrationEnabled: true, + isEmailVerificationEnabled: false, + emailVerificationExpiresMinutes: 10, + resendApiKey: "", + resendFromEmail: "", }; } } diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 6891e817c..7c77f6096 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -416,6 +416,14 @@ export const authSettings = pgTable("AuthSettings", { isDingTalkEnabled: boolean("isDingTalkEnabled").notNull().default(false), isGoogleEnabled: boolean("isGoogleEnabled").notNull().default(false), isRegistrationEnabled: boolean("isRegistrationEnabled").notNull().default(true), + isEmailVerificationEnabled: boolean("isEmailVerificationEnabled") + .notNull() + .default(false), + emailVerificationExpiresMinutes: integer("emailVerificationExpiresMinutes") + .notNull() + .default(10), + resendApiKey: text("resendApiKey"), + resendFromEmail: text("resendFromEmail"), updatedAt: timestamp("updatedAt").notNull().defaultNow(), updatedBy: uuid("updatedBy").references(() => user.id), }); @@ -433,6 +441,17 @@ export const loginToken = pgTable("LoginToken", { export type LoginToken = InferSelectModel; +export const emailVerificationCode = pgTable("EmailVerificationCode", { + id: uuid("id").primaryKey().notNull().defaultRandom(), + email: varchar("email", { length: 64 }).notNull(), + purpose: varchar("purpose", { length: 32 }).notNull().default("register"), + codeHash: varchar("codeHash", { length: 128 }).notNull(), + expiresAt: timestamp("expiresAt").notNull(), + createdAt: timestamp("createdAt").notNull().defaultNow(), +}); + +export type EmailVerificationCode = InferSelectModel; + // 生成图片记录表 export const generatedImage = pgTable("GeneratedImage", { id: uuid("id").primaryKey().notNull().defaultRandom(),