From d863448495357053a1685212a9a55fc15c83d91a Mon Sep 17 00:00:00 2001 From: Samuel James <143277412+SamuelSJames@users.noreply.github.com> Date: Sat, 20 Jun 2026 12:43:24 -0400 Subject: [PATCH] Add auth Phase 3: multi-user accounts with admin/member roles (#28) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements Phase 3 of the auth roadmap: multiple user accounts (cap 10), an admin/member role model, and admin-only gating of config-mutating routes. Dashboard data stays shared across all users (per the product decision in HANDOFF.md — this is a household/self-hosted dashboard, not a multi-tenant app), so there is no per-user data isolation. Schema (backend/src/db/index.ts): - Idempotent migration adds `role` (default 'admin') and `active` (default 1) columns to `users` when missing. The 'admin' default means the pre-existing single user is backfilled to admin on deploy and keeps full access; newly created users are inserted explicitly as 'member'. Verified against a production-like old schema (columns added, existing user backfilled to admin/active). Auth + access control: - `/api/setup` creates the first user as admin. Login enforces `active` (deactivated accounts get 403) and embeds the live role in the session. - `app.authenticate` now reads role+active fresh from the DB on every request (not from the possibly-stale JWT claim), rejects inactive accounts, and stashes the role on req.user. - New `requireAdmin` (auth + role check) and `adminOnly` (role check for routes already behind the plugin-level authenticate hook) decorators. User management (admin-only, in auth.ts): - GET/POST/PUT/DELETE /api/users — list, create (admin sets a temp password; no public signup), change role, activate/deactivate, delete. - 10-user cap enforced server-side; guard rails prevent removing the last active admin (demote/deactivate/delete) and deleting your own account; deactivating or deleting a user drops their sessions immediately. Admin-only route gating (members get 403): - integrations create/update/delete/test, tunnels create/delete, data export/import. Read routes and tunnel connect/disconnect stay open to all authenticated users, as do all the SSH/Docker/RDP tools and bookmarks (members are trusted to use the tooling, per product decision). Frontend: - api.ts: listUsers/createUser/updateUser/deleteUser + ManagedUser type; role+active added to AuthUser. - Settings: new admin-only "Users" section (create form, role toggle, activate/deactivate, delete, 10-cap indicator). Nav filters the Users tab by role and guards ?tab= deep-links. Data & Backup shows an admin-only notice for members; Integrations shows a read-only banner for members. (Backend remains the real enforcement boundary.) Verified end-to-end against a throwaway backend: role assignment, member 403s on every admin-only route + 200s on shared/read routes, admin 200/201s, last-admin guards (409/400), deactivation killing an active session and blocking re-login (then reactivation restoring it), and the 10-user cap (409 on the 11th). Both frontend and backend type-check clean. Co-authored-by: Samuel James Co-authored-by: Kiro --- HANDOFF.md | 37 ++-- backend/src/db/index.ts | 20 ++ backend/src/routes/auth.ts | 166 ++++++++++++++++- backend/src/routes/data.ts | 4 +- backend/src/routes/integrations.ts | 8 +- backend/src/routes/tunnels.ts | 4 +- backend/src/server.ts | 27 +++ backend/src/types.d.ts | 6 +- src/lib/api.ts | 19 ++ src/pages/Settings.tsx | 282 ++++++++++++++++++++++++++++- 10 files changed, 535 insertions(+), 38 deletions(-) diff --git a/HANDOFF.md b/HANDOFF.md index 21600dd..a09f9c9 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 recently fixed from being dead links, and that surfaced a much bigger piece of unbuilt scope — **multi-user accounts, password management, sessions, login audit logging, and Authentik SSO**. That work is planned in four phases below; **only Phase 1 is done**. If you're picking this up, Phase 2 (password change + sessions + login log) is the next concrete task. +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. ## Standing rules (read before doing anything) @@ -24,17 +24,17 @@ The current focus is **auth/account features**: the top-right user menu (Profile ### Frontend (`/src`) - React 19 + Vite + TypeScript, Tailwind v4, Recharts, Lucide icons, React Router. - `src/lib/api.ts` — typed fetch wrapper (`apiFetch`) + one function per backend endpoint + corresponding TS interfaces. -- `src/lib/AuthContext.tsx` — auth state, backed by `localStorage` for token persistence (single JWT, no session tracking yet — see Phase 2). +- `src/lib/AuthContext.tsx` — auth state, backed by `localStorage` for token persistence. JWT now carries a session id (`sid`) tracked server-side (Phase 2). - Pages in `src/pages/`: `Glance.tsx` (`/`), `Infrastructure.tsx`, `BookNest.tsx`, `Settings.tsx`, `Terminal.tsx`, `Tunnels.tsx`, `Files.tsx`, `Containers.tsx`, `RemoteDesktop.tsx`, `HostMetrics.tsx`, plus `Login.tsx`/`Enrollment.tsx`. - `src/components/` — `TopBar.tsx` (user identity, global search, user dropdown menu), `Sidebar.tsx` (system-health rollup). - `Settings.tsx` now supports **URL-based tab deep-linking** (`?tab=profile|appearance|security|integrations|notifications|data|about`) via `useSearchParams` — added in Phase 1, see below. Use this pattern for any new settings section. ### 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. **Single-user schema today**: `users` table has no `role` column and no concept of multiple accounts (see Phase 3). +- `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/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 one user), `/api/auth/login`, `/api/auth/me` (GET/PUT). **No password-change endpoint exists yet** — that's Phase 2 work. +- `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/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. @@ -58,24 +58,29 @@ See `TERMIX_MIGRATION.md` for the phase-by-phase record of the original feature ## Current initiative: User menu → full auth system (in progress) -The user menu (`TopBar.tsx`, avatar dropdown) had `Profile`/`Appearance`/`Security` as dead `href="#"` links. Root-caused and scoped into 4 phases; **only Phase 1 shipped**. +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**. ### 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. - Wired `Profile` → `/settings?tab=profile`, `Appearance` → `/settings?tab=appearance`. -- Added a `Security` tab (`SecuritySection` in `Settings.tsx`) — currently just a placeholder ("coming soon") pending Phase 2. +- Added a `Security` tab in `Settings.tsx` — was a placeholder in Phase 1, fully built in Phase 2 (see below). -### Phase 2 — NOT STARTED. Password change + sessions + login log (still single-user) -- Add `PUT /api/auth/password` to `backend/src/routes/auth.ts` (verify current password via `bcrypt.compare`, hash new with `bcrypt.hash(..., 12)` matching existing pattern in that file). -- Add a `sessions` table (`id`, `user_id`, `created_at`, `last_seen_at`, `user_agent`, `ip`) — issue a session row alongside each JWT at login, and switch `app.authenticate` to also check the session is still valid (not just signature-valid), so revoking a session actually invalidates it. Look at how `app.jwt.sign({ sub, username })` is currently used in `auth.ts` to wire the session id into the token claims. -- Add a `login_events` table (`user_id`, `ip`, `success`, `created_at`) — log on every `/api/auth/login` attempt (success and failure both, for the audit trail). -- Build out `SecuritySection` in `Settings.tsx`: change-password form, active-sessions list with per-session "Sign out", recent login-activity table. Follow the existing pattern of other Settings sections (see `ProfileSection` for the closest analog — form state, save handler, error display). +### Phase 2 — DONE (merged, deployed) +Password change + sessions + login audit log, still single-user. Shipped in PR #27. +- `sessions` table (`id`, `user_id`, `user_agent`, `ip`, `created_at`, `last_seen_at`) and `login_events` table (`id`, `user_id`, `username`, `ip`, `user_agent`, `success`, `created_at`) in `backend/src/db/index.ts`. +- Login and `/api/setup` mint a session row and embed its id as a `sid` claim in the JWT. `app.authenticate` (in `server.ts`) now validates the session still exists (and bumps `last_seen_at`), so revoking a session actually invalidates its token — not just signature-valid. Tokens minted before sessions existed have no `sid` and stay valid until expiry (backward compatible). +- Every login attempt (success and failure) is recorded in `login_events`. +- 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 — NOT STARTED. Multi-user (cap: 10 seats) +### 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`). -- Add an admin-only "User Management" section (likely a new Settings tab, or a section within Security): create user (admin sets temp password — no public signup), list users, deactivate/delete, enforce the 10-user cap server-side. -- Audit every existing route for permission gating: most data stays shared/visible to all logged-in users, but *managing* integrations, users, and possibly tunnels should likely be admin-only. Decide and document this per-route as you go — don't guess silently. +- 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 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. @@ -98,6 +103,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, start at **Phase 2** above — it's the smallest self-contained next step and doesn't require the Phase 3 multi-user schema decisions to already be made. +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. 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. diff --git a/backend/src/db/index.ts b/backend/src/db/index.ts index 4f05f4b..3c5d713 100644 --- a/backend/src/db/index.ts +++ b/backend/src/db/index.ts @@ -109,3 +109,23 @@ db.exec(` export function logEvent(type: string, title: string, source?: string | null) { db.prepare('INSERT INTO events (type, title, source) VALUES (?, ?, ?)').run(type, title, source ?? null) } + +// --- Idempotent migrations for columns added after initial release --- +// CREATE TABLE IF NOT EXISTS won't add columns to an already-existing table, so +// add them explicitly when missing. Safe to run on every boot. +function userColumns(): Set { + const rows = db.prepare('PRAGMA table_info(users)').all() as Array<{ name: string }> + return new Set(rows.map((r) => r.name)) +} + +{ + const cols = userColumns() + if (!cols.has('role')) { + // New column defaults to 'admin' so the pre-existing single user keeps full access; + // newly created users are inserted explicitly as 'member' unless promoted. + db.exec("ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'admin'") + } + if (!cols.has('active')) { + db.exec('ALTER TABLE users ADD COLUMN active INTEGER NOT NULL DEFAULT 1') + } +} diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index c0f5065..67f351f 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -21,7 +21,7 @@ function clientUserAgent(req: FastifyRequest): string { } /** Creates a session row and returns a JWT carrying its id, so the session can be revoked. */ -function createSession(app: FastifyInstance, req: FastifyRequest, userId: number, username: string): string { +function createSession(app: FastifyInstance, req: FastifyRequest, userId: number, username: string, role: string): string { const sid = randomUUID() db.prepare('INSERT INTO sessions (id, user_id, user_agent, ip) VALUES (?, ?, ?, ?)').run( sid, @@ -29,7 +29,7 @@ function createSession(app: FastifyInstance, req: FastifyRequest, userId: number clientUserAgent(req), clientIp(req), ) - return app.jwt.sign({ sub: userId, username, sid }) + return app.jwt.sign({ sub: userId, username, sid, role }) } export async function authRoutes(app: FastifyInstance) { @@ -50,10 +50,10 @@ export async function authRoutes(app: FastifyInstance) { const { username, password } = parsed.data const passwordHash = await bcrypt.hash(password, 12) const result = db - .prepare('INSERT INTO users (username, password_hash) VALUES (?, ?)') + .prepare("INSERT INTO users (username, password_hash, role, active) VALUES (?, ?, 'admin', 1)") .run(username, passwordHash) const userId = Number(result.lastInsertRowid) - const token = createSession(app, req, userId, username) + const token = createSession(app, req, userId, username, 'admin') logEvent('account_created', `Account created for ${username}`) return { token } }) @@ -65,9 +65,10 @@ export async function authRoutes(app: FastifyInstance) { } const { username, password } = parsed.data const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username) as - | { id: number; username: string; password_hash: string } + | { id: number; username: string; password_hash: string; role: string; active: number } | undefined - const ok = !!user && (await bcrypt.compare(password, user.password_hash)) + const passwordOk = !!user && (await bcrypt.compare(password, user.password_hash)) + const ok = passwordOk && user.active === 1 db.prepare('INSERT INTO login_events (user_id, username, ip, user_agent, success) VALUES (?, ?, ?, ?, ?)').run( user?.id ?? null, username, @@ -75,10 +76,13 @@ export async function authRoutes(app: FastifyInstance) { clientUserAgent(req), ok ? 1 : 0, ) - if (!ok || !user) { + if (!user || !passwordOk) { return reply.code(401).send({ error: 'Invalid username or password' }) } - const token = createSession(app, req, user.id, user.username) + if (user.active !== 1) { + return reply.code(403).send({ error: 'This account has been deactivated' }) + } + const token = createSession(app, req, user.id, user.username, user.role) logEvent('user_login', `${user.username} logged in`) return { token } }) @@ -86,7 +90,7 @@ export async function authRoutes(app: FastifyInstance) { app.get('/api/auth/me', { onRequest: [app.authenticate] }, async (req) => { const payload = req.user as { sub: number; username: string } const user = db - .prepare('SELECT id, username, display_name, email, avatar_data_url FROM users WHERE id = ?') + .prepare('SELECT id, username, display_name, email, avatar_data_url, role, active FROM users WHERE id = ?') .get(payload.sub) return { user } }) @@ -108,7 +112,7 @@ export async function authRoutes(app: FastifyInstance) { if (email !== undefined) db.prepare('UPDATE users SET email = ? WHERE id = ?').run(email, payload.sub) if (avatarDataUrl !== undefined) db.prepare('UPDATE users SET avatar_data_url = ? WHERE id = ?').run(avatarDataUrl, payload.sub) const user = db - .prepare('SELECT id, username, display_name, email, avatar_data_url FROM users WHERE id = ?') + .prepare('SELECT id, username, display_name, email, avatar_data_url, role, active FROM users WHERE id = ?') .get(payload.sub) return { user } }) @@ -194,4 +198,146 @@ export async function authRoutes(app: FastifyInstance) { })), } }) + + // --- User management (admin-only) --- + + const MAX_USERS = 10 + + app.get('/api/users', { onRequest: [app.requireAdmin] }, async () => { + const rows = db + .prepare('SELECT id, username, display_name, email, role, active, created_at FROM users ORDER BY created_at ASC') + .all() as Array<{ + id: number + username: string + display_name: string | null + email: string | null + role: string + active: number + created_at: string + }> + return { + users: rows.map((r) => ({ + id: r.id, + username: r.username, + displayName: r.display_name, + email: r.email, + role: r.role, + active: r.active === 1, + createdAt: r.created_at, + })), + } + }) + + const createUserSchema = z.object({ + username: z.string().min(3).max(64), + password: z.string().min(8).max(256), + role: z.enum(['admin', 'member']).default('member'), + displayName: z.string().max(128).nullable().optional(), + email: z.string().email().max(256).nullable().optional(), + }) + + app.post('/api/users', { onRequest: [app.requireAdmin] }, async (req, reply) => { + const parsed = createUserSchema.safeParse(req.body) + if (!parsed.success) { + return reply.code(400).send({ error: parsed.error.issues[0]?.message ?? 'Invalid input' }) + } + const { username, password, role, displayName, email } = parsed.data + const count = (db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number }).count + if (count >= MAX_USERS) { + return reply.code(409).send({ error: `User limit reached (${MAX_USERS} maximum)` }) + } + const existing = db.prepare('SELECT id FROM users WHERE username = ?').get(username) + if (existing) { + return reply.code(409).send({ error: 'Username already taken' }) + } + const passwordHash = await bcrypt.hash(password, 12) + const result = db + .prepare('INSERT INTO users (username, password_hash, role, active, display_name, email) VALUES (?, ?, ?, 1, ?, ?)') + .run(username, passwordHash, role, displayName ?? null, email ?? null) + logEvent('user_created', `User ${username} created (${role})`) + return { user: { id: Number(result.lastInsertRowid), username, role, active: true } } + }) + + const updateUserSchema = z.object({ + role: z.enum(['admin', 'member']).optional(), + active: z.boolean().optional(), + }) + + app.put('/api/users/:id', { onRequest: [app.requireAdmin] }, async (req, reply) => { + const actor = req.user as { sub: number } + const id = Number((req.params as { id: string }).id) + const parsed = updateUserSchema.safeParse(req.body) + if (!parsed.success) { + return reply.code(400).send({ error: parsed.error.issues[0]?.message ?? 'Invalid input' }) + } + const target = db.prepare('SELECT id, username, role, active FROM users WHERE id = ?').get(id) as + | { id: number; username: string; role: string; active: number } + | undefined + if (!target) return reply.code(404).send({ error: 'User not found' }) + + const { role, active } = parsed.data + // Guard rails: don't let an admin lock everyone out by demoting/deactivating the + // last remaining active admin (including themselves). + const willDemote = role === 'member' && target.role === 'admin' + const willDeactivate = active === false && target.active === 1 + if (willDemote || willDeactivate) { + const otherActiveAdmins = ( + db + .prepare("SELECT COUNT(*) as count FROM users WHERE role = 'admin' AND active = 1 AND id != ?") + .get(id) as { count: number } + ).count + if (otherActiveAdmins === 0) { + return reply.code(409).send({ error: 'Cannot remove the last active admin' }) + } + } + + if (role !== undefined) db.prepare('UPDATE users SET role = ? WHERE id = ?').run(role, id) + if (active !== undefined) { + db.prepare('UPDATE users SET active = ? WHERE id = ?').run(active ? 1 : 0, id) + // Deactivating a user kills their sessions immediately. + if (!active) db.prepare('DELETE FROM sessions WHERE user_id = ?').run(id) + } + logEvent('user_updated', `User ${target.username} updated by admin`) + void actor + const updated = db + .prepare('SELECT id, username, display_name, email, role, active, created_at FROM users WHERE id = ?') + .get(id) as { id: number; username: string; display_name: string | null; email: string | null; role: string; active: number; created_at: string } + return { + user: { + id: updated.id, + username: updated.username, + displayName: updated.display_name, + email: updated.email, + role: updated.role, + active: updated.active === 1, + createdAt: updated.created_at, + }, + } + }) + + app.delete('/api/users/:id', { onRequest: [app.requireAdmin] }, async (req, reply) => { + const actor = req.user as { sub: number } + const id = Number((req.params as { id: string }).id) + if (id === actor.sub) { + return reply.code(400).send({ error: 'You cannot delete your own account' }) + } + const target = db.prepare('SELECT id, username, role, active FROM users WHERE id = ?').get(id) as + | { id: number; username: string; role: string; active: number } + | undefined + if (!target) return reply.code(404).send({ error: 'User not found' }) + if (target.role === 'admin' && target.active === 1) { + const otherActiveAdmins = ( + db + .prepare("SELECT COUNT(*) as count FROM users WHERE role = 'admin' AND active = 1 AND id != ?") + .get(id) as { count: number } + ).count + if (otherActiveAdmins === 0) { + return reply.code(409).send({ error: 'Cannot delete the last active admin' }) + } + } + db.prepare('DELETE FROM sessions WHERE user_id = ?').run(id) + db.prepare('DELETE FROM users WHERE id = ?').run(id) + logEvent('user_deleted', `User ${target.username} deleted by admin`) + return { ok: true } + }) } diff --git a/backend/src/routes/data.ts b/backend/src/routes/data.ts index 43aa27e..e885a42 100644 --- a/backend/src/routes/data.ts +++ b/backend/src/routes/data.ts @@ -101,7 +101,7 @@ const importSchema = z.object({ export async function dataRoutes(app: FastifyInstance) { app.addHook('onRequest', app.authenticate) - app.get('/api/data/export', async () => { + app.get('/api/data/export', { onRequest: [app.adminOnly] }, async () => { const integrations = (db.prepare('SELECT id, type, name, enabled, config_json FROM integrations').all() as IntegrationRow[]).map((row) => { const secretRows = db.prepare('SELECT key, value_encrypted FROM secrets WHERE integration_id = ?').all(row.id) as SecretRow[] const secrets: Record = {} @@ -147,7 +147,7 @@ export async function dataRoutes(app: FastifyInstance) { return { version: EXPORT_VERSION, exportedAt: new Date().toISOString(), integrations, bookmarkCategories, bookmarks, tunnels } }) - app.post('/api/data/import', async (req, reply) => { + app.post('/api/data/import', { onRequest: [app.adminOnly] }, async (req, reply) => { const parsed = importSchema.safeParse(req.body) if (!parsed.success) { return reply.code(400).send({ error: parsed.error.issues[0]?.message ?? 'Invalid import file' }) diff --git a/backend/src/routes/integrations.ts b/backend/src/routes/integrations.ts index a2917ec..0ced90a 100644 --- a/backend/src/routes/integrations.ts +++ b/backend/src/routes/integrations.ts @@ -61,7 +61,7 @@ export async function integrationRoutes(app: FastifyInstance) { return { integrations: rows.map(serialize) } }) - app.post('/api/integrations', async (req, reply) => { + app.post('/api/integrations', { onRequest: [app.adminOnly] }, async (req, reply) => { const parsed = createSchema.safeParse(req.body) if (!parsed.success) { return reply.code(400).send({ error: parsed.error.issues[0]?.message ?? 'Invalid input' }) @@ -82,7 +82,7 @@ export async function integrationRoutes(app: FastifyInstance) { return reply.code(201).send({ integration: serialize(row) }) }) - app.put('/api/integrations/:id', async (req, reply) => { + app.put('/api/integrations/:id', { onRequest: [app.adminOnly] }, async (req, reply) => { const id = Number((req.params as { id: string }).id) const parsed = createSchema.partial().safeParse(req.body) if (!parsed.success) { @@ -113,7 +113,7 @@ export async function integrationRoutes(app: FastifyInstance) { return { integration: serialize(row) } }) - app.delete('/api/integrations/:id', async (req, reply) => { + app.delete('/api/integrations/:id', { onRequest: [app.adminOnly] }, async (req, reply) => { const id = Number((req.params as { id: string }).id) const existing = db.prepare('SELECT * FROM integrations WHERE id = ?').get(id) as IntegrationRow | undefined db.prepare('DELETE FROM integrations WHERE id = ?').run(id) @@ -121,7 +121,7 @@ export async function integrationRoutes(app: FastifyInstance) { return reply.code(204).send() }) - app.post('/api/integrations/:id/test', async (req, reply) => { + app.post('/api/integrations/:id/test', { onRequest: [app.adminOnly] }, async (req, reply) => { const id = Number((req.params as { id: string }).id) const row = db.prepare('SELECT * FROM integrations WHERE id = ?').get(id) as | IntegrationRow diff --git a/backend/src/routes/tunnels.ts b/backend/src/routes/tunnels.ts index 41ede9b..1c1980f 100644 --- a/backend/src/routes/tunnels.ts +++ b/backend/src/routes/tunnels.ts @@ -47,7 +47,7 @@ export async function tunnelRoutes(app: FastifyInstance) { return { tunnels: rows.map(serialize) } }) - app.post('/api/tunnels', async (req, reply) => { + app.post('/api/tunnels', { onRequest: [app.adminOnly] }, async (req, reply) => { const parsed = createSchema.safeParse(req.body) if (!parsed.success) { return reply.code(400).send({ error: parsed.error.issues[0]?.message ?? 'Invalid input' }) @@ -65,7 +65,7 @@ export async function tunnelRoutes(app: FastifyInstance) { return reply.code(201).send({ tunnel: serialize(row) }) }) - app.delete('/api/tunnels/:id', async (req, reply) => { + app.delete('/api/tunnels/:id', { onRequest: [app.adminOnly] }, async (req, reply) => { const id = Number((req.params as { id: string }).id) const row = getTunnelRow(id) if (!row) return reply.code(404).send({ error: 'Tunnel not found' }) diff --git a/backend/src/server.ts b/backend/src/server.ts index 69c4e1f..7ec3d95 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -51,6 +51,33 @@ app.decorate('authenticate', async function (req, reply) { } db.prepare("UPDATE sessions SET last_seen_at = datetime('now') WHERE id = ?").run(payload.sid) } + // The user must still exist and be active. Read role fresh from the DB rather than + // trusting the (possibly stale) JWT claim — a demoted/deactivated user shouldn't keep + // elevated access just because their token was minted earlier. Stash it on req.user. + const dbUser = db.prepare('SELECT role, active FROM users WHERE id = ?').get(payload.sub) as + | { role: string; active: number } + | undefined + if (!dbUser || dbUser.active !== 1) { + reply.code(401).send({ error: 'Account is inactive' }) + return + } + ;(req.user as { role?: string }).role = dbUser.role +}) + +app.decorate('requireAdmin', async function (req, reply) { + await app.authenticate(req, reply) + if (reply.sent) return + if ((req.user as { role?: string }).role !== 'admin') { + reply.code(403).send({ error: 'Admin access required' }) + } +}) + +// For routes already behind a plugin-level `authenticate` onRequest hook: assumes +// authentication ran first (so req.user.role is populated) and only enforces the role. +app.decorate('adminOnly', async function (req, reply) { + if ((req.user as { role?: string } | undefined)?.role !== 'admin') { + reply.code(403).send({ error: 'Admin access required' }) + } }) await app.register(authRoutes) diff --git a/backend/src/types.d.ts b/backend/src/types.d.ts index 8ea6ec3..e158fcd 100644 --- a/backend/src/types.d.ts +++ b/backend/src/types.d.ts @@ -3,12 +3,14 @@ import '@fastify/jwt' declare module 'fastify' { interface FastifyInstance { authenticate: (req: import('fastify').FastifyRequest, reply: import('fastify').FastifyReply) => Promise + requireAdmin: (req: import('fastify').FastifyRequest, reply: import('fastify').FastifyReply) => Promise + adminOnly: (req: import('fastify').FastifyRequest, reply: import('fastify').FastifyReply) => Promise } } declare module '@fastify/jwt' { interface FastifyJWT { - payload: { sub: number; username: string; sid?: string } - user: { sub: number; username: string; sid?: string } + payload: { sub: number; username: string; sid?: string; role?: string } + user: { sub: number; username: string; sid?: string; role?: string } } } diff --git a/src/lib/api.ts b/src/lib/api.ts index c2d9728..d53a7f9 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -58,6 +58,13 @@ export const api = { logout: () => apiFetch<{ ok: boolean }>('/auth/logout', { method: 'POST' }), listLoginEvents: (limit = 20) => apiFetch<{ events: LoginEvent[] }>(`/auth/login-events?limit=${limit}`), + listUsers: () => apiFetch<{ users: ManagedUser[] }>('/users'), + createUser: (data: { username: string; password: string; role: 'admin' | 'member'; displayName?: string | null; email?: string | null }) => + apiFetch<{ user: ManagedUser }>('/users', { method: 'POST', body: JSON.stringify(data) }), + updateUser: (id: number, data: Partial<{ role: 'admin' | 'member'; active: boolean }>) => + apiFetch<{ user: ManagedUser }>(`/users/${id}`, { method: 'PUT', body: JSON.stringify(data) }), + deleteUser: (id: number) => apiFetch<{ ok: boolean }>(`/users/${id}`, { method: 'DELETE' }), + listIntegrations: () => apiFetch<{ integrations: Integration[] }>('/integrations'), createIntegration: (data: { type: string; name: string; config?: Record; secrets?: Record }) => apiFetch<{ integration: Integration }>('/integrations', { method: 'POST', body: JSON.stringify(data) }), @@ -183,6 +190,18 @@ export interface AuthUser { display_name: string | null email: string | null avatar_data_url: string | null + role?: 'admin' | 'member' + active?: boolean +} + +export interface ManagedUser { + id: number + username: string + displayName: string | null + email: string | null + role: 'admin' | 'member' + active: boolean + createdAt: string } export interface AuthSession { diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 42f196e..4fc3c4c 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -21,12 +21,15 @@ import { Shield, Monitor, LogOut, + Users, + UserPlus, } from 'lucide-react' const navSections = [ { id: 'profile', label: 'Profile', icon: User }, { id: 'appearance', label: 'Appearance', icon: Palette }, { id: 'security', label: 'Security', icon: Shield }, + { id: 'users', label: 'Users', icon: Users, adminOnly: true }, { id: 'integrations', label: 'Integrations', icon: Plug }, { id: 'notifications', label: 'Notifications', icon: Bell }, { id: 'data', label: 'Data & Backup', icon: Database }, @@ -780,6 +783,8 @@ function SshHostsSection() { type NewIntegrationDraft = { id: number; type: string; values: Record } function IntegrationsSection() { + const { user } = useAuth() + const isAdmin = user?.role === 'admin' const [integrations, setIntegrations] = useState(null) const [revealed, setRevealed] = useState>(new Set()) const [editDrafts, setEditDrafts] = useState>>({}) @@ -952,6 +957,20 @@ function IntegrationsSection() { return (
+ {!isAdmin && ( +
+ You have member access — integrations are read-only. Ask an administrator to add or change connections. +
+ )}

SSH Hosts

@@ -1154,11 +1173,23 @@ function NotificationsSection() { } function DataBackupSection() { + const { user } = useAuth() const [busy, setBusy] = useState(false) const [message, setMessage] = useState(null) const [error, setError] = useState(null) const importRef = useRef(null) + if (user?.role !== 'admin') { + return ( +
+

Data & Backup

+

+ Backup export and import are restricted to administrators. +

+
+ ) + } + async function handleExport() { setBusy(true) setError(null) @@ -1532,10 +1563,252 @@ function SecuritySection() { ) } +const MAX_USERS = 10 + +function UsersSection() { + const { user: currentUser } = useAuth() + const [users, setUsers] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + + // Create-user form + const [showCreate, setShowCreate] = useState(false) + const [newUsername, setNewUsername] = useState('') + const [newPassword, setNewPassword] = useState('') + const [newRole, setNewRole] = useState<'admin' | 'member'>('member') + const [creating, setCreating] = useState(false) + const [createMsg, setCreateMsg] = useState<{ text: string; ok: boolean } | null>(null) + + async function load() { + try { + const { users } = await api.listUsers() + setUsers(users) + setError('') + } catch (err) { + setError(err instanceof ApiError ? err.message : 'Failed to load users') + } finally { + setLoading(false) + } + } + + useEffect(() => { + load() + }, []) + + async function handleCreate() { + setCreateMsg(null) + if (newUsername.length < 3) { + setCreateMsg({ text: 'Username must be at least 3 characters', ok: false }) + return + } + if (newPassword.length < 8) { + setCreateMsg({ text: 'Temporary password must be at least 8 characters', ok: false }) + return + } + setCreating(true) + try { + await api.createUser({ username: newUsername, password: newPassword, role: newRole }) + setCreateMsg({ text: `User "${newUsername}" created`, ok: true }) + setNewUsername('') + setNewPassword('') + setNewRole('member') + setShowCreate(false) + load() + } catch (err) { + setCreateMsg({ text: err instanceof ApiError ? err.message : 'Failed to create user', ok: false }) + } finally { + setCreating(false) + } + } + + async function handleRoleToggle(u: ManagedUser) { + const nextRole = u.role === 'admin' ? 'member' : 'admin' + try { + await api.updateUser(u.id, { role: nextRole }) + load() + } catch (err) { + setError(err instanceof ApiError ? err.message : 'Failed to update role') + } + } + + async function handleActiveToggle(u: ManagedUser) { + try { + await api.updateUser(u.id, { active: !u.active }) + load() + } catch (err) { + setError(err instanceof ApiError ? err.message : 'Failed to update user') + } + } + + async function handleDelete(u: ManagedUser) { + if (!window.confirm(`Delete user "${u.username}"? This cannot be undone.`)) return + try { + await api.deleteUser(u.id) + load() + } catch (err) { + setError(err instanceof ApiError ? err.message : 'Failed to delete user') + } + } + + const atCap = users.length >= MAX_USERS + + return ( +
+
+
+

+ Users · {users.length}/{MAX_USERS} +

+ setShowCreate((v) => !v)} disabled={atCap}> + {showCreate ? 'Cancel' : 'Add User'} + +
+ + {atCap && !showCreate && ( +

+ User limit reached ({MAX_USERS}). Delete a user to add another. +

+ )} + + {showCreate && ( +
+
+
+ + setNewUsername(e.target.value)} autoComplete="off" /> +
+
+ + setNewPassword(e.target.value)} autoComplete="off" /> +
+
+
+ +
+ {(['member', 'admin'] as const).map((r) => ( + + ))} +
+
+

+ The user signs in with this temporary password and can change it under Security. +

+
+ + {creating ? 'Creating…' : 'Create User'} + + {createMsg && {createMsg.text}} +
+
+ )} + + {error &&

{error}

} + + {loading ? ( +

Loading…

+ ) : ( +
+ {users.map((u) => { + const isSelf = currentUser?.id === u.id + return ( +
+
+ {(u.displayName || u.username).slice(0, 2).toUpperCase()} +
+
+
+ {u.username} + {isSelf && YOU} + {!u.active && DEACTIVATED} +
+
{u.email || 'No email'}
+
+ + {!isSelf && ( + <> + + + + )} +
+ ) + })} +
+ )} +
+
+ ) +} + const sectionComponents: Record React.ReactElement> = { profile: ProfileSection, appearance: AppearanceSection, security: SecuritySection, + users: UsersSection, integrations: IntegrationsSection, notifications: NotificationsSection, data: DataBackupSection, @@ -1544,8 +1817,13 @@ const sectionComponents: Record React.ReactElement> = { export default function Settings() { const [searchParams, setSearchParams] = useSearchParams() + const { user } = useAuth() + const isAdmin = user?.role === 'admin' + const visibleSections = navSections.filter((s) => !s.adminOnly || isAdmin) const requestedTab = searchParams.get('tab') - const active = requestedTab && sectionComponents[requestedTab] ? requestedTab : 'profile' + const requestedAllowed = + requestedTab && sectionComponents[requestedTab] && visibleSections.some((s) => s.id === requestedTab) + const active = requestedAllowed ? requestedTab! : 'profile' const ActiveSection = sectionComponents[active] function setActive(id: string) { @@ -1556,7 +1834,7 @@ export default function Settings() {
{/* Settings nav */}
- {navSections.map((s) => { + {visibleSections.map((s) => { const Icon = s.icon const isActive = active === s.id return (