Garmin auth/sync routes move under /api/garmin/*; sync.Service becomes garmin.Sync with garmin.SyncConfig/ClientConfig; applog becomes internal/log; the test mock moves into the garmin package as MockClient (breaking the test-only import cycle the merge created); stale test URLs and type names updated to match. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1190 lines
41 KiB
Markdown
1190 lines
41 KiB
Markdown
# Onboarding Wizard Deferred Commit Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Replace the two-screen (`CreateProfile` → `ConnectGarmin`) onboarding flow with a single wizard that defers every database write until Garmin authentication actually succeeds, and gains real Previous/Next navigation.
|
||
|
||
**Architecture:** Add an ephemeral, OIDC-subject-keyed Garmin session (`Server.setupGarmin`) alongside the existing per-user-id ones, so Garmin login/MFA can be attempted before any `users`/`profile` row exists. Three new endpoints (`/api/setup/garmin/login`, `/api/setup/garmin/mfa`, `/api/setup/complete`) replace the old `/api/setup`. `/api/setup/complete` is the single atomic commit point: it provisions the account, persists the Garmin credentials, marks it connected, and promotes the already-authenticated ephemeral client into the permanent per-user cache. The frontend collapses `CreateProfile.tsx` + `ConnectGarmin.tsx` into one `OnboardingWizard.tsx`, and `LoginGate.tsx` goes back to a two-way fork since `has_profile=true && garmin_connected=false` is now structurally unreachable.
|
||
|
||
**Tech Stack:** Go (`net/http`, chi router, `crypto/sha256`), React + TypeScript (Vite), no frontend test framework.
|
||
|
||
## Global Constraints
|
||
|
||
- Every store/API method touching per-user data takes an explicit `userID`/subject and filters by it.
|
||
- Cross-user (here, cross-subject) isolation is tested adversarially, not just checked for non-collision.
|
||
- `gofmt -l .` must report nothing; `go vet ./...` and `go build ./...` must pass.
|
||
- No frontend test suite exists — frontend changes are verified via `npm run build`, `npm run lint`, and a manual browser check.
|
||
- No schema changes this round — `profile.garmin_connected_at` already exists from the prior task.
|
||
|
||
---
|
||
|
||
### Task 1: Ephemeral pre-account Garmin sessions and the three new endpoints
|
||
|
||
**Files:**
|
||
- Modify: `backend/internal/api/server.go` (new `setupSession` type + `Server.setupGarmin` field + helper methods + route registration)
|
||
- Modify: `backend/internal/api/setup.go` (replace `handleSetup` with three new handlers)
|
||
- Modify: `backend/internal/api/session.go` (`handleSessionLogout` cleans up the subject's ephemeral session)
|
||
- Modify: `backend/internal/garmin/mock/mock.go` (add `AuthenticateCalls` counter, needed to prove promotion doesn't re-authenticate)
|
||
- Modify: `backend/internal/api/setup_test.go` (replace all three old tests with the new endpoint tests)
|
||
- Modify: `backend/internal/api/isolation_test.go` (new adversarial test)
|
||
|
||
**Interfaces:**
|
||
- Produces: `func (s *Server) setupSessionFor(sub string) (*setupSession, bool)`, `func (s *Server) replaceSetupSession(sub, email, password string) *setupSession`, `func (s *Server) recordSetupAuthResult(sub string, res garmin.AuthResult)`, `func (s *Server) removeSetupSession(sub string)`, `func setupTokenStoreDir(root, sub string) string` — all in `server.go`, used by `setup.go`'s new handlers and `session.go`'s logout handler.
|
||
- Routes: `POST /api/setup/garmin/login` `{garmin_email, garmin_password}` → `authResponse`; `POST /api/setup/garmin/mfa` `{code}` → `authResponse`; `POST /api/setup/complete` `{display_name}` → `{user_id, display_name}`. The old `POST /api/setup` is removed.
|
||
|
||
- [ ] **Step 1: Add `AuthenticateCalls` to the mock Garmin client**
|
||
|
||
In `backend/internal/garmin/mock/mock.go`, change the struct:
|
||
|
||
```go
|
||
// Client is a fake garmin.Client returning data supplied by the test/caller.
|
||
type Client struct {
|
||
AuthResults []garmin.AuthResult // consumed in order by Authenticate/CompleteMFA calls
|
||
Activities []garmin.Activity
|
||
Splits map[int64]garmin.ActivitySplits
|
||
Details map[int64]garmin.ActivityDetails
|
||
Workouts map[int64]garmin.Workout
|
||
Err error // if set, every call returns this error
|
||
authResultCursor int
|
||
ClosedCalled bool
|
||
GetActivitiesCalls int
|
||
AuthenticateCalls int
|
||
LastEmail string
|
||
LastPassword string
|
||
}
|
||
```
|
||
|
||
Change `Authenticate`:
|
||
|
||
```go
|
||
func (c *Client) Authenticate(ctx context.Context) (garmin.AuthResult, error) {
|
||
c.AuthenticateCalls++
|
||
if c.Err != nil {
|
||
return garmin.AuthResult{}, c.Err
|
||
}
|
||
return c.nextAuthResult(), nil
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Write the failing tests**
|
||
|
||
Replace the entire contents of `backend/internal/api/setup_test.go` with:
|
||
|
||
```go
|
||
package api
|
||
|
||
import (
|
||
"encoding/json"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"os"
|
||
"path/filepath"
|
||
"testing"
|
||
|
||
authmock "geniusrun/backend/internal/auth/mock"
|
||
"geniusrun/backend/internal/garmin"
|
||
"geniusrun/backend/internal/garmin/mock"
|
||
"geniusrun/backend/internal/store"
|
||
appsync "geniusrun/backend/internal/sync"
|
||
)
|
||
|
||
// newUnprovisionedServer builds a Server whose "test-user" OIDC subject
|
||
// (the identity doJSON's cookie always mints) has no users/profile row yet
|
||
// -- every test in this file needs that starting state, unlike
|
||
// newTestServer's auto-provisioned default.
|
||
func newUnprovisionedServer(t *testing.T) (*Server, *store.DB, *mock.Client) {
|
||
t.Helper()
|
||
db, err := store.Open(filepath.Join(t.TempDir(), "geniusrun_test.db"))
|
||
if err != nil {
|
||
t.Fatalf("store.Open: %v", err)
|
||
}
|
||
t.Cleanup(func() { db.Close() })
|
||
m := &mock.Client{}
|
||
garminFactory := func(garmin.Config) garmin.Client { return m }
|
||
s := NewServer(db, garminFactory, garmin.Config{}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig)
|
||
return s, db, m
|
||
}
|
||
|
||
func TestSetupGarminLogin_AuthenticatesWithoutCreatingAccount(t *testing.T) {
|
||
s, db, _ := newUnprovisionedServer(t)
|
||
router := s.Router()
|
||
|
||
rec := doJSON(t, router, http.MethodPost, "/api/setup/garmin/login", map[string]any{
|
||
"garmin_email": "runner@example.com", "garmin_password": "hunter2",
|
||
})
|
||
if rec.Code != http.StatusOK {
|
||
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
||
}
|
||
var resp authResponse
|
||
unmarshalBody(t, rec, &resp)
|
||
if resp.Status != "authenticated" {
|
||
t.Fatalf("status = %q, want authenticated", resp.Status)
|
||
}
|
||
|
||
if _, found, err := db.GetUserBySub(newCtx(), "test-user"); err != nil || found {
|
||
t.Fatalf("expected no user created yet, found=%v err=%v", found, err)
|
||
}
|
||
}
|
||
|
||
func TestSetupGarminLogin_MFARequiredThenCompleteMFA(t *testing.T) {
|
||
s, db, m := newUnprovisionedServer(t)
|
||
m.AuthResults = []garmin.AuthResult{{Status: garmin.AuthMFARequired, Message: "MFA required"}}
|
||
router := s.Router()
|
||
|
||
rec := doJSON(t, router, http.MethodPost, "/api/setup/garmin/login", map[string]any{
|
||
"garmin_email": "runner@example.com", "garmin_password": "hunter2",
|
||
})
|
||
if rec.Code != http.StatusOK {
|
||
t.Fatalf("login status = %d, body = %s", rec.Code, rec.Body.String())
|
||
}
|
||
var resp authResponse
|
||
unmarshalBody(t, rec, &resp)
|
||
if resp.Status != "mfa_required" {
|
||
t.Fatalf("status = %q, want mfa_required", resp.Status)
|
||
}
|
||
|
||
rec = doJSON(t, router, http.MethodPost, "/api/setup/garmin/mfa", map[string]any{"code": "123456"})
|
||
if rec.Code != http.StatusOK {
|
||
t.Fatalf("mfa status = %d, body = %s", rec.Code, rec.Body.String())
|
||
}
|
||
unmarshalBody(t, rec, &resp)
|
||
if resp.Status != "authenticated" {
|
||
t.Fatalf("status after mfa = %q, want authenticated", resp.Status)
|
||
}
|
||
|
||
if _, found, err := db.GetUserBySub(newCtx(), "test-user"); err != nil || found {
|
||
t.Fatalf("expected no user created yet even after MFA success, found=%v err=%v", found, err)
|
||
}
|
||
}
|
||
|
||
func TestSetupGarminMFA_RejectsWithoutPriorLoginAttempt(t *testing.T) {
|
||
s, _, _ := newUnprovisionedServer(t)
|
||
rec := doJSON(t, s.Router(), http.MethodPost, "/api/setup/garmin/mfa", map[string]any{"code": "123456"})
|
||
if rec.Code != http.StatusConflict {
|
||
t.Fatalf("status = %d, want 409, body = %s", rec.Code, rec.Body.String())
|
||
}
|
||
}
|
||
|
||
func TestSetupComplete_CreatesAccountWithGarminCredentialsAndPromotesClient(t *testing.T) {
|
||
s, db, m := newUnprovisionedServer(t)
|
||
router := s.Router()
|
||
|
||
rec := doJSON(t, router, http.MethodPost, "/api/setup/garmin/login", map[string]any{
|
||
"garmin_email": "runner@example.com", "garmin_password": "hunter2",
|
||
})
|
||
if rec.Code != http.StatusOK {
|
||
t.Fatalf("login status = %d, body = %s", rec.Code, rec.Body.String())
|
||
}
|
||
|
||
rec = doJSON(t, router, http.MethodPost, "/api/setup/complete", map[string]any{"display_name": "Lucie"})
|
||
if rec.Code != http.StatusOK {
|
||
t.Fatalf("complete status = %d, body = %s", rec.Code, rec.Body.String())
|
||
}
|
||
|
||
u, found, err := db.GetUserBySub(newCtx(), "test-user")
|
||
if err != nil || !found {
|
||
t.Fatalf("GetUserBySub: found=%v err=%v", found, err)
|
||
}
|
||
profile, err := db.GetProfile(newCtx(), u.ID)
|
||
if err != nil {
|
||
t.Fatalf("GetProfile: %v", err)
|
||
}
|
||
if profile.GarminEmail != "runner@example.com" || profile.GarminPassword != "hunter2" {
|
||
t.Errorf("profile garmin creds = %+v, want email=runner@example.com password=hunter2", profile)
|
||
}
|
||
if profile.GarminConnectedAt == nil {
|
||
t.Error("expected GarminConnectedAt to be set")
|
||
}
|
||
|
||
if m.AuthenticateCalls != 1 {
|
||
t.Errorf("AuthenticateCalls = %d, want 1 (no redundant re-authentication after promotion)", m.AuthenticateCalls)
|
||
}
|
||
if m.ClosedCalled {
|
||
t.Error("expected the promoted client to survive (not be Close()d)")
|
||
}
|
||
|
||
client, err := s.garminFor(newCtx(), u.ID)
|
||
if err != nil {
|
||
t.Fatalf("garminFor: %v", err)
|
||
}
|
||
if client != m {
|
||
t.Error("expected garminFor to return the promoted (already-authenticated) client")
|
||
}
|
||
}
|
||
|
||
func TestSetupComplete_RejectsWithoutSuccessfulGarminConnection(t *testing.T) {
|
||
s, db, _ := newUnprovisionedServer(t)
|
||
rec := doJSON(t, s.Router(), http.MethodPost, "/api/setup/complete", map[string]any{"display_name": "Lucie"})
|
||
if rec.Code != http.StatusConflict {
|
||
t.Fatalf("status = %d, want 409, body = %s", rec.Code, rec.Body.String())
|
||
}
|
||
if _, found, err := db.GetUserBySub(newCtx(), "test-user"); err != nil || found {
|
||
t.Fatalf("expected no user created, found=%v err=%v", found, err)
|
||
}
|
||
}
|
||
|
||
func TestSetupComplete_RejectsEmptyDisplayName(t *testing.T) {
|
||
s, _, _ := newUnprovisionedServer(t)
|
||
router := s.Router()
|
||
rec := doJSON(t, router, http.MethodPost, "/api/setup/garmin/login", map[string]any{
|
||
"garmin_email": "runner@example.com", "garmin_password": "hunter2",
|
||
})
|
||
if rec.Code != http.StatusOK {
|
||
t.Fatalf("login status = %d, body = %s", rec.Code, rec.Body.String())
|
||
}
|
||
rec = doJSON(t, router, http.MethodPost, "/api/setup/complete", map[string]any{"display_name": ""})
|
||
if rec.Code != http.StatusBadRequest {
|
||
t.Fatalf("status = %d, want 400", rec.Code)
|
||
}
|
||
}
|
||
|
||
func TestSetupGarminLogin_RejectsWhenAlreadyProvisioned(t *testing.T) {
|
||
s, _, _ := newTestServer(t) // pre-provisioned "test-user"
|
||
rec := doJSON(t, s.Router(), http.MethodPost, "/api/setup/garmin/login", map[string]any{
|
||
"garmin_email": "runner@example.com", "garmin_password": "hunter2",
|
||
})
|
||
if rec.Code != http.StatusConflict {
|
||
t.Fatalf("status = %d, want 409, body = %s", rec.Code, rec.Body.String())
|
||
}
|
||
}
|
||
|
||
func TestSetupComplete_RejectsWhenAlreadyProvisioned(t *testing.T) {
|
||
s, _, _ := newTestServer(t) // pre-provisioned "test-user"
|
||
rec := doJSON(t, s.Router(), http.MethodPost, "/api/setup/complete", map[string]any{"display_name": "Someone Else"})
|
||
if rec.Code != http.StatusConflict {
|
||
t.Fatalf("status = %d, want 409, body = %s", rec.Code, rec.Body.String())
|
||
}
|
||
}
|
||
|
||
func TestSetupComplete_RenamesTokenStoreDirectory(t *testing.T) {
|
||
db, err := store.Open(filepath.Join(t.TempDir(), "geniusrun_test.db"))
|
||
if err != nil {
|
||
t.Fatalf("store.Open: %v", err)
|
||
}
|
||
t.Cleanup(func() { db.Close() })
|
||
m := &mock.Client{}
|
||
tokenStoreRoot := t.TempDir()
|
||
s := NewServer(db, func(garmin.Config) garmin.Client { return m }, garmin.Config{TokenStorePath: tokenStoreRoot}, appsync.Config{}, &authmock.Verifier{}, testSessionConfig)
|
||
router := s.Router()
|
||
|
||
rec := doJSON(t, router, http.MethodPost, "/api/setup/garmin/login", map[string]any{
|
||
"garmin_email": "runner@example.com", "garmin_password": "hunter2",
|
||
})
|
||
if rec.Code != http.StatusOK {
|
||
t.Fatalf("login status = %d, body = %s", rec.Code, rec.Body.String())
|
||
}
|
||
|
||
// Simulate what a real subprocess would have written under the
|
||
// ephemeral (subject-keyed) directory during that login call.
|
||
oldDir := setupTokenStoreDir(tokenStoreRoot, "test-user")
|
||
if err := os.MkdirAll(oldDir, 0o755); err != nil {
|
||
t.Fatalf("MkdirAll: %v", err)
|
||
}
|
||
if err := os.WriteFile(filepath.Join(oldDir, "session.json"), []byte("{}"), 0o644); err != nil {
|
||
t.Fatalf("WriteFile: %v", err)
|
||
}
|
||
|
||
rec = doJSON(t, router, http.MethodPost, "/api/setup/complete", map[string]any{"display_name": "Lucie"})
|
||
if rec.Code != http.StatusOK {
|
||
t.Fatalf("complete status = %d, body = %s", rec.Code, rec.Body.String())
|
||
}
|
||
u, found, err := db.GetUserBySub(newCtx(), "test-user")
|
||
if err != nil || !found {
|
||
t.Fatalf("GetUserBySub: found=%v err=%v", found, err)
|
||
}
|
||
|
||
if _, err := os.Stat(oldDir); !os.IsNotExist(err) {
|
||
t.Fatalf("expected old setup token store dir %q to be gone, stat err = %v", oldDir, err)
|
||
}
|
||
newDir := filepath.Join(tokenStoreRoot, itoa(u.ID))
|
||
if _, err := os.Stat(filepath.Join(newDir, "session.json")); err != nil {
|
||
t.Fatalf("expected renamed token store dir %q to contain session.json: %v", newDir, err)
|
||
}
|
||
}
|
||
|
||
func unmarshalBody(t *testing.T, rec *httptest.ResponseRecorder, v any) {
|
||
t.Helper()
|
||
if err := json.Unmarshal(rec.Body.Bytes(), v); err != nil {
|
||
t.Fatalf("unmarshal response body %q: %v", rec.Body.String(), err)
|
||
}
|
||
}
|
||
```
|
||
|
||
Append to `backend/internal/api/isolation_test.go`:
|
||
|
||
```go
|
||
// TestIsolation_SetupSessionsNeverLeakAcrossSubjects confirms one OIDC
|
||
// subject's pending ephemeral Garmin session is invisible to another
|
||
// subject -- e.g. subject B completing MFA must not accidentally continue
|
||
// subject A's in-progress attempt.
|
||
func TestIsolation_SetupSessionsNeverLeakAcrossSubjects(t *testing.T) {
|
||
s, _, _ := newUnprovisionedServer(t)
|
||
router := s.Router()
|
||
|
||
rec := doJSON(t, router, http.MethodPost, "/api/setup/garmin/login", map[string]any{
|
||
"garmin_email": "a@example.com", "garmin_password": "pw-a",
|
||
})
|
||
if rec.Code != http.StatusOK {
|
||
t.Fatalf("login(a) status = %d, body = %s", rec.Code, rec.Body.String())
|
||
}
|
||
|
||
rec = doJSONAs(t, router, "user-b", http.MethodPost, "/api/setup/garmin/mfa", map[string]any{"code": "000000"})
|
||
if rec.Code != http.StatusConflict {
|
||
t.Fatalf("user-b mfa (no login attempt of their own) status = %d, want 409, body = %s", rec.Code, rec.Body.String())
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Run tests to verify they fail**
|
||
|
||
Run: `cd backend && go test ./internal/api/... -run 'TestSetup|TestIsolation_SetupSessions' -v`
|
||
Expected: FAIL — 404s (routes don't exist), plus `newUnprovisionedServer`/`setupTokenStoreDir` undefined.
|
||
|
||
- [ ] **Step 4: Add the ephemeral session infrastructure to `server.go`**
|
||
|
||
Change the import block:
|
||
|
||
```go
|
||
import (
|
||
"context"
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"fmt"
|
||
"log"
|
||
"net/http"
|
||
"os"
|
||
"path/filepath"
|
||
"strconv"
|
||
"sync"
|
||
"time"
|
||
|
||
"github.com/go-chi/chi/v5"
|
||
|
||
"geniusrun/backend/internal/auth"
|
||
"geniusrun/backend/internal/garmin"
|
||
"geniusrun/backend/internal/store"
|
||
appsync "geniusrun/backend/internal/sync"
|
||
)
|
||
```
|
||
|
||
Add the `setupSession` field to `Server` and initialize it in `NewServer`:
|
||
|
||
```go
|
||
mu sync.Mutex
|
||
userGarmin map[int64]garmin.Client
|
||
userSync map[int64]*appsync.Service
|
||
userAuthStatus map[int64]garmin.AuthStatus
|
||
userAuthMessage map[int64]string
|
||
userSyncRunning map[int64]bool
|
||
setupGarmin map[string]*setupSession
|
||
}
|
||
```
|
||
|
||
```go
|
||
return &Server{
|
||
DB: db, GarminFactory: garminFactory, GarminBase: garminBase, SyncConfig: syncConfig,
|
||
Auth: authVerifier, Session: session,
|
||
userGarmin: map[int64]garmin.Client{},
|
||
userSync: map[int64]*appsync.Service{},
|
||
userAuthStatus: map[int64]garmin.AuthStatus{},
|
||
userAuthMessage: map[int64]string{},
|
||
userSyncRunning: map[int64]bool{},
|
||
setupGarmin: map[string]*setupSession{},
|
||
}
|
||
}
|
||
```
|
||
|
||
Insert this block right after `NewServer` (before `clientFor`):
|
||
|
||
```go
|
||
|
||
// 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
|
||
}
|
||
|
||
// setupSessionIdleTimeout bounds how long an onboarding Garmin session
|
||
// survives without being touched (login, MFA, or complete) before it's
|
||
// evicted -- long enough to check email for an MFA code, short enough that
|
||
// an abandoned attempt doesn't leave a subprocess running indefinitely.
|
||
const setupSessionIdleTimeout = 15 * time.Minute
|
||
|
||
// setupTokenStoreDir returns the token-store directory an ephemeral setup
|
||
// session for sub should use -- hashed rather than sub itself, since sub is
|
||
// an opaque string from the identity provider and using it verbatim in a
|
||
// filesystem path would be a directory-traversal risk if it ever contained
|
||
// path separators.
|
||
func setupTokenStoreDir(root, sub string) string {
|
||
h := sha256.Sum256([]byte(sub))
|
||
return filepath.Join(root, "setup", hex.EncodeToString(h[:]))
|
||
}
|
||
|
||
// setupSessionFor returns sub's in-progress ephemeral Garmin session, if
|
||
// any and not stale. A stale session is closed and evicted first, so the
|
||
// caller always either gets a fresh, live session or none.
|
||
func (s *Server) setupSessionFor(sub string) (*setupSession, bool) {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
sess, ok := s.setupGarmin[sub]
|
||
if !ok {
|
||
return nil, false
|
||
}
|
||
if time.Since(sess.LastUsed) > setupSessionIdleTimeout {
|
||
sess.Client.Close()
|
||
delete(s.setupGarmin, sub)
|
||
if s.GarminBase.TokenStorePath != "" {
|
||
os.RemoveAll(setupTokenStoreDir(s.GarminBase.TokenStorePath, sub))
|
||
}
|
||
return nil, false
|
||
}
|
||
return sess, true
|
||
}
|
||
|
||
// replaceSetupSession closes and replaces sub's ephemeral Garmin session
|
||
// (if any) with a freshly built one for the given credentials -- same
|
||
// "close old, spawn new" semantics as garmin.Client.UpdateCredentials.
|
||
func (s *Server) replaceSetupSession(sub, email, password string) *setupSession {
|
||
s.mu.Lock()
|
||
old, ok := s.setupGarmin[sub]
|
||
s.mu.Unlock()
|
||
if ok {
|
||
old.Client.Close()
|
||
}
|
||
|
||
cfg := s.GarminBase
|
||
cfg.GarminEmail = email
|
||
cfg.GarminPassword = password
|
||
if cfg.TokenStorePath != "" {
|
||
cfg.TokenStorePath = setupTokenStoreDir(s.GarminBase.TokenStorePath, sub)
|
||
}
|
||
sess := &setupSession{Client: s.GarminFactory(cfg), Email: email, Password: password, LastUsed: time.Now()}
|
||
|
||
s.mu.Lock()
|
||
s.setupGarmin[sub] = sess
|
||
s.mu.Unlock()
|
||
return sess
|
||
}
|
||
|
||
// recordSetupAuthResult updates sub's ephemeral session after a login or
|
||
// MFA attempt. A no-op if the session is gone (e.g. evicted concurrently).
|
||
func (s *Server) recordSetupAuthResult(sub string, res garmin.AuthResult) {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
if sess, ok := s.setupGarmin[sub]; ok {
|
||
sess.Status = res.Status
|
||
sess.Message = res.Message
|
||
sess.LastUsed = time.Now()
|
||
}
|
||
}
|
||
|
||
// removeSetupSession closes and drops sub's ephemeral Garmin session, if
|
||
// any, and best-effort removes its token-store directory. Used when
|
||
// abandoning onboarding (logout) -- NOT used during promotion in
|
||
// handleSetupComplete, which transfers ownership of the client (and
|
||
// renames the directory) instead of discarding them.
|
||
func (s *Server) removeSetupSession(sub string) {
|
||
s.mu.Lock()
|
||
sess, ok := s.setupGarmin[sub]
|
||
delete(s.setupGarmin, sub)
|
||
s.mu.Unlock()
|
||
if !ok {
|
||
return
|
||
}
|
||
sess.Client.Close()
|
||
if s.GarminBase.TokenStorePath != "" {
|
||
os.RemoveAll(setupTokenStoreDir(s.GarminBase.TokenStorePath, sub))
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Replace the route registration**
|
||
|
||
In `server.go`'s `Router()`, change:
|
||
|
||
```go
|
||
r.Get("/session/me", s.handleSessionMe)
|
||
r.Post("/session/logout", s.handleSessionLogout)
|
||
r.Post("/setup", s.handleSetup)
|
||
```
|
||
|
||
to:
|
||
|
||
```go
|
||
r.Get("/session/me", s.handleSessionMe)
|
||
r.Post("/session/logout", s.handleSessionLogout)
|
||
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)
|
||
})
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 6: Replace `handleSetup` in `setup.go` with the three new handlers**
|
||
|
||
Replace the entire contents of `backend/internal/api/setup.go` with:
|
||
|
||
```go
|
||
package api
|
||
|
||
import (
|
||
"encoding/json"
|
||
"log"
|
||
"net/http"
|
||
"os"
|
||
"path/filepath"
|
||
"strconv"
|
||
|
||
"geniusrun/backend/internal/auth"
|
||
"geniusrun/backend/internal/garmin"
|
||
)
|
||
|
||
// handleSetupGarminLogin authenticates against Garmin using an ephemeral,
|
||
// not-yet-persisted session keyed by the OIDC subject -- no users/profile
|
||
// row exists yet at this point (see setupSession in server.go).
|
||
func (s *Server) handleSetupGarminLogin(w http.ResponseWriter, r *http.Request) {
|
||
claims, ok := auth.ClaimsFromContext(r.Context())
|
||
if !ok {
|
||
writeError(w, http.StatusUnauthorized, "not authenticated")
|
||
return
|
||
}
|
||
if _, found := userFromContext(r.Context()); found {
|
||
writeError(w, http.StatusConflict, "profile already exists for this account")
|
||
return
|
||
}
|
||
|
||
var body struct {
|
||
GarminEmail string `json:"garmin_email"`
|
||
GarminPassword string `json:"garmin_password"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||
return
|
||
}
|
||
if body.GarminEmail == "" || body.GarminPassword == "" {
|
||
writeError(w, http.StatusBadRequest, "garmin_email and garmin_password are required")
|
||
return
|
||
}
|
||
|
||
sess := s.replaceSetupSession(claims.Sub, body.GarminEmail, body.GarminPassword)
|
||
res, err := sess.Client.Authenticate(r.Context())
|
||
if err != nil {
|
||
writeError(w, http.StatusBadGateway, err.Error())
|
||
return
|
||
}
|
||
s.recordSetupAuthResult(claims.Sub, res)
|
||
writeJSON(w, http.StatusOK, authResponse{Status: authStatusString(res.Status), Message: res.Message})
|
||
}
|
||
|
||
// handleSetupGarminMFA continues an in-progress ephemeral Garmin login
|
||
// (started by handleSetupGarminLogin) with an MFA code, on the same
|
||
// session/subprocess -- never replaces it.
|
||
func (s *Server) handleSetupGarminMFA(w http.ResponseWriter, r *http.Request) {
|
||
claims, ok := auth.ClaimsFromContext(r.Context())
|
||
if !ok {
|
||
writeError(w, http.StatusUnauthorized, "not authenticated")
|
||
return
|
||
}
|
||
|
||
var body struct {
|
||
Code string `json:"code"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||
return
|
||
}
|
||
if body.Code == "" {
|
||
writeError(w, http.StatusBadRequest, "code is required")
|
||
return
|
||
}
|
||
|
||
sess, ok := s.setupSessionFor(claims.Sub)
|
||
if !ok {
|
||
writeError(w, http.StatusConflict, "no Garmin connection attempt in progress; connect again")
|
||
return
|
||
}
|
||
res, err := sess.Client.CompleteMFA(r.Context(), body.Code)
|
||
if err != nil {
|
||
writeError(w, http.StatusBadGateway, err.Error())
|
||
return
|
||
}
|
||
s.recordSetupAuthResult(claims.Sub, res)
|
||
writeJSON(w, http.StatusOK, authResponse{Status: authStatusString(res.Status), Message: res.Message})
|
||
}
|
||
|
||
// handleSetupComplete is the single atomic commit point: only reachable
|
||
// once the ephemeral session for this subject last reported
|
||
// garmin.AuthSuccess. Provisions the account, persists the Garmin
|
||
// credentials, marks it connected, and promotes the already-authenticated
|
||
// ephemeral client into the permanent per-user cache instead of discarding
|
||
// it (no redundant re-authentication, no repeat MFA prompt, right after
|
||
// signup).
|
||
func (s *Server) handleSetupComplete(w http.ResponseWriter, r *http.Request) {
|
||
claims, ok := auth.ClaimsFromContext(r.Context())
|
||
if !ok {
|
||
writeError(w, http.StatusUnauthorized, "not authenticated")
|
||
return
|
||
}
|
||
if _, found := userFromContext(r.Context()); found {
|
||
writeError(w, http.StatusConflict, "profile already exists for this account")
|
||
return
|
||
}
|
||
|
||
var body struct {
|
||
DisplayName string `json:"display_name"`
|
||
}
|
||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||
return
|
||
}
|
||
if body.DisplayName == "" {
|
||
writeError(w, http.StatusBadRequest, "display_name is required")
|
||
return
|
||
}
|
||
|
||
sess, ok := s.setupSessionFor(claims.Sub)
|
||
if !ok || sess.Status != garmin.AuthSuccess {
|
||
writeError(w, http.StatusConflict, "Garmin isn't connected yet -- connect before completing setup")
|
||
return
|
||
}
|
||
|
||
userID, err := s.DB.ProvisionUser(r.Context(), claims.Sub, body.DisplayName)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
profile, err := s.DB.GetProfile(r.Context(), userID)
|
||
if err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
profile.GarminEmail = sess.Email
|
||
profile.GarminPassword = sess.Password
|
||
if err := s.DB.UpdateProfile(r.Context(), userID, profile); err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
if err := s.DB.MarkGarminConnected(r.Context(), userID); err != nil {
|
||
writeError(w, http.StatusInternalServerError, err.Error())
|
||
return
|
||
}
|
||
|
||
s.mu.Lock()
|
||
delete(s.setupGarmin, claims.Sub)
|
||
s.userGarmin[userID] = sess.Client
|
||
s.userAuthStatus[userID] = sess.Status
|
||
s.userAuthMessage[userID] = sess.Message
|
||
s.mu.Unlock()
|
||
|
||
if s.GarminBase.TokenStorePath != "" {
|
||
oldDir := setupTokenStoreDir(s.GarminBase.TokenStorePath, claims.Sub)
|
||
newDir := filepath.Join(s.GarminBase.TokenStorePath, strconv.FormatInt(userID, 10))
|
||
if err := os.Rename(oldDir, newDir); err != nil && !os.IsNotExist(err) {
|
||
log.Printf("api: rename setup token store dir for user %d: %v", userID, err)
|
||
}
|
||
}
|
||
|
||
writeJSON(w, http.StatusOK, map[string]any{"user_id": userID, "display_name": body.DisplayName})
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 7: Clean up the ephemeral session on logout**
|
||
|
||
In `backend/internal/api/session.go`, change `handleSessionLogout`:
|
||
|
||
```go
|
||
func (s *Server) handleSessionLogout(w http.ResponseWriter, r *http.Request) {
|
||
claims, _ := auth.ClaimsFromContext(r.Context())
|
||
s.removeSetupSession(claims.Sub)
|
||
http.SetCookie(w, auth.ClearCookie(auth.SessionCookieName, s.Session.Secure))
|
||
http.Redirect(w, r, s.Auth.EndSessionURL(s.Session.FrontendURL+"/", claims.IDToken), http.StatusFound)
|
||
}
|
||
```
|
||
|
||
(`claims.Sub` is `""` for the "unreachable in practice" case where `ClaimsFromContext` fails — `removeSetupSession("")` is a harmless no-op since no session is ever stored under that key.)
|
||
|
||
- [ ] **Step 8: Run tests to verify they pass**
|
||
|
||
Run: `cd backend && go test ./internal/api/... -run 'TestSetup|TestIsolation_SetupSessions' -v`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 9: Run the full backend test suite**
|
||
|
||
Run: `cd backend && go build ./... && go vet ./... && gofmt -l . && go test ./...`
|
||
Expected: `gofmt -l .` empty; everything else passes. (`TestSetup_ProvisionsNewUserWithDisplayName`, `TestSetup_RejectsEmptyDisplayName`, `TestSetup_RejectsWhenAlreadyProvisioned` from the old `/api/setup` no longer exist — they were replaced wholesale in Step 2.)
|
||
|
||
- [ ] **Step 10: Commit**
|
||
|
||
```bash
|
||
git add backend/internal/api/server.go backend/internal/api/setup.go backend/internal/api/session.go backend/internal/api/setup_test.go backend/internal/api/isolation_test.go backend/internal/garmin/mock/mock.go
|
||
git commit -m "feat(api): defer account creation until Garmin actually connects"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: Frontend — single OnboardingWizard replaces CreateProfile/ConnectGarmin
|
||
|
||
**Files:**
|
||
- Modify: `frontend/src/api/client.ts` (remove `setup`, add `setupGarminLogin`/`setupGarminMFA`/`setupComplete`)
|
||
- Create: `frontend/src/OnboardingWizard.tsx`
|
||
- Create: `frontend/src/OnboardingWizard.css`
|
||
- Delete: `frontend/src/CreateProfile.tsx`, `frontend/src/ConnectGarmin.tsx`, `frontend/src/CreateProfile.css`
|
||
- Modify: `frontend/src/LoginGate.tsx` (collapse to two-way fork)
|
||
|
||
**Interfaces:**
|
||
- Consumes: the three new backend endpoints (Task 1); `AuthResponse` (existing type, unchanged).
|
||
- Produces: `OnboardingWizard({ onCreated: (displayName: string) => void })`, a React component.
|
||
|
||
- [ ] **Step 1: Replace `setup` with the three new API client methods**
|
||
|
||
In `frontend/src/api/client.ts`, change:
|
||
|
||
```ts
|
||
getSessionInfo: () => request<SessionInfo>("/api/session/me"),
|
||
setup: (displayName: string) =>
|
||
request<{ user_id: number; display_name: string }>("/api/setup", {
|
||
method: "POST",
|
||
body: JSON.stringify({ display_name: displayName }),
|
||
}),
|
||
```
|
||
|
||
to:
|
||
|
||
```ts
|
||
getSessionInfo: () => request<SessionInfo>("/api/session/me"),
|
||
// Onboarding (pre-account) -- distinct from the Profile page's
|
||
// auth.login()/submitMFA() below, since these run before any account
|
||
// exists: the Garmin session they build is ephemeral (keyed by OIDC
|
||
// subject, not a user id) until setupComplete() actually creates the
|
||
// account. See docs/superpowers/specs/2026-07-26-onboarding-wizard-deferred-commit-design.md.
|
||
setupGarminLogin: (garminEmail: string, garminPassword: string) =>
|
||
request<AuthResponse>("/api/setup/garmin/login", {
|
||
method: "POST",
|
||
body: JSON.stringify({ garmin_email: garminEmail, garmin_password: garminPassword }),
|
||
}),
|
||
setupGarminMFA: (code: string) =>
|
||
request<AuthResponse>("/api/setup/garmin/mfa", { method: "POST", body: JSON.stringify({ code }) }),
|
||
setupComplete: (displayName: string) =>
|
||
request<{ user_id: number; display_name: string }>("/api/setup/complete", {
|
||
method: "POST",
|
||
body: JSON.stringify({ display_name: displayName }),
|
||
}),
|
||
```
|
||
|
||
- [ ] **Step 2: Create `frontend/src/OnboardingWizard.css`**
|
||
|
||
```css
|
||
.onboarding-wizard {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
height: 100vh;
|
||
gap: 1rem;
|
||
text-align: center;
|
||
}
|
||
|
||
.onboarding-wizard form {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.75rem;
|
||
width: 100%;
|
||
max-width: 320px;
|
||
}
|
||
|
||
.onboarding-wizard input {
|
||
padding: 0.6rem 0.8rem;
|
||
font-size: 1rem;
|
||
border-radius: 0.5rem;
|
||
border: 1px solid #ccc;
|
||
}
|
||
|
||
.onboarding-wizard button {
|
||
padding: 0.6rem 0.8rem;
|
||
font-size: 1rem;
|
||
border-radius: 0.5rem;
|
||
border: none;
|
||
background: #3b82f6;
|
||
color: white;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.onboarding-wizard button:disabled {
|
||
opacity: 0.6;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.onboarding-wizard-error {
|
||
color: #ef4444;
|
||
}
|
||
|
||
.onboarding-wizard-message {
|
||
margin: 0;
|
||
color: #9ca3af;
|
||
}
|
||
|
||
.onboarding-wizard-mfa {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.75rem;
|
||
width: 100%;
|
||
max-width: 320px;
|
||
}
|
||
|
||
.onboarding-wizard-actions {
|
||
display: flex;
|
||
gap: 0.75rem;
|
||
}
|
||
|
||
.onboarding-wizard-actions button {
|
||
flex: 1;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Create `frontend/src/OnboardingWizard.tsx`**
|
||
|
||
```tsx
|
||
import { useState } from "react";
|
||
import { api, BASE_URL } from "./api/client";
|
||
import "./OnboardingWizard.css";
|
||
import type { AuthResponse } from "./types/api";
|
||
|
||
type Step = "name" | "garmin";
|
||
|
||
// Replaces the old CreateProfile.tsx + ConnectGarmin.tsx: a single wizard,
|
||
// since nothing is persisted to the database until Garmin actually
|
||
// connects (see
|
||
// docs/superpowers/specs/2026-07-26-onboarding-wizard-deferred-commit-design.md).
|
||
// Quitting partway through (closing the tab, logging out) leaves no trace
|
||
// in the database -- there's no half-created account to clean up or
|
||
// re-prompt for later.
|
||
export function OnboardingWizard({ onCreated }: { onCreated: (displayName: string) => void }) {
|
||
const [step, setStep] = useState<Step>("name");
|
||
const [displayName, setDisplayName] = useState("");
|
||
const [email, setEmail] = useState("");
|
||
const [password, setPassword] = useState("");
|
||
const [code, setCode] = useState("");
|
||
const [auth, setAuth] = useState<AuthResponse | null>(null);
|
||
const [busy, setBusy] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
function submitName(e: React.FormEvent) {
|
||
e.preventDefault();
|
||
if (!displayName.trim()) {
|
||
setError("Please enter a display name.");
|
||
return;
|
||
}
|
||
setError(null);
|
||
setStep("garmin");
|
||
}
|
||
|
||
async function submitGarmin(e: React.FormEvent) {
|
||
e.preventDefault();
|
||
if (!email.trim() || !password) {
|
||
setError("Please enter your Garmin email and password.");
|
||
return;
|
||
}
|
||
setBusy(true);
|
||
setError(null);
|
||
try {
|
||
setAuth(await api.setupGarminLogin(email.trim(), password));
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : "Something went wrong, please try again.");
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
async function submitMFA() {
|
||
if (!code.trim()) return;
|
||
setBusy(true);
|
||
setError(null);
|
||
try {
|
||
setAuth(await api.setupGarminMFA(code.trim()));
|
||
setCode("");
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : "Something went wrong, please try again.");
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
async function complete() {
|
||
setBusy(true);
|
||
setError(null);
|
||
try {
|
||
const result = await api.setupComplete(displayName.trim());
|
||
onCreated(result.display_name);
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : "Something went wrong, please try again.");
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
if (step === "name") {
|
||
return (
|
||
<div className="onboarding-wizard">
|
||
<h1>🧞♀️ Welcome to geniusrun</h1>
|
||
<p>Let's set up your profile. You'll connect your Garmin account next.</p>
|
||
<form onSubmit={submitName}>
|
||
<input
|
||
type="text"
|
||
placeholder="Display name"
|
||
value={displayName}
|
||
onChange={(e) => setDisplayName(e.target.value)}
|
||
autoFocus
|
||
/>
|
||
{error && <p className="onboarding-wizard-error">{error}</p>}
|
||
<button type="submit">Next</button>
|
||
</form>
|
||
<form method="post" action={`${BASE_URL}/api/session/logout`}>
|
||
<button type="submit" className="logout-link">
|
||
Log out
|
||
</button>
|
||
</form>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="onboarding-wizard">
|
||
<h1>🧞♀️ Connect your Garmin account</h1>
|
||
<p>geniusrun needs your Garmin credentials to sync your activities.</p>
|
||
|
||
{auth?.status === "authenticated" ? (
|
||
<>
|
||
<p className="onboarding-wizard-message">Connected to Garmin.</p>
|
||
<button type="button" disabled={busy} onClick={complete}>
|
||
{busy ? "Finishing…" : "Continue to geniusrun"}
|
||
</button>
|
||
</>
|
||
) : auth?.status === "mfa_required" ? (
|
||
<div className="onboarding-wizard-mfa">
|
||
<input
|
||
placeholder="MFA code"
|
||
value={code}
|
||
onChange={(e) => setCode(e.target.value)}
|
||
onKeyDown={(e) => e.key === "Enter" && submitMFA()}
|
||
disabled={busy}
|
||
autoFocus
|
||
/>
|
||
{auth.message && <p className="onboarding-wizard-message">{auth.message}</p>}
|
||
{error && <p className="onboarding-wizard-error">{error}</p>}
|
||
<div className="onboarding-wizard-actions">
|
||
<button
|
||
type="button"
|
||
disabled={busy}
|
||
onClick={() => {
|
||
setAuth(null);
|
||
setError(null);
|
||
}}
|
||
>
|
||
Previous
|
||
</button>
|
||
<button type="button" disabled={busy} onClick={submitMFA}>
|
||
Submit code
|
||
</button>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<form onSubmit={submitGarmin}>
|
||
<input
|
||
type="text"
|
||
placeholder="Garmin email"
|
||
value={email}
|
||
onChange={(e) => setEmail(e.target.value)}
|
||
disabled={busy}
|
||
autoFocus
|
||
/>
|
||
<input
|
||
type="password"
|
||
placeholder="Garmin password"
|
||
value={password}
|
||
onChange={(e) => setPassword(e.target.value)}
|
||
disabled={busy}
|
||
/>
|
||
{auth?.message && <p className="onboarding-wizard-message">{auth.message}</p>}
|
||
{error && <p className="onboarding-wizard-error">{error}</p>}
|
||
<div className="onboarding-wizard-actions">
|
||
<button
|
||
type="button"
|
||
disabled={busy}
|
||
onClick={() => {
|
||
setStep("name");
|
||
setError(null);
|
||
}}
|
||
>
|
||
Previous
|
||
</button>
|
||
<button type="submit" disabled={busy}>
|
||
{busy ? "Connecting…" : "Next"}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
)}
|
||
|
||
<form method="post" action={`${BASE_URL}/api/session/logout`}>
|
||
<button type="submit" className="logout-link">
|
||
Log out
|
||
</button>
|
||
</form>
|
||
</div>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Delete the old screens**
|
||
|
||
```bash
|
||
rm frontend/src/CreateProfile.tsx frontend/src/ConnectGarmin.tsx frontend/src/CreateProfile.css
|
||
```
|
||
|
||
- [ ] **Step 5: Collapse `LoginGate.tsx` to a two-way fork**
|
||
|
||
Change the imports:
|
||
|
||
```tsx
|
||
import { useEffect, useState } from "react";
|
||
import { api, BASE_URL } from "./api/client";
|
||
import "./LoginGate.css";
|
||
import App from "./App";
|
||
import { ConnectGarmin } from "./ConnectGarmin";
|
||
import { CreateProfile } from "./CreateProfile";
|
||
import type { SessionInfo } from "./types/api";
|
||
```
|
||
|
||
to:
|
||
|
||
```tsx
|
||
import { useEffect, useState } from "react";
|
||
import { api, BASE_URL } from "./api/client";
|
||
import "./LoginGate.css";
|
||
import App from "./App";
|
||
import { OnboardingWizard } from "./OnboardingWizard";
|
||
import type { SessionInfo } from "./types/api";
|
||
```
|
||
|
||
Change the doc comment above `LoginGate`:
|
||
|
||
```tsx
|
||
// Wraps App: on mount, asks the backend whether this browser already has a
|
||
// valid session (GET /api/session/me). geniusrun has no anonymous view, so
|
||
// this is the first fork -- "show the login screen" vs "show the app." A
|
||
// second fork, once authenticated, is whether the session's account has a
|
||
// provisioned profile yet (session.has_profile) -- a brand-new OIDC login
|
||
// sees CreateProfile instead of App until it submits one. A third fork,
|
||
// once provisioned, is whether Garmin has ever been successfully connected
|
||
// (session.garmin_connected) -- mandatory, and re-checked on every login,
|
||
// not just immediately after signup.
|
||
```
|
||
|
||
to:
|
||
|
||
```tsx
|
||
// Wraps App: on mount, asks the backend whether this browser already has a
|
||
// valid session (GET /api/session/me). geniusrun has no anonymous view, so
|
||
// this is the first fork -- "show the login screen" vs "show the app." A
|
||
// second fork, once authenticated, is whether the session's account has a
|
||
// provisioned profile yet (session.has_profile) -- a brand-new OIDC login
|
||
// sees OnboardingWizard instead of App until it completes one. Nothing is
|
||
// persisted until the wizard's Garmin-connect step actually succeeds (see
|
||
// docs/superpowers/specs/2026-07-26-onboarding-wizard-deferred-commit-design.md),
|
||
// so has_profile alone is always sufficient here -- there's no separate
|
||
// "provisioned but not connected" state to gate on.
|
||
```
|
||
|
||
Change the final part of the component body:
|
||
|
||
```tsx
|
||
if (!session!.has_profile) {
|
||
return (
|
||
<CreateProfile
|
||
onCreated={(displayName) => setSession((s) => (s ? { ...s, has_profile: true, display_name: displayName } : s))}
|
||
/>
|
||
);
|
||
}
|
||
|
||
if (!session!.garmin_connected) {
|
||
return <ConnectGarmin onConnected={() => setSession((s) => (s ? { ...s, garmin_connected: true } : s))} />;
|
||
}
|
||
|
||
return <App session={session!} />;
|
||
}
|
||
```
|
||
|
||
to:
|
||
|
||
```tsx
|
||
if (!session!.has_profile) {
|
||
return (
|
||
<OnboardingWizard
|
||
onCreated={(displayName) => setSession((s) => (s ? { ...s, has_profile: true, display_name: displayName } : s))}
|
||
/>
|
||
);
|
||
}
|
||
|
||
return <App session={session!} />;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: Build and lint**
|
||
|
||
Run: `cd frontend && npm run build && npm run lint`
|
||
Expected: build succeeds; lint reports no new warnings beyond the 3 pre-existing `PaceField.tsx` ones.
|
||
|
||
- [ ] **Step 7: Manual browser verification**
|
||
|
||
With the backend (`cd backend && ./start.sh`) and frontend (`cd frontend && ./start.sh`) running:
|
||
1. Delete your profile (or use a fresh OIDC account) so you land on the wizard's name step.
|
||
2. Enter a display name, click Next — lands on the Garmin step. Click Previous — back to the name step with the name still filled in (pure local state).
|
||
3. Enter an obviously wrong Garmin password, click Next — error shown, fields stay editable, no account created (nothing to check in the UI for this, but note it for your own peace of mind).
|
||
4. Enter correct credentials. If MFA is prompted, click Previous from the MFA screen — confirm it returns to the credentials form (not the name step). Complete MFA.
|
||
5. On the "Connected to Garmin" screen, click "Continue to geniusrun" — confirm you land in the main app.
|
||
6. Log out, log back in with the same account — confirm you land straight in the app (no re-prompt).
|
||
7. Separately: start the wizard fresh, enter Garmin credentials, get to the "Connected" screen, then just close the tab without clicking Continue. Log back in — confirm you're back at the *name* step (proving nothing was persisted), not stuck at a half-finished Garmin step.
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
git add frontend/src/api/client.ts frontend/src/OnboardingWizard.tsx frontend/src/OnboardingWizard.css frontend/src/LoginGate.tsx
|
||
git rm frontend/src/CreateProfile.tsx frontend/src/ConnectGarmin.tsx frontend/src/CreateProfile.css
|
||
git commit -m "feat(onboarding): replace CreateProfile/ConnectGarmin with a single deferred-commit wizard"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 3: Final verification
|
||
|
||
**Files:** none (verification only)
|
||
|
||
- [ ] **Step 1: Full backend check**
|
||
|
||
Run: `cd backend && gofmt -l . && go vet ./... && go build ./... && go test ./...`
|
||
Expected: `gofmt -l .` empty; everything else passes.
|
||
|
||
- [ ] **Step 2: Full frontend check**
|
||
|
||
Run: `cd frontend && npm run build && npm run lint`
|
||
Expected: both succeed, no new lint warnings.
|
||
|
||
- [ ] **Step 3: Commit (if anything drifted)**
|
||
|
||
If either check above required a fix, commit it:
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "fix: address final verification findings"
|
||
```
|