Benutzerverwaltung in der Oberfläche, Mediathek als eigene Seite
Konten gab es bisher nur über die Konsole. Unter "Benutzer" legt ein Administrator jetzt Konten an, ändert Rollen, setzt Passwörter und löscht Konten; unter "Mein Konto" ändert jeder sein eigenes Passwort, aber nur mit dem aktuellen in der Hand. Eine Rolle je Konto: Administrator, Veröffentlicher, Redakteur. Ein Passwort oder eine Rolle zu ändern meldet jede offene Sitzung dieses Kontos ab - dafür war nichts zu bauen, CmsUser::isEqualTo vergleicht Hash und Rollen mit der Datenbank. Genau eine Sitzung überlebt: die, die ihr eigenes Passwort geändert hat. "Jemanden sofort aussperren" ist damit dasselbe wie "Passwort setzen". Es bleibt immer ein Administrator übrig, nicht über eine Zählung, sondern weil niemand sein eigenes Konto löschen oder herabstufen kann und jede Änderung die Administratorrolle voraussetzt. Die zusätzliche Prüfung auf einen zweiten Administrator war damit unerreichbar und ist wieder heraus. Mediathek (Umut: "das sollte direkt laden in pagination"): /admin/media zeigt die Bilder jetzt selbst, 24 je Seite, neueste zuerst, mit Blättern und einem Schalter für archivierte. Hochladen und Bildangaben laufen über gewöhnliche Formulare ohne JavaScript; der Dialog bleibt der Weg, beim Bearbeiten ein Bild auszuwählen. Die Bild-ID steht in jeder Karte, weil Beitragsbild und Teilen-Vorschau genau diese ID erwarten. Die Migrationsliste der Testfixtures stand dreimal da und steht jetzt einmal in tests/TestSchema.php. 217 PHP-Tests / 845 Assertions unter PHP 8.2.12 und 8.4.22. axe deckt jetzt elf Bildschirme ab statt drei - alle Verwaltungsseiten, nicht nur Anmeldung, Seitenliste und Editor -, null Verstöße. Nicht geprüft: kein Screenreader; Blättern und Upload sind mit zwei Bildern geprüft, nicht mit einer großen Bibliothek. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4841bf65bc
commit
14031a5880
23 files changed
+1281
-101
No files matched your search
@@ -19,9 +19,80 @@ final class MediaController extends AbstractController
|
||||
}
|
||||
|
||||
#[Route('/admin/media', name: 'admin_media', methods: ['GET'])]
|
||||
public function index(): Response
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
return $this->render('admin/media.html.twig');
|
||||
// The screen renders the images itself, page by page. The dialog stays what it is: the way
|
||||
// to pick an image while editing, not the way to see what the library holds.
|
||||
$archived = $request->query->getBoolean('archiv');
|
||||
|
||||
return $this->render('admin/media.html.twig', [
|
||||
'library' => $this->media->page($request->query->getInt('seite', 1), $archived),
|
||||
'archived' => $archived,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/admin/media/upload', name: 'admin_media_upload', methods: ['POST'])]
|
||||
public function formUpload(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
if (!$this->isCsrfTokenValid('media-form', $request->request->getString('_csrf_token'))) {
|
||||
return $this->back($request, 'Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
$files = array_filter((array) $request->files->get('files'), static fn ($file): bool => $file instanceof UploadedFile);
|
||||
if ([] === $files) {
|
||||
return $this->back($request, 'Keine Datei empfangen. Bitte die Dateigröße und das PHP-Uploadlimit prüfen.');
|
||||
}
|
||||
$done = 0;
|
||||
foreach ($files as $file) {
|
||||
try {
|
||||
$this->media->upload($file);
|
||||
++$done;
|
||||
} catch (\InvalidArgumentException $error) {
|
||||
return $this->back($request, sprintf('%s (%s)%s', $error->getMessage(), $file->getClientOriginalName(), $done > 0 ? sprintf(' %d Bild(er) davor wurden hochgeladen.', $done) : ''));
|
||||
} catch (\RuntimeException) {
|
||||
return $this->back($request, 'Das Bild konnte nicht verarbeitet werden. GD/WebP und die Schreibrechte des Medienordners prüfen.');
|
||||
}
|
||||
}
|
||||
|
||||
return $this->back($request, sprintf('%d Bild(er) hochgeladen.', $done), 'success');
|
||||
}
|
||||
|
||||
#[Route('/admin/media/{id}', name: 'admin_media_save', requirements: ['id' => '[a-f0-9]{32}'], methods: ['POST'])]
|
||||
public function formUpdate(Request $request, string $id): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
if (!$this->isCsrfTokenValid('media-form', $request->request->getString('_csrf_token'))) {
|
||||
return $this->back($request, 'Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
if (!$this->media->find($id)) {
|
||||
throw $this->createNotFoundException();
|
||||
}
|
||||
try {
|
||||
$this->media->update($id, [
|
||||
'title' => $request->request->getString('title'),
|
||||
'alt' => $request->request->getString('alt'),
|
||||
'archived' => $request->request->getBoolean('archived'),
|
||||
'version' => $request->request->getInt('version'),
|
||||
]);
|
||||
} catch (StaleWrite) {
|
||||
return $this->back($request, 'Das Bild wurde inzwischen anderswo geändert. Lade die Seite neu und trage die Angaben erneut ein.');
|
||||
} catch (\InvalidArgumentException $error) {
|
||||
return $this->back($request, $error->getMessage());
|
||||
}
|
||||
|
||||
return $this->back($request, 'Die Bildangaben wurden gespeichert.', 'success');
|
||||
}
|
||||
|
||||
/** Back to the page the form was on, so a change does not jump to the first page. */
|
||||
private function back(Request $request, string $message, string $type = 'error'): Response
|
||||
{
|
||||
$this->addFlash($type, $message);
|
||||
$query = array_filter([
|
||||
'seite' => $request->request->getInt('seite', 1) > 1 ? $request->request->getInt('seite') : null,
|
||||
'archiv' => $request->request->getBoolean('archiv') ? '1' : null,
|
||||
]);
|
||||
|
||||
return $this->redirectToRoute('admin_media', $query);
|
||||
}
|
||||
|
||||
#[Route('/admin/api/media', methods: ['GET'])]
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Domain\CmsUser;
|
||||
use App\Domain\InvalidAccount;
|
||||
use App\Security\UserDirectory;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
|
||||
|
||||
/**
|
||||
* Accounts. The list and every change to somebody else's account need ROLE_ADMIN; the own password
|
||||
* is the one thing every signed in account may change, and only with the current one in hand.
|
||||
*/
|
||||
final class UserController extends AbstractController
|
||||
{
|
||||
public function __construct(private UserDirectory $users, private TokenStorageInterface $tokens)
|
||||
{
|
||||
}
|
||||
|
||||
#[Route('/admin/benutzer', name: 'admin_users', methods: ['GET'])]
|
||||
public function index(): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||||
|
||||
return $this->render('admin/users.html.twig', [
|
||||
'users' => $this->users->all(),
|
||||
'roles' => UserDirectory::ROLES,
|
||||
'roleHelp' => UserDirectory::ROLE_HELP,
|
||||
'minPassword' => UserDirectory::MIN_PASSWORD,
|
||||
'maxPassword' => UserDirectory::MAX_PASSWORD,
|
||||
'me' => $this->cmsUser()->id,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/admin/benutzer/neu', name: 'admin_user_create', methods: ['POST'])]
|
||||
public function create(Request $request): Response
|
||||
{
|
||||
if (null !== $refusal = $this->guard($request)) {
|
||||
return $refusal;
|
||||
}
|
||||
try {
|
||||
$this->users->create(
|
||||
$request->request->getString('username'),
|
||||
$request->request->getString('password'),
|
||||
$request->request->getString('role'),
|
||||
);
|
||||
} catch (InvalidAccount $e) {
|
||||
return $this->back($e->getMessage());
|
||||
}
|
||||
|
||||
return $this->back('Das Konto wurde angelegt.', 'success');
|
||||
}
|
||||
|
||||
#[Route('/admin/benutzer/{id}/rolle', name: 'admin_user_role', requirements: ['id' => '[a-zA-Z0-9_-]{1,64}'], methods: ['POST'])]
|
||||
public function role(Request $request, string $id): Response
|
||||
{
|
||||
if (null !== $refusal = $this->guard($request)) {
|
||||
return $refusal;
|
||||
}
|
||||
try {
|
||||
$this->users->changeRole($id, $request->request->getString('role'), $this->cmsUser());
|
||||
} catch (InvalidAccount $e) {
|
||||
return $this->back($e->getMessage());
|
||||
}
|
||||
|
||||
return $this->back('Die Rolle wurde geändert. Offene Sitzungen dieses Kontos sind damit abgemeldet.', 'success');
|
||||
}
|
||||
|
||||
#[Route('/admin/benutzer/{id}/passwort', name: 'admin_user_password', requirements: ['id' => '[a-zA-Z0-9_-]{1,64}'], methods: ['POST'])]
|
||||
public function password(Request $request, string $id): Response
|
||||
{
|
||||
if (null !== $refusal = $this->guard($request)) {
|
||||
return $refusal;
|
||||
}
|
||||
try {
|
||||
$this->users->setPassword($id, $request->request->getString('password'));
|
||||
} catch (InvalidAccount $e) {
|
||||
return $this->back($e->getMessage());
|
||||
}
|
||||
|
||||
return $this->back('Das Passwort wurde gesetzt. Offene Sitzungen dieses Kontos sind damit abgemeldet.', 'success');
|
||||
}
|
||||
|
||||
#[Route('/admin/benutzer/{id}/loeschen', name: 'admin_user_delete', requirements: ['id' => '[a-zA-Z0-9_-]{1,64}'], methods: ['POST'])]
|
||||
public function delete(Request $request, string $id): Response
|
||||
{
|
||||
if (null !== $refusal = $this->guard($request)) {
|
||||
return $refusal;
|
||||
}
|
||||
try {
|
||||
$this->users->delete($id, $this->cmsUser());
|
||||
} catch (InvalidAccount $e) {
|
||||
return $this->back($e->getMessage());
|
||||
}
|
||||
|
||||
return $this->back('Das Konto wurde gelöscht.', 'success');
|
||||
}
|
||||
|
||||
#[Route('/admin/konto', name: 'admin_account', methods: ['GET'])]
|
||||
public function account(): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
$me = $this->cmsUser();
|
||||
$mine = null;
|
||||
foreach ($this->users->all() as $account) {
|
||||
if ($account['id'] === $me->id) {
|
||||
$mine = $account;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->render('admin/account.html.twig', [
|
||||
'account' => $mine,
|
||||
'roles' => UserDirectory::ROLES,
|
||||
'roleHelp' => UserDirectory::ROLE_HELP,
|
||||
'minPassword' => UserDirectory::MIN_PASSWORD,
|
||||
'maxPassword' => UserDirectory::MAX_PASSWORD,
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/admin/konto/passwort', name: 'admin_account_password', methods: ['POST'])]
|
||||
public function ownPassword(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
||||
if (!$this->isCsrfTokenValid('account', $request->request->getString('_csrf_token'))) {
|
||||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.', 'error', 'admin_account');
|
||||
}
|
||||
$me = $this->cmsUser();
|
||||
try {
|
||||
$fresh = $this->users->changeOwnPassword(
|
||||
$me,
|
||||
$request->request->getString('current'),
|
||||
$request->request->getString('password'),
|
||||
);
|
||||
} catch (InvalidAccount $e) {
|
||||
return $this->back($e->getMessage(), 'error', 'admin_account');
|
||||
}
|
||||
|
||||
// Keep this session signed in with the new hash. Every other session of the account fails
|
||||
// its next refresh against the database and is logged out - which is the point.
|
||||
$this->tokens->setToken(new UsernamePasswordToken($fresh, 'admin', $fresh->getRoles()));
|
||||
|
||||
return $this->back('Das Passwort wurde geändert. Andere offene Sitzungen dieses Kontos sind abgemeldet.', 'success', 'admin_account');
|
||||
}
|
||||
|
||||
/** Every write needs the administrator role and a valid token; the check is the same each time. */
|
||||
private function guard(Request $request): ?Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||||
if (!$this->isCsrfTokenValid('users', $request->request->getString('_csrf_token'))) {
|
||||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function cmsUser(): CmsUser
|
||||
{
|
||||
$user = $this->getUser();
|
||||
if (!$user instanceof CmsUser) {
|
||||
throw $this->createAccessDeniedException();
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
private function back(string $message, string $type = 'error', string $route = 'admin_users'): Response
|
||||
{
|
||||
$this->addFlash($type, $message);
|
||||
|
||||
return $this->redirectToRoute($route);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user