diff --git a/backend/src/routes/access.js b/backend/src/routes/access.js index 729f6b4..3918e8c 100644 --- a/backend/src/routes/access.js +++ b/backend/src/routes/access.js @@ -146,6 +146,36 @@ router.get('/matrix', (req, res) => { res.json({ classes, open }); }); +/* ── История изменений правил доступа к контенту (только админ) ────────── */ +/* GET /api/access/log?content_type=&content_ref= + → [{ action:'grant'|'deny'|'inherit', actor, targetName, at }] (последние 50) */ +router.get('/log', requireRole('admin'), (req, res) => { + const { content_type, content_ref } = req.query; + if (!['textbook', 'exam', 'sim', 'course'].includes(content_type) || !content_ref) { + return res.status(400).json({ error: 'content_type и content_ref обязательны' }); + } + const rows = db.prepare(` + SELECT a.action, a.detail, a.created_at, u.name AS actor + FROM admin_audit_log a LEFT JOIN users u ON u.id = a.admin_id + WHERE a.action LIKE 'access.%' AND a.target = ? + ORDER BY a.id DESC LIMIT 50 + `).all(content_type + ':' + content_ref); + + const out = rows.map(r => { + const [scope, tid] = String(r.detail || '').split(':'); + let targetName = r.detail || ''; + if (scope === 'class') { + const c = db.prepare('SELECT name FROM classes WHERE id = ?').get(tid); + targetName = c ? `класс «${c.name}»` : `класс #${tid}`; + } else if (scope === 'student') { + const s = db.prepare('SELECT name, email FROM users WHERE id = ?').get(tid); + targetName = s ? `ученик ${s.name || s.email}` : `ученик #${tid}`; + } + return { action: r.action.replace('access.', ''), actor: r.actor || '—', targetName, at: r.created_at }; + }); + res.json(out); +}); + /* ── Текущие правила для одного контента ───────────────────────────────── */ /* GET /api/access/rules?content_type=&content_ref= → { classRules:{[class_id]:allow}, studentRules:{[user_id]:allow} } */ diff --git a/backend/tests/content-access.test.js b/backend/tests/content-access.test.js index 573666e..b37cdd8 100644 --- a/backend/tests/content-access.test.js +++ b/backend/tests/content-access.test.js @@ -116,6 +116,19 @@ describe('contentAccess', () => { assert.ok(Array.isArray(r.body.sims) && r.body.sims.length >= 1, 'каталог содержит симуляции'); }); + it('GET /api/access/log — история (admin видит запись; учителю 403)', async () => { + const admin = await getToken('admin'); + const p = await inject('POST', '/api/access/rules', + { content_type: 'textbook', content_ref: HUB, scope: 'class', target_id: classId, allow: 1 }, admin.token); + assert.ok(p.status < 300, JSON.stringify(p.body)); + const log = await inject('GET', `/api/access/log?content_type=textbook&content_ref=${HUB}`, null, admin.token); + assert.equal(log.status, 200); + assert.ok(Array.isArray(log.body) && log.body.length >= 1, 'есть запись истории'); + assert.equal(log.body[0].action, 'grant'); + const t = await inject('GET', `/api/access/log?content_type=textbook&content_ref=${HUB}`, null, teacher.token); + assert.equal(t.status, 403, 'учителю недоступно'); + }); + it('DELETE /api/classes/:id чистит правила класса (через purgeAccessFor)', async () => { setRule('class', classId, 1); const del = await inject('DELETE', `/api/classes/${classId}`, null, teacher.token); diff --git a/frontend/js/admin/sections/access.js b/frontend/js/admin/sections/access.js index 98d65ce..b4c0f4c 100644 --- a/frontend/js/admin/sections/access.js +++ b/frontend/js/admin/sections/access.js @@ -269,7 +269,29 @@
Загрузка…
'; + try { + const log = await LS.accessLog(_selContent.type, _selContent.ref); + if (!log.length) { box.innerHTML = 'Изменений пока нет.
'; return; } + const A = { grant: 'открыл', deny: 'закрыл (исключение)', inherit: 'сбросил (наследование)' }; + box.innerHTML = log.map(e => ` +${e && e.status === 403 ? 'История доступна только администратору.' : 'Ошибка: ' + esc(e.message)}
`; + } } /* пересчёт бейджа для текущего контента по отображаемым классам */ @@ -359,6 +381,17 @@ ${subjects.map(s => ``).join('')} `; } + const others = (_targets.classes || []).filter(c => c.id !== _selClass.id); + if (others.length) { + html += `