feat(users): implement server actions and audit logging
This commit is contained in:
477
src/features/users/components/CreateUserDialog.tsx
Normal file
477
src/features/users/components/CreateUserDialog.tsx
Normal file
@@ -0,0 +1,477 @@
|
||||
// CreateUserDialog - Multi-step form for creating new users
|
||||
import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import {
|
||||
User,
|
||||
Mail,
|
||||
Phone,
|
||||
KeyRound,
|
||||
Globe,
|
||||
Tag,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import { createUserSchema, type CreateUserInput } from '@/lib/validations/user';
|
||||
import { mockTags } from '../data/mockUserCrmData';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface CreateUserDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onUserCreated?: () => void;
|
||||
}
|
||||
|
||||
const steps = [
|
||||
{ id: 1, title: 'Basic Info', icon: User },
|
||||
{ id: 2, title: 'Security', icon: KeyRound },
|
||||
{ id: 3, title: 'Preferences', icon: Globe },
|
||||
{ id: 4, title: 'Tags', icon: Tag },
|
||||
];
|
||||
|
||||
const countryCodes = [
|
||||
{ code: '+91', country: 'India' },
|
||||
{ code: '+1', country: 'USA' },
|
||||
{ code: '+44', country: 'UK' },
|
||||
{ code: '+971', country: 'UAE' },
|
||||
{ code: '+65', country: 'Singapore' },
|
||||
];
|
||||
|
||||
const languages = [
|
||||
{ code: 'en', name: 'English' },
|
||||
{ code: 'hi', name: 'Hindi' },
|
||||
{ code: 'ta', name: 'Tamil' },
|
||||
{ code: 'te', name: 'Telugu' },
|
||||
{ code: 'mr', name: 'Marathi' },
|
||||
];
|
||||
|
||||
const timezones = [
|
||||
{ code: 'Asia/Kolkata', name: 'India (IST)' },
|
||||
{ code: 'America/New_York', name: 'Eastern Time (ET)' },
|
||||
{ code: 'Europe/London', name: 'London (GMT)' },
|
||||
{ code: 'Asia/Dubai', name: 'Dubai (GST)' },
|
||||
{ code: 'Asia/Singapore', name: 'Singapore (SGT)' },
|
||||
];
|
||||
|
||||
const currencies = [
|
||||
{ code: 'INR', symbol: '₹', name: 'Indian Rupee' },
|
||||
{ code: 'USD', symbol: '$', name: 'US Dollar' },
|
||||
{ code: 'GBP', symbol: '£', name: 'British Pound' },
|
||||
{ code: 'AED', symbol: 'د.إ', name: 'UAE Dirham' },
|
||||
{ code: 'SGD', symbol: 'S$', name: 'Singapore Dollar' },
|
||||
];
|
||||
|
||||
export function CreateUserDialog({ open, onOpenChange, onUserCreated }: CreateUserDialogProps) {
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||
|
||||
const form = useForm<CreateUserInput>({
|
||||
resolver: zodResolver(createUserSchema),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
countryCode: '+91',
|
||||
password: '',
|
||||
role: 'User',
|
||||
language: 'en',
|
||||
timezone: 'Asia/Kolkata',
|
||||
currency: 'INR',
|
||||
tagIds: [],
|
||||
},
|
||||
});
|
||||
|
||||
const handleNext = async () => {
|
||||
let isValid = true;
|
||||
|
||||
if (currentStep === 1) {
|
||||
isValid = await form.trigger(['name', 'email', 'phone', 'countryCode']);
|
||||
} else if (currentStep === 2) {
|
||||
isValid = await form.trigger(['password', 'role']);
|
||||
} else if (currentStep === 3) {
|
||||
isValid = await form.trigger(['language', 'timezone', 'currency']);
|
||||
}
|
||||
|
||||
if (isValid && currentStep < 4) {
|
||||
setCurrentStep(currentStep + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
if (currentStep > 1) {
|
||||
setCurrentStep(currentStep - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTagToggle = (tagId: string) => {
|
||||
const updated = selectedTags.includes(tagId)
|
||||
? selectedTags.filter((t) => t !== tagId)
|
||||
: [...selectedTags, tagId];
|
||||
setSelectedTags(updated);
|
||||
form.setValue('tagIds', updated);
|
||||
};
|
||||
|
||||
const onSubmit = async (data: CreateUserInput) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
// Simulate API call
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
toast.success(`User "${data.name}" created successfully!`);
|
||||
onOpenChange(false);
|
||||
onUserCreated?.();
|
||||
form.reset();
|
||||
setCurrentStep(1);
|
||||
setSelectedTags([]);
|
||||
} catch (error) {
|
||||
toast.error('Failed to create user');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (!isSubmitting) {
|
||||
onOpenChange(false);
|
||||
form.reset();
|
||||
setCurrentStep(1);
|
||||
setSelectedTags([]);
|
||||
}
|
||||
};
|
||||
|
||||
const progress = (currentStep / 4) * 100;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<User className="h-5 w-5 text-primary" />
|
||||
Create New User
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Add a new user to the platform. Step {currentStep} of 4.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="space-y-2">
|
||||
<Progress value={progress} className="h-2" />
|
||||
<div className="flex justify-between">
|
||||
{steps.map((step) => (
|
||||
<div
|
||||
key={step.id}
|
||||
className={cn(
|
||||
'flex items-center gap-1 text-xs',
|
||||
currentStep >= step.id ? 'text-primary' : 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
<step.icon className="h-3 w-3" />
|
||||
<span className="hidden sm:inline">{step.title}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
{/* Step 1: Basic Info */}
|
||||
{currentStep === 1 && (
|
||||
<div className="space-y-4 animate-in fade-in-50 duration-300">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Full Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="John Doe" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email Address</FormLabel>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input placeholder="john@example.com" className="pl-9" {...field} />
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="countryCode"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Code</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{countryCodes.map((cc) => (
|
||||
<SelectItem key={cc.code} value={cc.code}>
|
||||
{cc.code} {cc.country}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="phone"
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-2">
|
||||
<FormLabel>Phone Number</FormLabel>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<Phone className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input placeholder="9876543210" className="pl-9" {...field} />
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: Security */}
|
||||
{currentStep === 2 && (
|
||||
<div className="space-y-4 animate-in fade-in-50 duration-300">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<KeyRound className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input type="password" placeholder="Leave blank for magic link" className="pl-9" {...field} />
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
If left blank, user will receive a magic link to set password.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="role"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Role</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="User">User</SelectItem>
|
||||
<SelectItem value="Partner">Partner</SelectItem>
|
||||
<SelectItem value="Support Agent">Support Agent</SelectItem>
|
||||
<SelectItem value="Admin">Admin</SelectItem>
|
||||
<SelectItem value="Super Admin">Super Admin</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: Preferences */}
|
||||
{currentStep === 3 && (
|
||||
<div className="space-y-4 animate-in fade-in-50 duration-300">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="language"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Language</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{languages.map((lang) => (
|
||||
<SelectItem key={lang.code} value={lang.code}>
|
||||
{lang.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="timezone"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Timezone</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{timezones.map((tz) => (
|
||||
<SelectItem key={tz.code} value={tz.code}>
|
||||
{tz.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="currency"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Currency</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{currencies.map((cur) => (
|
||||
<SelectItem key={cur.code} value={cur.code}>
|
||||
{cur.symbol} {cur.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 4: Tags */}
|
||||
{currentStep === 4 && (
|
||||
<div className="space-y-4 animate-in fade-in-50 duration-300">
|
||||
<FormItem>
|
||||
<FormLabel>Tags (Optional)</FormLabel>
|
||||
<FormDescription>Add labels to help segment this user.</FormDescription>
|
||||
<div className="flex flex-wrap gap-2 mt-3">
|
||||
{mockTags.map((tag) => (
|
||||
<Badge
|
||||
key={tag.id}
|
||||
variant="outline"
|
||||
onClick={() => handleTagToggle(tag.id)}
|
||||
className={cn(
|
||||
'cursor-pointer transition-all',
|
||||
selectedTags.includes(tag.id)
|
||||
? tag.color
|
||||
: 'bg-secondary/50 text-muted-foreground hover:bg-secondary'
|
||||
)}
|
||||
>
|
||||
{tag.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</FormItem>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter className="flex justify-between sm:justify-between">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleBack}
|
||||
disabled={currentStep === 1 || isSubmitting}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4 mr-1" /> Back
|
||||
</Button>
|
||||
|
||||
{currentStep < 4 ? (
|
||||
<Button type="button" onClick={handleNext}>
|
||||
Next <ChevronRight className="h-4 w-4 ml-1" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" /> Creating...
|
||||
</>
|
||||
) : (
|
||||
'Create User'
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user