source: Klonkt/src/routes/admin-users.js@ 628056f

main
Last change on this file since 628056f was 8afbdd6, checked in by roboburr <roboburr@…>, 3 months ago

feat: viewer role + artists directory + Klonkt Hub Beta rebrand

Viewer role (replaces the separate view-mode/readonly toggle):

  • 'kijker' is now a real role (VALID_ROLES) selectable in Admin. May view EVERYTHING including Admin, but cannot modify anything.
  • isViewer(user) (= role kijker or legacy readonly flag) is the source; requireGod/requireSiteManager(BySlug) let a viewer through (viewing), the global guard 403s every write. Existing readonly accounts are migrated on each role change (readonly=0).
  • Write leaks via GET patched: /prutter/new (INSERT) blocks for viewers, /prutter/:id skips markAsRead (UPDATE); WS upgrade rejects viewers (the HTTP guard doesn't cover WebSockets).
  • Clean "Viewer mode" page (viewer-blocked) instead of raw 403 text; styled sticky banner; account page shows read-only UI instead of an upload button that silently 403s. canMutate hides write buttons.

Scalability (>50 artists):

  • Hub home shows max 24 (most active first) + "All N artists ->".
  • New searchable, paginated /artiesten directory (hub only).
  • 'user' + 'artiesten' reserved as slugs.

Rebrand PrutFolio v1 -> Klonkt Hub Beta (footer, PWA manifest, account/
admin texts, default page title, startup, README; internal package +
PWA id 'prutfolio' remain for stability).

Co-Authored-By: Claude <noreply@…>

  • Property mode set to 100644
File size: 5.2 KB
RevLine 
[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
15import express from 'express';
16import db from '../config/database.js';
17import { renderPage } from '../middleware/render.js';
18import { requireGod } from '../middleware/auth.js';
19
20const router = express.Router();
21
[8afbdd6]22// 'kijker' = alleen-lezen demonstratie/audit-account: mag alles bekijken (incl.
23// Beheer), maar de globale guard blokkeert elke wijziging. Vervangt de oude
24// losse 'kijk-modus'-vlag (readonly), die nu door deze rol wordt afgedekt.
25const VALID_ROLES = new Set(['kijker', 'member', 'admin', 'god']);
[7bc636b]26
27function godCount() {
28 return db.prepare("SELECT COUNT(*) AS c FROM users WHERE role = 'god'").get().c;
29}
30
31// ==================== LIST ====================
32router.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 ====================
51router.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
[8afbdd6]71 // readonly=0: de alleen-lezen-status zit nu volledig in de 'kijker'-rol, dus
72 // bij elke rolwijziging ruimen we de legacy-vlag op (geen dubbele bron).
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 ====================
79router.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
[0c2228a]91 // Cascade-verwijderen: de sites van deze user (+ posts/playlists/audio/leden/
92 // comments daaronder), z'n eigen content elders, en daarna de user zelf.
93 // Atomisch in een transactie — faalt er een FK, dan rolt alles terug.
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 }
104 // Eigen content op andere sites + losse koppelingen.
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
122export default router;
Note: See TracBrowser for help on using the repository browser.