From 5a9612822ef810628503afd6a5f6cb062bdee7e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 14 May 2026 14:13:58 +0200 Subject: [PATCH] feat(withings): OAuth scope upgrade + reconnect banner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v1.4.25 W5d. The Withings Activity / Sleep endpoints sit behind the `user.activity` OAuth scope which v1.4.24-and-earlier connections never requested. This commit ships the scope plumbing + reconnect surface so existing users can opt in to step / active energy / distance / floors-climbed ingest with a single click. - Schema: `WithingsConnection.scope` nullable column. NULL = legacy connection that's never re-authed since v1.4.25. - Migration 0050 — additive column add. - Client: `WITHINGS_OAUTH_SCOPE = "user.metrics,user.activity"` + `parseWithingsScope` / `hasActivityScope` helpers. - Callback: persists `scope: WITHINGS_OAUTH_SCOPE` on every successful exchange, so reconnecting upgrades the row. - Status route: exposes `scope` + `hasActivityScope` so the UI can render the reconnect banner conditionally. - Settings → Integrations: paint a reconnect banner when the user has an active connection AND `hasActivityScope === false`. Click → /api/withings/connect → standard OAuth flow. - i18n: EN + DE strings for the banner + `measurements.types.{fatFreeMass,fatMass,muscleMass, skinTemperature,pulseWaveVelocity,vascularAge,visceralFat}`. - 5 new unit tests covering the scope helpers. Note: the Activity sync routine itself (`POST /v2/measure?action=getactivity`) lands in v1.4.26. Shipping the scope upgrade now means users only reconnect once instead of twice. Cross-source dedup protection is already in place via the W5e foundation (`pickCanonicalSourceRows`): once Activity sync lands AND a user also has Apple Health passthrough (v1.5), the per-day picker prevents double-counting cumulative metrics from two sources. --- messages/de.json | 18 ++++++++ messages/en.json | 18 ++++++++ .../migration.sql | 20 +++++++++ prisma/schema.prisma | 8 ++++ src/app/api/withings/callback/route.ts | 12 ++++- src/app/api/withings/status/route.ts | 10 ++++- .../settings/integrations-section.tsx | 38 ++++++++++++++++ src/lib/withings/__tests__/client.test.ts | 36 ++++++++++++++- src/lib/withings/client.ts | 44 ++++++++++++++++++- 9 files changed, 199 insertions(+), 5 deletions(-) create mode 100644 prisma/migrations/0050_withings_connection_scope/migration.sql diff --git a/messages/de.json b/messages/de.json index f140521eb..79ab35aaa 100644 --- a/messages/de.json +++ b/messages/de.json @@ -322,6 +322,13 @@ "typeWalkingRunningDistance": "Geh- und Laufstrecke", "typeVo2Max": "VO₂max", "typeBodyTemperature": "Körpertemperatur", + "typeFatFreeMass": "Fettfreie Masse", + "typeFatMass": "Fettmasse", + "typeMuscleMass": "Muskelmasse", + "typeSkinTemperature": "Hauttemperatur", + "typePulseWaveVelocity": "Pulswellengeschwindigkeit", + "typeVascularAge": "Gefäßalter", + "typeVisceralFat": "Viszerales Fett", "glucoseContext": "Kontext", "glucoseContextFasting": "Nüchtern (≥8 h ohne Nahrung)", "glucoseContextPostprandial": "Postprandial (2 h nach dem Essen)", @@ -1718,6 +1725,17 @@ "withingsDisconnectDescription": "Die Verbindung zu Withings wird getrennt. Bereits synchronisierte Daten bleiben erhalten.", "withingsConnect": "Mit Withings verbinden", "withingsNoCredentials": "Bitte zuerst die API-Zugangsdaten oben eingeben, um Withings zu verbinden.", + "integrations": { + "withings": { + "reconnect": { + "banner": { + "title": "Aktivitäts-Sync verfügbar", + "body": "Deine Withings-Verbindung wurde autorisiert, bevor der Aktivitäts-Sync hinzugefügt wurde. Verbinde einmal neu, um Schritte, aktive Energie, Geh- und Laufdistanz sowie Stockwerke aus deinen Withings-Geräten zu übernehmen.", + "action": "Withings neu verbinden" + } + } + } + }, "integrationStatus": { "stateConnected": "Verbunden", "stateError": "Fehler", diff --git a/messages/en.json b/messages/en.json index 77687842b..e59a588d1 100644 --- a/messages/en.json +++ b/messages/en.json @@ -322,6 +322,13 @@ "typeWalkingRunningDistance": "Walking + running distance", "typeVo2Max": "VO₂ max", "typeBodyTemperature": "Body temperature", + "typeFatFreeMass": "Fat-free mass", + "typeFatMass": "Fat mass", + "typeMuscleMass": "Muscle mass", + "typeSkinTemperature": "Skin temperature", + "typePulseWaveVelocity": "Pulse-wave velocity", + "typeVascularAge": "Vascular age", + "typeVisceralFat": "Visceral fat", "glucoseContext": "Context", "glucoseContextFasting": "Fasting (≥8h without food)", "glucoseContextPostprandial": "Post-meal (2h after eating)", @@ -1718,6 +1725,17 @@ "withingsDisconnectDescription": "The connection to Withings will be disconnected. Previously synced data will be preserved.", "withingsConnect": "Connect with Withings", "withingsNoCredentials": "Please enter your API credentials above to connect Withings.", + "integrations": { + "withings": { + "reconnect": { + "banner": { + "title": "Activity sync available", + "body": "Your Withings connection was authorised before activity sync was added. Reconnect once to enable steps, active energy, walking + running distance, and floors-climbed ingest from your Withings devices.", + "action": "Reconnect Withings" + } + } + } + }, "integrationStatus": { "stateConnected": "Connected", "stateError": "Error", diff --git a/prisma/migrations/0050_withings_connection_scope/migration.sql b/prisma/migrations/0050_withings_connection_scope/migration.sql new file mode 100644 index 000000000..977ad5a09 --- /dev/null +++ b/prisma/migrations/0050_withings_connection_scope/migration.sql @@ -0,0 +1,20 @@ +-- v1.4.25 W5d — Withings OAuth scope tracking. +-- +-- Adds a nullable `scope` column to `withings_connections` so the +-- ingest path + the Settings UI can tell which OAuth permissions a +-- given connection holds. Legacy v1.4.24 connections sit on +-- `user.metrics` only; v1.4.25 starts requesting +-- `user.metrics,user.activity` for new connections so the activity / +-- sleep endpoints unlock. +-- +-- The Settings → Integrations card surfaces a reconnect banner for +-- legacy connections (`scope IS NULL` OR scope NOT LIKE +-- '%user.activity%') so the user can re-auth and gain step / active +-- energy / distance ingest. +-- +-- Forward-only, additive. NULL is the documented "legacy connection" +-- sentinel; existing rows stay NULL and continue to read every +-- `user.metrics`-gated endpoint exactly as before. + +ALTER TABLE "withings_connections" + ADD COLUMN IF NOT EXISTS "scope" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9911e498d..ab0769b4b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -649,6 +649,14 @@ model WithingsConnection { refreshToken String @map("refresh_token") // encrypted at app level tokenExpiresAt DateTime @map("token_expires_at") lastSyncedAt DateTime? @map("last_synced_at") + /// v1.4.25 W5d — comma-separated OAuth scope string returned by the + /// Withings authorisation flow. Legacy v1.4.24- connections sit on + /// `user.metrics` only; v1.4.25 starts requesting + /// `user.metrics,user.activity` for new + reconnecting users so the + /// activity / sleep endpoints unlock. Null = legacy connection that + /// has never re-authed since v1.4.25 — the UI surfaces a banner + /// inviting them to reconnect. + scope String? @map("scope") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") diff --git a/src/app/api/withings/callback/route.ts b/src/app/api/withings/callback/route.ts index 61453dce0..f733e82d9 100644 --- a/src/app/api/withings/callback/route.ts +++ b/src/app/api/withings/callback/route.ts @@ -3,7 +3,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate, getEvent } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { encrypt } from "@/lib/crypto"; -import { exchangeCode } from "@/lib/withings/client"; +import { exchangeCode, WITHINGS_OAUTH_SCOPE } from "@/lib/withings/client"; import { getUserWithingsCredentials } from "@/lib/withings/credentials"; import { setupWebhook } from "@/lib/withings/sync"; import { markReconnected } from "@/lib/integrations/status"; @@ -72,7 +72,13 @@ export const GET = apiHandler(async (request: NextRequest) => { const expiresAt = new Date(Date.now() + tokens.expires_in * 1000); - // Upsert connection (user may be reconnecting) + // v1.4.25 W5d — persist the OAuth scope we requested. The token + // response doesn't echo the granted scopes back as a top-level + // field, but Withings honours the scope param verbatim — there's + // no scope downgrade unless the user explicitly revokes one in + // their Health Mate account, which would fail the token exchange + // entirely. Storing the requested set is therefore a safe + // representation of what the connection holds. await prisma.withingsConnection.upsert({ where: { userId: user.id }, update: { @@ -80,6 +86,7 @@ export const GET = apiHandler(async (request: NextRequest) => { accessToken: encrypt(tokens.access_token), refreshToken: encrypt(tokens.refresh_token), tokenExpiresAt: expiresAt, + scope: WITHINGS_OAUTH_SCOPE, }, create: { userId: user.id, @@ -87,6 +94,7 @@ export const GET = apiHandler(async (request: NextRequest) => { accessToken: encrypt(tokens.access_token), refreshToken: encrypt(tokens.refresh_token), tokenExpiresAt: expiresAt, + scope: WITHINGS_OAUTH_SCOPE, }, }); diff --git a/src/app/api/withings/status/route.ts b/src/app/api/withings/status/route.ts index 41f6ea1b6..68c306513 100644 --- a/src/app/api/withings/status/route.ts +++ b/src/app/api/withings/status/route.ts @@ -4,7 +4,7 @@ import { annotate, getEvent } from "@/lib/logging/context"; import { apiSuccess } from "@/lib/api-response"; import { decrypt, encrypt } from "@/lib/crypto"; import { getUserWithingsCredentials } from "@/lib/withings/credentials"; -import { refreshAccessToken } from "@/lib/withings/client"; +import { hasActivityScope, refreshAccessToken } from "@/lib/withings/client"; import { isWithingsRefreshReauthFailure } from "@/lib/withings/sync"; import { markReauthRequired } from "@/lib/integrations/status"; @@ -37,6 +37,7 @@ export const GET = apiHandler(async () => { lastSyncedAt: true, tokenExpiresAt: true, createdAt: true, + scope: true, }, }); @@ -95,5 +96,12 @@ export const GET = apiHandler(async () => { tokenExpired, tokenRefreshFailed, tokenExpiresAt, + // v1.4.25 W5d — `scope` is the comma-separated OAuth scope string + // returned during the auth flow; `hasActivityScope` is the + // pre-computed convenience flag the Settings reconnect banner + // reads. Null `scope` means a legacy v1.4.24 connection that's + // never re-authed since v1.4.25 — it counts as "missing activity". + scope: connection.scope, + hasActivityScope: hasActivityScope(connection.scope), }); }); diff --git a/src/components/settings/integrations-section.tsx b/src/components/settings/integrations-section.tsx index 9e3ea5a35..bdf2e5de9 100644 --- a/src/components/settings/integrations-section.tsx +++ b/src/components/settings/integrations-section.tsx @@ -230,6 +230,12 @@ function WithingsCard({ lastSyncedAt?: string | null; connectedAt?: string; tokenExpired?: boolean; + // v1.4.25 W5d — reconnect banner conditional. `null` = + // legacy v1.4.24 connection without `user.activity`; the + // user needs to re-auth before steps / active energy / + // distance / floors ingest unlocks. + scope?: string | null; + hasActivityScope?: boolean; }; }, enabled: isAuthenticated, @@ -352,6 +358,38 @@ function WithingsCard({
{errorMessage && } + {/* v1.4.25 W5d — Withings activity-scope reconnect banner. + Only paints when (a) the user has an active Withings + connection (so the credentials are already saved + a token + exists) AND (b) the persisted scope is missing + `user.activity`. Reconnecting takes the user through the + standard /api/withings/connect → Withings → callback flow; + the callback persists the upgraded scope and the banner + vanishes on the next status poll. */} + {status?.connected && status?.hasActivityScope === false && ( + + + {t( + "settings.integrations.withings.reconnect.banner.title", + )} + + + {t( + "settings.integrations.withings.reconnect.banner.body", + )} + + + {t( + "settings.integrations.withings.reconnect.banner.action", + )} + {" →"} + + + )}

{t("settings.withingsCredentials")} diff --git a/src/lib/withings/__tests__/client.test.ts b/src/lib/withings/__tests__/client.test.ts index 71915e903..905f238c4 100644 --- a/src/lib/withings/__tests__/client.test.ts +++ b/src/lib/withings/__tests__/client.test.ts @@ -1,5 +1,11 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { MEASURE_TYPE_MAP, fetchMeasurements } from "../client"; +import { + MEASURE_TYPE_MAP, + WITHINGS_OAUTH_SCOPE, + fetchMeasurements, + hasActivityScope, + parseWithingsScope, +} from "../client"; import { WITHINGS_NOTIFY_APPLIS } from "../sync"; /** @@ -103,6 +109,34 @@ describe("MEASURE_TYPE_MAP", () => { }); }); +describe("WITHINGS_OAUTH_SCOPE + scope helpers", () => { + it("requests user.metrics + user.activity (v1.4.25 default)", () => { + expect(WITHINGS_OAUTH_SCOPE).toBe("user.metrics,user.activity"); + }); + + it("parses a comma-separated scope string into a Set", () => { + expect(parseWithingsScope("user.metrics,user.activity")).toEqual( + new Set(["user.metrics", "user.activity"]), + ); + }); + + it("treats null/empty scope as empty set (legacy connection)", () => { + expect(parseWithingsScope(null).size).toBe(0); + expect(parseWithingsScope("").size).toBe(0); + }); + + it("hasActivityScope returns false for legacy v1.4.24 connections", () => { + expect(hasActivityScope(null)).toBe(false); + expect(hasActivityScope("user.metrics")).toBe(false); + }); + + it("hasActivityScope returns true once user.activity is granted", () => { + expect(hasActivityScope("user.metrics,user.activity")).toBe(true); + expect(hasActivityScope("user.activity")).toBe(true); + expect(hasActivityScope(" user.activity , user.metrics ")).toBe(true); + }); +}); + describe("WITHINGS_NOTIFY_APPLIS", () => { it("subscribes to weight + temperature + pressure categories", () => { expect(WITHINGS_NOTIFY_APPLIS).toEqual([1, 2, 4]); diff --git a/src/lib/withings/client.ts b/src/lib/withings/client.ts index 0a2c3e030..77af03ce6 100644 --- a/src/lib/withings/client.ts +++ b/src/lib/withings/client.ts @@ -22,6 +22,48 @@ function getRedirectUri(): string { ); } +/** + * v1.4.25 W5d — Withings OAuth scope set HealthLog requests. + * + * - `user.metrics` : every meastype the Measure / Heart-list / + * Sleep endpoints expose (weight, BP, pulse, + * SpO2, body comp, temperature, VO2 max, …) + * - `user.activity` : steps / active energy / distance / floors — + * served by `POST /v2/measure?action=getactivity`. + * The sync routine itself lands in v1.4.26; the + * scope is requested now so existing users + * reconnect once instead of twice. + * + * v1.4.24-and-earlier connections requested `user.metrics` only — the + * Settings → Integrations card surfaces a reconnect banner for those + * users (see `WithingsConnection.scope IS NULL` or scope without + * `user.activity`). + */ +export const WITHINGS_OAUTH_SCOPE = "user.metrics,user.activity" as const; + +/** + * Parse the persisted scope string into a Set for membership checks. + * Legacy NULL → empty set (treated as "no scopes yet") so the + * reconnect-banner conditional reads as truthy. + */ +export function parseWithingsScope(scope: string | null): Set { + if (!scope) return new Set(); + return new Set( + scope + .split(",") + .map((s) => s.trim()) + .filter(Boolean), + ); +} + +/** + * Returns true when the connection holds `user.activity` — the + * minimum scope required by the Activity / Sleep endpoints. + */ +export function hasActivityScope(scope: string | null): boolean { + return parseWithingsScope(scope).has("user.activity"); +} + /** * Generate Withings OAuth authorization URL. */ @@ -33,7 +75,7 @@ export function getAuthorizationUrl( response_type: "code", client_id: creds.clientId, redirect_uri: getRedirectUri(), - scope: "user.metrics", + scope: WITHINGS_OAUTH_SCOPE, state, }); return `${WITHINGS_OAUTH_URL}?${params}`;