feat(integrations): add Polar AccessLink OAuth sync

Connect a Polar account over AccessLink v3: an authorization-code OAuth
flow (shared app from POLAR_CLIENT_ID / POLAR_CLIENT_SECRET) backed by a
stateless HMAC-signed state token doubling as an httpOnly cookie for CSRF.
The callback verifies the signature, expiry, provider binding, and the
byte-exact cookie match, then encrypts the access token plus member id onto
the user row.

An hourly poll-cohort job maps Nightly Recharge recovery, sleep stages,
and daily activity to canonical measurements tagged source POLAR, idempotent
on (userId, type, source, externalId). The recovery band feeds RECOVERY_SCORE
through the canonical ladder, which now ranks native readiness above the
computed proxy. Settings gains a connect / disconnect card. Tokens are never
logged or returned; every egress goes through safeFetch.
This commit is contained in:
Marc-André Bombeck committed 2026-06-14 20:08:50 +02:00
1 parent e561f8d893
commit 31abd307fd
31 files changed
+2341 -13

No files matched your search

@@ -131,9 +131,10 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
});
const html = render();
// Exactly one pill per card → 4 pills total (Withings, WHOOP,
// Fitbit / Google Health, Nightscout). The moodLog integration was removed.
expect(count(html, 'data-testid="integration-status-pill"')).toBe(4);
// Exactly one pill per card → 5 pills total (Withings, WHOOP,
// Fitbit / Google Health, Polar, Nightscout). The moodLog integration was
// removed; Polar (F4) was added in v1.17.0.
expect(count(html, 'data-testid="integration-status-pill"')).toBe(5);
// The redundant banner from v1.4.15 is gone.
expect(html).not.toContain('data-testid="integration-status-banner"');
// Card-body "letzter Sync" repetition is gone — no
@@ -8,6 +8,7 @@ import { toast } from "sonner";
import { FitbitCard } from "@/components/settings/integrations/fitbit-card";
import { NightscoutCard } from "@/components/settings/integrations/nightscout-card";
import { PolarCard } from "@/components/settings/integrations/polar-card";
import {
pickStatus,
useIntegrationStatuses,
@@ -90,6 +91,58 @@ export function IntegrationsSection() {
}
}, [withingsOauthOutcome, router, queryClient, t]);
// v1.17.0 (F4) — generic OAuth-callback toast for the env-based providers.
// The Polar callback redirects back with `?polar=connected` or
// `?polar=error&reason=<tag>`; surface the outcome as a toast and scrub the
// one-shot params so a reload doesn't replay it.
const [oauthOutcome] = useState<
{ provider: "polar"; kind: "connected" } | { provider: "polar"; kind: "error"; reason: string } | null
>(() => {
if (typeof window === "undefined") return null;
const params = new URLSearchParams(window.location.search);
for (const provider of ["polar"] as const) {
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;
});
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 });
const key = queryKeys.polar();
if (oauthOutcome.kind === "connected") {
toast.success(t(`settings.${provider}OauthConnected`));
queryClient.invalidateQueries({ queryKey: key });
} else {
// Known reason tags resolve to a specific message; anything else falls
// back to the generic copy (matching the Withings handler).
const knownReasons = new Set([
"csrf1",
"state",
"cross_user",
"nocode",
"nocreds",
"token",
"rate_limited",
]);
const reasonKey = knownReasons.has(oauthOutcome.reason)
? `settings.${provider}OauthError.${oauthOutcome.reason}`
: `settings.${provider}OauthError.generic`;
toast.error(t(`settings.${provider}OauthFailed`), {
description: t(reasonKey),
duration: 10_000,
});
}
}, [oauthOutcome, router, queryClient, t]);
const withingsViewModel = pickStatus(integrationStatus, "withings");
const whoopViewModel = pickStatus(integrationStatus, "whoop");
const fitbitViewModel = pickStatus(integrationStatus, "fitbit");
@@ -125,6 +178,7 @@ export function IntegrationsSection() {
<WithingsCard viewModel={withingsViewModel} />
<WhoopCard viewModel={whoopViewModel} />
<FitbitCard viewModel={fitbitViewModel} />
<PolarCard enabled={isAuthenticated} />
<NightscoutCard enabled={isAuthenticated} />
</section>
);
@@ -0,0 +1,213 @@
"use client";
// v1.17.0 (F4) — shared OAuth integration card for env-based providers (Polar,
// Oura). Unlike WHOOP / Fitbit (per-user BYO client id/secret), these use a
// single shared OAuth app the operator configures via env, so the card has NO
// credential inputs — just a connect button that redirects to the provider, a
// status pill off the self-contained status read, and a disconnect action.
// Mirrors the Nightscout card's self-contained status pattern.
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Activity, Link2, Unlink } from "lucide-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import {
IntegrationStatusPill,
type IntegrationPillState,
} from "@/components/settings/integration-status-pill";
import { apiGet, apiPost } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { invalidateKeys, measurementDependentKeys } from "@/lib/query-keys";
export interface OAuthProviderStatus {
connected: boolean;
configured: boolean;
available: boolean;
state?:
| "connected"
| "error_transient"
| "error_reauth"
| "disconnected"
| "parked";
lastSuccessAt?: string | null;
lastAttemptAt?: string | null;
lastError?: string | null;
}
function pillStateFor(status: OAuthProviderStatus | undefined): IntegrationPillState {
if (!status?.connected) return "disconnected";
switch (status.state) {
case "parked":
return "parked";
case "error_reauth":
return "error";
case "error_transient":
return "warning";
default:
return "connected";
}
}
export interface OAuthProviderCardProps {
/** Lower-case provider key, used for routes + query keys + testids. */
provider: "polar" | "oura";
/** The query-key array shared by the status read + the invalidations. */
statusQueryKey: readonly unknown[];
/** i18n key prefix (e.g. `settings.polar`). */
i18nPrefix: string;
enabled?: boolean;
}
export function OAuthProviderCard({
provider,
statusQueryKey,
i18nPrefix,
enabled = true,
}: OAuthProviderCardProps) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [msg, setMsg] = useState<string | null>(null);
const { data: status } = useQuery({
queryKey: statusQueryKey,
queryFn: async () => apiGet<OAuthProviderStatus>(`/api/${provider}/status`),
enabled,
refetchOnWindowFocus: true,
});
const disconnect = useMutation({
mutationFn: async () => {
await apiPost(`/api/${provider}/disconnect`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: statusQueryKey });
},
});
const pillState = pillStateFor(status);
const errorMessage =
(pillState === "error" || pillState === "parked") && status?.lastError
? status.lastError
: null;
const serverUnavailable = status && !status.available;
function handleConnect() {
setMsg(null);
void invalidateKeys(queryClient, measurementDependentKeys);
window.location.href = `/api/${provider}/connect`;
}
return (
<div
data-testid={`${provider}-card`}
className="bg-card border-border rounded-xl border p-4 sm:p-6"
>
<SettingsCardHeader
icon={Activity}
title={t(`${i18nPrefix}`)}
description={<p>{t(`${i18nPrefix}Description`)}</p>}
status={
<IntegrationStatusPill
state={pillState}
lastSyncAt={status?.lastSuccessAt ?? null}
/>
}
/>
<hr className="border-border/60 mt-4" />
<div className="mt-4 space-y-4 pl-7">
{errorMessage && (
<p
role="alert"
data-testid={`${provider}-error`}
className="text-destructive text-sm break-words"
>
{errorMessage}
</p>
)}
<p className="text-muted-foreground text-xs">
{t(`${i18nPrefix}Help`)}
</p>
{serverUnavailable && (
<p
className="text-muted-foreground/80 text-xs"
data-testid={`${provider}-unavailable`}
>
{t(`${i18nPrefix}Unavailable`)}
</p>
)}
{status?.connected ? (
<div className="flex flex-wrap items-start gap-2 [&>*]:min-w-[10rem] sm:[&>*]:min-w-0">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="text-destructive min-h-11"
data-testid={`${provider}-disconnect`}
>
<Unlink className="h-3.5 w-3.5" />
{t(`${i18nPrefix}Disconnect`)}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t(`${i18nPrefix}DisconnectTitle`)}
</AlertDialogTitle>
<AlertDialogDescription>
{t(`${i18nPrefix}DisconnectDescription`)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() => disconnect.mutate()}
>
{t(`${i18nPrefix}Disconnect`)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
) : (
<Button
type="button"
size="sm"
className="min-h-11 w-full sm:w-auto"
disabled={serverUnavailable}
onClick={handleConnect}
data-testid={`${provider}-connect`}
>
<Link2 className="h-3.5 w-3.5" />
{t(`${i18nPrefix}Connect`)}
</Button>
)}
{msg && (
<p role="alert" className="text-destructive text-sm">
{msg}
</p>
)}
</div>
</div>
);
}
@@ -0,0 +1,18 @@
"use client";
// v1.17.0 (F4) — Polar AccessLink integration card. Thin wrapper over the
// shared env-based OAuth card.
import { OAuthProviderCard } from "@/components/settings/integrations/oauth-provider-card";
import { queryKeys } from "@/lib/query-keys";
export function PolarCard({ enabled = true }: { enabled?: boolean }) {
return (
<OAuthProviderCard
provider="polar"
statusQueryKey={queryKeys.polar()}
i18nPrefix="settings.polar"
enabled={enabled}
/>
);
}