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 — for every account, including ones that originally registered via Apple/Google: social sign-in is retired, so those accounts also authenticate with the email OTP (product decision 2026-07-28; live-verified in docs/testing/auth-test-plan.md, AUTH-LOG-07). 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, missing canAccessPro)Pro only (a One user is blocked with a 314, missing canAccessPortal)
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)

Both apps are Pro-only, for login AND registration (product decision 2026-07-28, live-verified — AUTH-LOG-10/11 in docs/testing/auth-test-plan.md): One users stay in the One app. The earlier "One may log in to the Portal to migrate" story is retired; the client's allowOneLogin flag and the One welcome copy are pending removal.

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-config — AppFlavor, 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 app — src/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. Pro accounts only — a One user is blocked with a 314 on both deployments.

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. The sheet is inescapable: backdrop, Escape and browser Back are all refused (onDismissAttempt={() => false}), so the only exits are its two buttons. That is not decoration — login() fires its preloads concurrently and each will 312 in turn, so a dismissable sheet simply re-summons itself on the next response.

Android destroys 3xx unless the transport is bypassed​

These three redirects are the reason @enode/core/api/capacitor-http exists.

With CapacitorHttp: { enabled: true }, Capacitor patches window.fetch and splits by method. Writes go to the CapacitorHttp plugin, which hands JS a Response carrying whatever status came back. Reads (GET / HEAD / OPTIONS / TRACE) are instead rewritten to https://localhost/_capacitor_http_interceptor_/?u=<url> and served by the WebView, which must return an android.webkit.WebResourceResponse — and that class throws on any status in [300, 399]. Capacitor swallows the exception and returns null, so JS receives anything except the real status.

The effect: 310/311/312 landing on a GET were silently destroyed on Android. apiRequest never saw the status, actionForError never fired, and the dialog that should have interrupted the user never opened. The symptom that found this was a first-login subuser never being shown InvitationsDialog on Android while the same build in a browser worked.

installNativeHttpTransport() (called from the tracking app's ApiConfigInit) routes Android GETs through the plugin instead, rebuilding an equivalent Response so nothing downstream can tell the difference. It is a no-op on iOS — whose interceptor re-emits an HTTPURLResponse, which carries any status — and on the web.

The deeper fix is for these interruptions to stop using 3xx at all. They carry a JSON body, no Location, and expect the client to act and retry, which is not what a redirect is. A 4xx would make the platform restriction irrelevant and remove the asymmetry between Android and iOS reads.

The admission gate​

A 312 does not arrive on the login call; it rides on the preloads login() fires without awaiting, i.e. after the login-time 314 gate has already passed. Resolving it can also change the account's tier — declining drops the user out of their parent org and can leave them without canAccessPortal / canAccessPro.

So submitOtp (auth/use-auth-flow.ts) awaits verifyAppAccess(config.flavor) (auth/app-access.ts) before calling onAuthenticated. Awaiting it is what keeps the login page mounted: any redirect dialog raised by a preload opens over the login screen and is answered there, and the app only navigates once the account is admitted. verifyAppAccess force-refreshes the profile, treats a 314 on that refresh as the server's verdict, and otherwise checks the flavor's privilege. A denial calls logout() — the token must be gone before the login page re-renders, or its if (getToken()) redirect re-admits the user — and dispatches the blocked step, landing on the same FlavorMismatchDialog a login-time 314 shows.

It fails open on anything else (network failure, an unresolved redirect), so a blip can't eject a legitimate user; the server-side 314 remains the real gate. The check runs only at login, so an invitation arriving mid-session is not re-verified — part of the wider post-login 314 gap.

The check must never be run from inside ActionDialogHost: the profile read would 310/311/312 itself, coalesce onto the queue entry the host has not settled yet, and deadlock against the resolveAction that is waiting on it.

  • 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. The gate fires on the very first call: live (2026-09-08) the OTP request GET /users/login/otp/:email already answers 314 for a One account on the Pro app, so requestOtpStep maps a 314 to the blocked step exactly like verifyOtpStep does (before, it fell through to the inline error as the raw reason "App flavor mismatch"). The Pro-app copy names the backend's currentTier ("Your current license is Enode One, …"), asks the user to reach out to enode for Pro, and shows the App Store / Google Play badges for the orange Enode One app (AppStoreBadges); a backend userMessage still wins over the client fallback.

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.

Every request header is ASCII-only​

standardApiHeaders (packages/core/src/api/client.ts) passes the whole header record through toHeaderSafeAscii (packages/core/src/api/header-value.ts) before it can reach fetch.

This is a correctness requirement, not hardening. Header values are ByteStrings: fetch throws TypeError: … Value is not a valid ByteString for any code point above U+00FF, which fails the request before it leaves the device. The device-name header carries the user's own device name, and that routinely contains such characters:

  • iOS spells its default as <Name>’s iPhone with U+2019, not an ASCII '
  • Android lets the user type anything into the device name, emoji included
  • a non-Latin name (김철수의 iPhone) is entirely above U+00FF

Unsanitized, any of those is a total outage for that one user — every request fails, login included, with an error that looks nothing like its cause. Values in U+0080–U+00FF (Müller) don't throw but are silently mis-decoded server-side.

The sanitizer transliterates accented Latin (Müller → Muller), folds typographic punctuation, and drops everything else outside printable ASCII. Removing CR/LF also makes header injection impossible. resolveDeviceName (api/device-identity.ts) sanitizes at the source as well, so the value stored in the API config — which also surfaces in diagnostics and the device-session list — is already clean; the choke point in standardApiHeaders is the hard guarantee that no future header can reintroduce the problem.

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).