-
Notifications
You must be signed in to change notification settings - Fork 137
Client Portal Registration
Status: Nearly complete. All four registration channels (self-service phone-number, self-service email, staff-assisted, and kiosk — options 1–4 below), login, and password reset are all merged — see GitHub issues #22367 / #22363 / #22370 / #22368 / #22369 and pull requests #22372 / #22392 / #22400 / #22406 / #22413 — and will appear once your deployment picks up
development. Password reset by email is intentionally not included yet, even though accounts can now have a verified email — see Login and Password Reset below. The only remaining piece is the landing page, default institution, and deferred DDL regeneration (#22371). Tracked under master issue #22359, original ask #398.
The Client Portal lets a patient create their own login account for the HMIS web portal, instead of every portal account having to be set up by hospital staff one at a time. Once registered, a patient logs in with their phone number or email plus a password — the same way hospital staff log in today, but as a separate, patient-only account type that has none of the staff menus or permissions.
Registration is designed around one rule: a portal account always attaches to a person who is already known to the hospital, or becomes known to the hospital at the moment of registration. There is no "create an account for a stranger with no visit history" path — every account traces back to a specific patient record.
If you're at the hospital or on the phone with a staff member, they can create your portal account for you directly from their own staff login — no OTP needed, because the staff member verifies who you are in person or by phone. Tracked in #22363, pull request #22392.
For staff: this is an internal admin screen, reached via
Administration → Create Client Portal Account (requires the
ClientPortalCreateAccount privilege — see
For Administrators below). Search for the patient by
phone number, PHN, or name, select them from the results, then set a
password on their behalf:
| Step 1 — search and select | Step 2 — set password |
|---|---|
![]() |
![]() |
| Step 3 — confirmation |
|---|
![]() |
Unlike the phone-OTP path, there's no multi-match picker here — searching directly and picking a row from the results is the disambiguation step, so a shared household phone number isn't a special case for this channel. If the selected person already has an active portal account, the screen blocks creating a second one and points staff to login/password reset instead:

If your phone number is already on file at the hospital (from a previous
visit), you can register from home at /client_portal/register_phone.xhtml:
- Enter your phone number.
- The hospital sends you a one-time code (OTP) by SMS.
- Enter the code to verify it's really your phone.
- Set a password for your new account.
| Step 1 — enter phone number | Step 2 — enter OTP |
|---|---|
![]() |
![]() |
The "OTP SMS failed to send" banner in the screenshot above is a local/dev-only artifact — it appears when no SMS gateway is configured for the environment. On a properly configured deployment the OTP is delivered by SMS and this message doesn't appear. See Troubleshooting below.
If your phone number isn't on file yet, this path won't find you:

— see the Kiosk option below (once built), or ask staff (option 1, once built).
If you already have a portal account for that person, trying to register again shows a message telling you an account already exists instead of creating a duplicate:

Same idea as the phone method (option 2), but with an email instead: your
email must already be on file, you'll receive a one-time code by email
instead of SMS, and you verify it the same way before setting a password.
Register from home at /client_portal/register_email.xhtml:
- Enter your email address.
- The hospital sends you a one-time code (OTP) by email.
- Enter the code to verify it's really your email.
- Set a password for your new account.
| Step 1 — enter email address | Step 2 — enter OTP |
|---|---|
![]() |
![]() |
The "OTP email failed to send" banner in the screenshot above is a local/dev-only artifact — it appears when no email gateway is configured for the environment. On a properly configured deployment the OTP is delivered by email and this message doesn't appear. See Troubleshooting below.
If your email address isn't on file yet, this path won't find you — same "No Matching Patient Record" message as the phone channel:

If you already have a portal account for that person (registered via any channel — phone, staff-assisted, or a previous email registration), trying to register again by email shows the same "account already exists" message rather than creating a duplicate, and — importantly — never discloses the account holder's name before this point:

Once complete:

Built, verified, and merged — #22368, PR #22406.
Hospitals can set up a self-service registration terminal in the lobby or
reception area, restricted to that terminal's network so it can't be used
remotely, at /client_portal/register_kiosk.xhtml. The kiosk still
verifies you by OTP, but — unlike registering from home (options 2 and
3) — the phone or email you enter doesn't have to already be on file. If
it isn't, the kiosk creates your patient record on the spot as part of
registering your account. This is the only registration path that can
sign up someone with no prior hospital visit at all.
- Choose phone or email as your verification method.
- Enter your phone number or email address.
- The hospital sends you a one-time code (OTP) by SMS or email.
- Enter the code to verify it.
- If it matches an existing patient record, you're taken straight to the password step (or a picker, if more than one record matches — see What If My Family Shares One Phone Number? below). If it matches no existing record, fill in your name, title, gender, and date of birth to create your patient record on the spot.
- Set a password for your new account.
| Existing patient — registration complete | New patient — falls through to password step |
|---|---|
![]() |
![]() |
Like the phone/email self-service channels, if the matched person already has an active portal account, the kiosk blocks creating a duplicate:

The kiosk terminal itself needs to be authorized via the
Client Portal - Kiosk Allowed IPs configuration key — see
For Administrators below. An unauthorized terminal
(wrong IP, or the key left unconfigured) sees a "Terminal Not Authorized"
message and none of the registration flow — same fail-closed behavior as
an empty allowlist.
Built, verified, and merged — #22369, PR #22413.
Many households — especially those with elderly parents or young children — share a single phone number. The phone-registration path (option 2, available now) accounts for this: when you verify a shared phone number by OTP, if more than one patient record is linked to that number, you'll be shown a list of the people registered under it and asked to pick who you are before continuing. The one-time code verifies the phone, not the person — picking yourself from the list is a separate step afterward.
You set a password during registration. Future sign-ins use your phone or email plus that password — the one-time code is only needed at registration, and again if you ever need to reset a forgotten password.
Once the landing page exists (#22371), signing in will show your hospital's name, address, and contact details, along with a welcome message. Features like viewing your bills, appointments, or lab reports are not part of this first version — those will be added in later updates. Until then, logging in shows a simple "you're logged in" confirmation (see below).
Once you have a Client Portal account (via any registration channel above),
sign in at /client_portal/login.xhtml with your phone number or email
plus the password you set at registration:
| Login form | After logging in |
|---|---|
![]() |
![]() |
There's no landing page yet (#22371 is still planned) — logging in shows a simple confirmation card with your name and a note that bills, appointments, and reports are coming soon, plus a Log Out button.
If your phone number is shared with other family members who also have
accounts, or you simply forget your password, use Forgot your
password? on the login page (or go directly to
/client_portal/reset_password.xhtml):
- Enter the phone number or email on your account.
- If more than one account matches (shared household phone), pick which one is yours — same picker pattern as the registration flow.
- The hospital sends a one-time code (OTP) to that phone number.
- Enter the code, then set a new password.
| Enter phone/email | Verify OTP | Reset complete |
|---|---|---|
![]() |
![]() |
![]() |
Password reset by email is not available yet. If your account's contact on file is a phone number, reset works as described above. If it matches only by email (not currently possible until the email-OTP registration channel, #22368, ships), you'll see a message explaining email reset isn't available yet and to try your phone number, or contact support.
Staff who should be able to create Client Portal accounts on a patient's
behalf need the ClientPortalCreateAccount ("Create Client Portal
Account") privilege, assigned the normal way: Administration → Manage
Users → (select the user) → Manage Privileges → List Privileges, check it
under the Administration section of the privilege tree, then Update
User Privileges. As with any privilege change, the affected staff member
needs to log out and back in for it to take effect (privileges are cached
per session at login).
The phone-registration channel reads these configuration keys (via the usual application configuration screen), all optional with sensible defaults:
| Key | Default | Purpose |
|---|---|---|
Client Portal - OTP Length |
6 | Digits in the generated OTP. Values below 4 or above 12 fall back to the default. |
Client Portal - OTP Timeout Minutes |
2 | How long an OTP stays valid after it's sent. |
Client Portal - Custom SMS Body Message for Send OTP |
(built-in message) | Optional custom SMS template for registration OTPs. Use {otp} as a placeholder for the code. |
Client Portal - Custom SMS Body Message for Password Reset OTP |
(built-in message) | Same idea, for password-reset OTPs — kept as a separate key so the two messages can be worded differently (e.g. "registration code" vs "password reset code"). |
Client Portal - Custom Email Subject for Send OTP |
Your Client Portal Registration Code |
Subject line for registration OTP emails. |
Client Portal - Custom Email Body Message for Send OTP |
(built-in message) | Optional custom email body template for registration OTPs. Use {otp} as a placeholder for the code, same convention as the SMS template key. |
Client Portal - Theme Color |
#1b4332 (forest green) |
Accent color for the registration page. |
Client Portal - Logo URL |
(none — shows a generic icon) | Optional hospital logo shown on the registration page. |
Client Portal - Kiosk Allowed IPs |
(empty — fails closed) | Comma-separated list of IP addresses allowed to use the kiosk registration terminal (/client_portal/register_kiosk.xhtml). Checked the same way WebUser.isIpAllowed() checks its per-staff-user allowlist. Must be set for any kiosk terminal to work — an empty value blocks all access, matching, unauthorized-terminal behavior rather than accidentally allowing all IPs. |
OTP delivery uses the same SMS/email gateway configuration as the rest of the system (channel-booking OTPs, reminders, etc.) — there's nothing registration-specific to configure for the gateways themselves.
- "OTP SMS failed to send" / "OTP email failed to send" appears for every registration attempt — the environment's SMS or email gateway isn't configured or is failing. Check the usual gateway configuration keys. The registration flow still lets the patient proceed to the OTP-entry screen even when delivery fails (so the flow is testable without a gateway), so this message alone doesn't block testing — but it does mean no real patient can complete registration until the gateway is fixed.
-
"No Matching Patient Record" — the phone number or email address the
patient entered doesn't match any active
Patientrecord's contact details on file. Ask the patient to confirm the exact number/address registered with the hospital, or direct them to update their contact details in person. The phone/email self-service channels deliberately do not create new patient records — direct the patient to a kiosk terminal instead, which can create one on the spot. -
"Terminal Not Authorized" at the kiosk — the terminal's IP address
isn't in the
Client Portal - Kiosk Allowed IPsconfiguration key (or the key is empty/unconfigured, which fails closed by design). Confirm the terminal's actual outbound IP (check for NAT/proxying between the terminal and the app server — the app checksX-Forwarded-Forfirst, falling back to the raw connection address) and add it to the allowlist. - "Account Already Exists" — the selected person already has an active portal account. Direct them to log in, or use Forgot your password? if they don't remember their password.
-
A shared household phone number shows the wrong person, or too many
people — this reflects whichever
Patientrecords in the system have that phone number on file; it's not a bug in the picker itself. Data cleanup (correcting phone numbers on stale/duplicate patient records) is the usual fix. - "Incorrect phone/email or password" at login — deliberately generic (doesn't reveal whether the phone/email matched an account at all, to avoid account enumeration). If the patient is sure of their phone/email, direct them to password reset.
- "Email Reset Not Yet Available" at password reset — expected until the email-OTP registration channel (#22368) ships and accounts can have a verified email on file. Direct the patient to reset via phone number instead, or contact support if they only have an email on file.
-
"No Matching Account" at password reset — no active
ClientAccountmatches that phone/email at all (different from "Account Already Exists" during registration, which means an account does exist). Direct them to register instead.
This feature is being built in phases, each with its own GitHub issue and pull request under master issue #22359:
| Phase | Issue | Status |
|---|---|---|
| Foundation (entity, facade, enums, pure utilities) | #398 | Merged — PR #22358 |
| Self-service phone-OTP registration | #22367 | Merged — PR #22372 |
| Staff-assisted registration | #22363 | Built, verified — PR #22392 open |
| Login and password reset | #22370 | Merged — PR #22400 |
| Self-service email-OTP registration | #22368 | Merged — PR #22406 |
| Kiosk registration | #22369 | Merged — PR #22413 |
| Landing page, default institution, deferred DDL regen | #22371 | Not started — last remaining piece |
Key implementation pieces from the phone-OTP channel, reusable by the remaining channels:
-
ClientAccountentity (com.divudi.core.entity.ClientAccount) —@ManyToOnetoPerson, intentionally not@OneToOne(a retired account and a new active one can coexist). -
ClientAccountFacade.createIfNoActiveAccount(personId, newAccount)— locks thePersonrow (PESSIMISTIC_WRITE) for the duration of the check-then-create, preventing two concurrent registrations for the same person from both succeeding. First implementation of this locking pattern; later channels should reuse it rather than re-implementing. -
ClientPortalMatcher.classify(List<Patient>)— pure 0/1/many match classifier (com.divudi.core.util). -
ClientPortalOtpGenerator.generate(int length)— pure OTP code generator. -
ClientPortalPhoneRegistrationController(com.divudi.bean.clientportal,@ViewScoped) — the OTP send/verify/match/register controller for this channel; a template for the email-OTP and kiosk controllers. -
client_portal/register_phone.xhtml— standalone public page (no internal template), matching the pre-existingpatient_portal/portal_login.xhtmlstructural/CSS pattern. -
MessageType.ClientPortalRegistrationOTP— distinct from the pre-existingMessageType.PatientPortalOTPused by channel-booking payments.
Staff-assisted channel (#22363) additions, reusable by remaining channels:
-
ClientPortalStaffRegistrationController(com.divudi.bean.clientportal,@ViewScoped) — no OTP step; multi-criteriaPatientsearch (phone/PHN/name) built as a small dedicated JPQL query rather than reusing the large pre-existingPatientController(OPD-billing-flow-specific, not a good fit here). -
admin/client_portal/staff_assisted_registration.xhtml— template for any internal, staff-facing Client Portal admin screen: uses the normal app template (ui:composition template="/resources/template/template.xhtml"), unlike the standalone public pages (client_portal/register_phone.xhtml,patient_portal/portal_login.xhtml). - New privilege
Privileges.ClientPortalCreateAccount, registered as a sibling under the existingadminNodeinUserPrivilageController.createPrivilegeHolderTreeNodes()— the pattern to follow for any further admin-only Client Portal screens.
Login and password reset (#22370) additions, reusable by remaining channels:
-
ClientPortalSessionController(com.divudi.bean.clientportal,@SessionScoped) — holds the logged-inClientAccount, entirely separate from the staffSessionController. First session-scoped bean in this package; any future client-facing page that needs to know "who's logged in" should inject this rather than creating a parallel mechanism. -
ClientAccountFacade.findByVerifiedPhoneOrEmail(String)— returns all active accounts matching a phone or email exactly (can be more than one for a shared household phone). Login disambiguates by checkingSecurityController.matchPassword(...)against each candidate rather than showing a picker, since only the real account holder knows their own password; password reset shows a picker (same pattern as registration) since there's no password yet to disambiguate by at that point. -
MessageType.ClientPortalPasswordResetOTP— distinct fromClientPortalRegistrationOTPso a phone's registration-OTP and password-reset-OTPSmsrows never cross-match. -
ClientPortalLoginController/ClientPortalPasswordResetController(com.divudi.bean.clientportal,@ViewScoped) — templates for any future auth-adjacent Client Portal controller. -
client_portal/login.xhtml/client_portal/reset_password.xhtml— standalone public pages, same pattern asregister_phone.xhtml. - Known gap, by design, not a bug: password reset only supports phone/SMS OTP for now. Accounts can now have a verified email (email-OTP registration, #22368, has shipped), but wiring that into the reset flow itself is a separate follow-up, not built as part of #22368 — building it would have expanded that issue's scope beyond "add the email registration channel." If an account is matched only by email, the reset flow shows an explicit "not available yet" message rather than silently failing.
Self-service email-OTP registration (#22368) additions, reusable by the remaining (kiosk) channel:
-
AppEmail.otp(com.divudi.core.entity.AppEmail) — new nullableStringfield, added to mirrorSms.otp.AppEmailalready existed for general email logging (seeEmailController,InwardDocumentUploadController) but had no field for storing a one-time code before this. -
MessageType.ClientPortalEmailRegistrationOTP— distinct fromClientPortalRegistrationOTP(phone) so a person's phone-OTP and email-OTP traffic never cross-match, same reasoning as why registration and password-reset already have separate values. -
ClientPortalEmailRegistrationController(com.divudi.bean.clientportal,@ViewScoped) — a structural mirror ofClientPortalPhoneRegistrationController, injectingEmailFacade(pre-existing, previously used only via ad hoc inline JPQL from other controllers — no custom finder methods added, following the same "thin facade, JPQL in the controller" pattern asSmsFacade) andEmailManagerEjb.sendEmail(List<String>, String, String, boolean)(the non-deprecated overload) instead ofSmsFacade/SmsManagerEjb. Matches byPerson.emailinstead ofPatient.patientPhoneNumber. -
client_portal/register_email.xhtml— structural/CSS copy ofregister_phone.xhtml, same standalone-page pattern, same OTP-digit-box JS widget reused unmodified. -
Known, deliberate gap: the
AppEmail.otpcolumn follows the same deferred-DDL convention as theCLIENTACCOUNTtable (see below) — not yet in a formal migration, folded into #22371's eventual one-time regen.
Kiosk registration (#22369) additions:
-
ClientPortalKioskRegistrationController(com.divudi.bean.clientportal,@ViewScoped) — merges the phone/email OTP template pattern with aContactType(PHONE/EMAIL) toggle and a new-patient-creation branch. ReusesClientPortalIpAllowlist,ClientAccountCreationChannel.KIOSK,ClientPortalMatcher,ClientPortalOtpGenerator, andClientAccountFacade.createIfNoActiveAccountfrom the foundation PR — no new foundation pieces needed, everything the design spec called for (ClientPortalIpAllowlist,ClientAccountCreationChannel.KIOSK) was already added ahead of time in #22358. -
New-patient creation is the one genuinely new code path in this channel: mirrors
PatientController.saveSelected()'s save sequence (Personcreate→ PatientcreateAndFlush→ PHN generation viaapplicationController.createNewPersonalHealthNumber(...)) but skips the staff-oriented mandatory-field checks (title/gender/dob/area gates behindconfigOptionApplicationController) since the kiosk is a public, unauthenticated terminal — not a staff data-entry screen. SetsPatientRegistrationSource.KIOSKexplicitly (a field that already existed for this exact purpose, from issue #21181, otherwise defaults toWALK_INon persist). Nocreater/createdInstitutionset, matching the phone/email self-service channels' anonymity. -
ClientPortalIpAllowlist.isAllowed(requestIp, allowedIpsCsv)(from #22358) is called with the request IP resolved the same waySessionController.populateRequestInfo()resolves it for audit logging (X-Forwarded-Forheader, falling back toHttpServletRequest.getRemoteAddr()) — there was no existing shared public helper for this outsideSessionController, so the kiosk controller resolves it independently rather than depending on a session-scoped bean's private method. - Single-use OTP consumption (
sms.setOtp(null)/email.setOtp(null)after successful verify) applied to both phone and email from the start in this controller — the email self-service channel (#22406) got this via a CodeRabbit review finding after the fact, but phone self-service (#22367) and password reset (#22370) still don't have it (tracked in the still-open follow-up #22403). Doing it here from the start avoids repeating that same review cycle for a third controller. -
client_portal/register_kiosk.xhtml— structural/CSS copy ofregister_email.xhtml(same OTP digit-box JS widget, countdown timer, step indicators, forest-green theme), plus: a hard IP-gate panel that renders before anything else and blocks the entire rest of the flow whenipAllowedis false; a phone/email contact-type toggle (twop:commandButtons withactionListener="#{...switchContactType('PHONE'/'EMAIL')}"— no existingp:selectOneButton-bound-to-enum precedent was found elsewhere in the codebase, so this simpler pattern was used instead); and a new-patient form (title/name/gender/dob) whose title→sex auto-derivation AJAX wiring (p:ajax event="change" listener="#{...updateSexByTitle}") was copied from the pre-existing, unrelatedkiosk/kiosk_registration.xhtml(issue #21198 — a different front-desk self-registration feature with no OTP/IP-restriction/ClientAccountinvolvement; do not confuse the two "kiosk" features). -
Important distinction from a pre-existing, unrelated feature:
com.divudi.bean.kiosk.KioskController/kiosk/kiosk_registration.xhtml(issue #21198) is a different front-desk patient self-registration kiosk with no OTP, no IP restriction, and noClientAccountinvolvement — it only createsPatient/Personrecords tagged withPatientRegistrationSource.KIOSK(a different enum fromClientAccountCreationChannel.KIOSKused here). The two features share the word "kiosk" and thePatientRegistrationSource.KIOSKenum value, but are otherwise unrelated; #22369's kiosk lives entirely underclient_portal/andcom.divudi.bean.clientportal.
Design spec and plans:
- Design spec:
docs/superpowers/specs/2026-07-23-client-portal-registration-design.md - Foundation implementation plan:
docs/superpowers/plans/2026-07-23-client-portal-registration-foundation.md - Handover notes for the next phase:
docs/superpowers/handover/2026-07-24-client-portal-registration-handover.md
Known, deliberate gaps (not bugs): the CLIENTACCOUNT table and the
APPEMAIL.OTP column's DDL regeneration are deferred until all six child
issues land (owned by #22371, so the full schema diff is captured in one
pass — #22369 didn't add any new columns/tables, so it doesn't change
this); OTP
attempt-limiting/cooldown/atomic-consumption hardening was flagged in
code review and deliberately deferred rather than scope-creeping the
phone-OTP PR — see the discussion on PR #22372 for the reasoning, and
#22403 for the tracking issue (kiosk got single-use OTP consumption from
the start, but not the throttling/session-fixation pieces #22403 covers).
A new patient created via the kiosk gets a NULL PHN (personal health
number) if the deployment has no institution resolvable at the point of
registration (createNewPersonalHealthNumber returns null for a null
institution) — this is expected to be resolved once #22371 adds
Institution.defaultInstitution, giving the client-portal beans an
institution to generate a PHN against.













