diff --git a/main.py b/main.py index 4103e2c..8262035 100644 --- a/main.py +++ b/main.py @@ -4,6 +4,7 @@ - Admin (kun for medlemmer af ADMIN_GROUP): IP→label-mapping, cookie-tekster, statistik. - Admin-moduler er registreret i ADMIN_MODULES — tilføj nye ved at registrere en post. """ +import asyncio import json import os import subprocess @@ -67,6 +68,10 @@ ADMIN_MODULES = [ "desc": "Rediger beskrivelse og gruppe på dine Authentik-apps."}, {"key": "ops", "title": "Drift / egress", "icon": "🛰️", "desc": "Static-assets-version + random-proxy egress, rollover & inflight."}, + {"key": "users", "title": "Brugere", "icon": "👤", + "desc": "Opret og administrér brugere + adgang (via grupper)."}, + {"key": "groups", "title": "Grupper", "icon": "👥", + "desc": "Grupper og medlemmer — styrer hvilke services brugere har adgang til."}, ] @@ -184,14 +189,16 @@ async def fetch_all_apps_admin(): async def patch_apps(rows): headers = {"Authorization": f"Bearer {API_TOKEN}"} - n = 0 async with httpx.AsyncClient(base_url=AUTHENTIK, headers=headers, timeout=20) as c: - for r in rows: - resp = await c.patch(f"/api/v3/core/applications/{r['slug']}/", - json={"meta_description": r["desc"], "group": r["group"]}) - if resp.status_code < 300: - n += 1 - return n + async def one(r): + try: + resp = await c.patch(f"/api/v3/core/applications/{r['slug']}/", + json={"meta_description": r["desc"], "group": r["group"]}) + return resp.status_code < 300 + except Exception: + return False + results = await asyncio.gather(*[one(r) for r in rows]) + return sum(1 for ok in results if ok) def geoip_country(ip): @@ -237,6 +244,80 @@ async def rp_status(): return {"error": str(exc)[:200]} +def _ak_headers(): + return {"Authorization": f"Bearer {API_TOKEN}"} + + +async def ak_list_groups(): + async with httpx.AsyncClient(base_url=AUTHENTIK, headers=_ak_headers(), timeout=15) as c: + gs = (await c.get("/api/v3/core/groups/", params={"page_size": 100, "ordering": "name"})).json().get("results", []) + return [{"pk": g["pk"], "name": g["name"], "members": len(g.get("users") or [])} for g in gs] + + +async def ak_list_users(): + async with httpx.AsyncClient(base_url=AUTHENTIK, headers=_ak_headers(), timeout=20) as c: + us = (await c.get("/api/v3/core/users/", params={"page_size": 300, "ordering": "username"})).json().get("results", []) + out = [] + for u in us: + out.append({"pk": u["pk"], "username": u["username"], "name": u.get("name"), + "email": u.get("email"), "active": u.get("is_active"), + "groups": [str(x) for x in (u.get("groups") or [])], + "group_names": [g.get("name") for g in (u.get("groups_obj") or [])], + "type": u.get("type")}) + return out + + +async def ak_get_user(pk): + async with httpx.AsyncClient(base_url=AUTHENTIK, headers=_ak_headers(), timeout=15) as c: + u = (await c.get(f"/api/v3/core/users/{pk}/")).json() + return {"pk": u["pk"], "username": u["username"], "name": u.get("name"), "email": u.get("email"), + "active": u.get("is_active"), "groups": [str(x) for x in (u.get("groups") or [])]} + + +async def ak_create_user(username, name, email, password, group_pks): + async with httpx.AsyncClient(base_url=AUTHENTIK, headers=_ak_headers(), timeout=20) as c: + r = await c.post("/api/v3/core/users/", json={ + "username": username, "name": name or username, "email": email or "", + "is_active": True, "type": "internal", "path": "users", "groups": group_pks}) + if r.status_code >= 300: + return False, r.text[:300] + pk = r.json()["pk"] + if password: + await c.post(f"/api/v3/core/users/{pk}/set_password/", json={"password": password}) + return True, pk + + +async def ak_update_user(pk, group_pks, is_active, password=""): + async with httpx.AsyncClient(base_url=AUTHENTIK, headers=_ak_headers(), timeout=20) as c: + r = await c.patch(f"/api/v3/core/users/{pk}/", json={"groups": group_pks, "is_active": is_active}) + if password: + await c.post(f"/api/v3/core/users/{pk}/set_password/", json={"password": password}) + return r.status_code < 300 + + +async def ak_create_group(name): + async with httpx.AsyncClient(base_url=AUTHENTIK, headers=_ak_headers(), timeout=15) as c: + r = await c.post("/api/v3/core/groups/", json={"name": name}) + return r.status_code < 300 + + +async def ak_group_members(gpk): + async with httpx.AsyncClient(base_url=AUTHENTIK, headers=_ak_headers(), timeout=15) as c: + g = (await c.get(f"/api/v3/core/groups/{gpk}/")).json() + return {"pk": g["pk"], "name": g["name"], "users": [str(x) for x in (g.get("users") or [])]} + + +async def ak_set_group_members(gpk, user_pks): + desired = set(str(x) for x in user_pks) + async with httpx.AsyncClient(base_url=AUTHENTIK, headers=_ak_headers(), timeout=25) as c: + g = (await c.get(f"/api/v3/core/groups/{gpk}/")).json() + cur = set(str(x) for x in (g.get("users") or [])) + adds = [c.post(f"/api/v3/core/groups/{gpk}/add_user/", json={"pk": int(p)}) for p in desired - cur] + rems = [c.post(f"/api/v3/core/groups/{gpk}/remove_user/", json={"pk": int(p)}) for p in cur - desired] + await asyncio.gather(*adds, *rems) + return True + + async def matomo_summary(): if not MATOMO_TOKEN: return None @@ -285,7 +366,7 @@ async def logout(request: Request): @app.get("/", response_class=HTMLResponse) -async def home(request: Request): +async def home(request: Request, saved: str = ""): user = current_user(request) if not user: return templates.TemplateResponse(request, "login.html", {"request": request}) @@ -301,7 +382,7 @@ async def home(request: Request): "request": request, "user": user, "error": error, "is_admin": is_admin(user), "groups": group_apps(apps), "apps_sorted": by_name, "fav_apps": [a for a in by_name if a["slug"] in fav_set], - "favorites": list(fav_set), "view": prefs["view"], + "favorites": list(fav_set), "view": prefs["view"], "saved": saved, }) @@ -429,7 +510,99 @@ async def admin_apps_save(request: Request): rows = [{"slug": sl, "desc": (d or "").strip(), "group": (g or "").strip()} for sl, d, g in zip(slugs, descs, groups) if sl] n = await patch_apps(rows) - return RedirectResponse(f"/admin/apps?saved=Gemt {n} apps", status_code=303) + return RedirectResponse(f"/?saved=Gemt {n} apps", status_code=303) + + +@app.get("/admin/users", response_class=HTMLResponse) +async def admin_users(request: Request, saved: str = ""): + user = current_user(request) + if not is_admin(user): + raise HTTPException(403, "Kun for administratorer") + return templates.TemplateResponse(request, "admin_users.html", { + "request": request, "user": user, "is_admin": True, + "users": await ak_list_users(), "groups": await ak_list_groups(), "saved": saved, + }) + + +@app.post("/admin/users") +async def admin_users_create(request: Request): + user = current_user(request) + if not is_admin(user): + raise HTTPException(403) + f = await request.form() + username = (f.get("username") or "").strip() + if not username: + return RedirectResponse("/admin/users?saved=Brugernavn mangler", status_code=303) + ok, res = await ak_create_user(username, (f.get("name") or "").strip(), + (f.get("email") or "").strip(), f.get("password") or "", + f.getlist("groups")) + msg = f"Bruger '{username}' oprettet" if ok else f"Fejl: {res}" + return RedirectResponse(f"/admin/users?saved={msg}", status_code=303) + + +@app.get("/admin/users/{pk}", response_class=HTMLResponse) +async def admin_user_edit(request: Request, pk: int): + user = current_user(request) + if not is_admin(user): + raise HTTPException(403) + return templates.TemplateResponse(request, "admin_user_edit.html", { + "request": request, "user": user, "is_admin": True, + "u": await ak_get_user(pk), "groups": await ak_list_groups(), + }) + + +@app.post("/admin/users/{pk}") +async def admin_user_save(request: Request, pk: int): + user = current_user(request) + if not is_admin(user): + raise HTTPException(403) + f = await request.form() + await ak_update_user(pk, f.getlist("groups"), f.get("is_active") == "on", f.get("password") or "") + return RedirectResponse("/admin/users?saved=Bruger gemt", status_code=303) + + +@app.get("/admin/groups", response_class=HTMLResponse) +async def admin_groups(request: Request, saved: str = ""): + user = current_user(request) + if not is_admin(user): + raise HTTPException(403) + return templates.TemplateResponse(request, "admin_groups.html", { + "request": request, "user": user, "is_admin": True, + "groups": await ak_list_groups(), "saved": saved, + }) + + +@app.post("/admin/groups") +async def admin_groups_create(request: Request): + user = current_user(request) + if not is_admin(user): + raise HTTPException(403) + f = await request.form() + name = (f.get("name") or "").strip() + if name: + await ak_create_group(name) + return RedirectResponse(f"/admin/groups?saved=Gruppe '{name}' oprettet", status_code=303) + + +@app.get("/admin/groups/{gpk}", response_class=HTMLResponse) +async def admin_group_edit(request: Request, gpk: str): + user = current_user(request) + if not is_admin(user): + raise HTTPException(403) + return templates.TemplateResponse(request, "admin_group_edit.html", { + "request": request, "user": user, "is_admin": True, + "g": await ak_group_members(gpk), "users": await ak_list_users(), + }) + + +@app.post("/admin/groups/{gpk}") +async def admin_group_save(request: Request, gpk: str): + user = current_user(request) + if not is_admin(user): + raise HTTPException(403) + f = await request.form() + await ak_set_group_members(gpk, f.getlist("members")) + return RedirectResponse("/admin/groups?saved=Medlemmer gemt", status_code=303) # Offentligt JSON-endpoint så frontend/andre kan læse cookie-teksterne (valgfrit) diff --git a/templates/admin_apps.html b/templates/admin_apps.html index 1bfea08..4100ea5 100644 --- a/templates/admin_apps.html +++ b/templates/admin_apps.html @@ -4,7 +4,7 @@
Beskrivelsen og gruppen kommer fra hver apps felter i Authentik. Rediger her — det gemmes tilbage i Authentik og slår igennem på både portalen og Authentiks egen side.
{% if saved %}