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:
1 parent
f8f21ab42a
commit
d6254a8be5
4 files changed
+111
-2
No files matched your search
@@ -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)) |
|
||||
|
||||
|
||||
+8
-2
@@ -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 () => {
|
||||
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'))))),
|
||||
} }, '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 () => {
|
||||
|
||||
@@ -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 || '');
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user