Fix tab close modal, section headers, and credential storage
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
This commit is contained in:
@@ -16,8 +16,8 @@ const DEFAULTS = {
|
|||||||
protocol: 'ssh',
|
protocol: 'ssh',
|
||||||
ssh: {
|
ssh: {
|
||||||
authMethods: ['publickey', 'password', 'keyboard-interactive'],
|
authMethods: ['publickey', 'password', 'keyboard-interactive'],
|
||||||
secretMode: 'ask-every-time',
|
secretMode: 'saved',
|
||||||
keySecretMode: 'ask-every-time',
|
keySecretMode: 'saved',
|
||||||
hostKeyPolicy: 'ask',
|
hostKeyPolicy: 'ask',
|
||||||
terminalType: 'xterm-256color',
|
terminalType: 'xterm-256color',
|
||||||
keepaliveInterval: 30,
|
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', () => {
|
header.addEventListener('click', () => {
|
||||||
collapsed = !collapsed;
|
collapsed = !collapsed;
|
||||||
content.style.display = collapsed ? 'none' : 'block';
|
content.style.display = collapsed ? 'none' : 'block';
|
||||||
|
|||||||
@@ -117,6 +117,9 @@ class Sidebar {
|
|||||||
<span>${escapeHtml(title)}</span>
|
<span>${escapeHtml(title)}</span>
|
||||||
<span class="section-count">${profiles.length}</span>
|
<span class="section-count">${profiles.length}</span>
|
||||||
`;
|
`;
|
||||||
|
// 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));
|
header.addEventListener('click', () => this.toggleSection(key));
|
||||||
section.appendChild(header);
|
section.appendChild(header);
|
||||||
|
|
||||||
@@ -305,7 +308,9 @@ class Sidebar {
|
|||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
async _deleteProfile(profile) {
|
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;
|
if (!ok) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -318,6 +323,52 @@ class Sidebar {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows a confirmation modal for profile deletion.
|
||||||
|
* @param {Object} profile
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
* @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 = `
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 class="modal-title">Delete Profile</h3>
|
||||||
|
<button class="btn btn-icon btn-xs modal-close">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p style="font-size:var(--text-sm);color:var(--text-secondary)">
|
||||||
|
Delete <strong>${label}</strong>? This cannot be undone.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-sm confirm-cancel">Cancel</button>
|
||||||
|
<button class="btn btn-sm btn-danger confirm-ok">Delete</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
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 ────────────────────────────────────────────────────
|
// ── Filtering ────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -172,10 +172,14 @@ class TabManager {
|
|||||||
`;
|
`;
|
||||||
backdrop.appendChild(modal);
|
backdrop.appendChild(modal);
|
||||||
document.body.appendChild(backdrop);
|
document.body.appendChild(backdrop);
|
||||||
requestAnimationFrame(() => backdrop.classList.add('is-visible'));
|
requestAnimationFrame(() => {
|
||||||
|
backdrop.classList.add('is-visible');
|
||||||
|
modal.classList.add('is-visible');
|
||||||
|
});
|
||||||
|
|
||||||
const close = (val) => {
|
const close = (val) => {
|
||||||
backdrop.classList.remove('is-visible');
|
backdrop.classList.remove('is-visible');
|
||||||
|
modal.classList.remove('is-visible');
|
||||||
setTimeout(() => backdrop.remove(), 200);
|
setTimeout(() => backdrop.remove(), 200);
|
||||||
resolve(val);
|
resolve(val);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -72,8 +72,8 @@ func NewProfile(protocol Protocol) Profile {
|
|||||||
p.Port = DefaultSSHPort
|
p.Port = DefaultSSHPort
|
||||||
p.SSH = &SSHConfig{
|
p.SSH = &SSHConfig{
|
||||||
AuthMethods: []string{"publickey", "password", "keyboard-interactive"},
|
AuthMethods: []string{"publickey", "password", "keyboard-interactive"},
|
||||||
SecretMode: SecretModeAskEveryTime,
|
SecretMode: SecretModeSaved,
|
||||||
KeySecretMode: SecretModeAskEveryTime,
|
KeySecretMode: SecretModeSaved,
|
||||||
HostKeyPolicy: HostKeyAsk,
|
HostKeyPolicy: HostKeyAsk,
|
||||||
TerminalType: DefaultTerminalType,
|
TerminalType: DefaultTerminalType,
|
||||||
KeepaliveInterval: DefaultKeepaliveInterval,
|
KeepaliveInterval: DefaultKeepaliveInterval,
|
||||||
|
|||||||
@@ -456,9 +456,10 @@ func (m *Manager) connectSSH(sess *Session, getSecret func(prompt string) (strin
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
// Build credential callbacks that check keychain/store before prompting.
|
// Build credential callbacks that respect the profile's secret mode.
|
||||||
getPassword := m.buildSecretCallback(profile.ID, "password", getSecret)
|
sshConf := profile.SSH
|
||||||
getPassphrase := m.buildSecretCallback(profile.ID, "passphrase", getSecret)
|
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 {
|
if err := client.Connect(getPassword, getPassphrase); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -561,35 +562,75 @@ func (m *Manager) connectRAW(sess *Session) error {
|
|||||||
// Internal: helpers
|
// Internal: helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
// buildSecretCallback returns a func() (string, error) that first checks the
|
// buildSecretCallback returns a func() (string, error) that resolves a
|
||||||
// OS keychain, then the encrypted vault, then prompts the user via getSecret.
|
// credential according to the profile's SecretMode:
|
||||||
func (m *Manager) buildSecretCallback(profileID string, secretType string, getSecret func(prompt string) (string, error)) func() (string, error) {
|
// - "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) {
|
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.
|
// Try the encrypted file store.
|
||||||
if m.keychainStore != nil {
|
if m.secretStore != nil && m.secretStore.IsUnlocked() {
|
||||||
if val, err := m.keychainStore.GetSecret(secretKey); err == nil && val != "" {
|
if val, err := m.secretStore.GetSecret(secretKey); err == nil && val != "" {
|
||||||
m.auditLog.LogSecretAccess(profileID, secretType)
|
m.auditLog.LogSecretAccess(profileID, secretType)
|
||||||
return val, nil
|
return val, nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try the encrypted file store.
|
// ── "session-only" mode: return cached value if we already asked ─
|
||||||
if m.secretStore != nil && m.secretStore.IsUnlocked() {
|
if mode == models.SecretModeSessionOnly && sessionCachedSet {
|
||||||
if val, err := m.secretStore.GetSecret(secretKey); err == nil && val != "" {
|
return sessionCached, nil
|
||||||
m.auditLog.LogSecretAccess(profileID, secretType)
|
|
||||||
return val, nil
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prompt the user via the callback.
|
// ── Prompt the user ──────────────────────────────────────────────
|
||||||
if getSecret == nil {
|
if getSecret == nil {
|
||||||
return "", fmt.Errorf("no %s available and no prompt callback provided", secretType)
|
return "", fmt.Errorf("no %s available and no prompt callback provided", secretType)
|
||||||
}
|
}
|
||||||
|
|
||||||
prompt := fmt.Sprintf("Enter %s:", 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user