From ea2e618990ffcd2761fa5b50297fd50c264fa946 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Wed, 8 Apr 2026 11:11:53 +0000 Subject: [PATCH] feat(dns-providers): add provider-agnostic DNS provider form metadata and reusable UI for create/edit flows --- changelog.md | 7 + ts/00_commitinfo_data.ts | 2 +- ts/dns/manager.dns.ts | 4 +- ts/dns/providers/cloudflare.provider.ts | 8 - ts/dns/providers/factory.ts | 17 ++ ts_interfaces/data/dns-provider.ts | 69 ++++++ ts_web/00_commitinfo_data.ts | 2 +- ts_web/elements/domains/dns-provider-form.ts | 216 ++++++++++++++++++ ts_web/elements/domains/index.ts | 1 + ts_web/elements/domains/ops-view-providers.ts | 81 +++---- 10 files changed, 356 insertions(+), 51 deletions(-) create mode 100644 ts_web/elements/domains/dns-provider-form.ts diff --git a/changelog.md b/changelog.md index ca927c0..e5f7c33 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,12 @@ # 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) add db-backed DNS provider, domain, and record management with ops UI support diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 1b4d364..1a25cb0 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@serve.zone/dcrouter', - version: '13.6.0', + version: '13.7.0', description: 'A multifaceted routing service handling mail and SMS delivery functions.' } diff --git a/ts/dns/manager.dns.ts b/ts/dns/manager.dns.ts index 2cfb2c1..c377e1f 100644 --- a/ts/dns/manager.dns.ts +++ b/ts/dns/manager.dns.ts @@ -302,7 +302,9 @@ export class DnsManager { /** * 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. */ public buildAcmeConvenientDnsProvider(): plugins.tsclass.network.IConvenientDnsProvider { diff --git a/ts/dns/providers/cloudflare.provider.ts b/ts/dns/providers/cloudflare.provider.ts index 7f91205..d5cd497 100644 --- a/ts/dns/providers/cloudflare.provider.ts +++ b/ts/dns/providers/cloudflare.provider.ts @@ -27,14 +27,6 @@ export class CloudflareDnsProvider implements IDnsProviderClient { 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 { try { // Listing zones is the lightest-weight call that proves the token works. diff --git a/ts/dns/providers/factory.ts b/ts/dns/providers/factory.ts index 97fecef..4102807 100644 --- a/ts/dns/providers/factory.ts +++ b/ts/dns/providers/factory.ts @@ -9,6 +9,21 @@ import { CloudflareDnsProvider } from './cloudflare.provider.js'; * Instantiate a runtime DNS provider client from a stored DnsProviderDoc. * * @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( type: TDnsProviderType, @@ -24,6 +39,8 @@ export function createDnsProvider( return new CloudflareDnsProvider(credentials.apiToken); } 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; throw new Error(`createDnsProvider: unsupported provider type: ${_exhaustive}`); } diff --git a/ts_interfaces/data/dns-provider.ts b/ts_interfaces/data/dns-provider.ts index 8396bed..a884c17 100644 --- a/ts_interfaces/data/dns-provider.ts +++ b/ts_interfaces/data/dns-provider.ts @@ -71,3 +71,72 @@ export interface IProviderDomainListing { /** Authoritative nameservers reported by the provider. */ 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 = [ + { + 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); +} diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts index 1b4d364..1a25cb0 100644 --- a/ts_web/00_commitinfo_data.ts +++ b/ts_web/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@serve.zone/dcrouter', - version: '13.6.0', + version: '13.7.0', description: 'A multifaceted routing service handling mail and SMS delivery functions.' } diff --git a/ts_web/elements/domains/dns-provider-form.ts b/ts_web/elements/domains/dns-provider-form.ts new file mode 100644 index 0000000..879ce0e --- /dev/null +++ b/ts_web/elements/domains/dns-provider-form.ts @@ -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 = {}; + + 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` + +
+ +
+ + ${this.lockType + ? html` +
+ +
+ ` + : html` +
+ ({ 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 = {}; + } + }} + > +
+ `} + ${descriptor + ? html` +
${descriptor.description}
+ ${this.credentialsHint + ? html`
${this.credentialsHint}
` + : ''} + ${descriptor.credentialFields.map( + (f) => html` +
+ + ${f.helpText ? html`
${f.helpText}
` : ''} +
+ `, + )} + ` + : html`

No provider types registered.

`} +
+ `; + } + + /** + * 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 = {}; + 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, + }; + } +} diff --git a/ts_web/elements/domains/index.ts b/ts_web/elements/domains/index.ts index 9650bd7..4fc1aa4 100644 --- a/ts_web/elements/domains/index.ts +++ b/ts_web/elements/domains/index.ts @@ -1,3 +1,4 @@ +export * from './dns-provider-form.js'; export * from './ops-view-providers.js'; export * from './ops-view-domains.js'; export * from './ops-view-dns.js'; diff --git a/ts_web/elements/domains/ops-view-providers.ts b/ts_web/elements/domains/ops-view-providers.ts index 3501537..fd0acbf 100644 --- a/ts_web/elements/domains/ops-view-providers.ts +++ b/ts_web/elements/domains/ops-view-providers.ts @@ -10,6 +10,8 @@ import { import * as appstate from '../../appstate.js'; import * as interfaces from '../../../dist_ts_interfaces/index.js'; import { viewHostCss } from '../shared/css.js'; +import './dns-provider-form.js'; +import type { DnsProviderForm } from './dns-provider-form.js'; declare global { interface HTMLElementTagNameMap { @@ -80,12 +82,12 @@ export class OpsViewProviders extends DeesElement {
({ Name: p.name, - Type: p.type, + Type: this.providerTypeLabel(p.type), Status: this.renderStatusBadge(p.status), 'Last Tested': p.lastTestedAt ? new Date(p.lastTestedAt).toLocaleString() : 'never', Error: p.lastError || '-', @@ -147,34 +149,39 @@ export class OpsViewProviders extends DeesElement { return html`${status}`; } + private providerTypeLabel(type: interfaces.data.TDnsProviderType): string { + return interfaces.data.getDnsProviderTypeDescriptor(type)?.displayName ?? type; + } + 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({ heading: 'Add DNS Provider', - content: html` - - - - - `, + content: html`${formEl}`, menuOptions: [ { name: 'Cancel', action: async (modalArg: any) => modalArg.destroy() }, { name: 'Create', action: async (modalArg: any) => { - const form = modalArg.shadowRoot - ?.querySelector('.content') - ?.querySelector('dees-form'); - if (!form) return; - const data = await form.collectFormData(); + const data = await formEl.collectData(); + if (!data) return; + if (!data.name) { + DeesToast.show({ message: 'Name is required', type: 'warning', duration: 2500 }); + return; + } + if (!data.credentialsTouched) { + DeesToast.show({ + message: 'Fill in the provider credentials', + type: 'warning', + duration: 2500, + }); + return; + } await appstate.domainsStatePart.dispatchAction(appstate.createDnsProviderAction, { - name: String(data.name), - type: 'cloudflare', - credentials: { type: 'cloudflare', apiToken: String(data.apiToken) }, + name: data.name, + type: data.type, + credentials: data.credentials, }); modalArg.destroy(); }, @@ -185,34 +192,28 @@ export class OpsViewProviders extends DeesElement { private async showEditDialog(provider: interfaces.data.IDnsProviderPublic) { 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({ heading: `Edit Provider: ${provider.name}`, - content: html` - - - - - `, + content: html`${formEl}`, 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 apiToken = data.apiToken ? String(data.apiToken) : ''; + const data = await formEl.collectData(); + if (!data) return; await appstate.domainsStatePart.dispatchAction(appstate.updateDnsProviderAction, { id: provider.id, - name: String(data.name), - credentials: apiToken - ? { type: 'cloudflare', apiToken } - : undefined, + name: data.name || provider.name, + // Only send credentials if the user actually entered something — + // otherwise we keep the current secret untouched. + credentials: data.credentialsTouched ? data.credentials : undefined, }); modalArg.destroy(); },