feat(dns-providers): add provider-agnostic DNS provider form metadata and reusable UI for create/edit flows

This commit is contained in:
2026-04-08 11:11:53 +00:00
parent 140637a307
commit ea2e618990
10 changed files with 356 additions and 51 deletions

View File

@@ -1,5 +1,12 @@
# Changelog # Changelog
## 2026-04-08 - 13.7.0 - feat(dns-providers)
add provider-agnostic DNS provider form metadata and reusable UI for create/edit flows
- Introduce shared DNS provider type descriptors and credential field metadata to drive provider forms dynamically.
- Add a reusable dns-provider-form component and update provider create/edit dialogs to use typed provider selection and credential handling.
- Remove Cloudflare-specific ACME helper exposure and clarify provider-agnostic DNS manager and factory documentation for future provider implementations.
## 2026-04-08 - 13.6.0 - feat(dns) ## 2026-04-08 - 13.6.0 - feat(dns)
add db-backed DNS provider, domain, and record management with ops UI support add db-backed DNS provider, domain, and record management with ops UI support

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@serve.zone/dcrouter', name: '@serve.zone/dcrouter',
version: '13.6.0', version: '13.7.0',
description: 'A multifaceted routing service handling mail and SMS delivery functions.' description: 'A multifaceted routing service handling mail and SMS delivery functions.'
} }

View File

@@ -302,7 +302,9 @@ export class DnsManager {
/** /**
* Build an IConvenientDnsProvider that dispatches each ACME challenge to * Build an IConvenientDnsProvider that dispatches each ACME challenge to
* the right CloudflareDnsProvider based on the challenge's hostName. * the right provider client (whichever provider type owns the parent zone),
* based on the challenge's hostName. Provider-agnostic — uses the IDnsProviderClient
* interface, so any registered provider implementation works.
* Returned object plugs directly into smartacme's Dns01Handler. * Returned object plugs directly into smartacme's Dns01Handler.
*/ */
public buildAcmeConvenientDnsProvider(): plugins.tsclass.network.IConvenientDnsProvider { public buildAcmeConvenientDnsProvider(): plugins.tsclass.network.IConvenientDnsProvider {

View File

@@ -27,14 +27,6 @@ export class CloudflareDnsProvider implements IDnsProviderClient {
this.cfAccount = new plugins.cloudflare.CloudflareAccount(apiToken); this.cfAccount = new plugins.cloudflare.CloudflareAccount(apiToken);
} }
/**
* Returns the underlying CloudflareAccount — used by ACME DNS-01
* to wrap into a smartacme Dns01Handler.
*/
public getCloudflareAccount(): plugins.cloudflare.CloudflareAccount {
return this.cfAccount;
}
public async testConnection(): Promise<IConnectionTestResult> { public async testConnection(): Promise<IConnectionTestResult> {
try { try {
// Listing zones is the lightest-weight call that proves the token works. // Listing zones is the lightest-weight call that proves the token works.

View File

@@ -9,6 +9,21 @@ import { CloudflareDnsProvider } from './cloudflare.provider.js';
* Instantiate a runtime DNS provider client from a stored DnsProviderDoc. * Instantiate a runtime DNS provider client from a stored DnsProviderDoc.
* *
* @throws if the provider type is not supported. * @throws if the provider type is not supported.
*
* ## Adding a new provider (e.g. Route53)
*
* 1. **Type union** — extend `TDnsProviderType` in
* `ts_interfaces/data/dns-provider.ts` (e.g. `'cloudflare' | 'route53'`).
* 2. **Credentials interface** — add `IRoute53Credentials` and append it to
* the `TDnsProviderCredentials` discriminated union.
* 3. **Descriptor** — append a new entry to `dnsProviderTypeDescriptors` so
* the OpsServer UI picks up the new type and renders the right credential
* form fields automatically.
* 4. **Provider class** — create `ts/dns/providers/route53.provider.ts`
* implementing `IDnsProviderClient`.
* 5. **Factory case** — add a new `case 'route53':` below. The
* `_exhaustive: never` line will fail to compile until you do.
* 6. **Index** — re-export the new class from `ts/dns/providers/index.ts`.
*/ */
export function createDnsProvider( export function createDnsProvider(
type: TDnsProviderType, type: TDnsProviderType,
@@ -24,6 +39,8 @@ export function createDnsProvider(
return new CloudflareDnsProvider(credentials.apiToken); return new CloudflareDnsProvider(credentials.apiToken);
} }
default: { default: {
// If you see a TypeScript error here after extending TDnsProviderType,
// add a `case` for the new type above. The `never` enforces exhaustiveness.
const _exhaustive: never = type; const _exhaustive: never = type;
throw new Error(`createDnsProvider: unsupported provider type: ${_exhaustive}`); throw new Error(`createDnsProvider: unsupported provider type: ${_exhaustive}`);
} }

View File

@@ -71,3 +71,72 @@ export interface IProviderDomainListing {
/** Authoritative nameservers reported by the provider. */ /** Authoritative nameservers reported by the provider. */
nameservers: string[]; nameservers: string[];
} }
/**
* Schema entry for a single credential field, used by the OpsServer UI to
* render a provider's credential form dynamically.
*/
export interface IDnsProviderCredentialField {
/** Key under which the value is stored in the credentials object. */
key: string;
/** Label shown to the user. */
label: string;
/** Optional inline help text. */
helpText?: string;
/** Whether the field must be filled. */
required: boolean;
/** True for secret fields (rendered as password input, never echoed back). */
secret: boolean;
}
/**
* Metadata describing a DNS provider type. Drives:
* - the OpsServer UI's provider type picker + credential form,
* - documentation of which credentials each provider needs,
* - end-to-end consistency between the type union, the discriminated
* credentials union, the runtime factory, and the form rendering.
*
* To add a new provider, append a new entry to `dnsProviderTypeDescriptors`
* below — and follow the checklist in `ts/dns/providers/factory.ts`.
*/
export interface IDnsProviderTypeDescriptor {
type: TDnsProviderType;
/** Human-readable name for the UI. */
displayName: string;
/** One-line description shown next to the type picker. */
description: string;
/** Schema for the credentials form. */
credentialFields: IDnsProviderCredentialField[];
}
/**
* Single source of truth for which DNS provider types exist and what
* credentials each one needs. Used by both backend and frontend.
*/
export const dnsProviderTypeDescriptors: ReadonlyArray<IDnsProviderTypeDescriptor> = [
{
type: 'cloudflare',
displayName: 'Cloudflare',
description:
'Manages records via the Cloudflare API. Provider stays authoritative; dcrouter pushes record changes.',
credentialFields: [
{
key: 'apiToken',
label: 'API Token',
helpText:
'A Cloudflare API token with Zone:Read and DNS:Edit permissions for the target zones.',
required: true,
secret: true,
},
],
},
];
/**
* Look up the descriptor for a given provider type.
*/
export function getDnsProviderTypeDescriptor(
type: TDnsProviderType,
): IDnsProviderTypeDescriptor | undefined {
return dnsProviderTypeDescriptors.find((d) => d.type === type);
}

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@serve.zone/dcrouter', name: '@serve.zone/dcrouter',
version: '13.6.0', version: '13.7.0',
description: 'A multifaceted routing service handling mail and SMS delivery functions.' description: 'A multifaceted routing service handling mail and SMS delivery functions.'
} }

View File

@@ -0,0 +1,216 @@
import {
DeesElement,
html,
customElement,
type TemplateResult,
css,
state,
property,
cssManager,
} from '@design.estate/dees-element';
import * as interfaces from '../../../dist_ts_interfaces/index.js';
declare global {
interface HTMLElementTagNameMap {
'dns-provider-form': DnsProviderForm;
}
}
/**
* Reactive credential form for a DNS provider. Renders the type picker
* and the credential fields for the currently-selected type.
*
* Provider-agnostic — driven entirely by `dnsProviderTypeDescriptors` from
* `ts_interfaces/data/dns-provider.ts`. Adding a new provider type means
* appending one entry to the descriptors array; this form picks it up
* automatically.
*
* Usage:
*
* const formEl = document.createElement('dns-provider-form');
* formEl.providerName = 'My provider';
* // ... pass element into a DeesModal as content ...
* // on submit:
* const data = formEl.collectData();
* // → { name, type, credentials }
*
* In edit mode, set `lockType = true` so the user cannot change provider
* type after creation (credentials shapes don't transfer between types).
*/
@customElement('dns-provider-form')
export class DnsProviderForm extends DeesElement {
/** Pre-populated provider name. */
@property({ type: String })
accessor providerName: string = '';
/**
* Currently selected provider type. Initialized to the first descriptor;
* caller can override before mounting (e.g. for edit dialogs).
*/
@state()
accessor selectedType: interfaces.data.TDnsProviderType =
interfaces.data.dnsProviderTypeDescriptors[0]?.type ?? 'cloudflare';
/** When true, hide the type picker — used in edit dialogs. */
@property({ type: Boolean })
accessor lockType: boolean = false;
/**
* Help text shown above credentials. Useful for edit dialogs to indicate
* that fields can be left blank to keep current values.
*/
@property({ type: String })
accessor credentialsHint: string = '';
/** Internal map of credential field values, keyed by the descriptor's `key`. */
@state()
accessor credentialValues: Record<string, string> = {};
public static styles = [
cssManager.defaultStyles,
css`
:host {
display: block;
}
.field {
margin-bottom: 12px;
}
.helpText {
font-size: 12px;
opacity: 0.7;
margin-top: -6px;
margin-bottom: 8px;
}
.typeDescription {
font-size: 12px;
opacity: 0.8;
margin: 4px 0 16px;
padding: 8px 12px;
background: ${cssManager.bdTheme('#f3f4f6', '#1f2937')};
border-radius: 6px;
}
.credentialsHint {
font-size: 12px;
opacity: 0.7;
margin-bottom: 12px;
}
`,
];
public render(): TemplateResult {
const descriptors = interfaces.data.dnsProviderTypeDescriptors;
const descriptor = interfaces.data.getDnsProviderTypeDescriptor(this.selectedType);
return html`
<dees-form>
<div class="field">
<dees-input-text
.key=${'name'}
.label=${'Provider name'}
.value=${this.providerName}
.required=${true}
></dees-input-text>
</div>
${this.lockType
? html`
<div class="field">
<dees-input-text
.key=${'__type_display'}
.label=${'Type'}
.value=${descriptor?.displayName ?? this.selectedType}
.disabled=${true}
></dees-input-text>
</div>
`
: html`
<div class="field">
<dees-input-dropdown
.key=${'__type'}
.label=${'Provider type'}
.options=${descriptors.map((d) => ({ option: d.displayName, key: d.type }))}
.selectedOption=${descriptor
? { option: descriptor.displayName, key: descriptor.type }
: undefined}
@selectedOption=${(e: CustomEvent) => {
const newType = (e.detail as any)?.key as
| interfaces.data.TDnsProviderType
| undefined;
if (newType && newType !== this.selectedType) {
this.selectedType = newType;
this.credentialValues = {};
}
}}
></dees-input-dropdown>
</div>
`}
${descriptor
? html`
<div class="typeDescription">${descriptor.description}</div>
${this.credentialsHint
? html`<div class="credentialsHint">${this.credentialsHint}</div>`
: ''}
${descriptor.credentialFields.map(
(f) => html`
<div class="field">
<dees-input-text
.key=${f.key}
.label=${f.label}
.required=${f.required && !this.lockType}
></dees-input-text>
${f.helpText ? html`<div class="helpText">${f.helpText}</div>` : ''}
</div>
`,
)}
`
: html`<p>No provider types registered.</p>`}
</dees-form>
`;
}
/**
* Read the form values and assemble the create/update payload.
* Returns the typed credentials object built from the descriptor's keys.
*/
public async collectData(): Promise<{
name: string;
type: interfaces.data.TDnsProviderType;
credentials: interfaces.data.TDnsProviderCredentials;
credentialsTouched: boolean;
} | null> {
const form = this.shadowRoot?.querySelector('dees-form') as any;
if (!form) return null;
const data = await form.collectFormData();
const descriptor = interfaces.data.getDnsProviderTypeDescriptor(this.selectedType);
if (!descriptor) return null;
// Build the credentials object from the descriptor's field keys.
const credsBody: Record<string, string> = {};
let credentialsTouched = false;
for (const f of descriptor.credentialFields) {
const value = data[f.key];
if (value !== undefined && value !== null && String(value).length > 0) {
credsBody[f.key] = String(value);
credentialsTouched = true;
}
}
// The discriminator goes on the credentials object so the backend
// factory and the discriminated union both stay happy.
const credentials = {
type: this.selectedType,
...credsBody,
} as unknown as interfaces.data.TDnsProviderCredentials;
return {
name: String(data.name ?? ''),
type: this.selectedType,
credentials,
credentialsTouched,
};
}
}

View File

@@ -1,3 +1,4 @@
export * from './dns-provider-form.js';
export * from './ops-view-providers.js'; export * from './ops-view-providers.js';
export * from './ops-view-domains.js'; export * from './ops-view-domains.js';
export * from './ops-view-dns.js'; export * from './ops-view-dns.js';

View File

@@ -10,6 +10,8 @@ import {
import * as appstate from '../../appstate.js'; import * as appstate from '../../appstate.js';
import * as interfaces from '../../../dist_ts_interfaces/index.js'; import * as interfaces from '../../../dist_ts_interfaces/index.js';
import { viewHostCss } from '../shared/css.js'; import { viewHostCss } from '../shared/css.js';
import './dns-provider-form.js';
import type { DnsProviderForm } from './dns-provider-form.js';
declare global { declare global {
interface HTMLElementTagNameMap { interface HTMLElementTagNameMap {
@@ -80,12 +82,12 @@ export class OpsViewProviders extends DeesElement {
<div class="providersContainer"> <div class="providersContainer">
<dees-table <dees-table
.heading1=${'Providers'} .heading1=${'Providers'}
.heading2=${'External DNS provider accounts (Cloudflare, etc.)'} .heading2=${'External DNS provider accounts'}
.data=${providers} .data=${providers}
.showColumnFilters=${true} .showColumnFilters=${true}
.displayFunction=${(p: interfaces.data.IDnsProviderPublic) => ({ .displayFunction=${(p: interfaces.data.IDnsProviderPublic) => ({
Name: p.name, Name: p.name,
Type: p.type, Type: this.providerTypeLabel(p.type),
Status: this.renderStatusBadge(p.status), Status: this.renderStatusBadge(p.status),
'Last Tested': p.lastTestedAt ? new Date(p.lastTestedAt).toLocaleString() : 'never', 'Last Tested': p.lastTestedAt ? new Date(p.lastTestedAt).toLocaleString() : 'never',
Error: p.lastError || '-', Error: p.lastError || '-',
@@ -147,34 +149,39 @@ export class OpsViewProviders extends DeesElement {
return html`<span class="statusBadge ${status}">${status}</span>`; return html`<span class="statusBadge ${status}">${status}</span>`;
} }
private providerTypeLabel(type: interfaces.data.TDnsProviderType): string {
return interfaces.data.getDnsProviderTypeDescriptor(type)?.displayName ?? type;
}
private async showCreateDialog() { private async showCreateDialog() {
const { DeesModal } = await import('@design.estate/dees-catalog'); const { DeesModal, DeesToast } = await import('@design.estate/dees-catalog');
const formEl = document.createElement('dns-provider-form') as DnsProviderForm;
DeesModal.createAndShow({ DeesModal.createAndShow({
heading: 'Add DNS Provider', heading: 'Add DNS Provider',
content: html` content: html`${formEl}`,
<dees-form>
<dees-input-text .key=${'name'} .label=${'Provider name'} .required=${true}></dees-input-text>
<dees-input-text
.key=${'apiToken'}
.label=${'Cloudflare API token'}
.required=${true}
></dees-input-text>
</dees-form>
`,
menuOptions: [ menuOptions: [
{ name: 'Cancel', action: async (modalArg: any) => modalArg.destroy() }, { name: 'Cancel', action: async (modalArg: any) => modalArg.destroy() },
{ {
name: 'Create', name: 'Create',
action: async (modalArg: any) => { action: async (modalArg: any) => {
const form = modalArg.shadowRoot const data = await formEl.collectData();
?.querySelector('.content') if (!data) return;
?.querySelector('dees-form'); if (!data.name) {
if (!form) return; DeesToast.show({ message: 'Name is required', type: 'warning', duration: 2500 });
const data = await form.collectFormData(); return;
}
if (!data.credentialsTouched) {
DeesToast.show({
message: 'Fill in the provider credentials',
type: 'warning',
duration: 2500,
});
return;
}
await appstate.domainsStatePart.dispatchAction(appstate.createDnsProviderAction, { await appstate.domainsStatePart.dispatchAction(appstate.createDnsProviderAction, {
name: String(data.name), name: data.name,
type: 'cloudflare', type: data.type,
credentials: { type: 'cloudflare', apiToken: String(data.apiToken) }, credentials: data.credentials,
}); });
modalArg.destroy(); modalArg.destroy();
}, },
@@ -185,34 +192,28 @@ export class OpsViewProviders extends DeesElement {
private async showEditDialog(provider: interfaces.data.IDnsProviderPublic) { private async showEditDialog(provider: interfaces.data.IDnsProviderPublic) {
const { DeesModal } = await import('@design.estate/dees-catalog'); const { DeesModal } = await import('@design.estate/dees-catalog');
const formEl = document.createElement('dns-provider-form') as DnsProviderForm;
formEl.providerName = provider.name;
formEl.selectedType = provider.type;
formEl.lockType = true;
formEl.credentialsHint =
'Leave credential fields blank to keep the current values. Fill them to rotate.';
DeesModal.createAndShow({ DeesModal.createAndShow({
heading: `Edit Provider: ${provider.name}`, heading: `Edit Provider: ${provider.name}`,
content: html` content: html`${formEl}`,
<dees-form>
<dees-input-text .key=${'name'} .label=${'Provider name'} .value=${provider.name}></dees-input-text>
<dees-input-text
.key=${'apiToken'}
.label=${'New API token (leave blank to keep current)'}
></dees-input-text>
</dees-form>
`,
menuOptions: [ menuOptions: [
{ name: 'Cancel', action: async (modalArg: any) => modalArg.destroy() }, { name: 'Cancel', action: async (modalArg: any) => modalArg.destroy() },
{ {
name: 'Save', name: 'Save',
action: async (modalArg: any) => { action: async (modalArg: any) => {
const form = modalArg.shadowRoot const data = await formEl.collectData();
?.querySelector('.content') if (!data) return;
?.querySelector('dees-form');
if (!form) return;
const data = await form.collectFormData();
const apiToken = data.apiToken ? String(data.apiToken) : '';
await appstate.domainsStatePart.dispatchAction(appstate.updateDnsProviderAction, { await appstate.domainsStatePart.dispatchAction(appstate.updateDnsProviderAction, {
id: provider.id, id: provider.id,
name: String(data.name), name: data.name || provider.name,
credentials: apiToken // Only send credentials if the user actually entered something —
? { type: 'cloudflare', apiToken } // otherwise we keep the current secret untouched.
: undefined, credentials: data.credentialsTouched ? data.credentials : undefined,
}); });
modalArg.destroy(); modalArg.destroy();
}, },