From bea155005f268a20af363ce557f262ae9bc5e9cc Mon Sep 17 00:00:00 2001 From: Adrian Bonpin Date: Sun, 26 Apr 2026 11:11:48 -0500 Subject: [PATCH] feat: add security settings tab with password and passkey management --- components/profile/settings-security-tab.tsx | 421 +++++++++++++++++++ lib/api/user.ts | 97 ++++- 2 files changed, 517 insertions(+), 1 deletion(-) create mode 100644 components/profile/settings-security-tab.tsx diff --git a/components/profile/settings-security-tab.tsx b/components/profile/settings-security-tab.tsx new file mode 100644 index 0000000..a37f3c3 --- /dev/null +++ b/components/profile/settings-security-tab.tsx @@ -0,0 +1,421 @@ +"use client" + +import { useState, useEffect, useCallback } from "react" +import { authClient } from "@/lib/auth-client" +import { Loader2, Key, Fingerprint, Plus, Trash2, Pencil, Check, X, Shield } from "lucide-react" +import { motion, AnimatePresence } from "motion/react" + +interface Passkey { + id: string + name: string | null + deviceType: string + createdAt: string | null +} + +interface AuthMethods { + hasPassword: boolean + passkeyCount: number + oauthProviders: Array<{ providerId: string; id: string }> + totalAuthMethods: number +} + +export function SettingsSecurityTab() { + const [authMethods, setAuthMethods] = useState(null) + const [passkeys, setPasskeys] = useState([]) + const [isLoadingAuthMethods, setIsLoadingAuthMethods] = useState(true) + const [isLoadingPasskeys, setIsLoadingPasskeys] = useState(true) + const [passwordMessage, setPasswordMessage] = useState<{ type: "success" | "error"; text: string } | null>(null) + const [isPasswordSubmitting, setIsPasswordSubmitting] = useState(false) + + // Password form state + const [currentPassword, setCurrentPassword] = useState("") + const [newPassword, setNewPassword] = useState("") + const [confirmPassword, setConfirmPassword] = useState("") + + // Passkey editing state + const [editingPasskeyId, setEditingPasskeyId] = useState(null) + const [editingName, setEditingName] = useState("") + const [isUpdatingPasskey, setIsUpdatingPasskey] = useState(false) + const [isDeletingPasskey, setIsDeletingPasskey] = useState(null) + const [isAddingPasskey, setIsAddingPasskey] = useState(false) + + const fetchAuthMethods = useCallback(async () => { + try { + const res = await fetch("/api/user/me/auth-methods", { + credentials: "include", + }) + if (res.ok) { + const data = await res.json() + setAuthMethods(data) + } + } catch { + // silently fail + } finally { + setIsLoadingAuthMethods(false) + } + }, []) + + const fetchPasskeys = useCallback(async () => { + try { + const res = await fetch("/api/auth/passkey/list-user-passkeys", { + credentials: "include", + }) + if (res.ok) { + const data = await res.json() + setPasskeys(Array.isArray(data) ? data : []) + } + } catch { + // silently fail + } finally { + setIsLoadingPasskeys(false) + } + }, []) + + useEffect(() => { + fetchAuthMethods() + fetchPasskeys() + }, [fetchAuthMethods, fetchPasskeys]) + + const handlePasswordSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setPasswordMessage(null) + + if (newPassword !== confirmPassword) { + setPasswordMessage({ type: "error", text: "Passwords do not match" }) + return + } + + if (newPassword.length < 8) { + setPasswordMessage({ type: "error", text: "Password must be at least 8 characters" }) + return + } + + setIsPasswordSubmitting(true) + + if (authMethods?.hasPassword) { + const { error } = await authClient.changePassword({ + currentPassword, + newPassword, + }) + + if (error) { + setPasswordMessage({ type: "error", text: error.message || "Failed to change password" }) + } else { + setPasswordMessage({ type: "success", text: "Password changed successfully" }) + setCurrentPassword("") + setNewPassword("") + setConfirmPassword("") + } + } else { + const res = await fetch("/api/user/me/set-password", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ newPassword }), + credentials: "include", + }) + + if (!res.ok) { + const data = await res.json().catch(() => ({ error: "Failed to set password" })) + setPasswordMessage({ type: "error", text: data.error || "Failed to set password" }) + } else { + setPasswordMessage({ type: "success", text: "Password set successfully" }) + setNewPassword("") + setConfirmPassword("") + await fetchAuthMethods() + } + } + + setIsPasswordSubmitting(false) + } + + const handleAddPasskey = async () => { + setIsAddingPasskey(true) + try { + const { error } = await authClient.passkey.addPasskey() + if (error) { + // eslint-disable-next-line no-console + console.error("Failed to add passkey:", error) + } else { + await fetchPasskeys() + await fetchAuthMethods() + } + } catch { + // silently fail + } finally { + setIsAddingPasskey(false) + } + } + + const handleDeletePasskey = async (id: string) => { + if (authMethods && authMethods.totalAuthMethods <= 1) return + + setIsDeletingPasskey(id) + try { + const res = await fetch("/api/auth/passkey/delete-passkey", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id }), + credentials: "include", + }) + if (res.ok) { + await fetchPasskeys() + await fetchAuthMethods() + } + } catch { + // silently fail + } finally { + setIsDeletingPasskey(null) + } + } + + const startEditingPasskey = (passkey: Passkey) => { + setEditingPasskeyId(passkey.id) + setEditingName(passkey.name || "") + } + + const cancelEditingPasskey = () => { + setEditingPasskeyId(null) + setEditingName("") + } + + const handleRenamePasskey = async (id: string) => { + setIsUpdatingPasskey(true) + try { + const res = await fetch("/api/auth/passkey/update-passkey", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id, name: editingName }), + credentials: "include", + }) + if (res.ok) { + await fetchPasskeys() + setEditingPasskeyId(null) + setEditingName("") + } + } catch { + // silently fail + } finally { + setIsUpdatingPasskey(false) + } + } + + const isSingleAuthMethod = authMethods && authMethods.totalAuthMethods <= 1 + + return ( + + {/* Warning for single auth method */} + + {isSingleAuthMethod && ( + + +

+ Single authentication method — consider adding a passkey or linking a social account +

+
+ )} +
+ + {/* Password Section */} +
+

+ + Password +

+ + {isLoadingAuthMethods ? ( +
+ + Loading... +
+ ) : ( +
+ {authMethods?.hasPassword && ( +
+ + setCurrentPassword(e.target.value)} + className="w-full px-3 py-2 rounded-lg bg-text/5 border border-border text-sm text-text focus:outline-none focus:border-primary/60 focus:ring-1 focus:ring-primary/30 transition-colors" + placeholder="Enter current password" + required + /> +
+ )} +
+ + setNewPassword(e.target.value)} + className="w-full px-3 py-2 rounded-lg bg-text/5 border border-border text-sm text-text focus:outline-none focus:border-primary/60 focus:ring-1 focus:ring-primary/30 transition-colors" + placeholder={authMethods?.hasPassword ? "Enter new password" : "Set a password"} + required + /> +
+
+ + setConfirmPassword(e.target.value)} + className="w-full px-3 py-2 rounded-lg bg-text/5 border border-border text-sm text-text focus:outline-none focus:border-primary/60 focus:ring-1 focus:ring-primary/30 transition-colors" + placeholder="Confirm password" + required + /> +
+
+ + {passwordMessage && ( +

+ {passwordMessage.text} +

+ )} +
+
+ )} +
+ + {/* Passkey Section */} +
+
+

+ + Passkeys +

+ +
+ + {isLoadingPasskeys ? ( +
+ + Loading passkeys... +
+ ) : passkeys.length === 0 ? ( +

No passkeys registered.

+ ) : ( +
+ + {passkeys.map((pk) => ( + +
+ +
+ {editingPasskeyId === pk.id ? ( +
+ setEditingName(e.target.value)} + className="px-2 py-1 rounded bg-text/5 border border-border text-sm text-text focus:outline-none focus:border-primary/60" + autoFocus + /> + + +
+ ) : ( + <> +

+ {pk.name || "Unnamed passkey"} +

+

+ {pk.deviceType} + {pk.createdAt && ( + + · Added {new Date(pk.createdAt).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + })} + + )} +

+ + )} +
+
+ {editingPasskeyId !== pk.id && ( +
+ + +
+ )} +
+ ))} +
+
+ )} +
+
+ ) +} diff --git a/lib/api/user.ts b/lib/api/user.ts index 31d9a16..f79ac7d 100644 --- a/lib/api/user.ts +++ b/lib/api/user.ts @@ -1,8 +1,9 @@ import { Elysia, t } from "elysia" import { auth } from "@/lib/auth" import { db } from "@/lib/db/index" -import { user, performanceEntries, games, gameVersions, hardware } from "@/lib/db/schema" +import { user, performanceEntries, games, gameVersions, hardware, account, passkey } from "@/lib/db/schema" import { eq, sql, and, desc } from "drizzle-orm" +import { hashPassword } from "better-auth/crypto" export const userRoutes = new Elysia({ prefix: "/user" }) .get( @@ -136,6 +137,100 @@ export const userRoutes = new Elysia({ prefix: "/user" }) return sessions }, ) + .get( + "/me/auth-methods", + async ({ request, set }) => { + const session = await auth.api.getSession({ + headers: request.headers, + }) + + if (!session) { + set.status = 401 + return { error: "Unauthorized" } + } + + // Count accounts by provider + const accounts = await db + .select({ providerId: account.providerId, id: account.id }) + .from(account) + .where(eq(account.userId, session.user.id)) + + // Count passkeys + const [passkeyRow] = await db + .select({ count: sql`count(*)::int` }) + .from(passkey) + .where(eq(passkey.userId, session.user.id)) + + // Check if user has a password (from accounts where providerId is "credential") + const hasPassword = accounts.some((a) => a.providerId === "credential") + + // OAuth providers + const oauthProviders = accounts + .filter((a) => a.providerId !== "credential") + .map((a) => ({ + providerId: a.providerId, + id: a.id, + })) + + // Total auth methods = passwords + passkeys + oauth accounts + const totalAuthMethods = + (hasPassword ? 1 : 0) + (passkeyRow?.count ?? 0) + oauthProviders.length + + return { + hasPassword, + passkeyCount: passkeyRow?.count ?? 0, + oauthProviders, + totalAuthMethods, + } + }, + ) + .post( + "/me/set-password", + async ({ request, body, set }) => { + const session = await auth.api.getSession({ + headers: request.headers, + }) + + if (!session) { + set.status = 401 + return { error: "Unauthorized" } + } + + // Check if user already has a password + const existing = await db + .select({ id: account.id }) + .from(account) + .where( + and( + eq(account.userId, session.user.id), + eq(account.providerId, "credential") + ) + ) + .limit(1) + + if (existing.length > 0) { + set.status = 400 + return { error: "Password already set" } + } + + const hashed = await hashPassword(body.newPassword) + + await db.insert(account).values({ + id: crypto.randomUUID(), + userId: session.user.id, + providerId: "credential", + accountId: session.user.id, + password: hashed, + }) + + return { success: true } + }, + { + body: t.Object({ + newPassword: t.String({ minLength: 8 }), + }), + }, + ) .get( "/profile/:id/contributions", async ({ params, query, set }) => {