From dd4f40b50eaabfaec6e11e292e78e5e99372b428 Mon Sep 17 00:00:00 2001 From: Adrian Bonpin Date: Sun, 26 Apr 2026 11:18:05 -0500 Subject: [PATCH] feat: add linked accounts settings tab with link/unlink --- components/profile/settings-accounts-tab.tsx | 263 +++++++++++++++++++ 1 file changed, 263 insertions(+) create mode 100644 components/profile/settings-accounts-tab.tsx diff --git a/components/profile/settings-accounts-tab.tsx b/components/profile/settings-accounts-tab.tsx new file mode 100644 index 0000000..d2a45f6 --- /dev/null +++ b/components/profile/settings-accounts-tab.tsx @@ -0,0 +1,263 @@ +"use client" + +import { useState, useEffect, useCallback } from "react" +import { Loader2, Link as LinkIcon, Unlink, Shield } from "lucide-react" +import { motion } from "motion/react" +import { FaGoogle, FaDiscord } from "react-icons/fa" + + +interface LinkedAccount { + id: string + providerId: string + accountId: string + createdAt: string +} + +interface AuthMethods { + hasPassword: boolean + passkeyCount: number + oauthProviders: { providerId: string; id: string }[] + totalAuthMethods: number +} + +const providerConfig: Record | null; color: string; bgColor: string }> = { + google: { name: "Google", icon: FaGoogle, color: "text-red-400", bgColor: "bg-red-500/10 border-red-500/20" }, + discord: { name: "Discord", icon: FaDiscord, color: "text-indigo-400", bgColor: "bg-indigo-500/10 border-indigo-500/20" }, + credential: { name: "Password", icon: null, color: "text-text/60", bgColor: "bg-text/5 border-border" }, +} + +export function SettingsAccountsTab() { + const [accounts, setAccounts] = useState([]) + const [loading, setLoading] = useState(true) + const [authMethods, setAuthMethods] = useState(null) + const [unlinking, setUnlinking] = useState(null) + const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null) + + const fetchData = useCallback(async () => { + try { + const [accountsRes, methodsRes] = await Promise.all([ + fetch("/api/auth/list-accounts", { credentials: "include" }), + fetch("/api/user/me/auth-methods"), + ]) + + if (accountsRes.ok) { + const data = await accountsRes.json() + setAccounts(Array.isArray(data) ? data : []) + } + + if (methodsRes.ok) { + const data = await methodsRes.json() + setAuthMethods(data) + } + } catch (err) { + console.error("Failed to fetch account data:", err) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + fetchData() + }, [fetchData]) + + const handleLink = async (provider: "google" | "discord") => { + try { + const res = await fetch("/api/auth/link-social", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ + provider, + callbackURL: window.location.href, + }), + }) + + if (!res.ok) { + const data = await res.json().catch(() => ({})) + setMessage({ type: "error", text: data.message || "Failed to link account" }) + return + } + + const data = await res.json() + // If the API returns a URL, redirect to it + if (data && data.url) { + window.location.href = data.url + } + } catch (err) { + setMessage({ type: "error", text: "Failed to initiate account linking" }) + } + } + + const handleUnlink = async (providerId: string) => { + if (authMethods && authMethods.totalAuthMethods <= 1) return + + setUnlinking(providerId) + setMessage(null) + + try { + const res = await fetch("/api/auth/unlink-account", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ providerId }), + }) + + if (res.ok) { + setMessage({ type: "success", text: `${providerConfig[providerId]?.name || providerId} account unlinked` }) + await fetchData() + } else { + const data = await res.json() + setMessage({ type: "error", text: data.message || "Failed to unlink account" }) + } + } catch { + setMessage({ type: "error", text: "Failed to unlink account" }) + } finally { + setUnlinking(null) + } + } + + const linkedProviders = new Set(accounts.map((a) => a.providerId)) + const availableProviders = ["google", "discord"].filter((p) => !linkedProviders.has(p)) + const isOnlyAuthMethod = authMethods ? authMethods.totalAuthMethods <= 1 : true + + return ( + + {/* Warning if only one auth method */} + {isOnlyAuthMethod && ( +
+ +
+

Single authentication method

+

+ You only have one way to sign in. Consider linking a social account or adding a passkey. +

+
+
+ )} + + {message && ( +

+ {message.text} +

+ )} + + {/* Linked Accounts */} +
+

+ + Linked Accounts +

+ + {loading ? ( +
+ +
+ ) : ( +
+ {/* Password status */} + {authMethods && ( +
+
+
+ ● +
+
+

Password

+

+ {authMethods.hasPassword ? "Configured" : "Not set"} +

+
+
+ + {authMethods.hasPassword ? "Active" : "Inactive"} + +
+ )} + + {/* OAuth accounts */} + {accounts.map((account) => { + const config = providerConfig[account.providerId] || { + name: account.providerId, + icon: null, + color: "text-text/60", + bgColor: "bg-text/5 border-border", + } + const canUnlink = !isOnlyAuthMethod + + return ( +
+
+
+ {config.icon ? : } +
+
+

{config.name}

+

Linked

+
+
+ +
+ ) + })} + + {/* Passkeys count */} + {authMethods && authMethods.passkeyCount > 0 && ( +
+
+
+ +
+
+

Passkeys

+

{authMethods.passkeyCount} registered

+
+
+ + Active + +
+ )} +
+ )} +
+ + {/* Link New Account */} + {availableProviders.length > 0 && ( +
+

Link a Social Account

+
+ {availableProviders.map((provider) => { + const config = providerConfig[provider] + if (!config) return null + return ( + + ) + })} +
+
+ )} +
+ ) +}