Skip to content

Invitations

An invitation brings an email address into one organization with chosen role assignments. The invitee gets a single-use link and either sets their name and password (new account) or logs in and accepts (existing account). Either way they end up with a membership in the organization holding exactly those assignments. This page covers:

  1. How it works (the flow, the data, the endpoints).
  2. What happens in each step, and why it is safe under concurrency.
  3. How to try it locally.
  4. What production needs.

Related docs:


1. How it works

Who invites

  • An organization admin (Invitations.Create, org-wide) invites into the active organization, with any role and scope.
  • A unit admin (the same permission, scoped to a unit) invites only into their unit’s subtree: every assignment must name a unit inside it (403 UNIT_FORBIDDEN otherwise). They list, resend and revoke only invitations wholly inside their subtree; others are 404.
  • The platform admin creates an organization (POST /api/organizations) and the first admin is invited in the same transaction (ownerEmail, ownerRoleIds). The platform admin can also invite in any organization like an admin, naming it with X-Organization-Id.

The flow

Org admin: Members → "Invite member" (email, role + scope, language)
│
└─► POST /api/members/invitations ──► 201 invitation (pending)
│
└─► email naming the inviter and the organization:
APP_URL/auth/accept-invitation?token=XXXX (7 days, single use)
│
Invitee clicks ──► client: /auth/accept-invitation
│
├─► GET /api/auth/invitation?token=XXXX
│ 200 { email, expiresAt, organization: { name }, accountExists }
│ 400 ──► "link expired, revoked or used" (no form)
│ 409 ──► a deleted account has the email: it can neither sign up nor log in
│
├─ accountExists = false ──► form (first name, last name, password)
│ └─► POST /api/auth/accept-invitation { token, password, firstName, lastName }
│ 204 ──► /auth/login?invited=1 ("your account is ready")
│ 400 ──► dead link
│ 400 ORGANIZATION_SUSPENDED ──► nothing written, invitation stays pending
│ 409 ──► "a user with this email already exists" (registered meanwhile)
│
└─ accountExists = true ──► "log in to accept"
└─► POST /api/auth/accept-invitation { token } (Bearer token of the invited account)
204 ──► membership created (or a removed one restored)
401 ──► no session
400 ORGANIZATION_SUSPENDED ──► nothing written, invitation stays pending
403 INVITATION_EMAIL_MISMATCH ──► the session is another email; nothing written
409 ALREADY_MEMBER ──► invitation stays pending
  • An invitation is not a user or a membership. Inviting stores a row in user_invitations (plus its assignments). The user and the membership are created only when the link is accepted, so an unused invite never appears in the members list.
  • An invitation speaks with its inviter’s authority. Its link works only while the inviter still acts for the organization: a platform admin or a live member there. Once they leave, are removed or delete their account, their pending invitations are dead links (peek and accept answer 400 INVALID_OR_EXPIRED_TOKEN, and Google sign-in passes them over). Resending one makes the resender its inviter, so they must hold what it grants (403 PERMISSION_NOT_HELD), as when inviting.
  • Existing accounts can be invited. The account does not have to be new; this is how one person joins a second organization with one login. Accepting needs the session of the invited email.
  • Accepting with a new account does not log you in. It creates the account and sends the invitee to the login page, the same as password reset. The new account is already verified (the link proves the inbox).
  • No account enumeration for inviters. Inviting, listing and resending never reveal whether the email has a platform account: the inviter’s only signal is 409 ALREADY_MEMBER for a live member of their own organization, and the list rows carry no “has an account” flag. Only the holder of the token sees accountExists at peek. The one exception is the platform side (creating an organization), which refuses an email held by a deleted account, because that account could never accept.
  • Language: the invite’s language (el/en, default the product’s defaultLanguage) picks the email’s language and becomes a new user’s preference. The email names the inviter and the organization (subject and body).

The data

UserInvitation (libs/core/db/prisma/core/auth.prisma, table user_invitations) and UserInvitationAssignment (user_invitation_assignments):

FieldMeaning
organizationIdThe organization the invitee joins. Required.
emailLowercased. Indexed, not unique (old rows stay as history).
assignmentsRows { roleId, unitId } (unitId null = whole organization; set = that unit and its descendants).
languageEL / EN.
tokenHashsha256 of the token in the link. The raw token is never stored.
expiresAt7 days after the last send.
lastSentAtUsed for the 60 s resend limit.
acceptedAtSet when accepted.
acceptedUserIdThe user the invitation created or joined.
invitedByIdWho invited. No foreign key (they may be deleted).
// @baseisDeleted, deletedAt, … - soft delete = revoked/superseded.

At most one pending invitation per (organization, email): enforced by the service under an advisory lock, not by an index. An invitation to another organization for the same email is independent.

An invitation is in one of these states:

StateConditionLink works?In pending list?
pendingacceptedAt = null, not deleted, not expiredyesyes
expiredpending, but expiresAt <= nownoyes, marked in red
acceptedacceptedAt setnono
revokedsoft-deleted by revoke, or superseded by a new invitenono

Expired invitations can still be resent (fresh link, fresh 7 days) or revoked.

Endpoints

In the active organization (@Entity({ name: 'Invitations', units: true }); needs an active organization, 403 ORGANIZATION_REQUIRED without; a platform admin names it with X-Organization-Id, 400 ORGANIZATION_REQUIRED without):

EndpointPermissionResponse
POST /api/members/invitationsInvitations.Create201 invitation. Body { email, assignments: [{ roleId, unitId }], language? }. 409 ALREADY_MEMBER for a live member. 400 unknown role or unit. 403 UNIT_FORBIDDEN.
GET /api/members/invitationsInvitations.ReadPending invitations, newest first, { data, total, page, pageSize }, search on email. Each row has assignments (role + unit) and expired.
POST /api/members/invitations/:id/resendInvitations.Resend204. 400 "Resent too recently" within 60 s. 404 if accepted, revoked, unknown or another organization’s. 409 ALREADY_MEMBER if the invitee is a member by now.
DELETE /api/members/invitations/:idInvitations.Delete204. 404 if accepted, already revoked, unknown or another organization’s.
GET /api/members/role-optionsMembers.ReadThe roles an assignment may name, for the invite dialog.
GET /api/members/unit-optionsMembers.ReadThe units an assignment may be scoped to (the caller’s reach).

Public, rate limited:

EndpointResponse
GET /api/auth/invitation?token=…200 { email, expiresAt, organization: { name }, accountExists }, 400 INVALID_OR_EXPIRED_TOKEN, or 409 if a deleted account has the email.
POST /api/auth/accept-invitationNew account (with password, firstName, lastName): 204, 400 INVALID_OR_EXPIRED_TOKEN, 409 email taken, 400 validation. Existing account ({ token } + Bearer): 204, 401, 403 INVITATION_EMAIL_MISMATCH, 409 ALREADY_MEMBER. Either: 400 ORGANIZATION_SUSPENDED while the organization is suspended (the invitation stays pending).

Every dead link (unknown, used, expired, revoked, superseded) gets the same 400 INVALID_OR_EXPIRED_TOKEN, so the link reveals nothing about why it died.

The token is sent as a query parameter on the peek route, not as a path segment. The API’s request log records only the path, so the token never appears in it; a proxy in front logs full URLs (see Production).

Where the code is

PartFile
Contractslibs/core/shared/src/lib/invitation.ts, units.ts (RoleAssignmentInput)
Writing an invitationlibs/core/api/invitations/src/lib/services/organization-inviter.ts (shared by the members side and organization creation)
Members side (routes)libs/core/api/users/src/lib/services/invitations.service.ts, controllers/invitations.controller.ts
Role and unit validationlibs/core/api/invitations/src/lib/utils/assignable-roles.ts, unit-reach.ts (also used by PATCH /members/:userId/roles)
Invitee sidelibs/core/api/auth/src/lib/services/invitation-accept.service.ts, controllers/auth.controller.ts
Joining an organizationlibs/core/db/src/lib/join-organization.ts
Token helpernewEmailToken() in libs/core/api/security/src/lib/utils/token.util.ts
Email templatelibs/core/api/mail/src/lib/templates/invitation.ts
UIlibs/core/client/users-feature/src/lib/components/members/invite-member-dialog/, components/pending-invitations/
Accept pagelibs/core/client/auth-feature/src/lib/components/accept-invitation/
E2E testsapps/keelmint/api-e2e/src/api/invitations.spec.ts

2. Step by step

Invite (OrganizationInviter.inviteToOrganization)

  1. Lowercase the email. Validate the assignments: at least one, every role must exist and be assignable, every unit must be a live unit of the organization and (for a unit-scoped inviter) inside their subtree. When a unit is named, the organization’s units lock is taken first, so a concurrent unit delete cannot slip in.
  2. Generate the token: 32 random bytes, base64url. Keep only its sha256.
  3. In one transaction:
    • Take a Postgres advisory lock on (organization, email), so two invites for the same pair run one after the other (accepting takes the same lock).
    • Refuse with 409 ALREADY_MEMBER when the email is a live member of this organization.
    • Soft-delete every pending invitation for this (organization, email) (supersede). Their links die. A unit-scoped inviter may only supersede invitations wholly inside their subtree.
    • Create the new invitation and its assignments.
    • Write the USER_INVITED audit event, with the organization on it.
  4. Queue the invitation email in the same transaction (a mail.send job, encrypted). The background worker sends it and retries SMTP failures; see Background jobs.

The email may belong to an existing account: that is fine and is not revealed.

Resend (InvitationsService.resend)

  1. Find the pending invitation of the active organization (within the caller’s reach), or answer 404.
  2. If the invitee is a live member by now, answer 409 ALREADY_MEMBER: a new link could only fail.
  3. If the last send was less than 60 s ago, answer 400 "Resent too recently".
  4. Update it with a new token hash, a new 7-day expiry and lastSentAt = now. The update only matches if lastSentAt is still the value just read, so of two simultaneous resends only one wins. The loser answers 400, or 404 if it lost to a revoke or accept.
  5. Write USER_INVITATION_RESENT and queue the email in the same transaction. The previous link stops working because its hash no longer matches.

Revoke (InvitationsService.revoke)

Soft-delete the invitation only if it is still not accepted (softDeleteMany({ id, acceptedAt: null })), within the organization and the caller’s reach. If nothing matched, answer 404. Write USER_INVITATION_REVOKED in the same transaction.

Peek (InvitationAcceptService.peek)

Look the invitation up by sha256(token). The soft-delete filter hides revoked and superseded rows automatically. If it is missing, accepted or expired, answer 400. Otherwise return the email, expiry, the organization’s name and accountExists, so the page shows “You were invited to …” and either the sign-up form or “log in to accept”. If a deleted account has the email, answer 409: it can neither sign up nor log in.

Accept, new account (InvitationAcceptService.accept, with a password)

  1. Find the live invitation by token hash, as in peek. A dead link answers 400 before any password hashing.
  2. Hash the password before opening the transaction (argon2 is slow).
  3. In one transaction:
    • Claim it: updateMany({ id, tokenHash, acceptedAt: null } -> acceptedAt = now). If the count is not 1, someone else got there first: answer 400.
    • Check that no user has the email (raw SQL, so soft-deleted users count). If one does, answer 409. The transaction rolls back and the invitation stays pending. A user inserted between that check and ours makes the insert fail with P2002, answered with the same 409.
    • Resolve the assignments: keep the invited ones whose role and unit still exist. If none remain, use the default role (the organization’s, else the platform’s), org-wide. A membership is never created with zero roles.
    • Create the user with emailVerifiedAt = now and the invitation’s language, then the membership with the assignments (joinOrganization; a unit-scoped assignment also links the member to the unit).
    • Store acceptedUserId on the invitation.
    • Write USER_INVITATION_ACCEPTED (actor and target: the new user; the organization on the event). USER_REGISTERED is not written: this event is the account’s creation event.
    • Notify the new user (WELCOME) and the inviter (INVITATION_ACCEPTED).

Accept, existing account (the same method, no password)

  1. The request needs the access token of the invited account (401 without). The account’s email is read from the account row, not the token, and must equal the invitation’s: otherwise 403 INVITATION_EMAIL_MISMATCH before anything is written.
  2. In one transaction, under the (organization, email) lock: claim the invitation as above, resolve the assignments, and create the membership - or restore a removed one, replacing its old roles. A live member already is 409 ALREADY_MEMBER, which rolls the claim back.
  3. Write USER_INVITATION_ACCEPTED (metadata.existingAccount: true) and notify the inviter. No new account and no WELCOME. The accepted organization becomes available in the switcher at the next token refresh or switch.

Why the races are safe

Every write that changes an invitation’s state is conditional on the invitation still being unaccepted:

WriteCondition
acceptacceptedAt: null (and the same tokenHash)
revokeacceptedAt: null
supersedeacceptedAt: null (by organization and email)
resendacceptedAt: null and the lastSentAt just read

The soft-delete extension also adds isDeleted: false, so a revoked or superseded row matches none of them.

In Postgres (READ COMMITTED), when two transactions update the same row, the second waits for the first to commit and then re-checks its condition. So:

  • Two tabs accept at once: one claims the row, the other waits, re-checks, matches nothing and answers 400. Only one user or membership is created.
  • Revoke against accept: whichever touches the row first wins. Either the invitation is revoked and the accept answers 400, or the account is created and the revoke answers 404. Never both.
  • Re-invite against accept: the supersede waits for the accept, then skips the accepted row. The membership check right after it sees the new member, so the re-invite answers 409 ALREADY_MEMBER instead of mailing someone who already joined.
  • Resend against revoke or accept: the resend matches nothing and answers 404.
  • Invite into a unit against deleting that unit: both take the organization’s units lock; the delete also counts pending invitations that name the unit (409 UNIT_NOT_EMPTY).

The e2e suite fires these pairs concurrently against the real database.

Audit events

ActionActorTargetMetadata
USER_INVITEDthe inviterinvitation{ email, roles, assignments, language, supersededId }
USER_INVITATION_RESENTthe inviterinvitation{ email }
USER_INVITATION_REVOKEDthe inviterinvitation{ email }
USER_INVITATION_ACCEPTEDthe new or joining useruser{ invitationId, roles, assignments } (+ existingAccount: true)

All four are written in the same transaction as the change: no audit row, no change. Each carries the organization. The token is never in the metadata.


3. Try it locally

  1. Apply the migration and start the services:

    Terminal window
    docker compose up -d db mailpit
    pnpm nx run keelmint-db:migrate
    pnpm nx run keelmint-db:seed # optional: admin@keelmint.local / keelmint-dev-password
  2. Run the API and the client as usual, and log in as alice@keelmint.local (an ORG_ADMIN of Acme Accounting).

  3. Go to Members (sidebar, Administration section) and press Invite member. Enter an email, pick a role and a scope (the whole organization, or a unit) and a language, and send.

  4. Open Mailpit at http://localhost:8025 and click the link in the email.

  5. Fill in the name and password. You land on the login page with “Your account is ready”; log in with the new password.

  6. Back in Members, the invitation is gone from the pending list and the user is in the members list. Administration → Audit log shows the events.

Things worth trying:

  • Invite the same email twice: the first link stops working.
  • Press Resend twice within a minute: the second answers “Resent too recently”.
  • Revoke, then open the link: “link expired, revoked or used”.
  • As admin@keelmint.local, open Globex Partners under Organizations (/organizations/<id>) and invite bob@keelmint.local from its Members tab: bob is not a member there, so it is accepted (and the response does not say bob has an account). Open the link in a window logged in as bob: the page asks to accept with the session, and Globex Partners appears in his switcher. Log in as someone else first and accept: INVITATION_EMAIL_MISMATCH.
  • Invite an existing member of the organization: 409 ALREADY_MEMBER.
  • Log in as bob@keelmint.local (unit admin of Manchester) and invite: the scope picker offers only Manchester and Payroll, and an org-wide role is refused.

4. Production

  • Migration: run pnpm nx run <product>-db:migrate before deploying the code. Invitations belong to an organization (user_invitations.organizationId) and carry their roles in user_invitation_assignments; the organizations migrations backfill pending invitations into the default organization.
  • SMTP: the same provider as verification and reset (Authentication, section 3). The link uses APP_URL, so it must be the public client URL.
  • Reverse proxy logs: the API’s own log never records the token, but a proxy or CDN in front of it logs full URLs, including ?token= on the peek route and on the client page. Strip or mask the token query parameter in those logs. This applies to the verification and reset links too.
  • Mail failures: the invitation email is retried (5 retries, backoff from 30 s). If it still fails, an error is logged with the job id and the job is kept 7 days in mail.send.failed. The inviter sees the invitation as pending and can press Resend.
  • Cleanup: expired invitations are not deleted automatically. They show in the pending list as expired and can be revoked.

Known limitations

  • A retried invitation mail whose link was superseded meanwhile can arrive after the newer one; following it shows the “expired or revoked” page. See Background jobs.