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:
@@ -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...
|
||||||
|
|||||||
Reference in New Issue
Block a user