Merge pull request #26 from screentinker/feat/global-default-branding-15

feat: instance-level default white-label branding (#15)
This commit is contained in:
screentinker 2026-06-08 17:03:39 -05:00 committed by GitHub
commit d6e85b1745
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 346 additions and 22 deletions

View file

@ -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 }) }),

View file

@ -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',

View file

@ -66,6 +66,12 @@ export async function render(container) {
<div id="allUsersTable"><p style="color:var(--text-muted)">${t('common.loading')}</p></div>
</div>
<div class="settings-section">
<h3>${t('admin.branding.title')}</h3>
<p style="color:var(--text-muted);font-size:12px;margin-bottom:12px">${t('admin.branding.desc')}</p>
<div id="brandingForm"><p style="color:var(--text-muted)">${t('common.loading')}</p></div>
</div>
<div class="settings-section">
<h3>${t('admin.plans')}</h3>
<div id="plansTable"><p style="color:var(--text-muted)">${t('common.loading')}</p></div>
@ -92,11 +98,50 @@ export async function render(container) {
});
loadUsers();
loadBranding();
loadPlans();
loadSystem();
}
// #15: instance-level default branding form (platform default; every workspace
// without its own white-label inherits this, as does the login page).
async function loadBranding() {
const el = document.getElementById('brandingForm');
if (!el) return;
let b = {};
try { b = await api.adminGetBranding(); } catch (e) { el.innerHTML = `<p style="color:var(--danger)">${esc(e.message || 'Failed to load')}</p>`; return; }
const v = (x) => esc(x == null ? '' : x);
el.innerHTML = `
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;max-width:640px">
<div class="form-group" style="grid-column:1/-1"><label>${t('admin.branding.brand_name')}</label><input type="text" id="brBrandName" class="input" placeholder="ScreenTinker" value="${v(b.brand_name)}"></div>
<div class="form-group"><label>${t('admin.branding.primary_color')}</label><input type="text" id="brPrimary" class="input" placeholder="#3B82F6" value="${v(b.primary_color)}"></div>
<div class="form-group"><label>${t('admin.branding.bg_color')}</label><input type="text" id="brBg" class="input" placeholder="#111827" value="${v(b.bg_color)}"></div>
<div class="form-group" style="grid-column:1/-1"><label>${t('admin.branding.logo_url')}</label><input type="text" id="brLogo" class="input" placeholder="https:///logo.png" value="${v(b.logo_url)}"></div>
<div class="form-group" style="grid-column:1/-1"><label>${t('admin.branding.favicon_url')}</label><input type="text" id="brFavicon" class="input" placeholder="https:///favicon.ico" value="${v(b.favicon_url)}"></div>
<div class="form-group" style="grid-column:1/-1"><label>${t('admin.branding.custom_css')}</label><textarea id="brCss" class="input" rows="3" placeholder="/* optional */">${v(b.custom_css)}</textarea></div>
<label style="grid-column:1/-1;display:flex;align-items:center;gap:8px;font-size:13px;cursor:pointer">
<input type="checkbox" id="brHide" ${b.hide_branding ? 'checked' : ''}> ${t('admin.branding.hide_branding')}
</label>
</div>
<button class="btn btn-primary btn-sm" id="brSave" style="margin-top:12px">${t('admin.branding.save')}</button>
`;
document.getElementById('brSave').onclick = async () => {
try {
await api.adminSetBranding({
brand_name: document.getElementById('brBrandName').value.trim() || 'ScreenTinker',
primary_color: document.getElementById('brPrimary').value.trim() || null,
bg_color: document.getElementById('brBg').value.trim() || null,
logo_url: document.getElementById('brLogo').value.trim() || null,
favicon_url: document.getElementById('brFavicon').value.trim() || null,
custom_css: document.getElementById('brCss').value.trim() || null,
hide_branding: document.getElementById('brHide').checked,
});
showToast(t('admin.branding.saved'), 'success');
} catch (err) { showToast(err.message, 'error'); }
};
}
async function loadUsers() {
const el = document.getElementById('allUsersTable');
try {

View file

@ -10,22 +10,59 @@ async function loadAuthConfig() {
return authConfig;
}
// #15: resolve instance/default branding for the (pre-login) login page.
// Public endpoint: custom-domain match -> platform default -> ScreenTinker.
async function loadLoginBranding() {
try {
const res = await fetch('/api/branding?domain=' + encodeURIComponent(location.hostname));
if (!res.ok) return {};
return await res.json();
} catch { return {}; }
}
function brandEsc(s) {
return String(s ?? '').replace(/[&<>"']/g, c => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[c]));
}
// Apply document-level branding (colors, favicon, title, custom CSS) for login.
function applyLoginBrandingDoc(b) {
const root = document.documentElement;
if (b.primary_color) root.style.setProperty('--accent', b.primary_color);
if (b.bg_color) root.style.setProperty('--bg-primary', b.bg_color);
if (b.brand_name) document.title = b.brand_name;
if (b.favicon_url) {
document.querySelectorAll('link[rel="icon"], link[rel="apple-touch-icon"]').forEach(l => l.setAttribute('href', b.favicon_url));
}
if (b.custom_css) {
let style = document.getElementById('wl-custom-css');
if (!style) { style = document.createElement('style'); style.id = 'wl-custom-css'; document.head.appendChild(style); }
style.textContent = b.custom_css;
}
}
export async function render(container) {
const config = await loadAuthConfig();
const [config, branding] = await Promise.all([loadAuthConfig(), loadLoginBranding()]);
const isSetup = config.needsSetup;
// registration_enabled may be absent on older servers — treat as enabled for back-compat
const canRegister = config.registration_enabled !== false;
applyLoginBrandingDoc(branding);
const brandName = branding.brand_name || 'ScreenTinker';
// Branded logo if set, else the default ScreenTinker glyph.
const logoHtml = branding.logo_url
? `<img src="${brandEsc(branding.logo_url)}" alt="${brandEsc(brandName)}" style="max-height:48px;max-width:200px;margin:0 auto 12px;display:block">`
: `<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" stroke-width="2" style="margin:0 auto 12px">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/>
<line x1="8" y1="21" x2="16" y2="21"/>
<line x1="12" y1="17" x2="12" y2="21"/>
</svg>`;
container.innerHTML = `
<div style="display:flex;align-items:center;justify-content:center;min-height:100vh;padding:16px">
<div style="width:400px;max-width:100%">
<div style="text-align:center;margin-bottom:32px">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" stroke-width="2" style="margin:0 auto 12px">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/>
<line x1="8" y1="21" x2="16" y2="21"/>
<line x1="12" y1="17" x2="12" y2="21"/>
</svg>
<h1 style="font-size:24px;font-weight:700;color:var(--accent)">ScreenTinker</h1>
${logoHtml}
<h1 style="font-size:24px;font-weight:700;color:var(--accent)">${brandEsc(brandName)}</h1>
<p style="color:var(--text-secondary);font-size:13px;margin-top:4px">
${isSetup ? t('auth.subtitle_setup') : t('auth.subtitle_signin')}
</p>

53
server/lib/branding.js Normal file
View file

@ -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 };

View file

@ -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;

View file

@ -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

View file

@ -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);

View file

@ -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);
});