Files
Learn_System/frontend/js/admin/sections/users.js
T
Maxim Dolgolyov bd3020067b feat(admin): Phase 6 sub-commit 1 — add deep-page sections (overlay still works)
Add user-detail.js (~370L) and session-detail.js (~180L) section
modules that render full pages for #users/:id and #sessions/:id, plus
admin.js dispatch and HTML tab-panes. The legacy .user-panel overlay
is intentionally still in place — sub-commit 2 will remove it once the
deep pages are verified.

* admin.js: DEEP_ROUTES map + activateDeepPane(); activate(route, params)
  signature; initial dispatch respects hash params (so F5 on #users/123
  goes straight to the deep page).
* admin.html: new tab-panes #tab-user-detail / #tab-session-detail and
  two script tags. Old #user-panel overlay untouched.
* user-detail.js: header (avatar/role/email/meta) + sub-tabs
  (Обзор/Сессии/Классы/Audit) with URL-synced sub-tab routing
  (#users/N/sessions etc). Overview: 4 stat cards + per-subject SVG
  bar chart. Sessions: clickable rows that navigate to #sessions/N.
  Classes: placeholder empty-state (no per-user classes endpoint).
  Audit: client-side filter of /admin/audit-log by uid match. Header
  action buttons (Изменить/Права/История/Бан/Удалить) call existing
  overlay handlers; window.activeUid is set before opening any modal.
* session-detail.js: full header (user/subject/score/stats) + per-
  question correctness layout reusing the drawer renderer. Delete
  button uses LS.adminDeleteSession then navigates to #sessions.
  Clicking the user name opens the user deep page.
* users.js: quickOpenUserSessions now navigates to
  #users/<uid>/sessions instead of the bare #sessions list.

Verified node --check on all new/modified JS. baseline npm test still
shows pre-existing 3 auth failures unrelated to this change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 00:01:22 +03:00

482 lines
26 KiB
JavaScript
Raw 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 → users section: users table + pagination + user-panel overlay + user-perms modal */
(function () {
'use strict';
let inited = false;
let _usersPage = 1;
const _USERS_PER_PAGE = 50;
/* ── one-time CSS injection for hover row-actions (shared with sessions) ── */
function ensureRowActionsStyles() {
if (document.getElementById('row-actions-style')) return;
const s = document.createElement('style');
s.id = 'row-actions-style';
s.textContent = `
.row-actions { opacity: 0; transition: opacity .15s ease; display: inline-flex; gap: 4px; vertical-align: middle; }
tr:hover .row-actions, .sess-tl-item:hover .row-actions { opacity: 1; }
tr.selected .row-actions, .sess-tl-item.open .row-actions { opacity: 1; }
.row-action-btn { width: 28px; height: 28px; border-radius: 6px; border: 1px solid var(--border); background: transparent; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; color: var(--text-3); transition: background .12s ease, border-color .12s ease, color .12s ease; padding: 0; }
.row-action-btn:hover { background: rgba(155,93,229,.08); border-color: var(--violet); color: var(--violet); }
.row-action-btn:focus-visible { outline: 2px solid var(--violet); outline-offset: 1px; }
.row-action-btn.danger:hover { background: rgba(239,68,68,.08); border-color: var(--red, #EF4444); color: var(--red, #EF4444); }
.row-action-btn svg { width: 14px; height: 14px; pointer-events: none; }
.row-action-btn:disabled { opacity: .5; cursor: wait; }
.row-actions-cell { text-align: right; white-space: nowrap; padding-right: 12px; }
@media (max-width: 768px) {
.row-actions { display: none; }
}
`;
document.head.appendChild(s);
}
/* SVG icons (Lucide-style, 24x24 viewBox) */
const ICONS = {
ban: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="m4.9 4.9 14.2 14.2"/></svg>',
unlock: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 9.9-1"/></svg>',
coins: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="6"/><path d="M18.09 10.37A6 6 0 1 1 10.34 18"/><path d="M7 6h1v4"/><path d="m16.71 13.88.7.71-2.82 2.82"/></svg>',
history: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 9-9 9.74 9.74 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/></svg>',
trash: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>',
eye: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/></svg>',
};
// user-panel + edit modal + perms modal state
let activeTr = null;
let activeUid = null;
let activeUserRole = null;
let _editUid = null;
let _upPermsData = null;
async function load(page) {
const { pctClass, fmtDate, renderPgnControls } = AdminCtx;
const isAdmin = AdminCtx.isAdmin;
const user = AdminCtx.user;
if (page) _usersPage = page;
ensureRowActionsStyles();
try {
const r = await LS.adminGetUsers({ page: _usersPage, limit: _USERS_PER_PAGE });
const users = r.users || [];
const tbody = document.getElementById('users-body');
if (!users.length) {
tbody.innerHTML = '<tr><td colspan="7"><div class="empty">Пользователей нет</div></td></tr>';
document.getElementById('users-pagination').style.display = 'none';
return;
}
tbody.innerHTML = users.map(u => {
const pc = pctClass(u.avg_pct);
const initials = (u.name||'?').split(' ').slice(0,2).map(w=>w[0]?.toUpperCase()||'').join('')||'?';
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>
</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="openUserPanel(event,${u.id},'${u.role}')">
<td>
<div style="display:flex;align-items:center;gap:12px">
<div style="width:36px;height:36px;border-radius:10px;background:${avatarBg};display:flex;align-items:center;justify-content:center;font-family:'Unbounded',sans-serif;font-size:0.62rem;font-weight:800;color:#fff;flex-shrink:0;${u.is_banned?'filter:grayscale(1);opacity:.5':''}">${initials}</div>
<div>
<div style="font-weight:700;font-size:0.88rem;color:var(--text)">${esc(u.name)}${u.is_banned ? ' <span style="font-size:0.7rem;background:rgba(239,68,68,.12);color:#EF4444;border-radius:4px;padding:1px 5px;font-weight:600;vertical-align:middle">заблокирован</span>' : ''}</div>
<div style="color:var(--text-3);font-size:0.76rem">${esc(u.email)}</div>
</div>
</div>
</td>
<td onclick="event.stopPropagation()">${roleCell}</td>
<td style="font-weight:700">${u.tests_count}</td>
<td>
<span class="pct-cell ${pc}">${u.avg_pct !== null ? u.avg_pct+'%' : '—'}</span>
${u.avg_pct !== null ? `<div class="perf-bar"><div class="perf-fill ${pc}" style="width:${u.avg_pct}%"></div></div>` : ''}
</td>
<td style="color:var(--text-3);font-size:0.8rem">${fmtDate(u.created_at)}</td>
<td style="color:var(--text-3);font-size:0.8rem">${u.last_login ? new Date(u.last_login).toLocaleDateString('ru',{day:'numeric',month:'short'}) : '—'}</td>
<td class="row-actions-cell">${renderUserRowActions(u, isAdmin && u.id !== user.id)}</td>
</tr>`;
}).join('');
renderPgnControls('users-pagination', _usersPage, r.total || users.length, _USERS_PER_PAGE, 'gotoUsersPage');
} catch (e) {
document.getElementById('users-body').innerHTML = `<tr><td colspan="7"></td></tr>`;
LS.state.error(document.getElementById('users-body').querySelector('td'), e, load);
}
}
/* ─── Per-row hover actions (Phase 5) ─── */
function renderUserRowActions(u, canAct) {
if (!canAct) {
// Hide actions for non-admins or current user; keep arrow indicator as before
return '<span style="color:var(--text-3);font-size:0.85rem;opacity:0.4"></span>';
}
const banIcon = u.is_banned ? ICONS.unlock : ICONS.ban;
const banLabel = u.is_banned ? 'Разблокировать' : 'Заблокировать';
return `<div class="row-actions" onclick="event.stopPropagation()">
<button type="button" class="row-action-btn" title="${banLabel}"
onclick="event.stopPropagation();quickToggleBan(${u.id},${u.is_banned?1:0},this)">${banIcon}</button>
<button type="button" class="row-action-btn" title="Начислить монеты"
onclick="event.stopPropagation();quickAwardCoins(${u.id},'${esc(u.name).replace(/'/g, "\\'")}')">${ICONS.coins}</button>
<button type="button" class="row-action-btn" title="История сессий"
onclick="event.stopPropagation();quickOpenUserSessions(${u.id})">${ICONS.history}</button>
<button type="button" class="row-action-btn danger" title="Удалить пользователя"
onclick="event.stopPropagation();quickDeleteUser(${u.id},'${esc(u.name).replace(/'/g, "\\'")}',this)">${ICONS.trash}</button>
</div>`;
}
async function quickToggleBan(uid, isBanned, btn) {
const action = isBanned ? 'Разблокировать' : 'Заблокировать';
const msg = isBanned
? 'Разблокировать пользователя? Он снова сможет войти в систему.'
: 'Заблокировать пользователя? Он не сможет войти в систему.';
if (!await LS.confirm(msg, { title: action, confirmText: action })) return;
btn.disabled = true;
try {
await LS.adminBanUser(uid, !isBanned);
LS.toast(isBanned ? 'Пользователь разблокирован' : 'Пользователь заблокирован', isBanned ? 'success' : 'warning');
await load();
if (activeUid === uid) await reloadUserPanel(uid);
} catch (e) {
LS.toast('Ошибка: ' + e.message, 'error');
btn.disabled = false;
}
}
function quickAwardCoins(uid, name) {
const body = document.createElement('div');
body.innerHTML = `
<p style="margin:0 0 14px;font-size:0.88rem;color:var(--text-2)">Начислить монеты пользователю <strong>${esc(name)}</strong>:</p>
<div style="display:flex;flex-direction:column;gap:10px">
<label style="font-size:0.78rem;font-weight:600;color:var(--text-3)">Количество монет
<input id="qa-coins-amt" type="number" min="1" max="100000" value="100"
style="display:block;margin-top:4px;width:100%;padding:9px 12px;border:1.5px solid rgba(15,23,42,.15);border-radius:10px;font-family:inherit;font-size:0.92rem">
</label>
<label style="font-size:0.78rem;font-weight:600;color:var(--text-3)">Причина (необязательно)
<input id="qa-coins-reason" type="text" maxlength="200" placeholder="напр. награда за активность"
style="display:block;margin-top:4px;width:100%;padding:9px 12px;border:1.5px solid rgba(15,23,42,.15);border-radius:10px;font-family:inherit;font-size:0.88rem">
</label>
</div>`;
const m = LS.modal({
title: 'Начислить монеты',
content: body,
size: 'sm',
actions: [
{ label: 'Отмена', onClick: ({ close }) => close() },
{ label: 'Начислить', primary: true, onClick: async ({ close, setError }) => {
const amt = parseInt(body.querySelector('#qa-coins-amt').value, 10);
const reason = body.querySelector('#qa-coins-reason').value.trim();
if (!Number.isFinite(amt) || amt <= 0) { setError('Введите положительное количество монет'); return; }
try {
const r = await LS.adminShopAwardCoins({ userId: uid, amount: amt, reason });
LS.toast(`Начислено ${amt} монет. Баланс: ${r.coins ?? '?'}`, 'success');
close();
} catch (e) { setError('Ошибка: ' + e.message); }
} },
],
});
setTimeout(() => body.querySelector('#qa-coins-amt')?.focus(), 80);
}
function quickOpenUserSessions(uid) {
// Phase 6: open the user's deep page with the Sessions sub-tab active.
if (window.AdminRouter) AdminRouter.navigate('#users/' + uid + '/sessions');
else if (typeof window.switchTab === 'function') {
const btn = document.querySelector('.admin-nav-item[onclick*="sessions"]');
if (btn) window.switchTab(btn);
}
}
async function quickDeleteUser(uid, name, btn) {
if (!await LS.confirm(
`Удалить пользователя «${name}» навсегда?\nВсе его данные, тесты и прогресс будут удалены. Это действие нельзя отменить.`,
{ title: 'Удалить пользователя', confirmText: 'Удалить навсегда' }
)) return;
btn.disabled = true;
try {
await LS.adminDeleteUser(uid);
LS.toast('Пользователь удалён', 'success');
if (activeUid === uid) closeUserPanel();
await load();
} catch (e) {
LS.toast('Ошибка: ' + e.message, 'error');
btn.disabled = false;
}
}
function gotoUsersPage(n) {
_usersPage = n;
load();
document.getElementById('tab-users')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
async function changeRole(select) {
select.disabled = true;
try { await LS.adminUpdateRole(select.dataset.uid, select.value); LS.toast('Роль изменена', 'success', 2000); }
catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
finally { select.disabled = false; }
}
/* ─── User panel ─── */
async function openUserPanel(e, uid, role) {
const isAdmin = AdminCtx.isAdmin;
if (activeTr) activeTr.classList.remove('selected');
activeTr = e.currentTarget; activeTr.classList.add('selected');
activeUid = uid;
activeUserRole = role;
const panel = document.getElementById('user-panel');
panel.classList.add('visible');
panel.scrollIntoView({ behavior:'smooth', block:'nearest' });
document.getElementById('up-sessions').innerHTML = LS.skeleton(3, 'row');
document.getElementById('up-name').textContent = '…';
document.getElementById('up-email').textContent = '';
if (isAdmin) {
document.getElementById('up-edit-btn').style.display = '';
document.getElementById('up-clear-btn').style.display = '';
document.getElementById('up-perms-btn').style.display = role === 'teacher' ? '' : 'none';
document.getElementById('up-ban-btn').style.display = '';
document.getElementById('up-delete-btn').style.display = '';
}
await reloadUserPanel(uid);
}
async function reloadUserPanel(uid) {
const { MODES, pctClass, fmtDate } = AdminCtx;
const isAdmin = AdminCtx.isAdmin;
try {
const { user: u, sessions } = await LS.adminGetUserSessions(uid);
activeUserRole = u.role;
document.getElementById('up-name').innerHTML = LS.esc(u.name) + (u.is_banned ? ' <svg class="ic" viewBox="0 0 24 24"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>' : '');
document.getElementById('up-email').textContent = u.email;
if (isAdmin) {
document.getElementById('up-perms-btn').style.display = u.role === 'teacher' ? '' : 'none';
const banBtn = document.getElementById('up-ban-btn');
const banLbl = document.getElementById('up-ban-label');
if (u.is_banned) {
banBtn.style.background = 'rgba(34,197,94,.12)';
banBtn.style.color = '#22C55E';
banBtn.style.borderColor = 'rgba(34,197,94,.25)';
banLbl.textContent = 'Разблокировать';
} else {
banBtn.style.background = '';
banBtn.style.color = '';
banBtn.style.borderColor = '';
banLbl.textContent = 'Заблокировать';
}
}
const el = document.getElementById('up-sessions');
if (!sessions.length) { el.innerHTML = '<div class="empty">Тестов нет</div>'; return; }
el.innerHTML = '<div class="sess-list">' + sessions.map(s => {
const pct = s.score !== null ? Math.round((s.score/s.total)*100) : null;
return `<div class="sess-item">
<div class="sess-pct ${pctClass(pct)}">${pct !== null ? pct+'%' : '—'}</div>
<div class="sess-info"><div class="sess-subj">${s.subject_name||'Тест'}</div><div class="sess-meta">${fmtDate(s.started_at)} · ${MODES[s.mode]||s.mode}</div></div>
<div class="sess-score">${s.score??'—'} / ${s.total}</div>
</div>`;
}).join('') + '</div>';
} catch (e) { LS.state.error(document.getElementById('up-sessions'), e); }
}
function closeUserPanel() {
document.getElementById('user-panel').classList.remove('visible');
if (activeTr) { activeTr.classList.remove('selected'); activeTr = null; }
activeUid = null;
}
async function clearUserHistory() {
const name = document.getElementById('up-name').textContent;
if (!await LS.confirm(`Удалить всю историю тестов пользователя «${name}»?\nЭто действие нельзя отменить.`, { title: 'Очистить историю', confirmText: 'Удалить историю' })) return;
try {
await LS.adminClearUserSessions(activeUid);
await reloadUserPanel(activeUid);
load();
} catch (e) { LS.toast('Ошибка очистки истории: ' + e.message, 'error'); }
}
async function toggleBanUser() {
const banLbl = document.getElementById('up-ban-label');
const isBanning = banLbl.textContent === 'Заблокировать';
const name = document.getElementById('up-name').innerHTML.replace(' <svg class="ic" viewBox="0 0 24 24"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>','');
const msg = isBanning
? `Заблокировать пользователя «${name}»?\nОн не сможет войти в систему.`
: `Разблокировать пользователя «${name}»?`;
if (!await LS.confirm(msg, { title: isBanning ? 'Блокировка' : 'Разблокировка', confirmText: isBanning ? 'Заблокировать' : 'Разблокировать' })) return;
try {
await LS.adminBanUser(activeUid, isBanning);
LS.toast(isBanning ? 'Пользователь заблокирован' : 'Пользователь разблокирован', isBanning ? 'warning' : 'success');
await reloadUserPanel(activeUid);
load();
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
}
async function confirmDeleteUser() {
const name = document.getElementById('up-name').innerHTML.replace(' <svg class="ic" viewBox="0 0 24 24"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>','');
if (!await LS.confirm(`Удалить пользователя «${name}» навсегда?\nВсе его данные, тесты и прогресс будут удалены. Это действие нельзя отменить.`, { title: 'Удалить пользователя', confirmText: 'Удалить навсегда' })) return;
try {
await LS.adminDeleteUser(activeUid);
LS.toast('Пользователь удалён', 'success');
closeUserPanel();
load();
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
}
/* ─── Edit user modal ─── */
function closeEditUserModal() {
document.getElementById('eu-modal').classList.remove('open');
_editUid = null;
}
function openEditUserModal() {
_editUid = activeUid;
document.getElementById('eu-name').value = document.getElementById('up-name').textContent;
document.getElementById('eu-email').value = document.getElementById('up-email').textContent;
document.getElementById('eu-password').value = '';
document.getElementById('eu-error').textContent = '';
document.getElementById('eu-modal').classList.add('open');
setTimeout(() => document.getElementById('eu-name').focus(), 80);
}
async function saveEditUser() {
const name = document.getElementById('eu-name').value.trim();
const email = document.getElementById('eu-email').value.trim();
const password = document.getElementById('eu-password').value;
const errEl = document.getElementById('eu-error');
errEl.textContent = '';
if (!name) { errEl.textContent = 'Введите имя'; return; }
if (!email) { errEl.textContent = 'Введите email'; return; }
if (password && password.length < 6) { errEl.textContent = 'Пароль должен быть не менее 6 символов'; return; }
const payload = { name, email };
if (password) payload.password = password;
const btn = document.getElementById('eu-save');
btn.disabled = true; btn.textContent = 'Сохранение…';
try {
await LS.adminUpdateUser(_editUid, payload);
closeEditUserModal();
await reloadUserPanel(activeUid);
load();
} catch (e) {
errEl.textContent = 'Ошибка: ' + e.message;
} finally {
btn.disabled = false; btn.textContent = 'Сохранить';
}
}
/* ─── User permissions modal (opened from inside user-panel) ─── */
function closeUserPermsModal() {
document.getElementById('up-modal').classList.remove('open');
_upPermsData = null;
}
async function openUserPermsModal() {
if (!activeUid) return;
const name = document.getElementById('up-name').textContent;
document.getElementById('up-modal-title').textContent = `Права: ${name}`;
document.getElementById('up-modal-list').innerHTML = LS.skeleton(5, 'row');
document.getElementById('up-modal').classList.add('open');
try {
_upPermsData = await LS.getUserPermissions(activeUid);
renderUserPerms();
} catch(e) {
document.getElementById('up-modal-list').innerHTML = `<p style="color:var(--danger);font-size:13px">Ошибка: ${esc(e.message)}</p>`;
}
}
function renderUserPerms() {
if (!_upPermsData) return;
const list = document.getElementById('up-modal-list');
list.innerHTML = _upPermsData.permissions.map(p => {
const hasOverride = p.userVal !== undefined;
const checked = p.effective;
const badge = hasOverride
? `<span style="font-size:10px;padding:2px 7px;border-radius:var(--r-pill);background:rgba(155,93,229,0.12);color:var(--violet);font-weight:700">Инд.</span>`
: `<span style="font-size:10px;padding:2px 7px;border-radius:var(--r-pill);background:rgba(136,152,170,0.12);color:var(--text-3);font-weight:700">По роли</span>`;
const resetBtn = hasOverride
? `<button style="background:none;border:none;cursor:pointer;color:var(--text-3);padding:3px 6px;border-radius:6px;font-size:11px;font-weight:700;transition:color .2s"
onmouseover="this.style.color='var(--danger)'" onmouseout="this.style.color='var(--text-3)'"
onclick="doResetOneUserPerm('${esc(p.key)}')" title="Сбросить к роли">×</button>`
: '';
return `
<div class="perm-card${checked ? ' enabled' : ''}" id="up-perm-card-${p.key.replace('.','_')}">
<div class="perm-info">
<div style="display:flex;align-items:center;gap:7px">
<span class="perm-label">${esc(p.label)}</span>
${badge}
${resetBtn}
</div>
<div class="perm-desc">${esc(p.desc)}</div>
</div>
<label class="perm-toggle">
<input type="checkbox" ${checked ? 'checked' : ''}
onchange="doSetUserPerm('${esc(p.key)}', this.checked, this)">
<span class="perm-track"></span>
<span class="perm-thumb"></span>
</label>
</div>`;
}).join('');
const hasAny = _upPermsData.permissions.some(p => p.userVal !== undefined);
document.getElementById('up-modal-reset-btn').style.opacity = hasAny ? '1' : '0.4';
}
async function doSetUserPerm(key, enabled, checkbox) {
checkbox.disabled = true;
try {
await LS.setUserPermission(activeUid, key, enabled);
_upPermsData = await LS.getUserPermissions(activeUid);
renderUserPerms();
LS.toast(enabled ? 'Право включено' : 'Право отключено', 'success');
} catch(e) {
checkbox.checked = !enabled;
LS.toast('Ошибка: ' + e.message, 'error');
} finally {
checkbox.disabled = false;
}
}
async function doResetOneUserPerm(key) {
try {
await LS.resetUserPermissions(activeUid, key);
_upPermsData = await LS.getUserPermissions(activeUid);
renderUserPerms();
LS.toast('Сброшено к значению роли', 'success');
} catch(e) { LS.toast('Ошибка: ' + e.message, 'error'); }
}
async function doResetAllUserPerms() {
const name = document.getElementById('up-name').textContent;
if (!await LS.confirm(`Сбросить все индивидуальные права «${name}»?\nБудут применены права роли.`, { title: 'Сбросить права', confirmText: 'Сбросить' })) return;
try {
await LS.resetUserPermissions(activeUid);
_upPermsData = await LS.getUserPermissions(activeUid);
renderUserPerms();
LS.toast('Права сброшены к роли', 'success');
} catch(e) { LS.toast('Ошибка: ' + e.message, 'error'); }
}
// Expose handlers used by HTML onclicks
window.loadUsers = load;
window.gotoUsersPage = gotoUsersPage;
window.changeRole = changeRole;
window.openUserPanel = openUserPanel;
window.reloadUserPanel = reloadUserPanel;
window.closeUserPanel = closeUserPanel;
window.clearUserHistory = clearUserHistory;
window.toggleBanUser = toggleBanUser;
window.confirmDeleteUser = confirmDeleteUser;
window.closeEditUserModal = closeEditUserModal;
window.openEditUserModal = openEditUserModal;
window.saveEditUser = saveEditUser;
window.closeUserPermsModal = closeUserPermsModal;
window.openUserPermsModal = openUserPermsModal;
window.doSetUserPerm = doSetUserPerm;
window.doResetOneUserPerm = doResetOneUserPerm;
window.doResetAllUserPerms = doResetAllUserPerms;
// Phase 5 quick actions
window.quickToggleBan = quickToggleBan;
window.quickAwardCoins = quickAwardCoins;
window.quickOpenUserSessions = quickOpenUserSessions;
window.quickDeleteUser = quickDeleteUser;
window.AdminSections = window.AdminSections || {};
window.AdminSections.users = {
init: async () => { if (inited) return; inited = true; await load(); },
reload: load,
};
})();