From a35df3f5b05061fa3b9abcb7d6770e89595821d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 20 May 2026 20:22:58 +0000 Subject: [PATCH] Fix Discord linking error handling and section routing issues - Reset linkingDiscord state and show error message when linkSocial throws, so users can retry if the OAuth flow fails - Move VALID_SECTION_IDS to module level and derive it from SECTIONS to avoid per-render allocation and prevent the two from drifting out of sync - Remove premature SectionId cast before the allowlist validation in the ?section= URL param handling - Add tests for loading state and error recovery in AccountSection Fixes #455 https://claude.ai/code/session_01UerTpTuBWjreTNEns8srkp --- .../user/settings/AccountSection.tsx | 30 +++++++++---- .../__tests__/AccountSection.test.tsx | 43 ++++++++++++++++--- app/routes/settings.tsx | 6 +-- 3 files changed, 62 insertions(+), 17 deletions(-) diff --git a/app/components/user/settings/AccountSection.tsx b/app/components/user/settings/AccountSection.tsx index 3091510..27597c7 100644 --- a/app/components/user/settings/AccountSection.tsx +++ b/app/components/user/settings/AccountSection.tsx @@ -22,13 +22,20 @@ const PROVIDER_LABELS: Record = { export function AccountSection({ email, linkedAccounts }: Props) { const [linkingDiscord, setLinkingDiscord] = useState(false); + const [linkError, setLinkError] = useState(null); const hasPassword = linkedAccounts.some((a) => a.providerId === "credential"); const oauthAccounts = linkedAccounts.filter((a) => a.providerId !== "credential"); const hasDiscord = linkedAccounts.some((a) => a.providerId === "discord"); async function handleConnectDiscord() { setLinkingDiscord(true); - await authClient.linkSocial({ provider: "discord", callbackURL: "/settings?section=account" }); + setLinkError(null); + try { + await authClient.linkSocial({ provider: "discord", callbackURL: "/settings?section=account" }); + } catch { + setLinkError("Failed to connect Discord. Please try again."); + setLinkingDiscord(false); + } } return ( @@ -68,14 +75,19 @@ export function AccountSection({ email, linkedAccounts }: Props) { )} {!hasDiscord && ( - +
+ + {linkError && ( +

{linkError}

+ )} +
)} diff --git a/app/components/user/settings/__tests__/AccountSection.test.tsx b/app/components/user/settings/__tests__/AccountSection.test.tsx index 7a54760..b8f7366 100644 --- a/app/components/user/settings/__tests__/AccountSection.test.tsx +++ b/app/components/user/settings/__tests__/AccountSection.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen, fireEvent } from "@testing-library/react"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import { AccountSection } from "../AccountSection"; vi.mock("react-router", () => ({ @@ -44,7 +44,7 @@ describe("AccountSection", () => { expect(screen.queryByRole("button", { name: /connect discord/i })).not.toBeInTheDocument(); }); - it("calls authClient.linkSocial with discord provider on button click", () => { + it("calls authClient.linkSocial with discord provider on button click", async () => { render( { /> ); fireEvent.click(screen.getByRole("button", { name: /connect discord/i })); - expect(mockLinkSocial).toHaveBeenCalledWith({ - provider: "discord", - callbackURL: "/settings?section=account", + await waitFor(() => { + expect(mockLinkSocial).toHaveBeenCalledWith({ + provider: "discord", + callbackURL: "/settings?section=account", + }); + }); + }); + + it("shows Redirecting… and disables the button while linking", async () => { + // Never resolves so we can inspect the in-flight state + mockLinkSocial.mockReturnValue(new Promise(() => {})); + render( + + ); + fireEvent.click(screen.getByRole("button", { name: /connect discord/i })); + await waitFor(() => { + const btn = screen.getByRole("button", { name: /redirecting/i }); + expect(btn).toBeDisabled(); + }); + }); + + it("re-enables the button and shows an error message when linkSocial throws", async () => { + mockLinkSocial.mockRejectedValue(new Error("OAuth failed")); + render( + + ); + fireEvent.click(screen.getByRole("button", { name: /connect discord/i })); + await waitFor(() => { + expect(screen.getByRole("button", { name: /connect discord/i })).not.toBeDisabled(); + expect(screen.getByText(/failed to connect discord/i)).toBeInTheDocument(); }); }); }); diff --git a/app/routes/settings.tsx b/app/routes/settings.tsx index 4194f93..b3a4cf1 100644 --- a/app/routes/settings.tsx +++ b/app/routes/settings.tsx @@ -35,6 +35,7 @@ const SECTIONS: readonly SettingsGridSection[] = [ ] as const; const VALID_TIMEZONES = new Set(TIMEZONE_OPTIONS.flatMap((g) => g.zones.map((z) => z.value))); +const VALID_SECTION_IDS = new Set(SECTIONS.map((s) => s.id as SectionId)); const DATA_REQUEST_COOLDOWN_MS = 30 * 24 * 60 * 60 * 1000; // 30 days @@ -207,9 +208,8 @@ export async function action(args: Route.ActionArgs): Promise(["profile", "account", "notifications", "api", "privacy"]); - const sectionParam = searchParams.get("section") as SectionId | null; - const initialSection: SectionId = sectionParam && VALID_SECTION_IDS.has(sectionParam) ? sectionParam : "profile"; + const raw = searchParams.get("section"); + const initialSection: SectionId = raw && (VALID_SECTION_IDS as Set).has(raw) ? (raw as SectionId) : "profile"; const [activeSection, setActiveSection] = useState(initialSection); const [mobileView, setMobileView] = useState<"grid" | "section">("grid");