Authentication (register & login)
Both apps share one email + OTP register/login flow, parameterized per
deployment. The flow logic is framework-light and lives in @enode/core; the
screens live in @enode/ui; each app supplies a small config and its own page
chrome. There are no app-specific copies of the state machine.
The flow is email + one-time code. Social sign-in is planned but not yet present. The OTP auto-submits when the last digit is entered (no verify button).
The two deployments
Pro app (@enode/tracking, Capacitor) | Portal (@enode/portal, web) | |
|---|---|---|
flavor | "pro_app" | "portal" |
| Who may log in | Pro only (a One user is blocked with a 314) | One and Pro |
| Who may register | Pro only | Pro only |
| Post-auth route | /workouts/today | /dashboard |
| Legacy migration | enode_pro / enode_one (from OTP reason) | enode_one / enode_pro (from OTP reason) |
Registration is Pro-only in both apps: One users sign up in the iOS app. The Portal exists mainly so One users can migrate their legacy data into the new system, so One users may log in there but cannot register.
Where the code lives
@enode/core/api/auth— the network calls:requestLoginOtp,requestRegisterOtp,login,register,migrateLegacy,submitOnboarding,requestDeleteOtp+deleteAccount(OTP-protected account deletion), pluspreloadAfterAuth(shared reference-data warm-up) andlogout.@enode/core/auth/use-auth-flow— the headless state machine (useAuthFlow) and its pure helpers (authReducer,migrationOriginForReason,flavorMismatchFromError,authErrorInfoFor). Renders nothing.@enode/core/auth/flavor-config—AppFlavor,AuthFlavorConfig, and theDEFAULT_PRO_LICENSE_BASE_IDfallback.@enode/ui/auth— the screens:AuthSteps(the composite both apps render),WelcomeContent,OrgNameStep,EmailStep,OtpStep, the segmentedOtpInput,FlavorMismatchDialog, and the redirect surfaces (ActionDialogHost,DeviceLimitDialog,InvitationsDialog,OnboardingScreen,ActionResolverInit,action-dialog-store).- each app —
src/app/page.tsxbuilds anAuthFlavorConfigand renders<AuthSteps controller={…} flavor={…} />inside its chrome;src/app/ApiConfigInit.tsxinjects theapi-key+device-type;src/app/layout.tsxmounts the error engine,ActionResolverInit,ActionDialogHost,AuthGuard, and the toaster.
The flows
Sign-in and sign-up are separate (the welcome screen picks one) so a typo can't silently create a duplicate account.
Login: welcome → email → requestLoginOtp → enter OTP (auto-submits on the
last digit) → login → home. Open to everyone (One and Pro) on the Portal.
Register: welcome → organisation name + email (one step) →
requestRegisterOtp → enter OTP (auto-submits) → register (name +
licenseBaseID) → home. Pro orgs only. The welcome screen presents "Create
organisation account" and "Sign in" as equal choices so One users aren't pushed
away. (UI copy uses British "organisation".)
The OTP request returns a reason (OTPReason). After auth, a loginPro /
loginOne / registerOne reason dispatches migrateLegacy (fire-and-forget; a
migration failure never blocks the now-valid session). The migration origin is
derived from the reason inside the hook, never from the flavor.
A single OTP is valid for 15 minutes and across any number of devices
(the emailed code, not the requesting device, is what's checked). It is consumed
after 3 failed attempts or when the 15 minutes elapse — whichever comes
first. "Resend code" on the OTP step hits a separate endpoint
(resendLoginOtp / resendRegisterOtp → /users/{login,register}/otp/resend/:email,
i.e. /resend/ inserted before the base64 email), NOT the initial request
route. A resend re-mails the code for the same OTP session and keeps the captured
reason — it never re-classifies the account, so its errors carry no wrong-flow
mode-switch hint. The controller exposes it as resendOtp (distinct from
submitEmail, which the email step uses for the first request).
Errors & redirects
The flow uses the one error engine — it reacts to a disposition, never a raw status. Auth-relevant cases:
- 401 (bad/expired OTP) → inline error on the OTP step.
- 400 on an OTP request (wrong flow, e.g. "already registered") → inline error with a one-tap switch to the other mode.
- 310 DeviceLimit →
DeviceLimitDialog(remove a session) → resume. - 312 OpenInvitations →
InvitationsDialog(accept/decline) → resume. - 311 UserNotEnabled → the full-screen
OnboardingScreen. A One user is sent to finish in the Enode One app; a not-yet-enabled Pro user sets their organisation name (submitOnboarding), which enables the account so the original request resumes. This is the same name surface the register flow shows up front. A cancel sticks for the current session token: on a not-enabled account the backend 311s every request — including background ones (SSE reconnects, preloads) — so without the stickiness the screen would re-summon itself seconds after being dismissed. Repeat 311s auto-cancel until a fresh login (new token) or a completed onboarding re-arms the screen (action-dialog-store.ts). - 314 AppFlavorMismatch → terminal
FlavorMismatchDialog. The account's license can't access this app (e.g. a One user on the Pro app). It is ablockingdisposition inerrors/status-table.ts— deliberately not anaction-table.tsredirect, so it never enters the resume/retry loop.
Account deletion
A destructive Delete account control lives at the bottom of the tracking
app's Settings (@enode/ui/auth/delete-account-section). It is OTP-protected and
irreversible: tap → an "are you sure, this can't be undone" confirm sheet →
requestDeleteOtp(email) mails a code → the segmented OtpInput (auto-submit) →
deleteAccount(email, otp) (DELETE /users/delete, Basic email:otp). On
success deleteAccount also wipes the offline database (wipeOfflineData) —
logout deliberately no longer does (ADR 0002 amendment 2: the DB survives
logout, owner-scoped to the account; a deleted account's local data must not
outlive it or be adopted by the next login) — then the app clears the session
(logout) and returns to the welcome screen.
A successful deletion answers with 401 — once the account is gone, the
credentials that authorized the call are invalid. So deleteAccount treats a 401
as success (it resolves), and the app then logs out and lands on the
welcome/login screen. The call sets suppressUnauthorizedEvent so that 401 does
not also fire the global enode:unauthorized logout — the component owns the
single teardown + redirect. Genuine failures (network / 5xx) are shown inline.
The DeleteAccountSection component is reusable; the Portal can mount it once it
has a settings surface.
The Pro-app gate (api-key + device-type)
The "Pro app blocks One users" and device-limit behaviors are enforced
server-side from the api-key JWT subject (pro_app / portal) and the
device-type header. The client just renders the right message. Each app sets
both in src/app/ApiConfigInit.tsx:
- Pro app →
pro_appkey,device-typefrom the Capacitor platform (phone/web). - Portal →
portalkey,device-type: "web".
Provide the real keys via
NEXT_PUBLIC_API_KEY. Until thepro_appkey is set, the 314 / device-limit gate cannot fire (the UI is built but inert).
The Pro LicenseBase for registration comes from NEXT_PUBLIC_PRO_LICENSE_BASE_ID
(defaults to DEFAULT_PRO_LICENSE_BASE_ID). All licenses grant canAccessPortal,
so one base works for both apps.
Testing
Automated (Vitest, node env via stubbed fetch — no DOM):
packages/core/src/auth/use-auth-flow.test.ts— the reducer + decision helpers and the full login + registration flows for both deployments, via the exportedrequestOtpStep/verifyOtpStep: OTP request (reason capture + wrong-flow 400 → switch hint), verify → authenticate, legacy-migration routing (loginPro/loginOne/registerOne), the register body (per-flavorlicenseBaseID+ organisation name), 314 → blocked, and 401 → inline error.packages/core/src/api/auth.test.ts— the wire shape of every auth call, includingdeleteAccount's 401-as-success.packages/core/src/email.test.ts— the email validator.- Storybook: the presentational screens and their states —
WelcomeContent(Pro app vs Portal),EmailStep(login / register / submitting),OtpStep(idle / verifying), and the segmentedOtpInput.
Manual: use the backend GET /api/debug/errors/* triggers (device-limit,
open-invitations, user-not-enabled, abort/314) to exercise each surface.
Open items
- 311 onboarding endpoint:
submitOnboardingcallsPUT /users/onboarding(iOS shape). Confirm the exact endpoint/DTO and whether a Bearer token exists at the 311 point to authenticate it, or whether the OTP Basic credentials must be reused. - 311 tier signal:
OnboardingScreenbranches One vs Pro from the 311 body (currentTier/canAccessOne); confirm the field is present, otherwise it defaults to the Pro name-entry path. - Capacitor token storage: tokens live in
localStorage;@capacitor/preferencesis a later hardening (isolated toapi/storage.ts).