v0.3.28-beta: alleen-lezen-vlag voor gedeelde items (server)
- Nieuwe migratie db/011: shared_library.readonly (default false)
- Beheerregel aangescherpt: een vergrendeld school-item is ook voor de
eigenaar alleen-lezen; alleen schoolbeheerder en systeemmanager kunnen
het nog wijzigen (systeem-items waren al alleen-lezen voor iedereen
behalve de systeemmanager) - kopiëren blijft altijd werken
- De vlag zetten/weghalen kan per item (PATCH readonly) of per map
(PATCH /shared/:kind/folder met {from, readonly}), uitsluitend door de
schoolbeheerder van die school of de systeemmanager
- Lijst/detail geven readonly + canLock terug voor de UI; drie nieuwe tests
This commit is contained in:
parent
e21bc177e1
commit
24180beec1
5 changed files with 101 additions and 20 deletions
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
0.3.27-beta
|
0.3.28-beta
|
||||||
|
|
|
||||||
6
db/011_shared_readonly.sql
Normal file
6
db/011_shared_readonly.sql
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
-- Alleen-lezen-vlag voor gedeelde items: een vergrendeld school-item kan ook
|
||||||
|
-- door de eigenaar niet meer gewijzigd worden, alleen nog door de
|
||||||
|
-- schoolbeheerder of de systeemmanager (die de vlag ook zetten/weghalen).
|
||||||
|
-- Systeembrede items zijn impliciet al alleen-lezen voor iedereen behalve de
|
||||||
|
-- systeemmanager (beheerregel), de vlag is daar niet voor nodig.
|
||||||
|
ALTER TABLE shared_library ADD COLUMN IF NOT EXISTS readonly BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
"use strict";
|
"use strict";
|
||||||
/* version — shown until /api/version resolves (or if the fetch fails, e.g. offline).
|
/* version — shown until /api/version resolves (or if the fetch fails, e.g. offline).
|
||||||
Kept in sync by hand with the VERSION file at the repo root on every release. */
|
Kept in sync by hand with the VERSION file at the repo root on every release. */
|
||||||
const VERSION = "0.3.27-beta";
|
const VERSION = "0.3.28-beta";
|
||||||
(function(){
|
(function(){
|
||||||
const tag = document.getElementById("verTag");
|
const tag = document.getElementById("verTag");
|
||||||
tag.textContent = "v"+VERSION;
|
tag.textContent = "v"+VERSION;
|
||||||
|
|
|
||||||
63
src/api.js
63
src/api.js
|
|
@ -743,9 +743,17 @@ export default async function api(app) {
|
||||||
need(req, reply);
|
need(req, reply);
|
||||||
if (req.user.role === 'pupil') { reply.code(403); throw new Error('geen rechten'); }
|
if (req.user.role === 'pupil') { reply.code(403); throw new Error('geen rechten'); }
|
||||||
};
|
};
|
||||||
|
// Vergrendeld (readonly) school-item: ook de eigenaar kan er niet meer bij,
|
||||||
|
// alleen de schoolbeheerder en de systeemmanager nog.
|
||||||
const canManageShared = (req, item) =>
|
const canManageShared = (req, item) =>
|
||||||
req.user.allRoles.includes('super')
|
req.user.allRoles.includes('super')
|
||||||
|| Number(item.owner_id) === Number(req.user.id)
|
|| (item.school_id != null && req.user.allRoles.includes('admin')
|
||||||
|
&& Number(item.school_id) === Number(req.user.school_id))
|
||||||
|
|| (Number(item.owner_id) === Number(req.user.id) && !item.readonly);
|
||||||
|
// Wie mag de alleen-lezen-vlag zetten/weghalen: de systeemmanager altijd,
|
||||||
|
// de schoolbeheerder voor items van de eigen school (niet de leerkracht).
|
||||||
|
const canLockShared = (req, item) =>
|
||||||
|
req.user.allRoles.includes('super')
|
||||||
|| (item.school_id != null && req.user.allRoles.includes('admin')
|
|| (item.school_id != null && req.user.allRoles.includes('admin')
|
||||||
&& Number(item.school_id) === Number(req.user.school_id));
|
&& Number(item.school_id) === Number(req.user.school_id));
|
||||||
const sharedItemVisible = (req, item) =>
|
const sharedItemVisible = (req, item) =>
|
||||||
|
|
@ -760,7 +768,9 @@ export default async function api(app) {
|
||||||
folder: it.folder || '',
|
folder: it.folder || '',
|
||||||
scope: it.school_id == null ? 'global' : 'school',
|
scope: it.school_id == null ? 'global' : 'school',
|
||||||
ownerName: it.owner_name || null,
|
ownerName: it.owner_name || null,
|
||||||
|
readonly: !!it.readonly,
|
||||||
canManage: canManageShared(req, it),
|
canManage: canManageShared(req, it),
|
||||||
|
canLock: canLockShared(req, it),
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/shared/:kind', async (req, reply) => {
|
app.get('/shared/:kind', async (req, reply) => {
|
||||||
|
|
@ -769,11 +779,11 @@ export default async function api(app) {
|
||||||
if (!SHARED_KINDS.includes(kind)) return fail(reply, 404, 'onbekende soort');
|
if (!SHARED_KINDS.includes(kind)) return fail(reply, 404, 'onbekende soort');
|
||||||
const rows = req.user.allRoles.includes('super')
|
const rows = req.user.allRoles.includes('super')
|
||||||
? (await pool.query(
|
? (await pool.query(
|
||||||
`SELECT s.id, s.school_id, s.owner_id, s.folder, s.name, u.display_name AS owner_name
|
`SELECT s.id, s.school_id, s.owner_id, s.folder, s.name, s.readonly, u.display_name AS owner_name
|
||||||
FROM shared_library s LEFT JOIN users u ON u.id = s.owner_id
|
FROM shared_library s LEFT JOIN users u ON u.id = s.owner_id
|
||||||
WHERE s.kind = $1 ORDER BY s.folder, s.name`, [kind])).rows
|
WHERE s.kind = $1 ORDER BY s.folder, s.name`, [kind])).rows
|
||||||
: (await pool.query(
|
: (await pool.query(
|
||||||
`SELECT s.id, s.school_id, s.owner_id, s.folder, s.name, u.display_name AS owner_name
|
`SELECT s.id, s.school_id, s.owner_id, s.folder, s.name, s.readonly, u.display_name AS owner_name
|
||||||
FROM shared_library s LEFT JOIN users u ON u.id = s.owner_id
|
FROM shared_library s LEFT JOIN users u ON u.id = s.owner_id
|
||||||
WHERE s.kind = $1 AND (s.school_id IS NULL OR s.school_id = $2)
|
WHERE s.kind = $1 AND (s.school_id IS NULL OR s.school_id = $2)
|
||||||
ORDER BY s.folder, s.name`, [kind, req.user.school_id])).rows;
|
ORDER BY s.folder, s.name`, [kind, req.user.school_id])).rows;
|
||||||
|
|
@ -810,15 +820,19 @@ export default async function api(app) {
|
||||||
[schoolId, req.user.id, kind, folder || '', name.trim(), data]);
|
[schoolId, req.user.id, kind, folder || '', name.trim(), data]);
|
||||||
return { ok: true, id: Number(r.rows[0].id) };
|
return { ok: true, id: Number(r.rows[0].id) };
|
||||||
});
|
});
|
||||||
// map-prefix-rewrite (map hernoemen/verplaatsen in de gedeelde boom); mag
|
// map-prefix-rewrite (map hernoemen/verplaatsen: {from, to}) of de hele map
|
||||||
// alleen als de gebruiker ALLE items onder het pad mag beheren
|
// op alleen-lezen zetten ({from, readonly}); rewrite mag alleen als de
|
||||||
|
// gebruiker ALLE items onder het pad mag beheren, vergrendelen alleen door
|
||||||
|
// wie de vlag op alle items mag zetten (schoolbeheerder/systeemmanager)
|
||||||
app.patch('/shared/:kind/folder', async (req, reply) => {
|
app.patch('/shared/:kind/folder', async (req, reply) => {
|
||||||
sharedStaff(req, reply);
|
sharedStaff(req, reply);
|
||||||
const { kind } = req.params;
|
const { kind } = req.params;
|
||||||
if (!SHARED_KINDS.includes(kind)) return fail(reply, 404, 'onbekende soort');
|
if (!SHARED_KINDS.includes(kind)) return fail(reply, 404, 'onbekende soort');
|
||||||
const { scope, from, to } = req.body ?? {};
|
const { scope, from, to, readonly } = req.body ?? {};
|
||||||
if (scope !== 'global' && scope !== 'school') return fail(reply, 400, 'ongeldige scope');
|
if (scope !== 'global' && scope !== 'school') return fail(reply, 400, 'ongeldige scope');
|
||||||
if (!validSharedFolder(from) || !from || !validSharedFolder(to) || !to) return fail(reply, 400, 'ongeldige map');
|
if (!validSharedFolder(from) || !from) return fail(reply, 400, 'ongeldige map');
|
||||||
|
if (to === undefined && typeof readonly !== 'boolean') return fail(reply, 400, 'niets te doen');
|
||||||
|
if (to !== undefined && (!validSharedFolder(to) || !to)) return fail(reply, 400, 'ongeldige map');
|
||||||
const rows = scope === 'global'
|
const rows = scope === 'global'
|
||||||
? (await pool.query(
|
? (await pool.query(
|
||||||
`SELECT * FROM shared_library WHERE kind = $1 AND school_id IS NULL AND (folder = $2 OR folder LIKE $3)`,
|
`SELECT * FROM shared_library WHERE kind = $1 AND school_id IS NULL AND (folder = $2 OR folder LIKE $3)`,
|
||||||
|
|
@ -826,11 +840,17 @@ export default async function api(app) {
|
||||||
: (await pool.query(
|
: (await pool.query(
|
||||||
`SELECT * FROM shared_library WHERE kind = $1 AND school_id = $2 AND (folder = $3 OR folder LIKE $4)`,
|
`SELECT * FROM shared_library WHERE kind = $1 AND school_id = $2 AND (folder = $3 OR folder LIKE $4)`,
|
||||||
[kind, req.user.school_id, from, from + '/%'])).rows;
|
[kind, req.user.school_id, from, from + '/%'])).rows;
|
||||||
if (rows.some((it) => !canManageShared(req, it))) return fail(reply, 403, 'geen rechten op alle items in deze map');
|
if (to !== undefined){
|
||||||
await pool.query(
|
if (rows.some((it) => !canManageShared(req, it))) return fail(reply, 403, 'geen rechten op alle items in deze map');
|
||||||
`UPDATE shared_library SET folder = $1 || substr(folder, $2)
|
await pool.query(
|
||||||
WHERE id = ANY($3)`,
|
`UPDATE shared_library SET folder = $1 || substr(folder, $2)
|
||||||
[to, from.length + 1, rows.map((it) => it.id)]);
|
WHERE id = ANY($3)`,
|
||||||
|
[to, from.length + 1, rows.map((it) => it.id)]);
|
||||||
|
}
|
||||||
|
if (typeof readonly === 'boolean'){
|
||||||
|
if (rows.some((it) => !canLockShared(req, it))) return fail(reply, 403, 'alleen de schoolbeheerder of systeemmanager kan dit vergrendelen');
|
||||||
|
await pool.query('UPDATE shared_library SET readonly = $1 WHERE id = ANY($2)', [readonly, rows.map((it) => it.id)]);
|
||||||
|
}
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
});
|
});
|
||||||
app.patch('/shared/:kind/:id', async (req, reply) => {
|
app.patch('/shared/:kind/:id', async (req, reply) => {
|
||||||
|
|
@ -839,12 +859,19 @@ export default async function api(app) {
|
||||||
if (!SHARED_KINDS.includes(kind)) return fail(reply, 404, 'onbekende soort');
|
if (!SHARED_KINDS.includes(kind)) return fail(reply, 404, 'onbekende soort');
|
||||||
const it = (await pool.query('SELECT * FROM shared_library WHERE id = $1 AND kind = $2', [req.params.id, kind])).rows[0];
|
const it = (await pool.query('SELECT * FROM shared_library WHERE id = $1 AND kind = $2', [req.params.id, kind])).rows[0];
|
||||||
if (!it || !sharedItemVisible(req, it)) return fail(reply, 404, 'item onbekend');
|
if (!it || !sharedItemVisible(req, it)) return fail(reply, 404, 'item onbekend');
|
||||||
if (!canManageShared(req, it)) return fail(reply, 403, 'geen rechten');
|
const { name, folder, readonly } = req.body ?? {};
|
||||||
const { name, folder } = req.body ?? {};
|
if (name !== undefined || folder !== undefined){
|
||||||
if (name !== undefined && !validSharedName(name)) return fail(reply, 400, 'ongeldige naam');
|
if (!canManageShared(req, it)) return fail(reply, 403, 'geen rechten');
|
||||||
if (folder !== undefined && !validSharedFolder(folder)) return fail(reply, 400, 'ongeldige map');
|
if (name !== undefined && !validSharedName(name)) return fail(reply, 400, 'ongeldige naam');
|
||||||
if (name !== undefined) await pool.query('UPDATE shared_library SET name = $1 WHERE id = $2', [name.trim(), it.id]);
|
if (folder !== undefined && !validSharedFolder(folder)) return fail(reply, 400, 'ongeldige map');
|
||||||
if (folder !== undefined) await pool.query('UPDATE shared_library SET folder = $1 WHERE id = $2', [folder, it.id]);
|
if (name !== undefined) await pool.query('UPDATE shared_library SET name = $1 WHERE id = $2', [name.trim(), it.id]);
|
||||||
|
if (folder !== undefined) await pool.query('UPDATE shared_library SET folder = $1 WHERE id = $2', [folder, it.id]);
|
||||||
|
}
|
||||||
|
if (readonly !== undefined){
|
||||||
|
if (typeof readonly !== 'boolean') return fail(reply, 400, 'ongeldige waarde');
|
||||||
|
if (!canLockShared(req, it)) return fail(reply, 403, 'alleen de schoolbeheerder of systeemmanager kan dit vergrendelen');
|
||||||
|
await pool.query('UPDATE shared_library SET readonly = $1 WHERE id = $2', [readonly, it.id]);
|
||||||
|
}
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
});
|
});
|
||||||
app.delete('/shared/:kind/:id', async (req, reply) => {
|
app.delete('/shared/:kind/:id', async (req, reply) => {
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,54 @@ test('beheren mag alleen door eigenaar, schoolbeheerder of systeemmanager', asyn
|
||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('een vergrendeld school-item is ook voor de eigenaar alleen-lezen', async () => {
|
||||||
|
const lockedOwn = { id: 5, school_id: 2, owner_id: 7, kind: 'board', folder: '', name: 'X', data: {}, readonly: true };
|
||||||
|
const { app } = await makeApp(teacher, [lockedOwn]);
|
||||||
|
const res = await app.inject({ method: 'PATCH', url: '/api/shared/board/5', cookies,
|
||||||
|
payload: { name: 'Nieuwe naam' } });
|
||||||
|
assert.equal(res.statusCode, 403);
|
||||||
|
// kopiëren (GET met data) blijft gewoon werken
|
||||||
|
const get = await app.inject({ method: 'GET', url: '/api/shared/board/5', cookies });
|
||||||
|
assert.equal(get.statusCode, 200);
|
||||||
|
assert.equal(get.json().item.readonly, true);
|
||||||
|
assert.equal(get.json().item.canManage, false);
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('alleen schoolbeheerder/systeemmanager mag de alleen-lezen-vlag zetten', async () => {
|
||||||
|
const ownItem = { id: 5, school_id: 2, owner_id: 7, kind: 'board', folder: '', name: 'X', data: {}, readonly: false };
|
||||||
|
// eigenaar-leerkracht mag de vlag NIET zetten
|
||||||
|
const { app } = await makeApp(teacher, [ownItem]);
|
||||||
|
const res = await app.inject({ method: 'PATCH', url: '/api/shared/board/5', cookies,
|
||||||
|
payload: { readonly: true } });
|
||||||
|
assert.equal(res.statusCode, 403);
|
||||||
|
await app.close();
|
||||||
|
// schoolbeheerder van dezelfde school wel
|
||||||
|
const admin = { id: 3, username: 'beheer', role: 'admin', school_id: 2, class_id: null, data: {}, data_rev: 0 };
|
||||||
|
const { app: app2, calls } = await makeApp(admin, [ownItem]);
|
||||||
|
const res2 = await app2.inject({ method: 'PATCH', url: '/api/shared/board/5', cookies,
|
||||||
|
payload: { readonly: true } });
|
||||||
|
assert.equal(res2.statusCode, 200, res2.body);
|
||||||
|
const upd = calls.find((c) => c.sql.includes('SET readonly'));
|
||||||
|
assert.deepEqual(upd.params, [true, 5]);
|
||||||
|
await app2.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('map vergrendelen zet de vlag op alle items onder het pad', async () => {
|
||||||
|
const admin = { id: 3, username: 'beheer', role: 'admin', school_id: 2, class_id: null, data: {}, data_rev: 0 };
|
||||||
|
const rows = [
|
||||||
|
{ id: 5, school_id: 2, owner_id: 7, kind: 'board', folder: 'Taal', name: 'A', data: {}, readonly: false },
|
||||||
|
{ id: 6, school_id: 2, owner_id: 8, kind: 'board', folder: 'Taal/Sub', name: 'B', data: {}, readonly: false },
|
||||||
|
];
|
||||||
|
const { app, calls } = await makeApp(admin, rows);
|
||||||
|
const res = await app.inject({ method: 'PATCH', url: '/api/shared/board/folder', cookies,
|
||||||
|
payload: { scope: 'school', from: 'Taal', readonly: true } });
|
||||||
|
assert.equal(res.statusCode, 200, res.body);
|
||||||
|
const upd = calls.find((c) => c.sql.includes('SET readonly'));
|
||||||
|
assert.deepEqual(upd.params, [true, [5, 6]]);
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
test('map-rewrite weigert als niet alle items beheerd mogen worden', async () => {
|
test('map-rewrite weigert als niet alle items beheerd mogen worden', async () => {
|
||||||
const rows = [
|
const rows = [
|
||||||
{ id: 5, school_id: 2, owner_id: 7, kind: 'board', folder: 'Taal', name: 'A', data: {} },
|
{ id: 5, school_id: 2, owner_id: 7, kind: 'board', folder: 'Taal', name: 'A', data: {} },
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue