diff --git a/app/audit_logger.py b/app/audit_logger.py index 4d9b41f..f66cae2 100644 --- a/app/audit_logger.py +++ b/app/audit_logger.py @@ -339,6 +339,16 @@ def log_key_upload(username, key_name, success, ip_address): f"key={_sanitize_log_value(key_name)} | ip={_sanitize_log_value(ip_address)}" ) + +def log_key_rename(username, old_name, new_name, ip_address): + audit_logger.info( + f"KEY_RENAME | user={_sanitize_log_value(username)} | " + f"old={_sanitize_log_value(old_name)} | " + f"new={_sanitize_log_value(new_name)} | " + f"ip={_sanitize_log_value(ip_address)}" + ) + + def log_key_delete(username, key_name, ip_address): audit_logger.info( f"KEY_DELETE | user={_sanitize_log_value(username)} | " diff --git a/app/key_manager.py b/app/key_manager.py index f44aa5a..3f8228c 100644 --- a/app/key_manager.py +++ b/app/key_manager.py @@ -291,6 +291,31 @@ def save_keys(user_id, keys): return False +def rename_key(user_id, key_id, new_name): + """Rename one owned key without changing its identity or encrypted data.""" + if not isinstance(new_name, str) or not new_name.strip(): + return None, "Invalid key name" + name = new_name.strip() + if len(name) > 128: + return None, "Key name too long (max 128 characters)" + if not isinstance(key_id, str) or not key_id: + return None, "Key not found" + + with storage_lock(f'keys:{user_id}'): + keys = _load_keys_with_lock_held(user_id) + for index, key in enumerate(keys): + if key['id'] != key_id: + continue + before = dict(key) + updated = {**key, 'name': name} + replacement = [*keys] + replacement[index] = updated + if not save_keys(user_id, replacement): + return None, "Failed to rename key" + return {'before': before, 'key': updated}, None + return None, "Key not found" + + def _remove_key_after_metadata_failure(user_id, key_id, key_path): """Best-effort rollback when the encrypted key has no metadata entry.""" try: diff --git a/app/socket_events.py b/app/socket_events.py index 7409272..319eb6e 100644 --- a/app/socket_events.py +++ b/app/socket_events.py @@ -9,7 +9,7 @@ from .audit_logger import (log_info, log_warning, log_error, log_debug, log_ssh_connection, log_ssh_disconnect, log_file_upload, log_file_download, - log_key_upload, log_key_delete, + log_key_upload, log_key_rename, log_key_delete, log_tailscale_ssh_usage) from .tailscale_ssh import ( profile_is_authorized_for_launch, @@ -87,6 +87,12 @@ def _emit_storage_error(error, current_user): return payload +def _key_mutation_error(message): + payload = {'success': False, 'error': message} + emit('error', {'error': message}) + return payload + + def _is_valid_host(host_str): """Validate host is a valid hostname or IP address.""" try: @@ -807,35 +813,68 @@ def handle_list_keys(current_user=None): def handle_upload_key(data, current_user=None): """Store a new SSH private key for this user.""" try: + data = data if isinstance(data, dict) else {} name = data.get('name') key_content = data.get('key_content') - if not all([name, key_content]): - emit('error', {'error': 'Name and key content required'}) - return + if (not isinstance(name, str) or not name + or not isinstance(key_content, str) or not key_content): + return _key_mutation_error('Name and key content required') # SSH private keys are a few KB at most; reject oversized input outright # so a client cannot force large writes to disk. if len(name) > 128: - emit('error', {'error': 'Key name too long (max 128 characters)'}) - return + return _key_mutation_error( + 'Key name too long (max 128 characters)' + ) if len(key_content) > 64 * 1024: - emit('error', {'error': 'Key content too large (max 64KB)'}) - return + return _key_mutation_error( + 'Key content too large (max 64KB)' + ) key_meta, error = key_manager.save_key(current_user.id, name, key_content) if error: log_key_upload(current_user.username, name, False, request.remote_addr) - emit('error', {'error': error}) - else: - log_key_upload(current_user.username, name, True, request.remote_addr) - emit('key_uploaded', {'key': key_meta}) - handle_list_keys(current_user=current_user) + return _key_mutation_error(error) + log_key_upload(current_user.username, name, True, request.remote_addr) + emit('key_uploaded', {'key': key_meta}) + handle_list_keys(current_user=current_user) + return {'success': True, 'key': key_meta} + + except StorageCorruptionError as error: + return _emit_storage_error(error, current_user) + except Exception: + return _key_mutation_error('Failed to upload key') + +@socketio.on('rename_key') +@socket_login_required +def handle_rename_key(data, current_user=None): + """Rename one owned SSH key without exposing its encrypted contents.""" + try: + data = data if isinstance(data, dict) else {} + result, error = key_manager.rename_key( + current_user.id, + data.get('key_id'), + data.get('name'), + ) + if error: + return _key_mutation_error(error) + + log_key_rename( + current_user.username, + result['before']['name'], + result['key']['name'], + request.remote_addr, + ) + payload = {'success': True, 'key': result['key']} + emit('key_renamed', payload) + handle_list_keys(current_user=current_user) + return payload except StorageCorruptionError as error: return _emit_storage_error(error, current_user) except Exception: - emit('error', {'error': 'Failed to upload key'}) + return _key_mutation_error('Failed to rename key') @socketio.on('delete_key') @socket_login_required diff --git a/static/css/style.css b/static/css/style.css index 8c33a9d..25a1632 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -2413,6 +2413,7 @@ textarea.form-control { display: flex; flex-direction: column; gap: 6px; + min-width: 0; } .key-info strong { @@ -2436,6 +2437,82 @@ textarea.form-control { font-weight: 600; } +.profile-inline-key { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 10px; + min-width: 0; + margin-top: 10px; +} + +.profile-inline-key-panel { + width: 100%; + padding: 14px; + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + border-radius: 8px; +} + +.profile-inline-key-panel textarea { + width: 100%; + resize: vertical; +} + +.profile-inline-key-actions, +.key-item-actions, +.key-rename-editor { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + min-width: 0; +} + +.profile-inline-key-actions { + justify-content: flex-end; +} + +.key-rename-editor .form-control { + min-width: 0; +} + +.profile-inline-key-status { + min-height: 1.25em; + color: var(--text-secondary); + overflow-wrap: anywhere; +} + +.profile-inline-key-status.error { + color: var(--error-color, #e57373); +} + +@media (max-width: 375px) { + .key-item { + align-items: stretch; + flex-direction: column; + gap: 12px; + transform: none; + } + + .key-item:hover { + transform: none; + } + + .key-item-actions, + .profile-inline-key-actions { + align-items: stretch; + flex-direction: column; + } + + .key-item-actions .btn, + .profile-inline-key-actions .btn, + .profile-inline-key > .btn { + width: 100%; + min-height: 44px; + } +} + .no-items { text-align: center; color: var(--text-secondary); diff --git a/static/js/app.js b/static/js/app.js index a4c9ebe..38dfc50 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -982,11 +982,11 @@ }); socket.on('profile_saved', (data) => { - showNotification('Profile saved successfully', 'success'); + showNotification('Saved connection updated successfully', 'success'); }); socket.on('profile_deleted', (data) => { - showNotification('Profile deleted successfully', 'success'); + showNotification('Saved connection deleted successfully', 'success'); }); socket.on('keys_list', (data) => { @@ -994,8 +994,13 @@ }); socket.on('key_uploaded', (data) => { + ProfileManager.upsertKeySummary(data.key); showNotification('SSH key uploaded successfully', 'success'); - document.getElementById('keyUploadForm').reset(); + document.getElementById('keyUploadForm')?.reset(); + }); + + socket.on('key_renamed', (data) => { + ProfileManager.upsertKeySummary(data.key); }); socket.on('key_deleted', (data) => { @@ -1036,6 +1041,50 @@ let connectTimer = null; let connectSeconds = 0; + function closeProfileManagementModal() { + window.ModalManager?.close( + document.getElementById('profileManagementModal'), + ); + } + + function startConnection(connectionData, paneIndex) { + if (currentConnectRequestId) return false; + + closeProfileManagementModal(); + const requestId = ( + `req_${Date.now().toString(36)}_` + + Math.random().toString(36).slice(2, 6) + ); + const payload = { + ...connectionData, + client_request_id: requestId, + }; + currentConnectRequestId = requestId; + pendingPaneIndex = null; + SessionManager.createPendingConnection( + requestId, + payload.host, + payload.username, + payload.port, + ); + if (paneIndex !== null && paneIndex !== undefined) { + pendingRequestPaneMap.set(requestId, paneIndex); + } + + Object.keys(SessionManager.sessions).forEach(sessionId => { + const session = SessionManager.sessions[sessionId]; + if (session?.isPersistentCandidate + && session.host === payload.host + && session.port === Number(payload.port) + && session.username === payload.username) { + SessionManager.removeSessionUI(sessionId); + } + }); + + socket.emit('ssh_connect', payload); + return true; + } + function openConnectionModalForPane(paneIndex) { window.clearConnectionProfileState(); pendingPaneIndex = paneIndex; @@ -1098,47 +1147,11 @@ window.selectConnectionProfile = selectConnectionProfile; - function isSelectedProfileReady(profile) { - const authTypeSelect = document.getElementById('authTypeSelect'); - const keySelect = document.getElementById('keySelect'); - const jumpHostSelect = document.getElementById('jumpHostSelect'); - if (!profile || authTypeSelect.value !== profile.auth_type) { - return false; - } - if (profile.auth_type === 'key' && keySelect.value !== profile.key_id) { - return false; - } - if (jumpHostSelect.value !== (profile.jump_host_id || '')) { - return false; - } - return true; - } - - function launchProfileForPane(profileId, paneIndex) { - const profile = ProfileManager.getProfile(profileId); - if (!profile) { - showNotification( - window.i18n - ? i18n.t('connection.profileUnavailable') - : 'This profile is no longer available.', - 'warning', - ); - ProfileManager.loadProfiles(); - return; - } - + function openProfileForReview(profileId, paneIndex, mode) { + closeProfileManagementModal(); openConnectionModalForPane(paneIndex); const selected = selectConnectionProfile(profileId); - if (!selected) { - return; - } - - const mode = ProfileManager.getLaunchMode(selected); - const form = document.getElementById('connectionForm'); - if (mode === 'connect' && isSelectedProfileReady(selected)) { - form.requestSubmit(); - return; - } + if (!selected) return; let focusTarget = document.getElementById('connectBtn'); if (mode === 'password') { @@ -1149,13 +1162,6 @@ window.requestAnimationFrame(() => focusTarget?.focus()); } - window.launchProfileForPane = launchProfileForPane; - - window.openConnectionModalForProfile = (profileId) => { - openConnectionModalForPane(getDefaultPaneIndex()); - selectConnectionProfile(profileId); - }; - function queuePaneConnection(paneIndex) { if (paneIndex === null || paneIndex === undefined) { return; @@ -1196,6 +1202,31 @@ return activeIndex !== null && activeIndex !== undefined ? activeIndex : 0; } + const savedConnectionLauncher = ( + ConnectionLauncher.createConnectionLauncher({ + getProfile: profileId => ProfileManager.getProfile(profileId), + getContext: () => ({ + keys: ProfileManager.keys, + jumpHosts: window.JumpHostManager?.jumpHosts || [], + }), + getDefaultPaneIndex, + isBusy: () => Boolean(currentConnectRequestId), + startConnection, + openReview: openProfileForReview, + notify: (key, fallback, type) => { + const translated = window.i18n ? i18n.t(key) : key; + showNotification( + translated && translated !== key ? translated : fallback, + type, + ); + }, + refreshProfiles: () => ProfileManager.loadProfiles(), + }) + ); + window.launchProfileForPane = (profileId, paneIndex = null) => ( + savedConnectionLauncher.launch(profileId, paneIndex) + ); + window.openConnectionModalForPane = openConnectionModalForPane; function setConnectLoading(isLoading) { @@ -1979,18 +2010,10 @@ } } - pendingPaneIndex = null; - currentConnectRequestId = `req_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`; - SessionManager.createPendingConnection(currentConnectRequestId, host, username, port); - if (targetPane !== null && targetPane !== undefined) { - pendingRequestPaneMap.set(currentConnectRequestId, targetPane); - } - const connectionData = { host: host, port: parseInt(port), username: username, - client_request_id: currentConnectRequestId, auth_type: authType }; Object.assign(connectionData, ConnectionCommandManager.getPayload()); @@ -2011,17 +2034,19 @@ // Include tmux session name for reconnection to persistent sessions if (SessionManager.pendingReconnectTmux) { connectionData.reconnect_tmux_name = SessionManager.pendingReconnectTmux; - SessionManager.pendingReconnectTmux = null; } // Include display name for reconnecting persistent sessions if (SessionManager.pendingDisplayName) { connectionData.display_name = SessionManager.pendingDisplayName; - SessionManager.pendingDisplayName = null; } } + const started = startConnection(connectionData, targetPane); + if (!started) return; + + SessionManager.pendingReconnectTmux = null; + SessionManager.pendingDisplayName = null; const connectBtn = document.getElementById('connectBtn'); - const originalText = connectBtn.textContent; connectSeconds = 0; connectBtn.textContent = 'Connecting... 0s'; connectTimer = setInterval(() => { @@ -2029,17 +2054,6 @@ connectBtn.textContent = `Connecting... ${connectSeconds}s`; }, 1000); - // Clean up any persistent candidate tab for the same host/port/user - // Use removeSessionUI to avoid emitting ssh_disconnect which would - // delete the DB record and lose the session display name. - Object.keys(SessionManager.sessions).forEach(sid => { - const s = SessionManager.sessions[sid]; - if (s && s.isPersistentCandidate && s.host === host && s.port === parseInt(port) && s.username === username) { - SessionManager.removeSessionUI(sid); - } - }); - - socket.emit('ssh_connect', connectionData); setConnectLoading(true); document.getElementById('passwordInput').value = ''; diff --git a/static/js/connection-launcher.js b/static/js/connection-launcher.js new file mode 100644 index 0000000..6c57c26 --- /dev/null +++ b/static/js/connection-launcher.js @@ -0,0 +1,83 @@ +(function (root, factory) { + const utils = typeof module === 'object' && module.exports + ? require('./profile-launcher-utils.js') + : root.ProfileLauncherUtils; + const api = factory(utils); + if (typeof module === 'object' && module.exports) module.exports = api; + if (root?.document) root.ConnectionLauncher = api; +}(typeof globalThis !== 'undefined' ? globalThis : this, function ( + ProfileLauncherUtils, +) { + 'use strict'; + + function createConnectionLauncher(deps) { + const required = [ + 'getProfile', + 'getContext', + 'getDefaultPaneIndex', + 'isBusy', + 'startConnection', + 'openReview', + 'notify', + 'refreshProfiles', + ]; + required.forEach(name => { + if (typeof deps?.[name] !== 'function') { + throw new TypeError( + `ConnectionLauncher requires ${name}()`, + ); + } + }); + + return { + launch(profileId, paneIndex = null) { + if (deps.isBusy()) { + deps.notify( + 'connection.connectBusy', + 'A connection attempt is already in progress.', + 'info', + ); + return 'rejected'; + } + + const profile = deps.getProfile(profileId); + if (!profile) { + deps.notify( + 'connection.profileUnavailable', + 'This saved connection is no longer available.', + 'warning', + ); + deps.refreshProfiles(); + return 'rejected'; + } + + const context = deps.getContext(); + const mode = ProfileLauncherUtils.determineLaunchMode( + profile, + context, + ); + const targetPane = paneIndex ?? deps.getDefaultPaneIndex(); + if (mode === 'connect') { + const connectionData = ( + ProfileLauncherUtils.buildDirectConnectionData( + profile, + context, + ) + ); + if (!connectionData) { + deps.openReview(profileId, targetPane, 'review'); + return 'review'; + } + return deps.startConnection(connectionData, targetPane) + ? 'connect' + : 'rejected'; + } + + deps.openReview(profileId, targetPane, mode); + return 'review'; + }, + }; + } + + return { createConnectionLauncher }; +})); diff --git a/static/js/i18n.js b/static/js/i18n.js index 29c59d5..835bace 100644 --- a/static/js/i18n.js +++ b/static/js/i18n.js @@ -34,25 +34,25 @@ const translations = { 'terminal.mobileInputPlaceholder': 'Type or paste here...', 'connection.recentConnections': 'Recent Connections', - 'connection.newConnection': 'New Connection', - 'connection.newSSHConnection': 'New SSH Connection', + 'connection.newConnection': 'Quick Connect', + 'connection.newSSHConnection': 'Quick Connect', 'connection.noActiveSessions': 'No Active Sessions', - 'connection.clickToStart': 'Click "New Connection" to start an SSH session', - 'connection.savedProfiles': 'Saved Profiles', - 'connection.savedProfilesHint': 'Choose a profile to connect', + 'connection.clickToStart': 'Click "Quick Connect" to start an SSH session', + 'connection.savedProfiles': 'Saved Connections', + 'connection.savedProfilesHint': 'Choose a saved connection to connect', 'connection.connectNow': 'Connect now', 'connection.passwordRequired': 'Password required', 'connection.reviewConnection': 'Review connection', - 'connection.profileUnavailable': 'This profile is no longer available.', - 'connection.loadProfile': 'Load Profile (Optional)', - 'connection.selectProfile': '-- Select Profile --', + 'connection.profileUnavailable': 'This saved connection is no longer available.', + 'connection.loadProfile': 'Load Saved Connection (Optional)', + 'connection.selectProfile': '-- Select Saved Connection --', 'connection.host': 'Host *', 'connection.port': 'Port *', 'connection.authMethod': 'Authentication Method', 'connection.sshKey': 'SSH Key *', 'connection.tailscaleSSH': 'Tailscale SSH', 'connection.selectSSHKey': '-- Select SSH Key --', - 'connection.saveAsProfile': 'Save as profile', + 'connection.saveAsProfile': 'Save as saved connection', 'connection.jumpHost': 'Jump Host', 'connection.noJumpHost': 'None (direct connection)', 'connection.jumpHostHint': 'Manage jump hosts in the account menu.', @@ -61,7 +61,7 @@ const translations = { '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', - 'commandSets.manageHint': 'Build reusable command sequences and assign one to any connection profile.', + 'commandSets.manageHint': 'Build reusable command sequences and assign one to any saved connection.', 'connection.runAfterConnect': 'Run after connecting', 'connection.runAfterConnectTooltip': 'Choose exactly what WebSSH sends to the remote terminal after a new SSH connection is ready.', 'commandModes.none': 'Nothing', @@ -69,17 +69,17 @@ const translations = { 'commandModes.command': 'Command', 'commandModes.freeText': 'Free text', 'commandModes.selectCommand': '-- Select command --', - 'commandModes.parametersTooltip': 'Keep the saved parameters or replace them only for this connection or profile.', + 'commandModes.parametersTooltip': 'Keep the saved parameters or replace them only for this connection.', 'commandModes.exactPreview': 'Exact command preview', 'commandModes.emptyFreeText': 'Enter at least one command.', 'commandModes.missingCommand': 'Select a command first.', 'commandModes.useSavedParameters': 'Use saved parameters', - 'profiles.manage': 'Profiles', - 'profiles.manageHint': 'Create, inspect, update, and connect saved profiles independently.', - 'profiles.create': 'Create profile', - 'profiles.none': 'No profiles saved.', - 'profiles.saveFailed': 'Could not save the profile.', - 'profiles.saved': 'Profile saved.', + 'profiles.manage': 'Saved Connections', + 'profiles.manageHint': 'Create, review, update, and launch saved connections.', + 'profiles.create': 'Create saved connection', + 'profiles.none': 'No saved connections.', + 'profiles.saveFailed': 'Could not save the connection.', + 'profiles.saved': 'Connection saved.', 'commandSets.none': 'None', 'commandSets.create': 'Create command set', 'commandSets.createNew': 'Create new', @@ -102,7 +102,7 @@ const translations = { 'commandSets.missingCommand': 'Missing library command', 'commandSets.missingSet': 'Missing command set', 'commandSets.missingSetHint': 'This command set is unavailable. The connection will be blocked until you select another one.', - 'commandSets.legacyNotice': 'This profile still uses legacy free-text commands.', + 'commandSets.legacyNotice': 'This saved connection still uses legacy free-text commands.', 'commandSets.convert': 'Convert', 'commandSets.saved': 'Command set saved', 'commandSets.confirmDelete': 'Delete this command set?', @@ -117,9 +117,10 @@ const translations = { 'jumphosts.confirmDelete': 'Delete this jump host?', 'jumphosts.deleted': 'Jump host deleted', 'jumphosts.noPasswordHint': 'Passwords are never stored — you enter them when connecting.', - 'connection.profileName': 'Profile Name', + 'connection.profileName': 'Connection Name', 'connection.profileNamePlaceholder': 'My Server', 'connection.connect': 'Connect', + 'connection.connectBusy': 'A connection attempt is already in progress.', 'connection.reconnected': 'Reconnected!', 'connection.lostReconnecting': 'Connection lost. Reconnecting...', 'connection.lostReconnectingAttempt': 'Connection lost. Reconnecting... (attempt {attempt})', @@ -133,6 +134,12 @@ const translations = { 'keys.privateKey': 'Private Key', 'keys.uploadKey': 'Upload Key', 'keys.storedKeys': 'Stored Keys', + 'keys.addNew': 'Add new key', + 'keys.add': 'Add key', + 'keys.rename': 'Rename', + 'keys.renameNamed': 'Rename {name}', + 'keys.saveName': 'Save name', + 'keys.renameFailed': 'Failed to rename key', 'files.fileTransfer': 'File Transfer', 'files.fileManager': 'File Manager', @@ -294,7 +301,7 @@ const translations = { 'fm.selectSource': '-- Select Source --', 'fm.yourComputer': 'Your Computer', 'fm.sshSessions': 'SSH Sessions', - 'fm.newConnection': '+ New Connection...', + 'fm.newConnection': '+ Quick Connect...', 'fm.goUp': 'Go up', 'fm.goHome': 'Go to home', 'fm.parentDirectory': 'Parent directory', @@ -328,7 +335,7 @@ const translations = { 'fm.skip': 'Skip', 'fm.applyToAll': 'Apply to all', 'fm.qc.title': 'Connect to Server', - 'fm.qc.savedProfiles': 'Saved Profiles', + 'fm.qc.savedProfiles': 'Saved Connections', 'fm.qc.enterManually': '-- Enter manually --', 'fm.qc.orEnterDetails': 'or enter connection details', 'fm.qc.host': 'Host', @@ -400,7 +407,7 @@ const translations = { 'shortcuts.openCommandLibrary': 'Open Command Library', 'shortcuts.openCommandPalette': 'Open Command Palette', 'shortcuts.showShortcuts': 'Show Shortcuts', - 'shortcuts.newConnection': 'New Connection', + 'shortcuts.newConnection': 'Quick Connect', 'shortcuts.searchTerminal': 'Search in Terminal', 'shortcuts.copyTerminal': 'Copy terminal selection', 'shortcuts.pasteTerminal': 'Paste into terminal', @@ -437,16 +444,16 @@ const translations = { 'common.error': 'Error', 'panes.assignTitle': 'Assign Sessions to Panes', - 'panes.assignInfo': 'Select which sessions to display in each pane. You can assign existing sessions or create new connections.', + 'panes.assignInfo': 'Select which sessions to display in each pane. Assign an existing session or use Quick Connect.', 'panes.pane': 'Pane', 'panes.empty': 'Empty', 'panes.emptyDesc': 'Leave this pane empty', - 'panes.newConnection': '+ New Connection', - 'panes.newConnectionDesc': 'Open connection dialog for this pane', + 'panes.newConnection': '+ Quick Connect', + 'panes.newConnectionDesc': 'Open Quick Connect for this pane', 'panes.connected': 'Connected', 'panes.disconnected': 'Disconnected', 'panes.emptyPane': 'Empty pane', - 'panes.selectSession': 'Select a session or open a connection', + 'panes.selectSession': 'Select a session or use Quick Connect', 'session.closeWarning': 'You have active SSH sessions. They will be closed.', 'session.rename': 'Rename session', @@ -533,25 +540,25 @@ const translations = { 'terminal.mobileInputPlaceholder': 'Nhập hoặc dán vào đây...', 'connection.recentConnections': 'Kết nối gần đây', - 'connection.newConnection': 'Kết nối mới', - 'connection.newSSHConnection': 'Kết nối SSH mới', + 'connection.newConnection': 'Kết nối nhanh', + 'connection.newSSHConnection': 'Kết nối nhanh', 'connection.noActiveSessions': 'Không có phiên hoạt động', - 'connection.clickToStart': 'Nhấp vào "Kết nối mới" để bắt đầu một phiên SSH', - 'connection.savedProfiles': 'Cấu hình đã lưu', - 'connection.savedProfilesHint': 'Chọn một cấu hình để kết nối', + 'connection.clickToStart': 'Nhấp vào "Kết nối nhanh" để bắt đầu một phiên SSH', + 'connection.savedProfiles': 'Kết nối đã lưu', + 'connection.savedProfilesHint': 'Chọn một kết nối đã lưu để kết nối', 'connection.connectNow': 'Kết nối ngay', 'connection.passwordRequired': 'Yêu cầu mật khẩu', 'connection.reviewConnection': 'Kiểm tra kết nối', - 'connection.profileUnavailable': 'Cấu hình này không còn khả dụng.', - 'connection.loadProfile': 'Tải cấu hình (Tùy chọn)', - 'connection.selectProfile': '-- Chọn cấu hình --', + 'connection.profileUnavailable': 'Kết nối đã lưu này không còn khả dụng.', + 'connection.loadProfile': 'Tải kết nối đã lưu (Tùy chọn)', + 'connection.selectProfile': '-- Chọn kết nối đã lưu --', 'connection.host': 'Máy chủ *', 'connection.port': 'Cổng *', 'connection.authMethod': 'Phương thức xác thực', 'connection.sshKey': 'Khóa SSH *', 'connection.tailscaleSSH': 'SSH qua Tailscale', 'connection.selectSSHKey': '-- Chọn khóa SSH --', - 'connection.saveAsProfile': 'Lưu thành cấu hình', + 'connection.saveAsProfile': 'Lưu thành kết nối đã lưu', 'connection.jumpHost': 'Máy chủ trung chuyển', 'connection.noJumpHost': 'Không (kết nối trực tiếp)', 'connection.jumpHostHint': 'Quản lý máy chủ trung chuyển trong menu tài khoản.', @@ -560,7 +567,7 @@ const translations = { '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', - 'commandSets.manageHint': 'Tạo chuỗi lệnh có thể tái sử dụng và gán một bộ cho mỗi hồ sơ kết nối.', + 'commandSets.manageHint': 'Tạo chuỗi lệnh có thể tái sử dụng và gán một bộ cho mỗi kết nối đã lưu.', 'connection.runAfterConnect': 'Chạy sau khi kết nối', 'connection.runAfterConnectTooltip': 'Chọn chính xác nội dung WebSSH gửi đến terminal từ xa sau khi kết nối SSH mới sẵn sàng.', 'commandModes.none': 'Không chạy', @@ -568,17 +575,17 @@ const translations = { 'commandModes.command': 'Lệnh', 'commandModes.freeText': 'Văn bản tự do', 'commandModes.selectCommand': '-- Chọn lệnh --', - 'commandModes.parametersTooltip': 'Giữ tham số đã lưu hoặc chỉ thay thế cho kết nối hay hồ sơ này.', + 'commandModes.parametersTooltip': 'Giữ tham số đã lưu hoặc chỉ thay thế cho kết nối này.', 'commandModes.exactPreview': 'Xem trước lệnh chính xác', 'commandModes.emptyFreeText': 'Nhập ít nhất một lệnh.', 'commandModes.missingCommand': 'Trước tiên hãy chọn một lệnh.', 'commandModes.useSavedParameters': 'Dùng tham số đã lưu', - 'profiles.manage': 'Hồ sơ', - 'profiles.manageHint': 'Tạo, xem, cập nhật và kết nối hồ sơ đã lưu một cách độc lập.', - 'profiles.create': 'Tạo hồ sơ', - 'profiles.none': 'Chưa có hồ sơ nào.', - 'profiles.saveFailed': 'Không thể lưu hồ sơ.', - 'profiles.saved': 'Đã lưu hồ sơ.', + 'profiles.manage': 'Kết nối đã lưu', + 'profiles.manageHint': 'Tạo, xem lại, cập nhật và khởi chạy các kết nối đã lưu.', + 'profiles.create': 'Tạo kết nối đã lưu', + 'profiles.none': 'Chưa có kết nối đã lưu.', + 'profiles.saveFailed': 'Không thể lưu kết nối.', + 'profiles.saved': 'Đã lưu kết nối.', 'commandSets.none': 'Không dùng', 'commandSets.create': 'Tạo bộ lệnh', 'commandSets.createNew': 'Tạo mới', @@ -601,7 +608,7 @@ const translations = { 'commandSets.missingCommand': 'Thiếu lệnh trong thư viện', 'commandSets.missingSet': 'Thiếu bộ lệnh', 'commandSets.missingSetHint': 'Bộ lệnh này không khả dụng. Kết nối sẽ bị chặn cho đến khi bạn chọn bộ khác.', - 'commandSets.legacyNotice': 'Hồ sơ này vẫn dùng lệnh văn bản tự do kiểu cũ.', + 'commandSets.legacyNotice': 'Kết nối đã lưu này vẫn dùng lệnh văn bản tự do kiểu cũ.', 'commandSets.convert': 'Chuyển đổi', 'commandSets.saved': 'Đã lưu bộ lệnh', 'commandSets.confirmDelete': 'Xóa bộ lệnh này?', @@ -616,9 +623,10 @@ const translations = { 'jumphosts.confirmDelete': 'Xóa máy chủ trung chuyển này?', 'jumphosts.deleted': 'Đã xóa máy chủ trung chuyển', 'jumphosts.noPasswordHint': 'Mật khẩu không bao giờ được lưu — bạn sẽ nhập khi kết nối.', - 'connection.profileName': 'Tên cấu hình', + 'connection.profileName': 'Tên kết nối', 'connection.profileNamePlaceholder': 'Máy chủ của tôi', 'connection.connect': 'Kết nối', + 'connection.connectBusy': 'Một lần kết nối đang được thực hiện.', 'connection.reconnected': 'Đã kết nối lại!', 'connection.lostReconnecting': 'Mất kết nối. Đang kết nối lại...', 'connection.lostReconnectingAttempt': 'Mất kết nối. Đang kết nối lại... (lần thử {attempt})', @@ -632,6 +640,12 @@ const translations = { 'keys.privateKey': 'Khóa riêng tư', 'keys.uploadKey': 'Tải lên khóa', 'keys.storedKeys': 'Khóa đã lưu', + 'keys.addNew': 'Thêm khóa mới', + 'keys.add': 'Thêm khóa', + 'keys.rename': 'Đổi tên', + 'keys.renameNamed': 'Đổi tên {name}', + 'keys.saveName': 'Lưu tên', + 'keys.renameFailed': 'Không thể đổi tên khóa', 'files.fileTransfer': 'Truyền tệp', 'files.fileManager': 'Trình quản lý tệp', @@ -793,7 +807,7 @@ const translations = { 'fm.selectSource': '-- Chọn nguồn --', 'fm.yourComputer': 'Máy tính của bạn', 'fm.sshSessions': 'Các phiên SSH', - 'fm.newConnection': '+ Kết nối mới...', + 'fm.newConnection': '+ Kết nối nhanh...', 'fm.goUp': 'Lên trên', 'fm.goHome': 'Về thư mục chính', 'fm.parentDirectory': 'Thư mục cha', @@ -827,7 +841,7 @@ const translations = { 'fm.skip': 'Bỏ qua', 'fm.applyToAll': 'Áp dụng cho tất cả', 'fm.qc.title': 'Kết nối tới máy chủ', - 'fm.qc.savedProfiles': 'Cấu hình đã lưu', + 'fm.qc.savedProfiles': 'Kết nối đã lưu', 'fm.qc.enterManually': '-- Nhập thủ công --', 'fm.qc.orEnterDetails': 'hoặc nhập thông tin kết nối', 'fm.qc.host': 'Máy chủ', @@ -899,7 +913,7 @@ const translations = { 'shortcuts.openCommandLibrary': 'Mở thư viện lệnh', 'shortcuts.openCommandPalette': 'Mở bảng lệnh', 'shortcuts.showShortcuts': 'Hiển thị phím tắt', - 'shortcuts.newConnection': 'Kết nối mới', + 'shortcuts.newConnection': 'Kết nối nhanh', 'shortcuts.searchTerminal': 'Tìm kiếm trong terminal', 'shortcuts.copyTerminal': 'Sao chép vùng chọn terminal', 'shortcuts.pasteTerminal': 'Dán vào terminal', @@ -936,16 +950,16 @@ const translations = { 'common.error': 'Lỗi', 'panes.assignTitle': 'Gán phiên vào khung', - 'panes.assignInfo': 'Chọn phiên hiển thị trong mỗi khung. Bạn có thể gán các phiên hiện có hoặc tạo kết nối mới.', + 'panes.assignInfo': 'Chọn phiên hiển thị trong mỗi khung. Gán một phiên hiện có hoặc dùng Kết nối nhanh.', 'panes.pane': 'Khung', 'panes.empty': 'Trống', 'panes.emptyDesc': 'Để trống khung này', - 'panes.newConnection': '+ Kết nối mới', - 'panes.newConnectionDesc': 'Mở hộp thoại kết nối cho khung này', + 'panes.newConnection': '+ Kết nối nhanh', + 'panes.newConnectionDesc': 'Mở Kết nối nhanh cho khung này', 'panes.connected': 'Đã kết nối', 'panes.disconnected': 'Đã ngắt kết nối', 'panes.emptyPane': 'Khung trống', - 'panes.selectSession': 'Chọn một phiên hoặc mở một kết nối', + 'panes.selectSession': 'Chọn một phiên hoặc dùng Kết nối nhanh', 'session.closeWarning': 'Bạn đang có phiên SSH hoạt động. Các phiên này sẽ bị đóng.', 'session.rename': 'Đổi tên phiên', @@ -1031,25 +1045,25 @@ const translations = { 'terminal.mobileInputPlaceholder': 'Hier tippen oder einfügen...', 'connection.recentConnections': 'Letzte Verbindungen', - 'connection.newConnection': 'Neue Verbindung', - 'connection.newSSHConnection': 'Neue SSH-Verbindung', + 'connection.newConnection': 'Schnellverbindung', + 'connection.newSSHConnection': 'Schnellverbindung', 'connection.noActiveSessions': 'Keine aktiven Sitzungen', - 'connection.clickToStart': 'Klicken Sie auf "Neue Verbindung", um eine SSH-Sitzung zu starten', - 'connection.savedProfiles': 'Gespeicherte Profile', - 'connection.savedProfilesHint': 'Profil auswählen und verbinden', + 'connection.clickToStart': 'Klicken Sie auf "Schnellverbindung", um eine SSH-Sitzung zu starten', + 'connection.savedProfiles': 'Gespeicherte Verbindungen', + 'connection.savedProfilesHint': 'Gespeicherte Verbindung zum Verbinden auswählen', 'connection.connectNow': 'Jetzt verbinden', 'connection.passwordRequired': 'Kennwort erforderlich', 'connection.reviewConnection': 'Verbindung prüfen', - 'connection.profileUnavailable': 'Dieses Profil ist nicht mehr verfügbar.', - 'connection.loadProfile': 'Profil laden (Optional)', - 'connection.selectProfile': '-- Profil auswählen --', + 'connection.profileUnavailable': 'Diese gespeicherte Verbindung ist nicht mehr verfügbar.', + 'connection.loadProfile': 'Gespeicherte Verbindung laden (optional)', + 'connection.selectProfile': '-- Gespeicherte Verbindung auswählen --', 'connection.host': 'Host *', 'connection.port': 'Port *', 'connection.authMethod': 'Authentifizierungsmethode', 'connection.sshKey': 'SSH-Schlüssel *', 'connection.tailscaleSSH': 'Tailscale SSH', 'connection.selectSSHKey': '-- SSH-Schlüssel auswählen --', - 'connection.saveAsProfile': 'Als Profil speichern', + 'connection.saveAsProfile': 'Als gespeicherte Verbindung speichern', 'connection.jumpHost': 'Jump Host', 'connection.noJumpHost': 'Keiner (direkte Verbindung)', 'connection.jumpHostHint': 'Jump Hosts verwaltest du im Account-Menü.', @@ -1066,17 +1080,17 @@ const translations = { 'commandModes.command': 'Befehl', 'commandModes.freeText': 'Freitext', 'commandModes.selectCommand': '-- Befehl auswählen --', - 'commandModes.parametersTooltip': 'Nutze die gespeicherten Parameter oder ersetze sie nur für diese Verbindung beziehungsweise dieses Profil.', + 'commandModes.parametersTooltip': 'Nutze die gespeicherten Parameter oder ersetze sie nur für diese Verbindung.', 'commandModes.exactPreview': 'Exakte Befehlsvorschau', 'commandModes.emptyFreeText': 'Gib mindestens einen Befehl ein.', 'commandModes.missingCommand': 'Wähle zuerst einen Befehl aus.', 'commandModes.useSavedParameters': 'Gespeicherte Parameter verwenden', - 'profiles.manage': 'Profile', - 'profiles.manageHint': 'Erstelle, prüfe, aktualisiere und verbinde gespeicherte Profile unabhängig voneinander.', - 'profiles.create': 'Profil erstellen', - 'profiles.none': 'Keine Profile gespeichert.', - 'profiles.saveFailed': 'Das Profil konnte nicht gespeichert werden.', - 'profiles.saved': 'Profil gespeichert.', + 'profiles.manage': 'Gespeicherte Verbindungen', + 'profiles.manageHint': 'Gespeicherte Verbindungen erstellen, prüfen, aktualisieren und starten.', + 'profiles.create': 'Gespeicherte Verbindung erstellen', + 'profiles.none': 'Keine gespeicherten Verbindungen.', + 'profiles.saveFailed': 'Die Verbindung konnte nicht gespeichert werden.', + 'profiles.saved': 'Verbindung gespeichert.', 'commandSets.none': 'Keiner', 'commandSets.create': 'Befehlssatz erstellen', 'commandSets.createNew': 'Neu erstellen', @@ -1099,7 +1113,7 @@ const translations = { 'commandSets.missingCommand': 'Bibliotheksbefehl fehlt', 'commandSets.missingSet': 'Befehlssatz fehlt', 'commandSets.missingSetHint': 'Dieser Befehlssatz ist nicht verfügbar. Die Verbindung wird blockiert, bis du einen anderen auswählst.', - 'commandSets.legacyNotice': 'Dieses Profil verwendet noch alte Freitext-Befehle.', + 'commandSets.legacyNotice': 'Diese gespeicherte Verbindung verwendet noch alte Freitext-Befehle.', 'commandSets.convert': 'Umwandeln', 'commandSets.saved': 'Befehlssatz gespeichert', 'commandSets.confirmDelete': 'Diesen Befehlssatz löschen?', @@ -1114,9 +1128,10 @@ const translations = { 'jumphosts.confirmDelete': 'Diesen Jump Host löschen?', 'jumphosts.deleted': 'Jump Host gelöscht', 'jumphosts.noPasswordHint': 'Passwörter werden nie gespeichert — du gibst sie beim Verbinden ein.', - 'connection.profileName': 'Profilname', + 'connection.profileName': 'Verbindungsname', 'connection.profileNamePlaceholder': 'Mein Server', 'connection.connect': 'Verbinden', + 'connection.connectBusy': 'Ein Verbindungsversuch läuft bereits.', 'connection.reconnected': 'Wieder verbunden!', 'connection.lostReconnecting': 'Verbindung unterbrochen. Wiederverbindung läuft...', 'connection.lostReconnectingAttempt': 'Verbindung unterbrochen. Wiederverbindung läuft... (Versuch {attempt})', @@ -1130,6 +1145,12 @@ const translations = { 'keys.privateKey': 'Privater Schlüssel', 'keys.uploadKey': 'Schlüssel hochladen', 'keys.storedKeys': 'Gespeicherte Schlüssel', + 'keys.addNew': 'Neuen Schlüssel hinzufügen', + 'keys.add': 'Schlüssel hinzufügen', + 'keys.rename': 'Umbenennen', + 'keys.renameNamed': '{name} umbenennen', + 'keys.saveName': 'Namen speichern', + 'keys.renameFailed': 'Schlüssel konnte nicht umbenannt werden', 'files.fileTransfer': 'Dateiübertragung', 'files.fileManager': 'Dateimanager', @@ -1308,7 +1329,7 @@ const translations = { 'fm.selectSource': '-- Quelle auswählen --', 'fm.yourComputer': 'Ihr Computer', 'fm.sshSessions': 'SSH-Sitzungen', - 'fm.newConnection': '+ Neue Verbindung...', + 'fm.newConnection': '+ Schnellverbindung...', 'fm.goUp': 'Nach oben', 'fm.goHome': 'Zum Startverzeichnis', 'fm.parentDirectory': 'Übergeordnetes Verzeichnis', @@ -1342,7 +1363,7 @@ const translations = { 'fm.skip': 'Überspringen', 'fm.applyToAll': 'Auf alle anwenden', 'fm.qc.title': 'Mit Server verbinden', - 'fm.qc.savedProfiles': 'Gespeicherte Profile', + 'fm.qc.savedProfiles': 'Gespeicherte Verbindungen', 'fm.qc.enterManually': '-- Manuell eingeben --', 'fm.qc.orEnterDetails': 'oder Verbindungsdaten eingeben', 'fm.qc.host': 'Host', @@ -1409,7 +1430,7 @@ const translations = { 'shortcuts.openCommandLibrary': 'Befehlsbibliothek öffnen', 'shortcuts.openCommandPalette': 'Befehlspalette öffnen', 'shortcuts.showShortcuts': 'Tastenkürzel anzeigen', - 'shortcuts.newConnection': 'Neue Verbindung', + 'shortcuts.newConnection': 'Schnellverbindung', 'shortcuts.searchTerminal': 'Im Terminal suchen', 'shortcuts.copyTerminal': 'Terminalauswahl kopieren', 'shortcuts.pasteTerminal': 'In das Terminal einfügen', @@ -1446,16 +1467,16 @@ const translations = { 'common.error': 'Fehler', 'panes.assignTitle': 'Sitzungen zu Panes zuweisen', - 'panes.assignInfo': 'Wählen Sie, welche Sitzungen in welchem Pane angezeigt werden sollen. Sie können bestehende Sitzungen zuweisen oder neue Verbindungen erstellen.', + 'panes.assignInfo': 'Wählen Sie die Sitzungen für jedes Pane. Weisen Sie eine bestehende Sitzung zu oder nutzen Sie die Schnellverbindung.', 'panes.pane': 'Pane', 'panes.empty': 'Leer', 'panes.emptyDesc': 'Dieses Pane leer lassen', - 'panes.newConnection': '+ Neue Verbindung', - 'panes.newConnectionDesc': 'Verbindungsdialog für dieses Pane öffnen', + 'panes.newConnection': '+ Schnellverbindung', + 'panes.newConnectionDesc': 'Schnellverbindung für dieses Pane öffnen', 'panes.connected': 'Verbunden', 'panes.disconnected': 'Getrennt', 'panes.emptyPane': 'Leeres Pane', - 'panes.selectSession': 'Wählen Sie eine Sitzung oder öffnen Sie eine Verbindung', + 'panes.selectSession': 'Wählen Sie eine Sitzung oder nutzen Sie die Schnellverbindung', 'commands.workspace': 'Befehle', 'commands.library': 'Befehlsbibliothek', @@ -1528,25 +1549,25 @@ const translations = { 'terminal.mobileInputPlaceholder': 'Tapez ou collez ici...', 'connection.recentConnections': 'Connexions récentes', - 'connection.newConnection': 'Nouvelle connexion', - 'connection.newSSHConnection': 'Nouvelle connexion SSH', + 'connection.newConnection': 'Connexion rapide', + 'connection.newSSHConnection': 'Connexion rapide', 'connection.noActiveSessions': 'Aucune session active', - 'connection.clickToStart': 'Cliquez sur "Nouvelle connexion" pour démarrer une session SSH', - 'connection.savedProfiles': 'Profils enregistrés', - 'connection.savedProfilesHint': 'Choisissez un profil pour vous connecter', + 'connection.clickToStart': 'Cliquez sur "Connexion rapide" pour démarrer une session SSH', + 'connection.savedProfiles': 'Connexions enregistrées', + 'connection.savedProfilesHint': 'Choisissez une connexion enregistrée pour vous connecter', 'connection.connectNow': 'Se connecter', 'connection.passwordRequired': 'Mot de passe requis', 'connection.reviewConnection': 'Vérifier la connexion', - 'connection.profileUnavailable': 'Ce profil n’est plus disponible.', - 'connection.loadProfile': 'Charger le profil (Optionnel)', - 'connection.selectProfile': '-- Sélectionner un profil --', + 'connection.profileUnavailable': 'Cette connexion enregistrée n’est plus disponible.', + 'connection.loadProfile': 'Charger une connexion enregistrée (optionnel)', + 'connection.selectProfile': '-- Sélectionner une connexion enregistrée --', 'connection.host': 'Hôte *', 'connection.port': 'Port *', 'connection.authMethod': "Méthode d'authentification", 'connection.sshKey': 'Clé SSH *', 'connection.tailscaleSSH': 'Tailscale SSH', 'connection.selectSSHKey': '-- Sélectionner une clé SSH --', - 'connection.saveAsProfile': 'Enregistrer comme profil', + 'connection.saveAsProfile': 'Enregistrer la connexion', 'connection.jumpHost': 'Hôte de rebond', 'connection.noJumpHost': 'Aucun (connexion directe)', 'connection.jumpHostHint': 'Gérez les hôtes de rebond dans le menu du compte.', @@ -1555,7 +1576,7 @@ const translations = { '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', - 'commandSets.manageHint': 'Créez des séquences réutilisables et associez-en une à chaque profil de connexion.', + 'commandSets.manageHint': 'Créez des séquences réutilisables et associez-en une à chaque connexion enregistrée.', 'connection.runAfterConnect': 'Exécuter après la connexion', 'connection.runAfterConnectTooltip': 'Choisissez précisément ce que WebSSH envoie au terminal distant lorsqu’une nouvelle connexion SSH est prête.', 'commandModes.none': 'Rien', @@ -1563,17 +1584,17 @@ const translations = { 'commandModes.command': 'Commande', 'commandModes.freeText': 'Texte libre', 'commandModes.selectCommand': '-- Sélectionner une commande --', - 'commandModes.parametersTooltip': 'Conservez les paramètres enregistrés ou remplacez-les uniquement pour cette connexion ou ce profil.', + 'commandModes.parametersTooltip': 'Conservez les paramètres enregistrés ou remplacez-les uniquement pour cette connexion.', 'commandModes.exactPreview': 'Aperçu exact de la commande', 'commandModes.emptyFreeText': 'Saisissez au moins une commande.', 'commandModes.missingCommand': 'Sélectionnez d’abord une commande.', 'commandModes.useSavedParameters': 'Utiliser les paramètres enregistrés', - 'profiles.manage': 'Profils', - 'profiles.manageHint': 'Créez, vérifiez, mettez à jour et connectez les profils enregistrés indépendamment.', - 'profiles.create': 'Créer un profil', - 'profiles.none': 'Aucun profil enregistré.', - 'profiles.saveFailed': 'Impossible d’enregistrer le profil.', - 'profiles.saved': 'Profil enregistré.', + 'profiles.manage': 'Connexions enregistrées', + 'profiles.manageHint': 'Créez, vérifiez, mettez à jour et lancez des connexions enregistrées.', + 'profiles.create': 'Créer une connexion enregistrée', + 'profiles.none': 'Aucune connexion enregistrée.', + 'profiles.saveFailed': 'Impossible d’enregistrer la connexion.', + 'profiles.saved': 'Connexion enregistrée.', 'commandSets.none': 'Aucun', 'commandSets.create': 'Créer un ensemble', 'commandSets.createNew': 'Créer', @@ -1596,7 +1617,7 @@ const translations = { 'commandSets.missingCommand': 'Commande de bibliothèque manquante', 'commandSets.missingSet': 'Ensemble de commandes manquant', 'commandSets.missingSetHint': 'Cet ensemble est indisponible. La connexion sera bloquée jusqu’à ce que vous en choisissiez un autre.', - 'commandSets.legacyNotice': 'Ce profil utilise encore les anciennes commandes en texte libre.', + 'commandSets.legacyNotice': 'Cette connexion enregistrée utilise encore les anciennes commandes en texte libre.', 'commandSets.convert': 'Convertir', 'commandSets.saved': 'Ensemble de commandes enregistré', 'commandSets.confirmDelete': 'Supprimer cet ensemble de commandes ?', @@ -1611,9 +1632,10 @@ const translations = { 'jumphosts.confirmDelete': 'Supprimer cet hôte de rebond ?', 'jumphosts.deleted': 'Hôte de rebond supprimé', 'jumphosts.noPasswordHint': 'Les mots de passe ne sont jamais enregistrés — vous les saisissez à la connexion.', - 'connection.profileName': 'Nom du profil', + 'connection.profileName': 'Nom de la connexion', 'connection.profileNamePlaceholder': 'Mon serveur', 'connection.connect': 'Connecter', + 'connection.connectBusy': 'Une tentative de connexion est déjà en cours.', 'connection.reconnected': 'Reconnexion réussie !', 'connection.lostReconnecting': 'Connexion perdue. Reconnexion en cours...', 'connection.lostReconnectingAttempt': 'Connexion perdue. Reconnexion en cours... (tentative {attempt})', @@ -1627,6 +1649,12 @@ const translations = { 'keys.privateKey': 'Clé privée', 'keys.uploadKey': 'Télécharger la clé', 'keys.storedKeys': 'Clés stockées', + 'keys.addNew': 'Ajouter une nouvelle clé', + 'keys.add': 'Ajouter la clé', + 'keys.rename': 'Renommer', + 'keys.renameNamed': 'Renommer {name}', + 'keys.saveName': 'Enregistrer le nom', + 'keys.renameFailed': 'Impossible de renommer la clé', 'files.fileTransfer': 'Transfert de fichiers', 'files.fileManager': 'Gestionnaire de fichiers', @@ -1732,12 +1760,12 @@ const translations = { 'panes.pane': 'Volet', 'panes.empty': 'Vide', 'panes.emptyDesc': 'Laisser ce volet vide', - 'panes.newConnection': '+ Nouvelle connexion', - 'panes.newConnectionDesc': 'Ouvrir la boîte de dialogue de connexion pour ce volet', + 'panes.newConnection': '+ Connexion rapide', + 'panes.newConnectionDesc': 'Ouvrir la connexion rapide pour ce volet', 'panes.connected': 'Connecté', 'panes.disconnected': 'Déconnecté', 'panes.emptyPane': 'Volet vide', - 'panes.selectSession': 'Sélectionnez une session ou ouvrez une connexion', + 'panes.selectSession': 'Sélectionnez une session ou utilisez Connexion rapide', 'session.closeWarning': 'Vous avez des sessions SSH actives. Elles seront fermées.', 'session.rename': 'Renommer la session', 'session.close': 'Fermer la session', @@ -1814,7 +1842,7 @@ const translations = { 'fm.selectSource': '-- Sélectionner une source --', 'fm.yourComputer': 'Votre ordinateur', 'fm.sshSessions': 'Sessions SSH', - 'fm.newConnection': '+ Nouvelle connexion...', + 'fm.newConnection': '+ Connexion rapide...', 'fm.goUp': 'Remonter', 'fm.goHome': 'Aller au répertoire personnel', 'fm.parentDirectory': 'Répertoire parent', @@ -1848,7 +1876,7 @@ const translations = { 'fm.skip': 'Ignorer', 'fm.applyToAll': 'Appliquer à tous', 'fm.qc.title': 'Connexion au serveur', - 'fm.qc.savedProfiles': 'Profils enregistrés', + 'fm.qc.savedProfiles': 'Connexions enregistrées', 'fm.qc.enterManually': '-- Saisie manuelle --', 'fm.qc.orEnterDetails': 'ou entrez les détails de connexion', 'fm.qc.host': 'Hôte', @@ -1915,7 +1943,7 @@ const translations = { 'shortcuts.openCommandLibrary': 'Ouvrir la bibliothèque de commandes', 'shortcuts.openCommandPalette': 'Ouvrir la palette de commandes', 'shortcuts.showShortcuts': 'Afficher les raccourcis', - 'shortcuts.newConnection': 'Nouvelle connexion', + 'shortcuts.newConnection': 'Connexion rapide', 'shortcuts.searchTerminal': 'Rechercher dans le terminal', 'shortcuts.copyTerminal': 'Copier la sélection du terminal', 'shortcuts.pasteTerminal': 'Coller dans le terminal', @@ -1952,7 +1980,7 @@ const translations = { 'common.error': 'Erreur', 'panes.assignTitle': 'Attribuer des sessions aux volets', - 'panes.assignInfo': 'Sélectionnez les sessions à afficher dans chaque volet. Vous pouvez attribuer des sessions existantes ou créer de nouvelles connexions.', + 'panes.assignInfo': 'Sélectionnez les sessions à afficher dans chaque volet. Attribuez une session existante ou utilisez Connexion rapide.', 'commands.workspace': 'Commandes', 'commands.library': 'Bibliothèque de commandes', @@ -2025,25 +2053,25 @@ const translations = { 'terminal.mobileInputPlaceholder': 'Escribe o pega aquí...', 'connection.recentConnections': 'Conexiones recientes', - 'connection.newConnection': 'Nueva conexión', - 'connection.newSSHConnection': 'Nueva conexión SSH', + 'connection.newConnection': 'Conexión rápida', + 'connection.newSSHConnection': 'Conexión rápida', 'connection.noActiveSessions': 'Sin sesiones activas', - 'connection.clickToStart': 'Haz clic en "Nueva conexión" para iniciar una sesión SSH', - 'connection.savedProfiles': 'Perfiles guardados', - 'connection.savedProfilesHint': 'Elige un perfil para conectarte', + 'connection.clickToStart': 'Haz clic en "Conexión rápida" para iniciar una sesión SSH', + 'connection.savedProfiles': 'Conexiones guardadas', + 'connection.savedProfilesHint': 'Elige una conexión guardada para conectarte', 'connection.connectNow': 'Conectar ahora', 'connection.passwordRequired': 'Se requiere contraseña', 'connection.reviewConnection': 'Revisar conexión', - 'connection.profileUnavailable': 'Este perfil ya no está disponible.', - 'connection.loadProfile': 'Cargar perfil (Opcional)', - 'connection.selectProfile': '-- Seleccionar perfil --', + 'connection.profileUnavailable': 'Esta conexión guardada ya no está disponible.', + 'connection.loadProfile': 'Cargar conexión guardada (opcional)', + 'connection.selectProfile': '-- Seleccionar conexión guardada --', 'connection.host': 'Host *', 'connection.port': 'Puerto *', 'connection.authMethod': 'Método de autenticación', 'connection.sshKey': 'Clave SSH *', 'connection.tailscaleSSH': 'Tailscale SSH', 'connection.selectSSHKey': '-- Seleccionar clave SSH --', - 'connection.saveAsProfile': 'Guardar como perfil', + 'connection.saveAsProfile': 'Guardar la conexión', 'connection.jumpHost': 'Host de salto', 'connection.noJumpHost': 'Ninguno (conexión directa)', 'connection.jumpHostHint': 'Gestiona los hosts de salto en el menú de la cuenta.', @@ -2052,7 +2080,7 @@ const translations = { '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', - 'commandSets.manageHint': 'Crea secuencias reutilizables y asigna una a cada perfil de conexión.', + 'commandSets.manageHint': 'Crea secuencias reutilizables y asigna una a cada conexión guardada.', 'connection.runAfterConnect': 'Ejecutar después de conectar', 'connection.runAfterConnectTooltip': 'Elige exactamente qué envía WebSSH al terminal remoto cuando una nueva conexión SSH está lista.', 'commandModes.none': 'Nada', @@ -2060,17 +2088,17 @@ const translations = { 'commandModes.command': 'Comando', 'commandModes.freeText': 'Texto libre', 'commandModes.selectCommand': '-- Seleccionar comando --', - 'commandModes.parametersTooltip': 'Conserva los parámetros guardados o sustitúyelos solo para esta conexión o perfil.', + 'commandModes.parametersTooltip': 'Conserva los parámetros guardados o sustitúyelos solo para esta conexión.', 'commandModes.exactPreview': 'Vista previa exacta del comando', 'commandModes.emptyFreeText': 'Introduce al menos un comando.', 'commandModes.missingCommand': 'Selecciona primero un comando.', 'commandModes.useSavedParameters': 'Usar parámetros guardados', - 'profiles.manage': 'Perfiles', - 'profiles.manageHint': 'Crea, revisa, actualiza y conecta perfiles guardados de forma independiente.', - 'profiles.create': 'Crear perfil', - 'profiles.none': 'No hay perfiles guardados.', - 'profiles.saveFailed': 'No se pudo guardar el perfil.', - 'profiles.saved': 'Perfil guardado.', + 'profiles.manage': 'Conexiones guardadas', + 'profiles.manageHint': 'Crea, revisa, actualiza e inicia conexiones guardadas.', + 'profiles.create': 'Crear conexión guardada', + 'profiles.none': 'No hay conexiones guardadas.', + 'profiles.saveFailed': 'No se pudo guardar la conexión.', + 'profiles.saved': 'Conexión guardada.', 'commandSets.none': 'Ninguno', 'commandSets.create': 'Crear conjunto', 'commandSets.createNew': 'Crear nuevo', @@ -2093,7 +2121,7 @@ const translations = { 'commandSets.missingCommand': 'Falta el comando de la biblioteca', 'commandSets.missingSet': 'Falta el conjunto de comandos', 'commandSets.missingSetHint': 'Este conjunto no está disponible. La conexión se bloqueará hasta que selecciones otro.', - 'commandSets.legacyNotice': 'Este perfil todavía usa comandos de texto libre antiguos.', + 'commandSets.legacyNotice': 'Esta conexión guardada todavía usa comandos de texto libre antiguos.', 'commandSets.convert': 'Convertir', 'commandSets.saved': 'Conjunto de comandos guardado', 'commandSets.confirmDelete': '¿Eliminar este conjunto de comandos?', @@ -2108,9 +2136,10 @@ const translations = { 'jumphosts.confirmDelete': '¿Eliminar este host de salto?', 'jumphosts.deleted': 'Host de salto eliminado', 'jumphosts.noPasswordHint': 'Las contraseñas nunca se guardan — las introduces al conectar.', - 'connection.profileName': 'Nombre del perfil', + 'connection.profileName': 'Nombre de la conexión', 'connection.profileNamePlaceholder': 'Mi servidor', 'connection.connect': 'Conectar', + 'connection.connectBusy': 'Ya hay un intento de conexión en curso.', 'connection.reconnected': '¡Conexión restablecida!', 'connection.lostReconnecting': 'Conexión perdida. Reconectando...', 'connection.lostReconnectingAttempt': 'Conexión perdida. Reconectando... (intento {attempt})', @@ -2124,6 +2153,12 @@ const translations = { 'keys.privateKey': 'Clave privada', 'keys.uploadKey': 'Subir clave', 'keys.storedKeys': 'Claves almacenadas', + 'keys.addNew': 'Añadir una clave nueva', + 'keys.add': 'Añadir clave', + 'keys.rename': 'Cambiar nombre', + 'keys.renameNamed': 'Cambiar el nombre de {name}', + 'keys.saveName': 'Guardar nombre', + 'keys.renameFailed': 'No se pudo cambiar el nombre de la clave', 'files.fileTransfer': 'Transferencia de archivos', 'files.fileManager': 'Gestor de archivos', @@ -2229,12 +2264,12 @@ const translations = { 'panes.pane': 'Panel', 'panes.empty': 'Vacío', 'panes.emptyDesc': 'Dejar este panel vacío', - 'panes.newConnection': '+ Nueva conexión', - 'panes.newConnectionDesc': 'Abrir el diálogo de conexión para este panel', + 'panes.newConnection': '+ Conexión rápida', + 'panes.newConnectionDesc': 'Abrir Conexión rápida para este panel', 'panes.connected': 'Conectado', 'panes.disconnected': 'Desconectado', 'panes.emptyPane': 'Panel vacío', - 'panes.selectSession': 'Selecciona una sesión o abre una conexión', + 'panes.selectSession': 'Selecciona una sesión o usa Conexión rápida', 'session.closeWarning': 'Tienes sesiones SSH activas. Se cerrarán.', 'session.rename': 'Cambiar nombre de la sesión', 'session.close': 'Cerrar sesión', @@ -2311,7 +2346,7 @@ const translations = { 'fm.selectSource': '-- Seleccionar fuente --', 'fm.yourComputer': 'Tu ordenador', 'fm.sshSessions': 'Sesiones SSH', - 'fm.newConnection': '+ Nueva conexión...', + 'fm.newConnection': '+ Conexión rápida...', 'fm.goUp': 'Subir', 'fm.goHome': 'Ir al directorio principal', 'fm.parentDirectory': 'Directorio superior', @@ -2345,7 +2380,7 @@ const translations = { 'fm.skip': 'Omitir', 'fm.applyToAll': 'Aplicar a todos', 'fm.qc.title': 'Conectar al servidor', - 'fm.qc.savedProfiles': 'Perfiles guardados', + 'fm.qc.savedProfiles': 'Conexiones guardadas', 'fm.qc.enterManually': '-- Introducir manualmente --', 'fm.qc.orEnterDetails': 'o introduce los detalles de conexión', 'fm.qc.host': 'Host', @@ -2412,7 +2447,7 @@ const translations = { 'shortcuts.openCommandLibrary': 'Abrir la biblioteca de comandos', 'shortcuts.openCommandPalette': 'Abrir la paleta de comandos', 'shortcuts.showShortcuts': 'Mostrar atajos', - 'shortcuts.newConnection': 'Nueva conexión', + 'shortcuts.newConnection': 'Conexión rápida', 'shortcuts.searchTerminal': 'Buscar en el terminal', 'shortcuts.copyTerminal': 'Copiar la selección del terminal', 'shortcuts.pasteTerminal': 'Pegar en el terminal', @@ -2449,7 +2484,7 @@ const translations = { 'common.error': 'Error', 'panes.assignTitle': 'Asignar sesiones a paneles', - 'panes.assignInfo': 'Selecciona las sesiones que se mostrarán en cada panel. Puedes asignar sesiones existentes o crear conexiones nuevas.', + 'panes.assignInfo': 'Selecciona las sesiones que se mostrarán en cada panel. Asigna una sesión existente o usa Conexión rápida.', 'commands.workspace': 'Comandos', 'commands.library': 'Biblioteca de comandos', @@ -2522,25 +2557,25 @@ const translations = { 'terminal.mobileInputPlaceholder': '在这里输入或粘贴...', 'connection.recentConnections': '最近连接', - 'connection.newConnection': '新建连接', - 'connection.newSSHConnection': '新建 SSH 连接', + 'connection.newConnection': '快速连接', + 'connection.newSSHConnection': '快速连接', 'connection.noActiveSessions': '当前没有活动会话', - 'connection.clickToStart': '点击“新建连接”开始一个 SSH 会话', - 'connection.savedProfiles': '已保存的配置', - 'connection.savedProfilesHint': '选择配置以连接', + 'connection.clickToStart': '点击“快速连接”开始一个 SSH 会话', + 'connection.savedProfiles': '已保存的连接', + 'connection.savedProfilesHint': '选择一个已保存的连接进行连接', 'connection.connectNow': '立即连接', 'connection.passwordRequired': '需要密码', 'connection.reviewConnection': '检查连接', - 'connection.profileUnavailable': '此配置已不可用。', - 'connection.loadProfile': '加载配置(可选)', - 'connection.selectProfile': '-- 选择配置 --', + 'connection.profileUnavailable': '此已保存的连接已不可用。', + 'connection.loadProfile': '加载已保存的连接(可选)', + 'connection.selectProfile': '-- 选择已保存的连接 --', 'connection.host': '主机 *', 'connection.port': '端口 *', 'connection.authMethod': '认证方式', 'connection.sshKey': 'SSH 密钥 *', 'connection.tailscaleSSH': 'Tailscale SSH', 'connection.selectSSHKey': '-- 选择 SSH 密钥 --', - 'connection.saveAsProfile': '保存为配置', + 'connection.saveAsProfile': '保存连接', 'connection.jumpHost': '跳板机', 'connection.noJumpHost': '无(直接连接)', 'connection.jumpHostHint': '在账户菜单中管理跳板机。', @@ -2549,7 +2584,7 @@ const translations = { 'connection.commandSet': '连接后运行的命令(可选)', 'connection.commandSetHint': '连接成功后在远程主机上运行,而不是在 WebSSH 中运行。重新连接到现有 tmux 会话时不会再次运行。', 'commandSets.manage': '命令集', - 'commandSets.manageHint': '创建可重复使用的命令序列,并为每个连接配置文件分配一个命令集。', + 'commandSets.manageHint': '创建可重复使用的命令序列,并为每个已保存的连接分配一个命令集。', 'connection.runAfterConnect': '连接后运行', 'connection.runAfterConnectTooltip': '准确选择 WebSSH 在新的 SSH 连接就绪后发送到远程终端的内容。', 'commandModes.none': '不运行', @@ -2557,17 +2592,17 @@ const translations = { 'commandModes.command': '命令', 'commandModes.freeText': '自由文本', 'commandModes.selectCommand': '-- 选择命令 --', - 'commandModes.parametersTooltip': '使用已保存的参数,或仅为此次连接或此配置文件替换参数。', + 'commandModes.parametersTooltip': '使用已保存的参数,或仅为此次连接替换参数。', 'commandModes.exactPreview': '准确命令预览', 'commandModes.emptyFreeText': '请至少输入一条命令。', 'commandModes.missingCommand': '请先选择一条命令。', 'commandModes.useSavedParameters': '使用已保存的参数', - 'profiles.manage': '配置文件', - 'profiles.manageHint': '独立创建、检查、更新和连接已保存的配置文件。', - 'profiles.create': '创建配置文件', - 'profiles.none': '尚未保存配置文件。', - 'profiles.saveFailed': '无法保存配置文件。', - 'profiles.saved': '配置文件已保存。', + 'profiles.manage': '已保存的连接', + 'profiles.manageHint': '创建、检查、更新并启动已保存的连接。', + 'profiles.create': '创建已保存的连接', + 'profiles.none': '尚无已保存的连接。', + 'profiles.saveFailed': '无法保存连接。', + 'profiles.saved': '连接已保存。', 'commandSets.none': '无', 'commandSets.create': '创建命令集', 'commandSets.createNew': '新建', @@ -2590,7 +2625,7 @@ const translations = { 'commandSets.missingCommand': '缺少库命令', 'commandSets.missingSet': '缺少命令集', 'commandSets.missingSetHint': '此命令集不可用。选择其他命令集之前,连接将被阻止。', - 'commandSets.legacyNotice': '此配置文件仍使用旧版自由文本命令。', + 'commandSets.legacyNotice': '此已保存的连接仍使用旧版自由文本命令。', 'commandSets.convert': '转换', 'commandSets.saved': '命令集已保存', 'commandSets.confirmDelete': '删除此命令集?', @@ -2605,9 +2640,10 @@ const translations = { 'jumphosts.confirmDelete': '删除此跳板机?', 'jumphosts.deleted': '跳板机已删除', 'jumphosts.noPasswordHint': '密码不会被保存 — 连接时输入。', - 'connection.profileName': '配置名称', + 'connection.profileName': '连接名称', 'connection.profileNamePlaceholder': '我的服务器', 'connection.connect': '连接', + 'connection.connectBusy': '已有连接尝试正在进行。', 'connection.reconnected': '已重新连接!', 'connection.lostReconnecting': '连接已中断。正在重新连接...', 'connection.lostReconnectingAttempt': '连接已中断。正在重新连接...(第 {attempt} 次尝试)', @@ -2621,6 +2657,12 @@ const translations = { 'keys.privateKey': '私钥', 'keys.uploadKey': '上传密钥', 'keys.storedKeys': '已保存的密钥', + 'keys.addNew': '添加新密钥', + 'keys.add': '添加密钥', + 'keys.rename': '重命名', + 'keys.renameNamed': '重命名 {name}', + 'keys.saveName': '保存名称', + 'keys.renameFailed': '无法重命名密钥', 'files.fileTransfer': '文件传输', 'files.fileManager': '文件管理器', @@ -2799,7 +2841,7 @@ const translations = { 'fm.selectSource': '-- 选择来源 --', 'fm.yourComputer': '你的电脑', 'fm.sshSessions': 'SSH 会话', - 'fm.newConnection': '+ 新建连接...', + 'fm.newConnection': '+ 快速连接...', 'fm.goUp': '返回上级', 'fm.goHome': '前往主目录', 'fm.parentDirectory': '上级目录', @@ -2833,7 +2875,7 @@ const translations = { 'fm.skip': '跳过', 'fm.applyToAll': '应用到全部', 'fm.qc.title': '连接到服务器', - 'fm.qc.savedProfiles': '已保存的配置', + 'fm.qc.savedProfiles': '已保存的连接', 'fm.qc.enterManually': '-- 手动输入 --', 'fm.qc.orEnterDetails': '或手动输入连接信息', 'fm.qc.host': '主机', @@ -2900,7 +2942,7 @@ const translations = { 'shortcuts.openCommandLibrary': '打开命令库', 'shortcuts.openCommandPalette': '打开命令面板', 'shortcuts.showShortcuts': '显示快捷键', - 'shortcuts.newConnection': '新建连接', + 'shortcuts.newConnection': '快速连接', 'shortcuts.searchTerminal': '在终端中搜索', 'shortcuts.copyTerminal': '复制终端选中内容', 'shortcuts.pasteTerminal': '粘贴到终端', @@ -2937,16 +2979,16 @@ const translations = { 'common.error': '错误', 'panes.assignTitle': '为分栏分配会话', - 'panes.assignInfo': '选择每个分栏中要显示的会话。你可以分配现有会话,或为该分栏创建新连接。', + 'panes.assignInfo': '选择每个分栏中要显示的会话。分配现有会话或使用快速连接。', 'panes.pane': '分栏', 'panes.empty': '留空', 'panes.emptyDesc': '保持该分栏为空', - 'panes.newConnection': '+ 新建连接', - 'panes.newConnectionDesc': '为此分栏打开连接对话框', + 'panes.newConnection': '+ 快速连接', + 'panes.newConnectionDesc': '为此分栏打开快速连接', 'panes.connected': '已连接', 'panes.disconnected': '已断开', 'panes.emptyPane': '空分栏', - 'panes.selectSession': '选择一个会话或新建连接', + 'panes.selectSession': '选择一个会话或使用快速连接', 'commands.workspace': '命令', 'commands.library': '命令库', diff --git a/static/js/profile-launcher-utils.js b/static/js/profile-launcher-utils.js index e775be1..a38b74b 100644 --- a/static/js/profile-launcher-utils.js +++ b/static/js/profile-launcher-utils.js @@ -55,6 +55,104 @@ return needsTargetPassword ? 'password' : 'connect'; } + function inferProfileStartupMode(profile) { + if (profile?.startup_mode) return profile.startup_mode; + if (profile?.command_set_id) return 'command_set'; + if (profile?.command_id) return 'command'; + if (profile?.startup_commands) return 'free_text'; + return 'none'; + } + + function profilePostConnectPayload(profile) { + const mode = inferProfileStartupMode(profile); + if (mode === 'free_text') { + return { + startup_mode: 'free_text', + startup_commands: profile.startup_commands || '', + }; + } + if (mode === 'command') { + if (!profile.command_id) return null; + const payload = { + startup_mode: 'command', + command_id: profile.command_id, + }; + if (Object.prototype.hasOwnProperty.call( + profile, + 'parameters_override', + )) { + payload.parameters_override = profile.parameters_override; + } + return payload; + } + if (mode === 'command_set') { + return profile.command_set_id ? { + startup_mode: 'command_set', + command_set_id: profile.command_set_id, + } : null; + } + return mode === 'none' ? { startup_mode: 'none' } : null; + } + + function normalizedPort(value, defaultPort = 22) { + const candidate = value === undefined || value === null || value === '' + ? defaultPort + : Number(value); + return Number.isInteger(candidate) + && candidate >= 1 + && candidate <= 65535 + ? candidate + : null; + } + + function buildDirectConnectionData(profile, context = {}) { + if (determineLaunchMode(profile, context) !== 'connect') return null; + + const host = String(profile.host || '').trim(); + const username = String(profile.username || '').trim(); + const port = normalizedPort(profile.port); + if (!host || !username || port === null) return null; + + const result = { + host, + port, + username, + auth_type: profile.auth_type, + }; + if (profile.auth_type === 'key') result.key_id = profile.key_id; + if (profile.use_tmux === true) result.use_tmux = true; + + const postConnect = profilePostConnectPayload(profile); + if (!postConnect) return null; + Object.assign(result, postConnect); + + if (!profile.jump_host_id) return result; + + const jumpHost = ( + Array.isArray(context.jumpHosts) ? context.jumpHosts : [] + ).find(item => item?.id === profile.jump_host_id); + if (!jumpHost) return null; + + const jumpHostName = String(jumpHost.host || '').trim(); + const jumpUsername = String(jumpHost.username || '').trim(); + const jumpPort = normalizedPort(jumpHost.port); + if (!jumpHostName || !jumpUsername || jumpPort === null) return null; + + const proxyJump = { + jump_host_id: jumpHost.id, + host: jumpHostName, + port: jumpPort, + username: jumpUsername, + auth_type: jumpHost.auth_type, + }; + if (jumpHost.auth_type === 'key') { + if (!jumpHost.key_id) return null; + proxyJump.key_id = jumpHost.key_id; + } + result.proxy_jump = proxyJump; + return result; + } + function formatEndpoint(profile) { const value = profile && typeof profile === 'object' ? profile : {}; const username = String(value.username || ''); @@ -63,5 +161,9 @@ return `${username}@${host}:${port}`; } - return { determineLaunchMode, formatEndpoint }; + return { + buildDirectConnectionData, + determineLaunchMode, + formatEndpoint, + }; })); diff --git a/static/js/profile-manager.js b/static/js/profile-manager.js index f214db2..5aca87c 100644 --- a/static/js/profile-manager.js +++ b/static/js/profile-manager.js @@ -4,6 +4,10 @@ const ProfileManager = { profilesLoaded: false, selectedLegacyStartupCommands: '', editingProfileId: null, + editingKeyId: null, + editingKeyName: null, + keyRenamePending: false, + inlineKeyUploadPending: false, init() { document.getElementById('manageProfilesBtn')?.addEventListener('click', () => { @@ -52,9 +56,52 @@ const ProfileManager = { if (button.dataset.profileAction === 'edit') this.openEditor(profileId); if (button.dataset.profileAction === 'delete') this.deleteProfile(profileId); }); + document.getElementById('profileEditorAddKeyBtn')?.addEventListener('click', () => { + this.setInlineKeyPanelExpanded(true); + document.getElementById('profileEditorNewKeyName')?.focus(); + }); + document.getElementById('profileEditorCancelKeyBtn')?.addEventListener('click', () => { + this.setInlineKeyPanelExpanded(false); + }); + document.getElementById('profileEditorUploadKeyBtn')?.addEventListener('click', () => { + this.submitInlineKeyUpload(); + }); + document.getElementById('keysList')?.addEventListener('click', event => { + const button = event.target.closest('[data-key-action]'); + if (!button) return; + const keyId = button.dataset.keyId; + if (button.dataset.keyAction === 'rename') this.beginKeyRename(keyId); + if (button.dataset.keyAction === 'cancel-rename') this.cancelKeyRename(); + if (button.dataset.keyAction === 'save-rename') { + const input = button.closest('.key-item')?.querySelector('.key-rename-input'); + this.submitKeyRename(keyId, input?.value || ''); + } + if (button.dataset.keyAction === 'delete') this.deleteKey(keyId); + }); + document.getElementById('keysList')?.addEventListener('keydown', event => { + const input = event.target.closest('.key-rename-input'); + if (!input) return; + if (event.key === 'Enter') { + event.preventDefault(); + event.stopPropagation(); + this.submitKeyRename(input.dataset.keyId, input.value); + } + if (event.key === 'Escape') { + event.preventDefault(); + event.stopPropagation(); + this.cancelKeyRename(); + } + }); + document.getElementById('keysList')?.addEventListener('input', event => { + const input = event.target.closest('.key-rename-input'); + if (input?.dataset.keyId === this.editingKeyId) { + this.editingKeyName = input.value; + } + }); window.addEventListener('languageChanged', () => { this.renderProfileSelect(); this.renderManagementList(); + this.renderKeysList(); }); }, @@ -86,6 +133,14 @@ const ProfileManager = { this.refreshEmptyPanes(); }, + upsertKeySummary(summary) { + if (!summary || !summary.id) return; + const exists = this.keys.some(key => key.id === summary.id); + this.setKeys(exists + ? this.keys.map(key => key.id === summary.id ? summary : key) + : [...this.keys, summary]); + }, + refreshEmptyPanes() { if (typeof SessionManager !== 'undefined') { SessionManager.refreshEmptyPanes(); @@ -117,14 +172,14 @@ const ProfileManager = { const title = document.createElement('div'); title.className = 'profile-launcher-title'; title.textContent = profiles.length - ? (window.i18n ? i18n.t('connection.savedProfiles') : 'Saved Profiles') + ? (window.i18n ? i18n.t('connection.savedProfiles') : 'Saved Connections') : (window.i18n ? i18n.t('panes.emptyPane') : 'Empty pane'); empty.appendChild(title); const hint = document.createElement('div'); hint.className = 'profile-launcher-hint'; hint.textContent = profiles.length - ? (window.i18n ? i18n.t('connection.savedProfilesHint') : 'Choose a profile to connect') + ? (window.i18n ? i18n.t('connection.savedProfilesHint') : 'Choose a saved connection to connect') : (window.i18n ? i18n.t('panes.selectSession') : 'Select a session or open a connection'); empty.appendChild(hint); @@ -179,7 +234,7 @@ const ProfileManager = { : 'btn btn-primary profile-launcher-new'; newConnection.textContent = window.i18n ? i18n.t('connection.newConnection') - : 'New Connection'; + : 'Quick Connect'; newConnection.addEventListener('click', event => { event.stopPropagation(); window.openConnectionModalForPane?.(paneIndex); @@ -198,7 +253,7 @@ const ProfileManager = { placeholder.value = ''; placeholder.textContent = this.t( 'connection.selectProfile', - '-- Select Profile --', + '-- Select Saved Connection --', ); select.appendChild(placeholder); @@ -248,7 +303,7 @@ const ProfileManager = { return; } - container.innerHTML = ''; + container.replaceChildren(); this.keys.forEach(key => { const keyItem = document.createElement('div'); keyItem.className = 'key-item'; @@ -256,8 +311,23 @@ const ProfileManager = { const keyInfo = document.createElement('div'); keyInfo.className = 'key-info'; - const nameStrong = document.createElement('strong'); - nameStrong.textContent = key.name; + if (this.editingKeyId === key.id) { + const renameEditor = document.createElement('div'); + renameEditor.className = 'key-rename-editor'; + const input = document.createElement('input'); + input.type = 'text'; + input.className = 'form-control key-rename-input'; + input.dataset.keyId = key.id; + input.value = this.editingKeyName ?? key.name; + input.maxLength = 128; + input.disabled = this.keyRenamePending; + renameEditor.appendChild(input); + keyInfo.appendChild(renameEditor); + } else { + const nameStrong = document.createElement('strong'); + nameStrong.textContent = key.name; + keyInfo.appendChild(nameStrong); + } const typeSpan = document.createElement('span'); typeSpan.className = 'key-type'; @@ -267,25 +337,58 @@ const ProfileManager = { dateSpan.className = 'key-date'; dateSpan.textContent = `Uploaded: ${new Date(key.uploaded_at).toLocaleString()}`; - keyInfo.appendChild(nameStrong); keyInfo.appendChild(typeSpan); keyInfo.appendChild(dateSpan); - const deleteBtn = document.createElement('button'); - deleteBtn.className = 'btn btn-danger btn-sm'; - deleteBtn.dataset.keyId = key.id; - deleteBtn.textContent = 'Delete'; + const actions = document.createElement('div'); + actions.className = 'key-item-actions'; + const addAction = (action, label, style) => { + const button = document.createElement('button'); + button.type = 'button'; + button.className = `btn btn-sm ${style}`; + button.dataset.keyAction = action; + button.dataset.keyId = key.id; + button.textContent = label; + button.disabled = this.editingKeyId === key.id && this.keyRenamePending; + actions.appendChild(button); + return button; + }; + + if (this.editingKeyId === key.id) { + addAction( + 'save-rename', + this.t('keys.saveName', 'Save name'), + 'btn-primary', + ); + addAction( + 'cancel-rename', + this.t('common.cancel', 'Cancel'), + 'btn-secondary', + ); + } else { + const renameButton = addAction( + 'rename', + this.t('keys.rename', 'Rename'), + 'btn-secondary', + ); + renameButton.setAttribute( + 'aria-label', + this.t('keys.renameNamed', 'Rename {name}').replace('{name}', key.name), + ); + addAction('delete', this.t('common.delete', 'Delete'), 'btn-danger'); + } keyItem.appendChild(keyInfo); - keyItem.appendChild(deleteBtn); - - deleteBtn.addEventListener('click', (e) => { - const keyId = e.target.dataset.keyId; - this.deleteKey(keyId); - }); + keyItem.appendChild(actions); container.appendChild(keyItem); }); + + if (this.editingKeyId && !this.keyRenamePending) { + container.querySelector( + `.key-rename-input[data-key-id="${CSS.escape(this.editingKeyId)}"]` + )?.focus(); + } }, selectProfile(profileId) { @@ -399,7 +502,7 @@ const ProfileManager = { if (!this.profiles.length) { const empty = document.createElement('p'); empty.className = 'no-items'; - empty.textContent = this.t('profiles.none', 'No profiles saved.'); + empty.textContent = this.t('profiles.none', 'No saved connections.'); container.appendChild(empty); return; } @@ -513,6 +616,7 @@ const ProfileManager = { this.renderEditorSelects(); document.getElementById('profileEditorForm')?.reset(); + this.setInlineKeyPanelExpanded(false); document.getElementById('profileEditorId').value = profile?.id || ''; document.getElementById('profileEditorName').value = profile?.name || ''; document.getElementById('profileEditorHost').value = profile?.host || ''; @@ -653,7 +757,7 @@ const ProfileManager = { window.socket.emit('save_profile', payload, acknowledgement => { if (!acknowledgement?.success) { window.showNotification?.( - acknowledgement?.error || this.t('profiles.saveFailed', 'Failed to save profile'), + acknowledgement?.error || this.t('profiles.saveFailed', 'Failed to save connection'), 'error', ); return; @@ -669,8 +773,7 @@ const ProfileManager = { }, connect(profileId) { - window.ModalManager?.close(document.getElementById('profileManagementModal')); - window.openConnectionModalForProfile?.(profileId); + window.launchProfileForPane?.(profileId); }, saveProfile(profileData) { @@ -680,20 +783,121 @@ const ProfileManager = { }, deleteProfile(profileId) { - if (confirm('Are you sure you want to delete this profile?')) { + if (confirm('Are you sure you want to delete this saved connection?')) { if (window.socket) { window.socket.emit('delete_profile', { profile_id: profileId }); } } }, - uploadKey(name, keyContent) { - if (window.socket) { - window.socket.emit('upload_key', { - name: name, - key_content: keyContent - }); + setInlineKeyPanelExpanded(expanded) { + const panel = document.getElementById('profileEditorAddKeyPanel'); + const button = document.getElementById('profileEditorAddKeyBtn'); + panel?.classList.toggle('hidden', !expanded); + button?.setAttribute('aria-expanded', String(expanded)); + if (!expanded) { + const status = document.getElementById('profileEditorKeyUploadStatus'); + if (status) { + status.textContent = ''; + status.classList.remove('error'); + } + } + }, + + submitInlineKeyUpload() { + if (this.inlineKeyUploadPending) return; + const nameInput = document.getElementById('profileEditorNewKeyName'); + const contentInput = document.getElementById('profileEditorNewKeyContent'); + const submitButton = document.getElementById('profileEditorUploadKeyBtn'); + const status = document.getElementById('profileEditorKeyUploadStatus'); + const name = nameInput?.value.trim() || ''; + const keyContent = contentInput?.value || ''; + if (!name || !keyContent) { + if (status) { + status.textContent = 'Key name and content are required'; + status.classList.add('error'); + } + return; + } + + this.inlineKeyUploadPending = true; + if (submitButton) submitButton.disabled = true; + if (status) { + status.textContent = `${this.t('keys.uploadKey', 'Upload Key')}…`; + status.classList.remove('error'); } + this.uploadKey(name, keyContent, acknowledgement => { + this.inlineKeyUploadPending = false; + if (submitButton) submitButton.disabled = false; + if (!acknowledgement?.success || !acknowledgement.key) { + if (status) { + status.textContent = acknowledgement?.error || 'Failed to upload key'; + status.classList.add('error'); + } + return; + } + + this.upsertKeySummary(acknowledgement.key); + if (nameInput) nameInput.value = ''; + if (contentInput) contentInput.value = ''; + this.setInlineKeyPanelExpanded(false); + const select = document.getElementById('profileEditorKeySelect'); + if (select) { + select.value = acknowledgement.key.id; + select.focus(); + } + }); + }, + + uploadKey(name, keyContent, callback = null) { + if (!window.socket) { + callback?.({success: false, error: 'Connection unavailable'}); + return; + } + window.socket.emit('upload_key', { + name: name, + key_content: keyContent + }, acknowledgement => callback?.(acknowledgement)); + }, + + beginKeyRename(keyId) { + if (this.keyRenamePending || !this.keys.some(key => key.id === keyId)) return; + this.editingKeyId = keyId; + this.editingKeyName = this.keys.find(key => key.id === keyId).name; + this.renderKeysList(); + }, + + cancelKeyRename() { + if (this.keyRenamePending) return; + this.editingKeyId = null; + this.editingKeyName = null; + this.renderKeysList(); + }, + + submitKeyRename(keyId, name) { + if (this.keyRenamePending || keyId !== this.editingKeyId || !window.socket) return; + this.editingKeyName = name; + this.keyRenamePending = true; + this.renderKeysList(); + window.socket.emit('rename_key', { + key_id: keyId, + name: name, + }, acknowledgement => { + this.keyRenamePending = false; + if (!acknowledgement?.success || !acknowledgement.key) { + window.showNotification?.( + acknowledgement?.error || this.t( + 'keys.renameFailed', 'Failed to rename key' + ), + 'error', + ); + this.renderKeysList(); + return; + } + this.editingKeyId = null; + this.editingKeyName = null; + this.upsertKeySummary(acknowledgement.key); + }); }, deleteKey(keyId) { diff --git a/static/js/session-manager.js b/static/js/session-manager.js index 8232c98..359414b 100644 --- a/static/js/session-manager.js +++ b/static/js/session-manager.js @@ -1230,8 +1230,8 @@ const SessionManager = { const newOption = this.createPaneOption( paneIndex, '__new__', - window.i18n ? window.i18n.t('panes.newConnection') : '+ New Connection', - window.i18n ? window.i18n.t('panes.newConnectionDesc') : 'Open connection dialog for this pane', + window.i18n ? window.i18n.t('panes.newConnection') : '+ Quick Connect', + window.i18n ? window.i18n.t('panes.newConnectionDesc') : 'Open Quick Connect for this pane', false ); optionsContainer.appendChild(newOption); diff --git a/static/js/sftp-file-manager.js b/static/js/sftp-file-manager.js index ee68c12..743a4f6 100644 --- a/static/js/sftp-file-manager.js +++ b/static/js/sftp-file-manager.js @@ -120,7 +120,7 @@ class SFTPFileManager {