diff --git a/backend/app/routers/environments.py b/backend/app/routers/environments.py index b15bada..e01343a 100644 --- a/backend/app/routers/environments.py +++ b/backend/app/routers/environments.py @@ -1,5 +1,6 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Request, status from fastapi.responses import RedirectResponse +import asyncio from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.future import select from sqlalchemy import text @@ -8,7 +9,7 @@ from uuid import UUID from ..database import get_db from ..models import Environment, Setting, WorkerServer -from ..core.security import SecretCipherError, SecretKeyError, encrypt_secret +from ..core.security import SecretCipherError, SecretKeyError, decrypt_secret, encrypt_secret from ..core.worker_registry import ( WORKER_HEALTH_HEALTHY, WorkerRequestError, @@ -20,6 +21,7 @@ CustomPortAllocateResponse, CustomPortMapping, EnvironmentCreate, + EnvironmentRootPasswordResetRequest, EnvironmentResponse, ) from ..tasks import create_environment_task @@ -29,6 +31,7 @@ import time import random import logging +import socket from sqlalchemy.exc import IntegrityError import json from urllib.parse import urlsplit, urlunsplit @@ -54,6 +57,58 @@ BUILD_ERROR_SETTING_PREFIX = "build_error:" +def _write_exec_stdin(sock: object, payload: bytes) -> None: + # docker SDK may return different socket wrappers by version. + if hasattr(sock, "sendall"): + sock.sendall(payload) # type: ignore[attr-defined] + return + + raw_sock = getattr(sock, "_sock", None) or getattr(sock, "sock", None) + if raw_sock is not None and hasattr(raw_sock, "sendall"): + raw_sock.sendall(payload) # type: ignore[attr-defined] + return + + if hasattr(sock, "send"): + total = 0 + while total < len(payload): + sent = sock.send(payload[total:]) # type: ignore[attr-defined] + if not sent: + raise RuntimeError("Failed to write docker exec stdin payload") + total += int(sent) + return + + raise RuntimeError("Docker exec stdin socket does not support send operations") + + +def _close_exec_stdin(sock: object) -> None: + raw_sock = getattr(sock, "_sock", None) or getattr(sock, "sock", None) + if raw_sock is not None and hasattr(raw_sock, "shutdown"): + try: + raw_sock.shutdown(socket.SHUT_WR) # type: ignore[attr-defined] + except OSError: + pass + if hasattr(sock, "close"): + try: + sock.close() # type: ignore[attr-defined] + except OSError: + pass + + +async def _wait_exec_exit_code(docker_api: object, exec_id: str, timeout_seconds: float = 2.0) -> int | None: + deadline = time.time() + timeout_seconds + while True: + info = docker_api.exec_inspect(exec_id) # type: ignore[attr-defined] + exit_code = info.get("ExitCode") + if exit_code is not None: + try: + return int(exit_code) + except (TypeError, ValueError): + return None + if time.time() >= deadline: + return None + await asyncio.sleep(0.05) + + def _extract_dockerfile_base_image(dockerfile_content: str) -> str | None: if not dockerfile_content: return None @@ -1652,3 +1707,202 @@ async def stop_environment(environment_id: str, db: AsyncSession = Depends(get_d env.status = "error" await db.commit() raise HTTPException(status_code=500, detail=str(e)) + + +def _validate_new_root_password(value: str) -> str: + password = str(value or "") + if not password.strip(): + raise HTTPException( + status_code=400, + detail={"code": "invalid_password", "message": "new_password is required"}, + ) + if len(password) < 4: + raise HTTPException( + status_code=400, + detail={"code": "weak_password", "message": "new_password must be at least 4 characters"}, + ) + if "\n" in password or "\r" in password: + raise HTTPException( + status_code=400, + detail={"code": "invalid_password", "message": "new_password cannot contain line breaks"}, + ) + return password + + +@router.post("/{environment_id}/accounts/root/reset-password", status_code=status.HTTP_200_OK) +async def reset_environment_root_password( + environment_id: str, + payload: EnvironmentRootPasswordResetRequest, + db: AsyncSession = Depends(get_db), +): + result = await db.execute(select(Environment).where(Environment.id == environment_id)) + env = result.scalars().first() + if env is None: + raise HTTPException(status_code=404, detail="Environment not found") + + if env.worker_server_id: + worker = await _assert_worker_is_ready(db, env.worker_server_id) + new_password = _validate_new_root_password(payload.new_password) + previous_encrypted_password = env.root_password_encrypted + try: + encrypted_password = encrypt_secret(new_password) + except (SecretKeyError, SecretCipherError) as error: + raise HTTPException( + status_code=500, + detail={"code": "password_encryption_failed", "message": str(error)}, + ) from error + try: + response = await call_worker_api( + worker, + method="POST", + path=f"/api/worker/environments/{env.id}/accounts/root/reset-password", + payload={"new_password": new_password}, + ) + except WorkerRequestError as error: + raise _map_worker_request_error(error) from error + + env.root_password = "__redacted__" + env.root_password_encrypted = encrypted_password + try: + await db.commit() + except Exception as error: + try: + await db.rollback() + except Exception: # noqa: BLE001 + pass + restored = False + if previous_encrypted_password: + try: + previous_password = decrypt_secret(previous_encrypted_password) + await call_worker_api( + worker, + method="POST", + path=f"/api/worker/environments/{env.id}/accounts/root/reset-password", + payload={"new_password": previous_password}, + ) + restored = True + except Exception as rollback_error: # noqa: BLE001 + logger.warning( + "Failed to rollback worker root password change for environment %s after DB commit error: %s", + env.id, + rollback_error, + ) + logger.warning("Failed to persist worker root password metadata for environment %s: %s", env.id, error) + raise HTTPException( + status_code=500, + detail={ + "code": "password_metadata_sync_failed", + "message": ( + "Failed to persist root password metadata" + if restored + else "Failed to persist root password metadata (manual verification required)" + ), + }, + ) from error + return response if isinstance(response, dict) else {"message": "Root password updated"} + + if env.status != "running" and not _is_host_environment_running_now(env): + raise HTTPException( + status_code=409, + detail={"code": "env_not_running", "message": "Environment must be running"}, + ) + + new_password = _validate_new_root_password(payload.new_password) + + previous_encrypted_password = env.root_password_encrypted + try: + encrypted_password = encrypt_secret(new_password) + except (SecretKeyError, SecretCipherError) as error: + raise HTTPException( + status_code=500, + detail={"code": "password_encryption_failed", "message": str(error)}, + ) from error + + container_name = f"lyra-{env.name}-{env.id}" + client = docker.from_env() + try: + container = client.containers.get(container_name) + if container.status != "running": + raise HTTPException( + status_code=409, + detail={"code": "env_not_running", "message": "Environment must be running"}, + ) + + exec_id = client.api.exec_create( + container.id, + cmd=["chpasswd"], + stdin=True, + tty=False, + )["Id"] + sock = client.api.exec_start(exec_id, detach=False, tty=False, socket=True) + try: + _write_exec_stdin(sock, f"root:{new_password}\n".encode("utf-8")) + finally: + _close_exec_stdin(sock) + + exit_code = await _wait_exec_exit_code(client.api, exec_id) + if exit_code is None or exit_code != 0: + raise HTTPException( + status_code=500, + detail={"code": "reset_failed", "message": "Failed to reset root password"}, + ) + except docker.errors.NotFound: + raise HTTPException( + status_code=409, + detail={"code": "container_not_found", "message": "Container not found. Please recreate the environment."}, + ) + except HTTPException: + raise + except Exception as error: + logger.warning("Failed to reset root password for environment %s: %s", env.id, error) + raise HTTPException( + status_code=500, + detail={"code": "reset_failed", "message": "Failed to reset root password"}, + ) from error + + env.root_password = "__redacted__" + env.root_password_encrypted = encrypted_password + try: + await db.commit() + except Exception as error: + try: + await db.rollback() + except Exception: # noqa: BLE001 + pass + # Best-effort compensation: revert container password to previous value when DB sync fails. + restored = False + if previous_encrypted_password: + try: + previous_password = decrypt_secret(previous_encrypted_password) + rollback_exec_id = client.api.exec_create( + container.id, + cmd=["chpasswd"], + stdin=True, + tty=False, + )["Id"] + rollback_sock = client.api.exec_start(rollback_exec_id, detach=False, tty=False, socket=True) + try: + _write_exec_stdin(rollback_sock, f"root:{previous_password}\n".encode("utf-8")) + finally: + _close_exec_stdin(rollback_sock) + rollback_exit = await _wait_exec_exit_code(client.api, rollback_exec_id) + restored = rollback_exit is not None and rollback_exit == 0 + except Exception as rollback_error: # noqa: BLE001 + logger.warning( + "Failed to rollback root password change for environment %s after DB commit error: %s", + env.id, + rollback_error, + ) + logger.warning("Failed to persist root password reset metadata for environment %s: %s", env.id, error) + raise HTTPException( + status_code=500, + detail={ + "code": "password_metadata_sync_failed", + "message": ( + "Failed to persist root password metadata" + if restored + else "Failed to persist root password metadata (manual verification required)" + ), + }, + ) from error + return {"message": "Root password updated"} diff --git a/backend/app/routers/worker_api.py b/backend/app/routers/worker_api.py index eef8871..5c52bb6 100644 --- a/backend/app/routers/worker_api.py +++ b/backend/app/routers/worker_api.py @@ -9,6 +9,7 @@ from ..routers import environments as env_router from ..routers import resources as resource_router from ..schemas import EnvironmentCreate +from ..schemas import EnvironmentRootPasswordResetRequest router = APIRouter( @@ -224,6 +225,22 @@ async def _action() -> dict: ) +@router.post("/environments/{environment_id}/accounts/root/reset-password") +async def worker_reset_root_password( + environment_id: str, + payload: EnvironmentRootPasswordResetRequest, + db: AsyncSession = Depends(get_db), +): + async def _action() -> dict: + return await env_router.reset_environment_root_password(environment_id=environment_id, payload=payload, db=db) + + return await _run_worker_action( + _action, + fallback_code="reset_failed", + success_message="Root password updated", + ) + + @router.post("/environments/{environment_id}/jupyter/launch") async def worker_create_jupyter_launch_url(environment_id: str, db: AsyncSession = Depends(get_db)): result = await db.execute(select(Environment).where(Environment.id == environment_id)) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 5c6dc58..5665338 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -51,6 +51,10 @@ class Config: from_attributes = True +class EnvironmentRootPasswordResetRequest(BaseModel): + new_password: str + + class CustomPortAllocateRequest(BaseModel): count: int = 1 current_ports: List[CustomPortMapping] = [] diff --git a/backend/app/tasks.py b/backend/app/tasks.py index 65adbb7..9e1eec8 100644 --- a/backend/app/tasks.py +++ b/backend/app/tasks.py @@ -172,11 +172,11 @@ def _build_runtime_command(jupyter_mode: Optional[str], enable_jupyter: bool, en if enable_jupyter: if jupyter_mode == "python_module": script_parts.append( - 'exec python3 -m jupyter lab --ip=0.0.0.0 --port=8888 --no-browser --allow-root --ServerApp.token="$JUPYTER_TOKEN" --NotebookApp.token="$JUPYTER_TOKEN"' # noqa: E501 + 'exec python3 -m jupyter lab --ip=0.0.0.0 --port=8888 --no-browser --allow-root --ServerApp.token="$JUPYTER_TOKEN" --NotebookApp.token="$JUPYTER_TOKEN" --ServerApp.terminado_settings="{\'shell_command\': [\'$(command -v zsh || command -v bash || command -v sh || echo /bin/sh)\']}"' # noqa: E501 ) else: script_parts.append( - 'exec jupyter lab --ip=0.0.0.0 --port=8888 --no-browser --allow-root --ServerApp.token="$JUPYTER_TOKEN" --NotebookApp.token="$JUPYTER_TOKEN"' # noqa: E501 + 'exec jupyter lab --ip=0.0.0.0 --port=8888 --no-browser --allow-root --ServerApp.token="$JUPYTER_TOKEN" --NotebookApp.token="$JUPYTER_TOKEN" --ServerApp.terminado_settings="{\'shell_command\': [\'$(command -v zsh || command -v bash || command -v sh || echo /bin/sh)\']}"' # noqa: E501 ) else: script_parts.append("exec tail -f /dev/null") diff --git a/backend/tests/test_environment_root_password_reset.py b/backend/tests/test_environment_root_password_reset.py new file mode 100644 index 0000000..78bddfa --- /dev/null +++ b/backend/tests/test_environment_root_password_reset.py @@ -0,0 +1,572 @@ +import asyncio +import uuid +from types import SimpleNamespace + +import pytest +from fastapi import HTTPException + +from app.core.worker_registry import WorkerRequestError +from app.routers import environments as env_router +from app.schemas import EnvironmentRootPasswordResetRequest + + +class _ScalarResult: + def __init__(self, item): + self._item = item + + def first(self): + return self._item + + +class _ExecuteResult: + def __init__(self, item): + self._item = item + + def scalars(self): + return _ScalarResult(self._item) + + +class _FakeDb: + def __init__(self, env, *, fail_commit: bool = False): + self._env = env + self.commit_called = False + self._fail_commit = fail_commit + self.rollback_called = False + + async def execute(self, _stmt, *_args, **_kwargs): + return _ExecuteResult(self._env) + + async def commit(self): + self.commit_called = True + if self._fail_commit: + raise RuntimeError("commit failed") + + async def rollback(self): + self.rollback_called = True + + +def _env(*, status="running", worker_server_id=None): + return SimpleNamespace( + id=uuid.uuid4(), + name="test-env", + status=status, + worker_server_id=worker_server_id, + root_password="__redacted__", + root_password_encrypted="enc-old", + ) + + +def test_reset_root_password_host_success(monkeypatch): + env = _env(status="running") + db = _FakeDb(env) + + class _Container: + status = "running" + id = "container-123" + + class _Containers: + def get(self, name): + assert name == f"lyra-{env.name}-{env.id}" + return _Container() + + class _Socket: + def __init__(self): + self.payload = b"" + + def sendall(self, data: bytes): + self.payload += data + + def close(self): + return None + + sock = _Socket() + + class _DockerApi: + def exec_create(self, container_id, cmd, stdin, tty): + assert container_id == "container-123" + assert cmd == ["chpasswd"] + assert stdin is True + assert tty is False + return {"Id": "exec-1"} + + def exec_start(self, exec_id, detach, tty, socket): + assert exec_id == "exec-1" + assert detach is False + assert tty is False + assert socket is True + return sock + + def exec_inspect(self, exec_id): + assert exec_id == "exec-1" + return {"ExitCode": 0} + + class _DockerClient: + containers = _Containers() + api = _DockerApi() + + monkeypatch.setattr(env_router.docker, "from_env", lambda: _DockerClient()) + monkeypatch.setattr(env_router, "encrypt_secret", lambda value: f"enc::{value}") + + result = asyncio.run( + env_router.reset_environment_root_password( + str(env.id), + payload=EnvironmentRootPasswordResetRequest(new_password="newpass123"), + db=db, + ) + ) + + assert result["message"] == "Root password updated" + assert db.commit_called is True + assert env.root_password == "__redacted__" + assert env.root_password_encrypted == "enc::newpass123" + assert sock.payload == b"root:newpass123\n" + + +def test_reset_root_password_routes_to_worker(monkeypatch): + env = _env(status="running", worker_server_id=uuid.uuid4()) + db = _FakeDb(env) + worker = SimpleNamespace(id=env.worker_server_id, name="w1", base_url="http://w1:8000") + + async def _fake_assert_worker_ready(_db, worker_server_id): + assert str(worker_server_id) == str(env.worker_server_id) + return worker + + async def _fake_call_worker_api(_worker, *, method, path, payload=None, timeout=None): + assert method == "POST" + assert path.endswith(f"/api/worker/environments/{env.id}/accounts/root/reset-password") + assert payload == {"new_password": "newpass123"} + assert timeout is None + return {"message": "Root password updated"} + + monkeypatch.setattr(env_router, "_assert_worker_is_ready", _fake_assert_worker_ready) + monkeypatch.setattr(env_router, "call_worker_api", _fake_call_worker_api) + monkeypatch.setattr(env_router, "encrypt_secret", lambda value: f"enc::{value}") + + result = asyncio.run( + env_router.reset_environment_root_password( + str(env.id), + payload=EnvironmentRootPasswordResetRequest(new_password="newpass123"), + db=db, + ) + ) + + assert result["message"] == "Root password updated" + assert db.commit_called is True + assert env.root_password == "__redacted__" + assert env.root_password_encrypted == "enc::newpass123" + + +def test_reset_root_password_maps_worker_error(monkeypatch): + env = _env(status="running", worker_server_id=uuid.uuid4()) + db = _FakeDb(env) + worker = SimpleNamespace(id=env.worker_server_id, name="w1", base_url="http://w1:8000") + + async def _fake_assert_worker_ready(_db, _worker_server_id): + return worker + + async def _fake_call_worker_api(_worker, *, method, path, payload=None, timeout=None): + del method, path, payload, timeout + raise WorkerRequestError("worker_unreachable", "Worker request timed out", status_code=503) + + monkeypatch.setattr(env_router, "_assert_worker_is_ready", _fake_assert_worker_ready) + monkeypatch.setattr(env_router, "call_worker_api", _fake_call_worker_api) + monkeypatch.setattr(env_router, "encrypt_secret", lambda value: f"enc::{value}") + + with pytest.raises(HTTPException) as exc: + asyncio.run( + env_router.reset_environment_root_password( + str(env.id), + payload=EnvironmentRootPasswordResetRequest(new_password="newpass123"), + db=db, + ) + ) + + assert exc.value.status_code == 503 + assert exc.value.detail["code"] == "worker_unreachable" + assert db.commit_called is False + + +def test_reset_root_password_maps_worker_auth_error(monkeypatch): + env = _env(status="running", worker_server_id=uuid.uuid4()) + db = _FakeDb(env) + worker = SimpleNamespace(id=env.worker_server_id, name="w1", base_url="http://w1:8000") + + async def _fake_assert_worker_ready(_db, _worker_server_id): + return worker + + async def _fake_call_worker_api(_worker, *, method, path, payload=None, timeout=None): + del method, path, payload, timeout + raise WorkerRequestError("worker_auth_failed", "Worker authentication failed", status_code=401) + + monkeypatch.setattr(env_router, "_assert_worker_is_ready", _fake_assert_worker_ready) + monkeypatch.setattr(env_router, "call_worker_api", _fake_call_worker_api) + monkeypatch.setattr(env_router, "encrypt_secret", lambda value: f"enc::{value}") + + with pytest.raises(HTTPException) as exc: + asyncio.run( + env_router.reset_environment_root_password( + str(env.id), + payload=EnvironmentRootPasswordResetRequest(new_password="newpass123"), + db=db, + ) + ) + + assert exc.value.status_code == 401 + assert exc.value.detail["code"] == "worker_auth_failed" + assert db.commit_called is False + + +def test_reset_root_password_requires_running_environment(monkeypatch): + env = _env(status="stopped") + db = _FakeDb(env) + monkeypatch.setattr(env_router, "_is_host_environment_running_now", lambda _env: False) + + with pytest.raises(HTTPException) as exc: + asyncio.run( + env_router.reset_environment_root_password( + str(env.id), + payload=EnvironmentRootPasswordResetRequest(new_password="newpass123"), + db=db, + ) + ) + + assert exc.value.status_code == 409 + assert exc.value.detail["code"] == "env_not_running" + assert db.commit_called is False + + +def test_reset_root_password_rejects_weak_password(): + env = _env(status="running") + db = _FakeDb(env) + + with pytest.raises(HTTPException) as exc: + asyncio.run( + env_router.reset_environment_root_password( + str(env.id), + payload=EnvironmentRootPasswordResetRequest(new_password="a"), + db=db, + ) + ) + + assert exc.value.status_code == 400 + assert exc.value.detail["code"] == "weak_password" + + +def test_reset_root_password_container_not_found(monkeypatch): + env = _env(status="running") + db = _FakeDb(env) + + class _Containers: + def get(self, _name): + raise env_router.docker.errors.NotFound("missing") + + class _DockerClient: + containers = _Containers() + + monkeypatch.setattr(env_router.docker, "from_env", lambda: _DockerClient()) + monkeypatch.setattr(env_router, "encrypt_secret", lambda value: f"enc::{value}") + + with pytest.raises(HTTPException) as exc: + asyncio.run( + env_router.reset_environment_root_password( + str(env.id), + payload=EnvironmentRootPasswordResetRequest(new_password="newpass123"), + db=db, + ) + ) + + assert exc.value.status_code == 409 + assert exc.value.detail["code"] == "container_not_found" + + +def test_reset_root_password_exec_failure_hides_sensitive_output(monkeypatch): + env = _env(status="running") + db = _FakeDb(env) + + class _Container: + status = "running" + id = "container-123" + + class _Containers: + def get(self, _name): + return _Container() + + class _Socket: + def sendall(self, _data: bytes): + return None + + def close(self): + return None + + class _DockerApi: + def exec_create(self, *_args, **_kwargs): + return {"Id": "exec-1"} + + def exec_start(self, *_args, **_kwargs): + return _Socket() + + def exec_inspect(self, _exec_id): + return {"ExitCode": 1} + + class _DockerClient: + containers = _Containers() + api = _DockerApi() + + monkeypatch.setattr(env_router.docker, "from_env", lambda: _DockerClient()) + monkeypatch.setattr(env_router, "encrypt_secret", lambda value: f"enc::{value}") + + with pytest.raises(HTTPException) as exc: + asyncio.run( + env_router.reset_environment_root_password( + str(env.id), + payload=EnvironmentRootPasswordResetRequest(new_password="newpass123"), + db=db, + ) + ) + + assert exc.value.status_code == 500 + assert exc.value.detail["code"] == "reset_failed" + assert "newpass123" not in str(exc.value.detail) + + +def test_reset_root_password_commit_failure_reports_sync_error(monkeypatch): + env = _env(status="running") + db = _FakeDb(env, fail_commit=True) + + class _Container: + status = "running" + id = "container-123" + + class _Containers: + def get(self, _name): + return _Container() + + class _Socket: + def sendall(self, _data: bytes): + return None + + def close(self): + return None + + class _DockerApi: + def __init__(self): + self._exec_count = 0 + + def exec_create(self, *_args, **_kwargs): + self._exec_count += 1 + return {"Id": f"exec-{self._exec_count}"} + + def exec_start(self, *_args, **_kwargs): + return _Socket() + + def exec_inspect(self, _exec_id): + return {"ExitCode": 0} + + class _DockerClient: + containers = _Containers() + api = _DockerApi() + + monkeypatch.setattr(env_router.docker, "from_env", lambda: _DockerClient()) + monkeypatch.setattr(env_router, "encrypt_secret", lambda value: f"enc::{value}") + monkeypatch.setattr(env_router, "decrypt_secret", lambda _v: "oldpass") + + with pytest.raises(HTTPException) as exc: + asyncio.run( + env_router.reset_environment_root_password( + str(env.id), + payload=EnvironmentRootPasswordResetRequest(new_password="newpass123"), + db=db, + ) + ) + + assert exc.value.status_code == 500 + assert exc.value.detail["code"] == "password_metadata_sync_failed" + assert db.rollback_called is True + + +def test_reset_root_password_worker_commit_failure_attempts_remote_rollback(monkeypatch): + env = _env(status="running", worker_server_id=uuid.uuid4()) + db = _FakeDb(env, fail_commit=True) + worker = SimpleNamespace(id=env.worker_server_id, name="w1", base_url="http://w1:8000") + calls: list[str] = [] + + async def _fake_assert_worker_ready(_db, _worker_server_id): + return worker + + async def _fake_call_worker_api(_worker, *, method, path, payload=None, timeout=None): + del method, timeout + calls.append(payload["new_password"]) + if payload["new_password"] == "newpass123": + return {"message": "Root password updated"} + if payload["new_password"] == "oldpass": + return {"message": "Root password updated"} + raise AssertionError(f"unexpected payload: {payload}") + + monkeypatch.setattr(env_router, "_assert_worker_is_ready", _fake_assert_worker_ready) + monkeypatch.setattr(env_router, "call_worker_api", _fake_call_worker_api) + monkeypatch.setattr(env_router, "encrypt_secret", lambda value: f"enc::{value}") + monkeypatch.setattr(env_router, "decrypt_secret", lambda _v: "oldpass") + + with pytest.raises(HTTPException) as exc: + asyncio.run( + env_router.reset_environment_root_password( + str(env.id), + payload=EnvironmentRootPasswordResetRequest(new_password="newpass123"), + db=db, + ) + ) + + assert exc.value.status_code == 500 + assert exc.value.detail["code"] == "password_metadata_sync_failed" + assert db.rollback_called is True + assert calls == ["newpass123", "oldpass"] + + +def test_reset_root_password_host_success_with_wrapped_socket(monkeypatch): + env = _env(status="running") + db = _FakeDb(env) + + class _Container: + status = "running" + id = "container-123" + + class _Containers: + def get(self, name): + assert name == f"lyra-{env.name}-{env.id}" + return _Container() + + class _InnerSocket: + def __init__(self): + self.payload = b"" + + def sendall(self, data: bytes): + self.payload += data + + class _SocketWrapper: + def __init__(self, inner): + self._sock = inner + + def close(self): + return None + + inner_sock = _InnerSocket() + wrapped = _SocketWrapper(inner_sock) + + class _DockerApi: + def exec_create(self, container_id, cmd, stdin, tty): + assert container_id == "container-123" + assert cmd == ["chpasswd"] + assert stdin is True + assert tty is False + return {"Id": "exec-1"} + + def exec_start(self, exec_id, detach, tty, socket): + assert exec_id == "exec-1" + assert detach is False + assert tty is False + assert socket is True + return wrapped + + def exec_inspect(self, exec_id): + assert exec_id == "exec-1" + return {"ExitCode": 0} + + class _DockerClient: + containers = _Containers() + api = _DockerApi() + + monkeypatch.setattr(env_router.docker, "from_env", lambda: _DockerClient()) + monkeypatch.setattr(env_router, "encrypt_secret", lambda value: f"enc::{value}") + + result = asyncio.run( + env_router.reset_environment_root_password( + str(env.id), + payload=EnvironmentRootPasswordResetRequest(new_password="newpass123"), + db=db, + ) + ) + + assert result["message"] == "Root password updated" + assert db.commit_called is True + assert inner_sock.payload == b"root:newpass123\n" + + +def test_reset_root_password_host_waits_for_delayed_exec_exit_code(monkeypatch): + env = _env(status="running") + db = _FakeDb(env) + + class _Container: + status = "running" + id = "container-123" + + class _Containers: + def get(self, name): + assert name == f"lyra-{env.name}-{env.id}" + return _Container() + + class _InnerSocket: + def __init__(self): + self.payload = b"" + + def sendall(self, data: bytes): + self.payload += data + + def shutdown(self, _how): + return None + + class _SocketWrapper: + def __init__(self, inner): + self._sock = inner + + def close(self): + return None + + inner_sock = _InnerSocket() + wrapped = _SocketWrapper(inner_sock) + + class _DockerApi: + def __init__(self): + self.inspect_count = 0 + + def exec_create(self, container_id, cmd, stdin, tty): + assert container_id == "container-123" + assert cmd == ["chpasswd"] + assert stdin is True + assert tty is False + return {"Id": "exec-1"} + + def exec_start(self, exec_id, detach, tty, socket): + assert exec_id == "exec-1" + assert detach is False + assert tty is False + assert socket is True + return wrapped + + def exec_inspect(self, exec_id): + assert exec_id == "exec-1" + self.inspect_count += 1 + if self.inspect_count == 1: + return {"ExitCode": None} + return {"ExitCode": 0} + + docker_api = _DockerApi() + + class _DockerClient: + containers = _Containers() + api = docker_api + + monkeypatch.setattr(env_router.docker, "from_env", lambda: _DockerClient()) + monkeypatch.setattr(env_router, "encrypt_secret", lambda value: f"enc::{value}") + + result = asyncio.run( + env_router.reset_environment_root_password( + str(env.id), + payload=EnvironmentRootPasswordResetRequest(new_password="newpass123"), + db=db, + ) + ) + + assert result["message"] == "Root password updated" + assert db.commit_called is True + assert inner_sock.payload == b"root:newpass123\n" + assert docker_api.inspect_count >= 2 diff --git a/backend/tests/test_worker_api_envelope.py b/backend/tests/test_worker_api_envelope.py index 0b03c16..2f6c783 100644 --- a/backend/tests/test_worker_api_envelope.py +++ b/backend/tests/test_worker_api_envelope.py @@ -6,6 +6,7 @@ from app.routers import worker_api from app.routers import environments as env_router from app.routers import resources as resource_router +from app.schemas import EnvironmentRootPasswordResetRequest def _build_client(): @@ -64,3 +65,46 @@ async def _fake_start(*, environment_id, db): body = response.json() assert body["detail"]["code"] == "environment_not_running" assert body["detail"]["message"] == "Environment must be running" + + +def test_worker_root_password_reset_envelope(monkeypatch): + async def _fake_reset(*, environment_id, payload: EnvironmentRootPasswordResetRequest, db): + assert environment_id == "11111111-1111-1111-1111-111111111111" + assert payload.new_password == "newpass123" + return {"message": "Root password updated"} + + monkeypatch.setattr(env_router, "reset_environment_root_password", _fake_reset) + + client = _build_client() + response = client.post( + "/api/worker/environments/11111111-1111-1111-1111-111111111111/accounts/root/reset-password", + json={"new_password": "newpass123"}, + ) + + assert response.status_code == 200 + body = response.json() + assert body["status"] == "ok" + assert body["code"] == "ok" + assert body["data"]["message"] == "Root password updated" + + +def test_worker_root_password_reset_normalizes_error(monkeypatch): + async def _fake_reset(*, environment_id, payload: EnvironmentRootPasswordResetRequest, db): + del environment_id, payload, db + raise HTTPException( + status_code=409, + detail={"code": "env_not_running", "message": "Environment must be running"}, + ) + + monkeypatch.setattr(env_router, "reset_environment_root_password", _fake_reset) + + client = _build_client() + response = client.post( + "/api/worker/environments/11111111-1111-1111-1111-111111111111/accounts/root/reset-password", + json={"new_password": "newpass123"}, + ) + + assert response.status_code == 409 + body = response.json() + assert body["detail"]["code"] == "env_not_running" + assert body["detail"]["message"] == "Environment must be running" diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5e29082..e0074ce 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -40,7 +40,8 @@ "tailwindcss": "^4.1.18", "typescript": "~5.9.3", "typescript-eslint": "^8.46.4", - "vite": "^7.2.4" + "vite": "^7.2.4", + "vitest": "^3.2.4" } }, "node_modules/@alloc/quick-lru": { @@ -1745,6 +1746,17 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/debug": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", @@ -1754,6 +1766,13 @@ "@types/ms": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2141,6 +2160,121 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -2205,6 +2339,16 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -2332,6 +2476,16 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -2386,6 +2540,23 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2443,6 +2614,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -2572,6 +2753,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2682,6 +2873,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -2959,6 +3157,16 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -2969,6 +3177,16 @@ "node": ">=0.10.0" } }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -3939,6 +4157,13 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -5051,6 +5276,23 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -5436,6 +5678,13 @@ "node": ">=8" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -5456,12 +5705,26 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/state-local": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz", "integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==", "license": "MIT" }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/stringify-entities": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", @@ -5489,6 +5752,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, "node_modules/style-to-js": { "version": "1.1.21", "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", @@ -5551,6 +5834,20 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -5568,6 +5865,36 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/trim-lines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", @@ -5907,6 +6234,102 @@ } } }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, "node_modules/void-elements": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", @@ -5932,6 +6355,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/frontend/package.json b/frontend/package.json index 4584ac5..a5ec839 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,6 +6,7 @@ "scripts": { "dev": "vite", "build": "tsc -b && vite build", + "test": "vitest run", "lint": "eslint .", "preview": "vite preview", "i18n:scan": "node scripts/i18n-scan.mjs", @@ -44,6 +45,7 @@ "tailwindcss": "^4.1.18", "typescript": "~5.9.3", "typescript-eslint": "^8.46.4", - "vite": "^7.2.4" + "vite": "^7.2.4", + "vitest": "^3.2.4" } } diff --git a/frontend/src/context/ToastContext.tsx b/frontend/src/context/ToastContext.tsx index 89a977d..5f1c632 100644 --- a/frontend/src/context/ToastContext.tsx +++ b/frontend/src/context/ToastContext.tsx @@ -30,7 +30,7 @@ export function ToastProvider({ children }: { children: ReactNode }) { return ( {children} -
+
{toasts.map((toast) => ( with your host SSH username.', + rootAccountSectionTitle: 'Root account', + rootAccountSectionDescription: 'Reset root password for this environment if credentials are lost.', + accountRootLabel: 'root', + rootPasswordResetInputPlaceholder: 'New root password', + rootPasswordResetAction: 'Reset Password', + rootPasswordResetConfirmTitle: 'Reset Root Password', + rootPasswordResetConfirmMessage: 'Reset root password for {{name}} now?', + rootPasswordResetConfirmAction: 'Reset', + sshGuideAliases: 'Aliases: jump={{jumpAlias}}, env={{envAlias}}', + sshCommand: 'SSH Command', openJupyterLab: 'Open Jupyter Lab', openCodeServer: 'Open code-server', hostServer: 'Host', @@ -294,6 +317,8 @@ const enCommon = { faviconRecommended: 'Recommended: square icon (32x32 or 64x64), max 512KB.', hostServerTitle: 'Host Server Connection', hostServerDescription: 'Configure SSH access to the host machine for the Terminal tab.', + sshResetConfirmTitle: 'Reset host server connection?', + sshResetConfirmMessage: 'This will clear saved SSH credentials and key metadata in this browser.', hostAddress: 'Host Address', autoDetected: 'Auto-detected', port: 'Port', @@ -375,6 +400,13 @@ const enCommon = { dashboard: { sshCopied: 'SSH command copied to clipboard.', copyFailedRunManually: 'Unable to copy. Run manually: {{command}}', + sshGuideCommandCopied: 'SSH one-shot command copied.', + sshGuideConfigCopied: 'SSH config example copied.', + sshGuideCopyFailed: 'Failed to copy text. Please copy it manually.', + rootPasswordRequired: 'Please enter a new root password.', + rootPasswordTooShort: 'Root password must be at least 4 characters.', + rootPasswordResetSuccess: 'Root password has been reset.', + rootPasswordResetFailed: 'Failed to reset root password.', jupyterLaunchUrlMissing: 'Unable to open Jupyter: launch URL was not returned.', jupyterOpenFailed: 'Unable to open Jupyter. Please ensure the environment is running.', codeLaunchUrlMissing: 'Unable to open code-server: launch URL was not returned.', @@ -422,6 +454,8 @@ const enCommon = { sshKeyFileRequired: 'Please select an SSH key file.', sshSettingsUpdated: 'SSH settings updated! Key is encrypted in your browser.', sshSettingsUpdateFailed: 'Failed to update SSH settings.', + sshSettingsReset: 'SSH settings reset.', + sshSettingsResetFailed: 'Failed to reset SSH settings.', sshTesting: 'Testing connection...', sshPickKeyToTest: 'Please pick a key file to test.', sshConnectionSuccess: 'Connection Successful!', diff --git a/frontend/src/i18n/locales/ko/common.ts b/frontend/src/i18n/locales/ko/common.ts index 655926d..bc66264 100644 --- a/frontend/src/i18n/locales/ko/common.ts +++ b/frontend/src/i18n/locales/ko/common.ts @@ -26,6 +26,8 @@ const koCommon = { delete: '삭제', selectFile: '파일 선택', reset: '초기화', + show: '보기', + hide: '숨기기', testConnection: '연결 테스트', }, labels: { @@ -89,8 +91,29 @@ const koCommon = { noCustomPorts: '커스텀 포트 없음', copySshCommand: 'SSH 명령 복사 (포트: {{port}})', openInTerminal: '터미널에서 열기 (포트: {{port}})', + openSshGuide: 'SSH 안내 열기 (포트: {{port}})', openInTerminalWorkerUnsupported: '대시보드 직접 SSH는 호스트 환경에서만 지원됩니다.', environmentMustBeRunning: '환경이 실행 중이어야 합니다 (포트: {{port}})', + sshAccessGuideTitle: 'SSH 접속 안내', + sshAccessGuideFor: '{{name}} 접속 안내', + sshAccessGuideHostInfo: '대상 호스트: {{host}} · SSH 포트: {{port}}', + sshGuideOneShot: '즉시 실행 명령', + sshGuideConfigExample: 'SSH config 예시', + sshGuideCopyCommand: '명령 복사', + sshGuideCopyConfig: '설정 복사', + sshGuideNotes: '안내', + sshGuideRootWarning: '이 가이드는 컨테이너 root 계정으로 접속합니다.', + sshGuidePlaceholderWarning: '를 실제 호스트 SSH 사용자명으로 바꿔서 사용하세요.', + rootAccountSectionTitle: 'root 계정', + rootAccountSectionDescription: '비밀번호를 잊어버린 경우 이 환경의 root 비밀번호를 재설정할 수 있습니다.', + accountRootLabel: 'root', + rootPasswordResetInputPlaceholder: '새 root 비밀번호', + rootPasswordResetAction: '비밀번호 재설정', + rootPasswordResetConfirmTitle: 'root 비밀번호 재설정', + rootPasswordResetConfirmMessage: '{{name}} 환경의 root 비밀번호를 지금 재설정할까요?', + rootPasswordResetConfirmAction: '재설정', + sshGuideAliases: '별칭: 점프={{jumpAlias}}, 환경={{envAlias}}', + sshCommand: 'SSH 명령', openJupyterLab: 'Jupyter Lab 열기', openCodeServer: 'code-server 열기', hostServer: '호스트', @@ -291,6 +314,8 @@ const koCommon = { faviconRecommended: '권장: 정사각형 아이콘 (32x32 또는 64x64), 최대 512KB.', hostServerTitle: '호스트 서버 연결', hostServerDescription: '터미널 탭에서 사용할 호스트 SSH 연결을 설정합니다.', + sshResetConfirmTitle: '호스트 서버 연결을 초기화할까요?', + sshResetConfirmMessage: '이 브라우저에 저장된 SSH 인증 정보와 키 메타데이터가 삭제됩니다.', hostAddress: '호스트 주소', autoDetected: '자동 감지', port: '포트', @@ -372,6 +397,13 @@ const koCommon = { dashboard: { sshCopied: 'SSH 명령을 클립보드에 복사했습니다.', copyFailedRunManually: '복사할 수 없습니다. 수동 실행: {{command}}', + sshGuideCommandCopied: 'SSH 즉시 실행 명령을 복사했습니다.', + sshGuideConfigCopied: 'SSH config 예시를 복사했습니다.', + sshGuideCopyFailed: '복사에 실패했습니다. 수동으로 복사해주세요.', + rootPasswordRequired: '새 root 비밀번호를 입력해주세요.', + rootPasswordTooShort: 'root 비밀번호는 최소 4자 이상이어야 합니다.', + rootPasswordResetSuccess: 'root 비밀번호를 재설정했습니다.', + rootPasswordResetFailed: 'root 비밀번호 재설정에 실패했습니다.', jupyterLaunchUrlMissing: 'Jupyter를 열 수 없습니다: 실행 URL을 받지 못했습니다.', jupyterOpenFailed: 'Jupyter를 열 수 없습니다. 환경이 실행 중인지 확인해주세요.', codeLaunchUrlMissing: 'code-server를 열 수 없습니다: 실행 URL을 받지 못했습니다.', @@ -419,6 +451,8 @@ const koCommon = { sshKeyFileRequired: 'SSH 키 파일을 선택해주세요.', sshSettingsUpdated: 'SSH 설정이 업데이트되었습니다. 키는 브라우저에서 암호화됩니다.', sshSettingsUpdateFailed: 'SSH 설정 업데이트에 실패했습니다.', + sshSettingsReset: 'SSH 설정이 초기화되었습니다.', + sshSettingsResetFailed: 'SSH 설정 초기화에 실패했습니다.', sshTesting: '연결을 테스트하는 중...', sshPickKeyToTest: '테스트할 키 파일을 선택해주세요.', sshConnectionSuccess: '연결 성공!', diff --git a/frontend/src/index.css b/frontend/src/index.css index c8cab82..d230b0f 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -20,6 +20,10 @@ --primary-contrast: #ffffff; --danger: #dc2626; --success: #16a34a; + --warning-bg: #fef3c7; + --warning-border: #f59e0b; + --warning-text: #78350f; + --warning-title: #92400e; --terminal-bg: #000000; --terminal-border: #3f3f46; } @@ -37,6 +41,10 @@ --primary-contrast: #ffffff; --danger: #ef4444; --success: #22c55e; + --warning-bg: rgb(245 158 11 / 0.12); + --warning-border: rgb(245 158 11 / 0.38); + --warning-text: #fcd34d; + --warning-title: #fbbf24; --terminal-bg: #000000; --terminal-border: #3f3f46; } diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 876c811..949fecf 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -1,14 +1,15 @@ import axios from 'axios'; -import { ChevronDown, ChevronUp, Code2, HardDrive, HelpCircle, LayoutTemplate, Network, Play, RefreshCw, Square, SquareTerminal, Trash2, X } from 'lucide-react'; +import { ChevronDown, ChevronUp, Code2, Eye, EyeOff, HardDrive, HelpCircle, LayoutTemplate, Network, Play, RefreshCw, Square, SquareTerminal, Trash2, X } from 'lucide-react'; import { isValidElement, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import { useTranslation } from 'react-i18next'; -import { useNavigate } from 'react-router-dom'; import Modal from '../components/Modal'; import OverlayPortal from '../components/OverlayPortal'; import { useApp } from '../context/AppContext'; import { useToast } from '../context/ToastContext'; +import { buildSshGuide } from '../utils/sshGuide'; +import { readStoredSshClientConfig } from '../utils/sshClientConfig'; interface MountConfig { host_path: string; @@ -43,14 +44,12 @@ interface Environment { } const ENVS_CACHE_KEY = 'lyra.dashboard.environments'; -const TERMINAL_ACTION_QUEUE_KEY = 'lyra.terminal.pending_action'; const NOTICE_OPEN_KEY = 'lyra.dashboard.notice_open'; const MIN_REFRESH_SPIN_MS = 900; export default function Dashboard() { const { showToast } = useToast(); const { t } = useTranslation(); - const navigate = useNavigate(); const { announcementMarkdown } = useApp(); const hasAnnouncement = announcementMarkdown.trim().length > 0; const [isNoticeOpen, setIsNoticeOpen] = useState(() => { @@ -88,6 +87,12 @@ export default function Dashboard() { const [errorLogEnv, setErrorLogEnv] = useState(null); const [errorLog, setErrorLog] = useState(""); const [logLoading, setLogLoading] = useState(false); + const [sshGuideEnv, setSshGuideEnv] = useState(null); + const [rootResetPassword, setRootResetPassword] = useState(''); + const [rootResetInputError, setRootResetInputError] = useState(''); + const [showRootResetPassword, setShowRootResetPassword] = useState(false); + const [rootResetConfirm, setRootResetConfirm] = useState<{ envId: string; name: string; password: string } | null>(null); + const [rootResetSubmitting, setRootResetSubmitting] = useState(false); const [workerErrorInfo, setWorkerErrorInfo] = useState<{ name: string; message: string } | null>(null); const [actionLoading, setActionLoading] = useState>({}); const [isRefreshSpinning, setIsRefreshSpinning] = useState(false); @@ -438,71 +443,8 @@ export default function Dashboard() { }); }; - const openEnvInTerminal = (env: Environment) => { - const resolveSshHost = () => { - if (!env.worker_server_name) { - return '127.0.0.1'; - } - const baseUrl = env.worker_server_base_url || ''; - if (baseUrl) { - try { - return new URL(baseUrl).hostname; - } catch { - // Fall through to worker name. - } - } - return env.worker_server_name; - }; - - const host = resolveSshHost(); - const sshUser = env.container_user || 'root'; - const sshCommand = `ssh -p ${env.ssh_port} ${sshUser}@${host}`; - - if (env.worker_server_name) { - const copyWithFallback = async (text: string) => { - if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) { - await navigator.clipboard.writeText(text); - return; - } - const textarea = document.createElement('textarea'); - textarea.value = text; - textarea.setAttribute('readonly', 'true'); - textarea.style.position = 'fixed'; - textarea.style.opacity = '0'; - textarea.style.left = '-9999px'; - document.body.appendChild(textarea); - textarea.select(); - const copied = document.execCommand('copy'); - document.body.removeChild(textarea); - if (!copied) { - throw new Error('copy_failed'); - } - }; - - copyWithFallback(sshCommand) - .then(() => { - showToast(t('feedback.dashboard.sshCopied'), 'success'); - }) - .catch(() => { - showToast(t('feedback.dashboard.copyFailedRunManually', { command: sshCommand }), 'error'); - }); - return; - } - - try { - window.localStorage.setItem( - TERMINAL_ACTION_QUEUE_KEY, - JSON.stringify({ - type: 'open_tab_and_run', - command: sshCommand, - environmentName: env.name, - requestedAt: Date.now(), - }), - ); - } catch { - // Ignore storage failures and still move to terminal page. - } - navigate('/terminal'); + const openSshAccessGuide = (env: Environment) => { + setSshGuideEnv(env); }; const openJupyter = async (env: Environment) => { @@ -561,6 +503,62 @@ export default function Dashboard() { } }; + const copySshGuideValue = async (value: string, successMessage: string) => { + try { + await navigator.clipboard.writeText(value); + showToast(successMessage, 'success'); + } catch { + showToast(t('feedback.dashboard.sshGuideCopyFailed'), 'error'); + } + }; + + const requestRootPasswordReset = () => { + if (!sshGuideEnv) return; + const password = rootResetPassword; + if (!password.trim()) { + setRootResetInputError(t('feedback.dashboard.rootPasswordRequired')); + return; + } + if (password.length < 4) { + setRootResetInputError(t('feedback.dashboard.rootPasswordTooShort')); + return; + } + setRootResetInputError(''); + setRootResetConfirm({ envId: sshGuideEnv.id, name: sshGuideEnv.name, password }); + }; + + const submitRootPasswordReset = async () => { + if (!rootResetConfirm) return; + setRootResetSubmitting(true); + try { + await axios.post(`environments/${rootResetConfirm.envId}/accounts/root/reset-password`, { + new_password: rootResetConfirm.password, + }); + showToast(t('feedback.dashboard.rootPasswordResetSuccess'), 'success'); + setRootResetPassword(''); + setRootResetInputError(''); + await fetchEnvironments(); + } catch (error: unknown) { + const { code, message } = getApiErrorCodeAndMessage(error); + if (code) { + const workerKey = `dashboard.workerError.${code}`; + const workerTranslated = t(workerKey); + if (workerTranslated !== workerKey) { + showToast(workerTranslated, 'error'); + } else { + showToast(message || t('feedback.dashboard.rootPasswordResetFailed'), 'error'); + } + } else if (message) { + showToast(message, 'error'); + } else { + showToast(t('feedback.dashboard.rootPasswordResetFailed'), 'error'); + } + } finally { + setRootResetSubmitting(false); + setRootResetConfirm(null); + } + }; + useEffect(() => { fetchEnvironments({ showLoading: true }); const interval = setInterval(() => { @@ -596,6 +594,12 @@ export default function Dashboard() { } }, [hasAnnouncement, isNoticeOpen]); + useEffect(() => { + setRootResetPassword(''); + setRootResetInputError(''); + setShowRootResetPassword(false); + }, [sshGuideEnv?.id]); + const renderAccessCell = (env: Environment): ReactNode => { const hasWorkerError = Boolean(env.worker_server_name && (env.worker_error_code || env.worker_error_message)); if (env.status === 'stopped' || env.status === 'error' || hasWorkerError) { @@ -606,26 +610,20 @@ export default function Dashboard() { const jupyterEnabled = env.enable_jupyter !== false; const codeEnabled = env.enable_code_server !== false; - const isWorkerEnv = Boolean(env.worker_server_name); - const accessItems: Array<{ key: string; node: ReactNode }> = [ { key: 'ssh', node: (
- {isWorkerEnv && isRunning - ? t('dashboard.copySshCommand', { port: env.ssh_port }) - : !isWorkerEnv && isRunning - ? t('dashboard.openInTerminal', { port: env.ssh_port }) - : t('dashboard.environmentMustBeRunning', { port: env.ssh_port })} + {isRunning ? t('dashboard.openSshGuide', { port: env.ssh_port }) : t('dashboard.environmentMustBeRunning', { port: env.ssh_port })}
), @@ -723,6 +721,127 @@ export default function Dashboard() { message={t('dashboard.forceDeleteEnvironmentMessage')} isDestructive={true} /> + setRootResetConfirm(null)} + onConfirm={() => { + void submitRootPasswordReset(); + }} + title={t('dashboard.rootPasswordResetConfirmTitle')} + message={t('dashboard.rootPasswordResetConfirmMessage', { name: rootResetConfirm?.name || '' })} + isDestructive={true} + confirmText={t('dashboard.rootPasswordResetConfirmAction')} + /> + {sshGuideEnv && ( + + {(() => { + const sshClient = readStoredSshClientConfig(); + const guide = buildSshGuide(sshGuideEnv, { + host: sshClient.host, + username: sshClient.username, + port: sshClient.port, + }); + const showSshGuideNotes = !sshGuideEnv.worker_server_name && !String(sshClient.username || '').trim(); + return ( +
+
+

{t('dashboard.sshAccessGuideTitle')}

+ +
+
+

{t('dashboard.sshAccessGuideFor', { name: sshGuideEnv.name })}

+

+ {t('dashboard.sshAccessGuideHostInfo', { host: guide.jumpHost, port: sshGuideEnv.ssh_port })} +

+
+
+
{t('dashboard.sshGuideOneShot')}
+ +
+
{guide.oneShotCommand}
+
+
+
+
{t('dashboard.sshGuideConfigExample')}
+ +
+
{guide.sshConfig}
+
+ {showSshGuideNotes && ( +
+
{t('dashboard.sshGuideNotes')}
+
    +
  • {t('dashboard.sshGuideRootWarning')}
  • +
  • {t('dashboard.sshGuidePlaceholderWarning')}
  • +
+
+ )} +
+
{t('dashboard.rootAccountSectionTitle')}
+
{t('dashboard.rootAccountSectionDescription')}
+
+ {t('dashboard.accountRootLabel')} +
+ { + setRootResetPassword(event.target.value); + if (rootResetInputError) setRootResetInputError(''); + }} + placeholder={t('dashboard.rootPasswordResetInputPlaceholder')} + className="min-w-0 flex-1 bg-transparent px-1 text-sm text-[var(--text)] focus:outline-none" + /> + +
+ +
+ {rootResetInputError && ( +

{rootResetInputError}

+ )} +
+
+
+ +
+
+ ); + })()} +
+ )} {/* Volume Details Modal */} {selectedVolEnv && ( diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 47a212e..e018419 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -10,6 +10,7 @@ import OverlayPortal from '../components/OverlayPortal'; import Modal from '../components/Modal'; import { getStoredUserName, setStoredUserName } from '../utils/userIdentity'; import { + clearStoredSshClientConfig, isSshClientConfigReady, readStoredSshClientConfig, toSshConnectPayload, @@ -110,6 +111,7 @@ export default function Settings() { const [tmuxLoading, setTmuxLoading] = useState(false); const [resourceCleanupTarget, setResourceCleanupTarget] = useState(null); const [workerCleanupTarget, setWorkerCleanupTarget] = useState(null); + const [sshResetConfirmOpen, setSshResetConfirmOpen] = useState(false); const fileInputRef = useRef(null); const faviconInputRef = useRef(null); @@ -494,6 +496,34 @@ export default function Settings() { } }; + const handleResetSsh = () => { + try { + setSshResetConfirmOpen(false); + clearStoredSshClientConfig(); + localStorage.removeItem('ssh_private_key_encrypted'); + localStorage.removeItem('ssh_key_name'); + if (fileInputRef.current) fileInputRef.current.value = ''; + + setSshSettings({ + port: '22', + username: '', + authMethod: 'password', + password: '', + privateKey: '', + keyName: '', + masterPassword: '', + }); + setTmuxSessions([]); + setSelectedTmuxSessions([]); + setSessionStatus({ type: 'idle' }); + setSshStatus({ type: 'success', message: t('feedback.settings.sshSettingsReset') }); + setTimeout(() => setSshStatus({ type: 'idle' }), 3000); + } catch (error) { + console.error(error); + setSshStatus({ type: 'error', message: t('feedback.settings.sshSettingsResetFailed') }); + } + }; + const handleTestSsh = async () => { try { setSshStatus({ type: 'loading', message: t('feedback.settings.sshTesting') }); @@ -962,6 +992,16 @@ export default function Settings() { return (
+ setSshResetConfirmOpen(false)} + onConfirm={handleResetSsh} + title={t('settings.sshResetConfirmTitle')} + message={t('settings.sshResetConfirmMessage')} + type="confirm" + confirmText={t('actions.confirm')} + cancelText={t('actions.cancel')} + /> setResourceCleanupTarget(null)} @@ -1308,6 +1348,13 @@ export default function Settings() { )}
+