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:
co-authored by
Claude Opus 4.8
parent
22d9864436
commit
8d19b09abd
+111
-1
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user