feat: Implement authenticated image handling for tenant logos and favicons, utilizing file attachment UUIDs and a new AuthenticatedImage component.

This commit is contained in:
Yashwin 2026-03-06 12:41:13 +05:30
parent 757a9f216b
commit 5b03dec1d8
11 changed files with 363 additions and 153 deletions

2
.env.example Normal file
View File

@ -0,0 +1,2 @@
VITE_FRONTEND_BASE_URL=
VITE_API_BASE_URL=

View File

@ -80,6 +80,7 @@ A modern, responsive admin dashboard for managing tenants, users, roles, and sys
Edit `.env` and add your configuration: Edit `.env` and add your configuration:
```env ```env
VITE_API_BASE_URL=http://localhost:3000/api/v1 VITE_API_BASE_URL=http://localhost:3000/api/v1
VITE_FRONTEND_BASE_URL=http://localhost:5173
``` ```
4. **Start the development server** 4. **Start the development server**
@ -123,6 +124,7 @@ Create a `.env` file in the root directory:
```env ```env
VITE_API_BASE_URL=your-api-base-url VITE_API_BASE_URL=your-api-base-url
VITE_FRONTEND_BASE_URL=your-frontend-base-url
``` ```
## 🎨 Design System ## 🎨 Design System

View File

@ -13,6 +13,7 @@ import {
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useAppSelector } from '@/hooks/redux-hooks'; import { useAppSelector } from '@/hooks/redux-hooks';
import { useTenantTheme } from '@/hooks/useTenantTheme'; import { useTenantTheme } from '@/hooks/useTenantTheme';
import { AuthenticatedImage } from '@/components/shared';
interface MenuItem { interface MenuItem {
icon: React.ComponentType<{ className?: string }>; icon: React.ComponentType<{ className?: string }>;
@ -221,15 +222,10 @@ export const Sidebar = ({ isOpen, onClose }: SidebarProps) => {
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<div className="flex gap-3 items-center"> <div className="flex gap-3 items-center">
{!isSuperAdmin && logoUrl ? ( {!isSuperAdmin && logoUrl ? (
<img <AuthenticatedImage
src={logoUrl} src={logoUrl}
alt="Logo" alt="Logo"
className="h-9 w-auto max-w-[180px] object-contain" className="h-9 w-auto max-w-[180px] object-contain"
onError={(e) => {
e.currentTarget.style.display = 'none';
const fallback = e.currentTarget.nextElementSibling as HTMLElement;
if (fallback) fallback.style.display = 'flex';
}}
/> />
) : null} ) : null}
<div <div

View File

@ -0,0 +1,104 @@
import { useState, useEffect, type ImgHTMLAttributes, type ReactElement } from 'react';
import { fileService } from '@/services/file-service';
import apiClient from '@/services/api-client';
import { Loader2, ImageIcon } from 'lucide-react';
interface AuthenticatedImageProps extends Omit<ImgHTMLAttributes<HTMLImageElement>, 'src'> {
fileId?: string | null;
src?: string | null;
fallback?: ReactElement;
}
export const AuthenticatedImage = ({
fileId,
src,
fallback,
className,
alt = 'Image',
...props
}: AuthenticatedImageProps): ReactElement => {
const [blobUrl, setBlobUrl] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [error, setError] = useState<boolean>(false);
useEffect(() => {
// If it's already a blob URL (local preview) or a data URL, use it directly
if (src && (src.startsWith('blob:') || src.startsWith('data:'))) {
setBlobUrl(src);
return;
}
const baseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000';
const isBackendUrl = src && src.includes(`${baseUrl}/files/`) && src.includes('/preview');
// If we have a fileId or a backend URL, fetch it via authenticated request
if (fileId || isBackendUrl) {
let isMounted = true;
const fetchImage = async () => {
setIsLoading(true);
setError(false);
try {
let url: string;
if (fileId) {
url = await fileService.getPreview(fileId);
} else if (src) {
const response = await apiClient.get(src, { responseType: 'blob' });
url = URL.createObjectURL(response.data);
} else {
return;
}
if (isMounted) {
setBlobUrl(url);
}
} catch (err) {
console.error('Failed to fetch authenticated image:', err);
if (isMounted) {
setError(true);
}
} finally {
if (isMounted) {
setIsLoading(false);
}
}
};
fetchImage();
return () => {
isMounted = false;
if (blobUrl && blobUrl.startsWith('blob:')) {
URL.revokeObjectURL(blobUrl);
}
};
} else if (src) {
// For other external URLs, use them directly
setBlobUrl(src);
}
}, [fileId, src]);
if (isLoading) {
return (
<div className={`flex items-center justify-center bg-[#f5f7fa] rounded-md ${className}`}>
<Loader2 className="w-5 h-5 text-[#6b7280] animate-spin" />
</div>
);
}
if (error || (!blobUrl && !src)) {
return fallback || (
<div className={`flex items-center justify-center bg-[#f5f7fa] rounded-md ${className}`}>
<ImageIcon className="w-5 h-5 text-[#9ca3af]" />
</div>
);
}
return (
<img
src={blobUrl || src || ''}
className={className}
alt={alt}
{...props}
/>
);
};

View File

@ -21,4 +21,5 @@ export { ViewRoleModal } from './ViewRoleModal';
export { EditRoleModal } from './EditRoleModal'; export { EditRoleModal } from './EditRoleModal';
export { ViewAuditLogModal } from './ViewAuditLogModal'; export { ViewAuditLogModal } from './ViewAuditLogModal';
export { PageHeader } from './PageHeader'; export { PageHeader } from './PageHeader';
export type { TabItem } from './PageHeader'; export type { TabItem } from './PageHeader';
export { AuthenticatedImage } from './AuthenticatedImage';

View File

@ -1,6 +1,7 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useAppDispatch, useAppSelector } from '@/hooks/redux-hooks'; import { useAppDispatch, useAppSelector } from '@/hooks/redux-hooks';
import { fetchThemeAsync } from '@/store/themeSlice'; import { fetchThemeAsync } from '@/store/themeSlice';
import apiClient from '@/services/api-client';
/** /**
* Hook to fetch and apply tenant theme * Hook to fetch and apply tenant theme
@ -19,17 +20,53 @@ export const useTenantTheme = (): void => {
// Apply favicon // Apply favicon
useEffect(() => { useEffect(() => {
if (faviconUrl) { if (!faviconUrl) return;
// Remove existing favicon links
const existingFavicons = document.querySelectorAll("link[rel='icon'], link[rel='shortcut icon']");
existingFavicons.forEach((favicon) => favicon.remove());
// Add new favicon let isMounted = true;
const link = document.createElement('link'); let blobUrl: string | null = null;
link.rel = 'icon';
link.type = 'image/png'; const applyFavicon = async () => {
link.href = faviconUrl; const baseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api/v1';
document.head.appendChild(link); const isBackendUrl = faviconUrl.includes(`${baseUrl}/files/`) && faviconUrl.includes('/preview');
}
let finalUrl = faviconUrl;
// If it's a backend URL, fetch it with authentication
if (isBackendUrl) {
try {
const response = await apiClient.get(faviconUrl, { responseType: 'blob' });
if (isMounted) {
blobUrl = URL.createObjectURL(response.data);
finalUrl = blobUrl;
}
} catch (err) {
console.error('Failed to fetch authenticated favicon:', err);
// Fallback to original URL, although it might still fail at browser level
}
}
if (isMounted) {
// Remove existing favicon links
const existingFavicons = document.querySelectorAll("link[rel='icon'], link[rel='shortcut icon']");
existingFavicons.forEach((favicon) => favicon.remove());
// Add new favicon
const link = document.createElement('link');
link.rel = 'icon';
// Try to detect type or default to image/png
link.type = faviconUrl.endsWith('.ico') ? 'image/x-icon' : 'image/png';
link.href = finalUrl;
document.head.appendChild(link);
}
};
applyFavicon();
return () => {
isMounted = false;
if (blobUrl) {
URL.revokeObjectURL(blobUrl);
}
};
}, [faviconUrl]); }, [faviconUrl]);
}; };

View File

@ -5,7 +5,7 @@ import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod'; import { z } from 'zod';
import { Layout } from '@/components/layout/Layout'; import { Layout } from '@/components/layout/Layout';
import { FormField, FormSelect, PrimaryButton, SecondaryButton, MultiselectPaginatedSelect } from '@/components/shared'; import { FormField, FormSelect, PrimaryButton, SecondaryButton, MultiselectPaginatedSelect, AuthenticatedImage } from '@/components/shared';
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 { fileService } from '@/services/file-service'; import { fileService } from '@/services/file-service';
@ -100,7 +100,7 @@ const subscriptionTierOptions = [
// Helper function to get base URL with protocol // Helper function to get base URL with protocol
const getBaseUrlWithProtocol = (): string => { const getBaseUrlWithProtocol = (): string => {
return import.meta.env.VITE_FRONTEND_BASE_URL || 'http://localhost:5173'; return import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000';
}; };
const CreateTenantWizard = (): ReactElement => { const CreateTenantWizard = (): ReactElement => {
@ -176,10 +176,10 @@ const CreateTenantWizard = (): ReactElement => {
// File upload state for branding // File upload state for branding
const [logoFile, setLogoFile] = useState<File | null>(null); const [logoFile, setLogoFile] = useState<File | null>(null);
const [faviconFile, setFaviconFile] = useState<File | null>(null); const [faviconFile, setFaviconFile] = useState<File | null>(null);
// const [logoFilePath, setLogoFilePath] = useState<string | null>(null); const [logoFileAttachmentUuid, setLogoFileAttachmentUuid] = useState<string | null>(null);
const [faviconFileAttachmentUuid, setFaviconFileAttachmentUuid] = useState<string | null>(null);
const [logoFileUrl, setLogoFileUrl] = useState<string | null>(null); const [logoFileUrl, setLogoFileUrl] = useState<string | null>(null);
const [logoPreviewUrl, setLogoPreviewUrl] = useState<string | null>(null); const [logoPreviewUrl, setLogoPreviewUrl] = useState<string | null>(null);
// const [faviconFilePath, setFaviconFilePath] = useState<string | null>(null);
const [faviconFileUrl, setFaviconFileUrl] = useState<string | null>(null); const [faviconFileUrl, setFaviconFileUrl] = useState<string | null>(null);
const [faviconPreviewUrl, setFaviconPreviewUrl] = useState<string | null>(null); const [faviconPreviewUrl, setFaviconPreviewUrl] = useState<string | null>(null);
const [isUploadingLogo, setIsUploadingLogo] = useState<boolean>(false); const [isUploadingLogo, setIsUploadingLogo] = useState<boolean>(false);
@ -307,6 +307,7 @@ const CreateTenantWizard = (): ReactElement => {
setLogoFile(null); setLogoFile(null);
setLogoPreviewUrl(null); setLogoPreviewUrl(null);
setLogoFileUrl(null); setLogoFileUrl(null);
setLogoFileAttachmentUuid(null);
setLogoError(null); setLogoError(null);
// Reset the file input // Reset the file input
const fileInput = document.getElementById('logo-upload-wizard') as HTMLInputElement; const fileInput = document.getElementById('logo-upload-wizard') as HTMLInputElement;
@ -322,6 +323,7 @@ const CreateTenantWizard = (): ReactElement => {
setFaviconFile(null); setFaviconFile(null);
setFaviconPreviewUrl(null); setFaviconPreviewUrl(null);
setFaviconFileUrl(null); setFaviconFileUrl(null);
setFaviconFileAttachmentUuid(null);
setFaviconError(null); setFaviconError(null);
// Reset the file input // Reset the file input
const fileInput = document.getElementById('favicon-upload-wizard') as HTMLInputElement; const fileInput = document.getElementById('favicon-upload-wizard') as HTMLInputElement;
@ -380,7 +382,9 @@ const CreateTenantWizard = (): ReactElement => {
secondary_color: secondary_color || undefined, secondary_color: secondary_color || undefined,
accent_color: accent_color || undefined, accent_color: accent_color || undefined,
logo_file_path: logoFileUrl || undefined, logo_file_path: logoFileUrl || undefined,
logo_file_attachment_uuid: logoFileAttachmentUuid || undefined,
favicon_file_path: faviconFileUrl || undefined, favicon_file_path: faviconFileUrl || undefined,
favicon_file_attachment_uuid: faviconFileAttachmentUuid || undefined,
}, },
}, },
}; };
@ -936,11 +940,16 @@ const CreateTenantWizard = (): ReactElement => {
setLogoPreviewUrl(previewUrl); setLogoPreviewUrl(previewUrl);
setIsUploadingLogo(true); setIsUploadingLogo(true);
try { try {
const response = await fileService.uploadSimple(file); const slug = tenantDetailsForm.getValues('slug');
// setLogoFilePath(response.data.file_path); const response = await fileService.upload(file, slug || 'tenant');
setLogoFileUrl(response.data.file_url); const fileId = response.data.id;
setLogoError(null); // Clear error on successful upload setLogoFileAttachmentUuid(fileId);
// Keep preview URL as fallback, will be cleaned up on component unmount or file change
const baseUrl = getBaseUrlWithProtocol();
const formattedUrl = `${baseUrl}/files/${fileId}/preview`;
setLogoFileUrl(formattedUrl);
setLogoError(null);
showToast.success('Logo uploaded successfully'); showToast.success('Logo uploaded successfully');
} catch (err: any) { } catch (err: any) {
const errorMessage = const errorMessage =
@ -950,11 +959,10 @@ const CreateTenantWizard = (): ReactElement => {
'Failed to upload logo. Please try again.'; 'Failed to upload logo. Please try again.';
showToast.error(errorMessage); showToast.error(errorMessage);
setLogoFile(null); setLogoFile(null);
// Clean up preview URL on error
URL.revokeObjectURL(previewUrl); URL.revokeObjectURL(previewUrl);
setLogoPreviewUrl(null); setLogoPreviewUrl(null);
setLogoFileUrl(null); setLogoFileUrl(null);
// setLogoFilePath(null); setLogoFileAttachmentUuid(null);
} finally { } finally {
setIsUploadingLogo(false); setIsUploadingLogo(false);
} }
@ -975,26 +983,13 @@ const CreateTenantWizard = (): ReactElement => {
)} )}
{(logoPreviewUrl || logoFileUrl) && ( {(logoPreviewUrl || logoFileUrl) && (
<div className="mt-2 relative inline-block"> <div className="mt-2 relative inline-block">
<img <AuthenticatedImage
key={logoPreviewUrl || logoFileUrl} key={logoPreviewUrl || logoFileUrl}
src={logoPreviewUrl || logoFileUrl || ''} fileId={logoFileAttachmentUuid}
src={logoPreviewUrl || logoFileUrl}
alt="Logo preview" alt="Logo preview"
className="max-w-full h-20 object-contain border border-[#d1d5db] rounded-md p-2 bg-white" 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' }}
onError={(e) => {
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,
});
}}
/> />
<button <button
type="button" type="button"
@ -1055,11 +1050,16 @@ const CreateTenantWizard = (): ReactElement => {
setFaviconPreviewUrl(previewUrl); setFaviconPreviewUrl(previewUrl);
setIsUploadingFavicon(true); setIsUploadingFavicon(true);
try { try {
const response = await fileService.uploadSimple(file); const slug = tenantDetailsForm.getValues('slug');
// setFaviconFilePath(response.data.file_path); const response = await fileService.upload(file, slug || 'tenant');
setFaviconFileUrl(response.data.file_url); const fileId = response.data.id;
setFaviconError(null); // Clear error on successful upload setFaviconFileAttachmentUuid(fileId);
// Keep preview URL as fallback, will be cleaned up on component unmount or file change
const baseUrl = getBaseUrlWithProtocol();
const formattedUrl = `${baseUrl}/files/${fileId}/preview`;
setFaviconFileUrl(formattedUrl);
setFaviconError(null);
showToast.success('Favicon uploaded successfully'); showToast.success('Favicon uploaded successfully');
} catch (err: any) { } catch (err: any) {
const errorMessage = const errorMessage =
@ -1069,11 +1069,10 @@ const CreateTenantWizard = (): ReactElement => {
'Failed to upload favicon. Please try again.'; 'Failed to upload favicon. Please try again.';
showToast.error(errorMessage); showToast.error(errorMessage);
setFaviconFile(null); setFaviconFile(null);
// Clean up preview URL on error
URL.revokeObjectURL(previewUrl); URL.revokeObjectURL(previewUrl);
setFaviconPreviewUrl(null); setFaviconPreviewUrl(null);
setFaviconFileUrl(null); setFaviconFileUrl(null);
// setFaviconFilePath(null); setFaviconFileAttachmentUuid(null);
} finally { } finally {
setIsUploadingFavicon(false); setIsUploadingFavicon(false);
} }
@ -1094,26 +1093,13 @@ const CreateTenantWizard = (): ReactElement => {
)} )}
{(faviconPreviewUrl || faviconFileUrl) && ( {(faviconPreviewUrl || faviconFileUrl) && (
<div className="mt-2 relative inline-block"> <div className="mt-2 relative inline-block">
<img <AuthenticatedImage
key={faviconFileUrl || faviconPreviewUrl || ''} key={faviconFileUrl || faviconPreviewUrl || ''}
src={faviconPreviewUrl || faviconFileUrl || ''} fileId={faviconFileAttachmentUuid}
src={faviconPreviewUrl || faviconFileUrl}
alt="Favicon preview" alt="Favicon preview"
className="w-16 h-16 object-contain border border-[#d1d5db] rounded-md p-2 bg-white" 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' }}
onError={(e) => {
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,
});
}}
/> />
<button <button
type="button" type="button"

View File

@ -5,7 +5,7 @@ import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod'; import { z } from 'zod';
import { Layout } from '@/components/layout/Layout'; import { Layout } from '@/components/layout/Layout';
import { FormField, FormSelect, PrimaryButton, SecondaryButton, MultiselectPaginatedSelect } from '@/components/shared'; import { FormField, FormSelect, PrimaryButton, SecondaryButton, MultiselectPaginatedSelect, AuthenticatedImage } from '@/components/shared';
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 { fileService } from '@/services/file-service'; import { fileService } from '@/services/file-service';
@ -92,7 +92,7 @@ const subscriptionTierOptions = [
// Helper function to get base URL with protocol // Helper function to get base URL with protocol
const getBaseUrlWithProtocol = (): string => { const getBaseUrlWithProtocol = (): string => {
return import.meta.env.VITE_FRONTEND_BASE_URL || 'http://localhost:5173'; return import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000';
}; };
const EditTenant = (): ReactElement => { const EditTenant = (): ReactElement => {
@ -110,6 +110,8 @@ const EditTenant = (): ReactElement => {
const [logoFile, setLogoFile] = useState<File | null>(null); const [logoFile, setLogoFile] = useState<File | null>(null);
const [faviconFile, setFaviconFile] = useState<File | null>(null); const [faviconFile, setFaviconFile] = useState<File | null>(null);
const [logoFilePath, setLogoFilePath] = useState<string | null>(null); const [logoFilePath, setLogoFilePath] = useState<string | null>(null);
const [logoFileAttachmentUuid, setLogoFileAttachmentUuid] = useState<string | null>(null);
const [faviconFileAttachmentUuid, setFaviconFileAttachmentUuid] = useState<string | null>(null);
const [logoPreviewUrl, setLogoPreviewUrl] = useState<string | null>(null); const [logoPreviewUrl, setLogoPreviewUrl] = useState<string | null>(null);
const [faviconFilePath, setFaviconFilePath] = useState<string | null>(null); const [faviconFilePath, setFaviconFilePath] = useState<string | null>(null);
const [faviconFileUrl, setFaviconFileUrl] = useState<string | null>(null); const [faviconFileUrl, setFaviconFileUrl] = useState<string | null>(null);
@ -243,20 +245,22 @@ const EditTenant = (): ReactElement => {
const accentColor = tenant.accent_color || branding.accent_color || '#084CC8'; const accentColor = tenant.accent_color || branding.accent_color || '#084CC8';
const logoPath = tenant.logo_file_path || branding.logo_file_path || null; const logoPath = tenant.logo_file_path || branding.logo_file_path || null;
const faviconPath = tenant.favicon_file_path || branding.favicon_file_path || null; const faviconPath = tenant.favicon_file_path || branding.favicon_file_path || null;
const logoUuid = tenant.logo_file_attachment_uuid || branding.logo_file_attachment_uuid || null;
const faviconUuid = tenant.favicon_file_attachment_uuid || branding.favicon_file_attachment_uuid || null;
// Set file paths and URLs if they exist // Set file paths and URLs if they exist
if (logoPath) { if (logoPath) {
setLogoFilePath(logoPath); setLogoFilePath(logoPath);
// const apiBaseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api/v1';
setLogoPreviewUrl(logoPath); setLogoPreviewUrl(logoPath);
setLogoError(null); // Clear error if existing logo is found setLogoFileAttachmentUuid(logoUuid);
setLogoError(null);
} }
if (faviconPath) { if (faviconPath) {
setFaviconFilePath(faviconPath); setFaviconFilePath(faviconPath);
// const apiBaseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api/v1';
setFaviconFileUrl(faviconPath); setFaviconFileUrl(faviconPath);
setFaviconPreviewUrl(faviconPath); setFaviconPreviewUrl(faviconPath);
setFaviconError(null); // Clear error if existing favicon is found setFaviconFileAttachmentUuid(faviconUuid);
setFaviconError(null);
} }
// Validate subscription_tier // Validate subscription_tier
@ -405,7 +409,8 @@ const EditTenant = (): ReactElement => {
setLogoFile(null); setLogoFile(null);
setLogoPreviewUrl(null); setLogoPreviewUrl(null);
setLogoFilePath(null); setLogoFilePath(null);
setLogoError(null); // Clear error on delete setLogoFileAttachmentUuid(null);
setLogoError(null);
// Reset the file input // Reset the file input
const fileInput = document.getElementById('logo-upload-edit-page') as HTMLInputElement; const fileInput = document.getElementById('logo-upload-edit-page') as HTMLInputElement;
if (fileInput) { if (fileInput) {
@ -421,7 +426,8 @@ const EditTenant = (): ReactElement => {
setFaviconPreviewUrl(null); setFaviconPreviewUrl(null);
setFaviconFileUrl(null); setFaviconFileUrl(null);
setFaviconFilePath(null); setFaviconFilePath(null);
setFaviconError(null); // Clear error on delete setFaviconFileAttachmentUuid(null);
setFaviconError(null);
// Reset the file input // Reset the file input
const fileInput = document.getElementById('favicon-upload-edit-page') as HTMLInputElement; const fileInput = document.getElementById('favicon-upload-edit-page') as HTMLInputElement;
if (fileInput) { if (fileInput) {
@ -476,7 +482,9 @@ const EditTenant = (): ReactElement => {
secondary_color: secondary_color || undefined, secondary_color: secondary_color || undefined,
accent_color: accent_color || undefined, accent_color: accent_color || undefined,
logo_file_path: logoFilePath || undefined, logo_file_path: logoFilePath || undefined,
logo_file_attachment_uuid: logoFileAttachmentUuid || undefined,
favicon_file_path: faviconFilePath || undefined, favicon_file_path: faviconFilePath || undefined,
favicon_file_attachment_uuid: faviconFileAttachmentUuid || undefined,
}, },
}, },
}; };
@ -490,17 +498,17 @@ const EditTenant = (): ReactElement => {
tenantDetailsForm.clearErrors(); tenantDetailsForm.clearErrors();
contactDetailsForm.clearErrors(); contactDetailsForm.clearErrors();
settingsForm.clearErrors(); settingsForm.clearErrors();
// Handle validation errors from API // 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; const validationErrors = err.response.data.details;
let hasTenantErrors = false; let hasTenantErrors = false;
let hasContactErrors = false; let hasContactErrors = false;
let hasSettingsErrors = false; let hasSettingsErrors = false;
validationErrors.forEach((detail: { path: string; message: string }) => { validationErrors.forEach((detail: { path: string; message: string }) => {
const path = detail.path; const path = detail.path;
// Handle nested paths first // Handle nested paths first
if (path.startsWith('settings.contact.')) { if (path.startsWith('settings.contact.')) {
// Contact details errors from nested path // Contact details errors from nested path
@ -586,7 +594,7 @@ const EditTenant = (): ReactElement => {
}); });
} }
}); });
// Navigate to the step with errors // Navigate to the step with errors
if (hasTenantErrors) { if (hasTenantErrors) {
setCurrentStep(1); setCurrentStep(1);
@ -1029,9 +1037,17 @@ const EditTenant = (): ReactElement => {
setLogoPreviewUrl(previewUrl); setLogoPreviewUrl(previewUrl);
setIsUploadingLogo(true); setIsUploadingLogo(true);
try { try {
const response = await fileService.uploadSimple(file); const slug = tenantDetailsForm.getValues('slug');
setLogoFilePath(response.data.file_url); const response = await fileService.upload(file, slug || 'tenant', id, id);
setLogoError(null); // Clear error on successful upload const fileId = response.data.id;
setLogoFileAttachmentUuid(fileId);
const baseUrl = getBaseUrlWithProtocol();
const formattedUrl = `${baseUrl}/files/${fileId}/preview`;
setLogoFilePath(formattedUrl);
setLogoPreviewUrl(formattedUrl);
setLogoError(null);
showToast.success('Logo uploaded successfully'); showToast.success('Logo uploaded successfully');
} catch (err: any) { } catch (err: any) {
const errorMessage = const errorMessage =
@ -1044,6 +1060,7 @@ const EditTenant = (): ReactElement => {
URL.revokeObjectURL(previewUrl); URL.revokeObjectURL(previewUrl);
setLogoPreviewUrl(null); setLogoPreviewUrl(null);
setLogoFilePath(null); setLogoFilePath(null);
setLogoFileAttachmentUuid(null);
} finally { } finally {
setIsUploadingLogo(false); setIsUploadingLogo(false);
} }
@ -1064,18 +1081,13 @@ const EditTenant = (): ReactElement => {
)} )}
{logoPreviewUrl && ( {logoPreviewUrl && (
<div className="mt-2 relative inline-block"> <div className="mt-2 relative inline-block">
<img <AuthenticatedImage
key={logoPreviewUrl} key={logoPreviewUrl}
src={logoPreviewUrl || ''} fileId={logoFileAttachmentUuid}
src={logoPreviewUrl}
alt="Logo preview" alt="Logo preview"
className="max-w-full h-20 object-contain border border-[#d1d5db] rounded-md p-2 bg-white" 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' }}
onError={(e) => {
console.error('Failed to load logo preview image', {
logoPreviewUrl,
src: e.currentTarget.src,
});
}}
/> />
<button <button
type="button" type="button"
@ -1131,10 +1143,18 @@ const EditTenant = (): ReactElement => {
setFaviconPreviewUrl(previewUrl); setFaviconPreviewUrl(previewUrl);
setIsUploadingFavicon(true); setIsUploadingFavicon(true);
try { try {
const response = await fileService.uploadSimple(file); const slug = tenantDetailsForm.getValues('slug');
setFaviconFilePath(response.data.file_url); const response = await fileService.upload(file, slug || 'tenant', id, id);
setFaviconFileUrl(response.data.file_url); const fileId = response.data.id;
setFaviconError(null); // Clear error on successful upload setFaviconFileAttachmentUuid(fileId);
const baseUrl = getBaseUrlWithProtocol();
const formattedUrl = `${baseUrl}/files/${fileId}/preview`;
setFaviconFilePath(formattedUrl);
setFaviconFileUrl(formattedUrl);
setFaviconPreviewUrl(formattedUrl);
setFaviconError(null);
showToast.success('Favicon uploaded successfully'); showToast.success('Favicon uploaded successfully');
} catch (err: any) { } catch (err: any) {
const errorMessage = const errorMessage =
@ -1148,6 +1168,7 @@ const EditTenant = (): ReactElement => {
setFaviconPreviewUrl(null); setFaviconPreviewUrl(null);
setFaviconFileUrl(null); setFaviconFileUrl(null);
setFaviconFilePath(null); setFaviconFilePath(null);
setFaviconFileAttachmentUuid(null);
} finally { } finally {
setIsUploadingFavicon(false); setIsUploadingFavicon(false);
} }
@ -1168,19 +1189,13 @@ const EditTenant = (): ReactElement => {
)} )}
{(faviconPreviewUrl || faviconFileUrl) && ( {(faviconPreviewUrl || faviconFileUrl) && (
<div className="mt-2 relative inline-block"> <div className="mt-2 relative inline-block">
<img <AuthenticatedImage
key={faviconPreviewUrl || faviconFileUrl} key={faviconPreviewUrl || faviconFileUrl}
src={faviconPreviewUrl || faviconFileUrl || ''} fileId={faviconFileAttachmentUuid}
src={faviconPreviewUrl || faviconFileUrl}
alt="Favicon preview" alt="Favicon preview"
className="w-16 h-16 object-contain border border-[#d1d5db] rounded-md p-2 bg-white" 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' }}
onError={(e) => {
console.error('Failed to load favicon preview image', {
faviconFileUrl,
faviconPreviewUrl,
src: e.currentTarget.src,
});
}}
/> />
<button <button
type="button" type="button"

View File

@ -6,35 +6,42 @@ import { tenantService } from '@/services/tenant-service';
import { fileService } from '@/services/file-service'; import { fileService } from '@/services/file-service';
import { showToast } from '@/utils/toast'; import { showToast } from '@/utils/toast';
import { updateTheme } from '@/store/themeSlice'; import { updateTheme } from '@/store/themeSlice';
import { PrimaryButton } from '@/components/shared'; import { PrimaryButton, AuthenticatedImage } from '@/components/shared';
import type { Tenant } from '@/types/tenant'; 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';
};
const Settings = (): ReactElement => { const Settings = (): ReactElement => {
const tenantId = useAppSelector((state) => state.auth.tenantId); const tenantId = useAppSelector((state) => state.auth.tenantId);
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const [isLoading, setIsLoading] = useState<boolean>(true); const [isLoading, setIsLoading] = useState<boolean>(true);
const [isSaving, setIsSaving] = useState<boolean>(false); const [isSaving, setIsSaving] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Tenant data // Tenant data
const [tenant, setTenant] = useState<Tenant | null>(null); const [tenant, setTenant] = useState<Tenant | null>(null);
// Color states // Color states
const [primaryColor, setPrimaryColor] = useState<string>('#112868'); const [primaryColor, setPrimaryColor] = useState<string>('#112868');
const [secondaryColor, setSecondaryColor] = useState<string>('#23DCE1'); const [secondaryColor, setSecondaryColor] = useState<string>('#23DCE1');
const [accentColor, setAccentColor] = useState<string>('#084CC8'); const [accentColor, setAccentColor] = useState<string>('#084CC8');
// Logo states // Logo states
const [logoFile, setLogoFile] = useState<File | null>(null); const [logoFile, setLogoFile] = useState<File | null>(null);
const [logoFilePath, setLogoFilePath] = useState<string | null>(null); const [logoFilePath, setLogoFilePath] = useState<string | null>(null);
const [logoFileAttachmentUuid, setLogoFileAttachmentUuid] = useState<string | null>(null);
const [logoFileUrl, setLogoFileUrl] = useState<string | null>(null); const [logoFileUrl, setLogoFileUrl] = useState<string | null>(null);
const [logoPreviewUrl, setLogoPreviewUrl] = useState<string | null>(null); const [logoPreviewUrl, setLogoPreviewUrl] = useState<string | null>(null);
const [isUploadingLogo, setIsUploadingLogo] = useState<boolean>(false); const [isUploadingLogo, setIsUploadingLogo] = useState<boolean>(false);
// Favicon states // Favicon states
const [faviconFile, setFaviconFile] = useState<File | null>(null); const [faviconFile, setFaviconFile] = useState<File | null>(null);
const [faviconFilePath, setFaviconFilePath] = useState<string | null>(null); const [faviconFilePath, setFaviconFilePath] = useState<string | null>(null);
const [faviconFileAttachmentUuid, setFaviconFileAttachmentUuid] = useState<string | null>(null);
const [faviconFileUrl, setFaviconFileUrl] = useState<string | null>(null); const [faviconFileUrl, setFaviconFileUrl] = useState<string | null>(null);
const [faviconPreviewUrl, setFaviconPreviewUrl] = useState<string | null>(null); const [faviconPreviewUrl, setFaviconPreviewUrl] = useState<string | null>(null);
const [isUploadingFavicon, setIsUploadingFavicon] = useState<boolean>(false); const [isUploadingFavicon, setIsUploadingFavicon] = useState<boolean>(false);
@ -54,28 +61,30 @@ const Settings = (): ReactElement => {
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
const response = await tenantService.getById(tenantId); const response = await tenantService.getById(tenantId);
if (response.success && response.data) { if (response.success && response.data) {
const tenantData = response.data; const tenantData = response.data;
setTenant(tenantData); setTenant(tenantData);
// Set colors // Set colors
setPrimaryColor(tenantData.primary_color || '#112868'); setPrimaryColor(tenantData.primary_color || '#112868');
setSecondaryColor(tenantData.secondary_color || '#23DCE1'); setSecondaryColor(tenantData.secondary_color || '#23DCE1');
setAccentColor(tenantData.accent_color || '#084CC8'); setAccentColor(tenantData.accent_color || '#084CC8');
// Set logo // Set logo
if (tenantData.logo_file_path) { if (tenantData.logo_file_path) {
setLogoFileUrl(tenantData.logo_file_path); setLogoFileUrl(tenantData.logo_file_path);
setLogoFilePath(tenantData.logo_file_path); setLogoFilePath(tenantData.logo_file_path);
setLogoError(null); // Clear error if existing logo is found setLogoFileAttachmentUuid(tenantData.logo_file_attachment_uuid || null);
setLogoError(null);
} }
// Set favicon // Set favicon
if (tenantData.favicon_file_path) { if (tenantData.favicon_file_path) {
setFaviconFileUrl(tenantData.favicon_file_path); setFaviconFileUrl(tenantData.favicon_file_path);
setFaviconFilePath(tenantData.favicon_file_path); setFaviconFilePath(tenantData.favicon_file_path);
setFaviconError(null); // Clear error if existing favicon is found setFaviconFileAttachmentUuid(tenantData.favicon_file_attachment_uuid || null);
setFaviconError(null);
} }
} }
} catch (err: any) { } catch (err: any) {
@ -134,10 +143,16 @@ const Settings = (): ReactElement => {
setIsUploadingLogo(true); setIsUploadingLogo(true);
try { try {
const response = await fileService.uploadSimple(file); const response = await fileService.upload(file, 'tenant', tenantId || undefined);
setLogoFilePath(response.data.file_url); const fileId = response.data.id;
setLogoFileUrl(response.data.file_url); setLogoFileAttachmentUuid(fileId);
setLogoError(null); // Clear error on successful upload
const baseUrl = getBaseUrlWithProtocol();
const formattedUrl = `${baseUrl}/files/${fileId}/preview`;
setLogoFilePath(formattedUrl);
setLogoFileUrl(formattedUrl);
setLogoError(null);
showToast.success('Logo uploaded successfully'); showToast.success('Logo uploaded successfully');
} catch (err: any) { } catch (err: any) {
const errorMessage = const errorMessage =
@ -151,6 +166,7 @@ const Settings = (): ReactElement => {
setLogoPreviewUrl(null); setLogoPreviewUrl(null);
setLogoFileUrl(null); setLogoFileUrl(null);
setLogoFilePath(null); setLogoFilePath(null);
setLogoFileAttachmentUuid(null);
} finally { } finally {
setIsUploadingLogo(false); setIsUploadingLogo(false);
} }
@ -184,10 +200,16 @@ const Settings = (): ReactElement => {
setIsUploadingFavicon(true); setIsUploadingFavicon(true);
try { try {
const response = await fileService.uploadSimple(file); const response = await fileService.upload(file, 'tenant', tenantId || undefined);
setFaviconFilePath(response.data.file_url); const fileId = response.data.id;
setFaviconFileUrl(response.data.file_url); setFaviconFileAttachmentUuid(fileId);
setFaviconError(null); // Clear error on successful upload
const baseUrl = getBaseUrlWithProtocol();
const formattedUrl = `${baseUrl}/files/${fileId}/preview`;
setFaviconFilePath(formattedUrl);
setFaviconFileUrl(formattedUrl);
setFaviconError(null);
showToast.success('Favicon uploaded successfully'); showToast.success('Favicon uploaded successfully');
} catch (err: any) { } catch (err: any) {
const errorMessage = const errorMessage =
@ -201,6 +223,7 @@ const Settings = (): ReactElement => {
setFaviconPreviewUrl(null); setFaviconPreviewUrl(null);
setFaviconFileUrl(null); setFaviconFileUrl(null);
setFaviconFilePath(null); setFaviconFilePath(null);
setFaviconFileAttachmentUuid(null);
} finally { } finally {
setIsUploadingFavicon(false); setIsUploadingFavicon(false);
} }
@ -214,7 +237,8 @@ const Settings = (): ReactElement => {
setLogoPreviewUrl(null); setLogoPreviewUrl(null);
setLogoFileUrl(null); setLogoFileUrl(null);
setLogoFilePath(null); setLogoFilePath(null);
setLogoError(null); // Clear error on delete setLogoFileAttachmentUuid(null);
setLogoError(null);
// Reset the file input // Reset the file input
const fileInput = document.getElementById('logo-upload') as HTMLInputElement; const fileInput = document.getElementById('logo-upload') as HTMLInputElement;
if (fileInput) { if (fileInput) {
@ -230,7 +254,8 @@ const Settings = (): ReactElement => {
setFaviconPreviewUrl(null); setFaviconPreviewUrl(null);
setFaviconFileUrl(null); setFaviconFileUrl(null);
setFaviconFilePath(null); setFaviconFilePath(null);
setFaviconError(null); // Clear error on delete setFaviconFileAttachmentUuid(null);
setFaviconError(null);
// Reset the file input // Reset the file input
const fileInput = document.getElementById('favicon-upload') as HTMLInputElement; const fileInput = document.getElementById('favicon-upload') as HTMLInputElement;
if (fileInput) { if (fileInput) {
@ -267,7 +292,7 @@ const Settings = (): ReactElement => {
// Build update data matching EditTenantModal format // Build update data matching EditTenantModal format
const existingSettings = (tenant.settings as Record<string, unknown>) || {}; const existingSettings = (tenant.settings as Record<string, unknown>) || {};
const existingContact = (existingSettings.contact as Record<string, unknown>) || {}; const existingContact = (existingSettings.contact as Record<string, unknown>) || {};
const updateData = { const updateData = {
name: tenant.name, name: tenant.name,
slug: tenant.slug, slug: tenant.slug,
@ -285,7 +310,9 @@ const Settings = (): ReactElement => {
secondary_color: secondaryColor || undefined, secondary_color: secondaryColor || undefined,
accent_color: accentColor || undefined, accent_color: accentColor || undefined,
logo_file_path: logoFilePath || undefined, logo_file_path: logoFilePath || undefined,
logo_file_attachment_uuid: logoFileAttachmentUuid || undefined,
favicon_file_path: faviconFilePath || undefined, favicon_file_path: faviconFilePath || undefined,
favicon_file_attachment_uuid: faviconFileAttachmentUuid || undefined,
}, },
}, },
}; };
@ -294,7 +321,7 @@ const Settings = (): ReactElement => {
if (response.success) { if (response.success) {
showToast.success('Settings updated successfully'); showToast.success('Settings updated successfully');
// Update theme in Redux // Update theme in Redux
dispatch( dispatch(
updateTheme({ updateTheme({
@ -313,7 +340,9 @@ const Settings = (): ReactElement => {
secondary_color: secondaryColor, secondary_color: secondaryColor,
accent_color: accentColor, accent_color: accentColor,
logo_file_path: logoFilePath, logo_file_path: logoFilePath,
logo_file_attachment_uuid: logoFileAttachmentUuid,
favicon_file_path: faviconFilePath, favicon_file_path: faviconFilePath,
favicon_file_attachment_uuid: faviconFileAttachmentUuid,
}); });
} }
} catch (err: any) { } catch (err: any) {
@ -429,18 +458,12 @@ const Settings = (): ReactElement => {
)} )}
{(logoPreviewUrl || logoFileUrl) && ( {(logoPreviewUrl || logoFileUrl) && (
<div className="mt-2 relative inline-block"> <div className="mt-2 relative inline-block">
<img <AuthenticatedImage
src={logoPreviewUrl || logoFileUrl || ''} fileId={logoFileAttachmentUuid}
src={logoPreviewUrl || logoFileUrl}
alt="Logo preview" alt="Logo preview"
className="max-w-full h-20 object-contain border border-[#d1d5db] rounded-md p-2 bg-white" 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' }}
onError={(e) => {
console.error('Failed to load logo preview image', {
logoFileUrl,
logoPreviewUrl,
src: e.currentTarget.src,
});
}}
/> />
<button <button
type="button" type="button"
@ -497,18 +520,12 @@ const Settings = (): ReactElement => {
)} )}
{(faviconPreviewUrl || faviconFileUrl) && ( {(faviconPreviewUrl || faviconFileUrl) && (
<div className="mt-2 relative inline-block"> <div className="mt-2 relative inline-block">
<img <AuthenticatedImage
src={faviconPreviewUrl || faviconFileUrl || ''} fileId={faviconFileAttachmentUuid}
src={faviconPreviewUrl || faviconFileUrl}
alt="Favicon preview" alt="Favicon preview"
className="w-16 h-16 object-contain border border-[#d1d5db] rounded-md p-2 bg-white" 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' }}
onError={(e) => {
console.error('Failed to load favicon preview image', {
faviconFileUrl,
faviconPreviewUrl,
src: e.currentTarget.src,
});
}}
/> />
<button <button
type="button" type="button"

View File

@ -3,13 +3,40 @@ import apiClient from './api-client';
export interface FileUploadResponse { export interface FileUploadResponse {
success: boolean; success: boolean;
data: { data: {
id: string;
tenant_id: string;
original_name: string; original_name: string;
stored_name: string;
file_path: string; file_path: string;
file_url: string;
mime_type: string; mime_type: string;
file_size: number; file_size: number;
checksum: string;
storage_provider: string;
storage_bucket: string | null;
storage_region: string | null;
entity_type: string;
entity_id: string;
category: string;
description: string;
tags: string[];
version: number;
is_current_version: boolean;
previous_version_id: string | null;
is_public: boolean;
access_level: string;
download_count: number;
has_thumbnail: boolean;
thumbnail_path: string | null;
metadata: Record<string, any>;
scan_status: string;
scanned_at: string | null;
uploaded_by: string;
created_at: string;
updated_at: string;
deleted_at: string | null;
source_module: string;
file_size_formatted: string; file_size_formatted: string;
uploaded_at: string; file_url?: string; // Kept for backward compatibility
}; };
} }
@ -17,11 +44,30 @@ export const fileService = {
uploadSimple: async (file: File): Promise<FileUploadResponse> => { uploadSimple: async (file: File): Promise<FileUploadResponse> => {
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', file);
const response = await apiClient.post<FileUploadResponse>('/files/upload', formData);
// Axios automatically sets Content-Type to multipart/form-data with boundary for FormData
// The interceptor will still add the Authorization header
// Don't set Content-Type explicitly - let axios handle it with the correct boundary
const response = await apiClient.post<FileUploadResponse>('/files/upload/simple', formData);
return response.data; return response.data;
}, },
upload: async (
file: File,
entityType: string = 'tenant',
entityId?: string,
tenantId?: string
): Promise<FileUploadResponse> => {
const formData = new FormData();
formData.append('file', file);
formData.append('entity_type', entityType);
if (entityId) formData.append('entity_id', entityId);
if (tenantId) formData.append('tenantId', tenantId);
const response = await apiClient.post<FileUploadResponse>('/files/upload', formData);
return response.data;
},
getPreview: async (fileId: string): Promise<string> => {
const response = await apiClient.get(`/files/${fileId}/preview`, {
responseType: 'blob',
});
return URL.createObjectURL(response.data);
},
}; };

View File

@ -35,7 +35,9 @@ export interface TenantAdmin {
export interface TenantBranding { export interface TenantBranding {
logo_file_path?: string | null; logo_file_path?: string | null;
logo_file_attachment_uuid?: string | null;
favicon_file_path?: string | null; favicon_file_path?: string | null;
favicon_file_attachment_uuid?: string | null;
primary_color?: string | null; primary_color?: string | null;
secondary_color?: string | null; secondary_color?: string | null;
accent_color?: string | null; accent_color?: string | null;
@ -54,7 +56,9 @@ export interface Tenant {
enable_sso?: boolean; enable_sso?: boolean;
enable_2fa?: boolean; enable_2fa?: boolean;
logo_file_path?: string | null; logo_file_path?: string | null;
logo_file_attachment_uuid?: string | null;
favicon_file_path?: string | null; favicon_file_path?: string | null;
favicon_file_attachment_uuid?: string | null;
primary_color?: string | null; primary_color?: string | null;
secondary_color?: string | null; secondary_color?: string | null;
accent_color?: string | null; accent_color?: string | null;