Files
florian.wildeandClaude Opus 5 7f52613821 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>
2026-08-14 12:03:20 +02:00

161 lines
7.7 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* @var list<array<string,mixed>> $links
* @var int $total
* @var string $search
* @var string $sort
* @var list<array<string,mixed>> $domains
* @var int|null $domainFilter
* @var list<int>|null $editableDomainIds null = alle
* @var bool $canCreate
* @var App\Config $config
* @var App\Security\Csrf $csrf
*/
use App\Repository\LinkRepository;
use App\Support\ShortDomain;
$fallbackBase = rtrim($config->baseUrl(), '/');
$multiDomain = count($domains) > 1;
// Wer in einer Gruppe nur "Viewer" ist, sieht die Kurzlinks, darf sie
// aber nicht anfassen. Entschieden wird das je Zeile ueber die Domain.
$mayEdit = static fn (int $domainId): bool =>
$editableDomainIds === null || in_array($domainId, $editableDomainIds, true);
$sortOptions = [
'created_desc' => 'Neueste zuerst',
'created_asc' => 'Älteste zuerst',
'clicks_desc' => 'Meiste Klicks',
'clicks_asc' => 'Wenigste Klicks',
'slug_asc' => 'Slug (A–Z)',
];
?>
<div class="page-head">
<div>
<h1>Kurzlinks</h1>
<p class="muted"><?= (int) $total ?> Eintrag/Einträge<?= $search !== '' ? ' für „' . $e($search) . '“' : '' ?>.</p>
</div>
<?php if ($canCreate): ?>
<a href="<?= $e($url('/admin/links/new')) ?>" class="btn btn--primary">Neuer Kurzlink</a>
<?php endif; ?>
</div>
<form class="toolbar" method="get" action="<?= $e($url('/admin/links')) ?>">
<input type="search" name="q" value="<?= $e($search) ?>" placeholder="Slug, Titel oder Ziel-URL suchen…"
aria-label="Suche">
<?php if ($multiDomain): ?>
<select name="domain" aria-label="Domain">
<option value="0">Alle Domains</option>
<?php foreach ($domains as $domain): ?>
<option value="<?= (int) $domain['id'] ?>" <?= $domainFilter === (int) $domain['id'] ? 'selected' : '' ?>>
<?= $e(ShortDomain::displayName((string) $domain['base_url'])) ?>
</option>
<?php endforeach; ?>
</select>
<?php endif; ?>
<select name="sort" aria-label="Sortierung">
<?php foreach ($sortOptions as $value => $label): ?>
<option value="<?= $e($value) ?>" <?= $sort === $value ? 'selected' : '' ?>><?= $e($label) ?></option>
<?php endforeach; ?>
</select>
<button type="submit" class="btn">Anwenden</button>
<?php if ($search !== '' || $sort !== 'created_desc' || $domainFilter !== null): ?>
<a class="btn btn--ghost" href="<?= $e($url('/admin/links')) ?>">Zurücksetzen</a>
<?php endif; ?>
</form>
<?php if ($links === []): ?>
<div class="card empty">
<h2>Noch nichts hier</h2>
<?php if ($canCreate): ?>
<p class="muted">Lege deinen ersten Kurzlink an – anschließend kannst du direkt einen QR-Code dazu gestalten.</p>
<a href="<?= $e($url('/admin/links/new')) ?>" class="btn btn--primary">Kurzlink anlegen</a>
<?php else: ?>
<p class="muted">In Ihren Gruppen gibt es noch keine Kurzlinks. Zum Anlegen wird die Rolle
„Editor“ in einer Gruppe benötigt – eine Administratorin oder ein Administrator kann sie vergeben.</p>
<?php endif; ?>
</div>
<?php else: ?>
<div class="card card--flush">
<div class="table-wrap">
<table class="table">
<thead>
<tr>
<th scope="col">Kurzlink</th>
<th scope="col">Ziel</th>
<th scope="col" class="num">Klicks</th>
<th scope="col">Erstellt</th>
<th scope="col"><span class="sr-only">Aktionen</span></th>
</tr>
</thead>
<tbody>
<?php foreach ($links as $link):
$shortUrl = LinkRepository::shortUrlOf($link, $fallbackBase);
$active = (int) $link['is_active'] === 1;
$domainName = $link['domain_base_url'] !== null
? ShortDomain::displayName((string) $link['domain_base_url'])
: '';
$editable = $mayEdit((int) $link['domain_id']);
?>
<tr class="<?= $active ? '' : 'is-inactive' ?>">
<td>
<div class="cell-stack">
<button type="button" class="linkcopy mono" data-copy="<?= $e($shortUrl) ?>"
title="In die Zwischenablage kopieren"><?php
if ($multiDomain && $domainName !== ''): ?><span class="linkcopy__domain"><?= $e($domainName) ?></span><?php
endif; ?>/<?= $e($link['slug']) ?></button>
<?php if (($link['title'] ?? '') !== ''): ?>
<span class="muted small"><?= $e($link['title']) ?></span>
<?php endif; ?>
<?php if (!$active): ?>
<span class="badge badge--muted">deaktiviert</span>
<?php endif; ?>
</div>
</td>
<td class="truncate-cell">
<a href="<?= $e($link['target_url']) ?>" rel="noopener noreferrer nofollow" target="_blank"
class="muted small"><?= $e($link['target_url']) ?></a>
</td>
<td class="num">
<a href="<?= $e($url('/admin/links/' . $link['id'] . '/stats')) ?>">
<?= number_format((int) $link['click_count'], 0, ',', '.') ?>
</a>
</td>
<td class="muted small nowrap">
<?= $e(date('d.m.Y', strtotime((string) $link['created_at'] . ' UTC') ?: time())) ?>
</td>
<td class="actions">
<a class="btn btn--sm" href="<?= $e($url('/admin/links/' . $link['id'] . '/qr')) ?>">QR-Code</a>
<a class="btn btn--sm btn--ghost" href="<?= $e($url('/admin/links/' . $link['id'] . '/stats')) ?>">Statistik</a>
<?php if ($editable): ?>
<a class="btn btn--sm btn--ghost" href="<?= $e($url('/admin/links/' . $link['id'] . '/edit')) ?>">Bearbeiten</a>
<form method="post" action="<?= $e($url('/admin/links/' . $link['id'] . '/toggle')) ?>" class="inline">
<?= $csrf->field() ?>
<button type="submit" class="btn btn--sm btn--ghost">
<?= $active ? 'Deaktivieren' : 'Aktivieren' ?>
</button>
</form>
<form method="post" action="<?= $e($url('/admin/links/' . $link['id'] . '/delete')) ?>" class="inline"
data-confirm="Kurzlink <?= $e($shortUrl) ?> wirklich löschen? Die Statistik geht dabei verloren.">
<?= $csrf->field() ?>
<button type="submit" class="btn btn--sm btn--danger">Löschen</button>
</form>
<?php else: ?>
<span class="badge badge--muted" title="In dieser Gruppe ist nur das Ansehen erlaubt">nur lesen</span>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php endif; ?>