feat(permissions): C-4b — админ-UI конструктора ролей + назначение пользователю

Клиент: listRoles/createRole/updateRoleDef/deleteRole/rolePermissions. Во вкладке
«Доступ · роли» — блок «Конструктор ролей»: создать роль (имя-идентификатор +
название + базовые роли чекбоксами), список кастомных ролей, «Настроить права»
(тогглы по группам через getRolePermissions + setPermission под именем роли),
«Удалить» (возврат пользователей на базу). В списке пользователей выпадающий
список ролей теперь включает optgroup «Кастомные роли» (выбор по custom_role);
listUsers отдаёт custom_role. Phase C (произвольные роли) завершена на ветке.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-03 15:26:52 +03:00
parent bdc8bef857
commit 6b148127b6
5 changed files with 123 additions and 5 deletions
+1 -1
View File
@@ -206,7 +206,7 @@ function getUsers(req, res) {
const cursorWhere = where + ' AND u.id < ?'; const cursorWhere = where + ' AND u.id < ?';
const cursorArgs = [...args, cursor]; const cursorArgs = [...args, cursor];
const users = db.prepare(` const users = db.prepare(`
SELECT u.id, u.name, u.email, u.role, u.created_at, u.last_login, u.is_banned, SELECT u.id, u.name, u.email, u.role, u.custom_role, u.created_at, u.last_login, u.is_banned,
COUNT(ts.id) AS tests_count, COUNT(ts.id) AS tests_count,
ROUND(AVG(CAST(ts.score AS REAL) / ts.total * 100), 1) AS avg_pct ROUND(AVG(CAST(ts.score AS REAL) / ts.total * 100), 1) AS avg_pct
FROM users u FROM users u
+2
View File
@@ -1305,6 +1305,8 @@
oninput="filterPermissions(this.value)"> oninput="filterPermissions(this.value)">
</div> </div>
<div class="perm-role-block" id="perm-roles"></div>
<div class="perm-role-block"> <div class="perm-role-block">
<div class="perm-role-title"> <div class="perm-role-title">
<span class="badge badge-warn" style="font-size:13px;padding:4px 12px">Учитель</span> <span class="badge badge-warn" style="font-size:13px;padding:4px 12px">Учитель</span>
+107
View File
@@ -8,11 +8,15 @@
let _bulkClassId = null; let _bulkClassId = null;
let _presets = null; // { student:[{id,label,desc,perms}] } let _presets = null; // { student:[{id,label,desc,perms}] }
let _roles = null; // [{name,label,baseRoles,isBuiltin,users}]
let _roleEdit = null; // { name, base, permissions, definitions } — открытый редактор прав роли
async function load() { async function load() {
try { try {
_permData = await LS.getPermissions(); _permData = await LS.getPermissions();
renderPermissions(); renderPermissions();
loadBulk(); loadBulk();
loadRoles();
} catch(e) { } catch(e) {
document.getElementById('perm-teacher').innerHTML = document.getElementById('perm-teacher').innerHTML =
`<p style="color:var(--danger);font-size:13px">Ошибка загрузки: ${esc(e.message)}</p>`; `<p style="color:var(--danger);font-size:13px">Ошибка загрузки: ${esc(e.message)}</p>`;
@@ -220,6 +224,109 @@
} }
} }
/* ════════ Конструктор ролей (C-4) ════════ */
const BASE_OPTS = [['teacher', 'Учитель'], ['student', 'Ученик'], ['free_student', 'Свободный ученик'], ['admin', 'Администратор']];
async function loadRoles() {
const root = document.getElementById('perm-roles');
if (!root) return;
try { _roles = await LS.listRoles(); } catch { _roles = []; }
renderRoles();
}
function renderRoles() {
const root = document.getElementById('perm-roles');
if (!root) return;
const customs = (_roles || []).filter(r => !r.isBuiltin);
const baseChecks = BASE_OPTS.map(([v, lbl]) =>
`<label style="display:inline-flex;align-items:center;gap:4px;font-size:12.5px;margin-right:10px"><input type="checkbox" value="${v}" class="nr-base"> ${esc(lbl)}</label>`).join('');
const rolesList = customs.length ? customs.map(r => `
<div style="display:flex;align-items:center;justify-content:space-between;gap:10px;padding:8px 0;border-top:1px solid var(--border-h,#eee)">
<div><b style="font-size:13.5px">${esc(r.label)}</b> <span style="color:var(--muted);font-size:12px">(${esc(r.name)} · база: ${(r.baseRoles || []).join(', ')} · ${r.users} польз.)</span></div>
<span style="display:inline-flex;gap:6px">
<button onclick="configureRole('${esc(r.name)}')" style="padding:4px 10px;border:1px solid var(--border);border-radius:7px;background:var(--accent-soft,#eef2ff);color:var(--accent,#4f46e5);cursor:pointer;font-family:inherit;font-size:12px">Настроить права</button>
<button onclick="deleteRoleUI('${esc(r.name)}')" style="padding:4px 10px;border:1px solid var(--border);border-radius:7px;background:transparent;color:var(--danger,#dc2626);cursor:pointer;font-family:inherit;font-size:12px">Удалить</button>
</span>
</div>`).join('') : '<p style="color:var(--muted);font-size:12px;margin:6px 0">Кастомных ролей пока нет.</p>';
root.innerHTML = `
<div style="font-weight:600;margin-bottom:4px">Конструктор ролей</div>
<div style="font-size:12px;color:var(--muted);margin-bottom:12px">Кастомная роль проходит гейты своих «базовых ролей» и имеет свой набор прав (изначально — копия базы). Назначается пользователю в его карточке.</div>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:8px">
<input id="nr-name" placeholder="имя (латиница)" style="padding:7px 10px;border:1.5px solid var(--border);border-radius:8px;font-family:inherit;font-size:13px;width:150px">
<input id="nr-label" placeholder="название" style="padding:7px 10px;border:1.5px solid var(--border);border-radius:8px;font-family:inherit;font-size:13px;width:170px">
<button onclick="createRoleUI()" class="adm-btn adm-btn-small">Создать роль</button>
</div>
<div style="margin-bottom:6px">${baseChecks}</div>
${rolesList}
<div id="role-editor" style="margin-top:12px">${_roleEdit ? roleEditorHtml() : ''}</div>`;
}
function roleEditorHtml() {
const defs = _roleEdit.definitions || [];
const map = _roleEdit.permissions || {};
const order = [], byGroup = {};
defs.forEach(d => { const g = d.group || 'Прочее'; if (!byGroup[g]) { byGroup[g] = []; order.push(g); } byGroup[g].push(d); });
const card = (d) => `
<div class="perm-card${map[d.key] ? ' enabled' : ''}">
<div class="perm-info"><div class="perm-label">${esc(d.label)}</div><div class="perm-desc">${esc(d.desc)}</div></div>
<label class="perm-toggle"><input type="checkbox" ${map[d.key] ? 'checked' : ''} onchange="toggleRolePerm('${esc(_roleEdit.name)}','${esc(d.key)}',this.checked,this)"><span class="perm-track"></span><span class="perm-thumb"></span></label>
</div>`;
const groups = order.map(g => `<div style="font-size:11.5px;font-weight:700;color:var(--text-3);text-transform:uppercase;letter-spacing:.05em;margin:8px 0 6px">${esc(g)}</div><div class="perm-grid">${byGroup[g].map(card).join('')}</div>`).join('');
return `<div style="border-top:1px solid var(--border);padding-top:10px">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px">
<b style="font-size:13px">Права роли «${esc(_roleEdit.name)}» (база: ${esc(_roleEdit.base)})</b>
<button onclick="closeRoleEditor()" style="border:none;background:transparent;cursor:pointer;color:var(--text-3);font-size:13px">закрыть ×</button>
</div>
${defs.length ? groups : '<p style="color:var(--muted);font-size:12px">У базы нет настраиваемых прав.</p>'}</div>`;
}
async function createRoleUI() {
const name = (document.getElementById('nr-name').value || '').trim();
const label = (document.getElementById('nr-label').value || '').trim();
const bases = [...document.querySelectorAll('#perm-roles .nr-base:checked')].map(c => c.value);
if (!name) { LS.toast('Укажите имя роли', 'error'); return; }
if (!bases.length) { LS.toast('Отметьте хотя бы одну базовую роль', 'error'); return; }
try {
await LS.createRole(name, label || name, bases);
await loadRoles();
LS.toast('Роль создана', 'success');
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
}
async function deleteRoleUI(name) {
if (!await LS.confirm(`Удалить роль «${name}»? Пользователи вернутся на её базовую роль.`, { title: 'Удалить роль', confirmText: 'Удалить' })) return;
try {
const r = await LS.deleteRole(name);
if (_roleEdit && _roleEdit.name === name) _roleEdit = null;
await loadRoles();
LS.toast(`Роль удалена (вернулось ${r.reassigned || 0} польз.)`, 'success');
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
}
async function configureRole(name) {
try { _roleEdit = await LS.rolePermissions(name); renderRoles(); }
catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
}
function closeRoleEditor() { _roleEdit = null; renderRoles(); }
async function toggleRolePerm(roleName, key, enabled, checkbox) {
checkbox.disabled = true;
try {
await LS.setPermission(roleName, key, enabled);
if (_roleEdit && _roleEdit.name === roleName) _roleEdit.permissions[key] = enabled;
const editor = document.getElementById('role-editor');
if (editor) editor.innerHTML = _roleEdit ? roleEditorHtml() : '';
LS.toast(enabled ? 'Право включено' : 'Право отключено', 'success');
} catch (e) { checkbox.checked = !enabled; LS.toast('Ошибка: ' + e.message, 'error'); }
finally { checkbox.disabled = false; }
}
window.createRoleUI = createRoleUI;
window.deleteRoleUI = deleteRoleUI;
window.configureRole = configureRole;
window.closeRoleEditor = closeRoleEditor;
window.toggleRolePerm = toggleRolePerm;
window.togglePermission = togglePermission; window.togglePermission = togglePermission;
window.togglePermGroup = togglePermGroup; window.togglePermGroup = togglePermGroup;
window.filterPermissions = filterPermissions; window.filterPermissions = filterPermissions;
+7 -4
View File
@@ -6,6 +6,7 @@
let _usersPage = 1; let _usersPage = 1;
const _USERS_PER_PAGE = 50; const _USERS_PER_PAGE = 50;
let _customRoles = []; // кастомные роли для выпадающего списка назначения
/* ── one-time CSS injection for hover row-actions (shared with sessions) ── */ /* ── one-time CSS injection for hover row-actions (shared with sessions) ── */
function ensureRowActionsStyles() { function ensureRowActionsStyles() {
@@ -65,6 +66,7 @@
try { try {
const r = await LS.adminGetUsers({ page: _usersPage, limit: _USERS_PER_PAGE }); const r = await LS.adminGetUsers({ page: _usersPage, limit: _USERS_PER_PAGE });
const users = r.users || []; const users = r.users || [];
try { _customRoles = (await LS.listRoles()).filter(x => !x.isBuiltin); } catch { _customRoles = []; }
const tbody = document.getElementById('users-body'); const tbody = document.getElementById('users-body');
if (!users.length) { if (!users.length) {
tbody.innerHTML = '<tr><td colspan="7"><div class="empty">Пользователей нет</div></td></tr>'; tbody.innerHTML = '<tr><td colspan="7"><div class="empty">Пользователей нет</div></td></tr>';
@@ -77,10 +79,11 @@
const avatarBg = u.role==='admin' ? 'linear-gradient(135deg,#9B5DE5,#c084fc)' : u.role==='teacher' ? 'linear-gradient(135deg,#06D6E0,#9B5DE5)' : u.role==='free_student' ? 'linear-gradient(135deg,#10B981,#059669)' : 'linear-gradient(135deg,#8898AA,#3D4F6B)'; const avatarBg = u.role==='admin' ? 'linear-gradient(135deg,#9B5DE5,#c084fc)' : u.role==='teacher' ? 'linear-gradient(135deg,#06D6E0,#9B5DE5)' : u.role==='free_student' ? 'linear-gradient(135deg,#10B981,#059669)' : 'linear-gradient(135deg,#8898AA,#3D4F6B)';
const roleCell = isAdmin && u.id !== user.id const roleCell = isAdmin && u.id !== user.id
? `<select class="role-select" data-uid="${u.id}" onchange="changeRole(this)"> ? `<select class="role-select" data-uid="${u.id}" onchange="changeRole(this)">
<option value="student" ${u.role==='student' ?'selected':''}>Ученик</option> <option value="student" ${(!u.custom_role && u.role==='student') ?'selected':''}>Ученик</option>
<option value="free_student" ${u.role==='free_student' ?'selected':''}>Своб. ученик</option> <option value="free_student" ${(!u.custom_role && u.role==='free_student') ?'selected':''}>Своб. ученик</option>
<option value="teacher" ${u.role==='teacher' ?'selected':''}>Учитель</option> <option value="teacher" ${(!u.custom_role && u.role==='teacher') ?'selected':''}>Учитель</option>
<option value="admin" ${u.role==='admin' ?'selected':''}>Админ</option> <option value="admin" ${(!u.custom_role && u.role==='admin') ?'selected':''}>Админ</option>
${_customRoles.length ? '<optgroup label="Кастомные роли">' + _customRoles.map(cr => `<option value="${esc(cr.name)}" ${u.custom_role===cr.name?'selected':''}>${esc(cr.label)}</option>`).join('') + '</optgroup>' : ''}
</select>` </select>`
: `<span class="role-badge ${u.role}">${{student:'Ученик',free_student:'Своб. ученик',teacher:'Учитель',admin:'Админ'}[u.role]||u.role}</span>`; : `<span class="role-badge ${u.role}">${{student:'Ученик',free_student:'Своб. ученик',teacher:'Учитель',admin:'Админ'}[u.role]||u.role}</span>`;
return `<tr class="clickable${u.is_banned ? ' banned-row' : ''}" onclick="AdminRouter.navigate('#users/${u.id}')"> return `<tr class="clickable${u.is_banned ? ' banned-row' : ''}" onclick="AdminRouter.navigate('#users/${u.id}')">
+6
View File
@@ -1034,6 +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, permissionsLog, setClassPermission, permissionsPresets, applyClassPreset, setPermission, getUserPermissions, setUserPermission, resetUserPermissions, getPermissions, permissionsLog, setClassPermission, permissionsPresets, applyClassPreset, setPermission, getUserPermissions, setUserPermission, resetUserPermissions,
listRoles, createRole, updateRoleDef, deleteRole, rolePermissions,
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,
@@ -1298,6 +1299,11 @@ async function getPermissions() { return req('GET',
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 permissionsPresets() { return req('GET', '/permissions/presets'); }
async function listRoles() { return req('GET', '/roles'); }
async function createRole(name, label, baseRoles) { return req('POST', '/roles', { name, label, baseRoles }); }
async function updateRoleDef(name, body) { return req('PUT', `/roles/${encodeURIComponent(name)}`, body); }
async function deleteRole(name) { return req('DELETE', `/roles/${encodeURIComponent(name)}`); }
async function rolePermissions(name) { return req('GET', `/roles/${encodeURIComponent(name)}/permissions`); }
async function applyClassPreset(classId, preset) { return req('POST', `/permissions/class/${classId}/preset`, { preset }); } 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}`); }