diff --git a/frontend/js/api.js b/frontend/js/api.js index 446b9f5..8e2a9b4 100644 --- a/frontend/js/api.js +++ b/frontend/js/api.js @@ -178,6 +178,10 @@ export const api = { // workspaceId, role, mustChangePassword } adminCreateUser: (data) => request('/admin/users', { method: 'POST', body: JSON.stringify(data) }), + // Instance-level default branding (#15, platform admin). + adminGetBranding: () => request('/admin/branding'), + adminSetBranding: (data) => request('/admin/branding', { method: 'PUT', body: JSON.stringify(data) }), + // Per-user workspace membership management (platform Users page modal). adminGetUserWorkspaces: (id) => request(`/admin/users/${id}/workspaces`), adminAddUserWorkspace: (id, workspaceId, role) => request(`/admin/users/${id}/workspaces`, { method: 'POST', body: JSON.stringify({ workspaceId, role }) }), diff --git a/frontend/js/i18n/en.js b/frontend/js/i18n/en.js index d15cf95..9c0cb00 100644 --- a/frontend/js/i18n/en.js +++ b/frontend/js/i18n/en.js @@ -789,6 +789,18 @@ export default { 'admin.all_users': 'All Users', 'admin.plans': 'Subscription Plans', 'admin.system': 'System', + // #15: instance-level default branding + 'admin.branding.title': 'Default branding', + 'admin.branding.desc': "Instance-wide default. Every workspace that hasn't set its own white-label inherits this, as does the login page.", + 'admin.branding.brand_name': 'Brand name', + 'admin.branding.primary_color': 'Primary color', + 'admin.branding.bg_color': 'Background color', + 'admin.branding.logo_url': 'Logo URL', + 'admin.branding.favicon_url': 'Favicon URL', + 'admin.branding.custom_css': 'Custom CSS', + 'admin.branding.hide_branding': 'Hide "Powered by" branding', + 'admin.branding.save': 'Save branding', + 'admin.branding.saved': 'Default branding saved', 'admin.col.user': 'User', 'admin.col.auth': 'Auth', 'admin.col.last_login': 'Last Login', diff --git a/frontend/js/views/admin.js b/frontend/js/views/admin.js index acf40f0..2fe0153 100644 --- a/frontend/js/views/admin.js +++ b/frontend/js/views/admin.js @@ -66,6 +66,12 @@ export async function render(container) {
${t('common.loading')}
${t('admin.branding.desc')}
+${t('common.loading')}
${t('common.loading')}
${esc(e.message || 'Failed to load')}
`; return; } + const v = (x) => esc(x == null ? '' : x); + el.innerHTML = ` +${isSetup ? t('auth.subtitle_setup') : t('auth.subtitle_signin')}
diff --git a/server/lib/branding.js b/server/lib/branding.js new file mode 100644 index 0000000..2103ded --- /dev/null +++ b/server/lib/branding.js @@ -0,0 +1,53 @@ +'use strict'; + +// Issue #15: instance-level default white-label branding. +// +// Branding is stored per-workspace in white_labels (keyed by workspace_id). This +// adds a single "platform default" row that every workspace inherits unless it +// has set its own. Resolution order: +// 1. the current workspace's row (per-workspace override; unchanged) +// 2. a custom-domain match (public/pre-login white-label hosts) +// 3. the platform-default row (instance default, #15) +// 4. hardcoded ScreenTinker fallback +// +// The platform-default row is identified by a FIXED id (not "workspace_id IS +// NULL"): legacy pre-multitenancy white_labels rows can also have a null +// workspace_id, so a null-scope sentinel would be ambiguous. A fixed id is not. +// +// Override is ROW-LEVEL: a workspace that has any row uses it wholesale; only +// workspaces with NO row fall through to the platform default. No row-copying at +// creation, so editing the platform default propagates everywhere instantly. + +const PLATFORM_DEFAULT_ID = 'platform-default'; + +const HARDCODED_BRANDING = { + brand_name: 'ScreenTinker', + logo_url: null, + favicon_url: null, + primary_color: '#3B82F6', + secondary_color: '#1E293B', + bg_color: '#111827', + custom_css: null, + hide_branding: 0, +}; + +// The single platform-default row (fixed id), or null if none has been set. +function platformDefaultRow(db) { + return db.prepare('SELECT * FROM white_labels WHERE id = ?').get(PLATFORM_DEFAULT_ID) || null; +} + +// Resolve effective branding for a context. Pass whichever you have: +// { workspaceId } for the authed app, { domain } for the public/login path. +function resolveBranding(db, { workspaceId = null, domain = null } = {}) { + if (workspaceId) { + const wl = db.prepare('SELECT * FROM white_labels WHERE workspace_id = ?').get(workspaceId); + if (wl) return wl; + } + if (domain) { + const wl = db.prepare('SELECT * FROM white_labels WHERE custom_domain = ?').get(domain); + if (wl) return wl; + } + return platformDefaultRow(db) || { ...HARDCODED_BRANDING }; +} + +module.exports = { resolveBranding, platformDefaultRow, HARDCODED_BRANDING, PLATFORM_DEFAULT_ID }; diff --git a/server/routes/admin.js b/server/routes/admin.js index 6d07374..2ed955e 100644 --- a/server/routes/admin.js +++ b/server/routes/admin.js @@ -6,6 +6,7 @@ const { db } = require('../db/database'); const { canAdminWorkspace } = require('../lib/permissions'); const { requirePlatformAdmin } = require('../middleware/auth'); const { logActivity, getClientIp } = require('../services/activity'); +const { platformDefaultRow, HARDCODED_BRANDING, PLATFORM_DEFAULT_ID } = require('../lib/branding'); // Admin-provisioned user creation (#10). Operates on a target workspace // specified in the body, NOT the caller's active workspace - so this router is @@ -224,4 +225,52 @@ router.delete('/users/:id/workspaces/:workspaceId', requirePlatformAdmin, (req, res.json({ success: true }); }); +// ===================== Instance-level default branding (#15) ===================== +// Platform-admin only. The "platform default" is a single white_labels row with +// workspace_id IS NULL that every workspace inherits unless it set its own +// (resolution lives in lib/branding.js). Editable here / in the Admin UI. + +const BRANDING_FIELDS = ['brand_name', 'logo_url', 'favicon_url', 'primary_color', 'secondary_color', 'bg_color', 'custom_css', 'hide_branding']; + +// GET - the current platform-default branding (falls back to hardcoded so the +// admin form always has values to show). +router.get('/branding', requirePlatformAdmin, (req, res) => { + res.json(platformDefaultRow(db) || { ...HARDCODED_BRANDING }); +}); + +// PUT - upsert the single platform-default row (workspace_id IS NULL). +router.put('/branding', requirePlatformAdmin, (req, res) => { + const existing = platformDefaultRow(db); + if (existing) { + const updates = []; + const values = []; + for (const f of BRANDING_FIELDS) { + if (req.body[f] !== undefined) { + updates.push(`${f} = ?`); + values.push(f === 'hide_branding' ? (req.body[f] ? 1 : 0) : (req.body[f] || null)); + } + } + if (updates.length) { + updates.push("updated_at = strftime('%s','now')"); + values.push(existing.id); + db.prepare(`UPDATE white_labels SET ${updates.join(', ')} WHERE id = ?`).run(...values); + } + } else { + // Fixed id sentinel (not workspace_id IS NULL - see lib/branding.js). + // user_id is NOT NULL on the legacy table; stamp the acting admin. + db.prepare(` + INSERT INTO white_labels (id, user_id, workspace_id, brand_name, logo_url, favicon_url, primary_color, secondary_color, bg_color, custom_css, hide_branding) + VALUES (?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + PLATFORM_DEFAULT_ID, req.user.id, + req.body.brand_name || 'ScreenTinker', + req.body.logo_url || null, req.body.favicon_url || null, + req.body.primary_color || '#3B82F6', req.body.secondary_color || '#1E293B', req.body.bg_color || '#111827', + req.body.custom_css || null, req.body.hide_branding ? 1 : 0 + ); + } + logActivity(req.user.id, 'admin_set_platform_branding', `brand: ${req.body.brand_name || ''}`, null, getClientIp(req), null); + res.json(platformDefaultRow(db)); +}); + module.exports = router; diff --git a/server/routes/white-label.js b/server/routes/white-label.js index a152200..f96dded 100644 --- a/server/routes/white-label.js +++ b/server/routes/white-label.js @@ -5,26 +5,21 @@ 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'); +const { resolveBranding } = require('../lib/branding'); -// Get current workspace's white-label config. +// Get the current workspace's effective branding. #15: when the workspace has no +// row of its own, fall through to the platform default (workspace_id IS NULL) +// instead of the hardcoded ScreenTinker default, so unbranded/new workspaces +// inherit the instance brand. router.get('/', (req, res) => { - 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) { - wl = { brand_name: 'ScreenTinker', primary_color: '#3B82F6', secondary_color: '#1E293B', bg_color: '#111827', hide_branding: 0 }; - } - res.json(wl); + res.json(resolveBranding(db, { workspaceId: req.workspaceId || null })); }); -// 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. +// Get branding by custom domain. #15: domain match -> platform default -> +// hardcoded. (Mounted behind requireAuth like the rest of this router; the +// public/pre-login path is GET /api/branding, registered before auth.) 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); + res.json(resolveBranding(db, { domain: req.params.domain })); }); // Create or update the current workspace's white-label config. Restricted to diff --git a/server/server.js b/server/server.js index 84bb4bc..0e57ff1 100644 --- a/server/server.js +++ b/server/server.js @@ -276,6 +276,17 @@ app.use('/api/contact', require('./routes/contact')); app.use('/api/player-debug', rateLimit(60000, 10)); app.use('/api/player-debug', require('./routes/player-debug')); +// Public branding resolver (#15). Pre-login / pre-workspace contexts (the login +// page especially) need branding without a token. Resolves custom-domain match +// -> platform default -> hardcoded ScreenTinker. Domain comes from ?domain= or +// the request hostname (trust-proxy resolves the forwarded Host behind CF/Nginx). +app.get('/api/branding', (req, res) => { + const { db } = require('./db/database'); + const { resolveBranding } = require('./lib/branding'); + const domain = (req.query.domain || req.hostname || '').toString(); + res.json(resolveBranding(db, { domain })); +}); + // Stripe billing routes (checkout, portal) app.use('/api/stripe', stripeRouter); diff --git a/server/test/branding.test.js b/server/test/branding.test.js new file mode 100644 index 0000000..0987135 --- /dev/null +++ b/server/test/branding.test.js @@ -0,0 +1,118 @@ +'use strict'; + +// Issue #15: instance-level default branding. Tests the resolver order +// (workspace row -> custom-domain -> platform default -> hardcoded) and the +// platform-admin GET/PUT /api/admin/branding endpoints. + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const Database = require('better-sqlite3'); + +process.env.JWT_SECRET = 'test-secret-branding'; + +const db = new Database(':memory:'); +db.pragma('foreign_keys = ON'); +db.exec(` + CREATE TABLE users ( + id TEXT PRIMARY KEY, email TEXT UNIQUE NOT NULL, name TEXT DEFAULT '', + password_hash TEXT, auth_provider TEXT NOT NULL DEFAULT 'local', avatar_url TEXT, + role TEXT NOT NULL DEFAULT 'user', plan_id TEXT DEFAULT 'free', email_alerts INTEGER DEFAULT 1, + must_change_password INTEGER NOT NULL DEFAULT 0 + ); + CREATE TABLE white_labels ( + id TEXT PRIMARY KEY, user_id TEXT, brand_name TEXT NOT NULL DEFAULT 'ScreenTinker', + logo_url TEXT, favicon_url TEXT, primary_color TEXT DEFAULT '#3B82F6', + secondary_color TEXT DEFAULT '#1E293B', bg_color TEXT DEFAULT '#111827', + custom_domain TEXT, custom_css TEXT, hide_branding INTEGER DEFAULT 0, + workspace_id TEXT, created_at INTEGER DEFAULT (strftime('%s','now')), updated_at INTEGER DEFAULT (strftime('%s','now')) + ); + CREATE TABLE activity_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT, device_id TEXT, action TEXT NOT NULL, + details TEXT, ip_address TEXT, workspace_id TEXT, created_at INTEGER DEFAULT (strftime('%s','now')) + ); +`); + +const dbModulePath = require.resolve('../db/database'); +require.cache[dbModulePath] = { id: dbModulePath, filename: dbModulePath, loaded: true, exports: { db, pruneTelemetry() {}, pruneScreenshots() {} } }; + +const express = require('express'); +const { generateToken, requireAuth } = require('../middleware/auth'); +const { resolveBranding } = require('../lib/branding'); +const adminRouter = require('../routes/admin'); + +db.prepare("INSERT INTO users (id, email, role, password_hash) VALUES ('u-admin','admin@test.local','platform_admin','x')").run(); +db.prepare("INSERT INTO users (id, email, role, password_hash) VALUES ('u-reg','reg@test.local','user','x')").run(); +const tokens = { + admin: generateToken({ id: 'u-admin', email: 'admin@test.local', role: 'platform_admin' }, null), + reg: generateToken({ id: 'u-reg', email: 'reg@test.local', role: 'user' }, null), +}; + +const app = express(); +app.use(express.json()); +app.use('/api/admin', requireAuth, adminRouter); +const server = app.listen(0); +let base; +test.before(async () => { await new Promise(r => server.listening ? r() : server.once('listening', r)); base = `http://127.0.0.1:${server.address().port}`; }); +test.after(() => { server.close(); db.close(); }); + +const wl = (id, fields) => db.prepare( + `INSERT INTO white_labels (id, user_id, brand_name, custom_domain, workspace_id) VALUES (?, 'u-admin', ?, ?, ?)` +).run(id, fields.brand_name, fields.custom_domain || null, fields.workspace_id || null); + +test('resolver order: workspace row > domain > platform default > hardcoded', () => { + db.prepare('DELETE FROM white_labels').run(); + wl('w1', { brand_name: 'WS One', workspace_id: 'ws1' }); + // a custom-domain row belongs to a workspace (realistic); also seed a legacy + // null-workspace row to prove the fixed-id sentinel ignores it. + wl('dom', { brand_name: 'Domain Brand', custom_domain: 'cust.example', workspace_id: 'ws-dom' }); + wl('legacy', { brand_name: 'Legacy Null WS', workspace_id: null }); + wl('platform-default', { brand_name: 'Global Default', workspace_id: null }); // fixed-id platform default + + assert.equal(resolveBranding(db, { workspaceId: 'ws1' }).brand_name, 'WS One', 'workspace row wins'); + assert.equal(resolveBranding(db, { domain: 'cust.example' }).brand_name, 'Domain Brand', 'domain match'); + assert.equal(resolveBranding(db, { workspaceId: 'ws-none' }).brand_name, 'Global Default', 'unbranded workspace inherits platform default (not the legacy null-ws row)'); + assert.equal(resolveBranding(db, {}).brand_name, 'Global Default', 'no context -> platform default'); + + db.prepare("DELETE FROM white_labels WHERE id='platform-default'").run(); + assert.equal(resolveBranding(db, {}).brand_name, 'ScreenTinker', 'no platform default -> hardcoded (legacy null-ws row not used)'); +}); + +test('GET /api/admin/branding returns hardcoded default when none set', async () => { + db.prepare('DELETE FROM white_labels').run(); + const res = await fetch(base + '/api/admin/branding', { headers: { Authorization: `Bearer ${tokens.admin}` } }); + assert.equal(res.status, 200); + assert.equal((await res.json()).brand_name, 'ScreenTinker'); +}); + +test('PUT /api/admin/branding creates then updates the single platform-default row', async () => { + const put = (body) => fetch(base + '/api/admin/branding', { + method: 'PUT', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${tokens.admin}` }, body: JSON.stringify(body), + }); + let res = await put({ brand_name: 'Acme Signage', primary_color: '#10b981', hide_branding: true }); + assert.equal(res.status, 200); + // exactly one platform-default row, with workspace_id NULL + let rows = db.prepare("SELECT * FROM white_labels WHERE id = 'platform-default'").all(); + assert.equal(rows.length, 1); + assert.equal(rows[0].brand_name, 'Acme Signage'); + assert.equal(rows[0].primary_color, '#10b981'); + assert.equal(rows[0].hide_branding, 1); + + // second PUT updates the same row (no second row) + res = await put({ brand_name: 'Acme Displays' }); + assert.equal(res.status, 200); + rows = db.prepare("SELECT * FROM white_labels WHERE id = 'platform-default'").all(); + assert.equal(rows.length, 1, 'still a single platform-default row'); + assert.equal(rows[0].brand_name, 'Acme Displays'); + + // and now an unbranded workspace resolves to it + assert.equal(resolveBranding(db, { workspaceId: 'whatever' }).brand_name, 'Acme Displays'); +}); + +test('branding endpoints are platform-admin only (403 for a regular user)', async () => { + const get = await fetch(base + '/api/admin/branding', { headers: { Authorization: `Bearer ${tokens.reg}` } }); + assert.equal(get.status, 403); + const put = await fetch(base + '/api/admin/branding', { + method: 'PUT', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${tokens.reg}` }, body: JSON.stringify({ brand_name: 'Hacker' }), + }); + assert.equal(put.status, 403); +});