Files
Learn_System/frontend/js/admin/sections/permissions.js
Maxim Dolgolyov 6b148127b6 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>
2026-06-03 15:26:52 +03:00

344 lines
21 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
/* admin → permissions section (role-based teacher/student permissions) */
(function () {
'use strict';
let inited = false;
let _permData = null;
let _bulkTargets = null; // { classes:[{id,name,students:[]}] }
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>`;
}
}
/* ── Массово по классу ── */
async function loadBulk() {
const root = document.getElementById('perm-bulk');
if (!root) return;
try { _bulkTargets = await LS.accessTargets(); }
catch { _bulkTargets = { classes: [] }; }
if (!_presets) { try { _presets = await LS.permissionsPresets(); } catch { _presets = { student: [] }; } }
renderBulk();
}
function renderBulk() {
const root = document.getElementById('perm-bulk');
if (!root || !_permData) return;
const classes = (_bulkTargets && _bulkTargets.classes) || [];
const studentDefs = (_permData.definitions || []).filter(d => d.role === 'student');
const opts = classes.map(c => `<option value="${c.id}" ${_bulkClassId === c.id ? 'selected' : ''}>${esc(c.name)} (${(c.students || []).length})</option>`).join('');
const cls = classes.find(c => c.id === _bulkClassId);
const btn = (key, val, label, bg) => `<button onclick="bulkPerm('${esc(key)}',${val})" style="padding:3px 9px;border:1px solid var(--border);border-radius:7px;background:${bg};color:var(--text-3);cursor:pointer;font-family:inherit;font-size:11.5px">${label}</button>`;
const rows = (cls ? studentDefs : []).map(d => `
<div style="display:flex;align-items:center;justify-content:space-between;gap:10px;padding:7px 0;border-top:1px solid var(--border-h,#eee)">
<span style="font-size:13px;color:var(--text-1)">${esc(d.label)}</span>
<span style="display:inline-flex;gap:6px">${btn(d.key, 1, 'включить всем', 'transparent')}${btn(d.key, 0, 'выключить всем', 'transparent')}${btn(d.key, 'null', 'сбросить', 'var(--border-h,#eee)')}</span>
</div>`).join('');
const presets = (_presets && _presets.student) || [];
const presetBar = (cls && presets.length) ? `
<div style="margin-bottom:12px;padding-bottom:10px;border-bottom:1px solid var(--border-h,#eee)">
<span style="font-size:12px;color:var(--muted);margin-right:6px">Пресет-профиль:</span>
${presets.map(p => `<button onclick="applyPreset('${esc(p.id)}')" title="${esc(p.desc)}" style="padding:5px 11px;margin:3px 3px 0 0;border:1px solid var(--border);border-radius:8px;background:var(--accent-soft,#eef2ff);color:var(--accent,#4f46e5);cursor:pointer;font-family:inherit;font-size:12px">${esc(p.label)}</button>`).join('')}
</div>` : '';
root.innerHTML = `
<div style="font-weight:600;margin-bottom:6px">Массово по классу</div>
<div style="font-size:12px;color:var(--muted);margin-bottom:10px">Выставить личное правило сразу всем ученикам класса (переопределяет роль). «Сбросить» — вернуть наследование роли.</div>
<select onchange="selBulkClass(this.value)" style="padding:8px 12px;border:1.5px solid var(--border);border-radius:9px;font-family:inherit;font-size:0.9rem;min-width:220px">
<option value="">— выберите класс —</option>${opts}
</select>
<div style="margin-top:10px">${cls ? (presetBar + (rows || '<p style="color:var(--muted);font-size:12px">Нет студенческих прав.</p>')) : '<p style="color:var(--muted);font-size:12px">Выберите класс.</p>'}</div>`;
}
function selBulkClass(v) { _bulkClassId = v ? Number(v) : null; renderBulk(); }
async function bulkPerm(permission, enabled) {
if (!_bulkClassId) return;
if (enabled === 'null') enabled = null;
const cls = ((_bulkTargets && _bulkTargets.classes) || []).find(c => c.id === _bulkClassId);
const clsName = cls ? cls.name : ('#' + _bulkClassId);
if (enabled === 0) {
const ok = await LS.confirm(`Выключить это право всем ученикам класса «${clsName}»?`,
{ title: 'Подтвердите', confirmText: 'Выключить всем' });
if (!ok) return;
}
try {
const r = await LS.setClassPermission(_bulkClassId, permission, enabled);
LS.toast(`Готово: затронуто учеников — ${r.affected}`, 'success');
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
}
async function applyPreset(presetId) {
if (!_bulkClassId) return;
const cls = ((_bulkTargets && _bulkTargets.classes) || []).find(c => c.id === _bulkClassId);
const p = ((_presets && _presets.student) || []).find(x => x.id === presetId);
const ok = await LS.confirm(
`Применить пресет «${p ? p.label : presetId}» ко всем ученикам класса «${cls ? cls.name : ''}»?`,
{ title: 'Применить пресет', confirmText: 'Применить' });
if (!ok) return;
try {
const r = await LS.applyClassPreset(_bulkClassId, presetId);
LS.toast(`Пресет применён: ${r.affected} учеников`, 'success');
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
}
function permCard(role, def, en, labelOf) {
const enabled = en[def.key];
const reqs = def.requires || [];
const unmet = reqs.filter(r => !en[r]);
const blocked = unmet.length > 0; // зависимость не выполнена → право неактивно
const effective = enabled && !blocked;
const isModified = (enabled ? 1 : 0) !== def.default;
const modDot = isModified
? `<span class="perm-modified-dot" title="Отличается от значения по умолчанию"></span>`
: '';
const reqNote = reqs.length
? `<div class="perm-desc" style="margin-top:3px;color:${blocked ? 'var(--danger,#dc2626)' : 'var(--muted)'}">${blocked ? 'Требует: ' + unmet.map(r => esc(labelOf[r] || r)).join(', ') : 'Зависит от: ' + reqs.map(r => esc(labelOf[r] || r)).join(', ')}</div>`
: '';
return `
<div class="perm-card${effective ? ' enabled' : ''}" id="perm-card-${role}-${def.key.replace('.','_')}" style="${blocked ? 'opacity:.65' : ''}">
<div class="perm-info">
<div class="perm-label">${esc(def.label)}${modDot}</div>
<div class="perm-desc">${esc(def.desc)}</div>
${reqNote}
</div>
<label class="perm-toggle" title="${blocked ? 'Сначала включите зависимость' : (enabled ? 'Выключить' : 'Включить')}">
<input type="checkbox" ${enabled ? 'checked' : ''} ${blocked ? 'disabled' : ''}
onchange="togglePermission('${esc(role)}','${esc(def.key)}',this.checked,this)">
<span class="perm-track"></span>
<span class="perm-thumb"></span>
</label>
</div>`;
}
function renderPermissions() {
if (!_permData) return;
const { permissions, definitions } = _permData;
['teacher', 'student'].forEach(role => {
const container = document.getElementById('perm-' + role);
const defs = definitions.filter(d => d.role === role);
const en = {}, labelOf = {};
defs.forEach(d => { en[d.key] = permissions[role]?.[d.key] ?? d.default; labelOf[d.key] = d.label; });
// группировка по def.group (порядок групп — по первому появлению)
const order = [], byGroup = {};
defs.forEach(d => { const g = d.group || 'Прочее'; if (!byGroup[g]) { byGroup[g] = []; order.push(g); } byGroup[g].push(d); });
const grpBtn = (g, on) => `<button onclick="togglePermGroup('${esc(role)}','${esc(g)}',${on})" style="padding:3px 9px;border:1px solid var(--border);border-radius:7px;background:transparent;color:var(--text-3);cursor:pointer;font-family:inherit;font-size:11.5px">${on ? 'включить все' : 'выключить все'}</button>`;
container.style.display = 'block'; // секции групп блоками (контейнер сам — .perm-grid)
container.innerHTML = order.map(g => `
<div class="perm-group" data-group="${esc(g)}" style="margin-bottom:14px">
<div style="display:flex;align-items:center;justify-content:space-between;gap:10px;margin:4px 0 8px">
<div style="font-size:11.5px;font-weight:700;color:var(--text-3);text-transform:uppercase;letter-spacing:.05em">${esc(g)}</div>
<div style="display:flex;gap:6px">${grpBtn(g, true)}${grpBtn(g, false)}</div>
</div>
<div class="perm-grid">${byGroup[g].map(def => permCard(role, def, en, labelOf)).join('')}</div>
</div>`).join('');
});
}
async function togglePermGroup(role, group, enable) {
const defs = (_permData.definitions || []).filter(d => d.role === role && (d.group || 'Прочее') === group);
if (!defs.length) return;
if (!enable && defs.some(d => d.requireConfirmOff)) {
const roleLabel = role === 'teacher' ? 'Учитель' : 'Ученик';
const ok = await LS.confirm(
`Выключить все права группы «${group}» для роли «${roleLabel}»? Затронет всех пользователей роли.`,
{ title: 'Подтвердите выключение группы', confirmText: 'Выключить все' }
);
if (!ok) return;
}
try {
await Promise.all(defs.map(d => LS.setPermission(role, d.key, enable)));
if (!_permData.permissions[role]) _permData.permissions[role] = {};
defs.forEach(d => { _permData.permissions[role][d.key] = enable; });
renderPermissions();
LS.toast(enable ? `Группа «${group}» включена` : `Группа «${group}» выключена`, 'success');
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); load(); }
}
async function togglePermission(role, key, enabled, checkbox) {
if (!enabled) {
const def = (_permData.definitions || []).find(d => d.role === role && d.key === key);
if (def && def.requireConfirmOff) {
const roleLabel = role === 'teacher' ? 'Учитель' : 'Ученик';
const ok = await LS.confirm(
`Выключение «${def.label}» затронет всех пользователей роли «${roleLabel}». Они потеряют доступ. Продолжить?`,
{ title: 'Подтвердите выключение права', confirmText: 'Выключить' }
);
if (!ok) { checkbox.checked = true; return; }
}
}
checkbox.disabled = true;
try {
await LS.setPermission(role, key, enabled);
if (!_permData.permissions[role]) _permData.permissions[role] = {};
_permData.permissions[role][key] = enabled;
const safeKey = key.replace('.', '_');
const card = document.getElementById(`perm-card-${role}-${safeKey}`);
if (card) card.classList.toggle('enabled', enabled);
// Re-render to refresh the modified-dot indicator across all cards.
renderPermissions();
LS.toast(enabled ? 'Право включено' : 'Право отключено', 'success');
} catch(e) {
checkbox.checked = !enabled;
LS.toast('Ошибка: ' + e.message, 'error');
} finally {
checkbox.disabled = false;
}
}
function filterPermissions(query) {
const q = (query || '').trim().toLowerCase();
['teacher', 'student'].forEach(role => {
const block = document.querySelector(`#perm-${role}`)?.closest('.perm-role-block');
const cards = document.querySelectorAll(`#perm-${role} .perm-card`);
let visibleCount = 0;
cards.forEach(card => {
const label = (card.querySelector('.perm-label')?.textContent || '').toLowerCase();
const desc = (card.querySelector('.perm-desc')?.textContent || '').toLowerCase();
const show = !q || label.includes(q) || desc.includes(q);
card.style.display = show ? '' : 'none';
if (show) visibleCount++;
});
if (block) block.style.display = visibleCount === 0 ? 'none' : '';
});
}
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>`;
}
}
/* ════════ Конструктор ролей (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;
window.loadPermLog = loadPermLog;
window.selBulkClass = selBulkClass;
window.bulkPerm = bulkPerm;
window.applyPreset = applyPreset;
window.AdminSections = window.AdminSections || {};
window.AdminSections.permissions = {
init: async () => { if (inited) return; inited = true; await load(); },
reload: load,
};
})();