diff --git a/app/api/admin.py b/app/api/admin.py index 3a24509..cb0a7ce 100644 --- a/app/api/admin.py +++ b/app/api/admin.py @@ -17,7 +17,7 @@ from app.auth import is_admin_user, require_admin, require_admin_or_user from app.config import settings from app.dependencies import get_store from app.runtime_config import RUNTIME_SETTABLE, invalidate_cache, runtime_settings -from app.schemas import AdminUserPrefsUpdate, MemoryCreate, MemoryOut, UserCreate, UserCreated, UserUpdate +from app.schemas import AdminUserPrefsUpdate, MemoryCreate, MemoryOut, UserCreate, UserCreated, UserUpdate, UserAdminUpdate router = APIRouter() @@ -94,6 +94,15 @@ async def reset_token(user_id: str): return UserCreated(user_id=user.id, display_name=user.display_name, token=token) +@router.put("/admin/users/{user_id}/admin", dependencies=[Depends(require_admin)]) +async def set_user_admin(user_id: str, payload: UserAdminUpdate): + """Schaltet Admin-Rechte (persistentes DB-Flag) für einen Nutzer ein/aus.""" + user = get_store().set_user_admin(user_id, payload.is_admin) + if user is None: + raise HTTPException(status_code=404, detail=f"Nutzer {user_id!r} nicht gefunden.") + return {"user_id": user.id, "is_admin": user.is_admin} + + @router.put("/admin/users/{user_id}/prefs", dependencies=[Depends(require_admin)]) async def update_user_prefs(user_id: str, payload: AdminUserPrefsUpdate): """Setzt Nutzer-Einstellungen (z. B. erlaubte Sprachen). Merge mit bestehenden Prefs.""" @@ -135,6 +144,7 @@ async def list_users(): "external_id": u.external_id, "created_at": u.created_at, "allowed_languages": (u.prefs or {}).get("allowed_languages", ""), + "is_admin": is_admin_user(u), } for u in get_store().list_users() ] diff --git a/app/auth.py b/app/auth.py index d18767c..3ab18ab 100644 --- a/app/auth.py +++ b/app/auth.py @@ -64,7 +64,11 @@ def _username_from_cookie(headers, cfg: Settings) -> str | None: def is_admin_user(user: User | None, cfg: Settings = settings) -> bool: """True, wenn der Nutzer (per SSO-Identitaet) in ADMIN_USERS steht.""" - if user is None or not user.external_id: + if user is None: + return False + if getattr(user, "is_admin", False): + return True # persistentes DB-Flag (vom Admin gesetzt) + if not user.external_id: return False return user.external_id in _csv_set(cfg.admin_users) diff --git a/app/schemas.py b/app/schemas.py index 34c008e..2e53c82 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -113,3 +113,8 @@ class EmergencyRequest(BaseModel): class AdminUserPrefsUpdate(BaseModel): """Admin setzt Nutzer-Einstellungen (z. B. erlaubte Sprachen, CSV: "de,en").""" allowed_languages: str | None = None + + +class UserAdminUpdate(BaseModel): + """Admin schaltet Admin-Rechte für einen Nutzer ein/aus.""" + is_admin: bool diff --git a/app/store.py b/app/store.py index d7624ba..8e3d9be 100644 --- a/app/store.py +++ b/app/store.py @@ -69,6 +69,8 @@ class Store(ABC): @abstractmethod def set_user_prefs(self, user_id: str, prefs: dict) -> User: ... + def set_user_admin(self, user_id: str, is_admin: bool) -> User | None: ... + @abstractmethod def ensure_anonymous_user(self) -> User: ... @@ -185,7 +187,8 @@ class SQLiteStore(Store): token_hash TEXT NOT NULL UNIQUE, prefs_json TEXT NOT NULL DEFAULT '{}', created_at TEXT NOT NULL, - external_id TEXT + external_id TEXT, + is_admin INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS sessions ( id TEXT PRIMARY KEY, @@ -236,6 +239,8 @@ class SQLiteStore(Store): cols = {row["name"] for row in conn.execute("PRAGMA table_info(users)")} if "external_id" not in cols: conn.execute("ALTER TABLE users ADD COLUMN external_id TEXT") + if "is_admin" not in cols: + conn.execute("ALTER TABLE users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0") # NULLs gelten in SQLite als verschieden -> Alt-Nutzer ohne external_id ok. conn.execute( "CREATE UNIQUE INDEX IF NOT EXISTS idx_users_external" @@ -251,6 +256,7 @@ class SQLiteStore(Store): prefs=json.loads(row["prefs_json"] or "{}"), created_at=row["created_at"], external_id=row["external_id"] if "external_id" in keys else None, + is_admin=bool(row["is_admin"]) if "is_admin" in keys else False, ) def create_user(self, display_name: str) -> tuple[User, str]: @@ -505,6 +511,15 @@ class SQLiteStore(Store): row = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone() return self._row_to_user(row) if row else None + def set_user_admin(self, user_id: str, is_admin: bool) -> User | None: + with self._connect() as conn: + conn.execute( + "UPDATE users SET is_admin = ? WHERE id = ?", + (1 if is_admin else 0, user_id), + ) + row = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone() + return self._row_to_user(row) if row else None + # ----- Notfall-Protokoll ------------------------------------------------ def log_emergency(self, user_id: str, category: str, snippet: str) -> None: with self._connect() as conn: diff --git a/app/web/app.js b/app/web/app.js index 14fd7fa..c1fc61c 100644 --- a/app/web/app.js +++ b/app/web/app.js @@ -320,6 +320,12 @@ let currentUserId = null; const sessionKey = (uid) => "va-session-" + uid; // Sprachen (Code -> Kurzlabel) für Filter + Admin-Auswahl. +function loginLink(token) { return location.origin + "/?k=" + token; } +async function copyText(text, btn) { + try { await navigator.clipboard.writeText(text); if (btn) { const t = btn.textContent; btn.textContent = "✓ kopiert"; setTimeout(() => (btn.textContent = t), 1500); } } + catch (e) { /* Clipboard evtl. blockiert -> select-all bleibt als Fallback */ } +} + const ALL_LANGS = [["de","DE"],["en","EN"],["fr","FR"],["es","ES"],["it","IT"], ["pt","PT"],["pl","PL"],["ar","AR"],["ru","RU"],["zh","ZH"]]; @@ -1013,7 +1019,7 @@ $("#create-user-form").addEventListener("submit", async (e) => { if (!name) return; const data = await adminFetch("/api/admin/users", "POST", { display_name: name }); if (data?.token) { - $("#new-token-value").textContent = data.token; + $("#new-token-value").textContent = loginLink(data.token); $("#new-token-box").classList.remove("hidden"); nameEl.value = ""; loadAdminUsers(); @@ -1039,14 +1045,15 @@ function buildUserCard(u) {
Token (nur einmal sichtbar — jetzt kopieren!):
+Login-Link (nur einmal sichtbar — jetzt kopieren & an den Nutzer geben!):