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:
Marc-André Bombeck committed 2026-07-17 23:12:12 +02:00
1 parent 4f7420905a
commit fb73c97125
27 files changed
+744 -73

No files matched your search

@@ -0,0 +1,115 @@
/**
* GET /api/fitbit/connect
*
* Pins: happy path mints a nonce + PKCE pair and redirects to the Fitbit
* consent screen; rate-limit and no-credentials both redirect back to
* `/settings/integrations?fitbit=error&reason=<tag>` rather than returning
* JSON — a browser-navigation entry point should never surface an unstyled
* JSON page.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
requireAuth: vi.fn(async () => ({ user: { id: "u-1" } })),
}));
vi.mock("@/lib/db", () => ({
prisma: { fitbitOAuthState: { create: vi.fn() } },
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
}));
vi.mock("@/lib/fitbit/credentials", () => ({
getUserFitbitCredentials: vi.fn(),
}));
vi.mock("@/lib/fitbit/client", () => ({
getAuthorizationUrl: vi.fn(
() => "https://www.fitbit.com/oauth2/authorize?state=nonce-stub",
),
generatePkcePair: vi.fn(() => ({
verifier: "verifier-stub",
challenge: "challenge-stub",
})),
}));
vi.mock("@/lib/fitbit/oauth-state", () => ({
FITBIT_OAUTH_STATE_COOKIE: "fitbit_oauth_state",
FITBIT_OAUTH_STATE_TTL_MS: 10 * 60 * 1000,
mintFitbitOAuthStateNonce: vi.fn(() => "nonce-stub"),
}));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
getEvent: vi.fn(() => ({ setError: vi.fn() })),
}));
vi.mock("@/lib/auth/secure-cookie", () => ({
shouldEmitSecureCookie: () => true,
}));
import { GET } from "../route";
import { prisma } from "@/lib/db";
import { checkRateLimit } from "@/lib/rate-limit";
import { getUserFitbitCredentials } from "@/lib/fitbit/credentials";
function connectRequest(): NextRequest {
return new NextRequest("http://localhost/api/fitbit/connect", {
method: "GET",
});
}
beforeEach(() => {
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 9,
resetAt: Date.now() + 60_000,
} as never);
vi.mocked(prisma.fitbitOAuthState.create).mockReset();
vi.mocked(prisma.fitbitOAuthState.create).mockResolvedValue({} as never);
vi.mocked(getUserFitbitCredentials).mockResolvedValue({
clientId: "client-id",
clientSecret: "client-secret",
} as never);
});
describe("GET /api/fitbit/connect", () => {
it("redirects to the Fitbit authorize URL on the happy path", async () => {
const response = await GET(connectRequest());
expect(response.status).toBe(307);
expect(response.headers.get("location")).toContain(
"www.fitbit.com/oauth2/authorize",
);
expect(prisma.fitbitOAuthState.create).toHaveBeenCalledTimes(1);
});
it("redirects to reason=rate_limited when the bucket is exhausted", async () => {
vi.mocked(checkRateLimit).mockResolvedValueOnce({
allowed: false,
remaining: 0,
resetAt: Date.now() + 60_000,
} as never);
const response = await GET(connectRequest());
const location = response.headers.get("location") ?? "";
expect(location).toContain("/settings/integrations");
expect(location).toContain("fitbit=error");
expect(location).toContain("reason=rate_limited");
expect(prisma.fitbitOAuthState.create).not.toHaveBeenCalled();
});
it("redirects to reason=nocreds when no per-user credentials are stored", async () => {
vi.mocked(getUserFitbitCredentials).mockResolvedValueOnce(null);
const response = await GET(connectRequest());
const location = response.headers.get("location") ?? "";
expect(location).toContain("/settings/integrations");
expect(location).toContain("fitbit=error");
expect(location).toContain("reason=nocreds");
expect(prisma.fitbitOAuthState.create).not.toHaveBeenCalled();
});
});
+5 -5
View File
@@ -1,5 +1,4 @@
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError } from "@/lib/api-response";
import { prisma } from "@/lib/db";
import { annotate, getEvent } from "@/lib/logging/context";
import { checkRateLimit } from "@/lib/rate-limit";
@@ -32,7 +31,8 @@ import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
* Rate-limited per user (10 calls / 60 s) so a logged-in session can't spam
* ledger rows for the full 10-min TTL window. Both the rate-limit and
* create-failure paths redirect (not JSON) because the entry point is a browser
* navigation — a 429 envelope would surface as a blank page.
* navigation — a 429 envelope would surface as a blank page. The
* no-credentials branch redirects too (v1.29.x), matching Withings/WHOOP.
*/
const CONNECT_RATE_LIMIT = 10;
const CONNECT_WINDOW_MS = 60_000;
@@ -58,9 +58,9 @@ export const GET = apiHandler(async (req: NextRequest) => {
const creds = await getUserFitbitCredentials(user.id);
if (!creds) {
return apiError(
"Please configure your Fitbit OAuth Client ID and Client Secret in Settings first.",
400,
annotate({ action: { name: "fitbit.connect.no_credentials" } });
return NextResponse.redirect(
new URL("/settings/integrations?fitbit=error&reason=nocreds", req.url),
);
}