Add RBAC: admin user management, audit log, runtime settings, delete PC
This commit is contained in:
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
Reference in New Issue
Block a user