feat(frontend): add profile page for user management

- Add Profile page for viewing/editing user info
- Add display name editing functionality
- Add change password functionality with validation
- Add Profile button to Sidebar navigation
- Handle authentication errors gracefully
This commit is contained in:
Matteo
2026-01-24 17:12:26 +01:00
parent b834f777b5
commit 112d58a92a
2 changed files with 355 additions and 2 deletions
+34 -2
View File
@@ -1,10 +1,11 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom'; 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 type { Collection } from '../types';
import clsx from 'clsx'; import clsx from 'clsx';
import { ConfirmModal } from './ConfirmModal'; import { ConfirmModal } from './ConfirmModal';
import { Logo } from './Logo'; import { Logo } from './Logo';
import { useAuth } from '../context/AuthContext';
interface SidebarProps { interface SidebarProps {
collections: Collection[]; collections: Collection[];
@@ -120,6 +121,8 @@ export const Sidebar: React.FC<SidebarProps> = ({
onDeleteCollection, onDeleteCollection,
onDrop onDrop
}) => { }) => {
const navigate = useNavigate();
const { logout, user } = useAuth();
const [isCreating, setIsCreating] = useState(false); const [isCreating, setIsCreating] = useState(false);
const [newCollectionName, setNewCollectionName] = useState(''); const [newCollectionName, setNewCollectionName] = useState('');
const [editingId, setEditingId] = useState<string | null>(null); const [editingId, setEditingId] = useState<string | null>(null);
@@ -127,7 +130,6 @@ export const Sidebar: React.FC<SidebarProps> = ({
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; type: 'item' | 'background'; id?: string } | null>(null); const [contextMenu, setContextMenu] = useState<{ x: number; y: number; type: 'item' | 'background'; id?: string } | null>(null);
const [collectionToDelete, setCollectionToDelete] = useState<string | null>(null); const [collectionToDelete, setCollectionToDelete] = useState<string | null>(null);
const [isTrashDragOver, setIsTrashDragOver] = useState(false); const [isTrashDragOver, setIsTrashDragOver] = useState(false);
const navigate = useNavigate();
useEffect(() => { useEffect(() => {
const handleClickOutside = () => setContextMenu(null); const handleClickOutside = () => setContextMenu(null);
@@ -284,6 +286,19 @@ export const Sidebar: React.FC<SidebarProps> = ({
<span className="min-w-0 flex-1 text-left">Trash</span> <span className="min-w-0 flex-1 text-left">Trash</span>
</button> </button>
<button
onClick={() => navigate('/profile')}
className={clsx(
"w-full flex items-center gap-3 px-3 py-2 text-sm font-bold rounded-xl transition-all duration-200 border-2 border-black dark:border-neutral-700 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)]",
selectedCollectionId === 'PROFILE'
? "bg-indigo-50 dark:bg-neutral-800 text-indigo-900 dark:text-neutral-200 -translate-y-0.5"
: "bg-white dark:bg-neutral-900 text-slate-900 dark:text-neutral-200 hover:bg-slate-50 dark:hover:bg-neutral-800 hover:shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:hover:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)] hover:-translate-y-0.5"
)}
>
<User size={18} />
<span className="min-w-0 flex-1 text-left">Profile</span>
</button>
<button <button
onClick={() => navigate('/settings')} onClick={() => navigate('/settings')}
className={clsx( className={clsx(
@@ -296,6 +311,23 @@ export const Sidebar: React.FC<SidebarProps> = ({
<SettingsIcon size={18} /> <SettingsIcon size={18} />
<span className="min-w-0 flex-1 text-left">Settings</span> <span className="min-w-0 flex-1 text-left">Settings</span>
</button> </button>
{/* User info and logout */}
<div className="mt-auto pt-4 border-t-2 border-slate-200 dark:border-neutral-700">
{user && (
<div className="px-3 py-2 text-xs text-slate-500 dark:text-neutral-500 mb-2">
<div className="font-semibold text-slate-700 dark:text-neutral-300">{user.name}</div>
<div className="truncate">{user.email}</div>
</div>
)}
<button
onClick={logout}
className="w-full flex items-center gap-3 px-3 py-2 text-sm font-bold rounded-xl transition-all duration-200 border-2 border-rose-300 dark:border-rose-700 bg-white dark:bg-neutral-900 text-rose-600 dark:text-rose-400 hover:bg-rose-50 dark:hover:bg-rose-900/30 hover:shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:hover:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] hover:-translate-y-0.5 cursor-pointer"
>
<LogOut size={18} />
<span className="min-w-0 flex-1 text-left">Logout</span>
</button>
</div>
</div> </div>
</div> </div>
+321
View File
@@ -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<Collection[]>([]);
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 (
<Layout
collections={collections}
selectedCollectionId="PROFILE"
onSelectCollection={handleSelectCollection}
onCreateCollection={handleCreateCollection}
onEditCollection={handleEditCollection}
onDeleteCollection={handleDeleteCollection}
>
<h1 className="text-5xl mb-8 text-slate-900 dark:text-white pl-1" style={{ fontFamily: 'Excalifont' }}>
Profile
</h1>
{/* Success/Error Messages */}
{success && (
<div className="mb-6 p-4 bg-green-50 dark:bg-green-900/20 border-2 border-green-200 dark:border-green-800 rounded-xl">
<p className="text-green-800 dark:text-green-200 font-medium">{success}</p>
</div>
)}
{error && (
<div className="mb-6 p-4 bg-red-50 dark:bg-red-900/20 border-2 border-red-200 dark:border-red-800 rounded-xl">
<p className="text-red-800 dark:text-red-200 font-medium">{error}</p>
</div>
)}
<div className="space-y-6">
{/* Personal Information Section */}
<div className="bg-white dark:bg-neutral-900 border-2 border-black dark:border-neutral-700 rounded-2xl shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)] p-6">
<div className="flex items-center gap-3 mb-6">
<div className="w-12 h-12 bg-indigo-50 dark:bg-neutral-800 rounded-xl flex items-center justify-center border-2 border-indigo-100 dark:border-neutral-700">
<User size={24} className="text-indigo-600 dark:text-indigo-400" />
</div>
<h2 className="text-2xl font-bold text-slate-900 dark:text-white">Personal Information</h2>
</div>
<div className="space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-bold text-slate-700 dark:text-neutral-300 mb-2">
Email Address
</label>
<input
id="email"
type="email"
value={email}
disabled
className="w-full px-4 py-3 bg-slate-50 dark:bg-neutral-800 border-2 border-slate-200 dark:border-neutral-700 rounded-xl text-slate-600 dark:text-neutral-400 cursor-not-allowed"
/>
<p className="mt-1 text-xs text-slate-500 dark:text-neutral-500">Email cannot be changed</p>
</div>
<div>
<label htmlFor="name" className="block text-sm font-bold text-slate-700 dark:text-neutral-300 mb-2">
Display Name
</label>
<div className="flex gap-3">
<input
id="name"
type="text"
value={name}
onChange={(e) => 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"
/>
<button
onClick={handleUpdateName}
disabled={loading || !name.trim() || name === authUser?.name}
className="px-6 py-3 bg-indigo-600 dark:bg-indigo-500 text-white font-bold rounded-xl border-2 border-black dark:border-neutral-700 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] hover:shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:hover:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)] hover:-translate-y-0.5 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:translate-y-0 disabled:hover:shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:disabled:hover:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] flex items-center gap-2"
>
<Save size={18} />
Save
</button>
</div>
</div>
</div>
</div>
{/* Password Change Section */}
<div className="bg-white dark:bg-neutral-900 border-2 border-black dark:border-neutral-700 rounded-2xl shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)] p-6">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-rose-50 dark:bg-neutral-800 rounded-xl flex items-center justify-center border-2 border-rose-100 dark:border-neutral-700">
<Lock size={24} className="text-rose-600 dark:text-rose-400" />
</div>
<h2 className="text-2xl font-bold text-slate-900 dark:text-white">Change Password</h2>
</div>
{!showPasswordForm && (
<button
onClick={() => setShowPasswordForm(true)}
className="px-4 py-2 bg-rose-600 dark:bg-rose-500 text-white font-bold rounded-xl border-2 border-black dark:border-neutral-700 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] hover:shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:hover:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)] hover:-translate-y-0.5 transition-all duration-200"
>
Change Password
</button>
)}
</div>
{showPasswordForm && (
<div className="space-y-4">
<div>
<label htmlFor="currentPassword" className="block text-sm font-bold text-slate-700 dark:text-neutral-300 mb-2">
Current Password
</label>
<input
id="currentPassword"
type="password"
value={currentPassword}
onChange={(e) => 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"
/>
</div>
<div>
<label htmlFor="newPassword" className="block text-sm font-bold text-slate-700 dark:text-neutral-300 mb-2">
New Password
</label>
<input
id="newPassword"
type="password"
value={newPassword}
onChange={(e) => 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)"
/>
</div>
<div>
<label htmlFor="confirmPassword" className="block text-sm font-bold text-slate-700 dark:text-neutral-300 mb-2">
Confirm New Password
</label>
<input
id="confirmPassword"
type="password"
value={confirmPassword}
onChange={(e) => 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"
/>
</div>
<div className="flex gap-3 pt-2">
<button
onClick={handleChangePassword}
disabled={loading || !currentPassword || !newPassword || !confirmPassword}
className="flex-1 px-6 py-3 bg-rose-600 dark:bg-rose-500 text-white font-bold rounded-xl border-2 border-black dark:border-neutral-700 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] hover:shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:hover:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)] hover:-translate-y-0.5 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:translate-y-0 disabled:hover:shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:disabled:hover:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)]"
>
{loading ? 'Changing...' : 'Change Password'}
</button>
<button
onClick={() => {
setShowPasswordForm(false);
setCurrentPassword('');
setNewPassword('');
setConfirmPassword('');
setError('');
}}
disabled={loading}
className="px-6 py-3 bg-white dark:bg-neutral-800 text-slate-700 dark:text-neutral-300 font-bold rounded-xl border-2 border-black dark:border-neutral-700 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] hover:shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:hover:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)] hover:-translate-y-0.5 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
<X size={18} />
Cancel
</button>
</div>
</div>
)}
</div>
</div>
</Layout>
);
};