feat(withings): OAuth scope upgrade + reconnect banner

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.
This commit is contained in:
Marc-André Bombeck committed 2026-05-14 14:13:58 +02:00
1 parent 6f04123982
commit 5a9612822e
9 files changed
+199 -5

No files matched your search

+18
View File
@@ -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",
+18
View File
@@ -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",
@@ -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;
+8
View File
@@ -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")
+10 -2
View File
@@ -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,
},
});
+9 -1
View File
@@ -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),
});
});
@@ -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({
<div className="mt-4 space-y-4">
{errorMessage && <IntegrationErrorMessage message={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 && (
<a
href="/api/withings/connect"
data-testid="withings-reconnect-banner"
className="border-warning/30 bg-warning/10 text-warning-foreground hover:bg-warning/20 block rounded-md border px-3 py-2 text-sm transition-colors"
>
<span className="font-medium">
{t(
"settings.integrations.withings.reconnect.banner.title",
)}
</span>
<span className="text-muted-foreground block text-xs">
{t(
"settings.integrations.withings.reconnect.banner.body",
)}
</span>
<span className="text-primary mt-1 inline-block text-xs font-medium">
{t(
"settings.integrations.withings.reconnect.banner.action",
)}
{" →"}
</span>
</a>
)}
<div className="space-y-3">
<h3 className="text-sm font-medium">
{t("settings.withingsCredentials")}
+35 -1
View File
@@ -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]);
+43 -1
View File
@@ -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<string> {
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}`;