Edit Page

Team Invitations

RESTHeart Cloud

Team invitations allow users with the owner role to add people to their tenant.

Important

The invite endpoint requires the JWT to contain the active tenant claim. Ensure the following configuration:

  • accountsConfig.tenant-claim-name: tenant (or your custom claim name)

  • jwtTokenManager.account-properties-claims: [tenant]

  • accountsConfig.account-properties-claims: [tenant]

Without these, the service cannot determine the caller’s team role and returns 403 Forbidden.

The behavior differs depending on whether the invited user already has an account.

flowchart TD
  A[POST /auth/invite\nrequires owner role] --> B{User exists?}
  B -- No --> C[Check pending invitation\nin auth_invitations]
  C -- Pending exists --> L2[409 Conflict]
  C -- None --> D[Create user roles: $unauthenticated\nCreate auth_invitations doc isNewUser=true]
  D --> E[Email: /auth/activate\n?email=…&token=…]
  E --> F1[PATCH /auth/activate\nemail + token + password]
  E --> F2[OAuth authorize\n?pendingInviteToken=…]
  F1 --> G[Set password\nMembershipProvider.addMember\nroles → user\nDelete auth_invitations\nAuto-login JWT cookie]
  F2 --> G2[addMember from invite\nactivateViaOAuth\nDelete auth_invitations\nJWT cookie]

  B -- Yes, not in tenant --> H[Create auth_invitations doc\nisNewUser=false TTL 7 days]
  H --> I[Email: /invitations/accept\n?email=…&token=…]
  I --> J1[POST /auth/accept-invite\ntoken — auth required]
  I --> J2[OAuth authorize\n?pendingInviteToken=…]
  J1 --> K[MembershipProvider.addMember\nsetActiveMembership\nDelete auth_invitations\n200 OK]
  J2 --> K

  B -- Yes, already member --> L[409 Conflict]

Invite a user

POST /auth/invite — requires role owner.

Request

POST /auth/invite
Authorization: Bearer <token>
Content-Type: application/json

{
  "email": "bob@example.com",
  "role":  "member"
}

role must be one of: the configured member-role-name (default "member") or the configured ownership-role (default "owner").

Important
The admin role is a system-level ACL role in RESTHeart and should NEVER be used as a team/membership role. Using admin as a membership role grants the user unrestricted access to the entire system.

Server-side steps

  1. Authorize the caller (must be owner of their tenant).

  2. Validate email format and role value.

  3. If the user already belongs to the caller’s tenant → 409 Conflict.

  4. If a pending invitation already exists for the same email+org in auth_invitations → 409 Conflict.

  5. If the user does not exist → create user with roles: ["$unauthenticated"]. Create an auth_invitations document with isNewUser: true. Send activation email with link to {frontend-url}/auth/activate?email=…​&token=…​. Membership is NOT added yet — it is deferred until the user activates.

  6. If the user exists but is NOT in this tenant → create an auth_invitations document with isNewUser: false. Do NOT add membership yet. Send invite email with link to {frontend-url}/invitations/accept?email=…​&token=…​.

  7. Return 201 Created.

Note

All invitation tokens — for both new and existing users — are stored exclusively in the auth_invitations collection (same database as users and acl). No inviteToken or inviteCreatedAt fields are written to user documents.

The auth_invitations document schema:

{
  "_id":       {"$oid": "..."},
  "email":     "user@example.com",
  "token":     "64-char hex",
  "orgId":     {"$oid": "..."},
  "role":      "member",
  "isNewUser": true,
  "createdAt": {"$date": 1234567890},
  "expiresAt": {"$date": 1234567890}
}
User type Email link

New user (no account)

{frontend-url}/auth/activate?email=…​&token=…​

Existing user

{frontend-url}/invitations/accept?email=…​&token=…​

Read invitation metadata

GET /auth/invitation?email=…​&token=…​ — public.

The frontend calls this on page load to determine which UI to show and to display the org name and role.

Both parameters are required. The (email, token) pair is known only to the invitee (delivered via private email link), so this endpoint cannot be used for enumeration.

Response 200 OK

{
  "email":     "bob@example.com",
  "orgName":   "Acme Corp",
  "role":      "member",
  "isNewUser": true,
  "expiresAt": "2026-06-24T10:00:00Z"
}

Frontend behavior

  • isNewUser: true → show "Set your password" form + OAuth buttons → PATCH /auth/activate

  • isNewUser: false → show "Log in to accept" form + OAuth buttons → login then POST /auth/accept-invite

Error responses

Status Reason

400

Missing email or token parameter

404

No valid (non-expired) invitation found for that (email, token) pair

Activate an invitation (new user)

PATCH /auth/activate — public (the user has only the token link).

Request

PATCH /auth/activate
Content-Type: application/json

{
  "email":    "bob@example.com",
  "token":    "<token from email>",
  "password": "correct-horse-battery"
}

Server-side steps

  1. Find invitation by (email, token) in auth_invitations (not on user document).

  2. Verify isNewUser: true — 400 otherwise (wrong endpoint).

  3. Check expiry (TTL 7 days).

  4. Enforce password strength.

  5. Set password, assign roles: ["user"].

  6. MembershipProvider.addMember(userId, orgId, role) — adds user to the org and sets active tenant (reads orgId and role from the invitation document).

  7. Delete invitation from auth_invitations (one-shot).

  8. Issue JWT + cookie → auto-login.

  9. Return 200 OK.

Note

Consents are not managed by this endpoint. Use a response interceptor on the deployment layer to persist consents from the request body. See the AccountsConsentsSaver interceptor in restheart-cloud-server for a reference implementation.

Error responses

Status Reason

400

Missing fields; password too weak; isNewUser=false (wrong endpoint)

401

Invalid or expired invite token

Accept an invitation (existing user)

POST /auth/accept-invite — requires authentication.

Request

POST /auth/accept-invite
Authorization: Bearer <token>
Content-Type: application/json

{
  "token": "<token from email>"
}

Server-side steps

  1. Find invitation by token in auth_invitations.

  2. Verify isNewUser: false — 400 otherwise (wrong endpoint).

  3. Verify invitation email matches the authenticated user.

  4. MembershipProvider.addMember(userId, orgId, role) — adds user to team.

  5. MembershipProvider.setActiveMembership(userId, orgId) — switches the user’s active org to the newly joined one.

  6. Delete invitation from auth_invitations.

  7. Return 200 OK.

Frontend flow

  1. User clicks link in email → /invitations/accept?email=…​&token=…​

  2. Frontend calls GET /auth/invitation?email=…​&token=…​ to get org name and role.

  3. If not logged in → shows "Log in to accept" with OAuth buttons + login form.

  4. After login → calls POST /auth/accept-invite { token }.

  5. On success → redirect to dashboard.

Accept via OAuth

Both new and existing invited users can accept via OAuth. The pendingInviteToken is passed as a query parameter to the OAuth authorize URL.

OAuth URL

{api-base-url}/auth/oauth/authorize/{provider}?pendingInviteToken=...

New user (roles: $unauthenticated)

  1. OAuth callback detects $unauthenticated user with pendingInviteToken.

  2. Find invitation by (email, pendingInviteToken) in auth_invitations; call MembershipProvider.addMember(userId, orgId, role) to set tenant.

  3. MembershipProvider.activateViaOAuth(userId, consents) — assigns roles: ["user"], stores consents.

  4. Delete invitation from auth_invitations. JWT cookie set, redirect to frontend-app-url.

Existing user

  1. OAuth callback detects user is NOT $unauthenticated and pendingInviteToken is present.

  2. Find invitation by (email, pendingInviteToken) in auth_invitations.

  3. MembershipProvider.addMember(userId, orgId, role).

  4. Delete invitation. Issue JWT. Redirect to frontend-app-url.

Re-send an invitation

POST /auth/resend-invite — requires role owner.

Request body:

{ "email": "bob@example.com" }

Generates a new token (invalidating the previous one) by updating the auth_invitations document, and re-sends the invitation email. Works for both new and existing user invitations. Returns 200 OK on success. Returns 404 if no pending invitation is found for that user in the caller’s team.

Remove a member

DELETE /auth/remove-member — requires role owner.

Request

DELETE /auth/remove-member
Authorization: Bearer <token>
Content-Type: application/json

{
  "email": "bob@example.com"
}

Server-side steps

  1. Authorize the caller (must be owner of their active tenant).

  2. Verify the target user is a member of the caller’s tenant — 404 Not Found otherwise.

  3. Prevent the owner from removing themselves (400 Bad Request).

  4. Call MembershipProvider.removeMember(email, tenantId).

  5. Return 200 OK.

Error responses

Status Reason

400

Missing email field; or owner trying to remove themselves

403

Caller does not have owner role

404

Target user is not a member of the caller’s tenant

Update a member’s role

PATCH /auth/member-role — requires role owner.

Request

PATCH /auth/member-role
Authorization: Bearer <token>
Content-Type: application/json

{
  "email": "bob@example.com",
  "role":  "member"
}

role must be one of: the configured member-role-name (default "member") or the configured ownership-role (default "owner").

Important
The admin role is a system-level ACL role and cannot be assigned as a team role via this endpoint.

Server-side steps

  1. Authorize the caller (must be owner of their active tenant).

  2. Validate role — 400 Bad Request if not memberRoleName or ownershipRole.

  3. Verify the target user is a member of the caller’s tenant — 404 Not Found otherwise.

  4. Call MembershipProvider.updateMemberRole(email, tenantId, newRole).

  5. Return 200 OK.

Error responses

Status Reason

400

Missing fields; or role not in [memberRoleName, ownershipRole]

403

Caller does not have owner role

404

Target user is not a member of the caller’s tenant