mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +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),
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user