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:
@@ -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
|
||||
|
||||
@@ -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": "🔐",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 %}
|
||||
Reference in New Issue
Block a user