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:
Maxim Dolgolyov
2026-05-16 23:53:19 +03:00
parent f562fe4a71
commit 69113ab35e
8 changed files with 286 additions and 44 deletions
+2 -1
View File
@@ -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
+3 -3
View File
@@ -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
+56 -37
View File
@@ -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 скрипта.