feat: add invitation link limits
This commit is contained in:
7
modules/core/invitation-limits.ts
Normal file
7
modules/core/invitation-limits.ts
Normal 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;
|
||||
@@ -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
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
}));
|
||||
|
||||
@@ -156,6 +156,8 @@ export type OrganizationInvitation = {
|
||||
acceptedByAccountId?: string;
|
||||
acceptedAt?: string;
|
||||
expiresAt: string;
|
||||
maxUses: number;
|
||||
usedCount: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user