fix: require email verification before activation

This commit is contained in:
JetSprow
2026-04-29 16:52:04 +10:00
parent aeeef895de
commit 69be1d6fcc
9 changed files with 21 additions and 8 deletions

View File

@@ -13,6 +13,7 @@ enum Role {
enum UserStatus { enum UserStatus {
ACTIVE ACTIVE
PENDING_EMAIL
DISABLED DISABLED
BANNED BANNED
} }

View File

@@ -77,7 +77,7 @@ export async function requestRegistrationVerification(formData: FormData) {
select: { id: true, email: true, status: true, emailVerifiedAt: true }, select: { id: true, email: true, status: true, emailVerifiedAt: true },
}); });
if (user?.status === "ACTIVE" && !user.emailVerifiedAt) { if (user && ["ACTIVE", "PENDING_EMAIL"].includes(user.status) && !user.emailVerifiedAt) {
await sendRegistrationVerificationEmail({ await sendRegistrationVerificationEmail({
userId: user.id, userId: user.id,
email: user.email, email: user.email,

View File

@@ -1,4 +1,4 @@
import type { Prisma, SubscriptionRiskEvent } from "@prisma/client"; import type { Prisma, SubscriptionRiskEvent, UserStatus } from "@prisma/client";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { parsePage } from "@/lib/utils"; import { parsePage } from "@/lib/utils";
import { import {
@@ -11,7 +11,7 @@ type RiskUser = {
id: string; id: string;
email: string; email: string;
name: string | null; name: string | null;
status: "ACTIVE" | "DISABLED" | "BANNED"; status: UserStatus;
}; };
type RiskSubscription = { type RiskSubscription = {

View File

@@ -44,6 +44,7 @@ export default async function UsersPage({
options: [ options: [
{ label: "全部状态", value: "" }, { label: "全部状态", value: "" },
{ label: "正常", value: "ACTIVE" }, { label: "正常", value: "ACTIVE" },
{ label: "待邮箱验证", value: "PENDING_EMAIL" },
{ label: "禁用", value: "DISABLED" }, { label: "禁用", value: "DISABLED" },
{ label: "封禁", value: "BANNED" }, { label: "封禁", value: "BANNED" },
], ],

View File

@@ -1,4 +1,4 @@
import type { Prisma } from "@prisma/client"; import type { Prisma, UserStatus } from "@prisma/client";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { parsePage } from "@/lib/utils"; import { parsePage } from "@/lib/utils";
@@ -31,7 +31,7 @@ export async function getAdminUsers(
const where = { const where = {
...(role ? { role: role as "ADMIN" | "USER" } : {}), ...(role ? { role: role as "ADMIN" | "USER" } : {}),
...(status ? { status: status as "ACTIVE" | "DISABLED" | "BANNED" } : {}), ...(status ? { status: status as UserStatus } : {}),
...(q ...(q
? { ? {
OR: [ OR: [

View File

@@ -90,6 +90,7 @@ export async function POST(req: Request) {
data: { data: {
email, email,
emailVerifiedAt: config.emailVerificationRequired ? null : new Date(), emailVerifiedAt: config.emailVerificationRequired ? null : new Date(),
status: config.emailVerificationRequired ? "PENDING_EMAIL" : "ACTIVE",
password: hashedPassword, password: hashedPassword,
name: name || null, name: name || null,
invitedById: inviterId, invitedById: inviterId,

View File

@@ -51,6 +51,7 @@ export const userRoleLabels: Record<Role, string> = {
export const userStatusLabels: Record<UserStatus, string> = { export const userStatusLabels: Record<UserStatus, string> = {
ACTIVE: "正常", ACTIVE: "正常",
PENDING_EMAIL: "待邮箱验证",
DISABLED: "禁用", DISABLED: "禁用",
BANNED: "封禁", BANNED: "封禁",
}; };
@@ -104,6 +105,7 @@ export function getSubscriptionTypeTone(type: SubscriptionType): StatusTone {
export function getUserStatusTone(status: UserStatus): StatusTone { export function getUserStatusTone(status: UserStatus): StatusTone {
if (status === "ACTIVE") return "success"; if (status === "ACTIVE") return "success";
if (status === "PENDING_EMAIL") return "info";
if (status === "DISABLED") return "warning"; if (status === "DISABLED") return "warning";
return "danger"; return "danger";
} }

View File

@@ -27,12 +27,17 @@ export const authOptions: NextAuthOptions = {
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { email: credentials.email.trim().toLowerCase() }, where: { email: credentials.email.trim().toLowerCase() },
}); });
if (!user || user.status !== "ACTIVE") return null; if (!user) return null;
const valid = await bcrypt.compare(credentials.password, user.password); const valid = await bcrypt.compare(credentials.password, user.password);
if (!valid) return null; if (!valid) return null;
if (config?.emailVerificationRequired && user.role !== "ADMIN" && !user.emailVerifiedAt) { if (
user.role !== "ADMIN" &&
!user.emailVerifiedAt &&
(config?.emailVerificationRequired || user.status === "PENDING_EMAIL")
) {
throw new Error("EMAIL_NOT_VERIFIED"); throw new Error("EMAIL_NOT_VERIFIED");
} }
if (user.status !== "ACTIVE") return null;
return { id: user.id, email: user.email, name: user.name, role: user.role }; return { id: user.id, email: user.email, name: user.name, role: user.role };
}, },
}), }),

View File

@@ -244,7 +244,10 @@ export async function verifyEmailToken(token: string) {
if (!record.userId) return { ok: false as const, message: "验证链接缺少账户信息" }; if (!record.userId) return { ok: false as const, message: "验证链接缺少账户信息" };
await prisma.user.update({ await prisma.user.update({
where: { id: record.userId }, where: { id: record.userId },
data: { emailVerifiedAt: new Date() }, data: {
emailVerifiedAt: new Date(),
status: "ACTIVE",
},
}); });
return { ok: true as const, message: "邮箱验证完成,现在可以登录账户。" }; return { ok: true as const, message: "邮箱验证完成,现在可以登录账户。" };
} }