SysOps: approved_backup — 2026-08-16 11:01 UTC
This commit is contained in:
@@ -8,7 +8,7 @@ from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
|
||||
from fastapi import Request, APIRouter, File, Form, HTTPException, UploadFile
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.config import settings
|
||||
@@ -1019,14 +1019,17 @@ async def ai_generate_post_draft(body: AIPostDraftBody):
|
||||
|
||||
|
||||
@herman_api.post("/chat")
|
||||
async def herman_chat_api(body: HermanChatBody):
|
||||
async def herman_chat_api(request: Request, body: HermanChatBody):
|
||||
if not body.message.strip():
|
||||
raise HTTPException(400, "message required")
|
||||
user = getattr(request.state, "user", None) or {}
|
||||
result = await herman_service.chat(
|
||||
body.message.strip(),
|
||||
channel=(body.channel or "cockpit").strip(),
|
||||
session_id=body.session_id,
|
||||
confirm_action_id=body.confirm_action_id,
|
||||
username=(user.get("username") if isinstance(user, dict) else None),
|
||||
role=(user.get("role") if isinstance(user, dict) else None),
|
||||
)
|
||||
return {"ok": True, **result}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Form, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from app.db import execute, fetch_all
|
||||
@@ -22,7 +22,7 @@ AGENT_STATUSES = [
|
||||
@router.get("")
|
||||
async def agents_page(request: Request):
|
||||
active_tab = request.query_params.get("tab", "souls")
|
||||
if active_tab not in {"souls", "mesh", "terminals", "approvals"}:
|
||||
if active_tab not in {"souls", "mesh", "terminals", "approvals", "warroom"}:
|
||||
active_tab = "souls"
|
||||
events: list = []
|
||||
try:
|
||||
@@ -52,6 +52,14 @@ async def agents_page(request: Request):
|
||||
)
|
||||
|
||||
|
||||
|
||||
@router.get("/warroom/popup", response_class=HTMLResponse)
|
||||
async def agents_warroom_popup(request: Request):
|
||||
return templates.TemplateResponse(
|
||||
"agents_warroom_popup.html",
|
||||
{"request": request, "page_title": "War Room"},
|
||||
)
|
||||
|
||||
@router.post("/approve/{event_id}")
|
||||
async def approve_event(event_id: int, next_url: str = Form("/")):
|
||||
try:
|
||||
|
||||
@@ -5,7 +5,7 @@ import asyncio
|
||||
import json
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi import File, UploadFile, APIRouter, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
@@ -545,3 +545,121 @@ def api_execute_approved_request(request_id: int, body: ExecuteBody) -> dict[str
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
return {"ok": True, "request": req}
|
||||
|
||||
|
||||
# --- War Room chat ---
|
||||
class WarroomMessageBody(BaseModel):
|
||||
content: str = Field("", max_length=8000)
|
||||
attachments: list[dict] = Field(default_factory=list)
|
||||
|
||||
|
||||
@router.get("/warroom/rooms")
|
||||
async def warroom_list_rooms() -> dict[str, Any]:
|
||||
from app.services import agent_chat
|
||||
agent_chat.ensure_warroom()
|
||||
return {"items": agent_chat.list_rooms()}
|
||||
|
||||
|
||||
@router.post("/warroom/rooms/dm/{agent_key}")
|
||||
async def warroom_open_dm(agent_key: str) -> dict[str, Any]:
|
||||
from app.services import agent_chat
|
||||
try:
|
||||
room = agent_chat.get_or_create_dm(agent_key)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(404, str(exc)) from exc
|
||||
return {"room": room}
|
||||
|
||||
|
||||
@router.get("/warroom/rooms/{room_id}/messages")
|
||||
async def warroom_list_messages(room_id: int, limit: int = 100, after_id: int | None = None) -> dict[str, Any]:
|
||||
from app.services import agent_chat
|
||||
room = agent_chat.get_room(room_id)
|
||||
if not room:
|
||||
raise HTTPException(404, "Room niet gevonden")
|
||||
return {"room": room, "items": agent_chat.list_messages(room_id, limit=min(limit, 200), after_id=after_id)}
|
||||
|
||||
|
||||
@router.post("/warroom/rooms/{room_id}/messages")
|
||||
async def warroom_post_message(request: Request, room_id: int, body: WarroomMessageBody) -> dict[str, Any]:
|
||||
from app.services import agent_chat
|
||||
user = getattr(request.state, "user", None) or {}
|
||||
try:
|
||||
result = await agent_chat.post_user_message(
|
||||
room_id,
|
||||
body.content,
|
||||
username=(user.get("username") if isinstance(user, dict) else None),
|
||||
role=(user.get("role") if isinstance(user, dict) else None),
|
||||
sender_avatar_url=(user.get("avatar_url") if isinstance(user, dict) else None),
|
||||
attachments=body.attachments,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc)) from exc
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/warroom/rooms/{room_id}/messages/stream")
|
||||
async def warroom_post_message_stream(request: Request, room_id: int, body: WarroomMessageBody):
|
||||
"""SSE stream: user → typing tokens → agent message(s) → done."""
|
||||
import json
|
||||
from app.services import agent_chat
|
||||
|
||||
user = getattr(request.state, "user", None) or {}
|
||||
|
||||
async def gen():
|
||||
try:
|
||||
async for ev in agent_chat.post_user_message_stream(
|
||||
room_id,
|
||||
body.content,
|
||||
username=(user.get("username") if isinstance(user, dict) else None),
|
||||
role=(user.get("role") if isinstance(user, dict) else None),
|
||||
sender_avatar_url=(user.get("avatar_url") if isinstance(user, dict) else None),
|
||||
attachments=body.attachments,
|
||||
):
|
||||
yield f"data: {json.dumps(ev, default=str)}\n\n"
|
||||
except ValueError as exc:
|
||||
yield f"data: {json.dumps({'event': 'error', 'detail': str(exc)})}\n\n"
|
||||
except Exception as exc:
|
||||
yield f"data: {json.dumps({'event': 'error', 'detail': str(exc)})}\n\n"
|
||||
|
||||
return StreamingResponse(
|
||||
gen(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/warroom/upload")
|
||||
async def warroom_upload(file: UploadFile = File(...)) -> dict[str, Any]:
|
||||
"""Upload a file to share in War Room (images/docs)."""
|
||||
from pathlib import Path as P
|
||||
import uuid
|
||||
import re
|
||||
|
||||
raw_name = (file.filename or "upload.bin").strip()
|
||||
safe = re.sub(r"[^a-zA-Z0-9._-]+", "_", raw_name)[:120] or "upload.bin"
|
||||
ext = P(safe).suffix.lower()
|
||||
allowed = {
|
||||
".png", ".jpg", ".jpeg", ".gif", ".webp", ".pdf", ".txt", ".csv",
|
||||
".xlsx", ".xls", ".doc", ".docx", ".zip", ".json", ".md",
|
||||
}
|
||||
if ext not in allowed:
|
||||
raise HTTPException(400, f"Bestandstype niet toegestaan: {ext or '(geen)'}")
|
||||
data = await file.read()
|
||||
if len(data) > 12 * 1024 * 1024:
|
||||
raise HTTPException(400, "Max 12MB per bestand")
|
||||
dest_dir = P(__file__).resolve().parent.parent.parent / "static" / "uploads" / "warroom"
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
fname = f"{uuid.uuid4().hex[:12]}_{safe}"
|
||||
(dest_dir / fname).write_bytes(data)
|
||||
url = f"/static/uploads/warroom/{fname}"
|
||||
return {
|
||||
"ok": True,
|
||||
"url": url,
|
||||
"name": raw_name[:180],
|
||||
"mime": file.content_type or "application/octet-stream",
|
||||
"size": len(data),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
"""Login / logout / me + user admin API."""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import uuid
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, File, HTTPException, Request, UploadFile
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app import auth_users
|
||||
|
||||
router = APIRouter(tags=["auth"])
|
||||
api_router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
users_api = APIRouter(prefix="/api/settings/users", tags=["users"])
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent.parent
|
||||
templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
|
||||
|
||||
AVATAR_DIR = BASE_DIR / "static" / "uploads" / "avatars"
|
||||
AVATAR_DIR.mkdir(parents=True, exist_ok=True)
|
||||
ALLOWED_AVATAR = {".jpg", ".jpeg", ".png", ".webp", ".gif"}
|
||||
|
||||
|
||||
async def _save_avatar_file(user_id: int, file: UploadFile) -> str:
|
||||
raw_name = (file.filename or "avatar.jpg").lower()
|
||||
ext = Path(raw_name).suffix
|
||||
if ext not in ALLOWED_AVATAR:
|
||||
raise HTTPException(status_code=400, detail="Alleen JPG, PNG, WEBP of GIF")
|
||||
data = await file.read()
|
||||
if len(data) > 3 * 1024 * 1024:
|
||||
raise HTTPException(status_code=400, detail="Max 3 MB")
|
||||
# strip old files for this user
|
||||
for old in AVATAR_DIR.glob(f"user_{user_id}_*"):
|
||||
try:
|
||||
old.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
fname = f"user_{user_id}_{uuid.uuid4().hex[:10]}{ext}"
|
||||
path = AVATAR_DIR / fname
|
||||
path.write_bytes(data)
|
||||
return f"/static/uploads/avatars/{fname}"
|
||||
|
||||
|
||||
|
||||
class LoginBody(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class UserCreateBody(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
display_name: Optional[str] = None
|
||||
role: str = "user"
|
||||
permissions: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class UserUpdateBody(BaseModel):
|
||||
display_name: Optional[str] = None
|
||||
role: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
password: Optional[str] = None
|
||||
permissions: Optional[dict[str, Any]] = None
|
||||
|
||||
|
||||
def _session_user(request: Request) -> dict[str, Any] | None:
|
||||
return getattr(request.state, "user", None)
|
||||
|
||||
|
||||
@router.get("/login", response_class=HTMLResponse)
|
||||
async def login_page(request: Request, next: str = "/"):
|
||||
if _session_user(request):
|
||||
return RedirectResponse(next or "/", status_code=303)
|
||||
return templates.TemplateResponse(
|
||||
"login.html",
|
||||
{"request": request, "page_title": "Inloggen", "next": next or "/", "error": None},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
async def login_submit(request: Request):
|
||||
form = await request.form()
|
||||
username = str(form.get("username") or "").strip()
|
||||
password = str(form.get("password") or "")
|
||||
next_url = str(form.get("next") or "/")
|
||||
if not next_url.startswith("/"):
|
||||
next_url = "/"
|
||||
user = auth_users.authenticate(username, password)
|
||||
if not user:
|
||||
return templates.TemplateResponse(
|
||||
"login.html",
|
||||
{
|
||||
"request": request,
|
||||
"page_title": "Inloggen",
|
||||
"next": next_url,
|
||||
"error": "Ongeldige gebruikersnaam of wachtwoord",
|
||||
"username": username,
|
||||
},
|
||||
status_code=401,
|
||||
)
|
||||
request.session.clear()
|
||||
request.session["user_id"] = user["id"]
|
||||
request.session["username"] = user["username"]
|
||||
return RedirectResponse(next_url, status_code=303)
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
@router.get("/logout")
|
||||
async def logout(request: Request):
|
||||
request.session.clear()
|
||||
return RedirectResponse("/login", status_code=303)
|
||||
|
||||
|
||||
@api_router.post("/login")
|
||||
async def api_login(request: Request, body: LoginBody):
|
||||
user = auth_users.authenticate(body.username, body.password)
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Ongeldige login")
|
||||
request.session.clear()
|
||||
request.session["user_id"] = user["id"]
|
||||
request.session["username"] = user["username"]
|
||||
return {"ok": True, "user": user}
|
||||
|
||||
|
||||
@api_router.post("/logout")
|
||||
async def api_logout(request: Request):
|
||||
request.session.clear()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@api_router.get("/me")
|
||||
async def api_me(request: Request):
|
||||
user = _session_user(request)
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Niet ingelogd")
|
||||
return {
|
||||
"user": user,
|
||||
"pages": auth_users.PAGE_CATALOG,
|
||||
"can_manage_users": auth_users.can_manage_users(user),
|
||||
}
|
||||
|
||||
|
||||
def _require_user_admin(request: Request) -> dict[str, Any]:
|
||||
user = _session_user(request)
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Niet ingelogd")
|
||||
if not auth_users.can_manage_users(user):
|
||||
raise HTTPException(status_code=403, detail="Geen rechten om gebruikers te beheren")
|
||||
return user
|
||||
|
||||
|
||||
@users_api.get("")
|
||||
async def users_list(request: Request):
|
||||
_require_user_admin(request)
|
||||
return {"users": auth_users.list_users(), "pages": auth_users.PAGE_CATALOG}
|
||||
|
||||
|
||||
@users_api.post("")
|
||||
async def users_create(request: Request, body: UserCreateBody):
|
||||
_require_user_admin(request)
|
||||
try:
|
||||
user = auth_users.create_user(
|
||||
body.username, body.password, body.display_name, body.role, body.permissions or {}
|
||||
)
|
||||
except Exception as e:
|
||||
msg = str(e)
|
||||
if "unique" in msg.lower() or "duplicate" in msg.lower():
|
||||
raise HTTPException(status_code=400, detail="Gebruikersnaam bestaat al") from e
|
||||
raise HTTPException(status_code=400, detail=msg) from e
|
||||
return {"user": user}
|
||||
|
||||
|
||||
@users_api.put("/{user_id}")
|
||||
async def users_update(request: Request, user_id: int, body: UserUpdateBody):
|
||||
_require_user_admin(request)
|
||||
try:
|
||||
user = auth_users.update_user(
|
||||
user_id,
|
||||
display_name=body.display_name,
|
||||
role=body.role,
|
||||
is_active=body.is_active,
|
||||
permissions=body.permissions,
|
||||
password=body.password or None,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
return {"user": user}
|
||||
|
||||
|
||||
@users_api.delete("/{user_id}")
|
||||
async def users_delete(request: Request, user_id: int):
|
||||
actor = _require_user_admin(request)
|
||||
try:
|
||||
auth_users.delete_user(user_id, actor_id=actor["id"])
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
return {"ok": True}
|
||||
|
||||
@api_router.post("/me/avatar")
|
||||
async def upload_my_avatar(request: Request, file: UploadFile = File(...)):
|
||||
user = _session_user(request)
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Niet ingelogd")
|
||||
url = await _save_avatar_file(user["id"], file)
|
||||
updated = auth_users.set_avatar(user["id"], url)
|
||||
request.session["username"] = updated["username"]
|
||||
return {"user": updated, "avatar_url": url}
|
||||
|
||||
|
||||
@users_api.post("/{user_id}/avatar")
|
||||
async def upload_user_avatar(request: Request, user_id: int, file: UploadFile = File(...)):
|
||||
_require_user_admin(request)
|
||||
url = await _save_avatar_file(user_id, file)
|
||||
updated = auth_users.set_avatar(user_id, url)
|
||||
return {"user": updated, "avatar_url": url}
|
||||
|
||||
|
||||
@users_api.delete("/{user_id}/avatar")
|
||||
async def delete_user_avatar(request: Request, user_id: int):
|
||||
actor = _require_user_admin(request)
|
||||
for old in AVATAR_DIR.glob(f"user_{user_id}_*"):
|
||||
try:
|
||||
old.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
updated = auth_users.set_avatar(user_id, None)
|
||||
return {"user": updated}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from pathlib import Path
|
||||
|
||||
from app import auth_users
|
||||
|
||||
router = APIRouter(tags=["settings"])
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent.parent
|
||||
@@ -10,15 +13,20 @@ templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
|
||||
|
||||
@router.get("/settings")
|
||||
async def settings_page(request: Request, tab: str = "email"):
|
||||
allowed = ("email", "general", "permissions", "social")
|
||||
allowed = ("email", "general", "permissions", "social", "users")
|
||||
active = tab if tab in allowed else "email"
|
||||
if tab == "ai":
|
||||
active = "email"
|
||||
user = getattr(request.state, "user", None)
|
||||
if active == "users" and not auth_users.can_manage_users(user):
|
||||
return RedirectResponse("/settings?tab=email", status_code=303)
|
||||
return templates.TemplateResponse(
|
||||
"settings.html",
|
||||
{
|
||||
"request": request,
|
||||
"page_title": "Settings",
|
||||
"active_tab": active,
|
||||
"can_manage_users": auth_users.can_manage_users(user),
|
||||
"current_user": user,
|
||||
},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user