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 = `

${t('settings.account')}

${user.auth_provider === 'local' ? `

${t('settings.change_password')}

${t('settings.password_min_8')}

` : `

${t('settings.sso_note', { provider: esc(user.auth_provider || 'SSO') })}

`}

${t('settings.signin_method')}

${t('settings.2fa_title')}

${t('apitoken.title')}

${t('apitoken.desc')}

${t('apitoken.docs_link')}

${t('settings.loading_users')}

${canManageOrgSecurity ? `

Security

Widget sandbox isolation
Keep widget code in a null-origin sandbox. Turning this off allows widget code to run with same-origin access.
` : ''} ${isAdmin ? `

${t('settings.license')}

${t('settings.license_mit')}

${isSuperAdmin ? `

${t('settings.platform_admin_link')} ${t('nav.admin')} ${t('settings.platform_admin_page_suffix')}

` : ''}

${t('settings.user_management')}

${t('settings.loading_users')}

${t('settings.white_label')}

${t('settings.white_label_desc')}

` : ''}

${t('settings.server_info')}

${t('settings.server_url')}
${serverUrl}

${t('settings.server_url_hint')}

${t('settings.api_endpoint')}
${serverUrl}/api

${t('settings.setup_guide')}

  1. ${t('settings.setup_step_1')}
  2. ${t('settings.setup_step_2_prefix')} ${serverUrl}
  3. ${t('settings.setup_step_3')}
  4. ${t('settings.setup_step_4')}
  5. ${t('settings.setup_step_5')}
  6. ${t('settings.setup_step_6')}

${t('settings.your_data')}

${t('settings.your_data_desc')}

${t('settings.language')}

${t('settings.about')}

ScreenTinker${appVersion ? ` v${esc(appVersion)}` : ''}

${t('settings.about_tagline')}

${t('auth.terms')}  ·  ${t('auth.privacy')}  ·  ${t('settings.third_party_licenses')}

`; if (isAdmin) { loadUsers(); loadWhiteLabel(); // Support token generator document.getElementById('generateSupportBtn')?.addEventListener('click', async () => { const org = document.getElementById('supportOrg').value.trim() || 'Customer'; const hours = parseInt(document.getElementById('supportHours').value) || 4; try { const token = localStorage.getItem('token'); const res = await fetch('/api/auth/support/generate', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, body: JSON.stringify({ org, hours, reason: 'Support session' }) }); const data = await res.json(); if (res.ok) { document.getElementById('supportTokenOutput').value = data.token; document.getElementById('supportTokenResult').style.display = 'block'; showToast(t('settings.toast.support_token_generated', { hours }), 'success'); } else showToast(data.error, 'error'); } catch (err) { showToast(err.message, 'error'); } }); } // Export data handler document.getElementById('exportDataBtn')?.addEventListener('click', () => { const includeFiles = document.getElementById('exportIncludeFiles')?.checked; const token = localStorage.getItem('token'); const url = `/api/status/export?token=${token}${includeFiles ? '&include_files=true' : ''}`; window.location.href = url; }); // Import data handler document.getElementById('importDataBtn')?.addEventListener('click', () => { document.getElementById('importFileInput').click(); }); document.getElementById('importFileInput')?.addEventListener('change', async (e) => { const file = e.target.files[0]; if (!file) return; const isZip = file.name.endsWith('.zip') || file.type === 'application/zip'; const statusEl = document.getElementById('importStatus'); statusEl.style.display = 'block'; statusEl.style.background = 'var(--bg-secondary)'; statusEl.style.border = '1px solid var(--border)'; statusEl.style.color = 'var(--text-secondary)'; statusEl.textContent = t('settings.import.reading_file'); try { let data; if (isZip) { // For ZIP, show basic info and skip preview parsing data = { format: 'screentinker-export-v1', _isZip: true }; statusEl.innerHTML = `${t('settings.import.zip_detected', { name: esc(file.name), size: (file.size / 1048576).toFixed(1) })}

`; } else { const text = await file.text(); data = JSON.parse(text); if (!data.format || !data.format.startsWith('screentinker-export')) { statusEl.style.color = 'var(--danger)'; statusEl.textContent = t('settings.import.invalid_file'); return; } const summary = [ data.devices?.length ? t('settings.import.summary_devices', { n: data.devices.length }) : null, data.content?.length ? t('settings.import.summary_content', { n: data.content.length }) : null, data.widgets?.length ? t('settings.import.summary_widgets', { n: data.widgets.length }) : null, data.layouts?.length ? t('settings.import.summary_layouts', { n: data.layouts.length }) : null, data.schedules?.length ? t('settings.import.summary_schedules', { n: data.schedules.length }) : null, data.video_walls?.length ? t('settings.import.summary_walls', { n: data.video_walls.length }) : null, data.kiosk_pages?.length ? t('settings.import.summary_kiosk', { n: data.kiosk_pages.length }) : null, ].filter(Boolean).join(', '); statusEl.innerHTML = `${t('settings.import.found_summary', { summary: esc(summary) || t('settings.import.empty_export'), email: esc(data.user?.email) || t('common.unknown'), date: esc(data.exported_at?.split('T')[0]) || t('common.unknown') })}

`; } document.getElementById('cancelImportBtn').onclick = () => { statusEl.style.display = 'none'; e.target.value = ''; }; document.getElementById('confirmImportBtn').onclick = async () => { statusEl.innerHTML = isZip ? t('settings.import.uploading_zip') : t('settings.import.importing'); try { const token = localStorage.getItem('token'); let res; if (isZip) { const formData = new FormData(); formData.append('file', file); res = await fetch('/api/status/import', { method: 'POST', headers: { Authorization: `Bearer ${token}` }, body: formData, }); } else { res = await fetch('/api/status/import', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, body: JSON.stringify(data), }); } const result = await res.json(); if (res.ok) { const imported = Object.entries(result.stats).filter(([k,v]) => v > 0 && k !== 'files_restored').map(([k,v]) => `${v} ${k}`).join(', '); statusEl.style.color = 'var(--success)'; let html = t('settings.import.complete', { imported }); if (result.device_pairings?.length) { html += `

${t('settings.import.pairing_codes_title')}
` + result.device_pairings.map(d => ``).join('') + `
${esc(d.name)}${d.pairing_code}

${t('settings.import.pairing_codes_hint')}`; } html += `

${(result.notes || []).map(n => '• ' + n).join('
')}`; statusEl.innerHTML = html; showToast(t('settings.toast.import_success'), 'success'); } else { statusEl.style.color = 'var(--danger)'; statusEl.textContent = result.error || t('settings.import.failed'); } } catch (err) { statusEl.style.color = 'var(--danger)'; statusEl.textContent = t('settings.import.failed_with_error', { error: err.message }); } e.target.value = ''; }; } catch (err) { statusEl.style.color = 'var(--danger)'; statusEl.textContent = t('settings.import.read_failed', { error: err.message }); } }); document.getElementById('langSelect')?.addEventListener('change', (e) => { // setLanguage dispatches hashchange so the router re-renders the current // view (including this settings page) with new strings — no refresh needed. setLanguage(e.target.value); }); // API Tokens — available to every user (manages their own, workspace-scoped). const fmtTokenDate = (ts) => { if (!ts) return ''; try { return new Date(ts * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); } catch { return String(ts); } }; const scopeLabel = (s) => ({ read: t('apitoken.scope_read'), write: t('apitoken.scope_write'), full: t('apitoken.scope_full'), agency: t('apitoken.scope_agency'), }[s] || s); async function loadTokens() { const el = document.getElementById('tokenList'); if (!el) return; const tokens = await api.getTokens().catch(() => []); if (!tokens.length) { el.innerHTML = `

${t('apitoken.none')}

`; return; } el.innerHTML = `
${tokens.map(tok => ` `).join('')}
${t('apitoken.col_token')} ${t('apitoken.col_name')} ${t('apitoken.col_scope')} ${t('apitoken.col_created')} ${t('apitoken.col_last_used')}
${esc(tok.prefix)}… ${esc(tok.name || '')} ${esc(scopeLabel(tok.scope))}${ tok.scope === 'agency' && Array.isArray(tok.targets) ? `
${t('apitoken.targets_label')} ${tok.targets.length ? tok.targets.map(p => esc(p.name)).join(', ') : '—'}${tok.auto_publish ? ' · ' + esc(t('apitoken.auto_publish_on')) : ''}
${t('apitoken.folder_label')} ${tok.upload_folder ? esc(tok.upload_folder) : esc(t('apitoken.folder_root'))}
` : ''}
${esc(fmtTokenDate(tok.created_at))} ${tok.last_used_at ? esc(fmtTokenDate(tok.last_used_at)) : t('apitoken.never')} ${tok.revoked_at ? `${t('apitoken.revoked')}` : `${tok.scope === 'agency' ? ` ` : ''}`}
`; el.querySelectorAll('.revoke-token-btn').forEach(btn => { btn.addEventListener('click', async () => { if (!confirm(t('apitoken.revoke_confirm'))) return; try { await api.revokeToken(btn.dataset.id); showToast(t('apitoken.revoked_toast'), 'success'); loadTokens(); } catch (err) { showToast(err.message, 'error'); } }); }); // #73: edit an agency token's playlist designations -> PUT /:id/targets (atomic re-designate). el.querySelectorAll('.edit-targets-btn').forEach(btn => btn.addEventListener('click', async () => { const id = btn.dataset.id; const current = new Set((btn.dataset.targets || '').split(',').filter(Boolean)); const panel = document.getElementById('tokenEditPanel'); const pls = await api.getPlaylists().catch(() => []); panel.style.display = 'block'; panel.innerHTML = `

${t('apitoken.edit_targets')}

${pls.length ? pls.map(p => p.zoned ? `` : ``).join('') : `

${t('apitoken.agency_no_playlists')}

`}
`; document.getElementById('saveTargetsBtn').onclick = async () => { const ids = [...panel.querySelectorAll('.edit-pl:checked')].map(c => c.value); if (!ids.length) return showToast(t('apitoken.agency_needs_playlists'), 'error'); try { await api.setTokenTargets(id, ids); showToast(t('apitoken.targets_updated'), 'success'); panel.style.display = 'none'; loadTokens(); } catch (err) { showToast(err.message, 'error'); } }; document.getElementById('cancelTargetsBtn').onclick = () => { panel.style.display = 'none'; }; })); // #158: rebind an agency token's upload folder -> PUT /:id/upload-folder (null = root). el.querySelectorAll('.edit-folder-btn').forEach(btn => btn.addEventListener('click', async () => { const id = btn.dataset.id; const current = btn.dataset.folder || ''; const panel = document.getElementById('tokenEditPanel'); const folders = await api.getFolders().catch(() => []); panel.style.display = 'block'; panel.innerHTML = `

${t('apitoken.edit_folder')}

${t('apitoken.agency_folder_hint')}

`; 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(`

${t('settings.signin_linked', { provider: esc(name) })}

`); document.getElementById('unlinkBtn').onclick = () => { document.getElementById('unlinkForm').style.display = ''; document.getElementById('unlinkBtn').style.display = 'none'; document.getElementById('unlinkPw').focus(); }; document.getElementById('unlinkConfirmBtn').onclick = async () => { const pw = document.getElementById('unlinkPw').value; const pw2 = document.getElementById('unlinkPw2').value; if (pw !== pw2) return showToast(t('settings.passwords_dont_match'), 'error'); try { await api.ssoUnlink(pw); showToast(t('settings.signin_unlinked_toast'), 'success'); loadSsoLink(); } catch (e) { showToast(e.message, 'error'); } }; return; } if (!providers.length) { paint(`

${t('settings.signin_password_only')}

`); return; } paint(`

${t('settings.signin_password_now')}

${providers.map((p) => ``).join('')}
`); 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(`

${esc(e.message)}

`); return; } if (!status.eligible) { const provider = (JSON.parse(localStorage.getItem('user') || '{}').auth_provider) || 'SSO'; paint(`

${t('settings.2fa_sso_note', { provider: esc(provider) })}

`); return; } if (status.enabled) return showEnabled(status.recovery_codes_remaining); return showDisabled(); function showDisabled() { paint(`

${t('settings.2fa_desc')} ${t('settings.2fa_status_off')}

`); document.getElementById('enable2faBtn').addEventListener('click', startEnroll); } async function startEnroll() { let data; try { data = await api.totpSetup(); } catch (e) { showToast(e.message, 'error'); return; } const qr = data.qr_data_url ? `TOTP QR` : `

${t('settings.2fa_setup_manual')}

`; paint(`

${t('settings.2fa_setup_scan')}

${qr}

${t('settings.2fa_setup_manual')}

${esc(data.secret)}
`); const codeEl = document.getElementById('enroll2faCode'); codeEl.focus(); const doEnable = async () => { const code = codeEl.value.trim(); if (!code) { showToast(t('settings.2fa_code_required'), 'error'); return; } try { const r = await api.totpEnable(code); showToast(t('settings.2fa_enabled_toast'), 'success'); showRecoveryCodes(r.recovery_codes); } catch (e) { showToast(e.message, 'error'); codeEl.select(); } }; document.getElementById('enroll2faVerify').addEventListener('click', doEnable); codeEl.addEventListener('keydown', (e) => { if (e.key === 'Enter') doEnable(); }); document.getElementById('enroll2faCancel').addEventListener('click', load2FA); } function showRecoveryCodes(codes) { const list = codes.map((c) => `
${esc(c)}
`).join(''); const text = codes.join('\n'); paint(`

${t('settings.2fa_recovery_title')}

${t('settings.2fa_recovery_warning')}

${list}
`); document.getElementById('copyRecovery').addEventListener('click', async () => { try { await navigator.clipboard.writeText(text); showToast(t('settings.2fa_recovery_copy'), 'success'); } catch { /* field is selectable */ } }); document.getElementById('dlRecovery').addEventListener('click', () => { const blob = new Blob([text + '\n'], { type: 'text/plain' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'screentinker-recovery-codes.txt'; a.click(); URL.revokeObjectURL(a.href); }); document.getElementById('doneRecovery').addEventListener('click', load2FA); } function showEnabled(remaining) { paint(`

✓ ${t('settings.2fa_status_on')} ${t('settings.2fa_recovery_remaining', { n: remaining })}

`); document.getElementById('disable2faBtn').addEventListener('click', () => promptCode({ prompt: t('settings.2fa_disable_prompt'), confirm: t('settings.2fa_disable_confirm'), danger: true, run: async (code) => { await api.totpDisable(code); showToast(t('settings.2fa_disabled_toast'), 'success'); load2FA(); }, })); document.getElementById('regen2faBtn').addEventListener('click', () => promptCode({ prompt: t('settings.2fa_regen_prompt'), confirm: t('settings.2fa_regen_confirm'), run: async (code) => { const r = await api.totpRegenRecovery(code); showRecoveryCodes(r.recovery_codes); }, })); } function promptCode({ prompt, confirm, danger, run }) { const box = document.getElementById('twoFactorAction'); box.innerHTML = `

${prompt}

`; 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) => `
${esc(p.name)} ${p.enabled ? '' : ` — ${esc(t('sso.disabled'))}`}
${esc(p.issuer)}
${esc(t('sso.domains_label'))}: ${esc(p.email_domains || '—')}
${((p.domains || []).some((d) => !d.verified) || (p.domains || []).length === 0) ? `
⚠️ ${esc(t('sso.unverified_warning'))}
` : ''}
${esc(t('sso.callback_label'))}
${esc(origin + p.callback_url)}
${(p.domains || []).length ? `
${esc(t('sso.domains_heading'))}
${p.domains.map((d, di) => `
${esc(d.domain)} ${d.verified ? ` — ${esc(t('sso.domain_verified'))}` : ` — ${esc(t('sso.domain_pending'))}`}
${d.verified ? '' : ``}
${d.verified ? '' : `
${esc(t('sso.dns_instructions'))}
${esc(d.record_name)} TXT ${esc(d.txt_value)} `}
${d.verified ? '' : esc(d.last_error || '')}
`).join('')}
` : ''}
`).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) => `
${c.ok ? '✅' : '❌'} ${esc(CHECK_LABELS[c.name] || c.name)} — ${esc(c.detail || '')}
`).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 = `

${t('apitoken.secret_title')}

${t('apitoken.secret_warning')}

${scope === 'agency' ? `
` : ''}
`; document.getElementById('copyTokenBtn')?.addEventListener('click', async () => { try { await navigator.clipboard.writeText(r.token); showToast(t('apitoken.copied'), 'success'); } catch { /* clipboard may be unavailable; the field is selectable */ } }); document.getElementById('copyInviteBtn')?.addEventListener('click', async () => { try { await navigator.clipboard.writeText(inviteText); // full "go here + paste key" text showToast(t('apitoken.copied'), 'success'); } catch { /* field is selectable as a fallback */ } }); document.getElementById('tokName').value = ''; showToast(t('apitoken.created_toast'), 'success'); loadTokens(); } catch (err) { showToast(err.message, 'error'); } finally { btn.disabled = false; } }); document.getElementById('saveAcctBtn')?.addEventListener('click', async () => { const name = document.getElementById('acctName').value.trim(); if (!name) return showToast(t('settings.toast.name_required'), 'error'); const email_alerts = !!document.getElementById('acctEmailAlerts')?.checked; const btn = document.getElementById('saveAcctBtn'); btn.disabled = true; try { const updated = await api.updateMe({ name, email_alerts }); const stored = JSON.parse(localStorage.getItem('user') || '{}'); localStorage.setItem('user', JSON.stringify({ ...stored, ...updated })); showToast(t('settings.toast.profile_saved'), 'success'); } catch (err) { showToast(err.message, 'error'); } finally { btn.disabled = false; } }); document.getElementById('changePwBtn')?.addEventListener('click', async () => { const current = document.getElementById('acctCurrentPw').value; const next = document.getElementById('acctNewPw').value; const confirm = document.getElementById('acctConfirmPw').value; if (!current) return showToast(t('settings.toast.current_password_required'), 'error'); if (next.length < 8) return showToast(t('settings.toast.new_password_min_8'), 'error'); if (next !== confirm) return showToast(t('settings.toast.passwords_dont_match'), 'error'); const btn = document.getElementById('changePwBtn'); btn.disabled = true; try { await api.updateMe({ current_password: current, password: next }); document.getElementById('acctCurrentPw').value = ''; document.getElementById('acctNewPw').value = ''; document.getElementById('acctConfirmPw').value = ''; showToast(t('settings.toast.password_changed'), 'success'); } catch (err) { showToast(err.message, 'error'); } finally { btn.disabled = false; } }); document.getElementById('widgetSandboxIsolationToggle')?.addEventListener('change', async (e) => { const checkbox = e.currentTarget; const shouldEnableIsolation = !!checkbox.checked; const workspaceId = user.current_workspace_id; if (!workspaceId) { checkbox.checked = !shouldEnableIsolation; showToast('No active workspace', 'error'); return; } if (!shouldEnableIsolation) { const confirmed = await openWidgetSandboxDisableConfirmModal(WIDGET_ISOLATION_CONFIRM_PHRASE); if (!confirmed) { checkbox.checked = true; return; } try { await api.updateWorkspaceSecuritySettings(workspaceId, { widgetSandboxIsolationDisabled: true, confirmationPhrase: WIDGET_ISOLATION_CONFIRM_PHRASE, }); const nextUser = { ...user, current_organization: { ...(user.current_organization || {}), widget_sandbox_isolation_disabled: 1 } }; localStorage.setItem('user', JSON.stringify(nextUser)); showToast('Widget sandbox isolation disabled', 'success'); } catch (err) { checkbox.checked = true; showToast(err.message, 'error'); } return; } try { await api.updateWorkspaceSecuritySettings(workspaceId, { widgetSandboxIsolationDisabled: false }); const nextUser = { ...user, current_organization: { ...(user.current_organization || {}), widget_sandbox_isolation_disabled: 0 } }; localStorage.setItem('user', JSON.stringify(nextUser)); showToast('Widget sandbox isolation enabled', 'success'); } catch (err) { checkbox.checked = false; showToast(err.message, 'error'); } }); } function openWidgetSandboxDisableConfirmModal(confirmationPhrase) { return new Promise((resolve) => { const overlay = document.createElement('div'); overlay.className = 'modal-overlay'; overlay.style.display = 'flex'; overlay.innerHTML = ` `; 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 = `

${t('settings.white_label')}

${t('settings.white_label_enterprise_only')}

${t('settings.view_plans')}
`; return; } try { const res = await fetch('/api/white-label', { headers }); const wl = await res.json(); if (wl.brand_name) document.getElementById('wlBrandName').value = wl.brand_name; if (wl.logo_url) document.getElementById('wlLogoUrl').value = wl.logo_url; if (wl.primary_color) document.getElementById('wlPrimaryColor').value = wl.primary_color; if (wl.bg_color) document.getElementById('wlBgColor').value = wl.bg_color; if (wl.custom_domain) document.getElementById('wlDomain').value = wl.custom_domain; if (wl.favicon_url) document.getElementById('wlFavicon').value = wl.favicon_url; if (wl.custom_css) document.getElementById('wlCustomCss').value = wl.custom_css; if (wl.hide_branding) document.getElementById('wlHideBranding').checked = true; } catch {} document.getElementById('saveWhiteLabelBtn')?.addEventListener('click', async () => { try { await fetch('/api/white-label', { method: 'POST', headers: { ...headers, 'Content-Type': 'application/json' }, body: JSON.stringify({ brand_name: document.getElementById('wlBrandName').value, logo_url: document.getElementById('wlLogoUrl').value, primary_color: document.getElementById('wlPrimaryColor').value, bg_color: document.getElementById('wlBgColor').value, custom_domain: document.getElementById('wlDomain').value, favicon_url: document.getElementById('wlFavicon').value, custom_css: document.getElementById('wlCustomCss').value, hide_branding: document.getElementById('wlHideBranding').checked ? 1 : 0, }) }); await resetBranding(); showToast(t('settings.toast.branding_saved'), 'success'); } catch (err) { showToast(err.message, 'error'); } }); document.getElementById('previewWhiteLabelBtn')?.addEventListener('click', () => { const primary = document.getElementById('wlPrimaryColor').value; const bg = document.getElementById('wlBgColor').value; document.documentElement.style.setProperty('--accent', primary); document.documentElement.style.setProperty('--bg-primary', bg); showToast(t('settings.toast.preview_applied'), 'info'); }); } async function loadUsers() { const el = document.getElementById('userManagement'); if (!el) return; try { const [users, plans] = await Promise.all([ api.getUsers(), fetch('/api/subscription/plans').then(r => r.json()) ]); const currentUser = JSON.parse(localStorage.getItem('user') || '{}'); el.innerHTML = `
${users.map(u => ` `).join('')}
${t('settings.user.col_user')} ${t('settings.user.col_auth')} ${t('settings.user.col_role')} ${t('settings.user.col_plan')} ${t('settings.user.col_actions')}
${esc(u.name || u.email)}
${esc(u.email)}
${esc(u.auth_provider)} ${esc(u.role)} ${u.auth_provider === 'local' && u.id !== currentUser.id ? `` : ''} ${u.id !== currentUser.id ? `` : `${t('settings.user.you')}`}

${tn('settings.user.count', users.length)}

`; // Plan change handlers el.querySelectorAll('.plan-select').forEach(select => { select.addEventListener('change', async () => { const userId = select.dataset.userId; const planId = select.value; try { await api.assignPlan(userId, planId); showToast(t('settings.toast.plan_updated'), 'success'); } catch (err) { showToast(err.message, 'error'); loadUsers(); // Revert } }); }); // Reset password handlers el.querySelectorAll('.reset-user-pw-btn').forEach(btn => { btn.addEventListener('click', async () => { const email = btn.dataset.userEmail; const pw = prompt(t('settings.user.prompt_reset_password', { email })); if (pw === null) return; if (pw.length < 8) { showToast(t('settings.toast.new_password_min_8'), 'error'); return; } try { await api.resetUserPassword(btn.dataset.userId, pw); showToast(t('settings.toast.password_reset_for_user'), 'success'); } catch (err) { showToast(err.message, 'error'); } }); }); // Delete user handlers el.querySelectorAll('.delete-user-btn').forEach(btn => { let confirming = false; btn.addEventListener('click', async () => { if (confirming) { try { await api.deleteUser(btn.dataset.userId); showToast(t('settings.toast.user_removed'), 'success'); loadUsers(); } catch (err) { showToast(err.message, 'error'); } return; } confirming = true; btn.textContent = t('settings.user.confirm'); btn.style.background = 'var(--danger)'; btn.style.color = 'white'; setTimeout(() => { confirming = false; btn.textContent = t('settings.user.remove'); btn.style.background = ''; btn.style.color = ''; }, 3000); }); }); } catch (err) { el.innerHTML = `

${esc(err.message)}

`; } } export function cleanup() {}