/** * VpnPage — WireGuard VPN management with simple setup flow. * * States: * 1. Not configured → "Enable VPN" button * 2. Active → Server info + peer list + add device flow */ import { useState } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useUIStore } from '@/lib/store' import { Shield, ShieldCheck, ShieldOff, Plus, Trash2, Copy, Terminal, CheckCircle, Wifi, WifiOff, Globe, Building2, } from 'lucide-react' import { vpnApi, devicesApi, type VpnConfigResponse, type VpnPeerResponse, type VpnPeerConfig, type DeviceResponse, } from '@/lib/api' import { useAuth, isSuperAdmin, canWrite } from '@/lib/auth' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Dialog, DialogContent, DialogHeader, DialogTitle, } from '@/components/ui/dialog' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select' import { toast } from '@/components/ui/toast' import { cn } from '@/lib/utils' import { EmptyState } from '@/components/ui/empty-state' import { TableSkeleton } from '@/components/ui/page-skeleton' export function VpnPage() { const { user } = useAuth() const queryClient = useQueryClient() const writable = canWrite(user) const { selectedTenantId } = useUIStore() const tenantId = isSuperAdmin(user) ? (selectedTenantId ?? '') : (user?.tenant_id ?? '') const [showAddDevice, setShowAddDevice] = useState(false) const [showConfig, setShowConfig] = useState(null) const [endpoint, setEndpoint] = useState('') const [selectedDevice, setSelectedDevice] = useState('') const [copied, setCopied] = useState(false) // ── Queries ── const { data: config, isLoading: configLoading } = useQuery({ queryKey: ['vpn-config', tenantId], queryFn: () => vpnApi.getConfig(tenantId), enabled: !!tenantId, }) const { data: peers = [], isLoading: peersLoading } = useQuery({ queryKey: ['vpn-peers', tenantId], queryFn: () => vpnApi.listPeers(tenantId), enabled: !!tenantId && !!config, }) const { data: devices = [] } = useQuery({ queryKey: ['devices', tenantId], queryFn: () => devicesApi.list(tenantId).then((r: any) => r.items ?? r.devices ?? []), enabled: !!tenantId && showAddDevice, }) const { data: peerConfig } = useQuery({ queryKey: ['vpn-peer-config', tenantId, showConfig], queryFn: () => vpnApi.getPeerConfig(tenantId, showConfig!), enabled: !!showConfig, }) // ── Mutations ── const setupMutation = useMutation({ mutationFn: () => vpnApi.setup(tenantId, endpoint || undefined), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['vpn-config'] }) toast({ title: 'VPN enabled successfully' }) }, onError: (e: any) => toast({ title: e?.response?.data?.detail || 'Failed to enable VPN', variant: 'destructive' }), }) const addPeerMutation = useMutation({ mutationFn: (deviceId: string) => vpnApi.addPeer(tenantId, deviceId), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['vpn-peers'] }) queryClient.invalidateQueries({ queryKey: ['vpn-config'] }) setShowAddDevice(false) setSelectedDevice('') toast({ title: 'Device added to VPN' }) }, onError: (e: any) => toast({ title: e?.response?.data?.detail || 'Failed to add device', variant: 'destructive' }), }) const removePeerMutation = useMutation({ mutationFn: (peerId: string) => vpnApi.removePeer(tenantId, peerId), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['vpn-peers'] }) queryClient.invalidateQueries({ queryKey: ['vpn-config'] }) toast({ title: 'Device removed from VPN' }) }, onError: (e: any) => toast({ title: e?.response?.data?.detail || 'Failed to remove device', variant: 'destructive' }), }) const toggleMutation = useMutation({ mutationFn: (enabled: boolean) => vpnApi.updateConfig(tenantId, { is_enabled: enabled }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['vpn-config'] }) toast({ title: 'VPN updated' }) }, }) // ── Helpers ── const connectedPeerIds = new Set(peers.map((p) => p.device_id)) const availableDevices = devices.filter( (d: DeviceResponse) => !connectedPeerIds.has(d.id), ) const copyToClipboard = (text: string) => { navigator.clipboard.writeText(text) setCopied(true) setTimeout(() => setCopied(false), 2000) toast({ title: 'Copied to clipboard' }) } // Super admin needs to select a tenant first if (isSuperAdmin(user) && !tenantId) { return (

VPN

) } if (configLoading) { return (

VPN

) } // ── Not configured state ── if (!config) { return (

VPN

Connect Remote Devices

Enable WireGuard VPN so your MikroTik devices can securely connect to the portal from anywhere — no port forwarding needed on the device side.

setEndpoint(e.target.value)} className="text-center" />

The public hostname or IP where devices will connect. You can set this later.

{writable && ( )}
) } // ── Active state ── return (
{/* Header */}

VPN

{config.is_enabled ? ( <> Active ) : ( <> Disabled )}
{writable && ( <> )}
{/* Server info card */}
p.last_handshake).length} / ${peers.length} connected`} icon={ShieldCheck} />
{/* Peer list */} {peersLoading ? ( ) : peers.length === 0 ? (

VPN is ready

Your WireGuard server is running. Add your first device to create a secure tunnel.

{writable && ( )}
) : (
{peers.map((peer) => ( ))}
Device VPN IP Status Added Actions
{peer.device_hostname}
{peer.device_ip}
{peer.assigned_ip} {peer.last_handshake ? ( Connected ) : ( Awaiting connection )} {new Date(peer.created_at).toLocaleDateString()}
{writable && ( )}
)} {/* Add Device Dialog */} Add Device to VPN

Select a device to create a WireGuard tunnel. You'll get RouterOS commands to paste on the device.

{availableDevices.length === 0 ? (

All devices are on VPN

Every device in your fleet is already connected. Add more devices to your fleet first.

) : ( <> )}
{/* Config Dialog */} setShowConfig(null)}> Device Setup {peerConfig && (

Paste these commands into your MikroTik device terminal to connect it to the VPN.

                  {peerConfig.routeros_commands.join('\n')}
                
VPN IP: {peerConfig.assigned_ip}
Server: {peerConfig.server_endpoint}
)}
) } // ── Reusable info card ── function InfoCard({ label, value, icon: Icon, muted, }: { label: string value: string icon: React.FC<{ className?: string }> muted?: boolean }) { return (
{label}
{value}
) }