Style password reset flow and send corporate design mails
Give the lost password module the same centred card as the login, right-align the reset link, add explanatory copy and a way back to the login from every dead end Contao routes through mod_message. Contao only sets a text body on the opt-in mail, so a MessageEvent listener adds an HTML alternative in the corporate design. It reads the locale from the fallback root page because mails can go out from a CLI worker, where kernel.default_locale is "en". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -454,6 +454,68 @@ Wichtige Settings:
|
||||
- nur Warnsymbol statt Backend-Sperre
|
||||
- Admin darf Fragen auch nach Veröffentlichung oder nach vorhandenen Antworten umsortieren
|
||||
|
||||
## Mitglieder-Login und Passwort-vergessen
|
||||
|
||||
### Aufbau
|
||||
|
||||
- Login: Contao-Kernmodul `login` (tl_module ID 5), Seite `index`.
|
||||
- Im Modul ist die Seite `Passwort vergessen` als `pwResetPage` hinterlegt. Erst dadurch rendert Contao den Link `Passwort vergessen?` im Login-Formular (`div.password-reset` innerhalb der `formbody`).
|
||||
- Passwort vergessen: Contao-Kernmodul `lostPassword` (tl_module ID 9), Seite `passwort-vergessen`.
|
||||
- `reg_skipName = 1`, es wird also nur die E-Mail-Adresse abgefragt.
|
||||
- Captcha ist aktiv (`disableCaptcha = 0`).
|
||||
|
||||
### Gestaltung
|
||||
|
||||
- Alle Auth-Styles liegen zentral in `public/css/auth-module.css`, global eingebunden über `contao/config/config.php` (`$GLOBALS['TL_CSS']`).
|
||||
- Das Passwort-vergessen-Modul (`.mod_lostPassword.block`) verwendet bewusst dieselbe zentrierte Karte wie das Login-Modul: gleiche Breite, Radien, Farbverläufe, Schriften und Button-Optik.
|
||||
- Der Link `Passwort vergessen?` im Login ist rechtsbündig und im Corporate Design (Blau, Hover Orange, Chevron `›`). Auf Mobil wird er zentriert.
|
||||
- Fehler- und Bestätigungsmeldungen (auch das Contao-Template `mod_message`, das der Kern bei ungültigem/abgelaufenem Token einsetzt) werden als farbige Hinweisboxen dargestellt.
|
||||
|
||||
### Template-Override
|
||||
|
||||
- `contao/templates/frontend/mod_lostPassword.html.twig`
|
||||
- Wichtig: Der Override muss als **Twig**-Template angelegt sein. Contao 5 liefert für `mod_lostPassword` eine eigene Twig-Variante mit; ein `.html5`-Override im Bundle greift dadurch **nicht** und wird still ignoriert.
|
||||
- Das Template ergänzt oberhalb des Formulars einen Erklärblock (`.survey-auth-intro`) und darunter einen Link zurück zur Anmeldung (`.survey-auth-back`).
|
||||
- Es unterscheidet die beiden Schritte des Ablaufs anhand des Query-Parameters `token` (`pw-...`):
|
||||
- ohne Token: Texte aus `survey.auth.lost_password.*`
|
||||
- mit Token: Texte aus `survey.auth.set_password.*`
|
||||
- Die Texte liegen in `translations/messages.de.yaml` bzw. `messages.en.yaml` unter `survey.auth`, sind also nicht im Template hartkodiert.
|
||||
|
||||
### E-Mail-Versand
|
||||
|
||||
- Der Versand läuft über einen benannten Mailer-Transport `ksb`, konfiguriert in der Projektinstallation (`config/config.yaml`), nicht im Bundle:
|
||||
- `framework.mailer.transports.ksb` liest die SMTP-DSN aus der Umgebungsvariablen `MAILER_DSN` (in `.env.local`).
|
||||
- `contao.mailer.transports.ksb.from` setzt den Absender.
|
||||
- Häufige Fehlerquelle: Der Name der Umgebungsvariablen in `config.yaml` muss exakt zu dem in `.env.local` passen. Weicht er ab, wirft Symfony beim Absenden `EnvNotFoundException: Environment variable not found` und die Seite läuft in einen 500er.
|
||||
- Ist gar kein SMTP-Transport aktiv, fällt Symfony auf `sendmail` zurück. Auf diesem Server schlägt das mit `sendmail: fatal: User kinderschutzbund is not allowed to submit mail` fehl.
|
||||
- Mails laufen über Messenger (`contao_prio_high`, Doctrine-Transport). Fehlgeschlagene Nachrichten landen im Transport `contao_failure` und sind über `messenger:failed:show` einsehbar.
|
||||
|
||||
### Ablauf und Sackgassen
|
||||
|
||||
Der Kern von Contao wechselt im Passwort-vergessen-Modul in mehreren Fällen auf das Template `mod_message` (ungültiger Token, bereits benutzter Token, zu viele Versuche, Passwort erfolgreich gesetzt). Der Wrapper behält dabei die Klasse `mod_lostPassword`.
|
||||
|
||||
- `contao/templates/frontend/mod_message.html.twig` nutzt genau diese Klasse als Erkennungsmerkmal und ergänzt in diesen Fällen einen Button `Zurück zur Anmeldung`.
|
||||
- Damit endet insbesondere die Meldung „Das Passwort wurde aktualisiert" nicht mehr in einer Sackgasse.
|
||||
- Für alle anderen Module bleibt `mod_message` unverändert beim Kern-Markup.
|
||||
|
||||
Nach erfolgreicher Anforderung des Passwort-Links macht `ModuleLostPassword::sendPasswordLink()` nur ein `reload()`, wenn im Modul **keine** Weiterleitungsseite (`jumpTo`) gesetzt ist – der Nutzer sähe dann gar keine Bestätigung.
|
||||
|
||||
- Deshalb ist im Modul (tl_module ID 9) die Seite `Passwortlink versendet` (tl_page ID 8, Alias `passwortlink-versendet`) als `jumpTo` hinterlegt.
|
||||
- Der Bestätigungstext liegt dort bewusst als ganz normales Contao-Textelement, damit er redaktionell pflegbar bleibt.
|
||||
- Das Inhaltselement trägt die CSS-Klasse `survey-auth-note`; darüber greift in `auth-module.css` dieselbe Kartenoptik wie beim Login. Der Button darin nutzt die Klasse `survey-auth-button`, der Link auf die Anmeldeseite das Insert-Tag `{{link_url::2}}`.
|
||||
|
||||
### HTML-Mails im Corporate Design
|
||||
|
||||
Contao verschickt die Passwort-Mail über `OptInToken::send()` und setzt dort ausschließlich einen Textbody – ein Template dafür gibt es im Kern nicht.
|
||||
|
||||
- `src/EventListener/CorporateMailListener.php` hängt sich stattdessen an das `MessageEvent` von Symfony Mailer und ergänzt zu reinen Text-Mails eine HTML-Alternative.
|
||||
- Template: `src/Resources/views/email/corporate.html.twig` (Tabellenlayout, Inline-Styles, Systemschriften – Webfonts sind in Mailclients nicht verlässlich).
|
||||
- Der Textteil bleibt unverändert erhalten. Mails, die bereits HTML mitbringen, werden nicht angefasst.
|
||||
- Der Listener ist idempotent: Das `MessageEvent` wird sowohl beim Einreihen in die Messenger-Queue als auch beim tatsächlichen Versand ausgelöst; der Check auf einen vorhandenen HTML-Body fängt das ab.
|
||||
- Absätze, die nur aus einer URL bestehen, werden als Call-to-Action-Button gerendert, zusätzlich mit der URL als kopierbarem Klartext darunter. Enthält die URL `token=pw-`, lautet die Beschriftung „Neues Passwort festlegen".
|
||||
- **Wichtig (Stolperfalle):** Mails können auch aus einem CLI-Worker heraus verschickt werden. Dort gibt es keinen Request, aus dem der Translator eine Sprache ableiten könnte, und `kernel.default_locale` ist in Contao standardmäßig `en`. Der Listener liest die Sprache deshalb aus der Fallback-Startseite (`tl_page.type = 'root' AND fallback = '1'`), sonst wechselt der Text je nach Versandweg zwischen Deutsch und Englisch.
|
||||
- Die Absender-/Footerdaten stehen direkt im Template, die übrigen Texte unter `survey.email.*` in den Übersetzungsdateien.
|
||||
|
||||
## Wichtige Dateien nach Themengebiet
|
||||
|
||||
Frontend-Liste:
|
||||
@@ -484,6 +546,16 @@ Exporte:
|
||||
- `src/Service/SurveyResultsExportService.php`
|
||||
- `src/Service/SurveyResultsPdfService.php`
|
||||
|
||||
Mitglieder-Login / Passwort vergessen:
|
||||
|
||||
- `public/css/auth-module.css`
|
||||
- `contao/templates/frontend/mod_lostPassword.html.twig`
|
||||
- `contao/templates/frontend/mod_message.html.twig`
|
||||
- `src/EventListener/CorporateMailListener.php`
|
||||
- `src/Resources/views/email/corporate.html.twig`
|
||||
- `translations/messages.de.yaml` (Abschnitte `survey.auth` und `survey.email`)
|
||||
- Projektinstallation: `config/config.yaml`, `.env.local`
|
||||
|
||||
Backend:
|
||||
|
||||
- `contao/dca/tl_survey.php`
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
{% extends '@Contao/block_unsearchable.html.twig' %}
|
||||
{% trans_default_domain 'messages' %}
|
||||
|
||||
{# Schritt 2: Der Nutzer kommt ueber den Link aus der E-Mail und vergibt ein neues Passwort. #}
|
||||
{% set surveyAuthScope = (app.request.query.get('token')|default('')) starts with 'pw-' ? 'set_password' : 'lost_password' %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="survey-auth-card">
|
||||
|
||||
<div class="survey-auth-intro">
|
||||
<h2 class="survey-auth-intro-headline">{{ ('survey.auth.' ~ surveyAuthScope ~ '.headline')|trans }}</h2>
|
||||
<p class="survey-auth-intro-text">{{ ('survey.auth.' ~ surveyAuthScope ~ '.intro')|trans }}</p>
|
||||
<p class="survey-auth-hint">{{ ('survey.auth.' ~ surveyAuthScope ~ '.hint')|trans }}</p>
|
||||
</div>
|
||||
|
||||
<form id="{{ formId }}" method="post">
|
||||
<div class="formbody">
|
||||
<input type="hidden" name="FORM_SUBMIT" value="{{ formId }}">
|
||||
<input type="hidden" name="REQUEST_TOKEN" value="{{ contao.request_token }}">
|
||||
{% if error|default %}
|
||||
<p class="error">{{ error }}</p>
|
||||
{% endif %}
|
||||
{{ fields|raw }}
|
||||
<div class="widget widget-submit">
|
||||
<button type="submit" class="submit">{{ slabel }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<p class="survey-auth-back">
|
||||
<a href="{{ app.request.baseUrl }}/">{{ 'survey.auth.back_to_login'|trans }}</a>
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,26 @@
|
||||
{% extends '@Contao/block_unsearchable.html.twig' %}
|
||||
{% trans_default_domain 'messages' %}
|
||||
|
||||
{#
|
||||
Contao setzt beim Passwort-vergessen-Modul in mehreren Faellen auf mod_message um
|
||||
(ungueltiger Token, bereits benutzter Token, zu viele Versuche, Passwort gesetzt).
|
||||
Der Wrapper behaelt dabei die Klasse mod_lostPassword. In genau diesen Faellen
|
||||
ergaenzen wir den Weg zurueck zur Anmeldung, damit der Nutzer nicht in einer
|
||||
Sackgasse landet.
|
||||
#}
|
||||
{% set surveyIsAuthMessage = 'mod_lostPassword' in (class|default('')) %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
{% if surveyIsAuthMessage %}
|
||||
<div class="survey-auth-card">
|
||||
<p class="{{ type }} message">{{ message }}</p>
|
||||
<p class="survey-auth-actions">
|
||||
<a class="survey-auth-button" href="{{ app.request.baseUrl }}/">{{ 'survey.auth.back_to_login'|trans }}</a>
|
||||
</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="{{ type }} message">{{ message }}</p>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
+402
-1
@@ -158,6 +158,39 @@
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* "Passwort vergessen?"-Link im Login-Modul */
|
||||
.mod_login.block.login .password-reset {
|
||||
margin-top: 0.1rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.mod_login.block.login .password-reset a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.15rem 0;
|
||||
border-bottom: 1px solid rgba(0, 100, 173, 0.3);
|
||||
color: var(--survey-auth-blue);
|
||||
font-family: "Blogger Sans", sans-serif;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
transition: color 0.18s ease, border-color 0.18s ease;
|
||||
}
|
||||
|
||||
.mod_login.block.login .password-reset a::after {
|
||||
content: "\203A";
|
||||
font-size: 1.05em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.mod_login.block.login .password-reset a:hover,
|
||||
.mod_login.block.login .password-reset a:focus {
|
||||
color: var(--survey-auth-orange);
|
||||
border-color: rgba(236, 124, 50, 0.55);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
nav.mod_customnav.block:has(a[href*="/abmelden"]) {
|
||||
position: fixed;
|
||||
top: 1em;
|
||||
@@ -231,4 +264,372 @@ nav.mod_customnav.block:has(a[href*="/abmelden"]) .level_1 > li > a:focus {
|
||||
.mod_login.block.login button.submit {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Passwort-vergessen-Modul (Contao "lostPassword")
|
||||
Gleiches Kartenlayout wie das Login-Modul, mittig zentriert.
|
||||
------------------------------------------------------------------ */
|
||||
|
||||
.mod_lostPassword.block {
|
||||
max-width: min(100%, 36rem);
|
||||
margin: clamp(1.5rem, 4vw, 3rem) auto;
|
||||
padding: clamp(1.5rem, 2.6vw, 2.4rem);
|
||||
border: 1px solid var(--survey-auth-border);
|
||||
border-radius: 1.8rem;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(0, 100, 173, 0.2), transparent 36%),
|
||||
radial-gradient(circle at bottom right, rgba(0, 174, 151, 0.16), transparent 38%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.97) 0%, rgba(248, 251, 255, 0.96) 100%);
|
||||
box-shadow: var(--survey-auth-shadow);
|
||||
box-sizing: border-box;
|
||||
font-family: "PT Sans Narrow", sans-serif;
|
||||
color: var(--survey-auth-ink);
|
||||
}
|
||||
|
||||
.mod_lostPassword.block .survey-auth-card {
|
||||
display: grid;
|
||||
gap: 1.35rem;
|
||||
}
|
||||
|
||||
.mod_lostPassword.block .survey-auth-intro {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
padding-bottom: 1.1rem;
|
||||
border-bottom: 1px solid rgba(162, 168, 180, 0.28);
|
||||
}
|
||||
|
||||
.mod_lostPassword.block .survey-auth-intro-headline {
|
||||
margin: 0;
|
||||
font-family: "Blogger Sans", sans-serif;
|
||||
font-size: clamp(1.35rem, 2.4vw, 1.7rem);
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
color: var(--survey-auth-blue);
|
||||
}
|
||||
|
||||
.mod_lostPassword.block .survey-auth-intro-text {
|
||||
margin: 0;
|
||||
font-size: 1.02rem;
|
||||
line-height: 1.5;
|
||||
color: var(--survey-auth-ink);
|
||||
}
|
||||
|
||||
.mod_lostPassword.block .survey-auth-hint {
|
||||
margin: 0;
|
||||
padding-left: 0.85rem;
|
||||
border-left: 3px solid var(--survey-auth-green);
|
||||
font-size: 0.94rem;
|
||||
line-height: 1.45;
|
||||
color: var(--survey-auth-muted);
|
||||
}
|
||||
|
||||
.mod_lostPassword.block form,
|
||||
.mod_lostPassword.block .formbody {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.mod_lostPassword.block .formbody {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.mod_lostPassword.block .widget {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.mod_lostPassword.block label {
|
||||
font-family: "Blogger Sans", sans-serif;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
color: var(--survey-auth-blue);
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.mod_lostPassword.block label .mandatory {
|
||||
margin-left: 0.15rem;
|
||||
color: var(--survey-auth-orange);
|
||||
}
|
||||
|
||||
.mod_lostPassword.block .invisible {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mod_lostPassword.block input.text,
|
||||
.mod_lostPassword.block input.password,
|
||||
.mod_lostPassword.block input.captcha,
|
||||
.mod_lostPassword.block input[type="text"],
|
||||
.mod_lostPassword.block input[type="email"],
|
||||
.mod_lostPassword.block input[type="password"] {
|
||||
width: 100%;
|
||||
min-height: 3.2rem;
|
||||
padding: 0.8rem 0.95rem;
|
||||
border: 1px solid rgba(162, 168, 180, 0.48);
|
||||
border-radius: 1rem;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
box-shadow: inset 0 1px 2px rgba(34, 34, 34, 0.04);
|
||||
box-sizing: border-box;
|
||||
font: inherit;
|
||||
color: var(--survey-auth-ink);
|
||||
transition: border-color 0.18s ease, box-shadow 0.18s ease, background-color 0.18s ease;
|
||||
}
|
||||
|
||||
.mod_lostPassword.block input.text:focus,
|
||||
.mod_lostPassword.block input.password:focus,
|
||||
.mod_lostPassword.block input.captcha:focus,
|
||||
.mod_lostPassword.block input[type="text"]:focus,
|
||||
.mod_lostPassword.block input[type="email"]:focus,
|
||||
.mod_lostPassword.block input[type="password"]:focus {
|
||||
outline: none;
|
||||
border-color: rgba(0, 100, 173, 0.72);
|
||||
box-shadow: 0 0 0 4px rgba(0, 100, 173, 0.12);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
/* Sicherheitsfrage: Rechenaufgabe als Hinweistext unter dem Feld */
|
||||
.mod_lostPassword.block .widget-captcha .captcha_text {
|
||||
order: 3;
|
||||
font-size: 0.9rem;
|
||||
color: var(--survey-auth-muted);
|
||||
}
|
||||
|
||||
.mod_lostPassword.block .widget-captcha input.captcha {
|
||||
max-width: 9rem;
|
||||
}
|
||||
|
||||
/* Zweiter Schritt: Passwortbestaetigung liegt als eigenes Feld direkt darunter */
|
||||
.mod_lostPassword.block .widget-password + .widget-password {
|
||||
margin-top: -0.15rem;
|
||||
}
|
||||
|
||||
.mod_lostPassword.block .widget-submit {
|
||||
margin-top: 0.3rem;
|
||||
}
|
||||
|
||||
.mod_lostPassword.block button.submit {
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 3.2rem;
|
||||
padding: 0.8rem 1.35rem;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(135deg, var(--survey-auth-blue), #0d7acb);
|
||||
color: #fff;
|
||||
font-family: "Blogger Sans", sans-serif;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.01em;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 16px 28px rgba(0, 100, 173, 0.24);
|
||||
transition: transform 0.18s ease, box-shadow 0.18s ease, filter 0.18s ease;
|
||||
}
|
||||
|
||||
.mod_lostPassword.block button.submit:hover,
|
||||
.mod_lostPassword.block button.submit:focus {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 18px 34px rgba(0, 100, 173, 0.28);
|
||||
filter: saturate(1.05);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Fehler- und Bestaetigungsmeldungen (auch das Contao-Template mod_message) */
|
||||
.mod_lostPassword.block p.error,
|
||||
.mod_lostPassword.block p.confirm,
|
||||
.mod_lostPassword.block p.message {
|
||||
margin: 0;
|
||||
padding: 0.85rem 1rem;
|
||||
border-radius: 1rem;
|
||||
border: 1px solid transparent;
|
||||
font-size: 0.98rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.mod_lostPassword.block p.error {
|
||||
border-color: rgba(236, 124, 50, 0.4);
|
||||
background: rgba(236, 124, 50, 0.12);
|
||||
color: #a4501a;
|
||||
}
|
||||
|
||||
.mod_lostPassword.block p.confirm {
|
||||
border-color: rgba(0, 174, 151, 0.4);
|
||||
background: rgba(0, 174, 151, 0.12);
|
||||
color: #00705f;
|
||||
}
|
||||
|
||||
.mod_lostPassword.block .survey-auth-back {
|
||||
margin: 0;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.mod_lostPassword.block .survey-auth-back a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0.15rem 0;
|
||||
border-bottom: 1px solid rgba(0, 100, 173, 0.3);
|
||||
color: var(--survey-auth-blue);
|
||||
font-family: "Blogger Sans", sans-serif;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
transition: color 0.18s ease, border-color 0.18s ease;
|
||||
}
|
||||
|
||||
.mod_lostPassword.block .survey-auth-back a::before {
|
||||
content: "\2039";
|
||||
font-size: 1.05em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.mod_lostPassword.block .survey-auth-back a:hover,
|
||||
.mod_lostPassword.block .survey-auth-back a:focus {
|
||||
color: var(--survey-auth-orange);
|
||||
border-color: rgba(236, 124, 50, 0.55);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Bestaetigungs-/Hinweiskarte als normales Contao-Textelement
|
||||
(Inhaltselement mit CSS-Klasse "survey-auth-note", z. B. auf der
|
||||
Seite "Passwortlink versendet"). Der Text bleibt damit im Backend
|
||||
ganz normal redaktionell pflegbar.
|
||||
------------------------------------------------------------------ */
|
||||
|
||||
.survey-auth-note {
|
||||
max-width: min(100%, 36rem);
|
||||
margin: clamp(1.5rem, 4vw, 3rem) auto;
|
||||
padding: clamp(1.5rem, 2.6vw, 2.4rem);
|
||||
border: 1px solid var(--survey-auth-border);
|
||||
border-radius: 1.8rem;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(0, 174, 151, 0.2), transparent 36%),
|
||||
radial-gradient(circle at bottom right, rgba(0, 100, 173, 0.16), transparent 38%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.97) 0%, rgba(248, 251, 255, 0.96) 100%);
|
||||
box-shadow: var(--survey-auth-shadow);
|
||||
box-sizing: border-box;
|
||||
font-family: "PT Sans Narrow", sans-serif;
|
||||
color: var(--survey-auth-ink);
|
||||
}
|
||||
|
||||
.survey-auth-note > h1,
|
||||
.survey-auth-note > h2,
|
||||
.survey-auth-note > h3 {
|
||||
margin: 0 0 0.9rem;
|
||||
font-family: "Blogger Sans", sans-serif;
|
||||
font-size: clamp(1.35rem, 2.4vw, 1.7rem);
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
color: var(--survey-auth-blue);
|
||||
}
|
||||
|
||||
.survey-auth-note .rte > p {
|
||||
margin: 0 0 0.8rem;
|
||||
font-size: 1.02rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.survey-auth-note .rte > p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Primaeraktion (z. B. "Zurueck zur Anmeldung") */
|
||||
.survey-auth-actions {
|
||||
margin: 1.4rem 0 0;
|
||||
}
|
||||
|
||||
.survey-auth-note .rte > p.survey-auth-actions {
|
||||
margin: 1.4rem 0 0;
|
||||
}
|
||||
|
||||
a.survey-auth-button {
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
min-height: 3.2rem;
|
||||
padding: 0.8rem 1.6rem;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(135deg, var(--survey-auth-blue), #0d7acb);
|
||||
color: #fff;
|
||||
font-family: "Blogger Sans", sans-serif;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.01em;
|
||||
text-decoration: none;
|
||||
box-shadow: 0 16px 28px rgba(0, 100, 173, 0.24);
|
||||
transition: transform 0.18s ease, box-shadow 0.18s ease, filter 0.18s ease;
|
||||
}
|
||||
|
||||
a.survey-auth-button:hover,
|
||||
a.survey-auth-button:focus {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 18px 34px rgba(0, 100, 173, 0.28);
|
||||
filter: saturate(1.05);
|
||||
color: #fff;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Meldungsseiten des Passwort-vergessen-Moduls (Contao-Template mod_message) */
|
||||
.mod_lostPassword.block .survey-auth-actions {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Hilfetexte und Feldfehler im Passwort-vergessen-Modul */
|
||||
.mod_lostPassword.block p.help {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.4;
|
||||
color: var(--survey-auth-muted);
|
||||
}
|
||||
|
||||
/* Fehlermeldung im Login-Modul im gleichen Stil */
|
||||
.mod_login.block.login p.error {
|
||||
margin: 0;
|
||||
padding: 0.85rem 1rem;
|
||||
border: 1px solid rgba(236, 124, 50, 0.4);
|
||||
border-radius: 1rem;
|
||||
background: rgba(236, 124, 50, 0.12);
|
||||
color: #a4501a;
|
||||
font-size: 0.98rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.mod_lostPassword.block {
|
||||
padding: 1.2rem;
|
||||
border-radius: 1.35rem;
|
||||
}
|
||||
|
||||
.mod_lostPassword.block button.submit {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.survey-auth-note {
|
||||
padding: 1.2rem;
|
||||
border-radius: 1.35rem;
|
||||
}
|
||||
|
||||
a.survey-auth-button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mod_login.block.login .password-reset,
|
||||
.mod_lostPassword.block .survey-auth-back {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mummert\SurveyBundle\EventListener;
|
||||
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
|
||||
use Symfony\Component\Mailer\Event\MessageEvent;
|
||||
use Symfony\Component\Mime\Email;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
use Twig\Environment;
|
||||
|
||||
/**
|
||||
* Ergänzt reine Text-Mails (z. B. die Contao-Kernmail "Passwort vergessen")
|
||||
* um eine HTML-Variante im Corporate Design des Kinderschutzbundes.
|
||||
*
|
||||
* Der Textteil bleibt unverändert erhalten, die HTML-Variante wird nur als
|
||||
* Alternative ergänzt. Mails, die bereits HTML mitbringen, werden nicht angefasst.
|
||||
*/
|
||||
#[AsEventListener]
|
||||
class CorporateMailListener
|
||||
{
|
||||
private const TEMPLATE = __DIR__.'/../Resources/views/email/corporate.html.twig';
|
||||
|
||||
private string|null $locale = null;
|
||||
|
||||
public function __construct(
|
||||
private readonly Environment $twig,
|
||||
private readonly TranslatorInterface $translator,
|
||||
private readonly Connection $connection,
|
||||
) {
|
||||
}
|
||||
|
||||
public function __invoke(MessageEvent $event): void
|
||||
{
|
||||
$message = $event->getMessage();
|
||||
|
||||
if (!$message instanceof Email) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Der Event wird beim Einreihen in die Queue und beim tatsächlichen Versand
|
||||
// ausgelöst. Der Check auf einen vorhandenen HTML-Teil macht den Listener
|
||||
// idempotent und lässt bewusst gesetzte HTML-Mails unangetastet.
|
||||
if (null !== $message->getHtmlBody()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$text = $message->getTextBody();
|
||||
|
||||
if (null === $text || '' === trim($text)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$blocks = $this->buildBlocks($text);
|
||||
|
||||
if ([] === $blocks) {
|
||||
return;
|
||||
}
|
||||
|
||||
$template = $this->twig->createTemplate(file_get_contents(self::TEMPLATE));
|
||||
|
||||
$message->html($template->render([
|
||||
'subject' => (string) $message->getSubject(),
|
||||
'blocks' => $blocks,
|
||||
'locale' => $this->resolveLocale(),
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Zerlegt den Rohtext in Absätze. Ein Absatz, der nur aus einer URL besteht,
|
||||
* wird als Call-to-Action-Button dargestellt.
|
||||
*
|
||||
* @return list<array{type: string, html?: string, url?: string, label?: string}>
|
||||
*/
|
||||
private function buildBlocks(string $text): array
|
||||
{
|
||||
$blocks = [];
|
||||
|
||||
foreach (preg_split('/\R\s*\R/', trim($text)) ?: [] as $paragraph) {
|
||||
$paragraph = trim($paragraph);
|
||||
|
||||
if ('' === $paragraph) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (preg_match('#^https?://\S+$#', $paragraph)) {
|
||||
$blocks[] = [
|
||||
'type' => 'button',
|
||||
'url' => $paragraph,
|
||||
'label' => $this->buttonLabel($paragraph),
|
||||
];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$blocks[] = [
|
||||
'type' => 'text',
|
||||
'html' => nl2br(htmlspecialchars($paragraph, ENT_QUOTES, 'UTF-8')),
|
||||
];
|
||||
}
|
||||
|
||||
return $blocks;
|
||||
}
|
||||
|
||||
private function buttonLabel(string $url): string
|
||||
{
|
||||
// Contao hängt an den Passwort-Link einen Opt-in-Token "pw-..." an.
|
||||
$key = str_contains($url, 'token=pw-')
|
||||
? 'survey.email.cta_password'
|
||||
: 'survey.email.cta_generic';
|
||||
|
||||
return $this->translator->trans($key, [], 'messages', $this->resolveLocale());
|
||||
}
|
||||
|
||||
/**
|
||||
* Mails werden auch von einem CLI-Worker verschickt. Dort gibt es keinen Request,
|
||||
* aus dem der Translator eine Sprache ableiten könnte, und `kernel.default_locale`
|
||||
* ist in Contao-Installationen standardmäßig "en". Deshalb wird die Sprache aus
|
||||
* der Fallback-Startseite gelesen, damit der Text nicht je nach Versandweg wechselt.
|
||||
*/
|
||||
private function resolveLocale(): string|null
|
||||
{
|
||||
if (null !== $this->locale) {
|
||||
return $this->locale;
|
||||
}
|
||||
|
||||
try {
|
||||
$language = $this->connection->fetchOne(
|
||||
"SELECT language FROM tl_page WHERE type = 'root' AND fallback = '1' ORDER BY sorting LIMIT 1"
|
||||
);
|
||||
} catch (\Throwable) {
|
||||
$language = false;
|
||||
}
|
||||
|
||||
return $this->locale = \is_string($language) && '' !== $language ? $language : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
{% trans_default_domain 'messages' %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{ subject }}</title>
|
||||
</head>
|
||||
<body style="margin:0; padding:0; background-color:#eef3f8;">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="background-color:#eef3f8;">
|
||||
<tr>
|
||||
<td align="center" style="padding:24px 12px;">
|
||||
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="600" style="width:600px; max-width:100%; background-color:#ffffff; border-radius:14px; overflow:hidden; border:1px solid #dde4ec;">
|
||||
|
||||
<tr>
|
||||
<td style="background-color:#0064ad; padding:22px 28px;">
|
||||
<div style="margin:0; font-family:Arial,Helvetica,sans-serif; font-size:17px; line-height:1.3; font-weight:bold; color:#ffffff;">Der Kinderschutzbund</div>
|
||||
<div style="margin:2px 0 0; font-family:Arial,Helvetica,sans-serif; font-size:13px; line-height:1.3; color:#cfe4f4;">Landesverband Sachsen e. V.</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="height:4px; line-height:4px; font-size:0; background-color:#ec7c32;"> </td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td style="padding:28px 28px 8px;">
|
||||
<h1 style="margin:0 0 18px; font-family:Arial,Helvetica,sans-serif; font-size:20px; line-height:1.3; font-weight:bold; color:#0064ad;">{{ subject }}</h1>
|
||||
|
||||
{% for block in blocks %}
|
||||
{% if block.type == 'button' %}
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:22px 0;">
|
||||
<tr>
|
||||
<td align="center" bgcolor="#0064ad" style="border-radius:26px;">
|
||||
<a href="{{ block.url }}" style="display:inline-block; padding:14px 28px; font-family:Arial,Helvetica,sans-serif; font-size:15px; line-height:1; font-weight:bold; color:#ffffff; text-decoration:none; border-radius:26px;">{{ block.label }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p style="margin:0 0 16px; font-family:Arial,Helvetica,sans-serif; font-size:13px; line-height:1.5; color:#5f6975;">
|
||||
{{ 'survey.email.link_fallback'|trans({}, 'messages', locale) }}<br>
|
||||
<a href="{{ block.url }}" style="color:#0064ad; word-break:break-all;">{{ block.url }}</a>
|
||||
</p>
|
||||
{% else %}
|
||||
<p style="margin:0 0 14px; font-family:Arial,Helvetica,sans-serif; font-size:15px; line-height:1.6; color:#22313f;">{{ block.html|raw }}</p>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td style="padding:8px 28px 0;">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%">
|
||||
<tr>
|
||||
<td style="height:1px; line-height:1px; font-size:0; background-color:#dde4ec;"> </td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td style="padding:18px 28px 26px;">
|
||||
<p style="margin:0 0 10px; font-family:Arial,Helvetica,sans-serif; font-size:13px; line-height:1.6; color:#5f6975;">
|
||||
<strong style="color:#22313f;">Der Kinderschutzbund Landesverband Sachsen e. V.</strong><br>
|
||||
Hamburger Straße 39b (Haus F)<br>
|
||||
01067 Dresden<br>
|
||||
Telefon: <a href="tel:+493514242044" style="color:#0064ad; text-decoration:none;">0351 4242044</a><br>
|
||||
E-Mail: <a href="mailto:info@kinderschutzbund-sachsen.de" style="color:#0064ad; text-decoration:none;">info@kinderschutzbund-sachsen.de</a><br>
|
||||
Web: <a href="https://www.kinderschutzbund-sachsen.de" style="color:#0064ad; text-decoration:none;">www.kinderschutzbund-sachsen.de</a>
|
||||
</p>
|
||||
<p style="margin:0; font-family:Arial,Helvetica,sans-serif; font-size:12px; line-height:1.5; color:#8a929d;">{{ 'survey.email.footer_note'|trans({}, 'messages', locale) }}</p>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -163,3 +163,18 @@ survey:
|
||||
no_questions_title: "Keine Fragen vorhanden"
|
||||
no_questions_text: "Für diese Umfrage sind noch keine Fragen angelegt."
|
||||
footer: "PDF-Export des Survey-Bundles"
|
||||
auth:
|
||||
lost_password:
|
||||
headline: "Passwort vergessen?"
|
||||
intro: "Kein Problem. Tragen Sie hier die E-Mail-Adresse ein, mit der Sie für die Umfrageplattform registriert sind. Wir senden Ihnen anschließend eine E-Mail mit einem Link, über den Sie sich ein neues Passwort vergeben können."
|
||||
hint: "Der Link ist aus Sicherheitsgründen nur eine begrenzte Zeit gültig. Falls keine E-Mail ankommt, prüfen Sie bitte auch Ihren Spam-Ordner."
|
||||
set_password:
|
||||
headline: "Neues Passwort vergeben"
|
||||
intro: "Bitte vergeben Sie jetzt Ihr neues Passwort für die Umfrageplattform. Anschließend können Sie sich damit sofort anmelden."
|
||||
hint: "Wählen Sie ein Passwort, das Sie nicht bereits an anderer Stelle verwenden."
|
||||
back_to_login: "Zurück zur Anmeldung"
|
||||
email:
|
||||
cta_password: "Neues Passwort festlegen"
|
||||
cta_generic: "Link öffnen"
|
||||
link_fallback: "Falls sich der Button nicht anklicken lässt, kopieren Sie bitte diese Adresse in die Adresszeile Ihres Browsers:"
|
||||
footer_note: "Diese E-Mail wurde automatisch von der Umfrageplattform des Kinderschutzbundes Landesverband Sachsen e. V. versendet. Bitte antworten Sie nicht direkt auf diese Nachricht."
|
||||
|
||||
@@ -163,3 +163,18 @@ survey:
|
||||
no_questions_title: "No questions available"
|
||||
no_questions_text: "No questions have been created for this survey yet."
|
||||
footer: "Survey bundle PDF export"
|
||||
auth:
|
||||
lost_password:
|
||||
headline: "Forgot your password?"
|
||||
intro: "No problem. Enter the e-mail address you are registered with. We will then send you an e-mail containing a link that lets you set a new password."
|
||||
hint: "For security reasons the link is only valid for a limited time. If no e-mail arrives, please also check your spam folder."
|
||||
set_password:
|
||||
headline: "Set a new password"
|
||||
intro: "Please choose your new password for the survey platform. You can log in with it right away."
|
||||
hint: "Please pick a password that you do not already use elsewhere."
|
||||
back_to_login: "Back to login"
|
||||
email:
|
||||
cta_password: "Set a new password"
|
||||
cta_generic: "Open link"
|
||||
link_fallback: "If the button does not work, please copy this address into your browser's address bar:"
|
||||
footer_note: "This e-mail was sent automatically by the survey platform of Der Kinderschutzbund Landesverband Sachsen e. V. Please do not reply to this message."
|
||||
|
||||
Reference in New Issue
Block a user