From 7986d012453d87a5e436e7bdf8d2029278e012c6 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Tue, 19 May 2026 17:06:50 +0000 Subject: [PATCH] feat(opsserver): add admin user create/delete management and default hosted idp.global auth support --- changelog.md | 8 ++ package.json | 2 +- pnpm-lock.yaml | 10 +- readme.md | 2 +- test/test.admin-bootstrap.node.ts | 41 ++++++ ts/classes.dcrouter.ts | 2 +- ts/opsserver/handlers/admin.handler.ts | 101 +++++++++++++-- ts/opsserver/handlers/users.handler.ts | 27 +++- ts_interfaces/readme.md | 2 +- ts_interfaces/requests/users.ts | 45 ++++++- ts_web/appstate.ts | 70 ++++++++++- ts_web/elements/access/ops-view-users.ts | 140 ++++++++++++++++++++- ts_web/elements/network/ops-view-routes.ts | 9 ++ ts_web/elements/ops-dashboard.ts | 4 +- 14 files changed, 436 insertions(+), 27 deletions(-) diff --git a/changelog.md b/changelog.md index 6a262bc..48f8d2a 100644 --- a/changelog.md +++ b/changelog.md @@ -5,6 +5,14 @@ +### Features + +- add admin user create/delete management and default hosted idp.global auth support (opsserver) + - adds admin-only createUser and deleteUser typed requests with safeguards against deleting the current user or last active admin + - updates the ops users UI to create and delete users, show richer account details, and support optional idp.global login during account creation + - treats idp.global as available by default via the hosted https://idp.global endpoint while keeping URL settings as optional overrides + - adds VPN-only route controls and indicators in the ops routes UI + ## 2026-05-18 - 13.30.0 ### Features diff --git a/package.json b/package.json index c3a7aad..c7d02c2 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "@apiclient.xyz/cloudflare": "^7.1.0", "@design.estate/dees-catalog": "^3.81.0", "@design.estate/dees-element": "^2.2.4", - "@idp.global/sdk": "^1.3.0", + "@idp.global/sdk": "^1.3.1", "@push.rocks/lik": "^6.4.1", "@push.rocks/projectinfo": "^5.1.0", "@push.rocks/qenv": "^6.1.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7c1387f..a3ed4c1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -30,8 +30,8 @@ importers: specifier: ^2.2.4 version: 2.2.4 '@idp.global/sdk': - specifier: ^1.3.0 - version: 1.3.0(@push.rocks/smartserve@2.0.4)(socks@2.8.8) + specifier: ^1.3.1 + version: 1.3.1(@push.rocks/smartserve@2.0.4)(socks@2.8.8) '@push.rocks/lik': specifier: ^6.4.1 version: 6.4.1 @@ -753,8 +753,8 @@ packages: '@idp.global/interfaces@1.0.1': resolution: {integrity: sha512-PEA538+V2VUnKTkLbt66OWxsRZxWHuhC1nduzeFvBaOlH7EIXIZ0rA9D20JKtUZyucZJlXj1hFp2WCMUmgqPwQ==} - '@idp.global/sdk@1.3.0': - resolution: {integrity: sha512-ADxra57bBVHXrsOCrh6c82PhiJoxWZQ9LMPqkfWiQ/jspufo2WhSZIjm7G7C5SvFtGRmiYYJStKfwYBpCKO01w==} + '@idp.global/sdk@1.3.1': + resolution: {integrity: sha512-mZ7cRhbyaE7PoGo9WRJLNwQLIjs9mZxLK1HhVQntvf7hN+OrCRjpYfDn5/G3ub8tpTNq1trtROYNjlhbD/4GLw==} '@img/colour@1.1.0': resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} @@ -5381,7 +5381,7 @@ snapshots: '@api.global/typedrequest-interfaces': 3.0.19 '@tsclass/tsclass': 9.5.1 - '@idp.global/sdk@1.3.0(@push.rocks/smartserve@2.0.4)(socks@2.8.8)': + '@idp.global/sdk@1.3.1(@push.rocks/smartserve@2.0.4)(socks@2.8.8)': dependencies: '@api.global/typedrequest': 3.3.1 '@api.global/typedsocket': 4.1.3(@push.rocks/smartserve@2.0.4) diff --git a/readme.md b/readme.md index 1af8a1d..60664f6 100644 --- a/readme.md +++ b/readme.md @@ -86,7 +86,7 @@ Bootstrap behavior: - `getAdminBootstrapStatus` reports whether persistence is ready and whether a first admin is required. - The temporary env/config admin identity is only used to authorize bootstrap access while no persisted admin exists. - `createInitialAdminUser` creates the first persisted admin with normalized email and local password authentication. -- Optional `idp.global` authentication can be enabled for that local account; the local dcrouter role remains authoritative and the IdP email must match the local account email. +- 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. - After a persisted admin exists, temporary bootstrap admin login is rejected and normal persisted-account authentication is used. ## Configuration Model diff --git a/test/test.admin-bootstrap.node.ts b/test/test.admin-bootstrap.node.ts index 13b9c99..094f627 100644 --- a/test/test.admin-bootstrap.node.ts +++ b/test/test.admin-bootstrap.node.ts @@ -16,6 +16,7 @@ let testDb: DcRouterDb; let storagePath: string; let bootstrapIdentity: interfaces.data.IIdentity; let persistedIdentity: interfaces.data.IIdentity; +let createdUserId: string; const createStatusRequest = () => new TypedRequest( baseUrl, @@ -84,6 +85,7 @@ tap.test('reports bootstrap required without auto-persisting an admin', async () expect(status.hasPersistentAdmin).toEqual(false); expect(status.needsBootstrap).toEqual(true); expect(status.ephemeralAdminAvailable).toEqual(true); + expect(status.idpGlobalConfigured).toEqual(true); }); tap.test('allows temporary bootstrap admin login before persisted admin exists', async () => { @@ -183,6 +185,45 @@ tap.test('rejects idp.global login when IdP email does not match local account', expect(rejected).toEqual(true); }); +tap.test('creates a persisted non-admin user explicitly', async () => { + const request = new TypedRequest(baseUrl, 'createUser'); + const response = await request.fire({ + identity: persistedIdentity, + email: 'operator@example.com', + name: 'Operator User', + role: 'user', + password: 'operator-password', + }); + + expect(response.success).toEqual(true); + expect(response.user?.role).toEqual('user'); + expect(response.user?.email).toEqual('operator@example.com'); + if (!response.user?.id) { + throw new Error('Expected created user id'); + } + createdUserId = response.user.id; +}); + +tap.test('rejects deleting the current persisted admin user', async () => { + const request = new TypedRequest(baseUrl, 'deleteUser'); + const response = await request.fire({ + identity: persistedIdentity, + id: persistedIdentity.userId, + }); + + expect(response.success).toEqual(false); +}); + +tap.test('deletes a persisted non-current user', async () => { + const request = new TypedRequest(baseUrl, 'deleteUser'); + const response = await request.fire({ + identity: persistedIdentity, + id: createdUserId, + }); + + expect(response.success).toEqual(true); +}); + tap.test('lists persisted users without password material', async () => { const request = new TypedRequest(baseUrl, 'listUsers'); const response = await request.fire({ identity: persistedIdentity }); diff --git a/ts/classes.dcrouter.ts b/ts/classes.dcrouter.ts index 1525574..91aef6b 100644 --- a/ts/classes.dcrouter.ts +++ b/ts/classes.dcrouter.ts @@ -169,7 +169,7 @@ export interface IDcRouterOptions { /** Optional OpsServer account authentication settings. */ adminAuth?: { - /** Base URL for idp.global password authentication. Can also be set through DCROUTER_IDP_GLOBAL_URL. */ + /** 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. */ idpGlobalUrl?: string; /** Test/integration hook for injecting an idp.global-compatible password client. */ idpClient?: Pick; diff --git a/ts/opsserver/handlers/admin.handler.ts b/ts/opsserver/handlers/admin.handler.ts index f8db92b..2014014 100644 --- a/ts/opsserver/handlers/admin.handler.ts +++ b/ts/opsserver/handlers/admin.handler.ts @@ -159,6 +159,93 @@ export class AdminHandler { throw new plugins.typedrequest.TypedResponseError((error as Error).message || 'failed to create initial admin'); } } + + public async createUser(optionsArg: { + email: string; + name?: string; + role: interfaces.requests.TUserManagementRole; + password: string; + enableIdpGlobalAuth?: boolean; + }): Promise { + const store = this.getAccountStore(); + if (!store) { + return { success: false, message: 'database is not ready' }; + } + if (!(await store.hasActiveAdminAccount())) { + return { success: false, message: 'initial admin bootstrap is required before creating users' }; + } + + const role = optionsArg.role; + if (role !== 'admin' && role !== 'user') { + return { success: false, message: 'role must be admin or user' }; + } + + const password = String(optionsArg.password || ''); + if (!password) { + return { success: false, message: 'password is required' }; + } + + const authSources: Array<'local' | 'idp.global'> = ['local']; + if (optionsArg.enableIdpGlobalAuth) { + authSources.push('idp.global'); + } + + try { + const email = String(optionsArg.email || '').trim(); + const account = await store.createAccount({ + email, + name: String(optionsArg.name || '').trim() || email, + role, + authSources, + password, + }); + return { success: true, user: this.accountToUser(account) }; + } catch (error) { + return { success: false, message: (error as Error).message || 'failed to create user' }; + } + } + + public async deleteUser(optionsArg: { + id: string; + requestingUserId: string; + }): Promise { + const store = this.getAccountStore(); + if (!store) { + return { success: false, message: 'database is not ready' }; + } + if (!(await store.hasActiveAdminAccount())) { + return { success: false, message: 'initial admin bootstrap is required before deleting users' }; + } + + const id = String(optionsArg.id || '').trim(); + if (!id) { + return { success: false, message: 'user id is required' }; + } + if (id === optionsArg.requestingUserId) { + return { success: false, message: 'cannot delete the current user' }; + } + + const account = await store.getAccountById(id); + if (!account) { + return { success: false, message: 'user not found' }; + } + + if (account.role === 'admin' && account.status === 'active') { + const activeAdmins = (await store.listAccounts()).filter( + (accountArg) => accountArg.role === 'admin' && accountArg.status === 'active', + ); + if (activeAdmins.length <= 1) { + return { success: false, message: 'cannot delete the last active admin' }; + } + } + + const doc = await plugins.idpSdkServer.IdpSdkAccountDoc.findById(id); + if (!doc) { + return { success: false, message: 'user not found' }; + } + await doc.delete(); + return { success: true }; + } private registerHandlers(): void { this.typedrouter.addTypedHandler( @@ -420,23 +507,17 @@ export class AdminHandler { } const baseUrl = this.opsServerRef.dcRouterRef.options.adminAuth?.idpGlobalUrl || process.env.DCROUTER_IDP_GLOBAL_URL; - if (!baseUrl) { - return undefined; - } - if (!this.idpClient) { - this.idpClient = new plugins.idpSdkServer.IdpGlobalServerClient({ baseUrl }); + this.idpClient = baseUrl + ? new plugins.idpSdkServer.IdpGlobalServerClient({ baseUrl }) + : new plugins.idpSdkServer.IdpGlobalServerClient({} as plugins.idpSdkServer.IIdpGlobalServerClientOptions); this.ownsIdpClient = true; } return this.idpClient; } private isIdpGlobalConfigured(): boolean { - return !!( - this.opsServerRef.dcRouterRef.options.adminAuth?.idpClient || - this.opsServerRef.dcRouterRef.options.adminAuth?.idpGlobalUrl || - process.env.DCROUTER_IDP_GLOBAL_URL - ); + return true; } private accountToUser(accountArg: plugins.idpSdkServer.IIdpSdkAccount): TAdminUser { diff --git a/ts/opsserver/handlers/users.handler.ts b/ts/opsserver/handlers/users.handler.ts index 66c0106..1d91cc2 100644 --- a/ts/opsserver/handlers/users.handler.ts +++ b/ts/opsserver/handlers/users.handler.ts @@ -3,7 +3,7 @@ import type { OpsServer } from '../classes.opsserver.js'; import * as interfaces from '../../../ts_interfaces/index.js'; /** - * Read-only handler for OpsServer user accounts. Registers on adminRouter, + * Handler for OpsServer user accounts. Registers on adminRouter, * so admin middleware enforces auth + role check before the handler runs. * User data is owned by AdminHandler; this handler just exposes a safe * projection of it via TypedRequest. @@ -16,7 +16,7 @@ export class UsersHandler { private registerHandlers(): void { const router = this.opsServerRef.adminRouter; - // List users (admin-only, read-only) + // List users (admin-only) router.addTypedHandler( new plugins.typedrequest.TypedHandler( 'listUsers', @@ -26,5 +26,28 @@ export class UsersHandler { }, ), ); + + router.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'createUser', + async (dataArg) => this.opsServerRef.adminHandler.createUser({ + email: dataArg.email, + name: dataArg.name, + role: dataArg.role, + password: dataArg.password, + enableIdpGlobalAuth: dataArg.enableIdpGlobalAuth, + }), + ), + ); + + router.addTypedHandler( + new plugins.typedrequest.TypedHandler( + 'deleteUser', + async (dataArg) => this.opsServerRef.adminHandler.deleteUser({ + id: dataArg.id, + requestingUserId: dataArg.identity.userId, + }), + ), + ); } } diff --git a/ts_interfaces/readme.md b/ts_interfaces/readme.md index 00df73c..a8e2c55 100644 --- a/ts_interfaces/readme.md +++ b/ts_interfaces/readme.md @@ -68,7 +68,7 @@ for (const route of response.routes) { ## Bootstrap Contracts -The auth request group includes `getAdminBootstrapStatus` and `createInitialAdminUser`. These exist so a fresh DB-backed dcrouter can require explicit first-admin creation instead of auto-persisting a default account. `createInitialAdminUser` requires the temporary bootstrap identity and can optionally enable `idp.global` authentication for the same normalized local email. +The auth request group includes `getAdminBootstrapStatus` and `createInitialAdminUser`. These exist so a fresh DB-backed dcrouter can require explicit first-admin creation instead of auto-persisting a default account. `createInitialAdminUser` requires the temporary bootstrap identity and can optionally enable `idp.global` authentication for the same normalized local email. The SDK defaults to hosted `https://idp.global`; dcrouter URL settings are overrides only. ## When To Use It diff --git a/ts_interfaces/requests/users.ts b/ts_interfaces/requests/users.ts index 413bfcc..bf1534d 100644 --- a/ts_interfaces/requests/users.ts +++ b/ts_interfaces/requests/users.ts @@ -2,8 +2,10 @@ import * as plugins from '../plugins.js'; import * as authInterfaces from '../data/auth.js'; import type { IAdminUserProjection } from './admin.js'; +export type TUserManagementRole = 'admin' | 'user'; + /** - * List all OpsServer users (admin-only, read-only). + * List all OpsServer users (admin-only). * Deliberately omits password/secret fields from the response. */ export interface IReq_ListUsers extends plugins.typedrequestInterfaces.implementsTR< @@ -18,3 +20,44 @@ export interface IReq_ListUsers extends plugins.typedrequestInterfaces.implement users: IAdminUserProjection[]; }; } + +/** + * Create a persisted OpsServer user account (admin-only). + */ +export interface IReq_CreateUser extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_CreateUser +> { + method: 'createUser'; + request: { + identity: authInterfaces.IIdentity; + email: string; + name?: string; + role: TUserManagementRole; + password: string; + enableIdpGlobalAuth?: boolean; + }; + response: { + success: boolean; + user?: IAdminUserProjection; + message?: string; + }; +} + +/** + * Delete a persisted OpsServer user account (admin-only). + */ +export interface IReq_DeleteUser extends plugins.typedrequestInterfaces.implementsTR< + plugins.typedrequestInterfaces.ITypedRequest, + IReq_DeleteUser +> { + method: 'deleteUser'; + request: { + identity: authInterfaces.IIdentity; + id: string; + }; + response: { + success: boolean; + message?: string; + }; +} diff --git a/ts_web/appstate.ts b/ts_web/appstate.ts index 6c02695..270760d 100644 --- a/ts_web/appstate.ts +++ b/ts_web/appstate.ts @@ -2637,7 +2637,7 @@ export async function createGatewayClientToken( }); } -// Users (read-only list) +// Users export const fetchUsersAction = usersStatePart.createAction(async (statePartArg): Promise => { const context = getActionContext(); const currentState = statePartArg.getState()!; @@ -2666,6 +2666,74 @@ export const fetchUsersAction = usersStatePart.createAction(async (statePartArg) } }); +export const createUserAction = usersStatePart.createAction<{ + email: string; + name?: string; + role: interfaces.requests.TUserManagementRole; + password: string; + enableIdpGlobalAuth?: boolean; +}>(async (statePartArg, dataArg, actionContext): Promise => { + const context = getActionContext(); + const currentState = statePartArg.getState()!; + if (!context.identity) return currentState; + + try { + const request = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_CreateUser + >('/typedrequest', 'createUser'); + + const response = await request.fire({ + identity: context.identity, + email: dataArg.email, + name: dataArg.name, + role: dataArg.role, + password: dataArg.password, + enableIdpGlobalAuth: dataArg.enableIdpGlobalAuth, + }); + + if (!response.success) { + throw new Error(response.message || 'Failed to create user'); + } + + return await actionContext!.dispatch(fetchUsersAction, null); + } catch (error) { + return { + ...currentState, + error: error instanceof Error ? error.message : 'Failed to create user', + }; + } +}); + +export const deleteUserAction = usersStatePart.createAction( + async (statePartArg, userIdArg, actionContext): Promise => { + const context = getActionContext(); + const currentState = statePartArg.getState()!; + if (!context.identity) return currentState; + + try { + const request = new plugins.domtools.plugins.typedrequest.TypedRequest< + interfaces.requests.IReq_DeleteUser + >('/typedrequest', 'deleteUser'); + + const response = await request.fire({ + identity: context.identity, + id: userIdArg, + }); + + if (!response.success) { + throw new Error(response.message || 'Failed to delete user'); + } + + return await actionContext!.dispatch(fetchUsersAction, null); + } catch (error) { + return { + ...currentState, + error: error instanceof Error ? error.message : 'Failed to delete user', + }; + } + }, +); + export async function createApiToken( name: string, scopes: interfaces.data.TApiTokenScope[], diff --git a/ts_web/elements/access/ops-view-users.ts b/ts_web/elements/access/ops-view-users.ts index 1d59ce9..c7b797e 100644 --- a/ts_web/elements/access/ops-view-users.ts +++ b/ts_web/elements/access/ops-view-users.ts @@ -116,12 +116,31 @@ export class OpsViewUsers extends DeesElement { .showColumnFilters=${true} .displayFunction=${(user: appstate.IUser) => ({ ID: html`${user.id}`, - Username: user.username, + Email: user.email || user.username, + Name: user.name || '', Role: this.renderRoleBadge(user.role), + Status: user.status || 'active', + Auth: (user.authSources || []).join(', ') || 'bootstrap', Session: user.id === currentUserId ? html`current` : '', })} + .dataActions=${[ + { + name: 'Create User', + iconName: 'lucide:userPlus', + type: ['header'], + actionFunc: async () => await this.showCreateUserDialog(), + }, + { + name: 'Delete', + iconName: 'lucide:trash2', + type: ['inRow', 'contextmenu'] as any, + actionFunc: async (actionData: any) => { + await this.showDeleteUserDialog(actionData.item as appstate.IUser); + }, + }, + ]} > `; @@ -132,6 +151,125 @@ export class OpsViewUsers extends DeesElement { return html`${role}`; } + private async showCreateUserDialog(): Promise { + const { DeesModal, DeesToast } = await import('@design.estate/dees-catalog'); + + await DeesModal.createAndShow({ + heading: 'Create User', + content: html` + + + + + + + + + `, + menuOptions: [ + { + name: 'Cancel', + iconName: 'lucide:x', + action: async (modalArg: any) => await modalArg.destroy(), + }, + { + name: 'Create', + iconName: 'lucide:userPlus', + action: async (modalArg: any) => { + const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form') as any; + if (!form) return; + const data = await form.collectFormData(); + const email = String(data.email || '').trim(); + const name = String(data.name || '').trim(); + const password = String(data.password || ''); + const passwordConfirm = String(data.passwordConfirm || ''); + const roleValue = String(data.role?.key ?? data.role ?? 'user'); + + if (!email || !password) { + form.setStatus?.('error', 'Email and password are required.'); + return; + } + if (password !== passwordConfirm) { + form.setStatus?.('error', 'Passwords do not match.'); + return; + } + + form.setStatus?.('pending', 'Creating user...'); + await appstate.usersStatePart.dispatchAction(appstate.createUserAction, { + email, + name, + role: roleValue === 'admin' ? 'admin' : 'user', + password, + enableIdpGlobalAuth: Boolean(data.enableIdpGlobalAuth), + }); + + const state = appstate.usersStatePart.getState(); + if (state?.error) { + form.setStatus?.('error', state.error); + return; + } + + DeesToast.show({ message: `User created for ${email}`, type: 'success', duration: 3000 }); + await modalArg.destroy(); + }, + }, + ], + }); + } + + private async showDeleteUserDialog(userArg: appstate.IUser): Promise { + const { DeesModal, DeesToast } = await import('@design.estate/dees-catalog'); + const currentUserId = this.loginState.identity?.userId; + if (userArg.id === currentUserId) { + DeesToast.show({ message: 'You cannot delete the current user.', type: 'error', duration: 4000 }); + return; + } + + await DeesModal.createAndShow({ + heading: 'Delete User', + content: html` +
+

Delete ${userArg.email || userArg.username}?

+

This removes the local dcrouter account and cannot be undone.

+
+ `, + menuOptions: [ + { + name: 'Cancel', + iconName: 'lucide:x', + action: async (modalArg: any) => await modalArg.destroy(), + }, + { + name: 'Delete', + iconName: 'lucide:trash2', + action: async (modalArg: any) => { + await appstate.usersStatePart.dispatchAction(appstate.deleteUserAction, userArg.id); + const state = appstate.usersStatePart.getState(); + if (state?.error) { + DeesToast.show({ message: state.error, type: 'error', duration: 4000 }); + return; + } + DeesToast.show({ message: 'User deleted.', type: 'success', duration: 3000 }); + await modalArg.destroy(); + }, + }, + ], + }); + } + async firstUpdated() { if (this.loginState.isLoggedIn) { await appstate.usersStatePart.dispatchAction(appstate.fetchUsersAction, null); diff --git a/ts_web/elements/network/ops-view-routes.ts b/ts_web/elements/network/ops-view-routes.ts index 2c55fac..6089d7b 100644 --- a/ts_web/elements/network/ops-view-routes.ts +++ b/ts_web/elements/network/ops-view-routes.ts @@ -271,6 +271,7 @@ export class OpsViewRoutes extends DeesElement { const tags = [...(mr.route.tags || [])]; tags.push(mr.origin); if (!mr.enabled) tags.push('disabled'); + if (mr.route.vpnOnly) tags.push('vpn-only'); return { ...mr.route, @@ -360,6 +361,7 @@ export class OpsViewRoutes extends DeesElement {

Origin: ${merged.origin}

Status: ${merged.enabled ? 'Enabled' : 'Disabled'}

+ ${merged.route.vpnOnly ? html`

Access: VPN only

` : ''}

ID: ${merged.id}

${isSystemManaged ? html`

This route is system-managed. Change its source config to modify it directly.

` : ''} ${meta?.sourceProfileName ? html`

Source Profile: ${meta.sourceProfileName}

` : ''} @@ -491,6 +493,7 @@ export class OpsViewRoutes extends DeesElement { ? (Array.isArray(firstTarget.host) ? firstTarget.host[0] : firstTarget.host) : ''; const currentTargetPort = typeof firstTarget?.port === 'number' ? String(firstTarget.port) : ''; + const currentVpnOnly = route.vpnOnly === true; const currentRemoteIngressEnabled = route.remoteIngress?.enabled === true; const currentEdgeFilter = route.remoteIngress?.edgeFilter || []; @@ -518,6 +521,7 @@ export class OpsViewRoutes extends DeesElement { +
@@ -570,6 +574,7 @@ export class OpsViewRoutes extends DeesElement { const remoteIngressEdgeFilter: string[] = Array.isArray(formData.remoteIngressEdgeFilter) ? formData.remoteIngressEdgeFilter.filter(Boolean) : []; + const vpnOnly = Boolean(formData.vpnOnly); const updatedRoute: any = { name: formData.name, @@ -586,6 +591,7 @@ export class OpsViewRoutes extends DeesElement { }, ], }, + vpnOnly: vpnOnly ? true : null, remoteIngress: remoteIngressEnabled ? { enabled: true, @@ -684,6 +690,7 @@ export class OpsViewRoutes extends DeesElement { +