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>
151 lines
5.0 KiB
TypeScript
151 lines
5.0 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import { api } from "../api/client";
|
|
import { showError, showSuccess } from "../banner";
|
|
import type { AuthResponse } from "../types/api";
|
|
import { GarminMFAModal } from "./GarminMFAModal";
|
|
import { SyncModal } from "./SyncModal";
|
|
|
|
export function GarminConnection({ onBeforeConnect }: { onBeforeConnect?: () => Promise<void> }) {
|
|
const [auth, setAuth] = useState<AuthResponse | null>(null);
|
|
const [busy, setBusy] = useState(false);
|
|
// There's no real Garmin logout -- the backend session just sits idle.
|
|
// "Disconnect" only hides that and shows Connect again locally; a real
|
|
// login (connect()) clears it.
|
|
const [disconnected, setDisconnected] = useState(false);
|
|
const [showSyncModal, setShowSyncModal] = useState(false);
|
|
|
|
// Auth status alone -- sync status is polled by SyncModal itself while
|
|
// it's open, so this no longer needs the "poll faster while syncing"
|
|
// dynamic interval it used to have.
|
|
useEffect(() => {
|
|
api.garminStatus().then(setAuth).catch((e) => showError(String(e)));
|
|
const interval = setInterval(() => {
|
|
api.garminStatus().then(setAuth).catch(() => {});
|
|
}, 6000);
|
|
return () => clearInterval(interval);
|
|
}, []);
|
|
|
|
async function connect() {
|
|
setBusy(true);
|
|
try {
|
|
await onBeforeConnect?.();
|
|
const result = await api.garminLogin();
|
|
setAuth(result);
|
|
setDisconnected(false);
|
|
if (result.status === "authenticated") showSuccess("Connected to Garmin successfully.");
|
|
} catch (e) {
|
|
showError(String(e));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
function disconnect() {
|
|
setDisconnected(true);
|
|
}
|
|
|
|
async function submitMFA(code: string) {
|
|
if (!code.trim()) return;
|
|
setBusy(true);
|
|
try {
|
|
const result = await api.garminMFA(code.trim());
|
|
setAuth(result);
|
|
if (result.status === "authenticated") showSuccess("Connected to Garmin successfully.");
|
|
} catch (e) {
|
|
showError(String(e));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
async function sync() {
|
|
setBusy(true);
|
|
try {
|
|
await api.garminSyncRun();
|
|
setShowSyncModal(true);
|
|
} catch (e) {
|
|
showError(String(e));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
async function resetAll() {
|
|
if (!window.confirm("This deletes every synced activity, lap, and kind assignment, then starts a fresh pull from Garmin next sync. This cannot be undone. Continue?")) {
|
|
return;
|
|
}
|
|
setBusy(true);
|
|
try {
|
|
await api.garminSyncReset();
|
|
// Reset runs as a background sync job; wait for it to actually finish
|
|
// before reloading, otherwise other pages (e.g. Activities) would
|
|
// still show the just-deleted activities from their own stale state.
|
|
let status = await api.garminSyncStatus();
|
|
while (status.in_progress) {
|
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
status = await api.garminSyncStatus();
|
|
}
|
|
window.location.reload();
|
|
} catch (e) {
|
|
showError(String(e));
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
const status = disconnected ? "unknown" : auth?.status ?? "unknown";
|
|
|
|
return (
|
|
<div className="garmin-connection">
|
|
<div className="garmin-connection-row garmin-connection-main">
|
|
<div className="garmin-connection-actions">
|
|
{status !== "authenticated" && status !== "mfa_required" && (
|
|
<button disabled={busy} onClick={connect}>
|
|
Connect
|
|
</button>
|
|
)}
|
|
|
|
{status === "authenticated" && (
|
|
<>
|
|
<button disabled={busy} onClick={sync}>
|
|
Sync now
|
|
</button>
|
|
<button disabled={busy} onClick={disconnect}>
|
|
Disconnect
|
|
</button>
|
|
</>
|
|
)}
|
|
|
|
{/* Reset all only wipes local DB state (see handleSyncReset) -- it
|
|
doesn't touch the Garmin session, so it stays available even
|
|
while disconnected/not-yet-connected. */}
|
|
<button className="button-danger" disabled={busy} onClick={resetAll}>
|
|
Reset all
|
|
</button>
|
|
</div>
|
|
|
|
<div className="garmin-connection-status">
|
|
<span className={`status-dot status-${status}`} />
|
|
{/* Connected/not-connected is already conveyed by the
|
|
Connect/Disconnect button itself -- only MFA/failed need a
|
|
label, since no button distinguishes those from "not connected". */}
|
|
{(status === "mfa_required" || status === "failed") && (
|
|
<span className="status-label">
|
|
{status === "mfa_required" ? "MFA code required" : "Connection failed"}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{status === "mfa_required" && (
|
|
<GarminMFAModal message={auth?.message} busy={busy} onSubmit={submitMFA} onCancel={disconnect} />
|
|
)}
|
|
|
|
{!disconnected && auth?.message && status !== "authenticated" && (
|
|
<p className="garmin-connection-message">{auth.message}</p>
|
|
)}
|
|
|
|
{showSyncModal && <SyncModal onClose={() => setShowSyncModal(false)} />}
|
|
</div>
|
|
);
|
|
}
|