fix: create users after email verification

This commit is contained in:
JetSprow
2026-04-30 15:14:05 +10:00
parent 85abba9bbf
commit 45e2257e68
5 changed files with 214 additions and 23 deletions

View File

@@ -215,6 +215,24 @@ model EmailToken {
@@index([expiresAt])
}
model PendingRegistration {
id String @id @default(cuid())
email String @unique
passwordHash String
name String?
inviteCode String?
invitedById String?
tokenHash String @unique
expiresAt DateTime
consumedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([consumedAt, expiresAt])
@@index([expiresAt])
@@index([invitedById])
}
model SubscriptionCategory {
id String @id @default(cuid())
name String

View File

@@ -7,7 +7,15 @@ import { prisma } from "@/lib/prisma";
import { rateLimit } from "@/lib/rate-limit";
import { getClientIp } from "@/lib/request-context";
import { getAppConfig } from "@/services/app-config";
import { isSmtpConfigured, normalizeEmailAddress, sendPasswordResetEmail, sendRegistrationVerificationEmail, consumePasswordResetToken, verifyEmailToken } from "@/services/email";
import {
consumePasswordResetToken,
isSmtpConfigured,
normalizeEmailAddress,
resendPendingRegistrationVerificationEmail,
sendPasswordResetEmail,
sendRegistrationVerificationEmail,
verifyEmailToken,
} from "@/services/email";
const emailSchema = z.object({
email: z.string().trim().email("请输入正确的邮箱"),
@@ -83,7 +91,13 @@ export async function requestRegistrationVerification(formData: FormData) {
email: user.email,
...context,
});
return;
}
await resendPendingRegistrationVerificationEmail({
email,
...context,
});
}
export async function resetPasswordByEmail(formData: FormData) {

View File

@@ -68,7 +68,7 @@ export function RegisterPageClient({ siteKey }: { siteKey?: string | null }) {
<div className="text-4xl" aria-hidden="true">🎉</div>
<h1 className="text-xl font-semibold tracking-tight">{requiresEmailVerification ? "验证邮件已发送" : "注册成功"}</h1>
<p className="text-sm leading-6 text-muted-foreground">
{requiresEmailVerification ? "请查收邮箱并完成验证,验证后即可登录。" : "账户已创建,请登录。"}
{requiresEmailVerification ? "请查收邮箱并完成验证,验证后会自动创建账户。" : "账户已创建,请登录。"}
</p>
<Link href={requiresEmailVerification ? "/verify-email-request" : "/login"} className={buttonVariants({ size: "lg", className: "w-full" })}>
{requiresEmailVerification ? "没有收到?重新发送" : "去登录"}

View File

@@ -6,7 +6,7 @@ import { getAppConfig } from "@/services/app-config";
import { verifyTurnstile } from "@/lib/turnstile";
import { rateLimit } from "@/lib/rate-limit";
import { getClientIp } from "@/lib/request-context";
import { isSmtpConfigured, normalizeEmailAddress, sendRegistrationVerificationEmail } from "@/services/email";
import { isSmtpConfigured, normalizeEmailAddress, sendPendingRegistrationVerificationEmail } from "@/services/email";
import { decryptIfEncrypted } from "@/lib/crypto";
const schema = z.object({
@@ -90,33 +90,37 @@ export async function POST(req: Request) {
}
const hashedPassword = await bcrypt.hash(password, 12);
const user = await prisma.user.create({
data: {
email,
emailVerifiedAt: config.emailVerificationRequired ? null : new Date(),
status: config.emailVerificationRequired ? "PENDING_EMAIL" : "ACTIVE",
password: hashedPassword,
name: name || null,
invitedById: inviterId,
},
select: { id: true, email: true },
});
if (config.emailVerificationRequired) {
try {
await sendRegistrationVerificationEmail({
userId: user.id,
email: user.email,
await sendPendingRegistrationVerificationEmail({
email,
passwordHash: hashedPassword,
name: name || null,
inviteCode: inviteCode || null,
invitedById: inviterId,
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 },
);
}
} else {
await prisma.$transaction(async (tx) => {
await tx.user.create({
data: {
email,
emailVerifiedAt: new Date(),
status: "ACTIVE",
password: hashedPassword,
name: name || null,
invitedById: inviterId,
},
});
await tx.pendingRegistration.deleteMany({ where: { email } });
});
}
return NextResponse.json({ ok: true, requiresEmailVerification: config.emailVerificationRequired });

View File

@@ -161,6 +161,85 @@ export async function sendRegistrationVerificationEmail(input: {
});
}
export async function sendPendingRegistrationVerificationEmail(input: {
email: string;
passwordHash: string;
name?: string | null;
inviteCode?: string | null;
invitedById?: string | null;
headers?: Headers;
requestUrl?: string;
}) {
const config = await getAppConfig();
const email = normalizeEmailAddress(input.email);
const token = crypto.randomBytes(TOKEN_BYTES).toString("hex");
await prisma.pendingRegistration.upsert({
where: { email },
create: {
email,
passwordHash: input.passwordHash,
name: input.name || null,
inviteCode: input.inviteCode || null,
invitedById: input.invitedById || null,
tokenHash: hashToken(token),
expiresAt: addMinutes(REGISTRATION_TTL_MINUTES),
},
update: {
passwordHash: input.passwordHash,
name: input.name || null,
inviteCode: input.inviteCode || null,
invitedById: input.invitedById || null,
tokenHash: hashToken(token),
expiresAt: addMinutes(REGISTRATION_TTL_MINUTES),
consumedAt: null,
},
});
const url = await buildActionUrl("/verify-email", token, input);
const template = renderRegistrationEmail(config.siteName, url);
await sendMail(config, email, {
subject: `验证你的 ${config.siteName} 邮箱`,
...template,
});
}
export async function resendPendingRegistrationVerificationEmail(input: {
email: string;
headers?: Headers;
requestUrl?: string;
}) {
const config = await getAppConfig();
const email = normalizeEmailAddress(input.email);
const pending = await prisma.pendingRegistration.findUnique({
where: { email },
select: { id: true, consumedAt: true },
});
if (!pending || pending.consumedAt) return false;
const token = crypto.randomBytes(TOKEN_BYTES).toString("hex");
await prisma.pendingRegistration.update({
where: { id: pending.id },
data: {
tokenHash: hashToken(token),
expiresAt: addMinutes(REGISTRATION_TTL_MINUTES),
consumedAt: null,
},
});
const url = await buildActionUrl("/verify-email", token, input);
const template = renderRegistrationEmail(config.siteName, url);
await sendMail(config, email, {
subject: `验证你的 ${config.siteName} 邮箱`,
...template,
});
return true;
}
export async function sendPasswordResetEmail(input: {
userId: string;
email: string;
@@ -237,6 +316,9 @@ export async function consumeEmailToken(token: string, purpose?: EmailPurpose) {
}
export async function verifyEmailToken(token: string) {
const pendingResult = await consumePendingRegistrationToken(token);
if (pendingResult) return pendingResult;
const record = await consumeEmailToken(token);
if (!record) return { ok: false as const, message: "验证链接无效或已过期" };
@@ -280,6 +362,79 @@ export async function verifyEmailToken(token: string) {
return { ok: false as const, message: "这个链接不能用于邮箱验证" };
}
async function consumePendingRegistrationToken(token: string) {
const tokenHash = hashToken(token.trim());
const pending = await prisma.pendingRegistration.findUnique({ where: { tokenHash } });
if (!pending || pending.consumedAt || pending.expiresAt <= new Date()) {
return null;
}
const now = new Date();
return prisma.$transaction(async (tx) => {
const consumed = await tx.pendingRegistration.updateMany({
where: {
id: pending.id,
consumedAt: null,
expiresAt: { gt: now },
},
data: { consumedAt: now },
});
if (consumed.count !== 1) {
return { ok: false as const, message: "验证链接无效或已过期" };
}
const existing = await tx.user.findUnique({
where: { email: pending.email },
select: { id: true },
});
if (existing) {
await tx.pendingRegistration.delete({ where: { id: pending.id } });
return { ok: false as const, message: "这个邮箱已经注册,请直接登录。" };
}
let invitedById = pending.invitedById;
if (invitedById) {
const inviter = await tx.user.findUnique({
where: { id: invitedById },
select: { id: true },
});
invitedById = inviter?.id ?? null;
}
if (!invitedById && pending.inviteCode) {
const inviter = await tx.user.findUnique({
where: { inviteCode: pending.inviteCode },
select: { id: true },
});
invitedById = inviter?.id ?? null;
}
try {
await tx.user.create({
data: {
email: pending.email,
password: pending.passwordHash,
name: pending.name,
invitedById,
emailVerifiedAt: now,
status: "ACTIVE",
},
});
await tx.pendingRegistration.delete({ where: { id: pending.id } });
} catch (error) {
if ((error as { code?: string }).code === "P2002") {
await tx.pendingRegistration.deleteMany({ where: { id: pending.id } });
return { ok: false as const, message: "这个邮箱已经注册,请直接登录。" };
}
throw error;
}
return { ok: true as const, message: "邮箱验证完成,账户已创建,现在可以登录。" };
});
}
export async function consumePasswordResetToken(token: string) {
const record = await consumeEmailToken(token, "PASSWORD_RESET");
if (!record?.userId) {