diff --git a/backend/src/controllers/permissionsController.js b/backend/src/controllers/permissionsController.js index 89914f4..646f91d 100644 --- a/backend/src/controllers/permissionsController.js +++ b/backend/src/controllers/permissionsController.js @@ -157,34 +157,69 @@ 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 PRESETS = { + student: [ + { id: 'full', label: 'Полный доступ', desc: 'Все возможности ученика включены', + perms: { 'tests.free': 1, 'board.post': 1, 'profile.edit': 1, 'shop.purchase': 1, 'gamification.challenges': 1, 'theory.access': 1, 'simulations.access': 1, 'simulations.quiz': 1 } }, + { id: 'focus', label: 'Режим фокуса', desc: 'Без магазина и испытаний — меньше отвлечений', + perms: { 'shop.purchase': 0, 'gamification.challenges': 0 } }, + { id: 'restricted', label: 'Ограниченный', desc: 'Без магазина, испытаний и лаборатории', + perms: { 'shop.purchase': 0, 'gamification.challenges': 0, 'simulations.access': 0 } }, + { id: 'reset', label: 'Сбросить к стандарту роли', desc: 'Снять все личные правила (наследование роли)', + perms: { 'tests.free': 'inherit', 'board.post': 'inherit', 'profile.edit': 'inherit', 'shop.purchase': 'inherit', 'gamification.challenges': 'inherit', 'theory.access': 'inherit', 'simulations.access': 'inherit', 'simulations.quiz': 'inherit' } }, + ], +}; +/* Применить карту прав { key: 1|0|'inherit' } всем ученикам класса. → число затронутых. */ +function applyPermsToClass(cid, permsMap) { 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); + for (const [key, v] of Object.entries(permsMap)) { + if (v === 'inherit' || v === null) del.run(m.id, key); + else up.run(m.id, key, (v === 1 || v === true || v === '1') ? 1 : 0); + } 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 }); + return members.length; +} + +/* ── POST /api/permissions/class/:id/bulk { permission, enabled } ────────── + Выставить ОДНО личное правило сразу всем ученикам класса. */ +function setClassPermission(req, res) { + const cid = Number(req.params.id); + const { permission } = req.body || {}; + const { 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 inherit = (enabled === null || enabled === undefined || enabled === 'inherit'); + const affected = applyPermsToClass(cid, { [permission]: inherit ? 'inherit' : ((enabled === 1 || enabled === true || enabled === '1') ? 1 : 0) }); + audit(req, 'permission.class_bulk', `class:${cid}/${permission}`, inherit ? 'inherit' : `enabled=${enabled ? 1 : 0}`); + res.json({ ok: true, affected }); +} + +/* ── GET /api/permissions/presets → { student:[{id,label,desc,perms}] } ──── */ +function getPresets(_req, res) { res.json(PRESETS); } + +/* ── POST /api/permissions/class/:id/preset { preset } ───────────────────── + Применить пресет-профиль ко всем ученикам класса. */ +function applyClassPreset(req, res) { + const cid = Number(req.params.id); + const { preset } = req.body || {}; + if (!Number.isInteger(cid) || cid <= 0) return res.status(400).json({ error: 'неверный id класса' }); + const p = PRESETS.student.find(x => x.id === preset); + if (!p) return res.status(400).json({ error: 'Unknown preset' }); + const affected = applyPermsToClass(cid, p.perms); + audit(req, 'permission.class_preset', `class:${cid}`, p.id); + res.json({ ok: true, affected, preset: p.id }); } /* ── GET /api/permissions/log?user_id= — история изменений прав (admin) ── */ @@ -225,4 +260,4 @@ function getPermissionLog(req, res) { res.json(out); } -module.exports = { getPermissions, setPermission, seedDefaults, ALL_PERMISSIONS, getMyPermissions, getUserPermissions, setUserPermission, resetUserPermissions, getPermissionLog, setClassPermission }; +module.exports = { getPermissions, setPermission, seedDefaults, ALL_PERMISSIONS, getMyPermissions, getUserPermissions, setUserPermission, resetUserPermissions, getPermissionLog, setClassPermission, getPresets, applyClassPreset }; diff --git a/backend/src/routes/permissions.js b/backend/src/routes/permissions.js index 97128e6..4371909 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, setClassPermission } = require('../controllers/permissionsController'); +const { getPermissions, setPermission, getMyPermissions, getUserPermissions, setUserPermission, resetUserPermissions, getPermissionLog, setClassPermission, getPresets, applyClassPreset } = require('../controllers/permissionsController'); router.use(authMiddleware); @@ -13,8 +13,10 @@ router.get('/', getPermissions); router.get('/log', getPermissionLog); router.post('/', setPermission); -/* ── Массово по классу (личные оверрайды всем ученикам класса) ── */ +/* ── Пресеты-профили + массово по классу (личные оверрайды ученикам класса) ── */ +router.get('/presets', requireRole('admin'), getPresets); router.post('/class/:id/bulk', requireRole('admin'), setClassPermission); +router.post('/class/:id/preset', requireRole('admin'), applyClassPreset); /* ── Per-user overrides ── */ router.get('/users/:id', getUserPermissions); diff --git a/backend/tests/permissions.test.js b/backend/tests/permissions.test.js index 7c83048..7681e26 100644 --- a/backend/tests/permissions.test.js +++ b/backend/tests/permissions.test.js @@ -246,4 +246,32 @@ describe('Permissions', () => { { permission: 'questions.manage', enabled: true }, adminToken); assert.equal(bad.status, 400); }); + + // ── B7: пресеты-профили ──────────────────────────────────────────────────── + it('B7: пресеты — список + применение к классу + валидация', async () => { + const list = await inject('GET', '/api/permissions/presets', null, adminToken); + assert.equal(list.status, 200); + assert.ok(Array.isArray(list.body.student) && list.body.student.some(p => p.id === 'focus')); + + const cr = await inject('POST', '/api/classes', { name: 'PresetClass' }, adminToken); + assert.ok(cr.status < 300); + const cid = db.prepare('SELECT id FROM classes WHERE name = ?').get('PresetClass').id; + await inject('POST', `/api/classes/${cid}/members`, { user_id: studentUser.userId }, adminToken); + + // focus: shop.purchase=0, gamification.challenges=0 + const ap = await inject('POST', `/api/permissions/class/${cid}/preset`, { preset: 'focus' }, adminToken); + assert.equal(ap.status, 200); + assert.ok(ap.body.affected >= 1); + const shop = db.prepare('SELECT enabled FROM user_permissions WHERE user_id=? AND permission=?') + .get(studentUser.userId, 'shop.purchase'); + assert.ok(shop && shop.enabled === 0, 'focus выключил магазин'); + + // reset: снимает все оверрайды + await inject('POST', `/api/permissions/class/${cid}/preset`, { preset: 'reset' }, adminToken); + const left = db.prepare("SELECT COUNT(*) n FROM user_permissions WHERE user_id=?").get(studentUser.userId).n; + assert.equal(left, 0, 'reset снял все личные правила'); + + const badP = await inject('POST', `/api/permissions/class/${cid}/preset`, { preset: 'nope' }, adminToken); + assert.equal(badP.status, 400); + }); }); diff --git a/frontend/js/admin/sections/permissions.js b/frontend/js/admin/sections/permissions.js index a3fb5e7..ce0bcb0 100644 --- a/frontend/js/admin/sections/permissions.js +++ b/frontend/js/admin/sections/permissions.js @@ -6,6 +6,7 @@ let _permData = null; let _bulkTargets = null; // { classes:[{id,name,students:[]}] } let _bulkClassId = null; + let _presets = null; // { student:[{id,label,desc,perms}] } async function load() { try { @@ -24,6 +25,7 @@ if (!root) return; try { _bulkTargets = await LS.accessTargets(); } catch { _bulkTargets = { classes: [] }; } + if (!_presets) { try { _presets = await LS.permissionsPresets(); } catch { _presets = { student: [] }; } } renderBulk(); } function renderBulk() { @@ -39,13 +41,19 @@ ${esc(d.label)} ${btn(d.key, 1, 'включить всем', 'transparent')}${btn(d.key, 0, 'выключить всем', 'transparent')}${btn(d.key, 'null', 'сбросить', 'var(--border-h,#eee)')} `).join(''); + const presets = (_presets && _presets.student) || []; + const presetBar = (cls && presets.length) ? ` +
Нет студенческих прав.
') : 'Выберите класс.
'}Нет студенческих прав.
')) : 'Выберите класс.
'}