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-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')}; } `, ]; render(): TemplateResult { const status = this.vpnState.status; const clients = this.vpnState.clients; const connectedCount = status?.connectedClients ?? 0; 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` VPN
${this.vpnState.newClientConfig ? html`
Client created successfully!
Copy the WireGuard config now. It contains private keys that won't be shown again.
${this.vpnState.newClientConfig}
{ 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 { 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 appstate.vpnStatePart.dispatchAction(appstate.clearNewClientConfigAction, null)} >Dismiss
` : ''} ${status ? html`
Subnet ${status.subnet}
WireGuard Port ${status.wgListenPort}
${status.serverPublicKeys ? html`
WG Public Key ${status.serverPublicKeys.wgPublicKey}
` : ''}
` : ''} ({ 'Client ID': client.clientId, 'Status': client.enabled ? html`enabled` : html`disabled`, 'VPN IP': client.assignedIp || '-', 'Tags': client.serverDefinedClientTags?.length ? html`${client.serverDefinedClientTags.map(t => html`${t}`)}` : '-', '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'); await DeesModal.createAndShow({ heading: 'Create VPN Client', content: html` `, 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; await appstate.vpnStatePart.dispatchAction(appstate.createVpnClientAction, { clientId: data.clientId, description: data.description || undefined, serverDefinedClientTags, }); await modalArg.destroy(); }, }, ], }); }, }, { name: 'Toggle', iconName: 'lucide:power', type: ['contextmenu', 'inRow'], actionFunc: async (client: interfaces.data.IVpnClient) => { await appstate.vpnStatePart.dispatchAction(appstate.toggleVpnClientAction, { clientId: client.clientId, enabled: !client.enabled, }); }, }, { name: 'Export Config', iconName: 'lucide:download', type: ['contextmenu', 'inRow'], actionFunc: async (client: interfaces.data.IVpnClient) => { const { DeesToast } = await import('@design.estate/dees-catalog'); 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 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}.conf`; a.click(); URL.revokeObjectURL(url); DeesToast.createAndShow({ message: '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 }); } }, }, { name: 'Rotate Keys', iconName: 'lucide:rotate-cw', type: ['contextmenu'], actionFunc: async (client: interfaces.data.IVpnClient) => { const { DeesModal, DeesToast } = await import('@design.estate/dees-catalog'); DeesModal.createAndShow({ heading: 'Rotate Client Keys', content: html`

Generate new keys for "${client.clientId}"? The old keys will be invalidated and the client will need the new config to reconnect.

`, 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 (client: interfaces.data.IVpnClient) => { const { DeesModal } = await import('@design.estate/dees-catalog'); DeesModal.createAndShow({ heading: 'Delete VPN Client', content: html`

Are you sure you want to delete client "${client.clientId}"?

`, 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(); }, }, ], }); }, }, ]} >
`; } }