12 KiB
Onboarding wizard with deferred DB commit
Status: approved, not yet implemented.
Problem
Feedback on the just-shipped mandatory Garmin-connect onboarding
(docs/superpowers/specs/2026-07-26-improve-first-connection-design.md):
- UX: a wizard-style Previous/Next pair reads better than the current
Continue/Log out pairing across
CreateProfile→ConnectGarmin. - If the user quits mid-flow without logging out, the account is left in a provisioned-but-not-Garmin-connected state -- confirmed this should instead mean nothing is written to the database at all until Garmin authentication actually succeeds, at which point display name + Garmin credentials + the connected flag are all committed together, atomically.
(A third piece of feedback, structured JSON logging, is unrelated and gets its own design afterward.)
Design
Consequence: the three-way LoginGate fork collapses
Since account provisioning and Garmin-connection now happen together in one
atomic step, has_profile=true && garmin_connected=false becomes
structurally unreachable -- confirmed to collapse LoginGate back to a
two-way fork (has_profile ? App : OnboardingWizard). profile.garmin_connected_at
stays as an informational timestamp (still written by the new atomic
commit); it just no longer gates anything on its own.
The core problem: Garmin auth needs an account today
garminFor builds a per-user garmin.Client keyed by a DB user id and
reads Garmin credentials from that user's profile row. To authenticate
with Garmin before an account exists, onboarding needs a temporary,
not-yet-persisted Garmin session -- keyed by the OIDC subject (already
known from the session cookie, before any users row exists) instead of a
user id. A long-held-open SQL transaction across the wizard's multiple
HTTP round trips is not an option: SQLite's single connection
(SetMaxOpenConns(1), see db.go) means one held-open transaction would
block every other request in the app for as long as the user takes to
finish onboarding.
Backend: ephemeral pre-account Garmin sessions
New type and Server field (server.go, alongside the existing
userGarmin/userSync/etc. per-user maps):
// setupSession is a temporary, not-yet-persisted Garmin authentication
// attempt made during onboarding, before any users/profile row exists --
// keyed by OIDC subject (the only stable identifier available pre-account)
// rather than a user id. Promoted into Server.userGarmin once
// /api/setup/complete actually creates the account; evicted lazily (the
// next setup-endpoint touch for that subject checks staleness first) once
// idle past setupSessionIdleTimeout.
type setupSession struct {
Client garmin.Client
Email, Password string
Status garmin.AuthStatus
Message string
LastUsed time.Time
}
const setupSessionIdleTimeout = 15 * time.Minute
Server gains setupGarmin map[string]*setupSession (keyed by OIDC
subject string), initialized alongside the other maps in NewServer.
Helper methods on Server (server.go):
setupSessionFor(sub string) (*setupSession, bool)-- returns the session if present and not stale; a stale one is closed and evicted first (lazy cleanup, no background sweep goroutine -- an abandoned session otherwise leaks one idle subprocess until the backend restarts, an accepted trade-off for a single-operator app).replaceSetupSession(sub, email, password string) *setupSession-- closes and replaces any existing session forsub(same "close old, spawn new" semantics asgarmin.Client.UpdateCredentials), builds a fresh ephemeral client whoseTokenStorePathis namespaced under{root}/setup/{sub}(distinct from the permanent{root}/{userID}namespacinggarminForuses, so two people onboarding concurrently never collide, and so the ephemeral session's persisted Garmin tokens have some home even though no user id exists yet).recordSetupAuthResult(sub string, res garmin.AuthResult)-- updates a session'sStatus/Message/LastUsedafter a login or MFA attempt.removeSetupSession(sub string)-- closes and dropssub's session (best-effort removes its{root}/setup/{sub}token-store directory too). Called both when/api/setup/completepromotes a session into the permanent cache, and fromhandleSessionLogoutso abandoning mid-wizard via logout doesn't linger until the idle timeout.
Backend: three new endpoints replace /api/setup
Registered where r.Post("/setup", s.handleSetup) used to be (same
middleware group: RequireSession + resolveUser, but not
requireProvisionedUser -- these must work before a profile exists):
r.Route("/setup", func(r chi.Router) {
r.Post("/complete", s.handleSetupComplete)
r.Route("/garmin", func(r chi.Router) {
r.Post("/login", s.handleSetupGarminLogin)
r.Post("/mfa", s.handleSetupGarminMFA)
})
})
POST /api/setup/garmin/login{garmin_email, garmin_password}: 401 if unauthenticated; 409 if a profile already exists for this subject (mirrors oldhandleSetup's existing check); 400 on missing fields. OtherwisereplaceSetupSession+Authenticate()+recordSetupAuthResult, responding with the same{status, message}shape/api/auth/loginalready uses.POST /api/setup/garmin/mfa{code}: 409 if no session exists for this subject (never started, or expired) -- frontend resets to the Garmin step and asks the user to reconnect. OtherwiseCompleteMFA()+recordSetupAuthResulton the same session (never replaces it -- MFA continues an in-progress attempt on the same subprocess).POST /api/setup/complete{display_name}: 409 if already provisioned; 409 if no session exists for this subject or itsStatus != garmin.AuthSuccess("Garmin isn't connected yet"). Otherwise, in order:ProvisionUser(sub, displayName)(unchanged).GetProfile+ setGarminEmail/GarminPasswordfrom the session +UpdateProfile(unchanged method, just called here instead of from the Profile page).MarkGarminConnected(userID).- Promote: move the session's already-authenticated
garmin.Clientintos.userGarmin[userID]and its recorded status intos.userAuthStatus[userID]/userAuthMessage[userID](avoids a redundant re-authentication -- and possibly a repeat MFA prompt -- immediately after signup), thendelete(s.setupGarmin, sub)without closing the client (ownership transferred, not discarded). - Best-effort
os.Renamethe ephemeral token-store directory ({root}/setup/{sub}) to the permanent one ({root}/{userID}), so a later respawn of this user's subprocess (e.g. after a server restart) resumes the already-established Garmin session instead of requiring a fresh login. Logged, not fatal, on error.
internal/api/auth.go's existing handleAuthLogin/handleAuthMFA/handleAuthStatus
(used by the Profile page's GarminConnection.tsx for reconnecting an
already-provisioned user) are untouched.
Frontend: one wizard component instead of two screens
CreateProfile.tsx and ConnectGarmin.tsx are deleted, replaced by
frontend/src/OnboardingWizard.tsx (and OnboardingWizard.css, replacing
CreateProfile.css). Two top-level steps ("name" | "garmin"); the
in-progress-MFA and already-authenticated states are just conditions on
auth?.status within the "garmin" step, not a third step value --
simpler than threading a separate "mfa" step through, and matches how
GarminConnection.tsx already branches on status today:
namestep: display name field, held in local state only -- no API call. "Next" just validates non-empty and advancesstepto"garmin".garminstep, branching onauth?.status:undefined/"failed": email/password fields. "Previous" returns to thenamestep (pure local state, nothing to undo server-side). "Next" callsapi.setupGarminLogin(email, password)."mfa_required": MFA code field. "Previous" clearsauthback tonull, which re-shows the credentials form (submitting it again just replaces the ephemeral session, same as the backend's "close old, spawn new" semantics). "Submit code" callsapi.setupGarminMFA(code)."authenticated": a confirmation message and "Continue to geniusrun", which callsapi.setupComplete(displayName)and thenonCreated(...).
- "Log out" (
<form method="post" action=".../session/logout">) stays visible throughout, the same escape hatch as before.
frontend/src/api/client.ts: remove setup; add setupGarminLogin,
setupGarminMFA, setupComplete (bodies/shapes as described above).
frontend/src/LoginGate.tsx: collapse back to
!session.has_profile ? <OnboardingWizard onCreated={...}> : <App> --
garmin_connected is no longer read here at all (still present on
SessionInfo/session/me, just unused by the gate).
Error handling
- Garmin login/MFA failure: same as today -- message shown, fields stay editable, retry always available, nothing persisted regardless of outcome.
/api/setup/completewith no session, or session not yetauthenticated:409, frontend falls back to thegarminstep (credentials form) so the user can (re)connect./api/setup/complete//api/setup/garmin/loginwhen already provisioned (double-submit, duplicate tab):409"profile already exists"; frontend treats this as success and refreshes session info rather than showing an error.- Idle sessions (15 minutes unused) are evicted lazily on next touch --
/api/setup/garmin/mfaor/api/setup/completeagainst an expired session behaves identically to "never connected" (409), prompting a fresh/api/setup/garmin/login. - Logging out mid-wizard closes and drops that subject's ephemeral session and its token-store directory.
Testing
internal/api:POST /api/setup/garmin/login(mockAuthSuccess) creates no DB user yet (GetUserBySubstill not found); themfa_requiredpath via/api/setup/garmin/mfaon the same session;/api/setup/completeafter a successful session creates the user withGarminEmail/GarminPassword/GarminConnectedAtall set, and promotes the same*mock.Clientinstance intos.userGarmin[userID](asserted viagarminForreturning that exact pointer, andClosedCalledstill false -- proving no redundant re-authentication happened);/api/setup/completewith no prior login attempt → 409; already-provisioned subject hitting either endpoint → 409; two different OIDC subjects' ephemeral sessions never interfere (isolation test, matching this repo's adversarial-isolation convention). A token-store rename test mirroring the existingTestDeleteProfile_RemovesTokenStoreDirectorypattern (setGarminBase.TokenStorePath, confirm the directory ends up under the new user id's path after/api/setup/complete, not undersetup/{sub}).- No frontend test suite -- manual smoke test: fresh account → name step →
Previous/Next between name and Garmin steps preserves typed input →
wrong password shows an error and stays editable → MFA prompt → Previous
from MFA returns to credentials → correct credentials (or MFA resolved)
→ "Continue to geniusrun" → lands in the app → confirm
GetUserBySubnow exists with everything set. Separately: start the wizard, enter Garmin credentials, then just close the tab without finishing -- confirm nousers/profilerow was created at all.
Out of scope
- A background sweep goroutine for abandoned ephemeral sessions -- accepted as a known, low-stakes trade-off (see Error handling).
- Structured JSON logging (the third piece of feedback) -- separate design.
- Any change to the Profile page's own reconnect flow
(
GarminConnection.tsx,/api/auth/*) -- untouched.