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 ? `
Sitzungsaufnahmen automatisch transkribieren und protokollieren
-Sitzungsaufnahmen automatisch transkribieren und protokollieren
+