Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions src/lib/associate-errors.ts
Original file line number Diff line number Diff line change
@@ -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 <Provider>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<AssociateProvider, string> = {
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"
}
20 changes: 2 additions & 18 deletions src/pages/callback/google-callback-page.tsx
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
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<unknown> {
try {
return (await res.clone().json())?.detail
Expand Down Expand Up @@ -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 {
Expand Down
20 changes: 2 additions & 18 deletions src/pages/callback/steam-callback-page.tsx
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
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<string | null> {
try {
const detail = (await res.clone().json())?.detail
Expand Down Expand Up @@ -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.")
}
Expand Down
102 changes: 102 additions & 0 deletions src/pages/profile/profile-page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
45 changes: 44 additions & 1 deletion src/pages/profile/profile-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"

Expand Down Expand Up @@ -410,6 +434,25 @@ export function ProfilePage() {
data-testid="connections-section"
>
<h3 className="text-sm font-semibold">Connected accounts</h3>
{associateError && (
<div
role="alert"
className="border-destructive/40 bg-destructive/10 relative rounded-md border p-3 pr-8 text-xs leading-relaxed"
data-testid="associate-error"
>
<p className="text-destructive-foreground">
{associateError.message}
</p>
<button
type="button"
aria-label="Dismiss error"
onClick={() => setAssociateError(null)}
className="text-muted-foreground hover:text-foreground absolute top-2 right-2 transition-colors"
>
<X className="size-3.5" />
</button>
</div>
)}
{connections === null ? (
<p className="text-muted-foreground flex items-center gap-2 text-xs">
<LoaderCircle className="size-3 animate-spin" />
Expand Down
13 changes: 13 additions & 0 deletions src/routes/profile.tsx
Original file line number Diff line number Diff line change
@@ -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")({
Expand All @@ -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 }) => {
Expand Down
Loading