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:
- How it works (the flow, the data, the endpoints).
- What happens in each step, and why it is safe under concurrency.
- How to try it locally.
- What production needs.
Related docs:
- Organizations and units: organizations, role assignments, units, who may invite whom.
- Authentication: the same token rules, Mailpit setup, SMTP in production.
- Authentication: login and sessions.
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_FORBIDDENotherwise). 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 withX-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 (403PERMISSION_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_MEMBERfor a live member of their own organization, and the list rows carry no “has an account” flag. Only the holder of the token seesaccountExistsat 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’sdefaultLanguage) 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):
| Field | Meaning |
|---|---|
organizationId | The organization the invitee joins. Required. |
email | Lowercased. Indexed, not unique (old rows stay as history). |
| assignments | Rows { roleId, unitId } (unitId null = whole organization; set = that unit and its descendants). |
language | EL / EN. |
tokenHash | sha256 of the token in the link. The raw token is never stored. |
expiresAt | 7 days after the last send. |
lastSentAt | Used for the 60 s resend limit. |
acceptedAt | Set when accepted. |
acceptedUserId | The user the invitation created or joined. |
invitedById | Who invited. No foreign key (they may be deleted). |
// @base | isDeleted, 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:
| State | Condition | Link works? | In pending list? |
|---|---|---|---|
| pending | acceptedAt = null, not deleted, not expired | yes | yes |
| expired | pending, but expiresAt <= now | no | yes, marked in red |
| accepted | acceptedAt set | no | no |
| revoked | soft-deleted by revoke, or superseded by a new invite | no | no |
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):
| Endpoint | Permission | Response |
|---|---|---|
POST /api/members/invitations | Invitations.Create | 201 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/invitations | Invitations.Read | Pending invitations, newest first, { data, total, page, pageSize }, search on email. Each row has assignments (role + unit) and expired. |
POST /api/members/invitations/:id/resend | Invitations.Resend | 204. 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/:id | Invitations.Delete | 204. 404 if accepted, already revoked, unknown or another organization’s. |
GET /api/members/role-options | Members.Read | The roles an assignment may name, for the invite dialog. |
GET /api/members/unit-options | Members.Read | The units an assignment may be scoped to (the caller’s reach). |
Public, rate limited:
| Endpoint | Response |
|---|---|
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-invitation | New 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
| Part | File |
|---|---|
| Contracts | libs/core/shared/src/lib/invitation.ts, units.ts (RoleAssignmentInput) |
| Writing an invitation | libs/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 validation | libs/core/api/invitations/src/lib/utils/assignable-roles.ts, unit-reach.ts (also used by PATCH /members/:userId/roles) |
| Invitee side | libs/core/api/auth/src/lib/services/invitation-accept.service.ts, controllers/auth.controller.ts |
| Joining an organization | libs/core/db/src/lib/join-organization.ts |
| Token helper | newEmailToken() in libs/core/api/security/src/lib/utils/token.util.ts |
| Email template | libs/core/api/mail/src/lib/templates/invitation.ts |
| UI | libs/core/client/users-feature/src/lib/components/members/invite-member-dialog/, components/pending-invitations/ |
| Accept page | libs/core/client/auth-feature/src/lib/components/accept-invitation/ |
| E2E tests | apps/keelmint/api-e2e/src/api/invitations.spec.ts |
2. Step by step
Invite (OrganizationInviter.inviteToOrganization)
- 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.
- Generate the token: 32 random bytes, base64url. Keep only its sha256.
- 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_MEMBERwhen 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_INVITEDaudit event, with the organization on it.
- Queue the invitation email in the same transaction (a
mail.sendjob, 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)
- Find the pending invitation of the active organization (within the caller’s reach), or answer 404.
- If the invitee is a live member by now, answer 409
ALREADY_MEMBER: a new link could only fail. - If the last send was less than 60 s ago, answer
400 "Resent too recently". - Update it with a new token hash, a new 7-day expiry and
lastSentAt = now. The update only matches iflastSentAtis 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. - Write
USER_INVITATION_RESENTand 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)
- Find the live invitation by token hash, as in peek. A dead link answers 400 before any password hashing.
- Hash the password before opening the transaction (argon2 is slow).
- 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 = nowand the invitation’s language, then the membership with the assignments (joinOrganization; a unit-scoped assignment also links the member to the unit). - Store
acceptedUserIdon the invitation. - Write
USER_INVITATION_ACCEPTED(actor and target: the new user; the organization on the event).USER_REGISTEREDis not written: this event is the account’s creation event. - Notify the new user (
WELCOME) and the inviter (INVITATION_ACCEPTED).
- Claim it:
Accept, existing account (the same method, no password)
- 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_MISMATCHbefore anything is written. - 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. - Write
USER_INVITATION_ACCEPTED(metadata.existingAccount: true) and notify the inviter. No new account and noWELCOME. 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:
| Write | Condition |
|---|---|
| accept | acceptedAt: null (and the same tokenHash) |
| revoke | acceptedAt: null |
| supersede | acceptedAt: null (by organization and email) |
| resend | acceptedAt: 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_MEMBERinstead 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
| Action | Actor | Target | Metadata |
|---|---|---|---|
USER_INVITED | the inviter | invitation | { email, roles, assignments, language, supersededId } |
USER_INVITATION_RESENT | the inviter | invitation | { email } |
USER_INVITATION_REVOKED | the inviter | invitation | { email } |
USER_INVITATION_ACCEPTED | the new or joining user | user | { 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
-
Apply the migration and start the services:
Terminal window docker compose up -d db mailpitpnpm nx run keelmint-db:migratepnpm nx run keelmint-db:seed # optional: admin@keelmint.local / keelmint-dev-password -
Run the API and the client as usual, and log in as
alice@keelmint.local(anORG_ADMINof Acme Accounting). -
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.
-
Open Mailpit at http://localhost:8025 and click the link in the email.
-
Fill in the name and password. You land on the login page with “Your account is ready”; log in with the new password.
-
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 invitebob@keelmint.localfrom 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:migratebefore deploying the code. Invitations belong to an organization (user_invitations.organizationId) and carry their roles inuser_invitation_assignments; the organizations migrations backfill pending invitations into thedefaultorganization. - 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 thetokenquery 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.