diff --git a/HANDOFF.md b/HANDOFF.md index a09f9c9..9c4fe1a 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -6,7 +6,7 @@ Status snapshot as of **2026-06-20**, branch `claude/dazzling-mendel-rzyxos`. Wr ArchNest is **live and deployed** at `archnest.snsnetlabs.com`, auto-deploying via GitHub Actions (`.github/workflows/deploy.yml`) on every merge to `main` — push triggers a build + SCP + `docker compose up -d --build` on `racknerd1`, with a health-check gate (`/api/health`). Deployment is no longer the open task; it's working infrastructure now. -The current focus is **auth/account features**: the top-right user menu (Profile/Appearance/Security) was fixed from being dead links (Phase 1), then **password management, sessions, and login audit logging shipped (Phase 2)**. The remaining unbuilt scope is **multi-user accounts (Phase 3, in progress) and Authentik SSO (Phase 4)**. See the phase breakdown below. +The current focus is **auth/account features**: the top-right user menu (Profile/Appearance/Security) was fixed from being dead links (Phase 1), then **password management, sessions, and login audit logging shipped (Phase 2)**, then **multi-user accounts with admin/member roles shipped (Phase 3)**. **Phase 4 (Authentik SSO) is deferred to a paid add-on for the future AWS deployment** — see `ROADMAP.md`. With Phases 1-3 done, there is no active auth task in the current self-hosted build. ## Standing rules (read before doing anything) @@ -31,10 +31,10 @@ The current focus is **auth/account features**: the top-right user menu (Profile ### Backend (`/backend`) - Fastify 5, TypeScript, ESM (`type: "module"` — `tsx` in dev, entrypoint `src/server.ts`). -- `backend/src/db/index.ts` — SQLite schema + `logEvent()` audit log, plus `sessions` and `login_events` tables (Phase 2). **Multi-user is in progress (Phase 3)**: a `role`/`active` column is being added to `users`. +- `backend/src/db/index.ts` — SQLite schema + `logEvent()` audit log, plus `sessions` and `login_events` tables (Phase 2). **Multi-user shipped (Phase 3)**: `users` has `role` (`admin`/`member`) and `active` columns, added via idempotent boot-time migrations. - `backend/src/db/crypto.ts` — AES-256-GCM `encryptSecret`/`decryptSecret`, keyed by `ARCHNEST_SECRET_KEY`. - `backend/src/routes/` — one file per route group (`auth`, `bookmarks`, `integrations`, `events`, `terminal`, `tunnels`, `files`, `docker`, `guacamole`, `metrics`, `transfer`, `data`). -- `backend/src/routes/auth.ts` — `/api/setup` (first-run, creates the first admin user), `/api/auth/login`, `/api/auth/me` (GET/PUT), `/api/auth/password`, `/api/auth/sessions`, `/api/auth/logout`, `/api/auth/login-events` (Phase 2). User-management endpoints land here in Phase 3. +- `backend/src/routes/auth.ts` — `/api/setup` (first-run, creates the first admin user), `/api/auth/login`, `/api/auth/me` (GET/PUT), `/api/auth/password`, `/api/auth/sessions`, `/api/auth/logout`, `/api/auth/login-events` (Phase 2), plus user-management endpoints `/api/users` (GET/POST) and `/api/users/:id` (PUT/DELETE) gated by `requireAdmin` (Phase 3). - `backend/src/integrations/` — the 8 integration adapters (Proxmox, Docker, NetBird, Cloudflare, AWS, Uptime Kuma, Weather, SSH). - `backend/src/ssh/` — SSH-backed feature engines: terminal sessions, tunnels, file ops, host metrics collectors, host-to-host transfer. - Docker images run on Alpine; **OpenSSL legacy provider is enabled** in `backend/Dockerfile` (`OPENSSL_CONF=/etc/ssl/openssl-legacy.cnf`) so old-format encrypted PEM keys (`BEGIN RSA PRIVATE KEY` + `DEK-Info`) still decrypt under OpenSSL 3 — don't remove this without understanding why it's there. @@ -56,9 +56,9 @@ See `TERMIX_MIGRATION.md` for the phase-by-phase record of the original feature 10. **TopBar global search** — across nav pages, integrations, bookmarks. 11. **Settings UX fixes** — secret fields show a "· saved" indicator instead of appearing blank/deleted after reload (`secretKeys: string[]` on the integration serializer); SSH host cards default-collapsed if already configured; SSH private-key/cert fields support file upload to avoid paste corruption. -## Current initiative: User menu → full auth system (in progress) +## Auth system — Phases 1-3 complete -The user menu (`TopBar.tsx`, avatar dropdown) had `Profile`/`Appearance`/`Security` as dead `href="#"` links. Root-caused and scoped into 4 phases; **Phases 1 and 2 shipped, Phase 3 is in progress**. +The user menu (`TopBar.tsx`, avatar dropdown) had `Profile`/`Appearance`/`Security` as dead `href="#"` links. Root-caused and scoped into 4 phases; **Phases 1, 2, and 3 shipped. Phase 4 (SSO) is deferred to a paid AWS add-on — see `ROADMAP.md`.** ### Phase 1 — DONE (merged, deployed) - Added `?tab=` deep-linking to `Settings.tsx` (`useSearchParams`) so menu items can jump to a specific section instead of always landing on Profile. @@ -73,27 +73,25 @@ Password change + sessions + login audit log, still single-user. Shipped in PR # - Endpoints in `auth.ts`: `PUT /api/auth/password` (verify current via bcrypt, hash new at cost 12, revoke all *other* sessions), `GET /api/auth/sessions`, `DELETE /api/auth/sessions/:id` (can't revoke current), `POST /api/auth/logout` (revokes current), `GET /api/auth/login-events?limit`. - `SecuritySection` in `Settings.tsx` is fully built: change-password form, active-sessions list with per-session "Sign out", recent login-activity feed. `AuthContext.logout()` calls `POST /api/auth/logout` so signing out revokes the server session. -### Phase 3 — IN PROGRESS. Multi-user (cap: 10 seats) -- **Decision already made by the user**: dashboard data (integrations, bookmarks, tunnels, etc.) is **shared across all users**, not private per-user — this is a household/self-hosted dashboard, not a multi-tenant app. Don't build per-user data isolation. -- Add a `role` column to `users` (`admin` / `member`) and an `active` column (for deactivate-without-delete). First user (`/api/setup`) is `admin`; existing single user is backfilled to `admin`. -- Add an admin-only "User Management" section in Settings: create user (admin sets temp password — **no public signup**), list users, change role, deactivate/delete, enforce the **10-user cap** server-side. -- **Permission model (decided with the user, see below) — gate via a `requireAdmin` hook:** - - **Admin-only (mutating shared config):** integrations create/update/delete/test, tunnels create/delete, user management, and data export/import (`/api/data/*` — round-trips decrypted secrets). - - **All authenticated users (admin + member):** view everything (Glance/Infrastructure/BookNest/Host Metrics), use ALL the SSH/Docker tooling (Terminal, Files, Containers, Remote Desktop, connect/disconnect existing tunnels — the user explicitly OK'd members having shell/root access; trusted household/team), bookmarks CRUD (shared link hub everyone contributes to), and their own profile/password/sessions. - - A deactivated user (`active = 0`) is rejected at login and their existing sessions stop validating. +### Phase 3 — DONE (merged, deployed). Multi-user (cap: 10 seats) +Shipped in PR #28 (with a build-fix follow-up in PR #29). Both frontend and backend type-check cleanly. +- **Decision (made by the user):** dashboard data (integrations, bookmarks, tunnels, etc.) is **shared across all users**, not private per-user — household/self-hosted dashboard, not multi-tenant. No per-user data isolation was built. +- `users` gained a `role` column (`admin`/`member`, defaults to `'admin'` so the pre-existing single user keeps full access) and an `active` column (deactivate-without-delete), added via idempotent boot-time `ALTER TABLE` migrations in `backend/src/db/index.ts`. First user (`/api/setup`) is `admin`; new users are created as `member` unless promoted. +- Admin-only "User Management" section in Settings (`UsersSection` in `Settings.tsx`): create user (admin sets temp password — **no public signup**), list users, toggle role, deactivate/delete. The **10-user cap** is enforced server-side in `POST /api/users`. +- Endpoints in `auth.ts`, all behind `app.requireAdmin`: `GET /api/users`, `POST /api/users`, `PUT /api/users/:id` (role/active), `DELETE /api/users/:id`. Last-active-admin guardrails: can't demote, deactivate, or delete the final active admin; can't delete your own account. Deactivating a user deletes their sessions immediately. +- **Permission model (gated via hooks in `server.ts`):** + - `requireAdmin` (authenticates, then enforces `role === 'admin'`) and `adminOnly` (role-only, for routes already behind a plugin-level `authenticate` hook). + - `authenticate` re-reads `role`/`active` fresh from the DB on every request rather than trusting the JWT claim, so a demoted/deactivated user loses elevated access immediately even with an older token; a deactivated user is rejected (401/at login 403) and their sessions stop validating. + - **Admin-only (mutating shared config):** integrations create/update/delete/test (`adminOnly` in `integrations.ts`), tunnels create/delete (`tunnels.ts`), data export/import (`data.ts`), and user management. + - **All authenticated users (admin + member):** view everything, use ALL the SSH/Docker tooling (Terminal, Files, Containers, Remote Desktop, connect/disconnect existing tunnels), bookmarks CRUD, and their own profile/password/sessions. +- Frontend wiring: `listUsers`/`createUser`/`updateUser`/`deleteUser` + `ManagedUser` type in `src/lib/api.ts`. -### Phase 4 — NOT STARTED. Authentik SSO (OIDC) -- Add instance-level SSO config (issuer URL, client ID/secret, redirect URI) — likely an integration-like settings entry, or dedicated config table/env vars. -- `GET /api/auth/sso/login` → redirect to Authentik; `GET /api/auth/sso/callback` → exchange code, look up/create local user by SSO subject claim (respecting the 10-user cap from Phase 3), issue the same JWT format as today. -- Add a "Sign in with SSO" button on `Login.tsx` alongside username/password (local accounts remain as an admin recovery path — don't remove password auth entirely). +### Phase 4 — DEFERRED to paid add-on (AWS deployment). Authentik SSO (OIDC) +Moved out of the core build. Planned as a **paid add-on shipped when ArchNest is deployed on AWS**, not on the current `racknerd1` deployment. Full intended scope and the open scope questions now live in **`ROADMAP.md`**. Local username/password auth (Phases 1-3) stays as the free path and admin recovery path. -## Known non-blocking stubs (cosmetic, not flagged as work to do unless asked) +## Known non-blocking stubs -- `Infrastructure.tsx`'s "Network" sub-tab is **intentionally** disabled (`title="Coming soon"`) — leave alone unless explicitly asked. -- `Settings.tsx`'s Appearance section (theme/accent/fontSize/radius/sidebarExpanded/animations) is local-state-only — doesn't persist or apply anywhere. Recommended fix if picked up: mirror the Terminal page's `localStorage`-backed prefs pattern and apply via CSS variables on `:root`. -- `Settings.tsx`'s Notifications section (email/push/sound toggles) has no backing delivery mechanism — recommend removing or clearly labeling as not-yet-functional rather than persisting settings that do nothing. - -Neither has been actioned because the user hasn't asked — check the latest conversation/commits before assuming a direction. +Moved to **`ROADMAP.md`** ("Known non-blocking stubs"). Summary: the Infrastructure "Network" sub-tab is intentionally disabled, and the Settings Appearance and Notifications sections are non-functional placeholders. None are flagged as work to do unless explicitly asked — check the latest conversation/commits before assuming a direction. ## Deployment (already working — reference only) @@ -103,6 +101,6 @@ Neither has been actioned because the user hasn't asked — check the latest con 1. Read this file, then `TERMIX_MIGRATION.md` for feature-level history, then skim recent `git log --oneline -30` for the latest concrete changes (commit messages are deliberately descriptive). 2. Frontend type-checks with `npx tsc --noEmit -p .` from repo root; backend the same from `backend/`. Both should pass cleanly before any commit. -3. If continuing the auth work, **Phase 2 is done** (password change + sessions + login log). **Phase 3 (multi-user) is in progress** — see its section above for the agreed permission model. +3. The auth roadmap's **Phases 1-3 are done** (user menu wiring; password change + sessions + login log; multi-user accounts with admin/member roles). **Phase 4 (Authentik SSO) is deferred to a paid AWS add-on — see `ROADMAP.md`.** There is no active auth task in the current self-hosted build. 4. If asked to add a feature unrelated to auth, follow existing patterns: integration adapters in `backend/src/integrations/`, SSH-backed engines in `backend/src/ssh/`, one route file per feature in `backend/src/routes/`, one `api.ts` entry + page component per frontend feature. -5. For anything ambiguous in scope (especially Phase 3's permission model or Phase 4's SSO provider assumptions), use `AskUserQuestion` rather than guessing — that's how Phases 2–4 above got scoped in the first place. +5. For anything ambiguous in scope (especially the permission model, or Phase 4's SSO scope questions in `ROADMAP.md` if that add-on gets picked up), use `AskUserQuestion` rather than guessing — that's how Phases 2–4 above got scoped in the first place. diff --git a/README.md b/README.md index 11c6d78..490a92f 100644 --- a/README.md +++ b/README.md @@ -27,16 +27,20 @@ managed host. merge to `main` via `.github/workflows/deploy.yml`. All 11 pages and their backend routes are built and working — there is no pending/on-hold page. The active area of work is **the auth system**: the user menu's -Profile/Appearance/Security links were fixed in Phase 1; Phases 2-4 -(password change + sessions + login audit log, multi-user accounts, -Authentik SSO) are planned but not started — see `HANDOFF.md` for the full -phase breakdown and exactly where to resume. +Profile/Appearance/Security links were fixed in Phase 1; Phase 2 +(password change + sessions + login audit log) and Phase 3 (multi-user +accounts with admin/member roles, 10-seat cap) have shipped. Phase 4 +(Authentik SSO) is **deferred to a paid add-on for the future AWS +deployment** — see `ROADMAP.md`. With Phases 1-3 done there is no active +auth task in the current self-hosted build; see `HANDOFF.md` for the full +phase breakdown. If you're a fresh AI session: read this file, then `HANDOFF.md` (current task state + standing workflow rules), then `design-decisions.md` (visual -conventions + accurate per-page implementation notes), then -`TERMIX_MIGRATION.md` (history of how the SSH/Docker/Guacamole feature set -was built) if you need historical context on a specific feature. +conventions + accurate per-page implementation notes), then `ROADMAP.md` +(deferred/planned work, incl. the paid SSO add-on) and `TERMIX_MIGRATION.md` +(history of how the SSH/Docker/Guacamole feature set was built) if you need +that context. ## Pages @@ -67,8 +71,9 @@ with the actual code, not a spec written before the page existed. - `src/lib/api.ts` — typed fetch wrapper (`apiFetch`) + one function per backend endpoint + matching TS interfaces. This is the contract between frontend and backend; any new backend route needs a matching entry here. -- `src/lib/AuthContext.tsx` — auth state backed by `localStorage` (single JWT, - no session tracking yet — Phase 2 of the auth roadmap). +- `src/lib/AuthContext.tsx` — auth state backed by `localStorage` (JWT + carrying a server-tracked session id; signing out revokes the session + server-side). - `src/pages/` — one file per route (see table above), plus `Login.tsx` / `Enrollment.tsx` for the unauthenticated/first-run flows. - `src/components/` — `TopBar.tsx` (title, global search across pages/ @@ -81,14 +86,15 @@ with the actual code, not a spec written before the page existed. ### Backend (`/backend`) - Fastify 5, TypeScript, ESM (`tsx` for dev, `tsc -b` for build), entrypoint `src/server.ts`. -- `backend/src/db/index.ts` — SQLite schema + `logEvent()` audit log. - Single-user schema today (no `role` column, no multi-account concept — - see `HANDOFF.md` Phase 3). +- `backend/src/db/index.ts` — SQLite schema + `logEvent()` audit log, + plus `sessions`/`login_events` tables and a multi-user `users` schema + (`role` admin/member + `active` columns). - `backend/src/db/crypto.ts` — AES-256-GCM `encryptSecret`/`decryptSecret`, keyed by `ARCHNEST_SECRET_KEY`. - `backend/src/routes/` — one file per feature area: - - `auth.ts` — setup, login, profile (`/api/setup`, `/api/auth/login`, - `/api/auth/me`) + - `auth.ts` — setup, login, profile, password change, sessions, + login audit log, and admin-only user management (`/api/setup`, + `/api/auth/*`, `/api/users`) - `integrations.ts` — integration CRUD + connection testing - `bookmarks.ts` — bookmarks + categories CRUD - `events.ts` — activity log retrieval diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..39dae43 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,85 @@ +# ArchNest — Roadmap + +Forward-looking work that is **planned but not currently being built**. For +the state of shipped work and the active task, see `HANDOFF.md`. For +historical feature build-out, see `TERMIX_MIGRATION.md`. + +--- + +## Shipped (for context) + +The auth roadmap so far — full detail in `HANDOFF.md`: + +- **Phase 1** — User menu Profile/Appearance/Security wired up; `?tab=` + deep-linking in Settings. +- **Phase 2** — Password change, server-tracked sessions, login audit log. +- **Phase 3** — Multi-user accounts: admin/member roles, `active` flag, + 10-seat cap, admin-only user management, `requireAdmin`/`adminOnly` gating. + +--- + +## Phase 4 — Authentik SSO (OIDC) — PAID ADD-ON (AWS deployment) + +**Status:** deferred. This is intentionally **not** part of the +self-hosted core build. It is planned as a **paid add-on, shipped when +ArchNest is deployed on AWS** — not on the current `racknerd1` deployment. + +Local username/password auth (Phases 1-3) remains the free, always-available +path and the admin recovery path; SSO layers on top of it rather than +replacing it. + +### Intended scope (when built) +- Instance-level SSO config (issuer URL, client ID/secret, redirect URI) — + likely an integration-like settings entry, or a dedicated config + table / env vars. +- `GET /api/auth/sso/login` → redirect to Authentik. +- `GET /api/auth/sso/callback` → exchange code, look up/create local user by + SSO subject claim (respecting the 10-user cap from Phase 3), issue the same + JWT format as today. +- "Sign in with SSO" button on `Login.tsx` alongside username/password + (local accounts remain — do **not** remove password auth entirely). + +### Open scope questions (decide before any code) +1. **Where does SSO config live?** env vars (simplest, redeploy to change) vs. + a dedicated config table vs. an integration-like settings entry (editable + in-UI, more work). +2. **First-login provisioning** — auto-create a local `member` for an + unknown-but-valid SSO user (subject to the 10-seat cap), or require an + admin to pre-create the account and only *link* it on SSO login? +3. **Role mapping** — do Authentik groups/claims map to admin/member, or do + all SSO users default to `member` with roles managed locally? + +--- + +## Terminal — window grid view (tiered: self-hosted vs. paid) + +**Status:** self-hosted behavior is current; the paid tier is planned. + +The Terminal page (`src/pages/Terminal.tsx`) supports a split-pane grid view +within a tab. + +- **Self-hosted (current):** capped at a **4-window grid** (1 / 2 / 4 pane + layouts via the toolbar buttons). This is the free, always-available tier. +- **Paid (planned, AWS deployment):** **as many windows as fit on the + screen** — dynamic grid sizing beyond the 4-pane cap, laid out responsively + to the viewport rather than a fixed 1/2/4 choice. + +When the paid tier is built, the 4-pane cap becomes a licensing/feature gate +rather than a hard UI limit; the grid layout logic generalizes to an +arbitrary pane count. + +--- + +## Known non-blocking stubs (cosmetic, not scheduled) + +Not flagged as work to do unless explicitly asked: + +- `Infrastructure.tsx`'s "Network" sub-tab is **intentionally** disabled + (`title="Coming soon"`) — leave alone unless explicitly asked. +- `Settings.tsx`'s Appearance section (theme/accent/fontSize/radius/ + sidebarExpanded/animations) is local-state-only — doesn't persist or apply + anywhere. Recommended fix if picked up: mirror the Terminal page's + `localStorage`-backed prefs pattern and apply via CSS variables on `:root`. +- `Settings.tsx`'s Notifications section (email/push/sound toggles) has no + backing delivery mechanism — recommend removing or clearly labeling as + not-yet-functional rather than persisting settings that do nothing. diff --git a/design-decisions.md b/design-decisions.md index 0024f20..57c5b29 100644 --- a/design-decisions.md +++ b/design-decisions.md @@ -257,8 +257,9 @@ an actual SQL-backed or live-polled endpoint, not a config file or static array. - `backend/` — Fastify 5 + TypeScript (ESM, `tsx` dev / `tsc -b` build), `better-sqlite3` for storage, deployed as its own Docker container alongside the frontend and a `guacd` sidecar. -- Auth: single-user-schema JWT (`@fastify/jwt`) today — see `HANDOFF.md` for - the multi-user/SSO roadmap (Phases 2-4, not yet built). +- Auth: JWT (`@fastify/jwt`) with server-tracked sessions and a multi-user + schema (admin/member roles, 10-seat cap). Authentik SSO is deferred to a + paid AWS add-on — see `ROADMAP.md`. - Integration credentials split across `integrations` (non-secret config) and `secrets` (AES-256-GCM-encrypted, keyed by `ARCHNEST_SECRET_KEY`) tables — secrets never appear in a generic "list integrations" response by diff --git a/src/lib/TerminalSessionContext.tsx b/src/lib/TerminalSessionContext.tsx new file mode 100644 index 0000000..2cff0c7 --- /dev/null +++ b/src/lib/TerminalSessionContext.tsx @@ -0,0 +1,389 @@ +import { createContext, useContext, useEffect, useRef, useState, type ReactNode } from 'react' +import { Terminal as XTerm } from '@xterm/xterm' +import { FitAddon } from '@xterm/addon-fit' +import '@xterm/xterm/css/xterm.css' +import { getToken } from './api' +import { useAuth } from './AuthContext' +import { + TERM_THEMES, + loadPrefs, + savePrefs, + type TabState, + type TerminalPrefs, +} from './terminalPrefs' + +/** + * One live terminal pane: its xterm instance, fit addon, WebSocket, and the + * persistent wrapper DOM node that xterm is attached to. The wrapper lives in + * a hidden container at the app root and is re-parented into the Terminal page + * grid when that page is mounted, so the xterm instance and its WebSocket + * survive route changes (the page unmounting no longer disposes them). + */ +interface PaneSession { + paneId: number + wrapper: HTMLDivElement + term: XTerm + fit: FitAddon + ws: WebSocket | null + hostId: number | null + connected: boolean + /** Last tmux session list fetched for the current host. */ + tmuxSessions: string[] + /** Currently-selected tmux option in the pane header (raw select value). */ + selectedTmux: string + /** Disposes the term's onData/onResize listeners before re-wiring on reconnect. */ + disposeListeners: (() => void) | null +} + +let nextId = 1 +const genId = () => nextId++ + +function newTab(): TabState { + return { id: genId(), name: 'Terminal', panes: [{ id: genId(), hostId: null }] } +} + +interface TerminalSessionContextValue { + tabs: TabState[] + activeTabId: number + activePaneId: number | null + prefs: TerminalPrefs + setActiveTabId: (id: number) => void + setActivePaneId: (id: number | null) => void + setPrefs: (update: (p: TerminalPrefs) => TerminalPrefs) => void + addTab: () => void + closeTab: (id: number) => void + setPaneCount: (count: number) => void + setPaneHost: (paneId: number, hostId: number) => void + /** Mount a pane's persistent DOM node into a grid cell; returns a detach fn. */ + attachPane: (paneId: number, cell: HTMLElement) => () => void + /** Read-only snapshot of a pane's live connection state (for header UI). */ + getPaneInfo: (paneId: number) => { connected: boolean; tmuxSessions: string[]; selectedTmux: string } + /** Reconnect a pane to a tmux session (or plain shell) via the header select. */ + reconnectTmux: (paneId: number, rawSelectValue: string) => void + /** Version counter that bumps whenever live pane state changes, to trigger re-render. */ + version: number +} + +const TerminalSessionContext = createContext(null) + +export function TerminalSessionProvider({ children }: { children: ReactNode }) { + const { status } = useAuth() + + const [tabs, setTabs] = useState(() => [newTab()]) + const [activeTabId, setActiveTabId] = useState(() => tabs[0].id) + const [activePaneId, setActivePaneId] = useState(null) + const [prefs, setPrefsState] = useState(loadPrefs) + const [version, setVersion] = useState(0) + + // Hidden host for offscreen pane wrappers; created lazily on first use (never + // during render — only from effects/handlers — to satisfy the rules of refs). + const hiddenRootRef = useRef(null) + function getHiddenRoot(): HTMLDivElement { + let el = hiddenRootRef.current + if (!el) { + el = document.createElement('div') + el.style.position = 'absolute' + el.style.width = '0' + el.style.height = '0' + el.style.overflow = 'hidden' + el.style.left = '-99999px' + el.style.top = '0' + hiddenRootRef.current = el + } + if (!el.isConnected) document.body.appendChild(el) + return el + } + + const sessionsRef = useRef>(new Map()) + + useEffect(() => { + const hidden = getHiddenRoot() + return () => { + if (hidden.isConnected) hidden.remove() + } + }, []) + + useEffect(() => savePrefs(prefs), [prefs]) + + const bump = () => setVersion((v) => v + 1) + + // --- Session lifecycle ---------------------------------------------------- + + function ensureSession(paneId: number): PaneSession { + const existing = sessionsRef.current.get(paneId) + if (existing) return existing + + const wrapper = document.createElement('div') + wrapper.style.width = '100%' + wrapper.style.height = '100%' + wrapper.style.minHeight = '0' + getHiddenRoot().appendChild(wrapper) + + const theme = TERM_THEMES.find((t) => t.name === prefs.themeName) ?? TERM_THEMES[0] + const term = new XTerm({ + cursorBlink: true, + fontSize: prefs.fontSize, + fontFamily: prefs.fontFamily, + theme: { background: theme.background, foreground: theme.foreground, cursor: theme.cursor }, + }) + const fit = new FitAddon() + term.loadAddon(fit) + term.open(wrapper) + + const session: PaneSession = { + paneId, + wrapper, + term, + fit, + ws: null, + hostId: null, + connected: false, + tmuxSessions: [], + selectedTmux: '', + disposeListeners: null, + } + sessionsRef.current.set(paneId, session) + return session + } + + function applyPrefsToSession(s: PaneSession) { + const theme = TERM_THEMES.find((t) => t.name === prefs.themeName) ?? TERM_THEMES[0] + s.term.options.fontSize = prefs.fontSize + s.term.options.fontFamily = prefs.fontFamily + s.term.options.theme = { background: theme.background, foreground: theme.foreground, cursor: theme.cursor } + try { + s.fit.fit() + } catch { + /* container may be detached */ + } + } + + function fetchTmuxSessions(s: PaneSession, id: number) { + s.tmuxSessions = [] + bump() + const token = getToken() + const proto = window.location.protocol === 'https:' ? 'wss' : 'ws' + const ws = new WebSocket(`${proto}://${window.location.host}/api/terminal?token=${encodeURIComponent(token ?? '')}`) + ws.onopen = () => ws.send(JSON.stringify({ type: 'list_tmux', integrationId: id })) + ws.onmessage = (event) => { + const msg = JSON.parse(event.data) + if (msg.type === 'tmux_sessions') { + s.tmuxSessions = msg.sessions ?? [] + bump() + ws.close() + } + } + } + + function connect(s: PaneSession, id: number, tmuxSession?: string) { + s.disposeListeners?.() + s.ws?.close() + s.connected = false + bump() + + const term = s.term + term.reset() + term.writeln('Connecting…') + + const token = getToken() + const proto = window.location.protocol === 'https:' ? 'wss' : 'ws' + const ws = new WebSocket(`${proto}://${window.location.host}/api/terminal?token=${encodeURIComponent(token ?? '')}`) + s.ws = ws + + ws.onopen = () => { + ws.send(JSON.stringify({ type: 'connect', integrationId: id, cols: term.cols, rows: term.rows, tmuxSession })) + } + ws.onmessage = (event) => { + const msg = JSON.parse(event.data) + if (msg.type === 'connected') { + s.connected = true + bump() + term.reset() + } else if (msg.type === 'data') { + term.write(msg.data) + } else if (msg.type === 'error') { + term.writeln(`\r\n\x1b[31mError: ${msg.message}\x1b[0m`) + s.connected = false + bump() + } else if (msg.type === 'closed') { + term.writeln('\r\n\x1b[33mConnection closed.\x1b[0m') + s.connected = false + bump() + } + } + ws.onclose = () => { + s.connected = false + bump() + } + + const dataDisp = term.onData((data) => { + if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'input', data })) + }) + const resizeDisp = term.onResize(({ cols, rows }) => { + if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'resize', cols, rows })) + }) + s.disposeListeners = () => { + dataDisp.dispose() + resizeDisp.dispose() + } + } + + function destroySession(paneId: number) { + const s = sessionsRef.current.get(paneId) + if (!s) return + s.disposeListeners?.() + s.ws?.close() + s.term.dispose() + s.wrapper.remove() + sessionsRef.current.delete(paneId) + } + + function destroyAllSessions() { + for (const paneId of [...sessionsRef.current.keys()]) destroySession(paneId) + } + + // Tear everything down on logout (sessions must not outlive the auth session). + useEffect(() => { + if (status === 'logged-out' || status === 'needs-setup') { + destroyAllSessions() + // Defer the state reset so we don't call setState synchronously inside the + // effect body (which would trigger a cascading render). + queueMicrotask(() => { + const fresh = newTab() + setTabs([fresh]) + setActiveTabId(fresh.id) + setActivePaneId(null) + }) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [status]) + + // --- Public actions ------------------------------------------------------- + + function setPrefs(update: (p: TerminalPrefs) => TerminalPrefs) { + setPrefsState((prev) => { + const next = update(prev) + // Re-style any live sessions in place rather than recreating them. + queueMicrotask(() => { + for (const s of sessionsRef.current.values()) applyPrefsToSession(s) + }) + return next + }) + } + + function addTab() { + const tab = newTab() + setTabs((prev) => [...prev, tab]) + setActiveTabId(tab.id) + } + + function closeTab(id: number) { + setTabs((prev) => { + const closing = prev.find((t) => t.id === id) + // Closing a tab disconnects its panes' sessions. + closing?.panes.forEach((p) => destroySession(p.id)) + const next = prev.filter((t) => t.id !== id) + if (next.length === 0) { + const t = newTab() + setActiveTabId(t.id) + return [t] + } + if (id === activeTabId) setActiveTabId(next[0].id) + return next + }) + } + + function updateActiveTab(fn: (tab: TabState) => TabState) { + setTabs((prev) => prev.map((t) => (t.id === activeTabId ? fn(t) : t))) + } + + function setPaneCount(count: number) { + updateActiveTab((tab) => { + const panes = [...tab.panes] + while (panes.length < count) panes.push({ id: genId(), hostId: null }) + while (panes.length > count) { + const removed = panes.pop() + // Reducing the grid disconnects the dropped pane's session. + if (removed) destroySession(removed.id) + } + return { ...tab, panes } + }) + } + + function setPaneHost(paneId: number, hostId: number) { + updateActiveTab((tab) => ({ + ...tab, + panes: tab.panes.map((p) => (p.id === paneId ? { ...p, hostId } : p)), + })) + setActivePaneId(paneId) + + const s = ensureSession(paneId) + if (s.hostId === hostId) return + s.hostId = hostId + s.selectedTmux = '' + fetchTmuxSessions(s, hostId) + connect(s, hostId) + } + + function attachPane(paneId: number, cell: HTMLElement): () => void { + const s = ensureSession(paneId) + cell.appendChild(s.wrapper) + // Defer the fit until layout has settled in the new parent. + requestAnimationFrame(() => { + try { + s.fit.fit() + } catch { + /* ignore */ + } + }) + return () => { + // On page unmount, move the wrapper back to the hidden root instead of + // disposing it, so the xterm + WebSocket keep running in the background. + const hidden = getHiddenRoot() + if (s.wrapper.parentElement !== hidden) hidden.appendChild(s.wrapper) + } + } + + function getPaneInfo(paneId: number) { + const s = sessionsRef.current.get(paneId) + return { + connected: s?.connected ?? false, + tmuxSessions: s?.tmuxSessions ?? [], + selectedTmux: s?.selectedTmux ?? '', + } + } + + function reconnectTmux(paneId: number, rawSelectValue: string) { + const s = sessionsRef.current.get(paneId) + if (!s || s.hostId === null) return + s.selectedTmux = rawSelectValue + const value = rawSelectValue === '__new__' ? `archnest-${Date.now().toString(36)}` : rawSelectValue + connect(s, s.hostId, value || undefined) + } + + const value: TerminalSessionContextValue = { + tabs, + activeTabId, + activePaneId, + prefs, + setActiveTabId, + setActivePaneId, + setPrefs, + addTab, + closeTab, + setPaneCount, + setPaneHost, + attachPane, + getPaneInfo, + reconnectTmux, + version, + } + + return {children} +} + +export function useTerminalSessions() { + const ctx = useContext(TerminalSessionContext) + if (!ctx) throw new Error('useTerminalSessions must be used within TerminalSessionProvider') + return ctx +} diff --git a/src/lib/terminalPrefs.ts b/src/lib/terminalPrefs.ts new file mode 100644 index 0000000..fa5f384 --- /dev/null +++ b/src/lib/terminalPrefs.ts @@ -0,0 +1,56 @@ +// Shared terminal constants, types, and prefs persistence. Kept in a non-component +// module so the context file can export only its provider/hook (React Fast Refresh +// requires component files to export components exclusively). + +const GOLD = '#C8A434' + +export interface TermTheme { + name: string + background: string + foreground: string + cursor: string +} + +export const TERM_THEMES: TermTheme[] = [ + { name: 'ArchNest Dark', background: '#15161A', foreground: '#E8E6E0', cursor: GOLD }, + { name: 'Matrix', background: '#000000', foreground: '#33FF66', cursor: '#33FF66' }, + { name: 'Solarized', background: '#002B36', foreground: '#93A1A1', cursor: '#CB4B16' }, + { name: 'Midnight Blue', background: '#0B1021', foreground: '#C7D3F2', cursor: '#5FA8FF' }, +] + +export interface TerminalPrefs { + themeName: string + fontSize: number + fontFamily: string +} + +export interface PaneState { + id: number + hostId: number | null +} + +export interface TabState { + id: number + name: string + panes: PaneState[] +} + +const PREFS_KEY = 'archnest-terminal-prefs' + +export function defaultPrefs(): TerminalPrefs { + return { themeName: TERM_THEMES[0].name, fontSize: 13, fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace' } +} + +export function loadPrefs(): TerminalPrefs { + try { + const raw = localStorage.getItem(PREFS_KEY) + if (raw) return { ...defaultPrefs(), ...JSON.parse(raw) } + } catch { + /* ignore malformed local storage */ + } + return defaultPrefs() +} + +export function savePrefs(prefs: TerminalPrefs) { + localStorage.setItem(PREFS_KEY, JSON.stringify(prefs)) +} diff --git a/src/main.tsx b/src/main.tsx index a688069..5abc14c 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -4,12 +4,15 @@ import { BrowserRouter } from 'react-router-dom' import './index.css' import App from './App.tsx' import { AuthProvider } from './lib/AuthContext' +import { TerminalSessionProvider } from './lib/TerminalSessionContext' createRoot(document.getElementById('root')!).render( - + + + , diff --git a/src/pages/Terminal.tsx b/src/pages/Terminal.tsx index 3166c73..a4948c7 100644 --- a/src/pages/Terminal.tsx +++ b/src/pages/Terminal.tsx @@ -1,27 +1,12 @@ import { useEffect, useRef, useState } from 'react' -import { Terminal as XTerm } from '@xterm/xterm' -import { FitAddon } from '@xterm/addon-fit' -import '@xterm/xterm/css/xterm.css' import { Settings2, Plus, X, Columns2, Grid2x2, SquareSlash } from 'lucide-react' -import { api, getToken, type Integration } from '../lib/api' +import { api, type Integration } from '../lib/api' +import { useTerminalSessions } from '../lib/TerminalSessionContext' +import { TERM_THEMES } from '../lib/terminalPrefs' const GOLD = '#C8A434' const TEXT_SECONDARY = '#7A7D85' -interface TermTheme { - name: string - background: string - foreground: string - cursor: string -} - -const TERM_THEMES: TermTheme[] = [ - { name: 'ArchNest Dark', background: '#15161A', foreground: '#E8E6E0', cursor: GOLD }, - { name: 'Matrix', background: '#000000', foreground: '#33FF66', cursor: '#33FF66' }, - { name: 'Solarized', background: '#002B36', foreground: '#93A1A1', cursor: '#CB4B16' }, - { name: 'Midnight Blue', background: '#0B1021', foreground: '#C7D3F2', cursor: '#5FA8FF' }, -] - const FONT_SIZES = [11, 12, 13, 14, 15, 16] const FONT_FAMILIES = [ { name: 'Monospace', value: 'ui-monospace, SFMono-Regular, Menlo, monospace' }, @@ -29,108 +14,32 @@ const FONT_FAMILIES = [ { name: 'JetBrains Mono', value: '"JetBrains Mono", ui-monospace, monospace' }, ] -interface TerminalPrefs { - themeName: string - fontSize: number - fontFamily: string -} - -const PREFS_KEY = 'archnest-terminal-prefs' - -function loadPrefs(): TerminalPrefs { - try { - const raw = localStorage.getItem(PREFS_KEY) - if (raw) return { ...defaultPrefs(), ...JSON.parse(raw) } - } catch { - /* ignore malformed local storage */ - } - return defaultPrefs() -} - -function defaultPrefs(): TerminalPrefs { - return { themeName: TERM_THEMES[0].name, fontSize: 13, fontFamily: FONT_FAMILIES[0].value } -} - -function savePrefs(prefs: TerminalPrefs) { - localStorage.setItem(PREFS_KEY, JSON.stringify(prefs)) -} - -interface PaneState { - id: number - hostId: number | null -} - -interface TabState { - id: number - name: string - panes: PaneState[] -} - -let nextId = 1 -const genId = () => nextId++ - -function newTab(): TabState { - return { id: genId(), name: 'Terminal', panes: [{ id: genId(), hostId: null }] } -} - export default function Terminal() { const [hosts, setHosts] = useState([]) - const [tabs, setTabs] = useState(() => [newTab()]) - const [activeTabId, setActiveTabId] = useState(() => tabs[0].id) - const [activePaneId, setActivePaneId] = useState(null) - const [prefs, setPrefs] = useState(loadPrefs) const [showPrefs, setShowPrefs] = useState(false) + const { + tabs, + activeTabId, + activePaneId, + prefs, + setActiveTabId, + setActivePaneId, + setPrefs, + addTab, + closeTab, + setPaneCount, + setPaneHost, + } = useTerminalSessions() + useEffect(() => { api.listIntegrations().then(({ integrations }) => { setHosts(integrations.filter((i) => i.type === 'ssh')) }) }, []) - useEffect(() => savePrefs(prefs), [prefs]) - const activeTab = tabs.find((t) => t.id === activeTabId) ?? tabs[0] - function addTab() { - const tab = newTab() - setTabs((prev) => [...prev, tab]) - setActiveTabId(tab.id) - } - - function closeTab(id: number) { - setTabs((prev) => { - const next = prev.filter((t) => t.id !== id) - if (next.length === 0) { - const t = newTab() - setActiveTabId(t.id) - return [t] - } - if (id === activeTabId) setActiveTabId(next[0].id) - return next - }) - } - - function updateActiveTab(fn: (tab: TabState) => TabState) { - setTabs((prev) => prev.map((t) => (t.id === activeTabId ? fn(t) : t))) - } - - function setPaneCount(count: number) { - updateActiveTab((tab) => { - const panes = [...tab.panes] - while (panes.length < count) panes.push({ id: genId(), hostId: null }) - while (panes.length > count) panes.pop() - return { ...tab, panes } - }) - } - - function setPaneHost(paneId: number, hostId: number) { - updateActiveTab((tab) => ({ - ...tab, - panes: tab.panes.map((p) => (p.id === paneId ? { ...p, hostId } : p)), - })) - setActivePaneId(paneId) - } - const paneGridClass = activeTab.panes.length === 1 ? 'grid-cols-1 grid-rows-1' @@ -138,6 +47,8 @@ export default function Terminal() { ? 'grid-cols-2 grid-rows-1' : 'grid-cols-2 grid-rows-2' + const activePaneHostId = activeTab.panes.find((p) => p.id === activePaneId)?.hostId ?? null + return (
@@ -156,8 +67,8 @@ export default function Terminal() { onClick={() => activePaneId !== null && setPaneHost(activePaneId, h.id)} className="rounded-md px-2 py-1.5 text-left text-sm transition-colors" style={{ - background: activeTab.panes.find((p) => p.id === activePaneId)?.hostId === h.id ? 'rgba(200,164,52,0.15)' : 'transparent', - color: activeTab.panes.find((p) => p.id === activePaneId)?.hostId === h.id ? GOLD : '#E8E6E0', + background: activePaneHostId === h.id ? 'rgba(200,164,52,0.15)' : 'transparent', + color: activePaneHostId === h.id ? GOLD : '#E8E6E0', }} > {h.name} @@ -165,7 +76,8 @@ export default function Terminal() { ))}

- Click a pane, then a host to connect it. + Click a pane, then a host to connect it. Sessions stay connected when + you switch pages — close a tab or pane to disconnect.

@@ -264,9 +176,9 @@ export default function Terminal() { {activeTab.panes.map((pane) => ( setActivePaneId(pane.id)} /> @@ -278,122 +190,34 @@ export default function Terminal() { } function TerminalPane({ + paneId, hostId, hosts, - prefs, active, onFocus, }: { + paneId: number hostId: number | null hosts: Integration[] - prefs: TerminalPrefs active: boolean onFocus: () => void }) { - const [connected, setConnected] = useState(false) - const [tmuxSessions, setTmuxSessions] = useState([]) - const [selectedTmux, setSelectedTmux] = useState('') - const containerRef = useRef(null) - const termRef = useRef(null) - const fitRef = useRef(null) - const wsRef = useRef(null) - const lastHostIdRef = useRef(null) + const { attachPane, getPaneInfo, reconnectTmux, version } = useTerminalSessions() + const cellRef = useRef(null) + // Mount the persistent xterm DOM node owned by the provider into this cell; + // on unmount (route change or layout change) the wrapper is moved back to the + // hidden root rather than disposed, so the session keeps running. useEffect(() => { - if (!containerRef.current) return - const theme = TERM_THEMES.find((t) => t.name === prefs.themeName) ?? TERM_THEMES[0] - const term = new XTerm({ - cursorBlink: true, - fontSize: prefs.fontSize, - fontFamily: prefs.fontFamily, - theme: { background: theme.background, foreground: theme.foreground, cursor: theme.cursor }, - }) - const fit = new FitAddon() - term.loadAddon(fit) - term.open(containerRef.current) - fit.fit() - termRef.current = term - fitRef.current = fit - - const onResize = () => fit.fit() - window.addEventListener('resize', onResize) - return () => { - window.removeEventListener('resize', onResize) - term.dispose() - wsRef.current?.close() - } + if (!cellRef.current) return + const detach = attachPane(paneId, cellRef.current) + return detach // eslint-disable-next-line react-hooks/exhaustive-deps - }, [prefs.themeName, prefs.fontSize, prefs.fontFamily]) - - useEffect(() => { - fitRef.current?.fit() - }) - - useEffect(() => { - if (hostId === null || hostId === lastHostIdRef.current) return - lastHostIdRef.current = hostId - setSelectedTmux('') - fetchTmuxSessions(hostId) - connect(hostId) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [hostId]) - - function fetchTmuxSessions(id: number) { - setTmuxSessions([]) - const token = getToken() - const proto = window.location.protocol === 'https:' ? 'wss' : 'ws' - const ws = new WebSocket(`${proto}://${window.location.host}/api/terminal?token=${encodeURIComponent(token ?? '')}`) - ws.onopen = () => ws.send(JSON.stringify({ type: 'list_tmux', integrationId: id })) - ws.onmessage = (event) => { - const msg = JSON.parse(event.data) - if (msg.type === 'tmux_sessions') { - setTmuxSessions(msg.sessions ?? []) - ws.close() - } - } - } - - function connect(id: number, tmuxSession?: string) { - wsRef.current?.close() - setConnected(false) - const term = termRef.current - if (!term) return - term.reset() - term.writeln('Connecting…') - - const token = getToken() - const proto = window.location.protocol === 'https:' ? 'wss' : 'ws' - const ws = new WebSocket(`${proto}://${window.location.host}/api/terminal?token=${encodeURIComponent(token ?? '')}`) - wsRef.current = ws - - ws.onopen = () => { - ws.send(JSON.stringify({ type: 'connect', integrationId: id, cols: term.cols, rows: term.rows, tmuxSession })) - } - ws.onmessage = (event) => { - const msg = JSON.parse(event.data) - if (msg.type === 'connected') { - setConnected(true) - term.reset() - } else if (msg.type === 'data') { - term.write(msg.data) - } else if (msg.type === 'error') { - term.writeln(`\r\n\x1b[31mError: ${msg.message}\x1b[0m`) - setConnected(false) - } else if (msg.type === 'closed') { - term.writeln('\r\n\x1b[33mConnection closed.\x1b[0m') - setConnected(false) - } - } - ws.onclose = () => setConnected(false) - - term.onData((data) => { - if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'input', data })) - }) - term.onResize(({ cols, rows }) => { - if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'resize', cols, rows })) - }) - } + }, [paneId]) + // `version` is read so the header re-renders when live pane state changes. + void version + const info = getPaneInfo(paneId) const host = hosts.find((h) => h.id === hostId) return ( @@ -406,25 +230,20 @@ function TerminalPane({ }} >
- - {host ? (connected ? `Connected — ${host.name}` : `Disconnected — ${host.name}`) : 'Select a host to connect'} + + {host ? (info.connected ? `Connected — ${host.name}` : `Disconnected — ${host.name}`) : 'Select a host to connect'} {host && ( )}
-
+
) }