463 lines
15 KiB
TypeScript
463 lines
15 KiB
TypeScript
import {
|
||
useState,
|
||
useCallback,
|
||
useEffect,
|
||
useRef,
|
||
type FormEvent,
|
||
} from "react";
|
||
import type { SshHost } from "../ipc";
|
||
import "./HostManager.css";
|
||
|
||
function newId(): string {
|
||
return (
|
||
globalThis.crypto?.randomUUID?.() ??
|
||
Math.random().toString(36).slice(2, 12)
|
||
);
|
||
}
|
||
|
||
function blankHost(): SshHost {
|
||
return { id: newId(), label: "", hostname: "" };
|
||
}
|
||
|
||
/** Per-edit transient state for the password field. The actual password
|
||
* text never lives on `SshHost` — it stays in this map until the user
|
||
* clicks Save, at which point we either send a set/delete to keyring
|
||
* via the parent callbacks or do nothing. */
|
||
interface PasswordDraft {
|
||
/** What the user typed (or "" if untouched). */
|
||
input: string;
|
||
/** True iff the user clicked "Remove password" — overrides `input`. */
|
||
cleared: boolean;
|
||
}
|
||
|
||
interface HostManagerProps {
|
||
hosts: SshHost[];
|
||
/** Persist the host list (label/hostname/etc — no password). */
|
||
onSave: (hosts: SshHost[]) => void;
|
||
/** Write a new password to keyring for the given host id. Called only
|
||
* on Save, only when the user typed something into the password field. */
|
||
onSavePassword: (hostId: string, password: string) => void;
|
||
/** Delete the keyring entry for this host id. Called when the user
|
||
* clicked "Remove password" before Save. */
|
||
onClearPassword: (hostId: string) => void;
|
||
onClose: () => void;
|
||
}
|
||
|
||
export default function HostManager({
|
||
hosts,
|
||
onSave,
|
||
onSavePassword,
|
||
onClearPassword,
|
||
onClose,
|
||
}: HostManagerProps) {
|
||
// Local editable copy. Any save / delete acts on this and pushes the
|
||
// whole list back up via onSave.
|
||
const [draft, setDraft] = useState<SshHost[]>(() => hosts.map((h) => ({ ...h })));
|
||
// Per-row password edits (keyed by host id). Absent = unchanged.
|
||
const [pwDrafts, setPwDrafts] = useState<Record<string, PasswordDraft>>({});
|
||
// Which row is being edited. null = list view only.
|
||
const [editingId, setEditingId] = useState<string | null>(null);
|
||
const dialogRef = useRef<HTMLDivElement>(null);
|
||
|
||
// Escape closes; click outside the panel closes.
|
||
useEffect(() => {
|
||
const onKey = (e: KeyboardEvent) => {
|
||
if (e.key === "Escape") onClose();
|
||
};
|
||
window.addEventListener("keydown", onKey);
|
||
return () => window.removeEventListener("keydown", onKey);
|
||
}, [onClose]);
|
||
|
||
const startEdit = useCallback((id: string) => {
|
||
setEditingId(id);
|
||
setPwDrafts((cur) => {
|
||
if (cur[id]) return cur;
|
||
return { ...cur, [id]: { input: "", cleared: false } };
|
||
});
|
||
}, []);
|
||
|
||
const cancelEdit = useCallback(() => {
|
||
// Revert any unsaved edits to that row from props; drop password drafts.
|
||
setDraft((cur) =>
|
||
cur
|
||
.map((h) => {
|
||
if (h.id !== editingId) return h;
|
||
const original = hosts.find((o) => o.id === editingId);
|
||
return original ?? h;
|
||
})
|
||
.filter((h) => {
|
||
if (h.id !== editingId) return true;
|
||
return hosts.some((o) => o.id === editingId);
|
||
}),
|
||
);
|
||
if (editingId) {
|
||
setPwDrafts((cur) => {
|
||
if (!(editingId in cur)) return cur;
|
||
const next = { ...cur };
|
||
delete next[editingId];
|
||
return next;
|
||
});
|
||
}
|
||
setEditingId(null);
|
||
}, [editingId, hosts]);
|
||
|
||
const onFieldChange = useCallback(
|
||
(id: string, field: keyof SshHost, value: string) => {
|
||
setDraft((cur) =>
|
||
cur.map((h) => {
|
||
if (h.id !== id) return h;
|
||
if (field === "port") {
|
||
if (value.trim() === "") return { ...h, port: undefined };
|
||
const n = Number(value);
|
||
if (!Number.isFinite(n) || n < 1 || n > 65535) return h;
|
||
return { ...h, port: n };
|
||
}
|
||
if (field === "extraArgs") {
|
||
const parts = value
|
||
.split(/\s+/)
|
||
.map((s) => s.trim())
|
||
.filter((s) => s.length > 0);
|
||
return { ...h, extraArgs: parts.length > 0 ? parts : undefined };
|
||
}
|
||
if (value.trim() === "" && field !== "label" && field !== "hostname") {
|
||
const next = { ...h };
|
||
delete next[field];
|
||
return next;
|
||
}
|
||
return { ...h, [field]: value };
|
||
}),
|
||
);
|
||
},
|
||
[],
|
||
);
|
||
|
||
const onPasswordInput = useCallback((id: string, value: string) => {
|
||
setPwDrafts((cur) => ({
|
||
...cur,
|
||
[id]: { input: value, cleared: false },
|
||
}));
|
||
}, []);
|
||
|
||
const onPasswordClear = useCallback((id: string) => {
|
||
setPwDrafts((cur) => ({
|
||
...cur,
|
||
[id]: { input: "", cleared: true },
|
||
}));
|
||
}, []);
|
||
|
||
const saveRow = useCallback(
|
||
(id: string, e: FormEvent) => {
|
||
e.preventDefault();
|
||
const row = draft.find((h) => h.id === id);
|
||
if (!row) return;
|
||
if (!row.hostname.trim()) {
|
||
// Hostname is the only truly required field. Refuse the save instead
|
||
// of silently persisting a useless entry.
|
||
return;
|
||
}
|
||
// Auto-fill label from hostname if the user left it blank.
|
||
const cleaned: SshHost = {
|
||
...row,
|
||
label: row.label.trim() || row.hostname.trim(),
|
||
hostname: row.hostname.trim(),
|
||
};
|
||
|
||
// Apply the password edit — if any — BEFORE flipping `hasPassword`
|
||
// on the local copy so the row redraws with the right state.
|
||
const pw = pwDrafts[id];
|
||
let nextHasPassword = row.hasPassword;
|
||
if (pw) {
|
||
if (pw.cleared) {
|
||
onClearPassword(id);
|
||
nextHasPassword = false;
|
||
} else if (pw.input.length > 0) {
|
||
onSavePassword(id, pw.input);
|
||
nextHasPassword = true;
|
||
}
|
||
}
|
||
cleaned.hasPassword = nextHasPassword;
|
||
|
||
const next = draft.map((h) => (h.id === id ? cleaned : h));
|
||
setDraft(next);
|
||
onSave(next.map(({ hasPassword: _hp, ...rest }) => rest));
|
||
// Drop the pw draft so re-edit doesn't carry it over.
|
||
setPwDrafts((cur) => {
|
||
if (!(id in cur)) return cur;
|
||
const nxt = { ...cur };
|
||
delete nxt[id];
|
||
return nxt;
|
||
});
|
||
setEditingId(null);
|
||
},
|
||
[draft, pwDrafts, onSave, onSavePassword, onClearPassword],
|
||
);
|
||
|
||
const removeRow = useCallback(
|
||
(id: string) => {
|
||
const next = draft.filter((h) => h.id !== id);
|
||
setDraft(next);
|
||
// Strip hasPassword on persist — the backend recomputes it. (The
|
||
// save command sweeps orphan credentials, so the deleted host's
|
||
// password is also removed from keyring.)
|
||
onSave(next.map(({ hasPassword: _hp, ...rest }) => rest));
|
||
if (editingId === id) setEditingId(null);
|
||
setPwDrafts((cur) => {
|
||
if (!(id in cur)) return cur;
|
||
const nxt = { ...cur };
|
||
delete nxt[id];
|
||
return nxt;
|
||
});
|
||
},
|
||
[draft, editingId, onSave],
|
||
);
|
||
|
||
const addRow = useCallback(() => {
|
||
const fresh = blankHost();
|
||
setDraft((cur) => [...cur, fresh]);
|
||
setEditingId(fresh.id);
|
||
setPwDrafts((cur) => ({
|
||
...cur,
|
||
[fresh.id]: { input: "", cleared: false },
|
||
}));
|
||
}, []);
|
||
|
||
return (
|
||
<div className="host-mgr-overlay" onClick={onClose}>
|
||
<div
|
||
className="host-mgr-panel"
|
||
ref={dialogRef}
|
||
onClick={(e) => e.stopPropagation()}
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-label="Manage SSH hosts"
|
||
>
|
||
<header className="host-mgr-header">
|
||
<span className="host-mgr-title">SSH hosts</span>
|
||
<button className="host-mgr-close" onClick={onClose} aria-label="Close">
|
||
×
|
||
</button>
|
||
</header>
|
||
|
||
<div className="host-mgr-body">
|
||
{draft.length === 0 ? (
|
||
<p className="host-mgr-empty">
|
||
No saved hosts. Click <strong>Add host</strong> to create one.
|
||
</p>
|
||
) : (
|
||
<ul className="host-mgr-list">
|
||
{draft.map((h) => (
|
||
<li key={h.id} className="host-row">
|
||
{editingId === h.id ? (
|
||
<form className="host-form" onSubmit={(e) => saveRow(h.id, e)}>
|
||
<label>
|
||
Label
|
||
<input
|
||
type="text"
|
||
value={h.label}
|
||
onChange={(e) =>
|
||
onFieldChange(h.id, "label", e.target.value)
|
||
}
|
||
placeholder="prod-web"
|
||
autoFocus
|
||
/>
|
||
</label>
|
||
<label>
|
||
Hostname <span className="required">*</span>
|
||
<input
|
||
type="text"
|
||
required
|
||
value={h.hostname}
|
||
onChange={(e) =>
|
||
onFieldChange(h.id, "hostname", e.target.value)
|
||
}
|
||
placeholder="example.com or 10.0.0.5"
|
||
/>
|
||
</label>
|
||
<div className="host-form-row">
|
||
<label>
|
||
User
|
||
<input
|
||
type="text"
|
||
value={h.user ?? ""}
|
||
onChange={(e) =>
|
||
onFieldChange(h.id, "user", e.target.value)
|
||
}
|
||
placeholder="(default)"
|
||
/>
|
||
</label>
|
||
<label className="host-form-port">
|
||
Port
|
||
<input
|
||
type="number"
|
||
min={1}
|
||
max={65535}
|
||
value={h.port ?? ""}
|
||
onChange={(e) =>
|
||
onFieldChange(h.id, "port", e.target.value)
|
||
}
|
||
placeholder="22"
|
||
/>
|
||
</label>
|
||
</div>
|
||
<label>
|
||
Identity file
|
||
<input
|
||
type="text"
|
||
value={h.identityFile ?? ""}
|
||
onChange={(e) =>
|
||
onFieldChange(h.id, "identityFile", e.target.value)
|
||
}
|
||
placeholder="(uses ssh-agent / default)"
|
||
/>
|
||
</label>
|
||
<label>
|
||
Jump host
|
||
<input
|
||
type="text"
|
||
value={h.jumpHost ?? ""}
|
||
onChange={(e) =>
|
||
onFieldChange(h.id, "jumpHost", e.target.value)
|
||
}
|
||
placeholder="user@bastion[:port]"
|
||
/>
|
||
</label>
|
||
<label>
|
||
Extra ssh args
|
||
<input
|
||
type="text"
|
||
value={(h.extraArgs ?? []).join(" ")}
|
||
onChange={(e) =>
|
||
onFieldChange(h.id, "extraArgs", e.target.value)
|
||
}
|
||
placeholder="-o ServerAliveInterval=30"
|
||
/>
|
||
</label>
|
||
|
||
<PasswordField
|
||
hostHasPassword={!!h.hasPassword}
|
||
draft={pwDrafts[h.id]}
|
||
onChange={(v) => onPasswordInput(h.id, v)}
|
||
onClear={() => onPasswordClear(h.id)}
|
||
/>
|
||
|
||
<div className="host-form-actions">
|
||
<button type="submit" className="primary">
|
||
Save
|
||
</button>
|
||
<button type="button" onClick={cancelEdit}>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="danger"
|
||
onClick={() => removeRow(h.id)}
|
||
>
|
||
Delete
|
||
</button>
|
||
</div>
|
||
</form>
|
||
) : (
|
||
<div className="host-display">
|
||
<div className="host-summary">
|
||
<div className="host-summary-label">
|
||
{h.label || h.hostname}
|
||
{h.hasPassword && (
|
||
<span
|
||
className="host-pw-badge"
|
||
title="Password stored in Windows Credential Manager"
|
||
>
|
||
🔒
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div className="host-summary-detail">
|
||
{h.user ? `${h.user}@` : ""}
|
||
{h.hostname}
|
||
{h.port ? `:${h.port}` : ""}
|
||
{h.jumpHost ? ` via ${h.jumpHost}` : ""}
|
||
</div>
|
||
</div>
|
||
<button
|
||
className="host-edit-btn"
|
||
onClick={() => startEdit(h.id)}
|
||
>
|
||
Edit
|
||
</button>
|
||
</div>
|
||
)}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
|
||
<button className="host-add-btn" onClick={addRow}>
|
||
+ Add host
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function PasswordField({
|
||
hostHasPassword,
|
||
draft,
|
||
onChange,
|
||
onClear,
|
||
}: {
|
||
hostHasPassword: boolean;
|
||
draft: PasswordDraft | undefined;
|
||
onChange: (value: string) => void;
|
||
onClear: () => void;
|
||
}) {
|
||
const [reveal, setReveal] = useState(false);
|
||
const cleared = draft?.cleared ?? false;
|
||
const showClearButton = hostHasPassword && !cleared;
|
||
|
||
const placeholder = cleared
|
||
? "(password will be removed on save)"
|
||
: hostHasPassword
|
||
? "(saved — leave blank to keep, or type new)"
|
||
: "password (optional)";
|
||
|
||
return (
|
||
<label>
|
||
<span className="host-form-pw-label">
|
||
Password
|
||
<span
|
||
className="host-form-pw-hint"
|
||
title="Stored in Windows Credential Manager; auto-typed at the ssh password prompt on connect."
|
||
>
|
||
stored encrypted; auto-typed at prompt
|
||
</span>
|
||
</span>
|
||
<div className="host-form-pw-row">
|
||
<input
|
||
type={reveal ? "text" : "password"}
|
||
value={draft?.input ?? ""}
|
||
onChange={(e) => onChange(e.target.value)}
|
||
placeholder={placeholder}
|
||
autoComplete="off"
|
||
/>
|
||
<button
|
||
type="button"
|
||
className="host-form-pw-reveal"
|
||
onClick={() => setReveal((r) => !r)}
|
||
title={reveal ? "Hide" : "Show"}
|
||
>
|
||
{reveal ? "🙈" : "👁"}
|
||
</button>
|
||
{showClearButton && (
|
||
<button
|
||
type="button"
|
||
className="host-form-pw-clear"
|
||
onClick={onClear}
|
||
title="Remove the saved password from keyring on next Save"
|
||
>
|
||
Remove
|
||
</button>
|
||
)}
|
||
</div>
|
||
</label>
|
||
);
|
||
}
|