Skip to content

Invitations

Org owners invite new users by email with optional role pre-assignment. The invited user receives a link, creates an account, and is automatically logged in as a member of the inviting organization with any pre-assigned roles.

Owner sends invitation (POST /api/invitations/send)
→ Validates optional role_ids against org roles
→ Creates invitation record (email, org, unique token, role_ids)
→ Sends email with accept link: /invite/accept/{token}
Invitee opens link → AcceptInvitationCard loads (POST /api/invitations/details)
→ Shows pre-filled email + org name
Invitee submits name + password (POST /api/invitations/accept)
→ Creates: user, email_password_account, member (is_owner: false), session
→ Assigns pre-selected roles via member_role table
→ Deletes invitation record
→ Sets session cookie → auto-login redirect to dashboard
Owner can list pending invitations (GET /api/invitations)
→ Each invitation shows assigned role names
Owner can revoke a pending invitation (DELETE /api/invitations/{id})
CREATE TABLE invitation (
id UUID PRIMARY KEY,
email TEXT NOT NULL,
organization_id UUID NOT NULL REFERENCES organization(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE,
role_ids JSONB NOT NULL DEFAULT '[]',
created_at TIMESTAMP NOT NULL
);

The role_ids column stores an array of role UUIDs to assign when the invitation is accepted. Defaults to an empty array for invitations without pre-assigned roles.

Invitations have no expiry and no status column — the row is deleted when accepted or revoked.

POST /api/invitations/send

Requires: authenticated session with is_owner: true.

Request body:

email: String, // plain string field (Dioxus server function)
role_ids: Vec<String>, // optional list of role UUIDs to pre-assign

Response:

pub enum SendInvitationResponse {
Success,
EmailAlreadyRegistered, // email already has an account
InvitationAlreadyExists, // pending invite already sent to this email in this org
InvalidEmail, // bad format (no @ or .)
InvalidRoleIds, // one or more role IDs don't belong to this org
InternalError(String),
}

What it does:

  1. Validates is_owner — only owners can send invitations.
  2. Trims and lowercases the email; validates format.
  3. Checks for duplicate account and duplicate pending invitation.
  4. Parses role_ids to UUIDs and validates all belong to the organization. Returns InvalidRoleIds if any are invalid (all-or-nothing).
  5. Generates a UUID token, inserts the invitation row with role_ids serialized as JSON.
  6. Sends an HTML email via Resend with the accept link: https://{APP_HOST}/invite/accept/{token}.
  7. If email send fails, rolls back the invitation row so the owner can retry.

POST /api/invitations/details

No session required — called by the accept page before the user has an account.

Request body:

token: String

Response:

pub enum InvitationDetailsResponse {
Found(InvitationDetails),
NotFound,
InternalError(String),
}
pub struct InvitationDetails {
pub email: String,
pub organization_name: String,
}

POST /api/invitations/accept

No session required.

Request body:

token: String,
name: String, // display name for the new user
password: String // minimum 8 characters

Response:

pub enum AcceptInvitationResponse {
Success(String), // contains the session token
InvalidToken, // invitation not found
InvalidUserName, // empty name
PasswordTooShort, // < 8 characters
EmailAlreadyRegistered, // race condition guard
InternalError(String),
}

What it does:

  1. Validates name and password.
  2. Looks up invitation by token → InvalidToken if not found.
  3. Guards against race condition (email registered between send and accept).
  4. Creates user, email_password_account (bcrypt password), member (is_owner: false), and session records.
  5. Deserializes role_ids from the invitation and inserts a member_role record for each role, linking the new member to the pre-assigned roles.
  6. Deletes the invitation row (token is now consumed).
  7. The IntoResponse impl for AcceptInvitationResponse::Success sets the session cookie, triggering auto-login in the browser.

GET /api/invitations

Requires: authenticated session with is_owner: true. Returns 403 otherwise.

Response: Vec<PendingInvitation>

pub struct PendingInvitation {
pub id: String, // UUID
pub email: String,
pub created_at: String, // formatted: "YYYY-MM-DD HH:MM"
pub roles: Vec<PendingInvitationRole>,
}
pub struct PendingInvitationRole {
pub id: String, // role UUID
pub name: String, // display name
}

The endpoint batch-fetches role names in a single query across all unique role IDs from pending invitations. Results are ordered by created_at descending.


DELETE /api/invitations/{id}

Requires: authenticated session with is_owner: true. Returns 403 otherwise.

Deletes the invitation row. The accept link becomes invalid immediately — visiting it shows “Invalid Invitation”.

AcceptInvitationResponse implements axum::IntoResponse to set the cookie:

src/bases/auth/types/accept_invitation_response_type.rs
if let AcceptInvitationResponse::Success(ref session_token) = self {
response = response.header("Set-Cookie", session_cookie_set(session_token));
}

This follows the same pattern as LoginResponse and SignupResponse — the client stores the cookie automatically on a 200 response.

Invite Form (Settings → Organization tab)

Section titled “Invite Form (Settings → Organization tab)”

src/mods/settings/components/invite_user_section_component.rs

  • Owner-only: the component is not rendered for non-owners.
  • Shows an email input + Send Invitation button.
  • Includes a collapsible Assign roles picker below the email field.
  • Fetches available roles via get_roles_api() on mount and renders checkboxes.
  • Tracks selected role IDs in a Signal<Vec<String>> and passes them to send_invitation_api().
  • The picker toggle shows the selected count (e.g., “Assign roles (2 selected)”).
  • Clears selected roles on successful send.
  • Fires on_invited callback on success to trigger a list refresh.

Pending Invitations List (Settings → Organization tab)

Section titled “Pending Invitations List (Settings → Organization tab)”

src/mods/settings/components/pending_invitations_section_component.rs

  • Owner-only section below the invite form.
  • Shows email, sent date, and role badges for each pending invitation.
  • Role names display as Badge components with BadgeVariant::Secondary.
  • Trash icon button calls DELETE /api/invitations/{id} and removes the row from the list.
  • Auto-refreshes after a new invitation is sent (via the on_invited callback).
  • Handles loading, empty, and error states.

Route: /invite/accept/:token (unauthenticated)

src/shared/views/accept_invitation_view.rs → renders AcceptInvitationCard

src/shared/components/accept_invitation_card_component.rs

  • On mount: calls POST /api/invitations/details with the token.
  • Shows org name and pre-fills the email (read-only).
  • User fills in name + password and submits.
  • On Success: stores the session token and redirects to the dashboard.
  • On InvalidToken: shows “Invalid Invitation” card.
src/mods/invitation/
├── api/
│ ├── send_invitation_api.rs # POST /api/invitations/send
│ ├── get_invitation_details_api.rs # POST /api/invitations/details
│ ├── accept_invitation_api.rs # POST /api/invitations/accept
│ ├── get_invitations_api.rs # GET /api/invitations
│ └── revoke_invitation_api.rs # DELETE /api/invitations/{id}
└── mod.rs
src/bases/auth/types/
└── accept_invitation_response_type.rs # IntoResponse impl (sets session cookie)
src/shared/
├── types/
│ └── invitation_response_type.rs # all response + data types
├── views/
│ └── accept_invitation_view.rs # /invite/accept/:token page
└── components/
└── accept_invitation_card_component.rs
src/mods/settings/components/
├── invite_user_section_component.rs # send form
└── pending_invitations_section_component.rs # list + revoke