Files
HealthLog/src/components/settings/integrations/setup-guide-link.tsx
T
Marc-André Bombeck 05d3736d75 Resolve OAuth callback URLs on the server for integration cards
The v1.38.0 notice that warns when the configured OAuth callback origin
differs from the origin the browser is on never rendered in the published
image, and the copyable callback address in the redirect guide showed a
bare path. `integrationCallbackUrl()` read `process.env.NEXT_PUBLIC_APP_URL`
inside a "use client" module. Next.js inlines NEXT_PUBLIC_* into client
bundles at build time, and the GHCR image is built without that variable
(only the version, build-sha and built-at args reach the build), so the
base compiled to an empty string, `new URL("/api/<provider>/callback")`
threw, and the notice returned null on every card for every self-hoster.

The value now comes from the server at request time. A new map
(`src/lib/integrations/callback-urls.ts`) calls each provider's own
redirect-URI function (the four private `getRedirectUri()` helpers are
exported under provider-specific names; Polar, Oura and Strava already
exported theirs), so the page shows exactly what the handshake registers,
explicit `*_REDIRECT_URI` override included. The settings route resolves
the map and passes it as a prop through `IntegrationsSection` and
`ConnectionsPanel` to every card. Fitbit and Google Health validate their
URI and throw on a bad configuration; that surfaces as `null`, the notice
skips the comparison and the guide falls back to the fixed path.

Tests: a map test proving the explicit override wins and the derived form
is used otherwise, a component test rendering the notice from a passed
value (mismatch renders, match does not), and a structural guard that
fails when any "use client" file under src/components/settings/integrations
reads NEXT_PUBLIC_APP_URL.
2026-09-01 21:00:12 +02:00

182 lines
6.1 KiB
TypeScript

"use client";
/**
* v1.17.1 — shared "Setup guide" doc-link for every Settings → Integrations
* card. Each card paints the same affordance: one discreet, external link to
* the provider's setup runbook ("was eingeben, wo klicken"). Single-sourced
* here so all six cards (WHOOP / Withings / Fitbit / Polar / Oura / Nightscout)
* read as one family and the docs host lives in exactly one place.
*
* The runbooks themselves are authored separately; this link wires the
* destination now so a user who is mid-setup always knows where to go.
*/
import { useSyncExternalStore } from "react";
import { ExternalLink } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
/**
* Base for every provider setup runbook. The provider key is appended as a
* path segment, e.g. `https://docs.healthlog.dev/integrations/whoop`. Kept as
* a single constant so the host never drifts across cards.
*/
export const INTEGRATION_DOCS_BASE = "https://docs.healthlog.dev/integrations";
export type IntegrationDocsProvider =
| "whoop"
| "withings"
| "fitbit"
| "polar"
| "oura"
| "nightscout"
// v1.27.0 — Google Health runbook (docs.healthlog.dev/integrations/google-health).
| "google-health"
// v1.28.x — Strava runbook (docs.healthlog.dev/integrations/strava).
| "strava";
export function integrationDocsHref(provider: IntegrationDocsProvider): string {
return `${INTEGRATION_DOCS_BASE}/${provider}`;
}
/**
* Shows a compact warning when the callback origin differs from the app.
*
* `callbackUrl` is the value the server registers as `redirect_uri`, resolved
* per request by `getIntegrationCallbackUrls()` and passed down as a prop. It
* must not be derived here: a `NEXT_PUBLIC_*` read in a client module is
* inlined at build time, and the published image is built without it.
* `null` means the provider cannot build a redirect URI from the current env,
* so there is nothing to compare.
*/
export function CallbackMismatchNotice({
provider,
callbackUrl,
}: {
provider: string;
callbackUrl: string | null;
}) {
// Get the current origin of the app
const { t } = useTranslations();
const currentOrigin = useSyncExternalStore(
() => () => {},
() => window.location.origin,
() => null,
);
// Get the origin of the callback URL
const callbackOrigin = (() => {
if (!callbackUrl) return null;
try {
return new URL(callbackUrl).origin;
} catch {
return null;
}
})();
if (!currentOrigin || !callbackOrigin || callbackOrigin === currentOrigin) {
return null;
}
// If the callback origin differs from the app, show a warning
return (
<span className="text-warning border-warning/30 bg-warning/5 block rounded-md border px-2 py-1 text-xs">
{t("settings.oauthCallbackPreflightItem", {
provider,
configuredOrigin: callbackOrigin,
currentOrigin,
})}
</span>
);
}
/**
* v1.29.x (UX audit H2) — compact 3-step BYO-developer-app guide for the
* six providers that require a self-hoster to register their own vendor
* app before an OAuth connect can ever succeed (Withings / WHOOP / Fitbit /
* Polar / Oura / Strava). The external "Setup guide" link explains the
* *why*; this inline callout gives the one value every vendor form asks
* for and the app already knows — the callback URL — so it doesn't have to
* be reconstructed by hand from the docs. Shown only while the user hasn't
* saved their own credentials yet; it steps out of the way once configured.
*/
export function IntegrationRedirectGuide({
provider,
providerLabel,
callbackUrl,
}: {
provider: IntegrationDocsProvider;
providerLabel: string;
/**
* The server-resolved callback URL (see `CallbackMismatchNotice`). When the
* provider cannot build one from the current env, show the fixed path so
* the step still names what the vendor form expects.
*/
callbackUrl: string | null;
}) {
const { t } = useTranslations();
const shownCallbackUrl = callbackUrl ?? `/api/${provider}/callback`;
return (
<div
className="bg-muted/40 border-border/60 space-y-1.5 rounded-md border p-3 text-xs"
data-testid={`${provider}-redirect-guide`}
>
<p className="text-muted-foreground">
{t("settings.integrationRedirectGuide.intro", {
provider: providerLabel,
})}
</p>
<ol className="text-muted-foreground list-decimal space-y-1 pl-4">
<li>
{t("settings.integrationRedirectGuide.step1", {
provider: providerLabel,
})}
</li>
<li>
{t("settings.integrationRedirectGuide.step2")}{" "}
<code
className="bg-background text-foreground rounded px-1 py-0.5 font-mono break-all"
data-testid={`${provider}-redirect-uri`}
>
{shownCallbackUrl}
</code>
</li>
<li>{t("settings.integrationRedirectGuide.step3")}</li>
</ol>
</div>
);
}
/**
* The one description every integration card paints: ONE sentence, then the
* setup-guide link inline at the end of it.
*
* It used to be two paragraphs stacked in the description slot — a white
* primary sentence and a grey secondary one whose only job was to carry the
* link. The secondary sentence was byte-identical across six of the eight
* cards, which is the tell: it said nothing about the provider it sat under.
* The link is the part that carried information, so the link is what stayed.
*/
export function IntegrationCardDescription({
i18nPrefix,
provider,
}: {
i18nPrefix: string;
provider: IntegrationDocsProvider;
}) {
const { t } = useTranslations();
return (
<>
{t(`${i18nPrefix}Description`)}{" "}
<a
href={integrationDocsHref(provider)}
target="_blank"
rel="noopener noreferrer"
data-testid={`${provider}-setup-guide`}
data-slot="integration-setup-guide"
className="hover:text-foreground inline-flex items-center gap-1 underline underline-offset-2"
>
{t("settings.integrationSetupGuide")}
<ExternalLink className="h-3 w-3" aria-hidden="true" />
</a>
</>
);
}