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

Steps

+

+ Steps +

{steps.map((step) => (
{step.number}
{step.title}
-
{step.description}
+
+ {step.description} +
))} @@ -611,7 +691,9 @@ const CreateTenantWizard = (): ReactElement => { {currentStep === 1 && (
-

Tenant Details

+

+ Tenant Details +

Basic information for the new organization.

@@ -619,7 +701,9 @@ const CreateTenantWizard = (): ReactElement => { {/* General Error Display */} {tenantDetailsForm.formState.errors.root && (
-

{tenantDetailsForm.formState.errors.root.message}

+

+ {tenantDetailsForm.formState.errors.root.message} +

)} @@ -629,20 +713,20 @@ const CreateTenantWizard = (): ReactElement => { required placeholder="Enter tenant name" error={tenantDetailsForm.formState.errors.name?.message} - {...tenantDetailsForm.register('name')} + {...tenantDetailsForm.register("name")} /> {/* Status and Subscription Tier Row */}
@@ -652,9 +736,12 @@ const CreateTenantWizard = (): ReactElement => { required placeholder="Select Status" options={statusOptions} - value={tenantDetailsForm.watch('status')} + value={tenantDetailsForm.watch("status")} onValueChange={(value) => - tenantDetailsForm.setValue('status', value as 'active' | 'suspended' | 'deleted') + tenantDetailsForm.setValue( + "status", + value as "active" | "suspended" | "deleted", + ) } error={tenantDetailsForm.formState.errors.status?.message} /> @@ -664,14 +751,22 @@ const CreateTenantWizard = (): ReactElement => { label="Subscription Tier" placeholder="Select Subscription" options={subscriptionTierOptions} - value={tenantDetailsForm.watch('subscription_tier') || ''} + value={tenantDetailsForm.watch("subscription_tier") || ""} onValueChange={(value) => tenantDetailsForm.setValue( - 'subscription_tier', - value === '' ? null : (value as 'basic' | 'professional' | 'enterprise') + "subscription_tier", + value === "" + ? null + : (value as + | "basic" + | "professional" + | "enterprise"), ) } - error={tenantDetailsForm.formState.errors.subscription_tier?.message} + error={ + tenantDetailsForm.formState.errors.subscription_tier + ?.message + } />
@@ -684,10 +779,17 @@ const CreateTenantWizard = (): ReactElement => { min="1" step="1" placeholder="Enter number" - error={tenantDetailsForm.formState.errors.max_users?.message} - {...tenantDetailsForm.register('max_users', { + error={ + tenantDetailsForm.formState.errors.max_users?.message + } + {...tenantDetailsForm.register("max_users", { setValueAs: (value) => { - if (value === '' || value === null || value === undefined) return null; + if ( + value === "" || + value === null || + value === undefined + ) + return null; const num = Number(value); return isNaN(num) ? null : num; }, @@ -701,10 +803,17 @@ const CreateTenantWizard = (): ReactElement => { min="1" step="1" placeholder="Enter number" - error={tenantDetailsForm.formState.errors.max_modules?.message} - {...tenantDetailsForm.register('max_modules', { + error={ + tenantDetailsForm.formState.errors.max_modules?.message + } + {...tenantDetailsForm.register("max_modules", { setValueAs: (value) => { - if (value === '' || value === null || value === undefined) return null; + if ( + value === "" || + value === null || + value === undefined + ) + return null; const num = Number(value); return isNaN(num) ? null : num; }, @@ -719,7 +828,10 @@ const CreateTenantWizard = (): ReactElement => { value={selectedModules} onValueChange={(values) => { setSelectedModules(values); - tenantDetailsForm.setValue('modules', values.length > 0 ? values : []); + tenantDetailsForm.setValue( + "modules", + values.length > 0 ? values : [], + ); }} onLoadOptions={loadModules} initialOptions={initialModuleOptions} @@ -733,7 +845,9 @@ const CreateTenantWizard = (): ReactElement => { {currentStep === 2 && (
-

Contact Details

+

+ Contact Details +

Contact information for the main account administrator.

@@ -741,7 +855,9 @@ const CreateTenantWizard = (): ReactElement => { {/* General Error Display */} {contactDetailsForm.formState.errors.root && (
-

{contactDetailsForm.formState.errors.root.message}

+

+ {contactDetailsForm.formState.errors.root.message} +

)}
@@ -754,7 +870,7 @@ const CreateTenantWizard = (): ReactElement => { required placeholder="Enter email address" error={contactDetailsForm.formState.errors.email?.message} - {...contactDetailsForm.register('email')} + {...contactDetailsForm.register("email")} /> {/* First Name and Last Name Row */}
@@ -762,15 +878,19 @@ const CreateTenantWizard = (): ReactElement => { label="First Name" required placeholder="Enter first name" - error={contactDetailsForm.formState.errors.first_name?.message} - {...contactDetailsForm.register('first_name')} + error={ + contactDetailsForm.formState.errors.first_name?.message + } + {...contactDetailsForm.register("first_name")} />
{/* Password and Confirm Password Row */} @@ -780,16 +900,21 @@ const CreateTenantWizard = (): ReactElement => { type="password" required placeholder="Enter password" - error={contactDetailsForm.formState.errors.password?.message} - {...contactDetailsForm.register('password')} + error={ + contactDetailsForm.formState.errors.password?.message + } + {...contactDetailsForm.register("password")} />
{/* Contact Phone */} @@ -799,12 +924,19 @@ const CreateTenantWizard = (): ReactElement => { type="tel" placeholder="Enter 10-digit phone number" maxLength={10} - error={contactDetailsForm.formState.errors.contact_phone?.message} - {...contactDetailsForm.register('contact_phone', { + error={ + contactDetailsForm.formState.errors.contact_phone + ?.message + } + {...contactDetailsForm.register("contact_phone", { onChange: (e) => { // Only allow digits and limit to 10 characters - const value = e.target.value.replace(/\D/g, '').slice(0, 10); - contactDetailsForm.setValue('contact_phone', value, { shouldValidate: true }); + const value = e.target.value + .replace(/\D/g, "") + .slice(0, 10); + contactDetailsForm.setValue("contact_phone", value, { + shouldValidate: true, + }); }, })} /> @@ -812,35 +944,47 @@ const CreateTenantWizard = (): ReactElement => {
{/* Organization Address Section */}
-

Organization Address

+

+ Organization Address +

@@ -848,10 +992,15 @@ const CreateTenantWizard = (): ReactElement => { label="Postal Code" required placeholder="Enter postal code" - error={contactDetailsForm.formState.errors.postal_code?.message} - {...contactDetailsForm.register('postal_code', { + error={ + contactDetailsForm.formState.errors.postal_code + ?.message + } + {...contactDetailsForm.register("postal_code", { onChange: (e) => { - e.target.value = e.target.value.replace(/\D/g, '').slice(0, 6); + e.target.value = e.target.value + .replace(/\D/g, "") + .slice(0, 6); }, })} /> @@ -859,8 +1008,10 @@ const CreateTenantWizard = (): ReactElement => { label="Country" required placeholder="Enter country" - error={contactDetailsForm.formState.errors.country?.message} - {...contactDetailsForm.register('country')} + error={ + contactDetailsForm.formState.errors.country?.message + } + {...contactDetailsForm.register("country")} />
@@ -873,7 +1024,9 @@ const CreateTenantWizard = (): ReactElement => { {currentStep === 3 && (
-

Configuration & Limits

+

+ Configuration & Limits +

Set resource limits and security preferences for this tenant.

@@ -881,7 +1034,9 @@ const CreateTenantWizard = (): ReactElement => { {/* General Error Display */} {settingsForm.formState.errors.root && (
-

{settingsForm.formState.errors.root.message}

+

+ {settingsForm.formState.errors.root.message} +

)} @@ -889,9 +1044,12 @@ const CreateTenantWizard = (): ReactElement => {
{/* Section Header */}
-

Branding

+

+ Branding +

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

@@ -910,8 +1068,12 @@ const CreateTenantWizard = (): ReactElement => {
- Upload Logo - PNG, SVG, JPG up to 2MB. + + Upload Logo + + + PNG, SVG, JPG up to 2MB. +
{ // Validate file size (2MB max) if (file.size > 2 * 1024 * 1024) { - showToast.error('Logo file size must be less than 2MB'); + showToast.error( + "Logo file size must be less than 2MB", + ); return; } // Validate file type - const validTypes = ['image/png', 'image/svg+xml', 'image/jpeg', 'image/jpg']; + const validTypes = [ + "image/png", + "image/svg+xml", + "image/jpeg", + "image/jpg", + ]; if (!validTypes.includes(file.type)) { - showToast.error('Logo must be PNG, SVG, or JPG format'); + showToast.error( + "Logo must be PNG, SVG, or JPG format", + ); return; } // Create local preview URL immediately @@ -942,7 +1113,11 @@ const CreateTenantWizard = (): ReactElement => { setLogoPreviewUrl(previewUrl); setIsUploadingLogo(true); try { - const response = await fileService.upload(file, 'tenant', wizardId); + const response = await fileService.upload( + file, + "tenant", + crypto.randomUUID(), + ); const fileId = response.data.id; setLogoFileAttachmentUuid(fileId); @@ -952,13 +1127,13 @@ const CreateTenantWizard = (): ReactElement => { setLogoFileUrl(formattedUrl); setLogoError(null); - showToast.success('Logo uploaded successfully'); + showToast.success("Logo uploaded successfully"); } catch (err: any) { const errorMessage = err?.response?.data?.error?.message || err?.response?.data?.message || err?.message || - 'Failed to upload logo. Please try again.'; + "Failed to upload logo. Please try again."; showToast.error(errorMessage); setLogoFile(null); URL.revokeObjectURL(previewUrl); @@ -980,7 +1155,9 @@ const CreateTenantWizard = (): ReactElement => {
{logoFile && (
- {isUploadingLogo ? 'Uploading...' : `Selected: ${logoFile.name}`} + {isUploadingLogo + ? "Uploading..." + : `Selected: ${logoFile.name}`}
)} {(logoPreviewUrl || logoFileUrl) && ( @@ -991,7 +1168,7 @@ const CreateTenantWizard = (): ReactElement => { src={logoPreviewUrl || logoFileUrl} alt="Logo preview" className="max-w-full h-20 object-contain border border-[#d1d5db] rounded-md p-2 bg-white" - style={{ display: 'block', maxHeight: '80px' }} + style={{ display: "block", maxHeight: "80px" }} />
- Upload Favicon - ICO or PNG up to 500KB. + + Upload Favicon + + + ICO or PNG up to 500KB. +
{ // Validate file size (500KB max) if (file.size > 500 * 1024) { - showToast.error('Favicon file size must be less than 500KB'); + showToast.error( + "Favicon file size must be less than 500KB", + ); return; } // Validate file type - const validTypes = ['image/x-icon', 'image/png', 'image/vnd.microsoft.icon']; + const validTypes = [ + "image/x-icon", + "image/png", + "image/vnd.microsoft.icon", + ]; if (!validTypes.includes(file.type)) { - showToast.error('Favicon must be ICO or PNG format'); + showToast.error( + "Favicon must be ICO or PNG format", + ); return; } // Create local preview URL immediately @@ -1052,7 +1241,11 @@ const CreateTenantWizard = (): ReactElement => { setFaviconPreviewUrl(previewUrl); setIsUploadingFavicon(true); try { - const response = await fileService.upload(file, 'tenant', wizardId); + const response = await fileService.upload( + file, + "tenant", + crypto.randomUUID(), + ); const fileId = response.data.id; setFaviconFileAttachmentUuid(fileId); @@ -1062,13 +1255,15 @@ const CreateTenantWizard = (): ReactElement => { setFaviconFileUrl(formattedUrl); setFaviconError(null); - showToast.success('Favicon uploaded successfully'); + showToast.success( + "Favicon uploaded successfully", + ); } catch (err: any) { const errorMessage = err?.response?.data?.error?.message || err?.response?.data?.message || err?.message || - 'Failed to upload favicon. Please try again.'; + "Failed to upload favicon. Please try again."; showToast.error(errorMessage); setFaviconFile(null); URL.revokeObjectURL(previewUrl); @@ -1090,18 +1285,24 @@ const CreateTenantWizard = (): ReactElement => {
{faviconFile && (
- {isUploadingFavicon ? 'Uploading...' : `Selected: ${faviconFile.name}`} + {isUploadingFavicon + ? "Uploading..." + : `Selected: ${faviconFile.name}`}
)} {(faviconPreviewUrl || faviconFileUrl) && (
- Upload Favicon - ICO or PNG up to 500KB. + + Upload Favicon + + + ICO or PNG up to 500KB. +
{ const file = e.target.files?.[0]; if (file) { if (file.size > 500 * 1024) { - showToast.error('Favicon file size must be less than 500KB'); + showToast.error( + "Favicon file size must be less than 500KB", + ); return; } - const validTypes = ['image/x-icon', 'image/png', 'image/vnd.microsoft.icon']; + const validTypes = [ + "image/x-icon", + "image/png", + "image/vnd.microsoft.icon", + ]; if (!validTypes.includes(file.type)) { - showToast.error('Favicon must be ICO or PNG format'); + showToast.error( + "Favicon must be ICO or PNG format", + ); return; } if (faviconPreviewUrl) { @@ -1143,8 +1355,13 @@ const EditTenant = (): ReactElement => { setFaviconPreviewUrl(previewUrl); setIsUploadingFavicon(true); try { - const slug = tenantDetailsForm.getValues('slug'); - const response = await fileService.upload(file, slug || 'tenant', id, id); + const slug = tenantDetailsForm.getValues("slug"); + const response = await fileService.upload( + file, + slug || "tenant", + crypto.randomUUID(), + id, + ); const fileId = response.data.id; setFaviconFileAttachmentUuid(fileId); @@ -1155,13 +1372,15 @@ const EditTenant = (): ReactElement => { setFaviconPreviewUrl(formattedUrl); setFaviconError(null); - showToast.success('Favicon uploaded successfully'); + showToast.success( + "Favicon uploaded successfully", + ); } catch (err: any) { const errorMessage = err?.response?.data?.error?.message || err?.response?.data?.message || err?.message || - 'Failed to upload favicon. Please try again.'; + "Failed to upload favicon. Please try again."; showToast.error(errorMessage); setFaviconFile(null); URL.revokeObjectURL(previewUrl); @@ -1184,7 +1403,9 @@ const EditTenant = (): ReactElement => {
{faviconFile && (
- {isUploadingFavicon ? 'Uploading...' : `Selected: ${faviconFile.name}`} + {isUploadingFavicon + ? "Uploading..." + : `Selected: ${faviconFile.name}`}
)} {(faviconPreviewUrl || faviconFileUrl) && ( @@ -1195,7 +1416,11 @@ const EditTenant = (): ReactElement => { src={faviconPreviewUrl || faviconFileUrl} alt="Favicon preview" className="w-16 h-16 object-contain border border-[#d1d5db] rounded-md p-2 bg-white" - style={{ display: 'block', width: '64px', height: '64px' }} + style={{ + display: "block", + width: "64px", + height: "64px", + }} /> -
- )} -
- )} -
- - {/* Favicon */} -
- - - {faviconError && ( -

{faviconError}

- )} - {(faviconFile || faviconFileUrl) && ( -
- {faviconFile && ( -
- {isUploadingFavicon ? 'Uploading...' : `Selected: ${faviconFile.name}`} -
- )} - {(faviconPreviewUrl || faviconFileUrl) && ( -
- - -
- )} -
- )} -
-
- - {/* Primary Color */} -
- -
-
-
- setPrimaryColor(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" - /> -
- setPrimaryColor(e.target.value)} - className="size-10 rounded-md border border-[#d1d5db] cursor-pointer" - /> -
-

- Used for navigation, headers, and key actions. -

-
- - {/* Secondary and Accent Colors */} -
- {/* Secondary Color */} -
- -
-
-
- setSecondaryColor(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" - /> -
- setSecondaryColor(e.target.value)} - className="size-10 rounded-md border border-[#d1d5db] cursor-pointer" - /> -
-

- Used for highlights and supporting elements. -

-
- - {/* Accent Color */} -
- -
-
-
- setAccentColor(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" - /> -
- setAccentColor(e.target.value)} - className="size-10 rounded-md border border-[#d1d5db] cursor-pointer" - /> -
-

- Used for alerts and special notices. -

-
-
- - {/* Save Button */} -
- - {isSaving ? 'Saving...' : 'Save Changes'} - -
-
-
- + +
+ +
+
); + } + + if (error && !tenant) { + return ( + +
+

{error}

+
+
+ ); + } + + return ( + +
+ {error && ( +
+

{error}

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

Branding

+

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

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

{logoError}

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

{faviconError}

+ )} + {(faviconFile || faviconFileUrl) && ( +
+ {faviconFile && ( +
+ {isUploadingFavicon + ? "Uploading..." + : `Selected: ${faviconFile.name}`} +
+ )} + {(faviconPreviewUrl || faviconFileUrl) && ( +
+ + +
+ )} +
+ )} +
+
+ + {/* Primary Color */} +
+ +
+
+
+ setPrimaryColor(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" + /> +
+ setPrimaryColor(e.target.value)} + className="size-10 rounded-md border border-[#d1d5db] cursor-pointer" + /> +
+

+ Used for navigation, headers, and key actions. +

+
+ + {/* Secondary and Accent Colors */} +
+ {/* Secondary Color */} +
+ +
+
+
+ setSecondaryColor(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" + /> +
+ setSecondaryColor(e.target.value)} + className="size-10 rounded-md border border-[#d1d5db] cursor-pointer" + /> +
+

+ Used for highlights and supporting elements. +

+
+ + {/* Accent Color */} +
+ +
+
+
+ setAccentColor(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" + /> +
+ setAccentColor(e.target.value)} + className="size-10 rounded-md border border-[#d1d5db] cursor-pointer" + /> +
+

+ Used for alerts and special notices. +

+
+
+ + {/* Save Button */} +
+ + {isSaving ? "Saving..." : "Save Changes"} + +
+
+
+ + ); }; export default Settings;