Files
HealthLog/src/components/settings/integrations/connections-panel.tsx
T
Marc-André Bombeck 1a5b09a73c feat(settings): one integration card for every connection
Every connection in Settings renders the same card from the server's health
field: a badge with one word, one sentence per reason, the newest data as a
meta line, a collapsed details block whose text is exactly what the copy
button copies, and an action row with the rare actions in a menu and at most
one primary button. The per-provider banners, the raw provider error line,
the token expiry, the failure counter and the button rows are gone; every
capability they carried is reachable from the sentence, the one button, the
menu or the details. The managed-profile view renders the same card,
read-only. A structural guard keeps the cards off the retired fields.
2026-10-15 15:06:03 +02:00

400 lines
15 KiB
TypeScript

"use client";
/**
* `<ConnectionsPanel>` — the connected-services half of Settings →
* Integrations: Withings / WHOOP / Fitbit / Polar / Oura / Nightscout.
*
* v1.18.0 (S3) — extracted out of `integrations-section.tsx` so the section
* file can host the Connections / Channels / Sources sub-tabs without growing
* unbounded. This panel owns the OAuth-callback toast handlers (the four
* providers + Withings redirect back here with `?<provider>=…` params) and the
* card grid. It renders no page header of its own; the parent supplies it.
*/
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { AppleHealthCard } from "@/components/settings/integrations/apple-health-card";
import { GarminInfoNote } from "@/components/settings/integrations/garmin-info-note";
import { GoogleHealthCard } from "@/components/settings/integrations/google-health-card";
import { IntegrationFallbackRow } from "@/components/settings/integrations/integration-fallback-row";
import { NightscoutCard } from "@/components/settings/integrations/nightscout-card";
import {
OAUTH_INTEGRATIONS,
OAuthIntegrationCard,
} from "@/components/settings/integrations/oauth-integration-card";
import {
pickStatus,
useIntegrationStatuses,
type IntegrationKey,
} from "@/components/settings/integrations/shared";
import { Badge } from "@/components/ui/badge";
import { TagChip } from "@/components/ui/tag-chip";
import { useAuth } from "@/hooks/use-auth";
import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
/**
* The Withings OAuth callback (`/api/withings/callback`) redirects back
* here with `?withings=connected` or `?withings=error&reason=<tag>`.
* Map every reason tag the callback emits onto a human-readable i18n
* key (what went wrong + what to do next). Unknown tags fall back to
* the generic entry so a future callback branch never strands the user
* with silent params.
*/
const WITHINGS_OAUTH_ERROR_KEYS: Record<string, string> = {
csrf1: "settings.withingsOauthError.csrf1",
replay: "settings.withingsOauthError.replay",
state: "settings.withingsOauthError.state",
expired: "settings.withingsOauthError.expired",
cross_user: "settings.withingsOauthError.cross_user",
nocode: "settings.withingsOauthError.nocode",
nocreds: "settings.withingsOauthError.nocreds",
token: "settings.withingsOauthError.token",
};
type WithingsOauthOutcome =
{ kind: "connected" } | { kind: "error"; reason: string };
/**
* The OAuth providers whose callbacks redirect back to the settings page with a
* `?<provider>=connected|error&reason=<tag>` outcome param. Polar/Oura own a
* per-card status query; WHOOP/Fitbit read off the consolidated envelope — both
* are invalidated on a successful return so the card repaints either way.
*/
// v1.27.0 — `googleHealth` joins the outcome set. Its callback redirects with
// `?googleHealth=connected|error&reason=<tag>` (the fitbit→googleHealth
// identifier rename), and the i18n toast keys resolve under the same camelCase
// prefix (`settings.googleHealthOauth*`).
const OAUTH_OUTCOME_PROVIDERS = [
"polar",
"oura",
"whoop",
"fitbit",
"googleHealth",
// v1.28.x — Strava callback redirects with `?strava=connected|error&reason=…`.
"strava",
] as const;
type OAuthOutcomeProvider = (typeof OAUTH_OUTCOME_PROVIDERS)[number];
const OAUTH_OUTCOME_KEYS: Record<
OAuthOutcomeProvider,
() => readonly unknown[]
> = {
polar: queryKeys.polar,
oura: queryKeys.oura,
whoop: queryKeys.whoop,
fitbit: queryKeys.fitbit,
googleHealth: queryKeys.googleHealth,
strava: queryKeys.strava,
};
/**
* v1.32.36 — the error-copy subtree per provider, spelled out rather than
* interpolated from the provider id. Building the key from the bare `settings`
* root left the reverse-i18n guard with no anchor deeper than the namespace,
* which marked every key under `settings.*` reachable and let genuinely dead
* copy sit in the bundle unnoticed. Written out, each base is a full node path
* the guard resolves on its own.
*/
const OAUTH_ERROR_KEY_BASE: Record<OAuthOutcomeProvider, string> = {
polar: "settings.polarOauthError",
oura: "settings.ouraOauthError",
whoop: "settings.whoopOauthError",
fitbit: "settings.fitbitOauthError",
googleHealth: "settings.googleHealthOauthError",
strava: "settings.stravaOauthError",
};
/**
* Reason tags the four OAuth callbacks emit. Known tags resolve to a specific
* message; anything else falls back to the provider's `generic` copy. Union of
* the Polar/Oura set (`rate_limited`) and the WHOOP/Fitbit set (`expired`).
*/
const OAUTH_OUTCOME_REASONS = new Set([
"csrf1",
"state",
"cross_user",
"nocode",
"nocreds",
"token",
"rate_limited",
"expired",
]);
/**
* Reason tags only one provider's callback emits, with copy under that
* provider's subtree alone. Oura's `issuer`: the authorization response named
* an issuer that is not Oura's (RFC 9207), so the callback refused the code.
*/
const PROVIDER_ONLY_REASONS: Partial<
Record<OAuthOutcomeProvider, ReadonlySet<string>>
> = {
oura: new Set(["issuer"]),
};
type OAuthOutcome =
| { provider: OAuthOutcomeProvider; kind: "connected" }
| { provider: OAuthOutcomeProvider; kind: "error"; reason: string };
/**
* Parse the OAuth-return outcome from a URL query string. Reads the first
* provider whose `?<provider>=connected|error` param is present, in
* `OAUTH_OUTCOME_PROVIDERS` order. Pure + exported so the four-provider
* coverage is unit-testable without a browser.
*/
export function parseOAuthOutcome(search: string): OAuthOutcome | null {
const params = new URLSearchParams(search);
for (const provider of OAUTH_OUTCOME_PROVIDERS) {
const v = params.get(provider);
if (v === "connected") return { provider, kind: "connected" };
if (v === "error") {
return {
provider,
kind: "error",
reason: params.get("reason") ?? "unknown",
};
}
}
return null;
}
/**
* Resolve the i18n key for an error reason tag. Known tags map to the
* provider-specific message; anything else falls back to `generic`.
*/
export function oauthReasonKey(
provider: OAuthOutcomeProvider,
reason: string,
): string {
const base = OAUTH_ERROR_KEY_BASE[provider];
return OAUTH_OUTCOME_REASONS.has(reason) ||
PROVIDER_ONLY_REASONS[provider]?.has(reason)
? `${base}.${reason}`
: `${base}.generic`;
}
/**
* The keys a bespoke card on this panel renders. Every OTHER configured
* envelope entry falls through to `IntegrationFallbackRow` — the panel's unit
* of coverage is the envelope entry, not the card, so a provider can never
* again be shipped on the wire and rendered nowhere.
*/
const CARD_BACKED_INTEGRATIONS: ReadonlySet<IntegrationKey> = new Set([
"withings",
"whoop",
"fitbit",
"google-health",
"polar",
"oura",
"strava",
"nightscout",
]);
export function ConnectionsPanel({
callbackUrls,
}: {
/**
* The per-provider OAuth callback URLs, resolved on the server per request
* (`getIntegrationCallbackUrls`) and threaded down from the route. A client
* module cannot derive them: `NEXT_PUBLIC_APP_URL` is inlined at build
* time and absent from the published image.
*/
callbackUrls: IntegrationCallbackUrls;
}) {
const { t } = useTranslations();
const { isAuthenticated } = useAuth();
const router = useRouter();
const queryClient = useQueryClient();
const { data: integrationStatus } = useIntegrationStatuses(isAuthenticated);
// OAuth callback handler — reads `?withings=connected|error&reason=…`
// from the URL (lazy initialiser, same shape as the Codex handler in
// `ai-section.tsx`) and surfaces the outcome as a toast. Pre-fix the
// callback set these params and nothing ever read them: a user came
// back from Withings onto a silently unchanged settings page.
const [withingsOauthOutcome] = useState<WithingsOauthOutcome | null>(() => {
if (typeof window === "undefined") return null;
const params = new URLSearchParams(window.location.search);
const status = params.get("withings");
if (status === "connected") return { kind: "connected" };
if (status === "error") {
return { kind: "error", reason: params.get("reason") ?? "unknown" };
}
return null;
});
useEffect(() => {
if (!withingsOauthOutcome) return;
// Scrub the one-shot params so a reload / bookmark doesn't replay
// the toast.
const url = new URL(window.location.href);
url.searchParams.delete("withings");
url.searchParams.delete("reason");
router.replace(`${url.pathname}${url.search}`, { scroll: false });
if (withingsOauthOutcome.kind === "connected") {
toast.success(t("settings.withingsOauthConnected"));
queryClient.invalidateQueries({ queryKey: queryKeys.withings() });
queryClient.invalidateQueries({
queryKey: queryKeys.integrationsStatus(),
});
} else {
const reasonKey =
WITHINGS_OAUTH_ERROR_KEYS[withingsOauthOutcome.reason] ??
"settings.withingsOauthError.generic";
toast.error(t("settings.withingsOauthFailed"), {
description: t(reasonKey),
duration: 10_000,
});
}
}, [withingsOauthOutcome, router, queryClient, t]);
// v1.17.0 (F4) — generic OAuth-callback toast for the OAuth providers.
// The Polar / Oura / WHOOP / Fitbit callbacks redirect back with
// `?<provider>=connected` or `?<provider>=error&reason=<tag>`; surface the
// outcome as a toast and scrub the one-shot params so a reload doesn't replay
// it. Pre-v1.17.1 only Polar/Oura were read here — a user returning from a
// WHOOP or Fitbit round-trip landed on a silently unchanged settings page,
// the same gap the Withings handler was written to close.
const [oauthOutcome] = useState<OAuthOutcome | null>(() => {
if (typeof window === "undefined") return null;
return parseOAuthOutcome(window.location.search);
});
useEffect(() => {
if (!oauthOutcome) return;
const { provider } = oauthOutcome;
const url = new URL(window.location.href);
url.searchParams.delete(provider);
url.searchParams.delete("reason");
router.replace(`${url.pathname}${url.search}`, { scroll: false });
if (oauthOutcome.kind === "connected") {
toast.success(t(`settings.${provider}OauthConnected`));
// Polar/Oura own a per-card status query; WHOOP/Fitbit read off the
// consolidated envelope — invalidate both so the card repaints either way.
queryClient.invalidateQueries({
queryKey: OAUTH_OUTCOME_KEYS[provider](),
});
queryClient.invalidateQueries({
queryKey: queryKeys.integrationsStatus(),
});
} else {
// Known reason tags resolve to a specific message; anything else falls
// back to the generic copy (matching the Withings handler).
toast.error(t(`settings.${provider}OauthFailed`), {
description: t(oauthReasonKey(provider, oauthOutcome.reason)),
duration: 10_000,
});
}
}, [oauthOutcome, router, queryClient, t]);
const unclaimedIntegrations = (integrationStatus?.integrations ?? [])
.filter(
(entry) =>
!CARD_BACKED_INTEGRATIONS.has(entry.integration) &&
(entry.configured || entry.connected),
)
.map((entry) => ({
...entry,
failureThreshold: integrationStatus?.threshold,
}));
return (
<div className="space-y-6" data-slot="connections-panel">
{/* Per-provider anchors so deep-links (onboarding "more sources" chips,
`/settings/integrations#oura`) scroll straight to the right card. */}
<div id="withings" className="scroll-mt-28">
<OAuthIntegrationCard
config={OAUTH_INTEGRATIONS.withings}
viewModel={pickStatus(integrationStatus, "withings")}
callbackUrl={callbackUrls.withings}
/>
</div>
<div id="whoop" className="scroll-mt-28">
<OAuthIntegrationCard
config={OAUTH_INTEGRATIONS.whoop}
viewModel={pickStatus(integrationStatus, "whoop")}
callbackUrl={callbackUrls.whoop}
/>
</div>
<div id="fitbit" className="scroll-mt-28">
<OAuthIntegrationCard
config={OAUTH_INTEGRATIONS.fitbit}
viewModel={pickStatus(integrationStatus, "fitbit")}
callbackUrl={callbackUrls.fitbit}
titleAccessory={
<>
<TagChip>{t("settings.fitbitTag")}</TagChip>
<Badge
variant="outline"
data-testid="fitbit-experimental-badge"
className="border-warning/50 text-warning"
>
{t("settings.fitbitExperimentalBadge")}
</Badge>
</>
}
/>
</div>
<div id="google-health" className="scroll-mt-28">
<GoogleHealthCard
viewModel={pickStatus(integrationStatus, "google-health")}
callbackUrl={callbackUrls["google-health"]}
/>
</div>
<div id="apple-health" className="scroll-mt-28">
<AppleHealthCard enabled={isAuthenticated} />
</div>
<div id="polar" className="scroll-mt-28">
<OAuthIntegrationCard
config={OAUTH_INTEGRATIONS.polar}
viewModel={pickStatus(integrationStatus, "polar")}
callbackUrl={callbackUrls.polar}
/>
</div>
<div id="oura" className="scroll-mt-28">
<OAuthIntegrationCard
config={OAUTH_INTEGRATIONS.oura}
viewModel={pickStatus(integrationStatus, "oura")}
callbackUrl={callbackUrls.oura}
/>
</div>
<div id="strava" className="scroll-mt-28">
<OAuthIntegrationCard
config={OAUTH_INTEGRATIONS.strava}
viewModel={pickStatus(integrationStatus, "strava")}
callbackUrl={callbackUrls.strava}
/>
</div>
<div id="nightscout" className="scroll-mt-28">
<NightscoutCard
viewModel={pickStatus(integrationStatus, "nightscout")}
/>
</div>
{/* Every configured envelope entry no card above claims. Normally this
renders nothing; on an account carrying a dropped-but-still-running
integration it renders the honest status that provider otherwise has
nowhere to appear. */}
{unclaimedIntegrations.map((entry) => (
<div
key={entry.integration}
id={entry.integration}
className="scroll-mt-28"
>
<IntegrationFallbackRow status={entry} />
</div>
))}
{/* Garmin: no direct connector (business-partner-only). A quiet
non-connector note pointing to the Apple Health / Health Connect
path — not an OAuthProviderCard (nothing to connect). */}
<div id="garmin" className="scroll-mt-28">
<GarminInfoNote />
</div>
</div>
);
}