From 48680d0eafee104bf5507805cee62ef4c69246b8 Mon Sep 17 00:00:00 2001 From: Andy Dixon Date: Wed, 15 Apr 2026 00:00:00 +0100 Subject: [PATCH] Fix tab close modal, section headers, and credential storage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tab close confirmation: - Add is-visible to both backdrop AND modal element — the modal CSS starts with visibility:hidden/opacity:0, so the modal was invisible despite the backdrop blur appearing Section headers (sidebar + profile editor): - Add mousedown preventDefault() on all clickable section headers — WKWebView initiates a text selection gesture on mousedown for divs, swallowing the first click and requiring a double-click - Fix sidebar delete profile to use custom modal instead of blocked window.confirm() Credential storage: - buildSecretCallback now respects profile's secretMode field: - 'saved': checks keychain/vault first, prompts if missing, then persists to keychain (preferred) or encrypted vault - 'session-only': caches in memory per-session, prompts once - 'ask-every-time': always prompts - Previously the callback always prompted and never saved - Change default secretMode from 'ask-every-time' to 'saved' in both Go defaults and JS frontend defaults --- frontend/src/js/profiles.js | 7 +++- frontend/src/js/sidebar.js | 53 ++++++++++++++++++++++- frontend/src/js/tabs.js | 6 ++- internal/models/defaults.go | 4 +- internal/sessions/manager.go | 81 +++++++++++++++++++++++++++--------- 5 files changed, 125 insertions(+), 26 deletions(-) diff --git a/frontend/src/js/profiles.js b/frontend/src/js/profiles.js index 03c4e1b..55194a2 100644 --- a/frontend/src/js/profiles.js +++ b/frontend/src/js/profiles.js @@ -16,8 +16,8 @@ const DEFAULTS = { protocol: 'ssh', ssh: { authMethods: ['publickey', 'password', 'keyboard-interactive'], - secretMode: 'ask-every-time', - keySecretMode: 'ask-every-time', + secretMode: 'saved', + keySecretMode: 'saved', hostKeyPolicy: 'ask', terminalType: 'xterm-256color', keepaliveInterval: 30, @@ -459,6 +459,9 @@ class ProfileEditor { } } + // Prevent mousedown from starting a text selection gesture in WKWebView, + // which would swallow the first click and require a double-click. + header.addEventListener('mousedown', (e) => e.preventDefault()); header.addEventListener('click', () => { collapsed = !collapsed; content.style.display = collapsed ? 'none' : 'block'; diff --git a/frontend/src/js/sidebar.js b/frontend/src/js/sidebar.js index 83f65ff..057ac55 100644 --- a/frontend/src/js/sidebar.js +++ b/frontend/src/js/sidebar.js @@ -117,6 +117,9 @@ class Sidebar { ${escapeHtml(title)} ${profiles.length} `; + // Prevent mousedown from starting a text selection gesture in WKWebView, + // which would swallow the first click and require a double-click. + header.addEventListener('mousedown', (e) => e.preventDefault()); header.addEventListener('click', () => this.toggleSection(key)); section.appendChild(header); @@ -305,7 +308,9 @@ class Sidebar { * @private */ async _deleteProfile(profile) { - const ok = confirm(`Delete "${profile.displayName || profile.host}"?\nThis cannot be undone.`); + // Use a custom confirmation modal — window.confirm() is silently + // blocked in WKWebView and always returns false. + const ok = await this._confirmDelete(profile); if (!ok) return; try { @@ -318,6 +323,52 @@ class Sidebar { } } + /** + * Shows a confirmation modal for profile deletion. + * @param {Object} profile + * @returns {Promise} + * @private + */ + _confirmDelete(profile) { + return new Promise((resolve) => { + const backdrop = createElement('div', 'modal-backdrop'); + const modal = createElement('div', 'modal modal-sm'); + const label = escapeHtml(profile.displayName || profile.host || 'Untitled'); + modal.innerHTML = ` + + + + `; + backdrop.appendChild(modal); + document.body.appendChild(backdrop); + requestAnimationFrame(() => { + backdrop.classList.add('is-visible'); + modal.classList.add('is-visible'); + }); + + const close = (val) => { + backdrop.classList.remove('is-visible'); + modal.classList.remove('is-visible'); + setTimeout(() => backdrop.remove(), 200); + resolve(val); + }; + modal.querySelector('.modal-close').addEventListener('click', () => close(false)); + modal.querySelector('.confirm-cancel').addEventListener('click', () => close(false)); + modal.querySelector('.confirm-ok').addEventListener('click', () => close(true)); + backdrop.addEventListener('click', (e) => { if (e.target === backdrop) close(false); }); + }); + } + // ── Filtering ──────────────────────────────────────────────────── /** diff --git a/frontend/src/js/tabs.js b/frontend/src/js/tabs.js index 262a9cf..29d4a7e 100644 --- a/frontend/src/js/tabs.js +++ b/frontend/src/js/tabs.js @@ -172,10 +172,14 @@ class TabManager { `; backdrop.appendChild(modal); document.body.appendChild(backdrop); - requestAnimationFrame(() => backdrop.classList.add('is-visible')); + requestAnimationFrame(() => { + backdrop.classList.add('is-visible'); + modal.classList.add('is-visible'); + }); const close = (val) => { backdrop.classList.remove('is-visible'); + modal.classList.remove('is-visible'); setTimeout(() => backdrop.remove(), 200); resolve(val); }; diff --git a/internal/models/defaults.go b/internal/models/defaults.go index 7220447..f5931eb 100644 --- a/internal/models/defaults.go +++ b/internal/models/defaults.go @@ -72,8 +72,8 @@ func NewProfile(protocol Protocol) Profile { p.Port = DefaultSSHPort p.SSH = &SSHConfig{ AuthMethods: []string{"publickey", "password", "keyboard-interactive"}, - SecretMode: SecretModeAskEveryTime, - KeySecretMode: SecretModeAskEveryTime, + SecretMode: SecretModeSaved, + KeySecretMode: SecretModeSaved, HostKeyPolicy: HostKeyAsk, TerminalType: DefaultTerminalType, KeepaliveInterval: DefaultKeepaliveInterval, diff --git a/internal/sessions/manager.go b/internal/sessions/manager.go index adacd06..276bb44 100644 --- a/internal/sessions/manager.go +++ b/internal/sessions/manager.go @@ -456,9 +456,10 @@ func (m *Manager) connectSSH(sess *Session, getSecret func(prompt string) (strin }, ) - // Build credential callbacks that check keychain/store before prompting. - getPassword := m.buildSecretCallback(profile.ID, "password", getSecret) - getPassphrase := m.buildSecretCallback(profile.ID, "passphrase", getSecret) + // Build credential callbacks that respect the profile's secret mode. + sshConf := profile.SSH + getPassword := m.buildSecretCallback(profile.ID, "password", sshConf.SecretMode, getSecret) + getPassphrase := m.buildSecretCallback(profile.ID, "passphrase", sshConf.KeySecretMode, getSecret) if err := client.Connect(getPassword, getPassphrase); err != nil { return err @@ -561,35 +562,75 @@ func (m *Manager) connectRAW(sess *Session) error { // Internal: helpers // --------------------------------------------------------------------------- -// buildSecretCallback returns a func() (string, error) that first checks the -// OS keychain, then the encrypted vault, then prompts the user via getSecret. -func (m *Manager) buildSecretCallback(profileID string, secretType string, getSecret func(prompt string) (string, error)) func() (string, error) { +// buildSecretCallback returns a func() (string, error) that resolves a +// credential according to the profile's SecretMode: +// - "saved": check keychain/vault first, prompt if missing, then persist +// - "session-only": check an in-memory session cache, prompt once per session +// - "ask-every-time" (or any other value): always prompt the user +func (m *Manager) buildSecretCallback(profileID string, secretType string, mode models.SecretMode, getSecret func(prompt string) (string, error)) func() (string, error) { + secretKey := fmt.Sprintf("profile:%s:%s", profileID, secretType) + + // Session-local cache for "session-only" mode. The closure captures this + // variable so it survives across multiple calls within the same connection. + var sessionCached string + var sessionCachedSet bool + return func() (string, error) { - secretKey := fmt.Sprintf("profile:%s:%s", profileID, secretType) + // ── "saved" mode: try persistent stores first ──────────────────── + if mode == models.SecretModeSaved { + // Try the OS keychain first. + if m.keychainStore != nil { + if val, err := m.keychainStore.GetSecret(secretKey); err == nil && val != "" { + m.auditLog.LogSecretAccess(profileID, secretType) + return val, nil + } + } - // Try the OS keychain first. - if m.keychainStore != nil { - if val, err := m.keychainStore.GetSecret(secretKey); err == nil && val != "" { - m.auditLog.LogSecretAccess(profileID, secretType) - return val, nil + // Try the encrypted file store. + if m.secretStore != nil && m.secretStore.IsUnlocked() { + if val, err := m.secretStore.GetSecret(secretKey); err == nil && val != "" { + m.auditLog.LogSecretAccess(profileID, secretType) + return val, nil + } } } - // Try the encrypted file store. - if m.secretStore != nil && m.secretStore.IsUnlocked() { - if val, err := m.secretStore.GetSecret(secretKey); err == nil && val != "" { - m.auditLog.LogSecretAccess(profileID, secretType) - return val, nil - } + // ── "session-only" mode: return cached value if we already asked ─ + if mode == models.SecretModeSessionOnly && sessionCachedSet { + return sessionCached, nil } - // Prompt the user via the callback. + // ── Prompt the user ────────────────────────────────────────────── if getSecret == nil { return "", fmt.Errorf("no %s available and no prompt callback provided", secretType) } prompt := fmt.Sprintf("Enter %s:", secretType) - return getSecret(prompt) + val, err := getSecret(prompt) + if err != nil { + return "", err + } + + // ── Persist according to mode ──────────────────────────────────── + switch mode { + case models.SecretModeSaved: + // Prefer OS keychain, fall back to encrypted vault. + stored := false + if m.keychainStore != nil { + if sErr := m.keychainStore.SetSecret(secretKey, val); sErr == nil { + stored = true + } + } + if !stored && m.secretStore != nil && m.secretStore.IsUnlocked() { + _ = m.secretStore.SetSecret(secretKey, val) + } + + case models.SecretModeSessionOnly: + sessionCached = val + sessionCachedSet = true + } + + return val, nil } }