Task 5: Refactor SettingsSecurityTab

- Accept authMethods, isLoadingAuthMethods, onRefreshAuthMethods as props
- Fix passkey list URL to /api/auth/passkey/list-user-passkeys
- Fix passkey delete/rename to use /api/auth/passkey/delete-passkey and /api/auth/passkey/update-passkey
- Add error feedback with retry UI for passkey loading failures
- Add passkeyMessage state for success/error feedback
- Keep password validation at 10 characters minimum
- Call onRefreshAuthMethods after password and passkey operations
This commit is contained in:
2026-04-26 12:22:06 -05:00
parent 45c071692e
commit 2063c0cb83
+107 -58
View File
@@ -1,6 +1,6 @@
"use client" "use client"
import { useState, useRef, useEffect } from "react" import { useState, useEffect } from "react"
import { authClient } from "@/lib/auth-client" import { authClient } from "@/lib/auth-client"
import { Loader2, Key, Fingerprint, Plus, Trash2, Pencil, Check, X, Shield } from "lucide-react" import { Loader2, Key, Fingerprint, Plus, Trash2, Pencil, Check, X, Shield } from "lucide-react"
import { motion, AnimatePresence } from "motion/react" import { motion, AnimatePresence } from "motion/react"
@@ -19,11 +19,16 @@ interface AuthMethods {
totalAuthMethods: number totalAuthMethods: number
} }
export function SettingsSecurityTab() { interface SettingsSecurityTabProps {
const [authMethods, setAuthMethods] = useState<AuthMethods | null>(null) authMethods: AuthMethods | null
isLoadingAuthMethods: boolean
onRefreshAuthMethods: () => Promise<void>
}
export function SettingsSecurityTab({ authMethods, isLoadingAuthMethods, onRefreshAuthMethods }: SettingsSecurityTabProps) {
const [passkeys, setPasskeys] = useState<Passkey[]>([]) const [passkeys, setPasskeys] = useState<Passkey[]>([])
const [isLoadingAuthMethods, setIsLoadingAuthMethods] = useState(true)
const [isLoadingPasskeys, setIsLoadingPasskeys] = useState(true) const [isLoadingPasskeys, setIsLoadingPasskeys] = useState(true)
const [passkeysError, setPasskeysError] = useState<string | null>(null)
const [passwordMessage, setPasswordMessage] = useState<{ type: "success" | "error"; text: string } | null>(null) const [passwordMessage, setPasswordMessage] = useState<{ type: "success" | "error"; text: string } | null>(null)
const [isPasswordSubmitting, setIsPasswordSubmitting] = useState(false) const [isPasswordSubmitting, setIsPasswordSubmitting] = useState(false)
@@ -32,43 +37,48 @@ export function SettingsSecurityTab() {
const [newPassword, setNewPassword] = useState("") const [newPassword, setNewPassword] = useState("")
const [confirmPassword, setConfirmPassword] = useState("") const [confirmPassword, setConfirmPassword] = useState("")
// Passkey editing state // Passkey state
const [editingPasskeyId, setEditingPasskeyId] = useState<string | null>(null) const [editingPasskeyId, setEditingPasskeyId] = useState<string | null>(null)
const [editingName, setEditingName] = useState("") const [editingName, setEditingName] = useState("")
const [isUpdatingPasskey, setIsUpdatingPasskey] = useState(false) const [isUpdatingPasskey, setIsUpdatingPasskey] = useState(false)
const [isDeletingPasskey, setIsDeletingPasskey] = useState<string | null>(null) const [isDeletingPasskey, setIsDeletingPasskey] = useState<string | null>(null)
const [isAddingPasskey, setIsAddingPasskey] = useState(false) const [isAddingPasskey, setIsAddingPasskey] = useState(false)
const [passkeyMessage, setPasskeyMessage] = useState<{ type: "success" | "error"; text: string } | null>(null)
const fetchRef = useRef(false)
useEffect(() => { useEffect(() => {
if (fetchRef.current) return fetchPasskeys()
fetchRef.current = true // eslint-disable-next-line react-hooks/exhaustive-deps
let cancelled = false
Promise.all([
fetch("/api/user/me/auth-methods", { credentials: "include" }).then(r => r.ok ? r.json() : null).catch(() => null),
fetch("/api/auth/passkey/list", { credentials: "include" }).then(r => r.ok ? r.json() : []).catch(() => []),
]).then(([methodsData, passkeyData]) => {
if (cancelled) return
if (methodsData) setAuthMethods(methodsData)
if (passkeyData) setPasskeys(Array.isArray(passkeyData) ? passkeyData : [])
setIsLoadingAuthMethods(false)
setIsLoadingPasskeys(false)
})
return () => { cancelled = true }
}, []) }, [])
const refreshAuthMethods = async () => { async function fetchPasskeys() {
const res = await fetch("/api/user/me/auth-methods", { credentials: "include" }) try {
if (res.ok) setAuthMethods(await res.json()) const res = await fetch("/api/auth/passkey/list-user-passkeys", { credentials: "include" })
if (!res.ok) {
setPasskeysError("Failed to load passkeys")
setPasskeys([])
} else {
const data = await res.json()
setPasskeys(Array.isArray(data) ? data : [])
setPasskeysError(null)
}
} catch {
setPasskeysError("Failed to load passkeys. Please try again.")
setPasskeys([])
} finally {
setIsLoadingPasskeys(false)
}
} }
const refreshPasskeys = async () => { const refreshPasskeys = async () => {
const res = await fetch("/api/auth/passkey/list", { credentials: "include" }) try {
const res = await fetch("/api/auth/passkey/list-user-passkeys", { credentials: "include" })
if (res.ok) { if (res.ok) {
const data = await res.json() const data = await res.json()
setPasskeys(Array.isArray(data) ? data : []) setPasskeys(Array.isArray(data) ? data : [])
setPasskeysError(null)
}
} catch {
// silently fail on refresh
} }
} }
@@ -88,6 +98,7 @@ export function SettingsSecurityTab() {
setIsPasswordSubmitting(true) setIsPasswordSubmitting(true)
try {
if (authMethods?.hasPassword) { if (authMethods?.hasPassword) {
const { error } = await authClient.changePassword({ const { error } = await authClient.changePassword({
currentPassword, currentPassword,
@@ -101,6 +112,7 @@ export function SettingsSecurityTab() {
setCurrentPassword("") setCurrentPassword("")
setNewPassword("") setNewPassword("")
setConfirmPassword("") setConfirmPassword("")
await onRefreshAuthMethods()
} }
} else { } else {
const res = await fetch("/api/user/me/set-password", { const res = await fetch("/api/user/me/set-password", {
@@ -117,25 +129,30 @@ export function SettingsSecurityTab() {
setPasswordMessage({ type: "success", text: "Password set successfully" }) setPasswordMessage({ type: "success", text: "Password set successfully" })
setNewPassword("") setNewPassword("")
setConfirmPassword("") setConfirmPassword("")
await refreshAuthMethods() await onRefreshAuthMethods()
} }
} }
} catch {
setPasswordMessage({ type: "error", text: "An unexpected error occurred" })
} finally {
setIsPasswordSubmitting(false) setIsPasswordSubmitting(false)
} }
}
const handleAddPasskey = async () => { const handleAddPasskey = async () => {
setIsAddingPasskey(true) setIsAddingPasskey(true)
setPasskeyMessage(null)
try { try {
const { error } = await authClient.passkey.addPasskey() const { error } = await authClient.passkey.addPasskey()
if (error) { if (error) {
console.error("Failed to add passkey:", error) setPasskeyMessage({ type: "error", text: error.message || "Failed to add passkey" })
} else { } else {
setPasskeyMessage({ type: "success", text: "Passkey added successfully" })
await refreshPasskeys() await refreshPasskeys()
await refreshAuthMethods() await onRefreshAuthMethods()
} }
} catch { } catch {
// silently fail setPasskeyMessage({ type: "error", text: "Failed to add passkey. Please try again." })
} finally { } finally {
setIsAddingPasskey(false) setIsAddingPasskey(false)
} }
@@ -145,24 +162,57 @@ export function SettingsSecurityTab() {
if (authMethods && authMethods.totalAuthMethods <= 1) return if (authMethods && authMethods.totalAuthMethods <= 1) return
setIsDeletingPasskey(id) setIsDeletingPasskey(id)
setPasskeyMessage(null)
try { try {
const res = await fetch("/api/user/me/passkey/delete", { const res = await fetch("/api/auth/passkey/delete-passkey", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id }),
credentials: "include", credentials: "include",
body: JSON.stringify({ id }),
}) })
if (res.ok) {
if (!res.ok) {
const data = await res.json().catch(() => ({ message: "Failed to delete passkey" }))
setPasskeyMessage({ type: "error", text: data.message || "Failed to delete passkey" })
} else {
setPasskeyMessage({ type: "success", text: "Passkey deleted" })
await refreshPasskeys() await refreshPasskeys()
await refreshAuthMethods() await onRefreshAuthMethods()
} }
} catch { } catch {
// silently fail setPasskeyMessage({ type: "error", text: "Failed to delete passkey. Please try again." })
} finally { } finally {
setIsDeletingPasskey(null) setIsDeletingPasskey(null)
} }
} }
const handleRenamePasskey = async (id: string) => {
setIsUpdatingPasskey(true)
setPasskeyMessage(null)
try {
const res = await fetch("/api/auth/passkey/update-passkey", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ id, name: editingName }),
})
if (!res.ok) {
const data = await res.json().catch(() => ({ message: "Failed to rename passkey" }))
setPasskeyMessage({ type: "error", text: data.message || "Failed to rename passkey" })
} else {
setPasskeyMessage({ type: "success", text: "Passkey renamed" })
await refreshPasskeys()
setEditingPasskeyId(null)
setEditingName("")
}
} catch {
setPasskeyMessage({ type: "error", text: "Failed to rename passkey. Please try again." })
} finally {
setIsUpdatingPasskey(false)
}
}
const startEditingPasskey = (passkey: Passkey) => { const startEditingPasskey = (passkey: Passkey) => {
setEditingPasskeyId(passkey.id) setEditingPasskeyId(passkey.id)
setEditingName(passkey.name || "") setEditingName(passkey.name || "")
@@ -173,27 +223,6 @@ export function SettingsSecurityTab() {
setEditingName("") setEditingName("")
} }
const handleRenamePasskey = async (id: string) => {
setIsUpdatingPasskey(true)
try {
const res = await fetch("/api/user/me/passkey/update", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id, name: editingName }),
credentials: "include",
})
if (res.ok) {
await refreshPasskeys()
setEditingPasskeyId(null)
setEditingName("")
}
} catch {
// silently fail
} finally {
setIsUpdatingPasskey(false)
}
}
const isSingleAuthMethod = authMethods && authMethods.totalAuthMethods <= 1 const isSingleAuthMethod = authMethods && authMethods.totalAuthMethods <= 1
return ( return (
@@ -314,7 +343,27 @@ export function SettingsSecurityTab() {
</button> </button>
</div> </div>
{isLoadingPasskeys ? ( {passkeyMessage && (
<div className={`mb-3 text-sm ${passkeyMessage.type === "success" ? "text-green-400" : "text-red-400"}`}>
{passkeyMessage.text}
</div>
)}
{passkeysError ? (
<div className="text-center py-4">
<p className="text-sm text-red-400 mb-2">{passkeysError}</p>
<button
onClick={() => {
setPasskeysError(null)
setIsLoadingPasskeys(true)
fetchPasskeys()
}}
className="text-sm text-primary hover:underline cursor-pointer"
>
Retry
</button>
</div>
) : isLoadingPasskeys ? (
<div className="flex items-center gap-2 text-sm text-text/50"> <div className="flex items-center gap-2 text-sm text-text/50">
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
Loading passkeys... Loading passkeys...