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
@@ -109,4 +109,4 @@ Serverkonfiguration über echte Umgebungsvariablen oder eine nicht öffentliche
|
||||
|
||||
Deployment später mit `composer install --no-dev --optimize-autoloader`, Backup, Migration und Cacheaufbau. Composer darf auf einem geeigneten Build-Rechner laufen, sodass das Hosting weder Node noch Composer benötigt; CLI-Zugriff oder ein verlässlich dokumentierter Deploymentweg für Datenbankmigrationen bleibt zu klären. Keine Installation ins öffentlich erreichbare Projektstammverzeichnis.
|
||||
|
||||
Ein Livegang, Hostzugriff, Restore-Test und Produktionsfreigabe sind nicht erfolgt. Bei bestehenden Installationen vor dem Update Datenbank und Medien sichern und `php vendor/bin/doctrine-migrations migrate --no-interaction` ausführen. Migration `20260912000200` ergänzt Benutzer, `20260912000300` die Medientabelle; beide verändern keine Seitendokumente. Neue Seiten, Passwortänderung und der Abgleich mit einer konkreten Pilotwebsite bleiben nächste Schritte.
|
||||
Ein Livegang, Hostzugriff, Restore-Test und Produktionsfreigabe sind nicht erfolgt. Bei bestehenden Installationen vor dem Update Datenbank und Medien sichern und `php vendor/bin/doctrine-migrations migrate --no-interaction` ausführen. Migration `20260912000200` ergänzt Benutzer, `20260912000300` die Medientabelle; beide verändern keine Seitendokumente. Migration `20260912001100` ergänzt drei Datumsspalten an `cms_user` (drei ADD COLUMN, kein Tabellenneubau). Benutzerverwaltung und Passwortänderung gibt es jetzt in der Oberfläche unter „Benutzer“ und „Mein Konto“; `php bin/console cms:user:create` bleibt der Weg, wenn kein Konto mehr funktioniert. Der Abgleich mit der Pilotwebsite bleibt der nächste Schritt.
|
||||
@@ -373,3 +373,23 @@ Umuts Auftrag war „backend komplett neugestalten, einfacher schöner Bedienbar
|
||||
Beim Zerlegen ist mir ein Fehler unterlaufen, den erst der Browsertest gefunden hat: mein Skript schnitt am Ende jedes `@media`-Blocks **alle** schließenden Klammern ab statt nur einer. Die Datei war damit ab dem ersten Medienblock ungültig, der Browser behielt 29 von 202 Regeln. Symptom war eine Vorschaubreite von 1236 statt 1240 Pixeln, weil der Standardrahmen des iframe nicht mehr entfernt wurde – vier Pixel als einziges Anzeichen dafür, dass dreiviertel des Editor-Stylesheets fehlte.
|
||||
|
||||
**Systemschrift statt Arial**, und eine dunkle Seitenleiste als ruhiger Anker. Für die Verwaltung wird nichts nachgeladen; Google Fonts bleiben der Website vorbehalten, wo Umut sie auswählt.
|
||||
|
||||
## ADR 030 – Benutzerverwaltung: eine Rolle je Konto, und Sperren heißt Abmelden (12.09.2026)
|
||||
|
||||
Konten gab es bisher nur über die Konsole. Jetzt gibt es sie in der Verwaltung, mit drei Entscheidungen.
|
||||
|
||||
**Eine Rolle je Konto.** Gespeichert ist weiter eine Liste, weil die Sitzung sie so vergleicht, aber die Oberfläche kennt genau eine: Administrator, Veröffentlicher, Redakteur. Mehrere Rollen gleichzeitig hätte niemand erklären können, und die Rollenhierarchie in `security.yaml` leistet dasselbe.
|
||||
|
||||
**Wer gesperrt wird, wird abgemeldet.** Ein Passwort oder eine Rolle zu ändern beendet jede offene Sitzung dieses Kontos. Dafür war nichts zu bauen: `CmsUser::isEqualTo` vergleicht Passworthash und Rollen mit der Datenbank, und eine Sitzung, deren gespeicherter Stand abweicht, ist bei der nächsten Anfrage nicht mehr angemeldet. Genau eine Sitzung überlebt, nämlich die, die ihr eigenes Passwort geändert hat – der Controller legt ihr den frischen Benutzer in die Sitzung. Damit ist „jemanden sofort aussperren“ dasselbe wie „Passwort setzen“, und das steht auch so in der Oberfläche.
|
||||
|
||||
**Es bleibt immer ein Administrator übrig.** Nicht über eine Zählung, sondern über eine einfachere Regel: niemand kann sein eigenes Konto löschen oder herabstufen. Weil jede Änderung an einem Konto die Administratorrolle voraussetzt, braucht das Entfernen eines Administrators einen zweiten, der danach noch da ist. Ich hatte zuerst zusätzlich geprüft, ob noch ein anderer Administrator existiert – mit dieser Regel ist das unerreichbarer Code und wieder heraus.
|
||||
|
||||
Das eigene Passwort darf jeder ändern, aber nur mit dem aktuellen in der Hand: eine offen stehengelassene Sitzung soll nicht genügen, um ein Konto zu übernehmen. Untere Grenze 14 Zeichen, obere 72 Bytes, weil bcrypt alles darüber stillschweigend ignoriert. `cms:user:create` bleibt der Weg zurück, wenn kein Konto mehr funktioniert; das steht auch auf dem Bildschirm, samt Hinweis, dass es Dateizugriff auf den Server braucht.
|
||||
|
||||
## ADR 031 – Die Mediathek ist eine Seite, kein Knopf (12.09.2026)
|
||||
|
||||
Umut: „Ich finde mediathek komisch das sollte direkt laden in pagination“. Er hatte recht: der Bildschirm zeigte einen Knopf, der denselben Dialog öffnete, den der Editor zum Auswählen benutzt. Um zu sehen, was in der Mediathek liegt, musste man erst etwas anklicken.
|
||||
|
||||
Jetzt rendert `/admin/media` die Bilder selbst, 24 je Seite, neueste zuerst, mit Blättern über `?seite=` und einem Schalter für archivierte. Hochladen und Bildangaben laufen über gewöhnliche Formulare statt über `fetch`, also ohne JavaScript; dieser Bildschirm lädt weder `media.js` noch `editor.css`. Die Bild-ID steht in jeder Karte, weil die Felder für Beitragsbild und Teilen-Vorschau genau diese ID erwarten.
|
||||
|
||||
Der Dialog bleibt, was er ist: der Weg, beim Bearbeiten ein Bild auszuwählen. Zwei Wege zum gleichen Bestand mit verschiedenen Aufgaben – `MediaLibrary::list()` bleibt für den Dialog neben `page()` bestehen, weil der Dialog seinen eigenen Filter mitbringt.
|
||||
+1
-1
@@ -145,7 +145,7 @@ Abnahme: Templatewechsel erhält die Inhalte. Das Referenzplugin lässt sich akt
|
||||
|
||||
*Braucht: nichts; teils vorziehbar.*
|
||||
|
||||
Benutzerverwaltung und Passwortänderung in der Oberfläche. Inhaltscache mit gezielter Erneuerung beim Veröffentlichen. Bildformate und Ressourcenpakete prüfen. Sicherung und Wiederherstellung erproben. Updateverfahren mit Datenbankmigration und Rückfallstrategie dokumentieren.
|
||||
~~Benutzerverwaltung und Passwortänderung in der Oberfläche.~~ — **erledigt am 12.09.2026** (ADR 030). Offen bleibt der Rest des Pakets: Inhaltscache mit gezielter Erneuerung beim Veröffentlichen. Bildformate und Ressourcenpakete prüfen. Sicherung und Wiederherstellung erproben. Updateverfahren mit Datenbankmigration und Rückfallstrategie dokumentieren.
|
||||
|
||||
Abnahme: Veröffentlichen macht die Änderung sofort sichtbar und erneuert nur die betroffenen Einträge. Eine Wiederherstellung aus dem Backup ist nachgewiesen.
|
||||
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# Qualitätsbericht – erster ausführbarer Prototyp
|
||||
|
||||
## Ergänzung: Benutzerverwaltung und Mediathek, 12.09.2026
|
||||
|
||||
- **217 PHP-Tests / 845 Assertions** unter PHP 8.2.12 und PHP 8.4.22, davon 16 neue für die Benutzerverwaltung; Playwright-Editortest und öffentliche Browserprüfung grün.
|
||||
- **axe deckt jetzt elf Bildschirme ab statt drei**: Anmeldung, Seitenübersicht, Benutzer, eigenes Konto, Mediathek, Menüs, Formulare, Bereiche, Design, Einstellungen und der Editor in Desktop und Mobil. Null Verstöße. Das war der offene Punkt aus der Umgestaltung.
|
||||
- Zwei gleichzeitige Sitzungen im Test zeigen die Sperrwirkung: nach einem zurückgesetzten Passwort und nach einer geänderten Rolle ist die Sitzung des betroffenen Kontos bei der nächsten Anfrage abgemeldet, die des handelnden Administrators nicht. Beim eigenen Passwortwechsel bleibt die wechselnde Sitzung angemeldet, die zweite nicht.
|
||||
- Acht abgewiesene Kontoanlagen erreichen die Datenbank nicht, darunter ein Passwort mit Nullbyte und eines mit 73 Bytes – letzteres, weil bcrypt alles über 72 Bytes stillschweigend ignoriert und ein längeres Passwort damit kürzer wäre, als es aussieht.
|
||||
- Die Migration lief mit Sicherung (`var/backups/before-users-*.db`) und erst als Probelauf gegen die Entwicklungsdatenbank: drei `ALTER TABLE ... ADD COLUMN`, kein Tabellenneubau.
|
||||
- **Dabei aufgefallen:** der lokale Vorschauserver lieferte 500 für die neuen Bildschirme, weil sein Prozess einen älteren kompilierten Container hielt – `cache:clear` ohne `--env=prod` räumt den nicht ab. Kein Codefehler, aber der Grund, warum eine Änderung im Browser gegen einen frisch gestarteten Server anzusehen ist.
|
||||
- **Nicht geprüft:** kein Screenreader; Upload und Blättern in der Mediathek sind mit zwei Bildern geprüft, nicht mit einer großen Bibliothek.
|
||||
|
||||
## Ergänzung: Verwaltung neu gestaltet, 12.09.2026
|
||||
|
||||
- **201 PHP-Tests / 716 Assertions** grün, Playwright-Editortest mit axe ohne Verstöße auf **Anmeldung, Seitenübersicht und Editor** (Desktop und Mobil), öffentliche Browserprüfung unverändert grün.
|
||||
|
||||
+6
-2
@@ -1,6 +1,6 @@
|
||||
# Projektstatus
|
||||
|
||||
Stand: 12.09.2026. Arbeitstitel Kant CMS. **Die Verwaltung hat einen gemeinsamen Rahmen mit einer Navigation, die zeigt, wo man ist. Eigenes CSS je Element für alle, eigenes Skript nur für Administratoren und nur als eigene Datei. Formatierter Text mit Werkzeugleiste und HTML-Ansicht, serverseitig gegen eine Positivliste gefiltert. Kopf- und Fußbereich sind frei gestaltbare Blockdokumente, dazu verknüpfte wiederverwendbare Sektionen (Arbeitspaket G, Teil 1). Frei wählbare Startseite; `/sitemap.xml` und Geschwister waren lokal nie erreichbar und sind es jetzt. Seiten verschieben mit automatischen Weiterleitungen, Papierkorb, Fassungsverlauf und geplante Veröffentlichung (Arbeitspaket F). Browsertests wiederhergestellt und erweitert; dabei drei echte Fehler gefunden und behoben, darunter die in der Vorschau wirkungslose Gestaltung. Direktbearbeitung in der Vorschau, Kontrastwarnung, Gestaltungsvorlagen und Bildauswahl für Hintergrundbilder. Der Editor gestaltet frei statt über feste Auswahllisten: eigene Farbpalette, Schriften, und jede Eigenschaft je Block, je Gerät und für den Hover-Zustand frei einstellbar (Dokumentschema v10). SEO-Grundlage mit Sitemap, robots.txt, Weiterleitungen und strukturierten Daten (Arbeitspaket E). Formulare mit serverseitiger Validierung, Einwilligung, E-Mail-Versand und Spamschutz (Arbeitspaket D). Tabs als fortschreitende Verbesserung mit JavaScript (Dokumentschema v7). Zehn neue Blöcke für die Abschnitte der Pilotstartseite (Arbeitspaket C). Beiträge mit Datum, Autor, Auszug, Beitragsbild, Kategorien, Schlagwörtern, Archiven, einbettbarer Beitragsliste und RSS (Arbeitspaket B). Menüs mit Seiten-, Anker- und externen Zielen ersetzen die fest verdrahtete Navigation (Arbeitspaket A). Seiten und Beiträge lassen sich im Browser anlegen und löschen, mit Adresshierarchie bis vier Ebenen.** Die vollständige Phase-1-Abnahme bleibt wegen des fehlenden UX-Builder-Referenzabgleichs offen.
|
||||
Stand: 12.09.2026. Arbeitstitel Kant CMS. **Benutzerverwaltung in der Oberfläche, Mediathek als eigene Seite mit Blättern. Die Verwaltung hat einen gemeinsamen Rahmen mit einer Navigation, die zeigt, wo man ist. Eigenes CSS je Element für alle, eigenes Skript nur für Administratoren und nur als eigene Datei. Formatierter Text mit Werkzeugleiste und HTML-Ansicht, serverseitig gegen eine Positivliste gefiltert. Kopf- und Fußbereich sind frei gestaltbare Blockdokumente, dazu verknüpfte wiederverwendbare Sektionen (Arbeitspaket G, Teil 1). Frei wählbare Startseite; `/sitemap.xml` und Geschwister waren lokal nie erreichbar und sind es jetzt. Seiten verschieben mit automatischen Weiterleitungen, Papierkorb, Fassungsverlauf und geplante Veröffentlichung (Arbeitspaket F). Browsertests wiederhergestellt und erweitert; dabei drei echte Fehler gefunden und behoben, darunter die in der Vorschau wirkungslose Gestaltung. Direktbearbeitung in der Vorschau, Kontrastwarnung, Gestaltungsvorlagen und Bildauswahl für Hintergrundbilder. Der Editor gestaltet frei statt über feste Auswahllisten: eigene Farbpalette, Schriften, und jede Eigenschaft je Block, je Gerät und für den Hover-Zustand frei einstellbar (Dokumentschema v10). SEO-Grundlage mit Sitemap, robots.txt, Weiterleitungen und strukturierten Daten (Arbeitspaket E). Formulare mit serverseitiger Validierung, Einwilligung, E-Mail-Versand und Spamschutz (Arbeitspaket D). Tabs als fortschreitende Verbesserung mit JavaScript (Dokumentschema v7). Zehn neue Blöcke für die Abschnitte der Pilotstartseite (Arbeitspaket C). Beiträge mit Datum, Autor, Auszug, Beitragsbild, Kategorien, Schlagwörtern, Archiven, einbettbarer Beitragsliste und RSS (Arbeitspaket B). Menüs mit Seiten-, Anker- und externen Zielen ersetzen die fest verdrahtete Navigation (Arbeitspaket A). Seiten und Beiträge lassen sich im Browser anlegen und löschen, mit Adresshierarchie bis vier Ebenen.** Die vollständige Phase-1-Abnahme bleibt wegen des fehlenden UX-Builder-Referenzabgleichs offen.
|
||||
|
||||
Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt. Noch kein Remote und kein Push.
|
||||
|
||||
@@ -25,6 +25,8 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt.
|
||||
- Auf ausdrücklichen Nutzerwunsch lokalen Administrator `umut` mit zufälligem Passwort angelegt und im In-App-Browser erfolgreich angemeldet. Das Passwort steht in keiner Projektdatei; nur der Hash in der lokalen Datenbank. Keine Browser-Passwortspeicherung ausgelöst.
|
||||
- Seitenerstellung und -löschung im Browser: `POST /admin/pages` (ab `ROLE_EDITOR`) legt Seite oder Beitrag mit gültigem Startdokument als privaten Entwurf an; `POST /admin/pages/{id}/delete` (nur `ROLE_ADMIN`) entfernt eine Seite mit allen Revisionen. Beide mit CSRF-Token, Fehlermeldungen als Flash und ohne JavaScript bedienbar.
|
||||
- Adresshierarchie: neue Spalten `path`, `parent_id`, `type`, `created_at` über Migration `20260912000400`; websiteweit eindeutiger Pfad statt eindeutigem Slug, Tiefe bis vier Ebenen, Beiträge ohne Unterseiten, reservierte Adressen und Adressableitung aus dem Titel. Öffentliche Route bedient verschachtelte Pfade wie `/leistungen/webdesign`; ein Lookahead hält `/admin`, `/media` und `/assets` aus dem Auffangmuster.
|
||||
- **Benutzerverwaltung** (ADR 030): Konten anlegen, Rolle ändern, Passwort setzen, Konto löschen – nur für Administratoren. Eine Rolle je Konto (Administrator, Veröffentlicher, Redakteur). **Ein Passwort oder eine Rolle zu ändern meldet jede offene Sitzung dieses Kontos ab**, weil die Sitzung Hash und Rollen gegen die Datenbank prüft; nur die Sitzung, die ihr eigenes Passwort ändert, bleibt angemeldet. Niemand kann sein eigenes Konto löschen oder herabstufen, deshalb bleibt immer ein Administrator übrig. Das eigene Passwort darf jeder ändern, aber nur mit dem aktuellen. Die Liste zeigt letzte Anmeldung, Passwortdatum und Anlagedatum.
|
||||
- **Mediathek als Seite** (ADR 031): `/admin/media` zeigt die Bilder direkt, 24 je Seite, mit Blättern und Archivfilter. Hochladen und Bildangaben über gewöhnliche Formulare, ohne JavaScript. Der Dialog bleibt für die Bildauswahl im Editor.
|
||||
- **Verwaltung neu gestaltet** (ADR 029): ein Rahmen für alle Bildschirme mit fester Seitenleiste, aktivem Eintrag über `aria-current`, Titel, einem Satz Erklärung und Platz für die Hauptaktion. Vorher trug jede Seite ihre eigene Kopfzeile und eine eigene, unvollständige Linkliste. Je Seite eine Zeile mit dem Alltäglichen und **einem** Aufklappfeld „Einstellungen“, das an seinem Platz öffnet, statt sechs Kästen, die über den Rand hinausragten. Tabellen scrollen seitlich, statt die Karte aus dem Fenster zu schieben. Die Editorregeln stehen jetzt in `editor.css`, das nur Editor und Mediathek laden.
|
||||
- **Verwaltung neu gestaltet** (ADR 029): ein Rahmen für alle Bildschirme mit fester Seitenleiste, aktivem Eintrag über `aria-current`, Titel, einem Satz Erklärung und Platz für die Hauptaktion. Vorher trug jede Seite ihre eigene Kopfzeile und eine eigene, unvollständige Linkliste. Je Seite eine Zeile mit dem Alltäglichen und **einem** Aufklappfeld „Einstellungen“, das an seinem Platz öffnet, statt sechs Kästen, die über den Rand hinausragten. Tabellen scrollen seitlich. Der Editor behält seinen Aufbau und übernimmt die neue Gestaltung; seine Regeln stehen jetzt in `editor.css`, das nur Editor und Mediathek laden.
|
||||
- **Eigenes CSS je Element** (Dokumentschema v13) im neuen Reiter „Erweitert“: Eigenschaften für das Element und verschachtelte Regeln mit `&`. Der Selektor wird vom Server gesetzt, die Ausgabe neu serialisiert – eine überzählige `}` kann den Block nicht verlassen. Abgewiesen: @-Regeln, Backslash-Escapes, `expression()`, fremde Selektoren und jede Adresse außer einem einzelnen `/` oder `https://`. Für jeden mit Editorrolle.
|
||||
@@ -66,7 +68,9 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt.
|
||||
- 201 PHP-Tests / 716 Assertions unter PHP 8.2.12 und 8.4.22 erfolgreich; zusätzlich 15 JavaScript-Modelltests, der vollständige Playwright-Editortest und die öffentliche Browserprüfung. Abgedeckt sind Migration/Erhaltung, Versionen, ungültige Geräte-/Typ-/Design-/Effektwerte, Vererbung/Reset/Undo, Vorlage-/Galerie-IDs, Uploadvalidierung und Neuencodierung, Metadatenkonflikte, Archivierung sowie sichere Bildquellen.
|
||||
- Neu geprüft (15 Tests): privater Entwurf nach dem Anlegen, verschachtelte Pfade mit gleichem Slug unter verschiedenen Eltern, abgewiesene Adressen (Großbuchstaben, Schrägstrich, leer, reserviert, unbekannter Typ, fehlendes Elternteil) ohne Schreibzugriff, doppelte Adresse, Tiefengrenze, Beiträge ohne Unterseiten, Löschen mit allen Revisionen samt Schutz für Elternseiten und veraltete Versionen, sowie dass die CMS-Routen nicht vom öffentlichen Auffangmuster verschluckt werden. Über HTTP zusätzlich: anonyme Schreibversuche, CSRF für Anlegen und Löschen, Adressableitung aus dem Titel, kein Löschangebot und HTTP 403 für die Editorrolle, erfolgreiches Löschen als Administrator.
|
||||
- Verwaltung geprüft: axe ohne Verstöße auf Anmeldung, Seitenübersicht und Editor (Desktop und Mobil), 201 PHP-Tests grün. **Nicht geprüft:** kein Screenreader, und die Bildschirme Menüs, Formulare, Bereiche, Design, Einstellungen und Fassungen sind nur von Hand angesehen, nicht automatisiert auf Barrierefreiheit geprüft – die Browserprüfung deckt sie nicht ab.
|
||||
- Verwaltung geprüft: axe ohne Verstöße auf Anmeldung, Seitenübersicht und Editor (Desktop und Mobil), alle PHP-Tests grün. **Nicht geprüft:** kein Screenreader, und die Bildschirme Menüs, Formulare, Bereiche, Design, Einstellungen und Fassungen habe ich nur im Browser angesehen – die automatische Barrierefreiheitsprüfung deckt sie nicht ab.
|
||||
- Verwaltung geprüft: axe ohne Verstöße auf **elf** Bildschirmen – Anmeldung, Seitenübersicht, Benutzer, eigenes Konto, Mediathek, Menüs, Formulare, Bereiche, Design, Einstellungen und Editor in Desktop und Mobil. Vorher waren es drei.
|
||||
- Benutzerverwaltung geprüft: 16 Tests, darunter zwei gleichzeitige Sitzungen, die zeigen, dass ein zurückgesetztes Passwort und eine geänderte Rolle die andere Sitzung abmelden, während die handelnde angemeldet bleibt. Acht abgewiesene Kontoanlagen (zu kurzes Passwort, mehr als 72 Bytes, Nullbyte, drei ungültige Namen, Doppelname, unbekannte Rolle) legen kein Konto an. Jeder Schreibweg braucht ein CSRF-Token und ist ohne Administratorrolle 403.
|
||||
- **Nicht geprüft:** kein Screenreader.
|
||||
- Eigener Code geprüft: fünf CSS-Formen werden korrekt auf das Element bezogen, darunter `& .block-text` mit erhaltenem Nachfahren-Leerzeichen. **Zehn abgewiesene CSS-Eingaben** erreichen kein Dokument: Ausbruch mit `}`, fremder Selektor, `@import`, `@media`, `javascript:`-Adresse, protokollrelative Adresse, Backslash-Escape, `expression()`, Eigenschaft ohne Wert und unsinniger Eigenschaftsname. Eigenes CSS erscheint im Seiten-Stylesheet, eigenes Skript **nur** als Datei mit `script-src 'self'` ausschließlich auf dieser Seite; eine Seite ohne eigenes Skript behält `script-src 'none'` und liefert keine Datei. Ein Skript aus einem unveröffentlichten Entwurf wird nicht ausgeliefert. Der Rollenvergleich weist Ändern, Entfernen und Hinzufügen eines Skripts ab und lässt unveränderte Skripte durch.
|
||||
- Formatierter Text geprüft: **dreizehn Fälle** durch den Filter, darunter erlaubte Auszeichnung und Listen, ein `script` samt Inhalt entfernt und der Text danach erhalten, Ereignisattribut entfernt, `class`/`style` entfernt und das Element ausgepackt, `javascript:`-Link zu Text geworden, Bild mit `onerror` entfernt, `iframe` entfernt, unausgeglichene Auszeichnung geschlossen, lokaler Link erhalten, externer Link mit `rel="noopener"`, Klartext maskiert und Umbruch zu `<br>`. Dazu: gefiltert wird beim Speichern (die Datenbank enthält schon die sichere Fassung), ein nach dem Filtern leerer Textblock wird abgewiesen ohne zu schreiben, ab v12 erscheint Auszeichnung als Auszeichnung in einem `div`, vor v12 maskiert in einem `p`, und in einem geteilten Bereich gilt derselbe Filter.
|
||||
- Bereiche geprüft: die eingesetzten Kopf- und Fußbereiche erscheinen als gewöhnliche Blöcke, der Navigationsblock zeigt das Menü, und eine Änderung am Kopfbereich erreicht beide Seiten gleichzeitig. Ein leerer Bereich rendert nichts statt einer kaputten Hülle. Verknüpfte Sektion geprüft: auf zwei Seiten sichtbar, die Seitendokumente enthalten nur die Adresse, eine Änderung wirkt an beiden Stellen, `usedBy` nennt beide Seiten, und nach dem Entfernen bleiben die Seiten heil und der Verweis erhalten. Abgewiesen werden: Hauptüberschrift in einem geteilten Bereich, Sektion mit Selbstbezug, zweiter Kopfbereich und das Löschen von Kopf- oder Fußbereich.
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260912001100 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Account dates for the user administration: created, last login, password changed.';
|
||||
}
|
||||
|
||||
public function isTransactional(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// Columns only. On SQLite an index or foreign key change rebuilds the whole table, and
|
||||
// cms_user is the one table where losing a row locks everybody out.
|
||||
$users = $schema->getTable('cms_user');
|
||||
$users->addColumn('created_at', 'datetime_immutable', ['notnull' => false]);
|
||||
// Not a security control, just the answer to "is this account still in use?".
|
||||
$users->addColumn('last_login_at', 'datetime_immutable', ['notnull' => false]);
|
||||
// Shown in the list so an old password is visible as such.
|
||||
$users->addColumn('password_changed_at', 'datetime_immutable', ['notnull' => false]);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->throwIrreversibleMigrationException('Contains account data. Restore the verified database backup with matching code.');
|
||||
}
|
||||
}
|
||||
@@ -339,6 +339,38 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
.sheet-actions { display: flex; gap: 8px; flex-wrap: wrap; align-items: center }
|
||||
.sheet-actions form { margin: 0 }
|
||||
|
||||
/* ---- the media library, rendered as a page rather than fetched ------------------------- */
|
||||
|
||||
.library-grid {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
.library-tile {
|
||||
background: var(--paper);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 11px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.library-tile > a { display: block; background: #eef1ed; line-height: 0 }
|
||||
.library-tile img { width: 100%; height: 160px; object-fit: cover }
|
||||
.library-body { padding: 12px 14px; display: grid; gap: 3px; flex: 1 }
|
||||
.library-body strong { font-size: 13px; overflow-wrap: anywhere }
|
||||
.library-body span { font-size: 11px }
|
||||
.library-body code { font-size: 10px; color: var(--muted); overflow-wrap: anywhere; background: #f2f4f0; padding: 2px 5px; border-radius: 4px; justify-self: start }
|
||||
.library-tile .sheet { margin-top: auto }
|
||||
.library-tile .sheet > summary { padding: 9px 14px }
|
||||
.library-tile .sheet-body { padding: 2px 14px 18px }
|
||||
.library-tile .sheet-body label:first-of-type { margin-top: 12px }
|
||||
|
||||
.pager { display: flex; align-items: center; justify-content: center; gap: 16px; flex-wrap: wrap; margin-top: 10px }
|
||||
.pager .muted { font-size: 12px }
|
||||
|
||||
/* ---- tables ---------------------------------------------------------------------------- */
|
||||
|
||||
/* A table scrolls sideways instead of pushing the card out of the screen. As a block it also
|
||||
|
||||
@@ -346,6 +346,32 @@ try {
|
||||
await page.waitForFunction(() => document.querySelector('#property-title')?.value === 'Parallel gespeicherter Titel');
|
||||
checks.push({ name: 'conflict retains local edits, JSON export, explicit server reload', passed: true });
|
||||
|
||||
// Every management screen, not just the page list: they carry tables, disclosures and now the
|
||||
// media grid, and none of that was ever checked automatically before.
|
||||
for (const [name, path] of [
|
||||
['user administration', '/admin/benutzer'],
|
||||
['own account', '/admin/konto'],
|
||||
['media library', '/admin/media'],
|
||||
['menus', '/admin/menus'],
|
||||
['forms', '/admin/forms'],
|
||||
['areas', '/admin/partials'],
|
||||
['design', '/admin/theme'],
|
||||
['settings', '/admin/settings'],
|
||||
]) {
|
||||
await page.goto(origin + path);
|
||||
assert.equal(new URL(page.url()).pathname, path, path);
|
||||
await axe(name);
|
||||
}
|
||||
// The media screen renders its images itself, so the library has to be visible without the dialog.
|
||||
await page.goto(origin + '/admin/media');
|
||||
assert.equal(await page.locator('.library-tile').count(), 2);
|
||||
await page.screenshot({ path: 'reports/editor/media-screen.png', fullPage: true });
|
||||
checks.push({ name: 'management screens reachable, media library rendered as a page', passed: true });
|
||||
|
||||
// Back into the editor. The draft differs from the published state by now, so wait for the
|
||||
// structure rather than for a particular save status.
|
||||
await page.goto(origin + '/admin/pages/test-home');
|
||||
await page.locator('#structure [data-block-id="headline"]').waitFor();
|
||||
await page.locator('#structure [data-block-id="headline"]').click();
|
||||
await page.getByRole('button', { name: 'Design', exact: true }).click();
|
||||
await page.locator('#preview-status').filter({ hasText: 'Zum Auswählen' }).waitFor();
|
||||
|
||||
@@ -8,6 +8,9 @@ use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
|
||||
final class MediaLibrary
|
||||
{
|
||||
/** Enough to fill a screen, few enough that the browser does not decode a hundred images. */
|
||||
public const PER_PAGE = 24;
|
||||
|
||||
public function __construct(private Connection $db, private string $directory)
|
||||
{
|
||||
}
|
||||
@@ -17,6 +20,36 @@ final class MediaLibrary
|
||||
return array_map($this->present(...), $this->db->fetchAllAssociative('SELECT * FROM media ORDER BY created_at DESC, id DESC'));
|
||||
}
|
||||
|
||||
/**
|
||||
* One page of the library, newest first.
|
||||
*
|
||||
* The media screen renders the images itself instead of fetching them, so it has to ask for a
|
||||
* slice: a library of a few hundred images would otherwise be one very long page and a lot of
|
||||
* thumbnails at once.
|
||||
*
|
||||
* @return array{items: list<array<string, mixed>>, total: int, page: int, pages: int, perPage: int}
|
||||
*/
|
||||
public function page(int $page, bool $withArchived = false, int $perPage = self::PER_PAGE): array
|
||||
{
|
||||
$where = $withArchived ? '' : ' WHERE archived = 0';
|
||||
$total = (int) $this->db->fetchOne('SELECT COUNT(*) FROM media'.$where);
|
||||
$perPage = max(1, min(96, $perPage));
|
||||
$pages = max(1, (int) ceil($total / $perPage));
|
||||
$page = max(1, min($pages, $page));
|
||||
$items = $this->db->fetchAllAssociative(
|
||||
'SELECT * FROM media'.$where.' ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?',
|
||||
[$perPage, ($page - 1) * $perPage],
|
||||
);
|
||||
|
||||
return [
|
||||
'items' => array_map($this->present(...), $items),
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
'pages' => $pages,
|
||||
'perPage' => $perPage,
|
||||
];
|
||||
}
|
||||
|
||||
public function find(string $id): ?array
|
||||
{
|
||||
$row = $this->db->fetchAssociative('SELECT * FROM media WHERE id = ?', [$id]);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domain;
|
||||
|
||||
/** An account change the CMS refuses to make, with a message meant for the administrator. */
|
||||
final class InvalidAccount extends \RuntimeException
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\EventSubscriber;
|
||||
|
||||
use App\Domain\CmsUser;
|
||||
use App\Security\UserDirectory;
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
use Symfony\Component\Security\Http\Event\LoginSuccessEvent;
|
||||
|
||||
/**
|
||||
* Records when an account was last used. Not a security control - it answers the question whether
|
||||
* an account is still in use before somebody deletes it. The column is outside everything
|
||||
* CmsUser::isEqualTo compares, so writing it does not end the session that just started.
|
||||
*/
|
||||
final class LoginSubscriber implements EventSubscriberInterface
|
||||
{
|
||||
public function __construct(private UserDirectory $users)
|
||||
{
|
||||
}
|
||||
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
return [LoginSuccessEvent::class => 'onLogin'];
|
||||
}
|
||||
|
||||
public function onLogin(LoginSuccessEvent $event): void
|
||||
{
|
||||
$user = $event->getUser();
|
||||
if ($user instanceof CmsUser) {
|
||||
$this->users->recordLogin($user->getUserIdentifier());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
<?php
|
||||
|
||||
namespace App\Security;
|
||||
|
||||
use App\Domain\CmsUser;
|
||||
use App\Domain\InvalidAccount;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
|
||||
/**
|
||||
* The accounts, as the administration sees them.
|
||||
*
|
||||
* Two rules are enforced here rather than in the controller, because they are the ones that lock
|
||||
* people out of their own website: nobody can delete or demote their own account. Together with
|
||||
* the administrator role that every change requires, that keeps at least one administrator alive -
|
||||
* removing or demoting an administrator takes a second administrator, who then remains. The
|
||||
* console command stays as the way in when no account works any more.
|
||||
*
|
||||
* Changing a password or a role ends every session of that account. That is not extra work: the
|
||||
* session stores the password hash and the roles, CmsUser::isEqualTo compares them against the
|
||||
* database on the next request, and a difference logs the session out. The one session that
|
||||
* survives is the one that made its own change, because the controller hands it the fresh user.
|
||||
*/
|
||||
final class UserDirectory
|
||||
{
|
||||
/** The roles an account can have, in descending order. ROLE_ADMIN implies the other two. */
|
||||
public const ROLES = [
|
||||
'ROLE_ADMIN' => 'Administrator',
|
||||
'ROLE_PUBLISHER' => 'Veröffentlicher',
|
||||
'ROLE_EDITOR' => 'Redakteur',
|
||||
];
|
||||
|
||||
public const ROLE_HELP = [
|
||||
'ROLE_ADMIN' => 'Darf alles, einschließlich Benutzer, Design, Einstellungen, eigene Skripte und endgültiges Löschen.',
|
||||
'ROLE_PUBLISHER' => 'Darf bearbeiten und veröffentlichen, aber keine Benutzer verwalten und keine Skripte setzen.',
|
||||
'ROLE_EDITOR' => 'Darf Entwürfe bearbeiten und speichern, aber nicht veröffentlichen.',
|
||||
];
|
||||
|
||||
/** Bcrypt silently ignores everything after 72 bytes, so that is the honest upper limit. */
|
||||
public const MIN_PASSWORD = 14;
|
||||
public const MAX_PASSWORD = 72;
|
||||
|
||||
public function __construct(private Connection $db, private UserPasswordHasherInterface $hasher)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{id: string, username: string, role: string, roleName: string,
|
||||
* createdAt: ?string, lastLoginAt: ?string, passwordChangedAt: ?string}>
|
||||
*/
|
||||
public function all(): array
|
||||
{
|
||||
return array_map(
|
||||
fn (array $row): array => [
|
||||
'id' => $row['id'],
|
||||
'username' => $row['username'],
|
||||
'role' => $role = $this->roleOf($row['roles']),
|
||||
'roleName' => self::ROLES[$role],
|
||||
'createdAt' => $row['created_at'],
|
||||
'lastLoginAt' => $row['last_login_at'],
|
||||
'passwordChangedAt' => $row['password_changed_at'],
|
||||
],
|
||||
$this->db->fetchAllAssociative('SELECT * FROM cms_user ORDER BY username'),
|
||||
);
|
||||
}
|
||||
|
||||
public function create(string $username, string $password, string $role): string
|
||||
{
|
||||
$username = $this->checkUsername($username);
|
||||
$this->checkPassword($password);
|
||||
$this->checkRole($role);
|
||||
if (false !== $this->db->fetchOne('SELECT id FROM cms_user WHERE username = ?', [$username])) {
|
||||
throw new InvalidAccount(sprintf('Der Benutzername „%s“ ist schon vergeben.', $username));
|
||||
}
|
||||
$id = bin2hex(random_bytes(16));
|
||||
$now = gmdate('Y-m-d H:i:s');
|
||||
$this->db->insert('cms_user', [
|
||||
'id' => $id,
|
||||
'username' => $username,
|
||||
'password_hash' => $this->hash($username, $password),
|
||||
'roles' => json_encode([$role], JSON_THROW_ON_ERROR),
|
||||
'created_at' => $now,
|
||||
'password_changed_at' => $now,
|
||||
]);
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
public function changeRole(string $id, string $role, CmsUser $actor): void
|
||||
{
|
||||
$this->checkRole($role);
|
||||
$account = $this->require($id);
|
||||
if ($account['id'] === $actor->id) {
|
||||
throw new InvalidAccount('Die eigene Rolle lässt sich hier nicht ändern. Das soll ein zweiter Administrator tun.');
|
||||
}
|
||||
$this->db->executeStatement('UPDATE cms_user SET roles = ? WHERE id = ?', [json_encode([$role], JSON_THROW_ON_ERROR), $id]);
|
||||
}
|
||||
|
||||
/** An administrator setting someone else's password. Ends every session of that account. */
|
||||
public function setPassword(string $id, string $password): void
|
||||
{
|
||||
$account = $this->require($id);
|
||||
$this->checkPassword($password);
|
||||
$this->db->executeStatement(
|
||||
'UPDATE cms_user SET password_hash = ?, password_changed_at = ? WHERE id = ?',
|
||||
[$this->hash($account['username'], $password), gmdate('Y-m-d H:i:s'), $id],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Somebody changing their own password. The current one has to be right, so a forgotten open
|
||||
* session cannot be used to take the account over.
|
||||
*/
|
||||
public function changeOwnPassword(CmsUser $user, string $current, string $password): CmsUser
|
||||
{
|
||||
if (!$this->hasher->isPasswordValid($user, $current)) {
|
||||
throw new InvalidAccount('Das aktuelle Passwort stimmt nicht.');
|
||||
}
|
||||
if (hash_equals($current, $password)) {
|
||||
throw new InvalidAccount('Das neue Passwort ist das alte.');
|
||||
}
|
||||
$this->checkPassword($password);
|
||||
$hash = $this->hash($user->getUserIdentifier(), $password);
|
||||
$this->db->executeStatement(
|
||||
'UPDATE cms_user SET password_hash = ?, password_changed_at = ? WHERE id = ?',
|
||||
[$hash, gmdate('Y-m-d H:i:s'), $user->id],
|
||||
);
|
||||
|
||||
// The caller puts this user into the session, so this one session stays signed in while
|
||||
// every other session of the account is logged out on its next request.
|
||||
return new CmsUser($user->id, $user->getUserIdentifier(), $hash, $user->getRoles());
|
||||
}
|
||||
|
||||
public function delete(string $id, CmsUser $actor): void
|
||||
{
|
||||
$account = $this->require($id);
|
||||
if ($account['id'] === $actor->id) {
|
||||
throw new InvalidAccount('Das eigene Konto lässt sich nicht löschen. Das soll ein zweiter Administrator tun.');
|
||||
}
|
||||
$this->db->executeStatement('DELETE FROM cms_user WHERE id = ?', [$id]);
|
||||
}
|
||||
|
||||
public function recordLogin(string $username): void
|
||||
{
|
||||
$this->db->executeStatement(
|
||||
'UPDATE cms_user SET last_login_at = ? WHERE username = ?',
|
||||
[gmdate('Y-m-d H:i:s'), strtolower(trim($username))],
|
||||
);
|
||||
}
|
||||
|
||||
public function checkUsername(string $username): string
|
||||
{
|
||||
$username = strtolower(trim($username));
|
||||
if (1 !== preg_match('/^[a-z0-9][a-z0-9._-]{2,99}$/D', $username)) {
|
||||
throw new InvalidAccount('Benutzername: 3 bis 100 Zeichen, Kleinbuchstaben, Ziffern, Punkt, Unterstrich oder Bindestrich, beginnend mit Buchstabe oder Ziffer.');
|
||||
}
|
||||
|
||||
return $username;
|
||||
}
|
||||
|
||||
public function checkPassword(string $password): void
|
||||
{
|
||||
if (str_contains($password, "\0")) {
|
||||
throw new InvalidAccount('Das Passwort darf kein Nullbyte enthalten.');
|
||||
}
|
||||
$length = strlen($password);
|
||||
if ($length < self::MIN_PASSWORD || $length > self::MAX_PASSWORD) {
|
||||
throw new InvalidAccount(sprintf('Das Passwort braucht mindestens %d Zeichen und darf höchstens %d Bytes lang sein.', self::MIN_PASSWORD, self::MAX_PASSWORD));
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array{id: string, username: string, role: string} */
|
||||
private function require(string $id): array
|
||||
{
|
||||
$row = $this->db->fetchAssociative('SELECT id, username, roles FROM cms_user WHERE id = ?', [$id]);
|
||||
if (false === $row) {
|
||||
throw new InvalidAccount('Dieses Konto gibt es nicht mehr.');
|
||||
}
|
||||
|
||||
return ['id' => $row['id'], 'username' => $row['username'], 'role' => $this->roleOf($row['roles'])];
|
||||
}
|
||||
|
||||
private function checkRole(string $role): void
|
||||
{
|
||||
if (!isset(self::ROLES[$role])) {
|
||||
throw new InvalidAccount('Unbekannte Rolle.');
|
||||
}
|
||||
}
|
||||
|
||||
/** The stored list can hold several roles; the highest one is what the administration shows. */
|
||||
private function roleOf(string $stored): string
|
||||
{
|
||||
$roles = json_decode($stored, true, 512, JSON_THROW_ON_ERROR);
|
||||
foreach (array_keys(self::ROLES) as $role) {
|
||||
if (in_array($role, (array) $roles, true)) {
|
||||
return $role;
|
||||
}
|
||||
}
|
||||
|
||||
return 'ROLE_EDITOR';
|
||||
}
|
||||
|
||||
private function hash(string $username, string $password): string
|
||||
{
|
||||
return $this->hasher->hashPassword(new CmsUser('', $username, '', ['ROLE_EDITOR']), $password);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
{% extends 'admin/shell.html.twig' %}
|
||||
{% set nav = 'account' %}
|
||||
{% block title %}Mein Konto{% endblock %}
|
||||
{% block heading %}Mein Konto{% endblock %}
|
||||
{% block lead %}Dein Passwort kannst du hier selbst ändern. Deine Rolle kann nur ein Administrator ändern.{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head"><h2>{{ app.user.userIdentifier }}</h2><span class="muted">{{ account ? roles[account.role] : 'Rolle unbekannt' }}</span></div>
|
||||
<div class="card-body">
|
||||
{% if account %}
|
||||
<p class="muted">{{ roleHelp[account.role] }}</p>
|
||||
<p class="field-help">
|
||||
{% if account.lastLoginAt %}Zuletzt angemeldet am {{ account.lastLoginAt|date('d.m.Y H:i') }} UTC{% else %}Dies ist deine erste Sitzung{% endif %}.
|
||||
{% if account.passwordChangedAt %} Passwort zuletzt geändert am {{ account.passwordChangedAt|date('d.m.Y') }}.{% endif %}
|
||||
{% if account.createdAt %} Konto angelegt am {{ account.createdAt|date('d.m.Y') }}.{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head"><h2>Passwort ändern</h2><span class="muted">Andere Sitzungen werden abgemeldet</span></div>
|
||||
<form action="{{ path('admin_account_password') }}" method="post" class="card-body">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('account') }}">
|
||||
<div class="fields">
|
||||
<div>
|
||||
<label for="current">Aktuelles Passwort</label>
|
||||
<input id="current" name="current" type="password" required autocomplete="current-password" maxlength="{{ maxPassword }}">
|
||||
<p class="field-help">Nötig, damit eine vergessene offene Sitzung nicht ausreicht, um dein Konto zu übernehmen.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="password">Neues Passwort</label>
|
||||
<input id="password" name="password" type="password" required autocomplete="new-password" minlength="{{ minPassword }}" maxlength="{{ maxPassword }}">
|
||||
<p class="field-help">Mindestens {{ minPassword }} Zeichen, höchstens {{ maxPassword }} Bytes. Diese Sitzung bleibt angemeldet, alle anderen dieses Kontos nicht.</p>
|
||||
</div>
|
||||
</div>
|
||||
<button class="primary" type="submit">Passwort ändern</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<p class="footnote">Kant CMS · Arbeitsstand</p>
|
||||
{% endblock %}
|
||||
@@ -1,16 +1,82 @@
|
||||
{% extends 'admin/shell.html.twig' %}
|
||||
{% set nav = 'media' %}
|
||||
{% block title %}Mediathek{% endblock %}
|
||||
{% block styles %}<link rel="stylesheet" href="/assets/editor.css">{% endblock %}
|
||||
{% block scripts %}<script type="module" src="/assets/media.js"></script>{% endblock %}
|
||||
{% block heading %}Mediathek{% endblock %}
|
||||
{% block lead %}Bilder hochladen, Angaben pflegen und in deinen Seiten wiederverwenden. Dieselbe Mediathek öffnet sich auch im Editor, wenn du ein Bild auswählst.{% endblock %}
|
||||
{% block actions %}<button id="open-media-library" class="primary">Mediathek öffnen</button>{% endblock %}
|
||||
{% block lead %}Alle Bilder, neueste zuerst. Beim Hochladen wird jedes Bild in WebP umgewandelt und in mehreren Breiten abgelegt; die Seite liefert dann die passende aus.{% endblock %}
|
||||
{% block content %}
|
||||
<div class="card">
|
||||
<div class="card-head"><h2>Bilder verwalten</h2><span class="muted">Hochladen, benennen, Alternativtext pflegen</span></div>
|
||||
<div class="card-body">
|
||||
<p class="muted">Beim Hochladen wird jedes Bild in WebP umgewandelt und in mehreren Größen abgelegt; die Seite liefert dann die passende aus. Ein Alternativtext beschreibt, was zu sehen ist – er ist das, was Menschen mit Screenreader vom Bild haben.</p>
|
||||
</div>
|
||||
|
||||
{% if is_granted('ROLE_EDITOR') %}
|
||||
<form action="{{ path('admin_media_upload') }}" method="post" enctype="multipart/form-data" class="card card-body">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('media-form') }}">
|
||||
<input type="hidden" name="seite" value="{{ library.page }}">
|
||||
<input type="hidden" name="archiv" value="{{ archived ? '1' : '' }}">
|
||||
<label for="files">Bilder hochladen</label>
|
||||
<input id="files" name="files[]" type="file" accept="image/jpeg,image/png,image/webp" multiple required>
|
||||
<p class="field-help">JPEG, PNG oder WebP, höchstens 8 MB je Bild. Mehrere Dateien sind möglich; das Hosting kann ein kleineres Limit setzen.</p>
|
||||
<button class="primary" type="submit">Hochladen</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
<div class="section-head">
|
||||
<h2>{{ archived ? 'Alle Bilder einschließlich Archiv' : 'Bilder' }} <span class="count">{{ library.total }}</span></h2>
|
||||
<span class="muted">
|
||||
{% if archived %}<a href="{{ path('admin_media') }}">Archivierte ausblenden</a>{% else %}<a href="{{ path('admin_media', {archiv: 1}) }}">Archivierte anzeigen</a>{% endif %}
|
||||
{% if library.pages > 1 %} · Seite {{ library.page }} von {{ library.pages }}{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{% if library.items is empty %}
|
||||
<div class="empty">
|
||||
<h3>{{ archived ? 'Keine Bilder vorhanden.' : 'Keine Bilder in der Mediathek.' }}</h3>
|
||||
<p>{{ is_granted('ROLE_EDITOR') ? 'Lade oben dein erstes Bild hoch.' : 'Ein Redakteur kann Bilder hochladen.' }}</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<ul class="library-grid">
|
||||
{% for item in library.items %}
|
||||
<li class="library-tile">
|
||||
{# The smallest variant is the thumbnail; the largest opens in a new tab. #}
|
||||
<a href="{{ (item.variants|last).src }}" target="_blank" rel="noopener" aria-label="{{ item.title }} in Originalgröße öffnen">
|
||||
<img src="{{ (item.variants|first).src }}" alt="" loading="lazy" width="{{ (item.variants|first).width }}" height="{{ (item.variants|first).height }}">
|
||||
</a>
|
||||
<div class="library-body">
|
||||
<strong>{{ item.title }}</strong>
|
||||
<span class="muted">{{ (item.variants|last).width }} × {{ (item.variants|last).height }} px{% if item.archived %} · archiviert{% endif %}</span>
|
||||
<span class="muted">{{ item.alt ? 'Alternativtext gesetzt' : 'Kein Alternativtext' }}</span>
|
||||
<code>{{ item.id }}</code>
|
||||
</div>
|
||||
{% if is_granted('ROLE_EDITOR') %}
|
||||
<details class="sheet">
|
||||
<summary aria-label="Angaben zu {{ item.title }}">Angaben</summary>
|
||||
<div class="sheet-body">
|
||||
<form action="{{ path('admin_media_save', {id: item.id}) }}" method="post">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('media-form') }}">
|
||||
<input type="hidden" name="version" value="{{ item.version }}">
|
||||
<input type="hidden" name="seite" value="{{ library.page }}">
|
||||
<input type="hidden" name="archiv" value="{{ archived ? '1' : '' }}">
|
||||
<label for="title-{{ item.id }}">Bildtitel</label>
|
||||
<input id="title-{{ item.id }}" name="title" maxlength="160" required value="{{ item.title }}">
|
||||
<label for="alt-{{ item.id }}">Alternativtext</label>
|
||||
<textarea id="alt-{{ item.id }}" name="alt" maxlength="300" rows="2">{{ item.alt }}</textarea>
|
||||
<p class="field-help">Beschreibt, was zu sehen ist. Das ist, was Menschen mit Screenreader vom Bild haben. Bei rein dekorativen Bildern leer lassen.</p>
|
||||
<label class="check-label" for="archived-{{ item.id }}"><input id="archived-{{ item.id }}" name="archived" type="checkbox" value="1"{{ item.archived ? ' checked' : '' }}> Archiviert</label>
|
||||
<p class="field-help">Archivierte Bilder erscheinen nicht mehr in der Auswahl, bleiben aber ausgeliefert – eine Seite, die das Bild verwendet, verliert es nicht.</p>
|
||||
<button class="primary" type="submit">Angaben speichern</button>
|
||||
</form>
|
||||
</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
{% if library.pages > 1 %}
|
||||
<nav class="pager" aria-label="Seiten der Mediathek">
|
||||
{% if library.page > 1 %}<a class="secondary" href="{{ path('admin_media', {seite: library.page - 1, archiv: archived ? 1 : null}) }}" rel="prev"><span aria-hidden="true">←</span> Neuere</a>{% endif %}
|
||||
<span class="muted">Seite {{ library.page }} von {{ library.pages }} · {{ library.total }} Bilder</span>
|
||||
{% if library.page < library.pages %}<a class="secondary" href="{{ path('admin_media', {seite: library.page + 1, archiv: archived ? 1 : null}) }}" rel="next">Ältere <span aria-hidden="true">→</span></a>{% endif %}
|
||||
</nav>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<p class="footnote">Kant CMS · Arbeitsstand</p>
|
||||
{% endblock %}
|
||||
@@ -23,12 +23,13 @@
|
||||
<ul class="nav-list" aria-labelledby="nav-site">
|
||||
<li><a class="nav-item" href="{{ path('admin_theme') }}"{{ nav|default('') == 'theme' ? ' aria-current="page"' : '' }}><span class="nav-icon" aria-hidden="true">◐</span>Design</a></li>
|
||||
<li><a class="nav-item" href="{{ path('admin_settings') }}"{{ nav|default('') == 'settings' ? ' aria-current="page"' : '' }}><span class="nav-icon" aria-hidden="true">⚙</span>Einstellungen</a></li>
|
||||
{% if is_granted('ROLE_ADMIN') %}<li><a class="nav-item" href="{{ path('admin_users') }}"{{ nav|default('') == 'users' ? ' aria-current="page"' : '' }}><span class="nav-icon" aria-hidden="true">☺</span>Benutzer</a></li>{% endif %}
|
||||
<li><a class="nav-item nav-out" href="/">Website ansehen</a></li>
|
||||
<li><a class="nav-item nav-out" href="/beitraege">Beitragsübersicht</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
<div class="shell-foot">
|
||||
<p class="shell-user"><span class="shell-avatar" aria-hidden="true">{{ app.user.userIdentifier|first|upper }}</span><strong>{{ app.user.userIdentifier }}</strong></p>
|
||||
<a class="shell-user" href="{{ path('admin_account') }}"{{ nav|default('') == 'account' ? ' aria-current="page"' : '' }}><span class="shell-avatar" aria-hidden="true">{{ app.user.userIdentifier|first|upper }}</span><strong>{{ app.user.userIdentifier }}</strong></a>
|
||||
<form action="{{ path('admin_logout') }}" method="post">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('logout') }}">
|
||||
<button class="subtle" type="submit">Abmelden</button>
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
{% extends 'admin/shell.html.twig' %}
|
||||
{% set nav = 'users' %}
|
||||
{% block title %}Benutzer{% endblock %}
|
||||
{% block heading %}Benutzer{% endblock %}
|
||||
{% block lead %}Jedes Konto hat genau eine Rolle. Ein Passwort oder eine Rolle zu ändern meldet alle offenen Sitzungen dieses Kontos ab – das ist der Weg, jemanden sofort auszusperren.{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<details class="card"{% if app.flashes is not empty %} open{% endif %}>
|
||||
<summary>Neues Konto anlegen</summary>
|
||||
<form action="{{ path('admin_user_create') }}" method="post" class="card-body">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('users') }}">
|
||||
<div class="fields">
|
||||
<div>
|
||||
<label for="new-username">Benutzername</label>
|
||||
<input id="new-username" name="username" maxlength="100" required autocomplete="off" pattern="[a-z0-9][a-z0-9._-]{2,99}">
|
||||
<p class="field-help">3 bis 100 Zeichen: Kleinbuchstaben, Ziffern, Punkt, Unterstrich, Bindestrich.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="new-password">Passwort</label>
|
||||
<input id="new-password" name="password" type="password" required autocomplete="new-password" minlength="{{ minPassword }}" maxlength="{{ maxPassword }}">
|
||||
<p class="field-help">Mindestens {{ minPassword }} Zeichen, höchstens {{ maxPassword }} Bytes. Gib es der Person auf einem anderen Weg als per E-Mail weiter; sie kann es unter „Mein Konto“ selbst ändern.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="new-role">Rolle</label>
|
||||
<select id="new-role" name="role">
|
||||
{% for value, label in roles %}<option value="{{ value }}"{{ value == 'ROLE_EDITOR' ? ' selected' : '' }}>{{ label }}</option>{% endfor %}
|
||||
</select>
|
||||
<p class="field-help">{% for value, help in roleHelp %}<strong>{{ roles[value] }}:</strong> {{ help }}{% if not loop.last %}<br>{% endif %}{% endfor %}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button class="primary" type="submit">Konto anlegen</button>
|
||||
</form>
|
||||
</details>
|
||||
|
||||
<div class="section-head"><h2>Konten <span class="count">{{ users|length }}</span></h2><span class="muted">Zeiten in UTC.</span></div>
|
||||
<div class="rows">
|
||||
{% for account in users %}
|
||||
<article class="row row-stack">
|
||||
<div class="row-line">
|
||||
<span class="row-icon" aria-hidden="true">☺</span>
|
||||
<div class="row-main">
|
||||
<h3>{{ account.username }}{% if account.id == me %} <span class="pill">dein Konto</span>{% endif %}</h3>
|
||||
<p>{{ account.roleName }}
|
||||
· {% if account.lastLoginAt %}zuletzt angemeldet {{ account.lastLoginAt|date('d.m.Y H:i') }}{% else %}noch nie angemeldet{% endif %}
|
||||
{% if account.passwordChangedAt %} · Passwort vom {{ account.passwordChangedAt|date('d.m.Y') }}{% endif %}
|
||||
{% if account.createdAt %} · angelegt {{ account.createdAt|date('d.m.Y') }}{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
<span class="pill {{ 'ROLE_ADMIN' == account.role ? 'pill-live' : ('ROLE_PUBLISHER' == account.role ? 'pill-draft' : 'pill-off') }}">{{ account.roleName }}</span>
|
||||
</div>
|
||||
<details class="sheet">
|
||||
<summary aria-label="Einstellungen von {{ account.username }}">Einstellungen</summary>
|
||||
<div class="sheet-body">
|
||||
<div class="sheet-part">
|
||||
<h4>Rolle</h4>
|
||||
{% if account.id == me %}
|
||||
<p>Die eigene Rolle lässt sich hier nicht ändern. Das soll ein zweiter Administrator tun – sonst kann sich der letzte Administrator selbst die Rechte nehmen.</p>
|
||||
{% else %}
|
||||
<form action="{{ path('admin_user_role', {id: account.id}) }}" method="post">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('users') }}">
|
||||
<div class="field">
|
||||
<label for="role-{{ account.id }}">Rolle von {{ account.username }}</label>
|
||||
<select id="role-{{ account.id }}" name="role">
|
||||
{% for value, label in roles %}<option value="{{ value }}"{{ value == account.role ? ' selected' : '' }}>{{ label }}</option>{% endfor %}
|
||||
</select>
|
||||
<p class="field-help">Offene Sitzungen dieses Kontos werden dabei abgemeldet.</p>
|
||||
</div>
|
||||
<button class="secondary" type="submit">Rolle ändern</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="sheet-part">
|
||||
<h4>Passwort setzen</h4>
|
||||
<p>Setzt ein neues Passwort, ohne das alte zu kennen – für den Fall, dass jemand nicht mehr hineinkommt. Alle offenen Sitzungen dieses Kontos werden abgemeldet.</p>
|
||||
<form action="{{ path('admin_user_password', {id: account.id}) }}" method="post">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('users') }}">
|
||||
<div class="field">
|
||||
<label for="password-{{ account.id }}">Neues Passwort für {{ account.username }}</label>
|
||||
<input id="password-{{ account.id }}" name="password" type="password" required autocomplete="new-password" minlength="{{ minPassword }}" maxlength="{{ maxPassword }}">
|
||||
</div>
|
||||
<button class="secondary" type="submit">Passwort setzen</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% if account.id != me %}
|
||||
<div class="sheet-part">
|
||||
<h4>Konto löschen</h4>
|
||||
<p>Entfernt „{{ account.username }}“. Gespeicherte Inhalte bleiben unberührt – Seiten und Fassungen hängen nicht am Konto. Das eigene Konto lässt sich nicht löschen; deshalb bleibt immer mindestens ein Administrator übrig.</p>
|
||||
<form action="{{ path('admin_user_delete', {id: account.id}) }}" method="post">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('users') }}">
|
||||
<button class="subtle danger-text" type="submit">Konto löschen</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</details>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head"><h2>Wenn kein Konto mehr funktioniert</h2><span class="muted">Zugang über die Konsole</span></div>
|
||||
<div class="card-body">
|
||||
<p class="muted">Auf dem Server legt <code>php bin/console cms:user:create name --role=admin</code> ein Konto an; das Passwort wird verdeckt abgefragt und ein vorhandenes Konto nie überschrieben. Das ist der Weg zurück, wenn niemand mehr hineinkommt – er braucht Dateizugriff auf den Server und lässt sich hier nicht ersetzen.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="footnote">Kant CMS · Arbeitsstand</p>
|
||||
{% endblock %}
|
||||
+1
-27
@@ -21,19 +21,8 @@ use App\Domain\StaleWrite;
|
||||
use App\Infrastructure\ConnectionFactory;
|
||||
use App\Kernel;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use DoctrineMigrations\Version20260912000100;
|
||||
use DoctrineMigrations\Version20260912000300;
|
||||
use DoctrineMigrations\Version20260912000400;
|
||||
use DoctrineMigrations\Version20260912000500;
|
||||
use DoctrineMigrations\Version20260912000600;
|
||||
use DoctrineMigrations\Version20260912000700;
|
||||
use DoctrineMigrations\Version20260912000800;
|
||||
use DoctrineMigrations\Version20260912000900;
|
||||
use DoctrineMigrations\Version20260912001000;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\NullLogger;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
@@ -53,22 +42,7 @@ final class CmsTest extends TestCase
|
||||
{
|
||||
$this->path = tempnam(sys_get_temp_dir(), 'kant-test-');
|
||||
$this->db = ConnectionFactory::create('sqlite:///'.str_replace('\\', '/', $this->path));
|
||||
$schema = new Schema();
|
||||
(new Version20260912000100($this->db, new NullLogger()))->up($schema);
|
||||
(new Version20260912000300($this->db, new NullLogger()))->up($schema);
|
||||
(new Version20260912000400($this->db, new NullLogger()))->up($schema);
|
||||
(new Version20260912000500($this->db, new NullLogger()))->up($schema);
|
||||
(new Version20260912000600($this->db, new NullLogger()))->up($schema);
|
||||
(new Version20260912000700($this->db, new NullLogger()))->up($schema);
|
||||
(new Version20260912000800($this->db, new NullLogger()))->up($schema);
|
||||
(new Version20260912000900($this->db, new NullLogger()))->up($schema);
|
||||
(new Version20260912001000($this->db, new NullLogger()))->up($schema);
|
||||
foreach ($schema->toSql($this->db->getDatabasePlatform()) as $sql) {
|
||||
$this->db->executeStatement($sql);
|
||||
}
|
||||
Version20260912000400::applyIndexes($this->db);
|
||||
Version20260912000600::applyIndexes($this->db);
|
||||
Version20260912000900::applyIndexes($this->db);
|
||||
TestSchema::build($this->db);
|
||||
$this->db->insert('website', ['id' => 'default', 'name' => 'Kant CMS', 'default_locale' => 'de', 'theme' => '{}']);
|
||||
foreach (['page-1' => 'home', 'page-2' => 'private'] as $id => $slug) {
|
||||
$this->db->insert('page', ['id' => $id, 'slug' => $slug, 'path' => $slug, 'type' => 'page', 'locale' => 'de', 'website_id' => 'default', 'version' => 0, 'created_at' => gmdate('Y-m-d H:i:s')]);
|
||||
|
||||
@@ -13,19 +13,7 @@ use App\Infrastructure\ConnectionFactory;
|
||||
use App\Kernel;
|
||||
use App\Security\UserProvider;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use DoctrineMigrations\Version20260912000100;
|
||||
use DoctrineMigrations\Version20260912000200;
|
||||
use DoctrineMigrations\Version20260912000300;
|
||||
use DoctrineMigrations\Version20260912000400;
|
||||
use DoctrineMigrations\Version20260912000500;
|
||||
use DoctrineMigrations\Version20260912000600;
|
||||
use DoctrineMigrations\Version20260912000700;
|
||||
use DoctrineMigrations\Version20260912000800;
|
||||
use DoctrineMigrations\Version20260912000900;
|
||||
use DoctrineMigrations\Version20260912001000;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use Psr\Log\NullLogger;
|
||||
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
@@ -50,23 +38,7 @@ final class EditorSecurityTest extends WebTestCase
|
||||
$_SERVER[$key] = $_ENV[$key] = $value;
|
||||
}
|
||||
$this->db = ConnectionFactory::create($url);
|
||||
$schema = new Schema();
|
||||
(new Version20260912000100($this->db, new NullLogger()))->up($schema);
|
||||
(new Version20260912000200($this->db, new NullLogger()))->up($schema);
|
||||
(new Version20260912000300($this->db, new NullLogger()))->up($schema);
|
||||
(new Version20260912000400($this->db, new NullLogger()))->up($schema);
|
||||
(new Version20260912000500($this->db, new NullLogger()))->up($schema);
|
||||
(new Version20260912000600($this->db, new NullLogger()))->up($schema);
|
||||
(new Version20260912000700($this->db, new NullLogger()))->up($schema);
|
||||
(new Version20260912000800($this->db, new NullLogger()))->up($schema);
|
||||
(new Version20260912000900($this->db, new NullLogger()))->up($schema);
|
||||
(new Version20260912001000($this->db, new NullLogger()))->up($schema);
|
||||
foreach ($schema->toSql($this->db->getDatabasePlatform()) as $sql) {
|
||||
$this->db->executeStatement($sql);
|
||||
}
|
||||
Version20260912000400::applyIndexes($this->db);
|
||||
Version20260912000600::applyIndexes($this->db);
|
||||
Version20260912000900::applyIndexes($this->db);
|
||||
TestSchema::build($this->db);
|
||||
$this->db->insert('website', ['id' => 'default', 'name' => 'Test', 'default_locale' => 'de', 'theme' => '{}']);
|
||||
$this->db->insert('page', ['id' => 'p1', 'slug' => 'home', 'path' => 'home', 'type' => 'page', 'locale' => 'de', 'website_id' => 'default', 'version' => 0, 'created_at' => gmdate('Y-m-d H:i:s')]);
|
||||
$pages = new PageRepository($this->db, new DocumentValidator(), new SiteSettings($this->db));
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests;
|
||||
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use DoctrineMigrations\Version20260912000100;
|
||||
use DoctrineMigrations\Version20260912000200;
|
||||
use DoctrineMigrations\Version20260912000300;
|
||||
use DoctrineMigrations\Version20260912000400;
|
||||
use DoctrineMigrations\Version20260912000500;
|
||||
use DoctrineMigrations\Version20260912000600;
|
||||
use DoctrineMigrations\Version20260912000700;
|
||||
use DoctrineMigrations\Version20260912000800;
|
||||
use DoctrineMigrations\Version20260912000900;
|
||||
use DoctrineMigrations\Version20260912001000;
|
||||
use DoctrineMigrations\Version20260912001100;
|
||||
use Psr\Log\NullLogger;
|
||||
|
||||
/**
|
||||
* The schema a migrated installation has, built for a fresh test database.
|
||||
*
|
||||
* The list used to stand in three places, so a new migration was three edits and the fixtures
|
||||
* drifted apart. It stands here once. Indexes come from the same postUp() helpers the migrations
|
||||
* use, because on SQLite an index added the ordinary way rebuilds the table.
|
||||
*/
|
||||
final class TestSchema
|
||||
{
|
||||
public static function build(Connection $db): void
|
||||
{
|
||||
$schema = new Schema();
|
||||
foreach ([
|
||||
Version20260912000100::class,
|
||||
Version20260912000200::class,
|
||||
Version20260912000300::class,
|
||||
Version20260912000400::class,
|
||||
Version20260912000500::class,
|
||||
Version20260912000600::class,
|
||||
Version20260912000700::class,
|
||||
Version20260912000800::class,
|
||||
Version20260912000900::class,
|
||||
Version20260912001000::class,
|
||||
Version20260912001100::class,
|
||||
] as $migration) {
|
||||
(new $migration($db, new NullLogger()))->up($schema);
|
||||
}
|
||||
foreach ($schema->toSql($db->getDatabasePlatform()) as $sql) {
|
||||
$db->executeStatement($sql);
|
||||
}
|
||||
Version20260912000400::applyIndexes($db);
|
||||
Version20260912000600::applyIndexes($db);
|
||||
Version20260912000900::applyIndexes($db);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests;
|
||||
|
||||
use App\Content\DemoContent;
|
||||
use App\Content\DocumentValidator;
|
||||
use App\Content\PageRepository;
|
||||
use App\Content\PartialRepository;
|
||||
use App\Content\SiteSettings;
|
||||
use App\Infrastructure\ConnectionFactory;
|
||||
use App\Kernel;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
|
||||
/**
|
||||
* The user administration, and above all the two properties that matter about it: only an
|
||||
* administrator can touch an account, and a changed password or role ends every open session of
|
||||
* that account.
|
||||
*
|
||||
* Several tests hold two sessions at once, which is the only way to show the second property. The
|
||||
* response assertions are therefore written against a named client instead of the framework's
|
||||
* "current" one - with two clients around, "current" is whichever was created last.
|
||||
*/
|
||||
final class UserAdminTest extends WebTestCase
|
||||
{
|
||||
private const FIXTURE_PASSWORD = 'Fixture-only-password';
|
||||
|
||||
private Connection $db;
|
||||
private string $path;
|
||||
private array $previous;
|
||||
|
||||
protected static function getKernelClass(): string
|
||||
{
|
||||
return Kernel::class;
|
||||
}
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->path = tempnam(sys_get_temp_dir(), 'kant-users-');
|
||||
$url = 'sqlite:///'.str_replace('\\', '/', $this->path);
|
||||
$this->previous = [$_SERVER, $_ENV];
|
||||
foreach (['APP_SECRET' => 'isolated-test-secret', 'DATABASE_URL' => $url, 'SITE_ORIGIN' => 'https://cms.test', 'MEDIA_DIRECTORY' => $this->path.'-media'] as $key => $value) {
|
||||
$_SERVER[$key] = $_ENV[$key] = $value;
|
||||
}
|
||||
$this->db = ConnectionFactory::create($url);
|
||||
TestSchema::build($this->db);
|
||||
$this->db->insert('website', ['id' => 'default', 'name' => 'Test', 'default_locale' => 'de', 'theme' => '{}']);
|
||||
$this->db->insert('page', ['id' => 'p1', 'slug' => 'home', 'path' => 'home', 'type' => 'page', 'locale' => 'de', 'website_id' => 'default', 'version' => 0, 'created_at' => gmdate('Y-m-d H:i:s')]);
|
||||
$pages = new PageRepository($this->db, new DocumentValidator(), new SiteSettings($this->db));
|
||||
$pages->publish('p1', $pages->saveDraft('p1', DemoContent::pages()['home'], 0), 1);
|
||||
$partials = new PartialRepository($this->db, new DocumentValidator());
|
||||
foreach (DemoContent::areas() as $kind => $area) {
|
||||
$partials->create($kind, 'default', $area['name'], $area['document']);
|
||||
}
|
||||
foreach (['admin' => 'ROLE_ADMIN', 'zweite' => 'ROLE_ADMIN', 'editor' => 'ROLE_EDITOR'] as $username => $role) {
|
||||
$this->account($username, $role);
|
||||
}
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
parent::tearDown();
|
||||
$this->db->close();
|
||||
gc_collect_cycles();
|
||||
@unlink($this->path);
|
||||
foreach (glob($this->path.'-media/*') ?: [] as $file) {
|
||||
unlink($file);
|
||||
}
|
||||
if (is_dir($this->path.'-media')) {
|
||||
rmdir($this->path.'-media');
|
||||
}
|
||||
[$_SERVER, $_ENV] = $this->previous;
|
||||
}
|
||||
|
||||
public function testOnlyAnAdministratorReachesTheAccountsAtAll(): void
|
||||
{
|
||||
self::ensureKernelShutdown();
|
||||
$anonymous = self::createClient();
|
||||
$anonymous->followRedirects(false);
|
||||
$anonymous->request('GET', '/admin/benutzer');
|
||||
self::assertSame('http://localhost/admin/login', $anonymous->getResponse()->headers->get('Location'));
|
||||
|
||||
$editor = $this->login('editor');
|
||||
$editor->request('GET', '/admin/benutzer');
|
||||
self::assertSame(403, $editor->getResponse()->getStatusCode());
|
||||
// An editor does have an account screen: changing the own password is not a privilege.
|
||||
$editor->request('GET', '/admin/konto');
|
||||
self::assertSame(200, $editor->getResponse()->getStatusCode());
|
||||
|
||||
foreach ([
|
||||
['/admin/benutzer/neu', ['username' => 'geschmuggelt', 'password' => 'Ein-sehr-langes-Passwort', 'role' => 'ROLE_ADMIN']],
|
||||
['/admin/benutzer/'.$this->id('editor').'/rolle', ['role' => 'ROLE_ADMIN']],
|
||||
['/admin/benutzer/'.$this->id('admin').'/passwort', ['password' => 'Ein-sehr-langes-Passwort']],
|
||||
['/admin/benutzer/'.$this->id('admin').'/loeschen', []],
|
||||
] as [$url, $body]) {
|
||||
$editor->request('POST', $url, [...$body, '_csrf_token' => $this->token($editor, 'account')]);
|
||||
self::assertSame(403, $editor->getResponse()->getStatusCode(), $url);
|
||||
}
|
||||
self::assertSame(3, $this->accounts());
|
||||
self::assertSame('ROLE_EDITOR', $this->roleOf('editor'));
|
||||
}
|
||||
|
||||
public function testAnAccountIsCreatedAndCanSignIn(): void
|
||||
{
|
||||
$client = $this->login();
|
||||
$this->submit($client, '/admin/benutzer/neu', ['username' => 'Redakteurin', 'password' => 'Ein-sehr-langes-Passwort', 'role' => 'ROLE_PUBLISHER']);
|
||||
self::assertSame('/admin/benutzer', $client->getResponse()->headers->get('Location'));
|
||||
|
||||
// Stored in lower case, with the dates the list shows.
|
||||
$row = $this->db->fetchAssociative('SELECT * FROM cms_user WHERE username = ?', ['redakteurin']);
|
||||
self::assertNotFalse($row);
|
||||
self::assertSame('["ROLE_PUBLISHER"]', $row['roles']);
|
||||
self::assertNotNull($row['created_at']);
|
||||
self::assertNotNull($row['password_changed_at']);
|
||||
self::assertNull($row['last_login_at']);
|
||||
|
||||
$fresh = $this->login('redakteurin', 'Ein-sehr-langes-Passwort');
|
||||
$fresh->request('GET', '/admin/benutzer');
|
||||
self::assertSame(403, $fresh->getResponse()->getStatusCode(), 'A publisher is not an administrator.');
|
||||
// The login is recorded, so nobody has to guess whether an account is still in use.
|
||||
self::assertNotNull($this->db->fetchOne('SELECT last_login_at FROM cms_user WHERE username = ?', ['redakteurin']));
|
||||
}
|
||||
|
||||
/** @return iterable<string, array{array<string, string>}> */
|
||||
public static function refusedAccounts(): iterable
|
||||
{
|
||||
yield 'password too short' => [['username' => 'neu', 'password' => 'Dreizehn-Zeic', 'role' => 'ROLE_EDITOR']];
|
||||
yield 'password beyond bcrypt' => [['username' => 'neu', 'password' => str_repeat('a', 73), 'role' => 'ROLE_EDITOR']];
|
||||
yield 'password with a null byte' => [['username' => 'neu', 'password' => "Ein-langes-Pass\0wort", 'role' => 'ROLE_EDITOR']];
|
||||
yield 'username too short' => [['username' => 'ab', 'password' => 'Ein-sehr-langes-Passwort', 'role' => 'ROLE_EDITOR']];
|
||||
yield 'username with a space' => [['username' => 'neuer nutzer', 'password' => 'Ein-sehr-langes-Passwort', 'role' => 'ROLE_EDITOR']];
|
||||
yield 'username starting with a dot' => [['username' => '.neu', 'password' => 'Ein-sehr-langes-Passwort', 'role' => 'ROLE_EDITOR']];
|
||||
yield 'name already taken' => [['username' => 'editor', 'password' => 'Ein-sehr-langes-Passwort', 'role' => 'ROLE_EDITOR']];
|
||||
yield 'unknown role' => [['username' => 'neu', 'password' => 'Ein-sehr-langes-Passwort', 'role' => 'ROLE_SUPERUSER']];
|
||||
}
|
||||
|
||||
#[DataProvider('refusedAccounts')]
|
||||
public function testRefusedAccountsAreNotCreated(array $body): void
|
||||
{
|
||||
$client = $this->login();
|
||||
$this->submit($client, '/admin/benutzer/neu', $body);
|
||||
self::assertSame('/admin/benutzer', $client->getResponse()->headers->get('Location'));
|
||||
self::assertSame(3, $this->accounts());
|
||||
self::assertSame('ROLE_EDITOR', $this->roleOf('editor'), 'An existing account must stay as it is.');
|
||||
self::assertCount(1, $client->followRedirect()->filter('.notice.error'));
|
||||
}
|
||||
|
||||
public function testEveryWriteNeedsACsrfToken(): void
|
||||
{
|
||||
$client = $this->login();
|
||||
foreach ([
|
||||
['/admin/benutzer/neu', ['username' => 'ohnetoken', 'password' => 'Ein-sehr-langes-Passwort', 'role' => 'ROLE_ADMIN']],
|
||||
['/admin/benutzer/'.$this->id('editor').'/rolle', ['role' => 'ROLE_ADMIN']],
|
||||
['/admin/benutzer/'.$this->id('editor').'/passwort', ['password' => 'Ein-sehr-langes-Passwort']],
|
||||
['/admin/benutzer/'.$this->id('editor').'/loeschen', []],
|
||||
] as [$url, $body]) {
|
||||
$client->request('POST', $url, [...$body, '_csrf_token' => 'wrong']);
|
||||
self::assertSame('/admin/benutzer', $client->getResponse()->headers->get('Location'), $url);
|
||||
}
|
||||
self::assertSame(3, $this->accounts());
|
||||
self::assertSame('ROLE_EDITOR', $this->roleOf('editor'));
|
||||
self::assertTrue($this->passwordWorks('editor', self::FIXTURE_PASSWORD));
|
||||
|
||||
$client->request('POST', '/admin/konto/passwort', ['current' => self::FIXTURE_PASSWORD, 'password' => 'Ein-sehr-langes-Passwort', '_csrf_token' => 'wrong']);
|
||||
self::assertSame('/admin/konto', $client->getResponse()->headers->get('Location'));
|
||||
self::assertTrue($this->passwordWorks('admin', self::FIXTURE_PASSWORD));
|
||||
}
|
||||
|
||||
public function testNobodyRemovesOrDemotesTheirOwnAccountSoAnAdministratorAlwaysRemains(): void
|
||||
{
|
||||
$client = $this->login();
|
||||
$me = $this->id('admin');
|
||||
|
||||
$this->submit($client, '/admin/benutzer/'.$me.'/loeschen', []);
|
||||
self::assertSame(3, $this->accounts(), 'The own account must survive.');
|
||||
$this->submit($client, '/admin/benutzer/'.$me.'/rolle', ['role' => 'ROLE_EDITOR']);
|
||||
self::assertSame('ROLE_ADMIN', $this->roleOf('admin'), 'The own role must stay.');
|
||||
self::assertCount(1, $client->followRedirect()->filter('.notice.error'));
|
||||
|
||||
// Another administrator can be demoted and removed, and the actor stays an administrator -
|
||||
// that is why at least one always remains.
|
||||
$this->submit($client, '/admin/benutzer/'.$this->id('zweite').'/rolle', ['role' => 'ROLE_EDITOR']);
|
||||
self::assertSame('ROLE_EDITOR', $this->roleOf('zweite'));
|
||||
$this->submit($client, '/admin/benutzer/'.$this->id('zweite').'/loeschen', []);
|
||||
self::assertSame(2, $this->accounts());
|
||||
self::assertSame('ROLE_ADMIN', $this->roleOf('admin'));
|
||||
}
|
||||
|
||||
public function testAResetPasswordSignsThatAccountOutEverywhere(): void
|
||||
{
|
||||
$editor = $this->login('editor');
|
||||
$admin = $this->login();
|
||||
|
||||
$this->submit($admin, '/admin/benutzer/'.$this->id('editor').'/passwort', ['password' => 'Ein-ganz-neues-Passwort']);
|
||||
self::assertSame('/admin/benutzer', $admin->getResponse()->headers->get('Location'));
|
||||
|
||||
// The editor's session held the old hash, so its next request is no longer signed in.
|
||||
$editor->request('GET', '/admin');
|
||||
self::assertSame('http://localhost/admin/login', $editor->getResponse()->headers->get('Location'));
|
||||
self::assertFalse($this->passwordWorks('editor', self::FIXTURE_PASSWORD));
|
||||
self::assertTrue($this->passwordWorks('editor', 'Ein-ganz-neues-Passwort'));
|
||||
// And the administrator who made the change stays signed in.
|
||||
$admin->request('GET', '/admin/benutzer');
|
||||
self::assertSame(200, $admin->getResponse()->getStatusCode());
|
||||
}
|
||||
|
||||
public function testAChangedRoleSignsThatAccountOutEverywhere(): void
|
||||
{
|
||||
$editor = $this->login('editor');
|
||||
$admin = $this->login();
|
||||
|
||||
$this->submit($admin, '/admin/benutzer/'.$this->id('editor').'/rolle', ['role' => 'ROLE_PUBLISHER']);
|
||||
self::assertSame('ROLE_PUBLISHER', $this->roleOf('editor'));
|
||||
$editor->request('GET', '/admin');
|
||||
self::assertSame('http://localhost/admin/login', $editor->getResponse()->headers->get('Location'));
|
||||
}
|
||||
|
||||
public function testChangingTheOwnPasswordNeedsTheCurrentOneAndKeepsThisSession(): void
|
||||
{
|
||||
$other = $this->login('editor');
|
||||
$client = $this->login('editor');
|
||||
|
||||
foreach ([
|
||||
'wrong current password' => ['current' => 'Nicht-das-Passwort', 'password' => 'Ein-ganz-neues-Passwort'],
|
||||
'the same password again' => ['current' => self::FIXTURE_PASSWORD, 'password' => self::FIXTURE_PASSWORD],
|
||||
'new one too short' => ['current' => self::FIXTURE_PASSWORD, 'password' => 'Dreizehn-Zeic'],
|
||||
] as $case => $body) {
|
||||
$this->submit($client, '/admin/konto/passwort', $body, 'account');
|
||||
self::assertSame('/admin/konto', $client->getResponse()->headers->get('Location'), $case);
|
||||
self::assertTrue($this->passwordWorks('editor', self::FIXTURE_PASSWORD), $case);
|
||||
self::assertCount(1, $client->followRedirect()->filter('.notice.error'), $case);
|
||||
}
|
||||
|
||||
$this->submit($client, '/admin/konto/passwort', ['current' => self::FIXTURE_PASSWORD, 'password' => 'Ein-ganz-neues-Passwort'], 'account');
|
||||
self::assertSame('/admin/konto', $client->getResponse()->headers->get('Location'));
|
||||
self::assertFalse($this->passwordWorks('editor', self::FIXTURE_PASSWORD));
|
||||
self::assertTrue($this->passwordWorks('editor', 'Ein-ganz-neues-Passwort'));
|
||||
|
||||
// This session keeps working, because it was handed the fresh account.
|
||||
$client->request('GET', '/admin/konto');
|
||||
self::assertSame(200, $client->getResponse()->getStatusCode());
|
||||
// The other session of the same account is signed out.
|
||||
$other->request('GET', '/admin');
|
||||
self::assertSame('http://localhost/admin/login', $other->getResponse()->headers->get('Location'));
|
||||
}
|
||||
|
||||
public function testTheAccountScreenShowsTheOwnRoleButNoWayToChangeIt(): void
|
||||
{
|
||||
$client = $this->login('editor');
|
||||
$crawler = $client->request('GET', '/admin/konto');
|
||||
$head = $crawler->filter('.card-head')->first()->text();
|
||||
self::assertStringContainsString('editor', $head);
|
||||
self::assertStringContainsString('Redakteur', $head);
|
||||
self::assertCount(0, $crawler->filter('select[name="role"]'));
|
||||
self::assertCount(0, $crawler->filter('a[href="/admin/benutzer"]'), 'An editor is not offered the user administration.');
|
||||
}
|
||||
|
||||
private function account(string $username, string $role): void
|
||||
{
|
||||
$this->db->insert('cms_user', [
|
||||
'id' => bin2hex(random_bytes(8)),
|
||||
'username' => $username,
|
||||
'password_hash' => password_hash(self::FIXTURE_PASSWORD, PASSWORD_BCRYPT, ['cost' => 4]),
|
||||
'roles' => json_encode([$role], JSON_THROW_ON_ERROR),
|
||||
'created_at' => gmdate('Y-m-d H:i:s'),
|
||||
'password_changed_at' => gmdate('Y-m-d H:i:s'),
|
||||
]);
|
||||
}
|
||||
|
||||
private function login(string $username = 'admin', string $password = self::FIXTURE_PASSWORD): KernelBrowser
|
||||
{
|
||||
// Several sessions in one test: a new client needs the kernel down before it is created,
|
||||
// and every request boots its own kernel afterwards.
|
||||
self::ensureKernelShutdown();
|
||||
$client = self::createClient();
|
||||
$client->followRedirects(false);
|
||||
$crawler = $client->request('GET', '/admin/login');
|
||||
$client->submit($crawler->selectButton('Anmelden')->form(['_username' => $username, '_password' => $password]));
|
||||
self::assertSame('http://localhost/admin', $client->getResponse()->headers->get('Location'), 'Anmeldung von '.$username);
|
||||
$client->followRedirect();
|
||||
self::assertSame(200, $client->getResponse()->getStatusCode());
|
||||
|
||||
return $client;
|
||||
}
|
||||
|
||||
private function submit(KernelBrowser $client, string $url, array $body, string $tokenId = 'users'): void
|
||||
{
|
||||
$client->request('POST', $url, [...$body, '_csrf_token' => $this->token($client, $tokenId)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* A token as the browser would carry it: read out of the form this client was served. The form
|
||||
* is addressed explicitly, because the first token on the page belongs to the logout form in
|
||||
* the shell and is issued for a different token id.
|
||||
*/
|
||||
private function token(KernelBrowser $client, string $id): string
|
||||
{
|
||||
[$url, $form] = 'users' === $id
|
||||
? ['/admin/benutzer', 'form[action="/admin/benutzer/neu"]']
|
||||
: ['/admin/konto', 'form[action="/admin/konto/passwort"]'];
|
||||
$crawler = $client->request('GET', $url);
|
||||
if (200 !== $client->getResponse()->getStatusCode()) {
|
||||
return 'no-token-for-this-account';
|
||||
}
|
||||
|
||||
return (string) $crawler->filter($form.' input[name="_csrf_token"]')->first()->attr('value');
|
||||
}
|
||||
|
||||
private function id(string $username): string
|
||||
{
|
||||
return (string) $this->db->fetchOne('SELECT id FROM cms_user WHERE username = ?', [$username]);
|
||||
}
|
||||
|
||||
private function roleOf(string $username): string
|
||||
{
|
||||
return json_decode((string) $this->db->fetchOne('SELECT roles FROM cms_user WHERE username = ?', [$username]), true, 512, JSON_THROW_ON_ERROR)[0];
|
||||
}
|
||||
|
||||
private function accounts(): int
|
||||
{
|
||||
return (int) $this->db->fetchOne('SELECT COUNT(*) FROM cms_user');
|
||||
}
|
||||
|
||||
private function passwordWorks(string $username, string $password): bool
|
||||
{
|
||||
return password_verify($password, (string) $this->db->fetchOne('SELECT password_hash FROM cms_user WHERE username = ?', [$username]));
|
||||
}
|
||||
}
|
||||
@@ -9,18 +9,7 @@ use App\Content\PageRepository;
|
||||
use App\Content\PartialRepository;
|
||||
use App\Content\SiteSettings;
|
||||
use App\Infrastructure\ConnectionFactory;
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use DoctrineMigrations\Version20260912000100;
|
||||
use DoctrineMigrations\Version20260912000200;
|
||||
use DoctrineMigrations\Version20260912000300;
|
||||
use DoctrineMigrations\Version20260912000400;
|
||||
use DoctrineMigrations\Version20260912000500;
|
||||
use DoctrineMigrations\Version20260912000600;
|
||||
use DoctrineMigrations\Version20260912000700;
|
||||
use DoctrineMigrations\Version20260912000800;
|
||||
use DoctrineMigrations\Version20260912000900;
|
||||
use DoctrineMigrations\Version20260912001000;
|
||||
use Psr\Log\NullLogger;
|
||||
use App\Tests\TestSchema;
|
||||
|
||||
$directory = dirname(__DIR__).'/var/browser-tests';
|
||||
if (!is_dir($directory)) {
|
||||
@@ -29,23 +18,7 @@ if (!is_dir($directory)) {
|
||||
$path = $directory.'/'.bin2hex(random_bytes(12)).'.db';
|
||||
$url = 'sqlite:///'.str_replace('\\', '/', $path);
|
||||
$db = ConnectionFactory::create($url);
|
||||
$schema = new Schema();
|
||||
(new Version20260912000100($db, new NullLogger()))->up($schema);
|
||||
(new Version20260912000200($db, new NullLogger()))->up($schema);
|
||||
(new Version20260912000300($db, new NullLogger()))->up($schema);
|
||||
(new Version20260912000400($db, new NullLogger()))->up($schema);
|
||||
(new Version20260912000500($db, new NullLogger()))->up($schema);
|
||||
(new Version20260912000600($db, new NullLogger()))->up($schema);
|
||||
(new Version20260912000700($db, new NullLogger()))->up($schema);
|
||||
(new Version20260912000800($db, new NullLogger()))->up($schema);
|
||||
(new Version20260912000900($db, new NullLogger()))->up($schema);
|
||||
(new Version20260912001000($db, new NullLogger()))->up($schema);
|
||||
foreach ($schema->toSql($db->getDatabasePlatform()) as $sql) {
|
||||
$db->executeStatement($sql);
|
||||
}
|
||||
Version20260912000400::applyIndexes($db);
|
||||
Version20260912000600::applyIndexes($db);
|
||||
Version20260912000900::applyIndexes($db);
|
||||
TestSchema::build($db);
|
||||
$db->insert('website', ['id' => 'default', 'name' => 'Browser test', 'default_locale' => 'de', 'theme' => '{}']);
|
||||
$pages = new PageRepository($db, new DocumentValidator(), new SiteSettings($db));
|
||||
foreach (DemoContent::pages() as $slug => $document) {
|
||||
|
||||
Reference in new issue
Block a user