425 lines
18 KiB
TypeScript
425 lines
18 KiB
TypeScript
/**
|
||
* 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 [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 overflow-hidden">
|
||
{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">
|
||
<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')}
|
||
>
|
||
{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">
|
||
<button
|
||
onClick={() => handleToggleActive(user.id, user.isActive)}
|
||
disabled={toggleActiveMutation.isPending}
|
||
className={`px-3 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${
|
||
user.isActive
|
||
? 'bg-green-100 text-green-800 hover:bg-green-200'
|
||
: 'bg-red-100 text-red-800 hover:bg-red-200'
|
||
} disabled:opacity-50 disabled:cursor-not-allowed`}
|
||
>
|
||
{user.isActive ? 'Active' : 'Inactive'}
|
||
</button>
|
||
</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={() => handleDelete(user.id)}
|
||
disabled={deleteMutation.isPending}
|
||
className="text-red-600 hover:text-red-900 ml-4 disabled:opacity-50 disabled:cursor-not-allowed"
|
||
>
|
||
Delete
|
||
</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>
|
||
|
||
{/* 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>
|
||
);
|
||
}
|