diff --git a/components/settings/ProfileSettings.tsx b/components/settings/ProfileSettings.tsx index 4fb2fe7..6b1a4dd 100644 --- a/components/settings/ProfileSettings.tsx +++ b/components/settings/ProfileSettings.tsx @@ -1,998 +1,54 @@ '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 '@ciphera-net/ui' -import { toast } from 'sonner' +import { ProfileSettings as SharedProfileSettings } from '@ciphera-net/ui' 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' +import { deleteAccount, getUserSessions, revokeSession, updateUserPreferences } from '@/lib/api/user' +import { setup2FA, verify2FA, disable2FA, regenerateRecoveryCodes } from '@/lib/api/2fa' 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(null) - - // 2FA State - const [show2FASetup, setShow2FASetup] = useState(false) - const [twoFAData, setTwoFAData] = useState(null) - const [twoFACode, setTwoFACode] = useState('') - const [loading2FA, setLoading2FA] = useState(false) - - // Recovery Codes State - const [recoveryCodes, setRecoveryCodes] = useState([]) - 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(null) - - // Sessions State - const [sessions, setSessions] = useState([]) - const [loadingSessions, setLoadingSessions] = useState(false) - const [revokingSessionId, setRevokingSessionId] = useState(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 + const handleUpdateProfile = async (email: string, currentPasswordDerived: string, newDerivedKey: string) => { + await api('/auth/user/email', { + method: 'PUT', + body: JSON.stringify({ + email: email, + current_password: currentPasswordDerived, + new_derived_key: newDerivedKey + }) + }) + } + + const handleUpdatePassword = async (currentPasswordDerived: string, newDerivedKey: string) => { + await api('/auth/user/password', { + method: 'PUT', + body: JSON.stringify({ + current_password: currentPasswordDerived, + new_password: newDerivedKey + }) + }) + } + return ( -
-
-

Settings

-

- Manage your account settings and preferences. -

-
- -
- {/* Sidebar Navigation */} - - - {/* Content Area */} -
- - {activeTab === 'preferences' && ( -
-
-

- Preferences are currently not available. -

-
-
- )} - - {activeTab === 'profile' && ( -
-
-
-

Profile Information

-

Update your account details.

-
- -
-
- -
- { - 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" - /> - -
-
-
- -
- -
-
- -
-
-

Danger Zone

-

Irreversible actions for your account.

-
- -
-
-

Delete Account

-

Permanently delete your account and all data.

-
- -
-
-
- )} - - {activeTab === 'security' && ( -
- {/* 2FA Section */} -
-
-

Two-Factor Authentication

-

Add an extra layer of security to your account.

-
- -
-
-
-
-
- -
-
-

Authenticator App

-

Use an app like Google Authenticator or Authy.

-
-
- - {user.totp_enabled ? ( - - ) : ( - - )} -
- - - {show2FASetup && twoFAData && ( - -
-
- {/* QR Code */} - 2FA QR Code -
-
-
-

1. Scan QR Code

-

- Open your authenticator app and scan the image. -

-
- -
-

2. Enter Code

-
- 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" - /> - -
-
- -
-

- Secret: {twoFAData.secret} -

-
-
-
-
- )} -
-
- - {user.totp_enabled && ( -
-
-
-
- -
-
-

Recovery Codes

-

- Generate backup codes in case you lose access to your device. -

-
-
- -
-
- )} -
-
- -
- -
-
-

Password

-

Update your password to keep your account secure.

-
- - {securityError && ( -
- - {securityError} -
- )} - -
- setCurrentPassword(e.target.value)} - placeholder="Enter current password" - required - /> -
- setNewPassword(e.target.value)} - placeholder="Enter new password" - required - /> - setConfirmPassword(e.target.value)} - placeholder="Confirm new password" - required - /> -
- -
- -
-
- -
- - {/* Active Sessions Section */} -
-
-

Active Sessions

-

Manage devices that are currently signed in to your account.

-
- - {loadingSessions ? ( -
-
-
- ) : sessions.length === 0 ? ( -
- No active sessions found. -
- ) : ( -
- {sessions.map((session) => { - const { browser, os, device } = parseUserAgent(session.user_agent) - const isCurrent = session.is_current - - return ( -
-
-
-
- - {browser} on {os} - - {isCurrent && ( - - Current Session - - )} -
-
-
{device} • {session.client_ip || 'Unknown IP'}
-
Signed in {formatDate(session.created_at)}
- {session.expires_at && ( -
Expires {formatDate(session.expires_at)}
- )} -
-
- {!isCurrent && ( - - )} -
-
- ) - })} -
- )} -
-
- )} - - {/* Email Password Confirmation Modal */} - - {showEmailPasswordPrompt && ( - - -
-

Confirm Change

- -
- -

- Please enter your password to confirm changing your email to {email}. -

- -
- setEmailConfirmPassword(e.target.value)} - placeholder="Enter your password" - required - className="mb-2" - /> - -
- - -
- -
-
- )} -
- - {/* Account Deletion Modal */} - - {showDeletePrompt && ( - - -
-

Delete Account?

- -
- -

- This action is irreversible. All your data will be permanently deleted. -

- - {deleteError && ( -
- {deleteError} -
- )} - -
- setDeletePassword(e.target.value)} - placeholder="Enter your password" - required - className="mb-2" - /> - -
- - -
- -
-
- )} -
- - {/* Recovery Codes Modal */} - - {showRecoveryCodes && ( - - -
-

Recovery Codes

- -
- -
-

- - Save these codes securely! -

-

- They are the only way to access your account if you lose your device. We cannot display them again. -

-
- -
- {recoveryCodes.map(code => ( -
{code}
- ))} -
- -
- - -
-
-
- )} -
- - {/* Regenerate Confirm Modal */} - - {showRegenerateConfirm && ( - - -
-

Regenerate Codes?

- -
- -

- This will invalidate all existing codes. Make sure you have your new codes saved immediately. -

- -
- - -
-
-
- )} -
- -
-
-
+ ) } diff --git a/package-lock.json b/package-lock.json index e8c7489..8cc574e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,7 @@ "name": "pulse-frontend", "version": "0.1.0", "dependencies": { - "@ciphera-net/ui": "^0.0.14", + "@ciphera-net/ui": "^0.0.16", "@radix-ui/react-icons": "^1.3.2", "axios": "^1.13.2", "country-flag-icons": "^1.6.4", @@ -268,13 +268,14 @@ } }, "node_modules/@ciphera-net/ui": { - "version": "0.0.14", - "resolved": "https://npm.pkg.github.com/download/@ciphera-net/ui/0.0.14/fb56b1bbd138eddc5a16d26c26d58524821f78c8", - "integrity": "sha512-IcQnp8pr7qsCU1QLKCUad7i+H0l/MykwHiu7pvbEON31PeFEJj8pdkXYnp+0ihRunWQ73G5Jik44AZtqHgNyFg==", + "version": "0.0.16", + "resolved": "https://npm.pkg.github.com/download/@ciphera-net/ui/0.0.16/896c89477d9a9907f3e1f3db950fcd84400ee64c", + "integrity": "sha512-HYmyv9wUxggr+ZwpR+kSf+LtmEHuvrPxHVDOg9OxVuIppjExku1YdNbKbUgYFvcLbsHDqULGDfecrgsHoC1Rgg==", "dependencies": { "@radix-ui/react-icons": "^1.3.0", "clsx": "^2.1.0", "framer-motion": "^12.0.0", + "sonner": "^2.0.7", "tailwind-merge": "^2.2.0" }, "peerDependencies": { diff --git a/package.json b/package.json index 2ac6828..0415fa4 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "type-check": "tsc --noEmit" }, "dependencies": { - "@ciphera-net/ui": "^0.0.14", + "@ciphera-net/ui": "^0.0.16", "@radix-ui/react-icons": "^1.3.2", "axios": "^1.13.2", "country-flag-icons": "^1.6.4",