ca27ea5522
component.js now creates an AbortController for each page mount, passing it to load(). On unmount, the controller is aborted to cancel in-flight requests that would otherwise mutate unmounted state. Page load functions consistently pass the signal to apiFetch and guard state mutations with abort checks. This eliminates the need for per-page abortController boilerplate and prevents stale errors from appearing on rapid navigation. Users page now guards catch block and loading state cleanup against aborted requests, matching passkeys.js pattern.
342 lines
14 KiB
JavaScript
342 lines
14 KiB
JavaScript
import { html, PageHeader, Empty, Table, renderGuard, esc, enc, $val, apiFetch, toast, openModal, closeModal, formModal, modalVNodes, refreshModals, definePage, getModel, modelFetch, ActionCell, certStatusBadge, poll, formAction } from '/static/hoover/index.js?v=10';
|
|
import { isModalProcessing, setModalProcessing } from '/static/hoover/components/modal.js?v=9';
|
|
|
|
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();
|
|
modelFetch('acme');
|
|
}),
|
|
},
|
|
],
|
|
);
|
|
});
|
|
}
|
|
|
|
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', async () => {
|
|
if (isModalProcessing()) return;
|
|
setModalProcessing(true);
|
|
try {
|
|
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(idx);
|
|
modelFetch('acme');
|
|
} else {
|
|
toast(resp.error || 'Failed', 'error');
|
|
}
|
|
} finally {
|
|
setModalProcessing(false);
|
|
refreshModals();
|
|
}
|
|
});
|
|
|
|
inner.querySelector('[data-action="set-deactivate"]')?.addEventListener('click', async () => {
|
|
if (!confirm('Deactivate ACME account? You will need to register again to issue certificates.')) return;
|
|
if (isModalProcessing()) return;
|
|
setModalProcessing(true);
|
|
try {
|
|
const resp = await apiFetch('/api/certs/account', { method: 'DELETE' });
|
|
if (resp.ok) {
|
|
toast('Account deactivated', 'success');
|
|
closeModal(idx);
|
|
modelFetch('acme');
|
|
} else {
|
|
toast(resp.error || 'Failed', 'error');
|
|
}
|
|
} finally {
|
|
setModalProcessing(false);
|
|
refreshModals();
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
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');
|
|
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 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=${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"
|
|
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),
|
|
rows.length
|
|
? Table({
|
|
columns: ['Domain', 'Issuer', 'Expiry', 'Status', 'Actions'],
|
|
rows,
|
|
})
|
|
: Empty({ text: 'No certificates found. Issue a certificate to get started.' }),
|
|
];
|
|
},
|
|
}); |