Phase 4: backups w/ volumes, notifications, settings & users, audit page (0.4.0)

- Backup/restore: per-stack tar.gz incl. named-volume snapshots (helper
  container), upload restore with rename/overwrite/conflict detection.
- Notifications: ntfy/Discord/Slack/Gotify/generic webhooks, per-event
  subscriptions; wired into the update checker and stack lifecycle.
- Settings page: update-check interval, webhook CRUD + test, user management
  (with last-admin safeguards).
- Audit log page (searchable, paginated).
- Mobile-responsive sidebar/layout.

Multi-host agents and remote backup destinations (SFTP/S3) deferred.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
menzelj
2026-06-07 20:58:05 +00:00
co-authored by Claude Opus 4.8
parent 22d9864436
commit 8d19b09abd
30 changed files with 2034 additions and 71 deletions
+111 -1
View File
@@ -5,7 +5,7 @@ import time
from collections import defaultdict, deque
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlmodel import Session
from sqlmodel import Session, select
import auth as auth_mod
from database import get_session
@@ -16,6 +16,7 @@ from models.user import (
User,
UserCreate,
UserRead,
UserUpdate,
)
from services import audit_service
@@ -109,3 +110,112 @@ def refresh(
@router.get("/me", response_model=UserRead)
def me(user: User = Depends(auth_mod.get_current_user)) -> User:
return user
# --------------------------------------------------------------------------- #
# User management (admin only)
# --------------------------------------------------------------------------- #
def _ip(request: Request) -> str:
return request.client.host if request.client else "unknown"
@router.get("/users", response_model=list[UserRead])
def list_users(
session: Session = Depends(get_session),
_admin: User = Depends(auth_mod.require_admin),
) -> list[User]:
return session.exec(select(User).order_by(User.id)).all()
@router.post("/users", response_model=UserRead, status_code=201)
def create_user(
body: UserCreate,
request: Request,
session: Session = Depends(get_session),
admin: User = Depends(auth_mod.require_admin),
) -> User:
if not body.username.strip() or not body.password:
raise HTTPException(status_code=400, detail="Username and password required")
if auth_mod.get_user(session, body.username):
raise HTTPException(status_code=409, detail="Username already exists")
role = body.role if body.role in ("admin", "user") else "user"
user = User(
username=body.username,
hashed_password=auth_mod.hash_password(body.password),
role=role,
)
session.add(user)
session.commit()
session.refresh(user)
audit_service.record(
session, user=admin.username, action="user.create", target=user.username,
detail=f"role={role}", ip=_ip(request),
)
return user
@router.patch("/users/{user_id}", response_model=UserRead)
def update_user(
user_id: int,
body: UserUpdate,
request: Request,
session: Session = Depends(get_session),
admin: User = Depends(auth_mod.require_admin),
) -> User:
user = session.get(User, user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Guard against locking yourself out / demoting the last admin.
demoting = (body.role is not None and body.role != "admin") or body.is_active is False
if user.role == "admin" and demoting:
other_admins = session.exec(
select(User).where(User.role == "admin", User.is_active == True, User.id != user_id) # noqa: E712
).first()
if not other_admins:
raise HTTPException(status_code=400, detail="Cannot demote or disable the last active admin")
if body.password:
user.hashed_password = auth_mod.hash_password(body.password)
if body.role is not None:
if body.role not in ("admin", "user"):
raise HTTPException(status_code=400, detail="Invalid role")
user.role = body.role
if body.is_active is not None:
user.is_active = body.is_active
session.add(user)
session.commit()
session.refresh(user)
audit_service.record(
session, user=admin.username, action="user.update", target=user.username,
ip=_ip(request),
)
return user
@router.delete("/users/{user_id}")
def delete_user(
user_id: int,
request: Request,
session: Session = Depends(get_session),
admin: User = Depends(auth_mod.require_admin),
) -> dict:
user = session.get(User, user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
if user.id == admin.id:
raise HTTPException(status_code=400, detail="You cannot delete your own account")
if user.role == "admin":
other_admins = session.exec(
select(User).where(User.role == "admin", User.is_active == True, User.id != user_id) # noqa: E712
).first()
if not other_admins:
raise HTTPException(status_code=400, detail="Cannot delete the last active admin")
username = user.username
session.delete(user)
session.commit()
audit_service.record(
session, user=admin.username, action="user.delete", target=username,
ip=_ip(request),
)
return {"ok": True}