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:
@@ -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)
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<h1>App-beskrivelser & 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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user