385 lines
15 KiB
JavaScript
385 lines
15 KiB
JavaScript
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`<div class="card">
|
|
<div class="card-header">
|
|
<span>ACME Account</span>
|
|
<button class="btn btn-sm btn-primary" style="margin-left:auto"
|
|
onClick=${() => registerAccountModal()}>Register Account</button>
|
|
</div>
|
|
<div class="card-body">
|
|
<div class="text-muted text-sm">Not registered</div>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
return html`<div class="card">
|
|
<div class="card-header">
|
|
<span>ACME Account</span>
|
|
<button class="btn btn-sm btn-outline" style="margin-left:auto"
|
|
onClick=${() => settingsModal(account)}>\u2699</button>
|
|
</div>
|
|
<div class="card-body">
|
|
<div>Registered as <strong>${esc(account.email)}</strong></div>
|
|
<div class="text-sm text-muted">CA: ${esc(account.ca)}</div>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
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`<div>
|
|
<h2 class="modal-title">Account Settings</h2>
|
|
<div class="modal-body">
|
|
<div class="form-group">
|
|
<label>Current Account</label>
|
|
<div class="text-sm">${esc(account.email)}${account.ca ? ' (' + esc(account.ca) + ')' : ''}</div>
|
|
</div>
|
|
<hr />
|
|
<div class="form-group">
|
|
<label>Update Email</label>
|
|
<input id="set-email" type="email" placeholder="new@example.com" />
|
|
</div>
|
|
<hr />
|
|
<div class="text-danger"><strong>Danger Zone</strong></div>
|
|
<button class="btn btn-danger" data-action="set-deactivate">Deactivate Account</button>
|
|
</div>
|
|
<div class="modal-actions">
|
|
<button class="btn btn-outline" data-action="set-cancel">Cancel</button>
|
|
<button class="btn btn-primary" data-action="set-save">Save Email</button>
|
|
</div>
|
|
</div>`);
|
|
|
|
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`<div>${icon} <strong>${esc(c.name)}</strong>: <span class=${cls}>${esc(c.message)}</span></div>`;
|
|
});
|
|
|
|
return html`<div>
|
|
<h2 class="modal-title">Validate: ${esc(s.domain)}</h2>
|
|
<div class="modal-body">
|
|
<div class="form-group">
|
|
<label>Domain</label>
|
|
<input id="ic-domain" value=${esc(s.domain)} />
|
|
</div>
|
|
<div id="ic-vresults">${resultsVNodes}</div>
|
|
</div>
|
|
<div class="modal-actions">
|
|
<button class="btn btn-outline" data-action="ic-cancel">Cancel</button>
|
|
<button class="btn btn-outline" data-action="ic-validate" style="margin-right:8px;">Re-validate</button>
|
|
<button class="btn btn-primary" data-action="ic-issue"${s.ready ? '' : ' disabled'}>Issue</button>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
return html`<div>
|
|
<h2 class="modal-title">Issue Certificate</h2>
|
|
<div class="modal-body">
|
|
<div id="ic-account-info" class="text-sm mb-2">
|
|
<strong>Using account:</strong> ${esc(accountBadge)}
|
|
</div>
|
|
${registered
|
|
? html`<div class="form-group"><label>Domain</label><input id="ic-domain" placeholder="example.com" /></div>`
|
|
: html`<div class="text-warning">Register an ACME account first</div>`}
|
|
<div id="ic-vresults"></div>
|
|
</div>
|
|
<div class="modal-actions">
|
|
<button class="btn btn-outline" data-action="ic-cancel">Cancel</button>
|
|
${registered
|
|
? html`<button class="btn btn-primary" data-action="ic-validate">Validate</button>`
|
|
: html`<button class="btn btn-primary" disabled>Validate</button>
|
|
<button class="btn btn-outline" data-action="ic-register" style="margin-left:8px;">Register Account</button>`}
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
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`<tr key=${c.domain}>
|
|
<td><strong>${esc(c.domain || 'unknown')}</strong></td>
|
|
<td class="text-sm">${esc(c.issuer || '-')}</td>
|
|
<td>${esc(c.expiry || 'N/A')}</td>
|
|
<td>${badge}</td>
|
|
<${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} />
|
|
</tr>`;
|
|
});
|
|
|
|
return [
|
|
PageHeader({
|
|
title: 'Certificates',
|
|
subtitle: 'ACME certificate management',
|
|
actions: html`<button class="btn btn-primary"
|
|
onClick=${() => issueCertModal(state)}>Issue Certificate</button>`,
|
|
}),
|
|
_accountCard(account),
|
|
certError
|
|
? html`<div class="card">
|
|
<div class="card-body">
|
|
<div class="text-warning text-sm">
|
|
Certificate data unavailable: ${esc(certError)}
|
|
</div>
|
|
</div>
|
|
</div>`
|
|
: null,
|
|
rows.length
|
|
? Table({
|
|
columns: ['Domain', 'Issuer', 'Expiry', 'Status', 'Actions'],
|
|
rows,
|
|
})
|
|
: Empty({ text: 'No certificates found. Issue a certificate to get started.' }),
|
|
];
|
|
},
|
|
}); |