From a1167df4f124d7a05209f65592607ac3023172c8 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 14 May 2026 02:38:52 +0000 Subject: [PATCH] feat: User-Verwaltung mit Login, Sessions und Owner-Filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- deploy/install.sh | 19 ++- lxc-frontend/.env.example | 4 + lxc-frontend/app/api/admin.py | 86 ++++++++++ lxc-frontend/app/api/auth.py | 83 +++++++++ lxc-frontend/app/api/jobs.py | 60 +++++-- lxc-frontend/app/core/auth.py | 45 +++++ lxc-frontend/app/core/config.py | 5 + lxc-frontend/app/core/db.py | 1 + lxc-frontend/app/core/migrate.py | 43 +++++ lxc-frontend/app/main.py | 20 +++ lxc-frontend/app/models/job.py | 1 + lxc-frontend/app/models/user.py | 12 ++ lxc-frontend/app/static/app.js | 261 ++++++++++++++++++++++++++--- lxc-frontend/app/static/index.html | 75 ++++++++- lxc-frontend/app/static/style.css | 90 ++++++++++ lxc-frontend/requirements.txt | 2 + 16 files changed, 760 insertions(+), 47 deletions(-) create mode 100644 lxc-frontend/app/api/admin.py create mode 100644 lxc-frontend/app/api/auth.py create mode 100644 lxc-frontend/app/core/auth.py create mode 100644 lxc-frontend/app/core/migrate.py create mode 100644 lxc-frontend/app/models/user.py diff --git a/deploy/install.sh b/deploy/install.sh index ce7be39..689eacd 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -39,6 +39,18 @@ if [ ! -f "$APP_DIR/lxc-frontend/.env" ]; then cp "$APP_DIR/lxc-frontend/.env.example" "$APP_DIR/lxc-frontend/.env" chown "$APP_USER:$APP_USER" "$APP_DIR/lxc-frontend/.env" 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 chown -R "$APP_USER:$APP_USER" /var/lib/voice-agent @@ -60,5 +72,8 @@ echo echo "Fertig. Health-Check:" echo " curl -s http://localhost/health" echo -echo "Nach erstem Start unbedingt MAC_API_URL in $APP_DIR/lxc-frontend/.env setzen und Service neu starten:" -echo " sudo systemctl restart voice-agent" +echo "Beim ersten Aufruf im Browser: Initialer Admin wird angelegt — bestehende Jobs" +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" diff --git a/lxc-frontend/.env.example b/lxc-frontend/.env.example index f114f2d..ef31a8c 100644 --- a/lxc-frontend/.env.example +++ b/lxc-frontend/.env.example @@ -15,3 +15,7 @@ MAC_API_TIMEOUT=1800 # Limits MAX_UPLOAD_MB=500 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 diff --git a/lxc-frontend/app/api/admin.py b/lxc-frontend/app/api/admin.py new file mode 100644 index 0000000..e64c3e4 --- /dev/null +++ b/lxc-frontend/app/api/admin.py @@ -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} diff --git a/lxc-frontend/app/api/auth.py b/lxc-frontend/app/api/auth.py new file mode 100644 index 0000000..3935972 --- /dev/null +++ b/lxc-frontend/app/api/auth.py @@ -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} diff --git a/lxc-frontend/app/api/jobs.py b/lxc-frontend/app/api/jobs.py index fd14065..26ff050 100644 --- a/lxc-frontend/app/api/jobs.py +++ b/lxc-frontend/app/api/jobs.py @@ -1,5 +1,4 @@ import secrets -from datetime import datetime, timezone from pathlib import Path import aiofiles @@ -7,9 +6,11 @@ from fastapi import APIRouter, BackgroundTasks, Depends, File, Form, HTTPExcepti from fastapi.responses import FileResponse from sqlmodel import Session, select +from app.core.auth import current_user from app.core.config import settings 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 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 +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("") async def create_job( background: BackgroundTasks, audio: UploadFile = File(...), title: str = Form(""), session: Session = Depends(get_session), + user: User = Depends(current_user), ): if not audio.filename: 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)") 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.commit() session.refresh(job) background.add_task(run_pipeline, job.id) - return _job_dict(job) + return _job_dict(job, user) @router.get("") -def list_jobs(session: Session = Depends(get_session)): - jobs = session.exec(select(Job).order_by(Job.created_at.desc())).all() - return [_job_dict(j) for j in jobs] +def list_jobs(session: Session = Depends(get_session), user: User = Depends(current_user)): + stmt = select(Job).order_by(Job.created_at.desc()) + 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}") -def get_job(job_id: int, session: Session = Depends(get_session)): - job = session.get(Job, job_id) - if not job: - raise HTTPException(404, "Job nicht gefunden") - return _job_dict(job) +def get_job(job_id: int, session: Session = Depends(get_session), user: User = Depends(current_user)): + job = _require_access(session.get(Job, job_id), user) + return _job_dict(job, user) @router.get("/{job_id}/download/{kind}") -def download(job_id: int, kind: str, session: Session = Depends(get_session)): - job = session.get(Job, job_id) - if not job: - raise HTTPException(404, "Job nicht gefunden") +def download(job_id: int, kind: str, session: Session = Depends(get_session), user: User = Depends(current_user)): + job = _require_access(session.get(Job, job_id), user) mapping = { "docx": (job.docx_path, "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "protocol.docx"), "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}") -def _job_dict(j: Job) -> dict: - return { +def _job_dict(j: Job, user: User, owner_username: str | None = None) -> dict: + d = { "id": j.id, "title": j.title, "original_name": j.original_name, @@ -103,3 +123,7 @@ def _job_dict(j: Job) -> dict: "docx": bool(j.docx_path), }, } + if user.is_admin: + d["owner_id"] = j.owner_id + d["owner_username"] = owner_username or "" + return d diff --git a/lxc-frontend/app/core/auth.py b/lxc-frontend/app/core/auth.py new file mode 100644 index 0000000..b827723 --- /dev/null +++ b/lxc-frontend/app/core/auth.py @@ -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 diff --git a/lxc-frontend/app/core/config.py b/lxc-frontend/app/core/config.py index f31014f..8b0efb7 100644 --- a/lxc-frontend/app/core/config.py +++ b/lxc-frontend/app/core/config.py @@ -19,6 +19,11 @@ class Settings(BaseSettings): max_upload_mb: int = 500 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 def allowed_ext_set(self) -> set[str]: return {e.strip().lower().lstrip(".") for e in self.allowed_exts.split(",") if e.strip()} diff --git a/lxc-frontend/app/core/db.py b/lxc-frontend/app/core/db.py index b9e6012..618e765 100644 --- a/lxc-frontend/app/core/db.py +++ b/lxc-frontend/app/core/db.py @@ -6,6 +6,7 @@ engine = create_engine(settings.db_url, connect_args={"check_same_thread": False def init_db() -> None: from app.models.job import Job # noqa: F401 + from app.models.user import User # noqa: F401 SQLModel.metadata.create_all(engine) diff --git a/lxc-frontend/app/core/migrate.py b/lxc-frontend/app/core/migrate.py new file mode 100644 index 0000000..1119145 --- /dev/null +++ b/lxc-frontend/app/core/migrate.py @@ -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 diff --git a/lxc-frontend/app/main.py b/lxc-frontend/app/main.py index eecfff1..6a4001e 100644 --- a/lxc-frontend/app/main.py +++ b/lxc-frontend/app/main.py @@ -5,12 +5,16 @@ from pathlib import Path from fastapi import FastAPI from fastapi.responses import FileResponse, JSONResponse from fastapi.staticfiles import StaticFiles +from starlette.middleware.sessions import SessionMiddleware 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.core.config import settings from app.core.db import init_db from app.core.mac_client import MacClient +from app.core.migrate import run_migrations class NoCacheStaticFiles(StaticFiles): @@ -21,6 +25,7 @@ class NoCacheStaticFiles(StaticFiles): response.headers["Cache-Control"] = "no-cache, must-revalidate" return response + logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s") log = logging.getLogger("voice-agent") @@ -28,6 +33,9 @@ log = logging.getLogger("voice-agent") @asynccontextmanager async def lifespan(app: FastAPI): 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("Upload dir: %s", settings.upload_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.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) STATIC_DIR = Path(__file__).resolve().parent / "static" diff --git a/lxc-frontend/app/models/job.py b/lxc-frontend/app/models/job.py index 19647f4..3249b6f 100644 --- a/lxc-frontend/app/models/job.py +++ b/lxc-frontend/app/models/job.py @@ -15,6 +15,7 @@ class JobStatus: class Job(SQLModel, table=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 original_name: str title: str = "" diff --git a/lxc-frontend/app/models/user.py b/lxc-frontend/app/models/user.py new file mode 100644 index 0000000..e305c93 --- /dev/null +++ b/lxc-frontend/app/models/user.py @@ -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)) diff --git a/lxc-frontend/app/static/app.js b/lxc-frontend/app/static/app.js index 3e4c54b..d17da72 100644 --- a/lxc-frontend/app/static/app.js +++ b/lxc-frontend/app/static/app.js @@ -1,3 +1,4 @@ +// ─── DOM-Refs ────────────────────────────────────────────────────────────── const form = document.getElementById("upload-form"); const titleInput = document.getElementById("title"); const audioInput = document.getElementById("audio"); @@ -8,6 +9,154 @@ const uploadMsg = document.getElementById("upload-msg"); const jobsBody = document.getElementById("jobs-body"); 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) => ({ + "&": "&", "<": "<", ">": ">", '"': """, "'": "'", + }[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() { try { const r = await fetch("/api/mac/health"); @@ -27,24 +176,7 @@ async function checkMac() { } } -function fmtDate(s) { - 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) => ({ - "&": "&", "<": "<", ">": ">", '"': """, "'": "'", - }[c])); -} - +// ─── Jobs ────────────────────────────────────────────────────────────────── function row(job) { const tr = document.createElement("tr"); const title = job.title || job.original_name; @@ -65,6 +197,10 @@ function row(job) { dlCell.appendChild(a); }); + const ownerLine = currentUser?.is_admin && job.owner_username + ? `
von ${escapeHtml(job.owner_username)}` + : ""; + const statusCell = ` ${escapeHtml(job.status)} ${job.error ? `
${escapeHtml(job.error)}
` : ""} @@ -72,7 +208,7 @@ function row(job) { tr.innerHTML = ` #${job.id} - ${escapeHtml(title)}
${escapeHtml(job.original_name)} + ${escapeHtml(title)}
${escapeHtml(job.original_name)}${ownerLine} ${statusCell}
${job.progress}% ${fmtDate(job.updated_at)} @@ -82,8 +218,10 @@ function row(job) { } async function loadJobs() { + if (!currentUser) return; 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(); jobsBody.innerHTML = ""; if (!jobs.length) { @@ -111,6 +249,7 @@ form.addEventListener("submit", (ev) => { const xhr = new XMLHttpRequest(); xhr.open("POST", "/api/jobs"); + xhr.withCredentials = true; xhr.upload.onprogress = (e) => { if (e.lengthComputable) { const p = Math.round((e.loaded / e.total) * 100); @@ -120,6 +259,7 @@ form.addEventListener("submit", (ev) => { xhr.onload = () => { submitBtn.disabled = false; uploadProgress.classList.add("hidden"); + if (xhr.status === 401) { showAuth("login"); return; } if (xhr.status >= 200 && xhr.status < 300) { const j = JSON.parse(xhr.responseText); uploadMsg.textContent = `Job #${j.id} erstellt. Verarbeitung läuft …`; @@ -142,7 +282,84 @@ form.addEventListener("submit", (ev) => { 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 = ` + #${u.id} + ${escapeHtml(u.username)}${isMe ? ' (du)' : ""} + ${u.is_admin ? 'Admin' : 'User'} + ${fmtDate(u.created_at)} + + + ${isMe ? "" : ``} + + `; + 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(); -loadJobs(); -setInterval(loadJobs, 3000); +setInterval(() => { if (currentUser) loadJobs(); }, 3000); setInterval(checkMac, 15000); diff --git a/lxc-frontend/app/static/index.html b/lxc-frontend/app/static/index.html index 14ebb2c..67ae445 100644 --- a/lxc-frontend/app/static/index.html +++ b/lxc-frontend/app/static/index.html @@ -8,18 +8,50 @@
-

Voice-Agent

-

Sitzungsaufnahmen automatisch transkribieren und protokollieren

-
Mac-Backend: prüfe …
+
+
+

Voice-Agent

+

Sitzungsaufnahmen automatisch transkribieren und protokollieren

+
+
+
Mac-Backend: prüfe …
+ +
+
-
+ + + +

Neue Aufnahme hochladen

+ + +