import { api } from '../api.js'; import { showToast } from '../components/toast.js'; import { esc, isPlatformAdmin } from '../utils.js'; import { t } from '../i18n.js'; import { openAddUserModal } from '../components/workspace-members-add-user-modal.js'; import { openManageWorkspacesModal } from '../components/admin-user-workspaces-modal.js'; import { openCreateOrgModal } from '../components/admin-create-org-modal.js'; import { openTypeToConfirmModal } from '../components/type-to-confirm-modal.js'; // Reuse the members view's server-error -> friendly-string mapper (handles the // 409 duplicate-email / weak-password / invalid-email cases) so we don't fork a // second mapper. import { mapMutationError } from './workspace-members.js'; const headers = () => ({ Authorization: `Bearer ${localStorage.getItem('token')}`, 'Content-Type': 'application/json' }); // A refused request must reject, not resolve. // // This helper used to end in `.then(r => r.json())`, so a 403/404/500 body resolved as an ordinary // value and the surrounding try/catch was unreachable — every handler took the failure for success. // Concretely: deleting a built-in layout template showed "Layout deleted" while the server had // returned 403 and the template was still there, and a rejected platform-role change showed "Role // updated" while the dropdown kept displaying a value the server refused (its revert lives only in // the dead catch). The shared client in api.js has always thrown on !res.ok; these local copies did // not. Same contract now, including the 401 session-expiry reload. const API = (url, opts = {}) => fetch('/api' + url, { headers: headers(), ...opts }).then(async (r) => { if (r.status === 401) { localStorage.removeItem('token'); window.location.reload(); throw new Error('Session expired'); } if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.error || `Request failed (${r.status})`); } return r.json(); }); // #14: the platform user-management dropdown manages users.role (the // PLATFORM-level role) only - workspace/org roles are managed in the members // views. Options are the current model; the legacy 'admin'/'superadmin' strings // were normalized away. #13 adds 'platform_operator' (cross-org staff). const PLATFORM_ROLE_OPTIONS = ['user', 'platform_operator', 'platform_admin']; // Platform staff have cross-org access (no single workspace), so the Workspace // column shows read-only "Platform (all)" for them. Note utils.isPlatformAdmin // only covers admin/superadmin; operators are staff here too. function isPlatformStaffRole(role) { return role === 'platform_admin' || role === 'superadmin' || role === 'platform_operator'; } // Short summary of a user's workspace membership for the Users-table cell. // Platform staff have cross-org access (not per-workspace membership) -> "Platform // (all)". Otherwise: Unassigned (0), the workspace name (1), or "N workspaces". function workspaceSummary(u) { if (isPlatformStaffRole(u.role)) return t('admin.workspace.platform_all'); const count = u.workspace_count || 0; if (count === 0) return t('admin.workspace.unassigned'); if (count === 1) return esc(u.workspace_name || ''); return t('admin.workspace.multi', { n: count }); } // Workspace cell: a summary + a "Manage" button that opens the full membership // modal (add/remove workspaces, set per-workspace role). Manage is offered for // everyone, including staff (you can grant them explicit memberships too). function workspaceCell(u) { return `
${workspaceSummary(u)}
`; } export async function render(container) { const user = JSON.parse(localStorage.getItem('user') || '{}'); if (!isPlatformAdmin(user)) { container.innerHTML = `

${t('admin.access_denied')}

${t('admin.access_denied_desc')}

`; return; } container.innerHTML = `

${t('admin.all_users')}

${t('common.loading')}

${t('admin.orgs.title')}

${t('admin.orgs.desc')}

${t('common.loading')}

${t('admin.branding.title')}

${t('admin.branding.desc')}

${t('common.loading')}

${t('admin.plans')}

${t('common.loading')}

${t('admin.system')}

${t('common.loading')}

Status endpoint

${t('common.loading')}

`; // Add User (#10): platform admin provisions a user into ANY workspace. The // page is platform_admin-gated; the modal opens in picker mode (no fixed // workspace) so the admin chooses the target org/workspace. The endpoint // additionally enforces canAdminWorkspace (platform_admin passes everywhere). document.getElementById('adminAddUserBtn')?.addEventListener('click', () => { openAddUserModal(null, { onSuccess: (result) => { showToast(t('members.success.user_created', { email: result.email }), 'success'); loadUsers(); }, mapError: mapMutationError, }); }); // Create Organization (#35): platform admin provisions a new customer org + // its first workspace (owned by the admin). The modal reloads on success so // the new org shows up in the switcher. document.getElementById('adminCreateOrgBtn')?.addEventListener('click', () => { openCreateOrgModal({ onSuccess: (result) => showToast(t('admin.create_org.success', { name: result.name }), 'success'), }); }); loadUsers(); loadOrgs(); loadSsoOnlyRequests(); loadBranding(); loadPlans(); loadSystem(); loadStatusDebug(); } // #36: list organizations with owner + resource counts; platform admin can // cascade-delete an org or an individual workspace (type-the-name confirm). /* * Pending "stop requiring single sign-on" requests. * * The notification email tells the operator to review this under Admin, and for a while it did not * exist — the only way to approve was curl, while the customer sat locked out. The section hides * itself when there is nothing pending so it is never noise. */ async function loadSsoOnlyRequests() { const section = document.getElementById('ssoOnlySection'); const host = document.getElementById('ssoOnlyRequests'); if (!section || !host) return; // NB: `api` is a map of named methods, not a generic client — there is no api.get(), and calling // one silently hid this whole section behind the catch below. const authed = (path, init = {}) => fetch(`/api${path}`, { ...init, headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}`, ...(init.headers || {}) }, }); let requests = []; try { const res = await authed('/organizations/sso-only/removal-requests'); if (!res.ok) throw new Error(String(res.status)); requests = (await res.json()).requests || []; } catch { section.style.display = 'none'; return; } if (!requests.length) { section.style.display = 'none'; return; } section.style.display = ''; host.innerHTML = requests.map((r) => `
${esc(r.organization_name || r.organization_id)}
${esc(t('admin.sso_only.requested_by', { who: r.requested_by_email || 'unknown' }))}
${r.reason ? `
${esc(r.reason)}
` : ''}
${esc(t('admin.sso_only.effect'))}
`).join(''); const decide = async (id, decision) => { try { const res = await authed(`/organizations/sso-only/removal-requests/${id}/${decision}`, { method: 'POST', body: '{}' }); if (!res.ok) throw new Error(((await res.json().catch(() => ({}))).error) || String(res.status)); showToast(t(decision === 'approve' ? 'admin.sso_only.approved' : 'admin.sso_only.rejected'), 'success'); await loadSsoOnlyRequests(); } catch (e) { showToast((e && e.message) || t('admin.sso_only.failed'), 'error'); } }; // Approving RE-OPENS password sign-in for a whole organization, so it is confirmed; rejecting // only leaves the safe state in place and is not. host.querySelectorAll('[data-sso-approve]').forEach((b) => b.addEventListener('click', () => { if (window.confirm(t('admin.sso_only.confirm'))) decide(b.dataset.ssoApprove, 'approve'); })); host.querySelectorAll('[data-sso-reject]').forEach((b) => b.addEventListener('click', () => decide(b.dataset.ssoReject, 'reject'))); } async function loadOrgs() { const el = document.getElementById('orgsTable'); if (!el) return; let orgs; try { orgs = await api.adminListOrgs(); } catch (err) { el.innerHTML = `

${esc(err.message || 'Failed to load organizations')}

`; return; } if (!orgs.length) { el.innerHTML = `

${t('admin.orgs.empty')}

`; return; } el.innerHTML = orgs.map(o => { const wsRows = (o.workspaces || []).map(w => `
${esc(w.name)} · ${w.device_count} ${t('admin.orgs.devices')} · ${w.member_count} ${t('admin.orgs.members')}
`).join(''); return `
${esc(o.name)}
${t('admin.orgs.owner')}: ${esc(o.owner_email || '—')} · ${o.workspace_count} ${t('admin.orgs.workspaces')} · ${o.device_count} ${t('admin.orgs.devices')} · ${o.member_count} ${t('admin.orgs.members')}
${wsRows}
`; }).join(''); el.querySelectorAll('[data-del-org]').forEach(btn => btn.addEventListener('click', () => { const id = btn.dataset.delOrg, name = btn.dataset.orgName; openTypeToConfirmModal({ title: t('admin.orgs.delete_org_title'), body: t('admin.orgs.delete_org_body', { name: esc(name) }), expected: name, confirmLabel: t('admin.orgs.delete_org'), onConfirm: async () => { await api.adminDeleteOrg(id); showToast(t('admin.orgs.org_deleted', { name }), 'success'); loadOrgs(); loadUsers(); }, }); })); el.querySelectorAll('[data-del-ws]').forEach(btn => btn.addEventListener('click', () => { const id = btn.dataset.delWs, name = btn.dataset.wsName; openTypeToConfirmModal({ title: t('admin.orgs.delete_ws_title'), body: t('admin.orgs.delete_ws_body', { name: esc(name) }), expected: name, confirmLabel: t('admin.orgs.delete_ws'), onConfirm: async () => { await api.adminDeleteWorkspace(id); showToast(t('admin.orgs.ws_deleted', { name }), 'success'); loadOrgs(); }, }); })); } // #15: instance-level default branding form (platform default; every workspace // without its own white-label inherits this, as does the login page). async function loadBranding() { const el = document.getElementById('brandingForm'); if (!el) return; let b = {}; try { b = await api.adminGetBranding(); } catch (e) { el.innerHTML = `

${esc(e.message || 'Failed to load')}

`; return; } const v = (x) => esc(x == null ? '' : x); el.innerHTML = `
`; document.getElementById('brSave').onclick = async () => { try { await api.adminSetBranding({ brand_name: document.getElementById('brBrandName').value.trim() || 'ScreenTinker', primary_color: document.getElementById('brPrimary').value.trim() || null, bg_color: document.getElementById('brBg').value.trim() || null, logo_url: document.getElementById('brLogo').value.trim() || null, favicon_url: document.getElementById('brFavicon').value.trim() || null, custom_css: document.getElementById('brCss').value.trim() || null, hide_branding: document.getElementById('brHide').checked, }); showToast(t('admin.branding.saved'), 'success'); } catch (err) { showToast(err.message, 'error'); } }; } async function loadUsers() { const el = document.getElementById('allUsersTable'); try { const [users, plans] = await Promise.all([ API('/auth/users'), fetch('/api/subscription/plans').then(r => r.json()), ]); const currentUser = JSON.parse(localStorage.getItem('user') || '{}'); el.innerHTML = `
${users.map(u => ` ${workspaceCell(u)} `).join('')}
${t('admin.col.user')} ${t('admin.col.auth')} ${t('admin.col.last_login')} ${t('admin.col.role')} ${t('admin.col.plan')} ${t('admin.col.workspace')} ${t('admin.col.actions')}
${u.name || u.email}
${u.email}
${u.auth_provider} ${u.last_login ? new Date(u.last_login * 1000).toLocaleString() : t('common.never')} ${u.auth_provider === 'local' && u.id !== currentUser.id ? `` : ''} ${!isPlatformAdmin(u) ? `` : `${t('admin.owner')}`}

${t('admin.total_users', { n: users.length })}

`; el.querySelectorAll('[data-role-user]').forEach(select => { select.onchange = async () => { try { await API(`/auth/users/${select.dataset.roleUser}/role`, { method: 'PUT', body: JSON.stringify({ role: select.value }) }); showToast(t('admin.toast.role_updated'), 'success'); } catch (err) { showToast(err.message, 'error'); loadUsers(); } }; }); el.querySelectorAll('[data-plan-user]').forEach(select => { select.onchange = async () => { try { await API('/subscription/assign', { method: 'POST', body: JSON.stringify({ user_id: select.dataset.planUser, plan_id: select.value }) }); showToast(t('admin.toast.plan_updated'), 'success'); } catch (err) { showToast(err.message, 'error'); loadUsers(); } }; }); // Manage workspaces: open the per-user membership modal (add/remove // workspaces, set per-workspace role). Refresh the table on close only if // something changed (the modal calls onClose then). el.querySelectorAll('[data-ws-manage]').forEach(btn => { btn.onclick = () => { const u = users.find(x => x.id === btn.dataset.wsManage); if (!u) return; openManageWorkspacesModal(u, { onClose: () => loadUsers() }); }; }); // Reset password handlers el.querySelectorAll('[data-reset-pw-user]').forEach(btn => { btn.onclick = async () => { const email = btn.dataset.userEmail; const pw = prompt(t('admin.prompt_reset_password', { email })); if (pw === null) return; if (pw.length < 8) { showToast(t('admin.toast.password_min_8'), 'error'); return; } try { await api.resetUserPassword(btn.dataset.resetPwUser, pw); showToast(t('admin.toast.password_reset'), 'success'); } catch (err) { showToast(err.message, 'error'); } }; }); el.querySelectorAll('[data-delete-user]').forEach(btn => { let confirming = false; btn.onclick = async () => { if (confirming) { try { await api.deleteUser(btn.dataset.deleteUser); showToast(t('admin.toast.user_removed'), 'success'); loadUsers(); } catch (err) { showToast(err.message, 'error'); } return; } confirming = true; btn.textContent = t('admin.confirm'); btn.style.background = 'var(--danger)'; btn.style.color = 'white'; setTimeout(() => { confirming = false; btn.textContent = t('admin.remove'); btn.style.background = ''; btn.style.color = ''; }, 3000); }; }); } catch (err) { el.innerHTML = `

${esc(err.message)}

`; } } // #146: toggle /api/status debug-metrics exposure. Mirrors loadBranding's // load-then-save pattern; takes effect on the next status poll (no restart). async function loadStatusDebug() { const el = document.getElementById('statusDebugForm'); if (!el) return; let enabled = false; try { enabled = (await api.adminGetStatusDebug()).enabled; } catch (e) { el.innerHTML = `

${esc(e.message || 'Failed to load')}

`; return; } el.innerHTML = `

Adds internal limiter/prune/OTA counters to the public status endpoint. Off by default.

`; document.getElementById('statusDebugChk').onchange = async (e) => { const chk = e.target; chk.disabled = true; try { await api.adminSetStatusDebug(chk.checked); showToast('Status debug ' + (chk.checked ? 'enabled' : 'disabled'), 'success'); } catch (err) { showToast(err.message, 'error'); chk.checked = !chk.checked; } finally { chk.disabled = false; } }; } async function loadPlans() { const el = document.getElementById('plansTable'); try { // Admin endpoint, not /api/subscription/plans: that one filters `active = 1` because it feeds // the pricing page, so a deliberately hidden plan (a comped or beta tier) was invisible to the // operator too. Here we want every plan, plus who is actually on each one. const { plans, orphaned } = await api.adminListPlans(); el.innerHTML = `
${plans.map(p => ` `).join('')}
${t('admin.col.plan')} ${t('admin.col.devices')} ${t('admin.col.storage')} ${t('admin.col.monthly')} ${t('admin.col.yearly')} ${t('admin.col.accounts')} ${t('admin.col.screens')}
${esc(p.display_name)} ${esc(p.id)} ${p.active ? '' : `${t('admin.plan_hidden')}`} ${p.max_devices === -1 ? t('admin.unlimited') : p.max_devices} ${p.max_storage_mb === -1 ? t('admin.unlimited') : p.max_storage_mb >= 1024 ? (p.max_storage_mb/1024)+'GB' : p.max_storage_mb+'MB'} ${p.price_monthly > 0 ? '$'+p.price_monthly : t('admin.free')} ${p.price_yearly > 0 ? '$'+p.price_yearly : '-'} ${p.user_count} ${p.device_count}
${(orphaned && orphaned.length) ? `

${t('admin.plan_orphaned')}: ${orphaned.map(o => `${esc(o.plan_id)} (${o.user_count})`).join(', ')}

` : ''} `; } catch (err) { el.innerHTML = `

${esc(err.message)}

`; } } async function loadSystem() { const el = document.getElementById('systemInfo'); try { const version = await fetch('/api/version').then(r => r.json()); const token = localStorage.getItem('token'); const versionComparison = version.latest_version ? `
${t('admin.latest_version')}
${esc(version.latest_version)}
${t('admin.status')}
${version.update_available ? (t('admin.update_available')) : (t('admin.up_to_date'))}
` : `
${t('admin.latest_version')}
${t('admin.checking')}
`; el.innerHTML = `
${t('admin.version')}
${esc(version.version)}
${versionComparison}
${t('admin.download_db_backup')} ${t('admin.server_status')}
`; // Check Now button document.getElementById('checkUpdateBtn')?.addEventListener('click', async () => { const btn = document.getElementById('checkUpdateBtn'); btn.disabled = true; btn.textContent = t('admin.checking'); try { const res = await fetch('/api/admin/check-update', { method: 'POST', headers: headers() }); const data = await res.json(); const updBtn = document.getElementById('triggerUpdateBtn'); if (data.update_available && updBtn) { updBtn.style.display = ''; } loadSystem(); // refresh the whole card } catch (err) { showToast(err.message, 'error'); btn.disabled = false; btn.textContent = t('admin.check_now'); } }); // Update Now button document.getElementById('triggerUpdateBtn')?.addEventListener('click', async () => { const btn = document.getElementById('triggerUpdateBtn'); const resultEl = document.getElementById('updateResult'); btn.disabled = true; btn.textContent = t('admin.updating'); try { const res = await fetch('/api/admin/trigger-update', { method: 'POST', headers: headers() }); const data = await res.json(); if (data.docker_enabled) { // Docker executed — show output with Copy button resultEl.innerHTML = `
${data.success ? (t('admin.update_success')) : (t('admin.update_failed'))}
${esc(data.output || '')}
`; document.getElementById('copyOutputBtn')?.addEventListener('click', () => { const pre = resultEl.querySelector('pre'); const text = pre ? pre.textContent : ''; if (navigator.clipboard) { navigator.clipboard.writeText(text).then(() => showToast(t('admin.copied'), 'success')); } else { // Fallback for older browsers const ta = document.createElement('textarea'); ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); document.body.removeChild(ta); showToast(t('admin.copied'), 'success'); } }); } else if (data.instructions) { // Docker disabled — show manual instructions with Copy button resultEl.innerHTML = `
${t('admin.manual_update')}

${t('admin.manual_update_desc')}

${esc(data.instructions)}
`; document.getElementById('copyCmdBtn')?.addEventListener('click', () => { const pre = resultEl.querySelector('pre'); const text = pre ? pre.textContent : ''; if (navigator.clipboard) { navigator.clipboard.writeText(text).then(() => showToast(t('admin.copied'), 'success')); } else { const ta = document.createElement('textarea'); ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); document.body.removeChild(ta); showToast(t('admin.copied'), 'success'); } }); } } catch (err) { showToast(err.message, 'error'); } finally { btn.disabled = false; btn.textContent = t('admin.update_now'); } }); } catch (err) { el.innerHTML = `

${esc(err.message)}

`; } } export function cleanup() {}