Add RBAC: admin user management, audit log, runtime settings, delete PC

This commit is contained in:
2026-07-16 17:28:04 +07:00
parent c2148f334d
commit d1128f4aa3
16 changed files with 690 additions and 24 deletions
+35
View File
@@ -0,0 +1,35 @@
{% extends "base.html" %}
{% block title %}Аудит-лог — InfoUser Monitor{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Аудит-лог</h2>
<a href="/" class="btn btn-outline-secondary btn-sm">← Назад</a>
</div>
<div class="card shadow-sm">
<div class="card-body p-0">
<table class="table table-sm table-hover mb-0">
<thead class="table-dark">
<tr>
<th>Время</th>
<th>Пользователь</th>
<th>Действие</th>
<th>Детали</th>
</tr>
</thead>
<tbody>
{% for a in audit %}
<tr>
<td class="text-nowrap">{{ a.timestamp.strftime('%Y-%m-%d %H:%M:%S') if a.timestamp else '-' }}</td>
<td>{{ a.username or '-' }}</td>
<td><span class="badge bg-info">{{ a.action }}</span></td>
<td class="text-muted small">{{ a.details or '-' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
+69
View File
@@ -0,0 +1,69 @@
{% extends "base.html" %}
{% block title %}Настройки — InfoUser Monitor{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Настройки</h2>
<a href="/" class="btn btn-outline-secondary btn-sm">← Назад</a>
</div>
<div class="card shadow-sm">
<div class="card-body">
<form id="settings-form">
<div class="mb-3">
<label class="form-label">AGENT_TOKEN</label>
<input type="text" class="form-control" id="AGENT_TOKEN" value="{{ settings.AGENT_TOKEN or '' }}" required>
</div>
<div class="mb-3">
<label class="form-label">OFFLINE_THRESHOLD_MINUTES</label>
<input type="number" class="form-control" id="OFFLINE_THRESHOLD_MINUTES" value="{{ settings.OFFLINE_THRESHOLD_MINUTES or '' }}">
</div>
<div class="mb-3">
<label class="form-label">NOTIFICATION_WEBHOOK_URL</label>
<input type="text" class="form-control" id="NOTIFICATION_WEBHOOK_URL" value="{{ settings.NOTIFICATION_WEBHOOK_URL or '' }}">
</div>
<div class="mb-3">
<label class="form-label">NOTIFICATION_TELEGRAM_BOT_TOKEN</label>
<input type="text" class="form-control" id="NOTIFICATION_TELEGRAM_BOT_TOKEN" value="{{ settings.NOTIFICATION_TELEGRAM_BOT_TOKEN or '' }}">
</div>
<div class="mb-3">
<label class="form-label">NOTIFICATION_TELEGRAM_CHAT_ID</label>
<input type="text" class="form-control" id="NOTIFICATION_TELEGRAM_CHAT_ID" value="{{ settings.NOTIFICATION_TELEGRAM_CHAT_ID or '' }}">
</div>
<button type="submit" class="btn btn-primary">Сохранить</button>
</form>
<div id="settings-error" class="alert alert-danger mt-3 d-none"></div>
<div id="settings-success" class="alert alert-success mt-3 d-none">Настройки сохранены</div>
</div>
</div>
<script>
document.getElementById('settings-form').addEventListener('submit', async (e) => {
e.preventDefault();
const err = document.getElementById('settings-error');
const ok = document.getElementById('settings-success');
err.classList.add('d-none');
ok.classList.add('d-none');
const body = {
AGENT_TOKEN: document.getElementById('AGENT_TOKEN').value,
OFFLINE_THRESHOLD_MINUTES: parseInt(document.getElementById('OFFLINE_THRESHOLD_MINUTES').value),
NOTIFICATION_WEBHOOK_URL: document.getElementById('NOTIFICATION_WEBHOOK_URL').value,
NOTIFICATION_TELEGRAM_BOT_TOKEN: document.getElementById('NOTIFICATION_TELEGRAM_BOT_TOKEN').value,
NOTIFICATION_TELEGRAM_CHAT_ID: document.getElementById('NOTIFICATION_TELEGRAM_CHAT_ID').value,
};
const res = await fetch('/api/settings', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(body),
});
if (res.ok) {
ok.classList.remove('d-none');
} else {
const data = await res.json();
err.textContent = data.detail || 'Ошибка';
err.classList.remove('d-none');
}
});
</script>
{% endblock %}
+124
View File
@@ -0,0 +1,124 @@
{% extends "base.html" %}
{% block title %}Управление пользователями — InfoUser Monitor{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Пользователи</h2>
<a href="/" class="btn btn-outline-secondary btn-sm">← Назад</a>
</div>
<div class="card shadow-sm mb-4">
<div class="card-body">
<h5 class="card-title">Создать пользователя</h5>
<form id="create-user-form" class="row g-2">
<div class="col-md-4">
<input type="text" class="form-control" id="new-username" placeholder="Логин" required>
</div>
<div class="col-md-4">
<input type="password" class="form-control" id="new-password" placeholder="Пароль" required>
</div>
<div class="col-md-2">
<select class="form-select" id="new-role">
<option value="viewer">viewer</option>
<option value="admin">admin</option>
</select>
</div>
<div class="col-md-2">
<button type="submit" class="btn btn-primary w-100">Создать</button>
</div>
</form>
<div id="create-error" class="alert alert-danger mt-3 d-none"></div>
</div>
</div>
<table class="table table-hover align-middle">
<thead class="table-dark">
<tr>
<th>ID</th>
<th>Логин</th>
<th>Роль</th>
<th>Создан</th>
<th></th>
</tr>
</thead>
<tbody>
{% for u in users %}
<tr data-id="{{ u.id }}">
<td>{{ u.id }}</td>
<td>{{ u.username }}</td>
<td>
<span class="badge {% if u.role == 'admin' %}bg-danger{% else %}bg-secondary{% endif %}">{{ u.role }}</span>
</td>
<td>{{ u.created_at.strftime('%Y-%m-%d %H:%M') if u.created_at else '-' }}</td>
<td>
<button class="btn btn-sm btn-outline-primary change-role" data-id="{{ u.id }}" data-username="{{ u.username }}" data-role="{{ u.role }}">Роль</button>
<button class="btn btn-sm btn-outline-warning reset-password" data-id="{{ u.id }}" data-username="{{ u.username }}">Пароль</button>
<button class="btn btn-sm btn-outline-danger delete-user" data-id="{{ u.id }}" data-username="{{ u.username }}">Удалить</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<script>
document.getElementById('create-user-form').addEventListener('submit', async (e) => {
e.preventDefault();
const err = document.getElementById('create-error');
err.classList.add('d-none');
const res = await fetch('/api/auth/users', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
username: document.getElementById('new-username').value,
password: document.getElementById('new-password').value,
role: document.getElementById('new-role').value,
}),
});
if (res.ok) {
location.reload();
} else {
const data = await res.json();
err.textContent = data.detail || 'Ошибка';
err.classList.remove('d-none');
}
});
document.querySelectorAll('.change-role').forEach(btn => {
btn.addEventListener('click', async () => {
const newRole = btn.dataset.role === 'admin' ? 'viewer' : 'admin';
if (!confirm(`Сменить роль ${btn.dataset.username} на ${newRole}?`)) return;
const res = await fetch(`/api/auth/users/${btn.dataset.id}`, {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({role: newRole}),
});
if (res.ok) location.reload();
else alert('Ошибка смены роли');
});
});
document.querySelectorAll('.reset-password').forEach(btn => {
btn.addEventListener('click', async () => {
const password = prompt(`Новый пароль для ${btn.dataset.username}:`);
if (!password) return;
const res = await fetch(`/api/auth/users/${btn.dataset.id}`, {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({password}),
});
if (res.ok) alert('Пароль изменён');
else alert('Ошибка смены пароля');
});
});
document.querySelectorAll('.delete-user').forEach(btn => {
btn.addEventListener('click', async () => {
if (!confirm(`Удалить пользователя ${btn.dataset.username}?`)) return;
const res = await fetch(`/api/auth/users/${btn.dataset.id}`, {method: 'DELETE'});
if (res.ok) location.reload();
else alert('Ошибка удаления');
});
});
</script>
{% endblock %}
+14 -1
View File
@@ -14,8 +14,21 @@
<div class="container-fluid">
<a class="navbar-brand" href="/">InfoUser Monitor</a>
{% if user %}
<div class="collapse navbar-collapse">
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav ms-auto align-items-center">
{% if user.role == 'admin' %}
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">Admin</a>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="/admin/users">Пользователи</a></li>
<li><a class="dropdown-item" href="/admin/settings">Настройки</a></li>
<li><a class="dropdown-item" href="/admin/audit">Аудит-лог</a></li>
</ul>
</li>
{% endif %}
<li class="nav-item me-3">
<a href="/notifications" class="nav-link position-relative">
<i class="bi bi-bell"></i>
+26 -2
View File
@@ -81,7 +81,12 @@
<td>{{ c.current_ram_percent | round(1) if c.current_ram_percent is not none else '-' }}%</td>
<td>{{ c.os_info or '-' }}</td>
<td class="last-seen">{% if c.last_seen %}{{ c.last_seen.strftime('%Y-%m-%d %H:%M') }}{% else %}-{% endif %}</td>
<td><a href="/computers/{{ c.id }}" class="btn btn-sm btn-outline-primary">Детали</a></td>
<td>
<a href="/computers/{{ c.id }}" class="btn btn-sm btn-outline-primary">Детали</a>
{% if user.role == 'admin' %}
<button class="btn btn-sm btn-outline-danger delete-pc" data-id="{{ c.id }}" data-hostname="{{ c.hostname }}">Удалить</button>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
@@ -94,10 +99,23 @@
{% block scripts %}
<script>
const isAdmin = {{ 'true' if user.role == 'admin' else 'false' }};
function currentQuery() {
return new URLSearchParams(window.location.search).toString();
}
async function deleteComputer(id, hostname) {
if (!confirm(`Удалить ПК ${hostname}?`)) return;
const res = await fetch(`/api/computers/${id}`, {method: 'DELETE'});
if (res.ok) location.reload();
else alert('Ошибка удаления');
}
document.querySelectorAll('.delete-pc').forEach(btn => {
btn.addEventListener('click', () => deleteComputer(btn.dataset.id, btn.dataset.hostname));
});
async function loadDashboard() {
try {
const res = await fetch('/api/computers?' + currentQuery());
@@ -114,9 +132,15 @@ async function loadDashboard() {
<td>${c.current_ram_percent != null ? c.current_ram_percent.toFixed(1) : '-'}%</td>
<td>${escapeHtml(c.os_info || '-')}</td>
<td class="last-seen">${c.last_seen ? c.last_seen.replace('T', ' ').slice(0, 16) : '-'}</td>
<td><a href="/computers/${c.id}" class="btn btn-sm btn-outline-primary">Детали</a></td>
<td>
<a href="/computers/${c.id}" class="btn btn-sm btn-outline-primary">Детали</a>
${isAdmin ? `<button class="btn btn-sm btn-outline-danger delete-pc" data-id="${c.id}" data-hostname="${escapeHtml(c.hostname)}">Удалить</button>` : ''}
</td>
</tr>
`).join('');
document.querySelectorAll('.delete-pc').forEach(btn => {
btn.addEventListener('click', () => deleteComputer(btn.dataset.id, btn.dataset.hostname));
});
} catch (e) {
console.error('Dashboard refresh failed', e);
}
+38
View File
@@ -23,6 +23,21 @@
<div class="alert alert-danger mt-3">{{ error }}</div>
{% endif %}
<div id="login-error" class="alert alert-danger mt-3 d-none"></div>
{% if registration_open %}
<hr>
<h6 class="text-center">Или создайте первого администратора</h6>
<form id="register-form">
<div class="mb-3">
<input type="text" class="form-control" id="reg-username" placeholder="Логин" required>
</div>
<div class="mb-3">
<input type="password" class="form-control" id="reg-password" placeholder="Пароль" required>
</div>
<button type="submit" class="btn btn-outline-secondary w-100">Зарегистрироваться</button>
</form>
<div id="register-error" class="alert alert-danger mt-3 d-none"></div>
{% endif %}
</div>
</div>
<div class="text-center mt-3 text-muted small">
@@ -55,5 +70,28 @@ document.getElementById('login-form').addEventListener('submit', async (e) => {
err.classList.remove('d-none');
}
});
{% if registration_open %}
document.getElementById('register-form').addEventListener('submit', async (e) => {
e.preventDefault();
const err = document.getElementById('register-error');
err.classList.add('d-none');
const res = await fetch('/api/auth/register', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
username: document.getElementById('reg-username').value,
password: document.getElementById('reg-password').value,
}),
});
if (res.ok) {
window.location.href = '/';
} else {
const data = await res.json();
err.textContent = data.detail || 'Ошибка регистрации';
err.classList.remove('d-none');
}
});
{% endif %}
</script>
{% endblock %}