6b148127b6
Клиент: 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>
344 lines
21 KiB
JavaScript
344 lines
21 KiB
JavaScript
'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,
|
||
};
|
||
})();
|