mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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.
This commit is contained in:
1 parent
8ebc6fe546
commit
b17a250894
16 files changed
+3201
-12
No files matched your search
+17
-9
@@ -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:
|
||||
|
||||
@@ -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" } });
|
||||
|
||||
@@ -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(
|
||||
<I18nProvider initialLocale="en">
|
||||
<ManagedIntegrationStatus />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function withHealth(map: Record<string, ConnectionHealth | null>) {
|
||||
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("<ManagedIntegrationStatus>", () => {
|
||||
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\.</g)).toHaveLength(6);
|
||||
});
|
||||
|
||||
it("says once why nothing here can be changed, and links the explanation", () => {
|
||||
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.");
|
||||
});
|
||||
});
|
||||
@@ -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) =>
|
||||
/<IntegrationStatusPill\b/.test(readFileSync(join(SRC, rel), "utf8")),
|
||||
);
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { ConnectionHealth } from "@/lib/integrations/connection-health";
|
||||
|
||||
/** A `ConnectionHealth` as the server would publish it, with overrides. */
|
||||
export function healthFixture(
|
||||
overrides: Partial<ConnectionHealth> = {},
|
||||
): 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,
|
||||
};
|
||||
}
|
||||
@@ -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<typeof import("../integration-card")>();
|
||||
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<IntegrationStatusViewModel> | undefined,
|
||||
locale: Locale = "en",
|
||||
) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<OAuthIntegrationCard
|
||||
config={OAUTH_INTEGRATIONS[provider]}
|
||||
viewModel={
|
||||
viewModel
|
||||
? ({
|
||||
integration: provider,
|
||||
lastError: null,
|
||||
...viewModel,
|
||||
} as IntegrationStatusViewModel)
|
||||
: undefined
|
||||
}
|
||||
callbackUrl={`https://app.example/api/${provider}/callback`}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
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("<IntegrationCard> 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(
|
||||
/<div[^>]*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("<IntegrationCard> 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(
|
||||
/<button[^>]*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(
|
||||
/<span[^>]*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("<IntegrationCard> 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(
|
||||
/<pre[^>]*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");
|
||||
});
|
||||
});
|
||||
@@ -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<Locale, unknown> = {
|
||||
de,
|
||||
en,
|
||||
es,
|
||||
fr,
|
||||
it: itMessages,
|
||||
pl,
|
||||
ko,
|
||||
};
|
||||
|
||||
function translator(locale: Locale) {
|
||||
return (key: string, params?: Record<string, string | number>) => {
|
||||
const value = key
|
||||
.split(".")
|
||||
.reduce<unknown>(
|
||||
(node, part) => (node as Record<string, unknown> | 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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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/<provider>/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 (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="space-y-3"
|
||||
data-testid={`${provider}-credentials`}
|
||||
>
|
||||
<h3 className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
|
||||
{t(`${i18nPrefix}Credentials`)}
|
||||
</h3>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`${provider}-clientid`}>
|
||||
{t(`${i18nPrefix}ClientId`)}
|
||||
</Label>
|
||||
<Input
|
||||
id={`${provider}-clientid`}
|
||||
value={clientId}
|
||||
onChange={(e) => setClientId(e.target.value)}
|
||||
placeholder={
|
||||
saved
|
||||
? t(`${i18nPrefix}CredentialsSavedPlaceholder`)
|
||||
: t(`${i18nPrefix}ClientId`)
|
||||
}
|
||||
maxLength={200}
|
||||
autoComplete="off"
|
||||
inputMode="text"
|
||||
spellCheck={false}
|
||||
autoCapitalize="none"
|
||||
enterKeyHint="next"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`${provider}-secret`}>
|
||||
{t(`${i18nPrefix}ClientSecret`)}
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id={`${provider}-secret`}
|
||||
value={clientSecret}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{msg && (
|
||||
<p
|
||||
role={msg.kind === "error" ? "alert" : "status"}
|
||||
className={`text-sm ${msg.kind === "success" ? "text-success" : "text-destructive"}`}
|
||||
>
|
||||
{msg.text}
|
||||
</p>
|
||||
)}
|
||||
<SettingsCardActions>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 w-full sm:min-h-9 sm:w-auto"
|
||||
disabled={saving || !clientId.trim() || !clientSecret.trim()}
|
||||
>
|
||||
{saving ? (
|
||||
<Loader2 className="size-3.5 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Save className="size-3.5" aria-hidden="true" />
|
||||
)}
|
||||
{t(`${i18nPrefix}SaveCredentials`)}
|
||||
</Button>
|
||||
</SettingsCardActions>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -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/<provider>/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<SyncOutcomeState | null>(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<ConnectionTestResult | null>(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<ProviderSync, "syncing" | "result">;
|
||||
test?: ReturnType<typeof useConnectionTest>;
|
||||
}) {
|
||||
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 (
|
||||
<div className="space-y-2" data-slot="integration-action-results">
|
||||
{busy ? (
|
||||
<p
|
||||
role="status"
|
||||
className="text-muted-foreground flex items-center gap-1.5 text-xs"
|
||||
>
|
||||
<Loader2
|
||||
aria-hidden="true"
|
||||
className="size-3.5 animate-spin motion-reduce:animate-none"
|
||||
/>
|
||||
{sync?.syncing
|
||||
? t("settings.integrationCard.syncing")
|
||||
: t("settings.testConnection.testing")}
|
||||
</p>
|
||||
) : null}
|
||||
{syncResult ? (
|
||||
<WrittenOutcomeLine
|
||||
outcome={syncResult.outcome}
|
||||
message={syncResult.message}
|
||||
testId={`${provider}-sync-result`}
|
||||
/>
|
||||
) : null}
|
||||
{test?.result?.kind === "ok" ? (
|
||||
<p
|
||||
role="status"
|
||||
className="text-success flex items-center gap-1.5 text-xs"
|
||||
data-testid={`${provider}-test-result`}
|
||||
>
|
||||
<CheckCircle2 aria-hidden="true" className="size-3.5" />
|
||||
{t("settings.testConnection.ok", { latency: test.result.latency })}
|
||||
</p>
|
||||
) : null}
|
||||
{test?.result?.kind === "error" ? (
|
||||
<TestConnectionFailure
|
||||
message={describeError(test.result.errorCode)}
|
||||
upstreamStatus={test.result.upstreamStatus}
|
||||
smtpCode={test.result.smtpCode}
|
||||
upstreamBody={test.result.upstreamBody}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,603 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* `<IntegrationCard>` — 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<ConnectionHealthState, string> = {
|
||||
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 (
|
||||
<Badge
|
||||
data-slot="integration-health-badge"
|
||||
data-state={state}
|
||||
variant="outline"
|
||||
className={cn("gap-1.5", BADGE_TONE[state])}
|
||||
>
|
||||
<span aria-hidden="true" className="size-1.5 rounded-full bg-current" />
|
||||
{healthBadgeLabel(state, t)}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export interface IntegrationMenuAction {
|
||||
/** Stable id; becomes `data-action` and the `<provider>-menu-<id>` 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 `<provider>-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 = (
|
||||
<SettingsCardHeader
|
||||
icon={icon}
|
||||
title={title ?? providerName}
|
||||
titleAccessory={titleAccessory}
|
||||
description={!loading && !connected ? description : undefined}
|
||||
status={
|
||||
connected && !loading && health ? (
|
||||
<HealthBadge state={health.state} />
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
const shell = (body: ReactNode) => (
|
||||
<SettingsCard
|
||||
data-testid={testId ?? `${provider}-card`}
|
||||
data-slot="integration-card"
|
||||
data-integration={provider}
|
||||
data-state={cardState}
|
||||
>
|
||||
{header}
|
||||
{body}
|
||||
</SettingsCard>
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
// A loading state paints the card and its header (§13), so the page does
|
||||
// not move when the status lands.
|
||||
return shell(
|
||||
<div className="space-y-2" aria-busy="true">
|
||||
<Skeleton className="h-4 w-2/3" />
|
||||
<Skeleton className="h-3 w-1/3" />
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
|
||||
if (!connected) {
|
||||
return shell(
|
||||
<>
|
||||
<p className="text-sm" data-slot="integration-sentence">
|
||||
{t("settings.integrationCard.notConnected")}
|
||||
</p>
|
||||
{setup}
|
||||
{children}
|
||||
{connectAction ? (
|
||||
<SettingsCardActions data-slot="integration-actions">
|
||||
{connectAction}
|
||||
</SettingsCardActions>
|
||||
) : 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 ? (
|
||||
<PrimaryButton
|
||||
action="reconnect"
|
||||
icon={Link2}
|
||||
label={t("settings.integrationCard.reconnect")}
|
||||
onClick={primary.onReconnect}
|
||||
pending={primary.pending}
|
||||
disabled={primary.disabled}
|
||||
provider={provider}
|
||||
/>
|
||||
) : action === "resume" && primary?.onResume ? (
|
||||
<PrimaryButton
|
||||
action="resume"
|
||||
icon={Play}
|
||||
label={t("settings.integrationCard.resume")}
|
||||
onClick={primary.onResume}
|
||||
pending={primary.pending}
|
||||
disabled={primary.disabled}
|
||||
provider={provider}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
return shell(
|
||||
<>
|
||||
{sentence || lastData ? (
|
||||
<div className="space-y-1">
|
||||
{sentence ? (
|
||||
<p className="text-sm" data-slot="integration-sentence">
|
||||
{sentence}
|
||||
</p>
|
||||
) : null}
|
||||
{lastData ? (
|
||||
<p
|
||||
className="text-muted-foreground text-xs"
|
||||
data-slot="integration-last-data"
|
||||
>
|
||||
{lastData}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{children}
|
||||
{health ? (
|
||||
<DetailsPanel
|
||||
id={detailsId}
|
||||
open={detailsOpen}
|
||||
text={diagnosticsText(
|
||||
{
|
||||
version: APP_VERSION,
|
||||
provider: providerName,
|
||||
health,
|
||||
extra: diagnosticsExtra,
|
||||
},
|
||||
t,
|
||||
)}
|
||||
unavailable={health.unavailable.map((u) =>
|
||||
unavailableItemLabel(u.item, t),
|
||||
)}
|
||||
>
|
||||
{details}
|
||||
</DetailsPanel>
|
||||
) : details ? (
|
||||
<div className="space-y-3">{details}</div>
|
||||
) : null}
|
||||
{health || menu.length > 0 || primaryButton ? (
|
||||
<SettingsCardActions
|
||||
data-slot="integration-actions"
|
||||
className="justify-between"
|
||||
>
|
||||
{health ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={detailsOpen}
|
||||
aria-controls={detailsId}
|
||||
onClick={() => setDetailsOpen((open) => !open)}
|
||||
data-slot="integration-details-toggle"
|
||||
data-testid={`${provider}-details-toggle`}
|
||||
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 -ml-1 flex min-h-11 items-center gap-2 rounded-md px-1 text-sm outline-none focus-visible:ring-[3px] sm:min-h-9"
|
||||
>
|
||||
<ChevronDown
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"size-3.5 shrink-0 transition-transform motion-reduce:transition-none",
|
||||
detailsOpen && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
{t("settings.integrationCard.details.title")}
|
||||
</button>
|
||||
) : null}
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{menu.length > 0 ? (
|
||||
<IntegrationMenu
|
||||
provider={provider}
|
||||
providerName={providerName}
|
||||
items={menu}
|
||||
/>
|
||||
) : null}
|
||||
{primaryButton}
|
||||
</div>
|
||||
</SettingsCardActions>
|
||||
) : 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 (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={onClick}
|
||||
disabled={disabled || pending}
|
||||
aria-busy={pending || undefined}
|
||||
data-slot="integration-primary-action"
|
||||
data-action={action}
|
||||
data-testid={`${provider}-${action}-button`}
|
||||
>
|
||||
{pending ? (
|
||||
<Loader2 className="size-3.5 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Icon className="size-3.5" aria-hidden="true" />
|
||||
)}
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<IntegrationMenuAction | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="min-h-11 min-w-11 sm:size-9 sm:min-h-9 sm:min-w-9"
|
||||
aria-label={t("settings.integrationCard.menuLabel", {
|
||||
provider: providerName,
|
||||
})}
|
||||
data-slot="integration-menu-trigger"
|
||||
data-testid={`${provider}-menu`}
|
||||
>
|
||||
<MoreHorizontal className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className="w-64 max-w-[calc(100vw-2rem)]"
|
||||
data-slot="integration-menu"
|
||||
>
|
||||
{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 (
|
||||
<DropdownMenuItem key={item.id} asChild {...common}>
|
||||
<a
|
||||
href={item.href}
|
||||
{...(item.external
|
||||
? { target: "_blank", rel: "noopener noreferrer" }
|
||||
: {})}
|
||||
>
|
||||
<Icon aria-hidden="true" />
|
||||
{item.label}
|
||||
{item.external ? (
|
||||
<ExternalLink
|
||||
aria-hidden="true"
|
||||
className="ml-auto size-3"
|
||||
/>
|
||||
) : null}
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={item.id}
|
||||
{...common}
|
||||
onSelect={() => {
|
||||
if (item.confirm) setConfirming(item);
|
||||
else item.onSelect?.();
|
||||
}}
|
||||
>
|
||||
<Icon aria-hidden="true" />
|
||||
{item.label}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{confirming?.confirm ? (
|
||||
<ConfirmDialog
|
||||
open
|
||||
onOpenChange={(open) => {
|
||||
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 (
|
||||
<div
|
||||
id={id}
|
||||
hidden={!open}
|
||||
className="space-y-3"
|
||||
data-slot="integration-details"
|
||||
data-state={open ? "open" : "closed"}
|
||||
>
|
||||
{unavailable.length > 0 ? (
|
||||
<p className="text-sm" data-slot="integration-unavailable-list">
|
||||
{t("settings.integrationCard.details.unavailableList", {
|
||||
list: unavailable.join(", "),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
{children}
|
||||
<pre
|
||||
className="bg-muted/40 text-foreground overflow-x-auto rounded-lg border p-3 font-mono text-xs leading-relaxed whitespace-pre"
|
||||
data-slot="integration-diagnostics"
|
||||
>
|
||||
{text}
|
||||
</pre>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 sm:min-h-9"
|
||||
onClick={handleCopy}
|
||||
data-slot="integration-diagnostics-copy"
|
||||
>
|
||||
{copy === "copied" ? (
|
||||
<Check className="size-3.5" aria-hidden="true" />
|
||||
) : (
|
||||
<Copy className="size-3.5" aria-hidden="true" />
|
||||
)}
|
||||
{t("common.copy")}
|
||||
</Button>
|
||||
<span
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-slot="integration-diagnostics-copy-status"
|
||||
data-state={copy}
|
||||
className={cn(
|
||||
"text-xs",
|
||||
copy === "failed" ? "text-destructive" : "text-success",
|
||||
)}
|
||||
>
|
||||
{copy === "copied"
|
||||
? t("common.copied")
|
||||
: copy === "failed"
|
||||
? t("settings.integrationCard.details.copyFailed")
|
||||
: ""}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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, string | number>,
|
||||
) => 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");
|
||||
}
|
||||
@@ -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 (
|
||||
<div data-slot="metric-freshness" className="space-y-2">
|
||||
<p className="flex min-w-0 flex-wrap items-baseline gap-x-2">
|
||||
<span className="text-sm font-medium">
|
||||
{title ?? t("settings.integrationFreshness.title")}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("settings.integrationFreshness.summary", {
|
||||
count: entries.length,
|
||||
relative: relative(newest.lastSeenAt),
|
||||
})}
|
||||
{staleCount > 0 ? (
|
||||
<span className="text-warning">
|
||||
{", "}
|
||||
{t("settings.integrationFreshness.quiet", { count: staleCount })}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<ul className={detail ? "space-y-2.5" : "space-y-1"}>
|
||||
{sorted.map((entry) => {
|
||||
const extra = detail?.(entry);
|
||||
return (
|
||||
<li
|
||||
key={entry.type}
|
||||
data-slot="metric-freshness-row"
|
||||
data-metric={entry.type}
|
||||
data-state={entry.stale ? "stale" : "fresh"}
|
||||
className={cn(
|
||||
"text-xs",
|
||||
entry.stale ? "text-warning" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="flex min-w-0 items-center gap-1.5">
|
||||
{entry.stale ? (
|
||||
<AlertTriangle
|
||||
aria-hidden="true"
|
||||
className="size-3 shrink-0"
|
||||
/>
|
||||
) : null}
|
||||
{/* With a detail line under it, the type name is the
|
||||
row's content and the line its meta. */}
|
||||
<span
|
||||
className={cn(
|
||||
"truncate",
|
||||
extra && !entry.stale && "text-foreground",
|
||||
)}
|
||||
>
|
||||
{label(entry.type)}
|
||||
</span>
|
||||
</span>
|
||||
<time
|
||||
dateTime={entry.lastSeenAt}
|
||||
className="shrink-0 tabular-nums"
|
||||
>
|
||||
{relative(entry.lastSeenAt)}
|
||||
</time>
|
||||
</div>
|
||||
{extra ? (
|
||||
<div data-slot="metric-freshness-detail" className="mt-0.5">
|
||||
{extra}
|
||||
</div>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 `<IntegrationCard>` 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/<key>/…` 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 <OAuthIntegrationCardView {...props} sync={sync} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = (
|
||||
<CredentialsForm
|
||||
provider={provider}
|
||||
i18nPrefix={i18nPrefix}
|
||||
saved={ownCredentials}
|
||||
secretPlaceholder={config.secretPlaceholder}
|
||||
statusQueryKey={statusQueryKey}
|
||||
/>
|
||||
);
|
||||
|
||||
const description = (
|
||||
<IntegrationCardDescription
|
||||
i18nPrefix={i18nPrefix}
|
||||
provider={config.docs}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!connected) {
|
||||
return (
|
||||
<IntegrationCard
|
||||
provider={provider}
|
||||
providerName={providerName}
|
||||
icon={icon ?? config.icon}
|
||||
titleAccessory={titleAccessory}
|
||||
description={description}
|
||||
loading={viewModel === undefined}
|
||||
connected={false}
|
||||
health={null}
|
||||
setup={
|
||||
<div className="space-y-3">
|
||||
{config.redirectGuide && !ownCredentials ? (
|
||||
<IntegrationRedirectGuide
|
||||
provider={config.docs}
|
||||
providerLabel={providerName}
|
||||
callbackUrl={callbackUrl}
|
||||
/>
|
||||
) : null}
|
||||
{credentialsForm}
|
||||
{!configured ? (
|
||||
<p
|
||||
className="text-muted-foreground text-xs"
|
||||
data-testid={
|
||||
config.credentials === "required"
|
||||
? "integration-unavailable"
|
||||
: `${provider}-unavailable`
|
||||
}
|
||||
>
|
||||
{config.credentials === "required"
|
||||
? t(`${i18nPrefix}NoCredentials`)
|
||||
: t(`${i18nPrefix}Unavailable`)}
|
||||
</p>
|
||||
) : null}
|
||||
<CallbackMismatchNotice
|
||||
provider={providerName}
|
||||
callbackUrl={callbackUrl}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
connectAction={
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="min-h-11 w-full sm:min-h-9 sm:w-auto"
|
||||
disabled={!configured}
|
||||
onClick={connect}
|
||||
data-slot="integration-primary-action"
|
||||
data-action="connect"
|
||||
data-testid={`${provider}-connect`}
|
||||
>
|
||||
<Link2 className="size-3.5" aria-hidden="true" />
|
||||
{t(`${i18nPrefix}Connect`)}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<IntegrationCard
|
||||
provider={provider}
|
||||
providerName={providerName}
|
||||
icon={icon ?? config.icon}
|
||||
titleAccessory={titleAccessory}
|
||||
connected
|
||||
health={viewModel?.health ?? null}
|
||||
historyLoading={viewModel?.backfillCompleted === false}
|
||||
primary={{
|
||||
onReconnect: connect,
|
||||
onResume: config.resumeEndpoint ? () => 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 ? (
|
||||
<a
|
||||
href={WITHINGS_API_PLANS_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
data-testid="withings-plan-link"
|
||||
className="text-primary inline-flex min-h-11 items-center gap-1 text-sm underline-offset-2 hover:underline sm:min-h-0"
|
||||
>
|
||||
{t("settings.integrations.withings.plan.link")}
|
||||
<ExternalLink className="size-3" aria-hidden="true" />
|
||||
</a>
|
||||
) : null}
|
||||
{details}
|
||||
<MetricFreshnessList entries={viewModel?.metricFreshness} />
|
||||
</>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{syncResults ?? (
|
||||
<ActionResultLines provider={provider} sync={sync} test={test} />
|
||||
)}
|
||||
{syncResults ? (
|
||||
<ActionResultLines provider={provider} test={test} />
|
||||
) : null}
|
||||
{resume.isError ? (
|
||||
<p
|
||||
role="alert"
|
||||
className="text-destructive text-sm"
|
||||
data-testid={`${provider}-resume-error`}
|
||||
>
|
||||
{t("settings.integrationPill.resumeError")}
|
||||
</p>
|
||||
) : null}
|
||||
{resume.isSuccess && resume.data?.wasParked ? (
|
||||
<p
|
||||
role="status"
|
||||
className="text-success text-xs"
|
||||
data-testid={`${provider}-resume-success`}
|
||||
>
|
||||
{t("settings.integrationPill.resumeSuccess")}
|
||||
</p>
|
||||
) : null}
|
||||
{editingCredentials ? credentialsForm : null}
|
||||
<CallbackMismatchNotice
|
||||
provider={providerName}
|
||||
callbackUrl={callbackUrl}
|
||||
/>
|
||||
</IntegrationCard>
|
||||
);
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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<ZodOpenApiObject["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.",
|
||||
"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": {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user