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 ? (
-

{
- 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 (
+
+ );
+};
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) && (
-

{
- 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,
- });
- }}
/>