mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +00:00
fix(oura): ask for the spo2 and heart_health scopes, explain a missing scope calmly
Oura's current authorization server enforces scopes per collection. daily_spo2 needs spo2; vO2_max, daily_cardiovascular_age and daily_resilience need heart_health. The authorize request now asks for both. A grant issued before keeps answering 401 on those four collections while every other one succeeds. Such a 401 next to a success is now recorded as a scope gap rather than a failure: it no longer counts toward the reconnect threshold, it no longer forces a token refresh once known, and the Oura card asks for one reconnect instead. The Withings card names the readings the account's API plan withholds.
This commit is contained in:
1 parent
39b0cabb43
commit
044f9008ba
29 files changed
+824
-101
No files matched your search
@@ -54,6 +54,7 @@ import {
|
||||
queryKeys,
|
||||
} from "@/lib/query-keys";
|
||||
import { MetricFreshnessDisclosure } from "./metric-freshness-disclosure";
|
||||
import { OuraScopeNote } from "./oura-scope-note";
|
||||
import {
|
||||
readSyncOutcome,
|
||||
useSyncOutcomeMessage,
|
||||
@@ -72,6 +73,7 @@ import {
|
||||
type IntegrationDocsProvider,
|
||||
} from "./setup-guide-link";
|
||||
import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls";
|
||||
import type { OuraScopeGatedCollection } from "@/lib/oura/scope";
|
||||
import { loadDocument } from "@/lib/navigation/load-document";
|
||||
|
||||
export interface OAuthProviderStatus {
|
||||
@@ -99,6 +101,8 @@ export interface OAuthProviderStatus {
|
||||
/** The server-resolved liveness verdict; the pill is a projection of it. */
|
||||
syncHealth?: SyncHealth;
|
||||
metricFreshness?: MetricFreshnessEntry[];
|
||||
/** Oura: collections the stored grant does not cover. */
|
||||
scopeReconnect?: OuraScopeGatedCollection[];
|
||||
}
|
||||
|
||||
export interface OAuthProviderCardProps {
|
||||
@@ -330,6 +334,14 @@ export function OAuthProviderCard({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{provider === "oura" && status?.connected && (
|
||||
<OuraScopeNote
|
||||
collections={status.scopeReconnect}
|
||||
onReconnect={handleConnect}
|
||||
disabled={serverUnavailable}
|
||||
/>
|
||||
)}
|
||||
|
||||
{status?.connected && (
|
||||
<MetricFreshnessDisclosure
|
||||
entries={status.metricFreshness}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import { Link2 } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { OuraScopeGatedCollection } from "@/lib/oura/scope";
|
||||
|
||||
/**
|
||||
* A calm note on the Oura card when the stored grant does not cover some
|
||||
* collections (they answer 401 while their siblings succeed). Not an error and
|
||||
* not counted toward the reconnect threshold: everything else keeps syncing,
|
||||
* and one reconnect asks Oura for the missing scopes.
|
||||
*/
|
||||
export function OuraScopeNote({
|
||||
collections,
|
||||
onReconnect,
|
||||
disabled,
|
||||
}: {
|
||||
collections: readonly OuraScopeGatedCollection[] | undefined;
|
||||
onReconnect: () => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const { t, locale } = useTranslations();
|
||||
if (!collections || collections.length === 0) return null;
|
||||
|
||||
const labels: Record<OuraScopeGatedCollection, string> = {
|
||||
spo2: t("settings.integrations.oura.scope.items.spo2"),
|
||||
vo2max: t("settings.integrations.oura.scope.items.vo2max"),
|
||||
cardio_age: t("settings.integrations.oura.scope.items.cardio_age"),
|
||||
resilience: t("settings.integrations.oura.scope.items.resilience"),
|
||||
};
|
||||
const list = new Intl.ListFormat(locale, {
|
||||
style: "long",
|
||||
type: "conjunction",
|
||||
}).format(collections.map((c) => labels[c]));
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="oura-scope-note"
|
||||
className="border-info/30 bg-info/10 flex flex-wrap items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm"
|
||||
>
|
||||
<p className="min-w-0 break-words">
|
||||
{t("settings.integrations.oura.scope.body", { list })}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onReconnect}
|
||||
disabled={disabled}
|
||||
data-testid="oura-scope-reconnect"
|
||||
className="min-h-11"
|
||||
>
|
||||
<Link2 className="h-3.5 w-3.5" />
|
||||
{t("settings.integrations.oura.scope.action")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,8 @@ import type {
|
||||
SyncVerdict,
|
||||
} from "@/lib/integrations/sync-verdict";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type { WithingsPlanFeature } from "@/lib/withings/plan-features";
|
||||
import type { OuraScopeGatedCollection } from "@/lib/oura/scope";
|
||||
|
||||
// v1.4.15 Phase B2: shared status payload for both integration cards.
|
||||
// v1.4.19 Phase A5: the redundant in-card status banner is gone — the
|
||||
@@ -70,6 +72,10 @@ export interface IntegrationStatusViewModel {
|
||||
// Withings activity-scope reconnect banner.
|
||||
scope?: string | null;
|
||||
hasActivityScope?: boolean;
|
||||
// Withings: readings the account's API plan withholds (server-resolved).
|
||||
planUnavailable?: WithingsPlanFeature[];
|
||||
// Oura: collections the stored grant does not cover (server-resolved).
|
||||
scopeReconnect?: OuraScopeGatedCollection[];
|
||||
// WHOOP / Fitbit backfill-in-progress note.
|
||||
backfillCompleted?: boolean | null;
|
||||
// v1.27.0 — Google Health surfaces `needsReauth` when the refresh token has
|
||||
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
type IntegrationStatusViewModel,
|
||||
} from "./shared";
|
||||
import { MetricFreshnessDisclosure } from "./metric-freshness-disclosure";
|
||||
import { WithingsPlanNote } from "./withings-plan-note";
|
||||
import {
|
||||
readSyncOutcome,
|
||||
useSyncOutcomeMessage,
|
||||
@@ -316,6 +317,10 @@ export function WithingsCard({
|
||||
</p>
|
||||
)}
|
||||
|
||||
{status?.connected && (
|
||||
<WithingsPlanNote features={status.planUnavailable} />
|
||||
)}
|
||||
|
||||
{status?.connected && (
|
||||
<MetricFreshnessDisclosure
|
||||
entries={viewModel?.metricFreshness}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
"use client";
|
||||
|
||||
import { ExternalLink } from "lucide-react";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
WITHINGS_API_PLANS_URL,
|
||||
type WithingsPlanFeature,
|
||||
} from "@/lib/withings/plan-features";
|
||||
|
||||
/**
|
||||
* A calm note on the Withings card when the account's API plan withholds some
|
||||
* readings (status 277 since the 2026-10-12 plan split). Not an error: the
|
||||
* sync skips those readings and keeps importing the rest, so this sits in the
|
||||
* card body in the info wash, never in the pill.
|
||||
*/
|
||||
export function WithingsPlanNote({
|
||||
features,
|
||||
}: {
|
||||
features: readonly WithingsPlanFeature[] | undefined;
|
||||
}) {
|
||||
const { t, locale } = useTranslations();
|
||||
if (!features || features.length === 0) return null;
|
||||
|
||||
const labels: Record<WithingsPlanFeature, string> = {
|
||||
spo2: t("settings.integrations.withings.plan.items.spo2"),
|
||||
vascularAge: t("settings.integrations.withings.plan.items.vascularAge"),
|
||||
pulseWaveVelocity: t(
|
||||
"settings.integrations.withings.plan.items.pulseWaveVelocity",
|
||||
),
|
||||
vo2Max: t("settings.integrations.withings.plan.items.vo2Max"),
|
||||
visceralFat: t("settings.integrations.withings.plan.items.visceralFat"),
|
||||
skinTemperature: t(
|
||||
"settings.integrations.withings.plan.items.skinTemperature",
|
||||
),
|
||||
ecg: t("settings.integrations.withings.plan.items.ecg"),
|
||||
sleepScore: t("settings.integrations.withings.plan.items.sleepScore"),
|
||||
sleepHrv: t("settings.integrations.withings.plan.items.sleepHrv"),
|
||||
sleepHeartRate: t(
|
||||
"settings.integrations.withings.plan.items.sleepHeartRate",
|
||||
),
|
||||
sleepRespiratoryRate: t(
|
||||
"settings.integrations.withings.plan.items.sleepRespiratoryRate",
|
||||
),
|
||||
sleepSpo2: t("settings.integrations.withings.plan.items.sleepSpo2"),
|
||||
bodyTemperature: t(
|
||||
"settings.integrations.withings.plan.items.bodyTemperature",
|
||||
),
|
||||
bodyFat: t("settings.integrations.withings.plan.items.bodyFat"),
|
||||
fatMass: t("settings.integrations.withings.plan.items.fatMass"),
|
||||
fatFreeMass: t("settings.integrations.withings.plan.items.fatFreeMass"),
|
||||
muscleMass: t("settings.integrations.withings.plan.items.muscleMass"),
|
||||
bodyWater: t("settings.integrations.withings.plan.items.bodyWater"),
|
||||
boneMass: t("settings.integrations.withings.plan.items.boneMass"),
|
||||
heartRate: t("settings.integrations.withings.plan.items.heartRate"),
|
||||
bloodPressure: t("settings.integrations.withings.plan.items.bloodPressure"),
|
||||
weight: t("settings.integrations.withings.plan.items.weight"),
|
||||
};
|
||||
const list = new Intl.ListFormat(locale, {
|
||||
style: "long",
|
||||
type: "conjunction",
|
||||
}).format(features.map((f) => labels[f]));
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="withings-plan-note"
|
||||
className="border-info/30 bg-info/10 space-y-1 rounded-md border px-3 py-2 text-sm"
|
||||
>
|
||||
<p className="font-medium">
|
||||
{t("settings.integrations.withings.plan.title")}
|
||||
</p>
|
||||
<p>{t("settings.integrations.withings.plan.body", { list })}</p>
|
||||
<a
|
||||
href={WITHINGS_API_PLANS_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary inline-flex items-center gap-1 text-xs font-medium underline-offset-2 hover:underline"
|
||||
>
|
||||
{t("settings.integrations.withings.plan.link")}
|
||||
<ExternalLink className="h-3 w-3" aria-hidden="true" />
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user