BREAKING CHANGE(vpn): replace tag-based VPN access control with source and target profiles

This commit is contained in:
2026-04-05 00:37:37 +00:00
parent 25365678e0
commit 1ddf83b28d
38 changed files with 1546 additions and 321 deletions

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@serve.zone/dcrouter',
version: '12.10.0',
version: '13.0.0',
description: 'A multifaceted routing service handling mail and SMS delivery functions.'
}

View File

@@ -116,7 +116,7 @@ export const configStatePart = await appState.getStatePart<IConfigState>(
// Determine initial view from URL path
const getInitialView = (): string => {
const path = typeof window !== 'undefined' ? window.location.pathname : '/';
const validViews = ['overview', 'network', 'emails', 'logs', 'routes', 'apitokens', 'configuration', 'security', 'certificates', 'remoteingress', 'securityprofiles', 'networktargets'];
const validViews = ['overview', 'network', 'emails', 'logs', 'routes', 'apitokens', 'configuration', 'security', 'certificates', 'remoteingress', 'sourceprofiles', 'networktargets', 'targetprofiles'];
const segments = path.split('/').filter(Boolean);
const view = segments[0];
return validViews.includes(view) ? view : 'overview';
@@ -459,12 +459,19 @@ export const setActiveViewAction = uiStatePart.createAction<string>(async (state
}
// If switching to security profiles or network targets views, fetch profiles/targets data
if ((viewName === 'securityprofiles' || viewName === 'networktargets') && currentState.activeView !== viewName) {
if ((viewName === 'sourceprofiles' || viewName === 'networktargets') && currentState.activeView !== viewName) {
setTimeout(() => {
profilesTargetsStatePart.dispatchAction(fetchProfilesAndTargetsAction, null);
}, 100);
}
// If switching to target profiles view, fetch target profiles data
if (viewName === 'targetprofiles' && currentState.activeView !== viewName) {
setTimeout(() => {
targetProfilesStatePart.dispatchAction(fetchTargetProfilesAction, null);
}, 100);
}
return {
...currentState,
activeView: viewName,
@@ -1006,7 +1013,7 @@ export const fetchVpnAction = vpnStatePart.createAction(async (statePartArg): Pr
export const createVpnClientAction = vpnStatePart.createAction<{
clientId: string;
serverDefinedClientTags?: string[];
targetProfileIds?: string[];
description?: string;
forceDestinationSmartproxy?: boolean;
destinationAllowList?: string[];
@@ -1028,7 +1035,7 @@ export const createVpnClientAction = vpnStatePart.createAction<{
const response = await request.fire({
identity: context.identity!,
clientId: dataArg.clientId,
serverDefinedClientTags: dataArg.serverDefinedClientTags,
targetProfileIds: dataArg.targetProfileIds,
description: dataArg.description,
forceDestinationSmartproxy: dataArg.forceDestinationSmartproxy,
destinationAllowList: dataArg.destinationAllowList,
@@ -1105,7 +1112,7 @@ export const toggleVpnClientAction = vpnStatePart.createAction<{
export const updateVpnClientAction = vpnStatePart.createAction<{
clientId: string;
description?: string;
serverDefinedClientTags?: string[];
targetProfileIds?: string[];
forceDestinationSmartproxy?: boolean;
destinationAllowList?: string[];
destinationBlockList?: string[];
@@ -1127,7 +1134,7 @@ export const updateVpnClientAction = vpnStatePart.createAction<{
identity: context.identity!,
clientId: dataArg.clientId,
description: dataArg.description,
serverDefinedClientTags: dataArg.serverDefinedClientTags,
targetProfileIds: dataArg.targetProfileIds,
forceDestinationSmartproxy: dataArg.forceDestinationSmartproxy,
destinationAllowList: dataArg.destinationAllowList,
destinationBlockList: dataArg.destinationBlockList,
@@ -1158,11 +1165,167 @@ export const clearNewClientConfigAction = vpnStatePart.createAction(
);
// ============================================================================
// Security Profiles & Network Targets State
// Target Profiles State
// ============================================================================
export interface ITargetProfilesState {
profiles: interfaces.data.ITargetProfile[];
isLoading: boolean;
error: string | null;
lastUpdated: number;
}
export const targetProfilesStatePart = await appState.getStatePart<ITargetProfilesState>(
'targetProfiles',
{
profiles: [],
isLoading: false,
error: null,
lastUpdated: 0,
},
'soft'
);
// ============================================================================
// Target Profiles Actions
// ============================================================================
export const fetchTargetProfilesAction = targetProfilesStatePart.createAction(
async (statePartArg): Promise<ITargetProfilesState> => {
const context = getActionContext();
const currentState = statePartArg.getState()!;
if (!context.identity) return currentState;
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetTargetProfiles
>('/typedrequest', 'getTargetProfiles');
const response = await request.fire({ identity: context.identity });
return {
profiles: response.profiles,
isLoading: false,
error: null,
lastUpdated: Date.now(),
};
} catch (error) {
return {
...currentState,
isLoading: false,
error: error instanceof Error ? error.message : 'Failed to fetch target profiles',
};
}
}
);
export const createTargetProfileAction = targetProfilesStatePart.createAction<{
name: string;
description?: string;
domains?: string[];
targets?: Array<{ host: string; port: number }>;
routeRefs?: string[];
}>(async (statePartArg, dataArg, actionContext): Promise<ITargetProfilesState> => {
const context = getActionContext();
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_CreateTargetProfile
>('/typedrequest', 'createTargetProfile');
const response = await request.fire({
identity: context.identity!,
name: dataArg.name,
description: dataArg.description,
domains: dataArg.domains,
targets: dataArg.targets,
routeRefs: dataArg.routeRefs,
});
if (!response.success) {
return {
...statePartArg.getState()!,
error: response.message || 'Failed to create target profile',
};
}
return await actionContext!.dispatch(fetchTargetProfilesAction, null);
} catch (error: unknown) {
return {
...statePartArg.getState()!,
error: error instanceof Error ? error.message : 'Failed to create target profile',
};
}
});
export const updateTargetProfileAction = targetProfilesStatePart.createAction<{
id: string;
name?: string;
description?: string;
domains?: string[];
targets?: Array<{ host: string; port: number }>;
routeRefs?: string[];
}>(async (statePartArg, dataArg, actionContext): Promise<ITargetProfilesState> => {
const context = getActionContext();
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_UpdateTargetProfile
>('/typedrequest', 'updateTargetProfile');
const response = await request.fire({
identity: context.identity!,
id: dataArg.id,
name: dataArg.name,
description: dataArg.description,
domains: dataArg.domains,
targets: dataArg.targets,
routeRefs: dataArg.routeRefs,
});
if (!response.success) {
return {
...statePartArg.getState()!,
error: response.message || 'Failed to update target profile',
};
}
return await actionContext!.dispatch(fetchTargetProfilesAction, null);
} catch (error: unknown) {
return {
...statePartArg.getState()!,
error: error instanceof Error ? error.message : 'Failed to update target profile',
};
}
});
export const deleteTargetProfileAction = targetProfilesStatePart.createAction<{
id: string;
force?: boolean;
}>(async (statePartArg, dataArg, actionContext): Promise<ITargetProfilesState> => {
const context = getActionContext();
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_DeleteTargetProfile
>('/typedrequest', 'deleteTargetProfile');
const response = await request.fire({
identity: context.identity!,
id: dataArg.id,
force: dataArg.force,
});
if (!response.success) {
return {
...statePartArg.getState()!,
error: response.message || 'Failed to delete target profile',
};
}
return await actionContext!.dispatch(fetchTargetProfilesAction, null);
} catch (error: unknown) {
return {
...statePartArg.getState()!,
error: error instanceof Error ? error.message : 'Failed to delete target profile',
};
}
});
// ============================================================================
// Source Profiles & Network Targets State
// ============================================================================
export interface IProfilesTargetsState {
profiles: interfaces.data.ISecurityProfile[];
profiles: interfaces.data.ISourceProfile[];
targets: interfaces.data.INetworkTarget[];
isLoading: boolean;
error: string | null;
@@ -1182,7 +1345,7 @@ export const profilesTargetsStatePart = await appState.getStatePart<IProfilesTar
);
// ============================================================================
// Security Profiles & Network Targets Actions
// Source Profiles & Network Targets Actions
// ============================================================================
export const fetchProfilesAndTargetsAction = profilesTargetsStatePart.createAction(
@@ -1193,8 +1356,8 @@ export const fetchProfilesAndTargetsAction = profilesTargetsStatePart.createActi
try {
const profilesRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetSecurityProfiles
>('/typedrequest', 'getSecurityProfiles');
interfaces.requests.IReq_GetSourceProfiles
>('/typedrequest', 'getSourceProfiles');
const targetsRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetNetworkTargets
@@ -1231,8 +1394,8 @@ export const createProfileAction = profilesTargetsStatePart.createAction<{
const context = getActionContext();
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_CreateSecurityProfile
>('/typedrequest', 'createSecurityProfile');
interfaces.requests.IReq_CreateSourceProfile
>('/typedrequest', 'createSourceProfile');
await request.fire({
identity: context.identity!,
name: dataArg.name,
@@ -1259,8 +1422,8 @@ export const updateProfileAction = profilesTargetsStatePart.createAction<{
const context = getActionContext();
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_UpdateSecurityProfile
>('/typedrequest', 'updateSecurityProfile');
interfaces.requests.IReq_UpdateSourceProfile
>('/typedrequest', 'updateSourceProfile');
await request.fire({
identity: context.identity!,
id: dataArg.id,
@@ -1285,8 +1448,8 @@ export const deleteProfileAction = profilesTargetsStatePart.createAction<{
const context = getActionContext();
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_DeleteSecurityProfile
>('/typedrequest', 'deleteSecurityProfile');
interfaces.requests.IReq_DeleteSourceProfile
>('/typedrequest', 'deleteSourceProfile');
const response = await request.fire({
identity: context.identity!,
id: dataArg.id,

View File

@@ -10,6 +10,7 @@ export * from './ops-view-security.js';
export * from './ops-view-certificates.js';
export * from './ops-view-remoteingress.js';
export * from './ops-view-vpn.js';
export * from './ops-view-securityprofiles.js';
export * from './ops-view-sourceprofiles.js';
export * from './ops-view-networktargets.js';
export * from './ops-view-targetprofiles.js';
export * from './shared/index.js';

View File

@@ -24,8 +24,9 @@ import { OpsViewSecurity } from './ops-view-security.js';
import { OpsViewCertificates } from './ops-view-certificates.js';
import { OpsViewRemoteIngress } from './ops-view-remoteingress.js';
import { OpsViewVpn } from './ops-view-vpn.js';
import { OpsViewSecurityProfiles } from './ops-view-securityprofiles.js';
import { OpsViewSourceProfiles } from './ops-view-sourceprofiles.js';
import { OpsViewNetworkTargets } from './ops-view-networktargets.js';
import { OpsViewTargetProfiles } from './ops-view-targetprofiles.js';
@customElement('ops-dashboard')
export class OpsDashboard extends DeesElement {
@@ -81,15 +82,20 @@ export class OpsDashboard extends DeesElement {
element: OpsViewRoutes,
},
{
name: 'SecurityProfiles',
name: 'SourceProfiles',
iconName: 'lucide:shieldCheck',
element: OpsViewSecurityProfiles,
element: OpsViewSourceProfiles,
},
{
name: 'NetworkTargets',
iconName: 'lucide:server',
element: OpsViewNetworkTargets,
},
{
name: 'TargetProfiles',
iconName: 'lucide:target',
element: OpsViewTargetProfiles,
},
{
name: 'ApiTokens',
iconName: 'lucide:key',

View File

@@ -337,7 +337,7 @@ export class OpsViewRoutes extends DeesElement {
<p>Source: <strong style="color: #0af;">programmatic</strong></p>
<p>Status: <strong>${merged.enabled ? 'Enabled' : 'Disabled'}</strong></p>
<p>ID: <code style="color: #888;">${merged.storedRouteId}</code></p>
${meta?.securityProfileName ? html`<p>Security Profile: <strong style="color: #a78bfa;">${meta.securityProfileName}</strong></p>` : ''}
${meta?.sourceProfileName ? html`<p>Source Profile: <strong style="color: #a78bfa;">${meta.sourceProfileName}</strong></p>` : ''}
${meta?.networkTargetName ? html`<p>Network Target: <strong style="color: #a78bfa;">${meta.networkTargetName}</strong></p>` : ''}
</div>
`,
@@ -476,7 +476,7 @@ export class OpsViewRoutes extends DeesElement {
<dees-input-text .key=${'ports'} .label=${'Ports (comma-separated)'} .value=${currentPorts} .required=${true}></dees-input-text>
<dees-input-list .key=${'domains'} .label=${'Domains'} .placeholder=${'Add domain...'} .value=${currentDomains}></dees-input-list>
<dees-input-text .key=${'priority'} .label=${'Priority (higher = matched first)'} .value=${route.priority != null ? String(route.priority) : ''}></dees-input-text>
<dees-input-dropdown .key=${'securityProfileRef'} .label=${'Security Profile'} .options=${profileOptions} .selectedOption=${profileOptions.find((o) => o.key === (merged.metadata?.securityProfileRef || '')) || null}></dees-input-dropdown>
<dees-input-dropdown .key=${'sourceProfileRef'} .label=${'Source Profile'} .options=${profileOptions} .selectedOption=${profileOptions.find((o) => o.key === (merged.metadata?.sourceProfileRef || '')) || null}></dees-input-dropdown>
<dees-input-dropdown .key=${'networkTargetRef'} .label=${'Network Target'} .options=${targetOptions} .selectedOption=${targetOptions.find((o) => o.key === (merged.metadata?.networkTargetRef || '')) || null}></dees-input-dropdown>
<dees-input-text .key=${'targetHost'} .label=${'Target Host (if no target selected)'} .value=${currentTargetHost}></dees-input-text>
<dees-input-text .key=${'targetPort'} .label=${'Target Port (if no target selected)'} .value=${currentTargetPort}></dees-input-text>
@@ -549,10 +549,10 @@ export class OpsViewRoutes extends DeesElement {
}
const metadata: any = {};
const profileRefValue = formData.securityProfileRef as any;
const profileRefValue = formData.sourceProfileRef as any;
const profileKey = typeof profileRefValue === 'string' ? profileRefValue : profileRefValue?.key;
if (profileKey) {
metadata.securityProfileRef = profileKey;
metadata.sourceProfileRef = profileKey;
}
const targetRefValue = formData.networkTargetRef as any;
const targetKey = typeof targetRefValue === 'string' ? targetRefValue : targetRefValue?.key;
@@ -610,7 +610,7 @@ export class OpsViewRoutes extends DeesElement {
<dees-input-text .key=${'ports'} .label=${'Ports (comma-separated)'} .required=${true}></dees-input-text>
<dees-input-list .key=${'domains'} .label=${'Domains'} .placeholder=${'Add domain...'}></dees-input-list>
<dees-input-text .key=${'priority'} .label=${'Priority (higher = matched first)'}></dees-input-text>
<dees-input-dropdown .key=${'securityProfileRef'} .label=${'Security Profile'} .options=${profileOptions}></dees-input-dropdown>
<dees-input-dropdown .key=${'sourceProfileRef'} .label=${'Source Profile'} .options=${profileOptions}></dees-input-dropdown>
<dees-input-dropdown .key=${'networkTargetRef'} .label=${'Network Target'} .options=${targetOptions}></dees-input-dropdown>
<dees-input-text .key=${'targetHost'} .label=${'Target Host (if no target selected)'} .value=${'localhost'}></dees-input-text>
<dees-input-text .key=${'targetPort'} .label=${'Target Port (if no target selected)'}></dees-input-text>
@@ -682,10 +682,10 @@ export class OpsViewRoutes extends DeesElement {
// Build metadata if profile/target selected
const metadata: any = {};
const profileRefValue = formData.securityProfileRef as any;
const profileRefValue = formData.sourceProfileRef as any;
const profileKey = typeof profileRefValue === 'string' ? profileRefValue : profileRefValue?.key;
if (profileKey) {
metadata.securityProfileRef = profileKey;
metadata.sourceProfileRef = profileKey;
}
const targetRefValue = formData.networkTargetRef as any;
const targetKey = typeof targetRefValue === 'string' ? targetRefValue : targetRefValue?.key;

View File

@@ -14,12 +14,12 @@ import { type IStatsTile } from '@design.estate/dees-catalog';
declare global {
interface HTMLElementTagNameMap {
'ops-view-securityprofiles': OpsViewSecurityProfiles;
'ops-view-sourceprofiles': OpsViewSourceProfiles;
}
}
@customElement('ops-view-securityprofiles')
export class OpsViewSecurityProfiles extends DeesElement {
@customElement('ops-view-sourceprofiles')
export class OpsViewSourceProfiles extends DeesElement {
@state()
accessor profilesState: appstate.IProfilesTargetsState = appstate.profilesTargetsStatePart.getState()!;
@@ -58,20 +58,20 @@ export class OpsViewSecurityProfiles extends DeesElement {
type: 'number',
value: profiles.length,
icon: 'lucide:shieldCheck',
description: 'Reusable security profiles',
description: 'Reusable source profiles',
color: '#3b82f6',
},
];
return html`
<ops-sectionheading>Security Profiles</ops-sectionheading>
<ops-sectionheading>Source Profiles</ops-sectionheading>
<div class="profilesContainer">
<dees-statsgrid .tiles=${statsTiles}></dees-statsgrid>
<dees-table
.heading1=${'Security Profiles'}
.heading2=${'Reusable security configurations for routes'}
.heading1=${'Source Profiles'}
.heading2=${'Reusable source configurations for routes'}
.data=${profiles}
.displayFunction=${(profile: interfaces.data.ISecurityProfile) => ({
.displayFunction=${(profile: interfaces.data.ISourceProfile) => ({
Name: profile.name,
Description: profile.description || '-',
'IP Allow List': (profile.security?.ipAllowList || []).join(', ') || '-',
@@ -107,7 +107,7 @@ export class OpsViewSecurityProfiles extends DeesElement {
iconName: 'lucide:pencil',
type: ['inRow', 'contextmenu'] as any,
actionFunc: async (actionData: any) => {
const profile = actionData.item as interfaces.data.ISecurityProfile;
const profile = actionData.item as interfaces.data.ISourceProfile;
await this.showEditProfileDialog(profile);
},
},
@@ -116,7 +116,7 @@ export class OpsViewSecurityProfiles extends DeesElement {
iconName: 'lucide:trash2',
type: ['inRow', 'contextmenu'] as any,
actionFunc: async (actionData: any) => {
const profile = actionData.item as interfaces.data.ISecurityProfile;
const profile = actionData.item as interfaces.data.ISourceProfile;
await this.deleteProfile(profile);
},
},
@@ -129,7 +129,7 @@ export class OpsViewSecurityProfiles extends DeesElement {
private async showCreateProfileDialog() {
const { DeesModal } = await import('@design.estate/dees-catalog');
DeesModal.createAndShow({
heading: 'Create Security Profile',
heading: 'Create Source Profile',
content: html`
<dees-form>
<dees-input-text .key=${'name'} .label=${'Name'} .required=${true}></dees-input-text>
@@ -167,7 +167,7 @@ export class OpsViewSecurityProfiles extends DeesElement {
});
}
private async showEditProfileDialog(profile: interfaces.data.ISecurityProfile) {
private async showEditProfileDialog(profile: interfaces.data.ISourceProfile) {
const { DeesModal } = await import('@design.estate/dees-catalog');
DeesModal.createAndShow({
heading: `Edit Profile: ${profile.name}`,
@@ -209,7 +209,7 @@ export class OpsViewSecurityProfiles extends DeesElement {
});
}
private async deleteProfile(profile: interfaces.data.ISecurityProfile) {
private async deleteProfile(profile: interfaces.data.ISourceProfile) {
await appstate.profilesTargetsStatePart.dispatchAction(appstate.deleteProfileAction, {
id: profile.id,
force: false,

View File

@@ -0,0 +1,379 @@
import {
DeesElement,
html,
customElement,
type TemplateResult,
css,
state,
cssManager,
} from '@design.estate/dees-element';
import * as plugins from '../plugins.js';
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';
declare global {
interface HTMLElementTagNameMap {
'ops-view-targetprofiles': OpsViewTargetProfiles;
}
}
@customElement('ops-view-targetprofiles')
export class OpsViewTargetProfiles extends DeesElement {
@state()
accessor targetProfilesState: appstate.ITargetProfilesState = appstate.targetProfilesStatePart.getState()!;
constructor() {
super();
const sub = appstate.targetProfilesStatePart.select().subscribe((newState) => {
this.targetProfilesState = newState;
});
this.rxSubscriptions.push(sub);
}
async connectedCallback() {
await super.connectedCallback();
await appstate.targetProfilesStatePart.dispatchAction(appstate.fetchTargetProfilesAction, null);
}
public static styles = [
cssManager.defaultStyles,
viewHostCss,
css`
.profilesContainer {
display: flex;
flex-direction: column;
gap: 24px;
}
.tagBadge {
display: inline-flex;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
background: ${cssManager.bdTheme('#eff6ff', '#172554')};
color: ${cssManager.bdTheme('#1e40af', '#60a5fa')};
margin-right: 4px;
margin-bottom: 2px;
}
`,
];
public render(): TemplateResult {
const profiles = this.targetProfilesState.profiles;
const statsTiles: IStatsTile[] = [
{
id: 'totalProfiles',
title: 'Total Profiles',
type: 'number',
value: profiles.length,
icon: 'lucide:target',
description: 'Reusable target profiles',
color: '#8b5cf6',
},
];
return html`
<ops-sectionheading>Target Profiles</ops-sectionheading>
<div class="profilesContainer">
<dees-statsgrid .tiles=${statsTiles}></dees-statsgrid>
<dees-table
.heading1=${'Target Profiles'}
.heading2=${'Define what resources VPN clients can access'}
.data=${profiles}
.displayFunction=${(profile: interfaces.data.ITargetProfile) => ({
Name: profile.name,
Description: profile.description || '-',
Domains: profile.domains?.length
? html`${profile.domains.map(d => html`<span class="tagBadge">${d}</span>`)}`
: '-',
Targets: profile.targets?.length
? html`${profile.targets.map(t => html`<span class="tagBadge">${t.host}:${t.port}</span>`)}`
: '-',
'Route Refs': profile.routeRefs?.length
? html`${profile.routeRefs.map(r => html`<span class="tagBadge">${r}</span>`)}`
: '-',
Created: new Date(profile.createdAt).toLocaleDateString(),
})}
.dataActions=${[
{
name: 'Create Profile',
iconName: 'lucide:plus',
type: ['header' as const],
actionFunc: async () => {
await this.showCreateProfileDialog();
},
},
{
name: 'Refresh',
iconName: 'lucide:rotateCw',
type: ['header' as const],
actionFunc: async () => {
await appstate.targetProfilesStatePart.dispatchAction(appstate.fetchTargetProfilesAction, null);
},
},
{
name: 'Detail',
iconName: 'lucide:info',
type: ['doubleClick'] as any,
actionFunc: async (actionData: any) => {
const profile = actionData.item as interfaces.data.ITargetProfile;
await this.showDetailDialog(profile);
},
},
{
name: 'Edit',
iconName: 'lucide:pencil',
type: ['inRow', 'contextmenu'] as any,
actionFunc: async (actionData: any) => {
const profile = actionData.item as interfaces.data.ITargetProfile;
await this.showEditProfileDialog(profile);
},
},
{
name: 'Delete',
iconName: 'lucide:trash2',
type: ['inRow', 'contextmenu'] as any,
actionFunc: async (actionData: any) => {
const profile = actionData.item as interfaces.data.ITargetProfile;
await this.deleteProfile(profile);
},
},
]}
></dees-table>
</div>
`;
}
private async showCreateProfileDialog() {
const { DeesModal } = await import('@design.estate/dees-catalog');
DeesModal.createAndShow({
heading: 'Create Target Profile',
content: html`
<dees-form>
<dees-input-text .key=${'name'} .label=${'Name'} .required=${true}></dees-input-text>
<dees-input-text .key=${'description'} .label=${'Description'}></dees-input-text>
<dees-input-text .key=${'domains'} .label=${'Domains (comma-separated, e.g. *.example.com)'} ></dees-input-text>
<dees-input-text .key=${'targets'} .label=${'Targets (comma-separated host:port, e.g. 10.0.0.1:443)'}></dees-input-text>
<dees-input-text .key=${'routeRefs'} .label=${'Route Refs (comma-separated route names/IDs)'}></dees-input-text>
</dees-form>
`,
menuOptions: [
{ name: 'Cancel', iconName: 'lucide:x', action: async (modalArg: any) => modalArg.destroy() },
{
name: 'Create',
iconName: 'lucide:plus',
action: async (modalArg: any) => {
const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form');
if (!form) return;
const data = await form.collectFormData();
if (!data.name) return;
const domains = data.domains
? String(data.domains).split(',').map((s: string) => s.trim()).filter(Boolean)
: undefined;
const targets = data.targets
? String(data.targets).split(',').map((s: string) => {
const trimmed = s.trim();
const lastColon = trimmed.lastIndexOf(':');
if (lastColon === -1) return null;
return {
host: trimmed.substring(0, lastColon),
port: parseInt(trimmed.substring(lastColon + 1), 10),
};
}).filter((t): t is { host: string; port: number } => t !== null && !isNaN(t.port))
: undefined;
const routeRefs = data.routeRefs
? String(data.routeRefs).split(',').map((s: string) => s.trim()).filter(Boolean)
: undefined;
await appstate.targetProfilesStatePart.dispatchAction(appstate.createTargetProfileAction, {
name: String(data.name),
description: data.description ? String(data.description) : undefined,
domains,
targets,
routeRefs,
});
modalArg.destroy();
},
},
],
});
}
private async showEditProfileDialog(profile: interfaces.data.ITargetProfile) {
const currentDomains = profile.domains?.join(', ') ?? '';
const currentTargets = profile.targets?.map(t => `${t.host}:${t.port}`).join(', ') ?? '';
const currentRouteRefs = profile.routeRefs?.join(', ') ?? '';
const { DeesModal } = await import('@design.estate/dees-catalog');
DeesModal.createAndShow({
heading: `Edit Profile: ${profile.name}`,
content: html`
<dees-form>
<dees-input-text .key=${'name'} .label=${'Name'} .value=${profile.name}></dees-input-text>
<dees-input-text .key=${'description'} .label=${'Description'} .value=${profile.description || ''}></dees-input-text>
<dees-input-text .key=${'domains'} .label=${'Domains (comma-separated, e.g. *.example.com)'} .value=${currentDomains}></dees-input-text>
<dees-input-text .key=${'targets'} .label=${'Targets (comma-separated host:port, e.g. 10.0.0.1:443)'} .value=${currentTargets}></dees-input-text>
<dees-input-text .key=${'routeRefs'} .label=${'Route Refs (comma-separated route names/IDs)'} .value=${currentRouteRefs}></dees-input-text>
</dees-form>
`,
menuOptions: [
{ name: 'Cancel', iconName: 'lucide:x', action: async (modalArg: any) => modalArg.destroy() },
{
name: 'Save',
iconName: 'lucide:check',
action: async (modalArg: any) => {
const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form');
if (!form) return;
const data = await form.collectFormData();
const domains = data.domains
? String(data.domains).split(',').map((s: string) => s.trim()).filter(Boolean)
: [];
const targets = data.targets
? String(data.targets).split(',').map((s: string) => {
const trimmed = s.trim();
if (!trimmed) return null;
const lastColon = trimmed.lastIndexOf(':');
if (lastColon === -1) return null;
return {
host: trimmed.substring(0, lastColon),
port: parseInt(trimmed.substring(lastColon + 1), 10),
};
}).filter((t): t is { host: string; port: number } => t !== null && !isNaN(t.port))
: [];
const routeRefs = data.routeRefs
? String(data.routeRefs).split(',').map((s: string) => s.trim()).filter(Boolean)
: [];
await appstate.targetProfilesStatePart.dispatchAction(appstate.updateTargetProfileAction, {
id: profile.id,
name: String(data.name),
description: data.description ? String(data.description) : undefined,
domains,
targets,
routeRefs,
});
modalArg.destroy();
},
},
],
});
}
private async showDetailDialog(profile: interfaces.data.ITargetProfile) {
const { DeesModal } = await import('@design.estate/dees-catalog');
// Fetch usage (which VPN clients reference this profile)
let usageHtml = html`<p style="color: #9ca3af;">Loading usage...</p>`;
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetTargetProfileUsage
>('/typedrequest', 'getTargetProfileUsage');
const response = await request.fire({
identity: appstate.loginStatePart.getState()!.identity!,
id: profile.id,
});
if (response.clients.length > 0) {
usageHtml = html`
<div style="margin-top: 8px;">
${response.clients.map(c => html`
<div style="padding: 4px 0; font-size: 13px;">
<strong>${c.clientId}</strong>${c.description ? html` - ${c.description}` : ''}
</div>
`)}
</div>
`;
} else {
usageHtml = html`<p style="color: #9ca3af; font-size: 13px;">No VPN clients reference this profile.</p>`;
}
} catch {
usageHtml = html`<p style="color: #9ca3af;">Usage data unavailable.</p>`;
}
DeesModal.createAndShow({
heading: `Target Profile: ${profile.name}`,
content: html`
<div style="display: flex; flex-direction: column; gap: 12px;">
<div>
<div style="font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};">Description</div>
<div style="font-size: 14px; margin-top: 4px;">${profile.description || '-'}</div>
</div>
<div>
<div style="font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};">Domains</div>
<div style="font-size: 14px; margin-top: 4px;">
${profile.domains?.length
? profile.domains.map(d => html`<span class="tagBadge">${d}</span>`)
: '-'}
</div>
</div>
<div>
<div style="font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};">Targets</div>
<div style="font-size: 14px; margin-top: 4px;">
${profile.targets?.length
? profile.targets.map(t => html`<span class="tagBadge">${t.host}:${t.port}</span>`)
: '-'}
</div>
</div>
<div>
<div style="font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};">Route Refs</div>
<div style="font-size: 14px; margin-top: 4px;">
${profile.routeRefs?.length
? profile.routeRefs.map(r => html`<span class="tagBadge">${r}</span>`)
: '-'}
</div>
</div>
<div>
<div style="font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};">Created</div>
<div style="font-size: 14px; margin-top: 4px;">${new Date(profile.createdAt).toLocaleString()} by ${profile.createdBy}</div>
</div>
<div>
<div style="font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};">Updated</div>
<div style="font-size: 14px; margin-top: 4px;">${new Date(profile.updatedAt).toLocaleString()}</div>
</div>
<div>
<div style="font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};">VPN Clients Using This Profile</div>
${usageHtml}
</div>
</div>
`,
menuOptions: [
{ name: 'Close', iconName: 'lucide:x', action: async (m: any) => await m.destroy() },
],
});
}
private async deleteProfile(profile: interfaces.data.ITargetProfile) {
await appstate.targetProfilesStatePart.dispatchAction(appstate.deleteTargetProfileAction, {
id: profile.id,
force: false,
});
const currentState = appstate.targetProfilesStatePart.getState()!;
if (currentState.error?.includes('in use')) {
const { DeesModal } = await import('@design.estate/dees-catalog');
DeesModal.createAndShow({
heading: 'Profile In Use',
content: html`<p>${currentState.error} Force delete?</p>`,
menuOptions: [
{
name: 'Force Delete',
iconName: 'lucide:trash2',
action: async (modalArg: any) => {
await appstate.targetProfilesStatePart.dispatchAction(appstate.deleteTargetProfileAction, {
id: profile.id,
force: true,
});
modalArg.destroy();
},
},
{ name: 'Cancel', iconName: 'lucide:x', action: async (modalArg: any) => modalArg.destroy() },
],
});
}
}
}

View File

@@ -327,8 +327,8 @@ export class OpsViewVpn extends DeesElement {
'Status': statusHtml,
'Routing': routingHtml,
'VPN IP': client.assignedIp || '-',
'Tags': client.serverDefinedClientTags?.length
? html`${client.serverDefinedClientTags.map(t => html`<span class="tagBadge">${t}</span>`)}`
'Target Profiles': client.targetProfileIds?.length
? html`${client.targetProfileIds.map(t => html`<span class="tagBadge">${t}</span>`)}`
: '-',
'Description': client.description || '-',
'Created': new Date(client.createdAt).toLocaleDateString(),
@@ -347,7 +347,7 @@ export class OpsViewVpn extends DeesElement {
<dees-form>
<dees-input-text .key=${'clientId'} .label=${'Client ID'} .required=${true}></dees-input-text>
<dees-input-text .key=${'description'} .label=${'Description'}></dees-input-text>
<dees-input-text .key=${'tags'} .label=${'Server-Defined Tags (comma-separated)'}></dees-input-text>
<dees-input-text .key=${'targetProfileIds'} .label=${'Target Profile IDs (comma-separated)'}></dees-input-text>
<dees-input-checkbox .key=${'forceDestinationSmartproxy'} .label=${'Force traffic through SmartProxy'} .value=${true}></dees-input-checkbox>
<div class="hostIpGroup" style="display: none; flex-direction: column; gap: 16px;">
<dees-input-checkbox .key=${'useHostIp'} .label=${'Get Host IP'} .value=${false}></dees-input-checkbox>
@@ -383,8 +383,8 @@ export class OpsViewVpn extends DeesElement {
if (!form) return;
const data = await form.collectFormData();
if (!data.clientId) return;
const serverDefinedClientTags = data.tags
? data.tags.split(',').map((t: string) => t.trim()).filter(Boolean)
const targetProfileIds = data.targetProfileIds
? data.targetProfileIds.split(',').map((t: string) => t.trim()).filter(Boolean)
: undefined;
// Apply conditional logic based on checkbox states
@@ -406,7 +406,7 @@ export class OpsViewVpn extends DeesElement {
await appstate.vpnStatePart.dispatchAction(appstate.createVpnClientAction, {
clientId: data.clientId,
description: data.description || undefined,
serverDefinedClientTags,
targetProfileIds,
forceDestinationSmartproxy: forceSmartproxy,
useHostIp: useHostIp || undefined,
useDhcp: useDhcp || undefined,
@@ -479,7 +479,7 @@ export class OpsViewVpn extends DeesElement {
<div class="infoItem"><span class="infoLabel">Transport</span><span class="infoValue">${conn.transport}</span></div>
` : ''}
<div class="infoItem"><span class="infoLabel">Description</span><span class="infoValue">${client.description || '-'}</span></div>
<div class="infoItem"><span class="infoLabel">Tags</span><span class="infoValue">${client.serverDefinedClientTags?.join(', ') || '-'}</span></div>
<div class="infoItem"><span class="infoLabel">Target Profiles</span><span class="infoValue">${client.targetProfileIds?.join(', ') || '-'}</span></div>
<div class="infoItem"><span class="infoLabel">Routing</span><span class="infoValue">${client.forceDestinationSmartproxy !== false ? 'SmartProxy' : client.useHostIp ? 'Host IP' : 'Direct'}</span></div>
${client.useHostIp ? html`
<div class="infoItem"><span class="infoLabel">Host IP</span><span class="infoValue">${client.useDhcp ? 'DHCP' : client.staticIp ? `Static: ${client.staticIp}` : 'Not configured'}</span></div>
@@ -643,7 +643,7 @@ export class OpsViewVpn extends DeesElement {
const client = actionData.item as interfaces.data.IVpnClient;
const { DeesModal } = await import('@design.estate/dees-catalog');
const currentDescription = client.description ?? '';
const currentTags = client.serverDefinedClientTags?.join(', ') ?? '';
const currentTargetProfileIds = client.targetProfileIds?.join(', ') ?? '';
const currentForceSmartproxy = client.forceDestinationSmartproxy ?? true;
const currentUseHostIp = client.useHostIp ?? false;
const currentUseDhcp = client.useDhcp ?? false;
@@ -659,7 +659,7 @@ export class OpsViewVpn extends DeesElement {
content: html`
<dees-form>
<dees-input-text .key=${'description'} .label=${'Description'} .value=${currentDescription}></dees-input-text>
<dees-input-text .key=${'tags'} .label=${'Server-Defined Tags (comma-separated)'} .value=${currentTags}></dees-input-text>
<dees-input-text .key=${'targetProfileIds'} .label=${'Target Profile IDs (comma-separated)'} .value=${currentTargetProfileIds}></dees-input-text>
<dees-input-checkbox .key=${'forceDestinationSmartproxy'} .label=${'Force traffic through SmartProxy'} .value=${currentForceSmartproxy}></dees-input-checkbox>
<div class="hostIpGroup" style="display: ${currentForceSmartproxy ? 'none' : 'flex'}; flex-direction: column; gap: 16px;">
<dees-input-checkbox .key=${'useHostIp'} .label=${'Get Host IP'} .value=${currentUseHostIp}></dees-input-checkbox>
@@ -690,8 +690,8 @@ export class OpsViewVpn extends DeesElement {
const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form');
if (!form) return;
const data = await form.collectFormData();
const serverDefinedClientTags = data.tags
? data.tags.split(',').map((t: string) => t.trim()).filter(Boolean)
const targetProfileIds = data.targetProfileIds
? data.targetProfileIds.split(',').map((t: string) => t.trim()).filter(Boolean)
: [];
// Apply conditional logic based on checkbox states
@@ -713,7 +713,7 @@ export class OpsViewVpn extends DeesElement {
await appstate.vpnStatePart.dispatchAction(appstate.updateVpnClientAction, {
clientId: client.clientId,
description: data.description || undefined,
serverDefinedClientTags,
targetProfileIds,
forceDestinationSmartproxy: forceSmartproxy,
useHostIp: useHostIp || undefined,
useDhcp: useDhcp || undefined,

View File

@@ -3,7 +3,7 @@ import * as appstate from './appstate.js';
const SmartRouter = plugins.domtools.plugins.smartrouter.SmartRouter;
export const validViews = ['overview', 'network', 'emails', 'logs', 'routes', 'apitokens', 'configuration', 'security', 'certificates', 'remoteingress', 'vpn', 'securityprofiles', 'networktargets'] as const;
export const validViews = ['overview', 'network', 'emails', 'logs', 'routes', 'apitokens', 'configuration', 'security', 'certificates', 'remoteingress', 'vpn', 'sourceprofiles', 'networktargets', 'targetprofiles'] as const;
export type TValidView = typeof validViews[number];