feat: production-ready domain control dashboard
Container-Image veröffentlichen / container (push) Has been cancelled

This commit is contained in:
admin committed 2026-07-12 20:56:45 +02:00
1 parent 9c60eaf3fe
commit df7516e7ab
45 files changed
+7932 -1

No files matched your search

+58
View File
@@ -0,0 +1,58 @@
{{define "dashboard"}}{{template "layout" .}}{{end}}
{{define "content"}}
<header class="page-heading">
<div>
<span class="page-kicker">Systemstatus</span>
<h1>Übersicht</h1>
<p>Status der verbundenen DNS-Zonen und des automatischen Update-Dienstes.</p>
</div>
{{if .IsAdmin}}
<div class="heading-actions">
<form action="/run" method="post">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<button class="button button-primary" type="submit" {{if not .Dashboard.Settings.UpdaterEnabled}}disabled title="Updater ist deaktiviert"{{end}}>Update jetzt ausführen</button>
</form>
{{if not .Dashboard.Settings.UpdaterEnabled}}<a class="button button-quiet" href="/settings">Dienst aktivieren</a>{{end}}
</div>
{{end}}
</header>
<section class="metric-grid" aria-label="DynDNS-Status">
<article class="metric-card"><span class="metric-label">Aktive Ziele</span><strong>{{.Dashboard.ActiveTargets}}</strong><small>{{len .Dashboard.Zones}} sichtbare DNS-Zone(n)</small></article>
<article class="metric-card"><span class="metric-label">Öffentliche IPv4</span><strong class="mono address-value">{{if .Dashboard.Settings.LastIPv4}}{{.Dashboard.Settings.LastIPv4}}{{else}}Nicht ermittelt{{end}}</strong><small>Quelle: externer IP-Resolver</small></article>
<article class="metric-card {{if not .Dashboard.Settings.IPv6Enabled}}metric-card-disabled{{end}}"><span class="metric-label">Öffentliche IPv6</span><strong class="mono address-value compact">{{if not .Dashboard.Settings.IPv6Enabled}}Deaktiviert{{else if .Dashboard.Settings.LastIPv6}}{{.Dashboard.Settings.LastIPv6}}{{else}}Nicht ermittelt{{end}}</strong><small>{{if .Dashboard.Settings.IPv6Enabled}}Quelle: externer IP-Resolver{{else}}AAAA-Updates pausiert{{end}}</small></article>
<article class="metric-card countdown-card {{if not .Dashboard.Settings.UpdaterEnabled}}metric-card-disabled{{end}}"><span class="metric-label">Nächste Aktualisierung</span><strong class="mono countdown" data-countdown="{{unixSeconds .Dashboard.NextRunAt}}" data-running="{{.Dashboard.SchedulerRunning}}" data-enabled="{{.Dashboard.Settings.UpdaterEnabled}}">{{if .Dashboard.Settings.UpdaterEnabled}}--:--{{else}}Inaktiv{{end}}</strong><small>{{if .Dashboard.Settings.UpdaterEnabled}}Intervall: {{.Dashboard.Settings.UpdateIntervalSeconds}} Sekunden{{else}}Dienst in den Einstellungen deaktiviert{{end}}</small></article>
</section>
<section class="dashboard-grid">
{{if .IsAdmin}}
<article class="panel">
<div class="panel-heading"><div><span class="section-label">Scheduler</span><h2>Letzte Update-Läufe</h2></div><div class="panel-links"><a href="/errors" class="{{if .Dashboard.ErrorCount}}text-danger{{end}}">Alle Fehler{{if .Dashboard.ErrorCount}} · {{.Dashboard.ErrorCount}}{{end}}</a><a href="/settings">Konfiguration</a></div></div>
{{if .Dashboard.RecentRuns}}
<div class="data-list">
{{range .Dashboard.RecentRuns}}
<div class="data-row"><span class="state-dot {{statusClass .Status}}"></span><span><strong>{{.Summary}}</strong><small>{{formatTime .StartedAt}}</small></span><span class="status-pill {{statusClass .Status}}">{{.Status}}</span></div>
{{end}}
</div>
{{else}}<div class="empty-state">Noch kein Update-Lauf protokolliert.</div>{{end}}
</article>
{{end}}
<article class="panel">
<div class="panel-heading"><div><span class="section-label">DNS-Zonen</span><h2>Verbundene Domains</h2></div><a href="/domains">Verwalten</a></div>
{{if .Dashboard.Zones}}
<div class="domain-list">
{{range .Dashboard.Zones}}
<a href="/domains/{{.ID}}"><span class="zone-glyph">DNS</span><span><strong>{{.Name}}</strong><small>Synchronisiert: {{formatTime .LastSyncAt}}</small></span><span class="status-pill {{if .LastError}}status-bad{{else}}status-good{{end}}">{{if .LastError}}Fehler{{else}}Bereit{{end}}</span></a>
{{end}}
</div>
{{else}}<div class="empty-state">{{if .IsAdmin}}Keine Domain verbunden. <a href="/domains/new">Domain hinzufügen</a>{{else}}Keine Domain zugewiesen.{{end}}</div>{{end}}
</article>
</section>
{{if and .IsAdmin .Dashboard.RecentEvents}}
<section class="panel audit-panel">
<div class="panel-heading"><div><span class="section-label">Audit</span><h2>Systemereignisse</h2></div></div>
<ul class="audit-list">{{range .Dashboard.RecentEvents}}<li><time>{{formatTime .CreatedAt}}</time><strong>{{.Message}}</strong><code>{{.Kind}}</code></li>{{end}}</ul>
</section>
{{end}}
{{end}}
+128
View File
@@ -0,0 +1,128 @@
{{define "domain_detail"}}{{template "layout" .}}{{end}}
{{define "content"}}
<header class="page-heading">
<div><a class="back-link" href="/domains">← Domains</a><span class="page-kicker">DNS-Zone</span><h1>{{.Zone.Name}}</h1><p>{{if .IsAdmin}}DynDNS-Ziele und statische CNAME-Aliase dieser Zone.{{else}}Dir zugewiesene DynDNS-Ziele dieser Zone.{{end}}</p></div>
{{if .IsAdmin}}<form action="/domains/{{.Zone.ID}}/import" method="post"><input type="hidden" name="csrf" value="{{.CSRF}}"><button class="button button-quiet" type="submit">Records neu einlesen</button></form>{{end}}
</header>
<section class="zone-summary" aria-label="Zonenstatus">
<div><span>Provider</span><strong>Hetzner Cloud DNS</strong></div>
<div><span>Letzter Import</span><strong>{{formatTime .Zone.LastSyncAt}}</strong></div>
<div><span>Verknüpfte Ziele</span><strong>{{len .Targets}}</strong></div>
{{if .IsAdmin}}<div><span>Getrennte Ziele</span><strong>{{len .ArchivedTargets}}</strong></div>{{end}}
</section>
{{if .IsAdmin}}
<section class="bulk-ttl-panel" aria-label="TTL für verwaltete Records setzen">
<form action="/domains/{{.Zone.ID}}/ttl" method="post" class="bulk-ttl-form"><input type="hidden" name="csrf" value="{{.CSRF}}"><div><span class="section-label">Sammelaktion</span><strong>TTL für alle verwalteten Records</strong><small>A-/AAAA-Ziele und CNAMEs dieser Oberfläche; andere Hetzner-Records bleiben unverändert.</small></div><label>TTL <select name="ttl" required>{{range ttlOptions}}<option value="{{.}}" {{if eq . 60}}selected{{end}}>{{.}} Sekunden</option>{{end}}</select></label><button class="button button-quiet" type="submit">TTL für alle setzen</button></form>
</section>
{{end}}
<section class="targets-section">
<div class="section-heading"><div><span class="section-label">Verknüpfte Records</span><h2>Subdomains und Root-Record</h2></div><span class="record-count">{{len .Targets}} Einträge</span></div>
{{if .Targets}}
<div class="target-list">
{{range .Targets}}
{{$target := .}}
{{$canManage := or $.IsAdmin (index $.ManageTargetIDs .ID)}}
<article class="target-row {{if .Active}}is-active{{end}}">
<div class="target-main"><span class="target-status {{if .Active}}on{{end}}" aria-label="{{if .Active}}aktiv{{else}}inaktiv{{end}}"></span><div><h3>{{.FQDN}}</h3><p><span class="family-chip">{{families .}}</span><span>{{if .Description}}{{.Description}}{{else}}Keine Beschreibung{{end}}</span></p></div></div>
<div class="target-meta"><time>{{formatTime .LastUpdatedAt}}</time><span class="status-pill {{statusClass .LastStatus}}">{{if .LastStatus}}{{.LastStatus}}{{else}}Nicht ausgeführt{{end}}</span></div>
{{if $canManage}}<details class="target-edit"><summary>Einstellungen <span>⌄</span></summary><div class="edit-inner">
<form action="/targets/{{.ID}}/save" method="post" class="target-form" data-target-form data-ipv6-enabled="{{$.Settings.IPv6Enabled}}" data-update-ipv4="{{.UpdateIPv4}}" data-update-ipv6="{{.UpdateIPv6}}">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<div class="toggle-row"><label class="switch"><input type="checkbox" name="active" data-target-active {{if .Active}}checked{{end}}><span></span> DynDNS aktiv</label>{{if $.IsAdmin}}<label class="switch"><input type="checkbox" name="update_ipv4" {{if .UpdateIPv4}}checked{{end}}><span></span> IPv4 / A</label><label class="switch {{if not $.Settings.IPv6Enabled}}switch-disabled{{end}}"><input type="checkbox" name="update_ipv6" {{if and .UpdateIPv6 $.Settings.IPv6Enabled}}checked{{end}} {{if not $.Settings.IPv6Enabled}}disabled{{end}}><span></span> IPv6 / AAAA{{if not $.Settings.IPv6Enabled}} pausiert{{end}}</label>{{end}}</div>
<div class="form-grid" data-static-ip-fields {{if .Active}}hidden{{end}} aria-hidden="{{if .Active}}true{{else}}false{{end}}"><label>Feste IPv4 <input name="static_ipv4" data-static-ipv4 value="{{.StaticIPv4}}" inputmode="decimal" placeholder="203.0.113.10" {{if .Active}}disabled{{else if .UpdateIPv4}}required{{else}}disabled{{end}}><small>Pflichtfeld, wenn IPv4 aktiv und DynDNS aus ist.</small></label><label class="{{if not $.Settings.IPv6Enabled}}switch-disabled{{end}}">Feste IPv6 <input name="static_ipv6" data-static-ipv6 value="{{.StaticIPv6}}" inputmode="text" placeholder="2001:db8::10" {{if or .Active (not $.Settings.IPv6Enabled)}}disabled{{else if .UpdateIPv6}}required{{else}}disabled{{end}}><small>Pflichtfeld, wenn IPv6 aktiv und DynDNS aus ist.</small></label></div>
<p class="target-requirement" data-target-requirement hidden>Trage für jede aktive IP-Familie eine feste Adresse ein oder aktiviere DynDNS.</p>
<div class="form-grid"><label>Beschreibung <input name="description" value="{{.Description}}" maxlength="512" placeholder="Interne Beschreibung"></label><label>TTL <select name="ttl" required>{{if not (supportedTTL $target.TTL)}}<option value="{{$target.TTL}}" selected>{{$target.TTL}} Sekunden (bestehend)</option>{{end}}{{range ttlOptions}}<option value="{{.}}" {{if eq . $target.TTL}}selected{{end}}>{{.}} Sekunden</option>{{end}}</select></label></div>
<div class="form-actions"><button class="button button-primary" type="submit">Speichern</button></div>
</form>
{{if $.IsAdmin}}
<div class="danger-actions"><form action="/targets/{{.ID}}/archive" method="post" class="unlink-form">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<strong>Verknüpfung trennen</strong>
<p>Entfernt nur die lokale DynDNS-Verknüpfung. Die DNS-Records bleiben erhalten.</p>
<button class="button button-danger-soft" type="submit">Verknüpfung trennen</button>
</form><form action="/targets/{{.ID}}/delete" method="post" class="unlink-form">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<strong>Ziel vollständig löschen</strong>
<p>Löscht das Ziel und alle zugehörigen A-/AAAA-Records bei Hetzner.</p>
<button class="button button-danger-soft" type="submit">Ziel löschen</button>
</form></div>
{{end}}
</div></details>{{end}}
{{if .LastError}}<p class="row-error">{{.LastError}}</p>{{end}}
</article>
{{end}}
</div>
{{else}}<div class="empty-state">Keine verknüpften A-/AAAA-Records vorhanden. {{if or .IsAdmin .CanCreateTargets}}Lege unten eine neue Subdomain an.{{end}}</div>{{end}}
</section>
{{if .IsAdmin}}
<section class="targets-section">
<div class="section-heading"><div><span class="section-label">Statische Records</span><h2>CNAME-Aliase</h2></div><span class="record-count">{{len .CNAMERecords}} Einträge</span></div>
{{if .CNAMERecords}}
<div class="target-list">
{{range .CNAMERecords}}
{{$cname := .}}
<article class="target-row {{if .Active}}is-active{{end}}">
<div class="target-main"><span class="target-status {{if .Active}}on{{end}}" aria-label="{{if .Active}}aktiv{{else}}inaktiv{{end}}"></span><div><h3>{{.FQDN}}</h3><p><span class="family-chip">CNAME</span><span>→ {{.Destination}}</span>{{if .Description}}<span>· {{.Description}}</span>{{end}}</p></div></div>
<div class="target-meta"><span class="status-pill {{if .Active}}status-good{{end}}">{{if .Active}}Aktiv{{else}}Inaktiv{{end}}</span></div>
<details class="target-edit"><summary>Bearbeiten <span>⌄</span></summary><div class="edit-inner">
<form action="/cnames/{{.ID}}/save" method="post" class="target-form">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<div class="toggle-row"><label class="switch"><input type="checkbox" name="active" {{if .Active}}checked{{end}}><span></span> CNAME aktiv</label></div>
<div class="form-grid cname-edit-grid"><label>CNAME-Ziel <input name="destination" value="{{.Destination}}" maxlength="253" required></label><label>TTL <select name="ttl" required>{{if not (supportedTTL $cname.TTL)}}<option value="{{$cname.TTL}}" selected>{{$cname.TTL}} Sekunden (bestehend)</option>{{end}}{{range ttlOptions}}<option value="{{.}}" {{if eq . $cname.TTL}}selected{{end}}>{{.}} Sekunden</option>{{end}}</select></label></div><small class="field-note">Vollständiger Domainname; der abschließende Punkt wird ergänzt.</small>
<label>Beschreibung <input name="description" value="{{.Description}}" maxlength="512" placeholder="Interne Beschreibung"></label>
<div class="form-actions"><button class="button button-primary" type="submit">Speichern</button></div>
</form>
<div class="danger-actions"><form action="/cnames/{{.ID}}/remove" method="post" class="unlink-form">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<strong>Aus WebUI entfernen</strong>
<p>Entfernt nur die lokale Verknüpfung. Der CNAME-Record bei Hetzner bleibt unverändert.</p>
<button class="button button-danger-soft" type="submit">Aus WebUI entfernen</button>
</form><form action="/cnames/{{.ID}}/delete" method="post" class="unlink-form">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<strong>Ziel vollständig löschen</strong>
<p>Löscht das Ziel aus der WebUI und den zugehörigen CNAME-Record bei Hetzner.</p>
<button class="button button-danger-soft" type="submit">Ziel löschen</button>
</form></div>
</div></details>
</article>
{{end}}
</div>
{{else}}<div class="empty-state">Keine CNAME-Aliase vorhanden. Lege unten einen neuen Alias an.</div>{{end}}
</section>
{{end}}
{{if and .IsAdmin .ArchivedTargets}}
<section class="panel archive-panel">
<div class="panel-heading"><div><span class="section-label">Archiv</span><h2>Getrennte Ziele</h2></div><span class="record-count">{{len .ArchivedTargets}} Einträge</span></div>
<p class="panel-description">Diese Einträge sind nicht mehr mit dem Updater verknüpft. Die zugehörigen Hetzner-DNS-Records wurden beim Trennen nicht gelöscht.</p>
<div class="archive-list">
{{range .ArchivedTargets}}
<div class="archive-row"><span><strong>{{.FQDN}}</strong><small>{{families .}} · getrennt {{formatTime .ArchivedAt}}</small></span><form action="/targets/{{.ID}}/restore" method="post"><input type="hidden" name="csrf" value="{{$.CSRF}}"><button class="button button-quiet" type="submit">Wieder verknüpfen</button></form></div>
{{end}}
</div>
</section>
{{end}}
{{if or .IsAdmin .CanCreateTargets}}
<section class="action-grid admin-actions-grid">
<article class="panel"><div class="panel-heading"><div><span class="section-label">Record anlegen</span><h2>Neue Subdomain</h2></div></div>
<form action="/targets" method="post" class="stacked-form" data-target-form data-ipv6-enabled="{{.Settings.IPv6Enabled}}" data-update-ipv4="true" data-update-ipv6="false"><input type="hidden" name="csrf" value="{{.CSRF}}"><input type="hidden" name="zone_id" value="{{.Zone.ID}}"><label>{{if .IsAdmin}}Name oder Root-Record{{else}}Subdomain{{end}} <input name="record_name" required placeholder="{{if .IsAdmin}}home oder @{{else}}home{{end}}"><small>Die Zone {{.Zone.Name}} wird automatisch ergänzt.</small></label><div class="toggle-row"><label class="switch"><input type="checkbox" name="active" data-target-active checked><span></span> DynDNS aktivieren</label><label class="switch"><input type="checkbox" name="update_ipv4" checked><span></span> IPv4</label><label class="switch {{if not .Settings.IPv6Enabled}}switch-disabled{{end}}"><input type="checkbox" name="update_ipv6" {{if not .Settings.IPv6Enabled}}disabled{{end}}><span></span> IPv6{{if not .Settings.IPv6Enabled}} pausiert{{end}}</label></div><div class="form-grid" data-static-ip-fields hidden aria-hidden="true"><label>Feste IPv4 <input name="static_ipv4" data-static-ipv4 inputmode="decimal" placeholder="203.0.113.10" disabled><small>Pflichtfeld, wenn IPv4 aktiv und DynDNS aus ist.</small></label><label class="{{if not .Settings.IPv6Enabled}}switch-disabled{{end}}">Feste IPv6 <input name="static_ipv6" data-static-ipv6 inputmode="text" placeholder="2001:db8::10" disabled><small>Pflichtfeld, wenn IPv6 aktiv und DynDNS aus ist.</small></label></div><p class="target-requirement" data-target-requirement hidden>Trage für jede aktive IP-Familie eine feste Adresse ein oder aktiviere DynDNS.</p><div class="form-grid"><label>Beschreibung <input name="description" maxlength="512" placeholder="Interne Beschreibung"></label><label>TTL <select name="ttl" required>{{range ttlOptions}}<option value="{{.}}" {{if eq . 60}}selected{{end}}>{{.}} Sekunden</option>{{end}}</select></label></div><button class="button button-primary" type="submit">Record anlegen</button></form>
</article>
{{if .IsAdmin}}
<article class="panel"><div class="panel-heading"><div><span class="section-label">Statischer Record</span><h2>Neuen CNAME anlegen</h2></div></div>
<form action="/cnames" method="post" class="stacked-form"><input type="hidden" name="csrf" value="{{.CSRF}}"><input type="hidden" name="zone_id" value="{{.Zone.ID}}"><div class="form-grid"><label>Alias <input name="record_name" required placeholder="www"><small>Die Zone {{.Zone.Name}} wird automatisch ergänzt.</small></label><label>CNAME-Ziel <input name="destination" required placeholder="app.example.net."><small>Vollständiger Domainname; ein abschließender Punkt ist optional.</small></label></div><div class="form-grid"><label>Beschreibung <input name="description" maxlength="512" placeholder="Interne Beschreibung"></label><label>TTL <select name="ttl" required>{{range ttlOptions}}<option value="{{.}}" {{if eq . 60}}selected{{end}}>{{.}} Sekunden</option>{{end}}</select></label></div><div class="toggle-row"><label class="switch"><input type="checkbox" name="active" checked><span></span> CNAME aktivieren</label></div><button class="button button-primary" type="submit">CNAME anlegen</button></form>
</article>
<article class="panel"><div class="panel-heading"><div><span class="section-label">Zonenzugang</span><h2>Verbindung verwalten</h2></div></div>
<p class="panel-description">Der API-Token bleibt verschlüsselt. Eine Anzeige erfordert erneut das Admin-Passwort.</p>
<details><summary class="button button-quiet">API-Token anzeigen</summary><form action="/domains/{{.Zone.ID}}/token" method="post" class="stacked-form inset-form"><input type="hidden" name="csrf" value="{{.CSRF}}"><label>Admin-Passwort <input type="password" name="password" autocomplete="current-password" required></label><button class="button button-danger-soft" type="submit">Token entschlüsseln</button></form></details>
<div class="panel-divider"></div>
<details><summary class="text-danger">Domain-Verbindung entfernen</summary><div class="danger-actions inset-form"><form action="/domains/{{.Zone.ID}}/remove" method="post" class="unlink-form"><input type="hidden" name="csrf" value="{{.CSRF}}"><strong>Nur aus der WebUI entfernen</strong><p>Löscht ausschließlich die lokale Konfiguration. Alle DNS-Records bei Hetzner bleiben bestehen.</p><button class="button button-danger-soft" type="submit">Lokale Verbindung entfernen</button></form><form action="/domains/{{.Zone.ID}}/delete" method="post" class="unlink-form"><input type="hidden" name="csrf" value="{{.CSRF}}"><strong>Domain vollständig löschen</strong><p>Löscht alle hier verwalteten A-, AAAA- und CNAME-Einträge aus der WebUI sowie die zugehörigen Records bei Hetzner. Andere Record-Typen bleiben bestehen.</p><button class="button button-danger-soft" type="submit">Domain und Records löschen</button></form></div></details>
</article>
{{end}}
</section>
{{end}}
{{end}}
+13
View File
@@ -0,0 +1,13 @@
{{define "domain_new"}}{{template "layout" .}}{{end}}
{{define "content"}}
<header class="page-heading"><div><a class="back-link" href="/domains">← Domains</a><span class="page-kicker">Hetzner-Verbindung</span><h1>Domain hinzufügen</h1><p>Nach erfolgreicher Anmeldung werden alle vorhandenen A- und AAAA-Records der Zone automatisch importiert und als inaktive DynDNS-Ziele angezeigt.</p></div></header>
<section class="form-panel compact-panel">
<form action="/domains" method="post" class="stacked-form wide-form">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<div class="form-grid"><label>Interne Bezeichnung <input name="label" type="text" value="{{index .Form "label"}}" placeholder="Privates DNS-Projekt"></label><label>DNS-Zone <input name="zone" type="text" value="{{index .Form "zone"}}" placeholder="example.de" required autocomplete="off"><small>Muss im Hetzner-Projekt des Tokens vorhanden sein.</small></label></div>
<label>Hetzner API-Token <input name="token" type="password" autocomplete="off" spellcheck="false" required><small>Wird validiert und anschließend verschlüsselt gespeichert.</small></label>
<div class="info-box"><strong>Automatischer Import</strong><span>Nach dem Verbinden liest die Anwendung alle A-/AAAA-RRsets ein. Kein importierter Record wird automatisch für DynDNS aktiviert.</span></div>
<div class="form-actions"><button class="button button-primary" type="submit">Verbindung prüfen und importieren</button><a class="button button-quiet" href="/domains">Abbrechen</a></div>
</form>
</section>
{{end}}
+22
View File
@@ -0,0 +1,22 @@
{{define "domains"}}{{template "layout" .}}{{end}}
{{define "content"}}
<header class="page-heading">
<div><span class="page-kicker">DNS-Verwaltung</span><h1>Domains</h1><p>{{if .IsAdmin}}Hetzner-Zonen, Zugangszustand und DynDNS-Ziele verwalten.{{else}}Freigegebene Domains und Subdomains verwalten.{{end}}</p></div>
{{if .IsAdmin}}<a class="button button-primary" href="/domains/new">Domain hinzufügen</a>{{end}}
</header>
<section class="domain-board">
{{if .Zones}}
{{range .Zones}}
<article class="zone-card">
<div class="zone-card-top"><span class="zone-glyph">DNS</span><span class="status-pill {{if .LastError}}status-bad{{else}}status-good{{end}}">{{if .LastError}}Verbindungsfehler{{else}}Verbunden{{end}}</span></div>
<h2>{{.Name}}</h2>
<dl><div><dt>Letzter Import</dt><dd>{{formatTime .LastSyncAt}}</dd></div><div><dt>Provider</dt><dd>Hetzner Cloud DNS</dd></div></dl>
{{if .LastError}}<p class="inline-error">{{.LastError}}</p>{{end}}
<a href="/domains/{{.ID}}" class="card-link">Ziele öffnen <span>→</span></a>
</article>
{{end}}
{{else}}
<div class="empty-board"><span class="empty-icon">DNS</span><h2>{{if .IsAdmin}}Keine Domain verbunden{{else}}Keine Berechtigung zugewiesen{{end}}</h2><p>{{if .IsAdmin}}Füge eine Hetzner-Zone hinzu. A- und AAAA-Records werden beim Verbinden automatisch importiert.{{else}}Ein Admin kann dir eine vollständige Domain oder einzelne Subdomains freigeben.{{end}}</p>{{if .IsAdmin}}<a class="button button-primary" href="/domains/new">Domain hinzufügen</a>{{end}}</div>
{{end}}
</section>
{{end}}
+53
View File
@@ -0,0 +1,53 @@
{{define "errors"}}{{template "layout" .}}{{end}}
{{define "content"}}
<header class="page-heading">
<div><a class="back-link" href="/">← Übersicht</a><span class="page-kicker">Diagnose</span><h1>Fehlerzentrale</h1><p>Aktuelle Ziel- und Zonenfehler sowie alle gespeicherten fehlgeschlagenen oder partiellen Update-Läufe.</p></div>
<form action="/errors/clear" method="post">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<button class="button button-danger-soft" type="submit" {{if eq .ErrorCount 0}}disabled{{end}}>Fehlerliste leeren</button>
</form>
</header>
{{if eq .ErrorCount 0}}
<section class="panel error-empty"><span class="error-empty-icon" aria-hidden="true">✓</span><div><h2>Keine Fehler gespeichert</h2><p>Aktuell sind weder Ziel- oder Zonenfehler noch problematische Update-Läufe vorhanden.</p></div></section>
{{else}}
<section class="error-summary-grid" aria-label="Fehlerübersicht">
<article><span>Ziele</span><strong>{{len .ErrorTargets}}</strong></article>
<article><span>Zonen</span><strong>{{len .ErrorZones}}</strong></article>
<article><span>Update-Läufe</span><strong>{{len .ErrorRuns}}</strong></article>
</section>
{{if .ErrorTargets}}
<section class="panel error-section">
<div class="panel-heading"><div><span class="section-label">Aktuell</span><h2>Zielprobleme</h2></div><span class="status-pill status-bad">{{len .ErrorTargets}}</span></div>
<div class="error-list">
{{range .ErrorTargets}}
<article class="error-item"><span class="error-mark" aria-hidden="true">!</span><div><strong>{{.FQDN}}</strong><p>{{.LastError}}</p><small>{{if .LastStatus}}{{.LastStatus}}{{else}}Fehler{{end}} · letzter erfolgreicher Abgleich {{formatTime .LastUpdatedAt}}</small></div><a href="/domains/{{.ZoneID}}">Öffnen →</a></article>
{{end}}
</div>
</section>
{{end}}
{{if .ErrorZones}}
<section class="panel error-section">
<div class="panel-heading"><div><span class="section-label">Provider</span><h2>Zonenprobleme</h2></div><span class="status-pill status-bad">{{len .ErrorZones}}</span></div>
<div class="error-list">
{{range .ErrorZones}}
<article class="error-item"><span class="error-mark" aria-hidden="true">!</span><div><strong>{{.Name}}</strong><p>{{.LastError}}</p><small>Letzter Synchronisationsversuch {{formatTime .LastSyncAt}}</small></div><a href="/domains/{{.ID}}">Öffnen →</a></article>
{{end}}
</div>
</section>
{{end}}
{{if .ErrorRuns}}
<section class="panel error-section">
<div class="panel-heading"><div><span class="section-label">Historie</span><h2>Problematische Update-Läufe</h2></div><span class="status-pill status-bad">{{len .ErrorRuns}}</span></div>
<div class="error-list run-error-list">
{{range .ErrorRuns}}
<article class="error-item"><span class="state-dot {{statusClass .Status}}"></span><div><strong>{{.Summary}}</strong><small>{{formatTime .StartedAt}} · {{.Status}}{{if .IPv4}} · IPv4 {{.IPv4}}{{end}}{{if .IPv6}} · IPv6 {{.IPv6}}{{end}}</small></div></article>
{{end}}
</div>
</section>
{{end}}
{{end}}
{{end}}
+71
View File
@@ -0,0 +1,71 @@
{{define "layout"}}
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light">
<title>{{.Title}} · Hetzner DynDNS</title>
<link rel="stylesheet" href="/static/styles.css?v=features-5">
<script src="/static/app.js?v=features-5" defer></script>
</head>
<body class="page-{{.Page}}">
<a class="skip-link" href="#main">Zum Inhalt springen</a>
{{if and (ne .Page "setup") (ne .Page "login")}}
<div class="admin-layout">
<aside class="sidebar">
<a class="brand" href="/" aria-label="Hetzner DynDNS Startseite">
<span class="brand-mark">DD</span>
<span><strong>DynDNS Console</strong><small>Hetzner DNS</small></span>
</a>
<div class="instance-state {{if not .UpdaterEnabled}}is-inactive{{end}}"><span></span> Dienst {{if .UpdaterEnabled}}aktiv{{else}}inaktiv{{end}}</div>
<nav aria-label="Hauptnavigation">
<a href="/" {{if eq .Page "dashboard"}}aria-current="page"{{end}}><span class="nav-icon">01</span>Übersicht</a>
<a href="/domains" {{if eq .Page "domains"}}aria-current="page"{{end}}><span class="nav-icon">02</span>Domains</a>
{{if .IsAdmin}}
<a href="/settings" {{if eq .Page "settings"}}aria-current="page"{{end}}><span class="nav-icon">03</span>Updater</a>
<a href="/errors" {{if eq .Page "errors"}}aria-current="page"{{end}}><span class="nav-icon">04</span>Fehler</a>
<a href="/users" {{if eq .Page "users"}}aria-current="page"{{end}}><span class="nav-icon">05</span>Benutzer</a>
{{end}}
<a href="/security" {{if eq .Page "security"}}aria-current="page"{{end}}><span class="nav-icon">{{if .IsAdmin}}06{{else}}03{{end}}</span>Sicherheit</a>
</nav>
<div class="sidebar-account">
<span class="account-avatar">{{initial .User.DisplayName}}</span>
<span><strong>{{.User.DisplayName}}</strong><small>@{{.User.Username}} · {{if .IsAdmin}}Admin{{else}}Mitglied{{end}}</small></span>
<form action="/logout" method="post">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<button type="submit" title="Abmelden" aria-label="Abmelden">↪</button>
</form>
</div>
</aside>
<div class="workspace">
<header class="workspace-bar">
<div><span>DynDNS</span><strong>/ {{.Title}}</strong></div>
{{if eq .Mode "lan"}}<span class="environment-badge">LAN · HTTP</span>{{else}}<span class="environment-badge secure">HTTPS</span>{{end}}
</header>
<main id="main" class="app-shell">
{{template "content" .}}
</main>
</div>
</div>
{{else}}
<main id="main" class="auth-shell">
{{template "content" .}}
</main>
{{end}}
{{if .Flash}}
<div class="toast-region">
<div class="toast toast-{{.Flash.Kind}}" data-toast role="{{if eq .Flash.Kind "error"}}alert{{else}}status{{end}}" aria-atomic="true">
<span class="toast-icon" aria-hidden="true">{{if eq .Flash.Kind "error"}}!{{else if eq .Flash.Kind "info"}}i{{else}}✓{{end}}</span>
<div class="toast-copy">
<span class="toast-eyebrow">{{if eq .Flash.Kind "error"}}Bitte prüfen{{else if eq .Flash.Kind "info"}}Hinweis{{else}}Erledigt{{end}}</span>
<p>{{.Flash.Message}}</p>
</div>
<button class="toast-close" type="button" data-toast-close aria-label="Meldung schließen">×</button>
<span class="toast-progress" aria-hidden="true"></span>
</div>
</div>
{{end}}
</body>
</html>
{{end}}
+18
View File
@@ -0,0 +1,18 @@
{{define "login"}}{{template "layout" .}}{{end}}
{{define "content"}}
<section class="auth-card">
<div class="auth-brand"><span>DD</span> DynDNS Console</div>
<h1>Anmelden</h1>
<form action="/login" method="post" class="stacked-form">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<label>Benutzername
<input name="username" type="text" autocomplete="username" required autofocus>
</label>
<label>Passwort
<input name="password" type="password" autocomplete="current-password" required>
</label>
<button class="button button-primary" type="submit">Anmelden <span aria-hidden="true">→</span></button>
</form>
<div class="login-meta">Sitzungen laufen nach acht Stunden automatisch ab.</div>
</section>
{{end}}
+8
View File
@@ -0,0 +1,8 @@
{{define "security"}}{{template "layout" .}}{{end}}
{{define "content"}}
<header class="page-heading"><div><span class="page-kicker">Konto und Laufzeit</span><h1>Sicherheit</h1><p>Eigenes Passwort ändern und aktive Sicherheitsmechanismen der Instanz prüfen.</p></div></header>
<section class="split-grid">
<article class="panel"><div class="panel-heading"><div><span class="section-label">Anmeldedaten</span><h2>Passwort ändern</h2></div></div><form action="/security/password" method="post" class="stacked-form"><input type="hidden" name="csrf" value="{{.CSRF}}"><label>Aktuelles Passwort <input type="password" name="current_password" autocomplete="current-password" required></label><label>Neues Passwort <input type="password" name="new_password" autocomplete="new-password" minlength="12" required></label><label>Neues Passwort wiederholen <input type="password" name="confirm_password" autocomplete="new-password" minlength="12" required></label><button class="button button-primary" type="submit">Passwort aktualisieren</button></form></article>
<article class="panel security-facts"><div class="panel-heading"><div><span class="section-label">Instanzschutz</span><h2>Aktive Maßnahmen</h2></div></div><ul><li><strong>Argon2id</strong><span>Passwörter werden ausschließlich als gehärtete Hashes gespeichert.</span></li><li><strong>XChaCha20-Poly1305</strong><span>Hetzner-Tokens sind authentifiziert verschlüsselt.</span></li><li><strong>Rollen und Grants</strong><span>Mitglieder sehen nur explizit freigegebene Domains oder Hosts.</span></li><li><strong>CSRF und Sessions</strong><span>Schreibzugriffe sind tokengebunden; Sitzungen laufen automatisch ab.</span></li></ul>{{if eq .Mode "lan"}}<div class="warning-box">HTTP-LAN-Modus aktiv. Nur in einem isolierten, vertrauenswürdigen Netzwerk verwenden.</div>{{end}}</article>
</section>
{{end}}
+43
View File
@@ -0,0 +1,43 @@
{{define "settings"}}{{template "layout" .}}{{end}}
{{define "content"}}
<header class="page-heading">
<div><span class="page-kicker">Scheduler</span><h1>Updater-Konfiguration</h1><p>Dienststatus, Intervall und IP-Familien zentral steuern.</p></div>
</header>
<form action="/settings" method="post" class="settings-form" data-settings-form>
<input type="hidden" name="csrf" value="{{.CSRF}}">
<section class="settings-mode-grid" aria-label="Updater-Schalter">
<label class="setting-switch-card {{if .Settings.UpdaterEnabled}}is-enabled{{end}}">
<span class="setting-switch-copy"><span class="section-label">Automatik</span><strong>DynDNS-Dienst</strong><small>Stoppt geplante und automatisch ausgelöste Aktualisierungen vollständig.</small></span>
<span class="switch setting-switch"><input type="checkbox" name="updater_enabled" data-updater-toggle {{if .Settings.UpdaterEnabled}}checked{{end}}><span></span><b>{{if .Settings.UpdaterEnabled}}Aktiv{{else}}Inaktiv{{end}}</b></span>
</label>
<label class="setting-switch-card {{if .Settings.IPv6Enabled}}is-enabled{{end}}">
<span class="setting-switch-copy"><span class="section-label">IP-Familie</span><strong>IPv6-Aktualisierung</strong><small>Deaktiviert IPv6-Ermittlung und AAAA-Updates, ohne Zielkonfigurationen zu löschen.</small></span>
<span class="switch setting-switch"><input type="checkbox" name="ipv6_enabled" data-ipv6-toggle {{if .Settings.IPv6Enabled}}checked{{end}}><span></span><b>{{if .Settings.IPv6Enabled}}Aktiv{{else}}Inaktiv{{end}}</b></span>
</label>
</section>
<section class="form-panel compact-panel settings-fields">
<div class="stacked-form wide-form">
<div class="form-grid">
<label>Abfrageintervall in Sekunden
<input type="number" name="interval" min="60" max="86400" value="{{.Settings.UpdateIntervalSeconds}}" required>
<small>Zulässig: 60 bis 86.400 Sekunden. Jeder manuelle oder automatisch ausgelöste Lauf startet dieses Intervall neu.</small>
</label>
<div class="technical-summary"><span>Letzter Lauf</span><strong>{{formatTime .Settings.LastRunAt}}</strong><span>Status</span><strong class="{{statusClass .Settings.LastRunStatus}}">{{if .Settings.LastRunStatus}}{{.Settings.LastRunStatus}}{{else}}Nicht ausgeführt{{end}}</strong></div>
</div>
<div class="form-grid endpoint-grid">
<label>IPv4-Endpunkte
<textarea name="ipv4_endpoints" rows="4" spellcheck="false" required>{{formatEndpoints .Settings.IPv4Endpoints}}</textarea>
<small>Eine HTTPS-URL pro Zeile; Reihenfolge entspricht der Fallback-Reihenfolge.</small>
</label>
<label class="endpoint-setting {{if not .Settings.IPv6Enabled}}is-disabled{{end}}" data-ipv6-settings aria-disabled="{{if .Settings.IPv6Enabled}}false{{else}}true{{end}}">IPv6-Endpunkte
<textarea name="ipv6_endpoints" rows="4" spellcheck="false" {{if .Settings.IPv6Enabled}}required{{end}}>{{formatEndpoints .Settings.IPv6Endpoints}}</textarea>
<small>Werden nur verwendet, wenn IPv6 oben aktiviert ist.</small>
</label>
</div>
<div class="form-actions"><button class="button button-primary" type="submit">Konfiguration speichern</button><a class="button button-quiet" href="/">Zur Übersicht</a></div>
</div>
</section>
</form>
{{end}}
+26
View File
@@ -0,0 +1,26 @@
{{define "setup"}}{{template "layout" .}}{{end}}
{{define "content"}}
<section class="auth-card">
<div class="auth-brand"><span>DD</span> DynDNS Console</div>
<h1>System einrichten</h1>
<p class="lede">Erstelle den ersten Administrator. Weitere Benutzer und Zugriffsrechte können anschließend in der Verwaltung angelegt werden.</p>
<form action="/setup" method="post" class="stacked-form">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<div class="form-grid"><label>Admin-Benutzername
<input name="username" type="text" value="{{index .Form "username"}}" autocomplete="username" minlength="3" maxlength="40" required autofocus>
</label>
<label>Anzeigename
<input name="display_name" type="text" value="{{index .Form "display_name"}}" maxlength="80" required>
</label></div>
<label>Admin-Passwort
<input name="password" type="password" autocomplete="new-password" minlength="12" required>
<small>Mindestens 12 Zeichen; dieses Passwort kann in V1 nicht über die Oberfläche zurückgesetzt werden.</small>
</label>
<label>Passwort wiederholen
<input name="confirm_password" type="password" autocomplete="new-password" minlength="12" required>
</label>
<button class="button button-primary" type="submit">Sichere Einrichtung abschließen <span aria-hidden="true">→</span></button>
</form>
<div class="security-strip"><span>✓</span> Argon2id-Passworthash · verschlüsselte API-Tokens · lokale Datenhaltung</div>
</section>
{{end}}
+4
View File
@@ -0,0 +1,4 @@
{{define "token"}}{{template "layout" .}}{{end}}
{{define "content"}}
<section class="token-card"><span class="page-kicker">Sensible Zugangsdaten</span><h1>API-Token</h1><p>Zone: <strong>{{.Zone.Name}}</strong>. Der Wert wird nicht in URL, Audit-Log oder Browser-Cache gespeichert.</p><div class="token-value"><code id="api-token">{{.Token}}</code><button class="button button-primary" type="button" data-copy-target="api-token">Kopieren</button></div><p class="warning-box">Nach dem Kopieren diese Seite schließen. Eine erneute Anzeige erfordert wieder das Admin-Passwort.</p><a class="button button-quiet" href="/domains/{{.Zone.ID}}">Zur Domain zurückkehren</a></section>
{{end}}
+73
View File
@@ -0,0 +1,73 @@
{{define "user_detail"}}{{template "layout" .}}{{end}}
{{define "content"}}
<header class="page-heading">
<div>
<a class="back-link" href="/users">← Benutzer</a>
<span class="page-kicker">Benutzerprofil</span>
<h1>{{.ManagedUser.DisplayName}}</h1>
<p>@{{.ManagedUser.Username}} · {{if eq .ManagedUser.Role "admin"}}Admin mit vollständigem Zugriff{{else}}Mitglied mit gezielten Freigaben{{end}}</p>
</div>
<span class="status-pill {{if .ManagedUser.Active}}status-good{{else}}status-bad{{end}}">{{if .ManagedUser.Active}}aktiv{{else}}deaktiviert{{end}}</span>
</header>
<section class="split-grid">
<article class="panel">
<div class="panel-heading"><div><span class="section-label">Konto</span><h2>Rolle und Status</h2></div></div>
<form action="/users/{{.ManagedUser.ID}}/state" method="post" class="stacked-form">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<label>Anzeigename <input name="display_name" value="{{.ManagedUser.DisplayName}}" maxlength="80" required></label>
<label>Rolle <select name="role"><option value="member" {{if eq .ManagedUser.Role "member"}}selected{{end}}>Mitglied</option><option value="admin" {{if eq .ManagedUser.Role "admin"}}selected{{end}}>Admin</option></select></label>
<label class="switch"><input type="checkbox" name="active" {{if .ManagedUser.Active}}checked{{end}}><span></span> Konto aktiv</label>
<button class="button button-primary" type="submit">Kontostatus speichern</button>
</form>
</article>
<article class="panel">
<div class="panel-heading"><div><span class="section-label">Anmeldedaten</span><h2>Passwort zurücksetzen</h2></div></div>
<form action="/users/{{.ManagedUser.ID}}/password" method="post" class="stacked-form">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<label>Neues Passwort <input name="password" type="password" minlength="12" autocomplete="new-password" required></label>
<label>Passwort wiederholen <input name="confirm_password" type="password" minlength="12" autocomplete="new-password" required></label>
<button class="button button-danger" type="submit">Passwort zurücksetzen</button>
<small>Alle Sitzungen dieses Benutzers werden dabei beendet.</small>
</form>
</article>
</section>
{{if eq .ManagedUser.Role "member"}}
<section class="panel access-panel">
<div class="panel-heading"><div><span class="section-label">Berechtigungen</span><h2>Domain- und Subdomain-Zugriffe</h2></div></div>
<p class="access-intro">Lege getrennt fest, was sichtbar ist, was bearbeitet werden darf und in welchen Domains das Mitglied selbst neue Subdomains anlegen kann.</p>
<form action="/users/{{.ManagedUser.ID}}/access" method="post">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<div class="access-list">
{{range .AccessZones}}
{{$accessZone := .}}
<article class="access-zone" data-access-zone>
<div class="access-zone-head">
<div><h3>{{$accessZone.Zone.Name}}</h3><small>{{len $accessZone.Targets}} bekannte DynDNS-Ziele</small></div>
<div class="access-zone-controls">
<label class="switch"><input type="checkbox" name="zone_{{$accessZone.Zone.ID}}" data-zone-access {{if $accessZone.DomainGranted}}checked{{end}}><span></span> Ganze Domain sehen</label>
<label class="switch"><input type="checkbox" name="manage_zone_{{$accessZone.Zone.ID}}" data-zone-manage {{if $accessZone.DomainManaged}}checked{{end}}><span></span> Alle Ziele bearbeiten</label>
<label class="switch"><input type="checkbox" name="create_zone_{{$accessZone.Zone.ID}}" data-zone-create {{if $accessZone.CanCreateTargets}}checked{{end}}><span></span> Subdomains anlegen</label>
</div>
</div>
{{if $accessZone.Targets}}
<div class="access-targets">
{{range $accessZone.Targets}}
<div class="access-target" data-access-target>
<label class="access-target-main"><input type="checkbox" name="target_{{.ID}}" data-target-access {{if index $accessZone.GrantedTargetIDs .ID}}checked{{end}}><span><strong>{{.FQDN}}</strong><small>{{families .}} · {{if .Active}}aktiv{{else}}inaktiv{{end}}</small></span></label>
<label class="switch access-manage-switch"><input type="checkbox" name="manage_target_{{.ID}}" data-target-manage {{if index $accessZone.ManagedTargetIDs .ID}}checked{{end}}><span></span> Bearbeiten</label>
</div>
{{end}}
</div>
{{else}}<p class="muted-cell">Keine A-/AAAA-Records importiert.</p>{{end}}
</article>
{{end}}
</div>
<div class="form-actions"><button class="button button-primary" type="submit">Zugriffsrechte speichern</button></div>
</form>
</section>
{{else}}
<section class="panel access-panel"><div class="warning-box">Dieser Benutzer ist Admin und hat deshalb automatisch vollen Zugriff auf alle Domains, Hosts, Tokens und Einstellungen.</div></section>
{{end}}
{{end}}
+13
View File
@@ -0,0 +1,13 @@
{{define "user_new"}}{{template "layout" .}}{{end}}
{{define "content"}}
<header class="page-heading"><div><a class="back-link" href="/users">← Benutzer</a><span class="page-kicker">Neues Konto</span><h1>Benutzer anlegen</h1><p>Mitgliedskonten erhalten zunächst keine Domain- oder Host-Berechtigungen.</p></div></header>
<section class="form-panel compact-panel">
<form action="/users" method="post" class="stacked-form wide-form">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<div class="form-grid"><label>Benutzername <input name="username" value="{{index .Form "username"}}" autocomplete="username" minlength="3" maxlength="40" required><small>Kleinbuchstaben, Zahlen, Punkt, Bindestrich und Unterstrich.</small></label><label>Anzeigename <input name="display_name" value="{{index .Form "display_name"}}" maxlength="80" required></label></div>
<div class="form-grid"><label>Startpasswort <input name="password" type="password" autocomplete="new-password" minlength="12" required></label><label>Passwort wiederholen <input name="confirm_password" type="password" autocomplete="new-password" minlength="12" required></label></div>
<label>Rolle <select name="role"><option value="member" {{if eq (index .Form "role") "member"}}selected{{end}}>Mitglied – nur explizit freigegebene Domains/Hosts</option><option value="admin" {{if eq (index .Form "role") "admin"}}selected{{end}}>Admin – vollständige Verwaltung</option></select><small>Admins dürfen Tokens sehen, Einstellungen ändern und weitere Benutzer verwalten.</small></label>
<div class="form-actions"><button class="button button-primary" type="submit">Benutzer anlegen</button><a class="button button-quiet" href="/users">Abbrechen</a></div>
</form>
</section>
{{end}}
+5
View File
@@ -0,0 +1,5 @@
{{define "users"}}{{template "layout" .}}{{end}}
{{define "content"}}
<header class="page-heading"><div><span class="page-kicker">Zugriffssteuerung</span><h1>Benutzer</h1><p>Konten, Rollen sowie Domain- und Subdomain-Berechtigungen verwalten.</p></div><a class="button button-primary" href="/users/new">Benutzer anlegen</a></header>
<section class="panel user-table-panel"><div class="panel-heading"><div><span class="section-label">Konten</span><h2>{{len .Users}} Benutzer</h2></div></div><div class="user-table" role="table" aria-label="Benutzer"><div class="user-table-head" role="row"><span>Benutzer</span><span>Rolle</span><span>Status</span><span>Letzte Anmeldung</span><span></span></div>{{range .Users}}<a href="/users/{{.ID}}" class="user-table-row" role="row"><span><strong>{{.DisplayName}}</strong><small>@{{.Username}}</small></span><span class="role-chip {{.Role}}">{{if eq .Role "admin"}}Admin{{else}}Mitglied{{end}}</span><span class="status-pill {{if .Active}}status-good{{else}}status-bad{{end}}">{{if .Active}}aktiv{{else}}deaktiviert{{end}}</span><span class="muted-cell">{{formatTime .LastLoginAt}}</span><span class="row-arrow">→</span></a>{{end}}</div></section>
{{end}}