feat(permissions): B6 — массовая выдача права классу (личный оверрайд всем ученикам)

POST /api/permissions/class/:id/bulk { permission, enabled } (admin, явный
requireRole) — выставляет user_permissions всем ученикам класса (1/0/null=сброс),
точечный token_version bump каждому. Валидация: только студенческие ключи.
Клиент LS.setClassPermission. В админке «Доступ · роли» — блок «Массово по
классу»: выбор класса → у каждого права «включить/выключить всем / сбросить».
Тест: оверрайд всем + сброс + отклонение teacher-ключа. Backend 221 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-03 14:27:58 +03:00
parent 0b0c113181
commit b95b639e75
6 changed files with 118 additions and 3 deletions
@@ -157,6 +157,36 @@ function resetUserPermissions(req, res) {
res.json({ ok: true });
}
/* ── POST /api/permissions/class/:id/bulk { permission, enabled } ──────────
Выставить личное правило (user_permissions) сразу всем ученикам класса.
enabled: 1 (вкл) | 0 (выкл) | null/'inherit' (снять оверрайд → наследование роли). */
function setClassPermission(req, res) {
const cid = Number(req.params.id);
const { permission } = req.body || {};
let { enabled } = req.body || {};
if (!Number.isInteger(cid) || cid <= 0) return res.status(400).json({ error: 'неверный id класса' });
if (!ALL_PERMISSIONS.find(p => p.key === permission && p.role === 'student'))
return res.status(400).json({ error: 'Unknown student permission' });
const members = db.prepare(`
SELECT u.id FROM class_members cm JOIN users u ON u.id = cm.user_id
WHERE cm.class_id = ? AND u.role IN ('student','free_student')`).all(cid);
const inherit = (enabled === null || enabled === undefined || enabled === 'inherit');
const val = (enabled === 1 || enabled === true || enabled === '1') ? 1 : 0;
const del = db.prepare('DELETE FROM user_permissions WHERE user_id = ? AND permission = ?');
const up = db.prepare('INSERT OR REPLACE INTO user_permissions (user_id, permission, enabled) VALUES (?, ?, ?)');
const bump = db.prepare('UPDATE users SET token_version = token_version + 1 WHERE id = ?');
db.transaction(() => {
for (const m of members) {
if (inherit) del.run(m.id, permission); else up.run(m.id, permission, val);
bump.run(m.id); // user-level: точечно обновляем сессию каждого затронутого ученика
}
})();
audit(req, 'permission.class_bulk', `class:${cid}/${permission}`, inherit ? 'inherit' : `enabled=${val}`);
res.json({ ok: true, affected: members.length });
}
/* ── GET /api/permissions/log?user_id= — история изменений прав (admin) ── */
function getPermissionLog(req, res) {
const uid = req.query.user_id ? Number(req.query.user_id) : null;
@@ -195,4 +225,4 @@ function getPermissionLog(req, res) {
res.json(out);
}
module.exports = { getPermissions, setPermission, seedDefaults, ALL_PERMISSIONS, getMyPermissions, getUserPermissions, setUserPermission, resetUserPermissions, getPermissionLog };
module.exports = { getPermissions, setPermission, seedDefaults, ALL_PERMISSIONS, getMyPermissions, getUserPermissions, setUserPermission, resetUserPermissions, getPermissionLog, setClassPermission };
+4 -1
View File
@@ -1,6 +1,6 @@
const router = require('express').Router();
const { authMiddleware, requireRole } = require('../middleware/auth');
const { getPermissions, setPermission, getMyPermissions, getUserPermissions, setUserPermission, resetUserPermissions, getPermissionLog } = require('../controllers/permissionsController');
const { getPermissions, setPermission, getMyPermissions, getUserPermissions, setUserPermission, resetUserPermissions, getPermissionLog, setClassPermission } = require('../controllers/permissionsController');
router.use(authMiddleware);
@@ -13,6 +13,9 @@ router.get('/', getPermissions);
router.get('/log', getPermissionLog);
router.post('/', setPermission);
/* ── Массово по классу (личные оверрайды всем ученикам класса) ── */
router.post('/class/:id/bulk', requireRole('admin'), setClassPermission);
/* ── Per-user overrides ── */
router.get('/users/:id', getUserPermissions);
router.post('/users/:id', setUserPermission);
+28
View File
@@ -218,4 +218,32 @@ describe('Permissions', () => {
const denied = await inject('GET', '/api/permissions/log', null, fresh.token);
assert.equal(denied.status, 403, 'не-админу недоступно');
});
// ── B6: массово по классу ──────────────────────────────────────────────────
it('B6: массовое право классу — личный оверрайд всем ученикам + сброс + валидация', async () => {
const cr = await inject('POST', '/api/classes', { name: 'PermBulk Class' }, adminToken);
assert.ok(cr.status < 300, JSON.stringify(cr.body));
const cid = db.prepare('SELECT id FROM classes WHERE name = ?').get('PermBulk Class').id;
await inject('POST', `/api/classes/${cid}/members`, { user_id: studentUser.userId }, adminToken);
const off = await inject('POST', `/api/permissions/class/${cid}/bulk`,
{ permission: 'shop.purchase', enabled: false }, adminToken);
assert.equal(off.status, 200);
assert.ok(off.body.affected >= 1, 'затронут хотя бы один ученик');
const row = db.prepare('SELECT enabled FROM user_permissions WHERE user_id = ? AND permission = ?')
.get(studentUser.userId, 'shop.purchase');
assert.ok(row && row.enabled === 0, 'личный оверрайд выключен у ученика класса');
// сброс (наследование роли)
await inject('POST', `/api/permissions/class/${cid}/bulk`,
{ permission: 'shop.purchase', enabled: null }, adminToken);
const gone = db.prepare('SELECT 1 FROM user_permissions WHERE user_id = ? AND permission = ?')
.get(studentUser.userId, 'shop.purchase');
assert.ok(!gone, 'оверрайд снят');
// teacher-право для массовой студенческой операции отклоняется
const bad = await inject('POST', `/api/permissions/class/${cid}/bulk`,
{ permission: 'questions.manage', enabled: true }, adminToken);
assert.equal(bad.status, 400);
});
});
+2
View File
@@ -1319,6 +1319,8 @@
<div class="perm-grid" id="perm-student"></div>
</div>
<div class="perm-role-block" id="perm-bulk"></div>
<div class="perm-role-block">
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:10px">
<span style="font-weight:600">История изменений прав ролей</span>
+51
View File
@@ -4,17 +4,66 @@
'use strict';
let inited = false;
let _permData = null;
let _bulkTargets = null; // { classes:[{id,name,students:[]}] }
let _bulkClassId = null;
async function load() {
try {
_permData = await LS.getPermissions();
renderPermissions();
loadBulk();
} catch(e) {
document.getElementById('perm-teacher').innerHTML =
`<p style="color:var(--danger);font-size:13px">Ошибка загрузки: ${esc(e.message)}</p>`;
}
}
/* ── Массово по классу ── */
async function loadBulk() {
const root = document.getElementById('perm-bulk');
if (!root) return;
try { _bulkTargets = await LS.accessTargets(); }
catch { _bulkTargets = { classes: [] }; }
renderBulk();
}
function renderBulk() {
const root = document.getElementById('perm-bulk');
if (!root || !_permData) return;
const classes = (_bulkTargets && _bulkTargets.classes) || [];
const studentDefs = (_permData.definitions || []).filter(d => d.role === 'student');
const opts = classes.map(c => `<option value="${c.id}" ${_bulkClassId === c.id ? 'selected' : ''}>${esc(c.name)} (${(c.students || []).length})</option>`).join('');
const cls = classes.find(c => c.id === _bulkClassId);
const btn = (key, val, label, bg) => `<button onclick="bulkPerm('${esc(key)}',${val})" style="padding:3px 9px;border:1px solid var(--border);border-radius:7px;background:${bg};color:var(--text-3);cursor:pointer;font-family:inherit;font-size:11.5px">${label}</button>`;
const rows = (cls ? studentDefs : []).map(d => `
<div style="display:flex;align-items:center;justify-content:space-between;gap:10px;padding:7px 0;border-top:1px solid var(--border-h,#eee)">
<span style="font-size:13px;color:var(--text-1)">${esc(d.label)}</span>
<span style="display:inline-flex;gap:6px">${btn(d.key, 1, 'включить всем', 'transparent')}${btn(d.key, 0, 'выключить всем', 'transparent')}${btn(d.key, 'null', 'сбросить', 'var(--border-h,#eee)')}</span>
</div>`).join('');
root.innerHTML = `
<div style="font-weight:600;margin-bottom:6px">Массово по классу</div>
<div style="font-size:12px;color:var(--muted);margin-bottom:10px">Выставить личное правило сразу всем ученикам класса (переопределяет роль). «Сбросить» — вернуть наследование роли.</div>
<select onchange="selBulkClass(this.value)" style="padding:8px 12px;border:1.5px solid var(--border);border-radius:9px;font-family:inherit;font-size:0.9rem;min-width:220px">
<option value="">— выберите класс —</option>${opts}
</select>
<div style="margin-top:10px">${cls ? (rows || '<p style="color:var(--muted);font-size:12px">Нет студенческих прав.</p>') : '<p style="color:var(--muted);font-size:12px">Выберите класс.</p>'}</div>`;
}
function selBulkClass(v) { _bulkClassId = v ? Number(v) : null; renderBulk(); }
async function bulkPerm(permission, enabled) {
if (!_bulkClassId) return;
if (enabled === 'null') enabled = null;
const cls = ((_bulkTargets && _bulkTargets.classes) || []).find(c => c.id === _bulkClassId);
const clsName = cls ? cls.name : ('#' + _bulkClassId);
if (enabled === 0) {
const ok = await LS.confirm(`Выключить это право всем ученикам класса «${clsName}»?`,
{ title: 'Подтвердите', confirmText: 'Выключить всем' });
if (!ok) return;
}
try {
const r = await LS.setClassPermission(_bulkClassId, permission, enabled);
LS.toast(`Готово: затронуто учеников — ${r.affected}`, 'success');
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
}
function permCard(role, def, en, labelOf) {
const enabled = en[def.key];
const reqs = def.requires || [];
@@ -154,6 +203,8 @@
window.togglePermGroup = togglePermGroup;
window.filterPermissions = filterPermissions;
window.loadPermLog = loadPermLog;
window.selBulkClass = selBulkClass;
window.bulkPerm = bulkPerm;
window.AdminSections = window.AdminSections || {};
window.AdminSections.permissions = {
+2 -1
View File
@@ -1033,7 +1033,7 @@ window.LS = {
getFolders, createFolder, renameFolder, deleteFolder, moveFile,
getFolderAccess, clearFolderAccess, assignFolder, unassignFolder, getStudentsList,
submitWork, resubmitWork, getMySubmissions, getClassSubmissions, reviewSubmission, deleteSubmission, submissionDownloadUrl,
getPermissions, permissionsLog, setPermission, getUserPermissions, setUserPermission, resetUserPermissions,
getPermissions, permissionsLog, setClassPermission, setPermission, getUserPermissions, setUserPermission, resetUserPermissions,
accessCatalog, accessTargets, accessSummary, accessClassOpen, accessMatrix, accessLog, accessRules, accessSetRule,
getCourseTemplates, saveCourseTemplate, createFromCourseTemplate, deleteCourseTemplate,
getLessonTemplates, saveLessonTemplate, createFromLessonTemplate, deleteLessonTemplate,
@@ -1296,6 +1296,7 @@ function submissionDownloadUrl(id) { return `${API}/submissions/${id}/d
/* ── permissions (admin only) ────────────────────────────────────────────── */
async function getPermissions() { return req('GET', '/permissions'); }
async function permissionsLog(userId) { return req('GET', userId ? `/permissions/log?user_id=${userId}` : '/permissions/log'); }
async function setClassPermission(classId, permission, enabled) { return req('POST', `/permissions/class/${classId}/bulk`, { permission, enabled }); }
async function setPermission(role, permission, enabled) { return req('POST', '/permissions', { role, permission, enabled }); }
async function getUserPermissions(uid) { return req('GET', `/permissions/users/${uid}`); }
async function setUserPermission(uid, permission, enabled) { return req('POST', `/permissions/users/${uid}`, { permission, enabled }); }