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

@@ -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-domains.js';
export * from './ops-view-dns.js';

View File

@@ -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 {
<div class="providersContainer">
<dees-table
.heading1=${'Providers'}
.heading2=${'External DNS provider accounts (Cloudflare, etc.)'}
.heading2=${'External DNS provider accounts'}
.data=${providers}
.showColumnFilters=${true}
.displayFunction=${(p: interfaces.data.IDnsProviderPublic) => ({
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`<span class="statusBadge ${status}">${status}</span>`;
}
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`
<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>
`,
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`
<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>
`,
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();
},