xpeditis2.0/apps/frontend/app/dashboard/settings/users/page.tsx
2025-12-18 16:56:35 +01:00

510 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* User Management Page
*
* Manage organization users, roles, and invitations
*/
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { listUsers, updateUser, deleteUser } from '@/lib/api';
import { createInvitation } from '@/lib/api/invitations';
import { useAuth } from '@/lib/context/auth-context';
export default function UsersManagementPage() {
const router = useRouter();
const queryClient = useQueryClient();
const { user: currentUser } = useAuth();
const [showInviteModal, setShowInviteModal] = useState(false);
const [openMenuId, setOpenMenuId] = useState<string | null>(null);
const [menuPosition, setMenuPosition] = useState<{ top: number; left: number } | null>(null);
const [inviteForm, setInviteForm] = useState({
email: '',
firstName: '',
lastName: '',
role: 'USER' as 'MANAGER' | 'USER' | 'VIEWER',
});
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const { data: users, isLoading } = useQuery({
queryKey: ['users'],
queryFn: () => listUsers(),
});
const inviteMutation = useMutation({
mutationFn: (data: typeof inviteForm) => {
return createInvitation({
email: data.email,
firstName: data.firstName,
lastName: data.lastName,
role: data.role,
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
setSuccess('Invitation sent successfully! The user will receive an email with a registration link.');
setShowInviteModal(false);
setInviteForm({
email: '',
firstName: '',
lastName: '',
role: 'USER',
});
setTimeout(() => setSuccess(''), 5000);
},
onError: (err: any) => {
setError(err.response?.data?.message || 'Failed to send invitation');
setTimeout(() => setError(''), 5000);
},
});
const changeRoleMutation = useMutation({
mutationFn: ({ id, role }: { id: string; role: 'ADMIN' | 'MANAGER' | 'USER' | 'VIEWER' }) => {
return updateUser(id, { role });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
setSuccess('Role updated successfully');
setTimeout(() => setSuccess(''), 3000);
},
onError: (err: any) => {
setError(err.response?.data?.message || 'Failed to update role');
setTimeout(() => setError(''), 5000);
},
});
const toggleActiveMutation = useMutation({
mutationFn: ({ id, isActive }: { id: string; isActive: boolean }) => {
return updateUser(id, { isActive: !isActive });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
setSuccess('User status updated successfully');
setTimeout(() => setSuccess(''), 3000);
},
onError: (err: any) => {
setError(err.response?.data?.message || 'Failed to update user status');
setTimeout(() => setError(''), 5000);
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteUser(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
setSuccess('User deleted successfully');
setTimeout(() => setSuccess(''), 3000);
},
onError: (err: any) => {
setError(err.response?.data?.message || 'Failed to delete user');
setTimeout(() => setError(''), 5000);
},
});
// Restrict access to ADMIN and MANAGER only
useEffect(() => {
if (currentUser && currentUser.role !== 'ADMIN' && currentUser.role !== 'MANAGER') {
router.push('/dashboard');
}
}, [currentUser, router]);
// Don't render until we've checked permissions
if (!currentUser || (currentUser.role !== 'ADMIN' && currentUser.role !== 'MANAGER')) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
);
}
const handleInvite = (e: React.FormEvent) => {
e.preventDefault();
setError('');
inviteMutation.mutate(inviteForm);
};
const handleRoleChange = (userId: string, newRole: string) => {
changeRoleMutation.mutate({ id: userId, role: newRole as 'ADMIN' | 'MANAGER' | 'USER' | 'VIEWER' });
};
const handleToggleActive = (userId: string, isActive: boolean) => {
if (
window.confirm(`Are you sure you want to ${isActive ? 'deactivate' : 'activate'} this user?`)
) {
toggleActiveMutation.mutate({ id: userId, isActive });
}
};
const handleDelete = (userId: string) => {
if (
window.confirm('Are you sure you want to delete this user? This action cannot be undone.')
) {
deleteMutation.mutate(userId);
}
};
const getRoleBadgeColor = (role: string) => {
const colors: Record<string, string> = {
ADMIN: 'bg-red-100 text-red-800',
MANAGER: 'bg-blue-100 text-blue-800',
USER: 'bg-green-100 text-green-800',
VIEWER: 'bg-gray-100 text-gray-800',
};
return colors[role] || 'bg-gray-100 text-gray-800';
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">User Management</h1>
<p className="text-sm text-gray-500 mt-1">Manage team members and their permissions</p>
</div>
<button
onClick={() => setShowInviteModal(true)}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
>
<span className="mr-2"></span>
Invite User
</button>
</div>
{success && (
<div className="rounded-md bg-green-50 p-4">
<div className="text-sm text-green-800">{success}</div>
</div>
)}
{error && (
<div className="rounded-md bg-red-50 p-4">
<div className="text-sm text-red-800">{error}</div>
</div>
)}
{/* Users Table */}
<div className="bg-white rounded-lg shadow">
{isLoading ? (
<div className="px-6 py-12 text-center text-gray-500">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
Loading users...
</div>
) : users?.users && users.users.length > 0 ? (
<div className="overflow-x-auto overflow-y-visible">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
User
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Email
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Role
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Last Login
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{users.users.map(user => (
<tr key={user.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<div className="flex-shrink-0 h-10 w-10 bg-blue-600 rounded-full flex items-center justify-center text-white font-semibold">
{user.firstName[0]}
{user.lastName[0]}
</div>
<div className="ml-4">
<div className="text-sm font-medium text-gray-900">
{user.firstName} {user.lastName}
</div>
<div className="text-sm text-gray-500">
{user.email}
</div>
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">{user.email}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<select
value={user.role}
onChange={e => handleRoleChange(user.id, e.target.value)}
className={`text-xs font-semibold rounded-full px-3 py-1 ${getRoleBadgeColor(
user.role
)}`}
disabled={
changeRoleMutation.isPending ||
(user.role === 'ADMIN' && currentUser?.role !== 'ADMIN') ||
user.id === currentUser?.id
}
title={user.id === currentUser?.id ? 'You cannot change your own role' : ''}
>
{currentUser?.role === 'ADMIN' && <option value="ADMIN">Admin</option>}
<option value="MANAGER">Manager</option>
<option value="USER">User</option>
<option value="VIEWER">Viewer</option>
</select>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={`px-3 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${
user.isActive
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}
>
{user.isActive ? 'Active' : 'Inactive'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{new Date(user.createdAt).toLocaleDateString()}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button
onClick={(e) => {
if (openMenuId === user.id) {
setOpenMenuId(null);
setMenuPosition(null);
} else {
const rect = e.currentTarget.getBoundingClientRect();
setMenuPosition({
top: rect.bottom + 5,
left: rect.left - 180
});
setOpenMenuId(user.id);
}
}}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
>
<svg className="w-5 h-5 text-gray-600" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 6a2 2 0 110-4 2 2 0 010 4zM10 12a2 2 0 110-4 2 2 0 010 4zM10 18a2 2 0 110-4 2 2 0 010 4z" />
</svg>
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="px-6 py-12 text-center">
<svg
className="mx-auto h-12 w-12 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
<h3 className="mt-2 text-sm font-medium text-gray-900">No users</h3>
<p className="mt-1 text-sm text-gray-500">Get started by inviting a team member</p>
<div className="mt-6">
<button
onClick={() => setShowInviteModal(true)}
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
>
<span className="mr-2"></span>
Invite User
</button>
</div>
</div>
)}
</div>
{/* Actions Menu Modal */}
{openMenuId && menuPosition && (
<>
<div
className="fixed inset-0 z-[998]"
onClick={() => {
setOpenMenuId(null);
setMenuPosition(null);
}}
/>
<div
className="fixed w-56 bg-white border-2 border-gray-300 rounded-lg shadow-2xl z-[999]"
style={{
top: `${menuPosition.top}px`,
left: `${menuPosition.left}px`,
}}
>
<div className="py-2">
<button
onClick={() => {
const user = users?.users.find(u => u.id === openMenuId);
if (user) {
handleToggleActive(user.id, user.isActive);
}
setOpenMenuId(null);
setMenuPosition(null);
}}
disabled={toggleActiveMutation.isPending}
className="w-full px-4 py-3 text-left hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-3 border-b border-gray-200"
>
{users?.users.find(u => u.id === openMenuId)?.isActive ? (
<>
<svg className="w-5 h-5 text-orange-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
</svg>
<span className="text-sm font-medium text-gray-700">Désactiver</span>
</>
) : (
<>
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span className="text-sm font-medium text-gray-700">Activer</span>
</>
)}
</button>
<button
onClick={() => {
if (openMenuId) {
handleDelete(openMenuId);
}
setOpenMenuId(null);
setMenuPosition(null);
}}
disabled={deleteMutation.isPending}
className="w-full px-4 py-3 text-left hover:bg-red-50 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-3"
>
<svg className="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
<span className="text-sm font-medium text-red-600">Supprimer</span>
</button>
</div>
</div>
</>
)}
{/* Invite Modal */}
{showInviteModal && (
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
<div
className="fixed inset-0 transition-opacity bg-gray-500 bg-opacity-75"
onClick={() => setShowInviteModal(false)}
/>
<div className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
<div>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-medium text-gray-900">Invite User</h3>
<button
onClick={() => setShowInviteModal(false)}
className="text-gray-400 hover:text-gray-500"
>
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<form onSubmit={handleInvite} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700">
First Name *
</label>
<input
type="text"
required
value={inviteForm.firstName}
onChange={e => setInviteForm({ ...inviteForm, firstName: e.target.value })}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm px-3 py-2 border"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Last Name *</label>
<input
type="text"
required
value={inviteForm.lastName}
onChange={e => setInviteForm({ ...inviteForm, lastName: e.target.value })}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm px-3 py-2 border"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Email *</label>
<input
type="email"
required
value={inviteForm.email}
onChange={e => setInviteForm({ ...inviteForm, email: e.target.value })}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm px-3 py-2 border"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Role *</label>
<select
value={inviteForm.role}
onChange={e => setInviteForm({ ...inviteForm, role: e.target.value as any })}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm px-3 py-2 border"
>
<option value="USER">User</option>
<option value="MANAGER">Manager</option>
{currentUser?.role === 'ADMIN' && <option value="ADMIN">Admin</option>}
<option value="VIEWER">Viewer</option>
</select>
{currentUser?.role !== 'ADMIN' && (
<p className="mt-1 text-xs text-gray-500">
Only platform administrators can assign the ADMIN role
</p>
)}
</div>
<div className="mt-5 sm:mt-6 sm:grid sm:grid-cols-2 sm:gap-3 sm:grid-flow-row-dense">
<button
type="submit"
disabled={inviteMutation.isPending}
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-blue-600 text-base font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:col-start-2 sm:text-sm disabled:bg-gray-400"
>
{inviteMutation.isPending ? 'Inviting...' : 'Send Invitation'}
</button>
<button
type="button"
onClick={() => setShowInviteModal(false)}
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:mt-0 sm:col-start-1 sm:text-sm"
>
Cancel
</button>
</div>
</form>
</div>
</div>
</div>
</div>
)}
</div>
);
}