From 8b495f1508fad8c51de48d866a6654bb7196d1e7 Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Wed, 3 Jun 2026 14:33:25 +0300 Subject: [PATCH] =?UTF-8?q?feat(permissions):=20B7=20=E2=80=94=20=D0=BF?= =?UTF-8?q?=D1=80=D0=B5=D1=81=D0=B5=D1=82=D1=8B-=D0=BF=D1=80=D0=BE=D1=84?= =?UTF-8?q?=D0=B8=D0=BB=D0=B8=20=D0=BF=D1=80=D0=B0=D0=B2=20(=D0=BF=D1=80?= =?UTF-8?q?=D0=B8=D0=BC=D0=B5=D0=BD=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BA=20?= =?UTF-8?q?=D0=BA=D0=BB=D0=B0=D1=81=D1=81=D1=83=20=D0=BE=D0=B4=D0=BD=D0=B8?= =?UTF-8?q?=D0=BC=20=D0=BA=D0=BB=D0=B8=D0=BA=D0=BE=D0=BC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PRESETS (student): «Полный доступ», «Режим фокуса» (без магазина/испытаний), «Ограниченный» (+ без лаборатории), «Сбросить к стандарту роли». GET /api/permissions/presets + POST /api/permissions/class/:id/preset (admin). Рефактор: общий applyPermsToClass() (карта key→1/0/inherit) — его используют и bulk, и preset. В блоке «Массово по классу» — кнопки пресетов (с подтверждением). Тест: список + применение focus/reset + валидация. Backend pass (3 baseline-Auth). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/controllers/permissionsController.js | 69 ++++++++++++++----- backend/src/routes/permissions.js | 6 +- backend/tests/permissions.test.js | 28 ++++++++ frontend/js/admin/sections/permissions.js | 24 ++++++- js/api.js | 4 +- 5 files changed, 110 insertions(+), 21 deletions(-) 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) ? ` +
+ Пресет-профиль: + ${presets.map(p => ``).join('')} +
` : ''; root.innerHTML = `
Массово по классу
Выставить личное правило сразу всем ученикам класса (переопределяет роль). «Сбросить» — вернуть наследование роли.
-
${cls ? (rows || '

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

') : '

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

'}
`; +
${cls ? (presetBar + (rows || '

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

')) : '

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

'}
`; } function selBulkClass(v) { _bulkClassId = v ? Number(v) : null; renderBulk(); } async function bulkPerm(permission, enabled) { @@ -63,6 +71,19 @@ LS.toast(`Готово: затронуто учеников — ${r.affected}`, 'success'); } catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); } } + async function applyPreset(presetId) { + if (!_bulkClassId) return; + const cls = ((_bulkTargets && _bulkTargets.classes) || []).find(c => c.id === _bulkClassId); + const p = ((_presets && _presets.student) || []).find(x => x.id === presetId); + const ok = await LS.confirm( + `Применить пресет «${p ? p.label : presetId}» ко всем ученикам класса «${cls ? cls.name : ''}»?`, + { title: 'Применить пресет', confirmText: 'Применить' }); + if (!ok) return; + try { + const r = await LS.applyClassPreset(_bulkClassId, presetId); + LS.toast(`Пресет применён: ${r.affected} учеников`, 'success'); + } catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); } + } function permCard(role, def, en, labelOf) { const enabled = en[def.key]; @@ -205,6 +226,7 @@ window.loadPermLog = loadPermLog; window.selBulkClass = selBulkClass; window.bulkPerm = bulkPerm; + window.applyPreset = applyPreset; window.AdminSections = window.AdminSections || {}; window.AdminSections.permissions = { diff --git a/js/api.js b/js/api.js index e9b2ccc..cfd1787 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, setClassPermission, setPermission, getUserPermissions, setUserPermission, resetUserPermissions, + getPermissions, permissionsLog, setClassPermission, permissionsPresets, applyClassPreset, setPermission, getUserPermissions, setUserPermission, resetUserPermissions, accessCatalog, accessTargets, accessSummary, accessClassOpen, accessMatrix, accessLog, accessRules, accessSetRule, getCourseTemplates, saveCourseTemplate, createFromCourseTemplate, deleteCourseTemplate, getLessonTemplates, saveLessonTemplate, createFromLessonTemplate, deleteLessonTemplate, @@ -1297,6 +1297,8 @@ function submissionDownloadUrl(id) { return `${API}/submissions/${id}/d 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 permissionsPresets() { return req('GET', '/permissions/presets'); } +async function applyClassPreset(classId, preset) { return req('POST', `/permissions/class/${classId}/preset`, { preset }); } 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 }); }