Commit Graph

450 Commits

Author SHA1 Message Date
Brian Krabach e5827a8037 refactor: extract _updateNotificationUI helper, use const, fix double query
- Extract shared _updateNotificationUI(statusEl, reqBtn, permission) helper
  to remove duplicated notification status UI update logic from openSettings
  and the requestPermission click handler
- Replace var with const for local bindings in new Notifications handlers
- Use this.checked in bell sound change handler instead of re-querying
  the DOM element that was already looked up for addEventListener
2026-03-30 00:29:38 -07:00
Brian Krabach 2f909eae89 feat: add Notifications settings tab 2026-03-30 00:18:05 -07:00
Brian Krabach c955aadad1 feat: add Sessions settings tab with server-side persistence 2026-03-29 23:57:22 -07:00
Brian Krabach f380307f08 fix: rename data-panel to data-tab on settings panels to match switchSettingsTab()
switchSettingsTab() in app.js reads panel.dataset.tab (data-tab attribute).
The four .settings-panel elements in index.html were using data-panel instead,
causing all panels to get hidden on every tab click — the settings dialog rendered
as a blank content area after any tab interaction.

Fix: Change data-panel="..." to data-tab="..." on all four panel divs.

Tests added:
- test_html_settings_panels_use_data_tab: verifies each .settings-panel uses
  data-tab and not data-panel
- test_html_settings_tab_panel_data_tab_alignment: cross-checks that every tab
  button data-tab value has a matching .settings-panel data-tab value, preventing
  this class of HTML/JS attribute mismatch from recurring
2026-03-29 23:47:59 -07:00
Brian Krabach fd4e91bc51 feat: wire Display tab — font size, hover delay, grid columns with immediate apply 2026-03-29 23:28:12 -07:00
Brian Krabach bb21ce7aa9 style: reformat DISPLAY_DEFAULTS to multi-line, simplify switchSettingsTab panel lookup 2026-03-29 23:21:48 -07:00
Brian Krabach 1eec617cb9 feat: add settings dialog open/close, tab switching, localStorage management 2026-03-29 23:16:19 -07:00
Brian Krabach b20759c6cb feat: add settings dialog HTML structure and CSS 2026-03-29 23:04:11 -07:00
Brian Krabach 19ec5c02a5 feat: add POST /api/sessions endpoint for new session creation 2026-03-29 22:54:23 -07:00
Brian Krabach 6c72bd05cc feat: add GET/PATCH /api/settings endpoints 2026-03-29 22:47:53 -07:00
Brian Krabach 85d3bb1312 fix: use deepcopy for DEFAULT_SETTINGS and narrow exception catch in settings module
- Replace DEFAULT_SETTINGS.copy() with copy.deepcopy(DEFAULT_SETTINGS) in
  load_settings() and save_settings() to prevent mutation of module-level
  mutable defaults (hidden_sessions list was shared via shallow copy)
- Replace except (json.JSONDecodeError, Exception) with
  except (FileNotFoundError, json.JSONDecodeError) to only suppress expected
  errors and let unexpected exceptions (PermissionError etc.) propagate
- Add two regression tests:
  test_load_does_not_mutate_default_settings: verifies deepcopy isolation
  test_load_propagates_non_json_errors: verifies narrow exception handling
2026-03-29 22:43:24 -07:00
Brian Krabach ffecfa69cb feat: add server-side settings module 2026-03-29 22:36:19 -07:00
Brian Krabach 8e4f17068d refactor: remove command palette JavaScript 2026-03-29 22:28:20 -07:00
Brian Krabach a971cb9bbd refactor: remove orphaned command palette HTML remnants from index.html 2026-03-29 22:16:03 -07:00
Brian Krabach 80dc082df0 refactor: remove command palette HTML and CSS 2026-03-29 22:12:07 -07:00
Brian Krabach 77eecd1d72 docs: add settings panel and new session design document 2026-03-29 21:55:40 -07:00
Brian Krabach e4efadddf4 fix: remove gradient fades, fix sidebar active indicator full-height
Remove tile-body::before and sidebar-item-body::before gradient overlays
that obscured ANSI colors at the bottom of previews. Replace box-shadow
inset with border-left on sidebar-item--active so the cyan accent stripe
spans the full item height (was only showing on the header).
2026-03-29 19:41:49 -07:00
Brian Krabach 583de6ec2d feat: match preview styling to xterm.js — palette, background, font, line-height
ANSI_COLORS: replaced with xterm.js default GTK/Tango palette.
Background: #000000 on tile-body, sidebar-item-body, preview-popover.
Font: 'SF Mono', 'Fira Code', Consolas, monospace (same as terminal).
Line-height: 1.0 across all preview surfaces (xterm.js default).
Gradient overlays: fade to #000 instead of theme bg.
Result: previews are visually indistinguishable from the terminal.
2026-03-29 19:19:05 -07:00
Brian Krabach 85298abdcc fix: match tile + sidebar preview text color to xterm.js foreground (#c9d1d9) 2026-03-29 18:59:44 -07:00
Brian Krabach 71fdd544b2 fix: match preview text color to xterm.js foreground (#c9d1d9) 2026-03-29 18:48:51 -07:00
Brian Krabach bf76affef2 refactor: remove dimmer overlay, add cyan border to preview popover
ANSI colors make the overlay content readable without dimming. Removed
preview-dimmer, liftHoveredTile(), tile--previewing/item--previewing —
all dead code now. Added var(--accent) 2px border to .preview-popover
for visual separation.
2026-03-29 18:44:55 -07:00
Brian Krabach b3b2165524 feat: ANSI color rendering in preview snapshots
Server: add -e flag to tmux capture-pane to preserve escape sequences.
Frontend: ansiToHtml() parser converts SGR codes (bold, italic, dim,
16-color, 256-color) to <span> tags with inline styles. Applied to
dashboard tiles, sidebar items, and hover overlay. No external deps.
2026-03-29 18:24:15 -07:00
Brian Krabach dbade5a3d9 fix: preview overlay text color — use primary text, not disabled dim 2026-03-29 18:09:09 -07:00
Brian Krabach af95b90b02 feat: hover preview as full-window overlay with text wrap + click-to-navigate
Replace side-positioned popover with a viewport-covering overlay (5vh/5vw
margins). pre-wrap + word-break wraps terminal text to fit any viewport.
pointer-events:none on overlay lets mouseleave fire on the original tile.
Click anywhere while preview is active navigates to that session.
Removes repositionPreview() — no JS positioning needed.
2026-03-29 18:00:36 -07:00
Brian Krabach 5906de9e67 fix: rewrite hover preview — track by session name, survive innerHTML rebuilds
Previous approach guarded renderGrid/renderSidebar from running while
preview active, but catch-up renders, stale DOM references, and timer
races caused flicker, wrong positioning, and stale content.

New approach: track _previewSessionName (string, not DOM node). Renders
run normally. After each innerHTML rebuild, liftHoveredTile() re-applies
z-index to the new DOM element. repositionPreview() re-queries DOM each
call. Timer always cleared when entering a new tile. No render guards.
2026-03-29 17:40:54 -07:00
Brian Krabach 06d1e02ce1 fix: hover preview flashing — skip grid/sidebar re-render while preview active
Poll cycle rebuilds innerHTML every ~2s, destroying the hovered tile's
DOM element and tile--previewing class. Fix: renderGrid() and
renderSidebar() early-return when _previewPopover is non-null. On
hidePreview(), a catch-up render fires so no data is stale.
2026-03-29 17:21:15 -07:00
Brian Krabach 905cbf35e5 feat: hover preview dim effect — overlay dims page, hovered tile + popover stand out
1.5s hover delay (up from 350ms) for deliberate preview. When popover
appears, a semi-transparent dimmer covers the page (z-index 499). The
hovered tile/sidebar-item gets z-index 500 (same as popover) so it
appears above the dimmer alongside the preview. Both cleaned up on
mouseleave via hidePreview().
2026-03-29 16:56:09 -07:00
Brian Krabach 40ec20f4ba fix: hover preview — bottom-anchor, flexible width, sidebar support, longer delay
- Auto-scroll to bottom (prompt area is the valuable part)
- Dynamic width based on available space (fixes 3-column layout)
- 350ms delay (prevents brush-over flicker)
- Extend hover preview to sidebar items (popover to the right)
- Remove fixed min/max-width from CSS, calculate in JS
2026-03-29 16:48:52 -07:00
Brian Krabach 6144f39955 feat: hover preview popover on dashboard tiles
Desktop only (guarded by ontouchstart check). On mouseenter of a
session tile, shows a floating popover with the full tmux snapshot
(all lines, not the cropped 20-line preview). Positioned right of
tile if space, else left. 100ms delay prevents flicker on brush-over.
Data from _currentSessions (already in memory, zero API calls).

Also calls hidePreview() in openSession() so the popover is cleaned
up when switching to fullscreen view.
2026-03-29 14:42:30 -07:00
Brian Krabach 4b4b914858 fix: session switch race — mount terminal AFTER /connect POST completes
_openTerminal(name) was inside a 260ms setTimeout that fired before the
/connect POST completed. The WebSocket connected to the OLD ttyd (still
serving the previous session). Fix: animation runs concurrently with
/connect as before, but _openTerminal only fires after BOTH resolve.

Sequence: start animation + POST concurrently → await POST → await
animation → mount terminal. The new ttyd is guaranteed to be serving
the correct session before the WebSocket connects.

Added timerId null-check so animDone resolves immediately in test envs
where setTimeout is stubbed, preventing test hangs.
2026-03-29 14:17:10 -07:00
Brian Krabach ab1f579ea9 feat: Android-only rAF-batched touch scroll for terminal
Android batches touchmove events irregularly — firing one WheelEvent per
touchmove caused burst delivery (5 events → 5 WheelEvents → visible jump).
Fix: accumulate delta in touchmove, dispatch exactly one WheelEvent per
requestAnimationFrame tick (≤60fps). Remainder carries over to next frame.

UA-gated (/Android/i) — iOS and macOS are completely unaffected.
WheelEvent path → xterm.js mouse reporting → tmux (correct inside path).
e.preventDefault() blocks outer-container scroll on Android.
2026-03-29 06:29:09 -07:00
Brian Krabach d1d700e7fd test: clean up WebSocket auth test hygiene (unused fixture, deprecated cookies kwarg) 2026-03-29 04:30:25 -07:00
Brian Krabach 6c0bc80f21 fix(auth): protect WebSocket /terminal/ws with session cookie check
BaseHTTPMiddleware only handles HTTP scope — WebSocket connections
bypassed auth entirely. Inline check before websocket.accept() reads
the muxplex_session cookie and closes with code 4001 if invalid.
Localhost (127.0.0.1, ::1) still bypasses as intended.
2026-03-29 04:23:41 -07:00
Brian Krabach 575cebbcac fix: resolve_auth file password log prints actual path, not module name
Add `get_password_path` to top-level imports in main.py and fix the file
password resolution branch to log the actual file path instead of
`load_password.__module__` (which printed "muxplex.auth").

Also adds 4 tests covering all _resolve_auth logging paths:
- test_resolve_auth_pam_mode_logs_pam
- test_resolve_auth_env_password_logs_env
- test_resolve_auth_file_password_logs_file
- test_resolve_auth_generates_password_as_last_resort
2026-03-28 23:06:27 -07:00
Brian Krabach 387e19a2a2 feat: add reset-secret CLI subcommand
- Add reset_secret() function that regenerates the signing secret
- Uses secrets.token_urlsafe(32) for secure key generation
- Creates parent directories if they don't exist
- Sets file permissions to 0o600 (owner read/write only)
- Prints warning that all active sessions are now invalid
- Register 'reset-secret' subparser in main() arg parser
- Add dispatch branch for 'reset-secret' command

Tests added:
- test_reset_secret_writes_new_secret: secret file exists with content > 20 chars
- test_reset_secret_sets_0600_permissions: file mode is 0o600
- test_reset_secret_prints_warning: output contains 'invalid' or 'warning'

Co-authored-by: Amplifier <amplifier@sourcegraph.com>
2026-03-28 22:58:09 -07:00
Brian Krabach 0d691eab12 feat: add show-password CLI subcommand
Adds 'show-password' subcommand to the muxplex CLI that:
- Prints 'Auth mode: PAM — no password file used' when MUXPLEX_AUTH
  is not 'password' and PAM is available
- Prints 'Password: <pw>' when a password file exists
- Prints guidance to auto-generate when no password file found

Also imports load_password and pam_available at module level in cli.py
to enable testable patching via muxplex.cli namespace.

Tests added:
- test_show_password_prints_password_from_file
- test_show_password_no_file
- test_show_password_pam_mode
2026-03-28 22:53:29 -07:00
Brian Krabach 159b2c3618 feat: change --host default to 127.0.0.1, add --auth and --session-ttl flags
- Change serve() signature: host='127.0.0.1', add auth='pam', session_ttl=604800
- Inside serve(): set MUXPLEX_AUTH and MUXPLEX_SESSION_TTL env defaults
- Change --host CLI default from '0.0.0.0' to '127.0.0.1'
- Add --auth argument with choices=['pam', 'password'], default='pam'
- Add --session-ttl argument, type=int, default=604800 (7 days; 0=browser session)
- Update serve() call in main() to forward auth and session_ttl
- Update test_main_calls_serve_by_default assertion for new defaults
- Add test_main_default_host_is_localhost
- Add test_main_passes_auth_flag
- Add test_main_passes_session_ttl_flag

Co-authored-by: Amplifier <amplifier@amplified.dev>
2026-03-28 22:46:23 -07:00
Brian Krabach fd55a71958 feat: serve branded login.html with injected window.MUXPLEX_AUTH
Replace login_page() stub with handler that:
- Reads login.html from _FRONTEND_DIR
- Determines username (PAM mode: current user, else empty string)
- Builds mode_data with json.dumps({mode, user})
- Injects <script>window.MUXPLEX_AUTH = {...};</script> before </head>
- Returns HTMLResponse(html)

Move _FRONTEND_DIR definition above routes so login_page() can reference it.
Keep app.mount() at bottom and /auth/mode endpoint.

Add test: test_get_login_injects_muxplex_auth
  - Asserts status 200, MUXPLEX_AUTH in text, '"mode"' in text

All 45 tests pass.
2026-03-28 22:40:36 -07:00
Brian Krabach cf79d9fc9f docs(tests): clarify why logout tests bypass the shared client fixture
The client fixture pre-injects a valid muxplex_session cookie to make
most tests pass auth middleware. Logout tests intentionally create their
own TestClient (without the cookie) to prove the endpoint works
correctly for unauthenticated and expired-session requests.

Co-authored-by: Amplifier <amplifier@sourcegraph.com>
2026-03-28 22:36:09 -07:00
Brian Krabach 2ca4420536 feat: add GET /auth/logout route that clears session cookie and redirects to /login
- Add @app.get('/auth/logout') handler in muxplex/main.py after POST /login
- Creates RedirectResponse('/login', status_code=303)
- Calls response.delete_cookie('muxplex_session') to clear the auth cookie
- Add two tests in test_api.py:
  - test_logout_redirects_to_login: verifies 303 status and /login in location header
  - test_logout_clears_session_cookie: verifies Set-Cookie has muxplex_session with max-age=0
- Route is already exempt from AuthMiddleware via _AUTH_EXEMPT_PATHS in auth.py

Co-authored-by: Amplifier <amplifier@sourcegraph.com>
2026-03-28 22:30:39 -07:00
Brian Krabach b5d700214b feat: add POST /login handler with PAM and password mode auth
- Import authenticate_pam and create_session_cookie from muxplex.auth
- Import Form, Request from fastapi; RedirectResponse from starlette.responses
- Add @app.post('/login') handler that reads username and password form fields
- In PAM mode: delegates to authenticate_pam(username, password)
- In password mode: compares password to _auth_password
- On success: creates signed muxplex_session cookie (httponly, samesite=strict),
  redirects to / with 303
- On failure: redirects to /login?error=1 with 303
- Add 4 tests covering all branches (password success/failure, PAM success/failure)

Closes task-2-post-login-handler
2026-03-28 22:20:38 -07:00
Brian Krabach d5ffced6a5 feat: add branded login.html with PAM/password mode detection
- Create muxplex/frontend/login.html with:
  - POST form targeting /login with password autocomplete='current-password'
  - Wordmark img referencing /wordmark-on-dark.svg
  - Hidden username field shown in PAM mode via window.MUXPLEX_AUTH
  - Error banner shown when ?error=1 query param is present
  - Inline dark-theme CSS with design token variables (--bg, --accent, etc.)
  - Inline <script> reading window.MUXPLEX_AUTH for auth mode detection

- Add 6 login tests to muxplex/tests/test_frontend_html.py:
  - test_login_html_exists
  - test_login_html_has_form
  - test_login_html_has_password_autocomplete
  - test_login_html_has_wordmark
  - test_login_html_references_muxplex_auth
  - test_login_html_has_error_display

All 6 tests pass. Existing 15 index.html tests unaffected.

Co-authored-by: Amplifier <amplifier@amplified.dev>
2026-03-28 22:13:14 -07:00
Brian Krabach dd0507d8e1 fix: remove unused JSONResponse alias causing ruff F401 lint error 2026-03-28 21:56:59 -07:00
Brian Krabach ba7101292c feat: add /login stub and /auth/mode endpoint 2026-03-28 21:53:00 -07:00
Brian Krabach 88b21832f7 feat: wire auth middleware into FastAPI app
- Add _resolve_auth() with PAM → MUXPLEX_PASSWORD env → password file → auto-generate fallback chain
- MUXPLEX_AUTH=password forces password mode regardless of PAM availability
- MUXPLEX_SESSION_TTL controls session cookie TTL (default 604800 = 7 days)
- app.add_middleware(AuthMiddleware, ...) called after app creation
- Update client fixture to set a valid session cookie for test compatibility
- Add test_non_localhost_without_auth_gets_redirected integration test

Co-authored-by: Amplifier <amplifier@amplified.dev>
2026-03-28 21:47:01 -07:00
Brian Krabach 4e590345c3 fix(auth): remove Host-header auth bypass; inject client IP in tests via ASGI middleware
The server_host check (request.url.hostname) read from the user-controlled
HTTP Host header, allowing any attacker to bypass authentication by sending
'Host: 127.0.0.1'. Remove it entirely — request.client.host is the
socket-level IP and cannot be forged.

Fix the localhost test by introducing _InjectClientMiddleware, a thin ASGI
wrapper that writes a real IP into the scope's 'client' field. This lets the
test exercise the actual localhost check with 127.0.0.1 rather than relying
on the URL hostname.

Also:
- Promote _LOCALHOST_ADDRS to module-level constant (no-op per request)
- Add clarifying comment on auth_header[6:] magic slice
- Replace per-request cookies= kwarg with client.cookies.set() (Starlette deprecation)
2026-03-28 21:40:22 -07:00
Brian Krabach 22be933ebf feat(auth): request middleware with localhost bypass and session cookie check
- Add AuthMiddleware class extending BaseHTTPMiddleware
- Localhost bypass: checks both client.host and url.hostname for 127.0.0.1/::1/localhost
- Exempt paths: /login, /auth/mode, /auth/logout bypass auth
- Valid session cookie (muxplex_session) passes through
- Basic auth header: valid password passes, invalid returns 401
- API clients (Accept: application/json) get 401 on auth failure
- Browser clients get 307 redirect to /login on auth failure
- _check_credentials dispatches to PAM or password comparison
- Add 8 middleware tests to test_auth.py

Co-authored-by: Amplifier <amplifier@example.com>
2026-03-28 21:31:48 -07:00
Brian Krabach 7cf2a5882a style(auth): apply code quality suggestions from review
- auth.py: replace 'import os as _os' with plain 'import os' inside
  authenticate_pam() — the alias convention is for module-level private
  names, not function-scoped imports
- test_auth.py: move 'import pwd' from two test functions to module-level
  imports, consistent with existing 'import os' placement
- test_auth.py: make wrong-user test robust against running as root —
  derives 'wrong_user' dynamically so the test holds in Docker CI
  environments where the runner may be root
2026-03-28 21:25:16 -07:00
Brian Krabach 49475ba352 feat(auth): PAM authentication with running-user check 2026-03-28 21:19:53 -07:00
Brian Krabach 79dade7af7 style(auth): clarify ttl_seconds is signing-time passthrough in create_session_cookie 2026-03-28 21:16:16 -07:00