mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
feat(integrations): per-user Polar and Oura OAuth credentials
Polar and Oura resolved their OAuth client id/secret from the server
environment only, forcing every user on a deploy to share one app. Both
now read per-user BYO keys first, falling back to the shared env app when
unset, mirroring the WHOOP and Fitbit model.
- Add DB-first-then-env resolvers in src/lib/{polar,oura}/credentials.ts,
encrypting the client id/secret at rest on User.
- Repoint the connect, callback, status routes and the Oura refresh path
to the user-scoped resolver.
- Add /api/{polar,oura}/credentials (GET/PUT/DELETE) with Zod-validated
input and userId narrowed from the session.
- Render an optional 'your OAuth app credentials' form on the Polar and
Oura cards; the status read exposes hasOwnCredentials for the saved
placeholder. Existing env-only deploys keep working unchanged.
- Cover both resolvers (DB hit, env fallback, no creds) and the card form
with unit tests; add the new strings across all six locales.
This commit is contained in:
1 parent
111fa7a323
commit
38c7604ce4
28 files changed
+856
-76
No files matched your search
+16
-2
@@ -4603,7 +4603,14 @@
|
||||
"polarDescription": "Verbinde dein Polar-Konto, um Nightly-Recharge-Erholung, Schlafphasen und Tagesaktivität zu synchronisieren.",
|
||||
"polarViewData": "Schlaf & Erholung ansehen",
|
||||
"polarHelp": "Melde dich bei Polar an, um den Zugriff zu erlauben. Daten werden stündlich im Hintergrund abgerufen.",
|
||||
"polarUnavailable": "Polar ist auf diesem Server nicht konfiguriert. Bitte deinen Administrator, die Polar-OAuth-Zugangsdaten zu hinterlegen.",
|
||||
"polarCredentials": "API-Zugangsdaten",
|
||||
"polarCredentialsHelp": "Optional: Registriere deine eigene Polar-AccessLink-App und füge hier Client ID und Client Secret ein. Leer lassen, um die gemeinsame Polar-App des Servers zu nutzen.",
|
||||
"polarClientId": "Client ID",
|
||||
"polarClientSecret": "Client Secret",
|
||||
"polarCredentialsSaved": "Zugangsdaten gespeichert",
|
||||
"polarCredentialsSavedPlaceholder": "Gespeichert — neu eingeben zum Ersetzen",
|
||||
"polarSaveCredentials": "Speichern",
|
||||
"polarUnavailable": "Polar ist auf diesem Server nicht konfiguriert. Füge oben deine eigenen Polar-OAuth-Zugangsdaten hinzu oder bitte deinen Administrator, die gemeinsame App zu konfigurieren.",
|
||||
"polarConnect": "Polar verbinden",
|
||||
"polarDisconnect": "Trennen",
|
||||
"polarDisconnectTitle": "Polar trennen?",
|
||||
@@ -4624,7 +4631,14 @@
|
||||
"ouraDescription": "Verbinde dein Oura-Konto, um Readiness, Schlafphasen, HRV und Tagesaktivität zu synchronisieren.",
|
||||
"ouraViewData": "Schlaf & Erholung ansehen",
|
||||
"ouraHelp": "Melde dich bei Oura an, um den Zugriff zu erlauben. Daten werden stündlich im Hintergrund abgerufen.",
|
||||
"ouraUnavailable": "Oura ist auf diesem Server nicht konfiguriert. Bitte deinen Administrator, die Oura-OAuth-Zugangsdaten zu hinterlegen.",
|
||||
"ouraCredentials": "API-Zugangsdaten",
|
||||
"ouraCredentialsHelp": "Optional: Registriere deine eigene Oura-App und füge hier Client ID und Client Secret ein. Leer lassen, um die gemeinsame Oura-App des Servers zu nutzen.",
|
||||
"ouraClientId": "Client ID",
|
||||
"ouraClientSecret": "Client Secret",
|
||||
"ouraCredentialsSaved": "Zugangsdaten gespeichert",
|
||||
"ouraCredentialsSavedPlaceholder": "Gespeichert — neu eingeben zum Ersetzen",
|
||||
"ouraSaveCredentials": "Speichern",
|
||||
"ouraUnavailable": "Oura ist auf diesem Server nicht konfiguriert. Füge oben deine eigenen Oura-OAuth-Zugangsdaten hinzu oder bitte deinen Administrator, die gemeinsame App zu konfigurieren.",
|
||||
"ouraConnect": "Oura verbinden",
|
||||
"ouraDisconnect": "Trennen",
|
||||
"ouraDisconnectTitle": "Oura trennen?",
|
||||
|
||||
+16
-2
@@ -4603,7 +4603,14 @@
|
||||
"polarDescription": "Connect your Polar account to sync Nightly Recharge recovery, sleep stages, and daily activity.",
|
||||
"polarViewData": "View your sleep & recovery",
|
||||
"polarHelp": "Sign in with Polar to grant access. Data is pulled in the background once an hour.",
|
||||
"polarUnavailable": "Polar is not configured on this server. Ask your administrator to add the Polar OAuth credentials.",
|
||||
"polarCredentials": "API Credentials",
|
||||
"polarCredentialsHelp": "Optional: register your own Polar AccessLink app and paste its Client ID and Client Secret here. Leave blank to use the server's shared Polar app.",
|
||||
"polarClientId": "Client ID",
|
||||
"polarClientSecret": "Client Secret",
|
||||
"polarCredentialsSaved": "Credentials saved",
|
||||
"polarCredentialsSavedPlaceholder": "Saved — enter new to replace",
|
||||
"polarSaveCredentials": "Save",
|
||||
"polarUnavailable": "Polar is not configured on this server. Add your own Polar OAuth credentials above, or ask your administrator to configure the shared app.",
|
||||
"polarConnect": "Connect Polar",
|
||||
"polarDisconnect": "Disconnect",
|
||||
"polarDisconnectTitle": "Disconnect Polar?",
|
||||
@@ -4624,7 +4631,14 @@
|
||||
"ouraDescription": "Connect your Oura account to sync readiness, sleep stages, HRV, and daily activity.",
|
||||
"ouraViewData": "View your sleep & recovery",
|
||||
"ouraHelp": "Sign in with Oura to grant access. Data is pulled in the background once an hour.",
|
||||
"ouraUnavailable": "Oura is not configured on this server. Ask your administrator to add the Oura OAuth credentials.",
|
||||
"ouraCredentials": "API Credentials",
|
||||
"ouraCredentialsHelp": "Optional: register your own Oura app and paste its Client ID and Client Secret here. Leave blank to use the server's shared Oura app.",
|
||||
"ouraClientId": "Client ID",
|
||||
"ouraClientSecret": "Client Secret",
|
||||
"ouraCredentialsSaved": "Credentials saved",
|
||||
"ouraCredentialsSavedPlaceholder": "Saved — enter new to replace",
|
||||
"ouraSaveCredentials": "Save",
|
||||
"ouraUnavailable": "Oura is not configured on this server. Add your own Oura OAuth credentials above, or ask your administrator to configure the shared app.",
|
||||
"ouraConnect": "Connect Oura",
|
||||
"ouraDisconnect": "Disconnect",
|
||||
"ouraDisconnectTitle": "Disconnect Oura?",
|
||||
|
||||
+16
-2
@@ -4603,7 +4603,14 @@
|
||||
"polarDescription": "Conecta tu cuenta de Polar para sincronizar la recuperación Nightly Recharge, las fases de sueño y la actividad diaria.",
|
||||
"polarViewData": "Ver tu sueño y recuperación",
|
||||
"polarHelp": "Inicia sesión en Polar para conceder acceso. Los datos se obtienen en segundo plano una vez por hora.",
|
||||
"polarUnavailable": "Polar no está configurado en este servidor. Pide a tu administrador que añada las credenciales OAuth de Polar.",
|
||||
"polarCredentials": "Credenciales de API",
|
||||
"polarCredentialsHelp": "Opcional: registra tu propia app de Polar AccessLink y pega aquí su Client ID y Client Secret. Déjalo en blanco para usar la app compartida del servidor.",
|
||||
"polarClientId": "Client ID",
|
||||
"polarClientSecret": "Client Secret",
|
||||
"polarCredentialsSaved": "Credenciales guardadas",
|
||||
"polarCredentialsSavedPlaceholder": "Guardado — introduce nuevas para reemplazar",
|
||||
"polarSaveCredentials": "Guardar",
|
||||
"polarUnavailable": "Polar no está configurado en este servidor. Añade arriba tus propias credenciales OAuth de Polar, o pide a tu administrador que configure la app compartida.",
|
||||
"polarConnect": "Conectar Polar",
|
||||
"polarDisconnect": "Desconectar",
|
||||
"polarDisconnectTitle": "¿Desconectar Polar?",
|
||||
@@ -4624,7 +4631,14 @@
|
||||
"ouraDescription": "Conecta tu cuenta de Oura para sincronizar readiness, fases de sueño, VFC y actividad diaria.",
|
||||
"ouraViewData": "Ver tu sueño y recuperación",
|
||||
"ouraHelp": "Inicia sesión en Oura para conceder acceso. Los datos se obtienen en segundo plano una vez por hora.",
|
||||
"ouraUnavailable": "Oura no está configurado en este servidor. Pide a tu administrador que añada las credenciales OAuth de Oura.",
|
||||
"ouraCredentials": "Credenciales de API",
|
||||
"ouraCredentialsHelp": "Opcional: registra tu propia app de Oura y pega aquí su Client ID y Client Secret. Déjalo en blanco para usar la app compartida del servidor.",
|
||||
"ouraClientId": "Client ID",
|
||||
"ouraClientSecret": "Client Secret",
|
||||
"ouraCredentialsSaved": "Credenciales guardadas",
|
||||
"ouraCredentialsSavedPlaceholder": "Guardado — introduce nuevas para reemplazar",
|
||||
"ouraSaveCredentials": "Guardar",
|
||||
"ouraUnavailable": "Oura no está configurado en este servidor. Añade arriba tus propias credenciales OAuth de Oura, o pide a tu administrador que configure la app compartida.",
|
||||
"ouraConnect": "Conectar Oura",
|
||||
"ouraDisconnect": "Desconectar",
|
||||
"ouraDisconnectTitle": "¿Desconectar Oura?",
|
||||
|
||||
+16
-2
@@ -4603,7 +4603,14 @@
|
||||
"polarDescription": "Connectez votre compte Polar pour synchroniser la récupération Nightly Recharge, les phases de sommeil et l'activité quotidienne.",
|
||||
"polarViewData": "Voir votre sommeil et récupération",
|
||||
"polarHelp": "Connectez-vous à Polar pour accorder l'accès. Les données sont récupérées en arrière-plan une fois par heure.",
|
||||
"polarUnavailable": "Polar n'est pas configuré sur ce serveur. Demandez à votre administrateur d'ajouter les identifiants OAuth Polar.",
|
||||
"polarCredentials": "Identifiants API",
|
||||
"polarCredentialsHelp": "Facultatif : enregistrez votre propre application Polar AccessLink et collez ici son Client ID et son Client Secret. Laissez vide pour utiliser l'application Polar partagée du serveur.",
|
||||
"polarClientId": "Client ID",
|
||||
"polarClientSecret": "Client Secret",
|
||||
"polarCredentialsSaved": "Identifiants enregistrés",
|
||||
"polarCredentialsSavedPlaceholder": "Enregistré — saisir de nouveaux pour remplacer",
|
||||
"polarSaveCredentials": "Enregistrer",
|
||||
"polarUnavailable": "Polar n'est pas configuré sur ce serveur. Ajoutez vos propres identifiants OAuth Polar ci-dessus, ou demandez à votre administrateur de configurer l'application partagée.",
|
||||
"polarConnect": "Connecter Polar",
|
||||
"polarDisconnect": "Déconnecter",
|
||||
"polarDisconnectTitle": "Déconnecter Polar ?",
|
||||
@@ -4624,7 +4631,14 @@
|
||||
"ouraDescription": "Connectez votre compte Oura pour synchroniser le readiness, les phases de sommeil, la VFC et l'activité quotidienne.",
|
||||
"ouraViewData": "Voir votre sommeil et récupération",
|
||||
"ouraHelp": "Connectez-vous à Oura pour accorder l'accès. Les données sont récupérées en arrière-plan une fois par heure.",
|
||||
"ouraUnavailable": "Oura n'est pas configuré sur ce serveur. Demandez à votre administrateur d'ajouter les identifiants OAuth Oura.",
|
||||
"ouraCredentials": "Identifiants API",
|
||||
"ouraCredentialsHelp": "Facultatif : enregistrez votre propre application Oura et collez ici son Client ID et son Client Secret. Laissez vide pour utiliser l'application Oura partagée du serveur.",
|
||||
"ouraClientId": "Client ID",
|
||||
"ouraClientSecret": "Client Secret",
|
||||
"ouraCredentialsSaved": "Identifiants enregistrés",
|
||||
"ouraCredentialsSavedPlaceholder": "Enregistré — saisir de nouveaux pour remplacer",
|
||||
"ouraSaveCredentials": "Enregistrer",
|
||||
"ouraUnavailable": "Oura n'est pas configuré sur ce serveur. Ajoutez vos propres identifiants OAuth Oura ci-dessus, ou demandez à votre administrateur de configurer l'application partagée.",
|
||||
"ouraConnect": "Connecter Oura",
|
||||
"ouraDisconnect": "Déconnecter",
|
||||
"ouraDisconnectTitle": "Déconnecter Oura ?",
|
||||
|
||||
+16
-2
@@ -4603,7 +4603,14 @@
|
||||
"polarDescription": "Collega il tuo account Polar per sincronizzare il recupero Nightly Recharge, le fasi del sonno e l'attività giornaliera.",
|
||||
"polarViewData": "Vedi sonno e recupero",
|
||||
"polarHelp": "Accedi a Polar per concedere l'accesso. I dati vengono recuperati in background una volta all'ora.",
|
||||
"polarUnavailable": "Polar non è configurato su questo server. Chiedi al tuo amministratore di aggiungere le credenziali OAuth di Polar.",
|
||||
"polarCredentials": "Credenziali API",
|
||||
"polarCredentialsHelp": "Facoltativo: registra la tua app Polar AccessLink e incolla qui il suo Client ID e Client Secret. Lascia vuoto per usare l'app Polar condivisa del server.",
|
||||
"polarClientId": "Client ID",
|
||||
"polarClientSecret": "Client Secret",
|
||||
"polarCredentialsSaved": "Credenziali salvate",
|
||||
"polarCredentialsSavedPlaceholder": "Salvato — inserisci nuove per sostituire",
|
||||
"polarSaveCredentials": "Salva",
|
||||
"polarUnavailable": "Polar non è configurato su questo server. Aggiungi sopra le tue credenziali OAuth di Polar oppure chiedi al tuo amministratore di configurare l'app condivisa.",
|
||||
"polarConnect": "Collega Polar",
|
||||
"polarDisconnect": "Disconnetti",
|
||||
"polarDisconnectTitle": "Disconnettere Polar?",
|
||||
@@ -4624,7 +4631,14 @@
|
||||
"ouraDescription": "Collega il tuo account Oura per sincronizzare readiness, fasi del sonno, HRV e attività giornaliera.",
|
||||
"ouraViewData": "Vedi sonno e recupero",
|
||||
"ouraHelp": "Accedi a Oura per concedere l'accesso. I dati vengono recuperati in background una volta all'ora.",
|
||||
"ouraUnavailable": "Oura non è configurato su questo server. Chiedi al tuo amministratore di aggiungere le credenziali OAuth di Oura.",
|
||||
"ouraCredentials": "Credenziali API",
|
||||
"ouraCredentialsHelp": "Facoltativo: registra la tua app Oura e incolla qui il suo Client ID e Client Secret. Lascia vuoto per usare l'app Oura condivisa del server.",
|
||||
"ouraClientId": "Client ID",
|
||||
"ouraClientSecret": "Client Secret",
|
||||
"ouraCredentialsSaved": "Credenziali salvate",
|
||||
"ouraCredentialsSavedPlaceholder": "Salvato — inserisci nuove per sostituire",
|
||||
"ouraSaveCredentials": "Salva",
|
||||
"ouraUnavailable": "Oura non è configurato su questo server. Aggiungi sopra le tue credenziali OAuth di Oura oppure chiedi al tuo amministratore di configurare l'app condivisa.",
|
||||
"ouraConnect": "Collega Oura",
|
||||
"ouraDisconnect": "Disconnetti",
|
||||
"ouraDisconnectTitle": "Disconnettere Oura?",
|
||||
|
||||
+16
-2
@@ -4603,7 +4603,14 @@
|
||||
"polarDescription": "Połącz konto Polar, aby synchronizować regenerację Nightly Recharge, fazy snu i aktywność dzienną.",
|
||||
"polarViewData": "Zobacz sen i regenerację",
|
||||
"polarHelp": "Zaloguj się w Polar, aby przyznać dostęp. Dane są pobierane w tle raz na godzinę.",
|
||||
"polarUnavailable": "Polar nie jest skonfigurowany na tym serwerze. Poproś administratora o dodanie danych OAuth Polar.",
|
||||
"polarCredentials": "Dane API",
|
||||
"polarCredentialsHelp": "Opcjonalnie: zarejestruj własną aplikację Polar AccessLink i wklej tutaj jej Client ID oraz Client Secret. Pozostaw puste, aby użyć współdzielonej aplikacji Polar serwera.",
|
||||
"polarClientId": "Client ID",
|
||||
"polarClientSecret": "Client Secret",
|
||||
"polarCredentialsSaved": "Dane zapisane",
|
||||
"polarCredentialsSavedPlaceholder": "Zapisano — wpisz nowe, aby zastąpić",
|
||||
"polarSaveCredentials": "Zapisz",
|
||||
"polarUnavailable": "Polar nie jest skonfigurowany na tym serwerze. Dodaj powyżej własne dane OAuth Polar lub poproś administratora o skonfigurowanie współdzielonej aplikacji.",
|
||||
"polarConnect": "Połącz Polar",
|
||||
"polarDisconnect": "Rozłącz",
|
||||
"polarDisconnectTitle": "Rozłączyć Polar?",
|
||||
@@ -4624,7 +4631,14 @@
|
||||
"ouraDescription": "Połącz konto Oura, aby synchronizować readiness, fazy snu, HRV i aktywność dzienną.",
|
||||
"ouraViewData": "Zobacz sen i regenerację",
|
||||
"ouraHelp": "Zaloguj się w Oura, aby przyznać dostęp. Dane są pobierane w tle raz na godzinę.",
|
||||
"ouraUnavailable": "Oura nie jest skonfigurowana na tym serwerze. Poproś administratora o dodanie danych OAuth Oura.",
|
||||
"ouraCredentials": "Dane API",
|
||||
"ouraCredentialsHelp": "Opcjonalnie: zarejestruj własną aplikację Oura i wklej tutaj jej Client ID oraz Client Secret. Pozostaw puste, aby użyć współdzielonej aplikacji Oura serwera.",
|
||||
"ouraClientId": "Client ID",
|
||||
"ouraClientSecret": "Client Secret",
|
||||
"ouraCredentialsSaved": "Dane zapisane",
|
||||
"ouraCredentialsSavedPlaceholder": "Zapisano — wpisz nowe, aby zastąpić",
|
||||
"ouraSaveCredentials": "Zapisz",
|
||||
"ouraUnavailable": "Oura nie jest skonfigurowana na tym serwerze. Dodaj powyżej własne dane OAuth Oura lub poproś administratora o skonfigurowanie współdzielonej aplikacji.",
|
||||
"ouraConnect": "Połącz Oura",
|
||||
"ouraDisconnect": "Rozłącz",
|
||||
"ouraDisconnectTitle": "Rozłączyć Oura?",
|
||||
|
||||
@@ -4,7 +4,8 @@ import { getSession } from "@/lib/auth/session";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import { exchangeCode, getOuraCredentials } from "@/lib/oura/client";
|
||||
import { exchangeCode } from "@/lib/oura/client";
|
||||
import { getOuraClientCredentials } from "@/lib/oura/credentials";
|
||||
import {
|
||||
oauthStateCookieName,
|
||||
stateMatchesCookie,
|
||||
@@ -65,7 +66,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
if (!code) return ERR("nocode");
|
||||
|
||||
try {
|
||||
const creds = getOuraCredentials();
|
||||
const creds = await getOuraClientCredentials(userId);
|
||||
if (!creds) return ERR("nocreds");
|
||||
|
||||
const tokens = await exchangeCode(code, creds);
|
||||
|
||||
@@ -3,7 +3,8 @@ import { apiError } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { getAuthorizationUrl, getOuraCredentials } from "@/lib/oura/client";
|
||||
import { getAuthorizationUrl } from "@/lib/oura/client";
|
||||
import { getOuraClientCredentials } from "@/lib/oura/credentials";
|
||||
import {
|
||||
OAUTH_STATE_TTL_MS,
|
||||
mintSignedState,
|
||||
@@ -40,7 +41,7 @@ export const GET = apiHandler(async () => {
|
||||
);
|
||||
}
|
||||
|
||||
const creds = getOuraCredentials();
|
||||
const creds = await getOuraClientCredentials(user.id);
|
||||
if (!creds) {
|
||||
return apiError("Oura integration is not configured on this server.", 400);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
storeOuraClientCredentials,
|
||||
clearOuraClientCredentials,
|
||||
} from "@/lib/oura/credentials";
|
||||
import { ouraCredentialsSchema } from "@/lib/validations/oura";
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
/**
|
||||
* v1.17.1 — per-user Oura BYO-key credentials.
|
||||
*
|
||||
* Whether the user has their own Oura client id/secret stored. The
|
||||
* connect/callback/status routes resolve credentials DB-first then env, so an
|
||||
* unset pair simply falls back to the shared env app.
|
||||
*/
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "oura.credentials.get" } });
|
||||
|
||||
const dbUser = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: {
|
||||
ouraClientIdEncrypted: true,
|
||||
ouraClientSecretEncrypted: true,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
hasCredentials:
|
||||
!!dbUser?.ouraClientIdEncrypted && !!dbUser?.ouraClientSecretEncrypted,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Save Oura OAuth client credentials (encrypted at rest).
|
||||
*/
|
||||
export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "oura.credentials.update" } });
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 64 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const result = z.safeParse(ouraCredentialsSchema, body);
|
||||
if (!result.success) {
|
||||
return apiError("Client ID and Client Secret are required", 422);
|
||||
}
|
||||
|
||||
await storeOuraClientCredentials(
|
||||
user.id,
|
||||
result.data.clientId,
|
||||
result.data.clientSecret,
|
||||
);
|
||||
|
||||
return apiSuccess({ updated: true });
|
||||
});
|
||||
|
||||
/**
|
||||
* Delete Oura credentials and the active connection.
|
||||
*/
|
||||
export const DELETE = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "oura.credentials.delete" } });
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
ouraAccessTokenEncrypted: null,
|
||||
ouraRefreshTokenEncrypted: null,
|
||||
},
|
||||
});
|
||||
await clearOuraClientCredentials(user.id);
|
||||
|
||||
return apiSuccess({ deleted: true });
|
||||
});
|
||||
@@ -15,8 +15,11 @@ vi.mock("@/lib/api-response", () => ({
|
||||
apiSuccess: (data: unknown) => ({ data, error: null }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/oura/client", () => ({
|
||||
getOuraCredentials: vi.fn(() => ({ clientId: "c", clientSecret: "s" })),
|
||||
vi.mock("@/lib/oura/credentials", () => ({
|
||||
getOuraClientCredentials: vi.fn(async () => ({
|
||||
clientId: "c",
|
||||
clientSecret: "s",
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/integrations/status", () => ({
|
||||
@@ -32,10 +35,10 @@ vi.mock("@/lib/integrations/status", () => ({
|
||||
|
||||
import { GET } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getOuraCredentials } from "@/lib/oura/client";
|
||||
import { getOuraClientCredentials } from "@/lib/oura/credentials";
|
||||
|
||||
const userFind = prisma.user.findUnique as ReturnType<typeof vi.fn>;
|
||||
const credsMock = getOuraCredentials as ReturnType<typeof vi.fn>;
|
||||
const credsMock = getOuraClientCredentials as ReturnType<typeof vi.fn>;
|
||||
|
||||
type Body = {
|
||||
connected: boolean;
|
||||
@@ -49,7 +52,7 @@ const call = () =>
|
||||
describe("GET /api/oura/status", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
credsMock.mockReturnValue({ clientId: "c", clientSecret: "s" });
|
||||
credsMock.mockResolvedValue({ clientId: "c", clientSecret: "s" });
|
||||
});
|
||||
|
||||
it("reports not-connected but available", async () => {
|
||||
|
||||
@@ -2,15 +2,18 @@ import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { getOuraCredentials } from "@/lib/oura/client";
|
||||
import { getOuraClientCredentials } from "@/lib/oura/credentials";
|
||||
import { getIntegrationStatus } from "@/lib/integrations/status";
|
||||
|
||||
/**
|
||||
* v1.17.0 (F4) — Oura connection status for the current user.
|
||||
*
|
||||
* `available` reports whether the server has the shared OAuth app configured.
|
||||
* `available` reports whether usable OAuth credentials resolve for this user —
|
||||
* per-user BYO keys (v1.17.1) first, then the shared env app.
|
||||
* `hasOwnCredentials` reports whether the user has stored their own BYO pair.
|
||||
* `connected` is whether a token is stored. The ledger snapshot comes off the
|
||||
* shared `oura` IntegrationKey. The access / refresh tokens are NEVER returned.
|
||||
* shared `oura` IntegrationKey. The access / refresh tokens + client secret are
|
||||
* NEVER returned.
|
||||
*/
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
@@ -18,14 +21,25 @@ export const GET = apiHandler(async () => {
|
||||
|
||||
const dbUser = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { ouraAccessTokenEncrypted: true },
|
||||
select: {
|
||||
ouraAccessTokenEncrypted: true,
|
||||
ouraClientIdEncrypted: true,
|
||||
ouraClientSecretEncrypted: true,
|
||||
},
|
||||
});
|
||||
|
||||
const available = !!getOuraCredentials();
|
||||
const available = !!(await getOuraClientCredentials(user.id));
|
||||
const connected = !!dbUser?.ouraAccessTokenEncrypted;
|
||||
const hasOwnCredentials =
|
||||
!!dbUser?.ouraClientIdEncrypted && !!dbUser?.ouraClientSecretEncrypted;
|
||||
|
||||
if (!connected) {
|
||||
return apiSuccess({ connected: false, configured: false, available });
|
||||
return apiSuccess({
|
||||
connected: false,
|
||||
configured: false,
|
||||
available,
|
||||
hasOwnCredentials,
|
||||
});
|
||||
}
|
||||
|
||||
const status = await getIntegrationStatus(user.id, "oura");
|
||||
@@ -34,6 +48,7 @@ export const GET = apiHandler(async () => {
|
||||
connected: true,
|
||||
configured: true,
|
||||
available,
|
||||
hasOwnCredentials,
|
||||
state: status.state,
|
||||
lastSuccessAt: status.lastSuccessAt,
|
||||
lastAttemptAt: status.lastAttemptAt,
|
||||
|
||||
@@ -4,11 +4,8 @@ import { getSession } from "@/lib/auth/session";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import {
|
||||
exchangeCode,
|
||||
getPolarCredentials,
|
||||
registerUser,
|
||||
} from "@/lib/polar/client";
|
||||
import { exchangeCode, registerUser } from "@/lib/polar/client";
|
||||
import { getPolarClientCredentials } from "@/lib/polar/credentials";
|
||||
import {
|
||||
oauthStateCookieName,
|
||||
stateMatchesCookie,
|
||||
@@ -75,7 +72,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
if (!code) return ERR("nocode");
|
||||
|
||||
try {
|
||||
const creds = getPolarCredentials();
|
||||
const creds = await getPolarClientCredentials(userId);
|
||||
if (!creds) return ERR("nocreds");
|
||||
|
||||
const tokens = await exchangeCode(code, creds);
|
||||
|
||||
@@ -3,7 +3,8 @@ import { apiError } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { getAuthorizationUrl, getPolarCredentials } from "@/lib/polar/client";
|
||||
import { getAuthorizationUrl } from "@/lib/polar/client";
|
||||
import { getPolarClientCredentials } from "@/lib/polar/credentials";
|
||||
import {
|
||||
OAUTH_STATE_TTL_MS,
|
||||
mintSignedState,
|
||||
@@ -44,7 +45,7 @@ export const GET = apiHandler(async () => {
|
||||
);
|
||||
}
|
||||
|
||||
const creds = getPolarCredentials();
|
||||
const creds = await getPolarClientCredentials(user.id);
|
||||
if (!creds) {
|
||||
return apiError(
|
||||
"Polar integration is not configured on this server.",
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
storePolarClientCredentials,
|
||||
clearPolarClientCredentials,
|
||||
} from "@/lib/polar/credentials";
|
||||
import { polarCredentialsSchema } from "@/lib/validations/polar";
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
/**
|
||||
* v1.17.1 — per-user Polar BYO-key credentials.
|
||||
*
|
||||
* Whether the user has their own Polar AccessLink client id/secret stored. The
|
||||
* connect/callback/status routes resolve credentials DB-first then env, so an
|
||||
* unset pair simply falls back to the shared env app.
|
||||
*/
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "polar.credentials.get" } });
|
||||
|
||||
const dbUser = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: {
|
||||
polarClientIdEncrypted: true,
|
||||
polarClientSecretEncrypted: true,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
hasCredentials:
|
||||
!!dbUser?.polarClientIdEncrypted && !!dbUser?.polarClientSecretEncrypted,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Save Polar OAuth client credentials (encrypted at rest).
|
||||
*/
|
||||
export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "polar.credentials.update" } });
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 64 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const result = z.safeParse(polarCredentialsSchema, body);
|
||||
if (!result.success) {
|
||||
return apiError("Client ID and Client Secret are required", 422);
|
||||
}
|
||||
|
||||
await storePolarClientCredentials(
|
||||
user.id,
|
||||
result.data.clientId,
|
||||
result.data.clientSecret,
|
||||
);
|
||||
|
||||
return apiSuccess({ updated: true });
|
||||
});
|
||||
|
||||
/**
|
||||
* Delete Polar credentials and the active connection.
|
||||
*/
|
||||
export const DELETE = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "polar.credentials.delete" } });
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
polarAccessTokenEncrypted: null,
|
||||
polarUserIdEncrypted: null,
|
||||
},
|
||||
});
|
||||
await clearPolarClientCredentials(user.id);
|
||||
|
||||
return apiSuccess({ deleted: true });
|
||||
});
|
||||
@@ -15,8 +15,11 @@ vi.mock("@/lib/api-response", () => ({
|
||||
apiSuccess: (data: unknown) => ({ data, error: null }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/polar/client", () => ({
|
||||
getPolarCredentials: vi.fn(() => ({ clientId: "c", clientSecret: "s" })),
|
||||
vi.mock("@/lib/polar/credentials", () => ({
|
||||
getPolarClientCredentials: vi.fn(async () => ({
|
||||
clientId: "c",
|
||||
clientSecret: "s",
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/integrations/status", () => ({
|
||||
@@ -32,10 +35,10 @@ vi.mock("@/lib/integrations/status", () => ({
|
||||
|
||||
import { GET } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getPolarCredentials } from "@/lib/polar/client";
|
||||
import { getPolarClientCredentials } from "@/lib/polar/credentials";
|
||||
|
||||
const userFind = prisma.user.findUnique as ReturnType<typeof vi.fn>;
|
||||
const credsMock = getPolarCredentials as ReturnType<typeof vi.fn>;
|
||||
const credsMock = getPolarClientCredentials as ReturnType<typeof vi.fn>;
|
||||
|
||||
type Body = {
|
||||
connected: boolean;
|
||||
@@ -49,7 +52,7 @@ const call = () =>
|
||||
describe("GET /api/polar/status", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
credsMock.mockReturnValue({ clientId: "c", clientSecret: "s" });
|
||||
credsMock.mockResolvedValue({ clientId: "c", clientSecret: "s" });
|
||||
});
|
||||
|
||||
it("reports not-connected but available when env is set and no token stored", async () => {
|
||||
@@ -60,7 +63,7 @@ describe("GET /api/polar/status", () => {
|
||||
});
|
||||
|
||||
it("reports available=false when the server has no Polar app configured", async () => {
|
||||
credsMock.mockReturnValue(null);
|
||||
credsMock.mockResolvedValue(null);
|
||||
userFind.mockResolvedValue(null);
|
||||
const res = await call();
|
||||
expect(res.available).toBe(false);
|
||||
|
||||
@@ -2,17 +2,19 @@ import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { getPolarCredentials } from "@/lib/polar/client";
|
||||
import { getPolarClientCredentials } from "@/lib/polar/credentials";
|
||||
import { getIntegrationStatus } from "@/lib/integrations/status";
|
||||
|
||||
/**
|
||||
* v1.17.0 (F4) — Polar connection status for the current user.
|
||||
*
|
||||
* `available` reports whether the server has the shared OAuth app configured
|
||||
* (env client id/secret) so the card can grey out the connect button when the
|
||||
* operator hasn't set it up. `connected` is whether a token is stored. The
|
||||
* ledger snapshot (state / lastSuccessAt / lastError) comes off the shared
|
||||
* `polar` IntegrationKey. The access token + member id are NEVER returned.
|
||||
* `available` reports whether usable OAuth credentials resolve for this user —
|
||||
* per-user BYO keys (v1.17.1) first, then the shared env app — so the card can
|
||||
* grey out the connect button when neither is set. `hasOwnCredentials` reports
|
||||
* whether the user has stored their own BYO pair (drives the saved-placeholder
|
||||
* UI). `connected` is whether a token is stored. The ledger snapshot (state /
|
||||
* lastSuccessAt / lastError) comes off the shared `polar` IntegrationKey. The
|
||||
* access token, member id, and client secret are NEVER returned.
|
||||
*/
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
@@ -20,14 +22,25 @@ export const GET = apiHandler(async () => {
|
||||
|
||||
const dbUser = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { polarAccessTokenEncrypted: true },
|
||||
select: {
|
||||
polarAccessTokenEncrypted: true,
|
||||
polarClientIdEncrypted: true,
|
||||
polarClientSecretEncrypted: true,
|
||||
},
|
||||
});
|
||||
|
||||
const available = !!getPolarCredentials();
|
||||
const available = !!(await getPolarClientCredentials(user.id));
|
||||
const connected = !!dbUser?.polarAccessTokenEncrypted;
|
||||
const hasOwnCredentials =
|
||||
!!dbUser?.polarClientIdEncrypted && !!dbUser?.polarClientSecretEncrypted;
|
||||
|
||||
if (!connected) {
|
||||
return apiSuccess({ connected: false, configured: false, available });
|
||||
return apiSuccess({
|
||||
connected: false,
|
||||
configured: false,
|
||||
available,
|
||||
hasOwnCredentials,
|
||||
});
|
||||
}
|
||||
|
||||
const status = await getIntegrationStatus(user.id, "polar");
|
||||
@@ -36,6 +49,7 @@ export const GET = apiHandler(async () => {
|
||||
connected: true,
|
||||
configured: true,
|
||||
available,
|
||||
hasOwnCredentials,
|
||||
state: status.state,
|
||||
lastSuccessAt: status.lastSuccessAt,
|
||||
lastAttemptAt: status.lastAttemptAt,
|
||||
|
||||
@@ -23,7 +23,7 @@ vi.mock("@tanstack/react-query", () => ({
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { OAuthProviderCard } from "../oauth-provider-card";
|
||||
|
||||
function render() {
|
||||
function render({ credentials = false }: { credentials?: boolean } = {}) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<OAuthProviderCard
|
||||
@@ -32,6 +32,7 @@ function render() {
|
||||
i18nPrefix="settings.polar"
|
||||
icon={Watch}
|
||||
dataHref="/insights/sleep"
|
||||
credentials={credentials}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
@@ -86,3 +87,38 @@ describe("OAuthProviderCard — parked + test + data-link parity", () => {
|
||||
expect(html).toContain('data-testid="polar-connect"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("OAuthProviderCard — per-user BYO credentials form (v1.17.1)", () => {
|
||||
it("renders the credentials form only when the `credentials` prop is set", () => {
|
||||
statusPayload = {
|
||||
connected: false,
|
||||
configured: false,
|
||||
available: true,
|
||||
hasOwnCredentials: false,
|
||||
};
|
||||
// Opt-in: the BYO client-id/secret form + save button appear.
|
||||
const withForm = render({ credentials: true });
|
||||
expect(withForm).toContain('data-testid="polar-credentials"');
|
||||
expect(withForm).toContain('id="polar-clientid"');
|
||||
expect(withForm).toContain('id="polar-secret"');
|
||||
|
||||
// Default: no credential inputs (env-only behaviour preserved).
|
||||
const withoutForm = render();
|
||||
expect(withoutForm).not.toContain('data-testid="polar-credentials"');
|
||||
expect(withoutForm).not.toContain('id="polar-clientid"');
|
||||
});
|
||||
|
||||
it("shows the saved-placeholder once the user has stored their own pair", () => {
|
||||
statusPayload = {
|
||||
connected: true,
|
||||
configured: true,
|
||||
available: true,
|
||||
hasOwnCredentials: true,
|
||||
state: "connected",
|
||||
lastSuccessAt: null,
|
||||
lastError: null,
|
||||
};
|
||||
const html = render({ credentials: true });
|
||||
expect(html).toContain("Saved — enter new to replace");
|
||||
});
|
||||
});
|
||||
@@ -1,16 +1,17 @@
|
||||
"use client";
|
||||
|
||||
// v1.17.0 (F4) — shared OAuth integration card for env-based providers (Polar,
|
||||
// Oura). Unlike WHOOP / Fitbit (per-user BYO client id/secret), these use a
|
||||
// single shared OAuth app the operator configures via env, so the card has NO
|
||||
// credential inputs — just a connect button that redirects to the provider, a
|
||||
// status pill off the self-contained status read, and a disconnect action.
|
||||
// v1.17.0 (F4) — shared OAuth integration card for Polar / Oura.
|
||||
// v1.17.1 — these are now per-user BYO-key integrations (like WHOOP / Fitbit):
|
||||
// the card renders an optional "your OAuth app credentials" form (driven by the
|
||||
// `credentials` prop) above the connect button. Credentials resolve DB-first
|
||||
// then env on the server, so a user who pastes their own client id/secret uses
|
||||
// their own app while existing env-configured deploys keep working unchanged.
|
||||
// Mirrors the Nightscout card's self-contained status pattern.
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { ArrowRight, Link2, Unlink, type LucideIcon } from "lucide-react";
|
||||
import { ArrowRight, Link2, Loader2, Save, Unlink, type LucideIcon } from "lucide-react";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -24,13 +25,16 @@ import {
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { PasswordInput } from "@/components/ui/password-input";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import {
|
||||
IntegrationStatusPill,
|
||||
type IntegrationPillState,
|
||||
} from "@/components/settings/integration-status-pill";
|
||||
import { TestConnectionButton } from "@/components/settings/test-connection-button";
|
||||
import { apiGet, apiPost } from "@/lib/api/api-fetch";
|
||||
import { apiFetchRaw, apiGet, apiPost } from "@/lib/api/api-fetch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { invalidateKeys, measurementDependentKeys } from "@/lib/query-keys";
|
||||
|
||||
@@ -38,6 +42,8 @@ export interface OAuthProviderStatus {
|
||||
connected: boolean;
|
||||
configured: boolean;
|
||||
available: boolean;
|
||||
/** Whether the user has stored their own BYO client id/secret pair. */
|
||||
hasOwnCredentials?: boolean;
|
||||
state?:
|
||||
| "connected"
|
||||
| "error_transient"
|
||||
@@ -74,6 +80,9 @@ export interface OAuthProviderCardProps {
|
||||
icon: LucideIcon;
|
||||
/** Where this provider's synced data surfaces (e.g. `/insights/sleep`). */
|
||||
dataHref: string;
|
||||
/** When set, render a per-user BYO OAuth-credentials form above the connect
|
||||
* button. The endpoint is the PUT target (e.g. `/api/polar/credentials`). */
|
||||
credentials?: boolean;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
@@ -83,11 +92,19 @@ export function OAuthProviderCard({
|
||||
i18nPrefix,
|
||||
icon,
|
||||
dataHref,
|
||||
credentials = false,
|
||||
enabled = true,
|
||||
}: OAuthProviderCardProps) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
const [clientId, setClientId] = useState("");
|
||||
const [clientSecret, setClientSecret] = useState("");
|
||||
const [credsSaving, setCredsSaving] = useState(false);
|
||||
const [credsMsg, setCredsMsg] = useState<string | null>(null);
|
||||
const [credsMsgType, setCredsMsgType] = useState<"success" | "error" | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const { data: status } = useQuery({
|
||||
queryKey: statusQueryKey,
|
||||
@@ -96,6 +113,42 @@ export function OAuthProviderCard({
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
|
||||
async function handleSaveCredentials(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setCredsSaving(true);
|
||||
setCredsMsg(null);
|
||||
setCredsMsgType(null);
|
||||
try {
|
||||
const res = await apiFetchRaw(`/api/${provider}/credentials`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
clientId: clientId.trim(),
|
||||
clientSecret: clientSecret.trim(),
|
||||
}),
|
||||
});
|
||||
if (res.ok) {
|
||||
setCredsMsg(t(`${i18nPrefix}CredentialsSaved`));
|
||||
setCredsMsgType("success");
|
||||
setClientId("");
|
||||
setClientSecret("");
|
||||
queryClient.invalidateQueries({ queryKey: statusQueryKey });
|
||||
} else {
|
||||
try {
|
||||
const json = await res.json();
|
||||
setCredsMsg(json.error || t("settings.savingError"));
|
||||
} catch {
|
||||
setCredsMsg(t("settings.savingError"));
|
||||
}
|
||||
setCredsMsgType("error");
|
||||
}
|
||||
} catch {
|
||||
setCredsMsg(t("common.networkError"));
|
||||
setCredsMsgType("error");
|
||||
}
|
||||
setCredsSaving(false);
|
||||
}
|
||||
|
||||
const disconnect = useMutation({
|
||||
mutationFn: async () => {
|
||||
await apiPost(`/api/${provider}/disconnect`);
|
||||
@@ -182,6 +235,89 @@ export function OAuthProviderCard({
|
||||
{t(`${i18nPrefix}Help`)}
|
||||
</p>
|
||||
|
||||
{credentials && (
|
||||
<div className="space-y-3" data-testid={`${provider}-credentials`}>
|
||||
<h3 className="text-sm font-semibold">
|
||||
{t(`${i18nPrefix}Credentials`)}
|
||||
</h3>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t(`${i18nPrefix}CredentialsHelp`)}
|
||||
</p>
|
||||
<form onSubmit={handleSaveCredentials} className="space-y-3">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`${provider}-clientid`}>
|
||||
{t(`${i18nPrefix}ClientId`)}
|
||||
</Label>
|
||||
<Input
|
||||
id={`${provider}-clientid`}
|
||||
value={clientId}
|
||||
onChange={(e) => setClientId(e.target.value)}
|
||||
placeholder={
|
||||
status?.hasOwnCredentials
|
||||
? t(`${i18nPrefix}CredentialsSavedPlaceholder`)
|
||||
: t(`${i18nPrefix}ClientId`)
|
||||
}
|
||||
maxLength={200}
|
||||
autoComplete="off"
|
||||
inputMode="text"
|
||||
spellCheck={false}
|
||||
autoCapitalize="none"
|
||||
enterKeyHint="next"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor={`${provider}-secret`}>
|
||||
{t(`${i18nPrefix}ClientSecret`)}
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id={`${provider}-secret`}
|
||||
value={clientSecret}
|
||||
onChange={(e) => setClientSecret(e.target.value)}
|
||||
placeholder={
|
||||
status?.hasOwnCredentials
|
||||
? t(`${i18nPrefix}CredentialsSavedPlaceholder`)
|
||||
: t(`${i18nPrefix}ClientSecret`)
|
||||
}
|
||||
maxLength={200}
|
||||
autoComplete="off"
|
||||
inputMode="text"
|
||||
spellCheck={false}
|
||||
autoCapitalize="none"
|
||||
enterKeyHint="done"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-11 w-full sm:w-auto"
|
||||
disabled={
|
||||
credsSaving || !clientId.trim() || !clientSecret.trim()
|
||||
}
|
||||
>
|
||||
{credsSaving ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Save className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t(`${i18nPrefix}SaveCredentials`)}
|
||||
</Button>
|
||||
</div>
|
||||
{credsMsg && (
|
||||
<p
|
||||
role="alert"
|
||||
className={`text-sm ${credsMsgType === "success" ? "text-success" : "text-destructive"}`}
|
||||
>
|
||||
{credsMsg}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{serverUnavailable && (
|
||||
<p
|
||||
className="text-muted-foreground/80 text-xs"
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
// v1.17.0 (F4) — Oura Cloud integration card. Thin wrapper over the shared
|
||||
// env-based OAuth card.
|
||||
// OAuth card.
|
||||
// v1.17.1 — per-user BYO OAuth credentials (DB-first then env).
|
||||
|
||||
import { CircleDot } from "lucide-react";
|
||||
|
||||
@@ -16,6 +17,7 @@ export function OuraCard({ enabled = true }: { enabled?: boolean }) {
|
||||
i18nPrefix="settings.oura"
|
||||
icon={CircleDot}
|
||||
dataHref="/insights/sleep"
|
||||
credentials
|
||||
enabled={enabled}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
// v1.17.0 (F4) — Polar AccessLink integration card. Thin wrapper over the
|
||||
// shared env-based OAuth card.
|
||||
// shared OAuth card.
|
||||
// v1.17.1 — per-user BYO OAuth credentials (DB-first then env).
|
||||
|
||||
import { Watch } from "lucide-react";
|
||||
|
||||
@@ -16,6 +17,7 @@ export function PolarCard({ enabled = true }: { enabled?: boolean }) {
|
||||
i18nPrefix="settings.polar"
|
||||
icon={Watch}
|
||||
dataHref="/insights/sleep"
|
||||
credentials
|
||||
enabled={enabled}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const findUnique = vi.fn();
|
||||
const getOuraCredentials = vi.fn();
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: { user: { findUnique: (...args: unknown[]) => findUnique(...args) } },
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
decrypt: (cipher: string) => `dec(${cipher})`,
|
||||
encrypt: (plain: string) => `enc(${plain})`,
|
||||
}));
|
||||
|
||||
// The env fallback lives in ./client; stub it so the precedence is observable.
|
||||
vi.mock("../client", () => ({
|
||||
getOuraCredentials: () => getOuraCredentials(),
|
||||
}));
|
||||
|
||||
import { getOuraClientCredentials } from "../credentials";
|
||||
|
||||
describe("getOuraClientCredentials — DB-first then env", () => {
|
||||
beforeEach(() => {
|
||||
findUnique.mockReset();
|
||||
getOuraCredentials.mockReset();
|
||||
});
|
||||
|
||||
it("returns the per-user BYO client id/secret when both columns are set", async () => {
|
||||
findUnique.mockResolvedValue({
|
||||
ouraClientIdEncrypted: "enc-id",
|
||||
ouraClientSecretEncrypted: "enc-secret",
|
||||
});
|
||||
const creds = await getOuraClientCredentials("user-1");
|
||||
expect(creds).toEqual({
|
||||
clientId: "dec(enc-id)",
|
||||
clientSecret: "dec(enc-secret)",
|
||||
});
|
||||
expect(getOuraCredentials).not.toHaveBeenCalled();
|
||||
expect(findUnique).toHaveBeenCalledWith({
|
||||
where: { id: "user-1" },
|
||||
select: {
|
||||
ouraClientIdEncrypted: true,
|
||||
ouraClientSecretEncrypted: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the shared env app when the user has no BYO pair", async () => {
|
||||
findUnique.mockResolvedValue({
|
||||
ouraClientIdEncrypted: null,
|
||||
ouraClientSecretEncrypted: null,
|
||||
});
|
||||
getOuraCredentials.mockReturnValue({
|
||||
clientId: "env-id",
|
||||
clientSecret: "env-secret",
|
||||
});
|
||||
const creds = await getOuraClientCredentials("user-1");
|
||||
expect(creds).toEqual({ clientId: "env-id", clientSecret: "env-secret" });
|
||||
expect(getOuraCredentials).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("falls back to env when only one half of the BYO pair is present", async () => {
|
||||
findUnique.mockResolvedValue({
|
||||
ouraClientIdEncrypted: null,
|
||||
ouraClientSecretEncrypted: "enc-secret",
|
||||
});
|
||||
getOuraCredentials.mockReturnValue({
|
||||
clientId: "env-id",
|
||||
clientSecret: "env-secret",
|
||||
});
|
||||
const creds = await getOuraClientCredentials("user-1");
|
||||
expect(creds).toEqual({ clientId: "env-id", clientSecret: "env-secret" });
|
||||
});
|
||||
|
||||
it("returns null when neither the user nor the env is configured", async () => {
|
||||
findUnique.mockResolvedValue(null);
|
||||
getOuraCredentials.mockReturnValue(null);
|
||||
expect(await getOuraClientCredentials("ghost")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -31,6 +31,7 @@ const {
|
||||
vi.mock("../credentials", () => ({
|
||||
getOuraConnection: getConnMock,
|
||||
storeOuraTokens: storeTokensMock,
|
||||
getOuraClientCredentials: getCredsMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
@@ -57,7 +58,6 @@ vi.mock("../client", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../client")>();
|
||||
return {
|
||||
...actual,
|
||||
getOuraCredentials: getCredsMock,
|
||||
fetchReadiness: fetchReadinessMock,
|
||||
fetchSleep: fetchSleepMock,
|
||||
fetchDailyActivity: fetchActivityMock,
|
||||
@@ -73,7 +73,9 @@ const CONN = { accessToken: "acc", refreshToken: "ref" };
|
||||
beforeEach(() => {
|
||||
getConnMock.mockReset();
|
||||
storeTokensMock.mockReset().mockResolvedValue(undefined);
|
||||
getCredsMock.mockReset().mockReturnValue({ clientId: "c", clientSecret: "s" });
|
||||
getCredsMock
|
||||
.mockReset()
|
||||
.mockResolvedValue({ clientId: "c", clientSecret: "s" });
|
||||
fetchReadinessMock.mockReset().mockResolvedValue([]);
|
||||
fetchSleepMock.mockReset().mockResolvedValue([]);
|
||||
fetchActivityMock.mockReset().mockResolvedValue([]);
|
||||
|
||||
@@ -1,14 +1,76 @@
|
||||
/**
|
||||
* v1.17.0 (F4) — per-user Oura token accessors.
|
||||
* v1.17.1 — per-user BYO client id/secret resolver (DB-first then env).
|
||||
*
|
||||
* Oura uses a single shared OAuth app (env client id/secret via
|
||||
* `getOuraCredentials`). Per-user state is the granted access + refresh token,
|
||||
* stored encrypted on `User`. The merged schema carries NO token-expiry column,
|
||||
* so the sync layer refreshes REACTIVELY on a 401 rather than proactively on an
|
||||
* expiry timestamp (and persists both rotated tokens).
|
||||
* Like WHOOP / Fitbit, Oura is now a per-user BYO-key integration: each user may
|
||||
* register their own Oura app and store the client id/secret encrypted on
|
||||
* `User`, with a fall-back to the shared env app for existing single-app
|
||||
* deploys. Also per-user is the granted access + refresh token. The schema
|
||||
* carries NO token-expiry column, so the sync layer refreshes REACTIVELY on a
|
||||
* 401 rather than proactively on an expiry timestamp (persisting both rotated
|
||||
* tokens).
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { decrypt, encrypt } from "@/lib/crypto";
|
||||
import { getOuraCredentials, type OuraCredentials } from "./client";
|
||||
|
||||
/**
|
||||
* Resolve the user's Oura OAuth client id/secret, DB-first then env.
|
||||
*
|
||||
* v1.17.1 makes Oura a per-user BYO-key integration (mirroring WHOOP / Fitbit):
|
||||
* a self-hoster registers their own Oura app and pastes the client id/secret
|
||||
* into Settings, stored encrypted on `User`. When the user has not configured
|
||||
* per-user keys we fall back to the shared env app (`OURA_CLIENT_ID` /
|
||||
* `OURA_CLIENT_SECRET`) so existing single-app deploys keep working. Returns
|
||||
* null when neither source is configured.
|
||||
*/
|
||||
export async function getOuraClientCredentials(
|
||||
userId: string,
|
||||
): Promise<OuraCredentials | null> {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
ouraClientIdEncrypted: true,
|
||||
ouraClientSecretEncrypted: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (user?.ouraClientIdEncrypted && user?.ouraClientSecretEncrypted) {
|
||||
return {
|
||||
clientId: decrypt(user.ouraClientIdEncrypted),
|
||||
clientSecret: decrypt(user.ouraClientSecretEncrypted),
|
||||
};
|
||||
}
|
||||
|
||||
// Fall back to the shared env-configured OAuth app.
|
||||
return getOuraCredentials();
|
||||
}
|
||||
|
||||
/** Persist the user's Oura OAuth client id/secret, encrypted at rest. */
|
||||
export async function storeOuraClientCredentials(
|
||||
userId: string,
|
||||
clientId: string,
|
||||
clientSecret: string,
|
||||
): Promise<void> {
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
ouraClientIdEncrypted: encrypt(clientId),
|
||||
ouraClientSecretEncrypted: encrypt(clientSecret),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Clear the user's stored Oura OAuth client id/secret. */
|
||||
export async function clearOuraClientCredentials(userId: string): Promise<void> {
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
ouraClientIdEncrypted: null,
|
||||
ouraClientSecretEncrypted: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export interface OuraConnection {
|
||||
accessToken: string;
|
||||
|
||||
@@ -35,14 +35,17 @@ import {
|
||||
fetchDailyActivity,
|
||||
fetchReadiness,
|
||||
fetchSleep,
|
||||
getOuraCredentials,
|
||||
mapDailyActivity,
|
||||
mapReadiness,
|
||||
mapSleep,
|
||||
refreshAccessToken,
|
||||
type MappedMeasurement,
|
||||
} from "./client";
|
||||
import { getOuraConnection, storeOuraTokens } from "./credentials";
|
||||
import {
|
||||
getOuraClientCredentials,
|
||||
getOuraConnection,
|
||||
storeOuraTokens,
|
||||
} from "./credentials";
|
||||
import { OuraApiError, classifyOuraError } from "./response-classifier";
|
||||
|
||||
/** Default lookback window (days) for an incremental sync. Oura finalises a
|
||||
@@ -118,7 +121,7 @@ async function fetchAll(
|
||||
// case — rethrow so the caller classifies it.
|
||||
if (!(err instanceof OuraApiError) || err.httpStatus !== 401) throw err;
|
||||
|
||||
const creds = getOuraCredentials();
|
||||
const creds = await getOuraClientCredentials(userId);
|
||||
if (!creds) throw err;
|
||||
|
||||
const rotated = await refreshAccessToken(refreshToken, creds);
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const findUnique = vi.fn();
|
||||
const getPolarCredentials = vi.fn();
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: { user: { findUnique: (...args: unknown[]) => findUnique(...args) } },
|
||||
}));
|
||||
|
||||
// decrypt() is the fail-closed AES-256-GCM reader; the resolver only needs it
|
||||
// to round-trip the stored ciphertext, so a `dec(<x>)` shim is sufficient here.
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
decrypt: (cipher: string) => `dec(${cipher})`,
|
||||
encrypt: (plain: string) => `enc(${plain})`,
|
||||
}));
|
||||
|
||||
// The env fallback lives in ./client; stub it so the precedence is observable.
|
||||
vi.mock("../client", () => ({
|
||||
getPolarCredentials: () => getPolarCredentials(),
|
||||
}));
|
||||
|
||||
import { getPolarClientCredentials } from "../credentials";
|
||||
|
||||
describe("getPolarClientCredentials — DB-first then env", () => {
|
||||
beforeEach(() => {
|
||||
findUnique.mockReset();
|
||||
getPolarCredentials.mockReset();
|
||||
});
|
||||
|
||||
it("returns the per-user BYO client id/secret when both columns are set", async () => {
|
||||
findUnique.mockResolvedValue({
|
||||
polarClientIdEncrypted: "enc-id",
|
||||
polarClientSecretEncrypted: "enc-secret",
|
||||
});
|
||||
const creds = await getPolarClientCredentials("user-1");
|
||||
expect(creds).toEqual({
|
||||
clientId: "dec(enc-id)",
|
||||
clientSecret: "dec(enc-secret)",
|
||||
});
|
||||
// DB hit must not consult the env fallback.
|
||||
expect(getPolarCredentials).not.toHaveBeenCalled();
|
||||
expect(findUnique).toHaveBeenCalledWith({
|
||||
where: { id: "user-1" },
|
||||
select: {
|
||||
polarClientIdEncrypted: true,
|
||||
polarClientSecretEncrypted: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the shared env app when the user has no BYO pair", async () => {
|
||||
findUnique.mockResolvedValue({
|
||||
polarClientIdEncrypted: null,
|
||||
polarClientSecretEncrypted: null,
|
||||
});
|
||||
getPolarCredentials.mockReturnValue({
|
||||
clientId: "env-id",
|
||||
clientSecret: "env-secret",
|
||||
});
|
||||
const creds = await getPolarClientCredentials("user-1");
|
||||
expect(creds).toEqual({ clientId: "env-id", clientSecret: "env-secret" });
|
||||
expect(getPolarCredentials).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("falls back to env when only one half of the BYO pair is present", async () => {
|
||||
findUnique.mockResolvedValue({
|
||||
polarClientIdEncrypted: "enc-id",
|
||||
polarClientSecretEncrypted: null,
|
||||
});
|
||||
getPolarCredentials.mockReturnValue({
|
||||
clientId: "env-id",
|
||||
clientSecret: "env-secret",
|
||||
});
|
||||
const creds = await getPolarClientCredentials("user-1");
|
||||
expect(creds).toEqual({ clientId: "env-id", clientSecret: "env-secret" });
|
||||
});
|
||||
|
||||
it("returns null when neither the user nor the env is configured", async () => {
|
||||
findUnique.mockResolvedValue(null);
|
||||
getPolarCredentials.mockReturnValue(null);
|
||||
expect(await getPolarClientCredentials("ghost")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,75 @@
|
||||
/**
|
||||
* v1.17.0 (F4) — per-user Polar token accessors.
|
||||
* v1.17.1 — per-user BYO client id/secret resolver (DB-first then env).
|
||||
*
|
||||
* Unlike WHOOP (per-user BYO client id/secret), Polar uses a single shared
|
||||
* OAuth app whose client id/secret come from env (`getPolarCredentials`). What
|
||||
* is per-user is the granted token + Polar member id, stored encrypted on
|
||||
* `User`. Polar access tokens do not expire and carry no refresh token, so
|
||||
* there is no refresh path — a revoked grant surfaces as a 401 on the next read.
|
||||
* Like WHOOP / Fitbit, Polar is now a per-user BYO-key integration: each user
|
||||
* may register their own AccessLink app and store the client id/secret encrypted
|
||||
* on `User`, with a fall-back to the shared env app for existing single-app
|
||||
* deploys. Also per-user is the granted token + Polar member id. Polar access
|
||||
* tokens do not expire and carry no refresh token, so there is no refresh path —
|
||||
* a revoked grant surfaces as a 401 on the next read.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { decrypt } from "@/lib/crypto";
|
||||
import { decrypt, encrypt } from "@/lib/crypto";
|
||||
import { getPolarCredentials, type PolarCredentials } from "./client";
|
||||
|
||||
/**
|
||||
* Resolve the user's Polar OAuth client id/secret, DB-first then env.
|
||||
*
|
||||
* v1.17.1 makes Polar a per-user BYO-key integration (mirroring WHOOP / Fitbit):
|
||||
* a self-hoster registers their own Polar AccessLink app and pastes the client
|
||||
* id/secret into Settings, stored encrypted on `User`. When the user has not
|
||||
* configured per-user keys we fall back to the shared env app
|
||||
* (`POLAR_CLIENT_ID` / `POLAR_CLIENT_SECRET`) so existing single-app deploys
|
||||
* keep working. Returns null when neither source is configured.
|
||||
*/
|
||||
export async function getPolarClientCredentials(
|
||||
userId: string,
|
||||
): Promise<PolarCredentials | null> {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
polarClientIdEncrypted: true,
|
||||
polarClientSecretEncrypted: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (user?.polarClientIdEncrypted && user?.polarClientSecretEncrypted) {
|
||||
return {
|
||||
clientId: decrypt(user.polarClientIdEncrypted),
|
||||
clientSecret: decrypt(user.polarClientSecretEncrypted),
|
||||
};
|
||||
}
|
||||
|
||||
// Fall back to the shared env-configured OAuth app.
|
||||
return getPolarCredentials();
|
||||
}
|
||||
|
||||
/** Persist the user's Polar OAuth client id/secret, encrypted at rest. */
|
||||
export async function storePolarClientCredentials(
|
||||
userId: string,
|
||||
clientId: string,
|
||||
clientSecret: string,
|
||||
): Promise<void> {
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
polarClientIdEncrypted: encrypt(clientId),
|
||||
polarClientSecretEncrypted: encrypt(clientSecret),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Clear the user's stored Polar OAuth client id/secret. */
|
||||
export async function clearPolarClientCredentials(userId: string): Promise<void> {
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
polarClientIdEncrypted: null,
|
||||
polarClientSecretEncrypted: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export interface PolarConnection {
|
||||
accessToken: string;
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
/**
|
||||
* Per-user Oura BYO-key credentials. Each self-hoster registers their own Oura
|
||||
* app and pastes the client id/secret into Settings. Stored encrypted on
|
||||
* `User`. When unset, the integration falls back to the shared env-configured
|
||||
* OAuth app.
|
||||
*/
|
||||
export const ouraCredentialsSchema = z.object({
|
||||
// Trimmed: a trailing space or newline from the portal's copy button reaches
|
||||
// Oura verbatim and answers as "unknown client".
|
||||
clientId: z.string().trim().min(1).max(200),
|
||||
clientSecret: z.string().trim().min(1).max(200),
|
||||
});
|
||||
|
||||
export type OuraCredentialsInput = z.infer<typeof ouraCredentialsSchema>;
|
||||
@@ -0,0 +1,16 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
/**
|
||||
* Per-user Polar BYO-key credentials. Each self-hoster registers their own
|
||||
* Polar AccessLink app and pastes the client id/secret into Settings. Stored
|
||||
* encrypted on `User`. When unset, the integration falls back to the shared
|
||||
* env-configured OAuth app.
|
||||
*/
|
||||
export const polarCredentialsSchema = z.object({
|
||||
// Trimmed: a trailing space or newline from the portal's copy button reaches
|
||||
// Polar verbatim and answers as "unknown client".
|
||||
clientId: z.string().trim().min(1).max(200),
|
||||
clientSecret: z.string().trim().min(1).max(200),
|
||||
});
|
||||
|
||||
export type PolarCredentialsInput = z.infer<typeof polarCredentialsSchema>;
|
||||
Reference in new issue
Block a user