forked from cardosofelipe/fast-next-template
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:
372
frontend/src/components/admin/users/UserFormDialog.tsx
Normal file
372
frontend/src/components/admin/users/UserFormDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user