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
+181 -8
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:
async def one(r):
try:
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
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)
+8 -2
View File
@@ -4,7 +4,7 @@
<h1>App-beskrivelser &amp; grupper</h1>
<p class="lead">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.</p>
{% if saved %}<div class="flash">{{ saved }}</div>{% endif %}
<form method="post" action="/admin/apps">
<form method="post" action="/admin/apps" id="appform">
<table style="margin-top:1rem">
<thead><tr><th style="width:24%">App</th><th style="width:18%">Gruppe</th><th>Beskrivelse</th></tr></thead>
<tbody>
@@ -20,6 +20,12 @@
<datalist id="groups">
{% for g in rows|map(attribute='group')|reject('equalto','')|unique %}<option value="{{ g }}">{% endfor %}
</datalist>
<p style="margin-top:1.1rem"><button class="btn" type="submit">Gem alle</button></p>
<p style="margin-top:1.1rem"><button class="btn" type="submit" id="savebtn">Gem alle</button></p>
</form>
<script>
document.getElementById('appform').addEventListener('submit', function(){
var b=document.getElementById('savebtn');
b.disabled=true; b.textContent='Der går lige et øjeblik…';
});
</script>
{% endblock %}
+15
View File
@@ -0,0 +1,15 @@
{% extends "base.html" %}
{% block title %}Admin — {{ g.name }}{% endblock %}
{% block body %}
<p><a href="/admin/groups" class="muted">← Grupper</a></p>
<h1>{{ g.name }}</h1>
<p class="lead">Sæt flueben ved de brugere der skal være medlem af gruppen.</p>
<form method="post" action="/admin/groups/{{ g.pk }}">
<div style="display:grid;gap:.3rem .9rem;grid-template-columns:repeat(auto-fill,minmax(13rem,1fr));margin-top:1rem">
{% for u in users %}
<label style="display:flex;gap:.4rem;align-items:center;font-size:.9rem"><input type="checkbox" name="members" value="{{ u.pk }}" {% if u.pk|string in g.users %}checked{% endif %} style="width:auto"> {{ u.username }}{% if u.name %} <span class="muted">({{ u.name }})</span>{% endif %}</label>
{% endfor %}
</div>
<p style="margin-top:1.2rem"><button class="btn" type="submit">Gem medlemmer</button></p>
</form>
{% endblock %}
+19
View File
@@ -0,0 +1,19 @@
{% extends "base.html" %}
{% block title %}Admin — grupper{% endblock %}
{% block body %}
<h1>Grupper</h1>
<p class="lead">Grupper styrer hvilke services brugere har adgang til. Bind en app til en gruppe i Authentik, og alle i gruppen får adgang.</p>
{% if saved %}<div class="flash">{{ saved }}</div>{% endif %}
<form method="post" action="/admin/groups" style="display:flex;gap:.6rem;align-items:end;margin:1rem 0;max-width:28rem">
<label style="flex:1">Ny gruppe<input name="name" required autocomplete="off" placeholder="fx Familie"></label>
<button class="btn" type="submit">Opret</button>
</form>
<table>
<thead><tr><th>Gruppe</th><th>Medlemmer</th><th></th></tr></thead>
<tbody>
{% for g in groups %}
<tr><td><strong>{{ g.name }}</strong></td><td class="muted">{{ g.members }}</td><td><a href="/admin/groups/{{ g.pk }}">Medlemmer →</a></td></tr>
{% endfor %}
</tbody>
</table>
{% endblock %}
+19
View File
@@ -0,0 +1,19 @@
{% extends "base.html" %}
{% block title %}Admin — {{ u.username }}{% endblock %}
{% block body %}
<p><a href="/admin/users" class="muted">← Brugere</a></p>
<h1>{{ u.username }}</h1>
<p class="lead">{{ u.name }}{% if u.email %} · {{ u.email }}{% endif %}</p>
<form method="post" action="/admin/users/{{ u.pk }}">
<label style="display:flex;gap:.5rem;align-items:center;margin:1rem 0"><input type="checkbox" name="is_active" {% if u.active %}checked{% endif %} style="width:auto"> Aktiv konto</label>
<h2>Nulstil adgangskode</h2>
<input name="password" type="text" autocomplete="new-password" placeholder="(tom = uændret)" style="max-width:22rem">
<h2>Grupper (adgang til services)</h2>
<div style="display:grid;gap:.3rem .9rem;grid-template-columns:repeat(auto-fill,minmax(11rem,1fr))">
{% for g in groups %}
<label style="display:flex;gap:.4rem;align-items:center;font-size:.9rem"><input type="checkbox" name="groups" value="{{ g.pk }}" {% if g.pk in u.groups %}checked{% endif %} style="width:auto"> {{ g.name }}</label>
{% endfor %}
</div>
<p style="margin-top:1.2rem"><button class="btn" type="submit">Gem</button></p>
</form>
{% endblock %}
+40
View File
@@ -0,0 +1,40 @@
{% extends "base.html" %}
{% block title %}Admin — brugere{% endblock %}
{% block body %}
<h1>Brugere</h1>
<p class="lead">Opret og administrér brugere. Adgang til services styres via <strong class="text-text">grupper</strong> (en app er bundet til en gruppe).</p>
{% if saved %}<div class="flash">{{ saved }}</div>{% endif %}
<h2>Opret ny bruger</h2>
<form method="post" action="/admin/users" class="stat" style="display:block">
<div style="display:grid;gap:.7rem;grid-template-columns:repeat(auto-fit,minmax(12rem,1fr))">
<label>Brugernavn<input name="username" required autocomplete="off"></label>
<label>Navn<input name="name" autocomplete="off"></label>
<label>Email<input name="email" type="email" autocomplete="off"></label>
<label>Adgangskode<input name="password" type="text" autocomplete="new-password" placeholder="(valgfri)"></label>
</div>
<p class="muted" style="margin:.9rem 0 .4rem;font-size:.85rem">Adgang (grupper) — sæt flueben for de services brugeren skal kunne tilgå:</p>
<div style="display:grid;gap:.3rem .9rem;grid-template-columns:repeat(auto-fill,minmax(11rem,1fr))">
{% for g in groups %}
<label style="display:flex;gap:.4rem;align-items:center;font-size:.9rem"><input type="checkbox" name="groups" value="{{ g.pk }}" style="width:auto"> {{ g.name }}</label>
{% endfor %}
</div>
<p style="margin-top:1rem"><button class="btn" type="submit">Opret bruger</button></p>
</form>
<h2>Alle brugere ({{ users|length }})</h2>
<table>
<thead><tr><th>Bruger</th><th>Email</th><th>Grupper</th><th>Status</th><th></th></tr></thead>
<tbody>
{% for u in users %}
<tr>
<td><strong>{{ u.username }}</strong>{% if u.name %} <span class="muted">· {{ u.name }}</span>{% endif %}</td>
<td class="muted">{{ u.email or '—' }}</td>
<td class="muted" style="font-size:.85rem">{{ u.group_names|join(', ') if u.group_names else '—' }}</td>
<td>{% if u.active %}<span style="color:var(--accent)">aktiv</span>{% else %}<span class="muted">inaktiv</span>{% endif %}</td>
<td><a href="/admin/users/{{ u.pk }}">Rediger →</a></td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}
+1
View File
@@ -19,6 +19,7 @@
<h1>Hej, {{ user.name }}</h1>
<p class="lead">Tjenester du har adgang til.</p>
{% if error %}<div class="err">{{ error }}</div>{% endif %}
{% if saved %}<div class="flash">{{ saved }}</div>{% endif %}
{% if groups %}
<div class="toolbar">
<div class="filter">