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 <noreply@anthropic.com>
This commit is contained in:
MaashkinoozheandClaude Opus 5.5 committed 2026-10-22 19:35:04 +02:00
1 parent f8f21ab42a
commit d6254a8be5
4 files changed
+111 -2

No files matched your search

+1
View File
@@ -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 | | `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/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` `{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 | | `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)) | | `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)) |
+8 -2
View File
@@ -473,10 +473,16 @@
h('div', { class: 'field' }, h('span', {}, 'Users'), h('div', { class: 'field' }, h('span', {}, 'Users'),
h('div', { class: 'users' }, data.users.map(u => h('div', {}, 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), 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 () => { 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; if (!confirm(`Delete ${u.name} and their reading progress?`)) return;
await api('api/admin/user/delete', { name: u.name }); load(); await api('api/admin/user/delete', { name: u.name }); load();
} }, 'Delete'))))), } }, 'Delete')))))),
h('div', { class: 'field' }, h('span', {}, 'Add a user or reset a password'), 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('div', { class: 'row' }, name, pw, h('label', { class: 'row' }, isAdmin, 'admin'),
h('button', { class: 'btn', onclick: async () => { h('button', { class: 'btn', onclick: async () => {
+15
View File
@@ -735,6 +735,21 @@ const routes = {
createUser(name, b.password, name === u.name || !!b.admin, true); createUser(name, b.password, name === u.name || !!b.admin, true);
return { ok: 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 }) => { 'POST /api/admin/user/delete': ({ u, b }) => {
admin(u); admin(u);
const name = String(b.name || ''); const name = String(b.name || '');
+87
View File
@@ -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 });
}
});