feat(cells): Phase 3 tests + Phase 4 UI for cell service-sharing
Phase 3 — tests (50 new, total now 1071): - test_cell_link_manager: atomicity (WG fail → DNS not called, link not persisted), DNS warning non-fatal, inbound_services arg, unknown service filtered, update/get permissions, lazy migration of legacy entries - test_wireguard_manager: subnet overlap rejection (exact, supernet, adjacent non-overlapping, different class-A, honours wg0.conf configured network) - test_firewall_manager: _cell_tag sanitisation, apply_cell_rules emits correct ACCEPT/DROP per service + catch-all DROP, clear_cell_rules no-op and exact line removal, apply_all_cell_rules iterates with correct args - test_cells_endpoints: RuntimeError→400, GET /services, GET/PUT permissions (200/400/404 paths, service name validation, arg forwarding) Phase 4 — UI: - CellNetwork.jsx: replace flat cell list with CellPanel expandable cards; add ServiceShareToggle (ARIA switch, saves immediately), InboundServiceBadge (read-only), DisconnectConfirmModal (replaces window.confirm); relative timestamps; paste validation on blur; WireGuard status merged by public_key - api.js: add cellLinkAPI.getPermissions, updatePermissions, getServices Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+265
-47
@@ -1,9 +1,25 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Link2, Link2Off, Copy, CheckCheck, RefreshCw, Plug, Unplug, Globe, Wifi } from 'lucide-react';
|
||||
import { Link2, Link2Off, Copy, CheckCheck, RefreshCw, Plug, Unplug, Globe, Wifi, Calendar, FolderOpen, Mail, HardDrive, ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import { cellLinkAPI } from '../services/api';
|
||||
import { useConfig } from '../contexts/ConfigContext';
|
||||
import QRCode from 'qrcode';
|
||||
|
||||
const relativeTime = (ts) => {
|
||||
if (!ts) return null;
|
||||
const diff = Math.floor((Date.now() / 1000) - (typeof ts === 'string' ? new Date(ts).getTime() / 1000 : ts));
|
||||
if (diff < 60) return `${diff}s ago`;
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||||
return `${Math.floor(diff / 86400)}d ago`;
|
||||
};
|
||||
|
||||
const SERVICE_DEFS = [
|
||||
{ key: 'calendar', label: 'Calendar', Icon: Calendar },
|
||||
{ key: 'files', label: 'Files', Icon: FolderOpen },
|
||||
{ key: 'mail', label: 'Mail', Icon: Mail },
|
||||
{ key: 'webdav', label: 'WebDAV', Icon: HardDrive },
|
||||
];
|
||||
|
||||
function CopyButton({ text, small }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const copy = () => {
|
||||
@@ -52,6 +68,194 @@ function useToasts() {
|
||||
return [toasts, add];
|
||||
}
|
||||
|
||||
function DisconnectConfirmModal({ cellName, onConfirm, onCancel }) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
<div className="bg-white rounded-xl shadow-2xl p-6 max-w-sm w-full mx-4">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<Unplug className="h-6 w-6 text-red-500 flex-shrink-0" />
|
||||
<h3 className="text-base font-semibold text-gray-900">Disconnect "{cellName}"?</h3>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mb-2">
|
||||
This will remove the WireGuard tunnel and all sharing permissions between your cells.
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mb-5">
|
||||
The other cell's admin will need to remove the connection on their end too. Shared services will become inaccessible immediately.
|
||||
</p>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button onClick={onCancel} autoFocus className="btn btn-secondary text-sm">Cancel</button>
|
||||
<button onClick={onConfirm} className="btn btn-danger text-sm flex items-center gap-2">
|
||||
<Unplug className="h-4 w-4" /> Disconnect
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ServiceShareToggle({ serviceKey, label, Icon, enabled, saving, onChange }) {
|
||||
return (
|
||||
<label className="flex items-center gap-3 cursor-pointer select-none py-1">
|
||||
<div
|
||||
role="switch"
|
||||
aria-checked={enabled}
|
||||
aria-label={`Share ${label}`}
|
||||
tabIndex={0}
|
||||
onClick={() => !saving && onChange(!enabled)}
|
||||
onKeyDown={e => (e.key === 'Enter' || e.key === ' ') && !saving && onChange(!enabled)}
|
||||
className={`relative w-10 h-5 rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-primary-400 focus:ring-offset-1 ${
|
||||
enabled ? 'bg-primary-500' : 'bg-gray-300'
|
||||
} ${saving ? 'opacity-60 cursor-wait' : 'cursor-pointer'}`}
|
||||
>
|
||||
<span className={`absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full shadow transition-transform ${
|
||||
enabled ? 'translate-x-5' : ''
|
||||
}`} />
|
||||
</div>
|
||||
<Icon className="h-4 w-4 text-gray-500 shrink-0" />
|
||||
<span className="text-sm text-gray-700">{label}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function InboundServiceBadge({ label, Icon, active }) {
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium ${
|
||||
active ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-400'
|
||||
}`}>
|
||||
<Icon className="h-3 w-3" />
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function CellPanel({ conn, onDisconnect, addToast }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [inboundPerms, setInboundPerms] = useState(conn.permissions?.inbound || {});
|
||||
const [saving, setSaving] = useState({});
|
||||
const [confirmDisconnect, setConfirmDisconnect] = useState(false);
|
||||
|
||||
const handleToggle = async (serviceKey, newValue) => {
|
||||
setSaving(s => ({ ...s, [serviceKey]: true }));
|
||||
const newInbound = { ...inboundPerms, [serviceKey]: newValue };
|
||||
try {
|
||||
await cellLinkAPI.updatePermissions(conn.cell_name, newInbound, conn.permissions?.outbound || {});
|
||||
setInboundPerms(newInbound);
|
||||
addToast(`${serviceKey} sharing ${newValue ? 'enabled' : 'disabled'}`, 'success');
|
||||
} catch {
|
||||
addToast('Failed to save sharing permission', 'error');
|
||||
} finally {
|
||||
setSaving(s => ({ ...s, [serviceKey]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const hasRevokedService = Object.values(inboundPerms).some(v => !v);
|
||||
|
||||
return (
|
||||
<>
|
||||
{confirmDisconnect && (
|
||||
<DisconnectConfirmModal
|
||||
cellName={conn.cell_name}
|
||||
onConfirm={() => { setConfirmDisconnect(false); onDisconnect(conn.cell_name); }}
|
||||
onCancel={() => setConfirmDisconnect(false)}
|
||||
/>
|
||||
)}
|
||||
<div className="border border-gray-100 rounded-lg overflow-hidden">
|
||||
<button
|
||||
className="w-full flex items-center justify-between px-4 py-3 bg-gray-50 hover:bg-gray-100 transition-colors text-left"
|
||||
onClick={() => setOpen(v => !v)}
|
||||
aria-expanded={open}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<StatusDot online={conn.online} />
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<span className="font-medium text-gray-900 truncate">{conn.cell_name}</span>
|
||||
<span className="text-xs text-gray-400 font-mono shrink-0">.{conn.domain}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-3 text-xs text-gray-500 mt-0.5">
|
||||
<span className={conn.online ? 'text-green-600 font-medium' : 'text-gray-400'}>
|
||||
{conn.online ? 'Online' : 'Offline'}
|
||||
</span>
|
||||
{conn.last_handshake && (
|
||||
<span>{relativeTime(conn.last_handshake)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{open
|
||||
? <ChevronDown className="h-4 w-4 text-gray-400 shrink-0" />
|
||||
: <ChevronRight className="h-4 w-4 text-gray-400 shrink-0" />}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="px-4 py-4 bg-white border-t border-gray-100">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-3">
|
||||
I share with {conn.cell_name}
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{SERVICE_DEFS.map(({ key, label, Icon }) => (
|
||||
<ServiceShareToggle
|
||||
key={key}
|
||||
serviceKey={key}
|
||||
label={label}
|
||||
Icon={Icon}
|
||||
enabled={!!inboundPerms[key]}
|
||||
saving={!!saving[key]}
|
||||
onChange={v => handleToggle(key, v)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{hasRevokedService && (
|
||||
<p className="text-xs text-gray-400 mt-2">
|
||||
Services you stop sharing become unreachable from {conn.cell_name} immediately.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-3">
|
||||
{conn.cell_name} shares with me
|
||||
</p>
|
||||
{(conn.permissions?.outbound && Object.values(conn.permissions.outbound).some(Boolean)) ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{SERVICE_DEFS.map(({ key, label, Icon }) => (
|
||||
<InboundServiceBadge
|
||||
key={key}
|
||||
label={label}
|
||||
Icon={Icon}
|
||||
active={!!conn.permissions.outbound[key]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-gray-400">Nothing shared yet.</p>
|
||||
)}
|
||||
<p className="text-xs text-gray-400 mt-3">
|
||||
Inbound sharing is set by the other cell's admin.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 pt-3 border-t border-gray-100 flex flex-wrap items-center justify-between gap-3">
|
||||
<dl className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-gray-500">
|
||||
{conn.vpn_subnet && <div><dt className="inline text-gray-400">Subnet </dt><dd className="inline font-mono">{conn.vpn_subnet}</dd></div>}
|
||||
{conn.endpoint && <div><dt className="inline text-gray-400">Endpoint </dt><dd className="inline font-mono">{conn.endpoint}</dd></div>}
|
||||
</dl>
|
||||
<button
|
||||
onClick={() => setConfirmDisconnect(true)}
|
||||
className="btn btn-danger flex items-center gap-2 text-sm py-1.5"
|
||||
>
|
||||
<Unplug className="h-4 w-4" />
|
||||
Disconnect
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CellNetwork() {
|
||||
const { cell_name = 'mycell', domain = 'cell' } = useConfig();
|
||||
const [toasts, addToast] = useToasts();
|
||||
@@ -64,6 +268,7 @@ export default function CellNetwork() {
|
||||
const [connsLoading, setConnsLoading] = useState(true);
|
||||
|
||||
const [pasteText, setPasteText] = useState('');
|
||||
const [pasteError, setPasteError] = useState('');
|
||||
const [connecting, setConnecting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -89,17 +294,29 @@ export default function CellNetwork() {
|
||||
setConnsLoading(true);
|
||||
try {
|
||||
const r = await cellLinkAPI.listConnections();
|
||||
// Enrich with live status
|
||||
const enriched = await Promise.all(
|
||||
(r.data || []).map(async (conn) => {
|
||||
try {
|
||||
const s = await cellLinkAPI.getStatus(conn.cell_name);
|
||||
return { ...conn, online: s.data.online, last_handshake: s.data.last_handshake };
|
||||
} catch {
|
||||
return { ...conn, online: false };
|
||||
}
|
||||
})
|
||||
);
|
||||
const conns = r.data || [];
|
||||
|
||||
// Fetch all WireGuard peer statuses in one call and index by public_key
|
||||
let statusByKey = {};
|
||||
try {
|
||||
const { wireguardAPI } = await import('../services/api');
|
||||
const sr = await wireguardAPI.getPeerStatuses();
|
||||
(sr.data?.peers || []).forEach(p => {
|
||||
if (p.public_key) statusByKey[p.public_key] = p;
|
||||
});
|
||||
} catch {
|
||||
// Status enrichment is best-effort; continue without it
|
||||
}
|
||||
|
||||
const enriched = conns.map(conn => {
|
||||
const wg = conn.public_key ? statusByKey[conn.public_key] : null;
|
||||
return {
|
||||
...conn,
|
||||
online: wg ? wg.online : false,
|
||||
last_handshake: wg ? wg.last_handshake : null,
|
||||
};
|
||||
});
|
||||
|
||||
setConnections(enriched);
|
||||
} catch {
|
||||
addToast('Failed to load connections', 'error');
|
||||
@@ -108,6 +325,20 @@ export default function CellNetwork() {
|
||||
}
|
||||
};
|
||||
|
||||
const validatePaste = (text) => {
|
||||
if (!text.trim()) { setPasteError(''); return; }
|
||||
try {
|
||||
const p = JSON.parse(text.trim());
|
||||
if (!p.cell_name || !p.public_key || !p.vpn_subnet) {
|
||||
setPasteError('JSON is missing required fields (cell_name, public_key, vpn_subnet)');
|
||||
} else {
|
||||
setPasteError('');
|
||||
}
|
||||
} catch {
|
||||
setPasteError('Not valid JSON — paste the complete invite from the other cell');
|
||||
}
|
||||
};
|
||||
|
||||
const handleConnect = async () => {
|
||||
if (!pasteText.trim()) return;
|
||||
let parsed;
|
||||
@@ -122,6 +353,7 @@ export default function CellNetwork() {
|
||||
await cellLinkAPI.addConnection(parsed);
|
||||
addToast(`Connected to cell "${parsed.cell_name}"`);
|
||||
setPasteText('');
|
||||
setPasteError('');
|
||||
loadConnections();
|
||||
} catch (e) {
|
||||
addToast(e?.response?.data?.error || 'Connection failed', 'error');
|
||||
@@ -130,8 +362,8 @@ export default function CellNetwork() {
|
||||
}
|
||||
};
|
||||
|
||||
// Confirmation is handled inside CellPanel via DisconnectConfirmModal
|
||||
const handleDisconnect = async (name) => {
|
||||
if (!window.confirm(`Disconnect from cell "${name}"?`)) return;
|
||||
try {
|
||||
await cellLinkAPI.removeConnection(name);
|
||||
addToast(`Disconnected from "${name}"`);
|
||||
@@ -230,16 +462,22 @@ export default function CellNetwork() {
|
||||
<p className="text-sm text-gray-600">
|
||||
Paste the invite JSON from the other cell's "Your Cell's Invite" panel:
|
||||
</p>
|
||||
<textarea
|
||||
value={pasteText}
|
||||
onChange={e => setPasteText(e.target.value)}
|
||||
placeholder={'{\n "cell_name": "...",\n "public_key": "...",\n ...\n}'}
|
||||
rows={8}
|
||||
className="w-full text-xs font-mono border rounded-lg p-3 focus:outline-none focus:ring-2 focus:ring-primary-400 resize-none bg-white"
|
||||
/>
|
||||
<div>
|
||||
<textarea
|
||||
value={pasteText}
|
||||
onChange={e => { setPasteText(e.target.value); if (pasteError) validatePaste(e.target.value); }}
|
||||
onBlur={e => validatePaste(e.target.value)}
|
||||
placeholder={'{\n "cell_name": "...",\n "public_key": "...",\n ...\n}'}
|
||||
rows={8}
|
||||
className={`w-full text-xs font-mono border rounded-lg p-3 focus:outline-none focus:ring-2 focus:ring-primary-400 resize-none bg-white ${
|
||||
pasteError ? 'border-red-400 focus:ring-red-400' : ''
|
||||
}`}
|
||||
/>
|
||||
{pasteError && <p className="text-xs text-red-600 mt-1">{pasteError}</p>}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleConnect}
|
||||
disabled={connecting || !pasteText.trim()}
|
||||
disabled={connecting || !pasteText.trim() || !!pasteError}
|
||||
className="w-full btn btn-primary flex items-center justify-center gap-2 disabled:opacity-50"
|
||||
>
|
||||
{connecting
|
||||
@@ -286,32 +524,12 @@ export default function CellNetwork() {
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{connections.map(conn => (
|
||||
<div key={conn.cell_name}
|
||||
className="flex items-center justify-between p-3 bg-gray-50 rounded-lg border border-gray-100">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<StatusDot online={conn.online} />
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<span className="font-medium text-gray-900 truncate" title={conn.cell_name}>{conn.cell_name}</span>
|
||||
<span className="text-xs text-gray-400 font-mono shrink-0">.{conn.domain}</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 space-x-3 mt-0.5 truncate">
|
||||
<span>Subnet: <span className="font-mono">{conn.vpn_subnet}</span></span>
|
||||
<span>Endpoint: <span className="font-mono">{conn.endpoint || '—'}</span></span>
|
||||
{conn.last_handshake && (
|
||||
<span>Last seen: {new Date(conn.last_handshake * 1000).toLocaleString()}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDisconnect(conn.cell_name)}
|
||||
className="text-red-400 hover:text-red-600 p-1.5 rounded hover:bg-red-50"
|
||||
title="Disconnect"
|
||||
>
|
||||
<Unplug className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<CellPanel
|
||||
key={conn.cell_name}
|
||||
conn={conn}
|
||||
onDisconnect={handleDisconnect}
|
||||
addToast={addToast}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -278,6 +278,10 @@ export const cellLinkAPI = {
|
||||
addConnection: (invite) => api.post('/api/cells', invite),
|
||||
removeConnection: (name) => api.delete(`/api/cells/${name}`),
|
||||
getStatus: (name) => api.get(`/api/cells/${name}/status`),
|
||||
getPermissions: (cellName) => api.get(`/api/cells/${cellName}/permissions`),
|
||||
updatePermissions: (cellName, inbound, outbound) =>
|
||||
api.put(`/api/cells/${cellName}/permissions`, { inbound, outbound }),
|
||||
getServices: () => api.get('/api/cells/services'),
|
||||
};
|
||||
|
||||
// Health check
|
||||
|
||||
Reference in New Issue
Block a user