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"; import { generateUUID } from "@/lib/utils"; // 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" }), 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"), }); // 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_FRONTEND_BASE_URL || "http://localhost:5173"; }; const CreateTenantWizard = (): ReactElement => { const navigate = useNavigate(); const [currentStep, setCurrentStep] = useState(1); const [isSubmitting, setIsSubmitting] = useState(false); const [selectedModules, setSelectedModules] = useState([]); const [initialModuleOptions, setInitialModuleOptions] = useState< Array<{ value: string; label: string }> >([]); // 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: "", 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< string | null >(null); const [faviconFileAttachmentUuid, setFaviconFileAttachmentUuid] = useState< string | null >(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); // 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; const 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.", ""); 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 */}
{/* 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;