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
This commit is contained in:
parent
80973d3212
commit
737a808777
3 changed files with 86 additions and 2 deletions
|
|
@ -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,15 @@ const PROVIDER_LABELS: Record<string, string> = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export function AccountSection({ email, linkedAccounts }: Props) {
|
export function AccountSection({ email, linkedAccounts }: Props) {
|
||||||
|
const [linkingDiscord, setLinkingDiscord] = useState(false);
|
||||||
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);
|
||||||
|
await authClient.linkSocial({ provider: "discord", callbackURL: "/settings?section=account" });
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
|
@ -57,6 +67,16 @@ 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 && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleConnectDiscord}
|
||||||
|
disabled={linkingDiscord}
|
||||||
|
>
|
||||||
|
{linkingDiscord ? "Redirecting…" : "Connect Discord"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{hasPassword && (
|
{hasPassword && (
|
||||||
|
|
|
||||||
|
|
@ -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 }) => (
|
||||||
|
<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", () => {
|
||||||
|
render(
|
||||||
|
<AccountSection
|
||||||
|
email="user@example.com"
|
||||||
|
linkedAccounts={[{ id: "1", providerId: "credential" }]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /connect discord/i }));
|
||||||
|
expect(mockLinkSocial).toHaveBeenCalledWith({
|
||||||
|
provider: "discord",
|
||||||
|
callbackURL: "/settings?section=account",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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";
|
||||||
|
|
@ -206,7 +206,11 @@ 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 VALID_SECTION_IDS = new Set<SectionId>(["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<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) => {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue