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; // #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.2fa_title')}

${t('apitoken.title')}

${t('apitoken.desc')}

${t('apitoken.docs_link')}

${t('settings.loading_users')}

${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('') + `
${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. 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(); // #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; } 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 || '—')}
${esc(t('sso.callback_label'))}
${esc(origin + p.callback_url)}
`).join(''); listEl.querySelectorAll('[data-sso-toggle]').forEach((btn) => { btn.addEventListener('click', async () => { await ssoRequest('PUT', `/${btn.dataset.ssoToggle}`, { enabled: btn.dataset.enabled !== '1' }); }); }); 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; } showToast(t('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; } }); } 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')}
${u.name || u.email}
${u.email}
${u.auth_provider} ${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() {}