diff --git a/README.md b/README.md index d5422fd..b7fd2a1 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ae8d379..cdb5080 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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. diff --git a/docs/AUSBAUPLAN.md b/docs/AUSBAUPLAN.md index 06cc301..823d6af 100644 --- a/docs/AUSBAUPLAN.md +++ b/docs/AUSBAUPLAN.md @@ -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. diff --git a/docs/QUALITY.md b/docs/QUALITY.md index 5d353ec..c4ed657 100644 --- a/docs/QUALITY.md +++ b/docs/QUALITY.md @@ -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. diff --git a/docs/STATUS.md b/docs/STATUS.md index 6aea532..bfc9e66 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -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 `
`. 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. diff --git a/migrations/Version20260912001100.php b/migrations/Version20260912001100.php new file mode 100644 index 0000000..ba025ba --- /dev/null +++ b/migrations/Version20260912001100.php @@ -0,0 +1,36 @@ +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.'); + } +} diff --git a/public/assets/admin.css b/public/assets/admin.css index 7cefa1a..73f90eb 100644 --- a/public/assets/admin.css +++ b/public/assets/admin.css @@ -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 diff --git a/scripts/editor-check.mjs b/scripts/editor-check.mjs index 9b6806c..d47fe59 100644 --- a/scripts/editor-check.mjs +++ b/scripts/editor-check.mjs @@ -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(); diff --git a/src/Content/MediaLibrary.php b/src/Content/MediaLibrary.php index d999ccd..b376d73 100644 --- a/src/Content/MediaLibrary.php +++ b/src/Content/MediaLibrary.php @@ -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>, 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]); diff --git a/src/Controller/MediaController.php b/src/Controller/MediaController.php index f224416..c6caa0d 100644 --- a/src/Controller/MediaController.php +++ b/src/Controller/MediaController.php @@ -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'])] diff --git a/src/Controller/UserController.php b/src/Controller/UserController.php new file mode 100644 index 0000000..813f9b5 --- /dev/null +++ b/src/Controller/UserController.php @@ -0,0 +1,177 @@ +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); + } +} diff --git a/src/Domain/InvalidAccount.php b/src/Domain/InvalidAccount.php new file mode 100644 index 0000000..bfeab96 --- /dev/null +++ b/src/Domain/InvalidAccount.php @@ -0,0 +1,8 @@ + 'onLogin']; + } + + public function onLogin(LoginSuccessEvent $event): void + { + $user = $event->getUser(); + if ($user instanceof CmsUser) { + $this->users->recordLogin($user->getUserIdentifier()); + } + } +} diff --git a/src/Security/UserDirectory.php b/src/Security/UserDirectory.php new file mode 100644 index 0000000..23d6d33 --- /dev/null +++ b/src/Security/UserDirectory.php @@ -0,0 +1,207 @@ + '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 + */ + 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); + } +} diff --git a/templates/admin/account.html.twig b/templates/admin/account.html.twig new file mode 100644 index 0000000..0837003 --- /dev/null +++ b/templates/admin/account.html.twig @@ -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 %} + +
+

{{ app.user.userIdentifier }}

{{ account ? roles[account.role] : 'Rolle unbekannt' }}
+
+ {% if account %} +

{{ roleHelp[account.role] }}

+

+ {% 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 %} +

+ {% endif %} +
+
+ +
+

Passwort ändern

Andere Sitzungen werden abgemeldet
+
+ +
+
+ + +

Nötig, damit eine vergessene offene Sitzung nicht ausreicht, um dein Konto zu übernehmen.

+
+
+ + +

Mindestens {{ minPassword }} Zeichen, höchstens {{ maxPassword }} Bytes. Diese Sitzung bleibt angemeldet, alle anderen dieses Kontos nicht.

+
+
+ +
+
+ +

Kant CMS · Arbeitsstand

+{% endblock %} diff --git a/templates/admin/media.html.twig b/templates/admin/media.html.twig index d69b4eb..6ab8630 100644 --- a/templates/admin/media.html.twig +++ b/templates/admin/media.html.twig @@ -1,16 +1,82 @@ {% extends 'admin/shell.html.twig' %} {% set nav = 'media' %} {% block title %}Mediathek{% endblock %} -{% block styles %}{% endblock %} -{% block scripts %}{% 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 %}{% 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 %} -
-

Bilder verwalten

Hochladen, benennen, Alternativtext pflegen
-
-

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.

-
+ +{% if is_granted('ROLE_EDITOR') %} +
+ + + + + +

JPEG, PNG oder WebP, höchstens 8 MB je Bild. Mehrere Dateien sind möglich; das Hosting kann ein kleineres Limit setzen.

+ +
+{% endif %} + +
+

{{ archived ? 'Alle Bilder einschließlich Archiv' : 'Bilder' }} {{ library.total }}

+ + {% if archived %}Archivierte ausblenden{% else %}Archivierte anzeigen{% endif %} + {% if library.pages > 1 %} · Seite {{ library.page }} von {{ library.pages }}{% endif %} +
+ +{% if library.items is empty %} +
+

{{ archived ? 'Keine Bilder vorhanden.' : 'Keine Bilder in der Mediathek.' }}

+

{{ is_granted('ROLE_EDITOR') ? 'Lade oben dein erstes Bild hoch.' : 'Ein Redakteur kann Bilder hochladen.' }}

+
+{% else %} +
    + {% for item in library.items %} +
  • + {# The smallest variant is the thumbnail; the largest opens in a new tab. #} + + + +
    + {{ item.title }} + {{ (item.variants|last).width }} × {{ (item.variants|last).height }} px{% if item.archived %} · archiviert{% endif %} + {{ item.alt ? 'Alternativtext gesetzt' : 'Kein Alternativtext' }} + {{ item.id }} +
    + {% if is_granted('ROLE_EDITOR') %} +
    + Angaben +
    +
    + + + + + + + + +

    Beschreibt, was zu sehen ist. Das ist, was Menschen mit Screenreader vom Bild haben. Bei rein dekorativen Bildern leer lassen.

    + +

    Archivierte Bilder erscheinen nicht mehr in der Auswahl, bleiben aber ausgeliefert – eine Seite, die das Bild verwendet, verliert es nicht.

    + +
    +
    +
    + {% endif %} +
  • + {% endfor %} +
+ +{% if library.pages > 1 %} + +{% endif %} +{% endif %} + +

Kant CMS · Arbeitsstand

{% endblock %} diff --git a/templates/admin/shell.html.twig b/templates/admin/shell.html.twig index 1bec3b2..133a7d2 100644 --- a/templates/admin/shell.html.twig +++ b/templates/admin/shell.html.twig @@ -23,12 +23,13 @@
-

{{ app.user.userIdentifier }}

+ {{ app.user.userIdentifier }}
diff --git a/templates/admin/users.html.twig b/templates/admin/users.html.twig new file mode 100644 index 0000000..d8b61c9 --- /dev/null +++ b/templates/admin/users.html.twig @@ -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 %} + +
+ Neues Konto anlegen + + +
+
+ + +

3 bis 100 Zeichen: Kleinbuchstaben, Ziffern, Punkt, Unterstrich, Bindestrich.

+
+
+ + +

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.

+
+
+ + +

{% for value, help in roleHelp %}{{ roles[value] }}: {{ help }}{% if not loop.last %}
{% endif %}{% endfor %}

+
+
+ + +
+ +

Konten {{ users|length }}

Zeiten in UTC.
+
+{% for account in users %} +
+
+ +
+

{{ account.username }}{% if account.id == me %} dein Konto{% endif %}

+

{{ 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 %} +

+
+ {{ account.roleName }} +
+
+ Einstellungen +
+
+

Rolle

+ {% if account.id == me %} +

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.

+ {% else %} +
+ +
+ + +

Offene Sitzungen dieses Kontos werden dabei abgemeldet.

+
+ +
+ {% endif %} +
+ +
+

Passwort setzen

+

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.

+
+ +
+ + +
+ +
+
+ + {% if account.id != me %} +
+

Konto löschen

+

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.

+
+ + +
+
+ {% endif %} +
+
+
+{% endfor %} +
+ +
+

Wenn kein Konto mehr funktioniert

Zugang über die Konsole
+
+

Auf dem Server legt php bin/console cms:user:create name --role=admin 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.

+
+
+ +

Kant CMS · Arbeitsstand

+{% endblock %} diff --git a/tests/CmsTest.php b/tests/CmsTest.php index c6304f8..a1dae07 100644 --- a/tests/CmsTest.php +++ b/tests/CmsTest.php @@ -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')]); diff --git a/tests/EditorSecurityTest.php b/tests/EditorSecurityTest.php index a682dc2..39afb4d 100644 --- a/tests/EditorSecurityTest.php +++ b/tests/EditorSecurityTest.php @@ -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)); diff --git a/tests/TestSchema.php b/tests/TestSchema.php new file mode 100644 index 0000000..700e79b --- /dev/null +++ b/tests/TestSchema.php @@ -0,0 +1,54 @@ +up($schema); + } + foreach ($schema->toSql($db->getDatabasePlatform()) as $sql) { + $db->executeStatement($sql); + } + Version20260912000400::applyIndexes($db); + Version20260912000600::applyIndexes($db); + Version20260912000900::applyIndexes($db); + } +} diff --git a/tests/UserAdminTest.php b/tests/UserAdminTest.php new file mode 100644 index 0000000..3a1c40c --- /dev/null +++ b/tests/UserAdminTest.php @@ -0,0 +1,330 @@ +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}> */ + 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])); + } +} diff --git a/tests/browser-fixture.php b/tests/browser-fixture.php index 82b28cf..751552c 100644 --- a/tests/browser-fixture.php +++ b/tests/browser-fixture.php @@ -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) {