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"
|
||||
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"
|
||||
|
||||
@ -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
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
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
|
||||
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()}
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
|
||||
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.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"
|
||||
|
||||
@ -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 = ""
|
||||
|
||||
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 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
|
||||
? `<br><small class="muted">von ${escapeHtml(job.owner_username)}</small>`
|
||||
: "";
|
||||
|
||||
const statusCell = `
|
||||
<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>` : ""}
|
||||
@ -72,7 +208,7 @@ function row(job) {
|
||||
|
||||
tr.innerHTML = `
|
||||
<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-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>
|
||||
@ -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 = `
|
||||
<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();
|
||||
loadJobs();
|
||||
setInterval(loadJobs, 3000);
|
||||
setInterval(() => { if (currentUser) loadJobs(); }, 3000);
|
||||
setInterval(checkMac, 15000);
|
||||
|
||||
@ -8,18 +8,50 @@
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Voice-Agent</h1>
|
||||
<p class="sub">Sitzungsaufnahmen automatisch transkribieren und protokollieren</p>
|
||||
<div id="mac-status" class="badge">Mac-Backend: prüfe …</div>
|
||||
<div class="header-row">
|
||||
<div class="header-left">
|
||||
<h1>Voice-Agent</h1>
|
||||
<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="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>
|
||||
|
||||
<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">
|
||||
<h2>Neue Aufnahme hochladen</h2>
|
||||
<form id="upload-form">
|
||||
<label>
|
||||
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>
|
||||
Audiodatei (MP3, WAV, M4A, MP4, OGG, FLAC)
|
||||
@ -52,6 +84,39 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</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>
|
||||
|
||||
<footer>
|
||||
|
||||
@ -162,3 +162,93 @@ th { color: var(--muted); font-weight: 600; font-size: 12px; text-transform: upp
|
||||
opacity: .55;
|
||||
}
|
||||
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
|
||||
jinja2==3.1.4
|
||||
aiofiles==24.1.0
|
||||
bcrypt==4.2.1
|
||||
itsdangerous==2.2.0
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user