From 737a80877759ee4f1a770d5db27e8161d4ede677 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 20 May 2026 20:11:33 +0000 Subject: [PATCH] Add Discord account linking from settings page Adds a "Connect Discord" button in the Account settings section that triggers BetterAuth's linkSocial flow to attach a Discord account to an existing user without requiring a sign-out/sign-in. After the OAuth callback, the ?section= URL param returns the user to the Account tab. The Notifications toggle was already gated on Discord being linked. https://claude.ai/code/session_01UerTpTuBWjreTNEns8srkp --- .../user/settings/AccountSection.tsx | 20 +++++++ .../__tests__/AccountSection.test.tsx | 60 +++++++++++++++++++ app/routes/settings.tsx | 8 ++- 3 files changed, 86 insertions(+), 2 deletions(-) create mode 100644 app/components/user/settings/__tests__/AccountSection.test.tsx diff --git a/app/components/user/settings/AccountSection.tsx b/app/components/user/settings/AccountSection.tsx index 9d887f3..3091510 100644 --- a/app/components/user/settings/AccountSection.tsx +++ b/app/components/user/settings/AccountSection.tsx @@ -1,5 +1,8 @@ +import { useState } from "react"; import { Link } from "react-router"; import { Badge } from "~/components/ui/badge"; +import { Button } from "~/components/ui/button"; +import { authClient } from "~/lib/auth-client"; type LinkedAccount = { id: string; @@ -18,8 +21,15 @@ const PROVIDER_LABELS: Record = { }; export function AccountSection({ email, linkedAccounts }: Props) { + const [linkingDiscord, setLinkingDiscord] = useState(false); 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" }); + } return (
@@ -57,6 +67,16 @@ export function AccountSection({ email, linkedAccounts }: Props) {

No sign-in methods found.

)}
+ {!hasDiscord && ( + + )} {hasPassword && ( diff --git a/app/components/user/settings/__tests__/AccountSection.test.tsx b/app/components/user/settings/__tests__/AccountSection.test.tsx new file mode 100644 index 0000000..7a54760 --- /dev/null +++ b/app/components/user/settings/__tests__/AccountSection.test.tsx @@ -0,0 +1,60 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { AccountSection } from "../AccountSection"; + +vi.mock("react-router", () => ({ + Link: ({ children, to }: { children: React.ReactNode; to: string }) => ( + {children} + ), +})); + +const mockLinkSocial = vi.fn(); +vi.mock("~/lib/auth-client", () => ({ + authClient: { + linkSocial: (...args: unknown[]) => mockLinkSocial(...args), + }, +})); + +describe("AccountSection", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockLinkSocial.mockResolvedValue({}); + }); + + it("shows Connect Discord button when Discord is not linked", () => { + render( + + ); + expect(screen.getByRole("button", { name: /connect discord/i })).toBeInTheDocument(); + }); + + it("does not show Connect Discord button when Discord is already linked", () => { + render( + + ); + expect(screen.queryByRole("button", { name: /connect discord/i })).not.toBeInTheDocument(); + }); + + it("calls authClient.linkSocial with discord provider on button click", () => { + render( + + ); + fireEvent.click(screen.getByRole("button", { name: /connect discord/i })); + expect(mockLinkSocial).toHaveBeenCalledWith({ + provider: "discord", + callbackURL: "/settings?section=account", + }); + }); +}); diff --git a/app/routes/settings.tsx b/app/routes/settings.tsx index 4824c02..4194f93 100644 --- a/app/routes/settings.tsx +++ b/app/routes/settings.tsx @@ -1,4 +1,4 @@ -import { redirect } from "react-router"; +import { redirect, useSearchParams } from "react-router"; import { useState } from "react"; import { Bell, Key, Lock, Shield, User } from "lucide-react"; import { auth } from "~/lib/auth.server"; @@ -206,7 +206,11 @@ export async function action(args: Route.ActionArgs): Promise("profile"); + const [searchParams] = useSearchParams(); + const VALID_SECTION_IDS = new Set(["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 [activeSection, setActiveSection] = useState(initialSection); const [mobileView, setMobileView] = useState<"grid" | "section">("grid"); const handleSectionChange = (id: string, mobile = false) => {