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

@@ -0,0 +1,7 @@
export const DEFAULT_INVITATION_VALIDITY_DAYS = 7;
export const MIN_INVITATION_VALIDITY_DAYS = 1;
export const MAX_INVITATION_VALIDITY_DAYS = 365;
export const DEFAULT_INVITATION_MAX_USES = 1;
export const MIN_INVITATION_MAX_USES = 1;
export const MAX_INVITATION_MAX_USES = 100;

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),
}));

View File

@@ -156,6 +156,8 @@ export type OrganizationInvitation = {
acceptedByAccountId?: string;
acceptedAt?: string;
expiresAt: string;
maxUses: number;
usedCount: number;
createdAt: string;
updatedAt: string;
};

View File

@@ -167,7 +167,7 @@ export function GlobalSettingsClient({
<h3 className="text-sm font-semibold"></h3>
</div>
<p className="mt-2 text-sm text-muted-foreground">
`/register?invite=...`
`/register?invite=...`使
</p>
</section>
<section className="rounded-md border p-3">
@@ -183,7 +183,7 @@ export function GlobalSettingsClient({
<div className="flex flex-col gap-3 rounded-md border bg-secondary/25 p-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0 text-sm">
<p className="font-medium"></p>
<p className="mt-1 text-muted-foreground"></p>
<p className="mt-1 text-muted-foreground">使</p>
</div>
<LinkButton href={organizationSettingsHref} variant="outline">
<UserPlus className="size-4" aria-hidden="true" />

View File

@@ -179,12 +179,13 @@ export function OrganizationDetailClient({
</CardHeader>
<CardContent className="grid gap-4">
<div className="overflow-x-auto rounded-md border">
<Table className="w-full min-w-[720px] text-sm">
<Table className="w-full min-w-[820px] text-sm">
<Table.Header className="bg-secondary text-left text-xs text-muted-foreground">
<Table.Row>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 font-medium">使</Table.Head>
<Table.Head className="px-4 py-3 font-medium"></Table.Head>
<Table.Head className="px-4 py-3 text-right font-medium"></Table.Head>
</Table.Row>
@@ -197,6 +198,9 @@ export function OrganizationDetailClient({
<Table.Cell className="px-4 py-3">
<Badge variant={invitation.status === "active" ? "success" : "secondary"}>{invitation.status}</Badge>
</Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">
{invitation.usedCount} / {invitation.maxUses}
</Table.Cell>
<Table.Cell className="px-4 py-3 text-muted-foreground">
{new Date(invitation.expiresAt).toLocaleString("zh-CN")}
</Table.Cell>
@@ -215,7 +219,7 @@ export function OrganizationDetailClient({
))}
{invitations.length === 0 ? (
<Table.Row>
<Table.Cell className="px-4 py-8 text-center text-muted-foreground" colSpan={5}>
<Table.Cell className="px-4 py-8 text-center text-muted-foreground" colSpan={6}>
</Table.Cell>
</Table.Row>
@@ -225,7 +229,7 @@ export function OrganizationDetailClient({
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<LinkIcon className="size-4" aria-hidden="true" />
使 `/register?invite=...`
使 `/register?invite=...`使
</div>
</CardContent>
</Card>

View File

@@ -8,6 +8,14 @@ import { Button } from "@/components/ui/button";
import { Dialog } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Select } from "@/components/ui/select";
import {
DEFAULT_INVITATION_MAX_USES,
DEFAULT_INVITATION_VALIDITY_DAYS,
MAX_INVITATION_MAX_USES,
MAX_INVITATION_VALIDITY_DAYS,
MIN_INVITATION_MAX_USES,
MIN_INVITATION_VALIDITY_DAYS,
} from "@/modules/core/invitation-limits";
import type { ApiResult } from "@/modules/core/server/api";
import type { Organization, OrganizationInvitation, RoleDefinition } from "@/modules/core/types";
@@ -64,7 +72,9 @@ export function OrganizationInviteDialog({
headers: { "content-type": "application/json" },
body: JSON.stringify({
email: String(formData.get("email") ?? ""),
maxUses: Number(formData.get("maxUses") ?? DEFAULT_INVITATION_MAX_USES),
roleId,
validityDays: Number(formData.get("validityDays") ?? DEFAULT_INVITATION_VALIDITY_DAYS),
}),
});
const result = (await response.json()) as ApiResult<{ invitation: OrganizationInvitation }>;
@@ -84,7 +94,7 @@ export function OrganizationInviteDialog({
return (
<Dialog
description={`生成 ${organization.name} 的邀请链接,可限定邮箱并指定入组角色。`}
description={`生成 ${organization.name} 的邀请链接,可限定邮箱入组角色、有效期和使用次数`}
onClose={closeDialog}
open={open}
title="发起机构邀请"
@@ -109,6 +119,26 @@ export function OrganizationInviteDialog({
required
/>
</label>
<div className="grid gap-3 sm:grid-cols-2">
<Input
defaultValue={DEFAULT_INVITATION_VALIDITY_DAYS}
label="有效期(天)"
max={MAX_INVITATION_VALIDITY_DAYS}
min={MIN_INVITATION_VALIDITY_DAYS}
name="validityDays"
required
type="number"
/>
<Input
defaultValue={DEFAULT_INVITATION_MAX_USES}
label="最大使用次数"
max={MAX_INVITATION_MAX_USES}
min={MIN_INVITATION_MAX_USES}
name="maxUses"
required
type="number"
/>
</div>
{message ? (
<p className="rounded-md bg-secondary px-3 py-2 text-sm text-secondary-foreground" role="status">
{message}