@serve.zone/dcrouter 13.29.1 → 13.31.0

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.
package/readme.md CHANGED
@@ -73,10 +73,22 @@ await router.start();
73
73
  After startup:
74
74
 
75
75
  - open the dashboard at `http://localhost:3000`
76
- - log in with the current built-in development credentials `admin` / `admin`
76
+ - complete the first-admin bootstrap flow if no persisted admin account exists yet
77
77
  - send proxied traffic to `http://localhost:18080`
78
78
  - stop gracefully with `await router.stop()`
79
79
 
80
+ ## Initial Admin Bootstrap
81
+
82
+ When DB-backed persistence is enabled and no persisted admin exists, dcrouter does not auto-create an admin account. The Ops dashboard exposes a non-cancelable first-admin bootstrap flow that must be completed explicitly.
83
+
84
+ Bootstrap behavior:
85
+
86
+ - `getAdminBootstrapStatus` reports whether persistence is ready and whether a first admin is required.
87
+ - The temporary env/config admin identity is only used to authorize bootstrap access while no persisted admin exists.
88
+ - `createInitialAdminUser` creates the first persisted admin with normalized email and local password authentication.
89
+ - Optional `idp.global` authentication can be enabled for that local account. The hosted `https://idp.global` endpoint is used by default, `adminAuth.idpGlobalUrl` or `DCROUTER_IDP_GLOBAL_URL` only override it, and the local dcrouter role remains authoritative.
90
+ - After a persisted admin exists, temporary bootstrap admin login is rejected and normal persisted-account authentication is used.
91
+
80
92
  ## Configuration Model
81
93
 
82
94
  `DcRouter` is configured with `IDcRouterOptions` from `@serve.zone/dcrouter`.
@@ -199,7 +211,7 @@ const client = new DcRouterApiClient({
199
211
  baseUrl: 'https://dcrouter.example.com',
200
212
  });
201
213
 
202
- await client.login('admin', 'admin');
214
+ await client.login('admin@example.com', 'strong-password');
203
215
 
204
216
  const route = await client.routes.build()
205
217
  .setName('api-gateway')
@@ -279,7 +291,7 @@ Use of these trademarks must comply with Task Venture Capital GmbH's Trademark G
279
291
 
280
292
  ### Company Information
281
293
 
282
- Task Venture Capital GmbH
294
+ Task Venture Capital GmbH
283
295
  Registered at District Court Bremen HRB 35230 HB, Germany
284
296
 
285
297
  For any legal inquiries or further information, please contact us via email at hello@task.vc.
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@serve.zone/dcrouter',
6
- version: '13.29.1',
6
+ version: '13.31.0',
7
7
  description: 'A multifaceted routing service handling mail and SMS delivery functions.'
8
8
  }
@@ -169,7 +169,7 @@ export interface IDcRouterOptions {
169
169
 
170
170
  /** Optional OpsServer account authentication settings. */
171
171
  adminAuth?: {
172
- /** Base URL for idp.global password authentication. Can also be set through DCROUTER_IDP_GLOBAL_URL. */
172
+ /** Optional idp.global password-authentication URL override. Defaults to the SDK's hosted https://idp.global endpoint. Can also be set through DCROUTER_IDP_GLOBAL_URL. */
173
173
  idpGlobalUrl?: string;
174
174
  /** Test/integration hook for injecting an idp.global-compatible password client. */
175
175
  idpClient?: Pick<plugins.idpSdkServer.IdpGlobalServerClient, 'loginWithEmailAndPassword' | 'stop'>;
@@ -159,6 +159,93 @@ export class AdminHandler {
159
159
  throw new plugins.typedrequest.TypedResponseError((error as Error).message || 'failed to create initial admin');
160
160
  }
161
161
  }
162
+
163
+ public async createUser(optionsArg: {
164
+ email: string;
165
+ name?: string;
166
+ role: interfaces.requests.TUserManagementRole;
167
+ password: string;
168
+ enableIdpGlobalAuth?: boolean;
169
+ }): Promise<interfaces.requests.IReq_CreateUser['response']> {
170
+ const store = this.getAccountStore();
171
+ if (!store) {
172
+ return { success: false, message: 'database is not ready' };
173
+ }
174
+ if (!(await store.hasActiveAdminAccount())) {
175
+ return { success: false, message: 'initial admin bootstrap is required before creating users' };
176
+ }
177
+
178
+ const role = optionsArg.role;
179
+ if (role !== 'admin' && role !== 'user') {
180
+ return { success: false, message: 'role must be admin or user' };
181
+ }
182
+
183
+ const password = String(optionsArg.password || '');
184
+ if (!password) {
185
+ return { success: false, message: 'password is required' };
186
+ }
187
+
188
+ const authSources: Array<'local' | 'idp.global'> = ['local'];
189
+ if (optionsArg.enableIdpGlobalAuth) {
190
+ authSources.push('idp.global');
191
+ }
192
+
193
+ try {
194
+ const email = String(optionsArg.email || '').trim();
195
+ const account = await store.createAccount({
196
+ email,
197
+ name: String(optionsArg.name || '').trim() || email,
198
+ role,
199
+ authSources,
200
+ password,
201
+ });
202
+ return { success: true, user: this.accountToUser(account) };
203
+ } catch (error) {
204
+ return { success: false, message: (error as Error).message || 'failed to create user' };
205
+ }
206
+ }
207
+
208
+ public async deleteUser(optionsArg: {
209
+ id: string;
210
+ requestingUserId: string;
211
+ }): Promise<interfaces.requests.IReq_DeleteUser['response']> {
212
+ const store = this.getAccountStore();
213
+ if (!store) {
214
+ return { success: false, message: 'database is not ready' };
215
+ }
216
+ if (!(await store.hasActiveAdminAccount())) {
217
+ return { success: false, message: 'initial admin bootstrap is required before deleting users' };
218
+ }
219
+
220
+ const id = String(optionsArg.id || '').trim();
221
+ if (!id) {
222
+ return { success: false, message: 'user id is required' };
223
+ }
224
+ if (id === optionsArg.requestingUserId) {
225
+ return { success: false, message: 'cannot delete the current user' };
226
+ }
227
+
228
+ const account = await store.getAccountById(id);
229
+ if (!account) {
230
+ return { success: false, message: 'user not found' };
231
+ }
232
+
233
+ if (account.role === 'admin' && account.status === 'active') {
234
+ const activeAdmins = (await store.listAccounts()).filter(
235
+ (accountArg) => accountArg.role === 'admin' && accountArg.status === 'active',
236
+ );
237
+ if (activeAdmins.length <= 1) {
238
+ return { success: false, message: 'cannot delete the last active admin' };
239
+ }
240
+ }
241
+
242
+ const doc = await plugins.idpSdkServer.IdpSdkAccountDoc.findById(id);
243
+ if (!doc) {
244
+ return { success: false, message: 'user not found' };
245
+ }
246
+ await doc.delete();
247
+ return { success: true };
248
+ }
162
249
 
163
250
  private registerHandlers(): void {
164
251
  this.typedrouter.addTypedHandler(
@@ -420,23 +507,17 @@ export class AdminHandler {
420
507
  }
421
508
 
422
509
  const baseUrl = this.opsServerRef.dcRouterRef.options.adminAuth?.idpGlobalUrl || process.env.DCROUTER_IDP_GLOBAL_URL;
423
- if (!baseUrl) {
424
- return undefined;
425
- }
426
-
427
510
  if (!this.idpClient) {
428
- this.idpClient = new plugins.idpSdkServer.IdpGlobalServerClient({ baseUrl });
511
+ this.idpClient = baseUrl
512
+ ? new plugins.idpSdkServer.IdpGlobalServerClient({ baseUrl })
513
+ : new plugins.idpSdkServer.IdpGlobalServerClient({} as plugins.idpSdkServer.IIdpGlobalServerClientOptions);
429
514
  this.ownsIdpClient = true;
430
515
  }
431
516
  return this.idpClient;
432
517
  }
433
518
 
434
519
  private isIdpGlobalConfigured(): boolean {
435
- return !!(
436
- this.opsServerRef.dcRouterRef.options.adminAuth?.idpClient ||
437
- this.opsServerRef.dcRouterRef.options.adminAuth?.idpGlobalUrl ||
438
- process.env.DCROUTER_IDP_GLOBAL_URL
439
- );
520
+ return true;
440
521
  }
441
522
 
442
523
  private accountToUser(accountArg: plugins.idpSdkServer.IIdpSdkAccount): TAdminUser {
@@ -3,7 +3,7 @@ import type { OpsServer } from '../classes.opsserver.js';
3
3
  import * as interfaces from '../../../ts_interfaces/index.js';
4
4
 
5
5
  /**
6
- * Read-only handler for OpsServer user accounts. Registers on adminRouter,
6
+ * Handler for OpsServer user accounts. Registers on adminRouter,
7
7
  * so admin middleware enforces auth + role check before the handler runs.
8
8
  * User data is owned by AdminHandler; this handler just exposes a safe
9
9
  * projection of it via TypedRequest.
@@ -16,7 +16,7 @@ export class UsersHandler {
16
16
  private registerHandlers(): void {
17
17
  const router = this.opsServerRef.adminRouter;
18
18
 
19
- // List users (admin-only, read-only)
19
+ // List users (admin-only)
20
20
  router.addTypedHandler(
21
21
  new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ListUsers>(
22
22
  'listUsers',
@@ -26,5 +26,28 @@ export class UsersHandler {
26
26
  },
27
27
  ),
28
28
  );
29
+
30
+ router.addTypedHandler(
31
+ new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateUser>(
32
+ 'createUser',
33
+ async (dataArg) => this.opsServerRef.adminHandler.createUser({
34
+ email: dataArg.email,
35
+ name: dataArg.name,
36
+ role: dataArg.role,
37
+ password: dataArg.password,
38
+ enableIdpGlobalAuth: dataArg.enableIdpGlobalAuth,
39
+ }),
40
+ ),
41
+ );
42
+
43
+ router.addTypedHandler(
44
+ new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteUser>(
45
+ 'deleteUser',
46
+ async (dataArg) => this.opsServerRef.adminHandler.deleteUser({
47
+ id: dataArg.id,
48
+ requestingUserId: dataArg.identity.userId,
49
+ }),
50
+ ),
51
+ );
29
52
  }
30
53
  }
package/ts/readme.md CHANGED
@@ -91,7 +91,7 @@ Use of these trademarks must comply with Task Venture Capital GmbH's Trademark G
91
91
 
92
92
  ### Company Information
93
93
 
94
- Task Venture Capital GmbH
94
+ Task Venture Capital GmbH
95
95
  Registered at District Court Bremen HRB 35230 HB, Germany
96
96
 
97
97
  For any legal inquiries or further information, please contact us via email at hello@task.vc.
@@ -27,7 +27,7 @@ const client = new DcRouterApiClient({
27
27
  baseUrl: 'https://dcrouter.example.com',
28
28
  });
29
29
 
30
- await client.login('admin', 'admin');
30
+ await client.login('admin@example.com', 'strong-password');
31
31
 
32
32
  const { routes, warnings } = await client.routes.list();
33
33
  console.log(routes.length, warnings.length);
@@ -43,13 +43,13 @@ await route.toggle(true);
43
43
 
44
44
  ## Authentication
45
45
 
46
- The client supports session login and API-token authentication.
46
+ The client supports persisted-admin session login and API-token authentication. Initial admin creation is a bootstrap flow exposed by the Ops dashboard and raw TypedRequest contracts; after a persisted admin exists, use that account with `login()`.
47
47
 
48
48
  ```typescript
49
49
  const sessionClient = new DcRouterApiClient({
50
50
  baseUrl: 'https://dcrouter.example.com',
51
51
  });
52
- await sessionClient.login('admin', 'admin');
52
+ await sessionClient.login('admin@example.com', 'strong-password');
53
53
 
54
54
  const tokenClient = new DcRouterApiClient({
55
55
  baseUrl: 'https://dcrouter.example.com',
@@ -153,7 +153,7 @@ Use of these trademarks must comply with Task Venture Capital GmbH's Trademark G
153
153
 
154
154
  ### Company Information
155
155
 
156
- Task Venture Capital GmbH
156
+ Task Venture Capital GmbH
157
157
  Registered at District Court Bremen HRB 35230 HB, Germany
158
158
 
159
159
  For any legal inquiries or further information, please contact us via email at hello@task.vc.
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@serve.zone/dcrouter',
6
- version: '13.29.1',
6
+ version: '13.31.0',
7
7
  description: 'A multifaceted routing service handling mail and SMS delivery functions.'
8
8
  }
@@ -2637,7 +2637,7 @@ export async function createGatewayClientToken(
2637
2637
  });
2638
2638
  }
2639
2639
 
2640
- // Users (read-only list)
2640
+ // Users
2641
2641
  export const fetchUsersAction = usersStatePart.createAction(async (statePartArg): Promise<IUsersState> => {
2642
2642
  const context = getActionContext();
2643
2643
  const currentState = statePartArg.getState()!;
@@ -2666,6 +2666,74 @@ export const fetchUsersAction = usersStatePart.createAction(async (statePartArg)
2666
2666
  }
2667
2667
  });
2668
2668
 
2669
+ export const createUserAction = usersStatePart.createAction<{
2670
+ email: string;
2671
+ name?: string;
2672
+ role: interfaces.requests.TUserManagementRole;
2673
+ password: string;
2674
+ enableIdpGlobalAuth?: boolean;
2675
+ }>(async (statePartArg, dataArg, actionContext): Promise<IUsersState> => {
2676
+ const context = getActionContext();
2677
+ const currentState = statePartArg.getState()!;
2678
+ if (!context.identity) return currentState;
2679
+
2680
+ try {
2681
+ const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
2682
+ interfaces.requests.IReq_CreateUser
2683
+ >('/typedrequest', 'createUser');
2684
+
2685
+ const response = await request.fire({
2686
+ identity: context.identity,
2687
+ email: dataArg.email,
2688
+ name: dataArg.name,
2689
+ role: dataArg.role,
2690
+ password: dataArg.password,
2691
+ enableIdpGlobalAuth: dataArg.enableIdpGlobalAuth,
2692
+ });
2693
+
2694
+ if (!response.success) {
2695
+ throw new Error(response.message || 'Failed to create user');
2696
+ }
2697
+
2698
+ return await actionContext!.dispatch(fetchUsersAction, null);
2699
+ } catch (error) {
2700
+ return {
2701
+ ...currentState,
2702
+ error: error instanceof Error ? error.message : 'Failed to create user',
2703
+ };
2704
+ }
2705
+ });
2706
+
2707
+ export const deleteUserAction = usersStatePart.createAction<string>(
2708
+ async (statePartArg, userIdArg, actionContext): Promise<IUsersState> => {
2709
+ const context = getActionContext();
2710
+ const currentState = statePartArg.getState()!;
2711
+ if (!context.identity) return currentState;
2712
+
2713
+ try {
2714
+ const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
2715
+ interfaces.requests.IReq_DeleteUser
2716
+ >('/typedrequest', 'deleteUser');
2717
+
2718
+ const response = await request.fire({
2719
+ identity: context.identity,
2720
+ id: userIdArg,
2721
+ });
2722
+
2723
+ if (!response.success) {
2724
+ throw new Error(response.message || 'Failed to delete user');
2725
+ }
2726
+
2727
+ return await actionContext!.dispatch(fetchUsersAction, null);
2728
+ } catch (error) {
2729
+ return {
2730
+ ...currentState,
2731
+ error: error instanceof Error ? error.message : 'Failed to delete user',
2732
+ };
2733
+ }
2734
+ },
2735
+ );
2736
+
2669
2737
  export async function createApiToken(
2670
2738
  name: string,
2671
2739
  scopes: interfaces.data.TApiTokenScope[],
@@ -116,12 +116,31 @@ export class OpsViewUsers extends DeesElement {
116
116
  .showColumnFilters=${true}
117
117
  .displayFunction=${(user: appstate.IUser) => ({
118
118
  ID: html`<span class="userIdCell">${user.id}</span>`,
119
- Username: user.username,
119
+ Email: user.email || user.username,
120
+ Name: user.name || '',
120
121
  Role: this.renderRoleBadge(user.role),
122
+ Status: user.status || 'active',
123
+ Auth: (user.authSources || []).join(', ') || 'bootstrap',
121
124
  Session: user.id === currentUserId
122
125
  ? html`<span class="sessionBadge">current</span>`
123
126
  : '',
124
127
  })}
128
+ .dataActions=${[
129
+ {
130
+ name: 'Create User',
131
+ iconName: 'lucide:userPlus',
132
+ type: ['header'],
133
+ actionFunc: async () => await this.showCreateUserDialog(),
134
+ },
135
+ {
136
+ name: 'Delete',
137
+ iconName: 'lucide:trash2',
138
+ type: ['inRow', 'contextmenu'] as any,
139
+ actionFunc: async (actionData: any) => {
140
+ await this.showDeleteUserDialog(actionData.item as appstate.IUser);
141
+ },
142
+ },
143
+ ]}
125
144
  ></dees-table>
126
145
  </div>
127
146
  `;
@@ -132,6 +151,125 @@ export class OpsViewUsers extends DeesElement {
132
151
  return html`<span class="roleBadge ${cls}">${role}</span>`;
133
152
  }
134
153
 
154
+ private async showCreateUserDialog(): Promise<void> {
155
+ const { DeesModal, DeesToast } = await import('@design.estate/dees-catalog');
156
+
157
+ await DeesModal.createAndShow({
158
+ heading: 'Create User',
159
+ content: html`
160
+ <dees-form>
161
+ <dees-input-text .key=${'email'} .label=${'Email'} .required=${true}></dees-input-text>
162
+ <dees-input-text .key=${'name'} .label=${'Display name'}></dees-input-text>
163
+ <dees-input-dropdown
164
+ .key=${'role'}
165
+ .label=${'Role'}
166
+ .options=${[
167
+ { option: 'User', key: 'user' },
168
+ { option: 'Admin', key: 'admin' },
169
+ ]}
170
+ .selectedOption=${{ option: 'User', key: 'user' }}
171
+ .required=${true}
172
+ ></dees-input-dropdown>
173
+ <dees-input-text .key=${'password'} .label=${'Password'} .required=${true} .isPasswordBool=${true}></dees-input-text>
174
+ <dees-input-text .key=${'passwordConfirm'} .label=${'Confirm password'} .required=${true} .isPasswordBool=${true}></dees-input-text>
175
+ <dees-input-checkbox
176
+ .key=${'enableIdpGlobalAuth'}
177
+ .label=${'Allow idp.global login for this email'}
178
+ .description=${'Uses https://idp.global by default; the local dcrouter account and role remain authoritative.'}
179
+ ></dees-input-checkbox>
180
+ </dees-form>
181
+ `,
182
+ menuOptions: [
183
+ {
184
+ name: 'Cancel',
185
+ iconName: 'lucide:x',
186
+ action: async (modalArg: any) => await modalArg.destroy(),
187
+ },
188
+ {
189
+ name: 'Create',
190
+ iconName: 'lucide:userPlus',
191
+ action: async (modalArg: any) => {
192
+ const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form') as any;
193
+ if (!form) return;
194
+ const data = await form.collectFormData();
195
+ const email = String(data.email || '').trim();
196
+ const name = String(data.name || '').trim();
197
+ const password = String(data.password || '');
198
+ const passwordConfirm = String(data.passwordConfirm || '');
199
+ const roleValue = String(data.role?.key ?? data.role ?? 'user');
200
+
201
+ if (!email || !password) {
202
+ form.setStatus?.('error', 'Email and password are required.');
203
+ return;
204
+ }
205
+ if (password !== passwordConfirm) {
206
+ form.setStatus?.('error', 'Passwords do not match.');
207
+ return;
208
+ }
209
+
210
+ form.setStatus?.('pending', 'Creating user...');
211
+ await appstate.usersStatePart.dispatchAction(appstate.createUserAction, {
212
+ email,
213
+ name,
214
+ role: roleValue === 'admin' ? 'admin' : 'user',
215
+ password,
216
+ enableIdpGlobalAuth: Boolean(data.enableIdpGlobalAuth),
217
+ });
218
+
219
+ const state = appstate.usersStatePart.getState();
220
+ if (state?.error) {
221
+ form.setStatus?.('error', state.error);
222
+ return;
223
+ }
224
+
225
+ DeesToast.show({ message: `User created for ${email}`, type: 'success', duration: 3000 });
226
+ await modalArg.destroy();
227
+ },
228
+ },
229
+ ],
230
+ });
231
+ }
232
+
233
+ private async showDeleteUserDialog(userArg: appstate.IUser): Promise<void> {
234
+ const { DeesModal, DeesToast } = await import('@design.estate/dees-catalog');
235
+ const currentUserId = this.loginState.identity?.userId;
236
+ if (userArg.id === currentUserId) {
237
+ DeesToast.show({ message: 'You cannot delete the current user.', type: 'error', duration: 4000 });
238
+ return;
239
+ }
240
+
241
+ await DeesModal.createAndShow({
242
+ heading: 'Delete User',
243
+ content: html`
244
+ <div style="padding: 8px 0; font-size: 14px; line-height: 1.5;">
245
+ <p>Delete <strong>${userArg.email || userArg.username}</strong>?</p>
246
+ <p style="color: #f59e0b; margin-top: 12px;">This removes the local dcrouter account and cannot be undone.</p>
247
+ </div>
248
+ `,
249
+ menuOptions: [
250
+ {
251
+ name: 'Cancel',
252
+ iconName: 'lucide:x',
253
+ action: async (modalArg: any) => await modalArg.destroy(),
254
+ },
255
+ {
256
+ name: 'Delete',
257
+ iconName: 'lucide:trash2',
258
+ action: async (modalArg: any) => {
259
+ await appstate.usersStatePart.dispatchAction(appstate.deleteUserAction, userArg.id);
260
+ const state = appstate.usersStatePart.getState();
261
+ if (state?.error) {
262
+ DeesToast.show({ message: state.error, type: 'error', duration: 4000 });
263
+ return;
264
+ }
265
+ DeesToast.show({ message: 'User deleted.', type: 'success', duration: 3000 });
266
+ await modalArg.destroy();
267
+ },
268
+ },
269
+ ],
270
+ });
271
+ }
272
+
135
273
  async firstUpdated() {
136
274
  if (this.loginState.isLoggedIn) {
137
275
  await appstate.usersStatePart.dispatchAction(appstate.fetchUsersAction, null);
@@ -271,6 +271,7 @@ export class OpsViewRoutes extends DeesElement {
271
271
  const tags = [...(mr.route.tags || [])];
272
272
  tags.push(mr.origin);
273
273
  if (!mr.enabled) tags.push('disabled');
274
+ if (mr.route.vpnOnly) tags.push('vpn-only');
274
275
 
275
276
  return {
276
277
  ...mr.route,
@@ -360,6 +361,7 @@ export class OpsViewRoutes extends DeesElement {
360
361
  <div style="color: #ccc; padding: 8px 0;">
361
362
  <p>Origin: <strong style="color: #0af;">${merged.origin}</strong></p>
362
363
  <p>Status: <strong>${merged.enabled ? 'Enabled' : 'Disabled'}</strong></p>
364
+ ${merged.route.vpnOnly ? html`<p>Access: <strong style="color: #22c55e;">VPN only</strong></p>` : ''}
363
365
  <p>ID: <code style="color: #888;">${merged.id}</code></p>
364
366
  ${isSystemManaged ? html`<p>This route is system-managed. Change its source config to modify it directly.</p>` : ''}
365
367
  ${meta?.sourceProfileName ? html`<p>Source Profile: <strong style="color: #a78bfa;">${meta.sourceProfileName}</strong></p>` : ''}
@@ -491,6 +493,7 @@ export class OpsViewRoutes extends DeesElement {
491
493
  ? (Array.isArray(firstTarget.host) ? firstTarget.host[0] : firstTarget.host)
492
494
  : '';
493
495
  const currentTargetPort = typeof firstTarget?.port === 'number' ? String(firstTarget.port) : '';
496
+ const currentVpnOnly = route.vpnOnly === true;
494
497
  const currentRemoteIngressEnabled = route.remoteIngress?.enabled === true;
495
498
  const currentEdgeFilter = route.remoteIngress?.edgeFilter || [];
496
499
 
@@ -518,6 +521,7 @@ export class OpsViewRoutes extends DeesElement {
518
521
  <dees-input-text .key=${'targetHost'} .label=${'Target Host'} .description=${'Used when no network target is selected'} .value=${currentTargetHost}></dees-input-text>
519
522
  <dees-input-text .key=${'targetPort'} .label=${'Target Port'} .description=${'Used when no network target is selected'} .value=${currentTargetPort}></dees-input-text>
520
523
  <dees-input-checkbox .key=${'preserveMatchPort'} .label=${'Preserve incoming port'} .value=${currentPreserveMatchPort}></dees-input-checkbox>
524
+ <dees-input-checkbox .key=${'vpnOnly'} .label=${'VPN only'} .description=${'Only VPN clients with matching target profiles can access this route'} .value=${currentVpnOnly}></dees-input-checkbox>
521
525
  <dees-input-checkbox .key=${'remoteIngressEnabled'} .label=${'Enable Remote Ingress'} .value=${currentRemoteIngressEnabled}></dees-input-checkbox>
522
526
  <div class="remoteIngressGroup" style="display: ${currentRemoteIngressEnabled ? 'flex' : 'none'}; flex-direction: column; gap: 16px;">
523
527
  <dees-input-list .key=${'remoteIngressEdgeFilter'} .label=${'Edge Filter'} .description=${'Optional edge IDs or tags. Leave empty to allow all edges.'} .placeholder=${'Add edge ID or tag...'} .value=${currentEdgeFilter}></dees-input-list>
@@ -570,6 +574,7 @@ export class OpsViewRoutes extends DeesElement {
570
574
  const remoteIngressEdgeFilter: string[] = Array.isArray(formData.remoteIngressEdgeFilter)
571
575
  ? formData.remoteIngressEdgeFilter.filter(Boolean)
572
576
  : [];
577
+ const vpnOnly = Boolean(formData.vpnOnly);
573
578
 
574
579
  const updatedRoute: any = {
575
580
  name: formData.name,
@@ -586,6 +591,7 @@ export class OpsViewRoutes extends DeesElement {
586
591
  },
587
592
  ],
588
593
  },
594
+ vpnOnly: vpnOnly ? true : null,
589
595
  remoteIngress: remoteIngressEnabled
590
596
  ? {
591
597
  enabled: true,
@@ -684,6 +690,7 @@ export class OpsViewRoutes extends DeesElement {
684
690
  <dees-input-text .key=${'targetHost'} .label=${'Target Host'} .description=${'Used when no network target is selected'} .value=${'localhost'}></dees-input-text>
685
691
  <dees-input-text .key=${'targetPort'} .label=${'Target Port'} .description=${'Used when no network target is selected'}></dees-input-text>
686
692
  <dees-input-checkbox .key=${'preserveMatchPort'} .label=${'Preserve incoming port'} .value=${false}></dees-input-checkbox>
693
+ <dees-input-checkbox .key=${'vpnOnly'} .label=${'VPN only'} .description=${'Only VPN clients with matching target profiles can access this route'} .value=${false}></dees-input-checkbox>
687
694
  <dees-input-checkbox .key=${'remoteIngressEnabled'} .label=${'Enable Remote Ingress'} .value=${false}></dees-input-checkbox>
688
695
  <div class="remoteIngressGroup" style="display: none; flex-direction: column; gap: 16px;">
689
696
  <dees-input-list .key=${'remoteIngressEdgeFilter'} .label=${'Edge Filter'} .description=${'Optional edge IDs or tags. Leave empty to allow all edges.'} .placeholder=${'Add edge ID or tag...'}></dees-input-list>
@@ -736,6 +743,7 @@ export class OpsViewRoutes extends DeesElement {
736
743
  const remoteIngressEdgeFilter: string[] = Array.isArray(formData.remoteIngressEdgeFilter)
737
744
  ? formData.remoteIngressEdgeFilter.filter(Boolean)
738
745
  : [];
746
+ const vpnOnly = Boolean(formData.vpnOnly);
739
747
 
740
748
  const route: any = {
741
749
  name: formData.name,
@@ -752,6 +760,7 @@ export class OpsViewRoutes extends DeesElement {
752
760
  },
753
761
  ],
754
762
  },
763
+ ...(vpnOnly ? { vpnOnly: true } : {}),
755
764
  ...(remoteIngressEnabled
756
765
  ? {
757
766
  remoteIngress: {
@@ -426,9 +426,7 @@ export class OpsDashboard extends DeesElement {
426
426
  <dees-input-checkbox
427
427
  .key=${'enableIdpGlobalAuth'}
428
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.'}
429
+ .description=${'Uses https://idp.global by default; the local dcrouter account and role remain authoritative.'}
432
430
  ></dees-input-checkbox>
433
431
  </dees-form>
434
432
  </div>
package/ts_web/readme.md CHANGED
@@ -12,8 +12,8 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
12
12
  | --- | --- |
13
13
  | `index.ts` | Initializes the app router and renders `<ops-dashboard>` into `document.body`. |
14
14
  | `router.ts` | Defines top-level dashboard routes, subviews, redirects, and URL/state synchronization. |
15
- | `appstate.ts` | Holds reactive login, UI, config, stats, route, DNS, email, remote ingress, VPN, and log state. |
16
- | `elements/` | Contains the dashboard shell and feature-specific Dees web components. |
15
+ | `appstate.ts` | Holds reactive login, bootstrap, UI, config, stats, route, DNS, email, remote ingress, VPN, and log state. |
16
+ | `elements/` | Contains the dashboard shell, first-admin bootstrap stepper, and feature-specific Dees web components. |
17
17
 
18
18
  ## View Map
19
19
 
@@ -37,6 +37,8 @@ The dashboard talks to the dcrouter OpsServer through:
37
37
  - Dees web components and app-state subscriptions for UI updates
38
38
  - QR code rendering for VPN client UX
39
39
 
40
+ On a fresh DB-backed instance, the dashboard checks `getAdminBootstrapStatus` and shows a non-cancelable first-admin stepper before normal dashboard access.
41
+
40
42
  ## Usage
41
43
 
42
44
  This package is primarily consumed by the main dcrouter build and served by OpsServer. Install it directly only when you intentionally need the dashboard module boundary.
@@ -79,7 +81,7 @@ Use of these trademarks must comply with Task Venture Capital GmbH's Trademark G
79
81
 
80
82
  ### Company Information
81
83
 
82
- Task Venture Capital GmbH
84
+ Task Venture Capital GmbH
83
85
  Registered at District Court Bremen HRB 35230 HB, Germany
84
86
 
85
87
  For any legal inquiries or further information, please contact us via email at hello@task.vc.