import { useState, useEffect, useRef } from 'react'; import type { ReactElement } from 'react'; import { useNavigate } from 'react-router-dom'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; import { Layout } from '@/components/layout/Layout'; import { FormField, FormSelect, PrimaryButton, SecondaryButton, MultiselectPaginatedSelect, AuthenticatedImage } from '@/components/shared'; import { tenantService } from '@/services/tenant-service'; import { moduleService } from '@/services/module-service'; import { fileService } from '@/services/file-service'; import { showToast } from '@/utils/toast'; import { ChevronRight, ChevronLeft, Image as ImageIcon, X } from 'lucide-react'; // Step 1: Tenant Details Schema - matches NewTenantModal const tenantDetailsSchema = z.object({ name: z .string() .min(1, 'name is required') .min(3, 'name must be at least 3 characters') .max(100, 'name must be at most 100 characters'), slug: z .string() .min(1, 'slug is required') .min(3, 'slug must be at least 3 characters') .max(100, 'slug must be at most 100 characters') .regex(/^[a-z0-9-]+$/, 'slug format is invalid'), domain: z.string().optional().nullable(), status: z.enum(['active', 'suspended', 'deleted'], { message: 'Status is required', }), subscription_tier: z.enum(['basic', 'professional', 'enterprise'], { message: 'Invalid subscription tier', }).optional().nullable(), max_users: z.number().int().min(1, 'max_users must be at least 1').optional().nullable(), max_modules: z.number().int().min(1, 'max_modules must be at least 1').optional().nullable(), modules: z.array(z.uuid()).optional().nullable(), }); // Step 2: Contact Details Schema - user creation + organization address const contactDetailsSchema = z .object({ email: z .email({ message: 'Please enter a valid email address' }), password: z.string().min(1, 'Password is required').min(6, 'Password must be at least 6 characters'), confirmPassword: z.string().min(1, 'Confirm password is required'), first_name: z.string().min(1, 'First name is required'), last_name: z.string().min(1, 'Last name is required'), contact_phone: z .string() .optional() .nullable() .refine( (val) => { if (!val || val.trim() === '') return true; // Optional field, empty is valid return /^\d{10}$/.test(val); }, { message: 'Phone number must be exactly 10 digits', } ), address_line1: z.string().min(1, 'Address is required'), address_line2: z.string().optional().nullable(), city: z.string().min(1, 'City is required'), state: z.string().min(1, 'State is required'), postal_code: z .string() .regex(/^[1-9]\d{5}$/, 'Postal code must be a valid 6-digit PIN code'), country: z.string().min(1, 'Country is required'), }) .refine((data) => data.password === data.confirmPassword, { message: "Passwords don't match", path: ['confirmPassword'], }); // Step 3: Settings Schema const settingsSchema = z.object({ enable_sso: z.boolean(), enable_2fa: z.boolean(), primary_color: z.string().optional().nullable(), secondary_color: z.string().optional().nullable(), accent_color: z.string().optional().nullable(), }); type TenantDetailsForm = z.infer; type ContactDetailsForm = z.infer; type SettingsForm = z.infer; const statusOptions = [ { value: 'active', label: 'Active' }, { value: 'suspended', label: 'Suspended' }, { value: 'deleted', label: 'Deleted' }, ]; const subscriptionTierOptions = [ { value: 'basic', label: 'Basic' }, { value: 'professional', label: 'Professional' }, { value: 'enterprise', label: 'Enterprise' }, ]; // Helper function to get base URL with protocol const getBaseUrlWithProtocol = (): string => { return import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000'; }; const CreateTenantWizard = (): ReactElement => { const navigate = useNavigate(); const [currentStep, setCurrentStep] = useState(1); const [isSubmitting, setIsSubmitting] = useState(false); const [selectedModules, setSelectedModules] = useState([]); const [initialModuleOptions, setInitialModuleOptions] = useState>([]); // Form instances for each step const tenantDetailsForm = useForm({ resolver: zodResolver(tenantDetailsSchema), mode: 'onChange', reValidateMode: 'onChange', defaultValues: { name: '', slug: '', domain: '', status: 'active', subscription_tier: null, max_users: null, max_modules: null, modules: [], }, }); // Load modules for multiselect const loadModules = async (page: number, limit: number) => { const response = await moduleService.getRunningModules(page, limit); return { options: response.data.map((module) => ({ value: module.id, label: module.name, })), pagination: response.pagination, }; }; const contactDetailsForm = useForm({ resolver: zodResolver(contactDetailsSchema), mode: 'onChange', reValidateMode: 'onChange', defaultValues: { email: '', password: '', confirmPassword: '', first_name: '', last_name: '', contact_phone: '', address_line1: '', address_line2: '', city: '', state: '', postal_code: '', country: '', }, }); const settingsForm = useForm({ resolver: zodResolver(settingsSchema), mode: 'onChange', reValidateMode: 'onChange', defaultValues: { enable_sso: false, enable_2fa: false, primary_color: '#112868', secondary_color: '#23DCE1', accent_color: '#084CC8', }, }); // File upload state for branding const [logoFile, setLogoFile] = useState(null); const [faviconFile, setFaviconFile] = useState(null); const [logoFileAttachmentUuid, setLogoFileAttachmentUuid] = useState(null); const [faviconFileAttachmentUuid, setFaviconFileAttachmentUuid] = useState(null); const [logoFileUrl, setLogoFileUrl] = useState(null); const [logoPreviewUrl, setLogoPreviewUrl] = useState(null); const [faviconFileUrl, setFaviconFileUrl] = useState(null); const [faviconPreviewUrl, setFaviconPreviewUrl] = useState(null); const [isUploadingLogo, setIsUploadingLogo] = useState(false); const [isUploadingFavicon, setIsUploadingFavicon] = useState(false); const [logoError, setLogoError] = useState(null); const [faviconError, setFaviconError] = useState(null); const [wizardId] = useState(() => crypto.randomUUID()); // Auto-generate slug and domain from name const nameValue = tenantDetailsForm.watch('name'); const baseUrlWithProtocol = getBaseUrlWithProtocol(); const previousNameRef = useRef(''); useEffect(() => { if (nameValue) { const slug = nameValue .toLowerCase() .trim() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, ''); tenantDetailsForm.setValue('slug', slug, { shouldValidate: true }); // Auto-generate domain when tenant name changes (like slug) // Format: http://tenant-slug.localhost:5173/tenant // Extract host from base URL and construct domain with protocol if (nameValue !== previousNameRef.current) { try { const baseUrlObj = new URL(baseUrlWithProtocol); const host = baseUrlObj.host; // e.g., "localhost:5173" const protocol = baseUrlObj.protocol; // e.g., "http:" or "https:" const autoGeneratedDomain = `${protocol}//${slug}.${host}/tenant`; tenantDetailsForm.setValue('domain', autoGeneratedDomain, { shouldValidate: false }); } catch { // Fallback if URL parsing fails const autoGeneratedDomain = `${baseUrlWithProtocol.replace(/\/$/, '')}/${slug}/tenant`; tenantDetailsForm.setValue('domain', autoGeneratedDomain, { shouldValidate: false }); } previousNameRef.current = nameValue; } } else if (!nameValue && previousNameRef.current) { // Clear domain when name is cleared tenantDetailsForm.setValue('domain', '', { shouldValidate: false }); previousNameRef.current = ''; } }, [nameValue, tenantDetailsForm, baseUrlWithProtocol]); const handleNext = async (): Promise => { if (currentStep === 1) { const isValid = await tenantDetailsForm.trigger(); if (isValid) { // Store selected modules and their options for restoration when going back const modules = tenantDetailsForm.getValues('modules') || []; if (modules.length > 0 && initialModuleOptions.length === 0) { // Load module names for selected modules try { const moduleOptionsPromises = modules.map(async (moduleId: string) => { try { const moduleResponse = await moduleService.getById(moduleId); return { value: moduleId, label: moduleResponse.data.name, }; } catch { return null; } }); const moduleOptions = (await Promise.all(moduleOptionsPromises)).filter( (opt) => opt !== null ) as Array<{ value: string; label: string }>; setInitialModuleOptions(moduleOptions); } catch (err) { console.warn('Failed to load module names:', err); } } setCurrentStep(2); } } else if (currentStep === 2) { const isValid = await contactDetailsForm.trigger(); if (isValid) { setCurrentStep(3); } } }; const handlePrevious = (): void => { if (currentStep > 1) { // When going back to step 1, restore selected modules and their options if (currentStep === 2) { const modules = tenantDetailsForm.getValues('modules') || []; setSelectedModules(modules); // Restore initial module options if we have selected modules if (modules.length > 0 && initialModuleOptions.length === 0) { // Load module names for selected modules const loadModuleOptions = async () => { try { const moduleOptionsPromises = modules.map(async (moduleId: string) => { try { const moduleResponse = await moduleService.getById(moduleId); return { value: moduleId, label: moduleResponse.data.name, }; } catch { return null; } }); const moduleOptions = (await Promise.all(moduleOptionsPromises)).filter( (opt) => opt !== null ) as Array<{ value: string; label: string }>; setInitialModuleOptions(moduleOptions); } catch (err) { console.warn('Failed to load module names:', err); } }; loadModuleOptions(); } } setCurrentStep(currentStep - 1); } }; const handleDeleteLogo = (): void => { if (logoPreviewUrl) { URL.revokeObjectURL(logoPreviewUrl); } setLogoFile(null); setLogoPreviewUrl(null); setLogoFileUrl(null); setLogoFileAttachmentUuid(null); setLogoError(null); // Reset the file input const fileInput = document.getElementById('logo-upload-wizard') as HTMLInputElement; if (fileInput) { fileInput.value = ''; } }; const handleDeleteFavicon = (): void => { if (faviconPreviewUrl) { URL.revokeObjectURL(faviconPreviewUrl); } setFaviconFile(null); setFaviconPreviewUrl(null); setFaviconFileUrl(null); setFaviconFileAttachmentUuid(null); setFaviconError(null); // Reset the file input const fileInput = document.getElementById('favicon-upload-wizard') as HTMLInputElement; if (fileInput) { fileInput.value = ''; } }; const handleSubmit = async (): Promise => { const isValid = await settingsForm.trigger(); if (!isValid) return; // Validate logo and favicon are uploaded setLogoError(null); setFaviconError(null); const isLogoMissing = !logoFileUrl && !logoFile; const isFaviconMissing = !faviconFileUrl && !faviconFile; if (isLogoMissing) { setLogoError('Logo is required'); } if (isFaviconMissing) { setFaviconError('Favicon is required'); } if (isLogoMissing || isFaviconMissing) { setCurrentStep(3); // Go to settings step where logo/favicon are return; } try { setIsSubmitting(true); const tenantDetails = tenantDetailsForm.getValues(); const contactDetails = contactDetailsForm.getValues(); const settings = settingsForm.getValues(); // Combine all data for tenant creation - matches NewTenantModal structure const { modules, ...restTenantDetails } = tenantDetails; // Extract confirmPassword from contactDetails (not needed in API call) const { confirmPassword, ...contactData } = contactDetails; // Extract branding colors from settings const { enable_sso, enable_2fa, primary_color, secondary_color, accent_color } = settings; const tenantData = { ...restTenantDetails, module_ids: selectedModules.length > 0 ? selectedModules : undefined, settings: { enable_sso, enable_2fa, contact: contactData, // Include first_name, last_name, email, password branding: { primary_color: primary_color || undefined, secondary_color: secondary_color || undefined, accent_color: accent_color || undefined, logo_file_path: logoFileUrl || undefined, logo_file_attachment_uuid: logoFileAttachmentUuid || undefined, favicon_file_path: faviconFileUrl || undefined, favicon_file_attachment_uuid: faviconFileAttachmentUuid || undefined, }, }, }; const response = await tenantService.create(tenantData); const message = response.message || 'Tenant created successfully'; showToast.success(message); navigate('/tenants'); } catch (err: any) { // Clear previous errors tenantDetailsForm.clearErrors(); contactDetailsForm.clearErrors(); settingsForm.clearErrors(); // Handle validation errors from API if (err?.response?.data?.details && Array.isArray(err.response.data.details)) { const validationErrors = err.response.data.details; let hasTenantErrors = false; let hasContactErrors = false; let hasSettingsErrors = false; validationErrors.forEach((detail: { path: string; message: string }) => { const path = detail.path; // Handle nested paths first if (path.startsWith('settings.contact.')) { // Contact details errors from nested path hasContactErrors = true; const fieldName = path.replace('settings.contact.', ''); if (fieldName === 'confirmPassword') { // Skip confirmPassword as it's not in the form schema return; } contactDetailsForm.setError(fieldName as keyof ContactDetailsForm, { type: 'server', message: detail.message, }); } else if (path.startsWith('settings.branding.')) { // Settings/branding errors from nested path hasSettingsErrors = true; const fieldName = path.replace('settings.branding.', ''); // Map file_path fields to form fields if (fieldName === 'logo_file_path') { setLogoError(detail.message); } else if (fieldName === 'favicon_file_path') { setFaviconError(detail.message); } else { settingsForm.setError(fieldName as keyof SettingsForm, { type: 'server', message: detail.message, }); } } else if (path.startsWith('settings.')) { // Other settings errors hasSettingsErrors = true; const fieldName = path.replace('settings.', ''); settingsForm.setError(fieldName as keyof SettingsForm, { type: 'server', message: detail.message, }); } // Handle tenant details errors (step 1) else if ( path === 'name' || path === 'slug' || path === 'domain' || path === 'status' || path === 'subscription_tier' || path === 'max_users' || path === 'max_modules' || path === 'module_ids' ) { hasTenantErrors = true; const fieldPath = path === 'module_ids' ? 'modules' : path; tenantDetailsForm.setError(fieldPath as keyof TenantDetailsForm, { type: 'server', message: detail.message, }); } // Handle contact details errors (step 2) - direct paths else if ( path === 'email' || path === 'password' || path === 'first_name' || path === 'last_name' || path === 'contact_phone' || path === 'address_line1' || path === 'address_line2' || path === 'city' || path === 'state' || path === 'postal_code' || path === 'country' ) { hasContactErrors = true; contactDetailsForm.setError(path as keyof ContactDetailsForm, { type: 'server', message: detail.message, }); } // Handle settings errors (step 3) - direct paths else if ( path === 'enable_sso' || path === 'enable_2fa' || path === 'primary_color' || path === 'secondary_color' || path === 'accent_color' ) { hasSettingsErrors = true; settingsForm.setError(path as keyof SettingsForm, { type: 'server', message: detail.message, }); } }); // Navigate to the step with errors if (hasTenantErrors) { setCurrentStep(1); } else if (hasContactErrors) { setCurrentStep(2); } else if (hasSettingsErrors) { setCurrentStep(3); } } else { // Handle general errors const errorObj = err?.response?.data?.error; const errorMessage = (typeof errorObj === 'object' && errorObj !== null && 'message' in errorObj ? errorObj.message : null) || (typeof errorObj === 'string' ? errorObj : null) || err?.response?.data?.message || err?.message || 'Failed to create tenant. Please try again.'; tenantDetailsForm.setError('root', { type: 'server', message: typeof errorMessage === 'string' ? errorMessage : 'Failed to create tenant. Please try again.', }); showToast.error(typeof errorMessage === 'string' ? errorMessage : 'Failed to create tenant. Please try again.'); setCurrentStep(1); // Navigate to first step for general errors } } finally { setIsSubmitting(false); } }; const steps = [ { number: 1, title: 'Tenant Details', description: 'Basic organization information', isActive: currentStep === 1, isCompleted: currentStep > 1, }, { number: 2, title: 'Contact Details', description: 'Primary contact & address', isActive: currentStep === 2, isCompleted: currentStep > 2, }, { number: 3, title: 'Settings', description: 'Usage limits & security', isActive: currentStep === 3, isCompleted: false, }, ]; return (
{/* Steps Sidebar */}

Steps

{steps.map((step) => (
{step.number}
{step.title}
{step.description}
))}
{/* Main Content */}
{/* Step 1: Tenant Details */} {currentStep === 1 && (

Tenant Details

Basic information for the new organization.

{/* General Error Display */} {tenantDetailsForm.formState.errors.root && (

{tenantDetailsForm.formState.errors.root.message}

)}
{/* Status and Subscription Tier Row */}
tenantDetailsForm.setValue('status', value as 'active' | 'suspended' | 'deleted') } error={tenantDetailsForm.formState.errors.status?.message} />
tenantDetailsForm.setValue( 'subscription_tier', value === '' ? null : (value as 'basic' | 'professional' | 'enterprise') ) } error={tenantDetailsForm.formState.errors.subscription_tier?.message} />
{/* Max Users and Max Modules Row */}
{ if (value === '' || value === null || value === undefined) return null; const num = Number(value); return isNaN(num) ? null : num; }, })} />
{ if (value === '' || value === null || value === undefined) return null; const num = Number(value); return isNaN(num) ? null : num; }, })} />
{/* Modules Multiselect */} { setSelectedModules(values); tenantDetailsForm.setValue('modules', values.length > 0 ? values : []); }} onLoadOptions={loadModules} initialOptions={initialModuleOptions} error={tenantDetailsForm.formState.errors.modules?.message} />
)} {/* Step 2: Contact Details */} {currentStep === 2 && (

Contact Details

Contact information for the main account administrator.

{/* General Error Display */} {contactDetailsForm.formState.errors.root && (

{contactDetailsForm.formState.errors.root.message}

)}
{/* User Account Information Section */}
{/* Email */} {/* First Name and Last Name Row */}
{/* Password and Confirm Password Row */}
{/* Contact Phone */}
{ // Only allow digits and limit to 10 characters const value = e.target.value.replace(/\D/g, '').slice(0, 10); contactDetailsForm.setValue('contact_phone', value, { shouldValidate: true }); }, })} />
{/* Organization Address Section */}

Organization Address

{ e.target.value = e.target.value.replace(/\D/g, '').slice(0, 6); }, })} />
)} {/* Step 3: Settings */} {currentStep === 3 && (

Configuration & Limits

Set resource limits and security preferences for this tenant.

{/* General Error Display */} {settingsForm.formState.errors.root && (

{settingsForm.formState.errors.root.message}

)} {/* Branding Section */}
{/* Section Header */}

Branding

Customize logo, favicon, and colors for this tenant experience.

{/* Logo and Favicon Upload */}
{/* Company Logo */}
{logoError && (

{logoError}

)} {(logoFile || logoFileUrl) && (
{logoFile && (
{isUploadingLogo ? 'Uploading...' : `Selected: ${logoFile.name}`}
)} {(logoPreviewUrl || logoFileUrl) && (
)}
)}
{/* Favicon */}
{faviconError && (

{faviconError}

)} {(faviconFile || faviconFileUrl) && (
{faviconFile && (
{isUploadingFavicon ? 'Uploading...' : `Selected: ${faviconFile.name}`}
)} {(faviconPreviewUrl || faviconFileUrl) && (
)}
)}
{/* Primary Color */}
settingsForm.setValue('primary_color', e.target.value)} className="bg-[#f5f7fa] border border-[#d1d5db] h-10 px-3 py-1 rounded-md text-sm text-[#0f1724] w-full focus:outline-none focus:ring-2 focus:ring-[#112868] focus:border-transparent" placeholder="#112868" />
settingsForm.setValue('primary_color', e.target.value)} className="size-10 rounded-md border border-[#d1d5db] cursor-pointer" />

Used for navigation, headers, and key actions.

{/* Secondary and Accent Colors */}
{/* Secondary Color */}
settingsForm.setValue('secondary_color', e.target.value)} className="bg-[#f5f7fa] border border-[#d1d5db] h-10 px-3 py-1 rounded-md text-sm text-[#0f1724] w-full focus:outline-none focus:ring-2 focus:ring-[#112868] focus:border-transparent" placeholder="#23DCE1" />
settingsForm.setValue('secondary_color', e.target.value)} className="size-10 rounded-md border border-[#d1d5db] cursor-pointer" />

Used for highlights and supporting elements.

{/* Accent Color */}
settingsForm.setValue('accent_color', e.target.value)} className="bg-[#f5f7fa] border border-[#d1d5db] h-10 px-3 py-1 rounded-md text-sm text-[#0f1724] w-full focus:outline-none focus:ring-2 focus:ring-[#112868] focus:border-transparent" placeholder="#084CC8" />
settingsForm.setValue('accent_color', e.target.value)} className="size-10 rounded-md border border-[#d1d5db] cursor-pointer" />

Used for alerts and special notices.

{/* Security Settings */}

Enable Single Sign-On (SSO)

Allow users to log in using their organization's identity provider.

Enable Two-Factor Authentication (2FA)

Enforce 2FA for all users in this tenant organization.

)} {/* Footer Navigation */}
{currentStep > 1 && ( Previous )} {currentStep < 3 ? ( Next ) : ( {isSubmitting ? 'Saving...' : 'Save Tenant'} )}
); }; export default CreateTenantWizard;