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:
parent
d4b0d21ed1
commit
a1167df4f1
@ -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"
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
86
lxc-frontend/app/api/admin.py
Normal file
86
lxc-frontend/app/api/admin.py
Normal 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}
|
||||||
83
lxc-frontend/app/api/auth.py
Normal file
83
lxc-frontend/app/api/auth.py
Normal 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}
|
||||||
@ -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
|
||||||
|
|||||||
45
lxc-frontend/app/core/auth.py
Normal file
45
lxc-frontend/app/core/auth.py
Normal 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
|
||||||
@ -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()}
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
43
lxc-frontend/app/core/migrate.py
Normal file
43
lxc-frontend/app/core/migrate.py
Normal 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
|
||||||
@ -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"
|
||||||
|
|||||||
@ -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 = ""
|
||||||
|
|||||||
12
lxc-frontend/app/models/user.py
Normal file
12
lxc-frontend/app/models/user.py
Normal 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))
|
||||||
@ -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) => ({
|
||||||
|
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
||||||
|
}[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) => ({
|
|
||||||
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
|
||||||
}[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);
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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; }
|
||||||
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user