@serve.zone/dcrouter 13.27.1 → 13.29.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/.smartconfig.json +32 -10
  2. package/dist_serve/bundle.js +930 -799
  3. package/dist_ts/00_commitinfo_data.js +1 -1
  4. package/dist_ts/classes.dcrouter.d.ts +9 -1
  5. package/dist_ts/classes.dcrouter.js +22 -7
  6. package/dist_ts/config/classes.gateway-client-manager.d.ts +22 -0
  7. package/dist_ts/config/classes.gateway-client-manager.js +101 -0
  8. package/dist_ts/config/classes.route-config-manager.js +8 -7
  9. package/dist_ts/config/index.d.ts +1 -0
  10. package/dist_ts/config/index.js +2 -1
  11. package/dist_ts/db/documents/classes.gateway-client.doc.d.ts +18 -0
  12. package/dist_ts/db/documents/classes.gateway-client.doc.js +133 -0
  13. package/dist_ts/db/documents/index.d.ts +1 -0
  14. package/dist_ts/db/documents/index.js +2 -1
  15. package/dist_ts/opsserver/classes.opsserver.js +4 -1
  16. package/dist_ts/opsserver/handlers/admin.handler.d.ts +21 -6
  17. package/dist_ts/opsserver/handlers/admin.handler.js +188 -29
  18. package/dist_ts/opsserver/handlers/certificate.handler.js +5 -1
  19. package/dist_ts/opsserver/handlers/target-profile.handler.js +3 -1
  20. package/dist_ts/opsserver/handlers/users.handler.js +2 -2
  21. package/dist_ts/opsserver/handlers/workhoster.handler.d.ts +4 -0
  22. package/dist_ts/opsserver/handlers/workhoster.handler.js +146 -16
  23. package/dist_ts/plugins.d.ts +2 -0
  24. package/dist_ts/plugins.js +4 -1
  25. package/dist_ts/vpn/classes.vpn-manager.d.ts +2 -0
  26. package/dist_ts/vpn/classes.vpn-manager.js +41 -20
  27. package/dist_ts_apiclient/classes.workhoster.d.ts +1 -0
  28. package/dist_ts_apiclient/classes.workhoster.js +5 -1
  29. package/dist_ts_interfaces/data/workhoster.d.ts +28 -3
  30. package/dist_ts_interfaces/requests/admin.d.ts +38 -0
  31. package/dist_ts_interfaces/requests/users.d.ts +2 -5
  32. package/dist_ts_interfaces/requests/workhoster.d.ts +83 -1
  33. package/dist_ts_web/00_commitinfo_data.js +1 -1
  34. package/dist_ts_web/appstate.d.ts +46 -0
  35. package/dist_ts_web/appstate.js +105 -1
  36. package/dist_ts_web/elements/access/ops-view-apitokens.js +2 -1
  37. package/dist_ts_web/elements/access/ops-view-gatewayclients.d.ts +15 -0
  38. package/dist_ts_web/elements/access/ops-view-gatewayclients.js +293 -0
  39. package/dist_ts_web/elements/domains/ops-view-certificates.d.ts +6 -0
  40. package/dist_ts_web/elements/domains/ops-view-certificates.js +155 -13
  41. package/dist_ts_web/elements/network/ops-view-routes.js +2 -1
  42. package/dist_ts_web/elements/ops-dashboard.d.ts +4 -0
  43. package/dist_ts_web/elements/ops-dashboard.js +102 -3
  44. package/dist_ts_web/router.js +3 -3
  45. package/package.json +15 -22
  46. package/ts/00_commitinfo_data.ts +1 -1
  47. package/ts/classes.dcrouter.ts +30 -6
  48. package/ts/config/classes.gateway-client-manager.ts +117 -0
  49. package/ts/config/classes.route-config-manager.ts +8 -6
  50. package/ts/config/index.ts +2 -1
  51. package/ts/db/documents/classes.gateway-client.doc.ts +54 -0
  52. package/ts/db/documents/index.ts +1 -0
  53. package/ts/opsserver/classes.opsserver.ts +3 -0
  54. package/ts/opsserver/handlers/admin.handler.ts +244 -32
  55. package/ts/opsserver/handlers/certificate.handler.ts +5 -0
  56. package/ts/opsserver/handlers/target-profile.handler.ts +2 -0
  57. package/ts/opsserver/handlers/users.handler.ts +1 -1
  58. package/ts/opsserver/handlers/workhoster.handler.ts +191 -17
  59. package/ts/plugins.ts +7 -0
  60. package/ts/vpn/classes.vpn-manager.ts +56 -25
  61. package/ts_apiclient/classes.workhoster.ts +8 -0
  62. package/ts_web/00_commitinfo_data.ts +1 -1
  63. package/ts_web/appstate.ts +160 -0
  64. package/ts_web/elements/access/ops-view-apitokens.ts +1 -0
  65. package/ts_web/elements/access/ops-view-gatewayclients.ts +250 -0
  66. package/ts_web/elements/domains/ops-view-certificates.ts +166 -11
  67. package/ts_web/elements/network/ops-view-routes.ts +1 -0
  68. package/ts_web/elements/ops-dashboard.ts +102 -0
  69. package/ts_web/router.ts +2 -2
@@ -0,0 +1,250 @@
1
+ import * as appstate from '../../appstate.js';
2
+ import * as interfaces from '../../../dist_ts_interfaces/index.js';
3
+ import { viewHostCss } from '../shared/css.js';
4
+
5
+ import {
6
+ DeesElement,
7
+ css,
8
+ cssManager,
9
+ customElement,
10
+ html,
11
+ state,
12
+ type TemplateResult,
13
+ } from '@design.estate/dees-element';
14
+
15
+ @customElement('ops-view-gatewayclients')
16
+ export class OpsViewGatewayClients extends DeesElement {
17
+ @state() accessor routeState: appstate.IRouteManagementState = {
18
+ mergedRoutes: [],
19
+ warnings: [],
20
+ apiTokens: [],
21
+ gatewayClients: [],
22
+ isLoading: false,
23
+ error: null,
24
+ lastUpdated: 0,
25
+ };
26
+
27
+ constructor() {
28
+ super();
29
+ const sub = appstate.routeManagementStatePart
30
+ .select((s) => s)
31
+ .subscribe((routeState) => {
32
+ this.routeState = routeState;
33
+ });
34
+ this.rxSubscriptions.push(sub);
35
+
36
+ const loginSub = appstate.loginStatePart
37
+ .select((s) => s.isLoggedIn)
38
+ .subscribe((isLoggedIn) => {
39
+ if (isLoggedIn) {
40
+ appstate.routeManagementStatePart.dispatchAction(appstate.fetchGatewayClientsAction, null);
41
+ }
42
+ });
43
+ this.rxSubscriptions.push(loginSub);
44
+ }
45
+
46
+ public static styles = [
47
+ cssManager.defaultStyles,
48
+ viewHostCss,
49
+ css`
50
+ .pill {
51
+ display: inline-flex;
52
+ padding: 2px 6px;
53
+ border-radius: 3px;
54
+ font-size: 11px;
55
+ background: ${cssManager.bdTheme('rgba(37, 99, 235, 0.1)', 'rgba(96, 165, 250, 0.14)')};
56
+ color: ${cssManager.bdTheme('#1d4ed8', '#93c5fd')};
57
+ margin-right: 4px;
58
+ margin-bottom: 2px;
59
+ }
60
+ `,
61
+ ];
62
+
63
+ public render(): TemplateResult {
64
+ return html`
65
+ <dees-heading level="3">Gateway Clients</dees-heading>
66
+ <dees-table
67
+ .heading1=${'Gateway Clients'}
68
+ .heading2=${'Create durable clients and token credentials for Onebox, Cloudly, or custom integrations'}
69
+ .data=${this.routeState.gatewayClients}
70
+ .dataName=${'gateway client'}
71
+ .searchable=${true}
72
+ .showColumnFilters=${true}
73
+ .displayFunction=${(client: interfaces.data.IGatewayClient) => ({
74
+ name: client.name,
75
+ id: client.id,
76
+ type: client.type,
77
+ hostnames: this.renderPills(client.hostnamePatterns),
78
+ targets: this.renderTargets(client.allowedRouteTargets),
79
+ tokens: client.tokenCount || 0,
80
+ status: client.enabled ? 'Active' : 'Disabled',
81
+ })}
82
+ .dataActions=${[
83
+ {
84
+ name: 'Create Client',
85
+ iconName: 'lucide:plus',
86
+ type: ['header'],
87
+ actionFunc: async () => await this.showCreateClientDialog(),
88
+ },
89
+ {
90
+ name: 'Create Token',
91
+ iconName: 'lucide:keyRound',
92
+ type: ['inRow', 'contextmenu'] as any,
93
+ actionFunc: async (actionData: any) => await this.showCreateTokenDialog(actionData.item),
94
+ },
95
+ {
96
+ name: 'Enable',
97
+ iconName: 'lucide:play',
98
+ type: ['inRow', 'contextmenu'] as any,
99
+ actionRelevancyCheckFunc: (actionData: any) => !actionData.item.enabled,
100
+ actionFunc: async (actionData: any) => {
101
+ await appstate.routeManagementStatePart.dispatchAction(appstate.updateGatewayClientAction, {
102
+ id: actionData.item.id,
103
+ enabled: true,
104
+ });
105
+ },
106
+ },
107
+ {
108
+ name: 'Disable',
109
+ iconName: 'lucide:pause',
110
+ type: ['inRow', 'contextmenu'] as any,
111
+ actionRelevancyCheckFunc: (actionData: any) => actionData.item.enabled,
112
+ actionFunc: async (actionData: any) => {
113
+ await appstate.routeManagementStatePart.dispatchAction(appstate.updateGatewayClientAction, {
114
+ id: actionData.item.id,
115
+ enabled: false,
116
+ });
117
+ },
118
+ },
119
+ {
120
+ name: 'Delete',
121
+ iconName: 'lucide:trash2',
122
+ type: ['inRow', 'contextmenu'] as any,
123
+ actionFunc: async (actionData: any) => {
124
+ await appstate.routeManagementStatePart.dispatchAction(appstate.deleteGatewayClientAction, actionData.item.id);
125
+ },
126
+ },
127
+ ]}
128
+ ></dees-table>
129
+ `;
130
+ }
131
+
132
+ private renderPills(values: string[]): TemplateResult {
133
+ if (!values.length) return html`<span>None</span>`;
134
+ return html`${values.map((value) => html`<span class="pill">${value}</span>`)}`;
135
+ }
136
+
137
+ private renderTargets(targets: interfaces.data.IGatewayClient['allowedRouteTargets']): TemplateResult {
138
+ if (!targets.length) return html`<span>None</span>`;
139
+ return html`${targets.map((target) => html`<span class="pill">${target.host}:${target.ports.join(',')}</span>`)}`;
140
+ }
141
+
142
+ private async showCreateClientDialog(): Promise<void> {
143
+ const { DeesModal } = await import('@design.estate/dees-catalog');
144
+ await DeesModal.createAndShow({
145
+ heading: 'Create Gateway Client',
146
+ content: html`
147
+ <dees-form>
148
+ <dees-input-text .key=${'name'} .label=${'Name'} .required=${true}></dees-input-text>
149
+ <dees-input-text .key=${'type'} .label=${'Type'} .value=${'onebox'} .description=${'onebox, cloudly, or custom'}></dees-input-text>
150
+ <dees-input-text .key=${'id'} .label=${'Client ID'} .description=${'Optional stable ID; generated when empty'}></dees-input-text>
151
+ <dees-input-text .key=${'hostnamePatterns'} .label=${'Hostname Patterns'} .description=${'Comma separated, e.g. *.apps.example.com'}></dees-input-text>
152
+ <dees-input-text .key=${'allowedRouteTarget'} .label=${'Allowed Route Target'} .description=${'Optional host:ports, e.g. onebox-smartproxy:80'}></dees-input-text>
153
+ <dees-input-text .key=${'description'} .label=${'Description'}></dees-input-text>
154
+ </dees-form>
155
+ `,
156
+ menuOptions: [
157
+ { name: 'Cancel', iconName: 'lucide:x', action: async (modalArg: any) => await modalArg.destroy() },
158
+ {
159
+ name: 'Create',
160
+ iconName: 'lucide:plus',
161
+ action: async (modalArg: any) => {
162
+ const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form');
163
+ if (!form) return;
164
+ const formData = await (form as any).collectFormData();
165
+ const name = String(formData.name || '').trim();
166
+ if (!name) return;
167
+ await modalArg.destroy();
168
+ await appstate.createGatewayClient({
169
+ id: String(formData.id || '').trim() || undefined,
170
+ type: this.normalizeClientType(String(formData.type || 'onebox')),
171
+ name,
172
+ description: String(formData.description || '').trim() || undefined,
173
+ hostnamePatterns: this.parseList(String(formData.hostnamePatterns || '')),
174
+ allowedRouteTargets: this.parseAllowedRouteTargets(String(formData.allowedRouteTarget || '')),
175
+ });
176
+ await appstate.routeManagementStatePart.dispatchAction(appstate.fetchGatewayClientsAction, null);
177
+ },
178
+ },
179
+ ],
180
+ });
181
+ }
182
+
183
+ private async showCreateTokenDialog(client: interfaces.data.IGatewayClient): Promise<void> {
184
+ const { DeesModal } = await import('@design.estate/dees-catalog');
185
+ await DeesModal.createAndShow({
186
+ heading: `Create Token for ${client.name}`,
187
+ content: html`
188
+ <div style="color: #888; margin-bottom: 12px; font-size: 13px;">
189
+ The token will be shown once. Configure Onebox with the dcrouter URL and this token.
190
+ </div>
191
+ <dees-form>
192
+ <dees-input-text .key=${'name'} .label=${'Token Name'} .value=${`${client.name} Token`}></dees-input-text>
193
+ <dees-input-text .key=${'expiresInDays'} .label=${'Expires in'} .description=${'Number of days; leave blank for no expiration'}></dees-input-text>
194
+ </dees-form>
195
+ `,
196
+ menuOptions: [
197
+ { name: 'Cancel', iconName: 'lucide:x', action: async (modalArg: any) => await modalArg.destroy() },
198
+ {
199
+ name: 'Create Token',
200
+ iconName: 'lucide:key',
201
+ action: async (modalArg: any) => {
202
+ const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form');
203
+ if (!form) return;
204
+ const formData = await (form as any).collectFormData();
205
+ const expiresInDays = formData.expiresInDays ? parseInt(formData.expiresInDays, 10) : null;
206
+ await modalArg.destroy();
207
+ const response = await appstate.createGatewayClientToken(
208
+ client.id,
209
+ String(formData.name || '').trim() || undefined,
210
+ expiresInDays,
211
+ );
212
+ await appstate.routeManagementStatePart.dispatchAction(appstate.fetchGatewayClientsAction, null);
213
+ if (response.success && response.tokenValue) {
214
+ await DeesModal.createAndShow({
215
+ heading: 'Gateway Client Token Created',
216
+ content: html`
217
+ <p>Copy this token now. It will not be shown again.</p>
218
+ <div style="background: #111; padding: 12px; border-radius: 6px; margin-top: 8px;">
219
+ <code style="color: #0f8; word-break: break-all; font-size: 13px;">${response.tokenValue}</code>
220
+ </div>
221
+ `,
222
+ menuOptions: [
223
+ { name: 'Done', iconName: 'lucide:check', action: async (m: any) => await m.destroy() },
224
+ ],
225
+ });
226
+ }
227
+ },
228
+ },
229
+ ],
230
+ });
231
+ }
232
+
233
+ private normalizeClientType(value: string): interfaces.data.IGatewayClient['type'] {
234
+ const normalized = value.trim().toLowerCase();
235
+ if (normalized === 'cloudly' || normalized === 'custom') return normalized;
236
+ return 'onebox';
237
+ }
238
+
239
+ private parseList(value: string): string[] {
240
+ return value.split(',').map((entry) => entry.trim()).filter(Boolean);
241
+ }
242
+
243
+ private parseAllowedRouteTargets(value: string): interfaces.data.IGatewayClient['allowedRouteTargets'] {
244
+ const target = value.trim();
245
+ if (!target.includes(':')) return [];
246
+ const [host, portsValue] = target.split(':');
247
+ const ports = portsValue.split(',').map((port) => Number(port.trim())).filter((port) => Number.isInteger(port));
248
+ return host.trim() && ports.length ? [{ host: host.trim(), ports }] : [];
249
+ }
250
+ }
@@ -11,6 +11,7 @@ import * as appstate from '../../appstate.js';
11
11
  import * as interfaces from '../../../dist_ts_interfaces/index.js';
12
12
  import { viewHostCss } from '../shared/css.js';
13
13
  import { type IStatsTile } from '@design.estate/dees-catalog';
14
+ import { appRouter } from '../../router.js';
14
15
 
15
16
  declare global {
16
17
  interface HTMLElementTagNameMap {
@@ -26,6 +27,9 @@ export class OpsViewCertificates extends DeesElement {
26
27
  @state()
27
28
  accessor acmeState: appstate.IAcmeConfigState = appstate.acmeConfigStatePart.getState()!;
28
29
 
30
+ @state()
31
+ accessor domainsState: appstate.IDomainsState = appstate.domainsStatePart.getState()!;
32
+
29
33
  constructor() {
30
34
  super();
31
35
  const certSub = appstate.certificateStatePart.select().subscribe((newState) => {
@@ -36,12 +40,19 @@ export class OpsViewCertificates extends DeesElement {
36
40
  this.acmeState = newState;
37
41
  });
38
42
  this.rxSubscriptions.push(acmeSub);
43
+ const domainsSub = appstate.domainsStatePart.select().subscribe((newState) => {
44
+ this.domainsState = newState;
45
+ });
46
+ this.rxSubscriptions.push(domainsSub);
39
47
  }
40
48
 
41
49
  async connectedCallback() {
42
50
  await super.connectedCallback();
43
- await appstate.certificateStatePart.dispatchAction(appstate.fetchCertificateOverviewAction, null);
44
- await appstate.acmeConfigStatePart.dispatchAction(appstate.fetchAcmeConfigAction, null);
51
+ await Promise.all([
52
+ appstate.certificateStatePart.dispatchAction(appstate.fetchCertificateOverviewAction, null),
53
+ appstate.acmeConfigStatePart.dispatchAction(appstate.fetchAcmeConfigAction, null),
54
+ appstate.domainsStatePart.dispatchAction(appstate.fetchDomainsAndProvidersAction, null),
55
+ ]);
45
56
  }
46
57
 
47
58
  public static styles = [
@@ -127,10 +138,16 @@ export class OpsViewCertificates extends DeesElement {
127
138
  .errorText {
128
139
  font-size: 12px;
129
140
  color: ${cssManager.bdTheme('#991b1b', '#f87171')};
130
- max-width: 200px;
131
- overflow: hidden;
132
- text-overflow: ellipsis;
133
- white-space: nowrap;
141
+ max-width: 420px;
142
+ line-height: 1.35;
143
+ white-space: normal;
144
+ }
145
+
146
+ .errorStack {
147
+ display: flex;
148
+ flex-direction: column;
149
+ align-items: flex-start;
150
+ gap: 4px;
134
151
  }
135
152
 
136
153
  .backoffIndicator {
@@ -160,6 +177,39 @@ export class OpsViewCertificates extends DeesElement {
160
177
  .expiryInfo .daysLeft.danger {
161
178
  color: ${cssManager.bdTheme('#991b1b', '#f87171')};
162
179
  }
180
+
181
+ .dnsWarningPanel {
182
+ border: 1px solid ${cssManager.bdTheme('#fed7aa', '#7c2d12')};
183
+ border-radius: 12px;
184
+ padding: 16px;
185
+ background: ${cssManager.bdTheme('#fff7ed', '#1c1917')};
186
+ color: ${cssManager.bdTheme('#7c2d12', '#fdba74')};
187
+ }
188
+
189
+ .dnsWarningTitle {
190
+ font-weight: 700;
191
+ margin-bottom: 6px;
192
+ }
193
+
194
+ .dnsWarningText {
195
+ font-size: 13px;
196
+ line-height: 1.45;
197
+ color: ${cssManager.bdTheme('#9a3412', '#fed7aa')};
198
+ }
199
+
200
+ .dnsWarningList {
201
+ margin: 12px 0 0;
202
+ padding-left: 18px;
203
+ font-size: 13px;
204
+ line-height: 1.5;
205
+ }
206
+
207
+ .dnsWarningActions {
208
+ display: flex;
209
+ flex-wrap: wrap;
210
+ gap: 8px;
211
+ margin-top: 14px;
212
+ }
163
213
  `,
164
214
  ];
165
215
 
@@ -172,11 +222,102 @@ export class OpsViewCertificates extends DeesElement {
172
222
  <div class="certificatesContainer">
173
223
  ${this.renderStatsTiles(summary)}
174
224
  ${this.renderAcmeSettingsTile()}
225
+ ${this.renderManagedDomainWarnings()}
175
226
  ${this.renderCertificateTable()}
176
227
  </div>
177
228
  `;
178
229
  }
179
230
 
231
+ private renderManagedDomainWarnings(): TemplateResult {
232
+ const issues = this.getMissingManagedDomainIssues();
233
+ if (issues.length === 0) {
234
+ return html``;
235
+ }
236
+
237
+ const shownIssues = issues.slice(0, 6);
238
+ const remaining = issues.length - shownIssues.length;
239
+
240
+ return html`
241
+ <div class="dnsWarningPanel">
242
+ <div class="dnsWarningTitle">DNS-01 certificate provisioning needs managed DNS domains</div>
243
+ <div class="dnsWarningText">
244
+ DcRouter can only create ACME TXT records for domains listed under Domains > Domains.
245
+ Add the zone directly or import it from a DNS provider before reprovisioning certificates.
246
+ </div>
247
+ <ul class="dnsWarningList">
248
+ ${shownIssues.map((issue) => html`
249
+ <li>
250
+ <strong>${issue.domain}</strong>: no managed DNS domain covers
251
+ <code>${issue.challengeHost}</code>. Add/import <code>${issue.requiredDomain}</code>
252
+ or a parent zone.
253
+ </li>
254
+ `)}
255
+ ${remaining > 0 ? html`<li>${remaining} more domain${remaining === 1 ? '' : 's'} need managed DNS.</li>` : ''}
256
+ </ul>
257
+ <div class="dnsWarningActions">
258
+ <dees-button @click=${() => appRouter.navigateToView('domains', 'domains')}>Manage Domains</dees-button>
259
+ <dees-button @click=${() => appRouter.navigateToView('domains', 'providers')}>DNS Providers</dees-button>
260
+ </div>
261
+ </div>
262
+ `;
263
+ }
264
+
265
+ private getMissingManagedDomainIssues(): Array<{
266
+ domain: string;
267
+ challengeHost: string;
268
+ requiredDomain: string;
269
+ }> {
270
+ const managedDomains = this.domainsState.domains
271
+ .map((domain) => this.normalizeDomain(domain.name))
272
+ .filter(Boolean);
273
+ const issues: Array<{ domain: string; challengeHost: string; requiredDomain: string }> = [];
274
+ const seen = new Set<string>();
275
+
276
+ for (const cert of this.certState.certificates) {
277
+ if (!cert.canReprovision || (cert.source !== 'acme' && cert.source !== 'provision-function')) {
278
+ continue;
279
+ }
280
+
281
+ const requiredDomain = this.getAcmeChallengeDomain(cert.domain);
282
+ if (!requiredDomain) {
283
+ continue;
284
+ }
285
+
286
+ const covered = managedDomains.some((managedDomain) =>
287
+ requiredDomain === managedDomain || requiredDomain.endsWith(`.${managedDomain}`),
288
+ );
289
+ if (covered) {
290
+ continue;
291
+ }
292
+
293
+ const key = `${cert.domain}:${requiredDomain}`;
294
+ if (seen.has(key)) {
295
+ continue;
296
+ }
297
+ seen.add(key);
298
+ issues.push({
299
+ domain: cert.domain,
300
+ challengeHost: `_acme-challenge.${requiredDomain}`,
301
+ requiredDomain,
302
+ });
303
+ }
304
+
305
+ return issues;
306
+ }
307
+
308
+ private getAcmeChallengeDomain(domain: string): string {
309
+ const normalized = this.normalizeDomain(domain).replace(/^\*\.?/, '');
310
+ const parts = normalized.split('.').filter(Boolean);
311
+ if (parts.length >= 2 && parts.length <= 3) {
312
+ return parts.slice(-2).join('.');
313
+ }
314
+ return normalized;
315
+ }
316
+
317
+ private normalizeDomain(domain: string): string {
318
+ return domain.trim().toLowerCase().replace(/^\*\.?/, '').replace(/\.$/, '');
319
+ }
320
+
180
321
  private renderAcmeSettingsTile(): TemplateResult {
181
322
  const config = this.acmeState.config;
182
323
 
@@ -349,11 +490,7 @@ export class OpsViewCertificates extends DeesElement {
349
490
  Status: this.renderStatusBadge(cert.status),
350
491
  Source: this.renderSourceBadge(cert.source),
351
492
  Expires: this.renderExpiry(cert.expiryDate),
352
- Error: cert.backoffInfo
353
- ? html`<span class="backoffIndicator">${cert.backoffInfo.failures} failures, retry ${this.formatRetryTime(cert.backoffInfo.retryAfter)}</span>`
354
- : cert.error
355
- ? html`<span class="errorText" title="${cert.error}">${cert.error}</span>`
356
- : '',
493
+ Error: this.renderError(cert),
357
494
  })}
358
495
  .dataActions=${[
359
496
  {
@@ -632,6 +769,24 @@ export class OpsViewCertificates extends DeesElement {
632
769
  `;
633
770
  }
634
771
 
772
+ private renderError(cert: interfaces.requests.ICertificateInfo): TemplateResult | string {
773
+ if (cert.backoffInfo) {
774
+ const message = cert.backoffInfo.lastError || cert.error;
775
+ return html`
776
+ <span class="errorStack">
777
+ ${message ? html`<span class="errorText" title=${message}>${message}</span>` : ''}
778
+ <span class="backoffIndicator">
779
+ ${cert.backoffInfo.failures} failure${cert.backoffInfo.failures === 1 ? '' : 's'}, retry ${this.formatRetryTime(cert.backoffInfo.retryAfter)}
780
+ </span>
781
+ </span>
782
+ `;
783
+ }
784
+ if (cert.error) {
785
+ return html`<span class="errorText" title=${cert.error}>${cert.error}</span>`;
786
+ }
787
+ return '';
788
+ }
789
+
635
790
  private formatRetryTime(retryAfter?: string): string {
636
791
  if (!retryAfter) return 'soon';
637
792
  const retryDate = new Date(retryAfter);
@@ -129,6 +129,7 @@ export class OpsViewRoutes extends DeesElement {
129
129
  mergedRoutes: [],
130
130
  warnings: [],
131
131
  apiTokens: [],
132
+ gatewayClients: [],
132
133
  isLoading: false,
133
134
  error: null,
134
135
  lastUpdated: 0,
@@ -35,6 +35,7 @@ import { OpsViewEmailSecurity } from './email/ops-view-email-security.js';
35
35
  import { OpsViewEmailDomains } from './email/ops-view-email-domains.js';
36
36
 
37
37
  // Access group
38
+ import { OpsViewGatewayClients } from './access/ops-view-gatewayclients.js';
38
39
  import { OpsViewApiTokens } from './access/ops-view-apitokens.js';
39
40
  import { OpsViewUsers } from './access/ops-view-users.js';
40
41
 
@@ -65,6 +66,9 @@ export class OpsDashboard extends DeesElement {
65
66
  isLoggedIn: false,
66
67
  };
67
68
 
69
+ private bootstrapStepper?: any;
70
+ private bootstrapCheckPromise?: Promise<void>;
71
+
68
72
  @state() accessor uiState: appstate.IUiState = {
69
73
  activeView: 'overview',
70
74
  activeSubview: null,
@@ -121,6 +125,7 @@ export class OpsDashboard extends DeesElement {
121
125
  name: 'Access',
122
126
  iconName: 'lucide:keyRound',
123
127
  subViews: [
128
+ { slug: 'gatewayclients', name: 'Gateway Clients', iconName: 'lucide:plugZap', element: OpsViewGatewayClients },
124
129
  { slug: 'apitokens', name: 'API Tokens', iconName: 'lucide:key', element: OpsViewApiTokens },
125
130
  { slug: 'users', name: 'Users', iconName: 'lucide:users', element: OpsViewUsers },
126
131
  ],
@@ -334,6 +339,7 @@ export class OpsDashboard extends DeesElement {
334
339
  await (simpleLogin as any).switchToSlottedContent();
335
340
  await appstate.statsStatePart.dispatchAction(appstate.fetchAllStatsAction, null);
336
341
  await appstate.configStatePart.dispatchAction(appstate.fetchConfigurationAction, null);
342
+ await this.ensureAdminBootstrap();
337
343
  } else {
338
344
  // Server rejected the JWT — clear state, show login
339
345
  await appstate.loginStatePart.dispatchAction(appstate.logoutAction, null);
@@ -368,10 +374,106 @@ export class OpsDashboard extends DeesElement {
368
374
  await simpleLogin!.switchToSlottedContent();
369
375
  await appstate.statsStatePart.dispatchAction(appstate.fetchAllStatsAction, null);
370
376
  await appstate.configStatePart.dispatchAction(appstate.fetchConfigurationAction, null);
377
+ await this.ensureAdminBootstrap();
371
378
  } else {
372
379
  form!.setStatus('error', 'Login failed!');
373
380
  await domtools.convenience.smartdelay.delayFor(2000);
374
381
  form!.reset();
375
382
  }
376
383
  }
384
+
385
+ private async ensureAdminBootstrap(): Promise<void> {
386
+ if (!this.loginState.identity || this.bootstrapStepper?.isConnected) {
387
+ return;
388
+ }
389
+ if (this.bootstrapCheckPromise) {
390
+ return this.bootstrapCheckPromise;
391
+ }
392
+
393
+ this.bootstrapCheckPromise = (async () => {
394
+ try {
395
+ const status = await appstate.getAdminBootstrapStatus();
396
+ if (status.needsBootstrap) {
397
+ await this.showAdminBootstrapStepper(status);
398
+ }
399
+ } catch (error) {
400
+ console.error('Admin bootstrap status check failed:', error);
401
+ } finally {
402
+ this.bootstrapCheckPromise = undefined;
403
+ }
404
+ })();
405
+
406
+ return this.bootstrapCheckPromise;
407
+ }
408
+
409
+ private async showAdminBootstrapStepper(statusArg: appstate.IAdminBootstrapStatus): Promise<void> {
410
+ const { DeesStepper } = await import('@design.estate/dees-catalog');
411
+ this.bootstrapStepper = await DeesStepper.createAndShow({
412
+ cancelable: false,
413
+ steps: [
414
+ {
415
+ title: 'Create Persisted Admin',
416
+ content: html`
417
+ <div style="display: grid; gap: 16px; color: var(--dees-color-text-secondary); font-size: 14px; line-height: 1.5;">
418
+ <p style="margin: 0;">
419
+ This router is currently using the temporary bootstrap admin. Create the first persisted admin account to continue.
420
+ </p>
421
+ <dees-form>
422
+ <dees-input-text .key=${'email'} .label=${'Admin email'} .required=${true}></dees-input-text>
423
+ <dees-input-text .key=${'name'} .label=${'Display name'}></dees-input-text>
424
+ <dees-input-text .key=${'password'} .label=${'Password'} .required=${true} .isPasswordBool=${true}></dees-input-text>
425
+ <dees-input-text .key=${'passwordConfirm'} .label=${'Confirm password'} .required=${true} .isPasswordBool=${true}></dees-input-text>
426
+ <dees-input-checkbox
427
+ .key=${'enableIdpGlobalAuth'}
428
+ .label=${'Allow idp.global login for this email'}
429
+ .description=${statusArg.idpGlobalConfigured
430
+ ? 'The local account remains authoritative; idp.global only verifies identity.'
431
+ : 'Requires DCROUTER_IDP_GLOBAL_URL before idp.global logins can work.'}
432
+ ></dees-input-checkbox>
433
+ </dees-form>
434
+ </div>
435
+ `,
436
+ menuOptions: [
437
+ {
438
+ name: 'Create admin',
439
+ action: async (stepperArg: any) => {
440
+ const form = stepperArg.shadowRoot?.querySelector('.selected dees-form') as any;
441
+ if (!form) return;
442
+ const formData = await form.collectFormData();
443
+ const email = String(formData.email || '').trim();
444
+ const name = String(formData.name || '').trim();
445
+ const password = String(formData.password || '');
446
+ const passwordConfirm = String(formData.passwordConfirm || '');
447
+
448
+ if (!email || !password) {
449
+ form.setStatus?.('error', 'Email and password are required.');
450
+ return;
451
+ }
452
+ if (password !== passwordConfirm) {
453
+ form.setStatus?.('error', 'Passwords do not match.');
454
+ return;
455
+ }
456
+
457
+ try {
458
+ form.setStatus?.('pending', 'Creating persisted admin...');
459
+ await appstate.createInitialAdminUser({
460
+ email,
461
+ name,
462
+ password,
463
+ enableIdpGlobalAuth: Boolean(formData.enableIdpGlobalAuth),
464
+ });
465
+ form.setStatus?.('success', 'Persisted admin created.');
466
+ await stepperArg.destroy();
467
+ this.bootstrapStepper = undefined;
468
+ await appstate.usersStatePart.dispatchAction(appstate.fetchUsersAction, null);
469
+ } catch (error) {
470
+ form.setStatus?.('error', error instanceof Error ? error.message : 'Failed to create admin.');
471
+ }
472
+ },
473
+ },
474
+ ],
475
+ },
476
+ ],
477
+ });
478
+ }
377
479
  }
package/ts_web/router.ts CHANGED
@@ -11,7 +11,7 @@ const subviewMap: Record<string, readonly string[]> = {
11
11
  overview: ['stats', 'configuration'] as const,
12
12
  network: ['activity', 'routes', 'sourceprofiles', 'networktargets', 'targetprofiles', 'remoteingress', 'vpn'] as const,
13
13
  email: ['log', 'security', 'domains'] as const,
14
- access: ['apitokens', 'users'] as const,
14
+ access: ['gatewayclients', 'apitokens', 'users'] as const,
15
15
  security: ['overview', 'blocked', 'authentication'] as const,
16
16
  domains: ['providers', 'domains', 'dns', 'certificates'] as const,
17
17
  };
@@ -21,7 +21,7 @@ const defaultSubview: Record<string, string> = {
21
21
  overview: 'stats',
22
22
  network: 'activity',
23
23
  email: 'log',
24
- access: 'apitokens',
24
+ access: 'gatewayclients',
25
25
  security: 'overview',
26
26
  domains: 'domains',
27
27
  };