mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
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:
1 parent
6f04123982
commit
5a9612822e
9 files changed
+199
-5
No files matched your search
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
Reference in new issue
Block a user