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 && ( +
+ Current logo { + 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 && ( +
+
+ Uploading... +
+ )} +
+ + {/* Preview */} + {logoPreview && ( +
+ Logo preview +

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)