Kulcare Docs

Users Management

Two surfaces, one identity model.

Where users are managed

AppRouteScopeCapabilities
Shell/account/usersTenant-wide with optional facility filterFull management: list, invite, cancel invites, view stats
CMS/settings/usersFacility-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_role enum 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>
ParamEffect
role=practitionerJoins through identity.practitioners → practitioner_roles and returns users with an active role at facility_id.
role=staffSame shape via identity.staffs → staff_roles.
role=owner/admin/stakeholderReturns [] when facility_id is set — those roles are tenant-scoped and have no facility relation.
facility_id omittedTenant-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=pending

Precedence: 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

SurfacePractitioners tabStaff tabStakeholders tab
Shellvisible to allowner or admin onlyowner or admin only
CMSvisible to allowner or admin onlyowner 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 placeholder

Design 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_role enum (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).

On this page