feat(dns-providers): add provider-agnostic DNS provider form metadata and reusable UI for create/edit flows
This commit is contained in:
216
ts_web/elements/domains/dns-provider-form.ts
Normal file
216
ts_web/elements/domains/dns-provider-form.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user