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 }); + } +});