import { api } from '../api.js';
import { showToast } from '../components/toast.js';
import { getLanguage, setLanguage, getAvailableLanguages, t, tn } from '../i18n.js';
import { esc, isPlatformAdmin } from '../utils.js';
import { resetBranding } from '../branding.js';
export async function render(container) {
const serverUrl = `${window.location.protocol}//${window.location.host}`;
// Fetch fresh user from the server — plan_id and role may have been changed
// by an admin since login. Fall back to localStorage if the request fails.
let user;
try { user = await api.getMe(); localStorage.setItem('user', JSON.stringify(user)); }
catch { user = JSON.parse(localStorage.getItem('user') || '{}'); }
const isSuperAdmin = isPlatformAdmin(user);
// #14: the legacy 'admin' platform role was normalized away; platform-level
// admin is now just isPlatformAdmin. (Elevated capability otherwise comes from
// org/workspace membership, gated in the members views, not users.role.)
const isAdmin = isSuperAdmin;
const canManageOrgSecurity = isSuperAdmin || user.current_org_role === 'org_owner' || user.current_org_role === 'org_admin';
const widgetIsolationDisabled = !!user.current_organization?.widget_sandbox_isolation_disabled;
const WIDGET_ISOLATION_CONFIRM_PHRASE = 'I understand I am enabling a security hole';
// #83: the "About" version was hardcoded (showed v1.4.1 regardless of the build).
// Read it from the server (/api/version) the same way the admin view does.
let appVersion = '';
try { appVersion = ((await fetch('/api/version').then(r => r.json())).version) || ''; } catch { /* leave blank on failure */ }
container.innerHTML = `
`;
document.getElementById('saveFolderBtn').onclick = async () => {
try {
await api.setTokenUploadFolder(id, document.getElementById('rebindFolder').value || null);
showToast(t('apitoken.folder_updated'), 'success');
panel.style.display = 'none';
loadTokens();
} catch (err) { showToast(err.message, 'error'); }
};
document.getElementById('cancelFolderBtn').onclick = () => { panel.style.display = 'none'; };
}));
}
// ==================== Two-factor authentication (#100) ====================
// Drives the merged TOTP backend (/api/auth/totp/*). Re-renders #twoFactorBlock
// for each state: SSO note / disabled+enroll / recovery-codes / enabled+manage.
/*
* Sign-in method: password OR one instance-wide provider, never both.
*
* The warning on the link button is the whole UX: the local password is DELETED, not kept as a
* fallback, and someone who does not read that will think they gained a second way in. Unlink
* asks for the new password up front for the same reason — the account must never sit between
* credentials.
*
* Only instance-wide providers appear. An organization's provider is chosen by a customer and
* must not be attachable to a platform account; the server refuses it too.
*/
async function loadSsoLink() {
const block = document.getElementById('ssoLinkBlock');
if (!block) return;
const head = `
${t('settings.signin_method')}
`;
const muted = 'color:var(--text-muted);font-size:12px';
const paint = (inner) => { block.innerHTML = head + inner; };
let me;
try { me = await api.getMe(); }
catch (e) { paint(`
${esc(e.message)}
`); return; }
let providers = [];
try {
const res = await fetch('/api/auth/providers');
if (res.ok) providers = (await res.json()).providers || [];
} catch { /* offline: fall through to the no-providers copy */ }
if (me.auth_provider && me.auth_provider !== 'local') {
const name = providers.find((p) => p.slug === me.auth_provider)?.name || me.auth_provider;
paint(`
`);
block.querySelectorAll('[data-link-slug]').forEach((btn) => {
btn.onclick = async () => {
const slug = btn.dataset.linkSlug;
const name = providers.find((p) => p.slug === slug)?.name || slug;
// Deliberately blunt: the password is destroyed, and that is the part people miss.
if (!window.confirm(t('settings.signin_link_warning', { provider: name }))) return;
/*
* Fetch the authorize URL, then navigate to it. NOT location.href straight at the start
* route: the session is a bearer token in localStorage, so a top-level navigation arrives
* with no Authorization header and is refused as anonymous.
*/
try {
const { url } = await api.ssoLinkStart(slug);
window.location.href = url;
} catch (e) { showToast(e.message, 'error'); }
};
});
}
async function load2FA() {
const block = document.getElementById('twoFactorBlock');
if (!block) return;
const head = `
${t('settings.2fa_title')}
`;
const muted = 'color:var(--text-muted);font-size:12px';
const paint = (inner) => { block.innerHTML = head + inner; };
let status;
try { status = await api.totpStatus(); }
catch (e) { paint(`
`;
const codeEl = document.getElementById('twoFactorActionCode');
codeEl.focus();
const go = async () => {
const code = codeEl.value.trim();
if (!code) { showToast(t('settings.2fa_code_required'), 'error'); return; }
try { await run(code); } catch (e) { showToast(e.message, 'error'); codeEl.select(); }
};
document.getElementById('twoFactorActionConfirm').addEventListener('click', go);
codeEl.addEventListener('keydown', (e) => { if (e.key === 'Enter') go(); });
document.getElementById('twoFactorActionCancel').addEventListener('click', () => { box.innerHTML = ''; });
}
}
loadTokens();
load2FA();
loadSsoLink();
/*
* Report the outcome of a link round trip.
*
* The callback returns to #/settings rather than the login page — an authenticated user bounced
* to a login screen to be told "that did not work" reads as having been signed out. Params are
* stripped afterwards so a refresh or a copied URL does not replay the message.
*/
(function reportLinkOutcome() {
const q = new URLSearchParams((location.hash.split('?')[1] || ''));
const linked = q.get('sso_linked');
const err = q.get('sso_error');
if (!linked && !err) return;
if (linked) {
showToast(t('settings.signin_linked_toast', { provider: linked }), 'success');
} else {
const known = ['link_email_mismatch', 'link_already_used', 'not_linkable', 'no_email',
'email_unverified', 'verification_failed', 'provider_unavailable', 'provider_refused',
'unknown_provider', 'expired', 'bad_state', 'no_code', 'server_error'];
const key = known.includes(err) ? `settings.signin_err_${err}` : 'auth.sso_failed';
showToast(t(key), 'error');
}
history.replaceState(null, '', location.pathname + location.search + '#/settings');
loadSsoLink();
}());
// #73: agency scope reveals a playlist picker (the token's allowlist). Loaded lazily once.
const tokScopeSel = document.getElementById('tokScope');
let agencyPlaylistsLoaded = false;
tokScopeSel?.addEventListener('change', async () => {
const picker = document.getElementById('agencyPlaylistPicker');
const isAgency = tokScopeSel.value === 'agency';
picker.style.display = isAgency ? 'block' : 'none';
if (isAgency && !agencyPlaylistsLoaded) {
agencyPlaylistsLoaded = true;
const list = document.getElementById('agencyPlaylistList');
const pls = await api.getPlaylists().catch(() => []);
list.innerHTML = pls.length
? pls.map(p => p.zoned
? ``
: ``).join('')
: `
${t('apitoken.agency_no_playlists')}
`;
// #158: offer existing folders to bind, or leave on the auto-create default.
const folders = await api.getFolders().catch(() => []);
const fsel = document.getElementById('tokUploadFolder');
if (fsel && folders.length) fsel.insertAdjacentHTML('beforeend', folders.map(f => ``).join(''));
}
});
/* ── Per-organization SSO ──────────────────────────────────────────────────────────────────
*
* Only an org owner/admin sees this. The server enforces the same rule (and answers 404, not
* 403, so an outsider learns nothing) — this just avoids showing a card the user cannot use.
*/
const orgId = user.current_organization?.id;
const canManageSso = orgId && ['org_owner', 'org_admin'].includes(user.current_org_role);
async function loadSso() {
const card = document.getElementById('ssoCard');
if (!card || !canManageSso) return;
card.style.display = '';
const listEl = document.getElementById('ssoList');
let providers = [];
try {
const res = await fetch(`/api/organizations/${orgId}/sso`, {
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` },
});
if (!res.ok) throw new Error('load failed');
providers = (await res.json()).providers || [];
} catch {
listEl.innerHTML = `
${esc(t('sso.load_failed'))}
`;
return;
}
if (!providers.length) {
listEl.innerHTML = `
${esc(t('sso.none'))}
`;
return;
}
// Requiring SSO is a separate decision from having it, so it gets its own block rather than
// hiding inside a provider — an organization may have several providers and one answer.
let onlyState = null;
try {
const r = await fetch(`/api/organizations/${orgId}/sso-only`, {
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` },
});
if (r.ok) onlyState = await r.json();
} catch { /* the providers still render; the toggle simply does not appear */ }
const origin = `${window.location.protocol}//${window.location.host}`;
listEl.innerHTML = providers.map((p) => `
`).join('');
listEl.querySelectorAll('[data-sso-toggle]').forEach((btn) => {
btn.addEventListener('click', async () => {
await ssoRequest('PUT', `/${btn.dataset.ssoToggle}`, { enabled: btn.dataset.enabled !== '1' });
});
});
/*
* Ask the server to look for the DNS record now. Pull-based on purpose: the admin has just
* edited DNS and wants an answer, and a failure has to say WHICH failure — not published yet,
* published wrong, or the claim expired and the record has changed underneath them.
*/
if (onlyState) {
const pend = onlyState.pending_removal_request;
const box = document.createElement('div');
box.style.cssText = 'border:1px solid var(--border);border-radius:var(--radius);padding:12px;margin-top:4px';
box.innerHTML = `
${esc(t('sso.only_heading'))}
${esc(t('sso.only_help'))}
${onlyState.sso_only ? `
✅ ${esc(t('sso.only_on'))}
${pend
? `
⏳ ${esc(t('sso.only_pending'))}
`
: `
${esc(t('sso.only_remove_help'))}
`}
` : `
${esc(t('sso.only_off'))}
${onlyState.verified_domains
? ``
: `
⚠️ ${esc(t('sso.only_needs_domain'))}
`}
`}`;
listEl.appendChild(box);
const post = async (url, body, method = 'POST') => {
const r = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}` },
body: body ? JSON.stringify(body) : undefined,
});
const j = await r.json().catch(() => ({}));
if (!r.ok) { showToast(j.error || t('sso.only_failed'), 'error'); return null; }
return j;
};
const enableBtn = box.querySelector('#ssoOnlyEnable');
if (enableBtn) enableBtn.addEventListener('click', async () => {
// Confirmed, because it removes the only way in for everyone at these domains, and the way
// back needs the operator rather than this button.
if (!window.confirm(t('sso.only_confirm'))) return;
const r = await post(`/api/organizations/${orgId}/sso-only`);
if (r) {
showToast(t('sso.only_on'), 'success');
/*
* Name the people who just lost their only way in. The server reports them precisely so
* the admin finds out HERE rather than from a support ticket — and it was being thrown
* away, which made the whole warning pointless.
*/
const stranded = r.stranded_members || [];
if (stranded.length) {
window.alert(t('sso.only_stranded', { list: stranded.join('\n') }));
}
await loadSso();
}
});
const reqBtn = box.querySelector('#ssoOnlyRequest');
if (reqBtn) reqBtn.addEventListener('click', async () => {
const reason = window.prompt(t('sso.only_reason_prompt')) || '';
const r = await post(`/api/organizations/${orgId}/sso-only/removal-request`, { reason });
if (r) { showToast(t('sso.only_requested'), 'success'); await loadSso(); }
});
const cancelBtn = box.querySelector('#ssoOnlyCancel');
if (cancelBtn) cancelBtn.addEventListener('click', async () => {
const r = await post(`/api/organizations/${orgId}/sso-only/removal-request/${cancelBtn.dataset.req}`, null, 'DELETE');
if (r) { showToast(t('sso.only_cancelled'), 'success'); await loadSso(); }
});
}
listEl.querySelectorAll('[data-sso-verify]').forEach((btn) => {
btn.addEventListener('click', async () => {
const id = btn.dataset.ssoVerify;
const domain = btn.dataset.domain;
// Indexed, not derived from the domain: `a.b.test` and `a-b.test` both slugify to
// `a-b-test`, and getElementById would put one domain's answer in the other's box.
const out = document.getElementById(`ssoVerify-${id}-${btn.dataset.di}`);
btn.disabled = true;
if (out) { out.style.color = 'var(--text-muted)'; out.textContent = t('sso.verifying'); }
try {
const res = await fetch(`/api/organizations/${orgId}/sso/${id}/domains/${encodeURIComponent(domain)}/verify`, {
method: 'POST',
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` },
});
const body = await res.json().catch(() => ({}));
if (body.ok) {
showToast(t('sso.domain_verified_toast', { domain }), 'success');
await loadSso(); // re-render: the domain now routes, and the card must say so
return;
}
// An expired claim has already been reissued server-side, so the records on screen are
// stale — reload rather than leaving the admin publishing a value that no longer matches.
if (body.expired) {
showToast(body.error || t('sso.verify_failed'), 'error');
await loadSso();
return;
}
if (out) { out.style.color = 'var(--danger,#b91c1c)'; out.textContent = body.error || t('sso.verify_failed'); }
} catch {
if (out) { out.style.color = 'var(--danger,#b91c1c)'; out.textContent = t('sso.verify_failed'); }
} finally {
btn.disabled = false;
}
});
});
listEl.querySelectorAll('[data-sso-test]').forEach((btn) => {
btn.addEventListener('click', async () => {
const id = btn.dataset.ssoTest;
const out = document.getElementById(`ssoTest-${id}`);
if (!out) return;
out.style.display = '';
out.textContent = t('sso.testing');
try {
const res = await fetch(`/api/organizations/${orgId}/sso/${id}/test`, {
method: 'POST',
headers: { Authorization: `Bearer ${localStorage.getItem('token')}` },
});
const data = await res.json();
if (!res.ok) { out.textContent = data.error || t('sso.test_failed'); return; }
/*
* Literal keys, never a key built by concatenating a check name. Doing that defeats the
* check in server/test/i18n-keys-exist.js that every key an operator can see is
* translated — and a check name the UI does not know would render as raw key text. The
* fallback keeps an unknown one readable instead.
*/
const CHECK_LABELS = {
discovery: t('sso.check_discovery'),
endpoints: t('sso.check_endpoints'),
signing_keys: t('sso.check_signing_keys'),
};
const rows = (data.checks || []).map((c) => `
`).join('');
/*
* The caveat is shown on SUCCESS, not tucked away. Discovery and keys prove the provider
* exists and that we could verify a token it signs — they say nothing about whether the
* client id, the secret, or the redirect URI registration are right. A green tick that
* implied "SSO works" would send an admin away from the one thing still to check.
*/
out.innerHTML = rows + (data.ok
? `
${esc(t('sso.test_caveat'))}
`
: '');
} catch {
out.textContent = t('sso.test_failed');
}
});
});
listEl.querySelectorAll('[data-sso-edit]').forEach((btn) => {
btn.addEventListener('click', () => {
const panel = document.getElementById(`ssoEdit-${btn.dataset.ssoEdit}`);
if (panel) panel.style.display = panel.style.display === 'none' ? 'block' : 'none';
});
});
listEl.querySelectorAll('[data-sso-cancel]').forEach((btn) => {
btn.addEventListener('click', () => {
const panel = document.getElementById(`ssoEdit-${btn.dataset.ssoCancel}`);
if (panel) panel.style.display = 'none';
});
});
listEl.querySelectorAll('[data-sso-save]').forEach((btn) => {
btn.addEventListener('click', async () => {
const panel = document.getElementById(`ssoEdit-${btn.dataset.ssoSave}`);
if (!panel) return;
const val = (f) => panel.querySelector(`[data-f="${f}"]`)?.value?.trim() ?? '';
const body = {
name: val('name'),
issuer: val('issuer'),
client_id: val('client_id'),
email_domains: val('email_domains'),
};
/*
* Three states, and only these three:
* typed a value -> replace the secret
* ticked "remove" -> send '' so the server clears it
* left blank, unticked -> send NOTHING, so the stored secret survives
* Sending '' on every save is the bug this shape exists to avoid.
*/
const typed = panel.querySelector('[data-f="client_secret"]')?.value || '';
const clearing = panel.querySelector('[data-f="clear_secret"]')?.checked;
if (typed) body.client_secret = typed;
else if (clearing) body.client_secret = '';
if (!body.name || !body.issuer || !body.client_id) {
showToast(t('sso.missing_fields'), 'error');
return;
}
await ssoRequest('PUT', `/${btn.dataset.ssoSave}`, body);
});
});
listEl.querySelectorAll('[data-sso-delete]').forEach((btn) => {
btn.addEventListener('click', async () => {
if (!confirm(t('sso.confirm_delete'))) return;
await ssoRequest('DELETE', `/${btn.dataset.ssoDelete}`);
});
});
}
async function ssoRequest(method, path = '', body) {
try {
const res = await fetch(`/api/organizations/${orgId}/sso${path}`, {
method,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${localStorage.getItem('token')}`,
},
body: body ? JSON.stringify(body) : undefined,
});
const data = await res.json().catch(() => ({}));
// The server's message is the useful one here — a bad issuer or a domain already claimed by
// another organization both say exactly what went wrong, and a generic failure would not.
if (!res.ok) { showToast(data.error || t('sso.save_failed'), 'error'); return false; }
// "Saved" for a DELETE read as though nothing had been destroyed.
showToast(t(method === 'DELETE' ? 'sso.removed' : 'sso.saved'), 'success');
await loadSso();
return true;
} catch {
showToast(t('sso.save_failed'), 'error');
return false;
}
}
document.getElementById('ssoCreateBtn')?.addEventListener('click', async () => {
const payload = {
name: document.getElementById('ssoName').value.trim(),
issuer: document.getElementById('ssoIssuer').value.trim(),
client_id: document.getElementById('ssoClientId').value.trim(),
client_secret: document.getElementById('ssoClientSecret').value,
email_domains: document.getElementById('ssoDomains').value.trim(),
};
if (!payload.name || !payload.issuer || !payload.client_id) {
showToast(t('sso.missing_fields'), 'error');
return;
}
if (await ssoRequest('POST', '', payload)) {
['ssoName', 'ssoIssuer', 'ssoClientId', 'ssoClientSecret', 'ssoDomains']
.forEach((id) => { document.getElementById(id).value = ''; });
document.getElementById('ssoAddDetails').open = false;
}
});
loadSso();
document.getElementById('createTokenBtn')?.addEventListener('click', async () => {
const name = document.getElementById('tokName').value.trim();
const scope = document.getElementById('tokScope').value;
const payload = { name, scope };
if (scope === 'agency') {
const ids = [...document.querySelectorAll('#agencyPlaylistList .agency-pl:checked')].map(c => c.value);
if (!ids.length) return showToast(t('apitoken.agency_needs_playlists'), 'error');
payload.target_playlist_ids = ids;
payload.auto_publish = !!document.getElementById('tokAutoPublish')?.checked;
// #158: blank = auto-create "Agency — "; a value binds that existing folder.
const fv = document.getElementById('tokUploadFolder')?.value;
if (fv) payload.upload_folder_id = fv;
}
const btn = document.getElementById('createTokenBtn');
btn.disabled = true;
try {
const r = await api.createToken(payload);
const box = document.getElementById('tokenSecretBox');
box.style.display = 'block';
// #73: for agency tokens, surface the handoff (portal URL + a copyable invite). The key
// is in the invite TEXT, never in a URL (Cloudflare logs query strings + chat apps unfurl
// links). window.location.origin is the real public host the admin is on (correct behind CF).
const portalUrl = window.location.origin + '/agency';
const inviteText = t('apitoken.invite_text', { url: portalUrl, key: r.token });
box.innerHTML = `
Disable widget sandbox isolation for this organization
Widget HTML currently runs in a null-origin sandbox. That means widget code
cannot read your session, your cookies, or anything else stored by
ScreenTinker in this browser.
Turning this off re-enables allow-same-origin. Widget HTML will then run with
the same privileges as ScreenTinker itself. Any script in any widget in this
organization will be able to:
- Read the device token of every display that shows the widget, and act as
that display against the ScreenTinker API
- Read the session token of any logged-in user who opens a display in their
own browser
- Call the ScreenTinker API as that user, including admin actions
- Read and modify content on every other display in this organization
- Silently exfiltrate all of the above to any server it likes
The widget editor's Preview is NOT affected: it renders inside the dashboard,
where your session lives, so it stays isolated whatever this setting says. A
widget may therefore behave differently in Preview than on a display.
Because allow-scripts is also required for widgets to function, a widget can
remove its own sandbox entirely once same-origin is granted. There is no
partial protection left after this point.
Only enable this if every widget source in this organization is code you
wrote, or code from a party you would trust with your admin password. A single
compromised third-party embed, CDN, or ad tag is enough.
This setting applies to ALL widgets in this organization and cannot be scoped
per display.
${esc(confirmationPhrase)}
`;
document.body.appendChild(overlay);
const input = overlay.querySelector('#widgetSandboxConfirmInput');
const submit = overlay.querySelector('#widgetSandboxConfirmSubmit');
const close = (ok) => {
overlay.remove();
resolve(ok);
};
const updateEnabled = () => {
submit.disabled = input.value.trim() !== confirmationPhrase;
};
input.addEventListener('input', updateEnabled);
overlay.querySelector('#widgetSandboxConfirmCancel').addEventListener('click', () => close(false));
submit.addEventListener('click', () => close(true));
overlay.addEventListener('click', (ev) => { if (ev.target === overlay) close(false); });
});
}
async function loadWhiteLabel() {
const token = localStorage.getItem('token');
const headers = { Authorization: `Bearer ${token}` };
// Only show white-label for enterprise plans or platform admins.
// Use the fresh user cached by render() above, which called api.getMe().
const user = JSON.parse(localStorage.getItem('user') || '{}');
const section = document.getElementById('whiteLabelSection');
if (section && user.plan_id !== 'enterprise' && !isPlatformAdmin(user)) {
section.innerHTML = `