Overview
The Members & Invites module provides team management for Lumio accounts. Account owners and administrators can invite users to their account, assign roles to members, and manage the team roster. The invite system supports invite links with configurable expiry and maximum uses, role assignment at invite time, and user search across the platform. Members can be part of multiple accounts, enabling account switching for multi-account users.
Architecture
Backend
- GraphQL (
apps/api/src/graphql/members.rs) -- Queries for listing members, listing invites, public invite lookup by code, and searching users. Mutations for updating member roles, removing members, creating/deleting invites, and accepting invites.
- REST (
apps/api/src/routes/members.rs) -- The same operations under /v1/accounts/{id}/members and /v1/invites.
- Database (
apps/api/src/db/members.rs) -- PostgreSQL operations for memberships, invites, user search, and platform connections lookup.
- Permission Cache -- The GraphQL
updateMemberRole and removeMember mutations and the REST role-change and remove-member endpoints invalidate the Redis permission cache via lo_api::middleware::auth::invalidate_permission_cache(), so a role change (grant or revocation) takes effect immediately instead of lingering until the cache TTL expires.
- WebSocket -- Members and invites have no WebSocket channel;
channel_gate_for in crates/lo-websocket/src/gate.rs defines no members/invites channel type. Roster changes are picked up on the next query.
Frontend
- Team management in the Members area of the settings window at
/dashboard/settings/channel/members, with member list and role badges (apps/web/src/components/settings/sections/channel/members/). The settings section route checks members:read before loading it.
- 4-step invite wizard (
invite-dialog.tsx), in order: Method → Role → Options → Result. The method step offers three cards: Invite Link (generate a shareable link), Invite Token (generate a code to share manually), and Invite Person (search by username, email, or platform and target a specific user). The result step shows the generated link/code to copy.
- Account switcher (
apps/web/src/components/account-switcher.tsx) for users with multiple account memberships. After a switch the tab leaves an account-bound detail route for its static parent and reloads anywhere else; switching to the personal profile opens /dashboard. Every place that changes active_account_id posts on the lumio-account BroadcastChannel, and the other dashboard tabs apply the same rule (useAccountSwitchSync). The channel selector in the settings window applies the rule too and keeps the window open on the same area: it reloads in place, or opens the static parent with the same #settings/… hash. The account-bound routes are the ACCOUNT_BOUND_DETAIL_ROUTES constant in apps/web/src/lib/account-switch.ts; a unit test fails when a new dynamic dashboard route is missing from it or from ACCOUNT_NEUTRAL_DYNAMIC_ROUTES. Classify a new route by its resolver's account_id filter, not by its name.
API
GraphQL Queries
| Query | Permission | Description |
|---|
members: [Member!]! | members:read | List all members of the current account (includes user info, role details, and linked platforms) |
invites: [Invite!]! | members:read | List all invites for the current account |
inviteByCode(code: String!): InviteDetail! | Public (no auth) | Public invite details (account name, inviter, role, isExpired / isFull) for the accept page |
searchUsers(query: String!, limit: Int! = 10): [UserSearchResult!]! | members:create | Search users by name, email, or platform username. Minimum 3 characters (shorter queries error). Excludes existing account members. limit clamps to 1–10. Returns user info with platform connections. |
GraphQL Mutations
| Mutation | Permission | Description |
|---|
updateMemberRole(input: UpdateMemberRoleInput!): MemberMutationResult! | members:edit | Change a member's role. Cannot change the account owner's role or assign the owner system role. Invalidates the Redis permission cache. |
removeMember(membershipId: UUID!): MemberMutationResult! | members:delete | Remove a member from the account. Cannot remove the account owner. Deletes popout tokens assigned to that member in this account and invalidates the Redis permission cache. |
createInvite(input: CreateInviteInput!): Invite! | members:create | Create an invite with role assignment, optional email/user targeting, max uses (default 1), and expiry (default 168 hours / 7 days). Cannot create invites with the owner role. |
deleteInvite(inviteId: UUID!): MemberMutationResult! | members:delete | Delete an invite |
acceptInvite(code: String!): AcceptInviteResult! | AuthGuard + first-party session (logged-in user or own API key; popout/overlay/widget/extension tokens rejected with FORBIDDEN, ZAF-1740 A6) | Accept an invite by code. A targeted invite (invitedUserId set) can only be accepted by that user (FORBIDDEN "This invite was issued to a different user" otherwise). Validates expiry, max uses, and duplicate membership. Creates a new membership. |
REST Endpoints
All paths live under /v1. Bodies are snake_case and mirror the GraphQL inputs.
| Method | Path | Permission | Description |
|---|
GET | /v1/accounts/{id}/members | members:read | List members of an account |
PATCH | /v1/accounts/{id}/members/{membership_id}/role | members:edit | Change a member's role. Returns 204 No Content. Invalidates the Redis permission cache. |
DELETE | /v1/accounts/{id}/members/{membership_id} | members:delete | Remove a member and delete popout tokens assigned to that member in this account. Returns 204 No Content. Invalidates the Redis permission cache. |
POST | /v1/invites | members:create | Create an invite (role + optional email/user + expiry) |
GET | /v1/invites | members:read | List account invites |
GET | /v1/invites/{code}/info | Public (no auth) | Invite lookup used by the accept page - the REST mirror of inviteByCode |
DELETE | /v1/invites/{id} | members:delete | Revoke an invite |
POST | /v1/invites/{code}/accept | First-party session (popout/overlay/widget/extension tokens get 403) | Accept an invite as the authenticated user; a targeted invite only by its target (403) |
CreateInviteInput (GraphQL camelCase; the REST body uses the snake_case equivalents):
| Field | Type | Default | Description |
|---|
roleId | UUID! | (required) | Role to assign to the invited user |
email | String | null | Target email address; when set, an AccountInvite email is sent fire-and-forget |
invitedUserId | UUID | null | Target user ID (from user search); when set, an in-dashboard invite notification is created |
maxUses | Int | 1 | Maximum number of times the invite can be used |
expiresInHours | Int | 168 (7 days) | Expiry duration in hours |
UpdateMemberRoleInput: membershipId: UUID!, roleId: UUID!.
Permissions
| Permission | Description |
|---|
members:read | View member list and invites |
members:create | Create invites, search users |
members:edit | Change member roles |
members:delete | Remove members, revoke invites |
Database
| Table | Database | Description |
|---|
account_memberships | PostgreSQL | Links users to accounts. Columns: id, user_id, account_id, role_id (nullable FK to account_roles), created_at. UNIQUE(user_id, account_id). Queries alias created_at as joined_at, which is the name the API exposes. |
account_invites | PostgreSQL | Invite records. Columns: id, account_id, invited_by, invited_user_id, email, invite_code (unique), role_id, accepted_by, max_uses, use_count, expires_at, accepted_at, created_at |
account_roles | PostgreSQL | Roles that memberships and invites reference |
users | PostgreSQL | User profiles (display_name, email, avatar_url) |
login_connections | PostgreSQL | Platform connections shown on members and user-search results (provider, username, display_name) |
Data Flow
- An administrator creates an invite via the 4-step wizard, choosing a method, a role, and options.
- An invite record is created with a unique
invite_code.
- Delivery depends on the input: an
email triggers the AccountInvite transactional email; an invitedUserId creates an in-dashboard invite notification (plus the same email when the user has an address on record); a plain link or token is copied from the result step and shared manually.
- The target user opens the link, which calls
acceptInvite(code) (or POST /v1/invites/{code}/accept).
- The system validates the invite: a targeted invite (
invited_user_id set, Invite Person) may only be accepted by that user - anyone else who obtains the code is refused with FORBIDDEN, identically on REST and GraphQL (ZAF-1740 C3); then not expired, use_count below max_uses, and no existing membership for that user/account pair.
- A new
account_memberships row is created, linking the user to the account with the invite's role.
- The invite
use_count is incremented and accepted_at is set.
Email and link invites are bearer links. An invite that only carries an email, and every Invite Link / Invite Token, can be accepted by whoever holds the code. The email is a delivery address, not a binding: Lumio has no verified-email trust anchor, so matching the accepting account's email would add no real guarantee. Use Invite Person when the invite must reach exactly one account.
An invited user can also resolve the invite straight from the notification bell: the invite notification carries accept_invite / decline_invite actions. See Notifications.
Role Change Flow
- A caller with
members:edit invokes updateMemberRole with the membership ID and new role ID.
- The system validates: the membership belongs to the active account, the target is not the account owner, the role exists, belongs to the same account, and is not the
owner system role.
- The role is updated in the database.
- Both the GraphQL mutation and the REST endpoint invalidate the Redis permission cache for the affected user/account pair, so the change takes effect on the next request instead of after the cache TTL.
Key Files
| Path | Description |
|---|
apps/api/src/graphql/members.rs | GraphQL queries and mutations |
apps/api/src/routes/members.rs | REST handlers for members and invites |
apps/api/src/db/members.rs | Database operations for memberships, invites, and user search |
apps/api/src/db/roles.rs | Role lookup for validation |
apps/api/src/services/invite_notifications.rs | In-dashboard invite notification + email side channel |
apps/web/src/components/settings/sections/channel/members/ | Members area and invite wizard; the settings section gate checks members:read |