Skip to content

Commit 447c44c

Browse files
authored
fix(profile): render associate-flow errors as an alert instead of raw JSON (#50)
When linking a provider fails (most commonly oauth_account_already_linked), the backend now 302s back to /profile?associate_error=<code>&associate_provider=<p> 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.
1 parent cca5cf1 commit 447c44c

6 files changed

Lines changed: 211 additions & 37 deletions

File tree

src/lib/associate-errors.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
// Friendly copy for `oauth_*` codes the backend can raise during an
2+
// associate (link-to-current-user) flow. Used in two places:
3+
// 1. The dev fetch path in <Provider>CallbackPage, when the API still
4+
// replies with JSON because the SPA is mediating.
5+
// 2. The profile page, when the API redirects to /profile with the
6+
// code in the query string (prod path; the user lands directly
7+
// back on profile rather than seeing raw JSON).
8+
9+
export type AssociateProvider = "google" | "steam"
10+
11+
const PROVIDER_LABELS: Record<AssociateProvider, string> = {
12+
google: "Google",
13+
steam: "Steam",
14+
}
15+
16+
export function providerLabel(provider: AssociateProvider): string {
17+
return PROVIDER_LABELS[provider]
18+
}
19+
20+
export function associateErrorMessage(
21+
code: string,
22+
provider: AssociateProvider
23+
): string {
24+
const label = providerLabel(provider)
25+
switch (code) {
26+
case "oauth_account_already_linked":
27+
return `This ${label} account is already linked to another criticalbit account. Unlink it there first.`
28+
case "oauth_state_invalid":
29+
case "oauth_state_missing":
30+
case "oauth_state_wrong_purpose":
31+
return "Your link session is invalid. Please return to your profile and try again."
32+
case "oauth_state_expired":
33+
case "oauth_csrf_mismatch":
34+
return "Your link session expired. Please return to your profile and try again."
35+
case "oauth_state_user_mismatch":
36+
return "Something went wrong linking your account. Please try again."
37+
case "oauth_verify_failed":
38+
return `${label} rejected the response. Please try again.`
39+
default:
40+
return `Linking your ${label} account failed. Please try again.`
41+
}
42+
}
43+
44+
export function isAssociateProvider(
45+
value: unknown
46+
): value is AssociateProvider {
47+
return value === "google" || value === "steam"
48+
}

src/pages/callback/google-callback-page.tsx

Lines changed: 2 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,11 @@
11
import { useEffect, useRef, useState } from "react"
22
import { baseUrl } from "@/api/api"
3+
import { associateErrorMessage } from "@/lib/associate-errors"
34
import { readPurposeFromStateParam, type OAuthPurpose } from "@/lib/oauth-state"
45

56
const OAUTH_USER_ALREADY_EXISTS_MESSAGE =
67
"An unverified account already exists for this email. Sign in with your password, verify your email, then link Google from your profile."
78

8-
const ASSOCIATE_ERROR_MESSAGES: Record<string, string> = {
9-
oauth_account_already_linked:
10-
"This Google account is already linked to another criticalbit account. Unlink it there first.",
11-
oauth_state_invalid:
12-
"Your link session is invalid. Please return to your profile and try again.",
13-
oauth_state_expired:
14-
"Your link session expired. Please return to your profile and try again.",
15-
oauth_csrf_mismatch:
16-
"Your link session expired. Please return to your profile and try again.",
17-
oauth_state_user_mismatch:
18-
"Something went wrong linking your account. Please try again.",
19-
oauth_verify_failed: "Google rejected the response. Please try again.",
20-
}
21-
229
async function readDetail(res: Response): Promise<unknown> {
2310
try {
2411
return (await res.clone().json())?.detail
@@ -75,10 +62,7 @@ export function GoogleCallbackPage() {
7562
if (!res.ok) {
7663
const code = detailCode(await readDetail(res))
7764
if (purpose === "associate") {
78-
setError(
79-
(code && ASSOCIATE_ERROR_MESSAGES[code]) ??
80-
"Linking your Google account failed. Please try again."
81-
)
65+
setError(associateErrorMessage(code ?? "", "google"))
8266
} else if (code === "OAUTH_USER_ALREADY_EXISTS") {
8367
setError(OAUTH_USER_ALREADY_EXISTS_MESSAGE)
8468
} else {

src/pages/callback/steam-callback-page.tsx

Lines changed: 2 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,8 @@
11
import { useEffect, useRef, useState } from "react"
22
import { baseUrl } from "@/api/api"
3+
import { associateErrorMessage } from "@/lib/associate-errors"
34
import { readPurposeFromStateParam, type OAuthPurpose } from "@/lib/oauth-state"
45

5-
const ASSOCIATE_ERROR_MESSAGES: Record<string, string> = {
6-
oauth_account_already_linked:
7-
"This Steam account is already linked to another criticalbit account. Unlink it there first.",
8-
oauth_state_invalid:
9-
"Your link session is invalid. Please return to your profile and try again.",
10-
oauth_state_expired:
11-
"Your link session expired. Please return to your profile and try again.",
12-
oauth_csrf_mismatch:
13-
"Your link session expired. Please return to your profile and try again.",
14-
oauth_state_user_mismatch:
15-
"Something went wrong linking your account. Please try again.",
16-
oauth_verify_failed: "Steam rejected the response. Please try again.",
17-
}
18-
196
async function readDetailCode(res: Response): Promise<string | null> {
207
try {
218
const detail = (await res.clone().json())?.detail
@@ -69,10 +56,7 @@ export function SteamCallbackPage() {
6956
if (!res.ok) {
7057
if (purpose === "associate") {
7158
const code = await readDetailCode(res)
72-
setError(
73-
(code && ASSOCIATE_ERROR_MESSAGES[code]) ??
74-
"Linking your Steam account failed. Please try again."
75-
)
59+
setError(associateErrorMessage(code ?? "", "steam"))
7660
} else {
7761
setError("Steam sign-in failed. Please try again.")
7862
}

src/pages/profile/profile-page.test.tsx

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,108 @@ describe("ProfilePage connected accounts", () => {
364364
)
365365
})
366366

367+
it("renders the associate error alert when the API redirects with ?associate_error", async () => {
368+
const me = authMeHandler({
369+
id: "u-1",
370+
email: "player@example.com",
371+
display_name: null,
372+
avatar_url: null,
373+
tos_accepted_at: "2026-01-01T00:00:00Z",
374+
})
375+
server.use(
376+
me.handler,
377+
consentsHandler,
378+
http.get("*/auth/me/connections", () => HttpResponse.json([]))
379+
)
380+
381+
const { history } = await renderWithFileRoutes(<></>, {
382+
initialLocation:
383+
"/profile?associate_error=oauth_account_already_linked&associate_provider=steam",
384+
})
385+
386+
const alert = await screen.findByTestId("associate-error")
387+
expect(alert).toHaveTextContent(
388+
/this steam account is already linked to another criticalbit account/i
389+
)
390+
// The error params should be scrubbed from the URL so a refresh
391+
// doesn't re-trigger the alert.
392+
await waitFor(() =>
393+
expect(history.location.search).not.toContain("associate_error")
394+
)
395+
})
396+
397+
it("falls back to a generic message for an unknown associate_error code", async () => {
398+
const me = authMeHandler({
399+
id: "u-1",
400+
email: "player@example.com",
401+
display_name: null,
402+
avatar_url: null,
403+
tos_accepted_at: "2026-01-01T00:00:00Z",
404+
})
405+
server.use(
406+
me.handler,
407+
consentsHandler,
408+
http.get("*/auth/me/connections", () => HttpResponse.json([]))
409+
)
410+
411+
await renderWithFileRoutes(<></>, {
412+
initialLocation:
413+
"/profile?associate_error=something_new&associate_provider=google",
414+
})
415+
416+
const alert = await screen.findByTestId("associate-error")
417+
expect(alert).toHaveTextContent(/linking your google account failed/i)
418+
})
419+
420+
it("dismisses the associate error alert when the user clicks X", async () => {
421+
const me = authMeHandler({
422+
id: "u-1",
423+
email: "player@example.com",
424+
display_name: null,
425+
avatar_url: null,
426+
tos_accepted_at: "2026-01-01T00:00:00Z",
427+
})
428+
server.use(
429+
me.handler,
430+
consentsHandler,
431+
http.get("*/auth/me/connections", () => HttpResponse.json([]))
432+
)
433+
434+
await renderWithFileRoutes(<></>, {
435+
initialLocation:
436+
"/profile?associate_error=oauth_state_expired&associate_provider=steam",
437+
})
438+
439+
const alert = await screen.findByTestId("associate-error")
440+
const dismiss = screen.getByRole("button", { name: /dismiss error/i })
441+
const user = userEvent.setup()
442+
await user.click(dismiss)
443+
444+
await waitFor(() => expect(alert).not.toBeInTheDocument())
445+
})
446+
447+
it("ignores ?associate_error= when the provider is missing or unknown", async () => {
448+
const me = authMeHandler({
449+
id: "u-1",
450+
email: "player@example.com",
451+
display_name: null,
452+
avatar_url: null,
453+
tos_accepted_at: "2026-01-01T00:00:00Z",
454+
})
455+
server.use(
456+
me.handler,
457+
consentsHandler,
458+
http.get("*/auth/me/connections", () => HttpResponse.json([]))
459+
)
460+
461+
await renderWithFileRoutes(<></>, {
462+
initialLocation: "/profile?associate_error=oauth_state_expired",
463+
})
464+
465+
await screen.findByText("Connected accounts")
466+
expect(screen.queryByTestId("associate-error")).not.toBeInTheDocument()
467+
})
468+
367469
it("disables Disconnect with a helper hint when removing it would strand the user", async () => {
368470
const me = authMeHandler({
369471
id: "u-1",

src/pages/profile/profile-page.tsx

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ import { Label } from "@/components/ui/label"
1010
import { UserAvatar } from "@/components/user-avatar"
1111
import { api, baseUrl } from "@/api/api"
1212
import { getErrorMessage } from "@/lib/api-errors"
13+
import {
14+
associateErrorMessage,
15+
providerLabel,
16+
type AssociateProvider,
17+
} from "@/lib/associate-errors"
1318
import { useAuth } from "@/lib/auth"
1419
import {
1520
hasStaleConsent,
@@ -83,6 +88,13 @@ export function ProfilePage() {
8388
message: string
8489
remediation: string[]
8590
} | null>(null)
91+
// Captured from the URL on mount and kept in state so the alert
92+
// survives the navigate({ replace: true }) call that scrubs the
93+
// ?associate_error= param from the address bar.
94+
const [associateError, setAssociateError] = useState<{
95+
provider: AssociateProvider
96+
message: string
97+
} | null>(null)
8698
const [nudgeDismissed, setNudgeDismissed] = useState(() =>
8799
typeof window === "undefined"
88100
? false
@@ -119,11 +131,23 @@ export function ProfilePage() {
119131

120132
useEffect(() => {
121133
if (!search.linked) return
122-
const label = search.linked === "google" ? "Google" : "Steam"
134+
const label = providerLabel(search.linked)
123135
toast.success(`Linked your ${label} account.`)
124136
navigate({ to: "/profile", search: {}, replace: true })
125137
}, [search.linked, navigate])
126138

139+
useEffect(() => {
140+
if (!search.associate_error || !search.associate_provider) return
141+
setAssociateError({
142+
provider: search.associate_provider,
143+
message: associateErrorMessage(
144+
search.associate_error,
145+
search.associate_provider
146+
),
147+
})
148+
navigate({ to: "/profile", search: {}, replace: true })
149+
}, [search.associate_error, search.associate_provider, navigate])
150+
127151
const stale = hasStaleConsent(auth.consents)
128152
const showStaleBanner = stale || search.reason === "consent-stale"
129153

@@ -410,6 +434,25 @@ export function ProfilePage() {
410434
data-testid="connections-section"
411435
>
412436
<h3 className="text-sm font-semibold">Connected accounts</h3>
437+
{associateError && (
438+
<div
439+
role="alert"
440+
className="border-destructive/40 bg-destructive/10 relative rounded-md border p-3 pr-8 text-xs leading-relaxed"
441+
data-testid="associate-error"
442+
>
443+
<p className="text-destructive-foreground">
444+
{associateError.message}
445+
</p>
446+
<button
447+
type="button"
448+
aria-label="Dismiss error"
449+
onClick={() => setAssociateError(null)}
450+
className="text-muted-foreground hover:text-foreground absolute top-2 right-2 transition-colors"
451+
>
452+
<X className="size-3.5" />
453+
</button>
454+
</div>
455+
)}
413456
{connections === null ? (
414457
<p className="text-muted-foreground flex items-center gap-2 text-xs">
415458
<LoaderCircle className="size-3 animate-spin" />

src/routes/profile.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
import { createFileRoute, redirect } from "@tanstack/react-router"
22
import { ProfilePage } from "@/pages/profile/profile-page"
3+
import { isAssociateProvider } from "@/lib/associate-errors"
34

45
interface ProfileSearch {
56
reason?: "consent-stale"
67
linked?: "google" | "steam"
8+
associate_error?: string
9+
associate_provider?: "google" | "steam"
710
}
811

912
export const Route = createFileRoute("/profile")({
@@ -13,6 +16,16 @@ export const Route = createFileRoute("/profile")({
1316
if (search.linked === "google" || search.linked === "steam") {
1417
next.linked = search.linked
1518
}
19+
// Both come from the backend's redirect on associate failure; we
20+
// need the pair to render a sensible message. If either is missing
21+
// or the provider is unknown, drop both rather than half-rendering.
22+
if (
23+
typeof search.associate_error === "string" &&
24+
isAssociateProvider(search.associate_provider)
25+
) {
26+
next.associate_error = search.associate_error
27+
next.associate_provider = search.associate_provider
28+
}
1629
return next
1730
},
1831
beforeLoad: ({ context }) => {

0 commit comments

Comments
 (0)