tiletopia/src/components/HostManager.tsx

463 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}