fix: repair user management CRUD operations (create, update, delete)

Problems Fixed:

1. **User Creation (Invite)**
   -  Missing password field (required by API)
   -  Hardcoded organizationId 'default-org-id'
   -  Wrong role format (lowercase instead of ADMIN/USER/MANAGER)
   -  Now uses currentUser.organizationId from auth context
   -  Added password field with validation (min 8 chars)
   -  Fixed role enum to match backend (ADMIN, USER, MANAGER, VIEWER)

2. **Role Change (PATCH)**
   -  Used 'as any' masking type errors
   -  Lowercase role values
   -  Proper typing with uppercase roles
   -  Added success/error feedback
   -  Disabled state during mutation

3. **Toggle Active (PATCH)**
   -  Was working but added better feedback
   -  Added disabled state during mutation

4. **Delete User (DELETE)**
   -  Was working but added better feedback
   -  Added disabled state during mutation

5. **UI Improvements**
   - Added success messages with auto-dismiss (3s)
   - Added error messages with auto-dismiss (5s)
   - Added loading states on all action buttons
   - Fixed role badge colors to use uppercase keys
   - Better form validation before API call

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
David 2025-11-20 23:35:10 +01:00
parent 84c31f38a0
commit b2f5d9968d

View File

@ -9,15 +9,18 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { listUsers, createUser, updateUser, deleteUser } from '@/lib/api';
import { useAuth } from '@/lib/context/auth-context';
export default function UsersManagementPage() {
const queryClient = useQueryClient();
const { user: currentUser } = useAuth();
const [showInviteModal, setShowInviteModal] = useState(false);
const [inviteForm, setInviteForm] = useState({
email: '',
firstName: '',
lastName: '',
role: 'user' as 'admin' | 'manager' | 'user' | 'viewer',
role: 'USER' as 'ADMIN' | 'MANAGER' | 'USER' | 'VIEWER',
password: '',
phoneNumber: '',
});
const [error, setError] = useState('');
@ -30,8 +33,14 @@ export default function UsersManagementPage() {
const inviteMutation = useMutation({
mutationFn: (data: typeof inviteForm & { organizationId: string }) => {
// TODO: API should generate password or send invitation email
return createUser(data as any);
return createUser({
email: data.email,
password: data.password,
firstName: data.firstName,
lastName: data.lastName,
role: data.role,
organizationId: data.organizationId,
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
@ -41,33 +50,45 @@ export default function UsersManagementPage() {
email: '',
firstName: '',
lastName: '',
role: 'user',
role: 'USER',
password: '',
phoneNumber: '',
});
setTimeout(() => setSuccess(''), 3000);
},
onError: (err: any) => {
setError(err.response?.data?.message || 'Failed to invite user');
setTimeout(() => setError(''), 5000);
},
});
const changeRoleMutation = useMutation({
mutationFn: ({ id, role }: { id: string; role: 'admin' | 'manager' | 'user' | 'viewer' }) => {
// TODO: Implement changeRole API endpoint
return updateUser(id, { role } as any);
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 }) => {
// TODO: Implement activate/deactivate API endpoints
return updateUser(id, { isActive: !isActive } as any);
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);
},
});
@ -75,18 +96,34 @@ export default function UsersManagementPage() {
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);
},
});
const handleInvite = (e: React.FormEvent) => {
e.preventDefault();
setError('');
// TODO: Get actual organizationId from auth context
inviteMutation.mutate({ ...inviteForm, organizationId: 'default-org-id' });
if (!currentUser?.organizationId) {
setError('Organization ID not found. Please log in again.');
return;
}
if (!inviteForm.password || inviteForm.password.length < 8) {
setError('Password must be at least 8 characters long');
return;
}
inviteMutation.mutate({ ...inviteForm, organizationId: currentUser.organizationId });
};
const handleRoleChange = (userId: string, newRole: string) => {
changeRoleMutation.mutate({ id: userId, role: newRole as any });
changeRoleMutation.mutate({ id: userId, role: newRole as 'ADMIN' | 'MANAGER' | 'USER' | 'VIEWER' });
};
const handleToggleActive = (userId: string, isActive: boolean) => {
@ -107,10 +144,10 @@ export default function UsersManagementPage() {
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',
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';
};
@ -205,21 +242,23 @@ export default function UsersManagementPage() {
className={`text-xs font-semibold rounded-full px-3 py-1 ${getRoleBadgeColor(
user.role
)}`}
disabled={changeRoleMutation.isPending}
>
<option value="admin">Admin</option>
<option value="manager">Manager</option>
<option value="user">User</option>
<option value="viewer">Viewer</option>
<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>
@ -230,7 +269,8 @@ export default function UsersManagementPage() {
<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"
disabled={deleteMutation.isPending}
className="text-red-600 hover:text-red-900 ml-4 disabled:opacity-50 disabled:cursor-not-allowed"
>
Delete
</button>
@ -335,6 +375,22 @@ export default function UsersManagementPage() {
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Password *</label>
<input
type="password"
required
minLength={8}
value={inviteForm.password}
onChange={e => setInviteForm({ ...inviteForm, password: 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"
placeholder="Minimum 8 characters"
/>
<p className="mt-1 text-xs text-gray-500">
Provide a temporary password (minimum 8 characters). User can change it after first login.
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">Phone Number</label>
<input
@ -352,14 +408,11 @@ export default function UsersManagementPage() {
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>
<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">