809 lines
39 KiB
TypeScript
809 lines
39 KiB
TypeScript
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';
|
|
|
|
/**
|
|
* 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;
|
|
if (hostIpGroup) hostIpGroup.style.display = data.forceDestinationSmartproxy ? 'none' : show;
|
|
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();
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
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')};
|
|
}
|
|
`,
|
|
];
|
|
|
|
/** 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)
|
|
);
|
|
}
|
|
|
|
render(): TemplateResult {
|
|
const status = this.vpnState.status;
|
|
const clients = this.vpnState.clients;
|
|
const connectedClients = this.vpnState.connectedClients || [];
|
|
const connectedCount = connectedClients.length;
|
|
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',
|
|
description: status?.running ? 'Active' : 'VPN server not running',
|
|
color: status?.running ? '#10b981' : '#ef4444',
|
|
},
|
|
];
|
|
|
|
return html`
|
|
<ops-sectionheading>VPN</ops-sectionheading>
|
|
<div class="vpnContainer">
|
|
|
|
${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>
|
|
<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>
|
|
<dees-button
|
|
@click=${() => appstate.vpnStatePart.dispatchAction(appstate.clearNewClientConfigAction, null)}
|
|
>Dismiss</dees-button>
|
|
</div>
|
|
` : ''}
|
|
|
|
<dees-statsgrid .tiles=${statsTiles}></dees-statsgrid>
|
|
|
|
${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}
|
|
.displayFunction=${(client: interfaces.data.IVpnClient) => {
|
|
const conn = this.getConnectedInfo(client);
|
|
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>`;
|
|
}
|
|
let routingHtml;
|
|
if (client.forceDestinationSmartproxy !== false) {
|
|
routingHtml = html`<span class="statusBadge enabled">SmartProxy</span>`;
|
|
} else if (client.useHostIp) {
|
|
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>`;
|
|
}
|
|
return {
|
|
'Client ID': client.clientId,
|
|
'Status': statusHtml,
|
|
'Routing': routingHtml,
|
|
'VPN IP': client.assignedIp || '-',
|
|
'Tags': client.serverDefinedClientTags?.length
|
|
? html`${client.serverDefinedClientTags.map(t => html`<span class="tagBadge">${t}</span>`)}`
|
|
: '-',
|
|
'Description': client.description || '-',
|
|
'Created': new Date(client.createdAt).toLocaleDateString(),
|
|
};
|
|
}}
|
|
.dataActions=${[
|
|
{
|
|
name: 'Create Client',
|
|
iconName: 'lucide:plus',
|
|
type: ['header'],
|
|
actionFunc: async () => {
|
|
const { DeesModal } = await import('@design.estate/dees-catalog');
|
|
const createModal = await DeesModal.createAndShow({
|
|
heading: 'Create VPN Client',
|
|
content: html`
|
|
<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-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>
|
|
<div class="hostIpDetails" style="display: none; flex-direction: column; gap: 16px;">
|
|
<dees-input-checkbox .key=${'useDhcp'} .label=${'Get IP through DHCP'} .value=${false}></dees-input-checkbox>
|
|
<div class="staticIpGroup" style="display: flex; flex-direction: column; gap: 16px;">
|
|
<dees-input-text .key=${'staticIp'} .label=${'Static IP'}></dees-input-text>
|
|
</div>
|
|
<dees-input-checkbox .key=${'forceVlan'} .label=${'Force VLAN'} .value=${false}></dees-input-checkbox>
|
|
<div class="vlanIdGroup" style="display: none; flex-direction: column; gap: 16px;">
|
|
<dees-input-text .key=${'vlanId'} .label=${'VLAN ID'}></dees-input-text>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<dees-input-checkbox .key=${'allowAdditionalAcls'} .label=${'Allow additional ACLs'} .value=${false}></dees-input-checkbox>
|
|
<div class="aclGroup" style="display: none; flex-direction: column; gap: 16px;">
|
|
<dees-input-text .key=${'destinationAllowList'} .label=${'Destination Allow List (comma-separated IPs/CIDRs)'}></dees-input-text>
|
|
<dees-input-text .key=${'destinationBlockList'} .label=${'Destination Block List (comma-separated IPs/CIDRs)'}></dees-input-text>
|
|
</div>
|
|
</dees-form>
|
|
`,
|
|
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;
|
|
const serverDefinedClientTags = data.tags
|
|
? data.tags.split(',').map((t: string) => t.trim()).filter(Boolean)
|
|
: undefined;
|
|
|
|
// Apply conditional logic based on checkbox states
|
|
const forceSmartproxy = data.forceDestinationSmartproxy ?? true;
|
|
const useHostIp = !forceSmartproxy && (data.useHostIp ?? false);
|
|
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;
|
|
|
|
await appstate.vpnStatePart.dispatchAction(appstate.createVpnClientAction, {
|
|
clientId: data.clientId,
|
|
description: data.description || undefined,
|
|
serverDefinedClientTags,
|
|
forceDestinationSmartproxy: forceSmartproxy,
|
|
useHostIp: useHostIp || undefined,
|
|
useDhcp: useDhcp || undefined,
|
|
staticIp,
|
|
forceVlan: forceVlan || undefined,
|
|
vlanId,
|
|
destinationAllowList,
|
|
destinationBlockList,
|
|
});
|
|
await modalArg.destroy();
|
|
},
|
|
},
|
|
],
|
|
});
|
|
// 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);
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: 'Detail',
|
|
iconName: 'lucide:info',
|
|
type: ['doubleClick'],
|
|
actionFunc: async (actionData: any) => {
|
|
const client = actionData.item as interfaces.data.IVpnClient;
|
|
const conn = this.getConnectedInfo(client);
|
|
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>
|
|
<div class="infoItem"><span class="infoLabel">Tags</span><span class="infoValue">${client.serverDefinedClientTags?.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>
|
|
<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>
|
|
<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',
|
|
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: false,
|
|
});
|
|
},
|
|
},
|
|
{
|
|
name: 'Export Config',
|
|
iconName: 'lucide:download',
|
|
type: ['contextmenu', 'inRow'],
|
|
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 });
|
|
}
|
|
};
|
|
|
|
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 });
|
|
}
|
|
};
|
|
|
|
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');
|
|
},
|
|
},
|
|
{
|
|
name: 'QR Code (WireGuard)',
|
|
iconName: 'lucide:qr-code',
|
|
action: async (modalArg: any) => {
|
|
await modalArg.destroy();
|
|
await showQrCode();
|
|
},
|
|
},
|
|
{
|
|
name: 'Cancel',
|
|
iconName: 'lucide:x',
|
|
action: async (modalArg: any) => await modalArg.destroy(),
|
|
},
|
|
],
|
|
});
|
|
},
|
|
},
|
|
{
|
|
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 ?? '';
|
|
const currentTags = client.serverDefinedClientTags?.join(', ') ?? '';
|
|
const currentForceSmartproxy = client.forceDestinationSmartproxy ?? true;
|
|
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({
|
|
heading: `Edit: ${client.clientId}`,
|
|
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-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>
|
|
<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}></dees-input-checkbox>
|
|
<div class="staticIpGroup" style="display: ${currentUseDhcp ? 'none' : 'flex'}; flex-direction: column; gap: 16px;">
|
|
<dees-input-text .key=${'staticIp'} .label=${'Static IP'} .value=${currentStaticIp}></dees-input-text>
|
|
</div>
|
|
<dees-input-checkbox .key=${'forceVlan'} .label=${'Force VLAN'} .value=${currentForceVlan}></dees-input-checkbox>
|
|
<div class="vlanIdGroup" style="display: ${currentForceVlan ? 'flex' : 'none'}; flex-direction: column; gap: 16px;">
|
|
<dees-input-text .key=${'vlanId'} .label=${'VLAN ID'} .value=${currentVlanId}></dees-input-text>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<dees-input-checkbox .key=${'allowAdditionalAcls'} .label=${'Allow additional ACLs'} .value=${currentAllowAcls}></dees-input-checkbox>
|
|
<div class="aclGroup" style="display: ${currentAllowAcls ? 'flex' : 'none'}; flex-direction: column; gap: 16px;">
|
|
<dees-input-text .key=${'destinationAllowList'} .label=${'Destination Allow List (comma-separated IPs/CIDRs)'} .value=${currentAllowList}></dees-input-text>
|
|
<dees-input-text .key=${'destinationBlockList'} .label=${'Destination Block List (comma-separated IPs/CIDRs)'} .value=${currentBlockList}></dees-input-text>
|
|
</div>
|
|
</dees-form>
|
|
`,
|
|
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();
|
|
const serverDefinedClientTags = data.tags
|
|
? data.tags.split(',').map((t: string) => t.trim()).filter(Boolean)
|
|
: [];
|
|
|
|
// Apply conditional logic based on checkbox states
|
|
const forceSmartproxy = data.forceDestinationSmartproxy ?? true;
|
|
const useHostIp = !forceSmartproxy && (data.useHostIp ?? false);
|
|
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)
|
|
: [];
|
|
|
|
await appstate.vpnStatePart.dispatchAction(appstate.updateVpnClientAction, {
|
|
clientId: client.clientId,
|
|
description: data.description || undefined,
|
|
serverDefinedClientTags,
|
|
forceDestinationSmartproxy: forceSmartproxy,
|
|
useHostIp: useHostIp || undefined,
|
|
useDhcp: useDhcp || undefined,
|
|
staticIp,
|
|
forceVlan: forceVlan || undefined,
|
|
vlanId,
|
|
destinationAllowList,
|
|
destinationBlockList,
|
|
});
|
|
await modalArg.destroy();
|
|
},
|
|
},
|
|
],
|
|
});
|
|
// 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);
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: 'Rotate Keys',
|
|
iconName: 'lucide:rotate-cw',
|
|
type: ['contextmenu'],
|
|
actionFunc: async (actionData: any) => {
|
|
const client = actionData.item as interfaces.data.IVpnClient;
|
|
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 });
|
|
}
|
|
},
|
|
},
|
|
],
|
|
});
|
|
},
|
|
},
|
|
{
|
|
name: 'Delete',
|
|
iconName: 'lucide:trash2',
|
|
type: ['contextmenu'],
|
|
actionFunc: async (actionData: any) => {
|
|
const client = actionData.item as interfaces.data.IVpnClient;
|
|
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: [
|
|
{ name: 'Cancel', iconName: 'lucide:x', action: async (modalArg: any) => await modalArg.destroy() },
|
|
{
|
|
name: 'Delete',
|
|
iconName: 'lucide:trash2',
|
|
action: async (modalArg: any) => {
|
|
await appstate.vpnStatePart.dispatchAction(appstate.deleteVpnClientAction, client.clientId);
|
|
await modalArg.destroy();
|
|
},
|
|
},
|
|
],
|
|
});
|
|
},
|
|
},
|
|
]}
|
|
></dees-table>
|
|
</div>
|
|
`;
|
|
}
|
|
}
|