feat: add email verification with resend auth settings
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-02-23 12:31:41 +08:00
parent 2c0a5379e7
commit aa1ec8bd43
12 changed files with 704 additions and 2 deletions

View File

@ -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 })}
/>
</div>
<div className="grid grid-cols-1 gap-3 rounded-md border p-3 md:grid-cols-2">
<div className="flex items-center justify-between md:col-span-2">
<div className="space-y-0.5">
<Label>邮箱验证码注册</Label>
<p className="text-sm text-muted-foreground">
开启后,用户注册时必须先收取邮箱验证码。
</p>
</div>
<Switch
checked={authSettings.isEmailVerificationEnabled === true}
onCheckedChange={(checked) =>
setAuthSettings({
...authSettings,
isEmailVerificationEnabled: checked,
})
}
/>
</div>
<div className="space-y-1">
<Label htmlFor="emailVerificationExpiresMinutes">
验证码有效期(分钟)
</Label>
<Input
id="emailVerificationExpiresMinutes"
type="number"
min={3}
max={30}
value={authSettings.emailVerificationExpiresMinutes ?? 10}
onChange={(event) =>
setAuthSettings({
...authSettings,
emailVerificationExpiresMinutes: Math.min(
Math.max(Number(event.target.value) || 10, 3),
30
),
})
}
/>
</div>
<div className="space-y-1">
<Label htmlFor="resendFromEmail">发件邮箱(Resend)</Label>
<Input
id="resendFromEmail"
value={authSettings.resendFromEmail || ""}
onChange={(event) =>
setAuthSettings({
...authSettings,
resendFromEmail: event.target.value,
})
}
placeholder="noreply@your-domain.ai"
/>
</div>
<div className="space-y-1 md:col-span-2">
<Label htmlFor="resendApiKey">Resend API Key</Label>
<Input
id="resendApiKey"
type="password"
value={authSettings.resendApiKey || ""}
onChange={(event) =>
setAuthSettings({
...authSettings,
resendApiKey: event.target.value,
})
}
placeholder="re_xxxxxxxxxxxxxxxxx"
/>
</div>
<p className="text-xs text-muted-foreground md:col-span-2">
当前状态:
{Boolean(
String(authSettings.resendApiKey || "").trim() &&
String(authSettings.resendFromEmail || "").trim()
)
? " 已配置"
: " 未配置"}
</p>
</div>
<Separator />
<div className="space-y-4">
<h3 className="text-lg font-semibold">钉钉登录</h3>

View File

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

View File

@ -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<string | null>(null);
const [isEmailVerificationEnabled, setIsEmailVerificationEnabled] = useState(false);
const [emailVerificationExpiresMinutes, setEmailVerificationExpiresMinutes] = useState(10);
const [isSendingCode, setIsSendingCode] = useState(false);
const [codeCooldown, setCodeCooldown] = useState(0);
const redirectTimerRef = useRef<number | null>(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<RegisterActionState, FormData>(
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 (
<div className="flex h-dvh w-screen items-start justify-center bg-background pt-12 md:items-center md:pt-0">
<div className="flex w-full max-w-md flex-col gap-12 overflow-hidden rounded-2xl">
@ -99,7 +169,45 @@ export default function Page() {
使用邮箱与密码创建新账号
</p>
</div>
<AuthForm action={handleSubmit} defaultEmail={email}>
<AuthForm
action={handleSubmit}
defaultEmail={email}
onEmailChange={setEmail}
showConfirmPassword
>
{isEmailVerificationEnabled ? (
<div className="flex flex-col gap-2">
<Label
className="font-normal text-zinc-600 dark:text-zinc-400"
htmlFor="verificationCode"
>
邮箱验证码
</Label>
<div className="flex items-center gap-2">
<Input
className="bg-muted text-md md:text-sm"
id="verificationCode"
name="verificationCode"
placeholder="请输入 6 位验证码"
required
type="text"
/>
<Button
type="button"
variant="outline"
onClick={handleSendCode}
disabled={isSendingCode || codeCooldown > 0}
className="shrink-0"
>
{isSendingCode
? "发送中..."
: codeCooldown > 0
? `${codeCooldown}s`
: "发送验证码"}
</Button>
</div>
</div>
) : null}
<SubmitButton isSuccessful={isSuccessful}>注册</SubmitButton>
<p className="mt-4 text-center text-gray-600 text-sm dark:text-zinc-400">
已经有账号?

View File

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

View File

@ -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<string, number>();
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 });
}
}

View File

@ -8,6 +8,8 @@ export function AuthForm({
children,
defaultEmail = "",
className = "",
showConfirmPassword = false,
onEmailChange,
}: {
action: NonNullable<
string | ((formData: FormData) => void | Promise<void>) | undefined
@ -15,6 +17,8 @@ export function AuthForm({
children: React.ReactNode;
defaultEmail?: string;
className?: string;
showConfirmPassword?: boolean;
onEmailChange?: (email: string) => void;
}) {
return (
<Form action={action} className={`flex flex-col gap-4 px-4 sm:px-16 ${className}`}>
@ -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({
/>
</div>
{showConfirmPassword ? (
<div className="flex flex-col gap-2">
<Label
className="font-normal text-zinc-600 dark:text-zinc-400"
htmlFor="confirmPassword"
>
确认密码
</Label>
<Input
className="bg-muted text-md md:text-sm"
id="confirmPassword"
name="confirmPassword"
required
type="password"
/>
</div>
) : null}
{children}
</Form>
);

View File

@ -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<void> {
const resolvedConfig = params.config || resolveResendConfig();
if (!resolvedConfig) {
throw new Error("Resend is not configured");
}
const subject = `【${params.siteName}】邮箱验证码`;
const html = `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; padding: 20px; color: #111;">
<h2 style="margin: 0 0 12px; font-size: 18px;">邮箱验证码</h2>
<p style="margin: 0 0 12px; font-size: 14px;">你正在注册 ${params.siteName},验证码如下:</p>
<div style="font-size: 28px; font-weight: 700; letter-spacing: 6px; margin: 10px 0 16px;">${params.code}</div>
<p style="margin: 0; font-size: 13px; color: #666;">验证码 ${params.expiresInMinutes} 分钟内有效,请勿泄露给他人。</p>
</div>
`;
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}`);
}
}

View File

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

View File

@ -0,0 +1,5 @@
ALTER TABLE "AuthSettings"
ADD COLUMN IF NOT EXISTS "resendApiKey" text;
ALTER TABLE "AuthSettings"
ADD COLUMN IF NOT EXISTS "resendFromEmail" text;

View File

@ -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
}
]
}

View File

@ -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: "",
};
}
}

View File

@ -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<typeof loginToken>;
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<typeof emailVerificationCode>;
// 生成图片记录表
export const generatedImage = pgTable("GeneratedImage", {
id: uuid("id").primaryKey().notNull().defaultRandom(),