feat(access): Фаза 2c — история правил + пресет «копировать доступ из класса»

История: GET /api/access/log (admin-only) — кто/когда открыл/закрыл/сбросил
правило для контента (из admin_audit_log, имена классов/учеников резолвятся).
Клиент LS.accessLog; в режиме «По контенту» — кнопка «История изменений».
Пресет: в режиме «По классу» — «Скопировать доступ из класса [выбор]» (дополняет
текущие правила открытыми правилами класса-источника). Тест: история (admin
видит запись, учителю 403). content-access 13/13.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-03 13:55:02 +03:00
parent 11ec350dfa
commit b702b04ed2
4 changed files with 102 additions and 2 deletions
+30
View File
@@ -146,6 +146,36 @@ router.get('/matrix', (req, res) => {
res.json({ classes, open }); 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= /* GET /api/access/rules?content_type=&content_ref=
→ { classRules:{[class_id]:allow}, studentRules:{[user_id]:allow} } */ → { classRules:{[class_id]:allow}, studentRules:{[user_id]:allow} } */
+13
View File
@@ -116,6 +116,19 @@ describe('contentAccess', () => {
assert.ok(Array.isArray(r.body.sims) && r.body.sims.length >= 1, 'каталог содержит симуляции'); 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 () => { it('DELETE /api/classes/:id чистит правила класса (через purgeAccessFor)', async () => {
setRule('class', classId, 1); setRule('class', classId, 1);
const del = await inject('DELETE', `/api/classes/${classId}`, null, teacher.token); const del = await inject('DELETE', `/api/classes/${classId}`, null, teacher.token);
+54 -1
View File
@@ -269,7 +269,29 @@
<div style="margin-top:18px"> <div style="margin-top:18px">
<div style="font-weight:600;font-size:13px;color:var(--text-3);margin-bottom:8px">Отдельные ученики (без класса)</div> <div style="font-weight:600;font-size:13px;color:var(--text-3);margin-bottom:8px">Отдельные ученики (без класса)</div>
${loose.map(looseRow).join('')} ${loose.map(looseRow).join('')}
</div>` : ''}`; </div>` : ''}
<div style="margin-top:18px;border-top:1px solid var(--border);padding-top:12px">
<button class="adm-btn adm-btn-small" style="background:transparent;color:var(--text-3);border:1px solid var(--border)" onclick="accShowLog()">История изменений</button>
<div id="acc-log" style="margin-top:10px"></div>
</div>`;
}
async function showLog() {
const box = document.getElementById('acc-log');
if (!box || !_selContent) return;
box.innerHTML = '<p style="color:var(--muted);font-size:12px">Загрузка…</p>';
try {
const log = await LS.accessLog(_selContent.type, _selContent.ref);
if (!log.length) { box.innerHTML = '<p style="color:var(--muted);font-size:12px">Изменений пока нет.</p>'; return; }
const A = { grant: 'открыл', deny: 'закрыл (исключение)', inherit: 'сбросил (наследование)' };
box.innerHTML = log.map(e => `
<div style="font-size:12.5px;color:var(--text-1);padding:5px 0;border-top:1px solid var(--border-soft,#f0f0f0)">
<b>${esc(e.actor)}</b> ${A[e.action] || esc(e.action)} · ${esc(e.targetName)}
<span style="color:var(--muted)"> · ${esc((e.at || '').replace('T', ' ').slice(0, 16))}</span>
</div>`).join('');
} catch (e) {
box.innerHTML = `<p style="color:var(--muted);font-size:12px">${e && e.status === 403 ? 'История доступна только администратору.' : 'Ошибка: ' + esc(e.message)}</p>`;
}
} }
/* пересчёт бейджа для текущего контента по отображаемым классам */ /* пересчёт бейджа для текущего контента по отображаемым классам */
@@ -359,6 +381,17 @@
${subjects.map(s => `<button class="adm-btn adm-btn-small" style="background:var(--accent-soft,#eef2ff);color:var(--accent,#4f46e5)" onclick="accClassSubj('${esc(s)}')">+ ${esc(subjLabel(s))}</button>`).join('')} ${subjects.map(s => `<button class="adm-btn adm-btn-small" style="background:var(--accent-soft,#eef2ff);color:var(--accent,#4f46e5)" onclick="accClassSubj('${esc(s)}')">+ ${esc(subjLabel(s))}</button>`).join('')}
</div>`; </div>`;
} }
const others = (_targets.classes || []).filter(c => c.id !== _selClass.id);
if (others.length) {
html += `<div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin-bottom:14px">
<span style="font-size:12px;color:var(--muted)">Скопировать доступ из класса:</span>
<select id="acc-copy-src" style="padding:5px 8px;border:1px solid var(--border);border-radius:7px;background:var(--card);color:var(--text-1);font-family:inherit;font-size:12.5px">
<option value="">— выберите —</option>
${others.map(c => `<option value="${c.id}">${esc(c.name)}</option>`).join('')}
</select>
<button class="adm-btn adm-btn-small" onclick="accCopyFrom()">Скопировать</button>
</div>`;
}
CONTENT_TYPES.forEach(type => { CONTENT_TYPES.forEach(type => {
const items = itemsOf(type); const items = itemsOf(type);
html += `<div style="font-weight:600;font-size:13px;color:var(--text-3);margin:14px 0 8px">${TYPE_LABEL[type]}</div>`; html += `<div style="font-weight:600;font-size:13px;color:var(--text-3);margin:14px 0 8px">${TYPE_LABEL[type]}</div>`;
@@ -397,6 +430,24 @@
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); selClass(_selClass.id); } } catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); selClass(_selClass.id); }
} }
/* пресет: скопировать открытый доступ из другого класса в текущий (дополняет) */
async function copyFrom() {
const sel = document.getElementById('acc-copy-src');
if (!sel || !sel.value) { LS.toast('Выберите класс-источник', 'error'); return; }
const srcId = Number(sel.value);
const srcName = sel.options[sel.selectedIndex].text;
if (!confirm(`Скопировать весь открытый доступ из «${srcName}» в «${_selClass.name}»? Текущие правила класса дополнятся.`)) return;
try {
const src = await LS.accessClassOpen(srcId);
const items = CONTENT_TYPES.flatMap(t => (src[bucket(t)] || []).map(ref => [t, ref]));
if (!items.length) { LS.toast('В классе-источнике нет открытого контента', 'error'); return; }
await Promise.all(items.map(([t, ref]) => LS.accessSetRule(t, ref, 'class', _selClass.id, 1)));
items.forEach(([t, ref]) => { const set = _classOpen[bucket(t)]; if (set && !set.has(ref)) { set.add(ref); bumpSummary(t, ref, +1); } });
renderRight();
LS.toast(`Скопировано из «${srcName}» (${items.length})`, 'success');
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); selClass(_selClass.id); }
}
async function classBulk(allow) { async function classBulk(allow) {
if (!allow && !confirm(`Закрыть весь контент у класса «${_selClass.name}»?`)) return; if (!allow && !confirm(`Закрыть весь контент у класса «${_selClass.name}»?`)) return;
const all = CONTENT_TYPES.flatMap(t => itemsOf(t).map(it => [t, it[keyName(t)]])); const all = CONTENT_TYPES.flatMap(t => itemsOf(t).map(it => [t, it[keyName(t)]]));
@@ -532,6 +583,8 @@
window.accClassToggle = classToggle; window.accClassToggle = classToggle;
window.accClassBulk = classBulk; window.accClassBulk = classBulk;
window.accClassSubj = classSubjectBulk; window.accClassSubj = classSubjectBulk;
window.accCopyFrom = copyFrom;
window.accShowLog = showLog;
window.accMx = mxToggle; window.accMx = mxToggle;
window.accMxSearch = mxSearch; window.accMxSearch = mxSearch;
window.accMxRowBulk = mxRowBulk; window.accMxRowBulk = mxRowBulk;
+5 -1
View File
@@ -1034,7 +1034,7 @@ window.LS = {
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, setPermission, getUserPermissions, setUserPermission, resetUserPermissions, getPermissions, setPermission, getUserPermissions, setUserPermission, resetUserPermissions,
accessCatalog, accessTargets, accessSummary, accessClassOpen, accessMatrix, 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,
getBookmarks, addBookmark, removeBookmark, removeBookmarkByEntity, checkBookmark, getBookmarks, addBookmark, removeBookmark, removeBookmarkByEntity, checkBookmark,
@@ -1306,6 +1306,10 @@ async function accessTargets() { return req('GET', '/access/targets'); }
async function accessSummary() { return req('GET', '/access/summary'); } async function accessSummary() { return req('GET', '/access/summary'); }
async function accessClassOpen(classId) { return req('GET', `/access/class/${classId}`); } async function accessClassOpen(classId) { return req('GET', `/access/class/${classId}`); }
async function accessMatrix() { return req('GET', '/access/matrix'); } async function accessMatrix() { return req('GET', '/access/matrix'); }
async function accessLog(content_type, content_ref) {
const p = new URLSearchParams({ content_type, content_ref });
return req('GET', `/access/log?${p}`);
}
async function accessRules(content_type, content_ref) { async function accessRules(content_type, content_ref) {
const p = new URLSearchParams({ content_type, content_ref }); const p = new URLSearchParams({ content_type, content_ref });
return req('GET', `/access/rules?${p}`); return req('GET', `/access/rules?${p}`);