mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
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:
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user