mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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?<provider>=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.
This commit is contained in:
1 parent
4f7420905a
commit
fb73c97125
27 files changed
+744
-73
No files matched your search
+12
-4
@@ -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.",
|
||||
|
||||
+12
-4
@@ -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.",
|
||||
|
||||
+12
-4
@@ -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.",
|
||||
|
||||
+12
-4
@@ -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.",
|
||||
|
||||
+12
-4
@@ -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.",
|
||||
|
||||
+12
-4
@@ -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.",
|
||||
|
||||
@@ -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=<tag>` 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: <T extends (...args: unknown[]) => 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();
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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: <T extends (...args: unknown[]) => 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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -13,9 +13,6 @@ vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => 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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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: <T extends (...args: unknown[]) => 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");
|
||||
});
|
||||
});
|
||||
@@ -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!,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<typeof vi.fn>;
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}) => (
|
||||
<a href={href} {...rest}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
import { SourceCardGrid } from "../source-card-grid";
|
||||
|
||||
function render() {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<SourceCardGrid />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<SourceCardGrid> 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");
|
||||
});
|
||||
});
|
||||
@@ -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<SourceCard["slug"]>("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")}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -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}
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<a
|
||||
href={card.href}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
onClick={onSelect}
|
||||
className="inline-flex items-center gap-1.5"
|
||||
>
|
||||
<Wifi className="size-3.5" />
|
||||
{withingsCtaLabel}
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
{withingsConfigured ? (
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<a
|
||||
href={card.href}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
onClick={onSelect}
|
||||
className="inline-flex items-center gap-1.5"
|
||||
>
|
||||
<Wifi className="size-3.5" />
|
||||
{withingsCtaLabel}
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
// 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.
|
||||
<div className="space-y-2 pt-1">
|
||||
<p className="text-muted-foreground text-xs">{withingsSetupHint}</p>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link
|
||||
href="/settings/integrations#withings"
|
||||
onClick={onSelect}
|
||||
className="inline-flex items-center gap-1.5"
|
||||
data-testid="source-card-withings-setup"
|
||||
>
|
||||
<Wifi className="size-3.5" />
|
||||
{withingsSetupCtaLabel}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
@@ -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<IntegrationStatusViewModel>) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<WhoopCard
|
||||
viewModel={viewModel as IntegrationStatusViewModel | undefined}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<WhoopCard> 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"');
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
<h3 className="text-sm font-semibold">
|
||||
{t("settings.fitbitCredentials")}
|
||||
</h3>
|
||||
{!status?.configured && (
|
||||
<IntegrationRedirectGuide
|
||||
provider="fitbit"
|
||||
providerLabel={t("settings.fitbit")}
|
||||
/>
|
||||
)}
|
||||
<form onSubmit={handleSaveCredentials} className="space-y-3">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
|
||||
@@ -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({
|
||||
<h3 className="text-sm font-semibold">
|
||||
{t(`${i18nPrefix}Credentials`)}
|
||||
</h3>
|
||||
{!status?.hasOwnCredentials && (
|
||||
<IntegrationRedirectGuide
|
||||
provider={provider}
|
||||
providerLabel={t(i18nPrefix)}
|
||||
/>
|
||||
)}
|
||||
<form onSubmit={handleSaveCredentials} className="space-y-3">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
className="bg-muted/40 border-border/60 space-y-1.5 rounded-md border p-3 text-xs"
|
||||
data-testid={`${provider}-redirect-guide`}
|
||||
>
|
||||
<p className="text-muted-foreground">
|
||||
{t("settings.integrationRedirectGuide.intro", {
|
||||
provider: providerLabel,
|
||||
})}
|
||||
</p>
|
||||
<ol className="text-muted-foreground list-decimal space-y-1 pl-4">
|
||||
<li>
|
||||
{t("settings.integrationRedirectGuide.step1", {
|
||||
provider: providerLabel,
|
||||
})}
|
||||
</li>
|
||||
<li>
|
||||
{t("settings.integrationRedirectGuide.step2")}{" "}
|
||||
<code
|
||||
className="bg-background text-foreground rounded px-1 py-0.5 font-mono break-all"
|
||||
data-testid={`${provider}-redirect-uri`}
|
||||
>
|
||||
{callbackUrl}
|
||||
</code>
|
||||
</li>
|
||||
<li>{t("settings.integrationRedirectGuide.step3")}</li>
|
||||
</ol>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.18.7 (Wave F) — the one description skeleton every integration card paints:
|
||||
* a WHITE primary sentence (`${i18nPrefix}Description`), then a GREY secondary
|
||||
|
||||
@@ -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({
|
||||
<h3 className="text-sm font-semibold">
|
||||
{t("settings.whoopCredentials")}
|
||||
</h3>
|
||||
{!status?.configured && (
|
||||
<IntegrationRedirectGuide
|
||||
provider="whoop"
|
||||
providerLabel={t("settings.whoop")}
|
||||
/>
|
||||
)}
|
||||
<form onSubmit={handleSaveCredentials} className="space-y-3">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
|
||||
@@ -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({
|
||||
<h3 className="text-sm font-semibold">
|
||||
{t("settings.withingsCredentials")}
|
||||
</h3>
|
||||
{!status?.configured && (
|
||||
<IntegrationRedirectGuide
|
||||
provider="withings"
|
||||
providerLabel={t("settings.withings")}
|
||||
/>
|
||||
)}
|
||||
<form onSubmit={handleSaveCredentials} className="space-y-3">
|
||||
{/* v1.4.27 MB7 / CF-53 — the credentials grid drops from
|
||||
a 3-column row (client-id / secret / save) to a
|
||||
|
||||
Reference in new issue
Block a user