feat(acme): add DB-backed ACME configuration management and OpsServer certificate settings UI

This commit is contained in:
2026-04-08 13:12:20 +00:00
parent 4fbe01823b
commit c224028495
18 changed files with 793 additions and 33 deletions

View File

@@ -23,17 +23,25 @@ export class OpsViewCertificates extends DeesElement {
@state()
accessor certState: appstate.ICertificateState = appstate.certificateStatePart.getState()!;
@state()
accessor acmeState: appstate.IAcmeConfigState = appstate.acmeConfigStatePart.getState()!;
constructor() {
super();
const sub = appstate.certificateStatePart.select().subscribe((newState) => {
const certSub = appstate.certificateStatePart.select().subscribe((newState) => {
this.certState = newState;
});
this.rxSubscriptions.push(sub);
this.rxSubscriptions.push(certSub);
const acmeSub = appstate.acmeConfigStatePart.select().subscribe((newState) => {
this.acmeState = newState;
});
this.rxSubscriptions.push(acmeSub);
}
async connectedCallback() {
await super.connectedCallback();
await appstate.certificateStatePart.dispatchAction(appstate.fetchCertificateOverviewAction, null);
await appstate.acmeConfigStatePart.dispatchAction(appstate.fetchAcmeConfigAction, null);
}
public static styles = [
@@ -46,6 +54,62 @@ export class OpsViewCertificates extends DeesElement {
gap: 24px;
}
.acmeCard {
padding: 16px 20px;
background: ${cssManager.bdTheme('#f9fafb', '#111827')};
border: 1px solid ${cssManager.bdTheme('#e5e7eb', '#374151')};
border-radius: 8px;
}
.acmeCard.acmeCardEmpty {
background: ${cssManager.bdTheme('#fffbeb', '#1c1917')};
border-color: ${cssManager.bdTheme('#fde68a', '#78350f')};
}
.acmeCardHeader {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.acmeCardTitle {
font-size: 14px;
font-weight: 600;
color: ${cssManager.bdTheme('#111827', '#f3f4f6')};
}
.acmeGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px 24px;
}
.acmeField {
display: flex;
flex-direction: column;
gap: 2px;
}
.acmeLabel {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.03em;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
.acmeValue {
font-size: 13px;
color: ${cssManager.bdTheme('#111827', '#f3f4f6')};
}
.acmeEmptyHint {
margin: 0;
font-size: 13px;
line-height: 1.5;
color: ${cssManager.bdTheme('#78350f', '#fde68a')};
}
.statusBadge {
display: inline-flex;
align-items: center;
@@ -162,12 +226,151 @@ export class OpsViewCertificates extends DeesElement {
<dees-heading level="3">Certificates</dees-heading>
<div class="certificatesContainer">
${this.renderAcmeSettingsCard()}
${this.renderStatsTiles(summary)}
${this.renderCertificateTable()}
</div>
`;
}
private renderAcmeSettingsCard(): TemplateResult {
const config = this.acmeState.config;
if (!config) {
return html`
<div class="acmeCard acmeCardEmpty">
<div class="acmeCardHeader">
<span class="acmeCardTitle">ACME Settings</span>
<dees-button
eventName="edit-acme"
@click=${() => this.showEditAcmeDialog()}
.type=${'highlighted'}
>Configure</dees-button>
</div>
<p class="acmeEmptyHint">
No ACME configuration yet. Click <strong>Configure</strong> to set up automated TLS
certificate issuance via Let's Encrypt. You'll also need at least one DNS provider
under <strong>Domains &gt; Providers</strong>.
</p>
</div>
`;
}
return html`
<div class="acmeCard">
<div class="acmeCardHeader">
<span class="acmeCardTitle">ACME Settings</span>
<dees-button eventName="edit-acme" @click=${() => this.showEditAcmeDialog()}>Edit</dees-button>
</div>
<div class="acmeGrid">
<div class="acmeField">
<span class="acmeLabel">Account email</span>
<span class="acmeValue">${config.accountEmail || '(not set)'}</span>
</div>
<div class="acmeField">
<span class="acmeLabel">Status</span>
<span class="acmeValue">
<span class="statusBadge ${config.enabled ? 'valid' : 'unknown'}">
${config.enabled ? 'enabled' : 'disabled'}
</span>
</span>
</div>
<div class="acmeField">
<span class="acmeLabel">Mode</span>
<span class="acmeValue">
<span class="statusBadge ${config.useProduction ? 'valid' : 'provisioning'}">
${config.useProduction ? 'production' : 'staging'}
</span>
</span>
</div>
<div class="acmeField">
<span class="acmeLabel">Auto-renew</span>
<span class="acmeValue">${config.autoRenew ? 'on' : 'off'}</span>
</div>
<div class="acmeField">
<span class="acmeLabel">Renewal threshold</span>
<span class="acmeValue">${config.renewThresholdDays} days</span>
</div>
</div>
</div>
`;
}
private async showEditAcmeDialog() {
const { DeesModal, DeesToast } = await import('@design.estate/dees-catalog');
const current = this.acmeState.config;
DeesModal.createAndShow({
heading: current ? 'Edit ACME Settings' : 'Configure ACME',
content: html`
<dees-form>
<dees-input-text
.key=${'accountEmail'}
.label=${'Account email'}
.value=${current?.accountEmail ?? ''}
.required=${true}
></dees-input-text>
<dees-input-checkbox
.key=${'enabled'}
.label=${'Enabled'}
.value=${current?.enabled ?? true}
></dees-input-checkbox>
<dees-input-checkbox
.key=${'useProduction'}
.label=${"Use Let's Encrypt production (uncheck for staging)"}
.value=${current?.useProduction ?? true}
></dees-input-checkbox>
<dees-input-checkbox
.key=${'autoRenew'}
.label=${'Auto-renew certificates'}
.value=${current?.autoRenew ?? true}
></dees-input-checkbox>
<dees-input-text
.key=${'renewThresholdDays'}
.label=${'Renewal threshold (days)'}
.value=${String(current?.renewThresholdDays ?? 30)}
></dees-input-text>
</dees-form>
<p style="margin-top: 12px; font-size: 12px; opacity: 0.7;">
Most fields take effect on the next dcrouter restart (SmartAcme is instantiated once at
startup). Changing the account email creates a new Let's Encrypt account — only do this
if you know what you're doing.
</p>
`,
menuOptions: [
{ name: 'Cancel', action: async (modalArg: any) => modalArg.destroy() },
{
name: 'Save',
action: async (modalArg: any) => {
const form = modalArg.shadowRoot
?.querySelector('.content')
?.querySelector('dees-form');
if (!form) return;
const data = await form.collectFormData();
const email = String(data.accountEmail ?? '').trim();
if (!email) {
DeesToast.show({
message: 'Account email is required',
type: 'warning',
duration: 2500,
});
return;
}
const threshold = parseInt(String(data.renewThresholdDays ?? '30'), 10);
await appstate.acmeConfigStatePart.dispatchAction(appstate.updateAcmeConfigAction, {
accountEmail: email,
enabled: Boolean(data.enabled),
useProduction: Boolean(data.useProduction),
autoRenew: Boolean(data.autoRenew),
renewThresholdDays: Number.isFinite(threshold) ? threshold : 30,
});
modalArg.destroy();
},
},
],
});
}
private renderStatsTiles(summary: appstate.ICertificateState['summary']): TemplateResult {
const tiles: IStatsTile[] = [
{