aba2b0d33f
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>
401 lines
16 KiB
React
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>
|
|
);
|
|
}
|