Save SSH passwords in Windows Credential Manager and auto-type at prompt

This commit is contained in:
megaproxy 2026-05-25 20:08:31 +01:00
parent 872fb0e80e
commit 1c243b3f3f
11 changed files with 538 additions and 38 deletions

View file

@ -188,6 +188,60 @@
color: #fcc;
}
.host-pw-badge {
margin-left: 6px;
font-size: 10px;
vertical-align: middle;
filter: grayscale(0.4);
}
.host-form-pw-label {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
width: 100%;
}
.host-form-pw-hint {
text-transform: none;
letter-spacing: normal;
color: #555;
font-size: 9px;
}
.host-form-pw-row {
display: flex;
gap: 4px;
}
.host-form-pw-row input {
flex: 1 1 auto;
}
.host-form-pw-reveal,
.host-form-pw-clear {
font: inherit;
font-family: "Cascadia Mono", "JetBrains Mono", "Consolas", monospace;
font-size: 11px;
padding: 2px 8px;
background: #222;
color: #aaa;
border: 1px solid #2a2a2a;
border-radius: 3px;
cursor: pointer;
}
.host-form-pw-reveal:hover,
.host-form-pw-clear:hover {
background: #2a2a2a;
color: #ddd;
}
.host-form-pw-clear {
color: #d88;
border-color: #3a1a1a;
}
.host-form-pw-clear:hover {
background: #3a1a1a;
color: #fcc;
}
.host-add-btn {
margin-top: 10px;
font: inherit;

View file

@ -19,22 +19,42 @@ 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[];
/** Called when the user clicks Save on a row. Returns a fresh list (with
* the edit applied) to persist. The parent owns the canonical state. */
/** 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);
@ -48,20 +68,36 @@ export default function HostManager({
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
const startEdit = useCallback((id: string) => setEditingId(id), []);
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.
// 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);
// Newly-added row that was never saved? Drop it entirely on cancel.
return original ?? h;
}).filter((h) => {
if (h.id !== editingId) return true;
return hosts.some((o) => o.id === editingId);
}),
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]);
@ -95,6 +131,20 @@ export default function HostManager({
[],
);
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();
@ -111,20 +161,52 @@ export default function HostManager({
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);
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, onSave],
[draft, pwDrafts, onSave, onSavePassword, onClearPassword],
);
const removeRow = useCallback(
(id: string) => {
const next = draft.filter((h) => h.id !== id);
setDraft(next);
onSave(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],
);
@ -133,6 +215,10 @@ export default function HostManager({
const fresh = blankHost();
setDraft((cur) => [...cur, fresh]);
setEditingId(fresh.id);
setPwDrafts((cur) => ({
...cur,
[fresh.id]: { input: "", cleared: false },
}));
}, []);
return (
@ -246,14 +332,19 @@ export default function HostManager({
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}
>
<button type="button" onClick={cancelEdit}>
Cancel
</button>
<button
@ -270,6 +361,14 @@ export default function HostManager({
<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}@` : ""}
@ -299,3 +398,66 @@ export default function HostManager({
</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>
);
}