diff --git a/app/__init__.py b/app/__init__.py index d4cdfd8b..8efbd6e7 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -816,7 +816,9 @@ def enforce_security_feature_gate(): from .audit_export import audit_export_blueprint from .admin_backup import admin_backup_blueprint from .account_step_up_routes import account_step_up_blueprint + from .account_sessions import account_sessions_blueprint from .health import health_blueprint + from .support_report import support_report_blueprint from .host_key_routes import host_key_blueprint from .profile_exchange_routes import profile_exchange_blueprint from .host_context_routes import host_context_blueprint @@ -835,8 +837,10 @@ def enforce_security_feature_gate(): oidc_ready = True app.register_blueprint(audit_export_blueprint) app.register_blueprint(account_step_up_blueprint) + app.register_blueprint(account_sessions_blueprint) app.register_blueprint(admin_backup_blueprint) app.register_blueprint(health_blueprint) + app.register_blueprint(support_report_blueprint) from .operations import operations_blueprint app.register_blueprint(operations_blueprint) app.register_blueprint(host_key_blueprint) @@ -1827,6 +1831,23 @@ def admin_get_security_features(): ], }) + @app.get('/admin/api/security-features//impact') + @admin_required + @login_required + def admin_security_feature_impact(feature_name): + from .auth_assurance import current_authentication_session + from .security_feature_impact import feature_impact_preview + from .security_features import UnknownSecurityFeature + auth = current_authentication_session() + if auth is None: + abort(401) + try: + response = jsonify(feature_impact_preview(feature_name, current_user.id, auth.id)) + except UnknownSecurityFeature: + abort(404) + response.headers['Cache-Control'] = 'no-store' + return response + @app.route( '/admin/api/security-features/', methods=['POST'], @@ -1849,12 +1870,17 @@ def admin_set_security_feature(feature_name): if not isinstance(data, dict) or type(data.get('enabled')) is not bool: return jsonify({'error': 'enabled must be a boolean'}), 400 try: + # Keep approval verification and gate mutation in one SQLite write + # transaction: another account/factor edit must not slip between. + from sqlalchemy import text + db.session.execute(text('BEGIN IMMEDIATE')) current_status = feature_status(feature_name) if ( current_status.active and data['enabled'] is False and data.get('confirm_session_fallback') is not True ): + db.session.rollback() log_warning( 'Security feature change needs fallback confirmation', admin=current_user.username, @@ -1870,6 +1896,18 @@ def admin_set_security_feature(feature_name): ), 'feature': current_status.to_dict(), }), 409 + if current_status.active and data['enabled'] is False: + from .auth_assurance import current_authentication_session + from .security_feature_impact import verify_impact_approval + auth = current_authentication_session() + if auth is None or not verify_impact_approval( + data.get('impact_approval'), feature_name, current_user.id, auth.id, + ): + db.session.rollback() + return jsonify({ + 'error': 'Account configuration changed or approval expired. Review the impact again.', + 'code': 'feature_impact_changed', + }), 409 status = set_feature_active( feature_name, data['enabled'], @@ -1878,6 +1916,7 @@ def admin_set_security_feature(feature_name): except UnknownSecurityFeature: abort(404) except FeatureUnavailable as exc: + db.session.rollback() return jsonify({ 'error': exc.status.reason, 'feature': exc.status.to_dict(), diff --git a/app/account_sessions.py b/app/account_sessions.py new file mode 100644 index 00000000..deeb9661 --- /dev/null +++ b/app/account_sessions.py @@ -0,0 +1,148 @@ +"""Owned browser-login inventory and selective access revocation.""" +from datetime import datetime, timezone +import hashlib +import hmac +import re + +from flask import Blueprint, current_app, jsonify, request +from flask_login import current_user, login_required + +from .audit_logger import log_security_event, log_warning +from .auth_assurance import authentication_methods, current_authentication_session +from .models import AuthenticationSession, GitHubOAuthState, OIDCLoginState, SocketSession, StepUpIntent, User, db +from .login_access import serialize_login_access + +account_sessions_blueprint = Blueprint('account_sessions', __name__) + + +def login_cancellation_check(auth): + """Capture an exact login, then recheck it during an ongoing HTTP command.""" + auth_id, user_id, digest, generation = auth.id, auth.user_id, auth.session_hash, auth.auth_generation + lifecycle = current_app.extensions['runtime_lifecycle'] + + def cancelled(): + if not lifecycle.accepting_work(): + return True + return db.session.query(AuthenticationSession.id).join(User).filter( + AuthenticationSession.id == auth_id, + AuthenticationSession.user_id == user_id, + AuthenticationSession.session_hash == digest, + AuthenticationSession.auth_generation == generation, + AuthenticationSession.expires_at > datetime.now(timezone.utc).replace(tzinfo=None), + User.auth_generation == generation, + User.is_locked.is_(False), + ).first() is None + + return cancelled + + +def _handle(row): + # A management-only reference, domain-separated from all login credentials. + key = str(current_app.secret_key).encode('utf-8') + return hmac.new(key, f'login-inventory:{row.id}:{row.session_hash}'.encode(), hashlib.sha256).hexdigest() + + +def _live_sessions(): + return AuthenticationSession.query.filter( + AuthenticationSession.user_id == current_user.id, + AuthenticationSession.auth_generation == int(current_user.auth_generation or 0), + AuthenticationSession.expires_at > datetime.now(timezone.utc).replace(tzinfo=None), + ).order_by(AuthenticationSession.authenticated_at.desc(), AuthenticationSession.id.desc()).all() + + +def _response(**payload): + response = jsonify(**payload) + response.headers['Cache-Control'] = 'no-store' + return response + + +@account_sessions_blueprint.get('/api/account/sessions') +@login_required +def list_sessions(): + current = current_authentication_session() + if current is None: + return _response(error='Authentication required'), 401 + return _response(sessions=[{ + 'handle': _handle(row), + 'current': row.id == current.id, + 'methods': authentication_methods(row), + 'started_at': row.authenticated_at.isoformat() + 'Z', + 'expires_at': row.expires_at.isoformat() + 'Z', + } for row in _live_sessions()]) + + +@account_sessions_blueprint.post('/api/account/sessions/revoke') +@login_required +@serialize_login_access +def revoke_sessions(): + db.session.expire_all() + current = current_authentication_session() + if current is None: + return _response(error='Authentication required'), 401 + body = request.get_json(silent=True) + if not isinstance(body, dict) or not ( + set(body) == {'others'} and body['others'] is True + or set(body) == {'session'} and isinstance(body['session'], str) and re.fullmatch(r'[0-9a-f]{64}', body['session']) + ): + return _response(error='Select a login or all other logins'), 400 + rows = _live_sessions() + if body.get('others') is True: + selected = [row for row in rows if row.id != current.id] + else: + selected = [row for row in rows if hmac.compare_digest(_handle(row), body['session'])] + if not selected: + return _response(error='Login is no longer available'), 404 + if selected[0].id == current.id: + return _response(error='Use Sign out to end this login'), 409 + selected_ids = [row.id for row in selected] + selected_identities = [(row.user_id, row.id, row.session_hash) for row in selected] + sockets = SocketSession.query.filter( + SocketSession.user_id == current_user.id, + SocketSession.authentication_session_id.in_(selected_ids), + ).all() if selected_ids else [] + socket_sids = [row.socket_sid for row in sockets] + intents = [row.id for row in StepUpIntent.query.filter( + StepUpIntent.authentication_session_id.in_(selected_ids), + )] if selected_ids else [] + if selected_ids: + for model in (GitHubOAuthState, OIDCLoginState): + model.query.filter(db.or_( + model.authentication_session_id.in_(selected_ids), + model.step_up_intent_id.in_(intents), + )).delete(synchronize_session=False) + for row in selected: + db.session.delete(row) + db.session.commit() + + # Commit the authentication fence before interrupting jobs or transports. + # Retained account-owned SSH/tmux sessions are deliberately not destroyed. + cleanup_errors = False + from .discovery_routes import cancel_for_authentication_sessions + cancel_for_authentication_sessions(selected_identities) + for session_id in selected_ids: + for name in ('credentials', 'automation'): + service = current_app.extensions.get(name) + if service is not None: + try: + service.revoke_session(session_id) + except Exception as error: + cleanup_errors = True + log_warning('Login resource cleanup failed', resource=name, error_type=type(error).__name__) + from . import socketio + from .socket_events import disconnect_socket_transport + from .transfer_routes import transfer_manager + for sid in socket_sids: + # Also cancel directly so a failing transport teardown cannot preserve + # an already-consumed HTTP transfer token or its streaming response. + try: + transfer_manager.cancel_all_for_socket(current_user.id, sid) + except Exception as error: + cleanup_errors = True + log_warning('Login transfer cleanup failed', error_type=type(error).__name__) + try: + disconnect_socket_transport(socketio.server, sid, drain=False) + except Exception as error: + cleanup_errors = True + log_warning('Login socket cleanup failed', error_type=type(error).__name__) + log_security_event('BROWSER_LOGINS_REVOKED', user=current_user.username, count=len(selected_ids)) + return _response(revoked=len(selected_ids), cleanup_incomplete=cleanup_errors) diff --git a/app/auth.py b/app/auth.py index 3c0fd03a..a6f49b9e 100644 --- a/app/auth.py +++ b/app/auth.py @@ -338,12 +338,16 @@ def register_socket_session(user_id, socket_sid, user_agent=None): Returns: SocketSession object """ + from flask import has_request_context + from .auth_assurance import current_authentication_session + auth = current_authentication_session() if has_request_context() else None SocketSession.query.filter_by(socket_sid=socket_sid).delete() socket_session = SocketSession( user_id=user_id, socket_sid=socket_sid, - user_agent=user_agent + user_agent=user_agent, + authentication_session_id=auth.id if auth and auth.user_id == user_id else None, ) db.session.add(socket_session) db.session.commit() diff --git a/app/auth_assurance.py b/app/auth_assurance.py index f5154be1..3ca15fbc 100644 --- a/app/auth_assurance.py +++ b/app/auth_assurance.py @@ -32,6 +32,7 @@ ) from .session_epoch import current_epoch from .totp_service import disable_totp_mfa +from .login_access import serialize_login_access _PENDING_TTL = timedelta(minutes=5) @@ -406,6 +407,7 @@ def invalidate_user_authentication( return row +@serialize_login_access def finalize_login(pending, *, methods, strong_authenticated_at=None): """Create the sole authenticated browser-session record.""" if not isinstance(pending, PendingAuthentication): diff --git a/app/discovery_routes.py b/app/discovery_routes.py index 2bdcb6a8..e8f710d7 100644 --- a/app/discovery_routes.py +++ b/app/discovery_routes.py @@ -21,6 +21,8 @@ import config from .audit_logger import log_security_event from .auth import check_user_rate_limit +from .login_access import login_access +from .auth_assurance import current_authentication_session discovery_blueprint = Blueprint('discovery', __name__) @@ -159,9 +161,11 @@ def _known_endpoints(user_id): class Scan: - def __init__(self, user_id, network, addresses, ports, known): + def __init__(self, user_id, network, addresses, ports, known, authentication_session_id=None, authentication_session_hash=None): self.id = secrets.token_urlsafe(12) self.user_id = user_id + self.authentication_session_id = authentication_session_id + self.authentication_session_hash = authentication_session_hash self.network = network self.targets = [(address, port) for address in addresses for port in ports] self.known = known @@ -240,6 +244,9 @@ def _own_scan(scan_id): @discovery_blueprint.post('/api/discovery/scans') @login_required def start_scan(): + auth = current_authentication_session() + if auth is None: + return jsonify(error='Authentication required'), 401 if not discovery_available(): return jsonify(error='Host discovery is not available on this instance.', code='unavailable'), 404 lifecycle = current_app.extensions['runtime_lifecycle'] @@ -258,16 +265,20 @@ def start_scan(): known = _known_endpoints(user_id) if check_user_rate_limit(user_id, 'host_discovery', RATE_LIMIT): return jsonify(error='Too many scans. Try again later.', code='busy'), 429 - scan = Scan(user_id, network, addresses, ports, known) - # Admission and registration happen under one lock, so concurrent starts cannot pass the limits together. - with _LOCK: - _prune() - # A cancelled scan counts until its in-flight probes have ended. - if any(item.user_id == user_id and item.finished_at is None for item in _SCANS.values()): - return jsonify(error='A scan is already running. Wait for it or cancel it.', code='running'), 409 - if sum(item.finished_at is None for item in _SCANS.values()) >= MAX_RUNNING_SCANS: - return jsonify(error='Another discovery is running. Try again shortly.', code='busy'), 429 - _SCANS[scan.id] = scan + scan = Scan(user_id, network, addresses, ports, known, auth.id, auth.session_hash) + # Only final admission is serialized with revocation. Validation and scan + # construction may proceed concurrently, without delaying login cleanup. + with login_access(): + if current_authentication_session() is None: + return jsonify(error='Authentication required'), 401 + with _LOCK: + _prune() + # A cancelled scan counts until its in-flight probes have ended. + if any(item.user_id == user_id and item.finished_at is None for item in _SCANS.values()): + return jsonify(error='A scan is already running. Wait for it or cancel it.', code='running'), 409 + if sum(item.finished_at is None for item in _SCANS.values()) >= MAX_RUNNING_SCANS: + return jsonify(error='Another discovery is running. Try again shortly.', code='busy'), 429 + _SCANS[scan.id] = scan _RUNNERS.submit(scan.run) log_security_event('host_discovery_started', user_id=user_id, network=network, addresses=len(addresses), ports=ports) return jsonify(scan.view()), 202 @@ -311,6 +322,21 @@ def cancel_for_user(user_id): return len(owned) +def cancel_for_authentication_sessions(identities): + """Cancel only discovery launched by the revoked browser logins.""" + with _LOCK: + owned = [scan for scan in _SCANS.values() if ( + scan.user_id, scan.authentication_session_id, scan.authentication_session_hash, + ) in identities] + for scan in owned: + scan.cancel_event.set() + scan.revoked = True + scan.found = [] + if scan.state == 'running': + scan.state = 'cancelled' + return len(owned) + + def cancel_all(_deadline=None): """Shutdown hook: stop every running scan; in-flight probes end within their timeouts.""" with _LOCK: diff --git a/app/host_key_routes.py b/app/host_key_routes.py index a01546d2..f8379997 100644 --- a/app/host_key_routes.py +++ b/app/host_key_routes.py @@ -30,6 +30,45 @@ def list_user_host_keys(): return jsonify({"entries": store.list_entries()}) +def _owned_ssh_targets(): + from .profile_manager import load_profiles + from .jump_host_manager import load_jump_hosts + + # Never accept hostnames or a user selector from the request: matching a + # global wildcard/hash must not become an arbitrary host-inventory oracle. + targets = [] + for kind, records in [('ssh', load_profiles(current_user.id)), + ('jump', load_jump_hosts(current_user.id))]: + for item in records: + targets.append({'id': item['id'], 'name': item.get('name', ''), + 'kind': kind, 'host': item['host'], + 'port': item.get('port') or 22}) + return targets + + +@host_key_blueprint.get("/api/host-keys/effective") +@login_required +def list_effective_host_keys(): + _require_enabled() + store = HostKeyStore(current_user.id, config.KNOWN_HOSTS_FILE, config.USERS_DIR) + response = jsonify({'targets': store.effective_targets(_owned_ssh_targets())}) + response.headers['Cache-Control'] = 'no-store' + return response + + +@host_key_blueprint.get("/api/host-keys//deletion-impact") +@login_required +def preview_user_host_key_deletion(entry_id): + _require_enabled() + store = HostKeyStore(current_user.id, config.KNOWN_HOSTS_FILE, config.USERS_DIR) + impact = store.deletion_impact(entry_id, _owned_ssh_targets()) + if impact is None: + return jsonify({'error': 'Host key not found'}), 404 + response = jsonify(impact) + response.headers['Cache-Control'] = 'no-store' + return response + + @host_key_blueprint.delete("/api/host-keys/") @login_required def delete_user_host_key(entry_id): @@ -39,7 +78,11 @@ def delete_user_host_key(entry_id): config.KNOWN_HOSTS_FILE, config.USERS_DIR, ) - if not store.delete_entry(entry_id): + try: + removed = store.delete_entry(entry_id, expected_revision=request.headers.get('If-Match')) + except ValueError: + return jsonify({'error': 'Trust changed. Reload and review the deletion again.', 'code': 'trust_changed'}), 409 + if not removed: return jsonify({"error": "Host key not found"}), 404 log_security_event( "SSH_HOST_KEY_DELETED", diff --git a/app/host_key_store.py b/app/host_key_store.py index 88f067c9..cd4e10a0 100644 --- a/app/host_key_store.py +++ b/app/host_key_store.py @@ -23,6 +23,9 @@ ) +_PREVIEW_SECRET = os.urandom(32) + + class HostKeyNotAccepted(paramiko.SSHException): """First-contact trust requires an explicit interactive decision.""" @@ -274,6 +277,94 @@ def check(hostname, port=22): def missing_key_policy(self, decision=None, context="target") -> paramiko.MissingHostKeyPolicy: return _PerUserMissingHostKeyPolicy(self, decision, context) + def effective_targets(self, targets): + """Explain matching trust for caller-owned endpoints, never host patterns. + + This is a configuration snapshot, not a verification of a remote server. + Resolve entries in the same order and with the same revocation matcher + as the connection layer. Global aliases, comments and key material are + deliberately excluded from this user-facing projection. + """ + with self._account_access(), storage_lock("host_keys:global"), storage_lock(self._lock_key): + global_file = self._load_or_empty(self.global_path) + user_file = self._load_or_empty(self.user_path) + return self._effective_target_reports(targets, global_file, user_file) + + @staticmethod + def _effective_target_reports(targets, global_file, user_file): + layered = _LayeredHostKeys(global_file, user_file) + reports = [] + for target in targets: + host, port = target['host'], int(target.get('port') or 22) + hostname = host if port == 22 else f'[{host}]:{port}' + effective = layered.lookup(hostname) or {} + selected = set() + keys = [] + for scope, parsed in [('user', user_file), ('global', global_file)]: + for collection, revoked in [(parsed.trusted, False), (parsed.revoked, True)]: + for entry in collection._entries: + if not _entry_matches_hostname(hostname, entry): + continue + algorithm = entry.key.get_name() + if revoked or layered.is_revoked(hostname, entry.key): + status = 'revoked' + elif algorithm not in selected and effective.get(algorithm) == entry.key: + status = 'effective' + selected.add(algorithm) + else: + status = 'overridden' + fingerprint = base64.b64encode(hashlib.sha256(entry.key.asbytes()).digest()).decode('ascii').rstrip('=') + keys.append({'scope': scope, 'algorithm': algorithm, + 'fingerprint': f'SHA256:{fingerprint}', 'status': status}) + reports.append({key: target[key] for key in ('id', 'name', 'kind') if key in target} + | {'host': host, 'port': port, 'keys': keys}) + return reports + + def _preview_revision(self, entry_id, global_file, user_file): + # Opaque, process-local optimistic concurrency token; it must not expose + # a digest that could be used to guess unrelated global host inventory. + payload = (str(self.user_id).encode() + b'\0' + entry_id.encode() + + hashlib.sha256(global_file.raw).digest() + + hashlib.sha256(user_file.raw).digest()) + return hmac.new(_PREVIEW_SECRET, payload, hashlib.sha256).hexdigest() + + def deletion_impact(self, entry_id, targets): + """Preview exact personal-entry deletion without writing either store.""" + with self._account_access(), storage_lock("host_keys:global"), storage_lock(self._lock_key): + global_file = self._load_or_empty(self.global_path) + user_file = self._load_or_empty(self.user_path) + kept = [] + removed = None + for raw_line in user_file.raw.splitlines(keepends=True): + metadata = self._management_entry( + raw_line.rstrip(b'\r\n'), scope='user', + owner_id=self.user_id, timestamp=None, + ) + if metadata is not None and metadata['id'] == entry_id: + fields = raw_line.decode('utf8').strip().split() + if fields[0].startswith('@'): + fields.pop(0) + removed = HostKeyEntry.from_line(' '.join(fields[:3])) + else: + kept.append(raw_line) + if removed is None: + return None + names = [target['host'] if int(target.get('port') or 22) == 22 + else f"[{target['host']}]:{int(target['port'])}" for target in targets] + affected = [target for target, hostname in zip(targets, names) + if _entry_matches_hostname(hostname, removed)] + unresolved = any( + '*' in pattern or '?' in pattern + or not any(_host_pattern_matches(hostname, pattern) for hostname in names) + for pattern in removed.hostnames if not pattern.startswith('!') + ) + remaining = self._parse_raw(b''.join(kept)) + return { + 'revision': self._preview_revision(entry_id, global_file, user_file), + 'targets': self._effective_target_reports(affected, global_file, remaining), + 'unresolved': unresolved, + } + def record(self, hostname: str, key: paramiko.PKey) -> None: """Append a first-seen key without rewriting the user's raw file.""" with self._account_access(), storage_lock("host_keys:global"), storage_lock(self._lock_key): @@ -323,15 +414,23 @@ def list_entries(self): owner_id=self.user_id, ) - def delete_entry(self, entry_id): + def delete_entry(self, entry_id, *, expected_revision=None): """Delete one trusted user key while preserving unrelated raw lines.""" - with self._account_access(): - return self.delete_file_entry( + with self._account_access(), storage_lock("host_keys:global"), storage_lock(self._lock_key): + if expected_revision is not None: + revision = self._preview_revision( + entry_id, self._load_or_empty(self.global_path), + self._load_or_empty(self.user_path), + ) + if (not isinstance(expected_revision, str) + or not re.fullmatch(r'[0-9a-f]{64}', expected_revision) + or not hmac.compare_digest(expected_revision, revision)): + raise ValueError('trust_changed') + return self._delete_file_entry_locked( self.user_path, entry_id, scope="user", owner_id=self.user_id, - lock_key=self._lock_key, ) @classmethod @@ -369,33 +468,37 @@ def delete_file_entry( lock_key, ): access = bind_user_data_access(owner_id) if scope == 'user' else None + with (access() if access is not None else nullcontext()), storage_lock(lock_key): + return cls._delete_file_entry_locked(path, entry_id, scope=scope, owner_id=owner_id) + + @classmethod + def _delete_file_entry_locked(cls, path, entry_id, *, scope, owner_id): if not isinstance(entry_id, str) or not re.fullmatch( r"[0-9a-f]{64}", entry_id ): return False path = Path(path) - with (access() if access is not None else nullcontext()), storage_lock(lock_key): - try: - raw = path.read_bytes() - except FileNotFoundError: - return False - kept = [] - removed = False - for raw_line in raw.splitlines(keepends=True): - candidate = cls._management_entry( - raw_line.rstrip(b"\r\n"), - scope=scope, - owner_id=owner_id, - timestamp=None, - ) - if candidate is not None and candidate["id"] == entry_id: - removed = True - continue - kept.append(raw_line) - if not removed: - return False - atomic_write_bytes(path, b"".join(kept), mode=0o600) - return True + try: + raw = path.read_bytes() + except FileNotFoundError: + return False + kept = [] + removed = False + for raw_line in raw.splitlines(keepends=True): + candidate = cls._management_entry( + raw_line.rstrip(b"\r\n"), + scope=scope, + owner_id=owner_id, + timestamp=None, + ) + if candidate is not None and candidate["id"] == entry_id: + removed = True + continue + kept.append(raw_line) + if not removed: + return False + atomic_write_bytes(path, b"".join(kept), mode=0o600) + return True @classmethod def add_file_entry(cls, path, value, *, scope, owner_id, lock_key): @@ -570,6 +673,10 @@ def _load_or_empty(cls, path): @staticmethod def _load_strict(path: Path) -> _ParsedHostKeyFile: raw = Path(path).read_bytes() + return HostKeyStore._parse_raw(raw) + + @staticmethod + def _parse_raw(raw: bytes) -> _ParsedHostKeyFile: try: text = raw.decode("utf-8") except UnicodeDecodeError as exc: diff --git a/app/login_access.py b/app/login_access.py new file mode 100644 index 00000000..473bd5fe --- /dev/null +++ b/app/login_access.py @@ -0,0 +1,20 @@ +"""Single-worker admission boundary for browser logins and revocation.""" +from functools import wraps +from contextlib import contextmanager +import threading + +_session_access_lock = threading.RLock() + + +@contextmanager +def login_access(): + with _session_access_lock: + yield + + +def serialize_login_access(function): + @wraps(function) + def guarded(*args, **kwargs): + with login_access(): + return function(*args, **kwargs) + return guarded diff --git a/app/models.py b/app/models.py index a1c4ab55..6e8e42eb 100644 --- a/app/models.py +++ b/app/models.py @@ -194,6 +194,11 @@ def ensure_security_columns(): ensure_user_columns() inspector = inspect(db.engine) + if 'socket_sessions' in inspector.get_table_names(): + columns = {column['name'] for column in inspector.get_columns('socket_sessions')} + if 'authentication_session_id' not in columns: + db.session.execute(text('ALTER TABLE socket_sessions ADD COLUMN authentication_session_id INTEGER')) + db.session.commit() if 'github_oauth_states' in inspector.get_table_names(): github_columns = {column['name'] for column in inspector.get_columns('github_oauth_states')} for name in ('auth_generation', 'authentication_session_id'): @@ -265,6 +270,8 @@ class SocketSession(db.Model): created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc)) last_activity = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) user_agent = db.Column(db.String(256)) + # Metadata only: deleting a login must not be blocked by live transports. + authentication_session_id = db.Column(db.Integer, nullable=True) def __repr__(self): return f'' diff --git a/app/security_feature_impact.py b/app/security_feature_impact.py new file mode 100644 index 00000000..d2c715a9 --- /dev/null +++ b/app/security_feature_impact.py @@ -0,0 +1,120 @@ +"""Concrete, expiring disable approvals bound to the account configuration.""" +from collections import Counter +import hashlib +import hmac +import json + +from flask import current_app +from itsdangerous import BadData, URLSafeTimedSerializer + +from .models import AuthenticationSession, User, db +from .security_features import all_feature_statuses, feature_status + + +def _snapshot(feature): + from .github_auth_service import github_auth_is_active + + feature = feature_status(feature).name # Normalize before account inspection. + gates = {status.name: status.to_dict() for status in all_feature_statuses()} + github_active = github_auth_is_active() + accounts = [] + def identity(row): + return [row.id, row.created_at.isoformat()] + + for user in User.query.order_by(User.id).all(): + accounts.append({ + 'id': user.id, + 'created_at': user.created_at.isoformat(), + 'username': user.username, + 'generation': user.auth_generation, + 'admin': user.is_admin, + 'locked': user.is_locked, + 'mfa': user.mfa_enabled, + 'password': not user.is_ldap_managed and not user.is_github_managed, + 'passkey': [identity(row) for row in user.webauthn_credentials.order_by('id').all()], + 'totp': [identity(row) for row in user.totp_authenticators.filter_by(active=True).order_by('id').all()], + # A consumed recovery verifier is deleted, not retained as used. + 'recovery': [identity(row) for row in user.recovery_codes.order_by('id').all()], + 'oidc': [identity(row) for row in user.oidc_identities.order_by('id').all()], + 'ldap': identity(user.ldap_identity) if user.ldap_identity else None, + 'github': identity(user.github_identity) if user.github_identity else None, + }) + snapshot = {'gates': gates, 'github_active': github_active, 'accounts': accounts} + digest = hashlib.sha256(json.dumps(snapshot, sort_keys=True).encode()).hexdigest() + active = {name: status['active'] and name != feature for name, status in gates.items()} + primary = Counter() + factors = Counter() + affected = [account for account in accounts if account[feature]] + details = [] + no_primary = no_primary_admins = no_mfa = no_mfa_admins = 0 + for account in affected: + configured_primary = [name for name in ('password', 'passkey', 'oidc', 'ldap', 'github') if account[name] and ( + name == 'password' or name == 'github' and github_active or active.get(name, False) + ) and not (name == 'oidc' and account['ldap'])] + configured_factors = [name for name in ('passkey', 'totp', 'recovery') if account[name] and active[name]] + details.append({ + 'username': account['username'], 'admin': account['admin'], 'locked': account['locked'], + 'mfa_required': account['mfa'], 'remaining_primary': configured_primary, + 'remaining_factors': configured_factors, + }) + if account['locked']: + continue + primary.update(configured_primary) + factors.update(configured_factors) + if not configured_primary: + no_primary += 1 + no_primary_admins += int(account['admin']) + if account['mfa'] and not any(name in configured_factors for name in ('passkey', 'totp')): + no_mfa += 1 + no_mfa_admins += int(account['admin']) + return digest, { + 'feature': feature, + 'active': gates[feature]['active'], + 'affected_accounts': len(affected), + 'affected_admins': sum(account['admin'] for account in affected), + 'locked_affected_accounts': sum(account['locked'] for account in affected), + 'remaining_primary': dict(primary), + 'remaining_factors': dict(factors), + 'accounts_without_primary': no_primary, + 'admins_without_primary': no_primary_admins, + 'mfa_required_without_local_factor': no_mfa, + 'admins_without_local_factor': no_mfa_admins, + 'tested': False, + 'accounts': details, + } + + +def _signer(): + return URLSafeTimedSerializer(current_app.secret_key, salt='security-feature-impact-v1') + + +def _bound_digest(digest, user_id, auth_id): + row = db.session.get(AuthenticationSession, auth_id) + if row is None or row.user_id != user_id: + return None + # A row ID may be reused after expiry; bind the exact login without + # returning its authentication hash in the signed, readable payload. + return hmac.new(str(current_app.secret_key).encode(), + f'feature-impact:{digest}:{row.session_hash}'.encode(), hashlib.sha256).hexdigest() + + +def feature_impact_preview(feature, user_id, auth_id): + digest, impact = _snapshot(feature) + return { + 'impact': impact, + 'approval': _signer().dumps([feature, user_id, auth_id, _bound_digest(digest, user_id, auth_id)]), + } + + +def verify_impact_approval(token, feature, user_id, auth_id): + if not isinstance(token, str) or len(token) > 2048: + return False + try: + approved = _signer().loads(token, max_age=300) + except BadData: + return False + if not isinstance(approved, list) or len(approved) != 4 or approved[:3] != [feature, user_id, auth_id]: + return False + digest, _impact = _snapshot(feature) + bound = _bound_digest(digest, user_id, auth_id) + return bound is not None and isinstance(approved[3], str) and hmac.compare_digest(approved[3], bound) diff --git a/app/server_operations_routes.py b/app/server_operations_routes.py index 68983b2a..1e7556e6 100644 --- a/app/server_operations_routes.py +++ b/app/server_operations_routes.py @@ -9,6 +9,7 @@ from . import server_operations as operations from .audit_logger import log_security_event from .auth_assurance import current_authentication_session +from .account_sessions import login_cancellation_check from .step_up import consume_account_step_up_grant, StepUpError server_operations_blueprint = Blueprint('server_operations', __name__) @@ -64,7 +65,7 @@ def capabilities(session_id): @server_operations_blueprint.post('/api/server-operations/read') @login_required def read(): - _auth() + auth = _auth() data = _data() action = data.get('action') if not isinstance(action, str) or action not in operations.READ_ACTIONS: @@ -73,10 +74,9 @@ def read(): use_sudo=data.get('use_sudo', False)) session_id = data.get('session_id') session = operations.owned_session(session_id, current_user.id) - lifecycle = current_app.extensions['runtime_lifecycle'] result = operations.execute_command( session_id, current_user.id, session, command, - cancelled=lambda: not lifecycle.accepting_work(), + cancelled=login_cancellation_check(auth), ) if action == 'process.list' and result.get('status') == 'completed': result['processes'] = operations.parse_process_list(result.get('output', '')) @@ -123,11 +123,10 @@ def execute(): log_security_event('SERVER_OPERATION_REQUESTED', user=current_user.username, action=intent.action, session_id=intent.session_id) try: - lifecycle = current_app.extensions['runtime_lifecycle'] result = operations.execute_command( intent.session_id, current_user.id, intent.session, intent.command, mutating=True, expected_client=intent.client, - cancelled=lambda: not lifecycle.accepting_work(), + cancelled=login_cancellation_check(auth), ) except operations.OperationError as error: log_security_event('SERVER_OPERATION_RESULT', user=current_user.username, diff --git a/app/socket_events.py b/app/socket_events.py index fb1748d6..187a6dbc 100644 --- a/app/socket_events.py +++ b/app/socket_events.py @@ -12,6 +12,7 @@ sftp_handler, jump_host_manager, post_connect_manager, session_insights, runtime_inventory, smb_share_manager) from .decorators import socket_login_required +from .login_access import serialize_login_access from .auth import ( check_socket_rate_limit, get_user_from_socket, @@ -796,6 +797,7 @@ def _reject_socket_protocol_mismatch(auth, user): @socketio.on('connect') @_close_transport_after_rejected_connect +@serialize_login_access def handle_connect(auth=None): """Handle client connection - authenticate and restore sessions.""" from .maintenance_mode import is_active diff --git a/app/support_report.py b/app/support_report.py new file mode 100644 index 00000000..d4443620 --- /dev/null +++ b/app/support_report.py @@ -0,0 +1,72 @@ +"""Explicit, allowlisted diagnostic snapshots for the signed-in account.""" + +from datetime import datetime, timezone +import re + +from flask import Blueprint, current_app, jsonify, request +from flask_login import current_user, login_required + +import config +from .app_settings import is_automation_enabled, is_credentials_enabled, runtime_feature_status +from .security_features import feature_is_active +from .version import display_version + + +support_report_blueprint = Blueprint('support_report', __name__) +_AREAS = ('general', 'ssh', 'files', 'sign_in', 'automation') +_REFERENCE = re.compile(r'SMB-[A-F0-9]{12}\Z', re.ASCII) +_READINESS_FAILURES = ('maintenance', 'runtime', 'database', 'data_directory') + + +@support_report_blueprint.post('/api/account/support-report') +@login_required +def create_support_report(): + data = request.get_json(silent=True) + if not isinstance(data, dict) or set(data) - {'area', 'reference'}: + return jsonify(error='Invalid report options'), 400 + area = data.get('area', 'general') + reference = data.get('reference', '') + if (not isinstance(area, str) or area not in _AREAS + or not isinstance(reference, str) + or (reference and not _REFERENCE.fullmatch(reference))): + return jsonify(error='Invalid report options'), 400 + + # Reuse the local readiness checks; never connect to a remote target, + # collect logs, or enumerate account data to generate a support report. + from .health import ready + result = ready() + readiness = (result[0] if isinstance(result, tuple) else result).get_json() + features = { + 'ssh': True, + 'sftp': True, + 'smb': bool(config.SMB_ENABLED), + 'tmux': bool(runtime_feature_status('tmux_enabled')['enabled']), + 'automation': bool(is_automation_enabled()), + 'saved_passwords': bool(is_credentials_enabled()), + **{name: bool(feature_is_active(name)) + for name in ('passkey', 'totp', 'oidc', 'ldap', 'recovery')}, + } + report = { + 'schema': 1, + 'generated_at': datetime.now(timezone.utc).isoformat(), + 'version': display_version(), + 'area': area, + 'reference': reference or None, + 'https': bool(request.is_secure), + 'readiness': { + 'status': 'ready' if readiness.get('status') == 'ready' else 'not_ready', + 'failed': [name for name in _READINESS_FAILURES + if name in readiness.get('failed', [])], + }, + 'features': features, + } + if current_user.is_admin: + profile = config.DEPLOYMENT_PROFILE + report['deployment'] = { + 'profile': profile if profile in ('homelab', 'production') else 'unknown', + 'secure_cookie': bool(current_app.config.get('SESSION_COOKIE_SECURE')), + 'internal_ssh_blocked': bool(config.BLOCK_INTERNAL_SSH), + } + response = jsonify(report) + response.headers['Cache-Control'] = 'no-store' + return response diff --git a/app/transfer_manager.py b/app/transfer_manager.py index e76ff4ec..eb5cb864 100644 --- a/app/transfer_manager.py +++ b/app/transfer_manager.py @@ -9,10 +9,12 @@ from dataclasses import dataclass from enum import Enum from collections.abc import Mapping +from flask import has_request_context, session from .file_sources import SourceHoldSet from .quota_manager import QuotaKind, quota_manager as default_quota_manager from .runtime_lifecycle import RuntimeShuttingDown +from .login_access import serialize_login_access class TransferState(Enum): @@ -213,6 +215,7 @@ def bind_runtime(self): self._accepting = True return binding + @serialize_login_access def create( self, user_id, @@ -261,6 +264,11 @@ def create( now = self._clock() try: + if has_request_context() and session.get('_auth_session'): + from .auth_assurance import current_authentication_session + auth = current_authentication_session() + if auth is None or str(auth.user_id) != user_id: + raise ValueError('authentication is no longer available') with self._lock: if not self._accepting: raise RuntimeShuttingDown( diff --git a/app/user_settings.py b/app/user_settings.py index ac3cc099..9708536c 100644 --- a/app/user_settings.py +++ b/app/user_settings.py @@ -15,7 +15,7 @@ # Overview panels the account can arrange. Widths are columns of a 12-column # grid; a height of None follows the content, otherwise it is in 8px rows. -OVERVIEW_PANELS = ('kpis', 'topology', 'hosts', 'attention', 'automation', 'platforms', 'activity') +OVERVIEW_PANELS = ('kpis', 'topology', 'hosts', 'attention', 'automation', 'platforms', 'activity', 'continue') OVERVIEW_PANEL_WIDTHS = range(3, 13) OVERVIEW_PANEL_HEIGHTS = range(8, 161) OVERVIEW_DISMISSED_LIMIT = 100 diff --git a/docs/account-access-controls.md b/docs/account-access-controls.md new file mode 100644 index 00000000..139c445b --- /dev/null +++ b/docs/account-access-controls.md @@ -0,0 +1,76 @@ +# Account access controls + +## Browser logins + +Settings → Security overview lists this account's active server-side browser +logins, their authentication methods, start and expiry times, and the current +login. The management references shown to the browser are separate from the +credentials used to authenticate; session cookies and authentication hashes +are not returned. + +**Revoke login** ends one other browser login. **Revoke all other logins** keeps +the current login. Use the existing Sign out action to end the current login. +Expired logins are omitted. Recovery sessions cannot manage other logins before +repairing their authentication factors. + +Revocation removes the selected authentication records and associated step-up +state. Existing cookies, including Remember me, cannot recreate them. Attached +Socket.IO connections are disconnected, and their transfers, permission jobs +and connection attempts are cancelled. Login-bound credential access, manual +automation and discovery are revoked. Ongoing HTTP server-operation channels +recheck their captured authentication record and stop when it is revoked. +Transfer creation and socket admission are serialized against revocation so +an already-admitted browser cannot create new resources after the cancellation. + +SSH/tmux sessions belong to the account and remain available to its other valid +logins. Scheduled automation is also account-owned and is not disabled by +revoking a browser. Already-issued remote commands are not rolled back and may +continue on the remote system. Interrupted transfers can leave partial files. +If live-resource cleanup fails, the login is still revoked and the UI reports +that some resources could not be closed; an operator should investigate. + +## Disabling authentication features + +Before disabling an active Passkey, TOTP, OIDC, LDAP or recovery-code feature, +the administrator sees affected account and administrator counts, primary-path +loss and local MFA-factor loss. Expand the details to identify affected accounts +and their remaining configured primary, factor and recovery paths. Locked +accounts are listed but omitted from remaining-path totals. + +This is a configuration inspection, not a sign-in test. A stored local password +does not prove that its owner knows it. A configured provider does not prove +availability or external MFA assurance. Recovery codes permit restricted repair, +not a substitute unrestricted primary login. Test an alternative login before +disabling the current method. + +The approval expires after five minutes and is bound to the feature, approving +administrator, browser login and account/factor configuration. Saving rechecks +that configuration in the same SQLite write transaction as the feature change. +If it changed, review the updated impact and authenticate again. Existing +deployment/readiness limits and action-bound step-up remain required. + +Disabling a feature affects new logins and factor ceremonies. It does not +terminate existing browser, SSH or tmux sessions. + +## Account deletion and retained files + +The deletion confirmation names the selected account. Deletion revokes its +access and removes its database account. Its local user files are moved out of +the active user namespace into `DATA_DIR/deleted_users/user__/`. +This is quarantine, not secure erasure, and no automatic cleanup is performed. + +For later operator cleanup: + +1. Identify the actual persistent data directory or mounted data volume used by + this installation. Match the retained directory to the intended deleted + account; inspect its contents and applicable retention requirements. +2. Decide whether an offline archive is required. Existing backups and separate + archives have their own retention lifecycle and are not erased by account + deletion or quarantine cleanup. +3. During a controlled maintenance window, explicitly remove only the verified + quarantine directory using the operating system or volume-management tools. + Verify the resolved path remains below `DATA_DIR/deleted_users/`. Do not remove + the active `users/` directory, the database, or the complete data volume. + +The application deliberately provides no automatic purge or browser action for +this irreversible operator step. diff --git a/docs/credentials.md b/docs/credentials.md index 8bea5450..876bd75f 100644 --- a/docs/credentials.md +++ b/docs/credentials.md @@ -18,6 +18,14 @@ Sending a saved password to a destination it is not yet assigned to is treated l Changing address, port, username, authentication mode, jump route, SMB domain or share invalidates its destination approval. Explicitly approve the new target before reuse. Names and groups do not change its access identity. SSH host-key verification, ownership, interactive MFA, protocol policy and quotas remain enforced. +The password list counts assignments that need review. Each assignment in the +detail view shows **Ready** or **Review required**. Use **Review and authorize +again** to reload the current destination, inspect its exact address and account, +and confirm it through the existing target-bound authorization and account +verification. Cancelling leaves the assignment unusable; a concurrently changed +destination or assignment requires a fresh review. This does not test the remote +password or bypass host-key verification. + ## Availability and encryption New credentials have no separate passphrase or 15-minute unlock. The server decrypts them for authorized connections and explicitly approved schedules. The browser receives references, not saved passwords. diff --git a/docs/wiki/Backup-Restore-and-Secret-Rotation.md b/docs/wiki/Backup-Restore-and-Secret-Rotation.md index 0c767bbf..34807ff6 100644 --- a/docs/wiki/Backup-Restore-and-Secret-Rotation.md +++ b/docs/wiki/Backup-Restore-and-Secret-Rotation.md @@ -130,4 +130,4 @@ An untested archive is not a verified recovery capability. ## Recovery checklist in the application -The backup page lists included configuration/account data and excluded remote credentials/live sessions separately. Normal export restores write a small informational `recovery-check.json` timestamp; private rollback restore does not create a new successful-restore marker. **Recovery check** uses the signed-in account’s current host/key/password and schedule metadata to guide re-provisioning. It does not import excluded credentials, approve host keys, test connectivity or enable schedules. The timestamp is not a restore-drill result. +In **Settings Center → Backup & Restore**, expand **Backup coverage** to see included configuration/account data and excluded remote credentials/live sessions. The adjacent **Recovery check** opens the checklist for the signed-in account. Normal export restores write a small informational `recovery-check.json` timestamp; private rollback restore does not create a new successful-restore marker. The checklist uses the account’s current host/key/password and schedule metadata to guide re-provisioning. It does not import excluded credentials, approve host keys, test connectivity or enable schedules. The timestamp is not a restore-drill result. diff --git a/docs/wiki/Health-Checks-and-Troubleshooting.md b/docs/wiki/Health-Checks-and-Troubleshooting.md index 6af7b20b..e552e221 100644 --- a/docs/wiki/Health-Checks-and-Troubleshooting.md +++ b/docs/wiki/Health-Checks-and-Troubleshooting.md @@ -159,6 +159,19 @@ Slow or refused work can result from thread, socket, SSH, transfer, temporary-by ## Safe evidence collection +Open **Settings → Security overview → Support report** to generate a +local snapshot on demand. Select the affected area and optionally enter the +SMB reference shown by an error. Review the full JSON preview before copying +or downloading it. Nothing is sent to a support service automatically. + +The report includes the WebSSH version, UTC generation time, feature +availability and local readiness checks. Administrators additionally receive +three allowlisted deployment flags. It excludes usernames, host addresses, +paths, logs, terminal contents and credentials. It does not test SSH targets +or identity providers; feature availability does not prove a successful +connection. Changing the report options clears the old preview. Add your +reproduction steps and the actual error time separately when sharing it. + When requesting help, include: - exact WebSSH version or commit; diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md index bf4bcb52..ab8bef36 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -26,7 +26,9 @@ overview, screenshots, release badges, and source code, visit the and security controls suitable for a trusted self-hosted deployment. - A status-first overview with host readiness, open sessions, automation results, optional reachability checks and items that need attention. Each - account arranges the panels freely and can dismiss handled alerts. + account arranges the panels freely and can dismiss handled alerts. The + Continue working panel starts at the bottom; use Customize to move, resize, + hide or restore it. Existing saved layouts keep their arrangement. - Native automation with immediate and scheduled runs, optional encrypted saved passwords, and manual discovery of SSH hosts in a network range you choose. - A fully local browser runtime: vendored frontend dependencies and no built-in diff --git a/docs/wiki/SFTP-File-Workspace-and-Transfers.md b/docs/wiki/SFTP-File-Workspace-and-Transfers.md index 8cfaac99..27a38b55 100644 --- a/docs/wiki/SFTP-File-Workspace-and-Transfers.md +++ b/docs/wiki/SFTP-File-Workspace-and-Transfers.md @@ -4,6 +4,18 @@ The File Workspace uses SFTP over SSH and can optionally open temporary SMB shares. SFTP can reuse active terminal sessions, open saved SSH hosts, or create temporary SFTP-only connections. +## Transfer history + +The transfer panel keeps active, cancelling and failed or unconfirmed jobs +ahead of finished history. Use **Show** to filter active work, failures or +finished transfers, and **Previous** / **Next** to browse pages of 20 entries. +Older entries remain reachable when new transfers arrive. Each row shows its +source and destination; browser uploads and downloads explicitly identify +**This browser**. Remote addresses are captured when the transfer is queued, +so switching or closing a source tab does not relabel an existing transfer. +Cancellation, failure details and supported recovery actions remain on each row. +This history belongs to the current page and is not a persistent transfer log. + ## Sources and tabs The source launcher offers: diff --git a/docs/wiki/SSH-Connections-and-Host-Keys.md b/docs/wiki/SSH-Connections-and-Host-Keys.md index 2c4aee0f..1ceceed2 100644 --- a/docs/wiki/SSH-Connections-and-Host-Keys.md +++ b/docs/wiki/SSH-Connections-and-Host-Keys.md @@ -74,8 +74,25 @@ verified global trust record. A simultaneous approval cannot overwrite a conflicting stored key. Trust is scoped to the relevant user or administrator-managed global store. -Users can inspect and revoke their own trust records in the Security Center; -administrators can manage global trust. +Users can inspect and delete their own trust records in the Security Center; +administrators can manage global trust and explicit revocation records. + +**Personal SSH trust** also explains the effective keys for your saved SSH +profiles and jump hosts: fingerprint, algorithm, personal or global source, +overridden keys and revocations. This is a configuration snapshot, not a live +connection check. Global aliases and unrelated global hosts are not listed. +Personal keys take precedence for each algorithm; revocations in either store +block the matching key. Search and pagination keep larger target lists usable. + +The personal management table remains available for entries with no saved +profile. Deleting a personal entry does not revoke the key: another personal +entry or global trust may become effective. Before deletion, the preview shows +the exact remaining fingerprints and sources for affected saved targets, +including remaining revocations. Unmatched aliases, hashes and other wildcard +matches are explicitly marked as unknown. No connection is opened and no global +inventory is disclosed. A changed trust store invalidates the preview and the +delete request requires a fresh review. Removing a revocation can allow a +previously blocked key again. Verify fingerprints before reconnecting. ## Add global host trust diff --git a/docs/wiki/Terminal-and-Persistent-tmux-Sessions.md b/docs/wiki/Terminal-and-Persistent-tmux-Sessions.md index d1d6dd1c..fdd1a281 100644 --- a/docs/wiki/Terminal-and-Persistent-tmux-Sessions.md +++ b/docs/wiki/Terminal-and-Persistent-tmux-Sessions.md @@ -9,7 +9,7 @@ The terminal supports: - multiple session tabs; - one-, two-, and four-pane layouts; -- broadcast input across open sessions; +- broadcast input to explicitly selected sessions; - a command palette with `Ctrl+K`; - terminal search with `Ctrl+F`; - tab switching with `Ctrl+1` through `Ctrl+9`; @@ -101,8 +101,22 @@ capability checks and application-specific setup. ## Broadcast input -Broadcast mode sends the same input to every open SSH session. Treat it as a -high-impact operation: +Broadcast mode sends the same input to explicitly selected connected SSH +sessions. Opening broadcast shows a recipient picker with each `user@host:port` +and session identifier, so sessions with identical names remain distinguishable. +The selection stays fixed: new sessions are not added automatically. A removed, +disconnected or reconnected recipient blocks sending until you choose the +recipients again. Use **Recipients** to review or change the selection. + +The compact command field preserves pasted line breaks; pasting or editing does +not send anything. Press **Shift+Enter** to add a line, or **Enter** / **Send to +selected** to submit. Multiline or control-character input opens the same safety +preview used by terminal paste, listing every selected recipient. Confirmation +is bound to that complete selection; changing it or reconnecting a recipient +cancels the pending send. Cancel keeps the draft. Broadcast appends Enter when +sending, including after removal of trailing line breaks in the preview. + +Treat broadcast as a high-impact operation: - verify the visible target set; - start with a harmless command; @@ -110,6 +124,30 @@ high-impact operation: - disable broadcast immediately after the intended action. WebSSH does not interpret whether a command is safe for every target. +The command is retained if a recipient rejects input. A partial-send warning +means some sessions may already have executed it; inspect those sessions before +retrying. + +## Review pasted terminal input + +Pasting a safe single line into a terminal remains immediate. Text containing +line breaks or control characters opens a preview showing the exact destination +and visible markers such as `[LF]`, `[CR]`, `[TAB]` and `[ESC]`. You can remove +trailing line breaks before confirming. This is an input review, not a judgment +that the command is safe. + +The review covers browser and context-menu paste, the command palette's +**Paste Clipboard** action, and the mobile command input. Pasting such text into +the mobile command field opens the same review before the browser can strip line +breaks; confirmation pastes directly into the displayed terminal. Cancel sends +nothing. Switching sessions, closing a session or losing its connection cancels +the pending review, including an outstanding clipboard read. Reconnect and +switching back do not reuse the earlier approval. + +Confirmed terminal paste retains xterm's bracketed-paste behavior when the +remote application enables it. A terminal without bracketed-paste support may +execute each pasted line immediately. Input continues through the existing +UTF-8 chunking and backpressure handling. ## Focused session workspace @@ -277,6 +315,6 @@ Run `node tests/integration/tmux_sync.cjs` after installing the project Node dep ## Named workspace presets -Open **Workspace presets** in the desktop terminal toolbar or account menu. Save a name, one/two/four panes and up to that many of your saved hosts. Presets belong to your account and retain only layout and host references, never passwords, commands or terminal output. Applying prepares the layout and offers an explicit Connect button for each host; it never logs in or executes commands by itself. Existing assigned sessions stay in place, and a smaller preset does not shrink their layout. More than four open floating windows must be docked or reduced before applying a preset. Deleted hosts are shown as unavailable; remove the affected preset and save updated choices. +Open **Layout → Workspace presets** in the workspace toolbar, on desktop or mobile. Save a name, one/two/four panes and up to that many of your saved hosts. Presets belong to your account and retain only layout and host references, never passwords, commands or terminal output. Applying prepares the layout and offers an explicit Connect button for each host; it never logs in or executes commands by itself. Existing assigned sessions stay in place, and a smaller preset does not shrink their layout. More than four open floating windows must be docked or reduced before applying a preset. Deleted hosts are shown as unavailable; remove the affected preset and save updated choices. -The session continuity message distinguishes browser transport loss from remote SSH disconnection and explains the different normal-SSH and tmux guarantees. Reconnection never proves that a previous command succeeded; inspect the remote state before repeating work. +Reconnection never proves that a previous command succeeded; inspect the remote state before repeating work. diff --git a/docs/wiki/Users-and-Account-Management.md b/docs/wiki/Users-and-Account-Management.md index 421942b2..b3cfe980 100644 --- a/docs/wiki/Users-and-Account-Management.md +++ b/docs/wiki/Users-and-Account-Management.md @@ -104,6 +104,16 @@ user to change it. Passwords are bcrypt hashes at rest. New or changed passwords are limited to 72 bytes after UTF-8 encoding because of bcrypt's input boundary. +## Browser logins + +To end selected browser access without locking the account, open **Settings → +Security overview → Active logins**. Revoke one other login or all other logins. +The current login and account-owned SSH/tmux sessions remain available; the +selected browser connections, transfers and login-bound jobs are interrupted. +See [account access controls](https://github.com/bifrost0x/webssh/blob/main/docs/account-access-controls.md) +for exact revocation effects and the impact preview before disabling an +authentication method. + ## Locking an account Locking prevents new HTTP and Socket.IO authorization. WebSSH also revokes the @@ -131,6 +141,11 @@ This quarantine prevents a future numeric user ID from inheriting the deleted account's files. It is retention, not secure erasure. Operators must define a review and secure-disposal policy for `deleted_users`. +The confirmation states that files are retained. Follow the +[operator cleanup guidance](https://github.com/bifrost0x/webssh/blob/main/docs/account-access-controls.md#account-deletion-and-retained-files) +to remove a verified quarantine directory later; existing backups have their +own retention lifecycle. + If database deletion fails, WebSSH attempts to move the quarantined directory back into the active namespace. diff --git a/static/css/account-controls.css b/static/css/account-controls.css new file mode 100644 index 00000000..0ea73d26 --- /dev/null +++ b/static/css/account-controls.css @@ -0,0 +1,22 @@ +.account-login-list { list-style: none; margin: 0; padding: 0; } +.account-login-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 14px 0; border-bottom: 1px solid var(--border-color, #ffffff20); } +.account-login-row p { margin: 5px 0 0; overflow-wrap: anywhere; } +.account-controls-actions { display: flex; gap: 8px; flex-wrap: wrap; margin: 12px 0; } +.account-impact-dialog { margin: auto; color: var(--text-primary, #e7eaf3); background: var(--bg-secondary, #182030); border: 1px solid var(--border-color, #ffffff30); border-radius: 12px; padding: 24px; width: min(620px, calc(100vw - 32px)); max-height: calc(100dvh - 32px); overflow: hidden; box-sizing: border-box; } +.account-impact-dialog[open] { display: flex; flex-direction: column; } +.account-impact-dialog h2, .account-impact-dialog > .account-controls-actions { flex: 0 0 auto; } +.account-impact-body { overflow: auto; min-height: 0; } +.account-impact-counts { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin: 0 0 16px; } +.account-impact-counts dt { color: var(--text-secondary); font-size: .75rem; } +.account-impact-counts dd { margin: 5px 0 0; font-size: 1.65rem; font-weight: 600; } +.account-impact-warning { color: var(--warning-color, #e4b15b); font-weight: 500; } +.account-login-effect { grid-column: 2; } +.account-login-effect summary { cursor: pointer; color: var(--text-secondary); font-size: .85rem; } +.account-login-effect p { padding: 10px 0; } +.account-impact-dialog::backdrop { background: #0009; } +.account-impact-dialog h2 { margin: 0 0 18px; font-size: 1.2rem; } +.account-impact-dialog p { margin: 10px 0; line-height: 1.5; font-size: .9rem; overflow-wrap: anywhere; } +.account-impact-accounts { padding-left: 20px; } +.account-impact-accounts strong { overflow-wrap: anywhere; } +.account-impact-dialog summary { cursor: pointer; padding: 10px 0; } +@media (max-width: 600px) { .account-login-row { align-items: flex-start; flex-direction: column; } .account-impact-dialog { padding: 18px; } .account-login-effect { grid-column: 1 / -1; } } diff --git a/static/css/connection-overview.css b/static/css/connection-overview.css index ed5a0248..8872e455 100644 --- a/static/css/connection-overview.css +++ b/static/css/connection-overview.css @@ -156,16 +156,6 @@ body.webssh-shell { .overview-card-heading { gap: 8px; } } @media (min-width: 768px) and (max-height: 650px) { .webssh-shell .shell-groups { display: none !important; } } -/* Compact navigation preserves the existing two/four-pane working widths. */ -@media (min-width: 1101px) and (not ((max-height: 520px) and (pointer: coarse))) { - body.webssh-shell[data-primary-workspace="workspaces"]:has(.terminal-grid:is(.split-2, .split-4)) { --shell-rail: 72px; } - .webssh-shell[data-primary-workspace="workspaces"]:has(.terminal-grid:is(.split-2, .split-4)) .header-title-row { padding: 0 6px; } - .webssh-shell[data-primary-workspace="workspaces"]:has(.terminal-grid:is(.split-2, .split-4)) .app-brand-logo { width: 60px; } - .webssh-shell[data-primary-workspace="workspaces"]:has(.terminal-grid:is(.split-2, .split-4)) .global-nav-item { justify-content: center; padding: 11px; } - .webssh-shell[data-primary-workspace="workspaces"]:has(.terminal-grid:is(.split-2, .split-4)) .global-nav-item span:not(.material-icons), - .webssh-shell[data-primary-workspace="workspaces"]:has(.terminal-grid:is(.split-2, .split-4)) .shell-groups { display: none; } -} - /* Connection launch board: visible records become compact launch tiles; history * and existing browser sessions stay in a distinct secondary activity column. */ /* Welcome atlas: an origin, a configured route, and a bounded group inventory. */ diff --git a/static/css/customer-workflows.css b/static/css/customer-workflows.css index edd4f736..cba44ef0 100644 --- a/static/css/customer-workflows.css +++ b/static/css/customer-workflows.css @@ -47,9 +47,13 @@ .customer-saved { color:var(--ws2-success, #4ade80); font-size:.875rem; } .customer-dialog :disabled { opacity:.55; cursor:not-allowed; } @media (max-width:540px) { .customer-dialog-header, .customer-dialog-footer { padding:.75rem 1rem; } .customer-dialog-body { padding:1rem; } .customer-dialog fieldset { grid-template-columns:1fr; } .customer-row-actions { width:100%; } .customer-row-actions .btn:first-child { flex:1; } .customer-dialog-footer > .btn { width:100%; } } -.workspace-continuity { margin: .25rem .75rem; max-width: 75ch; color: var(--ws2-text-secondary, var(--text-secondary)); font-size: .875rem; line-height: 1.5; } .fm-recovery-guidance { margin-block: .5rem; font-size: .875rem; line-height: 1.5; overflow-wrap: anywhere; } -.backup-coverage { margin-block: 1rem; line-height: 1.6; } +.backup-coverage { display:flex; align-items:flex-start; flex-wrap:wrap; gap:.75rem 1.5rem; margin-block:1rem; padding-bottom:1rem; border-bottom:1px solid var(--border-color); } +.backup-coverage details { flex:1 1 18rem; min-width:0; } +.backup-coverage summary { width:fit-content; padding-block:.5rem; cursor:pointer; font-size:.875rem; font-weight:600; } +.backup-coverage p { max-width:80ch; margin:.5rem 0 0; line-height:1.6; } +.backup-coverage .btn { flex:none; margin:0; } +.backup-coverage summary:focus-visible { outline:2px solid var(--accent-primary); outline-offset:3px; } .customer-dialog a { color: var(--ws2-accent, var(--accent-color, #58a6ff)); text-underline-offset: .2em; } .customer-dialog :is(button,a,input,select,summary):focus-visible { outline: 2px solid var(--ws2-accent, var(--accent-color, #58a6ff)); outline-offset: 3px; } diff --git a/static/css/midnight-ui.css b/static/css/midnight-ui.css index a1dc0bc5..6c1bed3a 100644 --- a/static/css/midnight-ui.css +++ b/static/css/midnight-ui.css @@ -147,10 +147,6 @@ body.webssh-ui[data-theme="glass"] { .overview-heading-copy { max-width: 460px; } .connection-overview:not(.overview-graph) .overview-hosts { grid-template-columns: minmax(0, 1fr); } } -@media (min-width: 1101px) and (not ((max-height: 520px) and (pointer: coarse))) { - .webssh-shell[data-primary-workspace="workspaces"]:has(.terminal-grid:is(.split-2, .split-4)) .brand-wordmark { display: none; } - .webssh-shell[data-primary-workspace="workspaces"]:has(.terminal-grid:is(.split-2, .split-4)) .app-brand-logo { width: 38px; } -} @media (max-width: 900px) { .overview-route-card { order: 2; } .overview-recents-card { order: 3; } .overview-sessions-card { order: 4; } } @media (max-width: 767px), (max-height: 520px) and (pointer: coarse) { .webssh-shell .brand-wordmark { font-size: 19px; } diff --git a/static/css/overview-workspace.css b/static/css/overview-workspace.css index 1af3a5a9..c0ddf31b 100644 --- a/static/css/overview-workspace.css +++ b/static/css/overview-workspace.css @@ -542,8 +542,9 @@ body.webssh-ui[data-theme="glass"]:not([data-custom-background]) .od-scene { bac .od-host:hover, .od-host:focus-within { transform: none; } } -/* Task shortcuts precede the customizable board without changing saved layouts. */ +/* Task shortcuts share the board's arrangement, sizing and visibility controls. */ .od-work-first { padding: 16px 20px; border: 1px solid var(--ws2-border); border-radius: var(--od-radius); background: var(--ws2-panel); color: var(--ws2-text); } +.od-board[data-editing="true"] > .od-work-first { padding-top: 28px; } .od-work-first .od-panel-head { flex-wrap: wrap; gap: 8px; padding: 0 0 12px; } .od-work-first h2 { margin: 0; font-size: 15px; font-weight: 600; } .od-work-first .od-panel-head a { font-size: 12px; } diff --git a/static/css/support-report.css b/static/css/support-report.css new file mode 100644 index 00000000..c4c1902d --- /dev/null +++ b/static/css/support-report.css @@ -0,0 +1,22 @@ +.support-report-dialog { margin: auto; padding: 0; width: min(44rem, calc(100vw - 2rem)); max-height: calc(100dvh - 2rem); color: var(--text-primary); background: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: 12px; overflow: hidden; } +.support-report-dialog[open] { display: flex; flex-direction: column; } +.support-report-dialog::backdrop { background: rgb(0 0 0 / 65%); } +.support-report-header, .support-report-footer { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: .75rem; padding: 1rem 1.5rem; } +.support-report-header { border-bottom: 1px solid var(--border-color); } +.support-report-footer { justify-content: flex-end; border-top: 1px solid var(--border-color); } +.support-report-header h2 { margin: 0; font-size: 1.2rem; } +.support-report-body { padding: 1.25rem 1.5rem; overflow: auto; overscroll-behavior: contain; min-height: 0; scrollbar-width: thin; } +.support-report-body p { margin: 0 0 1rem; font-size: .875rem; line-height: 1.55; max-width: 70ch; } +.support-report-form { display: grid; gap: .5rem; } +.support-report-form label { font-size: .875rem; font-weight: 600; } +.support-report-form label:not(:first-child) { margin-top: .5rem; } +.support-report-form .form-control { width: 100%; box-sizing: border-box; font-size: 1rem; min-height: 2.75rem; } +.support-report-form .btn { justify-self: start; margin-block: .5rem 1rem; } +.support-report-dialog .btn { min-height: 2.75rem; white-space: normal; } +.support-report-body h3 { font-size: .875rem; margin: 1rem 0 .75rem; } +.support-report-body pre { white-space: pre-wrap; overflow-wrap: anywhere; padding: 1rem; margin: 0; background: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border-color); border-radius: 6px; font-size: .8125rem; line-height: 1.6; } +.support-report-dialog :is(button, input, select, pre):focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 3px; } +.support-report-dialog :disabled { opacity: .55; cursor: not-allowed; } +.support-report-dialog ::selection { background: var(--accent-primary); color: var(--bg-primary); } +[data-support-report] { min-height: 2.75rem; white-space: normal; } +@media (max-width: 540px) { .support-report-header, .support-report-footer, .support-report-body { padding: 1rem; } .support-report-footer .btn { flex: 1 1 9rem; } } diff --git a/static/css/terminal-safety.css b/static/css/terminal-safety.css new file mode 100644 index 00000000..dc33d168 --- /dev/null +++ b/static/css/terminal-safety.css @@ -0,0 +1,52 @@ +/* Focused safety controls reuse the existing workspace palette and buttons. */ +.terminal-safety-dialog { + color: var(--text-primary); + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: var(--radius-lg, 12px); + padding: 24px; + margin: auto; + width: min(640px, calc(100vw - 32px)); + max-height: calc(100dvh - 32px); + overflow: auto; + box-sizing: border-box; +} +.terminal-safety-dialog::backdrop { background: rgba(0, 0, 0, .6); } +.terminal-safety-dialog h2 { font-size: 20px; margin: 0 0 16px; } +.terminal-safety-dialog p { margin: 0 0 16px; line-height: 1.5; overflow-wrap: anywhere; } +.terminal-safety-target { font-family: var(--font-mono); font-size: 14px; white-space: pre-wrap; } +.terminal-paste-preview { + background: var(--bg-primary); + border: 1px solid var(--border-color); + padding: 16px; + margin: 16px 0; + max-height: 36dvh; + overflow: auto; + white-space: pre-wrap; + overflow-wrap: anywhere; + font-family: var(--font-mono); + font-size: 14px; + line-height: 1.5; + unicode-bidi: plaintext; +} +.terminal-safety-option, .terminal-recipient { display: flex; align-items: center; gap: 12px; min-height: 44px; } +.terminal-safety-dialog input[type=checkbox] { accent-color: var(--accent-primary); flex-shrink: 0; width: 18px; height: 18px; } +.terminal-safety-actions { display: flex; justify-content: flex-end; flex-wrap: wrap; gap: 10px; margin-top: 24px; } +.terminal-safety-actions .btn { min-height: 44px; } +.terminal-recipient-list { max-height: 45dvh; overflow-y: auto; } +.terminal-recipient { padding: 12px 0; border-bottom: 1px solid var(--border-color); cursor: pointer; } +.terminal-recipient > span { min-width: 0; overflow-wrap: anywhere; } +.terminal-recipient strong, .terminal-recipient small { display: block; } +.terminal-recipient small { margin-top: 4px; color: var(--text-secondary); font-size: 14px; } +#broadcastBar { flex-wrap: wrap; } +#broadcastInput { resize: vertical; min-height: 42px; max-height: 180px; line-height: 1.5; } +.broadcast-selection { flex: 0 0 100%; min-width: 0; font-size: 13px; } +#broadcastTargets { display: block; overflow-wrap: anywhere; max-height: 72px; overflow-y: auto; } +#broadcastSelectionStatus { display: block; margin-top: 4px; } +#broadcastRecipientError { color: var(--error-color); } +@media (max-width: 600px) { + .terminal-safety-dialog { padding: 20px; font-size: 16px; } + #broadcastInput { flex-basis: 100%; font-size: 16px; } + .terminal-safety-actions { justify-content: stretch; } + .terminal-safety-actions .btn { flex: 1 1 auto; } +} diff --git a/static/css/terminal-windows.css b/static/css/terminal-windows.css index 076ee7f0..54de2d0d 100644 --- a/static/css/terminal-windows.css +++ b/static/css/terminal-windows.css @@ -528,12 +528,14 @@ body[data-theme] .ws-window .terminal-pane.active { } .workspace-layout-options .split-controls, -.workspace-layout-options .window-arrange-controls { +.workspace-layout-options .window-arrange-controls, +.workspace-layout-presets { display: grid; gap: 4px; } -.workspace-layout-options .window-arrange-controls { +.workspace-layout-options .window-arrange-controls, +.workspace-layout-presets { border-top: 1px solid var(--border-color); margin-top: 6px; padding-top: 6px; @@ -612,7 +614,10 @@ body.terminal-window-mode .session-tab.is-minimized:hover { } @media (max-width: 767px) { - .workspace-layout-picker { display: none; } + .session-tabs-row > .tab-row-actions { display:flex; } + .tab-row-actions > :not(.workspace-layout-picker) { display:none; } + .workspace-layout-picker > summary { width:32px; padding:0; justify-content:center; } + .workspace-layout-picker > summary > :not(:first-child) { display:none; } } @media (prefers-reduced-motion: reduce) { diff --git a/static/css/trust-files-controls.css b/static/css/trust-files-controls.css new file mode 100644 index 00000000..0768d795 --- /dev/null +++ b/static/css/trust-files-controls.css @@ -0,0 +1,25 @@ +.fm-queue-controls { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 8px 12px; border-bottom: 1px solid var(--border-color); } +.fm-queue-controls label { display: flex; align-items: center; gap: 8px; flex: 1 1 230px; min-width: 0; } +.fm-queue-controls select { min-width: 0; width: auto; flex: 1; padding: 5px 8px; } +.fm-queue-controls button { padding: 5px 10px; } +.fm-queue.collapsed .fm-queue-controls { display: none; } +.fm-transfer-endpoints { font-size: 12px; color: var(--text-secondary); white-space: normal; overflow-wrap: anywhere; margin: 3px 0; } +.effective-trust-target { padding: 10px 0; border-bottom: 1px solid var(--border-color); } +.effective-trust-target summary { cursor: pointer; overflow-wrap: anywhere; } +.effective-trust-key { display: flex; flex-direction: column; gap: 5px; margin: 10px 0; } +.effective-trust-key code { white-space: normal; overflow-wrap: anywhere; } +.effective-trust-pagination[hidden] { display: none; } +.trust-deletion-dialog { margin: auto; width: min(720px, calc(100vw - 32px)); max-height: calc(100dvh - 40px); overflow: auto; background: var(--bg-secondary); color: var(--text-primary); border: 1px solid var(--border-color); border-radius: 8px; padding: 20px; } +.trust-deletion-dialog::backdrop { background: #0009; } +.trust-deletion-dialog h2, .trust-deletion-dialog h3, .trust-deletion-dialog p { margin-bottom: 12px; } +.trust-deletion-dialog code { overflow-wrap: anywhere; } +.trust-deletion-dialog > div { margin-top: 16px; } +.trust-deletion-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 12px; } +.credential-assignment-status { display: block; font-size: 12px; color: var(--text-secondary); margin: 5px 0; } +.credential-assignment-invalid .credential-assignment-status { color: var(--warning-color, var(--text-primary)); } +@media (max-width: 600px) { + .fm-queue-controls label { flex-basis: 100%; } + .fm-queue-controls button { min-height: 36px; } + .credential-assignments ~ ul { padding-left: 18px; } + .credential-assignments ~ ul li { margin-bottom: 12px; } +} diff --git a/static/js/account-controls-i18n.js b/static/js/account-controls-i18n.js new file mode 100644 index 00000000..f65462de --- /dev/null +++ b/static/js/account-controls-i18n.js @@ -0,0 +1,258 @@ +(function () { + 'use strict'; + const bundles = { + "en": { + "accountControls.shortHint": "Review where your account is signed in.", + "accountControls.effectTitle": "What does revocation do?", + "accountControls.affectedAccounts": "Accounts affected", + "accountControls.affectedAdmins": "Administrators", + "accountControls.withoutPrimary": "Without primary path", + "accountControls.configuredOnly": "Configured, not tested. Test an alternative sign-in first; provider availability and MFA have not been verified.", + "accountControls.affectedList": "Review affected accounts", + "accountControls.admin": "Administrator", + "accountControls.locked": "Locked", + "accountControls.accountPaths": "Primary: {primary} · Factors / recovery: {factors}", + "accountControls.title": "Active logins", + "accountControls.hint": "Review browser logins for your account. Revoking a login disconnects its browser connections and cancels its transfers and login-bound jobs. Shared SSH/tmux sessions and schedules remain available to valid logins.", + "accountControls.revokeOthers": "Revoke all other logins", + "accountControls.current": "This login", + "accountControls.revoke": "Revoke login", + "accountControls.dates": "Started: {start} · Expires: {expiry}", + "accountControls.others": "All other logins", + "accountControls.confirmRevoke": "Revoke this access? The selected browser connections, their transfers and login-bound jobs will stop. Shared SSH/tmux sessions remain available to valid logins.", + "accountControls.cleanupWarning": "Login access was revoked, but some live resources could not be closed. Contact the operator.", + "accountControls.revoked": "Selected login access revoked.", + "accountControls.failed": "Could not update logins. Refresh and try again.", + "accountControls.delete": "Delete account \"{user}\"? Its access will be revoked and its account removed. User files are retained in quarantine until an operator explicitly removes them.", + "accountControls.disableTitle": "Disable {feature}?", + "accountControls.continue": "Continue to authentication", + "accountControls.none": "None", + "accountControls.affected": "Affected accounts: {accounts}; administrators: {admins}; already locked: {locked}.", + "accountControls.primary": "Remaining configured primary sign-in paths (unlocked affected accounts): ", + "accountControls.factors": "Remaining configured local factors / recovery paths: ", + "accountControls.noPrimary": "Without a configured primary path: {accounts}; administrators: {admins}.", + "accountControls.noMfa": "MFA required but no remaining local Passkey/TOTP: {accounts}; administrators: {admins}.", + "accountControls.unverified": "These paths are configured, not tested. An identity provider may supply MFA; its availability and assurance have not been verified. Recovery codes allow restricted repair only. Test an alternative sign-in before disabling a method.", + "accountControls.featureEffect": "Existing browser, SSH and tmux sessions are not terminated. New sign-ins and factor setup use the changed rule immediately.", + "accountControls.method.password": "Password", + "accountControls.method.passkey": "Passkey", + "accountControls.method.totp": "Authenticator app", + "accountControls.method.recovery": "Recovery codes", + "accountControls.method.recovery_code": "Recovery code", + "accountControls.method.oidc": "Identity provider", + "accountControls.method.ldap": "LDAP", + "accountControls.method.github": "GitHub" + }, + "de": { + "accountControls.shortHint": "Prüfe die aktiven Anmeldungen deines Kontos.", + "accountControls.effectTitle": "Was bewirkt ein Widerruf?", + "accountControls.affectedAccounts": "Betroffene Konten", + "accountControls.affectedAdmins": "Administratoren", + "accountControls.withoutPrimary": "Ohne primären Weg", + "accountControls.configuredOnly": "Konfiguriert, nicht getestet. Teste zuerst einen alternativen Anmeldeweg; Anbieter-Verfügbarkeit und MFA wurden nicht geprüft.", + "accountControls.affectedList": "Betroffene Konten prüfen", + "accountControls.admin": "Administrator", + "accountControls.locked": "Gesperrt", + "accountControls.accountPaths": "Primär: {primary} · Faktoren / Wiederherstellung: {factors}", + "accountControls.title": "Aktive Anmeldungen", + "accountControls.hint": "Prüfe die Browser-Anmeldungen deines Kontos. Ein Widerruf trennt deren Browser-Verbindungen und beendet ihre Transfers sowie an die Anmeldung gebundene Aufträge. Gemeinsame SSH-/tmux-Sitzungen und Zeitpläne bleiben für gültige Anmeldungen verfügbar.", + "accountControls.revokeOthers": "Alle anderen Anmeldungen widerrufen", + "accountControls.current": "Diese Anmeldung", + "accountControls.revoke": "Anmeldung widerrufen", + "accountControls.dates": "Beginn: {start} · Ablauf: {expiry}", + "accountControls.others": "Alle anderen Anmeldungen", + "accountControls.confirmRevoke": "Diesen Zugriff widerrufen? Die ausgewählten Browser-Verbindungen, ihre Transfers und an die Anmeldung gebundenen Aufträge werden beendet. Gemeinsame SSH-/tmux-Sitzungen bleiben für gültige Anmeldungen verfügbar.", + "accountControls.cleanupWarning": "Der Anmeldezugriff wurde widerrufen, aber einige laufende Ressourcen konnten nicht geschlossen werden. Kontaktiere den Betreiber.", + "accountControls.revoked": "Ausgewählter Anmeldezugriff widerrufen.", + "accountControls.failed": "Anmeldungen konnten nicht aktualisiert werden. Aktualisiere die Liste und versuche es erneut.", + "accountControls.delete": "Konto „{user}“ löschen? Der Zugriff wird widerrufen und das Konto entfernt. Benutzerdateien bleiben in Quarantäne erhalten, bis ein Betreiber sie ausdrücklich entfernt.", + "accountControls.disableTitle": "{feature} deaktivieren?", + "accountControls.continue": "Weiter zur Authentifizierung", + "accountControls.none": "Keine", + "accountControls.affected": "Betroffene Konten: {accounts}; Administratoren: {admins}; bereits gesperrt: {locked}.", + "accountControls.primary": "Verbleibende konfigurierte primäre Anmeldewege (nicht gesperrte betroffene Konten): ", + "accountControls.factors": "Verbleibende konfigurierte lokale Faktoren / Wiederherstellungswege: ", + "accountControls.noPrimary": "Ohne konfigurierten primären Anmeldeweg: {accounts}; Administratoren: {admins}.", + "accountControls.noMfa": "MFA erforderlich, aber kein lokaler Passkey/TOTP mehr vorhanden: {accounts}; Administratoren: {admins}.", + "accountControls.unverified": "Diese Wege sind konfiguriert, nicht getestet. Ein Identitätsanbieter kann MFA bereitstellen; seine Verfügbarkeit und Sicherheitsstufe wurden nicht geprüft. Wiederherstellungscodes erlauben nur eingeschränkte Reparaturen. Teste vor dem Deaktivieren einen alternativen Anmeldeweg.", + "accountControls.featureEffect": "Bestehende Browser-, SSH- und tmux-Sitzungen werden nicht beendet. Neue Anmeldungen und die Einrichtung von Faktoren folgen sofort der geänderten Regel.", + "accountControls.method.password": "Passwort", + "accountControls.method.passkey": "Passkey", + "accountControls.method.totp": "Authenticator-App", + "accountControls.method.recovery": "Wiederherstellungscodes", + "accountControls.method.recovery_code": "Wiederherstellungscode", + "accountControls.method.oidc": "Identitätsanbieter", + "accountControls.method.ldap": "LDAP", + "accountControls.method.github": "GitHub" + }, + "fr": { + "accountControls.shortHint": "Vérifiez où votre compte est connecté.", + "accountControls.effectTitle": "Que fait la révocation ?", + "accountControls.affectedAccounts": "Comptes concernés", + "accountControls.affectedAdmins": "Administrateurs", + "accountControls.withoutPrimary": "Sans moyen principal", + "accountControls.configuredOnly": "Configuré, pas testé. Testez d’abord un autre moyen de connexion ; la disponibilité du fournisseur et sa MFA n’ont pas été vérifiées.", + "accountControls.affectedList": "Vérifier les comptes concernés", + "accountControls.admin": "Administrateur", + "accountControls.locked": "Verrouillé", + "accountControls.accountPaths": "Principal : {primary} · Facteurs / récupération : {factors}", + "accountControls.title": "Connexions actives", + "accountControls.hint": "Vérifiez les connexions de navigateur de votre compte. Une révocation ferme leurs connexions, transferts et tâches liés à la connexion. Les sessions SSH/tmux partagées et les planifications restent disponibles aux connexions valides.", + "accountControls.revokeOthers": "Révoquer toutes les autres connexions", + "accountControls.current": "Cette connexion", + "accountControls.revoke": "Révoquer la connexion", + "accountControls.dates": "Début : {start} · Expiration : {expiry}", + "accountControls.others": "Toutes les autres connexions", + "accountControls.confirmRevoke": "Révoquer cet accès ? Les connexions de navigateur sélectionnées, leurs transferts et leurs tâches liées seront arrêtés. Les sessions SSH/tmux partagées restent disponibles aux connexions valides.", + "accountControls.cleanupWarning": "L’accès a été révoqué, mais certaines ressources actives n’ont pas pu être fermées. Contactez l’opérateur.", + "accountControls.revoked": "Accès sélectionné révoqué.", + "accountControls.failed": "Impossible de mettre à jour les connexions. Actualisez puis réessayez.", + "accountControls.delete": "Supprimer le compte « {user} » ? Son accès sera révoqué et son compte supprimé. Les fichiers sont conservés en quarantaine jusqu’à leur suppression explicite par un opérateur.", + "accountControls.disableTitle": "Désactiver {feature} ?", + "accountControls.continue": "Continuer vers l’authentification", + "accountControls.none": "Aucun", + "accountControls.affected": "Comptes concernés : {accounts} ; administrateurs : {admins} ; déjà verrouillés : {locked}.", + "accountControls.primary": "Moyens principaux configurés restants (comptes concernés non verrouillés) : ", + "accountControls.factors": "Facteurs locaux / moyens de récupération configurés restants : ", + "accountControls.noPrimary": "Sans moyen principal configuré : {accounts} ; administrateurs : {admins}.", + "accountControls.noMfa": "MFA requise mais aucun Passkey/TOTP local restant : {accounts} ; administrateurs : {admins}.", + "accountControls.unverified": "Ces moyens sont configurés, pas testés. Un fournisseur d’identité peut fournir la MFA ; sa disponibilité et son niveau de sécurité n’ont pas été vérifiés. Les codes de récupération ne permettent qu’une réparation limitée. Testez un autre moyen avant toute désactivation.", + "accountControls.featureEffect": "Les sessions existantes du navigateur, SSH et tmux ne sont pas arrêtées. Les nouvelles connexions et configurations de facteurs appliquent immédiatement la nouvelle règle.", + "accountControls.method.password": "Mot de passe", + "accountControls.method.passkey": "Passkey", + "accountControls.method.totp": "Application d’authentification", + "accountControls.method.recovery": "Codes de récupération", + "accountControls.method.recovery_code": "Code de récupération", + "accountControls.method.oidc": "Fournisseur d’identité", + "accountControls.method.ldap": "LDAP", + "accountControls.method.github": "GitHub" + }, + "es": { + "accountControls.shortHint": "Revisa dónde ha iniciado sesión tu cuenta.", + "accountControls.effectTitle": "¿Qué hace la revocación?", + "accountControls.affectedAccounts": "Cuentas afectadas", + "accountControls.affectedAdmins": "Administradores", + "accountControls.withoutPrimary": "Sin vía principal", + "accountControls.configuredOnly": "Configurado, no probado. Prueba primero otra vía de acceso; no se han verificado la disponibilidad del proveedor ni su MFA.", + "accountControls.affectedList": "Revisar las cuentas afectadas", + "accountControls.admin": "Administrador", + "accountControls.locked": "Bloqueada", + "accountControls.accountPaths": "Principal: {primary} · Factores / recuperación: {factors}", + "accountControls.title": "Inicios de sesión activos", + "accountControls.hint": "Revisa los inicios de sesión de tu cuenta. La revocación desconecta sus navegadores y cancela sus transferencias y trabajos vinculados al inicio de sesión. Las sesiones SSH/tmux compartidas y las programaciones siguen disponibles para los accesos válidos.", + "accountControls.revokeOthers": "Revocar los demás inicios de sesión", + "accountControls.current": "Este inicio de sesión", + "accountControls.revoke": "Revocar acceso", + "accountControls.dates": "Inicio: {start} · Caducidad: {expiry}", + "accountControls.others": "Todos los demás inicios de sesión", + "accountControls.confirmRevoke": "¿Revocar este acceso? Se detendrán las conexiones de navegador seleccionadas, sus transferencias y trabajos vinculados. Las sesiones SSH/tmux compartidas siguen disponibles para los accesos válidos.", + "accountControls.cleanupWarning": "El acceso se revocó, pero no se pudieron cerrar algunos recursos activos. Contacta con el operador.", + "accountControls.revoked": "Acceso seleccionado revocado.", + "accountControls.failed": "No se pudieron actualizar los inicios de sesión. Actualiza e inténtalo de nuevo.", + "accountControls.delete": "¿Eliminar la cuenta «{user}»? Se revocará su acceso y se eliminará la cuenta. Los archivos se conservan en cuarentena hasta que un operador los elimine explícitamente.", + "accountControls.disableTitle": "¿Desactivar {feature}?", + "accountControls.continue": "Continuar a la autenticación", + "accountControls.none": "Ninguno", + "accountControls.affected": "Cuentas afectadas: {accounts}; administradores: {admins}; ya bloqueadas: {locked}.", + "accountControls.primary": "Vías principales configuradas restantes (cuentas afectadas sin bloquear): ", + "accountControls.factors": "Factores locales / vías de recuperación configurados restantes: ", + "accountControls.noPrimary": "Sin vía principal configurada: {accounts}; administradores: {admins}.", + "accountControls.noMfa": "MFA obligatoria sin Passkey/TOTP local restante: {accounts}; administradores: {admins}.", + "accountControls.unverified": "Estas vías están configuradas, no probadas. Un proveedor de identidad puede aportar MFA; no se han verificado su disponibilidad ni su nivel de seguridad. Los códigos de recuperación solo permiten una reparación restringida. Prueba una alternativa antes de desactivar un método.", + "accountControls.featureEffect": "Las sesiones existentes del navegador, SSH y tmux no se terminan. Los nuevos accesos y la configuración de factores aplican la nueva regla inmediatamente.", + "accountControls.method.password": "Contraseña", + "accountControls.method.passkey": "Passkey", + "accountControls.method.totp": "Aplicación de autenticación", + "accountControls.method.recovery": "Códigos de recuperación", + "accountControls.method.recovery_code": "Código de recuperación", + "accountControls.method.oidc": "Proveedor de identidad", + "accountControls.method.ldap": "LDAP", + "accountControls.method.github": "GitHub" + }, + "vi": { + "accountControls.shortHint": "Kiểm tra các nơi tài khoản đang đăng nhập.", + "accountControls.effectTitle": "Thu hồi có tác dụng gì?", + "accountControls.affectedAccounts": "Tài khoản ảnh hưởng", + "accountControls.affectedAdmins": "Quản trị viên", + "accountControls.withoutPrimary": "Không có cách chính", + "accountControls.configuredOnly": "Đã cấu hình, chưa thử nghiệm. Hãy thử cách đăng nhập khác trước; tính sẵn sàng và MFA của nhà cung cấp chưa được xác minh.", + "accountControls.affectedList": "Kiểm tra tài khoản bị ảnh hưởng", + "accountControls.admin": "Quản trị viên", + "accountControls.locked": "Đã khóa", + "accountControls.accountPaths": "Chính: {primary} · Yếu tố / khôi phục: {factors}", + "accountControls.title": "Các phiên đăng nhập đang hoạt động", + "accountControls.hint": "Kiểm tra các phiên trình duyệt của tài khoản. Thu hồi sẽ ngắt kết nối trình duyệt và hủy việc truyền tệp cùng tác vụ gắn với phiên đó. Các phiên SSH/tmux dùng chung và lịch chạy vẫn dành cho phiên đăng nhập hợp lệ.", + "accountControls.revokeOthers": "Thu hồi tất cả phiên khác", + "accountControls.current": "Phiên đăng nhập này", + "accountControls.revoke": "Thu hồi phiên", + "accountControls.dates": "Bắt đầu: {start} · Hết hạn: {expiry}", + "accountControls.others": "Tất cả phiên đăng nhập khác", + "accountControls.confirmRevoke": "Thu hồi quyền truy cập này? Các kết nối trình duyệt đã chọn, việc truyền tệp và tác vụ gắn với chúng sẽ dừng. Các phiên SSH/tmux dùng chung vẫn dành cho phiên đăng nhập hợp lệ.", + "accountControls.cleanupWarning": "Quyền đăng nhập đã bị thu hồi, nhưng một số tài nguyên đang chạy chưa đóng được. Hãy liên hệ người vận hành.", + "accountControls.revoked": "Đã thu hồi quyền đăng nhập đã chọn.", + "accountControls.failed": "Không thể cập nhật các phiên đăng nhập. Làm mới và thử lại.", + "accountControls.delete": "Xóa tài khoản “{user}”? Quyền truy cập sẽ bị thu hồi và tài khoản bị xóa. Tệp người dùng được giữ trong vùng cách ly cho đến khi người vận hành chủ động xóa.", + "accountControls.disableTitle": "Tắt {feature}?", + "accountControls.continue": "Tiếp tục xác thực", + "accountControls.none": "Không có", + "accountControls.affected": "Tài khoản bị ảnh hưởng: {accounts}; quản trị viên: {admins}; đã bị khóa: {locked}.", + "accountControls.primary": "Phương thức đăng nhập chính còn được cấu hình (tài khoản bị ảnh hưởng chưa khóa): ", + "accountControls.factors": "Yếu tố cục bộ / phương thức khôi phục còn được cấu hình: ", + "accountControls.noPrimary": "Không còn phương thức chính được cấu hình: {accounts}; quản trị viên: {admins}.", + "accountControls.noMfa": "Yêu cầu MFA nhưng không còn Passkey/TOTP cục bộ: {accounts}; quản trị viên: {admins}.", + "accountControls.unverified": "Các phương thức này đã được cấu hình, chưa được thử nghiệm. Nhà cung cấp danh tính có thể cung cấp MFA; tính sẵn sàng và mức bảo đảm chưa được xác minh. Mã khôi phục chỉ cho phép sửa chữa hạn chế. Thử phương thức khác trước khi tắt.", + "accountControls.featureEffect": "Các phiên trình duyệt, SSH và tmux hiện có không bị kết thúc. Đăng nhập mới và thiết lập yếu tố sẽ áp dụng quy tắc mới ngay lập tức.", + "accountControls.method.password": "Mật khẩu", + "accountControls.method.passkey": "Passkey", + "accountControls.method.totp": "Ứng dụng xác thực", + "accountControls.method.recovery": "Mã khôi phục", + "accountControls.method.recovery_code": "Mã khôi phục", + "accountControls.method.oidc": "Nhà cung cấp danh tính", + "accountControls.method.ldap": "LDAP", + "accountControls.method.github": "GitHub" + }, + "zh": { + "accountControls.shortHint": "查看账户的有效登录。", + "accountControls.effectTitle": "撤销有什么影响?", + "accountControls.affectedAccounts": "受影响账户", + "accountControls.affectedAdmins": "管理员", + "accountControls.withoutPrimary": "无主要方式", + "accountControls.configuredOnly": "已配置,未经测试。请先测试其他登录方式;提供商可用性和 MFA 尚未验证。", + "accountControls.affectedList": "查看受影响账户", + "accountControls.admin": "管理员", + "accountControls.locked": "已锁定", + "accountControls.accountPaths": "主要方式:{primary} · 因素/恢复:{factors}", + "accountControls.title": "有效登录", + "accountControls.hint": "查看此账户的浏览器登录。撤销会断开对应的浏览器连接,并取消其传输和与登录绑定的任务。共享 SSH/tmux 会话及计划仍供有效登录使用。", + "accountControls.revokeOthers": "撤销所有其他登录", + "accountControls.current": "当前登录", + "accountControls.revoke": "撤销登录", + "accountControls.dates": "开始:{start} · 到期:{expiry}", + "accountControls.others": "所有其他登录", + "accountControls.confirmRevoke": "撤销此访问权限?选定的浏览器连接、传输和与登录绑定的任务将停止。共享 SSH/tmux 会话仍供有效登录使用。", + "accountControls.cleanupWarning": "登录权限已撤销,但部分活动资源无法关闭。请联系运维人员。", + "accountControls.revoked": "已撤销所选登录权限。", + "accountControls.failed": "无法更新登录。请刷新后重试。", + "accountControls.delete": "删除账户“{user}”?其访问权限将被撤销,账户将被移除。用户文件会保留在隔离区,直到运维人员明确删除。", + "accountControls.disableTitle": "禁用 {feature}?", + "accountControls.continue": "继续身份验证", + "accountControls.none": "无", + "accountControls.affected": "受影响账户:{accounts};管理员:{admins};已锁定:{locked}。", + "accountControls.primary": "仍已配置的主要登录方式(未锁定的受影响账户):", + "accountControls.factors": "仍已配置的本地因素/恢复方式:", + "accountControls.noPrimary": "没有已配置的主要登录方式:{accounts};管理员:{admins}。", + "accountControls.noMfa": "需要 MFA 但不再有本地 Passkey/TOTP:{accounts};管理员:{admins}。", + "accountControls.unverified": "这些方式已配置,但未经测试。身份提供商可能提供 MFA,但其可用性及保障级别尚未验证。恢复代码仅允许有限修复。禁用前请测试其他登录方式。", + "accountControls.featureEffect": "现有浏览器、SSH 和 tmux 会话不会终止。新的登录和因素设置会立即遵循更改后的规则。", + "accountControls.method.password": "密码", + "accountControls.method.passkey": "Passkey", + "accountControls.method.totp": "验证器应用", + "accountControls.method.recovery": "恢复代码", + "accountControls.method.recovery_code": "恢复代码", + "accountControls.method.oidc": "身份提供商", + "accountControls.method.ldap": "LDAP", + "accountControls.method.github": "GitHub" + } + }; + for (const [language, values] of Object.entries(bundles)) window.i18n.addTranslations(language, values); +}()); diff --git a/static/js/account-controls.js b/static/js/account-controls.js new file mode 100644 index 00000000..78877166 --- /dev/null +++ b/static/js/account-controls.js @@ -0,0 +1,195 @@ +(function (root, factory) { + 'use strict'; + const api = factory(); + if (typeof module === 'object' && module.exports) { module.exports = api; } + if (root?.document) { + root.WebSSHAccountControls = api; + if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', api.init); + else api.init(); + } +})(typeof window === 'undefined' ? null : window, function () { + 'use strict'; + const defaultT = (_key, fallback) => fallback; + const translate = (key, fallback) => { + const value = window.i18n?.t(key); + return value && value !== key ? value : fallback; + }; + function format(text, values) { + return text.replace(/\{(\w+)\}/g, (match, name) => values[name] ?? match); + } + function methodLabel(method, t = defaultT) { + const labels = {password:'Password', passkey:'Passkey', totp:'Authenticator app', recovery:'Recovery codes', + recovery_code:'Recovery code', oidc:'Identity provider', ldap:'LDAP', github:'GitHub'}; + return t(`accountControls.method.${method}`, labels[method] || method); + } + function featureImpactLines(impact, t = defaultT) { + const counts = values => Object.entries(values || {}).map(([name, count]) => `${methodLabel(name, t)}: ${count}`).join(', ') + || t('accountControls.none', 'None'); + return [ + format(t('accountControls.affected', 'Affected accounts: {accounts}; administrators: {admins}; already locked: {locked}.'), + {accounts:impact.affected_accounts, admins:impact.affected_admins, locked:impact.locked_affected_accounts}), + t('accountControls.primary', 'Remaining configured primary sign-in paths (unlocked affected accounts): ') + counts(impact.remaining_primary), + t('accountControls.factors', 'Remaining configured local factors / recovery paths: ') + counts(impact.remaining_factors), + format(t('accountControls.noPrimary', 'Without a configured primary path: {accounts}; administrators: {admins}.'), + {accounts:impact.accounts_without_primary, admins:impact.admins_without_primary}), + format(t('accountControls.noMfa', 'MFA required but no remaining local Passkey/TOTP: {accounts}; administrators: {admins}.'), + {accounts:impact.mfa_required_without_local_factor, admins:impact.admins_without_local_factor}), + t('accountControls.unverified', 'These paths are configured, not tested. An identity provider may supply MFA; its availability and assurance have not been verified. Recovery codes allow restricted repair only. Test an alternative sign-in before disabling a method.'), + t('accountControls.featureEffect', 'Existing browser, SSH and tmux sessions are not terminated. New sign-ins and factor setup use the changed rule immediately.'), + ]; + } + function deletionConfirmation(username, t = defaultT) { + return format(t('accountControls.delete', 'Delete account "{user}"? Its access will be revoked and its account removed. User files are retained in quarantine until an operator explicitly removes them.'), {user:username}); + } + function confirmFeatureImpact(preview, label, t = translate) { + return new Promise(resolve => { + const previous = document.activeElement; + const dialog = document.createElement('dialog'); + dialog.className = 'account-impact-dialog'; + const title = document.createElement('h2'); + title.id = 'accountImpactTitle'; + title.textContent = format(t('accountControls.disableTitle', 'Disable {feature}?'), {feature:label}); + dialog.setAttribute('aria-labelledby', title.id); + dialog.append(title); + const body = document.createElement('div'); + body.className = 'account-impact-body'; + const counts = document.createElement('dl'); + counts.className = 'account-impact-counts'; + for (const [key, fallback, count] of [ + ['affectedAccounts', 'Accounts affected', preview.impact.affected_accounts], + ['affectedAdmins', 'Administrators', preview.impact.affected_admins], + ['withoutPrimary', 'Without primary path', preview.impact.accounts_without_primary], + ]) { + const group = document.createElement('div'); + const term = document.createElement('dt'); term.textContent = t(`accountControls.${key}`, fallback); + const value = document.createElement('dd'); value.textContent = count; + group.append(term, value); counts.append(group); + } + body.append(counts); + if (preview.impact.mfa_required_without_local_factor > 0) { + const warning = document.createElement('p'); + warning.className = 'account-impact-warning'; + warning.textContent = format(t('accountControls.noMfa', 'MFA required but no remaining local Passkey/TOTP: {accounts}; administrators: {admins}.'), + {accounts:preview.impact.mfa_required_without_local_factor, admins:preview.impact.admins_without_local_factor}); + body.append(warning); + } + const unverified = document.createElement('p'); + unverified.textContent = t('accountControls.configuredOnly', 'Configured, not tested. Test an alternative sign-in first; provider availability and MFA have not been verified.'); + const effect = document.createElement('p'); + effect.textContent = t('accountControls.featureEffect', 'Existing browser, SSH and tmux sessions are not terminated. New sign-ins and factor setup use the changed rule immediately.'); + body.append(unverified, effect); + const details = document.createElement('details'); + const summary = document.createElement('summary'); + summary.textContent = t('accountControls.affectedList', 'Review affected accounts'); + details.append(summary); + for (const line of featureImpactLines(preview.impact, t).slice(0, 6)) { + const paragraph = document.createElement('p'); paragraph.textContent = line; details.append(paragraph); + } + if (preview.impact.accounts?.length) { + const list = document.createElement('ul'); + list.className = 'account-impact-accounts'; + preview.impact.accounts.forEach(account => { + const item = document.createElement('li'); + const name = document.createElement('strong'); + name.textContent = account.username; + if (account.admin) name.textContent += ` · ${t('accountControls.admin', 'Administrator')}`; + if (account.locked) name.textContent += ` · ${t('accountControls.locked', 'Locked')}`; + if (account.mfa_required) name.textContent += ' · MFA'; + const paths = document.createElement('p'); + const describe = methods => methods.map(method => methodLabel(method, t)).join(', ') || t('accountControls.none', 'None'); + paths.textContent = format(t('accountControls.accountPaths', 'Primary: {primary} · Factors / recovery: {factors}'), + {primary:describe(account.remaining_primary), factors:describe(account.remaining_factors)}); + item.append(name, paths); list.append(item); + }); + details.append(list); + } + body.append(details); dialog.append(body); + const actions = document.createElement('div'); + actions.className = 'account-controls-actions'; + const cancel = document.createElement('button'); + cancel.className = 'btn btn-secondary'; + cancel.textContent = t('common.cancel', 'Cancel'); + const confirm = document.createElement('button'); + confirm.className = 'btn btn-danger'; + confirm.textContent = t('accountControls.continue', 'Continue to authentication'); + const finish = approved => { + dialog.close(); dialog.remove(); previous?.focus(); resolve(approved); + }; + cancel.addEventListener('click', () => finish(false)); + confirm.addEventListener('click', () => finish(true)); + dialog.addEventListener('cancel', event => { event.preventDefault(); finish(false); }); + actions.append(cancel, confirm); dialog.append(actions); document.body.append(dialog); + dialog.showModal(); cancel.focus(); + }); + } + function init() { + const list = document.getElementById('accountLoginList'); + if (!list) return; + const status = document.getElementById('accountLoginStatus'); + const others = document.getElementById('accountRevokeOthers'); + const refresh = document.getElementById('accountRefreshLogins'); + const appRoot = (document.querySelector('meta[name="app-root"]')?.content || '').replace(/\/$/, ''); + const csrf = document.querySelector('meta[name="csrf-token"]')?.content || ''; + let busy = false; + let rows = []; + async function api(path, body) { + const response = await fetch(appRoot + path, {method:body ? 'POST' : 'GET', + headers:{Accept:'application/json', 'Content-Type':'application/json', 'X-CSRFToken':csrf}, + ...(body ? {body:JSON.stringify(body)} : {})}); + const data = await response.json(); + if (!response.ok) throw new Error(translate('accountControls.failed', 'Could not update logins. Refresh and try again.')); + return data; + } + const timestamp = value => new Date(value).toLocaleString(document.documentElement.lang || undefined); + function render() { + list.replaceChildren(); + rows.forEach(row => { + const item = document.createElement('li'); item.className = 'account-login-row'; + const details = document.createElement('div'); + const heading = document.createElement('strong'); + heading.textContent = row.methods.map(method => methodLabel(method, translate)).join(' + '); + if (row.current) heading.textContent += ` · ${translate('accountControls.current', 'This login')}`; + const dates = document.createElement('p'); dates.className = 'admin-muted'; + dates.textContent = format(translate('accountControls.dates', 'Started: {start} · Expires: {expiry}'), + {start:timestamp(row.started_at), expiry:timestamp(row.expires_at)}); + details.append(heading, dates); item.append(details); + if (!row.current) { + const button = document.createElement('button'); button.className = 'btn btn-danger'; + button.textContent = translate('accountControls.revoke', 'Revoke login'); + button.setAttribute('aria-label', `${button.textContent}: ${heading.textContent}, ${timestamp(row.started_at)}`); + button.addEventListener('click', () => revoke({session:row.handle}, dates.textContent)); + item.append(button); + } + list.append(item); + }); + others.disabled = busy || !rows.some(row => !row.current); + } + async function load() { + if (busy) return; + busy = true; refresh.disabled = true; others.disabled = true; + try { rows = (await api('/api/account/sessions')).sessions; status.textContent = ''; render(); } + catch (error) { status.textContent = error.message; } + finally { busy = false; refresh.disabled = false; others.disabled = !rows.some(row => !row.current); } + } + async function revoke(body, details) { + if (busy) return; + const warning = translate('accountControls.confirmRevoke', 'Revoke this access? The selected browser connections, their transfers and login-bound jobs will stop. Shared SSH/tmux sessions remain available to valid logins.'); + if (!window.confirm(`${details || translate('accountControls.others', 'All other logins')}\n\n${warning}`)) return; + busy = true; refresh.disabled = true; others.disabled = true; + list.querySelectorAll('button').forEach(button => { button.disabled = true; }); + let message = ''; + try { + const result = await api('/api/account/sessions/revoke', body); + message = result.cleanup_incomplete + ? translate('accountControls.cleanupWarning', 'Login access was revoked, but some live resources could not be closed. Contact the operator.') + : translate('accountControls.revoked', 'Selected login access revoked.'); + } catch (error) { message = error.message; } + finally { busy = false; await load(); status.textContent = message; } + } + refresh.addEventListener('click', load); + others.addEventListener('click', () => revoke({others:true})); + window.addEventListener('languageChanged', render); + load(); + } + return {init, methodLabel, featureImpactLines, deletionConfirmation, confirmFeatureImpact}; +}); diff --git a/static/js/admin.js b/static/js/admin.js index af7827ae..2c170942 100644 --- a/static/js/admin.js +++ b/static/js/admin.js @@ -1063,7 +1063,7 @@ openSecurityAction(action, userId, username); return; } - if (action === 'delete' && !window.confirm(t('admin.confirmDelete', 'Delete this user permanently?'))) { return; } + if (action === 'delete' && !window.confirm(window.WebSSHAccountControls.deletionConfirmation(username, t))) { return; } doUserAction(userId, action); }); document.getElementById('closeSecurityAction')?.addEventListener('click', closeSecurityAction); @@ -1496,25 +1496,22 @@ const featureName = target.dataset.securityFeature; if (securityFeatureWrites.has(featureName)) { return; } const enabling = target.checked; - if (!enabling && !window.confirm( - window.WebSSHSecurityUI.featureDisableWarning( - { name: featureName }, - securityFeatureLabel(featureName), - t - ) - )) { - target.checked = true; - return; - } target.disabled = true; securityFeatureWrites.add(featureName); securityFeatureReadGeneration += 1; try { + let impactApproval; + if (!enabling) { + const preview = await api(`/admin/api/security-features/${encodeURIComponent(featureName)}/impact`); + if (!await window.WebSSHAccountControls.confirmFeatureImpact(preview, securityFeatureLabel(featureName), t)) return; + impactApproval = preview.approval; + } const data = await stepUpApi('security_feature.update', featureName, `/admin/api/security-features/${encodeURIComponent(featureName)}`, { method: 'POST', body: { enabled: enabling, - confirm_session_fallback: !enabling + confirm_session_fallback: !enabling, + impact_approval: impactApproval } }); if (data.feature?.name === featureName) { @@ -1529,6 +1526,8 @@ securityFeatureWrites.delete(featureName); renderSecurityFeatures(securityFeatureSnapshot); await loadSecurityFeatures(); + Array.from(document.querySelectorAll('input[data-security-feature]')) + .find(input => input.dataset.securityFeature === featureName)?.focus(); } }); document.getElementById('settingSshGateway')?.addEventListener('change', async (e) => { diff --git a/static/js/app.js b/static/js/app.js index 00bcd03a..6faf0b0f 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -2527,6 +2527,7 @@ showNotification((window.i18n ? window.i18n.t('toast.clipboardDenied') : 'Clipboard access denied'), 'error'); return; } + const binding = window.TerminalPasteGuard?.capture(active, TerminalManager.terminals[active]); let clipboardText; try { clipboardText = navigator.clipboard.readText(); @@ -2536,16 +2537,7 @@ } Promise.resolve(clipboardText) .then(text => { - if (window.socket && text) { - const terminal = TerminalManager.terminals[active]; - if (terminal?.paste) { - terminal.paste(text); - } else if (window.SSHInput) { - window.SSHInput.sendText(active, text); - } else { - window.socket.emit('ssh_input', { session_id: active, data: text.replace(/\r\n|\n/g, '\r') }); - } - } + if (text) return window.TerminalPasteGuard?.paste(binding, text); }) .catch(() => showNotification((window.i18n ? window.i18n.t('toast.clipboardDenied') : 'Clipboard access denied'), 'error')); } @@ -2590,13 +2582,9 @@ try { let sent = false; if (window.socket?.connected === true) { - const data = submitted + '\r'; - if (window.SSHInput) { - sent = await window.SSHInput.sendText(active, data); - } else { - window.socket.emit('ssh_input', { session_id: active, data: data.replace(/\r\n|\n/g, '\r') }); - sent = true; - } + const binding = window.TerminalPasteGuard?.capture(active); + sent = await window.TerminalPasteGuard?.review(binding, submitted, + value => window.SSHInput?.sendText(active, value + '\r')); } if (sent && mobileInput.value === submitted) mobileInput.value = ''; } finally { @@ -2605,6 +2593,16 @@ }; if (mobileInput) { + // Input[type=text] strips line breaks. Review the original clipboard + // before the browser can silently turn several commands into one. + mobileInput.addEventListener('paste', event => { + const text = event.clipboardData?.getData('text/plain') || ''; + if (!window.TerminalPasteGuard?.needsReview(text)) return; + event.preventDefault(); + const active = SessionManager.getActiveSession(); + const binding = window.TerminalPasteGuard.capture(active, TerminalManager.terminals[active]); + window.TerminalPasteGuard.paste(binding, text); + }); mobileInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); diff --git a/static/js/broadcast-input.js b/static/js/broadcast-input.js index 4b6e69db..19de06cc 100644 --- a/static/js/broadcast-input.js +++ b/static/js/broadcast-input.js @@ -1,107 +1,145 @@ -/* - * Broadcast input — a dedicated input bar whose content is sent to EVERY open - * (connected) SSH session at once (cluster-SSH style), independent of which - * terminal is focused. Pure client-side: each target still goes through the - * existing, ownership-checked `ssh_input` handler. - */ +/* Explicit recipient snapshots; new and reconnected sessions require a new selection. */ (function () { 'use strict'; - - function connectedSessions() { - if (typeof SessionManager === 'undefined' || !SessionManager.getAllSessions) { - return []; - } - return SessionManager.getAllSessions().filter(s => s && s.connected); - } - + const byId = id => document.getElementById(id); + const t = (key, fallback) => window.i18n?.t(`terminalSafety.${key}`, fallback) || fallback; + const guard = () => window.TerminalPasteGuard; + const connectedSessions = () => typeof SessionManager === 'undefined' ? [] : SessionManager.getAllSessions().filter(s => s?.connected); + let selection = []; + let stale = false; + let sending = false; const BroadcastInput = { open: false, - - toggle() { - this.open ? this.close() : this.show(); - }, - + toggle() { this.open ? this.close() : this.show(); }, show() { this.open = true; document.body.classList.add('broadcast-active'); - const bar = document.getElementById('broadcastBar'); - const btn = document.getElementById('broadcastToggleBtn'); - const input = document.getElementById('broadcastInput'); - if (bar) bar.classList.remove('hidden'); - if (btn) { - btn.classList.add('active'); - btn.setAttribute('aria-pressed', 'true'); - } + byId('broadcastBar')?.classList.remove('hidden'); + byId('broadcastToggleBtn')?.classList.add('active'); + byId('broadcastToggleBtn')?.setAttribute('aria-pressed', 'true'); this.updateCount(); - if (input) input.focus(); + this.chooseRecipients(); }, - close() { this.open = false; + selection = selection.slice(); + guard()?.onWorkspaceChange(); document.body.classList.remove('broadcast-active'); - const bar = document.getElementById('broadcastBar'); - const btn = document.getElementById('broadcastToggleBtn'); - if (bar) bar.classList.add('hidden'); - if (btn) { - btn.classList.remove('active'); - btn.setAttribute('aria-pressed', 'false'); + byId('broadcastBar')?.classList.add('hidden'); + byId('broadcastToggleBtn')?.classList.remove('active'); + byId('broadcastToggleBtn')?.setAttribute('aria-pressed', 'false'); + byId('broadcastRecipientDialog')?.close(); + }, + selectRecipients(ids, candidates = connectedSessions().map(s => guard()?.capture(s.id, null, false))) { + const requested = new Set(ids); + const chosen = candidates.filter(binding => binding && requested.has(binding.id)); + if (chosen.length !== requested.size || chosen.some(binding => !guard()?.valid(binding))) return false; + selection = chosen; + stale = false; + guard()?.onWorkspaceChange(); + this.updateCount(); + return true; + }, + chooseRecipients() { + if (sending) return; + const dialog = byId('broadcastRecipientDialog'); + if (!dialog) return; + const list = byId('broadcastRecipientList'); + list.replaceChildren(); + const candidates = connectedSessions().map(s => guard()?.capture(s.id, null, false)).filter(Boolean); + for (const binding of candidates) { + const row = document.createElement('label'); + row.className = 'terminal-recipient'; + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.value = binding.id; + checkbox.checked = !stale && selection.some(item => item.session === binding.session && guard().valid(item)); + const details = document.createElement('span'); + const endpoint = document.createElement('strong'); + endpoint.textContent = binding.endpoint; + const context = document.createElement('small'); + context.textContent = [binding.session.displayName, binding.id].filter(Boolean).join(' · '); + details.append(endpoint, context); + row.append(checkbox, details); + list.append(row); } + if (!candidates.length) list.textContent = t('noRecipients', 'No connected sessions.'); + byId('broadcastRecipientError').textContent = ''; + byId('broadcastRecipientApply').onclick = () => { + const ids = Array.from(list.querySelectorAll('input:checked'), input => input.value); + if (!this.selectRecipients(ids, candidates)) { + byId('broadcastRecipientError').textContent = t('broadcastChanged', 'Recipients changed. Choose the connected sessions again before sending.'); + return; + } + dialog.close(); + byId('broadcastInput')?.focus(); + }; + dialog.showModal(); }, - updateCount() { - const el = document.getElementById('broadcastCount'); - if (el) el.textContent = String(connectedSessions().length); + if (selection.some(binding => !guard()?.valid(binding))) stale = true; + if (byId('broadcastCount')) byId('broadcastCount').textContent = String(selection.length); + if (byId('broadcastTargets')) byId('broadcastTargets').textContent = selection.map(binding => `${binding.endpoint} (${binding.id})`).join(', '); + if (byId('broadcastSelectionStatus')) byId('broadcastSelectionStatus').textContent = stale + ? t('broadcastChanged', 'Recipients changed. Choose the connected sessions again before sending.') + : selection.length ? t('frozen', 'Only the selected sessions receive input. New sessions are not added.') + : t('chooseFirst', 'Choose recipients before sending.'); + if (byId('broadcastSendBtn')) byId('broadcastSendBtn').disabled = sending || stale || !selection.length; + if (byId('broadcastSelectBtn')) byId('broadcastSelectBtn').disabled = sending; }, - - // Send the given text (plus a carriage return) to every connected session. - sendAll(text) { - if (!window.socket) return 0; - const sessions = connectedSessions(); - sessions.forEach(s => { - const data = text + '\r'; - if (window.SSHInput) { - window.SSHInput.sendText(s.id, data); - } else { - window.socket.emit('ssh_input', { session_id: s.id, data: data.replace(/\r\n|\n/g, '\r') }); - } - }); - return sessions.length; + async sendAll(text) { + this.updateCount(); + if (sending || stale || !selection.length || !window.SSHInput) return 0; + sending = true; + this.updateCount(); + const recipients = selection; + try { + const isCurrent = () => selection === recipients && !stale; + const result = await guard().review(recipients, text, async value => { + // Revalidate the entire frozen set immediately before the first send. + if (!isCurrent() || !recipients.every(binding => guard().valid(binding))) return 0; + const results = await Promise.allSettled(recipients.map(binding => ( + guard().valid(binding) ? window.SSHInput.sendText(binding.id, value + '\r') : false + ))); + return results.filter(item => item.status === 'fulfilled' && item.value === true).length; + }, {isCurrent, titleKey: 'broadcastReviewTitle', warningKey: 'broadcastReviewWarning', + confirmKey: 'sendSelected', changedMessage: t('broadcastChanged')}); + return Number(result) || 0; + } finally { + sending = false; + this.updateCount(); + } } }; - window.BroadcastInput = BroadcastInput; - document.addEventListener('DOMContentLoaded', () => { - const toggle = document.getElementById('broadcastToggleBtn'); - const input = document.getElementById('broadcastInput'); - const sendBtn = document.getElementById('broadcastSendBtn'); - const closeBtn = document.getElementById('broadcastCloseBtn'); - - const submit = () => { - if (!input || input.value === '') return; - const count = BroadcastInput.sendAll(input.value); - input.value = ''; - input.focus(); - if (window.showNotification && window.i18n) { - showNotification(i18n.t('broadcast.sent').replace('{n}', count), 'info'); + const input = byId('broadcastInput'); + const submit = async () => { + if (!input?.value || sending) return; + const text = input.value; + const expected = selection.length; + const count = await BroadcastInput.sendAll(text); + if (count > 0 && count === expected) { + if (input.value === text) input.value = ''; + window.showNotification?.((window.i18n?.t('broadcast.sent') || 'Sent to {n} sessions').replace('{n}', count), 'info'); + } else { + window.showNotification?.(count > 0 + ? t('partial', 'Some recipients did not accept the input. Check each session before retrying; the command may already have run.') + : t('notSent', 'Input was not sent. Check the selected recipients and connection.'), 'warning'); } + input.focus(); }; - - if (toggle) toggle.addEventListener('click', () => BroadcastInput.toggle()); - if (sendBtn) sendBtn.addEventListener('click', submit); - if (closeBtn) closeBtn.addEventListener('click', () => BroadcastInput.close()); - if (input) { - input.addEventListener('keydown', (e) => { - if (e.key === 'Enter') { - e.preventDefault(); - submit(); - } else if (e.key === 'Escape') { - e.preventDefault(); - BroadcastInput.close(); - } - }); - // Keep the session counter fresh while the bar is open. - input.addEventListener('focus', () => BroadcastInput.updateCount()); - } + byId('broadcastToggleBtn')?.addEventListener('click', () => BroadcastInput.toggle()); + byId('broadcastSelectBtn')?.addEventListener('click', () => BroadcastInput.chooseRecipients()); + byId('broadcastSendBtn')?.addEventListener('click', submit); + byId('broadcastCloseBtn')?.addEventListener('click', () => BroadcastInput.close()); + byId('broadcastRecipientCancel')?.addEventListener('click', () => byId('broadcastRecipientDialog').close()); + byId('broadcastRecipientDialog')?.addEventListener('keydown', event => event.stopPropagation()); + input?.addEventListener('keydown', event => { + if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) { event.preventDefault(); submit(); } + else if (event.key === 'Escape') { event.preventDefault(); BroadcastInput.close(); } + }); + window.addEventListener('session-workspace-change', () => BroadcastInput.updateCount()); + window.addEventListener('languageChanged', () => BroadcastInput.updateCount()); }); -})(); +}()); diff --git a/static/js/customer-workflows-i18n.js b/static/js/customer-workflows-i18n.js index d84a691c..89a29e5f 100644 --- a/static/js/customer-workflows-i18n.js +++ b/static/js/customer-workflows-i18n.js @@ -54,12 +54,6 @@ "customer.conflict": "The source or destination changed. Refresh both locations and compare their current contents before deciding whether to repeat the operation.", "customer.source": "The source or share is unavailable. Check its connection and access, select the source again, then inspect the destination before starting a new transfer.", "customer.failure": "Review the error, connection and available storage. Inspect source and destination before repeating; partial results may already exist.", - "customer.continuity.none": "Select a session to review its continuity.", - "customer.continuity.transport": "The browser connection is interrupted. The remote outcome is uncertain; reconnect and inspect before repeating commands.", - "customer.continuity.tmux": "tmux session: remote processes can continue after an SSH disconnect while tmux remains running. Reconnect to the same host and session to inspect progress.", - "customer.continuity.tmuxDisconnected": "SSH disconnected. tmux may still be running remotely; reconnect to the same session and inspect its state. Commands are not replayed automatically.", - "customer.continuity.normal": "Normal SSH session: no durable remote-session guarantee. Losing the SSH connection may end foreground commands; inspect results before retrying.", - "customer.continuity.disconnected": "SSH disconnected. Foreground commands may have stopped or partially completed. Reconnect explicitly and inspect results; do not assume success or repeat blindly.", "customer.recoveryNoticeTitle": "Review recovery after restore", "customer.recoveryNoticeBody": "This instance was restored. Review your missing remote credentials, host trust and schedules before resuming work.", "customer.openRecoveryChecklist": "Open recovery checklist", @@ -120,12 +114,6 @@ "customer.conflict": "Quelle oder Ziel hat sich geändert. Beide Orte aktualisieren und aktuelle Inhalte vergleichen, bevor du über eine Wiederholung entscheidest.", "customer.source": "Quelle oder Freigabe nicht verfügbar. Verbindung und Zugriff prüfen, Quelle erneut auswählen und vor einem neuen Transfer das Ziel prüfen.", "customer.failure": "Fehler, Verbindung und freien Speicher prüfen. Quelle und Ziel vor einer Wiederholung kontrollieren; Teilergebnisse können bereits vorhanden sein.", - "customer.continuity.none": "Sitzung wählen, um ihre Fortsetzung zu prüfen.", - "customer.continuity.transport": "Die Browser-Verbindung ist unterbrochen. Das Remote-Ergebnis ist unklar; erneut verbinden und vor dem Wiederholen von Befehlen prüfen.", - "customer.continuity.tmux": "tmux-Sitzung: Remote-Prozesse können nach einem SSH-Abbruch weiterlaufen, solange tmux läuft. Mit demselben Host und derselben Sitzung verbinden, um den Fortschritt zu prüfen.", - "customer.continuity.tmuxDisconnected": "SSH getrennt. tmux kann auf dem Ziel weiterlaufen; dieselbe Sitzung erneut verbinden und Zustand prüfen. Befehle werden nicht automatisch wiederholt.", - "customer.continuity.normal": "Normale SSH-Sitzung: keine garantierte dauerhafte Remote-Sitzung. Ein SSH-Abbruch kann Vordergrundbefehle beenden; Ergebnisse vor einem neuen Versuch prüfen.", - "customer.continuity.disconnected": "SSH getrennt. Vordergrundbefehle können beendet oder teilweise abgeschlossen sein. Ausdrücklich erneut verbinden und Ergebnisse prüfen; weder Erfolg annehmen noch blind wiederholen.", "customer.recoveryNoticeTitle": "Wiederherstellung prüfen", "customer.recoveryNoticeBody": "Diese Instanz wurde wiederhergestellt. Prüfe fehlende Remote-Zugangsdaten, Hostvertrauen und Zeitpläne, bevor du weiterarbeitest.", "customer.openRecoveryChecklist": "Wiederherstellungscheckliste öffnen", @@ -186,12 +174,6 @@ "customer.conflict": "La source ou la destination a changé. Actualisez les deux emplacements et comparez leur contenu avant de décider de répéter l’opération.", "customer.source": "La source ou le partage est indisponible. Vérifiez connexion et accès, sélectionnez à nouveau la source puis inspectez la destination avant un nouveau transfert.", "customer.failure": "Vérifiez l’erreur, la connexion et l’espace disponible. Inspectez source et destination avant de recommencer ; des résultats partiels peuvent déjà exister.", - "customer.continuity.none": "Sélectionnez une session pour vérifier sa continuité.", - "customer.continuity.transport": "La connexion du navigateur est interrompue. Le résultat distant est incertain ; reconnectez-vous et vérifiez avant de répéter des commandes.", - "customer.continuity.tmux": "Session tmux : les processus distants peuvent continuer après une déconnexion SSH tant que tmux fonctionne. Reconnectez-vous au même hôte et à la même session pour vérifier leur progression.", - "customer.continuity.tmuxDisconnected": "SSH déconnecté. tmux peut encore fonctionner à distance ; reconnectez-vous à la même session et vérifiez son état. Les commandes ne sont pas rejouées automatiquement.", - "customer.continuity.normal": "Session SSH classique : aucune garantie de session distante persistante. Une perte de connexion peut terminer les commandes au premier plan ; vérifiez les résultats avant de réessayer.", - "customer.continuity.disconnected": "SSH déconnecté. Les commandes au premier plan peuvent être arrêtées ou partiellement terminées. Reconnectez-vous explicitement et vérifiez les résultats ; ne présumez pas le succès et ne répétez pas aveuglément.", "customer.recoveryNoticeTitle": "Vérifier après restauration", "customer.recoveryNoticeBody": "Cette instance a été restaurée. Vérifiez les identifiants distants manquants, la confiance des hôtes et les planifications avant de reprendre.", "customer.openRecoveryChecklist": "Ouvrir la liste de récupération", @@ -252,12 +234,6 @@ "customer.conflict": "El origen o el destino cambió. Actualice ambas ubicaciones y compare su contenido antes de decidir si repite la operación.", "customer.source": "El origen o recurso compartido no está disponible. Compruebe conexión y acceso, seleccione de nuevo el origen e inspeccione el destino antes de iniciar otra transferencia.", "customer.failure": "Revise el error, la conexión y el espacio disponible. Inspeccione origen y destino antes de repetir; pueden existir resultados parciales.", - "customer.continuity.none": "Seleccione una sesión para revisar su continuidad.", - "customer.continuity.transport": "Se interrumpió la conexión del navegador. El resultado remoto es incierto; reconecte e inspeccione antes de repetir comandos.", - "customer.continuity.tmux": "Sesión tmux: los procesos remotos pueden continuar tras una desconexión SSH mientras tmux siga activo. Reconecte al mismo host y sesión para comprobar el progreso.", - "customer.continuity.tmuxDisconnected": "SSH desconectado. tmux puede seguir activo en el destino; reconecte a la misma sesión y revise su estado. Los comandos no se repiten automáticamente.", - "customer.continuity.normal": "Sesión SSH normal: no garantiza una sesión remota persistente. Perder la conexión puede terminar los comandos en primer plano; compruebe los resultados antes de reintentar.", - "customer.continuity.disconnected": "SSH desconectado. Los comandos en primer plano pueden haberse detenido o completado parcialmente. Reconecte explícitamente y compruebe resultados; no suponga éxito ni repita a ciegas.", "customer.recoveryNoticeTitle": "Revisar tras restaurar", "customer.recoveryNoticeBody": "Esta instancia se ha restaurado. Revisa credenciales remotas ausentes, confianza de hosts y programaciones antes de continuar.", "customer.openRecoveryChecklist": "Abrir lista de recuperación", @@ -318,12 +294,6 @@ "customer.conflict": "Nguồn hoặc đích đã thay đổi. Làm mới cả hai nơi và so sánh nội dung hiện tại trước khi quyết định lặp lại thao tác.", "customer.source": "Nguồn hoặc thư mục chia sẻ không khả dụng. Kiểm tra kết nối và quyền truy cập, chọn lại nguồn, rồi kiểm tra đích trước khi bắt đầu truyền mới.", "customer.failure": "Kiểm tra lỗi, kết nối và dung lượng trống. Kiểm tra nguồn và đích trước khi lặp lại; có thể đã có kết quả một phần.", - "customer.continuity.none": "Chọn phiên để kiểm tra khả năng tiếp tục.", - "customer.continuity.transport": "Kết nối trình duyệt bị gián đoạn. Chưa rõ kết quả từ xa; kết nối lại và kiểm tra trước khi lặp lại lệnh.", - "customer.continuity.tmux": "Phiên tmux: tiến trình từ xa có thể tiếp tục sau khi SSH ngắt nếu tmux còn chạy. Kết nối lại đúng máy chủ và phiên để kiểm tra tiến độ.", - "customer.continuity.tmuxDisconnected": "SSH đã ngắt. tmux có thể vẫn chạy từ xa; kết nối lại cùng phiên và kiểm tra trạng thái. Lệnh không được tự động chạy lại.", - "customer.continuity.normal": "Phiên SSH thông thường: không bảo đảm duy trì phiên từ xa. Mất kết nối có thể kết thúc lệnh tiền cảnh; kiểm tra kết quả trước khi thử lại.", - "customer.continuity.disconnected": "SSH đã ngắt. Lệnh tiền cảnh có thể đã dừng hoặc hoàn tất một phần. Chủ động kết nối lại và kiểm tra kết quả; không mặc định thành công hoặc lặp lại mù quáng.", "customer.recoveryNoticeTitle": "Kiểm tra sau khôi phục", "customer.recoveryNoticeBody": "Phiên bản này đã được khôi phục. Kiểm tra thông tin đăng nhập từ xa còn thiếu, độ tin cậy máy chủ và lịch chạy trước khi tiếp tục.", "customer.openRecoveryChecklist": "Mở danh sách kiểm tra khôi phục", @@ -384,12 +354,6 @@ "customer.conflict": "源或目标已更改。请刷新两处并比较当前内容,再决定是否重复操作。", "customer.source": "源或共享不可用。请检查连接和访问权限,重新选择源,然后在开始新传输前检查目标。", "customer.failure": "检查错误、连接和可用存储空间。重复操作前请检查源和目标;可能已存在部分结果。", - "customer.continuity.none": "选择会话以查看其持续性。", - "customer.continuity.transport": "浏览器连接已中断。远程结果不确定;请重新连接并检查,再决定是否重复命令。", - "customer.continuity.tmux": "tmux 会话:只要 tmux 仍在运行,SSH 断开后远程进程可能继续。请重新连接同一主机和会话以检查进度。", - "customer.continuity.tmuxDisconnected": "SSH 已断开。远程 tmux 可能仍在运行;请重新连接同一会话并检查状态。命令不会自动重放。", - "customer.continuity.normal": "普通 SSH 会话:不保证远程会话持久存在。SSH 连接中断可能终止前台命令;重试前请检查结果。", - "customer.continuity.disconnected": "SSH 已断开。前台命令可能已停止或部分完成。请主动重新连接并检查结果;不要假定成功或盲目重复。", "customer.recoveryNoticeTitle": "恢复后请检查", "customer.recoveryNoticeBody": "此实例已恢复。继续工作前,请检查缺失的远程凭据、主机信任和计划。", "customer.openRecoveryChecklist": "打开恢复检查清单", diff --git a/static/js/customer-workflows.js b/static/js/customer-workflows.js index 8797651c..403b007e 100644 --- a/static/js/customer-workflows.js +++ b/static/js/customer-workflows.js @@ -18,12 +18,6 @@ // Metadata identifiers carry no authority; HTTP homelabs lack randomUUID. return `preset_${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}`; } - function continuityKey(session, transport) { - if (!session) return 'none'; - if (session.connected && !transport) return 'transport'; - if (session.useTmux) return session.connected ? 'tmux' : 'tmuxDisconnected'; - return session.connected ? 'normal' : 'disconnected'; - } function adviceKey(code) { if (code === 'PERMISSION_DENIED') return 'permission'; if (code === 'OUTCOME_UNKNOWN' || code === 'TIMEOUT') return 'unknown'; @@ -108,6 +102,8 @@ }catch(error){loading.remove();report(modal,error);} } async function openPresets() { + const picker=document.getElementById('workspaceLayoutPicker'); + if(picker?.open){picker.open=false;picker.querySelector('summary')?.focus();} const modal=dialog(t('presetsTitle'));modal.body.append(el('p',t('presetsHint'),'customer-intro')); const loading=el('p',t('loading'),'customer-muted');loading.role='status';modal.body.append(loading); let presets; @@ -164,17 +160,7 @@ } document.querySelectorAll('[data-workspace-presets]').forEach(item=>item.addEventListener('click',openPresets)); document.querySelectorAll('[data-recovery-check]').forEach(item=>item.addEventListener('click',openRecovery)); - const status=document.getElementById('workspaceContinuity'); - function renderContinuity() { - if(!status)return; - const manager=sessions;const session=manager?.getSession(manager.getWorkspaceSession?.() || manager.getActiveSession?.()); - status.textContent=t('continuity.'+continuityKey(session,root.socket?.connected!==false)); - status.hidden=!session; - } - root.addEventListener('session-workspace-change',renderContinuity);root.addEventListener('languageChanged',renderContinuity); - for(const event of ['connect','disconnect','ssh_disconnected','ssh_session_restored']) root.socket?.on(event,()=>root.setTimeout(renderContinuity,0)); - renderContinuity(); refreshRecoveryNotice(); } - return {layoutAssignments, presetId, continuityKey, adviceKey, init}; + return {layoutAssignments, presetId, adviceKey, init}; })); diff --git a/static/js/effective-host-trust.js b/static/js/effective-host-trust.js new file mode 100644 index 00000000..cf0b946b --- /dev/null +++ b/static/js/effective-host-trust.js @@ -0,0 +1,108 @@ +(function () { + 'use strict'; + const t = (key, fallback) => { + const value = window.i18n?.t(key); + return value && value !== key ? value : fallback; + }; + const node = (tag, text, className) => { + const element = document.createElement(tag); + if (text) element.textContent = text; + if (className) element.className = className; + return element; + }; + let generation = 0; + async function load(api) { + const container = document.getElementById('effectiveHostTrust'); + if (!container) return; + const request = ++generation; + container.replaceChildren(node('p', t('trustFiles.loading', 'Loading effective trust…'))); + try { + const data = await api('/api/host-keys/effective'); + if (request !== generation) return; + render(container, data.targets || []); + } catch { + if (request === generation) container.replaceChildren(node('p', t('trustFiles.loadFailed', 'Effective trust could not be loaded. Refresh to try again.'))); + } + } + function render(container, targets, afterDelete = false) { + const title = node('h3', afterDelete ? t('trustFiles.afterDelete', 'After deleting this personal entry') : t('trustFiles.effectiveTitle', 'Effective trust for saved SSH targets')); + const hint = node('p', t('trustFiles.effectiveHint', 'Configuration only; no connection is made. Personal keys take precedence per algorithm. Revocations apply across both sources.'), 'admin-muted'); + const searchLabel = node('label', t('trustFiles.search', 'Search saved targets')); + const search = node('input', null, 'form-control'); + search.type = 'search'; + searchLabel.append(search); + const list = node('div'); + const controls = node('div', null, 'admin-toolbar effective-trust-pagination'); + const previous = node('button', t('trustFiles.previous', 'Previous'), 'btn btn-secondary'); + const next = node('button', t('trustFiles.next', 'Next'), 'btn btn-secondary'); + previous.type = next.type = 'button'; + const status = node('span'); + status.setAttribute('role', 'status'); + controls.append(previous, status, next); + container.replaceChildren(title, hint, searchLabel, list, controls); + let page = 0; + const draw = () => { + const query = search.value.toLowerCase(); + const filtered = targets.filter(target => `${target.name} ${target.host}:${target.port}`.toLowerCase().includes(query)); + const pages = Math.max(1, Math.ceil(filtered.length / 20)); + controls.hidden = pages === 1; + page = Math.min(page, pages - 1); + status.textContent = `${page + 1} / ${pages} · ${filtered.length}`; + previous.disabled = page === 0; + next.disabled = page + 1 >= pages; + list.replaceChildren(); + for (const target of filtered.slice(page * 20, (page + 1) * 20)) { + const details = node('details', null, 'effective-trust-target'); + details.open = afterDelete; + const summary = node('summary', `${target.name || target.host} · ${target.host}:${target.port}`); + details.append(summary); + const keys = target.keys || []; + if (!keys.some(key => key.status === 'effective')) details.append(node('p', t('trustFiles.noTrust', 'No effective trusted key. A connection still requires host-key verification.'))); + for (const key of keys) { + const row = node('div', null, 'effective-trust-key'); + const source = key.scope === 'user' ? t('trustFiles.personal', 'Personal') : t('trustFiles.global', 'Global'); + const states = {effective: ['trustFiles.effective', 'Effective'], overridden: ['trustFiles.overridden', 'Overridden'], revoked: ['trustFiles.revoked', 'Revoked']}; + const state = states[key.status] || ['trustFiles.unknown', 'Unknown']; + row.append(node('strong', `${source} · ${t(...state)} · ${key.algorithm}`), node('code', key.fingerprint)); + details.append(row); + } + list.append(details); + } + if (!filtered.length) list.append(node('p', afterDelete ? t('trustFiles.noAffectedTargets', 'No matching saved SSH targets. Remaining trust cannot be determined for this entry.') : t('trustFiles.noTargets', 'No matching saved SSH targets. Personal entries remain manageable below.'))); + }; + search.oninput = () => { page = 0; draw(); }; + previous.onclick = () => { page--; draw(); }; + next.onclick = () => { page++; draw(); }; + draw(); + } + function confirmDeletion(entry, hostLabel, impact) { + return new Promise(resolve => { + const opener = document.activeElement; + const dialog = node('dialog', null, 'trust-deletion-dialog'); + const title = node('h2', t('trustFiles.deleteTitle', 'Review personal host-key deletion')); + title.id = 'trustDeletionTitle'; + dialog.setAttribute('aria-labelledby', title.id); + const explanation = node('p', window.WebSSHSecurityUI.hostKeyConfirmation(entry, hostLabel, t)); + const fingerprint = node('code', `${entry.algorithm} · ${entry.fingerprint}`); + const preview = node('div'); + render(preview, impact.targets || [], true); + dialog.append(title, explanation, fingerprint, preview); + if (impact.unresolved) dialog.append(node('p', t('trustFiles.unresolved', 'Other hosts matched by this entry are not saved targets. Their remaining trust is unknown; this includes unmatched aliases, wildcard matches and hashed hosts.'))); + const actions = node('div', null, 'trust-deletion-actions'); + const cancel = node('button', t('common.cancel', 'Cancel'), 'btn btn-secondary'); + const remove = node('button', window.WebSSHSecurityUI.describeHostKey(entry, t).action, 'btn btn-danger'); + cancel.type = remove.type = 'button'; + actions.append(cancel, remove); + dialog.append(actions); + let confirmed = false; + cancel.onclick = () => dialog.close(); + remove.onclick = () => { confirmed = true; dialog.close(); }; + dialog.onclose = () => { dialog.remove(); if (opener?.isConnected) opener.focus(); resolve(confirmed); }; + dialog.addEventListener('keydown', event => event.stopPropagation()); + document.body.append(dialog); + dialog.showModal(); + cancel.focus(); + }); + } + window.EffectiveHostTrust = {load, render, confirmDeletion}; +}()); diff --git a/static/js/native-credentials.js b/static/js/native-credentials.js index 80c3ebad..1aae5db5 100644 --- a/static/js/native-credentials.js +++ b/static/js/native-credentials.js @@ -89,11 +89,25 @@ } } async function load(){const generation=++loadGeneration;const data=await api('/api/credentials/state');if(generation===loadGeneration)state=data;return data;} + function assignmentReady(use,resource){ + return use?.valid===true&&(!resource?.credential_status||resource.credential_status==='ready'); + } + async function reviewAssignment(resourceId,credentialId,current=()=>true){ + const data=await load();if(!current())throw new DOMException('Cancelled','AbortError'); + const target=data.profiles?.find(resource=>resource.id===resourceId); + if(!target||target.credential_id!==credentialId)throw new Error(t('credentials.assignmentChanged','The destination or its password assignment changed. Reload it and review the assignment again.')); + if(!await confirmTargetReuse(target)||!current())throw new DOMException('Cancelled','AbortError'); + return mutate('assign',{resource_id:resourceId,credential_id:credentialId,confirm_target:true,target},null,current); + } function editor(container,item={},onSaved=()=>{},current=()=>true){ container.replaceChildren();const form=el('form',null,'native-credential-editor');const active=()=>current()&&form.isConnected&&!container.hidden&&(!container.closest('#credentialManagementView')||!container.closest('#credentialManagementView').hidden);const title=el('h3',item.id?t('credentials.edit','Password details'):t('credentials.new','New password'));form.append(title); const fields={};[['name',t('credentials.name','Name'),'text'],['username',t('credentials.username','Suggested username'),'text'],['password',item.id?t('credentials.replace','Replace password (leave empty to keep)'):t('credentials.password','Password'),'password']].forEach(([key,text,type])=>{const label=el('label',text);const input=el('input');input.name=key;input.type=type;input.className='form-control';input.autocomplete=type==='password'?'new-password':'off';input.required=key==='name'||(key==='password'&&!item.id);if(type!=='password')input.value=item[key]||'';input.maxLength=key==='name'?120:4096;label.append(input);form.append(label);fields[key]=input;}); const hint=el('p',t('credentials.writeOnly','Saved passwords are never displayed. Replacing one affects every listed assignment.'),'credential-hint');form.append(hint); - if(item.id){const assignments=el('section',null,'credential-assignments');assignments.append(el('h4',t('credentials.assignmentsTitle','Assigned hosts')));if(!item.uses?.length)assignments.append(el('p',t('credentials.noAssignments','This password is not assigned to a host yet.'),'credential-hint'));const assign=el('button',t('credentials.assignHosts','Assign to hosts'),'btn btn-secondary');assign.type='button';assign.disabled=state?.enabled===false;assign.onclick=()=>{if(!clean())return;assignHosts(assignments,item,onSaved,active,clean);};assignments.append(assign);form.append(assignments);}if(item.uses?.length){const list=el('ul');item.uses.forEach(use=>{const li=el('li');const link=el('button',use.name||use.resource_id||use.id,'btn btn-secondary btn-sm');link.type='button';link.onclick=()=>window.dispatchEvent(new CustomEvent('webssh:credential-resource',{detail:use}));const unassign=el('button',t('credentials.unassign','Remove assignment'),'btn btn-secondary btn-sm');unassign.type='button';unassign.onclick=async()=>{if(form.dataset.assignmentsDirty==='true'){status.textContent=t('credentials.applyFirst','Apply or cancel your host assignment changes before saving password details.');return;}if(!clean())return;unassign.disabled=true;try{await mutate('assign',{resource_id:use.resource_id||use.id,credential_id:null,confirm_target:true},null,()=>active()&&clean());if(active())onSaved();}catch(error){if(error.name!=='AbortError')unassign.textContent=error.message;}finally{unassign.disabled=false;}};const resource=state?.profiles?.find(target=>target.id===(use.resource_id||use.id));li.append(link);if(resource)li.append(el('span',resource.username+'@'+resource.host+':'+(resource.port||22),'credential-target-address'));li.append(unassign);list.append(li);});form.append(list);} + if(item.id){const assignments=el('section',null,'credential-assignments');assignments.append(el('h4',t('credentials.assignmentsTitle','Assigned hosts')));if(!item.uses?.length)assignments.append(el('p',t('credentials.noAssignments','This password is not assigned to a host yet.'),'credential-hint'));const assign=el('button',t('credentials.assignHosts','Assign to hosts'),'btn btn-secondary');assign.type='button';assign.disabled=state?.enabled===false;assign.onclick=()=>{if(!clean())return;assignHosts(assignments,item,onSaved,active,clean);};assignments.append(assign);form.append(assignments);}if(item.uses?.length){const list=el('ul');item.uses.forEach(use=>{const li=el('li');const link=el('button',use.name||use.resource_id||use.id,'btn btn-secondary btn-sm');link.type='button';link.onclick=()=>window.dispatchEvent(new CustomEvent('webssh:credential-resource',{detail:use}));const unassign=el('button',t('credentials.unassign','Remove assignment'),'btn btn-secondary btn-sm');unassign.type='button';unassign.onclick=async()=>{if(form.dataset.assignmentsDirty==='true'){status.textContent=t('credentials.applyFirst','Apply or cancel your host assignment changes before saving password details.');return;}if(!clean())return;unassign.disabled=true;try{await mutate('assign',{resource_id:use.resource_id||use.id,credential_id:null,confirm_target:true},null,()=>active()&&clean());if(active())onSaved();}catch(error){if(error.name!=='AbortError')unassign.textContent=error.message;}finally{unassign.disabled=false;}};const resource=state?.profiles?.find(target=>target.id===(use.resource_id||use.id));li.append(link);if(resource)li.append(el('span',resource.username+'@'+resource.host+':'+(resource.port||22),'credential-target-address'));const ready=assignmentReady(use,resource); + li.classList.toggle('credential-assignment-invalid',!ready); + li.append(el('span',ready?t('trustFiles.assignmentReady','Ready'):t('trustFiles.assignmentInvalid','Review required · saved password cannot be used for this destination'),'credential-assignment-status')); + if(!ready){const review=el('button',t('trustFiles.reviewAssignment','Review and authorize again'),'btn btn-secondary btn-sm');review.type='button';review.disabled=state?.enabled===false;review.onclick=async()=>{if(!clean()||form.dataset.assignmentsDirty==='true')return;review.disabled=true;try{await reviewAssignment(use.resource_id||use.id,item.id,()=>active()&&clean());if(active())onSaved(item.id);}catch(error){if(error.name!=='AbortError')status.textContent=error.message;}finally{review.disabled=false;}};li.append(review);} + li.append(unassign);list.append(li);});form.append(list);} const status=el('p');const clean=()=>{const unchanged=form.dataset.initial===JSON.stringify(Array.from(form.querySelectorAll('input[name]')).map(input=>input.value));if(!unchanged)status.textContent=t('credentials.saveFirst','Save your password changes before changing host assignments.');return unchanged;};status.setAttribute('role','status');const save=el('button',t('common.save','Save'),'btn btn-primary');save.type='submit';save.disabled=state?.enabled===false;const actions=el('div',null,'credential-editor-actions');actions.append(save);form.append(status,actions); if(item.id){const remove=el('button',t('common.delete','Delete'),'btn btn-secondary');remove.type='button';const erasingAssigned=state?.enabled===false&&Boolean(item.uses?.length);remove.disabled=state?.enabled!==false&&Boolean(item.uses?.length);remove.onclick=async()=>{if(await question(erasingAssigned?t('credentials.deleteAssignedConfirm','Delete this password and remove its host assignments?'):t('credentials.deleteConfirm','Delete this unused password?'),{confirmation:true,acceptLabel:t('common.delete','Delete')})!==true)return;remove.disabled=true;try{await mutate('delete',{},item.id,active);onSaved();}catch(error){status.textContent=error.message;}finally{remove.disabled=false;}};actions.append(remove);} form.onsubmit=async event=>{event.preventDefault();if(form.dataset.assignmentsDirty==='true'){status.textContent=t('credentials.applyFirst','Apply or cancel your host assignment changes before saving password details.');return;}save.disabled=true;Object.values(fields).forEach(input=>input.disabled=true);const payload={name:fields.name.value.trim(),username:fields.username.value.trim()};if(fields.password.value)payload.password=fields.password.value; @@ -140,7 +154,7 @@ if(!state.enabled){const help=el('a','Wiki','btn btn-secondary');help.href='https://github.com/bifrost0x/webssh/wiki/Profiles-Jump-Hosts-and-Commands';help.target='_blank';help.rel='noopener noreferrer';notice.append(document.createTextNode(' '),help);detail.replaceChildren();list.replaceChildren();view.querySelector('[data-credential-legacy]').hidden=true;(state.credentials||[]).forEach(item=>{const row=el('div',null,'native-credential-row');row.append(el('strong',item.name));const remove=el('button',t('common.delete','Delete'),'btn btn-secondary');remove.type='button';remove.onclick=async()=>{const assigned=Boolean(item.uses?.length);if(await question(assigned?t('credentials.deleteAssignedConfirm','Delete this password and remove its host assignments?'):t('credentials.deleteConfirm','Delete this unused password?'),{confirmation:true,acceptLabel:t('common.delete','Delete')})!==true)return;remove.disabled=true;try{await mutate('delete',{},item.id);await mount();}catch(error){notice.textContent=error.message;remove.disabled=false;}};row.append(remove);list.append(row);});return;} const saved=id=>{const chosen=id||detail.dataset.credentialId;detail.replaceChildren();return mount(chosen);};const discard=async()=>{const form=detail.querySelector('form');if(!form||(form.dataset.assignmentsDirty!=='true'&&form.dataset.initial===JSON.stringify(Array.from(form.querySelectorAll('input[name]')).map(input=>input.value))))return true;return await question(t('editor.unsavedConfirm','Discard unsaved changes?'),{confirmation:true})===true;}; const back=el('button',t('common.backToList','Back to list'),'btn btn-secondary credential-back');back.type='button';back.onclick=async()=>{if(!await discard())return;detail.hidden=true;detail.replaceChildren();view.classList.remove('has-credential-detail');search.focus();};if(!view.querySelector('.credential-back'))view.querySelector('.native-credential-toolbar').prepend(back); - const render=()=>{list.replaceChildren();const records=(state.credentials||[]).filter(item=>`${item.name} ${item.username}`.toLowerCase().includes(search.value.toLowerCase()));if(!records.length)list.append(el('p',t('credentials.empty','No passwords found.')));records.forEach(item=>{const button=el('button',null,'native-credential-row');button.type='button';button.setAttribute('aria-pressed',String(item.id===(detail.dataset.credentialId||selectedId)));button.append(el('strong',item.name),el('span',`${item.username||''} · ${item.uses?.length||0} ${t('credentials.assignments','assignments')}`));button.onclick=async()=>{if(!await discard())return;detail.dataset.credentialId=item.id;list.querySelectorAll('button').forEach(row=>row.setAttribute('aria-pressed',String(row===button)));editor(detail,item,saved);detail.hidden=false;view.classList.add('has-credential-detail');detail.querySelector('input')?.focus();};list.append(button);});};search.oninput=render;view.querySelector('[data-credential-new]').onclick=async()=>{if(!await discard())return;detail.dataset.credentialId='';editor(detail,{},saved);detail.hidden=false;view.classList.add('has-credential-detail');detail.querySelector('input')?.focus();};render();if(selectedId){const selected=state.credentials?.find(item=>item.id===selectedId);if(selected){detail.dataset.credentialId=selected.id;editor(detail,selected,saved);detail.hidden=false;view.classList.add('has-credential-detail');}}if(!detail.children.length){detail.hidden=false;view.classList.remove('has-credential-detail');detail.append(el('div',t('credentials.selectHint','Select a password to view its details and host assignments.'),'credential-empty-detail'));} + const render=()=>{list.replaceChildren();const records=(state.credentials||[]).filter(item=>`${item.name} ${item.username}`.toLowerCase().includes(search.value.toLowerCase()));if(!records.length)list.append(el('p',t('credentials.empty','No passwords found.')));records.forEach(item=>{const button=el('button',null,'native-credential-row');button.type='button';button.setAttribute('aria-pressed',String(item.id===(detail.dataset.credentialId||selectedId)));button.append(el('strong',item.name),el('span',`${item.username||''} · ${item.uses?.length||0} ${t('credentials.assignments','assignments')}`));const invalid=(item.uses||[]).filter(use=>!assignmentReady(use,state.profiles?.find(resource=>resource.id===(use.resource_id||use.id)))).length;if(invalid)button.append(el('span',invalid+' '+t('trustFiles.assignmentsInvalid','assignments need review'),'credential-assignment-status'));button.onclick=async()=>{if(!await discard())return;detail.dataset.credentialId=item.id;list.querySelectorAll('button').forEach(row=>row.setAttribute('aria-pressed',String(row===button)));editor(detail,item,saved);detail.hidden=false;view.classList.add('has-credential-detail');detail.querySelector('input')?.focus();};list.append(button);});};search.oninput=render;view.querySelector('[data-credential-new]').onclick=async()=>{if(!await discard())return;detail.dataset.credentialId='';editor(detail,{},saved);detail.hidden=false;view.classList.add('has-credential-detail');detail.querySelector('input')?.focus();};render();if(selectedId){const selected=state.credentials?.find(item=>item.id===selectedId);if(selected){detail.dataset.credentialId=selected.id;editor(detail,selected,saved);detail.hidden=false;view.classList.add('has-credential-detail');}}if(!detail.children.length){detail.hidden=false;view.classList.remove('has-credential-detail');detail.append(el('div',t('credentials.selectHint','Select a password to view its details and host assignments.'),'credential-empty-detail'));} const legacy=view.querySelector('[data-credential-legacy]');legacy.hidden=!state.legacy_vault;legacy.onclick=async()=>{if(!await discard())return;const passphrase=await question(t('credentials.legacyPassphrase','Import old vault: enter its original passphrase'),{secret:true,heading:t('credentials.importLegacy','Import existing vault')});if(passphrase===null)return;legacy.disabled=true;try{await mutate('import',{passphrase});await mount();}catch(error){notice.textContent=error.message;}finally{legacy.disabled=false;}}; }catch(error){notice.textContent=error.message;} } @@ -201,7 +215,7 @@ try{const data=await load();select.closest('.native-host-credentials').hidden=!data.enabled;if(select.dataset.resource!==(resourceId||'')||snapshot!==JSON.stringify(Array.from(form.querySelectorAll('input,select')).map(input=>input.type==='checkbox'||input.type==='radio'?input.checked:input.value)))return;const resource=data.profiles.find(item=>item.id===resourceId);const manual=el('option',t('credentials.manual','Enter manually'));manual.value='';select.replaceChildren(manual);data.credentials.forEach(item=>{const option=el('option',item.name);option.value=item.id;select.append(option);});select.value=resource?.credential_id||'';select.dataset.assigned=resource?.credential_id||'';select.dataset.confirmRequired=String(Boolean(resource?.credential_id&&resource?.credential_status!=='ready'));if(prefix==='jhCredential'&&window.JumpHostManager?.editingId===(resourceId?resourceId.slice(5):null))window.JumpHostManager.baseline=window.JumpHostManager.draft();}catch{} } async function confirmTargetReuse(target){return await question(t('credentials.confirmTarget','Reuse this saved password for the changed destination?')+' '+target.username+'@'+target.host+':'+target.port+(target.share?'/'+target.share:'')+(target.domain?' · '+target.domain:''),{confirmation:true})===true;} - window.NativeCredentials={api,authorizeTarget,mutate,load,mount,pick,editor,inlineDraft,inlineIdentity,confirmTargetReuse,refreshInline}; + window.NativeCredentials={assignmentReady,reviewAssignment,api,authorizeTarget,mutate,load,mount,pick,editor,inlineDraft,inlineIdentity,confirmTargetReuse,refreshInline}; document.addEventListener('DOMContentLoaded',()=>{installInlineCreate('jumpHostForm','jhCredential');installInlineCreate('smbSourceForm','smbCredential');}); document.addEventListener('DOMContentLoaded',installHostEditor); }()); diff --git a/static/js/overview-layout.js b/static/js/overview-layout.js index fb37e035..53779487 100644 --- a/static/js/overview-layout.js +++ b/static/js/overview-layout.js @@ -22,6 +22,7 @@ {id: 'hosts', w: 8, h: null, hidden: false}, {id: 'platforms', w: 4, h: null, hidden: false}, {id: 'activity', w: 4, h: null, hidden: false}, + {id: 'continue', w: 12, h: null, hidden: false}, ]; const PANELS = DEFAULTS.map(item => item.id); diff --git a/static/js/overview-workspace.js b/static/js/overview-workspace.js index 7473ba31..7dd81d80 100644 --- a/static/js/overview-workspace.js +++ b/static/js/overview-workspace.js @@ -1039,6 +1039,7 @@ kpis: tw('kpisLabel', 'Key figures'), topology: tw('topologyTitle', 'Infrastructure'), hosts: tw('hostsTitle', 'Hosts'), attention: tw('attentionTitle', 'Needs attention'), automation: tw('automationTitle', 'Automation'), platforms: tw('platformsTitle', 'Platforms'), activity: tw('activityTitle', 'Activity'), + continue: wf('title', 'Continue working'), }[id] || id; } function announce(message) { diff --git a/static/js/session-manager.js b/static/js/session-manager.js index 0f9f01a0..21c3f837 100644 --- a/static/js/session-manager.js +++ b/static/js/session-manager.js @@ -634,6 +634,9 @@ const SessionManager = { } if (this.sessions[sessionId]) { + if (status !== 'connected') { + this.sessions[sessionId].inputGeneration = (this.sessions[sessionId].inputGeneration || 0) + 1; + } this.sessions[sessionId].connected = (status === 'connected'); } @@ -1092,6 +1095,7 @@ const SessionManager = { }, notifyWorkspaceChange() { + window.TerminalPasteGuard?.onWorkspaceChange(); if (!window.dispatchEvent || !window.CustomEvent) { return; } diff --git a/static/js/sftp-file-manager.js b/static/js/sftp-file-manager.js index 8dee6e9b..cef59d66 100644 --- a/static/js/sftp-file-manager.js +++ b/static/js/sftp-file-manager.js @@ -1370,6 +1370,17 @@ class SFTPFileManager extends window.FileDirectoryRequests { +
+ + + + +
@@ -1775,6 +1786,18 @@ class SFTPFileManager extends window.FileDirectoryRequests { this.setupDropZones(); document.getElementById('fmQueueHeader').addEventListener('click', () => this.toggleQueue()); + document.getElementById('fmQueueFilter').addEventListener('change', event => { + this.transferQueueFilter = event.target.value; + this.transferQueuePage = 0; + this.renderTransferQueue(); + }); + for (const [id, direction] of [['fmQueuePrevious', -1], ['fmQueueNext', 1]]) { + document.getElementById(id).addEventListener('click', () => { + this.transferQueuePage = (this.transferQueuePage || 0) + direction; + this.renderTransferQueue(); + document.getElementById('fmQueueList').scrollTop = 0; + }); + } document.getElementById('fmQueueList').addEventListener('click', (event) => { const recovery = event.target.closest('[data-transfer-recover]'); if (recovery) { @@ -5077,6 +5100,8 @@ class SFTPFileManager extends window.FileDirectoryRequests { filename: item.name, sourcePath: sourcePath, targetPath: targetPath, + sourcePane: {source: {...sourcePane.source}}, + targetPane: {source: {...targetPane.source}}, size: item.size || 0, sourceIds, requestId, @@ -5479,6 +5504,7 @@ class SFTPFileManager extends window.FileDirectoryRequests { const sourcesRetained = transfer.sourcesRetained === true; delete transfer.sourcesRetained; transfer.sourceIds = this.getTransferSourceIds(transfer); + transfer.endpoints = this.describeTransferEndpoints(transfer); if (!sourcesRetained) this.retainTransferSources(transfer.sourceIds); transfer.status = 'pending'; transfer.progress = 0; @@ -5963,11 +5989,59 @@ class SFTPFileManager extends window.FileDirectoryRequests { return {active, failed, unconfirmed, total, text}; } + describeTransferEndpoints(transfer) { + if (transfer.endpoints) return transfer.endpoints; + const browser = this.t('trustFiles.browser', 'This browser'); + const endpoint = (id, pane, path) => { + const source = pane?.source || (id ? this.findPaneStateEntry(id)?.state?.source : null); + const label = source?.endpoint || source?.label || this.t('trustFiles.remote', 'Remote source'); + return [label, path].filter(Boolean).join(' · '); + }; + if (transfer.type === 'upload') return { + source: `${browser} · ${transfer.filename}`, + destination: endpoint(transfer.sourceId, transfer.targetPane, transfer.targetPath) + }; + if (transfer.type === 'download') return { + source: endpoint(transfer.sourceId, transfer.sourcePane, transfer.sourcePath), + destination: `${browser} · ${transfer.filename}` + }; + return { + source: endpoint(transfer.sourceIds?.[0], transfer.sourcePane, transfer.sourcePath), + destination: endpoint(transfer.sourceIds?.[1], transfer.targetPane, transfer.targetPath) + }; + } + + getTransferQueuePage() { + const active = item => ['pending', 'active', 'cancelling'].includes(item.status); + const filter = this.transferQueueFilter || 'all'; + const ordered = this.transferQueue.filter(item => filter === 'active' ? active(item) + : filter === 'error' ? item.status === 'error' + : filter === 'history' ? !active(item) && item.status !== 'error' : true); + if (filter === 'all') { + // Stable order keeps pending cancellation and failed outcomes ahead + // of completed history, even when new transfers keep arriving. + ordered.sort((a, b) => (active(a) ? 0 : a.status === 'error' ? 1 : 2) + - (active(b) ? 0 : b.status === 'error' ? 1 : 2)); + } + const pages = Math.max(1, Math.ceil(ordered.length / 20)); + const page = Math.max(0, Math.min(pages - 1, this.transferQueuePage || 0)); + this.transferQueuePage = page; + return {entries: ordered.slice(page * 20, (page + 1) * 20), total: ordered.length, page, pages}; + } + renderTransferQueue() { window.dispatchEvent?.(new CustomEvent('file-transfer-queue-change')); const container = document.getElementById('fmQueueList'); const badge = document.getElementById('fmQueueBadge'); const queue = document.getElementById('fmQueue'); + if (!container || !badge) return; + const page = this.getTransferQueuePage(); + const pageLabel = document.getElementById('fmQueuePage'); + if (pageLabel) pageLabel.textContent = `${page.page + 1} / ${page.pages} · ${page.total}`; + const previous = document.getElementById('fmQueuePrevious'); + const next = document.getElementById('fmQueueNext'); + if (previous) previous.disabled = page.page === 0; + if (next) next.disabled = page.page + 1 >= page.pages; const summary = this.getTransferQueueSummary(); const summaryElement = document.getElementById('fmQueueSummary'); if (summaryElement && summaryElement.textContent !== summary.text) summaryElement.textContent = summary.text; @@ -5977,6 +6051,7 @@ class SFTPFileManager extends window.FileDirectoryRequests { - Number(container.scrollTop || 0) - Number(container.clientHeight || 0) ) <= 24; + const previousScrollTop = container.scrollTop; const tailId = this.transferQueue.length > 0 ? String(this.transferQueue[this.transferQueue.length - 1].id) : null; @@ -5986,12 +6061,16 @@ class SFTPFileManager extends window.FileDirectoryRequests { badge.textContent = summary.total; badge.style.display = summary.total > 0 ? 'inline' : 'none'; - if (this.transferQueue.length === 0) { + if (page.entries.length === 0) { container.innerHTML = `
${this.t('fm.noTransfers', 'No transfers')}
`; return; } - container.innerHTML = this.transferQueue.slice(-20).map(t => { + const focused = document.activeElement?.dataset; + const focusedId = focused?.transferCancel || focused?.transferRecover; + const openDetails = new Set(Array.from(container.querySelectorAll?.('details[open]') || [], node => node.closest('[data-transfer-id]')?.dataset.transferId)); + container.innerHTML = page.entries.map(t => { + const endpoints = this.describeTransferEndpoints(t); const reason = t.status === 'error' ? this.escapeHtml(t.error || this.transferFailureMessage(t.errorCode)) : ''; @@ -6003,12 +6082,13 @@ class SFTPFileManager extends window.FileDirectoryRequests { ? this.t('transfer.reviewDestination', 'Check the destination before starting the transfer again.') : ''; return ` -
+
${t.type === 'upload' ? '⬆️' : t.type === 'download' ? '⬇️' : '↔️'}
${this.escapeHtml(t.filename)}
+
${this.escapeHtml(endpoints.source)} → ${this.escapeHtml(endpoints.destination)}
${reason ? `
${reason}
` : ''} ${t.status === 'error' && window.CustomerWorkflows ? `
${this.escapeHtml(this.t('customer.nextSteps', 'What can I do?'))}

${this.escapeHtml(this.t('customer.' + window.CustomerWorkflows.adviceKey(t.errorCode), 'Inspect the destination before repeating this operation.'))}

${this.escapeHtml([t.sourcePath, t.targetPath].filter(Boolean).join(' → '))}

` : ''} ${retryHint ? `
${this.escapeHtml(retryHint)}
` : ''} @@ -6030,7 +6110,12 @@ class SFTPFileManager extends window.FileDirectoryRequests {
`; }).join(''); - if (!queue?.classList?.contains('collapsed') && (appended || nearBottom)) { + container.scrollTop = previousScrollTop; + for (const row of container.querySelectorAll?.('[data-transfer-id]') || []) { + if (openDetails.has(row.dataset.transferId)) row.querySelector('details')?.setAttribute('open', ''); + if (focusedId === row.dataset.transferId) row.querySelector(focused?.transferCancel ? '[data-transfer-cancel]' : '[data-transfer-recover]')?.focus({preventScroll:true}); + } + if (!focusedId && page.page === 0 && !queue?.classList?.contains('collapsed') && (appended || nearBottom)) { this.scrollTransferQueueToEnd(tailId); } } @@ -6069,7 +6154,10 @@ class SFTPFileManager extends window.FileDirectoryRequests { expectedTailId !== null && this.renderedQueueTailId !== expectedTailId ) return; - container.scrollTop = container.scrollHeight; + const prioritized = (!this.transferQueueFilter || this.transferQueueFilter === 'all') + && this.transferQueue?.length > 20 + && this.transferQueue.some(item => ['pending', 'active', 'cancelling', 'error'].includes(item.status)); + container.scrollTop = prioritized ? 0 : container.scrollHeight; }); } diff --git a/static/js/support-report-i18n.js b/static/js/support-report-i18n.js new file mode 100644 index 00000000..c006c1e3 --- /dev/null +++ b/static/js/support-report-i18n.js @@ -0,0 +1,138 @@ +(function () { + 'use strict'; + const dictionaries = { + "en": { + "supportReport.title": "Support report", + "supportReport.close": "Close", + "supportReport.hint": "Review the exact contents before sharing. The report contains version, feature availability and local readiness; administrators also see selected deployment flags. No logs, host details or terminal contents are collected. Nothing is sent automatically.", + "supportReport.area": "Area", + "supportReport.general": "General", + "supportReport.ssh": "SSH connections", + "supportReport.files": "Files and transfers", + "supportReport.sign_in": "Sign-in", + "supportReport.automation": "Automation", + "supportReport.reference": "Error reference (optional)", + "supportReport.referenceHint": "If the error shows an SMB reference, enter it here. Leave blank for other errors.", + "supportReport.generate": "Generate preview", + "supportReport.preview": "Report contents", + "supportReport.copy": "Copy report", + "supportReport.download": "Download report", + "supportReport.loading": "Generating report…", + "supportReport.ready": "Preview ready. Add reproduction steps separately when sharing.", + "supportReport.failed": "The report could not be generated. Please try again.", + "supportReport.copied": "Report copied.", + "supportReport.copyFailed": "Clipboard access is unavailable. Download the report instead." + }, + "de": { + "supportReport.title": "Diagnosebericht", + "supportReport.close": "Schließen", + "supportReport.hint": "Prüfe den Inhalt vor dem Teilen. Der Bericht enthält Version, verfügbare Funktionen und lokale Betriebsbereitschaft; Administratoren sehen zusätzlich ausgewählte Deployment-Einstellungen. Logs, Hostdetails und Terminalinhalte werden nicht erfasst. Es wird nichts automatisch versendet.", + "supportReport.area": "Bereich", + "supportReport.general": "Allgemein", + "supportReport.ssh": "SSH-Verbindungen", + "supportReport.files": "Dateien und Transfers", + "supportReport.sign_in": "Anmeldung", + "supportReport.automation": "Automation", + "supportReport.reference": "Fehlerreferenz (optional)", + "supportReport.referenceHint": "Falls der Fehler eine SMB-Referenz zeigt, trage sie hier ein. Bei anderen Fehlern leer lassen.", + "supportReport.generate": "Vorschau erzeugen", + "supportReport.preview": "Berichtsinhalt", + "supportReport.copy": "Bericht kopieren", + "supportReport.download": "Bericht herunterladen", + "supportReport.loading": "Bericht wird erstellt…", + "supportReport.ready": "Vorschau bereit. Ergänze beim Teilen die Schritte zum Nachstellen separat.", + "supportReport.failed": "Der Bericht konnte nicht erstellt werden. Bitte erneut versuchen.", + "supportReport.copied": "Bericht kopiert.", + "supportReport.copyFailed": "Kein Zugriff auf die Zwischenablage. Lade den Bericht stattdessen herunter." + }, + "fr": { + "supportReport.title": "Rapport de diagnostic", + "supportReport.close": "Fermer", + "supportReport.hint": "Vérifiez le contenu avant de le partager. Le rapport contient la version, les fonctions disponibles et la disponibilité locale ; les administrateurs voient aussi certains paramètres de déploiement. Aucun journal, détail de serveur ou contenu de terminal n’est collecté. Rien n’est envoyé automatiquement.", + "supportReport.area": "Domaine", + "supportReport.general": "Général", + "supportReport.ssh": "Connexions SSH", + "supportReport.files": "Fichiers et transferts", + "supportReport.sign_in": "Connexion au compte", + "supportReport.automation": "Automatisation", + "supportReport.reference": "Référence de l’erreur (facultatif)", + "supportReport.referenceHint": "Si l’erreur affiche une référence SMB, saisissez-la ici. Sinon, laissez ce champ vide.", + "supportReport.generate": "Générer l’aperçu", + "supportReport.preview": "Contenu du rapport", + "supportReport.copy": "Copier le rapport", + "supportReport.download": "Télécharger le rapport", + "supportReport.loading": "Génération du rapport…", + "supportReport.ready": "Aperçu prêt. Ajoutez séparément les étapes de reproduction lors du partage.", + "supportReport.failed": "Le rapport n’a pas pu être généré. Réessayez.", + "supportReport.copied": "Rapport copié.", + "supportReport.copyFailed": "Presse-papiers indisponible. Téléchargez le rapport." + }, + "es": { + "supportReport.title": "Informe de diagnóstico", + "supportReport.close": "Cerrar", + "supportReport.hint": "Revisa el contenido antes de compartirlo. El informe incluye la versión, las funciones disponibles y la disponibilidad local; los administradores también ven algunos ajustes del despliegue. No se recopilan registros, datos de hosts ni contenido del terminal. No se envía nada automáticamente.", + "supportReport.area": "Área", + "supportReport.general": "General", + "supportReport.ssh": "Conexiones SSH", + "supportReport.files": "Archivos y transferencias", + "supportReport.sign_in": "Inicio de sesión", + "supportReport.automation": "Automatización", + "supportReport.reference": "Referencia del error (opcional)", + "supportReport.referenceHint": "Si el error muestra una referencia SMB, introdúcela aquí. Para otros errores, deja el campo vacío.", + "supportReport.generate": "Generar vista previa", + "supportReport.preview": "Contenido del informe", + "supportReport.copy": "Copiar informe", + "supportReport.download": "Descargar informe", + "supportReport.loading": "Generando informe…", + "supportReport.ready": "Vista previa lista. Añade por separado los pasos para reproducir el problema al compartirla.", + "supportReport.failed": "No se pudo generar el informe. Inténtalo de nuevo.", + "supportReport.copied": "Informe copiado.", + "supportReport.copyFailed": "El portapapeles no está disponible. Descarga el informe." + }, + "vi": { + "supportReport.title": "Báo cáo chẩn đoán", + "supportReport.close": "Đóng", + "supportReport.hint": "Kiểm tra nội dung trước khi chia sẻ. Báo cáo chứa phiên bản, tính năng khả dụng và trạng thái sẵn sàng cục bộ; quản trị viên còn thấy một số thiết lập triển khai. Không thu thập nhật ký, chi tiết máy chủ hay nội dung terminal. Không tự động gửi dữ liệu.", + "supportReport.area": "Phạm vi", + "supportReport.general": "Chung", + "supportReport.ssh": "Kết nối SSH", + "supportReport.files": "Tệp và truyền tệp", + "supportReport.sign_in": "Đăng nhập", + "supportReport.automation": "Tự động hóa", + "supportReport.reference": "Mã tham chiếu lỗi (tùy chọn)", + "supportReport.referenceHint": "Nếu lỗi hiển thị mã tham chiếu SMB, nhập mã đó ở đây. Để trống với lỗi khác.", + "supportReport.generate": "Tạo bản xem trước", + "supportReport.preview": "Nội dung báo cáo", + "supportReport.copy": "Sao chép báo cáo", + "supportReport.download": "Tải báo cáo xuống", + "supportReport.loading": "Đang tạo báo cáo…", + "supportReport.ready": "Bản xem trước đã sẵn sàng. Khi chia sẻ, bổ sung riêng các bước tái hiện lỗi.", + "supportReport.failed": "Không thể tạo báo cáo. Vui lòng thử lại.", + "supportReport.copied": "Đã sao chép báo cáo.", + "supportReport.copyFailed": "Không thể truy cập bộ nhớ tạm. Hãy tải báo cáo xuống." + }, + "zh": { + "supportReport.title": "诊断报告", + "supportReport.close": "关闭", + "supportReport.hint": "分享前请检查内容。报告包含版本、可用功能和本地就绪状态;管理员还会看到部分部署设置。不收集日志、主机详情或终端内容,也不会自动发送任何数据。", + "supportReport.area": "范围", + "supportReport.general": "常规", + "supportReport.ssh": "SSH 连接", + "supportReport.files": "文件与传输", + "supportReport.sign_in": "登录", + "supportReport.automation": "自动化", + "supportReport.reference": "错误参考编号(可选)", + "supportReport.referenceHint": "如果错误显示 SMB 参考编号,请在此输入。其他错误请留空。", + "supportReport.generate": "生成预览", + "supportReport.preview": "报告内容", + "supportReport.copy": "复制报告", + "supportReport.download": "下载报告", + "supportReport.loading": "正在生成报告…", + "supportReport.ready": "预览已就绪。分享时请另行补充复现步骤。", + "supportReport.failed": "无法生成报告,请重试。", + "supportReport.copied": "报告已复制。", + "supportReport.copyFailed": "无法访问剪贴板,请下载报告。" + } +}; + for (const [language, values] of Object.entries(dictionaries)) window.i18n.addTranslations(language, values); +}()); diff --git a/static/js/support-report.js b/static/js/support-report.js new file mode 100644 index 00000000..e180a7c5 --- /dev/null +++ b/static/js/support-report.js @@ -0,0 +1,104 @@ +(function () { + 'use strict'; + const t = key => window.i18n.t(`supportReport.${key}`); + const root = (document.querySelector('meta[name="app-root"]')?.content || '').replace(/\/$/, ''); + const csrf = document.querySelector('meta[name="csrf-token"]')?.content || ''; + function node(tag, text, className) { + const element = document.createElement(tag); + if (text) element.textContent = text; + if (className) element.className = className; + return element; + } + function openReport(event) { + if (document.querySelector('.support-report-dialog')) return; + const trigger = event.currentTarget; + const dialog = node('dialog', '', 'support-report-dialog'); + dialog.setAttribute('aria-labelledby', 'supportReportTitle'); + const header = node('header', '', 'support-report-header'); + const title = node('h2', t('title')); title.id = 'supportReportTitle'; + const close = node('button', t('close'), 'btn btn-secondary'); close.type = 'button'; + close.addEventListener('click', () => dialog.close()); + header.append(title, close); + const body = node('div', '', 'support-report-body'); + body.append(node('p', t('hint'), 'admin-muted')); + const form = node('form', '', 'support-report-form'); + const areaLabel = node('label', t('area')); areaLabel.htmlFor = 'supportReportArea'; + const area = node('select', '', 'form-control'); area.id = 'supportReportArea'; + for (const value of ['general', 'ssh', 'files', 'sign_in', 'automation']) { + const option = node('option', t(value)); option.value = value; area.append(option); + } + const referenceLabel = node('label', t('reference')); referenceLabel.htmlFor = 'supportReportReference'; + const reference = node('input', '', 'form-control'); reference.id = 'supportReportReference'; + reference.type = 'text'; reference.maxLength = 16; reference.autocomplete = 'off'; reference.spellcheck = false; + reference.placeholder = 'SMB-ABCDEF123456'; reference.pattern = 'SMB-[A-F0-9]{12}'; + reference.setAttribute('aria-describedby', 'supportReportReferenceHint'); + const referenceHint = node('p', t('referenceHint'), 'admin-muted'); referenceHint.id = 'supportReportReferenceHint'; + const generate = node('button', t('generate'), 'btn btn-primary'); generate.type = 'submit'; + form.append(areaLabel, area, referenceLabel, reference, referenceHint, generate); + const status = node('p', '', 'support-report-status'); status.setAttribute('role', 'status'); + const preview = node('section'); preview.hidden = true; + const previewLabel = node('h3', t('preview')); previewLabel.id = 'supportReportPreviewTitle'; + const content = node('pre'); content.tabIndex = 0; content.setAttribute('aria-labelledby', previewLabel.id); + preview.append(previewLabel, content); body.append(form, status, preview); + const footer = node('footer', '', 'support-report-footer'); + const copy = node('button', t('copy'), 'btn btn-secondary'); copy.type = 'button'; copy.disabled = true; + const download = node('button', t('download'), 'btn btn-secondary'); download.type = 'button'; download.disabled = true; + footer.append(copy, download); dialog.append(header, body, footer); + let report = ''; + let controller; + const clearPreview = () => { + report = ''; content.textContent = ''; preview.hidden = true; + copy.disabled = download.disabled = true; status.textContent = ''; + }; + form.addEventListener('input', clearPreview); + form.addEventListener('change', clearPreview); + form.addEventListener('submit', async submit => { + submit.preventDefault(); + if (controller) return; + clearPreview(); controller = new AbortController(); + const timeout = setTimeout(() => controller?.abort(), 10000); + generate.disabled = area.disabled = reference.disabled = true; + status.textContent = t('loading'); + try { + const response = await fetch(`${root}/api/account/support-report`, { + method: 'POST', cache: 'no-store', signal: controller.signal, + headers: {'Content-Type': 'application/json', 'X-CSRFToken': csrf}, + body: JSON.stringify({area: area.value, reference: reference.value.trim()}), + }); + if (!response.ok) throw new Error('Report unavailable'); + const data = await response.json(); + if (!dialog.open) return; + if (data.schema !== 1 || !data.features || !data.readiness) throw new Error('Invalid report'); + report = JSON.stringify(data, null, 2); + content.textContent = report; preview.hidden = false; + copy.disabled = download.disabled = false; status.textContent = t('ready'); + content.focus({preventScroll: true}); + body.scrollTop = preview.offsetTop - body.offsetTop; + } catch { + if (dialog.open) status.textContent = t('failed'); + } finally { + clearTimeout(timeout); controller = null; + generate.disabled = area.disabled = reference.disabled = false; + } + }); + copy.addEventListener('click', async () => { + if (!report) return; + try { + if (!navigator.clipboard?.writeText) throw new Error('Clipboard unavailable'); + await navigator.clipboard.writeText(report + '\n'); status.textContent = t('copied'); + } catch { status.textContent = t('copyFailed'); } + }); + download.addEventListener('click', () => { + if (!report) return; + const url = URL.createObjectURL(new Blob([report + '\n'], {type: 'application/json;charset=utf-8'})); + const link = node('a'); link.href = url; link.download = 'webssh-support-report.json'; + document.body.append(link); link.click(); link.remove(); + setTimeout(() => URL.revokeObjectURL(url), 1000); + }); + dialog.addEventListener('close', () => { + controller?.abort(); report = ''; dialog.remove(); trigger.focus(); + }, {once: true}); + document.body.append(dialog); dialog.showModal(); area.focus(); + } + document.querySelectorAll('[data-support-report]').forEach(button => button.addEventListener('click', openReport)); +}()); diff --git a/static/js/terminal-manager.js b/static/js/terminal-manager.js index 7452780f..63f3a4a1 100644 --- a/static/js/terminal-manager.js +++ b/static/js/terminal-manager.js @@ -553,6 +553,7 @@ const TerminalManager = { } terminal.open(container); + window.TerminalPasteGuard?.attach(sessionId, terminal); this.compositionDisposers[key]?.(); this.compositionDisposers[key] = this.setupAndroidCompositionGuard(terminal); diff --git a/static/js/terminal-paste-guard.js b/static/js/terminal-paste-guard.js new file mode 100644 index 00000000..7bc72862 --- /dev/null +++ b/static/js/terminal-paste-guard.js @@ -0,0 +1,152 @@ +/* Session-bound paste review. Native clipboard events are intercepted before xterm. */ +(function (root) { + 'use strict'; + const originalPastes = new WeakMap(); + let observedSocket = null; + let transportRevision = 0; + let activeRevision = 0; + let previousActive = null; + let pending = null; + const byId = id => document.getElementById(id); + const t = (key, fallback) => root.i18n?.t(`terminalSafety.${key}`, fallback) || fallback; + const manager = () => typeof SessionManager !== 'undefined' ? SessionManager : null; + const label = session => `${session.username || '?'}@${session.host || '?'}:${session.port || 22}`; + const needsReview = text => /[\x00-\x1f\x7f-\x9f\u061c\u200e\u200f\u2028\u2029\u202a-\u202e\u2066-\u2069]/.test(text); + const trimTrailingNewlines = text => text.replace(/[\r\n]+$/, ''); + function visibleText(text) { + return text.replace(/[\x00-\x1f\x7f-\x9f\u061c\u200e\u200f\u2028\u2029\u202a-\u202e\u2066-\u2069]/g, char => { + const names = {'\r': 'CR', '\n': 'LF', '\t': 'TAB', '\x1b': 'ESC', '\x7f': 'DEL'}; + return `[${names[char] || `U+${char.charCodeAt(0).toString(16).toUpperCase().padStart(4, '0')}`}]${char === '\n' || char === '\r' ? '\n' : ''}`; + }); + } + function notifyChanged(message) { root.showNotification?.(message || t('changed', 'Session changed. Nothing was pasted; try again in the intended session.'), 'warning'); } + function cancel(changed = false) { + const request = pending; + pending = null; + if (!request) return; + byId('terminalPasteDialog')?.close(); + byId('terminalPastePreview').textContent = ''; + request.resolve(false); + if (changed) notifyChanged(request.changedMessage); + } + function disconnected() { + transportRevision += 1; + cancel(true); + root.BroadcastInput?.updateCount(); + } + function observeSocket() { + if (observedSocket === root.socket) return; + observedSocket?.off?.('disconnect', disconnected); + observedSocket = root.socket; + transportRevision += 1; + observedSocket?.on?.('disconnect', disconnected); + } + function syncActive() { + const active = manager()?.getActiveSession?.() || null; + if (previousActive !== active) { + previousActive = active; + activeRevision += 1; + } + } + function capture(sessionId, terminal = null, requireActive = true) { + observeSocket(); + syncActive(); + const session = manager()?.getSession(sessionId); + if (!session?.connected || root.socket?.connected !== true) return null; + if (requireActive && previousActive !== sessionId) return null; + return {id: sessionId, session, terminal, endpoint: label(session), + generation: session.inputGeneration || 0, socket: root.socket, socketId: root.socket.id, + transportRevision, activeRevision: requireActive ? activeRevision : null}; + } + function valid(binding) { + observeSocket(); + syncActive(); + return Boolean(binding && manager()?.getSession(binding.id) === binding.session + && binding.session.connected && label(binding.session) === binding.endpoint + && (binding.session.inputGeneration || 0) === binding.generation + && root.socket === binding.socket && root.socket?.connected === true + && root.socket.id === binding.socketId && transportRevision === binding.transportRevision + && (!binding.terminal || typeof TerminalManager === 'undefined' + || Object.values(TerminalManager.terminals).includes(binding.terminal)) + && (binding.activeRevision === null || (previousActive === binding.id && activeRevision === binding.activeRevision))); + } + function onWorkspaceChange() { + syncActive(); + if (pending && !pending.isCurrent()) cancel(true); + } + function review(binding, text, send, options = {}) { + if (typeof text !== 'string' || !text) return Promise.resolve(false); + const bindings = Array.isArray(binding) ? binding.slice() : [binding]; + const isCurrent = () => bindings.length > 0 && bindings.every(valid) + && (!options.isCurrent || options.isCurrent()); + if (!isCurrent()) { notifyChanged(options.changedMessage); return Promise.resolve(false); } + cancel(); + if (!isCurrent()) { notifyChanged(options.changedMessage); return Promise.resolve(false); } + if (!needsReview(text)) return Promise.resolve(send(text)); + const dialog = byId('terminalPasteDialog'); + if (!dialog) return Promise.resolve(false); + byId('terminalPasteTarget').textContent = bindings.map(target => `${target.endpoint} · ${target.id}`).join('\n'); + for (const [id, key] of [['terminalPasteTitle', options.titleKey || 'pasteTitle'], + ['terminalPasteWarning', options.warningKey || 'pasteWarning'], + ['terminalPasteConfirm', options.confirmKey || 'pasteConfirm']]) { + const element = byId(id); + element.dataset.i18n = `terminalSafety.${key}`; + element.textContent = t(key); + } + const trim = byId('terminalPasteTrim'); + trim.checked = false; + trim.disabled = trimTrailingNewlines(text) === text; + byId('terminalPastePreview').textContent = visibleText(text); + return new Promise(resolve => { + pending = {isCurrent, changedMessage: options.changedMessage, text, send, resolve}; + dialog.showModal(); + byId('terminalPasteCancel').focus(); + }); + } + function paste(binding, text) { + return review(binding, text, value => { + if (!valid(binding)) return false; + const original = binding.terminal && originalPastes.get(binding.terminal); + if (original) { original(value); return true; } + if (binding.terminal?.paste) { binding.terminal.paste(value); return true; } + return root.SSHInput?.sendText(binding.id, value) || false; + }); + } + function attach(sessionId, terminal) { + if (originalPastes.has(terminal)) return; + originalPastes.set(terminal, terminal.paste.bind(terminal)); + terminal.paste = text => paste(capture(sessionId, terminal), text); + terminal.element?.addEventListener('paste', event => { + event.preventDefault(); + event.stopImmediatePropagation(); + paste(capture(sessionId, terminal), event.clipboardData?.getData('text/plain') || ''); + }, true); + } + root.TerminalPasteGuard = {capture, valid, label, needsReview, visibleText, trimTrailingNewlines, paste, review, attach, cancel, onWorkspaceChange}; + document.addEventListener('DOMContentLoaded', () => { + const dialog = byId('terminalPasteDialog'); + dialog?.addEventListener('keydown', event => event.stopPropagation()); + dialog?.addEventListener('cancel', () => cancel()); + dialog?.addEventListener('close', () => { if (!dialog.open && pending) cancel(); }); + byId('terminalPasteCancel')?.addEventListener('click', () => cancel()); + byId('terminalPasteTrim')?.addEventListener('change', event => { + if (pending) byId('terminalPastePreview').textContent = visibleText(event.target.checked ? trimTrailingNewlines(pending.text) : pending.text); + }); + byId('terminalPasteConfirm')?.addEventListener('click', async () => { + const request = pending; + if (!request) return; + if (!request.isCurrent()) { cancel(true); return; } + const text = byId('terminalPasteTrim').checked ? trimTrailingNewlines(request.text) : request.text; + pending = null; + dialog.close(); + byId('terminalPastePreview').textContent = ''; + if (!request.isCurrent()) { + request.resolve(false); + notifyChanged(request.changedMessage); + return; + } + try { request.resolve(text ? await request.send(text) : false); } + catch { request.resolve(false); } + }); + }); +}(window)); diff --git a/static/js/terminal-safety-i18n.js b/static/js/terminal-safety-i18n.js new file mode 100644 index 00000000..1ca79b66 --- /dev/null +++ b/static/js/terminal-safety-i18n.js @@ -0,0 +1,132 @@ +(function () { + 'use strict'; + const bundles = { + "en": { + "broadcast.toggle": "Broadcast to selected sessions", + "terminalSafety.broadcastPlaceholder": "Command for the selected sessions", + "terminalSafety.recipients": "Recipients", + "terminalSafety.sendSelected": "Send to selected", + "terminalSafety.recipientsTitle": "Choose broadcast recipients", + "terminalSafety.broadcastReviewTitle": "Review broadcast input", + "terminalSafety.broadcastReviewWarning": "This input contains line breaks or control characters and can execute commands in every listed session. Review the full text. Sending appends Enter, even if you remove trailing line breaks.", + "terminalSafety.frozen": "Only the selected sessions receive input. New sessions are not added.", + "terminalSafety.useSelection": "Use selection", + "terminalSafety.noRecipients": "No connected sessions.", + "terminalSafety.chooseFirst": "Choose recipients before sending.", + "terminalSafety.broadcastChanged": "Recipients changed. Choose the connected sessions again before sending.", + "terminalSafety.partial": "Some recipients did not accept the input. Check each session before retrying; the command may already have run.", + "terminalSafety.notSent": "Input was not sent. Check the selected recipients and connection.", + "terminalSafety.pasteTitle": "Review terminal paste", + "terminalSafety.pasteWarning": "This text contains line breaks or control characters. Pasting can execute commands or change terminal behavior. Review the full text before sending.", + "terminalSafety.trim": "Remove trailing line breaks", + "terminalSafety.pasteConfirm": "Paste to this terminal", + "terminalSafety.changed": "Session changed. Nothing was pasted; try again in the intended session." + }, + "de": { + "broadcast.toggle": "An ausgewählte Sitzungen senden", + "terminalSafety.broadcastPlaceholder": "Befehl für die ausgewählten Sitzungen", + "terminalSafety.recipients": "Empfänger", + "terminalSafety.sendSelected": "An Auswahl senden", + "terminalSafety.recipientsTitle": "Broadcast-Empfänger auswählen", + "terminalSafety.broadcastReviewTitle": "Broadcast-Eingabe prüfen", + "terminalSafety.broadcastReviewWarning": "Diese Eingabe enthält Zeilenumbrüche oder Steuerzeichen und kann in jeder aufgeführten Sitzung Befehle ausführen. Den vollständigen Text prüfen. Senden fügt Enter an, auch wenn abschließende Zeilenumbrüche entfernt werden.", + "terminalSafety.frozen": "Nur ausgewählte Sitzungen erhalten die Eingabe. Neue Sitzungen werden nicht hinzugefügt.", + "terminalSafety.useSelection": "Auswahl übernehmen", + "terminalSafety.noRecipients": "Keine verbundenen Sitzungen.", + "terminalSafety.chooseFirst": "Vor dem Senden Empfänger auswählen.", + "terminalSafety.broadcastChanged": "Empfänger haben sich geändert. Vor dem Senden die verbundenen Sitzungen erneut auswählen.", + "terminalSafety.partial": "Einige Empfänger haben die Eingabe nicht angenommen. Vor einem erneuten Versuch jede Sitzung prüfen; der Befehl könnte bereits ausgeführt worden sein.", + "terminalSafety.notSent": "Eingabe wurde nicht gesendet. Ausgewählte Empfänger und Verbindung prüfen.", + "terminalSafety.pasteTitle": "Terminal-Eingabe prüfen", + "terminalSafety.pasteWarning": "Dieser Text enthält Zeilenumbrüche oder Steuerzeichen. Einfügen kann Befehle ausführen oder das Terminalverhalten ändern. Vor dem Senden den vollständigen Text prüfen.", + "terminalSafety.trim": "Abschließende Zeilenumbrüche entfernen", + "terminalSafety.pasteConfirm": "In dieses Terminal einfügen", + "terminalSafety.changed": "Sitzung geändert. Es wurde nichts eingefügt. Im gewünschten Terminal erneut versuchen." + }, + "fr": { + "broadcast.toggle": "Diffuser aux sessions sélectionnées", + "terminalSafety.broadcastPlaceholder": "Commande pour les sessions sélectionnées", + "terminalSafety.recipients": "Destinataires", + "terminalSafety.sendSelected": "Envoyer à la sélection", + "terminalSafety.recipientsTitle": "Choisir les destinataires", + "terminalSafety.broadcastReviewTitle": "Vérifier la saisie à diffuser", + "terminalSafety.broadcastReviewWarning": "Cette saisie contient des sauts de ligne ou des caractères de contrôle et peut exécuter des commandes dans chaque session affichée. Vérifiez tout le texte. L’envoi ajoute Entrée, même si vous supprimez les sauts de ligne finaux.", + "terminalSafety.frozen": "Seules les sessions sélectionnées reçoivent la saisie. Les nouvelles sessions ne sont pas ajoutées.", + "terminalSafety.useSelection": "Utiliser la sélection", + "terminalSafety.noRecipients": "Aucune session connectée.", + "terminalSafety.chooseFirst": "Choisissez les destinataires avant l’envoi.", + "terminalSafety.broadcastChanged": "Les destinataires ont changé. Sélectionnez à nouveau les sessions connectées avant l’envoi.", + "terminalSafety.partial": "Certains destinataires ont refusé la saisie. Vérifiez chaque session avant de réessayer ; la commande a peut-être déjà été exécutée.", + "terminalSafety.notSent": "La saisie n’a pas été envoyée. Vérifiez les destinataires et la connexion.", + "terminalSafety.pasteTitle": "Vérifier le texte à coller", + "terminalSafety.pasteWarning": "Ce texte contient des sauts de ligne ou des caractères de contrôle. Le coller peut exécuter des commandes ou modifier le terminal. Vérifiez tout le texte avant l’envoi.", + "terminalSafety.trim": "Supprimer les sauts de ligne finaux", + "terminalSafety.pasteConfirm": "Coller dans ce terminal", + "terminalSafety.changed": "La session a changé. Rien n’a été collé ; réessayez dans la session souhaitée." + }, + "es": { + "broadcast.toggle": "Enviar a las sesiones seleccionadas", + "terminalSafety.broadcastPlaceholder": "Comando para las sesiones seleccionadas", + "terminalSafety.recipients": "Destinatarios", + "terminalSafety.sendSelected": "Enviar a la selección", + "terminalSafety.recipientsTitle": "Elegir destinatarios", + "terminalSafety.broadcastReviewTitle": "Revisar entrada de difusión", + "terminalSafety.broadcastReviewWarning": "La entrada contiene saltos de línea o caracteres de control y puede ejecutar comandos en cada sesión indicada. Revise todo el texto. El envío añade Intro, aunque elimine los saltos de línea finales.", + "terminalSafety.frozen": "Solo las sesiones seleccionadas reciben la entrada. No se añaden sesiones nuevas.", + "terminalSafety.useSelection": "Usar selección", + "terminalSafety.noRecipients": "No hay sesiones conectadas.", + "terminalSafety.chooseFirst": "Elija destinatarios antes de enviar.", + "terminalSafety.broadcastChanged": "Los destinatarios han cambiado. Seleccione de nuevo las sesiones conectadas antes de enviar.", + "terminalSafety.partial": "Algunos destinatarios no aceptaron la entrada. Revise cada sesión antes de reintentar; el comando podría haberse ejecutado ya.", + "terminalSafety.notSent": "No se envió la entrada. Revise los destinatarios y la conexión.", + "terminalSafety.pasteTitle": "Revisar texto para el terminal", + "terminalSafety.pasteWarning": "Este texto contiene saltos de línea o caracteres de control. Pegarlo puede ejecutar comandos o alterar el terminal. Revise el texto completo antes de enviar.", + "terminalSafety.trim": "Eliminar saltos de línea finales", + "terminalSafety.pasteConfirm": "Pegar en este terminal", + "terminalSafety.changed": "La sesión ha cambiado. No se pegó nada; inténtelo en la sesión deseada." + }, + "vi": { + "broadcast.toggle": "Gửi đến các phiên đã chọn", + "terminalSafety.broadcastPlaceholder": "Lệnh cho các phiên đã chọn", + "terminalSafety.recipients": "Phiên nhận", + "terminalSafety.sendSelected": "Gửi đến phiên đã chọn", + "terminalSafety.recipientsTitle": "Chọn các phiên nhận", + "terminalSafety.broadcastReviewTitle": "Kiểm tra dữ liệu gửi nhiều phiên", + "terminalSafety.broadcastReviewWarning": "Dữ liệu có xuống dòng hoặc ký tự điều khiển và có thể chạy lệnh trong mọi phiên được liệt kê. Kiểm tra toàn bộ văn bản. Khi gửi sẽ thêm Enter, kể cả khi bỏ xuống dòng cuối.", + "terminalSafety.frozen": "Chỉ các phiên đã chọn nhận dữ liệu. Phiên mới không được tự thêm.", + "terminalSafety.useSelection": "Dùng lựa chọn", + "terminalSafety.noRecipients": "Không có phiên đang kết nối.", + "terminalSafety.chooseFirst": "Chọn phiên nhận trước khi gửi.", + "terminalSafety.broadcastChanged": "Các phiên nhận đã thay đổi. Hãy chọn lại các phiên đang kết nối trước khi gửi.", + "terminalSafety.partial": "Một số phiên không nhận dữ liệu. Kiểm tra từng phiên trước khi thử lại; lệnh có thể đã chạy.", + "terminalSafety.notSent": "Chưa gửi dữ liệu. Kiểm tra các phiên đã chọn và kết nối.", + "terminalSafety.pasteTitle": "Kiểm tra nội dung dán", + "terminalSafety.pasteWarning": "Văn bản có xuống dòng hoặc ký tự điều khiển. Dán có thể chạy lệnh hoặc thay đổi hoạt động của terminal. Kiểm tra toàn bộ văn bản trước khi gửi.", + "terminalSafety.trim": "Bỏ các dòng mới ở cuối", + "terminalSafety.pasteConfirm": "Dán vào terminal này", + "terminalSafety.changed": "Phiên đã thay đổi. Chưa dán nội dung; hãy thử lại trong phiên mong muốn." + }, + "zh": { + "broadcast.toggle": "向所选会话广播", + "terminalSafety.broadcastPlaceholder": "发送给所选会话的命令", + "terminalSafety.recipients": "接收会话", + "terminalSafety.sendSelected": "发送给所选会话", + "terminalSafety.recipientsTitle": "选择广播接收会话", + "terminalSafety.broadcastReviewTitle": "检查广播输入", + "terminalSafety.broadcastReviewWarning": "输入包含换行或控制字符,可能在每个列出的会话中执行命令。请检查完整文本。发送会追加 Enter,即使删除了末尾换行。", + "terminalSafety.frozen": "只有所选会话会收到输入。新会话不会自动加入。", + "terminalSafety.useSelection": "使用所选会话", + "terminalSafety.noRecipients": "没有已连接的会话。", + "terminalSafety.chooseFirst": "发送前请选择接收会话。", + "terminalSafety.broadcastChanged": "接收会话已更改。发送前请重新选择已连接的会话。", + "terminalSafety.partial": "部分会话未接受输入。重试前请检查每个会话;命令可能已经执行。", + "terminalSafety.notSent": "输入未发送。请检查所选会话和连接。", + "terminalSafety.pasteTitle": "检查终端粘贴内容", + "terminalSafety.pasteWarning": "此文本包含换行或控制字符。粘贴可能执行命令或改变终端行为。发送前请检查完整文本。", + "terminalSafety.trim": "删除末尾换行", + "terminalSafety.pasteConfirm": "粘贴到此终端", + "terminalSafety.changed": "会话已更改。没有粘贴任何内容;请在目标会话中重试。" + } + }; + for (const [language, values] of Object.entries(bundles)) window.i18n.addTranslations(language, values); +}()); diff --git a/static/js/trust-files-i18n.js b/static/js/trust-files-i18n.js new file mode 100644 index 00000000..80214565 --- /dev/null +++ b/static/js/trust-files-i18n.js @@ -0,0 +1,204 @@ +(function () { + 'use strict'; + const bundles = { + "en": { + "trustFiles.filter": "Show", + "trustFiles.all": "All · active and failed first", + "trustFiles.active": "Active", + "trustFiles.errors": "Failed / unconfirmed", + "trustFiles.history": "Finished", + "trustFiles.previous": "Previous", + "trustFiles.next": "Next", + "trustFiles.browser": "This browser", + "trustFiles.remote": "Remote source", + "trustFiles.assignmentReady": "Ready", + "trustFiles.assignmentInvalid": "Review required · saved password cannot be used for this destination", + "trustFiles.assignmentsInvalid": "assignments need review", + "trustFiles.reviewAssignment": "Review and authorize again", + "trustFiles.loading": "Loading effective trust…", + "trustFiles.loadFailed": "Effective trust could not be loaded. Refresh to try again.", + "trustFiles.effectiveTitle": "Effective trust for saved SSH targets", + "trustFiles.effectiveHint": "Configuration only; no connection is made. Personal keys take precedence per algorithm. Revocations apply across both sources.", + "trustFiles.search": "Search saved targets", + "trustFiles.noTrust": "No effective trusted key. A connection still requires host-key verification.", + "trustFiles.personal": "Personal", + "trustFiles.global": "Global", + "trustFiles.effective": "Effective", + "trustFiles.overridden": "Overridden", + "trustFiles.revoked": "Revoked", + "trustFiles.unknown": "Unknown", + "trustFiles.afterDelete": "After deleting this personal entry", + "trustFiles.deleteTitle": "Review personal host-key deletion", + "trustFiles.noAffectedTargets": "No matching saved SSH targets. Remaining trust cannot be determined for this entry.", + "trustFiles.unresolved": "Other hosts matched by this entry are not saved targets. Their remaining trust is unknown; this includes unmatched aliases, wildcard matches and hashed hosts.", + "trustFiles.noTargets": "No matching saved SSH targets. Personal entries remain manageable below.", + "trustFiles.deleteHint": "Deleting a personal entry does not revoke the server key. Other personal or global trust may still apply; removing a revocation may allow a key again." + }, + "de": { + "trustFiles.filter": "Anzeigen", + "trustFiles.all": "Alle · aktive und Fehler zuerst", + "trustFiles.active": "Aktiv", + "trustFiles.errors": "Fehler / unbestätigt", + "trustFiles.history": "Beendet", + "trustFiles.previous": "Zurück", + "trustFiles.next": "Weiter", + "trustFiles.browser": "Dieser Browser", + "trustFiles.remote": "Remote-Quelle", + "trustFiles.assignmentReady": "Bereit", + "trustFiles.assignmentInvalid": "Prüfung erforderlich · gespeichertes Passwort für dieses Ziel nicht nutzbar", + "trustFiles.assignmentsInvalid": "Zuweisungen erfordern Prüfung", + "trustFiles.reviewAssignment": "Prüfen und erneut freigeben", + "trustFiles.loading": "Wirksames Vertrauen wird geladen…", + "trustFiles.loadFailed": "Wirksames Vertrauen konnte nicht geladen werden. Bitte aktualisieren.", + "trustFiles.effectiveTitle": "Wirksames Vertrauen für gespeicherte SSH-Ziele", + "trustFiles.effectiveHint": "Nur Konfiguration; es wird keine Verbindung aufgebaut. Persönliche Schlüssel haben je Algorithmus Vorrang. Sperren gelten für beide Quellen.", + "trustFiles.search": "Gespeicherte Ziele suchen", + "trustFiles.noTrust": "Kein wirksamer vertrauenswürdiger Schlüssel. Eine Verbindung erfordert weiterhin eine Host-Key-Prüfung.", + "trustFiles.personal": "Persönlich", + "trustFiles.global": "Global", + "trustFiles.effective": "Wirksam", + "trustFiles.overridden": "Überlagert", + "trustFiles.revoked": "Gesperrt", + "trustFiles.unknown": "Unbekannt", + "trustFiles.afterDelete": "Nach dem Löschen dieses persönlichen Eintrags", + "trustFiles.deleteTitle": "Persönlichen Host-Key-Eintrag löschen: Prüfung", + "trustFiles.noAffectedTargets": "Keine passenden gespeicherten SSH-Ziele. Verbleibendes Vertrauen ist für diesen Eintrag nicht ermittelbar.", + "trustFiles.unresolved": "Weitere von diesem Eintrag erfasste Hosts sind keine gespeicherten Ziele. Ihr verbleibendes Vertrauen ist unbekannt; dazu gehören nicht zugeordnete Aliase, Wildcards und gehashte Hosts.", + "trustFiles.noTargets": "Keine passenden gespeicherten SSH-Ziele. Persönliche Einträge bleiben unten verwaltbar.", + "trustFiles.deleteHint": "Das Löschen eines persönlichen Eintrags sperrt den Serverschlüssel nicht. Andere persönliche oder globale Vertrauenseinträge können weiter gelten; das Entfernen einer Sperre kann einen Schlüssel wieder zulassen." + }, + "fr": { + "trustFiles.filter": "Afficher", + "trustFiles.all": "Tous · actifs et échecs en premier", + "trustFiles.active": "Actifs", + "trustFiles.errors": "Échecs / non confirmés", + "trustFiles.history": "Terminés", + "trustFiles.previous": "Précédent", + "trustFiles.next": "Suivant", + "trustFiles.browser": "Ce navigateur", + "trustFiles.remote": "Source distante", + "trustFiles.assignmentReady": "Prêt", + "trustFiles.assignmentInvalid": "Vérification requise · mot de passe inutilisable pour cette destination", + "trustFiles.assignmentsInvalid": "affectations à vérifier", + "trustFiles.reviewAssignment": "Vérifier et autoriser à nouveau", + "trustFiles.loading": "Chargement de la confiance effective…", + "trustFiles.loadFailed": "Impossible de charger la confiance effective. Actualisez pour réessayer.", + "trustFiles.effectiveTitle": "Confiance effective des destinations SSH enregistrées", + "trustFiles.effectiveHint": "Configuration uniquement, sans connexion. Les clés personnelles priment par algorithme. Les révocations valent pour les deux sources.", + "trustFiles.search": "Rechercher des destinations", + "trustFiles.noTrust": "Aucune clé de confiance effective. La connexion exige une vérification de clé hôte.", + "trustFiles.personal": "Personnel", + "trustFiles.global": "Global", + "trustFiles.effective": "Effectif", + "trustFiles.overridden": "Supplanté", + "trustFiles.revoked": "Révoqué", + "trustFiles.unknown": "Inconnu", + "trustFiles.afterDelete": "Après suppression de cette entrée personnelle", + "trustFiles.deleteTitle": "Vérifier la suppression de la clé personnelle", + "trustFiles.noAffectedTargets": "Aucune destination SSH enregistrée correspondante. La confiance restante est indéterminée.", + "trustFiles.unresolved": "Les autres hôtes couverts ne sont pas des destinations enregistrées. Leur confiance restante est inconnue, y compris les alias, jokers et hôtes hachés non associés.", + "trustFiles.noTargets": "Aucune destination SSH correspondante. Les entrées personnelles restent gérables ci-dessous.", + "trustFiles.deleteHint": "Supprimer une entrée personnelle ne révoque pas la clé serveur. Une autre confiance personnelle ou globale peut subsister ; retirer une révocation peut autoriser une clé à nouveau." + }, + "es": { + "trustFiles.filter": "Mostrar", + "trustFiles.all": "Todos · activos y fallidos primero", + "trustFiles.active": "Activos", + "trustFiles.errors": "Fallidos / sin confirmar", + "trustFiles.history": "Finalizados", + "trustFiles.previous": "Anterior", + "trustFiles.next": "Siguiente", + "trustFiles.browser": "Este navegador", + "trustFiles.remote": "Origen remoto", + "trustFiles.assignmentReady": "Listo", + "trustFiles.assignmentInvalid": "Revisión necesaria · contraseña no utilizable para este destino", + "trustFiles.assignmentsInvalid": "asignaciones requieren revisión", + "trustFiles.reviewAssignment": "Revisar y autorizar de nuevo", + "trustFiles.loading": "Cargando confianza efectiva…", + "trustFiles.loadFailed": "No se pudo cargar la confianza efectiva. Actualice para reintentar.", + "trustFiles.effectiveTitle": "Confianza efectiva de destinos SSH guardados", + "trustFiles.effectiveHint": "Solo configuración, sin conexión. Las claves personales tienen prioridad por algoritmo. Las revocaciones se aplican a ambos orígenes.", + "trustFiles.search": "Buscar destinos guardados", + "trustFiles.noTrust": "Ninguna clave de confianza efectiva. La conexión requiere verificar la clave del host.", + "trustFiles.personal": "Personal", + "trustFiles.global": "Global", + "trustFiles.effective": "Efectiva", + "trustFiles.overridden": "Reemplazada", + "trustFiles.revoked": "Revocada", + "trustFiles.unknown": "Desconocida", + "trustFiles.afterDelete": "Tras eliminar esta entrada personal", + "trustFiles.deleteTitle": "Revisar la eliminación de la clave personal", + "trustFiles.noAffectedTargets": "Sin destinos SSH guardados coincidentes. No se puede determinar la confianza restante.", + "trustFiles.unresolved": "Otros hosts cubiertos no son destinos guardados. Su confianza restante es desconocida, incluidos alias, comodines y hosts cifrados sin correspondencia.", + "trustFiles.noTargets": "Sin destinos SSH coincidentes. Las entradas personales se gestionan abajo.", + "trustFiles.deleteHint": "Eliminar una entrada personal no revoca la clave del servidor. Otra confianza personal o global puede seguir vigente; quitar una revocación puede permitir una clave de nuevo." + }, + "vi": { + "trustFiles.filter": "Hiển thị", + "trustFiles.all": "Tất cả · đang chạy và lỗi trước", + "trustFiles.active": "Đang chạy", + "trustFiles.errors": "Lỗi / chưa xác nhận", + "trustFiles.history": "Đã kết thúc", + "trustFiles.previous": "Trước", + "trustFiles.next": "Tiếp", + "trustFiles.browser": "Trình duyệt này", + "trustFiles.remote": "Nguồn từ xa", + "trustFiles.assignmentReady": "Sẵn sàng", + "trustFiles.assignmentInvalid": "Cần xem lại · không thể dùng mật khẩu đã lưu cho đích này", + "trustFiles.assignmentsInvalid": "gán cần xem lại", + "trustFiles.reviewAssignment": "Xem lại và cấp phép lại", + "trustFiles.loading": "Đang tải tin cậy có hiệu lực…", + "trustFiles.loadFailed": "Không thể tải tin cậy có hiệu lực. Hãy làm mới.", + "trustFiles.effectiveTitle": "Tin cậy có hiệu lực cho đích SSH đã lưu", + "trustFiles.effectiveHint": "Chỉ là cấu hình; không kết nối. Khóa cá nhân được ưu tiên theo thuật toán. Thu hồi áp dụng cho cả hai nguồn.", + "trustFiles.search": "Tìm đích đã lưu", + "trustFiles.noTrust": "Không có khóa tin cậy có hiệu lực. Kết nối vẫn cần xác minh khóa máy chủ.", + "trustFiles.personal": "Cá nhân", + "trustFiles.global": "Toàn cục", + "trustFiles.effective": "Có hiệu lực", + "trustFiles.overridden": "Bị ghi đè", + "trustFiles.revoked": "Đã thu hồi", + "trustFiles.unknown": "Không rõ", + "trustFiles.afterDelete": "Sau khi xóa mục cá nhân này", + "trustFiles.deleteTitle": "Xem lại việc xóa khóa máy chủ cá nhân", + "trustFiles.noAffectedTargets": "Không có đích SSH đã lưu phù hợp. Không xác định được tin cậy còn lại.", + "trustFiles.unresolved": "Các máy chủ khác được mục này bao phủ không phải đích đã lưu. Không rõ tin cậy còn lại của bí danh, ký tự đại diện và máy chủ băm chưa khớp.", + "trustFiles.noTargets": "Không có đích SSH phù hợp. Vẫn có thể quản lý mục cá nhân bên dưới.", + "trustFiles.deleteHint": "Xóa mục cá nhân không thu hồi khóa máy chủ. Tin cậy cá nhân hoặc toàn cục khác vẫn có thể áp dụng; bỏ thu hồi có thể cho phép khóa trở lại." + }, + "zh": { + "trustFiles.filter": "显示", + "trustFiles.all": "全部 · 进行中和失败优先", + "trustFiles.active": "进行中", + "trustFiles.errors": "失败 / 未确认", + "trustFiles.history": "已结束", + "trustFiles.previous": "上一页", + "trustFiles.next": "下一页", + "trustFiles.browser": "此浏览器", + "trustFiles.remote": "远程来源", + "trustFiles.assignmentReady": "就绪", + "trustFiles.assignmentInvalid": "需要检查 · 已保存密码无法用于此目标", + "trustFiles.assignmentsInvalid": "项分配需要检查", + "trustFiles.reviewAssignment": "检查并重新授权", + "trustFiles.loading": "正在加载有效信任…", + "trustFiles.loadFailed": "无法加载有效信任。请刷新重试。", + "trustFiles.effectiveTitle": "已保存 SSH 目标的有效信任", + "trustFiles.effectiveHint": "仅显示配置,不建立连接。个人密钥按算法优先,撤销对两个来源均有效。", + "trustFiles.search": "搜索已保存目标", + "trustFiles.noTrust": "无有效受信任密钥。连接仍需验证主机密钥。", + "trustFiles.personal": "个人", + "trustFiles.global": "全局", + "trustFiles.effective": "有效", + "trustFiles.overridden": "被覆盖", + "trustFiles.revoked": "已撤销", + "trustFiles.unknown": "未知", + "trustFiles.afterDelete": "删除此个人条目后", + "trustFiles.deleteTitle": "检查个人主机密钥删除", + "trustFiles.noAffectedTargets": "无匹配的已保存 SSH 目标。无法确定此条目的剩余信任。", + "trustFiles.unresolved": "此条目匹配的其他主机不是已保存目标。其剩余信任未知,包括未匹配的别名、通配符和哈希主机。", + "trustFiles.noTargets": "无匹配的已保存 SSH 目标。仍可在下方管理个人条目。", + "trustFiles.deleteHint": "删除个人条目不会撤销服务器密钥。其他个人或全局信任可能仍然有效;删除撤销条目可能重新允许该密钥。" + } + }; + for (const [language, values] of Object.entries(bundles)) window.i18n.addTranslations(language, values); +}()); diff --git a/static/js/webauthn.js b/static/js/webauthn.js index 35cc6796..2b06b39f 100644 --- a/static/js/webauthn.js +++ b/static/js/webauthn.js @@ -291,6 +291,7 @@ const body = document.getElementById('hostKeyList'); if (!body) { return; } const data = await api('/api/host-keys'); + window.EffectiveHostTrust?.load(api); body.replaceChildren(); for (const entry of data.entries || []) { const row = document.createElement('tr'); @@ -312,15 +313,19 @@ button.className = 'btn btn-danger'; button.textContent = presentation.action; button.addEventListener('click', async () => { - if (!window.confirm( - window.WebSSHSecurityUI.hostKeyConfirmation( - entry, - hostLabel, - t - ) - )) { return; } - await api(`/api/host-keys/${entry.id}`, { method: 'DELETE' }); - await loadHostKeys(); + button.disabled = true; + try { + const impact = await api(`/api/host-keys/${entry.id}/deletion-impact`); + if (!window.EffectiveHostTrust || !impact.revision) throw new Error(t('trustFiles.loadFailed', 'Effective trust could not be loaded. Refresh to try again.')); + if (!await window.EffectiveHostTrust.confirmDeletion(entry, hostLabel, impact)) return; + await api(`/api/host-keys/${entry.id}`, {method: 'DELETE', headers: {'If-Match': impact.revision}}); + await loadHostKeys(); + } catch (error) { + notify(error.message, 'error'); + } finally { + button.disabled = false; + if (button.isConnected) button.focus(); + } }); action.appendChild(button); row.appendChild(action); diff --git a/templates/_overview_workspace.html b/templates/_overview_workspace.html index ef31145d..d44c6378 100644 --- a/templates/_overview_workspace.html +++ b/templates/_overview_workspace.html @@ -26,20 +26,6 @@

Overview

-
-

Continue working

- Choose your start page
-
-
- First connection -

Progress reflects your selected host and this browser's live session. Nothing connects or trusts a key automatically.

-
-
-
    -

    A successful interactive connection does not approve a workflow or schedule. Automation needs its own credentials, host trust and approval.

    -
    -
    -
    diff --git a/templates/admin.html b/templates/admin.html index f63166ee..da282a49 100644 --- a/templates/admin.html +++ b/templates/admin.html @@ -18,6 +18,7 @@ + @@ -361,7 +362,14 @@

    GitHub authentication

    Native backups exclude saved remote passwords and SSH keys, but retain application secrets and account authentication data. Store every downloaded archive encrypted and off-host.

    -

    Backup coverage

    Includes configuration and account authentication data.

    Excludes remote passwords, SSH keys and live sessions. Re-provision credentials and review schedules after restore.

    +
    +
    + Backup coverage +

    Includes configuration, account sign-in data, profiles, commands, workflows and user settings.

    +

    Portable backups exclude saved remote passwords, SSH private keys and live sessions. Re-provision remote credentials after restore.

    +
    + +

    Create backup

    @@ -544,6 +552,8 @@

    Final destru + + diff --git a/templates/index.html b/templates/index.html index 6471e9e1..072b56c5 100644 --- a/templates/index.html +++ b/templates/index.html @@ -32,6 +32,7 @@ + @@ -66,6 +67,7 @@ {% endif %} + @@ -142,7 +144,6 @@ - @@ -165,7 +166,7 @@
    - Layout + Layout
    @@ -178,12 +179,14 @@

    Drag a title bar to an edge to snap; to a corner for a quarter.

    +
    + +
    - @@ -241,12 +244,14 @@ 0 sessions - - + + + +
    @@ -349,7 +354,6 @@

    Notes

    -