Auth & User Management Frontend Migration
Status: Complete Date: 2026-03-16 Issues: #1062, #1063, #1065, #1066 Branch:
docs/auth-user-mgmt-migration-plan
Overview
This document describes the complete migration of four auth and user management features from legacy endpoints to the new /identity/* domain architecture. The backend identity domain was already fully implemented with controllers, models, services, and routes. The work covered here is the frontend wiring -- updating API clients, React Query hooks, and Shell app page components to call the new endpoints -- along with targeted backend fixes discovered during the migration, a comprehensive code review with fixes, and deletion of all superseded legacy code.
The migration covers:
- Session Management (#1062) -- viewing and revoking active login sessions
- Two-Factor Authentication (#1063) -- TOTP/SMS/Email 2FA setup, verification, and recovery codes
- Roles & Permissions (#1065) -- system-wide and tenant-scoped permission management with UUID-based IDs
- User Management (#1066) -- listing practitioners, staff, and stakeholders with an invite-based onboarding flow
Architecture
Pattern 1 Architecture
The migration follows the established frontend architecture pattern used across the Kulcare platform:
- State:
@kulcare/auth-identityZustand store holds authentication state, selected profile, and cached permissions. The store is read-only from the perspective of feature pages -- it is updated by API responses, not by UI interactions directly. - API Logic:
@kulcare/shared-services(identity domain) provides typed API functions that call the Rails backend, plus React Query hooks that wrap those functions with proper cache key factories and invalidation. - UI: Shell app page components consume the hooks and render the UI. Pages do not call API endpoints directly.
This three-layer separation means that changing an API endpoint requires updating only the api.ts file in shared-services, and the hooks and pages continue to work unchanged. Similarly, replacing a page's UI does not affect the API layer.
Package Dependency Graph
@kulcare/api-client (base HTTP client + generated types)
^
|
@kulcare/shared-services (API functions, React Query hooks, query keys)
^
|
Shell app pages (consume hooks, render UI)
|
@kulcare/ui-kit (presentational components, no API dependencies)There are no circular dependencies. @kulcare/ui-kit defines its own prop interfaces rather than importing from shared-services, following the Dependency Inversion principle -- the UI component declares the interface it needs, and the consumer maps data to fit.
Backend Changes
Session Management Fixes
Two issues were discovered in the existing backend session management code and fixed as part of this migration.
1. Session.revoke_all_for excluding current session
The revoke_all_for class method on Identity::Session previously revoked all active sessions for a user, including the session making the request. This meant clicking "Logout All" would log the user out of their own browser. The fix adds an except_session: keyword argument that the controller passes with current_session:
# backend/domains/identity/models/session.rb
def self.revoke_all_for(user, except_session: nil)
scope = active.for_user(user)
scope = scope.where.not(id: except_session.id) if except_session
scope.update_all(status: 'REVOKED', revoked_at: Time.current)
endThe SessionManagementController#revoke_all action now passes except_session: current_session so the user stays logged in after revoking all other sessions.
2. Current session indicator in blueprint
The SessionBlueprint was updated to include a current boolean field that identifies which session in the list belongs to the current request. This is computed server-side by comparing session IDs, so no sensitive information (like token hashes) is exposed to the client:
# backend/domains/identity/blueprints/session_blueprint.rb
field :current do |session, options|
options[:current_session_id].present? && session.id == options[:current_session_id]
endThe controller passes current_session_id: current_session&.id as a blueprint option when rendering the sessions list.
Invite Controller Authorization
The security review identified that both StaffInvitesController and PractitionerInvitesController lacked authorization on their create and destroy actions. Any authenticated user with tenant and facility access could create invites, including inviting themselves with elevated roles (privilege escalation).
The fix adds a before_action :authorize_invite_management! filter that restricts invite creation and cancellation to users with owner or admin roles. For staff invites, an additional validate_staff_role! check ensures the invited role cannot exceed the inviter's privilege level using a role hierarchy:
ROLE_HIERARCHY = { 'owner' => 3, 'admin' => 2, 'staff' => 1, 'practitioner' => 1 }Frontend: Shared Services Layer
All new API functions, types, React Query hooks, and query keys were added to @kulcare/shared-services in the identity domain (frontend/packages/shared-services/src/domains/identity/).
Types Added
Session Management: UserSession, SessionDevice, RevokeAllSessionsResponse -- representing the backend's session blueprint response shape, including the current boolean, device details, JSONB metadata, and status enum (ACTIVE | REVOKED | EXPIRED).
Two-Factor Authentication: TwoFactorStatus, TwoFactorMethod, EnableTwoFactorRequest/Response, ConfirmTwoFactorRequest/Response, VerifyTwoFactorRequest/Response, RegenerateRecoveryCodesResponse -- covering all six 2FA endpoints with their request and response shapes.
User Management: IdentityUser, IdentityUserListParams -- for the /identity/users listing endpoint filtered by role.
Query Key Factory
The identityKeys factory was extended with two new key hierarchies:
sessions: {
all: () => [...identityKeys.all, 'sessions'] as const,
list: () => [...identityKeys.sessions.all(), 'list'] as const,
detail: (id: string) => [...identityKeys.sessions.all(), 'detail', id] as const,
},
twoFactor: {
all: () => [...identityKeys.all, 'twoFactor'] as const,
status: () => [...identityKeys.twoFactor.all(), 'status'] as const,
},This hierarchical structure enables granular cache invalidation -- mutating a single session invalidates sessions.all(), which cascades to both sessions.list() and any sessions.detail(id) queries.
API Functions
Nine API functions were added for sessions and 2FA, following the existing pattern of typed Axios calls with JSDoc endpoint annotations:
| Function | Method | Endpoint | Response Extraction |
|---|---|---|---|
getUserSessions | GET | /identity/sessions | response.data.data |
revokeSession | DELETE | /identity/sessions/:id | void |
revokeAllSessions | POST | /identity/sessions/revoke_all | response.data |
getTwoFactorStatus | GET | /identity/two_factor/status | response.data.data |
enableTwoFactor | POST | /identity/two_factor/enable | response.data |
confirmTwoFactor | POST | /identity/two_factor/confirm | response.data |
verifyTwoFactor | POST | /identity/two_factor/verify | response.data |
disableTwoFactor | POST | /identity/two_factor/disable | void |
regenerateRecoveryCodes | POST | /identity/two_factor/regenerate_recovery_codes | response.data |
User listing hooks (getIdentityUsers, useIdentityUsers) were also added during the Phase 3 work to support the user management page's filtered queries.
React Query Hooks
Each API function has a corresponding hook. Query hooks use useQuery with the key factory. Mutation hooks use useMutation with onSuccess callbacks that invalidate the relevant query keys. For example:
export function useRevokeSession() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: revokeSession,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: identityKeys.sessions.all() });
},
});
}The 2FA hooks follow the same pattern. The useVerifyTwoFactor hook is the only mutation that does not invalidate queries on success, because verification during authentication returns tokens rather than changing 2FA state.
Frontend: Page Migrations
Session Management (SessionsPage)
The SessionsPage was rewritten using React Query hooks from @kulcare/shared-services. The previous implementation used useUserManagement() from @kulcare/auth-identity, which was a Zustand store with imperative fetch/force-logout methods. It also had a broken useEffect that unconditionally redirected to /account on every render, making the page unreachable.
The new implementation uses three hooks from @kulcare/shared-services: useUserSessions(), useRevokeSession(), and useRevokeAllSessions(). The page no longer takes a userId URL parameter -- the API scopes sessions to current_user automatically. The broken permission guard was removed entirely.
The page includes a custom SessionCard component that renders device info (device name, type, version, app type), metadata (IP address, location via geolocation from device metadata), and the current boolean as a "This Device" badge. It provides inline session management without relying on the ui-kit component.
Two-Factor Authentication (TwoFactorAuthPage + SecurityPage)
The 2FA migration touched four files:
TwoFactorAuthPage -- replaced the useTwoFactorAuth custom hook (which used useState + useEffect for manual fetching) with useTwoFactorStatus() and useDisableTwoFactor() from shared-services. The page retains its full management UI: method selection, QR code display, verification code input, recovery codes, and disable modal.
TwoFactorSetup -- replaced direct calls to twoFactorApi.enable() and twoFactorApi.confirm() with the shared-services API functions enableTwoFactor() and confirmTwoFactor().
RecoveryCodeManagement -- replaced twoFactorApi.regenerateRecoveryCodes(password) with regenerateRecoveryCodes() from shared-services. The password field was removed from the UI because the backend's RegenerateRecoveryService does not require password confirmation (the user is already authenticated).
SecurityPage -- simplified from a full TwoFactorManagement component (which had a broken password-less disable flow) to a status summary card with a link to the full TwoFactorAuthPage for management.
The legacy files twoFactorApi.ts and useTwoFactorAuth.ts were deleted.
Roles & Permissions (TenantPermissionsPage)
The permission management functionality was migrated to TenantPermissionsPage. The old system used numeric role IDs (1=owner, 2=consultant, 3=staff, 4=admin), numeric permission IDs from an Elasticsearch index, and hardcoded PERMISSION_DEPENDENCIES in the frontend. The new system uses string role names (owner, admin, staff, practitioner), UUID-based permission IDs from the REST API, and relies on the backend for dependency enforcement.
The page uses useSystemPermissions(), useTenantPermissions(), and useUpdateTenantPermissions() from @kulcare/shared-services to fetch system permissions with defaults, tenant-specific overrides, and handle permission updates. After saving, it refreshes the auth store so permission changes take effect immediately.
The hardcoded canEditPermissions = true was replaced with an actual role-based check: only users with owner or admin roles can edit permissions.
The legacy files usePermissionManagement.ts and permissionsApi.ts (which queried Elasticsearch) were deleted.
Note: A duplicate
RolesPagewas created during migration but has since been removed.TenantPermissionsPageat/account/tenant-permissionsis the sole permission management interface.
User Management (UsersManagementPage)
The user management migration was the most complex, involving ~20 files across the features/userManagement/ directory: the main page, three tab components, six modal components, hooks, services, types, and query keys.
Entity Mapping: The old system had separate concepts for clinic_doctors, clinic_staff, clinic_stakeholders, and clinic_admins, each with their own legacy endpoints. The new system uses Identity::User with role-based filtering via UserMembership:
| Old Entity | New Query | Notes |
|---|---|---|
clinic_doctors | useIdentityUsers({ role: 'practitioner' }) | Combined with usePractitionerInvites() for pending invites |
clinic_staff | useIdentityUsers({ role: 'staff' }) | Combined with useStaffInvites() for pending invites |
clinic_stakeholders | useIdentityUsers({ role: 'stakeholder' }) | Stakeholders are commission payout recipients, kept as distinct role |
Invite Flow: The most significant UX change is the shift from direct user creation to an invite-based flow. In the old system, POST /clinics/:id/clinic_staff would immediately create a staff member. In the new system, POST /identity/staff_invites creates an invitation. The backend's StaffServices::InviteService handles a smart path: if the invitee already has a Kulcare account (matched by phone number), they are added directly with no pending state. If they are new, an invite is created and the invitee must accept it after signing up.
The tab components now display both active users and pending invites in separate sections. Each pending invite shows status badges and a cancel action.
Modal Consolidation: RemoveDoctorModal and RemoveStaffModal were identical 70-line components that only differed in their type imports and title strings. They were consolidated into a single CancelInviteModal component that accepts generic props (title, identifier, onConfirm, isLoading).
Permission Checks: The hardcoded canManageStaff = true and canManageUsers = true were replaced with role-based checks -- only owner and admin roles can manage users.
Deleted Legacy Files: useUsers.ts, usersApi.ts, types/users.ts, types/index.ts, utils/queryKeys.ts, SpecializedCMSModal.tsx (unused).
Legacy Code Cleanup
The migration follows a clean cutover approach with no feature flag gating. All legacy code paths were deleted:
| Deleted File | Package | Replaced By |
|---|---|---|
twoFactorApi.ts | shell/services | shared-services 2FA API functions |
useTwoFactorAuth.ts | shell/hooks | shared-services 2FA hooks |
usePermissionManagement.ts | shell/hooks | shared-services permission hooks |
permissionsApi.ts | api-client/services | shared-services permission API |
userManagementApi.ts | api-client/services | shared-services identity API |
useUsers.ts | shell/features/userManagement | shared-services useIdentityUsers |
usersApi.ts | shell/features/userManagement | shared-services identity API |
types/users.ts | shell/features/userManagement | shared-services IdentityUser type |
utils/queryKeys.ts | shell/features/userManagement | shared-services identityKeys factory |
SpecializedCMSModal.tsx | shell/features/userManagement | Unused -- deleted |
RemoveDoctorModal.tsx | shell/features/userManagement | CancelInviteModal |
RemoveStaffModal.tsx | shell/features/userManagement | CancelInviteModal |
Remaining Legacy References
Two legacy files were intentionally kept:
deviceApi.ts-- Still used by theDeviceManagementcomponent for SMS/EMAIL 2FA device trust management. Device management hooks have not yet been added to shared-services.useUserManagement()re-export in@kulcare/auth-identity-- Still imported byAuditTrailPage.tsx. Cannot be removed until AuditTrailPage is migrated separately.
Stakeholder Role Decision
During the review, stakeholders were initially mapped to the admin role. This was corrected after recognizing that stakeholders serve a distinct purpose in the system: they are commission payout recipients (investors/partners) who need visibility into their revenue share, not administrative access. The stakeholder role was kept as a separate concept with useIdentityUsers({ role: 'stakeholder' }).
The backend identity.user_memberships model will need a stakeholder role added to the role enum to support this. This is a follow-up backend task.
File Reference
Backend (Modified)
backend/domains/identity/models/session.rb # revoke_all_for except_session fix
backend/domains/identity/controllers/session_management_controller.rb # current_session_id in blueprint
backend/domains/identity/blueprints/session_blueprint.rb # current field
backend/domains/identity/controllers/staff_invites_controller.rb # authorization + role validation
backend/domains/identity/controllers/practitioner_invites_controller.rb # authorizationFrontend: Shared Services (Added)
frontend/packages/shared-services/src/domains/identity/
types.ts # +128 lines: UserSession, SessionDevice, TwoFactorStatus, etc.
keys.ts # +20 lines: sessions and twoFactor key factories
api.ts # +146 lines: 9 session/2FA functions + user listing
hooks.ts # +176 lines: 9 session/2FA hooks + user listing hooksFrontend: Shell App (Modified)
frontend/apps/shell/src/
pages/account/SessionsPage.tsx # Rewritten (151 -> 55 lines)
pages/account/SecurityPage.tsx # Simplified to status card
pages/account/TenantPermissionsPage.tsx # Updated to use shared-services hooks
pages/settings/TwoFactorAuthPage.tsx # Updated imports
components/settings/TwoFactorSetup.tsx # Updated API calls
components/settings/RecoveryCodeManagement.tsx # Removed password field
components/settings/DisableTwoFactorModal.tsx # Error type fix
components/settings/DeviceManagement.tsx # Error type fix
features/userManagement/ # 16 files rewritten/deletedFrontend: UI Kit (Modified)
frontend/packages/ui-kit/src/components/users/SessionManagement.tsx # New data shapeRelated Documents
- Migration Plan -- the original plan with acceptance criteria and gap analysis
- Identity Schema -- entity definitions for users, sessions, tenants, permissions
- Session Tracking Design -- original design for the sessions table
- Two-Factor Authentication Plan -- original 2FA implementation plan
- RBAC Migration Design -- permission system architecture
- Auth APIs Implementation Plan -- registration and session endpoints
- Brainstorm: Signup, Onboarding & Auth -- key architectural decisions