diff --git a/frontend/src/ConnectGarmin.tsx b/frontend/src/ConnectGarmin.tsx new file mode 100644 index 0000000..555ca10 --- /dev/null +++ b/frontend/src/ConnectGarmin.tsx @@ -0,0 +1,117 @@ +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.css b/frontend/src/CreateProfile.css index 01bda25..3b3698b 100644 --- a/frontend/src/CreateProfile.css +++ b/frontend/src/CreateProfile.css @@ -41,3 +41,16 @@ .create-profile-error { color: #ef4444; } + +.create-profile-message { + margin: 0; + color: #9ca3af; +} + +.create-profile-mfa { + display: flex; + flex-direction: column; + gap: 0.75rem; + width: 100%; + max-width: 320px; +} diff --git a/frontend/src/CreateProfile.tsx b/frontend/src/CreateProfile.tsx index ae621b3..4618f69 100644 --- a/frontend/src/CreateProfile.tsx +++ b/frontend/src/CreateProfile.tsx @@ -1,11 +1,12 @@ import { useState } from "react"; -import { api } from "./api/client"; +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. The only thing asked for is a display name -- -// Garmin credentials and every other tunable are filled in afterward via -// the existing Profile screen, same as a fresh single-user install today. +// 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); @@ -32,7 +33,7 @@ export function CreateProfile({ onCreated }: { onCreated: (displayName: string) return (

🧞‍♀️ Welcome to geniusrun

-

Let's set up your profile. You can add your Garmin account afterward.

+

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

+
+ +
); } diff --git a/frontend/src/LoginGate.tsx b/frontend/src/LoginGate.tsx index 73e63f1..b533cd1 100644 --- a/frontend/src/LoginGate.tsx +++ b/frontend/src/LoginGate.tsx @@ -2,6 +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 type { SessionInfo } from "./types/api"; @@ -17,7 +18,10 @@ 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. +// 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. export function LoginGate() { const [status, setStatus] = useState("loading"); const [session, setSession] = useState(null); @@ -57,5 +61,9 @@ export function LoginGate() { ); } + if (!session!.garmin_connected) { + return setSession((s) => (s ? { ...s, garmin_connected: true } : s))} />; + } + return ; } diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 0c2359d..fb7f7e2 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -191,6 +191,7 @@ export interface SessionInfo { email: string; has_profile: boolean; display_name?: string; + garmin_connected: boolean; } export interface DetailFillProgress {