Enhance tenant management UI by adding branding customization options in CreateTenantWizard and TenantDetails components. Implement file upload functionality for logo and favicon, and allow users to set primary, secondary, and accent colors for tenant branding. Update settings schema to accommodate new branding fields.

This commit is contained in:
Yashwin 2026-01-27 19:44:40 +05:30
parent f07db4040e
commit 4226f67923
2 changed files with 444 additions and 4 deletions

View File

@ -9,7 +9,7 @@ import { FormField, FormSelect, PrimaryButton, SecondaryButton, MultiselectPagin
import { tenantService } from '@/services/tenant-service'; import { tenantService } from '@/services/tenant-service';
import { moduleService } from '@/services/module-service'; import { moduleService } from '@/services/module-service';
import { showToast } from '@/utils/toast'; import { showToast } from '@/utils/toast';
import { ChevronRight, ChevronLeft } from 'lucide-react'; import { ChevronRight, ChevronLeft, Image as ImageIcon } from 'lucide-react';
// Step 1: Tenant Details Schema - matches NewTenantModal // Step 1: Tenant Details Schema - matches NewTenantModal
const tenantDetailsSchema = z.object({ const tenantDetailsSchema = z.object({
@ -61,6 +61,9 @@ const contactDetailsSchema = z
const settingsSchema = z.object({ const settingsSchema = z.object({
enable_sso: z.boolean(), enable_sso: z.boolean(),
enable_2fa: 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<typeof tenantDetailsSchema>; type TenantDetailsForm = z.infer<typeof tenantDetailsSchema>;
@ -144,9 +147,16 @@ const CreateTenantWizard = (): ReactElement => {
defaultValues: { defaultValues: {
enable_sso: false, enable_sso: false,
enable_2fa: false, enable_2fa: false,
primary_color: '#112868',
secondary_color: '#23DCE1',
accent_color: '#084CC8',
}, },
}); });
// File upload state for branding
const [logoFile, setLogoFile] = useState<File | null>(null);
const [faviconFile, setFaviconFile] = useState<File | null>(null);
// Auto-generate slug and domain from name // Auto-generate slug and domain from name
const nameValue = tenantDetailsForm.watch('name'); const nameValue = tenantDetailsForm.watch('name');
const baseUrlWithoutProtocol = getBaseUrlWithoutProtocol(); const baseUrlWithoutProtocol = getBaseUrlWithoutProtocol();
@ -265,12 +275,25 @@ const CreateTenantWizard = (): ReactElement => {
// Extract confirmPassword from contactDetails (not needed in API call) // Extract confirmPassword from contactDetails (not needed in API call)
const { confirmPassword, ...contactData } = contactDetails; const { confirmPassword, ...contactData } = contactDetails;
// Extract branding colors from settings
const { enable_sso, enable_2fa, primary_color, secondary_color, accent_color } = settings;
const tenantData = { const tenantData = {
...restTenantDetails, ...restTenantDetails,
module_ids: selectedModules.length > 0 ? selectedModules : undefined, module_ids: selectedModules.length > 0 ? selectedModules : undefined,
settings: { settings: {
...settings, enable_sso,
enable_2fa,
contact: contactData, // Include first_name, last_name, email, password contact: contactData, // Include first_name, last_name, email, password
branding: {
primary_color: primary_color || undefined,
secondary_color: secondary_color || undefined,
accent_color: accent_color || undefined,
// Note: logo and favicon files would need to be uploaded separately via FormData
// For now, we're just storing the file references
logo_file: logoFile ? logoFile.name : undefined,
favicon_file: faviconFile ? faviconFile.name : undefined,
},
}, },
}; };
@ -663,13 +686,209 @@ const CreateTenantWizard = (): ReactElement => {
{/* Step 3: Settings */} {/* Step 3: Settings */}
{currentStep === 3 && ( {currentStep === 3 && (
<div className="space-y-4"> <div className="space-y-6">
<div className="pb-4 border-b border-[rgba(0,0,0,0.08)]"> <div className="pb-4 border-b border-[rgba(0,0,0,0.08)]">
<h2 className="text-lg font-semibold text-[#0f1724]">Configuration & Limits</h2> <h2 className="text-lg font-semibold text-[#0f1724]">Configuration & Limits</h2>
<p className="text-sm text-[#6b7280] mt-1"> <p className="text-sm text-[#6b7280] mt-1">
Set resource limits and security preferences for this tenant. Set resource limits and security preferences for this tenant.
</p> </p>
</div> </div>
{/* Branding Section */}
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-lg p-4 md:p-6 flex flex-col gap-4">
{/* Section Header */}
<div className="flex flex-col gap-1">
<h3 className="text-base font-semibold text-[#0f1724]">Branding</h3>
<p className="text-sm font-normal text-[#9ca3af]">
Customize logo, favicon, and colors for this tenant experience.
</p>
</div>
{/* Logo and Favicon Upload */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
{/* Company Logo */}
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-[#0f1724]">Company Logo</label>
<label
htmlFor="logo-upload-wizard"
className="bg-[#f5f7fa] border border-dashed border-[#d1d5db] rounded-md p-4 flex gap-4 items-center cursor-pointer hover:bg-[#e5e7eb] transition-colors"
>
<div className="bg-white border border-[#d1d5db] rounded-lg size-12 flex items-center justify-center shrink-0">
<ImageIcon className="w-5 h-5 text-[#6b7280]" />
</div>
<div className="flex flex-col gap-0.5 flex-1 min-w-0">
<span className="text-sm font-medium text-[#0f1724]">Upload Logo</span>
<span className="text-xs font-normal text-[#9ca3af]">PNG, SVG, JPG up to 2MB.</span>
</div>
<input
id="logo-upload-wizard"
type="file"
accept="image/png,image/svg+xml,image/jpeg,image/jpg"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
// Validate file size (2MB max)
if (file.size > 2 * 1024 * 1024) {
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'];
if (!validTypes.includes(file.type)) {
showToast.error('Logo must be PNG, SVG, or JPG format');
return;
}
setLogoFile(file);
}
}}
className="hidden"
/>
</label>
{logoFile && (
<div className="text-xs text-[#6b7280] mt-1">
Selected: {logoFile.name}
</div>
)}
</div>
{/* Favicon */}
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-[#0f1724]">Favicon</label>
<label
htmlFor="favicon-upload-wizard"
className="bg-[#f5f7fa] border border-dashed border-[#d1d5db] rounded-md p-4 flex gap-4 items-center cursor-pointer hover:bg-[#e5e7eb] transition-colors"
>
<div className="bg-white border border-[#d1d5db] rounded-lg size-12 flex items-center justify-center shrink-0">
<ImageIcon className="w-5 h-5 text-[#6b7280]" />
</div>
<div className="flex flex-col gap-0.5 flex-1 min-w-0">
<span className="text-sm font-medium text-[#0f1724]">Upload Favicon</span>
<span className="text-xs font-normal text-[#9ca3af]">ICO or PNG up to 500KB.</span>
</div>
<input
id="favicon-upload-wizard"
type="file"
accept="image/x-icon,image/png,image/vnd.microsoft.icon"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
// Validate file size (500KB max)
if (file.size > 500 * 1024) {
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'];
if (!validTypes.includes(file.type)) {
showToast.error('Favicon must be ICO or PNG format');
return;
}
setFaviconFile(file);
}
}}
className="hidden"
/>
</label>
{faviconFile && (
<div className="text-xs text-[#6b7280] mt-1">
Selected: {faviconFile.name}
</div>
)}
</div>
</div>
{/* Primary Color */}
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-[#0f1724]">Primary Color</label>
<div className="flex gap-3 items-center">
<div
className="border border-[#d1d5db] rounded-md size-10 shrink-0"
style={{ backgroundColor: settingsForm.watch('primary_color') || '#112868' }}
/>
<div className="flex-1">
<input
type="text"
value={settingsForm.watch('primary_color') || '#112868'}
onChange={(e) => 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"
/>
</div>
<input
type="color"
value={settingsForm.watch('primary_color') || '#112868'}
onChange={(e) => settingsForm.setValue('primary_color', e.target.value)}
className="size-10 rounded-md border border-[#d1d5db] cursor-pointer"
/>
</div>
<p className="text-xs font-normal text-[#9ca3af]">
Used for navigation, headers, and key actions.
</p>
</div>
{/* Secondary and Accent Colors */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
{/* Secondary Color */}
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-[#0f1724]">Secondary Color</label>
<div className="flex gap-3 items-center">
<div
className="border border-[#d1d5db] rounded-md size-10 shrink-0"
style={{ backgroundColor: settingsForm.watch('secondary_color') || '#23DCE1' }}
/>
<div className="flex-1">
<input
type="text"
value={settingsForm.watch('secondary_color') || '#23DCE1'}
onChange={(e) => 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"
/>
</div>
<input
type="color"
value={settingsForm.watch('secondary_color') || '#23DCE1'}
onChange={(e) => settingsForm.setValue('secondary_color', e.target.value)}
className="size-10 rounded-md border border-[#d1d5db] cursor-pointer"
/>
</div>
<p className="text-xs font-normal text-[#9ca3af]">
Used for highlights and supporting elements.
</p>
</div>
{/* Accent Color */}
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-[#0f1724]">Accent Color</label>
<div className="flex gap-3 items-center">
<div
className="border border-[#d1d5db] rounded-md size-10 shrink-0"
style={{ backgroundColor: settingsForm.watch('accent_color') || '#084CC8' }}
/>
<div className="flex-1">
<input
type="text"
value={settingsForm.watch('accent_color') || '#084CC8'}
onChange={(e) => 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"
/>
</div>
<input
type="color"
value={settingsForm.watch('accent_color') || '#084CC8'}
onChange={(e) => settingsForm.setValue('accent_color', e.target.value)}
className="size-10 rounded-md border border-[#d1d5db] cursor-pointer"
/>
</div>
<p className="text-xs font-normal text-[#9ca3af]">
Used for alerts and special notices.
</p>
</div>
</div>
</div>
{/* Security Settings */}
<div className="space-y-4"> <div className="space-y-4">
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-md p-4 flex items-center justify-between"> <div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-md p-4 flex items-center justify-between">
<div> <div>

View File

@ -12,6 +12,8 @@ import {
Edit, Edit,
CheckCircle2, CheckCircle2,
XCircle, XCircle,
Settings,
Image as ImageIcon,
} from 'lucide-react'; } from 'lucide-react';
import { Layout } from '@/components/layout/Layout'; import { Layout } from '@/components/layout/Layout';
import { import {
@ -28,13 +30,14 @@ import type { Tenant, AssignedModule } from '@/types/tenant';
import type { AuditLog } from '@/types/audit-log'; import type { AuditLog } from '@/types/audit-log';
import { formatDate } from '@/utils/format-date'; import { formatDate } from '@/utils/format-date';
type TabType = 'overview' | 'users' | 'roles' | 'modules' | 'license' | 'audit-logs' | 'billing'; type TabType = 'overview' | 'users' | 'roles' | 'modules' | 'settings' | 'license' | 'audit-logs' | 'billing';
const tabs: Array<{ id: TabType; label: string; icon: ReactElement }> = [ const tabs: Array<{ id: TabType; label: string; icon: ReactElement }> = [
{ id: 'overview', label: 'Overview', icon: <FileText className="w-4 h-4" /> }, { id: 'overview', label: 'Overview', icon: <FileText className="w-4 h-4" /> },
{ id: 'users', label: 'Users', icon: <Users className="w-4 h-4" /> }, { id: 'users', label: 'Users', icon: <Users className="w-4 h-4" /> },
{ id: 'roles', label: 'Roles', icon: <FileText className="w-4 h-4" /> }, { id: 'roles', label: 'Roles', icon: <FileText className="w-4 h-4" /> },
{ id: 'modules', label: 'Modules', icon: <Package className="w-4 h-4" /> }, { id: 'modules', label: 'Modules', icon: <Package className="w-4 h-4" /> },
{ id: 'settings', label: 'Settings', icon: <Settings className="w-4 h-4" /> },
{ id: 'license', label: 'License', icon: <FileText className="w-4 h-4" /> }, { id: 'license', label: 'License', icon: <FileText className="w-4 h-4" /> },
{ id: 'audit-logs', label: 'Audit Logs', icon: <History className="w-4 h-4" /> }, { id: 'audit-logs', label: 'Audit Logs', icon: <History className="w-4 h-4" /> },
{ id: 'billing', label: 'Billing', icon: <CreditCard className="w-4 h-4" /> }, { id: 'billing', label: 'Billing', icon: <CreditCard className="w-4 h-4" /> },
@ -276,6 +279,9 @@ const TenantDetails = (): ReactElement => {
modules={tenant.assignedModules || []} modules={tenant.assignedModules || []}
/> />
)} )}
{activeTab === 'settings' && tenant && (
<SettingsTab tenant={tenant} />
)}
{activeTab === 'license' && <LicenseTab tenant={tenant} />} {activeTab === 'license' && <LicenseTab tenant={tenant} />}
{activeTab === 'audit-logs' && ( {activeTab === 'audit-logs' && (
<AuditLogsTab <AuditLogsTab
@ -606,6 +612,221 @@ const AuditLogsTab = ({
); );
}; };
// Settings Tab Component
interface SettingsTabProps {
tenant: Tenant;
}
const SettingsTab = ({ tenant: _tenant }: SettingsTabProps): ReactElement => {
const [logoFile, setLogoFile] = useState<File | null>(null);
const [faviconFile, setFaviconFile] = useState<File | null>(null);
const [primaryColor, setPrimaryColor] = useState<string>('#112868');
const [secondaryColor, setSecondaryColor] = useState<string>('#23DCE1');
const [accentColor, setAccentColor] = useState<string>('#084CC8');
const handleLogoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
// Validate file size (2MB max)
if (file.size > 2 * 1024 * 1024) {
alert('Logo file size must be less than 2MB');
return;
}
// Validate file type
const validTypes = ['image/png', 'image/svg+xml', 'image/jpeg', 'image/jpg'];
if (!validTypes.includes(file.type)) {
alert('Logo must be PNG, SVG, or JPG format');
return;
}
setLogoFile(file);
}
};
const handleFaviconChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
// Validate file size (500KB max)
if (file.size > 500 * 1024) {
alert('Favicon file size must be less than 500KB');
return;
}
// Validate file type
const validTypes = ['image/x-icon', 'image/png', 'image/vnd.microsoft.icon'];
if (!validTypes.includes(file.type)) {
alert('Favicon must be ICO or PNG format');
return;
}
setFaviconFile(file);
}
};
return (
<div className="flex flex-col gap-6">
{/* Branding Section */}
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-lg p-4 md:p-6 flex flex-col gap-4">
{/* Section Header */}
<div className="flex flex-col gap-1">
<h3 className="text-base font-semibold text-[#0f1724]">Branding</h3>
<p className="text-sm font-normal text-[#9ca3af]">
Customize logo, favicon, and colors for this tenant experience.
</p>
</div>
{/* Logo and Favicon Upload */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
{/* Company Logo */}
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-[#0f1724]">Company Logo</label>
<label
htmlFor="logo-upload"
className="bg-[#f5f7fa] border border-dashed border-[#d1d5db] rounded-md p-4 flex gap-4 items-center cursor-pointer hover:bg-[#e5e7eb] transition-colors"
>
<div className="bg-white border border-[#d1d5db] rounded-lg size-12 flex items-center justify-center shrink-0">
<ImageIcon className="w-5 h-5 text-[#6b7280]" />
</div>
<div className="flex flex-col gap-0.5 flex-1 min-w-0">
<span className="text-sm font-medium text-[#0f1724]">Upload Logo</span>
<span className="text-xs font-normal text-[#9ca3af]">PNG, SVG, JPG up to 2MB.</span>
</div>
<input
id="logo-upload"
type="file"
accept="image/png,image/svg+xml,image/jpeg,image/jpg"
onChange={handleLogoChange}
className="hidden"
/>
</label>
{logoFile && (
<div className="text-xs text-[#6b7280] mt-1">
Selected: {logoFile.name}
</div>
)}
</div>
{/* Favicon */}
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-[#0f1724]">Favicon</label>
<label
htmlFor="favicon-upload"
className="bg-[#f5f7fa] border border-dashed border-[#d1d5db] rounded-md p-4 flex gap-4 items-center cursor-pointer hover:bg-[#e5e7eb] transition-colors"
>
<div className="bg-white border border-[#d1d5db] rounded-lg size-12 flex items-center justify-center shrink-0">
<ImageIcon className="w-5 h-5 text-[#6b7280]" />
</div>
<div className="flex flex-col gap-0.5 flex-1 min-w-0">
<span className="text-sm font-medium text-[#0f1724]">Upload Favicon</span>
<span className="text-xs font-normal text-[#9ca3af]">ICO or PNG up to 500KB.</span>
</div>
<input
id="favicon-upload"
type="file"
accept="image/x-icon,image/png,image/vnd.microsoft.icon"
onChange={handleFaviconChange}
className="hidden"
/>
</label>
{faviconFile && (
<div className="text-xs text-[#6b7280] mt-1">
Selected: {faviconFile.name}
</div>
)}
</div>
</div>
{/* Primary Color */}
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-[#0f1724]">Primary Color</label>
<div className="flex gap-3 items-center">
<div
className="border border-[#d1d5db] rounded-md size-10 shrink-0"
style={{ backgroundColor: primaryColor }}
/>
<div className="flex-1">
<input
type="text"
value={primaryColor}
onChange={(e) => 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"
/>
</div>
<input
type="color"
value={primaryColor}
onChange={(e) => setPrimaryColor(e.target.value)}
className="size-10 rounded-md border border-[#d1d5db] cursor-pointer"
/>
</div>
<p className="text-xs font-normal text-[#9ca3af]">
Used for navigation, headers, and key actions.
</p>
</div>
{/* Secondary and Accent Colors */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
{/* Secondary Color */}
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-[#0f1724]">Secondary Color</label>
<div className="flex gap-3 items-center">
<div
className="border border-[#d1d5db] rounded-md size-10 shrink-0"
style={{ backgroundColor: secondaryColor }}
/>
<div className="flex-1">
<input
type="text"
value={secondaryColor}
onChange={(e) => 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"
/>
</div>
<input
type="color"
value={secondaryColor}
onChange={(e) => setSecondaryColor(e.target.value)}
className="size-10 rounded-md border border-[#d1d5db] cursor-pointer"
/>
</div>
<p className="text-xs font-normal text-[#9ca3af]">
Used for highlights and supporting elements.
</p>
</div>
{/* Accent Color */}
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-[#0f1724]">Accent Color</label>
<div className="flex gap-3 items-center">
<div
className="border border-[#d1d5db] rounded-md size-10 shrink-0"
style={{ backgroundColor: accentColor }}
/>
<div className="flex-1">
<input
type="text"
value={accentColor}
onChange={(e) => 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"
/>
</div>
<input
type="color"
value={accentColor}
onChange={(e) => setAccentColor(e.target.value)}
className="size-10 rounded-md border border-[#d1d5db] cursor-pointer"
/>
</div>
<p className="text-xs font-normal text-[#9ca3af]">
Used for alerts and special notices.
</p>
</div>
</div>
</div>
</div>
);
};
// Billing Tab Component // Billing Tab Component
interface BillingTabProps { interface BillingTabProps {
tenant: Tenant; tenant: Tenant;