- The installer only opens with a per-installation key that install.php creates and shows; the key is deleted with the first account. - Lock files keep .env, the database, backups and logs out of reach when a domain points at the project folder instead of public/. - Linked sections render by their own schema version, so an old section cannot carry unfiltered HTML into a newer page. - Editors no longer move, trash or change areas and post data of published pages; a scheduled publication goes live with the revision that was scheduled (migration 20261012000100). - Form stamps are bound to their form, IPv6 senders count per /64, and a flood per form or overall stops storing with a visible message. - The consent redirect rejects control characters, update targets must look like 1.2.3, and own CSS cannot reach outside its block. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
114 lines
11 KiB
Twig
114 lines
11 KiB
Twig
{# Server rendered, no JavaScript needed. Errors are announced and each message sits at its field.
|
|
Schritte sind fieldsets: ohne Skript ein langes Formular mit Abschnitten, mit form-steps.js ein
|
|
Schritt nach dem anderen. Bedingungen stehen als Datenattribute am Feld; der Server prüft sie
|
|
beim Absenden selbst und verlangt nichts, was gar nicht galt. #}
|
|
{% if form is null %}
|
|
<p class="form-missing">{{ 'Dieses Formular ist nicht mehr vorhanden.'|kant_t }}</p>
|
|
{% elseif sent %}
|
|
<p class="form-success" role="status">{{ form.settings.success|kant_t }}</p>
|
|
{% else %}
|
|
{% set hasSteps = form.fields|filter(f => f.type == 'step')|length > 0 %}
|
|
<form class="block-form{{ hasSteps ? ' form-stepped' : '' }}" data-form="{{ form.slug }}" method="post" action="{{ path('form_submit', {slug: form.slug}) }}" novalidate{% if hasSteps %} data-steps{% endif %}{% if form.settings.summary|default(false) %} data-summary{% endif %}{% if form.locale|default(null) %} data-texts="{{ {summary: 'Zusammenfassung'|kant_t, summaryIntro: 'Bitte prüfen Sie Ihre Angaben. Über „Ändern“ kommen Sie zum jeweiligen Schritt zurück.'|kant_t, steps: 'Schritte des Formulars'|kant_t, step: 'Schritt'|kant_t, back: 'Zurück'|kant_t, next: 'Weiter'|kant_t, change: 'Ändern'|kant_t, yes: 'Ja'|kant_t, chooseOne: 'Bitte wähle mindestens eine Option.'|kant_t}|json_encode|e('html_attr') }}"{% endif %}>
|
|
<input type="hidden" name="page" value="{{ page.path }}">
|
|
{% if form.locale|default(null) %}<input type="hidden" name="language" value="{{ form.locale }}">{% endif %}
|
|
<input type="hidden" name="block" value="{{ blockId }}">
|
|
<input type="hidden" name="issued" value="{{ issued }}">
|
|
<input type="hidden" name="issuedSignature" value="{{ issuedSignature }}">
|
|
{# Bait for automated senders. Hidden from view, from assistive technology and from tab order. #}
|
|
<div class="form-trap" aria-hidden="true"><label for="website-{{ blockId }}">Website</label><input id="website-{{ blockId }}" name="website" type="text" tabindex="-1" autocomplete="off"></div>
|
|
|
|
{% if errors._form is defined %}
|
|
<p class="form-error-summary" role="alert">{{ errors._form|kant_t }}</p>
|
|
{% elseif errors is not empty %}
|
|
<p class="form-error-summary" role="alert">{{ (errors|length == 1 ? 'Bitte prüfe 1 Eingabe. Die betroffenen Felder sind unten gekennzeichnet.' : 'Bitte prüfe %d Eingaben. Die betroffenen Felder sind unten gekennzeichnet.')|kant_t|format(errors|length) }}</p>
|
|
{% endif %}
|
|
|
|
{% for field in form.fields %}
|
|
{% set id = blockId ~ '-' ~ field.name %}
|
|
{% set error = errors[field.name]|default(null) %}
|
|
{% set value = values[field.name]|default('') %}
|
|
{% set described = (field.help ? id ~ '-help ' : '') ~ (error ? id ~ '-error' : '') %}
|
|
{% set condition %}{% if field.showIf|default(null) %} data-show-if="{{ field.showIf.field }}" data-show-values="{{ field.showIf.values|join('|') }}"{% endif %}{% endset %}
|
|
{% set requiredMark %}{% if field.required %} <span class="required-mark" aria-hidden="true">*</span><span class="visually-hidden"> ({{ 'Pflichtfeld'|kant_t }})</span>{% endif %}{% endset %}
|
|
{% if field.type == 'step' %}
|
|
{% if not loop.first and form.fields[0:loop.index0]|filter(f => f.type == 'step')|length > 0 %}</fieldset>{% endif %}
|
|
<fieldset class="form-step" data-step{{ condition }}>
|
|
<legend class="form-step-title" tabindex="-1">{{ field.label }}</legend>
|
|
{% if field.help %}<p class="form-step-intro">{{ field.help }}</p>{% endif %}
|
|
{% elseif field.type == 'consent' %}
|
|
<div class="form-field{{ error ? ' has-error' : '' }}" data-field data-label="{{ field.label }}"{{ condition }}>
|
|
<div class="check-row">
|
|
<input id="{{ id }}" name="{{ field.name }}" type="checkbox" value="ja"{{ value ? ' checked' : '' }} required
|
|
{% if described|trim %}aria-describedby="{{ described|trim }}"{% endif %}{{ error ? ' aria-invalid="true"' : '' }}>
|
|
<label for="{{ id }}">{{ field.label }} <span class="required-mark" aria-hidden="true">*</span></label>
|
|
</div>
|
|
{% if field.help %}<p class="field-note" id="{{ id }}-help">{{ field.help }}</p>{% endif %}
|
|
{% if error %}<p class="field-error" id="{{ id }}-error">{{ error }}</p>{% endif %}
|
|
</div>
|
|
{% elseif field.type == 'radio' or field.type == 'checkboxes' %}
|
|
{# Eine Gruppe: die Beschriftung ist die legend, jede Option hat ihr eigenes Feld. Bei
|
|
der Mehrfachauswahl steht required nicht am Kästchen - das hieße „alle ankreuzen“ -
|
|
sondern an der Gruppe; Skript und Server prüfen es dort. #}
|
|
{# Hat eine Option ein Bild, wird die ganze Auswahl zur Bildauswahl: jede Option eine
|
|
Karte mit Bild und Text, das Kästchen bleibt darin für Tastatur und Vorlesesoftware. #}
|
|
{% set bildauswahl = field.images|default({})|length > 0 %}
|
|
<fieldset class="form-field form-choice{{ bildauswahl ? ' form-choice-images' : '' }}{{ error ? ' has-error' : '' }}" data-field data-label="{{ field.label }}"{{ condition }}{% if field.type == 'checkboxes' and field.required %} data-required{% endif %}
|
|
{% if described|trim %}aria-describedby="{{ described|trim }}"{% endif %}>
|
|
<legend>{{ field.label }}{{ requiredMark }}</legend>
|
|
{% set picked = field.type == 'checkboxes' ? value|split(', ') : [value] %}
|
|
{% if bildauswahl %}<div class="form-choice-grid">{% endif %}
|
|
{% for option in field.options %}
|
|
{% set bild = optionImages[field.images[option]|default('')]|default(null) %}
|
|
<div class="{{ bildauswahl ? 'form-choice-card' : 'check-row' }}">
|
|
<input id="{{ id }}-{{ loop.index }}" name="{{ field.name }}{{ field.type == 'checkboxes' ? '[]' : '' }}" type="{{ field.type == 'checkboxes' ? 'checkbox' : 'radio' }}" value="{{ option }}"{{ option in picked ? ' checked' : '' }}{{ field.type == 'radio' and field.required ? ' required' : '' }}{{ error ? ' aria-invalid="true"' : '' }}>
|
|
<label for="{{ id }}-{{ loop.index }}">{% if bildauswahl %}<span class="form-choice-picture">{% if bild %}<img src="{{ bild.src }}" alt="" width="{{ bild.width }}" height="{{ bild.height }}" loading="lazy" decoding="async">{% endif %}</span><span class="form-choice-text">{{ field.optionLabels[option]|default(option) }}</span>{% else %}{{ field.optionLabels[option]|default(option) }}{% endif %}</label>
|
|
</div>
|
|
{% endfor %}
|
|
{% if bildauswahl %}</div>{% endif %}
|
|
{% if field.help %}<p class="field-note" id="{{ id }}-help">{{ field.help }}</p>{% endif %}
|
|
{% if error %}<p class="field-error" id="{{ id }}-error">{{ error }}</p>{% endif %}
|
|
</fieldset>
|
|
{% else %}
|
|
<div class="form-field{{ error ? ' has-error' : '' }}" data-field data-label="{{ field.label }}"{{ condition }}>
|
|
<label for="{{ id }}">{{ field.label }}{{ requiredMark }}{% if field.unit|default('') %}<span class="visually-hidden"> {{ 'in'|kant_t }} {{ field.unit }}</span>{% endif %}</label>
|
|
{% if field.type == 'textarea' %}
|
|
<textarea id="{{ id }}" name="{{ field.name }}" rows="6" maxlength="5000"{{ field.required ? ' required' : '' }}
|
|
{% if described|trim %}aria-describedby="{{ described|trim }}"{% endif %}{{ error ? ' aria-invalid="true"' : '' }}>{{ value }}</textarea>
|
|
{% elseif field.type == 'select' %}
|
|
<select id="{{ id }}" name="{{ field.name }}"{{ field.required ? ' required' : '' }}
|
|
{% if described|trim %}aria-describedby="{{ described|trim }}"{% endif %}{{ error ? ' aria-invalid="true"' : '' }}>
|
|
<option value="">— {{ 'bitte wählen'|kant_t }} —</option>
|
|
{% for option in field.options %}<option value="{{ option }}"{{ value == option ? ' selected' : '' }}>{{ field.optionLabels[option]|default(option) }}</option>{% endfor %}
|
|
</select>
|
|
{% elseif field.type == 'number' %}
|
|
<div class="form-number{{ field.unit|default('') ? ' has-unit' : '' }}">
|
|
<input id="{{ id }}" name="{{ field.name }}" value="{{ value }}" type="number" step="any" inputmode="decimal"
|
|
{% if field.min is not null %}min="{{ field.min }}"{% endif %} {% if field.max is not null %}max="{{ field.max }}"{% endif %}
|
|
{{ field.required ? ' required' : '' }}
|
|
{% if described|trim %}aria-describedby="{{ described|trim }}"{% endif %}{{ error ? ' aria-invalid="true"' : '' }}>
|
|
{% if field.unit|default('') %}<span class="form-unit" aria-hidden="true">{{ field.unit }}</span>{% endif %}
|
|
</div>
|
|
{% else %}
|
|
<input id="{{ id }}" name="{{ field.name }}" value="{{ value }}" maxlength="200"
|
|
type="{{ {email: 'email', phone: 'tel'}[field.type]|default('text') }}"
|
|
autocomplete="{{ {email: 'email', phone: 'tel'}[field.type]|default('on') }}"
|
|
{{ field.required ? ' required' : '' }}
|
|
{% if described|trim %}aria-describedby="{{ described|trim }}"{% endif %}{{ error ? ' aria-invalid="true"' : '' }}>
|
|
{% endif %}
|
|
{% if field.help %}<p class="field-note" id="{{ id }}-help">{{ field.help }}</p>{% endif %}
|
|
{% if error %}<p class="field-error" id="{{ id }}-error">{{ error }}</p>{% endif %}
|
|
</div>
|
|
{% endif %}
|
|
{% endfor %}
|
|
{% if hasSteps %}</fieldset>{% endif %}
|
|
{% if turnstileKey|default('') %}
|
|
{# Cloudflare Turnstile. Braucht JavaScript; ohne Skript bleibt das Formular unabsendbar, und
|
|
genau deshalb ist die Prüfung eine Entscheidung und keine Voreinstellung. #}
|
|
<div class="cf-turnstile" data-sitekey="{{ turnstileKey }}" data-language="{{ form.locale|default('de') }}"></div>
|
|
<noscript><p class="field-error">{{ 'Zum Absenden dieses Formulars wird JavaScript benötigt. Schreib uns sonst gern direkt eine E-Mail.'|kant_t }}</p></noscript>
|
|
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
|
|
{% endif %}
|
|
<button class="button cms-button" type="submit">{{ form.settings.buttonLabel|default('Absenden')|kant_t }}</button>
|
|
</form>
|
|
{% endif %}
|