Files
rmancinasandClaude Opus 5 999717f77b feat: self-hosted remote support over VNC
Browser-based remote control (noVNC) with invite links, per-user access
control, garagedoor SSO and a persisted client list.

The hub proxies RFB rather than pointing the browser at a VNC server. That
is what lets it authenticate upstream with a stored password the browser
never sees, and enforce view-only by dropping input messages on the
client->server stream instead of hiding buttons.

Machines are reachable two ways: direct TCP for LAN hosts, or an outbound
agent tunnel for anything behind NAT. Node 22's global WebSocket keeps the
agent dependency-free, and node:sqlite keeps the image free of native
builds.

Ships with an end-to-end suite that boots the real server against a fake
VNC server and a fake auth service (72 assertions), plus Gitea Actions
CI/CD to Portainer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 23:37:35 -07:00

719 lines
27 KiB
JavaScript

'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();