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
+107
View File
@@ -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;