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])
|
@@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 {
|
model SubscriptionCategory {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String
|
name String
|
||||||
@@ -780,10 +798,10 @@ model AppConfig {
|
|||||||
subscriptionRiskIpLimitPerHour Int @default(180)
|
subscriptionRiskIpLimitPerHour Int @default(180)
|
||||||
subscriptionRiskTokenLimitPerHour Int @default(60)
|
subscriptionRiskTokenLimitPerHour Int @default(60)
|
||||||
nodeAccessRiskEnabled Boolean @default(true)
|
nodeAccessRiskEnabled Boolean @default(true)
|
||||||
nodeAccessConnectionWarning Int @default(180)
|
nodeAccessConnectionWarning Int @default(180)
|
||||||
nodeAccessConnectionSuspend Int @default(360)
|
nodeAccessConnectionSuspend Int @default(360)
|
||||||
nodeAccessUniqueTargetWarning Int @default(80)
|
nodeAccessUniqueTargetWarning Int @default(80)
|
||||||
nodeAccessUniqueTargetSuspend Int @default(160)
|
nodeAccessUniqueTargetSuspend Int @default(160)
|
||||||
inviteRewardCouponId String?
|
inviteRewardCouponId String?
|
||||||
inviteRewardRate Decimal @default(0)
|
inviteRewardRate Decimal @default(0)
|
||||||
inviteRewardEnabled Boolean @default(false)
|
inviteRewardEnabled Boolean @default(false)
|
||||||
|
|||||||
@@ -7,7 +7,15 @@ import { prisma } from "@/lib/prisma";
|
|||||||
import { rateLimit } from "@/lib/rate-limit";
|
import { rateLimit } from "@/lib/rate-limit";
|
||||||
import { getClientIp } from "@/lib/request-context";
|
import { getClientIp } from "@/lib/request-context";
|
||||||
import { getAppConfig } from "@/services/app-config";
|
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({
|
const emailSchema = z.object({
|
||||||
email: z.string().trim().email("请输入正确的邮箱"),
|
email: z.string().trim().email("请输入正确的邮箱"),
|
||||||
@@ -83,7 +91,13 @@ export async function requestRegistrationVerification(formData: FormData) {
|
|||||||
email: user.email,
|
email: user.email,
|
||||||
...context,
|
...context,
|
||||||
});
|
});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await resendPendingRegistrationVerificationEmail({
|
||||||
|
email,
|
||||||
|
...context,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resetPasswordByEmail(formData: FormData) {
|
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>
|
<div className="text-4xl" aria-hidden="true">🎉</div>
|
||||||
<h1 className="text-xl font-semibold tracking-tight">{requiresEmailVerification ? "验证邮件已发送" : "注册成功"}</h1>
|
<h1 className="text-xl font-semibold tracking-tight">{requiresEmailVerification ? "验证邮件已发送" : "注册成功"}</h1>
|
||||||
<p className="text-sm leading-6 text-muted-foreground">
|
<p className="text-sm leading-6 text-muted-foreground">
|
||||||
{requiresEmailVerification ? "请查收邮箱并完成验证,验证后即可登录。" : "账户已创建,请登录。"}
|
{requiresEmailVerification ? "请查收邮箱并完成验证,验证后会自动创建账户。" : "账户已创建,请登录。"}
|
||||||
</p>
|
</p>
|
||||||
<Link href={requiresEmailVerification ? "/verify-email-request" : "/login"} className={buttonVariants({ size: "lg", className: "w-full" })}>
|
<Link href={requiresEmailVerification ? "/verify-email-request" : "/login"} className={buttonVariants({ size: "lg", className: "w-full" })}>
|
||||||
{requiresEmailVerification ? "没有收到?重新发送" : "去登录"}
|
{requiresEmailVerification ? "没有收到?重新发送" : "去登录"}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { getAppConfig } from "@/services/app-config";
|
|||||||
import { verifyTurnstile } from "@/lib/turnstile";
|
import { verifyTurnstile } from "@/lib/turnstile";
|
||||||
import { rateLimit } from "@/lib/rate-limit";
|
import { rateLimit } from "@/lib/rate-limit";
|
||||||
import { getClientIp } from "@/lib/request-context";
|
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";
|
import { decryptIfEncrypted } from "@/lib/crypto";
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
@@ -90,33 +90,37 @@ export async function POST(req: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const hashedPassword = await bcrypt.hash(password, 12);
|
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) {
|
if (config.emailVerificationRequired) {
|
||||||
try {
|
try {
|
||||||
await sendRegistrationVerificationEmail({
|
await sendPendingRegistrationVerificationEmail({
|
||||||
userId: user.id,
|
email,
|
||||||
email: user.email,
|
passwordHash: hashedPassword,
|
||||||
|
name: name || null,
|
||||||
|
inviteCode: inviteCode || null,
|
||||||
|
invitedById: inviterId,
|
||||||
headers: req.headers,
|
headers: req.headers,
|
||||||
requestUrl: req.url,
|
requestUrl: req.url,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await prisma.user.delete({ where: { id: user.id } }).catch(() => null);
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: error instanceof Error ? error.message : "验证邮件发送失败" },
|
{ error: error instanceof Error ? error.message : "验证邮件发送失败" },
|
||||||
{ status: 500 },
|
{ 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 });
|
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: {
|
export async function sendPasswordResetEmail(input: {
|
||||||
userId: string;
|
userId: string;
|
||||||
email: string;
|
email: string;
|
||||||
@@ -237,6 +316,9 @@ export async function consumeEmailToken(token: string, purpose?: EmailPurpose) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function verifyEmailToken(token: string) {
|
export async function verifyEmailToken(token: string) {
|
||||||
|
const pendingResult = await consumePendingRegistrationToken(token);
|
||||||
|
if (pendingResult) return pendingResult;
|
||||||
|
|
||||||
const record = await consumeEmailToken(token);
|
const record = await consumeEmailToken(token);
|
||||||
if (!record) return { ok: false as const, message: "验证链接无效或已过期" };
|
if (!record) return { ok: false as const, message: "验证链接无效或已过期" };
|
||||||
|
|
||||||
@@ -280,6 +362,79 @@ export async function verifyEmailToken(token: string) {
|
|||||||
return { ok: false as const, message: "这个链接不能用于邮箱验证" };
|
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) {
|
export async function consumePasswordResetToken(token: string) {
|
||||||
const record = await consumeEmailToken(token, "PASSWORD_RESET");
|
const record = await consumeEmailToken(token, "PASSWORD_RESET");
|
||||||
if (!record?.userId) {
|
if (!record?.userId) {
|
||||||
|
|||||||
Reference in New Issue
Block a user