| [7bc636b] | 1 | /**
|
|---|
| 2 | * Admin: User management — Phase E.
|
|---|
| 3 | *
|
|---|
| 4 | * GET /admin/users -> list users
|
|---|
| 5 | * POST /admin/users/:id/role -> change role (member/admin/god)
|
|---|
| 6 | * POST /admin/users/:id/delete -> delete (refused if user has owned content)
|
|---|
| 7 | *
|
|---|
| 8 | * Safety rules:
|
|---|
| 9 | * - The system always keeps at least 1 god (you can't demote/delete the last one).
|
|---|
| 10 | * - You can't change your OWN role to non-god (avoid locking yourself out).
|
|---|
| 11 | * - Delete is refused if the user owns any sites or has any posts.
|
|---|
| 12 | * Leaves it to god to reassign content first.
|
|---|
| 13 | */
|
|---|
| 14 |
|
|---|
| 15 | import express from 'express';
|
|---|
| 16 | import db from '../config/database.js';
|
|---|
| 17 | import { renderPage } from '../middleware/render.js';
|
|---|
| 18 | import { requireGod } from '../middleware/auth.js';
|
|---|
| 19 |
|
|---|
| 20 | const router = express.Router();
|
|---|
| 21 |
|
|---|
| [834bcc3] | 22 | // 'kijker' = read-only demo/audit account: may view everything (incl. admin panel),
|
|---|
| 23 | // but the global guard blocks all mutations. Replaces the old separate
|
|---|
| 24 | // 'kijk-modus' flag (readonly), which is now covered by this role.
|
|---|
| [8afbdd6] | 25 | const VALID_ROLES = new Set(['kijker', 'member', 'admin', 'god']);
|
|---|
| [7bc636b] | 26 |
|
|---|
| 27 | function godCount() {
|
|---|
| 28 | return db.prepare("SELECT COUNT(*) AS c FROM users WHERE role = 'god'").get().c;
|
|---|
| 29 | }
|
|---|
| 30 |
|
|---|
| 31 | // ==================== LIST ====================
|
|---|
| 32 | router.get('/', requireGod, (req, res) => {
|
|---|
| 33 | const users = db.prepare(`
|
|---|
| [a64f642] | 34 | SELECT u.id, u.username, u.email, u.role, u.created_at, u.avatar_url, u.readonly,
|
|---|
| [7bc636b] | 35 | (SELECT COUNT(*) FROM posts p WHERE p.author_id = u.id) AS post_count,
|
|---|
| 36 | (SELECT COUNT(*) FROM sites s WHERE s.owner_id = u.id) AS site_count
|
|---|
| 37 | FROM users u
|
|---|
| 38 | ORDER BY u.created_at DESC
|
|---|
| 39 | `).all();
|
|---|
| 40 |
|
|---|
| 41 | renderPage(req, res, 'pages/admin-users', {
|
|---|
| 42 | pageTitle: 'Users',
|
|---|
| 43 | bodyClass: 'on-admin',
|
|---|
| 44 | users,
|
|---|
| 45 | success: req.query.success || null,
|
|---|
| 46 | error: req.query.error || null,
|
|---|
| 47 | });
|
|---|
| 48 | });
|
|---|
| 49 |
|
|---|
| 50 | // ==================== CHANGE ROLE ====================
|
|---|
| 51 | router.post('/:id/role', requireGod, (req, res) => {
|
|---|
| 52 | const userId = req.params.id;
|
|---|
| 53 | const newRole = (req.body.role || '').toString();
|
|---|
| 54 | if (!VALID_ROLES.has(newRole)) {
|
|---|
| 55 | return res.redirect('/admin/users?error=Invalid+role');
|
|---|
| 56 | }
|
|---|
| 57 |
|
|---|
| 58 | const target = db.prepare('SELECT id, role FROM users WHERE id = ?').get(userId);
|
|---|
| 59 | if (!target) return res.redirect('/admin/users?error=User+not+found');
|
|---|
| 60 |
|
|---|
| 61 | // Prevent self-demotion away from god (lock-out protection)
|
|---|
| 62 | if (target.id === req.session.user.id && newRole !== 'god') {
|
|---|
| 63 | return res.redirect('/admin/users?error=' + encodeURIComponent('Cannot demote yourself'));
|
|---|
| 64 | }
|
|---|
| 65 |
|
|---|
| 66 | // Prevent removing the last god
|
|---|
| 67 | if (target.role === 'god' && newRole !== 'god' && godCount() <= 1) {
|
|---|
| 68 | return res.redirect('/admin/users?error=' + encodeURIComponent('Cannot demote the only remaining god'));
|
|---|
| 69 | }
|
|---|
| 70 |
|
|---|
| [834bcc3] | 71 | // readonly=0: read-only status now lives entirely in the 'kijker' role, so
|
|---|
| 72 | // on every role change we clear the legacy flag (no dual source of truth).
|
|---|
| [8afbdd6] | 73 | db.prepare('UPDATE users SET role = ?, readonly = 0, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
|
|---|
| [7bc636b] | 74 | .run(newRole, userId);
|
|---|
| 75 | res.redirect('/admin/users?success=' + encodeURIComponent('Role updated'));
|
|---|
| 76 | });
|
|---|
| 77 |
|
|---|
| 78 | // ==================== DELETE ====================
|
|---|
| 79 | router.post('/:id/delete', requireGod, (req, res) => {
|
|---|
| 80 | const userId = req.params.id;
|
|---|
| 81 | const target = db.prepare('SELECT id, role, username FROM users WHERE id = ?').get(userId);
|
|---|
| 82 | if (!target) return res.redirect('/admin/users?error=User+not+found');
|
|---|
| 83 |
|
|---|
| 84 | if (target.id === req.session.user.id) {
|
|---|
| 85 | return res.redirect('/admin/users?error=' + encodeURIComponent('Cannot delete yourself'));
|
|---|
| 86 | }
|
|---|
| 87 | if (target.role === 'god' && godCount() <= 1) {
|
|---|
| 88 | return res.redirect('/admin/users?error=' + encodeURIComponent('Cannot delete the only remaining god'));
|
|---|
| 89 | }
|
|---|
| 90 |
|
|---|
| [834bcc3] | 91 | // Cascade delete: this user's sites (+ posts/playlists/audio/members/
|
|---|
| 92 | // comments under them), their own content elsewhere, then the user themselves.
|
|---|
| 93 | // Atomic in a transaction — if any FK fails, everything rolls back.
|
|---|
| [0c2228a] | 94 | const del = db.transaction(() => {
|
|---|
| 95 | const sites = db.prepare('SELECT id FROM sites WHERE owner_id = ?').all(userId).map((s) => s.id);
|
|---|
| 96 | for (const sid of sites) {
|
|---|
| 97 | db.prepare('DELETE FROM comments WHERE post_id IN (SELECT id FROM posts WHERE site_id = ?)').run(sid);
|
|---|
| 98 | db.prepare('DELETE FROM posts WHERE site_id = ?').run(sid);
|
|---|
| 99 | db.prepare('DELETE FROM playlists WHERE site_id = ?').run(sid);
|
|---|
| 100 | db.prepare('DELETE FROM audio_tracks WHERE site_id = ?').run(sid);
|
|---|
| 101 | db.prepare('DELETE FROM site_members WHERE site_id = ?').run(sid);
|
|---|
| 102 | db.prepare('DELETE FROM sites WHERE id = ?').run(sid);
|
|---|
| 103 | }
|
|---|
| [834bcc3] | 104 | // Own content on other sites + loose associations.
|
|---|
| [0c2228a] | 105 | db.prepare('DELETE FROM comments WHERE post_id IN (SELECT id FROM posts WHERE author_id = ?)').run(userId);
|
|---|
| 106 | db.prepare('DELETE FROM posts WHERE author_id = ?').run(userId);
|
|---|
| 107 | db.prepare('DELETE FROM comments WHERE author_id = ?').run(userId);
|
|---|
| 108 | db.prepare('DELETE FROM site_members WHERE user_id = ?').run(userId);
|
|---|
| 109 | db.prepare('DELETE FROM users WHERE id = ?').run(userId);
|
|---|
| 110 | });
|
|---|
| [7bc636b] | 111 |
|
|---|
| [0c2228a] | 112 | try {
|
|---|
| 113 | del();
|
|---|
| 114 | } catch (e) {
|
|---|
| 115 | console.error('[admin/users delete]', e.message);
|
|---|
| 116 | return res.redirect('/admin/users?error=' + encodeURIComponent('Verwijderen mislukt (mogelijk gekoppelde data).'));
|
|---|
| 117 | }
|
|---|
| [7bc636b] | 118 |
|
|---|
| [0c2228a] | 119 | res.redirect('/admin/users?success=' + encodeURIComponent('Gebruiker verwijderd: ' + target.username));
|
|---|
| [7bc636b] | 120 | });
|
|---|
| 121 |
|
|---|
| 122 | export default router;
|
|---|