Add auth Phase 3: multi-user accounts with admin/member roles (#28)

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 <ssamjame@amazon.com>
Co-authored-by: Kiro <noreply@kiro.dev>
This commit is contained in:
Samuel James 2026-06-20 12:43:24 -04:00 committed by GitHub
parent 2ccc7b82d7
commit d863448495
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 535 additions and 38 deletions

View file

@ -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 24 above got scoped in the first place.

View file

@ -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<string> {
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')
}
}

View file

@ -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 }
})
}

View file

@ -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<string, string> = {}
@ -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' })

View file

@ -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

View file

@ -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' })

View file

@ -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)

View file

@ -3,12 +3,14 @@ import '@fastify/jwt'
declare module 'fastify' {
interface FastifyInstance {
authenticate: (req: import('fastify').FastifyRequest, reply: import('fastify').FastifyReply) => Promise<void>
requireAdmin: (req: import('fastify').FastifyRequest, reply: import('fastify').FastifyReply) => Promise<void>
adminOnly: (req: import('fastify').FastifyRequest, reply: import('fastify').FastifyReply) => Promise<void>
}
}
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 }
}
}

View file

@ -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<string, string>; secrets?: Record<string, string> }) =>
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 {

View file

@ -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<string, string> }
function IntegrationsSection() {
const { user } = useAuth()
const isAdmin = user?.role === 'admin'
const [integrations, setIntegrations] = useState<Integration[] | null>(null)
const [revealed, setRevealed] = useState<Set<string>>(new Set())
const [editDrafts, setEditDrafts] = useState<Record<number, Record<string, string>>>({})
@ -952,6 +957,20 @@ function IntegrationsSection() {
return (
<div className="flex flex-col gap-4">
{!isAdmin && (
<div
style={{
padding: '12px 14px',
borderRadius: '10px',
border: '1px solid rgba(200,164,52,0.2)',
backgroundColor: 'rgba(200,164,52,0.06)',
fontSize: '12px',
color: '#C8A434',
}}
>
You have member access integrations are read-only. Ask an administrator to add or change connections.
</div>
)}
<div>
<h3 style={sectionTitle}>SSH Hosts</h3>
<SshHostsSection />
@ -1154,11 +1173,23 @@ function NotificationsSection() {
}
function DataBackupSection() {
const { user } = useAuth()
const [busy, setBusy] = useState(false)
const [message, setMessage] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const importRef = useRef<HTMLInputElement | null>(null)
if (user?.role !== 'admin') {
return (
<div style={cardBase}>
<h3 style={sectionTitle}>Data &amp; Backup</h3>
<p style={{ fontSize: '13px', color: '#7A7D85' }}>
Backup export and import are restricted to administrators.
</p>
</div>
)
}
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<ManagedUser[]>([])
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 (
<div className="flex flex-col gap-5">
<div style={cardBase}>
<div className="flex items-center justify-between" style={{ marginBottom: '16px' }}>
<h3 style={{ ...sectionTitle, marginBottom: 0 }}>
Users <span style={{ color: '#7A7D85', fontWeight: 400 }}>· {users.length}/{MAX_USERS}</span>
</h3>
<GoldButton onClick={() => setShowCreate((v) => !v)} disabled={atCap}>
<UserPlus size={14} /> {showCreate ? 'Cancel' : 'Add User'}
</GoldButton>
</div>
{atCap && !showCreate && (
<p style={{ fontSize: '12px', color: '#E67E22', marginBottom: '14px' }}>
User limit reached ({MAX_USERS}). Delete a user to add another.
</p>
)}
{showCreate && (
<div
className="flex flex-col gap-3"
style={{ marginBottom: '18px', padding: '16px', borderRadius: '10px', border: '1px solid rgba(200,164,52,0.12)', backgroundColor: 'rgba(255,255,255,0.02)' }}
>
<div className="grid grid-cols-2 gap-3">
<div>
<label style={labelStyle}>Username</label>
<input style={inputStyle} value={newUsername} onChange={(e) => setNewUsername(e.target.value)} autoComplete="off" />
</div>
<div>
<label style={labelStyle}>Temporary Password</label>
<input style={inputStyle} type="text" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} autoComplete="off" />
</div>
</div>
<div>
<label style={labelStyle}>Role</label>
<div className="flex gap-2">
{(['member', 'admin'] as const).map((r) => (
<button
key={r}
onClick={() => setNewRole(r)}
className="cursor-pointer transition-colors"
style={{
fontSize: '12px',
textTransform: 'capitalize',
padding: '7px 16px',
borderRadius: '8px',
border: newRole === r ? '1px solid #C8A434' : '1px solid rgba(200,164,52,0.12)',
backgroundColor: newRole === r ? 'rgba(200,164,52,0.12)' : 'transparent',
color: newRole === r ? '#C8A434' : '#7A7D85',
}}
>
{r}
</button>
))}
</div>
</div>
<p style={{ fontSize: '11px', color: '#7A7D85' }}>
The user signs in with this temporary password and can change it under Security.
</p>
<div className="flex items-center gap-3">
<GoldButton onClick={handleCreate} disabled={creating || !newUsername || !newPassword}>
{creating ? 'Creating…' : 'Create User'}
</GoldButton>
{createMsg && <span style={{ fontSize: '12px', color: createMsg.ok ? '#2ECC71' : '#E74C3C' }}>{createMsg.text}</span>}
</div>
</div>
)}
{error && <p style={{ fontSize: '12px', color: '#E74C3C', marginBottom: '12px' }}>{error}</p>}
{loading ? (
<p style={{ fontSize: '13px', color: '#7A7D85' }}>Loading</p>
) : (
<div className="flex flex-col gap-2">
{users.map((u) => {
const isSelf = currentUser?.id === u.id
return (
<div
key={u.id}
className="flex items-center gap-3"
style={{
padding: '12px 14px',
borderRadius: '8px',
border: '1px solid rgba(200,164,52,0.08)',
backgroundColor: 'rgba(255,255,255,0.02)',
opacity: u.active ? 1 : 0.55,
}}
>
<div
className="rounded-full flex items-center justify-center font-bold shrink-0"
style={{ width: '34px', height: '34px', fontSize: '12px', color: '#C8A434', border: '1px solid rgba(200,164,52,0.4)', backgroundColor: 'rgba(200,164,52,0.08)' }}
>
{(u.displayName || u.username).slice(0, 2).toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<div style={{ fontSize: '13px', color: '#E8E6E0', fontWeight: 500 }}>
{u.username}
{isSelf && <span style={{ fontSize: '10px', color: '#C8A434', marginLeft: '8px', fontWeight: 600 }}>YOU</span>}
{!u.active && <span style={{ fontSize: '10px', color: '#E67E22', marginLeft: '8px', fontWeight: 600 }}>DEACTIVATED</span>}
</div>
<div style={{ fontSize: '11px', color: '#7A7D85' }}>{u.email || 'No email'}</div>
</div>
<button
onClick={() => handleRoleToggle(u)}
disabled={isSelf}
className="cursor-pointer transition-colors shrink-0"
title={isSelf ? "You can't change your own role" : `Make ${u.role === 'admin' ? 'member' : 'admin'}`}
style={{
fontSize: '11px',
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.5px',
padding: '5px 10px',
borderRadius: '6px',
border: 'none',
color: u.role === 'admin' ? '#C8A434' : '#7A7D85',
backgroundColor: u.role === 'admin' ? 'rgba(200,164,52,0.12)' : 'rgba(255,255,255,0.05)',
opacity: isSelf ? 0.5 : 1,
cursor: isSelf ? 'default' : 'pointer',
}}
>
{u.role}
</button>
{!isSelf && (
<>
<button
onClick={() => handleActiveToggle(u)}
className="cursor-pointer shrink-0"
style={{ fontSize: '11px', fontWeight: 600, padding: '6px 11px', borderRadius: '7px', border: '1px solid rgba(200,164,52,0.2)', background: 'transparent', color: '#7A7D85' }}
>
{u.active ? 'Deactivate' : 'Activate'}
</button>
<button
onClick={() => handleDelete(u)}
className="flex items-center cursor-pointer shrink-0"
title="Delete user"
style={{ padding: '6px', borderRadius: '7px', border: '1px solid rgba(231,76,60,0.4)', background: 'transparent', color: '#E74C3C' }}
>
<Trash2 size={14} />
</button>
</>
)}
</div>
)
})}
</div>
)}
</div>
</div>
)
}
const sectionComponents: Record<string, () => React.ReactElement> = {
profile: ProfileSection,
appearance: AppearanceSection,
security: SecuritySection,
users: UsersSection,
integrations: IntegrationsSection,
notifications: NotificationsSection,
data: DataBackupSection,
@ -1544,8 +1817,13 @@ const sectionComponents: Record<string, () => 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() {
<div className="flex h-full w-full gap-5">
{/* Settings nav */}
<div className="flex flex-col gap-1 shrink-0" style={{ width: '200px' }}>
{navSections.map((s) => {
{visibleSections.map((s) => {
const Icon = s.icon
const isActive = active === s.id
return (