2026-03-30 08:15:09 +00:00
import {
DeesElement ,
html ,
customElement ,
type TemplateResult ,
css ,
state ,
cssManager ,
} from '@design.estate/dees-element' ;
2026-04-08 08:24:55 +00:00
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' ;
2026-03-30 08:15:09 +00:00
import { type IStatsTile } from '@design.estate/dees-catalog' ;
2026-04-01 05:13:01 +00:00
/**
* Toggle form field visibility based on checkbox states.
* Used in Create and Edit VPN client dialogs.
*/
function setupFormVisibility ( formEl : any ) {
const show = 'flex' ; // match dees-form's flex layout
const updateVisibility = async ( ) = > {
const data = await formEl . collectFormData ( ) ;
const contentEl = formEl . closest ( '.content' ) || formEl . parentElement ;
if ( ! contentEl ) return ;
const hostIpGroup = contentEl . querySelector ( '.hostIpGroup' ) as HTMLElement ;
const hostIpDetails = contentEl . querySelector ( '.hostIpDetails' ) as HTMLElement ;
const staticIpGroup = contentEl . querySelector ( '.staticIpGroup' ) as HTMLElement ;
const vlanIdGroup = contentEl . querySelector ( '.vlanIdGroup' ) as HTMLElement ;
const aclGroup = contentEl . querySelector ( '.aclGroup' ) as HTMLElement ;
2026-04-07 21:02:37 +00:00
if ( hostIpGroup ) hostIpGroup . style . display = show ; // always show (forceTarget is always on)
2026-04-01 05:13:01 +00:00
if ( hostIpDetails ) hostIpDetails . style . display = data . useHostIp ? show : 'none' ;
if ( staticIpGroup ) staticIpGroup . style . display = data . useDhcp ? 'none' : show ;
if ( vlanIdGroup ) vlanIdGroup . style . display = data . forceVlan ? show : 'none' ;
if ( aclGroup ) aclGroup . style . display = data . allowAdditionalAcls ? show : 'none' ;
} ;
formEl . changeSubject . subscribe ( ( ) = > updateVisibility ( ) ) ;
updateVisibility ( ) ;
}
2026-03-30 08:15:09 +00:00
declare global {
interface HTMLElementTagNameMap {
'ops-view-vpn' : OpsViewVpn ;
}
}
@customElement ( 'ops-view-vpn' )
export class OpsViewVpn extends DeesElement {
@state ( )
accessor vpnState : appstate.IVpnState = appstate . vpnStatePart . getState ( ) ! ;
constructor ( ) {
super ( ) ;
const sub = appstate . vpnStatePart . select ( ) . subscribe ( ( newState ) = > {
this . vpnState = newState ;
} ) ;
this . rxSubscriptions . push ( sub ) ;
}
async connectedCallback() {
await super . connectedCallback ( ) ;
await appstate . vpnStatePart . dispatchAction ( appstate . fetchVpnAction , null ) ;
2026-04-06 08:05:07 +00:00
// Ensure target profiles are loaded for autocomplete candidates
await appstate . targetProfilesStatePart . dispatchAction ( appstate . fetchTargetProfilesAction , null ) ;
2026-03-30 08:15:09 +00:00
}
public static styles = [
cssManager . defaultStyles ,
viewHostCss ,
css `
.vpnContainer {
display: flex;
flex-direction: column;
gap: 24px;
}
.statusBadge {
display: inline-flex;
align-items: center;
padding: 3px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.02em;
text-transform: uppercase;
}
.statusBadge.enabled {
background: ${ cssManager . bdTheme ( '#dcfce7' , '#14532d' ) } ;
color: ${ cssManager . bdTheme ( '#166534' , '#4ade80' ) } ;
}
.statusBadge.disabled {
background: ${ cssManager . bdTheme ( '#fef2f2' , '#450a0a' ) } ;
color: ${ cssManager . bdTheme ( '#991b1b' , '#f87171' ) } ;
}
.configDialog {
padding: 16px;
background: ${ cssManager . bdTheme ( '#fffbeb' , '#1c1917' ) } ;
border: 1px solid ${ cssManager . bdTheme ( '#fbbf24' , '#92400e' ) } ;
border-radius: 8px;
margin-bottom: 16px;
}
.configDialog pre {
display: block;
padding: 12px;
background: ${ cssManager . bdTheme ( '#1f2937' , '#111827' ) } ;
color: #10b981;
border-radius: 4px;
font-family: monospace;
font-size: 12px;
white-space: pre-wrap;
word-break: break-all;
margin: 8px 0;
user-select: all;
max-height: 300px;
overflow-y: auto;
}
.configDialog .warning {
font-size: 12px;
color: ${ cssManager . bdTheme ( '#92400e' , '#fbbf24' ) } ;
margin-top: 8px;
}
.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;
}
.serverInfo {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 12px;
padding: 16px;
background: ${ cssManager . bdTheme ( '#f9fafb' , '#111827' ) } ;
border-radius: 8px;
border: 1px solid ${ cssManager . bdTheme ( '#e5e7eb' , '#1f2937' ) } ;
}
.serverInfo .infoItem {
display: flex;
flex-direction: column;
gap: 4px;
}
.serverInfo .infoLabel {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: ${ cssManager . bdTheme ( '#6b7280' , '#9ca3af' ) } ;
}
.serverInfo .infoValue {
font-size: 14px;
font-family: monospace;
color: ${ cssManager . bdTheme ( '#111827' , '#f9fafb' ) } ;
}
` ,
] ;
2026-03-31 11:19:29 +00:00
/** Look up connected client info by clientId or assignedIp */
private getConnectedInfo ( client : interfaces.data.IVpnClient ) : interfaces . data . IVpnConnectedClient | undefined {
return this . vpnState . connectedClients ? . find (
c = > c . clientId === client . clientId || ( client . assignedIp && c . assignedIp === client . assignedIp )
) ;
2026-03-31 09:53:37 +00:00
}
2026-03-30 08:15:09 +00:00
render ( ) : TemplateResult {
const status = this . vpnState . status ;
const clients = this . vpnState . clients ;
2026-03-31 09:53:37 +00:00
const connectedClients = this . vpnState . connectedClients || [ ] ;
const connectedCount = connectedClients . length ;
2026-03-30 08:15:09 +00:00
const totalClients = clients . length ;
const enabledClients = clients . filter ( c = > c . enabled ) . length ;
const statsTiles : IStatsTile [ ] = [
{
id : 'totalClients' ,
title : 'Total Clients' ,
type : 'number' ,
value : totalClients ,
icon : 'lucide:users' ,
description : 'Registered VPN clients' ,
color : '#3b82f6' ,
} ,
{
id : 'connectedClients' ,
title : 'Connected' ,
type : 'number' ,
value : connectedCount ,
icon : 'lucide:link' ,
description : 'Currently connected' ,
color : '#10b981' ,
} ,
{
id : 'enabledClients' ,
title : 'Enabled' ,
type : 'number' ,
value : enabledClients ,
icon : 'lucide:shieldCheck' ,
description : 'Active client registrations' ,
color : '#8b5cf6' ,
} ,
{
id : 'serverStatus' ,
title : 'Server' ,
type : 'text' ,
value : status?.running ? 'Running' : 'Stopped' ,
icon : 'lucide:server' ,
2026-03-30 13:06:14 +00:00
description : status?.running ? 'Active' : 'VPN server not running' ,
2026-03-30 08:15:09 +00:00
color : status?.running ? '#10b981' : '#ef4444' ,
} ,
] ;
return html `
2026-04-08 11:08:18 +00:00
<dees-heading level="3">VPN</dees-heading>
2026-03-30 16:49:58 +00:00
<div class="vpnContainer">
2026-03-30 08:15:09 +00:00
${ this . vpnState . newClientConfig ? html `
<div class="configDialog">
<strong>Client created successfully!</strong>
<div class="warning">Copy the WireGuard config now. It contains private keys that won't be shown again.</div>
<pre> ${ this . vpnState . newClientConfig } </pre>
<dees-button
@click= ${ async ( ) = > {
if ( navigator . clipboard && typeof navigator . clipboard . writeText === 'function' ) {
await navigator . clipboard . writeText ( this . vpnState . newClientConfig ! ) ;
}
const { DeesToast } = await import('@design.estate/dees-catalog');
DeesToast.createAndShow({ message: 'Config copied to clipboard', type: 'success', duration: 3000 });
}}
>Copy to Clipboard</dees-button>
<dees-button
@click= ${ ( ) = > {
const blob = new Blob ( [ this . vpnState . newClientConfig ! ] , { type : 'text/plain' } );
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'wireguard.conf';
a.click();
URL.revokeObjectURL(url);
}}
>Download .conf</dees-button>
2026-03-30 23:50:51 +00:00
<dees-button
@click= ${ async ( ) = > {
const dataUrl = await plugins . qrcode . toDataURL (
this . vpnState . newClientConfig ! ,
{ width : 400 , margin : 2 }
);
const { DeesModal } = await import('@design.estate/dees-catalog');
DeesModal.createAndShow({
heading: 'WireGuard QR Code',
content: html `
< div style = "text-align: center; padding: 16px;" >
< img src = "${dataUrl}" style = "max-width: 100%; image-rendering: pixelated;" / >
< p style = "margin-top: 12px; font-size: 13px; color: #9ca3af;" >
Scan with the WireGuard app on your phone
< / p >
< / div >
` ,
menuOptions: [
{ name: 'Close', iconName: 'lucide:x', action: async (modalArg: any) => await modalArg.destroy() },
],
});
}}
>Show QR Code</dees-button>
2026-03-30 08:15:09 +00:00
<dees-button
@click= ${ ( ) = > appstate . vpnStatePart . dispatchAction ( appstate . clearNewClientConfigAction , null ) }
>Dismiss</dees-button>
</div>
` : '' }
2026-03-30 16:49:58 +00:00
<dees-statsgrid .tiles= ${ statsTiles } ></dees-statsgrid>
2026-03-30 08:15:09 +00:00
${ status ? html `
<div class="serverInfo">
<div class="infoItem">
<span class="infoLabel">Subnet</span>
<span class="infoValue"> ${ status . subnet } </span>
</div>
<div class="infoItem">
<span class="infoLabel">WireGuard Port</span>
<span class="infoValue"> ${ status . wgListenPort } </span>
</div>
${ status . serverPublicKeys ? html `
<div class="infoItem">
<span class="infoLabel">WG Public Key</span>
<span class="infoValue" style="font-size: 11px; word-break: break-all;"> ${ status . serverPublicKeys . wgPublicKey } </span>
</div>
` : '' }
</div>
` : '' }
<dees-table
.heading1= ${ 'VPN Clients' }
.heading2= ${ 'Manage WireGuard and SmartVPN client registrations' }
.data= ${ clients }
2026-04-08 15:26:12 +00:00
.rowKey= ${ 'clientId' }
.highlightUpdates= ${ 'flash' }
2026-04-08 07:11:21 +00:00
.showColumnFilters= ${ true }
2026-03-31 09:53:37 +00:00
.displayFunction= ${ ( client : interfaces.data.IVpnClient ) = > {
2026-03-31 11:19:29 +00:00
const conn = this . getConnectedInfo ( client ) ;
2026-03-31 09:53:37 +00:00
let statusHtml ;
if ( ! client . enabled ) {
statusHtml = html ` <span class="statusBadge disabled">disabled</span> ` ;
} else if (conn) {
const since = new Date(conn.connectedSince).toLocaleString();
statusHtml = html ` < span class = "statusBadge enabled" title = "Since ${since}" > connected < / span > ` ;
} else {
statusHtml = html ` < span class = "statusBadge enabled" style = "background: ${cssManager.bdTheme('#eff6ff', '#172554')}; color: ${cssManager.bdTheme('#1e40af', '#60a5fa')};" > offline < / span > ` ;
}
2026-04-01 05:13:01 +00:00
let routingHtml;
2026-04-07 21:02:37 +00:00
if (client.useHostIp) {
2026-04-01 05:13:01 +00:00
routingHtml = html ` < span class = "statusBadge" style = "background: ${cssManager.bdTheme('#f3e8ff', '#3b0764')}; color: ${cssManager.bdTheme('#7c3aed', '#c084fc')};" > Host IP < / span > ` ;
} else {
routingHtml = html ` < span class = "statusBadge" style = "background: ${cssManager.bdTheme('#eff6ff', '#172554')}; color: ${cssManager.bdTheme('#1e40af', '#60a5fa')};" > Direct < / span > ` ;
}
2026-03-31 09:53:37 +00:00
return {
'Client ID': client.clientId,
'Status': statusHtml,
2026-04-01 05:13:01 +00:00
'Routing': routingHtml,
2026-03-31 09:53:37 +00:00
'VPN IP': client.assignedIp || '-',
2026-04-05 00:37:37 +00:00
'Target Profiles': client.targetProfileIds?.length
2026-04-06 08:05:07 +00:00
? html ` $ { client . targetProfileIds . map ( id = > {
const profileState = appstate . targetProfilesStatePart . getState ( ) ;
const profile = profileState ? . profiles . find ( p = > p . id === id ) ;
return html ` <span class="tagBadge"> ${ profile ? . name || id } </span> ` ;
} ) } `
2026-03-31 09:53:37 +00:00
: '-',
'Description': client.description || '-',
'Created': new Date(client.createdAt).toLocaleDateString(),
};
}}
2026-03-30 08:15:09 +00:00
.dataActions= ${ [
2026-03-30 16:49:58 +00:00
{
name : 'Create Client' ,
iconName : 'lucide:plus' ,
type : [ 'header' ] ,
actionFunc : async ( ) = > {
const { DeesModal } = await import('@design.estate/dees-catalog');
2026-04-06 08:05:07 +00:00
const profileCandidates = this.getTargetProfileCandidates();
2026-04-01 05:13:01 +00:00
const createModal = await DeesModal.createAndShow({
2026-03-30 16:49:58 +00:00
heading: 'Create VPN Client',
content: html `
< dees - form >
< dees - input - text .key = $ { 'clientId' } .label = $ { 'Client ID' } .required = $ { true } > < / d e e s - i n p u t - t e x t >
< dees - input - text .key = $ { 'description' } .label = $ { 'Description' } > < / d e e s - i n p u t - t e x t >
2026-04-06 08:05:07 +00:00
< dees - input - list .key = $ { 'targetProfileNames' } .label = $ { 'Target Profiles' } .placeholder = $ { 'Type to search profiles...' } .candidates = $ { profileCandidates } .allowFreeform = $ { false } > < / d e e s - i n p u t - l i s t >
2026-04-07 21:02:37 +00:00
< div class = "hostIpGroup" style = "display: flex; flex-direction: column; gap: 16px;" >
2026-04-01 05:13:01 +00:00
< dees - input - checkbox .key = $ { 'useHostIp' } .label = $ { 'Get Host IP' } .value = $ { false } > < / d e e s - i n p u t - c h e c k b o x >
< div class = "hostIpDetails" style = "display: none; flex-direction: column; gap: 16px;" >
< dees - input - checkbox .key = $ { 'useDhcp' } .label = $ { 'Get IP through DHCP' } .value = $ { false } > < / d e e s - i n p u t - c h e c k b o x >
< div class = "staticIpGroup" style = "display: flex; flex-direction: column; gap: 16px;" >
< dees - input - text .key = $ { 'staticIp' } .label = $ { 'Static IP' } > < / d e e s - i n p u t - t e x t >
< / div >
< dees - input - checkbox .key = $ { 'forceVlan' } .label = $ { 'Force VLAN' } .value = $ { false } > < / d e e s - i n p u t - c h e c k b o x >
< div class = "vlanIdGroup" style = "display: none; flex-direction: column; gap: 16px;" >
< dees - input - text .key = $ { 'vlanId' } .label = $ { 'VLAN ID' } > < / d e e s - i n p u t - t e x t >
< / div >
< / div >
< / div >
< dees - input - checkbox .key = $ { 'allowAdditionalAcls' } .label = $ { 'Allow additional ACLs' } .value = $ { false } > < / d e e s - i n p u t - c h e c k b o x >
< div class = "aclGroup" style = "display: none; flex-direction: column; gap: 16px;" >
2026-04-12 19:42:07 +00:00
< dees - input - text .key = $ { 'destinationAllowList' } .label = $ { 'Destination Allow List' } .description = $ { 'Comma-separated IPs or CIDRs' } > < / d e e s - i n p u t - t e x t >
< dees - input - text .key = $ { 'destinationBlockList' } .label = $ { 'Destination Block List' } .description = $ { 'Comma-separated IPs or CIDRs' } > < / d e e s - i n p u t - t e x t >
2026-04-01 05:13:01 +00:00
< / div >
2026-03-30 16:49:58 +00:00
< / d e e s - f o r m >
` ,
menuOptions: [
{
name: 'Cancel',
iconName: 'lucide:x',
action: async (modalArg: any) => await modalArg.destroy(),
},
{
name: 'Create',
iconName: 'lucide:plus',
action: async (modalArg: any) => {
const form = modalArg.shadowRoot?.querySelector('.content')?.querySelector('dees-form');
if (!form) return;
const data = await form.collectFormData();
if (!data.clientId) return;
2026-04-06 08:05:07 +00:00
const targetProfileIds = this.resolveProfileNamesToIds(
Array.isArray(data.targetProfileNames) ? data.targetProfileNames : [],
);
2026-04-01 05:13:01 +00:00
// Apply conditional logic based on checkbox states
2026-04-07 21:02:37 +00:00
const useHostIp = data.useHostIp ?? false;
2026-04-01 05:13:01 +00:00
const useDhcp = useHostIp && (data.useDhcp ?? false);
const staticIp = useHostIp && !useDhcp && data.staticIp ? data.staticIp : undefined;
const forceVlan = useHostIp && (data.forceVlan ?? false);
const vlanId = forceVlan && data.vlanId ? parseInt(data.vlanId, 10) : undefined;
const allowAcls = data.allowAdditionalAcls ?? false;
const destinationAllowList = allowAcls && data.destinationAllowList
? data.destinationAllowList.split(',').map((s: string) => s.trim()).filter(Boolean)
: undefined;
const destinationBlockList = allowAcls && data.destinationBlockList
? data.destinationBlockList.split(',').map((s: string) => s.trim()).filter(Boolean)
: undefined;
2026-03-30 16:49:58 +00:00
await appstate.vpnStatePart.dispatchAction(appstate.createVpnClientAction, {
clientId: data.clientId,
description: data.description || undefined,
2026-04-05 00:37:37 +00:00
targetProfileIds,
2026-04-07 21:02:37 +00:00
2026-04-01 05:13:01 +00:00
useHostIp: useHostIp || undefined,
useDhcp: useDhcp || undefined,
staticIp,
forceVlan: forceVlan || undefined,
vlanId,
destinationAllowList,
destinationBlockList,
2026-03-30 16:49:58 +00:00
});
await modalArg.destroy();
},
},
],
});
2026-04-01 05:13:01 +00:00
// Setup conditional form visibility after modal renders
const createForm = createModal?.shadowRoot?.querySelector('.content')?.querySelector('dees-form') as any;
if (createForm) {
await createForm.updateComplete;
setupFormVisibility(createForm);
}
2026-03-30 16:49:58 +00:00
},
},
2026-03-30 08:15:09 +00:00
{
2026-03-31 09:53:37 +00:00
name: 'Detail',
iconName: 'lucide:info',
type: ['doubleClick'],
actionFunc: async (actionData: any) => {
const client = actionData.item as interfaces.data.IVpnClient;
2026-03-31 11:19:29 +00:00
const conn = this.getConnectedInfo(client);
2026-03-31 09:53:37 +00:00
const { DeesModal } = await import('@design.estate/dees-catalog');
// Fetch telemetry on-demand
let telemetryHtml = html ` < p style = "color: #9ca3af;" > Loading telemetry . . . < / p > ` ;
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_GetVpnClientTelemetry
>('/typedrequest', 'getVpnClientTelemetry');
const response = await request.fire({
identity: appstate.loginStatePart.getState()!.identity!,
clientId: client.clientId,
});
const t = response.telemetry;
if (t) {
const formatBytes = (b: number) => b > 1048576 ? ` $ { ( b / 1048576 ) . toFixed ( 1 ) } MB ` : b > 1024 ? ` $ { ( b / 1024 ) . toFixed ( 1 ) } KB ` : ` $ { b } B ` ;
telemetryHtml = html `
< div class = "serverInfo" style = "margin-top: 12px;" >
< div class = "infoItem" > < span class = "infoLabel" > Bytes Sent < / span > < span class = "infoValue" > $ { formatBytes ( t . bytesSent ) } < / span > < / div >
< div class = "infoItem" > < span class = "infoLabel" > Bytes Received < / span > < span class = "infoValue" > $ { formatBytes ( t . bytesReceived ) } < / span > < / div >
< div class = "infoItem" > < span class = "infoLabel" > Keepalives < / span > < span class = "infoValue" > $ { t . keepalivesReceived } < / span > < / div >
< div class = "infoItem" > < span class = "infoLabel" > Last Keepalive < / span > < span class = "infoValue" > $ { t . lastKeepaliveAt ? new Date ( t . lastKeepaliveAt ) . toLocaleString ( ) : '-' } < / span > < / div >
< div class = "infoItem" > < span class = "infoLabel" > Packets Dropped < / span > < span class = "infoValue" > $ { t . packetsDropped } < / span > < / div >
< / div >
` ;
} else {
telemetryHtml = html ` < p style = "color: #9ca3af;" > No telemetry available ( client not connected ) < / p > ` ;
}
} catch {
telemetryHtml = html ` < p style = "color: #9ca3af;" > Telemetry unavailable < / p > ` ;
}
DeesModal.createAndShow({
heading: ` Client : $ { client . clientId } ` ,
content: html `
< div class = "serverInfo" >
< div class = "infoItem" > < span class = "infoLabel" > Client ID < / span > < span class = "infoValue" > $ { client . clientId } < / span > < / div >
< div class = "infoItem" > < span class = "infoLabel" > VPN IP < / span > < span class = "infoValue" > $ { client . assignedIp || '-' } < / span > < / div >
< div class = "infoItem" > < span class = "infoLabel" > Status < / span > < span class = "infoValue" > $ { ! client . enabled ? 'Disabled' : conn ? 'Connected' : 'Offline' } < / span > < / div >
$ { conn ? html `
<div class="infoItem"><span class="infoLabel">Connected Since</span><span class="infoValue"> ${ new Date ( conn . connectedSince ) . toLocaleString ( ) } </span></div>
<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 >
2026-04-06 08:05:07 +00:00
< div class = "infoItem" > < span class = "infoLabel" > Target Profiles < / span > < span class = "infoValue" > $ { this . resolveProfileIdsToNames ( client . targetProfileIds ) ? . join ( ', ' ) || '-' } < / span > < / div >
2026-04-07 21:02:37 +00:00
< div class = "infoItem" > < span class = "infoLabel" > Routing < / span > < span class = "infoValue" > $ { client . useHostIp ? 'Host IP' : 'SmartProxy' } < / span > < / div >
2026-04-01 05:13:01 +00:00
$ { 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>
<div class="infoItem"><span class="infoLabel">VLAN</span><span class="infoValue"> ${ client . forceVlan && client . vlanId != null ? ` VLAN ${ client . vlanId } ` : 'No VLAN' } </span></div>
` : '' }
< div class = "infoItem" > < span class = "infoLabel" > Allow List < / span > < span class = "infoValue" > $ { client . destinationAllowList ? . length ? client . destinationAllowList . join ( ', ' ) : 'None' } < / span > < / div >
< div class = "infoItem" > < span class = "infoLabel" > Block List < / span > < span class = "infoValue" > $ { client . destinationBlockList ? . length ? client . destinationBlockList . join ( ', ' ) : 'None' } < / span > < / div >
2026-03-31 09:53:37 +00:00
< div class = "infoItem" > < span class = "infoLabel" > Created < / span > < span class = "infoValue" > $ { new Date ( client . createdAt ) . toLocaleString ( ) } < / span > < / div >
< div class = "infoItem" > < span class = "infoLabel" > Updated < / span > < span class = "infoValue" > $ { new Date ( client . updatedAt ) . toLocaleString ( ) } < / span > < / div >
< / div >
< h3 style = "margin: 16px 0 4px; font-size: 14px;" > Telemetry < / h3 >
$ { telemetryHtml }
` ,
menuOptions: [
{ name: 'Close', iconName: 'lucide:x', action: async (m: any) => await m.destroy() },
],
});
},
},
{
name: 'Enable',
iconName: 'lucide:power',
type: ['contextmenu', 'inRow'],
actionRelevancyCheckFunc: (actionData: any) => !actionData.item.enabled,
actionFunc: async (actionData: any) => {
const client = actionData.item as interfaces.data.IVpnClient;
await appstate.vpnStatePart.dispatchAction(appstate.toggleVpnClientAction, {
clientId: client.clientId,
enabled: true,
});
},
},
{
name: 'Disable',
2026-03-30 08:15:09 +00:00
iconName: 'lucide:power',
2026-03-30 16:49:58 +00:00
type: ['contextmenu', 'inRow'],
2026-03-31 09:53:37 +00:00
actionRelevancyCheckFunc: (actionData: any) => actionData.item.enabled,
2026-03-30 17:08:57 +00:00
actionFunc: async (actionData: any) => {
const client = actionData.item as interfaces.data.IVpnClient;
2026-03-30 08:15:09 +00:00
await appstate.vpnStatePart.dispatchAction(appstate.toggleVpnClientAction, {
clientId: client.clientId,
2026-03-31 09:53:37 +00:00
enabled: false,
2026-03-30 08:15:09 +00:00
});
},
},
2026-03-30 16:49:58 +00:00
{
name: 'Export Config',
iconName: 'lucide:download',
type: ['contextmenu', 'inRow'],
2026-03-30 17:08:57 +00:00
actionFunc: async (actionData: any) => {
const client = actionData.item as interfaces.data.IVpnClient;
const { DeesModal, DeesToast } = await import('@design.estate/dees-catalog');
const exportConfig = async (format: 'wireguard' | 'smartvpn') => {
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_ExportVpnClientConfig
>('/typedrequest', 'exportVpnClientConfig');
const response = await request.fire({
identity: appstate.loginStatePart.getState()!.identity!,
clientId: client.clientId,
format,
});
if (response.success && response.config) {
const ext = format === 'wireguard' ? 'conf' : 'json';
const blob = new Blob([response.config], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = ` $ { client . clientId } . $ { ext } ` ;
a.click();
URL.revokeObjectURL(url);
DeesToast.createAndShow({ message: ` $ { format } config downloaded ` , type: 'success', duration: 3000 });
} else {
DeesToast.createAndShow({ message: response.message || 'Export failed', type: 'error', duration: 5000 });
}
} catch (err: any) {
DeesToast.createAndShow({ message: err.message || 'Export failed', type: 'error', duration: 5000 });
2026-03-30 16:49:58 +00:00
}
2026-03-30 17:08:57 +00:00
};
2026-03-30 23:50:51 +00:00
const showQrCode = async () => {
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_ExportVpnClientConfig
>('/typedrequest', 'exportVpnClientConfig');
const response = await request.fire({
identity: appstate.loginStatePart.getState()!.identity!,
clientId: client.clientId,
format: 'wireguard',
});
if (response.success && response.config) {
const dataUrl = await plugins.qrcode.toDataURL(
response.config,
{ width: 400, margin: 2 }
);
DeesModal.createAndShow({
heading: ` QR Code : $ { client . clientId } ` ,
content: html `
< div style = "text-align: center; padding: 16px;" >
< img src = "${dataUrl}" style = "max-width: 100%; image-rendering: pixelated;" / >
< p style = "margin-top: 12px; font-size: 13px; color: #9ca3af;" >
Scan with the WireGuard app on your phone
< / p >
< / div >
` ,
menuOptions: [
{ name: 'Close', iconName: 'lucide:x', action: async (m: any) => await m.destroy() },
],
});
} else {
DeesToast.createAndShow({ message: response.message || 'Export failed', type: 'error', duration: 5000 });
}
} catch (err: any) {
DeesToast.createAndShow({ message: err.message || 'QR generation failed', type: 'error', duration: 5000 });
}
};
2026-03-30 17:08:57 +00:00
DeesModal.createAndShow({
heading: ` Export Config : $ { client . clientId } ` ,
content: html ` < p > Choose a config format to download . < / p > ` ,
menuOptions: [
{
name: 'WireGuard (.conf)',
iconName: 'lucide:shield',
action: async (modalArg: any) => {
await modalArg.destroy();
await exportConfig('wireguard');
},
},
{
name: 'SmartVPN (.json)',
iconName: 'lucide:braces',
action: async (modalArg: any) => {
await modalArg.destroy();
await exportConfig('smartvpn');
},
},
2026-03-30 23:50:51 +00:00
{
name: 'QR Code (WireGuard)',
iconName: 'lucide:qr-code',
action: async (modalArg: any) => {
await modalArg.destroy();
await showQrCode();
},
},
2026-03-30 17:08:57 +00:00
{
name: 'Cancel',
iconName: 'lucide:x',
action: async (modalArg: any) => await modalArg.destroy(),
},
],
});
2026-03-30 16:49:58 +00:00
},
},
2026-03-31 09:53:37 +00:00
{
name: 'Edit',
iconName: 'lucide:pencil',
type: ['contextmenu', 'inRow'],
actionFunc: async (actionData: any) => {
const client = actionData.item as interfaces.data.IVpnClient;
const { DeesModal } = await import('@design.estate/dees-catalog');
const currentDescription = client.description ?? '';
2026-04-06 08:05:07 +00:00
const currentTargetProfileNames = this.resolveProfileIdsToNames(client.targetProfileIds) || [];
const profileCandidates = this.getTargetProfileCandidates();
2026-04-01 05:13:01 +00:00
const currentUseHostIp = client.useHostIp ?? false;
const currentUseDhcp = client.useDhcp ?? false;
const currentStaticIp = client.staticIp ?? '';
const currentForceVlan = client.forceVlan ?? false;
const currentVlanId = client.vlanId != null ? String(client.vlanId) : '';
const currentAllowList = client.destinationAllowList?.join(', ') ?? '';
const currentBlockList = client.destinationBlockList?.join(', ') ?? '';
const currentAllowAcls = (client.destinationAllowList?.length ?? 0) > 0
|| (client.destinationBlockList?.length ?? 0) > 0;
const editModal = await DeesModal.createAndShow({
2026-03-31 09:53:37 +00:00
heading: ` Edit : $ { client . clientId } ` ,
content: html `
< dees - form >
< dees - input - text .key = $ { 'description' } .label = $ { 'Description' } .value = $ { currentDescription } > < / d e e s - i n p u t - t e x t >
2026-04-06 08:05:07 +00:00
< dees - input - list .key = $ { 'targetProfileNames' } .label = $ { 'Target Profiles' } .placeholder = $ { 'Type to search profiles...' } .candidates = $ { profileCandidates } .allowFreeform = $ { false } .value = $ { currentTargetProfileNames } > < / d e e s - i n p u t - l i s t >
2026-04-07 21:02:37 +00:00
< div class = "hostIpGroup" style = "display: flex; flex-direction: column; gap: 16px;" >
2026-04-01 05:13:01 +00:00
< dees - input - checkbox .key = $ { 'useHostIp' } .label = $ { 'Get Host IP' } .value = $ { currentUseHostIp } > < / d e e s - i n p u t - c h e c k b o x >
< div class = "hostIpDetails" style = "display: ${currentUseHostIp ? 'flex' : 'none'}; flex-direction: column; gap: 16px;" >
< dees - input - checkbox .key = $ { 'useDhcp' } .label = $ { 'Get IP through DHCP' } .value = $ { currentUseDhcp } > < / d e e s - i n p u t - c h e c k b o x >
< div class = "staticIpGroup" style = "display: ${currentUseDhcp ? 'none' : 'flex'}; flex-direction: column; gap: 16px;" >
< dees - input - text .key = $ { 'staticIp' } .label = $ { 'Static IP' } .value = $ { currentStaticIp } > < / d e e s - i n p u t - t e x t >
< / div >
< dees - input - checkbox .key = $ { 'forceVlan' } .label = $ { 'Force VLAN' } .value = $ { currentForceVlan } > < / d e e s - i n p u t - c h e c k b o x >
< div class = "vlanIdGroup" style = "display: ${currentForceVlan ? 'flex' : 'none'}; flex-direction: column; gap: 16px;" >
< dees - input - text .key = $ { 'vlanId' } .label = $ { 'VLAN ID' } .value = $ { currentVlanId } > < / d e e s - i n p u t - t e x t >
< / div >
< / div >
< / div >
< dees - input - checkbox .key = $ { 'allowAdditionalAcls' } .label = $ { 'Allow additional ACLs' } .value = $ { currentAllowAcls } > < / d e e s - i n p u t - c h e c k b o x >
< div class = "aclGroup" style = "display: ${currentAllowAcls ? 'flex' : 'none'}; flex-direction: column; gap: 16px;" >
2026-04-12 19:42:07 +00:00
< dees - input - text .key = $ { 'destinationAllowList' } .label = $ { 'Destination Allow List' } .description = $ { 'Comma-separated IPs or CIDRs' } .value = $ { currentAllowList } > < / d e e s - i n p u t - t e x t >
< dees - input - text .key = $ { 'destinationBlockList' } .label = $ { 'Destination Block List' } .description = $ { 'Comma-separated IPs or CIDRs' } .value = $ { currentBlockList } > < / d e e s - i n p u t - t e x t >
2026-04-01 05:13:01 +00:00
< / div >
2026-03-31 09:53:37 +00:00
< / d e e s - f o r m >
` ,
menuOptions: [
{ name: 'Cancel', iconName: 'lucide:x', action: async (modalArg: any) => await 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();
2026-04-06 08:05:07 +00:00
const targetProfileIds = this.resolveProfileNamesToIds(
Array.isArray(data.targetProfileNames) ? data.targetProfileNames : [],
);
2026-04-01 05:13:01 +00:00
// Apply conditional logic based on checkbox states
2026-04-07 21:02:37 +00:00
const useHostIp = data.useHostIp ?? false;
2026-04-01 05:13:01 +00:00
const useDhcp = useHostIp && (data.useDhcp ?? false);
const staticIp = useHostIp && !useDhcp && data.staticIp ? data.staticIp : undefined;
const forceVlan = useHostIp && (data.forceVlan ?? false);
const vlanId = forceVlan && data.vlanId ? parseInt(data.vlanId, 10) : undefined;
const allowAcls = data.allowAdditionalAcls ?? false;
const destinationAllowList = allowAcls && data.destinationAllowList
? data.destinationAllowList.split(',').map((s: string) => s.trim()).filter(Boolean)
: [];
const destinationBlockList = allowAcls && data.destinationBlockList
? data.destinationBlockList.split(',').map((s: string) => s.trim()).filter(Boolean)
: [];
2026-03-31 09:53:37 +00:00
await appstate.vpnStatePart.dispatchAction(appstate.updateVpnClientAction, {
clientId: client.clientId,
description: data.description || undefined,
2026-04-05 00:37:37 +00:00
targetProfileIds,
2026-04-07 21:02:37 +00:00
2026-04-01 05:13:01 +00:00
useHostIp: useHostIp || undefined,
useDhcp: useDhcp || undefined,
staticIp,
forceVlan: forceVlan || undefined,
vlanId,
destinationAllowList,
destinationBlockList,
2026-03-31 09:53:37 +00:00
});
await modalArg.destroy();
},
},
],
});
2026-04-01 05:13:01 +00:00
// Setup conditional form visibility for edit dialog
const editForm = editModal?.shadowRoot?.querySelector('.content')?.querySelector('dees-form') as any;
if (editForm) {
await editForm.updateComplete;
setupFormVisibility(editForm);
}
2026-03-31 09:53:37 +00:00
},
},
2026-03-30 16:49:58 +00:00
{
name: 'Rotate Keys',
iconName: 'lucide:rotate-cw',
type: ['contextmenu'],
2026-03-30 17:08:57 +00:00
actionFunc: async (actionData: any) => {
const client = actionData.item as interfaces.data.IVpnClient;
2026-03-30 16:49:58 +00:00
const { DeesModal, DeesToast } = await import('@design.estate/dees-catalog');
DeesModal.createAndShow({
heading: 'Rotate Client Keys',
content: html ` < p > Generate new keys for "${client.clientId}" ? The old keys will be invalidated and the client will need the new config to reconnect . < / p > ` ,
menuOptions: [
{ name: 'Cancel', iconName: 'lucide:x', action: async (modalArg: any) => await modalArg.destroy() },
{
name: 'Rotate',
iconName: 'lucide:rotate-cw',
action: async (modalArg: any) => {
try {
const request = new plugins.domtools.plugins.typedrequest.TypedRequest<
interfaces.requests.IReq_RotateVpnClientKey
>('/typedrequest', 'rotateVpnClientKey');
const response = await request.fire({
identity: appstate.loginStatePart.getState()!.identity!,
clientId: client.clientId,
});
if (response.success && response.wireguardConfig) {
appstate.vpnStatePart.setState({
...appstate.vpnStatePart.getState()!,
newClientConfig: response.wireguardConfig,
});
}
await modalArg.destroy();
} catch (err: any) {
DeesToast.createAndShow({ message: err.message || 'Rotate failed', type: 'error', duration: 5000 });
}
},
},
],
});
},
},
2026-03-30 08:15:09 +00:00
{
name: 'Delete',
iconName: 'lucide:trash2',
2026-03-30 16:49:58 +00:00
type: ['contextmenu'],
2026-03-30 17:08:57 +00:00
actionFunc: async (actionData: any) => {
const client = actionData.item as interfaces.data.IVpnClient;
2026-03-30 08:15:09 +00:00
const { DeesModal } = await import('@design.estate/dees-catalog');
DeesModal.createAndShow({
heading: 'Delete VPN Client',
content: html ` < p > Are you sure you want to delete client "${client.clientId}" ? < / p > ` ,
menuOptions: [
2026-03-30 16:49:58 +00:00
{ name: 'Cancel', iconName: 'lucide:x', action: async (modalArg: any) => await modalArg.destroy() },
2026-03-30 08:15:09 +00:00
{
name: 'Delete',
2026-03-30 16:49:58 +00:00
iconName: 'lucide:trash2',
action: async (modalArg: any) => {
2026-03-30 08:15:09 +00:00
await appstate.vpnStatePart.dispatchAction(appstate.deleteVpnClientAction, client.clientId);
2026-03-30 16:49:58 +00:00
await modalArg.destroy();
2026-03-30 08:15:09 +00:00
},
},
],
});
},
},
]}
></dees-table>
2026-03-30 16:49:58 +00:00
</div>
2026-03-30 08:15:09 +00:00
` ;
}
2026-04-06 08:05:07 +00:00
/**
* Build autocomplete candidates from loaded target profiles.
* viewKey = profile name (displayed), payload = { id } (carried for resolution).
*/
private getTargetProfileCandidates() {
const profileState = appstate . targetProfilesStatePart . getState ( ) ;
const profiles = profileState ? . profiles || [ ] ;
return profiles . map ( ( p ) = > ( { viewKey : p.name , payload : { id : p.id } } ) ) ;
}
/**
* Convert profile IDs to profile names (for populating edit form values).
*/
private resolveProfileIdsToNames ( ids? : string [ ] ) : string [ ] | undefined {
if ( ! ids ? . length ) return undefined ;
const profileState = appstate . targetProfilesStatePart . getState ( ) ;
const profiles = profileState ? . profiles || [ ] ;
return ids . map ( ( id ) = > {
const profile = profiles . find ( ( p ) = > p . id === id ) ;
return profile ? . name || id ;
} ) ;
}
/**
* Convert profile names back to IDs (for saving form data).
* Uses the dees-input-list candidates' payload when available.
*/
private resolveProfileNamesToIds ( names : string [ ] ) : string [ ] | undefined {
if ( ! names . length ) return undefined ;
const profileState = appstate . targetProfilesStatePart . getState ( ) ;
const profiles = profileState ? . profiles || [ ] ;
return names
. map ( ( name ) = > {
const profile = profiles . find ( ( p ) = > p . name === name ) ;
return profile ? . id ;
} )
. filter ( ( id ) : id is string = > ! ! id ) ;
}
2026-03-30 08:15:09 +00:00
}