From c6ee8c7032142c8190800ed4e7a25221d626fb3a Mon Sep 17 00:00:00 2001 From: Yashwin Date: Wed, 28 Jan 2026 18:43:50 +0530 Subject: [PATCH] Refactor EditTenantModal into EditTenant page with multi-step form for tenant details, contact information, and settings. Enhance validation schemas and integrate file upload functionality for branding assets. Update routing to navigate to the new EditTenant page instead of using a modal. Remove legacy modal handling from Tenants component. --- src/components/shared/EditTenantModal.tsx | 1157 +++++++++++++++++---- src/pages/EditTenant.tsx | 1148 ++++++++++++++++++++ src/pages/Tenants.tsx | 65 +- src/routes/super-admin-routes.tsx | 5 + src/services/tenant-service.ts | 2 + src/types/tenant.ts | 28 + 6 files changed, 2140 insertions(+), 265 deletions(-) create mode 100644 src/pages/EditTenant.tsx 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; }