feat(gateway-clients): add managed gateway client administration and token-bound route ownership
This commit is contained in:
@@ -20,6 +20,7 @@ export class OpsViewApiTokens extends DeesElement {
|
||||
mergedRoutes: [],
|
||||
warnings: [],
|
||||
apiTokens: [],
|
||||
gatewayClients: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastUpdated: 0,
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
import * as appstate from '../../appstate.js';
|
||||
import * as interfaces from '../../../dist_ts_interfaces/index.js';
|
||||
import { viewHostCss } from '../shared/css.js';
|
||||
|
||||
import {
|
||||
DeesElement,
|
||||
css,
|
||||
cssManager,
|
||||
customElement,
|
||||
html,
|
||||
state,
|
||||
type TemplateResult,
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
@customElement('ops-view-gatewayclients')
|
||||
export class OpsViewGatewayClients extends DeesElement {
|
||||
@state() accessor routeState: appstate.IRouteManagementState = {
|
||||
mergedRoutes: [],
|
||||
warnings: [],
|
||||
apiTokens: [],
|
||||
gatewayClients: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastUpdated: 0,
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const sub = appstate.routeManagementStatePart
|
||||
.select((s) => s)
|
||||
.subscribe((routeState) => {
|
||||
this.routeState = routeState;
|
||||
});
|
||||
this.rxSubscriptions.push(sub);
|
||||
|
||||
const loginSub = appstate.loginStatePart
|
||||
.select((s) => s.isLoggedIn)
|
||||
.subscribe((isLoggedIn) => {
|
||||
if (isLoggedIn) {
|
||||
appstate.routeManagementStatePart.dispatchAction(appstate.fetchGatewayClientsAction, null);
|
||||
}
|
||||
});
|
||||
this.rxSubscriptions.push(loginSub);
|
||||
}
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
viewHostCss,
|
||||
css`
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
background: ${cssManager.bdTheme('rgba(37, 99, 235, 0.1)', 'rgba(96, 165, 250, 0.14)')};
|
||||
color: ${cssManager.bdTheme('#1d4ed8', '#93c5fd')};
|
||||
margin-right: 4px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
return html`
|
||||
<dees-heading level="3">Gateway Clients</dees-heading>
|
||||
<dees-table
|
||||
.heading1=${'Gateway Clients'}
|
||||
.heading2=${'Create durable clients and token credentials for Onebox, Cloudly, or custom integrations'}
|
||||
.data=${this.routeState.gatewayClients}
|
||||
.dataName=${'gateway client'}
|
||||
.searchable=${true}
|
||||
.showColumnFilters=${true}
|
||||
.displayFunction=${(client: interfaces.data.IGatewayClient) => ({
|
||||
name: client.name,
|
||||
id: client.id,
|
||||
type: client.type,
|
||||
hostnames: this.renderPills(client.hostnamePatterns),
|
||||
targets: this.renderTargets(client.allowedRouteTargets),
|
||||
tokens: client.tokenCount || 0,
|
||||
status: client.enabled ? 'Active' : 'Disabled',
|
||||
})}
|
||||
.dataActions=${[
|
||||
{
|
||||
name: 'Create Client',
|
||||
iconName: 'lucide:plus',
|
||||
type: ['header'],
|
||||
actionFunc: async () => await this.showCreateClientDialog(),
|
||||
},
|
||||
{
|
||||
name: 'Create Token',
|
||||
iconName: 'lucide:keyRound',
|
||||
type: ['inRow', 'contextmenu'] as any,
|
||||
actionFunc: async (actionData: any) => await this.showCreateTokenDialog(actionData.item),
|
||||
},
|
||||
{
|
||||
name: 'Enable',
|
||||
iconName: 'lucide:play',
|
||||
type: ['inRow', 'contextmenu'] as any,
|
||||
actionRelevancyCheckFunc: (actionData: any) => !actionData.item.enabled,
|
||||
actionFunc: async (actionData: any) => {
|
||||
await appstate.routeManagementStatePart.dispatchAction(appstate.updateGatewayClientAction, {
|
||||
id: actionData.item.id,
|
||||
enabled: true,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Disable',
|
||||
iconName: 'lucide:pause',
|
||||
type: ['inRow', 'contextmenu'] as any,
|
||||
actionRelevancyCheckFunc: (actionData: any) => actionData.item.enabled,
|
||||
actionFunc: async (actionData: any) => {
|
||||
await appstate.routeManagementStatePart.dispatchAction(appstate.updateGatewayClientAction, {
|
||||
id: actionData.item.id,
|
||||
enabled: false,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Delete',
|
||||
iconName: 'lucide:trash2',
|
||||
type: ['inRow', 'contextmenu'] as any,
|
||||
actionFunc: async (actionData: any) => {
|
||||
await appstate.routeManagementStatePart.dispatchAction(appstate.deleteGatewayClientAction, actionData.item.id);
|
||||
},
|
||||
},
|
||||
]}
|
||||
></dees-table>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderPills(values: string[]): TemplateResult {
|
||||
if (!values.length) return html`<span>None</span>`;
|
||||
return html`${values.map((value) => html`<span class="pill">${value}</span>`)}`;
|
||||
}
|
||||
|
||||
private renderTargets(targets: interfaces.data.IGatewayClient['allowedRouteTargets']): TemplateResult {
|
||||
if (!targets.length) return html`<span>None</span>`;
|
||||
return html`${targets.map((target) => html`<span class="pill">${target.host}:${target.ports.join(',')}</span>`)}`;
|
||||
}
|
||||
|
||||
private async showCreateClientDialog(): Promise<void> {
|
||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||
await DeesModal.createAndShow({
|
||||
heading: 'Create Gateway Client',
|
||||
content: html`
|
||||
<dees-form>
|
||||
<dees-input-text .key=${'name'} .label=${'Name'} .required=${true}></dees-input-text>
|
||||
<dees-input-text .key=${'type'} .label=${'Type'} .value=${'onebox'} .description=${'onebox, cloudly, or custom'}></dees-input-text>
|
||||
<dees-input-text .key=${'id'} .label=${'Client ID'} .description=${'Optional stable ID; generated when empty'}></dees-input-text>
|
||||
<dees-input-text .key=${'hostnamePatterns'} .label=${'Hostname Patterns'} .description=${'Comma separated, e.g. *.apps.example.com'}></dees-input-text>
|
||||
<dees-input-text .key=${'allowedRouteTarget'} .label=${'Allowed Route Target'} .description=${'Optional host:ports, e.g. onebox-smartproxy:80'}></dees-input-text>
|
||||
<dees-input-text .key=${'description'} .label=${'Description'}></dees-input-text>
|
||||
</dees-form>
|
||||
`,
|
||||
menuOptions: [
|
||||
{ name: 'Cancel', iconName: 'lucide:x', action: async (modalArg: any) => await modalArg.destroy() },
|
||||
{
|
||||
name: 'Create',
|
||||
iconName: 'lucide:plus',
|
||||
action: async (modalArg: any) => {
|
||||
const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form');
|
||||
if (!form) return;
|
||||
const formData = await (form as any).collectFormData();
|
||||
const name = String(formData.name || '').trim();
|
||||
if (!name) return;
|
||||
await modalArg.destroy();
|
||||
await appstate.createGatewayClient({
|
||||
id: String(formData.id || '').trim() || undefined,
|
||||
type: this.normalizeClientType(String(formData.type || 'onebox')),
|
||||
name,
|
||||
description: String(formData.description || '').trim() || undefined,
|
||||
hostnamePatterns: this.parseList(String(formData.hostnamePatterns || '')),
|
||||
allowedRouteTargets: this.parseAllowedRouteTargets(String(formData.allowedRouteTarget || '')),
|
||||
});
|
||||
await appstate.routeManagementStatePart.dispatchAction(appstate.fetchGatewayClientsAction, null);
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
private async showCreateTokenDialog(client: interfaces.data.IGatewayClient): Promise<void> {
|
||||
const { DeesModal } = await import('@design.estate/dees-catalog');
|
||||
await DeesModal.createAndShow({
|
||||
heading: `Create Token for ${client.name}`,
|
||||
content: html`
|
||||
<div style="color: #888; margin-bottom: 12px; font-size: 13px;">
|
||||
The token will be shown once. Configure Onebox with the dcrouter URL and this token.
|
||||
</div>
|
||||
<dees-form>
|
||||
<dees-input-text .key=${'name'} .label=${'Token Name'} .value=${`${client.name} Token`}></dees-input-text>
|
||||
<dees-input-text .key=${'expiresInDays'} .label=${'Expires in'} .description=${'Number of days; leave blank for no expiration'}></dees-input-text>
|
||||
</dees-form>
|
||||
`,
|
||||
menuOptions: [
|
||||
{ name: 'Cancel', iconName: 'lucide:x', action: async (modalArg: any) => await modalArg.destroy() },
|
||||
{
|
||||
name: 'Create Token',
|
||||
iconName: 'lucide:key',
|
||||
action: async (modalArg: any) => {
|
||||
const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form');
|
||||
if (!form) return;
|
||||
const formData = await (form as any).collectFormData();
|
||||
const expiresInDays = formData.expiresInDays ? parseInt(formData.expiresInDays, 10) : null;
|
||||
await modalArg.destroy();
|
||||
const response = await appstate.createGatewayClientToken(
|
||||
client.id,
|
||||
String(formData.name || '').trim() || undefined,
|
||||
expiresInDays,
|
||||
);
|
||||
await appstate.routeManagementStatePart.dispatchAction(appstate.fetchGatewayClientsAction, null);
|
||||
if (response.success && response.tokenValue) {
|
||||
await DeesModal.createAndShow({
|
||||
heading: 'Gateway Client Token Created',
|
||||
content: html`
|
||||
<p>Copy this token now. It will not be shown again.</p>
|
||||
<div style="background: #111; padding: 12px; border-radius: 6px; margin-top: 8px;">
|
||||
<code style="color: #0f8; word-break: break-all; font-size: 13px;">${response.tokenValue}</code>
|
||||
</div>
|
||||
`,
|
||||
menuOptions: [
|
||||
{ name: 'Done', iconName: 'lucide:check', action: async (m: any) => await m.destroy() },
|
||||
],
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
private normalizeClientType(value: string): interfaces.data.IGatewayClient['type'] {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (normalized === 'cloudly' || normalized === 'custom') return normalized;
|
||||
return 'onebox';
|
||||
}
|
||||
|
||||
private parseList(value: string): string[] {
|
||||
return value.split(',').map((entry) => entry.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
private parseAllowedRouteTargets(value: string): interfaces.data.IGatewayClient['allowedRouteTargets'] {
|
||||
const target = value.trim();
|
||||
if (!target.includes(':')) return [];
|
||||
const [host, portsValue] = target.split(':');
|
||||
const ports = portsValue.split(',').map((port) => Number(port.trim())).filter((port) => Number.isInteger(port));
|
||||
return host.trim() && ports.length ? [{ host: host.trim(), ports }] : [];
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import * as appstate from '../../appstate.js';
|
||||
import * as interfaces from '../../../dist_ts_interfaces/index.js';
|
||||
import { viewHostCss } from '../shared/css.js';
|
||||
import { type IStatsTile } from '@design.estate/dees-catalog';
|
||||
import { appRouter } from '../../router.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
@@ -26,6 +27,9 @@ export class OpsViewCertificates extends DeesElement {
|
||||
@state()
|
||||
accessor acmeState: appstate.IAcmeConfigState = appstate.acmeConfigStatePart.getState()!;
|
||||
|
||||
@state()
|
||||
accessor domainsState: appstate.IDomainsState = appstate.domainsStatePart.getState()!;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const certSub = appstate.certificateStatePart.select().subscribe((newState) => {
|
||||
@@ -36,12 +40,19 @@ export class OpsViewCertificates extends DeesElement {
|
||||
this.acmeState = newState;
|
||||
});
|
||||
this.rxSubscriptions.push(acmeSub);
|
||||
const domainsSub = appstate.domainsStatePart.select().subscribe((newState) => {
|
||||
this.domainsState = newState;
|
||||
});
|
||||
this.rxSubscriptions.push(domainsSub);
|
||||
}
|
||||
|
||||
async connectedCallback() {
|
||||
await super.connectedCallback();
|
||||
await appstate.certificateStatePart.dispatchAction(appstate.fetchCertificateOverviewAction, null);
|
||||
await appstate.acmeConfigStatePart.dispatchAction(appstate.fetchAcmeConfigAction, null);
|
||||
await Promise.all([
|
||||
appstate.certificateStatePart.dispatchAction(appstate.fetchCertificateOverviewAction, null),
|
||||
appstate.acmeConfigStatePart.dispatchAction(appstate.fetchAcmeConfigAction, null),
|
||||
appstate.domainsStatePart.dispatchAction(appstate.fetchDomainsAndProvidersAction, null),
|
||||
]);
|
||||
}
|
||||
|
||||
public static styles = [
|
||||
@@ -127,10 +138,16 @@ export class OpsViewCertificates extends DeesElement {
|
||||
.errorText {
|
||||
font-size: 12px;
|
||||
color: ${cssManager.bdTheme('#991b1b', '#f87171')};
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 420px;
|
||||
line-height: 1.35;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.errorStack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.backoffIndicator {
|
||||
@@ -160,6 +177,39 @@ export class OpsViewCertificates extends DeesElement {
|
||||
.expiryInfo .daysLeft.danger {
|
||||
color: ${cssManager.bdTheme('#991b1b', '#f87171')};
|
||||
}
|
||||
|
||||
.dnsWarningPanel {
|
||||
border: 1px solid ${cssManager.bdTheme('#fed7aa', '#7c2d12')};
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
background: ${cssManager.bdTheme('#fff7ed', '#1c1917')};
|
||||
color: ${cssManager.bdTheme('#7c2d12', '#fdba74')};
|
||||
}
|
||||
|
||||
.dnsWarningTitle {
|
||||
font-weight: 700;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.dnsWarningText {
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
color: ${cssManager.bdTheme('#9a3412', '#fed7aa')};
|
||||
}
|
||||
|
||||
.dnsWarningList {
|
||||
margin: 12px 0 0;
|
||||
padding-left: 18px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.dnsWarningActions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
@@ -172,11 +222,102 @@ export class OpsViewCertificates extends DeesElement {
|
||||
<div class="certificatesContainer">
|
||||
${this.renderStatsTiles(summary)}
|
||||
${this.renderAcmeSettingsTile()}
|
||||
${this.renderManagedDomainWarnings()}
|
||||
${this.renderCertificateTable()}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderManagedDomainWarnings(): TemplateResult {
|
||||
const issues = this.getMissingManagedDomainIssues();
|
||||
if (issues.length === 0) {
|
||||
return html``;
|
||||
}
|
||||
|
||||
const shownIssues = issues.slice(0, 6);
|
||||
const remaining = issues.length - shownIssues.length;
|
||||
|
||||
return html`
|
||||
<div class="dnsWarningPanel">
|
||||
<div class="dnsWarningTitle">DNS-01 certificate provisioning needs managed DNS domains</div>
|
||||
<div class="dnsWarningText">
|
||||
DcRouter can only create ACME TXT records for domains listed under Domains > Domains.
|
||||
Add the zone directly or import it from a DNS provider before reprovisioning certificates.
|
||||
</div>
|
||||
<ul class="dnsWarningList">
|
||||
${shownIssues.map((issue) => html`
|
||||
<li>
|
||||
<strong>${issue.domain}</strong>: no managed DNS domain covers
|
||||
<code>${issue.challengeHost}</code>. Add/import <code>${issue.requiredDomain}</code>
|
||||
or a parent zone.
|
||||
</li>
|
||||
`)}
|
||||
${remaining > 0 ? html`<li>${remaining} more domain${remaining === 1 ? '' : 's'} need managed DNS.</li>` : ''}
|
||||
</ul>
|
||||
<div class="dnsWarningActions">
|
||||
<dees-button @click=${() => appRouter.navigateToView('domains', 'domains')}>Manage Domains</dees-button>
|
||||
<dees-button @click=${() => appRouter.navigateToView('domains', 'providers')}>DNS Providers</dees-button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private getMissingManagedDomainIssues(): Array<{
|
||||
domain: string;
|
||||
challengeHost: string;
|
||||
requiredDomain: string;
|
||||
}> {
|
||||
const managedDomains = this.domainsState.domains
|
||||
.map((domain) => this.normalizeDomain(domain.name))
|
||||
.filter(Boolean);
|
||||
const issues: Array<{ domain: string; challengeHost: string; requiredDomain: string }> = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const cert of this.certState.certificates) {
|
||||
if (!cert.canReprovision || (cert.source !== 'acme' && cert.source !== 'provision-function')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const requiredDomain = this.getAcmeChallengeDomain(cert.domain);
|
||||
if (!requiredDomain) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const covered = managedDomains.some((managedDomain) =>
|
||||
requiredDomain === managedDomain || requiredDomain.endsWith(`.${managedDomain}`),
|
||||
);
|
||||
if (covered) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = `${cert.domain}:${requiredDomain}`;
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
issues.push({
|
||||
domain: cert.domain,
|
||||
challengeHost: `_acme-challenge.${requiredDomain}`,
|
||||
requiredDomain,
|
||||
});
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
private getAcmeChallengeDomain(domain: string): string {
|
||||
const normalized = this.normalizeDomain(domain).replace(/^\*\.?/, '');
|
||||
const parts = normalized.split('.').filter(Boolean);
|
||||
if (parts.length >= 2 && parts.length <= 3) {
|
||||
return parts.slice(-2).join('.');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private normalizeDomain(domain: string): string {
|
||||
return domain.trim().toLowerCase().replace(/^\*\.?/, '').replace(/\.$/, '');
|
||||
}
|
||||
|
||||
private renderAcmeSettingsTile(): TemplateResult {
|
||||
const config = this.acmeState.config;
|
||||
|
||||
@@ -349,11 +490,7 @@ export class OpsViewCertificates extends DeesElement {
|
||||
Status: this.renderStatusBadge(cert.status),
|
||||
Source: this.renderSourceBadge(cert.source),
|
||||
Expires: this.renderExpiry(cert.expiryDate),
|
||||
Error: cert.backoffInfo
|
||||
? html`<span class="backoffIndicator">${cert.backoffInfo.failures} failures, retry ${this.formatRetryTime(cert.backoffInfo.retryAfter)}</span>`
|
||||
: cert.error
|
||||
? html`<span class="errorText" title="${cert.error}">${cert.error}</span>`
|
||||
: '',
|
||||
Error: this.renderError(cert),
|
||||
})}
|
||||
.dataActions=${[
|
||||
{
|
||||
@@ -632,6 +769,24 @@ export class OpsViewCertificates extends DeesElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private renderError(cert: interfaces.requests.ICertificateInfo): TemplateResult | string {
|
||||
if (cert.backoffInfo) {
|
||||
const message = cert.backoffInfo.lastError || cert.error;
|
||||
return html`
|
||||
<span class="errorStack">
|
||||
${message ? html`<span class="errorText" title=${message}>${message}</span>` : ''}
|
||||
<span class="backoffIndicator">
|
||||
${cert.backoffInfo.failures} failure${cert.backoffInfo.failures === 1 ? '' : 's'}, retry ${this.formatRetryTime(cert.backoffInfo.retryAfter)}
|
||||
</span>
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
if (cert.error) {
|
||||
return html`<span class="errorText" title=${cert.error}>${cert.error}</span>`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
private formatRetryTime(retryAfter?: string): string {
|
||||
if (!retryAfter) return 'soon';
|
||||
const retryDate = new Date(retryAfter);
|
||||
|
||||
@@ -129,6 +129,7 @@ export class OpsViewRoutes extends DeesElement {
|
||||
mergedRoutes: [],
|
||||
warnings: [],
|
||||
apiTokens: [],
|
||||
gatewayClients: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastUpdated: 0,
|
||||
|
||||
@@ -35,6 +35,7 @@ import { OpsViewEmailSecurity } from './email/ops-view-email-security.js';
|
||||
import { OpsViewEmailDomains } from './email/ops-view-email-domains.js';
|
||||
|
||||
// Access group
|
||||
import { OpsViewGatewayClients } from './access/ops-view-gatewayclients.js';
|
||||
import { OpsViewApiTokens } from './access/ops-view-apitokens.js';
|
||||
import { OpsViewUsers } from './access/ops-view-users.js';
|
||||
|
||||
@@ -121,6 +122,7 @@ export class OpsDashboard extends DeesElement {
|
||||
name: 'Access',
|
||||
iconName: 'lucide:keyRound',
|
||||
subViews: [
|
||||
{ slug: 'gatewayclients', name: 'Gateway Clients', iconName: 'lucide:plugZap', element: OpsViewGatewayClients },
|
||||
{ slug: 'apitokens', name: 'API Tokens', iconName: 'lucide:key', element: OpsViewApiTokens },
|
||||
{ slug: 'users', name: 'Users', iconName: 'lucide:users', element: OpsViewUsers },
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user