User Onboarding — Registration and Initial Setup Flows
Audiences: developer, internal, clinical-buyer
Three parallel front-door flows — invitation-led staff onboarding, self-service patient sign-up, and partner application — funnel into one verification + approval pipeline before a user ever sees a portal shell.
Business Purpose
Onboarding is the moment a buyer decides whether the HMS feels professional or homemade — and the moment a clinic admin decides whether the platform respects their hiring workflow. Dudoxx HMS ships three distinct onboarding entry points that share one verification backbone:
- Invitation-led (the default for clinical staff) — a clinic admin invites a doctor or nurse by email; the new user lands on a tokenized form with email and role pre-filled and cannot self-elevate.
- Self-service patient sign-up — a patient picks their clinic from a list, enters identity + DOB, and waits for clinic-admin approval if the org requires it.
- Partner application — a six-step certification timeline for resellers and implementation partners, separate from clinical-user provisioning.
Each path lands in the same gated state machine: register → email-verify → phone-verify → [optional] admin-approve → first-login wizard. The structural value is that a clinic never
has to write its own user-provisioning code, and a regulator can audit a single approval
queue across both invited staff and walk-in patient registrations.
Audiences
- Investor: shows that user acquisition is not a single form — there are three segmented funnels (staff, patients, partners) that each respect a different commercial motion. The invitation path is the lock-in surface for clinic deals; the self-service path is the wedge for solo practices.
- Clinical buyer (clinic admin): explains exactly which knobs they own — registration toggle, approval requirement, password policy, email/phone verification — and where the Pending Approvals queue lives in their portal.
- Developer/partner: documents the two registration components (one bare, one
invitation-aware), the shared
onboardingApiclient surface, the Trusted Gateway pattern for public token validation, the E.164 phone-normalization hop, and the wizard-step state machine for invitation flow. - Internal (ops/support): locates the four route surfaces (
/auth/register,/register?invitation=...,/auth/verify-email,/auth/verify-phone) plus the clinic-admin approvals page, and explains the redirects between them.
Architecture
Three entry routes, one backend funnel
Self-service: /[locale]/auth/register → POST /auth/register → email-verify
Invited: /[locale]/register?invitation= → POST /onboarding/register → phone-verify
Partner: /[locale]/public/partners/apply (separate certification track)
↓
verify email → verify phone → [admin approve] → login → first-login tour
The proxy at ddx-web/src/proxy.ts:129-133 treats /auth/register (the self-service form)
as an AUTH_ROUTE — public, but authenticated users are bounced away. The invitation
route /register is not in that list; an authenticated user hitting an invitation link
is force-signed-out (see Data Flow below) so the invitee actually becomes the new account
owner, not the existing session user.
Two registration components, two purposes
| File | Surface | Trigger | API endpoint |
|---|---|---|---|
ddx-web/src/app/[locale]/auth/register/page.tsx:34-148 + RegisterForm.tsx:24-557 | Self-service public sign-up | Anyone visiting /auth/register | authApi.register(...) |
ddx-web/src/app/[locale]/register/page.tsx:95-183 + InvitationRegisterForm.tsx:91-745 | Invitation-token sign-up | Clinic admin sends email invite | onboardingApi.register(...) |
The two forms look similar but enforce different invariants: the self-service form lets
the user pick their role and clinic; the invitation form locks the email and
organization to whatever the invitation token carries and forces phone verification before
submission (InvitationRegisterForm.tsx:263-268).
Per-role onboarding tours after login
Once the gated funnel completes, each role lands in a portal-specific "first-login"
experience. For clinicians, the curated 10-minute tour is seeded as help content
(ddx-seeder-ts/content/help/modules/doctor/01-getting-started/03-first-login.yaml:1-108)
covering display name + specialty, timezone, signature upload, mandatory 2FA enablement,
and notification preferences. Patient and other-role tours follow the same dashboard-tour
content pattern (ddx-seeder-ts/content/help/modules/patient/01-getting-started/01-dashboard-tour.yaml).
The tours are content, not gates — they are surfaced in the help drawer rather than
blocking access to the portal shell.
Honest disclosure: as of 2026-05-18 the per-role interactive first-login wizard is minimal in the frontend. The doctor 2FA enrolment is documented as policy in the help-content tour and is enforced server-side, but there is no full-screen wizard in
ddx-webthat walks every role through tour steps after first login — the content is available on-demand in the help drawer instead.
Tech Stack & Choices
| Concern | Choice | Rationale |
|---|---|---|
| Form validation | zod v3 via registerSchema | Same library used on backend (zod/v4) — schema parity prevents drift |
| Phone format | E.164 normalization in client (InvitationRegisterForm.tsx:32-53) | Twilio Verify rejects ambiguous formats; normalize once on submit |
| Token validation | Trusted Gateway via backendPublicGet (register/page.tsx:60-93) | Public endpoint must not require user session; gateway API key authenticates server-to-server |
| State machine | Local useState<FormStep> with `'form' | 'phone-verification' |
| Bot defense | Honeypot input + autoComplete="off" (RegisterForm.tsx:55-56,508-520) | No CAPTCHA on first-touch; relies on hidden field + rate-limit (HTTP 429 surfaced at RegisterForm.tsx:85-86) |
| Session handoff | Server-side redirect to /api/auth/logout?callbackUrl=... (register/page.tsx:113-118) | Cookies can only be mutated in Route Handlers, not in Server Components |
| Verification UX | Resend countdown (InvitationRegisterForm.tsx:131-136) + tracking input ID across the flow (requestId) | Same requestId correlates registration → email-check → phone-check on the backend |
| i18n | next-intl (getTranslations('auth.register') server-side, useTranslations('auth.invitationRegister') client-side) | en/fr/de catalogues; namespace per surface (auth.register, auth.invitationRegister, auth.verifyEmail, admin.approvals) |
| Design system | Modern split-screen with semantic Tailwind tokens (RegisterForm.tsx:128-138) | No bg-white/bg-blue-* — uses bg-primary, bg-background, border-border per project mandate |
Data Flow
Self-service patient registration
1. User → GET /[locale]/auth/register (proxy.ts:131 — AUTH_ROUTE)
2. Server Component checks session → if signed in, redirect /portal (register/page.tsx:27-30)
3. User fills form → registerSchema.safeParse (RegisterForm.tsx:67-77)
4. authApi.register({ email, password, role, organizationId, ... }) (RegisterForm.tsx:79-83)
5. Backend responds 200 → setSuccessMessage + setTimeout(3000) (RegisterForm.tsx:91-95)
6. Redirect → /[locale]/auth/verify-email (RegisterForm.tsx:94)
7. /auth/verify-email reads ?token=, POSTs to authApi.verifyEmail (verify-email/VerifyEmailForm.tsx:38-67)
8. On success → redirect to /auth/verify-phone after 3s (VerifyEmailForm.tsx:56-59)
9. (If org.registrationRequiresApproval) appears in clinic-admin queue
Invitation-led staff onboarding
1. Clinic admin → POST /onboarding/invitations (out of scope here, owned by user-management) 2. Email link → /[locale]/register?invitation=<token> 3. Server Component validates token via Trusted Gateway (register/page.tsx:60-93) 4. If user already logged in → /api/auth/logout?callbackUrl=... (register/page.tsx:113-118) 5. Invalid token → renders "invitation expired/revoked" card (register/page.tsx:124-153) 6. Valid token → renders InvitationRegisterForm with prefilled email + role badge + optional personal admin message (InvitationRegisterForm.tsx:512-535) 7. Submit form → validate → switch to step="phone-verification" (InvitationRegisterForm.tsx:254-268) 8. onboardingApi.sendPhoneVerification(E.164-phone, requestId) (InvitationRegisterForm.tsx:181-220) 9. User enters 6-digit code → onboardingApi.verifyPhoneCode (InvitationRegisterForm.tsx:222-252) 10. phoneVerified=true → onboardingApi.register(...) final POST (InvitationRegisterForm.tsx:271-310) 11. If nextSteps.awaitApproval → success card + back-to-login (InvitationRegisterForm.tsx:292-296) 12. Else → setTimeout 3s → router.push(/auth/login) (InvitationRegisterForm.tsx:300-302)
Clinic-admin approval queue
When org.registrationRequiresApproval is true, registrations land at
ddx-web/src/app/[locale]/portal/clinic-admin/approvals/page.tsx:66-84 rendered through
PendingApprovalsClient (PendingApprovalsClient.tsx:67-80). The page exposes filter
chips driven by searchParams.filter, a compact stats bar of pending/approvedToday/
rejectedToday/requiresAttention, and bulk approve/reject actions. The client calls
onboardingApi.approveRequest, rejectRequest, and bulkAction (onboarding.ts:276-301).
The relevant SSE events (verification completion, approval status changes) ride the
canonical SSE bus — see packages/ddx-sse-contract/ (@ddx/sse-contract, 4.1.0) for the
publisher/consumer rules; this surface is a consumer, not a publisher.
Implicated Code
MANDATORY: ≥3 file:line citations.
ddx-web/src/app/[locale]/auth/register/page.tsx:25-148— self-service register Server Component, split-screen layout, session-bounce ongetSession()ddx-web/src/app/[locale]/auth/register/RegisterForm.tsx:24-557— client form, zod validation, honeypot, rate-limit handling, redirect toverify-emailddx-web/src/app/[locale]/register/page.tsx:60-183— invitation Server Component, Trusted Gateway token validation, force-logout for signed-in inviteesddx-web/src/app/[locale]/register/InvitationRegisterForm.tsx:32-53— E.164 phone normalization,+49default for German local numbersddx-web/src/app/[locale]/register/InvitationRegisterForm.tsx:91-310— three-step state machine (form → phone-verification → success), password strength meter, terms/privacy checkboxesddx-web/src/lib/api/clients/onboarding.ts:144-302—OnboardingApiClient: validate invitation, register, send/verify email + phone, admin approve/reject/bulkddx-web/src/app/[locale]/auth/verify-email/page.tsx:14-22andverify-email/VerifyEmailForm.tsx:38-67— token-from-URL verification, auto-redirect to/auth/verify-phoneon successddx-web/src/app/[locale]/portal/clinic-admin/approvals/page.tsx:66-84— clinic-admin pending-approvals Server Component withSuspenseboundaries and filterddx-web/src/app/[locale]/portal/clinic-admin/approvals/PendingApprovalsClient.tsx:62-80— approvals client component, bulk-action wiringddx-web/src/proxy.ts:129-133—AUTH_ROUTESlist including/auth/register; bounces authenticated users away from sign-up pagesddx-web/src/app/[locale]/public/partners/onboarding/page.tsx:39-248— partner certification timeline (separate track from clinical-user onboarding)ddx-seeder-ts/content/help/modules/doctor/01-getting-started/03-first-login.yaml:1-108— seeded doctor first-login content (display name, timezone, signature, mandatory 2FA, notifications)ddx-seeder-ts/content/help/modules/patient/01-getting-started/01-dashboard-tour.yaml:5— patient first-login dashboard-tour content tag
Operational Notes
- Verification order: a successful self-service registration redirects to
/auth/verify-emailfirst; a successful invitation registration verifies phone before the final POST. Do not reorder without coordinating withddx-api's onboarding service. - Email + phone uniqueness: the backend (CR-029) routes both registration and patient
creation through
PatientCreationService. A registration that already has a phone match raises 409 — the client surfaces this viasetGlobalError. - Rate limiting: the self-service path explicitly handles HTTP 429
(
RegisterForm.tsx:85-86) by surfacing a localized rate-limit message. The invitation path inherits the same backend throttle but renders a generic error string. - Honeypot: the hidden
websitefield atRegisterForm.tsx:508-520is bait — do not remove or reposition. Removing it disables the cheapest bot filter and increases the approval-queue noise immediately. - Force logout on invitation: an authenticated session opening an invitation link
triggers a server redirect to
/api/auth/logout?callbackUrl=.... This is deliberate — cookies cannot be cleared in Server Components in Next.js 16; the Route Handler hop is required (register/page.tsx:111-118). - Skip / resume semantics: there is no resumable URL-driven multi-step state —
refresh during phone verification on the invitation form re-renders step
'form'and the user resubmits. This is intentional for token security; a refresh that survives the unverified state would let an attacker resume someone else's invitation. - 2FA enrolment for clinicians: enforced by clinic policy, surfaced via the help
tour, and required before signing prescriptions
(
first-login.yaml:101-108). Build alarms for "user signed prescription without 2FA" in the backend audit; this surface is not the only gate. - i18n keys:
auth.register.*(self-service),auth.invitationRegister.*(invitation),auth.verifyEmail.*,auth.verifyPhone.*,admin.approvals.*. Always wrap new strings; zero hardcoded strings in TSX per project mandate (seecontext/REMINDERS.md:31). - Demo helpers:
RegisterForm.tsx:113-126exposes a "Fill with Fake Data" button gated byshowDemoHelpers. This must be off in production builds — verify viaNODE_ENV=production pnpm buildbefore deploy.
Related Topics
- User Management — practitioner/staff account model that the onboarding flow populates
- Organization Management —
registrationRequiresApproval,registrationApprovalRoles, password policy live on the Organization record - Tenant Provisioner — how a clinic itself is bootstrapped before any user can register against it
- Multi-Portal Architecture — the role-aware shell users land in after first-login
- Patient Portal — destination for self-service patient registrations
- Doctor Portal — destination for invited clinicians; first-login help content is rendered here
- i18n Multilingual — en/fr/de catalog routing used by every onboarding surface