Phase 2.2f: white-label.js scoped to workspace_id; requireWorkspaceAdmin gate; status.js bundle

This commit is contained in:
ScreenTinker 2026-05-11 21:30:22 -05:00
parent 806c931e43
commit 0d642e4d80
2 changed files with 42 additions and 19 deletions

View file

@ -61,11 +61,13 @@ router.get('/export', (req, res) => {
if (!token) return res.status(401).json({ error: 'Token required' }); if (!token) return res.status(401).json({ error: 'Token required' });
let userId; let userId;
let workspaceId;
try { try {
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const config = require('../config'); const config = require('../config');
const decoded = jwt.verify(token, config.jwtSecret); const decoded = jwt.verify(token, config.jwtSecret);
userId = decoded.id; userId = decoded.id;
workspaceId = decoded.current_workspace_id || null;
if (!userId) return res.status(401).json({ error: 'Invalid token' }); if (!userId) return res.status(401).json({ error: 'Invalid token' });
} catch { } catch {
return res.status(401).json({ error: 'Invalid token' }); 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); 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' }); 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 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 deviceIds = devices.map(d => d.id);
const devicePlaceholders = deviceIds.map(() => '?').join(',') || "'__none__'"; const devicePlaceholders = deviceIds.map(() => '?').join(',') || "'__none__'";
@ -102,7 +115,7 @@ router.get('/export', (req, res) => {
const groupPlaceholders = groupIds.map(() => '?').join(',') || "'__none__'"; 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 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 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 = { const exportData = {
format: 'screentinker-export-v2', 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)); 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 // Import white label - UPSERT into the importer's current workspace.
if (data.white_label) { if (data.white_label && workspaceId) {
const wl = data.white_label; 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) { 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 { } 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);
} }
} }
}); });

View file

@ -2,30 +2,40 @@ const express = require('express');
const router = express.Router(); const router = express.Router();
const { v4: uuidv4 } = require('uuid'); const { v4: uuidv4 } = require('uuid');
const { db } = require('../db/database'); 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) => { 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) { if (!wl) {
// Return default branding
wl = { brand_name: 'ScreenTinker', primary_color: '#3B82F6', secondary_color: '#1E293B', bg_color: '#111827', hide_branding: 0 }; wl = { brand_name: 'ScreenTinker', primary_color: '#3B82F6', secondary_color: '#1E293B', bg_color: '#111827', hide_branding: 0 };
} }
res.json(wl); 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) => { router.get('/domain/:domain', (req, res) => {
const wl = db.prepare('SELECT * FROM white_labels WHERE custom_domain = ?').get(req.params.domain); 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' }); if (!wl) return res.json({ brand_name: 'ScreenTinker', primary_color: '#3B82F6' });
res.json(wl); res.json(wl);
}); });
// Create or update white-label config // Create or update the current workspace's white-label config. Restricted to
router.post('/', (req, res) => { // 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, const { brand_name, logo_url, favicon_url, primary_color, secondary_color, bg_color,
custom_domain, custom_css, hide_branding } = req.body; 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) { if (wl) {
const fields = { brand_name, logo_url, favicon_url, primary_color, secondary_color, bg_color, custom_domain, custom_css, hide_branding }; 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) { if (updates.length) {
updates.push("updated_at = strftime('%s','now')"); updates.push("updated_at = strftime('%s','now')");
values.push(req.user.id); values.push(req.workspaceId);
db.prepare(`UPDATE white_labels SET ${updates.join(', ')} WHERE user_id = ?`).run(...values); db.prepare(`UPDATE white_labels SET ${updates.join(', ')} WHERE workspace_id = ?`).run(...values);
} }
} else { } else {
const id = uuidv4(); 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) 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( VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
id, req.user.id, brand_name || 'ScreenTinker', logo_url || null, favicon_url || null, id, req.user.id, req.workspaceId, brand_name || 'ScreenTinker', logo_url || null, favicon_url || null,
primary_color || '#3B82F6', secondary_color || '#1E293B', bg_color || '#111827', primary_color || '#3B82F6', secondary_color || '#1E293B', bg_color || '#111827',
custom_domain || null, custom_css || null, hide_branding ? 1 : 0); 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; module.exports = router;