Users Management
Two surfaces, one identity model.
Where users are managed
| App | Route | Scope | Capabilities |
|---|---|---|---|
| Shell | /account/users | Tenant-wide with optional facility filter | Full management: list, invite, cancel invites, view stats |
| CMS | /settings/users | Facility-scoped (JWT's current facility) | Read-only: list practitioners / staff / pending invites for the current facility |
The CMS page used to redirect to Shell. As of #1429 it's a native page so CMS users can answer "who works at this clinic?" without leaving their app. Invite create/cancel still lives in Shell — duplicating those modals across apps is not worth the maintenance cost when the canonical home is one route away.
Shell — facility selector
/account/users shows a facility dropdown above the stats when the user's current tenant has more than one facility. Single-facility tenants get the tenant-wide view directly (no picker).
Options:
- All Facilities (default) — aggregates invites across all tenant facilities, users list is tenant-wide.
- Specific facility — both users (practitioners + staff) and invites (practitioner + staff) are scoped to that row.
The picker is hidden, not disabled, when there's only one facility. Owner/admin/stakeholder are tenant-scoped and never carry a facility relation; selecting a specific facility returns an empty list for those tabs (the UI hides those tabs anyway via canManageStaff / canManageUsers role checks).
CMS — facility-scoped read-only
/settings/users reads currentFacilityId from the JWT and shows three tabs:
- Practitioners — active practitioners + pending practitioner invites at this facility.
- Staff — same shape; tab visible only to
owner/admin. - Stakeholders — informational placeholder. Stakeholders are tenant-scoped (no facility relation) and the role isn't in the
identity.membership_roleenum yet, so this tab points users at Shell rather than fetching.
When the JWT has no currentFacilityId (e.g. multi-tenant operator mid-switch), the page short-circuits to a guard message and disables all underlying fetches (each hook is called with { enabled: hasFacility }). Without the gate, the hooks would fall through to tenant-wide data — exactly what this page is designed not to show.
Backend API surface
All three endpoints accept new query params introduced in #1429.
GET /identity/users
GET /identity/users?role=practitioner&facility_id=<uuid>
GET /identity/users?role=staff&facility_id=<uuid>| Param | Effect |
|---|---|
role=practitioner | Joins through identity.practitioners → practitioner_roles and returns users with an active role at facility_id. |
role=staff | Same shape via identity.staffs → staff_roles. |
role=owner/admin/stakeholder | Returns [] when facility_id is set — those roles are tenant-scoped and have no facility relation. |
facility_id omitted | Tenant-wide list filtered only by role. |
The subquery pins tenant_id = current_tenant.id as defense in depth (policy_scope should already restrict, but the explicit guard prevents a cross-tenant facility_id from leaking results even if scope plumbing ever changes).
GET /identity/practitioner_invites and GET /identity/staff_invites
GET /identity/practitioner_invites?facility_id=<uuid>&status=pending
GET /identity/practitioner_invites?all_facilities=true&status=pending
GET /identity/staff_invites?facility_id=<uuid>&status=pendingPrecedence: facility_id > all_facilities=true > JWT's current_facility (legacy default). A facility_id belonging to another tenant returns 404. require_facility! is dropped from index only.
Cross-facility invite create and cancel (#2660)
POST, GET /:id and DELETE on both invite endpoints also accept facility_id. The Add Doctor and Add Staff modals send the facility picked in the modal, and the tabs send the invite's own facility_id on cancel, so an admin in the "All Facilities" view can invite to, and cancel at, any facility. Without facility_id these actions use the session facility, as before.
ScopedInvites#target_facility resolves the id. It must belong to the session tenant, and the inviter must be able to reach it under the rule Profiles::SwitchProfileService applies to a profile switch: an owner or admin reaches every facility, a practitioner needs an active PractitionerRole there, and a member with no practitioner record is not facility-bound. Anything else returns 404. The same rule limits index: an explicit facility_id the inviter cannot reach returns 404, and all_facilities=true lists only the facilities they can reach. BaseController#current_facility is unchanged; the explicit id is honoured only by the invite controllers.
A user_id chosen with "Select user" must match the submitted phone (and the email, when the account has one). A mismatch returns 422 and writes an invite_rejected audit row, filed under the facility the request named. The selected account is the one the invite services check and bind; it binds only through a phone or email it verified. A legacy account stored with no phone country code matches only when it is the one such account with those digits.
GET /identity/users/lookup reports the target facility's membership and pending invites split by role (already_practitioner_at_facility, already_staff_at_facility, already_admin_at_facility, the matching already_*_invite_at_facility flags, and incompatible_role_*). Pending invites are matched on the phone, then the email. The staff invite service treats a pending admin invite and a pending non-admin staff invite as different invites, matching that split.
An invite to an existing member must land on the membership they hold (StaffInvite.lands_on_membership?): an accepted staff invite keeps the membership it finds, except that an admin invite upgrades a staff membership to admin. Any other difference (for example a marketing invite to a staff member, or a staff invite to a practitioner member) is refused when the invite is created and again when it is accepted, since the membership can change in between. The lookup's incompatible_role_* flags report the same rule to the modal.
Permissions
| Surface | Practitioners tab | Staff tab | Stakeholders tab |
|---|---|---|---|
| Shell | visible to all | owner or admin only | owner or admin only |
| CMS | visible to all | owner or admin only | owner or admin only (informational placeholder) |
Backend enforces this via authorize_invite_management! on the staff invites controller (gated to owner/admin) — bypassing the frontend check still returns 403.
File map
backend/domains/identity/
├── controllers/
│ ├── users_controller.rb # filter_by_facility helper
│ ├── practitioner_invites_controller.rb # scoped_invites helper
│ └── staff_invites_controller.rb # scoped_invites helper
└── spec/requests/
├── users_spec.rb # facility filter tests
└── invites_facility_scoping_spec.rb # facility_id / all_facilities tests
frontend/packages/shared-services/src/domains/identity/
├── types.ts # IdentityUserListParams, List*InvitesParams
├── api.ts # getIdentityUsers, listPractitionerInvites, listStaffInvites
└── hooks.ts # useIdentityUsers (+ enabled option), usePractitionerInvites, useStaffInvites
frontend/apps/shell/src/features/userManagement/
├── pages/UsersManagementPage.tsx # facility selector
└── components/tabs/
├── DoctorsTab.tsx # threads facilityId, resets pagination on change
├── StaffTab.tsx # threads facilityId
└── StakeholdersTab.tsx # threads facilityId (tenant-scoped, empty when filtered)
frontend/apps/cms/src/features/settings/usersManagement/
├── pages/UsersManagementPage.tsx # currentFacilityId from JWT; guard + enabled gate
└── components/tabs/
├── PractitionersTab.tsx # read-only list + invites
├── StaffTab.tsx # read-only list + invites (owner/admin)
└── StakeholdersTab.tsx # informational placeholderDesign decisions
Why CMS is read-only. Invite create/cancel modals live in Shell. Duplicating them in CMS would mean two copies to keep in sync. The CMS page exists so a user at the clinic UI doesn't need to context-switch to see who works there; full management is one route away.
Why owner/admin/stakeholder + facility_id returns []. Those roles attach at the tenant, not at any facility. Returning the tenant-wide list when facility_id is set would mislead the caller's UI into showing those users for every facility selection. Empty is the consistent answer; the caller's tab visibility handles the "don't show this tab for a facility filter" decision.
Why Shell's selector is hidden (not disabled) for single-facility tenants. A no-op picker is visual noise. Tenants with one facility get the tenant-wide view directly. The page state still treats the selection as "All Facilities" internally.
Why hooks are gated with enabled in CMS but not Shell. Shell's page intentionally fetches tenant-wide on mount (that's the default state). CMS's page only makes sense once a facility is in scope; firing fetches with facility_id: undefined would fall through to tenant-wide data, defeating the page's purpose. The enabled option on useIdentityUsers / usePractitionerInvites / useStaffInvites was added in #1429 for exactly this case.
Future enhancements
Tracked in the issue backlog rather than this doc:
- Stakeholder role in
identity.membership_roleenum (currently 500s on query). - Bulk invite operations.
- CMS surface for invite create/cancel (only if context-switch to Shell becomes a real pain point).
- Owner/admin tab visibility decision when a specific facility is picked (currently hidden via role check; could be a per-facility delegation model in future).