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:
parent
84c31f38a0
commit
b2f5d9968d
@ -9,15 +9,18 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { listUsers, createUser, updateUser, deleteUser } from '@/lib/api';
|
import { listUsers, createUser, updateUser, deleteUser } from '@/lib/api';
|
||||||
|
import { useAuth } from '@/lib/context/auth-context';
|
||||||
|
|
||||||
export default function UsersManagementPage() {
|
export default function UsersManagementPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const { user: currentUser } = useAuth();
|
||||||
const [showInviteModal, setShowInviteModal] = useState(false);
|
const [showInviteModal, setShowInviteModal] = useState(false);
|
||||||
const [inviteForm, setInviteForm] = useState({
|
const [inviteForm, setInviteForm] = useState({
|
||||||
email: '',
|
email: '',
|
||||||
firstName: '',
|
firstName: '',
|
||||||
lastName: '',
|
lastName: '',
|
||||||
role: 'user' as 'admin' | 'manager' | 'user' | 'viewer',
|
role: 'USER' as 'ADMIN' | 'MANAGER' | 'USER' | 'VIEWER',
|
||||||
|
password: '',
|
||||||
phoneNumber: '',
|
phoneNumber: '',
|
||||||
});
|
});
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
@ -30,8 +33,14 @@ export default function UsersManagementPage() {
|
|||||||
|
|
||||||
const inviteMutation = useMutation({
|
const inviteMutation = useMutation({
|
||||||
mutationFn: (data: typeof inviteForm & { organizationId: string }) => {
|
mutationFn: (data: typeof inviteForm & { organizationId: string }) => {
|
||||||
// TODO: API should generate password or send invitation email
|
return createUser({
|
||||||
return createUser(data as any);
|
email: data.email,
|
||||||
|
password: data.password,
|
||||||
|
firstName: data.firstName,
|
||||||
|
lastName: data.lastName,
|
||||||
|
role: data.role,
|
||||||
|
organizationId: data.organizationId,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||||
@ -41,33 +50,45 @@ export default function UsersManagementPage() {
|
|||||||
email: '',
|
email: '',
|
||||||
firstName: '',
|
firstName: '',
|
||||||
lastName: '',
|
lastName: '',
|
||||||
role: 'user',
|
role: 'USER',
|
||||||
|
password: '',
|
||||||
phoneNumber: '',
|
phoneNumber: '',
|
||||||
});
|
});
|
||||||
setTimeout(() => setSuccess(''), 3000);
|
setTimeout(() => setSuccess(''), 3000);
|
||||||
},
|
},
|
||||||
onError: (err: any) => {
|
onError: (err: any) => {
|
||||||
setError(err.response?.data?.message || 'Failed to invite user');
|
setError(err.response?.data?.message || 'Failed to invite user');
|
||||||
|
setTimeout(() => setError(''), 5000);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const changeRoleMutation = useMutation({
|
const changeRoleMutation = useMutation({
|
||||||
mutationFn: ({ id, role }: { id: string; role: 'admin' | 'manager' | 'user' | 'viewer' }) => {
|
mutationFn: ({ id, role }: { id: string; role: 'ADMIN' | 'MANAGER' | 'USER' | 'VIEWER' }) => {
|
||||||
// TODO: Implement changeRole API endpoint
|
return updateUser(id, { role });
|
||||||
return updateUser(id, { role } as any);
|
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
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({
|
const toggleActiveMutation = useMutation({
|
||||||
mutationFn: ({ id, isActive }: { id: string; isActive: boolean }) => {
|
mutationFn: ({ id, isActive }: { id: string; isActive: boolean }) => {
|
||||||
// TODO: Implement activate/deactivate API endpoints
|
return updateUser(id, { isActive: !isActive });
|
||||||
return updateUser(id, { isActive: !isActive } as any);
|
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
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),
|
mutationFn: (id: string) => deleteUser(id),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
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) => {
|
const handleInvite = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError('');
|
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) => {
|
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) => {
|
const handleToggleActive = (userId: string, isActive: boolean) => {
|
||||||
@ -107,10 +144,10 @@ export default function UsersManagementPage() {
|
|||||||
|
|
||||||
const getRoleBadgeColor = (role: string) => {
|
const getRoleBadgeColor = (role: string) => {
|
||||||
const colors: Record<string, string> = {
|
const colors: Record<string, string> = {
|
||||||
admin: 'bg-red-100 text-red-800',
|
ADMIN: 'bg-red-100 text-red-800',
|
||||||
manager: 'bg-blue-100 text-blue-800',
|
MANAGER: 'bg-blue-100 text-blue-800',
|
||||||
user: 'bg-green-100 text-green-800',
|
USER: 'bg-green-100 text-green-800',
|
||||||
viewer: 'bg-gray-100 text-gray-800',
|
VIEWER: 'bg-gray-100 text-gray-800',
|
||||||
};
|
};
|
||||||
return colors[role] || '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(
|
className={`text-xs font-semibold rounded-full px-3 py-1 ${getRoleBadgeColor(
|
||||||
user.role
|
user.role
|
||||||
)}`}
|
)}`}
|
||||||
|
disabled={changeRoleMutation.isPending}
|
||||||
>
|
>
|
||||||
<option value="admin">Admin</option>
|
<option value="ADMIN">Admin</option>
|
||||||
<option value="manager">Manager</option>
|
<option value="MANAGER">Manager</option>
|
||||||
<option value="user">User</option>
|
<option value="USER">User</option>
|
||||||
<option value="viewer">Viewer</option>
|
<option value="VIEWER">Viewer</option>
|
||||||
</select>
|
</select>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleToggleActive(user.id, user.isActive)}
|
onClick={() => handleToggleActive(user.id, user.isActive)}
|
||||||
|
disabled={toggleActiveMutation.isPending}
|
||||||
className={`px-3 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${
|
className={`px-3 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${
|
||||||
user.isActive
|
user.isActive
|
||||||
? 'bg-green-100 text-green-800 hover:bg-green-200'
|
? 'bg-green-100 text-green-800 hover:bg-green-200'
|
||||||
: 'bg-red-100 text-red-800 hover:bg-red-200'
|
: 'bg-red-100 text-red-800 hover:bg-red-200'
|
||||||
}`}
|
} disabled:opacity-50 disabled:cursor-not-allowed`}
|
||||||
>
|
>
|
||||||
{user.isActive ? 'Active' : 'Inactive'}
|
{user.isActive ? 'Active' : 'Inactive'}
|
||||||
</button>
|
</button>
|
||||||
@ -230,7 +269,8 @@ export default function UsersManagementPage() {
|
|||||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDelete(user.id)}
|
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
|
Delete
|
||||||
</button>
|
</button>
|
||||||
@ -335,6 +375,22 @@ export default function UsersManagementPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700">Phone Number</label>
|
<label className="block text-sm font-medium text-gray-700">Phone Number</label>
|
||||||
<input
|
<input
|
||||||
@ -352,14 +408,11 @@ export default function UsersManagementPage() {
|
|||||||
onChange={e => setInviteForm({ ...inviteForm, role: e.target.value as any })}
|
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"
|
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="USER">User</option>
|
||||||
<option value="manager">Manager</option>
|
<option value="MANAGER">Manager</option>
|
||||||
<option value="admin">Admin</option>
|
<option value="ADMIN">Admin</option>
|
||||||
<option value="viewer">Viewer</option>
|
<option value="VIEWER">Viewer</option>
|
||||||
</select>
|
</select>
|
||||||
<p className="mt-1 text-xs text-gray-500">
|
|
||||||
A temporary password will be sent to the user's email
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-5 sm:mt-6 sm:grid sm:grid-cols-2 sm:gap-3 sm:grid-flow-row-dense">
|
<div className="mt-5 sm:mt-6 sm:grid sm:grid-cols-2 sm:gap-3 sm:grid-flow-row-dense">
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user