Team Invitations
In B2B mode, workspace owners and admins can invite new members via email.
Invitation Flow
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ Admin sends │ │ Invitee gets │ │ Invitee clicks │
│ invitation │────▶│ email with │────▶│ accept link │
│ (email + role) │ │ magic link │ │ │
└──────────────────┘ └──────────────────┘ └──────────────────┘
│
▼
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ Member added │ │ Membership │ │ Token validated │
│ to workspace │◀────│ created with │◀────│ (7-day expiry) │
│ │ │ assigned role │ │ │
└──────────────────┘ └──────────────────┘ └──────────────────┘Sending Invitations
Admins and owners can invite new members from the org dashboard's Members page. Invitations support the configured admin and member roles; owner and custom roles cannot be assigned through this flow. The system generates a unique token, stores the invitation with the configured expiry (7 days by default), then sends a localized email after the database transaction commits. If delivery fails, the invitation remains available to resend.
Invitation API
Invitations are created by a server action from the workspace members page (not a public REST endpoint). The HTTP API surface is POST /api/invitations/accept (accept an invite), POST /api/invitations/resend (owner/admin re-send), and POST /api/invitations/revoke (owner/admin revoke). Creation checks workspace permissions and whether the recipient is already a member. Acceptance, resend and revocation revalidate their authority inside the database transaction.
Accepting Invitations
When a user clicks the invitation link, they are taken to /invite/accept which previews a pending invitation by its exact token. Expiry is checked when they confirm acceptance. An authenticated user explicitly confirms acceptance. Otherwise, they first log in or create an account, then return to complete acceptance. The membership and accepted status commit together. Accepting an invitation for an existing member preserves that member's role; an owner invitation already stored in the database grants admin. An accepted token cannot be used again.
An invitation can only be accepted by the address it was sent to. Addresses are compared case-insensitively and ignoring surrounding whitespace, but they must otherwise match: signing in as a different account and opening the link shows a “wrong account” screen naming the signed-in address, the invited one partially masked, and a button to sign out and switch. This is the most common reason an otherwise valid invitation appears not to work — in B2B mode users often already have their own account, and the invitation may have gone to a different address than the one they habitually sign in with.
The invitation token is 64 hexadecimal characters (32 random bytes), not a UUID. If you add your own validation anywhere along this path, derive it from invitationTokenPattern in config/workspace.ts rather than restating the format.
Invitation Database Schema
The invitations table stores the account ID, invited email, role slug, invitation token (unique), expiry date, status (pending / accepted / expired / revoked), and who sent the invitation. RLS scopes reads to the invitation's account; writes go through server actions / SECURITY DEFINER paths, not user-level INSERT policies.
Managing Pending Invitations
The Members page in the org dashboard shows all pending invitations alongside current members. Admins can renew an invitation whose date has expired while its status is still pending, or cancel an invitation. Accepted, expired and revoked statuses cannot be renewed. The email is sent after the renewal commits; a delivery error leaves the renewed invitation in place. The pending-invitations.tsx component displays the invitee's email, assigned role, expiry status, and action buttons.
Invitation Email Templates
Invitation emails are rendered by a single shared builder — buildInvitationEmail() in lib/email/templates.ts — wrapped in the universal email chrome from lib/email/layout.ts. Locale strings come from the email.invitation.* i18n keys (FR/EN), so there is no separate workspace-invitation-fr / -en file pair.
| Builder | Source | Parameters |
|---|---|---|
buildInvitationEmail |
lib/email/templates.ts |
workspaceName, role, inviteLink, inviterName, appName, locale |
- Invitation tokens are single-use and expire after 7 days
- Only owners and admins can send invitations
- Users cannot invite themselves
- Duplicate invitations to the same email are prevented
B2B Subscription Flow
Invitations sit on top of an already-existing workspace, so the workspace bootstrap, pending-checkout cookie, configured-provider round-trip, and onboarding gate live in the parent page. See Multi-Tenancy Overview for the full B2B subscription flow (five auto-bootstrap call sites, single onboarding gate, workspace-manager redirect rule).
Invited User Flow
When an invited user accepts an invitation:
- Click invitation link → Accept invitation page
- API-based acceptance via
/api/invitations/accept - Pending invitation revocation via
/api/invitations/revokewith authenticated CSRF-protected server validation - Redirect to success page → Onboarding (if needed)
- Subscription check: If workspace has active subscription, redirect via the workspace-manager rule (
isWorkspaceManager(user.id)) —/org-dashboardfor owners/admins, else/private-dashboard(not pricing) - Dashboard prioritizes workspace account for data display
Organization Member Limits
Invitation creation reserves capacity under the workspace lock. Existing memberships plus all pending invitations must fit within the workspace's explicit member limit, when set. Pending invitations also have a separate configured cap (10 by default). A pending invitation still reserves capacity after its date expires because it can be renewed; revoke it or let cleanup remove it to release the reservation. These checks govern invitation creation, not every administrative membership operation.
| Feature | Description |
|---|---|
max_members |
Configurable per organization (null = unlimited) |
| Enforcement | Creation checks memberships plus pending invitations against the workspace limit, and checks the configured pending-invitation cap |
| Admin Update | Can be updated via admin dashboard |
| Visual Indicator | Warning shown when limit is reached |
Member capacity is enforced in the database under an Account lock. Free limits come from pricing configuration; paid limits come from the accepted commercial snapshot, and license limits from the purchased license. A value of -1 means unlimited. The nullable administrative max_members field adds a ceiling; null removes only that extra ceiling. Pending invitations reserve seats. Renewals, acceptances after a downgrade and direct administrative additions cannot bypass the effective capacity. The members page displays that same effective limit.