import { useState, useEffect, useRef } from 'react'; import type { ReactElement } from 'react'; import { useNavigate, useParams } 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 } 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, Loader2, X } from 'lucide-react'; // Step 1: Tenant Details Schema 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 - NO password fields 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 EditTenant = (): ReactElement => { const navigate = useNavigate(); const { id } = useParams<{ id: string }>(); const [currentStep, setCurrentStep] = useState(1); const [isSubmitting, setIsSubmitting] = useState(false); const [isLoadingTenant, setIsLoadingTenant] = useState(true); const [loadError, setLoadError] = useState(null); const [selectedModules, setSelectedModules] = useState([]); const [initialModuleOptions, setInitialModuleOptions] = useState>([]); // File upload state for branding const [logoFile, setLogoFile] = useState(null); const [faviconFile, setFaviconFile] = useState(null); const [logoFilePath, setLogoFilePath] = useState(null); const [logoPreviewUrl, setLogoPreviewUrl] = useState(null); const [faviconFilePath, setFaviconFilePath] = 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); // 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: [], }, }); 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', }, }); // 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, }; }; // Auto-generate slug and domain from name const nameValue = tenantDetailsForm.watch('name'); const baseUrlWithProtocol = getBaseUrlWithProtocol(); const previousNameRef = useRef(''); // Auto-generate slug and domain when name changes 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]); // Load tenant data on mount useEffect(() => { const loadTenant = async (): Promise => { if (!id) { setLoadError('Tenant ID is required'); setIsLoadingTenant(false); return; } try { setIsLoadingTenant(true); setLoadError(null); const response = await tenantService.getById(id); const tenant = response.data; // Extract contact info from tenant_admin (preferred) or settings.contact (fallback) const contactInfo = tenant.tenant_admin || (tenant.settings as any)?.contact || {}; // Extract branding from settings or direct tenant fields const branding = (tenant.settings as any)?.branding || {}; const primaryColor = tenant.primary_color || branding.primary_color || '#112868'; const secondaryColor = tenant.secondary_color || branding.secondary_color || '#23DCE1'; const accentColor = tenant.accent_color || branding.accent_color || '#084CC8'; const logoPath = tenant.logo_file_path || branding.logo_file_path || null; const faviconPath = tenant.favicon_file_path || branding.favicon_file_path || null; // Set file paths and URLs if they exist if (logoPath) { setLogoFilePath(logoPath); // const apiBaseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api/v1'; setLogoPreviewUrl(logoPath); setLogoError(null); // Clear error if existing logo is found } if (faviconPath) { setFaviconFilePath(faviconPath); // const apiBaseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api/v1'; setFaviconFileUrl(faviconPath); setFaviconPreviewUrl(faviconPath); setFaviconError(null); // Clear error if existing favicon is found } // Validate subscription_tier const validSubscriptionTier = tenant.subscription_tier === 'basic' || tenant.subscription_tier === 'professional' || tenant.subscription_tier === 'enterprise' ? tenant.subscription_tier : null; // Extract module IDs const tenantModules = tenant.assignedModules ? tenant.assignedModules.map((module) => module.id) : tenant.modules || []; // Create initial options from assignedModules const initialOptions = tenant.assignedModules ? tenant.assignedModules.map((module) => ({ value: module.id, label: module.name, })) : []; setSelectedModules(tenantModules); setInitialModuleOptions(initialOptions); // Reset forms with tenant data tenantDetailsForm.reset({ name: tenant.name, slug: tenant.slug, domain: tenant.domain || '', status: tenant.status, subscription_tier: validSubscriptionTier, max_users: tenant.max_users, max_modules: tenant.max_modules, modules: tenantModules, }); // Set previous name ref to track changes previousNameRef.current = tenant.name; contactDetailsForm.reset({ email: contactInfo.email || '', first_name: contactInfo.first_name || '', last_name: contactInfo.last_name || '', contact_phone: contactInfo.contact_phone || '', address_line1: contactInfo.address_line1 || '', address_line2: contactInfo.address_line2 || '', city: contactInfo.city || '', state: contactInfo.state || '', postal_code: contactInfo.postal_code || '', country: contactInfo.country || '', }); settingsForm.reset({ enable_sso: tenant.enable_sso || false, enable_2fa: tenant.enable_2fa || false, primary_color: primaryColor, secondary_color: secondaryColor, accent_color: accentColor, }); } catch (err: any) { setLoadError(err?.response?.data?.error?.message || 'Failed to load tenant details'); showToast.error('Failed to load tenant details'); } finally { setIsLoadingTenant(false); } }; loadTenant(); }, [id, tenantDetailsForm, contactDetailsForm, settingsForm]); const handleNext = async (): Promise => { if (currentStep === 1) { const isValid = await tenantDetailsForm.trigger(); if (isValid) { const modules = tenantDetailsForm.getValues('modules') || []; if (modules.length > 0 && initialModuleOptions.length === 0) { 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) { if (currentStep === 2) { const modules = tenantDetailsForm.getValues('modules') || []; if (modules.length > 0 && initialModuleOptions.length === 0) { 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); setLogoFilePath(null); setLogoError(null); // Clear error on delete // Reset the file input const fileInput = document.getElementById('logo-upload-edit-page') as HTMLInputElement; if (fileInput) { fileInput.value = ''; } }; const handleDeleteFavicon = (): void => { if (faviconPreviewUrl) { URL.revokeObjectURL(faviconPreviewUrl); } setFaviconFile(null); setFaviconPreviewUrl(null); setFaviconFileUrl(null); setFaviconFilePath(null); setFaviconError(null); // Clear error on delete // Reset the file input const fileInput = document.getElementById('favicon-upload-edit-page') as HTMLInputElement; if (fileInput) { fileInput.value = ''; } }; const handleSubmit = async (): Promise => { if (!id) return; const isValid = await settingsForm.trigger(); if (!isValid) return; // Validate logo and favicon are uploaded setLogoError(null); setFaviconError(null); const isLogoMissing = !logoFilePath; const isFaviconMissing = !faviconFilePath; 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(); const { modules, ...restTenantDetails } = tenantDetails; 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: contactDetails, branding: { primary_color: primary_color || undefined, secondary_color: secondary_color || undefined, accent_color: accent_color || undefined, logo_file_path: logoFilePath || undefined, favicon_file_path: faviconFilePath || undefined, }, }, }; const response = await tenantService.update(id, tenantData); const message = response.message || 'Tenant updated 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 === '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 update tenant. Please try again.'; tenantDetailsForm.setError('root', { type: 'server', message: typeof errorMessage === 'string' ? errorMessage : 'Failed to update tenant. Please try again.', }); showToast.error(typeof errorMessage === 'string' ? errorMessage : 'Failed to update 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: 'Security & branding', isActive: currentStep === 3, isCompleted: false, }, ]; if (isLoadingTenant) { return (
); } if (loadError) { return (

{loadError}

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

{tenantDetailsForm.formState.errors.root && (

{tenantDetailsForm.formState.errors.root.message}

)}
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} />
{ 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; }, })} />
{ setSelectedModules(values); tenantDetailsForm.setValue('modules', values.length > 0 ? values : []); }} onLoadOptions={loadModules} initialOptions={initialModuleOptions} error={tenantDetailsForm.formState.errors.modules?.message} />
)} {/* Step 2: Contact Details - NO PASSWORD FIELDS */} {currentStep === 2 && (

Contact Details

Contact information for the main account administrator.

{contactDetailsForm.formState.errors.root && (

{contactDetailsForm.formState.errors.root.message}

)}
{ // 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

{ e.target.value = e.target.value.replace(/\D/g, '').slice(0, 6); }, })} />
)} {/* Step 3: Settings with Branding */} {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 - Same as CreateTenantWizard */}

Branding

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

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

{logoError}

)} {(logoFile || logoPreviewUrl) && (
{logoFile && (
{isUploadingLogo ? 'Uploading...' : `Selected: ${logoFile.name}`}
)} {logoPreviewUrl && (
Logo preview { console.error('Failed to load logo preview image', { logoPreviewUrl, src: e.currentTarget.src, }); }} />
)}
)}
{/* Favicon */}
{faviconError && (

{faviconError}

)} {(faviconFile || faviconFileUrl || faviconPreviewUrl) && (
{faviconFile && (
{isUploadingFavicon ? 'Uploading...' : `Selected: ${faviconFile.name}`}
)} {(faviconPreviewUrl || faviconFileUrl) && (
Favicon preview { console.error('Failed to load favicon preview image', { faviconFileUrl, faviconPreviewUrl, src: e.currentTarget.src, }); }} />
)}
)}
{/* 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 */}
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.

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 ? 'Updating...' : 'Update Tenant'} )}
); }; export default EditTenant;