From 69113ab35eaa216d89103b90660201548f082561 Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Sat, 16 May 2026 23:53:19 +0300 Subject: [PATCH] =?UTF-8?q?feat(admin):=20phase=205=20=E2=80=94=20per-row?= =?UTF-8?q?=20quick=20actions=20for=20users=20+=20sessions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- backend/src/controllers/adminController.js | 29 +++- backend/src/routes/admin.js | 1 + frontend/js/admin/sections/sessions.js | 54 +++++++ frontend/js/admin/sections/users.js | 141 +++++++++++++++++- js/api.js | 3 +- plans/admin-redesign/CONTEXT.md | 3 +- plans/admin-redesign/PLAN.md | 6 +- plans/admin-redesign/phase-5-quick-actions.md | 93 +++++++----- 8 files changed, 286 insertions(+), 44 deletions(-) diff --git a/backend/src/controllers/adminController.js b/backend/src/controllers/adminController.js index 6e337b4..18e290b 100644 --- a/backend/src/controllers/adminController.js +++ b/backend/src/controllers/adminController.js @@ -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, diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index 7847cb8..3875227 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -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); diff --git a/frontend/js/admin/sections/sessions.js b/frontend/js/admin/sections/sessions.js index f2d237e..ed6d3b1 100644 --- a/frontend/js/admin/sections/sessions.js +++ b/frontend/js/admin/sections/sessions.js @@ -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: '', + trash: '', + }; + + /* 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 = '
'; openDrawerId = null; + ensureRowActionsStyles(); try { allSessions = await LS.adminGetSessions({ subject: subject || undefined }); renderSessions(); @@ -68,6 +98,12 @@
${s.score??'—'} / ${s.total}
${fmtTime(s.duration_sec)}
+
+ + +
@@ -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 = { diff --git a/frontend/js/admin/sections/users.js b/frontend/js/admin/sections/users.js index 2cb46a1..f97c65b 100644 --- a/frontend/js/admin/sections/users.js +++ b/frontend/js/admin/sections/users.js @@ -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: '', + unlock: '', + coins: '', + history: '', + trash: '', + eye: '', + }; + // 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 @@ ${fmtDate(u.created_at)} ${u.last_login ? new Date(u.last_login).toLocaleDateString('ru',{day:'numeric',month:'short'}) : '—'} - › + ${renderUserRowActions(u, isAdmin && u.id !== user.id)} `; }).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 ''; + } + const banIcon = u.is_banned ? ICONS.unlock : ICONS.ban; + const banLabel = u.is_banned ? 'Разблокировать' : 'Заблокировать'; + return `
+ + + + +
`; + } + + 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 = ` +

Начислить монеты пользователю ${esc(name)}:

+
+ + +
`; + 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 = { diff --git a/js/api.js b/js/api.js index 9a4beeb..4bdbbbf 100644 --- a/js/api.js +++ b/js/api.js @@ -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, diff --git a/plans/admin-redesign/CONTEXT.md b/plans/admin-redesign/CONTEXT.md index ec35607..b78ea2d 100644 --- a/plans/admin-redesign/CONTEXT.md +++ b/plans/admin-redesign/CONTEXT.md @@ -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 diff --git a/plans/admin-redesign/PLAN.md b/plans/admin-redesign/PLAN.md index 1460a90..807081a 100644 --- a/plans/admin-redesign/PLAN.md +++ b/plans/admin-redesign/PLAN.md @@ -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 diff --git a/plans/admin-redesign/phase-5-quick-actions.md b/plans/admin-redesign/phase-5-quick-actions.md index 52add13..6e063f2 100644 --- a/plans/admin-redesign/phase-5-quick-actions.md +++ b/plans/admin-redesign/phase-5-quick-actions.md @@ -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`): - - Добавить в каждый `` дополнительную ячейку или абсолютно-позиционированный блок с 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`): + - Добавлена `` с 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 - +**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 скрипта.