Introduce comprehensive user management functionality for admin

- Added React Query hooks for user-related actions: `useCreateUser`, `useUpdateUser`, `useDeleteUser`, `useActivateUser`, `useDeactivateUser`, and `useBulkUserAction`.
- Implemented primary user management components: `UserFormDialog`, `UserManagementContent`, `UserListTable`, `BulkActionToolbar`, and `UserActionMenu`.
- Replaced placeholder content on the Users page with full user management capabilities.
- Included role-based validation, search, pagination, filtering, and bulk operations.
- Enhanced form validation with `zod` schema for robust user input handling.
- Added feedback mechanisms (toasts and alert dialogs) for user actions.
- Improved UI accessibility and usability across the admin user management feature.
This commit is contained in:
Felipe Cardoso
2025-11-06 12:08:10 +01:00
parent c10c1d1c39
commit 91bc4f190d
11 changed files with 1794 additions and 39 deletions

View File

@@ -0,0 +1,372 @@
/**
* UserFormDialog Component
* Dialog for creating and editing users with form validation
*/
'use client';
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { Alert } from '@/components/ui/alert';
import { toast } from 'sonner';
import {
useCreateUser,
useUpdateUser,
} from '@/lib/api/hooks/useAdmin';
// ============================================================================
// Validation Schema
// ============================================================================
const userFormSchema = z.object({
email: z
.string()
.min(1, 'Email is required')
.email('Please enter a valid email address'),
first_name: z
.string()
.min(1, 'First name is required')
.min(2, 'First name must be at least 2 characters')
.max(50, 'First name must not exceed 50 characters'),
last_name: z
.string()
.max(50, 'Last name must not exceed 50 characters')
.optional()
.or(z.literal('')),
password: z.string(),
is_active: z.boolean(),
is_superuser: z.boolean(),
});
type UserFormData = z.infer<typeof userFormSchema>;
// ============================================================================
// Component
// ============================================================================
interface User {
id: string;
email: string;
first_name: string;
last_name: string | null;
is_active: boolean;
is_superuser: boolean;
}
interface UserFormDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
user?: User | null;
mode: 'create' | 'edit';
}
export function UserFormDialog({
open,
onOpenChange,
user,
mode,
}: UserFormDialogProps) {
const isEdit = mode === 'edit' && user;
const createUser = useCreateUser();
const updateUser = useUpdateUser();
const form = useForm<UserFormData>({
resolver: zodResolver(userFormSchema),
defaultValues: {
email: '',
first_name: '',
last_name: '',
password: '',
is_active: true,
is_superuser: false,
},
});
// Reset form when dialog opens/closes or user changes
useEffect(() => {
if (open && isEdit) {
form.reset({
email: user.email,
first_name: user.first_name,
last_name: user.last_name || '',
password: '',
is_active: user.is_active,
is_superuser: user.is_superuser,
});
} else if (open && !isEdit) {
form.reset({
email: '',
first_name: '',
last_name: '',
password: '',
is_active: true,
is_superuser: false,
});
}
}, [open, isEdit, user, form]);
const onSubmit = async (data: UserFormData) => {
try {
// Validate password for create mode
if (!isEdit) {
if (!data.password || data.password.length === 0) {
form.setError('password', { message: 'Password is required' });
return;
}
if (data.password.length < 8) {
form.setError('password', { message: 'Password must be at least 8 characters' });
return;
}
if (!/[0-9]/.test(data.password)) {
form.setError('password', { message: 'Password must contain at least one number' });
return;
}
if (!/[A-Z]/.test(data.password)) {
form.setError('password', { message: 'Password must contain at least one uppercase letter' });
return;
}
}
if (isEdit) {
// Validate password if provided in edit mode
if (data.password && data.password.length > 0) {
if (data.password.length < 8) {
form.setError('password', { message: 'Password must be at least 8 characters' });
return;
}
if (!/[0-9]/.test(data.password)) {
form.setError('password', { message: 'Password must contain at least one number' });
return;
}
if (!/[A-Z]/.test(data.password)) {
form.setError('password', { message: 'Password must contain at least one uppercase letter' });
return;
}
}
// Prepare update data (exclude password if empty)
const updateData: Record<string, unknown> = {
email: data.email,
first_name: data.first_name,
last_name: data.last_name || null,
is_active: data.is_active,
is_superuser: data.is_superuser,
};
// Only include password if provided
if (data.password && data.password.length > 0) {
updateData.password = data.password;
}
await updateUser.mutateAsync({
userId: user.id,
userData: updateData as any,
});
toast.success(`User ${data.first_name} ${data.last_name || ''} updated successfully`);
onOpenChange(false);
form.reset();
} else {
// Create new user
await createUser.mutateAsync({
email: data.email,
first_name: data.first_name,
last_name: data.last_name || undefined,
password: data.password,
is_active: data.is_active,
is_superuser: data.is_superuser,
} as any);
toast.success(`User ${data.first_name} ${data.last_name || ''} created successfully`);
onOpenChange(false);
form.reset();
}
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Operation failed');
}
};
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
watch,
setValue,
} = form;
const isActive = watch('is_active');
const isSuperuser = watch('is_superuser');
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>{isEdit ? 'Edit User' : 'Create New User'}</DialogTitle>
<DialogDescription>
{isEdit
? 'Update user information and permissions'
: 'Add a new user to the system with specified permissions'}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
{/* Email */}
<div className="space-y-2">
<Label htmlFor="email">Email *</Label>
<Input
id="email"
type="email"
{...register('email')}
disabled={isSubmitting}
aria-invalid={errors.email ? 'true' : 'false'}
className={errors.email ? 'border-destructive' : ''}
/>
{errors.email && (
<p id="email-error" className="text-sm text-destructive">
{errors.email.message}
</p>
)}
</div>
{/* First Name */}
<div className="space-y-2">
<Label htmlFor="first_name">First Name *</Label>
<Input
id="first_name"
{...register('first_name')}
disabled={isSubmitting}
aria-invalid={errors.first_name ? 'true' : 'false'}
className={errors.first_name ? 'border-destructive' : ''}
/>
{errors.first_name && (
<p id="first-name-error" className="text-sm text-destructive">
{errors.first_name.message}
</p>
)}
</div>
{/* Last Name */}
<div className="space-y-2">
<Label htmlFor="last_name">Last Name</Label>
<Input
id="last_name"
{...register('last_name')}
disabled={isSubmitting}
aria-invalid={errors.last_name ? 'true' : 'false'}
className={errors.last_name ? 'border-destructive' : ''}
/>
{errors.last_name && (
<p id="last-name-error" className="text-sm text-destructive">
{errors.last_name.message}
</p>
)}
</div>
{/* Password */}
<div className="space-y-2">
<Label htmlFor="password">
Password {!isEdit && '*'} {isEdit && '(leave blank to keep current)'}
</Label>
<Input
id="password"
type="password"
{...register('password')}
disabled={isSubmitting}
aria-invalid={errors.password ? 'true' : 'false'}
className={errors.password ? 'border-destructive' : ''}
placeholder={isEdit ? 'Leave blank to keep current password' : ''}
/>
{errors.password && (
<p id="password-error" className="text-sm text-destructive">
{errors.password.message}
</p>
)}
{!isEdit && (
<p className="text-xs text-muted-foreground">
Must be at least 8 characters with 1 number and 1 uppercase letter
</p>
)}
</div>
{/* Checkboxes */}
<div className="space-y-3">
<div className="flex items-center space-x-2">
<Checkbox
id="is_active"
checked={isActive}
onCheckedChange={(checked) => setValue('is_active', checked as boolean)}
disabled={isSubmitting}
/>
<Label
htmlFor="is_active"
className="text-sm font-normal cursor-pointer"
>
Active (user can log in)
</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="is_superuser"
checked={isSuperuser}
onCheckedChange={(checked) => setValue('is_superuser', checked as boolean)}
disabled={isSubmitting}
/>
<Label
htmlFor="is_superuser"
className="text-sm font-normal cursor-pointer"
>
Superuser (admin privileges)
</Label>
</div>
</div>
{/* Server Error Display */}
{(createUser.isError || updateUser.isError) && (
<Alert variant="destructive">
{createUser.isError && createUser.error instanceof Error
? createUser.error.message
: updateUser.error instanceof Error
? updateUser.error.message
: 'An error occurred'}
</Alert>
)}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting
? isEdit
? 'Updating...'
: 'Creating...'
: isEdit
? 'Update User'
: 'Create User'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}