From 0d642e4d8018f23060710a6fe38a3a67159a0d4d Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Mon, 11 May 2026 21:30:22 -0500 Subject: [PATCH] Phase 2.2f: white-label.js scoped to workspace_id; requireWorkspaceAdmin gate; status.js bundle --- server/routes/status.js | 25 +++++++++++++++++++------ server/routes/white-label.js | 36 +++++++++++++++++++++++------------- 2 files changed, 42 insertions(+), 19 deletions(-) diff --git a/server/routes/status.js b/server/routes/status.js index b7c6079..b2e96e5 100644 --- a/server/routes/status.js +++ b/server/routes/status.js @@ -61,11 +61,13 @@ router.get('/export', (req, res) => { if (!token) return res.status(401).json({ error: 'Token required' }); let userId; + let workspaceId; try { const jwt = require('jsonwebtoken'); const config = require('../config'); const decoded = jwt.verify(token, config.jwtSecret); userId = decoded.id; + workspaceId = decoded.current_workspace_id || null; if (!userId) return res.status(401).json({ error: 'Invalid token' }); } catch { return res.status(401).json({ error: 'Invalid token' }); @@ -74,6 +76,17 @@ router.get('/export', (req, res) => { const user = db.prepare('SELECT id, email, name, role, auth_provider, plan_id, created_at FROM users WHERE id = ?').get(userId); if (!user) return res.status(404).json({ error: 'User not found' }); + // Phase 2.2f: export workspace-scoped branding. Fall back to first-accessible + // workspace if the JWT didn't carry one. + if (!workspaceId) { + const w = db.prepare(` + SELECT w.id FROM workspaces w + JOIN workspace_members wm ON wm.workspace_id = w.id + WHERE wm.user_id = ? ORDER BY wm.joined_at ASC LIMIT 1 + `).get(userId); + workspaceId = w?.id || null; + } + const devices = db.prepare('SELECT id, name, status, ip_address, android_version, app_version, screen_width, screen_height, created_at FROM devices WHERE user_id = ?').all(userId); const deviceIds = devices.map(d => d.id); const devicePlaceholders = deviceIds.map(() => '?').join(',') || "'__none__'"; @@ -102,7 +115,7 @@ router.get('/export', (req, res) => { const groupPlaceholders = groupIds.map(() => '?').join(',') || "'__none__'"; const groupMembers = groupIds.length ? db.prepare(`SELECT * FROM device_group_members WHERE group_id IN (${groupPlaceholders})`).all(...groupIds) : []; const alertConfigs = db.prepare('SELECT id, alert_type, enabled, config, created_at FROM alert_configs WHERE user_id = ?').all(userId); - const whiteLabel = db.prepare('SELECT * FROM white_labels WHERE user_id = ?').get(userId); + const whiteLabel = workspaceId ? db.prepare('SELECT * FROM white_labels WHERE workspace_id = ?').get(workspaceId) : null; const exportData = { format: 'screentinker-export-v2', @@ -425,14 +438,14 @@ router.post('/import', importUpload.single('file'), async (req, res) => { db.prepare(`INSERT INTO alert_configs (id, user_id, alert_type, enabled, config, created_at) VALUES (?, ?, ?, ?, ?, ?)`).run(newId, userId, a.alert_type, a.enabled !== undefined ? a.enabled : 1, config, a.created_at || Math.floor(Date.now() / 1000)); } - // Import white label - if (data.white_label) { + // Import white label - UPSERT into the importer's current workspace. + if (data.white_label && workspaceId) { const wl = data.white_label; - const existing = db.prepare('SELECT id FROM white_labels WHERE user_id = ?').get(userId); + const existing = db.prepare('SELECT id FROM white_labels WHERE workspace_id = ?').get(workspaceId); if (existing) { - db.prepare(`UPDATE white_labels SET brand_name=?, logo_url=?, favicon_url=?, primary_color=?, bg_color=?, custom_domain=?, custom_css=?, hide_branding=?, updated_at=strftime('%s','now') WHERE user_id=?`).run(wl.brand_name || 'ScreenTinker', wl.logo_url || null, wl.favicon_url || null, wl.primary_color || '#3B82F6', wl.bg_color || '#111827', wl.custom_domain || null, wl.custom_css || null, wl.hide_branding || 0, userId); + db.prepare(`UPDATE white_labels SET brand_name=?, logo_url=?, favicon_url=?, primary_color=?, bg_color=?, custom_domain=?, custom_css=?, hide_branding=?, updated_at=strftime('%s','now') WHERE workspace_id=?`).run(wl.brand_name || 'ScreenTinker', wl.logo_url || null, wl.favicon_url || null, wl.primary_color || '#3B82F6', wl.bg_color || '#111827', wl.custom_domain || null, wl.custom_css || null, wl.hide_branding || 0, workspaceId); } else { - db.prepare(`INSERT INTO white_labels (id, user_id, brand_name, logo_url, favicon_url, primary_color, bg_color, custom_domain, custom_css, hide_branding) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(uuid.v4(), userId, wl.brand_name || 'ScreenTinker', wl.logo_url || null, wl.favicon_url || null, wl.primary_color || '#3B82F6', wl.bg_color || '#111827', wl.custom_domain || null, wl.custom_css || null, wl.hide_branding || 0); + db.prepare(`INSERT INTO white_labels (id, user_id, workspace_id, brand_name, logo_url, favicon_url, primary_color, bg_color, custom_domain, custom_css, hide_branding) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(uuid.v4(), userId, workspaceId, wl.brand_name || 'ScreenTinker', wl.logo_url || null, wl.favicon_url || null, wl.primary_color || '#3B82F6', wl.bg_color || '#111827', wl.custom_domain || null, wl.custom_css || null, wl.hide_branding || 0); } } }); diff --git a/server/routes/white-label.js b/server/routes/white-label.js index 37fe7b8..a152200 100644 --- a/server/routes/white-label.js +++ b/server/routes/white-label.js @@ -2,30 +2,40 @@ const express = require('express'); const router = express.Router(); const { v4: uuidv4 } = require('uuid'); const { db } = require('../db/database'); +// Phase 2.2f: workspace-scoped branding. POST gated by requireWorkspaceAdmin +// per the design doc (branding is a workspace_admin power, not editor). +const { requireWorkspaceAdmin } = require('../lib/permissions'); -// Get current user's white-label config +// Get current workspace's white-label config. router.get('/', (req, res) => { - let wl = db.prepare('SELECT * FROM white_labels WHERE user_id = ?').get(req.user.id); + if (!req.workspaceId) { + return res.json({ brand_name: 'ScreenTinker', primary_color: '#3B82F6', secondary_color: '#1E293B', bg_color: '#111827', hide_branding: 0 }); + } + let wl = db.prepare('SELECT * FROM white_labels WHERE workspace_id = ?').get(req.workspaceId); if (!wl) { - // Return default branding wl = { brand_name: 'ScreenTinker', primary_color: '#3B82F6', secondary_color: '#1E293B', bg_color: '#111827', hide_branding: 0 }; } res.json(wl); }); -// Get branding by domain (public, for white-label domains) +// Get branding by custom domain (public, unauthenticated - used pre-login by +// white-label frontends to resolve their hostname's branding). Keyed by the +// globally-unique custom_domain column; no scope check. router.get('/domain/:domain', (req, res) => { const wl = db.prepare('SELECT * FROM white_labels WHERE custom_domain = ?').get(req.params.domain); if (!wl) return res.json({ brand_name: 'ScreenTinker', primary_color: '#3B82F6' }); res.json(wl); }); -// Create or update white-label config -router.post('/', (req, res) => { +// Create or update the current workspace's white-label config. Restricted to +// workspace_admin / org_owner / org_admin / platform_admin. +router.post('/', requireWorkspaceAdmin, (req, res) => { + if (!req.workspaceId) return res.status(403).json({ error: 'No workspace context. Switch to a workspace before configuring branding.' }); + const { brand_name, logo_url, favicon_url, primary_color, secondary_color, bg_color, custom_domain, custom_css, hide_branding } = req.body; - let wl = db.prepare('SELECT * FROM white_labels WHERE user_id = ?').get(req.user.id); + let wl = db.prepare('SELECT * FROM white_labels WHERE workspace_id = ?').get(req.workspaceId); if (wl) { const fields = { brand_name, logo_url, favicon_url, primary_color, secondary_color, bg_color, custom_domain, custom_css, hide_branding }; @@ -36,19 +46,19 @@ router.post('/', (req, res) => { }); if (updates.length) { updates.push("updated_at = strftime('%s','now')"); - values.push(req.user.id); - db.prepare(`UPDATE white_labels SET ${updates.join(', ')} WHERE user_id = ?`).run(...values); + values.push(req.workspaceId); + db.prepare(`UPDATE white_labels SET ${updates.join(', ')} WHERE workspace_id = ?`).run(...values); } } else { const id = uuidv4(); - db.prepare(`INSERT INTO white_labels (id, user_id, brand_name, logo_url, favicon_url, primary_color, secondary_color, bg_color, custom_domain, custom_css, hide_branding) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run( - id, req.user.id, brand_name || 'ScreenTinker', logo_url || null, favicon_url || null, + db.prepare(`INSERT INTO white_labels (id, user_id, workspace_id, brand_name, logo_url, favicon_url, primary_color, secondary_color, bg_color, custom_domain, custom_css, hide_branding) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run( + id, req.user.id, req.workspaceId, brand_name || 'ScreenTinker', logo_url || null, favicon_url || null, primary_color || '#3B82F6', secondary_color || '#1E293B', bg_color || '#111827', custom_domain || null, custom_css || null, hide_branding ? 1 : 0); } - res.json(db.prepare('SELECT * FROM white_labels WHERE user_id = ?').get(req.user.id)); + res.json(db.prepare('SELECT * FROM white_labels WHERE workspace_id = ?').get(req.workspaceId)); }); module.exports = router;