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
+79 -5
View File
@@ -6,11 +6,14 @@ namespace App;
use App\Controller\AuthController; use App\Controller\AuthController;
use App\Controller\DashboardController; use App\Controller\DashboardController;
use App\Controller\GroupController;
use App\Controller\LinkController; use App\Controller\LinkController;
use App\Controller\ProfileController;
use App\Controller\QrController; use App\Controller\QrController;
use App\Controller\RedirectController; use App\Controller\RedirectController;
use App\Controller\SetupController; use App\Controller\SetupController;
use App\Controller\StatsController; use App\Controller\StatsController;
use App\Controller\UserController;
use App\Http\Request; use App\Http\Request;
use App\Http\Response; use App\Http\Response;
use App\Http\Router; use App\Http\Router;
@@ -18,10 +21,13 @@ use App\Qr\LogoStore;
use App\Qr\QrRenderer; use App\Qr\QrRenderer;
use App\Controller\DomainController; use App\Controller\DomainController;
use App\Repository\DomainRepository; use App\Repository\DomainRepository;
use App\Repository\GroupRepository;
use App\Repository\LinkRepository; use App\Repository\LinkRepository;
use App\Repository\QrDesignRepository; use App\Repository\QrDesignRepository;
use App\Repository\SettingRepository; use App\Repository\SettingRepository;
use App\Repository\UserRepository;
use App\Repository\VisitRepository; use App\Repository\VisitRepository;
use App\Security\AccessPolicy;
use App\Security\Auth; use App\Security\Auth;
use App\Security\Csrf; use App\Security\Csrf;
use App\Security\RateLimiter; use App\Security\RateLimiter;
@@ -46,6 +52,9 @@ final class App
private QrDesignRepository $designs; private QrDesignRepository $designs;
private DomainRepository $domains; private DomainRepository $domains;
private SettingRepository $settings; private SettingRepository $settings;
private UserRepository $users;
private GroupRepository $groups;
private AccessPolicy $policy;
private LogoStore $logos; private LogoStore $logos;
private QrRenderer $qr; private QrRenderer $qr;
private RateLimiter $rateLimiter; private RateLimiter $rateLimiter;
@@ -61,12 +70,19 @@ final class App
$this->settings = $settings; $this->settings = $settings;
$this->session = new Session($config); $this->session = new Session($config);
$this->csrf = new Csrf($this->session); $this->csrf = new Csrf($this->session);
$this->auth = new Auth($config, $this->session, $settings);
$this->users = new UserRepository($this->db);
$this->groups = new GroupRepository($this->db);
$this->auth = new Auth($config, $this->session, $this->users);
$this->links = new LinkRepository($this->db); $this->links = new LinkRepository($this->db);
$this->visits = new VisitRepository($this->db); $this->visits = new VisitRepository($this->db);
$this->designs = new QrDesignRepository($this->db); $this->designs = new QrDesignRepository($this->db);
$this->domains = new DomainRepository($this->db); $this->domains = new DomainRepository($this->db, $this->groups);
// Beantwortet fuer den angemeldeten Benutzer, welche Domains und
// damit welche Kurzlinks sichtbar bzw. aenderbar sind.
$this->policy = new AccessPolicy($this->auth, $this->groups, $this->domains);
$this->logos = new LogoStore( $this->logos = new LogoStore(
$config->path($config->getString('UPLOAD_DIR', 'data/uploads')), $config->path($config->getString('UPLOAD_DIR', 'data/uploads')),
@@ -85,6 +101,9 @@ final class App
$this->view->share('csrf', $this->csrf); $this->view->share('csrf', $this->csrf);
$this->view->share('auth', $this->auth); $this->view->share('auth', $this->auth);
$this->view->share('baseUrl', $config->baseUrl()); $this->view->share('baseUrl', $config->baseUrl());
// Die Navigation blendet Verwaltungspunkte aus, fuer die die
// Rechte fehlen. Die Routen pruefen unabhaengig davon noch einmal.
$this->view->share('policy', $this->policy);
} }
public function run(): void public function run(): void
@@ -140,6 +159,8 @@ final class App
new SetupToken($this->config->path($this->config->getString('DATA_DIR', 'data'))), new SetupToken($this->config->path($this->config->getString('DATA_DIR', 'data'))),
$this->rateLimiter, $this->rateLimiter,
new SystemCheck($this->config), new SystemCheck($this->config),
$this->users,
$this->groups,
); );
$login = new AuthController($this->config, $this->view, $this->session, $this->csrf, $this->auth, $this->rateLimiter); $login = new AuthController($this->config, $this->view, $this->session, $this->csrf, $this->auth, $this->rateLimiter);
$dashboard = new DashboardController( $dashboard = new DashboardController(
@@ -150,10 +171,13 @@ final class App
$this->config, $this->config,
$this->domains, $this->domains,
new SystemCheck($this->config), new SystemCheck($this->config),
$this->policy,
$this->groups,
$this->users,
); );
$linkController = new LinkController($this->config, $this->view, $this->session, $this->csrf, $this->auth, $this->links, $this->designs, $this->logos, $this->domains); $linkController = new LinkController($this->config, $this->view, $this->session, $this->csrf, $this->auth, $this->links, $this->designs, $this->logos, $this->domains, $this->policy);
$stats = new StatsController($this->config, $this->view, $this->auth, $this->links, $this->visits); $stats = new StatsController($this->config, $this->view, $this->auth, $this->links, $this->visits, $this->policy);
$qrController = new QrController($this->config, $this->view, $this->session, $this->csrf, $this->auth, $this->links, $this->designs, $this->logos, $this->qr); $qrController = new QrController($this->config, $this->view, $this->session, $this->csrf, $this->auth, $this->links, $this->designs, $this->logos, $this->qr, $this->policy);
$domainController = new DomainController( $domainController = new DomainController(
$this->config, $this->config,
$this->view, $this->view,
@@ -163,6 +187,35 @@ final class App
$this->domains, $this->domains,
new DomainChecker($this->settings), new DomainChecker($this->settings),
$this->settings, $this->settings,
$this->groups,
);
$userController = new UserController(
$this->config,
$this->view,
$this->session,
$this->csrf,
$this->auth,
$this->users,
$this->groups,
);
$groupController = new GroupController(
$this->config,
$this->view,
$this->session,
$this->csrf,
$this->auth,
$this->groups,
$this->users,
$this->domains,
);
$profile = new ProfileController(
$this->config,
$this->view,
$this->session,
$this->csrf,
$this->auth,
$this->users,
$this->groups,
); );
$redirect = new RedirectController($this->config, $this->view, $this->links, $this->visits, $this->domains); $redirect = new RedirectController($this->config, $this->view, $this->links, $this->visits, $this->domains);
@@ -207,6 +260,27 @@ final class App
$router->post('/admin/domains/{id}/delete', [$domainController, 'destroy']); $router->post('/admin/domains/{id}/delete', [$domainController, 'destroy']);
$router->post('/admin/domains/{id}/check', [$domainController, 'check']); $router->post('/admin/domains/{id}/check', [$domainController, 'check']);
// --- Benutzer und Gruppen (nur Administratoren) -----------------
$router->get('/admin/users', [$userController, 'index']);
$router->get('/admin/users/new', [$userController, 'create']);
$router->post('/admin/users/new', [$userController, 'store']);
$router->get('/admin/users/{id}/edit', [$userController, 'edit']);
$router->post('/admin/users/{id}/edit', [$userController, 'update']);
$router->post('/admin/users/{id}/delete', [$userController, 'destroy']);
$router->get('/admin/groups', [$groupController, 'index']);
$router->get('/admin/groups/new', [$groupController, 'create']);
$router->post('/admin/groups/new', [$groupController, 'store']);
$router->get('/admin/groups/{id}/edit', [$groupController, 'edit']);
$router->post('/admin/groups/{id}/edit', [$groupController, 'update']);
$router->post('/admin/groups/{id}/members', [$groupController, 'saveMember']);
$router->post('/admin/groups/{id}/members/remove', [$groupController, 'removeMember']);
$router->post('/admin/groups/{id}/delete', [$groupController, 'destroy']);
// --- Eigenes Konto (jeder angemeldete Benutzer) -----------------
$router->get('/admin/profile', [$profile, 'show']);
$router->post('/admin/profile/password', [$profile, 'updatePassword']);
// Endpunkt der Domain-Pruefung: ohne Login erreichbar, aber nur // Endpunkt der Domain-Pruefung: ohne Login erreichbar, aber nur
// mit gueltigem, kurzlebigem Token (sonst 404). // mit gueltigem, kurzlebigem Token (sonst 404).
$router->get(DomainChecker::PATH, [$domainController, 'verify']); $router->get(DomainChecker::PATH, [$domainController, 'verify']);
+29 -12
View File
@@ -52,13 +52,25 @@ final class AuthController extends Controller
return $response; return $response;
} }
$ip = $request->clientIp(); $username = $request->post('username');
$password = (string) ($request->post['password'] ?? '');
if ($this->rateLimiter->isLocked($ip)) { // Zwei Zaehler: die IP bremst einen einzelnen Angreifer, der
$seconds = $this->rateLimiter->retryAfter($ip); // Benutzername schuetzt ein einzelnes Konto auch dann, wenn die
// Versuche von wechselnden Adressen kommen.
$ipKey = $request->clientIp();
$userKey = 'user|' . mb_strtolower($username, 'UTF-8');
$keys = $username === '' ? [$ipKey] : [$ipKey, $userKey];
foreach ($keys as $key) {
if (!$this->rateLimiter->isLocked($key)) {
continue;
}
$seconds = $this->rateLimiter->retryAfter($key);
return $this->html('auth/login', [ return $this->html('auth/login', [
'username' => $request->post('username'), 'username' => $username,
'error' => sprintf( 'error' => sprintf(
'Zu viele Fehlversuche. Bitte in %d Minuten erneut versuchen.', 'Zu viele Fehlversuche. Bitte in %d Minuten erneut versuchen.',
max(1, (int) ceil($seconds / 60)), max(1, (int) ceil($seconds / 60)),
@@ -66,12 +78,14 @@ final class AuthController extends Controller
], 429, 'layout_plain')->withHeader('Retry-After', (string) $seconds); ], 429, 'layout_plain')->withHeader('Retry-After', (string) $seconds);
} }
$username = $request->post('username'); $user = $this->auth->verifyCredentials($username, $password);
$password = (string) ($request->post['password'] ?? '');
if (!$this->auth->verifyCredentials($username, $password)) { if ($user === null) {
$this->rateLimiter->recordFailure($ip); foreach ($keys as $key) {
$remaining = $this->rateLimiter->remainingAttempts($ip); $this->rateLimiter->recordFailure($key);
}
$remaining = $this->rateLimiter->remainingAttempts($ipKey);
// Kleine Verzoegerung gegen automatisiertes Durchprobieren. // Kleine Verzoegerung gegen automatisiertes Durchprobieren.
usleep(300000); usleep(300000);
@@ -84,14 +98,17 @@ final class AuthController extends Controller
], 401, 'layout_plain'); ], 401, 'layout_plain');
} }
$this->rateLimiter->recordSuccess($ip); foreach ($keys as $key) {
$this->auth->login($username); $this->rateLimiter->recordSuccess($key);
}
$this->auth->login($user);
$intended = $this->session->get('_intended'); $intended = $this->session->get('_intended');
$this->session->remove('_intended'); $this->session->remove('_intended');
$target = is_string($intended) && str_starts_with($intended, '/') ? $intended : '/admin'; $target = is_string($intended) && str_starts_with($intended, '/') ? $intended : '/admin';
$this->session->flash('success', 'Willkommen zurück, ' . $username . '.'); $this->session->flash('success', 'Willkommen zurück, ' . (string) $user['username'] . '.');
return $this->redirect($target); return $this->redirect($target);
} }
+39
View File
@@ -68,6 +68,45 @@ abstract class Controller
return $this->redirect('/login'); return $this->redirect('/login');
} }
/**
* Wie requireAuth, verlangt zusaetzlich die globale Rolle
* "Administrator". Benutzer-, Gruppen- und Domainverwaltung sind
* ausschliesslich darueber erreichbar.
*/
protected function requireAdmin(Auth $auth, Session $session, Request $request): ?Response
{
if (($response = $this->requireAuth($auth, $session, $request)) !== null) {
return $response;
}
return $auth->isAdmin() ? null : $this->forbidden();
}
/**
* Antwort auf einen Zugriff, fuer den die Rechte fehlen.
*
* Bewusst nur dort, wo die Existenz ohnehin bekannt ist (etwa ein
* Menuepunkt). Bei fremden Kurzlinks und Domains antwortet die
* Anwendung stattdessen mit 404 - so verraet sie nicht, dass es den
* Datensatz gibt.
*/
protected function forbidden(
string $message = 'Für diesen Bereich fehlen die nötigen Rechte. Bitte an eine Administratorin oder einen Administrator wenden.',
): Response {
return Response::html(
$this->view->render(
'errors/error',
[
'title' => 'Kein Zugriff',
'message' => $message,
'pageTitle' => 'Kein Zugriff',
],
'layout',
),
403,
);
}
protected function requireCsrf(Csrf $csrf, Request $request): ?Response protected function requireCsrf(Csrf $csrf, Request $request): ?Response
{ {
$token = $request->post[Csrf::FIELD] ?? null; $token = $request->post[Csrf::FIELD] ?? null;
+31 -8
View File
@@ -8,8 +8,11 @@ use App\Config;
use App\Http\Request; use App\Http\Request;
use App\Http\Response; use App\Http\Response;
use App\Repository\DomainRepository; use App\Repository\DomainRepository;
use App\Repository\GroupRepository;
use App\Repository\LinkRepository; use App\Repository\LinkRepository;
use App\Repository\UserRepository;
use App\Repository\VisitRepository; use App\Repository\VisitRepository;
use App\Security\AccessPolicy;
use App\Security\Auth; use App\Security\Auth;
use App\Security\Session; use App\Security\Session;
use App\Service\SystemCheck; use App\Service\SystemCheck;
@@ -25,6 +28,9 @@ final class DashboardController extends Controller
Config $config, Config $config,
private readonly DomainRepository $domains, private readonly DomainRepository $domains,
private readonly SystemCheck $systemCheck, private readonly SystemCheck $systemCheck,
private readonly AccessPolicy $policy,
private readonly GroupRepository $groups,
private readonly UserRepository $users,
) { ) {
parent::__construct($config, $view); parent::__construct($config, $view);
} }
@@ -39,18 +45,35 @@ final class DashboardController extends Controller
return $this->redirect('/login'); return $this->redirect('/login');
} }
// Sorgt nebenbei dafuer, dass die Standarddomain existiert. $isAdmin = $this->policy->isAdmin();
$this->domains->defaultDomain($this->config->baseUrl());
// Sorgt nebenbei dafuer, dass die Standarddomain existiert -
// aber nur beim Administrator: eine Domain anzulegen ist ein
// Eingriff in die Installation.
if ($isAdmin) {
$this->domains->defaultDomain($this->config->baseUrl());
}
$visibleIds = $this->policy->visibleDomainIds();
$visibleDomains = $this->policy->visibleDomains();
return $this->html('dashboard/index', [ return $this->html('dashboard/index', [
'summary' => $this->links->summary(), 'summary' => $this->links->summary(null, $visibleIds),
'recentLinks' => $this->links->all('', 'created_desc', 5), 'recentLinks' => $this->links->all('', 'created_desc', 5, 0, null, $visibleIds),
'topLinks' => $this->links->all('', 'clicks_desc', 5), 'topLinks' => $this->links->all('', 'clicks_desc', 5, 0, null, $visibleIds),
'series' => $this->visits->clicksPerDayGlobal(30), 'series' => $this->visits->clicksPerDayGlobal(30, $visibleIds),
'domainCount' => $this->domains->count(), 'domainCount' => count($visibleDomains),
'isAdmin' => $isAdmin,
'canCreate' => $this->policy->canCreateLinks(),
// Gruppen des Benutzers - bei einem Administrator wenig
// aussagekraeftig, deshalb zeigt die Uebersicht ihm
// stattdessen die Gesamtzahlen der Installation.
'myGroups' => $this->groups->groupsOfUser($this->auth->id() ?? 0),
'groupCount' => $isAdmin ? $this->groups->count() : null,
'userCount' => $isAdmin ? $this->users->count() : null,
// Nur melden, wenn etwas nicht stimmt - im Normalfall // Nur melden, wenn etwas nicht stimmt - im Normalfall
// soll die Uebersicht nicht mit Technik zugestellt werden. // soll die Uebersicht nicht mit Technik zugestellt werden.
'systemProblems' => $this->systemCheck->problems(), 'systemProblems' => $isAdmin ? $this->systemCheck->problems() : [],
'pageTitle' => 'Übersicht', 'pageTitle' => 'Übersicht',
]); ]);
} }
+59 -12
View File
@@ -8,6 +8,7 @@ use App\Config;
use App\Http\Request; use App\Http\Request;
use App\Http\Response; use App\Http\Response;
use App\Repository\DomainRepository; use App\Repository\DomainRepository;
use App\Repository\GroupRepository;
use App\Repository\SettingRepository; use App\Repository\SettingRepository;
use App\Security\Auth; use App\Security\Auth;
use App\Security\Csrf; use App\Security\Csrf;
@@ -19,6 +20,11 @@ use App\View;
/** /**
* Verwaltung der Kurzlink-Domains. * 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 final class DomainController extends Controller
{ {
@@ -31,13 +37,14 @@ final class DomainController extends Controller
private readonly DomainRepository $domains, private readonly DomainRepository $domains,
private readonly DomainChecker $checker, private readonly DomainChecker $checker,
private readonly SettingRepository $settings, private readonly SettingRepository $settings,
private readonly GroupRepository $groups,
) { ) {
parent::__construct($config, $view); parent::__construct($config, $view);
} }
public function index(Request $request): Response 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; return $response;
} }
@@ -50,6 +57,7 @@ final class DomainController extends Controller
return $this->html('domains/index', [ return $this->html('domains/index', [
'domains' => $this->domains->all(), 'domains' => $this->domains->all(),
'groups' => $this->groups->all(),
'requestHost' => ShortDomain::normalizeRequestHost((string) ($request->server['HTTP_HOST'] ?? '')), 'requestHost' => ShortDomain::normalizeRequestHost((string) ($request->server['HTTP_HOST'] ?? '')),
'checkResult' => is_array($checkResult) ? $checkResult : null, 'checkResult' => is_array($checkResult) ? $checkResult : null,
'pageTitle' => 'Domains', 'pageTitle' => 'Domains',
@@ -63,7 +71,7 @@ final class DomainController extends Controller
*/ */
public function check(Request $request, array $args = []): Response 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; return $response;
} }
@@ -105,13 +113,19 @@ final class DomainController extends Controller
public function create(Request $request): Response 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 $response;
} }
return $this->html('domains/form', [ return $this->html('domains/form', [
'domain' => null, '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' => [], 'errors' => [],
'pageTitle' => 'Neue Domain', 'pageTitle' => 'Neue Domain',
]); ]);
@@ -119,7 +133,7 @@ final class DomainController extends Controller
public function store(Request $request): Response 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; return $response;
} }
@@ -135,9 +149,12 @@ final class DomainController extends Controller
$errors['base_url'] = 'Diese Domain ist bereits eingetragen.'; $errors['base_url'] = 'Diese Domain ist bereits eingetragen.';
} }
$groupId = $this->resolveGroupId($values['group_id'], $errors);
if ($errors !== []) { if ($errors !== []) {
return $this->html('domains/form', [ return $this->html('domains/form', [
'domain' => null, 'domain' => null,
'groups' => $this->groups->all(),
'values' => $values, 'values' => $values,
'errors' => $errors, 'errors' => $errors,
'pageTitle' => 'Neue Domain', 'pageTitle' => 'Neue Domain',
@@ -146,7 +163,7 @@ final class DomainController extends Controller
// Die erste Domain ist zwangslaeufig die Standarddomain. // Die erste Domain ist zwangslaeufig die Standarddomain.
$isDefault = $values['is_default'] === '1' || $this->domains->count() === 0; $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( $this->session->flash('success', sprintf(
'Domain "%s" wurde angelegt. Bitte daran denken, sie im DNS und beim Webserver auf diese Installation zu zeigen.', '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 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; return $response;
} }
@@ -172,10 +189,12 @@ final class DomainController extends Controller
return $this->html('domains/form', [ return $this->html('domains/form', [
'domain' => $domain, 'domain' => $domain,
'groups' => $this->groups->all(),
'values' => [ 'values' => [
'base_url' => (string) $domain['base_url'], 'base_url' => (string) $domain['base_url'],
'label' => (string) ($domain['label'] ?? ''), 'label' => (string) ($domain['label'] ?? ''),
'is_default' => ((int) $domain['is_default'] === 1) ? '1' : '0', 'is_default' => ((int) $domain['is_default'] === 1) ? '1' : '0',
'group_id' => (string) $domain['group_id'],
], ],
'errors' => [], 'errors' => [],
'pageTitle' => 'Domain bearbeiten', 'pageTitle' => 'Domain bearbeiten',
@@ -187,7 +206,7 @@ final class DomainController extends Controller
*/ */
public function update(Request $request, array $args = []): Response 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; return $response;
} }
@@ -209,16 +228,19 @@ final class DomainController extends Controller
$errors['base_url'] = 'Diese Domain ist bereits eingetragen.'; $errors['base_url'] = 'Diese Domain ist bereits eingetragen.';
} }
$groupId = $this->resolveGroupId($values['group_id'], $errors);
if ($errors !== []) { if ($errors !== []) {
return $this->html('domains/form', [ return $this->html('domains/form', [
'domain' => $domain, 'domain' => $domain,
'groups' => $this->groups->all(),
'values' => $values, 'values' => $values,
'errors' => $errors, 'errors' => $errors,
'pageTitle' => 'Domain bearbeiten', 'pageTitle' => 'Domain bearbeiten',
], 422); ], 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') { if ($values['is_default'] === '1') {
$this->domains->makeDefault($id); $this->domains->makeDefault($id);
@@ -234,7 +256,7 @@ final class DomainController extends Controller
*/ */
public function makeDefault(Request $request, array $args = []): Response 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; return $response;
} }
@@ -261,7 +283,7 @@ final class DomainController extends Controller
*/ */
public function destroy(Request $request, array $args = []): Response 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; 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 private function readValues(Request $request): array
{ {
@@ -322,6 +368,7 @@ final class DomainController extends Controller
'base_url' => $request->post('base_url'), 'base_url' => $request->post('base_url'),
'label' => mb_substr($request->post('label'), 0, 255), 'label' => mb_substr($request->post('label'), 0, 255),
'is_default' => $request->hasPost('is_default') ? '1' : '0', 'is_default' => $request->hasPost('is_default') ? '1' : '0',
'group_id' => $request->post('group_id'),
]; ];
} }
+320
View File
@@ -0,0 +1,320 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Config;
use App\Http\Request;
use App\Http\Response;
use App\Repository\DomainRepository;
use App\Repository\GroupRepository;
use App\Repository\UserRepository;
use App\Security\Auth;
use App\Security\Csrf;
use App\Security\Role;
use App\Security\Session;
use App\View;
/**
* Gruppenverwaltung - ausschliesslich fuer Administratoren.
*
* Eine Gruppe verbindet Benutzer mit Domains: wer Mitglied ist, sieht
* alle Kurzlinks aller Domains dieser Gruppe. Mit der Rolle "Editor"
* darf er sie ausserdem anlegen und aendern.
*/
final class GroupController extends Controller
{
private const MAX_NAME_LENGTH = 60;
public function __construct(
Config $config,
View $view,
private readonly Session $session,
private readonly Csrf $csrf,
private readonly Auth $auth,
private readonly GroupRepository $groups,
private readonly UserRepository $users,
private readonly DomainRepository $domains,
) {
parent::__construct($config, $view);
}
public function index(Request $request): Response
{
if (($response = $this->requireAdmin($this->auth, $this->session, $request)) !== null) {
return $response;
}
return $this->html('groups/index', [
'groups' => $this->groups->all(),
'pageTitle' => 'Gruppen',
]);
}
public function create(Request $request): Response
{
if (($response = $this->requireAdmin($this->auth, $this->session, $request)) !== null) {
return $response;
}
return $this->html('groups/form', [
'group' => null,
'members' => [],
'domains' => [],
'availableUsers' => $this->users->all(),
'values' => ['name' => ''],
'errors' => [],
'pageTitle' => 'Neue Gruppe',
]);
}
public function store(Request $request): Response
{
if (($response = $this->requireAdmin($this->auth, $this->session, $request)) !== null) {
return $response;
}
if (($response = $this->requireCsrf($this->csrf, $request)) !== null) {
return $response;
}
$name = $this->readName($request);
$errors = $this->validateName($name, null);
if ($errors !== []) {
return $this->html('groups/form', [
'group' => null,
'members' => [],
'domains' => [],
'availableUsers' => $this->users->all(),
'values' => ['name' => $name],
'errors' => $errors,
'pageTitle' => 'Neue Gruppe',
], 422);
}
$id = $this->groups->create($name);
$this->session->flash('success', sprintf('Gruppe "%s" wurde angelegt. Jetzt Mitglieder und Domains zuordnen.', $name));
return $this->redirect('/admin/groups/' . $id . '/edit');
}
/**
* @param array<string,string> $args
*/
public function edit(Request $request, array $args = []): Response
{
if (($response = $this->requireAdmin($this->auth, $this->session, $request)) !== null) {
return $response;
}
$group = $this->groups->find((int) ($args['id'] ?? 0));
if ($group === null) {
return $this->groupNotFound();
}
return $this->renderEdit($group, ['name' => (string) $group['name']], []);
}
/**
* @param array<string,string> $args
*/
public function update(Request $request, array $args = []): Response
{
if (($response = $this->requireAdmin($this->auth, $this->session, $request)) !== null) {
return $response;
}
if (($response = $this->requireCsrf($this->csrf, $request)) !== null) {
return $response;
}
$id = (int) ($args['id'] ?? 0);
$group = $this->groups->find($id);
if ($group === null) {
return $this->groupNotFound();
}
$name = $this->readName($request);
$errors = $this->validateName($name, $id);
if ($errors !== []) {
return $this->renderEdit($group, ['name' => $name], $errors, 422);
}
$this->groups->rename($id, $name);
$this->session->flash('success', 'Die Gruppe wurde gespeichert.');
return $this->redirect('/admin/groups/' . $id . '/edit');
}
/**
* Mitglied aufnehmen oder dessen Rolle aendern.
*
* @param array<string,string> $args
*/
public function saveMember(Request $request, array $args = []): Response
{
if (($response = $this->requireAdmin($this->auth, $this->session, $request)) !== null) {
return $response;
}
if (($response = $this->requireCsrf($this->csrf, $request)) !== null) {
return $response;
}
$id = (int) ($args['id'] ?? 0);
$group = $this->groups->find($id);
if ($group === null) {
return $this->groupNotFound();
}
$userId = (int) $request->post('user_id');
$user = $this->users->find($userId);
if ($user === null) {
$this->session->flash('error', 'Dieses Benutzerkonto gibt es nicht.');
return $this->redirect('/admin/groups/' . $id . '/edit');
}
$role = Role::groupOrDefault($request->post('role'));
$this->groups->setMember($id, $userId, $role);
$this->session->flash('success', sprintf(
'"%s" ist jetzt %s in dieser Gruppe.',
(string) $user['username'],
$role === Role::EDITOR ? 'Editor' : 'Viewer',
));
return $this->redirect('/admin/groups/' . $id . '/edit');
}
/**
* @param array<string,string> $args
*/
public function removeMember(Request $request, array $args = []): Response
{
if (($response = $this->requireAdmin($this->auth, $this->session, $request)) !== null) {
return $response;
}
if (($response = $this->requireCsrf($this->csrf, $request)) !== null) {
return $response;
}
$id = (int) ($args['id'] ?? 0);
if ($this->groups->find($id) === null) {
return $this->groupNotFound();
}
$this->groups->removeMember($id, (int) $request->post('user_id'));
$this->session->flash('success', 'Die Mitgliedschaft wurde entfernt.');
return $this->redirect('/admin/groups/' . $id . '/edit');
}
/**
* @param array<string,string> $args
*/
public function destroy(Request $request, array $args = []): Response
{
if (($response = $this->requireAdmin($this->auth, $this->session, $request)) !== null) {
return $response;
}
if (($response = $this->requireCsrf($this->csrf, $request)) !== null) {
return $response;
}
$id = (int) ($args['id'] ?? 0);
$group = $this->groups->find($id);
if ($group === null) {
return $this->groupNotFound();
}
// Eine Gruppe mit Domains zu loeschen wuerde deren Kurzlinks fuer
// alle ausser Administratoren unsichtbar machen. Deshalb zuerst
// die Domains umhaengen.
$domainCount = $this->groups->domainCount($id);
if ($domainCount > 0) {
$this->session->flash('error', sprintf(
'An der Gruppe "%s" hängen noch %d Domain(s). Bitte diese zuerst einer anderen Gruppe zuordnen.',
(string) $group['name'],
$domainCount,
));
return $this->redirect('/admin/groups');
}
if ($this->groups->count() <= 1) {
$this->session->flash('error', 'Die letzte Gruppe kann nicht gelöscht werden.');
return $this->redirect('/admin/groups');
}
$this->groups->delete($id);
$this->session->flash('success', sprintf('Gruppe "%s" wurde gelöscht.', (string) $group['name']));
return $this->redirect('/admin/groups');
}
// --- Intern ----------------------------------------------------------
/**
* @param array<string,mixed> $group
* @param array<string,string> $values
* @param array<string,string> $errors
*/
private function renderEdit(array $group, array $values, array $errors, int $status = 200): Response
{
$id = (int) $group['id'];
$members = $this->groups->members($id);
$memberIds = array_map(static fn (array $m): int => (int) $m['id'], $members);
return $this->html('groups/form', [
'group' => $group,
'members' => $members,
'domains' => $this->domains->allInGroup($id),
// Nur Konten anbieten, die noch nicht Mitglied sind.
'availableUsers' => array_values(array_filter(
$this->users->all(),
static fn (array $u): bool => !in_array((int) $u['id'], $memberIds, true),
)),
'values' => $values,
'errors' => $errors,
'pageTitle' => 'Gruppe bearbeiten',
], $status);
}
private function readName(Request $request): string
{
return trim(mb_substr($request->post('name'), 0, self::MAX_NAME_LENGTH));
}
/**
* @return array<string,string>
*/
private function validateName(string $name, ?int $exceptId): array
{
if (mb_strlen($name) < 2) {
return ['name' => 'Der Gruppenname muss mindestens 2 Zeichen lang sein.'];
}
if ($this->groups->nameExists($name, $exceptId)) {
return ['name' => 'Diesen Gruppennamen gibt es bereits.'];
}
return [];
}
private function groupNotFound(): Response
{
return $this->html('errors/error', [
'title' => 'Gruppe nicht gefunden',
'message' => 'Die gewünschte Gruppe existiert nicht (mehr).',
'pageTitle' => 'Nicht gefunden',
], 404);
}
}
+95 -28
View File
@@ -11,6 +11,7 @@ use App\Qr\LogoStore;
use App\Repository\DomainRepository; use App\Repository\DomainRepository;
use App\Repository\LinkRepository; use App\Repository\LinkRepository;
use App\Repository\QrDesignRepository; use App\Repository\QrDesignRepository;
use App\Security\AccessPolicy;
use App\Security\Auth; use App\Security\Auth;
use App\Security\Csrf; use App\Security\Csrf;
use App\Security\Session; use App\Security\Session;
@@ -30,6 +31,7 @@ final class LinkController extends Controller
private readonly QrDesignRepository $designs, private readonly QrDesignRepository $designs,
private readonly LogoStore $logos, private readonly LogoStore $logos,
private readonly DomainRepository $domains, private readonly DomainRepository $domains,
private readonly AccessPolicy $policy,
) { ) {
parent::__construct($config, $view); parent::__construct($config, $view);
} }
@@ -43,17 +45,21 @@ final class LinkController extends Controller
$search = $request->query('q'); $search = $request->query('q');
$sort = $request->query('sort', 'created_desc'); $sort = $request->query('sort', 'created_desc');
$domains = $this->allDomains(); $domains = $this->visibleDomains();
$visibleIds = $this->policy->visibleDomainIds();
$domainFilter = $request->queryInt('domain', 0); $domainFilter = $request->queryInt('domain', 0);
$domainFilter = $this->isKnownDomain($domains, $domainFilter) ? $domainFilter : null; $domainFilter = $this->isKnownDomain($domains, $domainFilter) ? $domainFilter : null;
return $this->html('links/index', [ return $this->html('links/index', [
'links' => $this->links->all($search, $sort, 200, 0, $domainFilter), 'links' => $this->links->all($search, $sort, 200, 0, $domainFilter, $visibleIds),
'total' => $this->links->count($search, $domainFilter), 'total' => $this->links->count($search, $domainFilter, $visibleIds),
'search' => $search, 'search' => $search,
'sort' => $sort, 'sort' => $sort,
'domains' => $domains, 'domains' => $domains,
'domainFilter' => $domainFilter, 'domainFilter' => $domainFilter,
'editableDomainIds' => $this->policy->editableDomainIds(),
'canCreate' => $this->policy->canCreateLinks(),
'pageTitle' => 'Kurzlinks', 'pageTitle' => 'Kurzlinks',
]); ]);
} }
@@ -64,11 +70,15 @@ final class LinkController extends Controller
return $response; return $response;
} }
$domains = $this->allDomains(); $domains = $this->editableDomains();
if ($domains === []) {
return $this->noEditableDomains();
}
$preselected = $request->queryInt('domain', 0); $preselected = $request->queryInt('domain', 0);
if (!$this->isKnownDomain($domains, $preselected)) { if (!$this->isKnownDomain($domains, $preselected)) {
$preselected = (int) $this->domains->defaultDomain($this->config->baseUrl())['id']; $preselected = (int) $domains[0]['id'];
} }
return $this->html('links/form', [ return $this->html('links/form', [
@@ -96,13 +106,17 @@ final class LinkController extends Controller
return $response; return $response;
} }
$domains = $this->allDomains(); $domains = $this->editableDomains();
if ($domains === []) {
return $this->noEditableDomains();
}
$values = $this->readValues($request); $values = $this->readValues($request);
$errors = []; $errors = [];
// Ohne Angabe gilt die Standarddomain; nur ein ausgefuellter, // Ohne Angabe gilt die erste beschreibbare Domain; nur ein
// aber unbekannter Wert ist ein Fehler. // ausgefuellter, aber unzulaessiger Wert ist ein Fehler.
$domainId = (int) $this->domains->defaultDomain($this->config->baseUrl())['id']; $domainId = (int) $domains[0]['id'];
if ($values['domain_id'] !== '') { if ($values['domain_id'] !== '') {
if ($this->isKnownDomain($domains, (int) $values['domain_id'])) { if ($this->isKnownDomain($domains, (int) $values['domain_id'])) {
$domainId = (int) $values['domain_id']; $domainId = (int) $values['domain_id'];
@@ -172,14 +186,14 @@ final class LinkController extends Controller
return $response; return $response;
} }
$link = $this->links->find((int) ($args['id'] ?? 0)); $link = $this->findEditable((int) ($args['id'] ?? 0));
if ($link === null) { if ($link instanceof Response) {
return $this->linkNotFound(); return $link;
} }
return $this->html('links/form', [ return $this->html('links/form', [
'link' => $link, 'link' => $link,
'domains' => $this->allDomains(), 'domains' => $this->editableDomains(),
'values' => [ 'values' => [
'target_url' => (string) $link['target_url'], 'target_url' => (string) $link['target_url'],
'slug' => (string) $link['slug'], 'slug' => (string) $link['slug'],
@@ -206,16 +220,19 @@ final class LinkController extends Controller
} }
$id = (int) ($args['id'] ?? 0); $id = (int) ($args['id'] ?? 0);
$link = $this->links->find($id); $link = $this->findEditable($id);
if ($link === null) { if ($link instanceof Response) {
return $this->linkNotFound(); return $link;
} }
$domains = $this->allDomains(); $domains = $this->editableDomains();
$values = $this->readValues($request); $values = $this->readValues($request);
$errors = []; $errors = [];
// Ohne Angabe bleibt der Link auf seiner bisherigen Domain. // Ohne Angabe bleibt der Link auf seiner bisherigen Domain. Ein
// Umzug ist nur in eine Domain moeglich, auf der der Benutzer
// ebenfalls schreiben darf - sonst wuerde er den Link aus seinem
// eigenen Zugriff schieben.
$domainId = (int) $link['domain_id']; $domainId = (int) $link['domain_id'];
if ($values['domain_id'] !== '') { if ($values['domain_id'] !== '') {
if ($this->isKnownDomain($domains, (int) $values['domain_id'])) { if ($this->isKnownDomain($domains, (int) $values['domain_id'])) {
@@ -277,9 +294,9 @@ final class LinkController extends Controller
return $response; return $response;
} }
$link = $this->links->find((int) ($args['id'] ?? 0)); $link = $this->findEditable((int) ($args['id'] ?? 0));
if ($link === null) { if ($link instanceof Response) {
return $this->linkNotFound(); return $link;
} }
$active = (int) $link['is_active'] !== 1; $active = (int) $link['is_active'] !== 1;
@@ -307,9 +324,9 @@ final class LinkController extends Controller
return $response; return $response;
} }
$link = $this->links->find((int) ($args['id'] ?? 0)); $link = $this->findEditable((int) ($args['id'] ?? 0));
if ($link === null) { if ($link instanceof Response) {
return $this->linkNotFound(); return $link;
} }
$design = $this->designs->forLink((int) $link['id']); $design = $this->designs->forLink((int) $link['id']);
@@ -324,14 +341,56 @@ final class LinkController extends Controller
} }
/** /**
* Laedt einen Kurzlink zum Aendern.
*
* Fremde Links antworten mit 404 (ihre Existenz geht den Aufrufer
* nichts an), sichtbare ohne Schreibrecht mit 403 - da ist der Link
* ohnehin schon aus der Liste bekannt.
*
* @return array<string,mixed>|Response
*/
private function findEditable(int $id): array|Response
{
$link = $this->links->find($id, $this->policy->visibleDomainIds());
if ($link === null) {
return $this->linkNotFound();
}
if (!$this->policy->canEditLink($link)) {
return $this->forbidden(
'In dieser Gruppe ist nur das Ansehen erlaubt. Zum Ändern von Kurzlinks wird die Rolle "Editor" benötigt.',
);
}
return $link;
}
/**
* Sichtbare Domains. Bei Administratoren wird nebenbei
* sichergestellt, dass ueberhaupt eine Standarddomain existiert.
*
* @return list<array<string,mixed>> * @return list<array<string,mixed>>
*/ */
private function allDomains(): array private function visibleDomains(): array
{ {
// Sorgt nebenbei dafuer, dass die Standarddomain existiert. if ($this->policy->isAdmin()) {
$this->domains->defaultDomain($this->config->baseUrl()); $this->domains->defaultDomain($this->config->baseUrl());
}
return $this->domains->all(); return $this->policy->visibleDomains();
}
/**
* @return list<array<string,mixed>>
*/
private function editableDomains(): array
{
if ($this->policy->isAdmin()) {
$this->domains->defaultDomain($this->config->baseUrl());
}
return $this->policy->editableDomains();
} }
/** /**
@@ -380,6 +439,14 @@ final class LinkController extends Controller
]; ];
} }
private function noEditableDomains(): Response
{
return $this->forbidden(
'Es gibt keine Domain, auf der Sie Kurzlinks anlegen dürfen. '
. 'Dafür wird die Mitgliedschaft in einer Gruppe mit der Rolle "Editor" benötigt.',
);
}
private function linkNotFound(): Response private function linkNotFound(): Response
{ {
return $this->html('errors/error', [ return $this->html('errors/error', [
+128
View File
@@ -0,0 +1,128 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Config;
use App\Http\Request;
use App\Http\Response;
use App\Repository\GroupRepository;
use App\Repository\UserRepository;
use App\Security\Auth;
use App\Security\Csrf;
use App\Security\Role;
use App\Security\Session;
use App\Support\Password;
use App\View;
/**
* Eigenes Konto: Gruppenzugehoerigkeit ansehen, Passwort aendern.
*
* Fuer jeden angemeldeten Benutzer erreichbar. Der Notfallzugang aus der
* .env hat keinen Datensatz in der Datenbank - fuer ihn bleibt die Seite
* lesbar, der Passwortwechsel ist aber gesperrt und wird als solcher
* erklaert.
*/
final class ProfileController extends Controller
{
public function __construct(
Config $config,
View $view,
private readonly Session $session,
private readonly Csrf $csrf,
private readonly Auth $auth,
private readonly UserRepository $users,
private readonly GroupRepository $groups,
) {
parent::__construct($config, $view);
}
public function show(Request $request): Response
{
if (($response = $this->requireAuth($this->auth, $this->session, $request)) !== null) {
return $response;
}
return $this->render([]);
}
public function updatePassword(Request $request): Response
{
if (($response = $this->requireAuth($this->auth, $this->session, $request)) !== null) {
return $response;
}
if (($response = $this->requireCsrf($this->csrf, $request)) !== null) {
return $response;
}
if ($this->auth->isEmergency()) {
return $this->forbidden(
'Das Passwort des Notfallzugangs steht in der .env (ADMIN_PASSWORD_HASH) und lässt sich nur dort ändern.',
);
}
$userId = $this->auth->id();
$user = $userId === null ? null : $this->users->find($userId);
if ($user === null) {
return $this->redirect('/login');
}
$current = (string) ($request->post['current_password'] ?? '');
$password = (string) ($request->post['password'] ?? '');
$confirm = (string) ($request->post['password_confirm'] ?? '');
$errors = [];
// Das aktuelle Passwort verlangen: sonst koennte eine offen
// gelassene Sitzung uebernommen werden.
if (!password_verify($current, (string) $user['password_hash'])) {
$errors['current_password'] = 'Das aktuelle Passwort stimmt nicht.';
}
$errors += Password::validationErrors($password, $confirm);
if ($errors === [] && $current === $password) {
$errors['password'] = 'Das neue Passwort muss sich vom bisherigen unterscheiden.';
}
if ($errors !== []) {
return $this->render($errors, 422);
}
$this->users->updatePassword((int) $user['id'], $password);
// Der Sitzungsfingerabdruck haengt am Passwort-Hash: alle
// Sitzungen sind damit ungueltig, auch die eigene. Deshalb hier
// gleich neu anmelden statt den Benutzer rauszuwerfen.
$refreshed = $this->users->find((int) $user['id']);
if ($refreshed !== null) {
$this->auth->login($refreshed);
}
$this->session->flash('success', 'Das Passwort wurde geändert. Andere Sitzungen wurden abgemeldet.');
return $this->redirect('/admin/profile');
}
/**
* @param array<string,string> $errors
*/
private function render(array $errors, int $status = 200): Response
{
$user = $this->auth->user();
$id = (int) ($user['id'] ?? 0);
return $this->html('profile/show', [
'user' => $user,
'roleLabel' => Role::globalLabel($user['role'] ?? Role::USER),
'isEmergency' => $this->auth->isEmergency(),
'groups' => $this->auth->isEmergency() ? [] : $this->groups->groupsOfUser($id),
'errors' => $errors,
'minLength' => Password::MIN_LENGTH,
'pageTitle' => 'Mein Konto',
], $status);
}
}
+52 -13
View File
@@ -12,6 +12,7 @@ use App\Qr\QrOptions;
use App\Qr\QrRenderer; use App\Qr\QrRenderer;
use App\Repository\LinkRepository; use App\Repository\LinkRepository;
use App\Repository\QrDesignRepository; use App\Repository\QrDesignRepository;
use App\Security\AccessPolicy;
use App\Security\Auth; use App\Security\Auth;
use App\Security\Csrf; use App\Security\Csrf;
use App\Security\Session; use App\Security\Session;
@@ -30,6 +31,7 @@ final class QrController extends Controller
private readonly QrDesignRepository $designs, private readonly QrDesignRepository $designs,
private readonly LogoStore $logos, private readonly LogoStore $logos,
private readonly QrRenderer $renderer, private readonly QrRenderer $renderer,
private readonly AccessPolicy $policy,
) { ) {
parent::__construct($config, $view); parent::__construct($config, $view);
} }
@@ -43,7 +45,7 @@ final class QrController extends Controller
return $response; return $response;
} }
$link = $this->links->find((int) ($args['id'] ?? 0)); $link = $this->links->find((int) ($args['id'] ?? 0), $this->policy->visibleDomainIds());
if ($link === null) { if ($link === null) {
return $this->linkNotFound(); return $this->linkNotFound();
} }
@@ -59,6 +61,9 @@ final class QrController extends Controller
'warning' => $options->contrastWarning(), 'warning' => $options->contrastWarning(),
'logoIsSvg' => $this->logos->isSvg($options->logoPath), 'logoIsSvg' => $this->logos->isSvg($options->logoPath),
'imagickAvailable' => class_exists(\Imagick::class), 'imagickAvailable' => class_exists(\Imagick::class),
// Ein Viewer darf den Code ansehen und herunterladen, die
// gespeicherte Gestaltung aber nicht veraendern.
'canEdit' => $this->policy->canEditLink($link),
'pageTitle' => 'QR-Code: ' . $link['slug'], 'pageTitle' => 'QR-Code: ' . $link['slug'],
]); ]);
} }
@@ -78,9 +83,9 @@ final class QrController extends Controller
return $response; return $response;
} }
$link = $this->links->find((int) ($args['id'] ?? 0)); $link = $this->findEditable((int) ($args['id'] ?? 0));
if ($link === null) { if ($link instanceof Response) {
return $this->linkNotFound(); return $link;
} }
$current = $this->designs->forLink((int) $link['id']); $current = $this->designs->forLink((int) $link['id']);
@@ -105,9 +110,9 @@ final class QrController extends Controller
return $response; return $response;
} }
$link = $this->links->find((int) ($args['id'] ?? 0)); $link = $this->findEditable((int) ($args['id'] ?? 0));
if ($link === null) { if ($link instanceof Response) {
return $this->linkNotFound(); return $link;
} }
$linkId = (int) $link['id']; $linkId = (int) $link['id'];
@@ -151,9 +156,9 @@ final class QrController extends Controller
return $response; return $response;
} }
$link = $this->links->find((int) ($args['id'] ?? 0)); $link = $this->findEditable((int) ($args['id'] ?? 0));
if ($link === null) { if ($link instanceof Response) {
return $this->linkNotFound(); return $link;
} }
$linkId = (int) $link['id']; $linkId = (int) $link['id'];
@@ -181,7 +186,7 @@ final class QrController extends Controller
return Response::html('Unauthorized', 401); return Response::html('Unauthorized', 401);
} }
$link = $this->links->find((int) ($args['id'] ?? 0)); $link = $this->links->find((int) ($args['id'] ?? 0), $this->policy->visibleDomainIds());
if ($link === null) { if ($link === null) {
return Response::html('Not Found', 404); return Response::html('Not Found', 404);
} }
@@ -217,7 +222,7 @@ final class QrController extends Controller
return $response; return $response;
} }
$link = $this->links->find((int) ($args['id'] ?? 0)); $link = $this->links->find((int) ($args['id'] ?? 0), $this->policy->visibleDomainIds());
if ($link === null) { if ($link === null) {
return $this->linkNotFound(); return $this->linkNotFound();
} }
@@ -251,6 +256,10 @@ final class QrController extends Controller
* Liefert hochgeladene Logos aus. Die Dateien liegen ausserhalb des * Liefert hochgeladene Logos aus. Die Dateien liegen ausserhalb des
* Webroots und sind nur nach Login erreichbar. * Webroots und sind nur nach Login erreichbar.
* *
* Alle Logos teilen sich ein Verzeichnis - der Dateiname allein waere
* also kein Zugriffsschutz. Ausgeliefert wird deshalb nur, was zu
* einem Kurzlink aus einer Gruppe des Aufrufers gehoert.
*
* @param array<string,string> $args * @param array<string,string> $args
*/ */
public function serveUpload(Request $request, array $args = []): Response public function serveUpload(Request $request, array $args = []): Response
@@ -259,7 +268,13 @@ final class QrController extends Controller
return Response::html('Unauthorized', 401); return Response::html('Unauthorized', 401);
} }
$path = $this->logos->pathFor($args['file'] ?? ''); $file = $args['file'] ?? '';
if (!$this->designs->logoIsVisible($file, $this->policy->visibleDomainIds())) {
return Response::html('Not Found', 404);
}
$path = $this->logos->pathFor($file);
if ($path === null) { if ($path === null) {
return Response::html('Not Found', 404); return Response::html('Not Found', 404);
} }
@@ -285,6 +300,30 @@ final class QrController extends Controller
]); ]);
} }
/**
* Laedt einen Kurzlink, dessen QR-Gestaltung veraendert werden soll.
*
* Fremde Links antworten mit 404, sichtbare ohne Schreibrecht mit 403.
*
* @return array<string,mixed>|Response
*/
private function findEditable(int $id): array|Response
{
$link = $this->links->find($id, $this->policy->visibleDomainIds());
if ($link === null) {
return $this->linkNotFound();
}
if (!$this->policy->canEditLink($link)) {
return $this->forbidden(
'In dieser Gruppe ist nur das Ansehen erlaubt. Zum Ändern der QR-Einstellungen wird die Rolle "Editor" benötigt.',
);
}
return $link;
}
/** /**
* Kurz-URL aus der Domain des Links - nicht aus APP_BASE_URL, * Kurz-URL aus der Domain des Links - nicht aus APP_BASE_URL,
* damit der QR-Code auf die richtige Domain zeigt. * damit der QR-Code auf die richtige Domain zeigt.
+24 -15
View File
@@ -7,12 +7,17 @@ namespace App\Controller;
use App\Config; use App\Config;
use App\Http\Request; use App\Http\Request;
use App\Http\Response; use App\Http\Response;
use App\Repository\GroupRepository;
use App\Repository\UserRepository;
use App\Security\Auth; use App\Security\Auth;
use App\Security\Csrf; use App\Security\Csrf;
use App\Security\RateLimiter; use App\Security\RateLimiter;
use App\Security\Role;
use App\Security\Session; use App\Security\Session;
use App\Security\SetupToken; use App\Security\SetupToken;
use App\Service\SystemCheck; use App\Service\SystemCheck;
use App\Support\Password;
use App\Support\Username;
use App\View; use App\View;
/** /**
@@ -28,8 +33,6 @@ use App\View;
*/ */
final class SetupController extends Controller final class SetupController extends Controller
{ {
private const MIN_PASSWORD_LENGTH = 10;
public function __construct( public function __construct(
Config $config, Config $config,
View $view, View $view,
@@ -39,6 +42,8 @@ final class SetupController extends Controller
private readonly SetupToken $setupToken, private readonly SetupToken $setupToken,
private readonly RateLimiter $rateLimiter, private readonly RateLimiter $rateLimiter,
private readonly SystemCheck $systemCheck, private readonly SystemCheck $systemCheck,
private readonly UserRepository $users,
private readonly GroupRepository $groups,
) { ) {
parent::__construct($config, $view); parent::__construct($config, $view);
} }
@@ -80,7 +85,7 @@ final class SetupController extends Controller
], 429)->withHeader('Retry-After', (string) $seconds); ], 429)->withHeader('Retry-After', (string) $seconds);
} }
$username = $request->post('username'); $username = Username::normalize($request->post('username'));
$password = (string) ($request->post['password'] ?? ''); $password = (string) ($request->post['password'] ?? '');
$confirm = (string) ($request->post['password_confirm'] ?? ''); $confirm = (string) ($request->post['password_confirm'] ?? '');
@@ -92,27 +97,31 @@ final class SetupController extends Controller
$errors['token'] = 'Das Einrichtungs-Token stimmt nicht.'; $errors['token'] = 'Das Einrichtungs-Token stimmt nicht.';
} }
if (!preg_match('/^[A-Za-z0-9._@-]{3,64}$/', $username)) { $usernameError = Username::validationError($username);
$errors['username'] = 'Der Benutzername muss 3-64 Zeichen lang sein (Buchstaben, Ziffern, . _ - @).'; if ($usernameError !== null) {
$errors['username'] = $usernameError;
} }
if (mb_strlen($password) < self::MIN_PASSWORD_LENGTH) { $errors += Password::validationErrors($password, $confirm);
$errors['password'] = sprintf('Das Passwort muss mindestens %d Zeichen lang sein.', self::MIN_PASSWORD_LENGTH);
}
if ($password !== $confirm) {
$errors['password_confirm'] = 'Die beiden Passwoerter stimmen nicht ueberein.';
}
if ($errors !== []) { if ($errors !== []) {
return $this->render($username, $errors, 422); return $this->render($username, $errors, 422);
} }
$this->auth->configure($username, $password); // Das erste Konto ist zwangslaeufig ein Administrator und wird
// gleich Editor der Standardgruppe - sonst koennte es zwar
// verwalten, aber selbst keine Kurzlinks anlegen.
$userId = $this->users->create($username, $password, Role::ADMIN);
$this->groups->setMember($this->groups->defaultGroupId(), $userId, Role::EDITOR);
$this->setupToken->discard(); $this->setupToken->discard();
$this->rateLimiter->recordSuccess($ip); $this->rateLimiter->recordSuccess($ip);
$this->auth->login($username); $user = $this->users->find($userId);
if ($user !== null) {
$this->auth->login($user);
}
$this->session->flash('success', 'Einrichtung abgeschlossen. Willkommen!'); $this->session->flash('success', 'Einrichtung abgeschlossen. Willkommen!');
return $this->redirect('/admin'); return $this->redirect('/admin');
@@ -126,7 +135,7 @@ final class SetupController extends Controller
return $this->html('setup/index', [ return $this->html('setup/index', [
'username' => $username, 'username' => $username,
'errors' => $errors, 'errors' => $errors,
'minLength' => self::MIN_PASSWORD_LENGTH, 'minLength' => Password::MIN_LENGTH,
'tokenPath' => $this->setupToken->path(), 'tokenPath' => $this->setupToken->path(),
'systemChecks' => $this->systemCheck->all(), 'systemChecks' => $this->systemCheck->all(),
'systemStatus' => $this->systemCheck->worstStatus(), 'systemStatus' => $this->systemCheck->worstStatus(),
+5 -2
View File
@@ -9,6 +9,7 @@ use App\Http\Request;
use App\Http\Response; use App\Http\Response;
use App\Repository\LinkRepository; use App\Repository\LinkRepository;
use App\Repository\VisitRepository; use App\Repository\VisitRepository;
use App\Security\AccessPolicy;
use App\Security\Auth; use App\Security\Auth;
use App\Security\Session; use App\Security\Session;
use App\Support\UserAgentParser; use App\Support\UserAgentParser;
@@ -22,6 +23,7 @@ final class StatsController extends Controller
private readonly Auth $auth, private readonly Auth $auth,
private readonly LinkRepository $links, private readonly LinkRepository $links,
private readonly VisitRepository $visits, private readonly VisitRepository $visits,
private readonly AccessPolicy $policy,
) { ) {
parent::__construct($config, $view); parent::__construct($config, $view);
} }
@@ -35,7 +37,7 @@ final class StatsController extends Controller
return $this->redirect('/login'); return $this->redirect('/login');
} }
$link = $this->links->find((int) ($args['id'] ?? 0)); $link = $this->links->find((int) ($args['id'] ?? 0), $this->policy->visibleDomainIds());
if ($link === null) { if ($link === null) {
return $this->html('errors/error', [ return $this->html('errors/error', [
'title' => 'Kurzlink nicht gefunden', 'title' => 'Kurzlink nicht gefunden',
@@ -66,6 +68,7 @@ final class StatsController extends Controller
'browsers' => $this->visits->breakdown($linkId, 'browser'), 'browsers' => $this->visits->breakdown($linkId, 'browser'),
'platforms' => $this->visits->breakdown($linkId, 'platform'), 'platforms' => $this->visits->breakdown($linkId, 'platform'),
'recent' => $this->visits->recent($linkId), 'recent' => $this->visits->recent($linkId),
'canEdit' => $this->policy->canEditLink($link),
'pageTitle' => 'Statistik: ' . $link['slug'], 'pageTitle' => 'Statistik: ' . $link['slug'],
]); ]);
} }
@@ -81,7 +84,7 @@ final class StatsController extends Controller
return Response::json(['error' => 'unauthorized'], 401); return Response::json(['error' => 'unauthorized'], 401);
} }
$link = $this->links->find((int) ($args['id'] ?? 0)); $link = $this->links->find((int) ($args['id'] ?? 0), $this->policy->visibleDomainIds());
if ($link === null) { if ($link === null) {
return Response::json(['error' => 'not_found'], 404); return Response::json(['error' => 'not_found'], 404);
} }
+370
View File
@@ -0,0 +1,370 @@
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Config;
use App\Http\Request;
use App\Http\Response;
use App\Repository\GroupRepository;
use App\Repository\UserRepository;
use App\Security\Auth;
use App\Security\Csrf;
use App\Security\Role;
use App\Security\Session;
use App\Support\Password;
use App\Support\Username;
use App\View;
/**
* Benutzerverwaltung - ausschliesslich fuer Administratoren.
*
* Durchgehend abgesichert ist die Regel, dass immer mindestens ein
* aktives Administratorkonto uebrig bleiben muss: sperren, herabstufen
* oder loeschen scheitert, wenn es das letzte waere. Der Notfallzugang
* aus der .env zaehlt dabei bewusst nicht mit - er ist nicht in jeder
* Installation eingerichtet.
*/
final class UserController extends Controller
{
public function __construct(
Config $config,
View $view,
private readonly Session $session,
private readonly Csrf $csrf,
private readonly Auth $auth,
private readonly UserRepository $users,
private readonly GroupRepository $groups,
) {
parent::__construct($config, $view);
}
public function index(Request $request): Response
{
if (($response = $this->requireAdmin($this->auth, $this->session, $request)) !== null) {
return $response;
}
$users = $this->users->all();
// Gruppen je Benutzer fuer die Uebersicht nachladen.
foreach ($users as $index => $user) {
$users[$index]['groups'] = $this->groups->groupsOfUser((int) $user['id']);
}
return $this->html('users/index', [
'users' => $users,
'currentId' => $this->auth->id(),
'emergencyUser' => $this->auth->emergencyAccount()['username'] ?? null,
'pageTitle' => 'Benutzer',
]);
}
public function create(Request $request): Response
{
if (($response = $this->requireAdmin($this->auth, $this->session, $request)) !== null) {
return $response;
}
return $this->html('users/form', [
'user' => null,
'groups' => $this->groups->all(),
'memberships' => [],
'values' => ['username' => '', 'role' => Role::USER, 'is_active' => '1'],
'errors' => [],
'minLength' => Password::MIN_LENGTH,
'pageTitle' => 'Neuer Benutzer',
]);
}
public function store(Request $request): Response
{
if (($response = $this->requireAdmin($this->auth, $this->session, $request)) !== null) {
return $response;
}
if (($response = $this->requireCsrf($this->csrf, $request)) !== null) {
return $response;
}
$values = $this->readValues($request);
$password = (string) ($request->post['password'] ?? '');
$confirm = (string) ($request->post['password_confirm'] ?? '');
$errors = $this->validateUsername($values['username'], null);
$errors += Password::validationErrors($password, $confirm);
$memberships = $this->readMemberships($request);
if ($errors !== []) {
return $this->html('users/form', [
'user' => null,
'groups' => $this->groups->all(),
'memberships' => $memberships,
'values' => $values,
'errors' => $errors,
'minLength' => Password::MIN_LENGTH,
'pageTitle' => 'Neuer Benutzer',
], 422);
}
$id = $this->users->create($values['username'], $password, $values['role']);
$this->applyMemberships($id, $memberships);
if ($values['is_active'] !== '1') {
$this->users->update($id, $values['username'], $values['role'], false);
}
$this->session->flash('success', sprintf('Benutzer "%s" wurde angelegt.', $values['username']));
return $this->redirect('/admin/users');
}
/**
* @param array<string,string> $args
*/
public function edit(Request $request, array $args = []): Response
{
if (($response = $this->requireAdmin($this->auth, $this->session, $request)) !== null) {
return $response;
}
$user = $this->users->find((int) ($args['id'] ?? 0));
if ($user === null) {
return $this->userNotFound();
}
return $this->html('users/form', [
'user' => $user,
'groups' => $this->groups->all(),
'memberships' => $this->currentMemberships((int) $user['id']),
'values' => [
'username' => (string) $user['username'],
'role' => Role::globalOrDefault($user['role']),
'is_active' => ((int) $user['is_active'] === 1) ? '1' : '0',
],
'errors' => [],
'minLength' => Password::MIN_LENGTH,
'pageTitle' => 'Benutzer bearbeiten',
]);
}
/**
* @param array<string,string> $args
*/
public function update(Request $request, array $args = []): Response
{
if (($response = $this->requireAdmin($this->auth, $this->session, $request)) !== null) {
return $response;
}
if (($response = $this->requireCsrf($this->csrf, $request)) !== null) {
return $response;
}
$id = (int) ($args['id'] ?? 0);
$user = $this->users->find($id);
if ($user === null) {
return $this->userNotFound();
}
$values = $this->readValues($request);
$password = (string) ($request->post['password'] ?? '');
$confirm = (string) ($request->post['password_confirm'] ?? '');
$memberships = $this->readMemberships($request);
$errors = $this->validateUsername($values['username'], $id);
// Ein leeres Feld heisst "Passwort unveraendert lassen".
if ($password !== '' || $confirm !== '') {
$errors += Password::validationErrors($password, $confirm);
}
// Nur pruefen, wenn dieses Konto ueberhaupt gerade ein aktiver
// Administrator ist - sonst wuerde das Bearbeiten eines
// gewoehnlichen Benutzers an derselben Regel scheitern.
$wasActiveAdmin = Role::globalOrDefault($user['role']) === Role::ADMIN && (int) $user['is_active'] === 1;
$staysAdmin = $values['role'] === Role::ADMIN && $values['is_active'] === '1';
if ($wasActiveAdmin && !$staysAdmin && $this->users->activeAdminCount($id) === 0) {
$errors['role'] = 'Das ist das letzte aktive Administratorkonto. Bitte zuerst ein weiteres anlegen.';
}
if ($errors !== []) {
return $this->html('users/form', [
'user' => $user,
'groups' => $this->groups->all(),
'memberships' => $memberships,
'values' => $values,
'errors' => $errors,
'minLength' => Password::MIN_LENGTH,
'pageTitle' => 'Benutzer bearbeiten',
], 422);
}
$this->users->update($id, $values['username'], $values['role'], $values['is_active'] === '1');
if ($password !== '') {
// Der Fingerabdruck der Sitzung haengt am Passwort-Hash:
// damit fliegt der Betroffene ueberall raus, wo er noch
// angemeldet ist.
$this->users->updatePassword($id, $password);
}
$this->applyMemberships($id, $memberships);
$this->session->flash('success', sprintf('Benutzer "%s" wurde gespeichert.', $values['username']));
return $this->redirect('/admin/users');
}
/**
* @param array<string,string> $args
*/
public function destroy(Request $request, array $args = []): Response
{
if (($response = $this->requireAdmin($this->auth, $this->session, $request)) !== null) {
return $response;
}
if (($response = $this->requireCsrf($this->csrf, $request)) !== null) {
return $response;
}
$id = (int) ($args['id'] ?? 0);
$user = $this->users->find($id);
if ($user === null) {
return $this->userNotFound();
}
if ($id === $this->auth->id()) {
$this->session->flash('error', 'Das eigene Konto kann nicht gelöscht werden.');
return $this->redirect('/admin/users');
}
$isActiveAdmin = Role::globalOrDefault($user['role']) === Role::ADMIN && (int) $user['is_active'] === 1;
if ($isActiveAdmin && $this->users->activeAdminCount($id) === 0) {
$this->session->flash('error', 'Das ist das letzte aktive Administratorkonto und kann nicht gelöscht werden.');
return $this->redirect('/admin/users');
}
// Kurzlinks bleiben bestehen: sie gehoeren der Gruppe, nicht der
// Person. Sonst wuerde das Loeschen eines Kontos gedruckte
// QR-Codes unbrauchbar machen.
$this->users->delete($id);
$this->session->flash('success', sprintf(
'Benutzer "%s" wurde gelöscht. Die Kurzlinks der Gruppen bleiben erhalten.',
(string) $user['username'],
));
return $this->redirect('/admin/users');
}
// --- Intern ----------------------------------------------------------
/**
* @return array<string,string>
*/
private function validateUsername(string $username, ?int $exceptId): array
{
$error = Username::validationError($username);
if ($error !== null) {
return ['username' => $error];
}
if ($this->users->usernameExists($username, $exceptId)) {
return ['username' => 'Diesen Benutzernamen gibt es bereits.'];
}
$emergency = $this->auth->emergencyAccount();
if ($emergency !== null && mb_strtolower((string) $emergency['username'], 'UTF-8') === mb_strtolower($username, 'UTF-8')) {
return ['username' => 'Dieser Name gehört dem Notfallzugang aus der .env und kann nicht vergeben werden.'];
}
return [];
}
/**
* Gruppenzuordnung aus dem Formular: je Gruppe entweder "-" (nicht
* Mitglied), "editor" oder "viewer".
*
* @return array<int,string> Gruppenkennung => Rolle
*/
private function readMemberships(Request $request): array
{
$raw = $request->post['groups'] ?? [];
if (!is_array($raw)) {
return [];
}
$result = [];
foreach ($raw as $groupId => $role) {
if (!is_scalar($role) || ($role !== Role::EDITOR && $role !== Role::VIEWER)) {
continue;
}
$result[(int) $groupId] = (string) $role;
}
return $result;
}
/**
* @return array<int,string>
*/
private function currentMemberships(int $userId): array
{
$result = [];
foreach ($this->groups->groupsOfUser($userId) as $group) {
$result[(int) $group['id']] = Role::groupOrDefault($group['group_role']);
}
return $result;
}
/**
* Setzt die Mitgliedschaften genau so, wie das Formular sie meldet -
* alles, was dort fehlt, wird entfernt.
*
* @param array<int,string> $memberships
*/
private function applyMemberships(int $userId, array $memberships): void
{
foreach ($this->groups->all() as $group) {
$groupId = (int) $group['id'];
if (isset($memberships[$groupId])) {
$this->groups->setMember($groupId, $userId, $memberships[$groupId]);
continue;
}
$this->groups->removeMember($groupId, $userId);
}
}
/**
* @return array{username:string,role:string,is_active:string}
*/
private function readValues(Request $request): array
{
return [
'username' => Username::normalize($request->post('username')),
'role' => Role::globalOrDefault($request->post('role')),
'is_active' => $request->hasPost('is_active') ? '1' : '0',
];
}
private function userNotFound(): Response
{
return $this->html('errors/error', [
'title' => 'Benutzer nicht gefunden',
'message' => 'Das gewünschte Benutzerkonto existiert nicht (mehr).',
'pageTitle' => 'Nicht gefunden',
], 404);
}
}
+41 -4
View File
@@ -6,6 +6,11 @@
* @var list<array<string,mixed>> $topLinks * @var list<array<string,mixed>> $topLinks
* @var list<array{date:string,clicks:int}> $series * @var list<array{date:string,clicks:int}> $series
* @var int $domainCount * @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 * @var App\Config $config
*/ */
@@ -49,9 +54,20 @@ $fallbackBase = rtrim($config->baseUrl(), '/');
<div class="page-head"> <div class="page-head">
<div> <div>
<h1>Übersicht</h1> <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> </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>
<div class="stats-grid"> <div class="stats-grid">
@@ -74,9 +90,27 @@ $fallbackBase = rtrim($config->baseUrl(), '/');
<div class="stat"> <div class="stat">
<span class="stat__label">Domains</span> <span class="stat__label">Domains</span>
<span class="stat__value"> <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> </span>
</div> </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> </div>
<section class="card"> <section class="card">
@@ -92,7 +126,10 @@ $fallbackBase = rtrim($config->baseUrl(), '/');
<h2>Zuletzt angelegt</h2> <h2>Zuletzt angelegt</h2>
<?php if ($recentLinks === []): ?> <?php if ($recentLinks === []): ?>
<p class="muted">Noch keine Kurzlinks vorhanden. <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: ?> <?php else: ?>
<ul class="list"> <ul class="list">
<?php foreach ($recentLinks as $link): ?> <?php foreach ($recentLinks as $link): ?>
+21 -1
View File
@@ -2,7 +2,8 @@
/** /**
* @var array<string,mixed>|null $domain * @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 array<string,string> $errors
* @var App\Security\Csrf $csrf * @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> <p class="field__hint">Nur zur eigenen Orientierung in der Auswahlliste.</p>
</div> </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"> <div class="field field--check">
<label class="check"> <label class="check">
<input type="checkbox" name="is_default" value="1" <input type="checkbox" name="is_default" value="1"
+8
View File
@@ -68,6 +68,7 @@ $statusSymbol = static fn (string $status): string => match ($status) {
<tr> <tr>
<th scope="col">Domain</th> <th scope="col">Domain</th>
<th scope="col">Bezeichnung</th> <th scope="col">Bezeichnung</th>
<th scope="col">Gruppe</th>
<th scope="col" class="num">Kurzlinks</th> <th scope="col" class="num">Kurzlinks</th>
<th scope="col"><span class="sr-only">Aktionen</span></th> <th scope="col"><span class="sr-only">Aktionen</span></th>
</tr> </tr>
@@ -92,6 +93,13 @@ $statusSymbol = static fn (string $status): string => match ($status) {
</div> </div>
</td> </td>
<td class="muted small"><?= $e($domain['label'] ?? '–') ?></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"> <td class="num">
<a href="<?= $e($url('/admin/links?domain=' . (int) $domain['id'])) ?>"> <a href="<?= $e($url('/admin/links?domain=' . (int) $domain['id'])) ?>">
<?= (int) $domain['link_count'] ?> <?= (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\Config $config
* @var App\Security\Csrf $csrf * @var App\Security\Csrf $csrf
* @var App\Security\Auth $auth * @var App\Security\Auth $auth
* @var App\Security\AccessPolicy $policy
* @var string $content * @var string $content
* @var list<array{type:string,message:string}> $flashes * @var list<array{type:string,message:string}> $flashes
* @var string $currentPath * @var string $currentPath
@@ -14,6 +15,12 @@
$title = $pageTitle ?? 'Verwaltung'; $title = $pageTitle ?? 'Verwaltung';
$isActive = static fn (string $prefix): bool => str_starts_with($currentPath ?? '/', $prefix); $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> <!doctype html>
<html lang="de"> <html lang="de">
@@ -36,13 +43,19 @@ $isActive = static fn (string $prefix): bool => str_starts_with($currentPath ??
<nav class="mainnav" aria-label="Hauptnavigation"> <nav class="mainnav" aria-label="Hauptnavigation">
<a href="<?= $e($url('/admin')) ?>" class="<?= ($currentPath === '/admin' || $currentPath === '/') ? 'is-active' : '' ?>">Übersicht</a> <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/links')) ?>" class="<?= $isActive('/admin/links') ? 'is-active' : '' ?>">Kurzlinks</a>
<a href="<?= $e($url('/admin/domains')) ?>" class="<?= $isActive('/admin/domains') ? 'is-active' : '' ?>">Domains</a> <?php if ($isAdmin): ?>
<a href="<?= $e($url('/admin/links/new')) ?>" class="btn btn--primary btn--sm">Neuer Kurzlink</a> <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> </nav>
<form class="logout" method="post" action="<?= $e($url('/logout')) ?>"> <form class="logout" method="post" action="<?= $e($url('/logout')) ?>">
<?= $csrf->field() ?> <?= $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> <button type="submit" class="btn btn--ghost btn--sm">Abmelden</button>
</form> </form>
</div> </div>
+35 -15
View File
@@ -7,6 +7,8 @@
* @var string $sort * @var string $sort
* @var list<array<string,mixed>> $domains * @var list<array<string,mixed>> $domains
* @var int|null $domainFilter * @var int|null $domainFilter
* @var list<int>|null $editableDomainIds null = alle
* @var bool $canCreate
* @var App\Config $config * @var App\Config $config
* @var App\Security\Csrf $csrf * @var App\Security\Csrf $csrf
*/ */
@@ -17,6 +19,11 @@ use App\Support\ShortDomain;
$fallbackBase = rtrim($config->baseUrl(), '/'); $fallbackBase = rtrim($config->baseUrl(), '/');
$multiDomain = count($domains) > 1; $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 = [ $sortOptions = [
'created_desc' => 'Neueste zuerst', 'created_desc' => 'Neueste zuerst',
'created_asc' => 'Älteste zuerst', 'created_asc' => 'Älteste zuerst',
@@ -30,7 +37,9 @@ $sortOptions = [
<h1>Kurzlinks</h1> <h1>Kurzlinks</h1>
<p class="muted"><?= (int) $total ?> Eintrag/Einträge<?= $search !== '' ? ' für „' . $e($search) . '“' : '' ?>.</p> <p class="muted"><?= (int) $total ?> Eintrag/Einträge<?= $search !== '' ? ' für „' . $e($search) . '“' : '' ?>.</p>
</div> </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>
<form class="toolbar" method="get" action="<?= $e($url('/admin/links')) ?>"> <form class="toolbar" method="get" action="<?= $e($url('/admin/links')) ?>">
@@ -62,8 +71,13 @@ $sortOptions = [
<?php if ($links === []): ?> <?php if ($links === []): ?>
<div class="card empty"> <div class="card empty">
<h2>Noch nichts hier</h2> <h2>Noch nichts hier</h2>
<p class="muted">Lege deinen ersten Kurzlink an – anschließend kannst du direkt einen QR-Code dazu gestalten.</p> <?php if ($canCreate): ?>
<a href="<?= $e($url('/admin/links/new')) ?>" class="btn btn--primary">Kurzlink anlegen</a> <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> </div>
<?php else: ?> <?php else: ?>
<div class="card card--flush"> <div class="card card--flush">
@@ -85,6 +99,7 @@ $sortOptions = [
$domainName = $link['domain_base_url'] !== null $domainName = $link['domain_base_url'] !== null
? ShortDomain::displayName((string) $link['domain_base_url']) ? ShortDomain::displayName((string) $link['domain_base_url'])
: ''; : '';
$editable = $mayEdit((int) $link['domain_id']);
?> ?>
<tr class="<?= $active ? '' : 'is-inactive' ?>"> <tr class="<?= $active ? '' : 'is-inactive' ?>">
<td> <td>
@@ -116,20 +131,25 @@ $sortOptions = [
<td class="actions"> <td class="actions">
<a class="btn btn--sm" href="<?= $e($url('/admin/links/' . $link['id'] . '/qr')) ?>">QR-Code</a> <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'] . '/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"> <?php if ($editable): ?>
<?= $csrf->field() ?> <a class="btn btn--sm btn--ghost" href="<?= $e($url('/admin/links/' . $link['id'] . '/edit')) ?>">Bearbeiten</a>
<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" <form method="post" action="<?= $e($url('/admin/links/' . $link['id'] . '/toggle')) ?>" class="inline">
data-confirm="Kurzlink <?= $e($shortUrl) ?> wirklich löschen? Die Statistik geht dabei verloren."> <?= $csrf->field() ?>
<?= $csrf->field() ?> <button type="submit" class="btn btn--sm btn--ghost">
<button type="submit" class="btn btn--sm btn--danger">Löschen</button> <?= $active ? 'Deaktivieren' : 'Aktivieren' ?>
</form> </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> </td>
</tr> </tr>
<?php endforeach; ?> <?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 string|null $warning
* @var bool $logoIsSvg * @var bool $logoIsSvg
* @var bool $imagickAvailable * @var bool $imagickAvailable
* @var bool $canEdit In dieser Gruppe nur "Viewer"? Dann ist alles nur lesbar.
* @var App\Security\Csrf $csrf * @var App\Security\Csrf $csrf
*/ */
@@ -164,10 +165,19 @@ $levelLabels = [
<?php endif; ?> <?php endif; ?>
<div class="form__actions"> <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> <button type="reset" class="btn btn--ghost" data-qr-reset>Zurücksetzen</button>
</div> </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> </form>
<section class="card"> <section class="card">
@@ -179,11 +189,13 @@ $levelLabels = [
alt="Aktuelles Logo" class="logo-current__img"> alt="Aktuelles Logo" class="logo-current__img">
<div class="logo-current__meta"> <div class="logo-current__meta">
<span class="mono small"><?= $e($options->logoPath) ?></span> <span class="mono small"><?= $e($options->logoPath) ?></span>
<form method="post" action="<?= $e($url('/admin/links/' . $id . '/qr/logo/delete')) ?>" <?php if ($canEdit): ?>
data-confirm="Logo wirklich entfernen?"> <form method="post" action="<?= $e($url('/admin/links/' . $id . '/qr/logo/delete')) ?>"
<?= $csrf->field() ?> data-confirm="Logo wirklich entfernen?">
<button type="submit" class="btn btn--sm btn--danger">Logo entfernen</button> <?= $csrf->field() ?>
</form> <button type="submit" class="btn btn--sm btn--danger">Logo entfernen</button>
</form>
<?php endif; ?>
</div> </div>
</div> </div>
@@ -195,17 +207,21 @@ $levelLabels = [
<?php endif; ?> <?php endif; ?>
<?php endif; ?> <?php endif; ?>
<form method="post" action="<?= $e($url('/admin/links/' . $id . '/qr/logo')) ?>" <?php if ($canEdit): ?>
enctype="multipart/form-data" class="form"> <form method="post" action="<?= $e($url('/admin/links/' . $id . '/qr/logo')) ?>"
<?= $csrf->field() ?> enctype="multipart/form-data" class="form">
<div class="field"> <?= $csrf->field() ?>
<label for="logo"><?= $options->hasLogo() ? 'Anderes Logo hochladen' : 'Logo hochladen' ?></label> <div class="field">
<input type="file" id="logo" name="logo" accept="image/png,image/jpeg,image/svg+xml" required> <label for="logo"><?= $options->hasLogo() ? 'Anderes Logo hochladen' : 'Logo hochladen' ?></label>
<p class="field__hint">PNG, JPG oder SVG. Das Logo wird mittig eingesetzt und passend skaliert; <input type="file" id="logo" name="logo" accept="image/png,image/jpeg,image/svg+xml" required>
die Fehlerkorrektur wird dabei automatisch auf Level H gesetzt.</p> <p class="field__hint">PNG, JPG oder SVG. Das Logo wird mittig eingesetzt und passend skaliert;
</div> die Fehlerkorrektur wird dabei automatisch auf Level H gesetzt.</p>
<button type="submit" class="btn">Hochladen</button> </div>
</form> <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> </section>
</div> </div>
</div> </div>
+3 -1
View File
@@ -52,7 +52,9 @@ $bars = static function (array $rows) use ($e): string {
</div> </div>
<div class="page-head__actions"> <div class="page-head__actions">
<a class="btn" href="<?= $e($url('/admin/links/' . $id . '/qr')) ?>">QR-Code</a> <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> <a class="btn btn--ghost" href="<?= $e($url('/admin/links')) ?>">Zur Liste</a>
</div> </div>
</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>