Files
wild-central/web/src/components/VpnComponent.tsx
Paul Payne 735f3fcb05 feat: Add Wild Central web app (Central-only UI)
Extract Central pages from the wild-cloud web app into a standalone
React app for Wild Central. Includes:

- Central overview, DNS, DHCP, Firewall, VPN, Ingress, CrowdSec pages
- Simplified sidebar with Central-only navigation
- Branding updated to "Wild Central"
- All Cloud-specific pages, components, hooks, and API services removed
- TypeScript type-check and production build pass

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-09 04:10:43 +00:00

584 lines
22 KiB
TypeScript

import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { Shield, Loader2, Edit2, Plus, Trash2, Copy, Check, CheckCircle, RefreshCw, KeyRound, Play, ChevronDown, ChevronUp } from 'lucide-react';
import { QRCodeSVG } from 'qrcode.react';
import { Card, CardHeader, CardTitle, CardContent } from './ui/card';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { Label } from './ui/label';
import { Badge } from './ui/badge';
import { Alert, AlertDescription } from './ui/alert';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from './ui/dialog';
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter } from './ui/sheet';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from './ui/alert-dialog';
import { useVpn } from '../hooks/useVpn';
import { vpnApi, type VpnPeer, type VpnPeerConfig } from '../services/api/vpn';
interface ConfigFormValues {
enabled: boolean;
listenPort: number;
address: string;
endpoint: string;
dns: string;
lanCIDR: string;
}
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<Button variant="ghost" size="sm" onClick={handleCopy} className="h-7 w-7 p-0">
{copied ? <Check className="h-3.5 w-3.5 text-green-500" /> : <Copy className="h-3.5 w-3.5" />}
</Button>
);
}
export function VpnComponent() {
const {
status, isLoadingStatus,
config, isLoadingConfig,
peers, isLoadingPeers,
updateConfig, isUpdatingConfig,
generateKeypair, isGeneratingKeypair,
apply, isApplying, applyError,
addPeer, isAddingPeer, addPeerError,
deletePeer,
} = useVpn();
const [editingConfig, setEditingConfig] = useState(false);
const [confirmKeygen, setConfirmKeygen] = useState(false);
const [addPeerOpen, setAddPeerOpen] = useState(false);
const [newPeerName, setNewPeerName] = useState('');
const [peerConfigModal, setPeerConfigModal] = useState<VpnPeerConfig | null>(null);
const [loadingPeerConfig, setLoadingPeerConfig] = useState<string | null>(null);
const [deleteConfirmPeer, setDeleteConfirmPeer] = useState<VpnPeer | null>(null);
const [showAdvanced, setShowAdvanced] = useState(false);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const { register, handleSubmit, reset, formState: { errors } } = useForm<ConfigFormValues>();
const showSuccess = (msg: string) => {
setSuccessMessage(msg);
setTimeout(() => setSuccessMessage(null), 5000);
};
const showError = (msg: string) => {
setErrorMessage(msg);
setTimeout(() => setErrorMessage(null), 8000);
};
const handleEditConfig = () => {
reset({
enabled: config?.enabled ?? false,
listenPort: config?.listenPort ?? 51820,
address: config?.address ?? '10.8.0.1/24',
endpoint: config?.endpoint ?? '',
dns: config?.dns ?? '',
lanCIDR: config?.lanCIDR ?? '',
});
setEditingConfig(true);
};
const handleSaveConfig = async (values: ConfigFormValues) => {
try {
await updateConfig({ ...values, listenPort: Number(values.listenPort) });
setEditingConfig(false);
if (values.enabled) {
await apply();
showSuccess('Configuration saved and applied');
} else {
showSuccess('Configuration saved');
}
} catch (e) {
showError(`Save failed: ${e instanceof Error ? e.message : String(e)}`);
}
};
const handleGenerateKeypair = async () => {
setConfirmKeygen(false);
try {
await generateKeypair();
showSuccess('Server keypair generated — re-add all peers to update their configs');
} catch (e) {
showError(`Keygen failed: ${e instanceof Error ? e.message : String(e)}`);
}
};
const handleApply = async () => {
try {
await apply();
showSuccess('WireGuard interface applied');
} catch (e) {
showError(`Apply failed: ${e instanceof Error ? e.message : String(e)}`);
}
};
const handleAddPeer = async () => {
if (!newPeerName.trim()) return;
try {
await addPeer(newPeerName.trim());
setNewPeerName('');
setAddPeerOpen(false);
showSuccess(`Peer "${newPeerName.trim()}" added`);
} catch (e) {
showError(`Add peer failed: ${e instanceof Error ? e.message : String(e)}`);
}
};
const handleShowPeerConfig = async (peer: VpnPeer) => {
setLoadingPeerConfig(peer.id);
try {
const cfg = await vpnApi.getPeerConfig(peer.id);
setPeerConfigModal(cfg);
} catch (e) {
showError(`Failed to get peer config: ${e instanceof Error ? e.message : String(e)}`);
} finally {
setLoadingPeerConfig(null);
}
};
const handleDeletePeer = async () => {
if (!deleteConfirmPeer) return;
try {
await deletePeer(deleteConfirmPeer.id);
setDeleteConfirmPeer(null);
showSuccess(`Peer "${deleteConfirmPeer.name}" deleted`);
} catch (e) {
showError(`Delete failed: ${e instanceof Error ? e.message : String(e)}`);
}
};
if (isLoadingStatus && isLoadingConfig) {
return (
<div className="flex items-center justify-center h-48">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="space-y-6">
{/* Page header */}
<div className="flex items-center gap-4">
<div className="p-2 bg-primary/10 rounded-lg">
<Shield className="h-6 w-6 text-primary" />
</div>
<div>
<h2 className="text-2xl font-semibold">VPN</h2>
<p className="text-muted-foreground">WireGuard remote access to your Wild Cloud network</p>
</div>
</div>
{/* Alerts */}
{successMessage && (
<Alert className="border-green-200 bg-green-50 dark:border-green-800 dark:bg-green-950/20">
<Check className="h-4 w-4 text-green-600" />
<AlertDescription className="text-green-700 dark:text-green-300">{successMessage}</AlertDescription>
</Alert>
)}
{(errorMessage || applyError || addPeerError) && (
<Alert variant="error">
<AlertDescription>{errorMessage ?? String(applyError ?? addPeerError)}</AlertDescription>
</Alert>
)}
{/* Status card */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Status</CardTitle>
<div className="flex items-center gap-2">
<Badge variant={status?.running ? 'success' : config?.enabled ? 'warning' : 'secondary'} className="gap-1">
{status?.running && <CheckCircle className="h-3 w-3" />}
{status?.running ? 'Running' : 'Stopped'}
</Badge>
<Button
size="sm"
onClick={handleApply}
disabled={isApplying || !config?.enabled}
className="gap-1 h-7 text-xs"
>
{isApplying ? <Loader2 className="h-3 w-3 animate-spin" /> : <Play className="h-3 w-3" />}
Apply
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4 text-sm sm:grid-cols-3">
<div>
<p className="text-muted-foreground">Interface</p>
<p className="font-mono">{status?.interface ?? 'wg0'}</p>
</div>
<div>
<p className="text-muted-foreground">Connected peers</p>
<p className="font-mono">{status?.peerCount ?? 0}</p>
</div>
<div>
<p className="text-muted-foreground">Listen port</p>
<p className="font-mono">{status?.listenPort ?? config?.listenPort ?? 51820}</p>
</div>
</div>
<div className="flex gap-2 pt-2">
<Button
variant="outline"
size="sm"
className="gap-1 h-7 text-xs"
onClick={() => setShowAdvanced(!showAdvanced)}
>
{showAdvanced ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
Advanced
</Button>
</div>
{showAdvanced && status?.rawOutput && (
<div className="border-t pt-3">
<span className="text-sm font-medium">WireGuard interface status</span>
<pre className="bg-muted p-3 rounded-lg overflow-x-auto text-xs font-mono mt-2">
{status.rawOutput}
</pre>
</div>
)}
</CardContent>
</Card>
{/* Server configuration card */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Server Configuration</CardTitle>
{!editingConfig && (
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setConfirmKeygen(true)}
disabled={isGeneratingKeypair}
className="gap-1 h-7 text-xs"
>
{isGeneratingKeypair ? <Loader2 className="h-3 w-3 animate-spin" /> : <KeyRound className="h-3 w-3" />}
Generate Keys
</Button>
<Button
variant="outline"
size="sm"
onClick={handleEditConfig}
className="gap-1 h-7 text-xs"
>
<Edit2 className="h-3 w-3" />
Configure
</Button>
</div>
)}
</div>
</CardHeader>
<CardContent>
{isLoadingConfig ? (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
) : editingConfig ? (
<form onSubmit={handleSubmit(handleSaveConfig)} className="space-y-3">
<div className="flex items-center gap-3">
<input
type="checkbox"
id="enabled"
{...register('enabled')}
className="h-4 w-4"
/>
<Label htmlFor="enabled">Enabled</Label>
</div>
<div>
<Label htmlFor="endpoint">Public Endpoint</Label>
<Input
id="endpoint"
{...register('endpoint')}
placeholder="vpn.example.com or 1.2.3.4"
className="mt-1"
/>
<p className="text-xs text-muted-foreground mt-1">
Public hostname or IP clients use to connect. Port appended automatically if omitted.
</p>
</div>
<div>
<Label htmlFor="listenPort">Listen Port</Label>
<Input
id="listenPort"
type="number"
{...register('listenPort', { required: 'Required', min: { value: 1, message: 'Invalid port' }, max: { value: 65535, message: 'Invalid port' } })}
className="mt-1"
/>
{errors.listenPort && <p className="text-sm text-red-600 mt-1">{errors.listenPort.message}</p>}
</div>
<div>
<Label htmlFor="address">Server VPN Address</Label>
<Input
id="address"
{...register('address', { required: 'Required' })}
placeholder="10.8.0.1/24"
className="mt-1 font-mono"
/>
{errors.address && <p className="text-sm text-red-600 mt-1">{errors.address.message}</p>}
</div>
<div>
<Label htmlFor="dns">DNS for Clients</Label>
<Input
id="dns"
{...register('dns')}
placeholder="10.8.0.1"
className="mt-1 font-mono"
/>
</div>
<div>
<Label htmlFor="lanCIDR">LAN CIDR (optional)</Label>
<Input
id="lanCIDR"
{...register('lanCIDR')}
placeholder="192.168.8.0/24"
className="mt-1 font-mono"
/>
<p className="text-xs text-muted-foreground mt-1">
Routes LAN traffic through the VPN and enables masquerade on the server.
</p>
</div>
<div className="flex gap-2 pt-2">
<Button type="submit" size="sm" disabled={isUpdatingConfig}>
{isUpdatingConfig && <Loader2 className="h-3 w-3 animate-spin mr-1" />}
Save
</Button>
<Button type="button" variant="outline" size="sm" onClick={() => setEditingConfig(false)}>
Cancel
</Button>
</div>
</form>
) : (
<div className="space-y-3 text-sm">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<p className="text-muted-foreground">Status</p>
<Badge variant={config?.enabled ? 'success' : 'secondary'} className="mt-0.5 gap-1">
{config?.enabled && <CheckCircle className="h-3 w-3" />}
{config?.enabled ? 'Enabled' : 'Disabled'}
</Badge>
</div>
<div>
<p className="text-muted-foreground">Public Endpoint</p>
<p className="font-mono bg-muted rounded px-2 py-1 mt-0.5">
{config?.endpoint || <span className="text-muted-foreground italic">not set</span>}
</p>
</div>
<div>
<p className="text-muted-foreground">Listen Port</p>
<p className="font-mono bg-muted rounded px-2 py-1 mt-0.5">{config?.listenPort ?? 51820}</p>
</div>
<div>
<p className="text-muted-foreground">VPN Subnet</p>
<p className="font-mono bg-muted rounded px-2 py-1 mt-0.5">{config?.address || '—'}</p>
</div>
<div>
<p className="text-muted-foreground">DNS for Clients</p>
<p className="font-mono bg-muted rounded px-2 py-1 mt-0.5">{config?.dns || '—'}</p>
</div>
<div>
<p className="text-muted-foreground">LAN CIDR</p>
<p className="font-mono bg-muted rounded px-2 py-1 mt-0.5">{config?.lanCIDR || '—'}</p>
</div>
<div>
<p className="text-muted-foreground">Public Key</p>
<div className="flex items-center gap-1 mt-0.5">
<p className="font-mono bg-muted rounded px-2 py-1 truncate text-xs flex-1">
{config?.publicKey || <span className="italic">not generated</span>}
</p>
{config?.publicKey && <CopyButton text={config.publicKey} />}
</div>
</div>
</div>
{!config?.publicKey && (
<Alert>
<KeyRound className="h-4 w-4" />
<AlertDescription>
No server keypair yet. Click <strong>Generate Keys</strong> before adding peers.
</AlertDescription>
</Alert>
)}
</div>
)}
</CardContent>
</Card>
{/* Peers card */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Peers</CardTitle>
<Button
size="sm"
onClick={() => setAddPeerOpen(true)}
disabled={!config?.publicKey}
className="gap-1 h-7 text-xs"
>
<Plus className="h-3 w-3" />
Add Peer
</Button>
</div>
</CardHeader>
<CardContent>
{isLoadingPeers ? (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
) : peers.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<Shield className="h-10 w-10 mx-auto mb-2 opacity-30" />
<p className="text-sm">No peers yet. Add a peer to get started.</p>
</div>
) : (
<div className="divide-y">
{peers.map((peer) => (
<div key={peer.id} className="flex items-center justify-between py-3">
<div>
<p className="font-medium text-sm">{peer.name}</p>
<p className="text-xs text-muted-foreground font-mono">{peer.allowedIPs}</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="gap-1 h-7 text-xs"
disabled={loadingPeerConfig === peer.id}
onClick={() => handleShowPeerConfig(peer)}
>
{loadingPeerConfig === peer.id
? <Loader2 className="h-3 w-3 animate-spin" />
: <RefreshCw className="h-3 w-3" />}
Config
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-destructive hover:text-destructive"
onClick={() => setDeleteConfirmPeer(peer)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Add peer drawer */}
<Sheet open={addPeerOpen} onOpenChange={setAddPeerOpen}>
<SheetContent>
<SheetHeader>
<SheetTitle>Add Peer</SheetTitle>
</SheetHeader>
<div className="space-y-4 py-6">
<div>
<Label htmlFor="peerName">Peer Name</Label>
<Input
id="peerName"
value={newPeerName}
onChange={(e) => setNewPeerName(e.target.value)}
placeholder="e.g. my-phone, laptop"
className="mt-1"
onKeyDown={(e) => e.key === 'Enter' && handleAddPeer()}
/>
<p className="text-xs text-muted-foreground mt-1">
A VPN IP will be assigned automatically from the server subnet.
</p>
</div>
{addPeerError && (
<Alert variant="error">
<AlertDescription>{String(addPeerError)}</AlertDescription>
</Alert>
)}
</div>
<SheetFooter>
<Button onClick={handleAddPeer} disabled={isAddingPeer || !newPeerName.trim()}>
{isAddingPeer && <Loader2 className="h-4 w-4 animate-spin mr-2" />}
Add Peer
</Button>
<Button variant="outline" onClick={() => setAddPeerOpen(false)}>Cancel</Button>
</SheetFooter>
</SheetContent>
</Sheet>
{/* Peer config modal */}
<Dialog open={!!peerConfigModal} onOpenChange={(open) => !open && setPeerConfigModal(null)}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{peerConfigModal?.name} Client Config</DialogTitle>
</DialogHeader>
{peerConfigModal && (
<div className="space-y-4">
<div className="flex justify-center">
<QRCodeSVG value={peerConfigModal.config} size={220} />
</div>
<p className="text-xs text-center text-muted-foreground">
Scan with the WireGuard mobile app
</p>
<div className="relative">
<pre className="text-xs font-mono bg-muted rounded-md p-3 overflow-x-auto whitespace-pre-wrap break-all">
{peerConfigModal.config}
</pre>
<div className="absolute top-2 right-2">
<CopyButton text={peerConfigModal.config} />
</div>
</div>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setPeerConfigModal(null)}>Close</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Generate keypair confirmation */}
<AlertDialog open={confirmKeygen} onOpenChange={setConfirmKeygen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Regenerate Server Keypair?</AlertDialogTitle>
<AlertDialogDescription>
This will generate a new server private and public key. All existing peer configs
will become invalid you'll need to regenerate and redistribute them.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleGenerateKeypair}>Generate</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Delete peer confirmation */}
<AlertDialog open={!!deleteConfirmPeer} onOpenChange={(open) => !open && setDeleteConfirmPeer(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete peer "{deleteConfirmPeer?.name}"?</AlertDialogTitle>
<AlertDialogDescription>
This will remove the peer configuration. The peer will no longer be able to connect
after you apply the VPN config.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDeletePeer}>Delete</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}