Kulcare Docs

Authentication flow map

Every way into and out of the Kulcare web app, what the token carries at each point, and how sign-in now ends on Connect instead of the facility picker.

This page maps every way a person gets into the Kulcare web app (the shell at frontend/apps/shell), and every way they leave it. For each flow it shows the path before the change and the path that is built now.

Status: built. Every unit is built, the U10 frontend included. The plan is docs/plans/2026-10-06-feat-auth-connect-landing-plan.md, on the integration branch feat/auth-connect-landing. Each flow below says which unit built it. The decisions are in Decisions.

All units are built. On a read-only practice the main write actions in CMS, Hospital and Growth are disabled with a short reason (see routing-architecture.md, "A suspended practice"). Other pages rely on the read-only banner and the TENANT_SUSPENDED message.

Why this page exists

QA recorded a new Facility Owner who signed up and never saw onboarding. The cause was in the sign-in flow:

  1. After signup and OTP, the shell sent a user with any membership to /select-profile.
  2. The user picked a facility. The shell then sent them to /, the shell dashboard.
  3. Onboarding never started.

A narrow fix for the signup path is on PR #2581. The broader change, now built, is: do not show the facility picker after authentication. Send every user to Connect. Connect sends an unfinished user to onboarding and a finished user to the dashboard. Almost every flow on this page went through the picker, so the team agreed on the full map before anyone changed it.

The three facts that shape every flow

1. The token decides the facility. The auth store (packages/auth-identity/src/store.ts) has no "selected facility" field. selectedProfile, currentTenantId and currentFacilityId come from the JWT claims tenant_id and facility_id (utils/deriveProfile.ts, hooks/useAuth.ts).

Token issued byHas tenant_id?
Password sign-in (POST /identity/sessions)No
2FA verify (POST /identity/two_factor/verify)No
Signup OTP verify (POST /identity/registrations/verify)No
Profile switch (POST /identity/profiles/switch)Yes, with facility_id and role
Refresh (POST /identity/sessions/refresh)Only if the session already had a tenant and a facility

2. The backend keeps its own tenant on the session. Row-level security reads the tenant from the session record, not from the JWT (backend/domains/shared/concerns/tenant_context.rb).

  • Password, 2FA and OTP sign-in put no tenant on the session (U8, built).
  • Only a profile switch sets the tenant and the facility (D3).

Before U8, password and 2FA sign-in put the user's oldest active membership on the session. The frontend then thought "no tenant" while the backend used the primary tenant. Now both sides agree.

3. The shell router asks for a facility only where a page needs one. ProtectedRoute (apps/shell/src/components/ProtectedRoute.tsx) defaults to requireClinic = true. With no selectedProfile, it counts the user's facility choices (U4, built):

  • None: /connect.
  • One, in a practice that is not suspended or cancelled: AutoSelectFacility switches into it silently, then the page renders.
  • Several, or one in a suspended or cancelled practice: /select-profile?next=<the page>.

These routes opt out with requireClinic={false}: /connect/*, /research/*, /style-guide, and the user-only pages from D2 (/account/preferences/:tab, /account/facilities, /settings/two-factor-authentication, /help/:tab).

Sign-in flows

Every sign-in now ends in one function, finishSignIn (apps/shell/src/services/identity/postAuth.ts, U3, built):

  1. If the user has exactly one facility in a practice that is not suspended or cancelled (silentSwitchTarget), it switches into it with the shared switchFacility. A failed switch is logged and does not stop the sign-in.
  2. It goes to signInTarget(next): a safe next, or /connect. A bare / also maps to /connect.

Password sign-in

Before: every user with a membership saw the picker. The picker then sent them to /, not to where they wanted to go.

Built (U3): no picker. One open facility is switched in the background. Everyone lands on a safe next or on /connect, and Connect decides between onboarding and the dashboard.

While finishSignIn runs, the form stays busy and a second submit does nothing. GuestRoute waits for finishSignIn (beginSignIn, isSignInFinishing), so the next page does not open before the switch lands.

Files: pages/identity/SignInPage.tsx, services/identity/postAuth.ts, App.tsx (GuestRoute).

2FA sign-in

Same as password sign-in, after a code step. Before, the 2FA path read availableProfiles from a store snapshot taken before it wrote the new profiles. So it could route on the previous user's list, or an empty one.

Built (U3): the same end as password sign-in, through finishSignIn. It reads the profile list from the store after the write, so the stale read is gone.

A trusted device skips the 2FA step on the backend (check_two_factor_service.rb). Device trust survives logout.

PHR ABHA sign-in and 2FA

The patient app signs in with an ABHA OTP (/phr/login/*). Phr::Login::GenerateKulcareSessionService finds the account by phone and starts a session that carries no tenant (#2409).

An account with 2FA on follows the same rule as password sign-in (#2650). The ABHA OTP still finds or makes the user, the patient and the ABHA profile row. On a device that the account does not trust, it gives no session:

  • The login endpoint answers success: false, error_code: TWO_FACTOR_REQUIRED. data holds only kulcare_session: { requires_2fa, two_factor_method, temp_auth_token, masked_contact }. masked_contact is the masked phone or email that got the code (nil for an authenticator app). The backend sends the second-factor code.
  • The answer carries no user, patient or ABHA token before the second factor (#2653). The backend keeps them, encrypted, in the cache (Identity::TwoFactor::PhrSessionHandoff, 10 minutes). The challenge token names them in its phr_handoff claim.
  • The client completes the sign-in with POST /identity/two_factor/verify. The challenge token carries tenant_access: false, so that session also carries no tenant. For a PHR challenge, the answer adds data.phr_session: { user, patient, abha_token, abha_refresh_token, abha_expires_in }, one time. The patient app builds its session from the new tokens and phr_session.
  • A PHR challenge whose kept data is gone answers 401 CHALLENGE_EXPIRED on verify and resend, and the code stays unused. The user, the patient and the ABHA tokens go to the cache in one write, before the code goes out. When the backend cannot keep the data, or any other error stops the second-factor step, the login endpoint answers error_code: TWO_FACTOR_UNAVAILABLE with no ABHA token, and sends no code.
  • POST /identity/two_factor/resend (challenge token in Authorization) sends a new SMS or email code and answers a new challenge token with the same tenant access and the same phr_handoff. Limits: 3 for each account and 10 for each IP in 10 minutes. An authenticator app answers RESEND_NOT_SUPPORTED.
  • The patient app shows a two-step sign-in screen (code, recovery code, Resend after 60 s) on the ABHA OTP and ABHA password sign-ins (TwoFactorStep).
  • When the second-factor code does not go out, the endpoint answers success: false with the send error.
  • The multi-ABHA picker step (no ABHA address yet) sends no code and gives no session. verify_user asks for the second factor after the pick.
  • PHR registration (IdentityPatient::FindOrCreate) starts no session and gets no challenge.

Account lockout and Turnstile

Both are backend checks on the sign-in request. Neither changes the route.

  • Lockout (#1810): after repeated failures the backend answers 403 "Account is temporarily locked". The shell shows it as a toast.
  • Turnstile: the sign-in, signup and OTP-resend forms carry a Cloudflare widget. It does nothing until VITE_TURNSTILE_SITE_KEY is set. The backend fails open on sign-in.
  • Code limits for each account (#2650), in two tiers so that one actor cannot lock a person out: a limit for the account and the IP, and a looser limit for the account from any IP.
    • Signup, OTP resend, password reset and the code that a sign-in of an unverified phone sends share one send budget for each phone or email: 5 for each IP and 15 in all in an hour. Signup and resend count only after Turnstile passes.
    • OTP verify: 10 tries for each IP and 30 in all for each phone in an hour.
    • PHR login: 5 code sends for each IP and 15 in all in an hour for the identifier the OTP goes to (mobile, ABHA number or ABHA address), 10 code checks in an hour for each ABDM transaction (txn_id), and 5 ABHA password tries for each IP and 15 in all for each ABHA address in an hour.
    • Over a limit, the answer is 429 until the hour ends. These limits fail closed: a cache fault answers 503. They come on top of the limits for each IP.
  • Code SMS cap (#2650): Shared::Notifications::CodeSmsCap sends code SMS to IN (+91), US and Canada (+1) and AE (+971) only. The +1 area codes of the Caribbean and Pacific are refused. It stops at a daily count for each country (UTC day), in two budgets:
    • Anonymous (signup, resend, password reset, public appointment codes): CODE_SMS_DAILY_CAP_IN (default 20000), CODE_SMS_DAILY_CAP_US and CODE_SMS_DAILY_CAP_AE (default 2000 each).
    • Account (2FA, 2FA setup, re-authentication, identifier change): CODE_SMS_DAILY_CAP_ACCOUNT_IN, _US and _AE, with the same defaults. A public sender cannot use up the codes that signed-in users need.
    • Voice (the facility contact code call, #2650): CODE_VOICE_DAILY_CAP_IN (default 500), CODE_VOICE_DAILY_CAP_US and CODE_VOICE_DAILY_CAP_AE (default 0). Shared::Notifications::Msg91VoiceCode reserves from this budget before each call. Calls never use up the SMS budgets. See the operator section of docs/guides/user/facility-contact-verification.md.
    • The first refusal of a day goes to the error reporter. When the counter fails, no code goes out, and the error reporter hears of it. A 2FA SMS that does not go out stops the sign-in with an error.

Signup flows

Signup and OTP

What the backend creates at OTP verify depends on the role the user picked (ProfileOrchestrationService):

Signed up asCreated at verifyMemberships after verify
DoctorA user, practitioner recordNone, unless an invite matches the phone or the invite link links one
Facility OwnerA draft tenant and facility; the user is its ownerOne
StaffA user, and a staff record only when an invite linksNone, unless an invite matches the phone or the invite link links one
VendorA userNone

Before (with the fix on PR #2581):

Before PR #2581, /identity/verify-otp sat inside GuestRoute. The new token made the user "signed in", so GuestRoute sent them to /. The "new signup" marker was lost, and the picker sent them to the shell dashboard. That is the QA bug.

Built (U3): the OTP page ends on finishSignIn. The /select-profile branch and the fromSignup state are gone. The page sits outside GuestRoute and sends a signed-in visitor to signInTarget(next).

Repeat signup on an unverified phone

A signup can type the phone of an account whose phone is not verified: an unfinished signup, or a migrated patient account that never signed in (#2650).

  • POST /identity/registrations changes no field of that account. The response shows the submitted fields, not the account.
  • The backend keeps the submitted name, email and password in users.settings['pending_signup'] (Identity::Users::PendingSignup), bound to the code that goes to the account's phone and to a signup token. The response gives the token in meta.signup_token. The entry holds keyed digests of the code and the token and a bcrypt digest of the password. It expires after 1 hour.
  • The fields apply only when that code verifies the phone and the verify carries the token of these fields. If an attacker's signup replaced the owner's fields, the owner's verify drops them. A verify with no token (the code step after sign-in) drops them too. A new email is not verified. An email that another account took meanwhile is left out.
  • A resend that carries the signup token binds the fields to the new code. The shell sends the token from the signup page to the OTP page in the router state, and sends it with verify and Resend.
  • A different code (Resend with no token, or the code step after sign-in) verifies the phone but drops the fields. The user can sign up again, or reset the password.
  • The patient app sends the token from the signup screen through verify and Resend too (VerifyOtp).

Unverified user who signs in

A user who signed up but never verified their phone gets a 403 at sign-in. The shell sends them to OTP with flow: login-unverified. Before, this flow always went to /connect/onboarding after verify, even for a user who already finished onboarding.

Built (U3): SignInPage passes next to the OTP page in the router state. After verify, the flow ends on finishSignIn, like every other sign-in.

A phone sign-in on an unverified phone sends the code with the 403 and says so in otp_sent (see "The code" below). SignInPage always puts otpSent (true or false) in the router state, so the OTP page sends no second code. When the router state has no otpSent, the OTP page sends one code (POST /identity/registrations/resend_otp) when it opens (#2648), and the user does not wait 60 s for Resend. The page sends it once only: a ref stops the second StrictMode effect, and codeRequested: true in the history entry stops a send after a remount, a reload, or back and forward. With Turnstile on, the send waits for the first token. The Resend countdown starts again when the code goes out. A failed send shows the not-sent warning and opens Resend after 30 s. The OTP verify passes no membership to the token service, so a suspended oldest practice does not refuse it (U8, D8).

The code (QA of #2636). Before, the 403 sent no code, but the OTP page said "Enter the 6-digit code sent to". Only Resend, after 60 seconds, sent a code. Now:

  • A phone sign-in with a correct password on an unverified phone sends a code with the 403. The 403 carries otp_sent: true or otp_sent: false.
  • The send keeps the rules of Resend. It needs a real Turnstile pass: a sign-in that passed only because the token was missing or Cloudflare was down sends nothing. It spends the send budget that signup, Resend and password reset share (5 for each IP and 15 in all in an hour). The code SMS cap applies.
  • Over the send budget, the answer is still the 403, with otp_sent: false, not a 429.
  • A code sent in the last 60 seconds is not sent again. It is still valid, so the answer has otp_sent: true.
  • The shell passes otpSent to the OTP page. With otpSent: false, the page does not say that a code was sent. It shows "We could not send the code. Try Resend." and Resend is open at once.
  • An email sign-in of an unverified email sends no code, and the 403 has no otp_sent.

After an admin changes the phone

A platform admin can change a practitioner's phone with PATCH /identity/admin/practitioners/:id. The user never proved the new number, so the change sets phone_verified to false, clears phone_verified_at, and clears a pending code. Invites match only a verified phone, so an invite to the new number waits. A change of format only (a leading +, or the country code in front) keeps the stored number and its verification.

The endpoint stores the number in the form that sign-up stores and phone sign-in looks up: phone_number without + and without the country code, 10-15 digits. It removes the account's own country code when 10 or more digits stay, so +919876500000 and 919876500000 are stored as 9876500000. A number that is not 10-15 digits after that gets 422. A number stored as typed before this rule is written in the stored form on an admin save of the same number, and its verification stays.

The user verifies the new number through the login-unverified flow above. Phone sign-in with the password answers 403 status: unverified, and the OTP page opens. The code comes from the sign-in itself (otp_sent: true), from Resend, or from the admin through POST /identity/admin/practitioners/:id/phone_verification. All three store the same registration code.

The code proves the phone only. So POST /identity/registrations/verify has these limits for an account that exists:

  • An established account gets no session (User#established_account?: it signed in before, verified an email, has 2FA, or is a platform admin). The phone becomes verified, and the response is 403 with error code SIGN_IN_REQUIRED. Signup orchestration does not run, so a pending invite to the new phone waits on the invites page. The OTP page shows no error, and the request sets quietForbidden, so the shell shows no forbidden toast. The page goes to /signin (with next) and the sign-in page shows "Your number is verified. Sign in to finish." The user then signs in with the password (and the second factor). The phone goes along in the router state, and the sign-in form fills it in (useSignInPrefill, #2650). Only a first registration gets a session from the code.
  • A deactivated account stays deactivated. The response is 422 Account is not active, and the phone stays unverified.
  • The code goes out by SMS only, so verify refuses an email identifier (422). Before, a code sent to the phone marked the email as verified.

POST /identity/registrations on the new phone answers 422 is already being used for an established account. Before, a repeat signup on an unverified phone set the password and the name with no code, and the account's verified email then signed in with that password.

Invitation flows

Before: the backend sent {app_url}/signin?next=/identity/my/invites. No frontend code read next, and /identity/my/invites is not a frontend route.

Built (U7, U3): the practitioner and staff invite links are /signin?next=/connect/invites. The admin message to a verified owner is /signin?next=/connect. Sign-in and GuestRoute follow a safe next.

The backend sends {base_url}/signup?invite_token=.... The signup page checks the token (GET /identity/invites/verify), fills the form and locks the role.

Built (#2614): the OTP page sends invite_token to POST /identity/registrations/verify. The backend links the invite of the token, and also every pending invite to the phone or email the user verified. One rule (InviteAddressable.linkable_by_token, ProfileOrchestrationService.link_invite_for) gives both the role and the link:

  • The invite must be pending, not bound to another account, and have an expires_at that is not past. An invite with no expires_at does not link.
  • A facility admin invite does not move by its link alone. It needs the invited phone or email, verified.
  • A token that links nothing does not change the role. The role from the form stays.
  • A repeat invite from the facility gives the invite a new token and sends the new link, but only when the request names the invite's own phone. The old link stops working, at registration and on the signup page (GET /identity/invites/verify needs the stored token). The tenant audit log gets an invite_resent row.
  • When the SMS does not go out, the invite goes back as it was, and the old link works. When the send went out but no answer came back (Net::ReadTimeout or Net::WriteTimeout, also when a provider client wraps it), the new link stays, because the text can be on the phone. The backend records the attempt as unconfirmed (unconfirmed_resend_attempts in invite_details) and reports a warning. The inviter reads "We sent the invite link again, but we could not confirm that the text went out." (Identity::Invites::ResendDelivery).
  • An invite JWT carries purpose: 'invite'. The signup page and registration refuse a token with another purpose, and an access token signed with the same key. A legacy invite JWT with no purpose works only while it is still the stored token of its invite (InviteTokenService.decode).

Resends and invite texts have these limits (Identity::Invites::RepeatInvite, Identity::Invites::InviteSms):

  • One resend of an invite in 10 minutes.
  • Three resend attempts for each invite in 24 hours.
  • A wait of 1 minute after a send that did not go, before the next attempt.
  • Five invite texts to one phone from one tenant in 24 hours.
  • Ten invite texts to one phone from all tenants in 24 hours.
  • 200 invite texts from one tenant in 24 hours.
  • 2000 invite texts from all tenants in 24 hours (Identity::Invites::PlatformTextCap::LIMIT). The variable INVITE_TEXT_PLATFORM_DAILY_LIMIT overrides it. A text that this cap stops is logged (tag=platform_text_limit), and the error tracker gets PlatformTextCap::LimitReached at most once an hour for each process.

The phone caps count both invite kinds, in every status: each invite made counts once, and each resend attempt counts once. The phone match uses the last 10 digits, so "+91", "91", a number typed with its country code and a number with a trunk 0 are one phone. In the count over all tenants, one tenant adds at most 3 (TENANT_SHARE_OF_PHONE_LIMIT), so it takes four tenants to stop the texts of other facilities to a phone. Both phone caps give the inviter the same message and the same code (phone_limit), so an inviter does not learn that other facilities texted the number. Logs keep the real code (phone_limit_all_tenants). The tenant cap does not count invites with no phone. A first invite over a cap stays saved, sends no text and answers sms_sent: false. An invite with no phone answers sms_sent: null. A resend over a cap gets a message that names the cap.

The invite create answers (staff, practitioner, and the admin facility invites of doctors, staff and admins) carry three meta keys:

  • sms_sent: true, false, or null when no text was due.
  • sms_error_code: the code of a text that did not go (for example phone_limit, tenant_limit, platform_limit, msg91_311, unconfirmed, missing_sender_id), else null.
  • sms_error_message: the message of a text cap, which the inviter can act on, else null.

No screen shows the invite link, so the cap and resend messages tell the inviter to ask the person to sign up with this phone number. A resend refusal starts with "Invite already exists." (practitioner) or "Invite already exists for this person at this facility." (staff), then the outcome. The frontend reads the outcome after that prefix. A tenant at its daily cap is reported, because it can show a bulk send.

A platform admin invite runs with no tenant GUC, so the invite text runs in the invite's tenant (the notification log is FORCE RLS). The owner invite text of Identity::Invites::AdminOwnerInvite has no text caps, on purpose: only a platform admin sends it, once for each practice that they create. It still counts toward the caps of later tenant invites to that phone. The owner invite of a practice that a platform admin creates is saved in the create transaction, and its text goes after the commit (Identity::Invites::AdminOwnerInvite). The create answer gives meta.owner_invite_sms_sent, and meta.owner_invite_sms_unconfirmed when the request went out with no answer. A text with no answer keeps the new link.

When the user sent a token and the backend linked no invite, the response carries meta.invite_not_linked: used, cancelled, declined, expired, bound_to_other_account, admin_needs_invited_phone, not_found, replaced or not_linked. The backend makes the account all the same. The OTP page shows the reason and "Your account is ready, but it has no access to that facility.", and goes on when the user clicks Continue. A staff signup makes no staff record when no invite links. A signup with no token gets the reason too when every invite that matched its phone was taken first. A retry by the user who already took the invite gets no reason. Every not-linked case is logged with the invite, its status, tenant and expiry.

An invite that links by its token only is checked again under the invite row lock, with the link rules: the stored token must still be the token that the user sent, and the invite must still be pending, not expired, a valid invite JWT, not bound to another account, and not an admin invite. A resend replaces the token and keeps the invite pending, so a signup that read the invite before the resend gets replaced and no access. An invite sent to a phone or email that the user verified keeps the status check only.

When an invite links by its token only (the user verified neither the invited phone nor the invited email), the backend tells the people who can see a forwarded or leaked link (Identity::Invites::LinkOnlyNotice). Who may link by token alone is still an open product decision (security review F1).

  • The inviter gets an in-app notice (invite_linked_other_phone) with the name and the masked phone of the new account. The notice also says whether the SMS and the email went out.
  • The invited phone gets an SMS that says someone used the invite with another number. An Indian number gets it only after someone registers its DLT template (entry 24 in docs/plans/2026-06-13-sms-dlt-registration-worksheet.md). Until then the backend skips the SMS and logs one info line for each skip.
  • When the SMS did not go out, the invited email gets the same words (Identity::InviteMailer#used_by_other_number).
  • The audit log of the invite's tenant gets a row (invite_linked_by_link_only), with the outcome of the SMS and of the email. A failed step is reported and does not fail the signup.

The SMS, the email and the invite texts show the facility name cut to 40 characters, with no link in it (Identity::Invites::SafeFacilityName). The tenant chooses the name, and the message comes from Kulcare. A name that loses words is logged once, with the facility id only ([invite.text] tag=name_cleaned facility=<id>). The inviter name in the practitioner invite text gets the same filter (Identity::Invites::SafePersonName, log tag person_name_cleaned). The registered DLT template has a variable for that name, so the template body does not change.

A practitioner signup from the link adopts the inactive placeholder record of the invite, and removes the invited phone and email from it. A link alone does not adopt an active record with no account, or a placeholder that another pending invite also uses.

Accepting an invite while signed in

There are three live places to accept: the Connect dashboard banner, /connect/invites and the onboarding Facility step. The shell InviteBadge is turned off (SHOW_INVITE_BADGE = false). All of them call POST /identity/my/invites/:id/accept.

Before, only the dashboard banner wrote the new membership list to the store. Built (U7): all three use the Connect hook useAcceptInviteAndRefreshProfiles. It accepts, then writes available_profiles to the store. The new facility shows in the switcher with no reload. No surface switches into the new facility.

Opening the CMS, Hospital or Reports app

This is where the change moved the picker to. Before, a user with no selected facility who opened CMS, Growth, Reports, Hospital or most /account pages went to the picker. After the pick they went to /. The app they asked for was lost.

Built (U4): the picker opens only when it is needed, and it returns the user to the app through next.

AutoSelectFacility and the picker both read the practice's feature flags into the store before the page renders (loadTenantFeatureFlags). Without this, a page gated on a practice flag (the Hospital app) first said it was not available.

The picker (pages/identity/SelectProfilePage.tsx):

  • Lists one card for each facility choice. The patient row is not one (D5).
  • On a direct visit with no next and one open facility, it selects that facility after 1.5 seconds.
  • Shows a suspended practice with a "Suspended" pill and "This practice is suspended. Contact Kulcare support." A cancelled practice gets a "Cancelled" pill and a closed message. Neither offers a switch (D9).
  • Shows the support message for a refused switch, not the raw backend text.

Which pages need a facility

SurfaceNeeds a facility?Without one (built)
Connect (/connect/*)NoWorks. Cross-tenant.
Research (/research/*)NoWorks. The backend allows no tenant.
CMSYesSilent switch or the picker, then back to the page.
ReportsYesSilent switch or the picker, then back to the page.
GrowthYesSilent switch or the picker, then back to the page.
HospitalYesSilent switch or the picker, then back to the page.
/account, subscription, invoices, payment methods, billing, billing address, users, permissions, audit trailYesSilent switch or the picker. The billing-address page no longer spins for ever: it shows "Select a facility" (U6).
/account/preferences/*, /settings/two-factor-authentication, /help/*, /account/facilitiesNoWork with no facility (U6, D2).
/Not a pageRedirects to /connect (U5, D1).

Switching facility after sign-in

Two functions issue a tenant token. Before, they behaved differently. Now every shell caller uses the shared switchFacility.

Shell switchProfileShared switchFacility
Fileapps/shell/src/services/identity/auth/index.tspackages/auth-identity/src/services/identityAuthService.ts
Cancels and refreshes cached dataNoYes (cancel, then invalidate)
Tells other tabsYesYes
Callers beforeThe picker, the shell sidebarCMS, Growth, Reports, Hospital and Research layouts; the Connect sidebar footer; Connect referrals
Callers nowNone in the shellEvery caller above, plus the picker, the shell sidebar, finishSignIn, AutoSelectFacility and the Connect facility profile page

The shell sidebar keeps the user on the page after a switch (U5). The page body remounts when the practice changes, so a page that kept state from the old practice starts again. A practitioner or invoice page goes to its list, because the record belongs to the old practice.

Password reset

The user is never signed in by a reset. They always land on /signin. The reset revokes every session.

A reset code is valid for 10 minutes, the same as every other 6-digit code (Identity::PasswordResetToken::MAX_EXPIRY). Five wrong codes end every open code of the account. Before the QA of #2636, a code stayed valid for about 10 years: the model read a 24-hour Duration as a number of hours. A code made before the fix stops working 10 minutes after it was made.

Two notes. The reset code page is /verify-otp and the signup code page is /identity/verify-otp, which is easy to confuse. All three reset pages sit inside GuestRoute, so a signed-in user who opens one goes to signInTarget(next), which is /connect with no next.

This work did not change this flow. Only the sign-in it ends on changed.

Before, ProtectedRoute sent a signed-out user to /signin with no record of where they were going. After sign-in they went to the picker and then /.

The CMS, Growth and Reports layouts also redirect on their own (Growth and Reports to /login, which redirects to /signin). The shell guard runs first, so these stay. Moving them onto the shell guard is out of scope.

Return to the page the user asked for

Built (U2, U3, U4): the guard passes the target in next, sign-in keeps it, and the end of sign-in goes to the target. The facility check for that app then runs (a silent switch or the picker).

safeReturnPath (packages/auth-identity/src/utils/safeReturnPath.ts) accepts only a path on this site:

  • It starts with one /, and not with // or /\.
  • Its path part has no colon, so it has no scheme. The query and the hash may have one.
  • It has no control character, raw or percent-encoded, and no raw space.
  • The decoded value passes the same checks. A value that does not decode fails.

Token refresh and expiry

The api client (packages/api-client/src/index.ts) refreshes on a 401. Only one tab refreshes at a time (Web Lock kulcare-token-refresh). A refresh keeps the tenant only when the session already had a tenant and a facility. A network or server error retries once and does not sign the user out. Only a final refresh failure fires kulcare-unauthorized.

Two changes from this work (U8, U10 backend):

  • A refresh on a session with no tenant passes no membership. A suspended oldest practice no longer signs the user out at the first refresh.
  • A refresh keeps a session in a suspended practice read-only. It drops the read-only marker once the practice is active again.

There is no idle or session timeout. A SESSION_TIMEOUT_HOURS constant exists but nothing reads it.

Logout

Every logout ends on /signin. The steps differ by app.

WhereServer logoutClears cached data and user keysGoes to
Shell sidebarYesYes/signin
Picker "Back to Sign In"YesYes/signin
CMSYesYes/signin
ReportsYesYes/login, then /signin
GrowthNoNo, only resets the store/login, then /signin
Connect, Hospital, ResearchYesYesRouter sends to /signin
A final 401YesYes/signin

This work did not change this flow. The Growth logout is out of scope.

More than one tab

A silent switch at sign-in reloads every other open tab. A tab that sits on /signin while another tab signs in becomes signed in. GuestRoute then sends it to a safe next, or to /connect. Before, it went to /.

Inside Connect

Only the index route /connect checks onboarding. /connect/dashboard and every deep link skip the check on purpose (I7: "the dashboard is never gated"). So the end of sign-in targets /connect, not /connect/dashboard.

Connect works with no facility selected. Where it reads one:

PlaceWithout a facility
Sidebar footerShows "Select Facility" and the switcher.
Referrals on a tenant the user belongs toAsk to confirm and switch before each action (one extra step).
Onboarding Facility step, "What your admin has granted" (staff)Stays empty. It needs a tenant.
/connect/facilitiesLists the facilities of the membership rows, with a tag on a suspended or cancelled practice. It does not call the tenant endpoint (U8).
/connect/facility/:id/profileNames the facility and offers "Open this facility", which switches into it. A suspended practice offers "Open read-only", and "Go to billing" to a member whose payment restores it (U10). A cancelled practice gets a support message and no switch (U8).
/connect/facility/profile with no idLinks to the facility list.

The Connect "Other apps" group and every app switcher rail leave out the shell Dashboard, because / redirects to Connect (U5).

The change (built)

  1. One end for every sign-in. Password, 2FA, signup OTP and login-unverified all finish on /connect, or on a safe return path when one is set. Built in U3.
  2. One facility: switch silently. A user with exactly one facility in an open practice gets a tenant token without a picker. A failed switch does not block sign-in, because Connect needs no tenant. Built in U3.
  3. Several facilities: pick on demand. The picker shows only when the user opens a page that needs a facility. It returns them to that page. Built in U4.
  4. GuestRoute sends a signed-in visitor to a safe next or /connect, not /. Built in U3.
  5. Return path. ProtectedRoute passes the target in next; sign-in and the picker return to it. Built in U2, U3 and U4.

Flows this changed: password sign-in, 2FA sign-in, signup OTP, login-unverified, invite links, opening the CMS, Hospital, Reports or Growth app, deep links, the multi-tab sign-in case, the shell sidebar switch.

Flows this did not change: password reset, logout, the facility switchers inside the apps, Connect's own routing. Token refresh changed only for a session with no tenant and for a suspended practice.

Problems found while mapping

#ProblemWhereStatus
1Sign-in ignored next and deep-link targetsSignInPage.tsx, ProtectedRouteFixed (U2, U3, U4)
2The invite link pointed to /identity/my/invites, which is not a pagebackend invite_service.rbFixed: /connect/invites (U7)
32FA sign-in routed on a stale profile listSignInPage.tsxFixed (U3)
4Shell switchProfile kept the previous facility's cached dataapps/shell/src/services/identity/auth/index.tsAvoided: the shell no longer calls it (U3, U4, U5)
5invite_token is never sent at signup; invites link by phone onlySignUpPage.tsx, auth/index.tsFixed (#2614): the token is sent and links its invite
6Accepting an invite did not update the membership list on two of three surfacesMyInvitesPage.tsx, FacilityStep.tsxFixed (U7)
7Growth logout does not end the server session or clear user keysGrowthLayout.tsxOpen, out of scope
8The password-reset phone prefill is ignoredResetPasswordPage.tsx, SignInForm.tsxFixed: signInPrefill.ts (#2650)
9/account/billing-address spun for ever with no facilityBillingPage.tsxFixed (U6)
10User-only pages (preferences, 2FA settings, help) asked for a facilityAppRoutes.tsxFixed (U6); / redirects (U5)
11A facility owner with no tenant gets no system permissions (signed_up_as: facility matches no role default)PermissionsBridge.tsx, auth/helpers.tsPartly: the silent switch gives an owner with one facility a tenant
12No idle or session timeoutauth-identity constantsOpen, out of scope
13The patient row threw or counted as a facilityderiveProfile.ts, useAuth.ts, shell and Connect countsFixed (U1)

Decisions

The team accepted each decision on 6 October. The plan numbers them D1 to D13. This table groups them as the map does.

#DecisionPlanStatus
1Redirect / to /connectD1Built (U5)
2Pages that need only the user stop asking for a facilityD2Built (U6)
3Only a profile switch sets the session tenantD3Built (U8)
4Fix the invite link and next now; the signup token separatelyD4Built (U7); the signup token built in #2614
5Do not count patient membershipsD5Built (U1)
6Suspended practices: sign-in works; staff read-only; the owner can pay and get the practice backD8 to D13Built (U4, U8, U9, U10)

D6 (every sign-in ends on a safe path or /connect) and D7 (a silent switch for one facility, the picker on demand) are the change itself. They are built in U2, U3 and U4.

1. The shell dashboard /

Built: / redirects to /connect.

  • No sign-in lands on /. / and the shell catch-all /* redirect to /connect.
  • The shell sidebar "Home" item goes to /connect.
  • The app switcher has no Dashboard entry. It was a second link to Connect. The ui-kit roster (APP_ROSTER) no longer has it, so no rail shows it. The Hospital layout now passes its flag-built list to the rail too, so its rail shows the same apps as the others.
  • The shell dashboard page stays in the tree, unrouted. A later cleanup deletes it, after one release.
  • Account pages stay in the avatar menu, so the redirect hides nothing.

2. Pages that need only the user

Built: these pages do not ask for a facility.

  • Preferences, security and 2FA settings, help and /account/facilities use requireClinic={false}.
  • Subscription, invoices, payment methods, billing, billing address, users, permissions, audit trail, /account and the practitioner page keep the facility check.
  • The billing-address page shows "Select a facility" instead of a spinner.
  • AppRoutes.test.tsx mounts the real route table and checks each page.

3. The backend session tenant

Built: only a profile switch sets the session tenant.

  • Password, 2FA and OTP sign-in put no tenant on the session.
  • With the silent switch, a user with one facility gets the same tenant on both sides. A user with several stays without a tenant in Connect, which needs none.
  • POST /identity/facilities/setup takes an explicit mode: new_tenant or existing_tenant. An active owner or admin with no session tenant and no mode gets 422 FACILITY_SETUP_MODE_REQUIRED, not a duplicate practice. A practitioner with no owner membership still gets a new tenant. New-tenant mode reads every active membership, with or without a session tenant: an owner, admin or practitioner may start a practice, and a user whose active memberships are all staff or other non-clinical roles gets 403 AUTHZ_INSUFFICIENT_ROLE.
  • The Facility track of Connect onboarding ends with POST /identity/my/facility_setup (plan 2026-10-07, B6). It makes the practice (new_tenant), or a facility under a practice the user owns or administers with manage_facilities (existing_tenant with tenant_id), in one transaction. It then starts the plan's trial, or adds the facility to the practice's subscription, and sends the team invites. The facility goes on the subscription only with manage_subscription, and only at the expected_new_recurring total the Plan step showed. A billing or invite failure is reported in the response and does not undo the practice. The endpoint finds the practice through the user's own memberships, so it needs no session tenant. A repeat with the same idempotency_key returns the first result and makes nothing. A user who already owns an active practice gets 422 PRACTICE_ALREADY_OWNED for a new_tenant, unless the body sets allow_second_practice: true; Connect gives such an owner no steps that make a practice (plan 2026-10-08, P1-2).
  • Sign-in no longer refuses a user whose oldest tenant is suspended (D8).
  • The tenant, facility, organization, practitioner and practitioner AI settings endpoints answer 400 TENANT_REQUIRED with no session tenant, not 500.

Built: the link, next and the signup token (#2614).

  • The link for an existing user is /signin?next=/connect/invites. Sign-in follows a safe next.
  • After an accept on any of the three surfaces, the membership list updates. The new facility shows with no reload.
  • Signup sends invite_token, and the invite links to the new account by its token. A facility admin invite still needs the invited phone or email. An expired token, or one with no expiry, sets no role and links nothing. The response then says why in meta.invite_not_linked, and the OTP page shows it. A link-only match notifies the inviter and the invited phone. See "An invite link for someone new".

5. Patient memberships

Built: they do not count.

  • membershipProfiles keeps rows with a tenant. facilityChoices lists each facility of those rows.
  • The facility count, the silent switch, the picker, deriveSelectedProfile, useProfileSelfHeal and the shell and Connect counts use them.
  • The api-client type IdentityProfileRow includes the patient row, so the compiler finds an unguarded tenant read.

6. Suspended practices

Decision: a suspended practice blocks only that practice, and its owner has a way back.

  • Today sign-in checks the user's oldest practice and refuses the sign-in if it is suspended. A doctor whose oldest practice is suspended is locked out of every practice. After the change they sign in and use Connect.
  • The facility picker shows a suspended practice with a "Suspended" label. A silent switch never targets it. The route guard does not switch silently at all while any practice of the user is suspended: it sends the user to the picker, because the page they asked for may be about that practice.
  • Staff can open a suspended practice read-only: they can read patient records, but not change them. The owner, or an admin with manage_subscription, can also pay from billing and get the practice back. This changes the switch rule: the switch issues a read-only token for a suspended practice.
  • A practice is suspended after its subscription has been past due for 7 days. Payment restores only a non-payment suspension. So the picker and the read-only banner offer "Go to billing" only for a non-payment suspension. Any other reason points to Kulcare support.
  • A non-payment suspension ends with an expired plan, so the billing page offers a new plan, not an open invoice. Paying for a plan restores the practice.
  • After the payment the session is still read-only, and the backend answers 409 PROFILE_SWITCH_REQUIRED. The shell switches again into the same facility. This also covers Cashfree, where a webhook restores the practice after the page has left for the gateway.

What is built:

  • Sign-in no longer checks the oldest practice (U8). A person tied to a suspended practice signs in and uses Connect.
  • The billing job suspends a practice when its subscription is past due for more than 7 days, with the reason non_payment (U9). Support can suspend by hand with compliance or other, and reinstate (POST /identity/admin/tenants/:id/suspend and /reinstate, Identity::Tenants::SuspensionService).
  • No silent switch targets a suspended or cancelled practice. The picker shows it with a "Suspended" pill (U4), and offers "Open read-only" and, where payment restores it, "Go to billing" (U10).
  • The backend accepts a switch into a suspended practice for any active member and marks the session tenant_access: 'read_only'. A cancelled practice is still refused (U10 backend).
  • Shared::Concerns::SuspendedTenantGuard lets GET and HEAD through on a suspended or cancelled tenant. Other methods get 403 TENANT_SUSPENDED. A member with manage_subscription may write on the payment path: subscription initiate and verify, payment methods, billing addresses and promo code validate. The payment path is open only on a practice suspended for non_payment. On a compliance or other suspension it also gets 403 TENANT_SUSPENDED, because payment does not lift that suspension.
  • A real charge restores a practice suspended for non_payment and writes an audit row. Other reasons stay until support lifts them. The restore is best-effort, so Billing::SubscriptionExpiryJob tries again each day: a practice suspended for non_payment that has a paid plan charge invoice created after suspended_at is restored with the source reconcile. A module add-on invoice does not count. A read_only session on a practice that is active again gets 409 PROFILE_SWITCH_REQUIRED, so the client switches again.
  • GET /identity/profiles rows carry tenant.status, tenant.suspension_reason and can_manage_subscription.

Built in U10 (frontend): the picker actions, useTenantAccess() and the banner in the shell, CMS, Hospital, Reports and Growth, the single TENANT_SUSPENDED message, the switch on PROFILE_SWITCH_REQUIRED, and disabled write controls with a short reason: patients, booking, queue and encounter in CMS, admissions and billing in Hospital, and campaign create in Growth. Other pages rely on the banner and the message for now.

On this page