feat(permissions): B5 — группы прав (секции в UI + вкл/выкл всей группы)
registry: карта GROUP (Вопросы / Класс и ученики / Библиотека / Курсы и шаблоны / Геймификация / Контент / Тесты и активность / Профиль), проброшена в byRole.group. permissions.js: вкладка «Доступ · роли» рендерит права секциями по группам, у каждой — «включить все / выключить все» (с подтверждением, если в группе есть requireConfirmOff). Карточка вынесена в permCard(). Тест: definitions содержат group. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -162,6 +162,24 @@ const PERMISSIONS = {
|
||||
},
|
||||
};
|
||||
|
||||
/* Группы для секций в админ-UI (один источник; byRole проставляет group). */
|
||||
const GROUP = {
|
||||
// teacher
|
||||
'questions.manage': 'Вопросы', 'questions.delete': 'Вопросы',
|
||||
'students.invite': 'Класс и ученики', 'sessions.reset': 'Класс и ученики',
|
||||
'results.export': 'Класс и ученики', 'classes.manage': 'Класс и ученики',
|
||||
'schedule.manage': 'Класс и ученики', 'announcements.send': 'Класс и ученики',
|
||||
'library.upload': 'Библиотека', 'library.folders': 'Библиотека',
|
||||
'templates.manage': 'Курсы и шаблоны', 'templates.public': 'Курсы и шаблоны',
|
||||
'courses.manage': 'Курсы и шаблоны', 'courses.interactive': 'Курсы и шаблоны',
|
||||
'shop.manage': 'Геймификация', 'gamification.manage': 'Геймификация',
|
||||
// student
|
||||
'tests.free': 'Тесты и активность', 'board.post': 'Тесты и активность',
|
||||
'profile.edit': 'Профиль',
|
||||
'shop.purchase': 'Геймификация', 'gamification.challenges': 'Геймификация',
|
||||
'theory.access': 'Контент', 'simulations.access': 'Контент', 'simulations.quiz': 'Контент',
|
||||
};
|
||||
|
||||
/**
|
||||
* Check whether a given permission key exists in the registry.
|
||||
* Used by perm() helper in auth.js to fail early on typos.
|
||||
@@ -182,7 +200,7 @@ function listKeys() {
|
||||
function byRole(role) {
|
||||
return Object.entries(PERMISSIONS)
|
||||
.filter(([, v]) => v.role === role)
|
||||
.map(([key, v]) => ({ key, role: v.role, default: v.default, label: v.label, desc: v.desc, requireConfirmOff: !!v.requireConfirmOff, requires: v.requires || [] }));
|
||||
.map(([key, v]) => ({ key, role: v.role, default: v.default, label: v.label, desc: v.desc, requireConfirmOff: !!v.requireConfirmOff, requires: v.requires || [], group: GROUP[key] || 'Прочее' }));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -197,6 +197,15 @@ describe('Permissions', () => {
|
||||
{ role: 'student', permission: 'simulations.access', enabled: true }, adminToken);
|
||||
});
|
||||
|
||||
// ── B5: группы прав в определениях ─────────────────────────────────────────
|
||||
it('B5: GET /api/permissions — у каждого определения есть group', async () => {
|
||||
const res = await inject('GET', '/api/permissions', null, adminToken);
|
||||
assert.equal(res.status, 200);
|
||||
assert.ok(Array.isArray(res.body.definitions) && res.body.definitions.length > 0);
|
||||
assert.ok(res.body.definitions.every(d => typeof d.group === 'string' && d.group.length > 0),
|
||||
'у каждого определения есть непустой group');
|
||||
});
|
||||
|
||||
// ── 11. A3: история изменений прав ─────────────────────────────────────────
|
||||
it('GET /api/permissions/log — история (admin видит записи; не-админу 403)', async () => {
|
||||
await inject('POST', '/api/permissions',
|
||||
|
||||
@@ -15,6 +15,35 @@
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -23,37 +52,42 @@
|
||||
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; });
|
||||
container.innerHTML = defs.map(def => {
|
||||
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>`;
|
||||
}).join('');
|
||||
// группировка по 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);
|
||||
@@ -117,6 +151,7 @@
|
||||
}
|
||||
|
||||
window.togglePermission = togglePermission;
|
||||
window.togglePermGroup = togglePermGroup;
|
||||
window.filterPermissions = filterPermissions;
|
||||
window.loadPermLog = loadPermLog;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user