Files
pic/webui/src/pages/Peers.jsx
T
roof 94957abd23 feat(webui): internet sharing UI — exit-offer toggle + peer route-via selector
CellNetwork page (CellPanel):
- Internet Sharing section below service toggles
- Toggle: 'Offer my internet to <cell>' (calls PUT /api/cells/<n>/exit-offer)
- Read-only indicator: whether remote cell offers internet back
- Contextual hints explaining what each party needs to do next

Peers page:
- Fetches connected cells on mount
- Edit modal: Internet Exit dropdown (route-via) showing all connected cells
  with ✓ marker for cells that have offered internet
- Warning if selected cell hasn't offered internet yet
- On save, calls PUT /api/peers/<n>/route-via only when value changed
- Table badge shows 'via <cell>' for peers with active routing

api.js:
- cellLinkAPI.setExitOffer(cellName, offered)
- peerRegistryAPI.setRouteVia(peerName, viaCell)

Tests (vitest + @testing-library/react):
- 19 new frontend tests in src/__tests__/
  - CellNetworkInternetSharing.test.jsx (10 tests)
  - PeersRouteVia.test.jsx (9 tests)
- make test-webui target runs them via docker node:18-alpine

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 23:07:50 -04:00

817 lines
38 KiB
React

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 (
<span className={`inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium mr-1 ${
active ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-400'
}`}>
<Icon className="h-3 w-3 mr-0.5" />
{label}
</span>
);
}
function Toggle({ checked, onChange, label, description }) {
return (
<label className="flex items-start gap-3 cursor-pointer">
<div className="relative mt-0.5">
<input type="checkbox" className="sr-only" checked={checked} onChange={e => onChange(e.target.checked)} />
<div className={`w-10 h-6 rounded-full transition-colors ${checked ? 'bg-primary-600' : 'bg-gray-300'}`} />
<div className={`absolute top-1 left-1 w-4 h-4 rounded-full bg-white shadow transition-transform ${checked ? 'translate-x-4' : ''}`} />
</div>
<div>
<div className="text-sm font-medium text-gray-900">{label}</div>
{description && <div className="text-xs text-gray-500 mt-0.5">{description}</div>}
</div>
</label>
);
}
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 }) => (
<div className="space-y-5 pt-4 border-t border-gray-200">
<div>
<div className="text-sm font-semibold text-gray-700 mb-3 flex items-center gap-1.5">
<Globe className="h-4 w-4" /> Internet Access
</div>
<div className="flex gap-2">
<button type="button"
onClick={() => 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'}`}>
<Globe className="h-4 w-4 inline mr-1" />Full tunnel
<div className="text-xs font-normal mt-0.5 opacity-75">All traffic via PIC server</div>
</button>
<button type="button"
onClick={() => 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'}`}>
<Lock className="h-4 w-4 inline mr-1" />Split tunnel
<div className="text-xs font-normal mt-0.5 opacity-75">Only PIC services via VPN</div>
</button>
</div>
</div>
<div>
<div className="text-sm font-semibold text-gray-700 mb-3 flex items-center gap-1.5">
<Server className="h-4 w-4" /> Service Access
</div>
<div className="grid grid-cols-2 gap-2">
{SERVICES.map(svc => (
<label key={svc.key} className={`flex items-center gap-2 p-2.5 rounded border cursor-pointer transition-colors ${
data.service_access?.includes(svc.key) ? 'bg-green-50 border-green-300' : 'bg-gray-50 border-gray-200'
}`}>
<input type="checkbox" className="rounded"
checked={data.service_access?.includes(svc.key) ?? true}
onChange={() => onChange({ service_access: data.service_access?.includes(svc.key)
? data.service_access.filter(s => s !== svc.key)
: [...(data.service_access || []), svc.key]
})} />
<div>
<div className="text-sm font-medium text-gray-900">{svc.label}</div>
<div className="text-xs text-gray-500">{svc.domain}</div>
</div>
</label>
))}
</div>
</div>
<div>
<div className="text-sm font-semibold text-gray-700 mb-3 flex items-center gap-1.5">
<Users className="h-4 w-4" /> Peer Communication
</div>
<Toggle
checked={data.peer_access !== false}
onChange={v => onChange({ peer_access: v })}
label="Allow peer-to-peer traffic"
description={`This peer can communicate with other VPN peers (${serverConf?.vpn_network || 'VPN subnet'})`}
/>
</div>
{connectedCells.length > 0 && (
<div>
<div className="text-sm font-semibold text-gray-700 mb-2 flex items-center gap-1.5">
<Globe className="h-4 w-4" /> Internet Exit
</div>
<select
value={data.route_via || ''}
onChange={e => onChange({ route_via: e.target.value || null })}
className="input"
>
<option value="">Direct (this cell's connection)</option>
{connectedCells.map(cell => (
<option key={cell.cell_name} value={cell.cell_name}>
Via {cell.cell_name}
{cell.remote_exit_offered ? ' offers internet' : ''}
</option>
))}
</select>
<p className="text-xs text-gray-400 mt-1">
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 && (
<span className="text-yellow-600 ml-1">The selected cell hasn't offered their internet yet.</span>
)}
</p>
</div>
)}
</div>
);
if (isLoading) return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
</div>
);
return (
<div>
{/* Toast */}
{toast && (
<div className={`fixed top-4 right-4 z-50 px-4 py-3 rounded-lg shadow-lg text-sm font-medium flex items-center gap-2 ${
toast.type === 'error' ? 'bg-red-600 text-white' :
toast.type === 'warning' ? 'bg-yellow-500 text-white' : 'bg-green-600 text-white'
}`}>
{toast.type === 'error' ? <AlertTriangle className="h-4 w-4" /> : <CheckCircle className="h-4 w-4" />}
{toast.msg}
</div>
)}
<div className="mb-8 flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold text-gray-900">Peers</h1>
<p className="mt-1 text-gray-600">Manage VPN peer connections and access policies</p>
</div>
<button onClick={() => { setFormData(emptyForm()); setErrors({}); setShowAdvanced(false); setShowAddModal(true); }}
className="btn btn-primary flex items-center">
<Plus className="h-4 w-4 mr-2" />Add Peer
</button>
</div>
{/* Peers Table */}
<div className="card">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Peer</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">IP</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Access</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{peers.length === 0 ? (
<tr><td colSpan="5" className="px-6 py-8 text-center text-gray-500">No peers configured.</td></tr>
) : peers.map(peer => (
<tr key={peer.name} className="hover:bg-gray-50">
<td className="px-6 py-4">
<div className="flex items-center gap-2">
<Shield className="h-4 w-4 text-primary-500 flex-shrink-0" />
<div>
<div className="text-sm font-medium text-gray-900 flex items-center gap-1.5">
{peer.name}
{peer.config_needs_reinstall && (
<span className="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded text-xs font-medium bg-yellow-100 text-yellow-800">
<AlertTriangle className="h-3 w-3" /> Reinstall tunnel
</span>
)}
</div>
{peer.description && <div className="text-xs text-gray-500">{peer.description}</div>}
</div>
</div>
</td>
<td className="px-6 py-4 text-sm text-gray-900 font-mono">{peer.ip}</td>
<td className="px-6 py-4">
<div className="flex flex-wrap gap-0.5">
<AccessBadge icon={Globe} label="Internet" active={peer.internet_access !== false} />
{SERVICES.map(s => (
<AccessBadge key={s.key} icon={Server} label={s.label} active={(peer.service_access || SERVICES.map(s=>s.key)).includes(s.key)} />
))}
<AccessBadge icon={Users} label="Peers" active={peer.peer_access !== false} />
{peer.route_via && (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-700 mr-1">
<Globe className="h-3 w-3 mr-0.5" />via {peer.route_via}
</span>
)}
</div>
</td>
<td className="px-6 py-4">
<span className={`status-indicator ${peer.online ? 'status-online' : 'status-offline'}`}>
{peer.online ? 'Online' : 'Offline'}
</span>
{peer.last_handshake_seconds_ago != null && (
<div className="text-xs text-gray-400 mt-0.5">{peer.last_handshake_seconds_ago}s ago</div>
)}
</td>
<td className="px-6 py-4">
<div className="flex space-x-2">
<button onClick={() => handleViewPeer(peer)} className="text-primary-600 hover:text-primary-900" title="View Config & QR">
<Eye className="h-4 w-4" />
</button>
<button onClick={() => handleEditPeer(peer)} className="text-primary-600 hover:text-primary-900" title="Edit Peer">
<Edit className="h-4 w-4" />
</button>
<button onClick={() => handleRemovePeer(peer.name)} className="text-red-600 hover:text-red-900" title="Remove Peer">
<Trash2 className="h-4 w-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Add Peer Modal */}
{showAddModal && (
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50"
onClick={e => { if (e.target === e.currentTarget) setShowAddModal(false); }}>
<div className="relative top-10 mx-auto p-6 border w-full max-w-lg shadow-lg rounded-md bg-white mb-10">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Shield className="h-6 w-6 text-primary-500" />
<h3 className="text-lg font-medium text-gray-900">Add New Peer</h3>
</div>
<button onClick={() => setShowAddModal(false)} className="text-gray-400 hover:text-gray-600">✕</button>
</div>
<form onSubmit={handleAddPeer} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Name *</label>
<input value={formData.name}
onChange={e => { setFormData(f => ({ ...f, name: e.target.value })); setErrors(e2 => ({ ...e2, name: undefined })); }}
className={`input ${errors.name ? 'border-red-500' : ''}`} placeholder="mobile-phone" />
{errors.name && <p className="text-xs text-red-600 mt-1">{errors.name}</p>}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Description</label>
<input value={formData.description} onChange={e => setFormData(f => ({ ...f, description: e.target.value }))}
className="input" placeholder="My laptop" />
</div>
</div>
<AccessForm data={formData} onChange={updates => setFormData(f => ({ ...f, ...updates }))} />
{/* Account Creation */}
<div className="pt-3 border-t border-gray-200">
<div className="text-sm font-semibold text-gray-700 mb-2">Account Setup</div>
<div className="mb-3">
<label className="block text-sm font-medium text-gray-700 mb-1">Dashboard Password *</label>
<div className="flex gap-2">
<input
type="password"
value={formData.password}
onChange={e => { setFormData(f => ({ ...f, password: e.target.value })); setErrors(e2 => ({ ...e2, password: undefined })); }}
className={`input flex-1 ${errors.password ? 'border-red-500' : ''}`}
placeholder="Min 10 characters"
autoComplete="new-password"
/>
<button type="button"
onClick={() => setFormData(f => ({ ...f, password: generatePassword() }))}
className="btn btn-secondary text-xs whitespace-nowrap">
Generate
</button>
</div>
{errors.password && <p className="text-xs text-red-600 mt-1">{errors.password}</p>}
</div>
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={formData.create_calendar}
onChange={e => setFormData(f => ({ ...f, create_calendar: e.target.checked }))} className="rounded" />
<span className="text-sm text-gray-700">Create calendar collection for this peer</span>
</label>
</div>
{/* Advanced */}
<div className="pt-2 border-t border-gray-200">
<button type="button" onClick={() => setShowAdvanced(!showAdvanced)}
className="text-sm text-primary-600 hover:text-primary-800 flex items-center gap-1">
<Key className="h-4 w-4" />
{showAdvanced ? 'Hide' : 'Show'} Advanced Options
</button>
{showAdvanced && (
<div className="mt-3 space-y-3">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Public Key</label>
<div className="flex gap-2">
<textarea value={formData.public_key}
onChange={e => setFormData(f => ({ ...f, public_key: e.target.value }))}
className="input flex-1" rows="2" placeholder="Leave empty to auto-generate" />
<button type="button" onClick={generateKeys} disabled={isGeneratingKeys}
className="btn btn-secondary text-xs self-start mt-0.5">
{isGeneratingKeys ? '' : 'Generate'}
</button>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Keepalive (s)</label>
<input type="number" value={formData.persistent_keepalive} min="0" max="65535"
onChange={e => setFormData(f => ({ ...f, persistent_keepalive: parseInt(e.target.value) }))}
className="input w-32" />
</div>
</div>
)}
</div>
<div className="flex justify-end gap-3 pt-2">
<button type="button" onClick={() => setShowAddModal(false)} className="btn btn-secondary">Cancel</button>
<button type="submit" disabled={isSubmitting} className="btn btn-primary flex items-center gap-2">
<Plus className="h-4 w-4" />{isSubmitting ? 'Adding' : 'Add Peer'}
</button>
</div>
</form>
</div>
</div>
)}
{/* Edit Peer Modal */}
{showEditModal && selectedPeer && (
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50"
onClick={e => { if (e.target === e.currentTarget) { setShowEditModal(false); setSelectedPeer(null); } }}>
<div className="relative top-10 mx-auto p-6 border w-full max-w-lg shadow-lg rounded-md bg-white mb-10">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Shield className="h-6 w-6 text-primary-500" />
<h3 className="text-lg font-medium text-gray-900">Edit Peer — {selectedPeer.name}</h3>
</div>
<button onClick={() => { setShowEditModal(false); setSelectedPeer(null); }} className="text-gray-400 hover:text-gray-600">✕</button>
</div>
{selectedPeer.config_needs_reinstall && (
<div className="mb-4 p-3 bg-yellow-50 border border-yellow-200 rounded-md flex items-start gap-2 text-sm text-yellow-800">
<AlertTriangle className="h-4 w-4 flex-shrink-0 mt-0.5" />
<div>Config changed since last tunnel install. After saving, download the updated config and reinstall on the device.</div>
</div>
)}
<form onSubmit={handleUpdatePeer} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
<input value={formData.name} className="input bg-gray-50" disabled />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">VPN IP</label>
<input value={selectedPeer?.ip || ''} className="input bg-gray-50 font-mono" disabled />
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Description</label>
<input value={formData.description} onChange={e => setFormData(f => ({ ...f, description: e.target.value }))} className="input" />
</div>
<AccessForm data={formData} onChange={updates => setFormData(f => ({ ...f, ...updates }))} />
<div>
<button type="button" onClick={() => setShowAdvanced(!showAdvanced)}
className="text-sm text-primary-600 hover:text-primary-800 flex items-center gap-1 mt-2">
<Key className="h-4 w-4" />
{showAdvanced ? 'Hide' : 'Show'} Advanced Options
</button>
{showAdvanced && (
<div className="mt-3">
<label className="block text-sm font-medium text-gray-700 mb-1">Keepalive (s)</label>
<input type="number" value={formData.persistent_keepalive} min="0" max="65535"
onChange={e => setFormData(f => ({ ...f, persistent_keepalive: parseInt(e.target.value) }))}
className="input w-32" />
</div>
)}
</div>
<div className="flex justify-end gap-3 pt-2">
<button type="button" onClick={() => { setShowEditModal(false); setSelectedPeer(null); }} className="btn btn-secondary">Cancel</button>
<button type="submit" disabled={isSubmitting} className="btn btn-primary flex items-center gap-2">
<Edit className="h-4 w-4" />{isSubmitting ? 'Saving' : 'Save Changes'}
</button>
</div>
</form>
</div>
</div>
)}
{/* View Config Modal */}
{showViewModal && selectedPeer && (
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50"
onClick={e => { if (e.target === e.currentTarget) setShowViewModal(false); }}>
<div className="relative top-10 mx-auto p-6 border w-full max-w-4xl shadow-lg rounded-md bg-white mb-10">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Shield className="h-6 w-6 text-primary-500" />
<h3 className="text-lg font-medium text-gray-900">{selectedPeer.name} — Tunnel Config</h3>
</div>
<button onClick={() => setShowViewModal(false)} className="text-gray-400 hover:text-gray-600">✕</button>
</div>
{selectedPeer.config_needs_reinstall && (
<div className="mb-4 p-3 bg-yellow-50 border border-yellow-200 rounded-md flex items-start gap-2 text-sm text-yellow-800">
<AlertTriangle className="h-4 w-4 flex-shrink-0 mt-0.5" />
<div>
<strong>Config changed.</strong> Download the updated config below and reinstall the WireGuard tunnel on the device. The old config will no longer work correctly.
</div>
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">WireGuard Configuration File</label>
{peerConfig ? (
<div className="relative">
<textarea value={peerConfig} readOnly
className="w-full h-64 p-3 border border-gray-300 rounded-md font-mono text-xs bg-gray-50" />
<div className="flex gap-2 mt-2">
<button onClick={() => copyToClipboard(peerConfig)}
className="btn btn-secondary btn-sm flex items-center gap-1">
<Copy className="h-4 w-4" /> Copy
</button>
<button onClick={() => downloadConfig(selectedPeer.name, peerConfig)}
className="btn btn-primary btn-sm flex items-center gap-1">
<Download className="h-4 w-4" /> Download .conf
</button>
</div>
</div>
) : (
<div className="h-64 bg-gray-50 rounded-md flex items-center justify-center text-gray-400 text-sm">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary-600 mr-2"></div>
Loading…
</div>
)}
<p className="text-xs text-gray-500 mt-2">
DNS is set to <code className="bg-gray-100 px-1 rounded">{serverConf?.dns_ip || '172.20.0.3'}</code> (PIC CoreDNS) — required to resolve <code className="bg-gray-100 px-1 rounded">.{domain}</code> domains.
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">QR Code — Scan with WireGuard app</label>
<div className="text-center">
{qrCodeDataUrl ? (
<div className="space-y-3">
<div className="inline-block p-3 bg-white border-2 border-gray-200 rounded-lg">
<img src={qrCodeDataUrl} alt="WireGuard QR Code" className="w-48 h-48 mx-auto" />
</div>
<button onClick={() => { 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 className="h-4 w-4" /> Download QR
</button>
</div>
) : (
<div className="h-56 bg-gray-50 border-2 border-dashed border-gray-300 rounded-lg flex items-center justify-center text-gray-400 text-sm">
{peerConfig ? 'Generating QR' : 'Loading'}
</div>
)}
</div>
</div>
</div>
<div className="flex justify-between items-center mt-4 pt-4 border-t border-gray-200">
<div className="text-xs text-gray-500">
Tunnel: <span className="font-medium">{selectedPeer.internet_access !== false ? 'Full' : 'Split'}</span>
{' · '}Services: <span className="font-medium">{(selectedPeer.service_access || SERVICES.map(s=>s.key)).join(', ')}</span>
</div>
<div className="flex gap-2">
<button onClick={() => { setShowViewModal(false); handleEditPeer(selectedPeer); }} className="btn btn-secondary flex items-center gap-1">
<Edit className="h-4 w-4" /> Edit
</button>
<button onClick={() => setShowViewModal(false)} className="btn btn-secondary">Close</button>
</div>
</div>
</div>
</div>
)}
</div>
);
}
export default Peers;