import { h, PageHeader, Empty, Table, Card, Badge, renderGuard, esc, enc, $val, apiFetch, toast, openModal, closeModal, formModal, definePage, getModel, modelFetch, ActionCell, certStatusBadge, poll } from '/static/hoover/index.js?v=7'; const _issueState = { domain: '', modalIdx: -1, account: null, validating: false }; function _accountCard(account) { if (!account || !account.registered) { return h('div', { class: 'card' }, h('div', { class: 'card-header' }, [ h('span', null, 'ACME Account'), h('button', { class: 'btn btn-sm btn-primary', style: 'margin-left:auto;', 'on:click': () => registerAccountModal(), }, 'Register Account'), ] ), h('div', { class: 'card-body' }, h('div', { class: 'text-muted text-sm' }, 'Not registered'), ), ); } return h('div', { class: 'card' }, h('div', { class: 'card-header' }, [ h('span', null, 'ACME Account'), h('button', { class: 'btn btn-sm btn-outline', style: 'margin-left:auto;', 'on:click': () => settingsModal(account), }, '\u2699'), ] ), h('div', { class: 'card-body' }, h('div', null, ['Registered as ', h('strong', null, esc(account.email))]), h('div', { class: 'text-sm text-muted' }, ['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: async () => { const email = ($val('reg-email') || '').trim(); if (!email) { toast('Email is required', 'error'); return; } const server = document.getElementById('reg-server')?.value || 'letsencrypt'; const resp = await apiFetch('/api/certs/account/register', { method: 'POST', body: { email, server }, }); if (resp.ok) { toast('ACME account registered', 'success'); closeModal(); modelFetch('acme'); } else { toast(resp.error || 'Registration failed', 'error'); } }, }, ], ); }); } function settingsModal(account) { openModal((inner) => { inner.innerHTML = '' + ''; inner.querySelector('[data-action="set-cancel"]')?.addEventListener('click', () => { closeModal(); }); inner.querySelector('[data-action="set-save"]')?.addEventListener('click', async () => { const email = ($val('set-email') || '').trim(); if (!email) { toast('Email is required', 'error'); return; } const resp = await apiFetch('/api/certs/email', { method: 'POST', body: { email }, }); if (resp.ok) { toast('Email updated', 'success'); closeModal(); modelFetch('acme'); } else { toast(resp.error || 'Failed', 'error'); } }); inner.querySelector('[data-action="set-deactivate"]')?.addEventListener('click', async () => { if (!confirm('Deactivate ACME account? You will need to register again to issue certificates.')) return; const resp = await apiFetch('/api/certs/account', { method: 'DELETE' }); if (resp.ok) { toast('Account deactivated', 'success'); closeModal(); modelFetch('acme'); } else { toast(resp.error || 'Failed', 'error'); } }); }); } function issueCertModal(state) { _issueState.domain = ''; _issueState.modalIdx = -1; _issueState.account = null; _issueState.validating = false; (async () => { const accountResp = await apiFetch('/api/certs/account'); _issueState.account = accountResp.ok ? accountResp.data : null; _renderIssueModal(state); })(); } function _renderIssueModal(state) { const account = _issueState.account; const registered = account && account.registered; const accountBadge = registered ? esc(account.email) + ' (' + esc(account.ca) + ')' : 'No account registered'; openModal((inner) => { inner.innerHTML = '' + ''; _issueState.modalIdx = document.querySelectorAll('#modal-root > div').length - 1; inner.querySelector('[data-action="ic-cancel"]')?.addEventListener('click', () => { closeModal(_issueState.modalIdx); }); inner.querySelector('[data-action="ic-register"]')?.addEventListener('click', () => { closeModal(_issueState.modalIdx); registerAccountModal(); }); if (!registered) return; inner.querySelector('[data-action="ic-validate"]')?.addEventListener('click', async () => { if (_issueState.validating) return; const domain = ($val('ic-domain') || '').trim(); if (!domain) { toast('Domain is required', 'error'); return; } _issueState.validating = true; _issueState.domain = domain; try { const resp = await apiFetch('/api/certs/validate', { method: 'POST', body: { domain }, }); if (!resp.ok) { toast(resp.error || 'Validation failed', 'error'); return; } _showValidate(inner, domain, resp.data.checks, resp.data.ready, state); } finally { _issueState.validating = false; } }); }); } function _showValidate(inner, domain, checks, ready, state) { const resultsHtml = checks.map(c => { let cls = 'text-success'; let icon = '\u2713'; if (!c.passed && c.blocking) { cls = 'text-danger'; icon = '\u2717'; } else if (!c.passed && !c.blocking) { cls = 'text-warning'; icon = '\u26A0'; } return '
' + icon + ' ' + esc(c.name) + '' + ': ' + '' + esc(c.message) + '
'; }).join(''); inner.innerHTML = '' + ''; inner.querySelector('[data-action="ic-cancel"]')?.addEventListener('click', () => { closeModal(_issueState.modalIdx); }); inner.querySelector('[data-action="ic-validate"]')?.addEventListener('click', async () => { if (_issueState.validating) return; const domain2 = ($val('ic-domain') || '').trim(); if (!domain2) { toast('Domain is required', 'error'); return; } _issueState.validating = true; _issueState.domain = domain2; try { const resp2 = await apiFetch('/api/certs/validate', { method: 'POST', body: { domain: domain2 }, }); if (!resp2.ok) { toast(resp2.error || 'Validation failed', 'error'); return; } _showValidate(inner, domain2, resp2.data.checks, resp2.data.ready, state); } finally { _issueState.validating = false; } }); inner.querySelector('[data-action="ic-issue"]')?.addEventListener('click', async () => { const body = { domain: _issueState.domain }; const issueResp = await apiFetch('/api/certs/issue/start', { method: 'POST', body, }); if (issueResp.ok) { toast('Issuance started for ' + _issueState.domain, 'success'); closeModal(_issueState.modalIdx); const rid = issueResp.data?.request_id; if (rid) pollCertIssue(rid, state); } else { toast(issueResp.error || 'Failed', 'error'); } }); } async function pollCertIssue(rid, state) { 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'); modelFetch('acme'); }, onError: (d) => { toast('Issuance failed: ' + (d?.error || 'unknown'), 'error'); }, }); } export default definePage({ 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 rows = (state.acme.data?.certs || []).map(c => { const badge = certStatusBadge({ expired: c.expired, daysRemaining: c.days_remaining }); return h('tr', { key: c.domain }, h('td', null, h('strong', null, esc(c.domain || 'unknown'))), h('td', { class: 'text-sm' }, esc(c.issuer || '-')), h('td', null, esc(c.expiry || 'N/A')), h('td', null, badge), ActionCell({ editLabel: 'Renew', editClick: async () => { const resp = await apiFetch('/api/certs/' + enc(c.domain) + '/renew', { method: 'POST' }); if (resp.ok) toast('Renewal started for ' + c.domain, 'success'); else toast(resp.error || 'Failed', 'error'); }, removeUrl: '/api/certs/' + enc(c.domain), removeMessage: 'Remove certificate for ' + c.domain + '?', removeSuccess: 'Certificate removed', removeRefresh: 'acme', }), ); }); return [ PageHeader({ title: 'Certificates', subtitle: 'ACME certificate management', actions: h('button', { class: 'btn btn-primary', 'on:click': () => issueCertModal(state) }, 'Issue Certificate'), }), _accountCard(account), rows.length ? Table({ columns: ['Domain', 'Issuer', 'Expiry', 'Status', 'Actions'], rows, }) : Empty({ text: 'No certificates found. Issue a certificate to get started.' }), ]; }, });