Save SSH passwords in Windows Credential Manager and auto-type at prompt
This commit is contained in:
parent
872fb0e80e
commit
1c243b3f3f
11 changed files with 538 additions and 38 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue