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:
parent
2ccc7b82d7
commit
d863448495
10 changed files with 535 additions and 38 deletions
37
HANDOFF.md
37
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.
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
6
backend/src/types.d.ts
vendored
6
backend/src/types.d.ts
vendored
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 & 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 (
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue