admin: kort-visning af brugere m. type-filter (skjul service-konti) + kort-baseret gruppe-medlemskab

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
agw
2026-06-11 21:35:00 +02:00
co-authored by Claude Opus 4.8
parent 7cf79fa901
commit 19386698b5
3 changed files with 132 additions and 36 deletions
+41 -6
View File
@@ -1,15 +1,50 @@
{% extends "base.html" %}
{% block title %}Admin — {{ g.name }}{% endblock %}
{% macro ucard(u) %}
<label class="ucard" data-search="{{ (u.username ~ ' ' ~ (u.name or '') ~ ' ' ~ (u.email or ''))|lower }}">
<input type="checkbox" name="members" value="{{ u.pk }}" {% if u.pk|string in g.users %}checked{% endif %}>
<span class="uini">{{ (u.username or '?')[0]|upper }}</span>
<span class="ubody">
<span class="uname">{{ u.name or u.username }}</span>
<span class="usub" title="{{ u.username }}{% if u.email %} · {{ u.email }}{% endif %}">{{ u.username }}{% if u.email %} · {{ u.email }}{% endif %}</span>
</span>
<span class="uchk">✓</span>
</label>
{% endmacro %}
{% 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>
<p class="lead">Klik på brugerne der skal være medlem. Markerede er medlemmer.</p>
{% set people = users|rejectattr('type','in',['service_account','internal_service_account'])|list %}
{% set svcs = users|selectattr('type','in',['service_account','internal_service_account'])|list %}
<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 class="filter" style="max-width:22rem">
<span class="mag">⌕</span>
<input id="ufilter" type="search" placeholder="Søg brugere…" autocomplete="off" aria-label="Søg brugere">
</div>
<p style="margin-top:1.2rem"><button class="btn" type="submit">Gem medlemmer</button></p>
<div class="ucards" id="ucards">
{% for u in people %}{{ ucard(u) }}{% endfor %}
</div>
{% if svcs %}
<details style="margin-top:1.2rem">
<summary class="muted" style="cursor:pointer">Service-konti ({{ svcs|length }})</summary>
<div class="ucards">{% for u in svcs %}{{ ucard(u) }}{% endfor %}</div>
</details>
{% endif %}
<p style="margin-top:1.3rem"><button class="btn" type="submit">Gem medlemmer</button></p>
</form>
<script>
(function(){
var inp=document.getElementById('ufilter'); if(!inp) return;
var cards=[].slice.call(document.querySelectorAll('#ucards .ucard'));
inp.addEventListener('input',function(){
var q=inp.value.trim().toLowerCase();
cards.forEach(function(c){ c.classList.toggle('hidden', !!q && c.getAttribute('data-search').indexOf(q)<0); });
});
})();
</script>
{% endblock %}
+74 -30
View File
@@ -2,39 +2,83 @@
{% 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>
<p class="lead">Opret og administrér brugere. Adgang til services styres via <strong class="text-text">grupper</strong>.</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>
<details style="margin:1rem 0">
<summary class="btn" style="display:inline-block">+ Opret ny bruger</summary>
<form method="post" action="/admin/users" class="stat" style="display:block;margin-top:.8rem">
<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 grp in groups %}
<label style="display:flex;gap:.4rem;align-items:center;font-size:.9rem"><input type="checkbox" name="groups" value="{{ grp.pk }}" style="width:auto"> {{ grp.name }}</label>
{% endfor %}
</div>
<p style="margin-top:1rem"><button class="btn" type="submit">Opret bruger</button></p>
</form>
</details>
<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>
<div class="toolbar">
<div class="filter" style="flex:1;min-width:12rem"><span class="mag">⌕</span>
<input id="ufilter" type="search" placeholder="Søg brugere…" autocomplete="off"></div>
<div class="viewtoggle" id="typetoggle" role="group" aria-label="Brugertype">
<button type="button" data-type="person" class="on">Personer</button>
<button type="button" data-type="service">Service-konti</button>
<button type="button" data-type="all">Alle</button>
</div>
</div>
<div class="ucards" id="ucards" style="margin-top:1rem">
{% 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>
{% set svc = u.type in ['service_account','internal_service_account'] %}
<a class="ucard nav{% if not u.active %} dim{% endif %}" href="/admin/users/{{ u.pk }}"
data-type="{{ 'service' if svc else 'person' }}"
data-search="{{ (u.username ~ ' ' ~ (u.name or '') ~ ' ' ~ (u.email or ''))|lower }}">
<span class="uini">{{ (u.username or '?')[0]|upper }}</span>
<span class="ubody">
<span class="uname">{{ u.name or u.username }}</span>
<span class="usub" title="{{ u.username }}{% if u.email %} · {{ u.email }}{% endif %}">{{ u.username }}{% if u.group_names %} · {{ u.group_names|length }} grupper{% endif %}{% if not u.active %} · inaktiv{% endif %}</span>
</span>
<span class="unav">→</span>
</a>
{% endfor %}
</tbody>
</table>
</div>
<p id="unone" class="muted" style="display:none;margin-top:1rem">Ingen brugere matcher.</p>
<style>
.toolbar { display:flex; gap:1rem; align-items:center; flex-wrap:wrap; margin:1.4rem 0 .2rem; }
.viewtoggle { display:inline-flex; border:1px solid var(--border); border-radius:.7rem; overflow:hidden; }
.viewtoggle button { background:transparent; color:var(--muted); border:0; padding:.5rem .9rem; cursor:pointer; font:inherit; font-size:.85rem; }
.viewtoggle button.on { background:var(--surface2); color:var(--text); }
.ucard.nav { text-decoration:none; color:inherit; }
.ucard.nav .unav { flex:0 0 auto; color:var(--accent); opacity:.7; }
.ucard.dim { opacity:.55; }
</style>
<script>
(function(){
var inp=document.getElementById('ufilter'), tt=document.getElementById('typetoggle');
var cards=[].slice.call(document.querySelectorAll('#ucards .ucard'));
var none=document.getElementById('unone'), type='person';
function apply(){
var q=(inp.value||'').trim().toLowerCase(), shown=0;
cards.forEach(function(c){
var okType=(type==='all')||c.getAttribute('data-type')===type;
var okSearch=!q||c.getAttribute('data-search').indexOf(q)>-1;
var hit=okType&&okSearch; c.classList.toggle('hidden',!hit); if(hit) shown++;
});
none.style.display=shown?'none':'block';
}
inp.addEventListener('input',apply);
tt.addEventListener('click',function(e){var b=e.target.closest('button[data-type]'); if(!b)return;
type=b.getAttribute('data-type');
tt.querySelectorAll('button').forEach(function(x){x.classList.toggle('on',x===b);}); apply();});
apply();
})();
</script>
{% endblock %}
+17
View File
@@ -113,6 +113,23 @@
code { background:var(--surface2); padding:.12em .42em; border-radius:.35rem; font-size:.85em; }
.stat { background:color-mix(in srgb, var(--surface) 70%, transparent); border:1px solid var(--border); border-radius:1rem; padding:1.3rem; }
.stat .n { font-size:2.1rem; font-weight:800; letter-spacing:-.02em; }
/* bruger-kort (medlemskab/tildeling) — fixed bredde + afkortning, intet overlap */
.ucards { display:grid; gap:.55rem; grid-template-columns:repeat(auto-fill,minmax(15rem,1fr)); margin-top:.6rem; }
.ucard { position:relative; display:flex; align-items:center; gap:.6rem; padding:.55rem .7rem; border:1px solid var(--border);
border-radius:.8rem; background:color-mix(in srgb,var(--surface) 70%,transparent); cursor:pointer; min-width:0;
transition:border-color .15s, background .15s; }
.ucard:hover { border-color:var(--accent); }
.ucard input { position:absolute; opacity:0; pointer-events:none; }
.ucard:has(input:checked) { border-color:var(--accent); background:color-mix(in srgb,var(--accent) 12%,transparent); }
.ucard .uini { flex:0 0 auto; width:1.9rem; height:1.9rem; border-radius:.5rem; display:grid; place-items:center;
font-weight:700; font-size:.85rem; background:color-mix(in srgb,var(--accent) 16%,transparent); color:var(--accent); }
.ucard .ubody { min-width:0; flex:1; }
.ucard .uname { font-weight:600; font-size:.9rem; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.ucard .usub { color:var(--muted); font-size:.78rem; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.ucard .uchk { flex:0 0 auto; width:1.25rem; height:1.25rem; border:1.5px solid var(--border); border-radius:.35rem;
display:grid; place-items:center; color:transparent; font-size:.8rem; font-weight:700; }
.ucard:has(input:checked) .uchk { background:var(--accent); border-color:var(--accent); color:#06101f; }
.ucard.hidden { display:none; }
/* footer */
.foot { width:100%; max-width:960px; margin:0 auto; padding:1.6rem 1.25rem 2.5rem; color:var(--muted); font-size:.85rem; border-top:1px solid var(--border); }
.foot a { color:var(--accent); text-decoration:none; }