diff --git a/public/views/control.js b/public/views/control.js index 73f4134..081bc80 100644 --- a/public/views/control.js +++ b/public/views/control.js @@ -9,7 +9,29 @@ import { api } from '../api.js'; const TIERS = ['lock', 'uninstall-keep', 'wipe']; const A = '/api/control/admin'; // Public, Google-gated self-service registration page testers are sent to. -const REGISTER_URL = 'https://ivctl.hynesy.com/register'; +const IVCTL_BASE = 'https://ivctl.hynesy.com'; +const REGISTER_URL = `${IVCTL_BASE}/register`; + +// A label + monospace value + Copy button row (used by the Deploy panel). +function copyRow(label, value) { + return el('div', { style: { display: 'flex', gap: '0.4rem', alignItems: 'center', margin: '0.12rem 0' } }, + el('span', { class: 'muted', style: { fontSize: '0.66rem', minWidth: '74px' } }, label), + el('code', { style: { userSelect: 'all', wordBreak: 'break-all', fontSize: '0.7rem', flex: '1' } }, value), + btn('Copy', () => navigator.clipboard?.writeText(value), 'ghost')); +} + +// Zero-touch deploy options for a freshly approved instance. The tester runs ONE +// of these; the artifact is already keyed, so there's no claim code to enter. +function deployPanel(code) { + return el('div', { class: 'card', style: { display: 'grid', gap: '0.15rem', marginTop: '0.3rem', padding: '0.5rem 0.6rem' } }, + el('div', { class: 'term-title', style: { fontSize: '0.7rem' } }, '◆ Deploy — send the tester ONE of these'), + copyRow('One-liner', `curl -fsSL ${IVCTL_BASE}/provision/install/${code} | sudo bash`), + copyRow('Download', `${IVCTL_BASE}/provision/get/${code}`), + copyRow('Docker', `${IVCTL_BASE}/provision/docker/${code}`), + copyRow('Landing', `${IVCTL_BASE}/provision/${code}`), + el('div', { class: 'muted', style: { fontSize: '0.64rem', marginTop: '0.2rem' } }, + 'Manual fallback code: ', el('code', { style: { userSelect: 'all' } }, code))); +} // ---- small UI helpers ------------------------------------------------------- @@ -104,17 +126,16 @@ async function renderApplicants(panel) { el('div', { style: { display: 'flex', alignItems: 'center', gap: '0.5rem' } }, addBtn, addMsg)); const body = rows.map(a => { - const msg = el('span', { class: 'muted', style: { fontSize: '0.75rem' } }, ''); + // A div (not span) so the post-approve Deploy panel can hold block content. + const msg = el('div', { 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')); + msg.appendChild(el('span', { style: { color: 'var(--accent,#5ec27a)', fontSize: '0.72rem' } }, '✓ Approved')); + msg.appendChild(deployPanel(code)); } catch (e) { notify(msg, e.message || 'approve failed', false); } }, 'primary'); const deny = btn('Deny', async () => {