Skip to content

Member Management

The Settings > Members tab provides organization owners with a full member management interface. You can assign phone numbers, toggle roles, promote or demote owners, and remove members — all from an inline detail panel.

Represents a phone number assigned to a member.

pub struct MemberPhoneSummary {
pub phone_number_id: Uuid,
pub number: String, // E.164: "+12345678901"
pub friendly_name: Option<String>, // "Main Office"
pub is_default: bool,
}

Request payload for phone assignment. Uses replace-all semantics — every call replaces all existing assignments.

pub struct AssignPhonesData {
pub phone_number_ids: Vec<Uuid>,
pub default_phone_number_id: Option<Uuid>,
}

If default_phone_number_id is None and exactly one phone is assigned, that phone becomes the default automatically.

Shows a phone number with all members currently assigned to it. Powers the phone picker’s cross-member visibility.

pub struct PhoneAssignmentInfo {
pub phone_number_id: Uuid,
pub number: String,
pub friendly_name: Option<String>,
pub assigned_to: Vec<PhoneAssignee>,
}
pub struct PhoneAssignee {
pub member_id: Uuid,
pub member_name: String,
pub is_default: bool,
}

Extended to include email and phone assignments:

pub struct OrgMemberWithRoles {
pub member_id: Uuid,
pub user_id: Uuid,
pub name: String,
pub email: Option<String>,
pub is_owner: bool,
pub last_active_at: Option<String>,
pub roles: Vec<MemberRoleSummary>,
pub phones: Vec<MemberPhoneSummary>,
}

The member_phone_number join table links members to phone numbers:

CREATE TABLE member_phone_number (
id UUID PRIMARY KEY,
member_id UUID NOT NULL REFERENCES member(id) ON DELETE CASCADE,
phone_number_id UUID NOT NULL REFERENCES phone_number(id) ON DELETE CASCADE,
is_default BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(member_id, phone_number_id)
);

Both foreign keys cascade on delete — removing a member clears their phone assignments, and removing a phone number clears all member assignments to it.

MethodPathDescription
GET/api/org/members-with-rolesList members with roles, phones, and emails
GET/api/org/phone-assignmentsAll org phones with their current assignees
POST/api/members/{id}/phonesAssign phones to a member (replace-all)
DELETE/api/members/{id}Remove a member from the organization
PUT/api/members/{id}/ownershipPromote or demote member ownership

All endpoints require an authenticated session. Permission checks:

EndpointPermission
List membersMember:Collection:List
Phone assignmentsMember:Collection:List
Assign phonesMember:Instance:Update
Remove memberMember:Instance:Remove
Change ownershipOwner-only (not ABAC-grantable)

Send a POST to /api/members/{member_id}/phones:

{
"phone_number_ids": ["uuid-1", "uuid-2"],
"default_phone_number_id": "uuid-1"
}

The server deletes all existing assignments for the member, then batch-inserts the new set. Pass an empty phone_number_ids array to remove all assignments.

Send a DELETE to /api/members/{member_id}. The server prevents removal of the organization owner (returns 400). On success, FK cascades remove member_phone_number and member_role records.

Organizations can have multiple owners. Owners bypass all ABAC permission checks and have full administrative control. Only existing owners can promote or demote other members — ownership is not grantable via roles.

PUT /api/members/{member_id}/ownership

Request:

pub struct TransferOwnershipData {
pub is_owner: bool, // true = promote, false = demote
}

Responses:

StatusCondition
200Ownership updated
400Last owner guard — cannot remove the only owner
403Caller is not an owner
404Member not found in org

Server logic:

  1. Checks session.is_owner directly (not an ABAC permission)
  2. Validates the target member belongs to the caller’s org
  3. Returns early if the member’s ownership status already matches the request
  4. On demotion: counts remaining owners — blocks if only one remains
  5. Updates member.is_owner and logs the change via tracing::info!

All owners bypass permission checks. The has_instance_permission and has_collection_permission methods in resource_trait.rs short-circuit when session.is_owner is true:

if session.is_owner {
return true; // Bypass all permission checks
}

This means owners can perform any action on any resource within their organization — view, edit, delete — regardless of their assigned roles.

The system prevents removing the last owner to avoid orphaned organizations. When demoting an owner, the server counts all owners in the org:

if !data.is_owner && member.is_owner {
let owner_count = count_owners_in_org();
(owner_count > 1).or_bad_request(
"Cannot remove the last owner of the organization"
)?;
}

The member table includes an is_owner boolean column (DEFAULT false). The migration auto-promotes the earliest member in each organization to owner.

The ConfirmOwnershipChange component renders a button and confirmation modal for ownership changes.

#[component]
pub fn ConfirmOwnershipChange(
member_id: Uuid,
member_name: String,
is_currently_owner: bool,
on_changed: EventHandler,
) -> Element
  • Promote: “Promote to Owner” button → warning modal → calls the ownership API with is_owner: true
  • Demote: “Remove Owner” button → destructive modal → calls the ownership API with is_owner: false

The component only appears in MemberDetailPanel when the caller is an owner (session.is_owner). Members are sorted with owners first in the member list.

The top-level component renders a responsive table (desktop) or card grid (mobile). It loads three resources in parallel:

  • get_org_members_with_roles_api() — members with roles and phones
  • get_roles_api() — available roles for the role picker
  • get_phone_assignments_api() — phone-to-member mapping for the phone picker

Clicking “Manage” on a row opens the MemberDetailPanel inline below the table.

An inline panel with four sections:

  1. Phone Numbers — lists assigned phones with badges (filled for default, outline for others). An “Assign Phones” button toggles the MemberPhonePicker.
  2. Roles — checkbox list of available roles. Each toggle saves immediately via assign_member_roles_api.
  3. Ownership — promote or demote member ownership via ConfirmOwnershipChange. Only visible when the caller is an owner.
  4. Danger ZoneConfirmRemoveMember with two-step confirmation. Hidden for the org owner.

Checkbox list of all org phones. Each phone row shows:

  • Checkbox for assignment
  • Phone label (friendly name or number)
  • “Assigned to: Alice, Bob” for cross-member visibility
  • Star toggle for default designation (visible only when checked)

Every check/uncheck or star click triggers an immediate save. If you uncheck the default phone, the picker auto-selects the first remaining phone as the new default.

Two-step inline confirmation: first click shows the prompt, second click executes the deletion. The confirmation box uses destructive styling (border-destructive/30 bg-destructive/5).

All APIs use batch loading to avoid N+1 queries:

// Collect all member IDs
let member_ids: Vec<Uuid> = members.iter().map(|(m, _)| m.id).collect();
// Single batch query for phone assignments
let member_phones = schemas::member_phone_number::Entity::find()
.filter(schemas::member_phone_number::Column::MemberId.is_in(member_ids))
.all(&db)
.await?;

The get_org_members_with_roles_api endpoint runs a fixed ~6 queries regardless of member count.

src/mods/settings/
├── api/
│ ├── get_org_members_with_roles_api.rs
│ ├── get_phone_assignments_api.rs
│ ├── assign_member_phones_api.rs
│ ├── remove_member_api.rs
│ └── transfer_ownership_api.rs
├── types/
│ ├── member_phone_type.rs
│ ├── phone_assignment_info_type.rs
│ └── org_member_with_roles_type.rs # includes TransferOwnershipData
└── components/
├── member_list_component.rs
├── member_detail_panel_component.rs
├── member_phone_picker_component.rs
├── confirm_remove_member_component.rs
└── confirm_ownership_change_component.rs