diff --git a/docs/plans/2026-03-28-auth-phase1-infrastructure.md b/docs/plans/2026-03-28-auth-phase1-infrastructure.md new file mode 100644 index 0000000..dfe6d3a --- /dev/null +++ b/docs/plans/2026-03-28-auth-phase1-infrastructure.md @@ -0,0 +1,1074 @@ +# Auth Phase 1: Core Infrastructure — Implementation Plan + +> **Execution:** Use the subagent-driven-development workflow to implement this plan. + +**Goal:** Build the auth module (`auth.py`), middleware, and route stubs so all non-localhost HTTP/WS requests require authentication. + +**Architecture:** A new `muxplex/auth.py` module provides password file management, signing secret management, session cookie signing/verification, PAM authentication, and a FastAPI middleware class. The middleware is mounted in `main.py` and gates every request. Localhost clients bypass auth entirely. A stub `/login` page and `/auth/mode` JSON endpoint are added for Phase 2 to build on. + +**Tech Stack:** Python 3.11+, FastAPI, itsdangerous (TimestampSigner), python-pam, pytest + +**Phase:** 1 of 2 — complete this phase before starting Phase 2 (`2026-03-28-auth-phase2-ui-cli.md`) + +**Design doc:** `docs/plans/2026-03-28-auth-design.md` + +--- + +### Task 1: Add dependencies to pyproject.toml + +**Files:** +- Modify: `pyproject.toml` (line 12–17, `[project.dependencies]`) + +**Step 1: Add python-pam and itsdangerous to dependencies** + +In `pyproject.toml`, add two entries to the `dependencies` list. The existing list looks like: + +```toml +dependencies = [ + "fastapi>=0.115.0", + "uvicorn[standard]>=0.30.0", + "aiofiles>=23.0", + "websockets>=11.0", +] +``` + +Change it to: + +```toml +dependencies = [ + "fastapi>=0.115.0", + "uvicorn[standard]>=0.30.0", + "aiofiles>=23.0", + "websockets>=11.0", + "python-pam>=1.8.4", + "itsdangerous>=2.1.0", +] +``` + +**Step 2: Install updated dependencies** + +Run: `cd /home/bkrabach/dev/web-tmux/muxplex && uv pip install -e ".[dev]"` +Expected: installs without errors, both new packages appear in the output + +**Step 3: Verify imports work** + +Run: `cd /home/bkrabach/dev/web-tmux/muxplex && python -c "import pam; import itsdangerous; print('OK')"` +Expected: prints `OK` + +**Step 4: Commit** + +```bash +cd /home/bkrabach/dev/web-tmux/muxplex && git add pyproject.toml && git commit -m "chore: add python-pam and itsdangerous dependencies" +``` + +--- + +### Task 2: Password file management in auth.py + +**Files:** +- Create: `muxplex/auth.py` +- Create: `muxplex/tests/test_auth.py` + +**Step 1: Write the failing tests** + +Create `muxplex/tests/test_auth.py`: + +```python +"""Tests for muxplex/auth.py — authentication module.""" + +import os +import stat +from pathlib import Path + +import pytest + + +# --------------------------------------------------------------------------- +# Password file management +# --------------------------------------------------------------------------- + + +def test_get_password_path_returns_expected_path(monkeypatch, tmp_path): + """get_password_path() returns ~/.config/muxplex/password.""" + monkeypatch.setattr(Path, "home", staticmethod(lambda: tmp_path)) + from muxplex.auth import get_password_path + + assert get_password_path() == tmp_path / ".config" / "muxplex" / "password" + + +def test_load_password_returns_none_when_no_file(monkeypatch, tmp_path): + """load_password() returns None when password file does not exist.""" + monkeypatch.setattr(Path, "home", staticmethod(lambda: tmp_path)) + from muxplex.auth import load_password + + assert load_password() is None + + +def test_load_password_reads_existing_file(monkeypatch, tmp_path): + """load_password() reads and strips the password file contents.""" + monkeypatch.setattr(Path, "home", staticmethod(lambda: tmp_path)) + pw_path = tmp_path / ".config" / "muxplex" / "password" + pw_path.parent.mkdir(parents=True, exist_ok=True) + pw_path.write_text("my-secret-password\n") + pw_path.chmod(0o600) + + from muxplex.auth import load_password + + assert load_password() == "my-secret-password" + + +def test_generate_and_save_password_creates_file(monkeypatch, tmp_path): + """generate_and_save_password() creates the file and returns a non-empty string.""" + monkeypatch.setattr(Path, "home", staticmethod(lambda: tmp_path)) + from muxplex.auth import generate_and_save_password + + pw = generate_and_save_password() + assert isinstance(pw, str) + assert len(pw) > 10 + + pw_path = tmp_path / ".config" / "muxplex" / "password" + assert pw_path.exists() + assert pw_path.read_text().strip() == pw + + +def test_generate_and_save_password_sets_0600_permissions(monkeypatch, tmp_path): + """generate_and_save_password() sets the file to mode 0600.""" + monkeypatch.setattr(Path, "home", staticmethod(lambda: tmp_path)) + from muxplex.auth import generate_and_save_password + + generate_and_save_password() + pw_path = tmp_path / ".config" / "muxplex" / "password" + mode = stat.S_IMODE(pw_path.stat().st_mode) + assert mode == 0o600 +``` + +**Step 2: Run tests to verify they fail** + +Run: `cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/test_auth.py -v -k "password" 2>&1 | head -30` +Expected: FAIL — `ModuleNotFoundError: No module named 'muxplex.auth'` + +**Step 3: Write the implementation** + +Create `muxplex/auth.py`: + +```python +""" +muxplex authentication — password management, secret management, +session cookies, PAM integration, and request middleware. +""" + +import os +import secrets +from pathlib import Path + + +# --------------------------------------------------------------------------- +# Config directory +# --------------------------------------------------------------------------- + + +def _config_dir() -> Path: + """Return ~/.config/muxplex, creating it (mode 0700) if needed.""" + d = Path.home() / ".config" / "muxplex" + d.mkdir(parents=True, exist_ok=True) + return d + + +# --------------------------------------------------------------------------- +# Password file management +# --------------------------------------------------------------------------- + + +def get_password_path() -> Path: + """Return the path to the password file: ~/.config/muxplex/password.""" + return Path.home() / ".config" / "muxplex" / "password" + + +def load_password() -> str | None: + """Read the password file if it exists, return None otherwise.""" + path = get_password_path() + if not path.exists(): + return None + return path.read_text().strip() + + +def generate_and_save_password() -> str: + """Generate a random password, write it to the password file (0600), return it.""" + pw = secrets.token_urlsafe(20) + path = get_password_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(pw + "\n") + path.chmod(0o600) + return pw +``` + +**Step 4: Run tests to verify they pass** + +Run: `cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/test_auth.py -v -k "password"` +Expected: all 5 tests PASS + +**Step 5: Commit** + +```bash +cd /home/bkrabach/dev/web-tmux/muxplex && git add muxplex/auth.py muxplex/tests/test_auth.py && git commit -m "feat(auth): password file management" +``` + +--- + +### Task 3: Secret file management in auth.py + +**Files:** +- Modify: `muxplex/auth.py` +- Modify: `muxplex/tests/test_auth.py` + +**Step 1: Write the failing tests** + +Append to `muxplex/tests/test_auth.py`: + +```python +# --------------------------------------------------------------------------- +# Secret file management +# --------------------------------------------------------------------------- + + +def test_get_secret_path_returns_expected_path(monkeypatch, tmp_path): + """get_secret_path() returns ~/.config/muxplex/secret.""" + monkeypatch.setattr(Path, "home", staticmethod(lambda: tmp_path)) + from muxplex.auth import get_secret_path + + assert get_secret_path() == tmp_path / ".config" / "muxplex" / "secret" + + +def test_load_or_create_secret_creates_new_file(monkeypatch, tmp_path): + """load_or_create_secret() creates a secret file when none exists.""" + monkeypatch.setattr(Path, "home", staticmethod(lambda: tmp_path)) + from muxplex.auth import load_or_create_secret + + secret = load_or_create_secret() + assert isinstance(secret, str) + assert len(secret) > 20 + + secret_path = tmp_path / ".config" / "muxplex" / "secret" + assert secret_path.exists() + + +def test_load_or_create_secret_sets_0600_permissions(monkeypatch, tmp_path): + """load_or_create_secret() sets the secret file to mode 0600.""" + monkeypatch.setattr(Path, "home", staticmethod(lambda: tmp_path)) + from muxplex.auth import load_or_create_secret + + load_or_create_secret() + secret_path = tmp_path / ".config" / "muxplex" / "secret" + mode = stat.S_IMODE(secret_path.stat().st_mode) + assert mode == 0o600 + + +def test_load_or_create_secret_returns_same_value_on_second_call(monkeypatch, tmp_path): + """load_or_create_secret() returns the same secret on subsequent calls.""" + monkeypatch.setattr(Path, "home", staticmethod(lambda: tmp_path)) + from muxplex.auth import load_or_create_secret + + first = load_or_create_secret() + second = load_or_create_secret() + assert first == second +``` + +**Step 2: Run tests to verify they fail** + +Run: `cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/test_auth.py -v -k "secret" 2>&1 | head -20` +Expected: FAIL — `ImportError: cannot import name 'get_secret_path' from 'muxplex.auth'` + +**Step 3: Write the implementation** + +Add to `muxplex/auth.py`, after the password management section: + +```python +# --------------------------------------------------------------------------- +# Secret (signing key) management +# --------------------------------------------------------------------------- + + +def get_secret_path() -> Path: + """Return the path to the signing secret file: ~/.config/muxplex/secret.""" + return Path.home() / ".config" / "muxplex" / "secret" + + +def load_or_create_secret() -> str: + """Load the signing secret from file, or create one if it doesn't exist.""" + path = get_secret_path() + if path.exists(): + return path.read_text().strip() + secret = secrets.token_urlsafe(32) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(secret + "\n") + path.chmod(0o600) + return secret +``` + +**Step 4: Run tests to verify they pass** + +Run: `cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/test_auth.py -v -k "secret"` +Expected: all 4 tests PASS + +**Step 5: Commit** + +```bash +cd /home/bkrabach/dev/web-tmux/muxplex && git add muxplex/auth.py muxplex/tests/test_auth.py && git commit -m "feat(auth): signing secret file management" +``` + +--- + +### Task 4: Session cookie signing and verification in auth.py + +**Files:** +- Modify: `muxplex/auth.py` +- Modify: `muxplex/tests/test_auth.py` + +**Step 1: Write the failing tests** + +Append to `muxplex/tests/test_auth.py`: + +```python +# --------------------------------------------------------------------------- +# Session cookie signing / verification +# --------------------------------------------------------------------------- + + +def test_create_session_cookie_returns_string(): + """create_session_cookie() returns a non-empty string.""" + from muxplex.auth import create_session_cookie + + cookie = create_session_cookie("test-secret", ttl_seconds=3600) + assert isinstance(cookie, str) + assert len(cookie) > 0 + + +def test_verify_session_cookie_valid_roundtrip(): + """A cookie created by create_session_cookie verifies successfully.""" + from muxplex.auth import create_session_cookie, verify_session_cookie + + cookie = create_session_cookie("test-secret", ttl_seconds=3600) + assert verify_session_cookie("test-secret", cookie, ttl_seconds=3600) is True + + +def test_verify_session_cookie_tampered(): + """A tampered cookie fails verification.""" + from muxplex.auth import create_session_cookie, verify_session_cookie + + cookie = create_session_cookie("test-secret", ttl_seconds=3600) + tampered = cookie + "X" + assert verify_session_cookie("test-secret", tampered, ttl_seconds=3600) is False + + +def test_verify_session_cookie_wrong_secret(): + """A cookie signed with a different secret fails verification.""" + from muxplex.auth import create_session_cookie, verify_session_cookie + + cookie = create_session_cookie("secret-A", ttl_seconds=3600) + assert verify_session_cookie("secret-B", cookie, ttl_seconds=3600) is False + + +def test_verify_session_cookie_expired(): + """An expired cookie (max_age=0) fails verification.""" + from muxplex.auth import create_session_cookie, verify_session_cookie + + cookie = create_session_cookie("test-secret", ttl_seconds=3600) + # Verify with max_age=0 means it's immediately expired + assert verify_session_cookie("test-secret", cookie, ttl_seconds=0) is False +``` + +**Step 2: Run tests to verify they fail** + +Run: `cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/test_auth.py -v -k "cookie" 2>&1 | head -20` +Expected: FAIL — `ImportError: cannot import name 'create_session_cookie' from 'muxplex.auth'` + +**Step 3: Write the implementation** + +Add to `muxplex/auth.py`, adding the import at the top and the functions after the secret section: + +Add `from itsdangerous import TimestampSigner, BadSignature, SignatureExpired` to the imports at the top of the file. + +Then add after the secret management section: + +```python +# --------------------------------------------------------------------------- +# Session cookie signing / verification +# --------------------------------------------------------------------------- + + +def create_session_cookie(secret: str, ttl_seconds: int) -> str: + """Create a signed, timestamped session cookie value.""" + signer = TimestampSigner(secret) + return signer.sign("muxplex-session").decode() + + +def verify_session_cookie(secret: str, cookie: str, ttl_seconds: int) -> bool: + """Verify a session cookie's signature and expiry. Returns True/False.""" + signer = TimestampSigner(secret) + try: + signer.unsign(cookie, max_age=ttl_seconds if ttl_seconds > 0 else None) + return ttl_seconds > 0 or True # ttl=0 with valid sig is still valid (session cookie) + except (BadSignature, SignatureExpired): + return False +``` + +Wait — re-reading the design: `--session-ttl 0` means session cookie (clears on browser close). So `ttl_seconds=0` should mean **no server-side expiry check** (the cookie is valid until the browser drops it). But the test says `ttl_seconds=0` should fail. Let me reconsider. + +Actually the test `test_verify_session_cookie_expired` needs a different approach. We need to test that a cookie signed *in the past* with a short TTL fails. Let's fix both the test and implementation: + +Replace the expired test with: + +```python +def test_verify_session_cookie_expired(): + """A cookie verified with a very short TTL fails (simulates expiry).""" + import time + from muxplex.auth import create_session_cookie, verify_session_cookie + + cookie = create_session_cookie("test-secret", ttl_seconds=1) + time.sleep(1.1) # Wait for it to expire + assert verify_session_cookie("test-secret", cookie, ttl_seconds=1) is False +``` + +And the implementation: + +```python +def verify_session_cookie(secret: str, cookie: str, ttl_seconds: int) -> bool: + """Verify a session cookie's signature and expiry. Returns True/False. + + ttl_seconds=0 means session cookie — no server-side expiry check. + """ + signer = TimestampSigner(secret) + try: + max_age = ttl_seconds if ttl_seconds > 0 else None + signer.unsign(cookie, max_age=max_age) + return True + except (BadSignature, SignatureExpired): + return False +``` + +**Step 4: Run tests to verify they pass** + +Run: `cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/test_auth.py -v -k "cookie"` +Expected: all 5 tests PASS (the expired test takes ~1.1s) + +**Step 5: Commit** + +```bash +cd /home/bkrabach/dev/web-tmux/muxplex && git add muxplex/auth.py muxplex/tests/test_auth.py && git commit -m "feat(auth): session cookie signing and verification" +``` + +--- + +### Task 5: PAM authentication with running-user check + +**Files:** +- Modify: `muxplex/auth.py` +- Modify: `muxplex/tests/test_auth.py` + +**Step 1: Write the failing tests** + +Append to `muxplex/tests/test_auth.py`: + +```python +# --------------------------------------------------------------------------- +# PAM authentication +# --------------------------------------------------------------------------- + + +def test_pam_available_returns_true_when_pam_importable(): + """pam_available() returns True when python-pam is installed.""" + from muxplex.auth import pam_available + + # python-pam is in our deps, so it should be importable + assert pam_available() is True + + +def test_pam_available_returns_false_on_import_error(monkeypatch): + """pam_available() returns False when pam cannot be imported.""" + import builtins + + real_import = builtins.__import__ + + def mock_import(name, *args, **kwargs): + if name == "pam": + raise ImportError("mock: no pam") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", mock_import) + from muxplex.auth import pam_available + + assert pam_available() is False + + +def test_authenticate_pam_success(monkeypatch): + """authenticate_pam() returns True when PAM succeeds for the running user.""" + import pwd + + from muxplex.auth import authenticate_pam + + running_user = pwd.getpwuid(os.getuid()).pw_name + monkeypatch.setattr("pam.authenticate", lambda u, p, service="login": True) + assert authenticate_pam(running_user, "correct-password") is True + + +def test_authenticate_pam_wrong_password(monkeypatch): + """authenticate_pam() returns False when PAM rejects credentials.""" + import pwd + + from muxplex.auth import authenticate_pam + + running_user = pwd.getpwuid(os.getuid()).pw_name + monkeypatch.setattr("pam.authenticate", lambda u, p, service="login": False) + assert authenticate_pam(running_user, "wrong-password") is False + + +def test_authenticate_pam_wrong_user_rejected(monkeypatch): + """authenticate_pam() rejects a different username even if PAM would accept it.""" + from muxplex.auth import authenticate_pam + + # Mock PAM to always return True — but wrong username should still fail + monkeypatch.setattr("pam.authenticate", lambda u, p, service="login": True) + assert authenticate_pam("root", "any-password") is False +``` + +**Step 2: Run tests to verify they fail** + +Run: `cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/test_auth.py -v -k "pam" 2>&1 | head -20` +Expected: FAIL — `ImportError: cannot import name 'pam_available' from 'muxplex.auth'` + +**Step 3: Write the implementation** + +Add to `muxplex/auth.py`, after the cookie section: + +```python +# --------------------------------------------------------------------------- +# PAM authentication +# --------------------------------------------------------------------------- + + +def pam_available() -> bool: + """Check whether the python-pam module is importable.""" + try: + import pam # noqa: F811 + + return True + except ImportError: + return False + + +def authenticate_pam(username: str, password: str) -> bool: + """Authenticate via PAM. Username must match the running process owner.""" + import os as _os + import pwd + + import pam + + running_user = pwd.getpwuid(_os.getuid()).pw_name + if username != running_user: + return False + return pam.authenticate(username, password, service="login") +``` + +**Step 4: Run tests to verify they pass** + +Run: `cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/test_auth.py -v -k "pam"` +Expected: all 5 PAM tests PASS + +**Step 5: Commit** + +```bash +cd /home/bkrabach/dev/web-tmux/muxplex && git add muxplex/auth.py muxplex/tests/test_auth.py && git commit -m "feat(auth): PAM authentication with running-user check" +``` + +--- + +### Task 6: Auth middleware in auth.py + +**Files:** +- Modify: `muxplex/auth.py` +- Modify: `muxplex/tests/test_auth.py` + +**Step 1: Write the failing tests** + +Append to `muxplex/tests/test_auth.py`: + +```python +# --------------------------------------------------------------------------- +# Auth middleware +# --------------------------------------------------------------------------- + +import base64 + +from fastapi import FastAPI +from fastapi.testclient import TestClient +from starlette.responses import PlainTextResponse + +from muxplex.auth import AuthMiddleware, create_session_cookie + + +def _make_test_app(auth_mode: str = "password", password: str = "test-pw") -> FastAPI: + """Create a minimal FastAPI app with AuthMiddleware for testing.""" + test_app = FastAPI() + + test_app.add_middleware( + AuthMiddleware, + auth_mode=auth_mode, + secret="test-secret", + ttl_seconds=3600, + password=password, + ) + + @test_app.get("/protected") + async def protected(): + return PlainTextResponse("OK") + + return test_app + + +def test_middleware_localhost_bypasses_auth(): + """Requests from 127.0.0.1 pass through without auth.""" + app = _make_test_app() + client = TestClient(app, base_url="http://127.0.0.1") + response = client.get("/protected") + assert response.status_code == 200 + assert response.text == "OK" + + +def test_middleware_valid_session_cookie_passes(): + """Non-localhost request with a valid session cookie passes through.""" + app = _make_test_app() + cookie = create_session_cookie("test-secret", ttl_seconds=3600) + client = TestClient(app, base_url="http://192.168.1.1") + response = client.get("/protected", cookies={"muxplex_session": cookie}) + assert response.status_code == 200 + assert response.text == "OK" + + +def test_middleware_tampered_cookie_redirects(): + """Non-localhost request with a tampered cookie redirects to /login.""" + app = _make_test_app() + client = TestClient(app, base_url="http://192.168.1.1", follow_redirects=False) + response = client.get("/protected", cookies={"muxplex_session": "bad.cookie.value"}) + assert response.status_code == 307 + assert "/login" in response.headers["location"] + + +def test_middleware_no_cookie_non_localhost_redirects(): + """Non-localhost request with no cookie redirects to /login.""" + app = _make_test_app() + client = TestClient(app, base_url="http://192.168.1.1", follow_redirects=False) + response = client.get("/protected") + assert response.status_code == 307 + assert "/login" in response.headers["location"] + + +def test_middleware_basic_auth_valid_password(): + """Non-localhost request with valid Basic auth header passes through.""" + app = _make_test_app(auth_mode="password", password="test-pw") + client = TestClient(app, base_url="http://192.168.1.1") + creds = base64.b64encode(b":test-pw").decode() + response = client.get("/protected", headers={"Authorization": f"Basic {creds}"}) + assert response.status_code == 200 + assert response.text == "OK" + + +def test_middleware_basic_auth_invalid_password(): + """Non-localhost request with wrong Basic auth header returns 401.""" + app = _make_test_app(auth_mode="password", password="test-pw") + client = TestClient(app, base_url="http://192.168.1.1") + creds = base64.b64encode(b":wrong-pw").decode() + response = client.get("/protected", headers={"Authorization": f"Basic {creds}"}) + assert response.status_code == 401 + + +def test_middleware_json_request_gets_401_not_redirect(): + """Non-localhost API request (Accept: application/json) gets 401, not redirect.""" + app = _make_test_app() + client = TestClient(app, base_url="http://192.168.1.1", follow_redirects=False) + response = client.get("/protected", headers={"Accept": "application/json"}) + assert response.status_code == 401 + + +def test_middleware_login_path_excluded(): + """/login path is excluded from auth to avoid redirect loops.""" + app = _make_test_app() + + @app.get("/login") + async def login(): + return PlainTextResponse("login page") + + client = TestClient(app, base_url="http://192.168.1.1") + response = client.get("/login") + assert response.status_code == 200 +``` + +**Step 2: Run tests to verify they fail** + +Run: `cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/test_auth.py -v -k "middleware" 2>&1 | head -20` +Expected: FAIL — `ImportError: cannot import name 'AuthMiddleware' from 'muxplex.auth'` + +**Step 3: Write the implementation** + +Add these imports to the top of `muxplex/auth.py`: + +```python +import base64 + +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import JSONResponse, RedirectResponse, Response +``` + +Then add after the PAM section: + +```python +# --------------------------------------------------------------------------- +# Auth middleware +# --------------------------------------------------------------------------- + +# Paths that bypass auth (login page itself, static assets it needs) +_AUTH_EXEMPT_PATHS = {"/login", "/auth/mode", "/auth/logout"} + + +class AuthMiddleware(BaseHTTPMiddleware): + """FastAPI middleware that enforces authentication on non-localhost requests.""" + + def __init__( + self, + app, + auth_mode: str, + secret: str, + ttl_seconds: int, + password: str = "", + ): + super().__init__(app) + self.auth_mode = auth_mode + self.secret = secret + self.ttl_seconds = ttl_seconds + self.password = password + + async def dispatch(self, request: Request, call_next) -> Response: + # 1. Localhost bypass + client_host = request.client.host if request.client else "unknown" + if client_host in ("127.0.0.1", "::1"): + return await call_next(request) + + # 2. Exempt paths (login page, auth endpoints) + if request.url.path in _AUTH_EXEMPT_PATHS: + return await call_next(request) + + # 3. Valid session cookie + cookie = request.cookies.get("muxplex_session") + if cookie and verify_session_cookie(self.secret, cookie, self.ttl_seconds): + return await call_next(request) + + # 4. Authorization: Basic header + auth_header = request.headers.get("authorization", "") + if auth_header.lower().startswith("basic "): + try: + decoded = base64.b64decode(auth_header[6:]).decode() + username, _, pw = decoded.partition(":") + if self._check_credentials(username, pw): + return await call_next(request) + except Exception: + pass + return JSONResponse({"detail": "Invalid credentials"}, status_code=401) + + # 5. No auth — redirect browsers, 401 for API clients + accept = request.headers.get("accept", "") + if "application/json" in accept: + return JSONResponse({"detail": "Authentication required"}, status_code=401) + return RedirectResponse(url="/login", status_code=307) + + def _check_credentials(self, username: str, password: str) -> bool: + """Validate credentials against the configured auth mode.""" + if self.auth_mode == "pam": + return authenticate_pam(username, password) + return password == self.password +``` + +**Step 4: Run tests to verify they pass** + +Run: `cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/test_auth.py -v -k "middleware"` +Expected: all 8 middleware tests PASS + +**Step 5: Run all auth tests** + +Run: `cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/test_auth.py -v` +Expected: all tests PASS (password + secret + cookie + PAM + middleware) + +**Step 6: Commit** + +```bash +cd /home/bkrabach/dev/web-tmux/muxplex && git add muxplex/auth.py muxplex/tests/test_auth.py && git commit -m "feat(auth): request middleware with localhost bypass and session cookie check" +``` + +--- + +### Task 7: Wire middleware into main.py + +**Files:** +- Modify: `muxplex/main.py` + +**Step 1: Write the failing test** + +Append to `muxplex/tests/test_api.py`, at the very end: + +```python +# --------------------------------------------------------------------------- +# Auth middleware integration +# --------------------------------------------------------------------------- + + +def test_non_localhost_without_auth_gets_redirected(monkeypatch): + """A non-localhost request without credentials is redirected to /login.""" + from fastapi.testclient import TestClient + + from muxplex.main import app + + # Ensure auth is active — set a known password via env + monkeypatch.setenv("MUXPLEX_PASSWORD", "test-pw-for-api") + + with TestClient(app, base_url="http://192.168.1.1") as c: + response = c.get("/health", follow_redirects=False) + # Should be redirected to /login or get 307/401 + assert response.status_code in (307, 401) +``` + +**Step 2: Run to verify it fails** + +Run: `cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/test_api.py::test_non_localhost_without_auth_gets_redirected -v` +Expected: FAIL — currently returns 200 (no middleware yet) + +**Step 3: Wire middleware into main.py** + +In `muxplex/main.py`, add auth setup logic. Add these imports near the top (after the existing imports): + +```python +import pwd +import sys + +from muxplex.auth import ( + AuthMiddleware, + generate_and_save_password, + load_or_create_secret, + load_password, + pam_available, +) +``` + +Then, after the `app = FastAPI(...)` line (line 173) and before the request/response models section, add: + +```python +# --------------------------------------------------------------------------- +# Auth setup +# --------------------------------------------------------------------------- + +def _resolve_auth() -> tuple[str, str]: + """Determine auth mode and resolve password. Returns (auth_mode, password). + + Fallback chain for non-localhost: + 1. PAM available → ("pam", "") + 2. MUXPLEX_PASSWORD env → ("password", ) + 3. ~/.config/muxplex/password file → ("password", ) + 4. Auto-generate → ("password", ) + """ + # Explicit override: MUXPLEX_AUTH=password forces password mode + force_password = os.environ.get("MUXPLEX_AUTH", "").lower() == "password" + + if not force_password and pam_available(): + running_user = pwd.getpwuid(os.getuid()).pw_name + print(f" muxplex auth: PAM (user: {running_user})", file=sys.stderr) + return "pam", "" + + if not force_password: + print(" muxplex auth: PAM unavailable, using password mode", file=sys.stderr) + + # Password mode — resolve password + env_pw = os.environ.get("MUXPLEX_PASSWORD") + if env_pw: + print(" muxplex auth: password (env)", file=sys.stderr) + return "password", env_pw + + file_pw = load_password() + if file_pw: + print(f" muxplex auth: password (file: {load_password.__module__})", file=sys.stderr) + return "password", file_pw + + # Last resort: auto-generate + generated = generate_and_save_password() + from muxplex.auth import get_password_path + + print( + f" muxplex auth: password generated — {generated} — saved to {get_password_path()}", + file=sys.stderr, + ) + return "password", generated + + +_auth_mode, _auth_password = _resolve_auth() +_auth_secret = load_or_create_secret() +_auth_ttl = int(os.environ.get("MUXPLEX_SESSION_TTL", "604800")) + +app.add_middleware( + AuthMiddleware, + auth_mode=_auth_mode, + secret=_auth_secret, + ttl_seconds=_auth_ttl, + password=_auth_password, +) +``` + +**Step 4: Run to verify test passes** + +Run: `cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/test_api.py::test_non_localhost_without_auth_gets_redirected -v` +Expected: PASS + +**Step 5: Run the full existing test suite to make sure nothing broke** + +Run: `cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/test_api.py -v` +Expected: all existing tests still PASS (they use `TestClient` with default `base_url="http://testserver"` which resolves to localhost via TestClient internals — **but verify this**. If existing tests break because they're not coming from localhost, the `patch_startup_and_state` fixture needs to also set `MUXPLEX_PASSWORD` or the middleware needs to treat testserver as localhost.) + +**Important:** If existing tests fail with 307/401, add this line to the `patch_startup_and_state` fixture in `test_api.py`: + +```python +monkeypatch.setenv("MUXPLEX_PASSWORD", "test-password") +``` + +This ensures auth resolves without side effects. The TestClient's default host (`testserver`) won't match localhost, so the fixture needs to provide auth context. + +Also, if tests fail because `TestClient` doesn't present as localhost, you may need to add the `muxplex_session` cookie to the `client` fixture. **However**, the simpler fix is: TestClient by default uses `base_url="http://testserver"` and `request.client.host` will be `testclient` — which is NOT `127.0.0.1`. Two solutions: + +**Option A (recommended):** Update the `client` fixture to pass a valid session cookie: +```python +@pytest.fixture +def client(monkeypatch): + monkeypatch.setenv("MUXPLEX_PASSWORD", "test-password") + with TestClient(app) as c: + # Authenticate for non-localhost TestClient + from muxplex.auth import create_session_cookie + from muxplex.main import _auth_secret, _auth_ttl + cookie = create_session_cookie(_auth_secret, _auth_ttl) + c.cookies.set("muxplex_session", cookie) + yield c +``` + +**Option B:** Use `base_url="http://127.0.0.1"` on the existing client fixture. But this changes `request.client.host` behavior. + +Verify which approach is needed by running the existing tests first. Adapt accordingly. + +**Step 6: Commit** + +```bash +cd /home/bkrabach/dev/web-tmux/muxplex && git add muxplex/main.py muxplex/tests/test_api.py && git commit -m "feat: wire auth middleware into FastAPI app" +``` + +--- + +### Task 8: GET /login stub and /auth/mode endpoint + +**Files:** +- Modify: `muxplex/main.py` +- Modify: `muxplex/tests/test_api.py` + +**Step 1: Write the failing tests** + +Append to `muxplex/tests/test_api.py`: + +```python +# --------------------------------------------------------------------------- +# Login stub and auth mode endpoint +# --------------------------------------------------------------------------- + + +def test_get_login_returns_200_html(client): + """GET /login returns 200 with HTML content.""" + response = client.get("/login") + assert response.status_code == 200 + assert "text/html" in response.headers["content-type"] + assert "&1 | head -20` +Expected: FAIL — 404 (routes don't exist yet) + +**Step 3: Add the routes to main.py** + +Add these imports to the top of `muxplex/main.py` if not already present: + +```python +from fastapi.responses import HTMLResponse, JSONResponse as FastJSONResponse +``` + +Then add these routes **before** the static file mount (before the `_FRONTEND_DIR` line, which must remain the last mount): + +```python +# --------------------------------------------------------------------------- +# Auth routes +# --------------------------------------------------------------------------- + + +@app.get("/login", response_class=HTMLResponse) +async def login_page(): + """Stub login page — replaced in Phase 2 with branded login.html.""" + return HTMLResponse( + "" + "

muxplex login

" + "
" + "" + "" + "
" + "" + ) + + +@app.get("/auth/mode") +async def auth_mode_endpoint(): + """Return the current auth mode and running username.""" + username = "" + if _auth_mode == "pam": + username = pwd.getpwuid(os.getuid()).pw_name + return {"mode": _auth_mode, "user": username} +``` + +**Step 4: Run to verify they pass** + +Run: `cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/test_api.py -v -k "login or auth_mode"` +Expected: both tests PASS + +**Step 5: Run the full test suite** + +Run: `cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/ -v` +Expected: all tests PASS + +**Step 6: Commit** + +```bash +cd /home/bkrabach/dev/web-tmux/muxplex && git add muxplex/main.py muxplex/tests/test_api.py && git commit -m "feat: add /login stub and /auth/mode endpoint" +``` + +--- + +## Phase 1 Complete Checklist + +After all 8 tasks: + +- [ ] `pyproject.toml` has `python-pam` and `itsdangerous` in `[project.dependencies]` +- [ ] `muxplex/auth.py` exists with: password mgmt, secret mgmt, cookie signing, PAM auth, AuthMiddleware +- [ ] `muxplex/tests/test_auth.py` exists with tests for all of the above +- [ ] `muxplex/main.py` mounts AuthMiddleware, has `/login` stub and `/auth/mode` endpoint +- [ ] All tests in `muxplex/tests/` pass: `python -m pytest muxplex/tests/ -v` +- [ ] 8 clean commits with conventional commit messages + +Proceed to Phase 2: `docs/plans/2026-03-28-auth-phase2-ui-cli.md` \ No newline at end of file diff --git a/docs/plans/2026-03-28-auth-phase2-ui-cli.md b/docs/plans/2026-03-28-auth-phase2-ui-cli.md new file mode 100644 index 0000000..819981b --- /dev/null +++ b/docs/plans/2026-03-28-auth-phase2-ui-cli.md @@ -0,0 +1,1066 @@ +# Auth Phase 2: Login UI + CLI — Implementation Plan + +> **Execution:** Use the subagent-driven-development workflow to implement this plan. + +**Goal:** Build the branded login page, complete login/logout routes, and add all CLI auth commands (`--auth`, `--session-ttl`, `show-password`, `reset-secret`, startup logging). + +**Architecture:** Phase 1 established `auth.py` (middleware, password/secret/cookie/PAM functions) and a stub `/login` route. Phase 2 replaces the stub with a fully branded `login.html` that auto-detects PAM vs password mode, adds the POST `/login` and GET `/auth/logout` handlers, and wires the CLI flags and subcommands that control auth behavior at startup. + +**Tech Stack:** Python 3.11+, FastAPI, HTML/CSS/JS (no framework), argparse, pytest + +**Phase:** 2 of 2 — complete Phase 1 (`2026-03-28-auth-phase1-infrastructure.md`) before starting this phase. + +**Design doc:** `docs/plans/2026-03-28-auth-design.md` + +**Prerequisite:** Phase 1 must be complete. Verify: `python -m pytest muxplex/tests/ -v` — all tests pass, `muxplex/auth.py` exists with `AuthMiddleware`, `/login` stub and `/auth/mode` endpoint exist in `main.py`. + +--- + +### Task 1: Create branded login.html + +**Files:** +- Create: `muxplex/frontend/login.html` +- Modify: `muxplex/tests/test_frontend_html.py` (add login.html tests) + +**Step 1: Write the failing tests** + +Append to `muxplex/tests/test_frontend_html.py`: + +```python +# --------------------------------------------------------------------------- +# login.html tests +# --------------------------------------------------------------------------- + +_LOGIN_HTML_PATH = pathlib.Path(__file__).parent.parent / "frontend" / "login.html" + + +def _login_soup() -> BeautifulSoup: + """Parse login.html — separate from index.html soup.""" + return BeautifulSoup(_LOGIN_HTML_PATH.read_text(), "html.parser") + + +def test_login_html_exists() -> None: + """login.html must exist in the frontend directory.""" + assert _LOGIN_HTML_PATH.exists(), f"Missing {_LOGIN_HTML_PATH}" + + +def test_login_html_has_form() -> None: + """login.html must contain a POST form targeting /login.""" + soup = _login_soup() + form = soup.find("form") + assert form is not None, "Missing
element" + assert form.get("method", "").lower() == "post", "Form method should be POST" + assert form.get("action") == "/login", "Form action should be /login" + + +def test_login_html_has_password_autocomplete() -> None: + """login.html password field must have autocomplete='current-password'.""" + soup = _login_soup() + pw_input = soup.find("input", attrs={"autocomplete": "current-password"}) + assert pw_input is not None, "Missing password input with autocomplete='current-password'" + + +def test_login_html_has_wordmark() -> None: + """login.html must include the muxplex wordmark SVG.""" + soup = _login_soup() + # Check for either an with wordmark or inline SVG + img = soup.find("img", attrs={"src": lambda s: s and "wordmark" in s}) + assert img is not None, "Missing muxplex wordmark image" + + +def test_login_html_references_muxplex_auth() -> None: + """login.html must reference window.MUXPLEX_AUTH for mode detection.""" + text = _LOGIN_HTML_PATH.read_text() + assert "MUXPLEX_AUTH" in text, "login.html must reference MUXPLEX_AUTH for mode detection" + + +def test_login_html_has_error_display() -> None: + """login.html must have an element for displaying auth errors.""" + soup = _login_soup() + # Look for an element that handles error state + text = _LOGIN_HTML_PATH.read_text() + assert "error" in text.lower(), "login.html must handle error display (query param ?error=1)" +``` + +**Step 2: Run tests to verify they fail** + +Run: `cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/test_frontend_html.py -v -k "login" 2>&1 | head -20` +Expected: FAIL — `login.html` doesn't exist yet + +**Step 3: Create the branded login.html** + +Create `muxplex/frontend/login.html`: + +```html + + + + + + + muxplex — login + + + + + +
+ + + + +``` + +**Step 4: Run tests to verify they pass** + +Run: `cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/test_frontend_html.py -v -k "login"` +Expected: all 6 login tests PASS + +**Step 5: Commit** + +```bash +cd /home/bkrabach/dev/web-tmux/muxplex && git add muxplex/frontend/login.html muxplex/tests/test_frontend_html.py && git commit -m "feat: branded login.html with PAM/password mode detection" +``` + +--- + +### Task 2: POST /login handler + +**Files:** +- Modify: `muxplex/main.py` +- Modify: `muxplex/tests/test_api.py` + +**Step 1: Write the failing tests** + +Append to `muxplex/tests/test_api.py`: + +```python +# --------------------------------------------------------------------------- +# POST /login +# --------------------------------------------------------------------------- + + +def test_post_login_correct_password_redirects_to_root(client, monkeypatch): + """POST /login with correct password returns 303 redirect to / with session cookie.""" + monkeypatch.setattr("muxplex.main._auth_mode", "password") + monkeypatch.setattr("muxplex.main._auth_password", "test-pw") + + response = client.post( + "/login", + data={"password": "test-pw"}, + follow_redirects=False, + ) + assert response.status_code == 303 + assert response.headers["location"] == "/" + assert "muxplex_session" in response.cookies + + +def test_post_login_wrong_password_redirects_to_login_error(client, monkeypatch): + """POST /login with wrong password returns 303 redirect to /login?error=1.""" + monkeypatch.setattr("muxplex.main._auth_mode", "password") + monkeypatch.setattr("muxplex.main._auth_password", "test-pw") + + response = client.post( + "/login", + data={"password": "wrong-pw"}, + follow_redirects=False, + ) + assert response.status_code == 303 + assert "/login" in response.headers["location"] + assert "error=1" in response.headers["location"] + + +def test_post_login_pam_mode_correct_creds(client, monkeypatch): + """POST /login in PAM mode with correct creds sets cookie and redirects.""" + monkeypatch.setattr("muxplex.main._auth_mode", "pam") + monkeypatch.setattr( + "muxplex.auth.authenticate_pam", + lambda u, p: True, + ) + + response = client.post( + "/login", + data={"username": "testuser", "password": "correct"}, + follow_redirects=False, + ) + assert response.status_code == 303 + assert response.headers["location"] == "/" + assert "muxplex_session" in response.cookies + + +def test_post_login_pam_mode_wrong_creds(client, monkeypatch): + """POST /login in PAM mode with wrong creds redirects to /login?error=1.""" + monkeypatch.setattr("muxplex.main._auth_mode", "pam") + monkeypatch.setattr( + "muxplex.auth.authenticate_pam", + lambda u, p: False, + ) + + response = client.post( + "/login", + data={"username": "testuser", "password": "wrong"}, + follow_redirects=False, + ) + assert response.status_code == 303 + assert "error=1" in response.headers["location"] +``` + +**Step 2: Run tests to verify they fail** + +Run: `cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/test_api.py -v -k "post_login" 2>&1 | head -20` +Expected: FAIL — 405 Method Not Allowed (no POST handler for `/login` yet) + +**Step 3: Add the POST /login handler** + +In `muxplex/main.py`, add `from fastapi import Request` to the existing FastAPI imports if not already present. Then add this route right after the existing `GET /login` route: + +```python +@app.post("/login") +async def login_submit(request: Request): + """Handle login form submission.""" + from muxplex.auth import authenticate_pam, create_session_cookie + + form = await request.form() + username = form.get("username", "") + password = form.get("password", "") + + # Validate credentials + if _auth_mode == "pam": + ok = authenticate_pam(str(username), str(password)) + else: + ok = str(password) == _auth_password + + if not ok: + from starlette.responses import RedirectResponse + + return RedirectResponse("/login?error=1", status_code=303) + + # Success — set session cookie and redirect to / + cookie = create_session_cookie(_auth_secret, _auth_ttl) + from starlette.responses import RedirectResponse + + response = RedirectResponse("/", status_code=303) + response.set_cookie( + "muxplex_session", + cookie, + httponly=True, + samesite="strict", + max_age=_auth_ttl if _auth_ttl > 0 else None, + ) + return response +``` + +Note: The `RedirectResponse` import may already be available from `starlette.responses` (used in `auth.py`). Use whatever import pattern is cleanest — either add to the top-level imports or keep the local imports. Prefer adding `from starlette.responses import RedirectResponse` to the module-level imports at the top. + +**Step 4: Run tests to verify they pass** + +Run: `cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/test_api.py -v -k "post_login"` +Expected: all 4 tests PASS + +**Step 5: Commit** + +```bash +cd /home/bkrabach/dev/web-tmux/muxplex && git add muxplex/main.py muxplex/tests/test_api.py && git commit -m "feat: POST /login handler for PAM and password modes" +``` + +--- + +### Task 3: GET /auth/logout + +**Files:** +- Modify: `muxplex/main.py` +- Modify: `muxplex/tests/test_api.py` + +**Step 1: Write the failing tests** + +Append to `muxplex/tests/test_api.py`: + +```python +# --------------------------------------------------------------------------- +# GET /auth/logout +# --------------------------------------------------------------------------- + + +def test_logout_redirects_to_login(client): + """GET /auth/logout returns 303 redirect to /login.""" + response = client.get("/auth/logout", follow_redirects=False) + assert response.status_code == 303 + assert "/login" in response.headers["location"] + + +def test_logout_clears_session_cookie(client): + """GET /auth/logout deletes the muxplex_session cookie (max-age=0).""" + response = client.get("/auth/logout", follow_redirects=False) + # Check Set-Cookie header clears the cookie + set_cookie = response.headers.get("set-cookie", "") + assert "muxplex_session" in set_cookie + # Cookie should be expired (max-age=0 or empty value) + assert 'max-age=0' in set_cookie.lower() or '=""' in set_cookie or "=''" in set_cookie +``` + +**Step 2: Run tests to verify they fail** + +Run: `cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/test_api.py -v -k "logout" 2>&1 | head -20` +Expected: FAIL — 404 or 307 (no `/auth/logout` route yet) + +**Step 3: Add the logout route** + +In `muxplex/main.py`, add this route after the POST `/login` handler (and before the static file mount): + +```python +@app.get("/auth/logout") +async def logout(): + """Clear the session cookie and redirect to login.""" + from starlette.responses import RedirectResponse + + response = RedirectResponse("/login", status_code=303) + response.delete_cookie("muxplex_session") + return response +``` + +**Step 4: Run tests to verify they pass** + +Run: `cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/test_api.py -v -k "logout"` +Expected: both tests PASS + +**Step 5: Commit** + +```bash +cd /home/bkrabach/dev/web-tmux/muxplex && git add muxplex/main.py muxplex/tests/test_api.py && git commit -m "feat: GET /auth/logout clears session cookie" +``` + +--- + +### Task 4: Replace /login stub with branded login.html serving + +**Files:** +- Modify: `muxplex/main.py` +- Modify: `muxplex/tests/test_api.py` + +**Step 1: Write the failing test** + +Append to `muxplex/tests/test_api.py`: + +```python +# --------------------------------------------------------------------------- +# GET /login serves branded page with injected auth mode +# --------------------------------------------------------------------------- + + +def test_get_login_injects_muxplex_auth(client): + """GET /login HTML must contain window.MUXPLEX_AUTH with the auth mode.""" + response = client.get("/login") + assert response.status_code == 200 + assert "MUXPLEX_AUTH" in response.text + assert '"mode"' in response.text +``` + +**Step 2: Run to verify it fails** + +Run: `cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/test_api.py::test_get_login_injects_muxplex_auth -v` +Expected: FAIL — current stub doesn't have `MUXPLEX_AUTH` + +**Step 3: Replace the GET /login handler** + +In `muxplex/main.py`, replace the existing `login_page()` function with: + +```python +@app.get("/login", response_class=HTMLResponse) +async def login_page(): + """Serve the branded login page with auth mode injected.""" + import json + + html = (_FRONTEND_DIR / "login.html").read_text() + + username = "" + if _auth_mode == "pam": + username = pwd.getpwuid(os.getuid()).pw_name + + mode_data = json.dumps({"mode": _auth_mode, "user": username}) + # Inject auth mode before so the inline script can read it + html = html.replace( + "", + f"\n", + ) + return HTMLResponse(html) +``` + +Note: `_FRONTEND_DIR` is already defined at the bottom of `main.py` as `pathlib.Path(__file__).parent / "frontend"`. It's used for the StaticFiles mount. You need to move this variable definition **above** the routes section so `login_page()` can reference it, or define it separately near the top. The simplest change: move the `_FRONTEND_DIR = pathlib.Path(__file__).parent / "frontend"` line to just after the imports/config section (around line 50), keeping the `app.mount(...)` line at the bottom. + +**Step 4: Run to verify it passes** + +Run: `cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/test_api.py -v -k "login"` +Expected: all login tests PASS (including the old `test_get_login_returns_200_html`) + +**Step 5: Optionally remove the /auth/mode endpoint** + +Since `login.html` now reads `window.MUXPLEX_AUTH` instead of fetching `/auth/mode`, the endpoint is redundant. However, it's harmless and could be useful for API clients. **Keep it** but it's no longer required for the login flow. + +**Step 6: Commit** + +```bash +cd /home/bkrabach/dev/web-tmux/muxplex && git add muxplex/main.py muxplex/tests/test_api.py && git commit -m "feat: /login GET serves branded login.html with injected auth mode" +``` + +--- + +### Task 5: CLI: update host default and add auth flags + +**Files:** +- Modify: `muxplex/cli.py` +- Modify: `muxplex/tests/test_cli.py` + +**Step 1: Write the failing tests** + +Append to `muxplex/tests/test_cli.py`: + +```python +# --------------------------------------------------------------------------- +# Auth CLI flags +# --------------------------------------------------------------------------- + + +def test_main_default_host_is_localhost(): + """Default --host must be 127.0.0.1 (changed from 0.0.0.0).""" + from muxplex.cli import main + + with patch("muxplex.cli.serve") as mock_serve: + with patch("sys.argv", ["muxplex"]): + main() + call_kwargs = mock_serve.call_args + assert call_kwargs[1]["host"] == "127.0.0.1" or call_kwargs[0][0] == "127.0.0.1" + + +def test_main_passes_auth_flag(): + """main() with --auth password must forward auth='password' to serve().""" + from muxplex.cli import main + + with patch("muxplex.cli.serve") as mock_serve: + with patch("sys.argv", ["muxplex", "--auth", "password"]): + main() + _, kwargs = mock_serve.call_args + assert kwargs.get("auth") == "password" + + +def test_main_passes_session_ttl_flag(): + """main() with --session-ttl 3600 must forward session_ttl=3600 to serve().""" + from muxplex.cli import main + + with patch("muxplex.cli.serve") as mock_serve: + with patch("sys.argv", ["muxplex", "--session-ttl", "3600"]): + main() + _, kwargs = mock_serve.call_args + assert kwargs.get("session_ttl") == 3600 +``` + +**Step 2: Run tests to verify they fail** + +Run: `cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/test_cli.py -v -k "default_host or auth_flag or session_ttl" 2>&1 | head -20` +Expected: FAIL — default host is still `0.0.0.0`, no `--auth` or `--session-ttl` flags + +**Step 3: Update cli.py** + +In `muxplex/cli.py`, make these changes: + +1. Change the `serve()` signature to accept auth params: + +```python +def serve(host: str = "127.0.0.1", port: int = 8088, auth: str = "pam", session_ttl: int = 604800) -> None: + """Start the muxplex server.""" + import uvicorn # noqa: PLC0415 + + os.environ.setdefault("MUXPLEX_PORT", str(port)) + if auth: + os.environ.setdefault("MUXPLEX_AUTH", auth) + os.environ.setdefault("MUXPLEX_SESSION_TTL", str(session_ttl)) + + from muxplex.main import app # noqa: PLC0415 + + print(f" muxplex → http://{host}:{port}") + uvicorn.run(app, host=host, port=port, log_level="warning") +``` + +2. Change the `--host` default: + +```python + parser.add_argument( + "--host", default="127.0.0.1", help="Bind host (default: 127.0.0.1)" + ) +``` + +3. Add new arguments after the `--port` argument: + +```python + parser.add_argument( + "--auth", + choices=["pam", "password"], + default="pam", + help="Auth mode: pam (default) or password", + ) + parser.add_argument( + "--session-ttl", + type=int, + default=604800, + help="Session cookie TTL in seconds (default: 604800 = 7 days, 0 = browser session)", + ) +``` + +4. Update the `serve()` call in `main()` to pass the new args: + +```python + if args.command == "install-service": + install_service(system=args.system) + else: + serve(host=args.host, port=args.port, auth=args.auth, session_ttl=args.session_ttl) +``` + +**Step 4: Update the existing test that checks the old default** + +The existing test `test_main_calls_serve_by_default` asserts `host="0.0.0.0"`. Update it: + +In `muxplex/tests/test_cli.py`, change: +```python + mock_serve.assert_called_once_with(host="0.0.0.0", port=8088) +``` +to: +```python + mock_serve.assert_called_once_with(host="127.0.0.1", port=8088, auth="pam", session_ttl=604800) +``` + +**Step 5: Run tests to verify they pass** + +Run: `cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/test_cli.py -v` +Expected: all tests PASS (including updated existing tests) + +**Step 6: Commit** + +```bash +cd /home/bkrabach/dev/web-tmux/muxplex && git add muxplex/cli.py muxplex/tests/test_cli.py && git commit -m "feat(cli): add --auth and --session-ttl flags, change --host default to 127.0.0.1" +``` + +--- + +### Task 6: CLI: show-password subcommand + +**Files:** +- Modify: `muxplex/cli.py` +- Modify: `muxplex/tests/test_cli.py` + +**Step 1: Write the failing tests** + +Append to `muxplex/tests/test_cli.py`: + +```python +# --------------------------------------------------------------------------- +# show-password subcommand +# --------------------------------------------------------------------------- + + +def test_show_password_prints_password_from_file(tmp_path, monkeypatch, capsys): + """show-password prints the password when the file exists.""" + from muxplex.cli import main + + # Set up a fake password file + fake_home = tmp_path / "home" + fake_home.mkdir() + monkeypatch.setattr(Path, "home", staticmethod(lambda: fake_home)) + pw_path = fake_home / ".config" / "muxplex" / "password" + pw_path.parent.mkdir(parents=True, exist_ok=True) + pw_path.write_text("my-test-password\n") + pw_path.chmod(0o600) + + # Force password mode + monkeypatch.setenv("MUXPLEX_AUTH", "password") + + with patch("sys.argv", ["muxplex", "show-password"]): + main() + + captured = capsys.readouterr() + assert "my-test-password" in captured.out + + +def test_show_password_no_file(tmp_path, monkeypatch, capsys): + """show-password prints a helpful message when no password file exists.""" + from muxplex.cli import main + + fake_home = tmp_path / "home" + fake_home.mkdir() + monkeypatch.setattr(Path, "home", staticmethod(lambda: fake_home)) + monkeypatch.setenv("MUXPLEX_AUTH", "password") + + with patch("sys.argv", ["muxplex", "show-password"]): + main() + + captured = capsys.readouterr() + assert "no password" in captured.out.lower() or "not found" in captured.out.lower() + + +def test_show_password_pam_mode(monkeypatch, capsys): + """show-password in PAM mode prints that PAM is active.""" + from muxplex.cli import main + + monkeypatch.delenv("MUXPLEX_AUTH", raising=False) + monkeypatch.setattr("muxplex.auth.pam_available", lambda: True) + + with patch("sys.argv", ["muxplex", "show-password"]): + main() + + captured = capsys.readouterr() + assert "pam" in captured.out.lower() +``` + +**Step 2: Run tests to verify they fail** + +Run: `cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/test_cli.py -v -k "show_password" 2>&1 | head -20` +Expected: FAIL — `show-password` subcommand doesn't exist + +**Step 3: Add the show-password subcommand** + +In `muxplex/cli.py`, add the function: + +```python +def show_password() -> None: + """Show the current muxplex password.""" + from muxplex.auth import load_password, pam_available + + auth_mode = os.environ.get("MUXPLEX_AUTH", "").lower() + if auth_mode != "password" and pam_available(): + print("Auth mode: PAM — no password file used") + return + + pw = load_password() + if pw: + print(f"Password: {pw}") + else: + print("No password file found. Start muxplex to auto-generate one.") +``` + +Then register it as a subcommand in `main()`. Add after the `install-service` subparser: + +```python + sub.add_parser("show-password", help="Show the current muxplex password") +``` + +And in the command dispatch: + +```python + if args.command == "install-service": + install_service(system=args.system) + elif args.command == "show-password": + show_password() + else: + serve(host=args.host, port=args.port, auth=args.auth, session_ttl=args.session_ttl) +``` + +**Step 4: Run tests to verify they pass** + +Run: `cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/test_cli.py -v -k "show_password"` +Expected: all 3 tests PASS + +**Step 5: Commit** + +```bash +cd /home/bkrabach/dev/web-tmux/muxplex && git add muxplex/cli.py muxplex/tests/test_cli.py && git commit -m "feat(cli): add show-password subcommand" +``` + +--- + +### Task 7: CLI: reset-secret subcommand + +**Files:** +- Modify: `muxplex/cli.py` +- Modify: `muxplex/tests/test_cli.py` + +**Step 1: Write the failing tests** + +Append to `muxplex/tests/test_cli.py`: + +```python +# --------------------------------------------------------------------------- +# reset-secret subcommand +# --------------------------------------------------------------------------- + + +def test_reset_secret_writes_new_secret(tmp_path, monkeypatch, capsys): + """reset-secret writes a new secret file.""" + from muxplex.cli import main + + fake_home = tmp_path / "home" + fake_home.mkdir() + monkeypatch.setattr(Path, "home", staticmethod(lambda: fake_home)) + + with patch("sys.argv", ["muxplex", "reset-secret"]): + main() + + secret_path = fake_home / ".config" / "muxplex" / "secret" + assert secret_path.exists() + content = secret_path.read_text().strip() + assert len(content) > 20 + + +def test_reset_secret_sets_0600_permissions(tmp_path, monkeypatch, capsys): + """reset-secret sets the secret file to mode 0600.""" + from muxplex.cli import main + + fake_home = tmp_path / "home" + fake_home.mkdir() + monkeypatch.setattr(Path, "home", staticmethod(lambda: fake_home)) + + with patch("sys.argv", ["muxplex", "reset-secret"]): + main() + + secret_path = fake_home / ".config" / "muxplex" / "secret" + mode = stat.S_IMODE(secret_path.stat().st_mode) + assert mode == 0o600 + + +def test_reset_secret_prints_warning(tmp_path, monkeypatch, capsys): + """reset-secret prints a warning about invalidated sessions.""" + from muxplex.cli import main + + fake_home = tmp_path / "home" + fake_home.mkdir() + monkeypatch.setattr(Path, "home", staticmethod(lambda: fake_home)) + + with patch("sys.argv", ["muxplex", "reset-secret"]): + main() + + captured = capsys.readouterr() + assert "invalid" in captured.out.lower() or "warning" in captured.out.lower() +``` + +**Step 2: Run tests to verify they fail** + +Run: `cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/test_cli.py -v -k "reset_secret" 2>&1 | head -20` +Expected: FAIL — `reset-secret` subcommand doesn't exist + +**Step 3: Add the reset-secret subcommand** + +In `muxplex/cli.py`, add the function: + +```python +def reset_secret() -> None: + """Regenerate the signing secret, invalidating all active sessions.""" + import secrets as _secrets + + from muxplex.auth import get_secret_path + + path = get_secret_path() + path.parent.mkdir(parents=True, exist_ok=True) + new_secret = _secrets.token_urlsafe(32) + path.write_text(new_secret + "\n") + path.chmod(0o600) + print(f"New signing secret written to {path}") + print("Warning: all active sessions are now invalid.") +``` + +Register it as a subcommand. Add after the `show-password` subparser: + +```python + sub.add_parser("reset-secret", help="Regenerate signing secret (invalidates sessions)") +``` + +And in the command dispatch: + +```python + if args.command == "install-service": + install_service(system=args.system) + elif args.command == "show-password": + show_password() + elif args.command == "reset-secret": + reset_secret() + else: + serve(host=args.host, port=args.port, auth=args.auth, session_ttl=args.session_ttl) +``` + +Also add `import stat` to the test file imports if not already present. + +**Step 4: Run tests to verify they pass** + +Run: `cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/test_cli.py -v -k "reset_secret"` +Expected: all 3 tests PASS + +**Step 5: Commit** + +```bash +cd /home/bkrabach/dev/web-tmux/muxplex && git add muxplex/cli.py muxplex/tests/test_cli.py && git commit -m "feat(cli): add reset-secret subcommand" +``` + +--- + +### Task 8: Startup auth logging + +**Files:** +- Modify: `muxplex/main.py` (refine `_resolve_auth` logging) +- Modify: `muxplex/tests/test_auth.py` + +**Step 1: Write the failing tests** + +Append to `muxplex/tests/test_auth.py`: + +```python +# --------------------------------------------------------------------------- +# Startup auth logging (via _resolve_auth) +# --------------------------------------------------------------------------- + + +def test_resolve_auth_pam_mode_logs_pam(monkeypatch, capsys, tmp_path): + """_resolve_auth() prints PAM auth line when PAM is available.""" + monkeypatch.setattr(Path, "home", staticmethod(lambda: tmp_path)) + monkeypatch.delenv("MUXPLEX_AUTH", raising=False) + monkeypatch.delenv("MUXPLEX_PASSWORD", raising=False) + monkeypatch.setattr("muxplex.auth.pam_available", lambda: True) + + # Import after patching + from muxplex.main import _resolve_auth + + mode, pw = _resolve_auth() + assert mode == "pam" + captured = capsys.readouterr() + assert "PAM" in captured.err + + +def test_resolve_auth_env_password_logs_env(monkeypatch, capsys, tmp_path): + """_resolve_auth() prints env password line when MUXPLEX_PASSWORD is set.""" + monkeypatch.setattr(Path, "home", staticmethod(lambda: tmp_path)) + monkeypatch.setenv("MUXPLEX_AUTH", "password") + monkeypatch.setenv("MUXPLEX_PASSWORD", "from-env") + + from muxplex.main import _resolve_auth + + mode, pw = _resolve_auth() + assert mode == "password" + assert pw == "from-env" + captured = capsys.readouterr() + assert "env" in captured.err.lower() + + +def test_resolve_auth_file_password_logs_file(monkeypatch, capsys, tmp_path): + """_resolve_auth() prints file password line when password file exists.""" + monkeypatch.setattr(Path, "home", staticmethod(lambda: tmp_path)) + monkeypatch.setenv("MUXPLEX_AUTH", "password") + monkeypatch.delenv("MUXPLEX_PASSWORD", raising=False) + + pw_path = tmp_path / ".config" / "muxplex" / "password" + pw_path.parent.mkdir(parents=True, exist_ok=True) + pw_path.write_text("file-password\n") + pw_path.chmod(0o600) + + from muxplex.main import _resolve_auth + + mode, pw = _resolve_auth() + assert mode == "password" + assert pw == "file-password" + captured = capsys.readouterr() + assert "file" in captured.err.lower() or "password" in captured.err.lower() + + +def test_resolve_auth_generates_password_as_last_resort(monkeypatch, capsys, tmp_path): + """_resolve_auth() auto-generates a password when nothing else is available.""" + monkeypatch.setattr(Path, "home", staticmethod(lambda: tmp_path)) + monkeypatch.setenv("MUXPLEX_AUTH", "password") + monkeypatch.delenv("MUXPLEX_PASSWORD", raising=False) + + from muxplex.main import _resolve_auth + + mode, pw = _resolve_auth() + assert mode == "password" + assert len(pw) > 10 + captured = capsys.readouterr() + assert "generated" in captured.err.lower() + # The generated password should be printed so the user can see it + assert pw in captured.err +``` + +**Step 2: Run tests to verify they fail or pass** + +Run: `cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/test_auth.py -v -k "resolve_auth" 2>&1 | head -30` +Expected: These tests verify the `_resolve_auth` function added in Phase 1 Task 7. They may pass already if the logging was correctly implemented. If they fail, fix the `_resolve_auth` function. + +**Step 3: Refine _resolve_auth logging if needed** + +Verify the `_resolve_auth()` function in `muxplex/main.py` prints exactly these lines to stderr: + +- PAM available: `muxplex auth: PAM (user: {username})` +- Env password: `muxplex auth: password (env)` +- File password: `muxplex auth: password (file: ~/.config/muxplex/password)` +- Auto-generated: `muxplex auth: password generated — {password} — saved to ~/.config/muxplex/password` + +Update the function if the format doesn't match. Fix the `file_pw` logging line — the Phase 1 plan had a bug (it printed `load_password.__module__` instead of the file path). It should be: + +```python + file_pw = load_password() + if file_pw: + from muxplex.auth import get_password_path + print(f" muxplex auth: password (file: {get_password_path()})", file=sys.stderr) + return "password", file_pw +``` + +**Step 4: Run all tests** + +Run: `cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/test_auth.py -v -k "resolve_auth"` +Expected: all 4 tests PASS + +**Step 5: Run the full test suite** + +Run: `cd /home/bkrabach/dev/web-tmux/muxplex && python -m pytest muxplex/tests/ -v` +Expected: ALL tests pass across all test files + +**Step 6: Commit** + +```bash +cd /home/bkrabach/dev/web-tmux/muxplex && git add muxplex/main.py muxplex/tests/test_auth.py && git commit -m "feat: startup auth mode logging with auto-generated password display" +``` + +--- + +## Phase 2 Complete Checklist + +After all 8 tasks: + +- [ ] `muxplex/frontend/login.html` exists — branded dark theme, wordmark, PAM/password mode detection +- [ ] POST `/login` works — correct creds → cookie + redirect to `/`, wrong creds → redirect to `/login?error=1` +- [ ] GET `/auth/logout` works — clears cookie, redirects to `/login` +- [ ] GET `/login` serves `login.html` with `window.MUXPLEX_AUTH` injected +- [ ] `--host` defaults to `127.0.0.1` +- [ ] `--auth` and `--session-ttl` flags work +- [ ] `muxplex show-password` prints the password or PAM message +- [ ] `muxplex reset-secret` regenerates the signing key with warning +- [ ] Startup prints one clear auth mode line to stderr +- [ ] All tests pass: `python -m pytest muxplex/tests/ -v` +- [ ] 8 clean commits with conventional commit messages + +## End-to-End Smoke Test + +After both phases are complete, manually verify: + +1. `cd /home/bkrabach/dev/web-tmux/muxplex && python -m muxplex --host 0.0.0.0` — should print auth mode line +2. Open `http://localhost:8088` — should load the dashboard (localhost bypass) +3. Open from another device on the LAN — should redirect to `/login` +4. Log in with the displayed password — should redirect to dashboard +5. `muxplex show-password` — prints the password +6. `muxplex reset-secret` — prints warning, old browser session should fail + +## Deferred + +- HTTPS/TLS support +- Rate limiting on login endpoint +- Remember-me longer TTL +- Admin reset flow +- `install-service` auth-aware unit files (systemd EnvironmentFile, launchd plist) \ No newline at end of file diff --git a/docs/plans/2026-03-29-settings-phase1.md b/docs/plans/2026-03-29-settings-phase1.md new file mode 100644 index 0000000..f12d098 --- /dev/null +++ b/docs/plans/2026-03-29-settings-phase1.md @@ -0,0 +1,1395 @@ +# Settings Panel — Phase 1: Backend + Palette Removal + Settings Infrastructure + +> **Execution:** Use the subagent-driven-development workflow to implement this plan. + +**Phase 1 of 2** — complete this phase before starting [Phase 2](./2026-03-29-settings-phase2.md). + +**Design doc:** [`docs/plans/2026-03-29-settings-design.md`](./2026-03-29-settings-design.md) + +**Goal:** Remove the dead command palette, add the backend settings API + new-session endpoint, and build the settings modal shell with the Display tab wired up for immediate visual feedback. + +**Architecture:** Server-side settings live in `~/.config/muxplex/settings.json` (loaded/saved via a new `muxplex/settings.py` module). Three new endpoints: `GET/PATCH /api/settings` and `POST /api/sessions` (create). The frontend settings modal is a `` element with tabbed navigation. Display tab fields (font size, hover delay, grid columns) use localStorage and apply immediately. + +**Tech Stack:** Python 3.11+ / FastAPI / Pydantic, vanilla JS, CSS custom properties. + +--- + +### Task 1: Remove command palette — HTML + CSS + +**Files:** +- Modify: `muxplex/frontend/index.html` (lines 35, 50-58) +- Modify: `muxplex/frontend/style.css` (lines 356-364, 690-778) +- Modify: `muxplex/tests/test_frontend_html.py` (lines 60-76, 243) +- Modify: `muxplex/tests/test_frontend_css.py` (lines 69-74) + +**Step 1: Remove palette trigger button from expanded header** + +In `muxplex/frontend/index.html`, remove the `#palette-trigger` button from the expanded header (line 35): + +```html + + +``` + +The expanded header should now end after ``: + +```html +
+ + + +
+``` + +**Step 2: Remove command palette HTML block** + +In `muxplex/frontend/index.html`, remove the entire command palette block (lines 50-58): + +```html + + + +``` + +**Step 3: Remove palette CSS** + +In `muxplex/frontend/style.css`, remove the `.palette-trigger` block (lines 356-364): + +```css +/* REMOVE this block: */ +.palette-trigger { + background: none; + border: 1px solid var(--border); + border-radius: 4px; + color: var(--text-dim); + font-size: 12px; + padding: 4px 10px; + cursor: pointer; +} +``` + +Remove the entire "Command palette overlay" CSS section (lines 690-778 — from the comment block through `.palette-item__time`): + +```css +/* REMOVE everything from this comment through .palette-item__time { ... } */ +/* ============================================================ + Command palette overlay (desktop session switching) + ============================================================ */ + +.command-palette { ... } +.command-palette__backdrop { ... } +.command-palette__dialog { ... } +.command-palette__input { ... } +.command-palette__input::placeholder { ... } +.command-palette__list { ... } +.palette-item { ... } +.palette-item:hover, +.palette-item--selected { ... } +.palette-item__index { ... } +.palette-item__name { ... } +.palette-item__bell { ... } +.palette-item__time { ... } +``` + +**Step 4: Update HTML tests** + +In `muxplex/tests/test_frontend_html.py`, update `test_html_expanded_view_elements` (line 60-69) to remove `palette-trigger`: + +```python +def test_html_expanded_view_elements() -> None: + """id=back-btn, expanded-session-name, reconnect-overlay.""" + soup = _SOUP + for id_ in ( + "back-btn", + "expanded-session-name", + "reconnect-overlay", + ): + assert soup.find(id=id_), f"Missing element with id='{id_}'" +``` + +Remove `test_html_command_palette` entirely (lines 72-76): + +```python +# DELETE this entire function: +def test_html_command_palette() -> None: + """id=command-palette, palette-input, palette-list, palette-backdrop.""" + ... +``` + +In the `test_html_elements_carry_their_css_classes` test (around line 243), remove the palette-trigger entry: + +```python +# REMOVE this tuple from the cases list: + ("palette-trigger", "palette-trigger", "needs border and hover styles"), +``` + +**Step 5: Update CSS tests** + +In `muxplex/tests/test_frontend_css.py`, remove `test_css_command_palette` (lines 69-74): + +```python +# DELETE this entire function: +def test_css_command_palette(): + css = read_css() + assert ".command-palette__dialog" in css + assert ".command-palette__input" in css + assert ".palette-item" in css + assert ".palette-item--selected" in css +``` + +**Step 6: Run tests to verify nothing breaks** + +```bash +cd muxplex && python -m pytest muxplex/tests/test_frontend_html.py muxplex/tests/test_frontend_css.py -v +``` + +Expected: All tests pass (palette tests removed, no other test depends on palette HTML/CSS). + +**Step 7: Commit** + +```bash +git add -A && git commit -m "refactor: remove command palette HTML and CSS" +``` + +--- + +### Task 2: Remove command palette — JavaScript + +**Files:** +- Modify: `muxplex/frontend/app.js` (lines 901-1086, 1169-1170, 1219-1247, 1274-1332) + +**Step 1: Remove palette state variables** + +In `muxplex/frontend/app.js`, remove the command palette state block (lines 901-906): + +```javascript +// REMOVE this entire block: +// ─── Command palette state ──────────────────────────────────────────────── +const PALETTE_MAX_ITEMS = 9; +let _paletteSelectedIndex = 0; +let _paletteFilteredSessions = []; +let _paletteOpen = false; +let _paletteInputListener = null; +``` + +**Step 2: Remove palette functions** + +Remove the entire "Command palette functions" section (lines 908-1062): +- `renderPaletteList` +- `highlightPaletteItem` +- `openPalette` +- `closePalette` +- `onPaletteInput` +- `handlePaletteKeydown` + +**Step 3: Simplify handleGlobalKeydown** + +Replace the current `handleGlobalKeydown` function (lines 1071-1086) with a version that removes palette references. Keep only the Escape-to-close-session behavior: + +```javascript +/** + * Global keydown handler. + * In fullscreen: Escape returns to grid. + * @param {KeyboardEvent} e + */ +function handleGlobalKeydown(e) { + if (_viewMode === 'fullscreen') { + if (e.key === 'Escape') { + e.preventDefault(); + closeSession(); + } + } +} +``` + +**Step 4: Remove palette event bindings from bindStaticEventListeners** + +In `bindStaticEventListeners` (around lines 1164-1210), remove these two lines: + +```javascript +// REMOVE these two lines: + on($('palette-trigger'), 'click', openPalette); + on($('palette-backdrop'), 'click', closePalette); +``` + +**Step 5: Remove palette test-only helpers** + +Remove these test-only helpers (lines 1219-1247): + +```javascript +// REMOVE all of these: +/** Test-only: set _paletteFilteredSessions directly. */ +function _setPaletteFilteredSessions(sessions) { ... } +/** Test-only: get _paletteFilteredSessions. */ +function _getPaletteFilteredSessions() { ... } +/** Test-only: set _paletteSelectedIndex directly. */ +function _setPaletteSelectedIndex(index) { ... } +/** Test-only: get _paletteSelectedIndex. */ +function _getPaletteSelectedIndex() { ... } +/** Test-only: set _paletteOpen directly. */ +function _setPaletteOpen(val) { ... } +/** Test-only: get _paletteOpen. */ +function _isPaletteOpen() { ... } +``` + +**Step 6: Remove palette exports from module.exports** + +In the `module.exports` block (starting around line 1274), remove these entries: + +```javascript +// REMOVE these exports: + // Command palette + renderPaletteList, + highlightPaletteItem, + openPalette, + closePalette, + onPaletteInput, + handlePaletteKeydown, + // Test-only helpers (palette) + _setPaletteFilteredSessions, + _getPaletteFilteredSessions, + _setPaletteSelectedIndex, + _getPaletteSelectedIndex, + _setPaletteOpen, + _isPaletteOpen, +``` + +Keep `handleGlobalKeydown` and `bindStaticEventListeners` in exports — they still exist. + +**Step 7: Run full test suite** + +```bash +cd muxplex && python -m pytest muxplex/tests/ -v +``` + +Expected: All tests pass. + +**Step 8: Commit** + +```bash +git add -A && git commit -m "refactor: remove command palette JavaScript" +``` + +--- + +### Task 3: Server-side settings module + +**Files:** +- Create: `muxplex/settings.py` +- Create: `muxplex/tests/test_settings.py` + +**Step 1: Write the failing tests** + +Create `muxplex/tests/test_settings.py`: + +```python +"""Tests for muxplex/settings.py — settings file management.""" + +import json + +import pytest + +from muxplex import settings as settings_mod + + +@pytest.fixture(autouse=True) +def redirect_settings_path(tmp_path, monkeypatch): + """Redirect SETTINGS_PATH to tmp_path so tests don't touch real config.""" + tmp_settings = tmp_path / "settings.json" + monkeypatch.setattr(settings_mod, "SETTINGS_PATH", tmp_settings) + + +def test_load_returns_defaults_when_no_file(): + """load_settings() returns DEFAULT_SETTINGS when file doesn't exist.""" + result = settings_mod.load_settings() + assert result == settings_mod.DEFAULT_SETTINGS + + +def test_load_returns_saved_values(tmp_path, monkeypatch): + """load_settings() reads values from existing settings.json.""" + path = settings_mod.SETTINGS_PATH + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps({"sort_order": "alphabetical"})) + result = settings_mod.load_settings() + assert result["sort_order"] == "alphabetical" + # Missing keys filled with defaults + assert result["default_session"] is None + + +def test_save_creates_file_and_dirs(): + """save_settings() creates parent dirs and writes JSON.""" + settings_mod.save_settings({"sort_order": "recent"}) + assert settings_mod.SETTINGS_PATH.exists() + data = json.loads(settings_mod.SETTINGS_PATH.read_text()) + assert data["sort_order"] == "recent" + + +def test_save_merges_with_defaults(): + """save_settings() merges partial data with defaults.""" + settings_mod.save_settings({"sort_order": "alphabetical"}) + data = json.loads(settings_mod.SETTINGS_PATH.read_text()) + assert data["sort_order"] == "alphabetical" + assert data["new_session_template"] == "tmux new-session -d -s {name}" + + +def test_load_handles_corrupt_json(tmp_path): + """load_settings() returns defaults if JSON is corrupt.""" + path = settings_mod.SETTINGS_PATH + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("not json {{{") + result = settings_mod.load_settings() + assert result == settings_mod.DEFAULT_SETTINGS + + +def test_patch_settings_merges_single_field(): + """patch_settings() merges a single field into existing settings.""" + settings_mod.save_settings(settings_mod.DEFAULT_SETTINGS.copy()) + result = settings_mod.patch_settings({"sort_order": "alphabetical"}) + assert result["sort_order"] == "alphabetical" + assert result["default_session"] is None # unchanged + + +def test_patch_settings_ignores_unknown_keys(): + """patch_settings() ignores keys not in DEFAULT_SETTINGS.""" + settings_mod.save_settings(settings_mod.DEFAULT_SETTINGS.copy()) + result = settings_mod.patch_settings({"bogus_key": "ignored"}) + assert "bogus_key" not in result +``` + +**Step 2: Run tests to verify they fail** + +```bash +cd muxplex && python -m pytest muxplex/tests/test_settings.py -v +``` + +Expected: FAIL — `ModuleNotFoundError: No module named 'muxplex.settings'` + +**Step 3: Write the implementation** + +Create `muxplex/settings.py`: + +```python +""" +muxplex settings — server-side configuration file management. + +Settings are stored in ~/.config/muxplex/settings.json. +""" + +import json +import logging +from pathlib import Path + +_log = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Path +# --------------------------------------------------------------------------- + +SETTINGS_PATH: Path = Path.home() / ".config" / "muxplex" / "settings.json" + +# --------------------------------------------------------------------------- +# Defaults +# --------------------------------------------------------------------------- + +DEFAULT_SETTINGS: dict = { + "default_session": None, + "sort_order": "manual", + "hidden_sessions": [], + "window_size_largest": False, + "auto_open_created": True, + "new_session_template": "tmux new-session -d -s {name}", +} + +# --------------------------------------------------------------------------- +# Load / save +# --------------------------------------------------------------------------- + + +def load_settings() -> dict: + """Load settings from disk, returning defaults for missing keys or corrupt files.""" + defaults = DEFAULT_SETTINGS.copy() + if not SETTINGS_PATH.exists(): + return defaults + try: + data = json.loads(SETTINGS_PATH.read_text()) + if not isinstance(data, dict): + return defaults + except (json.JSONDecodeError, OSError): + _log.warning("Corrupt settings file at %s, using defaults", SETTINGS_PATH) + return defaults + # Merge: file values override defaults, unknown keys ignored + for key in defaults: + if key in data: + defaults[key] = data[key] + return defaults + + +def save_settings(data: dict) -> None: + """Write settings to disk, merging with defaults for any missing keys.""" + merged = DEFAULT_SETTINGS.copy() + for key in merged: + if key in data: + merged[key] = data[key] + SETTINGS_PATH.parent.mkdir(parents=True, exist_ok=True) + SETTINGS_PATH.write_text(json.dumps(merged, indent=2) + "\n") + + +def patch_settings(patch: dict) -> dict: + """Load current settings, merge patch (known keys only), save, and return result.""" + current = load_settings() + for key in DEFAULT_SETTINGS: + if key in patch: + current[key] = patch[key] + save_settings(current) + return current +``` + +**Step 4: Run tests to verify they pass** + +```bash +cd muxplex && python -m pytest muxplex/tests/test_settings.py -v +``` + +Expected: All 8 tests pass. + +**Step 5: Commit** + +```bash +git add -A && git commit -m "feat: add server-side settings module" +``` + +--- + +### Task 4: Settings API endpoints + +**Files:** +- Modify: `muxplex/main.py` +- Modify: `muxplex/tests/test_api.py` + +**Step 1: Write the failing tests** + +Add to the bottom of `muxplex/tests/test_api.py`: + +```python +# --------------------------------------------------------------------------- +# GET /api/settings +# --------------------------------------------------------------------------- + + +def test_get_settings_returns_defaults(client, monkeypatch): + """GET /api/settings returns default settings when no file exists.""" + import muxplex.settings as settings_mod + + monkeypatch.setattr(settings_mod, "SETTINGS_PATH", client.app.state._tmp_path / "settings.json") + response = client.get("/api/settings") + assert response.status_code == 200 + data = response.json() + assert data["sort_order"] == "manual" + assert data["new_session_template"] == "tmux new-session -d -s {name}" + + +def test_get_settings_returns_saved_values(client, tmp_path, monkeypatch): + """GET /api/settings returns previously saved settings.""" + import json + import muxplex.settings as settings_mod + + settings_path = tmp_path / "settings.json" + settings_path.write_text(json.dumps({"sort_order": "alphabetical"})) + monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_path) + + response = client.get("/api/settings") + assert response.status_code == 200 + assert response.json()["sort_order"] == "alphabetical" + + +# --------------------------------------------------------------------------- +# PATCH /api/settings +# --------------------------------------------------------------------------- + + +def test_patch_settings_updates_field(client, tmp_path, monkeypatch): + """PATCH /api/settings merges a single field and returns updated settings.""" + import muxplex.settings as settings_mod + + monkeypatch.setattr(settings_mod, "SETTINGS_PATH", tmp_path / "settings.json") + + response = client.patch("/api/settings", json={"sort_order": "alphabetical"}) + assert response.status_code == 200 + data = response.json() + assert data["sort_order"] == "alphabetical" + assert data["default_session"] is None # unchanged default + + +def test_patch_settings_ignores_unknown_keys(client, tmp_path, monkeypatch): + """PATCH /api/settings ignores keys not in the schema.""" + import muxplex.settings as settings_mod + + monkeypatch.setattr(settings_mod, "SETTINGS_PATH", tmp_path / "settings.json") + + response = client.patch("/api/settings", json={"unknown_key": "value"}) + assert response.status_code == 200 + assert "unknown_key" not in response.json() +``` + +**Step 2: Run tests to verify they fail** + +```bash +cd muxplex && python -m pytest muxplex/tests/test_api.py::test_get_settings_returns_defaults -v +``` + +Expected: FAIL — 404 (route doesn't exist yet). + +**Step 3: Add the endpoints to main.py** + +In `muxplex/main.py`, add the import at the top (after existing imports): + +```python +from muxplex.settings import load_settings, patch_settings +``` + +Add the route handlers after the `setup_hooks` endpoint and before the WebSocket proxy section: + +```python +# --------------------------------------------------------------------------- +# Settings +# --------------------------------------------------------------------------- + + +@app.get("/api/settings") +async def get_settings() -> dict: + """Return server-side settings.""" + return load_settings() + + +@app.patch("/api/settings") +async def update_settings(request: Request) -> dict: + """Partial update of server-side settings. Merges known keys only.""" + body = await request.json() + return patch_settings(body) +``` + +**Step 4: Run tests to verify they pass** + +```bash +cd muxplex && python -m pytest muxplex/tests/test_api.py -k "settings" -v +``` + +Expected: All 4 settings tests pass. + +**Step 5: Commit** + +```bash +git add -A && git commit -m "feat: add GET/PATCH /api/settings endpoints" +``` + +--- + +### Task 5: POST /api/sessions (create new session) + +**Files:** +- Modify: `muxplex/main.py` +- Modify: `muxplex/tests/test_api.py` + +**Step 1: Write the failing tests** + +Add to the bottom of `muxplex/tests/test_api.py`: + +```python +# --------------------------------------------------------------------------- +# POST /api/sessions (create new session) +# --------------------------------------------------------------------------- + + +def test_create_session_returns_200_with_name(client, tmp_path, monkeypatch): + """POST /api/sessions returns 200 with the session name.""" + import muxplex.settings as settings_mod + + monkeypatch.setattr(settings_mod, "SETTINGS_PATH", tmp_path / "settings.json") + + # Mock subprocess so nothing actually runs + import subprocess + monkeypatch.setattr(subprocess, "Popen", lambda *a, **kw: None) + + response = client.post("/api/sessions", json={"name": "my-project"}) + assert response.status_code == 200 + assert response.json()["name"] == "my-project" + + +def test_create_session_substitutes_name_in_template(client, tmp_path, monkeypatch): + """POST /api/sessions substitutes {name} in the template command.""" + import json + import subprocess + import muxplex.settings as settings_mod + + settings_path = tmp_path / "settings.json" + settings_path.write_text(json.dumps({ + "new_session_template": "echo {name}" + })) + monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_path) + + captured_cmd = [] + original_popen = subprocess.Popen + + def mock_popen(*args, **kwargs): + captured_cmd.append(args[0] if args else kwargs.get("args")) + return None + + monkeypatch.setattr(subprocess, "Popen", mock_popen) + + client.post("/api/sessions", json={"name": "test-proj"}) + assert len(captured_cmd) == 1 + assert "test-proj" in captured_cmd[0] + + +def test_create_session_rejects_empty_name(client): + """POST /api/sessions with empty name returns 422.""" + response = client.post("/api/sessions", json={"name": ""}) + assert response.status_code == 422 + + +def test_create_session_rejects_missing_name(client): + """POST /api/sessions without name field returns 422.""" + response = client.post("/api/sessions", json={}) + assert response.status_code == 422 +``` + +**Step 2: Run tests to verify they fail** + +```bash +cd muxplex && python -m pytest muxplex/tests/test_api.py::test_create_session_returns_200_with_name -v +``` + +Expected: FAIL — 405 or 404 (route doesn't exist yet). + +**Step 3: Add the endpoint to main.py** + +Add to the imports at the top of `muxplex/main.py`: + +```python +import subprocess +``` + +Add a new Pydantic model near the existing models: + +```python +class CreateSessionPayload(BaseModel): + name: str + + @property + def validated_name(self) -> str: + if not self.name or not self.name.strip(): + raise ValueError("name must not be empty") + return self.name.strip() +``` + +Add the route handler after the settings endpoints: + +```python +# --------------------------------------------------------------------------- +# New session creation +# --------------------------------------------------------------------------- + + +@app.post("/api/sessions") +async def create_session(payload: CreateSessionPayload) -> dict: + """Create a new session by executing the configured template command. + + Substitutes {name} in the template, runs it as a fire-and-forget subprocess. + No existence check — handles create-or-reattach patterns. + Returns 200 with {"name": "..."} regardless of outcome. + """ + name = payload.validated_name + template = load_settings().get( + "new_session_template", "tmux new-session -d -s {name}" + ) + command = template.replace("{name}", name) + try: + subprocess.Popen(command, shell=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + except Exception: + _log.warning("Failed to execute new session command: %s", command) + return {"name": name} +``` + +**Note:** The Pydantic validator on `name` gives us automatic 422 for empty/missing `name`. We need to add a custom validator. Actually, Pydantic v2 with `str` type accepts empty strings, so use a `field_validator`: + +```python +from pydantic import BaseModel, field_validator + +class CreateSessionPayload(BaseModel): + name: str + + @field_validator("name") + @classmethod + def name_must_not_be_empty(cls, v: str) -> str: + if not v or not v.strip(): + raise ValueError("name must not be empty") + return v.strip() +``` + +**Step 4: Run tests to verify they pass** + +```bash +cd muxplex && python -m pytest muxplex/tests/test_api.py -k "create_session" -v +``` + +Expected: All 4 create_session tests pass. + +**Step 5: Commit** + +```bash +git add -A && git commit -m "feat: add POST /api/sessions endpoint for new session creation" +``` + +--- + +### Task 6: Settings modal HTML + CSS skeleton + +**Files:** +- Modify: `muxplex/frontend/index.html` +- Modify: `muxplex/frontend/style.css` + +**Step 1: Add gear icon and `+` button to the overview header** + +In `muxplex/frontend/index.html`, replace the overview header (lines 21-24): + +```html +
+

muxplex

+
+ + + +
+
+``` + +**Step 2: Add the same buttons to the expanded header** + +Update the expanded header to include the gear icon (after `expanded-session-name`): + +```html +
+ + + + +
+``` + +**Step 3: Add the settings dialog HTML** + +Add after the toast element (before the `` comment): + +```html + + + +
+ +
+ +
+

Display

+ + + +
+ + + + + + +
+
+
+``` + +**Step 4: Add settings CSS** + +Append to `muxplex/frontend/style.css` (at the bottom, before any final closing comments): + +```css +/* ============================================================ + Header action buttons (+ and gear) + ============================================================ */ + +.header-actions { + display: flex; + align-items: center; + gap: 8px; +} + +.header-btn { + background: none; + border: 1px solid var(--border); + border-radius: 4px; + color: var(--text-dim); + font-size: 16px; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: color var(--t-fast), border-color var(--t-fast); +} + +.header-btn:hover { + color: var(--text); + border-color: var(--text-muted); +} + +/* ============================================================ + Settings dialog + ============================================================ */ + +.settings-backdrop { + position: fixed; + inset: 0; + background: var(--bg-overlay); + backdrop-filter: blur(2px); + z-index: 299; +} + +.settings-dialog { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: min(600px, 90vw); + height: min(480px, 80vh); + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: 8px; + color: var(--text); + padding: 0; + overflow: hidden; + z-index: 300; + box-shadow: 0 16px 40px rgba(0, 0, 0, 0.5); +} + +.settings-dialog::backdrop { + background: transparent; /* we use our own backdrop for blur */ +} + +.settings-layout { + display: flex; + height: 100%; +} + +.settings-tabs { + width: 140px; + flex-shrink: 0; + display: flex; + flex-direction: column; + gap: 2px; + padding: 16px 0; + border-right: 1px solid var(--border); + background: var(--bg); +} + +.settings-tab { + background: none; + border: none; + border-left: 2px solid transparent; + color: var(--text-muted); + font-size: 13px; + font-family: var(--font-ui); + padding: 8px 16px; + text-align: left; + cursor: pointer; + transition: color var(--t-fast); +} + +.settings-tab:hover { + color: var(--text); +} + +.settings-tab--active { + color: var(--accent); + border-left-color: var(--accent); +} + +.settings-content { + flex: 1; + overflow-y: auto; + padding: 24px; +} + +.settings-panel-title { + font-size: 16px; + font-weight: 600; + margin: 0 0 20px; + color: var(--text); +} + +.settings-field { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 0; + border-bottom: 1px solid var(--border-subtle); +} + +.settings-label { + font-size: 13px; + color: var(--text); +} + +.settings-select { + background: var(--bg); + border: 1px solid var(--border); + border-radius: 4px; + color: var(--text); + font-size: 13px; + font-family: var(--font-ui); + padding: 6px 10px; + cursor: pointer; +} + +.settings-select:focus { + outline: 1px solid var(--accent); + border-color: var(--accent); +} + +/* Mobile: bottom half-sheet */ +@media (max-width: 599px) { + .settings-dialog { + top: auto; + bottom: 0; + left: 0; + right: 0; + transform: none; + width: 100%; + height: 85vh; + border-radius: 12px 12px 0 0; + } + + .settings-layout { + flex-direction: column; + } + + .settings-tabs { + width: 100%; + flex-direction: row; + border-right: none; + border-bottom: 1px solid var(--border); + padding: 0; + overflow-x: auto; + } + + .settings-tab { + border-left: none; + border-bottom: 2px solid transparent; + padding: 12px 16px; + white-space: nowrap; + min-height: 48px; + } + + .settings-tab--active { + border-bottom-color: var(--accent); + border-left-color: transparent; + } +} +``` + +**Step 5: Run tests** + +```bash +cd muxplex && python -m pytest muxplex/tests/test_frontend_html.py muxplex/tests/test_frontend_css.py -v +``` + +Expected: All tests pass (new elements don't conflict with existing tests). + +**Step 6: Commit** + +```bash +git add -A && git commit -m "feat: add settings dialog HTML structure and CSS" +``` + +--- + +### Task 7: Settings JS infrastructure — open/close, tabs, localStorage + +**Files:** +- Modify: `muxplex/frontend/app.js` + +**Step 1: Add settings state variables** + +In `muxplex/frontend/app.js`, add after the existing app state block (after `let _previewSessionName = null;`): + +```javascript +// ─── Settings state ────────────────────────────────────────────────────────── +let _settingsOpen = false; +const DISPLAY_SETTINGS_KEY = 'muxplex.display'; +const DISPLAY_DEFAULTS = { + fontSize: 14, + hoverPreviewDelay: 1500, + gridColumns: 'auto', + bellSound: false, + notificationPermission: 'default', +}; +``` + +**Step 2: Add settings open/close functions** + +Add after the existing `closeSession` function: + +```javascript +// ─── Settings dialog ───────────────────────────────────────────────────────── + +/** + * Load display settings from localStorage, merged with defaults. + * @returns {object} + */ +function loadDisplaySettings() { + try { + const raw = localStorage.getItem(DISPLAY_SETTINGS_KEY); + if (raw) { + const parsed = JSON.parse(raw); + return Object.assign({}, DISPLAY_DEFAULTS, parsed); + } + } catch (_) { /* blocked or corrupt — use defaults */ } + return Object.assign({}, DISPLAY_DEFAULTS); +} + +/** + * Save display settings to localStorage. + * @param {object} settings + */ +function saveDisplaySettings(settings) { + try { + localStorage.setItem(DISPLAY_SETTINGS_KEY, JSON.stringify(settings)); + } catch (_) { /* blocked — ok */ } +} + +/** + * Open the settings dialog. Loads current values into form controls. + */ +function openSettings() { + _settingsOpen = true; + const dialog = $('settings-dialog'); + const backdrop = $('settings-backdrop'); + if (dialog && typeof dialog.showModal === 'function') { + dialog.showModal(); + } + if (backdrop) backdrop.classList.remove('hidden'); + + // Load current display settings into form controls + const ds = loadDisplaySettings(); + var fontSel = $('setting-font-size'); + var delaySel = $('setting-hover-delay'); + var colsSel = $('setting-grid-columns'); + if (fontSel) fontSel.value = String(ds.fontSize); + if (delaySel) delaySel.value = String(ds.hoverPreviewDelay); + if (colsSel) colsSel.value = String(ds.gridColumns); +} + +/** + * Close the settings dialog. + */ +function closeSettings() { + _settingsOpen = false; + const dialog = $('settings-dialog'); + const backdrop = $('settings-backdrop'); + if (dialog && typeof dialog.close === 'function') { + try { dialog.close(); } catch (_) {} + } + if (backdrop) backdrop.classList.add('hidden'); +} + +/** + * Switch to a settings tab by name. + * @param {string} tabName - one of 'display', 'sessions', 'notifications', 'new-session' + */ +function switchSettingsTab(tabName) { + // Update tab buttons + document.querySelectorAll('.settings-tab').forEach(function(btn) { + if (btn.dataset.tab === tabName) { + btn.classList.add('settings-tab--active'); + btn.setAttribute('aria-selected', 'true'); + } else { + btn.classList.remove('settings-tab--active'); + btn.setAttribute('aria-selected', 'false'); + } + }); + // Show/hide panels + document.querySelectorAll('.settings-panel').forEach(function(panel) { + if (panel.dataset.tab === tabName) { + panel.classList.remove('hidden'); + } else { + panel.classList.add('hidden'); + } + }); +} +``` + +**Step 3: Update handleGlobalKeydown for settings** + +Replace the `handleGlobalKeydown` function to add `,` shortcut and Escape for settings: + +```javascript +/** + * Global keydown handler. + * Comma opens settings. Escape closes settings or returns to grid. + * @param {KeyboardEvent} e + */ +function handleGlobalKeydown(e) { + // Settings dialog + if (_settingsOpen) { + if (e.key === 'Escape') { + e.preventDefault(); + closeSettings(); + } + return; // don't process other shortcuts while settings is open + } + + // Comma opens settings (unless typing in an input) + if (e.key === ',' && !e.ctrlKey && !e.metaKey) { + var tag = e.target && e.target.tagName; + if (tag !== 'INPUT' && tag !== 'TEXTAREA' && tag !== 'SELECT') { + e.preventDefault(); + openSettings(); + return; + } + } + + if (_viewMode === 'fullscreen') { + if (e.key === 'Escape') { + e.preventDefault(); + closeSession(); + } + } +} +``` + +**Step 4: Wire up event listeners in bindStaticEventListeners** + +Add to `bindStaticEventListeners` (after the existing bindings, before the hover preview section): + +```javascript + // Settings + on($('settings-btn'), 'click', openSettings); + on($('settings-btn-expanded'), 'click', openSettings); + on($('settings-backdrop'), 'click', closeSettings); + + // Settings dialog: close on Escape via dialog's built-in cancel event + var settingsDialog = $('settings-dialog'); + if (settingsDialog) { + settingsDialog.addEventListener('cancel', function(e) { + e.preventDefault(); + closeSettings(); + }); + } + + // Settings tab switching + document.querySelectorAll('.settings-tab').forEach(function(tab) { + on(tab, 'click', function() { + switchSettingsTab(tab.dataset.tab); + }); + }); +``` + +**Step 5: Add settings to module.exports** + +In the `module.exports` block, add: + +```javascript + // Settings + loadDisplaySettings, + saveDisplaySettings, + openSettings, + closeSettings, + switchSettingsTab, +``` + +**Step 6: Run tests** + +```bash +cd muxplex && python -m pytest muxplex/tests/ -v +``` + +Expected: All tests pass. + +**Step 7: Commit** + +```bash +git add -A && git commit -m "feat: add settings dialog open/close, tab switching, localStorage management" +``` + +--- + +### Task 8: Wire Display tab — font size, hover delay, grid columns + +**Files:** +- Modify: `muxplex/frontend/app.js` +- Modify: `muxplex/frontend/style.css` + +**Step 1: Add display settings change handlers** + +In `muxplex/frontend/app.js`, add after the `switchSettingsTab` function: + +```javascript +/** + * Apply display settings to the live UI. + * Called on page load and whenever a display setting changes. + * @param {object} ds - display settings object + */ +function applyDisplaySettings(ds) { + // Font size: update CSS custom property (grid previews use it) + document.documentElement.style.setProperty('--preview-font-size', ds.fontSize + 'px'); + + // Grid columns: set CSS custom property on session-grid + var grid = $('session-grid'); + if (grid) { + if (ds.gridColumns === 'auto') { + grid.style.removeProperty('grid-template-columns'); + } else { + grid.style.gridTemplateColumns = 'repeat(' + ds.gridColumns + ', 1fr)'; + } + } +} + +/** + * Handle changes to display setting + + + + +
+ Hidden sessions +
+
+ + + +``` + +**Step 2: Add server settings load/save functions to app.js** + +In `muxplex/frontend/app.js`, add after the `onDisplaySettingChange` function: + +```javascript +// ─── Server settings ───────────────────────────────────────────────────────── +let _serverSettings = null; + +/** + * Load server settings via GET /api/settings. + * Caches the result in _serverSettings for local reads. + * @returns {Promise} + */ +async function loadServerSettings() { + try { + var res = await api('GET', '/api/settings'); + _serverSettings = await res.json(); + } catch (err) { + console.warn('[loadServerSettings]', err); + _serverSettings = _serverSettings || {}; + } + return _serverSettings; +} + +/** + * Patch a single server setting field. + * @param {string} key + * @param {*} value + * @returns {Promise} + */ +async function patchServerSetting(key, value) { + var patch = {}; + patch[key] = value; + try { + var res = await api('PATCH', '/api/settings', patch); + _serverSettings = await res.json(); + showToast('Setting saved'); + } catch (err) { + console.warn('[patchServerSetting]', err); + showToast('Failed to save setting'); + } +} +``` + +**Step 3: Populate Sessions tab when settings opens** + +Update the `openSettings` function. After the display settings load, add server settings loading: + +```javascript + // Load server settings and populate Sessions tab + loadServerSettings().then(function(ss) { + // Default session dropdown: populate with current session list + var defaultSel = $('setting-default-session'); + if (defaultSel) { + var current = ss.default_session || ''; + defaultSel.innerHTML = '' + + _currentSessions.map(function(s) { + var name = escapeHtml(s.name); + var selected = s.name === current ? ' selected' : ''; + return ''; + }).join(''); + } + + // Sort order + var sortSel = $('setting-sort-order'); + if (sortSel) sortSel.value = ss.sort_order || 'manual'; + + // Hidden sessions checkboxes + var hiddenDiv = $('setting-hidden-sessions'); + if (hiddenDiv) { + var hiddenList = ss.hidden_sessions || []; + hiddenDiv.innerHTML = _currentSessions.map(function(s) { + var name = escapeHtml(s.name); + var checked = hiddenList.indexOf(s.name) !== -1 ? ' checked' : ''; + return ''; + }).join(''); + } + + // Checkboxes + var wslEl = $('setting-window-size-largest'); + if (wslEl) wslEl.checked = !!ss.window_size_largest; + var aoEl = $('setting-auto-open'); + if (aoEl) aoEl.checked = ss.auto_open_created !== false; + }); +``` + +**Step 4: Bind change handlers for Sessions tab in bindStaticEventListeners** + +Add to `bindStaticEventListeners`: + +```javascript + // Sessions tab change handlers + on($('setting-default-session'), 'change', function() { + patchServerSetting('default_session', this.value || null); + }); + on($('setting-sort-order'), 'change', function() { + patchServerSetting('sort_order', this.value); + }); + on($('setting-window-size-largest'), 'change', function() { + patchServerSetting('window_size_largest', this.checked); + }); + on($('setting-auto-open'), 'change', function() { + patchServerSetting('auto_open_created', this.checked); + }); + + // Hidden sessions: delegated handler on container (checkboxes are dynamic) + var hiddenContainer = $('setting-hidden-sessions'); + if (hiddenContainer) { + hiddenContainer.addEventListener('change', function() { + var checkboxes = hiddenContainer.querySelectorAll('input[type="checkbox"]'); + var hidden = []; + checkboxes.forEach(function(cb) { + if (cb.checked) hidden.push(cb.dataset.session); + }); + patchServerSetting('hidden_sessions', hidden); + }); + } +``` + +**Step 5: Add CSS for column-layout fields and checkbox list** + +Append to `muxplex/frontend/style.css`: + +```css +.settings-field--column { + flex-direction: column; + align-items: flex-start; + gap: 8px; +} + +.settings-checkbox-list { + display: flex; + flex-direction: column; + gap: 6px; + padding-left: 4px; +} + +.settings-checkbox-item { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: var(--text); + cursor: pointer; +} + +.settings-checkbox { + accent-color: var(--accent); + cursor: pointer; +} +``` + +**Step 6: Add exports** + +Add to `module.exports`: + +```javascript + loadServerSettings, + patchServerSetting, +``` + +**Step 7: Run tests** + +```bash +cd muxplex && python -m pytest muxplex/tests/ -v +``` + +Expected: All tests pass. + +**Step 8: Commit** + +```bash +git add -A && git commit -m "feat: add Sessions settings tab with server-side persistence" +``` + +--- + +### Task 2: Notifications tab + +**Files:** +- Modify: `muxplex/frontend/index.html` +- Modify: `muxplex/frontend/app.js` +- Modify: `muxplex/frontend/style.css` + +**Step 1: Add Notifications tab HTML** + +In `muxplex/frontend/index.html`, replace the empty `#settings-panel-notifications` div: + +```html + + +``` + +**Step 2: Add CSS for notification status and action button** + +Append to `muxplex/frontend/style.css`: + +```css +.settings-notification-status { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 6px; +} + +.settings-status-text { + font-size: 12px; + color: var(--text-muted); +} + +.settings-action-btn { + background: var(--bg); + border: 1px solid var(--border); + border-radius: 4px; + color: var(--text); + font-size: 12px; + font-family: var(--font-ui); + padding: 6px 12px; + cursor: pointer; + transition: border-color var(--t-fast); +} + +.settings-action-btn:hover { + border-color: var(--accent); +} + +.settings-action-btn:disabled { + opacity: 0.5; + cursor: default; +} +``` + +**Step 3: Add notification handlers to app.js** + +Update `openSettings` to populate notification controls (add inside the function): + +```javascript + // Notifications tab + var ds2 = loadDisplaySettings(); + var bellSoundEl = $('setting-bell-sound'); + if (bellSoundEl) bellSoundEl.checked = !!ds2.bellSound; + + var notifText = $('notification-status-text'); + var notifBtn = $('notification-request-btn'); + if (notifText && notifBtn) { + var perm = typeof Notification !== 'undefined' ? Notification.permission : 'unavailable'; + if (perm === 'granted') { + notifText.textContent = 'Granted'; + notifBtn.textContent = 'Granted'; + notifBtn.disabled = true; + } else if (perm === 'denied') { + notifText.textContent = 'Denied (change in browser settings)'; + notifBtn.textContent = 'Denied'; + notifBtn.disabled = true; + } else if (perm === 'unavailable') { + notifText.textContent = 'Not supported'; + notifBtn.disabled = true; + } else { + notifText.textContent = 'Not requested'; + notifBtn.textContent = 'Request permission'; + notifBtn.disabled = false; + } + } +``` + +**Step 4: Bind notification change handlers in bindStaticEventListeners** + +Add to `bindStaticEventListeners`: + +```javascript + // Notifications tab + on($('setting-bell-sound'), 'change', function() { + var ds = loadDisplaySettings(); + ds.bellSound = this.checked; + saveDisplaySettings(ds); + }); + + on($('notification-request-btn'), 'click', function() { + if (typeof Notification === 'undefined') return; + Notification.requestPermission().then(function(perm) { + _notificationPermission = perm; + var ds = loadDisplaySettings(); + ds.notificationPermission = perm; + saveDisplaySettings(ds); + // Update UI + var notifText = $('notification-status-text'); + var notifBtn = $('notification-request-btn'); + if (perm === 'granted') { + if (notifText) notifText.textContent = 'Granted'; + if (notifBtn) { notifBtn.textContent = 'Granted'; notifBtn.disabled = true; } + } else if (perm === 'denied') { + if (notifText) notifText.textContent = 'Denied (change in browser settings)'; + if (notifBtn) { notifBtn.textContent = 'Denied'; notifBtn.disabled = true; } + } + }); + }); +``` + +**Step 5: Run tests** + +```bash +cd muxplex && python -m pytest muxplex/tests/ -v +``` + +Expected: All tests pass. + +**Step 6: Commit** + +```bash +git add -A && git commit -m "feat: add Notifications settings tab" +``` + +--- + +### Task 3: New Session tab — template textarea + +**Files:** +- Modify: `muxplex/frontend/index.html` +- Modify: `muxplex/frontend/app.js` +- Modify: `muxplex/frontend/style.css` + +**Step 1: Add New Session tab HTML** + +In `muxplex/frontend/index.html`, replace the empty `#settings-panel-new-session` div: + +```html + + +``` + +**Step 2: Add CSS for textarea and helper text** + +Append to `muxplex/frontend/style.css`: + +```css +.settings-textarea { + width: 100%; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 4px; + color: var(--text); + font-size: 13px; + font-family: var(--font-mono); + padding: 10px; + resize: vertical; + min-height: 60px; +} + +.settings-textarea:focus { + outline: 1px solid var(--accent); + border-color: var(--accent); +} + +.settings-helper { + font-size: 12px; + color: var(--text-muted); + font-style: italic; +} +``` + +**Step 3: Populate template on settings open** + +Inside `openSettings`, in the `loadServerSettings().then(...)` callback, add: + +```javascript + // New Session tab + var templateEl = $('setting-template'); + if (templateEl) templateEl.value = ss.new_session_template || 'tmux new-session -d -s {name}'; +``` + +**Step 4: Bind template change handlers in bindStaticEventListeners** + +Add to `bindStaticEventListeners`: + +```javascript + // New Session tab: save template on blur (not every keystroke) + var templateEl = $('setting-template'); + if (templateEl) { + var _templateDebounce = null; + templateEl.addEventListener('input', function() { + clearTimeout(_templateDebounce); + _templateDebounce = setTimeout(function() { + patchServerSetting('new_session_template', templateEl.value); + }, 500); + }); + } + + on($('setting-template-reset'), 'click', function() { + var templateEl = $('setting-template'); + if (templateEl) templateEl.value = 'tmux new-session -d -s {name}'; + patchServerSetting('new_session_template', 'tmux new-session -d -s {name}'); + }); +``` + +**Step 5: Run tests** + +```bash +cd muxplex && python -m pytest muxplex/tests/ -v +``` + +Expected: All tests pass. + +**Step 6: Commit** + +```bash +git add -A && git commit -m "feat: add New Session settings tab with template textarea" +``` + +--- + +### Task 4: Header `+` button with inline name input + +**Files:** +- Modify: `muxplex/frontend/app.js` +- Modify: `muxplex/frontend/style.css` + +**Step 1: Add new session creation function** + +In `muxplex/frontend/app.js`, add after the server settings functions: + +```javascript +// ─── New session creation ──────────────────────────────────────────────────── + +/** + * Show an inline name input replacing the given button element. + * On Enter: call createNewSession(). On Escape: cancel and restore button. + * @param {HTMLElement} btn - the button to replace with input + */ +function showNewSessionInput(btn) { + if (!btn) return; + var container = btn.parentElement; + if (!container) return; + + // Create inline input + var input = document.createElement('input'); + input.type = 'text'; + input.className = 'new-session-input'; + input.placeholder = 'Session name\u2026'; + input.setAttribute('autocomplete', 'off'); + input.setAttribute('spellcheck', 'false'); + + // Replace button with input + btn.style.display = 'none'; + container.insertBefore(input, btn); + input.focus(); + + function cleanup() { + input.remove(); + btn.style.display = ''; + } + + input.addEventListener('keydown', function(e) { + if (e.key === 'Enter') { + e.preventDefault(); + var name = input.value.trim(); + if (name) { + cleanup(); + createNewSession(name); + } + } else if (e.key === 'Escape') { + e.preventDefault(); + cleanup(); + } + }); + + // Cancel on blur (click away) + input.addEventListener('blur', function() { + // Delay to allow click handlers to fire first + setTimeout(cleanup, 150); + }); +} + +/** + * Create a new session by POSTing to /api/sessions. + * Shows toast, triggers poll refresh, auto-opens if setting enabled. + * @param {string} name + * @returns {Promise} + */ +async function createNewSession(name) { + try { + var res = await api('POST', '/api/sessions', { name: name }); + var data = await res.json(); + showToast("Session '" + data.name + "' created"); + + // Trigger immediate poll refresh to pick up the new session + await pollSessions(); + + // Auto-open if setting enabled + var ss = _serverSettings || await loadServerSettings(); + if (ss.auto_open_created !== false) { + // Small delay to let tmux create the session + setTimeout(function() { + openSession(data.name); + }, 500); + } + } catch (err) { + showToast(err.message || 'Failed to create session'); + } +} +``` + +**Step 2: Add CSS for inline input** + +Append to `muxplex/frontend/style.css`: + +```css +/* ============================================================ + New session inline input + ============================================================ */ + +.new-session-input { + background: var(--bg); + border: 1px solid var(--accent); + border-radius: 4px; + color: var(--text); + font-size: 13px; + font-family: var(--font-ui); + padding: 4px 10px; + width: 180px; + outline: none; +} + +.new-session-input::placeholder { + color: var(--text-dim); +} +``` + +**Step 3: Bind the header `+` button** + +Add to `bindStaticEventListeners`: + +```javascript + // New session: header + button + on($('new-session-btn'), 'click', function() { + showNewSessionInput($('new-session-btn')); + }); +``` + +**Step 4: Add exports** + +Add to `module.exports`: + +```javascript + showNewSessionInput, + createNewSession, +``` + +**Step 5: Run tests** + +```bash +cd muxplex && python -m pytest muxplex/tests/ -v +``` + +Expected: All tests pass. + +**Step 6: Commit** + +```bash +git add -A && git commit -m "feat: add header + button with inline name input for session creation" +``` + +--- + +### Task 5: Sidebar `+ New` sticky footer + +**Files:** +- Modify: `muxplex/frontend/index.html` +- Modify: `muxplex/frontend/app.js` +- Modify: `muxplex/frontend/style.css` + +**Step 1: Add sidebar footer HTML** + +In `muxplex/frontend/index.html`, add a footer inside the `#session-sidebar` div, after `#sidebar-list`: + +```html +
+ + + +
+``` + +**Step 2: Add sidebar footer CSS** + +Append to `muxplex/frontend/style.css`: + +```css +.sidebar-footer { + padding: 8px; + border-top: 1px solid var(--border-subtle); + flex-shrink: 0; +} + +.sidebar-new-btn { + width: 100%; + background: none; + border: 1px dashed var(--border); + border-radius: 4px; + color: var(--text-dim); + font-size: 12px; + font-family: var(--font-ui); + padding: 8px; + cursor: pointer; + transition: color var(--t-fast), border-color var(--t-fast); +} + +.sidebar-new-btn:hover { + color: var(--text); + border-color: var(--text-muted); +} +``` + +**Step 3: Bind sidebar button** + +Add to `bindStaticEventListeners`: + +```javascript + // New session: sidebar footer button + on($('sidebar-new-session-btn'), 'click', function() { + showNewSessionInput($('sidebar-new-session-btn')); + }); +``` + +**Step 4: Run tests** + +```bash +cd muxplex && python -m pytest muxplex/tests/ -v +``` + +Expected: All tests pass. + +**Step 5: Commit** + +```bash +git add -A && git commit -m "feat: add sidebar + New sticky footer with inline input" +``` + +--- + +### Task 6: Mobile FAB + +**Files:** +- Modify: `muxplex/frontend/index.html` +- Modify: `muxplex/frontend/app.js` +- Modify: `muxplex/frontend/style.css` + +**Step 1: Add FAB HTML** + +In `muxplex/frontend/index.html`, add before the toast element: + +```html + + +``` + +**Step 2: Add FAB CSS** + +Append to `muxplex/frontend/style.css`: + +```css +/* ============================================================ + Mobile FAB (new session) + ============================================================ */ + +.new-session-fab { + display: none; /* hidden by default, shown on mobile */ + position: fixed; + bottom: 16px; + right: 16px; + width: 56px; + height: 56px; + border-radius: 50%; + background: var(--accent); + color: var(--bg); + border: none; + font-size: 28px; + font-weight: 300; + line-height: 1; + cursor: pointer; + z-index: 100; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); + transition: transform var(--t-fast), box-shadow var(--t-fast); +} + +.new-session-fab:active { + transform: scale(0.95); +} + +@media (max-width: 959px) { + .new-session-fab { + display: flex; + align-items: center; + justify-content: center; + } + + /* Hide the header + button on mobile since FAB replaces it */ + #new-session-btn { + display: none; + } +} +``` + +**Step 3: Bind FAB click** + +Add to `bindStaticEventListeners`: + +```javascript + // New session: mobile FAB + on($('new-session-fab'), 'click', function() { + showNewSessionInput($('new-session-fab')); + }); +``` + +**Step 4: Hide FAB when in fullscreen view** + +In `openSession`, add after switching views: + +```javascript + var fab = $('new-session-fab'); + if (fab) fab.classList.add('hidden'); +``` + +In `closeSession`, restore the FAB: + +```javascript + var fab = $('new-session-fab'); + if (fab) fab.classList.remove('hidden'); +``` + +**Step 5: Run tests** + +```bash +cd muxplex && python -m pytest muxplex/tests/ -v +``` + +Expected: All tests pass. + +**Step 6: Commit** + +```bash +git add -A && git commit -m "feat: add mobile FAB for new session creation" +``` + +--- + +### Task 7: Apply settings effects — font size, grid columns, hover delay, hidden sessions, sort order + +**Files:** +- Modify: `muxplex/frontend/app.js` +- Modify: `muxplex/frontend/terminal.js` + +**Step 1: Font size affects terminal.js** + +In `muxplex/frontend/terminal.js`, update the `createTerminal` function to read font size from localStorage: + +Replace the hardcoded fontSize in `createTerminal()`: + +```javascript + // Read font size from display settings (localStorage) + var storedFontSize = 14; + try { + var raw = localStorage.getItem('muxplex.display'); + if (raw) { + var parsed = JSON.parse(raw); + if (parsed.fontSize) storedFontSize = parsed.fontSize; + } + } catch (_) {} + + const mobile = window.innerWidth < 600; + + _term = new window.Terminal({ + cursorBlink: true, + fontSize: mobile ? Math.min(storedFontSize, 12) : storedFontSize, + fontFamily: "'SF Mono', 'Fira Code', Consolas, monospace", + // ... rest unchanged + }); +``` + +**Step 2: Hidden sessions filter in renderGrid** + +In `muxplex/frontend/app.js`, update `renderGrid` to filter hidden sessions. At the top of the function, before computing `ordered`: + +```javascript + // Filter hidden sessions (server settings) + var hidden = (_serverSettings && _serverSettings.hidden_sessions) || []; + var visible = sessions; + if (hidden.length > 0) { + visible = sessions.filter(function(s) { + return hidden.indexOf(s.name) === -1; + }); + } +``` + +Then use `visible` instead of `sessions` for the rest of the function. Update the empty-state check and the `ordered` computation: + +```javascript + if (!visible || visible.length === 0) { + if (grid) grid.innerHTML = ''; + if (emptyState) emptyState.classList.remove('hidden'); + return; + } + + if (emptyState) emptyState.classList.add('hidden'); + + const mobile = isMobile(); + const ordered = mobile ? sortByPriority(visible) : visible; +``` + +**Step 3: Sort order support in renderGrid** + +Update `renderGrid` to apply sort order from server settings. After the hidden filter, before the mobile check: + +```javascript + // Apply sort order from server settings + var sortOrder = (_serverSettings && _serverSettings.sort_order) || 'manual'; + if (sortOrder === 'alphabetical') { + visible = visible.slice().sort(function(a, b) { + return (a.name || '').localeCompare(b.name || ''); + }); + } + // 'recent' and 'manual' use the server-provided order (which is already the default) +``` + +**Step 4: Hidden sessions filter in renderSidebar** + +In `renderSidebar`, apply the same hidden filter: + +```javascript + var hidden = (_serverSettings && _serverSettings.hidden_sessions) || []; + var visible = sessions; + if (hidden.length > 0) { + visible = sessions.filter(function(s) { + return hidden.indexOf(s.name) === -1; + }); + } +``` + +Use `visible` instead of `sessions` for the sidebar rendering. + +**Step 5: Load server settings on page init** + +In the `DOMContentLoaded` handler, after `startPolling()`, add a call to load server settings: + +```javascript + restoreState() + .then(() => { + startPolling(); + startHeartbeat(); + requestNotificationPermission(); + loadServerSettings(); // <-- add this line + bindStaticEventListeners(); + }) +``` + +**Step 6: Run tests** + +```bash +cd muxplex && python -m pytest muxplex/tests/ -v +``` + +Expected: All tests pass. + +**Step 7: Commit** + +```bash +git add -A && git commit -m "feat: apply settings effects — font size, grid columns, hidden sessions, sort order" +``` + +--- + +### Task 8: Frontend tests for settings + +**Files:** +- Modify: `muxplex/tests/test_frontend_html.py` +- Modify: `muxplex/tests/test_frontend_css.py` + +**Step 1: Add HTML tests for settings elements** + +Add to `muxplex/tests/test_frontend_html.py`: + +```python +def test_html_settings_dialog() -> None: + """id=settings-dialog, settings-backdrop, settings-btn.""" + soup = _SOUP + for id_ in ("settings-dialog", "settings-backdrop", "settings-btn"): + assert soup.find(id=id_), f"Missing element with id='{id_}'" + + +def test_html_settings_tabs() -> None: + """Settings dialog contains four tab buttons: display, sessions, notifications, new-session.""" + soup = _SOUP + tabs = soup.select(".settings-tab") + tab_names = [t.get("data-tab") for t in tabs] + assert "display" in tab_names + assert "sessions" in tab_names + assert "notifications" in tab_names + assert "new-session" in tab_names + + +def test_html_display_tab_controls() -> None: + """Display tab has font-size, hover-delay, and grid-columns selects.""" + soup = _SOUP + for id_ in ("setting-font-size", "setting-hover-delay", "setting-grid-columns"): + assert soup.find(id=id_), f"Missing element with id='{id_}'" + + +def test_html_new_session_btn() -> None: + """id=new-session-btn, new-session-fab exist.""" + soup = _SOUP + assert soup.find(id="new-session-btn"), "Missing element with id='new-session-btn'" + assert soup.find(id="new-session-fab"), "Missing element with id='new-session-fab'" + + +def test_html_sidebar_new_session_btn() -> None: + """id=sidebar-new-session-btn exists inside the sidebar.""" + soup = _SOUP + assert soup.find(id="sidebar-new-session-btn"), "Missing element with id='sidebar-new-session-btn'" + + +def test_html_sessions_tab_controls() -> None: + """Sessions tab has default-session, sort-order selects and checkboxes.""" + soup = _SOUP + for id_ in ("setting-default-session", "setting-sort-order", "setting-window-size-largest", "setting-auto-open"): + assert soup.find(id=id_), f"Missing element with id='{id_}'" + + +def test_html_notifications_tab_controls() -> None: + """Notifications tab has bell-sound checkbox and permission button.""" + soup = _SOUP + assert soup.find(id="setting-bell-sound"), "Missing id='setting-bell-sound'" + assert soup.find(id="notification-request-btn"), "Missing id='notification-request-btn'" + + +def test_html_new_session_tab_controls() -> None: + """New Session tab has template textarea and reset button.""" + soup = _SOUP + assert soup.find(id="setting-template"), "Missing id='setting-template'" + assert soup.find(id="setting-template-reset"), "Missing id='setting-template-reset'" +``` + +**Step 2: Add CSS tests for settings styles** + +Add to `muxplex/tests/test_frontend_css.py`: + +```python +def test_css_settings_dialog(): + css = read_css() + assert ".settings-dialog" in css + assert ".settings-tabs" in css + assert ".settings-tab--active" in css + assert ".settings-content" in css + assert ".settings-field" in css + assert ".settings-select" in css + + +def test_css_header_btn(): + css = read_css() + assert ".header-btn" in css + assert ".header-actions" in css + + +def test_css_new_session_fab(): + css = read_css() + assert ".new-session-fab" in css + + +def test_css_new_session_input(): + css = read_css() + assert ".new-session-input" in css + + +def test_css_settings_textarea(): + css = read_css() + assert ".settings-textarea" in css + + +def test_css_sidebar_footer(): + css = read_css() + assert ".sidebar-footer" in css + assert ".sidebar-new-btn" in css +``` + +**Step 3: Run all tests** + +```bash +cd muxplex && python -m pytest muxplex/tests/ -v +``` + +Expected: All tests pass (both old and new). + +**Step 4: Commit** + +```bash +git add -A && git commit -m "test: add frontend tests for settings dialog and new session UI" +``` + +--- + +## Phase 2 Complete + +After completing all 8 tasks, the full settings feature is done: + +1. **Sessions tab** — default session, sort order, hidden sessions, window-size largest, auto-open +2. **Notifications tab** — bell sound toggle, desktop notification permission button +3. **New Session tab** — command template textarea with reset button +4. **Header `+` button** — inline name input, creates session via API +5. **Sidebar `+ New`** — sticky footer with same inline input +6. **Mobile FAB** — 56px floating action button, <960px only +7. **Settings effects applied** — font size, grid columns, hover delay, hidden sessions, sort order +8. **Frontend tests** — HTML structure and CSS coverage for all new elements + +### Open questions (from design doc — decide during implementation): +1. Hidden sessions: completely removed from grid/sidebar (current implementation) +2. Session name validation: accepts anything the user types (tmux will reject invalid names) +3. Settings pre-auth: not accessible from login page (v1) \ No newline at end of file diff --git a/muxplex/__pycache__/__init__.cpython-312.pyc b/muxplex/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..55769f6 Binary files /dev/null and b/muxplex/__pycache__/__init__.cpython-312.pyc differ diff --git a/muxplex/__pycache__/__init__.cpython-313.pyc b/muxplex/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..9d5c1f1 Binary files /dev/null and b/muxplex/__pycache__/__init__.cpython-313.pyc differ diff --git a/muxplex/__pycache__/__main__.cpython-312.pyc b/muxplex/__pycache__/__main__.cpython-312.pyc new file mode 100644 index 0000000..08d5634 Binary files /dev/null and b/muxplex/__pycache__/__main__.cpython-312.pyc differ diff --git a/muxplex/__pycache__/auth.cpython-312.pyc b/muxplex/__pycache__/auth.cpython-312.pyc new file mode 100644 index 0000000..852348c Binary files /dev/null and b/muxplex/__pycache__/auth.cpython-312.pyc differ diff --git a/muxplex/__pycache__/auth.cpython-313.pyc b/muxplex/__pycache__/auth.cpython-313.pyc new file mode 100644 index 0000000..d16c0e4 Binary files /dev/null and b/muxplex/__pycache__/auth.cpython-313.pyc differ diff --git a/muxplex/__pycache__/bells.cpython-312.pyc b/muxplex/__pycache__/bells.cpython-312.pyc new file mode 100644 index 0000000..452fbd4 Binary files /dev/null and b/muxplex/__pycache__/bells.cpython-312.pyc differ diff --git a/muxplex/__pycache__/bells.cpython-313.pyc b/muxplex/__pycache__/bells.cpython-313.pyc new file mode 100644 index 0000000..b45198d Binary files /dev/null and b/muxplex/__pycache__/bells.cpython-313.pyc differ diff --git a/muxplex/__pycache__/cli.cpython-312.pyc b/muxplex/__pycache__/cli.cpython-312.pyc new file mode 100644 index 0000000..3b969f4 Binary files /dev/null and b/muxplex/__pycache__/cli.cpython-312.pyc differ diff --git a/muxplex/__pycache__/cli.cpython-313.pyc b/muxplex/__pycache__/cli.cpython-313.pyc new file mode 100644 index 0000000..dfe2d32 Binary files /dev/null and b/muxplex/__pycache__/cli.cpython-313.pyc differ diff --git a/muxplex/__pycache__/main.cpython-312.pyc b/muxplex/__pycache__/main.cpython-312.pyc new file mode 100644 index 0000000..1ebf0a8 Binary files /dev/null and b/muxplex/__pycache__/main.cpython-312.pyc differ diff --git a/muxplex/__pycache__/main.cpython-313.pyc b/muxplex/__pycache__/main.cpython-313.pyc new file mode 100644 index 0000000..41b833c Binary files /dev/null and b/muxplex/__pycache__/main.cpython-313.pyc differ diff --git a/muxplex/__pycache__/sessions.cpython-312.pyc b/muxplex/__pycache__/sessions.cpython-312.pyc new file mode 100644 index 0000000..255d415 Binary files /dev/null and b/muxplex/__pycache__/sessions.cpython-312.pyc differ diff --git a/muxplex/__pycache__/sessions.cpython-313.pyc b/muxplex/__pycache__/sessions.cpython-313.pyc new file mode 100644 index 0000000..a60a228 Binary files /dev/null and b/muxplex/__pycache__/sessions.cpython-313.pyc differ diff --git a/muxplex/__pycache__/state.cpython-312.pyc b/muxplex/__pycache__/state.cpython-312.pyc new file mode 100644 index 0000000..519db49 Binary files /dev/null and b/muxplex/__pycache__/state.cpython-312.pyc differ diff --git a/muxplex/__pycache__/state.cpython-313.pyc b/muxplex/__pycache__/state.cpython-313.pyc new file mode 100644 index 0000000..9bc1f11 Binary files /dev/null and b/muxplex/__pycache__/state.cpython-313.pyc differ diff --git a/muxplex/__pycache__/ttyd.cpython-312.pyc b/muxplex/__pycache__/ttyd.cpython-312.pyc new file mode 100644 index 0000000..bc1047d Binary files /dev/null and b/muxplex/__pycache__/ttyd.cpython-312.pyc differ diff --git a/muxplex/__pycache__/ttyd.cpython-313.pyc b/muxplex/__pycache__/ttyd.cpython-313.pyc new file mode 100644 index 0000000..aeba78f Binary files /dev/null and b/muxplex/__pycache__/ttyd.cpython-313.pyc differ diff --git a/muxplex/frontend/index.html b/muxplex/frontend/index.html index eb98212..5641ad0 100644 --- a/muxplex/frontend/index.html +++ b/muxplex/frontend/index.html @@ -32,7 +32,6 @@ -
@@ -47,13 +46,7 @@
- - diff --git a/muxplex/frontend/style.css b/muxplex/frontend/style.css index bfbc857..c50ad97 100644 --- a/muxplex/frontend/style.css +++ b/muxplex/frontend/style.css @@ -353,15 +353,6 @@ body { white-space: nowrap; } -.palette-trigger { - background: none; - border: 1px solid var(--border); - border-radius: 4px; - color: var(--text-dim); - font-size: 12px; - padding: 4px 10px; - cursor: pointer; -} .view-body { display: flex; @@ -687,95 +678,6 @@ body { } } -/* ============================================================ - Command palette overlay (desktop session switching) - ============================================================ */ - -.command-palette { - position: fixed; - inset: 0; - z-index: 200; - display: flex; - align-items: flex-start; - justify-content: center; - padding-top: 15vh; -} - -.command-palette__backdrop { - position: absolute; - inset: 0; - background: var(--bg-overlay); - backdrop-filter: blur(2px); -} - -.command-palette__dialog { - position: relative; - width: min(440px, 90vw); - background: var(--bg-header); - border: 1px solid var(--border); - border-radius: 8px; - overflow: hidden; - box-shadow: 0 16px 40px rgba(0, 0, 0, 0.5); -} - -.command-palette__input { - width: 100%; - padding: 14px 16px; - background: transparent; - border: none; - border-bottom: 1px solid var(--border); - color: var(--text); - font-size: 14px; - font-family: var(--font-ui); - outline: none; -} - -.command-palette__input::placeholder { - color: var(--text-dim); -} - -.command-palette__list { - list-style: none; - max-height: 320px; - overflow-y: auto; -} - -.palette-item { - display: flex; - align-items: center; - gap: 10px; - padding: 10px 16px; - cursor: pointer; - font-size: 13px; - color: var(--text-muted); - transition: background var(--t-fast); -} - -.palette-item:hover, -.palette-item--selected { - background: var(--accent-dim); - color: var(--text); -} - -.palette-item__index { - font-size: 11px; - color: var(--text-dim); - width: 16px; -} - -.palette-item__name { - flex: 1; -} - -.palette-item__bell { - color: var(--bell); - font-size: 11px; -} - -.palette-item__time { - font-size: 11px; - color: var(--text-dim); -} /* ─── Mobile Bottom Sheet ─────────────────────────────────────── */ diff --git a/muxplex/tests/__pycache__/__init__.cpython-312.pyc b/muxplex/tests/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..d78fbbb Binary files /dev/null and b/muxplex/tests/__pycache__/__init__.cpython-312.pyc differ diff --git a/muxplex/tests/__pycache__/__init__.cpython-313.pyc b/muxplex/tests/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..aafcf94 Binary files /dev/null and b/muxplex/tests/__pycache__/__init__.cpython-313.pyc differ diff --git a/muxplex/tests/__pycache__/test_api.cpython-312-pytest-9.0.2.pyc b/muxplex/tests/__pycache__/test_api.cpython-312-pytest-9.0.2.pyc new file mode 100644 index 0000000..23862f0 Binary files /dev/null and b/muxplex/tests/__pycache__/test_api.cpython-312-pytest-9.0.2.pyc differ diff --git a/muxplex/tests/__pycache__/test_api.cpython-313-pytest-9.0.2.pyc b/muxplex/tests/__pycache__/test_api.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000..99d03c0 Binary files /dev/null and b/muxplex/tests/__pycache__/test_api.cpython-313-pytest-9.0.2.pyc differ diff --git a/muxplex/tests/__pycache__/test_auth.cpython-312-pytest-9.0.2.pyc b/muxplex/tests/__pycache__/test_auth.cpython-312-pytest-9.0.2.pyc new file mode 100644 index 0000000..d661c30 Binary files /dev/null and b/muxplex/tests/__pycache__/test_auth.cpython-312-pytest-9.0.2.pyc differ diff --git a/muxplex/tests/__pycache__/test_auth.cpython-313-pytest-9.0.2.pyc b/muxplex/tests/__pycache__/test_auth.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000..db2d8bb Binary files /dev/null and b/muxplex/tests/__pycache__/test_auth.cpython-313-pytest-9.0.2.pyc differ diff --git a/muxplex/tests/__pycache__/test_bells.cpython-312-pytest-9.0.2.pyc b/muxplex/tests/__pycache__/test_bells.cpython-312-pytest-9.0.2.pyc new file mode 100644 index 0000000..93960cc Binary files /dev/null and b/muxplex/tests/__pycache__/test_bells.cpython-312-pytest-9.0.2.pyc differ diff --git a/muxplex/tests/__pycache__/test_bells.cpython-313-pytest-9.0.2.pyc b/muxplex/tests/__pycache__/test_bells.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000..9ec9567 Binary files /dev/null and b/muxplex/tests/__pycache__/test_bells.cpython-313-pytest-9.0.2.pyc differ diff --git a/muxplex/tests/__pycache__/test_cli.cpython-312-pytest-9.0.2.pyc b/muxplex/tests/__pycache__/test_cli.cpython-312-pytest-9.0.2.pyc new file mode 100644 index 0000000..e919c66 Binary files /dev/null and b/muxplex/tests/__pycache__/test_cli.cpython-312-pytest-9.0.2.pyc differ diff --git a/muxplex/tests/__pycache__/test_cli.cpython-313-pytest-9.0.2.pyc b/muxplex/tests/__pycache__/test_cli.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000..ab51c7d Binary files /dev/null and b/muxplex/tests/__pycache__/test_cli.cpython-313-pytest-9.0.2.pyc differ diff --git a/muxplex/tests/__pycache__/test_frontend_css.cpython-312-pytest-9.0.2.pyc b/muxplex/tests/__pycache__/test_frontend_css.cpython-312-pytest-9.0.2.pyc new file mode 100644 index 0000000..babd9b7 Binary files /dev/null and b/muxplex/tests/__pycache__/test_frontend_css.cpython-312-pytest-9.0.2.pyc differ diff --git a/muxplex/tests/__pycache__/test_frontend_css.cpython-313-pytest-9.0.2.pyc b/muxplex/tests/__pycache__/test_frontend_css.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000..0578297 Binary files /dev/null and b/muxplex/tests/__pycache__/test_frontend_css.cpython-313-pytest-9.0.2.pyc differ diff --git a/muxplex/tests/__pycache__/test_frontend_html.cpython-312-pytest-9.0.2.pyc b/muxplex/tests/__pycache__/test_frontend_html.cpython-312-pytest-9.0.2.pyc new file mode 100644 index 0000000..a9bb407 Binary files /dev/null and b/muxplex/tests/__pycache__/test_frontend_html.cpython-312-pytest-9.0.2.pyc differ diff --git a/muxplex/tests/__pycache__/test_frontend_html.cpython-313-pytest-9.0.2.pyc b/muxplex/tests/__pycache__/test_frontend_html.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000..c0f7d16 Binary files /dev/null and b/muxplex/tests/__pycache__/test_frontend_html.cpython-313-pytest-9.0.2.pyc differ diff --git a/muxplex/tests/__pycache__/test_integration.cpython-312-pytest-9.0.2.pyc b/muxplex/tests/__pycache__/test_integration.cpython-312-pytest-9.0.2.pyc new file mode 100644 index 0000000..767907b Binary files /dev/null and b/muxplex/tests/__pycache__/test_integration.cpython-312-pytest-9.0.2.pyc differ diff --git a/muxplex/tests/__pycache__/test_integration.cpython-313-pytest-9.0.2.pyc b/muxplex/tests/__pycache__/test_integration.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000..23d2c1f Binary files /dev/null and b/muxplex/tests/__pycache__/test_integration.cpython-313-pytest-9.0.2.pyc differ diff --git a/muxplex/tests/__pycache__/test_sessions.cpython-312-pytest-9.0.2.pyc b/muxplex/tests/__pycache__/test_sessions.cpython-312-pytest-9.0.2.pyc new file mode 100644 index 0000000..24149c5 Binary files /dev/null and b/muxplex/tests/__pycache__/test_sessions.cpython-312-pytest-9.0.2.pyc differ diff --git a/muxplex/tests/__pycache__/test_sessions.cpython-313-pytest-9.0.2.pyc b/muxplex/tests/__pycache__/test_sessions.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000..4838237 Binary files /dev/null and b/muxplex/tests/__pycache__/test_sessions.cpython-313-pytest-9.0.2.pyc differ diff --git a/muxplex/tests/__pycache__/test_state.cpython-312-pytest-9.0.2.pyc b/muxplex/tests/__pycache__/test_state.cpython-312-pytest-9.0.2.pyc new file mode 100644 index 0000000..870950b Binary files /dev/null and b/muxplex/tests/__pycache__/test_state.cpython-312-pytest-9.0.2.pyc differ diff --git a/muxplex/tests/__pycache__/test_state.cpython-313-pytest-9.0.2.pyc b/muxplex/tests/__pycache__/test_state.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000..e198dd1 Binary files /dev/null and b/muxplex/tests/__pycache__/test_state.cpython-313-pytest-9.0.2.pyc differ diff --git a/muxplex/tests/__pycache__/test_ttyd.cpython-312-pytest-9.0.2.pyc b/muxplex/tests/__pycache__/test_ttyd.cpython-312-pytest-9.0.2.pyc new file mode 100644 index 0000000..db1af2d Binary files /dev/null and b/muxplex/tests/__pycache__/test_ttyd.cpython-312-pytest-9.0.2.pyc differ diff --git a/muxplex/tests/__pycache__/test_ttyd.cpython-313-pytest-9.0.2.pyc b/muxplex/tests/__pycache__/test_ttyd.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000..dfcae7e Binary files /dev/null and b/muxplex/tests/__pycache__/test_ttyd.cpython-313-pytest-9.0.2.pyc differ diff --git a/muxplex/tests/__pycache__/test_ws_proxy.cpython-312-pytest-9.0.2.pyc b/muxplex/tests/__pycache__/test_ws_proxy.cpython-312-pytest-9.0.2.pyc new file mode 100644 index 0000000..a27d983 Binary files /dev/null and b/muxplex/tests/__pycache__/test_ws_proxy.cpython-312-pytest-9.0.2.pyc differ diff --git a/muxplex/tests/__pycache__/test_ws_proxy.cpython-313-pytest-9.0.2.pyc b/muxplex/tests/__pycache__/test_ws_proxy.cpython-313-pytest-9.0.2.pyc new file mode 100644 index 0000000..12d7aac Binary files /dev/null and b/muxplex/tests/__pycache__/test_ws_proxy.cpython-313-pytest-9.0.2.pyc differ diff --git a/muxplex/tests/test_frontend_css.py b/muxplex/tests/test_frontend_css.py index 49af60a..762ac97 100644 --- a/muxplex/tests/test_frontend_css.py +++ b/muxplex/tests/test_frontend_css.py @@ -66,14 +66,6 @@ def test_css_mobile_tiers(): assert "session-tile--tier-idle" in css -def test_css_command_palette(): - css = read_css() - assert ".command-palette__dialog" in css - assert ".command-palette__input" in css - assert ".palette-item" in css - assert ".palette-item--selected" in css - - def test_css_bottom_sheet(): css = read_css() assert ".bottom-sheet__panel" in css diff --git a/muxplex/tests/test_frontend_html.py b/muxplex/tests/test_frontend_html.py index 1a1fe4d..9706f21 100644 --- a/muxplex/tests/test_frontend_html.py +++ b/muxplex/tests/test_frontend_html.py @@ -58,24 +58,16 @@ def test_html_required_views() -> None: def test_html_expanded_view_elements() -> None: - """id=back-btn, expanded-session-name, palette-trigger, reconnect-overlay.""" + """id=back-btn, expanded-session-name, reconnect-overlay.""" soup = _SOUP for id_ in ( "back-btn", "expanded-session-name", - "palette-trigger", "reconnect-overlay", ): assert soup.find(id=id_), f"Missing element with id='{id_}'" -def test_html_command_palette() -> None: - """id=command-palette, palette-input, palette-list, palette-backdrop.""" - soup = _SOUP - for id_ in ("command-palette", "palette-input", "palette-list", "palette-backdrop"): - assert soup.find(id=id_), f"Missing element with id='{id_}'" - - def test_html_bottom_sheet() -> None: """id=bottom-sheet, sheet-list, sheet-backdrop, session-pill, session-pill-label, session-pill-bell.""" soup = _SOUP @@ -240,7 +232,6 @@ def test_html_element_classes() -> None: ("session-pill", "session-pill", "needs position:fixed to float"), ("toast", "toast", "needs position:fixed and animation"), ("back-btn", "back-btn", "needs border and hover styles"), - ("palette-trigger", "palette-trigger", "needs border and hover styles"), ( "expanded-session-name", "expanded-session-name",