Migration 20260912000700 legt form und form_submission an. Der Formularblock im Seiteninhalt enthält nur die Adresse: Felder, Empfänger und Einwilligungstext liegen in der Datenbank, damit ein gespeichertes Dokument nicht ändern kann, wonach gefragt wird oder wohin eine Anfrage geht. Serverseitige Validierung mit einer Meldung je Feld über aria-describedby und aria-invalid; eine Ablehnung rendert die Seite mit HTTP 422 und allen Eingaben neu, eine gültige Absendung wird mit HTTP 303 zurückgeleitet. Alles ohne JavaScript. Spamschutz ohne Dienst Dritter: Honeypot, signierter Zeitstempel gegen zu schnelle und zu alte Absendungen, fünf Absendungen je Absender und Stunde. Ein erkannter Bot bekommt dieselbe Antwort wie ein Erfolg. Die Absenderadresse wird nie gespeichert, nur ein Hash. Gespeichert wird vor dem Versand, damit ein kaputter Mailversand keine Anfrage verliert. Kein CSRF-Token auf dem öffentlichen Weg, weil das eine Sitzung und damit ein Cookie auf jeder öffentlichen Seite bedeuten würde; Begründung in ADR 017. Dateiuploads fehlen bewusst. Ein echter Fehler von den Tests gefunden: das Stundenfenster des Limits entstand aus lokaler Zeit, die Zeitstempel aber aus UTC - auf einer Uhr vor UTC hätte das Limit nie gegriffen. Geprüft: 117 PHP-Tests / 450 Assertions unter PHP 8.2.12 und 8.4.22, 14 JavaScript-Modelltests, Lint/Format/Container, composer audit. Der E-Mail-Versand wurde nie wirklich ausgeführt; keine Browser- oder Lighthouse-Läufe. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
109 lines
7.9 KiB
Twig
109 lines
7.9 KiB
Twig
{% extends 'admin/base.html.twig' %}
|
|
{% block title %}Deine Seiten{% endblock %}
|
|
{% block body %}
|
|
<header class="admin-header">
|
|
<a class="brand" href="{{ path('admin_pages') }}"><span class="brand-icon" aria-hidden="true">k.</span>kant<span>cms</span></a>
|
|
<div class="header-actions"><a href="/">Website ansehen ↗</a><span class="user-name">{{ app.user.userIdentifier }}</span><form action="{{ path('admin_logout') }}" method="post"><input type="hidden" name="_csrf_token" value="{{ csrf_token('logout') }}"><button class="subtle" type="submit">Abmelden</button></form></div>
|
|
</header>
|
|
<main id="admin-main" class="pages-main" tabindex="-1">
|
|
<p class="eyebrow">DEINE WEBSITE</p><h1>Gute Inhalte beginnen hier.</h1>
|
|
<p class="muted">Wähle eine Seite. Gestalte deinen Entwurf. Veröffentliche, wenn alles passt.</p>
|
|
<p class="admin-links"><a class="secondary" href="{{ path('admin_media') }}">Medienverwaltung</a> <a class="secondary" href="{{ path('admin_menus') }}">Menüs</a> <a class="secondary" href="{{ path('admin_forms') }}">Formulare</a> <a class="secondary" href="/beitraege">Beitragsübersicht ↗</a></p>
|
|
|
|
{% for type in ['error', 'success'] %}{% for message in app.flashes(type) %}
|
|
<p class="notice {{ type == 'error' ? 'error' : 'ok' }}" role="{{ type == 'error' ? 'alert' : 'status' }}">{{ message }}</p>
|
|
{% endfor %}{% endfor %}
|
|
|
|
{% if is_granted('ROLE_EDITOR') %}
|
|
<details class="create-panel"{% if app.flashes is not empty %} open{% endif %}>
|
|
<summary>Neue Seite oder neuen Beitrag anlegen</summary>
|
|
<form action="{{ path('admin_create') }}" method="post" class="create-form">
|
|
<input type="hidden" name="_csrf_token" value="{{ csrf_token('pages') }}">
|
|
<div class="create-grid">
|
|
<div>
|
|
<label for="new-title">Titel</label>
|
|
<input id="new-title" name="title" maxlength="160" required autocomplete="off">
|
|
<p class="field-help">Wird als Überschrift und als Seitentitel übernommen.</p>
|
|
</div>
|
|
<div>
|
|
<label for="new-slug">Adresse</label>
|
|
<input id="new-slug" name="slug" maxlength="80" pattern="[a-z0-9]+(-[a-z0-9]+)*" autocomplete="off" aria-describedby="new-slug-help">
|
|
<p class="field-help" id="new-slug-help">Kleinbuchstaben, Ziffern und Bindestriche, zum Beispiel <code>ueber-uns</code>. Leer lassen, um sie aus dem Titel zu bilden.</p>
|
|
</div>
|
|
<div>
|
|
<label for="new-type">Art</label>
|
|
<select id="new-type" name="type">
|
|
<option value="page">Seite</option>
|
|
<option value="post">Beitrag</option>
|
|
</select>
|
|
<p class="field-help">Beiträge sind für Blogartikel gedacht und haben keine Unterseiten.</p>
|
|
</div>
|
|
<div>
|
|
<label for="new-parent">Übergeordnete Seite</label>
|
|
<select id="new-parent" name="parent_id">
|
|
<option value="">— keine, direkt unter der Startseite —</option>
|
|
{% for parent in parents %}<option value="{{ parent.id }}">{{ parent.title }} (/{{ parent.path }})</option>{% endfor %}
|
|
</select>
|
|
<p class="field-help">Bestimmt die Adresse, zum Beispiel <code>/leistungen/webdesign</code>.</p>
|
|
</div>
|
|
</div>
|
|
<button class="primary" type="submit">Anlegen und bearbeiten</button>
|
|
</form>
|
|
</details>
|
|
{% endif %}
|
|
|
|
<div class="section-heading"><h2>Seiten <span class="count">{{ pages|length }}</span></h2><span class="muted">Entwürfe bleiben bis zur Veröffentlichung privat.</span></div>
|
|
<div class="page-list">
|
|
{% for page in pages %}
|
|
<article class="page-card" style="margin-left:{{ page.depth * 28 }}px">
|
|
<div class="page-icon" aria-hidden="true">{{ page.type == 'post' ? '✎' : '▤' }}</div>
|
|
<div class="page-info">
|
|
<h3><a href="{{ path('admin_editor', {id: page.id}) }}">{{ page.title }}</a></h3>
|
|
<p>/{{ page.path == 'home' ? '' : page.path }} <span>· {{ page.locale|upper }} · {{ page.type == 'post' ? 'Beitrag' : 'Seite' }}</span></p>
|
|
</div>
|
|
<span class="badge {{ page.published_revision_id == page.draft_revision_id ? 'published' : '' }}">{{ page.published_revision_id is null ? 'Nicht veröffentlicht' : (page.published_revision_id == page.draft_revision_id ? 'Veröffentlicht' : 'Entwurf geändert') }}</span>
|
|
<a class="secondary" href="{{ path('admin_editor', {id: page.id}) }}" aria-label="{{ page.title }} bearbeiten">Bearbeiten <span aria-hidden="true">→</span></a>
|
|
{% if page.type == 'post' and is_granted('ROLE_EDITOR') %}
|
|
<details class="meta-panel">
|
|
<summary aria-label="Beitragsdaten von {{ page.title }}">Beitragsdaten</summary>
|
|
<form action="{{ path('admin_post_meta', {id: page.id}) }}" method="post">
|
|
<input type="hidden" name="_csrf_token" value="{{ csrf_token('pages') }}">
|
|
<input type="hidden" name="expectedVersion" value="{{ page.version }}">
|
|
<label for="date-{{ page.id }}">Veröffentlicht am</label>
|
|
<input id="date-{{ page.id }}" name="publishedAt" type="date" value="{{ page.published_at ? page.published_at|date('Y-m-d') : '' }}">
|
|
<label for="author-{{ page.id }}">Autor</label>
|
|
<input id="author-{{ page.id }}" name="author" maxlength="120" value="{{ page.author }}">
|
|
<label for="excerpt-{{ page.id }}">Auszug</label>
|
|
<textarea id="excerpt-{{ page.id }}" name="excerpt" maxlength="500" rows="3">{{ page.excerpt }}</textarea>
|
|
<p class="field-help">Erscheint in der Übersicht und im Feed. Leer lassen, um die Seitenbeschreibung zu verwenden.</p>
|
|
<label for="categories-{{ page.id }}">Kategorien</label>
|
|
<input id="categories-{{ page.id }}" name="categories" value="{{ page.terms.category }}" placeholder="Projekte, Referenzen">
|
|
<label for="tags-{{ page.id }}">Schlagwörter</label>
|
|
<input id="tags-{{ page.id }}" name="tags" value="{{ page.terms.tag }}" placeholder="WordPress, Migration">
|
|
<p class="field-help">Mehrere durch Komma trennen. Unbekannte Namen werden angelegt.</p>
|
|
<label for="media-{{ page.id }}">Beitragsbild</label>
|
|
<input id="media-{{ page.id }}" name="featuredMediaId" maxlength="32" value="{{ page.featured_media_id }}" placeholder="ID aus der Mediathek">
|
|
<button class="primary" type="submit">Beitragsdaten speichern</button>
|
|
</form>
|
|
</details>
|
|
{% endif %}
|
|
{% if is_granted('ROLE_ADMIN') %}
|
|
<details class="delete-panel">
|
|
<summary aria-label="{{ page.title }} löschen">Löschen</summary>
|
|
<form action="{{ path('admin_delete', {id: page.id}) }}" method="post">
|
|
<input type="hidden" name="_csrf_token" value="{{ csrf_token('pages') }}">
|
|
<input type="hidden" name="expectedVersion" value="{{ page.version }}">
|
|
<p>Löscht „{{ page.title }}“ mit allen gespeicherten Ständen. Das lässt sich nicht rückgängig machen.</p>
|
|
<button class="secondary danger-text" type="submit">Endgültig löschen</button>
|
|
</form>
|
|
</details>
|
|
{% endif %}
|
|
</article>
|
|
{% else %}
|
|
<div class="empty-state"><h3>Noch keine Seiten vorhanden.</h3><p>Lege oben deine erste Seite an.</p></div>
|
|
{% endfor %}
|
|
</div>
|
|
<p class="footnote">Kant CMS · Visueller Editor, erster Prototyp</p>
|
|
</main>
|
|
{% endblock %}
|