feat(permissions): A3 — история изменений прав (endpoint + UI)
GET /api/permissions/log (admin-only) — последние изменения ролевых прав (или ?user_id= для личных оверрайдов) из admin_audit_log; читаемый текст («включил «X» для роли «учитель»») с резолвом меток через registry. Клиент LS.permissionsLog. Вкладка «Доступ · роли»: блок «История изменений прав ролей» с кнопкой «Показать». Тест: admin видит записи, не-админу 403. permissions 13/13. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -159,4 +159,42 @@ function resetUserPermissions(req, res) {
|
|||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { getPermissions, setPermission, seedDefaults, ALL_PERMISSIONS, getMyPermissions, getUserPermissions, setUserPermission, resetUserPermissions };
|
/* ── GET /api/permissions/log?user_id= — история изменений прав (admin) ── */
|
||||||
|
function getPermissionLog(req, res) {
|
||||||
|
const uid = req.query.user_id ? Number(req.query.user_id) : null;
|
||||||
|
const rows = uid
|
||||||
|
? db.prepare(`
|
||||||
|
SELECT a.action, a.target, 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 'permission.user%' AND (a.target = ? OR a.target LIKE ?)
|
||||||
|
ORDER BY a.id DESC LIMIT 50`).all('user:' + uid, 'user:' + uid + '/%')
|
||||||
|
: db.prepare(`
|
||||||
|
SELECT a.action, a.target, 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 = 'permission.set'
|
||||||
|
ORDER BY a.id DESC LIMIT 50`).all();
|
||||||
|
|
||||||
|
const labelOf = {};
|
||||||
|
for (const k of registry.listKeys()) labelOf[k] = registry.PERMISSIONS[k].label;
|
||||||
|
const roleName = (r) => (r === 'teacher' ? 'учитель' : r === 'student' ? 'ученик' : r);
|
||||||
|
const onoff = (d) => (/enabled=1/.test(d || '') ? 'включил' : /enabled=0/.test(d || '') ? 'выключил' : 'изменил');
|
||||||
|
|
||||||
|
const out = rows.map(r => {
|
||||||
|
let text;
|
||||||
|
if (r.action === 'permission.set') {
|
||||||
|
const m = /^role:([^/]+)\/(.+)$/.exec(r.target || '');
|
||||||
|
const key = m ? m[2] : '';
|
||||||
|
text = `${onoff(r.detail)} «${labelOf[key] || key}» для роли «${roleName(m ? m[1] : '')}»`;
|
||||||
|
} else if (r.action === 'permission.user_set') {
|
||||||
|
const m = /^user:\d+\/(.+)$/.exec(r.target || '');
|
||||||
|
const key = m ? m[1] : '';
|
||||||
|
text = `${onoff(r.detail)} личное «${labelOf[key] || key}»`;
|
||||||
|
} else { // permission.user_reset
|
||||||
|
text = r.detail ? `сбросил личное «${labelOf[r.detail] || r.detail}»` : 'сбросил все личные правила';
|
||||||
|
}
|
||||||
|
return { actor: r.actor || '—', text, at: r.created_at };
|
||||||
|
});
|
||||||
|
res.json(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getPermissions, setPermission, seedDefaults, ALL_PERMISSIONS, getMyPermissions, getUserPermissions, setUserPermission, resetUserPermissions, getPermissionLog };
|
||||||
|
|||||||
@@ -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 } = require('../controllers/permissionsController');
|
const { getPermissions, setPermission, getMyPermissions, getUserPermissions, setUserPermission, resetUserPermissions, getPermissionLog } = require('../controllers/permissionsController');
|
||||||
|
|
||||||
router.use(authMiddleware);
|
router.use(authMiddleware);
|
||||||
|
|
||||||
@@ -10,6 +10,7 @@ router.get('/me', getMyPermissions);
|
|||||||
router.use(requireRole('admin'));
|
router.use(requireRole('admin'));
|
||||||
|
|
||||||
router.get('/', getPermissions);
|
router.get('/', getPermissions);
|
||||||
|
router.get('/log', getPermissionLog);
|
||||||
router.post('/', setPermission);
|
router.post('/', setPermission);
|
||||||
|
|
||||||
/* ── Per-user overrides ── */
|
/* ── Per-user overrides ── */
|
||||||
|
|||||||
@@ -191,4 +191,17 @@ describe('Permissions', () => {
|
|||||||
await inject('POST', '/api/permissions',
|
await inject('POST', '/api/permissions',
|
||||||
{ role: 'student', permission: 'simulations.access', enabled: true }, adminToken);
|
{ role: 'student', permission: 'simulations.access', enabled: true }, adminToken);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── 11. A3: история изменений прав ─────────────────────────────────────────
|
||||||
|
it('GET /api/permissions/log — история (admin видит записи; не-админу 403)', async () => {
|
||||||
|
await inject('POST', '/api/permissions',
|
||||||
|
{ role: 'teacher', permission: 'shop.manage', enabled: true }, adminToken);
|
||||||
|
const log = await inject('GET', '/api/permissions/log', null, adminToken);
|
||||||
|
assert.equal(log.status, 200);
|
||||||
|
assert.ok(Array.isArray(log.body) && log.body.length >= 1, 'есть записи истории');
|
||||||
|
assert.ok('text' in log.body[0] && 'actor' in log.body[0], 'формат записи');
|
||||||
|
const fresh = await getToken('student');
|
||||||
|
const denied = await inject('GET', '/api/permissions/log', null, fresh.token);
|
||||||
|
assert.equal(denied.status, 403, 'не-админу недоступно');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -102,8 +102,23 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadPermLog() {
|
||||||
|
const box = document.getElementById('perm-log');
|
||||||
|
if (!box) return;
|
||||||
|
box.innerHTML = '<p style="color:var(--muted);font-size:12px">Загрузка…</p>';
|
||||||
|
try {
|
||||||
|
const log = await LS.permissionsLog();
|
||||||
|
box.innerHTML = log.length
|
||||||
|
? log.map(e => `<div style="font-size:12.5px;padding:5px 0;border-top:1px solid var(--border-h,#eee)"><b>${esc(e.actor)}</b> ${esc(e.text)} <span style="color:var(--muted)">· ${esc((e.at || '').replace('T', ' ').slice(0, 16))}</span></div>`).join('')
|
||||||
|
: '<p style="color:var(--muted);font-size:12px">Изменений пока нет.</p>';
|
||||||
|
} catch (e) {
|
||||||
|
box.innerHTML = `<p style="color:var(--danger);font-size:12px">Ошибка: ${esc(e.message)}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
window.togglePermission = togglePermission;
|
window.togglePermission = togglePermission;
|
||||||
window.filterPermissions = filterPermissions;
|
window.filterPermissions = filterPermissions;
|
||||||
|
window.loadPermLog = loadPermLog;
|
||||||
|
|
||||||
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, setPermission, getUserPermissions, setUserPermission, resetUserPermissions,
|
getPermissions, permissionsLog, 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,
|
||||||
@@ -1295,6 +1295,7 @@ function submissionDownloadUrl(id) { return `${API}/submissions/${id}/d
|
|||||||
|
|
||||||
/* ── permissions (admin only) ────────────────────────────────────────────── */
|
/* ── permissions (admin only) ────────────────────────────────────────────── */
|
||||||
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 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