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:
@@ -206,7 +206,7 @@ function getUsers(req, res) {
|
||||
const cursorWhere = where + ' AND u.id < ?';
|
||||
const cursorArgs = [...args, cursor];
|
||||
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,
|
||||
ROUND(AVG(CAST(ts.score AS REAL) / ts.total * 100), 1) AS avg_pct
|
||||
FROM users u
|
||||
|
||||
@@ -1305,6 +1305,8 @@
|
||||
oninput="filterPermissions(this.value)">
|
||||
</div>
|
||||
|
||||
<div class="perm-role-block" id="perm-roles"></div>
|
||||
|
||||
<div class="perm-role-block">
|
||||
<div class="perm-role-title">
|
||||
<span class="badge badge-warn" style="font-size:13px;padding:4px 12px">Учитель</span>
|
||||
|
||||
@@ -8,11 +8,15 @@
|
||||
let _bulkClassId = null;
|
||||
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() {
|
||||
try {
|
||||
_permData = await LS.getPermissions();
|
||||
renderPermissions();
|
||||
loadBulk();
|
||||
loadRoles();
|
||||
} catch(e) {
|
||||
document.getElementById('perm-teacher').innerHTML =
|
||||
`<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.togglePermGroup = togglePermGroup;
|
||||
window.filterPermissions = filterPermissions;
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
let _usersPage = 1;
|
||||
const _USERS_PER_PAGE = 50;
|
||||
let _customRoles = []; // кастомные роли для выпадающего списка назначения
|
||||
|
||||
/* ── one-time CSS injection for hover row-actions (shared with sessions) ── */
|
||||
function ensureRowActionsStyles() {
|
||||
@@ -65,6 +66,7 @@
|
||||
try {
|
||||
const r = await LS.adminGetUsers({ page: _usersPage, limit: _USERS_PER_PAGE });
|
||||
const users = r.users || [];
|
||||
try { _customRoles = (await LS.listRoles()).filter(x => !x.isBuiltin); } catch { _customRoles = []; }
|
||||
const tbody = document.getElementById('users-body');
|
||||
if (!users.length) {
|
||||
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 roleCell = isAdmin && u.id !== user.id
|
||||
? `<select class="role-select" data-uid="${u.id}" onchange="changeRole(this)">
|
||||
<option value="student" ${u.role==='student' ?'selected':''}>Ученик</option>
|
||||
<option value="free_student" ${u.role==='free_student' ?'selected':''}>Своб. ученик</option>
|
||||
<option value="teacher" ${u.role==='teacher' ?'selected':''}>Учитель</option>
|
||||
<option value="admin" ${u.role==='admin' ?'selected':''}>Админ</option>
|
||||
<option value="student" ${(!u.custom_role && u.role==='student') ?'selected':''}>Ученик</option>
|
||||
<option value="free_student" ${(!u.custom_role && u.role==='free_student') ?'selected':''}>Своб. ученик</option>
|
||||
<option value="teacher" ${(!u.custom_role && u.role==='teacher') ?'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>`
|
||||
: `<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}')">
|
||||
|
||||
@@ -1034,6 +1034,7 @@ window.LS = {
|
||||
getFolderAccess, clearFolderAccess, assignFolder, unassignFolder, getStudentsList,
|
||||
submitWork, resubmitWork, getMySubmissions, getClassSubmissions, reviewSubmission, deleteSubmission, submissionDownloadUrl,
|
||||
getPermissions, permissionsLog, setClassPermission, permissionsPresets, applyClassPreset, setPermission, getUserPermissions, setUserPermission, resetUserPermissions,
|
||||
listRoles, createRole, updateRoleDef, deleteRole, rolePermissions,
|
||||
accessCatalog, accessTargets, accessSummary, accessClassOpen, accessMatrix, accessLog, accessRules, accessSetRule,
|
||||
getCourseTemplates, saveCourseTemplate, createFromCourseTemplate, deleteCourseTemplate,
|
||||
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 setClassPermission(classId, permission, enabled) { return req('POST', `/permissions/class/${classId}/bulk`, { permission, enabled }); }
|
||||
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 setPermission(role, permission, enabled) { return req('POST', '/permissions', { role, permission, enabled }); }
|
||||
async function getUserPermissions(uid) { return req('GET', `/permissions/users/${uid}`); }
|
||||
|
||||
Reference in New Issue
Block a user