feat(admin): invitationslinks med gruppe og udløbstid

Udrullet direkte til prod 2026-09-29; importeret her så repo == prod.
Bruger Authentik-flowet weircon-invitation (grupper og engangs via
fixed_data og politikker i flowet).

Claude-Session: https://claude.ai/code/session_012LCTmU2ewo8tnM1dByYAoG
This commit is contained in:
agw
2026-09-29 17:13:43 +02:00
parent 9675a5b76b
commit 52b1a2bb6c
4 changed files with 195 additions and 0 deletions
+55
View File
@@ -281,3 +281,58 @@ async def set_app_access(app_pk, want_groups, want_users):
ops.append(c.delete(f"/api/v3/policies/bindings/{pk}/"))
await asyncio.gather(*ops)
return len(want_g - set(cur_g)) + len(want_u - set(cur_u)), len(dels)
# --- Invitationer (enrollment-flowet "weircon-invitation" i Authentik) -------
# Modtageren vælger selv brugernavn, navn, e-mail og kode. Gruppe(r) og
# engangs-flag ligger i invitationens fixed_data og håndteres af politikker i
# flowet: "weircon-invitation-grupper" (lægger brugeren i grupperne lige før
# oprettelse) og "weircon-invitation-forbrug" (sletter en engangs-invitation
# FØRST når brugeren er oprettet; Authentiks eget single_use sletter allerede
# når linket åbnes, så et link der bare blev kigget på, ville være brændt).
INV_FLOW = "weircon-invitation"
# Grupper der aldrig giver mening at invitere til (service-/systemgrupper).
INV_SKJUL = {"authentik Read-only", "authentik Agent-Users", "ldapservice", "sonderlund-avatar-læs"}
def invitation_url(pk):
return f"{AUTHENTIK}/if/flow/{INV_FLOW}/?itoken={pk}"
async def list_invitations():
async with _client(15) as c:
rows = (await c.get("/api/v3/stages/invitation/invitations/",
params={"page_size": 100, "ordering": "expires"})).json().get("results", [])
out = []
for r in rows:
if (r.get("flow_obj") or {}).get("slug") != INV_FLOW:
continue
fd = r.get("fixed_data") or {}
out.append({"pk": r["pk"], "name": r.get("name"), "expires": r.get("expires"),
"grupper": fd.get("weircon_grupper") or [], "engangs": bool(fd.get("weircon_engangs")),
"af": (r.get("created_by") or {}).get("username") if isinstance(r.get("created_by"), dict) else None,
"url": invitation_url(r["pk"])})
return out
async def create_invitation(label, group_pks, minutes, engangs):
"""Opret en invitation. Returnerer (ok, pk eller fejltekst)."""
import re, secrets
from datetime import datetime, timedelta, timezone
slug = re.sub(r"[^a-z0-9_-]+", "-", (label or "invitation").lower()).strip("-")[:40] or "invitation"
slug = f"{slug}-{secrets.token_hex(3)}"
udloeb = (datetime.now(timezone.utc) + timedelta(minutes=int(minutes))).isoformat()
async with _client(20) as c:
flow = (await c.get(f"/api/v3/flows/instances/{INV_FLOW}/")).json()
r = await c.post("/api/v3/stages/invitation/invitations/", json={
"name": slug, "expires": udloeb, "single_use": False, "flow": flow["pk"],
"fixed_data": {"weircon_grupper": [str(g) for g in group_pks], "weircon_engangs": bool(engangs)}})
if r.status_code >= 300:
return False, r.text[:300]
return True, r.json()["pk"]
async def delete_invitation(pk):
async with _client(15) as c:
r = await c.delete(f"/api/v3/stages/invitation/invitations/{pk}/")
return r.status_code < 300
+2
View File
@@ -94,6 +94,8 @@ ADMIN_MODULES = [
"desc": "Static-assets-version + random-proxy egress, rollover & inflight."},
{"key": "users", "title": "Brugere", "icon": "👤",
"desc": "Opret og administrér brugere + adgang (via grupper)."},
{"key": "invitations", "title": "Invitationer", "icon": "✉️",
"desc": "Lav et invitationslink med gruppe og udløbstid. Modtageren opretter selv sit login."},
{"key": "groups", "title": "Grupper", "icon": "👥",
"desc": "Grupper og medlemmer — styrer hvilke services brugere har adgang til."},
{"key": "access", "title": "Adgang til services", "icon": "🔐",
+53
View File
@@ -271,6 +271,59 @@ async def admin_user_save(request: Request, pk: int):
return RedirectResponse(f"/admin/users?saved=Bruger gemt{note}", status_code=303)
INV_VARIGHED = [(30, "30 minutter"), (120, "2 timer"), (1440, "1 dag"), (4320, "3 dage"),
(10080, "7 dage"), (20160, "14 dage"), (43200, "30 dage")]
@app.get("/admin/invitations", response_class=HTMLResponse)
async def admin_invitations(request: Request, ny: str = "", saved: str = ""):
user = require_admin(request)
grupper = [g for g in await ak.list_groups() if g["name"] not in ak.INV_SKJUL]
navn = {str(g["pk"]): g["name"] for g in await ak.list_groups()}
invs = await ak.list_invitations()
from datetime import datetime, timezone
nu = datetime.now(timezone.utc)
for i in invs:
i["gruppenavne"] = [navn.get(str(x), "?") for x in i["grupper"]]
try:
rest = datetime.fromisoformat(i["expires"].replace("Z", "+00:00")) - nu
sek = int(rest.total_seconds())
i["udloebet"] = sek <= 0
i["rest"] = ("udløbet" if sek <= 0 else f"{sek // 86400} dage" if sek >= 86400
else f"{sek // 3600} timer" if sek >= 3600 else f"{max(sek // 60, 1)} min")
except Exception:
i["udloebet"], i["rest"] = False, "?"
nyt = next((i for i in invs if str(i["pk"]) == ny), None)
return templates.TemplateResponse(request, "admin_invitations.html", {
"request": request, "user": user, "is_admin": True, "grupper": grupper,
"invitationer": invs, "nyt": nyt, "varigheder": INV_VARIGHED, "saved": saved})
@app.post("/admin/invitations")
async def admin_invitations_create(request: Request):
require_admin(request)
f = await request.form()
grupper = f.getlist("grupper")
minutter = int(f.get("minutter") or 20160)
if minutter not in {m for m, _ in INV_VARIGHED}:
minutter = 20160
if not grupper:
return RedirectResponse("/admin/invitations?saved=Vælg mindst én gruppe", status_code=303)
ok, res = await ak.create_invitation((f.get("label") or "").strip(), grupper, minutter,
engangs=bool(f.get("engangs")))
if not ok:
return RedirectResponse(f"/admin/invitations?saved=Kunne ikke oprette: {res[:120]}", status_code=303)
return RedirectResponse(f"/admin/invitations?ny={res}", status_code=303)
@app.post("/admin/invitations/{pk}/slet")
async def admin_invitations_delete(request: Request, pk: str):
require_admin(request)
ok = await ak.delete_invitation(pk)
return RedirectResponse(f"/admin/invitations?saved={'Invitationen er tilbagekaldt' if ok else 'Kunne ikke tilbagekalde'}",
status_code=303)
@app.get("/admin/groups", response_class=HTMLResponse)
async def admin_groups(request: Request, saved: str = ""):
user = require_admin(request)
+85
View File
@@ -0,0 +1,85 @@
{% extends "base.html" %}
{% block title %}Admin — invitationer{% endblock %}
{% block body %}
<p><a href="/admin" class="wt-muted">← Admin</a></p>
<h1>Invitationer</h1>
<p class="wt-lead">Lav et link og send det til personen. De vælger selv brugernavn, navn, e-mail og adgangskode, og lander i de grupper du vælger her. Brugernavn og e-mail kan ikke ændres bagefter.</p>
{% if saved %}<div class="wt-flash">{{ saved }}</div>{% endif %}
{% if nyt %}
<section class="wt-card" style="margin:1rem 0;padding:1rem 1.2rem;border:2px solid var(--color-accent, #38bdf8)">
<strong>Invitationen er klar.</strong> Den virker i {{ nyt.rest }}{% if nyt.engangs %} og kan kun bruges af én person{% endif %}.
<div style="display:flex;gap:.5rem;margin-top:.7rem;flex-wrap:wrap">
<input id="nytlink" class="wt-input" style="flex:1;min-width:16rem" readonly value="{{ nyt.url }}">
<button class="wt-btn wt-btn--primary" type="button" data-kopier="nytlink">Kopiér link</button>
</div>
<p class="wt-muted" style="margin:.5rem 0 0">Grupper: {{ nyt.gruppenavne|join(", ") }}</p>
</section>
{% endif %}
<form method="post" action="/admin/invitations" style="margin:1.2rem 0 2rem">
<h2 style="font-size:1.1rem">Ny invitation</h2>
<label style="display:block;max-width:26rem;margin:.6rem 0">Hvem er den til? <span class="wt-muted">(kun til dig selv, så du kan kende den i listen)</span>
<input class="wt-input" name="label" autocomplete="off" placeholder="fx Mormor"></label>
<p style="margin:.9rem 0 .4rem"><strong>Grupper de skal med i</strong></p>
<div class="wt-grid wt-grid--tight">
{% for g in grupper %}
<label class="wt-select-card">
<input type="checkbox" name="grupper" value="{{ g.pk }}" {% if g.name == "Familie" %}checked{% endif %}>
<span class="wt-select-card-ini">{{ g.name[0]|upper }}</span>
<span class="wt-select-card-body">
<span class="wt-select-card-name">{{ g.name }}</span>
<span class="wt-select-card-sub">{{ g.members }} medlemmer{% if g.name == "authentik Admins" %} · ⚠️ fuld admin-adgang{% endif %}</span>
</span>
<span class="wt-select-card-chk">✓</span>
</label>
{% endfor %}
</div>
<div style="display:flex;gap:1.2rem;align-items:end;flex-wrap:wrap;margin-top:1rem">
<label>Linket virker i
<select class="wt-input" name="minutter">
{% for m, t in varigheder %}<option value="{{ m }}" {% if m == 20160 %}selected{% endif %}>{{ t }}</option>{% endfor %}
</select></label>
<label style="display:flex;gap:.4rem;align-items:center"><input type="checkbox" name="engangs" value="1" checked> Kun én person kan bruge linket</label>
<button class="wt-btn wt-btn--primary" type="submit">Lav invitation</button>
</div>
</form>
<h2 style="font-size:1.1rem">Aktive invitationer</h2>
{% if invitationer %}
<table class="wt-table">
<thead><tr><th>Til</th><th>Grupper</th><th>Udløber om</th><th>Brug</th><th></th></tr></thead>
<tbody>
{% for i in invitationer %}
<tr {% if i.udloebet %}class="wt-muted"{% endif %}>
<td><strong>{{ i.name.rsplit("-", 1)[0] }}</strong></td>
<td>{{ i.gruppenavne|join(", ") }}</td>
<td>{{ i.rest }}</td>
<td>{{ "Én person" if i.engangs else "Flere" }}</td>
<td style="white-space:nowrap">
{% if not i.udloebet %}<input id="l-{{ i.pk }}" type="hidden" value="{{ i.url }}"><button class="wt-btn" type="button" data-kopier="l-{{ i.pk }}">Kopiér</button>{% endif %}
<form method="post" action="/admin/invitations/{{ i.pk }}/slet" style="display:inline" onsubmit="return confirm('Tilbagekald invitationen? Linket holder op med at virke.')">
<button class="wt-btn wt-btn--danger" type="submit">{{ "Slet" if i.udloebet else "Tilbagekald" }}</button></form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="wt-muted">Ingen aktive invitationer.</p>
{% endif %}
<script>
(function(){
document.querySelectorAll('[data-kopier]').forEach(function(b){
b.addEventListener('click', function(){
var v = document.getElementById(b.getAttribute('data-kopier')).value;
var ok = function(){ var t=b.textContent; b.textContent='Kopieret ✓'; setTimeout(function(){ b.textContent=t; }, 1600); };
if (navigator.clipboard) { navigator.clipboard.writeText(v).then(ok, function(){ prompt('Kopiér linket:', v); }); }
else { prompt('Kopiér linket:', v); }
});
});
})();
</script>
{% endblock %}