feat: User-Verwaltung mit Login, Sessions und Owner-Filter

Backend:
- User-Modell (sqlmodel) mit bcrypt-Passwort-Hash und is_admin-Flag
- Job.owner_id (FK auf user.id), per Migration nachgerüstet (SQLite ALTER)
- Starlette SessionMiddleware mit signiertem Cookie (SESSION_SECRET)
- /api/auth: login / logout / me / needs-setup / setup (Initial-Admin)
- /api/admin: User-CRUD + Passwort-Reset (Admin-only); letzter Admin /
  eigener Account vor Löschung geschützt
- /api/jobs: Owner-Filter (Admin sieht alle inkl. owner_username),
  404 statt 403 bei fremden Jobs (kein Existenz-Leak)

Frontend:
- Auth-Overlay mit Login bzw. Erst-Setup-Modus (auto-Detection)
- Header zeigt User + Logout, Admin-Badge bei Admin-Konten
- Admin-Karte: User anlegen, Passwort zurücksetzen, Löschen (mit Confirm)
- 401 → Login-Overlay (auch bei XHR-Upload)

Deploy:
- install.sh generiert SESSION_SECRET einmalig per python-secrets
- .env wird auf chmod 600 gesetzt
- Erstaufruf zeigt automatisch das Setup-Formular; bestehende Jobs werden
  dem ersten Admin zugeordnet
This commit is contained in:
root 2026-05-14 02:38:52 +00:00
parent d4b0d21ed1
commit a1167df4f1
16 changed files with 760 additions and 47 deletions

View File

@ -39,6 +39,18 @@ if [ ! -f "$APP_DIR/lxc-frontend/.env" ]; then
cp "$APP_DIR/lxc-frontend/.env.example" "$APP_DIR/lxc-frontend/.env" cp "$APP_DIR/lxc-frontend/.env.example" "$APP_DIR/lxc-frontend/.env"
chown "$APP_USER:$APP_USER" "$APP_DIR/lxc-frontend/.env" chown "$APP_USER:$APP_USER" "$APP_DIR/lxc-frontend/.env"
fi fi
chmod 600 "$APP_DIR/lxc-frontend/.env"
# SESSION_SECRET einmalig generieren, falls noch Platzhalter drinsteht
if grep -q "^SESSION_SECRET=CHANGE-ME-SET-IN-ENV" "$APP_DIR/lxc-frontend/.env"; then
SECRET="$(python3 -c 'import secrets; print(secrets.token_urlsafe(48))')"
sed -i "s|^SESSION_SECRET=CHANGE-ME-SET-IN-ENV|SESSION_SECRET=${SECRET}|" "$APP_DIR/lxc-frontend/.env"
echo " SESSION_SECRET wurde generiert."
elif ! grep -q "^SESSION_SECRET=" "$APP_DIR/lxc-frontend/.env"; then
SECRET="$(python3 -c 'import secrets; print(secrets.token_urlsafe(48))')"
echo "SESSION_SECRET=${SECRET}" >> "$APP_DIR/lxc-frontend/.env"
echo " SESSION_SECRET nachgetragen."
fi
mkdir -p /var/lib/voice-agent/uploads /var/lib/voice-agent/results mkdir -p /var/lib/voice-agent/uploads /var/lib/voice-agent/results
chown -R "$APP_USER:$APP_USER" /var/lib/voice-agent chown -R "$APP_USER:$APP_USER" /var/lib/voice-agent
@ -60,5 +72,8 @@ echo
echo "Fertig. Health-Check:" echo "Fertig. Health-Check:"
echo " curl -s http://localhost/health" echo " curl -s http://localhost/health"
echo echo
echo "Nach erstem Start unbedingt MAC_API_URL in $APP_DIR/lxc-frontend/.env setzen und Service neu starten:" echo "Beim ersten Aufruf im Browser: Initialer Admin wird angelegt — bestehende Jobs"
echo " sudo systemctl restart voice-agent" echo "werden diesem Account automatisch zugeordnet."
echo
echo "Falls noch nicht geschehen: MAC_API_URL in $APP_DIR/lxc-frontend/.env setzen"
echo "und Service neu starten: sudo systemctl restart voice-agent"

View File

@ -15,3 +15,7 @@ MAC_API_TIMEOUT=1800
# Limits # Limits
MAX_UPLOAD_MB=500 MAX_UPLOAD_MB=500
ALLOWED_EXTS=mp3,wav,m4a,mp4,ogg,flac ALLOWED_EXTS=mp3,wav,m4a,mp4,ogg,flac
# Session-Cookie-Signierung — wird vom install.sh automatisch generiert.
# NIE committen. Bei Wechsel werden alle Sessions invalidiert.
SESSION_SECRET=CHANGE-ME-SET-IN-ENV

View File

@ -0,0 +1,86 @@
"""Admin-Endpoints: User-CRUD. Nur für Admins."""
from __future__ import annotations
import logging
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlmodel import Session, select
from app.core.auth import admin_required, get_user_by_username, hash_password
from app.core.db import get_session
from app.models.user import User
router = APIRouter(prefix="/api/admin", tags=["admin"], dependencies=[Depends(admin_required)])
log = logging.getLogger("voice-agent.admin")
class CreateUserIn(BaseModel):
username: str = Field(min_length=3, max_length=64, pattern=r"^[A-Za-z0-9._@-]+$")
password: str = Field(min_length=8, max_length=200)
is_admin: bool = False
class ResetPasswordIn(BaseModel):
password: str = Field(min_length=8, max_length=200)
def _user_dict(u: User) -> dict:
return {
"id": u.id,
"username": u.username,
"is_admin": u.is_admin,
"created_at": u.created_at.isoformat(),
}
@router.get("/users")
def list_users(session: Session = Depends(get_session)):
users = session.exec(select(User).order_by(User.created_at)).all()
return [_user_dict(u) for u in users]
@router.post("/users", status_code=status.HTTP_201_CREATED)
def create_user(payload: CreateUserIn, session: Session = Depends(get_session)):
if get_user_by_username(session, payload.username.strip()):
raise HTTPException(status.HTTP_409_CONFLICT, f"Benutzer '{payload.username}' existiert bereits")
user = User(
username=payload.username.strip(),
password_hash=hash_password(payload.password),
is_admin=payload.is_admin,
)
session.add(user)
session.commit()
session.refresh(user)
log.info("User angelegt: %s admin=%s", user.username, user.is_admin)
return _user_dict(user)
@router.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_user(user_id: int, session: Session = Depends(get_session), me: User = Depends(admin_required)):
if user_id == me.id:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Eigenen Account kann man nicht löschen")
user = session.get(User, user_id)
if not user:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Benutzer nicht gefunden")
# Sicherheit: mind. ein Admin muss bleiben
if user.is_admin:
admins = session.exec(select(User).where(User.is_admin == True)).all() # noqa: E712
if len(admins) <= 1:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Letzten Admin kann man nicht löschen")
session.delete(user)
session.commit()
log.info("User gelöscht: id=%d username=%s", user_id, user.username)
return None
@router.post("/users/{user_id}/password")
def reset_password(user_id: int, payload: ResetPasswordIn, session: Session = Depends(get_session)):
user = session.get(User, user_id)
if not user:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Benutzer nicht gefunden")
user.password_hash = hash_password(payload.password)
session.add(user)
session.commit()
log.info("Passwort zurückgesetzt: user=%s", user.username)
return {"ok": True}

View File

@ -0,0 +1,83 @@
"""Auth-Endpoints: Login, Logout, Me, Erst-Setup (Initial-Admin)."""
from __future__ import annotations
import logging
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel, Field
from sqlmodel import Session
from app.core import migrate
from app.core.auth import (
current_user,
get_user_by_username,
has_any_user,
hash_password,
verify_password,
)
from app.core.db import get_session
from app.models.user import User
router = APIRouter(prefix="/api/auth", tags=["auth"])
log = logging.getLogger("voice-agent.auth")
class LoginIn(BaseModel):
username: str = Field(min_length=1, max_length=64)
password: str = Field(min_length=1, max_length=200)
class SetupIn(BaseModel):
username: str = Field(min_length=3, max_length=64, pattern=r"^[A-Za-z0-9._@-]+$")
password: str = Field(min_length=8, max_length=200)
def _user_dict(u: User) -> dict:
return {"id": u.id, "username": u.username, "is_admin": u.is_admin}
@router.get("/me")
def me(user: User = Depends(current_user)):
return _user_dict(user)
@router.get("/needs-setup")
def needs_setup(session: Session = Depends(get_session)):
"""Vor Login prüfen ob noch kein User existiert (erstes Deployment)."""
return {"needs_setup": not has_any_user(session)}
@router.post("/login")
def login(payload: LoginIn, request: Request, session: Session = Depends(get_session)):
user = get_user_by_username(session, payload.username.strip())
if not user or not verify_password(payload.password, user.password_hash):
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Benutzername oder Passwort falsch")
request.session["user_id"] = user.id
log.info("Login: user=%s id=%d admin=%s", user.username, user.id, user.is_admin)
return _user_dict(user)
@router.post("/logout")
def logout(request: Request):
request.session.clear()
return {"ok": True}
@router.post("/setup")
def setup(payload: SetupIn, request: Request, session: Session = Depends(get_session)):
"""Erstellt den ersten Admin und ordnet alle Bestand-Jobs ihm zu.
Nur möglich, solange kein User existiert."""
if has_any_user(session):
raise HTTPException(status.HTTP_409_CONFLICT, "Setup bereits abgeschlossen")
user = User(
username=payload.username.strip(),
password_hash=hash_password(payload.password),
is_admin=True,
)
session.add(user)
session.commit()
session.refresh(user)
log.info("Initial-Admin angelegt: %s (id=%d)", user.username, user.id)
n = migrate.assign_orphan_jobs_to(user.id)
request.session["user_id"] = user.id
return {**_user_dict(user), "migrated_jobs": n}

View File

@ -1,5 +1,4 @@
import secrets import secrets
from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
import aiofiles import aiofiles
@ -7,9 +6,11 @@ from fastapi import APIRouter, BackgroundTasks, Depends, File, Form, HTTPExcepti
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from sqlmodel import Session, select from sqlmodel import Session, select
from app.core.auth import current_user
from app.core.config import settings from app.core.config import settings
from app.core.db import get_session from app.core.db import get_session
from app.models.job import Job, JobStatus from app.models.job import Job, JobStatus # noqa: F401
from app.models.user import User
from app.services.pipeline import run_pipeline from app.services.pipeline import run_pipeline
router = APIRouter(prefix="/api/jobs", tags=["jobs"]) router = APIRouter(prefix="/api/jobs", tags=["jobs"])
@ -17,12 +18,20 @@ router = APIRouter(prefix="/api/jobs", tags=["jobs"])
MAX_BYTES = lambda: settings.max_upload_mb * 1024 * 1024 MAX_BYTES = lambda: settings.max_upload_mb * 1024 * 1024
def _require_access(job: Job | None, user: User) -> Job:
"""404 wenn nicht gefunden oder nicht zugänglich (kein Hinweis darauf, dass es existiert)."""
if not job or (job.owner_id != user.id and not user.is_admin):
raise HTTPException(404, "Job nicht gefunden")
return job
@router.post("") @router.post("")
async def create_job( async def create_job(
background: BackgroundTasks, background: BackgroundTasks,
audio: UploadFile = File(...), audio: UploadFile = File(...),
title: str = Form(""), title: str = Form(""),
session: Session = Depends(get_session), session: Session = Depends(get_session),
user: User = Depends(current_user),
): ):
if not audio.filename: if not audio.filename:
raise HTTPException(400, "Dateiname fehlt") raise HTTPException(400, "Dateiname fehlt")
@ -43,34 +52,45 @@ async def create_job(
raise HTTPException(413, f"Datei zu groß (max. {settings.max_upload_mb} MB)") raise HTTPException(413, f"Datei zu groß (max. {settings.max_upload_mb} MB)")
await f.write(chunk) await f.write(chunk)
job = Job(filename=safe, original_name=audio.filename, title=title.strip()) job = Job(
owner_id=user.id,
filename=safe,
original_name=audio.filename,
title=title.strip(),
)
session.add(job) session.add(job)
session.commit() session.commit()
session.refresh(job) session.refresh(job)
background.add_task(run_pipeline, job.id) background.add_task(run_pipeline, job.id)
return _job_dict(job) return _job_dict(job, user)
@router.get("") @router.get("")
def list_jobs(session: Session = Depends(get_session)): def list_jobs(session: Session = Depends(get_session), user: User = Depends(current_user)):
jobs = session.exec(select(Job).order_by(Job.created_at.desc())).all() stmt = select(Job).order_by(Job.created_at.desc())
return [_job_dict(j) for j in jobs] if not user.is_admin:
stmt = stmt.where(Job.owner_id == user.id)
jobs = session.exec(stmt).all()
# Admin sieht zusätzlich Owner-Username — kleine Map für effiziente Anzeige.
owner_names: dict[int, str] = {}
if user.is_admin:
owner_ids = {j.owner_id for j in jobs if j.owner_id is not None}
if owner_ids:
owners = session.exec(select(User).where(User.id.in_(owner_ids))).all() # type: ignore[attr-defined]
owner_names = {u.id: u.username for u in owners}
return [_job_dict(j, user, owner_names.get(j.owner_id) if user.is_admin else None) for j in jobs]
@router.get("/{job_id}") @router.get("/{job_id}")
def get_job(job_id: int, session: Session = Depends(get_session)): def get_job(job_id: int, session: Session = Depends(get_session), user: User = Depends(current_user)):
job = session.get(Job, job_id) job = _require_access(session.get(Job, job_id), user)
if not job: return _job_dict(job, user)
raise HTTPException(404, "Job nicht gefunden")
return _job_dict(job)
@router.get("/{job_id}/download/{kind}") @router.get("/{job_id}/download/{kind}")
def download(job_id: int, kind: str, session: Session = Depends(get_session)): def download(job_id: int, kind: str, session: Session = Depends(get_session), user: User = Depends(current_user)):
job = session.get(Job, job_id) job = _require_access(session.get(Job, job_id), user)
if not job:
raise HTTPException(404, "Job nicht gefunden")
mapping = { mapping = {
"docx": (job.docx_path, "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "protocol.docx"), "docx": (job.docx_path, "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "protocol.docx"),
"transcript": (job.transcript_path, "application/json", "transcript.json"), "transcript": (job.transcript_path, "application/json", "transcript.json"),
@ -86,8 +106,8 @@ def download(job_id: int, kind: str, session: Session = Depends(get_session)):
return FileResponse(path, media_type=media, filename=f"{base}-{name}") return FileResponse(path, media_type=media, filename=f"{base}-{name}")
def _job_dict(j: Job) -> dict: def _job_dict(j: Job, user: User, owner_username: str | None = None) -> dict:
return { d = {
"id": j.id, "id": j.id,
"title": j.title, "title": j.title,
"original_name": j.original_name, "original_name": j.original_name,
@ -103,3 +123,7 @@ def _job_dict(j: Job) -> dict:
"docx": bool(j.docx_path), "docx": bool(j.docx_path),
}, },
} }
if user.is_admin:
d["owner_id"] = j.owner_id
d["owner_username"] = owner_username or ""
return d

View File

@ -0,0 +1,45 @@
"""Auth-Helfer: Passwort-Hashing, current_user-Dependency, Admin-Check."""
from __future__ import annotations
import bcrypt
from fastapi import Depends, HTTPException, Request, status
from sqlmodel import Session, select
from app.core.db import get_session
from app.models.user import User
def hash_password(password: str) -> str:
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt(rounds=12)).decode("utf-8")
def verify_password(password: str, password_hash: str) -> bool:
try:
return bcrypt.checkpw(password.encode("utf-8"), password_hash.encode("utf-8"))
except (ValueError, TypeError):
return False
def current_user(request: Request, session: Session = Depends(get_session)) -> User:
user_id = request.session.get("user_id")
if not user_id:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Nicht angemeldet")
user = session.get(User, user_id)
if not user:
request.session.clear()
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Sitzung ungültig")
return user
def admin_required(user: User = Depends(current_user)) -> User:
if not user.is_admin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin-Rechte erforderlich")
return user
def get_user_by_username(session: Session, username: str) -> User | None:
return session.exec(select(User).where(User.username == username)).first()
def has_any_user(session: Session) -> bool:
return session.exec(select(User).limit(1)).first() is not None

View File

@ -19,6 +19,11 @@ class Settings(BaseSettings):
max_upload_mb: int = 500 max_upload_mb: int = 500
allowed_exts: str = "mp3,wav,m4a,mp4,ogg,flac" allowed_exts: str = "mp3,wav,m4a,mp4,ogg,flac"
# Wird beim Deploy automatisch generiert (siehe install.sh).
session_secret: str = "CHANGE-ME-SET-IN-ENV"
session_cookie_name: str = "voice_agent_session"
session_max_age: int = 60 * 60 * 24 * 14 # 14 Tage
@property @property
def allowed_ext_set(self) -> set[str]: def allowed_ext_set(self) -> set[str]:
return {e.strip().lower().lstrip(".") for e in self.allowed_exts.split(",") if e.strip()} return {e.strip().lower().lstrip(".") for e in self.allowed_exts.split(",") if e.strip()}

View File

@ -6,6 +6,7 @@ engine = create_engine(settings.db_url, connect_args={"check_same_thread": False
def init_db() -> None: def init_db() -> None:
from app.models.job import Job # noqa: F401 from app.models.job import Job # noqa: F401
from app.models.user import User # noqa: F401
SQLModel.metadata.create_all(engine) SQLModel.metadata.create_all(engine)

View File

@ -0,0 +1,43 @@
"""Leichte In-Place-Migrationen für SQLite (kein Alembic).
Wird beim App-Start nach create_all() ausgeführt. Idempotent.
"""
from __future__ import annotations
import logging
from sqlalchemy import text
from sqlmodel import Session
from app.core.db import engine
log = logging.getLogger("voice-agent.migrate")
def _existing_columns(conn, table: str) -> set[str]:
rows = conn.execute(text(f"PRAGMA table_info({table})")).all()
return {r[1] for r in rows} # row[1] = column name
def run_migrations() -> None:
with engine.begin() as conn:
# 1) Job.owner_id Spalte nachrüsten, falls die Tabelle aus alter Version kommt.
cols = _existing_columns(conn, "job")
if "owner_id" not in cols:
log.info("Migration: füge job.owner_id hinzu")
conn.execute(text("ALTER TABLE job ADD COLUMN owner_id INTEGER"))
conn.execute(text("CREATE INDEX IF NOT EXISTS ix_job_owner_id ON job(owner_id)"))
def assign_orphan_jobs_to(user_id: int) -> int:
"""Weist alle Jobs ohne owner_id dem angegebenen User zu. Gibt Anzahl zurück."""
with Session(engine) as session:
result = session.execute(
text("UPDATE job SET owner_id = :uid WHERE owner_id IS NULL"),
{"uid": user_id},
)
session.commit()
n = result.rowcount or 0
if n:
log.info("Migration: %d Bestand-Jobs an user_id=%d zugeordnet", n, user_id)
return n

View File

@ -5,12 +5,16 @@ from pathlib import Path
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.responses import FileResponse, JSONResponse from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from starlette.middleware.sessions import SessionMiddleware
from starlette.types import Scope from starlette.types import Scope
from app.api.admin import router as admin_router
from app.api.auth import router as auth_router
from app.api.jobs import router as jobs_router from app.api.jobs import router as jobs_router
from app.core.config import settings from app.core.config import settings
from app.core.db import init_db from app.core.db import init_db
from app.core.mac_client import MacClient from app.core.mac_client import MacClient
from app.core.migrate import run_migrations
class NoCacheStaticFiles(StaticFiles): class NoCacheStaticFiles(StaticFiles):
@ -21,6 +25,7 @@ class NoCacheStaticFiles(StaticFiles):
response.headers["Cache-Control"] = "no-cache, must-revalidate" response.headers["Cache-Control"] = "no-cache, must-revalidate"
return response return response
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s") logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s")
log = logging.getLogger("voice-agent") log = logging.getLogger("voice-agent")
@ -28,6 +33,9 @@ log = logging.getLogger("voice-agent")
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
init_db() init_db()
run_migrations()
if settings.session_secret == "CHANGE-ME-SET-IN-ENV":
log.warning("SESSION_SECRET nicht gesetzt — bitte in .env auf einen geheimen Wert ändern!")
log.info("DB initialized at %s", settings.db_url) log.info("DB initialized at %s", settings.db_url)
log.info("Upload dir: %s", settings.upload_dir) log.info("Upload dir: %s", settings.upload_dir)
log.info("Result dir: %s", settings.result_dir) log.info("Result dir: %s", settings.result_dir)
@ -36,6 +44,18 @@ async def lifespan(app: FastAPI):
app = FastAPI(title=settings.app_name, lifespan=lifespan) app = FastAPI(title=settings.app_name, lifespan=lifespan)
app.add_middleware(
SessionMiddleware,
secret_key=settings.session_secret,
session_cookie=settings.session_cookie_name,
max_age=settings.session_max_age,
same_site="lax",
https_only=False, # via Reverse-Proxy: ggf. auf True wenn HTTPS terminiert
)
app.include_router(auth_router)
app.include_router(admin_router)
app.include_router(jobs_router) app.include_router(jobs_router)
STATIC_DIR = Path(__file__).resolve().parent / "static" STATIC_DIR = Path(__file__).resolve().parent / "static"

View File

@ -15,6 +15,7 @@ class JobStatus:
class Job(SQLModel, table=True): class Job(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True) id: Optional[int] = Field(default=None, primary_key=True)
owner_id: Optional[int] = Field(default=None, foreign_key="user.id", index=True)
filename: str filename: str
original_name: str original_name: str
title: str = "" title: str = ""

View File

@ -0,0 +1,12 @@
from datetime import datetime, timezone
from typing import Optional
from sqlmodel import Field, SQLModel
class User(SQLModel, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
username: str = Field(index=True, unique=True)
password_hash: str
is_admin: bool = Field(default=False)
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))

View File

@ -1,3 +1,4 @@
// ─── DOM-Refs ──────────────────────────────────────────────────────────────
const form = document.getElementById("upload-form"); const form = document.getElementById("upload-form");
const titleInput = document.getElementById("title"); const titleInput = document.getElementById("title");
const audioInput = document.getElementById("audio"); const audioInput = document.getElementById("audio");
@ -8,6 +9,154 @@ const uploadMsg = document.getElementById("upload-msg");
const jobsBody = document.getElementById("jobs-body"); const jobsBody = document.getElementById("jobs-body");
const macStatus = document.getElementById("mac-status"); const macStatus = document.getElementById("mac-status");
const appMain = document.getElementById("app");
const authOverlay = document.getElementById("auth-overlay");
const authTitle = document.getElementById("auth-title");
const authInfo = document.getElementById("auth-info");
const authForm = document.getElementById("auth-form");
const authUsername = document.getElementById("auth-username");
const authPassword = document.getElementById("auth-password");
const authSubmit = document.getElementById("auth-submit");
const authMsg = document.getElementById("auth-msg");
const userBar = document.getElementById("user-bar");
const userName = document.getElementById("user-name");
const userAdminBadge = document.getElementById("user-admin-badge");
const logoutBtn = document.getElementById("logout-btn");
const adminCard = document.getElementById("admin-card");
const userForm = document.getElementById("user-form");
const newUsername = document.getElementById("new-username");
const newPassword = document.getElementById("new-password");
const newIsAdmin = document.getElementById("new-is-admin");
const userMsg = document.getElementById("user-msg");
const usersBody = document.getElementById("users-body");
let currentUser = null; // { id, username, is_admin }
let mode = "login"; // "login" | "setup"
const DOWNLOADS = [
{ kind: "docx", label: "DOCX", title: "Sitzungsprotokoll als Word-Dokument" },
{ kind: "protocol", label: "Protokoll", title: "Strukturiertes Protokoll (JSON)" },
{ kind: "summary", label: "Summary", title: "Zusammenfassung (JSON)" },
{ kind: "transcript", label: "Transkript", title: "Rohes Transkript (JSON)" },
];
// ─── Helpers ───────────────────────────────────────────────────────────────
function escapeHtml(s) {
return String(s ?? "").replace(/[&<>"']/g, (c) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
}[c]));
}
function fmtDate(s) {
const d = new Date(s);
return d.toLocaleString("de-DE", { dateStyle: "short", timeStyle: "medium" });
}
async function api(path, opts = {}) {
const r = await fetch(path, {
credentials: "same-origin",
headers: { "Content-Type": "application/json", ...(opts.headers || {}) },
...opts,
});
return r;
}
// ─── Auth-Flow ─────────────────────────────────────────────────────────────
async function bootstrap() {
const me = await api("/api/auth/me");
if (me.ok) {
currentUser = await me.json();
enterApp();
return;
}
// Nicht eingeloggt — Setup nötig?
const setupCheck = await api("/api/auth/needs-setup");
const setupData = await setupCheck.json();
if (setupData.needs_setup) {
showAuth("setup");
} else {
showAuth("login");
}
}
function showAuth(which) {
mode = which;
authMsg.textContent = "";
authMsg.className = "msg";
authForm.reset();
if (which === "setup") {
authTitle.textContent = "Initialen Admin anlegen";
authInfo.textContent =
"Noch kein Benutzer vorhanden. Lege jetzt den ersten Admin an. Bestehende Jobs werden diesem Konto zugeordnet.";
authInfo.classList.remove("hidden");
authPassword.autocomplete = "new-password";
authPassword.minLength = 8;
authSubmit.textContent = "Admin anlegen";
} else {
authTitle.textContent = "Anmelden";
authInfo.classList.add("hidden");
authInfo.textContent = "";
authPassword.autocomplete = "current-password";
authPassword.minLength = 1;
authSubmit.textContent = "Anmelden";
}
authOverlay.classList.remove("hidden");
appMain.classList.add("hidden");
userBar.classList.add("hidden");
setTimeout(() => authUsername.focus(), 50);
}
function enterApp() {
authOverlay.classList.add("hidden");
appMain.classList.remove("hidden");
userBar.classList.remove("hidden");
userName.textContent = currentUser.username;
userAdminBadge.classList.toggle("hidden", !currentUser.is_admin);
adminCard.classList.toggle("hidden", !currentUser.is_admin);
loadJobs();
if (currentUser.is_admin) loadUsers();
}
authForm.addEventListener("submit", async (ev) => {
ev.preventDefault();
authSubmit.disabled = true;
authMsg.textContent = "";
authMsg.className = "msg";
const body = JSON.stringify({
username: authUsername.value.trim(),
password: authPassword.value,
});
const url = mode === "setup" ? "/api/auth/setup" : "/api/auth/login";
try {
const r = await api(url, { method: "POST", body });
const data = await r.json();
if (!r.ok) {
authMsg.textContent = data.detail || "Fehler bei der Anmeldung";
authMsg.className = "msg err";
return;
}
currentUser = data;
if (mode === "setup" && typeof data.migrated_jobs === "number" && data.migrated_jobs > 0) {
console.info(`${data.migrated_jobs} Bestand-Jobs wurden zugeordnet.`);
}
enterApp();
} catch (e) {
authMsg.textContent = "Netzwerkfehler";
authMsg.className = "msg err";
} finally {
authSubmit.disabled = false;
}
});
logoutBtn.addEventListener("click", async () => {
await api("/api/auth/logout", { method: "POST" });
currentUser = null;
jobsBody.innerHTML = "";
showAuth("login");
});
// ─── Mac-Status ────────────────────────────────────────────────────────────
async function checkMac() { async function checkMac() {
try { try {
const r = await fetch("/api/mac/health"); const r = await fetch("/api/mac/health");
@ -27,24 +176,7 @@ async function checkMac() {
} }
} }
function fmtDate(s) { // ─── Jobs ──────────────────────────────────────────────────────────────────
const d = new Date(s);
return d.toLocaleString("de-DE", { dateStyle: "short", timeStyle: "medium" });
}
const DOWNLOADS = [
{ kind: "docx", label: "DOCX", title: "Sitzungsprotokoll als Word-Dokument" },
{ kind: "protocol", label: "Protokoll", title: "Strukturiertes Protokoll (JSON)" },
{ kind: "summary", label: "Summary", title: "Zusammenfassung (JSON)" },
{ kind: "transcript", label: "Transkript", title: "Rohes Transkript (JSON)" },
];
function escapeHtml(s) {
return String(s ?? "").replace(/[&<>"']/g, (c) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
}[c]));
}
function row(job) { function row(job) {
const tr = document.createElement("tr"); const tr = document.createElement("tr");
const title = job.title || job.original_name; const title = job.title || job.original_name;
@ -65,6 +197,10 @@ function row(job) {
dlCell.appendChild(a); dlCell.appendChild(a);
}); });
const ownerLine = currentUser?.is_admin && job.owner_username
? `<br><small class="muted">von ${escapeHtml(job.owner_username)}</small>`
: "";
const statusCell = ` const statusCell = `
<span class="status-pill status-${job.status}">${escapeHtml(job.status)}</span> <span class="status-pill status-${job.status}">${escapeHtml(job.status)}</span>
${job.error ? `<div class="status-error" title="${escapeHtml(job.error)}">${escapeHtml(job.error)}</div>` : ""} ${job.error ? `<div class="status-error" title="${escapeHtml(job.error)}">${escapeHtml(job.error)}</div>` : ""}
@ -72,7 +208,7 @@ function row(job) {
tr.innerHTML = ` tr.innerHTML = `
<td class="col-id">#${job.id}</td> <td class="col-id">#${job.id}</td>
<td class="col-title">${escapeHtml(title)}<br><small class="muted">${escapeHtml(job.original_name)}</small></td> <td class="col-title">${escapeHtml(title)}<br><small class="muted">${escapeHtml(job.original_name)}</small>${ownerLine}</td>
<td class="col-status">${statusCell}</td> <td class="col-status">${statusCell}</td>
<td class="col-progress"><div class="bar"><div class="bar-fill" style="width:${job.progress}%"></div></div><small class="muted">${job.progress}%</small></td> <td class="col-progress"><div class="bar"><div class="bar-fill" style="width:${job.progress}%"></div></div><small class="muted">${job.progress}%</small></td>
<td class="col-updated"><small class="muted">${fmtDate(job.updated_at)}</small></td> <td class="col-updated"><small class="muted">${fmtDate(job.updated_at)}</small></td>
@ -82,8 +218,10 @@ function row(job) {
} }
async function loadJobs() { async function loadJobs() {
if (!currentUser) return;
try { try {
const r = await fetch("/api/jobs"); const r = await api("/api/jobs");
if (r.status === 401) { showAuth("login"); return; }
const jobs = await r.json(); const jobs = await r.json();
jobsBody.innerHTML = ""; jobsBody.innerHTML = "";
if (!jobs.length) { if (!jobs.length) {
@ -111,6 +249,7 @@ form.addEventListener("submit", (ev) => {
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();
xhr.open("POST", "/api/jobs"); xhr.open("POST", "/api/jobs");
xhr.withCredentials = true;
xhr.upload.onprogress = (e) => { xhr.upload.onprogress = (e) => {
if (e.lengthComputable) { if (e.lengthComputable) {
const p = Math.round((e.loaded / e.total) * 100); const p = Math.round((e.loaded / e.total) * 100);
@ -120,6 +259,7 @@ form.addEventListener("submit", (ev) => {
xhr.onload = () => { xhr.onload = () => {
submitBtn.disabled = false; submitBtn.disabled = false;
uploadProgress.classList.add("hidden"); uploadProgress.classList.add("hidden");
if (xhr.status === 401) { showAuth("login"); return; }
if (xhr.status >= 200 && xhr.status < 300) { if (xhr.status >= 200 && xhr.status < 300) {
const j = JSON.parse(xhr.responseText); const j = JSON.parse(xhr.responseText);
uploadMsg.textContent = `Job #${j.id} erstellt. Verarbeitung läuft …`; uploadMsg.textContent = `Job #${j.id} erstellt. Verarbeitung läuft …`;
@ -142,7 +282,84 @@ form.addEventListener("submit", (ev) => {
xhr.send(fd); xhr.send(fd);
}); });
// ─── Admin: User-Verwaltung ────────────────────────────────────────────────
async function loadUsers() {
if (!currentUser?.is_admin) return;
try {
const r = await api("/api/admin/users");
if (!r.ok) return;
const users = await r.json();
usersBody.innerHTML = "";
users.forEach((u) => {
const tr = document.createElement("tr");
const isMe = u.id === currentUser.id;
tr.innerHTML = `
<td>#${u.id}</td>
<td><strong>${escapeHtml(u.username)}</strong>${isMe ? ' <span class="muted">(du)</span>' : ""}</td>
<td>${u.is_admin ? '<span class="badge admin-badge">Admin</span>' : '<span class="muted">User</span>'}</td>
<td><small class="muted">${fmtDate(u.created_at)}</small></td>
<td class="user-actions">
<button type="button" class="btn-link reset-pw" data-id="${u.id}" data-name="${escapeHtml(u.username)}">Passwort zurücksetzen</button>
${isMe ? "" : `<button type="button" class="btn-link btn-danger del-user" data-id="${u.id}" data-name="${escapeHtml(u.username)}">Löschen</button>`}
</td>
`;
usersBody.appendChild(tr);
});
} catch (e) {
console.error(e);
}
}
userForm.addEventListener("submit", async (ev) => {
ev.preventDefault();
userMsg.textContent = "";
userMsg.className = "msg";
const body = JSON.stringify({
username: newUsername.value.trim(),
password: newPassword.value,
is_admin: newIsAdmin.checked,
});
const r = await api("/api/admin/users", { method: "POST", body });
const data = await r.json();
if (!r.ok) {
userMsg.textContent = data.detail || "Fehler beim Anlegen";
userMsg.className = "msg err";
return;
}
userMsg.textContent = `Benutzer '${data.username}' angelegt.`;
userMsg.className = "msg ok";
userForm.reset();
loadUsers();
});
usersBody.addEventListener("click", async (ev) => {
const t = ev.target;
if (t.matches(".del-user")) {
if (!confirm(`Benutzer '${t.dataset.name}' wirklich löschen?\nDie Jobs des Benutzers bleiben erhalten (gehören dann keinem mehr).`)) return;
const r = await api(`/api/admin/users/${t.dataset.id}`, { method: "DELETE" });
if (!r.ok) {
const d = await r.json().catch(() => ({}));
alert(d.detail || "Löschen fehlgeschlagen");
}
loadUsers();
} else if (t.matches(".reset-pw")) {
const pw = prompt(`Neues Passwort für '${t.dataset.name}' (min. 8 Zeichen):`);
if (!pw) return;
if (pw.length < 8) { alert("Passwort muss min. 8 Zeichen haben"); return; }
const r = await api(`/api/admin/users/${t.dataset.id}/password`, {
method: "POST", body: JSON.stringify({ password: pw }),
});
if (!r.ok) {
const d = await r.json().catch(() => ({}));
alert(d.detail || "Zurücksetzen fehlgeschlagen");
} else {
alert("Passwort gesetzt.");
}
}
});
// ─── Init ──────────────────────────────────────────────────────────────────
bootstrap();
checkMac(); checkMac();
loadJobs(); setInterval(() => { if (currentUser) loadJobs(); }, 3000);
setInterval(loadJobs, 3000);
setInterval(checkMac, 15000); setInterval(checkMac, 15000);

View File

@ -8,18 +8,50 @@
</head> </head>
<body> <body>
<header> <header>
<div class="header-row">
<div class="header-left">
<h1>Voice-Agent</h1> <h1>Voice-Agent</h1>
<p class="sub">Sitzungsaufnahmen automatisch transkribieren und protokollieren</p> <p class="sub">Sitzungsaufnahmen automatisch transkribieren und protokollieren</p>
</div>
<div class="header-right">
<div id="mac-status" class="badge">Mac-Backend: prüfe …</div> <div id="mac-status" class="badge">Mac-Backend: prüfe …</div>
<div id="user-bar" class="user-bar hidden">
<span class="muted">Angemeldet als</span>
<strong id="user-name"></strong>
<span id="user-admin-badge" class="badge admin-badge hidden">Admin</span>
<button type="button" id="logout-btn" class="btn-link">Abmelden</button>
</div>
</div>
</div>
</header> </header>
<main> <!-- Auth-Overlay (Login oder Erst-Setup) -->
<div id="auth-overlay" class="overlay hidden">
<div class="auth-card">
<h2 id="auth-title">Anmelden</h2>
<p id="auth-info" class="muted hidden"></p>
<form id="auth-form">
<label>
Benutzername
<input type="text" id="auth-username" autocomplete="username" required />
</label>
<label>
Passwort
<input type="password" id="auth-password" autocomplete="current-password" required />
</label>
<button type="submit" id="auth-submit">Anmelden</button>
</form>
<p id="auth-msg" class="msg"></p>
</div>
</div>
<main id="app" class="hidden">
<section class="card"> <section class="card">
<h2>Neue Aufnahme hochladen</h2> <h2>Neue Aufnahme hochladen</h2>
<form id="upload-form"> <form id="upload-form">
<label> <label>
Sitzungstitel (optional) Sitzungstitel (optional)
<input type="text" name="title" id="title" placeholder="z. B. Monatsbesprechung 13.05.2026" /> <input type="text" name="title" id="title" placeholder="z. B. Monatsbesprechung" />
</label> </label>
<label> <label>
Audiodatei (MP3, WAV, M4A, MP4, OGG, FLAC) Audiodatei (MP3, WAV, M4A, MP4, OGG, FLAC)
@ -52,6 +84,39 @@
</tbody> </tbody>
</table> </table>
</section> </section>
<!-- Nur sichtbar für Admins -->
<section id="admin-card" class="card hidden">
<h2>Benutzer verwalten</h2>
<form id="user-form" class="user-form">
<label>
Benutzername
<input type="text" id="new-username" autocomplete="off" required pattern="[A-Za-z0-9._@\-]+" />
</label>
<label>
Passwort (min. 8 Zeichen)
<input type="password" id="new-password" autocomplete="new-password" required minlength="8" />
</label>
<label class="inline-check">
<input type="checkbox" id="new-is-admin" />
Admin-Rechte
</label>
<button type="submit">Anlegen</button>
</form>
<p id="user-msg" class="msg"></p>
<table id="users-table">
<thead>
<tr>
<th>#</th>
<th>Benutzer</th>
<th>Rolle</th>
<th>Angelegt</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody id="users-body"></tbody>
</table>
</section>
</main> </main>
<footer> <footer>

View File

@ -162,3 +162,93 @@ th { color: var(--muted); font-weight: 600; font-size: 12px; text-transform: upp
opacity: .55; opacity: .55;
} }
footer { text-align: center; padding: 24px 16px 32px; } footer { text-align: center; padding: 24px 16px 32px; }
/* ── Header-Layout ─────────────────────────────────────────────── */
.header-row {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
max-width: 1000px;
margin: 0 auto;
}
.header-left { text-align: left; }
.header-right { display: flex; flex-direction: column; align-items: flex-end; gap: 8px; }
.user-bar {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
}
.user-bar strong { color: var(--text); }
.btn-link {
background: transparent;
color: var(--accent);
border: 0;
padding: 2px 6px;
font-size: 13px;
cursor: pointer;
text-decoration: underline;
}
.btn-link:hover { color: var(--accent-hover); }
.btn-link.btn-danger { color: var(--err); }
.admin-badge {
background: rgba(79,140,255,.18);
color: var(--accent);
border-color: rgba(79,140,255,.45);
text-transform: uppercase;
font-size: 11px;
letter-spacing: .5px;
padding: 2px 8px;
}
/* ── Auth-Overlay ──────────────────────────────────────────────── */
.overlay {
position: fixed;
inset: 0;
background: rgba(15, 17, 21, .92);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
padding: 20px;
}
.auth-card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 28px 28px 22px;
width: 100%;
max-width: 380px;
box-shadow: 0 20px 60px rgba(0,0,0,.4);
}
.auth-card h2 { margin: 0 0 8px; font-size: 20px; }
.auth-card #auth-info { font-size: 13px; margin: 0 0 14px; }
.auth-card form { display: grid; gap: 12px; }
.auth-card button { justify-self: stretch; }
/* ── Admin: Benutzer-Verwaltung ────────────────────────────────── */
.user-form {
display: grid;
grid-template-columns: 1fr 1fr auto auto;
gap: 12px;
align-items: end;
}
.user-form label { font-size: 13px; }
.inline-check {
display: flex !important;
flex-direction: row !important;
align-items: center;
gap: 6px;
color: var(--text);
}
.inline-check input { margin: 0; }
#users-table .user-actions {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
@media (max-width: 700px) {
.user-form { grid-template-columns: 1fr; }
}

View File

@ -6,3 +6,5 @@ httpx==0.28.1
pydantic-settings==2.7.0 pydantic-settings==2.7.0
jinja2==3.1.4 jinja2==3.1.4
aiofiles==24.1.0 aiofiles==24.1.0
bcrypt==4.2.1
itsdangerous==2.2.0