mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 06:32:03 +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
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user