diff --git a/authentik.py b/authentik.py index da0746e..26ca45a 100644 --- a/authentik.py +++ b/authentik.py @@ -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 diff --git a/config.py b/config.py index 37d1c57..642259b 100644 --- a/config.py +++ b/config.py @@ -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": "🔐", diff --git a/main.py b/main.py index 5238855..581c102 100644 --- a/main.py +++ b/main.py @@ -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) diff --git a/templates/admin_invitations.html b/templates/admin_invitations.html new file mode 100644 index 0000000..f380cd4 --- /dev/null +++ b/templates/admin_invitations.html @@ -0,0 +1,85 @@ +{% extends "base.html" %} +{% block title %}Admin — invitationer{% endblock %} +{% block body %} +
+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.
+{% if saved %}Grupper: {{ nyt.gruppenavne|join(", ") }}
+| Til | Grupper | Udløber om | Brug | |
|---|---|---|---|---|
| {{ i.name.rsplit("-", 1)[0] }} | +{{ i.gruppenavne|join(", ") }} | +{{ i.rest }} | +{{ "Én person" if i.engangs else "Flere" }} | ++ {% if not i.udloebet %}{% endif %} + + | +
Ingen aktive invitationer.
+{% endif %} + +{% endblock %}