import { html, PageHeader, Empty, Table, renderGuard, esc, enc, $val, apiFetch, toast, openModal, closeModal, formModal, modalVNodes, refreshModals, definePage, getModel, ActionCell, certStatusBadge, poll, formAction, requestUpdate } from '/static/hoover/index.js'; import { isModalProcessing, setModalProcessing } from '/static/hoover/components/modal.js'; // Domains with an in-flight renewal (button disabled while pending). const _renewInFlight = new Set(); function renewCert(domain) { if (_renewInFlight.has(domain)) return; _renewInFlight.add(domain); requestUpdate(); const done = () => { if (_renewInFlight.delete(domain)) requestUpdate(); }; apiFetch('/api/certs/' + enc(domain) + '/renew', { method: 'POST' }).then(resp => { if (!resp.ok) { done(); toast(resp.error || 'Renewal failed', 'error'); return; } const rid = resp.data?.request_id; if (!rid) { done(); toast('Renewal not started', 'error'); return; } if (resp.data?.status === 'existing') { toast('Renewal already in progress for ' + domain, 'warning'); } else { toast('Renewal started for ' + domain, 'success'); } poll({ url: '/api/certs/renew/' + enc(rid), successKey: (d) => d.status === 'completed', onErrorKey: (d) => d.status === 'failed' || d.status === 'skipped', timeout: 180000, onComplete: () => { done(); toast('Certificate renewed for ' + domain, 'success'); }, onError: (d) => { done(); if (d && d.status === 'skipped') { toast('Certificate still valid — renewal skipped for ' + domain, 'info'); return; } let msg = (d && d.error) || 'unknown'; if (d == null) msg = 'timed out waiting for renewal'; else if (d.steps) { const failed = d.steps.find(s => s.status === 'error'); if (failed && failed.message) msg = failed.message; } toast('Renewal failed for ' + domain + ': ' + msg, 'error'); }, }); }).catch((err) => { done(); toast(err?.message || 'Renewal failed', 'error'); }); } function _accountCard(account) { if (!account || !account.registered) { return html`
ACME Account
Not registered
`; } return html`
ACME Account
Registered as ${esc(account.email)}
CA: ${esc(account.ca)}
`; } function registerAccountModal() { openModal((inner) => { formModal(inner, 'Register ACME Account', [ { label: 'Email', id: 'reg-email', type: 'email', placeholder: 'you@example.com' }, { label: 'CA Provider', id: 'reg-server', tag: 'select', options: [['letsencrypt', "Let's Encrypt"], ['zerossl', 'ZeroSSL']], }, ], [ { label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal() }, { label: 'Register', cls: 'btn-primary', action: 'r', handler: formAction(async () => { const email = ($val('reg-email') || '').trim(); if (!email) throw 'Email is required'; const server = document.getElementById('reg-server')?.value || 'letsencrypt'; const resp = await apiFetch('/api/certs/account/register', { method: 'POST', body: { email, server }, }); if (!resp.ok) throw resp.error || 'Registration failed'; toast('ACME account registered', 'success'); closeModal(); // No modelFetch — WS delta updates the acme model. }), }, ], ); }); } function settingsModal(account) { openModal((inner, idx) => { modalVNodes(inner, html`
`); inner.querySelector('[data-action="set-cancel"]')?.addEventListener('click', () => { closeModal(idx); }); inner.querySelector('[data-action="set-save"]')?.addEventListener('click', formAction(async () => { const email = ($val('set-email') || '').trim(); if (!email) throw 'Email is required'; const resp = await apiFetch('/api/certs/email', { method: 'POST', body: { email } }); if (!resp.ok) throw resp.error || 'Failed'; toast('Email updated', 'success'); closeModal(idx); // No modelFetch — WS delta updates the acme model. }), ); inner.querySelector('[data-action="set-deactivate"]')?.addEventListener('click', formAction(async () => { if (!confirm('Deactivate ACME account? You will need to register again to issue certificates.')) throw new Error('Cancelled'); const resp = await apiFetch('/api/certs/account', { method: 'DELETE' }); if (!resp.ok) throw resp.error || 'Failed'; toast('Account deactivated', 'success'); closeModal(idx); // No modelFetch — WS delta updates the acme model. }), ); }); } function createIssueState() { return { step: 'init', domain: '', account: null, validating: false, checks: [], ready: false, }; } let _currentIssueState = null; function issueCertModal(state) { _currentIssueState = createIssueState(); (async () => { const accountResp = await apiFetch('/api/certs/account'); _currentIssueState.account = accountResp.ok ? accountResp.data : null; openModal((inner, modalIdx) => { modalVNodes(inner, _renderIssueContent()); _bindIssueButtons(inner, modalIdx); }); })(); } function _renderIssueContent() { const s = _currentIssueState; const account = s.account; const registered = account && account.registered; const accountBadge = registered ? esc(account.email) + ' (' + esc(account.ca) + ')' : 'No account registered'; if (s.step === 'results') { const resultsVNodes = s.checks.map(c => { let cls = 'text-success', icon = '\u2713'; if (!c.passed && c.blocking) { cls = 'text-danger'; icon = '\u2717'; } else if (!c.passed) { cls = 'text-warning'; icon = '\u26A0'; } return html`
${icon} ${esc(c.name)}: ${esc(c.message)}
`; }); return html`
`; } return html`
`; } function _bindIssueButtons(inner, modalIdx) { inner.querySelector('[data-action="ic-cancel"]')?.addEventListener('click', () => { closeModal(modalIdx); }); inner.querySelector('[data-action="ic-register"]')?.addEventListener('click', () => { closeModal(modalIdx); registerAccountModal(); }); inner.querySelector('[data-action="ic-validate"]')?.addEventListener('click', async () => { if (_currentIssueState.validating) return; if (isModalProcessing()) return; const s = _currentIssueState; const domain = ($val('ic-domain') || '').trim(); if (!domain) { toast('Domain is required', 'error'); return; } setModalProcessing(true); s.validating = true; s.domain = domain; try { const resp = await apiFetch('/api/certs/validate', { method: 'POST', body: { domain } }); if (!resp.ok) { toast(resp.error || 'Validation failed', 'error'); return; } s.step = 'results'; s.checks = resp.data.checks; s.ready = resp.data.ready; refreshModals(); } finally { s.validating = false; setModalProcessing(false); refreshModals(); } }); inner.querySelector('[data-action="ic-issue"]')?.addEventListener('click', async () => { if (isModalProcessing()) return; setModalProcessing(true); try { const body = { domain: _currentIssueState.domain }; const issueResp = await apiFetch('/api/certs/issue/start', { method: 'POST', body }); if (issueResp.ok) { const status = issueResp.data?.status; if (status === 'existing') { toast('Issuance already in progress for ' + _currentIssueState.domain, 'warning'); } else { toast('Issuance started for ' + _currentIssueState.domain, 'success'); } closeModal(modalIdx); const rid = issueResp.data?.request_id; if (rid) pollCertIssue(rid); } else { toast(issueResp.error || 'Failed', 'error'); } } finally { setModalProcessing(false); refreshModals(); } }); } async function pollCertIssue(rid) { poll({ url: '/api/certs/issue/' + enc(rid), successKey: (d) => d.status === 'completed', onErrorKey: (d) => d.status === 'failed', onComplete: (d) => { toast('Certificate issued for ' + (d.domain || rid), 'success'); // No modelFetch — WS delta updates the acme model. }, onError: (d) => { toast('Issuance failed: ' + (d?.error || 'unknown'), 'error'); }, }); } export default definePage({ title: 'Certificates - Vacuum Wall', init() { return { acme: getModel('acme'), }; }, render(state) { const guard = renderGuard(state.acme, 'Certificates', 'ACME certificate management', state.acme.data); if (guard) return guard; const account = state.acme.data?.account || { registered: false, email: '', ca: '' }; const certError = state.acme.data?.status?.error; const rows = (state.acme.data?.certs || []).map(c => { const badge = certStatusBadge({ expired: c.expired, daysRemaining: c.days_remaining }); return html` ${esc(c.domain || 'unknown')} ${esc(c.issuer || '-')} ${esc(c.expiry || 'N/A')} ${badge} <${ActionCell} editLabel="Renew" editClick=${() => renewCert(c.domain)} busy=${_renewInFlight.has(c.domain)} removeUrl=${'/api/certs/' + enc(c.domain)} removeMessage=${'Remove certificate for ' + c.domain + '?'} removeSuccess="Certificate removed" deleteKey=${c.domain} /> `; }); return [ PageHeader({ title: 'Certificates', subtitle: 'ACME certificate management', actions: html``, }), _accountCard(account), certError ? html`
Certificate data unavailable: ${esc(certError)}
` : null, rows.length ? Table({ columns: ['Domain', 'Issuer', 'Expiry', 'Status', 'Actions'], rows, }) : Empty({ text: 'No certificates found. Issue a certificate to get started.' }), ]; }, });