From 72ffb3d73fb98b74ac782ee68dc83185d944a6a4 Mon Sep 17 00:00:00 2001 From: bifrost0x Date: Mon, 3 Aug 2026 08:34:38 +0200 Subject: [PATCH 01/10] feat: add atomic SSH key rename --- app/key_manager.py | 24 ++++++++ tests/test_key_manager.py | 121 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 145 insertions(+) diff --git a/app/key_manager.py b/app/key_manager.py index f44aa5a..94158df 100644 --- a/app/key_manager.py +++ b/app/key_manager.py @@ -291,6 +291,30 @@ 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 + updated = {**key, 'name': name} + replacement = [*keys] + replacement[index] = updated + if not save_keys(user_id, replacement): + return None, "Failed to rename key" + return 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/tests/test_key_manager.py b/tests/test_key_manager.py index 1823b79..42e46fd 100644 --- a/tests/test_key_manager.py +++ b/tests/test_key_manager.py @@ -18,6 +18,127 @@ def create_user(app, username='key-user'): return user.id +def test_rename_key_changes_only_owned_metadata_name( + app, rsa_private_key_pem): + from app import key_manager + + owner_id = create_user(app, 'rename-owner') + other_id = create_user(app, 'rename-other') + with app.app_context(): + key, error = key_manager.save_key( + owner_id, 'Before', rsa_private_key_pem + ) + assert error is None + key_path = Path(key_manager.get_key_path(owner_id, key['id'])) + encrypted_before = key_path.read_bytes() + metadata_before = dict(key) + + updated, error = key_manager.rename_key( + owner_id, key['id'], ' After ' + ) + + assert error is None + assert updated == {**metadata_before, 'name': 'After'} + assert key_path.read_bytes() == encrypted_before + assert key_manager.load_keys(owner_id) == [updated] + missing, error = key_manager.rename_key( + other_id, key['id'], 'Stolen' + ) + assert missing is None + assert error == 'Key not found' + + +def test_rename_key_allows_duplicate_display_names( + app, rsa_private_key_pem): + from app import key_manager + + user_id = create_user(app, 'rename-duplicate') + with app.app_context(): + first, error = key_manager.save_key( + user_id, 'Shared', rsa_private_key_pem + ) + assert error is None + second, error = key_manager.save_key( + user_id, 'Other', rsa_private_key_pem + ) + assert error is None + + updated, error = key_manager.rename_key( + user_id, second['id'], 'Shared' + ) + + assert error is None + assert updated['id'] == second['id'] + assert [ + key['name'] for key in key_manager.load_keys(user_id) + ] == [first['name'], 'Shared'] + + +@pytest.mark.parametrize('value', [None, 7, '', ' ', 'x' * 129]) +def test_rename_key_rejects_invalid_names_without_writing( + app, rsa_private_key_pem, value): + from app import key_manager + + user_id = create_user(app, 'invalid-rename') + with app.app_context(): + key, error = key_manager.save_key( + user_id, 'Original', rsa_private_key_pem + ) + assert error is None + metadata_path = key_manager.get_user_keys_file(user_id) + before = metadata_path.read_bytes() + + updated, error = key_manager.rename_key( + user_id, key['id'], value + ) + + assert updated is None + assert error in { + 'Invalid key name', + 'Key name too long (max 128 characters)', + } + assert metadata_path.read_bytes() == before + + +def test_rename_key_write_failure_preserves_metadata( + app, monkeypatch, rsa_private_key_pem): + from app import key_manager + + user_id = create_user(app, 'rename-write-failure') + with app.app_context(): + key, error = key_manager.save_key( + user_id, 'Original', rsa_private_key_pem + ) + assert error is None + metadata_path = key_manager.get_user_keys_file(user_id) + before = metadata_path.read_bytes() + monkeypatch.setattr(key_manager, 'save_keys', lambda *_args: False) + + updated, error = key_manager.rename_key( + user_id, key['id'], 'After' + ) + + assert updated is None + assert error == 'Failed to rename key' + assert metadata_path.read_bytes() == before + + +def test_rename_key_preserves_corrupt_metadata(app): + from app import key_manager + + user_id = create_user(app, 'rename-corrupt') + with app.app_context(): + metadata_path = key_manager.get_user_keys_file(user_id) + metadata_path.parent.mkdir(parents=True, exist_ok=True) + metadata_path.write_text('{broken', encoding='utf-8') + before = metadata_path.read_bytes() + + with pytest.raises(StorageCorruptionError): + key_manager.rename_key(user_id, 'missing', 'After') + + assert metadata_path.read_bytes() == before + + @pytest.mark.parametrize( ('fixture_name', 'expected'), [ From 865ee1e83977ed629c30b93ffd20275153edad5b Mon Sep 17 00:00:00 2001 From: bifrost0x Date: Mon, 3 Aug 2026 08:39:29 +0200 Subject: [PATCH 02/10] feat: add SSH key mutation acknowledgements --- app/audit_logger.py | 10 ++ app/socket_events.py | 70 ++++++++++--- tests/test_key_socket_events.py | 172 ++++++++++++++++++++++++++++++++ 3 files changed, 238 insertions(+), 14 deletions(-) create mode 100644 tests/test_key_socket_events.py 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/socket_events.py b/app/socket_events.py index 7409272..2a0faec 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,71 @@ 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: - emit('error', {'error': 'Failed to upload key'}) + 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 {} + existing = key_manager.get_key(current_user.id, data.get('key_id')) + updated, error = key_manager.rename_key( + current_user.id, + data.get('key_id'), + data.get('name'), + ) + if error: + return _key_mutation_error(error) + if existing is None: + return _key_mutation_error('Key not found') + + log_key_rename( + current_user.username, + existing['name'], + updated['name'], + request.remote_addr, + ) + payload = {'success': True, 'key': updated} + 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: + return _key_mutation_error('Failed to rename key') @socketio.on('delete_key') @socket_login_required diff --git a/tests/test_key_socket_events.py b/tests/test_key_socket_events.py new file mode 100644 index 0000000..9c74524 --- /dev/null +++ b/tests/test_key_socket_events.py @@ -0,0 +1,172 @@ +"""Socket contracts for safe SSH key upload and metadata rename.""" + +from flask import request + + +def create_socket_user(app, username): + from app.auth import register_socket_session, register_user + from app.models import db + + with app.app_context(): + user, error = register_user(username, 'socket-password-123') + assert error is None + sid = f'{username}-socket' + register_socket_session(user.id, sid) + db.session.commit() + return user.id, sid + + +def call_socket_handler(app, monkeypatch, handler, sid, payload): + import app.socket_events as socket_events + + emitted = [] + monkeypatch.setattr( + socket_events, + 'emit', + lambda event, data=None, **_kwargs: emitted.append((event, data)), + ) + with app.test_request_context('/socket.io'): + request.sid = sid + acknowledgement = handler(payload) + return acknowledgement, emitted + + +def test_key_upload_and_rename_return_safe_acknowledgements( + app, monkeypatch, rsa_private_key_pem): + from app import key_manager + import app.socket_events as socket_events + + user_id, sid = create_socket_user(app, 'key_ack_owner') + uploaded, emitted = call_socket_handler( + app, + monkeypatch, + socket_events.handle_upload_key, + sid, + {'name': 'Initial', 'key_content': rsa_private_key_pem}, + ) + + assert uploaded['success'] is True + assert uploaded['key']['name'] == 'Initial' + assert 'key_content' not in repr(uploaded) + assert any(event == 'keys_list' for event, _payload in emitted) + + renamed, emitted = call_socket_handler( + app, + monkeypatch, + socket_events.handle_rename_key, + sid, + {'key_id': uploaded['key']['id'], 'name': 'Renamed'}, + ) + + assert renamed == { + 'success': True, + 'key': {**uploaded['key'], 'name': 'Renamed'}, + } + assert any(event == 'key_renamed' for event, _payload in emitted) + with app.app_context(): + assert key_manager.load_keys(user_id)[0]['name'] == 'Renamed' + + +def test_key_upload_rejection_never_returns_private_input( + app, monkeypatch): + import app.socket_events as socket_events + + _user_id, sid = create_socket_user(app, 'key_ack_rejected') + rejected, emitted = call_socket_handler( + app, + monkeypatch, + socket_events.handle_upload_key, + sid, + {'name': '', 'key_content': 'private-input-sentinel'}, + ) + + assert rejected == { + 'success': False, + 'error': 'Name and key content required', + } + assert 'private-input-sentinel' not in repr(rejected) + assert 'private-input-sentinel' not in repr(emitted) + + +def test_key_rename_rejects_unknown_and_cross_user_ids( + app, monkeypatch, rsa_private_key_pem): + from app import key_manager + import app.socket_events as socket_events + + _owner_id, owner_sid = create_socket_user(app, 'key_ack_owned') + foreign_user_id, _foreign_sid = create_socket_user( + app, 'key_ack_foreign' + ) + with app.app_context(): + foreign_key, error = key_manager.save_key( + foreign_user_id, + 'Foreign', + rsa_private_key_pem, + ) + assert error is None + + for key_id in ('not-owned', foreign_key['id']): + acknowledgement, emitted = call_socket_handler( + app, + monkeypatch, + socket_events.handle_rename_key, + owner_sid, + {'key_id': key_id, 'name': 'Cross-user'}, + ) + assert acknowledgement == { + 'success': False, + 'error': 'Key not found', + } + assert all(event != 'key_renamed' for event, _payload in emitted) + + with app.app_context(): + assert key_manager.load_keys(foreign_user_id)[0]['name'] == 'Foreign' + + +def test_key_rename_translates_storage_corruption_without_overwriting( + app, monkeypatch, rsa_private_key_pem): + from app import key_manager + import app.socket_events as socket_events + + user_id, sid = create_socket_user(app, 'key_ack_corrupt') + with app.app_context(): + key, error = key_manager.save_key( + user_id, 'Initial', rsa_private_key_pem + ) + assert error is None + metadata_path = key_manager.get_user_keys_file(user_id) + metadata_path.write_text('{broken', encoding='utf-8') + before = metadata_path.read_bytes() + + corrupt, emitted = call_socket_handler( + app, + monkeypatch, + socket_events.handle_rename_key, + sid, + {'key_id': key['id'], 'name': 'After corruption'}, + ) + + assert corrupt['success'] is False + assert corrupt['code'] == 'storage_error' + assert any(event == 'error' for event, _payload in emitted) + with app.app_context(): + assert metadata_path.read_bytes() == before + + +def test_key_rename_audit_sanitizes_names(monkeypatch): + from app import audit_logger + + messages = [] + monkeypatch.setattr(audit_logger.audit_logger, 'info', messages.append) + + audit_logger.log_key_rename( + 'admin\nforged', + 'before\rsecret', + 'after\nsecret', + '127.0.0.1\nforged', + ) + + assert len(messages) == 1 + assert messages[0].startswith('KEY_RENAME | ') + assert '\n' not in messages[0] + assert '\r' not in messages[0] From 5e046b9fe687c7ff2f1ce2a7d433de76eb8359f3 Mon Sep 17 00:00:00 2001 From: bifrost0x Date: Mon, 3 Aug 2026 08:42:05 +0200 Subject: [PATCH 03/10] feat: add saved connection launcher --- static/js/connection-launcher.js | 83 +++++++++++++++ static/js/profile-launcher-utils.js | 104 +++++++++++++++++- tests/js/connection-launcher.test.js | 123 ++++++++++++++++++++++ tests/js/profile-launcher-utils.test.js | 133 +++++++++++++++++++++++- 4 files changed, 440 insertions(+), 3 deletions(-) create mode 100644 static/js/connection-launcher.js create mode 100644 tests/js/connection-launcher.test.js 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/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/tests/js/connection-launcher.test.js b/tests/js/connection-launcher.test.js new file mode 100644 index 0000000..3053fb5 --- /dev/null +++ b/tests/js/connection-launcher.test.js @@ -0,0 +1,123 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { + createConnectionLauncher, +} = require('../../static/js/connection-launcher.js'); + +function profile(overrides = {}) { + return { + id: 'profile-1', + host: 'server.example', + port: 22, + username: 'alice', + auth_type: 'key', + key_id: 'target-key', + ...overrides, + }; +} + +function dependencies(overrides = {}) { + const profiles = new Map([['profile-1', profile()]]); + return { + getProfile: id => profiles.get(id), + getContext: () => ({ + keys: [{ id: 'target-key', usable: true }], + jumpHosts: [], + }), + getDefaultPaneIndex: () => 4, + isBusy: () => false, + startConnection: () => true, + openReview: () => {}, + notify: () => {}, + refreshProfiles: () => {}, + ...overrides, + }; +} + +test('launches a complete profile directly without opening review', () => { + const calls = []; + const launcher = createConnectionLauncher(dependencies({ + startConnection: (data, pane) => { + calls.push(['connect', data, pane]); + return true; + }, + openReview: (...args) => calls.push(['review', ...args]), + })); + + assert.equal(launcher.launch('profile-1', 2), 'connect'); + assert.equal(calls.length, 1); + assert.equal(calls[0][0], 'connect'); + assert.equal(calls[0][2], 2); + assert.equal(calls[0][1].key_id, 'target-key'); +}); + +test('uses the default pane and opens review for interactive profiles', () => { + const calls = []; + const launcher = createConnectionLauncher(dependencies({ + getProfile: () => profile({ auth_type: 'password', key_id: null }), + startConnection: (...args) => calls.push(['connect', ...args]), + openReview: (...args) => calls.push(['review', ...args]), + })); + + assert.equal(launcher.launch('profile-1'), 'review'); + assert.deepEqual(calls, [['review', 'profile-1', 4, 'password']]); +}); + +test('rejects busy and missing profiles without starting a connection', () => { + const busyCalls = []; + const busy = createConnectionLauncher(dependencies({ + isBusy: () => true, + startConnection: (...args) => busyCalls.push(['connect', ...args]), + openReview: (...args) => busyCalls.push(['review', ...args]), + notify: (...args) => busyCalls.push(['notify', ...args]), + })); + assert.equal(busy.launch('profile-1'), 'rejected'); + assert.deepEqual(busyCalls.map(call => call[0]), ['notify']); + + const missingCalls = []; + const missing = createConnectionLauncher(dependencies({ + getProfile: () => null, + startConnection: (...args) => missingCalls.push(['connect', ...args]), + openReview: (...args) => missingCalls.push(['review', ...args]), + notify: (...args) => missingCalls.push(['notify', ...args]), + refreshProfiles: () => missingCalls.push(['refresh']), + })); + assert.equal(missing.launch('missing'), 'rejected'); + assert.deepEqual( + missingCalls.map(call => call[0]), + ['notify', 'refresh'], + ); +}); + +test('falls back to review when strict request construction rejects data', () => { + const calls = []; + const launcher = createConnectionLauncher(dependencies({ + getProfile: () => profile({ host: ' ' }), + startConnection: (...args) => calls.push(['connect', ...args]), + openReview: (...args) => calls.push(['review', ...args]), + })); + + assert.equal(launcher.launch('profile-1', 1), 'review'); + assert.deepEqual(calls, [['review', 'profile-1', 1, 'review']]); +}); + +test('validates every required dependency', () => { + for (const name of [ + 'getProfile', + 'getContext', + 'getDefaultPaneIndex', + 'isBusy', + 'startConnection', + 'openReview', + 'notify', + 'refreshProfiles', + ]) { + const deps = dependencies(); + delete deps[name]; + assert.throws( + () => createConnectionLauncher(deps), + new RegExp(`requires ${name}\\(\\)`), + ); + } +}); diff --git a/tests/js/profile-launcher-utils.test.js b/tests/js/profile-launcher-utils.test.js index 911ca55..f95e636 100644 --- a/tests/js/profile-launcher-utils.test.js +++ b/tests/js/profile-launcher-utils.test.js @@ -2,6 +2,7 @@ const test = require('node:test'); const assert = require('node:assert/strict'); const { + buildDirectConnectionData, determineLaunchMode, formatEndpoint, } = require('../../static/js/profile-launcher-utils.js'); @@ -11,8 +12,22 @@ const keys = [ { id: 'jump-key', usable: true }, ]; const jumpHosts = [ - { id: 'jump-with-key', auth_type: 'key', key_id: 'jump-key' }, - { id: 'jump-with-password', auth_type: 'password', key_id: null }, + { + id: 'jump-with-key', + host: 'jump.example', + port: 2222, + username: 'jumper', + auth_type: 'key', + key_id: 'jump-key', + }, + { + id: 'jump-with-password', + host: 'jump.example', + port: 22, + username: 'jumper', + auth_type: 'password', + key_id: null, + }, ]; function profile(overrides = {}) { @@ -28,6 +43,120 @@ function profile(overrides = {}) { }; } +test('builds a complete key profile request without mutating inputs', () => { + const candidate = profile({ + username: ' alice ', + use_tmux: true, + startup_mode: 'command', + command_id: 'command-1', + parameters_override: '', + }); + const before = structuredClone(candidate); + + assert.deepEqual( + buildDirectConnectionData(candidate, { keys, jumpHosts }), + { + host: 'server.example', + port: 22, + username: 'alice', + auth_type: 'key', + key_id: 'target-key', + use_tmux: true, + startup_mode: 'command', + command_id: 'command-1', + parameters_override: '', + }, + ); + assert.deepEqual(candidate, before); +}); + +test('builds Tailscale, jump-host, and legacy post-connect payloads', () => { + assert.deepEqual(buildDirectConnectionData(profile({ + auth_type: 'tailscale', + key_id: null, + tailscale_authorized: true, + }), { keys, jumpHosts }), { + host: 'server.example', + port: 22, + username: 'deploy', + auth_type: 'tailscale', + startup_mode: 'none', + }); + + assert.deepEqual(buildDirectConnectionData(profile({ + jump_host_id: 'jump-with-key', + startup_mode: 'free_text', + startup_commands: 'uptime\nwhoami', + }), { keys, jumpHosts }), { + host: 'server.example', + port: 22, + username: 'deploy', + auth_type: 'key', + key_id: 'target-key', + startup_mode: 'free_text', + startup_commands: 'uptime\nwhoami', + proxy_jump: { + jump_host_id: 'jump-with-key', + host: 'jump.example', + port: 2222, + username: 'jumper', + auth_type: 'key', + key_id: 'jump-key', + }, + }); + + assert.deepEqual(buildDirectConnectionData(profile({ + command_set_id: 'set-1', + }), { keys, jumpHosts }), { + host: 'server.example', + port: 22, + username: 'deploy', + auth_type: 'key', + key_id: 'target-key', + startup_mode: 'command_set', + command_set_id: 'set-1', + }); +}); + +test('direct request construction fails closed for incomplete data', () => { + const invalidProfiles = [ + profile({ auth_type: 'password', key_id: null }), + profile({ key_id: 'missing' }), + profile({ jump_host_id: 'jump-with-password' }), + profile({ jump_host_id: 'missing' }), + profile({ host: ' ' }), + profile({ username: '' }), + profile({ port: 0 }), + profile({ port: 65536 }), + profile({ startup_mode: 'command', command_id: null }), + profile({ startup_mode: 'command_set', command_set_id: null }), + profile({ startup_mode: 'unknown' }), + profile({ + auth_type: 'tailscale', + key_id: null, + tailscale_authorized: false, + }), + ]; + + for (const candidate of invalidProfiles) { + assert.equal( + buildDirectConnectionData(candidate, { keys, jumpHosts }), + null, + ); + } + + const unusableJumpContext = { + keys, + jumpHosts: [{ + ...jumpHosts[0], + key_id: 'missing', + }], + }; + assert.equal(buildDirectConnectionData(profile({ + jump_host_id: 'jump-with-key', + }), unusableJumpContext), null); +}); + test('key and Tailscale profiles connect when every reference is available', () => { assert.equal(determineLaunchMode(profile(), { keys, jumpHosts }), 'connect'); assert.equal(determineLaunchMode(profile({ From cb78eda97fac5faaec3259396e84c358a8bb05de Mon Sep 17 00:00:00 2001 From: bifrost0x Date: Mon, 3 Aug 2026 08:48:32 +0200 Subject: [PATCH 04/10] feat: launch saved connections without modal flash --- static/js/app.js | 145 +++++++++++++------------ static/js/profile-manager.js | 3 +- templates/index.html | 7 +- tests/e2e/profile-launcher.spec.js | 23 +++- tests/test_profile_launcher_ui.py | 48 ++++---- tests/test_startup_command_ui_state.py | 4 +- 6 files changed, 133 insertions(+), 97 deletions(-) diff --git a/static/js/app.js b/static/js/app.js index a4c9ebe..2b78488 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -1036,6 +1036,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 +1142,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 +1157,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 +1197,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 +2005,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 +2029,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 +2049,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/profile-manager.js b/static/js/profile-manager.js index f214db2..58ba9c2 100644 --- a/static/js/profile-manager.js +++ b/static/js/profile-manager.js @@ -669,8 +669,7 @@ const ProfileManager = { }, connect(profileId) { - window.ModalManager?.close(document.getElementById('profileManagementModal')); - window.openConnectionModalForProfile?.(profileId); + window.launchProfileForPane?.(profileId); }, saveProfile(profileData) { diff --git a/templates/index.html b/templates/index.html index 51ec00d..58250a8 100644 --- a/templates/index.html +++ b/templates/index.html @@ -850,13 +850,14 @@

File Preview

- + + - + @@ -864,7 +865,7 @@

File Preview

- + - + @@ -857,7 +875,7 @@

File Preview

- + @@ -865,7 +883,7 @@

File Preview

- + - + diff --git a/tests/test_i18n_parity.py b/tests/test_i18n_parity.py index 5d3901d..d23aa16 100644 --- a/tests/test_i18n_parity.py +++ b/tests/test_i18n_parity.py @@ -2,6 +2,16 @@ from pathlib import Path +TERMINOLOGY = { + 'en': ('Quick Connect', 'Saved Connections'), + 'vi': ('Kết nối nhanh', 'Kết nối đã lưu'), + 'de': ('Schnellverbindung', 'Gespeicherte Verbindungen'), + 'fr': ('Connexion rapide', 'Connexions enregistrées'), + 'es': ('Conexión rápida', 'Conexiones guardadas'), + 'zh': ('快速连接', '已保存的连接'), +} + + def test_all_locales_have_matching_translation_keys(): source = Path('static/js/i18n.js').read_text(encoding='utf-8') locale_starts = list(re.finditer(r'^ (en|vi|de|fr|es|zh): \{$', source, re.MULTILINE)) @@ -48,6 +58,35 @@ def test_all_locales_have_matching_translation_keys(): ) +def test_saved_connection_and_quick_connect_terms_are_consistent(): + source = Path('static/js/i18n.js').read_text(encoding='utf-8') + locale_starts = list( + re.finditer(r'^ (en|vi|de|fr|es|zh): \{$', source, re.MULTILINE) + ) + quick_keys = { + 'connection.newConnection', + 'connection.newSSHConnection', + 'shortcuts.newConnection', + } + saved_keys = {'connection.savedProfiles', 'profiles.manage'} + + for index, match in enumerate(locale_starts): + end = ( + locale_starts[index + 1].start() + if index + 1 < len(locale_starts) + else source.index('\n};', match.end()) + ) + values = dict(re.findall( + r"^ '([^']+)': '([^']*)',$", + source[match.end():end], + re.MULTILINE, + )) + quick_connect, saved_connections = TERMINOLOGY[match.group(1)] + assert {values[key] for key in quick_keys} == {quick_connect} + assert {values[key] for key in saved_keys} == {saved_connections} + assert values['panes.newConnection'] == f'+ {quick_connect}' + + def test_all_locales_preserve_translation_placeholders(): source = Path('static/js/i18n.js').read_text(encoding='utf-8') locale_starts = list( diff --git a/tests/test_profile_launcher_ui.py b/tests/test_profile_launcher_ui.py index b8b95f4..9049621 100644 --- a/tests/test_profile_launcher_ui.py +++ b/tests/test_profile_launcher_ui.py @@ -24,16 +24,17 @@ def test_template_has_one_empty_pane_renderer_and_loads_launcher_utility_first() def test_merged_profile_frontend_assets_have_distinct_cache_versions(): template = read('templates/index.html') expected_versions = { - "filename='css/style.css'": '?v=4', - "filename='js/i18n.js'": '?v=1', + "filename='css/style.css'": '?v=5', + "filename='js/i18n.js'": '?v=2', "filename='js/command-workspace.js'": '?v=2', "filename='js/profile-launcher-utils.js'": '?v=2', "filename='js/connection-launcher.js'": '?v=1', - "filename='js/profile-manager.js'": '?v=5', + "filename='js/profile-manager.js'": '?v=6', + "filename='js/session-manager.js'": '?v=4', "filename='js/jump-host-manager.js'": '?v=4', "filename='js/command-library.js'": '?v=3', "filename='js/command-set-manager.js'": '?v=2', - "filename='js/app.js'": '?v=5', + "filename='js/app.js'": '?v=6', } for asset, version in expected_versions.items(): asset_start = template.index(asset) @@ -120,7 +121,7 @@ def test_mobile_launcher_stacks_status_below_profile_details(): def test_profile_launcher_stylesheet_uses_current_cache_version(): template = read('templates/index.html') - assert "filename='css/style.css') }}?v=4" in template + assert "filename='css/style.css') }}?v=5" in template def test_profile_launch_uses_shared_executor_and_review_callback(): @@ -185,3 +186,23 @@ def test_profile_management_connect_uses_only_the_central_launcher(): source = read('static/js/profile-manager.js') assert 'window.launchProfileForPane?.(profileId)' in source assert 'openConnectionModalForProfile' not in source + + +def test_visible_connection_copy_uses_saved_connections_and_quick_connect(): + template = read('templates/index.html') + profiles = read('static/js/profile-manager.js') + sessions = read('static/js/session-manager.js') + affected_source = '\n'.join((template, profiles, sessions)) + + for obsolete in ( + 'Saved Profiles', + 'Choose a profile to connect', + 'New SSH Connection', + '+ New Connection', + ): + assert obsolete not in affected_source + + assert '>Quick Connect<' in template + assert '>Saved Connections<' in template + assert ": 'Quick Connect';" in profiles + assert ": '+ Quick Connect'," in sessions From aab34d2c391f6c24db88743a354924e2996999cf Mon Sep 17 00:00:00 2001 From: bifrost0x Date: Mon, 3 Aug 2026 09:11:46 +0200 Subject: [PATCH 07/10] test: cover saved connection browser workflows --- static/js/profile-manager.js | 2 + tests/e2e/helpers.js | 75 +++++++++++++- tests/e2e/profile-launcher.spec.js | 159 ++++++++++++++++++++++++++--- tests/e2e/run_app.py | 21 +++- 4 files changed, 242 insertions(+), 15 deletions(-) diff --git a/static/js/profile-manager.js b/static/js/profile-manager.js index 2a4683e..da90429 100644 --- a/static/js/profile-manager.js +++ b/static/js/profile-manager.js @@ -82,10 +82,12 @@ const ProfileManager = { 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(); } }); diff --git a/tests/e2e/helpers.js b/tests/e2e/helpers.js index 6de89a0..80a153f 100644 --- a/tests/e2e/helpers.js +++ b/tests/e2e/helpers.js @@ -72,6 +72,58 @@ async function installSshConnectTrap(page) { }); } +async function observeConnectionModal(page) { + await page.evaluate(() => { + window.__connectionModalShows = 0; + window.__connectionModalObserver?.disconnect(); + const modal = document.getElementById('connectionModal'); + window.__connectionModalObserver = new MutationObserver(() => { + if (modal.classList.contains('show')) { + window.__connectionModalShows += 1; + } + }); + window.__connectionModalObserver.observe(modal, { + attributes: true, + attributeFilter: ['class'], + }); + }); +} + +async function installKeyUploadTrap(page) { + await page.evaluate(() => { + if (window.__keyUploadOriginalEmit) { + window.socket.emit = window.__keyUploadOriginalEmit; + } + const originalEmit = window.socket.emit.bind(window.socket); + window.__keyUploadOriginalEmit = originalEmit; + window.__keyUploadAttempts = []; + window.socket.emit = function wrappedEmit(event, ...args) { + if (event !== 'upload_key') { + return originalEmit(event, ...args); + } + const acknowledgement = typeof args.at(-1) === 'function' + ? args.pop() + : null; + const payload = structuredClone(args[0]); + window.__keyUploadAttempts.push(payload); + queueMicrotask(() => acknowledgement?.({ + success: true, + key: { + id: 'e2e-inline-key', + name: payload.name, + key_type: 'ED25519', + uploaded_at: new Date().toISOString(), + }, + })); + return window.socket; + }; + }); +} + +async function keyUploadAttempts(page) { + return page.evaluate(() => window.__keyUploadAttempts || []); +} + async function sshAttempts(page) { return page.evaluate(() => window.__sshConnectAttempts || []); } @@ -86,12 +138,29 @@ async function restoreSshConnect(page) { }); } +async function openResponsiveHeader(page, target) { + if (await target.isVisible()) return; + await page.locator('#mobileMenuBtn').click(); + await expect(target).toBeVisible(); +} + async function openProfileManagement(page) { - await page.locator('#manageProfilesBtn').click(); + const trigger = page.locator('#manageProfilesBtn'); + await openResponsiveHeader(page, trigger); + await trigger.click(); await expect(page.locator('#profileManagementModal')).toHaveClass(/show/); await expect(page.locator('#profileManagementView')).not.toHaveClass(/hidden/); } +async function openKeyManagement(page) { + const accountButton = page.locator('#accountBtnHeader'); + await openResponsiveHeader(page, accountButton); + await accountButton.click(); + await page.locator('#accountConnectionsToggle').click(); + await page.locator('#manageKeysBtn').click(); + await expect(page.locator('#keyManagementModal')).toHaveClass(/show/); +} + async function launchProfile(page, name) { const exactName = new RegExp(`^${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`); const card = page.locator('.profile-launcher-card').filter({ @@ -108,10 +177,14 @@ async function assertNoExternalRequests(page) { module.exports = { assertNoExternalRequests, installExternalRequestGuard, + installKeyUploadTrap, installSshConnectTrap, + keyUploadAttempts, launchProfile, login, + openKeyManagement, openProfileManagement, + observeConnectionModal, restoreSshConnect, sshAttempts, }; diff --git a/tests/e2e/profile-launcher.spec.js b/tests/e2e/profile-launcher.spec.js index 5797ec3..e7f9989 100644 --- a/tests/e2e/profile-launcher.spec.js +++ b/tests/e2e/profile-launcher.spec.js @@ -1,10 +1,14 @@ const { test, expect } = require('playwright/test'); const { assertNoExternalRequests, + installKeyUploadTrap, installSshConnectTrap, + keyUploadAttempts, launchProfile, login, + openKeyManagement, openProfileManagement, + observeConnectionModal, sshAttempts, } = require('./helpers'); @@ -72,19 +76,7 @@ test('only auto-connects profiles whose credentials and references are currently await expect.poll(() => sshAttempts(page)).toHaveLength(0); await page.locator('#cancelConnectionBtn').click(); - await page.evaluate(() => { - window.__connectionModalShows = 0; - const modal = document.getElementById('connectionModal'); - window.__connectionModalObserver = new MutationObserver(() => { - if (modal.classList.contains('show')) { - window.__connectionModalShows += 1; - } - }); - window.__connectionModalObserver.observe(modal, { - attributes: true, - attributeFilter: ['class'], - }); - }); + await observeConnectionModal(page); await launchProfile(page, 'Usable key'); await expect.poll(() => sshAttempts(page)).toHaveLength(1); await expect.poll(() => page.evaluate( @@ -170,6 +162,101 @@ test('an unauthorized Tailscale profile opens for review without an SSH attempt' await expect(page.locator('#connectBtn')).toBeFocused(); }); +test('key jump hosts and management actions launch directly without Quick Connect', async ({ page }) => { + await observeConnectionModal(page); + await launchProfile(page, 'Key jump host'); + await expect.poll(() => sshAttempts(page)).toHaveLength(1); + await expect.poll(() => page.evaluate(() => window.__connectionModalShows)).toBe(0); + expect((await sshAttempts(page))[0].payload).toMatchObject({ + host: 'jump-target.local', + auth_type: 'key', + proxy_jump: { + host: 'jump.local', + username: 'jumpuser', + auth_type: 'key', + }, + }); + + await expect(page.locator('.profile-launcher-name', { hasText: 'Usable key' })).toBeVisible(); + await openProfileManagement(page); + const usable = page.locator('.profile-management-item').filter({ hasText: 'Usable key' }); + await usable.locator('[data-profile-action="connect"]').click(); + await expect.poll(() => sshAttempts(page)).toHaveLength(2); + await expect(page.locator('#profileManagementModal')).not.toHaveClass(/show/); + await expect.poll(() => page.evaluate(() => window.__connectionModalShows)).toBe(0); + await expect(page.locator('.notification-error').last()).toContainText( + 'E2E intercepted local SSH connect', + ); + await expect(page.locator('.profile-launcher-name', { hasText: 'Usable key' })).toBeVisible(); +}); + +test('inline key upload preserves the saved connection draft and focuses the new key', async ({ page }) => { + await installKeyUploadTrap(page); + await openProfileManagement(page); + const usable = page.locator('.profile-management-item').filter({ hasText: 'Usable key' }); + await usable.locator('[data-profile-action="edit"]').click(); + await page.locator('#profileEditorName').fill('Unsaved browser draft'); + await page.locator('#profileEditorHost').fill('draft.local'); + await page.locator('#profileEditorPort').fill('2202'); + await page.locator('#profileEditorUsername').fill('draftuser'); + await page.locator('#profileEditorAddKeyBtn').click(); + await page.locator('#profileEditorNewKeyName').fill('Inline browser key'); + await page.locator('#profileEditorNewKeyContent').fill('E2E private key text never sent'); + await page.locator('#profileEditorUploadKeyBtn').click(); + + await expect.poll(() => keyUploadAttempts(page)).toHaveLength(1); + expect((await keyUploadAttempts(page))[0]).toEqual({ + name: 'Inline browser key', + key_content: 'E2E private key text never sent', + }); + await expect(page.locator('#profileEditorName')).toHaveValue('Unsaved browser draft'); + await expect(page.locator('#profileEditorHost')).toHaveValue('draft.local'); + await expect(page.locator('#profileEditorPort')).toHaveValue('2202'); + await expect(page.locator('#profileEditorUsername')).toHaveValue('draftuser'); + await expect(page.locator('#profileEditorKeySelect')).toHaveValue('e2e-inline-key'); + await expect(page.locator('#profileEditorKeySelect')).toBeFocused(); + await expect(page.locator('#profileEditorNewKeyName')).toHaveValue(''); + await expect(page.locator('#profileEditorNewKeyContent')).toHaveValue(''); + await expect(page.locator('#profileEditorAddKeyPanel')).toHaveClass(/hidden/); +}); + +test('SSH key rename supports click, Enter, cancel, and Escape', async ({ page }) => { + await openKeyManagement(page); + const keyList = page.locator('#keysList'); + let keyItem = keyList.locator('.key-item').filter({ hasText: 'E2E usable key' }); + await expect(keyItem).toHaveCount(1); + const keyId = await keyItem.locator('[data-key-id]').first().getAttribute('data-key-id'); + const stableKeyItem = () => keyList.locator('.key-item').filter({ + has: page.locator(`[data-key-id="${keyId}"]`), + }); + + await keyItem.locator('[data-key-action="rename"]').click(); + keyItem = stableKeyItem(); + await keyItem.locator('.key-rename-input').fill('Cancelled rename'); + await keyItem.locator('[data-key-action="cancel-rename"]').click(); + await expect(keyList).toContainText('E2E usable key'); + await expect(keyList).not.toContainText('Cancelled rename'); + + keyItem = stableKeyItem(); + await keyItem.locator('[data-key-action="rename"]').click(); + await keyItem.locator('.key-rename-input').fill('Escaped rename'); + await page.keyboard.press('Escape'); + await expect(keyList).toContainText('E2E usable key'); + await expect(keyList).not.toContainText('Escaped rename'); + + keyItem = stableKeyItem(); + await keyItem.locator('[data-key-action="rename"]').click(); + await keyItem.locator('.key-rename-input').fill('Renamed E2E key'); + await page.keyboard.press('Enter'); + await expect(keyList).toContainText('Renamed E2E key'); + + keyItem = stableKeyItem(); + await keyItem.locator('[data-key-action="rename"]').click(); + await keyItem.locator('.key-rename-input').fill('E2E usable key'); + await keyItem.locator('[data-key-action="save-rename"]').click(); + await expect(keyList).toContainText('E2E usable key'); +}); + test('referenced commands and command sets cannot be deleted', async ({ page }) => { await page.locator('#commandLibraryBtn').click(); await expect(page.locator('#commandWorkspaceModal')).toHaveClass(/show/); @@ -264,3 +351,49 @@ test('the profile launcher remains contained and readable at 375px', async ({ pa expect(layout.endpointContained).toBe(true); expect(layout.actionBelowEndpoint).toBe(true); }); + +test('inline key upload and rename actions stay touchable at 375px', async ({ page }) => { + await page.setViewportSize({ width: 375, height: 812 }); + await openProfileManagement(page); + await page.locator('#newProfileBtn').click(); + await page.locator('#profileEditorAuthType').selectOption('key'); + await page.locator('#profileEditorAddKeyBtn').click(); + + const inlineLayout = await page.locator('#profileEditorAddKeyPanel').evaluate(panel => { + const panelBounds = panel.getBoundingClientRect(); + const modalBounds = panel.closest('.modal-content').getBoundingClientRect(); + const actions = [...panel.querySelectorAll('.profile-inline-key-actions .btn')]; + return { + contained: panelBounds.left >= modalBounds.left + && panelBounds.right <= modalBounds.right, + actionHeights: actions.map(action => action.getBoundingClientRect().height), + documentWidth: document.documentElement.scrollWidth, + viewportWidth: window.innerWidth, + }; + }); + expect(inlineLayout.contained).toBe(true); + expect(inlineLayout.actionHeights.every(height => height >= 44)).toBe(true); + expect(inlineLayout.documentWidth).toBeLessThanOrEqual(inlineLayout.viewportWidth); + + await page.locator('#closeProfileManagementModal').click(); + await openKeyManagement(page); + let keyItem = page.locator('#keysList .key-item').filter({ hasText: 'E2E usable key' }); + const keyId = await keyItem.locator('[data-key-id]').first().getAttribute('data-key-id'); + await keyItem.locator('[data-key-action="rename"]').click(); + keyItem = page.locator('#keysList .key-item').filter({ + has: page.locator(`[data-key-id="${keyId}"]`), + }); + const renameLayout = await keyItem.evaluate(item => { + const bounds = item.getBoundingClientRect(); + const actions = [...item.querySelectorAll('.key-item-actions .btn')]; + return { + contained: actions.every(action => { + const actionBounds = action.getBoundingClientRect(); + return actionBounds.left >= bounds.left && actionBounds.right <= bounds.right; + }), + actionHeights: actions.map(action => action.getBoundingClientRect().height), + }; + }); + expect(renameLayout.contained).toBe(true); + expect(renameLayout.actionHeights.every(height => height >= 44)).toBe(true); +}); diff --git a/tests/e2e/run_app.py b/tests/e2e/run_app.py index 869cf1f..19d0cc5 100644 --- a/tests/e2e/run_app.py +++ b/tests/e2e/run_app.py @@ -28,7 +28,7 @@ def _profile(name, host, username, auth_type, **extra): def _seed_launcher_profiles(admin, user): - from app import key_manager, profile_manager + from app import jump_host_manager, key_manager, profile_manager private_key = ed25519.Ed25519PrivateKey.generate().private_bytes( encoding=serialization.Encoding.PEM, @@ -36,12 +36,31 @@ def _seed_launcher_profiles(admin, user): encryption_algorithm=serialization.NoEncryption(), ).decode('utf-8') key, error = key_manager.save_key(admin.id, 'E2E usable key', private_key) + if error: + raise RuntimeError(error) + jump_host, error = jump_host_manager.add_jump_host( + admin.id, + 'E2E key jump host', + 'jump.local', + 22, + 'jumpuser', + 'key', + key_id=key['id'], + ) if error: raise RuntimeError(error) admin_profiles = [ _profile('Password review', 'password.local', 'passworduser', 'password'), _profile('Usable key', 'key.local', 'keyuser', 'key', key_id=key['id']), + _profile( + 'Key jump host', + 'jump-target.local', + 'keyuser', + 'key', + key_id=key['id'], + jump_host_id=jump_host['id'], + ), _profile('Missing key', 'missing-key.local', 'keyuser', 'key', key_id='missing-key'), _profile( 'Missing jump host', From 7b7070cff071ea532035c4c8b99e0b19dd08e84c Mon Sep 17 00:00:00 2001 From: bifrost0x Date: Mon, 3 Aug 2026 09:21:13 +0200 Subject: [PATCH 08/10] test: align post-connect profiles with direct launch --- tests/e2e/post-connect-profiles.spec.js | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tests/e2e/post-connect-profiles.spec.js b/tests/e2e/post-connect-profiles.spec.js index 421b9ab..4c3f856 100644 --- a/tests/e2e/post-connect-profiles.spec.js +++ b/tests/e2e/post-connect-profiles.spec.js @@ -53,7 +53,11 @@ test('projects all post-connect modes with exact parameter and stale-field seman const [{ payload }] = await sshAttempts(page); expect(payload).toMatchObject(expected); absent.forEach(field => expect(payload).not.toHaveProperty(field)); - await page.locator('#cancelConnectionBtn').click(); + await expect(page.locator('#connectionModal')).not.toHaveClass(/show/); + await expect(page.locator('.profile-launcher-name').getByText( + name, + { exact: true }, + )).toBeVisible(); await installSshConnectTrap(page); } }); @@ -104,10 +108,14 @@ test('missing post-connect references stop before the guarded SSH network layer' ['Post missing command set', 'Command set not found'], ]) { await launchProfile(page, name); + await expect(page.locator('#connectionModal')).not.toHaveClass(/show/); await expect(page.locator('.notification-error').last()).toContainText(message); await expect(page.locator('.notification-error').last()).not.toContainText( 'E2E network guard reached', ); - await page.locator('#cancelConnectionBtn').click(); + await expect(page.locator('.profile-launcher-name').getByText( + name, + { exact: true }, + )).toBeVisible(); } }); From a1e2bc42d6cf3f9eaf66355a8177c1aef3631bd6 Mon Sep 17 00:00:00 2001 From: bifrost0x Date: Mon, 3 Aug 2026 09:45:02 +0200 Subject: [PATCH 09/10] fix: address saved connection review findings --- app/key_manager.py | 3 +- app/socket_events.py | 11 +++---- static/js/i18n.js | 36 +++++++++++------------ static/js/profile-manager.js | 13 ++++++++- templates/index.html | 2 +- tests/e2e/profile-launcher.spec.js | 30 +++++++++++++++++++ tests/test_i18n_parity.py | 19 +++++++++++- tests/test_key_management_ui.py | 13 +++++++++ tests/test_key_manager.py | 46 ++++++++++++++++++++++++++++-- tests/test_key_socket_events.py | 7 +++++ 10 files changed, 149 insertions(+), 31 deletions(-) diff --git a/app/key_manager.py b/app/key_manager.py index 94158df..3f8228c 100644 --- a/app/key_manager.py +++ b/app/key_manager.py @@ -306,12 +306,13 @@ def rename_key(user_id, key_id, new_name): 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 updated, None + return {'before': before, 'key': updated}, None return None, "Key not found" diff --git a/app/socket_events.py b/app/socket_events.py index 2a0faec..319eb6e 100644 --- a/app/socket_events.py +++ b/app/socket_events.py @@ -853,24 +853,21 @@ 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 {} - existing = key_manager.get_key(current_user.id, data.get('key_id')) - updated, error = key_manager.rename_key( + result, error = key_manager.rename_key( current_user.id, data.get('key_id'), data.get('name'), ) if error: return _key_mutation_error(error) - if existing is None: - return _key_mutation_error('Key not found') log_key_rename( current_user.username, - existing['name'], - updated['name'], + result['before']['name'], + result['key']['name'], request.remote_addr, ) - payload = {'success': True, 'key': updated} + payload = {'success': True, 'key': result['key']} emit('key_renamed', payload) handle_list_keys(current_user=current_user) return payload diff --git a/static/js/i18n.js b/static/js/i18n.js index f70ae84..776b9af 100644 --- a/static/js/i18n.js +++ b/static/js/i18n.js @@ -37,7 +37,7 @@ const translations = { 'connection.newConnection': 'Quick Connect', 'connection.newSSHConnection': 'Quick Connect', 'connection.noActiveSessions': 'No Active Sessions', - 'connection.clickToStart': 'Click "New Connection" to start an SSH session', + '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', @@ -301,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', @@ -335,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', @@ -543,7 +543,7 @@ const translations = { '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.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', @@ -807,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', @@ -841,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ủ', @@ -1048,7 +1048,7 @@ const translations = { '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.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', @@ -1329,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', @@ -1363,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', @@ -1552,7 +1552,7 @@ const translations = { '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.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', @@ -1842,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', @@ -1876,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', @@ -2056,7 +2056,7 @@ const translations = { '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.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', @@ -2346,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', @@ -2380,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', @@ -2560,7 +2560,7 @@ const translations = { 'connection.newConnection': '快速连接', 'connection.newSSHConnection': '快速连接', 'connection.noActiveSessions': '当前没有活动会话', - 'connection.clickToStart': '点击“新建连接”开始一个 SSH 会话', + 'connection.clickToStart': '点击“快速连接”开始一个 SSH 会话', 'connection.savedProfiles': '已保存的连接', 'connection.savedProfilesHint': '选择一个已保存的连接进行连接', 'connection.connectNow': '立即连接', @@ -2841,7 +2841,7 @@ const translations = { 'fm.selectSource': '-- 选择来源 --', 'fm.yourComputer': '你的电脑', 'fm.sshSessions': 'SSH 会话', - 'fm.newConnection': '+ 新建连接...', + 'fm.newConnection': '+ 快速连接...', 'fm.goUp': '返回上级', 'fm.goHome': '前往主目录', 'fm.parentDirectory': '上级目录', @@ -2875,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': '主机', diff --git a/static/js/profile-manager.js b/static/js/profile-manager.js index da90429..70803e6 100644 --- a/static/js/profile-manager.js +++ b/static/js/profile-manager.js @@ -5,6 +5,7 @@ const ProfileManager = { selectedLegacyStartupCommands: '', editingProfileId: null, editingKeyId: null, + editingKeyName: null, keyRenamePending: false, inlineKeyUploadPending: false, @@ -91,6 +92,12 @@ const ProfileManager = { 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(); @@ -311,7 +318,7 @@ const ProfileManager = { input.type = 'text'; input.className = 'form-control key-rename-input'; input.dataset.keyId = key.id; - input.value = key.name; + input.value = this.editingKeyName ?? key.name; input.maxLength = 128; input.disabled = this.keyRenamePending; renameEditor.appendChild(input); @@ -856,17 +863,20 @@ const ProfileManager = { 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', { @@ -885,6 +895,7 @@ const ProfileManager = { return; } this.editingKeyId = null; + this.editingKeyName = null; this.upsertKeySummary(acknowledgement.key); }); }, diff --git a/templates/index.html b/templates/index.html index ee95f1b..5fa6a8c 100644 --- a/templates/index.html +++ b/templates/index.html @@ -120,7 +120,7 @@

Web SSH Terminal

- + diff --git a/tests/e2e/profile-launcher.spec.js b/tests/e2e/profile-launcher.spec.js index e7f9989..45a1a63 100644 --- a/tests/e2e/profile-launcher.spec.js +++ b/tests/e2e/profile-launcher.spec.js @@ -244,6 +244,36 @@ test('SSH key rename supports click, Enter, cancel, and Escape', async ({ page } await expect(keyList).toContainText('E2E usable key'); await expect(keyList).not.toContainText('Escaped rename'); + await page.evaluate(() => { + const originalEmit = window.socket.emit.bind(window.socket); + window.__keyRenameOriginalEmit = originalEmit; + window.socket.emit = function wrappedEmit(event, ...args) { + if (event !== 'rename_key') return originalEmit(event, ...args); + const acknowledgement = typeof args.at(-1) === 'function' + ? args.at(-1) + : null; + queueMicrotask(() => acknowledgement?.({ + success: false, + error: 'E2E intercepted key rename', + })); + return window.socket; + }; + }); + keyItem = stableKeyItem(); + await keyItem.locator('[data-key-action="rename"]').click(); + await keyItem.locator('.key-rename-input').fill('Preserved failed rename'); + await keyItem.locator('[data-key-action="save-rename"]').click(); + await expect(keyItem.locator('.key-rename-input')).toHaveValue( + 'Preserved failed rename', + ); + await expect(keyItem.locator('.key-rename-input')).toBeFocused(); + await expect(keyItem.locator('[data-key-action="save-rename"]')).toBeEnabled(); + await page.evaluate(() => { + window.socket.emit = window.__keyRenameOriginalEmit; + delete window.__keyRenameOriginalEmit; + }); + await keyItem.locator('[data-key-action="cancel-rename"]').click(); + keyItem = stableKeyItem(); await keyItem.locator('[data-key-action="rename"]').click(); await keyItem.locator('.key-rename-input').fill('Renamed E2E key'); diff --git a/tests/test_i18n_parity.py b/tests/test_i18n_parity.py index d23aa16..9f111b0 100644 --- a/tests/test_i18n_parity.py +++ b/tests/test_i18n_parity.py @@ -68,7 +68,11 @@ def test_saved_connection_and_quick_connect_terms_are_consistent(): 'connection.newSSHConnection', 'shortcuts.newConnection', } - saved_keys = {'connection.savedProfiles', 'profiles.manage'} + saved_keys = { + 'connection.savedProfiles', + 'profiles.manage', + 'fm.qc.savedProfiles', + } for index, match in enumerate(locale_starts): end = ( @@ -85,6 +89,19 @@ def test_saved_connection_and_quick_connect_terms_are_consistent(): assert {values[key] for key in quick_keys} == {quick_connect} assert {values[key] for key in saved_keys} == {saved_connections} assert values['panes.newConnection'] == f'+ {quick_connect}' + assert values['fm.newConnection'] == f'+ {quick_connect}...' + assert quick_connect in values['connection.clickToStart'] + + +def test_new_tab_accessible_name_uses_quick_connect_translation(): + source = Path('templates/index.html').read_text(encoding='utf-8') + new_tab = re.search(r']+id="newTabBtn"[^>]*>', source) + + assert new_tab is not None + assert 'title="Quick Connect"' in new_tab.group(0) + assert 'aria-label="Quick Connect"' in new_tab.group(0) + assert 'data-i18n-title="connection.newConnection"' in new_tab.group(0) + assert 'data-i18n-aria-label="connection.newConnection"' in new_tab.group(0) def test_all_locales_preserve_translation_placeholders(): diff --git a/tests/test_key_management_ui.py b/tests/test_key_management_ui.py index e4e31fb..989e20e 100644 --- a/tests/test_key_management_ui.py +++ b/tests/test_key_management_ui.py @@ -41,10 +41,23 @@ def test_key_mutations_use_acknowledgements_and_preserve_local_state(): assert 'this.upsertKeySummary(acknowledgement.key)' in PROFILE_MANAGER assert 'profileEditorKeySelect' in PROFILE_MANAGER assert 'editingKeyId' in PROFILE_MANAGER + assert 'editingKeyName' in PROFILE_MANAGER assert 'keyRenamePending' in PROFILE_MANAGER assert 'inlineKeyUploadPending' in PROFILE_MANAGER +def test_failed_key_rename_preserves_the_submitted_draft(): + assert 'this.editingKeyName = name;' in PROFILE_MANAGER + assert 'input.value = this.editingKeyName ?? key.name;' in PROFILE_MANAGER + failure_branch = PROFILE_MANAGER[ + PROFILE_MANAGER.index('if (!acknowledgement?.success'): + PROFILE_MANAGER.index('this.editingKeyId = null;', PROFILE_MANAGER.index( + 'if (!acknowledgement?.success' + )) + ] + assert 'this.editingKeyName = null' not in failure_branch + + def test_key_list_uses_delegated_actions_and_safe_text_rendering(): assert "closest('[data-key-action]')" in PROFILE_MANAGER assert 'button.dataset.keyAction = action' in PROFILE_MANAGER diff --git a/tests/test_key_manager.py b/tests/test_key_manager.py index 42e46fd..daa7d98 100644 --- a/tests/test_key_manager.py +++ b/tests/test_key_manager.py @@ -33,11 +33,13 @@ def test_rename_key_changes_only_owned_metadata_name( encrypted_before = key_path.read_bytes() metadata_before = dict(key) - updated, error = key_manager.rename_key( + result, error = key_manager.rename_key( owner_id, key['id'], ' After ' ) assert error is None + assert result['before'] == metadata_before + updated = result['key'] assert updated == {**metadata_before, 'name': 'After'} assert key_path.read_bytes() == encrypted_before assert key_manager.load_keys(owner_id) == [updated] @@ -63,11 +65,13 @@ def test_rename_key_allows_duplicate_display_names( ) assert error is None - updated, error = key_manager.rename_key( + result, error = key_manager.rename_key( user_id, second['id'], 'Shared' ) assert error is None + assert result['before'] == second + updated = result['key'] assert updated['id'] == second['id'] assert [ key['name'] for key in key_manager.load_keys(user_id) @@ -123,6 +127,44 @@ def test_rename_key_write_failure_preserves_metadata( assert metadata_path.read_bytes() == before +def test_concurrent_key_renames_report_atomic_before_and_after_names( + app, rsa_private_key_pem): + from concurrent.futures import ThreadPoolExecutor + from threading import Barrier + + from app import key_manager + + user_id = create_user(app, 'rename-concurrent') + with app.app_context(): + key, error = key_manager.save_key( + user_id, 'Original', rsa_private_key_pem + ) + assert error is None + + barrier = Barrier(2) + + def rename(name): + with app.app_context(): + barrier.wait() + return key_manager.rename_key(user_id, key['id'], name) + + with ThreadPoolExecutor(max_workers=2) as executor: + outcomes = list(executor.map(rename, ('First', 'Second'))) + + assert all(error is None for _result, error in outcomes) + transitions = { + (result['before']['name'], result['key']['name']) + for result, _error in outcomes + } + assert len(transitions) == 2 + assert sum(before == 'Original' for before, _after in transitions) == 1 + first_after = next( + after for before, after in transitions if before == 'Original' + ) + assert (first_after, {'First', 'Second'}.difference({first_after}).pop()) \ + in transitions + + def test_rename_key_preserves_corrupt_metadata(app): from app import key_manager diff --git a/tests/test_key_socket_events.py b/tests/test_key_socket_events.py index 9c74524..5bf28a6 100644 --- a/tests/test_key_socket_events.py +++ b/tests/test_key_socket_events.py @@ -48,6 +48,11 @@ def test_key_upload_and_rename_return_safe_acknowledgements( assert uploaded['success'] is True assert uploaded['key']['name'] == 'Initial' assert 'key_content' not in repr(uploaded) + assert rsa_private_key_pem not in repr(uploaded) + assert rsa_private_key_pem not in repr(emitted) + assert set(uploaded['key']) == { + 'id', 'name', 'filename', 'key_type', 'encrypted', 'uploaded_at' + } assert any(event == 'keys_list' for event, _payload in emitted) renamed, emitted = call_socket_handler( @@ -63,6 +68,8 @@ def test_key_upload_and_rename_return_safe_acknowledgements( 'key': {**uploaded['key'], 'name': 'Renamed'}, } assert any(event == 'key_renamed' for event, _payload in emitted) + assert rsa_private_key_pem not in repr(renamed) + assert rsa_private_key_pem not in repr(emitted) with app.app_context(): assert key_manager.load_keys(user_id)[0]['name'] == 'Renamed' From d1373164ec90f9fcc45f6e03c7fb043b0a7da641 Mon Sep 17 00:00:00 2001 From: bifrost0x Date: Mon, 3 Aug 2026 10:02:27 +0200 Subject: [PATCH 10/10] fix: complete saved connection terminology --- static/js/app.js | 4 +- static/js/i18n.js | 166 ++++++++++++++++----------------- static/js/profile-manager.js | 8 +- static/js/sftp-file-manager.js | 6 +- templates/index.html | 14 +-- tests/test_i18n_parity.py | 60 ++++++++++++ 6 files changed, 159 insertions(+), 99 deletions(-) diff --git a/static/js/app.js b/static/js/app.js index 90b1adc..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) => { diff --git a/static/js/i18n.js b/static/js/i18n.js index 776b9af..835bace 100644 --- a/static/js/i18n.js +++ b/static/js/i18n.js @@ -43,16 +43,16 @@ const translations = { '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': 'Saved Connections', 'profiles.manageHint': 'Create, review, update, and launch saved connections.', - 'profiles.create': 'Create profile', - 'profiles.none': 'No profiles saved.', - 'profiles.saveFailed': 'Could not save the profile.', - 'profiles.saved': 'Profile saved.', + '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,7 +117,7 @@ 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.', @@ -444,7 +444,7 @@ 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', @@ -453,7 +453,7 @@ const translations = { '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', @@ -549,16 +549,16 @@ const translations = { '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.', @@ -567,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', @@ -575,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': '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 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.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', @@ -608,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?', @@ -623,7 +623,7 @@ 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.', @@ -950,7 +950,7 @@ 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', @@ -959,7 +959,7 @@ const translations = { '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', @@ -1054,16 +1054,16 @@ const translations = { '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ü.', @@ -1080,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': 'Gespeicherte Verbindungen', 'profiles.manageHint': 'Gespeicherte Verbindungen erstellen, prüfen, aktualisieren und starten.', - 'profiles.create': 'Profil erstellen', - 'profiles.none': 'Keine Profile gespeichert.', - 'profiles.saveFailed': 'Das Profil konnte nicht gespeichert werden.', - 'profiles.saved': 'Profil gespeichert.', + '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', @@ -1113,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?', @@ -1128,7 +1128,7 @@ 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.', @@ -1467,7 +1467,7 @@ 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', @@ -1476,7 +1476,7 @@ const translations = { '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', @@ -1558,16 +1558,16 @@ const translations = { '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.', @@ -1576,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', @@ -1584,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': 'Connexions enregistrées', 'profiles.manageHint': 'Créez, vérifiez, mettez à jour et lancez des connexions enregistrées.', - 'profiles.create': 'Créer un profil', - 'profiles.none': 'Aucun profil enregistré.', - 'profiles.saveFailed': 'Impossible d’enregistrer le profil.', - 'profiles.saved': 'Profil enregistré.', + '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', @@ -1617,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 ?', @@ -1632,7 +1632,7 @@ 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.', @@ -1765,7 +1765,7 @@ const translations = { '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', @@ -1980,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', @@ -2062,16 +2062,16 @@ const translations = { '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.', @@ -2080,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', @@ -2088,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': 'Conexiones guardadas', 'profiles.manageHint': 'Crea, revisa, actualiza e inicia conexiones guardadas.', - 'profiles.create': 'Crear perfil', - 'profiles.none': 'No hay perfiles guardados.', - 'profiles.saveFailed': 'No se pudo guardar el perfil.', - 'profiles.saved': 'Perfil guardado.', + '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', @@ -2121,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?', @@ -2136,7 +2136,7 @@ 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.', @@ -2269,7 +2269,7 @@ const translations = { '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', @@ -2484,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', @@ -2566,16 +2566,16 @@ const translations = { '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': '在账户菜单中管理跳板机。', @@ -2584,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': '不运行', @@ -2592,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.create': '创建已保存的连接', + 'profiles.none': '尚无已保存的连接。', + 'profiles.saveFailed': '无法保存连接。', + 'profiles.saved': '连接已保存。', 'commandSets.none': '无', 'commandSets.create': '创建命令集', 'commandSets.createNew': '新建', @@ -2625,7 +2625,7 @@ const translations = { 'commandSets.missingCommand': '缺少库命令', 'commandSets.missingSet': '缺少命令集', 'commandSets.missingSetHint': '此命令集不可用。选择其他命令集之前,连接将被阻止。', - 'commandSets.legacyNotice': '此配置文件仍使用旧版自由文本命令。', + 'commandSets.legacyNotice': '此已保存的连接仍使用旧版自由文本命令。', 'commandSets.convert': '转换', 'commandSets.saved': '命令集已保存', 'commandSets.confirmDelete': '删除此命令集?', @@ -2640,7 +2640,7 @@ const translations = { 'jumphosts.confirmDelete': '删除此跳板机?', 'jumphosts.deleted': '跳板机已删除', 'jumphosts.noPasswordHint': '密码不会被保存 — 连接时输入。', - 'connection.profileName': '配置名称', + 'connection.profileName': '连接名称', 'connection.profileNamePlaceholder': '我的服务器', 'connection.connect': '连接', 'connection.connectBusy': '已有连接尝试正在进行。', @@ -2979,7 +2979,7 @@ const translations = { 'common.error': '错误', 'panes.assignTitle': '为分栏分配会话', - 'panes.assignInfo': '选择每个分栏中要显示的会话。你可以分配现有会话,或为该分栏创建新连接。', + 'panes.assignInfo': '选择每个分栏中要显示的会话。分配现有会话或使用快速连接。', 'panes.pane': '分栏', 'panes.empty': '留空', 'panes.emptyDesc': '保持该分栏为空', @@ -2988,7 +2988,7 @@ const translations = { 'panes.connected': '已连接', 'panes.disconnected': '已断开', 'panes.emptyPane': '空分栏', - 'panes.selectSession': '选择一个会话或新建连接', + 'panes.selectSession': '选择一个会话或使用快速连接', 'commands.workspace': '命令', 'commands.library': '命令库', diff --git a/static/js/profile-manager.js b/static/js/profile-manager.js index 70803e6..5aca87c 100644 --- a/static/js/profile-manager.js +++ b/static/js/profile-manager.js @@ -253,7 +253,7 @@ const ProfileManager = { placeholder.value = ''; placeholder.textContent = this.t( 'connection.selectProfile', - '-- Select Profile --', + '-- Select Saved Connection --', ); select.appendChild(placeholder); @@ -502,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; } @@ -757,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; @@ -783,7 +783,7 @@ 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 }); } 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 {
@@ -158,7 +158,7 @@ class SFTPFileManager {
@@ -271,7 +271,7 @@ class SFTPFileManager {
- + diff --git a/templates/index.html b/templates/index.html index 5fa6a8c..2693116 100644 --- a/templates/index.html +++ b/templates/index.html @@ -205,10 +205,10 @@

Quick Conn

- +
@@ -326,7 +326,7 @@

Quick Conn


                         
                         
Runs on the remote host after a successful connection, not in WebSSH. Not run again when reconnecting to an existing tmux session.
@@ -364,7 +364,7 @@

Saved Connections

Save and update connection settings without connecting.

- +
@@ -373,7 +373,7 @@

Saved Connections
- +
@@ -547,7 +547,7 @@

Commands

-

Build reusable command sequences and assign one to any connection profile.

+

Build reusable command sequences and assign one to any saved connection.

@@ -783,7 +783,7 @@

Assign Sessions to Pa