screentinker/frontend/js/branding.js
ScreenTinker 9823aaf595 White-label: stop naming the upstream product to a reseller's customers (#292)
A partner reselling this platform reported that white-labelling changed the sidebar
title and the browser tab, and nothing else. Three fixes, in the order they matter to
them.

THE APK FILENAME, which they called the highest priority and which is a commercial
leak rather than a cosmetic one: every download landed on their customer's disk as
"ScreenTinker.apk", naming the upstream product — and where to buy it directly — to
the people they were selling to. /download/apk now resolves branding by DOMAIN, since
that route is unauthenticated and has no workspace to read, which is also exactly how
a reseller deploys: their own hostname, their own brand.

The name is sanitised through a whitelist, in lib/brand-filename.js so it can be
tested. That is security code, not cosmetics: brand_name is arbitrary operator text
landing in a Content-Disposition header, where a quote ends the filename parameter
early and a CR/LF ends the header line entirely. The tests are mostly hostile input.

ADMIN-CREATED USERS ARE VERIFIED. POST /api/admin/users left email_verified at the
schema default of 0, so every admin-provisioned user met a "Please confirm your email
address" banner they could not dismiss — and on an instance with no SMTP, could never
clear. Operators were fixing it by editing the database by hand. An address typed in
by an administrator is as verified as this system can make it. Note the test fixture
had drifted from the real schema and lacked the column entirely; adding it there is
what let the fix be tested at all.

THE HARDCODED STRINGS. Nine user-facing strings named the product — setup steps, the
empty-dashboard hint, onboarding, sign-in errors. They are translated strings, so the
substitution belongs in the translation layer: they now say {brandName}, and i18n.js
fills it in inside format(), so every t() call gets it without threading a variable
through several hundred call sites. Read at CALL time, not captured, so a workspace
switch shows the new brand rather than the one cached at module load. 43 strings across
7 locales; the default is the product's own name, so an un-branded install is unchanged.

Deliberately NOT changed, because substituting a brand there would be wrong rather than
incomplete:
  - the White Label brand_name input's placeholder, which shows the default when empty;
  - the install-statistics explanation, which describes what the upstream project can
    and cannot see, and is not about the reseller's brand;
  - the widget security warning, which describes the privileges of this software; that
    is copy worth changing deliberately rather than by regex.

Full suite 1779 pass / 0 fail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014kfhrUPit5MCqxeTQyqr56
2026-08-18 22:55:56 -05:00

74 lines
2.5 KiB
JavaScript

// Applies the current user's saved white-label config to the DOM.
// Runs once after login/route bootstrap. Without this, saved values in the
// white_labels table are read into the Settings form but never applied to
// the actual page — so users see "ScreenTinker" and default colors after
// every reload, as if their save reverted.
let applied = false;
// Current workspace id from the JWT, so the branding cache (read render-blocking by
// brand-prime.js) is keyed per workspace — a switch shows the right brand. (#38)
function currentWorkspaceId() {
try {
const seg = localStorage.getItem('token').split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
return (JSON.parse(atob(seg)) || {}).current_workspace_id || 'none';
} catch { return 'none'; }
}
export async function applyBranding() {
if (applied) return;
applied = true;
const token = localStorage.getItem('token');
if (!token) return;
let wl;
try {
const res = await fetch('/api/white-label', { headers: { Authorization: `Bearer ${token}` } });
if (!res.ok) return;
wl = await res.json();
} catch { return; }
if (!wl) return;
// Cache for the next load/switch so brand-prime.js can apply it before paint.
try { localStorage.setItem('rd_branding_' + currentWorkspaceId(), JSON.stringify(wl)); } catch {}
const root = document.documentElement;
if (wl.primary_color) root.style.setProperty('--accent', wl.primary_color);
if (wl.bg_color) {
root.style.setProperty('--bg-primary', wl.bg_color);
const meta = document.querySelector('meta[name="theme-color"]');
if (meta) meta.setAttribute('content', wl.bg_color);
}
if (wl.brand_name) {
document.title = wl.brand_name;
// Publish it for i18n: strings say {brandName} and read this at call time (#292).
window.__ST_BRAND_NAME = wl.brand_name;
const span = document.getElementById('brandName');
if (span) span.textContent = wl.brand_name;
}
if (wl.favicon_url) {
document.querySelectorAll('link[rel="icon"], link[rel="apple-touch-icon"]').forEach(l => {
l.setAttribute('href', wl.favicon_url);
});
}
if (wl.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 = wl.custom_css;
}
}
// Force a re-apply (called from settings.js after save)
export function resetBranding() {
applied = false;
return applyBranding();
}