diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 07dcd06..7ab8e4c 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -1,10 +1,11 @@ import React, { useState, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; -import { LayoutGrid, Folder, Plus, Trash2, Edit2, Archive, FolderOpen, Settings as SettingsIcon } from 'lucide-react'; +import { LayoutGrid, Folder, Plus, Trash2, Edit2, Archive, FolderOpen, Settings as SettingsIcon, User, LogOut } from 'lucide-react'; import type { Collection } from '../types'; import clsx from 'clsx'; import { ConfirmModal } from './ConfirmModal'; import { Logo } from './Logo'; +import { useAuth } from '../context/AuthContext'; interface SidebarProps { collections: Collection[]; @@ -120,6 +121,8 @@ export const Sidebar: React.FC = ({ onDeleteCollection, onDrop }) => { + const navigate = useNavigate(); + const { logout, user } = useAuth(); const [isCreating, setIsCreating] = useState(false); const [newCollectionName, setNewCollectionName] = useState(''); const [editingId, setEditingId] = useState(null); @@ -127,7 +130,6 @@ export const Sidebar: React.FC = ({ const [contextMenu, setContextMenu] = useState<{ x: number; y: number; type: 'item' | 'background'; id?: string } | null>(null); const [collectionToDelete, setCollectionToDelete] = useState(null); const [isTrashDragOver, setIsTrashDragOver] = useState(false); - const navigate = useNavigate(); useEffect(() => { const handleClickOutside = () => setContextMenu(null); @@ -284,6 +286,19 @@ export const Sidebar: React.FC = ({ Trash + + + + {/* User info and logout */} +
+ {user && ( +
+
{user.name}
+
{user.email}
+
+ )} + +
diff --git a/frontend/src/pages/Profile.tsx b/frontend/src/pages/Profile.tsx new file mode 100644 index 0000000..5f2f9e1 --- /dev/null +++ b/frontend/src/pages/Profile.tsx @@ -0,0 +1,321 @@ +import React, { useState, useEffect } from 'react'; +import { Layout } from '../components/Layout'; +import { useNavigate } from 'react-router-dom'; +import { useAuth } from '../context/AuthContext'; +import * as api from '../api'; +import type { Collection } from '../types'; +import { User, Lock, Save, X } from 'lucide-react'; +import { ConfirmModal } from '../components/ConfirmModal'; + +export const Profile: React.FC = () => { + const { user: authUser, logout } = useAuth(); + const navigate = useNavigate(); + const [collections, setCollections] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(''); + + // User info state + const [name, setName] = useState(''); + const [email, setEmail] = useState(''); + + // Password change state + const [currentPassword, setCurrentPassword] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [showPasswordForm, setShowPasswordForm] = useState(false); + + useEffect(() => { + const fetchData = async () => { + try { + const collectionsData = await api.getCollections(); + setCollections(collectionsData); + + // Fetch user info + if (authUser) { + setName(authUser.name); + setEmail(authUser.email); + } + } catch (err) { + console.error('Failed to fetch data:', err); + } + }; + fetchData(); + }, [authUser]); + + const handleSelectCollection = (id: string | null | undefined) => { + if (id === undefined) navigate('/'); + else if (id === null) navigate('/collections?id=unorganized'); + else navigate(`/collections?id=${id}`); + }; + + const handleCreateCollection = async (name: string) => { + await api.createCollection(name); + const newCollections = await api.getCollections(); + setCollections(newCollections); + }; + + const handleEditCollection = async (id: string, name: string) => { + setCollections(prev => prev.map(c => c.id === id ? { ...c, name } : c)); + await api.updateCollection(id, name); + }; + + const handleDeleteCollection = async (id: string) => { + setCollections(prev => prev.filter(c => c.id !== id)); + await api.deleteCollection(id); + }; + + const handleUpdateName = async () => { + if (!name.trim()) { + setError('Name cannot be empty'); + return; + } + + setLoading(true); + setError(''); + setSuccess(''); + + try { + const response = await api.api.put<{ user: { id: string; email: string; name: string; createdAt: string; updatedAt: string } }>('/auth/profile', { name: name.trim() }); + setSuccess('Name updated successfully'); + // Update auth context - refresh user data + if (response.data?.user) { + // Update localStorage with new user data + localStorage.setItem('excalidash-user', JSON.stringify(response.data.user)); + // Reload to update auth context + setTimeout(() => window.location.reload(), 500); + } + } catch (err: unknown) { + let message = 'Failed to update name'; + if (api.isAxiosError(err)) { + if (err.response?.data?.message) { + message = err.response.data.message; + } else if (err.response?.data?.error) { + message = err.response.data.error; + } + } + setError(message); + } finally { + setLoading(false); + } + }; + + const handleChangePassword = async () => { + if (!currentPassword || !newPassword || !confirmPassword) { + setError('All password fields are required'); + return; + } + + if (newPassword.length < 8) { + setError('New password must be at least 8 characters long'); + return; + } + + if (newPassword !== confirmPassword) { + setError('New passwords do not match'); + return; + } + + setLoading(true); + setError(''); + setSuccess(''); + + try { + await api.api.post('/auth/change-password', { + currentPassword, + newPassword, + }); + setSuccess('Password changed successfully'); + setShowPasswordForm(false); + setCurrentPassword(''); + setNewPassword(''); + setConfirmPassword(''); + // Logout user to force re-login with new password + setTimeout(() => { + logout(); + navigate('/login'); + }, 2000); + } catch (err: unknown) { + let message = 'Failed to change password'; + if (api.isAxiosError(err)) { + if (err.response?.data?.message) { + message = err.response.data.message; + } else if (err.response?.data?.error) { + message = err.response.data.error; + } + } + setError(message); + } finally { + setLoading(false); + } + }; + + return ( + +

+ Profile +

+ + {/* Success/Error Messages */} + {success && ( +
+

{success}

+
+ )} + {error && ( +
+

{error}

+
+ )} + +
+ {/* Personal Information Section */} +
+
+
+ +
+

Personal Information

+
+ +
+
+ + +

Email cannot be changed

+
+ +
+ +
+ setName(e.target.value)} + className="flex-1 px-4 py-3 bg-white dark:bg-neutral-800 border-2 border-black dark:border-neutral-700 rounded-xl text-slate-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:focus:ring-indigo-400 font-medium" + placeholder="Your name" + /> + +
+
+
+
+ + {/* Password Change Section */} +
+
+
+
+ +
+

Change Password

+
+ {!showPasswordForm && ( + + )} +
+ + {showPasswordForm && ( +
+
+ + setCurrentPassword(e.target.value)} + className="w-full px-4 py-3 bg-white dark:bg-neutral-800 border-2 border-black dark:border-neutral-700 rounded-xl text-slate-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-rose-500 dark:focus:ring-rose-400 font-medium" + placeholder="Enter current password" + /> +
+ +
+ + setNewPassword(e.target.value)} + className="w-full px-4 py-3 bg-white dark:bg-neutral-800 border-2 border-black dark:border-neutral-700 rounded-xl text-slate-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-rose-500 dark:focus:ring-rose-400 font-medium" + placeholder="Enter new password (min 8 characters)" + /> +
+ +
+ + setConfirmPassword(e.target.value)} + className="w-full px-4 py-3 bg-white dark:bg-neutral-800 border-2 border-black dark:border-neutral-700 rounded-xl text-slate-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-rose-500 dark:focus:ring-rose-400 font-medium" + placeholder="Confirm new password" + /> +
+ +
+ + +
+
+ )} +
+
+
+ ); +};