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('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')) : ''}
` : ''}
${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'; }; })); } loadTokens(); // #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')}

`; } }); 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; } 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() {}