admin: bruger- og gruppe-administration (opret bruger m. gruppe-adgang, medlemsstyring)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
agw
2026-06-11 21:20:00 +02:00
co-authored by Claude Opus 4.8
parent dfe500c178
commit 7cf79fa901
7 changed files with 286 additions and 13 deletions
+183 -10
View File
@@ -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)