feat(admin): Verwaltung fuer Benutzer, Gruppen und das eigene Konto

Setzt die Rechte aus dem vorigen Commit in den Oberflaechen um und
ergaenzt die fehlenden Verwaltungsseiten.

Neu:
- /admin/users  - Konten anlegen, Rolle und Sperre setzen, Passwort
  zuruecksetzen, Gruppenzuordnung je Konto
- /admin/groups - Gruppen anlegen und umbenennen, Mitglieder samt
  Rolle pflegen, zugeordnete Domains einsehen
- /admin/profile - eigenes Konto: Gruppenzugehoerigkeit ansehen und
  das Passwort aendern. Das fehlte bisher vollstaendig, auch fuer
  den einen Administrator.

Domains sind jetzt Administratorsache, weil dort die Gruppe zugewiesen
wird. Kurzlinks, Statistik und QR-Codes richten sich nach der Gruppe:
ein fremder Kurzlink antwortet mit 404 (seine Existenz geht den
Aufrufer nichts an), ein sichtbarer ohne Schreibrecht mit 403.

Geschlossene Luecke: /admin/uploads/{file} pruefte bisher nur, ob
ueberhaupt jemand angemeldet ist. Da alle Logos in einem gemeinsamen
Verzeichnis liegen, war der Dateiname die einzige Huerde. Ausgeliefert
wird jetzt nur noch, was laut Datenbank zu einem Kurzlink aus einer
Gruppe des Aufrufers gehoert.

Die Navigation blendet aus, wofuer die Rechte fehlen - das ist
Bedienkomfort, kein Zugriffsschutz: die Routen pruefen unabhaengig
davon noch einmal.

Der Setup-Assistent legt das erste Konto als Administrator an und
traegt es gleich als Editor der Gruppe "Standard" ein. Ohne das
koennte es zwar verwalten, aber selbst keine Kurzlinks anlegen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-14 12:03:20 +02:00
co-authored by Claude Opus 5
parent cad05e5fd5
commit 7f52613821
24 changed files with 1946 additions and 137 deletions
+34 -18
View File
@@ -9,6 +9,7 @@
* @var string|null $warning
* @var bool $logoIsSvg
* @var bool $imagickAvailable
* @var bool $canEdit In dieser Gruppe nur "Viewer"? Dann ist alles nur lesbar.
* @var App\Security\Csrf $csrf
*/
@@ -164,10 +165,19 @@ $levelLabels = [
<?php endif; ?>
<div class="form__actions">
<button type="submit" class="btn btn--primary">Einstellungen speichern</button>
<?php if ($canEdit): ?>
<button type="submit" class="btn btn--primary">Einstellungen speichern</button>
<?php endif; ?>
<button type="reset" class="btn btn--ghost" data-qr-reset>Zurücksetzen</button>
</div>
<p class="field__hint">Speichern ist optional – Vorschau und Download nutzen immer die aktuellen Werte.</p>
<p class="field__hint">
<?php if ($canEdit): ?>
Speichern ist optional – Vorschau und Download nutzen immer die aktuellen Werte.
<?php else: ?>
In dieser Gruppe ist nur das Ansehen erlaubt. Ausprobieren und Herunterladen geht
trotzdem – gespeichert wird die Gestaltung aber nicht.
<?php endif; ?>
</p>
</form>
<section class="card">
@@ -179,11 +189,13 @@ $levelLabels = [
alt="Aktuelles Logo" class="logo-current__img">
<div class="logo-current__meta">
<span class="mono small"><?= $e($options->logoPath) ?></span>
<form method="post" action="<?= $e($url('/admin/links/' . $id . '/qr/logo/delete')) ?>"
data-confirm="Logo wirklich entfernen?">
<?= $csrf->field() ?>
<button type="submit" class="btn btn--sm btn--danger">Logo entfernen</button>
</form>
<?php if ($canEdit): ?>
<form method="post" action="<?= $e($url('/admin/links/' . $id . '/qr/logo/delete')) ?>"
data-confirm="Logo wirklich entfernen?">
<?= $csrf->field() ?>
<button type="submit" class="btn btn--sm btn--danger">Logo entfernen</button>
</form>
<?php endif; ?>
</div>
</div>
@@ -195,17 +207,21 @@ $levelLabels = [
<?php endif; ?>
<?php endif; ?>
<form method="post" action="<?= $e($url('/admin/links/' . $id . '/qr/logo')) ?>"
enctype="multipart/form-data" class="form">
<?= $csrf->field() ?>
<div class="field">
<label for="logo"><?= $options->hasLogo() ? 'Anderes Logo hochladen' : 'Logo hochladen' ?></label>
<input type="file" id="logo" name="logo" accept="image/png,image/jpeg,image/svg+xml" required>
<p class="field__hint">PNG, JPG oder SVG. Das Logo wird mittig eingesetzt und passend skaliert;
die Fehlerkorrektur wird dabei automatisch auf Level H gesetzt.</p>
</div>
<button type="submit" class="btn">Hochladen</button>
</form>
<?php if ($canEdit): ?>
<form method="post" action="<?= $e($url('/admin/links/' . $id . '/qr/logo')) ?>"
enctype="multipart/form-data" class="form">
<?= $csrf->field() ?>
<div class="field">
<label for="logo"><?= $options->hasLogo() ? 'Anderes Logo hochladen' : 'Logo hochladen' ?></label>
<input type="file" id="logo" name="logo" accept="image/png,image/jpeg,image/svg+xml" required>
<p class="field__hint">PNG, JPG oder SVG. Das Logo wird mittig eingesetzt und passend skaliert;
die Fehlerkorrektur wird dabei automatisch auf Level H gesetzt.</p>
</div>
<button type="submit" class="btn">Hochladen</button>
</form>
<?php elseif (!$options->hasLogo()): ?>
<p class="muted">Für diesen Kurzlink ist kein Logo hinterlegt.</p>
<?php endif; ?>
</section>
</div>
</div>