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

@@ -111,6 +111,107 @@ await database.transaction(async (transaction) => {
});
```
## Scenario: Organization Invitation Limits
### 1. Scope / Trigger
- Trigger: changing organization invitation creation, registration-by-invite consumption, or the `organization_invitations` table.
- Applies because invitation links are persisted in PostgreSQL, created through a protected organization API, consumed inside account registration, and displayed in organization settings.
### 2. Signatures
- `POST /api/organizations/[id]/invitations`
- Request: `{ email?: string; roleId: string; validityDays?: number; maxUses?: number }`
- Response: `ApiResult<{ invitation: typeof organizationInvitations.$inferSelect }>`
- DB columns:
- `organization_invitations.expires_at timestamp with time zone not null`
- `organization_invitations.max_uses integer not null default 1`
- `organization_invitations.used_count integer not null default 0`
- Registration helper: `createRegistration({ name, email, password, organizationId?, invitationToken? })`
### 3. Contracts
- Invite creation requires `account:manage` and may only target the active organization when the session is organization-scoped.
- `roleId` must refer to an enabled role in the target organization.
- If invitation `email` is non-empty, registration must use the same normalized email address.
- `validityDays` defaults to `7` and must be a positive integer within the product limit.
- `maxUses` defaults to `1` and must be a positive integer within the product limit.
- "Use count" means successful invitation registrations, not page visits or token preview requests.
- A token is consumable only when `status = 'active'`, `expires_at >= now`, and `used_count < max_uses`.
- Successful registration inserts the account and membership in the same transaction before consuming the invitation.
- Consuming an invitation increments `used_count`; only when the returned count reaches `max_uses` should the row become `status = 'accepted'` and receive `accepted_by_account_id` / `accepted_at`.
### 4. Validation & Error Matrix
- Invalid JSON body -> `400` / `请求数据格式无效`.
- Missing `roleId` -> `400` / `请选择邀请角色`.
- Invalid `validityDays` -> `400` / `邀请有效期需为 ... 的整数`.
- Invalid `maxUses` -> `400` / `最大使用次数需为 ... 的整数`.
- Target organization missing -> `404` / `机构不存在`.
- Role missing, disabled, or cross-organization -> `404` / `角色不存在`.
- Unknown invitation token during registration -> `邀请链接无效`.
- Expired, non-active, or exhausted invitation token during registration -> `邀请链接已失效`.
- Registration email mismatch for an email-limited invitation -> `邀请邮箱与注册邮箱不一致`.
### 5. Good/Base/Bad Cases
- Good: keep invitation limit constants shared between the create API and invite dialog so UI bounds and backend validation stay aligned.
- Good: consume invitation links with an atomic `UPDATE ... WHERE used_count < max_uses` predicate and check that a row was returned.
- Good: derive invitation list UI from the persisted `used_count / max_uses` fields.
- Base: old one-use behavior remains the default by using `validityDays = 7` and `maxUses = 1`.
- Bad: count page loads as invitation usage; link previews, refreshes, and bots can exhaust a link without a registration.
- Bad: decide whether a link is exhausted only from a value read before registration; concurrent registrations can over-consume the link.
### 6. Tests Required
- `pnpm db:generate`, then review SQL for only additive invitation limit columns or intentional invitation changes.
- `pnpm lint`
- `pnpm type-check`
- `pnpm test`
- `pnpm build`
- Integration assertions when route-level tests cover auth registration:
- default invite creation returns a 7-day, one-use link
- custom `validityDays` and `maxUses` are persisted
- expired and exhausted links fail registration
- successful registration increments `used_count`
- the final allowed registration marks the invitation accepted
### 7. Wrong vs Correct
#### Wrong
```ts
if (invitation.usedCount >= invitation.maxUses) {
throw new Error("邀请链接已失效");
}
await transaction.update(organizationInvitations)
.set({ usedCount: invitation.usedCount + 1 })
.where(eq(organizationInvitations.id, invitation.id));
```
#### Correct
```ts
const consumedRows = await transaction
.update(organizationInvitations)
.set({ usedCount: sql`${organizationInvitations.usedCount} + 1` })
.where(and(
eq(organizationInvitations.id, 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("邀请链接已失效");
}
```
## Scenario: Facility Room Creation Without Fabricated Hierarchy
### 1. Scope / Trigger

View File

@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}

View File

@@ -0,0 +1,82 @@
# Invite Link Limits Design
## Architecture
This change spans the persisted invitation model, the invitation create API, the registration consumption transaction, and the organization settings UI.
The invitation row remains the source of truth. The existing `expires_at` column continues to represent validity. New persisted counters will represent usage limits:
- `max_uses`: maximum successful invitation registrations allowed.
- `used_count`: number of successful registrations that have consumed the invitation.
## Data Model
Add non-null integer columns to `organization_invitations`:
- `max_uses integer not null default 1`
- `used_count integer not null default 0`
Existing invitation rows remain compatible: previous one-time invites become `max_uses = 1`, `used_count = 0` unless already accepted. The existing `status = accepted` already prevents use of accepted legacy rows.
## Invitation Creation Contract
`POST /api/organizations/[id]/invitations` accepts:
- `email?: string`
- `roleId: string`
- `validityDays?: number`
- `maxUses?: number`
Defaults:
- `validityDays = 7`
- `maxUses = 1`
Validation:
- `roleId` is still required.
- `validityDays` must be a positive integer in an operationally reasonable range.
- `maxUses` must be a positive integer in an operationally reasonable range.
- Invalid input returns `jsonFailure(...)` with the existing structured API shape.
## Invitation Consumption
Registration only counts a use after account creation and membership insertion succeed inside the existing transaction.
An invitation is valid only if:
- `status === "active"`
- `expiresAt >= now`
- `usedCount < maxUses`
On successful invitation registration:
- Increment `usedCount`.
- If the increment reaches `maxUses`, set `status = "accepted"` and fill `acceptedByAccountId` / `acceptedAt` with the consuming account details.
- If remaining uses exist, keep `status = "active"` and leave accepted metadata empty.
The update should guard against concurrent over-consumption by updating with a `used_count < max_uses` predicate and checking that a row was returned.
## UI
`OrganizationInviteDialog` adds controls for:
- Validity days, default 7.
- Maximum uses, default 1.
`OrganizationDetailClient` displays:
- Expiry time.
- Usage as `usedCount / maxUses`.
Copy behavior remains unchanged.
## Compatibility
The existing `/register?invite=...` URL format remains unchanged.
Existing rows without the new columns are migrated through default values. Existing one-time semantics are preserved by default.
## Rollback
If needed, UI inputs can be hidden and backend defaults can keep the old behavior. Dropping the columns would require a reverse migration only if deployed migrations must be rolled back.

View File

@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}

View File

@@ -0,0 +1,42 @@
# Invite Link Limits Implementation Plan
## Checklist
1. Update Trellis planning artifacts and get approval to start implementation.
2. Load pre-development specs with `trellis-before-dev`.
3. Update `modules/core/server/schema.ts`:
- Add `maxUses` and `usedCount` integer columns to `organizationInvitations`.
4. Generate or add the Drizzle migration for the new columns.
5. Update shared invitation types and row mappers:
- `modules/core/types.ts`
- `modules/core/server/settings.ts`
- `modules/core/server/store.ts`
6. Update invitation creation API:
- Parse `validityDays` and `maxUses`.
- Validate positive integer ranges.
- Persist `expiresAt`, `maxUses`, and `usedCount`.
7. Update invitation registration flow:
- Reject exhausted links.
- Increment usage inside the transaction.
- Mark status accepted only when `usedCount` reaches `maxUses`.
- Guard concurrent consumption with an update predicate.
8. Update frontend:
- Add validity and maximum-use controls to `OrganizationInviteDialog`.
- Display usage count in `OrganizationDetailClient`.
- Update invitation explanatory text where it still says invitations are one-time only.
9. Run validation:
- `pnpm lint`
- `pnpm type-check`
- `pnpm test` if tests are relevant or fast enough after type changes.
- `pnpm build` if lint/type-check pass.
## Risk Points
- Concurrent registrations could over-consume an invite unless the update predicate checks `used_count < max_uses`.
- Existing accepted invitations should remain unusable regardless of migrated counter defaults.
- Frontend numeric inputs submit strings, so backend parsing must not trust the client.
## Rollback Points
- Before migration: schema/API/UI changes can be reverted together.
- After migration: reverting code while keeping columns is safe because defaults preserve one-use invite behavior.

View File

@@ -0,0 +1,48 @@
# Invite link limits
## Goal
Allow organization admins to configure invitation link validity and usage limits when creating invite links.
This should make invite links safer to distribute by letting admins choose how long a link remains valid and how many times it may be used.
## Confirmed Facts
- Invitation links are created from `modules/settings/components/OrganizationInviteDialog.tsx`.
- The invitation create API is `app/api/organizations/[id]/invitations/route.ts`.
- Invitation persistence is backed by the `organization_invitations` table in `modules/core/server/schema.ts`.
- Invitations already have an `expiresAt` column and are currently created with a fixed 7-day validity window.
- Registration consumes invitation tokens through `modules/core/server/auth.ts`.
- The current registration flow marks an invitation as `accepted` after one successful invitation registration, so existing invite links behave as single-use links.
- Invitation rows are shown in `modules/settings/components/OrganizationDetailClient.tsx`, currently with target, role, status, expiry, and copy action.
## Requirements
- Admins can configure an invitation link's validity period when creating an organization invitation.
- Admins can configure the maximum usage count for an invitation link when creating an organization invitation.
- Existing behavior remains compatible by default: if the admin does not change the controls, a new invite should expire after 7 days and be usable once.
- The backend validates create-invitation inputs and rejects invalid limit values with structured API failures.
- The registration path rejects expired or exhausted invitation links.
- Invitation list data exposes enough information to show configured limits and current usage.
- The UI makes the configured validity and usage limit visible when creating and reviewing invitations.
- Maximum access count means maximum successful invitation registrations, not page loads or token preview requests.
## Out of Scope
- Invitation revocation management beyond existing status behavior.
- Editing limits after an invitation has been created.
- Tracking anonymous page views unless product intent explicitly requires page-load based access counting.
## Acceptance Criteria
- [x] Creating an invitation with default form values produces a 7-day, one-use invitation.
- [x] Creating an invitation with a custom validity period stores and displays the corresponding expiry.
- [x] Creating an invitation with a custom maximum usage count stores and displays the configured usage limit.
- [x] Registration with an expired invitation fails with the existing invalid/expired invitation behavior.
- [x] Registration after the invitation has reached its maximum allowed uses fails.
- [x] Successful invitation registration increments usage accounting and only exhausts the link when the configured maximum is reached.
- [x] Type-check, lint, and relevant tests/build checks pass.
## Notes
- Recommended interpretation: count successful invitation registrations as usage. Counting page loads would make links vulnerable to browser refreshes, previews, bots, and accidental visits, and it would require adding a separate token validation/view endpoint.

View File

@@ -0,0 +1,26 @@
{
"id": "invite-link-limits",
"name": "invite-link-limits",
"title": "Invite link limits",
"description": "",
"status": "in_progress",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "TalexDreamSoul",
"assignee": "TalexDreamSoul",
"createdAt": "2026-07-03",
"completedAt": null,
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {}
}

View File

@@ -2,6 +2,14 @@ import { randomBytes } from "node:crypto";
import { and, eq } from "drizzle-orm";
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 { jsonFailure, jsonSuccess, readJsonBody } from "@/modules/core/server/api";
import { recordAuditLog } from "@/modules/core/server/audit";
import { requirePermission } from "@/modules/core/server/auth";
@@ -23,6 +31,25 @@ function readString(source: Record<string, unknown>, key: string): string {
return typeof value === "string" ? value.trim() : "";
}
function readBoundedInteger(
source: Record<string, unknown>,
key: string,
options: {
defaultValue: number;
min: number;
max: number;
label: string;
},
): { success: true; value: number } | { success: false; reason: string } {
const rawValue = source[key];
const value = rawValue === undefined || rawValue === null ? options.defaultValue : Number(rawValue);
if (!Number.isInteger(value) || value < options.min || value > options.max) {
return { success: false, reason: `${options.label}需为 ${options.min}-${options.max} 的整数` };
}
return { success: true, value };
}
function createInvitationToken(): string {
return randomBytes(24).toString("base64url");
}
@@ -55,6 +82,26 @@ export async function POST(request: Request, context: RouteContext): Promise<Res
return jsonFailure("请选择邀请角色");
}
const validityDaysResult = readBoundedInteger(body, "validityDays", {
defaultValue: DEFAULT_INVITATION_VALIDITY_DAYS,
min: MIN_INVITATION_VALIDITY_DAYS,
max: MAX_INVITATION_VALIDITY_DAYS,
label: "邀请有效期",
});
if (!validityDaysResult.success) {
return jsonFailure(validityDaysResult.reason);
}
const maxUsesResult = readBoundedInteger(body, "maxUses", {
defaultValue: DEFAULT_INVITATION_MAX_USES,
min: MIN_INVITATION_MAX_USES,
max: MAX_INVITATION_MAX_USES,
label: "最大使用次数",
});
if (!maxUsesResult.success) {
return jsonFailure(maxUsesResult.reason);
}
const database = getDatabase();
const organizationRows = await database.select().from(organizations).where(eq(organizations.id, id)).limit(1);
const organization = organizationRows[0];
@@ -81,7 +128,9 @@ export async function POST(request: Request, context: RouteContext): Promise<Res
token: createInvitationToken(),
status: "active",
createdByAccountId: auth.context.account.id,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
expiresAt: new Date(Date.now() + validityDaysResult.value * 24 * 60 * 60 * 1000),
maxUses: maxUsesResult.value,
usedCount: 0,
})
.returning();
const invitation = rows[0];
@@ -96,7 +145,7 @@ export async function POST(request: Request, context: RouteContext): Promise<Res
targetType: "organizationInvitation",
targetId: invitation.id,
result: "success",
reason: `创建机构邀请:${email || "通用邀请"}`,
reason: `创建机构邀请:${email || "通用邀请"},有效期 ${validityDaysResult.value} 天,最多使用 ${maxUsesResult.value}`,
});
return jsonSuccess("邀请已创建", { invitation }, 201);

View File

@@ -0,0 +1,3 @@
ALTER TABLE "organization_invitations" ADD COLUMN "max_uses" integer DEFAULT 1 NOT NULL;--> statement-breakpoint
ALTER TABLE "organization_invitations" ADD COLUMN "used_count" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
UPDATE "organization_invitations" SET "used_count" = "max_uses" WHERE "status" <> 'active';

File diff suppressed because it is too large Load Diff

View File

@@ -43,6 +43,13 @@
"when": 1783071837853,
"tag": "0005_quiet_sandman",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1783077936270,
"tag": "0006_petite_night_nurse",
"breakpoints": true
}
]
}

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,6 +381,30 @@ export async function createRegistration(input: {
roleId: invitation.invitation.roleId,
status: "active",
});
const consumedRows = await transaction
.update(organizationInvitations)
.set({
usedCount: sql`${organizationInvitations.usedCount} + 1`,
updatedAt: new Date(),
})
.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({
@@ -380,7 +413,8 @@ export async function createRegistration(input: {
acceptedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(organizationInvitations.id, invitation.invitation.id));
.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}