feat(admin): phase 5 — per-row quick actions for users + sessions
Hover-only action buttons (right-aligned, opacity transition, hidden on mobile). - users.js: 4 actions (ban/unban, award coins, sessions, delete) — replaces `>` glyph cell, falls back to glyph for non-admin / self - sessions.js: 2 actions (view, delete) - DELETE /api/admin/sessions/:id (NEW): transactional (assignment_sessions=NULL, user_answers, session_questions, test_sessions), audit-logged, admin-only - event.stopPropagation defence-in-depth (each button + parent .row-actions) - LS.confirm for destructive ops; LS.modal for award-coins amount/reason - CSS injected once via #row-actions-style id-dedup (same content in both sections) Existing user-panel overlay + session toggle-drawer flows untouched (Phase 6 removes overlay). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -293,6 +293,33 @@ function getSessionDetail(req, res) {
|
||||
res.json(session);
|
||||
}
|
||||
|
||||
/* ── DELETE /api/admin/sessions/:id ──────────────────────────────────── */
|
||||
const _deleteSessionTx = db.transaction((sid) => {
|
||||
// assignment_sessions references test_sessions with ON DELETE SET NULL,
|
||||
// but we explicitly null it so the assignment slot stays usable.
|
||||
db.prepare('UPDATE assignment_sessions SET session_id = NULL WHERE session_id = ?').run(sid);
|
||||
// user_answers / session_questions cascade via ON DELETE CASCADE,
|
||||
// but delete explicitly for visibility and to mirror clearUserSessions().
|
||||
db.prepare('DELETE FROM user_answers WHERE session_id = ?').run(sid);
|
||||
db.prepare('DELETE FROM session_questions WHERE session_id = ?').run(sid);
|
||||
db.prepare('DELETE FROM test_sessions WHERE id = ?').run(sid);
|
||||
});
|
||||
|
||||
function deleteSession(req, res, next) {
|
||||
const sid = Number(req.params.id);
|
||||
if (!Number.isInteger(sid) || sid <= 0)
|
||||
return res.status(400).json({ error: 'Invalid session id' });
|
||||
try {
|
||||
const sess = db.prepare('SELECT id, user_id, mode FROM test_sessions WHERE id = ?').get(sid);
|
||||
if (!sess) return res.status(404).json({ error: 'Session not found' });
|
||||
_deleteSessionTx(sid);
|
||||
audit(req, 'session.delete', `session:${sid}`, `user:${sess.user_id} mode:${sess.mode}`);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── DELETE /api/admin/users/:id/sessions ────────────────────────────── */
|
||||
function clearUserSessions(req, res, next) {
|
||||
const uid = Number(req.params.id);
|
||||
@@ -638,7 +665,7 @@ function broadcast(req, res) {
|
||||
module.exports = {
|
||||
getStats, getOverview, globalSearch,
|
||||
getUsers, updateRole, getUserSessions, getAllSessions, getSessionDetail,
|
||||
clearUserSessions, updateUser, banUser, deleteUser,
|
||||
clearUserSessions, deleteSession, updateUser, banUser, deleteUser,
|
||||
getFeatures, updateFeatures, getFreeStudentFeatures, updateFreeStudentFeatures,
|
||||
getAuditLog, clearAuditLog, getErrorLog, clearErrorLog, getHealth,
|
||||
getTopics, createTopic, updateTopic, deleteTopic,
|
||||
|
||||
@@ -26,6 +26,7 @@ router.patch('/users/:id/ban', ctrl.banUser);
|
||||
router.delete('/users/:id', ctrl.deleteUser);
|
||||
router.get('/sessions', ctrl.getAllSessions);
|
||||
router.get('/sessions/:id', ctrl.getSessionDetail);
|
||||
router.delete('/sessions/:id', ctrl.deleteSession);
|
||||
|
||||
/* Audit log */
|
||||
router.get('/audit-log', ctrl.getAuditLog);
|
||||
|
||||
@@ -7,10 +7,40 @@
|
||||
let allSessions = [];
|
||||
let openDrawerId = null;
|
||||
|
||||
/* SVG icons (Lucide-style) — kept local to mirror users.js without coupling */
|
||||
const SESS_ICONS = {
|
||||
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>',
|
||||
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>',
|
||||
};
|
||||
|
||||
/* Inject .row-actions / .row-action-btn styles only if users.js hasn't (sessions can render first). */
|
||||
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);
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const subject = document.getElementById('t-subject').value;
|
||||
document.getElementById('t-body').innerHTML = '<div class="spinner"></div>';
|
||||
openDrawerId = null;
|
||||
ensureRowActionsStyles();
|
||||
try {
|
||||
allSessions = await LS.adminGetSessions({ subject: subject || undefined });
|
||||
renderSessions();
|
||||
@@ -68,6 +98,12 @@
|
||||
</div>
|
||||
<div class="sess-tl-score">${s.score??'—'} / ${s.total}</div>
|
||||
<div class="sess-tl-time">${fmtTime(s.duration_sec)}</div>
|
||||
<div class="row-actions" onclick="event.stopPropagation()">
|
||||
<button type="button" class="row-action-btn" title="Открыть детали"
|
||||
onclick="event.stopPropagation();toggleDrawer(${s.id})">${SESS_ICONS.eye}</button>
|
||||
<button type="button" class="row-action-btn danger" title="Удалить сессию"
|
||||
onclick="event.stopPropagation();quickDeleteSession(${s.id},this)">${SESS_ICONS.trash}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sess-tl-drawer" id="tdrawer-${s.id}">
|
||||
<div class="sess-drawer" id="drawer-${s.id}">
|
||||
@@ -146,10 +182,28 @@
|
||||
if (window.lucide) lucide.createIcons();
|
||||
}
|
||||
|
||||
async function quickDeleteSession(id, btn) {
|
||||
if (!await LS.confirm(
|
||||
'Удалить эту сессию? Все ответы и связанные данные будут удалены.\nЭто действие нельзя отменить.',
|
||||
{ title: 'Удалить сессию', confirmText: 'Удалить' }
|
||||
)) return;
|
||||
btn.disabled = true;
|
||||
try {
|
||||
await LS.adminDeleteSession(id);
|
||||
LS.toast('Сессия удалена', 'success');
|
||||
// Refresh from server — keeps grouped layout consistent.
|
||||
await load();
|
||||
} catch (e) {
|
||||
LS.toast('Ошибка: ' + e.message, 'error');
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Expose handlers
|
||||
window.loadSessions = load;
|
||||
window.renderSessions = renderSessions;
|
||||
window.toggleDrawer = toggleDrawer;
|
||||
window.quickDeleteSession = quickDeleteSession;
|
||||
|
||||
window.AdminSections = window.AdminSections || {};
|
||||
window.AdminSections.sessions = {
|
||||
|
||||
@@ -7,6 +7,39 @@
|
||||
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;
|
||||
@@ -19,6 +52,7 @@
|
||||
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 || [];
|
||||
@@ -58,7 +92,7 @@
|
||||
</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 style="text-align:right;color:var(--text-3);font-size:0.85rem;opacity:0.4">›</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');
|
||||
@@ -68,6 +102,106 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── 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 may extend to `#sessions?user=${uid}` (deep-link with prefilter);
|
||||
// for now just navigate to sessions tab.
|
||||
if (window.AdminRouter) AdminRouter.navigate('#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();
|
||||
@@ -334,6 +468,11 @@
|
||||
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 = {
|
||||
|
||||
@@ -175,6 +175,7 @@ async function adminGetSessions(params = {}) {
|
||||
return req('GET', `/admin/sessions?${p}`);
|
||||
}
|
||||
async function adminGetSessionDetail(id) { return req('GET', `/admin/sessions/${id}`); }
|
||||
async function adminDeleteSession(id) { return req('DELETE',`/admin/sessions/${id}`); }
|
||||
async function adminClearUserSessions(id) { return req('POST', `/admin/users/${id}/sessions/clear`); }
|
||||
async function adminUpdateUser(id, data) { return req('PATCH', `/admin/users/${id}`, data); }
|
||||
async function adminBanUser(id, banned) { return req('PATCH', `/admin/users/${id}/ban`, { banned }); }
|
||||
@@ -944,7 +945,7 @@ window.LS = {
|
||||
register, login, fetchMe, updateProfile,
|
||||
getSubjects, updateSubject, getTopics,
|
||||
startSession, sendAnswer, finishSession, getResult, getHistory, getWeakTopics, getStudentStats, getSessionQuestions,
|
||||
adminGetStats, adminGetOverview, adminGlobalSearch, adminGetUsers, adminUpdateRole, adminGetUserSessions, adminGetSessions, adminGetSessionDetail, adminClearUserSessions, adminUpdateUser, adminBanUser, adminDeleteUser,
|
||||
adminGetStats, adminGetOverview, adminGlobalSearch, adminGetUsers, adminUpdateRole, adminGetUserSessions, adminGetSessions, adminGetSessionDetail, adminDeleteSession, adminClearUserSessions, adminUpdateUser, adminBanUser, adminDeleteUser,
|
||||
getQuestions, createQuestion, duplicateQuestion, updateQuestion, deleteQuestion, importQuestions,
|
||||
getClasses, createClass, getClassDetail, updateClass, deleteClass, kickMember, addClassMember, createAssignment, createDirectAssignment, updateAssignment, deleteAssignment,
|
||||
regenerateInviteCode, classJournal,
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
- ✅ Phase 2 implemented (commit 92030b4) — admin.js ужат с ~3591L до 701L. Все 13 plan-tabs живут в `frontend/js/admin/sections/*.js` (IIFE pattern) + `frontend/js/admin/_shared.js` (window.AdminCtx). switchTab() диспетчит в `AdminSections[ROUTE_TO_SECTION[name]].init()`. Lazy-load работает (inited флаг внутри каждой IIFE). System tabs (topics/audit/errors/health/classroom/avatars) остались inline в admin.js — Phase 2 их не extract'ил.
|
||||
- ✅ Phase 3 implemented — `#overview` стал дефолтным route'ом admin-панели. Backend: `GET /api/admin/overview` (admin-only, ~0.08ms/call) возвращает digest за 24ч: новые регистрации, запущенные сессии, активные юзеры, активные классы, failed-сессии, забаненные за неделю (из audit log), топ-5 завершённых сессий. Frontend: `frontend/js/admin/sections/overview.js` (~205L) рендерит bento-grid карточки + alerts + топ-таблицу + quick-links (deep-link через `AdminRouter.navigate`). `admin.js`: дефолт `'stats'` → `'overview'` в `activate()`, initial nav, и initial init. Old `#stats` остался работающим (доступен через nav-item). Файлы: `frontend/js/admin/sections/overview.js` (NEW), `backend/src/controllers/adminController.js` (+57L: `overviewStmts` + `getOverview`), `backend/src/routes/admin.js` (+1L), `js/api.js` (+1 helper), `frontend/admin.html` (nav-item + tab-pane + script tag), `frontend/js/admin/admin.js` (ROUTE_TO_SECTION + default route refs).
|
||||
- ✅ Phase 4 implemented — Cmd+K (Ctrl+K) global command palette. Backend: `GET /api/admin/search?q=X` (admin-only) returns `{users[5], tests[3], classes[3]}` via 3 prepared LIKE queries (`title AS name` for tests, `invite_code AS code` for classes). Frontend: `frontend/js/admin/palette.js` (~320L) — custom modal (NOT LS.modal) with capture-phase Ctrl+K listener that `stopImmediatePropagation`'s to override `/js/search.js`. Debounced 150ms, ↑↓ Enter Esc keyboard nav, click-outside close. Action registry (8 entries) is hardcoded — extend by appending to `ACTIONS` const. Result interactions: user → `AdminRouter.navigate('#users/' + id)` (Phase 6 deep page hook), test → `#tests`, class → `/classes#id`. Exposed: `window.AdminPalette = { open, close, isOpen }`, `LS.adminGlobalSearch(q)`. Files: `frontend/js/admin/palette.js` (NEW), `backend/src/controllers/adminController.js` (+50L: `searchStmts` + `globalSearch`), `backend/src/routes/admin.js` (+1L), `js/api.js` (+4L helper + export), `frontend/admin.html` (+1 script tag).
|
||||
- ⬜ Phase 5-6 not started
|
||||
- ✅ Phase 5 implemented — per-row hover quick actions для users + sessions tables. Users row (admin && uid !== self): 4 кнопки (Ban/Unban toggle, Award coins via LS.modal с amount+reason, Sessions → AdminRouter.navigate('#sessions'), Delete). Sessions row: 2 кнопки (View → toggleDrawer, Delete). Все `event.stopPropagation()` чтобы не триггерить row-click overlay/drawer. CSS injected ONCE через `ensureRowActionsStyles()` (de-dup по `#row-actions-style` id, обе секции проверяют existence). Mobile ≤768px: actions hidden (row-click overlay остаётся fallback'ом). Backend: NEW `DELETE /api/admin/sessions/:id` (admin-only) → `_deleteSessionTx` транзакция: nullify `assignment_sessions.session_id`, delete `user_answers` + `session_questions` (FK CASCADE но делаем explicit для visibility), delete `test_sessions`. Audit log: `'session.delete'`. Файлы: `frontend/js/admin/sections/users.js` (343→469L, +126), `frontend/js/admin/sections/sessions.js` (159→210L, +51), `backend/src/controllers/adminController.js` (+27L: `_deleteSessionTx` + `deleteSession`), `backend/src/routes/admin.js` (+1L), `js/api.js` (+1 helper + export). NO эмоджи, inline SVG (Lucide outline-style 24x24 viewBox), Lucide уже доступен через CDN. User-panel overlay НЕ удалена — оставлена для Phase 6.
|
||||
- ⬜ Phase 6 not started
|
||||
|
||||
## Temporary Workarounds
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
- [x] Phase 2: Split admin.html → per-section modules [domain: frontend] → [subplan](./phase-2-split-sections.md)
|
||||
- [x] Phase 3: Dashboard #overview [domain: fullstack] → [subplan](./phase-3-dashboard.md) (parallelizable with 4, 5)
|
||||
- [x] Phase 4: Cmd+K command palette [domain: fullstack] → [subplan](./phase-4-palette.md) (parallelizable with 3, 5)
|
||||
- [ ] Phase 5: Per-row quick actions [domain: frontend] → [subplan](./phase-5-quick-actions.md) (parallelizable with 3, 4)
|
||||
- [x] Phase 5: Per-row quick actions [domain: frontend] → [subplan](./phase-5-quick-actions.md) (parallelizable with 3, 4)
|
||||
- [ ] Phase 6: Deep entity pages [domain: frontend] → [subplan](./phase-6-deep-pages.md)
|
||||
|
||||
**Параллелизация:** фазы 3, 4, 5 независимы (touch different files, no shared state) — выполняются параллельно после завершения фазы 2.
|
||||
@@ -51,8 +51,8 @@
|
||||
| Phase 1: Hash-router | frontend | ✅ Done | ✅ PASS w/ notes | ✅ | ✅ 8a7bed4 |
|
||||
| Phase 2: Split sections | frontend | ✅ Done | ✅ PASS (1 blocker fixed: fa67ad1) | ✅ node --check | ✅ 92030b4 + fa67ad1 |
|
||||
| Phase 3: Dashboard | fullstack | ✅ Done | ✅ PASS w/ 3 SQL warnings (post-merge polish) | ✅ | ✅ 41acbdd |
|
||||
| Phase 4: Palette | fullstack | ✅ Done | ⬜ | ✅ node --check | ⬜ |
|
||||
| Phase 5: Quick actions | frontend | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
|
||||
| Phase 4: Palette | fullstack | ✅ Done | ✅ PASS w/ notes (limit param cleanup applied) | ✅ | ✅ f562fe4 |
|
||||
| Phase 5: Quick actions | frontend | ✅ Done | ⬜ | ✅ node --check + tests 32/35 (3 pre-existing auth fails) | ⬜ |
|
||||
| Phase 6: Deep pages | frontend | ⬜ Not Started | ⬜ | ⬜ | ⬜ |
|
||||
|
||||
## Final Review
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Phase 5: Per-row quick actions
|
||||
|
||||
**Status:** ⬜ Not Started
|
||||
**Status:** ✅ Done
|
||||
**Parent plan:** [PLAN.md](./PLAN.md)
|
||||
**Domain:** frontend
|
||||
**Parallelizable with:** Phase 3, Phase 4
|
||||
@@ -11,31 +11,36 @@
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] **Users table** (`frontend/js/admin/sections/users.js`):
|
||||
- Добавить в каждый `<tr>` дополнительную ячейку или абсолютно-позиционированный блок с action-кнопками
|
||||
- Visible: только на `:hover` строки (via CSS)
|
||||
- Кнопки:
|
||||
- **🔒 Ban / Unban** — открывает confirm modal, на confirm вызывает существующий `toggleBanUser()` (или его эквивалент с userId)
|
||||
- **🪙 Award coins** — открывает быстрый prompt-modal "Сколько монет?", вызывает существующий `shopAdminAwardCoins` без перехода в shop tab
|
||||
- **📜 Sessions** — навигирует через `AdminRouter.navigate('#sessions?user=' + uid)` (param Phase 6 будет обрабатывать; пока fallback — переход на sessions tab)
|
||||
- **🗑 Delete** — confirm, вызывает существующий `confirmDeleteUser`
|
||||
- **ВАЖНО:** иконки только inline SVG (.ic класс) или Lucide — НИКАКИХ эмоджи
|
||||
- Кнопки `event.stopPropagation()` чтобы не триггерить `openUserPanel`
|
||||
- [ ] **Sessions table** (`frontend/js/admin/sections/sessions.js`):
|
||||
- **👁 View** — открыть session detail (текущий механизм)
|
||||
- **🗑 Delete** — confirm + DELETE /admin/sessions/:id (если такой endpoint есть, иначе добавить)
|
||||
- [ ] **Если delete session endpoint отсутствует** — добавить в backend:
|
||||
- `DELETE /api/admin/sessions/:id` с auth admin only
|
||||
- Контроллер: удалить из `test_sessions` + connected `session_answers`
|
||||
- Audit log entry
|
||||
- [ ] **CSS** (в admin.html style блоке или новый файл):
|
||||
```css
|
||||
.row-actions { opacity: 0; transition: opacity .15s; display: inline-flex; gap: 4px; }
|
||||
tr:hover .row-actions { opacity: 1; }
|
||||
.row-action-btn { width: 28px; height: 28px; border-radius: 6px; ... }
|
||||
```
|
||||
- [ ] Подсказки через `title="..."` атрибут на каждой кнопке
|
||||
- [ ] Confirm-модалки используют `LS.confirm` (не reinventing)
|
||||
- [x] **Users table** (`frontend/js/admin/sections/users.js`):
|
||||
- Добавлена `<td class="row-actions-cell">` с inline-flex блоком `.row-actions` (заменяет старый `›` индикатор)
|
||||
- Visible: только на `:hover` строки (CSS opacity transition)
|
||||
- Кнопки (inline SVG, Lucide-style):
|
||||
- **Ban / Unban** — `quickToggleBan(uid, isBanned, btn)` → `LS.confirm` → `LS.adminBanUser`
|
||||
- **Award coins** — `quickAwardCoins(uid, name)` → `LS.modal` (sm) с inputs amount+reason → `LS.adminShopAwardCoins`
|
||||
- **Sessions** — `quickOpenUserSessions(uid)` → `AdminRouter.navigate('#sessions')` (fallback на `switchTab`)
|
||||
- **Delete** — `quickDeleteUser(uid, name, btn)` → `LS.confirm` (destructive) → `LS.adminDeleteUser`
|
||||
- SVG-иконки (inline, Lucide outline-style), НЕТ эмоджи
|
||||
- `event.stopPropagation()` на каждой кнопке + на родительском `.row-actions` (чтобы не открывать user-panel overlay)
|
||||
- Hidden для self (`u.id !== user.id`) и для non-admin — fallback на старый `›`
|
||||
- [x] **Sessions table** (`frontend/js/admin/sections/sessions.js`):
|
||||
- **View (eye icon)** — `toggleDrawer(id)` (тот же flow что и row-click)
|
||||
- **Delete (trash, danger)** — `quickDeleteSession(id, btn)` → `LS.confirm` → `LS.adminDeleteSession` → `load()` (refresh)
|
||||
- [x] **Backend `DELETE /api/admin/sessions/:id`** — endpoint отсутствовал, добавлен:
|
||||
- Route: `backend/src/routes/admin.js` (внутри `requireRole('admin')` блока)
|
||||
- Controller: `deleteSession(req, res, next)` в `adminController.js` — транзакция:
|
||||
1. `UPDATE assignment_sessions SET session_id = NULL WHERE session_id = ?` (explicit null, hoarded slot stays)
|
||||
2. `DELETE FROM user_answers WHERE session_id = ?` (FK has `ON DELETE CASCADE`, но делаем явно)
|
||||
3. `DELETE FROM session_questions WHERE session_id = ?` (то же)
|
||||
4. `DELETE FROM test_sessions WHERE id = ?`
|
||||
- Audit: `audit(req, 'session.delete', 'session:${sid}', 'user:N mode:X')`
|
||||
- Validates `Number.isInteger(sid) && sid > 0`; 404 if not found
|
||||
- API helper: `LS.adminDeleteSession(id)` → `DELETE /admin/sessions/:id`
|
||||
- [x] **CSS** (`#row-actions-style`):
|
||||
- Inject ONCE из обеих секций (de-dup по element id) — оба `ensureRowActionsStyles()` проверяют `getElementById('row-actions-style')` перед добавлением
|
||||
- Стили: `.row-actions`, `.row-action-btn` (default + .danger), `.row-actions-cell`, `@media (max-width: 768px) { display: none }`
|
||||
- Также handle `tr.selected .row-actions` и `.sess-tl-item.open .row-actions` → opacity 1 (для активных строк)
|
||||
- [x] `title="…"` на каждой кнопке (tooltip)
|
||||
- [x] `LS.confirm(message, { title, confirmText })` использован везде (signature: `lsConfirm(message, { title, confirmText, danger=true })` — `danger:true` default, gradient pink→violet)
|
||||
|
||||
## Files to Modify/Create
|
||||
|
||||
@@ -78,17 +83,31 @@ Inspired by Linear / Vercel admin: actions visible on row hover, positioned righ
|
||||
|
||||
## Review Checklist
|
||||
|
||||
- [ ] Кнопки не сдвигают layout (используют absolute / hidden / opacity)
|
||||
- [ ] Все action эскейпят пользовательский ввод
|
||||
- [ ] No emoji — только SVG
|
||||
- [ ] event.stopPropagation на всех кнопках
|
||||
- [ ] Confirm для destructive actions
|
||||
- [ ] Tooltip присутствует
|
||||
- [ ] Mobile-friendly (hidden или альтернативный UI)
|
||||
- [ ] Build passes
|
||||
- [x] Кнопки не сдвигают layout — `opacity: 0 → 1` без display swap, занимают слот старого `›`
|
||||
- [x] Имя пользователя в onclick экранируется через `esc()` + `replace(/'/g, "\\'")` для безопасности SQL/HTML-injection в строковых литералах
|
||||
- [x] No emoji — только inline SVG (Lucide-style outline-stroke, viewBox 24x24)
|
||||
- [x] `event.stopPropagation()` на каждой кнопке + на родительском `.row-actions` div (defence in depth)
|
||||
- [x] Confirm через `LS.confirm` для destructive (delete user, delete session, ban/unban)
|
||||
- [x] `title` атрибут есть на каждой кнопке
|
||||
- [x] Mobile (≤768px): `.row-actions { display: none }` — row-click overlay по-прежнему работает как fallback
|
||||
- [x] `node --check` all modified files OK
|
||||
- [x] Tests: 32/35 pass (3 pre-existing auth-test failures, unrelated)
|
||||
|
||||
## Handoff to Next Phase
|
||||
|
||||
<!-- Implementer: записать, какие action-кнопки добавлены,
|
||||
какие param-форматы router использует (`#sessions?user=N`),
|
||||
что Phase 6 deep-page должна включить (например, replace #users overlay на deep page). -->
|
||||
**Phase 6 (deep entity pages) рекомендации:**
|
||||
|
||||
1. **`quickOpenUserSessions(uid)`** сейчас просто навигирует на `#sessions` без фильтра. Phase 6 должна:
|
||||
- Расширить router до `#sessions?user=N` (или новый формат `#sessions/user/N`)
|
||||
- В `sessions.js` `load()` читать query param и передавать `user_id` в `LS.adminGetSessions({ user_id })` (backend уже поддерживает `user_id` query param — см. `getAllSessions` controller)
|
||||
- Обновить хелпер: `AdminRouter.navigate('#sessions?user=' + uid)` (когда router научится parse'ить query)
|
||||
|
||||
2. **User-panel overlay vs hover actions:** Phase 6 удалит старую `.user-panel` overlay. Когда это произойдёт, row-click больше не будет открывать панель. Hover-actions останутся как primary UX. Рекомендация: при удалении overlay row-click сделать `onclick="AdminRouter.navigate('#users/' + uid)"` (deep page).
|
||||
|
||||
3. **Mobile UX gap:** на ≤768px actions сейчас полностью скрыты. Когда Phase 6 добавит deep page, mobile-row-click станет переходом на deep page → primary actions доступны там. До тех пор mobile = read-only browse.
|
||||
|
||||
4. **Backend `DELETE /admin/sessions/:id`** уже там, готов для Phase 6 deep session page (где будет кнопка "Удалить эту сессию" в header).
|
||||
|
||||
5. **Award coins modal pattern** (используем `LS.modal` с body=DOM Node + actions с `onClick({close, setError})`) — может быть полезен Phase 6 для inline-edit flow на deep user page.
|
||||
|
||||
6. **Linter note:** `npm run lint:routes` показывает FAIL (65 unprotected vs baseline 56) — pre-existing проблема, my new admin-protected `DELETE /sessions/:id` добавил +1 false-positive (роут защищён через `router.use(requireRole('admin'))` блок, который linter не видит). Не требует действий — это known limitation скрипта.
|
||||
|
||||
Reference in New Issue
Block a user