mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +00:00
fix(onboarding): stop the Withings connect dead-end and disclose BYO setup
The onboarding "Connect Withings" tap opened /api/withings/connect in a new tab, which returned a raw JSON 400 for every brand-new account (Withings credentials are per-user BYO with no env fallback, and the wizard never collects them). WHOOP, Fitbit, Polar, Oura, and Strava shared the identical raw-JSON-on-no-creds pattern on their own /connect routes, while every other failure branch already redirected with a toast reason. All six now redirect to /settings/integrations?<provider>=error&reason=nocreds instead, and the wizard's Withings card checks the integrations-status envelope first: unless credentials are already configured, the CTA reads "Set up in Settings" and links straight to the credentials form rather than launching a doomed OAuth handshake. Also surfaces the BYO-developer-app requirement, which was invisible until a user was deep inside a Settings card. The source-picker step now says up front that device sources need a free developer app on the user's own account; each of the six provider cards gets a compact 3-step guide with the exact callback URL to register, shown until the user has saved their own credentials.
This commit is contained in:
1 parent
4f7420905a
commit
fb73c97125
27 files changed
+744
-73
No files matched your search
@@ -148,3 +148,33 @@ describe("OAuthProviderCard — per-user BYO credentials form (v1.17.1)", () =>
|
||||
expect(html).toContain("Saved — enter new to replace");
|
||||
});
|
||||
});
|
||||
|
||||
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";
|
||||
statusPayload = {
|
||||
connected: false,
|
||||
configured: false,
|
||||
available: true,
|
||||
hasOwnCredentials: false,
|
||||
};
|
||||
const html = render({ credentials: true });
|
||||
expect(html).toContain('data-testid="polar-redirect-guide"');
|
||||
expect(html).toContain('data-testid="polar-redirect-uri"');
|
||||
expect(html).toContain("https://app.example/api/polar/callback");
|
||||
});
|
||||
|
||||
it("hides the guide once the user has stored their own credentials", () => {
|
||||
statusPayload = {
|
||||
connected: true,
|
||||
configured: true,
|
||||
available: true,
|
||||
hasOwnCredentials: true,
|
||||
state: "connected",
|
||||
lastSuccessAt: null,
|
||||
lastError: null,
|
||||
};
|
||||
const html = render({ credentials: true });
|
||||
expect(html).not.toContain('data-testid="polar-redirect-guide"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* v1.29.x — UX audit H2: the WHOOP card shows a compact callback-URL guide
|
||||
* while the account has no BYO credentials saved, and steps out of the way
|
||||
* once configured. Mirrors the OAuthProviderCard coverage for Polar/Oura.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { WhoopCard } from "../whoop-card";
|
||||
import type { IntegrationStatusViewModel } from "../shared";
|
||||
|
||||
function render(viewModel?: Partial<IntegrationStatusViewModel>) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<WhoopCard
|
||||
viewModel={viewModel as IntegrationStatusViewModel | undefined}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
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("hides the guide once credentials are configured", () => {
|
||||
const html = render({ connected: false, configured: true });
|
||||
expect(html).not.toContain('data-testid="whoop-redirect-guide"');
|
||||
});
|
||||
});
|
||||
@@ -52,7 +52,10 @@ import {
|
||||
pillStateFor,
|
||||
type IntegrationStatusViewModel,
|
||||
} from "./shared";
|
||||
import { IntegrationCardDescription } from "./setup-guide-link";
|
||||
import {
|
||||
IntegrationCardDescription,
|
||||
IntegrationRedirectGuide,
|
||||
} from "./setup-guide-link";
|
||||
|
||||
export function FitbitCard({
|
||||
viewModel,
|
||||
@@ -283,6 +286,12 @@ export function FitbitCard({
|
||||
<h3 className="text-sm font-semibold">
|
||||
{t("settings.fitbitCredentials")}
|
||||
</h3>
|
||||
{!status?.configured && (
|
||||
<IntegrationRedirectGuide
|
||||
provider="fitbit"
|
||||
providerLabel={t("settings.fitbit")}
|
||||
/>
|
||||
)}
|
||||
<form onSubmit={handleSaveCredentials} className="space-y-3">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
} from "@/lib/query-keys";
|
||||
import {
|
||||
IntegrationCardDescription,
|
||||
IntegrationRedirectGuide,
|
||||
type IntegrationDocsProvider,
|
||||
} from "./setup-guide-link";
|
||||
|
||||
@@ -277,6 +278,12 @@ export function OAuthProviderCard({
|
||||
<h3 className="text-sm font-semibold">
|
||||
{t(`${i18nPrefix}Credentials`)}
|
||||
</h3>
|
||||
{!status?.hasOwnCredentials && (
|
||||
<IntegrationRedirectGuide
|
||||
provider={provider}
|
||||
providerLabel={t(i18nPrefix)}
|
||||
/>
|
||||
)}
|
||||
<form onSubmit={handleSaveCredentials} className="space-y-3">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
|
||||
@@ -38,6 +38,69 @@ export function integrationDocsHref(provider: IntegrationDocsProvider): string {
|
||||
return `${INTEGRATION_DOCS_BASE}/${provider}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function integrationCallbackUrl(
|
||||
provider: IntegrationDocsProvider,
|
||||
): string {
|
||||
const base = process.env.NEXT_PUBLIC_APP_URL ?? "";
|
||||
return `${base}/api/${provider}/callback`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
}: {
|
||||
provider: IntegrationDocsProvider;
|
||||
providerLabel: string;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const callbackUrl = integrationCallbackUrl(provider);
|
||||
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`}
|
||||
>
|
||||
{callbackUrl}
|
||||
</code>
|
||||
</li>
|
||||
<li>{t("settings.integrationRedirectGuide.step3")}</li>
|
||||
</ol>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.18.7 (Wave F) — the one description skeleton every integration card paints:
|
||||
* a WHITE primary sentence (`${i18nPrefix}Description`), then a GREY secondary
|
||||
|
||||
@@ -44,7 +44,10 @@ import {
|
||||
pillStateFor,
|
||||
type IntegrationStatusViewModel,
|
||||
} from "./shared";
|
||||
import { IntegrationCardDescription } from "./setup-guide-link";
|
||||
import {
|
||||
IntegrationCardDescription,
|
||||
IntegrationRedirectGuide,
|
||||
} from "./setup-guide-link";
|
||||
|
||||
export function WhoopCard({
|
||||
viewModel,
|
||||
@@ -264,6 +267,12 @@ export function WhoopCard({
|
||||
<h3 className="text-sm font-semibold">
|
||||
{t("settings.whoopCredentials")}
|
||||
</h3>
|
||||
{!status?.configured && (
|
||||
<IntegrationRedirectGuide
|
||||
provider="whoop"
|
||||
providerLabel={t("settings.whoop")}
|
||||
/>
|
||||
)}
|
||||
<form onSubmit={handleSaveCredentials} className="space-y-3">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
|
||||
@@ -37,7 +37,10 @@ import {
|
||||
pillStateFor,
|
||||
type IntegrationStatusViewModel,
|
||||
} from "./shared";
|
||||
import { IntegrationCardDescription } from "./setup-guide-link";
|
||||
import {
|
||||
IntegrationCardDescription,
|
||||
IntegrationRedirectGuide,
|
||||
} from "./setup-guide-link";
|
||||
|
||||
export function WithingsCard({
|
||||
viewModel,
|
||||
@@ -299,6 +302,12 @@ export function WithingsCard({
|
||||
<h3 className="text-sm font-semibold">
|
||||
{t("settings.withingsCredentials")}
|
||||
</h3>
|
||||
{!status?.configured && (
|
||||
<IntegrationRedirectGuide
|
||||
provider="withings"
|
||||
providerLabel={t("settings.withings")}
|
||||
/>
|
||||
)}
|
||||
<form onSubmit={handleSaveCredentials} className="space-y-3">
|
||||
{/* v1.4.27 MB7 / CF-53 — the credentials grid drops from
|
||||
a 3-column row (client-id / secret / save) to a
|
||||
|
||||
Reference in new issue
Block a user