From 782d6df9e0e8b06947fcecf3f5f4a7418ec20f0a Mon Sep 17 00:00:00 2001 From: Hiren Kangad Date: Tue, 28 Apr 2026 20:34:49 +0530 Subject: [PATCH] feat: admin dashboard for user management Adds an /admin route, visible only to users with role=admin, that lets an operator manage the user base from the UI: - list / search users (active + inactive) - create new accounts (with role and optional display name) - reset a user's password - promote/demote between admin and member - deactivate / reactivate (soft-delete via is_active flag) Backend changes: - New adminOrApiKeyMiddleware accepts EITHER a Bearer JWT belonging to a role=admin user (UI path) OR the existing X-API-Key (bot/server-to-server). - Existing /api/admin/* routes switched to the hybrid middleware, so the same endpoints serve both the dashboard and any external scripts. - Added PUT /api/admin/users/:id/role and PUT /api/admin/users/:id/reactivate. - Self-deactivation and self-demotion are explicitly blocked so an admin can't lock themselves out. Frontend changes: - New Admin.tsx page (table view, modals for create + reset, toast feedback). - Admin button in CollectionList header, only rendered for admin role. - Wired into App.tsx routing. Also: friendly error when poppler-utils is missing on the host (PDF uploads return 501 POPPLER_MISSING with a one-line install hint instead of crashing the request); README clarifies poppler is required for the manual install. --- README.md | 19 +- backend/auth.js | 33 ++ backend/pdf-utils.js | 36 ++- backend/routes/admin.js | 58 +++- backend/routes/upload.js | 4 + frontend/src/App.tsx | 2 + frontend/src/pages/Admin.tsx | 449 ++++++++++++++++++++++++++ frontend/src/pages/CollectionList.tsx | 14 + 8 files changed, 606 insertions(+), 9 deletions(-) create mode 100644 frontend/src/pages/Admin.tsx diff --git a/README.md b/README.md index 3ab08a4..2fb783a 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,8 @@ Built because we needed PureRef's painlessness, Miro's collaboration, and a code - First user is auto-admin - `SEED_ADMIN_*` env vars to bootstrap an admin on first boot - `ALLOW_SELF_REGISTRATION` flag — when off, only admins can create accounts -- Admin endpoints to list/create/deactivate users (UI dashboard coming next) +- **Admin dashboard** at `/admin` — list users, create accounts, reset passwords, promote/demote between admin and member, deactivate / reactivate (admin-only, JWT-gated) +- Admin REST endpoints work with either a JWT belonging to an admin user or an `X-API-Key` header for bots **Deployment** - One `docker compose up` starts RefBoard + a bundled MinIO for object storage @@ -86,7 +87,19 @@ Persistent state lives under `./.docker-data/` (SQLite + MinIO objects). Back th ## Manual install (without Docker) -Requires Node.js 20+, ffmpeg, and poppler-utils on your `PATH`. You also need an S3-compatible object store reachable from the backend — easiest is to run MinIO standalone. +Requires Node.js 20+, **ffmpeg**, and **poppler-utils** on your `PATH`. The Docker image installs these automatically; for a manual install you need to bring them yourself. + +```bash +# macOS +brew install ffmpeg poppler + +# Debian / Ubuntu +sudo apt install ffmpeg poppler-utils +``` + +If `poppler-utils` is missing, image and video uploads still work, but PDF uploads will fail with a clear `501 POPPLER_MISSING` error rather than crashing. + +You also need an S3-compatible object store reachable from the backend — easiest is to run MinIO standalone. ```bash # 1. Object storage @@ -269,8 +282,8 @@ See [CHANGELOG.md](CHANGELOG.md) for the version history (v0.1.0 → v0.5.0). ## Roadmap +- [x] Admin dashboard frontend (live at `/admin` — user create / reset-password / role / deactivate) - [ ] Per-board activity log (who added/deleted what, when) -- [ ] Admin dashboard frontend (backend endpoints already exist at `/api/admin/users`) - [ ] Mobile-friendly read-only board view - [ ] Export board → PDF / image grid - [ ] Optional remote storage adapters (S3 direct, R2) diff --git a/backend/auth.js b/backend/auth.js index 32bd274..9f6a013 100644 --- a/backend/auth.js +++ b/backend/auth.js @@ -81,6 +81,38 @@ function apiKeyMiddleware(req, res, next) { next(); } +/** + * Express middleware: accepts EITHER a valid X-API-Key (server-to-server / bot) + * OR a Bearer JWT belonging to a user with role=admin (UI dashboard). + * + * On JWT path: attaches decoded user to req.user. + */ +function adminOrApiKeyMiddleware(req, res, next) { + const apiKey = req.headers['x-api-key']; + if (apiKey && REFBOARD_API_KEY && apiKey === REFBOARD_API_KEY) { + return next(); + } + + const authHeader = req.headers.authorization; + if (authHeader && authHeader.startsWith('Bearer ')) { + try { + const decoded = verifyToken(authHeader.slice(7)); + if (decoded.role !== 'admin') { + return res.status(403).json({ error: 'Admin access required' }); + } + req.user = decoded; + return next(); + } catch (err) { + if (err.name === 'TokenExpiredError') { + return res.status(401).json({ error: 'Token expired' }); + } + return res.status(401).json({ error: 'Invalid token' }); + } + } + + return res.status(401).json({ error: 'Admin authentication required' }); +} + module.exports = { generateToken, verifyToken, @@ -89,5 +121,6 @@ module.exports = { authMiddleware, adminMiddleware, apiKeyMiddleware, + adminOrApiKeyMiddleware, JWT_SECRET, }; diff --git a/backend/pdf-utils.js b/backend/pdf-utils.js index 7c2a932..9058689 100644 --- a/backend/pdf-utils.js +++ b/backend/pdf-utils.js @@ -15,6 +15,32 @@ const execFileAsync = promisify(execFile); const TIMEOUT_MS = 60_000; +class PopplerMissingError extends Error { + constructor(binary) { + super( + `RefBoard couldn't run "${binary}". PDF support requires poppler-utils to be installed on the host. ` + + `On macOS: brew install poppler. On Debian/Ubuntu: apt install poppler-utils. ` + + `The provided Dockerfile already installs it — this only matters for manual installs.` + ); + this.code = 'POPPLER_MISSING'; + this.binary = binary; + this.statusCode = 501; + } +} + +function wrapEnoent(binary, fn) { + return async (...args) => { + try { + return await fn(...args); + } catch (err) { + if (err && err.code === 'ENOENT' && (err.path === binary || err.syscall === `spawn ${binary}`)) { + throw new PopplerMissingError(binary); + } + throw err; + } + }; +} + /** * Write a buffer to a temporary file. Returns { tmpPath, cleanup }. * Caller MUST call cleanup() when done. @@ -131,4 +157,12 @@ async function pdfRenderPage(filePath, pageNum, dpi = 150) { } } -module.exports = { pdfInfo, pdfRenderPage, bufferToTempFile }; +const safePdfInfo = wrapEnoent('pdfinfo', pdfInfo); +const safePdfRenderPage = wrapEnoent('pdftoppm', pdfRenderPage); + +module.exports = { + pdfInfo: safePdfInfo, + pdfRenderPage: safePdfRenderPage, + bufferToTempFile, + PopplerMissingError, +}; diff --git a/backend/routes/admin.js b/backend/routes/admin.js index d00cc0d..3d968bd 100644 --- a/backend/routes/admin.js +++ b/backend/routes/admin.js @@ -1,11 +1,13 @@ const { Router } = require('express'); const { v4: uuidv4 } = require('uuid'); -const { apiKeyMiddleware, hashPassword } = require('../auth'); +const { adminOrApiKeyMiddleware, hashPassword } = require('../auth'); const { + db, createUser, getAllUsers, getUserById, getUserByEmail, + getUserByUsername, deactivateUser, updateUserPassword, createCollection, @@ -17,21 +19,29 @@ const { const router = Router(); -router.use(apiKeyMiddleware); +router.use(adminOrApiKeyMiddleware); // ---- User management ---- router.post('/users', async (req, res) => { try { - const { email, username, password, display_name, role } = req.body; + const { email, username, password, display_name, displayName, role } = req.body; + const dn = display_name || displayName; if (!email || !username || !password) { return res.status(400).json({ error: 'Email, username, and password are required' }); } + if (password.length < 6) { + return res.status(400).json({ error: 'Password must be at least 6 characters' }); + } const existing = getUserByEmail(email); if (existing) { return res.status(409).json({ error: 'Email already registered' }); } + const existingUsername = getUserByUsername(username); + if (existingUsername) { + return res.status(409).json({ error: 'Username already taken' }); + } const passwordHash = await hashPassword(password); const user = createUser({ @@ -39,8 +49,8 @@ router.post('/users', async (req, res) => { email, username, passwordHash, - displayName: display_name || username, - role: role || 'member', + displayName: dn || username, + role: role === 'admin' ? 'admin' : 'member', }); return res.status(201).json({ @@ -75,6 +85,9 @@ router.delete('/users/:userId', (req, res) => { if (!user) { return res.status(404).json({ error: 'User not found' }); } + if (req.user && req.user.id === req.params.userId) { + return res.status(400).json({ error: 'You cannot deactivate your own account' }); + } deactivateUser(req.params.userId); return res.json({ message: 'User deactivated' }); } catch (err) { @@ -83,6 +96,41 @@ router.delete('/users/:userId', (req, res) => { } }); +router.put('/users/:userId/reactivate', (req, res) => { + try { + const row = db.prepare('SELECT id FROM users WHERE id = ?').get(req.params.userId); + if (!row) { + return res.status(404).json({ error: 'User not found' }); + } + db.prepare("UPDATE users SET is_active = 1, updated_at = datetime('now') WHERE id = ?").run(req.params.userId); + return res.json({ message: 'User reactivated' }); + } catch (err) { + console.error('[admin] reactivate user error:', err); + return res.status(500).json({ error: 'Internal server error' }); + } +}); + +router.put('/users/:userId/role', (req, res) => { + try { + const { role } = req.body || {}; + if (role !== 'admin' && role !== 'member') { + return res.status(400).json({ error: "role must be 'admin' or 'member'" }); + } + const user = getUserById(req.params.userId); + if (!user) { + return res.status(404).json({ error: 'User not found' }); + } + if (req.user && req.user.id === req.params.userId && role !== 'admin') { + return res.status(400).json({ error: 'You cannot demote your own admin account' }); + } + db.prepare("UPDATE users SET role = ?, updated_at = datetime('now') WHERE id = ?").run(role, req.params.userId); + return res.json({ message: 'Role updated', role }); + } catch (err) { + console.error('[admin] update role error:', err); + return res.status(500).json({ error: 'Internal server error' }); + } +}); + router.put('/users/:userId/password', async (req, res) => { try { const { password } = req.body; diff --git a/backend/routes/upload.js b/backend/routes/upload.js index 85d5a7b..d809822 100644 --- a/backend/routes/upload.js +++ b/backend/routes/upload.js @@ -250,6 +250,10 @@ router.post('/boards/:boardId/images', upload.single('image'), async (req, res) if (err.code === 'LIMIT_FILE_SIZE') { return res.status(413).json({ error: `File too large (max ${MAX_FILE_SIZE_LABEL})` }); } + if (err.code === 'POPPLER_MISSING') { + console.error('[upload] poppler-utils missing — PDF support disabled until host installs it'); + return res.status(err.statusCode || 501).json({ error: err.message, code: err.code }); + } console.error('[upload] error:', err); return res.status(500).json({ error: 'Internal server error' }); } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f4b8bbf..585b8d2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,6 +5,7 @@ import Login from './pages/Login'; import CollectionList from './pages/CollectionList'; import CollectionDetail from './pages/CollectionDetail'; import Editor from './pages/Editor'; +import Admin from './pages/Admin'; function ProtectedRoute({ children }: { children: React.ReactNode }) { const { user, loading } = useAuth(); @@ -34,6 +35,7 @@ function AppRoutes() { } /> } /> } /> + } /> } /> } /> diff --git a/frontend/src/pages/Admin.tsx b/frontend/src/pages/Admin.tsx new file mode 100644 index 0000000..5316354 --- /dev/null +++ b/frontend/src/pages/Admin.tsx @@ -0,0 +1,449 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useAuth } from '../auth'; +import api from '../api'; + +interface AdminUser { + id: string; + email: string; + username: string; + display_name: string; + role: 'admin' | 'member'; + is_active: number; + created_at: string; + updated_at: string; +} + +const inputStyle: React.CSSProperties = { + padding: '10px 12px', + background: '#0d0d0d', + color: '#f0f0f0', + border: '1px solid #2a2a2a', + borderRadius: '6px', + fontSize: '13px', + width: '100%', + boxSizing: 'border-box', +}; + +function Toast({ msg, kind, onDone }: { msg: string; kind: 'ok' | 'err'; onDone: () => void }) { + useEffect(() => { + const t = setTimeout(onDone, 4000); + return () => clearTimeout(t); + }, [onDone]); + return ( +
{msg}
+ ); +} + +function CreateUserModal({ onClose, onCreated }: { onClose: () => void; onCreated: (msg: string) => void }) { + const [email, setEmail] = useState(''); + const [username, setUsername] = useState(''); + const [displayName, setDisplayName] = useState(''); + const [password, setPassword] = useState(''); + const [role, setRole] = useState<'admin' | 'member'>('member'); + const [err, setErr] = useState(''); + const [submitting, setSubmitting] = useState(false); + + async function submit(e: React.FormEvent) { + e.preventDefault(); + setErr(''); + setSubmitting(true); + try { + await api.post('/api/admin/users', { + email, username, password, + display_name: displayName || username, + role, + }); + onCreated(`Created ${email}`); + onClose(); + } catch (e: any) { + setErr(e?.response?.data?.error || 'Create failed'); + } finally { + setSubmitting(false); + } + } + + return ( +
+
e.stopPropagation()} onSubmit={submit} style={{ + background: '#161616', border: '1px solid #222', borderRadius: '14px', + padding: '28px', width: '440px', maxWidth: 'calc(100vw - 40px)', + boxShadow: '0 24px 64px rgba(0,0,0,0.6)', + }}> +

Create new user

+

+ New users sign in with email + password and start with the role you pick here. +

+ + {err && ( +
{err}
+ )} + + + + + + + +
+ + +
+
+
+ ); +} + +function ResetPasswordModal({ user, onClose, onDone }: { user: AdminUser; onClose: () => void; onDone: (msg: string) => void }) { + const [pw, setPw] = useState(''); + const [err, setErr] = useState(''); + const [submitting, setSubmitting] = useState(false); + + async function submit(e: React.FormEvent) { + e.preventDefault(); + setErr(''); + setSubmitting(true); + try { + await api.put(`/api/admin/users/${user.id}/password`, { password: pw }); + onDone(`Password reset for ${user.email}`); + onClose(); + } catch (e: any) { + setErr(e?.response?.data?.error || 'Reset failed'); + } finally { + setSubmitting(false); + } + } + + return ( +
+
e.stopPropagation()} onSubmit={submit} style={{ + background: '#161616', border: '1px solid #222', borderRadius: '14px', + padding: '28px', width: '400px', maxWidth: 'calc(100vw - 40px)', + boxShadow: '0 24px 64px rgba(0,0,0,0.6)', + }}> +

Reset password

+

For {user.email}

+ + {err && ( +
{err}
+ )} + + setPw(e.target.value)} required minLength={6} autoFocus /> +
+ + +
+
+
+ ); +} + +export default function Admin() { + const navigate = useNavigate(); + const { user: currentUser, loading: authLoading, logout } = useAuth(); + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(true); + const [loadErr, setLoadErr] = useState(''); + const [filter, setFilter] = useState(''); + const [showCreate, setShowCreate] = useState(false); + const [resetFor, setResetFor] = useState(null); + const [toast, setToast] = useState<{ msg: string; kind: 'ok' | 'err' } | null>(null); + + const refresh = useCallback(async () => { + setLoadErr(''); + try { + const res = await api.get('/api/admin/users'); + const list: AdminUser[] = res.data?.users || []; + list.sort((a, b) => { + if (a.is_active !== b.is_active) return b.is_active - a.is_active; + if (a.role !== b.role) return a.role === 'admin' ? -1 : 1; + return a.email.localeCompare(b.email); + }); + setUsers(list); + } catch (e: any) { + setLoadErr(e?.response?.data?.error || 'Failed to load users'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + if (!authLoading) refresh(); + }, [authLoading, refresh]); + + if (authLoading) { + return
Loading…
; + } + + if (!currentUser || currentUser.role !== 'admin') { + return ( +
+

403 — Admin only

+

You need admin privileges to view this page.

+ +
+ ); + } + + async function toggleRole(u: AdminUser) { + const next: 'admin' | 'member' = u.role === 'admin' ? 'member' : 'admin'; + try { + await api.put(`/api/admin/users/${u.id}/role`, { role: next }); + setToast({ msg: `${u.email} is now ${next}`, kind: 'ok' }); + refresh(); + } catch (e: any) { + setToast({ msg: e?.response?.data?.error || 'Update failed', kind: 'err' }); + } + } + + async function deactivate(u: AdminUser) { + if (!confirm(`Deactivate ${u.email}? They won't be able to sign in. This is reversible — you can reactivate them later.`)) return; + try { + await api.delete(`/api/admin/users/${u.id}`); + setToast({ msg: `${u.email} deactivated`, kind: 'ok' }); + refresh(); + } catch (e: any) { + setToast({ msg: e?.response?.data?.error || 'Deactivation failed', kind: 'err' }); + } + } + + async function reactivate(u: AdminUser) { + try { + await api.put(`/api/admin/users/${u.id}/reactivate`); + setToast({ msg: `${u.email} reactivated`, kind: 'ok' }); + refresh(); + } catch (e: any) { + setToast({ msg: e?.response?.data?.error || 'Reactivation failed', kind: 'err' }); + } + } + + const visible = users.filter((u) => { + if (!filter.trim()) return true; + const q = filter.toLowerCase(); + return u.email.toLowerCase().includes(q) + || u.username.toLowerCase().includes(q) + || (u.display_name || '').toLowerCase().includes(q); + }); + + const adminCount = users.filter((u) => u.role === 'admin' && u.is_active).length; + const memberCount = users.filter((u) => u.role === 'member' && u.is_active).length; + const inactiveCount = users.filter((u) => !u.is_active).length; + + return ( +
+ {/* Header */} +
+
+ +

Admin · User management

+
+
+ {currentUser.display_name || currentUser.email} + +
+
+ + {/* Stats + Controls */} +
+
+ + + + +
+ +
+ setFilter(e.target.value)} + style={{ ...inputStyle, maxWidth: 360 }} + /> +
+ +
+ + {loadErr && ( +
{loadErr}
+ )} + + {loading ? ( +
Loading users…
+ ) : ( +
+
+
Email
+
Username · Display
+
Role
+
Status
+
Actions
+
+ {visible.length === 0 ? ( +
No users match.
+ ) : visible.map((u) => { + const isMe = u.id === currentUser.id; + return ( +
+
+ {u.email}{isMe && (you)} +
+
+ {u.username}{u.display_name && u.display_name !== u.username ? ` · ${u.display_name}` : ''} +
+
+ {u.role} +
+
+ + {u.is_active ? '● Active' : '○ Inactive'} + +
+
+ toggleRole(u)} disabled={isMe && u.role === 'admin'}> + {u.role === 'admin' ? 'Demote' : 'Make admin'} + + setResetFor(u)}>Reset pw + {u.is_active ? ( + deactivate(u)} disabled={isMe} danger>Deactivate + ) : ( + reactivate(u)}>Reactivate + )} +
+
+ ); + })} +
+ )} +
+ + {showCreate && setShowCreate(false)} onCreated={(m) => { setToast({ msg: m, kind: 'ok' }); refresh(); }} />} + {resetFor && setResetFor(null)} onDone={(m) => setToast({ msg: m, kind: 'ok' })} />} + {toast && setToast(null)} />} +
+ ); +} + +function Stat({ label, value, accent }: { label: string; value: number; accent: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +function ActionButton({ children, onClick, disabled, danger }: { + children: React.ReactNode; + onClick: () => void; + disabled?: boolean; + danger?: boolean; +}) { + return ( + + ); +} diff --git a/frontend/src/pages/CollectionList.tsx b/frontend/src/pages/CollectionList.tsx index c04c383..524fc9b 100644 --- a/frontend/src/pages/CollectionList.tsx +++ b/frontend/src/pages/CollectionList.tsx @@ -238,6 +238,20 @@ export default function CollectionList() { {(user?.display_name || user?.email || '?')[0].toUpperCase()}
{user?.display_name || user?.email} + {user?.role === 'admin' && ( + + )}