mirror of
https://github.com/screentinker/screentinker.git
synced 2026-06-15 10:43:36 -06:00
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.
119 lines
6.3 KiB
JavaScript
119 lines
6.3 KiB
JavaScript
'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);
|
|
});
|