import { useState, useEffect } from 'react';
import { Plus, Trash2, Edit, Eye, Shield, Copy, Download, Key, AlertTriangle, CheckCircle, Globe, Lock, Users, Server } from 'lucide-react';
import { peerRegistryAPI, wireguardAPI, cellLinkAPI, getCsrfToken } from '../services/api';
import { useConfig } from '../contexts/ConfigContext';
import QRCode from 'qrcode';
const FULL_TUNNEL_IPS = '0.0.0.0/0, ::/0';
const emptyForm = () => ({
name: '',
description: '',
public_key: '',
persistent_keepalive: 25,
internet_access: true,
service_access: ['calendar', 'files', 'mail', 'webdav'],
peer_access: true,
create_calendar: false,
password: '',
});
const generatePassword = () => {
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%';
const arr = new Uint8Array(14);
crypto.getRandomValues(arr);
return Array.from(arr).map(b => chars[b % chars.length]).join('');
};
function AccessBadge({ icon: Icon, label, active }) {
return (
{label}
);
}
function Toggle({ checked, onChange, label, description }) {
return (
onChange(e.target.checked)} />
{label}
{description &&
{description}
}
);
}
function Peers() {
const { domain = 'cell' } = useConfig();
const SERVICES = [
{ key: 'calendar', label: 'Calendar', domain: `calendar.${domain}` },
{ key: 'files', label: 'Files', domain: `files.${domain}` },
{ key: 'mail', label: 'Webmail', domain: `mail.${domain}` },
{ key: 'webdav', label: 'WebDAV', domain: `webdav.${domain}` },
];
const [peers, setPeers] = useState([]);
const [connectedCells, setConnectedCells] = useState([]);
const [serverConf, setServerConf] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [showAddModal, setShowAddModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [showViewModal, setShowViewModal] = useState(false);
const [selectedPeer, setSelectedPeer] = useState(null);
const [formData, setFormData] = useState(emptyForm());
const [showAdvanced, setShowAdvanced] = useState(false);
const [peerConfig, setPeerConfig] = useState('');
const [qrCodeDataUrl, setQrCodeDataUrl] = useState('');
const [isGeneratingKeys, setIsGeneratingKeys] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [errors, setErrors] = useState({});
const [toast, setToast] = useState(null);
useEffect(() => {
fetchPeers();
cellLinkAPI.listConnections().then(r => setConnectedCells(r.data || [])).catch(() => {});
}, []);
const showToast = (msg, type = 'success') => {
setToast({ msg, type });
setTimeout(() => setToast(null), 4000);
};
const fetchPeers = async () => {
try {
const [regResp, statusResp, scResp] = await Promise.all([
peerRegistryAPI.getPeers(),
wireguardAPI.getPeerStatuses().catch(() => ({ data: {} })),
fetch('/api/wireguard/server-config', { credentials: 'include' }).then(r => r.ok ? r.json() : null).catch(() => null),
]);
const regPeers = regResp.data || [];
const statusMap = statusResp.data || {};
const merged = regPeers.map(p => ({
...p,
name: p.peer || p.name,
online: statusMap[p.public_key]?.online ?? false,
last_handshake: statusMap[p.public_key]?.last_handshake ?? null,
last_handshake_seconds_ago: statusMap[p.public_key]?.last_handshake_seconds_ago ?? null,
transfer_rx: statusMap[p.public_key]?.transfer_rx ?? 0,
transfer_tx: statusMap[p.public_key]?.transfer_tx ?? 0,
}));
setPeers(merged);
if (scResp) setServerConf(scResp);
} catch (err) {
console.error('Failed to fetch peers:', err);
} finally {
setIsLoading(false);
}
};
const getServerConfig = async () => {
if (serverConf) return serverConf;
try {
const r = await fetch('/api/wireguard/server-config', { credentials: 'include' });
if (r.ok) {
const sc = await r.json();
setServerConf(sc);
return sc;
}
} catch {}
return { public_key: 'SERVER_PUBLIC_KEY_PLACEHOLDER', endpoint: 'YOUR_SERVER_IP:51820' };
};
const generateConfig = (peer, sc) => {
const privateKey = peer.private_key || 'YOUR_PRIVATE_KEY_HERE';
const serverPubKey = peer.server_public_key || sc?.public_key || 'SERVER_PUBLIC_KEY_PLACEHOLDER';
const endpoint = peer.server_endpoint || sc?.endpoint || 'YOUR_SERVER_IP:51820';
const address = peer.ip?.includes('/') ? peer.ip : `${peer.ip}/32`;
const splitTunnelIPs = sc?.split_tunnel_ips || `10.0.0.0/24, 172.20.0.0/16`;
const allowedIPs = peer.internet_access !== false ? FULL_TUNNEL_IPS : splitTunnelIPs;
const dnsIp = sc?.dns_ip || '172.20.0.3';
return `[Interface]
PrivateKey = ${privateKey}
Address = ${address}
DNS = ${dnsIp}
[Peer]
PublicKey = ${serverPubKey}
Endpoint = ${endpoint}
AllowedIPs = ${allowedIPs}
PersistentKeepalive = ${peer.persistent_keepalive || 25}`;
};
const generateKeys = async () => {
if (!formData.name) { setErrors(e => ({ ...e, name: 'Enter a name first' })); return; }
setIsGeneratingKeys(true);
try {
const r = await wireguardAPI.generatePeerKeys({ peer_name: formData.name });
setFormData(f => ({ ...f, public_key: r.data.public_key, _private_key: r.data.private_key }));
} catch { showToast('Failed to generate keys', 'error'); }
finally { setIsGeneratingKeys(false); }
};
const validate = (data) => {
const errs = {};
if (!data.name.trim()) errs.name = 'Name is required';
if (!/^[a-zA-Z0-9_-]+$/.test(data.name)) errs.name = 'Only letters, numbers, - and _ allowed';
return errs;
};
const handleAddPeer = async (e) => {
e.preventDefault();
const errs = validate(formData);
if (!formData.password || formData.password.length < 10) errs.password = 'Password must be at least 10 characters';
if (Object.keys(errs).length) { setErrors(errs); return; }
setIsSubmitting(true);
try {
let publicKey = formData.public_key;
let privateKey = formData._private_key || null;
if (!publicKey) {
const kr = await wireguardAPI.generatePeerKeys({ peer_name: formData.name });
publicKey = kr.data.public_key;
privateKey = kr.data.private_key;
}
const serverConf = await getServerConfig();
const peerData = {
name: formData.name,
ip: formData.ip,
public_key: publicKey,
private_key: privateKey,
server_public_key: serverConf.public_key,
server_endpoint: serverConf.endpoint,
persistent_keepalive: formData.persistent_keepalive,
description: formData.description,
internet_access: formData.internet_access,
service_access: formData.service_access,
peer_access: formData.peer_access,
password: formData.password,
};
const addResult = await peerRegistryAPI.addPeer(peerData);
if (formData.create_calendar) {
try {
await fetch(`/api/calendar/create-user-collection?user=${formData.name}`, {
method: 'POST',
credentials: 'include',
headers: { 'X-CSRF-Token': getCsrfToken() || '' },
});
} catch {}
}
const provisioned = addResult.data?.provisioned;
const createdName = formData.name;
const provisionedList = provisioned
? Object.entries(provisioned).filter(([, v]) => v).map(([k]) => k).join(', ')
: '';
setShowAddModal(false);
setFormData(emptyForm());
setErrors({});
fetchPeers();
showToast(
`Peer "${createdName}" created.` + (provisionedList ? ` Accounts: ${provisionedList}.` : ''),
'success'
);
} catch (err) {
showToast(err?.response?.data?.error || 'Failed to add peer', 'error');
} finally { setIsSubmitting(false); }
};
const handleUpdatePeer = async (e) => {
e.preventDefault();
const errs = validate(formData);
if (Object.keys(errs).length) { setErrors(errs); return; }
setIsSubmitting(true);
try {
const r = await fetch(`/api/peers/${selectedPeer.name}`, {
method: 'PUT',
credentials: 'include',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCsrfToken() || '' },
body: JSON.stringify({
description: formData.description,
internet_access: formData.internet_access,
service_access: formData.service_access,
peer_access: formData.peer_access,
persistent_keepalive: formData.persistent_keepalive,
}),
});
const result = await r.json();
// Server-side AllowedIPs for the peer must stay as /32 (host route only)
const existingIp = selectedPeer.ip?.includes('/') ? selectedPeer.ip : `${selectedPeer.ip}/32`;
await wireguardAPI.addPeer({
name: selectedPeer.name,
public_key: formData.public_key || selectedPeer.public_key,
allowed_ips: existingIp,
persistent_keepalive: formData.persistent_keepalive,
});
// Route-via is a separate endpoint (triggers WG + iptables changes)
const oldRouteVia = selectedPeer.route_via || null;
const newRouteVia = formData.route_via || null;
if (oldRouteVia !== newRouteVia) {
await peerRegistryAPI.setRouteVia(selectedPeer.name, newRouteVia);
}
setShowEditModal(false);
setSelectedPeer(null);
fetchPeers();
if (result.config_changed) {
showToast(`Peer updated. Config changed — ask them to reinstall the tunnel.`, 'warning');
} else {
showToast('Peer updated successfully.');
}
} catch (err) {
showToast('Failed to update peer', 'error');
} finally { setIsSubmitting(false); }
};
const handleRemovePeer = async (peerName) => {
if (!window.confirm(`Remove peer "${peerName}"?`)) return;
try {
await peerRegistryAPI.removePeer(peerName);
fetchPeers();
showToast(`Peer "${peerName}" removed.`);
} catch { showToast('Failed to remove peer', 'error'); }
};
const handleViewPeer = async (peer) => {
setSelectedPeer(peer);
setQrCodeDataUrl('');
setPeerConfig('');
setShowViewModal(true);
try {
const serverConf = await getServerConfig();
let config;
try {
const r = await wireguardAPI.getPeerConfig({ name: peer.name });
config = r.data.config;
} catch {}
if (!config || config.includes('PLACEHOLDER')) {
config = generateConfig({ ...peer, server_public_key: serverConf.public_key, server_endpoint: serverConf.endpoint }, serverConf);
}
setPeerConfig(config);
const qr = await QRCode.toDataURL(config, { width: 256, margin: 2, errorCorrectionLevel: 'M' });
setQrCodeDataUrl(qr);
} catch (err) {
console.error('Failed to load peer config:', err);
}
};
const handleConfigDownloaded = async (peerName) => {
try {
await fetch(`/api/peers/${peerName}/clear-reinstall`, {
method: 'POST',
credentials: 'include',
headers: { 'X-CSRF-Token': getCsrfToken() || '' },
});
setPeers(ps => ps.map(p => p.name === peerName ? { ...p, config_needs_reinstall: false } : p));
} catch {}
};
const handleEditPeer = (peer) => {
setSelectedPeer(peer);
setFormData({
name: peer.name,
ip: peer.ip || '',
description: peer.description || '',
public_key: peer.public_key || '',
persistent_keepalive: peer.persistent_keepalive || 25,
internet_access: peer.internet_access !== false,
service_access: peer.service_access || ['calendar', 'files', 'mail', 'webdav'],
peer_access: peer.peer_access !== false,
create_calendar: false,
route_via: peer.route_via || null,
});
setErrors({});
setShowAdvanced(false);
setShowEditModal(true);
};
const toggleService = (key) => {
setFormData(f => ({
...f,
service_access: f.service_access.includes(key)
? f.service_access.filter(s => s !== key)
: [...f.service_access, key],
}));
};
const copyToClipboard = async (text) => {
try { await navigator.clipboard.writeText(text); showToast('Copied to clipboard!'); }
catch { showToast('Copy failed', 'error'); }
};
const downloadConfig = (peerName, config) => {
const blob = new Blob([config], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = `${peerName}.conf`;
document.body.appendChild(a); a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
if (selectedPeer) handleConfigDownloaded(selectedPeer.name);
};
const AccessForm = ({ data, onChange }) => (
Internet Access
onChange({ internet_access: true })}
className={`flex-1 px-3 py-2 rounded border text-sm font-medium transition-colors ${data.internet_access ? 'bg-primary-600 text-white border-primary-600' : 'bg-white text-gray-600 border-gray-300 hover:border-primary-400'}`}>
Full tunnel
All traffic via PIC server
onChange({ internet_access: false })}
className={`flex-1 px-3 py-2 rounded border text-sm font-medium transition-colors ${!data.internet_access ? 'bg-primary-600 text-white border-primary-600' : 'bg-white text-gray-600 border-gray-300 hover:border-primary-400'}`}>
Split tunnel
Only PIC services via VPN
Peer Communication
onChange({ peer_access: v })}
label="Allow peer-to-peer traffic"
description={`This peer can communicate with other VPN peers (${serverConf?.vpn_network || 'VPN subnet'})`}
/>
{connectedCells.length > 0 && (
Internet Exit
onChange({ route_via: e.target.value || null })}
className="input"
>
Direct (this cell's connection)
{connectedCells.map(cell => (
Via {cell.cell_name}
{cell.remote_exit_offered ? ' ✓ offers internet' : ''}
))}
Route this peer's internet traffic through a connected cell.
{data.route_via && !connectedCells.find(c => c.cell_name === data.route_via)?.remote_exit_offered && (
The selected cell hasn't offered their internet yet.
)}
)}
);
if (isLoading) return (
);
return (
{/* Toast */}
{toast && (
{toast.type === 'error' ?
:
}
{toast.msg}
)}
Peers
Manage VPN peer connections and access policies
{ setFormData(emptyForm()); setErrors({}); setShowAdvanced(false); setShowAddModal(true); }}
className="btn btn-primary flex items-center">
Add Peer
{/* Peers Table */}
Peer
IP
Access
Status
Actions
{peers.length === 0 ? (
No peers configured.
) : peers.map(peer => (
{peer.name}
{peer.config_needs_reinstall && (
Reinstall tunnel
)}
{peer.description &&
{peer.description}
}
{peer.ip}
{SERVICES.map(s => (
s.key)).includes(s.key)} />
))}
{peer.route_via && (
via {peer.route_via}
)}
{peer.online ? 'Online' : 'Offline'}
{peer.last_handshake_seconds_ago != null && (
{peer.last_handshake_seconds_ago}s ago
)}
handleViewPeer(peer)} className="text-primary-600 hover:text-primary-900" title="View Config & QR">
handleEditPeer(peer)} className="text-primary-600 hover:text-primary-900" title="Edit Peer">
handleRemovePeer(peer.name)} className="text-red-600 hover:text-red-900" title="Remove Peer">
))}
{/* Add Peer Modal */}
{showAddModal && (
{ if (e.target === e.currentTarget) setShowAddModal(false); }}>
Add New Peer
setShowAddModal(false)} className="text-gray-400 hover:text-gray-600">✕
)}
{/* Edit Peer Modal */}
{showEditModal && selectedPeer && (
{ if (e.target === e.currentTarget) { setShowEditModal(false); setSelectedPeer(null); } }}>
Edit Peer — {selectedPeer.name}
{ setShowEditModal(false); setSelectedPeer(null); }} className="text-gray-400 hover:text-gray-600">✕
{selectedPeer.config_needs_reinstall && (
Config changed since last tunnel install. After saving, download the updated config and reinstall on the device.
)}
Description
setFormData(f => ({ ...f, description: e.target.value }))} className="input" />
setFormData(f => ({ ...f, ...updates }))} />
setShowAdvanced(!showAdvanced)}
className="text-sm text-primary-600 hover:text-primary-800 flex items-center gap-1 mt-2">
{showAdvanced ? 'Hide' : 'Show'} Advanced Options
{showAdvanced && (
Keepalive (s)
setFormData(f => ({ ...f, persistent_keepalive: parseInt(e.target.value) }))}
className="input w-32" />
)}
{ setShowEditModal(false); setSelectedPeer(null); }} className="btn btn-secondary">Cancel
{isSubmitting ? 'Saving…' : 'Save Changes'}
)}
{/* View Config Modal */}
{showViewModal && selectedPeer && (
{ if (e.target === e.currentTarget) setShowViewModal(false); }}>
{selectedPeer.name} — Tunnel Config
setShowViewModal(false)} className="text-gray-400 hover:text-gray-600">✕
{selectedPeer.config_needs_reinstall && (
Config changed. Download the updated config below and reinstall the WireGuard tunnel on the device. The old config will no longer work correctly.
)}
WireGuard Configuration File
{peerConfig ? (
copyToClipboard(peerConfig)}
className="btn btn-secondary btn-sm flex items-center gap-1">
Copy
downloadConfig(selectedPeer.name, peerConfig)}
className="btn btn-primary btn-sm flex items-center gap-1">
Download .conf
) : (
)}
DNS is set to {serverConf?.dns_ip || '172.20.0.3'} (PIC CoreDNS) — required to resolve .{domain} domains.
QR Code — Scan with WireGuard app
{qrCodeDataUrl ? (
{ const a = document.createElement('a'); a.href = qrCodeDataUrl; a.download = `${selectedPeer.name}-qr.png`; a.click(); handleConfigDownloaded(selectedPeer.name); }}
className="btn btn-secondary btn-sm flex items-center gap-1 mx-auto">
Download QR
) : (
{peerConfig ? 'Generating QR…' : 'Loading…'}
)}
Tunnel: {selectedPeer.internet_access !== false ? 'Full' : 'Split'}
{' · '}Services: {(selectedPeer.service_access || SERVICES.map(s=>s.key)).join(', ')}
{ setShowViewModal(false); handleEditPeer(selectedPeer); }} className="btn btn-secondary flex items-center gap-1">
Edit
setShowViewModal(false)} className="btn btn-secondary">Close
)}
);
}
export default Peers;