diff --git a/messages/de.json b/messages/de.json index a72dcb006..925dd1686 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6553,7 +6553,8 @@ "nocode": "Withings hat keinen Freigabe-Code übermittelt. Versuche es erneut und erlaube den Zugriff im Withings-Dialog.", "nocreds": "Es sind keine Withings-API-Zugangsdaten hinterlegt. Speichere zuerst Client-ID und Client-Secret, dann verbinde erneut.", "token": "Der Token-Austausch mit Withings ist fehlgeschlagen. Prüfe Client-ID und Client-Secret und versuche es erneut.", - "generic": "Unbekannter Fehler bei der Verbindung. Starte die Verbindung bitte erneut." + "generic": "Unbekannter Fehler bei der Verbindung. Starte die Verbindung bitte erneut.", + "already_linked": "Dieses Withings-Konto ist schon mit einem anderen HealthLog-Profil verbunden. Melde dich bei Withings mit dem Konto an, das zu diesem Profil gehört, oder trenne es zuerst vom anderen Profil." }, "withingsSynchronize": "Synchronisieren", "withingsDisconnect": "Trennen", @@ -6592,7 +6593,8 @@ "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." + "generic": "Unbekannter Fehler bei der Verbindung. Bitte starte die Verbindung erneut.", + "already_linked": "Dieses WHOOP-Konto ist schon mit einem anderen HealthLog-Profil verbunden. Melde dich bei WHOOP mit dem Konto an, das zu diesem Profil gehört, oder trenne es zuerst vom anderen Profil." }, "fitbit": "Fitbit", "fitbitTag": "Fitbit & Pixel", @@ -6671,7 +6673,8 @@ "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." + "generic": "Unbekannter Fehler bei der Verbindung. Bitte starte die Verbindung erneut.", + "already_linked": "Dieses Google-Konto ist schon mit einem anderen HealthLog-Profil verbunden. Melde dich bei Google mit dem Konto an, das zu diesem Profil gehört, oder trenne es zuerst vom anderen Profil." }, "fitbitOauthError": { "csrf1": "Die Sicherheitsprüfung ist fehlgeschlagen, weil das Browser-Cookie fehlte. Bitte starte die Verbindung im selben Browser erneut.", @@ -6682,7 +6685,8 @@ "nocreds": "Fitbit ist nicht konfiguriert. Hinterlege oben deine Fitbit-OAuth-Zugangsdaten und versuche es erneut.", "token": "Der Token-Austausch mit Fitbit ist fehlgeschlagen. Bitte starte die Verbindung erneut.", "generic": "Unbekannter Fehler bei der Verbindung. Bitte starte die Verbindung erneut.", - "retired": "Google hat die Fitbit-Web-API abgeschaltet, deshalb lässt sich keine neue Fitbit-Verbindung mehr herstellen. Verbinde stattdessen Google Health, das dieselben Geräte liest." + "retired": "Google hat die Fitbit-Web-API abgeschaltet, deshalb lässt sich keine neue Fitbit-Verbindung mehr herstellen. Verbinde stattdessen Google Health, das dieselben Geräte liest.", + "already_linked": "Dieses Fitbit-Konto ist schon mit einem anderen HealthLog-Profil verbunden. Melde dich bei Fitbit mit dem Konto an, das zu diesem Profil gehört, oder trenne es zuerst vom anderen Profil." }, "integrations": { "withings": { @@ -7028,7 +7032,8 @@ "nocreds": "Polar ist auf diesem Server nicht konfiguriert. Bitte deinen Administrator, die Polar-OAuth-Zugangsdaten zu hinterlegen.", "token": "Der Token-Austausch mit Polar ist fehlgeschlagen. Bitte starte die Verbindung erneut.", "rate_limited": "Zu viele Verbindungsversuche. Warte einen Moment und versuche es erneut.", - "generic": "Unbekannter Fehler bei der Verbindung. Bitte starte die Verbindung erneut." + "generic": "Unbekannter Fehler bei der Verbindung. Bitte starte die Verbindung erneut.", + "already_linked": "Dieses Polar-Konto ist schon mit einem anderen HealthLog-Profil verbunden. Melde dich bei Polar mit dem Konto an, das zu diesem Profil gehört, oder trenne es zuerst vom anderen Profil." }, "oura": "Oura", "ouraDescription": "Verbinde dein Oura-Konto, um Readiness, Schlafphasen, Herzfrequenzvariabilität und tägliche Aktivität zu synchronisieren.", @@ -7058,7 +7063,8 @@ "token": "Der Token-Austausch mit Oura ist fehlgeschlagen. Bitte starte die Verbindung erneut.", "rate_limited": "Zu viele Verbindungsversuche. Warte einen Moment und versuche es erneut.", "issuer": "Die Anmeldeantwort kam nicht vom Autorisierungsserver von Oura, deshalb wurde die Verbindung zu deiner Sicherheit abgebrochen. Bitte starte die Verbindung erneut. Wenn das wieder passiert, sag deinem Administrator Bescheid.", - "generic": "Unbekannter Fehler bei der Verbindung. Bitte starte die Verbindung erneut." + "generic": "Unbekannter Fehler bei der Verbindung. Bitte starte die Verbindung erneut.", + "already_linked": "Dieses Oura-Konto ist schon mit einem anderen HealthLog-Profil verbunden. Melde dich bei Oura mit dem Konto an, das zu diesem Profil gehört, oder trenne es zuerst vom anderen Profil." }, "integrationSetupGuide": "Einrichtungsanleitung", "coach": { @@ -7286,7 +7292,8 @@ "nocreds": "Strava ist auf diesem Server nicht konfiguriert. Bitte deinen Administrator, die Strava-API-Zugangsdaten hinzuzufügen.", "token": "Der Token-Austausch mit Strava ist fehlgeschlagen. Bitte starte die Verbindung erneut.", "rate_limited": "Zu viele Verbindungsversuche. Warte einen Moment und versuche es erneut.", - "generic": "Unbekannter Fehler bei der Verbindung. Bitte starte die Verbindung erneut." + "generic": "Unbekannter Fehler bei der Verbindung. Bitte starte die Verbindung erneut.", + "already_linked": "Dieses Strava-Konto ist schon mit einem anderen HealthLog-Profil verbunden. Melde dich bei Strava mit dem Konto an, das zu diesem Profil gehört, oder trenne es zuerst vom anderen Profil." }, "appleHealth": { "title": "Apple Health", diff --git a/messages/en.json b/messages/en.json index 8ca1e04c3..275e51c39 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6553,7 +6553,8 @@ "nocode": "Withings did not return an authorization code. Try again and allow access in the Withings dialog.", "nocreds": "No Withings API credentials are stored. Save the client ID and client secret first, then connect again.", "token": "The token exchange with Withings failed. Check the client ID and client secret and try again.", - "generic": "Unknown error while connecting. Please start the connection again." + "generic": "Unknown error while connecting. Please start the connection again.", + "already_linked": "This Withings account is already connected to another HealthLog profile. Sign in to Withings with the account that belongs to this profile, or disconnect it from the other profile first." }, "withingsSynchronize": "Synchronize", "withingsDisconnect": "Disconnect", @@ -6592,7 +6593,8 @@ "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." + "generic": "Unknown error while connecting. Please start the connection again.", + "already_linked": "This WHOOP account is already connected to another HealthLog profile. Sign in to WHOOP with the account that belongs to this profile, or disconnect it from the other profile first." }, "fitbit": "Fitbit", "fitbitTag": "Fitbit & Pixel", @@ -6671,7 +6673,8 @@ "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." + "generic": "Unknown error while connecting. Please start the connection again.", + "already_linked": "This Google account is already connected to another HealthLog profile. Sign in to Google with the account that belongs to this profile, or disconnect it from the other profile first." }, "fitbitOauthError": { "csrf1": "The security check failed because the browser cookie was missing. Please start the connection again in the same browser.", @@ -6682,7 +6685,8 @@ "nocreds": "Fitbit is not configured. Add your Fitbit OAuth credentials above and try again.", "token": "The token exchange with Fitbit failed. Please start the connection again.", "generic": "Unknown error while connecting. Please start the connection again.", - "retired": "Google has turned the Fitbit Web API off, so no new Fitbit connection can be made. Connect Google Health instead, which reads the same devices." + "retired": "Google has turned the Fitbit Web API off, so no new Fitbit connection can be made. Connect Google Health instead, which reads the same devices.", + "already_linked": "This Fitbit account is already connected to another HealthLog profile. Sign in to Fitbit with the account that belongs to this profile, or disconnect it from the other profile first." }, "integrations": { "withings": { @@ -7028,7 +7032,8 @@ "nocreds": "Polar is not configured on this server. Ask your administrator to add the Polar OAuth credentials.", "token": "The token exchange with Polar failed. Please start the connection again.", "rate_limited": "Too many connection attempts. Wait a moment and try again.", - "generic": "Unknown error while connecting. Please start the connection again." + "generic": "Unknown error while connecting. Please start the connection again.", + "already_linked": "This Polar account is already connected to another HealthLog profile. Sign in to Polar with the account that belongs to this profile, or disconnect it from the other profile first." }, "oura": "Oura", "ouraDescription": "Connect your Oura account to sync readiness, sleep stages, heart-rate variability, and daily activity.", @@ -7058,7 +7063,8 @@ "token": "The token exchange with Oura failed. Please start the connection again.", "rate_limited": "Too many connection attempts. Wait a moment and try again.", "issuer": "The sign-in answer did not come from Oura's own authorization server, so the connection was stopped for your safety. Please start the connection again. If this keeps happening, tell your administrator.", - "generic": "Unknown error while connecting. Please start the connection again." + "generic": "Unknown error while connecting. Please start the connection again.", + "already_linked": "This Oura account is already connected to another HealthLog profile. Sign in to Oura with the account that belongs to this profile, or disconnect it from the other profile first." }, "integrationSetupGuide": "Setup guide", "coach": { @@ -7286,7 +7292,8 @@ "nocreds": "Strava is not configured on this server. Ask your administrator to add the Strava API credentials.", "token": "The token exchange with Strava failed. Please start the connection again.", "rate_limited": "Too many connection attempts. Wait a moment and try again.", - "generic": "Unknown error while connecting. Please start the connection again." + "generic": "Unknown error while connecting. Please start the connection again.", + "already_linked": "This Strava account is already connected to another HealthLog profile. Sign in to Strava with the account that belongs to this profile, or disconnect it from the other profile first." }, "appleHealth": { "title": "Apple Health", diff --git a/messages/es.json b/messages/es.json index 3239be811..2b08e49a8 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6553,7 +6553,8 @@ "nocode": "Withings no devolvió ningún código de autorización. Inténtalo de nuevo y permite el acceso en el diálogo de Withings.", "nocreds": "No hay credenciales de la API de Withings guardadas. Guarda primero el Client ID y el Client Secret y vuelve a conectar.", "token": "El intercambio de tokens con Withings falló. Comprueba el Client ID y el Client Secret e inténtalo de nuevo.", - "generic": "Error desconocido al conectar. Inicia la conexión de nuevo." + "generic": "Error desconocido al conectar. Inicia la conexión de nuevo.", + "already_linked": "Esta cuenta de Withings ya está conectada a otro perfil de HealthLog. Inicia sesión en Withings con la cuenta de este perfil o desconéctala primero del otro perfil." }, "withingsSynchronize": "Sincronizar", "withingsDisconnect": "Desconectar", @@ -6592,7 +6593,8 @@ "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." + "generic": "Error desconocido al conectar. Inicia la conexión de nuevo.", + "already_linked": "Esta cuenta de WHOOP ya está conectada a otro perfil de HealthLog. Inicia sesión en WHOOP con la cuenta de este perfil o desconéctala primero del otro perfil." }, "fitbit": "Fitbit", "fitbitTag": "Fitbit y Pixel", @@ -6671,7 +6673,8 @@ "nocode": "Google no devolvió un código de autorización. Inténtalo de nuevo y concede el acceso en el diálogo de Google.", "nocreds": "Google Health no está configurado. Añade arriba tus credenciales de OAuth de Google e inténtalo de nuevo.", "token": "El intercambio de token con Google falló. Vuelve a iniciar la conexión.", - "generic": "Error desconocido al conectar. Vuelve a iniciar la conexión." + "generic": "Error desconocido al conectar. Vuelve a iniciar la conexión.", + "already_linked": "Esta cuenta de Google ya está conectada a otro perfil de HealthLog. Inicia sesión en Google con la cuenta de este perfil o desconéctala primero del otro perfil." }, "fitbitOauthError": { "csrf1": "La verificación de seguridad falló porque faltaba la cookie del navegador. Inicia la conexión de nuevo en el mismo navegador.", @@ -6682,7 +6685,8 @@ "nocreds": "Fitbit no está configurado. Añade tus credenciales OAuth de Fitbit arriba e inténtalo de nuevo.", "token": "El intercambio de token con Fitbit falló. Inicia la conexión de nuevo.", "generic": "Error desconocido al conectar. Inicia la conexión de nuevo.", - "retired": "Google desactivó la API web de Fitbit, así que ya no se puede crear una nueva conexión de Fitbit. Conecta Google Health, que lee los mismos dispositivos." + "retired": "Google desactivó la API web de Fitbit, así que ya no se puede crear una nueva conexión de Fitbit. Conecta Google Health, que lee los mismos dispositivos.", + "already_linked": "Esta cuenta de Fitbit ya está conectada a otro perfil de HealthLog. Inicia sesión en Fitbit con la cuenta de este perfil o desconéctala primero del otro perfil." }, "integrations": { "withings": { @@ -7028,7 +7032,8 @@ "nocreds": "Polar no está configurado en este servidor. Pide a tu administrador que añada las credenciales OAuth de Polar.", "token": "El intercambio de token con Polar falló. Inicia la conexión de nuevo.", "rate_limited": "Demasiados intentos de conexión. Espera un momento e inténtalo de nuevo.", - "generic": "Error desconocido al conectar. Inicia la conexión de nuevo." + "generic": "Error desconocido al conectar. Inicia la conexión de nuevo.", + "already_linked": "Esta cuenta de Polar ya está conectada a otro perfil de HealthLog. Inicia sesión en Polar con la cuenta de este perfil o desconéctala primero del otro perfil." }, "oura": "Oura", "ouraDescription": "Conecta tu cuenta de Oura para sincronizar disposición, fases de sueño, variabilidad cardíaca y actividad diaria.", @@ -7058,7 +7063,8 @@ "token": "El intercambio de token con Oura falló. Inicia la conexión de nuevo.", "rate_limited": "Demasiados intentos de conexión. Espera un momento e inténtalo de nuevo.", "issuer": "La respuesta de inicio de sesión no procedía del servidor de autorización de Oura, así que la conexión se detuvo por tu seguridad. Vuelve a iniciar la conexión. Si sigue ocurriendo, avisa a tu administrador.", - "generic": "Error desconocido al conectar. Inicia la conexión de nuevo." + "generic": "Error desconocido al conectar. Inicia la conexión de nuevo.", + "already_linked": "Esta cuenta de Oura ya está conectada a otro perfil de HealthLog. Inicia sesión en Oura con la cuenta de este perfil o desconéctala primero del otro perfil." }, "integrationSetupGuide": "Guía de configuración", "coach": { @@ -7286,7 +7292,8 @@ "nocreds": "Strava no está configurado en este servidor. Pide a tu administrador que añada las credenciales de API de Strava.", "token": "El intercambio de tokens con Strava falló. Inicia la conexión de nuevo.", "rate_limited": "Demasiados intentos de conexión. Espera un momento e inténtalo de nuevo.", - "generic": "Error desconocido al conectar. Inicia la conexión de nuevo." + "generic": "Error desconocido al conectar. Inicia la conexión de nuevo.", + "already_linked": "Esta cuenta de Strava ya está conectada a otro perfil de HealthLog. Inicia sesión en Strava con la cuenta de este perfil o desconéctala primero del otro perfil." }, "appleHealth": { "title": "Apple Health", diff --git a/messages/fr.json b/messages/fr.json index 7f395b5e5..7df4a74fe 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6553,7 +6553,8 @@ "nocode": "Withings n'a renvoyé aucun code d'autorisation. Réessayez et autorisez l'accès dans la fenêtre Withings.", "nocreds": "Aucun identifiant d'API Withings n'est enregistré. Enregistrez d'abord le Client ID et le Client Secret, puis reconnectez.", "token": "L'échange de jetons avec Withings a échoué. Vérifiez le Client ID et le Client Secret, puis réessayez.", - "generic": "Erreur inconnue lors de la connexion. Relancez la connexion." + "generic": "Erreur inconnue lors de la connexion. Relancez la connexion.", + "already_linked": "Ce compte Withings est déjà connecté à un autre profil HealthLog. Connecte-toi à Withings avec le compte de ce profil, ou déconnecte-le d'abord de l'autre profil." }, "withingsSynchronize": "Synchroniser", "withingsDisconnect": "Déconnecter", @@ -6592,7 +6593,8 @@ "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." + "generic": "Erreur inconnue lors de la connexion. Veuillez relancer la connexion.", + "already_linked": "Ce compte WHOOP est déjà connecté à un autre profil HealthLog. Connecte-toi à WHOOP avec le compte de ce profil, ou déconnecte-le d'abord de l'autre profil." }, "fitbit": "Fitbit", "fitbitTag": "Fitbit et Pixel", @@ -6671,7 +6673,8 @@ "nocode": "Google n'a pas renvoyé de code d'autorisation. Réessaie et autorise l'accès dans la fenêtre Google.", "nocreds": "Google Health n'est pas configuré. Ajoute tes identifiants OAuth Google ci-dessus et réessaie.", "token": "L'échange de jeton avec Google a échoué. Relance la connexion.", - "generic": "Erreur inconnue lors de la connexion. Relance la connexion." + "generic": "Erreur inconnue lors de la connexion. Relance la connexion.", + "already_linked": "Ce compte Google est déjà connecté à un autre profil HealthLog. Connecte-toi à Google avec le compte de ce profil, ou déconnecte-le d'abord de l'autre profil." }, "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.", @@ -6682,7 +6685,8 @@ "nocreds": "Fitbit n'est pas configuré. Ajoutez vos identifiants OAuth Fitbit ci-dessus et réessayez.", "token": "L'échange de jeton avec Fitbit a échoué. Veuillez relancer la connexion.", "generic": "Erreur inconnue lors de la connexion. Veuillez relancer la connexion.", - "retired": "Google a désactivé l'API Web Fitbit : il n'est plus possible de créer une nouvelle connexion Fitbit. Connectez plutôt Google Health, qui lit les mêmes appareils." + "retired": "Google a désactivé l'API Web Fitbit : il n'est plus possible de créer une nouvelle connexion Fitbit. Connectez plutôt Google Health, qui lit les mêmes appareils.", + "already_linked": "Ce compte Fitbit est déjà connecté à un autre profil HealthLog. Connecte-toi à Fitbit avec le compte de ce profil, ou déconnecte-le d'abord de l'autre profil." }, "integrations": { "withings": { @@ -7028,7 +7032,8 @@ "nocreds": "Polar n'est pas configuré sur ce serveur. Demandez à votre administrateur d'ajouter les identifiants OAuth Polar.", "token": "L'échange de jeton avec Polar a échoué. Veuillez relancer la connexion.", "rate_limited": "Trop de tentatives de connexion. Patientez un instant et réessayez.", - "generic": "Erreur inconnue lors de la connexion. Veuillez relancer la connexion." + "generic": "Erreur inconnue lors de la connexion. Veuillez relancer la connexion.", + "already_linked": "Ce compte Polar est déjà connecté à un autre profil HealthLog. Connecte-toi à Polar avec le compte de ce profil, ou déconnecte-le d'abord de l'autre profil." }, "oura": "Oura", "ouraDescription": "Connecte ton compte Oura pour synchroniser readiness, phases de sommeil, variabilité cardiaque et activité quotidienne.", @@ -7058,7 +7063,8 @@ "token": "L'échange de jeton avec Oura a échoué. Veuillez relancer la connexion.", "rate_limited": "Trop de tentatives de connexion. Patientez un instant et réessayez.", "issuer": "La réponse de connexion ne provenait pas du serveur d'autorisation d'Oura, la connexion a donc été interrompue par sécurité. Veuillez relancer la connexion. Si cela se reproduit, prévenez votre administrateur.", - "generic": "Erreur inconnue lors de la connexion. Veuillez relancer la connexion." + "generic": "Erreur inconnue lors de la connexion. Veuillez relancer la connexion.", + "already_linked": "Ce compte Oura est déjà connecté à un autre profil HealthLog. Connecte-toi à Oura avec le compte de ce profil, ou déconnecte-le d'abord de l'autre profil." }, "integrationSetupGuide": "Guide de configuration", "coach": { @@ -7286,7 +7292,8 @@ "nocreds": "Strava n'est pas configuré sur ce serveur. Demande à ton administrateur d'ajouter les identifiants API Strava.", "token": "L'échange de jeton avec Strava a échoué. Relance la connexion.", "rate_limited": "Trop de tentatives de connexion. Attends un instant et réessaie.", - "generic": "Erreur inconnue lors de la connexion. Relance la connexion." + "generic": "Erreur inconnue lors de la connexion. Relance la connexion.", + "already_linked": "Ce compte Strava est déjà connecté à un autre profil HealthLog. Connecte-toi à Strava avec le compte de ce profil, ou déconnecte-le d'abord de l'autre profil." }, "appleHealth": { "title": "Apple Health", diff --git a/messages/it.json b/messages/it.json index 85e890708..6dd7ff8a7 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6553,7 +6553,8 @@ "nocode": "Withings non ha restituito alcun codice di autorizzazione. Riprova e consenti l'accesso nella finestra di Withings.", "nocreds": "Non sono salvate credenziali API di Withings. Salva prima Client ID e Client Secret, poi ricollega.", "token": "Lo scambio di token con Withings non è riuscito. Controlla Client ID e Client Secret e riprova.", - "generic": "Errore sconosciuto durante il collegamento. Avvia di nuovo il collegamento." + "generic": "Errore sconosciuto durante il collegamento. Avvia di nuovo il collegamento.", + "already_linked": "Questo account Withings è già collegato a un altro profilo HealthLog. Accedi a Withings con l'account di questo profilo oppure scollegalo prima dall'altro profilo." }, "withingsSynchronize": "Sincronizza", "withingsDisconnect": "Disconnetti", @@ -6592,7 +6593,8 @@ "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." + "generic": "Errore sconosciuto durante la connessione. Riavvia la connessione.", + "already_linked": "Questo account WHOOP è già collegato a un altro profilo HealthLog. Accedi a WHOOP con l'account di questo profilo oppure scollegalo prima dall'altro profilo." }, "fitbit": "Fitbit", "fitbitTag": "Fitbit e Pixel", @@ -6671,7 +6673,8 @@ "nocode": "Google non ha restituito un codice di autorizzazione. Riprova e consenti l'accesso nella finestra di Google.", "nocreds": "Google Health non è configurato. Aggiungi qui sopra le tue credenziali OAuth di Google e riprova.", "token": "Lo scambio del token con Google non è riuscito. Riavvia la connessione.", - "generic": "Errore sconosciuto durante la connessione. Riavvia la connessione." + "generic": "Errore sconosciuto durante la connessione. Riavvia la connessione.", + "already_linked": "Questo account Google è già collegato a un altro profilo HealthLog. Accedi a Google con l'account di questo profilo oppure scollegalo prima dall'altro profilo." }, "fitbitOauthError": { "csrf1": "Il controllo di sicurezza è fallito perché il cookie del browser era assente. Riavvia la connessione nello stesso browser.", @@ -6682,7 +6685,8 @@ "nocreds": "Fitbit non è configurato. Aggiungi le tue credenziali OAuth di Fitbit qui sopra e riprova.", "token": "Lo scambio del token con Fitbit è fallito. Riavvia la connessione.", "generic": "Errore sconosciuto durante la connessione. Riavvia la connessione.", - "retired": "Google ha disattivato l'API web di Fitbit, quindi non è più possibile creare una nuova connessione Fitbit. Collega invece Google Health, che legge gli stessi dispositivi." + "retired": "Google ha disattivato l'API web di Fitbit, quindi non è più possibile creare una nuova connessione Fitbit. Collega invece Google Health, che legge gli stessi dispositivi.", + "already_linked": "Questo account Fitbit è già collegato a un altro profilo HealthLog. Accedi a Fitbit con l'account di questo profilo oppure scollegalo prima dall'altro profilo." }, "integrations": { "withings": { @@ -7028,7 +7032,8 @@ "nocreds": "Polar non è configurato su questo server. Chiedi al tuo amministratore di aggiungere le credenziali OAuth di Polar.", "token": "Lo scambio del token con Polar è fallito. Riavvia la connessione.", "rate_limited": "Troppi tentativi di connessione. Attendi un momento e riprova.", - "generic": "Errore sconosciuto durante la connessione. Riavvia la connessione." + "generic": "Errore sconosciuto durante la connessione. Riavvia la connessione.", + "already_linked": "Questo account Polar è già collegato a un altro profilo HealthLog. Accedi a Polar con l'account di questo profilo oppure scollegalo prima dall'altro profilo." }, "oura": "Oura", "ouraDescription": "Collega il tuo account Oura per sincronizzare prontezza, fasi del sonno, variabilità cardiaca e attività giornaliera.", @@ -7058,7 +7063,8 @@ "token": "Lo scambio del token con Oura è fallito. Riavvia la connessione.", "rate_limited": "Troppi tentativi di connessione. Attendi un momento e riprova.", "issuer": "La risposta di accesso non proveniva dal server di autorizzazione di Oura, quindi la connessione è stata interrotta per la tua sicurezza. Avvia di nuovo la connessione. Se succede ancora, avvisa il tuo amministratore.", - "generic": "Errore sconosciuto durante la connessione. Riavvia la connessione." + "generic": "Errore sconosciuto durante la connessione. Riavvia la connessione.", + "already_linked": "Questo account Oura è già collegato a un altro profilo HealthLog. Accedi a Oura con l'account di questo profilo oppure scollegalo prima dall'altro profilo." }, "integrationSetupGuide": "Guida alla configurazione", "coach": { @@ -7286,7 +7292,8 @@ "nocreds": "Strava non è configurato su questo server. Chiedi al tuo amministratore di aggiungere le credenziali API di Strava.", "token": "Lo scambio del token con Strava non è riuscito. Riavvia la connessione.", "rate_limited": "Troppi tentativi di connessione. Attendi un momento e riprova.", - "generic": "Errore sconosciuto durante la connessione. Riavvia la connessione." + "generic": "Errore sconosciuto durante la connessione. Riavvia la connessione.", + "already_linked": "Questo account Strava è già collegato a un altro profilo HealthLog. Accedi a Strava con l'account di questo profilo oppure scollegalo prima dall'altro profilo." }, "appleHealth": { "title": "Apple Health", diff --git a/messages/ko.json b/messages/ko.json index 0b844ffae..5b88aee4e 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -6553,7 +6553,8 @@ "nocode": "Withings가 인증 코드를 보내지 않았어요. 다시 시도하고 Withings 창에서 접근을 허용해 주세요.", "nocreds": "저장된 Withings API 자격 증명이 없어요. 클라이언트 ID와 클라이언트 시크릿을 먼저 저장한 뒤 다시 연결해 주세요.", "token": "Withings와의 토큰 교환에 실패했어요. 클라이언트 ID와 클라이언트 시크릿을 확인하고 다시 시도해 주세요.", - "generic": "연결 중 알 수 없는 오류가 생겼어요. 연결을 다시 시작해 주세요." + "generic": "연결 중 알 수 없는 오류가 생겼어요. 연결을 다시 시작해 주세요.", + "already_linked": "이 Withings 계정은 이미 다른 HealthLog 프로필에 연결되어 있어요. 이 프로필의 Withings 계정으로 로그인하거나, 먼저 다른 프로필에서 연결을 해제해 주세요." }, "withingsSynchronize": "동기화", "withingsDisconnect": "연결 해제", @@ -6592,7 +6593,8 @@ "nocode": "WHOOP이 인증 코드를 보내지 않았어요. 다시 시도하고 WHOOP 창에서 접근을 허용해 주세요.", "nocreds": "WHOOP이 설정되지 않았어요. 위에 WHOOP OAuth 자격 증명을 추가하고 다시 시도해 주세요.", "token": "WHOOP과의 토큰 교환에 실패했어요. 연결을 다시 시작해 주세요.", - "generic": "연결 중 알 수 없는 오류가 생겼어요. 연결을 다시 시작해 주세요." + "generic": "연결 중 알 수 없는 오류가 생겼어요. 연결을 다시 시작해 주세요.", + "already_linked": "이 WHOOP 계정은 이미 다른 HealthLog 프로필에 연결되어 있어요. 이 프로필의 WHOOP 계정으로 로그인하거나, 먼저 다른 프로필에서 연결을 해제해 주세요." }, "fitbit": "Fitbit", "fitbitTag": "Fitbit & Pixel", @@ -6671,7 +6673,8 @@ "nocode": "Google이 인증 코드를 보내지 않았어요. 다시 시도하고 Google 창에서 접근을 허용해 주세요.", "nocreds": "Google Health가 설정되지 않았어요. 위에 Google OAuth 자격 증명을 추가하고 다시 시도해 주세요.", "token": "Google과의 토큰 교환에 실패했어요. 연결을 다시 시작해 주세요.", - "generic": "연결 중 알 수 없는 오류가 생겼어요. 연결을 다시 시작해 주세요." + "generic": "연결 중 알 수 없는 오류가 생겼어요. 연결을 다시 시작해 주세요.", + "already_linked": "이 Google 계정은 이미 다른 HealthLog 프로필에 연결되어 있어요. 이 프로필의 Google 계정으로 로그인하거나, 먼저 다른 프로필에서 연결을 해제해 주세요." }, "fitbitOauthError": { "csrf1": "브라우저 쿠키가 없어서 보안 확인에 실패했어요. 같은 브라우저에서 연결을 다시 시작해 주세요.", @@ -6682,7 +6685,8 @@ "nocreds": "Fitbit이 설정되지 않았어요. 위에 Fitbit OAuth 자격 증명을 추가하고 다시 시도해 주세요.", "token": "Fitbit과의 토큰 교환에 실패했어요. 연결을 다시 시작해 주세요.", "generic": "연결 중 알 수 없는 오류가 생겼어요. 연결을 다시 시작해 주세요.", - "retired": "Google이 Fitbit Web API를 종료해서 새 Fitbit 연결을 만들 수 없어요. 같은 기기의 데이터를 읽는 Google Health를 대신 연결해 주세요." + "retired": "Google이 Fitbit Web API를 종료해서 새 Fitbit 연결을 만들 수 없어요. 같은 기기의 데이터를 읽는 Google Health를 대신 연결해 주세요.", + "already_linked": "이 Fitbit 계정은 이미 다른 HealthLog 프로필에 연결되어 있어요. 이 프로필의 Fitbit 계정으로 로그인하거나, 먼저 다른 프로필에서 연결을 해제해 주세요." }, "integrations": { "withings": { @@ -7028,7 +7032,8 @@ "nocreds": "이 서버에는 Polar가 설정돼 있지 않아요. 관리자에게 Polar OAuth 자격 증명을 추가해 달라고 요청하세요.", "token": "Polar와의 토큰 교환에 실패했어요. 연결을 다시 시작해 주세요.", "rate_limited": "연결 시도가 너무 많아요. 잠시 후 다시 시도해 주세요.", - "generic": "연결 중 알 수 없는 오류가 났어요. 연결을 다시 시작해 주세요." + "generic": "연결 중 알 수 없는 오류가 났어요. 연결을 다시 시작해 주세요.", + "already_linked": "이 Polar 계정은 이미 다른 HealthLog 프로필에 연결되어 있어요. 이 프로필의 Polar 계정으로 로그인하거나, 먼저 다른 프로필에서 연결을 해제해 주세요." }, "oura": "Oura", "ouraDescription": "Oura 계정을 연결해 준비도, 수면 단계, 심박변이도, 일일 활동을 동기화해요.", @@ -7058,7 +7063,8 @@ "token": "Oura와의 토큰 교환에 실패했어요. 연결을 다시 시작해 주세요.", "rate_limited": "연결 시도가 너무 많아요. 잠시 후 다시 시도해 주세요.", "issuer": "로그인 응답이 Oura의 인증 서버에서 오지 않아서 안전을 위해 연결을 중단했어요. 연결을 다시 시작해 주세요. 계속 발생하면 관리자에게 알려 주세요.", - "generic": "연결 중 알 수 없는 오류가 났어요. 연결을 다시 시작해 주세요." + "generic": "연결 중 알 수 없는 오류가 났어요. 연결을 다시 시작해 주세요.", + "already_linked": "이 Oura 계정은 이미 다른 HealthLog 프로필에 연결되어 있어요. 이 프로필의 Oura 계정으로 로그인하거나, 먼저 다른 프로필에서 연결을 해제해 주세요." }, "integrationSetupGuide": "설정 가이드", "coach": { @@ -7286,7 +7292,8 @@ "nocreds": "이 서버에는 Strava가 설정돼 있지 않아요. 관리자에게 Strava API 자격 증명을 추가해 달라고 요청하세요.", "token": "Strava와의 토큰 교환에 실패했어요. 연결을 다시 시작해 주세요.", "rate_limited": "연결 시도가 너무 많아요. 잠시 후 다시 시도해 주세요.", - "generic": "연결 중 알 수 없는 오류가 났어요. 연결을 다시 시작해 주세요." + "generic": "연결 중 알 수 없는 오류가 났어요. 연결을 다시 시작해 주세요.", + "already_linked": "이 Strava 계정은 이미 다른 HealthLog 프로필에 연결되어 있어요. 이 프로필의 Strava 계정으로 로그인하거나, 먼저 다른 프로필에서 연결을 해제해 주세요." }, "appleHealth": { "title": "Apple Health", diff --git a/messages/nl.json b/messages/nl.json index c44560507..72dc4a3d4 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -6553,7 +6553,8 @@ "nocode": "Withings heeft geen autorisatiecode teruggestuurd. Probeer het opnieuw en sta toegang toe in het Withings-venster.", "nocreds": "Er zijn geen Withings-API-inloggegevens opgeslagen. Sla eerst de client-ID en het clientgeheim op en koppel daarna opnieuw.", "token": "De tokenuitwisseling met Withings is mislukt. Controleer de client-ID en het clientgeheim en probeer het opnieuw.", - "generic": "Onbekende fout bij het koppelen. Start de koppeling opnieuw." + "generic": "Onbekende fout bij het koppelen. Start de koppeling opnieuw.", + "already_linked": "Dit Withings-account is al gekoppeld aan een ander HealthLog-profiel. Log bij Withings in met het account van dit profiel, of ontkoppel het eerst van het andere profiel." }, "withingsSynchronize": "Synchroniseren", "withingsDisconnect": "Ontkoppelen", @@ -6592,7 +6593,8 @@ "nocode": "WHOOP heeft geen autorisatiecode teruggestuurd. Probeer het opnieuw en sta toegang toe in het WHOOP-venster.", "nocreds": "WHOOP is niet geconfigureerd. Voeg hierboven je WHOOP-OAuth-inloggegevens toe en probeer het opnieuw.", "token": "De tokenuitwisseling met WHOOP is mislukt. Start de koppeling opnieuw.", - "generic": "Onbekende fout bij het koppelen. Start de koppeling opnieuw." + "generic": "Onbekende fout bij het koppelen. Start de koppeling opnieuw.", + "already_linked": "Dit WHOOP-account is al gekoppeld aan een ander HealthLog-profiel. Log bij WHOOP in met het account van dit profiel, of ontkoppel het eerst van het andere profiel." }, "fitbit": "Fitbit", "fitbitTag": "Fitbit & Pixel", @@ -6671,7 +6673,8 @@ "nocode": "Google heeft geen autorisatiecode teruggestuurd. Probeer het opnieuw en sta toegang toe in het Google-venster.", "nocreds": "Google Health is niet geconfigureerd. Voeg hierboven je Google-OAuth-inloggegevens toe en probeer het opnieuw.", "token": "De tokenuitwisseling met Google is mislukt. Start de koppeling opnieuw.", - "generic": "Onbekende fout bij het koppelen. Start de koppeling opnieuw." + "generic": "Onbekende fout bij het koppelen. Start de koppeling opnieuw.", + "already_linked": "Dit Google-account is al gekoppeld aan een ander HealthLog-profiel. Log bij Google in met het account van dit profiel, of ontkoppel het eerst van het andere profiel." }, "fitbitOauthError": { "csrf1": "De beveiligingscontrole is mislukt omdat de browsercookie ontbrak. Start de koppeling opnieuw in dezelfde browser.", @@ -6682,7 +6685,8 @@ "nocreds": "Fitbit is niet geconfigureerd. Voeg hierboven je Fitbit-OAuth-inloggegevens toe en probeer het opnieuw.", "token": "De tokenuitwisseling met Fitbit is mislukt. Start de koppeling opnieuw.", "generic": "Onbekende fout bij het koppelen. Start de koppeling opnieuw.", - "retired": "Google heeft de Fitbit Web API uitgeschakeld, dus er kan geen nieuwe Fitbit-koppeling meer worden gemaakt. Koppel in plaats daarvan Google Health, dat dezelfde apparaten leest." + "retired": "Google heeft de Fitbit Web API uitgeschakeld, dus er kan geen nieuwe Fitbit-koppeling meer worden gemaakt. Koppel in plaats daarvan Google Health, dat dezelfde apparaten leest.", + "already_linked": "Dit Fitbit-account is al gekoppeld aan een ander HealthLog-profiel. Log bij Fitbit in met het account van dit profiel, of ontkoppel het eerst van het andere profiel." }, "integrations": { "withings": { @@ -7028,7 +7032,8 @@ "nocreds": "Polar is niet geconfigureerd op deze server. Vraag je beheerder om de Polar-OAuth-inloggegevens toe te voegen.", "token": "De tokenuitwisseling met Polar is mislukt. Start de koppeling opnieuw.", "rate_limited": "Te veel koppelingspogingen. Wacht even en probeer het opnieuw.", - "generic": "Onbekende fout bij het koppelen. Start de koppeling opnieuw." + "generic": "Onbekende fout bij het koppelen. Start de koppeling opnieuw.", + "already_linked": "Dit Polar-account is al gekoppeld aan een ander HealthLog-profiel. Log bij Polar in met het account van dit profiel, of ontkoppel het eerst van het andere profiel." }, "oura": "Oura", "ouraDescription": "Koppel je Oura-account om paraatheid, slaapfasen, hartslagvariabiliteit en dagelijkse activiteit te synchroniseren.", @@ -7058,7 +7063,8 @@ "token": "De tokenuitwisseling met Oura is mislukt. Start de koppeling opnieuw.", "rate_limited": "Te veel koppelingspogingen. Wacht even en probeer het opnieuw.", "issuer": "Het inlogantwoord kwam niet van de eigen autorisatieserver van Oura, daarom is de koppeling voor je veiligheid gestopt. Start de koppeling opnieuw. Als dit blijft gebeuren, laat het dan je beheerder weten.", - "generic": "Onbekende fout bij het koppelen. Start de koppeling opnieuw." + "generic": "Onbekende fout bij het koppelen. Start de koppeling opnieuw.", + "already_linked": "Dit Oura-account is al gekoppeld aan een ander HealthLog-profiel. Log bij Oura in met het account van dit profiel, of ontkoppel het eerst van het andere profiel." }, "integrationSetupGuide": "Installatiehandleiding", "coach": { @@ -7286,7 +7292,8 @@ "nocreds": "Strava is niet geconfigureerd op deze server. Vraag je beheerder om de Strava-API-inloggegevens toe te voegen.", "token": "Het uitwisselen van het token met Strava is mislukt. Start het koppelen opnieuw.", "rate_limited": "Te veel koppelpogingen. Wacht even en probeer het opnieuw.", - "generic": "Onbekende fout bij het koppelen. Start het koppelen opnieuw." + "generic": "Onbekende fout bij het koppelen. Start het koppelen opnieuw.", + "already_linked": "Dit Strava-account is al gekoppeld aan een ander HealthLog-profiel. Log bij Strava in met het account van dit profiel, of ontkoppel het eerst van het andere profiel." }, "appleHealth": { "title": "Apple Health", diff --git a/messages/pl.json b/messages/pl.json index c1812715f..f1b04e0bc 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6553,7 +6553,8 @@ "nocode": "Withings nie zwrócił kodu autoryzacji. Spróbuj ponownie i zezwól na dostęp w oknie Withings.", "nocreds": "Brak zapisanych danych dostępowych API Withings. Najpierw zapisz Client ID i Client Secret, potem połącz ponownie.", "token": "Wymiana tokenów z Withings nie powiodła się. Sprawdź Client ID i Client Secret i spróbuj ponownie.", - "generic": "Nieznany błąd podczas łączenia. Rozpocznij połączenie ponownie." + "generic": "Nieznany błąd podczas łączenia. Rozpocznij połączenie ponownie.", + "already_linked": "To konto Withings jest już połączone z innym profilem HealthLog. Zaloguj się do Withings kontem należącym do tego profilu albo najpierw odłącz je od tamtego profilu." }, "withingsSynchronize": "Synchronizuj", "withingsDisconnect": "Rozłącz", @@ -6592,7 +6593,8 @@ "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." + "generic": "Nieznany błąd podczas łączenia. Rozpocznij połączenie ponownie.", + "already_linked": "To konto WHOOP jest już połączone z innym profilem HealthLog. Zaloguj się do WHOOP kontem należącym do tego profilu albo najpierw odłącz je od tamtego profilu." }, "fitbit": "Fitbit", "fitbitTag": "Fitbit i Pixel", @@ -6671,7 +6673,8 @@ "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 tokenu z Google nie powiodła się. Rozpocznij połączenie ponownie.", - "generic": "Nieznany błąd podczas łączenia. Rozpocznij połączenie ponownie." + "generic": "Nieznany błąd podczas łączenia. Rozpocznij połączenie ponownie.", + "already_linked": "To konto Google jest już połączone z innym profilem HealthLog. Zaloguj się do Google kontem należącym do tego profilu albo najpierw odłącz je od tamtego profilu." }, "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.", @@ -6682,7 +6685,8 @@ "nocreds": "Fitbit nie jest skonfigurowany. Dodaj powyżej swoje dane OAuth Fitbit i spróbuj ponownie.", "token": "Wymiana tokena z Fitbit nie powiodła się. Rozpocznij połączenie ponownie.", "generic": "Nieznany błąd podczas łączenia. Rozpocznij połączenie ponownie.", - "retired": "Google wyłączył Fitbit Web API, więc nie można już utworzyć nowego połączenia Fitbit. Połącz zamiast tego Google Health, które odczytuje dane z tych samych urządzeń." + "retired": "Google wyłączył Fitbit Web API, więc nie można już utworzyć nowego połączenia Fitbit. Połącz zamiast tego Google Health, które odczytuje dane z tych samych urządzeń.", + "already_linked": "To konto Fitbit jest już połączone z innym profilem HealthLog. Zaloguj się do Fitbit kontem należącym do tego profilu albo najpierw odłącz je od tamtego profilu." }, "integrations": { "withings": { @@ -7028,7 +7032,8 @@ "nocreds": "Polar nie jest skonfigurowany na tym serwerze. Poproś administratora o dodanie danych OAuth Polar.", "token": "Wymiana tokena z Polar nie powiodła się. Rozpocznij połączenie ponownie.", "rate_limited": "Zbyt wiele prób połączenia. Poczekaj chwilę i spróbuj ponownie.", - "generic": "Nieznany błąd podczas łączenia. Rozpocznij połączenie ponownie." + "generic": "Nieznany błąd podczas łączenia. Rozpocznij połączenie ponownie.", + "already_linked": "To konto Polar jest już połączone z innym profilem HealthLog. Zaloguj się do Polar kontem należącym do tego profilu albo najpierw odłącz je od tamtego profilu." }, "oura": "Oura", "ouraDescription": "Połącz konto Oura, aby synchronizować gotowość, fazy snu, zmienność rytmu serca i dzienną aktywność.", @@ -7058,7 +7063,8 @@ "token": "Wymiana tokena z Oura nie powiodła się. Rozpocznij połączenie ponownie.", "rate_limited": "Zbyt wiele prób połączenia. Poczekaj chwilę i spróbuj ponownie.", "issuer": "Odpowiedź logowania nie pochodziła z serwera autoryzacji Oura, więc połączenie zostało przerwane dla Twojego bezpieczeństwa. Rozpocznij połączenie ponownie. Jeśli to się powtarza, powiadom administratora.", - "generic": "Nieznany błąd podczas łączenia. Rozpocznij połączenie ponownie." + "generic": "Nieznany błąd podczas łączenia. Rozpocznij połączenie ponownie.", + "already_linked": "To konto Oura jest już połączone z innym profilem HealthLog. Zaloguj się do Oura kontem należącym do tego profilu albo najpierw odłącz je od tamtego profilu." }, "integrationSetupGuide": "Przewodnik konfiguracji", "coach": { @@ -7286,7 +7292,8 @@ "nocreds": "Strava nie jest skonfigurowana na tym serwerze. Poproś administratora o dodanie danych API Strava.", "token": "Wymiana tokenu ze Strava nie powiodła się. Rozpocznij połączenie ponownie.", "rate_limited": "Zbyt wiele prób połączenia. Poczekaj chwilę i spróbuj ponownie.", - "generic": "Nieznany błąd podczas łączenia. Rozpocznij połączenie ponownie." + "generic": "Nieznany błąd podczas łączenia. Rozpocznij połączenie ponownie.", + "already_linked": "To konto Strava jest już połączone z innym profilem HealthLog. Zaloguj się do Strava kontem należącym do tego profilu albo najpierw odłącz je od tamtego profilu." }, "appleHealth": { "title": "Apple Health", diff --git a/prisma/migrations/0441_oura_user_id/migration.sql b/prisma/migrations/0441_oura_user_id/migration.sql new file mode 100644 index 000000000..d0752839a --- /dev/null +++ b/prisma/migrations/0441_oura_user_id/migration.sql @@ -0,0 +1,9 @@ +-- The Oura account id behind a connection. +-- +-- Read from `personal_info` at connect, so a connect that would attach an +-- Oura account another record already holds can be refused. Nullable and +-- unset for every existing row: a current connection is never asked to +-- reconnect, the id fills in at its next connect. + +ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "oura_user_id" TEXT; +CREATE INDEX IF NOT EXISTS "users_oura_user_id_idx" ON "users" ("oura_user_id"); diff --git a/prisma/migrations/0442_client_app_shared_by/migration.sql b/prisma/migrations/0442_client_app_shared_by/migration.sql new file mode 100644 index 000000000..c59acb756 --- /dev/null +++ b/prisma/migrations/0442_client_app_shared_by/migration.sql @@ -0,0 +1,12 @@ +-- Which provider apps on a record were copied from a guardian, and from whom. +-- +-- A guardian connecting a provider for a managed profile lends the profile +-- their own provider app (client id and secret, still sealed). The marker lets +-- the copy be removed when the provider is disconnected or the guardian's +-- MANAGE grant ends, instead of the secret staying on the profile for good. +-- +-- Nullable and unset for every existing row. A copy made before this column +-- existed carries no marker and is left as it is: there is no reliable way to +-- tell it from an app the record holds of its own. + +ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "client_app_shared_by" JSONB; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 254c052d1..203479c7a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -296,6 +296,18 @@ model User { /// v1.44 — the Oura history backfill finished (self-converging discovery /// predicate `oura_backfill_completed_at IS NULL`). Cleared on connect. ouraBackfillCompletedAt DateTime? @map("oura_backfill_completed_at") + /// v1.44 — the Oura account id (`personal_info.id`) of the current grant, + /// read at connect when the `personal` scope is granted. A connect whose + /// account another record already holds is refused + /// (`src/lib/integrations/oauth-kit/account-link.ts`). Cleared on disconnect. + ouraUserId String? @map("oura_user_id") + /// v1.44 — which provider apps on this record were copied from a + /// guardian at connect, and from whom: `{ "": "" }`. + /// A managed profile has no settings page for its own app, so a guardian's is + /// borrowed (`shareGuardianClientApp`). The copy is removed again when the + /// provider is disconnected or that guardian no longer holds a live MANAGE + /// grant on the record (`src/lib/integrations/oauth-kit/connect-target.ts`). + clientAppSharedBy Json? @map("client_app_shared_by") // v1.28.x — Strava OAuth credentials (per-user BYO-app, encrypted at app // level). Strava caps every newly-created API app at single-player mode @@ -5438,16 +5450,16 @@ model AppSettings { /// Active pulse day-mean method (CHECK in migration 0407). Readers: every /// process's mirror (`pulse-day-mean-method.ts`) and the rollup tier /// (`storedHourWeightSql`); the admin status route. - pulseDayMeanMethod String @default("hour_mean_v1") @map("pulse_day_mean_method") + pulseDayMeanMethod String @default("hour_mean_v1") @map("pulse_day_mean_method") /// When the admin applied the current method; shown on the admin card. - pulseDayMeanAppliedAt DateTime? @map("pulse_day_mean_applied_at") + pulseDayMeanAppliedAt DateTime? @map("pulse_day_mean_applied_at") /// Last dry-run status and result (account ordinals only, no ids); the apply /// step's precondition and the admin card's result. - pulseDayMeanDryRunJson Json? @map("pulse_day_mean_dry_run_json") + pulseDayMeanDryRunJson Json? @map("pulse_day_mean_dry_run_json") /// @internal: progress marker of the one-time workout climb backfill /// (`src/lib/workouts/climb-backfill.ts`): the last workout id scanned, /// `done` when finished, NULL before it starts. Operational, never shown. - workoutClimbBackfillCursor String? @map("workout_climb_backfill_cursor") + workoutClimbBackfillCursor String? @map("workout_climb_backfill_cursor") @@map("app_settings") } diff --git a/src/app/api/fitbit/callback/route.ts b/src/app/api/fitbit/callback/route.ts index 84af494a0..c553c0aff 100644 --- a/src/app/api/fitbit/callback/route.ts +++ b/src/app/api/fitbit/callback/route.ts @@ -1,4 +1,8 @@ import { prisma } from "@/lib/db"; +import { + ALREADY_LINKED_REASON, + accountLinkedElsewhere, +} from "@/lib/integrations/oauth-kit/account-link"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate, getEvent } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; @@ -163,6 +167,11 @@ export const GET = apiHandler(async (request: NextRequest) => { const profile = await fetchProfile(tokens.access_token); const fitbitUserId = resolveFitbitUserId(profile); + // One Fitbit account, one record. + if (await accountLinkedElsewhere("fitbit", fitbitUserId, user.id)) { + annotate({ meta: { reason: ALREADY_LINKED_REASON } }); + return ERR(ALREADY_LINKED_REASON); + } const expiresAt = new Date(Date.now() + tokens.expires_in * 1000); await prisma.fitbitConnection.upsert({ diff --git a/src/app/api/google-health/callback/route.ts b/src/app/api/google-health/callback/route.ts index 71d6cf67b..821c81530 100644 --- a/src/app/api/google-health/callback/route.ts +++ b/src/app/api/google-health/callback/route.ts @@ -1,4 +1,8 @@ import { prisma } from "@/lib/db"; +import { + ALREADY_LINKED_REASON, + accountLinkedElsewhere, +} from "@/lib/integrations/oauth-kit/account-link"; import { apiHandler, AUTH_ERROR_CODES, HttpError } from "@/lib/api-handler"; import { getSession } from "@/lib/auth/session"; import { verifyCallbackActor } from "@/lib/integrations/oauth-kit/connect-target"; @@ -176,6 +180,17 @@ export const GET = apiHandler(async (request: NextRequest) => { const profile = await fetchProfile(tokens.access_token); const googleUserId = resolveGoogleHealthUserId(profile); + // One Google account, one record. + if ( + await accountLinkedElsewhere( + "google-health", + googleUserId, + recordUserId, + ) + ) { + annotate({ meta: { reason: ALREADY_LINKED_REASON } }); + return ERR(ALREADY_LINKED_REASON); + } const expiresAt = new Date(Date.now() + tokens.expires_in * 1000); const scope = tokens.scope ?? getGoogleHealthScopeString(); diff --git a/src/app/api/oura/callback/__tests__/route.test.ts b/src/app/api/oura/callback/__tests__/route.test.ts index 342c6d1df..215b7a481 100644 --- a/src/app/api/oura/callback/__tests__/route.test.ts +++ b/src/app/api/oura/callback/__tests__/route.test.ts @@ -38,7 +38,7 @@ vi.mock("@/lib/api-handler", () => ({ })); vi.mock("@/lib/db", () => ({ - prisma: { user: { update: vi.fn() } }, + prisma: { user: { update: vi.fn(), findMany: vi.fn(async () => []) } }, })); vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn(async () => null) })); @@ -79,6 +79,7 @@ vi.mock("@/lib/oura/client", async (importOriginal) => ({ await importOriginal() ).isAcceptedOuraIssuer, exchangeCode: exchangeMock, + fetchOuraAccountId: vi.fn(async () => "oura-account-1"), })); vi.mock("@/lib/oura/credentials", () => ({ getOuraClientCredentials: getCredsMock, @@ -179,8 +180,26 @@ describe("GET /api/oura/callback", () => { userId: "u1", actorUserId: null, }); + expect(data.ouraUserId).toBe("oura-account-1"); expect(res.headers.get("location")).toContain("oura=connected"); }); + + it("refuses an Oura account another record already holds", async () => { + const { prisma } = await import("@/lib/db"); + vi.mocked(prisma.user.findMany).mockResolvedValueOnce([ + { id: "guardian-1" }, + ] as never); + const res = await GET( + makeReq({ code: "auth-code", state: "S", cookie: "S" }), + ); + expect(res.headers.get("location")).toContain("reason=already_linked"); + expect(prisma.user.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { ouraUserId: "oura-account-1", id: { not: "u1" } }, + }), + ); + expect(userUpdate).not.toHaveBeenCalled(); + }); }); // Oura's current authorization server names itself on the callback diff --git a/src/app/api/oura/callback/route.ts b/src/app/api/oura/callback/route.ts index 253ac4f60..ec67933d4 100644 --- a/src/app/api/oura/callback/route.ts +++ b/src/app/api/oura/callback/route.ts @@ -6,7 +6,15 @@ import { verifyCallbackActor } from "@/lib/integrations/oauth-kit/connect-target import { annotate, getEvent } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { encrypt } from "@/lib/crypto"; -import { exchangeCode, isAcceptedOuraIssuer } from "@/lib/oura/client"; +import { + exchangeCode, + fetchOuraAccountId, + isAcceptedOuraIssuer, +} from "@/lib/oura/client"; +import { + ALREADY_LINKED_REASON, + accountLinkedElsewhere, +} from "@/lib/integrations/oauth-kit/account-link"; import { getOuraClientCredentials } from "@/lib/oura/credentials"; import { oauthStateCookieName, @@ -133,6 +141,14 @@ async function completeCallback( const tokens = await exchangeCode(code, creds, iss); + // One Oura account, one record: a guardian signed in to their own Oura + // account must not attach it to the profile they manage. + const ouraUserId = await fetchOuraAccountId(tokens.access_token); + if (await accountLinkedElsewhere("oura", ouraUserId, userId)) { + annotate({ meta: { reason: ALREADY_LINKED_REASON } }); + return ERR(ALREADY_LINKED_REASON); + } + // The token response names the granted scopes when Oura sends them; a // permission the person unticked on the consent screen is known from it // at once. Without them, the first sync finds out per collection. @@ -146,6 +162,7 @@ async function completeCallback( data: { ouraAccessTokenEncrypted: encrypt(tokens.access_token), ouraRefreshTokenEncrypted: encrypt(tokens.refresh_token), + ouraUserId, ouraScopeDenied: missing && missing.length > 0 ? missing : Prisma.DbNull, // Open the history backfill again: a first connect has none yet, diff --git a/src/app/api/oura/credentials/route.ts b/src/app/api/oura/credentials/route.ts index d6b696c67..327faee25 100644 --- a/src/app/api/oura/credentials/route.ts +++ b/src/app/api/oura/credentials/route.ts @@ -98,6 +98,7 @@ export const DELETE = apiHandler(async () => { ouraAccessTokenEncrypted: null, ouraRefreshTokenEncrypted: null, ouraScopeDenied: Prisma.DbNull, + ouraUserId: null, }, }); await clearOuraClientCredentials(user.id); diff --git a/src/app/api/polar/callback/__tests__/route.test.ts b/src/app/api/polar/callback/__tests__/route.test.ts index 21d925ad3..84c255e1a 100644 --- a/src/app/api/polar/callback/__tests__/route.test.ts +++ b/src/app/api/polar/callback/__tests__/route.test.ts @@ -58,7 +58,9 @@ vi.mock("@/lib/integrations/oauth-kit/connect-target", () => ({ vi.mock("@/lib/api-handler", () => ({ apiHandler: unknown>(fn: T) => fn, })); -vi.mock("@/lib/db", () => ({ prisma: { user: { update: vi.fn() } } })); +vi.mock("@/lib/db", () => ({ + prisma: { user: { update: vi.fn(), findMany: vi.fn(async () => []) } }, +})); vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn(), getEvent: () => ({ @@ -68,7 +70,10 @@ vi.mock("@/lib/logging/context", () => ({ }), })); vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() })); -vi.mock("@/lib/crypto", () => ({ encrypt: (s: string) => `enc:${s}` })); +vi.mock("@/lib/crypto", () => ({ + encrypt: (s: string) => `enc:${s}`, + decrypt: (s: string) => s.replace(/^enc:/, ""), +})); vi.mock("@/lib/auth/session", () => ({ getSession: getSessionMock })); vi.mock("@/lib/polar/client", () => ({ exchangeCode: exchangeCodeMock, @@ -123,6 +128,17 @@ describe("GET /api/polar/callback", () => { expect(markReconnectedMock).toHaveBeenCalledWith("u1", "polar"); }); + it("refuses a Polar member another record already holds, before registering it", async () => { + const { prisma } = await import("@/lib/db"); + vi.mocked(prisma.user.findMany).mockResolvedValueOnce([ + { id: "guardian-1", polarUserIdEncrypted: "enc:42" }, + ] as never); + const res = await run(makeReq()); + expect(res.headers.get("location")).toContain("reason=already_linked"); + expect(registerUserMock).not.toHaveBeenCalled(); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + it("rejects a CSRF cookie/param mismatch before exchanging the code", async () => { stateMatchesCookieMock.mockReturnValue(false); const res = await run(makeReq()); diff --git a/src/app/api/polar/callback/route.ts b/src/app/api/polar/callback/route.ts index f67765b43..6bb84b67e 100644 --- a/src/app/api/polar/callback/route.ts +++ b/src/app/api/polar/callback/route.ts @@ -1,4 +1,8 @@ import { prisma } from "@/lib/db"; +import { + ALREADY_LINKED_REASON, + accountLinkedElsewhere, +} from "@/lib/integrations/oauth-kit/account-link"; import { apiHandler } from "@/lib/api-handler"; import { getSession } from "@/lib/auth/session"; import { verifyCallbackActor } from "@/lib/integrations/oauth-kit/connect-target"; @@ -123,6 +127,13 @@ async function completeCallback( } const polarUserId = String(tokens.x_user_id); + // One Polar member, one record. Checked before the registration so a + // refused connect registers nothing. + if (await accountLinkedElsewhere("polar", polarUserId, userId)) { + annotate({ meta: { reason: ALREADY_LINKED_REASON } }); + return ERR(ALREADY_LINKED_REASON); + } + // Register the user with the Polar app (idempotent; 409 = already linked). await registerUser(tokens.access_token, polarUserId); diff --git a/src/app/api/settings/data/route.ts b/src/app/api/settings/data/route.ts index bc9485205..d18710799 100644 --- a/src/app/api/settings/data/route.ts +++ b/src/app/api/settings/data/route.ts @@ -1,3 +1,4 @@ +import { releaseUnbackedClientApps } from "@/lib/integrations/oauth-kit/connect-target"; import { prisma } from "@/lib/db"; import { auditLog } from "@/lib/auth/audit"; import { @@ -92,7 +93,10 @@ export const DELETE = apiHandler(async (request: NextRequest) => { // through AccountGrant removal. A queued Guardian egress claim thus // rechecks after this wipe commits instead of sending from a grant // this transaction has already deleted. - await protectManagedProfilesDuringDataWipe(tx, userId); + const affectedProfiles = await protectManagedProfilesDuringDataWipe( + tx, + userId, + ); const perModel: Record = {}; for (const model of WIPE_MODELS) { @@ -114,6 +118,13 @@ export const DELETE = apiHandler(async (request: NextRequest) => { // the objects and removes it. await requestOffhostPurge(tx, userId, "data_wiped"); + // A guardian's wipe ends their grants; a provider app a profile + // borrowed from them goes with those grants. + for (const profileId of affectedProfiles) { + if (profileId !== userId) + await releaseUnbackedClientApps(profileId, tx); + } + return perModel; }, { diff --git a/src/app/api/strava/callback/__tests__/route.test.ts b/src/app/api/strava/callback/__tests__/route.test.ts index 2594d1adb..a2f0f15de 100644 --- a/src/app/api/strava/callback/__tests__/route.test.ts +++ b/src/app/api/strava/callback/__tests__/route.test.ts @@ -13,6 +13,7 @@ const { exchangeCodeMock, getCredsMock, userFindUnique, + userFindMany, userUpdate, markReconnectedMock, } = vi.hoisted(() => ({ @@ -21,6 +22,7 @@ const { exchangeCodeMock: vi.fn(), getCredsMock: vi.fn(), userFindUnique: vi.fn(), + userFindMany: vi.fn(), userUpdate: vi.fn(async () => ({})), markReconnectedMock: vi.fn(async () => {}), })); @@ -62,7 +64,13 @@ vi.mock("@/lib/api-handler", () => ({ apiHandler: unknown>(fn: T) => fn, })); vi.mock("@/lib/db", () => ({ - prisma: { user: { findUnique: userFindUnique, update: userUpdate } }, + prisma: { + user: { + findUnique: userFindUnique, + findMany: userFindMany, + update: userUpdate, + }, + }, })); vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn(async () => null) })); vi.mock("@/lib/logging/context", () => ({ @@ -116,6 +124,7 @@ beforeEach(() => { athlete: { id: 42 }, }); userFindUnique.mockResolvedValue({ stravaAthleteId: null }); + userFindMany.mockResolvedValue([]); getGlobalBossMock.mockReturnValue({ send: bossSend }); }); @@ -142,6 +151,22 @@ describe("GET /api/strava/callback — S-1 backfill enqueue", () => { }); }); +describe("GET /api/strava/callback — one athlete, one record", () => { + it("refuses an athlete another record already holds", async () => { + userFindMany.mockResolvedValueOnce([{ id: "guardian-1" }]); + + const res = await callback(); + + expect(res.headers.get("location")).toContain("reason=already_linked"); + expect(userFindMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { stravaAthleteId: "42", id: { not: "u1" } }, + }), + ); + expect(userUpdate).not.toHaveBeenCalled(); + }); +}); + describe("GET /api/strava/callback — claim only what can be exchanged", () => { it("does not claim the state for a callback without a code", async () => { const res = await callback("state=signed-state&error=access_denied"); diff --git a/src/app/api/strava/callback/route.ts b/src/app/api/strava/callback/route.ts index b6d184849..9e0c4c08e 100644 --- a/src/app/api/strava/callback/route.ts +++ b/src/app/api/strava/callback/route.ts @@ -1,4 +1,8 @@ import { prisma } from "@/lib/db"; +import { + ALREADY_LINKED_REASON, + accountLinkedElsewhere, +} from "@/lib/integrations/oauth-kit/account-link"; import { apiHandler } from "@/lib/api-handler"; import { getSession } from "@/lib/auth/session"; import { verifyCallbackActor } from "@/lib/integrations/oauth-kit/connect-target"; @@ -130,6 +134,11 @@ async function completeCallback( annotate({ meta: { reason: "cross_user" } }); return ERR("cross_user"); } + // One Strava athlete, one record. + if (await accountLinkedElsewhere("strava", athleteId, userId)) { + annotate({ meta: { reason: ALREADY_LINKED_REASON } }); + return ERR(ALREADY_LINKED_REASON); + } await prisma.user.update({ where: { id: userId }, diff --git a/src/app/api/strava/disconnect/__tests__/route.test.ts b/src/app/api/strava/disconnect/__tests__/route.test.ts index ee03d9dd0..25d4450bb 100644 --- a/src/app/api/strava/disconnect/__tests__/route.test.ts +++ b/src/app/api/strava/disconnect/__tests__/route.test.ts @@ -11,7 +11,15 @@ vi.mock("@/lib/api-handler", () => ({ apiHandler: unknown>(fn: T) => fn, requireAuth: vi.fn(async () => ({ user: { id: "u1" } })), })); -vi.mock("@/lib/db", () => ({ prisma: { user: { update: vi.fn() } } })); +vi.mock("@/lib/db", () => ({ + prisma: { + user: { + update: vi.fn(), + findUnique: vi.fn(async () => null), + findMany: vi.fn(async () => []), + }, + }, +})); vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn(), getEvent: () => null, diff --git a/src/app/api/whoop/callback/__tests__/route.test.ts b/src/app/api/whoop/callback/__tests__/route.test.ts index c7750844f..7ceec9334 100644 --- a/src/app/api/whoop/callback/__tests__/route.test.ts +++ b/src/app/api/whoop/callback/__tests__/route.test.ts @@ -165,7 +165,7 @@ describe("GET /api/whoop/callback custom-scheme redirect", () => { const res = await GET(makeReq("nonce-owner")); expect(res.headers.get("location")).toBe( - "https://app.example/settings/integrations?whoop=error&reason=owner_conflict", + "https://app.example/settings/integrations?whoop=error&reason=already_linked", ); expect(res.headers.get("location")).not.toContain("42"); expect(connUpsert).not.toHaveBeenCalled(); @@ -188,7 +188,7 @@ describe("GET /api/whoop/callback custom-scheme redirect", () => { const res = await GET(makeReq("nonce-race")); expect(res.headers.get("location")).toBe( - "dev.healthlog.app://whoop?whoop=error&reason=owner_conflict", + "dev.healthlog.app://whoop?whoop=error&reason=already_linked", ); expect(res.headers.get("location")).not.toContain("42"); }); diff --git a/src/app/api/whoop/callback/route.ts b/src/app/api/whoop/callback/route.ts index 89bc61364..fdf5fb1cd 100644 --- a/src/app/api/whoop/callback/route.ts +++ b/src/app/api/whoop/callback/route.ts @@ -1,4 +1,5 @@ import { prisma } from "@/lib/db"; +import { ALREADY_LINKED_REASON } from "@/lib/integrations/oauth-kit/account-link"; import { apiHandler } from "@/lib/api-handler"; import { getSession } from "@/lib/auth/session"; import { verifyCallbackActor } from "@/lib/integrations/oauth-kit/connect-target"; @@ -213,8 +214,8 @@ export const GET = apiHandler(async (request: NextRequest) => { select: { userId: true }, }); if (owner && owner.userId !== userId) { - annotate({ meta: { reason: "owner_conflict" } }); - return outcomeRedirect(returnScheme, "error", "owner_conflict"); + annotate({ meta: { reason: ALREADY_LINKED_REASON } }); + return outcomeRedirect(returnScheme, "error", ALREADY_LINKED_REASON); } try { @@ -239,8 +240,8 @@ export const GET = apiHandler(async (request: NextRequest) => { }); } catch (err) { if (isP2002(err)) { - annotate({ meta: { reason: "owner_conflict" } }); - return outcomeRedirect(returnScheme, "error", "owner_conflict"); + annotate({ meta: { reason: ALREADY_LINKED_REASON } }); + return outcomeRedirect(returnScheme, "error", ALREADY_LINKED_REASON); } throw err; } diff --git a/src/app/api/whoop/disconnect/__tests__/route.test.ts b/src/app/api/whoop/disconnect/__tests__/route.test.ts index f385e835d..d3f6dcf4c 100644 --- a/src/app/api/whoop/disconnect/__tests__/route.test.ts +++ b/src/app/api/whoop/disconnect/__tests__/route.test.ts @@ -8,6 +8,7 @@ vi.mock("@/lib/api-handler", () => ({ vi.mock("@/lib/db", () => ({ prisma: { whoopConnection: { findUnique: vi.fn(), delete: vi.fn() }, + user: { findUnique: vi.fn(async () => null) }, }, })); diff --git a/src/app/api/withings/callback/__tests__/route.test.ts b/src/app/api/withings/callback/__tests__/route.test.ts index 89ff7a13e..98b9b96c1 100644 --- a/src/app/api/withings/callback/__tests__/route.test.ts +++ b/src/app/api/withings/callback/__tests__/route.test.ts @@ -67,6 +67,7 @@ vi.mock("@/lib/db", () => { }, withingsConnection: { upsert: vi.fn(), + findMany: vi.fn(async () => []), }, oAuthCallbackClaim: { createMany: vi.fn(async () => ({ count: 1 })) }, $transaction: async (run: (tx: unknown) => unknown) => run(prisma), @@ -207,6 +208,43 @@ describe("withings/callback atomic nonce consumption (M3)", () => { expect(annotateMock).toHaveBeenCalledWith({ meta: { reason: "replay" } }); }); + it("a Withings account another record already holds is refused with reason=already_linked, nothing stored", async () => { + vi.mocked(prisma.withingsOAuthState.delete).mockResolvedValueOnce({ + nonce: NONCE, + userId: "user-1", + expiresAt: new Date(Date.now() + 10 * 60 * 1000), + createdAt: new Date(), + } as never); + const { getUserWithingsCredentials } = + await import("@/lib/withings/credentials"); + vi.mocked(getUserWithingsCredentials).mockResolvedValue({ + clientId: "cid", + clientSecret: "csec", + } as never); + const { exchangeCode } = await import("@/lib/withings/client"); + vi.mocked(exchangeCode).mockResolvedValue({ + userid: "wuid", + access_token: "atok", + refresh_token: "rtok", + expires_in: 3600, + } as never); + vi.mocked(prisma.withingsConnection.findMany).mockResolvedValueOnce([ + { userId: "guardian-1" }, + ] as never); + + const res = await GET(callbackRequest(NONCE, NONCE)); + + expect(res.headers.get("location")).toBe( + `${APP_URL}/settings/integrations?withings=error&reason=already_linked`, + ); + expect(prisma.withingsConnection.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { withingsUserId: "wuid", userId: { not: "user-1" } }, + }), + ); + expect(prisma.withingsConnection.upsert).not.toHaveBeenCalled(); + }); + it("non-P2025 delete failure (e.g. connection drop) surfaces a Wide-Event warning carrying err.name only, and bounces to the error page", async () => { // A real infra failure on the consume-delete must not be // silently swallowed. diff --git a/src/app/api/withings/callback/route.ts b/src/app/api/withings/callback/route.ts index a151b2250..07522df24 100644 --- a/src/app/api/withings/callback/route.ts +++ b/src/app/api/withings/callback/route.ts @@ -1,4 +1,8 @@ import { prisma } from "@/lib/db"; +import { + ALREADY_LINKED_REASON, + accountLinkedElsewhere, +} from "@/lib/integrations/oauth-kit/account-link"; import { apiHandler, AUTH_ERROR_CODES, HttpError } from "@/lib/api-handler"; import { getSession } from "@/lib/auth/session"; import { verifyCallbackActor } from "@/lib/integrations/oauth-kit/connect-target"; @@ -207,6 +211,26 @@ export const GET = apiHandler(async (request: NextRequest) => { const tokens = await exchangeCode(code, creds); + // One Withings account, one record. A guardian signed in to their own + // Health Mate account must not attach it to the profile they manage: + // its webhooks would route to one of the two rows, and a disconnect on + // either would unsubscribe the other. + if ( + await accountLinkedElsewhere( + "withings", + String(tokens.userid), + recordUserId, + ) + ) { + annotate({ meta: { reason: ALREADY_LINKED_REASON } }); + return NextResponse.redirect( + new URL( + `/settings/integrations?withings=error&reason=${ALREADY_LINKED_REASON}`, + process.env.NEXT_PUBLIC_APP_URL!, + ), + ); + } + const expiresAt = new Date(Date.now() + tokens.expires_in * 1000); const subscriptionRetryAt = new Date(); diff --git a/src/app/api/withings/webhook/[token]/__tests__/route.test.ts b/src/app/api/withings/webhook/[token]/__tests__/route.test.ts index 7980c43e0..d29f7207f 100644 --- a/src/app/api/withings/webhook/[token]/__tests__/route.test.ts +++ b/src/app/api/withings/webhook/[token]/__tests__/route.test.ts @@ -9,7 +9,7 @@ vi.mock("@/lib/api-handler", () => ({ vi.mock("@/lib/db", () => ({ prisma: { - withingsConnection: { findFirst: vi.fn() }, + withingsConnection: { findMany: vi.fn() }, }, })); @@ -54,6 +54,7 @@ const ORIGINAL_SECRET = process.env.WITHINGS_WEBHOOK_SECRET; beforeEach(() => { vi.resetAllMocks(); + vi.mocked(prisma.withingsConnection.findMany).mockResolvedValue([]); process.env.WITHINGS_WEBHOOK_SECRET = "test-secret"; vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true, @@ -92,10 +93,12 @@ function paramsFor(token: string) { describe("POST /api/withings/webhook/[token] (path-segment secret)", () => { it("returns 200 + triggers sync when the path token matches WITHINGS_WEBHOOK_SECRET", async () => { - vi.mocked(prisma.withingsConnection.findFirst).mockResolvedValueOnce({ - userId: "user-42", - withingsUserId: "wu-1", - } as never); + vi.mocked(prisma.withingsConnection.findMany).mockResolvedValueOnce([ + { + userId: "user-42", + withingsUserId: "wu-1", + }, + ] as never); const res = await POST( jsonRequest("test-secret", { userid: "wu-1" }), @@ -105,9 +108,9 @@ describe("POST /api/withings/webhook/[token] (path-segment secret)", () => { const body = (await res.json()) as { status: string }; expect(body.status).toBe("ok"); - expect(prisma.withingsConnection.findFirst).toHaveBeenCalledWith({ - where: { withingsUserId: "wu-1" }, - }); + expect(prisma.withingsConnection.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { withingsUserId: "wu-1" } }), + ); expect(syncUserMeasurements).toHaveBeenCalledTimes(1); expect(syncUserMeasurements).toHaveBeenCalledWith("user-42"); }); @@ -119,7 +122,7 @@ describe("POST /api/withings/webhook/[token] (path-segment secret)", () => { ); expect(res.status).toBe(401); expect(syncUserMeasurements).not.toHaveBeenCalled(); - expect(prisma.withingsConnection.findFirst).not.toHaveBeenCalled(); + expect(prisma.withingsConnection.findMany).not.toHaveBeenCalled(); }); it("returns 401 when the path token is the empty string (missing)", async () => { diff --git a/src/app/api/withings/webhook/__tests__/route.test.ts b/src/app/api/withings/webhook/__tests__/route.test.ts index 7a66495ff..f64a7b267 100644 --- a/src/app/api/withings/webhook/__tests__/route.test.ts +++ b/src/app/api/withings/webhook/__tests__/route.test.ts @@ -9,7 +9,7 @@ vi.mock("@/lib/api-handler", () => ({ vi.mock("@/lib/db", () => ({ prisma: { - withingsConnection: { findFirst: vi.fn() }, + withingsConnection: { findMany: vi.fn() }, }, })); @@ -58,6 +58,7 @@ const ORIGINAL_SECRET = process.env.WITHINGS_WEBHOOK_SECRET; beforeEach(() => { vi.resetAllMocks(); + vi.mocked(prisma.withingsConnection.findMany).mockResolvedValue([]); process.env.WITHINGS_WEBHOOK_SECRET = "test-secret"; vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true, @@ -113,7 +114,7 @@ describe("POST /api/withings/webhook", () => { const res = await POST(jsonRequest({ userid: "wu-1" })); expect(res.status).toBe(401); expect(syncUserMeasurements).not.toHaveBeenCalled(); - expect(prisma.withingsConnection.findFirst).not.toHaveBeenCalled(); + expect(prisma.withingsConnection.findMany).not.toHaveBeenCalled(); }); it("returns 401 when the X-Withings-Webhook-Secret header is wrong", async () => { @@ -145,10 +146,12 @@ describe("POST /api/withings/webhook", () => { }); it("happy path: valid header secret + JSON body triggers sync for the resolved user", async () => { - vi.mocked(prisma.withingsConnection.findFirst).mockResolvedValueOnce({ - userId: "user-42", - withingsUserId: "wu-1", - } as never); + vi.mocked(prisma.withingsConnection.findMany).mockResolvedValueOnce([ + { + userId: "user-42", + withingsUserId: "wu-1", + }, + ] as never); const res = await POST( jsonRequest( @@ -160,23 +163,47 @@ describe("POST /api/withings/webhook", () => { const body = (await res.json()) as { status: string }; expect(body.status).toBe("ok"); - expect(prisma.withingsConnection.findFirst).toHaveBeenCalledWith({ - where: { withingsUserId: "wu-1" }, - }); + expect(prisma.withingsConnection.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { withingsUserId: "wu-1" } }), + ); expect(syncUserMeasurements).toHaveBeenCalledTimes(1); expect(syncUserMeasurements).toHaveBeenCalledWith("user-42"); }); + it("an account two records hold routes to the record that linked it first, ordered by the query", async () => { + vi.mocked(prisma.withingsConnection.findMany).mockResolvedValueOnce([ + { userId: "first-holder" }, + { userId: "second-holder" }, + ] as never); + + const res = await POST( + jsonRequest( + { userid: "wu-1" }, + { "x-withings-webhook-secret": "test-secret" }, + ), + ); + expect(res.status).toBe(200); + expect(prisma.withingsConnection.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + orderBy: [{ createdAt: "asc" }, { id: "asc" }], + }), + ); + expect(syncUserMeasurements).toHaveBeenCalledTimes(1); + expect(syncUserMeasurements).toHaveBeenCalledWith("first-holder"); + }); + it("legacy ?secret=… query path still authorises and emits a Wide Event warning with the migration URL (Fix-K sec-M2)", async () => { const addWarning = vi.fn(); vi.mocked(getEvent).mockReturnValue({ setAuth: vi.fn(), addWarning, } as never); - vi.mocked(prisma.withingsConnection.findFirst).mockResolvedValueOnce({ - userId: "user-99", - withingsUserId: "wu-2", - } as never); + vi.mocked(prisma.withingsConnection.findMany).mockResolvedValueOnce([ + { + userId: "user-99", + withingsUserId: "wu-2", + }, + ] as never); const res = await POST( jsonRequest({ userid: "wu-2" }, {}, "?secret=test-secret"), @@ -194,10 +221,12 @@ describe("POST /api/withings/webhook", () => { }); it("accepts form-encoded payloads (Withings legacy delivery format)", async () => { - vi.mocked(prisma.withingsConnection.findFirst).mockResolvedValueOnce({ - userId: "user-form", - withingsUserId: "wu-form", - } as never); + vi.mocked(prisma.withingsConnection.findMany).mockResolvedValueOnce([ + { + userId: "user-form", + withingsUserId: "wu-form", + }, + ] as never); const res = await POST( formRequest( @@ -216,12 +245,12 @@ describe("POST /api/withings/webhook", () => { expect(res.status).toBe(200); const body = (await res.json()) as { status: string }; expect(body.status).toBe("ignored"); - expect(prisma.withingsConnection.findFirst).not.toHaveBeenCalled(); + expect(prisma.withingsConnection.findMany).not.toHaveBeenCalled(); expect(syncUserMeasurements).not.toHaveBeenCalled(); }); it("returns 200 unknown_user when no WithingsConnection matches the wire userid", async () => { - vi.mocked(prisma.withingsConnection.findFirst).mockResolvedValueOnce(null); + vi.mocked(prisma.withingsConnection.findMany).mockResolvedValueOnce([]); const res = await POST( jsonRequest( { userid: "wu-ghost" }, @@ -248,16 +277,18 @@ describe("POST /api/withings/webhook", () => { ); expect(res.status).toBe(429); expect(syncUserMeasurements).not.toHaveBeenCalled(); - expect(prisma.withingsConnection.findFirst).not.toHaveBeenCalled(); + expect(prisma.withingsConnection.findMany).not.toHaveBeenCalled(); }); }); describe("POST /api/withings/webhook — appli dispatch (W17b/c)", () => { it("appli=16 enqueues onto withings-activity-sync (not the measure path)", async () => { - vi.mocked(prisma.withingsConnection.findFirst).mockResolvedValueOnce({ - userId: "user-act", - withingsUserId: "wu-act", - } as never); + vi.mocked(prisma.withingsConnection.findMany).mockResolvedValueOnce([ + { + userId: "user-act", + withingsUserId: "wu-act", + }, + ] as never); const send = vi.fn().mockResolvedValue(undefined); vi.mocked(getGlobalBoss).mockReturnValue({ send } as never); @@ -276,10 +307,12 @@ describe("POST /api/withings/webhook — appli dispatch (W17b/c)", () => { }); it("appli=44 enqueues onto withings-sleep-sync (not the measure path)", async () => { - vi.mocked(prisma.withingsConnection.findFirst).mockResolvedValueOnce({ - userId: "user-sleep", - withingsUserId: "wu-sleep", - } as never); + vi.mocked(prisma.withingsConnection.findMany).mockResolvedValueOnce([ + { + userId: "user-sleep", + withingsUserId: "wu-sleep", + }, + ] as never); const send = vi.fn().mockResolvedValue(undefined); vi.mocked(getGlobalBoss).mockReturnValue({ send } as never); @@ -297,10 +330,12 @@ describe("POST /api/withings/webhook — appli dispatch (W17b/c)", () => { }); it("appli=16 falls back to inline activity sync when pg-boss is unavailable", async () => { - vi.mocked(prisma.withingsConnection.findFirst).mockResolvedValueOnce({ - userId: "user-act-inline", - withingsUserId: "wu-act-inline", - } as never); + vi.mocked(prisma.withingsConnection.findMany).mockResolvedValueOnce([ + { + userId: "user-act-inline", + withingsUserId: "wu-act-inline", + }, + ] as never); vi.mocked(getGlobalBoss).mockReturnValue(null); const res = await POST( @@ -315,10 +350,12 @@ describe("POST /api/withings/webhook — appli dispatch (W17b/c)", () => { }); it("appli=44 falls back to inline sleep sync when pg-boss is unavailable", async () => { - vi.mocked(prisma.withingsConnection.findFirst).mockResolvedValueOnce({ - userId: "user-sleep-inline", - withingsUserId: "wu-sleep-inline", - } as never); + vi.mocked(prisma.withingsConnection.findMany).mockResolvedValueOnce([ + { + userId: "user-sleep-inline", + withingsUserId: "wu-sleep-inline", + }, + ] as never); vi.mocked(getGlobalBoss).mockReturnValue(null); const res = await POST( @@ -333,10 +370,12 @@ describe("POST /api/withings/webhook — appli dispatch (W17b/c)", () => { }); it("appli=4 (BP) keeps the legacy measure path", async () => { - vi.mocked(prisma.withingsConnection.findFirst).mockResolvedValueOnce({ - userId: "user-bp", - withingsUserId: "wu-bp", - } as never); + vi.mocked(prisma.withingsConnection.findMany).mockResolvedValueOnce([ + { + userId: "user-bp", + withingsUserId: "wu-bp", + }, + ] as never); const res = await POST( formRequest( @@ -351,10 +390,12 @@ describe("POST /api/withings/webhook — appli dispatch (W17b/c)", () => { }); it("a missing appli falls back to the measure path (legacy Withings subscriptions)", async () => { - vi.mocked(prisma.withingsConnection.findFirst).mockResolvedValueOnce({ - userId: "user-legacy", - withingsUserId: "wu-legacy", - } as never); + vi.mocked(prisma.withingsConnection.findMany).mockResolvedValueOnce([ + { + userId: "user-legacy", + withingsUserId: "wu-legacy", + }, + ] as never); const res = await POST( formRequest( @@ -366,10 +407,12 @@ describe("POST /api/withings/webhook — appli dispatch (W17b/c)", () => { expect(syncUserMeasurements).toHaveBeenCalledWith("user-legacy"); }); it("returns retryable non-2xx when the ECG event cannot be durably enqueued", async () => { - vi.mocked(prisma.withingsConnection.findFirst).mockResolvedValue({ - userId: "user-ecg", - withingsUserId: "wu-ecg", - } as never); + vi.mocked(prisma.withingsConnection.findMany).mockResolvedValue([ + { + userId: "user-ecg", + withingsUserId: "wu-ecg", + }, + ] as never); const send = vi.fn().mockRejectedValue(new Error("queue unavailable")); vi.mocked(getGlobalBoss).mockReturnValue({ send } as never); @@ -390,10 +433,12 @@ describe("POST /api/withings/webhook — appli dispatch (W17b/c)", () => { }); it("uses one logical ECG job identity for a provider replay", async () => { - vi.mocked(prisma.withingsConnection.findFirst).mockResolvedValue({ - userId: "user-ecg", - withingsUserId: "wu-ecg", - } as never); + vi.mocked(prisma.withingsConnection.findMany).mockResolvedValue([ + { + userId: "user-ecg", + withingsUserId: "wu-ecg", + }, + ] as never); const send = vi .fn() .mockResolvedValueOnce("job-1") diff --git a/src/components/settings/__tests__/oauth-return-outcome.test.ts b/src/components/settings/__tests__/oauth-return-outcome.test.ts index caa988bdb..3e4b729a9 100644 --- a/src/components/settings/__tests__/oauth-return-outcome.test.ts +++ b/src/components/settings/__tests__/oauth-return-outcome.test.ts @@ -80,6 +80,27 @@ describe("oauthReasonKey", () => { ); }); + it("names an account already held by another record for every provider", async () => { + const en = (await import("../../../../messages/en.json")) + .default as unknown as { + settings: Record>; + }; + for (const provider of [ + "polar", + "oura", + "whoop", + "fitbit", + "googleHealth", + "strava", + ] as const) { + const key = oauthReasonKey(provider, "already_linked"); + expect(key.endsWith(".already_linked")).toBe(true); + const [, base] = key.split("."); + expect(en.settings[base!]!.already_linked).toBeTruthy(); + } + expect(en.settings.withingsOauthError!.already_linked).toBeTruthy(); + }); + it("falls back to generic for an unknown tag", () => { expect(oauthReasonKey("whoop", "totally_unknown")).toBe( "settings.whoopOauthError.generic", diff --git a/src/components/settings/integrations/connections-panel.tsx b/src/components/settings/integrations/connections-panel.tsx index b79f1e442..f44a30aaf 100644 --- a/src/components/settings/integrations/connections-panel.tsx +++ b/src/components/settings/integrations/connections-panel.tsx @@ -57,6 +57,7 @@ const WITHINGS_OAUTH_ERROR_KEYS: Record = { nocode: "settings.withingsOauthError.nocode", nocreds: "settings.withingsOauthError.nocreds", token: "settings.withingsOauthError.token", + already_linked: "settings.withingsOauthError.already_linked", }; type WithingsOauthOutcome = @@ -126,6 +127,8 @@ const OAUTH_OUTCOME_REASONS = new Set([ "token", "rate_limited", "expired", + // The provider account is already connected to another record. + "already_linked", ]); /** diff --git a/src/lib/data-wipe/wipe-plan.ts b/src/lib/data-wipe/wipe-plan.ts index 54d59b8d9..5395b35b5 100644 --- a/src/lib/data-wipe/wipe-plan.ts +++ b/src/lib/data-wipe/wipe-plan.ts @@ -465,6 +465,8 @@ export const USER_RESET = { ouraScopeDenied: Prisma.DbNull, ouraBackfillProgress: Prisma.DbNull, ouraBackfillCompletedAt: null, + ouraUserId: null, + clientAppSharedBy: Prisma.DbNull, stravaClientIdEncrypted: null, stravaClientSecretEncrypted: null, stravaAccessTokenEncrypted: null, diff --git a/src/lib/export/backup-plan.ts b/src/lib/export/backup-plan.ts index 527b43b74..c4a1137fd 100644 --- a/src/lib/export/backup-plan.ts +++ b/src/lib/export/backup-plan.ts @@ -940,6 +940,10 @@ export const USER_COLUMN_BACKUP_CLASS = { // What the Oura grant on this host does not cover; meaningless beside a // grant that does not travel, and found out again by the first sync. ouraScopeDenied: "CREDENTIAL", + ouraUserId: "CREDENTIAL", + // Names the guardian whose provider app the record borrows; the app itself + // does not travel, so neither does the marker. + clientAppSharedBy: "CREDENTIAL", stravaClientIdEncrypted: "CREDENTIAL", stravaClientSecretEncrypted: "CREDENTIAL", stravaAccessTokenEncrypted: "CREDENTIAL", diff --git a/src/lib/integrations/oauth-kit/__tests__/connect-target.test.ts b/src/lib/integrations/oauth-kit/__tests__/connect-target.test.ts index 19ec6a345..e83fc2082 100644 --- a/src/lib/integrations/oauth-kit/__tests__/connect-target.test.ts +++ b/src/lib/integrations/oauth-kit/__tests__/connect-target.test.ts @@ -19,7 +19,13 @@ vi.mock("@/lib/sharing/grants", () => ({ (need !== "manage" || grant.access === "MANAGE"), })); -import { shareGuardianClientApp, verifyCallbackActor } from "../connect-target"; +import { + releaseSharedClientApp, + releaseUnbackedClientApps, + shareGuardianClientApp, + verifyCallbackActor, +} from "../connect-target"; +import { Prisma } from "@/generated/prisma/client"; const MANAGED = { managedProfileAt: new Date("2026-01-01T00:00:00Z") }; const ADULT = { managedProfileAt: null }; @@ -127,6 +133,7 @@ describe("shareGuardianClientApp", () => { data: { ouraClientIdEncrypted: "sealed-id", ouraClientSecretEncrypted: "sealed-secret", + clientAppSharedBy: { oura: "guardian" }, }, }); expect(auditLog).toHaveBeenCalledWith("integration.client_app.shared", { @@ -146,3 +153,71 @@ describe("shareGuardianClientApp", () => { expect(auditLog).not.toHaveBeenCalled(); }); }); + +describe("releasing a borrowed provider app", () => { + it("removes the borrowed app on disconnect and audits it", async () => { + findUnique.mockResolvedValue({ + clientAppSharedBy: { oura: "guardian", strava: "guardian" }, + }); + await expect( + releaseSharedClientApp("oura", "rec", "disconnected"), + ).resolves.toBe(true); + expect(update).toHaveBeenCalledWith({ + where: { id: "rec" }, + data: { + ouraClientIdEncrypted: null, + ouraClientSecretEncrypted: null, + clientAppSharedBy: { strava: "guardian" }, + }, + }); + expect(auditLog).toHaveBeenCalledWith( + "integration.client_app.removed", + expect.objectContaining({ + userId: "rec", + details: { + provider: "oura", + reason: "disconnected", + sharedBy: "guardian", + }, + }), + ); + }); + + it("leaves an app the record holds of its own", async () => { + findUnique.mockResolvedValue({ clientAppSharedBy: null }); + await expect( + releaseSharedClientApp("oura", "rec", "disconnected"), + ).resolves.toBe(false); + expect(update).not.toHaveBeenCalled(); + }); + + it("clears the marker when the last borrowed app goes", async () => { + findUnique.mockResolvedValue({ clientAppSharedBy: { polar: "g" } }); + await releaseSharedClientApp("polar", "rec", "guardian_left"); + expect(update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ clientAppSharedBy: Prisma.DbNull }), + }), + ); + }); + + it("removes only the apps of guardians without a live MANAGE grant", async () => { + findUnique.mockResolvedValue({ + clientAppSharedBy: { oura: "left", strava: "stays" }, + }); + findActiveGrant.mockImplementation( + async ({ granteeId }: { granteeId: string }) => + granteeId === "stays" ? { access: "MANAGE", revokedAt: null } : null, + ); + await expect(releaseUnbackedClientApps("rec")).resolves.toBe(1); + expect(update).toHaveBeenCalledTimes(1); + expect(update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + ouraClientIdEncrypted: null, + ouraClientSecretEncrypted: null, + }), + }), + ); + }); +}); diff --git a/src/lib/integrations/oauth-kit/__tests__/disconnect-shared-account.test.ts b/src/lib/integrations/oauth-kit/__tests__/disconnect-shared-account.test.ts new file mode 100644 index 000000000..fc3cf7c5a --- /dev/null +++ b/src/lib/integrations/oauth-kit/__tests__/disconnect-shared-account.test.ts @@ -0,0 +1,88 @@ +/** + * A disconnect leaves provider-side state alone while another record still + * holds the same provider account: Withings webhook subscriptions and the + * Strava app authorisation belong to the account, not to the record. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { prismaMock, unsubscribeMock, deauthorizeMock } = vi.hoisted(() => ({ + prismaMock: { + withingsConnection: { + findUnique: vi.fn(), + findMany: vi.fn(), + delete: vi.fn(), + }, + user: { findUnique: vi.fn(), findMany: vi.fn(), update: vi.fn() }, + }, + unsubscribeMock: vi.fn(async () => {}), + deauthorizeMock: vi.fn(async () => {}), +})); + +vi.mock("@/lib/db", () => ({ prisma: prismaMock })); +vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() })); +vi.mock("@/lib/crypto", () => ({ decrypt: (s: string) => s })); +vi.mock("@/lib/logging/context", () => ({ + getEvent: () => ({ addWarning: vi.fn() }), +})); +vi.mock("@/lib/integrations/status", () => ({ markDisconnected: vi.fn() })); +vi.mock("@/lib/withings/client", () => ({ + unsubscribeWebhook: unsubscribeMock, +})); +vi.mock("@/lib/withings/sync", () => ({ + WITHINGS_NOTIFY_APPLIS: [1, 44], + getWithingsWebhookCallbackUrl: () => "https://app.example/hook", +})); +vi.mock("@/lib/strava/client", () => ({ deauthorize: deauthorizeMock })); +vi.mock("@/lib/strava/credentials", () => ({ + STRAVA_DISCONNECTED: { stravaAccessTokenEncrypted: null }, + getStravaConnection: vi.fn(async () => ({ accessToken: "at" })), +})); + +import { disconnectOAuth } from "../disconnect"; + +beforeEach(() => { + vi.clearAllMocks(); + prismaMock.withingsConnection.findUnique.mockResolvedValue({ + userId: "profile-1", + withingsUserId: "wu-1", + accessToken: "token", + }); + prismaMock.user.findUnique.mockResolvedValue({ stravaAthleteId: "42" }); +}); + +describe("disconnectOAuth with a shared provider account", () => { + it("keeps the Withings subscriptions while another record holds the account", async () => { + prismaMock.withingsConnection.findMany.mockResolvedValue([ + { userId: "guardian-1" }, + ]); + await expect(disconnectOAuth("withings", "profile-1")).resolves.toBe( + "disconnected", + ); + expect(prismaMock.withingsConnection.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { withingsUserId: "wu-1", userId: { not: "profile-1" } }, + }), + ); + expect(unsubscribeMock).not.toHaveBeenCalled(); + expect(prismaMock.withingsConnection.delete).toHaveBeenCalled(); + }); + + it("unsubscribes every category when no other record holds the account", async () => { + prismaMock.withingsConnection.findMany.mockResolvedValue([]); + await disconnectOAuth("withings", "profile-1"); + expect(unsubscribeMock).toHaveBeenCalledTimes(2); + }); + + it("keeps the Strava authorisation while another record holds the athlete", async () => { + prismaMock.user.findMany.mockResolvedValue([{ id: "guardian-1" }]); + await disconnectOAuth("strava", "profile-1"); + expect(deauthorizeMock).not.toHaveBeenCalled(); + expect(prismaMock.user.update).toHaveBeenCalled(); + }); + + it("deauthorises Strava when the athlete is held nowhere else", async () => { + prismaMock.user.findMany.mockResolvedValue([]); + await disconnectOAuth("strava", "profile-1"); + expect(deauthorizeMock).toHaveBeenCalledWith("at"); + }); +}); diff --git a/src/lib/integrations/oauth-kit/account-link.ts b/src/lib/integrations/oauth-kit/account-link.ts new file mode 100644 index 000000000..800d45221 --- /dev/null +++ b/src/lib/integrations/oauth-kit/account-link.ts @@ -0,0 +1,114 @@ +/** + * One provider account, one HealthLog record. + * + * A guardian finishing a connect for a managed profile is signed in to the + * provider in the same browser, so the consent screen can hand back the + * guardian's own provider account. Stored on the profile, that account's data + * would land on both records, its webhooks would route to whichever row a + * lookup found first, and a disconnect on one record would unsubscribe the + * other's notifications. Every callback therefore refuses a provider account + * that another record already holds (`already_linked`), and a disconnect only + * touches provider-side state (webhook subscriptions, app authorisation) when + * no other record holds the same account. + * + * Where the provider's account id is not stored there is nothing to compare, + * and the check passes: an unknown id is not evidence of a second holder. + */ +import { prisma } from "@/lib/db"; +import { decrypt } from "@/lib/crypto"; + +export type LinkedAccountProvider = + | "withings" + | "whoop" + | "fitbit" + | "google-health" + | "strava" + | "polar" + | "oura"; + +/** The callback reason a refused second holder redirects with. */ +export const ALREADY_LINKED_REASON = "already_linked"; + +/** + * The records other than `userId` that hold the provider account `accountId`. + */ +export async function otherHoldersOfAccount( + provider: LinkedAccountProvider, + accountId: string, + userId: string, +): Promise { + const notMe = { not: userId }; + switch (provider) { + case "withings": + return ( + await prisma.withingsConnection.findMany({ + where: { withingsUserId: accountId, userId: notMe }, + select: { userId: true }, + }) + ).map((r) => r.userId); + case "whoop": + return ( + await prisma.whoopConnection.findMany({ + where: { whoopUserId: accountId, userId: notMe }, + select: { userId: true }, + }) + ).map((r) => r.userId); + case "fitbit": + return ( + await prisma.fitbitConnection.findMany({ + where: { fitbitUserId: accountId, userId: notMe }, + select: { userId: true }, + }) + ).map((r) => r.userId); + case "google-health": + return ( + await prisma.googleHealthConnection.findMany({ + where: { googleUserId: accountId, userId: notMe }, + select: { userId: true }, + }) + ).map((r) => r.userId); + case "strava": + return ( + await prisma.user.findMany({ + where: { stravaAthleteId: accountId, id: notMe }, + select: { id: true }, + }) + ).map((r) => r.id); + case "oura": + return ( + await prisma.user.findMany({ + where: { ouraUserId: accountId, id: notMe }, + select: { id: true }, + }) + ).map((r) => r.id); + case "polar": { + // The member id is sealed with a random nonce, so it cannot be matched + // in SQL; the connected rows are few, and each is opened here. + const rows = await prisma.user.findMany({ + where: { polarUserIdEncrypted: { not: null }, id: notMe }, + select: { id: true, polarUserIdEncrypted: true }, + }); + const holders: string[] = []; + for (const row of rows) { + try { + if (decrypt(row.polarUserIdEncrypted!) === accountId) { + holders.push(row.id); + } + } catch { + // An unreadable row proves nothing either way. + } + } + return holders; + } + } +} + +/** Does a record other than `userId` hold the provider account `accountId`? */ +export async function accountLinkedElsewhere( + provider: LinkedAccountProvider, + accountId: string | null | undefined, + userId: string, +): Promise { + if (!accountId) return false; + return (await otherHoldersOfAccount(provider, accountId, userId)).length > 0; +} diff --git a/src/lib/integrations/oauth-kit/connect-target.ts b/src/lib/integrations/oauth-kit/connect-target.ts index 98dafb33b..2289d320e 100644 --- a/src/lib/integrations/oauth-kit/connect-target.ts +++ b/src/lib/integrations/oauth-kit/connect-target.ts @@ -17,6 +17,7 @@ * can pass on a consent screen and a grant can be revoked in them. */ import { prisma } from "@/lib/db"; +import { Prisma } from "@/generated/prisma/client"; import { auditLog } from "@/lib/auth/audit"; import { findActiveGrant, grantAllows } from "@/lib/sharing/grants"; import type { OAuthKitProvider } from "./token"; @@ -107,9 +108,20 @@ export async function shareGuardianClientApp( const clientId = guardian?.[idColumn] ?? null; const clientSecret = guardian?.[secretColumn] ?? null; if (!clientId || !clientSecret) return; + const record = await prisma.user.findUnique({ + where: { id: target.recordUserId }, + select: { clientAppSharedBy: true }, + }); await prisma.user.update({ where: { id: target.recordUserId }, - data: { [idColumn]: clientId, [secretColumn]: clientSecret }, + data: { + [idColumn]: clientId, + [secretColumn]: clientSecret, + clientAppSharedBy: { + ...readClientAppSharedBy(record?.clientAppSharedBy), + [provider]: target.actorUserId, + }, + }, }); await auditLog("integration.client_app.shared", { userId: target.recordUserId, @@ -117,3 +129,102 @@ export async function shareGuardianClientApp( details: { provider }, }); } + +/** Which provider apps on a record were borrowed, and from which guardian. */ +export type ClientAppSharedBy = Partial>; + +/** Read the stored marker, dropping anything that is not a provider → id. */ +export function readClientAppSharedBy(value: unknown): ClientAppSharedBy { + if (!value || typeof value !== "object" || Array.isArray(value)) return {}; + const out: ClientAppSharedBy = {}; + for (const provider of Object.keys(CLIENT_COLUMNS) as OAuthKitProvider[]) { + const guardianId = (value as Record)[provider]; + if (typeof guardianId === "string" && guardianId.length > 0) { + out[provider] = guardianId; + } + } + return out; +} + +type ReleaseDb = Pick< + Prisma.TransactionClient, + "user" | "accountGrant" | "auditLog" +>; + +/** Why a borrowed app left the record, as the audit row names it. */ +export type ClientAppReleaseReason = "disconnected" | "guardian_left"; + +/** + * Remove a provider app the record borrowed from a guardian, if it did. + * + * The app's client id and secret are the guardian's; they stay on the record + * only while they serve a connection the guardian may still act on. Does + * nothing when the record's app for `provider` is its own (no marker). + */ +export async function releaseSharedClientApp( + provider: OAuthKitProvider, + recordUserId: string, + reason: ClientAppReleaseReason, + db: ReleaseDb = prisma, +): Promise { + const row = await db.user.findUnique({ + where: { id: recordUserId }, + select: { clientAppSharedBy: true }, + }); + const sharedBy = readClientAppSharedBy(row?.clientAppSharedBy); + const guardianUserId = sharedBy[provider]; + if (!guardianUserId) return false; + delete sharedBy[provider]; + const [idColumn, secretColumn] = CLIENT_COLUMNS[provider]; + await db.user.update({ + where: { id: recordUserId }, + data: { + [idColumn]: null, + [secretColumn]: null, + clientAppSharedBy: + Object.keys(sharedBy).length > 0 ? sharedBy : Prisma.DbNull, + }, + }); + await auditLog("integration.client_app.removed", { + userId: recordUserId, + details: { provider, reason, sharedBy: guardianUserId }, + client: db, + }); + return true; +} + +/** + * Remove every app the record borrowed from a guardian who no longer holds a + * live MANAGE grant on it. Run after any change that can end a guardian's + * access (revoke, renounce, removal, handover, the guardian's account + * deletion or data wipe). A connection still using the removed app keeps its + * imported data and asks for a reconnect at its next refresh, which a + * remaining guardian finishes with their own app. + */ +export async function releaseUnbackedClientApps( + recordUserId: string, + db: ReleaseDb = prisma, +): Promise { + const row = await db.user.findUnique({ + where: { id: recordUserId }, + select: { clientAppSharedBy: true }, + }); + const sharedBy = readClientAppSharedBy(row?.clientAppSharedBy); + let released = 0; + for (const [provider, guardianUserId] of Object.entries(sharedBy) as [ + OAuthKitProvider, + string, + ][]) { + const grant = await findActiveGrant( + { grantorId: recordUserId, granteeId: guardianUserId }, + db, + ); + if (grant && grantAllows(grant, "manage")) continue; + if ( + await releaseSharedClientApp(provider, recordUserId, "guardian_left", db) + ) { + released += 1; + } + } + return released; +} diff --git a/src/lib/integrations/oauth-kit/disconnect.ts b/src/lib/integrations/oauth-kit/disconnect.ts index b4393f9eb..03f8b8e90 100644 --- a/src/lib/integrations/oauth-kit/disconnect.ts +++ b/src/lib/integrations/oauth-kit/disconnect.ts @@ -26,6 +26,8 @@ import { getStravaConnection, } from "@/lib/strava/credentials"; import type { OAuthKitProvider } from "./token"; +import { otherHoldersOfAccount } from "./account-link"; +import { releaseSharedClientApp } from "./connect-target"; export type DisconnectOutcome = "disconnected" | "not_connected"; @@ -51,19 +53,33 @@ async function dropGrant( if (!connection) return false; // Withings keeps calling a subscribed URL after the grant is gone, so // the subscriptions go first. Best effort: a refused unsubscribe must - // not keep the person connected. - try { - const accessToken = decrypt(connection.accessToken); - const callbackUrl = getWithingsWebhookCallbackUrl(); - for (const appli of WITHINGS_NOTIFY_APPLIS) { - try { - await unsubscribeWebhook(accessToken, callbackUrl, appli); - } catch { - // Already gone, or the token expired; the delete below stands. + // not keep the person connected. A subscription belongs to the Withings + // account, not to this record: while another record still holds the + // same account (a pair linked before callbacks refused a second + // holder), it is left in place, or that record would stop hearing. + const sharedWith = await otherHoldersOfAccount( + "withings", + connection.withingsUserId, + userId, + ); + if (sharedWith.length > 0) { + getEvent()?.addWarning( + "withings: webhook subscriptions kept, account held by another record", + ); + } else { + try { + const accessToken = decrypt(connection.accessToken); + const callbackUrl = getWithingsWebhookCallbackUrl(); + for (const appli of WITHINGS_NOTIFY_APPLIS) { + try { + await unsubscribeWebhook(accessToken, callbackUrl, appli); + } catch { + // Already gone, or the token expired; the delete below stands. + } } + } catch { + // An undecryptable token cannot unsubscribe anything. } - } catch { - // An undecryptable token cannot unsubscribe anything. } await prisma.withingsConnection.delete({ where: { userId } }); return true; @@ -96,6 +112,7 @@ async function dropGrant( ouraAccessTokenEncrypted: null, ouraRefreshTokenEncrypted: null, ouraScopeDenied: Prisma.DbNull, + ouraUserId: null, }, }); return true; @@ -115,13 +132,24 @@ async function dropGrant( case "strava": { const connection = await getStravaConnection(userId); if (!connection) return false; - // Strava keeps the app authorised until it is told otherwise. - try { - await deauthorize(connection.accessToken); - } catch (err) { - getEvent()?.addWarning( - `strava: deauthorize failed on disconnect: ${err}`, - ); + // Strava keeps the app authorised until it is told otherwise. The + // authorisation belongs to the athlete; while another record still + // holds the same athlete it stays, or that record would lose its grant. + const athlete = await prisma.user.findUnique({ + where: { id: userId }, + select: { stravaAthleteId: true }, + }); + const sharedWith = athlete?.stravaAthleteId + ? await otherHoldersOfAccount("strava", athlete.stravaAthleteId, userId) + : []; + if (sharedWith.length === 0) { + try { + await deauthorize(connection.accessToken); + } catch (err) { + getEvent()?.addWarning( + `strava: deauthorize failed on disconnect: ${err}`, + ); + } } await prisma.user.update({ where: { id: userId }, @@ -139,6 +167,9 @@ export async function disconnectOAuth( ): Promise { if (!(await dropGrant(provider, userId))) return "not_connected"; await auditLog(AUDIT_ACTION[provider], { userId }); + // A provider app the record borrowed from a guardian served this + // connection only; it leaves with it. + await releaseSharedClientApp(provider, userId, "disconnected"); await markDisconnected(userId, provider); return "disconnected"; } diff --git a/src/lib/managed-profiles/lifecycle.ts b/src/lib/managed-profiles/lifecycle.ts index 1b581906d..899d673f7 100644 --- a/src/lib/managed-profiles/lifecycle.ts +++ b/src/lib/managed-profiles/lifecycle.ts @@ -4,6 +4,7 @@ import { } from "@/lib/jobs/offhost-purge"; import { prisma } from "@/lib/db"; import { auditLog } from "@/lib/auth/audit"; +import { releaseUnbackedClientApps } from "@/lib/integrations/oauth-kit/connect-target"; import type { Prisma } from "@/generated/prisma/client"; import { dayKeyAsUtcMidnight, dateOnlyKey } from "@/lib/tz/date-only"; import { @@ -148,7 +149,13 @@ export async function deleteGuardianAccountWithLifecycle( }); } - return deleteAccount(tx); + const result = await deleteAccount(tx); + // The guardian's grants went with their account; a provider app any of + // their profiles borrowed from them goes too. + for (const { grantorId } of grants) { + await releaseUnbackedClientApps(grantorId, tx); + } + return result; }, ACCOUNT_DELETION_TRANSACTION_OPTIONS); } @@ -167,7 +174,7 @@ export async function deleteGuardianAccountWithLifecycle( export async function protectManagedProfilesDuringDataWipe( tx: Transaction, userId: string, -): Promise { +): Promise { const affectedProfiles = await tx.accountGrant.findMany({ where: { OR: [{ grantorId: userId }, { granteeId: userId }], @@ -212,6 +219,9 @@ export async function protectManagedProfilesDuringDataWipe( } }); } + // The profiles whose grants the wipe touches, for the caller to settle + // what those grants carried once they are gone. + return affectedProfiles.map((p) => p.grantorId); } export async function deleteManagedProfile(input: { diff --git a/src/lib/oura/client.ts b/src/lib/oura/client.ts index 99e7140c2..2e3ac2569 100644 --- a/src/lib/oura/client.ts +++ b/src/lib/oura/client.ts @@ -522,6 +522,36 @@ function collectionRefusal( }); } +/** + * The Oura account id of a grant (`personal_info.id`), or null when the grant + * does not cover the `personal` scope or Oura does not answer. Read once at + * connect so one Oura account cannot be attached to two records; a null id + * skips that check rather than failing the connect. + */ +export async function fetchOuraAccountId( + accessToken: string, +): Promise { + try { + const res = await safeFetch( + `${OURA_API_BASE}/v2/usercollection/personal_info`, + { + method: "GET", + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: "application/json", + }, + }, + ); + if (!res.ok) return null; + const json = (await res.json().catch(() => null)) as { + id?: unknown; + } | null; + return typeof json?.id === "string" && json.id.length > 0 ? json.id : null; + } catch { + return null; + } +} + /** Read one page of a collection; throws the classified refusal. */ async function readCollectionPage( path: string, diff --git a/src/lib/sharing/grants.ts b/src/lib/sharing/grants.ts index 183e38a55..dcb8daf68 100644 --- a/src/lib/sharing/grants.ts +++ b/src/lib/sharing/grants.ts @@ -55,6 +55,7 @@ import { prisma } from "@/lib/db"; import { auditLog } from "@/lib/auth/audit"; import { isP2002 } from "@/lib/prisma-errors"; import { clearActingSessions } from "@/lib/sharing/acting-session"; +import { releaseUnbackedClientApps } from "@/lib/integrations/oauth-kit/connect-target"; import { activeGuardianWhere, LastManagedGuardianError, @@ -617,6 +618,8 @@ export async function revokeManagedProfileGuardian(input: { { grantorId: profile.id, granteeId: target.granteeId }, tx, ); + // A provider app the profile borrowed from this guardian goes with them. + await releaseUnbackedClientApps(profile.id, tx); // v1.42 (#959) — a handover link the removed Guardian minted dies with // their access. The claim refuses it anyway (its creator must still be // an active Guardian); withdrawing it here keeps the other Guardians' @@ -771,6 +774,8 @@ export async function settleHandoverGuardianGrant( } const sessionsCleared = await clearActingSessions(pair, tx); + // A Guardian who keeps no MANAGE takes back the provider app they lent. + await releaseUnbackedClientApps(input.recordId, tx); return { access: input.target, grantId, changed: true, sessionsCleared }; } @@ -939,6 +944,8 @@ async function endAndClear( { grantorId: grant.grantorId, granteeId: grant.granteeId }, tx, ); + // An ended MANAGE grant takes back the provider app it lent the record. + await releaseUnbackedClientApps(grant.grantorId, tx); return { grant, sessionsCleared }; }); } diff --git a/src/lib/withings/webhook-handler.ts b/src/lib/withings/webhook-handler.ts index 156814bf5..0405653bf 100644 --- a/src/lib/withings/webhook-handler.ts +++ b/src/lib/withings/webhook-handler.ts @@ -138,9 +138,22 @@ export async function processWithingsNotification( }, }); - const connection = await prisma.withingsConnection.findFirst({ + // A Withings account belongs to one record; callbacks refuse a second + // holder. A pair linked before that rule routes to the record that linked + // the account first, never to whichever row a lookup happens to find. + const holders = await prisma.withingsConnection.findMany({ where: { withingsUserId }, + orderBy: [{ createdAt: "asc" }, { id: "asc" }], + select: { userId: true }, + take: 2, }); + const connection = holders[0] ?? null; + if (holders.length > 1) { + annotate({ meta: { withings_account_shared: true } }); + getEvent()?.addWarning( + "Withings account held by more than one record; routed to the first", + ); + } if (!connection) { getEvent()?.addWarning( diff --git a/tests/integration/managed-profile-oauth-connect.test.ts b/tests/integration/managed-profile-oauth-connect.test.ts index beca90d08..be879252e 100644 --- a/tests/integration/managed-profile-oauth-connect.test.ts +++ b/tests/integration/managed-profile-oauth-connect.test.ts @@ -286,3 +286,163 @@ describe("verifyCallbackActor against real grants", () => { }); }); }); + +describe("one provider account, one record", () => { + it("refuses to attach the guardian's own Oura account to the profile", async () => { + const prisma = getPrismaClient(); + await prisma.user.update({ + where: { id: GUARDIAN }, + data: { ouraUserId: "oura-account-guardian" }, + }); + vi.mocked(globalThis.fetch).mockImplementation(async (input) => { + const url = urlOf(input); + if (url.endsWith("/v2/usercollection/personal_info")) { + return new Response(JSON.stringify({ id: "oura-account-guardian" }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + if (url !== OURA_TOKEN_URL) throw new Error(`Unexpected fetch: ${url}`); + return new Response( + JSON.stringify({ + access_token: "guardian-access", + refresh_token: "guardian-refresh", + expires_in: 86400, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + }); + + await grant(PROFILE); + const session = await signIn(GUARDIAN); + await switchSessionTo(session.id, PROFILE); + const started = await startConnect(); + const { data } = (await started.json()) as { + data: { authorizationUrl: string }; + }; + + const res = await ouraCallback(stateOf(data.authorizationUrl)); + expect(res.headers.get("location")).toContain("reason=already_linked"); + const profile = await prisma.user.findUniqueOrThrow({ + where: { id: PROFILE }, + select: { ouraAccessTokenEncrypted: true, ouraUserId: true }, + }); + expect(profile.ouraAccessTokenEncrypted).toBeNull(); + expect(profile.ouraUserId).toBeNull(); + }); + + it("finds the other holder of a Withings account and routes its webhook to the first", async () => { + const prisma = getPrismaClient(); + const { encrypt } = await import("@/lib/crypto"); + const base = { + withingsUserId: "withings-shared", + accessToken: encrypt("a"), + refreshToken: encrypt("r"), + tokenExpiresAt: new Date(Date.now() + 3_600_000), + }; + await prisma.withingsConnection.create({ + data: { + ...base, + userId: GUARDIAN, + createdAt: new Date(Date.now() - 60_000), + }, + }); + await prisma.withingsConnection.create({ + data: { ...base, userId: PROFILE }, + }); + + const { otherHoldersOfAccount } = + await import("@/lib/integrations/oauth-kit/account-link"); + await expect( + otherHoldersOfAccount("withings", "withings-shared", PROFILE), + ).resolves.toEqual([GUARDIAN]); + await expect( + otherHoldersOfAccount("withings", "withings-shared", ADULT), + ).resolves.toHaveLength(2); + await expect( + otherHoldersOfAccount("withings", "withings-elsewhere", PROFILE), + ).resolves.toEqual([]); + }); +}); + +describe("a borrowed provider app leaves with its reason", () => { + async function connectForProfile() { + await grant(PROFILE); + const session = await signIn(GUARDIAN); + await switchSessionTo(session.id, PROFILE); + const started = await startConnect(); + const { data } = (await started.json()) as { + data: { authorizationUrl: string }; + }; + const res = await ouraCallback(stateOf(data.authorizationUrl)); + expect(res.headers.get("location")).toContain("oura=connected"); + } + + async function profileApp() { + return getPrismaClient().user.findUniqueOrThrow({ + where: { id: PROFILE }, + select: { + ouraClientIdEncrypted: true, + ouraClientSecretEncrypted: true, + clientAppSharedBy: true, + }, + }); + } + + it("removes the guardian's app when the profile's Oura is disconnected", async () => { + await connectForProfile(); + expect((await profileApp()).clientAppSharedBy).toEqual({ oura: GUARDIAN }); + + const { disconnectOAuth } = + await import("@/lib/integrations/oauth-kit/disconnect"); + await expect(disconnectOAuth("oura", PROFILE)).resolves.toBe( + "disconnected", + ); + expect(await profileApp()).toEqual({ + ouraClientIdEncrypted: null, + ouraClientSecretEncrypted: null, + clientAppSharedBy: null, + }); + const removed = await getPrismaClient().auditLog.findFirst({ + where: { userId: PROFILE, action: "integration.client_app.removed" }, + }); + expect(removed).not.toBeNull(); + }); + + it("removes the guardian's app when their MANAGE grant ends", async () => { + const prisma = getPrismaClient(); + await prisma.user.create({ + data: { + id: "second-guardian", + username: "second-guardian", + email: "second-guardian@example.test", + role: "USER", + }, + }); + await prisma.accountGrant.create({ + data: { + grantorId: PROFILE, + granteeId: "second-guardian", + access: "MANAGE", + acceptedAt: new Date(), + }, + }); + await connectForProfile(); + const leaving = await prisma.accountGrant.findFirstOrThrow({ + where: { grantorId: PROFILE, granteeId: GUARDIAN, revokedAt: null }, + }); + + const { revokeManagedProfileGuardian } = + await import("@/lib/sharing/grants"); + await revokeManagedProfileGuardian({ + profileId: PROFILE, + guardianId: "second-guardian", + grantId: leaving.id, + }); + + const app = await profileApp(); + expect(app.ouraClientIdEncrypted).toBeNull(); + expect(app.ouraClientSecretEncrypted).toBeNull(); + expect(app.clientAppSharedBy).toBeNull(); + }); +});