158 lines
6.5 KiB
JavaScript
158 lines
6.5 KiB
JavaScript
import { h, PageHeader, Badge, StatusDot, Empty, Card, esc, enc, att_esc, $val, apiFetch, toast, dismissToast, openModal, closeModal, formModal, definePage, hComp, connect, reactive, requestUpdate, Link, createRouter, ToastContainer, render, parseZones } from '/static/hoover/index.js';
|
|
|
|
function issueCertModal(state) {
|
|
openModal((inner, idx) => {
|
|
formModal(inner, 'Issue Certificate',
|
|
[
|
|
{ label: 'Domain', id: 'ic-domain', placeholder: 'example.com' },
|
|
{ label: 'Email (optional)', id: 'ic-email', placeholder: 'you@example.com' },
|
|
],
|
|
[
|
|
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal(idx) },
|
|
{
|
|
label: 'Issue', cls: 'btn-primary', action: 's', handler: async () => {
|
|
const domain = ($val('ic-domain') || '').trim();
|
|
if (!domain) { toast('Domain is required', 'error'); return; }
|
|
const body = {
|
|
domain,
|
|
email: ($val('ic-email') || '').trim() || undefined,
|
|
};
|
|
const resp = await apiFetch('/api/certs/issue/start', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
});
|
|
if (resp.ok) {
|
|
toast('Issuance started for ' + domain, 'success');
|
|
closeModal(idx);
|
|
const rid = resp.data?.request_id;
|
|
if (rid) pollCertIssue(rid, state);
|
|
} else {
|
|
toast(resp.error || 'Failed', 'error');
|
|
}
|
|
},
|
|
},
|
|
],
|
|
);
|
|
});
|
|
}
|
|
|
|
async function pollCertIssue(rid, state) {
|
|
let done = false;
|
|
const timer = setInterval(async () => {
|
|
if (done) return clearInterval(timer);
|
|
const r = await apiFetch('/api/certs/issue/' + enc(rid));
|
|
if (r.ok && r.data) {
|
|
if (r.data.status === 'completed') {
|
|
done = true;
|
|
clearInterval(timer);
|
|
toast('Certificate issued for ' + (r.data.domain || rid), 'success');
|
|
await load(state);
|
|
} else if (r.data.status === 'failed') {
|
|
done = true;
|
|
clearInterval(timer);
|
|
toast('Issuance failed: ' + (r.data.error || 'unknown'), 'error');
|
|
}
|
|
}
|
|
}, 2000);
|
|
}
|
|
|
|
async function load(state) {
|
|
try {
|
|
const r = await apiFetch('/api/certs/list');
|
|
if (r.ok) state.certs = r.data || [];
|
|
else state.error = r.error;
|
|
} catch (e) {
|
|
state.error = String(e);
|
|
}
|
|
state.loading = false;
|
|
}
|
|
|
|
export default definePage({
|
|
init() {
|
|
return { certs: [], loading: true, error: null };
|
|
},
|
|
subscribe: ['acme'],
|
|
load,
|
|
render(state) {
|
|
if (state.loading) {
|
|
return [
|
|
PageHeader({ title: 'Certificates' }),
|
|
h('div', { class: 'card', key: 'loading' },
|
|
h('div', { class: 'card-body loading' }, 'Loading...'),
|
|
),
|
|
];
|
|
}
|
|
|
|
if (state.error) {
|
|
return [
|
|
PageHeader({ title: 'Certificates' }),
|
|
h('div', { class: 'card', key: 'error' },
|
|
h('div', { class: 'card-body error-msg' }, state.error),
|
|
),
|
|
];
|
|
}
|
|
|
|
const rows = state.certs.map(c => {
|
|
const days = c.days_remaining;
|
|
let badge;
|
|
if (c.expired || (days !== undefined && days <= 0)) {
|
|
badge = Badge({ text: 'Expired', variant: 'danger' });
|
|
} else if (days !== undefined && days <= 30) {
|
|
badge = Badge({ text: days + 'd left', variant: 'warning' });
|
|
} else {
|
|
badge = Badge({ text: days !== undefined ? days + 'd left' : 'N/A', variant: 'success' });
|
|
}
|
|
|
|
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),
|
|
h('td', null,
|
|
h('button', { class: 'btn btn-sm btn-outline', style: 'margin-right:4px;',
|
|
'on:click': 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');
|
|
}}, 'Renew'),
|
|
h('button', { class: 'btn btn-sm btn-danger',
|
|
'on:click': async () => {
|
|
if (!confirm('Remove certificate for ' + c.domain + '?')) return;
|
|
const resp = await apiFetch('/api/certs/' + enc(c.domain), { method: 'DELETE' });
|
|
if (resp.ok) {
|
|
toast('Certificate removed', 'success');
|
|
await load(state);
|
|
} else {
|
|
toast(resp.error || 'Failed', 'error');
|
|
}
|
|
}}, 'Remove'),
|
|
),
|
|
);
|
|
});
|
|
|
|
return [
|
|
PageHeader({
|
|
title: 'Certificates',
|
|
subtitle: 'ACME certificate management',
|
|
actions: h('button', { class: 'btn btn-primary',
|
|
'on:click': () => issueCertModal(state) }, 'Issue Certificate'),
|
|
}),
|
|
rows.length
|
|
? h('div', { class: 'card' }, h('table', { class: 'table' },
|
|
h('thead', null,
|
|
h('tr', null,
|
|
h('th', null, 'Domain'),
|
|
h('th', null, 'Issuer'),
|
|
h('th', null, 'Expiry'),
|
|
h('th', null, 'Status'),
|
|
h('th', { style: 'width:120px;' }, 'Actions'),
|
|
),
|
|
),
|
|
h('tbody', null, ...rows),
|
|
))
|
|
: Empty({ text: 'No certificates found. Issue a certificate to get started.' }),
|
|
];
|
|
},
|
|
});
|