feat(permissions): B7 — пресеты-профили прав (применение к классу одним кликом)
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) <noreply@anthropic.com>
This commit is contained in:
@@ -157,34 +157,69 @@ function resetUserPermissions(req, res) {
|
|||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── POST /api/permissions/class/:id/bulk { permission, enabled } ──────────
|
/* ── Пресеты-профили (студенческие) — применяются к классу одним кликом ──── */
|
||||||
Выставить личное правило (user_permissions) сразу всем ученикам класса.
|
const PRESETS = {
|
||||||
enabled: 1 (вкл) | 0 (выкл) | null/'inherit' (снять оверрайд → наследование роли). */
|
student: [
|
||||||
function setClassPermission(req, res) {
|
{ id: 'full', label: 'Полный доступ', desc: 'Все возможности ученика включены',
|
||||||
const cid = Number(req.params.id);
|
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 } },
|
||||||
const { permission } = req.body || {};
|
{ id: 'focus', label: 'Режим фокуса', desc: 'Без магазина и испытаний — меньше отвлечений',
|
||||||
let { enabled } = req.body || {};
|
perms: { 'shop.purchase': 0, 'gamification.challenges': 0 } },
|
||||||
if (!Number.isInteger(cid) || cid <= 0) return res.status(400).json({ error: 'неверный id класса' });
|
{ id: 'restricted', label: 'Ограниченный', desc: 'Без магазина, испытаний и лаборатории',
|
||||||
if (!ALL_PERMISSIONS.find(p => p.key === permission && p.role === 'student'))
|
perms: { 'shop.purchase': 0, 'gamification.challenges': 0, 'simulations.access': 0 } },
|
||||||
return res.status(400).json({ error: 'Unknown student permission' });
|
{ 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(`
|
const members = db.prepare(`
|
||||||
SELECT u.id FROM class_members cm JOIN users u ON u.id = cm.user_id
|
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);
|
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 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 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 = ?');
|
const bump = db.prepare('UPDATE users SET token_version = token_version + 1 WHERE id = ?');
|
||||||
db.transaction(() => {
|
db.transaction(() => {
|
||||||
for (const m of members) {
|
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: точечно обновляем сессию каждого затронутого ученика
|
bump.run(m.id); // user-level: точечно обновляем сессию каждого затронутого ученика
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
audit(req, 'permission.class_bulk', `class:${cid}/${permission}`, inherit ? 'inherit' : `enabled=${val}`);
|
return members.length;
|
||||||
res.json({ ok: true, affected: 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) ── */
|
/* ── GET /api/permissions/log?user_id= — история изменений прав (admin) ── */
|
||||||
@@ -225,4 +260,4 @@ function getPermissionLog(req, res) {
|
|||||||
res.json(out);
|
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 };
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
const router = require('express').Router();
|
const router = require('express').Router();
|
||||||
const { authMiddleware, requireRole } = require('../middleware/auth');
|
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);
|
router.use(authMiddleware);
|
||||||
|
|
||||||
@@ -13,8 +13,10 @@ router.get('/', getPermissions);
|
|||||||
router.get('/log', getPermissionLog);
|
router.get('/log', getPermissionLog);
|
||||||
router.post('/', setPermission);
|
router.post('/', setPermission);
|
||||||
|
|
||||||
/* ── Массово по классу (личные оверрайды всем ученикам класса) ── */
|
/* ── Пресеты-профили + массово по классу (личные оверрайды ученикам класса) ── */
|
||||||
|
router.get('/presets', requireRole('admin'), getPresets);
|
||||||
router.post('/class/:id/bulk', requireRole('admin'), setClassPermission);
|
router.post('/class/:id/bulk', requireRole('admin'), setClassPermission);
|
||||||
|
router.post('/class/:id/preset', requireRole('admin'), applyClassPreset);
|
||||||
|
|
||||||
/* ── Per-user overrides ── */
|
/* ── Per-user overrides ── */
|
||||||
router.get('/users/:id', getUserPermissions);
|
router.get('/users/:id', getUserPermissions);
|
||||||
|
|||||||
@@ -246,4 +246,32 @@ describe('Permissions', () => {
|
|||||||
{ permission: 'questions.manage', enabled: true }, adminToken);
|
{ permission: 'questions.manage', enabled: true }, adminToken);
|
||||||
assert.equal(bad.status, 400);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
let _permData = null;
|
let _permData = null;
|
||||||
let _bulkTargets = null; // { classes:[{id,name,students:[]}] }
|
let _bulkTargets = null; // { classes:[{id,name,students:[]}] }
|
||||||
let _bulkClassId = null;
|
let _bulkClassId = null;
|
||||||
|
let _presets = null; // { student:[{id,label,desc,perms}] }
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
try {
|
try {
|
||||||
@@ -24,6 +25,7 @@
|
|||||||
if (!root) return;
|
if (!root) return;
|
||||||
try { _bulkTargets = await LS.accessTargets(); }
|
try { _bulkTargets = await LS.accessTargets(); }
|
||||||
catch { _bulkTargets = { classes: [] }; }
|
catch { _bulkTargets = { classes: [] }; }
|
||||||
|
if (!_presets) { try { _presets = await LS.permissionsPresets(); } catch { _presets = { student: [] }; } }
|
||||||
renderBulk();
|
renderBulk();
|
||||||
}
|
}
|
||||||
function renderBulk() {
|
function renderBulk() {
|
||||||
@@ -39,13 +41,19 @@
|
|||||||
<span style="font-size:13px;color:var(--text-1)">${esc(d.label)}</span>
|
<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>
|
<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('');
|
</div>`).join('');
|
||||||
|
const presets = (_presets && _presets.student) || [];
|
||||||
|
const presetBar = (cls && presets.length) ? `
|
||||||
|
<div style="margin-bottom:12px;padding-bottom:10px;border-bottom:1px solid var(--border-h,#eee)">
|
||||||
|
<span style="font-size:12px;color:var(--muted);margin-right:6px">Пресет-профиль:</span>
|
||||||
|
${presets.map(p => `<button onclick="applyPreset('${esc(p.id)}')" title="${esc(p.desc)}" style="padding:5px 11px;margin:3px 3px 0 0;border:1px solid var(--border);border-radius:8px;background:var(--accent-soft,#eef2ff);color:var(--accent,#4f46e5);cursor:pointer;font-family:inherit;font-size:12px">${esc(p.label)}</button>`).join('')}
|
||||||
|
</div>` : '';
|
||||||
root.innerHTML = `
|
root.innerHTML = `
|
||||||
<div style="font-weight:600;margin-bottom:6px">Массово по классу</div>
|
<div style="font-weight:600;margin-bottom:6px">Массово по классу</div>
|
||||||
<div style="font-size:12px;color:var(--muted);margin-bottom:10px">Выставить личное правило сразу всем ученикам класса (переопределяет роль). «Сбросить» — вернуть наследование роли.</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">
|
<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}
|
<option value="">— выберите класс —</option>${opts}
|
||||||
</select>
|
</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>`;
|
<div style="margin-top:10px">${cls ? (presetBar + (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(); }
|
function selBulkClass(v) { _bulkClassId = v ? Number(v) : null; renderBulk(); }
|
||||||
async function bulkPerm(permission, enabled) {
|
async function bulkPerm(permission, enabled) {
|
||||||
@@ -63,6 +71,19 @@
|
|||||||
LS.toast(`Готово: затронуто учеников — ${r.affected}`, 'success');
|
LS.toast(`Готово: затронуто учеников — ${r.affected}`, 'success');
|
||||||
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
|
} 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) {
|
function permCard(role, def, en, labelOf) {
|
||||||
const enabled = en[def.key];
|
const enabled = en[def.key];
|
||||||
@@ -205,6 +226,7 @@
|
|||||||
window.loadPermLog = loadPermLog;
|
window.loadPermLog = loadPermLog;
|
||||||
window.selBulkClass = selBulkClass;
|
window.selBulkClass = selBulkClass;
|
||||||
window.bulkPerm = bulkPerm;
|
window.bulkPerm = bulkPerm;
|
||||||
|
window.applyPreset = applyPreset;
|
||||||
|
|
||||||
window.AdminSections = window.AdminSections || {};
|
window.AdminSections = window.AdminSections || {};
|
||||||
window.AdminSections.permissions = {
|
window.AdminSections.permissions = {
|
||||||
|
|||||||
@@ -1033,7 +1033,7 @@ window.LS = {
|
|||||||
getFolders, createFolder, renameFolder, deleteFolder, moveFile,
|
getFolders, createFolder, renameFolder, deleteFolder, moveFile,
|
||||||
getFolderAccess, clearFolderAccess, assignFolder, unassignFolder, getStudentsList,
|
getFolderAccess, clearFolderAccess, assignFolder, unassignFolder, getStudentsList,
|
||||||
submitWork, resubmitWork, getMySubmissions, getClassSubmissions, reviewSubmission, deleteSubmission, submissionDownloadUrl,
|
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,
|
accessCatalog, accessTargets, accessSummary, accessClassOpen, accessMatrix, accessLog, accessRules, accessSetRule,
|
||||||
getCourseTemplates, saveCourseTemplate, createFromCourseTemplate, deleteCourseTemplate,
|
getCourseTemplates, saveCourseTemplate, createFromCourseTemplate, deleteCourseTemplate,
|
||||||
getLessonTemplates, saveLessonTemplate, createFromLessonTemplate, deleteLessonTemplate,
|
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 getPermissions() { return req('GET', '/permissions'); }
|
||||||
async function permissionsLog(userId) { return req('GET', userId ? `/permissions/log?user_id=${userId}` : '/permissions/log'); }
|
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 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 setPermission(role, permission, enabled) { return req('POST', '/permissions', { role, permission, enabled }); }
|
||||||
async function getUserPermissions(uid) { return req('GET', `/permissions/users/${uid}`); }
|
async function getUserPermissions(uid) { return req('GET', `/permissions/users/${uid}`); }
|
||||||
async function setUserPermission(uid, permission, enabled) { return req('POST', `/permissions/users/${uid}`, { permission, enabled }); }
|
async function setUserPermission(uid, permission, enabled) { return req('POST', `/permissions/users/${uid}`, { permission, enabled }); }
|
||||||
|
|||||||
Reference in New Issue
Block a user