Skip to main content

Members & Invites

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​

QueryPermissionDescription
members: [Member!]!members:readList all members of the current account (includes user info, role details, and linked platforms)
invites: [Invite!]!members:readList 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:createSearch 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​

MutationPermissionDescription
updateMemberRole(input: UpdateMemberRoleInput!): MemberMutationResult!members:editChange 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:deleteRemove 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:createCreate 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:deleteDelete 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.

MethodPathPermissionDescription
GET/v1/accounts/{id}/membersmembers:readList members of an account
PATCH/v1/accounts/{id}/members/{membership_id}/rolemembers:editChange a member's role. Returns 204 No Content. Invalidates the Redis permission cache.
DELETE/v1/accounts/{id}/members/{membership_id}members:deleteRemove a member and delete popout tokens assigned to that member in this account. Returns 204 No Content. Invalidates the Redis permission cache.
POST/v1/invitesmembers:createCreate an invite (role + optional email/user + expiry)
GET/v1/invitesmembers:readList account invites
GET/v1/invites/{code}/infoPublic (no auth)Invite lookup used by the accept page - the REST mirror of inviteByCode
DELETE/v1/invites/{id}members:deleteRevoke an invite
POST/v1/invites/{code}/acceptFirst-party session (popout/overlay/widget/extension tokens get 403)Accept an invite as the authenticated user; a targeted invite only by its target (403)

Input Types​

CreateInviteInput (GraphQL camelCase; the REST body uses the snake_case equivalents):

FieldTypeDefaultDescription
roleIdUUID!(required)Role to assign to the invited user
emailStringnullTarget email address; when set, an AccountInvite email is sent fire-and-forget
invitedUserIdUUIDnullTarget user ID (from user search); when set, an in-dashboard invite notification is created
maxUsesInt1Maximum number of times the invite can be used
expiresInHoursInt168 (7 days)Expiry duration in hours

UpdateMemberRoleInput: membershipId: UUID!, roleId: UUID!.

Permissions​

PermissionDescription
members:readView member list and invites
members:createCreate invites, search users
members:editChange member roles
members:deleteRemove members, revoke invites

Database​

TableDatabaseDescription
account_membershipsPostgreSQLLinks 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_invitesPostgreSQLInvite 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_rolesPostgreSQLRoles that memberships and invites reference
usersPostgreSQLUser profiles (display_name, email, avatar_url)
login_connectionsPostgreSQLPlatform connections shown on members and user-search results (provider, username, display_name)

Data Flow​

  1. An administrator creates an invite via the 4-step wizard, choosing a method, a role, and options.
  2. An invite record is created with a unique invite_code.
  3. 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.
  4. The target user opens the link, which calls acceptInvite(code) (or POST /v1/invites/{code}/accept).
  5. 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.
  6. A new account_memberships row is created, linking the user to the account with the invite's role.
  7. 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​

  1. A caller with members:edit invokes updateMemberRole with the membership ID and new role ID.
  2. 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.
  3. The role is updated in the database.
  4. 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​

PathDescription
apps/api/src/graphql/members.rsGraphQL queries and mutations
apps/api/src/routes/members.rsREST handlers for members and invites
apps/api/src/db/members.rsDatabase operations for memberships, invites, and user search
apps/api/src/db/roles.rsRole lookup for validation
apps/api/src/services/invite_notifications.rsIn-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