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
+59 -12
View File
@@ -8,6 +8,7 @@ use App\Config;
use App\Http\Request;
use App\Http\Response;
use App\Repository\DomainRepository;
use App\Repository\GroupRepository;
use App\Repository\SettingRepository;
use App\Security\Auth;
use App\Security\Csrf;
@@ -19,6 +20,11 @@ use App\View;
/**
* Verwaltung der Kurzlink-Domains.
*
* Ausschliesslich fuer Administratoren: eine Domain gehoert zu einer
* Gruppe und entscheidet damit, wer die Kurzlinks darunter sehen und
* anlegen darf. Einzige Ausnahme ist der Endpunkt der Domain-Pruefung,
* der sich ueber ein kurzlebiges Token ausweist.
*/
final class DomainController extends Controller
{
@@ -31,13 +37,14 @@ final class DomainController extends Controller
private readonly DomainRepository $domains,
private readonly DomainChecker $checker,
private readonly SettingRepository $settings,
private readonly GroupRepository $groups,
) {
parent::__construct($config, $view);
}
public function index(Request $request): Response
{
if (($response = $this->requireAuth($this->auth, $this->session, $request)) !== null) {
if (($response = $this->requireAdmin($this->auth, $this->session, $request)) !== null) {
return $response;
}
@@ -50,6 +57,7 @@ final class DomainController extends Controller
return $this->html('domains/index', [
'domains' => $this->domains->all(),
'groups' => $this->groups->all(),
'requestHost' => ShortDomain::normalizeRequestHost((string) ($request->server['HTTP_HOST'] ?? '')),
'checkResult' => is_array($checkResult) ? $checkResult : null,
'pageTitle' => 'Domains',
@@ -63,7 +71,7 @@ final class DomainController extends Controller
*/
public function check(Request $request, array $args = []): Response
{
if (($response = $this->requireAuth($this->auth, $this->session, $request)) !== null) {
if (($response = $this->requireAdmin($this->auth, $this->session, $request)) !== null) {
return $response;
}
@@ -105,13 +113,19 @@ final class DomainController extends Controller
public function create(Request $request): Response
{
if (($response = $this->requireAuth($this->auth, $this->session, $request)) !== null) {
if (($response = $this->requireAdmin($this->auth, $this->session, $request)) !== null) {
return $response;
}
return $this->html('domains/form', [
'domain' => null,
'values' => ['base_url' => '', 'label' => '', 'is_default' => '0'],
'groups' => $this->groups->all(),
'values' => [
'base_url' => '',
'label' => '',
'is_default' => '0',
'group_id' => (string) $this->groups->defaultGroupId(),
],
'errors' => [],
'pageTitle' => 'Neue Domain',
]);
@@ -119,7 +133,7 @@ final class DomainController extends Controller
public function store(Request $request): Response
{
if (($response = $this->requireAuth($this->auth, $this->session, $request)) !== null) {
if (($response = $this->requireAdmin($this->auth, $this->session, $request)) !== null) {
return $response;
}
@@ -135,9 +149,12 @@ final class DomainController extends Controller
$errors['base_url'] = 'Diese Domain ist bereits eingetragen.';
}
$groupId = $this->resolveGroupId($values['group_id'], $errors);
if ($errors !== []) {
return $this->html('domains/form', [
'domain' => null,
'groups' => $this->groups->all(),
'values' => $values,
'errors' => $errors,
'pageTitle' => 'Neue Domain',
@@ -146,7 +163,7 @@ final class DomainController extends Controller
// Die erste Domain ist zwangslaeufig die Standarddomain.
$isDefault = $values['is_default'] === '1' || $this->domains->count() === 0;
$this->domains->create($baseUrl, $values['label'] !== '' ? $values['label'] : null, $isDefault);
$this->domains->create($baseUrl, $values['label'] !== '' ? $values['label'] : null, $isDefault, $groupId);
$this->session->flash('success', sprintf(
'Domain "%s" wurde angelegt. Bitte daran denken, sie im DNS und beim Webserver auf diese Installation zu zeigen.',
@@ -161,7 +178,7 @@ final class DomainController extends Controller
*/
public function edit(Request $request, array $args = []): Response
{
if (($response = $this->requireAuth($this->auth, $this->session, $request)) !== null) {
if (($response = $this->requireAdmin($this->auth, $this->session, $request)) !== null) {
return $response;
}
@@ -172,10 +189,12 @@ final class DomainController extends Controller
return $this->html('domains/form', [
'domain' => $domain,
'groups' => $this->groups->all(),
'values' => [
'base_url' => (string) $domain['base_url'],
'label' => (string) ($domain['label'] ?? ''),
'is_default' => ((int) $domain['is_default'] === 1) ? '1' : '0',
'group_id' => (string) $domain['group_id'],
],
'errors' => [],
'pageTitle' => 'Domain bearbeiten',
@@ -187,7 +206,7 @@ final class DomainController extends Controller
*/
public function update(Request $request, array $args = []): Response
{
if (($response = $this->requireAuth($this->auth, $this->session, $request)) !== null) {
if (($response = $this->requireAdmin($this->auth, $this->session, $request)) !== null) {
return $response;
}
@@ -209,16 +228,19 @@ final class DomainController extends Controller
$errors['base_url'] = 'Diese Domain ist bereits eingetragen.';
}
$groupId = $this->resolveGroupId($values['group_id'], $errors);
if ($errors !== []) {
return $this->html('domains/form', [
'domain' => $domain,
'groups' => $this->groups->all(),
'values' => $values,
'errors' => $errors,
'pageTitle' => 'Domain bearbeiten',
], 422);
}
$this->domains->update($id, $baseUrl, $values['label'] !== '' ? $values['label'] : null);
$this->domains->update($id, $baseUrl, $values['label'] !== '' ? $values['label'] : null, $groupId);
if ($values['is_default'] === '1') {
$this->domains->makeDefault($id);
@@ -234,7 +256,7 @@ final class DomainController extends Controller
*/
public function makeDefault(Request $request, array $args = []): Response
{
if (($response = $this->requireAuth($this->auth, $this->session, $request)) !== null) {
if (($response = $this->requireAdmin($this->auth, $this->session, $request)) !== null) {
return $response;
}
@@ -261,7 +283,7 @@ final class DomainController extends Controller
*/
public function destroy(Request $request, array $args = []): Response
{
if (($response = $this->requireAuth($this->auth, $this->session, $request)) !== null) {
if (($response = $this->requireAdmin($this->auth, $this->session, $request)) !== null) {
return $response;
}
@@ -314,7 +336,31 @@ final class DomainController extends Controller
}
/**
* @return array{base_url:string,label:string,is_default:string}
* Prueft die gewaehlte Gruppe. Ohne Angabe gilt die Standardgruppe -
* eine Domain ohne Gruppe waere sonst fuer niemanden ausser
* Administratoren nutzbar.
*
* @param array<string,string> $errors
*/
private function resolveGroupId(string $value, array &$errors): int
{
if ($value === '') {
return $this->groups->defaultGroupId();
}
$group = $this->groups->find((int) $value);
if ($group === null) {
$errors['group_id'] = 'Bitte eine gültige Gruppe auswählen.';
return $this->groups->defaultGroupId();
}
return (int) $group['id'];
}
/**
* @return array{base_url:string,label:string,is_default:string,group_id:string}
*/
private function readValues(Request $request): array
{
@@ -322,6 +368,7 @@ final class DomainController extends Controller
'base_url' => $request->post('base_url'),
'label' => mb_substr($request->post('label'), 0, 255),
'is_default' => $request->hasPost('is_default') ? '1' : '0',
'group_id' => $request->post('group_id'),
];
}