Add Discord account linking from settings page (#457)

* 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

* 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

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Chris Parsons 2026-05-20 13:38:44 -07:00 committed by GitHub
parent 80973d3212
commit 7809864674
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 131 additions and 2 deletions

View file

@ -1,5 +1,8 @@
import { useState } from "react";
import { Link } from "react-router"; import { Link } from "react-router";
import { Badge } from "~/components/ui/badge"; import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import { authClient } from "~/lib/auth-client";
type LinkedAccount = { type LinkedAccount = {
id: string; id: string;
@ -18,8 +21,22 @@ const PROVIDER_LABELS: Record<string, string> = {
}; };
export function AccountSection({ email, linkedAccounts }: Props) { export function AccountSection({ email, linkedAccounts }: Props) {
const [linkingDiscord, setLinkingDiscord] = useState(false);
const [linkError, setLinkError] = useState<string | null>(null);
const hasPassword = linkedAccounts.some((a) => a.providerId === "credential"); const hasPassword = linkedAccounts.some((a) => a.providerId === "credential");
const oauthAccounts = linkedAccounts.filter((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);
setLinkError(null);
try {
await authClient.linkSocial({ provider: "discord", callbackURL: "/settings?section=account" });
} catch {
setLinkError("Failed to connect Discord. Please try again.");
setLinkingDiscord(false);
}
}
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@ -57,6 +74,21 @@ export function AccountSection({ email, linkedAccounts }: Props) {
<p className="text-sm text-muted-foreground">No sign-in methods found.</p> <p className="text-sm text-muted-foreground">No sign-in methods found.</p>
)} )}
</div> </div>
{!hasDiscord && (
<div className="space-y-2">
<Button
variant="outline"
size="sm"
onClick={handleConnectDiscord}
disabled={linkingDiscord}
>
{linkingDiscord ? "Redirecting…" : "Connect Discord"}
</Button>
{linkError && (
<p className="text-sm text-destructive">{linkError}</p>
)}
</div>
)}
</div> </div>
{hasPassword && ( {hasPassword && (

View file

@ -0,0 +1,93 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { AccountSection } from "../AccountSection";
vi.mock("react-router", () => ({
Link: ({ children, to }: { children: React.ReactNode; to: string }) => (
<a href={to}>{children}</a>
),
}));
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(
<AccountSection
email="user@example.com"
linkedAccounts={[{ id: "1", providerId: "credential" }]}
/>
);
expect(screen.getByRole("button", { name: /connect discord/i })).toBeInTheDocument();
});
it("does not show Connect Discord button when Discord is already linked", () => {
render(
<AccountSection
email="user@example.com"
linkedAccounts={[
{ id: "1", providerId: "credential" },
{ id: "2", providerId: "discord" },
]}
/>
);
expect(screen.queryByRole("button", { name: /connect discord/i })).not.toBeInTheDocument();
});
it("calls authClient.linkSocial with discord provider on button click", async () => {
render(
<AccountSection
email="user@example.com"
linkedAccounts={[{ id: "1", providerId: "credential" }]}
/>
);
fireEvent.click(screen.getByRole("button", { name: /connect discord/i }));
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(
<AccountSection
email="user@example.com"
linkedAccounts={[{ id: "1", providerId: "credential" }]}
/>
);
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(
<AccountSection
email="user@example.com"
linkedAccounts={[{ id: "1", providerId: "credential" }]}
/>
);
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();
});
});
});

View file

@ -1,4 +1,4 @@
import { redirect } from "react-router"; import { redirect, useSearchParams } from "react-router";
import { useState } from "react"; import { useState } from "react";
import { Bell, Key, Lock, Shield, User } from "lucide-react"; import { Bell, Key, Lock, Shield, User } from "lucide-react";
import { auth } from "~/lib/auth.server"; import { auth } from "~/lib/auth.server";
@ -35,6 +35,7 @@ const SECTIONS: readonly SettingsGridSection[] = [
] as const; ] as const;
const VALID_TIMEZONES = new Set(TIMEZONE_OPTIONS.flatMap((g) => g.zones.map((z) => z.value))); 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 const DATA_REQUEST_COOLDOWN_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
@ -206,7 +207,10 @@ export async function action(args: Route.ActionArgs): Promise<ActionData | Respo
export default function SettingsPage({ loaderData, actionData }: Route.ComponentProps) { export default function SettingsPage({ loaderData, actionData }: Route.ComponentProps) {
const { user, isInActiveDraft, linkedAccounts, dataRequestCooldownUntil } = loaderData; const { user, isInActiveDraft, linkedAccounts, dataRequestCooldownUntil } = loaderData;
const [activeSection, setActiveSection] = useState<SectionId>("profile"); const [searchParams] = useSearchParams();
const raw = searchParams.get("section");
const initialSection: SectionId = raw && (VALID_SECTION_IDS as Set<string>).has(raw) ? (raw as SectionId) : "profile";
const [activeSection, setActiveSection] = useState<SectionId>(initialSection);
const [mobileView, setMobileView] = useState<"grid" | "section">("grid"); const [mobileView, setMobileView] = useState<"grid" | "section">("grid");
const handleSectionChange = (id: string, mobile = false) => { const handleSectionChange = (id: string, mobile = false) => {