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
+14
View File
@@ -74,6 +74,20 @@ def require_admin(user: models.User = Depends(get_current_user)):
return user
def log_action(db: Session, user: Optional[models.User], action: str, details: Optional[str] = None):
try:
entry = models.AuditLog(
user_id=user.id if user else None,
username=user.username if user else None,
action=action,
details=details,
)
db.add(entry)
db.commit()
except Exception:
db.rollback()
def create_default_admin(db: Session) -> Optional[models.User]:
if not settings.CREATE_ADMIN_ON_STARTUP:
return None
+14
View File
@@ -24,3 +24,17 @@ def get_db():
def create_tables():
Base.metadata.create_all(bind=engine)
def run_migrations():
"""Apply lightweight schema migrations for SQLite."""
from sqlalchemy import inspect, text
insp = inspect(engine)
# AuditLog: add username column if missing
if "audit_log" in insp.get_table_names():
audit_cols = {c["name"] for c in insp.get_columns("audit_log")}
if "username" not in audit_cols:
with engine.connect() as conn:
conn.execute(text("ALTER TABLE audit_log ADD COLUMN username TEXT"))
conn.commit()
+2 -1
View File
@@ -3,7 +3,7 @@ from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware
from contextlib import asynccontextmanager
from database import create_tables, SessionLocal
from database import create_tables, run_migrations, SessionLocal
from auth import create_default_admin
from routes import auth_router, api_router, web_router
@@ -11,6 +11,7 @@ from routes import auth_router, api_router, web_router
@asynccontextmanager
async def lifespan(app: FastAPI):
create_tables()
run_migrations()
db = SessionLocal()
try:
create_default_admin(db)
+10
View File
@@ -54,6 +54,7 @@ class AuditLog(Base):
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
username = Column(String, nullable=True)
action = Column(String, nullable=False)
details = Column(Text, nullable=True)
timestamp = Column(DateTime(timezone=True), server_default=func.now())
@@ -71,3 +72,12 @@ class Notification(Base):
sent_at = Column(DateTime(timezone=True), server_default=func.now())
read_at = Column(DateTime(timezone=True), nullable=True)
class Setting(Base):
__tablename__ = "settings"
id = Column(Integer, primary_key=True, index=True)
key = Column(String, unique=True, nullable=False, index=True)
value = Column(Text, nullable=True)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
+25 -9
View File
@@ -6,11 +6,19 @@ from sqlalchemy.orm import Session
from config import get_settings
from models import Computer, Notification
from settings_store import get_setting
settings = get_settings()
logger = logging.getLogger(__name__)
def _int_or_default(value, default):
try:
return int(value)
except (TypeError, ValueError):
return default
def create_notification(db: Session, computer: Computer, ntype: str, title: str, message: str) -> Notification:
n = Notification(
computer_id=computer.id,
@@ -21,13 +29,17 @@ def create_notification(db: Session, computer: Computer, ntype: str, title: str,
db.add(n)
db.commit()
db.refresh(n)
send_external_notifications(n, computer)
send_external_notifications(db, n, computer)
return n
def check_offline_computers(db: Session):
"""Mark online computers that missed the threshold as offline and create notifications."""
threshold = datetime.now(timezone.utc) - timedelta(minutes=settings.OFFLINE_THRESHOLD_MINUTES)
threshold_minutes = _int_or_default(
get_setting(db, "OFFLINE_THRESHOLD_MINUTES"),
settings.OFFLINE_THRESHOLD_MINUTES,
)
threshold = datetime.now(timezone.utc) - timedelta(minutes=threshold_minutes)
stale = (
db.query(Computer)
.filter(Computer.status == "online", Computer.last_seen < threshold)
@@ -49,7 +61,7 @@ def check_offline_computers(db: Session):
if not existing:
msg = (
f"ПК {computer.hostname} не присылал данные более "
f"{settings.OFFLINE_THRESHOLD_MINUTES} минут. "
f"{threshold_minutes} минут. "
f"Последний пользователь: {computer.current_user or '-'}, "
f"IP: {computer.current_ip or '-'}"
)
@@ -57,11 +69,15 @@ def check_offline_computers(db: Session):
db.commit()
def send_external_notifications(notification: Notification, computer: Computer):
if settings.NOTIFICATION_WEBHOOK_URL:
def send_external_notifications(db: Session, notification: Notification, computer: Computer):
webhook_url = get_setting(db, "NOTIFICATION_WEBHOOK_URL") or settings.NOTIFICATION_WEBHOOK_URL
telegram_token = get_setting(db, "NOTIFICATION_TELEGRAM_BOT_TOKEN") or settings.NOTIFICATION_TELEGRAM_BOT_TOKEN
telegram_chat = get_setting(db, "NOTIFICATION_TELEGRAM_CHAT_ID") or settings.NOTIFICATION_TELEGRAM_CHAT_ID
if webhook_url:
try:
requests.post(
settings.NOTIFICATION_WEBHOOK_URL,
webhook_url,
json={
"type": notification.type,
"title": notification.title,
@@ -75,13 +91,13 @@ def send_external_notifications(notification: Notification, computer: Computer):
except Exception as e:
logger.error("Webhook notification failed: %s", e)
if settings.NOTIFICATION_TELEGRAM_BOT_TOKEN and settings.NOTIFICATION_TELEGRAM_CHAT_ID:
if telegram_token and telegram_chat:
try:
text = f"*{notification.title}*\n\n{notification.message}"
requests.post(
f"https://api.telegram.org/bot{settings.NOTIFICATION_TELEGRAM_BOT_TOKEN}/sendMessage",
f"https://api.telegram.org/bot{telegram_token}/sendMessage",
json={
"chat_id": settings.NOTIFICATION_TELEGRAM_CHAT_ID,
"chat_id": telegram_chat,
"text": text,
"parse_mode": "Markdown",
},
+69 -4
View File
@@ -10,10 +10,20 @@ from typing import List, Optional
from config import get_settings
from database import get_db
from models import Computer, Heartbeat, Notification
from schemas import HeartbeatPayload, ComputerOut, ComputerDetailOut, HeartbeatOut, NotificationOut
from auth import get_current_user
from models import Computer, Heartbeat, Notification, AuditLog
from schemas import (
HeartbeatPayload,
ComputerOut,
ComputerDetailOut,
HeartbeatOut,
NotificationOut,
AuditLogOut,
SettingOut,
SettingsUpdate,
)
from auth import get_current_user, require_admin, log_action
from notifications import check_offline_computers, mark_notification_read
from settings_store import get_setting, set_setting, get_all_settings
router = APIRouter(prefix="/api", tags=["api"])
settings = get_settings()
@@ -33,7 +43,8 @@ def heartbeat(
db: Session = Depends(get_db),
):
token = get_agent_token(request)
if token != settings.AGENT_TOKEN:
expected = get_setting(db, "AGENT_TOKEN") or settings.AGENT_TOKEN
if token != expected:
raise HTTPException(status_code=401, detail="Invalid agent token")
now = datetime.now(timezone.utc)
@@ -140,6 +151,23 @@ def get_computer(
return computer
@router.delete("/computers/{computer_id}")
def delete_computer(
computer_id: int,
db: Session = Depends(get_db),
admin=Depends(require_admin),
):
computer = db.query(Computer).filter(Computer.id == computer_id).first()
if not computer:
raise HTTPException(status_code=404, detail="Computer not found")
hostname = computer.hostname
db.delete(computer)
db.commit()
log_action(db, admin, "computer_deleted", f"Deleted computer {hostname} (id={computer_id})")
return {"ok": True}
@router.get("/computers/export/csv")
def export_csv(
status: Optional[str] = None,
@@ -230,3 +258,40 @@ def read_all_notifications(
)
db.commit()
return {"ok": True}
# Admin: audit log
@router.get("/audit", response_model=List[AuditLogOut])
def list_audit(
limit: int = 200,
db: Session = Depends(get_db),
admin=Depends(require_admin),
):
return db.query(AuditLog).order_by(desc(AuditLog.timestamp)).limit(limit).all()
# Admin: settings
@router.get("/settings")
def list_settings(
db: Session = Depends(get_db),
admin=Depends(require_admin),
):
return get_all_settings(db)
@router.post("/settings")
def update_settings(
payload: SettingsUpdate,
db: Session = Depends(get_db),
admin=Depends(require_admin),
):
changed = []
data = payload.model_dump(exclude_unset=True)
for key, value in data.items():
set_setting(db, key, value)
changed.append(key)
if changed:
log_action(db, admin, "settings_updated", f"Updated: {', '.join(changed)}")
return get_all_settings(db)
+88 -3
View File
@@ -1,8 +1,7 @@
from fastapi import APIRouter, Depends, HTTPException, status, Response
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.orm import Session
from datetime import datetime, timezone
from typing import Optional
from typing import Optional, List
from database import get_db
from auth import (
@@ -12,10 +11,12 @@ from auth import (
create_refresh_token,
verify_token,
get_current_user,
require_admin,
create_default_admin,
log_action,
)
from models import User
from schemas import UserCreate, UserOut, LoginPayload, Token, TokenRefresh
from schemas import UserCreate, UserOut, LoginPayload, Token, TokenRefresh, UserUpdate, UserListOut
router = APIRouter(prefix="/api/auth", tags=["auth"])
@@ -87,3 +88,87 @@ def me(user: User = Depends(get_current_user)):
def logout(response: Response):
response.delete_cookie("access_token")
return {"ok": True}
# Admin user management
@router.get("/users", response_model=List[UserListOut])
def list_users(
db: Session = Depends(get_db),
admin: User = Depends(require_admin),
):
return db.query(User).order_by(User.username).all()
@router.post("/users", response_model=UserOut, status_code=status.HTTP_201_CREATED)
def create_user(
payload: UserCreate,
db: Session = Depends(get_db),
admin: User = Depends(require_admin),
):
existing = db.query(User).filter(User.username == payload.username).first()
if existing:
raise HTTPException(status_code=400, detail="Username already exists")
user = User(
username=payload.username,
password_hash=get_password_hash(payload.password),
role="viewer",
)
db.add(user)
db.commit()
db.refresh(user)
log_action(db, admin, "user_created", f"Created user {user.username} (id={user.id})")
return user
@router.patch("/users/{user_id}", response_model=UserOut)
def update_user(
user_id: int,
payload: UserUpdate,
db: Session = Depends(get_db),
admin: User = Depends(require_admin),
):
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
if user.id == admin.id and payload.role and payload.role != "admin":
raise HTTPException(status_code=400, detail="Cannot downgrade yourself")
if payload.role:
if payload.role not in ("admin", "viewer"):
raise HTTPException(status_code=400, detail="Invalid role")
user.role = payload.role
if payload.password:
user.password_hash = get_password_hash(payload.password)
db.commit()
db.refresh(user)
log_action(db, admin, "user_updated", f"Updated user {user.username} (id={user.id}), role={user.role}, password_changed={bool(payload.password)}")
return user
@router.delete("/users/{user_id}")
def delete_user(
user_id: int,
db: Session = Depends(get_db),
admin: User = Depends(require_admin),
):
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
if user.id == admin.id:
raise HTTPException(status_code=400, detail="Cannot delete yourself")
# prevent deleting last admin
if user.role == "admin":
admin_count = db.query(User).filter(User.role == "admin").count()
if admin_count <= 1:
raise HTTPException(status_code=400, detail="Cannot delete the last admin")
username = user.username
db.delete(user)
db.commit()
log_action(db, admin, "user_deleted", f"Deleted user {username} (id={user_id})")
return {"ok": True}
+72 -4
View File
@@ -7,9 +7,10 @@ from datetime import datetime, timezone, timedelta
from typing import Optional
from database import get_db
from auth import verify_token
from models import Computer, Heartbeat, User, Notification
from auth import verify_token, require_admin
from models import Computer, Heartbeat, User, Notification, AuditLog, Setting
from notifications import check_offline_computers, get_unread_count
from settings_store import get_all_settings
router = APIRouter()
templates = Jinja2Templates(directory="templates")
@@ -25,6 +26,10 @@ def user_or_redirect(request: Request, db: Session):
return db.query(User).filter(User.username == payload["sub"]).first()
def has_admin(db: Session) -> bool:
return db.query(User).filter(User.role == "admin").count() > 0
@router.get("/", response_class=HTMLResponse)
def index(
request: Request,
@@ -85,8 +90,10 @@ def index(
@router.get("/login", response_class=HTMLResponse)
def login_page(request: Request):
return templates.TemplateResponse("login.html", {"request": request, "error": None})
def login_page(request: Request, db: Session = Depends(get_db)):
# Allow public registration only while no admin exists
registration_open = not has_admin(db)
return templates.TemplateResponse("login.html", {"request": request, "error": None, "registration_open": registration_open})
@router.get("/computers/{computer_id}", response_class=HTMLResponse)
@@ -153,3 +160,64 @@ def notifications_page(request: Request, db: Session = Depends(get_db)):
"unread_count": get_unread_count(db),
},
)
# Admin pages
@router.get("/admin/users", response_class=HTMLResponse)
def admin_users_page(request: Request, db: Session = Depends(get_db)):
current_user = user_or_redirect(request, db)
if not current_user or current_user.role != "admin":
return RedirectResponse(url="/login")
users = db.query(User).order_by(User.username).all()
return templates.TemplateResponse(
"admin_users.html",
{
"request": request,
"user": current_user,
"users": users,
"unread_count": get_unread_count(db),
},
)
@router.get("/admin/audit", response_class=HTMLResponse)
def admin_audit_page(request: Request, db: Session = Depends(get_db)):
current_user = user_or_redirect(request, db)
if not current_user or current_user.role != "admin":
return RedirectResponse(url="/login")
audit = (
db.query(AuditLog)
.order_by(desc(AuditLog.timestamp))
.limit(200)
.all()
)
return templates.TemplateResponse(
"admin_audit.html",
{
"request": request,
"user": current_user,
"audit": audit,
"unread_count": get_unread_count(db),
},
)
@router.get("/admin/settings", response_class=HTMLResponse)
def admin_settings_page(request: Request, db: Session = Depends(get_db)):
current_user = user_or_redirect(request, db)
if not current_user or current_user.role != "admin":
return RedirectResponse(url="/login")
settings = get_all_settings(db)
return templates.TemplateResponse(
"admin_settings.html",
{
"request": request,
"user": current_user,
"settings": settings,
"unread_count": get_unread_count(db),
},
)
+38
View File
@@ -17,6 +17,20 @@ class UserOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
class UserUpdate(BaseModel):
role: Optional[str] = None
password: Optional[str] = None
class UserListOut(BaseModel):
id: int
username: str
role: str
created_at: Optional[datetime]
model_config = ConfigDict(from_attributes=True)
class Token(BaseModel):
access_token: str
refresh_token: str
@@ -90,3 +104,27 @@ class NotificationOut(BaseModel):
read_at: Optional[datetime]
model_config = ConfigDict(from_attributes=True)
class AuditLogOut(BaseModel):
id: int
user_id: Optional[int]
username: Optional[str]
action: str
details: Optional[str]
timestamp: Optional[datetime]
model_config = ConfigDict(from_attributes=True)
class SettingOut(BaseModel):
key: str
value: Optional[str]
class SettingsUpdate(BaseModel):
AGENT_TOKEN: Optional[str] = None
OFFLINE_THRESHOLD_MINUTES: Optional[int] = None
NOTIFICATION_WEBHOOK_URL: Optional[str] = None
NOTIFICATION_TELEGRAM_BOT_TOKEN: Optional[str] = None
NOTIFICATION_TELEGRAM_CHAT_ID: Optional[str] = None
+52
View File
@@ -0,0 +1,52 @@
from typing import Optional
from sqlalchemy.orm import Session
from models import Setting
from config import get_settings
_settings_cache = {}
def get_setting(db: Session, key: str) -> Optional[str]:
"""Get setting from DB, fallback to .env config."""
s = db.query(Setting).filter(Setting.key == key).first()
if s:
return s.value
config = get_settings()
return getattr(config, key, None)
def set_setting(db: Session, key: str, value: Optional[str]) -> Setting:
s = db.query(Setting).filter(Setting.key == key).first()
if s:
s.value = value
else:
s = Setting(key=key, value=value)
db.add(s)
db.commit()
db.refresh(s)
_settings_cache[key] = value
return s
def get_all_settings(db: Session) -> dict:
"""Return merged DB settings and .env defaults."""
config = get_settings()
result = {}
keys = [
"AGENT_TOKEN",
"OFFLINE_THRESHOLD_MINUTES",
"NOTIFICATION_WEBHOOK_URL",
"NOTIFICATION_TELEGRAM_BOT_TOKEN",
"NOTIFICATION_TELEGRAM_CHAT_ID",
]
for key in keys:
db_val = db.query(Setting).filter(Setting.key == key).first()
value = db_val.value if db_val else getattr(config, key, None)
if key == "OFFLINE_THRESHOLD_MINUTES" and value is not None:
try:
value = int(value)
except (TypeError, ValueError):
value = getattr(config, key, None)
result[key] = value
return result
+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 %}