xpeditis2.0/apps/frontend/app/dashboard/settings/users/page.tsx
David-Henri ARNAUD b31d325646 feature phase 2
2025-10-10 15:07:05 +02:00

403 lines
17 KiB
TypeScript
Raw Permalink 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 } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { usersApi } from '@/lib/api';
export default function UsersManagementPage() {
const queryClient = useQueryClient();
const [showInviteModal, setShowInviteModal] = useState(false);
const [inviteForm, setInviteForm] = useState({
email: '',
firstName: '',
lastName: '',
role: 'user' as 'admin' | 'manager' | 'user' | 'viewer',
phoneNumber: '',
});
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const { data: users, isLoading } = useQuery({
queryKey: ['users'],
queryFn: () => usersApi.list(),
});
const inviteMutation = useMutation({
mutationFn: (data: typeof inviteForm & { organizationId: string }) =>
usersApi.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
setSuccess('User invited successfully');
setShowInviteModal(false);
setInviteForm({
email: '',
firstName: '',
lastName: '',
role: 'user',
phoneNumber: '',
});
setTimeout(() => setSuccess(''), 3000);
},
onError: (err: any) => {
setError(err.response?.data?.message || 'Failed to invite user');
},
});
const changeRoleMutation = useMutation({
mutationFn: ({ id, role }: { id: string; role: 'admin' | 'manager' | 'user' | 'viewer' }) =>
usersApi.changeRole(id, role),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
},
});
const toggleActiveMutation = useMutation({
mutationFn: ({ id, isActive }: { id: string; isActive: boolean }) =>
isActive ? usersApi.deactivate(id) : usersApi.activate(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) => usersApi.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
},
});
const handleInvite = (e: React.FormEvent) => {
e.preventDefault();
setError('');
// TODO: Get actual organizationId from auth context
inviteMutation.mutate({ ...inviteForm, organizationId: 'default-org-id' });
};
const handleRoleChange = (userId: string, newRole: string) => {
changeRoleMutation.mutate({ id: userId, role: newRole as any });
};
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.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.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.phoneNumber || 'No phone'}
</div>
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">{user.email}</div>
{user.isEmailVerified ? (
<span className="text-xs text-green-600"> Verified</span>
) : (
<span className="text-xs text-yellow-600"> Not verified</span>
)}
</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)}`}
>
<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)}
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'
}`}
>
{user.isActive ? 'Active' : 'Inactive'}
</button>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{user.lastLoginAt
? new Date(user.lastLoginAt).toLocaleDateString()
: 'Never'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button
onClick={() => handleDelete(user.id)}
className="text-red-600 hover:text-red-900 ml-4"
>
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">
Phone Number
</label>
<input
type="tel"
value={inviteForm.phoneNumber}
onChange={(e) =>
setInviteForm({ ...inviteForm, phoneNumber: 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>
<option value="admin">Admin</option>
<option value="viewer">Viewer</option>
</select>
<p className="mt-1 text-xs text-gray-500">
A temporary password will be sent to the user's email
</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>
);
}