refactor(settings-accounts-tab): use better-auth SDK, add error handling and OAuth callback

- Replace raw fetch calls with authClient.listAccounts(), linkSocial(), unlinkAccount()
- Accept authMethods, isLoadingAuthMethods, onRefreshAuthMethods as props
- Add error/retry states for failed account fetches
- Detect OAuth callback return (?linked=true) and show success message
- Show single auth method warning only when isLoadingAuthMethods is false
This commit is contained in:
2026-04-26 12:25:23 -05:00
parent 2063c0cb83
commit 36323f3ba3
+77 -60
View File
@@ -4,13 +4,16 @@ import { useState, useEffect } from "react"
import { Loader2, Link as LinkIcon, Unlink, Shield } from "lucide-react" import { Loader2, Link as LinkIcon, Unlink, Shield } from "lucide-react"
import { motion } from "motion/react" import { motion } from "motion/react"
import { FaGoogle, FaDiscord } from "react-icons/fa" import { FaGoogle, FaDiscord } from "react-icons/fa"
import { authClient } from "@/lib/auth-client"
interface LinkedAccount { interface LinkedAccount {
id: string id: string
providerId: string providerId: string
accountId: string accountId: string
createdAt: string createdAt: Date
updatedAt: Date
userId: string
scopes: string[]
} }
interface AuthMethods { interface AuthMethods {
@@ -26,72 +29,76 @@ const providerConfig: Record<string, { name: string; icon: React.ComponentType<{
credential: { name: "Password", icon: null, color: "text-text/60", bgColor: "bg-text/5 border-border" }, credential: { name: "Password", icon: null, color: "text-text/60", bgColor: "bg-text/5 border-border" },
} }
export function SettingsAccountsTab() { interface SettingsAccountsTabProps {
authMethods: AuthMethods | null
isLoadingAuthMethods: boolean
onRefreshAuthMethods: () => Promise<void>
}
export function SettingsAccountsTab({ authMethods, isLoadingAuthMethods, onRefreshAuthMethods }: SettingsAccountsTabProps) {
const [accounts, setAccounts] = useState<LinkedAccount[]>([]) const [accounts, setAccounts] = useState<LinkedAccount[]>([])
const [loading, setLoading] = useState(true) const [isLoadingAccounts, setIsLoadingAccounts] = useState(true)
const [authMethods, setAuthMethods] = useState<AuthMethods | null>(null) const [accountsError, setAccountsError] = useState<string | null>(null)
const [unlinking, setUnlinking] = useState<string | null>(null) const [unlinking, setUnlinking] = useState<string | null>(null)
const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null) const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null)
const refreshData = async () => { useEffect(() => {
fetchAccounts()
// Check for OAuth callback success
const params = new URLSearchParams(window.location.search)
if (params.has("linked")) {
setMessage({ type: "success", text: "Account linked successfully!" })
window.history.replaceState({}, "", window.location.pathname)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
async function fetchAccounts() {
try { try {
const [accountsRes, methodsRes] = await Promise.all([ const { data, error } = await authClient.listAccounts()
fetch("/api/auth/list-accounts", { credentials: "include" }), if (error || !data) {
fetch("/api/user/me/auth-methods"), setAccountsError("Failed to load linked accounts")
]) setAccounts([])
} else {
if (accountsRes.ok) {
const data = await accountsRes.json()
setAccounts(Array.isArray(data) ? data : []) setAccounts(Array.isArray(data) ? data : [])
} setAccountsError(null)
if (methodsRes.ok) {
const data = await methodsRes.json()
setAuthMethods(data)
} }
} catch { } catch {
// silently fail setAccountsError("Failed to load linked accounts. Please try again.")
setAccounts([])
} finally {
setIsLoadingAccounts(false)
} }
} }
useEffect(() => { const refreshAccounts = async () => {
if (accounts.length > 0 || authMethods !== null) return try {
let cancelled = false const { data } = await authClient.listAccounts()
Promise.all([ if (data) setAccounts(Array.isArray(data) ? data : accounts)
fetch("/api/auth/list-accounts", { credentials: "include" }).then(r => r.ok ? r.json() : []).catch(() => []), } catch {
fetch("/api/user/me/auth-methods").then(r => r.ok ? r.json() : null).catch(() => null), // silently fail on refresh
]).then(([accountsData, methodsData]) => { }
if (cancelled) return }
if (accountsData) setAccounts(Array.isArray(accountsData) ? accountsData : [])
if (methodsData) setAuthMethods(methodsData) const refreshData = async () => {
setLoading(false) await Promise.all([refreshAccounts(), onRefreshAuthMethods()])
}) }
return () => { cancelled = true }
// eslint-disable-next-line react-hooks/exhaustive-deps -- intentional: fetch only on mount
}, [])
const handleLink = async (provider: "google" | "discord") => { const handleLink = async (provider: "google" | "discord") => {
setMessage(null)
try { try {
const res = await fetch("/api/auth/link-social", { const { data, error } = await authClient.linkSocial({
method: "POST", provider,
headers: { "Content-Type": "application/json" }, callbackURL: window.location.origin + "/profile?linked=true",
credentials: "include",
body: JSON.stringify({
provider,
callbackURL: window.location.href,
}),
}) })
if (!res.ok) { if (error) {
const data = await res.json().catch(() => ({})) setMessage({ type: "error", text: error.message || "Failed to link account" })
setMessage({ type: "error", text: data.message || "Failed to link account" })
return return
} }
const data = await res.json() if (data?.url) {
// If the API returns a URL, redirect to it window.location.assign(data.url)
if (data && data.url) {
window.location.assign(data.url as string)
} }
} catch { } catch {
setMessage({ type: "error", text: "Failed to initiate account linking" }) setMessage({ type: "error", text: "Failed to initiate account linking" })
@@ -105,19 +112,15 @@ export function SettingsAccountsTab() {
setMessage(null) setMessage(null)
try { try {
const res = await fetch("/api/auth/unlink-account", { const { error } = await authClient.unlinkAccount({
method: "POST", providerId,
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ providerId }),
}) })
if (res.ok) { if (error) {
setMessage({ type: "error", text: error.message || "Failed to unlink account" })
} else {
setMessage({ type: "success", text: `${providerConfig[providerId]?.name || providerId} account unlinked` }) setMessage({ type: "success", text: `${providerConfig[providerId]?.name || providerId} account unlinked` })
await refreshData() await refreshData()
} else {
const data = await res.json()
setMessage({ type: "error", text: data.message || "Failed to unlink account" })
} }
} catch { } catch {
setMessage({ type: "error", text: "Failed to unlink account" }) setMessage({ type: "error", text: "Failed to unlink account" })
@@ -137,7 +140,7 @@ export function SettingsAccountsTab() {
className="space-y-6" className="space-y-6"
> >
{/* Warning if only one auth method */} {/* Warning if only one auth method */}
{isOnlyAuthMethod && ( {isOnlyAuthMethod && !isLoadingAuthMethods && (
<div className="flex items-start gap-3 p-4 rounded-xl border border-yellow-500/30 bg-yellow-500/5"> <div className="flex items-start gap-3 p-4 rounded-xl border border-yellow-500/30 bg-yellow-500/5">
<Shield className="h-5 w-5 text-yellow-400 shrink-0 mt-0.5" /> <Shield className="h-5 w-5 text-yellow-400 shrink-0 mt-0.5" />
<div> <div>
@@ -162,7 +165,21 @@ export function SettingsAccountsTab() {
Linked Accounts Linked Accounts
</h3> </h3>
{loading ? ( {accountsError ? (
<div className="text-center py-4">
<p className="text-sm text-red-400 mb-2">{accountsError}</p>
<button
onClick={() => {
setAccountsError(null)
setIsLoadingAccounts(true)
fetchAccounts()
}}
className="text-sm text-primary hover:underline cursor-pointer"
>
Retry
</button>
</div>
) : isLoadingAccounts ? (
<div className="flex items-center justify-center py-4"> <div className="flex items-center justify-center py-4">
<Loader2 className="h-5 w-5 animate-spin text-text/40" /> <Loader2 className="h-5 w-5 animate-spin text-text/40" />
</div> </div>