Skip to main content

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 inPro only (a One user is blocked with a 314)One and Pro
Who may registerPro onlyPro only
Post-auth route/workouts/today/dashboard
Legacy migrationenode_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), plus preloadAfterAuth (shared reference-data warm-up) and logout.
  • @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-configAppFlavor, AuthFlavorConfig, and the DEFAULT_PRO_LICENSE_BASE_ID fallback.
  • @enode/ui/auth — the screens: AuthSteps (the composite both apps render), WelcomeContent, OrgNameStep, EmailStep, OtpStep, the segmented OtpInput, FlavorMismatchDialog, and the redirect surfaces (ActionDialogHost, DeviceLimitDialog, InvitationsDialog, OnboardingScreen, ActionResolverInit, action-dialog-store).
  • each appsrc/app/page.tsx builds an AuthFlavorConfig and renders <AuthSteps controller={…} flavor={…} /> inside its chrome; src/app/ApiConfigInit.tsx injects the api-key + device-type; src/app/layout.tsx mounts 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 DeviceLimitDeviceLimitDialog (remove a session) → resume.
  • 312 OpenInvitationsInvitationsDialog (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 a blocking disposition in errors/status-table.ts — deliberately not an action-table.ts redirect, 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_app key, device-type from the Capacitor platform (phone/web).
  • Portal → portal key, device-type: "web".

Provide the real keys via NEXT_PUBLIC_API_KEY. Until the pro_app key 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 exported requestOtpStep / verifyOtpStep: OTP request (reason capture + wrong-flow 400 → switch hint), verify → authenticate, legacy-migration routing (loginPro/loginOne/registerOne), the register body (per-flavor licenseBaseID + organisation name), 314 → blocked, and 401 → inline error.
  • packages/core/src/api/auth.test.ts — the wire shape of every auth call, including deleteAccount'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 segmented OtpInput.

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: submitOnboarding calls PUT /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: OnboardingScreen branches 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/preferences is a later hardening (isolated to api/storage.ts).