From fb73c971252e1db03a39842b063a834dcd575ef0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 17 Jul 2026 23:08:19 +0200 Subject: [PATCH] fix(onboarding): stop the Withings connect dead-end and disclose BYO setup The onboarding "Connect Withings" tap opened /api/withings/connect in a new tab, which returned a raw JSON 400 for every brand-new account (Withings credentials are per-user BYO with no env fallback, and the wizard never collects them). WHOOP, Fitbit, Polar, Oura, and Strava shared the identical raw-JSON-on-no-creds pattern on their own /connect routes, while every other failure branch already redirected with a toast reason. All six now redirect to /settings/integrations?=error&reason=nocreds instead, and the wizard's Withings card checks the integrations-status envelope first: unless credentials are already configured, the CTA reads "Set up in Settings" and links straight to the credentials form rather than launching a doomed OAuth handshake. Also surfaces the BYO-developer-app requirement, which was invisible until a user was deep inside a Settings card. The source-picker step now says up front that device sources need a free developer app on the user's own account; each of the six provider cards gets a compact 3-step guide with the exact callback URL to register, shown until the user has saved their own credentials. --- messages/de.json | 16 ++- messages/en.json | 16 ++- messages/es.json | 16 ++- messages/fr.json | 16 ++- messages/it.json | 16 ++- messages/pl.json | 16 ++- .../fitbit/connect/__tests__/route.test.ts | 115 ++++++++++++++++++ src/app/api/fitbit/connect/route.ts | 10 +- .../api/oura/connect/__tests__/route.test.ts | 69 +++++++++++ src/app/api/oura/connect/route.ts | 14 ++- .../api/polar/connect/__tests__/route.test.ts | 12 +- src/app/api/polar/connect/route.ts | 14 ++- .../strava/connect/__tests__/route.test.ts | 69 +++++++++++ src/app/api/strava/connect/route.ts | 15 ++- .../api/whoop/connect/__tests__/route.test.ts | 12 ++ src/app/api/whoop/connect/route.ts | 11 +- .../withings/connect/__tests__/route.test.ts | 17 +++ src/app/api/withings/connect/route.ts | 13 +- .../__tests__/source-card-grid.test.tsx | 91 ++++++++++++++ .../onboarding/source-card-grid.tsx | 85 ++++++++++--- .../__tests__/oauth-provider-card.test.tsx | 30 +++++ .../__tests__/whoop-card.test.tsx | 41 +++++++ .../settings/integrations/fitbit-card.tsx | 11 +- .../integrations/oauth-provider-card.tsx | 7 ++ .../integrations/setup-guide-link.tsx | 63 ++++++++++ .../settings/integrations/whoop-card.tsx | 11 +- .../settings/integrations/withings-card.tsx | 11 +- 27 files changed, 744 insertions(+), 73 deletions(-) create mode 100644 src/app/api/fitbit/connect/__tests__/route.test.ts create mode 100644 src/app/api/oura/connect/__tests__/route.test.ts create mode 100644 src/app/api/strava/connect/__tests__/route.test.ts create mode 100644 src/components/onboarding/__tests__/source-card-grid.test.tsx create mode 100644 src/components/settings/integrations/__tests__/whoop-card.test.tsx diff --git a/messages/de.json b/messages/de.json index f0ac137eb..c9d7aef88 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4016,7 +4016,7 @@ }, "source": { "title": "Quelle verbinden", - "body": "Werte automatisch übernehmen oder weiter manuell eintragen. Hier wird nichts für dich eingerichtet – jede Karte zeigt nur, wo du sie verbindest.", + "body": "Werte automatisch übernehmen oder weiter manuell eintragen. Hier wird nichts für dich eingerichtet – jede Karte zeigt nur, wo du sie verbindest. Geräte-Quellen brauchen eine kostenlose Entwickler-App auf deinem eigenen Konto (etwa 5 Minuten); die Anleitung auf jeder Karte führt dich durch.", "recommended": "Empfohlen", "manual": { "title": "Manuelle Eingabe", @@ -4024,12 +4024,14 @@ }, "withings": { "title": "Withings", - "body": "Waagen, Blutdruckmesser und Tracker von Withings synchronisieren automatisch.", - "cta": "Withings verbinden" + "body": "Waagen, Blutdruckmesser und Tracker von Withings synchronisieren automatisch, sobald verbunden.", + "cta": "Withings verbinden", + "setupCta": "In den Einstellungen einrichten", + "setupHint": "Braucht zuerst deine eigene, kostenlose Withings-Entwickler-App – etwa 5 Minuten, einmalig." }, "more": { "title": "Weitere Quellen", - "body": "Verbinde eine davon in den Einstellungen, wann immer du bereit bist.", + "body": "Verbinde eine davon in den Einstellungen, wann immer du bereit bist – die meisten brauchen zuerst eine kostenlose Entwickler-App auf deinem eigenen Konto.", "appleHealth": "Apple Health", "whoop": "WHOOP", "oura": "Oura", @@ -5389,6 +5391,12 @@ "ouraDescriptionSecondary": "Werte fließen im Hintergrund in dein Dashboard. Neu beim Einrichten? Folge der", "nightscoutDescriptionSecondary": "Werte fließen im Hintergrund in dein Dashboard. Neu beim Einrichten? Folge der", "integrationCredentialsHint": "Wo du diese Zugangsdaten findest und wie du deine eigene App registrierst, steht in der Einrichtungsanleitung oben.", + "integrationRedirectGuide": { + "intro": "Die Verbindung läuft über deine eigene {provider}-Entwickler-App – etwa 5 Minuten Einrichtung:", + "step1": "Erstelle eine App im {provider}-Entwicklerportal.", + "step2": "Setze deren Weiterleitungs-/Callback-URL auf:", + "step3": "Trage die dort erhaltene Client-ID und das Client-Secret oben ein." + }, "security": { "sessionsTitle": "Aktive Sitzungen", "sessionsDescription": "Geräte und Browser, die aktuell bei deinem Konto angemeldet sind.", diff --git a/messages/en.json b/messages/en.json index 506d46265..c0ca0352a 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4016,7 +4016,7 @@ }, "source": { "title": "Connect a source", - "body": "Bring in measurements automatically, or stay on manual entry. Nothing here is configured for you — each card just shows where to connect it.", + "body": "Bring in measurements automatically, or stay on manual entry. Nothing here is configured for you — each card just shows where to connect it. Device sources need a free developer app registered on your own account (about 5 minutes); the setup guide on each card walks through it.", "recommended": "Recommended", "manual": { "title": "Manual entry", @@ -4024,12 +4024,14 @@ }, "withings": { "title": "Withings", - "body": "Scales, blood-pressure cuffs and trackers from Withings sync automatically.", - "cta": "Connect Withings" + "body": "Scales, blood-pressure cuffs and trackers from Withings sync automatically once connected.", + "cta": "Connect Withings", + "setupCta": "Set up in Settings", + "setupHint": "Needs your own free Withings developer app first — about 5 minutes, one time." }, "more": { "title": "More sources", - "body": "Connect any of these in Settings whenever you're ready.", + "body": "Connect any of these in Settings whenever you're ready — most need a free developer app registered on your own account first.", "appleHealth": "Apple Health", "whoop": "WHOOP", "oura": "Oura", @@ -5389,6 +5391,12 @@ "ouraDescriptionSecondary": "Readings flow into your dashboard in the background. New to the setup? Follow the", "nightscoutDescriptionSecondary": "Readings flow into your dashboard in the background. New to the setup? Follow the", "integrationCredentialsHint": "Where to find these credentials and how to register your own app is covered in the setup guide above.", + "integrationRedirectGuide": { + "intro": "This connects through your own {provider} developer app — about 5 minutes to set up:", + "step1": "Create an app in the {provider} developer portal.", + "step2": "Set its redirect / callback URL to:", + "step3": "Paste the client ID and client secret it gives you above." + }, "security": { "sessionsTitle": "Active sessions", "sessionsDescription": "Devices and browsers currently signed in to your account.", diff --git a/messages/es.json b/messages/es.json index edc3a2ed5..c30794199 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4016,7 +4016,7 @@ }, "source": { "title": "Conectar una fuente", - "body": "Importa las mediciones automáticamente o sigue con la entrada manual. Aquí no se configura nada por ti: cada tarjeta solo muestra dónde conectarla.", + "body": "Importa las mediciones automáticamente o sigue con la entrada manual. Aquí no se configura nada por ti: cada tarjeta solo muestra dónde conectarla. Las fuentes de dispositivo necesitan una app de desarrollador gratuita en tu propia cuenta (unos 5 minutos); la guía de cada tarjeta te lo explica.", "recommended": "Recomendado", "manual": { "title": "Entrada manual", @@ -4024,12 +4024,14 @@ }, "withings": { "title": "Withings", - "body": "Las básculas, los tensiómetros y los trackers de Withings se sincronizan automáticamente.", - "cta": "Conectar Withings" + "body": "Las básculas, los tensiómetros y los trackers de Withings se sincronizan automáticamente una vez conectados.", + "cta": "Conectar Withings", + "setupCta": "Configurar en Ajustes", + "setupHint": "Necesita primero tu propia app de desarrollador de Withings, gratuita — unos 5 minutos, una sola vez." }, "more": { "title": "Más fuentes", - "body": "Conecta cualquiera de ellas en Ajustes cuando quieras.", + "body": "Conecta cualquiera de ellas en Ajustes cuando quieras — la mayoría necesita antes una app de desarrollador gratuita en tu propia cuenta.", "appleHealth": "Apple Health", "whoop": "WHOOP", "oura": "Oura", @@ -5389,6 +5391,12 @@ "ouraDescriptionSecondary": "Las lecturas llegan a tu panel en segundo plano. ¿Es tu primera vez? Sigue la", "nightscoutDescriptionSecondary": "Las lecturas llegan a tu panel en segundo plano. ¿Es tu primera vez? Sigue la", "integrationCredentialsHint": "Dónde encontrar estas credenciales y cómo registrar tu propia aplicación se explica en la guía de configuración de arriba.", + "integrationRedirectGuide": { + "intro": "Esto se conecta mediante tu propia app de desarrollador de {provider} — unos 5 minutos de configuración:", + "step1": "Crea una app en el portal de desarrolladores de {provider}.", + "step2": "Configura su URL de redirección/callback en:", + "step3": "Pega arriba el ID de cliente y el secreto de cliente que te da." + }, "security": { "sessionsTitle": "Sesiones activas", "sessionsDescription": "Dispositivos y navegadores con sesión iniciada en tu cuenta.", diff --git a/messages/fr.json b/messages/fr.json index 49c4f7b9e..33b44e7a3 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4016,7 +4016,7 @@ }, "source": { "title": "Connecter une source", - "body": "Importez les mesures automatiquement ou conservez la saisie manuelle. Rien n'est configuré à votre place : chaque carte indique simplement où la connecter.", + "body": "Importez les mesures automatiquement ou conservez la saisie manuelle. Rien n'est configuré à votre place : chaque carte indique simplement où la connecter. Les sources d'appareil nécessitent une application développeur gratuite sur votre propre compte (environ 5 minutes) ; le guide de chaque carte vous accompagne.", "recommended": "Recommandé", "manual": { "title": "Saisie manuelle", @@ -4024,12 +4024,14 @@ }, "withings": { "title": "Withings", - "body": "Balances, tensiomètres et trackers Withings se synchronisent automatiquement.", - "cta": "Connecter Withings" + "body": "Balances, tensiomètres et trackers Withings se synchronisent automatiquement une fois connectés.", + "cta": "Connecter Withings", + "setupCta": "Configurer dans les Réglages", + "setupHint": "Nécessite d'abord votre propre application développeur Withings, gratuite — environ 5 minutes, une seule fois." }, "more": { "title": "Autres sources", - "body": "Connectez l'une d'elles dans les Réglages quand vous le souhaitez.", + "body": "Connectez l'une d'elles dans les Réglages quand vous le souhaitez — la plupart nécessitent d'abord une application développeur gratuite sur votre propre compte.", "appleHealth": "Apple Health", "whoop": "WHOOP", "oura": "Oura", @@ -5389,6 +5391,12 @@ "ouraDescriptionSecondary": "Les mesures arrivent dans ton tableau de bord en arrière-plan. Première fois ? Suis le", "nightscoutDescriptionSecondary": "Les mesures arrivent dans ton tableau de bord en arrière-plan. Première fois ? Suis le", "integrationCredentialsHint": "Où trouver ces identifiants et comment enregistrer ta propre application est expliqué dans le guide de configuration ci-dessus.", + "integrationRedirectGuide": { + "intro": "Cette connexion passe par votre propre application développeur {provider} — environ 5 minutes de configuration :", + "step1": "Créez une application dans le portail développeur {provider}.", + "step2": "Réglez son URL de redirection/callback sur :", + "step3": "Collez ci-dessus l'ID client et le secret client fournis." + }, "security": { "sessionsTitle": "Sessions actives", "sessionsDescription": "Appareils et navigateurs actuellement connectés à votre compte.", diff --git a/messages/it.json b/messages/it.json index 5ce77c180..79b40580f 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4016,7 +4016,7 @@ }, "source": { "title": "Connetti una sorgente", - "body": "Importa le misurazioni in automatico oppure resta sull'inserimento manuale. Qui non viene configurato nulla al posto tuo: ogni scheda mostra solo dove collegarla.", + "body": "Importa le misurazioni in automatico oppure resta sull'inserimento manuale. Qui non viene configurato nulla al posto tuo: ogni scheda mostra solo dove collegarla. Le fonti su dispositivo richiedono un'app sviluppatore gratuita sul tuo account (circa 5 minuti); la guida su ogni scheda ti accompagna.", "recommended": "Consigliato", "manual": { "title": "Inserimento manuale", @@ -4024,12 +4024,14 @@ }, "withings": { "title": "Withings", - "body": "Bilance, misuratori di pressione e tracker Withings si sincronizzano automaticamente.", - "cta": "Connetti Withings" + "body": "Bilance, misuratori di pressione e tracker Withings si sincronizzano automaticamente una volta collegati.", + "cta": "Connetti Withings", + "setupCta": "Configura nelle Impostazioni", + "setupHint": "Richiede prima la tua app sviluppatore Withings gratuita — circa 5 minuti, una tantum." }, "more": { "title": "Altre fonti", - "body": "Collega una di queste nelle Impostazioni quando vuoi.", + "body": "Collega una di queste nelle Impostazioni quando vuoi — la maggior parte richiede prima un'app sviluppatore gratuita sul tuo account.", "appleHealth": "Apple Health", "whoop": "WHOOP", "oura": "Oura", @@ -5389,6 +5391,12 @@ "ouraDescriptionSecondary": "Le misurazioni arrivano nella dashboard in background. È la prima volta? Segui la", "nightscoutDescriptionSecondary": "Le misurazioni arrivano nella dashboard in background. È la prima volta? Segui la", "integrationCredentialsHint": "Dove trovare queste credenziali e come registrare la tua app è spiegato nella guida alla configurazione qui sopra.", + "integrationRedirectGuide": { + "intro": "Questa connessione passa dalla tua app sviluppatore {provider} — circa 5 minuti di configurazione:", + "step1": "Crea un'app nel portale sviluppatori {provider}.", + "step2": "Imposta il suo URL di reindirizzamento/callback su:", + "step3": "Incolla qui sopra l'ID client e il client secret ricevuti." + }, "security": { "sessionsTitle": "Sessioni attive", "sessionsDescription": "Dispositivi e browser attualmente connessi al tuo account.", diff --git a/messages/pl.json b/messages/pl.json index 1577380ca..cc319642e 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4016,7 +4016,7 @@ }, "source": { "title": "Połącz źródło", - "body": "Pobieraj pomiary automatycznie lub zostań przy wpisywaniu ręcznym. Nic nie jest tu konfigurowane za Ciebie — każda karta pokazuje tylko, gdzie to połączyć.", + "body": "Pobieraj pomiary automatycznie lub zostań przy wpisywaniu ręcznym. Nic nie jest tu konfigurowane za Ciebie — każda karta pokazuje tylko, gdzie to połączyć. Źródła sprzętowe wymagają darmowej aplikacji deweloperskiej na Twoim własnym koncie (około 5 minut); przewodnik na każdej karcie prowadzi krok po kroku.", "recommended": "Polecane", "manual": { "title": "Wpis ręczny", @@ -4024,12 +4024,14 @@ }, "withings": { "title": "Withings", - "body": "Wagi, ciśnieniomierze i trackery Withings synchronizują się automatycznie.", - "cta": "Połącz Withings" + "body": "Wagi, ciśnieniomierze i trackery Withings synchronizują się automatycznie po połączeniu.", + "cta": "Połącz Withings", + "setupCta": "Skonfiguruj w Ustawieniach", + "setupHint": "Najpierw wymaga własnej, darmowej aplikacji deweloperskiej Withings — około 5 minut, jednorazowo." }, "more": { "title": "Więcej źródeł", - "body": "Połącz dowolne z nich w Ustawieniach, gdy zechcesz.", + "body": "Połącz dowolne z nich w Ustawieniach, gdy zechcesz — większość wymaga najpierw darmowej aplikacji deweloperskiej na Twoim własnym koncie.", "appleHealth": "Apple Health", "whoop": "WHOOP", "oura": "Oura", @@ -5389,6 +5391,12 @@ "ouraDescriptionSecondary": "Odczyty trafiają do panelu w tle. Pierwszy raz? Skorzystaj z", "nightscoutDescriptionSecondary": "Odczyty trafiają do panelu w tle. Pierwszy raz? Skorzystaj z", "integrationCredentialsHint": "Gdzie znaleźć te dane logowania i jak zarejestrować własną aplikację, opisano w przewodniku konfiguracji powyżej.", + "integrationRedirectGuide": { + "intro": "To połączenie działa przez Twoją własną aplikację deweloperską {provider} — około 5 minut konfiguracji:", + "step1": "Utwórz aplikację w portalu deweloperskim {provider}.", + "step2": "Ustaw jej URL przekierowania/callback na:", + "step3": "Wklej powyżej otrzymany identyfikator klienta i sekret klienta." + }, "security": { "sessionsTitle": "Aktywne sesje", "sessionsDescription": "Urządzenia i przeglądarki obecnie zalogowane na Twoje konto.", diff --git a/src/app/api/fitbit/connect/__tests__/route.test.ts b/src/app/api/fitbit/connect/__tests__/route.test.ts new file mode 100644 index 000000000..aa71ee8a4 --- /dev/null +++ b/src/app/api/fitbit/connect/__tests__/route.test.ts @@ -0,0 +1,115 @@ +/** + * GET /api/fitbit/connect + * + * Pins: happy path mints a nonce + PKCE pair and redirects to the Fitbit + * consent screen; rate-limit and no-credentials both redirect back to + * `/settings/integrations?fitbit=error&reason=` rather than returning + * JSON — a browser-navigation entry point should never surface an unstyled + * JSON page. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: unknown>(fn: T) => fn, + requireAuth: vi.fn(async () => ({ user: { id: "u-1" } })), +})); + +vi.mock("@/lib/db", () => ({ + prisma: { fitbitOAuthState: { create: vi.fn() } }, +})); + +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn(), +})); + +vi.mock("@/lib/fitbit/credentials", () => ({ + getUserFitbitCredentials: vi.fn(), +})); + +vi.mock("@/lib/fitbit/client", () => ({ + getAuthorizationUrl: vi.fn( + () => "https://www.fitbit.com/oauth2/authorize?state=nonce-stub", + ), + generatePkcePair: vi.fn(() => ({ + verifier: "verifier-stub", + challenge: "challenge-stub", + })), +})); + +vi.mock("@/lib/fitbit/oauth-state", () => ({ + FITBIT_OAUTH_STATE_COOKIE: "fitbit_oauth_state", + FITBIT_OAUTH_STATE_TTL_MS: 10 * 60 * 1000, + mintFitbitOAuthStateNonce: vi.fn(() => "nonce-stub"), +})); + +vi.mock("@/lib/logging/context", () => ({ + annotate: vi.fn(), + getEvent: vi.fn(() => ({ setError: vi.fn() })), +})); + +vi.mock("@/lib/auth/secure-cookie", () => ({ + shouldEmitSecureCookie: () => true, +})); + +import { GET } from "../route"; +import { prisma } from "@/lib/db"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { getUserFitbitCredentials } from "@/lib/fitbit/credentials"; + +function connectRequest(): NextRequest { + return new NextRequest("http://localhost/api/fitbit/connect", { + method: "GET", + }); +} + +beforeEach(() => { + vi.mocked(checkRateLimit).mockResolvedValue({ + allowed: true, + remaining: 9, + resetAt: Date.now() + 60_000, + } as never); + vi.mocked(prisma.fitbitOAuthState.create).mockReset(); + vi.mocked(prisma.fitbitOAuthState.create).mockResolvedValue({} as never); + vi.mocked(getUserFitbitCredentials).mockResolvedValue({ + clientId: "client-id", + clientSecret: "client-secret", + } as never); +}); + +describe("GET /api/fitbit/connect", () => { + it("redirects to the Fitbit authorize URL on the happy path", async () => { + const response = await GET(connectRequest()); + expect(response.status).toBe(307); + expect(response.headers.get("location")).toContain( + "www.fitbit.com/oauth2/authorize", + ); + expect(prisma.fitbitOAuthState.create).toHaveBeenCalledTimes(1); + }); + + it("redirects to reason=rate_limited when the bucket is exhausted", async () => { + vi.mocked(checkRateLimit).mockResolvedValueOnce({ + allowed: false, + remaining: 0, + resetAt: Date.now() + 60_000, + } as never); + + const response = await GET(connectRequest()); + const location = response.headers.get("location") ?? ""; + expect(location).toContain("/settings/integrations"); + expect(location).toContain("fitbit=error"); + expect(location).toContain("reason=rate_limited"); + expect(prisma.fitbitOAuthState.create).not.toHaveBeenCalled(); + }); + + it("redirects to reason=nocreds when no per-user credentials are stored", async () => { + vi.mocked(getUserFitbitCredentials).mockResolvedValueOnce(null); + + const response = await GET(connectRequest()); + const location = response.headers.get("location") ?? ""; + expect(location).toContain("/settings/integrations"); + expect(location).toContain("fitbit=error"); + expect(location).toContain("reason=nocreds"); + expect(prisma.fitbitOAuthState.create).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/fitbit/connect/route.ts b/src/app/api/fitbit/connect/route.ts index cf1e33ae6..bae44e08a 100644 --- a/src/app/api/fitbit/connect/route.ts +++ b/src/app/api/fitbit/connect/route.ts @@ -1,5 +1,4 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { apiError } from "@/lib/api-response"; import { prisma } from "@/lib/db"; import { annotate, getEvent } from "@/lib/logging/context"; import { checkRateLimit } from "@/lib/rate-limit"; @@ -32,7 +31,8 @@ import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie"; * Rate-limited per user (10 calls / 60 s) so a logged-in session can't spam * ledger rows for the full 10-min TTL window. Both the rate-limit and * create-failure paths redirect (not JSON) because the entry point is a browser - * navigation — a 429 envelope would surface as a blank page. + * navigation — a 429 envelope would surface as a blank page. The + * no-credentials branch redirects too (v1.29.x), matching Withings/WHOOP. */ const CONNECT_RATE_LIMIT = 10; const CONNECT_WINDOW_MS = 60_000; @@ -58,9 +58,9 @@ export const GET = apiHandler(async (req: NextRequest) => { const creds = await getUserFitbitCredentials(user.id); if (!creds) { - return apiError( - "Please configure your Fitbit OAuth Client ID and Client Secret in Settings first.", - 400, + annotate({ action: { name: "fitbit.connect.no_credentials" } }); + return NextResponse.redirect( + new URL("/settings/integrations?fitbit=error&reason=nocreds", req.url), ); } diff --git a/src/app/api/oura/connect/__tests__/route.test.ts b/src/app/api/oura/connect/__tests__/route.test.ts new file mode 100644 index 000000000..65cc3a80c --- /dev/null +++ b/src/app/api/oura/connect/__tests__/route.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const { rateLimitMock, getCredsMock, mintMock, authUrlMock } = vi.hoisted( + () => ({ + rateLimitMock: vi.fn(), + getCredsMock: vi.fn(), + mintMock: vi.fn(() => "signed-state"), + authUrlMock: vi.fn(() => "https://cloud.ouraring.com/oauth/authorize?x=1"), + }), +); + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: unknown>(fn: T) => fn, + requireAuth: vi.fn(async () => ({ user: { id: "u1" } })), +})); +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); +vi.mock("@/lib/auth/secure-cookie", () => ({ + shouldEmitSecureCookie: () => true, +})); +vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: rateLimitMock })); +vi.mock("@/lib/oura/client", () => ({ getAuthorizationUrl: authUrlMock })); +vi.mock("@/lib/oura/credentials", () => ({ + getOuraClientCredentials: getCredsMock, +})); +vi.mock("@/lib/oauth/signed-state", () => ({ + OAUTH_STATE_TTL_MS: 600_000, + mintSignedState: mintMock, + oauthStateCookieName: () => "oura_oauth_state", +})); + +import { GET } from "../route"; + +process.env.NEXT_PUBLIC_APP_URL = "https://app.example"; + +const run = GET as unknown as () => Promise<{ + status: number; + headers: { get(k: string): string | null }; + cookies: { get(k: string): { value: string } | undefined }; +}>; + +beforeEach(() => { + vi.clearAllMocks(); + rateLimitMock.mockResolvedValue({ allowed: true }); + getCredsMock.mockResolvedValue({ clientId: "c", clientSecret: "s" }); +}); + +describe("GET /api/oura/connect", () => { + it("redirects to the consent screen and sets the state cookie", async () => { + const res = await run(); + expect(res.headers.get("location")).toContain("cloud.ouraring.com"); + expect(res.cookies.get("oura_oauth_state")?.value).toBe("signed-state"); + }); + + it("redirects to an error when the bucket is exhausted", async () => { + rateLimitMock.mockResolvedValue({ allowed: false }); + const res = await run(); + expect(res.headers.get("location")).toContain("reason=rate_limited"); + expect(getCredsMock).not.toHaveBeenCalled(); + }); + + it("redirects to reason=nocreds when neither BYO nor env credentials resolve", async () => { + getCredsMock.mockResolvedValue(null); + const res = await run(); + const location = res.headers.get("location") ?? ""; + expect(location).toContain("/settings/integrations"); + expect(location).toContain("oura=error"); + expect(location).toContain("reason=nocreds"); + }); +}); diff --git a/src/app/api/oura/connect/route.ts b/src/app/api/oura/connect/route.ts index ce2314afb..ec9fac009 100644 --- a/src/app/api/oura/connect/route.ts +++ b/src/app/api/oura/connect/route.ts @@ -1,5 +1,4 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { apiError } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie"; import { checkRateLimit } from "@/lib/rate-limit"; @@ -19,7 +18,10 @@ import { NextResponse } from "next/server"; * BYO Oura client id/secret wins, falling back to the shared env app * (`OURA_CLIENT_ID` / `OURA_CLIENT_SECRET`) when none is stored. CSRF via the * stateless signed state (`src/lib/oauth/signed-state.ts`): the same token is - * the `state` URL param AND an httpOnly cookie. Rate-limited per user. + * the `state` URL param AND an httpOnly cookie. Rate-limited per user. The + * no-credentials branch redirects too (v1.29.x) rather than returning a raw + * JSON 400 — every other failure path here already redirects, and a + * browser-navigation entry point should never surface an unstyled JSON page. */ const CONNECT_RATE_LIMIT = 10; const CONNECT_WINDOW_MS = 60_000; @@ -45,7 +47,13 @@ export const GET = apiHandler(async () => { const creds = await getOuraClientCredentials(user.id); if (!creds) { - return apiError("Oura integration is not configured on this server.", 400); + annotate({ action: { name: "oura.connect.no_credentials" } }); + return NextResponse.redirect( + new URL( + "/settings/integrations?oura=error&reason=nocreds", + process.env.NEXT_PUBLIC_APP_URL!, + ), + ); } const state = mintSignedState("oura", user.id); diff --git a/src/app/api/polar/connect/__tests__/route.test.ts b/src/app/api/polar/connect/__tests__/route.test.ts index 87511d54e..baad21c01 100644 --- a/src/app/api/polar/connect/__tests__/route.test.ts +++ b/src/app/api/polar/connect/__tests__/route.test.ts @@ -13,9 +13,6 @@ vi.mock("@/lib/api-handler", () => ({ apiHandler: unknown>(fn: T) => fn, requireAuth: vi.fn(async () => ({ user: { id: "u1" } })), })); -vi.mock("@/lib/api-response", () => ({ - apiError: (error: string, status: number) => ({ data: null, error, status }), -})); vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); vi.mock("@/lib/auth/secure-cookie", () => ({ shouldEmitSecureCookie: () => true, @@ -61,9 +58,12 @@ describe("GET /api/polar/connect", () => { expect(getCredsMock).not.toHaveBeenCalled(); }); - it("returns 400 when neither BYO nor env credentials resolve", async () => { + it("redirects to reason=nocreds when neither BYO nor env credentials resolve", async () => { getCredsMock.mockResolvedValue(null); - const res = (await run()) as unknown as { status: number }; - expect(res.status).toBe(400); + const res = await run(); + const location = res.headers.get("location") ?? ""; + expect(location).toContain("/settings/integrations"); + expect(location).toContain("polar=error"); + expect(location).toContain("reason=nocreds"); }); }); diff --git a/src/app/api/polar/connect/route.ts b/src/app/api/polar/connect/route.ts index 3b9d8b5f2..824d4cc47 100644 --- a/src/app/api/polar/connect/route.ts +++ b/src/app/api/polar/connect/route.ts @@ -1,5 +1,4 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { apiError } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie"; import { checkRateLimit } from "@/lib/rate-limit"; @@ -22,7 +21,10 @@ import { NextResponse } from "next/server"; * same token is the `state` URL param AND an httpOnly cookie, and the callback * enforces both signature validity and a byte-exact cookie match. * - * Rate-limited per user so a session can't spam the consent redirect. + * Rate-limited per user so a session can't spam the consent redirect. The + * no-credentials branch redirects too (v1.29.x) rather than returning a raw + * JSON 400 — every other failure path here already redirects, and a + * browser-navigation entry point should never surface an unstyled JSON page. */ const CONNECT_RATE_LIMIT = 10; const CONNECT_WINDOW_MS = 60_000; @@ -48,7 +50,13 @@ export const GET = apiHandler(async () => { const creds = await getPolarClientCredentials(user.id); if (!creds) { - return apiError("Polar integration is not configured on this server.", 400); + annotate({ action: { name: "polar.connect.no_credentials" } }); + return NextResponse.redirect( + new URL( + "/settings/integrations?polar=error&reason=nocreds", + process.env.NEXT_PUBLIC_APP_URL!, + ), + ); } const state = mintSignedState("polar", user.id); diff --git a/src/app/api/strava/connect/__tests__/route.test.ts b/src/app/api/strava/connect/__tests__/route.test.ts new file mode 100644 index 000000000..9bcbb6610 --- /dev/null +++ b/src/app/api/strava/connect/__tests__/route.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const { rateLimitMock, getCredsMock, mintMock, authUrlMock } = vi.hoisted( + () => ({ + rateLimitMock: vi.fn(), + getCredsMock: vi.fn(), + mintMock: vi.fn(() => "signed-state"), + authUrlMock: vi.fn(() => "https://www.strava.com/oauth/authorize?x=1"), + }), +); + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: unknown>(fn: T) => fn, + requireAuth: vi.fn(async () => ({ user: { id: "u1" } })), +})); +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); +vi.mock("@/lib/auth/secure-cookie", () => ({ + shouldEmitSecureCookie: () => true, +})); +vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: rateLimitMock })); +vi.mock("@/lib/strava/client", () => ({ getAuthorizationUrl: authUrlMock })); +vi.mock("@/lib/strava/credentials", () => ({ + getStravaClientCredentials: getCredsMock, +})); +vi.mock("@/lib/oauth/signed-state", () => ({ + OAUTH_STATE_TTL_MS: 600_000, + mintSignedState: mintMock, + oauthStateCookieName: () => "strava_oauth_state", +})); + +import { GET } from "../route"; + +process.env.NEXT_PUBLIC_APP_URL = "https://app.example"; + +const run = GET as unknown as () => Promise<{ + status: number; + headers: { get(k: string): string | null }; + cookies: { get(k: string): { value: string } | undefined }; +}>; + +beforeEach(() => { + vi.clearAllMocks(); + rateLimitMock.mockResolvedValue({ allowed: true }); + getCredsMock.mockResolvedValue({ clientId: "c", clientSecret: "s" }); +}); + +describe("GET /api/strava/connect", () => { + it("redirects to the consent screen and sets the state cookie", async () => { + const res = await run(); + expect(res.headers.get("location")).toContain("www.strava.com"); + expect(res.cookies.get("strava_oauth_state")?.value).toBe("signed-state"); + }); + + it("redirects to an error when the bucket is exhausted", async () => { + rateLimitMock.mockResolvedValue({ allowed: false }); + const res = await run(); + expect(res.headers.get("location")).toContain("reason=rate_limited"); + expect(getCredsMock).not.toHaveBeenCalled(); + }); + + it("redirects to reason=nocreds when neither BYO nor env credentials resolve", async () => { + getCredsMock.mockResolvedValue(null); + const res = await run(); + const location = res.headers.get("location") ?? ""; + expect(location).toContain("/settings/integrations"); + expect(location).toContain("strava=error"); + expect(location).toContain("reason=nocreds"); + }); +}); diff --git a/src/app/api/strava/connect/route.ts b/src/app/api/strava/connect/route.ts index 24e9a62bc..c76a61786 100644 --- a/src/app/api/strava/connect/route.ts +++ b/src/app/api/strava/connect/route.ts @@ -1,5 +1,4 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { apiError } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie"; import { checkRateLimit } from "@/lib/rate-limit"; @@ -19,7 +18,10 @@ import { NextResponse } from "next/server"; * user's BYO Strava client id/secret wins, falling back to the shared env app * (`STRAVA_CLIENT_ID` / `STRAVA_CLIENT_SECRET`) when none is stored. CSRF via * the stateless signed state (`src/lib/oauth/signed-state.ts`): the same token - * is the `state` URL param AND an httpOnly cookie. Rate-limited per user. + * is the `state` URL param AND an httpOnly cookie. Rate-limited per user. The + * no-credentials branch redirects too (v1.29.x) rather than returning a raw + * JSON 400 — every other failure path here already redirects, and a + * browser-navigation entry point should never surface an unstyled JSON page. */ const CONNECT_RATE_LIMIT = 10; const CONNECT_WINDOW_MS = 60_000; @@ -45,9 +47,12 @@ export const GET = apiHandler(async () => { const creds = await getStravaClientCredentials(user.id); if (!creds) { - return apiError( - "Strava integration is not configured on this server.", - 400, + annotate({ action: { name: "strava.connect.no_credentials" } }); + return NextResponse.redirect( + new URL( + "/settings/integrations?strava=error&reason=nocreds", + process.env.NEXT_PUBLIC_APP_URL!, + ), ); } diff --git a/src/app/api/whoop/connect/__tests__/route.test.ts b/src/app/api/whoop/connect/__tests__/route.test.ts index 6e243ee0f..fd93eabfd 100644 --- a/src/app/api/whoop/connect/__tests__/route.test.ts +++ b/src/app/api/whoop/connect/__tests__/route.test.ts @@ -53,6 +53,7 @@ import { prisma } from "@/lib/db"; import { requireAuth } from "@/lib/api-handler"; import { consumeWhoopConnectTicket } from "@/lib/whoop/connect-ticket"; import { checkRateLimit } from "@/lib/rate-limit"; +import { getUserWhoopCredentials } from "@/lib/whoop/credentials"; import type { NextRequest } from "next/server"; const create = prisma.whoopOAuthState.create as ReturnType; @@ -133,6 +134,17 @@ describe("GET /api/whoop/connect", () => { expect(create.mock.calls[0][0].data.returnScheme).toBeNull(); }); + it("redirects to reason=nocreds when no per-user credentials are stored", async () => { + vi.mocked(getUserWhoopCredentials).mockResolvedValueOnce(null); + const res = await GET(makeReq("https://app.example/api/whoop/connect")); + expect(res.status).toBe(307); + const location = res.headers.get("location") ?? ""; + expect(location).toContain("/settings/integrations"); + expect(location).toContain("whoop=error"); + expect(location).toContain("reason=nocreds"); + expect(create).not.toHaveBeenCalled(); + }); + it("ticket + return_scheme combine", async () => { consume.mockResolvedValue({ userId: "ticket-user" }); const res = await GET( diff --git a/src/app/api/whoop/connect/route.ts b/src/app/api/whoop/connect/route.ts index 8f5bf8226..716e13895 100644 --- a/src/app/api/whoop/connect/route.ts +++ b/src/app/api/whoop/connect/route.ts @@ -40,7 +40,10 @@ import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie"; * Rate-limited per user (10 calls / 60 s) so a logged-in session can't spam * ledger rows for the full 10-min TTL window. Both the rate-limit and * create-failure paths redirect (not JSON) because the cookie entry point is a - * browser navigation — a 429 envelope would surface as a blank page. + * browser navigation — a 429 envelope would surface as a blank page. The + * no-credentials branch redirects too (v1.29.x), matching Withings — only the + * ticket-invalid branch stays a typed 401 JSON response, since that path is + * consumed by a native client, not a browser navigation. */ const CONNECT_RATE_LIMIT = 10; const CONNECT_WINDOW_MS = 60_000; @@ -89,9 +92,9 @@ export const GET = apiHandler(async (req: NextRequest) => { const creds = await getUserWhoopCredentials(userId); if (!creds) { - return apiError( - "Please configure your WHOOP Client ID and Client Secret in Settings first.", - 400, + annotate({ action: { name: "whoop.connect.no_credentials" } }); + return NextResponse.redirect( + new URL("/settings/integrations?whoop=error&reason=nocreds", req.url), ); } diff --git a/src/app/api/withings/connect/__tests__/route.test.ts b/src/app/api/withings/connect/__tests__/route.test.ts index cc3fec2b5..32d1044d7 100644 --- a/src/app/api/withings/connect/__tests__/route.test.ts +++ b/src/app/api/withings/connect/__tests__/route.test.ts @@ -12,6 +12,10 @@ * redirects to * `/settings/integrations?withings=error&reason=connect` * instead of bubbling a 500. + * 4. No credentials: redirects to + * `/settings/integrations?withings=error&reason=nocreds` instead of + * returning a raw JSON 400 (v1.29.x — the flagship onboarding CTA + * dead-ended in an unstyled JSON tab for every brand-new account). */ import { describe, it, expect, vi, beforeEach } from "vitest"; import { NextRequest } from "next/server"; @@ -132,4 +136,17 @@ describe("GET /api/withings/connect", () => { expect(location).toContain("withings=error"); expect(location).toContain("reason=connect"); }); + + it("redirects to reason=nocreds when no per-user credentials are stored", async () => { + vi.mocked(getUserWithingsCredentials).mockResolvedValueOnce(null); + + const response = await GET(connectRequest()); + + expect(response.status).toBe(307); + const location = response.headers.get("location") ?? ""; + expect(location).toContain("/settings/integrations"); + expect(location).toContain("withings=error"); + expect(location).toContain("reason=nocreds"); + expect(prisma.withingsOAuthState.create).not.toHaveBeenCalled(); + }); }); diff --git a/src/app/api/withings/connect/route.ts b/src/app/api/withings/connect/route.ts index c1e0c672f..44773679d 100644 --- a/src/app/api/withings/connect/route.ts +++ b/src/app/api/withings/connect/route.ts @@ -1,5 +1,4 @@ import { apiHandler, requireAuth } from "@/lib/api-handler"; -import { apiError } from "@/lib/api-response"; import { prisma } from "@/lib/db"; import { annotate, getEvent } from "@/lib/logging/context"; import { checkRateLimit } from "@/lib/rate-limit"; @@ -32,6 +31,12 @@ import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie"; * instead of bubbling a 500. Both rate-limit and create-failure paths * redirect rather than returning JSON because the entry point is a * browser navigation — a 429 envelope would surface as a blank page. + * + * v1.29.x — the no-credentials branch redirects too, matching every + * other failure path in this route. It used to return a raw JSON 400, + * which every browser navigation into this route (onboarding's + * "Connect Withings" tap included) surfaced as an unstyled JSON page — + * the first "connect a device" moment for a brand-new self-hoster. */ const CONNECT_RATE_LIMIT = 10; @@ -58,9 +63,9 @@ export const GET = apiHandler(async (req: NextRequest) => { const creds = await getUserWithingsCredentials(user.id); if (!creds) { - return apiError( - "Please configure your Withings Client ID and Client Secret in Settings first.", - 400, + annotate({ action: { name: "withings.connect.no_credentials" } }); + return NextResponse.redirect( + new URL("/settings/integrations?withings=error&reason=nocreds", req.url), ); } diff --git a/src/components/onboarding/__tests__/source-card-grid.test.tsx b/src/components/onboarding/__tests__/source-card-grid.test.tsx new file mode 100644 index 000000000..7721480cd --- /dev/null +++ b/src/components/onboarding/__tests__/source-card-grid.test.tsx @@ -0,0 +1,91 @@ +import { describe, it, expect, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; + +/** + * v1.29.x — UX audit H1: the wizard's Withings card must not launch an + * OAuth handshake that can only 400 for a brand-new account (Withings + * credentials are per-user BYO with no env fallback). The card reads the + * consolidated integrations-status envelope and swaps its CTA + href + * depending on whether the account already has its own credentials saved. + */ + +const integrationStatusState = vi.hoisted(() => ({ + data: undefined as + | { + threshold: number; + integrations: Array<{ integration: string; configured?: boolean }>; + } + | undefined, +})); + +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ data: integrationStatusState.data }), + useQueryClient: () => ({ invalidateQueries: vi.fn() }), +})); + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ isAuthenticated: true }), +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), +})); + +vi.mock("next/link", () => ({ + default: ({ + href, + children, + ...rest + }: { + href: string; + children: React.ReactNode; + }) => ( + + {children} + + ), +})); + +import { SourceCardGrid } from "../source-card-grid"; + +function render() { + return renderToStaticMarkup( + + + , + ); +} + +describe(" Withings card", () => { + it("points at Settings instead of launching OAuth when unconfigured", () => { + integrationStatusState.data = { + threshold: 3, + integrations: [{ integration: "withings", configured: false }], + }; + const html = render(); + expect(html).toContain('data-testid="source-card-withings-setup"'); + expect(html).toContain('href="/settings/integrations#withings"'); + expect(html).not.toContain('href="/api/withings/connect"'); + expect(html).toContain("Set up in Settings"); + }); + + it("also redirects to Settings when the status envelope hasn't loaded yet", () => { + integrationStatusState.data = undefined; + const html = render(); + expect(html).toContain('href="/settings/integrations#withings"'); + expect(html).not.toContain('href="/api/withings/connect"'); + }); + + it("opens the real OAuth connect flow once credentials are configured", () => { + integrationStatusState.data = { + threshold: 3, + integrations: [{ integration: "withings", configured: true }], + }; + const html = render(); + expect(html).toContain('href="/api/withings/connect"'); + expect(html).not.toContain('data-testid="source-card-withings-setup"'); + expect(html).toContain("Connect Withings"); + }); +}); diff --git a/src/components/onboarding/source-card-grid.tsx b/src/components/onboarding/source-card-grid.tsx index 6ba4305d9..8c0d64fdb 100644 --- a/src/components/onboarding/source-card-grid.tsx +++ b/src/components/onboarding/source-card-grid.tsx @@ -15,6 +15,11 @@ import { } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { + pickStatus, + useIntegrationStatuses, +} from "@/components/settings/integrations/shared"; +import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; import { cn } from "@/lib/utils"; import { apiPost } from "@/lib/api/api-fetch"; @@ -26,8 +31,12 @@ import { queryKeys } from "@/lib/query-keys"; * Presents the data sources HealthLog ships today. The headline cards * cover the two paths most people start on: * 1. Manual entry (enabled, recommended — works on any device) - * 2. Withings (enabled — opens OAuth `/api/withings/connect` in a new - * tab; the callback flips the connection server-side) + * 2. Withings — opens OAuth `/api/withings/connect` in a new tab once + * the account has its own Withings developer app configured; the + * callback flips the connection server-side. Before that, the card + * is honest about the prerequisite (v1.29.x, UX audit H1/H2): the + * CTA reads "Set up in Settings" and points at the credentials form + * instead of launching an OAuth handshake that can only 400. * * Below them a calm "more sources" row lists the remaining shipped * integrations (Apple Health, WHOOP, Oura, Polar, Nightscout, Fitbit), @@ -97,10 +106,20 @@ export function SourceCardGrid() { const { t } = useTranslations(); const router = useRouter(); const queryClient = useQueryClient(); + const { isAuthenticated } = useAuth(); const [selected, setSelected] = useState("manual"); const [advancing, setAdvancing] = useState(false); + // v1.29.x — the wizard's "Connect Withings" card must not launch an OAuth + // handshake that can only fail: Withings credentials are per-user BYO with + // no env fallback, and a brand-new account never has them yet. Read the + // same consolidated envelope Settings → Integrations uses so the card + // knows whether the handshake can actually succeed. + const { data: integrationStatus } = useIntegrationStatuses(isAuthenticated); + const withingsConfigured = + pickStatus(integrationStatus, "withings")?.configured ?? false; + async function advance() { if (advancing) return; setAdvancing(true); @@ -144,6 +163,9 @@ export function SourceCardGrid() { card.recommendedKey ? t(card.recommendedKey) : null } withingsCtaLabel={t("onboarding.source.withings.cta")} + withingsConfigured={withingsConfigured} + withingsSetupCtaLabel={t("onboarding.source.withings.setupCta")} + withingsSetupHint={t("onboarding.source.withings.setupHint")} /> ))} @@ -234,6 +256,14 @@ interface SourceCardItemProps { bodyLabel: string; recommendedLabel: string | null; withingsCtaLabel: string; + /** Whether the account already has its own Withings developer-app + * credentials saved — i.e. whether `/api/withings/connect` can + * actually succeed right now. */ + withingsConfigured: boolean; + /** CTA label shown instead of "Connect Withings" when unconfigured. */ + withingsSetupCtaLabel: string; + /** One-sentence explanation of the BYO credential prerequisite. */ + withingsSetupHint: string; } function SourceCardItem({ @@ -244,6 +274,9 @@ function SourceCardItem({ bodyLabel, recommendedLabel, withingsCtaLabel, + withingsConfigured, + withingsSetupCtaLabel, + withingsSetupHint, }: SourceCardItemProps) { const Icon = card.Icon; @@ -292,20 +325,40 @@ function SourceCardItem({ > {headerRow} {body} - + {withingsConfigured ? ( + + ) : ( + // No per-user Withings credentials yet — an OAuth handshake here + // can only 400 (Withings has no env fallback). Point at the + // credentials form instead of launching a doomed connect flow. +
+

{withingsSetupHint}

+ +
+ )} ); } diff --git a/src/components/settings/integrations/__tests__/oauth-provider-card.test.tsx b/src/components/settings/integrations/__tests__/oauth-provider-card.test.tsx index 80672df84..9a0f3739a 100644 --- a/src/components/settings/integrations/__tests__/oauth-provider-card.test.tsx +++ b/src/components/settings/integrations/__tests__/oauth-provider-card.test.tsx @@ -148,3 +148,33 @@ describe("OAuthProviderCard — per-user BYO credentials form (v1.17.1)", () => expect(html).toContain("Saved — enter new to replace"); }); }); + +describe("OAuthProviderCard — redirect-URI mini-guide (v1.29.x, UX audit H2)", () => { + it("shows the callback URL guide before the user has BYO credentials", () => { + process.env.NEXT_PUBLIC_APP_URL = "https://app.example"; + statusPayload = { + connected: false, + configured: false, + available: true, + hasOwnCredentials: false, + }; + const html = render({ credentials: true }); + expect(html).toContain('data-testid="polar-redirect-guide"'); + expect(html).toContain('data-testid="polar-redirect-uri"'); + expect(html).toContain("https://app.example/api/polar/callback"); + }); + + it("hides the guide once the user has stored their own credentials", () => { + statusPayload = { + connected: true, + configured: true, + available: true, + hasOwnCredentials: true, + state: "connected", + lastSuccessAt: null, + lastError: null, + }; + const html = render({ credentials: true }); + expect(html).not.toContain('data-testid="polar-redirect-guide"'); + }); +}); diff --git a/src/components/settings/integrations/__tests__/whoop-card.test.tsx b/src/components/settings/integrations/__tests__/whoop-card.test.tsx new file mode 100644 index 000000000..3faae333a --- /dev/null +++ b/src/components/settings/integrations/__tests__/whoop-card.test.tsx @@ -0,0 +1,41 @@ +/** + * v1.29.x — UX audit H2: the WHOOP card shows a compact callback-URL guide + * while the account has no BYO credentials saved, and steps out of the way + * once configured. Mirrors the OAuthProviderCard coverage for Polar/Oura. + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +vi.mock("@tanstack/react-query", () => ({ + useMutation: () => ({ mutate: vi.fn(), isPending: false }), + useQueryClient: () => ({ invalidateQueries: vi.fn() }), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { WhoopCard } from "../whoop-card"; +import type { IntegrationStatusViewModel } from "../shared"; + +function render(viewModel?: Partial) { + return renderToStaticMarkup( + + + , + ); +} + +describe(" redirect-URI mini-guide", () => { + it("shows the callback URL guide before credentials are configured", () => { + process.env.NEXT_PUBLIC_APP_URL = "https://app.example"; + const html = render({ connected: false, configured: false }); + expect(html).toContain('data-testid="whoop-redirect-guide"'); + expect(html).toContain('data-testid="whoop-redirect-uri"'); + expect(html).toContain("https://app.example/api/whoop/callback"); + }); + + it("hides the guide once credentials are configured", () => { + const html = render({ connected: false, configured: true }); + expect(html).not.toContain('data-testid="whoop-redirect-guide"'); + }); +}); diff --git a/src/components/settings/integrations/fitbit-card.tsx b/src/components/settings/integrations/fitbit-card.tsx index e5c4f73a1..f8ecf653a 100644 --- a/src/components/settings/integrations/fitbit-card.tsx +++ b/src/components/settings/integrations/fitbit-card.tsx @@ -52,7 +52,10 @@ import { pillStateFor, type IntegrationStatusViewModel, } from "./shared"; -import { IntegrationCardDescription } from "./setup-guide-link"; +import { + IntegrationCardDescription, + IntegrationRedirectGuide, +} from "./setup-guide-link"; export function FitbitCard({ viewModel, @@ -283,6 +286,12 @@ export function FitbitCard({

{t("settings.fitbitCredentials")}

+ {!status?.configured && ( + + )}
diff --git a/src/components/settings/integrations/oauth-provider-card.tsx b/src/components/settings/integrations/oauth-provider-card.tsx index bc9f1d58c..fe4a05d0d 100644 --- a/src/components/settings/integrations/oauth-provider-card.tsx +++ b/src/components/settings/integrations/oauth-provider-card.tsx @@ -51,6 +51,7 @@ import { } from "@/lib/query-keys"; import { IntegrationCardDescription, + IntegrationRedirectGuide, type IntegrationDocsProvider, } from "./setup-guide-link"; @@ -277,6 +278,12 @@ export function OAuthProviderCard({

{t(`${i18nPrefix}Credentials`)}

+ {!status?.hasOwnCredentials && ( + + )}
diff --git a/src/components/settings/integrations/setup-guide-link.tsx b/src/components/settings/integrations/setup-guide-link.tsx index 862d2df20..4a1b53ad1 100644 --- a/src/components/settings/integrations/setup-guide-link.tsx +++ b/src/components/settings/integrations/setup-guide-link.tsx @@ -38,6 +38,69 @@ export function integrationDocsHref(provider: IntegrationDocsProvider): string { return `${INTEGRATION_DOCS_BASE}/${provider}`; } +/** + * The OAuth callback path every BYO-key provider registers against. Every + * provider's `getRedirectUri()` (`src/lib/{provider}/client.ts`) derives the + * same shape from `NEXT_PUBLIC_APP_URL` unless an operator overrides it with + * an explicit `*_REDIRECT_URI` env var — the common case this card targets. + */ +export function integrationCallbackUrl( + provider: IntegrationDocsProvider, +): string { + const base = process.env.NEXT_PUBLIC_APP_URL ?? ""; + return `${base}/api/${provider}/callback`; +} + +/** + * v1.29.x (UX audit H2) — compact 3-step BYO-developer-app guide for the + * six providers that require a self-hoster to register their own vendor + * app before an OAuth connect can ever succeed (Withings / WHOOP / Fitbit / + * Polar / Oura / Strava). The external "Setup guide" link explains the + * *why*; this inline callout gives the one value every vendor form asks + * for and the app already knows — the callback URL — so it doesn't have to + * be reconstructed by hand from the docs. Shown only while the user hasn't + * saved their own credentials yet; it steps out of the way once configured. + */ +export function IntegrationRedirectGuide({ + provider, + providerLabel, +}: { + provider: IntegrationDocsProvider; + providerLabel: string; +}) { + const { t } = useTranslations(); + const callbackUrl = integrationCallbackUrl(provider); + return ( +
+

+ {t("settings.integrationRedirectGuide.intro", { + provider: providerLabel, + })} +

+
    +
  1. + {t("settings.integrationRedirectGuide.step1", { + provider: providerLabel, + })} +
  2. +
  3. + {t("settings.integrationRedirectGuide.step2")}{" "} + + {callbackUrl} + +
  4. +
  5. {t("settings.integrationRedirectGuide.step3")}
  6. +
+
+ ); +} + /** * v1.18.7 (Wave F) — the one description skeleton every integration card paints: * a WHITE primary sentence (`${i18nPrefix}Description`), then a GREY secondary diff --git a/src/components/settings/integrations/whoop-card.tsx b/src/components/settings/integrations/whoop-card.tsx index 6c3aefd31..309e54efd 100644 --- a/src/components/settings/integrations/whoop-card.tsx +++ b/src/components/settings/integrations/whoop-card.tsx @@ -44,7 +44,10 @@ import { pillStateFor, type IntegrationStatusViewModel, } from "./shared"; -import { IntegrationCardDescription } from "./setup-guide-link"; +import { + IntegrationCardDescription, + IntegrationRedirectGuide, +} from "./setup-guide-link"; export function WhoopCard({ viewModel, @@ -264,6 +267,12 @@ export function WhoopCard({

{t("settings.whoopCredentials")}

+ {!status?.configured && ( + + )}
diff --git a/src/components/settings/integrations/withings-card.tsx b/src/components/settings/integrations/withings-card.tsx index cf4c8d704..49a83245f 100644 --- a/src/components/settings/integrations/withings-card.tsx +++ b/src/components/settings/integrations/withings-card.tsx @@ -37,7 +37,10 @@ import { pillStateFor, type IntegrationStatusViewModel, } from "./shared"; -import { IntegrationCardDescription } from "./setup-guide-link"; +import { + IntegrationCardDescription, + IntegrationRedirectGuide, +} from "./setup-guide-link"; export function WithingsCard({ viewModel, @@ -299,6 +302,12 @@ export function WithingsCard({

{t("settings.withingsCredentials")}

+ {!status?.configured && ( + + )} {/* v1.4.27 MB7 / CF-53 — the credentials grid drops from a 3-column row (client-id / secret / save) to a