diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 28063fb..9ccd3eb 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -18,6 +18,10 @@ tauri = { version = "2", features = [] } tauri-plugin-clipboard-manager = "2" tauri-plugin-opener = "2" +# Saved-credential storage (Windows Credential Manager / DPAPI). +keyring-core = "1" +windows-native-keyring-store = "1" + serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 30f7e77..a6901f0 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -3,7 +3,8 @@ use base64::{engine::general_purpose::STANDARD as B64, Engine as _}; use tauri::{AppHandle, Manager}; -use crate::hosts::{self, SshHost}; +use crate::creds; +use crate::hosts::{self, SshHost, SshHostView}; use crate::pty::{list_wsl_distros, PaneId, PtyManager, SpawnSpec}; const WORKSPACE_FILE: &str = "workspace.json"; @@ -92,11 +93,47 @@ pub async fn load_workspace(app: AppHandle) -> Result, String> { } #[tauri::command] -pub async fn list_ssh_hosts(app: AppHandle) -> Result, String> { - hosts::load(&app).map_err(|e| e.to_string()) +pub async fn list_ssh_hosts(app: AppHandle) -> Result, String> { + let raw = hosts::load(&app).map_err(|e| e.to_string())?; + Ok(raw + .into_iter() + .map(|h| { + let has_password = creds::has(&h.id); + SshHostView { host: h, has_password } + }) + .collect()) } #[tauri::command] pub async fn save_ssh_hosts(app: AppHandle, hosts: Vec) -> Result<(), String> { + // Sweep orphaned credentials: any host id that existed before this call + // but isn't in the new list gets its keyring entry deleted. Saves the + // frontend from having to diff and call delete_host_password itself. + if let Ok(prior) = crate::hosts::load(&app) { + let new_ids: std::collections::HashSet<&str> = + hosts.iter().map(|h| h.id.as_str()).collect(); + for old in &prior { + if !new_ids.contains(old.id.as_str()) { + if let Err(e) = creds::delete(&old.id) { + tracing::warn!("orphan credential cleanup failed for {}: {e}", old.id); + } + } + } + } crate::hosts::save(&app, &hosts).map_err(|e| e.to_string()) } + +#[tauri::command] +pub async fn set_host_password(host_id: String, password: String) -> Result<(), String> { + creds::set(&host_id, &password).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn delete_host_password(host_id: String) -> Result<(), String> { + creds::delete(&host_id).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn has_host_password(host_id: String) -> Result { + Ok(creds::has(&host_id)) +} diff --git a/src-tauri/src/creds.rs b/src-tauri/src/creds.rs new file mode 100644 index 0000000..b7012df --- /dev/null +++ b/src-tauri/src/creds.rs @@ -0,0 +1,46 @@ +//! Saved SSH-host credentials. Backed by Windows Credential Manager via +//! `keyring-core` + `windows-native-keyring-store` — passwords are DPAPI- +//! encrypted at rest and scoped to the user account. Never written to +//! disk in plaintext, never logged, never sent to the frontend. + +use anyhow::{Context, Result}; +use keyring_core::{Entry, Error as KeyringError}; + +const SERVICE: &str = "tiletopia"; + +fn target_for(host_id: &str) -> String { + format!("ssh-host:{host_id}") +} + +fn entry(host_id: &str) -> Result { + Entry::new(SERVICE, &target_for(host_id)) + .with_context(|| format!("create keyring entry for {host_id}")) +} + +pub fn set(host_id: &str, password: &str) -> Result<()> { + entry(host_id)? + .set_password(password) + .with_context(|| format!("write credential for {host_id}")) +} + +pub fn get(host_id: &str) -> Result> { + match entry(host_id)?.get_password() { + Ok(p) => Ok(Some(p)), + Err(KeyringError::NoEntry) => Ok(None), + Err(e) => Err(anyhow::Error::from(e) + .context(format!("read credential for {host_id}"))), + } +} + +pub fn delete(host_id: &str) -> Result<()> { + match entry(host_id)?.delete_credential() { + Ok(()) => Ok(()), + Err(KeyringError::NoEntry) => Ok(()), + Err(e) => Err(anyhow::Error::from(e) + .context(format!("delete credential for {host_id}"))), + } +} + +pub fn has(host_id: &str) -> bool { + matches!(get(host_id), Ok(Some(_))) +} diff --git a/src-tauri/src/hosts.rs b/src-tauri/src/hosts.rs index 588d782..edd149a 100644 --- a/src-tauri/src/hosts.rs +++ b/src-tauri/src/hosts.rs @@ -10,6 +10,18 @@ use tauri::{AppHandle, Manager}; const HOSTS_FILE: &str = "hosts.json"; +/// What `list_ssh_hosts` returns: the saved host plus a flag derived from +/// keyring (true iff a password is stored under this host's id). The flag +/// is read-only — saving a host doesn't touch the credential store. See +/// the dedicated set/delete password commands for that. +#[derive(Debug, Clone, Serialize)] +pub struct SshHostView { + #[serde(flatten)] + pub host: SshHost, + #[serde(rename = "hasPassword")] + pub has_password: bool, +} + /// One saved host. Fields beyond `hostname` are optional; ssh.exe will fall /// back to `~/.ssh/config` and its own defaults for anything we don't pass. #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d4e6f2d..206cc66 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,6 +1,7 @@ //! Library entry point. `main.rs` calls `run()`. mod commands; +mod creds; mod hosts; mod pty; @@ -15,6 +16,16 @@ pub fn run() { .with_writer(std::io::stderr) .try_init(); + // keyring-core 1.x requires explicit store registration before any + // Entry::new() call. We're Windows-only so the Credential Manager + // backend is the only choice. Failure here means SSH passwords won't + // be retrievable — log and continue (host configs still work without + // saved passwords; users just see the prompt and type it manually). + match windows_native_keyring_store::Store::new() { + Ok(store) => keyring_core::set_default_store(store), + Err(e) => tracing::warn!("keyring store init failed: {e}"), + } + tauri::Builder::default() .plugin(tauri_plugin_clipboard_manager::init()) .plugin(tauri_plugin_opener::init()) @@ -29,6 +40,9 @@ pub fn run() { commands::load_workspace, commands::list_ssh_hosts, commands::save_ssh_hosts, + commands::set_host_password, + commands::delete_host_password, + commands::has_host_password, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/pty.rs b/src-tauri/src/pty.rs index 9c17b50..7025420 100644 --- a/src-tauri/src/pty.rs +++ b/src-tauri/src/pty.rs @@ -5,6 +5,8 @@ use std::collections::HashMap; use std::io::{Read, Write}; use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant}; use anyhow::{anyhow, Context, Result}; use base64::{engine::general_purpose::STANDARD as B64, Engine as _}; @@ -13,6 +15,8 @@ use portable_pty::{native_pty_system, CommandBuilder, MasterPty, PtySize}; use serde::{Deserialize, Serialize}; use tauri::{AppHandle, Emitter}; +use crate::creds; + pub type PaneId = u64; /// Discriminated union describing what to spawn into a fresh PTY. Serialized @@ -35,9 +39,19 @@ pub enum SpawnSpec { jump_host: Option, #[serde(rename = "extraArgs")] extra_args: Option>, + /// SshHost.id (if any) — backend uses this to fetch a saved + /// password from keyring at spawn time. Never sent back to the + /// frontend. + #[serde(rename = "hostId")] + host_id: Option, }, } +/// Type alias for the shared writer handle. Wrapped in Arc> so the +/// reader thread can also take it briefly to autotype a saved password at +/// the SSH prompt. +type SharedWriter = Arc>>; + /// What we keep alive for each spawned PTY. /// /// `master` stays in scope to keep the PTY alive; we never write through it @@ -46,7 +60,7 @@ pub enum SpawnSpec { struct PaneHandle { #[allow(dead_code)] master: Box, - writer: Box, + writer: SharedWriter, #[allow(dead_code)] child: Box, } @@ -84,6 +98,21 @@ impl PtyManager { }) .context("openpty failed")?; + // Look up any saved password BEFORE building the command (cheap, no + // bytes-on-the-wire involved). If this is an SSH spawn with a host + // id and the user has stored a credential, the reader thread will + // autotype it when ssh prompts. + let saved_password = match &spec { + SpawnSpec::Ssh { host_id: Some(id), .. } => match creds::get(id) { + Ok(p) => p, + Err(e) => { + tracing::warn!("keyring lookup for {id} failed: {e}"); + None + } + }, + _ => None, + }; + let (cmd, spawn_err) = build_command(&spec)?; let child = pair.slave.spawn_command(cmd).context(spawn_err)?; @@ -93,10 +122,11 @@ impl PtyManager { .master .try_clone_reader() .context("try_clone_reader failed")?; - let writer = pair + let writer_raw = pair .master .take_writer() .context("take_writer failed")?; + let writer: SharedWriter = Arc::new(Mutex::new(writer_raw)); let id = self.next_id.fetch_add(1, Ordering::Relaxed); @@ -104,16 +134,19 @@ impl PtyManager { id, PaneHandle { master: pair.master, - writer, + writer: writer.clone(), child, }, ); - // Reader thread: pump bytes -> base64 -> emit. + // Reader thread: pump bytes -> base64 -> emit. Also handles the + // password-prompt autotype state machine if `saved_password` is set. let app_for_reader = app.clone(); let event_name = format!("pane://{id}/data"); + let writer_for_reader = writer.clone(); std::thread::spawn(move || { let mut buf = [0u8; 8192]; + let mut pw_state = PasswordState::from(saved_password); loop { match reader.read(&mut buf) { Ok(0) => { @@ -122,6 +155,10 @@ impl PtyManager { break; } Ok(n) => { + // Try to autotype before emitting so we don't wait + // on the renderer; pw_state mutates here. + pw_state.observe(&buf[..n], &writer_for_reader, id); + let chunk_b64 = B64.encode(&buf[..n]); if let Err(e) = app_for_reader.emit(&event_name, DataChunk { b64: chunk_b64 }) @@ -142,12 +179,16 @@ impl PtyManager { } pub fn write(&self, id: PaneId, bytes: &[u8]) -> Result<()> { - let mut panes = self.panes.lock(); - let pane = panes - .get_mut(&id) - .ok_or_else(|| anyhow!("no pane with id {id}"))?; - pane.writer.write_all(bytes).context("pty write failed")?; - pane.writer.flush().ok(); + let writer = { + let panes = self.panes.lock(); + let pane = panes + .get(&id) + .ok_or_else(|| anyhow!("no pane with id {id}"))?; + pane.writer.clone() + }; + let mut w = writer.lock(); + w.write_all(bytes).context("pty write failed")?; + w.flush().ok(); Ok(()) } @@ -279,6 +320,80 @@ fn build_command(spec: &SpawnSpec) -> Result<(CommandBuilder, &'static str)> { } } +// ---- password-prompt autotype ---------------------------------------------- + +/// How long after spawn we keep watching for a password prompt. If nothing +/// matches in this window, we disarm and never autotype — so a remote shell +/// that prints "password" hours later can't get our credential injected. +const PASSWORD_AUTOTYPE_WINDOW: Duration = Duration::from_secs(30); +/// Sliding window of recent PTY output we scan for the prompt. Keeps the +/// scan bounded; matches don't need much context. +const PROMPT_SCAN_TAIL: usize = 256; + +enum PasswordState { + Disabled, + Armed { + password: String, + deadline: Instant, + tail: Vec, + }, +} + +impl PasswordState { + fn from(password: Option) -> Self { + match password { + None => Self::Disabled, + Some(p) => Self::Armed { + password: p, + deadline: Instant::now() + PASSWORD_AUTOTYPE_WINDOW, + tail: Vec::with_capacity(PROMPT_SCAN_TAIL * 2), + }, + } + } + + /// Called for each chunk of PTY output. Mutates state — once we write + /// the password (or time out) the state collapses to Disabled and this + /// becomes a no-op for the rest of the connection. + fn observe(&mut self, chunk: &[u8], writer: &SharedWriter, pane_id: PaneId) { + let (password, tail, deadline) = match self { + PasswordState::Disabled => return, + PasswordState::Armed { password, tail, deadline } => (password, tail, deadline), + }; + + if Instant::now() > *deadline { + *self = PasswordState::Disabled; + return; + } + + tail.extend_from_slice(chunk); + if tail.len() > PROMPT_SCAN_TAIL { + let drop = tail.len() - PROMPT_SCAN_TAIL; + tail.drain(..drop); + } + + if !looks_like_password_prompt(tail) { + return; + } + + // Match — write the password + Enter, then collapse to Disabled. + let mut w = writer.lock(); + if let Err(e) = w.write_all(password.as_bytes()) { + tracing::warn!("pane {pane_id}: password autotype write failed: {e}"); + } + let _ = w.write_all(b"\n"); + let _ = w.flush(); + *self = PasswordState::Disabled; + } +} + +fn looks_like_password_prompt(buf: &[u8]) -> bool { + // OpenSSH prompts: `@'s password:`, `Permission denied, + // please try again. password:`, `Enter passphrase for key '...':`. + // Lowercase the recent tail and substring-match — cheap and good enough. + let s = String::from_utf8_lossy(buf).to_ascii_lowercase(); + s.contains("password:") || s.contains("passphrase") +} + // ---- distro enumeration ----------------------------------------------------- /// Run a process without flashing a console window on Windows. diff --git a/src/App.tsx b/src/App.tsx index 737f362..ef347a0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,6 +5,8 @@ import { saveWorkspace, listSshHosts, saveSshHosts, + setHostPassword, + deleteHostPassword, writeToPane, killPane, type PaneId, @@ -262,10 +264,41 @@ export default function App() { const openHostManager = useCallback(() => setHostManagerOpen(true), []); const closeHostManager = useCallback(() => setHostManagerOpen(false), []); - const saveHosts = useCallback((next: SshHost[]) => { - setHosts(next); - saveSshHosts(next).catch((e) => - console.warn("saveSshHosts failed:", e), + const saveHosts = useCallback( + (next: SshHost[]) => { + // Preserve hasPassword flags that aren't included in the payload from + // HostManager (the manager strips them — backend recomputes on next + // list_ssh_hosts; we keep them locally so the badge doesn't flicker). + setHosts((prev) => + next.map((h) => { + const hp = h.hasPassword ?? prev.find((p) => p.id === h.id)?.hasPassword; + return hp === undefined ? h : { ...h, hasPassword: hp }; + }), + ); + saveSshHosts(next).catch((e) => + console.warn("saveSshHosts failed:", e), + ); + }, + [], + ); + + const savePassword = useCallback((hostId: string, password: string) => { + setHostPassword(hostId, password).then( + () => + setHosts((prev) => + prev.map((h) => (h.id === hostId ? { ...h, hasPassword: true } : h)), + ), + (e) => console.warn("setHostPassword failed:", e), + ); + }, []); + + const clearPassword = useCallback((hostId: string) => { + deleteHostPassword(hostId).then( + () => + setHosts((prev) => + prev.map((h) => (h.id === hostId ? { ...h, hasPassword: false } : h)), + ), + (e) => console.warn("deleteHostPassword failed:", e), ); }, []); @@ -715,6 +748,8 @@ export default function App() { )} diff --git a/src/components/HostManager.css b/src/components/HostManager.css index 9fe4cde..b4436d4 100644 --- a/src/components/HostManager.css +++ b/src/components/HostManager.css @@ -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; diff --git a/src/components/HostManager.tsx b/src/components/HostManager.tsx index 4e81b73..a87dd7d 100644 --- a/src/components/HostManager.tsx +++ b/src/components/HostManager.tsx @@ -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(() => hosts.map((h) => ({ ...h }))); + // Per-row password edits (keyed by host id). Absent = unchanged. + const [pwDrafts, setPwDrafts] = useState>({}); // Which row is being edited. null = list view only. const [editingId, setEditingId] = useState(null); const dialogRef = useRef(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" /> + + onPasswordInput(h.id, v)} + onClear={() => onPasswordClear(h.id)} + /> +
- + {showClearButton && ( + + )} +
+ + ); +} diff --git a/src/ipc.ts b/src/ipc.ts index 2cf8921..8ec3157 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -15,9 +15,13 @@ export type SpawnSpec = identityFile?: string; jumpHost?: string; extraArgs?: string[]; + /** Backend uses this to look up a saved password from keyring at + * spawn time. Never echoed back to the frontend. */ + hostId?: string; }; -/** One saved SSH host. Mirrors the Rust `SshHost` struct. */ +/** One saved SSH host. Mirrors the Rust `SshHost` struct (plus the + * `hasPassword` flag that the backend sets when listing). */ export interface SshHost { id: string; label: string; @@ -27,6 +31,10 @@ export interface SshHost { identityFile?: string; jumpHost?: string; extraArgs?: string[]; + /** True iff a credential is stored under this host's id in the system + * keyring. Set by the backend on `list_ssh_hosts`; the field is + * ignored on `save_ssh_hosts` (use the password commands below). */ + hasPassword?: boolean; } export const listDistros = (): Promise => invoke("list_distros"); @@ -70,3 +78,15 @@ export const listSshHosts = (): Promise => invoke("list_ssh_hosts"); export const saveSshHosts = (hosts: SshHost[]): Promise => invoke("save_ssh_hosts", { hosts }); + +/** Store / replace the saved password for this host id. Plaintext is + * IPC'd to the Rust side (in-process, no disk hop) and immediately + * written to Windows Credential Manager (DPAPI). */ +export const setHostPassword = (hostId: string, password: string): Promise => + invoke("set_host_password", { hostId, password }); + +export const deleteHostPassword = (hostId: string): Promise => + invoke("delete_host_password", { hostId }); + +export const hasHostPassword = (hostId: string): Promise => + invoke("has_host_password", { hostId }); diff --git a/src/lib/layout/LeafPane.tsx b/src/lib/layout/LeafPane.tsx index 6911a1d..41dc3cb 100644 --- a/src/lib/layout/LeafPane.tsx +++ b/src/lib/layout/LeafPane.tsx @@ -288,6 +288,7 @@ export default function LeafPane({ leaf }: { leaf: LeafNode }) { identityFile: host.identityFile, jumpHost: host.jumpHost, extraArgs: host.extraArgs, + hostId: host.id, }; })();