From 72986bff41f03dc13c0f5a722c0c55ae90bcfac9 Mon Sep 17 00:00:00 2001 From: Amr Gaber Date: Wed, 27 May 2026 13:04:07 -0500 Subject: [PATCH] fix(profile): render associate-flow errors as an alert instead of raw JSON MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When linking a provider fails (most commonly oauth_account_already_linked), the backend now 302s back to /profile?associate_error=&associate_provider=

instead of returning JSON. Render the result as a dismissible destructive alert in the Connected accounts section, then scrub the URL via navigate({replace: true}) so a refresh doesn't re-trigger. - New src/lib/associate-errors.ts holds the per-provider copy (also used by the dev fetch path in the steam/google callback pages, which were duplicating the same map). - profile route validates the pair atomically — if either param is missing or the provider is unknown, both are dropped. - Tests cover the happy path, generic fallback, dismiss action, and the malformed-search guard. --- src/lib/associate-errors.ts | 48 +++++++++ src/pages/callback/google-callback-page.tsx | 20 +--- src/pages/callback/steam-callback-page.tsx | 20 +--- src/pages/profile/profile-page.test.tsx | 102 ++++++++++++++++++++ src/pages/profile/profile-page.tsx | 45 ++++++++- src/routes/profile.tsx | 13 +++ 6 files changed, 211 insertions(+), 37 deletions(-) create mode 100644 src/lib/associate-errors.ts diff --git a/src/lib/associate-errors.ts b/src/lib/associate-errors.ts new file mode 100644 index 0000000..2f5c64e --- /dev/null +++ b/src/lib/associate-errors.ts @@ -0,0 +1,48 @@ +// Friendly copy for `oauth_*` codes the backend can raise during an +// associate (link-to-current-user) flow. Used in two places: +// 1. The dev fetch path in CallbackPage, when the API still +// replies with JSON because the SPA is mediating. +// 2. The profile page, when the API redirects to /profile with the +// code in the query string (prod path; the user lands directly +// back on profile rather than seeing raw JSON). + +export type AssociateProvider = "google" | "steam" + +const PROVIDER_LABELS: Record = { + google: "Google", + steam: "Steam", +} + +export function providerLabel(provider: AssociateProvider): string { + return PROVIDER_LABELS[provider] +} + +export function associateErrorMessage( + code: string, + provider: AssociateProvider +): string { + const label = providerLabel(provider) + switch (code) { + case "oauth_account_already_linked": + return `This ${label} account is already linked to another criticalbit account. Unlink it there first.` + case "oauth_state_invalid": + case "oauth_state_missing": + case "oauth_state_wrong_purpose": + return "Your link session is invalid. Please return to your profile and try again." + case "oauth_state_expired": + case "oauth_csrf_mismatch": + return "Your link session expired. Please return to your profile and try again." + case "oauth_state_user_mismatch": + return "Something went wrong linking your account. Please try again." + case "oauth_verify_failed": + return `${label} rejected the response. Please try again.` + default: + return `Linking your ${label} account failed. Please try again.` + } +} + +export function isAssociateProvider( + value: unknown +): value is AssociateProvider { + return value === "google" || value === "steam" +} diff --git a/src/pages/callback/google-callback-page.tsx b/src/pages/callback/google-callback-page.tsx index e0319b9..3f7445b 100644 --- a/src/pages/callback/google-callback-page.tsx +++ b/src/pages/callback/google-callback-page.tsx @@ -1,24 +1,11 @@ import { useEffect, useRef, useState } from "react" import { baseUrl } from "@/api/api" +import { associateErrorMessage } from "@/lib/associate-errors" import { readPurposeFromStateParam, type OAuthPurpose } from "@/lib/oauth-state" const OAUTH_USER_ALREADY_EXISTS_MESSAGE = "An unverified account already exists for this email. Sign in with your password, verify your email, then link Google from your profile." -const ASSOCIATE_ERROR_MESSAGES: Record = { - oauth_account_already_linked: - "This Google account is already linked to another criticalbit account. Unlink it there first.", - oauth_state_invalid: - "Your link session is invalid. Please return to your profile and try again.", - oauth_state_expired: - "Your link session expired. Please return to your profile and try again.", - oauth_csrf_mismatch: - "Your link session expired. Please return to your profile and try again.", - oauth_state_user_mismatch: - "Something went wrong linking your account. Please try again.", - oauth_verify_failed: "Google rejected the response. Please try again.", -} - async function readDetail(res: Response): Promise { try { return (await res.clone().json())?.detail @@ -75,10 +62,7 @@ export function GoogleCallbackPage() { if (!res.ok) { const code = detailCode(await readDetail(res)) if (purpose === "associate") { - setError( - (code && ASSOCIATE_ERROR_MESSAGES[code]) ?? - "Linking your Google account failed. Please try again." - ) + setError(associateErrorMessage(code ?? "", "google")) } else if (code === "OAUTH_USER_ALREADY_EXISTS") { setError(OAUTH_USER_ALREADY_EXISTS_MESSAGE) } else { diff --git a/src/pages/callback/steam-callback-page.tsx b/src/pages/callback/steam-callback-page.tsx index 6c8ea17..d2bf096 100644 --- a/src/pages/callback/steam-callback-page.tsx +++ b/src/pages/callback/steam-callback-page.tsx @@ -1,21 +1,8 @@ import { useEffect, useRef, useState } from "react" import { baseUrl } from "@/api/api" +import { associateErrorMessage } from "@/lib/associate-errors" import { readPurposeFromStateParam, type OAuthPurpose } from "@/lib/oauth-state" -const ASSOCIATE_ERROR_MESSAGES: Record = { - oauth_account_already_linked: - "This Steam account is already linked to another criticalbit account. Unlink it there first.", - oauth_state_invalid: - "Your link session is invalid. Please return to your profile and try again.", - oauth_state_expired: - "Your link session expired. Please return to your profile and try again.", - oauth_csrf_mismatch: - "Your link session expired. Please return to your profile and try again.", - oauth_state_user_mismatch: - "Something went wrong linking your account. Please try again.", - oauth_verify_failed: "Steam rejected the response. Please try again.", -} - async function readDetailCode(res: Response): Promise { try { const detail = (await res.clone().json())?.detail @@ -69,10 +56,7 @@ export function SteamCallbackPage() { if (!res.ok) { if (purpose === "associate") { const code = await readDetailCode(res) - setError( - (code && ASSOCIATE_ERROR_MESSAGES[code]) ?? - "Linking your Steam account failed. Please try again." - ) + setError(associateErrorMessage(code ?? "", "steam")) } else { setError("Steam sign-in failed. Please try again.") } diff --git a/src/pages/profile/profile-page.test.tsx b/src/pages/profile/profile-page.test.tsx index 842b7f1..8dc0ca6 100644 --- a/src/pages/profile/profile-page.test.tsx +++ b/src/pages/profile/profile-page.test.tsx @@ -364,6 +364,108 @@ describe("ProfilePage connected accounts", () => { ) }) + it("renders the associate error alert when the API redirects with ?associate_error", async () => { + const me = authMeHandler({ + id: "u-1", + email: "player@example.com", + display_name: null, + avatar_url: null, + tos_accepted_at: "2026-01-01T00:00:00Z", + }) + server.use( + me.handler, + consentsHandler, + http.get("*/auth/me/connections", () => HttpResponse.json([])) + ) + + const { history } = await renderWithFileRoutes(<>, { + initialLocation: + "/profile?associate_error=oauth_account_already_linked&associate_provider=steam", + }) + + const alert = await screen.findByTestId("associate-error") + expect(alert).toHaveTextContent( + /this steam account is already linked to another criticalbit account/i + ) + // The error params should be scrubbed from the URL so a refresh + // doesn't re-trigger the alert. + await waitFor(() => + expect(history.location.search).not.toContain("associate_error") + ) + }) + + it("falls back to a generic message for an unknown associate_error code", async () => { + const me = authMeHandler({ + id: "u-1", + email: "player@example.com", + display_name: null, + avatar_url: null, + tos_accepted_at: "2026-01-01T00:00:00Z", + }) + server.use( + me.handler, + consentsHandler, + http.get("*/auth/me/connections", () => HttpResponse.json([])) + ) + + await renderWithFileRoutes(<>, { + initialLocation: + "/profile?associate_error=something_new&associate_provider=google", + }) + + const alert = await screen.findByTestId("associate-error") + expect(alert).toHaveTextContent(/linking your google account failed/i) + }) + + it("dismisses the associate error alert when the user clicks X", async () => { + const me = authMeHandler({ + id: "u-1", + email: "player@example.com", + display_name: null, + avatar_url: null, + tos_accepted_at: "2026-01-01T00:00:00Z", + }) + server.use( + me.handler, + consentsHandler, + http.get("*/auth/me/connections", () => HttpResponse.json([])) + ) + + await renderWithFileRoutes(<>, { + initialLocation: + "/profile?associate_error=oauth_state_expired&associate_provider=steam", + }) + + const alert = await screen.findByTestId("associate-error") + const dismiss = screen.getByRole("button", { name: /dismiss error/i }) + const user = userEvent.setup() + await user.click(dismiss) + + await waitFor(() => expect(alert).not.toBeInTheDocument()) + }) + + it("ignores ?associate_error= when the provider is missing or unknown", async () => { + const me = authMeHandler({ + id: "u-1", + email: "player@example.com", + display_name: null, + avatar_url: null, + tos_accepted_at: "2026-01-01T00:00:00Z", + }) + server.use( + me.handler, + consentsHandler, + http.get("*/auth/me/connections", () => HttpResponse.json([])) + ) + + await renderWithFileRoutes(<>, { + initialLocation: "/profile?associate_error=oauth_state_expired", + }) + + await screen.findByText("Connected accounts") + expect(screen.queryByTestId("associate-error")).not.toBeInTheDocument() + }) + it("disables Disconnect with a helper hint when removing it would strand the user", async () => { const me = authMeHandler({ id: "u-1", diff --git a/src/pages/profile/profile-page.tsx b/src/pages/profile/profile-page.tsx index 60a411d..0e0265f 100644 --- a/src/pages/profile/profile-page.tsx +++ b/src/pages/profile/profile-page.tsx @@ -10,6 +10,11 @@ import { Label } from "@/components/ui/label" import { UserAvatar } from "@/components/user-avatar" import { api, baseUrl } from "@/api/api" import { getErrorMessage } from "@/lib/api-errors" +import { + associateErrorMessage, + providerLabel, + type AssociateProvider, +} from "@/lib/associate-errors" import { useAuth } from "@/lib/auth" import { hasStaleConsent, @@ -83,6 +88,13 @@ export function ProfilePage() { message: string remediation: string[] } | null>(null) + // Captured from the URL on mount and kept in state so the alert + // survives the navigate({ replace: true }) call that scrubs the + // ?associate_error= param from the address bar. + const [associateError, setAssociateError] = useState<{ + provider: AssociateProvider + message: string + } | null>(null) const [nudgeDismissed, setNudgeDismissed] = useState(() => typeof window === "undefined" ? false @@ -119,11 +131,23 @@ export function ProfilePage() { useEffect(() => { if (!search.linked) return - const label = search.linked === "google" ? "Google" : "Steam" + const label = providerLabel(search.linked) toast.success(`Linked your ${label} account.`) navigate({ to: "/profile", search: {}, replace: true }) }, [search.linked, navigate]) + useEffect(() => { + if (!search.associate_error || !search.associate_provider) return + setAssociateError({ + provider: search.associate_provider, + message: associateErrorMessage( + search.associate_error, + search.associate_provider + ), + }) + navigate({ to: "/profile", search: {}, replace: true }) + }, [search.associate_error, search.associate_provider, navigate]) + const stale = hasStaleConsent(auth.consents) const showStaleBanner = stale || search.reason === "consent-stale" @@ -410,6 +434,25 @@ export function ProfilePage() { data-testid="connections-section" >

Connected accounts

+ {associateError && ( +
+

+ {associateError.message} +

+ +
+ )} {connections === null ? (

diff --git a/src/routes/profile.tsx b/src/routes/profile.tsx index 96fe432..d2cd92d 100644 --- a/src/routes/profile.tsx +++ b/src/routes/profile.tsx @@ -1,9 +1,12 @@ import { createFileRoute, redirect } from "@tanstack/react-router" import { ProfilePage } from "@/pages/profile/profile-page" +import { isAssociateProvider } from "@/lib/associate-errors" interface ProfileSearch { reason?: "consent-stale" linked?: "google" | "steam" + associate_error?: string + associate_provider?: "google" | "steam" } export const Route = createFileRoute("/profile")({ @@ -13,6 +16,16 @@ export const Route = createFileRoute("/profile")({ if (search.linked === "google" || search.linked === "steam") { next.linked = search.linked } + // Both come from the backend's redirect on associate failure; we + // need the pair to render a sensible message. If either is missing + // or the provider is unknown, drop both rather than half-rendering. + if ( + typeof search.associate_error === "string" && + isAssociateProvider(search.associate_provider) + ) { + next.associate_error = search.associate_error + next.associate_provider = search.associate_provider + } return next }, beforeLoad: ({ context }) => {