From 84a7417781193650d8bd38d99042aeb8ae5e7207 Mon Sep 17 00:00:00 2001 From: Christophe Vila Date: Sun, 26 Jul 2026 13:32:32 +0200 Subject: [PATCH] feat(onboarding): replace CreateProfile/ConnectGarmin with a single deferred-commit wizard --- frontend/src/ConnectGarmin.tsx | 117 ----------- frontend/src/CreateProfile.tsx | 58 ------ frontend/src/LoginGate.tsx | 18 +- ...CreateProfile.css => OnboardingWizard.css} | 25 ++- frontend/src/OnboardingWizard.tsx | 187 ++++++++++++++++++ frontend/src/api/client.ts | 16 +- 6 files changed, 225 insertions(+), 196 deletions(-) delete mode 100644 frontend/src/ConnectGarmin.tsx delete mode 100644 frontend/src/CreateProfile.tsx rename frontend/src/{CreateProfile.css => OnboardingWizard.css} (67%) create mode 100644 frontend/src/OnboardingWizard.tsx diff --git a/frontend/src/ConnectGarmin.tsx b/frontend/src/ConnectGarmin.tsx deleted file mode 100644 index 555ca10..0000000 --- a/frontend/src/ConnectGarmin.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import { useState } from "react"; -import { api, BASE_URL } from "./api/client"; -import "./CreateProfile.css"; -import type { AuthResponse } from "./types/api"; - -// Shown after CreateProfile (or on any later login, until it succeeds -- -// see LoginGate) since connecting Garmin is mandatory: the account exists -// but this is the last gate before the main app. Deliberately not a reuse -// of GarminConnection.tsx (the Profile page's version), which also renders -// Sync now/Disconnect/Reset all -- none of which make sense here. -export function ConnectGarmin({ onConnected }: { onConnected: () => void }) { - const [email, setEmail] = useState(""); - const [password, setPassword] = useState(""); - const [code, setCode] = useState(""); - const [auth, setAuth] = useState(null); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(null); - - async function connect(e: React.FormEvent) { - e.preventDefault(); - if (!email.trim() || !password) { - setError("Please enter your Garmin email and password."); - return; - } - setBusy(true); - setError(null); - try { - const profile = await api.getProfile(); - await api.updateProfile({ ...profile, GarminEmail: email.trim(), GarminPassword: password }); - setAuth(await api.login()); - } 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.submitMFA(code.trim())); - setCode(""); - } catch (err) { - setError(err instanceof Error ? err.message : "Something went wrong, please try again."); - } finally { - setBusy(false); - } - } - - const status = auth?.status; - - return ( -
-

🧞‍♀️ Connect your Garmin account

-

geniusrun needs your Garmin credentials to sync your activities.

- - {status !== "authenticated" && status !== "mfa_required" && ( -
- setEmail(e.target.value)} - disabled={busy} - autoFocus - /> - setPassword(e.target.value)} - disabled={busy} - /> - -
- )} - - {status === "mfa_required" && ( -
- setCode(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && submitMFA()} - disabled={busy} - autoFocus - /> - -
- )} - - {status === "authenticated" && ( - <> -

Connected to Garmin.

- - - )} - - {auth?.message && status !== "authenticated" &&

{auth.message}

} - {error &&

{error}

} - -
- -
-
- ); -} diff --git a/frontend/src/CreateProfile.tsx b/frontend/src/CreateProfile.tsx deleted file mode 100644 index 4618f69..0000000 --- a/frontend/src/CreateProfile.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { useState } from "react"; -import { api, BASE_URL } from "./api/client"; -import "./CreateProfile.css"; - -// Shown once, right after a brand-new OIDC login, before the account has a -// geniusrun profile at all. Only asks for a display name -- Garmin -// credentials are collected next, on the ConnectGarmin screen (see -// LoginGate), which every other tunable is still filled in afterward via -// the existing Profile screen. -export function CreateProfile({ onCreated }: { onCreated: (displayName: string) => void }) { - const [displayName, setDisplayName] = useState(""); - const [submitting, setSubmitting] = useState(false); - const [error, setError] = useState(null); - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - const trimmed = displayName.trim(); - if (!trimmed) { - setError("Please enter a display name."); - return; - } - setSubmitting(true); - setError(null); - try { - const result = await api.setup(trimmed); - onCreated(result.display_name); - } catch (err) { - setError(err instanceof Error ? err.message : "Something went wrong, please try again."); - setSubmitting(false); - } - }; - - return ( -
-

🧞‍♀️ Welcome to geniusrun

-

Let's set up your profile. You'll connect your Garmin account next.

-
- setDisplayName(e.target.value)} - disabled={submitting} - autoFocus - /> - {error &&

{error}

} - -
-
- -
-
- ); -} diff --git a/frontend/src/LoginGate.tsx b/frontend/src/LoginGate.tsx index b533cd1..52ce90d 100644 --- a/frontend/src/LoginGate.tsx +++ b/frontend/src/LoginGate.tsx @@ -2,8 +2,7 @@ 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 { OnboardingWizard } from "./OnboardingWizard"; import type { SessionInfo } from "./types/api"; type Status = "loading" | "authenticated" | "unauthenticated"; @@ -18,10 +17,11 @@ const AUTH_ERROR_MESSAGES: Record = { // 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. +// 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. export function LoginGate() { const [status, setStatus] = useState("loading"); const [session, setSession] = useState(null); @@ -55,15 +55,11 @@ export function LoginGate() { if (!session!.has_profile) { return ( - setSession((s) => (s ? { ...s, has_profile: true, display_name: displayName } : s))} /> ); } - if (!session!.garmin_connected) { - return setSession((s) => (s ? { ...s, garmin_connected: true } : s))} />; - } - return ; } diff --git a/frontend/src/CreateProfile.css b/frontend/src/OnboardingWizard.css similarity index 67% rename from frontend/src/CreateProfile.css rename to frontend/src/OnboardingWizard.css index 3b3698b..fecba5a 100644 --- a/frontend/src/CreateProfile.css +++ b/frontend/src/OnboardingWizard.css @@ -1,4 +1,4 @@ -.create-profile { +.onboarding-wizard { display: flex; flex-direction: column; align-items: center; @@ -8,7 +8,7 @@ text-align: center; } -.create-profile form { +.onboarding-wizard form { display: flex; flex-direction: column; gap: 0.75rem; @@ -16,14 +16,14 @@ max-width: 320px; } -.create-profile input { +.onboarding-wizard input { padding: 0.6rem 0.8rem; font-size: 1rem; border-radius: 0.5rem; border: 1px solid #ccc; } -.create-profile button { +.onboarding-wizard button { padding: 0.6rem 0.8rem; font-size: 1rem; border-radius: 0.5rem; @@ -33,24 +33,33 @@ cursor: pointer; } -.create-profile button:disabled { +.onboarding-wizard button:disabled { opacity: 0.6; cursor: not-allowed; } -.create-profile-error { +.onboarding-wizard-error { color: #ef4444; } -.create-profile-message { +.onboarding-wizard-message { margin: 0; color: #9ca3af; } -.create-profile-mfa { +.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; +} diff --git a/frontend/src/OnboardingWizard.tsx b/frontend/src/OnboardingWizard.tsx new file mode 100644 index 0000000..5799584 --- /dev/null +++ b/frontend/src/OnboardingWizard.tsx @@ -0,0 +1,187 @@ +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("name"); + const [displayName, setDisplayName] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [code, setCode] = useState(""); + const [auth, setAuth] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(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 ( +
+

🧞‍♀️ Welcome to geniusrun

+

Let's set up your profile. You'll connect your Garmin account next.

+
+ setDisplayName(e.target.value)} + autoFocus + /> + {error &&

{error}

} + +
+
+ +
+
+ ); + } + + return ( +
+

🧞‍♀️ Connect your Garmin account

+

geniusrun needs your Garmin credentials to sync your activities.

+ + {auth?.status === "authenticated" ? ( + <> +

Connected to Garmin.

+ + + ) : auth?.status === "mfa_required" ? ( +
+ setCode(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && submitMFA()} + disabled={busy} + autoFocus + /> + {auth.message &&

{auth.message}

} + {error &&

{error}

} +
+ + +
+
+ ) : ( +
+ setEmail(e.target.value)} + disabled={busy} + autoFocus + /> + setPassword(e.target.value)} + disabled={busy} + /> + {auth?.message &&

{auth.message}

} + {error &&

{error}

} +
+ + +
+
+ )} + +
+ +
+
+ ); +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 3ad2121..41004d8 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -46,8 +46,20 @@ export const api = { // browser navigation. Logout must POST (see server.go's route), which an // can't do, hence the form. getSessionInfo: () => request("/api/session/me"), - setup: (displayName: string) => - request<{ user_id: number; display_name: string }>("/api/setup", { + // 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("/api/setup/garmin/login", { + method: "POST", + body: JSON.stringify({ garmin_email: garminEmail, garmin_password: garminPassword }), + }), + setupGarminMFA: (code: string) => + request("/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 }), }),