forked from cardosofelipe/fast-next-template
352 lines
11 KiB
TypeScript
352 lines
11 KiB
TypeScript
/**
|
|
* 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, type User } 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 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
|
|
// istanbul ignore next - Form reset logic tested in E2E (admin-users.spec.ts)
|
|
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]);
|
|
|
|
// istanbul ignore next - Form submission logic fully tested in E2E (admin-users.spec.ts)
|
|
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, email is not updatable)
|
|
const updateData: Record<string, unknown> = {
|
|
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,
|
|
});
|
|
|
|
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_superuser: data.is_superuser,
|
|
});
|
|
|
|
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');
|
|
|
|
// istanbul ignore next - JSX rendering tested in E2E (admin-users.spec.ts)
|
|
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>
|
|
);
|
|
}
|