From 7f52613821d34502cc47711fefd307ffd5e6cd98 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Fri, 14 Aug 2026 12:03:20 +0200 Subject: [PATCH] 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) --- src/App.php | 84 +++++- src/Controller/AuthController.php | 41 ++- src/Controller/Controller.php | 39 +++ src/Controller/DashboardController.php | 39 ++- src/Controller/DomainController.php | 71 ++++- src/Controller/GroupController.php | 320 +++++++++++++++++++++ src/Controller/LinkController.php | 123 ++++++-- src/Controller/ProfileController.php | 128 +++++++++ src/Controller/QrController.php | 65 ++++- src/Controller/SetupController.php | 39 ++- src/Controller/StatsController.php | 7 +- src/Controller/UserController.php | 370 +++++++++++++++++++++++++ templates/dashboard/index.php | 45 ++- templates/domains/form.php | 22 +- templates/domains/index.php | 8 + templates/groups/form.php | 160 +++++++++++ templates/groups/index.php | 60 ++++ templates/layout.php | 19 +- templates/links/index.php | 50 +++- templates/profile/show.php | 92 ++++++ templates/qr/show.php | 52 ++-- templates/stats/show.php | 4 +- templates/users/form.php | 149 ++++++++++ templates/users/index.php | 96 +++++++ 24 files changed, 1946 insertions(+), 137 deletions(-) create mode 100644 src/Controller/GroupController.php create mode 100644 src/Controller/ProfileController.php create mode 100644 src/Controller/UserController.php create mode 100644 templates/groups/form.php create mode 100644 templates/groups/index.php create mode 100644 templates/profile/show.php create mode 100644 templates/users/form.php create mode 100644 templates/users/index.php diff --git a/src/App.php b/src/App.php index 6f68852..b6f45a8 100644 --- a/src/App.php +++ b/src/App.php @@ -6,11 +6,14 @@ namespace App; use App\Controller\AuthController; use App\Controller\DashboardController; +use App\Controller\GroupController; use App\Controller\LinkController; +use App\Controller\ProfileController; use App\Controller\QrController; use App\Controller\RedirectController; use App\Controller\SetupController; use App\Controller\StatsController; +use App\Controller\UserController; use App\Http\Request; use App\Http\Response; use App\Http\Router; @@ -18,10 +21,13 @@ use App\Qr\LogoStore; use App\Qr\QrRenderer; use App\Controller\DomainController; use App\Repository\DomainRepository; +use App\Repository\GroupRepository; use App\Repository\LinkRepository; use App\Repository\QrDesignRepository; use App\Repository\SettingRepository; +use App\Repository\UserRepository; use App\Repository\VisitRepository; +use App\Security\AccessPolicy; use App\Security\Auth; use App\Security\Csrf; use App\Security\RateLimiter; @@ -46,6 +52,9 @@ final class App private QrDesignRepository $designs; private DomainRepository $domains; private SettingRepository $settings; + private UserRepository $users; + private GroupRepository $groups; + private AccessPolicy $policy; private LogoStore $logos; private QrRenderer $qr; private RateLimiter $rateLimiter; @@ -61,12 +70,19 @@ final class App $this->settings = $settings; $this->session = new Session($config); $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->visits = new VisitRepository($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( $config->path($config->getString('UPLOAD_DIR', 'data/uploads')), @@ -85,6 +101,9 @@ final class App $this->view->share('csrf', $this->csrf); $this->view->share('auth', $this->auth); $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 @@ -140,6 +159,8 @@ final class App new SetupToken($this->config->path($this->config->getString('DATA_DIR', 'data'))), $this->rateLimiter, new SystemCheck($this->config), + $this->users, + $this->groups, ); $login = new AuthController($this->config, $this->view, $this->session, $this->csrf, $this->auth, $this->rateLimiter); $dashboard = new DashboardController( @@ -150,10 +171,13 @@ final class App $this->config, $this->domains, 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); - $stats = new StatsController($this->config, $this->view, $this->auth, $this->links, $this->visits); - $qrController = new QrController($this->config, $this->view, $this->session, $this->csrf, $this->auth, $this->links, $this->designs, $this->logos, $this->qr); + $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, $this->policy); + $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( $this->config, $this->view, @@ -163,6 +187,35 @@ final class App $this->domains, new DomainChecker($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); @@ -207,6 +260,27 @@ final class App $router->post('/admin/domains/{id}/delete', [$domainController, 'destroy']); $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 // mit gueltigem, kurzlebigem Token (sonst 404). $router->get(DomainChecker::PATH, [$domainController, 'verify']); diff --git a/src/Controller/AuthController.php b/src/Controller/AuthController.php index 31b0042..53d2338 100644 --- a/src/Controller/AuthController.php +++ b/src/Controller/AuthController.php @@ -52,13 +52,25 @@ final class AuthController extends Controller return $response; } - $ip = $request->clientIp(); + $username = $request->post('username'); + $password = (string) ($request->post['password'] ?? ''); - if ($this->rateLimiter->isLocked($ip)) { - $seconds = $this->rateLimiter->retryAfter($ip); + // Zwei Zaehler: die IP bremst einen einzelnen Angreifer, der + // 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', [ - 'username' => $request->post('username'), + 'username' => $username, 'error' => sprintf( 'Zu viele Fehlversuche. Bitte in %d Minuten erneut versuchen.', max(1, (int) ceil($seconds / 60)), @@ -66,12 +78,14 @@ final class AuthController extends Controller ], 429, 'layout_plain')->withHeader('Retry-After', (string) $seconds); } - $username = $request->post('username'); - $password = (string) ($request->post['password'] ?? ''); + $user = $this->auth->verifyCredentials($username, $password); - if (!$this->auth->verifyCredentials($username, $password)) { - $this->rateLimiter->recordFailure($ip); - $remaining = $this->rateLimiter->remainingAttempts($ip); + if ($user === null) { + foreach ($keys as $key) { + $this->rateLimiter->recordFailure($key); + } + + $remaining = $this->rateLimiter->remainingAttempts($ipKey); // Kleine Verzoegerung gegen automatisiertes Durchprobieren. usleep(300000); @@ -84,14 +98,17 @@ final class AuthController extends Controller ], 401, 'layout_plain'); } - $this->rateLimiter->recordSuccess($ip); - $this->auth->login($username); + foreach ($keys as $key) { + $this->rateLimiter->recordSuccess($key); + } + + $this->auth->login($user); $intended = $this->session->get('_intended'); $this->session->remove('_intended'); $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); } diff --git a/src/Controller/Controller.php b/src/Controller/Controller.php index 3fd883a..8c35a9b 100644 --- a/src/Controller/Controller.php +++ b/src/Controller/Controller.php @@ -68,6 +68,45 @@ abstract class Controller 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 { $token = $request->post[Csrf::FIELD] ?? null; diff --git a/src/Controller/DashboardController.php b/src/Controller/DashboardController.php index 494e1f9..e47e6a2 100644 --- a/src/Controller/DashboardController.php +++ b/src/Controller/DashboardController.php @@ -8,8 +8,11 @@ use App\Config; use App\Http\Request; use App\Http\Response; use App\Repository\DomainRepository; +use App\Repository\GroupRepository; use App\Repository\LinkRepository; +use App\Repository\UserRepository; use App\Repository\VisitRepository; +use App\Security\AccessPolicy; use App\Security\Auth; use App\Security\Session; use App\Service\SystemCheck; @@ -25,6 +28,9 @@ final class DashboardController extends Controller Config $config, private readonly DomainRepository $domains, private readonly SystemCheck $systemCheck, + private readonly AccessPolicy $policy, + private readonly GroupRepository $groups, + private readonly UserRepository $users, ) { parent::__construct($config, $view); } @@ -39,18 +45,35 @@ final class DashboardController extends Controller return $this->redirect('/login'); } - // Sorgt nebenbei dafuer, dass die Standarddomain existiert. - $this->domains->defaultDomain($this->config->baseUrl()); + $isAdmin = $this->policy->isAdmin(); + + // 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', [ - 'summary' => $this->links->summary(), - 'recentLinks' => $this->links->all('', 'created_desc', 5), - 'topLinks' => $this->links->all('', 'clicks_desc', 5), - 'series' => $this->visits->clicksPerDayGlobal(30), - 'domainCount' => $this->domains->count(), + 'summary' => $this->links->summary(null, $visibleIds), + 'recentLinks' => $this->links->all('', 'created_desc', 5, 0, null, $visibleIds), + 'topLinks' => $this->links->all('', 'clicks_desc', 5, 0, null, $visibleIds), + 'series' => $this->visits->clicksPerDayGlobal(30, $visibleIds), + '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 // soll die Uebersicht nicht mit Technik zugestellt werden. - 'systemProblems' => $this->systemCheck->problems(), + 'systemProblems' => $isAdmin ? $this->systemCheck->problems() : [], 'pageTitle' => 'Übersicht', ]); } diff --git a/src/Controller/DomainController.php b/src/Controller/DomainController.php index 2ed996c..c8cddb6 100644 --- a/src/Controller/DomainController.php +++ b/src/Controller/DomainController.php @@ -8,6 +8,7 @@ use App\Config; use App\Http\Request; use App\Http\Response; use App\Repository\DomainRepository; +use App\Repository\GroupRepository; use App\Repository\SettingRepository; use App\Security\Auth; use App\Security\Csrf; @@ -19,6 +20,11 @@ use App\View; /** * Verwaltung der Kurzlink-Domains. + * + * Ausschliesslich fuer Administratoren: eine Domain gehoert zu einer + * Gruppe und entscheidet damit, wer die Kurzlinks darunter sehen und + * anlegen darf. Einzige Ausnahme ist der Endpunkt der Domain-Pruefung, + * der sich ueber ein kurzlebiges Token ausweist. */ final class DomainController extends Controller { @@ -31,13 +37,14 @@ final class DomainController extends Controller private readonly DomainRepository $domains, private readonly DomainChecker $checker, private readonly SettingRepository $settings, + private readonly GroupRepository $groups, ) { parent::__construct($config, $view); } public function index(Request $request): Response { - if (($response = $this->requireAuth($this->auth, $this->session, $request)) !== null) { + if (($response = $this->requireAdmin($this->auth, $this->session, $request)) !== null) { return $response; } @@ -50,6 +57,7 @@ final class DomainController extends Controller return $this->html('domains/index', [ 'domains' => $this->domains->all(), + 'groups' => $this->groups->all(), 'requestHost' => ShortDomain::normalizeRequestHost((string) ($request->server['HTTP_HOST'] ?? '')), 'checkResult' => is_array($checkResult) ? $checkResult : null, 'pageTitle' => 'Domains', @@ -63,7 +71,7 @@ final class DomainController extends Controller */ public function check(Request $request, array $args = []): Response { - if (($response = $this->requireAuth($this->auth, $this->session, $request)) !== null) { + if (($response = $this->requireAdmin($this->auth, $this->session, $request)) !== null) { return $response; } @@ -105,13 +113,19 @@ final class DomainController extends Controller public function create(Request $request): Response { - if (($response = $this->requireAuth($this->auth, $this->session, $request)) !== null) { + if (($response = $this->requireAdmin($this->auth, $this->session, $request)) !== null) { return $response; } return $this->html('domains/form', [ 'domain' => null, - 'values' => ['base_url' => '', 'label' => '', 'is_default' => '0'], + 'groups' => $this->groups->all(), + 'values' => [ + 'base_url' => '', + 'label' => '', + 'is_default' => '0', + 'group_id' => (string) $this->groups->defaultGroupId(), + ], 'errors' => [], 'pageTitle' => 'Neue Domain', ]); @@ -119,7 +133,7 @@ final class DomainController extends Controller public function store(Request $request): Response { - if (($response = $this->requireAuth($this->auth, $this->session, $request)) !== null) { + if (($response = $this->requireAdmin($this->auth, $this->session, $request)) !== null) { return $response; } @@ -135,9 +149,12 @@ final class DomainController extends Controller $errors['base_url'] = 'Diese Domain ist bereits eingetragen.'; } + $groupId = $this->resolveGroupId($values['group_id'], $errors); + if ($errors !== []) { return $this->html('domains/form', [ 'domain' => null, + 'groups' => $this->groups->all(), 'values' => $values, 'errors' => $errors, 'pageTitle' => 'Neue Domain', @@ -146,7 +163,7 @@ final class DomainController extends Controller // Die erste Domain ist zwangslaeufig die Standarddomain. $isDefault = $values['is_default'] === '1' || $this->domains->count() === 0; - $this->domains->create($baseUrl, $values['label'] !== '' ? $values['label'] : null, $isDefault); + $this->domains->create($baseUrl, $values['label'] !== '' ? $values['label'] : null, $isDefault, $groupId); $this->session->flash('success', sprintf( 'Domain "%s" wurde angelegt. Bitte daran denken, sie im DNS und beim Webserver auf diese Installation zu zeigen.', @@ -161,7 +178,7 @@ final class DomainController extends Controller */ public function edit(Request $request, array $args = []): Response { - if (($response = $this->requireAuth($this->auth, $this->session, $request)) !== null) { + if (($response = $this->requireAdmin($this->auth, $this->session, $request)) !== null) { return $response; } @@ -172,10 +189,12 @@ final class DomainController extends Controller return $this->html('domains/form', [ 'domain' => $domain, + 'groups' => $this->groups->all(), 'values' => [ 'base_url' => (string) $domain['base_url'], 'label' => (string) ($domain['label'] ?? ''), 'is_default' => ((int) $domain['is_default'] === 1) ? '1' : '0', + 'group_id' => (string) $domain['group_id'], ], 'errors' => [], 'pageTitle' => 'Domain bearbeiten', @@ -187,7 +206,7 @@ final class DomainController extends Controller */ public function update(Request $request, array $args = []): Response { - if (($response = $this->requireAuth($this->auth, $this->session, $request)) !== null) { + if (($response = $this->requireAdmin($this->auth, $this->session, $request)) !== null) { return $response; } @@ -209,16 +228,19 @@ final class DomainController extends Controller $errors['base_url'] = 'Diese Domain ist bereits eingetragen.'; } + $groupId = $this->resolveGroupId($values['group_id'], $errors); + if ($errors !== []) { return $this->html('domains/form', [ 'domain' => $domain, + 'groups' => $this->groups->all(), 'values' => $values, 'errors' => $errors, 'pageTitle' => 'Domain bearbeiten', ], 422); } - $this->domains->update($id, $baseUrl, $values['label'] !== '' ? $values['label'] : null); + $this->domains->update($id, $baseUrl, $values['label'] !== '' ? $values['label'] : null, $groupId); if ($values['is_default'] === '1') { $this->domains->makeDefault($id); @@ -234,7 +256,7 @@ final class DomainController extends Controller */ public function makeDefault(Request $request, array $args = []): Response { - if (($response = $this->requireAuth($this->auth, $this->session, $request)) !== null) { + if (($response = $this->requireAdmin($this->auth, $this->session, $request)) !== null) { return $response; } @@ -261,7 +283,7 @@ final class DomainController extends Controller */ public function destroy(Request $request, array $args = []): Response { - if (($response = $this->requireAuth($this->auth, $this->session, $request)) !== null) { + if (($response = $this->requireAdmin($this->auth, $this->session, $request)) !== null) { return $response; } @@ -314,7 +336,31 @@ final class DomainController extends Controller } /** - * @return array{base_url:string,label:string,is_default:string} + * Prueft die gewaehlte Gruppe. Ohne Angabe gilt die Standardgruppe - + * eine Domain ohne Gruppe waere sonst fuer niemanden ausser + * Administratoren nutzbar. + * + * @param array $errors + */ + private function resolveGroupId(string $value, array &$errors): int + { + if ($value === '') { + return $this->groups->defaultGroupId(); + } + + $group = $this->groups->find((int) $value); + + if ($group === null) { + $errors['group_id'] = 'Bitte eine gültige Gruppe auswählen.'; + + return $this->groups->defaultGroupId(); + } + + return (int) $group['id']; + } + + /** + * @return array{base_url:string,label:string,is_default:string,group_id:string} */ private function readValues(Request $request): array { @@ -322,6 +368,7 @@ final class DomainController extends Controller 'base_url' => $request->post('base_url'), 'label' => mb_substr($request->post('label'), 0, 255), 'is_default' => $request->hasPost('is_default') ? '1' : '0', + 'group_id' => $request->post('group_id'), ]; } diff --git a/src/Controller/GroupController.php b/src/Controller/GroupController.php new file mode 100644 index 0000000..b9ec896 --- /dev/null +++ b/src/Controller/GroupController.php @@ -0,0 +1,320 @@ +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 $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 $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 $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 $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 $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 $group + * @param array $values + * @param array $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 + */ + 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); + } +} diff --git a/src/Controller/LinkController.php b/src/Controller/LinkController.php index 7c38c06..8fa3b16 100644 --- a/src/Controller/LinkController.php +++ b/src/Controller/LinkController.php @@ -11,6 +11,7 @@ use App\Qr\LogoStore; use App\Repository\DomainRepository; use App\Repository\LinkRepository; use App\Repository\QrDesignRepository; +use App\Security\AccessPolicy; use App\Security\Auth; use App\Security\Csrf; use App\Security\Session; @@ -30,6 +31,7 @@ final class LinkController extends Controller private readonly QrDesignRepository $designs, private readonly LogoStore $logos, private readonly DomainRepository $domains, + private readonly AccessPolicy $policy, ) { parent::__construct($config, $view); } @@ -43,17 +45,21 @@ final class LinkController extends Controller $search = $request->query('q'); $sort = $request->query('sort', 'created_desc'); - $domains = $this->allDomains(); + $domains = $this->visibleDomains(); + $visibleIds = $this->policy->visibleDomainIds(); + $domainFilter = $request->queryInt('domain', 0); $domainFilter = $this->isKnownDomain($domains, $domainFilter) ? $domainFilter : null; return $this->html('links/index', [ - 'links' => $this->links->all($search, $sort, 200, 0, $domainFilter), - 'total' => $this->links->count($search, $domainFilter), + 'links' => $this->links->all($search, $sort, 200, 0, $domainFilter, $visibleIds), + 'total' => $this->links->count($search, $domainFilter, $visibleIds), 'search' => $search, 'sort' => $sort, 'domains' => $domains, 'domainFilter' => $domainFilter, + 'editableDomainIds' => $this->policy->editableDomainIds(), + 'canCreate' => $this->policy->canCreateLinks(), 'pageTitle' => 'Kurzlinks', ]); } @@ -64,11 +70,15 @@ final class LinkController extends Controller return $response; } - $domains = $this->allDomains(); + $domains = $this->editableDomains(); + if ($domains === []) { + return $this->noEditableDomains(); + } + $preselected = $request->queryInt('domain', 0); if (!$this->isKnownDomain($domains, $preselected)) { - $preselected = (int) $this->domains->defaultDomain($this->config->baseUrl())['id']; + $preselected = (int) $domains[0]['id']; } return $this->html('links/form', [ @@ -96,13 +106,17 @@ final class LinkController extends Controller return $response; } - $domains = $this->allDomains(); + $domains = $this->editableDomains(); + if ($domains === []) { + return $this->noEditableDomains(); + } + $values = $this->readValues($request); $errors = []; - // Ohne Angabe gilt die Standarddomain; nur ein ausgefuellter, - // aber unbekannter Wert ist ein Fehler. - $domainId = (int) $this->domains->defaultDomain($this->config->baseUrl())['id']; + // Ohne Angabe gilt die erste beschreibbare Domain; nur ein + // ausgefuellter, aber unzulaessiger Wert ist ein Fehler. + $domainId = (int) $domains[0]['id']; if ($values['domain_id'] !== '') { if ($this->isKnownDomain($domains, (int) $values['domain_id'])) { $domainId = (int) $values['domain_id']; @@ -172,14 +186,14 @@ final class LinkController extends Controller return $response; } - $link = $this->links->find((int) ($args['id'] ?? 0)); - if ($link === null) { - return $this->linkNotFound(); + $link = $this->findEditable((int) ($args['id'] ?? 0)); + if ($link instanceof Response) { + return $link; } return $this->html('links/form', [ 'link' => $link, - 'domains' => $this->allDomains(), + 'domains' => $this->editableDomains(), 'values' => [ 'target_url' => (string) $link['target_url'], 'slug' => (string) $link['slug'], @@ -206,16 +220,19 @@ final class LinkController extends Controller } $id = (int) ($args['id'] ?? 0); - $link = $this->links->find($id); - if ($link === null) { - return $this->linkNotFound(); + $link = $this->findEditable($id); + if ($link instanceof Response) { + return $link; } - $domains = $this->allDomains(); + $domains = $this->editableDomains(); $values = $this->readValues($request); $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']; if ($values['domain_id'] !== '') { if ($this->isKnownDomain($domains, (int) $values['domain_id'])) { @@ -277,9 +294,9 @@ final class LinkController extends Controller return $response; } - $link = $this->links->find((int) ($args['id'] ?? 0)); - if ($link === null) { - return $this->linkNotFound(); + $link = $this->findEditable((int) ($args['id'] ?? 0)); + if ($link instanceof Response) { + return $link; } $active = (int) $link['is_active'] !== 1; @@ -307,9 +324,9 @@ final class LinkController extends Controller return $response; } - $link = $this->links->find((int) ($args['id'] ?? 0)); - if ($link === null) { - return $this->linkNotFound(); + $link = $this->findEditable((int) ($args['id'] ?? 0)); + if ($link instanceof Response) { + return $link; } $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|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> */ - private function allDomains(): array + private function visibleDomains(): array { - // Sorgt nebenbei dafuer, dass die Standarddomain existiert. - $this->domains->defaultDomain($this->config->baseUrl()); + if ($this->policy->isAdmin()) { + $this->domains->defaultDomain($this->config->baseUrl()); + } - return $this->domains->all(); + return $this->policy->visibleDomains(); + } + + /** + * @return list> + */ + 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 { return $this->html('errors/error', [ diff --git a/src/Controller/ProfileController.php b/src/Controller/ProfileController.php new file mode 100644 index 0000000..a08bbda --- /dev/null +++ b/src/Controller/ProfileController.php @@ -0,0 +1,128 @@ +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 $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); + } +} diff --git a/src/Controller/QrController.php b/src/Controller/QrController.php index 184482d..cf26370 100644 --- a/src/Controller/QrController.php +++ b/src/Controller/QrController.php @@ -12,6 +12,7 @@ use App\Qr\QrOptions; use App\Qr\QrRenderer; use App\Repository\LinkRepository; use App\Repository\QrDesignRepository; +use App\Security\AccessPolicy; use App\Security\Auth; use App\Security\Csrf; use App\Security\Session; @@ -30,6 +31,7 @@ final class QrController extends Controller private readonly QrDesignRepository $designs, private readonly LogoStore $logos, private readonly QrRenderer $renderer, + private readonly AccessPolicy $policy, ) { parent::__construct($config, $view); } @@ -43,7 +45,7 @@ final class QrController extends Controller return $response; } - $link = $this->links->find((int) ($args['id'] ?? 0)); + $link = $this->links->find((int) ($args['id'] ?? 0), $this->policy->visibleDomainIds()); if ($link === null) { return $this->linkNotFound(); } @@ -59,6 +61,9 @@ final class QrController extends Controller 'warning' => $options->contrastWarning(), 'logoIsSvg' => $this->logos->isSvg($options->logoPath), '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'], ]); } @@ -78,9 +83,9 @@ final class QrController extends Controller return $response; } - $link = $this->links->find((int) ($args['id'] ?? 0)); - if ($link === null) { - return $this->linkNotFound(); + $link = $this->findEditable((int) ($args['id'] ?? 0)); + if ($link instanceof Response) { + return $link; } $current = $this->designs->forLink((int) $link['id']); @@ -105,9 +110,9 @@ final class QrController extends Controller return $response; } - $link = $this->links->find((int) ($args['id'] ?? 0)); - if ($link === null) { - return $this->linkNotFound(); + $link = $this->findEditable((int) ($args['id'] ?? 0)); + if ($link instanceof Response) { + return $link; } $linkId = (int) $link['id']; @@ -151,9 +156,9 @@ final class QrController extends Controller return $response; } - $link = $this->links->find((int) ($args['id'] ?? 0)); - if ($link === null) { - return $this->linkNotFound(); + $link = $this->findEditable((int) ($args['id'] ?? 0)); + if ($link instanceof Response) { + return $link; } $linkId = (int) $link['id']; @@ -181,7 +186,7 @@ final class QrController extends Controller 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) { return Response::html('Not Found', 404); } @@ -217,7 +222,7 @@ final class QrController extends Controller return $response; } - $link = $this->links->find((int) ($args['id'] ?? 0)); + $link = $this->links->find((int) ($args['id'] ?? 0), $this->policy->visibleDomainIds()); if ($link === null) { return $this->linkNotFound(); } @@ -251,6 +256,10 @@ final class QrController extends Controller * Liefert hochgeladene Logos aus. Die Dateien liegen ausserhalb des * 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 $args */ public function serveUpload(Request $request, array $args = []): Response @@ -259,7 +268,13 @@ final class QrController extends Controller 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) { 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|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, * damit der QR-Code auf die richtige Domain zeigt. diff --git a/src/Controller/SetupController.php b/src/Controller/SetupController.php index 578fc1b..3b9edfa 100644 --- a/src/Controller/SetupController.php +++ b/src/Controller/SetupController.php @@ -7,12 +7,17 @@ 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\RateLimiter; +use App\Security\Role; use App\Security\Session; use App\Security\SetupToken; use App\Service\SystemCheck; +use App\Support\Password; +use App\Support\Username; use App\View; /** @@ -28,8 +33,6 @@ use App\View; */ final class SetupController extends Controller { - private const MIN_PASSWORD_LENGTH = 10; - public function __construct( Config $config, View $view, @@ -39,6 +42,8 @@ final class SetupController extends Controller private readonly SetupToken $setupToken, private readonly RateLimiter $rateLimiter, private readonly SystemCheck $systemCheck, + private readonly UserRepository $users, + private readonly GroupRepository $groups, ) { parent::__construct($config, $view); } @@ -80,7 +85,7 @@ final class SetupController extends Controller ], 429)->withHeader('Retry-After', (string) $seconds); } - $username = $request->post('username'); + $username = Username::normalize($request->post('username')); $password = (string) ($request->post['password'] ?? ''); $confirm = (string) ($request->post['password_confirm'] ?? ''); @@ -92,27 +97,31 @@ final class SetupController extends Controller $errors['token'] = 'Das Einrichtungs-Token stimmt nicht.'; } - if (!preg_match('/^[A-Za-z0-9._@-]{3,64}$/', $username)) { - $errors['username'] = 'Der Benutzername muss 3-64 Zeichen lang sein (Buchstaben, Ziffern, . _ - @).'; + $usernameError = Username::validationError($username); + if ($usernameError !== null) { + $errors['username'] = $usernameError; } - if (mb_strlen($password) < self::MIN_PASSWORD_LENGTH) { - $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.'; - } + $errors += Password::validationErrors($password, $confirm); if ($errors !== []) { 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->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!'); return $this->redirect('/admin'); @@ -126,7 +135,7 @@ final class SetupController extends Controller return $this->html('setup/index', [ 'username' => $username, 'errors' => $errors, - 'minLength' => self::MIN_PASSWORD_LENGTH, + 'minLength' => Password::MIN_LENGTH, 'tokenPath' => $this->setupToken->path(), 'systemChecks' => $this->systemCheck->all(), 'systemStatus' => $this->systemCheck->worstStatus(), diff --git a/src/Controller/StatsController.php b/src/Controller/StatsController.php index f092020..913b376 100644 --- a/src/Controller/StatsController.php +++ b/src/Controller/StatsController.php @@ -9,6 +9,7 @@ use App\Http\Request; use App\Http\Response; use App\Repository\LinkRepository; use App\Repository\VisitRepository; +use App\Security\AccessPolicy; use App\Security\Auth; use App\Security\Session; use App\Support\UserAgentParser; @@ -22,6 +23,7 @@ final class StatsController extends Controller private readonly Auth $auth, private readonly LinkRepository $links, private readonly VisitRepository $visits, + private readonly AccessPolicy $policy, ) { parent::__construct($config, $view); } @@ -35,7 +37,7 @@ final class StatsController extends Controller 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) { return $this->html('errors/error', [ 'title' => 'Kurzlink nicht gefunden', @@ -66,6 +68,7 @@ final class StatsController extends Controller 'browsers' => $this->visits->breakdown($linkId, 'browser'), 'platforms' => $this->visits->breakdown($linkId, 'platform'), 'recent' => $this->visits->recent($linkId), + 'canEdit' => $this->policy->canEditLink($link), 'pageTitle' => 'Statistik: ' . $link['slug'], ]); } @@ -81,7 +84,7 @@ final class StatsController extends Controller 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) { return Response::json(['error' => 'not_found'], 404); } diff --git a/src/Controller/UserController.php b/src/Controller/UserController.php new file mode 100644 index 0000000..776aa8b --- /dev/null +++ b/src/Controller/UserController.php @@ -0,0 +1,370 @@ +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 $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 $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 $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 + */ + 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 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 + */ + 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 $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); + } +} diff --git a/templates/dashboard/index.php b/templates/dashboard/index.php index 8f8e893..43b8d73 100644 --- a/templates/dashboard/index.php +++ b/templates/dashboard/index.php @@ -6,6 +6,11 @@ * @var list> $topLinks * @var list $series * @var int $domainCount + * @var bool $isAdmin + * @var bool $canCreate + * @var list> $myGroups + * @var int|null $groupCount nur fuer Administratoren + * @var int|null $userCount nur fuer Administratoren * @var App\Config $config */ @@ -49,9 +54,20 @@ $fallbackBase = rtrim($config->baseUrl(), '/');

Übersicht

-

Alle Kurzlinks und Klicks auf einen Blick.

+

+ + Alle Kurzlinks und Klicks auf einen Blick. + + Sie sind noch in keiner Gruppe – deshalb ist hier noch nichts zu sehen. + + Kurzlinks und Klicks Ihrer Gruppen: + . + +

- Neuer Kurzlink + + Neuer Kurzlink +
@@ -74,9 +90,27 @@ $fallbackBase = rtrim($config->baseUrl(), '/');
Domains - + + + + +
+ +
+ Gruppen + + + +
+
+ Benutzer + + + +
+
@@ -92,7 +126,10 @@ $fallbackBase = rtrim($config->baseUrl(), '/');

Zuletzt angelegt

Noch keine Kurzlinks vorhanden. - Jetzt den ersten anlegen.

+ + Jetzt den ersten anlegen. + +

    diff --git a/templates/domains/form.php b/templates/domains/form.php index 642ab8f..e257859 100644 --- a/templates/domains/form.php +++ b/templates/domains/form.php @@ -2,7 +2,8 @@ /** * @var array|null $domain - * @var array{base_url:string,label:string,is_default:string} $values + * @var list> $groups + * @var array{base_url:string,label:string,is_default:string,group_id:string} $values * @var array $errors * @var App\Security\Csrf $csrf */ @@ -52,6 +53,25 @@ $isDefault = $isEdit && (int) $domain['is_default'] === 1;

    Nur zur eigenen Orientierung in der Auswahlliste.

    +
    + + +

    + 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. +

    + +

    + +
    +
    + + + + + keine + + diff --git a/templates/groups/form.php b/templates/groups/form.php new file mode 100644 index 0000000..944978b --- /dev/null +++ b/templates/groups/form.php @@ -0,0 +1,160 @@ +|null $group + * @var list> $members + * @var list> $domains Domains dieser Gruppe + * @var list> $availableUsers noch nicht Mitglied + * @var array{name:string} $values + * @var array $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'); +?> + + +
    +
    + field() ?> + +
    + + + +

    + +
    + +
    + + Abbrechen +
    +
    +
    + + + +
    +

    Mitglieder

    + + +

    Noch niemand in dieser Gruppe.

    + +
    + + + + + + + + + + + + + + + + + +
    BenutzerRolle in der GruppeAktionen
    +
    + + + Administrator – sieht ohnehin alles + + + gesperrt + +
    +
    +
    + field() ?> + + + +
    +
    +
    + field() ?> + + +
    +
    +
    + + + +

    Alle vorhandenen Konten sind bereits Mitglied. + Neues Konto anlegen.

    + +
    + field() ?> + + + +
    + +
    + +
    +

    Domains dieser Gruppe

    + + +

    Dieser Gruppe ist noch keine Domain zugeordnet – ihre Mitglieder sehen + deshalb noch keine Kurzlinks. Die Zuordnung erfolgt beim + Bearbeiten einer Domain.

    + + + +
    + + diff --git a/templates/groups/index.php b/templates/groups/index.php new file mode 100644 index 0000000..55ee1be --- /dev/null +++ b/templates/groups/index.php @@ -0,0 +1,60 @@ +> $groups + * @var App\Security\Csrf $csrf + */ +?> +
    +
    +

    Gruppen

    +

    Eine Gruppe verbindet Benutzer mit Domains.

    +
    + Neue Gruppe +
    + +
    +

    + 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 + Editor darf er sie auch anlegen und ändern, als Viewer + nur ansehen (einschließlich Statistik und QR-Download). Administratoren sehen unabhängig + von Gruppen alles. +

    +
    + +
    +
    + + + + + + + + + + + + + + + + + + + +
    GruppeMitgliederDomainsAktionen
    + Bearbeiten +
    + field() ?> + +
    +
    +
    +
    diff --git a/templates/layout.php b/templates/layout.php index e0623c3..ad844a2 100644 --- a/templates/layout.php +++ b/templates/layout.php @@ -7,6 +7,7 @@ * @var App\Config $config * @var App\Security\Csrf $csrf * @var App\Security\Auth $auth + * @var App\Security\AccessPolicy $policy * @var string $content * @var list $flashes * @var string $currentPath @@ -14,6 +15,12 @@ $title = $pageTitle ?? 'Verwaltung'; $isActive = static fn (string $prefix): bool => str_starts_with($currentPath ?? '/', $prefix); + +// Die Navigation zeigt nur, was der angemeldete Benutzer auch aufrufen +// darf. Die Routen pruefen unabhaengig davon noch einmal - das hier ist +// Bedienkomfort, kein Zugriffsschutz. +$isAdmin = $auth->check() && $policy->isAdmin(); +$canCreateLinks = $auth->check() && $policy->canCreateLinks(); ?> @@ -36,13 +43,19 @@ $isActive = static fn (string $prefix): bool => str_starts_with($currentPath ??
    field() ?> - currentUser() ?? '') ?> + currentUser() ?? '') ?>
    diff --git a/templates/links/index.php b/templates/links/index.php index 3f63ae4..4d3ab7d 100644 --- a/templates/links/index.php +++ b/templates/links/index.php @@ -7,6 +7,8 @@ * @var string $sort * @var list> $domains * @var int|null $domainFilter + * @var list|null $editableDomainIds null = alle + * @var bool $canCreate * @var App\Config $config * @var App\Security\Csrf $csrf */ @@ -17,6 +19,11 @@ use App\Support\ShortDomain; $fallbackBase = rtrim($config->baseUrl(), '/'); $multiDomain = count($domains) > 1; +// Wer in einer Gruppe nur "Viewer" ist, sieht die Kurzlinks, darf sie +// aber nicht anfassen. Entschieden wird das je Zeile ueber die Domain. +$mayEdit = static fn (int $domainId): bool => + $editableDomainIds === null || in_array($domainId, $editableDomainIds, true); + $sortOptions = [ 'created_desc' => 'Neueste zuerst', 'created_asc' => 'Älteste zuerst', @@ -30,7 +37,9 @@ $sortOptions = [

    Kurzlinks

    Eintrag/Einträge.

    - Neuer Kurzlink + + Neuer Kurzlink +
    @@ -62,8 +71,13 @@ $sortOptions = [

    Noch nichts hier

    -

    Lege deinen ersten Kurzlink an – anschließend kannst du direkt einen QR-Code dazu gestalten.

    - Kurzlink anlegen + +

    Lege deinen ersten Kurzlink an – anschließend kannst du direkt einen QR-Code dazu gestalten.

    + Kurzlink anlegen + +

    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.

    +
    @@ -85,6 +99,7 @@ $sortOptions = [ $domainName = $link['domain_base_url'] !== null ? ShortDomain::displayName((string) $link['domain_base_url']) : ''; + $editable = $mayEdit((int) $link['domain_id']); ?> @@ -116,20 +131,25 @@ $sortOptions = [ QR-Code Statistik - Bearbeiten - - field() ?> - - + + Bearbeiten -
    - field() ?> - -
    +
    + field() ?> + +
    + +
    + field() ?> + +
    + + nur lesen + diff --git a/templates/profile/show.php b/templates/profile/show.php new file mode 100644 index 0000000..9b11288 --- /dev/null +++ b/templates/profile/show.php @@ -0,0 +1,92 @@ +|null $user + * @var string $roleLabel + * @var bool $isEmergency + * @var list> $groups + * @var array $errors + * @var int $minLength + * @var App\Security\Csrf $csrf + */ + +use App\Security\Role; +?> +
    +
    +

    Mein Konto

    +

    +
    +
    + +
    +

    Zugehörigkeit

    +

    Rolle:

    + + +

    Dies ist der Notfallzugang aus der .env. Er hat volle + Administratorrechte und ist an keine Gruppe gebunden.

    + +

    Sie sind noch in keiner Gruppe. Ohne Gruppe sind keine Kurzlinks sichtbar – + eine Administratorin oder ein Administrator kann Sie hinzufügen.

    + +
      + +
    • + + +
    • + +
    + +
    + +
    +

    Passwort ändern

    + + +

    Das Passwort dieses Zugangs steht als Hash in der .env + (ADMIN_PASSWORD_HASH) und lässt sich nur dort ändern.

    + +
    + field() ?> + +
    + + + +

    + +
    + +
    + + +

    Mindestens Zeichen. Eine lange Passphrase + ist besser als kurze Sonderzeichenakrobatik.

    + +

    + +
    + +
    + + + +

    + +
    + +

    Nach der Änderung werden alle anderen Sitzungen dieses Kontos abgemeldet.

    + +
    + +
    +
    + +
    diff --git a/templates/qr/show.php b/templates/qr/show.php index 7ceab40..8c02158 100644 --- a/templates/qr/show.php +++ b/templates/qr/show.php @@ -9,6 +9,7 @@ * @var string|null $warning * @var bool $logoIsSvg * @var bool $imagickAvailable + * @var bool $canEdit In dieser Gruppe nur "Viewer"? Dann ist alles nur lesbar. * @var App\Security\Csrf $csrf */ @@ -164,10 +165,19 @@ $levelLabels = [
    - + + +
    -

    Speichern ist optional – Vorschau und Download nutzen immer die aktuellen Werte.

    +

    + + Speichern ist optional – Vorschau und Download nutzen immer die aktuellen Werte. + + In dieser Gruppe ist nur das Ansehen erlaubt. Ausprobieren und Herunterladen geht + trotzdem – gespeichert wird die Gestaltung aber nicht. + +

    @@ -179,11 +189,13 @@ $levelLabels = [ alt="Aktuelles Logo" class="logo-current__img">
    logoPath) ?> -
    - field() ?> - -
    + +
    + field() ?> + +
    +
    @@ -195,17 +207,21 @@ $levelLabels = [ -
    - field() ?> -
    - - -

    PNG, JPG oder SVG. Das Logo wird mittig eingesetzt und passend skaliert; - die Fehlerkorrektur wird dabei automatisch auf Level H gesetzt.

    -
    - -
    + +
    + field() ?> +
    + + +

    PNG, JPG oder SVG. Das Logo wird mittig eingesetzt und passend skaliert; + die Fehlerkorrektur wird dabei automatisch auf Level H gesetzt.

    +
    + +
    + hasLogo()): ?> +

    Für diesen Kurzlink ist kein Logo hinterlegt.

    +
diff --git a/templates/stats/show.php b/templates/stats/show.php index 4ad9d84..9fe57c1 100644 --- a/templates/stats/show.php +++ b/templates/stats/show.php @@ -52,7 +52,9 @@ $bars = static function (array $rows) use ($e): string { diff --git a/templates/users/form.php b/templates/users/form.php new file mode 100644 index 0000000..4b9aac0 --- /dev/null +++ b/templates/users/form.php @@ -0,0 +1,149 @@ +|null $user + * @var list> $groups + * @var array $memberships Gruppenkennung => Rolle + * @var array{username:string,role:string,is_active:string} $values + * @var array $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'); +?> +
+
+

+ +

+ +

Ein weiteres Konto für diese Installation.

+ +
+ Zurück zur Liste +
+ +
+
+ field() ?> + +
+ + +

3–64 Zeichen: Buchstaben, Ziffern sowie . _ - @. + Groß- und Kleinschreibung wird beim Anmelden nicht unterschieden.

+ +

+ +
+ +
+ + +

+ Administrator: verwaltet Benutzer, Gruppen und Domains und sieht alle Kurzlinks.
+ Benutzer: sieht ausschließlich die Kurzlinks seiner Gruppen. +

+ +

+ +
+ +
+ +

Ein gesperrtes Konto kann sich nicht anmelden; laufende Sitzungen enden sofort.

+
+ +
+ Gruppen + +

Es gibt noch keine Gruppe. + Jetzt eine anlegen.

+ +
+ + + + + + + + + + + + + + + +
GruppeRolle in der Gruppe
+ ( Domain(s)) + + +
+
+

Ein Administrator sieht alle Kurzlinks – für ihn ist die Zuordnung + nur dann nötig, wenn er auch selbst welche anlegen soll.

+ +
+ +
+ Passwort + +
+ + > +

+ Mindestens Zeichen. + +

+ +

+ +
+ +
+ + > + +

+ +
+
+ +
+ + Abbrechen +
+
+
diff --git a/templates/users/index.php b/templates/users/index.php new file mode 100644 index 0000000..a3664b3 --- /dev/null +++ b/templates/users/index.php @@ -0,0 +1,96 @@ +> $users + * @var int|null $currentId + * @var string|null $emergencyUser Name des Notfallzugangs aus der .env + * @var App\Security\Csrf $csrf + */ + +use App\Security\Role; +?> +
+
+

Benutzer

+

Konto/Konten. Wer welche Kurzlinks sieht, entscheidet die + Gruppenzugehörigkeit.

+
+ Neuer Benutzer +
+ + +
+ Zusätzlich ist der Notfallzugang aus der .env aktiv. + Er hat volle Administratorrechte, taucht in dieser Liste nicht auf und lässt sich nur dort ändern + (ADMIN_USER / ADMIN_PASSWORD_HASH). Wird er nicht mehr gebraucht, sollten + beide Zeilen entfernt werden. +
+ + +
+
+ + + + + + + + + + + + + + + + + + + + + +
BenutzerRolleGruppenZuletzt angemeldetAktionen
+
+ + + das eigene Konto + + + gesperrt + +
+
+ + – + + + + + + + + + + + + noch nie + + + Bearbeiten + +
+ field() ?> + +
+ +
+
+