From b95b639e75dbf56ef41fb97dcbab7422ef9cbb24 Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Wed, 3 Jun 2026 14:27:58 +0300 Subject: [PATCH] =?UTF-8?q?feat(permissions):=20B6=20=E2=80=94=20=D0=BC?= =?UTF-8?q?=D0=B0=D1=81=D1=81=D0=BE=D0=B2=D0=B0=D1=8F=20=D0=B2=D1=8B=D0=B4?= =?UTF-8?q?=D0=B0=D1=87=D0=B0=20=D0=BF=D1=80=D0=B0=D0=B2=D0=B0=20=D0=BA?= =?UTF-8?q?=D0=BB=D0=B0=D1=81=D1=81=D1=83=20(=D0=BB=D0=B8=D1=87=D0=BD?= =?UTF-8?q?=D1=8B=D0=B9=20=D0=BE=D0=B2=D0=B5=D1=80=D1=80=D0=B0=D0=B9=D0=B4?= =?UTF-8?q?=20=D0=B2=D1=81=D0=B5=D0=BC=20=D1=83=D1=87=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=BA=D0=B0=D0=BC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../src/controllers/permissionsController.js | 32 +++++++++++- backend/src/routes/permissions.js | 5 +- backend/tests/permissions.test.js | 28 ++++++++++ frontend/admin.html | 2 + frontend/js/admin/sections/permissions.js | 51 +++++++++++++++++++ js/api.js | 3 +- 6 files changed, 118 insertions(+), 3 deletions(-) diff --git a/backend/src/controllers/permissionsController.js b/backend/src/controllers/permissionsController.js index 5a5ef61..89914f4 100644 --- a/backend/src/controllers/permissionsController.js +++ b/backend/src/controllers/permissionsController.js @@ -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 }; diff --git a/backend/src/routes/permissions.js b/backend/src/routes/permissions.js index cb6c946..97128e6 100644 --- a/backend/src/routes/permissions.js +++ b/backend/src/routes/permissions.js @@ -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); diff --git a/backend/tests/permissions.test.js b/backend/tests/permissions.test.js index 1bf2d42..7c83048 100644 --- a/backend/tests/permissions.test.js +++ b/backend/tests/permissions.test.js @@ -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); + }); }); diff --git a/frontend/admin.html b/frontend/admin.html index 4f9ba24..31af389 100644 --- a/frontend/admin.html +++ b/frontend/admin.html @@ -1319,6 +1319,8 @@
+
+
История изменений прав ролей diff --git a/frontend/js/admin/sections/permissions.js b/frontend/js/admin/sections/permissions.js index f63a786..a3fb5e7 100644 --- a/frontend/js/admin/sections/permissions.js +++ b/frontend/js/admin/sections/permissions.js @@ -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 = `

Ошибка загрузки: ${esc(e.message)}

`; } } + /* ── Массово по классу ── */ + 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 => ``).join(''); + const cls = classes.find(c => c.id === _bulkClassId); + const btn = (key, val, label, bg) => ``; + const rows = (cls ? studentDefs : []).map(d => ` +
+ ${esc(d.label)} + ${btn(d.key, 1, 'включить всем', 'transparent')}${btn(d.key, 0, 'выключить всем', 'transparent')}${btn(d.key, 'null', 'сбросить', 'var(--border-h,#eee)')} +
`).join(''); + root.innerHTML = ` +
Массово по классу
+
Выставить личное правило сразу всем ученикам класса (переопределяет роль). «Сбросить» — вернуть наследование роли.
+ +
${cls ? (rows || '

Нет студенческих прав.

') : '

Выберите класс.

'}
`; + } + 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 = { diff --git a/js/api.js b/js/api.js index 942c51d..e9b2ccc 100644 --- a/js/api.js +++ b/js/api.js @@ -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 }); }