Qassure-frontend/src/pages/tenant/Settings.tsx

731 lines
25 KiB
TypeScript

import { Layout } from "@/components/layout/Layout";
import { ImageIcon, Loader2, X } from "lucide-react";
import { useState, useEffect, type ReactElement } from "react";
import { useAppSelector, useAppDispatch } from "@/hooks/redux-hooks";
import { tenantService } from "@/services/tenant-service";
import { fileService } from "@/services/file-service";
import { showToast } from "@/utils/toast";
import { updateTheme } from "@/store/themeSlice";
import { PrimaryButton, AuthenticatedImage } from "@/components/shared";
import { generateUUID } from "@/lib/utils";
import type { Tenant } from "@/types/tenant";
// Helper function to get base URL with protocol
const getBaseUrlWithProtocol = (): string => {
return import.meta.env.VITE_API_BASE_URL || "http://localhost:3000";
};
export interface SettingsProps {
customTenantId?: string;
hideLayout?: boolean;
}
const Settings = ({ customTenantId, hideLayout = false }: SettingsProps = {}): ReactElement => {
const authTenantId = useAppSelector((state) => state.auth.tenantId);
const tenantId = customTenantId || authTenantId;
const dispatch = useAppDispatch();
const [isLoading, setIsLoading] = useState<boolean>(true);
const [isSaving, setIsSaving] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
// Tenant data
const [tenant, setTenant] = useState<Tenant | null>(null);
// Color states
const [primaryColor, setPrimaryColor] = useState<string>("#112868");
const [secondaryColor, setSecondaryColor] = useState<string>("#23DCE1");
const [accentColor, setAccentColor] = useState<string>("#084CC8");
// Logo states
const [logoFile, setLogoFile] = useState<File | null>(null);
const [logoFilePath, setLogoFilePath] = useState<string | null>(null);
const [logoFileAttachmentUuid, setLogoFileAttachmentUuid] = useState<
string | null
>(null);
const [logoFileUrl, setLogoFileUrl] = useState<string | null>(null);
const [logoPreviewUrl, setLogoPreviewUrl] = useState<string | null>(null);
const [isUploadingLogo, setIsUploadingLogo] = useState<boolean>(false);
// Favicon states
const [faviconFile, setFaviconFile] = useState<File | null>(null);
const [faviconFilePath, setFaviconFilePath] = useState<string | null>(null);
const [faviconFileAttachmentUuid, setFaviconFileAttachmentUuid] = useState<
string | null
>(null);
const [faviconFileUrl, setFaviconFileUrl] = useState<string | null>(null);
const [faviconPreviewUrl, setFaviconPreviewUrl] = useState<string | null>(
null,
);
const [isUploadingFavicon, setIsUploadingFavicon] = useState<boolean>(false);
const [logoError, setLogoError] = useState<string | null>(null);
const [faviconError, setFaviconError] = useState<string | null>(null);
// Fetch tenant data on mount
useEffect(() => {
const fetchTenant = async (): Promise<void> => {
if (!tenantId) {
setError("Tenant ID not found");
setIsLoading(false);
return;
}
try {
setIsLoading(true);
setError(null);
const response = await tenantService.getById(tenantId);
if (response.success && response.data) {
const tenantData = response.data;
setTenant(tenantData);
// Set colors
setPrimaryColor(tenantData.primary_color || "#112868");
setSecondaryColor(tenantData.secondary_color || "#23DCE1");
setAccentColor(tenantData.accent_color || "#084CC8");
// Set logo
if (tenantData.logo_file_path) {
setLogoFileUrl(tenantData.logo_file_path);
setLogoFilePath(tenantData.logo_file_path);
setLogoFileAttachmentUuid(
tenantData.logo_file_attachment_uuid || null,
);
setLogoError(null);
}
// Set favicon
if (tenantData.favicon_file_path) {
setFaviconFileUrl(tenantData.favicon_file_path);
setFaviconFilePath(tenantData.favicon_file_path);
setFaviconFileAttachmentUuid(
tenantData.favicon_file_attachment_uuid || null,
);
setFaviconError(null);
}
}
} catch (err: any) {
const errorMessage =
err?.response?.data?.error?.message ||
err?.response?.data?.message ||
err?.message ||
"Failed to load tenant settings";
setError(errorMessage);
showToast.error(errorMessage);
} finally {
setIsLoading(false);
}
};
fetchTenant();
}, [tenantId]);
// Cleanup preview URLs
useEffect(() => {
return () => {
if (logoPreviewUrl) {
URL.revokeObjectURL(logoPreviewUrl);
}
if (faviconPreviewUrl) {
URL.revokeObjectURL(faviconPreviewUrl);
}
};
}, [logoPreviewUrl, faviconPreviewUrl]);
const handleLogoChange = async (
e: React.ChangeEvent<HTMLInputElement>,
): Promise<void> => {
const file = e.target.files?.[0];
if (!file) return;
// 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;
}
// Revoke previous preview URL
if (logoPreviewUrl) {
URL.revokeObjectURL(logoPreviewUrl);
}
const previewUrl = URL.createObjectURL(file);
setLogoFile(file);
setLogoPreviewUrl(previewUrl);
setIsUploadingLogo(true);
try {
const response = await fileService.upload(
file,
"tenant",
generateUUID(),
tenantId || undefined,
);
const fileId = response.data.id;
setLogoFileAttachmentUuid(fileId);
const baseUrl = getBaseUrlWithProtocol();
const formattedUrl = `${baseUrl}/files/${fileId}/preview`;
setLogoFilePath(formattedUrl);
setLogoFileUrl(formattedUrl);
setLogoError(null);
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.";
showToast.error(errorMessage);
setLogoFile(null);
URL.revokeObjectURL(previewUrl);
setLogoPreviewUrl(null);
setLogoFileUrl(null);
setLogoFilePath(null);
setLogoFileAttachmentUuid(null);
} finally {
setIsUploadingLogo(false);
}
};
const handleFaviconChange = async (
e: React.ChangeEvent<HTMLInputElement>,
): Promise<void> => {
const file = e.target.files?.[0];
if (!file) return;
// 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;
}
// Revoke previous preview URL
if (faviconPreviewUrl) {
URL.revokeObjectURL(faviconPreviewUrl);
}
const previewUrl = URL.createObjectURL(file);
setFaviconFile(file);
setFaviconPreviewUrl(previewUrl);
setIsUploadingFavicon(true);
try {
const response = await fileService.upload(
file,
"tenant",
generateUUID(),
tenantId || undefined,
);
const fileId = response.data.id;
setFaviconFileAttachmentUuid(fileId);
const baseUrl = getBaseUrlWithProtocol();
const formattedUrl = `${baseUrl}/files/${fileId}/preview`;
setFaviconFilePath(formattedUrl);
setFaviconFileUrl(formattedUrl);
setFaviconError(null);
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.";
showToast.error(errorMessage);
setFaviconFile(null);
URL.revokeObjectURL(previewUrl);
setFaviconPreviewUrl(null);
setFaviconFileUrl(null);
setFaviconFilePath(null);
setFaviconFileAttachmentUuid(null);
} finally {
setIsUploadingFavicon(false);
}
};
const handleDeleteLogo = (): void => {
if (logoPreviewUrl) {
URL.revokeObjectURL(logoPreviewUrl);
}
setLogoFile(null);
setLogoPreviewUrl(null);
setLogoFileUrl(null);
setLogoFilePath(null);
setLogoFileAttachmentUuid(null);
setLogoError(null);
// Reset the file input
const fileInput = document.getElementById(
"logo-upload",
) as HTMLInputElement;
if (fileInput) {
fileInput.value = "";
}
};
const handleDeleteFavicon = (): void => {
if (faviconPreviewUrl) {
URL.revokeObjectURL(faviconPreviewUrl);
}
setFaviconFile(null);
setFaviconPreviewUrl(null);
setFaviconFileUrl(null);
setFaviconFilePath(null);
setFaviconFileAttachmentUuid(null);
setFaviconError(null);
// Reset the file input
const fileInput = document.getElementById(
"favicon-upload",
) as HTMLInputElement;
if (fileInput) {
fileInput.value = "";
}
};
const handleSave = async (): Promise<void> => {
if (!tenantId || !tenant) return;
// Validate logo and favicon are uploaded
setLogoError(null);
setFaviconError(null);
const isLogoMissing = !logoFilePath;
const isFaviconMissing = !faviconFilePath;
if (isLogoMissing) {
setLogoError("Logo is required");
}
if (isFaviconMissing) {
setFaviconError("Favicon is required");
}
if (isLogoMissing || isFaviconMissing) {
return;
}
try {
setIsSaving(true);
setError(null);
// Build update data matching EditTenantModal format
const existingSettings =
(tenant.settings as Record<string, unknown>) || {};
const existingContact =
(existingSettings.contact as Record<string, unknown>) || {};
const updateData = {
name: tenant.name,
slug: tenant.slug,
status: tenant.status,
domain: tenant.domain || null,
subscription_tier: tenant.subscription_tier || null,
max_users: tenant.max_users || null,
max_modules: tenant.max_modules || null,
settings: {
enable_sso: tenant.enable_sso || false,
enable_2fa: tenant.enable_2fa || false,
contact: existingContact,
branding: {
primary_color: primaryColor || undefined,
secondary_color: secondaryColor || undefined,
accent_color: accentColor || undefined,
logo_file_path: logoFilePath || undefined,
logo_file_attachment_uuid: logoFileAttachmentUuid || undefined,
favicon_file_path: faviconFilePath || undefined,
favicon_file_attachment_uuid:
faviconFileAttachmentUuid || undefined,
},
},
};
const response = await tenantService.update(tenantId, updateData);
if (response.success) {
showToast.success("Settings updated successfully");
// Update theme in Redux for the active session, if not editing a different tenant context
if (!customTenantId || customTenantId === authTenantId) {
dispatch(
updateTheme({
logo_file_path: logoFilePath,
favicon_file_path: faviconFilePath,
primary_color: primaryColor,
secondary_color: secondaryColor,
accent_color: accentColor,
}),
);
}
// Update local tenant state
setTenant({
...tenant,
primary_color: primaryColor,
secondary_color: secondaryColor,
accent_color: accentColor,
logo_file_path: logoFilePath,
logo_file_attachment_uuid: logoFileAttachmentUuid,
favicon_file_path: faviconFilePath,
favicon_file_attachment_uuid: faviconFileAttachmentUuid,
});
}
} catch (err: any) {
const errorMessage =
err?.response?.data?.error?.message ||
err?.response?.data?.message ||
err?.message ||
"Failed to update settings. Please try again.";
setError(errorMessage);
showToast.error(errorMessage);
} finally {
setIsSaving(false);
}
};
if (isLoading) {
return (
<Layout
currentPage="Settings"
pageHeader={{
title: "Settings",
description: "Manage your tenant settings",
}}
>
<div className="flex items-center justify-center py-12">
<Loader2 className="w-6 h-6 text-[#112868] animate-spin" />
</div>
</Layout>
);
}
if (error && !tenant) {
return (
<Layout
currentPage="Settings"
pageHeader={{
title: "Settings",
description: "Manage your tenant settings",
}}
>
<div className="p-4 bg-[rgba(239,68,68,0.1)] border border-[#ef4444] rounded-md">
<p className="text-sm text-[#ef4444]">{error}</p>
</div>
</Layout>
);
}
const content = (
<div className="flex flex-col gap-6 w-full h-full">
{error && (
<div className="p-4 bg-[rgba(239,68,68,0.1)] border border-[#ef4444] rounded-md">
<p className="text-sm text-[#ef4444]">{error}</p>
</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 <span className="text-[#e02424]">*</span>
</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">
{isUploadingLogo ? (
<Loader2 className="w-5 h-5 text-[#6b7280] animate-spin" />
) : (
<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"
disabled={isUploadingLogo}
/>
</label>
{logoError && (
<p className="text-sm text-[#ef4444]">{logoError}</p>
)}
{(logoFile || logoFileUrl) && (
<div className="flex flex-col gap-2 mt-1">
{logoFile && (
<div className="text-xs text-[#6b7280]">
{isUploadingLogo
? "Uploading..."
: `Selected: ${logoFile.name}`}
</div>
)}
{(logoPreviewUrl || logoFileUrl) && (
<div className="mt-2 relative inline-block">
<AuthenticatedImage
fileId={logoFileAttachmentUuid}
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" }}
/>
<button
type="button"
onClick={handleDeleteLogo}
className="absolute top-1 right-1 bg-[#ef4444] text-white rounded-full p-1 hover:bg-[#dc2626] transition-colors"
aria-label="Delete logo"
>
<X className="w-3 h-3" />
</button>
</div>
)}
</div>
)}
</div>
{/* Favicon */}
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-[#0f1724]">
Favicon <span className="text-[#e02424]">*</span>
</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">
{isUploadingFavicon ? (
<Loader2 className="w-5 h-5 text-[#6b7280] animate-spin" />
) : (
<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"
disabled={isUploadingFavicon}
/>
</label>
{faviconError && (
<p className="text-sm text-[#ef4444]">{faviconError}</p>
)}
{(faviconFile || faviconFileUrl) && (
<div className="flex flex-col gap-2 mt-1">
{faviconFile && (
<div className="text-xs text-[#6b7280]">
{isUploadingFavicon
? "Uploading..."
: `Selected: ${faviconFile.name}`}
</div>
)}
{(faviconPreviewUrl || faviconFileUrl) && (
<div className="mt-2 relative inline-block">
<AuthenticatedImage
fileId={faviconFileAttachmentUuid}
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",
}}
/>
<button
type="button"
onClick={handleDeleteFavicon}
className="absolute top-1 right-1 bg-[#ef4444] text-white rounded-full p-1 hover:bg-[#dc2626] transition-colors"
aria-label="Delete favicon"
>
<X className="w-3 h-3" />
</button>
</div>
)}
</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>
{/* Save Button */}
<div className="flex justify-end pt-4 border-t border-[rgba(0,0,0,0.08)]">
<PrimaryButton
onClick={handleSave}
disabled={isSaving || isUploadingLogo || isUploadingFavicon}
className="px-6 py-2.5"
>
{isSaving ? "Saving..." : "Save Changes"}
</PrimaryButton>
</div>
</div>
</div>
);
if (hideLayout) {
return content;
}
return (
<Layout
currentPage="Settings"
pageHeader={{
title: "Settings",
description: "Manage your tenant settings",
}}
>
{content}
</Layout>
);
};
export default Settings;