From 8607a9f981736ea83fd875aec2eb62fdf9607b65 Mon Sep 17 00:00:00 2001 From: Gros Frumos Date: Fri, 20 Mar 2026 23:44:58 +0200 Subject: [PATCH] kin: BATON-005-frontend_dev --- frontend/admin.html | 379 ++++++++++++++++++++++++++++++++++++++++++++ frontend/admin.js | 333 ++++++++++++++++++++++++++++++++++++++ nginx/baton.conf | 13 ++ 3 files changed, 725 insertions(+) create mode 100644 frontend/admin.html create mode 100644 frontend/admin.js diff --git a/frontend/admin.html b/frontend/admin.html new file mode 100644 index 0000000..8dab73f --- /dev/null +++ b/frontend/admin.html @@ -0,0 +1,379 @@ + + + + + + Baton — Admin + + + + + +
+ +
+ + +
+
+

Пользователи

+ + +
+ +
+ + +
+ + + + + + + + + + + + + + +
#ИмяUUIDСтатусСозданДействия
Загрузка…
+
+
+
+ + + + + + + + + + diff --git a/frontend/admin.js b/frontend/admin.js new file mode 100644 index 0000000..7e46b9e --- /dev/null +++ b/frontend/admin.js @@ -0,0 +1,333 @@ +'use strict'; + +// ========== Token (sessionStorage — cleared on browser close) ========== + +function _getToken() { + return sessionStorage.getItem('baton_admin_token') || ''; +} + +function _saveToken(t) { + sessionStorage.setItem('baton_admin_token', t); +} + +function _clearToken() { + sessionStorage.removeItem('baton_admin_token'); +} + +// ========== API wrapper ========== + +async function _api(method, path, body) { + const opts = { + method, + headers: { 'Authorization': 'Bearer ' + _getToken() }, + }; + if (body !== undefined) { + opts.headers['Content-Type'] = 'application/json'; + opts.body = JSON.stringify(body); + } + + const res = await fetch(path, opts); + + if (res.status === 204) return null; + + const text = await res.text().catch(() => ''); + if (!res.ok) { + let detail = text; + try { detail = JSON.parse(text).detail || text; } catch (_) {} + throw new Error('HTTP ' + res.status + (detail ? ': ' + detail : '')); + } + + try { return JSON.parse(text); } catch (_) { return null; } +} + +// ========== UI helpers ========== + +function _esc(str) { + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function _setError(id, msg) { + const el = document.getElementById(id); + el.textContent = msg; + el.hidden = !msg; +} + +function _showPanel() { + document.getElementById('screen-token').style.display = 'none'; + document.getElementById('screen-panel').classList.add('active'); +} + +function _showTokenScreen() { + document.getElementById('screen-panel').classList.remove('active'); + document.getElementById('screen-token').style.display = ''; + document.getElementById('token-input').value = ''; +} + +// ========== Users table ========== + +function _renderTable(users) { + const tbody = document.getElementById('users-tbody'); + tbody.innerHTML = ''; + + if (!users.length) { + const tr = document.createElement('tr'); + tr.className = 'empty-row'; + tr.innerHTML = 'Нет пользователей'; + tbody.appendChild(tr); + return; + } + + users.forEach((u) => { + const tr = document.createElement('tr'); + if (u.is_blocked) tr.classList.add('is-blocked'); + + const date = u.created_at ? u.created_at.slice(0, 16).replace('T', ' ') : '—'; + const uuidShort = u.uuid ? u.uuid.slice(0, 8) + '…' : '—'; + + tr.innerHTML = ` + ${u.id} + ${_esc(u.name)} + ${_esc(uuidShort)} + + + ${u.is_blocked ? 'Заблокирован' : 'Активен'} + + + ${_esc(date)} + + + + + + `; + tbody.appendChild(tr); + }); +} + +// ========== Load users ========== + +async function _loadUsers() { + _setError('panel-error', ''); + try { + const users = await _api('GET', '/admin/users'); + _renderTable(users); + } catch (err) { + _setError('panel-error', err.message); + } +} + +// ========== Login / Logout ========== + +async function _handleLogin() { + const input = document.getElementById('token-input'); + const btn = document.getElementById('btn-login'); + const token = input.value.trim(); + if (!token) return; + + btn.disabled = true; + _setError('login-error', ''); + _saveToken(token); + + try { + const users = await _api('GET', '/admin/users'); + _renderTable(users); + _showPanel(); + } catch (err) { + _clearToken(); + const msg = err.message.includes('401') ? 'Неверный токен' : err.message; + _setError('login-error', msg); + btn.disabled = false; + } +} + +function _handleLogout() { + _clearToken(); + _showTokenScreen(); +} + +// ========== Table action dispatcher (event delegation) ========== + +async function _handleTableClick(e) { + const btn = e.target.closest('[data-action]'); + if (!btn) return; + + const { action, id, name, blocked } = btn.dataset; + + if (action === 'password') { + _openPasswordModal(id, name); + } else if (action === 'block') { + await _toggleBlock(id, blocked === '1'); + } else if (action === 'delete') { + await _handleDelete(id, name); + } +} + +// ========== Block / Unblock ========== + +async function _toggleBlock(userId, currentlyBlocked) { + _setError('panel-error', ''); + try { + await _api('PUT', `/admin/users/${userId}/block`, { is_blocked: !currentlyBlocked }); + await _loadUsers(); + } catch (err) { + _setError('panel-error', err.message); + } +} + +// ========== Delete ========== + +async function _handleDelete(userId, userName) { + if (!confirm(`Удалить пользователя "${userName}"?\n\nБудут удалены все его сигналы. Действие нельзя отменить.`)) return; + _setError('panel-error', ''); + try { + await _api('DELETE', `/admin/users/${userId}`); + await _loadUsers(); + } catch (err) { + _setError('panel-error', err.message); + } +} + +// ========== Password modal ========== + +function _openPasswordModal(userId, userName) { + document.getElementById('modal-pw-subtitle').textContent = `Пользователь: ${userName}`; + document.getElementById('modal-pw-user-id').value = userId; + document.getElementById('new-password').value = ''; + _setError('modal-pw-error', ''); + document.getElementById('btn-pw-save').disabled = false; + document.getElementById('modal-password').hidden = false; + document.getElementById('new-password').focus(); +} + +function _closePasswordModal() { + document.getElementById('modal-password').hidden = true; +} + +async function _handleSetPassword() { + const userId = document.getElementById('modal-pw-user-id').value; + const password = document.getElementById('new-password').value; + const btn = document.getElementById('btn-pw-save'); + + if (!password) { + _setError('modal-pw-error', 'Введите пароль'); + return; + } + + btn.disabled = true; + _setError('modal-pw-error', ''); + + try { + await _api('PUT', `/admin/users/${userId}/password`, { password }); + _closePasswordModal(); + } catch (err) { + _setError('modal-pw-error', err.message); + btn.disabled = false; + } +} + +// ========== Create user modal ========== + +function _openCreateModal() { + document.getElementById('create-uuid').value = crypto.randomUUID(); + document.getElementById('create-name').value = ''; + document.getElementById('create-password').value = ''; + _setError('create-error', ''); + document.getElementById('btn-create-submit').disabled = false; + document.getElementById('modal-create').hidden = false; + document.getElementById('create-name').focus(); +} + +function _closeCreateModal() { + document.getElementById('modal-create').hidden = true; +} + +async function _handleCreateUser() { + const uuid = document.getElementById('create-uuid').value.trim(); + const name = document.getElementById('create-name').value.trim(); + const password = document.getElementById('create-password').value; + const btn = document.getElementById('btn-create-submit'); + + if (!uuid || !name) { + _setError('create-error', 'UUID и имя обязательны'); + return; + } + + btn.disabled = true; + _setError('create-error', ''); + + const body = { uuid, name }; + if (password) body.password = password; + + try { + await _api('POST', '/admin/users', body); + _closeCreateModal(); + await _loadUsers(); + } catch (err) { + const msg = err.message.includes('409') ? 'Пользователь с таким UUID уже существует' : err.message; + _setError('create-error', msg); + btn.disabled = false; + } +} + +// ========== Init ========== + +function _init() { + // Login screen + document.getElementById('btn-login').addEventListener('click', _handleLogin); + document.getElementById('token-input').addEventListener('keydown', (e) => { + if (e.key === 'Enter') _handleLogin(); + }); + + // Panel + document.getElementById('btn-logout').addEventListener('click', _handleLogout); + document.getElementById('btn-create').addEventListener('click', _openCreateModal); + + // Table (event delegation) + document.getElementById('users-table').addEventListener('click', _handleTableClick); + + // Password modal + document.getElementById('btn-pw-cancel').addEventListener('click', _closePasswordModal); + document.getElementById('btn-pw-save').addEventListener('click', _handleSetPassword); + document.getElementById('new-password').addEventListener('keydown', (e) => { + if (e.key === 'Enter') _handleSetPassword(); + }); + document.getElementById('modal-password').addEventListener('click', (e) => { + if (e.target.id === 'modal-password') _closePasswordModal(); + }); + + // Create modal + document.getElementById('btn-create-cancel').addEventListener('click', _closeCreateModal); + document.getElementById('btn-create-submit').addEventListener('click', _handleCreateUser); + document.getElementById('create-password').addEventListener('keydown', (e) => { + if (e.key === 'Enter') _handleCreateUser(); + }); + document.getElementById('modal-create').addEventListener('click', (e) => { + if (e.target.id === 'modal-create') _closeCreateModal(); + }); + + // Auto-login if token is already saved in sessionStorage + if (_getToken()) { + _showPanel(); + _loadUsers().catch(() => { + _clearToken(); + _showTokenScreen(); + }); + } +} + +document.addEventListener('DOMContentLoaded', _init); diff --git a/nginx/baton.conf b/nginx/baton.conf index c9d892a..e148729 100644 --- a/nginx/baton.conf +++ b/nginx/baton.conf @@ -78,6 +78,19 @@ server { proxy_set_header X-Forwarded-Proto $scheme; } + # Admin API → FastAPI (UI-страница /admin.html раздаётся статикой ниже) + location /admin/users { + proxy_pass http://127.0.0.1:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_read_timeout 30s; + proxy_send_timeout 30s; + proxy_connect_timeout 5s; + } + # Статика фронтенда (SPA) location / { root /opt/baton/frontend;