import { api } from '../api.js'; import { showToast } from '../components/toast.js'; import { getLanguage, setLanguage, getAvailableLanguages } from '../i18n.js'; import { esc } from '../utils.js'; export async function render(container) { const serverUrl = `${window.location.protocol}//${window.location.host}`; const user = JSON.parse(localStorage.getItem('user') || '{}'); const isSuperAdmin = user.role === 'superadmin'; const isAdmin = user.role === 'admin' || isSuperAdmin; container.innerHTML = `

Account

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

Change Password

Must be at least 8 characters.

` : `

You sign in via ${esc(user.auth_provider || 'SSO')}. Manage your password there.

`}
${isAdmin ? `

License

MIT License - all features included.

${isSuperAdmin ? '

Platform admin tools are in the Admin page.

' : ''}

User Management

Loading users...

White Label / Branding

Customize the look of your dashboard and player for your clients.

` : ''}

Server Information

Server URL
${serverUrl}

Use this URL when setting up the Android app

API Endpoint
${serverUrl}/api

Setup Guide

  1. Install the ScreenTinker APK on your Apolosign portable TV via sideloading
  2. Open the app and enter this server URL: ${serverUrl}
  3. The app will display a 6-digit pairing code
  4. Click "Add Display" on the dashboard and enter the pairing code
  5. Upload content in the Content Library
  6. Assign content to the display's Playlist
${isAdmin ? ` ` : ''}

Your Data

Export or import your devices, content, layouts, schedules, and all settings. Use this to migrate between cloud and self-hosted instances.

Language

About

ScreenTinker v1.4.1

Digital signage management system.

Terms of Service  ·  Privacy Policy  ·  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(`Support token generated (valid ${hours}h)`, '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 = '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 = `ZIP export detected: ${esc(file.name)} (${(file.size / 1048576).toFixed(1)} MB)
Contains data + media files.

`; } 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 = 'Invalid file. Must be a ScreenTinker export JSON or ZIP.'; return; } const summary = [ data.devices?.length ? `${data.devices.length} devices` : null, data.content?.length ? `${data.content.length} content items` : null, data.widgets?.length ? `${data.widgets.length} widgets` : null, data.layouts?.length ? `${data.layouts.length} layouts` : null, data.schedules?.length ? `${data.schedules.length} schedules` : null, data.video_walls?.length ? `${data.video_walls.length} video walls` : null, data.kiosk_pages?.length ? `${data.kiosk_pages.length} kiosk pages` : null, ].filter(Boolean).join(', '); statusEl.innerHTML = `Found: ${esc(summary) || 'empty export'}.
From: ${esc(data.user?.email) || 'unknown'} (exported ${esc(data.exported_at?.split('T')[0]) || 'unknown'})

`; } document.getElementById('cancelImportBtn').onclick = () => { statusEl.style.display = 'none'; e.target.value = ''; }; document.getElementById('confirmImportBtn').onclick = async () => { statusEl.innerHTML = isZip ? 'Uploading and importing... This may take a moment for large files.' : '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 = `Import complete: ${imported}.`; if (result.device_pairings?.length) { html += `

Device Pairing Codes:
` + result.device_pairings.map(d => ``).join('') + `
${d.name}${d.pairing_code}

Enter these codes on each device to re-link them. All assignments and schedules will be preserved.`; } html += `

${(result.notes || []).map(n => '• ' + n).join('
')}`; statusEl.innerHTML = html; showToast('Data imported successfully', 'success'); } else { statusEl.style.color = 'var(--danger)'; statusEl.textContent = result.error || 'Import failed'; } } catch (err) { statusEl.style.color = 'var(--danger)'; statusEl.textContent = 'Import failed: ' + err.message; } e.target.value = ''; }; } catch (err) { statusEl.style.color = 'var(--danger)'; statusEl.textContent = 'Failed to read file: ' + err.message; } }); document.getElementById('langSelect')?.addEventListener('change', (e) => { setLanguage(e.target.value); showToast('Language changed. Refresh for full effect.', 'info'); }); document.getElementById('saveAcctBtn')?.addEventListener('click', async () => { const name = document.getElementById('acctName').value.trim(); if (!name) return showToast('Name cannot be empty', 'error'); const btn = document.getElementById('saveAcctBtn'); btn.disabled = true; try { const updated = await api.updateMe({ name }); const stored = JSON.parse(localStorage.getItem('user') || '{}'); localStorage.setItem('user', JSON.stringify({ ...stored, ...updated })); showToast('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('Enter your current password', 'error'); if (next.length < 8) return showToast('New password must be at least 8 characters', 'error'); if (next !== confirm) return showToast('New passwords do not 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('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/superadmin const user = JSON.parse(localStorage.getItem('user') || '{}'); const section = document.getElementById('whiteLabelSection'); if (section && user.plan_id !== 'enterprise' && user.role !== 'superadmin') { section.innerHTML = `

White Label / Branding

Custom branding is available on the Enterprise plan

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, }) }); showToast('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('Preview applied (refresh to reset)', '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('')}
User Auth Role Plan Actions
${u.name || u.email}
${u.email}
${u.auth_provider} ${u.role} ${u.id !== currentUser.id ? `` : 'You'}

${users.length} user(s) registered

`; // 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('Plan updated', 'success'); } catch (err) { showToast(err.message, 'error'); loadUsers(); // Revert } }); }); // 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('User removed', 'success'); loadUsers(); } catch (err) { showToast(err.message, 'error'); } return; } confirming = true; btn.textContent = 'Confirm?'; btn.style.background = 'var(--danger)'; btn.style.color = 'white'; setTimeout(() => { confirming = false; btn.textContent = 'Remove'; btn.style.background = ''; btn.style.color = ''; }, 3000); }); }); } catch (err) { el.innerHTML = `

${esc(err.message)}

`; } } export function cleanup() {}