From d6254a8be5297240d9a9880152bee91ee9205a67 Mon Sep 17 00:00:00 2001 From: maashkinoozhe Date: Thu, 22 Oct 2026 19:35:04 +0200 Subject: [PATCH] Admins can grant or revoke admin rights in Settings (BL-0045) New POST /api/admin/user/role {name, admin}: admin-only (checked on the server), keeps the password and sessions, takes effect on the target's current session at once, logs who changed whom. Revoking your own rights is refused, so at least one admin always remains. Settings -> Users & access shows "Make admin" / "Remove admin" next to every other user, with a confirmation. Test: normal users and anonymous callers are refused, input checks, promotion and revocation apply immediately and are saved. Co-Authored-By: Claude Opus 5.5 --- docs/architecture.md | 1 + public/settings.js | 14 +++++-- server.js | 15 +++++++ test/admin-role.test.js | 87 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 113 insertions(+), 4 deletions(-) create mode 100644 test/admin-role.test.js diff --git a/docs/architecture.md b/docs/architecture.md index 8ab383f..cf464ac 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -204,6 +204,7 @@ All requests and responses are JSON. POSTs require `Content-Type: application/js | `GET /api/admin` | admin | Users, last scan result, library path | | `POST /api/admin/rescan` `{full}` | admin | Scan now, and wait for the result | | `POST /api/admin/user` `{name, password, admin}` | admin | Create a user or reset a password | +| `POST /api/admin/user/role` `{name, admin}` | admin | Grant or revoke admin rights; keeps the password and sessions. Revoking your own rights is refused, so one admin always remains | | `POST /api/admin/user/delete` `{name}` | admin | Delete a user and their data | | `GET /api/admin/backup` ยท `POST /api/admin/backup` `{command:"settings"\|"run"}` | admin | Built-in backup status, schedule/retention, "back up now" ([backup.md](backup.md)) | diff --git a/public/settings.js b/public/settings.js index b33615f..679c1e4 100644 --- a/public/settings.js +++ b/public/settings.js @@ -473,10 +473,16 @@ h('div', { class: 'field' }, h('span', {}, 'Users'), h('div', { class: 'users' }, data.users.map(u => h('div', {}, h('span', {}, u.name, u.admin ? h('span', { class: 'tag', style: 'margin-left:8px' }, 'admin') : null), - u.name === me.name ? h('span', { class: 'muted small' }, 'you') : h('button', { class: 'btn small', onclick: async () => { - if (!confirm(`Delete ${u.name} and their reading progress?`)) return; - await api('api/admin/user/delete', { name: u.name }); load(); - } }, 'Delete'))))), + u.name === me.name ? h('span', { class: 'muted small' }, 'you') : h('span', { class: 'row' }, + h('button', { class: 'btn small', onclick: async () => { + if (!confirm(u.admin ? `Remove admin rights from ${u.name}?` : `Make ${u.name} an admin? Admins can manage users, the library and the scraper.`)) return; + try { await api('api/admin/user/role', { name: u.name, admin: !u.admin }); load(); } + catch (e) { msg.textContent = e.message; msg.className = 'small error'; } + } }, u.admin ? 'Remove admin' : 'Make admin'), + h('button', { class: 'btn small', onclick: async () => { + if (!confirm(`Delete ${u.name} and their reading progress?`)) return; + await api('api/admin/user/delete', { name: u.name }); load(); + } }, 'Delete')))))), h('div', { class: 'field' }, h('span', {}, 'Add a user or reset a password'), h('div', { class: 'row' }, name, pw, h('label', { class: 'row' }, isAdmin, 'admin'), h('button', { class: 'btn', onclick: async () => { diff --git a/server.js b/server.js index 35c816b..2fe2728 100644 --- a/server.js +++ b/server.js @@ -735,6 +735,21 @@ const routes = { createUser(name, b.password, name === u.name || !!b.admin, true); return { ok: true }; }, + // Grant or revoke admin rights without touching the password. Admins only (checked here, not + // just hidden in the UI); nobody can revoke their own rights, so at least one admin always remains. + 'POST /api/admin/user/role': ({ u, b }) => { + admin(u); + const name = String(b.name || '').trim().toLowerCase(); + const target = db.users[name] || bad(404, 'No such user.'); + if (typeof b.admin !== 'boolean') bad(400, 'admin must be true or false.'); + if (name === u.name && !b.admin) bad(400, "You can't remove your own admin rights."); + if (!!target.admin !== b.admin) { + target.admin = b.admin; + save(); + console.log(`[users] ${u.name} ${b.admin ? 'made' : 'removed'} ${name} ${b.admin ? 'an admin' : 'as admin'}`); + } + return { ok: true, name, admin: target.admin }; + }, 'POST /api/admin/user/delete': ({ u, b }) => { admin(u); const name = String(b.name || ''); diff --git a/test/admin-role.test.js b/test/admin-role.test.js new file mode 100644 index 0000000..6f5f386 --- /dev/null +++ b/test/admin-role.test.js @@ -0,0 +1,87 @@ +'use strict'; +// BL-0045: admins grant or revoke admin rights in Settings -> Users & access. +const test = require('node:test'), assert = require('node:assert/strict'); +const fs = require('fs'), os = require('os'), path = require('path'), net = require('net'); +const { spawn } = require('child_process'); + +async function freePort() { + const listener = net.createServer(); + await new Promise(resolve => listener.listen(0, '127.0.0.1', resolve)); + const port = listener.address().port; + await new Promise(resolve => listener.close(resolve)); + return port; +} + +test('only admins can promote or demote, nobody can demote themselves, and changes apply at once', { timeout: 20000 }, async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mc-admin-role-')); + let child; + try { + const library = path.join(root, 'library'), data = path.join(root, 'data'); + fs.mkdirSync(library); fs.mkdirSync(data); + const port = await freePort(), output = []; + child = spawn(process.execPath, ['server.js'], { cwd: path.resolve(__dirname, '..'), windowsHide: true, + env: { ...process.env, LIBRARY: library, DATA: data, PORT: String(port), SCAN_INTERVAL: '0', FULL_SCAN_INTERVAL: '0', + SCRAPER_URL: 'http://127.0.0.1:9', SCRAPER_TOKEN: 'fixture-token', SCRAPER_TOKEN_FILE: '', BACKUP: '' } }); + child.stdout.on('data', d => output.push(String(d))); + child.stderr.on('data', d => output.push(String(d))); + const base = `http://127.0.0.1:${port}`; + let ready = false; + for (let n = 0; n < 100 && !ready; n++) { + ready = await fetch(base + '/health').then(r => r.ok).catch(() => false); + if (!ready) await new Promise(resolve => setTimeout(resolve, 50)); + } + assert.ok(ready, output.join('')); + + const post = (cookie, url, body) => fetch(base + url, { method: 'POST', + headers: { 'content-type': 'application/json', ...(cookie ? { cookie } : {}) }, body: JSON.stringify(body) }); + const get = (cookie, url) => fetch(base + url, { headers: { cookie } }); + const session = r => r.headers.get('set-cookie').split(';')[0]; + + const first = await post(null, '/api/signup', { name: 'boss', password: 'boss-password' }); + assert.equal(first.status, 200); + const boss = session(first); + assert.equal((await post(boss, '/api/admin/user', { name: 'reader', password: 'reader-password' })).status, 200); + const login = await post(null, '/api/login', { name: 'reader', password: 'reader-password' }); + assert.equal(login.status, 200); + const reader = session(login); + + // a normal user can neither promote themselves nor reach admin pages + assert.equal((await post(reader, '/api/admin/user/role', { name: 'reader', admin: true })).status, 403); + assert.equal((await get(reader, '/api/admin')).status, 403); + assert.equal((await post(null, '/api/admin/user/role', { name: 'reader', admin: true })).status, 401); + + // input checks + assert.equal((await post(boss, '/api/admin/user/role', { name: 'nobody', admin: true })).status, 404); + assert.equal((await post(boss, '/api/admin/user/role', { name: 'reader', admin: 'yes' })).status, 400); + assert.equal((await post(boss, '/api/admin/user/role', { name: 'boss', admin: false })).status, 400); + + // promotion keeps the password and applies to the existing session immediately + const promoted = await post(boss, '/api/admin/user/role', { name: 'Reader', admin: true }); + assert.equal(promoted.status, 200); + assert.deepEqual(await promoted.json(), { ok: true, name: 'reader', admin: true }); + assert.equal((await (await get(reader, '/api/me')).json()).admin, true); + assert.equal((await get(reader, '/api/admin')).status, 200); + assert.equal((await post(null, '/api/login', { name: 'reader', password: 'reader-password' })).status, 200); + const users = (await (await get(boss, '/api/admin')).json()).users; + assert.deepEqual(users.map(x => [x.name, x.admin]).sort(), [['boss', true], ['reader', true]]); + + // another admin may revoke; the revoked admin loses access at once + assert.equal((await post(reader, '/api/admin/user/role', { name: 'boss', admin: false })).status, 200); + assert.equal((await get(boss, '/api/admin')).status, 403); + assert.equal((await post(boss, '/api/admin/user/role', { name: 'boss', admin: true })).status, 403); + assert.match(output.join(''), /\[users\] reader removed boss as admin/); + + // db.json is written shortly after a change (batched writes) + let saved = null; + for (let n = 0; n < 60; n++) { + try { saved = JSON.parse(fs.readFileSync(path.join(data, 'db.json'), 'utf8')); } catch { saved = null; } + if (saved && saved.users.boss && saved.users.boss.admin === false) break; + await new Promise(resolve => setTimeout(resolve, 100)); + } + assert.equal(saved.users.boss.admin, false); + assert.equal(saved.users.reader.admin, true); + } finally { + if (child) await new Promise(resolve => { if (child.exitCode !== null) return resolve(); child.once('exit', resolve); child.kill(); }); + fs.rmSync(root, { recursive: true, force: true }); + } +});