// #/control — Control: admin UI for the "IV Control" licensed-distribution system. // Talks ONLY to /api/control/* (Void 2's owner-only proxy to the ivctl admin API; // the admin token lives server-side). Tabs: Applicants, Instances, Releases, // Tickets, Groups. Pure el()/mount() — no innerHTML from API data. import { el, mount, clear, safeHref } from '../dom.js'; import { api } from '../api.js'; const TIERS = ['lock', 'uninstall-keep', 'wipe']; const A = '/api/control/admin'; // ---- small UI helpers ------------------------------------------------------- function btn(label, onclick, cls = 'ghost') { return el('button', { class: cls, style: { marginRight: '0.35rem' }, onclick }, label); } function field(labelText, control) { return el('label', { style: { display: 'flex', flexDirection: 'column', gap: '0.2rem', fontSize: '0.8rem' } }, el('span', { class: 'muted' }, labelText), control); } function select(options, value) { const s = el('select', { class: 'lk-url' }); for (const o of options) { const opt = el('option', { value: typeof o === 'string' ? o : o.value }, typeof o === 'string' ? o : o.label); if ((typeof o === 'string' ? o : o.value) === value) opt.selected = true; s.appendChild(opt); } return s; } function notify(host, msg, ok = true) { clear(host); host.appendChild(el('span', { style: { color: ok ? 'var(--accent, #5ec27a)' : 'var(--danger, #e06b6b)', fontSize: '0.8rem' } }, msg)); } function table(headers, rows) { const ths = headers.map(h => el('th', { style: { textAlign: 'left', padding: '0.4rem 0.5rem', borderBottom: '1px solid var(--border)', color: 'var(--muted)', fontWeight: '600' } }, h)); return el('table', { class: 'ctl-table', style: { width: '100%', borderCollapse: 'collapse', fontSize: '0.82rem' } }, el('thead', {}, el('tr', {}, ths)), el('tbody', {}, rows)); } function td(...children) { return el('td', { style: { padding: '0.4rem 0.5rem', borderBottom: '1px solid var(--border)', verticalAlign: 'top' } }, ...children); } function statusPill(s) { const colors = { active: '#5ec27a', open: '#5ec27a', suspended: '#e0b24b', revoked: '#e06b6b', closed: '#8a8a99', pending: '#e0b24b', approved: '#5ec27a', denied: '#e06b6b' }; return el('span', { class: 'badge', style: { background: 'transparent', border: `1px solid ${colors[s] || 'var(--border)'}`, color: colors[s] || 'var(--muted)' } }, s || '—'); } // ---- group cache (used by approve/instances dropdowns) ---------------------- let groupsCache = []; async function loadGroups() { try { groupsCache = await api.get(`${A}/groups`); } catch { groupsCache = []; } return groupsCache; } function groupName(id) { const g = groupsCache.find(g => String(g.id) === String(id)); return g ? g.name : (id ?? '—'); } // ============================================================================ // Applicants // ============================================================================ async function renderApplicants(panel) { mount(panel, el('p', { class: 'muted' }, 'Loading applicants…')); await loadGroups(); let rows; try { rows = await api.get(`${A}/applicants?status=pending`); } catch (e) { return mount(panel, errBox(e)); } const body = rows.map(a => { const msg = el('span', { class: 'muted', style: { fontSize: '0.75rem' } }, ''); const groupSel = select([{ value: '', label: '(default group)' }, ...groupsCache.map(g => ({ value: g.id, label: g.name }))]); const approve = btn('Approve', async () => { try { const r = await api.post(`${A}/applicants/${a.id}/approve`, groupSel.value ? { group_id: groupSel.value } : {}); clear(msg); const code = r.claim_code || r.code || ''; const codeEl = el('code', { style: { fontWeight: '700', userSelect: 'all' } }, code); msg.appendChild(el('span', { style: { color: 'var(--accent,#5ec27a)' } }, 'Claim code: ')); msg.appendChild(codeEl); msg.appendChild(btn('Copy', () => navigator.clipboard?.writeText(code), 'ghost')); } catch (e) { notify(msg, e.message || 'approve failed', false); } }, 'primary'); const deny = btn('Deny', async () => { try { await api.post(`${A}/applicants/${a.id}/deny`, {}); notify(msg, 'denied', true); } catch (e) { notify(msg, e.message || 'deny failed', false); } }); return el('tr', {}, td(el('strong', {}, a.label || a.name || a.email || `#${a.id}`), el('div', { class: 'muted', style: { fontSize: '0.72rem' } }, a.email || '')), td(a.note || a.reason || '—'), td(statusPill(a.status)), td(el('div', { style: { display: 'flex', alignItems: 'center', gap: '0.35rem', flexWrap: 'wrap' } }, groupSel, approve, deny)), td(msg)); }); mount(panel, el('div', { class: 'term-bar' }, el('span', { class: 'term-title' }, '◆ Pending applicants'), btn('Refresh', () => renderApplicants(panel), 'ghost')), rows.length ? table(['Applicant', 'Note', 'Status', 'Action', ''], body) : el('p', { class: 'muted' }, 'No pending applicants.')); } // ============================================================================ // Instances (licenses) // ============================================================================ async function renderInstances(panel) { mount(panel, el('p', { class: 'muted' }, 'Loading instances…')); await loadGroups(); let rows; try { rows = await api.get(`${A}/licenses`); } catch (e) { return mount(panel, errBox(e)); } const body = rows.map(l => { const msg = el('span', { class: 'muted', style: { fontSize: '0.72rem' } }, ''); const patch = async (payload, okMsg) => { try { await api.patch(`${A}/licenses/${l.id}`, payload); notify(msg, okMsg || 'updated', true); renderInstances(panel); } catch (e) { notify(msg, e.message || 'failed', false); } }; const tierSel = select(TIERS, l.tier); tierSel.onchange = () => patch({ tier: tierSel.value }, `tier → ${tierSel.value}`); const groupSel = select([{ value: '', label: '(none)' }, ...groupsCache.map(g => ({ value: g.id, label: g.name }))], l.group_id ?? ''); groupSel.onchange = () => patch({ group_id: groupSel.value || null }, 'group changed'); const actions = el('div', { style: { display: 'flex', flexWrap: 'wrap', gap: '0.2rem' } }); if (l.status !== 'suspended') actions.appendChild(btn('Suspend', () => patch({ status: 'suspended' }, 'suspended'))); if (l.status !== 'active') actions.appendChild(btn('Restore', () => patch({ status: 'active' }, 'restored'))); if (l.status !== 'revoked') actions.appendChild(btn('Revoke', () => { if (confirm('Revoke this instance?')) patch({ status: 'revoked' }, 'revoked'); }, 'ghost')); actions.appendChild(btn('+Extend', () => { const d = prompt('Extend lease by how many days?', '30'); const n = parseInt(d, 10); if (Number.isFinite(n) && n !== 0) patch({ extend_days: n }, `extended +${n}d`); })); return el('tr', {}, td(el('strong', {}, l.label || l.email || `#${l.id}`), el('div', { class: 'muted', style: { fontSize: '0.72rem' } }, l.email || '')), td(groupSel), td(statusPill(l.status)), td(tierSel), td(String(l.lease_days ?? '—')), td(l.version || '—'), td(el('span', { class: 'muted', style: { fontSize: '0.72rem' } }, fmtTime(l.last_seen || l.last_seen_at))), td(actions, msg)); }); mount(panel, el('div', { class: 'term-bar' }, el('span', { class: 'term-title' }, '◆ Instances'), btn('Refresh', () => renderInstances(panel), 'ghost')), rows.length ? table(['Instance', 'Group', 'Status', 'Tier', 'Lease', 'Version', 'Last seen', 'Actions'], body) : el('p', { class: 'muted' }, 'No instances yet.')); } // ============================================================================ // Releases // ============================================================================ async function renderReleases(panel) { mount(panel, el('p', { class: 'muted' }, 'Loading releases…')); await loadGroups(); let rows; try { rows = await api.get(`${A}/releases`); } catch (e) { return mount(panel, errBox(e)); } // Upload form const fileInput = el('input', { type: 'file', accept: '.tgz,.tar.gz,.tar,application/gzip,application/x-tar' }); const verInput = el('input', { class: 'lk-url', placeholder: 'version e.g. 1.4.0' }); const notesInput = el('textarea', { class: 'lk-url', rows: 2, placeholder: 'release notes…', style: { resize: 'vertical' } }); const upMsg = el('span', { class: 'muted', style: { fontSize: '0.78rem' } }, ''); const upBtn = btn('Upload release', async () => { if (!fileInput.files?.[0]) return notify(upMsg, 'pick a tarball first', false); if (!verInput.value.trim()) return notify(upMsg, 'version required', false); const fd = new FormData(); fd.append('file', fileInput.files[0]); fd.append('version', verInput.value.trim()); fd.append('notes', notesInput.value); notify(upMsg, 'uploading…', true); try { await api.postForm(`${A}/releases`, fd); notify(upMsg, 'uploaded', true); verInput.value = ''; notesInput.value = ''; fileInput.value = ''; renderReleases(panel); } catch (e) { notify(upMsg, e.message || 'upload failed', false); } }, 'primary'); const uploadCard = el('div', { class: 'card', style: { display: 'grid', gap: '0.5rem', marginBottom: '0.9rem' } }, el('div', { class: 'term-title' }, '◆ New release'), field('Tarball', fileInput), field('Version', verInput), field('Notes', notesInput), el('div', { style: { display: 'flex', alignItems: 'center', gap: '0.6rem' } }, upBtn, upMsg)); // Existing releases const body = rows.map(r => { const msg = el('span', { class: 'muted', style: { fontSize: '0.72rem' } }, ''); const patch = async (payload, okMsg) => { try { await api.patch(`${A}/releases/${r.id}`, payload); notify(msg, okMsg || 'updated', true); } catch (e) { notify(msg, e.message || 'failed', false); } }; const signoff = el('input', { type: 'checkbox', checked: !!r.signed_off }); signoff.onchange = () => patch({ signed_off: signoff.checked }, signoff.checked ? 'signed off' : 'sign-off cleared'); // Multi-select group targeting const targetSel = el('select', { class: 'lk-url', multiple: true, size: Math.min(4, Math.max(2, groupsCache.length)), style: { minWidth: '160px' } }); const targeted = new Set((r.target_group_ids || []).map(String)); for (const g of groupsCache) { const opt = el('option', { value: g.id }, g.name); if (targeted.has(String(g.id))) opt.selected = true; targetSel.appendChild(opt); } const applyTargets = btn('Set targets', () => { const ids = Array.from(targetSel.selectedOptions).map(o => o.value); patch({ target_group_ids: ids }, `targeting ${ids.length} group(s)`); }); const del = btn('Delete', async () => { if (!confirm(`Delete release ${r.version}?`)) return; try { await api.del(`${A}/releases/${r.id}`); renderReleases(panel); } catch (e) { notify(msg, e.message || 'delete failed', false); } }); return el('tr', {}, td(el('strong', {}, r.version || `#${r.id}`), el('div', { class: 'muted', style: { fontSize: '0.72rem' } }, fmtTime(r.created_at))), td(el('div', { style: { maxWidth: '260px', whiteSpace: 'pre-wrap' } }, r.notes || '—')), td(el('label', { style: { display: 'flex', alignItems: 'center', gap: '0.3rem' } }, signoff, el('span', { class: 'muted' }, 'signed off'))), td(el('div', { style: { display: 'flex', flexDirection: 'column', gap: '0.3rem' } }, targetSel, applyTargets)), td(del, msg)); }); mount(panel, el('div', { class: 'term-bar' }, el('span', { class: 'term-title' }, '◆ Releases'), btn('Refresh', () => renderReleases(panel), 'ghost')), uploadCard, rows.length ? table(['Version', 'Notes', 'Sign-off', 'Target groups', ''], body) : el('p', { class: 'muted' }, 'No releases uploaded.')); } // ============================================================================ // Tickets // ============================================================================ async function renderTickets(panel) { let filter = 'open'; const list = el('div'); const detail = el('div', { style: { marginTop: '0.9rem' } }); const statusSel = select([{ value: '', label: 'all' }, 'open', 'closed'], filter); statusSel.onchange = () => { filter = statusSel.value; loadList(); }; async function loadList() { mount(list, el('p', { class: 'muted' }, 'Loading tickets…')); let rows; try { rows = await api.get(`${A}/tickets${filter ? `?status=${encodeURIComponent(filter)}` : ''}`); } catch (e) { return mount(list, errBox(e)); } rows = rows.slice().sort((a, b) => new Date(b.created_at || 0) - new Date(a.created_at || 0)); const body = rows.map(t => el('tr', {}, td(el('a', { href: '#', onclick: (e) => { e.preventDefault(); openTicket(t.id); } }, el('strong', {}, t.subject || t.title || `Ticket #${t.id}`))), td(el('span', { class: 'muted', style: { fontSize: '0.72rem' } }, t.label || t.email || '—')), td(statusPill(t.status)), td(el('span', { class: 'muted', style: { fontSize: '0.72rem' } }, fmtTime(t.created_at))))); mount(list, rows.length ? table(['Subject', 'From', 'Status', 'Created'], body) : el('p', { class: 'muted' }, 'No tickets.')); } async function openTicket(id) { mount(detail, el('p', { class: 'muted' }, 'Loading ticket…')); let t; try { t = await api.get(`${A}/tickets/${id}`); } catch (e) { return mount(detail, errBox(e)); } const msg = el('span', { class: 'muted', style: { fontSize: '0.72rem' } }, ''); const notesInput = el('textarea', { class: 'lk-url', rows: 3, style: { resize: 'vertical' } }); notesInput.value = t.notes || ''; const patch = async (payload, okMsg) => { try { await api.patch(`${A}/tickets/${id}`, payload); notify(msg, okMsg || 'saved', true); loadList(); } catch (e) { notify(msg, e.message || 'failed', false); } }; const images = (t.images || t.image_attachments || []).map(att => { const attId = att.id ?? att; return el('a', { href: safeHref(`${A}/tickets/${id}/images/${attId}`), target: '_blank', rel: 'noopener' }, el('img', { src: `${A}/tickets/${id}/images/${attId}`, alt: 'screenshot', style: { maxWidth: '160px', maxHeight: '120px', border: '1px solid var(--border)', borderRadius: '4px', objectFit: 'cover' } })); }); const logs = (t.logs || t.log_attachments || []).map(att => { const attId = att.id ?? att; return el('a', { class: 'ghost', href: safeHref(`${A}/tickets/${id}/logs/${attId}`), target: '_blank', rel: 'noopener', style: { marginRight: '0.4rem' } }, '↗ ' + (att.name || `log ${attId}`)); }); mount(detail, el('div', { class: 'card', style: { display: 'grid', gap: '0.6rem' } }, el('div', { class: 'term-bar' }, el('span', { class: 'term-title' }, t.subject || t.title || `Ticket #${id}`), statusPill(t.status), el('span', { style: { marginLeft: 'auto' } }, btn(t.status === 'closed' ? 'Reopen' : 'Close', () => patch({ status: t.status === 'closed' ? 'open' : 'closed' }, 'status updated'), 'ghost'))), el('div', { class: 'muted', style: { fontSize: '0.74rem' } }, (t.label || t.email || '') + ' · ' + fmtTime(t.created_at)), el('div', { style: { whiteSpace: 'pre-wrap' } }, t.body || t.text || t.description || '(no text)'), images.length ? el('div', { style: { display: 'flex', flexWrap: 'wrap', gap: '0.5rem' } }, images) : null, logs.length ? el('div', {}, logs) : null, field('Admin notes', notesInput), el('div', { style: { display: 'flex', alignItems: 'center', gap: '0.6rem' } }, btn('Save notes', () => patch({ notes: notesInput.value }, 'notes saved'), 'primary'), msg))); } mount(panel, el('div', { class: 'term-bar' }, el('span', { class: 'term-title' }, '◆ Tickets'), el('span', { style: { marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: '0.4rem' } }, el('span', { class: 'muted', style: { fontSize: '0.78rem' } }, 'status'), statusSel, btn('Refresh', () => loadList(), 'ghost'))), list, detail); loadList(); } // ============================================================================ // Groups // ============================================================================ async function renderGroups(panel) { mount(panel, el('p', { class: 'muted' }, 'Loading groups…')); let rows; try { rows = await api.get(`${A}/groups`); } catch (e) { return mount(panel, errBox(e)); } groupsCache = rows; // Create form const nameI = el('input', { class: 'lk-url', placeholder: 'name' }); const leaseI = el('input', { class: 'lk-url', type: 'number', placeholder: 'lease_days', value: '30' }); const tierSel = select(TIERS, 'lock'); const cMsg = el('span', { class: 'muted', style: { fontSize: '0.78rem' } }, ''); const createBtn = btn('Create group', async () => { if (!nameI.value.trim()) return notify(cMsg, 'name required', false); try { await api.post(`${A}/groups`, { name: nameI.value.trim(), lease_days: parseInt(leaseI.value, 10) || 0, tier: tierSel.value }); nameI.value = ''; renderGroups(panel); } catch (e) { notify(cMsg, e.message || 'create failed', false); } }, 'primary'); const createCard = el('div', { class: 'card', style: { display: 'grid', gap: '0.5rem', marginBottom: '0.9rem', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', alignItems: 'end' } }, field('Name', nameI), field('Lease days', leaseI), field('Tier', tierSel), el('div', { style: { display: 'flex', alignItems: 'center', gap: '0.5rem' } }, createBtn, cMsg)); const body = rows.map(g => { const msg = el('span', { class: 'muted', style: { fontSize: '0.72rem' } }, ''); const nameE = el('input', { class: 'lk-url', value: g.name || '' }); const leaseE = el('input', { class: 'lk-url', type: 'number', value: String(g.lease_days ?? 0) }); const tierE = select(TIERS, g.tier); const save = btn('Save', async () => { try { await api.patch(`${A}/groups/${g.id}`, { name: nameE.value.trim(), lease_days: parseInt(leaseE.value, 10) || 0, tier: tierE.value }); notify(msg, 'saved', true); } catch (e) { notify(msg, e.message || 'failed', false); } }, 'primary'); return el('tr', {}, td(nameE), td(leaseE), td(tierE), td(save, msg)); }); mount(panel, el('div', { class: 'term-bar' }, el('span', { class: 'term-title' }, '◆ Groups'), btn('Refresh', () => renderGroups(panel), 'ghost')), createCard, rows.length ? table(['Name', 'Lease days', 'Tier', ''], body) : el('p', { class: 'muted' }, 'No groups yet.')); } // ---- shared bits ------------------------------------------------------------ function fmtTime(t) { if (!t) return '—'; const d = new Date(t); return Number.isNaN(d.getTime()) ? String(t) : d.toLocaleString(); } function errBox(e) { if (e?.body?.error === 'ivctl_not_configured' || e?.status === 503) { return el('div', { class: 'card' }, el('strong', {}, 'ivctl not configured'), el('p', { class: 'muted' }, 'Set IVCTL_URL (and IVCTL_ADMIN_TOKEN) on the Void server to enable the Control admin app.')); } return el('div', { class: 'card' }, el('strong', { style: { color: 'var(--danger, #e06b6b)' } }, 'Failed to load'), el('p', { class: 'muted' }, e?.message || 'request failed')); } const TABS = [ ['applicants', 'Applicants', renderApplicants], ['instances', 'Instances', renderInstances], ['releases', 'Releases', renderReleases], ['tickets', 'Tickets', renderTickets], ['groups', 'Groups', renderGroups] ]; export async function render(main) { const panel = el('div', { style: { marginTop: '1rem' } }); let active = 'applicants'; const tabBar = el('div', { style: { display: 'flex', gap: '0.3rem', flexWrap: 'wrap', borderBottom: '1px solid var(--border)', paddingBottom: '0.4rem' } }); function paint() { clear(tabBar); for (const [key, label, fn] of TABS) { tabBar.appendChild(btn(label, () => { active = key; paint(); fn(panel); }, active === key ? 'primary' : 'ghost')); } } mount(main, el('h1', { class: 'view-h1' }, 'Control'), el('p', { class: 'view-sub' }, 'IV Control — admin for the licensed-distribution system (applicants, instances, releases, tickets, groups).'), tabBar, panel); paint(); const initial = TABS.find(t => t[0] === active); initial[2](panel); }