From e1cf7d4b13f6afc2e7ea71ae01597ce8244481e7 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Fri, 16 Jan 2026 22:37:40 +0100 Subject: [PATCH] feat: add settings page to analytics-frontend --- app/settings/page.tsx | 14 + components/PasswordInput.tsx | 103 +++ components/settings/ProfileSettings.tsx | 993 ++++++++++++++++++++++++ lib/api/2fa.ts | 40 + lib/api/user.ts | 60 ++ lib/crypto/password.ts | 133 ++++ 6 files changed, 1343 insertions(+) create mode 100644 app/settings/page.tsx create mode 100644 components/PasswordInput.tsx create mode 100644 components/settings/ProfileSettings.tsx create mode 100644 lib/api/2fa.ts create mode 100644 lib/api/user.ts create mode 100644 lib/crypto/password.ts diff --git a/app/settings/page.tsx b/app/settings/page.tsx new file mode 100644 index 0000000..7f5d55b --- /dev/null +++ b/app/settings/page.tsx @@ -0,0 +1,14 @@ +import ProfileSettings from '@/components/settings/ProfileSettings' + +export const metadata = { + title: 'Settings - Ciphera Analytics', + description: 'Manage your account settings', +} + +export default function SettingsPage() { + return ( +
+ +
+ ) +} diff --git a/components/PasswordInput.tsx b/components/PasswordInput.tsx new file mode 100644 index 0000000..d3876e0 --- /dev/null +++ b/components/PasswordInput.tsx @@ -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 ( +
+ {label && ( + + )} +
+ 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) */} +
+ +
+ + {/* Toggle Visibility Button (Right) */} + +
+ {error && ( + + )} +
+ ) +} diff --git a/components/settings/ProfileSettings.tsx b/components/settings/ProfileSettings.tsx new file mode 100644 index 0000000..51edfe7 --- /dev/null +++ b/components/settings/ProfileSettings.tsx @@ -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(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 + + 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} +
+ )} + +
+ +
+ + +
+ +
+ +
+
+ +
+ + {/* 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}. +

+ +
+ + +
+ + +
+ +
+
+ )} +
+ + {/* Account Deletion Modal */} + + {showDeletePrompt && ( + + +
+

Delete Account?

+ +
+ +

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

+ + {deleteError && ( +
+ {deleteError} +
+ )} + +
+ + +
+ + +
+ +
+
+ )} +
+ + {/* 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/lib/api/2fa.ts b/lib/api/2fa.ts new file mode 100644 index 0000000..e2a7570 --- /dev/null +++ b/lib/api/2fa.ts @@ -0,0 +1,40 @@ +import apiRequest from './client' + +export interface Setup2FAResponse { + secret: string + qr_code: string +} + +export interface Verify2FAResponse { + message: string + recovery_codes: string[] +} + +export interface RegenerateCodesResponse { + recovery_codes: string[] +} + +export async function setup2FA(): Promise { + return apiRequest('/auth/2fa/setup', { + method: 'POST', + }) +} + +export async function verify2FA(code: string): Promise { + return apiRequest('/auth/2fa/verify', { + method: 'POST', + body: JSON.stringify({ code }), + }) +} + +export async function disable2FA(): Promise { + return apiRequest('/auth/2fa/disable', { + method: 'POST', + }) +} + +export async function regenerateRecoveryCodes(): Promise { + return apiRequest('/auth/2fa/recovery', { + method: 'POST', + }) +} diff --git a/lib/api/user.ts b/lib/api/user.ts new file mode 100644 index 0000000..4c1c78f --- /dev/null +++ b/lib/api/user.ts @@ -0,0 +1,60 @@ +import apiRequest from './client' + +export async function deleteAccount(password: string): Promise { + // This goes to ciphera-auth + return apiRequest('/auth/user', { + method: 'DELETE', + body: JSON.stringify({ password }), + }) +} + +export interface Session { + id: string + client_ip: string + user_agent: string + created_at: string + expires_at: string + is_current: boolean +} + +export async function getUserSessions(): Promise<{ sessions: Session[] }> { + // Hash the current refresh token to identify current session + const refreshToken = typeof window !== 'undefined' ? localStorage.getItem('refreshToken') : null + let currentTokenHash = '' + + if (refreshToken) { + // Hash the refresh token using SHA-256 + const encoder = new TextEncoder() + const data = encoder.encode(refreshToken) + const hashBuffer = await crypto.subtle.digest('SHA-256', data) + const hashArray = Array.from(new Uint8Array(hashBuffer)) + currentTokenHash = hashArray.map(b => b.toString(16).padStart(2, '0')).join('') + } + + return apiRequest<{ sessions: Session[] }>('/auth/user/sessions', { + headers: currentTokenHash ? { + 'X-Current-Session-Hash': currentTokenHash, + } : undefined, + }) +} + +export async function revokeSession(sessionId: string): Promise { + return apiRequest(`/auth/user/sessions/${sessionId}`, { + method: 'DELETE', + }) +} + +export interface UserPreferences { + email_notifications: { + new_file_received: boolean + file_downloaded: boolean + security_alerts: boolean + } +} + +export async function updateUserPreferences(preferences: UserPreferences): Promise { + return apiRequest('/auth/user/preferences', { + method: 'PUT', + body: JSON.stringify(preferences), + }) +} diff --git a/lib/crypto/password.ts b/lib/crypto/password.ts new file mode 100644 index 0000000..8ae67f4 --- /dev/null +++ b/lib/crypto/password.ts @@ -0,0 +1,133 @@ +/** + * Password protection utilities using PBKDF2 + */ + +/** + * Derive a key from password using PBKDF2 + * This is used to encrypt the file encryption key when password protection is enabled + */ +export async function deriveKeyFromPassword( + password: string, + salt: Uint8Array +): Promise { + // * Import password as key material + const passwordKey = await crypto.subtle.importKey( + 'raw', + new TextEncoder().encode(password), + 'PBKDF2', + false, + ['deriveBits', 'deriveKey'] + ) + + // * Derive key using PBKDF2 + return crypto.subtle.deriveKey( + { + name: 'PBKDF2', + salt: salt as BufferSource, + iterations: 100000, // * High iteration count for security + hash: 'SHA-256', + }, + passwordKey, + { + name: 'AES-GCM', + length: 256, + }, + false, // not extractable + ['encrypt', 'decrypt'] + ) +} + +/** + * Derive an authentication key from password and email (used as salt). + * This ensures the raw password never leaves the client. + */ +export async function deriveAuthKey( + password: string, + email: string +): Promise { + const encoder = new TextEncoder() + + // * Import password as key material + const passwordKey = await crypto.subtle.importKey( + 'raw', + encoder.encode(password), + 'PBKDF2', + false, + ['deriveBits'] + ) + + // * Derive bits using PBKDF2 + // * We use the email as a deterministic salt for the auth key + const derivedBits = await crypto.subtle.deriveBits( + { + name: 'PBKDF2', + salt: encoder.encode(email), + iterations: 100000, + hash: 'SHA-256', + }, + passwordKey, + 256 // 256 bits = 32 bytes + ) + + // * Convert to hex string + return Array.from(new Uint8Array(derivedBits)) + .map((b) => b.toString(16).padStart(2, '0')) + .join('') +} + +/** + * Generate a random salt for PBKDF2 + */ +export function generateSalt(): Uint8Array { + return crypto.getRandomValues(new Uint8Array(16)) +} + +/** + * Encrypt a key with a password-derived key + */ +export async function encryptKeyWithPassword( + key: Uint8Array, + password: string +): Promise<{ encrypted: ArrayBuffer; iv: Uint8Array; salt: Uint8Array }> { + const salt = generateSalt() + const derivedKey = await deriveKeyFromPassword(password, salt) + const iv = crypto.getRandomValues(new Uint8Array(12)) + + const encrypted = await crypto.subtle.encrypt( + { + name: 'AES-GCM', + iv: iv as BufferSource, + }, + derivedKey, + key as BufferSource + ) + + return { + encrypted, + iv, + salt, + } +} + +/** + * Decrypt a key using a password + */ +export async function decryptKeyWithPassword( + encrypted: ArrayBuffer, + iv: Uint8Array, + salt: Uint8Array, + password: string +): Promise { + const derivedKey = await deriveKeyFromPassword(password, salt) + + const decrypted = await crypto.subtle.decrypt( + { + name: 'AES-GCM', + iv: iv as BufferSource, + }, + derivedKey, + encrypted + ) + + return new Uint8Array(decrypted) +}