a250d15f9a
Миграция 053: user_permissions.expires_at (NULL = бессрочно). Резолвер isEnabled
+ /me + /users/:id игнорируют просроченные оверрайды (наследуют роль); seedDefaults
чистит просроченные строки. setUserPermission принимает days → выдаёт право на
срок (datetime('now','+N days')). API отдаёт expiresAt. Клиент: setUserPermission(...,days).
В модалке прав пользователя — бейдж «до ДАТА» + кнопка «врем.» (выдать на N дней).
Тест: срок хранится/отдаётся, просроченное игнорируется и вычищается. Backend pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
486 lines
26 KiB
JavaScript
486 lines
26 KiB
JavaScript
'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-related modal state.
|
||
* After Phase 6 the .user-panel overlay is gone — instead the modals
|
||
* (edit, perms) operate on window.activeUid which is set by user-detail.js
|
||
* when the deep page opens, or transiently by row actions on the list. */
|
||
let _editUid = null;
|
||
let _upPermsData = null;
|
||
// Helper: read the currently-active user id (set by user-detail.js or quick actions).
|
||
const getActiveUid = () => window.activeUid || null;
|
||
// Helper: after a mutation that may affect the active user, refresh the deep page
|
||
// (if it's currently showing the same user) AND the list.
|
||
function reloadDetailAndList() {
|
||
const sec = (window.AdminSections || {})['user-detail'];
|
||
if (sec && typeof sec.reload === 'function') sec.reload();
|
||
load();
|
||
}
|
||
|
||
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="AdminRouter.navigate('#users/${u.id}')">
|
||
<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 ? 'Разблокировать' : 'Заблокировать';
|
||
// Pass uid/name via data-* attributes (esc() escapes & < > " for the attribute
|
||
// context; dataset reads back the raw string — no JS-string injection surface).
|
||
return `<div class="row-actions" onclick="event.stopPropagation()">
|
||
<button type="button" class="row-action-btn" title="${banLabel}"
|
||
data-uid="${u.id}" data-banned="${u.is_banned?1:0}"
|
||
onclick="event.stopPropagation();quickToggleBan(this)">${banIcon}</button>
|
||
<button type="button" class="row-action-btn" title="Начислить монеты"
|
||
data-uid="${u.id}" data-name="${esc(u.name)}"
|
||
onclick="event.stopPropagation();quickAwardCoins(this)">${ICONS.coins}</button>
|
||
<button type="button" class="row-action-btn" title="История сессий"
|
||
data-uid="${u.id}"
|
||
onclick="event.stopPropagation();quickOpenUserSessions(this)">${ICONS.history}</button>
|
||
<button type="button" class="row-action-btn danger" title="Удалить пользователя"
|
||
data-uid="${u.id}" data-name="${esc(u.name)}"
|
||
onclick="event.stopPropagation();quickDeleteUser(this)">${ICONS.trash}</button>
|
||
</div>`;
|
||
}
|
||
|
||
async function quickToggleBan(btn) {
|
||
const uid = +btn.dataset.uid;
|
||
const isBanned = +btn.dataset.banned;
|
||
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 (getActiveUid() === uid) {
|
||
const sec = (window.AdminSections || {})['user-detail'];
|
||
if (sec && typeof sec.reload === 'function') sec.reload();
|
||
}
|
||
} catch (e) {
|
||
LS.toast('Ошибка: ' + e.message, 'error');
|
||
btn.disabled = false;
|
||
}
|
||
}
|
||
|
||
function quickAwardCoins(btn) {
|
||
const uid = +btn.dataset.uid;
|
||
const name = btn.dataset.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(btn) {
|
||
const uid = +btn.dataset.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(btn) {
|
||
const uid = +btn.dataset.uid;
|
||
const name = btn.dataset.name || '';
|
||
if (!await LS.confirm(
|
||
`Удалить пользователя «${name}» навсегда?\nВсе его данные, тесты и прогресс будут удалены. Это действие нельзя отменить.`,
|
||
{ title: 'Удалить пользователя', confirmText: 'Удалить навсегда' }
|
||
)) return;
|
||
btn.disabled = true;
|
||
try {
|
||
await LS.adminDeleteUser(uid);
|
||
LS.toast('Пользователь удалён', 'success');
|
||
// If the deleted user is currently open as a deep page, go back to the list.
|
||
if (getActiveUid() === uid && window.AdminRouter) {
|
||
AdminRouter.navigate('#users');
|
||
}
|
||
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 actions (called from the user-detail deep page header buttons) ───
|
||
* Pre-Phase 6 these talked to the .user-panel overlay; now they:
|
||
* - read the active uid via getActiveUid() (set by user-detail.init)
|
||
* - read display name from the #up-name span rendered inside the deep page
|
||
* - reload via AdminSections['user-detail'].reload() */
|
||
function _activeName() {
|
||
const el = document.getElementById('up-name');
|
||
return el ? el.textContent.trim() : '';
|
||
}
|
||
|
||
async function clearUserHistory() {
|
||
const uid = getActiveUid();
|
||
if (!uid) return;
|
||
const name = _activeName();
|
||
if (!await LS.confirm(`Удалить всю историю тестов пользователя «${name}»?\nЭто действие нельзя отменить.`, { title: 'Очистить историю', confirmText: 'Удалить историю' })) return;
|
||
try {
|
||
await LS.adminClearUserSessions(uid);
|
||
reloadDetailAndList();
|
||
} catch (e) { LS.toast('Ошибка очистки истории: ' + e.message, 'error'); }
|
||
}
|
||
|
||
async function toggleBanUser() {
|
||
const uid = getActiveUid();
|
||
if (!uid) return;
|
||
const banLbl = document.getElementById('up-ban-label');
|
||
const isBanning = banLbl ? banLbl.textContent.trim() === 'Заблокировать' : true;
|
||
const name = _activeName();
|
||
const msg = isBanning
|
||
? `Заблокировать пользователя «${name}»?\nОн не сможет войти в систему.`
|
||
: `Разблокировать пользователя «${name}»?`;
|
||
if (!await LS.confirm(msg, { title: isBanning ? 'Блокировка' : 'Разблокировка', confirmText: isBanning ? 'Заблокировать' : 'Разблокировать' })) return;
|
||
try {
|
||
await LS.adminBanUser(uid, isBanning);
|
||
LS.toast(isBanning ? 'Пользователь заблокирован' : 'Пользователь разблокирован', isBanning ? 'warning' : 'success');
|
||
reloadDetailAndList();
|
||
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
|
||
}
|
||
|
||
async function confirmDeleteUser() {
|
||
const uid = getActiveUid();
|
||
if (!uid) return;
|
||
const name = _activeName();
|
||
if (!await LS.confirm(`Удалить пользователя «${name}» навсегда?\nВсе его данные, тесты и прогресс будут удалены. Это действие нельзя отменить.`, { title: 'Удалить пользователя', confirmText: 'Удалить навсегда' })) return;
|
||
try {
|
||
await LS.adminDeleteUser(uid);
|
||
LS.toast('Пользователь удалён', 'success');
|
||
if (window.AdminRouter) AdminRouter.navigate('#users');
|
||
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 = getActiveUid();
|
||
if (!_editUid) return;
|
||
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();
|
||
reloadDetailAndList();
|
||
} 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() {
|
||
const uid = getActiveUid();
|
||
if (!uid) return;
|
||
const name = _activeName();
|
||
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(uid);
|
||
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:11px;padding:2px 5px;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 expBadge = (hasOverride && p.expiresAt)
|
||
? `<span title="Временный оверрайд истекает (UTC)" style="font-size:11px;padding:2px 6px;border-radius:var(--r-pill);background:rgba(245,158,11,0.14);color:#b45309;font-weight:700">до ${esc(p.expiresAt.slice(0, 10))}</span>`
|
||
: '';
|
||
const tempBtn = `<button style="background:none;border:none;cursor:pointer;color:var(--text-3);padding:3px 6px;border-radius:6px;font-size:11px;font-weight:600"
|
||
onmouseover="this.style.color='var(--violet)'" onmouseout="this.style.color='var(--text-3)'"
|
||
onclick="doSetUserPermTemp('${esc(p.key)}')" title="Выдать право на срок">врем.</button>`;
|
||
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;flex-wrap:wrap">
|
||
<span class="perm-label">${esc(p.label)}</span>
|
||
${badge}
|
||
${expBadge}
|
||
${tempBtn}
|
||
${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) {
|
||
const uid = getActiveUid();
|
||
if (!uid) return;
|
||
checkbox.disabled = true;
|
||
try {
|
||
await LS.setUserPermission(uid, key, enabled);
|
||
_upPermsData = await LS.getUserPermissions(uid);
|
||
renderUserPerms();
|
||
LS.toast(enabled ? 'Право включено' : 'Право отключено', 'success');
|
||
} catch(e) {
|
||
checkbox.checked = !enabled;
|
||
LS.toast('Ошибка: ' + e.message, 'error');
|
||
} finally {
|
||
checkbox.disabled = false;
|
||
}
|
||
}
|
||
|
||
async function doSetUserPermTemp(key) {
|
||
const uid = getActiveUid();
|
||
if (!uid) return;
|
||
const raw = window.prompt('Выдать право временно. На сколько дней?', '7');
|
||
if (raw === null) return;
|
||
const days = parseInt(raw, 10);
|
||
if (!Number.isInteger(days) || days <= 0) { LS.toast('Введите число дней > 0', 'error'); return; }
|
||
try {
|
||
await LS.setUserPermission(uid, key, true, days);
|
||
_upPermsData = await LS.getUserPermissions(uid);
|
||
renderUserPerms();
|
||
LS.toast(`Право выдано на ${days} дн.`, 'success');
|
||
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
|
||
}
|
||
|
||
async function doResetOneUserPerm(key) {
|
||
const uid = getActiveUid();
|
||
if (!uid) return;
|
||
try {
|
||
await LS.resetUserPermissions(uid, key);
|
||
_upPermsData = await LS.getUserPermissions(uid);
|
||
renderUserPerms();
|
||
LS.toast('Сброшено к значению роли', 'success');
|
||
} catch(e) { LS.toast('Ошибка: ' + e.message, 'error'); }
|
||
}
|
||
|
||
async function doResetAllUserPerms() {
|
||
const uid = getActiveUid();
|
||
if (!uid) return;
|
||
const name = _activeName();
|
||
if (!await LS.confirm(`Сбросить все индивидуальные права «${name}»?\nБудут применены права роли.`, { title: 'Сбросить права', confirmText: 'Сбросить' })) return;
|
||
try {
|
||
await LS.resetUserPermissions(uid);
|
||
_upPermsData = await LS.getUserPermissions(uid);
|
||
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.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.doSetUserPermTemp = doSetUserPermTemp;
|
||
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,
|
||
};
|
||
})();
|