From b17a25089422dca1faa41237549fab738a04bd15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 15 Oct 2026 15:06:02 +0200 Subject: [PATCH] feat(record-settings): publish connection health for a managed record's integrations The guardian's read of a managed record's connections now carries the same server-resolved health the owner's own envelope does, loaded for the record and never for the guardian. The legacy state stays on the wire for one transition release. --- docs/api/openapi.yaml | 26 +- .../api/record-settings/integrations/route.ts | 9 + .../managed-integration-status.test.tsx | 133 ++++ .../__tests__/cards-render-health.test.ts | 160 +++++ .../integrations/__tests__/health-fixture.ts | 25 + .../__tests__/integration-card.test.tsx | 403 ++++++++++++ .../__tests__/integration-health-copy.test.ts | 275 ++++++++ .../integrations/credentials-form.tsx | 175 +++++ .../integrations/integration-actions.tsx | 208 ++++++ .../integrations/integration-card.tsx | 603 ++++++++++++++++++ .../integrations/integration-health-copy.ts | 358 +++++++++++ .../integrations/metric-freshness-list.tsx | 162 +++++ .../integrations/oauth-integration-card.tsx | 539 ++++++++++++++++ src/lib/openapi/routes/integrations.ts | 2 +- src/lib/openapi/routes/record-settings.ts | 10 +- ...ecord-settings-integrations-health.test.ts | 125 ++++ 16 files changed, 3201 insertions(+), 12 deletions(-) create mode 100644 src/components/settings/__tests__/managed-integration-status.test.tsx create mode 100644 src/components/settings/integrations/__tests__/cards-render-health.test.ts create mode 100644 src/components/settings/integrations/__tests__/health-fixture.ts create mode 100644 src/components/settings/integrations/__tests__/integration-card.test.tsx create mode 100644 src/components/settings/integrations/__tests__/integration-health-copy.test.ts create mode 100644 src/components/settings/integrations/credentials-form.tsx create mode 100644 src/components/settings/integrations/integration-actions.tsx create mode 100644 src/components/settings/integrations/integration-card.tsx create mode 100644 src/components/settings/integrations/integration-health-copy.ts create mode 100644 src/components/settings/integrations/metric-freshness-list.tsx create mode 100644 src/components/settings/integrations/oauth-integration-card.tsx create mode 100644 tests/integration/record-settings-integrations-health.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 17fd03bc0..133ffc152 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -22032,10 +22032,11 @@ paths: tags: - Account sharing summary: Read the managed record's integration connection states - description: "Disclosure only: whether each of the record's integrations is connected and when it last succeeded. There - is deliberately no control arm and no credential projection anywhere under this path — a Guardian cannot - connect, disconnect, probe or resume a managed record's provider, because an OAuth flow cannot safely carry a - record selector. Cookie session or Bearer, while acting on a managed record the caller guards." + description: "Disclosure only: whether each of the record's integrations is connected, when it last succeeded, and its + server-resolved health. There is deliberately no control arm and no credential projection anywhere under this + path — a Guardian cannot connect, disconnect, probe or resume a managed record's provider, because an OAuth flow + cannot safely carry a record selector. Cookie session or Bearer, while acting on a managed record the caller + guards." responses: "200": description: Connection state per provider. @@ -79496,11 +79497,18 @@ components: format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" + health: + anyOf: + - $ref: "#/components/schemas/ConnectionHealth" + - type: "null" + description: The same server-resolved connection state the owner's own envelope carries, null while the provider is not + connected. The web renders this; `state` stays for one transition release. required: - integration - state - lastSuccessAt - lastAttemptAt + - health additionalProperties: false description: All eight providers, always, in the fixed order withings, whoop, fitbit, nightscout, polar, oura, google-health, strava. Note the order differs from the owner's own `/api/integrations/status` envelope. @@ -79508,11 +79516,11 @@ components: - recordId - integrations additionalProperties: false - description: Whether the managed record's integrations are connected, and nothing else. Deliberately no control and no - credential projection — OAuth and provider state cannot safely carry a record selector, so a Guardian can see - that a pipe exists but cannot connect, disconnect, probe or resume it from here. A provider with no real - credential reads `disconnected` even when its ledger row says otherwise, because a synthesised healthy ledger is - not proof of a connection. + description: Whether the managed record's integrations are connected and how each is doing (`health`), and nothing else. + Deliberately no control and no credential projection — OAuth and provider state cannot safely carry a record + selector, so a Guardian can see that a pipe exists but cannot connect, disconnect, probe or resume it from here. + A provider with no real credential reads `disconnected` even when its ledger row says otherwise, because a + synthesised healthy ledger is not proof of a connection. GetRecordSettingsFamilyEnvelope: type: object properties: diff --git a/src/app/api/record-settings/integrations/route.ts b/src/app/api/record-settings/integrations/route.ts index 295e24733..f649858eb 100644 --- a/src/app/api/record-settings/integrations/route.ts +++ b/src/app/api/record-settings/integrations/route.ts @@ -9,6 +9,7 @@ import { getIntegrationStatus, type IntegrationKey, } from "@/lib/integrations/status"; +import { loadConnectionHealth } from "@/lib/integrations/connection-health-load"; import { annotate } from "@/lib/logging/context"; import { resolveGuardianRecordSettingsAccess, @@ -70,6 +71,13 @@ export const GET = apiHandler(async () => { oura: context.user.ouraAccessTokenEncrypted !== null, strava: context.user.stravaAccessTokenEncrypted !== null, }; + // The one server-resolved state per connection, from the same loader the + // owner's own envelope uses, so a guardian reads the record's connections in + // the words the record's owner would. + const health = await loadConnectionHealth( + access.recordId, + MANAGED_INTEGRATIONS, + ); const integrations = statuses.map((status) => ({ integration: status.integration, state: resolveManagedIntegrationState( @@ -79,6 +87,7 @@ export const GET = apiHandler(async () => { ), lastSuccessAt: status.lastSuccessAt, lastAttemptAt: status.lastAttemptAt, + health: health[status.integration], })); annotate({ action: { name: "record-settings.integrations.get" } }); diff --git a/src/components/settings/__tests__/managed-integration-status.test.tsx b/src/components/settings/__tests__/managed-integration-status.test.tsx new file mode 100644 index 000000000..8cc091ea6 --- /dev/null +++ b/src/components/settings/__tests__/managed-integration-status.test.tsx @@ -0,0 +1,133 @@ +/** + * A managed record's connections on the shared integration card, read-only. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import type { ConnectionHealth } from "@/lib/integrations/connection-health"; + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: { + id: "guardian", + accountAccess: { + active: { + accountId: "record-1", + recordKind: "managed", + displayName: "Managed record", + }, + }, + }, + isAuthenticated: true, + isLoading: false, + }), +})); + +let payload: unknown; +let loading = false; +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ + data: payload, + isLoading: loading, + isError: false, + refetch: vi.fn(), + }), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { ManagedIntegrationStatus } from "../managed-integration-status"; +import { healthFixture } from "../integrations/__tests__/health-fixture"; + +const KEYS = [ + "withings", + "whoop", + "fitbit", + "nightscout", + "polar", + "oura", + "google-health", + "strava", +]; + +function render() { + return renderToStaticMarkup( + + + , + ); +} + +function withHealth(map: Record) { + return { + recordId: "record-1", + integrations: KEYS.map((integration) => ({ + integration, + // The legacy fields still ride the wire; the view must not use them. + state: "connected", + lastSuccessAt: null, + lastAttemptAt: null, + health: map[integration] ?? null, + })), + }; +} + +describe("", () => { + beforeEach(() => { + payload = undefined; + loading = false; + }); + + it("renders one shared card per provider from health, with no control on any", () => { + payload = withHealth({ + oura: healthFixture({ + state: "action_required", + reason: "reauth_required", + action: "reconnect", + }), + withings: healthFixture(), + }); + const html = render(); + + expect(html).toContain('data-record-settings-family="integrations"'); + expect(html).toContain( + 'aria-labelledby="managed-integration-status-title"', + ); + expect(html).toContain('data-record-id="record-1"'); + expect(html.match(/data-slot="integration-card"/g)).toHaveLength(8); + for (const key of KEYS) { + expect(html).toContain(`data-testid="managed-integration-${key}"`); + } + expect(html).toMatch( + /data-testid="managed-integration-oura"[\s\S]*?data-slot="integration-health-badge"[^>]*data-state="action_required"/, + ); + // Read-only even where the state asks for an action. + expect(html).not.toContain('data-slot="integration-primary-action"'); + expect(html).not.toContain('data-slot="integration-menu-trigger"'); + // The legacy `state: "connected"` on every row does not make a card + // connected; only `health` does. + expect(html.match(/>Not connected\. { + payload = withHealth({}); + const html = render(); + expect(html).toContain('data-slot="managed-integration-read-only"'); + expect(html).toContain( + "A managed profile shows its connected services as a status only.", + ); + expect(html).toContain( + 'href="https://docs.healthlog.dev/self-hosting/account-sharing#records-with-no-login"', + ); + }); + + it("paints the cards while loading without claiming a state", () => { + loading = true; + const html = render(); + expect(html).toContain('aria-busy="true"'); + expect(html).not.toContain( + 'aria-labelledby="managed-integration-status-title"', + ); + expect(html).not.toContain('data-slot="integration-health-badge"'); + expect(html).not.toContain("Not connected."); + }); +}); diff --git a/src/components/settings/integrations/__tests__/cards-render-health.test.ts b/src/components/settings/integrations/__tests__/cards-render-health.test.ts new file mode 100644 index 000000000..5ae749d7e --- /dev/null +++ b/src/components/settings/integrations/__tests__/cards-render-health.test.ts @@ -0,0 +1,160 @@ +/** + * The integration cards render the server's `health` and nothing else. + * + * Before v1.42.4 each card combined the ledger state, the liveness verdict, + * token expiry, plan and scope lists and a reauth flag in its own way, so one + * fact could read as a red error on one card and a footnote on the next. The + * server now folds all of it into `health`, and this pins that no card under + * this folder starts reading the old fields again, which is the one way a + * second status dialect could come back. The managed-profile status view is + * held to the same rule. + */ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import ts from "typescript"; + +import { walkSourceFiles } from "@/__tests__/helpers/source-files"; + +import { failureStreakFor, pickStatus } from "../shared"; + +describe("failureStreakFor", () => { + it("pairs the largest per-kind streak with the envelope threshold", () => { + const status = pickStatus( + { + threshold: 3, + integrations: [ + { + integration: "withings", + consecutiveFailuresByKind: { + transient: 1, + reauth_required: 2, + persistent: 0, + }, + }, + ], + }, + "withings", + ); + expect(failureStreakFor(status)).toEqual({ count: 2, threshold: 3 }); + }); + + it("reports nothing for a healthy zero-count status", () => { + const status = pickStatus( + { + threshold: 3, + integrations: [ + { + integration: "withings", + consecutiveFailuresByKind: { + transient: 0, + reauth_required: 0, + persistent: 0, + }, + }, + ], + }, + "withings", + ); + expect(failureStreakFor(status)).toBeNull(); + }); +}); + +describe("structural guard: cards read health, not the retired fields", () => { + const SRC = join(process.cwd(), "src"); + const CARDS = "components/settings/integrations"; + const MANAGED = "components/settings/managed-integration-status.tsx"; + + /** + * Fields the server keeps on the wire for one transition release and that + * `health` replaces. Matched as property reads in the parsed source, so a + * comment or an i18n key that happens to contain the word is not a read. + */ + const RETIRED = new Set([ + "lastError", + "state", + "needsReauth", + "planUnavailable", + "scopeReconnect", + "syncHealth", + "tokenExpired", + "tokenExpiresAt", + "hasActivityScope", + "legacyLastSyncedAt", + "consecutiveFailures", + ]); + + /** + * `.state` is also the name of `health.state` and of a Google Health sync + * run's progress state. Those bases are the allowed readers; any other base + * reading `.state` is a ledger state finding its way back onto a card. + */ + const STATE_READERS = /(^|\.)(health|progress|syncProgress|record)$/; + + const files = [ + ...walkSourceFiles(join(SRC, CARDS), { floor: 15 }) + .filter((p) => !p.includes("__tests__") && !p.endsWith(".test.ts")) + .map((p) => `${CARDS}/${p}`), + MANAGED, + ]; + + function retiredReads(rel: string): string[] { + const path = join(SRC, rel); + const file = ts.createSourceFile( + path, + readFileSync(path, "utf8"), + ts.ScriptTarget.Latest, + true, + path.endsWith("x") ? ts.ScriptKind.TSX : ts.ScriptKind.TS, + ); + const found: string[] = []; + function visit(node: ts.Node): void { + if (ts.isPropertyAccessExpression(node)) { + const name = node.name.text; + const base = node.expression.getText(file).replace(/\?$/, ""); + if ( + RETIRED.has(name) && + !(name === "state" && STATE_READERS.test(base)) + ) { + found.push(`${rel}: ${base}.${name}`); + } + } + ts.forEachChild(node, visit); + } + visit(file); + return found; + } + + it("walks the cards and the managed view", () => { + expect(files.length).toBeGreaterThan(10); + expect(files).toContain(`${CARDS}/integration-card.tsx`); + expect(files).toContain(`${CARDS}/oauth-integration-card.tsx`); + expect(files).toContain(MANAGED); + }); + + it("no card reads a retired health-shaped field", () => { + expect(files.flatMap(retiredReads)).toEqual([]); + }); + + it("the card data types do not declare them either", () => { + // The compiler is the stronger half of this guard: a field the view model + // does not declare cannot be read without a cast. + const shared = readFileSync(join(SRC, CARDS, "shared.tsx"), "utf8"); + const viewModel = shared.slice( + shared.indexOf("export interface IntegrationStatusViewModel"), + shared.indexOf("export interface IntegrationStatusEnvelope"), + ); + expect(viewModel.length).toBeGreaterThan(100); + for (const field of RETIRED) { + expect(viewModel, field).not.toMatch(new RegExp(`\\b${field}\\??:`)); + } + }); + + it("no card paints the old status pill", () => { + const offenders = files.filter((rel) => + / = {}, +): ConnectionHealth { + return { + state: "ok", + reason: "data_arriving", + since: null, + action: null, + unavailable: [], + lastSuccessAt: "2026-10-15T08:12:00.000Z", + lastDataAt: "2026-10-15T07:12:00.000Z", + diagnostics: { + ledgerState: "connected", + failingLegs: [], + failingSince: null, + lastAttemptAt: "2026-10-15T08:12:00.000Z", + verdict: "fresh", + recentCalls: [], + }, + ...overrides, + }; +} diff --git a/src/components/settings/integrations/__tests__/integration-card.test.tsx b/src/components/settings/integrations/__tests__/integration-card.test.tsx new file mode 100644 index 000000000..feedd6ee3 --- /dev/null +++ b/src/components/settings/integrations/__tests__/integration-card.test.tsx @@ -0,0 +1,403 @@ +/** + * The shared integration card, one test per state the server can publish. + * + * Every assertion reads the markup's data attributes or the bundle's own + * sentence, never a computed state: the card renders `health` and nothing + * else, so what is checked here is that each `health` lands on the right + * badge, sentence and (at most one) button. + */ +import { describe, expect, it, vi } from "vitest"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; + +vi.mock("@tanstack/react-query", () => ({ + useMutation: () => ({ mutate: vi.fn(), isPending: false }), + useQueryClient: () => ({ invalidateQueries: vi.fn() }), +})); + +let lastCardProps: IntegrationCardProps | null = null; +vi.mock("../integration-card", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + IntegrationCard: (props: IntegrationCardProps) => { + lastCardProps = props; + return createElement(actual.IntegrationCard, props); + }, + }; +}); + +import type { Locale } from "@/lib/i18n/config"; +import { I18nProvider } from "@/lib/i18n/context"; +import type { ConnectionHealth } from "@/lib/integrations/connection-health"; + +import type { IntegrationCardProps } from "../integration-card"; +import { + OAUTH_INTEGRATIONS, + OAuthIntegrationCard, + type OAuthIntegrationKey, +} from "../oauth-integration-card"; +import type { IntegrationStatusViewModel } from "../shared"; +import { healthFixture } from "./health-fixture"; + +const NOW = Date.now(); +const hoursAgo = (h: number) => new Date(NOW - h * 3_600_000).toISOString(); + +function render( + provider: OAuthIntegrationKey, + viewModel: Partial | undefined, + locale: Locale = "en", +) { + return renderToStaticMarkup( + + + , + ); +} + +function connected(health: ConnectionHealth, extra = {}) { + return { + connected: true, + configured: true, + available: true, + health, + ...extra, + }; +} + +function badgeState(html: string): string | null { + return ( + html.match( + /data-slot="integration-health-badge"[^>]*data-state="(\w+)"/, + )?.[1] ?? null + ); +} + +function primaryActions(html: string): string[] { + return [ + ...html.matchAll( + /data-slot="integration-primary-action"[^>]*data-action="(\w+)"/g, + ), + ].map((m) => m[1]); +} + +describe(" per state", () => { + it("ok: Working, one sentence, last data, no button", () => { + const html = render( + "oura", + connected(healthFixture({ lastDataAt: hoursAgo(1) })), + ); + expect(html).toMatch(/data-slot="integration-card"[^>]*data-state="ok"/); + expect(badgeState(html)).toBe("ok"); + expect(html).toContain(">Working<"); + expect(html).toContain("Data is arriving."); + expect(html).toContain('data-slot="integration-last-data"'); + expect(html).toContain("Last data: today,"); + expect(primaryActions(html)).toEqual([]); + // Details are always there, collapsed. + expect(html).toContain('data-slot="integration-details-toggle"'); + expect(html).toMatch( + /data-slot="integration-details"[^>]*data-state="closed"/, + ); + expect(html).toMatch( + /]*hidden=""[^>]*data-slot="integration-details"/, + ); + }); + + it("ok while older history loads: the sentence says so", () => { + const html = render( + "whoop", + connected(healthFixture(), { backfillCompleted: false }), + ); + expect(html).toContain( + "Data is arriving. Older history is loading in the background.", + ); + }); + + it("limited by plan: the sentence names up to three, counts the rest, no button", () => { + const html = render( + "withings", + connected( + healthFixture({ + state: "limited", + reason: "plan_limited", + unavailable: [ + { item: "spo2", cause: "plan" }, + { item: "vascularAge", cause: "plan" }, + { item: "pulseWaveVelocity", cause: "plan" }, + { item: "vo2Max", cause: "plan" }, + { item: "ecg", cause: "plan" }, + ], + }), + ), + ); + expect(badgeState(html)).toBe("limited"); + expect(html).toContain(">Limited<"); + expect(html).toContain( + "Data is arriving. Your Withings plan does not include SpO2, vascular age, and 3 more.", + ); + expect(primaryActions(html)).toEqual([]); + // The full list lives in the details, with the plans page one tap away. + const details = html.slice(html.indexOf('data-slot="integration-details"')); + expect(details).toContain("ECG and AFib"); + expect(details).toContain('data-testid="withings-plan-link"'); + }); + + it("limited by permission: the sentence names it and offers the one reconnect", () => { + const html = render( + "oura", + connected( + healthFixture({ + state: "limited", + reason: "permission_limited", + action: "reconnect", + unavailable: [ + { item: "spo2", cause: "permission" }, + { item: "vo2max", cause: "permission" }, + ], + }), + ), + ); + expect(badgeState(html)).toBe("limited"); + expect(html).toContain( + "Data is arriving. Permission is missing for SpO2 and VO2 max.", + ); + expect(primaryActions(html)).toEqual(["reconnect"]); + expect(html).toContain(">Reconnect<"); + }); + + it("degraded: Delayed, dated, reassuring, no button", () => { + const html = render( + "withings", + connected( + healthFixture({ + state: "degraded", + reason: "provider_errors", + since: hoursAgo(2), + }), + ), + ); + expect(badgeState(html)).toBe("degraded"); + expect(html).toContain(">Delayed<"); + expect(html).toMatch( + /Withings has not answered reliably since (today|yesterday), [^.]+\. HealthLog keeps trying; nothing to do on your side\./, + ); + expect(primaryActions(html)).toEqual([]); + }); + + it("action required, reauth: Action needed and exactly one Reconnect", () => { + const html = render( + "google-health", + connected( + healthFixture({ + state: "action_required", + reason: "reauth_required", + action: "reconnect", + }), + ), + ); + expect(badgeState(html)).toBe("action_required"); + expect(html).toContain(">Action needed<"); + expect(html).toContain( + "The Google Health connection has expired. Reconnect to keep data coming.", + ); + expect(primaryActions(html)).toEqual(["reconnect"]); + // The re-consent banner it replaces is gone. + expect(html).not.toContain("google-health-reauth-banner"); + }); + + it("action required, paused: exactly one Resume, wired to the resume endpoint", () => { + const html = render( + "whoop", + connected( + healthFixture({ + state: "action_required", + reason: "paused_after_errors", + since: hoursAgo(30), + action: "resume", + }), + ), + ); + expect(badgeState(html)).toBe("action_required"); + expect(html).toContain("WHOOP is paused after failing since"); + expect(primaryActions(html)).toEqual(["resume"]); + expect(html).toContain(">Resume<"); + expect(html).toContain('data-testid="whoop-resume-button"'); + expect(html).not.toContain("whoop-parked-banner"); + }); + + it("not connected: no badge, one sentence, the BYO form and Connect", () => { + const html = render("withings", { + connected: false, + configured: false, + health: null, + }); + expect(html).toMatch( + /data-slot="integration-card"[^>]*data-state="not_connected"/, + ); + expect(badgeState(html)).toBeNull(); + expect(html).toContain("Not connected."); + expect(html).toContain('data-testid="withings-setup-guide"'); + expect(html).toContain('data-testid="withings-redirect-guide"'); + expect(html).toContain('id="withings-clientid"'); + expect(html).toContain('data-testid="withings-connect"'); + // Connecting needs stored credentials first, and the card says so. + expect(html).toMatch( + /data-testid="withings-connect"[^>]*disabled=""|disabled=""[^>]*data-testid="withings-connect"/, + ); + expect(html).toContain('data-testid="integration-unavailable"'); + expect(html).not.toContain('data-slot="integration-details-toggle"'); + }); + + it("loading: paints the card and its header, says nothing about state", () => { + const html = render("polar", undefined); + expect(html).toMatch( + /data-slot="integration-card"[^>]*data-state="loading"/, + ); + expect(badgeState(html)).toBeNull(); + expect(html).not.toContain("Not connected."); + }); + + it("speaks German with the maintainer's words", () => { + const html = render( + "oura", + connected( + healthFixture({ + state: "degraded", + reason: "unexpected_silence", + since: hoursAgo(50), + }), + ), + "de", + ); + expect(html).toContain(">Verzögert<"); + expect(html).toContain("obwohl Oura sonst täglich liefert."); + expect(html).toContain("Details für eine Fehlermeldung"); + }); +}); + +describe(" menu and actions", () => { + it("moves sync, full reload, test, data, credentials, guide and disconnect into the menu", () => { + render("withings", connected(healthFixture())); + const menu = lastCardProps?.menu ?? []; + expect(menu.map((i) => i.id)).toEqual([ + "sync", + "full-sync", + "test", + "view-data", + "credentials", + "setup-guide", + "disconnect", + ]); + // The heavy and the destructive entries keep their confirmation. + expect(menu.find((i) => i.id === "full-sync")?.confirm).toBeTruthy(); + const disconnect = menu.find((i) => i.id === "disconnect"); + expect(disconnect?.destructive).toBe(true); + expect(disconnect?.confirm?.confirmLabel).toBe("Disconnect"); + }); + + it("offers no full reload where the provider has none", () => { + render("strava", connected(healthFixture())); + expect((lastCardProps?.menu ?? []).map((i) => i.id)).not.toContain( + "full-sync", + ); + }); + + it("names the menu trigger for screen readers and keeps the 44 px floor", () => { + const html = render("oura", connected(healthFixture())); + expect(html).toContain('aria-label="More actions for Oura"'); + const trigger = html.match( + /]*data-slot="integration-menu-trigger"[^>]*>/, + )?.[0]; + expect(trigger).toBeDefined(); + expect(trigger).toMatch(/\bmin-h-11\b/); + expect(trigger).toMatch(/\bmin-w-11\b/); + expect(trigger).toContain('aria-haspopup="menu"'); + }); + + it("shows the badge word as readable text, not an icon or a hidden label", () => { + const html = render( + "oura", + connected(healthFixture({ state: "degraded", reason: "no_new_data" })), + ); + const badge = html.match( + /]*data-slot="integration-health-badge"[^>]*>([\s\S]*?)<\/span>(?=<\/div>)/, + )?.[1]; + expect(badge).toContain("Delayed"); + expect(html).not.toMatch( + /data-slot="integration-health-badge"[^>]*aria-hidden="true"/, + ); + }); +}); + +describe(" details for a bug report", () => { + it("shows exactly the redacted diagnostics the copy button copies", () => { + const secret = "Oura error: token=abc.def.ghi for user user-42"; + const html = render( + "oura", + connected( + healthFixture({ + state: "degraded", + reason: "provider_errors", + since: "2026-10-14T18:40:00.000Z", + lastSuccessAt: "2026-10-14T18:12:00.000Z", + lastDataAt: "2026-10-14T07:55:00.000Z", + diagnostics: { + ledgerState: "error_transient", + failingLegs: ["sleep"], + failingSince: "2026-10-14T18:40:00.000Z", + lastAttemptAt: "2026-10-15T09:15:00.000Z", + verdict: "failing", + recentCalls: [ + { + endpoint: "sleep", + status: "transient", + at: "2026-10-15T09:15:00.000Z", + errorCode: "503", + }, + ], + }, + }), + { + lastError: secret, + consecutiveFailuresByKind: { + transient: 2, + reauth_required: 0, + persistent: 0, + }, + failureThreshold: 3, + }, + ), + ); + const pre = html.match( + /]*data-slot="integration-diagnostics"[^>]*>([\s\S]*?)<\/pre>/, + )?.[1]; + expect(pre).toBeDefined(); + expect(pre).toContain("HealthLog"); + expect(pre).toContain("Oura"); + expect(pre).toContain( + "State: degraded / provider_errors since 2026-10-14 18:40 UTC", + ); + expect(pre).toContain("Last sync: 2026-10-14 18:12 UTC"); + expect(pre).toContain("Ledger: error_transient, failing parts: sleep"); + expect(pre).toContain("Failure streak: 2 of 3"); + expect(pre).toMatch(/2026-10-15 09:15 {2}sleep {2}transient +503/); + // The stored provider message, its token and the account never appear, + // neither in the block nor anywhere else on the card. + expect(html).not.toContain("token=abc"); + expect(html).not.toContain("user-42"); + expect(html).not.toContain("Oura error"); + }); +}); diff --git a/src/components/settings/integrations/__tests__/integration-health-copy.test.ts b/src/components/settings/integrations/__tests__/integration-health-copy.test.ts new file mode 100644 index 000000000..abdef8e95 --- /dev/null +++ b/src/components/settings/integrations/__tests__/integration-health-copy.test.ts @@ -0,0 +1,275 @@ +import { describe, expect, it } from "vitest"; + +import { locales, type Locale } from "@/lib/i18n/config"; +import { makeFormatters } from "@/lib/format-locale"; +import { + CONNECTION_HEALTH_REASONS, + CONNECTION_HEALTH_STATES, +} from "@/lib/integrations/connection-health"; + +import de from "../../../../../messages/de.json"; +import en from "../../../../../messages/en.json"; +import es from "../../../../../messages/es.json"; +import fr from "../../../../../messages/fr.json"; +import itMessages from "../../../../../messages/it.json"; +import ko from "../../../../../messages/ko.json"; +import pl from "../../../../../messages/pl.json"; + +import { + diagnosticsText, + formatHealthInstant, + healthBadgeLabel, + healthSentence, + namedItemList, +} from "../integration-health-copy"; +import { healthFixture } from "./health-fixture"; + +const CATALOGS: Record = { + de, + en, + es, + fr, + it: itMessages, + pl, + ko, +}; + +function translator(locale: Locale) { + return (key: string, params?: Record) => { + const value = key + .split(".") + .reduce( + (node, part) => (node as Record | undefined)?.[part], + CATALOGS[locale], + ); + if (typeof value !== "string") return key; + return value.replace(/\{(\w+)\}/g, (_, name: string) => + String(params?.[name] ?? `{${name}}`), + ); + }; +} + +const NOW = new Date("2026-10-15T12:00:00.000Z"); +const fmt = makeFormatters("de", "Europe/Berlin", "AUTO", "AUTO"); +const t = translator("de"); + +describe("formatHealthInstant", () => { + it("reads today and yesterday with the time, older instants with the date", () => { + expect(formatHealthInstant("2026-10-15T05:12:00.000Z", t, fmt, NOW)).toBe( + "heute, 07:12", + ); + expect(formatHealthInstant("2026-10-14T16:40:00.000Z", t, fmt, NOW)).toBe( + "gestern, 18:40", + ); + expect(formatHealthInstant("2026-10-12T12:00:00.000Z", t, fmt, NOW)).toBe( + "12.10., 14:00", + ); + }); + + it("buckets the day in the reader's zone, not UTC", () => { + // 23:30 UTC on the 14th is already the 15th in Berlin. + expect(formatHealthInstant("2026-10-14T23:30:00.000Z", t, fmt, NOW)).toBe( + "heute, 01:30", + ); + }); +}); + +describe("namedItemList", () => { + it("names up to three types", () => { + expect( + namedItemList( + [ + { item: "spo2", cause: "plan" }, + { item: "vascularAge", cause: "plan" }, + { item: "ecg", cause: "plan" }, + ], + t, + "de", + ), + ).toBe("SpO2, Gefäßalter und EKG und Vorhofflimmern"); + }); + + it("names two and counts the rest beyond three", () => { + expect( + namedItemList( + ["spo2", "vascularAge", "ecg", "vo2Max", "weight"].map((item) => ({ + item, + cause: "plan" as const, + })), + t, + "de", + ), + ).toBe("SpO2, Gefäßalter und 3 weitere Werte"); + }); + + it("names the Withings activity scope gap", () => { + expect( + namedItemList( + [ + { item: "activity", cause: "permission" }, + { item: "sleep", cause: "permission" }, + ], + t, + "de", + ), + ).toBe("Aktivität und Schlaf"); + }); +}); + +describe("healthSentence", () => { + const opts = { provider: "Oura", locale: "de" as const, fmt, now: NOW }; + + it("uses the dated sentence when the server dates the condition", () => { + expect( + healthSentence( + healthFixture({ + state: "degraded", + reason: "no_new_data", + since: "2026-10-14T16:40:00.000Z", + }), + t, + opts, + ), + ).toBe("Seit gestern, 18:40 sind keine neuen Daten gekommen."); + }); + + it("falls back to an undated sentence rather than printing nothing", () => { + expect( + healthSentence( + healthFixture({ state: "degraded", reason: "provider_errors" }), + t, + opts, + ), + ).toBe( + "Oura antwortet gerade nicht zuverlässig. HealthLog versucht es weiter, du musst nichts tun.", + ); + }); + + it("tells an Apple Health user to open the app", () => { + expect( + healthSentence( + healthFixture({ + state: "degraded", + reason: "no_new_data", + since: "2026-10-12T12:00:00.000Z", + }), + t, + { ...opts, provider: "Apple Health", appleHealth: true }, + ), + ).toBe( + "Seit 12.10., 14:00 sind keine neuen Daten gekommen. Öffne HealthLog einmal auf dem iPhone.", + ); + }); + + it.each(locales)( + "has a sentence and a badge word for every state and reason in %s", + (locale) => { + const tl = translator(locale); + const f = makeFormatters(locale, "Europe/Berlin", "AUTO", "AUTO"); + for (const state of CONNECTION_HEALTH_STATES) { + const word = healthBadgeLabel(state, tl); + expect(word).not.toContain("settings."); + } + for (const reason of CONNECTION_HEALTH_REASONS) { + for (const since of [null, "2026-10-14T16:40:00.000Z"]) { + const sentence = healthSentence( + healthFixture({ + reason, + since, + unavailable: [ + { item: "spo2", cause: "plan" }, + { item: "activity", cause: "permission" }, + ], + }), + tl, + { provider: "Oura", locale, fmt: f, now: NOW }, + ); + expect(sentence, `${locale} ${reason}`).not.toMatch( + /settings\.|\{\w+\}| · | — | – /, + ); + expect(sentence.length).toBeGreaterThan(5); + } + } + }, + ); +}); + +describe("diagnosticsText", () => { + it("prints the draft's block from health alone", () => { + const text = diagnosticsText( + { + version: "1.42.4", + provider: "Oura", + health: healthFixture({ + state: "degraded", + reason: "provider_errors", + since: "2026-10-14T18:40:00.000Z", + lastSuccessAt: "2026-10-14T18:12:00.000Z", + lastDataAt: "2026-10-14T07:55:00.000Z", + unavailable: [{ item: "spo2", cause: "permission" }], + diagnostics: { + ledgerState: "error_transient", + failingLegs: ["sleep"], + failingSince: "2026-10-14T18:40:00.000Z", + lastAttemptAt: "2026-10-15T09:15:00.000Z", + verdict: "failing", + recentCalls: [ + { + endpoint: "sleep", + status: "transient", + at: "2026-10-15T09:15:00.000Z", + errorCode: "503", + }, + { + endpoint: "readiness", + status: "ok", + at: "2026-10-15T08:15:00.000Z", + errorCode: null, + }, + ], + }, + }), + }, + t, + ); + expect(text.split("\n")).toEqual([ + "HealthLog 1.42.4 Oura", + "Zustand: degraded / provider_errors seit 2026-10-14 18:40 UTC", + "Letzte Synchronisierung: 2026-10-14 18:12 UTC", + "Letzte Daten: 2026-10-14 07:55 UTC", + "Ledger: error_transient, fehlende Teile: sleep", + "Letzte Aufrufe:", + " 2026-10-15 09:15 sleep transient 503", + " 2026-10-15 08:15 readiness ok", + "Nicht verfügbar: spo2 (Freigabe)", + ]); + }); + + it("leaves out what the server does not know instead of inventing it", () => { + const text = diagnosticsText( + { + version: "", + provider: "Apple Health", + health: healthFixture({ + lastSuccessAt: null, + lastDataAt: null, + diagnostics: { + ledgerState: null, + failingLegs: [], + failingSince: null, + lastAttemptAt: null, + verdict: "fresh", + recentCalls: [], + }, + }), + }, + t, + ); + expect(text.split("\n")).toEqual([ + "HealthLog Apple Health", + "Zustand: ok / data_arriving", + "Letzte Synchronisierung: keine", + "Letzte Daten: keine", + ]); + }); +}); diff --git a/src/components/settings/integrations/credentials-form.tsx b/src/components/settings/integrations/credentials-form.tsx new file mode 100644 index 000000000..c3ecb5be9 --- /dev/null +++ b/src/components/settings/integrations/credentials-form.tsx @@ -0,0 +1,175 @@ +"use client"; + +/** + * The bring-your-own OAuth app credentials form, one copy for every provider + * that takes a client id and secret (Withings, WHOOP, Fitbit, Google Health, + * Polar, Oura, Strava). It sits in the not-connected card before "Connect", + * and behind "Change credentials" in the menu once connected. + * + * Config fields, so no password manager is offered them (`autoComplete="off"` + * on both, the secret through `PasswordInput`). + */ + +import { useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { Loader2, Save } from "lucide-react"; + +import { SettingsCardActions } from "@/components/settings/_card-actions"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { PasswordInput } from "@/components/ui/password-input"; +import { apiFetchRaw } from "@/lib/api/api-fetch"; +import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; + +export function CredentialsForm({ + provider, + i18nPrefix, + saved, + secretPlaceholder, + statusQueryKey, +}: { + /** Route segment: PUT `/api//credentials`; also the field ids. */ + provider: string; + /** i18n base, e.g. `settings.withings`. */ + i18nPrefix: string; + /** A pair is stored already: the fields show the saved placeholder. */ + saved: boolean; + /** + * The provider ships a secret-specific saved placeholder + * (`…CredentialsSavedPlaceholderSecret`); the others reuse the id's. + */ + secretPlaceholder: boolean; + statusQueryKey: readonly unknown[]; +}) { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + const [clientId, setClientId] = useState(""); + const [clientSecret, setClientSecret] = useState(""); + const [saving, setSaving] = useState(false); + const [msg, setMsg] = useState<{ + kind: "success" | "error"; + text: string; + } | null>(null); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setSaving(true); + setMsg(null); + try { + const res = await apiFetchRaw(`/api/${provider}/credentials`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + clientId: clientId.trim(), + clientSecret: clientSecret.trim(), + }), + }); + if (res.ok) { + setMsg({ kind: "success", text: t(`${i18nPrefix}CredentialsSaved`) }); + setClientId(""); + setClientSecret(""); + queryClient.invalidateQueries({ queryKey: statusQueryKey }); + // The cards read the consolidated envelope; repaint it as well. + queryClient.invalidateQueries({ + queryKey: queryKeys.integrationsStatus(), + }); + } else { + let text = t("settings.savingError"); + try { + const json = await res.json(); + if (json.error) text = json.error; + } catch { + // keep the generic message + } + setMsg({ kind: "error", text }); + } + } catch { + setMsg({ kind: "error", text: t("common.networkError") }); + } + setSaving(false); + } + + return ( +
+

+ {t(`${i18nPrefix}Credentials`)} +

+
+
+ + setClientId(e.target.value)} + placeholder={ + saved + ? t(`${i18nPrefix}CredentialsSavedPlaceholder`) + : t(`${i18nPrefix}ClientId`) + } + maxLength={200} + autoComplete="off" + inputMode="text" + spellCheck={false} + autoCapitalize="none" + enterKeyHint="next" + /> +
+
+ + setClientSecret(e.target.value)} + placeholder={ + !saved + ? t(`${i18nPrefix}ClientSecret`) + : secretPlaceholder + ? t(`${i18nPrefix}CredentialsSavedPlaceholderSecret`) + : t(`${i18nPrefix}CredentialsSavedPlaceholder`) + } + maxLength={200} + autoComplete="off" + inputMode="text" + spellCheck={false} + autoCapitalize="none" + enterKeyHint="done" + /> +
+
+ {msg && ( +

+ {msg.text} +

+ )} + + + +
+ ); +} diff --git a/src/components/settings/integrations/integration-actions.tsx b/src/components/settings/integrations/integration-actions.tsx new file mode 100644 index 000000000..797813c35 --- /dev/null +++ b/src/components/settings/integrations/integration-actions.tsx @@ -0,0 +1,208 @@ +"use client"; + +/** + * The requests behind an integration card's menu, shared by every provider + * controller: sync now (optionally the whole history), test the connection, + * and the lines that report what happened. The card renders the lines in its + * body; the menu only starts the request. + */ + +import { useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { CheckCircle2, Loader2 } from "lucide-react"; + +import { WrittenOutcomeLine } from "@/components/outcome/written-outcome-line"; +import { + runConnectionTest, + TestConnectionFailure, + type ConnectionTestResult, +} from "@/components/settings/test-connection-button"; +import { apiFetchRaw } from "@/lib/api/api-fetch"; +import { useTranslations } from "@/lib/i18n/context"; +import { + invalidateKeys, + measurementDependentKeys, + queryKeys, +} from "@/lib/query-keys"; + +import { + readSyncOutcome, + useSyncOutcomeMessage, + type SyncOutcomeState, +} from "./sync-outcome"; + +export interface ProviderSync { + syncing: boolean; + result: SyncOutcomeState | null; + run: (fullSync: boolean) => void; +} + +/** + * Sync now, for every provider whose sync route answers the shared written + * outcome. The tone comes off what the run wrote, never off `res.ok`. + */ +export function useStandardSync({ + provider, + i18nPrefix, + acceptsFullSync, + fullSyncResult, + statusQueryKey, +}: { + /** Route segment: POST `/api//sync`. */ + provider: string; + i18nPrefix: string; + /** The route takes `{ fullSync }`; the others take no body. */ + acceptsFullSync: boolean; + /** A full sync has its own success sentence (`…FullSyncResult`). */ + fullSyncResult?: boolean; + statusQueryKey?: readonly unknown[]; +}): ProviderSync { + const { t } = useTranslations(); + const describeSyncOutcome = useSyncOutcomeMessage(); + const queryClient = useQueryClient(); + const [syncing, setSyncing] = useState(false); + const [syncResult, setSyncResult] = useState(null); + + async function run(fullSync: boolean) { + setSyncing(true); + setSyncResult(null); + try { + const res = await apiFetchRaw( + `/api/${provider}/sync`, + acceptsFullSync + ? { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ fullSync }), + } + : { method: "POST" }, + ); + const json = await res.json(); + const result = res.ok ? readSyncOutcome(json) : null; + if (result) { + setSyncResult({ + outcome: result.outcome, + message: describeSyncOutcome( + result, + fullSync && fullSyncResult + ? t(`${i18nPrefix}FullSyncResult`, { count: result.imported }) + : t(`${i18nPrefix}SyncResult`, { count: result.imported }), + ), + }); + void invalidateKeys(queryClient, measurementDependentKeys); + if (statusQueryKey) { + queryClient.invalidateQueries({ queryKey: statusQueryKey }); + } + queryClient.invalidateQueries({ + queryKey: queryKeys.integrationsStatus(), + }); + } else { + setSyncResult({ + outcome: "failed", + message: json?.error || t(`${i18nPrefix}SyncFailed`), + }); + } + } catch { + setSyncResult({ + outcome: "failed", + message: t(`${i18nPrefix}SyncFailed`), + }); + } finally { + setSyncing(false); + } + } + + return { + syncing, + result: syncResult, + run: (fullSync) => void run(fullSync), + }; +} + +/** "Test connection" from the menu; the result shows in the card body. */ +export function useConnectionTest(endpoint: string) { + const [testing, setTesting] = useState(false); + const [result, setResult] = useState(null); + async function run() { + setTesting(true); + setResult(null); + try { + setResult(await runConnectionTest(endpoint)); + } finally { + setTesting(false); + } + } + return { testing, result, run: () => void run() }; +} + +/** + * What the last menu action reported: a running sync or test, the sync's + * written outcome, the test's answer. Renders nothing when nothing ran. + */ +export function ActionResultLines({ + provider, + sync, + test, +}: { + provider: string; + sync?: Pick; + test?: ReturnType; +}) { + const { t } = useTranslations(); + + function describeError(errorCode: string): string { + const key = `settings.testConnection.errors.${errorCode}`; + const translated = t(key); + return translated === key + ? t("settings.testConnection.errors.generic") + : translated; + } + + const busy = sync?.syncing || test?.testing; + const syncResult = sync?.result ?? null; + if (!busy && !syncResult && !test?.result) return null; + + return ( +
+ {busy ? ( +

+

+ ) : null} + {syncResult ? ( + + ) : null} + {test?.result?.kind === "ok" ? ( +

+

+ ) : null} + {test?.result?.kind === "error" ? ( + + ) : null} +
+ ); +} diff --git a/src/components/settings/integrations/integration-card.tsx b/src/components/settings/integrations/integration-card.tsx new file mode 100644 index 000000000..451b563db --- /dev/null +++ b/src/components/settings/integrations/integration-card.tsx @@ -0,0 +1,603 @@ +"use client"; + +/** + * `` — the one card every connection in Settings renders. + * + * A card says three things: whether it works (a badge with one word), what + * that means (one sentence), and what the person can do (at most one button). + * Everything else is either diagnostics, collapsed under "Details for a bug + * report", or rarely needed, in the three-dot menu. + * + * The card renders the server's `health` and nothing else. It does not read a + * ledger state, a verdict, a token expiry or a failure counter, and it never + * decides a state of its own: the badge, the sentence and the button are each + * a projection of one `ConnectionHealth`. Per-provider controllers (OAuth, + * Nightscout, Apple Health) own the requests and hand the card their menu + * entries, their primary handlers and anything that belongs in the details. + * + * Anatomy, top to bottom (design standards §1, §12, §13): + * header `SettingsCardHeader`, the badge in its status slot + * sentence `text-sm` foreground, then one muted meta line + * body transient lines a controller adds (a sync result, a form) + * details collapsed, always present on a connected card + * actions `SettingsCardActions`, the last element: menu left, at most + * one primary right + */ + +import { useState, type ReactNode } from "react"; +import { + Check, + ChevronDown, + Copy, + ExternalLink, + Link2, + Loader2, + MoreHorizontal, + Play, + type LucideIcon, +} from "lucide-react"; + +import { SettingsCardActions } from "@/components/settings/_card-actions"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { SettingsCard } from "@/components/settings/settings-card"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { ConfirmDialog } from "@/components/ui/confirm-button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import type { + ConnectionHealth, + ConnectionHealthState, +} from "@/lib/integrations/connection-health"; +import { cn } from "@/lib/utils"; + +import { + diagnosticsText, + formatHealthInstant, + healthBadgeLabel, + healthSentence, + unavailableItemLabel, +} from "./integration-health-copy"; + +/** The app version the build stamped; empty in a dev build. */ +const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || ""; + +const BADGE_TONE: Record = { + ok: "border-success/30 bg-success/15 text-success", + limited: "border-info/30 bg-info/15 text-info", + degraded: "border-warning/30 bg-warning/15 text-warning", + action_required: "border-destructive/30 bg-destructive/15 text-destructive", +}; + +/** The status badge: a dot and one word. The word is the accessible name. */ +export function HealthBadge({ state }: { state: ConnectionHealthState }) { + const { t } = useTranslations(); + return ( + + + ); +} + +export interface IntegrationMenuAction { + /** Stable id; becomes `data-action` and the `-menu-` testid. */ + id: string; + label: string; + icon: LucideIcon; + onSelect?: () => void; + /** Navigates instead of acting. External links open in a new tab. */ + href?: string; + external?: boolean; + disabled?: boolean; + destructive?: boolean; + /** Ask first; the action runs from the dialog's confirm button. */ + confirm?: { title: string; body: string; confirmLabel: string }; +} + +export interface IntegrationPrimaryHandlers { + onReconnect?: () => void; + onResume?: () => void; + pending?: boolean; + disabled?: boolean; +} + +export interface IntegrationCardProps { + /** Connection key; drives `data-integration` and the testids. */ + provider: string; + /** Provider name used inside sentences and the diagnostics. */ + providerName: string; + icon: LucideIcon; + title?: ReactNode; + titleAccessory?: ReactNode; + /** One meta sentence with the setup-guide link; shown while not connected. */ + description?: ReactNode; + /** The status has not loaded yet. */ + loading?: boolean; + connected: boolean; + /** The server-resolved health; null when the server has none to give. */ + health: ConnectionHealth | null; + /** Server fact: older history is still loading in the background. */ + historyLoading?: boolean; + /** Apple Health's "open HealthLog on your iPhone" sentence variant. */ + appleHealth?: boolean; + primary?: IntegrationPrimaryHandlers; + menu?: IntegrationMenuAction[]; + /** Provider extras for the details section (lists, delivery facts). */ + details?: ReactNode; + /** Extra redacted lines for the copied diagnostics. */ + diagnosticsExtra?: readonly string[]; + /** Lines below the sentence: results, errors, an opened form. */ + children?: ReactNode; + /** Not connected: the forms and guides that come before connecting. */ + setup?: ReactNode; + /** Not connected: the one primary button, usually "Connect". */ + connectAction?: ReactNode; + /** Overrides the `-card` testid (the read-only fallback row). */ + testId?: string; +} + +export function IntegrationCard({ + provider, + providerName, + icon, + title, + titleAccessory, + description, + loading = false, + connected, + health, + historyLoading, + appleHealth, + primary, + menu = [], + details, + diagnosticsExtra, + children, + setup, + connectAction, + testId, +}: IntegrationCardProps) { + const { t, locale } = useTranslations(); + const fmt = useFormatters(); + const [detailsOpen, setDetailsOpen] = useState(false); + const detailsId = `${provider}-integration-details`; + + const cardState = loading + ? "loading" + : !connected + ? "not_connected" + : (health?.state ?? "unknown"); + + const header = ( + + ) : null + } + /> + ); + + const shell = (body: ReactNode) => ( + + {header} + {body} + + ); + + if (loading) { + // A loading state paints the card and its header (§13), so the page does + // not move when the status lands. + return shell( +
+ + +
, + ); + } + + if (!connected) { + return shell( + <> +

+ {t("settings.integrationCard.notConnected")} +

+ {setup} + {children} + {connectAction ? ( + + {connectAction} + + ) : null} + , + ); + } + + const sentence = health + ? healthSentence(health, t, { + provider: providerName, + historyLoading, + appleHealth, + locale, + fmt, + }) + : null; + const lastData = health + ? health.lastDataAt + ? t("settings.integrationCard.lastData", { + when: formatHealthInstant(health.lastDataAt, t, fmt), + }) + : t("settings.integrationCard.lastDataNone") + : null; + + const action = health?.action ?? null; + const primaryButton = + action === "reconnect" && primary?.onReconnect ? ( + + ) : action === "resume" && primary?.onResume ? ( + + ) : null; + + return shell( + <> + {sentence || lastData ? ( +
+ {sentence ? ( +

+ {sentence} +

+ ) : null} + {lastData ? ( +

+ {lastData} +

+ ) : null} +
+ ) : null} + {children} + {health ? ( + + unavailableItemLabel(u.item, t), + )} + > + {details} + + ) : details ? ( +
{details}
+ ) : null} + {health || menu.length > 0 || primaryButton ? ( + + {health ? ( + + ) : null} +
+ {menu.length > 0 ? ( + + ) : null} + {primaryButton} +
+
+ ) : null} + , + ); +} + +function PrimaryButton({ + action, + icon: Icon, + label, + onClick, + pending, + disabled, + provider, +}: { + action: "reconnect" | "resume"; + icon: LucideIcon; + label: string; + onClick: () => void; + pending?: boolean; + disabled?: boolean; + provider: string; +}) { + return ( + + ); +} + +/** + * The rare actions. A destructive or heavy entry asks first through the same + * confirmation the card used before; the dialog is controlled here because a + * menu item closes its menu, and with it any dialog nested inside. + */ +function IntegrationMenu({ + provider, + providerName, + items, +}: { + provider: string; + providerName: string; + items: IntegrationMenuAction[]; +}) { + const { t } = useTranslations(); + const [confirming, setConfirming] = useState( + null, + ); + + return ( + <> + + + + + + {items.map((item) => { + const Icon = item.icon; + const common = { + "data-slot": "integration-menu-item", + "data-action": item.id, + "data-testid": `${provider}-menu-${item.id}`, + disabled: item.disabled, + variant: item.destructive + ? ("destructive" as const) + : ("default" as const), + }; + if (item.href) { + return ( + + + + + ); + } + return ( + { + if (item.confirm) setConfirming(item); + else item.onSelect?.(); + }} + > + + ); + })} + + + {confirming?.confirm ? ( + { + if (!open) setConfirming(null); + }} + title={confirming.confirm.title} + body={confirming.confirm.body} + confirmLabel={confirming.confirm.confirmLabel} + destructive={confirming.destructive} + slot={`${provider}-${confirming.id}`} + onConfirm={() => { + const run = confirming.onSelect; + setConfirming(null); + run?.(); + }} + /> + ) : null} + + ); +} + +/** + * "Details for a bug report": collapsed by default and present in every state, + * so a report always looks the same. Its toggle sits at the start of the + * action row and the panel opens above that row, which stays the last thing in + * the card. The monospace block is exactly the text the copy button places on + * the clipboard. The panel is always in the markup and `hidden` while closed, + * so what a report would contain is inspectable without opening it. + */ +function DetailsPanel({ + id, + open, + text, + unavailable, + children, +}: { + id: string; + open: boolean; + text: string; + unavailable: string[]; + children?: ReactNode; +}) { + const { t } = useTranslations(); + const [copy, setCopy] = useState<"idle" | "copied" | "failed">("idle"); + + async function handleCopy() { + try { + await navigator.clipboard.writeText(text); + setCopy("copied"); + window.setTimeout(() => setCopy("idle"), 2000); + } catch { + setCopy("failed"); + } + } + + return ( + + ); +} diff --git a/src/components/settings/integrations/integration-health-copy.ts b/src/components/settings/integrations/integration-health-copy.ts new file mode 100644 index 000000000..f9340dd4e --- /dev/null +++ b/src/components/settings/integrations/integration-health-copy.ts @@ -0,0 +1,358 @@ +/** + * The words an integration card says about a connection's health. + * + * The server resolves one `ConnectionHealth` per connection and the card only + * renders it, so everything here is a projection: a badge word per state, one + * sentence per reason, a relative instant, and the plain-text diagnostics a + * person can paste into a bug report. Nothing in this module decides a state. + * + * Pure (a translator and the locale formatters are passed in) so every + * sentence and the copied diagnostics are unit-testable without a renderer. + */ +import type { + ConnectionHealth, + ConnectionHealthState, + UnavailableItem, +} from "@/lib/integrations/connection-health"; +import { pluralKey } from "@/lib/i18n/plural"; +import type { Locale } from "@/lib/i18n/config"; + +type Translate = ( + key: string, + params?: Record, +) => string; + +interface InstantFormatters { + dateShortSmart: (value: Date) => string; + time: (value: Date) => string; +} + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** The one word in the status badge. */ +export function healthBadgeLabel( + state: ConnectionHealthState, + t: Translate, +): string { + switch (state) { + case "ok": + return t("settings.integrationCard.state.ok"); + case "limited": + return t("settings.integrationCard.state.limited"); + case "degraded": + return t("settings.integrationCard.state.degraded"); + case "action_required": + return t("settings.integrationCard.state.actionRequired"); + } +} + +/** + * An instant as a person reads it: "today, 07:12", "yesterday, 18:40", or the + * date with its time. The day comparison goes through the same zone-aware + * formatter that prints the date, so the boundary and the printed day can + * never disagree. + */ +export function formatHealthInstant( + iso: string, + t: Translate, + fmt: InstantFormatters, + now: Date = new Date(), +): string { + const at = new Date(iso); + if (Number.isNaN(at.getTime())) return ""; + const day = fmt.dateShortSmart(at); + const time = fmt.time(at); + if (day === fmt.dateShortSmart(now)) { + return t("settings.integrationCard.when.today", { time }); + } + if (day === fmt.dateShortSmart(new Date(now.getTime() - DAY_MS))) { + return t("settings.integrationCard.when.yesterday", { time }); + } + return t("settings.integrationCard.when.date", { date: day, time }); +} + +/** + * A readable name for a data type the provider withholds. Written as literal + * keys so the i18n coverage guards see each one; an unknown key degrades to a + * readable form of itself rather than leaking `snake_case`. + */ +export function unavailableItemLabel(item: string, t: Translate): string { + switch (item) { + case "spo2": + return t("settings.integrations.withings.plan.items.spo2"); + case "vascularAge": + return t("settings.integrations.withings.plan.items.vascularAge"); + case "pulseWaveVelocity": + return t("settings.integrations.withings.plan.items.pulseWaveVelocity"); + case "vo2Max": + return t("settings.integrations.withings.plan.items.vo2Max"); + case "visceralFat": + return t("settings.integrations.withings.plan.items.visceralFat"); + case "skinTemperature": + return t("settings.integrations.withings.plan.items.skinTemperature"); + case "ecg": + return t("settings.integrations.withings.plan.items.ecg"); + case "sleepScore": + return t("settings.integrations.withings.plan.items.sleepScore"); + case "sleepHrv": + return t("settings.integrations.withings.plan.items.sleepHrv"); + case "sleepHeartRate": + return t("settings.integrations.withings.plan.items.sleepHeartRate"); + case "sleepRespiratoryRate": + return t( + "settings.integrations.withings.plan.items.sleepRespiratoryRate", + ); + case "sleepSpo2": + return t("settings.integrations.withings.plan.items.sleepSpo2"); + case "bodyTemperature": + return t("settings.integrations.withings.plan.items.bodyTemperature"); + case "bodyFat": + return t("settings.integrations.withings.plan.items.bodyFat"); + case "fatMass": + return t("settings.integrations.withings.plan.items.fatMass"); + case "fatFreeMass": + return t("settings.integrations.withings.plan.items.fatFreeMass"); + case "muscleMass": + return t("settings.integrations.withings.plan.items.muscleMass"); + case "bodyWater": + return t("settings.integrations.withings.plan.items.bodyWater"); + case "boneMass": + return t("settings.integrations.withings.plan.items.boneMass"); + case "heartRate": + return t("settings.integrations.withings.plan.items.heartRate"); + case "bloodPressure": + return t("settings.integrations.withings.plan.items.bloodPressure"); + case "weight": + return t("settings.integrations.withings.plan.items.weight"); + case "vo2max": + return t("settings.integrations.oura.scope.items.vo2max"); + case "cardio_age": + return t("settings.integrations.oura.scope.items.cardio_age"); + case "resilience": + return t("settings.integrations.oura.scope.items.resilience"); + case "activity": + return t("settings.integrationCard.items.activity"); + case "sleep": + return t("settings.integrationCard.items.sleep"); + default: { + const words = item + .replace(/([a-z])([A-Z])/g, "$1 $2") + .replace(/[_-]+/g, " ") + .toLowerCase(); + return words; + } + } +} + +/** + * At most three names in a sentence. Up to three types are all named; more + * than three name the first two and count the rest ("SpO2, vascular age and + * 3 more"), and the full list lives in the details. + */ +export function namedItemList( + items: readonly UnavailableItem[], + t: Translate, + locale: Locale, +): string { + const labels = items.map((u) => unavailableItemLabel(u.item, t)); + const parts = + labels.length <= 3 + ? labels + : [ + ...labels.slice(0, 2), + t( + pluralKey( + "settings.integrationCard.moreValues", + labels.length - 2, + locale, + ), + { count: labels.length - 2 }, + ), + ]; + try { + return new Intl.ListFormat(locale, { + style: "long", + type: "conjunction", + }).format(parts); + } catch { + return parts.join(", "); + } +} + +export interface HealthSentenceOptions { + /** Provider name as the person knows it ("Oura", "Withings"). */ + provider: string; + /** Older history is still loading (a server fact, e.g. backfill pending). */ + historyLoading?: boolean; + /** Apple Health: data only moves when the iPhone app runs. */ + appleHealth?: boolean; + locale: Locale; + fmt: InstantFormatters; + now?: Date; +} + +/** The one sentence a card says under its header. */ +export function healthSentence( + health: ConnectionHealth, + t: Translate, + options: HealthSentenceOptions, +): string { + const { provider, locale, fmt } = options; + const since = health.since + ? formatHealthInstant(health.since, t, fmt, options.now) + : null; + const ofCause = (cause: UnavailableItem["cause"]) => + namedItemList( + health.unavailable.filter((u) => u.cause === cause), + t, + locale, + ); + + switch (health.reason) { + case "data_arriving": + return options.historyLoading + ? t("settings.integrationCard.reason.dataArrivingHistory") + : t("settings.integrationCard.reason.dataArriving"); + case "first_sync_pending": + return t("settings.integrationCard.reason.firstSyncPending"); + case "plan_limited": + return t("settings.integrationCard.reason.planLimited", { + provider, + list: ofCause("plan"), + }); + case "permission_limited": + return t("settings.integrationCard.reason.permissionLimited", { + list: ofCause("permission"), + }); + case "provider_errors": + return since + ? t("settings.integrationCard.reason.providerErrors", { + provider, + since, + }) + : t("settings.integrationCard.reason.providerErrorsNoSince", { + provider, + }); + case "sync_stalled": + return since + ? t("settings.integrationCard.reason.syncStalled", { since }) + : t("settings.integrationCard.reason.syncStalledNoSince"); + case "no_new_data": + if (options.appleHealth) { + return since + ? t("settings.integrationCard.reason.noNewDataApple", { since }) + : t("settings.integrationCard.reason.noNewDataAppleNoSince"); + } + return since + ? t("settings.integrationCard.reason.noNewData", { since }) + : t("settings.integrationCard.reason.noNewDataNoSince"); + case "unexpected_silence": + return since + ? t("settings.integrationCard.reason.unexpectedSilence", { + provider, + since, + }) + : t("settings.integrationCard.reason.unexpectedSilenceNoSince", { + provider, + }); + case "reauth_required": + return t("settings.integrationCard.reason.reauthRequired", { provider }); + case "paused_after_errors": + return since + ? t("settings.integrationCard.reason.pausedAfterErrors", { + provider, + since, + }) + : t("settings.integrationCard.reason.pausedAfterErrorsNoSince", { + provider, + }); + } +} + +/** `2026-10-14T18:40:12.000Z` → `2026-10-14 18:40 UTC`. */ +function utc(iso: string | null): string | null { + if (!iso) return null; + const at = new Date(iso); + if (Number.isNaN(at.getTime())) return null; + return `${at.toISOString().slice(0, 16).replace("T", " ")} UTC`; +} + +export interface DiagnosticsInput { + /** The running app version; empty when the build did not stamp one. */ + version: string; + provider: string; + health: ConnectionHealth; + /** Extra lines the card adds, already redacted (a failure streak count). */ + extra?: readonly string[]; +} + +/** + * The plain-text diagnostics, exactly as the details block shows them and the + * copy button places them on the clipboard. + * + * Built only from `health`, whose diagnostics the server already reduced to + * leg names, outcomes, instants and short error codes. No measured value, no + * token, no account id and no provider error message can reach this text, + * because none of them is on the input. + */ +export function diagnosticsText(input: DiagnosticsInput, t: Translate): string { + const { health } = input; + const none = t("settings.integrationCard.details.none"); + const lines: string[] = []; + lines.push( + `${["HealthLog", input.version].filter(Boolean).join(" ")} ${input.provider}`, + ); + const since = utc(health.since); + lines.push( + `${t("settings.integrationCard.details.state")}: ${health.state} / ${health.reason}${ + since ? ` ${t("settings.integrationCard.details.since")} ${since}` : "" + }`, + ); + lines.push( + `${t("settings.integrationCard.details.lastSync")}: ${utc(health.lastSuccessAt) ?? none}`, + ); + lines.push( + `${t("settings.integrationCard.details.lastData")}: ${utc(health.lastDataAt) ?? none}`, + ); + const d = health.diagnostics; + if (d.ledgerState || d.failingLegs.length > 0) { + const legs = + d.failingLegs.length > 0 + ? `, ${t("settings.integrationCard.details.failingParts")}: ${d.failingLegs.join(", ")}` + : ""; + lines.push(`Ledger: ${d.ledgerState ?? none}${legs}`); + } + for (const line of input.extra ?? []) lines.push(line); + if (d.recentCalls.length > 0) { + lines.push(`${t("settings.integrationCard.details.recentCalls")}:`); + const width = Math.max(...d.recentCalls.map((c) => c.endpoint.length)); + for (const call of d.recentCalls) { + const at = (utc(call.at) ?? "").replace(" UTC", ""); + const row = [ + at, + call.endpoint.padEnd(width), + call.status.padEnd(15), + call.errorCode ?? "", + ] + .join(" ") + .trimEnd(); + lines.push(` ${row}`); + } + } + if (health.unavailable.length > 0) { + const items = health.unavailable + .map( + (u) => + `${u.item} (${ + u.cause === "plan" + ? t("settings.integrationCard.details.causePlan") + : t("settings.integrationCard.details.causePermission") + })`, + ) + .join(", "); + lines.push( + `${t("settings.integrationCard.details.unavailable")}: ${items}`, + ); + } + return lines.join("\n"); +} diff --git a/src/components/settings/integrations/metric-freshness-list.tsx b/src/components/settings/integrations/metric-freshness-list.tsx new file mode 100644 index 000000000..b25df5130 --- /dev/null +++ b/src/components/settings/integrations/metric-freshness-list.tsx @@ -0,0 +1,162 @@ +"use client"; + +/** + * Per-metric freshness, rendered. + * + * The server has computed a last-seen timestamp per `(source, type)` on every + * status call since v1.27.30 and nothing rendered it. That is the one signal + * that separates "the connection is broken" from "one pipe inside a healthy + * connection is dead" — a revoked HealthKit permission, a per-collection 403, + * an upstream that quietly stopped returning one data type. The integration + * pill cannot express it, because the integration itself is fine. + * + * It lives in the card's collapsed details: a Withings or Google Health user + * carries 15-25 types and an always-open list would bury the card. The summary + * line above the rows still names how many types have gone quiet. + * + * Honest absence is structural: the server only reports `(source, type)` pairs + * that have rows, so a type a provider never delivered simply is not here. No + * "expected but missing" row is invented. + */ + +import type { ReactNode } from "react"; +import { AlertTriangle } from "lucide-react"; + +import { formatRelative } from "@/components/settings/integration-status-pill"; +import { MEASUREMENT_TYPE_LABEL_KEYS } from "@/components/measurements/measurement-list-meta"; +import { useTranslations } from "@/lib/i18n/context"; +import type { MetricFreshnessEntry } from "@/lib/integrations/sync-verdict"; +import { cn } from "@/lib/utils"; + +/** The pseudo-type carrying a source's newest workout. */ +const WORKOUT_TYPE = "WORKOUTS"; + +/** + * Turn an unmapped enum name into something readable. `MEASUREMENT_TYPE_LABEL_KEYS` + * is a plain record, not enum-complete, so a newly-added type must degrade to + * "Walking asymmetry" rather than leaking `WALKING_ASYMMETRY` at the user. + */ +function humanise(type: string): string { + const words = type.toLowerCase().split("_").join(" "); + return words.charAt(0).toUpperCase() + words.slice(1); +} + +export function MetricFreshnessList< + Entry extends MetricFreshnessEntry = MetricFreshnessEntry, +>({ + entries, + now, + title, + detail, +}: { + entries: Entry[] | undefined; + /** Override "now" for deterministic testing. */ + now?: Date; + /** Replaces the shared "received data" heading. */ + title?: string; + /** + * A second line under a row, for a card that knows more per type than the + * newest sample (the Apple Health card's arrival facts, #1173). + */ + detail?: (entry: Entry) => ReactNode; +}) { + const { t } = useTranslations(); + + if (!entries?.length) return null; + + const reference = now ?? new Date(); + const label = (type: string): string => { + if (type === WORKOUT_TYPE) + return t("settings.integrationFreshness.workouts"); + const key = MEASUREMENT_TYPE_LABEL_KEYS[type]; + return key ? t(key) : humanise(type); + }; + const relative = (lastSeenAt: string): string => + formatRelative( + Math.max(0, reference.getTime() - new Date(lastSeenAt).getTime()), + t, + ); + + // Dead pipes surface without scrolling; everything else stays alphabetical. + const sorted = [...entries].sort( + (a, b) => + Number(b.stale) - Number(a.stale) || + label(a.type).localeCompare(label(b.type)), + ); + const staleCount = entries.filter((entry) => entry.stale).length; + const newest = entries.reduce((max, entry) => + entry.lastSeenAt > max.lastSeenAt ? entry : max, + ); + + return ( +
+

+ + {title ?? t("settings.integrationFreshness.title")} + + + {t("settings.integrationFreshness.summary", { + count: entries.length, + relative: relative(newest.lastSeenAt), + })} + {staleCount > 0 ? ( + + {", "} + {t("settings.integrationFreshness.quiet", { count: staleCount })} + + ) : null} + +

+ +
    + {sorted.map((entry) => { + const extra = detail?.(entry); + return ( +
  • +
    + + {entry.stale ? ( + + +
    + {extra ? ( +
    + {extra} +
    + ) : null} +
  • + ); + })} +
+
+ ); +} diff --git a/src/components/settings/integrations/oauth-integration-card.tsx b/src/components/settings/integrations/oauth-integration-card.tsx new file mode 100644 index 000000000..0d9d653f1 --- /dev/null +++ b/src/components/settings/integrations/oauth-integration-card.tsx @@ -0,0 +1,539 @@ +"use client"; + +/** + * The controller behind every OAuth integration card: Withings, WHOOP, + * Fitbit, Google Health, Polar, Oura and Strava. + * + * It owns the requests (connect, resume, sync, test, disconnect, the BYO + * credentials form) and hands `` what the person can do. The + * card decides nothing about health: it renders the server's `health` field. + * What used to be per-provider banners (the Withings plan note, the Oura scope + * note, the Withings activity-scope reconnect, the Google Health re-consent, + * the paused pill with its resume button) is now the sentence plus the one + * primary button that `health.action` names. + */ + +import { useState, type ReactNode } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { + Activity, + ArrowRight, + BookOpen, + CircleDot, + ExternalLink, + HeartPulse, + History, + KeyRound, + Link2, + Plug, + RefreshCw, + Unlink, + Watch, + type LucideIcon, +} from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { apiPost } from "@/lib/api/api-fetch"; +import { useTranslations } from "@/lib/i18n/context"; +import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls"; +import { loadDocument } from "@/lib/navigation/load-document"; +import { WITHINGS_API_PLANS_URL } from "@/lib/withings/plan-features"; +import { + invalidateKeys, + measurementDependentKeys, + queryKeys, +} from "@/lib/query-keys"; + +import { CredentialsForm } from "./credentials-form"; +import { + ActionResultLines, + useConnectionTest, + useStandardSync, + type ProviderSync, +} from "./integration-actions"; +import { + IntegrationCard, + type IntegrationMenuAction, +} from "./integration-card"; +import { MetricFreshnessList } from "./metric-freshness-list"; +import { + CallbackMismatchNotice, + IntegrationCardDescription, + IntegrationRedirectGuide, + integrationDocsHref, + type IntegrationDocsProvider, +} from "./setup-guide-link"; +import { failureStreakFor, type IntegrationStatusViewModel } from "./shared"; + +export type OAuthIntegrationKey = + | "withings" + | "whoop" + | "fitbit" + | "google-health" + | "polar" + | "oura" + | "strava"; + +export interface OAuthIntegrationConfig { + /** Connection key; also the `/api//…` route segment. */ + provider: OAuthIntegrationKey; + /** i18n base, e.g. `settings.withings`. */ + i18nPrefix: string; + icon: LucideIcon; + docs: IntegrationDocsProvider; + /** Where this provider's data surfaces. */ + dataHref: string; + /** The sync route takes `{ fullSync }` and the menu offers the full reload. */ + fullSync: boolean; + /** A full sync reports with its own sentence (`…FullSyncResult`). */ + fullSyncResult?: boolean; + /** + * `required`: the person must store their own client id/secret before + * connecting (`configured`). `optional`: a shared app may exist on the + * server (`available`), and the person's own pair is an alternative. + */ + credentials: "required" | "optional"; + /** The provider ships a secret-specific saved placeholder. */ + secretPlaceholder: boolean; + /** Show the callback-URL guide before credentials are stored. */ + redirectGuide: boolean; + /** + * POST endpoint that clears a pause. Null: the grant itself has to be + * re-issued, so "Resume" re-runs connect, as it always did for these. + */ + resumeEndpoint: string | null; + testEndpoint: string; + statusQueryKey: () => readonly unknown[]; +} + +export const OAUTH_INTEGRATIONS: Record< + OAuthIntegrationKey, + OAuthIntegrationConfig +> = { + withings: { + provider: "withings", + i18nPrefix: "settings.withings", + icon: Link2, + docs: "withings", + dataHref: "/measurements", + fullSync: true, + fullSyncResult: true, + credentials: "required", + secretPlaceholder: true, + redirectGuide: true, + resumeEndpoint: "/api/integrations/withings/resume", + testEndpoint: "/api/integrations/withings/test", + statusQueryKey: queryKeys.withings, + }, + whoop: { + provider: "whoop", + i18nPrefix: "settings.whoop", + icon: Activity, + docs: "whoop", + dataHref: "/insights/recovery", + fullSync: true, + credentials: "required", + secretPlaceholder: true, + redirectGuide: true, + resumeEndpoint: "/api/integrations/whoop/resume", + testEndpoint: "/api/integrations/whoop/test", + statusQueryKey: queryKeys.whoop, + }, + fitbit: { + provider: "fitbit", + i18nPrefix: "settings.fitbit", + icon: HeartPulse, + docs: "fitbit", + dataHref: "/insights/sleep", + fullSync: true, + credentials: "required", + secretPlaceholder: true, + redirectGuide: true, + resumeEndpoint: "/api/integrations/fitbit/resume", + testEndpoint: "/api/integrations/fitbit/test", + statusQueryKey: queryKeys.fitbit, + }, + "google-health": { + provider: "google-health", + i18nPrefix: "settings.googleHealth", + icon: Watch, + docs: "google-health", + dataHref: "/insights/steps", + fullSync: true, + credentials: "required", + secretPlaceholder: true, + redirectGuide: false, + resumeEndpoint: "/api/integrations/google-health/resume", + testEndpoint: "/api/integrations/google-health/test", + statusQueryKey: queryKeys.googleHealth, + }, + polar: { + provider: "polar", + i18nPrefix: "settings.polar", + icon: Watch, + docs: "polar", + dataHref: "/insights/sleep", + fullSync: false, + credentials: "optional", + secretPlaceholder: false, + redirectGuide: true, + resumeEndpoint: null, + testEndpoint: "/api/polar/test", + statusQueryKey: queryKeys.polar, + }, + oura: { + provider: "oura", + i18nPrefix: "settings.oura", + icon: CircleDot, + docs: "oura", + dataHref: "/insights/sleep", + fullSync: false, + credentials: "optional", + secretPlaceholder: false, + redirectGuide: true, + resumeEndpoint: null, + testEndpoint: "/api/oura/test", + statusQueryKey: queryKeys.oura, + }, + strava: { + provider: "strava", + i18nPrefix: "settings.strava", + icon: Activity, + docs: "strava", + dataHref: "/insights/workouts", + fullSync: false, + credentials: "optional", + secretPlaceholder: false, + redirectGuide: true, + resumeEndpoint: null, + testEndpoint: "/api/strava/test", + statusQueryKey: queryKeys.strava, + }, +}; + +/** The standard controller: a provider whose sync answers in one response. */ +export function OAuthIntegrationCard(props: { + config: OAuthIntegrationConfig; + viewModel: IntegrationStatusViewModel | undefined; + callbackUrl: IntegrationCallbackUrls[OAuthIntegrationKey]; + icon?: LucideIcon; + titleAccessory?: ReactNode; +}) { + const { config } = props; + const sync = useStandardSync({ + provider: config.provider, + i18nPrefix: config.i18nPrefix, + acceptsFullSync: config.fullSync, + fullSyncResult: config.fullSyncResult, + statusQueryKey: config.statusQueryKey(), + }); + return ; +} + +/** + * The view half, so a provider with its own sync choreography (Google Health + * polls for completion) can hand in its own `sync` and keep everything else. + */ +export function OAuthIntegrationCardView({ + config, + viewModel, + callbackUrl, + icon, + titleAccessory, + sync, + syncLabel, + syncDisabled, + fullSyncDisabled, + syncResults, + details, +}: { + config: OAuthIntegrationConfig; + viewModel: IntegrationStatusViewModel | undefined; + callbackUrl: IntegrationCallbackUrls[OAuthIntegrationKey]; + icon?: LucideIcon; + titleAccessory?: ReactNode; + sync: ProviderSync; + /** Overrides the sync item's label (Google Health's "check status"). */ + syncLabel?: string; + syncDisabled?: boolean; + fullSyncDisabled?: boolean; + /** Replaces the standard sync result line. */ + syncResults?: ReactNode; + /** Provider extras for the details section. */ + details?: ReactNode; +}) { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + const { provider, i18nPrefix } = config; + const statusQueryKey = config.statusQueryKey(); + const test = useConnectionTest(config.testEndpoint); + const [editingCredentials, setEditingCredentials] = useState(false); + + const providerName = t(i18nPrefix); + const connected = viewModel?.connected ?? false; + const configured = + config.credentials === "required" + ? (viewModel?.configured ?? false) + : (viewModel?.available ?? false); + const ownCredentials = + config.credentials === "required" + ? (viewModel?.configured ?? false) + : (viewModel?.hasOwnCredentials ?? false); + + function invalidateStatus() { + queryClient.invalidateQueries({ queryKey: statusQueryKey }); + queryClient.invalidateQueries({ + queryKey: queryKeys.integrationsStatus(), + }); + } + + function connect() { + void invalidateKeys(queryClient, measurementDependentKeys); + loadDocument(`/api/${provider}/connect`); + } + + const disconnect = useMutation({ + mutationFn: async () => { + await apiPost(`/api/${provider}/disconnect`); + }, + onSuccess: invalidateStatus, + }); + + const resume = useMutation({ + mutationFn: async () => { + if (!config.resumeEndpoint) return null; + return apiPost<{ resumed: boolean; wasParked: boolean }>( + config.resumeEndpoint, + ); + }, + onSuccess: invalidateStatus, + }); + + const credentialsForm = ( + + ); + + const description = ( + + ); + + if (!connected) { + return ( + + {config.redirectGuide && !ownCredentials ? ( + + ) : null} + {credentialsForm} + {!configured ? ( +

+ {config.credentials === "required" + ? t(`${i18nPrefix}NoCredentials`) + : t(`${i18nPrefix}Unavailable`)} +

+ ) : null} + + + } + connectAction={ + + } + /> + ); + } + + const busy = sync.syncing; + const menu: IntegrationMenuAction[] = [ + { + id: "sync", + label: syncLabel ?? t(`${i18nPrefix}Sync`), + icon: RefreshCw, + disabled: busy || syncDisabled, + onSelect: () => sync.run(false), + }, + ...(config.fullSync + ? [ + { + id: "full-sync", + label: t(`${i18nPrefix}FullSync`), + icon: History, + disabled: busy || fullSyncDisabled, + onSelect: () => sync.run(true), + confirm: { + title: t(`${i18nPrefix}FullSyncTitle`), + body: t(`${i18nPrefix}FullSyncDescription`), + confirmLabel: t(`${i18nPrefix}Synchronize`), + }, + } satisfies IntegrationMenuAction, + ] + : []), + { + id: "test", + label: t("settings.testConnection.test"), + icon: Plug, + disabled: test.testing, + onSelect: test.run, + }, + { + id: "view-data", + label: t(`${i18nPrefix}ViewData`), + icon: ArrowRight, + href: config.dataHref, + }, + { + id: "credentials", + label: t("settings.integrationCard.menu.credentials"), + icon: KeyRound, + onSelect: () => setEditingCredentials((open) => !open), + }, + { + id: "setup-guide", + label: t("settings.integrationSetupGuide"), + icon: BookOpen, + href: integrationDocsHref(config.docs), + external: true, + }, + { + id: "disconnect", + label: t(`${i18nPrefix}Disconnect`), + icon: Unlink, + destructive: true, + onSelect: () => disconnect.mutate(), + confirm: { + title: t(`${i18nPrefix}DisconnectTitle`), + body: t(`${i18nPrefix}DisconnectDescription`), + confirmLabel: t(`${i18nPrefix}Disconnect`), + }, + }, + ]; + + const streak = failureStreakFor(viewModel); + // Withings' paid API plan withholds some readings; the page that explains + // the plans stays one tap away in the details. + const planLimited = + provider === "withings" && + (viewModel?.health?.unavailable.some((u) => u.cause === "plan") ?? false); + + return ( + resume.mutate() : connect, + pending: resume.isPending, + disabled: config.credentials === "optional" && !configured, + }} + menu={menu} + diagnosticsExtra={ + streak + ? [ + t("settings.integrationCard.details.failureStreak", { + count: streak.count, + threshold: streak.threshold, + }), + ] + : undefined + } + details={ + details || planLimited || viewModel?.metricFreshness?.length ? ( + <> + {planLimited ? ( + + {t("settings.integrations.withings.plan.link")} + + ) : null} + {details} + + + ) : undefined + } + > + {syncResults ?? ( + + )} + {syncResults ? ( + + ) : null} + {resume.isError ? ( +

+ {t("settings.integrationPill.resumeError")} +

+ ) : null} + {resume.isSuccess && resume.data?.wasParked ? ( +

+ {t("settings.integrationPill.resumeSuccess")} +

+ ) : null} + {editingCredentials ? credentialsForm : null} + +
+ ); +} diff --git a/src/lib/openapi/routes/integrations.ts b/src/lib/openapi/routes/integrations.ts index 9919a20c4..44862fc08 100644 --- a/src/lib/openapi/routes/integrations.ts +++ b/src/lib/openapi/routes/integrations.ts @@ -105,7 +105,7 @@ const syncHealth = z // Mirrors `ConnectionHealth` in `src/lib/integrations/connection-health.ts`: // the one server-resolved state per connection every client renders. -const connectionHealth = z +export const connectionHealth = z .object({ state: z .enum(CONNECTION_HEALTH_STATES) diff --git a/src/lib/openapi/routes/record-settings.ts b/src/lib/openapi/routes/record-settings.ts index d8622e60f..fc7dde72d 100644 --- a/src/lib/openapi/routes/record-settings.ts +++ b/src/lib/openapi/routes/record-settings.ts @@ -25,6 +25,7 @@ import { z } from "zod/v4"; import { METRIC_BOUNDS } from "@/lib/analytics/effective-range"; import { MANAGED_RECORD_SETTINGS_PATCH_SCHEMAS } from "@/lib/record-settings"; +import { connectionHealth } from "./integrations"; import { dataEnvelope, errorEnvelope, @@ -96,6 +97,11 @@ const recordSettingsIntegrationsResponse = z ]), lastSuccessAt: z.iso.datetime({ offset: true }).nullable(), lastAttemptAt: z.iso.datetime({ offset: true }).nullable(), + health: connectionHealth + .nullable() + .describe( + "The same server-resolved connection state the owner's own envelope carries, null while the provider is not connected. The web renders this; `state` stays for one transition release.", + ), }), ) .describe( @@ -105,7 +111,7 @@ const recordSettingsIntegrationsResponse = z .meta({ id: "RecordSettingsIntegrationsResponse", description: - "Whether the managed record's integrations are connected, and nothing else. Deliberately no control and no credential projection — OAuth and provider state cannot safely carry a record selector, so a Guardian can see that a pipe exists but cannot connect, disconnect, probe or resume it from here. A provider with no real credential reads `disconnected` even when its ledger row says otherwise, because a synthesised healthy ledger is not proof of a connection.", + "Whether the managed record's integrations are connected and how each is doing (`health`), and nothing else. Deliberately no control and no credential projection — OAuth and provider state cannot safely carry a record selector, so a Guardian can see that a pipe exists but cannot connect, disconnect, probe or resume it from here. A provider with no real credential reads `disconnected` even when its ledger row says otherwise, because a synthesised healthy ledger is not proof of a connection.", }); // ── The six family DTOs ────────────────────────────────────────────── @@ -353,7 +359,7 @@ export const recordSettingsPaths: NonNullable = { tags: ["Account sharing"], summary: "Read the managed record's integration connection states", description: - "Disclosure only: whether each of the record's integrations is connected and when it last succeeded. There is deliberately no control arm and no credential projection anywhere under this path — a Guardian cannot connect, disconnect, probe or resume a managed record's provider, because an OAuth flow cannot safely carry a record selector. Cookie session or Bearer, while acting on a managed record the caller guards.", + "Disclosure only: whether each of the record's integrations is connected, when it last succeeded, and its server-resolved health. There is deliberately no control arm and no credential projection anywhere under this path — a Guardian cannot connect, disconnect, probe or resume a managed record's provider, because an OAuth flow cannot safely carry a record selector. Cookie session or Bearer, while acting on a managed record the caller guards.", responses: { ...recordRefusal(), "200": { diff --git a/tests/integration/record-settings-integrations-health.test.ts b/tests/integration/record-settings-integrations-health.test.ts new file mode 100644 index 000000000..bbd8a61c6 --- /dev/null +++ b/tests/integration/record-settings-integrations-health.test.ts @@ -0,0 +1,125 @@ +/** + * A guardian reads the managed record's connections through the same + * server-resolved `health` the record's owner would see, and only the + * record's: a connection on the guardian's own account must not appear on + * the record, and the record's connection must answer from the record's rows. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables, switchSessionTo } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +interface ManagedIntegration { + integration: string; + state: string; + health: { state: string; reason: string } | null; +} + +async function readIntegrations(): Promise<{ + recordId: string; + integrations: ManagedIntegration[]; +}> { + const { GET } = await import("@/app/api/record-settings/integrations/route"); + const res = await GET(); + expect(res.status).toBe(200); + return (await res.json()).data; +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); +}); + +describe("GET /api/record-settings/integrations", () => { + it("publishes the record's own health per provider, never the guardian's", async () => { + const prisma = getPrismaClient(); + const guardian = await prisma.user.create({ + data: { + username: "health-guardian", + email: "health-guardian@example.test", + role: "USER", + timezone: "Europe/Berlin", + locale: "en", + onboardingCompletedAt: new Date(), + // The guardian's own Polar: must not surface on the record. + polarAccessTokenEncrypted: "not-a-real-token", + }, + }); + const { createManagedProfile } = + await import("@/lib/managed-profiles/create"); + const { profile } = await createManagedProfile({ + creatorId: guardian.id, + displayName: "Managed record", + dateOfBirth: null, + locale: "en", + timezone: "UTC", + gender: null, + }); + // The record's own Oura, synced once. + await prisma.user.update({ + where: { id: profile.id }, + data: { ouraAccessTokenEncrypted: "not-a-real-token" }, + }); + const { recordSyncSuccess } = await import("@/lib/integrations/status"); + await recordSyncSuccess(profile.id, "oura"); + + const session = await prisma.session.create({ + data: { + userId: guardian.id, + expiresAt: new Date(Date.now() + 60_000), + }, + }); + cookieJar.set("healthlog_session", session.id); + await switchSessionTo(session.id, profile.id); + + const data = await readIntegrations(); + expect(data.recordId).toBe(profile.id); + expect(data.integrations).toHaveLength(8); + + const byKey = new Map(data.integrations.map((i) => [i.integration, i])); + const oura = byKey.get("oura"); + expect(oura?.health).not.toBeNull(); + expect(oura?.health?.state).toBe("ok"); + // Health and the legacy state agree on "a pipe exists". + expect(oura?.state).toBe("connected"); + + // The guardian's Polar is the guardian's, not the record's. + expect(byKey.get("polar")?.health).toBeNull(); + for (const key of [ + "withings", + "whoop", + "fitbit", + "nightscout", + "google-health", + "strava", + ]) { + expect(byKey.get(key)?.health, key).toBeNull(); + } + }); +});