mirror of
https://github.com/JetSprow/J-Board-Lite.git
synced 2026-05-01 01:14:10 +05:30
fix: create users after email verification
This commit is contained in:
@@ -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
|
||||
@@ -780,10 +798,10 @@ model AppConfig {
|
||||
subscriptionRiskIpLimitPerHour Int @default(180)
|
||||
subscriptionRiskTokenLimitPerHour Int @default(60)
|
||||
nodeAccessRiskEnabled Boolean @default(true)
|
||||
nodeAccessConnectionWarning Int @default(180)
|
||||
nodeAccessConnectionSuspend Int @default(360)
|
||||
nodeAccessUniqueTargetWarning Int @default(80)
|
||||
nodeAccessUniqueTargetSuspend Int @default(160)
|
||||
nodeAccessConnectionWarning Int @default(180)
|
||||
nodeAccessConnectionSuspend Int @default(360)
|
||||
nodeAccessUniqueTargetWarning Int @default(80)
|
||||
nodeAccessUniqueTargetSuspend Int @default(160)
|
||||
inviteRewardCouponId String?
|
||||
inviteRewardRate Decimal @default(0)
|
||||
inviteRewardEnabled Boolean @default(false)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 ? "没有收到?重新发送" : "去登录"}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user