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.
This commit is contained in:
Hiren Kangad
2026-04-28 20:34:49 +05:30
parent f2260a9e35
commit 782d6df9e0
8 changed files with 606 additions and 9 deletions
+16 -3
View File
@@ -50,7 +50,8 @@ Built because we needed PureRef's painlessness, Miro's collaboration, and a code
- First user is auto-admin - First user is auto-admin
- `SEED_ADMIN_*` env vars to bootstrap an admin on first boot - `SEED_ADMIN_*` env vars to bootstrap an admin on first boot
- `ALLOW_SELF_REGISTRATION` flag — when off, only admins can create accounts - `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** **Deployment**
- One `docker compose up` starts RefBoard + a bundled MinIO for object storage - 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) ## 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 ```bash
# 1. Object storage # 1. Object storage
@@ -269,8 +282,8 @@ See [CHANGELOG.md](CHANGELOG.md) for the version history (v0.1.0 → v0.5.0).
## Roadmap ## Roadmap
- [x] Admin dashboard frontend (live at `/admin` — user create / reset-password / role / deactivate)
- [ ] Per-board activity log (who added/deleted what, when) - [ ] 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 - [ ] Mobile-friendly read-only board view
- [ ] Export board → PDF / image grid - [ ] Export board → PDF / image grid
- [ ] Optional remote storage adapters (S3 direct, R2) - [ ] Optional remote storage adapters (S3 direct, R2)
+33
View File
@@ -81,6 +81,38 @@ function apiKeyMiddleware(req, res, next) {
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 = { module.exports = {
generateToken, generateToken,
verifyToken, verifyToken,
@@ -89,5 +121,6 @@ module.exports = {
authMiddleware, authMiddleware,
adminMiddleware, adminMiddleware,
apiKeyMiddleware, apiKeyMiddleware,
adminOrApiKeyMiddleware,
JWT_SECRET, JWT_SECRET,
}; };
+35 -1
View File
@@ -15,6 +15,32 @@ const execFileAsync = promisify(execFile);
const TIMEOUT_MS = 60_000; 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 }. * Write a buffer to a temporary file. Returns { tmpPath, cleanup }.
* Caller MUST call cleanup() when done. * 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,
};
+53 -5
View File
@@ -1,11 +1,13 @@
const { Router } = require('express'); const { Router } = require('express');
const { v4: uuidv4 } = require('uuid'); const { v4: uuidv4 } = require('uuid');
const { apiKeyMiddleware, hashPassword } = require('../auth'); const { adminOrApiKeyMiddleware, hashPassword } = require('../auth');
const { const {
db,
createUser, createUser,
getAllUsers, getAllUsers,
getUserById, getUserById,
getUserByEmail, getUserByEmail,
getUserByUsername,
deactivateUser, deactivateUser,
updateUserPassword, updateUserPassword,
createCollection, createCollection,
@@ -17,21 +19,29 @@ const {
const router = Router(); const router = Router();
router.use(apiKeyMiddleware); router.use(adminOrApiKeyMiddleware);
// ---- User management ---- // ---- User management ----
router.post('/users', async (req, res) => { router.post('/users', async (req, res) => {
try { 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) { if (!email || !username || !password) {
return res.status(400).json({ error: 'Email, username, and password are required' }); 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); const existing = getUserByEmail(email);
if (existing) { if (existing) {
return res.status(409).json({ error: 'Email already registered' }); 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 passwordHash = await hashPassword(password);
const user = createUser({ const user = createUser({
@@ -39,8 +49,8 @@ router.post('/users', async (req, res) => {
email, email,
username, username,
passwordHash, passwordHash,
displayName: display_name || username, displayName: dn || username,
role: role || 'member', role: role === 'admin' ? 'admin' : 'member',
}); });
return res.status(201).json({ return res.status(201).json({
@@ -75,6 +85,9 @@ router.delete('/users/:userId', (req, res) => {
if (!user) { if (!user) {
return res.status(404).json({ error: 'User not found' }); 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); deactivateUser(req.params.userId);
return res.json({ message: 'User deactivated' }); return res.json({ message: 'User deactivated' });
} catch (err) { } 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) => { router.put('/users/:userId/password', async (req, res) => {
try { try {
const { password } = req.body; const { password } = req.body;
+4
View File
@@ -250,6 +250,10 @@ router.post('/boards/:boardId/images', upload.single('image'), async (req, res)
if (err.code === 'LIMIT_FILE_SIZE') { if (err.code === 'LIMIT_FILE_SIZE') {
return res.status(413).json({ error: `File too large (max ${MAX_FILE_SIZE_LABEL})` }); 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); console.error('[upload] error:', err);
return res.status(500).json({ error: 'Internal server error' }); return res.status(500).json({ error: 'Internal server error' });
} }
+2
View File
@@ -5,6 +5,7 @@ import Login from './pages/Login';
import CollectionList from './pages/CollectionList'; import CollectionList from './pages/CollectionList';
import CollectionDetail from './pages/CollectionDetail'; import CollectionDetail from './pages/CollectionDetail';
import Editor from './pages/Editor'; import Editor from './pages/Editor';
import Admin from './pages/Admin';
function ProtectedRoute({ children }: { children: React.ReactNode }) { function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth(); const { user, loading } = useAuth();
@@ -34,6 +35,7 @@ function AppRoutes() {
<Route path="/" element={<ProtectedRoute><CollectionList /></ProtectedRoute>} /> <Route path="/" element={<ProtectedRoute><CollectionList /></ProtectedRoute>} />
<Route path="/collection/:collectionId" element={<ProtectedRoute><CollectionDetail /></ProtectedRoute>} /> <Route path="/collection/:collectionId" element={<ProtectedRoute><CollectionDetail /></ProtectedRoute>} />
<Route path="/board/:boardId" element={<ProtectedRoute><Editor /></ProtectedRoute>} /> <Route path="/board/:boardId" element={<ProtectedRoute><Editor /></ProtectedRoute>} />
<Route path="/admin" element={<ProtectedRoute><Admin /></ProtectedRoute>} />
<Route path="/c/:shareToken" element={<CollectionDetail isPublicView />} /> <Route path="/c/:shareToken" element={<CollectionDetail isPublicView />} />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
+449
View File
@@ -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 (
<div style={{
position: 'fixed', bottom: '24px', right: '24px',
background: kind === 'ok' ? 'rgba(40, 100, 50, 0.95)' : 'rgba(120, 40, 40, 0.95)',
color: '#fff', padding: '12px 18px', borderRadius: '8px',
fontSize: '13px', maxWidth: '420px',
border: `1px solid ${kind === 'ok' ? '#3a7a4d' : '#a04545'}`,
boxShadow: '0 8px 32px rgba(0,0,0,0.5)', zIndex: 1000,
}}>{msg}</div>
);
}
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 (
<div onClick={onClose} style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(4px)',
display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 200,
}}>
<form onClick={(e) => 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)',
}}>
<h2 style={{ margin: '0 0 6px', fontSize: '18px', fontWeight: 700, color: '#f0f0f0' }}>Create new user</h2>
<p style={{ margin: '0 0 20px', fontSize: '12px', color: '#666' }}>
New users sign in with email + password and start with the role you pick here.
</p>
{err && (
<div style={{
background: 'rgba(255,107,107,0.08)', border: '1px solid rgba(255,107,107,0.2)',
color: '#ff8a8a', padding: '10px 14px', borderRadius: '6px',
fontSize: '12px', marginBottom: '14px',
}}>{err}</div>
)}
<label style={{ display: 'block', marginBottom: '14px' }}>
<span style={{ display: 'block', fontSize: '11px', color: '#888', marginBottom: '4px' }}>Email</span>
<input style={inputStyle} type="email" value={email} onChange={(e) => setEmail(e.target.value)} required autoFocus />
</label>
<label style={{ display: 'block', marginBottom: '14px' }}>
<span style={{ display: 'block', fontSize: '11px', color: '#888', marginBottom: '4px' }}>Username</span>
<input style={inputStyle} type="text" value={username} onChange={(e) => setUsername(e.target.value)} required />
</label>
<label style={{ display: 'block', marginBottom: '14px' }}>
<span style={{ display: 'block', fontSize: '11px', color: '#888', marginBottom: '4px' }}>Display name <span style={{ color: '#555' }}>(optional)</span></span>
<input style={inputStyle} type="text" value={displayName} onChange={(e) => setDisplayName(e.target.value)} />
</label>
<label style={{ display: 'block', marginBottom: '14px' }}>
<span style={{ display: 'block', fontSize: '11px', color: '#888', marginBottom: '4px' }}>Password (min 6 chars)</span>
<input style={inputStyle} type="password" value={password} onChange={(e) => setPassword(e.target.value)} required minLength={6} />
</label>
<label style={{ display: 'block', marginBottom: '20px' }}>
<span style={{ display: 'block', fontSize: '11px', color: '#888', marginBottom: '4px' }}>Role</span>
<select style={inputStyle} value={role} onChange={(e) => setRole(e.target.value as 'admin' | 'member')}>
<option value="member">Member</option>
<option value="admin">Admin</option>
</select>
</label>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '10px' }}>
<button type="button" onClick={onClose} style={{
padding: '10px 16px', background: 'transparent', border: '1px solid #2a2a2a',
borderRadius: '6px', color: '#888', fontSize: '13px', cursor: 'pointer',
}}>Cancel</button>
<button type="submit" disabled={submitting} style={{
padding: '10px 18px', background: submitting ? '#2a4f9a' : '#386fe5',
border: 'none', borderRadius: '6px', color: '#fff',
fontSize: '13px', fontWeight: 600, cursor: submitting ? 'wait' : 'pointer',
}}>{submitting ? 'Creating…' : 'Create user'}</button>
</div>
</form>
</div>
);
}
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 (
<div onClick={onClose} style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(4px)',
display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 200,
}}>
<form onClick={(e) => 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)',
}}>
<h2 style={{ margin: '0 0 6px', fontSize: '17px', fontWeight: 700, color: '#f0f0f0' }}>Reset password</h2>
<p style={{ margin: '0 0 18px', fontSize: '12px', color: '#888' }}>For <span style={{ color: '#ccc' }}>{user.email}</span></p>
{err && (
<div style={{
background: 'rgba(255,107,107,0.08)', border: '1px solid rgba(255,107,107,0.2)',
color: '#ff8a8a', padding: '10px 14px', borderRadius: '6px',
fontSize: '12px', marginBottom: '14px',
}}>{err}</div>
)}
<input style={inputStyle} type="password" placeholder="New password (min 6 chars)" value={pw} onChange={(e) => setPw(e.target.value)} required minLength={6} autoFocus />
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '10px', marginTop: '20px' }}>
<button type="button" onClick={onClose} style={{
padding: '10px 16px', background: 'transparent', border: '1px solid #2a2a2a',
borderRadius: '6px', color: '#888', fontSize: '13px', cursor: 'pointer',
}}>Cancel</button>
<button type="submit" disabled={submitting} style={{
padding: '10px 18px', background: submitting ? '#2a4f9a' : '#386fe5',
border: 'none', borderRadius: '6px', color: '#fff',
fontSize: '13px', fontWeight: 600, cursor: submitting ? 'wait' : 'pointer',
}}>{submitting ? 'Resetting…' : 'Reset password'}</button>
</div>
</form>
</div>
);
}
export default function Admin() {
const navigate = useNavigate();
const { user: currentUser, loading: authLoading, logout } = useAuth();
const [users, setUsers] = useState<AdminUser[]>([]);
const [loading, setLoading] = useState(true);
const [loadErr, setLoadErr] = useState('');
const [filter, setFilter] = useState('');
const [showCreate, setShowCreate] = useState(false);
const [resetFor, setResetFor] = useState<AdminUser | null>(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 <div style={{ padding: 40, color: '#888', background: '#0a0a0a', minHeight: '100vh' }}>Loading</div>;
}
if (!currentUser || currentUser.role !== 'admin') {
return (
<div style={{ padding: 60, background: '#0a0a0a', minHeight: '100vh', color: '#e8e8e8', textAlign: 'center' }}>
<h1 style={{ fontSize: 22, marginBottom: 8 }}>403 Admin only</h1>
<p style={{ color: '#888', marginBottom: 20 }}>You need admin privileges to view this page.</p>
<button onClick={() => navigate('/')} style={{
padding: '10px 18px', background: '#386fe5', border: 'none', borderRadius: 6,
color: '#fff', fontSize: 13, fontWeight: 600, cursor: 'pointer',
}}>Back to collections</button>
</div>
);
}
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 (
<div style={{ minHeight: '100vh', background: '#0a0a0a', color: '#e8e8e8' }}>
{/* Header */}
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '12px 32px', background: '#0f0f0f', borderBottom: '1px solid #1a1a1a',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '14px' }}>
<button onClick={() => navigate('/')} style={{
padding: '6px 12px', background: 'transparent', border: '1px solid #252525',
borderRadius: 6, color: '#888', fontSize: 12, cursor: 'pointer',
}}> Collections</button>
<h1 style={{ margin: 0, fontSize: 16, fontWeight: 700, color: '#e8e8e8' }}>Admin · User management</h1>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
<span style={{ fontSize: 13, color: '#888' }}>{currentUser.display_name || currentUser.email}</span>
<button onClick={() => { logout(); navigate('/login', { replace: true }); }} style={{
padding: '6px 14px', background: 'transparent', border: '1px solid #252525',
borderRadius: 6, color: '#666', fontSize: 12, cursor: 'pointer',
}}>Sign out</button>
</div>
</div>
{/* Stats + Controls */}
<div style={{ padding: '24px 32px 16px' }}>
<div style={{ display: 'flex', gap: 12, marginBottom: 18 }}>
<Stat label="Admins" value={adminCount} accent="#7ba9ff" />
<Stat label="Members" value={memberCount} accent="#9aa9bb" />
<Stat label="Inactive" value={inactiveCount} accent="#5a5a5a" />
<Stat label="Total" value={users.length} accent="#cfd6df" />
</div>
<div style={{ display: 'flex', gap: 12, alignItems: 'center', marginBottom: 16 }}>
<input
placeholder="Filter by email, username, display name…"
value={filter}
onChange={(e) => setFilter(e.target.value)}
style={{ ...inputStyle, maxWidth: 360 }}
/>
<div style={{ flex: 1 }} />
<button onClick={() => setShowCreate(true)} style={{
padding: '10px 18px', background: '#386fe5', border: 'none', borderRadius: 6,
color: '#fff', fontSize: 13, fontWeight: 600, cursor: 'pointer',
boxShadow: '0 2px 12px rgba(56,111,229,0.3)',
}}>+ New user</button>
</div>
{loadErr && (
<div style={{
background: 'rgba(255,107,107,0.08)', border: '1px solid rgba(255,107,107,0.2)',
color: '#ff8a8a', padding: '10px 14px', borderRadius: 6, fontSize: 13, marginBottom: 16,
}}>{loadErr}</div>
)}
{loading ? (
<div style={{ color: '#666', padding: 40, textAlign: 'center' }}>Loading users</div>
) : (
<div style={{
background: '#101010', border: '1px solid #1c1c1c', borderRadius: 10, overflow: 'hidden',
}}>
<div style={{
display: 'grid', gridTemplateColumns: '1fr 1fr 100px 100px 1fr',
padding: '12px 16px', background: '#0c0c0c', borderBottom: '1px solid #1a1a1a',
fontSize: 11, fontWeight: 600, color: '#666', textTransform: 'uppercase', letterSpacing: '0.6px',
}}>
<div>Email</div>
<div>Username · Display</div>
<div>Role</div>
<div>Status</div>
<div style={{ textAlign: 'right' }}>Actions</div>
</div>
{visible.length === 0 ? (
<div style={{ padding: 40, color: '#666', textAlign: 'center' }}>No users match.</div>
) : visible.map((u) => {
const isMe = u.id === currentUser.id;
return (
<div key={u.id} style={{
display: 'grid', gridTemplateColumns: '1fr 1fr 100px 100px 1fr',
padding: '12px 16px', borderTop: '1px solid #181818',
alignItems: 'center', fontSize: 13,
opacity: u.is_active ? 1 : 0.5,
}}>
<div style={{ color: '#dadada', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', paddingRight: 12 }}>
{u.email}{isMe && <span style={{ color: '#7ba9ff', marginLeft: 8, fontSize: 11 }}>(you)</span>}
</div>
<div style={{ color: '#888', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', paddingRight: 12 }}>
{u.username}{u.display_name && u.display_name !== u.username ? ` · ${u.display_name}` : ''}
</div>
<div>
<span style={{
display: 'inline-block', padding: '3px 10px', borderRadius: 12,
fontSize: 11, fontWeight: 600,
background: u.role === 'admin' ? 'rgba(74,158,255,0.12)' : 'rgba(255,255,255,0.04)',
color: u.role === 'admin' ? '#7ba9ff' : '#999',
border: u.role === 'admin' ? '1px solid rgba(74,158,255,0.25)' : '1px solid #222',
}}>{u.role}</span>
</div>
<div>
<span style={{ fontSize: 11, color: u.is_active ? '#5fc97e' : '#888' }}>
{u.is_active ? '● Active' : '○ Inactive'}
</span>
</div>
<div style={{ display: 'flex', gap: 6, justifyContent: 'flex-end' }}>
<ActionButton onClick={() => toggleRole(u)} disabled={isMe && u.role === 'admin'}>
{u.role === 'admin' ? 'Demote' : 'Make admin'}
</ActionButton>
<ActionButton onClick={() => setResetFor(u)}>Reset pw</ActionButton>
{u.is_active ? (
<ActionButton onClick={() => deactivate(u)} disabled={isMe} danger>Deactivate</ActionButton>
) : (
<ActionButton onClick={() => reactivate(u)}>Reactivate</ActionButton>
)}
</div>
</div>
);
})}
</div>
)}
</div>
{showCreate && <CreateUserModal onClose={() => setShowCreate(false)} onCreated={(m) => { setToast({ msg: m, kind: 'ok' }); refresh(); }} />}
{resetFor && <ResetPasswordModal user={resetFor} onClose={() => setResetFor(null)} onDone={(m) => setToast({ msg: m, kind: 'ok' })} />}
{toast && <Toast msg={toast.msg} kind={toast.kind} onDone={() => setToast(null)} />}
</div>
);
}
function Stat({ label, value, accent }: { label: string; value: number; accent: string }) {
return (
<div style={{
flex: 1, padding: '14px 16px', background: '#101010',
border: '1px solid #1c1c1c', borderRadius: 10,
}}>
<div style={{ fontSize: 11, color: '#666', textTransform: 'uppercase', letterSpacing: '0.5px', marginBottom: 4 }}>{label}</div>
<div style={{ fontSize: 22, fontWeight: 700, color: accent, letterSpacing: '-0.5px' }}>{value}</div>
</div>
);
}
function ActionButton({ children, onClick, disabled, danger }: {
children: React.ReactNode;
onClick: () => void;
disabled?: boolean;
danger?: boolean;
}) {
return (
<button
onClick={onClick}
disabled={disabled}
style={{
padding: '6px 10px',
background: 'transparent',
border: `1px solid ${danger ? '#5a2a2a' : '#252525'}`,
borderRadius: 5,
color: disabled ? '#444' : danger ? '#ff8a8a' : '#aaa',
fontSize: 11,
cursor: disabled ? 'not-allowed' : 'pointer',
whiteSpace: 'nowrap',
}}
>{children}</button>
);
}
+14
View File
@@ -238,6 +238,20 @@ export default function CollectionList() {
{(user?.display_name || user?.email || '?')[0].toUpperCase()} {(user?.display_name || user?.email || '?')[0].toUpperCase()}
</div> </div>
<span style={{ fontSize: '13px', color: '#888' }}>{user?.display_name || user?.email}</span> <span style={{ fontSize: '13px', color: '#888' }}>{user?.display_name || user?.email}</span>
{user?.role === 'admin' && (
<button
onClick={() => navigate('/admin')}
style={{
padding: '6px 14px', background: 'transparent', border: '1px solid #2a3a5c',
borderRadius: '6px', color: '#7ba9ff', fontSize: '12px', cursor: 'pointer',
transition: 'all 0.15s',
}}
onMouseEnter={(e) => { e.currentTarget.style.borderColor = '#4a9eff'; e.currentTarget.style.color = '#a8c8ff'; }}
onMouseLeave={(e) => { e.currentTarget.style.borderColor = '#2a3a5c'; e.currentTarget.style.color = '#7ba9ff'; }}
>
Admin
</button>
)}
<button <button
onClick={() => { logout(); navigate('/login', { replace: true }); }} onClick={() => { logout(); navigate('/login', { replace: true }); }}
style={{ style={{