diff --git a/client/src/pages/Settings.jsx b/client/src/pages/Settings.jsx
index d2da4f9..74c7c75 100644
--- a/client/src/pages/Settings.jsx
+++ b/client/src/pages/Settings.jsx
@@ -148,6 +148,8 @@ const BrandingSettings = ({ tenantConfig, onRefresh }) => {
company_name: ''
});
const [saving, setSaving] = useState(false);
+ const [uploading, setUploading] = useState(false);
+ const [logoPreview, setLogoPreview] = useState(null);
useEffect(() => {
if (tenantConfig?.branding) {
@@ -168,6 +170,56 @@ const BrandingSettings = ({ tenantConfig, onRefresh }) => {
}
};
+ const handleLogoUpload = async (event) => {
+ const file = event.target.files[0];
+ if (!file) return;
+
+ // Validate file type
+ if (!file.type.startsWith('image/')) {
+ toast.error('Please select an image file');
+ return;
+ }
+
+ // Validate file size (5MB)
+ if (file.size > 5 * 1024 * 1024) {
+ toast.error('File size must be less than 5MB');
+ return;
+ }
+
+ setUploading(true);
+
+ try {
+ const formData = new FormData();
+ formData.append('logo', file);
+
+ const response = await api.post('/tenant/logo-upload', formData, {
+ headers: {
+ 'Content-Type': 'multipart/form-data'
+ }
+ });
+
+ if (response.data.success) {
+ setBranding(prev => ({ ...prev, logo_url: response.data.data.logo_url }));
+ setLogoPreview(null);
+ toast.success('Logo uploaded successfully');
+ if (onRefresh) onRefresh();
+ }
+ } catch (error) {
+ toast.error('Failed to upload logo');
+ } finally {
+ setUploading(false);
+ }
+ };
+
+ const handleFilePreview = (event) => {
+ const file = event.target.files[0];
+ if (file && file.type.startsWith('image/')) {
+ const reader = new FileReader();
+ reader.onload = (e) => setLogoPreview(e.target.result);
+ reader.readAsDataURL(file);
+ }
+ };
+
return (
@@ -184,14 +236,70 @@ const BrandingSettings = ({ tenantConfig, onRefresh }) => {
-
-
setBranding(prev => ({ ...prev, logo_url: e.target.value }))}
- className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500"
- placeholder="https://example.com/logo.png"
- />
+
+
+ {/* Current logo display */}
+ {branding.logo_url && (
+
+
)
{
+ e.target.style.display = 'none';
+ }}
+ />
+
Current logo
+
+ )}
+
+ {/* Upload interface */}
+
+
+
{
+ handleFilePreview(e);
+ handleLogoUpload(e);
+ }}
+ className="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-primary-50 file:text-primary-700 hover:file:bg-primary-100"
+ disabled={uploading}
+ />
+
PNG, JPG up to 5MB
+
+
+ {uploading && (
+
+ )}
+
+
+ {/* Preview */}
+ {logoPreview && (
+
+

+
Preview
+
+ )}
+
+ {/* Manual URL input as fallback */}
+
+
+ setBranding(prev => ({ ...prev, logo_url: e.target.value }))}
+ className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500"
+ placeholder="https://example.com/logo.png"
+ />
+
diff --git a/server/Dockerfile b/server/Dockerfile
index 75d56df..d04d298 100644
--- a/server/Dockerfile
+++ b/server/Dockerfile
@@ -25,6 +25,9 @@ COPY . .
# Create logs directory
RUN mkdir -p logs
+# Create uploads directory for logos
+RUN mkdir -p uploads/logos
+
# Create non-root user
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodejs -u 1001
diff --git a/server/index.js b/server/index.js
index 9fe9255..6b91c42 100644
--- a/server/index.js
+++ b/server/index.js
@@ -1,4 +1,5 @@
const express = require('express');
+const path = require('path');
const cors = require('cors');
const helmet = require('helmet');
const morgan = require('morgan');
@@ -73,6 +74,9 @@ app.use(cors({
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true }));
+// Serve uploaded files
+app.use('/uploads', express.static(path.join(__dirname, 'uploads')));
+
// API Debug logging (only when API_DEBUG=true)
if (process.env.API_DEBUG === 'true') {
console.log('🐛 API Debug logging enabled');
diff --git a/server/package.json b/server/package.json
index 8ba3d6b..32739e1 100644
--- a/server/package.json
+++ b/server/package.json
@@ -32,7 +32,8 @@
"passport-openidconnect": "^0.1.1",
"ldapjs": "^3.0.7",
"express-session": "^1.17.3",
- "umzug": "^3.4.0"
+ "umzug": "^3.4.0",
+ "multer": "^1.4.5-lts.1"
},
"devDependencies": {
"nodemon": "^3.0.1",
diff --git a/server/routes/tenant.js b/server/routes/tenant.js
index 90a1e68..7850ee0 100644
--- a/server/routes/tenant.js
+++ b/server/routes/tenant.js
@@ -4,6 +4,9 @@
*/
const express = require('express');
+const multer = require('multer');
+const path = require('path');
+const fs = require('fs');
const router = express.Router();
const { Tenant, User } = require('../models');
const { authenticateToken } = require('../middleware/auth');
@@ -13,6 +16,40 @@ const MultiTenantAuth = require('../middleware/multi-tenant-auth');
// Initialize multi-tenant auth
const multiAuth = new MultiTenantAuth();
+// Configure multer for logo uploads
+const storage = multer.diskStorage({
+ destination: function (req, file, cb) {
+ const uploadDir = path.join(__dirname, '../uploads/logos');
+ // Create directory if it doesn't exist
+ if (!fs.existsSync(uploadDir)) {
+ fs.mkdirSync(uploadDir, { recursive: true });
+ }
+ cb(null, uploadDir);
+ },
+ filename: function (req, file, cb) {
+ // Use tenant ID and timestamp for unique filename
+ const tenantId = req.user.tenant_id;
+ const ext = path.extname(file.originalname);
+ const filename = `tenant-${tenantId}-${Date.now()}${ext}`;
+ cb(null, filename);
+ }
+});
+
+const upload = multer({
+ storage: storage,
+ limits: {
+ fileSize: 5 * 1024 * 1024 // 5MB limit
+ },
+ fileFilter: function (req, file, cb) {
+ // Accept only image files
+ if (file.mimetype.startsWith('image/')) {
+ cb(null, true);
+ } else {
+ cb(new Error('Only image files are allowed'), false);
+ }
+ }
+});
+
/**
* GET /tenant/info
* Get current tenant information
@@ -71,6 +108,83 @@ router.get('/info', authenticateToken, requirePermissions(['tenant.view']), asyn
}
});
+/**
+ * POST /tenant/logo-upload
+ * Upload tenant logo (branding admin or higher)
+ */
+router.post('/logo-upload', authenticateToken, requirePermissions(['branding.edit']), upload.single('logo'), async (req, res) => {
+ try {
+ if (!req.file) {
+ return res.status(400).json({
+ success: false,
+ message: 'No file uploaded'
+ });
+ }
+
+ // Determine tenant from request
+ const tenantId = await multiAuth.determineTenant(req);
+ if (!tenantId) {
+ return res.status(400).json({
+ success: false,
+ message: 'Invalid tenant'
+ });
+ }
+
+ // Get tenant
+ const tenant = await Tenant.findByPk(tenantId);
+ if (!tenant) {
+ return res.status(404).json({
+ success: false,
+ message: 'Tenant not found'
+ });
+ }
+
+ // Clean up old logo file if it exists
+ if (tenant.branding?.logo_url) {
+ const oldLogoPath = tenant.branding.logo_url.replace('/uploads/logos/', '');
+ const oldFilePath = path.join(__dirname, '../uploads/logos', oldLogoPath);
+ if (fs.existsSync(oldFilePath)) {
+ fs.unlinkSync(oldFilePath);
+ }
+ }
+
+ // Create logo URL
+ const logoUrl = `/uploads/logos/${req.file.filename}`;
+
+ // Update tenant branding with new logo
+ const updatedBranding = {
+ ...tenant.branding,
+ logo_url: logoUrl
+ };
+
+ await tenant.update({ branding: updatedBranding });
+
+ console.log(`✅ Tenant "${tenantId}" logo uploaded by user "${req.user.username}"`);
+
+ res.json({
+ success: true,
+ message: 'Logo uploaded successfully',
+ data: {
+ logo_url: logoUrl,
+ branding: updatedBranding
+ }
+ });
+
+ } catch (error) {
+ console.error('Error uploading logo:', error);
+
+ // Clean up uploaded file on error
+ if (req.file && fs.existsSync(req.file.path)) {
+ fs.unlinkSync(req.file.path);
+ }
+
+ res.status(500).json({
+ success: false,
+ message: 'Failed to upload logo'
+ });
+ }
+});
+
/**
* PUT /tenant/branding
* Update tenant branding (branding admin or higher)