screentinker/server/routes/white-label.js
ScreenTinker eb13f716d0 feat(branding): instance-level default white-label branding (#15)
White-label is stored per-workspace (white_labels.workspace_id); unbranded and
new workspaces - and the login page - fell back to hardcoded ScreenTinker. Add a
single platform default that everything inherits beneath the per-workspace layer.

Resolution (lib/branding.js): workspace row -> custom-domain match -> platform
default -> hardcoded ScreenTinker. Row-level override: a workspace with its own
row keeps it (current behavior); only row-less workspaces inherit the default,
so editing the default propagates instantly (no row-copying at creation).

The platform default is a white_labels row with a FIXED id ('platform-default'),
not a "workspace_id IS NULL" sentinel - legacy pre-multitenancy rows can also
have a null workspace_id, which would be ambiguous.

- routes/admin.js: GET/PUT /api/admin/branding (requirePlatformAdmin) to read/
  upsert the single platform-default row; audit-logged.
- server.js: public GET /api/branding (domain match -> platform default ->
  hardcoded) for pre-login/pre-workspace contexts.
- routes/white-label.js: authed GET now falls back to the platform default
  (was hardcoded) for row-less workspaces.
- Frontend: login page resolves + applies branding (logo, name, colors, favicon,
  custom CSS) pre-auth; Admin page gets a "Default branding" form.

Tests: resolver order incl. legacy null-ws safety; admin GET/PUT (single row,
upsert, platform-admin-only 403). Full suite 37/37. Verified end-to-end:
public + authed + login-page all inherit the platform default; per-workspace
override preserved.

Closes #15.
2026-06-08 16:55:22 -05:00

60 lines
2.9 KiB
JavaScript

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');
const { resolveBranding } = require('../lib/branding');
// 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) => {
res.json(resolveBranding(db, { workspaceId: req.workspaceId || null }));
});
// 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) => {
res.json(resolveBranding(db, { domain: req.params.domain }));
});
// 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 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 };
const updates = [];
const values = [];
Object.entries(fields).forEach(([k, v]) => {
if (v !== undefined) { updates.push(`${k} = ?`); values.push(v); }
});
if (updates.length) {
updates.push("updated_at = strftime('%s','now')");
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, 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 workspace_id = ?').get(req.workspaceId));
});
module.exports = router;