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
+41 -4
View File
@@ -6,6 +6,11 @@
* @var list<array<string,mixed>> $topLinks
* @var list<array{date:string,clicks:int}> $series
* @var int $domainCount
* @var bool $isAdmin
* @var bool $canCreate
* @var list<array<string,mixed>> $myGroups
* @var int|null $groupCount nur fuer Administratoren
* @var int|null $userCount nur fuer Administratoren
* @var App\Config $config
*/
@@ -49,9 +54,20 @@ $fallbackBase = rtrim($config->baseUrl(), '/');
<div class="page-head">
<div>
<h1>Übersicht</h1>
<p class="muted">Alle Kurzlinks und Klicks auf einen Blick.</p>
<p class="muted">
<?php if ($isAdmin): ?>
Alle Kurzlinks und Klicks auf einen Blick.
<?php elseif ($myGroups === []): ?>
Sie sind noch in keiner Gruppe – deshalb ist hier noch nichts zu sehen.
<?php else: ?>
Kurzlinks und Klicks Ihrer Gruppen:
<?= $e(implode(', ', array_column($myGroups, 'name'))) ?>.
<?php endif; ?>
</p>
</div>
<a href="<?= $e($url('/admin/links/new')) ?>" class="btn btn--primary">Neuer Kurzlink</a>
<?php if ($canCreate): ?>
<a href="<?= $e($url('/admin/links/new')) ?>" class="btn btn--primary">Neuer Kurzlink</a>
<?php endif; ?>
</div>
<div class="stats-grid">
@@ -74,9 +90,27 @@ $fallbackBase = rtrim($config->baseUrl(), '/');
<div class="stat">
<span class="stat__label">Domains</span>
<span class="stat__value">
<a href="<?= $e($url('/admin/domains')) ?>"><?= (int) $domainCount ?></a>
<?php if ($isAdmin): ?>
<a href="<?= $e($url('/admin/domains')) ?>"><?= (int) $domainCount ?></a>
<?php else: ?>
<?= (int) $domainCount ?>
<?php endif; ?>
</span>
</div>
<?php if ($isAdmin): ?>
<div class="stat">
<span class="stat__label">Gruppen</span>
<span class="stat__value">
<a href="<?= $e($url('/admin/groups')) ?>"><?= (int) $groupCount ?></a>
</span>
</div>
<div class="stat">
<span class="stat__label">Benutzer</span>
<span class="stat__value">
<a href="<?= $e($url('/admin/users')) ?>"><?= (int) $userCount ?></a>
</span>
</div>
<?php endif; ?>
</div>
<section class="card">
@@ -92,7 +126,10 @@ $fallbackBase = rtrim($config->baseUrl(), '/');
<h2>Zuletzt angelegt</h2>
<?php if ($recentLinks === []): ?>
<p class="muted">Noch keine Kurzlinks vorhanden.
<a href="<?= $e($url('/admin/links/new')) ?>">Jetzt den ersten anlegen.</a></p>
<?php if ($canCreate): ?>
<a href="<?= $e($url('/admin/links/new')) ?>">Jetzt den ersten anlegen.</a>
<?php endif; ?>
</p>
<?php else: ?>
<ul class="list">
<?php foreach ($recentLinks as $link): ?>
+21 -1
View File
@@ -2,7 +2,8 @@
/**
* @var array<string,mixed>|null $domain
* @var array{base_url:string,label:string,is_default:string} $values
* @var list<array<string,mixed>> $groups
* @var array{base_url:string,label:string,is_default:string,group_id:string} $values
* @var array<string,string> $errors
* @var App\Security\Csrf $csrf
*/
@@ -52,6 +53,25 @@ $isDefault = $isEdit && (int) $domain['is_default'] === 1;
<p class="field__hint">Nur zur eigenen Orientierung in der Auswahlliste.</p>
</div>
<div class="field <?= isset($errors['group_id']) ? 'field--error' : '' ?>">
<label for="group_id">Gruppe <span class="req">*</span></label>
<select id="group_id" name="group_id">
<?php foreach ($groups as $group): ?>
<option value="<?= (int) $group['id'] ?>" <?= $values['group_id'] === (string) $group['id'] ? 'selected' : '' ?>>
<?= $e($group['name']) ?>
</option>
<?php endforeach; ?>
</select>
<p class="field__hint">
Bestimmt, wer die Kurzlinks dieser Domain sehen und anlegen darf: alle Mitglieder
der gewählten Gruppe. Administratoren sehen ohnehin alles. Die Zuordnung lässt sich
jederzeit ändern – bestehende Kurzlinks wechseln dabei mit.
</p>
<?php if (isset($errors['group_id'])): ?>
<p class="field__error"><?= $e($errors['group_id']) ?></p>
<?php endif; ?>
</div>
<div class="field field--check">
<label class="check">
<input type="checkbox" name="is_default" value="1"
+8
View File
@@ -68,6 +68,7 @@ $statusSymbol = static fn (string $status): string => match ($status) {
<tr>
<th scope="col">Domain</th>
<th scope="col">Bezeichnung</th>
<th scope="col">Gruppe</th>
<th scope="col" class="num">Kurzlinks</th>
<th scope="col"><span class="sr-only">Aktionen</span></th>
</tr>
@@ -92,6 +93,13 @@ $statusSymbol = static fn (string $status): string => match ($status) {
</div>
</td>
<td class="muted small"><?= $e($domain['label'] ?? '–') ?></td>
<td class="small">
<?php if (($domain['group_name'] ?? null) !== null): ?>
<a href="<?= $e($url('/admin/groups/' . (int) $domain['group_id'] . '/edit')) ?>"><?= $e($domain['group_name']) ?></a>
<?php else: ?>
<span class="badge badge--muted">keine</span>
<?php endif; ?>
</td>
<td class="num">
<a href="<?= $e($url('/admin/links?domain=' . (int) $domain['id'])) ?>">
<?= (int) $domain['link_count'] ?>
+160
View File
@@ -0,0 +1,160 @@
<?php
/**
* Gruppe anlegen und bearbeiten - samt Mitgliedern und zugeordneten Domains.
*
* @var array<string,mixed>|null $group
* @var list<array<string,mixed>> $members
* @var list<array<string,mixed>> $domains Domains dieser Gruppe
* @var list<array<string,mixed>> $availableUsers noch nicht Mitglied
* @var array{name:string} $values
* @var array<string,string> $errors
* @var App\Security\Csrf $csrf
*/
use App\Security\Role;
use App\Support\ShortDomain;
$isEdit = $group !== null;
$action = $isEdit
? $url('/admin/groups/' . (int) $group['id'] . '/edit')
: $url('/admin/groups/new');
?>
<div class="page-head">
<div>
<h1><?= $isEdit ? 'Gruppe bearbeiten' : 'Neue Gruppe' ?></h1>
<?php if (!$isEdit): ?>
<p class="muted">Mitglieder und Domains werden im nächsten Schritt zugeordnet.</p>
<?php endif; ?>
</div>
<a href="<?= $e($url('/admin/groups')) ?>" class="btn btn--ghost">Zurück zur Liste</a>
</div>
<div class="card card--form">
<form method="post" action="<?= $e($action) ?>" class="form">
<?= $csrf->field() ?>
<div class="field <?= isset($errors['name']) ? 'field--error' : '' ?>">
<label for="name">Name <span class="req">*</span></label>
<input type="text" id="name" name="name" value="<?= $e($values['name']) ?>" maxlength="60"
required autofocus placeholder="z. B. Marketing">
<?php if (isset($errors['name'])): ?>
<p class="field__error"><?= $e($errors['name']) ?></p>
<?php endif; ?>
</div>
<div class="form__actions">
<button type="submit" class="btn btn--primary"><?= $isEdit ? 'Namen speichern' : 'Gruppe anlegen' ?></button>
<a href="<?= $e($url('/admin/groups')) ?>" class="btn btn--ghost">Abbrechen</a>
</div>
</form>
</div>
<?php if ($isEdit): $groupId = (int) $group['id']; ?>
<div class="card">
<h2>Mitglieder</h2>
<?php if ($members === []): ?>
<p class="muted">Noch niemand in dieser Gruppe.</p>
<?php else: ?>
<div class="table-wrap">
<table class="table">
<thead>
<tr>
<th scope="col">Benutzer</th>
<th scope="col">Rolle in der Gruppe</th>
<th scope="col"><span class="sr-only">Aktionen</span></th>
</tr>
</thead>
<tbody>
<?php foreach ($members as $member):
$memberId = (int) $member['id'];
$memberRole = Role::groupOrDefault($member['group_role']);
?>
<tr class="<?= (int) $member['is_active'] === 1 ? '' : 'is-inactive' ?>">
<td>
<div class="cell-stack">
<span class="mono"><?= $e($member['username']) ?></span>
<?php if (Role::globalOrDefault($member['global_role']) === Role::ADMIN): ?>
<span class="muted small">Administrator – sieht ohnehin alles</span>
<?php endif; ?>
<?php if ((int) $member['is_active'] !== 1): ?>
<span class="badge badge--muted">gesperrt</span>
<?php endif; ?>
</div>
</td>
<td>
<form method="post" action="<?= $e($url('/admin/groups/' . $groupId . '/members')) ?>" class="inline">
<?= $csrf->field() ?>
<input type="hidden" name="user_id" value="<?= $memberId ?>">
<select name="role" aria-label="Rolle von <?= $e($member['username']) ?>">
<?php foreach (Role::GROUP_LABELS as $value => $label): ?>
<option value="<?= $e($value) ?>" <?= $memberRole === $value ? 'selected' : '' ?>>
<?= $e($label) ?>
</option>
<?php endforeach; ?>
</select>
<button type="submit" class="btn btn--sm">Übernehmen</button>
</form>
</td>
<td class="actions">
<form method="post" action="<?= $e($url('/admin/groups/' . $groupId . '/members/remove')) ?>" class="inline"
data-confirm="<?= $e($member['username']) ?> aus der Gruppe entfernen?">
<?= $csrf->field() ?>
<input type="hidden" name="user_id" value="<?= $memberId ?>">
<button type="submit" class="btn btn--sm btn--danger">Entfernen</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
<?php if ($availableUsers === []): ?>
<p class="muted small">Alle vorhandenen Konten sind bereits Mitglied.
<a href="<?= $e($url('/admin/users/new')) ?>">Neues Konto anlegen.</a></p>
<?php else: ?>
<form method="post" action="<?= $e($url('/admin/groups/' . $groupId . '/members')) ?>" class="toolbar">
<?= $csrf->field() ?>
<select name="user_id" aria-label="Benutzer">
<?php foreach ($availableUsers as $candidate): ?>
<option value="<?= (int) $candidate['id'] ?>"><?= $e($candidate['username']) ?></option>
<?php endforeach; ?>
</select>
<select name="role" aria-label="Rolle">
<?php foreach (Role::GROUP_LABELS as $value => $label): ?>
<option value="<?= $e($value) ?>"><?= $e($label) ?></option>
<?php endforeach; ?>
</select>
<button type="submit" class="btn btn--primary">Hinzufügen</button>
</form>
<?php endif; ?>
</div>
<div class="card">
<h2>Domains dieser Gruppe</h2>
<?php if ($domains === []): ?>
<p class="muted">Dieser Gruppe ist noch keine Domain zugeordnet – ihre Mitglieder sehen
deshalb noch keine Kurzlinks. Die Zuordnung erfolgt beim
<a href="<?= $e($url('/admin/domains')) ?>">Bearbeiten einer Domain</a>.</p>
<?php else: ?>
<ul class="plain">
<?php foreach ($domains as $domain): ?>
<li>
<a class="mono" href="<?= $e($url('/admin/domains/' . (int) $domain['id'] . '/edit')) ?>">
<?= $e(ShortDomain::displayName((string) $domain['base_url'])) ?>
</a>
<?php if (($domain['label'] ?? '') !== ''): ?>
<span class="muted small"><?= $e($domain['label']) ?></span>
<?php endif; ?>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</div>
<?php endif; ?>
+60
View File
@@ -0,0 +1,60 @@
<?php
/**
* Gruppenuebersicht.
*
* @var list<array<string,mixed>> $groups
* @var App\Security\Csrf $csrf
*/
?>
<div class="page-head">
<div>
<h1>Gruppen</h1>
<p class="muted">Eine Gruppe verbindet Benutzer mit Domains.</p>
</div>
<a href="<?= $e($url('/admin/groups/new')) ?>" class="btn btn--primary">Neue Gruppe</a>
</div>
<div class="card">
<p class="muted small">
Jede Domain gehört zu genau einer Gruppe, jeder Kurzlink zu seiner Domain. Wer Mitglied einer
Gruppe ist, sieht damit alle Kurzlinks aller Domains dieser Gruppe – mit der Rolle
<strong>Editor</strong> darf er sie auch anlegen und ändern, als <strong>Viewer</strong>
nur ansehen (einschließlich Statistik und QR-Download). Administratoren sehen unabhängig
von Gruppen alles.
</p>
</div>
<div class="card card--flush">
<div class="table-wrap">
<table class="table">
<thead>
<tr>
<th scope="col">Gruppe</th>
<th scope="col" class="num">Mitglieder</th>
<th scope="col" class="num">Domains</th>
<th scope="col"><span class="sr-only">Aktionen</span></th>
</tr>
</thead>
<tbody>
<?php foreach ($groups as $group):
$id = (int) $group['id'];
?>
<tr>
<td><a href="<?= $e($url('/admin/groups/' . $id . '/edit')) ?>"><?= $e($group['name']) ?></a></td>
<td class="num"><?= (int) $group['member_count'] ?></td>
<td class="num"><?= (int) $group['domain_count'] ?></td>
<td class="actions">
<a class="btn btn--sm btn--ghost" href="<?= $e($url('/admin/groups/' . $id . '/edit')) ?>">Bearbeiten</a>
<form method="post" action="<?= $e($url('/admin/groups/' . $id . '/delete')) ?>" class="inline"
data-confirm="Gruppe <?= $e($group['name']) ?> wirklich löschen?">
<?= $csrf->field() ?>
<button type="submit" class="btn btn--sm btn--danger">Löschen</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
+16 -3
View File
@@ -7,6 +7,7 @@
* @var App\Config $config
* @var App\Security\Csrf $csrf
* @var App\Security\Auth $auth
* @var App\Security\AccessPolicy $policy
* @var string $content
* @var list<array{type:string,message:string}> $flashes
* @var string $currentPath
@@ -14,6 +15,12 @@
$title = $pageTitle ?? 'Verwaltung';
$isActive = static fn (string $prefix): bool => str_starts_with($currentPath ?? '/', $prefix);
// Die Navigation zeigt nur, was der angemeldete Benutzer auch aufrufen
// darf. Die Routen pruefen unabhaengig davon noch einmal - das hier ist
// Bedienkomfort, kein Zugriffsschutz.
$isAdmin = $auth->check() && $policy->isAdmin();
$canCreateLinks = $auth->check() && $policy->canCreateLinks();
?>
<!doctype html>
<html lang="de">
@@ -36,13 +43,19 @@ $isActive = static fn (string $prefix): bool => str_starts_with($currentPath ??
<nav class="mainnav" aria-label="Hauptnavigation">
<a href="<?= $e($url('/admin')) ?>" class="<?= ($currentPath === '/admin' || $currentPath === '/') ? 'is-active' : '' ?>">Übersicht</a>
<a href="<?= $e($url('/admin/links')) ?>" class="<?= $isActive('/admin/links') ? 'is-active' : '' ?>">Kurzlinks</a>
<a href="<?= $e($url('/admin/domains')) ?>" class="<?= $isActive('/admin/domains') ? 'is-active' : '' ?>">Domains</a>
<a href="<?= $e($url('/admin/links/new')) ?>" class="btn btn--primary btn--sm">Neuer Kurzlink</a>
<?php if ($isAdmin): ?>
<a href="<?= $e($url('/admin/domains')) ?>" class="<?= $isActive('/admin/domains') ? 'is-active' : '' ?>">Domains</a>
<a href="<?= $e($url('/admin/groups')) ?>" class="<?= $isActive('/admin/groups') ? 'is-active' : '' ?>">Gruppen</a>
<a href="<?= $e($url('/admin/users')) ?>" class="<?= $isActive('/admin/users') ? 'is-active' : '' ?>">Benutzer</a>
<?php endif; ?>
<?php if ($canCreateLinks): ?>
<a href="<?= $e($url('/admin/links/new')) ?>" class="btn btn--primary btn--sm">Neuer Kurzlink</a>
<?php endif; ?>
</nav>
<form class="logout" method="post" action="<?= $e($url('/logout')) ?>">
<?= $csrf->field() ?>
<span class="logout__user" title="Angemeldet als"><?= $e($auth->currentUser() ?? '') ?></span>
<a class="logout__user" href="<?= $e($url('/admin/profile')) ?>" title="Angemeldet als – zum eigenen Konto"><?= $e($auth->currentUser() ?? '') ?></a>
<button type="submit" class="btn btn--ghost btn--sm">Abmelden</button>
</form>
</div>
+35 -15
View File
@@ -7,6 +7,8 @@
* @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
*/
@@ -17,6 +19,11 @@ 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',
@@ -30,7 +37,9 @@ $sortOptions = [
<h1>Kurzlinks</h1>
<p class="muted"><?= (int) $total ?> Eintrag/Einträge<?= $search !== '' ? ' für „' . $e($search) . '“' : '' ?>.</p>
</div>
<a href="<?= $e($url('/admin/links/new')) ?>" class="btn btn--primary">Neuer Kurzlink</a>
<?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')) ?>">
@@ -62,8 +71,13 @@ $sortOptions = [
<?php if ($links === []): ?>
<div class="card empty">
<h2>Noch nichts hier</h2>
<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 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">
@@ -85,6 +99,7 @@ $sortOptions = [
$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>
@@ -116,20 +131,25 @@ $sortOptions = [
<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>
<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>
<?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'] . '/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>
<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; ?>
+92
View File
@@ -0,0 +1,92 @@
<?php
/**
* Eigenes Konto: Zugehoerigkeit ansehen, Passwort aendern.
*
* @var array<string,mixed>|null $user
* @var string $roleLabel
* @var bool $isEmergency
* @var list<array<string,mixed>> $groups
* @var array<string,string> $errors
* @var int $minLength
* @var App\Security\Csrf $csrf
*/
use App\Security\Role;
?>
<div class="page-head">
<div>
<h1>Mein Konto</h1>
<p class="muted mono"><?= $e($user['username'] ?? '') ?></p>
</div>
</div>
<div class="card">
<h2>Zugehörigkeit</h2>
<p class="muted small">Rolle: <strong><?= $e($roleLabel) ?></strong></p>
<?php if ($isEmergency): ?>
<p class="muted">Dies ist der Notfallzugang aus der <code>.env</code>. Er hat volle
Administratorrechte und ist an keine Gruppe gebunden.</p>
<?php elseif ($groups === []): ?>
<p class="muted">Sie sind noch in keiner Gruppe. Ohne Gruppe sind keine Kurzlinks sichtbar –
eine Administratorin oder ein Administrator kann Sie hinzufügen.</p>
<?php else: ?>
<ul class="plain">
<?php foreach ($groups as $group): ?>
<li>
<strong><?= $e($group['name']) ?></strong>
<span class="muted small"><?= $e(Role::groupLabel($group['group_role'])) ?></span>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</div>
<div class="card card--form">
<h2>Passwort ändern</h2>
<?php if ($isEmergency): ?>
<p class="muted">Das Passwort dieses Zugangs steht als Hash in der <code>.env</code>
(<code>ADMIN_PASSWORD_HASH</code>) und lässt sich nur dort ändern.</p>
<?php else: ?>
<form method="post" action="<?= $e($url('/admin/profile/password')) ?>" class="form">
<?= $csrf->field() ?>
<div class="field <?= isset($errors['current_password']) ? 'field--error' : '' ?>">
<label for="current_password">Aktuelles Passwort <span class="req">*</span></label>
<input type="password" id="current_password" name="current_password" required
autocomplete="current-password">
<?php if (isset($errors['current_password'])): ?>
<p class="field__error"><?= $e($errors['current_password']) ?></p>
<?php endif; ?>
</div>
<div class="field <?= isset($errors['password']) ? 'field--error' : '' ?>">
<label for="password">Neues Passwort <span class="req">*</span></label>
<input type="password" id="password" name="password" required minlength="<?= (int) $minLength ?>"
autocomplete="new-password">
<p class="field__hint">Mindestens <?= (int) $minLength ?> Zeichen. Eine lange Passphrase
ist besser als kurze Sonderzeichenakrobatik.</p>
<?php if (isset($errors['password'])): ?>
<p class="field__error"><?= $e($errors['password']) ?></p>
<?php endif; ?>
</div>
<div class="field <?= isset($errors['password_confirm']) ? 'field--error' : '' ?>">
<label for="password_confirm">Wiederholung <span class="req">*</span></label>
<input type="password" id="password_confirm" name="password_confirm" required
autocomplete="new-password">
<?php if (isset($errors['password_confirm'])): ?>
<p class="field__error"><?= $e($errors['password_confirm']) ?></p>
<?php endif; ?>
</div>
<p class="field__hint">Nach der Änderung werden alle anderen Sitzungen dieses Kontos abgemeldet.</p>
<div class="form__actions">
<button type="submit" class="btn btn--primary">Passwort ändern</button>
</div>
</form>
<?php endif; ?>
</div>
+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>
+3 -1
View File
@@ -52,7 +52,9 @@ $bars = static function (array $rows) use ($e): string {
</div>
<div class="page-head__actions">
<a class="btn" href="<?= $e($url('/admin/links/' . $id . '/qr')) ?>">QR-Code</a>
<a class="btn btn--ghost" href="<?= $e($url('/admin/links/' . $id . '/edit')) ?>">Bearbeiten</a>
<?php if ($canEdit ?? true): ?>
<a class="btn btn--ghost" href="<?= $e($url('/admin/links/' . $id . '/edit')) ?>">Bearbeiten</a>
<?php endif; ?>
<a class="btn btn--ghost" href="<?= $e($url('/admin/links')) ?>">Zur Liste</a>
</div>
</div>
+149
View File
@@ -0,0 +1,149 @@
<?php
/**
* Benutzer anlegen und bearbeiten.
*
* @var array<string,mixed>|null $user
* @var list<array<string,mixed>> $groups
* @var array<int,string> $memberships Gruppenkennung => Rolle
* @var array{username:string,role:string,is_active:string} $values
* @var array<string,string> $errors
* @var int $minLength
* @var App\Security\Csrf $csrf
*/
use App\Security\Role;
$isEdit = $user !== null;
$action = $isEdit
? $url('/admin/users/' . (int) $user['id'] . '/edit')
: $url('/admin/users/new');
?>
<div class="page-head">
<div>
<h1><?= $isEdit ? 'Benutzer bearbeiten' : 'Neuer Benutzer' ?></h1>
<?php if ($isEdit): ?>
<p class="muted mono"><?= $e($user['username']) ?></p>
<?php else: ?>
<p class="muted">Ein weiteres Konto für diese Installation.</p>
<?php endif; ?>
</div>
<a href="<?= $e($url('/admin/users')) ?>" class="btn btn--ghost">Zurück zur Liste</a>
</div>
<div class="card card--form">
<form method="post" action="<?= $e($action) ?>" class="form">
<?= $csrf->field() ?>
<div class="field <?= isset($errors['username']) ? 'field--error' : '' ?>">
<label for="username">Benutzername <span class="req">*</span></label>
<input type="text" id="username" name="username" value="<?= $e($values['username']) ?>"
required autofocus spellcheck="false" autocapitalize="off" autocomplete="username">
<p class="field__hint">3–64 Zeichen: Buchstaben, Ziffern sowie <code>. _ - @</code>.
Groß- und Kleinschreibung wird beim Anmelden nicht unterschieden.</p>
<?php if (isset($errors['username'])): ?>
<p class="field__error"><?= $e($errors['username']) ?></p>
<?php endif; ?>
</div>
<div class="field <?= isset($errors['role']) ? 'field--error' : '' ?>">
<label for="role">Rolle <span class="req">*</span></label>
<select id="role" name="role">
<?php foreach (Role::GLOBAL_LABELS as $value => $label): ?>
<option value="<?= $e($value) ?>" <?= $values['role'] === $value ? 'selected' : '' ?>><?= $e($label) ?></option>
<?php endforeach; ?>
</select>
<p class="field__hint">
<strong>Administrator:</strong> verwaltet Benutzer, Gruppen und Domains und sieht alle Kurzlinks.<br>
<strong>Benutzer:</strong> sieht ausschließlich die Kurzlinks seiner Gruppen.
</p>
<?php if (isset($errors['role'])): ?>
<p class="field__error"><?= $e($errors['role']) ?></p>
<?php endif; ?>
</div>
<div class="field field--check">
<label class="check">
<input type="checkbox" name="is_active" value="1" <?= $values['is_active'] === '1' ? 'checked' : '' ?>>
<span>Konto ist aktiv</span>
</label>
<p class="field__hint">Ein gesperrtes Konto kann sich nicht anmelden; laufende Sitzungen enden sofort.</p>
</div>
<fieldset class="field">
<legend>Gruppen</legend>
<?php if ($groups === []): ?>
<p class="field__hint">Es gibt noch keine Gruppe.
<a href="<?= $e($url('/admin/groups/new')) ?>">Jetzt eine anlegen.</a></p>
<?php else: ?>
<div class="table-wrap">
<table class="table">
<thead>
<tr>
<th scope="col">Gruppe</th>
<th scope="col">Rolle in der Gruppe</th>
</tr>
</thead>
<tbody>
<?php foreach ($groups as $group):
$groupId = (int) $group['id'];
$selected = $memberships[$groupId] ?? '';
?>
<tr>
<td><?= $e($group['name']) ?>
<span class="muted small">(<?= (int) $group['domain_count'] ?> Domain(s))</span>
</td>
<td>
<select name="groups[<?= $groupId ?>]" aria-label="Rolle in <?= $e($group['name']) ?>">
<option value="">kein Mitglied</option>
<option value="<?= Role::EDITOR ?>" <?= $selected === Role::EDITOR ? 'selected' : '' ?>>
<?= $e(Role::GROUP_LABELS[Role::EDITOR]) ?>
</option>
<option value="<?= Role::VIEWER ?>" <?= $selected === Role::VIEWER ? 'selected' : '' ?>>
<?= $e(Role::GROUP_LABELS[Role::VIEWER]) ?>
</option>
</select>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<p class="field__hint">Ein Administrator sieht alle Kurzlinks – für ihn ist die Zuordnung
nur dann nötig, wenn er auch selbst welche anlegen soll.</p>
<?php endif; ?>
</fieldset>
<fieldset class="field">
<legend>Passwort</legend>
<div class="field <?= isset($errors['password']) ? 'field--error' : '' ?>">
<label for="password"><?= $isEdit ? 'Neues Passwort' : 'Passwort' ?>
<?= $isEdit ? '' : '<span class="req">*</span>' ?></label>
<input type="password" id="password" name="password" autocomplete="new-password"
minlength="<?= (int) $minLength ?>" <?= $isEdit ? '' : 'required' ?>>
<p class="field__hint">
Mindestens <?= (int) $minLength ?> Zeichen.
<?= $isEdit ? 'Leer lassen, um das bisherige Passwort zu behalten. Eine Änderung meldet den Betroffenen überall ab.' : '' ?>
</p>
<?php if (isset($errors['password'])): ?>
<p class="field__error"><?= $e($errors['password']) ?></p>
<?php endif; ?>
</div>
<div class="field <?= isset($errors['password_confirm']) ? 'field--error' : '' ?>">
<label for="password_confirm">Wiederholung</label>
<input type="password" id="password_confirm" name="password_confirm" autocomplete="new-password"
<?= $isEdit ? '' : 'required' ?>>
<?php if (isset($errors['password_confirm'])): ?>
<p class="field__error"><?= $e($errors['password_confirm']) ?></p>
<?php endif; ?>
</div>
</fieldset>
<div class="form__actions">
<button type="submit" class="btn btn--primary"><?= $isEdit ? 'Änderungen speichern' : 'Benutzer anlegen' ?></button>
<a href="<?= $e($url('/admin/users')) ?>" class="btn btn--ghost">Abbrechen</a>
</div>
</form>
</div>
+96
View File
@@ -0,0 +1,96 @@
<?php
/**
* Benutzeruebersicht.
*
* @var list<array<string,mixed>> $users
* @var int|null $currentId
* @var string|null $emergencyUser Name des Notfallzugangs aus der .env
* @var App\Security\Csrf $csrf
*/
use App\Security\Role;
?>
<div class="page-head">
<div>
<h1>Benutzer</h1>
<p class="muted"><?= count($users) ?> Konto/Konten. Wer welche Kurzlinks sieht, entscheidet die
<a href="<?= $e($url('/admin/groups')) ?>">Gruppenzugehörigkeit</a>.</p>
</div>
<a href="<?= $e($url('/admin/users/new')) ?>" class="btn btn--primary">Neuer Benutzer</a>
</div>
<?php if ($emergencyUser !== null): ?>
<div class="alert alert--warn">
Zusätzlich ist der Notfallzugang <code><?= $e($emergencyUser) ?></code> aus der <code>.env</code> aktiv.
Er hat volle Administratorrechte, taucht in dieser Liste nicht auf und lässt sich nur dort ändern
(<code>ADMIN_USER</code> / <code>ADMIN_PASSWORD_HASH</code>). Wird er nicht mehr gebraucht, sollten
beide Zeilen entfernt werden.
</div>
<?php endif; ?>
<div class="card card--flush">
<div class="table-wrap">
<table class="table">
<thead>
<tr>
<th scope="col">Benutzer</th>
<th scope="col">Rolle</th>
<th scope="col">Gruppen</th>
<th scope="col">Zuletzt angemeldet</th>
<th scope="col"><span class="sr-only">Aktionen</span></th>
</tr>
</thead>
<tbody>
<?php foreach ($users as $user):
$id = (int) $user['id'];
$active = (int) $user['is_active'] === 1;
$isSelf = $currentId === $id;
?>
<tr class="<?= $active ? '' : 'is-inactive' ?>">
<td>
<div class="cell-stack">
<span class="mono"><?= $e($user['username']) ?></span>
<?php if ($isSelf): ?>
<span class="muted small">das eigene Konto</span>
<?php endif; ?>
<?php if (!$active): ?>
<span class="badge badge--muted">gesperrt</span>
<?php endif; ?>
</div>
</td>
<td class="small"><?= $e(Role::globalLabel($user['role'])) ?></td>
<td class="small">
<?php if ($user['groups'] === []): ?>
<span class="muted">–</span>
<?php else: ?>
<?php foreach ($user['groups'] as $group): ?>
<span class="badge badge--muted" title="<?= $e(Role::groupLabel($group['group_role'])) ?>">
<?= $e($group['name']) ?><?= $group['group_role'] === Role::EDITOR ? '' : ' (lesend)' ?>
</span>
<?php endforeach; ?>
<?php endif; ?>
</td>
<td class="muted small nowrap">
<?php if ($user['last_login_at'] !== null): ?>
<?= $e(date('d.m.Y H:i', strtotime((string) $user['last_login_at'] . ' UTC') ?: time())) ?>
<?php else: ?>
noch nie
<?php endif; ?>
</td>
<td class="actions">
<a class="btn btn--sm btn--ghost" href="<?= $e($url('/admin/users/' . $id . '/edit')) ?>">Bearbeiten</a>
<?php if (!$isSelf): ?>
<form method="post" action="<?= $e($url('/admin/users/' . $id . '/delete')) ?>" class="inline"
data-confirm="Benutzer <?= $e($user['username']) ?> wirklich löschen? Die Kurzlinks der Gruppen bleiben erhalten.">
<?= $csrf->field() ?>
<button type="submit" class="btn btn--sm btn--danger">Löschen</button>
</form>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>