merge: convert to bun workspaces monorepo
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
"use client"
|
||||
|
||||
import Image from "next/image"
|
||||
import Link from "next/link"
|
||||
import { Cpu, Clock, TrendingUp, CheckCircle } from "lucide-react"
|
||||
import { motion } from "motion/react"
|
||||
import type { ContributionEntry } from "@/types/api"
|
||||
|
||||
interface ContributionListProps {
|
||||
entries: ContributionEntry[]
|
||||
showViewAll?: boolean
|
||||
totalCount?: number
|
||||
}
|
||||
|
||||
export function ContributionList({ entries, showViewAll = false, totalCount }: ContributionListProps) {
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12 text-text/40">
|
||||
<TrendingUp className="h-8 w-8 mx-auto mb-2" />
|
||||
<p>No contributions yet</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{showViewAll && totalCount && totalCount > entries.length && (
|
||||
<div className="flex justify-end">
|
||||
<span className="text-xs text-text/40">
|
||||
Showing {entries.length} of {totalCount}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{entries.map((entry, i) => (
|
||||
<motion.div
|
||||
key={entry.id}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.05 }}
|
||||
>
|
||||
<Link
|
||||
href={`/game/${entry.gameId}`}
|
||||
className="flex items-center gap-4 p-3 rounded-lg bg-text/5 border border-border hover:border-primary/30 transition-colors"
|
||||
>
|
||||
{entry.gameHeaderImage ? (
|
||||
<Image
|
||||
src={entry.gameHeaderImage}
|
||||
alt={entry.gameTitle}
|
||||
width={80}
|
||||
height={36}
|
||||
unoptimized
|
||||
className="rounded h-9 w-20 object-cover shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-9 w-20 rounded bg-text/10 shrink-0" />
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{entry.gameTitle}</p>
|
||||
<div className="flex items-center gap-3 mt-1 text-xs text-text/50">
|
||||
<span className="flex items-center gap-1">
|
||||
<Cpu className="h-3 w-3" />
|
||||
{entry.hardwareName}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{new Date(entry.createdAt).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-right shrink-0">
|
||||
<div className="text-sm font-bold text-primary">
|
||||
{entry.fpsAvg.toFixed(0)} FPS
|
||||
</div>
|
||||
{entry.verifiedAt && (
|
||||
<div className="flex items-center gap-1 text-xs text-green-400 mt-0.5">
|
||||
<CheckCircle className="h-3 w-3" />
|
||||
Verified
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
"use client"
|
||||
|
||||
import { Shield, Crown, CheckCircle, Mail } from "lucide-react"
|
||||
import { motion } from "motion/react"
|
||||
|
||||
interface ProfileHeaderProps {
|
||||
name: string
|
||||
email?: string
|
||||
role: string | null
|
||||
verified: boolean
|
||||
createdAt: string
|
||||
image?: string | null
|
||||
}
|
||||
|
||||
const roleConfig: Record<string, { label: string; color: string; icon: typeof Crown }> = {
|
||||
admin: { label: "Admin", color: "bg-yellow-500/20 text-yellow-400 border-yellow-500/30", icon: Crown },
|
||||
contributor: { label: "Contributor", color: "bg-blue-500/20 text-blue-400 border-blue-500/30", icon: Shield },
|
||||
user: { label: "Member", color: "bg-text/10 text-text/60 border-text/20", icon: Shield },
|
||||
}
|
||||
|
||||
function isR2Avatar(url: string): boolean {
|
||||
return url.includes(".r2.dev")
|
||||
}
|
||||
|
||||
function getInitials(name: string): string {
|
||||
return name.charAt(0).toUpperCase()
|
||||
}
|
||||
|
||||
export function ProfileHeader({ name, email, role, verified, createdAt, image }: ProfileHeaderProps) {
|
||||
const config = roleConfig[role || "user"] || roleConfig.user
|
||||
const RoleIcon = config.icon
|
||||
|
||||
const joinDate = new Date(createdAt).toLocaleDateString("en-US", {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
})
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="flex flex-col gap-2"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{image ? (
|
||||
<div className={`shrink-0 w-16 h-16 rounded-full overflow-hidden ${isR2Avatar(image) ? "ring-2 ring-primary/30 ring-offset-2 ring-offset-background" : ""}`}>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={image} alt={`${name}'s profile photo`} className="w-full h-full object-cover" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="shrink-0 w-16 h-16 rounded-full overflow-hidden flex items-center justify-center bg-primary/10 text-primary text-xl font-bold">
|
||||
{getInitials(name)}
|
||||
</div>
|
||||
)}
|
||||
<h1 className="text-2xl font-bold">{name}</h1>
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium border ${config.color}`}>
|
||||
<RoleIcon className="h-3 w-3" />
|
||||
{config.label}
|
||||
</span>
|
||||
{verified && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-green-500/20 text-green-400 border border-green-500/30">
|
||||
<CheckCircle className="h-3 w-3" />
|
||||
Verified
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-sm text-text/50">
|
||||
<span>Member since {joinDate}</span>
|
||||
{email && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Mail className="h-3 w-3" />
|
||||
{email}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from "react"
|
||||
import { motion, AnimatePresence } from "motion/react"
|
||||
import { Camera, Trash2, Loader2, Upload } from "lucide-react"
|
||||
|
||||
interface ProfilePhotoUploadProps {
|
||||
currentImage: string | null
|
||||
userName: string
|
||||
userId: string
|
||||
onImageChange?: (url: string | null) => void
|
||||
}
|
||||
|
||||
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp"]
|
||||
const MAX_SIZE_MB = 5
|
||||
const MAX_SIZE_BYTES = MAX_SIZE_MB * 1024 * 1024
|
||||
|
||||
function isR2Avatar(url: string): boolean {
|
||||
return url.includes(".r2.dev")
|
||||
}
|
||||
|
||||
function getInitials(name: string): string {
|
||||
return name.charAt(0).toUpperCase()
|
||||
}
|
||||
|
||||
export function ProfilePhotoUpload({ currentImage, userName, onImageChange }: ProfilePhotoUploadProps) {
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(currentImage)
|
||||
const [uploadState, setUploadState] = useState<"idle" | "uploading" | "success" | "error">("idle")
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const tempUrlRef = useRef<string | null>(null)
|
||||
|
||||
const cleanupTempUrl = () => {
|
||||
if (tempUrlRef.current) {
|
||||
URL.revokeObjectURL(tempUrlRef.current)
|
||||
tempUrlRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
return cleanupTempUrl
|
||||
}, [])
|
||||
|
||||
// Sync previewUrl when parent updates currentImage externally
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setPreviewUrl(currentImage)
|
||||
}, [currentImage])
|
||||
|
||||
const validateFile = (file: File): string | null => {
|
||||
if (!ALLOWED_TYPES.includes(file.type)) {
|
||||
return "Only JPEG, PNG, and WebP images are allowed."
|
||||
}
|
||||
if (file.size > MAX_SIZE_BYTES) {
|
||||
return "File must be under 5MB."
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const handleFile = useCallback(async (file: File) => {
|
||||
const validationError = validateFile(file)
|
||||
if (validationError) {
|
||||
setErrorMessage(validationError)
|
||||
setUploadState("error")
|
||||
return
|
||||
}
|
||||
|
||||
setErrorMessage(null)
|
||||
setUploadState("uploading")
|
||||
cleanupTempUrl()
|
||||
|
||||
const objectUrl = URL.createObjectURL(file)
|
||||
tempUrlRef.current = objectUrl
|
||||
setPreviewUrl(objectUrl)
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append("photo", file)
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/user/profile-photo", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
throw new Error(data.error || "Upload failed")
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
if (data.url) {
|
||||
setPreviewUrl(data.url)
|
||||
cleanupTempUrl()
|
||||
onImageChange?.(data.url)
|
||||
}
|
||||
setUploadState("success")
|
||||
} catch (err) {
|
||||
setErrorMessage(err instanceof Error ? err.message : "Upload failed")
|
||||
setUploadState("error")
|
||||
setPreviewUrl(currentImage)
|
||||
cleanupTempUrl()
|
||||
}
|
||||
}, [currentImage, onImageChange])
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) handleFile(file)
|
||||
e.target.value = ""
|
||||
}
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(false)
|
||||
const file = e.dataTransfer.files?.[0]
|
||||
if (file) handleFile(file)
|
||||
}, [handleFile])
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(true)
|
||||
}, [])
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(false)
|
||||
}, [])
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!previewUrl || !isR2Avatar(previewUrl)) return
|
||||
|
||||
setUploadState("uploading")
|
||||
try {
|
||||
const res = await fetch("/api/user/profile-photo", {
|
||||
method: "DELETE",
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
throw new Error(data.error || "Delete failed")
|
||||
}
|
||||
|
||||
setPreviewUrl(null)
|
||||
setUploadState("idle")
|
||||
setErrorMessage(null)
|
||||
onImageChange?.(null)
|
||||
} catch (err) {
|
||||
setErrorMessage(err instanceof Error ? err.message : "Delete failed")
|
||||
setUploadState("error")
|
||||
}
|
||||
}
|
||||
|
||||
const triggerFileInput = () => {
|
||||
fileInputRef.current?.click()
|
||||
}
|
||||
|
||||
const showDelete = previewUrl ? isR2Avatar(previewUrl) : false
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-text/[0.03] p-5">
|
||||
<h3 className="text-sm font-medium uppercase tracking-wider text-text/60 mb-4">Profile Photo</h3>
|
||||
|
||||
<div className="flex flex-col sm:flex-row items-center gap-5">
|
||||
{/* Avatar area */}
|
||||
<div
|
||||
className={`relative shrink-0 rounded-full overflow-hidden w-[128px] h-[128px] cursor-pointer transition-all ${
|
||||
isDragging ? "ring-2 ring-primary ring-offset-2 ring-offset-background" : ""
|
||||
}`}
|
||||
onClick={triggerFileInput}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label="Upload profile photo"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault()
|
||||
triggerFileInput()
|
||||
}
|
||||
}}
|
||||
>
|
||||
{previewUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt={`${userName}'s profile photo`}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center bg-primary/10 text-primary text-4xl font-bold select-none">
|
||||
{getInitials(userName)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upload overlay */}
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/40 opacity-0 hover:opacity-100 transition-opacity">
|
||||
<Camera className="h-8 w-8 text-white" />
|
||||
</div>
|
||||
|
||||
{uploadState === "uploading" && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/50">
|
||||
<Loader2 className="h-8 w-8 text-white animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={triggerFileInput}
|
||||
className="flex items-center justify-center gap-2 px-4 py-2 rounded-lg bg-primary text-white text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed shrink-0 cursor-pointer"
|
||||
disabled={uploadState === "uploading"}
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
Upload Photo
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{showDelete && (
|
||||
<motion.button
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.8 }}
|
||||
onClick={handleDelete}
|
||||
disabled={uploadState === "uploading"}
|
||||
className="flex items-center justify-center w-10 h-10 rounded-lg border border-border bg-text/5 text-text/60 hover:text-red-400 hover:border-red-400/30 transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
|
||||
aria-label="Delete profile photo"
|
||||
title="Delete profile photo"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</motion.button>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-text/50">
|
||||
JPEG, PNG, or WebP. Max 5MB.
|
||||
</p>
|
||||
|
||||
<AnimatePresence>
|
||||
{errorMessage && (
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: -5 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -5 }}
|
||||
className="text-sm text-red-400"
|
||||
>
|
||||
{errorMessage}
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{uploadState === "success" && !errorMessage && (
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: -5 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="text-sm text-green-400"
|
||||
>
|
||||
Photo updated successfully.
|
||||
</motion.p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hidden file input */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
onChange={handleInputChange}
|
||||
className="hidden"
|
||||
aria-hidden="true"
|
||||
id="profile-photo-input"
|
||||
/>
|
||||
<label htmlFor="profile-photo-input" className="sr-only">
|
||||
Choose profile photo
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { Loader2, Link as LinkIcon, Unlink, Shield } from "lucide-react"
|
||||
import { motion } from "motion/react"
|
||||
import { FaGoogle, FaDiscord } from "react-icons/fa"
|
||||
import { authClient } from "@/lib/auth-client"
|
||||
|
||||
interface LinkedAccount {
|
||||
id: string
|
||||
providerId: string
|
||||
accountId: string
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
userId: string
|
||||
scopes: string[]
|
||||
}
|
||||
|
||||
interface AuthMethods {
|
||||
hasPassword: boolean
|
||||
passkeyCount: number
|
||||
oauthProviders: { providerId: string; id: string }[]
|
||||
totalAuthMethods: number
|
||||
}
|
||||
|
||||
const providerConfig: Record<string, { name: string; icon: React.ComponentType<{ className?: string }> | 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" },
|
||||
}
|
||||
|
||||
interface SettingsAccountsTabProps {
|
||||
authMethods: AuthMethods | null
|
||||
isLoadingAuthMethods: boolean
|
||||
onRefreshAuthMethods: () => Promise<void>
|
||||
}
|
||||
|
||||
export function SettingsAccountsTab({ authMethods, isLoadingAuthMethods, onRefreshAuthMethods }: SettingsAccountsTabProps) {
|
||||
const [accounts, setAccounts] = useState<LinkedAccount[]>([])
|
||||
const [isLoadingAccounts, setIsLoadingAccounts] = useState(true)
|
||||
const [accountsError, setAccountsError] = useState<string | null>(null)
|
||||
const [unlinking, setUnlinking] = useState<string | null>(null)
|
||||
const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(() => {
|
||||
// Check for OAuth callback success on initial render
|
||||
if (typeof window !== "undefined") {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
if (params.has("linked")) {
|
||||
window.history.replaceState({}, "", window.location.pathname)
|
||||
return { type: "success", text: "Account linked successfully!" }
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
fetchAccounts()
|
||||
}, [])
|
||||
|
||||
async function fetchAccounts() {
|
||||
try {
|
||||
const { data, error } = await authClient.listAccounts()
|
||||
if (error || !data) {
|
||||
setAccountsError("Failed to load linked accounts")
|
||||
setAccounts([])
|
||||
} else {
|
||||
setAccounts(Array.isArray(data) ? data : [])
|
||||
setAccountsError(null)
|
||||
}
|
||||
} catch {
|
||||
setAccountsError("Failed to load linked accounts. Please try again.")
|
||||
setAccounts([])
|
||||
} finally {
|
||||
setIsLoadingAccounts(false)
|
||||
}
|
||||
}
|
||||
|
||||
const refreshAccounts = async () => {
|
||||
try {
|
||||
const { data } = await authClient.listAccounts()
|
||||
if (data) setAccounts(Array.isArray(data) ? data : accounts)
|
||||
} catch {
|
||||
// silently fail on refresh
|
||||
}
|
||||
}
|
||||
|
||||
const refreshData = async () => {
|
||||
await Promise.all([refreshAccounts(), onRefreshAuthMethods()])
|
||||
}
|
||||
|
||||
const handleLink = async (provider: "google" | "discord") => {
|
||||
setMessage(null)
|
||||
try {
|
||||
const { data, error } = await authClient.linkSocial({
|
||||
provider,
|
||||
callbackURL: window.location.origin + "/profile?linked=true",
|
||||
})
|
||||
|
||||
if (error) {
|
||||
setMessage({ type: "error", text: error.message || "Failed to link account" })
|
||||
return
|
||||
}
|
||||
|
||||
if (data?.url) {
|
||||
window.location.assign(data.url)
|
||||
}
|
||||
} catch {
|
||||
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 { error } = await authClient.unlinkAccount({
|
||||
providerId,
|
||||
})
|
||||
|
||||
if (error) {
|
||||
setMessage({ type: "error", text: error.message || "Failed to unlink account" })
|
||||
} else {
|
||||
setMessage({ type: "success", text: `${providerConfig[providerId]?.name || providerId} account unlinked` })
|
||||
await refreshData()
|
||||
}
|
||||
} 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 (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="space-y-6"
|
||||
>
|
||||
{/* Warning if only one auth method */}
|
||||
{isOnlyAuthMethod && !isLoadingAuthMethods && (
|
||||
<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" />
|
||||
<div>
|
||||
<p className="text-sm text-yellow-400 font-medium">Single authentication method</p>
|
||||
<p className="text-xs text-text/50 mt-1">
|
||||
You only have one way to sign in. Consider linking a social account or adding a passkey.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{message && (
|
||||
<p className={`text-sm ${message.type === "success" ? "text-green-400" : "text-red-400"}`}>
|
||||
{message.text}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Linked Accounts */}
|
||||
<div className="rounded-xl border border-border bg-text/[0.03] p-5">
|
||||
<h3 className="text-sm font-medium uppercase tracking-wider text-text/60 mb-4 flex items-center gap-2">
|
||||
<LinkIcon className="h-4 w-4" />
|
||||
Linked Accounts
|
||||
</h3>
|
||||
|
||||
{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">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-text/40" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{/* Password status */}
|
||||
{authMethods && (
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-text/5 border border-border">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-8 w-8 rounded-lg bg-text/10 flex items-center justify-center text-text/40 text-sm font-bold">
|
||||
●
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">Password</p>
|
||||
<p className="text-xs text-text/40">
|
||||
{authMethods.hasPassword ? "Configured" : "Not set"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${authMethods.hasPassword ? "bg-green-500/10 text-green-400 border border-green-500/20" : "bg-text/10 text-text/40 border border-border"}`}>
|
||||
{authMethods.hasPassword ? "Active" : "Inactive"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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 (
|
||||
<div key={account.id} className="flex items-center justify-between p-3 rounded-lg bg-text/5 border border-border">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`h-8 w-8 rounded-lg flex items-center justify-center ${config.bgColor} border`}>
|
||||
{config.icon ? <config.icon className={`h-4 w-4 ${config.color}`} /> : <LinkIcon className={`h-4 w-4 ${config.color}`} />}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">{config.name}</p>
|
||||
<p className="text-xs text-text/40">Linked</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleUnlink(account.providerId)}
|
||||
disabled={!canUnlink || unlinking === account.providerId}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-red-500/20 text-xs font-medium text-red-400 hover:bg-red-500/10 transition-colors disabled:opacity-30 disabled:cursor-not-allowed cursor-pointer"
|
||||
>
|
||||
{unlinking === account.providerId ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Unlink className="h-3 w-3" />
|
||||
)}
|
||||
Unlink
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Passkeys count */}
|
||||
{authMethods && authMethods.passkeyCount > 0 && (
|
||||
<div className="flex items-center justify-between p-3 rounded-lg bg-text/5 border border-border">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-8 w-8 rounded-lg bg-text/10 flex items-center justify-center text-text/40">
|
||||
<LinkIcon className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">Passkeys</p>
|
||||
<p className="text-xs text-text/40">{authMethods.passkeyCount} registered</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-green-500/10 text-green-400 border border-green-500/20">
|
||||
Active
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Link New Account */}
|
||||
{availableProviders.length > 0 && (
|
||||
<div className="rounded-xl border border-border bg-text/[0.03] p-5">
|
||||
<h3 className="text-sm font-medium uppercase tracking-wider text-text/60 mb-4">Link a Social Account</h3>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{availableProviders.map((provider) => {
|
||||
const config = providerConfig[provider]
|
||||
if (!config) return null
|
||||
return (
|
||||
<button
|
||||
key={provider}
|
||||
onClick={() => handleLink(provider as "google" | "discord")}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg border ${config.bgColor} ${config.color} text-sm font-medium hover:opacity-80 transition-opacity cursor-pointer`}
|
||||
>
|
||||
{config.icon && <config.icon className="h-4 w-4" />}
|
||||
Link {config.name}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,474 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, useCallback } from "react"
|
||||
import { authClient } from "@/lib/auth-client"
|
||||
import {
|
||||
Loader2,
|
||||
Key,
|
||||
Plus,
|
||||
Trash2,
|
||||
Copy,
|
||||
Check,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
} from "lucide-react"
|
||||
import { motion, AnimatePresence } from "motion/react"
|
||||
|
||||
// Raw API key type from Better Auth (dates are Date objects from the API)
|
||||
interface RawApiKey {
|
||||
id: string
|
||||
name: string | null
|
||||
start: string | null
|
||||
prefix: string | null
|
||||
enabled: boolean
|
||||
expiresAt: Date | null
|
||||
lastRequest: Date | null
|
||||
remaining: number | null
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
referenceId: string
|
||||
metadata: Record<string, unknown> | null
|
||||
permissions: Record<string, string[]> | null
|
||||
configId: string
|
||||
refillInterval: number | null
|
||||
refillAmount: number | null
|
||||
lastRefillAt: Date | null
|
||||
rateLimitEnabled: boolean
|
||||
rateLimitTimeWindow: number | null
|
||||
rateLimitMax: number | null
|
||||
requestCount: number
|
||||
}
|
||||
|
||||
interface CreatedApiKey extends RawApiKey {
|
||||
key: string // Only returned on creation
|
||||
}
|
||||
|
||||
export function SettingsApiKeysTab() {
|
||||
const [apiKeys, setApiKeys] = useState<RawApiKey[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Create form state
|
||||
const [showCreateForm, setShowCreateForm] = useState(false)
|
||||
const [newKeyName, setNewKeyName] = useState("")
|
||||
const [newKeyExpiresIn, setNewKeyExpiresIn] = useState("")
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
const [createdKey, setCreatedKey] = useState<CreatedApiKey | null>(null)
|
||||
const [createError, setCreateError] = useState<string | null>(null)
|
||||
|
||||
// Delete state
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null)
|
||||
const [message, setMessage] = useState<{
|
||||
type: "success" | "error"
|
||||
text: string
|
||||
} | null>(null)
|
||||
|
||||
// Copy state
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [showKey, setShowKey] = useState(false)
|
||||
|
||||
const fetchApiKeys = useCallback(async () => {
|
||||
try {
|
||||
const { data, error } = await authClient.apiKey.list({})
|
||||
if (error) {
|
||||
setError(error.message || "Failed to load API keys")
|
||||
setApiKeys([])
|
||||
} else {
|
||||
setApiKeys((data?.apiKeys ?? []) as RawApiKey[])
|
||||
setError(null)
|
||||
}
|
||||
} catch {
|
||||
setError("Failed to load API keys. Please try again.")
|
||||
setApiKeys([])
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchApiKeys()
|
||||
}, [fetchApiKeys])
|
||||
|
||||
const handleCreate = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setCreateError(null)
|
||||
|
||||
if (!newKeyName.trim()) {
|
||||
setCreateError("Name is required")
|
||||
return
|
||||
}
|
||||
|
||||
setIsCreating(true)
|
||||
try {
|
||||
const expiresIn = newKeyExpiresIn
|
||||
? parseInt(newKeyExpiresIn) * 24 * 60 * 60
|
||||
: undefined
|
||||
|
||||
const { data, error } = await authClient.apiKey.create({
|
||||
name: newKeyName.trim(),
|
||||
expiresIn,
|
||||
})
|
||||
|
||||
if (error) {
|
||||
setCreateError(error.message || "Failed to create API key")
|
||||
} else if (data) {
|
||||
setCreatedKey(data as unknown as CreatedApiKey)
|
||||
await fetchApiKeys()
|
||||
setNewKeyName("")
|
||||
setNewKeyExpiresIn("")
|
||||
setShowCreateForm(false)
|
||||
}
|
||||
} catch {
|
||||
setCreateError("An unexpected error occurred")
|
||||
} finally {
|
||||
setIsCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (keyId: string) => {
|
||||
setDeletingId(keyId)
|
||||
setMessage(null)
|
||||
try {
|
||||
const { error } = await authClient.apiKey.delete({ keyId })
|
||||
if (error) {
|
||||
setMessage({
|
||||
type: "error",
|
||||
text: error.message || "Failed to delete API key",
|
||||
})
|
||||
} else {
|
||||
setMessage({ type: "success", text: "API key deleted" })
|
||||
await fetchApiKeys()
|
||||
}
|
||||
} catch {
|
||||
setMessage({
|
||||
type: "error",
|
||||
text: "Failed to delete API key. Please try again.",
|
||||
})
|
||||
} finally {
|
||||
setDeletingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (date: Date | string | null): string => {
|
||||
if (!date) return "Never"
|
||||
try {
|
||||
const d = date instanceof Date ? date : new Date(date)
|
||||
return d.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
} catch {
|
||||
return String(date)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopyKey = async () => {
|
||||
if (createdKey?.key) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(createdKey.key)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
} catch {
|
||||
// Fallback for older browsers
|
||||
const textarea = document.createElement("textarea")
|
||||
textarea.value = createdKey.key
|
||||
document.body.appendChild(textarea)
|
||||
textarea.select()
|
||||
document.execCommand("copy")
|
||||
document.body.removeChild(textarea)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Show the created key modal
|
||||
if (createdKey) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className='space-y-6'
|
||||
>
|
||||
<div className='rounded-xl border border-green-500/30 bg-green-500/10 p-6'>
|
||||
<div className='flex items-center gap-2 mb-4'>
|
||||
<Check className='h-5 w-5 text-green-400' />
|
||||
<h3 className='text-sm font-medium text-green-400'>
|
||||
API Key Created
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<p className='text-sm text-text/70 mb-3'>
|
||||
Copy your API key now. You won't be able to see it again.
|
||||
</p>
|
||||
|
||||
<div className='relative mb-4'>
|
||||
<div className='w-full px-4 py-3 rounded-lg bg-text/5 border border-border font-mono text-sm break-all pr-20'>
|
||||
{showKey
|
||||
? createdKey.key
|
||||
: `${createdKey.key.substring(0, 12)}${"•".repeat(Math.min(createdKey.key.length - 12, 20))}`}
|
||||
</div>
|
||||
<div className='absolute right-2 top-1/2 -translate-y-1/2 flex gap-1'>
|
||||
<button
|
||||
onClick={() => setShowKey(!showKey)}
|
||||
className='p-1.5 rounded hover:bg-text/5 text-text/40 hover:text-text/80 transition-colors cursor-pointer'
|
||||
title={showKey ? "Hide key" : "Show key"}
|
||||
>
|
||||
{showKey ? (
|
||||
<EyeOff className='h-4 w-4' />
|
||||
) : (
|
||||
<Eye className='h-4 w-4' />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCopyKey}
|
||||
className='p-1.5 rounded hover:bg-text/5 text-text/40 hover:text-text/80 transition-colors cursor-pointer'
|
||||
title='Copy to clipboard'
|
||||
>
|
||||
{copied ? (
|
||||
<Check className='h-4 w-4 text-green-400' />
|
||||
) : (
|
||||
<Copy className='h-4 w-4' />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{createdKey.name && (
|
||||
<p className='text-xs text-text/50'>
|
||||
Name: <span className='text-text/70'>{createdKey.name}</span>
|
||||
</p>
|
||||
)}
|
||||
{createdKey.expiresAt && (
|
||||
<p className='text-xs text-text/50 mt-1'>
|
||||
Expires:{" "}
|
||||
<span className='text-text/70'>
|
||||
{formatDate(createdKey.expiresAt)}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => setCreatedKey(null)}
|
||||
className='mt-4 px-4 py-2 rounded-lg bg-primary text-white text-sm font-medium hover:bg-primary/90 transition-colors cursor-pointer'
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className='space-y-6'
|
||||
>
|
||||
{/* Header */}
|
||||
<div className='flex items-center justify-between'>
|
||||
<h3 className='text-sm font-medium uppercase tracking-wider text-text/60 flex items-center gap-2'>
|
||||
<Key className='h-4 w-4' />
|
||||
API Keys
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowCreateForm(!showCreateForm)
|
||||
setCreateError(null)
|
||||
}}
|
||||
className='flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-border text-xs font-medium hover:border-primary/40 hover:text-primary transition-colors cursor-pointer'
|
||||
>
|
||||
<Plus className='h-3.5 w-3.5' />
|
||||
{showCreateForm ? "Cancel" : "Create Key"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
{message && (
|
||||
<div
|
||||
className={`text-sm px-4 py-2 rounded-lg ${
|
||||
message.type === "success"
|
||||
? "bg-green-500/10 text-green-400 border border-green-500/20"
|
||||
: "bg-red-500/10 text-red-400 border border-red-500/20"
|
||||
}`}
|
||||
>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create Form */}
|
||||
<AnimatePresence>
|
||||
{showCreateForm && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className='rounded-xl border border-border bg-text/3 p-5 overflow-hidden'
|
||||
>
|
||||
<form onSubmit={handleCreate} className='space-y-3'>
|
||||
<div>
|
||||
<label className='block text-xs text-text/50 mb-1'>
|
||||
Key Name <span className='text-red-400'>*</span>
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
value={newKeyName}
|
||||
onChange={(e) => setNewKeyName(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='e.g. Decky Loader Plugin'
|
||||
maxLength={32}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className='block text-xs text-text/50 mb-1'>
|
||||
Expires In (days){" "}
|
||||
<span className='text-text/40'>(optional — leave empty for no expiry)</span>
|
||||
</label>
|
||||
<input
|
||||
type='number'
|
||||
value={newKeyExpiresIn}
|
||||
onChange={(e) => setNewKeyExpiresIn(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='Leave empty for no expiry'
|
||||
min={1}
|
||||
max={365}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{createError && (
|
||||
<p className='text-sm text-red-400 flex items-center gap-1'>
|
||||
<AlertCircle className='h-3.5 w-3.5' />
|
||||
{createError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type='submit'
|
||||
disabled={isCreating}
|
||||
className='flex items-center justify-center gap-2 px-4 py-2 rounded-lg bg-primary text-white text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer w-full sm:w-auto'
|
||||
>
|
||||
{isCreating ? (
|
||||
<Loader2 className='h-4 w-4 animate-spin' />
|
||||
) : (
|
||||
<Key className='h-4 w-4' />
|
||||
)}
|
||||
Create API Key
|
||||
</button>
|
||||
</form>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Key List */}
|
||||
{error ? (
|
||||
<div className='text-center py-8'>
|
||||
<p className='text-sm text-red-400 mb-2'>{error}</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setError(null)
|
||||
setIsLoading(true)
|
||||
fetchApiKeys()
|
||||
}}
|
||||
className='text-sm text-primary hover:underline cursor-pointer'
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
) : isLoading ? (
|
||||
<div className='flex items-center gap-2 text-sm text-text/50 py-8'>
|
||||
<Loader2 className='h-4 w-4 animate-spin' />
|
||||
Loading API keys...
|
||||
</div>
|
||||
) : apiKeys.length === 0 ? (
|
||||
<div className='text-center py-8'>
|
||||
<Key className='h-8 w-8 text-text/20 mx-auto mb-3' />
|
||||
<p className='text-sm text-text/50'>
|
||||
No API keys created yet.
|
||||
</p>
|
||||
<p className='text-xs text-text/30 mt-1'>
|
||||
Create an API key to use with the Decky Loader plugin or other external tools.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className='space-y-3'>
|
||||
<AnimatePresence>
|
||||
{apiKeys.map((ak) => (
|
||||
<motion.div
|
||||
key={ak.id}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className='flex items-center justify-between gap-3 rounded-lg border border-border bg-text/2 p-4'
|
||||
>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Key className='h-4 w-4 text-text/40 shrink-0' />
|
||||
<p className='text-sm font-medium text-text truncate'>
|
||||
{ak.name || "Unnamed key"}
|
||||
</p>
|
||||
{!ak.enabled && (
|
||||
<span className='text-xs px-1.5 py-0.5 rounded bg-yellow-500/10 text-yellow-400 border border-yellow-500/20'>
|
||||
Disabled
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className='flex flex-wrap items-center gap-x-3 gap-y-1 mt-1'>
|
||||
{ak.start && (
|
||||
<span className='text-xs font-mono text-text/40'>
|
||||
{ak.start}...
|
||||
</span>
|
||||
)}
|
||||
{ak.expiresAt && (
|
||||
<span className='text-xs text-text/40 flex items-center gap-1'>
|
||||
<Clock className='h-3 w-3' />
|
||||
Expires {formatDate(ak.expiresAt)}
|
||||
</span>
|
||||
)}
|
||||
{ak.lastRequest && (
|
||||
<span className='text-xs text-text/40'>
|
||||
Last used {formatDate(ak.lastRequest)}
|
||||
</span>
|
||||
)}
|
||||
<span className='text-xs text-text/30'>
|
||||
Created {formatDate(ak.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDelete(ak.id)}
|
||||
disabled={deletingId === ak.id}
|
||||
className='p-2 rounded hover:bg-red-500/10 text-text/40 hover:text-red-400 transition-colors disabled:opacity-50 disabled:cursor-not-allowed shrink-0 cursor-pointer'
|
||||
title='Delete API key'
|
||||
>
|
||||
{deletingId === ak.id ? (
|
||||
<Loader2 className='h-4 w-4 animate-spin' />
|
||||
) : (
|
||||
<Trash2 className='h-4 w-4' />
|
||||
)}
|
||||
</button>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info */}
|
||||
<div className='rounded-xl border border-border bg-text/2 p-4'>
|
||||
<p className='text-xs text-text/40'>
|
||||
<strong className='text-text/60'>Using API keys:</strong> Pass your API key as the{" "}
|
||||
<code className='text-primary bg-text/5 px-1 rounded'>x-api-key</code> header when
|
||||
making requests to DeckyVault's API. You can use these keys with the Decky
|
||||
Loader plugin or any automation tool.
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { User, Shield, Link as LinkIcon, Key } from "lucide-react"
|
||||
import { SettingsProfileTab } from "@/components/profile/settings-profile-tab"
|
||||
import { SettingsSecurityTab } from "@/components/profile/settings-security-tab"
|
||||
import { SettingsAccountsTab } from "@/components/profile/settings-accounts-tab"
|
||||
import { SettingsApiKeysTab } from "@/components/profile/settings-api-keys-tab"
|
||||
|
||||
interface AuthMethods {
|
||||
hasPassword: boolean
|
||||
passkeyCount: number
|
||||
oauthProviders: Array<{ providerId: string; id: string }>
|
||||
totalAuthMethods: number
|
||||
}
|
||||
|
||||
type SettingsSubTab = "profile" | "security" | "accounts" | "api-keys"
|
||||
|
||||
const subTabs: { id: SettingsSubTab; label: string; icon: typeof User }[] = [
|
||||
{ id: "profile", label: "Profile", icon: User },
|
||||
{ id: "security", label: "Security", icon: Shield },
|
||||
{ id: "accounts", label: "Linked Accounts", icon: LinkIcon },
|
||||
{ id: "api-keys", label: "API Keys", icon: Key },
|
||||
]
|
||||
|
||||
interface SettingsContainerProps {
|
||||
name: string
|
||||
email: string
|
||||
role: string | null
|
||||
createdAt: string
|
||||
image?: string | null
|
||||
userId: string
|
||||
onImageChange?: (url: string | null) => void
|
||||
}
|
||||
|
||||
export function SettingsContainer({
|
||||
name,
|
||||
email,
|
||||
role,
|
||||
createdAt,
|
||||
image,
|
||||
userId,
|
||||
onImageChange,
|
||||
}: SettingsContainerProps) {
|
||||
const [activeSubTab, setActiveSubTab] = useState<SettingsSubTab>("profile")
|
||||
const [authMethods, setAuthMethods] = useState<AuthMethods | null>(null)
|
||||
const [isLoadingAuthMethods, setIsLoadingAuthMethods] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10000)
|
||||
|
||||
fetch("/api/user/me/auth-methods", {
|
||||
credentials: "include",
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then(async (r) => {
|
||||
if (r.ok) {
|
||||
return r.json()
|
||||
}
|
||||
// Log non-OK responses for debugging
|
||||
console.warn(`[auth-methods] fetch returned ${r.status}`)
|
||||
return null
|
||||
})
|
||||
.then((data) => {
|
||||
if (data) setAuthMethods(data)
|
||||
setIsLoadingAuthMethods(false)
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err.name !== "AbortError") {
|
||||
console.error("[auth-methods] fetch failed:", err)
|
||||
}
|
||||
setIsLoadingAuthMethods(false)
|
||||
})
|
||||
.finally(() => clearTimeout(timeoutId))
|
||||
|
||||
return () => {
|
||||
controller.abort()
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const refreshAuthMethods = async () => {
|
||||
setIsLoadingAuthMethods(true)
|
||||
try {
|
||||
const res = await fetch("/api/user/me/auth-methods", {
|
||||
credentials: "include",
|
||||
})
|
||||
if (res.ok) {
|
||||
setAuthMethods(await res.json())
|
||||
} else {
|
||||
setAuthMethods(null)
|
||||
}
|
||||
} catch {
|
||||
setAuthMethods(null)
|
||||
} finally {
|
||||
setIsLoadingAuthMethods(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex flex-col md:flex-row gap-6'>
|
||||
{/* Sidebar Navigation */}
|
||||
<nav className='md:w-48 shrink-0'>
|
||||
<div className='flex md:flex-col gap-1 overflow-x-auto md:overflow-visible pb-2 md:pb-0 md:border-r md:border-border'>
|
||||
{subTabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveSubTab(tab.id)}
|
||||
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium transition-colors whitespace-nowrap rounded-lg md:rounded-none md:border-l-2 md:border-r-0 md:border-transparent cursor-pointer ${
|
||||
activeSubTab === tab.id
|
||||
? "bg-primary/10 text-primary md:border-l-primary md:bg-primary/10"
|
||||
: "text-text/50 hover:text-text/70 hover:bg-text/5"
|
||||
}`}
|
||||
>
|
||||
<tab.icon className='h-4 w-4 shrink-0' />
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Content Area */}
|
||||
<div className='flex-1 min-w-0'>
|
||||
{activeSubTab === "profile" && (
|
||||
<SettingsProfileTab
|
||||
name={name}
|
||||
email={email}
|
||||
role={role}
|
||||
createdAt={createdAt}
|
||||
image={image}
|
||||
userId={userId}
|
||||
onImageChange={onImageChange}
|
||||
/>
|
||||
)}
|
||||
{activeSubTab === "security" && (
|
||||
<SettingsSecurityTab
|
||||
authMethods={authMethods}
|
||||
isLoadingAuthMethods={isLoadingAuthMethods}
|
||||
onRefreshAuthMethods={refreshAuthMethods}
|
||||
/>
|
||||
)}
|
||||
{activeSubTab === "accounts" && (
|
||||
<SettingsAccountsTab
|
||||
authMethods={authMethods}
|
||||
isLoadingAuthMethods={isLoadingAuthMethods}
|
||||
onRefreshAuthMethods={refreshAuthMethods}
|
||||
/>
|
||||
)}
|
||||
{activeSubTab === "api-keys" && (
|
||||
<SettingsApiKeysTab />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { authClient } from "@/lib/auth-client"
|
||||
import { Loader2, Save } from "lucide-react"
|
||||
import { motion } from "motion/react"
|
||||
|
||||
import { ProfilePhotoUpload } from "@/components/profile/profile-photo-upload"
|
||||
|
||||
interface SettingsProfileTabProps {
|
||||
name: string
|
||||
email: string
|
||||
role: string | null
|
||||
createdAt: string
|
||||
image?: string | null
|
||||
userId: string
|
||||
onImageChange?: (url: string | null) => void
|
||||
}
|
||||
|
||||
export function SettingsProfileTab({ name, email, role, createdAt, image, userId, onImageChange }: SettingsProfileTabProps) {
|
||||
const [displayName, setDisplayName] = useState(name)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [message, setMessage] = useState<{ type: "success" | "error"; text: string } | null>(null)
|
||||
|
||||
const handleSaveName = async () => {
|
||||
if (!displayName.trim()) {
|
||||
setMessage({ type: "error", text: "Name cannot be empty" })
|
||||
return
|
||||
}
|
||||
|
||||
setIsSaving(true)
|
||||
setMessage(null)
|
||||
|
||||
const { error } = await authClient.updateUser({
|
||||
name: displayName.trim(),
|
||||
})
|
||||
|
||||
if (error) {
|
||||
setMessage({ type: "error", text: error.message || "Failed to update name" })
|
||||
} else {
|
||||
setMessage({ type: "success", text: "Name updated successfully" })
|
||||
}
|
||||
setIsSaving(false)
|
||||
}
|
||||
|
||||
const joinDate = new Date(createdAt).toLocaleDateString("en-US", {
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
})
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="space-y-6"
|
||||
>
|
||||
{/* Profile Photo */}
|
||||
<ProfilePhotoUpload
|
||||
currentImage={image ?? null}
|
||||
userName={name}
|
||||
userId={userId}
|
||||
onImageChange={async (url) => {
|
||||
onImageChange?.(url)
|
||||
await authClient.updateUser({ image: url ?? "" })
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Display Name */}
|
||||
<div className="rounded-xl border border-border bg-text/[0.03] p-5">
|
||||
<h3 className="text-sm font-medium uppercase tracking-wider text-text/60 mb-4">Display Name</h3>
|
||||
<div className="flex flex-col sm:flex-row gap-3">
|
||||
<div className="flex-1">
|
||||
<input
|
||||
type="text"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(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="Your display name"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSaveName}
|
||||
disabled={isSaving || displayName === name}
|
||||
className="flex items-center justify-center gap-2 px-4 py-2 rounded-lg bg-primary text-white text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed shrink-0 cursor-pointer"
|
||||
>
|
||||
{isSaving ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-4 w-4" />
|
||||
)}
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
{message && (
|
||||
<p className={`mt-2 text-sm ${message.type === "success" ? "text-green-400" : "text-red-400"}`}>
|
||||
{message.text}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Email (read-only) */}
|
||||
<div className="rounded-xl border border-border bg-text/[0.03] p-5">
|
||||
<h3 className="text-sm font-medium uppercase tracking-wider text-text/60 mb-4">Email</h3>
|
||||
<p className="text-sm text-text/80">{email}</p>
|
||||
<p className="text-xs text-text/40 mt-1">Email changes require verification. Contact support if needed.</p>
|
||||
</div>
|
||||
|
||||
{/* Account Info (read-only) */}
|
||||
<div className="rounded-xl border border-border bg-text/[0.03] p-5">
|
||||
<h3 className="text-sm font-medium uppercase tracking-wider text-text/60 mb-4">Account Info</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-text/50">Role</span>
|
||||
<p className="text-text/80 capitalize">{role || "user"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-text/50">Member since</span>
|
||||
<p className="text-text/80">{joinDate}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,609 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } 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
|
||||
}
|
||||
|
||||
interface SettingsSecurityTabProps {
|
||||
authMethods: AuthMethods | null
|
||||
isLoadingAuthMethods: boolean
|
||||
onRefreshAuthMethods: () => Promise<void>
|
||||
}
|
||||
|
||||
export function SettingsSecurityTab({
|
||||
authMethods,
|
||||
isLoadingAuthMethods,
|
||||
onRefreshAuthMethods,
|
||||
}: SettingsSecurityTabProps) {
|
||||
const [passkeys, setPasskeys] = useState<Passkey[]>([])
|
||||
const [isLoadingPasskeys, setIsLoadingPasskeys] = useState(true)
|
||||
const [passkeysError, setPasskeysError] = useState<string | null>(null)
|
||||
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 state
|
||||
const [editingPasskeyId, setEditingPasskeyId] = useState<string | null>(
|
||||
null,
|
||||
)
|
||||
const [editingName, setEditingName] = useState("")
|
||||
const [isUpdatingPasskey, setIsUpdatingPasskey] = useState(false)
|
||||
const [isDeletingPasskey, setIsDeletingPasskey] = useState<string | null>(
|
||||
null,
|
||||
)
|
||||
const [isAddingPasskey, setIsAddingPasskey] = useState(false)
|
||||
const [passkeyMessage, setPasskeyMessage] = useState<{
|
||||
type: "success" | "error"
|
||||
text: string
|
||||
} | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetchPasskeys()
|
||||
}, [])
|
||||
|
||||
async function fetchPasskeys() {
|
||||
try {
|
||||
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 () => {
|
||||
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 : [])
|
||||
setPasskeysError(null)
|
||||
}
|
||||
} catch {
|
||||
// silently fail on refresh
|
||||
}
|
||||
}
|
||||
|
||||
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 < 10) {
|
||||
setPasswordMessage({
|
||||
type: "error",
|
||||
text: "Password must be at least 10 characters",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setIsPasswordSubmitting(true)
|
||||
|
||||
try {
|
||||
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("")
|
||||
await onRefreshAuthMethods()
|
||||
}
|
||||
} 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 onRefreshAuthMethods()
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
setPasswordMessage({
|
||||
type: "error",
|
||||
text: "An unexpected error occurred",
|
||||
})
|
||||
} finally {
|
||||
setIsPasswordSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddPasskey = async () => {
|
||||
setIsAddingPasskey(true)
|
||||
setPasskeyMessage(null)
|
||||
try {
|
||||
const { error } = await authClient.passkey.addPasskey()
|
||||
if (error) {
|
||||
setPasskeyMessage({
|
||||
type: "error",
|
||||
text: error.message || "Failed to add passkey",
|
||||
})
|
||||
} else {
|
||||
setPasskeyMessage({
|
||||
type: "success",
|
||||
text: "Passkey added successfully",
|
||||
})
|
||||
await refreshPasskeys()
|
||||
await onRefreshAuthMethods()
|
||||
}
|
||||
} catch {
|
||||
setPasskeyMessage({
|
||||
type: "error",
|
||||
text: "Failed to add passkey. Please try again.",
|
||||
})
|
||||
} finally {
|
||||
setIsAddingPasskey(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeletePasskey = async (id: string) => {
|
||||
if (authMethods && authMethods.totalAuthMethods <= 1) return
|
||||
|
||||
setIsDeletingPasskey(id)
|
||||
setPasskeyMessage(null)
|
||||
try {
|
||||
const res = await fetch("/api/auth/passkey/delete-passkey", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ id }),
|
||||
})
|
||||
|
||||
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 onRefreshAuthMethods()
|
||||
}
|
||||
} catch {
|
||||
setPasskeyMessage({
|
||||
type: "error",
|
||||
text: "Failed to delete passkey. Please try again.",
|
||||
})
|
||||
} finally {
|
||||
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) => {
|
||||
setEditingPasskeyId(passkey.id)
|
||||
setEditingName(passkey.name || "")
|
||||
}
|
||||
|
||||
const cancelEditingPasskey = () => {
|
||||
setEditingPasskeyId(null)
|
||||
setEditingName("")
|
||||
}
|
||||
|
||||
const isSingleAuthMethod = authMethods && authMethods.totalAuthMethods <= 1
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className='space-y-6'
|
||||
>
|
||||
{/* Warning for single auth method */}
|
||||
<AnimatePresence>
|
||||
{isSingleAuthMethod && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className='rounded-xl border border-yellow-500/30 bg-yellow-500/10 p-4 flex items-start gap-3'
|
||||
>
|
||||
<Shield className='h-5 w-5 text-yellow-500 shrink-0 mt-0.5' />
|
||||
<p className='text-sm text-yellow-200/80'>
|
||||
Single authentication method — consider adding a
|
||||
passkey or linking a social account
|
||||
</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Password Section */}
|
||||
<div className='rounded-xl border border-border bg-text/3 p-5'>
|
||||
<h3 className='text-sm font-medium uppercase tracking-wider text-text/60 mb-4 flex items-center gap-2'>
|
||||
<Key className='h-4 w-4' />
|
||||
Password
|
||||
</h3>
|
||||
|
||||
{isLoadingAuthMethods ? (
|
||||
<div className='flex items-center gap-2 text-sm text-text/50'>
|
||||
<Loader2 className='h-4 w-4 animate-spin' />
|
||||
Loading...
|
||||
</div>
|
||||
) : authMethods === null ? (
|
||||
<div className='flex items-center gap-2 text-sm text-red-400'>
|
||||
Failed to load authentication methods.{' '}
|
||||
<button
|
||||
onClick={onRefreshAuthMethods}
|
||||
className='underline hover:text-red-300 cursor-pointer'
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<form
|
||||
onSubmit={handlePasswordSubmit}
|
||||
className='space-y-3'
|
||||
>
|
||||
{authMethods?.hasPassword && (
|
||||
<div>
|
||||
<label className='block text-xs text-text/50 mb-1'>
|
||||
Current Password
|
||||
</label>
|
||||
<input
|
||||
type='password'
|
||||
value={currentPassword}
|
||||
onChange={(e) =>
|
||||
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
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className='block text-xs text-text/50 mb-1'>
|
||||
{authMethods?.hasPassword
|
||||
? "New Password"
|
||||
: "Password"}
|
||||
</label>
|
||||
<input
|
||||
type='password'
|
||||
value={newPassword}
|
||||
onChange={(e) => 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
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className='block text-xs text-text/50 mb-1'>
|
||||
Confirm Password
|
||||
</label>
|
||||
<input
|
||||
type='password'
|
||||
value={confirmPassword}
|
||||
onChange={(e) =>
|
||||
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
|
||||
/>
|
||||
</div>
|
||||
<div className='flex items-center gap-3 pt-1'>
|
||||
<button
|
||||
type='submit'
|
||||
disabled={isPasswordSubmitting}
|
||||
className='flex items-center justify-center gap-2 px-4 py-2 rounded-lg bg-primary text-white text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer'
|
||||
>
|
||||
{isPasswordSubmitting ? (
|
||||
<Loader2 className='h-4 w-4 animate-spin' />
|
||||
) : (
|
||||
<Key className='h-4 w-4' />
|
||||
)}
|
||||
{authMethods?.hasPassword
|
||||
? "Change Password"
|
||||
: "Set Password"}
|
||||
</button>
|
||||
{passwordMessage && (
|
||||
<p
|
||||
className={`text-sm ${passwordMessage.type === "success" ? "text-green-400" : "text-red-400"}`}
|
||||
>
|
||||
{passwordMessage.text}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Passkey Section */}
|
||||
<div className='rounded-xl border border-border bg-text/3 p-5'>
|
||||
<div className='flex items-center justify-between mb-4'>
|
||||
<h3 className='text-sm font-medium uppercase tracking-wider text-text/60 flex items-center gap-2'>
|
||||
<Fingerprint className='h-4 w-4' />
|
||||
Passkeys
|
||||
</h3>
|
||||
<button
|
||||
onClick={handleAddPasskey}
|
||||
disabled={isAddingPasskey}
|
||||
className='flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-border text-xs font-medium hover:border-primary/40 hover:text-primary transition-colors disabled:opacity-50 cursor-pointer'
|
||||
>
|
||||
{isAddingPasskey ? (
|
||||
<Loader2 className='h-3.5 w-3.5 animate-spin' />
|
||||
) : (
|
||||
<Plus className='h-3.5 w-3.5' />
|
||||
)}
|
||||
Add Passkey
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{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'>
|
||||
<Loader2 className='h-4 w-4 animate-spin' />
|
||||
Loading passkeys...
|
||||
</div>
|
||||
) : passkeys.length === 0 ? (
|
||||
<p className='text-sm text-text/50'>
|
||||
No passkeys registered.
|
||||
</p>
|
||||
) : (
|
||||
<div className='space-y-3'>
|
||||
<AnimatePresence>
|
||||
{passkeys.map((pk) => (
|
||||
<motion.div
|
||||
key={pk.id}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className='flex items-center justify-between gap-3 rounded-lg border border-border bg-text/2 p-3'
|
||||
>
|
||||
<div className='flex items-center gap-3 min-w-0'>
|
||||
<Fingerprint className='h-4 w-4 text-text/40 shrink-0' />
|
||||
<div className='min-w-0'>
|
||||
{editingPasskeyId === pk.id ? (
|
||||
<div className='flex items-center gap-2'>
|
||||
<input
|
||||
type='text'
|
||||
value={editingName}
|
||||
onChange={(e) =>
|
||||
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
|
||||
/>
|
||||
<button
|
||||
onClick={() =>
|
||||
handleRenamePasskey(
|
||||
pk.id,
|
||||
)
|
||||
}
|
||||
disabled={
|
||||
isUpdatingPasskey
|
||||
}
|
||||
className='p-1 rounded hover:bg-green-500/10 text-green-400 transition-colors cursor-pointer disabled:cursor-not-allowed'
|
||||
>
|
||||
{isUpdatingPasskey ? (
|
||||
<Loader2 className='h-3.5 w-3.5 animate-spin' />
|
||||
) : (
|
||||
<Check className='h-3.5 w-3.5' />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={
|
||||
cancelEditingPasskey
|
||||
}
|
||||
className='p-1 rounded hover:bg-red-500/10 text-red-400 transition-colors cursor-pointer'
|
||||
>
|
||||
<X className='h-3.5 w-3.5' />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className='text-sm font-medium text-text truncate'>
|
||||
{pk.name ||
|
||||
"Unnamed passkey"}
|
||||
</p>
|
||||
<p className='text-xs text-text/50'>
|
||||
{pk.deviceType}
|
||||
{pk.createdAt && (
|
||||
<span className='ml-1'>
|
||||
· Added{" "}
|
||||
{new Date(
|
||||
pk.createdAt,
|
||||
).toLocaleDateString(
|
||||
"en-US",
|
||||
{
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
},
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{editingPasskeyId !== pk.id && (
|
||||
<div className='flex items-center gap-1 shrink-0'>
|
||||
<button
|
||||
onClick={() =>
|
||||
startEditingPasskey(pk)
|
||||
}
|
||||
className='p-1.5 rounded hover:bg-text/5 text-text/40 hover:text-text/80 transition-colors cursor-pointer'
|
||||
>
|
||||
<Pencil className='h-3.5 w-3.5' />
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
handleDeletePasskey(pk.id)
|
||||
}
|
||||
disabled={
|
||||
isDeletingPasskey ===
|
||||
pk.id ||
|
||||
!!isSingleAuthMethod
|
||||
}
|
||||
title={
|
||||
isSingleAuthMethod
|
||||
? "Cannot remove your only authentication method"
|
||||
: undefined
|
||||
}
|
||||
className='p-1.5 rounded hover:bg-red-500/10 text-text/40 hover:text-red-400 transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer'
|
||||
>
|
||||
{isDeletingPasskey === pk.id ? (
|
||||
<Loader2 className='h-3.5 w-3.5 animate-spin' />
|
||||
) : (
|
||||
<Trash2 className='h-3.5 w-3.5' />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client"
|
||||
|
||||
import { TrendingUp, CheckCircle, Star } from "lucide-react"
|
||||
import { motion } from "motion/react"
|
||||
|
||||
interface StatsRowProps {
|
||||
contributions: number
|
||||
verifiedEntries: number
|
||||
reputation: number
|
||||
}
|
||||
|
||||
export function StatsRow({ contributions, verifiedEntries, reputation }: StatsRowProps) {
|
||||
const stats = [
|
||||
{ label: "Contributions", value: contributions, icon: TrendingUp, color: "text-primary" },
|
||||
{ label: "Verified", value: verifiedEntries, icon: CheckCircle, color: "text-green-400" },
|
||||
{ label: "Reputation", value: reputation, icon: Star, color: "text-accent" },
|
||||
]
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="grid grid-cols-3 gap-4"
|
||||
>
|
||||
{stats.map((stat) => (
|
||||
<div
|
||||
key={stat.label}
|
||||
className="flex flex-col items-center p-4 rounded-xl bg-text/5 border border-border"
|
||||
>
|
||||
<stat.icon className={`h-5 w-5 ${stat.color} mb-2`} />
|
||||
<span className="text-2xl font-bold">{stat.value}</span>
|
||||
<span className="text-xs text-text/50">{stat.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user