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.
This commit is contained in:
Marc-André Bombeck committed 2026-09-01 21:00:12 +02:00
1 parent 97b1603cb9
commit 05d3736d75
25 files changed
+516 -57

No files matched your search

@@ -75,10 +75,20 @@ vi.mock("@tanstack/react-query", () => ({
import { I18nProvider } from "@/lib/i18n/context";
import { IntegrationsSection } from "../integrations-section";
const CALLBACK_URLS = {
withings: "https://app.example/api/withings/callback",
whoop: "https://app.example/api/whoop/callback",
fitbit: "https://app.example/api/fitbit/callback",
"google-health": "https://app.example/api/google-health/callback",
polar: "https://app.example/api/polar/callback",
oura: "https://app.example/api/oura/callback",
strava: "https://app.example/api/strava/callback",
};
function render() {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<IntegrationsSection />
<IntegrationsSection callbackUrls={CALLBACK_URLS} />
</I18nProvider>,
);
}
@@ -104,6 +104,16 @@ import { NotificationsSection } from "../notifications-section";
// slug like every other settings section.
import { ThresholdsSection } from "../thresholds-section";
const CALLBACK_URLS = {
withings: "https://app.example/api/withings/callback",
whoop: "https://app.example/api/whoop/callback",
fitbit: "https://app.example/api/fitbit/callback",
"google-health": "https://app.example/api/google-health/callback",
polar: "https://app.example/api/polar/callback",
oura: "https://app.example/api/oura/callback",
strava: "https://app.example/api/strava/callback",
};
function render(node: React.ReactElement, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
@@ -184,7 +194,10 @@ describe("settings sections — SSR smoke", () => {
});
it("<IntegrationsSection> renders Withings card + the delivery-channels group", () => {
const html = renderFramed("integrations", <IntegrationsSection />);
const html = renderFramed(
"integrations",
<IntegrationsSection callbackUrls={CALLBACK_URLS} />,
);
expect(html).toContain("Integrations");
expect(html).toContain("Withings");
// v1.25.7 — delivery channels moved here from Notifications: the
@@ -32,6 +32,7 @@ import Link from "next/link";
import { Link2, Send } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls";
import { ConnectionsPanel } from "@/components/settings/integrations/connections-panel";
import { SectionHeading } from "@/components/ui/section-heading";
import { NotificationChannelsPanel } from "@/components/settings/integrations/notification-channels-panel";
@@ -43,7 +44,12 @@ export {
oauthReasonKey,
} from "@/components/settings/integrations/connections-panel";
export function IntegrationsSection() {
export function IntegrationsSection({
callbackUrls,
}: {
/** Server-resolved per-provider OAuth callback URLs; see `ConnectionsPanel`. */
callbackUrls: IntegrationCallbackUrls;
}) {
const { t } = useTranslations();
// v1.18.6 (W9) — the visible page heading + subtitle come from the shared
@@ -59,7 +65,7 @@ export function IntegrationsSection() {
icon={Link2}
title={t("settings.sections.integrations.connectionsHeading")}
/>
<ConnectionsPanel />
<ConnectionsPanel callbackUrls={callbackUrls} />
{/* 2026-07-17 UX/IA audit M7 — the opt-in nutrients sync inventory
(moved from Settings → Sources, where "source priority" only
@@ -0,0 +1,75 @@
/**
* `<CallbackMismatchNotice>` renders from the callback URL it is HANDED, not
* from anything it reads itself. The origin the browser is on comes through
* `useSyncExternalStore`, whose server snapshot is deliberately `null` (the
* server cannot know the browser origin, and a notice that flips at hydration
* would flash). A static render therefore never shows it; to exercise the
* client path here the hook is replaced with its client snapshot and a bare
* `window.location.origin` is provided.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
vi.mock("react", async (importOriginal) => {
const actual = await importOriginal<typeof import("react")>();
return {
...actual,
useSyncExternalStore: <T,>(
_subscribe: (onChange: () => void) => () => void,
getSnapshot: () => T,
): T => getSnapshot(),
};
});
import { I18nProvider } from "@/lib/i18n/context";
import { CallbackMismatchNotice } from "../setup-guide-link";
const BROWSER_ORIGIN = "https://health.example";
function render(callbackUrl: string | null) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<CallbackMismatchNotice provider="WHOOP" callbackUrl={callbackUrl} />
</I18nProvider>,
);
}
describe("<CallbackMismatchNotice>", () => {
const priorWindow = (globalThis as { window?: unknown }).window;
beforeEach(() => {
(globalThis as { window?: unknown }).window = {
location: { origin: BROWSER_ORIGIN },
};
});
afterEach(() => {
if (priorWindow === undefined) {
delete (globalThis as { window?: unknown }).window;
} else {
(globalThis as { window?: unknown }).window = priorWindow;
}
});
it("renders the warning when the configured callback origin differs from the browser origin", () => {
const html = render("https://edge.example/api/whoop/callback");
expect(html).toContain("WHOOP");
expect(html).toContain("https://edge.example");
expect(html).toContain(BROWSER_ORIGIN);
expect(html).toContain("differs from this app address");
});
it("renders nothing when the callback origin matches the browser origin", () => {
expect(render(`${BROWSER_ORIGIN}/api/whoop/callback`)).toBe("");
});
it("renders nothing when the server could not produce a callback URL", () => {
expect(render(null)).toBe("");
});
it("renders nothing for a callback URL that is not absolute", () => {
// The pre-fix client bundle produced exactly this shape and the notice
// stayed silent; it still must, rather than throwing out of the card.
expect(render("/api/whoop/callback")).toBe("");
});
});
@@ -84,10 +84,20 @@ function fullEnvelope() {
};
}
const CALLBACK_URLS = {
withings: "https://app.example/api/withings/callback",
whoop: "https://app.example/api/whoop/callback",
fitbit: "https://app.example/api/fitbit/callback",
"google-health": "https://app.example/api/google-health/callback",
polar: "https://app.example/api/polar/callback",
oura: "https://app.example/api/oura/callback",
strava: "https://app.example/api/strava/callback",
};
function render() {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<ConnectionsPanel />
<ConnectionsPanel callbackUrls={CALLBACK_URLS} />
</I18nProvider>,
);
}
@@ -41,6 +41,7 @@ function render({
dataHref="/insights/sleep"
credentials={credentials}
viewModel={viewModel}
callbackUrl="https://app.example/api/polar/callback"
/>
</I18nProvider>,
);
@@ -162,7 +163,6 @@ describe("OAuthProviderCard — per-user BYO credentials form (v1.17.1)", () =>
describe("OAuthProviderCard — redirect-URI mini-guide (v1.29.x, UX audit H2)", () => {
it("shows the callback URL guide before the user has BYO credentials", () => {
process.env.NEXT_PUBLIC_APP_URL = "https://app.example";
const html = render({
credentials: true,
viewModel: {
@@ -15,11 +15,15 @@ import { I18nProvider } from "@/lib/i18n/context";
import { WhoopCard } from "../whoop-card";
import type { IntegrationStatusViewModel } from "../shared";
function render(viewModel?: Partial<IntegrationStatusViewModel>) {
function render(
viewModel?: Partial<IntegrationStatusViewModel>,
callbackUrl: string | null = "https://app.example/api/whoop/callback",
) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<WhoopCard
viewModel={viewModel as IntegrationStatusViewModel | undefined}
callbackUrl={callbackUrl}
/>
</I18nProvider>,
);
@@ -27,13 +31,19 @@ function render(viewModel?: Partial<IntegrationStatusViewModel>) {
describe("<WhoopCard> redirect-URI mini-guide", () => {
it("shows the callback URL guide before credentials are configured", () => {
process.env.NEXT_PUBLIC_APP_URL = "https://app.example";
const html = render({ connected: false, configured: false });
expect(html).toContain('data-testid="whoop-redirect-guide"');
expect(html).toContain('data-testid="whoop-redirect-uri"');
expect(html).toContain("https://app.example/api/whoop/callback");
});
it("falls back to the fixed path when the server has no redirect URI", () => {
const html = render({ connected: false, configured: false }, null);
expect(html).toContain('data-testid="whoop-redirect-uri"');
expect(html).toContain("/api/whoop/callback");
expect(html).not.toContain("undefined/api/whoop/callback");
});
it("hides the guide once credentials are configured", () => {
const html = render({ connected: false, configured: true });
expect(html).not.toContain('data-testid="whoop-redirect-guide"');
@@ -35,6 +35,7 @@ import {
import { WhoopCard } from "@/components/settings/integrations/whoop-card";
import { WithingsCard } from "@/components/settings/integrations/withings-card";
import { useAuth } from "@/hooks/use-auth";
import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
@@ -209,7 +210,17 @@ const CARD_BACKED_INTEGRATIONS: ReadonlySet<IntegrationKey> = new Set([
"nightscout",
]);
export function ConnectionsPanel() {
export function ConnectionsPanel({
callbackUrls,
}: {
/**
* The per-provider OAuth callback URLs, resolved on the server per request
* (`getIntegrationCallbackUrls`) and threaded down from the route. A client
* module cannot derive them: `NEXT_PUBLIC_APP_URL` is inlined at build
* time and absent from the published image.
*/
callbackUrls: IntegrationCallbackUrls;
}) {
const { t } = useTranslations();
const { isAuthenticated } = useAuth();
const router = useRouter();
@@ -328,28 +339,46 @@ export function ConnectionsPanel() {
{/* Per-provider anchors so deep-links (onboarding "more sources" chips,
`/settings/integrations#oura`) scroll straight to the right card. */}
<div id="withings" className="scroll-mt-28">
<WithingsCard viewModel={withingsViewModel} />
<WithingsCard
viewModel={withingsViewModel}
callbackUrl={callbackUrls.withings}
/>
</div>
<div id="whoop" className="scroll-mt-28">
<WhoopCard viewModel={whoopViewModel} />
<WhoopCard
viewModel={whoopViewModel}
callbackUrl={callbackUrls.whoop}
/>
</div>
<div id="fitbit" className="scroll-mt-28">
<FitbitCard viewModel={fitbitViewModel} />
<FitbitCard
viewModel={fitbitViewModel}
callbackUrl={callbackUrls.fitbit}
/>
</div>
<div id="google-health" className="scroll-mt-28">
<GoogleHealthCard viewModel={googleHealthViewModel} />
<GoogleHealthCard
viewModel={googleHealthViewModel}
callbackUrl={callbackUrls["google-health"]}
/>
</div>
<div id="apple-health" className="scroll-mt-28">
<AppleHealthCard enabled={isAuthenticated} />
</div>
<div id="polar" className="scroll-mt-28">
<PolarCard viewModel={polarViewModel} />
<PolarCard
viewModel={polarViewModel}
callbackUrl={callbackUrls.polar}
/>
</div>
<div id="oura" className="scroll-mt-28">
<OuraCard viewModel={ouraViewModel} />
<OuraCard viewModel={ouraViewModel} callbackUrl={callbackUrls.oura} />
</div>
<div id="strava" className="scroll-mt-28">
<StravaCard viewModel={stravaViewModel} />
<StravaCard
viewModel={stravaViewModel}
callbackUrl={callbackUrls.strava}
/>
</div>
<div id="nightscout" className="scroll-mt-28">
<NightscoutCard viewModel={nightscoutViewModel} />
@@ -68,13 +68,16 @@ import {
CallbackMismatchNotice,
IntegrationCardDescription,
IntegrationRedirectGuide,
integrationCallbackUrl,
} from "./setup-guide-link";
import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls";
export function FitbitCard({
viewModel,
callbackUrl,
}: {
viewModel: IntegrationStatusViewModel | undefined;
/** Server-resolved OAuth callback URL; see `getIntegrationCallbackUrls`. */
callbackUrl: IntegrationCallbackUrls["fitbit"];
}) {
const { t } = useTranslations();
const describeSyncOutcome = useSyncOutcomeMessage();
@@ -314,6 +317,7 @@ export function FitbitCard({
<IntegrationRedirectGuide
provider="fitbit"
providerLabel={t("settings.fitbit")}
callbackUrl={callbackUrl}
/>
)}
<form onSubmit={handleSaveCredentials} className="space-y-3">
@@ -525,7 +529,7 @@ export function FitbitCard({
</div>
<CallbackMismatchNotice
provider={t("settings.fitbit")}
callbackUrl={integrationCallbackUrl("fitbit")}
callbackUrl={callbackUrl}
/>
</SettingsCard>
);
@@ -83,13 +83,16 @@ import {
import {
CallbackMismatchNotice,
IntegrationCardDescription,
integrationCallbackUrl,
} from "./setup-guide-link";
import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls";
export function GoogleHealthCard({
viewModel,
callbackUrl,
}: {
viewModel: IntegrationStatusViewModel | undefined;
/** Server-resolved OAuth callback URL; see `getIntegrationCallbackUrls`. */
callbackUrl: IntegrationCallbackUrls["google-health"];
}) {
const { t } = useTranslations();
const describeSyncOutcome = useSyncOutcomeMessage();
@@ -764,7 +767,7 @@ export function GoogleHealthCard({
</div>
<CallbackMismatchNotice
provider={t("settings.googleHealth")}
callbackUrl={integrationCallbackUrl("google-health")}
callbackUrl={callbackUrl}
/>
</SettingsCard>
);
@@ -69,8 +69,8 @@ import {
IntegrationCardDescription,
IntegrationRedirectGuide,
type IntegrationDocsProvider,
integrationCallbackUrl,
} from "./setup-guide-link";
import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls";
export interface OAuthProviderStatus {
connected: boolean;
@@ -123,6 +123,8 @@ export interface OAuthProviderCardProps {
* exist.
*/
viewModel?: OAuthProviderStatus;
/** Server-resolved OAuth callback URL; see `getIntegrationCallbackUrls`. */
callbackUrl: IntegrationCallbackUrls[OAuthProviderCardProps["provider"]];
}
export function OAuthProviderCard({
@@ -133,6 +135,7 @@ export function OAuthProviderCard({
dataHref,
credentials = false,
viewModel,
callbackUrl,
}: OAuthProviderCardProps) {
const { t } = useTranslations();
const describeSyncOutcome = useSyncOutcomeMessage();
@@ -341,6 +344,7 @@ export function OAuthProviderCard({
<IntegrationRedirectGuide
provider={provider}
providerLabel={t(i18nPrefix)}
callbackUrl={callbackUrl}
/>
)}
<form onSubmit={handleSaveCredentials} className="space-y-3">
@@ -526,7 +530,7 @@ export function OAuthProviderCard({
</div>
<CallbackMismatchNotice
provider={t(i18nPrefix)}
callbackUrl={integrationCallbackUrl(provider)}
callbackUrl={callbackUrl}
/>
</SettingsCard>
);
@@ -10,9 +10,16 @@ import {
OAuthProviderCard,
type OAuthProviderStatus,
} from "@/components/settings/integrations/oauth-provider-card";
import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls";
import { queryKeys } from "@/lib/query-keys";
export function OuraCard({ viewModel }: { viewModel?: OAuthProviderStatus }) {
export function OuraCard({
viewModel,
callbackUrl,
}: {
viewModel?: OAuthProviderStatus;
callbackUrl: IntegrationCallbackUrls["oura"];
}) {
return (
<OAuthProviderCard
provider="oura"
@@ -22,6 +29,7 @@ export function OuraCard({ viewModel }: { viewModel?: OAuthProviderStatus }) {
dataHref="/insights/sleep"
credentials
viewModel={viewModel}
callbackUrl={callbackUrl}
/>
);
}
@@ -10,9 +10,16 @@ import {
OAuthProviderCard,
type OAuthProviderStatus,
} from "@/components/settings/integrations/oauth-provider-card";
import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls";
import { queryKeys } from "@/lib/query-keys";
export function PolarCard({ viewModel }: { viewModel?: OAuthProviderStatus }) {
export function PolarCard({
viewModel,
callbackUrl,
}: {
viewModel?: OAuthProviderStatus;
callbackUrl: IntegrationCallbackUrls["polar"];
}) {
return (
<OAuthProviderCard
provider="polar"
@@ -22,6 +29,7 @@ export function PolarCard({ viewModel }: { viewModel?: OAuthProviderStatus }) {
dataHref="/insights/sleep"
credentials
viewModel={viewModel}
callbackUrl={callbackUrl}
/>
);
}
@@ -40,25 +40,21 @@ export function integrationDocsHref(provider: IntegrationDocsProvider): string {
}
/**
* The OAuth callback path every BYO-key provider registers against. Every
* provider's `getRedirectUri()` (`src/lib/{provider}/client.ts`) derives the
* same shape from `NEXT_PUBLIC_APP_URL` unless an operator overrides it with
* an explicit `*_REDIRECT_URI` env var — the common case this card targets.
* 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 integrationCallbackUrl(
provider: IntegrationDocsProvider,
): string {
const base = process.env.NEXT_PUBLIC_APP_URL ?? "";
return `${base}/api/${provider}/callback`;
}
/** Shows a compact warning when the callback origin differs from the app. */
export function CallbackMismatchNotice({
provider,
callbackUrl,
}: {
provider: string;
callbackUrl: string;
callbackUrl: string | null;
}) {
// Get the current origin of the app
const { t } = useTranslations();
@@ -69,6 +65,7 @@ export function CallbackMismatchNotice({
);
// Get the origin of the callback URL
const callbackOrigin = (() => {
if (!callbackUrl) return null;
try {
return new URL(callbackUrl).origin;
} catch {
@@ -103,12 +100,19 @@ export function CallbackMismatchNotice({
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 callbackUrl = integrationCallbackUrl(provider);
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"
@@ -131,7 +135,7 @@ export function IntegrationRedirectGuide({
className="bg-background text-foreground rounded px-1 py-0.5 font-mono break-all"
data-testid={`${provider}-redirect-uri`}
>
{callbackUrl}
{shownCallbackUrl}
</code>
</li>
<li>{t("settings.integrationRedirectGuide.step3")}</li>
@@ -11,9 +11,16 @@ import {
OAuthProviderCard,
type OAuthProviderStatus,
} from "@/components/settings/integrations/oauth-provider-card";
import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls";
import { queryKeys } from "@/lib/query-keys";
export function StravaCard({ viewModel }: { viewModel?: OAuthProviderStatus }) {
export function StravaCard({
viewModel,
callbackUrl,
}: {
viewModel?: OAuthProviderStatus;
callbackUrl: IntegrationCallbackUrls["strava"];
}) {
return (
<OAuthProviderCard
provider="strava"
@@ -23,6 +30,7 @@ export function StravaCard({ viewModel }: { viewModel?: OAuthProviderStatus }) {
dataHref="/insights/workouts"
credentials
viewModel={viewModel}
callbackUrl={callbackUrl}
/>
);
}
@@ -59,13 +59,16 @@ import {
CallbackMismatchNotice,
IntegrationCardDescription,
IntegrationRedirectGuide,
integrationCallbackUrl,
} from "./setup-guide-link";
import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls";
export function WhoopCard({
viewModel,
callbackUrl,
}: {
viewModel: IntegrationStatusViewModel | undefined;
/** Server-resolved OAuth callback URL; see `getIntegrationCallbackUrls`. */
callbackUrl: IntegrationCallbackUrls["whoop"];
}) {
const { t } = useTranslations();
const describeSyncOutcome = useSyncOutcomeMessage();
@@ -296,6 +299,7 @@ export function WhoopCard({
<IntegrationRedirectGuide
provider="whoop"
providerLabel={t("settings.whoop")}
callbackUrl={callbackUrl}
/>
)}
<form onSubmit={handleSaveCredentials} className="space-y-3">
@@ -507,7 +511,7 @@ export function WhoopCard({
</div>
<CallbackMismatchNotice
provider={t("settings.whoop")}
callbackUrl={integrationCallbackUrl("whoop")}
callbackUrl={callbackUrl}
/>
</SettingsCard>
);
@@ -58,13 +58,16 @@ import {
CallbackMismatchNotice,
IntegrationCardDescription,
IntegrationRedirectGuide,
integrationCallbackUrl,
} from "./setup-guide-link";
import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls";
export function WithingsCard({
viewModel,
callbackUrl,
}: {
viewModel: IntegrationStatusViewModel | undefined;
/** Server-resolved OAuth callback URL; see `getIntegrationCallbackUrls`. */
callbackUrl: IntegrationCallbackUrls["withings"];
}) {
const { t } = useTranslations();
const describeSyncOutcome = useSyncOutcomeMessage();
@@ -335,6 +338,7 @@ export function WithingsCard({
<IntegrationRedirectGuide
provider="withings"
providerLabel={t("settings.withings")}
callbackUrl={callbackUrl}
/>
)}
<form onSubmit={handleSaveCredentials} className="space-y-3">
@@ -556,7 +560,7 @@ export function WithingsCard({
</div>
<CallbackMismatchNotice
provider={t("settings.withings")}
callbackUrl={integrationCallbackUrl("withings")}
callbackUrl={callbackUrl}
/>
</SettingsCard>
);