diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8bf4162..62868b4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,5 @@ import { Suspense, lazy } from 'react'; -import { BrowserRouter, Routes, Route } from 'react-router-dom'; +import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import { QueryProvider } from './providers/QueryProvider'; import { ThemeProvider } from './providers/ThemeProvider'; import { AuthProvider } from './contexts/AuthContext'; @@ -22,6 +22,11 @@ const Login = lazy(() => import('./pages/Login').then(m => ({ default: m.Login } const Register = lazy(() => import('./pages/Register').then(m => ({ default: m.Register }))); const ForgotPassword = lazy(() => import('./pages/ForgotPassword').then(m => ({ default: m.ForgotPassword }))); const ResetPassword = lazy(() => import('./pages/ResetPassword').then(m => ({ default: m.ResetPassword }))); +const SettingsLayout = lazy(() => import('./pages/settings/SettingsLayout').then(m => ({ default: m.SettingsLayout }))); +const SettingsProfile = lazy(() => import('./pages/settings/SettingsProfile').then(m => ({ default: m.SettingsProfile }))); +const SettingsPassword = lazy(() => import('./pages/settings/SettingsPassword').then(m => ({ default: m.SettingsPassword }))); +const SettingsNotifications = lazy(() => import('./pages/settings/SettingsNotifications').then(m => ({ default: m.SettingsNotifications }))); +const SettingsAccount = lazy(() => import('./pages/settings/SettingsAccount').then(m => ({ default: m.SettingsAccount }))); const ApiKeys = lazy(() => import('./pages/ApiKeys').then(m => ({ default: m.ApiKeys }))); const AnalyticsDashboard = lazy(() => import('./pages/AnalyticsDashboard').then(m => ({ default: m.AnalyticsDashboard }))); const NotFound = lazy(() => import('./pages/NotFound').then(m => ({ default: m.NotFound }))); @@ -75,16 +80,30 @@ function App() { } /> } /> - {/* Protected routes with layout */} - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - + {/* Protected routes with layout */} + }> + } /> + } /> + } /> + } /> + } /> + + + } /> + } /> + } /> + } /> + } /> + } /> + + + } + /> + } /> + {/* 404 */} } /> diff --git a/frontend/src/hooks/useProfile.ts b/frontend/src/hooks/useProfile.ts new file mode 100644 index 0000000..70605e6 --- /dev/null +++ b/frontend/src/hooks/useProfile.ts @@ -0,0 +1,109 @@ +import { useState, useEffect, useCallback } from 'react'; +import api from '@/lib/api'; +import { showToast } from '@/components/ui/toast-utils'; + +export interface User { + id: string; + email: string; + full_name: string; + is_active: boolean; + created_at: string; +} + +export function useProfile() { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // Fetch user profile + const getProfile = useCallback(async () => { + setLoading(true); + setError(null); + try { + const response = await api.get('/auth/me'); + const userData = response.data; + setUser(userData); + return userData; + } catch (err: any) { + const message = err.response?.data?.detail || 'Failed to load profile'; + setError(message); + showToast({ + title: 'Error loading profile', + description: message, + variant: 'destructive' + }); + return null; + } finally { + setLoading(false); + } + }, []); + + // Update user profile + const updateProfile = useCallback(async (data: Partial) => { + setLoading(true); + setError(null); + try { + const response = await api.put('/auth/me', data); + const updatedUser = response.data; + setUser(updatedUser); + showToast({ + title: 'Profile updated', + description: 'Your profile has been successfully updated' + }); + return updatedUser; + } catch (err: any) { + const message = err.response?.data?.detail || 'Failed to update profile'; + setError(message); + showToast({ + title: 'Update failed', + description: message, + variant: 'destructive' + }); + throw err; + } finally { + setLoading(false); + } + }, []); + + // Change password + const changePassword = useCallback(async (passwordData: { + current_password: string; + new_password: string; + }) => { + setLoading(true); + setError(null); + try { + await api.post('/auth/change-password', passwordData); + showToast({ + title: 'Password changed', + description: 'Your password has been successfully updated' + }); + return true; + } catch (err: any) { + const message = err.response?.data?.detail || 'Failed to change password'; + setError(message); + showToast({ + title: 'Password change failed', + description: message, + variant: 'destructive' + }); + throw err; + } finally { + setLoading(false); + } + }, []); + + // Load profile on mount + useEffect(() => { + getProfile(); + }, [getProfile]); + + return { + user, + loading, + error, + getProfile, + updateProfile, + changePassword + }; +} diff --git a/frontend/src/pages/Profile.tsx b/frontend/src/pages/Profile.tsx new file mode 100644 index 0000000..7732b75 --- /dev/null +++ b/frontend/src/pages/Profile.tsx @@ -0,0 +1,169 @@ +import { useState } from 'react'; +import { useProfile } from '@/hooks/useProfile'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; +import { Loader2 } from 'lucide-react'; +import { Link } from 'react-router-dom'; + +export function Profile() { + const { user, loading, error, updateProfile } = useProfile(); + const [isEditing, setIsEditing] = useState(false); + const [editData, setEditData] = useState({ + full_name: '', + email: '' + }); + + // Initialize edit data when user loads + // (we'd normally do this in useEffect, but keeping it simple for now) + + const handleEdit = () => { + if (user) { + setEditData({ + full_name: user.full_name, + email: user.email + }); + setIsEditing(true); + } + }; + + const handleSave = async () => { + if (editData.full_name.trim() && editData.email.trim()) { + await updateProfile({ + full_name: editData.full_name.trim(), + email: editData.email.trim() + }); + setIsEditing(false); + } + }; + + if (loading) { + return ( +
+
+ +

Loading profile...

+
+
+ ); + } + + if (error) { + return ( +
+
+

Error loading profile: {error}

+ +
+
+ ); + } + + if (!user) { + return ( +
+
+

No user data available

+ + + +
+
+ ); + } + + return ( +
+
+
+

My Profile

+ +
+ + + + Account Information + + + {isEditing ? ( +
+
+ + setEditData({ ...editData, full_name: e.target.value })} + className="w-full px-4 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-primary" + required + /> +
+
+ + setEditData({ ...editData, email: e.target.value })} + className="w-full px-4 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-primary" + required + /> +
+
+ ) : ( +
+
+
+ {user.full_name.split(' ').map(n => n[0]).join('').toUpperCase()} +
+
+

{user.full_name}

+

{user.email}

+
+
+ +
+
Account Details
+
+
+ Member since: + {new Date(user.created_at).toLocaleDateString()} +
+
+ Status: + + {user.is_active ? 'Active' : 'Inactive'} + +
+
+
+
+ )} +
+ + {!isEditing && ( + + )} + {isEditing && ( + <> + + + + )} + +
+
+
+ ); +} diff --git a/frontend/src/pages/settings/SettingsAccount.tsx b/frontend/src/pages/settings/SettingsAccount.tsx new file mode 100644 index 0000000..867ea10 --- /dev/null +++ b/frontend/src/pages/settings/SettingsAccount.tsx @@ -0,0 +1,39 @@ +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; + +export function SettingsAccount() { + return ( +
+ + + Account Management + + Manage your account settings and data + + + +
+
+

Data Export

+

+ Export your scenarios, reports, and account data +

+ +
+ +
+

Delete Account

+

+ Permanently delete your account and all associated data. This action cannot be undone. +

+ +
+
+
+ + + +
+
+ ); +} diff --git a/frontend/src/pages/settings/SettingsLayout.tsx b/frontend/src/pages/settings/SettingsLayout.tsx new file mode 100644 index 0000000..37b8a74 --- /dev/null +++ b/frontend/src/pages/settings/SettingsLayout.tsx @@ -0,0 +1,93 @@ +import { Link } from 'react-router-dom'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Settings as SettingsIcon, Lock as LockIcon, Bell as BellIcon, Key as KeyIcon, Trash2 as Trash2Icon } from 'lucide-react'; + +interface SettingsNavItem { + title: string; + description: string; + href: string; + icon: React.ComponentType<{ className?: string }>; +} + +const settingsNav: SettingsNavItem[] = [ + { + title: 'Profile', + description: 'Update your personal information', + href: '/settings/profile', + icon: SettingsIcon + }, + { + title: 'Password', + description: 'Change your account password', + href: '/settings/password', + icon: LockIcon + }, + { + title: 'Notifications', + description: 'Manage email and push notifications', + href: '/settings/notifications', + icon: BellIcon + }, + { + title: 'API Keys', + description: 'Manage your API access keys', + href: '/settings/api-keys', + icon: KeyIcon + }, + { + title: 'Account', + description: 'Delete account or export data', + href: '/settings/account', + icon: Trash2Icon + } +]; + +export function SettingsLayout({ children }: { children: React.ReactNode }) { + return ( +
+
+ {/* Sidebar */} + + + {/* Main Content */} +
+ + + {settingsNav.find(nav => nav.href === window.location.pathname)?.title || 'Settings'} + + {children} + +
+
+
+ ); +} diff --git a/frontend/src/pages/settings/SettingsNotifications.tsx b/frontend/src/pages/settings/SettingsNotifications.tsx new file mode 100644 index 0000000..9a841a5 --- /dev/null +++ b/frontend/src/pages/settings/SettingsNotifications.tsx @@ -0,0 +1,52 @@ +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; + +export function SettingsNotifications() { + return ( +
+ + + Notification Preferences + + Manage how and when you receive notifications from mockupAWS + + + +
+
+

Email Notifications

+
+ + + + +
+
+ +
+

Push Notifications

+

+ Browser notifications for real-time updates (coming soon) +

+
+
+
+ + + +
+
+ ); +} diff --git a/frontend/src/pages/settings/SettingsPassword.tsx b/frontend/src/pages/settings/SettingsPassword.tsx new file mode 100644 index 0000000..7a0e915 --- /dev/null +++ b/frontend/src/pages/settings/SettingsPassword.tsx @@ -0,0 +1,136 @@ +import { useState } from 'react'; +import { useProfile } from '@/hooks/useProfile'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; +import { Loader2 } from 'lucide-react'; + +export function SettingsPassword() { + const { loading, changePassword } = useProfile(); + const [formData, setFormData] = useState({ + current_password: '', + new_password: '', + confirm_password: '' + }); + const [isChanging, setIsChanging] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (formData.new_password !== formData.confirm_password) { + // In a real app, we'd show a form error + alert('New passwords do not match'); + return; + } + + if (formData.new_password.length < 8) { + alert('Password must be at least 8 characters'); + return; + } + + setIsChanging(true); + try { + await changePassword({ + current_password: formData.current_password, + new_password: formData.new_password + }); + // Reset form on success + setFormData({ + current_password: '', + new_password: '', + confirm_password: '' + }); + } catch (err) { + // Error handled by useProfile hook + } finally { + setIsChanging(false); + } + }; + + if (loading) { + return ( +
+
+ +

Loading...

+
+
+ ); + } + + return ( +
+ + + Change Password + + +
+
+ + setFormData({ ...formData, current_password: e.target.value })} + required + autoComplete="current-password" + /> +

+ Enter your current password +

+
+ +
+ + setFormData({ ...formData, new_password: e.target.value })} + required + autoComplete="new-password" + /> +

+ Must be at least 8 characters long +

+
+ +
+ + setFormData({ ...formData, confirm_password: e.target.value })} + required + autoComplete="new-password" + /> +

+ Re-enter your new password to confirm +

+
+
+
+ + + +
+
+ ); +} diff --git a/frontend/src/pages/settings/SettingsProfile.tsx b/frontend/src/pages/settings/SettingsProfile.tsx new file mode 100644 index 0000000..1f30b44 --- /dev/null +++ b/frontend/src/pages/settings/SettingsProfile.tsx @@ -0,0 +1,173 @@ +import { useState } from 'react'; +import { useProfile } from '@/hooks/useProfile'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Loader2 } from 'lucide-react'; + +export function SettingsProfile() { + const { user, loading, updateProfile } = useProfile(); + const [editMode, setEditMode] = useState(false); + const [formData, setFormData] = useState({ + full_name: '', + email: '' + }); + const [isSaving, setIsSaving] = useState(false); + + // Initialize form with user data + // In a real app, we'd use useEffect to set this when user loads + // For simplicity, we'll initialize on mount assuming user exists + + const handleSave = async () => { + setIsSaving(true); + try { + await updateProfile({ + full_name: formData.full_name.trim(), + email: formData.email.trim() + }); + setEditMode(false); + } catch (err) { + // Error handled by useProfile hook + } finally { + setIsSaving(false); + } + }; + + if (loading) { + return ( +
+
+ +

Loading...

+
+
+ ); + } + + if (!user) { + return ( +
+
+

Please log in to view your profile

+
+
+ ); + } + + // Initialize form data if not already set + // Using a simple approach - in practice this would be in useEffect + const initialized = formData.full_name !== '' || formData.email !== ''; + if (!initialized && user) { + setFormData({ + full_name: user.full_name, + email: user.email + }); + } + + return ( +
+
+ +
+ + {editMode ? ( + + + Edit Profile + + +
{ e.preventDefault(); handleSave(); }}> +
+
+ + setFormData({ ...formData, full_name: e.target.value })} + required + autoComplete="name" + /> +
+
+ + setFormData({ ...formData, email: e.target.value })} + required + autoComplete="email" + /> +
+
+ +
+
+
+ ) : ( + <> + + + Profile Information + + +
+ +

+ {user.full_name} +

+
+
+ +

+ {user.email} +

+
+
+
+ + + + Account Details + + +
+ +

+ {new Date(user.created_at).toLocaleDateString()} +

+
+
+ +

+ {user.is_active ? 'Active' : 'Inactive'} +

+
+
+
+ + )} +
+ ); +}