diff --git a/src/components/shared/EditTenantModal.tsx b/src/components/shared/EditTenantModal.tsx index 0543812..9ae89c7 100644 --- a/src/components/shared/EditTenantModal.tsx +++ b/src/components/shared/EditTenantModal.tsx @@ -3,13 +3,15 @@ import type { ReactElement } from 'react'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; -import { Loader2 } from 'lucide-react'; +import { Loader2, ChevronRight, ChevronLeft, Image as ImageIcon } from 'lucide-react'; import { Modal, FormField, FormSelect, PrimaryButton, SecondaryButton, MultiselectPaginatedSelect } from '@/components/shared'; import type { Tenant } from '@/types/tenant'; import { moduleService } from '@/services/module-service'; +import { fileService } from '@/services/file-service'; +import { showToast } from '@/utils/toast'; -// Validation schema - matches backend validation -const editTenantSchema = z.object({ +// Step 1: Tenant Details Schema +const tenantDetailsSchema = z.object({ name: z .string() .min(1, 'name is required') @@ -21,10 +23,10 @@ const editTenantSchema = z.object({ .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', }), - settings: z.any().optional().nullable(), subscription_tier: z.enum(['basic', 'professional', 'enterprise'], { message: 'Invalid subscription tier', }).optional().nullable(), @@ -33,16 +35,48 @@ const editTenantSchema = z.object({ modules: z.array(z.string().uuid()).optional().nullable(), }); -type EditTenantFormData = z.infer; +// Step 2: Contact Details Schema - NO password fields +const contactDetailsSchema = 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'), + last_name: z.string().min(1, 'Last name is required'), + contact_phone: z + .string() + .optional() + .nullable() + .refine( + (val) => { + if (!val || val.trim() === '') return true; + const phoneRegex = /^[\+]?[(]?[0-9]{1,4}[)]?[-\s\.]?[(]?[0-9]{1,4}[)]?[-\s\.]?[0-9]{1,9}[-\s\.]?[0-9]{1,9}$/; + return phoneRegex.test(val.replace(/\s/g, '')); + }, + { + message: 'Please enter a valid phone number (e.g., +1234567890, (123) 456-7890, 123-456-7890)', + } + ), + 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() + .min(1, 'Postal code is required') + .regex(/^[A-Za-z0-9\s\-]{3,10}$/, 'Postal code must be 3-10 characters (letters, numbers, spaces, or hyphens)'), + country: z.string().min(1, 'Country is required'), +}); -interface EditTenantModalProps { - isOpen: boolean; - onClose: () => void; - tenantId: string | null; - onLoadTenant: (id: string) => Promise; - onSubmit: (id: string, data: EditTenantFormData) => Promise; - isLoading?: boolean; -} +// 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' }, @@ -56,6 +90,15 @@ const subscriptionTierOptions = [ { value: 'enterprise', label: 'Enterprise' }, ]; +interface EditTenantModalProps { + isOpen: boolean; + onClose: () => void; + tenantId: string | null; + onLoadTenant: (id: string) => Promise; + onSubmit: (id: string, data: any) => Promise; + isLoading?: boolean; +} + export const EditTenantModal = ({ isOpen, onClose, @@ -64,27 +107,66 @@ export const EditTenantModal = ({ onSubmit, isLoading = false, }: EditTenantModalProps): ReactElement | null => { + const [currentStep, setCurrentStep] = useState(1); const [isLoadingTenant, setIsLoadingTenant] = useState(false); const [loadError, setLoadError] = useState(null); const loadedTenantIdRef = useRef(null); const [selectedModules, setSelectedModules] = useState([]); const [initialModuleOptions, setInitialModuleOptions] = useState>([]); - const { - register, - handleSubmit, - setValue, - watch, - reset, - setError, - clearErrors, - formState: { errors }, - } = useForm({ - resolver: zodResolver(editTenantSchema), + // File upload state for branding + const [logoFile, setLogoFile] = useState(null); + const [faviconFile, setFaviconFile] = useState(null); + const [logoFilePath, setLogoFilePath] = useState(null); + const [logoFileUrl, setLogoFileUrl] = 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); + + // Form instances for each step + const tenantDetailsForm = useForm({ + resolver: zodResolver(tenantDetailsSchema), + defaultValues: { + name: '', + slug: '', + domain: '', + status: 'active', + subscription_tier: null, + max_users: null, + max_modules: null, + modules: [], + }, }); - const statusValue = watch('status'); - const subscriptionTierValue = watch('subscription_tier'); + const contactDetailsForm = useForm({ + resolver: zodResolver(contactDetailsSchema), + defaultValues: { + email: '', + first_name: '', + last_name: '', + contact_phone: '', + address_line1: '', + address_line2: '', + city: '', + state: '', + postal_code: '', + country: '', + }, + }); + + const settingsForm = useForm({ + resolver: zodResolver(settingsSchema), + 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) => { @@ -98,90 +180,181 @@ export const EditTenantModal = ({ }; }; - // Load tenant data when modal opens - only load once per tenantId + // Load tenant data when modal opens useEffect(() => { if (isOpen && tenantId) { - // Only load if this is a new tenantId or modal was closed and reopened if (loadedTenantIdRef.current !== tenantId) { - const loadTenant = async (): Promise => { - try { - setIsLoadingTenant(true); - setLoadError(null); - clearErrors(); - const tenant = await onLoadTenant(tenantId); + const loadTenant = async (): Promise => { + try { + setIsLoadingTenant(true); + setLoadError(null); + const tenant = await onLoadTenant(tenantId); loadedTenantIdRef.current = tenantId; + + // Extract contact info from settings + const contactInfo = (tenant.settings as any)?.contact || {}; - // Validate subscription_tier to match enum type - const validSubscriptionTier = tenant.subscription_tier === 'basic' || - tenant.subscription_tier === 'professional' || - tenant.subscription_tier === 'enterprise' - ? tenant.subscription_tier - : null; - - // Extract module IDs from assignedModules (preferred) or fallback to modules array - const tenantModules = tenant.assignedModules + // 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); + // Construct URL from file path + const apiBaseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api/v1'; + const baseUrl = apiBaseUrl.replace('/api/v1', ''); + setLogoFileUrl(logoPath.startsWith('http') ? logoPath : `${baseUrl}/uploads/${logoPath}`); + } + if (faviconPath) { + setFaviconFilePath(faviconPath); + const apiBaseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api/v1'; + const baseUrl = apiBaseUrl.replace('/api/v1', ''); + setFaviconFileUrl(faviconPath.startsWith('http') ? faviconPath : `${baseUrl}/uploads/${faviconPath}`); + } + + // 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 for display + : 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({ - name: tenant.name, - slug: tenant.slug, - status: tenant.status, - settings: tenant.settings, + + // 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, + max_users: tenant.max_users, + max_modules: tenant.max_modules, modules: tenantModules, - }); - } catch (err: any) { - setLoadError(err?.response?.data?.error?.message || 'Failed to load tenant details'); - } finally { - setIsLoadingTenant(false); - } - }; - loadTenant(); + }); + + 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, + }); + + setCurrentStep(1); + } catch (err: any) { + setLoadError(err?.response?.data?.error?.message || 'Failed to load tenant details'); + } finally { + setIsLoadingTenant(false); + } + }; + loadTenant(); } } else if (!isOpen) { - // Only reset when modal is closed loadedTenantIdRef.current = null; setSelectedModules([]); setInitialModuleOptions([]); - reset({ - name: '', - slug: '', - status: 'active', - settings: null, - subscription_tier: null, - max_users: null, - max_modules: null, - modules: [], - }); + setCurrentStep(1); + setLogoFile(null); + setFaviconFile(null); + setLogoFilePath(null); + setLogoFileUrl(null); + setLogoPreviewUrl(null); + setFaviconFilePath(null); + setFaviconFileUrl(null); + setFaviconPreviewUrl(null); + tenantDetailsForm.reset(); + contactDetailsForm.reset(); + settingsForm.reset(); setLoadError(null); - clearErrors(); } - }, [isOpen, tenantId, onLoadTenant, reset, clearErrors]); + }, [isOpen, tenantId, onLoadTenant, tenantDetailsForm, contactDetailsForm, settingsForm]); - const handleFormSubmit = async (data: EditTenantFormData): Promise => { + const handleNext = async (): Promise => { + if (currentStep === 1) { + const isValid = await tenantDetailsForm.trigger(); + if (isValid) { + setCurrentStep(2); + } + } else if (currentStep === 2) { + const isValid = await contactDetailsForm.trigger(); + if (isValid) { + setCurrentStep(3); + } + } + }; + + const handlePrevious = (): void => { + if (currentStep > 1) { + setCurrentStep(currentStep - 1); + } + }; + + const handleFormSubmit = async (): Promise => { if (!tenantId) return; - clearErrors(); + const isValid = await settingsForm.trigger(); + if (!isValid) return; + try { - const { modules, ...restData } = data; - const submitData = { - ...restData, + 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, + }, + }, }; - await onSubmit(tenantId, submitData); + + await onSubmit(tenantId, tenantData); } catch (error: any) { // Handle validation errors from API if (error?.response?.data?.details && Array.isArray(error.response.data.details)) { @@ -191,23 +364,34 @@ export const EditTenantModal = ({ detail.path === 'name' || detail.path === 'slug' || detail.path === 'status' || - detail.path === 'settings' || detail.path === 'subscription_tier' || detail.path === 'max_users' || detail.path === 'max_modules' || detail.path === 'module_ids' ) { - // Map module_ids error to modules field for display const fieldPath = detail.path === 'module_ids' ? 'modules' : detail.path; - setError(fieldPath as keyof EditTenantFormData, { + tenantDetailsForm.setError(fieldPath as keyof TenantDetailsForm, { + type: 'server', + message: detail.message, + }); + } else if ( + detail.path === 'email' || + detail.path === 'first_name' || + detail.path === 'last_name' || + detail.path === 'contact_phone' || + detail.path === 'address_line1' || + detail.path === 'city' || + detail.path === 'state' || + detail.path === 'postal_code' || + detail.path === 'country' + ) { + contactDetailsForm.setError(detail.path as keyof ContactDetailsForm, { type: 'server', message: detail.message, }); } }); } else { - // Handle general errors - // Check for nested error object with message property const errorObj = error?.response?.data?.error; const errorMessage = (typeof errorObj === 'object' && errorObj !== null && 'message' in errorObj ? errorObj.message : null) || @@ -215,7 +399,7 @@ export const EditTenantModal = ({ error?.response?.data?.message || error?.message || 'Failed to update tenant. Please try again.'; - setError('root', { + tenantDetailsForm.setError('root', { type: 'server', message: typeof errorMessage === 'string' ? errorMessage : 'Failed to update tenant. Please try again.', }); @@ -223,36 +407,42 @@ export const EditTenantModal = ({ } }; + 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 (!isOpen) return null; + return ( - - Cancel - - - {isLoading ? 'Updating...' : 'Update Tenant'} - - - } + maxWidth="2xl" + footer={null} > -
+
{isLoadingTenant && (
@@ -266,111 +456,662 @@ export const EditTenantModal = ({ )} {!isLoadingTenant && ( -
- {/* General Error Display */} - {errors.root && ( -
-

{errors.root.message}

+
+ {/* Steps Sidebar */} +
+
+

Steps

- )} - - {/* Tenant Name */} - - - {/* Slug */} - - - {/* Status and Subscription Tier Row */} -
-
- setValue('status', value as 'active' | 'suspended' | 'deleted')} - error={errors.status?.message} - /> -
-
- setValue('subscription_tier', value === '' ? null : value as 'basic' | 'professional' | 'enterprise')} - error={errors.subscription_tier?.message} - /> +
+ {steps.map((step) => ( +
+
+ {step.number} +
+
+
+ {step.title} +
+
{step.description}
+
+
+ ))}
- {/* 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; - }, - })} - /> + {/* 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}

+
+ )} +
+
+ +
+ + +
+
+ +
+
+
+

Organization Address

+
+ + +
+ + +
+
+ + +
+
+
+
+
+ )} + + {/* Step 3: Settings with Branding */} + {currentStep === 3 && ( +
+
+

Configuration & Limits

+

Set resource limits and security preferences for this tenant.

+
+ + {/* Branding Section */} +
+
+

Branding

+

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

+
+ + {/* Logo and Favicon Upload */} +
+ {/* Company Logo */} +
+ + + {logoFile && ( +
+
+ {isUploadingLogo ? 'Uploading...' : `Selected: ${logoFile.name}`} +
+ {(logoPreviewUrl || logoFileUrl) && ( +
+ Logo preview { + console.error('Failed to load logo preview image', { + logoFileUrl, + logoPreviewUrl, + src: e.currentTarget.src, + }); + }} + onLoad={() => { + console.log('Logo preview loaded successfully', { + logoFileUrl, + logoPreviewUrl, + src: logoFileUrl || logoPreviewUrl, + }); + }} + /> +
+ )} +
+ )} + {!logoFile && logoFileUrl && ( +
+ Current logo +
+ )} +
+ + {/* Favicon */} +
+ + + {faviconFile && ( +
+
+ {isUploadingFavicon ? 'Uploading...' : `Selected: ${faviconFile.name}`} +
+ {(faviconPreviewUrl || faviconFileUrl) && ( +
+ Favicon preview { + console.error('Failed to load favicon preview image', { + faviconFileUrl, + faviconPreviewUrl, + src: e.currentTarget.src, + }); + }} + onLoad={() => { + console.log('Favicon preview loaded successfully', { + faviconFileUrl, + faviconPreviewUrl, + src: faviconFileUrl || faviconPreviewUrl, + }); + }} + /> +
+ )} +
+ )} + {!faviconFile && faviconFileUrl && ( +
+ Current favicon +
+ )} +
+
+ + {/* 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 + + + ) : ( + + {isLoading ? 'Updating...' : 'Update Tenant'} + + )}
- - {/* Modules Multiselect */} - { - setSelectedModules(values); - setValue('modules', values.length > 0 ? values : []); - }} - onLoadOptions={loadModules} - initialOptions={initialModuleOptions} - error={errors.modules?.message} - />
- )} - + )} +
); }; diff --git a/src/pages/EditTenant.tsx b/src/pages/EditTenant.tsx new file mode 100644 index 0000000..676297a --- /dev/null +++ b/src/pages/EditTenant.tsx @@ -0,0 +1,1148 @@ +import { useState, useEffect } 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 } 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.string().uuid()).optional().nullable(), +}); + +// Step 2: Contact Details Schema - NO password fields +const contactDetailsSchema = 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'), + last_name: z.string().min(1, 'Last name is required'), + contact_phone: z + .string() + .optional() + .nullable() + .refine( + (val) => { + if (!val || val.trim() === '') return true; + const phoneRegex = /^[\+]?[(]?[0-9]{1,4}[)]?[-\s\.]?[(]?[0-9]{1,4}[)]?[-\s\.]?[0-9]{1,9}[-\s\.]?[0-9]{1,9}$/; + return phoneRegex.test(val.replace(/\s/g, '')); + }, + { + message: 'Please enter a valid phone number (e.g., +1234567890, (123) 456-7890, 123-456-7890)', + } + ), + 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() + .min(1, 'Postal code is required') + .regex(/^[A-Za-z0-9\s\-]{3,10}$/, 'Postal code must be 3-10 characters (letters, numbers, spaces, or hyphens)'), + 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' }, +]; + +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 [logoFileUrl, setLogoFileUrl] = 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); + + // Form instances for each step + const tenantDetailsForm = useForm({ + resolver: zodResolver(tenantDetailsSchema), + defaultValues: { + name: '', + slug: '', + domain: '', + status: 'active', + subscription_tier: null, + max_users: null, + max_modules: null, + modules: [], + }, + }); + + const contactDetailsForm = useForm({ + resolver: zodResolver(contactDetailsSchema), + defaultValues: { + email: '', + first_name: '', + last_name: '', + contact_phone: '', + address_line1: '', + address_line2: '', + city: '', + state: '', + postal_code: '', + country: '', + }, + }); + + const settingsForm = useForm({ + resolver: zodResolver(settingsSchema), + 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, + }; + }; + + // 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'; + const baseUrl = apiBaseUrl.replace('/api/v1', ''); + setLogoFileUrl(logoPath.startsWith('http') ? logoPath : `${baseUrl}/uploads/${logoPath}`); + } + if (faviconPath) { + setFaviconFilePath(faviconPath); + const apiBaseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api/v1'; + const baseUrl = apiBaseUrl.replace('/api/v1', ''); + setFaviconFileUrl(faviconPath.startsWith('http') ? faviconPath : `${baseUrl}/uploads/${faviconPath}`); + } + + // 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, + }); + + 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 handleSubmit = async (): Promise => { + if (!id) return; + + const isValid = await settingsForm.trigger(); + if (!isValid) 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) { + if (err?.response?.data?.details && Array.isArray(err.response.data.details)) { + const validationErrors = err.response.data.details; + validationErrors.forEach((detail: { path: string; message: string }) => { + if ( + detail.path === 'name' || + detail.path === 'slug' || + detail.path === 'status' || + detail.path === 'subscription_tier' || + detail.path === 'max_users' || + detail.path === 'max_modules' || + detail.path === 'module_ids' + ) { + const fieldPath = detail.path === 'module_ids' ? 'modules' : detail.path; + tenantDetailsForm.setError(fieldPath as keyof TenantDetailsForm, { + type: 'server', + message: detail.message, + }); + } else if ( + detail.path === 'email' || + detail.path === 'first_name' || + detail.path === 'last_name' || + detail.path === 'contact_phone' || + detail.path === 'address_line1' || + detail.path === 'city' || + detail.path === 'state' || + detail.path === 'postal_code' || + detail.path === 'country' + ) { + contactDetailsForm.setError(detail.path as keyof ContactDetailsForm, { + type: 'server', + message: detail.message, + }); + } + }); + } else { + const errorMessage = + err?.response?.data?.error?.message || + err?.response?.data?.message || + err?.message || + 'Failed to update tenant. Please try again.'; + showToast.error(errorMessage); + } + } 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}

+
+ )} +
+
+ +
+ + +
+
+ +
+
+
+

Organization Address

+
+ + +
+ + +
+
+ + +
+
+
+
+
+ )} + + {/* Step 3: Settings with Branding */} + {currentStep === 3 && ( +
+
+

Configuration & Limits

+

Set resource limits and security preferences for this tenant.

+
+ + {/* Branding Section - Same as CreateTenantWizard */} +
+
+

Branding

+

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

+
+ + {/* Logo and Favicon Upload */} +
+ {/* Company Logo */} +
+ + + {logoFile && ( +
+
+ {isUploadingLogo ? 'Uploading...' : `Selected: ${logoFile.name}`} +
+ {(logoPreviewUrl || logoFileUrl) && ( +
+ Logo preview { + console.error('Failed to load logo preview image', { + logoFileUrl, + logoPreviewUrl, + src: e.currentTarget.src, + }); + }} + /> +
+ )} +
+ )} + {!logoFile && logoFileUrl && ( +
+ Current logo +
+ )} +
+ + {/* Favicon */} +
+ + + {faviconFile && ( +
+
+ {isUploadingFavicon ? 'Uploading...' : `Selected: ${faviconFile.name}`} +
+ {(faviconPreviewUrl || faviconFileUrl) && ( +
+ Favicon preview +
+ )} +
+ )} + {!faviconFile && faviconFileUrl && ( +
+ Current favicon +
+ )} +
+
+ + {/* 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; diff --git a/src/pages/Tenants.tsx b/src/pages/Tenants.tsx index d0ce130..da7258d 100644 --- a/src/pages/Tenants.tsx +++ b/src/pages/Tenants.tsx @@ -7,7 +7,7 @@ import { ActionDropdown, // NewTenantModal, // Commented out - using wizard instead // ViewTenantModal, // Commented out - using details page instead - EditTenantModal, + // EditTenantModal, // Commented out - using edit page instead DeleteConfirmationModal, DataTable, Pagination, @@ -18,7 +18,6 @@ import { Plus, Download, ArrowUpDown } from 'lucide-react'; import { useNavigate } from 'react-router-dom'; import { tenantService } from '@/services/tenant-service'; import type { Tenant } from '@/types/tenant'; -import { showToast } from '@/utils/toast'; // Helper function to get tenant initials const getTenantInitials = (name: string): string => { @@ -86,11 +85,10 @@ const Tenants = (): ReactElement => { // View, Edit, Delete modals // const [viewModalOpen, setViewModalOpen] = useState(false); // Commented out - using details page instead - const [editModalOpen, setEditModalOpen] = useState(false); + // const [editModalOpen, setEditModalOpen] = useState(false); // Commented out - using edit page instead const [deleteModalOpen, setDeleteModalOpen] = useState(false); const [selectedTenantId, setSelectedTenantId] = useState(null); const [selectedTenantName, setSelectedTenantName] = useState(''); - const [isUpdating, setIsUpdating] = useState(false); const [isDeleting, setIsDeleting] = useState(false); const fetchTenants = async ( @@ -152,40 +150,11 @@ const Tenants = (): ReactElement => { }; // Edit tenant handler - const handleEditTenant = (tenantId: string, tenantName: string): void => { - setSelectedTenantId(tenantId); - setSelectedTenantName(tenantName); - setEditModalOpen(true); + const handleEditTenant = (tenantId: string): void => { + navigate(`/tenants/${tenantId}/edit`); }; - // Update tenant handler - const handleUpdateTenant = async ( - id: string, - data: { - name: string; - slug: string; - status: 'active' | 'suspended' | 'deleted'; - settings?: Record | null; - subscription_tier?: string | null; - max_users?: number | null; - max_modules?: number | null; - } - ): Promise => { - try { - setIsUpdating(true); - const response = await tenantService.update(id, data); - const message = response.message || `Tenant updated successfully`; - const description = response.message ? undefined : `${data.name} has been updated`; - showToast.success(message, description); - setEditModalOpen(false); - setSelectedTenantId(null); - await fetchTenants(currentPage, limit, statusFilter, orderBy); - } catch (err: any) { - throw err; // Let the modal handle the error display - } finally { - setIsUpdating(false); - } - }; + // Update tenant handler - removed, now handled in EditTenant page // Delete tenant handler const handleDeleteTenant = (tenantId: string, tenantName: string): void => { @@ -212,12 +181,6 @@ const Tenants = (): ReactElement => { } }; - // Load tenant for view/edit - const loadTenant = async (id: string): Promise => { - const response = await tenantService.getById(id); - return response.data; - }; - // Define table columns const columns: Column[] = [ { @@ -291,7 +254,7 @@ const Tenants = (): ReactElement => {
handleViewTenant(tenant.id)} - onEdit={() => handleEditTenant(tenant.id, tenant.name)} + onEdit={() => handleEditTenant(tenant.id)} onDelete={() => handleDeleteTenant(tenant.id, tenant.name)} />
@@ -320,7 +283,7 @@ const Tenants = (): ReactElement => {
handleViewTenant(tenant.id)} - onEdit={() => handleEditTenant(tenant.id, tenant.name)} + onEdit={() => handleEditTenant(tenant.id)} onDelete={() => handleDeleteTenant(tenant.id, tenant.name)} />
@@ -489,19 +452,7 @@ const Tenants = (): ReactElement => { onLoadTenant={loadTenant} /> */} - {/* Edit Tenant Modal */} - { - setEditModalOpen(false); - setSelectedTenantId(null); - setSelectedTenantName(''); - }} - tenantId={selectedTenantId} - onLoadTenant={loadTenant} - onSubmit={handleUpdateTenant} - isLoading={isUpdating} - /> + {/* Edit Tenant Modal - Removed, using edit page instead */} {/* Delete Confirmation Modal */} , }, + { + path: '/tenants/:id/edit', + element: , + }, { path: '/tenants/:id', element: , diff --git a/src/services/tenant-service.ts b/src/services/tenant-service.ts index c150fd0..1062176 100644 --- a/src/services/tenant-service.ts +++ b/src/services/tenant-service.ts @@ -42,10 +42,12 @@ export interface UpdateTenantRequest { name: string; slug: string; status: 'active' | 'suspended' | 'deleted'; + domain?: string | null; settings?: Record | null; subscription_tier?: string | null; max_users?: number | null; max_modules?: number | null; + module_ids?: string[] | null; } export interface UpdateTenantResponse { diff --git a/src/types/tenant.ts b/src/types/tenant.ts index 3511239..af28632 100644 --- a/src/types/tenant.ts +++ b/src/types/tenant.ts @@ -19,6 +19,28 @@ export interface TenantUser { status: string; } +export interface TenantAdmin { + id: string; + email: string; + first_name: string; + last_name: string; + contact_phone?: string | null; + address_line1?: string | null; + address_line2?: string | null; + city?: string | null; + state?: string | null; + postal_code?: string | null; + country?: string | null; +} + +export interface TenantBranding { + logo_file_path?: string | null; + favicon_file_path?: string | null; + primary_color?: string | null; + secondary_color?: string | null; + accent_color?: string | null; +} + export interface Tenant { id: string; name: string; @@ -31,9 +53,15 @@ export interface Tenant { domain?: string | null; enable_sso?: boolean; enable_2fa?: boolean; + logo_file_path?: string | null; + favicon_file_path?: string | null; + primary_color?: string | null; + secondary_color?: string | null; + accent_color?: string | null; modules?: string[]; // Array of module IDs (legacy, for backward compatibility) assignedModules?: AssignedModule[]; // Array of assigned modules with full details users?: TenantUser[]; // Array of tenant users + tenant_admin?: TenantAdmin; // Tenant admin user details created_at: string; updated_at: string; }