feat: add invitation link limits

This commit is contained in:
2026-07-03 04:59:34 -07:00
parent fb15d4db47
commit 9fd2088a2a
20 changed files with 4968 additions and 15 deletions

View File

@@ -1,6 +1,6 @@
import { randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
import { and, eq, gt, isNull } from "drizzle-orm";
import { and, eq, gt, gte, isNull, lt, sql } from "drizzle-orm";
import { cookies } from "next/headers";
import { jsonFailure } from "@/modules/core/server/api";
@@ -324,12 +324,21 @@ export async function createRegistration(input: {
.limit(1)
: [];
const invitation = invitationRows[0];
const now = new Date();
if (input.invitationToken && !invitation) {
throw new Error("邀请链接无效");
}
if (invitation && (invitation.invitation.status !== "active" || invitation.invitation.expiresAt < new Date())) {
if (
invitation &&
(invitation.invitation.status !== "active" ||
invitation.invitation.expiresAt < now ||
invitation.invitation.usedCount >= invitation.invitation.maxUses)
) {
throw new Error("邀请链接已失效");
}
if (invitation?.invitation.email && normalizeEmail(invitation.invitation.email) !== normalizedEmail) {
throw new Error("邀请邮箱与注册邮箱不一致");
}
if (!invitation && !settings.registrationEnabled) {
throw new Error("系统暂未开放注册");
}
@@ -372,15 +381,40 @@ export async function createRegistration(input: {
roleId: invitation.invitation.roleId,
status: "active",
});
await transaction
const consumedRows = await transaction
.update(organizationInvitations)
.set({
status: "accepted",
acceptedByAccountId: account.id,
acceptedAt: new Date(),
usedCount: sql`${organizationInvitations.usedCount} + 1`,
updatedAt: new Date(),
})
.where(eq(organizationInvitations.id, invitation.invitation.id));
.where(
and(
eq(organizationInvitations.id, invitation.invitation.id),
eq(organizationInvitations.status, "active"),
gte(organizationInvitations.expiresAt, now),
lt(organizationInvitations.usedCount, organizationInvitations.maxUses),
),
)
.returning({
id: organizationInvitations.id,
maxUses: organizationInvitations.maxUses,
usedCount: organizationInvitations.usedCount,
});
const consumed = consumedRows[0];
if (!consumed) {
throw new Error("邀请链接已失效");
}
if (consumed.usedCount >= consumed.maxUses) {
await transaction
.update(organizationInvitations)
.set({
status: "accepted",
acceptedByAccountId: account.id,
acceptedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(organizationInvitations.id, consumed.id));
}
}
const sessionRows = await transaction

View File

@@ -168,6 +168,8 @@ export const organizationInvitations = pgTable("organization_invitations", {
acceptedByAccountId: uuid("accepted_by_account_id").references(() => accounts.id),
acceptedAt: timestamp("accepted_at", { withTimezone: true }),
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
maxUses: integer("max_uses").notNull().default(1),
usedCount: integer("used_count").notNull().default(0),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
}, (table) => ({

View File

@@ -113,6 +113,8 @@ function toOrganizationInvitation(row: {
acceptedByAccountId: row.invitation.acceptedByAccountId ?? undefined,
acceptedAt: row.invitation.acceptedAt ? iso(row.invitation.acceptedAt) : undefined,
expiresAt: iso(row.invitation.expiresAt),
maxUses: row.invitation.maxUses,
usedCount: row.invitation.usedCount,
createdAt: iso(row.invitation.createdAt),
updatedAt: iso(row.invitation.updatedAt),
};

View File

@@ -210,6 +210,8 @@ export async function readData(): Promise<AppData> {
acceptedByAccountId: row.invitation.acceptedByAccountId ?? undefined,
acceptedAt: row.invitation.acceptedAt ? iso(row.invitation.acceptedAt) : undefined,
expiresAt: iso(row.invitation.expiresAt),
maxUses: row.invitation.maxUses,
usedCount: row.invitation.usedCount,
createdAt: iso(row.invitation.createdAt),
updatedAt: iso(row.invitation.updatedAt),
}));