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:
2026-05-01 08:45:32 -04:00
parent 0b103ffafb
commit 562d866a65
6 changed files with 1023 additions and 47 deletions
+265 -47
View File
@@ -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>
)}