feat: add settings page to analytics-frontend
This commit is contained in:
103
components/PasswordInput.tsx
Normal file
103
components/PasswordInput.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
|
||||
interface PasswordInputProps {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
label?: string
|
||||
placeholder?: string
|
||||
error?: string | null
|
||||
disabled?: boolean
|
||||
required?: boolean
|
||||
className?: string
|
||||
id?: string
|
||||
autoComplete?: string
|
||||
minLength?: number
|
||||
}
|
||||
|
||||
export default function PasswordInput({
|
||||
value,
|
||||
onChange,
|
||||
label = 'Password',
|
||||
placeholder = 'Enter password',
|
||||
error,
|
||||
disabled = false,
|
||||
required = false,
|
||||
className = '',
|
||||
id,
|
||||
autoComplete,
|
||||
minLength
|
||||
}: PasswordInputProps) {
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const inputId = id || 'password-input'
|
||||
const errorId = `${inputId}-error`
|
||||
|
||||
return (
|
||||
<div className={`space-y-1.5 ${className}`}>
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
className="block text-sm font-medium text-neutral-700 dark:text-neutral-300"
|
||||
>
|
||||
{label}
|
||||
{required && <span className="text-brand-orange text-xs ml-1">(Required)</span>}
|
||||
</label>
|
||||
)}
|
||||
<div className="relative group">
|
||||
<input
|
||||
id={inputId}
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
autoComplete={autoComplete}
|
||||
minLength={minLength}
|
||||
aria-invalid={!!error}
|
||||
aria-describedby={error ? errorId : undefined}
|
||||
className={`w-full pl-11 pr-12 py-3 border rounded-xl bg-neutral-50/50 dark:bg-neutral-900/50 focus:bg-white dark:focus:bg-neutral-900
|
||||
transition-all duration-200 outline-none disabled:opacity-50 disabled:cursor-not-allowed dark:text-white
|
||||
${error
|
||||
? 'border-red-300 dark:border-red-800 focus:border-red-500 focus:ring-4 focus:ring-red-500/10'
|
||||
: 'border-neutral-200 dark:border-neutral-800 hover:border-brand-orange/50 focus:border-brand-orange focus:ring-4 focus:ring-brand-orange/10'
|
||||
}`}
|
||||
/>
|
||||
|
||||
{/* Lock Icon (Left) */}
|
||||
<div className={`absolute left-3.5 top-1/2 -translate-y-1/2 pointer-events-none transition-colors duration-200
|
||||
${error ? 'text-red-400' : 'text-neutral-400 dark:text-neutral-500 group-focus-within:text-brand-orange'}`}>
|
||||
<svg aria-hidden="true" className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Toggle Visibility Button (Right) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
disabled={disabled}
|
||||
aria-label={showPassword ? "Hide password" : "Show password"}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 p-1.5 rounded-lg text-neutral-400 dark:text-neutral-500
|
||||
hover:text-neutral-600 dark:hover:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-all duration-200 focus:outline-none"
|
||||
>
|
||||
{showPassword ? (
|
||||
<svg aria-hidden="true" className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg aria-hidden="true" className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{error && (
|
||||
<p id={errorId} role="alert" className="text-xs text-red-500 font-medium ml-1">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
993
components/settings/ProfileSettings.tsx
Normal file
993
components/settings/ProfileSettings.tsx
Normal file
@@ -0,0 +1,993 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useAuth } from '@/lib/auth/context'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { PersonIcon, LockClosedIcon, EnvelopeClosedIcon, CheckIcon, ExclamationTriangleIcon, Cross2Icon, GearIcon, MobileIcon, FileTextIcon, CopyIcon } from '@radix-ui/react-icons'
|
||||
// @ts-ignore
|
||||
import { Button, Input } from '@ciphera-net/ui'
|
||||
import PasswordInput from '../PasswordInput'
|
||||
import { toast } from 'sonner'
|
||||
import api from '@/lib/api/client'
|
||||
import { deriveAuthKey } from '@/lib/crypto/password'
|
||||
import { deleteAccount, getUserSessions, revokeSession, type Session } from '@/lib/api/user'
|
||||
import { setup2FA, verify2FA, disable2FA, regenerateRecoveryCodes, Setup2FAResponse } from '@/lib/api/2fa'
|
||||
import Image from 'next/image'
|
||||
|
||||
export default function ProfileSettings() {
|
||||
const { user, refresh, logout } = useAuth()
|
||||
const [activeTab, setActiveTab] = useState<'profile' | 'security' | 'preferences'>('profile')
|
||||
|
||||
// Profile State
|
||||
const [email, setEmail] = useState(user?.email || '')
|
||||
const [isEmailDirty, setIsEmailDirty] = useState(false)
|
||||
const [loadingProfile, setLoadingProfile] = useState(false)
|
||||
|
||||
// Update email when user data loads
|
||||
useEffect(() => {
|
||||
if (user?.email) {
|
||||
setEmail(user.email)
|
||||
setIsEmailDirty(false)
|
||||
}
|
||||
}, [user?.email])
|
||||
|
||||
// Email Password Prompt State
|
||||
const [showEmailPasswordPrompt, setShowEmailPasswordPrompt] = useState(false)
|
||||
const [emailConfirmPassword, setEmailConfirmPassword] = useState('')
|
||||
|
||||
// Security State
|
||||
const [currentPassword, setCurrentPassword] = useState('')
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [loadingSecurity, setLoadingSecurity] = useState(false)
|
||||
const [securityError, setSecurityError] = useState<string | null>(null)
|
||||
|
||||
// 2FA State
|
||||
const [show2FASetup, setShow2FASetup] = useState(false)
|
||||
const [twoFAData, setTwoFAData] = useState<Setup2FAResponse | null>(null)
|
||||
const [twoFACode, setTwoFACode] = useState('')
|
||||
const [loading2FA, setLoading2FA] = useState(false)
|
||||
|
||||
// Recovery Codes State
|
||||
const [recoveryCodes, setRecoveryCodes] = useState<string[]>([])
|
||||
const [showRecoveryCodes, setShowRecoveryCodes] = useState(false)
|
||||
const [showRegenerateConfirm, setShowRegenerateConfirm] = useState(false)
|
||||
|
||||
// Account Deletion State
|
||||
const [showDeletePrompt, setShowDeletePrompt] = useState(false)
|
||||
const [deletePassword, setDeletePassword] = useState('')
|
||||
const [loadingDelete, setLoadingDelete] = useState(false)
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null)
|
||||
|
||||
// Sessions State
|
||||
const [sessions, setSessions] = useState<Session[]>([])
|
||||
const [loadingSessions, setLoadingSessions] = useState(false)
|
||||
const [revokingSessionId, setRevokingSessionId] = useState<string | null>(null)
|
||||
|
||||
const handleUpdateProfile = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!email || email === user?.email) return
|
||||
|
||||
// Show password prompt if not visible
|
||||
if (!showEmailPasswordPrompt) {
|
||||
setShowEmailPasswordPrompt(true)
|
||||
return
|
||||
}
|
||||
|
||||
// Actual submission with password
|
||||
setLoadingProfile(true)
|
||||
try {
|
||||
if (!user?.email) throw new Error('User email not found')
|
||||
|
||||
const currentDerivedKey = await deriveAuthKey(emailConfirmPassword, user.email)
|
||||
const newDerivedKey = await deriveAuthKey(emailConfirmPassword, email) // Derive with NEW email
|
||||
|
||||
await api('/auth/user/email', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
email: email,
|
||||
current_password: currentDerivedKey,
|
||||
new_derived_key: newDerivedKey
|
||||
})
|
||||
})
|
||||
|
||||
toast.success('Profile updated successfully. Please verify your new email.')
|
||||
refresh()
|
||||
setShowEmailPasswordPrompt(false)
|
||||
setEmailConfirmPassword('')
|
||||
setIsEmailDirty(false)
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || 'Failed to update profile')
|
||||
console.error(err)
|
||||
} finally {
|
||||
setLoadingProfile(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdatePassword = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setSecurityError(null)
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
setSecurityError('New passwords do not match')
|
||||
return
|
||||
}
|
||||
|
||||
if (newPassword.length < 8) {
|
||||
setSecurityError('Password must be at least 8 characters')
|
||||
return
|
||||
}
|
||||
|
||||
setLoadingSecurity(true)
|
||||
try {
|
||||
if (!user?.email) throw new Error('User email not found')
|
||||
|
||||
const currentDerivedKey = await deriveAuthKey(currentPassword, user.email)
|
||||
const newDerivedKey = await deriveAuthKey(newPassword, user.email)
|
||||
|
||||
await api('/auth/user/password', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
current_password: currentDerivedKey,
|
||||
new_password: newDerivedKey
|
||||
})
|
||||
})
|
||||
|
||||
toast.success('Password updated successfully')
|
||||
setCurrentPassword('')
|
||||
setNewPassword('')
|
||||
setConfirmPassword('')
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || 'Failed to update password')
|
||||
setSecurityError(err.message || 'Failed to update password')
|
||||
} finally {
|
||||
setLoadingSecurity(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteAccount = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setDeleteError(null)
|
||||
setLoadingDelete(true)
|
||||
|
||||
try {
|
||||
if (!user?.email) throw new Error('User email not found')
|
||||
|
||||
// 1. Derive key to verify ownership
|
||||
const derivedKey = await deriveAuthKey(deletePassword, user.email)
|
||||
|
||||
// 2. Delete account
|
||||
await deleteAccount(derivedKey)
|
||||
|
||||
toast.success('Account deleted successfully')
|
||||
|
||||
// 3. Logout and redirect
|
||||
logout()
|
||||
} catch (err: any) {
|
||||
setDeleteError(err.message || 'Failed to delete account')
|
||||
toast.error(err.message || 'Failed to delete account')
|
||||
} finally {
|
||||
setLoadingDelete(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleStart2FASetup = async () => {
|
||||
setLoading2FA(true)
|
||||
try {
|
||||
const data = await setup2FA()
|
||||
setTwoFAData(data)
|
||||
setShow2FASetup(true)
|
||||
} catch (err: any) {
|
||||
toast.error('Failed to start 2FA setup')
|
||||
} finally {
|
||||
setLoading2FA(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleVerify2FA = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setLoading2FA(true)
|
||||
try {
|
||||
const res = await verify2FA(twoFACode)
|
||||
toast.success('2FA enabled successfully')
|
||||
setShow2FASetup(false)
|
||||
setTwoFAData(null)
|
||||
setTwoFACode('')
|
||||
|
||||
if (res.recovery_codes) {
|
||||
setRecoveryCodes(res.recovery_codes)
|
||||
setShowRecoveryCodes(true)
|
||||
}
|
||||
|
||||
await refresh()
|
||||
} catch (err: any) {
|
||||
toast.error('Invalid code. Please try again.')
|
||||
} finally {
|
||||
setLoading2FA(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDisable2FA = async () => {
|
||||
setLoading2FA(true)
|
||||
try {
|
||||
await disable2FA()
|
||||
toast.success('2FA disabled successfully')
|
||||
await refresh()
|
||||
} catch (err: any) {
|
||||
toast.error('Failed to disable 2FA')
|
||||
} finally {
|
||||
setLoading2FA(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRegenerateCodes = async () => {
|
||||
setLoading2FA(true)
|
||||
try {
|
||||
const res = await regenerateRecoveryCodes()
|
||||
setRecoveryCodes(res.recovery_codes)
|
||||
setShowRecoveryCodes(true)
|
||||
setShowRegenerateConfirm(false)
|
||||
toast.success('Recovery codes regenerated')
|
||||
} catch (err: any) {
|
||||
toast.error('Failed to regenerate codes')
|
||||
} finally {
|
||||
setLoading2FA(false)
|
||||
}
|
||||
}
|
||||
|
||||
const copyCodes = () => {
|
||||
navigator.clipboard.writeText(recoveryCodes.join('\n'))
|
||||
toast.success('Codes copied to clipboard')
|
||||
}
|
||||
|
||||
// Load sessions
|
||||
const loadSessions = async () => {
|
||||
setLoadingSessions(true)
|
||||
try {
|
||||
const data = await getUserSessions()
|
||||
setSessions(data.sessions)
|
||||
} catch (err: any) {
|
||||
toast.error('Failed to load sessions')
|
||||
console.error(err)
|
||||
} finally {
|
||||
setLoadingSessions(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Load sessions when security tab is active
|
||||
useEffect(() => {
|
||||
if (activeTab === 'security') {
|
||||
loadSessions()
|
||||
}
|
||||
}, [activeTab])
|
||||
|
||||
const handleRevokeSession = async (sessionId: string) => {
|
||||
const session = sessions.find(s => s.id === sessionId)
|
||||
const isCurrentSession = session?.is_current
|
||||
|
||||
if (isCurrentSession) {
|
||||
if (!confirm('This will log you out of this device. Continue?')) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
setRevokingSessionId(sessionId)
|
||||
try {
|
||||
await revokeSession(sessionId)
|
||||
toast.success('Session revoked successfully')
|
||||
|
||||
// If current session was revoked, logout immediately
|
||||
if (isCurrentSession) {
|
||||
setTimeout(() => {
|
||||
logout()
|
||||
}, 1000)
|
||||
} else {
|
||||
// Reload sessions list for other sessions
|
||||
await loadSessions()
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error('Failed to revoke session')
|
||||
console.error(err)
|
||||
} finally {
|
||||
setRevokingSessionId(null)
|
||||
}
|
||||
}
|
||||
|
||||
// Parse user agent to get browser and device info
|
||||
const parseUserAgent = (ua: string) => {
|
||||
let browser = 'Unknown Browser'
|
||||
let device = 'Unknown Device'
|
||||
let os = 'Unknown OS'
|
||||
|
||||
// Browser detection
|
||||
if (ua.includes('Chrome') && !ua.includes('Edg')) {
|
||||
browser = 'Chrome'
|
||||
} else if (ua.includes('Firefox')) {
|
||||
browser = 'Firefox'
|
||||
} else if (ua.includes('Safari') && !ua.includes('Chrome')) {
|
||||
browser = 'Safari'
|
||||
} else if (ua.includes('Edg')) {
|
||||
browser = 'Edge'
|
||||
} else if (ua.includes('Opera') || ua.includes('OPR')) {
|
||||
browser = 'Opera'
|
||||
}
|
||||
|
||||
// OS detection
|
||||
if (ua.includes('Windows')) {
|
||||
os = 'Windows'
|
||||
} else if (ua.includes('Mac OS X') || ua.includes('Macintosh')) {
|
||||
os = 'macOS'
|
||||
} else if (ua.includes('Linux')) {
|
||||
os = 'Linux'
|
||||
} else if (ua.includes('Android')) {
|
||||
os = 'Android'
|
||||
} else if (ua.includes('iOS') || ua.includes('iPhone') || ua.includes('iPad')) {
|
||||
os = 'iOS'
|
||||
}
|
||||
|
||||
// Device detection
|
||||
if (ua.includes('Mobile') || ua.includes('Android') || ua.includes('iPhone')) {
|
||||
device = 'Mobile'
|
||||
} else if (ua.includes('Tablet') || ua.includes('iPad')) {
|
||||
device = 'Tablet'
|
||||
} else {
|
||||
device = 'Desktop'
|
||||
}
|
||||
|
||||
return { browser, os, device }
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString)
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
if (!user) return null
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-neutral-900 dark:text-white">Settings</h1>
|
||||
<p className="mt-2 text-neutral-600 dark:text-neutral-400">
|
||||
Manage your account settings and preferences.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col md:flex-row gap-8">
|
||||
{/* Sidebar Navigation */}
|
||||
<nav className="w-full md:w-64 flex-shrink-0 space-y-1">
|
||||
<button
|
||||
onClick={() => setActiveTab('profile')}
|
||||
className={`w-full flex items-center gap-3 px-4 py-3 text-sm font-medium rounded-xl transition-all duration-200 ${
|
||||
activeTab === 'profile'
|
||||
? 'bg-brand-orange/10 text-brand-orange'
|
||||
: 'text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800'
|
||||
}`}
|
||||
>
|
||||
<PersonIcon className="w-5 h-5" />
|
||||
Profile
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('security')}
|
||||
className={`w-full flex items-center gap-3 px-4 py-3 text-sm font-medium rounded-xl transition-all duration-200 ${
|
||||
activeTab === 'security'
|
||||
? 'bg-brand-orange/10 text-brand-orange'
|
||||
: 'text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800'
|
||||
}`}
|
||||
>
|
||||
<LockClosedIcon className="w-5 h-5" />
|
||||
Security
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('preferences')}
|
||||
className={`w-full flex items-center gap-3 px-4 py-3 text-sm font-medium rounded-xl transition-all duration-200 ${
|
||||
activeTab === 'preferences'
|
||||
? 'bg-brand-orange/10 text-brand-orange'
|
||||
: 'text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800'
|
||||
}`}
|
||||
>
|
||||
<GearIcon className="w-5 h-5" />
|
||||
Preferences
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
{/* Content Area */}
|
||||
<div className="flex-1 relative">
|
||||
<motion.div
|
||||
key={activeTab}
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="bg-white dark:bg-neutral-900 rounded-2xl border border-neutral-200 dark:border-neutral-800 p-6 md:p-8 shadow-sm"
|
||||
>
|
||||
{activeTab === 'preferences' && (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
Preferences are currently not available.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'profile' && (
|
||||
<div className="space-y-12">
|
||||
<form onSubmit={handleUpdateProfile} className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-neutral-900 dark:text-white mb-1">Profile Information</h2>
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">Update your account details.</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
||||
Email Address
|
||||
</label>
|
||||
<div className="relative group">
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => {
|
||||
setEmail(e.target.value)
|
||||
setIsEmailDirty(e.target.value !== user.email)
|
||||
}}
|
||||
className="w-full pl-11 pr-4 py-3 border border-neutral-200 dark:border-neutral-800 rounded-xl bg-neutral-50/50 dark:bg-neutral-900/50 focus:bg-white dark:focus:bg-neutral-900
|
||||
focus:border-brand-orange focus:ring-4 focus:ring-brand-orange/10 outline-none transition-all duration-200 dark:text-white"
|
||||
/>
|
||||
<EnvelopeClosedIcon className="absolute left-3.5 top-1/2 -translate-y-1/2 w-5 h-5 text-neutral-400 dark:text-neutral-500 group-focus-within:text-brand-orange transition-colors" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-neutral-100 dark:border-neutral-800 flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!isEmailDirty || loadingProfile}
|
||||
className="flex items-center gap-2 px-6 py-2.5 bg-neutral-900 dark:bg-white text-white dark:text-neutral-900 rounded-xl font-medium
|
||||
hover:bg-neutral-800 dark:hover:bg-neutral-100 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200"
|
||||
>
|
||||
{loadingProfile ? (
|
||||
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<CheckIcon className="w-4 h-4" />
|
||||
Save Changes
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-red-600 dark:text-red-500 mb-1">Danger Zone</h2>
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">Irreversible actions for your account.</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border border-red-200 dark:border-red-900/50 bg-red-50 dark:bg-red-900/10 rounded-xl flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-medium text-red-900 dark:text-red-200">Delete Account</h3>
|
||||
<p className="text-sm text-red-700 dark:text-red-300 mt-1">Permanently delete your account and all data.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowDeletePrompt(true)}
|
||||
className="px-4 py-2 bg-white dark:bg-neutral-900 border border-red-200 dark:border-red-900 text-red-600 dark:text-red-400 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors text-sm font-medium"
|
||||
>
|
||||
Delete Account
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'security' && (
|
||||
<div className="space-y-12">
|
||||
{/* 2FA Section */}
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-neutral-900 dark:text-white mb-1">Two-Factor Authentication</h2>
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">Add an extra layer of security to your account.</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 bg-neutral-50 dark:bg-neutral-900/50 rounded-xl border border-neutral-100 dark:border-neutral-800">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-white dark:bg-neutral-800 rounded-lg text-neutral-400">
|
||||
<MobileIcon className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-neutral-900 dark:text-white">Authenticator App</h3>
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">Use an app like Google Authenticator or Authy.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{user.totp_enabled ? (
|
||||
<button
|
||||
onClick={handleDisable2FA}
|
||||
disabled={loading2FA}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded-lg font-medium hover:bg-red-700 transition-colors"
|
||||
>
|
||||
Disable 2FA
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleStart2FASetup}
|
||||
disabled={loading2FA}
|
||||
className="px-4 py-2 bg-neutral-900 dark:bg-white text-white dark:text-neutral-900 rounded-lg font-medium hover:bg-neutral-800 dark:hover:bg-neutral-100 transition-colors"
|
||||
>
|
||||
Enable 2FA
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{show2FASetup && twoFAData && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="mt-6 pt-6 border-t border-neutral-200 dark:border-neutral-800 overflow-hidden"
|
||||
>
|
||||
<div className="grid md:grid-cols-2 gap-8">
|
||||
<div className="flex justify-center bg-white p-4 rounded-xl border border-neutral-200">
|
||||
{/* QR Code */}
|
||||
<img src={twoFAData.qr_code} alt="2FA QR Code" className="w-48 h-48 object-contain" />
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="font-medium text-neutral-900 dark:text-white mb-2">1. Scan QR Code</h4>
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
Open your authenticator app and scan the image.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-medium text-neutral-900 dark:text-white mb-2">2. Enter Code</h4>
|
||||
<form onSubmit={handleVerify2FA} className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
maxLength={6}
|
||||
value={twoFACode}
|
||||
onChange={(e) => setTwoFACode(e.target.value.replace(/\D/g, ''))}
|
||||
placeholder="000000"
|
||||
className="w-32 px-3 py-2 text-center tracking-widest font-mono text-lg border border-neutral-200 dark:border-neutral-800 rounded-lg bg-white dark:bg-neutral-900 focus:ring-2 focus:ring-brand-orange/20 focus:border-brand-orange outline-none dark:text-white"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={twoFACode.length !== 6 || loading2FA}
|
||||
className="px-4 py-2 bg-brand-orange text-white rounded-lg font-medium hover:bg-brand-orange/90 transition-colors disabled:opacity-50"
|
||||
>
|
||||
Verify
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="pt-2">
|
||||
<p className="text-xs text-neutral-400 font-mono break-all">
|
||||
Secret: {twoFAData.secret}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{user.totp_enabled && (
|
||||
<div className="p-4 bg-white dark:bg-neutral-900 rounded-xl border border-neutral-200 dark:border-neutral-800">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-neutral-100 dark:bg-neutral-800 rounded-lg text-neutral-500">
|
||||
<FileTextIcon className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-neutral-900 dark:text-white">Recovery Codes</h3>
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
Generate backup codes in case you lose access to your device.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowRegenerateConfirm(true)}
|
||||
className="px-4 py-2 border border-neutral-200 dark:border-neutral-800 text-neutral-900 dark:text-white rounded-lg font-medium hover:bg-neutral-50 dark:hover:bg-neutral-800 transition-colors"
|
||||
>
|
||||
Regenerate Codes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr className="border-neutral-100 dark:border-neutral-800" />
|
||||
|
||||
<form onSubmit={handleUpdatePassword} className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-neutral-900 dark:text-white mb-1">Password</h2>
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">Update your password to keep your account secure.</p>
|
||||
</div>
|
||||
|
||||
{securityError && (
|
||||
<div className="p-4 bg-red-50 dark:bg-red-900/10 border border-red-100 dark:border-red-900/20 rounded-xl flex items-start gap-3 text-red-600 dark:text-red-400">
|
||||
<ExclamationTriangleIcon className="w-5 h-5 shrink-0 mt-0.5" />
|
||||
<span className="text-sm font-medium">{securityError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<PasswordInput
|
||||
label="Current Password"
|
||||
value={currentPassword}
|
||||
onChange={setCurrentPassword}
|
||||
required
|
||||
/>
|
||||
<hr className="border-neutral-100 dark:border-neutral-800 my-4" />
|
||||
<PasswordInput
|
||||
label="New Password"
|
||||
value={newPassword}
|
||||
onChange={setNewPassword}
|
||||
required
|
||||
/>
|
||||
<PasswordInput
|
||||
label="Confirm New Password"
|
||||
value={confirmPassword}
|
||||
onChange={setConfirmPassword}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-neutral-100 dark:border-neutral-800 flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!currentPassword || !newPassword || !confirmPassword || loadingSecurity}
|
||||
className="flex items-center gap-2 px-6 py-2.5 bg-neutral-900 dark:bg-white text-white dark:text-neutral-900 rounded-xl font-medium
|
||||
hover:bg-neutral-800 dark:hover:bg-neutral-100 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200"
|
||||
>
|
||||
{loadingSecurity ? (
|
||||
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<CheckIcon className="w-4 h-4" />
|
||||
Update Password
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<hr className="border-neutral-100 dark:border-neutral-800" />
|
||||
|
||||
{/* Active Sessions Section */}
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-neutral-900 dark:text-white mb-1">Active Sessions</h2>
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">Manage devices that are currently signed in to your account.</p>
|
||||
</div>
|
||||
|
||||
{loadingSessions ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="w-6 h-6 border-2 border-brand-orange/30 border-t-brand-orange rounded-full animate-spin" />
|
||||
</div>
|
||||
) : sessions.length === 0 ? (
|
||||
<div className="p-4 bg-neutral-50 dark:bg-neutral-900/50 rounded-xl border border-neutral-200 dark:border-neutral-800 text-center text-neutral-500 dark:text-neutral-400">
|
||||
No active sessions found.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{sessions.map((session) => {
|
||||
const { browser, os, device } = parseUserAgent(session.user_agent)
|
||||
const isCurrent = session.is_current
|
||||
|
||||
return (
|
||||
<div
|
||||
key={session.id}
|
||||
className={`p-4 rounded-xl border transition-all duration-200 ${
|
||||
isCurrent
|
||||
? 'bg-brand-orange/5 border-brand-orange/30'
|
||||
: 'bg-neutral-50 dark:bg-neutral-900/50 border-neutral-200 dark:border-neutral-800'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-neutral-900 dark:text-white">
|
||||
{browser} on {os}
|
||||
</span>
|
||||
{isCurrent && (
|
||||
<span className="px-2 py-0.5 text-xs font-medium bg-brand-orange/10 text-brand-orange rounded-full">
|
||||
Current Session
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-neutral-500 dark:text-neutral-400 space-y-0.5">
|
||||
<div>{device} • {session.client_ip || 'Unknown IP'}</div>
|
||||
<div>Signed in {formatDate(session.created_at)}</div>
|
||||
{session.expires_at && (
|
||||
<div>Expires {formatDate(session.expires_at)}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!isCurrent && (
|
||||
<button
|
||||
onClick={() => handleRevokeSession(session.id)}
|
||||
disabled={revokingSessionId === session.id}
|
||||
className="px-3 py-1.5 text-sm font-medium text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
{revokingSessionId === session.id ? 'Revoking...' : 'Revoke'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Email Password Confirmation Modal */}
|
||||
<AnimatePresence>
|
||||
{showEmailPasswordPrompt && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="absolute inset-0 z-10 flex items-center justify-center bg-white/80 dark:bg-neutral-900/80 backdrop-blur-sm rounded-2xl"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.95, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.95, opacity: 0 }}
|
||||
className="w-full max-w-sm bg-white dark:bg-neutral-900 p-6 rounded-2xl border border-neutral-200 dark:border-neutral-800 shadow-xl"
|
||||
>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">Confirm Change</h3>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowEmailPasswordPrompt(false)
|
||||
setEmailConfirmPassword('')
|
||||
setLoadingProfile(false)
|
||||
}}
|
||||
className="text-neutral-500 hover:text-neutral-700 dark:text-neutral-400 dark:hover:text-white"
|
||||
>
|
||||
<Cross2Icon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
|
||||
Please enter your password to confirm changing your email to <span className="font-medium text-neutral-900 dark:text-white">{email}</span>.
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleUpdateProfile} className="space-y-4">
|
||||
<PasswordInput
|
||||
label="Password"
|
||||
value={emailConfirmPassword}
|
||||
onChange={setEmailConfirmPassword}
|
||||
required
|
||||
className="mb-2"
|
||||
/>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowEmailPasswordPrompt(false)
|
||||
setEmailConfirmPassword('')
|
||||
setLoadingProfile(false)
|
||||
}}
|
||||
className="flex-1 px-4 py-2 text-sm font-medium text-neutral-700 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-xl transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!emailConfirmPassword || loadingProfile}
|
||||
className="flex-1 px-4 py-2 text-sm font-medium text-white bg-neutral-900 dark:bg-white dark:text-neutral-900 hover:bg-neutral-800 dark:hover:bg-neutral-100 rounded-xl transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loadingProfile ? 'Updating...' : 'Confirm'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Account Deletion Modal */}
|
||||
<AnimatePresence>
|
||||
{showDeletePrompt && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="absolute inset-0 z-10 flex items-center justify-center bg-white/80 dark:bg-neutral-900/80 backdrop-blur-sm rounded-2xl p-4"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.95, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.95, opacity: 0 }}
|
||||
className="w-full max-w-sm bg-white dark:bg-neutral-900 p-6 rounded-2xl border border-red-200 dark:border-red-900 shadow-xl"
|
||||
>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-lg font-semibold text-red-600 dark:text-red-500">Delete Account?</h3>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowDeletePrompt(false)
|
||||
setDeletePassword('')
|
||||
setDeleteError(null)
|
||||
}}
|
||||
className="text-neutral-500 hover:text-neutral-700 dark:text-neutral-400 dark:hover:text-white"
|
||||
>
|
||||
<Cross2Icon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
|
||||
This action is <span className="font-bold">irreversible</span>. All your data will be permanently deleted.
|
||||
</p>
|
||||
|
||||
{deleteError && (
|
||||
<div className="mb-4 p-3 bg-red-50 dark:bg-red-900/10 border border-red-100 dark:border-red-900/20 rounded-lg text-xs text-red-600 dark:text-red-400">
|
||||
{deleteError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleDeleteAccount} className="space-y-4">
|
||||
<PasswordInput
|
||||
label="Verify Password"
|
||||
value={deletePassword}
|
||||
onChange={setDeletePassword}
|
||||
required
|
||||
className="mb-2"
|
||||
/>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowDeletePrompt(false)
|
||||
setDeletePassword('')
|
||||
setDeleteError(null)
|
||||
}}
|
||||
className="flex-1 px-4 py-2 text-sm font-medium text-neutral-700 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-xl transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!deletePassword || loadingDelete}
|
||||
className="flex-1 px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 dark:bg-red-600 dark:hover:bg-red-700 rounded-xl transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loadingDelete ? 'Deleting...' : 'Delete Forever'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Recovery Codes Modal */}
|
||||
<AnimatePresence>
|
||||
{showRecoveryCodes && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="absolute inset-0 z-10 flex items-center justify-center bg-white/80 dark:bg-neutral-900/80 backdrop-blur-sm rounded-2xl p-4"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.95, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.95, opacity: 0 }}
|
||||
className="w-full max-w-lg bg-white dark:bg-neutral-900 p-6 rounded-2xl border border-neutral-200 dark:border-neutral-800 shadow-xl"
|
||||
>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">Recovery Codes</h3>
|
||||
<button
|
||||
onClick={() => setShowRecoveryCodes(false)}
|
||||
className="text-neutral-500 hover:text-neutral-700 dark:text-neutral-400 dark:hover:text-white"
|
||||
>
|
||||
<Cross2Icon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-900/30 rounded-lg mb-4">
|
||||
<p className="text-sm text-yellow-800 dark:text-yellow-200 font-medium flex items-center gap-2">
|
||||
<ExclamationTriangleIcon className="w-4 h-4" />
|
||||
Save these codes securely!
|
||||
</p>
|
||||
<p className="text-sm text-yellow-700 dark:text-yellow-300 mt-1">
|
||||
They are the only way to access your account if you lose your device. We cannot display them again.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 mb-6 p-4 bg-neutral-100 dark:bg-neutral-800 rounded-xl font-mono text-center text-neutral-900 dark:text-white tracking-wider">
|
||||
{recoveryCodes.map(code => (
|
||||
<div key={code}>{code}</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={copyCodes}
|
||||
className="flex-1 px-4 py-2 flex items-center justify-center gap-2 bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 text-neutral-900 dark:text-white rounded-xl font-medium hover:bg-neutral-50 dark:hover:bg-neutral-700 transition-colors"
|
||||
>
|
||||
<CopyIcon className="w-4 h-4" />
|
||||
Copy All
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowRecoveryCodes(false)}
|
||||
className="flex-1 px-4 py-2 bg-neutral-900 dark:bg-white text-white dark:text-neutral-900 rounded-xl font-medium hover:bg-neutral-800 dark:hover:bg-neutral-100 transition-colors"
|
||||
>
|
||||
I've Saved Them
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Regenerate Confirm Modal */}
|
||||
<AnimatePresence>
|
||||
{showRegenerateConfirm && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="absolute inset-0 z-10 flex items-center justify-center bg-white/80 dark:bg-neutral-900/80 backdrop-blur-sm rounded-2xl p-4"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.95, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.95, opacity: 0 }}
|
||||
className="w-full max-w-sm bg-white dark:bg-neutral-900 p-6 rounded-2xl border border-neutral-200 dark:border-neutral-800 shadow-xl"
|
||||
>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-white">Regenerate Codes?</h3>
|
||||
<button
|
||||
onClick={() => setShowRegenerateConfirm(false)}
|
||||
className="text-neutral-500 hover:text-neutral-700 dark:text-neutral-400 dark:hover:text-white"
|
||||
>
|
||||
<Cross2Icon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-6">
|
||||
This will <span className="font-bold text-red-600 dark:text-red-400">invalidate all existing codes</span>. Make sure you have your new codes saved immediately.
|
||||
</p>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => setShowRegenerateConfirm(false)}
|
||||
className="flex-1 px-4 py-2 text-sm font-medium text-neutral-700 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-xl transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleRegenerateCodes}
|
||||
disabled={loading2FA}
|
||||
className="flex-1 px-4 py-2 text-sm font-medium text-white bg-neutral-900 dark:bg-white dark:text-neutral-900 hover:bg-neutral-800 dark:hover:bg-neutral-100 rounded-xl transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading2FA ? 'Generating...' : 'Regenerate'}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user