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
+
+
+
+
+
+
+
+
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;