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 page at
/dashboard/members with member list and role badges (apps/web/src/app/(main)/(app)/dashboard/members/).
- 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.
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. 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 (any authenticated user) | Accept an invite by code. 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. 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 | Auth | Accept an invite as the authenticated user |
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: 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.
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/app/(main)/(app)/dashboard/members/ | Team page and invite wizard |