mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
feat(integrations): unify OAuth returns and the status envelope
The WHOOP and Fitbit OAuth callbacks redirect back with `?whoop=connected|error` / `?fitbit=connected|error`, but the settings page only read that param for Withings, Polar and Oura — a user returning from a WHOOP or Fitbit round-trip landed on a silently unchanged page with no toast. Extend the generic return handler to all four OAuth providers, add the matching connected/failed/error i18n keys across every locale, and invalidate both the per-card key and the consolidated envelope on a successful return. Polar and Oura already write the shared IntegrationKey ledger but each fired its own `/api/<provider>/status` round-trip from the card, so the page issued one consolidated request plus three per-card ones. Fold Polar and Oura into `/api/integrations/status` and let the cards read a passed view-model instead of fetching, retiring the extra round-trips. The per-provider status routes stay for the iOS and test callers.
This commit is contained in:
1 parent
38a2c7127e
commit
9430e89889
15 files changed
+583
-42
No files matched your search
@@ -4463,6 +4463,18 @@
|
||||
"whoopConnect": "Mit WHOOP verbinden",
|
||||
"whoopNoCredentials": "Bitte gib oben deine API-Zugangsdaten ein, um WHOOP zu verbinden.",
|
||||
"whoopBackfillInProgress": "Dein WHOOP-Verlauf wird im Hintergrund importiert…",
|
||||
"whoopOauthConnected": "WHOOP verbunden — die erste Synchronisierung startet im Hintergrund.",
|
||||
"whoopOauthFailed": "WHOOP-Verbindung fehlgeschlagen",
|
||||
"whoopOauthError": {
|
||||
"csrf1": "Die Sicherheitsprüfung ist fehlgeschlagen, weil das Browser-Cookie fehlte. Bitte starte die Verbindung im selben Browser erneut.",
|
||||
"state": "Die Antwort von WHOOP passte zu keiner offenen Verbindungsanfrage. Bitte starte die Verbindung erneut.",
|
||||
"expired": "Die Verbindungsanfrage ist abgelaufen, bevor sie abgeschlossen wurde. Bitte starte die Verbindung erneut.",
|
||||
"cross_user": "Die Antwort gehört zu einer Anfrage eines anderen Kontos. Bitte starte die Verbindung aus deinem eigenen Konto erneut.",
|
||||
"nocode": "WHOOP hat keinen Autorisierungscode zurückgegeben. Versuche es erneut und erlaube den Zugriff im WHOOP-Dialog.",
|
||||
"nocreds": "WHOOP ist nicht konfiguriert. Hinterlege oben deine WHOOP-OAuth-Zugangsdaten und versuche es erneut.",
|
||||
"token": "Der Token-Austausch mit WHOOP ist fehlgeschlagen. Bitte starte die Verbindung erneut.",
|
||||
"generic": "Unbekannter Fehler bei der Verbindung. Bitte starte die Verbindung erneut."
|
||||
},
|
||||
"fitbit": "Google Health",
|
||||
"fitbitTag": "Fitbit & Pixel",
|
||||
"fitbitDescription": "Verbinde Google Health, um deine Fitbit- und Pixel-Watch-Werte zu synchronisieren — Gewicht, Körperfett, Ruhepuls, Sauerstoffsättigung, Herzfrequenzvariabilität und mehr.",
|
||||
@@ -4490,6 +4502,18 @@
|
||||
"fitbitConnect": "Mit Google Health verbinden",
|
||||
"fitbitNoCredentials": "Bitte gib oben deine API-Zugangsdaten ein, um Google Health zu verbinden.",
|
||||
"fitbitBackfillInProgress": "Dein Google-Health-Verlauf wird im Hintergrund importiert…",
|
||||
"fitbitOauthConnected": "Google Health verbunden — die erste Synchronisierung startet im Hintergrund.",
|
||||
"fitbitOauthFailed": "Google-Health-Verbindung fehlgeschlagen",
|
||||
"fitbitOauthError": {
|
||||
"csrf1": "Die Sicherheitsprüfung ist fehlgeschlagen, weil das Browser-Cookie fehlte. Bitte starte die Verbindung im selben Browser erneut.",
|
||||
"state": "Die Antwort von Google passte zu keiner offenen Verbindungsanfrage. Bitte starte die Verbindung erneut.",
|
||||
"expired": "Die Verbindungsanfrage ist abgelaufen, bevor sie abgeschlossen wurde. Bitte starte die Verbindung erneut.",
|
||||
"cross_user": "Die Antwort gehört zu einer Anfrage eines anderen Kontos. Bitte starte die Verbindung aus deinem eigenen Konto erneut.",
|
||||
"nocode": "Google hat keinen Autorisierungscode zurückgegeben. Versuche es erneut und erlaube den Zugriff im Google-Dialog.",
|
||||
"nocreds": "Google Health ist nicht konfiguriert. Hinterlege oben deine Google-OAuth-Zugangsdaten und versuche es erneut.",
|
||||
"token": "Der Token-Austausch mit Google ist fehlgeschlagen. Bitte starte die Verbindung erneut.",
|
||||
"generic": "Unbekannter Fehler bei der Verbindung. Bitte starte die Verbindung erneut."
|
||||
},
|
||||
"integrations": {
|
||||
"withings": {
|
||||
"reconnect": {
|
||||
|
||||
@@ -4463,6 +4463,18 @@
|
||||
"whoopConnect": "Connect with WHOOP",
|
||||
"whoopNoCredentials": "Please enter your API credentials above to connect WHOOP.",
|
||||
"whoopBackfillInProgress": "Importing your WHOOP history in the background…",
|
||||
"whoopOauthConnected": "WHOOP connected — the first sync starts in the background.",
|
||||
"whoopOauthFailed": "WHOOP connection failed",
|
||||
"whoopOauthError": {
|
||||
"csrf1": "The security check failed because the browser cookie was missing. Please start the connection again in the same browser.",
|
||||
"state": "The response from WHOOP did not match any open connection request. Please start the connection again.",
|
||||
"expired": "The connection request expired before it completed. Please start the connection again.",
|
||||
"cross_user": "The response belongs to a request from a different account. Please restart the connection from your own account.",
|
||||
"nocode": "WHOOP did not return an authorization code. Try again and allow access in the WHOOP dialog.",
|
||||
"nocreds": "WHOOP is not configured. Add your WHOOP OAuth credentials above and try again.",
|
||||
"token": "The token exchange with WHOOP failed. Please start the connection again.",
|
||||
"generic": "Unknown error while connecting. Please start the connection again."
|
||||
},
|
||||
"fitbit": "Google Health",
|
||||
"fitbitTag": "Fitbit & Pixel",
|
||||
"fitbitDescription": "Connect Google Health to sync your Fitbit and Pixel Watch metrics — weight, body fat, resting heart rate, blood oxygen, heart-rate variability and more.",
|
||||
@@ -4490,6 +4502,18 @@
|
||||
"fitbitConnect": "Connect with Google Health",
|
||||
"fitbitNoCredentials": "Please enter your API credentials above to connect Google Health.",
|
||||
"fitbitBackfillInProgress": "Importing your Google Health history in the background…",
|
||||
"fitbitOauthConnected": "Google Health connected — the first sync starts in the background.",
|
||||
"fitbitOauthFailed": "Google Health connection failed",
|
||||
"fitbitOauthError": {
|
||||
"csrf1": "The security check failed because the browser cookie was missing. Please start the connection again in the same browser.",
|
||||
"state": "The response from Google did not match any open connection request. Please start the connection again.",
|
||||
"expired": "The connection request expired before it completed. Please start the connection again.",
|
||||
"cross_user": "The response belongs to a request from a different account. Please restart the connection from your own account.",
|
||||
"nocode": "Google did not return an authorization code. Try again and allow access in the Google dialog.",
|
||||
"nocreds": "Google Health is not configured. Add your Google OAuth credentials above and try again.",
|
||||
"token": "The token exchange with Google failed. Please start the connection again.",
|
||||
"generic": "Unknown error while connecting. Please start the connection again."
|
||||
},
|
||||
"integrations": {
|
||||
"withings": {
|
||||
"reconnect": {
|
||||
|
||||
@@ -4463,6 +4463,18 @@
|
||||
"whoopConnect": "Conectar con WHOOP",
|
||||
"whoopNoCredentials": "Introduce tus credenciales de API arriba para conectar WHOOP.",
|
||||
"whoopBackfillInProgress": "Importando tu historial de WHOOP en segundo plano…",
|
||||
"whoopOauthConnected": "WHOOP conectado: la primera sincronización empieza en segundo plano.",
|
||||
"whoopOauthFailed": "Error al conectar con WHOOP",
|
||||
"whoopOauthError": {
|
||||
"csrf1": "La verificación de seguridad falló porque faltaba la cookie del navegador. Inicia la conexión de nuevo en el mismo navegador.",
|
||||
"state": "La respuesta de WHOOP no coincidió con ninguna solicitud de conexión abierta. Inicia la conexión de nuevo.",
|
||||
"expired": "La solicitud de conexión caducó antes de completarse. Inicia la conexión de nuevo.",
|
||||
"cross_user": "La respuesta pertenece a una solicitud de otra cuenta. Reinicia la conexión desde tu propia cuenta.",
|
||||
"nocode": "WHOOP no devolvió un código de autorización. Inténtalo de nuevo y permite el acceso en el diálogo de WHOOP.",
|
||||
"nocreds": "WHOOP no está configurado. Añade tus credenciales OAuth de WHOOP arriba e inténtalo de nuevo.",
|
||||
"token": "El intercambio de token con WHOOP falló. Inicia la conexión de nuevo.",
|
||||
"generic": "Error desconocido al conectar. Inicia la conexión de nuevo."
|
||||
},
|
||||
"fitbit": "Google Health",
|
||||
"fitbitTag": "Fitbit y Pixel",
|
||||
"fitbitDescription": "Conecta Google Health para sincronizar tus métricas de Fitbit y Pixel Watch: peso, grasa corporal, frecuencia cardíaca en reposo, saturación de oxígeno, variabilidad de la frecuencia cardíaca y más.",
|
||||
@@ -4490,6 +4502,18 @@
|
||||
"fitbitConnect": "Conectar con Google Health",
|
||||
"fitbitNoCredentials": "Introduce tus credenciales de API arriba para conectar Google Health.",
|
||||
"fitbitBackfillInProgress": "Importando tu historial de Google Health en segundo plano…",
|
||||
"fitbitOauthConnected": "Google Health conectado: la primera sincronización empieza en segundo plano.",
|
||||
"fitbitOauthFailed": "Error al conectar con Google Health",
|
||||
"fitbitOauthError": {
|
||||
"csrf1": "La verificación de seguridad falló porque faltaba la cookie del navegador. Inicia la conexión de nuevo en el mismo navegador.",
|
||||
"state": "La respuesta de Google no coincidió con ninguna solicitud de conexión abierta. Inicia la conexión de nuevo.",
|
||||
"expired": "La solicitud de conexión caducó antes de completarse. Inicia la conexión de nuevo.",
|
||||
"cross_user": "La respuesta pertenece a una solicitud de otra cuenta. Reinicia la conexión desde tu propia cuenta.",
|
||||
"nocode": "Google no devolvió un código de autorización. Inténtalo de nuevo y permite el acceso en el diálogo de Google.",
|
||||
"nocreds": "Google Health no está configurado. Añade tus credenciales OAuth de Google arriba e inténtalo de nuevo.",
|
||||
"token": "El intercambio de token con Google falló. Inicia la conexión de nuevo.",
|
||||
"generic": "Error desconocido al conectar. Inicia la conexión de nuevo."
|
||||
},
|
||||
"integrations": {
|
||||
"withings": {
|
||||
"reconnect": {
|
||||
|
||||
@@ -4463,6 +4463,18 @@
|
||||
"whoopConnect": "Se connecter avec WHOOP",
|
||||
"whoopNoCredentials": "Veuillez saisir vos identifiants API ci-dessus pour connecter WHOOP.",
|
||||
"whoopBackfillInProgress": "Importation de votre historique WHOOP en arrière-plan…",
|
||||
"whoopOauthConnected": "WHOOP connecté — la première synchronisation démarre en arrière-plan.",
|
||||
"whoopOauthFailed": "Échec de la connexion à WHOOP",
|
||||
"whoopOauthError": {
|
||||
"csrf1": "La vérification de sécurité a échoué car le cookie du navigateur était absent. Veuillez relancer la connexion dans le même navigateur.",
|
||||
"state": "La réponse de WHOOP ne correspondait à aucune demande de connexion en cours. Veuillez relancer la connexion.",
|
||||
"expired": "La demande de connexion a expiré avant d'aboutir. Veuillez relancer la connexion.",
|
||||
"cross_user": "La réponse appartient à une demande d'un autre compte. Veuillez relancer la connexion depuis votre propre compte.",
|
||||
"nocode": "WHOOP n'a pas renvoyé de code d'autorisation. Réessayez et autorisez l'accès dans la fenêtre WHOOP.",
|
||||
"nocreds": "WHOOP n'est pas configuré. Ajoutez vos identifiants OAuth WHOOP ci-dessus et réessayez.",
|
||||
"token": "L'échange de jeton avec WHOOP a échoué. Veuillez relancer la connexion.",
|
||||
"generic": "Erreur inconnue lors de la connexion. Veuillez relancer la connexion."
|
||||
},
|
||||
"fitbit": "Google Health",
|
||||
"fitbitTag": "Fitbit et Pixel",
|
||||
"fitbitDescription": "Connectez Google Health pour synchroniser vos données Fitbit et Pixel Watch : poids, masse grasse, fréquence cardiaque au repos, saturation en oxygène, variabilité de la fréquence cardiaque et plus encore.",
|
||||
@@ -4490,6 +4502,18 @@
|
||||
"fitbitConnect": "Se connecter à Google Health",
|
||||
"fitbitNoCredentials": "Veuillez saisir vos identifiants d'API ci-dessus pour connecter Google Health.",
|
||||
"fitbitBackfillInProgress": "Importation de votre historique Google Health en arrière-plan…",
|
||||
"fitbitOauthConnected": "Google Health connecté — la première synchronisation démarre en arrière-plan.",
|
||||
"fitbitOauthFailed": "Échec de la connexion à Google Health",
|
||||
"fitbitOauthError": {
|
||||
"csrf1": "La vérification de sécurité a échoué car le cookie du navigateur était absent. Veuillez relancer la connexion dans le même navigateur.",
|
||||
"state": "La réponse de Google ne correspondait à aucune demande de connexion en cours. Veuillez relancer la connexion.",
|
||||
"expired": "La demande de connexion a expiré avant d'aboutir. Veuillez relancer la connexion.",
|
||||
"cross_user": "La réponse appartient à une demande d'un autre compte. Veuillez relancer la connexion depuis votre propre compte.",
|
||||
"nocode": "Google n'a pas renvoyé de code d'autorisation. Réessayez et autorisez l'accès dans la fenêtre Google.",
|
||||
"nocreds": "Google Health n'est pas configuré. Ajoutez vos identifiants OAuth Google ci-dessus et réessayez.",
|
||||
"token": "L'échange de jeton avec Google a échoué. Veuillez relancer la connexion.",
|
||||
"generic": "Erreur inconnue lors de la connexion. Veuillez relancer la connexion."
|
||||
},
|
||||
"integrations": {
|
||||
"withings": {
|
||||
"reconnect": {
|
||||
|
||||
@@ -4463,6 +4463,18 @@
|
||||
"whoopConnect": "Connetti con WHOOP",
|
||||
"whoopNoCredentials": "Inserisci le tue credenziali API qui sopra per connettere WHOOP.",
|
||||
"whoopBackfillInProgress": "Importazione dello storico WHOOP in background…",
|
||||
"whoopOauthConnected": "WHOOP collegato — la prima sincronizzazione parte in background.",
|
||||
"whoopOauthFailed": "Connessione a WHOOP non riuscita",
|
||||
"whoopOauthError": {
|
||||
"csrf1": "Il controllo di sicurezza è fallito perché il cookie del browser era assente. Riavvia la connessione nello stesso browser.",
|
||||
"state": "La risposta di WHOOP non corrispondeva a nessuna richiesta di connessione aperta. Riavvia la connessione.",
|
||||
"expired": "La richiesta di connessione è scaduta prima di completarsi. Riavvia la connessione.",
|
||||
"cross_user": "La risposta appartiene a una richiesta di un altro account. Riavvia la connessione dal tuo account.",
|
||||
"nocode": "WHOOP non ha restituito un codice di autorizzazione. Riprova e concedi l'accesso nella finestra di WHOOP.",
|
||||
"nocreds": "WHOOP non è configurato. Aggiungi le tue credenziali OAuth di WHOOP qui sopra e riprova.",
|
||||
"token": "Lo scambio del token con WHOOP è fallito. Riavvia la connessione.",
|
||||
"generic": "Errore sconosciuto durante la connessione. Riavvia la connessione."
|
||||
},
|
||||
"fitbit": "Google Health",
|
||||
"fitbitTag": "Fitbit e Pixel",
|
||||
"fitbitDescription": "Collega Google Health per sincronizzare i dati di Fitbit e Pixel Watch: peso, massa grassa, frequenza cardiaca a riposo, saturazione di ossigeno, variabilità della frequenza cardiaca e altro.",
|
||||
@@ -4490,6 +4502,18 @@
|
||||
"fitbitConnect": "Connetti con Google Health",
|
||||
"fitbitNoCredentials": "Inserisci le tue credenziali API qui sopra per connettere Google Health.",
|
||||
"fitbitBackfillInProgress": "Importazione dello storico Google Health in background…",
|
||||
"fitbitOauthConnected": "Google Health collegato — la prima sincronizzazione parte in background.",
|
||||
"fitbitOauthFailed": "Connessione a Google Health non riuscita",
|
||||
"fitbitOauthError": {
|
||||
"csrf1": "Il controllo di sicurezza è fallito perché il cookie del browser era assente. Riavvia la connessione nello stesso browser.",
|
||||
"state": "La risposta di Google non corrispondeva a nessuna richiesta di connessione aperta. Riavvia la connessione.",
|
||||
"expired": "La richiesta di connessione è scaduta prima di completarsi. Riavvia la connessione.",
|
||||
"cross_user": "La risposta appartiene a una richiesta di un altro account. Riavvia la connessione dal tuo account.",
|
||||
"nocode": "Google non ha restituito un codice di autorizzazione. Riprova e concedi l'accesso nella finestra di Google.",
|
||||
"nocreds": "Google Health non è configurato. Aggiungi le tue credenziali OAuth di Google qui sopra e riprova.",
|
||||
"token": "Lo scambio del token con Google è fallito. Riavvia la connessione.",
|
||||
"generic": "Errore sconosciuto durante la connessione. Riavvia la connessione."
|
||||
},
|
||||
"integrations": {
|
||||
"withings": {
|
||||
"reconnect": {
|
||||
|
||||
@@ -4463,6 +4463,18 @@
|
||||
"whoopConnect": "Połącz z WHOOP",
|
||||
"whoopNoCredentials": "Wprowadź powyżej swoje dane API, aby połączyć WHOOP.",
|
||||
"whoopBackfillInProgress": "Importowanie historii WHOOP w tle…",
|
||||
"whoopOauthConnected": "WHOOP połączony — pierwsza synchronizacja startuje w tle.",
|
||||
"whoopOauthFailed": "Połączenie z WHOOP nie powiodło się",
|
||||
"whoopOauthError": {
|
||||
"csrf1": "Kontrola bezpieczeństwa nie powiodła się, ponieważ brakowało pliku cookie przeglądarki. Rozpocznij połączenie ponownie w tej samej przeglądarce.",
|
||||
"state": "Odpowiedź z WHOOP nie pasowała do żadnego otwartego żądania połączenia. Rozpocznij połączenie ponownie.",
|
||||
"expired": "Żądanie połączenia wygasło przed zakończeniem. Rozpocznij połączenie ponownie.",
|
||||
"cross_user": "Odpowiedź należy do żądania z innego konta. Rozpocznij połączenie ponownie z własnego konta.",
|
||||
"nocode": "WHOOP nie zwrócił kodu autoryzacji. Spróbuj ponownie i zezwól na dostęp w oknie WHOOP.",
|
||||
"nocreds": "WHOOP nie jest skonfigurowany. Dodaj powyżej swoje dane OAuth WHOOP i spróbuj ponownie.",
|
||||
"token": "Wymiana tokena z WHOOP nie powiodła się. Rozpocznij połączenie ponownie.",
|
||||
"generic": "Nieznany błąd podczas łączenia. Rozpocznij połączenie ponownie."
|
||||
},
|
||||
"fitbit": "Google Health",
|
||||
"fitbitTag": "Fitbit i Pixel",
|
||||
"fitbitDescription": "Połącz Google Health, aby synchronizować dane z Fitbita i Pixel Watch — wagę, tkankę tłuszczową, tętno spoczynkowe, saturację, zmienność rytmu serca i więcej.",
|
||||
@@ -4490,6 +4502,18 @@
|
||||
"fitbitConnect": "Połącz z Google Health",
|
||||
"fitbitNoCredentials": "Wprowadź powyżej swoje dane uwierzytelniające API, aby połączyć Google Health.",
|
||||
"fitbitBackfillInProgress": "Importowanie historii Google Health w tle…",
|
||||
"fitbitOauthConnected": "Google Health połączony — pierwsza synchronizacja startuje w tle.",
|
||||
"fitbitOauthFailed": "Połączenie z Google Health nie powiodło się",
|
||||
"fitbitOauthError": {
|
||||
"csrf1": "Kontrola bezpieczeństwa nie powiodła się, ponieważ brakowało pliku cookie przeglądarki. Rozpocznij połączenie ponownie w tej samej przeglądarce.",
|
||||
"state": "Odpowiedź z Google nie pasowała do żadnego otwartego żądania połączenia. Rozpocznij połączenie ponownie.",
|
||||
"expired": "Żądanie połączenia wygasło przed zakończeniem. Rozpocznij połączenie ponownie.",
|
||||
"cross_user": "Odpowiedź należy do żądania z innego konta. Rozpocznij połączenie ponownie z własnego konta.",
|
||||
"nocode": "Google nie zwrócił kodu autoryzacji. Spróbuj ponownie i zezwól na dostęp w oknie Google.",
|
||||
"nocreds": "Google Health nie jest skonfigurowany. Dodaj powyżej swoje dane OAuth Google i spróbuj ponownie.",
|
||||
"token": "Wymiana tokena z Google nie powiodła się. Rozpocznij połączenie ponownie.",
|
||||
"generic": "Nieznany błąd podczas łączenia. Rozpocznij połączenie ponownie."
|
||||
},
|
||||
"integrations": {
|
||||
"withings": {
|
||||
"reconnect": {
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
requireAuth: vi.fn(async () => ({ user: { id: "u1" } })),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findUnique: vi.fn() },
|
||||
withingsConnection: { findUnique: vi.fn(async () => null) },
|
||||
whoopConnection: { findUnique: vi.fn(async () => null) },
|
||||
fitbitConnection: { findUnique: vi.fn(async () => null) },
|
||||
moodEntry: { count: vi.fn(async () => 0) },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/api-response", () => ({
|
||||
apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }),
|
||||
}));
|
||||
|
||||
const ledger: Record<string, unknown> = {
|
||||
state: "connected",
|
||||
lastSuccessAt: null,
|
||||
lastAttemptAt: null,
|
||||
lastError: null,
|
||||
};
|
||||
vi.mock("@/lib/integrations/status", () => ({
|
||||
getIntegrationStatus: vi.fn(async (_u: string, integration: string) => ({
|
||||
integration,
|
||||
...ledger,
|
||||
})),
|
||||
getPersistentFailureThreshold: () => 5,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/withings/client", () => ({ hasActivityScope: () => false }));
|
||||
vi.mock("@/lib/moodlog-secret", () => ({ readMoodLogSecret: () => null }));
|
||||
|
||||
const polarAvailable = vi.fn(async () => true);
|
||||
const ouraAvailable = vi.fn(async () => false);
|
||||
vi.mock("@/lib/polar/credentials", () => ({
|
||||
getPolarClientCredentials: () => polarAvailable(),
|
||||
}));
|
||||
vi.mock("@/lib/oura/credentials", () => ({
|
||||
getOuraClientCredentials: () => ouraAvailable(),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
const userFind = prisma.user.findUnique as ReturnType<typeof vi.fn>;
|
||||
|
||||
type Entry = {
|
||||
integration: string;
|
||||
connected?: boolean;
|
||||
configured?: boolean;
|
||||
available?: boolean;
|
||||
hasOwnCredentials?: boolean;
|
||||
};
|
||||
|
||||
async function fetchEntries(): Promise<Entry[]> {
|
||||
const res = (await (GET as unknown as () => Promise<{ data: unknown }>)())
|
||||
.data as { integrations: Entry[] };
|
||||
return res.integrations;
|
||||
}
|
||||
|
||||
describe("/api/integrations/status — Polar/Oura fold (04-M2)", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
polarAvailable.mockResolvedValue(true);
|
||||
ouraAvailable.mockResolvedValue(false);
|
||||
});
|
||||
|
||||
it("includes polar + oura entries on the consolidated envelope", async () => {
|
||||
userFind.mockResolvedValue({
|
||||
polarAccessTokenEncrypted: "tok",
|
||||
polarClientIdEncrypted: "id",
|
||||
polarClientSecretEncrypted: "sec",
|
||||
ouraAccessTokenEncrypted: null,
|
||||
ouraClientIdEncrypted: null,
|
||||
ouraClientSecretEncrypted: null,
|
||||
});
|
||||
|
||||
const entries = await fetchEntries();
|
||||
const keys = entries.map((e) => e.integration);
|
||||
expect(keys).toEqual(
|
||||
expect.arrayContaining(["polar", "oura"]),
|
||||
);
|
||||
|
||||
const polar = entries.find((e) => e.integration === "polar")!;
|
||||
expect(polar.connected).toBe(true);
|
||||
expect(polar.configured).toBe(true);
|
||||
expect(polar.available).toBe(true);
|
||||
expect(polar.hasOwnCredentials).toBe(true);
|
||||
|
||||
const oura = entries.find((e) => e.integration === "oura")!;
|
||||
expect(oura.connected).toBe(false);
|
||||
expect(oura.available).toBe(false);
|
||||
expect(oura.hasOwnCredentials).toBe(false);
|
||||
});
|
||||
|
||||
it("reports polar disconnected when no access token is stored", async () => {
|
||||
userFind.mockResolvedValue({
|
||||
polarAccessTokenEncrypted: null,
|
||||
polarClientIdEncrypted: null,
|
||||
polarClientSecretEncrypted: null,
|
||||
});
|
||||
const entries = await fetchEntries();
|
||||
const polar = entries.find((e) => e.integration === "polar")!;
|
||||
expect(polar.connected).toBe(false);
|
||||
expect(polar.configured).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,8 @@ import {
|
||||
getPersistentFailureThreshold,
|
||||
type IntegrationKey,
|
||||
} from "@/lib/integrations/status";
|
||||
import { getOuraClientCredentials } from "@/lib/oura/credentials";
|
||||
import { getPolarClientCredentials } from "@/lib/polar/credentials";
|
||||
import { hasActivityScope } from "@/lib/withings/client";
|
||||
import { readMoodLogSecret } from "@/lib/moodlog-secret";
|
||||
|
||||
@@ -38,16 +40,25 @@ export const GET = apiHandler(async () => {
|
||||
moodLogStatus,
|
||||
whoopStatus,
|
||||
fitbitStatus,
|
||||
polarStatus,
|
||||
ouraStatus,
|
||||
dbUser,
|
||||
withingsConn,
|
||||
whoopConn,
|
||||
fitbitConn,
|
||||
moodLogEntryCount,
|
||||
// v1.17.1 — `available` reports whether usable OAuth credentials resolve
|
||||
// (per-user BYO first, then the shared env app), mirroring the per-card
|
||||
// /api/<provider>/status the consolidated envelope now replaces.
|
||||
polarAvailable,
|
||||
ouraAvailable,
|
||||
] = await Promise.all([
|
||||
getIntegrationStatus(user.id, "withings"),
|
||||
getIntegrationStatus(user.id, "moodlog"),
|
||||
getIntegrationStatus(user.id, "whoop"),
|
||||
getIntegrationStatus(user.id, "fitbit"),
|
||||
getIntegrationStatus(user.id, "polar"),
|
||||
getIntegrationStatus(user.id, "oura"),
|
||||
prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: {
|
||||
@@ -57,6 +68,12 @@ export const GET = apiHandler(async () => {
|
||||
whoopClientSecretEncrypted: true,
|
||||
fitbitClientIdEncrypted: true,
|
||||
fitbitClientSecretEncrypted: true,
|
||||
polarAccessTokenEncrypted: true,
|
||||
polarClientIdEncrypted: true,
|
||||
polarClientSecretEncrypted: true,
|
||||
ouraAccessTokenEncrypted: true,
|
||||
ouraClientIdEncrypted: true,
|
||||
ouraClientSecretEncrypted: true,
|
||||
moodLogUrlEncrypted: true,
|
||||
moodLogApiKeyEncrypted: true,
|
||||
moodLogEnabled: true,
|
||||
@@ -95,6 +112,8 @@ export const GET = apiHandler(async () => {
|
||||
prisma.moodEntry.count({
|
||||
where: { userId: user.id, deletedAt: null },
|
||||
}),
|
||||
getPolarClientCredentials(user.id).then((c) => !!c),
|
||||
getOuraClientCredentials(user.id).then((c) => !!c),
|
||||
]);
|
||||
|
||||
const now = Date.now();
|
||||
@@ -164,6 +183,28 @@ export const GET = apiHandler(async () => {
|
||||
: null,
|
||||
backfillCompleted: fitbitConn ? !!fitbitConn.backfillCompletedAt : null,
|
||||
} satisfies IntegrationViewModel & FitbitExtras,
|
||||
{
|
||||
...polarStatus,
|
||||
// `connected` = a stored access token; `configured` mirrors it (the
|
||||
// OAuth card has no separate "credentials saved but disconnected" view
|
||||
// beyond `hasOwnCredentials`). The card greys out the connect button
|
||||
// when no usable credentials resolve (`available`).
|
||||
connected: !!dbUser?.polarAccessTokenEncrypted,
|
||||
configured: !!dbUser?.polarAccessTokenEncrypted,
|
||||
available: polarAvailable,
|
||||
hasOwnCredentials:
|
||||
!!dbUser?.polarClientIdEncrypted &&
|
||||
!!dbUser?.polarClientSecretEncrypted,
|
||||
} satisfies IntegrationViewModel & OAuthProviderExtras,
|
||||
{
|
||||
...ouraStatus,
|
||||
connected: !!dbUser?.ouraAccessTokenEncrypted,
|
||||
configured: !!dbUser?.ouraAccessTokenEncrypted,
|
||||
available: ouraAvailable,
|
||||
hasOwnCredentials:
|
||||
!!dbUser?.ouraClientIdEncrypted &&
|
||||
!!dbUser?.ouraClientSecretEncrypted,
|
||||
} satisfies IntegrationViewModel & OAuthProviderExtras,
|
||||
],
|
||||
});
|
||||
});
|
||||
@@ -214,3 +255,13 @@ interface FitbitExtras {
|
||||
tokenExpired: boolean | null;
|
||||
backfillCompleted: boolean | null;
|
||||
}
|
||||
|
||||
// v1.17.1 — Polar / Oura fold into the consolidated envelope. They carry the
|
||||
// per-user BYO-key flags the shared OAuth card reads instead of the dedicated
|
||||
// /api/<provider>/status round-trip the page used to fire per card.
|
||||
interface OAuthProviderExtras {
|
||||
connected: boolean;
|
||||
configured: boolean;
|
||||
available: boolean;
|
||||
hasOwnCredentials: boolean;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* v1.17.1 (04-M1) — the WHOOP / Fitbit OAuth callbacks redirect back with
|
||||
* `?whoop=connected|error` / `?fitbit=connected|error&reason=<tag>`, but the
|
||||
* settings page only read the param for Withings/Polar/Oura — so a user
|
||||
* returning from a WHOOP or Fitbit round-trip landed on a silently unchanged
|
||||
* page. These pin the four-provider outcome parsing + reason-key resolution.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import {
|
||||
parseOAuthOutcome,
|
||||
oauthReasonKey,
|
||||
} from "../integrations-section";
|
||||
|
||||
describe("parseOAuthOutcome", () => {
|
||||
it("reads a connected outcome for every OAuth provider", () => {
|
||||
for (const p of ["polar", "oura", "whoop", "fitbit"] as const) {
|
||||
expect(parseOAuthOutcome(`?${p}=connected`)).toEqual({
|
||||
provider: p,
|
||||
kind: "connected",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("reads WHOOP + Fitbit error outcomes with their reason tag (the gap fix)", () => {
|
||||
expect(parseOAuthOutcome("?whoop=error&reason=token")).toEqual({
|
||||
provider: "whoop",
|
||||
kind: "error",
|
||||
reason: "token",
|
||||
});
|
||||
expect(parseOAuthOutcome("?fitbit=error&reason=expired")).toEqual({
|
||||
provider: "fitbit",
|
||||
kind: "error",
|
||||
reason: "expired",
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults a missing reason to 'unknown'", () => {
|
||||
expect(parseOAuthOutcome("?whoop=error")).toEqual({
|
||||
provider: "whoop",
|
||||
kind: "error",
|
||||
reason: "unknown",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null when no provider param is present", () => {
|
||||
expect(parseOAuthOutcome("?foo=bar")).toBeNull();
|
||||
expect(parseOAuthOutcome("")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("oauthReasonKey", () => {
|
||||
it("maps a known tag to the provider-specific key", () => {
|
||||
expect(oauthReasonKey("whoop", "token")).toBe(
|
||||
"settings.whoopOauthError.token",
|
||||
);
|
||||
expect(oauthReasonKey("fitbit", "expired")).toBe(
|
||||
"settings.fitbitOauthError.expired",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to generic for an unknown tag", () => {
|
||||
expect(oauthReasonKey("whoop", "totally_unknown")).toBe(
|
||||
"settings.whoopOauthError.generic",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -8,11 +8,13 @@ import { toast } from "sonner";
|
||||
|
||||
import { FitbitCard } from "@/components/settings/integrations/fitbit-card";
|
||||
import { NightscoutCard } from "@/components/settings/integrations/nightscout-card";
|
||||
import type { OAuthProviderStatus } from "@/components/settings/integrations/oauth-provider-card";
|
||||
import { OuraCard } from "@/components/settings/integrations/oura-card";
|
||||
import { PolarCard } from "@/components/settings/integrations/polar-card";
|
||||
import {
|
||||
pickStatus,
|
||||
useIntegrationStatuses,
|
||||
type IntegrationStatusViewModel,
|
||||
} from "@/components/settings/integrations/shared";
|
||||
import { WhoopCard } from "@/components/settings/integrations/whoop-card";
|
||||
import { WithingsCard } from "@/components/settings/integrations/withings-card";
|
||||
@@ -43,6 +45,97 @@ type WithingsOauthOutcome =
|
||||
| { kind: "connected" }
|
||||
| { kind: "error"; reason: string };
|
||||
|
||||
/**
|
||||
* The OAuth providers whose callbacks redirect back to the settings page with a
|
||||
* `?<provider>=connected|error&reason=<tag>` outcome param. Polar/Oura own a
|
||||
* per-card status query; WHOOP/Fitbit read off the consolidated envelope — both
|
||||
* are invalidated on a successful return so the card repaints either way.
|
||||
*/
|
||||
const OAUTH_OUTCOME_PROVIDERS = ["polar", "oura", "whoop", "fitbit"] as const;
|
||||
type OAuthOutcomeProvider = (typeof OAUTH_OUTCOME_PROVIDERS)[number];
|
||||
|
||||
const OAUTH_OUTCOME_KEYS: Record<
|
||||
OAuthOutcomeProvider,
|
||||
() => readonly unknown[]
|
||||
> = {
|
||||
polar: queryKeys.polar,
|
||||
oura: queryKeys.oura,
|
||||
whoop: queryKeys.whoop,
|
||||
fitbit: queryKeys.fitbit,
|
||||
};
|
||||
|
||||
/**
|
||||
* Reason tags the four OAuth callbacks emit. Known tags resolve to a specific
|
||||
* message; anything else falls back to the provider's `generic` copy. Union of
|
||||
* the Polar/Oura set (`rate_limited`) and the WHOOP/Fitbit set (`expired`).
|
||||
*/
|
||||
const OAUTH_OUTCOME_REASONS = new Set([
|
||||
"csrf1",
|
||||
"state",
|
||||
"cross_user",
|
||||
"nocode",
|
||||
"nocreds",
|
||||
"token",
|
||||
"rate_limited",
|
||||
"expired",
|
||||
]);
|
||||
|
||||
type OAuthOutcome =
|
||||
| { provider: OAuthOutcomeProvider; kind: "connected" }
|
||||
| { provider: OAuthOutcomeProvider; kind: "error"; reason: string };
|
||||
|
||||
/**
|
||||
* Parse the OAuth-return outcome from a URL query string. Reads the first
|
||||
* provider whose `?<provider>=connected|error` param is present, in
|
||||
* `OAUTH_OUTCOME_PROVIDERS` order. Pure + exported so the four-provider
|
||||
* coverage is unit-testable without a browser.
|
||||
*/
|
||||
export function parseOAuthOutcome(search: string): OAuthOutcome | null {
|
||||
const params = new URLSearchParams(search);
|
||||
for (const provider of OAUTH_OUTCOME_PROVIDERS) {
|
||||
const v = params.get(provider);
|
||||
if (v === "connected") return { provider, kind: "connected" };
|
||||
if (v === "error") {
|
||||
return { provider, kind: "error", reason: params.get("reason") ?? "unknown" };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the i18n key for an error reason tag. Known tags map to the
|
||||
* provider-specific message; anything else falls back to `generic`.
|
||||
*/
|
||||
export function oauthReasonKey(
|
||||
provider: OAuthOutcomeProvider,
|
||||
reason: string,
|
||||
): string {
|
||||
return OAUTH_OUTCOME_REASONS.has(reason)
|
||||
? `settings.${provider}OauthError.${reason}`
|
||||
: `settings.${provider}OauthError.generic`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapt the consolidated-envelope view-model into the OAuth card's status
|
||||
* shape. Returns `undefined` when the envelope hasn't loaded so the card
|
||||
* renders its loading/disconnected default rather than a half-populated state.
|
||||
*/
|
||||
function toOAuthStatus(
|
||||
vm: IntegrationStatusViewModel | undefined,
|
||||
): OAuthProviderStatus | undefined {
|
||||
if (!vm) return undefined;
|
||||
return {
|
||||
connected: vm.connected ?? false,
|
||||
configured: vm.configured ?? false,
|
||||
available: vm.available ?? false,
|
||||
hasOwnCredentials: vm.hasOwnCredentials,
|
||||
state: vm.state,
|
||||
lastSuccessAt: vm.lastSuccessAt,
|
||||
lastAttemptAt: vm.lastAttemptAt,
|
||||
lastError: vm.lastError,
|
||||
};
|
||||
}
|
||||
|
||||
export function IntegrationsSection() {
|
||||
const { t } = useTranslations();
|
||||
const { isAuthenticated } = useAuth();
|
||||
@@ -92,23 +185,16 @@ export function IntegrationsSection() {
|
||||
}
|
||||
}, [withingsOauthOutcome, router, queryClient, t]);
|
||||
|
||||
// v1.17.0 (F4) — generic OAuth-callback toast for the env-based providers.
|
||||
// The Polar / Oura callbacks redirect back with `?<provider>=connected` or
|
||||
// `?<provider>=error&reason=<tag>`; surface the outcome as a toast and scrub
|
||||
// the one-shot params so a reload doesn't replay it.
|
||||
const [oauthOutcome] = useState<
|
||||
{ provider: "polar" | "oura"; kind: "connected" } | { provider: "polar" | "oura"; kind: "error"; reason: string } | null
|
||||
>(() => {
|
||||
// v1.17.0 (F4) — generic OAuth-callback toast for the OAuth providers.
|
||||
// The Polar / Oura / WHOOP / Fitbit callbacks redirect back with
|
||||
// `?<provider>=connected` or `?<provider>=error&reason=<tag>`; surface the
|
||||
// outcome as a toast and scrub the one-shot params so a reload doesn't replay
|
||||
// it. Pre-v1.17.1 only Polar/Oura were read here — a user returning from a
|
||||
// WHOOP or Fitbit round-trip landed on a silently unchanged settings page,
|
||||
// the same gap the Withings handler was written to close.
|
||||
const [oauthOutcome] = useState<OAuthOutcome | null>(() => {
|
||||
if (typeof window === "undefined") return null;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
for (const provider of ["polar", "oura"] as const) {
|
||||
const v = params.get(provider);
|
||||
if (v === "connected") return { provider, kind: "connected" };
|
||||
if (v === "error") {
|
||||
return { provider, kind: "error", reason: params.get("reason") ?? "unknown" };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
return parseOAuthOutcome(window.location.search);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -118,27 +204,19 @@ export function IntegrationsSection() {
|
||||
url.searchParams.delete(provider);
|
||||
url.searchParams.delete("reason");
|
||||
router.replace(`${url.pathname}${url.search}`, { scroll: false });
|
||||
const key = provider === "polar" ? queryKeys.polar() : queryKeys.oura();
|
||||
if (oauthOutcome.kind === "connected") {
|
||||
toast.success(t(`settings.${provider}OauthConnected`));
|
||||
queryClient.invalidateQueries({ queryKey: key });
|
||||
// Polar/Oura own a per-card status query; WHOOP/Fitbit read off the
|
||||
// consolidated envelope — invalidate both so the card repaints either way.
|
||||
queryClient.invalidateQueries({ queryKey: OAUTH_OUTCOME_KEYS[provider]() });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.integrationsStatus(),
|
||||
});
|
||||
} else {
|
||||
// Known reason tags resolve to a specific message; anything else falls
|
||||
// back to the generic copy (matching the Withings handler).
|
||||
const knownReasons = new Set([
|
||||
"csrf1",
|
||||
"state",
|
||||
"cross_user",
|
||||
"nocode",
|
||||
"nocreds",
|
||||
"token",
|
||||
"rate_limited",
|
||||
]);
|
||||
const reasonKey = knownReasons.has(oauthOutcome.reason)
|
||||
? `settings.${provider}OauthError.${oauthOutcome.reason}`
|
||||
: `settings.${provider}OauthError.generic`;
|
||||
toast.error(t(`settings.${provider}OauthFailed`), {
|
||||
description: t(reasonKey),
|
||||
description: t(oauthReasonKey(provider, oauthOutcome.reason)),
|
||||
duration: 10_000,
|
||||
});
|
||||
}
|
||||
@@ -147,6 +225,10 @@ export function IntegrationsSection() {
|
||||
const withingsViewModel = pickStatus(integrationStatus, "withings");
|
||||
const whoopViewModel = pickStatus(integrationStatus, "whoop");
|
||||
const fitbitViewModel = pickStatus(integrationStatus, "fitbit");
|
||||
// v1.17.1 — Polar/Oura now read off the same consolidated envelope; the cards
|
||||
// no longer fire their own /api/<provider>/status round-trip.
|
||||
const polarViewModel = toOAuthStatus(pickStatus(integrationStatus, "polar"));
|
||||
const ouraViewModel = toOAuthStatus(pickStatus(integrationStatus, "oura"));
|
||||
|
||||
return (
|
||||
<section
|
||||
@@ -179,8 +261,8 @@ export function IntegrationsSection() {
|
||||
<WithingsCard viewModel={withingsViewModel} />
|
||||
<WhoopCard viewModel={whoopViewModel} />
|
||||
<FitbitCard viewModel={fitbitViewModel} />
|
||||
<PolarCard enabled={isAuthenticated} />
|
||||
<OuraCard enabled={isAuthenticated} />
|
||||
<PolarCard enabled={isAuthenticated} viewModel={polarViewModel} />
|
||||
<OuraCard enabled={isAuthenticated} viewModel={ouraViewModel} />
|
||||
<NightscoutCard enabled={isAuthenticated} />
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -23,7 +23,13 @@ vi.mock("@tanstack/react-query", () => ({
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { OAuthProviderCard } from "../oauth-provider-card";
|
||||
|
||||
function render({ credentials = false }: { credentials?: boolean } = {}) {
|
||||
function render({
|
||||
credentials = false,
|
||||
viewModel,
|
||||
}: {
|
||||
credentials?: boolean;
|
||||
viewModel?: Parameters<typeof OAuthProviderCard>[0]["viewModel"];
|
||||
} = {}) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<OAuthProviderCard
|
||||
@@ -33,6 +39,7 @@ function render({ credentials = false }: { credentials?: boolean } = {}) {
|
||||
icon={Watch}
|
||||
dataHref="/insights/sleep"
|
||||
credentials={credentials}
|
||||
viewModel={viewModel}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
@@ -74,6 +81,25 @@ describe("OAuthProviderCard — parked + test + data-link parity", () => {
|
||||
expect(html).toContain('href="/insights/sleep"');
|
||||
});
|
||||
|
||||
it("reads off the passed view-model instead of the per-card fetch (04-M2)", () => {
|
||||
// The per-card useQuery mock returns null; the card must render the
|
||||
// connected state from the supplied envelope view-model alone, proving the
|
||||
// /api/<provider>/status round-trip is no longer the source.
|
||||
statusPayload = null;
|
||||
const html = render({
|
||||
viewModel: {
|
||||
connected: true,
|
||||
configured: true,
|
||||
available: true,
|
||||
state: "connected",
|
||||
lastSuccessAt: "2026-06-01T00:00:00.000Z",
|
||||
lastError: null,
|
||||
},
|
||||
});
|
||||
expect(html).toContain('data-testid="polar-data-link"');
|
||||
expect(html).toContain("Test connection");
|
||||
});
|
||||
|
||||
it("does not render the data link or test button when disconnected", () => {
|
||||
statusPayload = {
|
||||
connected: false,
|
||||
|
||||
@@ -36,7 +36,11 @@ import {
|
||||
import { TestConnectionButton } from "@/components/settings/test-connection-button";
|
||||
import { apiFetchRaw, apiGet, apiPost } from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { invalidateKeys, measurementDependentKeys } from "@/lib/query-keys";
|
||||
import {
|
||||
invalidateKeys,
|
||||
measurementDependentKeys,
|
||||
queryKeys,
|
||||
} from "@/lib/query-keys";
|
||||
import { IntegrationSetupGuideLink } from "./setup-guide-link";
|
||||
|
||||
export interface OAuthProviderStatus {
|
||||
@@ -85,6 +89,13 @@ export interface OAuthProviderCardProps {
|
||||
* button. The endpoint is the PUT target (e.g. `/api/polar/credentials`). */
|
||||
credentials?: boolean;
|
||||
enabled?: boolean;
|
||||
/**
|
||||
* When provided, the card reads its status from this view-model (sourced off
|
||||
* the consolidated `/api/integrations/status` envelope) instead of firing its
|
||||
* own `/api/<provider>/status` round-trip. v1.17.1 folds Polar/Oura onto the
|
||||
* same envelope WHOOP/Fitbit already use, so the page reads from one source.
|
||||
*/
|
||||
viewModel?: OAuthProviderStatus;
|
||||
}
|
||||
|
||||
export function OAuthProviderCard({
|
||||
@@ -95,6 +106,7 @@ export function OAuthProviderCard({
|
||||
dataHref,
|
||||
credentials = false,
|
||||
enabled = true,
|
||||
viewModel,
|
||||
}: OAuthProviderCardProps) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -107,12 +119,17 @@ export function OAuthProviderCard({
|
||||
null,
|
||||
);
|
||||
|
||||
const { data: status } = useQuery({
|
||||
// Read off the consolidated envelope when a view-model is passed; otherwise
|
||||
// fall back to the per-card status fetch (still used by any caller that has
|
||||
// not been migrated onto the envelope). The fetch is disabled once a
|
||||
// view-model is supplied so the page makes one request, not one-per-card.
|
||||
const { data: fetchedStatus } = useQuery({
|
||||
queryKey: statusQueryKey,
|
||||
queryFn: async () => apiGet<OAuthProviderStatus>(`/api/${provider}/status`),
|
||||
enabled,
|
||||
enabled: enabled && !viewModel,
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
const status = viewModel ?? fetchedStatus;
|
||||
|
||||
async function handleSaveCredentials(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
@@ -134,6 +151,11 @@ export function OAuthProviderCard({
|
||||
setClientId("");
|
||||
setClientSecret("");
|
||||
queryClient.invalidateQueries({ queryKey: statusQueryKey });
|
||||
// The card may read off the consolidated envelope — invalidate it too
|
||||
// so the saved-credentials state repaints regardless of the source.
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.integrationsStatus(),
|
||||
});
|
||||
} else {
|
||||
try {
|
||||
const json = await res.json();
|
||||
@@ -156,6 +178,9 @@ export function OAuthProviderCard({
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: statusQueryKey });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.integrationsStatus(),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -6,10 +6,19 @@
|
||||
|
||||
import { CircleDot } from "lucide-react";
|
||||
|
||||
import { OAuthProviderCard } from "@/components/settings/integrations/oauth-provider-card";
|
||||
import {
|
||||
OAuthProviderCard,
|
||||
type OAuthProviderStatus,
|
||||
} from "@/components/settings/integrations/oauth-provider-card";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
export function OuraCard({ enabled = true }: { enabled?: boolean }) {
|
||||
export function OuraCard({
|
||||
enabled = true,
|
||||
viewModel,
|
||||
}: {
|
||||
enabled?: boolean;
|
||||
viewModel?: OAuthProviderStatus;
|
||||
}) {
|
||||
return (
|
||||
<OAuthProviderCard
|
||||
provider="oura"
|
||||
@@ -19,6 +28,7 @@ export function OuraCard({ enabled = true }: { enabled?: boolean }) {
|
||||
dataHref="/insights/sleep"
|
||||
credentials
|
||||
enabled={enabled}
|
||||
viewModel={viewModel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -6,10 +6,19 @@
|
||||
|
||||
import { Watch } from "lucide-react";
|
||||
|
||||
import { OAuthProviderCard } from "@/components/settings/integrations/oauth-provider-card";
|
||||
import {
|
||||
OAuthProviderCard,
|
||||
type OAuthProviderStatus,
|
||||
} from "@/components/settings/integrations/oauth-provider-card";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
export function PolarCard({ enabled = true }: { enabled?: boolean }) {
|
||||
export function PolarCard({
|
||||
enabled = true,
|
||||
viewModel,
|
||||
}: {
|
||||
enabled?: boolean;
|
||||
viewModel?: OAuthProviderStatus;
|
||||
}) {
|
||||
return (
|
||||
<OAuthProviderCard
|
||||
provider="polar"
|
||||
@@ -19,6 +28,7 @@ export function PolarCard({ enabled = true }: { enabled?: boolean }) {
|
||||
dataHref="/insights/sleep"
|
||||
credentials
|
||||
enabled={enabled}
|
||||
viewModel={viewModel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -18,7 +18,13 @@ import { queryKeys } from "@/lib/query-keys";
|
||||
// v1.4.19 Phase A5: the redundant in-card status banner is gone — the
|
||||
// IntegrationStatusPill now owns state + last-sync presentation, and
|
||||
// the actionable error message is shown inline above the action row.
|
||||
export type IntegrationKey = "withings" | "whoop" | "fitbit" | "moodlog";
|
||||
export type IntegrationKey =
|
||||
| "withings"
|
||||
| "whoop"
|
||||
| "fitbit"
|
||||
| "moodlog"
|
||||
| "polar"
|
||||
| "oura";
|
||||
export type IntegrationState =
|
||||
| "connected"
|
||||
| "error_transient"
|
||||
@@ -52,6 +58,11 @@ export interface IntegrationStatusViewModel {
|
||||
// moodLog webhook secret + entry count.
|
||||
webhookSecret?: string | null;
|
||||
entryCount?: number;
|
||||
// Polar / Oura OAuth card: usable-credentials + BYO-key flags. `available`
|
||||
// greys out the connect button when no credentials resolve; `hasOwnCredentials`
|
||||
// drives the saved-placeholder UI.
|
||||
available?: boolean;
|
||||
hasOwnCredentials?: boolean;
|
||||
}
|
||||
|
||||
export interface IntegrationStatusEnvelope {
|
||||
|
||||
Reference in new issue
Block a user