Files
pic/webui/src/pages/connectivity/ConnectionForm.jsx
T
roof aba2b0d33f feat: connectivity redesign phase 6 — subpages UI, assignment matrix, Cell Network merge
Replace the monolithic Connectivity page with Services-style subpages:
overview dashboard (aggregated status), per-type connection lists (tunnels/
proxies/ssh/tor) with add/edit forms + lifecycle/health badges + empty states,
a peer+service assignment matrix with per-peer fail-open toggle, and Cell
Network moved under /connectivity/cells. Sidebar gains Connectivity children,
hidden when a type has no instances and its store service isn't installed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 22:53:46 -04:00

401 lines
16 KiB
React

import { useState, useEffect, useMemo } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import { ChevronDown, RefreshCw, Save, ArrowLeft, AlertCircle, KeyRound } from 'lucide-react';
import { connectivityAPI } from '../../services/api';
import {
Toast, useToasts, toastEvent, apiError, typeMeta,
GROUP_TYPES, TypeIcon,
} from './shared';
const inputCls =
'w-full rounded-md border border-gray-300 px-3 py-2 text-sm text-gray-800 placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500';
const monoCls =
'w-full rounded-md border border-gray-300 px-3 py-2 font-mono text-xs text-gray-800 placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 resize-y';
function FieldRow({ label, required, hint, children, error }) {
return (
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-gray-700">
{label}
{required && <span className="text-red-500" aria-hidden="true"> *</span>}
</label>
{children}
{error && <p className="text-xs text-red-600">{error}</p>}
{hint && !error && <p className="text-xs text-gray-400">{hint}</p>}
</div>
);
}
// A write-only secret control: on a new connection it's a plain editable field;
// on edit it shows "•••• set" with a Replace button that reveals the input.
function SecretField({ label, required, hint, isEdit, hasExisting, value, onChange, multiline, placeholder }) {
const [replacing, setReplacing] = useState(!isEdit || !hasExisting);
if (isEdit && hasExisting && !replacing) {
return (
<FieldRow label={label} required={required} hint={hint}>
<div className="flex items-center gap-3">
<span className="inline-flex items-center gap-1.5 text-sm text-gray-500 bg-gray-50 border border-gray-200 rounded-md px-3 py-2">
<KeyRound className="h-4 w-4 text-gray-400" />
set
</span>
<button
type="button"
onClick={() => { setReplacing(true); }}
className="text-sm font-medium text-primary-600 hover:text-primary-700"
>
Replace
</button>
</div>
</FieldRow>
);
}
return (
<FieldRow label={label} required={required && !isEdit} hint={hint}>
{multiline ? (
<textarea
value={value}
onChange={(e) => onChange(e.target.value)}
rows={6}
autoComplete="off"
spellCheck="false"
placeholder={placeholder}
className={monoCls}
/>
) : (
<input
type="password"
value={value}
onChange={(e) => onChange(e.target.value)}
autoComplete="new-password"
placeholder={placeholder}
className={inputCls}
/>
)}
{isEdit && hasExisting && (
<button
type="button"
onClick={() => { onChange(''); setReplacing(false); }}
className="text-xs text-gray-500 hover:text-gray-700 self-start"
>
Keep existing secret
</button>
)}
</FieldRow>
);
}
const EMPTY = {
name: '',
// sshuttle
host: '', port: '', user: '', auth: 'key', private_key: '', password: '', known_hosts: '',
// proxy
scheme: 'socks5', proxy_user: '', proxy_password: '',
// wireguard / openvpn
conf: '',
};
export default function ConnectionForm() {
const toasts = useToasts();
const navigate = useNavigate();
const { group, id } = useParams();
const isEdit = id && id !== 'new';
const groupTypes = GROUP_TYPES[group] || [];
const [type, setType] = useState(groupTypes[0] || 'wireguard_ext');
const [form, setForm] = useState(EMPTY);
const [existing, setExisting] = useState(null); // the loaded connection on edit
const [loading, setLoading] = useState(isEdit);
const [saving, setSaving] = useState(false);
const [loadError, setLoadError] = useState(null);
const set = (k, v) => setForm((f) => ({ ...f, [k]: v }));
useEffect(() => {
if (!isEdit) return;
let cancelled = false;
(async () => {
try {
const res = await connectivityAPI.listConnections();
const conns = res.data?.connections ?? res.data ?? [];
const conn = (Array.isArray(conns) ? conns : []).find((c) => c.id === id);
if (cancelled) return;
if (!conn) { setLoadError('Connection not found'); setLoading(false); return; }
setExisting(conn);
setType(conn.type);
const cfg = conn.config || {};
setForm({
...EMPTY,
name: conn.name || '',
host: cfg.host || '',
port: cfg.port != null ? String(cfg.port) : '',
user: cfg.user || '',
auth: cfg.auth || 'key',
scheme: cfg.scheme || 'socks5',
proxy_user: cfg.user || '',
});
setLoading(false);
} catch (err) {
if (!cancelled) { setLoadError(apiError(err, 'Failed to load connection')); setLoading(false); }
}
})();
return () => { cancelled = true; };
}, [isEdit, id]);
// Secrets that already exist on the connection (so we can show "•••• set").
const hasSecret = useMemo(() => {
const cfg = existing?.config || {};
return {
conf: !!existing && (type === 'wireguard_ext' || type === 'openvpn'),
private_key: !!existing && type === 'sshuttle' && (cfg.auth || 'key') === 'key',
password: !!existing && ((type === 'sshuttle' && cfg.auth === 'password') || type === 'proxy'),
known_hosts: !!existing && type === 'sshuttle',
};
}, [existing, type]);
const buildPayload = () => {
const config = {};
const secrets = {};
if (type === 'wireguard_ext' || type === 'openvpn') {
if (form.conf.trim()) secrets.conf = form.conf;
} else if (type === 'sshuttle') {
config.host = form.host.trim();
config.port = Number(form.port || 22);
config.user = form.user.trim();
config.auth = form.auth;
if (form.known_hosts.trim()) secrets.known_hosts = form.known_hosts.trim();
if (form.auth === 'key') {
if (form.private_key.trim()) secrets.private_key = form.private_key;
} else if (form.password) {
secrets.password = form.password;
}
} else if (type === 'proxy') {
config.scheme = form.scheme;
config.host = form.host.trim();
config.port = Number(form.port);
config.user = form.proxy_user.trim();
if (form.proxy_password) secrets.password = form.proxy_password;
}
return { config, secrets };
};
const handleSubmit = async (e) => {
e.preventDefault();
if (!form.name.trim()) { toastEvent('Name is required', 'error'); return; }
setSaving(true);
const { config, secrets } = buildPayload();
try {
if (isEdit) {
const fields = { name: form.name.trim(), config };
if (Object.keys(secrets).length) fields.secrets = secrets;
await connectivityAPI.updateConnection(id, fields);
toastEvent(`Saved ${form.name.trim()}`);
} else {
await connectivityAPI.createConnection(type, form.name.trim(), config, secrets);
toastEvent(`Created ${form.name.trim()}`);
}
navigate(`/connectivity/${group}`);
} catch (err) {
toastEvent(apiError(err, 'Failed to save connection'), 'error');
} finally {
setSaving(false);
}
};
if (loading) {
return (
<div className="bg-white rounded-lg border border-gray-200 p-6 animate-pulse space-y-3">
<div className="h-5 bg-gray-200 rounded w-1/3" />
<div className="h-4 bg-gray-100 rounded w-1/2" />
<div className="h-32 bg-gray-100 rounded" />
</div>
);
}
if (loadError) {
return (
<div className="bg-red-50 rounded-lg border border-red-200 p-4 flex items-start gap-3">
<AlertCircle className="h-5 w-5 text-red-500 mt-0.5 shrink-0" />
<div className="flex-1">
<p className="text-sm font-medium text-red-800">{loadError}</p>
</div>
<Link to={`/connectivity/${group}`} className="btn-secondary text-sm shrink-0">Back</Link>
</div>
);
}
const meta = typeMeta(type);
return (
<div className="max-w-2xl">
<Toast toasts={toasts} />
<Link
to={`/connectivity/${group}`}
className="inline-flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-700 mb-4"
>
<ArrowLeft className="h-4 w-4" /> Back
</Link>
<div className="flex items-center gap-3 mb-6">
<TypeIcon type={type} />
<h1 className="text-2xl font-bold text-gray-900">
{isEdit ? `Edit ${meta.short}` : `Add ${meta.short}`}
</h1>
</div>
<form onSubmit={handleSubmit} className="bg-white rounded-lg border border-gray-200 p-6 flex flex-col gap-4">
{/* Type picker (only when the group has more than one type and we're adding) */}
{!isEdit && groupTypes.length > 1 && (
<FieldRow label="Type">
<div className="relative">
<select
value={type}
onChange={(e) => setType(e.target.value)}
className={`${inputCls} appearance-none pr-8`}
>
{groupTypes.map((t) => (
<option key={t} value={t}>{typeMeta(t).label}</option>
))}
</select>
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
</div>
</FieldRow>
)}
<FieldRow label="Name" required hint="A label to identify this connection in assignments">
<input
type="text"
value={form.name}
onChange={(e) => set('name', e.target.value)}
placeholder={`my-${meta.short.toLowerCase()}`}
className={inputCls}
/>
</FieldRow>
{(type === 'wireguard_ext' || type === 'openvpn') && (
<SecretField
label={type === 'wireguard_ext' ? 'WireGuard config (.conf)' : 'OpenVPN profile (.ovpn)'}
required
isEdit={isEdit}
hasExisting={hasSecret.conf}
value={form.conf}
onChange={(v) => set('conf', v)}
multiline
placeholder={type === 'wireguard_ext'
? '[Interface]\nPrivateKey = ...\n\n[Peer]\nPublicKey = ...'
: 'client\ndev tun\nproto udp\nremote ...'}
hint="Stored encrypted in the vault. Paste the file contents."
/>
)}
{type === 'sshuttle' && (
<>
<div className="grid grid-cols-3 gap-3">
<div className="col-span-2">
<FieldRow label="Host" required>
<input type="text" value={form.host} onChange={(e) => set('host', e.target.value)}
placeholder="ssh.example.com" className={inputCls} />
</FieldRow>
</div>
<FieldRow label="Port">
<input type="number" min="1" max="65535" value={form.port}
onChange={(e) => set('port', e.target.value)} placeholder="22" className={inputCls} />
</FieldRow>
</div>
<FieldRow label="User" required>
<input type="text" value={form.user} onChange={(e) => set('user', e.target.value)}
placeholder="tunnel" className={inputCls} />
</FieldRow>
<FieldRow label="Authentication">
<div className="flex rounded-md border border-gray-200 overflow-hidden w-fit">
{['key', 'password'].map((a) => (
<button
key={a} type="button" onClick={() => set('auth', a)}
className={`px-3 py-1.5 text-sm font-medium transition-colors ${
form.auth === a ? 'bg-primary-600 text-white' : 'bg-white text-gray-600 hover:bg-gray-50'
}`}
>
{a === 'key' ? 'Private key' : 'Password'}
</button>
))}
</div>
</FieldRow>
{form.auth === 'key' ? (
<SecretField
label="Private key" required isEdit={isEdit} hasExisting={hasSecret.private_key}
value={form.private_key} onChange={(v) => set('private_key', v)} multiline
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"
hint="Stored encrypted in the vault."
/>
) : (
<SecretField
label="Password" required isEdit={isEdit} hasExisting={hasSecret.password}
value={form.password} onChange={(v) => set('password', v)}
/>
)}
<SecretField
label="Pinned host key (known_hosts line)" required isEdit={isEdit}
hasExisting={hasSecret.known_hosts}
value={form.known_hosts} onChange={(v) => set('known_hosts', v)}
placeholder="ssh.example.com ssh-ed25519 AAAA..."
hint="Get it with: ssh-keyscan -t ed25519 ssh.example.com"
/>
</>
)}
{type === 'proxy' && (
<>
<div className="grid grid-cols-3 gap-3">
<FieldRow label="Scheme">
<div className="relative">
<select value={form.scheme} onChange={(e) => set('scheme', e.target.value)}
className={`${inputCls} appearance-none pr-8`}>
<option value="socks5">SOCKS5</option>
<option value="http">HTTP</option>
</select>
<ChevronDown className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
</div>
</FieldRow>
<FieldRow label="Host" required>
<input type="text" value={form.host} onChange={(e) => set('host', e.target.value)}
placeholder="proxy.example.com" className={inputCls} />
</FieldRow>
<FieldRow label="Port" required>
<input type="number" min="1" max="65535" value={form.port}
onChange={(e) => set('port', e.target.value)} placeholder="1080" className={inputCls} />
</FieldRow>
</div>
<FieldRow label="User" hint="Leave blank for an unauthenticated proxy">
<input type="text" value={form.proxy_user} onChange={(e) => set('proxy_user', e.target.value)}
autoComplete="off" placeholder="optional" className={inputCls} />
</FieldRow>
<SecretField
label="Password" isEdit={isEdit} hasExisting={hasSecret.password}
value={form.proxy_password} onChange={(v) => set('proxy_password', v)}
hint="Required only when a user is set"
/>
</>
)}
{type === 'tor' && (
<p className="text-sm text-gray-500">
Tor needs no configuration. Create the connection, then assign peers or services to it.
</p>
)}
<div className="flex justify-end gap-2 pt-2 border-t border-gray-100">
<Link to={`/connectivity/${group}`} className="btn-secondary text-sm">Cancel</Link>
<button
type="submit"
disabled={saving}
className="flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-white bg-primary-600 hover:bg-primary-700 disabled:opacity-50 rounded-md transition-colors"
>
{saving ? <RefreshCw className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
{saving ? 'Saving…' : isEdit ? 'Save changes' : 'Create connection'}
</button>
</div>
</form>
</div>
);
}