feat: Implement authenticated image handling for tenant logos and favicons, utilizing file attachment UUIDs and a new AuthenticatedImage component.
This commit is contained in:
parent
757a9f216b
commit
5b03dec1d8
2
.env.example
Normal file
2
.env.example
Normal file
@ -0,0 +1,2 @@
|
||||
VITE_FRONTEND_BASE_URL=
|
||||
VITE_API_BASE_URL=
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
104
src/components/shared/AuthenticatedImage.tsx
Normal file
104
src/components/shared/AuthenticatedImage.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -21,4 +21,5 @@ export { ViewRoleModal } from './ViewRoleModal';
|
||||
export { EditRoleModal } from './EditRoleModal';
|
||||
export { ViewAuditLogModal } from './ViewAuditLogModal';
|
||||
export { PageHeader } from './PageHeader';
|
||||
export type { TabItem } from './PageHeader';
|
||||
export type { TabItem } from './PageHeader';
|
||||
export { AuthenticatedImage } from './AuthenticatedImage';
|
||||
@ -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,17 +20,53 @@ export const useTenantTheme = (): void => {
|
||||
|
||||
// Apply favicon
|
||||
useEffect(() => {
|
||||
if (faviconUrl) {
|
||||
// Remove existing favicon links
|
||||
const existingFavicons = document.querySelectorAll("link[rel='icon'], link[rel='shortcut icon']");
|
||||
existingFavicons.forEach((favicon) => favicon.remove());
|
||||
if (!faviconUrl) return;
|
||||
|
||||
// Add new favicon
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'icon';
|
||||
link.type = 'image/png';
|
||||
link.href = faviconUrl;
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
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());
|
||||
|
||||
// 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]);
|
||||
};
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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,
|
||||
},
|
||||
},
|
||||
};
|
||||
@ -490,17 +498,17 @@ const EditTenant = (): ReactElement => {
|
||||
tenantDetailsForm.clearErrors();
|
||||
contactDetailsForm.clearErrors();
|
||||
settingsForm.clearErrors();
|
||||
|
||||
|
||||
// Handle validation errors from API
|
||||
if (err?.response?.data?.details && Array.isArray(err.response.data.details)) {
|
||||
const validationErrors = err.response.data.details;
|
||||
let hasTenantErrors = false;
|
||||
let hasContactErrors = false;
|
||||
let hasSettingsErrors = false;
|
||||
|
||||
|
||||
validationErrors.forEach((detail: { path: string; message: string }) => {
|
||||
const path = detail.path;
|
||||
|
||||
|
||||
// Handle nested paths first
|
||||
if (path.startsWith('settings.contact.')) {
|
||||
// Contact details errors from nested path
|
||||
@ -586,7 +594,7 @@ const EditTenant = (): ReactElement => {
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Navigate to the step with errors
|
||||
if (hasTenantErrors) {
|
||||
setCurrentStep(1);
|
||||
@ -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"
|
||||
|
||||
@ -6,35 +6,42 @@ 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();
|
||||
|
||||
|
||||
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);
|
||||
@ -54,28 +61,30 @@ const Settings = (): ReactElement => {
|
||||
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);
|
||||
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) {
|
||||
@ -267,7 +292,7 @@ const Settings = (): ReactElement => {
|
||||
// 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,
|
||||
@ -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,
|
||||
},
|
||||
},
|
||||
};
|
||||
@ -294,7 +321,7 @@ const Settings = (): ReactElement => {
|
||||
|
||||
if (response.success) {
|
||||
showToast.success('Settings updated successfully');
|
||||
|
||||
|
||||
// Update theme in Redux
|
||||
dispatch(
|
||||
updateTheme({
|
||||
@ -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"
|
||||
|
||||
@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user