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) {
- + +
@@ -1104,6 +1111,27 @@ function buildUserCard(u) { } renderLangChips(); + // Admin-Rechte (persistentes Flag) ein-/ausschalten + let isAdmin = !!u.is_admin; + const adminBtn = card.querySelector(".btn-admin"); + function renderAdminBtn() { + adminBtn.textContent = isAdmin ? "★ Admin" : "Admin?"; + adminBtn.classList.toggle("bg-blue-600", isAdmin); + adminBtn.classList.toggle("text-white", isAdmin); + adminBtn.classList.toggle("border-blue-600", isAdmin); + adminBtn.classList.toggle("border-slate-300", !isAdmin); + adminBtn.classList.toggle("dark:border-slate-600", !isAdmin); + adminBtn.classList.toggle("text-slate-600", !isAdmin); + adminBtn.classList.toggle("dark:text-slate-300", !isAdmin); + } + renderAdminBtn(); + adminBtn.addEventListener("click", async () => { + const next = !isAdmin; + if (next && !confirm(`„${u.display_name}" Admin-Rechte geben?`)) return; + const res = await adminFetch(`/api/admin/users/${uid}/admin`, "PUT", { is_admin: next }); + if (res) { isAdmin = !!res.is_admin; renderAdminBtn(); } + }); + // Umbenennen card.querySelector(".btn-rename").addEventListener("click", () => card.querySelector(".rename-box").classList.toggle("hidden") @@ -1127,7 +1155,7 @@ function buildUserCard(u) { if (!confirm(`Token für „${u.display_name}" zurücksetzen?\nDer alte Token wird sofort ungültig.`)) return; const data = await adminFetch(`/api/admin/users/${uid}/token`, "POST"); if (data?.token) { - card.querySelector(".token-val").textContent = data.token; + card.querySelector(".token-val").textContent = loginLink(data.token); card.querySelector(".token-box").classList.remove("hidden"); } }); diff --git a/app/web/index.html b/app/web/index.html index 16bc9f6..c22f0e3 100644 --- a/app/web/index.html +++ b/app/web/index.html @@ -81,7 +81,7 @@ class="rounded-lg bg-blue-600 hover:bg-blue-700 text-white text-sm px-4 py-2 font-medium transition-colors">Anlegen @@ -356,6 +356,6 @@
- + diff --git a/tests/test_admin_settings.py b/tests/test_admin_settings.py index f91452d..a37f7f9 100644 --- a/tests/test_admin_settings.py +++ b/tests/test_admin_settings.py @@ -203,3 +203,15 @@ def test_admin_sets_user_allowed_languages(): users = client.get("/api/admin/users", headers=ADM_HDR).json() entry = next(u for u in users if u["user_id"] == uid) assert entry["allowed_languages"] == "de,en" + + +def test_admin_can_flag_user_as_admin(): + created = client.post("/api/admin/users", headers=ADM_HDR, + json={"display_name": "Caregiver"}).json() + uid = created["user_id"] + users = client.get("/api/admin/users", headers=ADM_HDR).json() + assert next(u for u in users if u["user_id"] == uid)["is_admin"] is False + r = client.put(f"/api/admin/users/{uid}/admin", headers=ADM_HDR, json={"is_admin": True}) + assert r.status_code == 200 and r.json()["is_admin"] is True + users = client.get("/api/admin/users", headers=ADM_HDR).json() + assert next(u for u in users if u["user_id"] == uid)["is_admin"] is True diff --git a/tests/test_capability_login.py b/tests/test_capability_login.py index 2f03308..e2e045d 100644 --- a/tests/test_capability_login.py +++ b/tests/test_capability_login.py @@ -46,3 +46,14 @@ def test_invalid_capability_link_does_not_set_cookie(monkeypatch): # ungültiges Token -> kein Cookie, kein 303-Redirect auf bereinigte URL assert "va_token=" not in r.headers.get("set-cookie", "") client.cookies.clear() + + +def test_db_admin_flag_grants_admin_via_me(monkeypatch): + monkeypatch.setattr(settings, "auth_enabled", True) + store = deps.get_store() + user, token = store.create_user("Caregiver") + store.set_user_admin(user.id, True) + client.cookies.set("va_token", token) + me = client.get("/api/me").json() + assert me["is_admin"] is True + client.cookies.clear()