diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6d703d2 --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +VITE_FRONTEND_BASE_URL= +VITE_API_BASE_URL= \ No newline at end of file diff --git a/README.md b/README.md index d6f3911..4973426 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index ec2ab8f..4e8e903 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -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) => {
{!isSuperAdmin && logoUrl ? ( - Logo { - e.currentTarget.style.display = 'none'; - const fallback = e.currentTarget.nextElementSibling as HTMLElement; - if (fallback) fallback.style.display = 'flex'; - }} /> ) : null}
, '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(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(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 ( +
+ +
+ ); + } + + if (error || (!blobUrl && !src)) { + return fallback || ( +
+ +
+ ); + } + + return ( + {alt} + ); +}; diff --git a/src/components/shared/index.ts b/src/components/shared/index.ts index cfe62bb..d62252c 100644 --- a/src/components/shared/index.ts +++ b/src/components/shared/index.ts @@ -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'; \ No newline at end of file +export type { TabItem } from './PageHeader'; +export { AuthenticatedImage } from './AuthenticatedImage'; \ No newline at end of file diff --git a/src/hooks/useTenantTheme.ts b/src/hooks/useTenantTheme.ts index 0060135..564a492 100644 --- a/src/hooks/useTenantTheme.ts +++ b/src/hooks/useTenantTheme.ts @@ -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]); }; diff --git a/src/pages/superadmin/CreateTenantWizard.tsx b/src/pages/superadmin/CreateTenantWizard.tsx index bd7aa26..0a2c079 100644 --- a/src/pages/superadmin/CreateTenantWizard.tsx +++ b/src/pages/superadmin/CreateTenantWizard.tsx @@ -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(null); const [faviconFile, setFaviconFile] = useState(null); - // const [logoFilePath, setLogoFilePath] = useState(null); + const [logoFileAttachmentUuid, setLogoFileAttachmentUuid] = useState(null); + const [faviconFileAttachmentUuid, setFaviconFileAttachmentUuid] = useState(null); const [logoFileUrl, setLogoFileUrl] = useState(null); const [logoPreviewUrl, setLogoPreviewUrl] = useState(null); - // const [faviconFilePath, setFaviconFilePath] = useState(null); const [faviconFileUrl, setFaviconFileUrl] = useState(null); const [faviconPreviewUrl, setFaviconPreviewUrl] = useState(null); const [isUploadingLogo, setIsUploadingLogo] = useState(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) && (
- Logo preview { - 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, - }); - }} />