'use strict'; /* Remote Support console. No build step: plain DOM, plain fetch. */ const TOKEN_KEY = 'rcs.token'; const state = { token: localStorage.getItem(TOKEN_KEY) || null, username: null, isAdmin: false, clients: [], tab: 'machines', }; /* ------------------------------------------------------------- helpers */ function h(tag, props = {}, ...children) { const el = document.createElement(tag); for (const [k, v] of Object.entries(props || {})) { if (v === null || v === undefined || v === false) continue; if (k === 'class') el.className = v; else if (k === 'text') el.textContent = v; else if (k.startsWith('on') && typeof v === 'function') el.addEventListener(k.slice(2).toLowerCase(), v); else el.setAttribute(k, v === true ? '' : v); } for (const child of children.flat()) { if (child === null || child === undefined || child === false) continue; el.append(child.nodeType ? child : document.createTextNode(String(child))); } return el; } function $(sel) { return document.querySelector(sel); } function toast(message, ms = 2600) { document.getElementById('toast')?.remove(); const el = h('div', { id: 'toast', text: message }); document.body.append(el); setTimeout(() => el.remove(), ms); } function ago(ts) { if (!ts) return 'never'; const s = Math.floor((Date.now() - ts) / 1000); if (s < 45) return 'just now'; if (s < 3600) return `${Math.floor(s / 60)}m ago`; if (s < 86400) return `${Math.floor(s / 3600)}h ago`; return `${Math.floor(s / 86400)}d ago`; } function until(ts) { if (!ts) return 'never'; const s = Math.floor((ts - Date.now()) / 1000); if (s <= 0) return 'expired'; if (s < 3600) return `${Math.floor(s / 60)}m`; if (s < 86400) return `${Math.floor(s / 3600)}h`; return `${Math.floor(s / 86400)}d`; } function bytes(n) { if (!n) return '0 B'; const units = ['B', 'KB', 'MB', 'GB']; const i = Math.min(Math.floor(Math.log(n) / Math.log(1024)), units.length - 1); return `${(n / 1024 ** i).toFixed(i ? 1 : 0)} ${units[i]}`; } function duration(from, to) { const s = Math.floor(((to || Date.now()) - from) / 1000); const m = Math.floor(s / 60); return m ? `${m}m ${s % 60}s` : `${s}s`; } /* ----------------------------------------------------------------- api */ async function api(path, { method = 'GET', body } = {}) { const res = await fetch(`/api${path}`, { method, headers: { ...(body ? { 'Content-Type': 'application/json' } : {}), ...(state.token ? { Authorization: `Bearer ${state.token}` } : {}), }, body: body ? JSON.stringify(body) : undefined, }); if (res.status === 401) { // The garagedoor token is only good for an hour; expiry means log in again. signOut(); throw new Error('your session expired — sign in again'); } const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || `request failed (${res.status})`); return data; } /* --------------------------------------------------------------- modal */ function modal({ title, body, actions }) { const root = $('#modal-root'); const close = () => { root.innerHTML = ''; document.removeEventListener('keydown', onKey); }; const onKey = (e) => { if (e.key === 'Escape') close(); }; document.addEventListener('keydown', onKey); const box = h('div', { class: 'modal' }, h('div', { class: 'modal-head' }, h('h2', { text: title }), h('div', { class: 'spacer' }), h('button', { class: 'ghost small', text: '✕', onclick: close })), body, actions ? h('div', { class: 'modal-actions' }, ...actions(close)) : null); const backdrop = h('div', { class: 'modal-backdrop', onclick: (e) => { if (e.target === backdrop) close(); }, }, box); root.append(backdrop); box.querySelector('input, select, textarea')?.focus(); return close; } function showLink(title, url, note) { const input = h('input', { value: url, readonly: true }); modal({ title, body: h('div', {}, note ? h('p', { class: 'faint', text: note }) : null, h('div', { class: 'copybox' }, input, h('button', { class: 'primary', text: 'Copy', onclick: async () => { try { await navigator.clipboard.writeText(url); toast('Link copied'); } catch { input.select(); toast('Press ⌘C / Ctrl-C to copy'); } }, })), h('p', { class: 'faint', style: 'margin-bottom:0', text: 'This link is shown once. Copy it now.' })), actions: (close) => [h('button', { text: 'Done', onclick: close })], }); input.select(); } /* ----------------------------------------------------------------- auth */ async function signIn(event) { event.preventDefault(); const button = $('#login-button'); const err = $('#login-error'); err.classList.add('hidden'); button.disabled = true; button.textContent = 'Signing in…'; try { const res = await fetch('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: $('#username').value, password: $('#password').value }), }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || 'sign in failed'); state.token = data.token; state.username = data.username; state.isAdmin = data.isAdmin; localStorage.setItem(TOKEN_KEY, data.token); $('#password').value = ''; showApp(); } catch (e) { err.textContent = e.message; err.classList.remove('hidden'); } finally { button.disabled = false; button.textContent = 'Sign in'; } } function signOut() { state.token = null; localStorage.removeItem(TOKEN_KEY); $('#app').classList.add('hidden'); $('#login').classList.remove('hidden'); } /* ------------------------------------------------------------- machines */ function connect(client, viewOnly) { const url = new URL('/viewer', location.origin); url.searchParams.set('client', client.id); if (viewOnly) url.searchParams.set('viewOnly', '1'); window.open(url.toString(), '_blank', 'noopener'); } function clientCard(client) { const isAgent = client.mode === 'agent'; const online = isAgent ? client.online : null; const status = isAgent ? h('span', { class: `dot ${online ? 'online' : 'offline'}`, title: online ? 'online' : 'offline' }) : h('span', { class: 'dot direct', title: 'direct connection' }); const meta = []; if (isAgent) meta.push(online ? 'Online' : `Last seen ${ago(client.lastSeenAt)}`); else meta.push(`${client.host}:${client.port}`); if (client.os) meta.push(client.os); const canConnect = !isAgent || online; const actions = [ h('button', { class: 'primary small', text: 'Connect', disabled: !canConnect, onclick: () => connect(client, false), }), h('button', { class: 'small', text: 'View only', disabled: !canConnect, onclick: () => connect(client, true), }), ]; if (state.isAdmin) { actions.push(h('button', { class: 'ghost small', text: 'Share', onclick: () => newShareLink(client) })); actions.push(h('button', { class: 'ghost small', text: '⋯', onclick: () => clientMenu(client) })); } return h('div', { class: 'card' }, h('div', { class: 'card-title' }, status, h('h3', { text: client.name }), h('div', { class: 'spacer' }), client.requireConsent ? h('span', { class: 'tag warn', text: 'asks first' }) : null, client.grantedRole ? h('span', { class: 'tag role', text: client.grantedRole }) : null), client.description ? h('div', { class: 'faint', text: client.description }) : null, h('div', { class: 'faint', text: meta.join(' · ') }), client.tags.length ? h('div', { class: 'row wrap' }, client.tags.map((t) => h('span', { class: 'tag', text: t }))) : null, h('div', { class: 'card-actions' }, ...actions)); } function clientMenu(client) { modal({ title: client.name, body: h('div', { class: 'row wrap' }, h('button', { class: 'small', text: 'Edit', onclick: () => { $('#modal-root').innerHTML = ''; clientForm(client); } }), h('button', { class: 'small', text: 'Who has access', onclick: () => { $('#modal-root').innerHTML = ''; grantsDialog(client); } }), h('button', { class: 'small', text: 'Session history', onclick: () => { $('#modal-root').innerHTML = ''; historyDialog(client); } }), h('button', { class: 'small', text: client.enrolled ? 'Re-issue agent key' : 'Issue agent key', onclick: async () => { if (client.enrolled && !confirm('The current agent will be disconnected and must be reconfigured. Continue?')) return; const { agentKey } = await api(`/clients/${client.id}/agent-key`, { method: 'POST' }); $('#modal-root').innerHTML = ''; showLink('Agent key', agentKey, 'Put this in the agent config on that machine.'); refresh(); }, }), h('button', { class: 'small danger', text: 'Delete', onclick: async () => { if (!confirm(`Delete "${client.name}"? Its grants and live sessions go with it.`)) return; await api(`/clients/${client.id}`, { method: 'DELETE' }); $('#modal-root').innerHTML = ''; toast('Machine deleted'); refresh(); }, })), }); } function clientForm(client) { const editing = !!client; const c = client || { mode: 'direct', port: 5900, tags: [] }; const name = h('input', { value: c.name || '', required: true, placeholder: 'Reception PC' }); const description = h('input', { value: c.description || '', placeholder: 'Optional note' }); const host = h('input', { value: c.host || '', placeholder: '192.168.4.50' }); const port = h('input', { type: 'number', value: c.port || 5900, min: '1', max: '65535' }); const password = h('input', { type: 'password', placeholder: editing && c.hasPassword ? '•••••••• (unchanged)' : 'VNC password, if the server needs one', }); const tags = h('input', { value: (c.tags || []).join(', '), placeholder: 'office, windows' }); const consent = h('input', { type: 'checkbox', ...(c.requireConsent ? { checked: true } : {}) }); const mode = h('select', {}, h('option', { value: 'direct', ...(c.mode === 'direct' ? { selected: true } : {}) }, 'Direct — the hub can reach its VNC port'), h('option', { value: 'agent', ...(c.mode === 'agent' ? { selected: true } : {}) }, 'Agent — the machine dials out to the hub')); const directFields = h('div', {}, h('div', { class: 'field-row' }, h('div', { class: 'field' }, h('label', { text: 'Host' }), host), h('div', { class: 'field' }, h('label', { text: 'Port' }), port))); const syncMode = () => { directFields.classList.toggle('hidden', mode.value !== 'direct'); }; mode.addEventListener('change', syncMode); const error = h('div', { class: 'notice error hidden' }); const body = h('div', {}, error, h('div', { class: 'field' }, h('label', { text: 'Name' }), name), h('div', { class: 'field' }, h('label', { text: 'Description' }), description), h('div', { class: 'field' }, h('label', { text: 'Connection' }), mode), directFields, h('div', { class: 'field' }, h('label', { text: 'VNC password' }), password), h('div', { class: 'field' }, h('label', { text: 'Tags' }), tags), h('div', { class: 'field' }, h('label', { class: 'check' }, consent, 'Ask the person at that machine before connecting'))); syncMode(); modal({ title: editing ? 'Edit machine' : 'Add machine', body, actions: (close) => [ h('button', { text: 'Cancel', onclick: close }), h('button', { class: 'primary', text: editing ? 'Save' : 'Add', onclick: async (e) => { const button = e.currentTarget; button.disabled = true; error.classList.add('hidden'); const payload = { name: name.value, description: description.value, mode: mode.value, host: host.value, port: Number(port.value) || 5900, tags: tags.value, requireConsent: consent.checked, }; // Leaving the password blank on an edit keeps whatever is stored. if (password.value || !editing) payload.vncPassword = password.value; try { if (editing) await api(`/clients/${client.id}`, { method: 'PATCH', body: payload }); else await api('/clients', { method: 'POST', body: payload }); close(); toast(editing ? 'Saved' : 'Machine added'); refresh(); } catch (err) { error.textContent = err.message; error.classList.remove('hidden'); button.disabled = false; } }, }), ], }); } async function grantsDialog(client) { const { grants } = await api(`/clients/${client.id}/grants`); const list = h('div', { class: 'table-wrap', style: 'margin-bottom:16px' }); const draw = (rows) => { list.innerHTML = ''; if (!rows.length) { list.append(h('div', { class: 'empty', text: 'Only admins can reach this machine.' })); return; } list.append(h('table', {}, h('thead', {}, h('tr', {}, h('th', { text: 'User' }), h('th', { text: 'Role' }), h('th', { text: 'Expires' }), h('th', {}))), h('tbody', {}, rows.map((g) => h('tr', {}, h('td', { text: g.username }), h('td', { text: g.role }), h('td', { class: 'faint', text: g.expires_at ? until(g.expires_at) : '—' }), h('td', {}, h('button', { class: 'ghost small danger', text: 'Remove', onclick: async () => { await api(`/clients/${client.id}/grants/${encodeURIComponent(g.username)}`, { method: 'DELETE' }); draw(rows.filter((r) => r.username !== g.username)); }, }))))))); }; draw(grants); const username = h('input', { placeholder: 'username' }); const role = h('select', {}, h('option', { value: 'viewer' }, 'View only'), h('option', { value: 'operator' }, 'Full control')); modal({ title: `Access to ${client.name}`, body: h('div', {}, list, h('div', { class: 'field-row' }, h('div', { class: 'field' }, h('label', { text: 'Add user' }), username), h('div', { class: 'field' }, h('label', { text: 'Role' }), role))), actions: (close) => [ h('button', { text: 'Close', onclick: close }), h('button', { class: 'primary', text: 'Grant access', onclick: async () => { if (!username.value.trim()) return; await api(`/clients/${client.id}/grants`, { method: 'POST', body: { username: username.value.trim(), role: role.value }, }); const fresh = await api(`/clients/${client.id}/grants`); username.value = ''; draw(fresh.grants); toast('Access granted'); }, }), ], }); } async function historyDialog(client) { const { sessions } = await api(`/sessions/history?clientId=${encodeURIComponent(client.id)}&limit=50`); modal({ title: `${client.name} — sessions`, body: sessions.length ? h('div', { class: 'table-wrap' }, sessionTable(sessions)) : h('div', { class: 'empty', text: 'No sessions recorded yet.' }), }); } /* -------------------------------------------------------------- invites */ const TTL_OPTIONS = [ ['1 hour', 3600e3], ['8 hours', 8 * 3600e3], ['1 day', 24 * 3600e3], ['7 days', 7 * 24 * 3600e3], ['30 days', 30 * 24 * 3600e3], ]; function newEnrollLink() { const name = h('input', { placeholder: 'leave blank to use the machine name' }); const tags = h('input', { placeholder: 'office, windows' }); const consent = h('input', { type: 'checkbox' }); const ttl = h('select', {}, TTL_OPTIONS.map(([label, ms], i) => h('option', { value: ms, ...(i === 2 ? { selected: true } : {}) }, label))); modal({ title: 'Invite a machine', body: h('div', {}, h('p', { class: 'faint', text: 'Send this to whoever is at the machine. Running the agent with it registers the machine here.' }), h('div', { class: 'field' }, h('label', { text: 'Name it' }), name), h('div', { class: 'field' }, h('label', { text: 'Tags' }), tags), h('div', { class: 'field' }, h('label', { text: 'Link valid for' }), ttl), h('div', { class: 'field' }, h('label', { class: 'check' }, consent, 'Ask the person there before each connection'))), actions: (close) => [ h('button', { text: 'Cancel', onclick: close }), h('button', { class: 'primary', text: 'Create link', onclick: async () => { const res = await api('/invites', { method: 'POST', body: { kind: 'enroll', name: name.value || undefined, tags: tags.value, requireConsent: consent.checked, ttlMs: Number(ttl.value), }, }); close(); showLink('Enrollment link', res.url, 'Open this on the machine you want to support.'); refresh(); }, }), ], }); } function newShareLink(preselect) { const target = h('select', {}, state.clients.map((c) => h('option', { value: c.id, ...(preselect && c.id === preselect.id ? { selected: true } : {}) }, c.name))); const role = h('select', {}, h('option', { value: 'viewer' }, 'View only'), h('option', { value: 'operator' }, 'Full control')); const ttl = h('select', {}, TTL_OPTIONS.map(([label, ms], i) => h('option', { value: ms, ...(i === 0 ? { selected: true } : {}) }, label))); const label = h('input', { placeholder: 'who is this for?' }); modal({ title: 'Support link', body: h('div', {}, h('p', { class: 'faint', text: 'Anyone with this link can connect to that machine until it expires. No sign in needed.' }), h('div', { class: 'field' }, h('label', { text: 'Machine' }), target), h('div', { class: 'field' }, h('label', { text: 'They can' }), role), h('div', { class: 'field' }, h('label', { text: 'Link valid for' }), ttl), h('div', { class: 'field' }, h('label', { text: 'Label' }), label)), actions: (close) => [ h('button', { text: 'Cancel', onclick: close }), h('button', { class: 'primary', text: 'Create link', onclick: async () => { const res = await api('/invites', { method: 'POST', body: { kind: 'session', clientId: target.value, role: role.value, ttlMs: Number(ttl.value), label: label.value }, }); close(); showLink('Support link', res.url, 'Send this to the person who needs access.'); if (state.tab === 'invites') loadInvites(); }, }), ], }); } async function loadInvites() { const container = $('#invites'); const { invites } = await api('/invites'); container.innerHTML = ''; if (!invites.length) { container.append(h('div', { class: 'empty', text: 'No invite links yet.' })); return; } container.append(h('div', { class: 'table-wrap' }, h('table', {}, h('thead', {}, h('tr', {}, h('th', { text: 'Kind' }), h('th', { text: 'Target' }), h('th', { text: 'Label' }), h('th', { text: 'Role' }), h('th', { text: 'Uses' }), h('th', { text: 'Expires' }), h('th', { text: 'Status' }), h('th', {}))), h('tbody', {}, invites.map((i) => h('tr', {}, h('td', { text: i.kind === 'enroll' ? 'Machine' : 'Support' }), h('td', { text: i.clientName || '—' }), h('td', { class: 'faint', text: i.label || '—' }), h('td', { text: i.role || '—' }), h('td', { text: i.maxUses ? `${i.uses}/${i.maxUses}` : String(i.uses) }), h('td', { class: 'faint', text: until(i.expiresAt) }), h('td', {}, h('span', { class: `tag ${i.status === 'active' ? 'role' : ''}`, text: i.status })), h('td', {}, i.status === 'active' ? h('button', { class: 'ghost small danger', text: 'Revoke', onclick: async () => { await api(`/invites/${i.id}/revoke`, { method: 'POST' }); toast('Link revoked'); loadInvites(); }, }) : h('button', { class: 'ghost small', text: 'Delete', onclick: async () => { await api(`/invites/${i.id}`, { method: 'DELETE' }); loadInvites(); }, })))))))); } /* ------------------------------------------------------------- sessions */ function sessionTable(rows) { return h('table', {}, h('thead', {}, h('tr', {}, h('th', { text: 'Machine' }), h('th', { text: 'Who' }), h('th', { text: 'Role' }), h('th', { text: 'Started' }), h('th', { text: 'Length' }), h('th', { text: 'Traffic' }), h('th', { text: 'Ended' }))), h('tbody', {}, rows.map((s) => h('tr', {}, h('td', { text: s.client_name || s.clientName || '—' }), h('td', { text: s.username }), h('td', {}, h('span', { class: 'tag', text: s.role })), h('td', { class: 'faint', text: ago(s.started_at || s.startedAt) }), h('td', { class: 'faint', text: duration(s.started_at || s.startedAt, s.ended_at) }), h('td', { class: 'faint', text: bytes((s.bytes_in || 0) + (s.bytes_out || 0)) }), h('td', { class: 'faint', text: s.end_reason || '—' }))))); } async function loadSessions() { const liveBox = $('#live-sessions'); const histBox = $('#session-history'); const { sessions: liveRows } = await api('/sessions/live'); liveBox.innerHTML = ''; if (!liveRows.length) { liveBox.append(h('div', { class: 'empty', text: 'Nobody is connected right now.' })); } else { liveBox.append(h('div', { class: 'table-wrap' }, h('table', {}, h('thead', {}, h('tr', {}, h('th', { text: 'Machine' }), h('th', { text: 'Who' }), h('th', { text: 'Role' }), h('th', { text: 'Source' }), h('th', { text: 'For' }), h('th', { text: 'Traffic' }), h('th', {}))), h('tbody', {}, liveRows.map((s) => h('tr', {}, h('td', {}, h('span', { class: 'row' }, h('span', { class: 'dot online' }), s.clientName)), h('td', { text: s.username }), h('td', {}, h('span', { class: 'tag role', text: s.role })), h('td', { class: 'faint', text: s.source }), h('td', { class: 'faint', text: duration(s.startedAt) }), h('td', { class: 'faint', text: bytes(s.bytesIn + s.bytesOut) }), h('td', {}, state.isAdmin ? h('button', { class: 'ghost small danger', text: 'Disconnect', onclick: async () => { await api(`/sessions/${s.id}/kill`, { method: 'POST' }); toast('Session ended'); loadSessions(); }, }) : null))))))); } if (!state.isAdmin) { histBox.innerHTML = ''; histBox.append(h('div', { class: 'empty', text: 'History is admin-only.' })); return; } const { sessions: history } = await api('/sessions/history?limit=100'); histBox.innerHTML = ''; histBox.append(history.length ? h('div', { class: 'table-wrap' }, sessionTable(history)) : h('div', { class: 'empty', text: 'No sessions recorded yet.' })); } async function loadAudit() { const box = $('#audit'); const { audit } = await api('/sessions/audit?limit=200'); box.innerHTML = ''; box.append(audit.length ? h('div', { class: 'table-wrap' }, h('table', {}, h('thead', {}, h('tr', {}, h('th', { text: 'When' }), h('th', { text: 'Who' }), h('th', { text: 'Action' }), h('th', { text: 'Target' }), h('th', { text: 'Detail' }))), h('tbody', {}, audit.map((a) => h('tr', {}, h('td', { class: 'faint', text: ago(a.ts) }), h('td', { text: a.username || '—' }), h('td', {}, h('span', { class: 'tag', text: a.action })), h('td', { class: 'mono dim', text: (a.target || '—').slice(0, 8) }), h('td', { class: 'faint', text: a.detail || '' })))))) : h('div', { class: 'empty', text: 'Nothing logged yet.' })); } /* ------------------------------------------------------------- shell */ async function refresh() { try { const { clients, isAdmin } = await api('/clients'); state.clients = clients; state.isAdmin = isAdmin; const grid = $('#machines'); grid.innerHTML = ''; $('#machines-count').textContent = clients.length ? `${clients.length} total` : ''; if (!clients.length) { grid.append(h('div', { class: 'empty', style: 'grid-column:1/-1' }, state.isAdmin ? 'No machines yet. Add one directly, or send an enrollment link.' : 'Nobody has given you access to a machine yet.')); } else { for (const c of clients) grid.append(clientCard(c)); } for (const el of document.querySelectorAll('[data-admin-only]')) el.classList.toggle('hidden', !state.isAdmin); } catch (err) { toast(err.message); } } function selectTab(tab) { state.tab = tab; for (const b of document.querySelectorAll('#tabs button')) b.classList.toggle('active', b.dataset.tab === tab); for (const name of ['machines', 'invites', 'sessions', 'audit']) { $(`#tab-${name}`).classList.toggle('hidden', name !== tab); } if (tab === 'machines') refresh(); if (tab === 'invites') loadInvites().catch((e) => toast(e.message)); if (tab === 'sessions') loadSessions().catch((e) => toast(e.message)); if (tab === 'audit') loadAudit().catch((e) => toast(e.message)); } function showApp() { $('#login').classList.add('hidden'); $('#app').classList.remove('hidden'); $('#whoami').textContent = state.isAdmin ? `${state.username} · admin` : state.username; for (const el of [$('#btn-add-client'), $('#btn-enroll-invite'), $('#btn-new-enroll'), $('#btn-new-share')]) { el.classList.toggle('hidden', !state.isAdmin); } $('#tabs').querySelector('[data-tab="audit"]').classList.toggle('hidden', !state.isAdmin); selectTab('machines'); } async function boot() { $('#login-form').addEventListener('submit', signIn); $('#logout').addEventListener('click', signOut); $('#tabs').addEventListener('click', (e) => { const tab = e.target.closest('button')?.dataset.tab; if (tab) selectTab(tab); }); $('#btn-add-client').addEventListener('click', () => clientForm(null)); $('#btn-enroll-invite').addEventListener('click', newEnrollLink); $('#btn-new-enroll').addEventListener('click', newEnrollLink); $('#btn-new-share').addEventListener('click', () => newShareLink(null)); $('#btn-refresh-sessions').addEventListener('click', () => loadSessions().catch((e) => toast(e.message))); if (!state.token) return $('#login').classList.remove('hidden'); try { const me = await api('/me'); state.username = me.username; state.isAdmin = me.isAdmin; showApp(); } catch { $('#login').classList.remove('hidden'); } // Keep the machine list and any open session view roughly current. setInterval(() => { if (document.hidden || $('#app').classList.contains('hidden')) return; if (state.tab === 'machines') refresh(); if (state.tab === 'sessions') loadSessions().catch(() => {}); }, 15_000); } boot();