Files
syndarix/frontend/src/components/admin/users/UserFormDialog.tsx
2025-11-26 09:47:10 +01:00

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>
);
}