feat: add invitation link limits
This commit is contained in:
@@ -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
|
## Scenario: Facility Room Creation Without Fabricated Hierarchy
|
||||||
|
|
||||||
### 1. Scope / Trigger
|
### 1. Scope / Trigger
|
||||||
|
|||||||
1
.trellis/tasks/07-03-invite-link-limits/check.jsonl
Normal file
1
.trellis/tasks/07-03-invite-link-limits/check.jsonl
Normal 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."}
|
||||||
82
.trellis/tasks/07-03-invite-link-limits/design.md
Normal file
82
.trellis/tasks/07-03-invite-link-limits/design.md
Normal 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.
|
||||||
1
.trellis/tasks/07-03-invite-link-limits/implement.jsonl
Normal file
1
.trellis/tasks/07-03-invite-link-limits/implement.jsonl
Normal 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."}
|
||||||
42
.trellis/tasks/07-03-invite-link-limits/implement.md
Normal file
42
.trellis/tasks/07-03-invite-link-limits/implement.md
Normal 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.
|
||||||
48
.trellis/tasks/07-03-invite-link-limits/prd.md
Normal file
48
.trellis/tasks/07-03-invite-link-limits/prd.md
Normal 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.
|
||||||
26
.trellis/tasks/07-03-invite-link-limits/task.json
Normal file
26
.trellis/tasks/07-03-invite-link-limits/task.json
Normal 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": {}
|
||||||
|
}
|
||||||
@@ -2,6 +2,14 @@ import { randomBytes } from "node:crypto";
|
|||||||
|
|
||||||
import { and, eq } from "drizzle-orm";
|
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 { jsonFailure, jsonSuccess, readJsonBody } from "@/modules/core/server/api";
|
||||||
import { recordAuditLog } from "@/modules/core/server/audit";
|
import { recordAuditLog } from "@/modules/core/server/audit";
|
||||||
import { requirePermission } from "@/modules/core/server/auth";
|
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() : "";
|
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 {
|
function createInvitationToken(): string {
|
||||||
return randomBytes(24).toString("base64url");
|
return randomBytes(24).toString("base64url");
|
||||||
}
|
}
|
||||||
@@ -55,6 +82,26 @@ export async function POST(request: Request, context: RouteContext): Promise<Res
|
|||||||
return jsonFailure("请选择邀请角色");
|
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 database = getDatabase();
|
||||||
const organizationRows = await database.select().from(organizations).where(eq(organizations.id, id)).limit(1);
|
const organizationRows = await database.select().from(organizations).where(eq(organizations.id, id)).limit(1);
|
||||||
const organization = organizationRows[0];
|
const organization = organizationRows[0];
|
||||||
@@ -81,7 +128,9 @@ export async function POST(request: Request, context: RouteContext): Promise<Res
|
|||||||
token: createInvitationToken(),
|
token: createInvitationToken(),
|
||||||
status: "active",
|
status: "active",
|
||||||
createdByAccountId: auth.context.account.id,
|
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();
|
.returning();
|
||||||
const invitation = rows[0];
|
const invitation = rows[0];
|
||||||
@@ -96,7 +145,7 @@ export async function POST(request: Request, context: RouteContext): Promise<Res
|
|||||||
targetType: "organizationInvitation",
|
targetType: "organizationInvitation",
|
||||||
targetId: invitation.id,
|
targetId: invitation.id,
|
||||||
result: "success",
|
result: "success",
|
||||||
reason: `创建机构邀请:${email || "通用邀请"}`,
|
reason: `创建机构邀请:${email || "通用邀请"},有效期 ${validityDaysResult.value} 天,最多使用 ${maxUsesResult.value} 次`,
|
||||||
});
|
});
|
||||||
|
|
||||||
return jsonSuccess("邀请已创建", { invitation }, 201);
|
return jsonSuccess("邀请已创建", { invitation }, 201);
|
||||||
|
|||||||
3
drizzle/0006_petite_night_nurse.sql
Normal file
3
drizzle/0006_petite_night_nurse.sql
Normal 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';
|
||||||
4510
drizzle/meta/0006_snapshot.json
Normal file
4510
drizzle/meta/0006_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -43,6 +43,13 @@
|
|||||||
"when": 1783071837853,
|
"when": 1783071837853,
|
||||||
"tag": "0005_quiet_sandman",
|
"tag": "0005_quiet_sandman",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 6,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1783077936270,
|
||||||
|
"tag": "0006_petite_night_nurse",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
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 { 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 { cookies } from "next/headers";
|
||||||
|
|
||||||
import { jsonFailure } from "@/modules/core/server/api";
|
import { jsonFailure } from "@/modules/core/server/api";
|
||||||
@@ -324,12 +324,21 @@ export async function createRegistration(input: {
|
|||||||
.limit(1)
|
.limit(1)
|
||||||
: [];
|
: [];
|
||||||
const invitation = invitationRows[0];
|
const invitation = invitationRows[0];
|
||||||
|
const now = new Date();
|
||||||
if (input.invitationToken && !invitation) {
|
if (input.invitationToken && !invitation) {
|
||||||
throw new Error("邀请链接无效");
|
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("邀请链接已失效");
|
throw new Error("邀请链接已失效");
|
||||||
}
|
}
|
||||||
|
if (invitation?.invitation.email && normalizeEmail(invitation.invitation.email) !== normalizedEmail) {
|
||||||
|
throw new Error("邀请邮箱与注册邮箱不一致");
|
||||||
|
}
|
||||||
if (!invitation && !settings.registrationEnabled) {
|
if (!invitation && !settings.registrationEnabled) {
|
||||||
throw new Error("系统暂未开放注册");
|
throw new Error("系统暂未开放注册");
|
||||||
}
|
}
|
||||||
@@ -372,15 +381,40 @@ export async function createRegistration(input: {
|
|||||||
roleId: invitation.invitation.roleId,
|
roleId: invitation.invitation.roleId,
|
||||||
status: "active",
|
status: "active",
|
||||||
});
|
});
|
||||||
await transaction
|
const consumedRows = await transaction
|
||||||
.update(organizationInvitations)
|
.update(organizationInvitations)
|
||||||
.set({
|
.set({
|
||||||
status: "accepted",
|
usedCount: sql`${organizationInvitations.usedCount} + 1`,
|
||||||
acceptedByAccountId: account.id,
|
|
||||||
acceptedAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
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
|
const sessionRows = await transaction
|
||||||
|
|||||||
@@ -168,6 +168,8 @@ export const organizationInvitations = pgTable("organization_invitations", {
|
|||||||
acceptedByAccountId: uuid("accepted_by_account_id").references(() => accounts.id),
|
acceptedByAccountId: uuid("accepted_by_account_id").references(() => accounts.id),
|
||||||
acceptedAt: timestamp("accepted_at", { withTimezone: true }),
|
acceptedAt: timestamp("accepted_at", { withTimezone: true }),
|
||||||
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
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(),
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
}, (table) => ({
|
}, (table) => ({
|
||||||
|
|||||||
@@ -113,6 +113,8 @@ function toOrganizationInvitation(row: {
|
|||||||
acceptedByAccountId: row.invitation.acceptedByAccountId ?? undefined,
|
acceptedByAccountId: row.invitation.acceptedByAccountId ?? undefined,
|
||||||
acceptedAt: row.invitation.acceptedAt ? iso(row.invitation.acceptedAt) : undefined,
|
acceptedAt: row.invitation.acceptedAt ? iso(row.invitation.acceptedAt) : undefined,
|
||||||
expiresAt: iso(row.invitation.expiresAt),
|
expiresAt: iso(row.invitation.expiresAt),
|
||||||
|
maxUses: row.invitation.maxUses,
|
||||||
|
usedCount: row.invitation.usedCount,
|
||||||
createdAt: iso(row.invitation.createdAt),
|
createdAt: iso(row.invitation.createdAt),
|
||||||
updatedAt: iso(row.invitation.updatedAt),
|
updatedAt: iso(row.invitation.updatedAt),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -210,6 +210,8 @@ export async function readData(): Promise<AppData> {
|
|||||||
acceptedByAccountId: row.invitation.acceptedByAccountId ?? undefined,
|
acceptedByAccountId: row.invitation.acceptedByAccountId ?? undefined,
|
||||||
acceptedAt: row.invitation.acceptedAt ? iso(row.invitation.acceptedAt) : undefined,
|
acceptedAt: row.invitation.acceptedAt ? iso(row.invitation.acceptedAt) : undefined,
|
||||||
expiresAt: iso(row.invitation.expiresAt),
|
expiresAt: iso(row.invitation.expiresAt),
|
||||||
|
maxUses: row.invitation.maxUses,
|
||||||
|
usedCount: row.invitation.usedCount,
|
||||||
createdAt: iso(row.invitation.createdAt),
|
createdAt: iso(row.invitation.createdAt),
|
||||||
updatedAt: iso(row.invitation.updatedAt),
|
updatedAt: iso(row.invitation.updatedAt),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -156,6 +156,8 @@ export type OrganizationInvitation = {
|
|||||||
acceptedByAccountId?: string;
|
acceptedByAccountId?: string;
|
||||||
acceptedAt?: string;
|
acceptedAt?: string;
|
||||||
expiresAt: string;
|
expiresAt: string;
|
||||||
|
maxUses: number;
|
||||||
|
usedCount: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ export function GlobalSettingsClient({
|
|||||||
<h3 className="text-sm font-semibold">机构邀请</h3>
|
<h3 className="text-sm font-semibold">机构邀请</h3>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
邀请链接格式为 `/register?invite=...`,可限定邮箱并指定入组角色,适合定向开通账号。
|
邀请链接格式为 `/register?invite=...`,可限定邮箱、角色、有效期和使用次数,适合定向开通账号。
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
<section className="rounded-md border p-3">
|
<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="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">
|
<div className="min-w-0 text-sm">
|
||||||
<p className="font-medium">前往机构管理生成邀请码</p>
|
<p className="font-medium">前往机构管理生成邀请码</p>
|
||||||
<p className="mt-1 text-muted-foreground">选择机构行内的“邀请”,生成一次性或邮箱限定的注册链接。</p>
|
<p className="mt-1 text-muted-foreground">选择机构行内的“邀请”,生成有有效期和使用次数限制的注册链接。</p>
|
||||||
</div>
|
</div>
|
||||||
<LinkButton href={organizationSettingsHref} variant="outline">
|
<LinkButton href={organizationSettingsHref} variant="outline">
|
||||||
<UserPlus className="size-4" aria-hidden="true" />
|
<UserPlus className="size-4" aria-hidden="true" />
|
||||||
|
|||||||
@@ -179,12 +179,13 @@ export function OrganizationDetailClient({
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="grid gap-4">
|
<CardContent className="grid gap-4">
|
||||||
<div className="overflow-x-auto rounded-md border">
|
<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.Header className="bg-secondary text-left text-xs text-muted-foreground">
|
||||||
<Table.Row>
|
<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 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.Head className="px-4 py-3 text-right font-medium">链接</Table.Head>
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
@@ -197,6 +198,9 @@ export function OrganizationDetailClient({
|
|||||||
<Table.Cell className="px-4 py-3">
|
<Table.Cell className="px-4 py-3">
|
||||||
<Badge variant={invitation.status === "active" ? "success" : "secondary"}>{invitation.status}</Badge>
|
<Badge variant={invitation.status === "active" ? "success" : "secondary"}>{invitation.status}</Badge>
|
||||||
</Table.Cell>
|
</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">
|
<Table.Cell className="px-4 py-3 text-muted-foreground">
|
||||||
{new Date(invitation.expiresAt).toLocaleString("zh-CN")}
|
{new Date(invitation.expiresAt).toLocaleString("zh-CN")}
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
@@ -215,7 +219,7 @@ export function OrganizationDetailClient({
|
|||||||
))}
|
))}
|
||||||
{invitations.length === 0 ? (
|
{invitations.length === 0 ? (
|
||||||
<Table.Row>
|
<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.Cell>
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
@@ -225,7 +229,7 @@ export function OrganizationDetailClient({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
<LinkIcon className="size-4" aria-hidden="true" />
|
<LinkIcon className="size-4" aria-hidden="true" />
|
||||||
邀请链接使用 `/register?invite=...`,注册后自动加入机构。
|
邀请链接使用 `/register?invite=...`,注册成功后计入使用次数并自动加入机构。
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -8,6 +8,14 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Dialog } from "@/components/ui/dialog";
|
import { Dialog } from "@/components/ui/dialog";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Select } from "@/components/ui/select";
|
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 { ApiResult } from "@/modules/core/server/api";
|
||||||
import type { Organization, OrganizationInvitation, RoleDefinition } from "@/modules/core/types";
|
import type { Organization, OrganizationInvitation, RoleDefinition } from "@/modules/core/types";
|
||||||
|
|
||||||
@@ -64,7 +72,9 @@ export function OrganizationInviteDialog({
|
|||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
email: String(formData.get("email") ?? ""),
|
email: String(formData.get("email") ?? ""),
|
||||||
|
maxUses: Number(formData.get("maxUses") ?? DEFAULT_INVITATION_MAX_USES),
|
||||||
roleId,
|
roleId,
|
||||||
|
validityDays: Number(formData.get("validityDays") ?? DEFAULT_INVITATION_VALIDITY_DAYS),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const result = (await response.json()) as ApiResult<{ invitation: OrganizationInvitation }>;
|
const result = (await response.json()) as ApiResult<{ invitation: OrganizationInvitation }>;
|
||||||
@@ -84,7 +94,7 @@ export function OrganizationInviteDialog({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
description={`生成 ${organization.name} 的邀请链接,可限定邮箱并指定入组角色。`}
|
description={`生成 ${organization.name} 的邀请链接,可限定邮箱、入组角色、有效期和使用次数。`}
|
||||||
onClose={closeDialog}
|
onClose={closeDialog}
|
||||||
open={open}
|
open={open}
|
||||||
title="发起机构邀请"
|
title="发起机构邀请"
|
||||||
@@ -109,6 +119,26 @@ export function OrganizationInviteDialog({
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</label>
|
</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 ? (
|
{message ? (
|
||||||
<p className="rounded-md bg-secondary px-3 py-2 text-sm text-secondary-foreground" role="status">
|
<p className="rounded-md bg-secondary px-3 py-2 text-sm text-secondary-foreground" role="status">
|
||||||
{message}
|
{message}
|
||||||
|
|||||||
Reference in New Issue
Block a user