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:
```env
VITE_API_BASE_URL=http://localhost:3000/api/v1
VITE_FRONTEND_BASE_URL=http://localhost:5173
```
4. **Start the development server**
@ -123,6 +124,7 @@ Create a `.env` file in the root directory:
```env
VITE_API_BASE_URL=your-api-base-url
VITE_FRONTEND_BASE_URL=your-frontend-base-url
```
## 🎨 Design System

View File

@ -13,6 +13,7 @@ import {
import { cn } from '@/lib/utils';
import { useAppSelector } from '@/hooks/redux-hooks';
import { useTenantTheme } from '@/hooks/useTenantTheme';
import { AuthenticatedImage } from '@/components/shared';
interface MenuItem {
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 gap-3 items-center">
{!isSuperAdmin && logoUrl ? (
<img
<AuthenticatedImage
src={logoUrl}
alt="Logo"
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}
<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

@ -22,3 +22,4 @@ export { EditRoleModal } from './EditRoleModal';
export { ViewAuditLogModal } from './ViewAuditLogModal';
export { PageHeader } from './PageHeader';
export type { TabItem } from './PageHeader';
export { AuthenticatedImage } from './AuthenticatedImage';

View File

@ -1,6 +1,7 @@
import { useEffect } from 'react';
import { useAppDispatch, useAppSelector } from '@/hooks/redux-hooks';
import { fetchThemeAsync } from '@/store/themeSlice';
import apiClient from '@/services/api-client';
/**
* Hook to fetch and apply tenant theme
@ -19,7 +20,32 @@ export const useTenantTheme = (): void => {
// Apply favicon
useEffect(() => {
if (faviconUrl) {
if (!faviconUrl) return;
let isMounted = true;
let blobUrl: string | null = null;
const applyFavicon = async () => {
const baseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api/v1';
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());
@ -27,9 +53,20 @@ export const useTenantTheme = (): void => {
// Add new favicon
const link = document.createElement('link');
link.rel = 'icon';
link.type = 'image/png';
link.href = faviconUrl;
// 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]);
};

View File

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

View File

@ -5,7 +5,7 @@ import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Layout } from '@/components/layout/Layout';
import { FormField, FormSelect, PrimaryButton, SecondaryButton, MultiselectPaginatedSelect } from '@/components/shared';
import { 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';
@ -92,7 +92,7 @@ const subscriptionTierOptions = [
// Helper function to get base URL with protocol
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 => {
@ -110,6 +110,8 @@ const EditTenant = (): ReactElement => {
const [logoFile, setLogoFile] = 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 [logoPreviewUrl, setLogoPreviewUrl] = useState<string | null>(null);
const [faviconFilePath, setFaviconFilePath] = 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 logoPath = tenant.logo_file_path || branding.logo_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
if (logoPath) {
setLogoFilePath(logoPath);
// const apiBaseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api/v1';
setLogoPreviewUrl(logoPath);
setLogoError(null); // Clear error if existing logo is found
setLogoFileAttachmentUuid(logoUuid);
setLogoError(null);
}
if (faviconPath) {
setFaviconFilePath(faviconPath);
// const apiBaseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api/v1';
setFaviconFileUrl(faviconPath);
setFaviconPreviewUrl(faviconPath);
setFaviconError(null); // Clear error if existing favicon is found
setFaviconFileAttachmentUuid(faviconUuid);
setFaviconError(null);
}
// Validate subscription_tier
@ -405,7 +409,8 @@ const EditTenant = (): ReactElement => {
setLogoFile(null);
setLogoPreviewUrl(null);
setLogoFilePath(null);
setLogoError(null); // Clear error on delete
setLogoFileAttachmentUuid(null);
setLogoError(null);
// Reset the file input
const fileInput = document.getElementById('logo-upload-edit-page') as HTMLInputElement;
if (fileInput) {
@ -421,7 +426,8 @@ const EditTenant = (): ReactElement => {
setFaviconPreviewUrl(null);
setFaviconFileUrl(null);
setFaviconFilePath(null);
setFaviconError(null); // Clear error on delete
setFaviconFileAttachmentUuid(null);
setFaviconError(null);
// Reset the file input
const fileInput = document.getElementById('favicon-upload-edit-page') as HTMLInputElement;
if (fileInput) {
@ -476,7 +482,9 @@ const EditTenant = (): ReactElement => {
secondary_color: secondary_color || undefined,
accent_color: accent_color || undefined,
logo_file_path: logoFilePath || undefined,
logo_file_attachment_uuid: logoFileAttachmentUuid || undefined,
favicon_file_path: faviconFilePath || undefined,
favicon_file_attachment_uuid: faviconFileAttachmentUuid || undefined,
},
},
};
@ -1029,9 +1037,17 @@ const EditTenant = (): ReactElement => {
setLogoPreviewUrl(previewUrl);
setIsUploadingLogo(true);
try {
const response = await fileService.uploadSimple(file);
setLogoFilePath(response.data.file_url);
setLogoError(null); // Clear error on successful upload
const slug = tenantDetailsForm.getValues('slug');
const response = await fileService.upload(file, slug || 'tenant', id, id);
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');
} catch (err: any) {
const errorMessage =
@ -1044,6 +1060,7 @@ const EditTenant = (): ReactElement => {
URL.revokeObjectURL(previewUrl);
setLogoPreviewUrl(null);
setLogoFilePath(null);
setLogoFileAttachmentUuid(null);
} finally {
setIsUploadingLogo(false);
}
@ -1064,18 +1081,13 @@ const EditTenant = (): ReactElement => {
)}
{logoPreviewUrl && (
<div className="mt-2 relative inline-block">
<img
<AuthenticatedImage
key={logoPreviewUrl}
src={logoPreviewUrl || ''}
fileId={logoFileAttachmentUuid}
src={logoPreviewUrl}
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' }}
onError={(e) => {
console.error('Failed to load logo preview image', {
logoPreviewUrl,
src: e.currentTarget.src,
});
}}
/>
<button
type="button"
@ -1131,10 +1143,18 @@ const EditTenant = (): ReactElement => {
setFaviconPreviewUrl(previewUrl);
setIsUploadingFavicon(true);
try {
const response = await fileService.uploadSimple(file);
setFaviconFilePath(response.data.file_url);
setFaviconFileUrl(response.data.file_url);
setFaviconError(null); // Clear error on successful upload
const slug = tenantDetailsForm.getValues('slug');
const response = await fileService.upload(file, slug || 'tenant', id, id);
const fileId = response.data.id;
setFaviconFileAttachmentUuid(fileId);
const baseUrl = getBaseUrlWithProtocol();
const formattedUrl = `${baseUrl}/files/${fileId}/preview`;
setFaviconFilePath(formattedUrl);
setFaviconFileUrl(formattedUrl);
setFaviconPreviewUrl(formattedUrl);
setFaviconError(null);
showToast.success('Favicon uploaded successfully');
} catch (err: any) {
const errorMessage =
@ -1148,6 +1168,7 @@ const EditTenant = (): ReactElement => {
setFaviconPreviewUrl(null);
setFaviconFileUrl(null);
setFaviconFilePath(null);
setFaviconFileAttachmentUuid(null);
} finally {
setIsUploadingFavicon(false);
}
@ -1168,19 +1189,13 @@ const EditTenant = (): ReactElement => {
)}
{(faviconPreviewUrl || faviconFileUrl) && (
<div className="mt-2 relative inline-block">
<img
<AuthenticatedImage
key={faviconPreviewUrl || faviconFileUrl}
src={faviconPreviewUrl || faviconFileUrl || ''}
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' }}
onError={(e) => {
console.error('Failed to load favicon preview image', {
faviconFileUrl,
faviconPreviewUrl,
src: e.currentTarget.src,
});
}}
/>
<button
type="button"

View File

@ -6,9 +6,14 @@ import { tenantService } from '@/services/tenant-service';
import { fileService } from '@/services/file-service';
import { showToast } from '@/utils/toast';
import { updateTheme } from '@/store/themeSlice';
import { PrimaryButton } from '@/components/shared';
import { PrimaryButton, AuthenticatedImage } from '@/components/shared';
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 tenantId = useAppSelector((state) => state.auth.tenantId);
const dispatch = useAppDispatch();
@ -28,6 +33,7 @@ const Settings = (): ReactElement => {
// 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);
@ -35,6 +41,7 @@ const Settings = (): ReactElement => {
// 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);
@ -68,14 +75,16 @@ const Settings = (): ReactElement => {
if (tenantData.logo_file_path) {
setLogoFileUrl(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
if (tenantData.favicon_file_path) {
setFaviconFileUrl(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) {
@ -134,10 +143,16 @@ const Settings = (): ReactElement => {
setIsUploadingLogo(true);
try {
const response = await fileService.uploadSimple(file);
setLogoFilePath(response.data.file_url);
setLogoFileUrl(response.data.file_url);
setLogoError(null); // Clear error on successful upload
const response = await fileService.upload(file, 'tenant', 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 =
@ -151,6 +166,7 @@ const Settings = (): ReactElement => {
setLogoPreviewUrl(null);
setLogoFileUrl(null);
setLogoFilePath(null);
setLogoFileAttachmentUuid(null);
} finally {
setIsUploadingLogo(false);
}
@ -184,10 +200,16 @@ const Settings = (): ReactElement => {
setIsUploadingFavicon(true);
try {
const response = await fileService.uploadSimple(file);
setFaviconFilePath(response.data.file_url);
setFaviconFileUrl(response.data.file_url);
setFaviconError(null); // Clear error on successful upload
const response = await fileService.upload(file, 'tenant', 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 =
@ -201,6 +223,7 @@ const Settings = (): ReactElement => {
setFaviconPreviewUrl(null);
setFaviconFileUrl(null);
setFaviconFilePath(null);
setFaviconFileAttachmentUuid(null);
} finally {
setIsUploadingFavicon(false);
}
@ -214,7 +237,8 @@ const Settings = (): ReactElement => {
setLogoPreviewUrl(null);
setLogoFileUrl(null);
setLogoFilePath(null);
setLogoError(null); // Clear error on delete
setLogoFileAttachmentUuid(null);
setLogoError(null);
// Reset the file input
const fileInput = document.getElementById('logo-upload') as HTMLInputElement;
if (fileInput) {
@ -230,7 +254,8 @@ const Settings = (): ReactElement => {
setFaviconPreviewUrl(null);
setFaviconFileUrl(null);
setFaviconFilePath(null);
setFaviconError(null); // Clear error on delete
setFaviconFileAttachmentUuid(null);
setFaviconError(null);
// Reset the file input
const fileInput = document.getElementById('favicon-upload') as HTMLInputElement;
if (fileInput) {
@ -285,7 +310,9 @@ const Settings = (): ReactElement => {
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,
},
},
};
@ -313,7 +340,9 @@ const Settings = (): ReactElement => {
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) {
@ -429,18 +458,12 @@ const Settings = (): ReactElement => {
)}
{(logoPreviewUrl || logoFileUrl) && (
<div className="mt-2 relative inline-block">
<img
src={logoPreviewUrl || logoFileUrl || ''}
<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' }}
onError={(e) => {
console.error('Failed to load logo preview image', {
logoFileUrl,
logoPreviewUrl,
src: e.currentTarget.src,
});
}}
/>
<button
type="button"
@ -497,18 +520,12 @@ const Settings = (): ReactElement => {
)}
{(faviconPreviewUrl || faviconFileUrl) && (
<div className="mt-2 relative inline-block">
<img
src={faviconPreviewUrl || faviconFileUrl || ''}
<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' }}
onError={(e) => {
console.error('Failed to load favicon preview image', {
faviconFileUrl,
faviconPreviewUrl,
src: e.currentTarget.src,
});
}}
/>
<button
type="button"

View File

@ -3,13 +3,40 @@ import apiClient from './api-client';
export interface FileUploadResponse {
success: boolean;
data: {
id: string;
tenant_id: string;
original_name: string;
stored_name: string;
file_path: string;
file_url: string;
mime_type: string;
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;
uploaded_at: string;
file_url?: string; // Kept for backward compatibility
};
}
@ -17,11 +44,30 @@ export const fileService = {
uploadSimple: async (file: File): Promise<FileUploadResponse> => {
const formData = new FormData();
formData.append('file', file);
// 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);
const response = await apiClient.post<FileUploadResponse>('/files/upload', formData);
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 {
logo_file_path?: string | null;
logo_file_attachment_uuid?: string | null;
favicon_file_path?: string | null;
favicon_file_attachment_uuid?: string | null;
primary_color?: string | null;
secondary_color?: string | null;
accent_color?: string | null;
@ -54,7 +56,9 @@ export interface Tenant {
enable_sso?: boolean;
enable_2fa?: boolean;
logo_file_path?: string | null;
logo_file_attachment_uuid?: string | null;
favicon_file_path?: string | null;
favicon_file_attachment_uuid?: string | null;
primary_color?: string | null;
secondary_color?: string | null;
accent_color?: string | null;