From a10f632796a81a377ab243b3b80d8fe3a56a4ab0 Mon Sep 17 00:00:00 2001 From: bifrost0x Date: Thu, 13 Aug 2026 08:22:46 +0200 Subject: [PATCH 1/3] Redesign Quick Connect workflow --- app/__init__.py | 5 + app/browser_identity.py | 24 +++ static/css/style.css | 264 +++++++++++++++++++++-- static/js/app.js | 172 +++++++-------- static/js/connection-history.js | 98 +++++++++ static/js/i18n.js | 60 ++++++ templates/index.html | 81 +++++-- tests/e2e/product-captures.spec.js | 10 +- tests/e2e/quick-connect-redesign.spec.js | 118 ++++++++++ tests/js/connection-history.test.js | 148 +++++++++++++ tests/test_browser_identity.py | 44 ++++ tests/test_key_management_ui.py | 6 +- tests/test_profile_launcher_ui.py | 22 +- tests/test_startup_command_ui_state.py | 9 +- 14 files changed, 901 insertions(+), 160 deletions(-) create mode 100644 app/browser_identity.py create mode 100644 static/js/connection-history.js create mode 100644 tests/e2e/quick-connect-redesign.spec.js create mode 100644 tests/js/connection-history.test.js create mode 100644 tests/test_browser_identity.py diff --git a/app/__init__.py b/app/__init__.py index c42a5a1..b021f5b 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -16,6 +16,7 @@ from .storage_errors import StorageCorruptionError from .tailscale_ssh import user_can_use_tailscale_ssh from .runtime_lifecycle import RuntimeLifecycle +from .browser_identity import connection_history_scope socketio = SocketIO( async_mode=config.SOCKETIO_ASYNC_MODE, @@ -327,6 +328,10 @@ def index(): 'index.html', username=current_user.username, theme=theme, + connection_history_scope=connection_history_scope( + current_user, + app.config['SECRET_KEY'], + ), confirm_session_close=settings.get('confirm_session_close', True), max_editor_file_size=config.MAX_EDITOR_FILE_SIZE, ) diff --git a/app/browser_identity.py b/app/browser_identity.py new file mode 100644 index 0000000..6ea201f --- /dev/null +++ b/app/browser_identity.py @@ -0,0 +1,24 @@ +"""Opaque browser-storage namespaces for authenticated users.""" + +import hashlib +import hmac + + +def connection_history_scope(user, secret_key): + """Return an instance-bound namespace for one generation of an account.""" + created_at = getattr(user, 'created_at', None) + if created_at is not None: + generation = created_at.isoformat() + else: + # Older imported databases can contain a null creation timestamp. The + # salted password hash still prevents a reused numeric id from inheriting + # browser history. A password change safely starts a fresh history. + generation = getattr(user, 'password_hash', '') + material = '\0'.join(( + 'webssh-connection-history-v1', + str(user.id), + str(getattr(user, 'username', '')), + generation, + )).encode('utf-8') + key = secret_key if isinstance(secret_key, bytes) else str(secret_key).encode('utf-8') + return hmac.new(key, material, hashlib.sha256).hexdigest() diff --git a/static/css/style.css b/static/css/style.css index 02d2933..eabcb9d 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -2618,34 +2618,60 @@ textarea.form-control { } .recent-connections-list { - display: flex; - flex-wrap: wrap; + display: grid; gap: 8px; - max-height: 80px; + max-height: 230px; overflow-y: auto; } +.recent-connections-empty { + margin: 0; + padding: 16px 12px; + border: 1px dashed var(--border-color); + border-radius: 8px; + color: var(--text-muted); + font-size: 12px; + line-height: 1.5; + text-align: center; +} + .recent-connection-item { - display: flex; + width: 100%; + min-width: 0; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; align-items: center; - gap: 8px; - padding: 6px 12px; + gap: 12px; + padding: 10px 12px; background: var(--bg-tertiary); border: 1px solid var(--border-color); - border-radius: 6px; + border-radius: 8px; + color: var(--text-primary); cursor: pointer; - transition: all 0.2s ease; + font: inherit; font-size: 13px; + text-align: left; + transition: background 0.15s ease, border-color 0.15s ease, transform 0.15s ease; } .recent-connection-item:hover { background: var(--bg-hover); border-color: var(--accent-primary); + transform: translateY(-1px); +} + +.recent-connection-item:focus-visible { + outline: 2px solid var(--accent-primary); + outline-offset: 2px; } .recent-conn-label { + min-width: 0; + overflow: hidden; color: var(--text-primary); - font-weight: 500; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; } .recent-conn-time { @@ -4732,29 +4758,227 @@ body.keyboard-open.notepad-focused .notepad-panel { color: var(--accent-primary); } -.connection-advanced-settings { - margin-bottom: 18px; - padding: 0 14px; +#connectionModal .modal-content { + width: min(100%, 1080px); +} + +.quick-connect-grid { + display: grid; + grid-template-columns: minmax(0, 1.12fr) minmax(320px, 0.88fr); + align-items: start; + gap: 18px; +} + +.quick-connect-secondary { + min-width: 0; + display: grid; + gap: 18px; +} + +.quick-connect-card { + min-width: 0; + padding: 20px; border: 1px solid var(--border-color); - border-radius: 10px; + border-radius: 12px; background: var(--bg-secondary); + background: color-mix(in srgb, var(--bg-secondary) 92%, transparent); + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.08); } -.connection-advanced-settings > summary { - padding: 13px 0; +.quick-connect-card-header { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 12px; + align-items: start; + margin-bottom: 20px; +} + +.quick-connect-card-header.compact { + margin-bottom: 14px; +} + +.quick-connect-card-header > .material-icons, +.connection-advanced-settings > summary > .material-icons:first-child { + display: grid; + width: 36px; + height: 36px; + place-items: center; + border-radius: 9px; + background: var(--bg-tertiary); + background: color-mix(in srgb, var(--accent-primary) 14%, transparent); + color: var(--accent-primary); + font-size: 20px; +} + +.quick-connect-card-header h3, +.quick-connect-card-header p { + margin: 0; +} + +.quick-connect-card-header h3 { + color: var(--text-primary); + font-size: 15px; + font-weight: 700; +} + +.quick-connect-card-header p { + margin-top: 3px; + color: var(--text-secondary); + font-size: 12px; + line-height: 1.45; +} + +.quick-connect-details-card > .form-group:last-child { + margin-bottom: 0; +} + +.connection-profile-context { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 10px; + margin-bottom: 18px; + padding: 12px; + border: 1px solid var(--accent-primary); + border-color: color-mix(in srgb, var(--accent-primary) 45%, var(--border-color)); + border-radius: 9px; + background: var(--bg-tertiary); + background: color-mix(in srgb, var(--accent-primary) 9%, transparent); + color: var(--text-secondary); +} + +.connection-profile-context > .material-icons { + color: var(--accent-primary); + font-size: 20px; +} + +.connection-profile-context > div:last-child { + min-width: 0; + display: grid; + gap: 2px; +} + +.connection-profile-context small { + color: var(--text-muted); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.connection-profile-context strong { + overflow-wrap: anywhere; + color: var(--text-primary); +} + +.connection-profile-context > div > span { + font-size: 12px; + line-height: 1.4; +} + +.connection-profile-jump-resolution { + display: grid; + gap: 8px; + margin-top: 8px; + padding-top: 10px; + border-top: 1px solid color-mix(in srgb, var(--accent-primary) 35%, var(--border-color)); + color: var(--warning-color); + font-size: 12px; + line-height: 1.45; +} + +.connection-profile-jump-resolution .checkbox-label { + width: fit-content; + padding: 6px 8px; color: var(--text-primary); font-weight: 600; +} + +.connection-advanced-settings { + margin: 0; + padding: 0; + overflow: clip; +} + +.connection-advanced-settings > summary { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 12px; + padding: 18px 20px; + color: var(--text-primary); cursor: pointer; + list-style: none; } -.connection-advanced-settings > summary:hover, -.connection-advanced-settings > summary:focus-visible { +.connection-advanced-settings > summary::-webkit-details-marker { + display: none; +} + +.connection-advanced-settings > summary > span:nth-child(2) { + min-width: 0; + display: grid; + gap: 3px; +} + +.connection-advanced-settings > summary strong { + font-size: 15px; +} + +.connection-advanced-settings > summary small { + color: var(--text-secondary); + font-size: 12px; + font-weight: 400; + line-height: 1.4; +} + +.connection-advanced-settings > summary:hover strong, +.connection-advanced-settings > summary:focus-visible strong { color: var(--accent-primary); } -.connection-advanced-settings[open] > summary { - margin-bottom: 12px; - border-bottom: 1px solid var(--border-color); +.connection-advanced-settings > summary:focus-visible { + outline: 2px solid var(--accent-primary); + outline-offset: -3px; + border-radius: 10px; +} + +.connection-advanced-chevron { + color: var(--text-muted); + transition: transform 0.18s ease; +} + +.connection-advanced-settings[open] .connection-advanced-chevron { + transform: rotate(180deg); +} + +.connection-advanced-content { + padding: 18px 20px 20px; + border-top: 1px solid var(--border-color); +} + +.connection-advanced-content > .form-group:last-child { + margin-bottom: 0; +} + +@media (max-width: 900px) { + .quick-connect-grid { + grid-template-columns: minmax(0, 1fr); + } +} + +@media (max-width: 480px) { + .quick-connect-card { + padding: 16px; + } + + .connection-advanced-settings { + padding: 0; + } + + .connection-advanced-settings > summary, + .connection-advanced-content { + padding: 16px; + } } /* Reusable post-connect command sets */ diff --git a/static/js/app.js b/static/js/app.js index c557296..01228b9 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -126,7 +126,21 @@ if (advanced) advanced.open = expanded === true; }; + let selectedConnectionProfileState = null; + + function refreshConnectionProfileJumpResolution() { + const resolution = document.getElementById('connectionProfileJumpResolution'); + const directConfirm = document.getElementById('connectionProfileDirectConfirm'); + const selectedJumpHostId = document.getElementById('jumpHostSelect')?.value || ''; + const needsDecision = selectedConnectionProfileState?.missingJumpHost === true + && !selectedJumpHostId; + resolution?.classList.toggle('hidden', !needsDecision); + if (!needsDecision && directConfirm) directConfirm.checked = false; + return needsDecision && directConfirm?.checked !== true; + } + window.clearConnectionProfileState = () => { + selectedConnectionProfileState = null; window.ConnectionCommandManager?.clear(); ProfileManager.clearLegacyCommands(); window.setConnectionAdvancedExpanded(false); @@ -134,80 +148,52 @@ const useTmuxCheck = document.getElementById('useTmuxCheck'); if (useTmuxCheck) useTmuxCheck.checked = useTmuxCheck.defaultChecked; - const profileSelect = document.getElementById('profileSelect'); - if (profileSelect) { - profileSelect.value = ''; - } - - const deleteProfileBtn = document.getElementById('deleteProfileBtn'); - if (deleteProfileBtn) { - deleteProfileBtn.style.display = 'none'; - delete deleteProfileBtn.dataset.profileId; - } + const profileContext = document.getElementById('connectionProfileContext'); + profileContext?.classList.add('hidden'); + const profileContextName = document.getElementById('connectionProfileContextName'); + if (profileContextName) profileContextName.textContent = ''; + const directConfirm = document.getElementById('connectionProfileDirectConfirm'); + if (directConfirm) directConfirm.checked = false; + document.getElementById('connectionProfileJumpResolution')?.classList.add('hidden'); }; - const ConnectionHistory = { - maxItems: 10, - storageKey: 'recentConnections', - maxAge: 30 * 24 * 60 * 60 * 1000, - - getHistory() { - try { - const history = JSON.parse(localStorage.getItem(this.storageKey) || '[]'); - const now = Date.now(); - const filtered = history.filter(entry => { - if (!entry.timestamp) return true; - return (now - entry.timestamp) < this.maxAge; - }); - if (filtered.length !== history.length) { - localStorage.setItem(this.storageKey, JSON.stringify(filtered)); - } - return filtered; - } catch { - return []; - } - }, - - addConnection(host, port, username) { - const history = this.getHistory(); - const entry = { host, port: parseInt(port), username, timestamp: Date.now() }; - - const filtered = history.filter(h => - !(h.host === host && h.port === parseInt(port) && h.username === username) - ); - - filtered.unshift(entry); - - const trimmed = filtered.slice(0, this.maxItems); + let connectionHistoryStorage = null; + try { + connectionHistoryStorage = window.localStorage; + } catch { + // Some privacy modes deny storage access entirely. + } + const connectionHistoryStore = window.ConnectionHistoryFactory?.createConnectionHistory({ + storage: connectionHistoryStorage, + scope: document.body.dataset.connectionHistoryScope, + }) || { getHistory: () => [], addConnection: () => {} }; - try { - localStorage.setItem(this.storageKey, JSON.stringify(trimmed)); - } catch { - console.error('Failed to save connection history'); - } - }, + const ConnectionHistory = { + ...connectionHistoryStore, renderHistoryDropdown() { const container = document.getElementById('recentConnectionsList'); if (!container) return; const history = this.getHistory(); - container.innerHTML = ''; - - if (history.length === 0) { - container.style.display = 'none'; - return; - } - - container.style.display = 'block'; + container.replaceChildren(); + container.classList.toggle('hidden', history.length === 0); + document.getElementById('recentConnectionsEmpty') + ?.classList.toggle('hidden', history.length > 0); history.forEach(conn => { - const option = document.createElement('div'); + const option = document.createElement('button'); + option.type = 'button'; option.className = 'recent-connection-item'; - option.innerHTML = ` - ${escapeHtml(conn.username)}@${escapeHtml(conn.host)}:${escapeHtml(String(conn.port))} - ${escapeHtml(this.formatTime(conn.timestamp))} - `; + option.setAttribute('aria-label', `${conn.username}@${conn.host}:${conn.port}`); + + const label = document.createElement('span'); + label.className = 'recent-conn-label'; + label.textContent = `${conn.username}@${conn.host}:${conn.port}`; + const time = document.createElement('span'); + time.className = 'recent-conn-time'; + time.textContent = this.formatTime(conn.timestamp); + option.append(label, time); option.addEventListener('click', () => { window.clearConnectionProfileState(); document.getElementById('hostInput').value = conn.host; @@ -1137,10 +1123,6 @@ } ConnectionHistory.renderHistoryDropdown(); - const historyGroup = document.getElementById('recentConnectionsGroup'); - if (historyGroup) { - historyGroup.style.display = ConnectionHistory.getHistory().length > 0 ? 'block' : 'none'; - } const modal = document.getElementById('connectionModal'); if (window.ModalManager) { @@ -1152,33 +1134,30 @@ } function selectConnectionProfile(profileId) { - const profileSelect = document.getElementById('profileSelect'); - const deleteBtn = document.getElementById('deleteProfileBtn'); - if (profileSelect) { - profileSelect.value = profileId || ''; - } - if (!profileId) { - ProfileManager.clearLegacyCommands(); - ConnectionCommandManager.clear(); - window.setConnectionAdvancedExpanded(false); - deleteBtn.style.display = 'none'; - delete deleteBtn.dataset.profileId; + window.clearConnectionProfileState(); return null; } const profile = ProfileManager.getProfile(profileId); if (!profile) { - if (profileSelect) { - profileSelect.value = ''; - } - deleteBtn.style.display = 'none'; - delete deleteBtn.dataset.profileId; + window.clearConnectionProfileState(); return null; } ProfileManager.selectProfile(profileId); - deleteBtn.style.display = 'block'; - deleteBtn.dataset.profileId = profileId; + const requiredJumpHostId = profile.jump_host_id || ''; + selectedConnectionProfileState = { + profileId, + missingJumpHost: Boolean( + requiredJumpHostId + && !window.JumpHostManager?.getById(requiredJumpHostId) + ), + }; + const profileContext = document.getElementById('connectionProfileContext'); + profileContext?.classList.remove('hidden'); + const profileContextName = document.getElementById('connectionProfileContextName'); + if (profileContextName) profileContextName.textContent = profile.name || profile.host; + refreshConnectionProfileJumpResolution(); return profile; } @@ -2085,6 +2064,18 @@ return; } + if (refreshConnectionProfileJumpResolution()) { + showNotification( + window.i18n?.t( + 'connection.resolveJumpHost', + 'Choose a jump host or confirm a direct connection.', + ) || 'Choose a jump host or confirm a direct connection.', + 'error', + ); + document.getElementById('connectionProfileDirectConfirm')?.focus(); + return; + } + // Optional jump host (bastion) — chosen from the saved list const jumpHostId = document.getElementById('jumpHostSelect').value; let proxyJump = null; @@ -2164,18 +2155,6 @@ document.getElementById('jumpHostPasswordInput').value = ''; }); - document.getElementById('profileSelect').addEventListener('change', (e) => { - selectConnectionProfile(e.target.value); - }); - - document.getElementById('deleteProfileBtn').addEventListener('click', (e) => { - const profileId = e.target.dataset.profileId; - if (profileId) { - ProfileManager.deleteProfile(profileId); - window.clearConnectionProfileState(); - } - }); - document.getElementById('authTypeSelect').addEventListener('change', (e) => { ProfileManager.handleAuthTypeChange(e.target.value); }); @@ -2184,6 +2163,7 @@ if (window.JumpHostManager) { window.JumpHostManager.updatePasswordVisibility(); } + refreshConnectionProfileJumpResolution(); }); document.querySelectorAll('[data-connection-asset]').forEach(button => { diff --git a/static/js/connection-history.js b/static/js/connection-history.js new file mode 100644 index 0000000..2340ebb --- /dev/null +++ b/static/js/connection-history.js @@ -0,0 +1,98 @@ +(function (root, factory) { + const api = factory(); + if (typeof module === 'object' && module.exports) module.exports = api; + if (root?.document) root.ConnectionHistoryFactory = api; +}(typeof globalThis !== 'undefined' ? globalThis : this, function () { + 'use strict'; + + const DEFAULT_MAX_ITEMS = 10; + const DEFAULT_MAX_AGE = 30 * 24 * 60 * 60 * 1000; + const LEGACY_STORAGE_KEY = 'recentConnections'; + + function normalizeEntry(entry) { + if (!entry || typeof entry !== 'object') return null; + + const host = typeof entry.host === 'string' ? entry.host.trim() : ''; + const username = typeof entry.username === 'string' ? entry.username.trim() : ''; + const port = Number(entry.port); + const timestamp = Number(entry.timestamp); + if (!host || !username || !Number.isInteger(port) || port < 1 || port > 65535) { + return null; + } + if (!Number.isFinite(timestamp) || timestamp <= 0) return null; + return { host, port, username, timestamp }; + } + + function createConnectionHistory(options = {}) { + const storage = options.storage; + const scope = String(options.scope || '').trim(); + const now = typeof options.now === 'function' ? options.now : Date.now; + const maxItems = Number.isInteger(options.maxItems) && options.maxItems > 0 + ? options.maxItems + : DEFAULT_MAX_ITEMS; + const maxAge = Number.isFinite(options.maxAge) && options.maxAge > 0 + ? options.maxAge + : DEFAULT_MAX_AGE; + const storageKey = scope ? `${LEGACY_STORAGE_KEY}:${scope}` : null; + + try { + storage?.removeItem(LEGACY_STORAGE_KEY); + } catch { + // History is optional. Restricted browser storage must not block SSH. + } + + function persist(history) { + if (!storageKey || !storage) return; + try { + storage.setItem(storageKey, JSON.stringify(history)); + } catch { + // History is convenience data; connecting must remain available. + } + } + + function getHistory() { + if (!storageKey || !storage) return []; + + try { + const raw = storage.getItem(storageKey); + const parsed = raw ? JSON.parse(raw) : []; + if (!Array.isArray(parsed)) { + persist([]); + return []; + } + + const currentTime = now(); + const history = parsed + .map(normalizeEntry) + .filter(entry => entry && currentTime - entry.timestamp < maxAge) + .slice(0, maxItems); + if (JSON.stringify(history) !== JSON.stringify(parsed)) persist(history); + return history; + } catch { + return []; + } + } + + function addConnection(host, port, username) { + if (!storageKey) return; + + const entry = normalizeEntry({ host, port, username, timestamp: now() }); + if (!entry) return; + const history = getHistory().filter(item => !( + item.host === entry.host + && item.port === entry.port + && item.username === entry.username + )); + history.unshift(entry); + persist(history.slice(0, maxItems)); + } + + return { + addConnection, + getHistory, + storageKey, + }; + } + + return { createConnectionHistory }; +})); diff --git a/static/js/i18n.js b/static/js/i18n.js index be9c559..2a88c75 100644 --- a/static/js/i18n.js +++ b/static/js/i18n.js @@ -34,6 +34,15 @@ const translations = { 'terminal.mobileInputPlaceholder': 'Type or paste here...', 'connection.recentConnections': 'Recent Connections', + 'connection.recentConnectionsHint': 'Stored only in this browser for your account.', + 'connection.noRecentConnections': 'Your recent connections will appear here.', + 'connection.details': 'Connection details', + 'connection.detailsHint': 'Enter the destination and choose how to authenticate.', + 'connection.savedContext': 'Saved connection', + 'connection.reviewBeforeConnect': 'Review the settings and provide any required credentials.', + 'connection.jumpHostUnavailable': 'The saved jump host is unavailable. Choose a replacement below or confirm a direct connection.', + 'connection.connectDirectlyInstead': 'Connect directly instead', + 'connection.resolveJumpHost': 'Choose a jump host or confirm a direct connection.', 'connection.newConnection': 'Quick Connect', 'connection.newSSHConnection': 'Quick Connect', 'connection.noActiveSessions': 'No Active Sessions', @@ -59,6 +68,7 @@ const translations = { 'connection.jumpHostPassword': 'Jump Host Password', 'connection.jumpHostPasswordHint': 'This bastion uses password auth — enter its password (never stored).', 'connection.advancedSettings': 'Advanced settings', + 'connection.advancedSettingsHint': 'Jump host, post-connect actions, and persistence.', 'connection.commandSet': 'Commands after connecting (optional)', 'connection.commandSetHint': 'Runs on the remote host after a successful connection, not in WebSSH. Not run again when reconnecting to an existing tmux session.', 'commandSets.manage': 'Command Sets', @@ -659,6 +669,15 @@ const translations = { 'terminal.mobileInputPlaceholder': 'Nhập hoặc dán vào đây...', 'connection.recentConnections': 'Kết nối gần đây', + 'connection.recentConnectionsHint': 'Chỉ được lưu trong trình duyệt này cho tài khoản của bạn.', + 'connection.noRecentConnections': 'Các kết nối gần đây của bạn sẽ xuất hiện tại đây.', + 'connection.details': 'Chi tiết kết nối', + 'connection.detailsHint': 'Nhập đích đến và chọn cách xác thực.', + 'connection.savedContext': 'Kết nối đã lưu', + 'connection.reviewBeforeConnect': 'Kiểm tra cài đặt và nhập thông tin xác thực bắt buộc.', + 'connection.jumpHostUnavailable': 'Máy chủ trung chuyển đã lưu không khả dụng. Chọn máy chủ thay thế bên dưới hoặc xác nhận kết nối trực tiếp.', + 'connection.connectDirectlyInstead': 'Thay vào đó, kết nối trực tiếp', + 'connection.resolveJumpHost': 'Chọn máy chủ trung chuyển hoặc xác nhận kết nối trực tiếp.', 'connection.newConnection': 'Kết nối nhanh', 'connection.newSSHConnection': 'Kết nối nhanh', 'connection.noActiveSessions': 'Không có phiên hoạt động', @@ -684,6 +703,7 @@ const translations = { 'connection.jumpHostPassword': 'Mật khẩu máy chủ trung chuyển', 'connection.jumpHostPasswordHint': 'Bastion này sử dụng xác thực bằng mật khẩu — nhập mật khẩu (không được lưu lại).', 'connection.advancedSettings': 'Cài đặt nâng cao', + 'connection.advancedSettingsHint': 'Máy chủ trung chuyển, tác vụ sau kết nối và phiên liên tục.', 'connection.commandSet': 'Lệnh sau khi kết nối (tùy chọn)', 'connection.commandSetHint': 'Chạy trên máy chủ từ xa sau khi kết nối thành công, không chạy trong WebSSH. Không chạy lại khi kết nối lại với một phiên tmux hiện có.', 'commandSets.manage': 'Bộ lệnh', @@ -1283,6 +1303,15 @@ const translations = { 'terminal.mobileInputPlaceholder': 'Hier tippen oder einfügen...', 'connection.recentConnections': 'Letzte Verbindungen', + 'connection.recentConnectionsHint': 'Wird nur in diesem Browser für deinen Account gespeichert.', + 'connection.noRecentConnections': 'Deine letzten Verbindungen erscheinen hier.', + 'connection.details': 'Verbindungsdetails', + 'connection.detailsHint': 'Gib das Ziel ein und wähle die Authentifizierung.', + 'connection.savedContext': 'Gespeicherte Verbindung', + 'connection.reviewBeforeConnect': 'Prüfe die Einstellungen und ergänze erforderliche Zugangsdaten.', + 'connection.jumpHostUnavailable': 'Der gespeicherte Jump Host ist nicht verfügbar. Wähle unten einen Ersatz oder bestätige eine direkte Verbindung.', + 'connection.connectDirectlyInstead': 'Stattdessen direkt verbinden', + 'connection.resolveJumpHost': 'Wähle einen Jump Host oder bestätige eine direkte Verbindung.', 'connection.newConnection': 'Schnellverbindung', 'connection.newSSHConnection': 'Schnellverbindung', 'connection.noActiveSessions': 'Keine aktiven Sitzungen', @@ -1308,6 +1337,7 @@ const translations = { 'connection.jumpHostPassword': 'Jump-Host-Passwort', 'connection.jumpHostPasswordHint': 'Diese Bastion nutzt Passwort-Auth — Passwort eingeben (wird nicht gespeichert).', 'connection.advancedSettings': 'Erweiterte Einstellungen', + 'connection.advancedSettingsHint': 'Jump Host, Aktionen nach dem Verbinden und persistente Sitzung.', 'connection.commandSet': 'Befehle nach dem Verbinden (optional)', 'connection.commandSetHint': 'Wird nach erfolgreicher Verbindung auf dem Remote-Host ausgeführt, nicht in WebSSH. Bei der Wiederverbindung mit einer bestehenden tmux-Sitzung nicht erneut ausgeführt.', 'commandSets.manage': 'Befehlssätze', @@ -1906,6 +1936,15 @@ const translations = { 'terminal.mobileInputPlaceholder': 'Tapez ou collez ici...', 'connection.recentConnections': 'Connexions récentes', + 'connection.recentConnectionsHint': 'Stockées uniquement dans ce navigateur pour votre compte.', + 'connection.noRecentConnections': 'Vos connexions récentes apparaîtront ici.', + 'connection.details': 'Détails de connexion', + 'connection.detailsHint': 'Saisissez la destination et choisissez le mode d’authentification.', + 'connection.savedContext': 'Connexion enregistrée', + 'connection.reviewBeforeConnect': 'Vérifiez les paramètres et fournissez les identifiants requis.', + 'connection.jumpHostUnavailable': 'L’hôte de rebond enregistré est indisponible. Choisissez un remplacement ci-dessous ou confirmez une connexion directe.', + 'connection.connectDirectlyInstead': 'Se connecter directement à la place', + 'connection.resolveJumpHost': 'Choisissez un hôte de rebond ou confirmez une connexion directe.', 'connection.newConnection': 'Connexion rapide', 'connection.newSSHConnection': 'Connexion rapide', 'connection.noActiveSessions': 'Aucune session active', @@ -1931,6 +1970,7 @@ const translations = { 'connection.jumpHostPassword': 'Mot de passe du rebond', 'connection.jumpHostPasswordHint': "Ce bastion utilise un mot de passe — saisissez-le (jamais enregistré).", 'connection.advancedSettings': 'Paramètres avancés', + 'connection.advancedSettingsHint': 'Hôte de rebond, actions après connexion et session persistante.', 'connection.commandSet': 'Commandes après la connexion (facultatif)', 'connection.commandSetHint': "Exécutées sur l'hôte distant après une connexion réussie, et non dans WebSSH. Elles ne sont pas réexécutées lors de la reconnexion à une session tmux existante.", 'commandSets.manage': 'Ensembles de commandes', @@ -2529,6 +2569,15 @@ const translations = { 'terminal.mobileInputPlaceholder': 'Escribe o pega aquí...', 'connection.recentConnections': 'Conexiones recientes', + 'connection.recentConnectionsHint': 'Se guardan solo en este navegador para tu cuenta.', + 'connection.noRecentConnections': 'Tus conexiones recientes aparecerán aquí.', + 'connection.details': 'Detalles de conexión', + 'connection.detailsHint': 'Introduce el destino y elige cómo autenticarte.', + 'connection.savedContext': 'Conexión guardada', + 'connection.reviewBeforeConnect': 'Revisa la configuración e introduce las credenciales necesarias.', + 'connection.jumpHostUnavailable': 'El host de salto guardado no está disponible. Elige otro abajo o confirma una conexión directa.', + 'connection.connectDirectlyInstead': 'Conectar directamente en su lugar', + 'connection.resolveJumpHost': 'Elige un host de salto o confirma una conexión directa.', 'connection.newConnection': 'Conexión rápida', 'connection.newSSHConnection': 'Conexión rápida', 'connection.noActiveSessions': 'Sin sesiones activas', @@ -2554,6 +2603,7 @@ const translations = { 'connection.jumpHostPassword': 'Contraseña del host de salto', 'connection.jumpHostPasswordHint': 'Este bastión usa contraseña — introdúcela (nunca se guarda).', 'connection.advancedSettings': 'Configuración avanzada', + 'connection.advancedSettingsHint': 'Host de salto, acciones posteriores y sesión persistente.', 'connection.commandSet': 'Comandos después de conectar (opcional)', 'connection.commandSetHint': 'Se ejecutan en el host remoto después de una conexión correcta, no en WebSSH. No se vuelven a ejecutar al reconectar con una sesión tmux existente.', 'commandSets.manage': 'Conjuntos de comandos', @@ -3152,6 +3202,15 @@ const translations = { 'terminal.mobileInputPlaceholder': '在这里输入或粘贴...', 'connection.recentConnections': '最近连接', + 'connection.recentConnectionsHint': '仅在此浏览器中为你的账户保存。', + 'connection.noRecentConnections': '你的最近连接将显示在这里。', + 'connection.details': '连接详情', + 'connection.detailsHint': '输入目标并选择身份验证方式。', + 'connection.savedContext': '已保存的连接', + 'connection.reviewBeforeConnect': '检查设置并提供所需的凭据。', + 'connection.jumpHostUnavailable': '已保存的跳板机不可用。请在下方选择替代项或确认直接连接。', + 'connection.connectDirectlyInstead': '改为直接连接', + 'connection.resolveJumpHost': '请选择跳板机或确认直接连接。', 'connection.newConnection': '快速连接', 'connection.newSSHConnection': '快速连接', 'connection.noActiveSessions': '当前没有活动会话', @@ -3177,6 +3236,7 @@ const translations = { 'connection.jumpHostPassword': '跳板机密码', 'connection.jumpHostPasswordHint': '该堡垒机使用密码认证 — 请输入密码(不会保存)。', 'connection.advancedSettings': '高级设置', + 'connection.advancedSettingsHint': '跳板机、连接后操作和持久会话。', 'connection.commandSet': '连接后运行的命令(可选)', 'connection.commandSetHint': '连接成功后在远程主机上运行,而不是在 WebSSH 中运行。重新连接到现有 tmux 会话时不会再次运行。', 'commandSets.manage': '命令集', diff --git a/templates/index.html b/templates/index.html index 13682b6..b1163c3 100644 --- a/templates/index.html +++ b/templates/index.html @@ -17,11 +17,11 @@ - + - +
@@ -412,22 +412,32 @@

Quick Conn ×

@@ -1184,7 +1220,7 @@

File Preview

- + @@ -1214,7 +1250,8 @@

File Preview

- + + - +