Changelog — BTV Portal
Alle Änderungen mit Bereichs-Präfix (core:, später <modul>:). SemVer;
app_version() liest die erste ## [x.y.z]-Überschrift.
[0.61.9] — 2026-07-27
- fix: Bild-Aktionen im Profilkopf einheitlich gemacht. Das native
<input type=file> brachte einen abgeschnittenen „Keine Datei ausgewählt"-Text
und einen nicht stylbaren Button mit; die drei Aktionen waren unterschiedlich
groß. Jetzt versteckt (barrierefrei über das Label erreichbar), ein gestyltes
Label übernimmt das Auswählen, und „Auswählen"/„Hochladen"/„Entfernen" sind
gleich breit. Bei Dateiauswahl zeigt das Label den (gekürzten) Namen; ohne
JavaScript voll bedienbar.
[0.61.8] — 2026-07-27
Optimierungs-Durchlauf (Code-Review):
- Aufgeräumt: Drei verwaiste Views gelöscht (
home.php,login.php,
platzhalter.php — nie über view() geladen); 19 tote CSS-Regeln aus dem
alten Design entfernt (Deko-SVG, alte Topnav, alter Profilkopf); totes
Registry-Feld daten aus app/blocks.php.
- Flash zentral: Die ~20-fach kopierte Flash-Markup-Zeile durch den Helper
flash_render() ersetzt. Behebt zugleich das ARIA-Level — Fehlermeldungen
bekommen jetzt role="alert" (assertiv) statt role="status".
- Konsistenz: Button „2FA zurücksetzen" → „Anmelde-Code zurücksetzen"
(Label und Button hießen unterschiedlich).
[0.61.7] — 2026-07-27
- Profil aufgeräumt: Die Stammdaten-Blöcke zeigten die Überschrift doppelt
(Block-Kopf „Beim BTV" + interne „BEIM BTV") und ließen oben Leerraum durch
die Trenner-Optik. Neuer Kompakt-Modus: Wird ein Abschnitt als eigener Block
gerendert, fallen die interne Überschrift und die Trennlinie weg, der Inhalt
steht direkt unter dem Block-Kopf. Die leere Listen-Spalte (Konten ohne
Lizenzen) wird nicht mehr gerendert.
- Maillog-Icon von „mail" (Briefumschlag) auf „send" (Papierflieger)
geändert — es stand in der Kopfzeile neben dem gleichen Briefumschlag der
Nachrichten und war nicht unterscheidbar.
[0.61.6] — 2026-07-27
- F8: Anleitungs-Illustrationen für „Anmelde-Code einrichten" ergänzt — neun
selbst gezeichnete, generische SVG-Mockups (iPhone / Android / QR, je drei
Schritte) unter public/assets/hilfe/. Keine echten App-Screenshots, keine
fremden Logos; skalierbar und CSP-konform als <img>. Die Anleitung zeigt
jetzt Bilder statt Platzhalter.
[0.61.5] — 2026-07-27
Abnahme-Befunde Teil 3 (Layout + Registrierung), im Browser verifiziert:
- fix (T7):
.alertzerriss mehrteilige Meldungen. Durchdisplay:flex
wurde jedes Inline-Element (z. B. eine lange E-Mail in <strong>) zu einem
eigenen Flex-Item und in eine schmale Spalte gequetscht — der Satz brach in
Kolumnen (Passwort-vergessen-Bestätigung). Icon jetzt als Inline-Prefix, Text
fließt normal. Gilt für alle Alerts.
- fix (T8): info@-Panel lesbar.
.alertsetzte global fett — der dichte
Drei-Absatz-Hinweis stand komplett in Fettschrift. Absätze auf Normalgewicht,
<strong> bleibt zur Betonung.
- fix (F4): 2FA-Button „In Authenticator-App öffnen" ragte aus der schmalen
Setup-Spalte; jetzt vollbreit und umbruchfähig.
- fix (F6): keine doppelte Verify-Mail bei fast gleichzeitigem Doppel-Start.
Der automatische Erstversand läuft jetzt mit Cap 1 (statt 3) — die zweite,
leicht versetzte Registrierung wird gedeckelt. „Erneut senden" behält Cap 5.
- F7: Systemstatus zusätzlich als benannter Sidebar-Eintrag (war nur ein
Icon in der Kopfzeile und wurde im Test nicht gefunden).
[0.61.4] — 2026-07-27
- core (C7): Reply-To für ausgehende Mails (Migration 0020,
mails.reply_to).
Der Versand läuft technisch weiter über den Portal-Absender (Graph App-only),
aber Nachrichten aus der Verwaltung tragen jetzt ein Reply-To auf das
Prüfteam-Postfach (pruefteam_mail) — Antworten landen dort, wo der Vorgang
bearbeitet wird, statt bei info@. Reply-To wird wie die Empfängeradresse gegen
Header-Injection gehärtet; simulate/SMTP/Graph setzen es je passend.
[0.61.3] — 2026-07-27
Abnahme-Befunde Teil 2 (Funktions- und Textfehler):
- fix (F1): „E-Mail erneut senden" meldet ehrlich. Der Rückgabewert des
Versands wurde ignoriert — war der Stundendeckel erreicht, meldete die Seite
trotzdem „verschickt", und die Mail kam nie. Jetzt ehrliche Rückmeldung, wenn
gerade nichts gesendet werden konnte.
- fix (T3): Kontosperre nennt den Grund („gesperrt"/„deaktiviert") und bietet
einen echten Hilfe-Kontakt-Button statt „(Link unten)".
- T9: Notfall-Codes in der PDF nummeriert (1.–10.), erleichtert das Abhaken.
[0.61.2] — 2026-07-27
Usability-Fixes aus dem fachlichen Abnahme-Test (Teil 1, ABNAHME-BEFUNDE.md):
- fix (F2): Panels lassen sich wieder alle verschieben. Die Einfügelogik
beim Ziehen verglich erst y, dann x — sobald zwei Blöcke nebeneinander in einer
Reihe standen (breit + schmal), waren ihre y-Mitten fast gleich und das
Einsortieren sprang oder blieb aus. Jetzt „nächster Mittelpunkt" (euklidisch),
robust in jedem Raster.
- fix (T2): Doppelter Text auf „Anmelde-Code einrichten" entfernt — der
Payoff-Satz stand bei aktivem Gate zweimal.
- T1: Anmelde-Code-Text weg von „Honorare und Bankdaten" → „schützt Deine
persönlichen und sensiblen Daten"; am Step-up-Gate jetzt kontextkonkret
(„… bevor Du Deine E-Mail-Adresse änderst"), über neuen optionalen Grund in
require_twofa().
- T4: Fokus bleibt beim Rollen-Block. Nach einer Rollenvergabe sprang die
Seite an den Anfang; jetzt Anker zurück auf den Block.
- T5: Geburtsdatum wird beim Tippen automatisch punktiert (TT.MM.JJJJ),
rein unterstützend (funktioniert ohne JS weiter).
- T6: „Adresse korrigieren" auf der Warteseite abgesetzt (Abstand + Linie).
- T10: „Datei auswählen" im Profilkopf ans Design angeglichen.
- I1: Rollen in „Angefragt" und „Zugewiesen" getrennt.
[0.61.1] — 2026-07-20
- core: Rollen-Vorschlag aus der Mitgliederverwaltung wird endlich angezeigt.
phoenix_rollen_vorschlag() gab es seit v0.5x, aber kein einziger Aufrufer
ausserhalb der Tests — die Einstellung phoenix.rollen_mapping haette also
auch gesetzt nichts bewirkt. Die Kontoseite zeigt den Vorschlag jetzt im Block
„Rollen & Rechte" als Angebot mit einem Klick, samt Begruendung („dort gefuehrt
als …"). Bereits vergebene Rollen werden nicht erneut vorgeschlagen.
Weiterhin Vorschlag, nie Auto-Zuweisung (8.1).
- core: Neue Systemrolle
uebungsleiter(„Übungsleiter/in", Migration 0019).
Produktiv liefert license-holders 23 verschiedene license_type-Werte —
aber ausnahmslos Qualifikationen (ÜL C Allround-Fitness, Trainer C
Breitensport Gerätturnen …), keine Funktionen. Fuer den Vorschlag gab es damit
kein sinnvolles Ziel: „Referent/in" waere falsch (das kommt aus den
Kurs-Quellen), „Ehrenamt" ebenso. Die neue Rolle ist bewusst rechtelos und
nicht selbst waehlbar — sie sagt nur aus, dass eine Lizenz gefuehrt wird.
phoenix.rollen_mappingproduktiv gesetzt: alle 23 Lizenzarten →
uebungsleiter. Bewusst auf dem Server aus den echten Indexwerten erzeugt,
nicht abgetippt: Der Abgleich ist eine exakte Zeichenkettengleichheit, ein
einziges falsches Zeichen ergaebe still „kein Vorschlag". Ein Testfall haelt
genau das fest (abweichende Schreibweise → nichts; Rand-Leerzeichen → egal).
[0.61.0] — 2026-07-20
- core: Profil und Kontoseite komplett neu aufgebaut. Beide Seiten bestehen
jetzt aus einem Kopfbereich (wer ist das, was ist offen) und darunter aus
BLOECKEN, die sich jede Person selbst anordnen und zuklappen kann. Neu:
app/blocks.php (Registry), views/_bloecke.php (Huelle), je ein Teil-
Template pro Block, app/controllers/layout.php, Migration 0018
(user_block_layout).
- core: Module koennen eigene Bloecke beisteuern ueber den Hook
<modul>_profil_bloecke($ansicht, $ctx) — dieselbe Mechanik wie bei den
Dashboard-Kacheln. Modul-Bloecke bekommen zwingend den Modulpraefix, damit
kein Modul einen Core-Block ueberschreiben kann.
- Bedienung bewusst doppelt: Ziehen am Griff (Pointer Events, also auch auf
dem Tablet) UND Knoepfe „nach oben"/„nach unten" plus Klapp-Knopf als echte
Formulare. Ohne JavaScript funktioniert alles — nur mit Neuladen statt ohne.
Reines Drag & Drop waere fuer Tastatur und Screenreader unbedienbar
(WCAG 2.1.1); der Griff ist deshalb aria-hidden und erscheint erst, wenn das
Skript laeuft.
- fix: Zwei Karten steckten in der Rollen-Schleife. „Referenten-Daten" und
„Verknuepfung loesen" standen INNERHALB des <li> der Rollen-Liste und wurden
je Rolle erneut ausgegeben — bei drei Rollen dreimal, in ungueltigem Markup.
- fix:
_stammdaten.phpbelegte die Variable$teileim Sichtbereich der
einbindenden View. Ein so allgemeiner Name kollidiert frueher oder spaeter mit
einer View-Variablen (beim Bau der Vorschau ist genau das passiert) — jetzt
$sd_aktiv.
- fix: Anordnung per JS haette nie gespeichert. Das Skript sendete das
CSRF-Feld als csrf, csrf_field() erzeugt _csrf — jeder Speicherversuch
waere an der Pruefung gescheitert. Vom neuen Seiten-Test gefunden, nicht im
Betrieb.
- core: Kontoseite zeigt offene Aufgaben im Kopf und den Zustand als Chips
(Status, Pruefung erforderlich, Zuordnung unklar) — vorher musste man sich das
aus einer Tabelle zusammensuchen.
[0.60.4] — 2026-07-20
- core: Stammdaten neu geordnet. Vier gleich breite Spalten fuer sehr
ungleiche Inhalte: Der BTV-Bereich mit Lizenzen und Kurskategorien quoll
ueber, waehrend die meist leere Bankverbindung genauso viel Platz bekam.
Jetzt zwei Bloecke fuer die eigenen Angaben (Person, Adresse) und je ein
eigener Block fuer „Beim BTV" (mit den Listen) und die Bankverbindung.
- fix: Lizenznummer stand doppelt — einmal als Einzelfeld „Lizenz", einmal
als erster Eintrag der Lizenzliste. Das Einzelfeld erscheint nur noch, wenn
es keine Liste gibt.
- fix: Herkunftshinweis wiederholte sich sechsmal. „Aus der
Mitgliederverwaltung" stand unter jedem einzelnen Wert und war praesenter als
die Werte selbst. Stammen alle gefuellten Felder einer Gruppe aus derselben
Quelle, steht der Hinweis einmal unter der Ueberschrift; bei gemischter
Herkunft bleibt er am Feld, damit die Ueberschrift nichts Falsches behauptet.
- fix: Der Einleitungssatz war zerrissen —
.herkunftist ein Block und
stand als Beispiel mitten im Fliesstext, was den Absatz in drei Teile brach.
- core: Benutzerverwaltung zeigt dieselbe Ansicht. Stammdaten liegen jetzt
in views/_stammdaten.php und werden von Profil und Verwaltung gemeinsam
genutzt — zwei Kopien waeren auseinandergelaufen. In der Verwaltung fehlten
bisher Lizenzen, Kurskategorien und Geschlecht ganz; ohne Bearbeitungsrecht
standen dort nur drei Tabellenzeilen. Der Herkunftshinweis wird dort ebenfalls
ermittelt.
[0.60.3] — 2026-07-20
- fix: Kurskategorien wurden ermittelt und dann still verworfen. Die
Variable fehlte in der use-Liste der Transaktions-Closure — drinnen war sie
undefiniert, gespeichert wurde NULL. Nichts stürzte ab, die Übernahme
meldete Erfolg, und im Profil stand nichts. Der neue Testfall prüft nicht
mehr die Ermittlung, sondern das Ergebnis in der Datenbank; nur so fällt
diese Fehlerart auf.
- fix: Von mehreren Lizenzen blieb genau eine übrig.
license-holders
liefert eine Zeile je Lizenz, nicht je Person — wer drei hat, steht
dreimal in der Antwort, mit derselben Personennummer. Weil der Index über die
Personennummer schlüsselt, überschrieb bisher jede weitere Zeile die vorige;
welche Lizenz überlebte, entschied die Reihenfolge der Antwort. Beim
Zusammenführen werden Listen jetzt vereinigt statt ersetzt (Lizenzen über
Nummer bzw. Art entdoppelt), Einzelwerte weiterhin nur ergänzt.
- fix: Ein zweiter Kurs-Satz verlor seine Kategorien. Für dasselbe
Zusammenführen gab es zwei Regeln: Der Upsert-Weg vereinigte, der
Anreicherungs-Weg füllte nur Lücken — war schon eine Kategorie da, kamen die
übrigen nie an. Beide Wege nutzen jetzt dieselbe Funktion.
- core: Mehrere Lizenzen im Profil (
profiles.lizenzen, Migration 0017)
mit Art, Nummer und Gültigkeit; Kurskategorien und Lizenzen werden im Profil
angezeigt. lizenz_ref bleibt als führende Einzelangabe bestehen.
[0.60.2] — 2026-07-20
- fix: „Daten neu holen" fand nichts, obwohl Daten da waren. Die Regel „bei
mehr als einem Namenstreffer passiert gar nichts" schützt vor Namensvettern —
sie blockierte aber auch den häufigen Realfall, dass **dieselbe Person auf
mehrere Index-Einträge verteilt** ist: einmal als Lizenzinhaber mit
angereichertem Kurs-Satz, einmal als eigener Kurs-Satz, der sich nicht
zuordnen ließ.
Jetzt wird unterschieden: Stimmen bei allen Treffern **E-Mail und
Geburtsdatum** überein, ist es ein Mensch — die Einträge werden
zusammengeführt, der mit der echten Personennummer führt, und die
Kurskategorien beider werden vereinigt (sonst ginge die Hälfte verloren).
Unterscheiden sich die Merkmale, passiert weiterhin nichts. Zwei Testfälle
sichern beide Seiten ab.
[0.60.1] — 2026-07-20
- **fix: Beim Zusammenführen gingen die Zusatzangaben der zweiten Quelle
verloren.** Der Merge setzte nur das Quellen-Kennzeichen und behielt den
bestehenden Payload. Die Kurs-Quellen liefern aber Geschlecht und
Kurskategorien, die Lizenzinhaber-Quelle nicht — dadurch trug nach dem ersten
Lauf nur rund ein Drittel der Referenten diese Felder. Jetzt reichert der
Merge den Payload an: ergänzt wird ausschließlich, was fehlt; der zuerst
geschriebene Satz bleibt führend.
[0.60.0] — 2026-07-20
Referenten-Daten vollständiger übernehmen (Entscheidung 2026-07-20).
- Neu: Geschlecht und Kurskategorien aus der Referenten-Quelle
(Migration 0016). Die echte API-Antwort liefert deutlich mehr als die Spec
beschreibt — unter anderem sex, courses und **entgegen der Spec auch
birthdate**. Letzteres verbessert den sicheren Match für alle Sätze, in
denen es gesetzt ist.
- Geschlecht wird nicht geraten: Die Spec kennt 1/2/5, in den echten Daten
stehen aber auch 3 und 14. Unbekannte Codes werden gar nicht übernommen —
ein falsch zugewiesenes Geschlecht ist verletzend, eine fehlende Angabe nicht.
- Kurskategorien werden bereinigt: Die Quelle liefert Listen mit
null-Einträgen und Duplikaten (beides real belegt). Ungefiltert stünden
leere Zeilen und Wiederholungen im Profil.
- Anzeige: Kategorien als Liste im Profil (eine Person hat bis zu 13 davon —
als Fließtext unlesbar), Geschlecht bei den Personendaten, beides auch in der
Admin-Einsicht.
- Neu: Knopf „Daten neu holen" in der Verwaltung. Der reguläre Weg füllt nur
leere Felder — ändert sich in der Mitgliederverwaltung eine Anschrift, käme
sie dort nie an. Dieser Weg ersetzt auch belegte Angaben, **rührt aber
bestätigte Stammdaten nie an**: Die Bestätigung ist ein Nachweis mit Zeit und
IP (12.5.3), den eine Fremdquelle nicht im Vorbeigehen entwerten darf. Die
Identitätskennung setzt auch dieser Weg nicht.
[0.59.1] — 2026-07-20
- Rollenentzug löst jetzt auch den Verweis. Bisher blieb
referenten_id
stehen, wenn die Rolle „Referent/in" entzogen wurde — ein Verweis auf einen
Referenten-Datensatz an einem Konto, das keiner mehr ist. Die übernommenen
Stammdaten bleiben dagegen bewusst erhalten: Sie gehören inzwischen der
Person, sie hat sie möglicherweise bestätigt, und ein Konto ohne Anschrift
zurückzulassen wäre schlechter als eine Anschrift, die einmal aus der
Mitgliederverwaltung kam.
[0.59.0] — 2026-07-20
Rollenbezogener Abgleich für Referentinnen und Referenten (Entscheidung
2026-07-20). Wird die Rolle „Referent/in" bestätigt, holt das Portal aus den
Kurs-Quellen, was dort an Stammdaten steht.
- Vor- und Nachname genügen — bewusst schwächer als sonst, weil
course-speakers kein Geburtsdatum liefert: 286 Index-Einträge sind über
den sicheren Weg gar nicht auffindbar.
- Der Preis dafür sind drei harte Grenzen:
1. phoenix_person_id wird nie gesetzt. An dieser Kennung hängen
Stammdaten und später Honorare — ein Namensvetter ohne eigenes Konto würde
sie sonst unbemerkt erben. Die Referenten-Kennung landet stattdessen in
profiles.referenten_id.
2. Nur leere Felder werden gefüllt. Was jemand selbst eingetragen hat,
ist seine Aussage über sich.
3. Bei mehr als einem Namenstreffer passiert gar nichts — genau dort wäre
die Verwechslung wahrscheinlich.
- Die übernommenen Werte landen als Snapshot in
phoenix_matches, damit im
Profil der Hinweis „aus der Mitgliederverwaltung" erscheint. Ohne ihn stünden
fremde Angaben unkommentiert im Profil.
- „Ehrenamt" löst bewusst nichts aus: Für Funktionäre ohne Lizenz gibt es in
Phönix keine abrufbare Personenliste (offene Frage an it4sport). Lieber nichts
als geraten.
- Nebenbei:
repo_profile()liefertereferenten_idgar nicht mit — das Feld
existiert seit Beginn im Schema, war aber nirgends lesbar.
[0.58.7] — 2026-07-20
- Neu: Ende-zu-Ende-Test über die ganze Kette *Sync → Index → Abgleich →
Verknüpfung → Herkunftsanzeige*. Anlass: Sechs Fehler dieses Tages hatten
alle 293 Einzeltests passiert, weil jeder davon eine Funktion gegen
Fixtures prüfte — die Fehler saßen aber in den Übergängen. Der neue Test läuft
bewusst über den Index-Pfad, nicht über die simulate-Fixtures, und prüft
am Ende, dass die Verknüpfung wirklich in der Datenbank steht — nicht nur,
dass eine Erfolgsmeldung erschien. Er hätte vier der sechs Fehler gefunden.
Dazu zwei Gegenproben: Namensvetter → Klärfall statt Verknüpfung, leerer Index
→ fehler statt kein_treffer.
- Dafür eine Naht in
phoenix_mode(), die nur im Testlauf greift
(PHOENIX_TESTLAUF). Außerhalb der Tests bleibt der Modus reine
Konfiguration, und echte Netzaufrufe verhindert der bestehende Guard.
TESTPLAN-ABNAHME.md: Die überarbeitete Fassung übernommen und um fünf Fälle
ergänzt (zweistufige Stammdaten, Wiedereinstieg mittendrin, Vereinsauswahl,
nachträgliche Verknüpfung, Verbindung lösen, unterscheidbare Kandidaten).
Der Fall „Personennummer-Feld" ist entfallen — das Feld gibt es seit
v0.58.2 nicht mehr.
[0.58.6] — 2026-07-20
- fix: Eindeutige Treffer wurden nie verknüpft. Beim Index-Abgleich entstanden
die Kandidaten aus dem entschlüsselten Payload — und **phoenix_person_id
fehlte darin**. Folge: Die Registrierung speicherte den Treffer ohne
Verknüpfung (Vorbefüllung klappte trotzdem, deshalb fiel es nicht auf), und
„Jetzt verknüpfen" meldete danach „kein passender Eintrag" — obwohl der Treffer
mit Score 115 vorlag. Jetzt tragen Kandidaten die Kennung unter beiden Namen.
- Schritt 2 verlangte ein Geburtsdatum, das es dort nicht mehr gibt. Der Satz
„gib zusätzlich Dein Geburtsdatum ein" stammte aus der einstufigen Fassung —
seit Etappe 5 zweistufig ist, wird es in Schritt 1 erfragt. Text korrigiert.
- „Eindeutig ohne Kennung" wird nicht mehr als „kein Eintrag" verkauft. Tritt
dieser interne Widerspruch je wieder auf, landet der Fall in der Prüfung statt
in einer Unwahrheit für die Person.
[0.58.5] — 2026-07-20
- fix: Die erste Bestätigungsmail einer Registrierung ging oft gar nicht raus.
Der Versand-Deckel (B9) stand beim regulären Start auf **1 pro Stunde und
Adresse, während „erneut senden" 5** erlaubt — also genau verkehrt herum:
Der reguläre Weg war strenger als der manuelle Knopf. Wer sich innerhalb einer
Stunde ein zweites Mal registrierte, dessen Mail wurde **stillschweigend
verworfen**, während die Warteseite behauptete, sie sei unterwegs.
*(Mein erster Befund „nur Versandverzögerung" war falsch — er stützte sich auf
ältere Mails, die tatsächlich rausgingen.)*
Jetzt: Deckel beim Start 3 pro Stunde — gegen Mail-Bombing genauso wirksam
(der Unterschied zwischen 1 und 3 Mails ist kein Angriff, der zwischen 3 und
300 schon), aber ein Neustart nach Abbruch funktioniert wieder.
- Und vor allem: Der gedeckelte Versand ist jetzt sichtbar.
registrieren_verify_mail_senden() meldet zurück, ob eingereiht wurde; die
Warteseite sagt dann klar, dass vor Kurzem schon eine Mail an diese Adresse
ging — statt auf etwas warten zu lassen, das nie kommt. Vier Testfälle sichern
das Deckel-Verhalten ab.
[0.58.4] — 2026-07-20
- fix: Kurs-Quellen erzeugten einen zweiten Index-Eintrag. Die Feld-Abbildung
wich für address_nr auf id aus — course-speakers liefert aber eine eigene
UUID. Dadurch bekam derselbe Mensch zwei Einträge, die Zusammenführung lief
nie an, und aus einem eindeutigen Treffer wurde ein Klärfall. Jetzt gilt nur
address_nr als Personennummer; ob die Kurs-UUID überhaupt dieselbe Person
meint, ist bei it4sport offen. Zwei Testfälle.
- fix: Die Meldung nach „Jetzt verknüpfen" riet zu Unmöglichem. Sie bat um
Telefonnummer und Anschrift — Angaben, die entweder schon erfasst sind oder
sich im Profil gar nicht bearbeiten lassen. Jetzt drei ehrliche Fälle:
Abgleich gestört · nicht sicher entscheidbar (legt jetzt einen Klärfall an
statt in einer Sackgasse zu enden) · wirklich kein Eintrag.
- Prüfmaske zeigt E-Mail, Ort und Kennung. Zwei Kandidaten mit gleichem
Namen und Geburtsdatum waren sonst nicht unterscheidbar — dann ist die Maske
wertlos.
- Mail „Anmelde-Code eingerichtet" erklärt die Notfall-Codes — ausdrücklich
ohne die Codes selbst: Wer Zugriff aufs Postfach hätte, käme sonst an beide
Sicherheitsstufen, und genau davor soll der zweite Faktor schützen. (Technisch
ginge es ohnehin nur einmal — gespeichert sind sie nur gehasht.) Die Mail sagt
jetzt, dass es zehn Codes gibt, wo man sie herunterlädt und wie man im Profil
neue erzeugt.
[0.58.3] — 2026-07-20
- Warteseite nennt jetzt die Versanddauer. Das Portal verschickt nie sofort,
sondern reiht ein; der Cron stellt im Minutentakt zu. Ohne diesen Hinweis wirkt
jede Registrierung kaputt: Man schaut sofort ins Postfach, sieht nichts, drückt
„erneut senden" — dann kommen zwei Mails, und der Eindruck bleibt, dass nur die
zweite funktioniert hat. *(Nachgeprüft: Der Versand funktioniert, die
Bestätigungsmail ging 57 Sekunden nach dem Einreihen raus.)*
- **fix: Schaltflächen sahen unterschiedlich aus, obwohl dieselbe Klasse
draufsteht.** Die btn-Klassen setzten kein font-family — ein <button>
erbt die Schrift nicht (Browser geben Schaltflächen die System-Schrift), ein
<a> dagegen schon. Jetzt erben alle die Seitenschrift, haben eine gemeinsame
Grundlinie und eine Mindesthöhe je Stufe; .card-head bricht auf schmalen
Schirmen sauber um.
- fix: Auswahlfelder waren nur in
.formgestylt. In.form-inline
(Mail-Log-Filter, Benutzersuche) blieben sie im Browser-Standard und wirkten
neben den gestylten Feldern wie ein Fremdkörper. Jetzt einheitlich, mit einem
eingebetteten SVG-Pfeil — kein externer Abruf (CSP) und kein JavaScript.
[0.58.2] — 2026-07-20
- fix: Der Abgleich lief nach Schritt 1 nie wieder an. Schritt 1 setzte den
alten Abgleich auf offen zurück — das erzeugte einen unrettbaren Zustand:
Die Reservierung arbeitet mit INSERT IGNORE und kann eine bestehende Zeile
nicht neu übernehmen, und der Retry-Job greift nur Zeilen mit leerem
checked_at auf. Das Konto blieb still für immer offen, ohne Fehlermeldung.
Jetzt wird die Zeile gelöscht — eine bereits bestätigte Verknüpfung bleibt
dabei unangetastet (9.4). Zwei Testfälle.
- Feld „BTV-Personennummer" entfernt (Etappe 5, Schritt 1): Praktisch
niemand kennt seine Nummer. Ein optionales Feld, das fast alle leer lassen,
kostet nur Aufmerksamkeit und erzeugt das Gefühl, etwas nicht zu haben, was
man haben müsste. Das Scoring gewichtet address_nr weiterhin, falls die
Nummer später aus anderer Quelle kommt.
- Neu: Einsicht in die geladenen Daten unter
/verwaltung/mitgliederdaten — nur für echte Administratoren
(is_real_admin(), bewusst nicht system.view): Der Index enthält Daten
Dritter, die vom Portal nichts wissen; das geht auch Prüfteams nichts an.
Jeder Aufruf wird protokolliert. Gesucht wird über den verschlüsselten
Nachnamen, also nur exakt und ohne Teilwortsuche — der Index soll bewusst
keine durchsuchbare Adressliste des Verbands sein. Entschlüsselt wird nur,
was auf der angezeigten Seite steht.
[0.58.1] — 2026-07-20
- fix: Paginierung verlor unsere eigenen Parameter. Der Paginierer folgte
bevorzugt dem links.next der API — und diese URL trägt limit nicht mit.
Ab Seite 2 galt damit die Server-Vorgabe (25 statt 1000): Für 4.653 Vereine
brauchte es 187 statt 5 Anfragen, was in die Seiten-Obergrenze lief. Ergebnis
im ersten Live-Sync: official/clubs scheiterte, der Vereins-Cache blieb
leer, und 50 Aufrufe waren für nichts verbraucht.
Jetzt wird die Folgeseite selbst gebaut (aus meta.current_page), und nur
wenn kein brauchbares meta kommt, wird links.next genutzt — dann mit
wieder angehängtem limit. Vier Testfälle sichern beide Wege.
[0.58.0] — 2026-07-20
Letzte zwei Punkte des Umsetzungsauftrags plus zwei Fehler, die der
Praxiseinsatz aufgedeckt hat.
- Nachträgliche Verknüpfung (9.4): „Jetzt verknüpfen" im Profil, sichtbar
nur solange der Zugang nicht verbunden ist. Beim ersten Abgleich fehlten
vielleicht Merkmale oder der Index war leer — ohne diesen Weg bliebe jemand
dauerhaft unverknüpft. Kostet keinen API-Aufruf (rechnet gegen den Index),
und E2 gilt auch hier: verknüpft wird nur mit bestätigtem Geburtsdatum,
sonst wäre der Knopf ein Weg um die Regel herum.
- Nachfass-Mail aus der Prüfmaske (E7): einmalig (
nachfass_requested_at),
ohne Trefferdetails, mit klarem Hinweis auf Freiwilligkeit. Eine zweite Bitte
um dieselben Daten wirkt wie ein Mahnschreiben und bringt fachlich nichts.
Zwei Fehler behoben, die im Betrieb aufgefallen sind:
- Der Sync-Job hatte keine Tagesbremse. Die Job-Registry kennt keine
Intervalle — sie führt bei jedem Cron-Durchgang alle Jobs aus, und der Cron
läuft alle 1–2 Minuten. Sobald die Zugangsdaten vollständig gewesen wären,
hätte der Sync rund 12 Requests pro Durchgang gezogen: über 8.000 am Tag
gegen ein geteiltes Limit von 5.000 — unbemerkt, bis die Website ausfällt.
Jetzt phoenix.sync_abstand_stunden (Standard 24), Zeitstempel vor dem
Lauf gesetzt, damit ein Abbruch keinen Sofort-Neustart auslöst.
- Fehlende Konfiguration ist ein Zustand, kein Dauerfehler. Der Job meldete
bei jedem Durchgang einen Fehler — hunderte Mails am Tag, die echte Störungen
verdecken. Jetzt steigt er still aus und vermerkt den Grund einmal.
- Selfcheck: neue Zeile „Betriebsbereit" (auch im Modus
simulate— genau
dort will man vor dem Umschalten wissen, ob die Zugangsdaten reichen). Alte
Protokollzeilen erscheinen im simulate-Modus nicht mehr als ALARM, sondern
als historisch gekennzeichnete Info: Ein Alarm, den keine Handlung auflösen
kann, macht die ganze Seite unglaubwürdig.
status_notify.phpwird im Selfcheck jetzt explizit geladen — die
Zeitformatierung lief bislang nur deshalb nie ins Leere, weil das
Sync-Protokoll leer war.
[0.57.0] — 2026-07-20
Registrierungsstrecke der Phönix-Anbindung (Konzept 9.3, Auftrag §4).
- Etappe 5 ist zweistufig. Das ist keine Kosmetik: Der Abgleich lief bisher
vor der Stammdaten-Eingabe, also allein mit der E-Mail-Adresse — nach der
Regel „ein Merkmal reicht nie" könnte daraus nie ein eindeutiger Treffer
werden, es gäbe also nie eine Vorbefüllung. Jetzt: erst Name und
Geburtsdatum, dann abgleichen, dann die vorbefüllten Restfelder.
- Welcher Schritt dran ist, ergibt sich aus den Daten, nicht aus der
Session. Nur so funktioniert der Wiedereinstieg auf einem anderen Gerät
(6.3) — mit einem Session-Marker finge man am Handy wieder von vorn an.
- Optionale BTV-Personennummer mit Fundort-Hilfe („findest Du z. B. in
Schreiben des BTV … kannst Du leer lassen, wir finden Dich auch so"). Sie
wird nur für diesen einen Abgleich verwendet und nie als Kennung
übernommen — sie ist eine Behauptung, kein Nachweis.
- Nachfass-Stufe (E7): Nachgefragt wird nur, was noch fehlt, und nur wenn
es bei „wahrscheinlich" bleibt — nie nach bereits erfassten Angaben, und
immer mit der Möglichkeit weiterzugehen.
- Vereins-Selbstauswahl (9.2a): Ein eindeutig erkannter Verein wird
zugeordnet, ein unbekannter blockiert die Registrierung nicht. Sie darf nie
an einer Vereinsschreibweise scheitern.
- Zustimmungssatz zum Abgleich in Etappe 1 (E6) — angekündigt wird er dort,
wo eingewilligt wird, nicht erst, wenn er schon gelaufen ist.
- Geburtsdatum-Prüfung: deutsche und ISO-Schreibweise, nichts Unmögliches
(kein 32., kein zweistelliges Jahr, nichts in der Zukunft).
TESTPLAN-ABNAHME.mdum fünf Fälle ergänzt (C3a–C3e).
[0.56.0] — 2026-07-20
Verwaltungs-Teil der Phönix-Anbindung (Konzept 9.3/9.4, Auftrag §4).
- Entknüpfung als Verwaltungsaktion mit Pflicht-Begründung, Audit
(phoenix.unlinked) und Purge des Snapshots. Der Snapshot enthält die
Stammdaten der fremden Person — und genau deren Zuordnung war ja der Fehler
(Löschklasse 12.5.4). Die bereits ins Profil übernommenen Werte bleiben
dagegen stehen: Sie zu leeren würde die Person aus ihrem eigenen Konto
aussperren. Sie gelten danach als selbst erfasst, weshalb auch der
Herkunftshinweis von allein verschwindet — ein Test prüft beides.
Die Person wird neutral informiert, ohne Trefferdetails.
- Prüfmaske zeigt jetzt, WORAN ein Kandidat passt, nicht nur wie gut:
Spalte „Passt bei" mit den getroffenen Merkmalen plus Punktzahl. Eine nackte
Punktzahl ist für die Entscheidung wertlos — „Geburtsdatum, Telefon" sagt,
ob man ihr trauen kann. Die Option „ohne Zuordnung freigeben" heißt jetzt
„Treffer unzutreffend".
- Externer Label-Satz (9.3): Nach außen zählt nur, ob die Verbindung steht
(„mit der Mitgliederverwaltung verbunden" / „noch nicht … verknüpft" /
„Verbindung wird noch geprüft"). Die internen Zwischenstufen wahrscheinlich
und mehrdeutig bleiben intern — sie zu zeigen verunsichert nur und verrät
nebenbei, dass es mehrere ähnliche Personen gibt.
[0.55.0] — 2026-07-20
- Eigener Selfcheck-Bereich „Phönix" (9.2b/Anhang A): Konfiguration (Modus,
Basis-URL je Modul, Key gesetzt ja/nein — nie der Wert, Pepper,
Rückschreib-Schalter), Status je genutzter API, Match-Index, Tageszähler und
Vereins-Cache, jeweils mit Ampel.
Der Bereich ruft die Schnittstelle nicht auf — alle Angaben stammen aus
dem Protokoll des Sync-Laufs. Sonst würde ausgerechnet das Nachsehen, warum
das Tageskontingent alle ist, weiteres Kontingent verbrauchen. Ein Test
prüft das über den Zählerstand.
- Button „Anbindung jetzt prüfen": POST hinter
system.view, höchstens
alle 15 Minuten, je Schnittstelle genau ein limit=1-Request. Ein
ungedrosselter Prüfknopf auf einer Statusseite wäre eine Einladung, das
Kontingent per Klick zu verbrauchen. Im Modus simulate prüft er stattdessen
die Fixtures — sonst meldete die Seite „alles gut" für eine Anbindung, die
gar nicht existiert.
- Ein leerer oder überalterter Match-Index wird als Alarm gemeldet: Er
bedeutet, dass jeder Abgleich fehler liefert.
[0.54.0] — 2026-07-20
Phönix-Anbindung nach PHOENIX-UMSETZUNG-fuer-Code.md (Konzept 9.2/9.2b) —
Fundament, Client und Sync-Lauf. Wizard-Strecken und Selfcheck folgen.
- Der Abgleich macht keinen API-Aufruf mehr. Bisher hätte jede
Registrierung die Personenliste gezogen — rund 12 Requests pro Vorgang.
Beim Start des Portals sprengt das das mit Website und Reporting geteilte
Tageslimit von 5.000, und die Person wartet minutenlang. Jetzt baut ein
täglicher Sync-Lauf einen lokalen Match-Index (~12 Requests am Tag), der
Abgleich rechnet dagegen.
- Der erfundene Endpunkt ist raus.
/persons?email=existierte in keiner
it4sport-Spec. Ein Test hält fest, dass weder Funktion noch Pfad zurückkommen.
- Scoring statt E-Mail-Suche (E4): Normalisierung (Umlaute deutsch, also
ü→ue; Bindestriche; zweiter Vorname optional; Telefon-Länderkennung),
Gewichte und Schwellen als settings. „Eindeutig" verlangt mindestens ein
*sehr starkes* Merkmal und mindestens zwei Merkmale insgesamt — eine
übereinstimmende E-Mail allein verknüpft nie, weil geteilte Postfächer real
sind. Zwei gleich gute Kandidaten sind mehrdeutig, nicht wahrscheinlich.
- E2: Verknüpft wird erst, wenn das selbst eingegebene Geburtsdatum den
Kandidaten bestätigt.
- Der Index enthält keine Klartext-Merkmale (12.5.4, Daten Dritter):
Vergleich über Hashes mit einem Pepper aus der config.php (nicht aus der
Datenbank — sonst wären sie mitsamt der Tabelle rückrechenbar), Payload
sodium-verschlüsselt, entschlüsselt wird nur der Treffer. Ein Test prüft, dass
nichts im Klartext in der Tabelle landet.
- Lebenszyklus: rollierender Ersatz, aber nur wenn mindestens eine Quelle
geliefert hat — sonst löschte ein Ausfall den Index und legte den Abgleich für
alle lahm. Nach mehr als sieben Tagen ohne Sync wird er hart gelöscht; der
Abgleich liefert dann fehler, nie kein_treffer („wir konnten nicht prüfen"
ist etwas anderes als „es gibt dich nicht").
- Dedup über die Quellen:
course-speakersliefert weder Personennummer
noch Geburtsdatum. Ohne Zusammenführung stünde dieselbe Person zweimal im
Index und ein eindeutiger Treffer würde zum Klärfall. Bei mehreren möglichen
Bezügen wird bewusst nicht zusammengeführt — zwei Personen an einem
geteilten Postfach dürfen nicht verschmelzen.
- Plausibilisierung bestehender Verknüpfungen bei jedem Lauf (Stabilität von
address_nr ist unbestätigt): Abweichung erzeugt einen Prüfhinweis, nie eine
automatische Korrektur.
- Betrieb: Basis-URL je Modul, Tageszähler über jeden Versuch, 401-Backoff
bis Tageswechsel, Sync-Protokoll je Quelle (Grundlage des Selfcheck-Bereichs),
Vereins-Cache für die Selbstauswahl im Wizard.
- Guard: Der Testrunner setzt
PHOENIX_TESTLAUF; der Client verweigert
dann jeden Aufruf, sobald der Modus nicht simulate ist.
- Migration 0015 (idempotent). Neu nötig:
phoenix.index_pepperin der
config.php — einmal setzen, danach nie ändern.
[0.53.1] — 2026-07-20
Nachbesserung zu 0.52.1 — beide Punkte waren Folgen meiner eigenen Lösung.
- Tabellen füllen jetzt immer die verfügbare Breite. Der Scroll lag auf der
Tabelle selbst (display:block; overflow-x:auto). Dadurch wurde die innere
Tabellenbox „shrink-to-fit" und blieb schmaler als der Platz. Der Scroll
liegt jetzt in einem Container um die Tabelle (.tabelle-scroll), die
Tabelle selbst hat width:100%; min-width:100%.
- Keine senkrecht stehenden Wörter mehr auf schmalen Schirmen.
overflow-wrap:anywhere senkt die min-content-Breite auf ein einzelnes
Zeichen — im automatischen Tabellenlayout durfte eine Spalte dann auf
Buchstabenbreite kollabieren. Jetzt break-word (trennt erst beim
tatsächlichen Überlauf und füllt die Zeile) plus min-width:5rem je Zelle.
Weil sich diese Mindestbreite über die Spalten summiert, wächst der
Platzbedarf automatisch mit der Spaltenzahl: schmale Tabellen passen weiter
auf den Schirm, breite lösen den Scroll des Containers aus.
- Auf schmalen Schirmen zusätzlich etwas kompakter (Schriftgröße, Innenabstand).
[0.53.0] — 2026-07-20
Die Seite ist öffentlich erreichbar (Basic-Auth bewusst entfernt) — damit
brauchen die Rechtstexte einen belastbaren Weg.
- **Impressum und Datenschutz verweisen auf die zentral gepflegten Seiten des
Betreibers.** Doppelt gepflegte Rechtstexte laufen auseinander, und die
veraltete Fassung ist dann die angreifbare. Die Adressen sind Texte
(impressum_url, datenschutz_url) und unter *Einstellungen → Texte*
änderbar — der Core bleibt damit white-label-fähig (14.9/O18). Ist ein Feld
leer, rendert das Portal wieder seinen eigenen Text.
/impressumund/datenschutzleiten weiter, wenn eine Adresse
hinterlegt ist. Ohne das bliebe unter der alten Route eine leere Seite
erreichbar — genau der rechtlich heikle Fall, den Lesezeichen und
Suchmaschinen zuverlässig finden.
- Der Wert wird streng geprüft, bevor er in einem
Location-Header landet:
nur http/https, keine Steuerzeichen. Die Steuerzeichen-Prüfung läuft auf
dem Rohwert — trim() würde ein führendes \r\n still entfernen und die
Prüfung ins Leere laufen lassen.
- Ein ausdrücklich geleertes Feld bedeutet „kein Verweis" und fällt nicht
auf die Vorbelegung zurück (sonst ließe sich der Verweis nie entfernen).
- Die Erklärung zur Barrierefreiheit bleibt im Portal — sie ist abgenommen
und beschreibt diese Anwendung; sie wird bei Änderungen fortgeschrieben.
- Externe Rechtstext-Links öffnen in einem neuen Tab, damit eine laufende
Registrierung nicht verloren geht; für Screenreader wird das angesagt
(neue Klasse .visually-hidden) statt stillschweigend ein Fenster zu öffnen.
README-DEPLOY.mdbeschrieb ein „flaches Layout", das der Server seit
Längerem nicht mehr hat. Jetzt am verifizierten Ist-Stand: Repo- und
Server-Pfade sind 1:1 gleich, inklusive Sicherungs-, Reihenfolge- und
Verifikationsregeln.
[0.52.1] — 2026-07-20
- fix: Tabellen brachen auf schmalen Bildschirmen nicht um. Die
Mobil-Regel setzte auf Tabellen pauschal white-space:nowrap — der Rahmen
war also scrollbar, der Inhalt aber auf je eine Zeile gezwungen. Aus einer
harmlosen Tabelle wurde endloses Horizontal-Scrollen. Jetzt brechen Zellen
um (overflow-wrap:anywhere, das senkt auch die min-content-Breite — nur
dadurch kann eine Tabelle überhaupt schmaler werden als ihr längstes Wort);
das Scrollen bleibt als Sicherheitsnetz für sehr breite Tabellen. Für
Zellen, in denen ein Umbruch schadet (Datum/Uhrzeit), gibt es die Klasse
.nowrap — gesetzt im Mail-Log und bei den Notfall-Codes. Nebenbei: Der
Selektor enthielt .grid, eine Klasse, die es nicht gibt und die keine
View benutzt.
[0.52.0] — 2026-07-20
Drei Korrekturen aus dem Praxiseinsatz.
- fix: SSO-Konten bekamen
<oid>@sso.invalidstatt der echten Adresse.
Die E-Mail wurde nur übernommen, wenn Entra sie als email_verified meldet —
Entra ID sendet dieses Claim für Arbeitskonten aber gar nicht. Die
Bedingung war damit immer unwahr, jedes per SSO angelegte Konto trug die
Platzhalter-Adresse. Der Fehler war, ein *fehlendes* Claim wie ein
*verneinendes* zu behandeln. Jetzt gilt: email-Claim, sonst der UPN aus
preferred_username/upn — beides ist tenant-verwaltet, und der Token ist
per tid-Prüfung nachweislich aus dem eigenen Tenant, Gäste sind ausgeschlossen.
E004 bleibt unangetastet: Meldet Entra email_verified ausdrücklich als
false, wird weiterhin nichts übernommen. Bestehende Konten heilen sich beim
nächsten SSO-Login selbst. Drei Testfälle ergänzt.
- 2FA-Einrichtung: „Später einrichten" steht jetzt oben und gleichwertig
(7.6). Der Aufschub war ein blasser Ghost-Button *unter* der kompletten
Anleitung — wer sich überfordert fühlt, sieht ihn also genau dann nicht, wenn
er ihn braucht, und bricht stattdessen ab. Jetzt zwei gleich gerahmte
Optionen direkt unter der Einleitung.
- Stammdaten zeigen ihre Herkunft (13.5): Werte, die noch unverändert aus
dem Abgleich stammen, tragen den dezenten Zusatz „aus der
Mitgliederverwaltung". Kein Herkunfts-Flag in der Datenbank, sondern ein
Vergleich mit dem Snapshot — ein Flag würde beim ersten Bearbeiten lügen.
Systemnamen bleiben draußen; sechs Testfälle sichern das ab.
[0.51.0] — 2026-07-19
Entscheidung zu geteilten Postfächern (B10/6.5a): kein Verbot — Texte angepasst.
- Entscheidung des BTV vom 19.07.2026: Sammeladressen bleiben zulässig. Begründung, die meine ursprüngliche Empfehlung („persönliche Adresse verbindlich") widerlegt: Eine Sperre könnte nur am Präfix ansetzen — und das beschreibt die Schreibweise der Adresse, nicht die Zahl der Personen dahinter. Ein Vereinsvorstand, der
vorstand@allein nutzt, und eine freiberufliche Übungsleitung mitinfo@haben ein vollständig privates Postfach. Ein Verbot hätte genau diese Einzelpersonen ausgeschlossen — ohne jeden Sicherheitsgewinn, dafür ohne Zugang zum Portal. - fix: Der Hinweistext behauptete eine Pflicht, die es nicht gibt. Er sagte „Für dieses Portal brauchst Du eine persönliche Adresse" — nach dieser Entscheidung schlicht falsch, und für einen Freiberufler mit eigener
info@eine unnötige Verunsicherung. Neu als Frage formuliert („Nutzt noch jemand dieses Postfach?") mit dem ausdrücklichen Zusatz „Wenn sie nur Dir gehört, ist alles in Ordnung — dann mach einfach weiter." Die Begründung (Honorare, Reset-Wege) steht weiterhin dabei, für die, die tatsächlich teilen. - Der Prüfhinweis in der Freigabe-Queue heißt jetzt „Sammeladresse?" statt „evtl. Sammelpostfach" und erklärt im Titel, dass es kein Mangel ist — nur ein Anlass zum Nachfragen. Die Bestätigungsmail war bereits bedingt formuliert („Teilst Du dieses Postfach mit anderen? Dann…") und blieb unverändert.
- Konzept 6.5a von „offene Produktentscheidung" auf entschieden umgeschrieben, mit der tragenden Begründung und dem bewusst getragenen Restrisiko (Anhang B): Wer sein Konto auf einem tatsächlich geteilten Postfach anlegt, trägt das Risiko — das Portal weist vorher darauf hin, die Entscheidung liegt bei der Person, die als Einzige weiß, wer Zugang zu ihrem Postfach hat.
- Kein Settings-Schalter (unverändert): Sollte der BTV die Entscheidung je revidieren, ist das eine bewusste Code-Änderung mit Freigabe — kein Häkchen, das später jemand unbemerkt umlegt.
- Betrieb: Team-Postfach auf
portal@btv-turnen.degesetzt (Klärfall-Bericht + Antworten aus dem Portal). Das Portal schickt damit an sich selbst — unproblematisch, da es keine Mails liest; gesendete Mails werden nicht abgelegt (saveToSentItems: false). - 227 Tests grün; der Fall „Vorstand registriert sich mit
vorstand@" live durchgespielt: Konto wird angelegt, Mail geht raus, Hinweis ist eine Frage statt einer Hürde.
[0.50.0] — 2026-07-18
Öffentliche Hilfe-Adresse und internes Team-Postfach getrennt.
- fix: Das Portal schickte Betriebsmeldungen an die öffentliche Hilfe-Adresse. Antwortete jemand auf eine Portal-Nachricht, ging die Benachrichtigung an
help_email()— also aninfo@btv-turnen.de, die Adresse aus Footer, FAQ und jeder Mail-Signatur. Das wirft zwei verschiedene Dinge zusammen: Die eine ist das, was Nutzende anschreiben sollen, die andere das Postfach, in dem das Team gestupst wird. Solange das Portal nicht offiziell läuft, bekam die BTV-Geschäftsstelle damit Meldungen zu einem System, das sie noch gar nicht kennt. - Neu
team_mail(): Interne Benachrichtigungen (Klärfall-Bericht und Antworten aus dem Portal) gehen an das Team-Postfach (pruefteam_mail), nie an die öffentliche Adresse. Musternotify_toaus dem Referenzprojekt. Es geht ab sofort keine einzige automatische Mail mehr an die Hilfe-Adresse — sie ist reine Anzeige. - Ohne Team-Postfach unterbleibt die Mail — die Antwort ist im Portal sichtbar und geht nicht verloren, es wird nur niemand angestupst. Der Selfcheck sagt das jetzt ausdrücklich, statt es stumm geschehen zu lassen; die neue Zeile „Öffentliche Hilfe-Adresse" hält fest, dass dorthin nichts geschickt wird.
- fix (beim Fristen-Prüfen gefunden): Die SLA-Zahl war nicht pflegbar, der Text schon.
sla_klaerfall_werktagerechnet die konkreten Termine („eingegangen am 16.07. — Antwort bis 23.07."), stand aber nicht in der Oberfläche; editierbar war nur der Rückfall-Text. Wer ihn auf „3 Werktage" ändert, während die Rechnung bei 5 bleibt, lässt das Portal an zwei Stellen Verschiedenes versprechen. Beide Felder stehen jetzt nebeneinander, mit Hinweis auf ihren Zusammenhang. - Beschriftungen in den Einstellungen geschärft: „Öffentliche Hilfe-Adresse (nur Anzeige)" vs. „Team-Postfach (Klärfall-Bericht + Antworten aus dem Portal)".
[0.49.0] — 2026-07-18
Spam-Schutz für das öffentliche Registrierungsformular + Gmail-Dubletten.
Anlass: eine Registrierung mit dem Gmail-Punkt-Trick (b.al.la.kl.i.1.997@gmail.com). Kein Schaden — ein unbestätigter Rumpf ohne Passwort, der nach 72 h von allein verschwindet, keine Dubletten, keine Massenanlage. Aber er hat gezeigt, dass unser einziges öffentliches Formular völlig ungeschützt war, während das Referenzprojekt (Akkreditierung) Honeypot, Zeitfalle und Rate-Limit hat. CLAUDE.md nennt es ausdrücklich als Vorlage — übernommen wurde es nie.
- Honeypot + Zeitfalle (Muster Akkreditierung, Konzept 2.2): ein verstecktes Feld, das nur Bots ausfüllen, und eine Mindest-Ausfüllzeit. Bei Verdacht erscheint die normale Warteseite — ein Bot, der eine Fehlermeldung bekäme, lernt daraus, welche Regel er verletzt hat. Kein Konto, keine Mail. Das Feld ist
aria-hiddenundtabindex="-1", damit Screenreader und Tastatur nie hineingeraten. - Rate-Limit je IP-Bereich — bewusst großzügiger als die Vorlage (20/h statt 5) und mit ehrlicher Meldung. Hier ist der Unterschied zum Honeypot entscheidend: Ein gefülltes Honeypot-Feld ist sicher ein Bot; ein erreichtes Limit ist es nicht — dahinter können ein Verein im WLAN oder ein Mobilfunknetz mit tausenden Anschlüssen stecken. Wer still abgewiesen würde, wartete auf eine Mail, die nie kommt. Eigene Tabelle
form_attemptsstattlogin_attempts: Ein Registrierungsversuch darf niemanden vom Login aussperren. Aufbewahrung 24 h, Cleanup protokolliert. - Gmail-Punkt-Trick geschlossen (Migration 0014): Gmail ignoriert Punkte und alles ab
+—b.all@gmail.comundball@gmail.comsind dasselbe Postfach, für unsere UNIQUE-Spalte aber zwei Konten. Damit ließen sich aus einem Postfach beliebig viele Konten anlegen. Neu vergleichtemail_kanonisch()— ausdrücklich nur für Gmail: Bei anderen Anbietern sindvor.name@firma.deundvorname@firma.dewomöglich zwei verschiedene Menschen, die zusammenzuwerfen wäre schlimmer als das Problem. Gespeichert und angeschrieben wird immer die Adresse wie eingegeben. - Im eigenen Test gefunden (1): Die Dubletten-Prüfung verhinderte das zweite Konto — aber es ging trotzdem eine zweite Mail raus, weil an die *getippte* Variante statt an die Adresse des Kontos verschickt wurde. Der Versand-Deckel (B9) zählt je Zeichenkette und griff nicht. Genau die Verstärkung, die der Schutz verhindern sollte. Jetzt geht jede Mail an die Adresse des Kontos; drei Varianten ergeben live ein Konto und eine Mail.
- Im eigenen Test gefunden (2):
repo_user_by_email_kanonisch()hatte eine Abkürzung („ändert die Kanonisierung nichts, lohnt die Suche nicht"). Falsch herum gedacht — wer sein Konto mit Punkten angelegt und ohne getippt hat, wäre nicht gefunden worden: der Aussperr-Fall, den die Funktion verhindern soll. Abkürzung raus, Gegenprobe grün. - Folgerichtig mitgezogen: „Passwort vergessen" und „Anmelde-Code zurücksetzen" finden Gmail-Varianten jetzt ebenfalls — sonst wäre eine neue Sackgasse entstanden: Der Login findet das Konto, der Reset nicht, und die Person wartet auf eine Mail, die nie kommt.
- Kennzahl „Automatisierte Versuche abgewehrt" unter
/kennzahlen; beide Schwellen unter *Einstellungen → Schutz des Registrierungsformulars* pflegbar (gekappt, damit niemand den Schutz versehentlich abschaltet). - 12 neue Tests (Gmail-Varianten fallen zusammen · andere Anbieter NICHT · Honeypot · Zeitfalle in beide Richtungen · Rate-Limit je Formular/IP/Zeitfenster · Aussperr-Fall), 227 Tests grün. Alle vier Bot-Wege und der Menschen-Weg live durchgespielt.
[0.48.0] — 2026-07-16
Mail-Log: Fälle abhaken · Benutzerliste: Rollen auf einen Blick.
- Gescheiterte Mails lassen sich als erledigt markieren (Migration 0013). Ein endgültig gescheiterter Versand ist ein echter Vorfall und muss auffallen — aber er bleibt für immer im Bestand. Ohne ein „erledigt" stünde die Warnung dauerhaft, und eine Warnung, die immer leuchtet, sieht bald niemand mehr an: Genau so übersieht man die nächste echte. Warnbanner und Badge zählen jetzt nur noch offene Fälle; nach dem Abhaken bleibt eine ruhige Zeile („3 gescheiterte E-Mails im Log — alle als erledigt markiert").
- Gelöscht wird nichts. Der Eintrag bleibt samt Fehlergrund stehen, mit Zeitpunkt und Person der Klärung — der Vorfall bleibt nachvollziehbar, und die Anonymisierung nach Frist (12.5.4) räumt ihn ohnehin. „Doch nicht"-Rücknahme je Zeile, Sammel-Abhaken für den Regelfall (nach einer Störung sind es selten einzelne).
- Neuer Filter „gescheitert, offen" neben „gescheitert (alle)" — die Arbeitsliste getrennt vom Archiv.
- Benutzerverwaltung: neue Spalte „Rollen" — kompakt als Badges. Bestätigt und angefragt sind optisch getrennt (letztere mit „?" und Erklärung im Titel): Eine selbst gewählte, unbestätigte Rolle wirkt nicht (8.1) — sie gleichwertig anzuzeigen würde vortäuschen, der Fall sei erledigt.
- Die Rollen kommen aus derselben Abfrage (
GROUP_CONCAT), nicht aus einer Zusatzabfrage je Zeile — bei 100 Treffern wären das sonst 100 Extra-Abfragen für eine Spalte. Trennzeichen ist U+001F: Ein Rollen-Label könnte ein Komma enthalten, ein Steuerzeichen nie. - 5 neue Tests (Abhaken beendet die Warnung, lässt aber Status und Grund stehen · zweimal abhaken ändert nichts · Rücknahme · nur gescheiterte Mails sind abhakbar · Sammel-Abhaken · Rollen getrennt, genau eine Zeile trotz
GROUP BY), 216 Tests grün.
[0.47.0] — 2026-07-16
Löschkonzept: Übernahme der DSB-Vorgabe aus dem Akkreditierungsprojekt (2026-07-15).
Nicht übernommen: die Fristen. Jenes Verfahren kennt keine Finanzdaten und darf nach sechs Monaten alles löschen. Hier gilt § 147 AO — wer die sechs Monate überträgt, vernichtet steuerrelevante Belege. Übernommen sind die Muster.
- fix: Unsere elf Cleanup-Jobs löschten wortlos. Auf die Frage „läuft eure Löschung überhaupt?" gab es nur ein Achselzucken — eine Löschpflicht, die man nicht nachweisen kann, ist gegenüber dem Datenschutzbeauftragten wertlos. Jeder Job meldet jetzt seine Zeilenzahl ins Audit (
aufbewahrung.<job>,{"zeilen":14}, Akteursystem/cron) — nur Zahlen, nie wer oder was. - Mails werden anonymisiert statt gelöscht (DSB-Muster): Empfänger, Betreff und Inhalt raus, die Zeile bleibt. Datenminimierung heißt nicht „alles weg", sondern „nur das mit Personenbezug". Der Gewinn ist doppelt: Das gestern gebaute Mail-Log behält seinen Zustellnachweis („am 16.07. ging eine Bestätigungsmail raus, zugestellt nach zwei Versuchen"), und der Personenbezug verschwindet trotzdem vollständig. Löschen hätte beides zerstört.
- Fristen sind Konfiguration, nicht Code (
aufbewahrung_tage(), Felder unter *Einstellungen → Aufbewahrung & Löschung*). Die O7-Freigabe soll eine Eingabe sein, kein Deploy. Hart gekappt (1–3650 Tage, Konten mind. 30) — ein Tippfehler darf keine Daten vernichten. - Startwerte (Entscheidung 2026-07-16): deaktivierte Konten 1825 Tage (5 Jahre) · Mail-Historie 365 · Zugriffsprotokoll 90 · Kennzahlen 1825 statt bisher 730 — anonyme Aggregate haben keine Löschpflicht, und Betriebsberichte brauchen den Mehrjahresvergleich.
- Neu: Konten-Pseudonymisierung (12.5.4, Migration 0012). Dafür fehlte die Grundlage:
users.account_status_atgab es nicht — die Frist hätte ab gar nichts laufen müssen (oder abregistered_at, was ein gestern deaktiviertes Konto sofort vernichtet hätte). Entfernt wird der Personenbezug, nicht der Datensatz:user_id/public_idbleiben, weil daran Abrechnungen hängen (§ 147 AO, bis 10 Jahre). - Bewusst konservativ — und das ist der teure Teil: Nicht angefasst werden gesperrte Konten (eine Sperre ist vorübergehend), Konten ohne bekannten Wechselzeitpunkt (Bestand — lieber einer zu lange als einer zu früh) und Konten, die noch Rollen tragen (Hinweis auf verbleibende Bindung). Vier Tests sichern genau diese Ausnahmen.
- Für Module verbindlich (
app/modules/README.md): Zweistufigkeit ab Fachereignis — Artefakte früh, Vorgang spät. Bei Honorarabrechnungen kann der Beleg weg, während der Vorgang zehn Jahre bleibt. Plus: anonyme Aggregate übermetricssichern, anonymisieren schlägt löschen,cleanup_job()protokolliert automatisch, Fristen nie als Konstante. - Konzept 12.5.4a: die Übernahme dokumentiert — inklusive der Ansage, dass Projekt29 für O7 nur noch vier Zahlen nennen muss, alles andere ist gebaut und getestet.
- 12 neue Tests, 211 Tests grün.
[0.46.0] — 2026-07-16
Mail-Log mit eigener Rolle — und der Fehler, der die Diagnose unmöglich machte.
Anlass: ein echter Vorfall. Der Versand lief am 15.07. nachweislich (Mails auf sent); seit dem 16.07. um 16:59 scheitert alles, ein Nutzer registrierte sich und bekam nichts. Die Ursachensuche scheiterte an unserem eigenen Code.
- fix: Der Grund eines Fehlversands ging verloren.
mail_send_now()gibt nurtrue/falsezurück — Microsofts Antwort landete ausschließlich im PHP-Error-Log, also in der Cron-Mail an eine Adresse, die selten jemand liest. In der Datenbank stand „Versand fehlgeschlagen (Modus graph)". Das ist keine Diagnose, sondern eine Kapitulation: Ob das Client-Secret falsch ist, die Access-Policy blockt oder der Absender nicht existiert, sind drei verschiedene Probleme mit drei verschiedenen Lösungen. Jetzt landet Microsofts echte Fehlerkennung inlast_error(ErrorAccessDenied,AADSTS7000215, …). - Neu:
/mail-log(Muster Digitalportal) — welche E-Mails das Portal verschickt hat, ob sie ankamen, wie viele Versuche liefen, wann der nächste ist. Zu jedem Fehler steht Microsofts Originaltext und daneben, was er bedeutet und was zu tun ist („Das hinterlegte Client-Secret stimmt nicht — vermutlich rotiert und nicht nachgetragen"). Filter nach Status, Suche über Empfänger und Betreff. Warnbanner bei endgültig gescheiterten Mails, samt Konsequenz: Wenn es Bestätigungslinks waren, müssen die Betroffenen sie neu anfordern. - Eigene Rolle „Mail-Einsicht" (Migration 0011, Recht
mails.view): Wer den Versand beobachtet, muss keine Konten verwalten dürfen. Getestet:mail_einsichtsieht das Log und weder Benutzerverwaltung noch Einstellungen noch Zugriffsprotokoll.adminundgs_adminbekommen das Recht mit; nur ein Admin darf es vergeben (8.2), und es trägt das 2FA-Gate (7.6) — das Log zeigt Personendaten. - Kein Mail-Inhalt, strukturell: Die Abfrage liefert
body/body_redacted/secrets_encnicht — ein Test prüft das, statt dem Kommentar zu glauben. Die Bestätigungs- und Reset-Links darin sind Geheimnisse (B8), und der Text einer Mail an eine Person geht auch die Verwaltung nichts an. Das Log beantwortet „ist sie rausgegangen und warum nicht", nicht „was stand drin". - Fehlerzahl als Badge in Navigation und auf der Startseite: Ein kaputter Versand fiel bisher erst auf, wenn jemand anrief — genau so ist es heute gelaufen.
- 6 neue Tests (Rechte-Trennung · Deutung der drei realen Fehlerfälle · kein Inhalt im Log · Filter/Suche · der Grund überlebt den bool-Rückgabewert), 204 Tests grün.
[0.45.0] — 2026-07-16
Paket 7l: Geteilte Postfächer — Guardrails (UXC-10, Code-Teil von B10).
Entscheidung 2026-07-16: nur Hinweis, kein Verbot und kein Settings-Schalter. Ein Schalter, den niemand umlegt, wäre spekulative Konfiguration; wenn der BTV „persönliche Adresse Pflicht" entscheidet, ist das eine bewusste Code-Änderung mit Freigabe.
- UXC-10 · Hinweis auf der Warteseite bei bekannten Sammel-Präfixen (
info@,vorstand@,geschaeftsstelle@…) — mit Begründung: Wer nur „bitte eine andere Adresse" liest, hält es für Bürokratie; wer erfährt, dass die Kollegin sonst an seine Bankdaten kommt, versteht es sofort. Die „Adresse korrigieren"-Möglichkeit steht direkt daneben (dieselbe Mechanik wie beim Tippfehler-Vorschlag), der Wechsel ist also ein Klick. - Bestätigungsmail nennt den Zeitpunkt („Am 16.07.2026 um 22:05 Uhr wurde mit dieser Adresse eine Registrierung begonnen") und weist auf geteilte Postfächer hin. Die Landing-Page fragt vor dem Einlösen: „Hast Du diese Registrierung begonnen?" — sonst klickt, wer zuerst hinschaut, und erbt ein Konto, das er nie wollte.
- Prüfhinweis „evtl. Sammelpostfach" in der Freigabe-Queue: Vor einer Freigabe ist relevant, dass unklar ist, wer da eigentlich registriert ist.
- Exakter Abgleich des lokalen Teils, kein „enthält":
infoabend@,post.mueller@undhans@info.desind keine Sammeladressen. Ein Fehlalarm untergräbt genau den Hinweis, auf den es ankommt — wer ihn zu Unrecht sieht, lernt ihn zu überlesen. - Konzept 6.5a: Entscheidungsvorlage für den BTV. Die eigentliche Frage („persönliche Adresse Pflicht?") ist dokumentiert — mit dem Argument, das sie entscheidet: Postfach-Zugriff ist Konto-Zugriff. Passwort-Reset und 2FA-Reset gehen beide an diese Adresse, und der „Das war ich nicht"-Abbruchlink landet im selben Postfach; ein Mitnutzer muss ihn nur löschen. Bei geteilten Postfächern gilt die Sicherheitszusage des Portals also schlicht nicht. Empfehlung: Pflicht — samt dem Preis, der dazugehört (manche Ehrenamtliche haben keine eigene Adresse).
- 3 neue Tests (Erkennung · keine Fehlalarme · kaputte Eingaben), 198 Tests grün. Live durchgespielt: Hinweis, Mail-Zeitpunkt, Landing-Frage, Queue-Badge.
- TESTPLAN-ABNAHME.md: A5d (geteiltes Postfach).
[0.44.0] — 2026-07-16
Paket 7k: Anonyme Kennzahlen (UXC-09/UX-07) und O023.
- UXC-09 — das Portal misst sich jetzt selbst. Ohne Messung ist jede weitere UX-Entscheidung Raten: Wir haben die 2FA aufschiebbar gemacht (7.6), weil ein Review sie als härteste Hürde benannte — ob das stimmt, wusste bisher niemand. Die neue Auswertung unter
/kennzahlen(Rechtsystem.view, Migration 0010) zeigt den Registrierungs-Funnel mit Absprung je Etappe, die Login-Erfolgsquote, die Klärfall-Quote und — die interessanteste Zahl — wie viele den Anmelde-Code aufschieben und wie viele ihn später nachholen. Grundlage der Betriebsberichte an den BTV. - Anonym per Bauart, nicht per Zusage: Die Tabelle
metricshat genau drei Spalten —tag,metric,wert. Keineuser_id, keine IP, keine Session, keine Reihenfolge, kein Cookie, kein Drittdienst. Sieben Ereignisse ergeben eine Zeile, keine Einzelspur. Ein Test prüft die Spaltenliste statt dem Kommentar zu glauben: Ein Personenbezug ließe sich nicht einbauen, ohne dass eine Migration auffällt. Der „Absprung" im Funnel ist die Differenz zweier Zähler — dafür wird niemand verfolgt. Aufbewahrung 24 Monate (Cleanup-Job), Transparenzhinweis steht auf der Seite selbst. - Ehrliche Quoten: Ohne Bezugsgröße zeigt die Seite „—" statt „0 %". Eine erfundene Null wäre eine Aussage, die niemand gemacht hat.
- fix: O023 — ein abgewiesener Login wurde als Erfolg gebucht.
login_attempt_log(..., true)bei gesperrtem Konto hatte zwei Folgen: Die Daten logen (jede Auswertung überlogin_attempts— Betriebsberichte, Alarmschwellen, dieser Funnel — zählte abgewiesene Versuche als Anmeldungen), und die Drosselung griff nicht:login_wait_seconds()zählt nursuccess = 0. Wer das Passwort eines gesperrten Kontos kannte, konnte es unbegrenzt oft anpingen und daran ablesen, dass es noch gilt — ein kostenloses Passwort-Orakel, ausgerechnet an einem Konto, dem wir nicht mehr trauen. Die ehrliche Meldung bleibt (die Person muss erfahren, woran sie ist), sie kostet jetzt nur nicht mehr nichts. - Selfcheck: Zeile „Kennzahlen" — eine stumme Messung ist so wertlos wie keine und fiele sonst erst auf, wenn der erste Bericht leer bleibt.
- 9 neue Tests (Funnel-Rechnung inkl. Division durch null · Quoten ohne Grundlage · Spaltenliste der Tabelle · Tagesaggregation statt Einzelspur · „eine Messung darf nie kaputt machen, was sie misst" · O023 mit Gegenprobe: 20 „Erfolge" bremsten nie, 5 Abweisungen bremsen jetzt), 195 Tests grün.
[0.43.0] — 2026-07-16
Paket 7j: Hilfe-Telefon pflegbar und wählbar (O14/UXC-01).
- fix: Der Settings-Wert
help_phonewar nirgends einstellbar. Eingeführt in v0.31.0, aber nie in die Oberfläche gehängt — eine Einstellung, die niemand einstellen kann, ist keine. Eigener Fehler aus dieser Session, gefunden erst, als die Nummer tatsächlich hinterlegt werden sollte. Jetzt steht das Feld unter *Einstellungen → Kontakt & Team*, mit dem Hinweis, dass leer = kein Telefon anbieten bedeutet. - Die Nummer ist wählbar, nicht nur lesbar:
tel:-Link auf der Hilfe-Seite und in der Kontaktzeile aller Fehler-, Warte- und Sperrseiten. Auf dem Handy ist Tippen statt Abtippen der Unterschied zwischen „ruft an" und „gibt auf" — genau der Moment von Persona „Jürgen", der mit verlorenem Handy und verlorenen Codes vor der Code-Abfrage steht.help_phone_tel()räumt dabei Schreibweisen auf (089/5419 6874→tel:08954196874), ohne die Anzeige zu verändern. - Ohne konfigurierte Nummer entsteht kein leerer Link — sonst tippt jemand auf ein totes
tel:. Dann bleibt es bei der E-Mail-Adresse als Weg (keine Sackgasse). - 2 neue Tests (vier Schreibweisen → wählbare Nummer · kein Telefon ohne Konfiguration · Escaping: Der Wert kommt aus den Einstellungen und wird als HTML ausgegeben — ein Admin-Tippfehler darf keine Lücke reißen), 186 Tests grün.
[0.42.1] — 2026-07-16
- docs: Die Dokumenten-Regel steht jetzt dort, wo sie gelesen wird (
app/modules/README.md). Konzept 4.6 verbietet modul-eigene Uploads — aber im Code stand davon nichts, und ein Verbot ohne erreichbare Alternative wird gebrochen. Der Abschnitt nennt den Grund (DocuWare braucht einen Andockpunkt, nicht fünf), erklärt warumdocuments.phpbewusst fehlt (O057: die entwurfsbestimmenden Fragen — Dateitypen, Größen, Virenprüfung, Aufbewahrungsklasse nach § 147 AO — lassen sich ohne den ersten Vorgangstyp nur raten) und gibt dem Modul-Autor das verbindliche Sicherheitsmuster aus 12.2 an die Hand.
[0.42.0] — 2026-07-16
Paket 7i: „Diesem Browser vertrauen" — ehrlich benannt (P02-1, E096).
- fix: Das Portal versprach 30 Tage und lieferte 90. Die Dauer stand hart im Text der Checkbox; mit v0.30.0 wurde sie auf 90 Tage umgestellt (pflegbar) — der Text blieb. Jetzt kommt er aus
trusted_device_tage(), kann also nicht mehr auseinanderlaufen. Eigener Fehler aus dieser Session. - fix: P02-1 — „Diesem Gerät vertrauen" war die falsche Zusage. Vertraut wird ein Browser, kein Gerät: Wer den Portal-Link aus einer App heraus öffnet (WhatsApp, Outlook, Instagram), sitzt in deren eingebautem Browser mit eigenen Cookies — dort gilt das Vertrauen, im normalen Browser nicht. Technisch nicht behebbar (Cookies sind pro Browser), aber benennbar: Der Haken heißt jetzt „Diesem Browser … vertrauen" und der Hilfetext sagt vorher, was passiert. Sonst hält man die zweite Code-Abfrage für einen Fehler — und verliert das Vertrauen ins Portal, nicht in den Browser.
- fix: E096 — die Geräteliste zeigte den rohen User-Agent (
Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 …). Darin erkennt niemand sein Handy. Neu:geraet_klartext()macht daraus „Safari · iPhone" oder „Outlook-App (In-App-Browser) · Android" — In-App-Browser werden zuerst geprüft, weil sie zusätzlich „Safari"/„Chrome" im String tragen und sonst als normaler Browser durchgingen; genau die Verwechslung, die P02-1 beschreibt. Dazu deutsche Datumsangaben statt DB-Zeitstempel. - Beim Live-Test im eigenen Bau gefunden: Ich hatte den Klartext gespeichert und beim Anzeigen ein zweites Mal übersetzt — der zweite Lauf fraß die Kennzeichnung („Facebook-App (In-App-Browser) · iPhone" wurde wieder zu „iPhone"). Jetzt speichert die Datenbank die Tatsache (User-Agent), die Ansicht übersetzt. Das ist auch für den Altbestand richtig: Der lässt sich so jederzeit nachbessern.
- 5 neue Tests (In-App-Erkennung · normale Browser · Edge/Opera gewinnen gegen „Chrome" im selben String · kaputte Angaben ergeben nie eine leere Zelle · kein Systemwert mehr im Label), 184 Tests grün. Live geprüft: Anmeldung aus einem Facebook-In-App-Browser erscheint korrekt benannt in der Liste.
- TESTPLAN-ABNAHME.md: B2 prüft jetzt ausdrücklich den App-Browser-Fall und die lesbare Benennung.
[0.41.0] — 2026-07-16
Paket 7h: Notfall-Codes — Nachweis statt Behauptung (P01-2/P02-3).
- fix: Die Pflicht-Checkbox „Ich habe die Codes gesichert" war kein Nachweis. Man hakt sie an, während die Codes auf dem Bildschirm stehen — sie belegt nichts. Persona „Jürgen" hatte genau das getan und stand später ohne Codes da; das ist kein Randfall, sondern der Regelfall. Neu in zwei Schritten: (1) Codes zeigen, drucken/PDF — (2) Codes ausgeblendet, ein zufällig gewählter davon wird abgefragt. Erst wer in seine gespeicherte Liste schaut, kommt weiter.
- Bewusst keine Falle: „Codes noch einmal anzeigen" führt jederzeit zurück auf Schritt 1 — mit denselben Codes, es werden keine neuen erzeugt. Wer feststellt, dass er doch nichts gespeichert hat, soll das jetzt merken statt in einem Jahr beim Handywechsel.
- Der Nachweis verbraucht keinen Code: Verglichen wird gegen die Anzeige in der Session, nicht über
recovery_redeem()— sonst hätte die Person nach dem Nachweis nur noch neun. Live geprüft: danach sind es 10 von 10. - Zufälliger Index: Wäre es immer der erste Code, notierten sich Leute genau einen — und der Nachweis wäre wieder wertlos. Innerhalb einer Sitzung bleibt der Index stabil (ein Reload darf nicht plötzlich nach einem anderen Code fragen).
- 4 neue Tests (Streuung des Index · Stabilität in der Sitzung · Randfälle 0/1 Codes · kein Verbrauch, inkl. Abgrenzung zu
recovery_redeem()), 179 Tests grün. Live-Durchlauf: Schritt 1 zeigt 10 Codes, Schritt 2 zeigt 0, falscher Code wird freundlich abgelehnt, richtiger führt weiter. - TESTPLAN-ABNAHME.md: A7 beschreibt jetzt den echten Nachweis statt der Checkbox.
[0.40.0] — 2026-07-16
Paket 7g: Adressen mit Umlauten (P04-1).
- fix:
info@münchen.dewurde als „ungültige E-Mail-Adresse" abgewiesen.filter_var(FILTER_VALIDATE_EMAIL)kennt nur ASCII — für einen bayerischen Verband ist das keine Randnotiz, und die Meldung war obendrein eine Lüge: Die Adresse existiert und ist zustellbar. Neu wandeltemail_normalisieren()den Domain-Teil verlustfrei nach Punycode (genau das tun Mailserver ohnehin). - Die Normalisierung sitzt an ALLEN sechs Eingängen — Registrierung, Adress-Korrektur, Login, Passwort-Reset, 2FA-Reset, E-Mail-Änderung. Eine halbe Umsetzung wäre schlimmer als gar keine: Wer sich als
info@münchen.deregistriert (gespeichert als Punycode) und beim Login gegen den Rohtext verglichen würde, käme nie wieder in sein Konto. Live geprüft: Umlaut-, Groß- und Punycode-Schreibweise führen alle ins selbe Konto. - Umlaute vor dem @ werden weiterhin abgelehnt — ohne SMTPUTF8 können wir dorthin nicht zustellen, und eine Adresse anzunehmen, an die nie eine Mail geht, wäre schlimmer als die Ablehnung. Aber die Meldung ist jetzt ehrlich: „Adressen mit Umlauten vor dem @ können wir leider noch nicht zustellen" plus Hilfe-Weg, statt die Person ihren Tippfehler suchen zu lassen, den es nicht gibt.
- Selfcheck: neue Zeile „Umlaut-Adressen (intl)" — ohne die
intl-Extension lässt sich nicht wandeln, dann werden solche Adressen abgewiesen. Kein Alarm, aber sichtbar: sonst wäre die Unterstützung eine stille Zusage, die auf dem Server nicht gilt (intlsteht nicht in der Pflicht-Extensionsliste). - fix (im eigenen Neubau gefunden): Die Prüfung auf Steuerzeichen lief nach
trim()— und PHP'strim()entfernt per Default auch\0. Ausopfer@x.de\0wäre stillopfer@x.degeworden und als gültig durchgerutscht. Exakt die Falle, die inmail_addr_safe()bereits kommentiert steht (B9/E054) — beim zweiten Mal wieder hineingetappt, vom eigenen Test gefangen. Steuerzeichen werden jetzt zuerst geprüft, danach nur echte Leerzeichen getrimmt. - 7 neue Tests (Round-Trip Registrierung↔Login in drei Schreibweisen · ehrliche Ablehnung · Injection-Abwehr · Ergebnis übersteht
mail_addr_safe()), 175 Tests grün.
[0.39.1] — 2026-07-16
Abnahme aus Nutzersicht: alle vier Persona-Durchläufe bestanden (UX-CODE-PAKET Abschnitt 4).
Kein Code-Fix — die Durchläufe sind die Definition of Done des Usability-Pakets. Lokal end-to-end gegen die echte Instanz, jeweils „ohne externe Hilfe ans Ziel, an keiner Stelle ohne nächsten Schritt".
- „Rosa" (P01) — bestanden (bereits v0.33.0): Registrierung ohne Authenticator-App bis zum Abschluss; das Step-up-Gate trägt sie später durch die Einrichtung und zurück zur ursprünglichen Aktion. Der Durchlauf fand damals einen Fehler im eigenen Werk: „Konto absichern" war ausgerechnet für aufgeschobene Konten unsichtbar.
- „Petra" (P09) — bestanden: Abbruch nach der E-Mail-Bestätigung, Rückkehr ohne Cookie. Sie bekommt „Registrierung fortsetzen" statt „Konto existiert bereits" (B1), der Link führt über die POST-Landing-Page (Safe-Links) direkt auf Schritt 2 — kein Kreisel.
- „Tobias" (P10) — bestanden: Tippfehler
@gmial.comwird erkannt; die Warteseite zeigt einen Primär-Button „An …@gmail.com senden" (P10-2), nicht bloß einen Hinweis. Nach dem Klick geht die Mail an die richtige Adresse, das Konto ist korrigiert, die falsche Adresse verschwindet aus der Anzeige (kein Desync, E037). - „Jürgen" (P06) — bestanden: Handy + Notfall-Codes verloren. Der Reset ist von der Code-Abfrage aus erreichbar (B2); die Bestätigung bleibt neutral; die Mail enthält Reset- und „Das war ich nicht"-Abbruchlink. Die Wartefrist-Seite nennt Restzeit in Worten („in etwa 48 Stunden"), den konkreten Zeitpunkt, den Zweck der Frist und den Sofortweg über die Geschäftsstelle (UXC-08b/P01-3). Nach Ablauf: Reset wirkt, TOTP entfernt, Neu-Einrichtung geführt.
Zwei Erkenntnisse aus den Durchläufen:
- In der lokalen Entwicklungs-Datenbank stand
twofa_self_reset_aktiv = false(Rückstand eines alten Test-POSTs auf die Einstellungen-Seite) — Jürgen lief zunächst in „Selbst-Zurücksetzen ist derzeit nicht aktiv". Gegen die Produktion geprüft: dort ist der Reset aktiv, der Code-Default isttrue(7.4). Kein Produktionsfehler, aber ein Beleg dafür, dass eine Test-Datenbank die Abnahme verfälschen kann. - Der Hilfe-Kontakt nennt derzeit nur die E-Mail-Adresse — die Telefonnummer (O14) ist noch nicht hinterlegt. Für Jürgen ist genau das der Moment, in dem ein Telefon zählt. Der Selfcheck weist darauf hin.
[0.39.0] — 2026-07-16
Paket 7f: Das Modul-Fundament trägt jetzt wirklich (Konzept 4.5 — O051 teilweise, O053, O054/O056).
Die Modul-Konvention stand im Konzept und in CLAUDE.md — funktioniert hat sie nicht. Ein Modul, das sich exakt an die Vorgaben hält, wäre schlicht wirkungslos geblieben, ohne eine einzige Fehlermeldung. Aufgefallen wäre das erst beim ersten Fachmodul.
- fix: Das
repository.phpeines Moduls wurde nie geladen. Der Loader inindex.phpkannte nurroutes.php+controllers.php— das gesamte SQL eines Moduls wäre unerreichbar gewesen. Laden passiert jetzt zentral inmodules_load()(Bootstrap) — im Web und auf der CLI. - fix: Module konnten keine Jobs registrieren. Der Cron lud Modul-Code überhaupt nicht; die zugesagte Job-Registry (14.5) war für Module eine leere Zusage — ihre Cron-Arbeit wäre nie gelaufen, lautlos.
- fix: Die Pflicht-Hooks wurden nie aufgerufen.
<modul>_dashboard_tiles()und<modul>_open_tasks()sind laut 4.5 verbindlich; im Code stand dazu nur ein Kommentar „liefern ihre Kacheln später". Jetzt hängen beide an der Startseite. Defensiv: Wirft ein Modul, verschwindet nur dieses Modul aus der Sammlung (Log-Eintrag) — ein fehlerhaftes Fachmodul darf das Portal nicht lahmlegen. - fix: O053 — der Präfix-Guard war eine reine Behauptung. CLAUDE.md sagt „präfixlos = Core-reserviert, Selfcheck prüft"; geprüft hat das nie jemand. Eine präfixlose Funktion in einem Modul kollidiert mit dem Core oder dem nächsten Modul — erst zur Laufzeit, auf einer echten Seite. Der Selfcheck meldet Verstöße jetzt namentlich, ebenso fehlende Pflicht-Hooks je aktivem Modul.
- Korrektur am Befund: O051 („Modul-Loader fehlt") stimmt so nicht —
routes.php/controllers.phpwurden sehr wohl geladen, undnav.phprief<modul>_navbereits auf. Fehlend warenrepository.php, die Jobs, die Pflicht-Hooks und der Guard. app/modules/README.md: die Konvention kompakt (Dateien, Regeln, Pflicht-Schnittstelle, Beispiel).- 6 neue Tests gegen ein echtes Modul im echten Verzeichnis (Fixture
tests/fixtures/modules/demo/, plus ein Gegenbeispiel, an dem der Guard anschlägt) — inklusive „ein kaputtes Modul reißt die Startseite nicht mit". Gegenprobe bestanden. 168 Tests grün. - Nebenbei:
modules_load()hatte einenstatic-Schalter, der die Funktion einmal pro Prozess wirksam machte — überflüssig (require_oncegenügt) und unprüfbar. Raus.
[0.38.0] — 2026-07-16
Paket 7e: Zwei Selbstdiagnose-Fehler, aufgefallen am echten Selfcheck der Produktion.
- fix: Die Rollen-Seed-Prüfung meldete „OK — 5 System-Rollen (erwartet: 4)". Eine Prüfung, die im selben Atemzug bestätigt und widerspricht, ist wertlos: Sie zählte nur (
>= 4) und trug die Erwartung hart im Text — seit GS-Admin dazukam (v0.18), stimmte die Zahl nicht mehr. Schlimmer als der Schönheitsfehler ist die Blindheit: Fehlteadminund gäbe es dafür zwei andere Rollen, wäre die Zahl gleich, der Selfcheck grün — und niemand käme mehr an die Verwaltung. Jetzt wird namentlich geprüft (repo_system_rollen_fehlend()) und die fehlende Rolle benannt. - fix: Endgültig gescheiterte Mails lösten keinen Alarm aus. Der Selfcheck zeigte sie zwar — den liest aber niemand von allein.
/health(der Weg zum Monitoring, 14.6) koppelte nur an Cron-Stillstand und Outbox-Stau; eine Mail, die aufgibt, verlässtqueuedund war für die Ampel unsichtbar. Jetzt zieht ein Fehler der letzten 24 h/healthauf 500. Bewusst mit Zeitfenster: Ein einzelner Altfall darf die Ampel nicht dauerhaft rot färben — sonst gewöhnt sich das Monitoring an Rot. Selfcheck und/healthbenennen jetzt dieselbe Regel. - 1 neuer Test (inkl. Gegenprobe: eine verschwundene System-Rolle muss auffallen), 163 Tests grün.
[0.37.0] — 2026-07-16
Paket 7d: Registrierungs-Robustheit (E026, E029, E030/E031, E037, E038).
- fix: E038 — eine fremde Registrierung überlebte den Login.
session_regenerate_id()tauscht nur die Kennung, die Session-Daten bleiben;onboarding_user_idüberstand damit die Anmeldung — undtwofa_setup_user()/wizard_user()lesen diesen Wert vor dem angemeldeten Konto. Auf einem geteilten Rechner hieß das: A bricht die Registrierung ab, B meldet sich an — und bekommt A's Onboarding serviert, richtet also den Anmelde-Code für A's Konto ein. Live gegengeprüft: ohne den Fix reproduzierbar.auth_login_user()räumt jetzt alle Reste der alten Identität weg. - fix: E029 — die Sperre für BTV-Adressen ließ sich umgehen. Sie stand nur am Einstieg, nicht bei „Adresse korrigieren": erst mit
privat@example.orgstarten, dann auffranz@btv-turnen.dekorrigieren. Live gegengeprüft: ohne den Fix entsteht tatsächlich ein lokales Konto zu einer BTV-Adresse — genau das Schattenkonto, das 6.2.1 ausschließt und das die Zuordnung überentra_oidunterlaufen würde. Die Prüfung ist jetzt eine gemeinsame Regel für beide Wege. - fix: E026 — ein Doppelklick erzeugte eine 500. Zwei parallele Anfragen für dieselbe Adresse sahen beide „kein Konto"; die zweite lief in den Unique-Key, die
PDOExceptionschlug bis zur Fehlerseite durch — ausgerechnet auf dem Weg, der neutral bleiben muss (die Seite darf nicht verraten, ob es die Adresse gibt). Jetzt: SQLSTATE 23000 wird als Race behandelt und neutral weitergeführt; andere Datenbankfehler bleiben sichtbar. - fix: E030/E031 — externer Aufruf und Schreibzugriff im GET-Render. Der Mitglieder-Abgleich lief beim Anzeigen der Stammdaten-Seite: Ein Reload stieß ihn erneut an, zwei Tabs riefen it4sport doppelt auf, und die Seite wartete bis zu 10 s auf eine fremde Schnittstelle. Jetzt wird er im POST ausgelöst, der nach Etappe 5 führt; der Rückfall für Wiederaufnahme-Wege ist durch einen atomaren Claim geschützt (nur ein Request ruft an), und die Ergebnis-Schreibvorgänge liegen in einer Transaktion — vorher konnte ein mehrdeutiger Treffer ohne Prüfhinweis entstehen.
- fix: E037 — Wartezustand ohne Ausweg. Führte „Adresse korrigieren" auf eine bereits vergebene Adresse, blieb die Datenbank auf der alten Adresse — die Session wurde trotzdem auf die neue gesetzt. Die Warteseite zeigte dann eine Adresse, an die nie eine Mail ging, und „Erneut senden" schickte an die alte. Die Antwort bleibt neutral, ist jetzt aber ehrlich in dem, was sie anzeigt.
- Ein hängengebliebener Abgleich bleibt nicht liegen: Der Retry-Job sammelt jetzt auch Zeilen ein, die reserviert, aber nie fertig wurden (
offenohne Prüfzeitpunkt, älter als eine Stunde) — sonst hätte ein Abbruch mitten im Request ein Konto dauerhaft ohne Abgleich gelassen. - 6 neue Tests; E026 und E038 zusätzlich per Gegenprobe gegen den alten Code belegt, E029 end-to-end als reproduzierbare Umgehung. Ehrlich dazu: Der E029-Test prüft die Regel, nicht ihre Verdrahtung im Korrektur-Handler — dieser Teil ist durch den Live-Durchlauf abgesichert. 162 Tests grün.
- TESTPLAN-ABNAHME.md: A5a (Doppelklick), A5b (BTV-Adresse über den Umweg), A5c (geteilter Rechner).
[0.36.0] — 2026-07-16
Paket 7c: Barrierefreiheit (P07-Cluster, WCAG 2.1 AA — Konzept 13).
- fix: P07-2 — die zugesagte „Copy"-Möglichkeit gab es gar nicht. Der Schlüssel stand in einem
<code data-copy>: nicht fokussierbar, ohne Namen, per Tastatur unerreichbar — und die Kopier-Funktion war nie implementiert, der Klick tat schlicht nichts. Konzept 7.2 sagt „Copy" als dritten gleichwertigen Weg zu. Jetzt ein echter Button mit Rückmeldung überaria-live; klappt das Kopieren nicht, wird der Text markiert. Ohne JavaScript bleibt der Schlüssel sichtbar und von Hand markierbar. - fix: P07-5 — Auswahlfelder wechselten den Kontext von selbst (
data-autosubmit→ WCAG 3.2.2 „On Input"). Schlimmer: Die „Ansicht als Rolle" ließ sich ohne JavaScript gar nicht umschalten, weil es keinen Absende-Button gab (Regel „Formulare funktionieren ohne JS"). Beide Stellen haben jetzt einen echten Button; der Listenfilter hatte ohnehin schon einen. Der totedata-autosubmit-Handler ist ausapp.jsraus. - fix: P07-4 — Icon-Link ohne verlässlichen Namen: Die Admin-Einstellungen trugen nur ein
title.aria-labelergänzt. (Die übrigen Header-Icons waren bereits korrekt benannt — geprüft, nicht geändert.) - P07-1/P07-3 bereits erledigt (v0.31.0, UXC-06): Feld-Hilfetexte hängen per
aria-describedbyam Feld, der Datumsformat-Hinweis steht sichtbar statt nur im Placeholder. - TESTPLAN-ABNAHME.md: C12 (Bedienung ohne Maus), C13 (ohne JavaScript). 156 Tests grün, live geprüft.
[0.35.0] — 2026-07-16
Paket 7b: Rollen-Workflow — Modul-Rechte greifen jetzt überhaupt (E079, E081, E093).
- fix: E081 — jede Modul-Rolle wäre rechtelos geblieben. Der Rechte-Editor bietet
<modul>.*an; der Wert landete als Literal inrole_rightsund wurde exakt verglichen —abrechnung.*traf also nieabrechnung.einreichen. Die Oberfläche versprach eine Rechtevergabe, die es nicht gab, und zwar lautlos: Aufgefallen wäre es erst beim ersten Fachmodul. Ein Wildcard deckt jetzt alle Rechte unterhalb seines Präfix ab — nie über den Punkt hinaus (abrechnung.*greift nicht aufabrechnungX.*und niemals aufusers.manage). - fix: E079 — Modul-Rechte griffen ohne Freigabe des Kontos. Konzept 8.1 verlangt „bestätigte Rolle und Freigabe"; geprüft wurde nur die bestätigte Rolle. Ein Konto im Klärfall bekam Modul-Funktionen. Core-Rechte hängen bewusst nicht an der Freigabe — sonst nähme ein Klärfall am eigenen Konto der Verwaltung die Verwaltungssicht. Fehlt
review_statusim Nutzerobjekt, wird fail-closed verweigert und geloggt (stilles Verweigern sähe aus wie „Modul kaputt"). - fix: E093 — die Schlüsselerzeugung verschluckte Umlaute.
[^a-z0-9]traf auchä/ö/ü/ß: Aus „Übungsleitung" wurde der Schlüssel_bungsleitung, und „Prüfung"/„Pr fung" fielen auf denselben Schlüssel. Der Schlüssel steht in URLs undrole_rights. Deutsche Umlaute werden jetzt ausgeschrieben (uebungsleitung,pruefteam,strasse). - fix (Nebenbefund, gleiche Fehlerklasse wie bei den Settings):
repo_role_rights()hatte einen statischen Cache, den kein Schreibvorgang verwarf — wer im selben Request Rechte speicherte und danach eine Rechteprüfung auslöste, arbeitete mit dem alten Stand. Cache ist jetzt verwerfbar;repo_role_set_rights()/repo_role_delete()tun das. - 9 neue Tests gegen die echten Funktionen inkl.
role_rights-Tabelle (ein erster Entwurf hatte die Logik in den Test kopiert und hätte sich nur selbst bestätigt — verworfen). Gegenprobe: E079 und E081 schlagen gegen den alten Code nachweislich an. 156 Tests grün.
[0.34.0] — 2026-07-16
Paket 7a: 2FA- und Klärfall-Robustheit (E002, E003, E021, E087, E090, E091, E092, O086, E096).
2FA-Robustheit
- fix: E003 — die Sperre galt nur für einen der beiden Wege. Der Recovery-Zweig wurde abgearbeitet, *bevor*
totp_check_account()die Sperre prüft: Ein gesperrtes Konto ließ sich über Notfall-Codes unbegrenzt weiter durchprobieren. Die Prüfung sitzt jetzt vor der Verzweigung und gilt für beide. - fix: E002 — TOTP-Fehlversuche waren für die Drosselung unsichtbar. Sie landeten nur im Audit, nicht in
login_attempts— die IP-/Konto-Drosselung sah einen Angriff auf viele Konten gleichzeitig also gar nicht. Jetzt werden TOTP- und Notfall-Code-Fehlversuche mitgezählt. - fix: E021 — die Geräteliste zeigte tote Geräte als „vertraut".
trusted_device_valid()verlangt zusätzlich die aktuellesession_epoch; nach Passwortänderung oder Sperrung sind alle Geräte entwertet (12.2). Die Liste im Profil filterte darauf nicht — eine Sicherheitsaussage, die nicht stimmte. - Nicht zutreffend (geprüft, nicht geändert): E006/E011 —
recovery_redeem()ist bereits atomar (UPDATE … WHERE used_at IS NULL+rowCount()===1).
Klärfälle
- fix: E087 — die Sammelfreigabe gab auch mehrdeutige Fälle frei. „Sammelfreigabe unauffälliger Fälle" (8.4) schloss sie nie ein: Wird ein mehrdeutiger Abgleich pauschal freigegeben, bleibt offen, welche Person gemeint ist — und die Kandidatendaten Dritter bleiben liegen, weil der Purge-Job nur aufgelöste Fälle räumt. Solche Fälle sind jetzt in der Liste nicht auswählbar (plus serverseitige Prüfung) und werden beim Absenden benannt, statt still übersprungen zu werden.
- fix: E092 — die Phönix-Kennung kam ungeprüft aus dem Formular und wurde nach 9.4 sofort führend. Ein Tippfehler band dauerhaft die falsche Person. Sie muss jetzt aus den tatsächlich ermittelten Kandidaten des Kontos stammen.
- fix: E090 — gesperrte Prüffälle verschwanden aus Queue und Zähler. Wer ein Konto sperrte, ließ den Prüfvorgang lautlos und dauerhaft verschwinden. Sie bleiben jetzt sichtbar und tragen ihren Status als Badge.
- fix: O086 — „wartet seit" rechnete ab
registered_at. Bei einem Fall, der erst später entstand (Nachtreffer), log das Alters-Badge um Wochen. Jetzt ab Eintritt in die Queue (review_status_at), mit Rückfall aufregistered_atfür Bestandsfälle. - fix: E091 — der Klärfall-Digest schaltete sich lautlos ab, wenn kein Prüfteam-Postfach hinterlegt war: Niemand merkte, dass der tägliche Bericht nie ankommt. Der Selfcheck meldet das jetzt (mit Zahl der offenen Fälle), ebenso ein fehlendes Hilfe-Telefon und den Zustand der Vorsaison-Erinnerung.
- fix: E096 — rohe Systemwerte in der UI: Die Queue zeigte den Enum-Wert (
mehrdeutig). Übersetzung jetzt zentral inabgleich_label()— eine Quelle, auch vonaccount_labels()genutzt. - Beim Live-Test im eigenen Werk gefunden: Die neue E087-Regel sperrte *jede* Checkbox, weil
repo_klaerfaelle()review_statusnicht mitlieferte — die Sammelfreigabe wäre komplett unbenutzbar geworden. Fail-closed sieht von außen aus wie „funktioniert". Feld ergänzt, Regel meldet fehlende Felder jetzt ins Log, Regressionstest gegen die echte Queue. - 7 neue Tests (Gegenprobe verifiziert: E087/E090/O086 schlagen gegen den alten Code an), 147 Tests grün.
[0.33.0] — 2026-07-16
Paket 6c: Abnahme-Durchlauf „Rosa" (Definition of Done, UX-CODE-PAKET Abschnitt 4).
- fix: „Konto absichern" war für genau die Konten unsichtbar, die es brauchen. Aufgefallen im Durchlauf: Wer die 2FA aufschiebt, gilt als „vollständig" (7.6) — und die volle Startseite zeigte die Aufgabenliste gar nicht, nur die eingeschränkte tat das. Die dauerhaft geforderte Empfehlung (7.6/8.6) verschwand also lautlos. Jetzt steht sie als ruhiger Hinweis (kein Alarm — das Konto funktioniert ja) auf der vollen Startseite, gezielt gesucht statt über die Listenposition gegriffen.
- Durchlauf „Rosa" (P01) bestanden, lokal end-to-end: Registrierung ohne Authenticator-App bis zum Abschluss (
review_status=freigegeben, Portal nutzbar), „Später einrichten" in Etappe 3 greift, Pausieren-Hinweis und „Warum fragen wir das?" (mitaria-describedby) erscheinen, Verify-Link ist in der Datenbank redigiert (B8 arbeitet). Danach:/profil/emaillöst das Gate aus, die Gate-Seite trägt die Begründung und kein „Später", nach Einrichtung + Notfall-Codes landet Rosa zurück auf/profil/email.
[0.32.0] — 2026-07-16
Paket 6b: Statuskommunikation und Fristen (UXC-05, UXC-08).
- UXC-05 · Wartende erfahren jetzt aktiv, was passiert. Bisher merkte man Fortschritt nur durch erneutes Einloggen. Neu: zentrales
status_notify()schickt bei jedem Übergang (Angaben eingegangen · in Prüfung · freigegeben · Rückfrage) eine Portal-Nachricht UND eine Mail über die Outbox. Die vier Freigabewege (Einzelfreigabe, Klärfall-Auflösung, Sammelfreigabe, Registrierungsabschluss) liefen bisher auseinander — teils nur Mail, im Portal nichts. Jetzt ein Pfad. - Konkrete Fristen statt Floskel (Migration 0009,
users.review_status_at): „Eingegangen am 16.07.2026 — wir melden uns in der Regel bis 23.07.2026" statt „in der Regel innerhalb von 5 Werktagen". Wer seit acht Tagen wartet, liest die Floskel als Hohn. Werktage-Rechnung überspringt Wochenenden (Feiertage bewusst nicht — deshalb „in der Regel bis", keine Garantie); Zieltage in Werktagen pflegbar (sla_klaerfall_werktage, 1–60 gekappt). Bestandskonten ohne bekannten Wechselzeitpunkt behalten die alte Formulierung — ein erfundenes Datum wäre schlimmer als eine vage Aussage. - fix: Fünf Stellen setzten
review_statusdirekt per SQL anrepo_user_set_review()vorbei (oidc.php,jobs.php,registrieren.php) — dort wäre der neue Zeitstempel leer geblieben. Alle stempeln jetzt mit;repo_user_set_review()stempelt nur bei echtem Wechsel, sonst schöbe ein erneuter Admin-Klick die Frist immer weiter nach hinten. - UXC-08a · Vorsaison-Erinnerung als Cron-Job (O040): Zwischen zwei Abrechnungen liegen Monate — zum Saisonstart sind Passwort vergessen, Handy neu und Anmelde-Code weg, bei allen gleichzeitig. Eine ruhige Mail vorher verteilt die Last. Konten ohne Anmelde-Code werden gezielt angesprochen (7.4/7.6). Steuerung über
vorsaison_monat_tag(Standard leer = der Job tut nichts, kein BTV-Fixwert im Core); Merker gegen Doppelversand wird vor dem Versand gesetzt (eine ausgelassene Erinnerung ist harmloser als eine doppelte an alle), 7 Tage Nachlauf für ausgefallene Cron-Läufe. - UXC-08b/P06-3 · Reset-Wartefrist verständlich: Die Seite zeigte den rohen Datenbank-Zeitstempel (
2026-07-18 10:00:00). Jetzt: Restzeit in Worten („in etwa 24 Stunden"), konkreter Zeitpunkt, wozu die Frist dient, der „Das war ich nicht"-Weg und der Sofortweg über die Geschäftsstelle (P01-3). Und kein „error"-Kasten mehr — es ist nichts schiefgegangen. - 10 neue Tests (Werktage-Rechnung inkl. „nie ein Wochenende als Zusage" über 28 Starttage · keine erfundene Frist ohne Zeitpunkt · SLA-Kappung · alle vier Übergänge ohne Systemnamen · Portal+Mail · Fälligkeit des Massen-Mailings), 141 Tests grün.
- TESTPLAN-ABNAHME.md: C4a (Statuskommunikation), B5c (Wartefrist).
[0.31.0] — 2026-07-16
Paket 6a: Usability vor Livegang — Hilfe, Anleitung, Vertrauen, Sackgassen (UXC-01/02/03/04/06/07, P01-1).
- UXC-07 · Microcopy-Leitlinie als
docs/MICROCOPY.md: Haltung (Du, genderneutral, schuldfrei), Verbotsliste für Systemnamen, die drei Zustands-Muster (Fehler/Leer/Warte — jeweils *was ist passiert · was kannst Du jetzt tun · wo bekommst Du Hilfe*) und eine Checkliste vor jedem Merge. Gilt auch für alle künftigen Module. - fix: Die 419-Seite war eine Sackgasse. Ein abgelaufenes Formular endete in
exit('Sitzung abgelaufen …')— ohne Layout, ohne Ausweg, und die Eingaben waren weg. Jetzt: Seite im Portal-Layout, die erklärt was passiert ist, die Eingaben überform_draftsrettet (13.3) und weiterführt. Erster Anwendungsfall der Leitlinie. - fix: Die 2FA-Sperre war eine Sackgasse. „Zu viele falsche Codes — bitte warte einige Minuten" nannte weder Notfall-Codes noch Reset-Weg, Anleitung oder Hilfe — genau der Moment, in dem Leute anrufen oder aufgeben. Jetzt eine eigene Seite mit konkreter Restzeit, allen Selbsthilfe-Wegen und der Klarstellung „Dein Konto ist nicht dauerhaft gesperrt".
- fix: P01-1 — der
otpauth://-Link lief ins Leere. Ohne installierte App passiert beim Antippen nichts; die Person hält das für kaputt (Persona „Rosa"). Neu: Zwischenseite/2fa/app-oeffnen, die erst erklärt, dann verlinkt, und den Fall „es passiert nichts" auffängt. Das Geheimnis kommt dabei aus der Session, nie aus der URL (sonst stünde es im Zugriffsprotokoll). - UXC-02 · Bebilderte Anleitung unter
/hilfe/anmelde-code: Schritt für Schritt je Gerät (iPhone „Passwörter" · Android Google Passwortmanager · zwei Geräte per QR), Einstieg „Ich habe noch keine App", Fehlerhilfe (u. a. falsch gehende Handy-Uhr). Die Screenshots sind Zulieferung — die Seite ist auch ohne sie vollständig benutzbar, jede Lücke zeigt ihren Dateinamen (public/assets/hilfe/README.md, inkl. Warnung: keine echten QR-Codes abbilden). - UXC-01 · Hilfe erreichbar machen: neuer Settings-Wert
help_phone(Standard leer — der Core trägt keine BTV-Nummer, O18) +hilfe_kontakt_zeile()auf Fehler-, Warte- und Sperrseiten. Bisher stand die Nummer nur in der Barrierefreiheitserklärung — also genau dort nicht, wo Leute feststecken. - UXC-03 · Vertrauen & Nutzen als pflegbare Bausteine: Betreiber-Klarheit und Datenschutz in Alltagssprache auf der Startseite, Nutzen-Moment vor der Registrierung, Payoff-Framing an der 2FA-Etappe und am Step-up-Gate.
- fix: Die Startseite forderte ein Smartphone als Voraussetzung — nach dem 2FA-Aufschub (7.6) stimmt das nicht mehr und hätte genau die Leute abgewiesen, die der Aufschub gewinnen soll. Jetzt: „Kein Smartphone nötig, um loszulegen".
- fix:
views/registrieren_intro.phpwar eine Waise — seit die Registrierung in die Startseite wanderte, hat die „Das brauchst Du"-Seite niemand mehr gerendert. Inhalte in den Registrieren-Tab verschoben (dort werden sie gelesen), Datei entfernt. - UXC-04 · Pausieren-Hinweis als Partial
_pausieren.phpan jeder Wizard-Etappe: Die Resume-Mechanik gab es seit Beginn — sie stand nur nirgends, wo jemand sie liest. - UXC-06 · „Warum fragen wir das?" als wiederverwendbares Muster (
feld_hilfe(), peraria-describedbyam Feld statt Deko daneben; Formathinweis raus aus dem Placeholder). Erster Fall: Geburtsdatum. Gilt für alle künftigen Module (Bankdaten). - Ansprache vereinheitlicht (Du/Dein statt gemischt) auf der Startseite und in der Sitzungsablauf-Meldung.
- TESTPLAN-ABNAHME.md: A9/A10 (Anleitung, kein totes Antippen), B8/B9 (Formular zu lange offen, zu viele falsche Codes). 131 Tests grün, alle Seiten live geprüft.
[0.30.0] — 2026-07-16
Paket 5: 2FA erst vor der ersten sensiblen Aktion (Konzept 7.6/7.5, Entscheidung 2026-07-16).
- Die 2FA-Einrichtung ist in der Registrierung nicht mehr Pflicht, sondern empfohlen und überspringbar („Später einrichten", Etappe 3). Sie war die härteste Hürde des Funnels, bevor überhaupt ein Nutzen erlebt wurde — und ein frisches Konto enthält noch keine Finanzdaten, die den Zwang rechtfertigen. Der Aufschub wird in
users.twofa_deferred_atfestgehalten (Migration 0008);/startzeigt dauerhaft die Aufgabe „Konto absichern (empfohlen)". - Verpflichtend wird die 2FA am neuen Step-up-Gate
require_twofa()— an dem Punkt, an dem ihr Zweck offensichtlich ist. Belegt: E-Mail-Änderung, Recovery-Codes nachgenerieren, Trusted Devices verwalten und alle Routen hinter erhöhten Rechten (users.*,roles.*,settings.*,audit.*,system.view). Letzteres sitzt zentral inrequire_right()statt in 20 Controllern — eine vergessene Zeile wäre sonst ein Loch. Nach der Einrichtung geht es zurück zur ursprünglich aufgerufenen Aktion. - Die Sicherheitsgrenze bleibt, wo sie war: Wer 2FA eingerichtet hat, kommt ohne bestandene Prüfung nirgends hin — live gegengeprüft, auch wenn die 2FA mitten in einer laufenden Session aktiviert wird. Gelockert ist ausschließlich der Fall „noch keine 2FA eingerichtet". Reihenfolge in
require_right(): erst das Recht, dann das Gate — wer das Recht nicht hat, bekommt die 403 und wird nicht in ein Onboarding für eine Seite geschickt, die er nie sieht. - Rücksprungziel nur als interner Pfad (
pfad_intern())://host,/\host, absolute URLs und Steuerzeichen werden abgewiesen — sonst wäre das Gate eine Phishing-Brücke (12.2). Am Gate gibt es kein „Später" — dort ist die 2FA der Weg hindurch. - Trusted Devices: 30 → 90 Tage (7.5), jetzt als Settings-Wert
trusted_device_tagepflegbar, hart auf 1–365 begrenzt (ein Tippfehler soll kein quasi-ewiges Gerät erzeugen). - fix (Nebenbefund):
setting_set()pflegte den Request-Cache vonsetting_get()nicht — wer im selben Request speicherte und wieder las (Einstellungs-Editor), bekam den alten Wert. - Dokumentiertes Restrisiko (Konzept 7.6/Anhang B): Bis zur ersten sensiblen Aktion ist das Konto nur passwortgeschützt; bei kompromittiertem Passwort könnte ein Angreifer die 2FA selbst einrichten. Gegenmaßnahmen: Info-Mail bei jeder Aktivierung + „Das war ich nicht"-Abbruchlink; keine Finanz-/Vertragsdaten ohne Gate. Interne Konten sind nicht betroffen (IdP-MFA).
- 6 neue Tests (Wizard-Aufschub · Gate-Katalog · Open-Redirect-Abwehr · Trusted-Device-Frist ·
account_is_full· Aufgabenliste) + Live-Durchlauf, 131 Tests grün.
[0.29.0] — 2026-07-16
Paket 4f: Phönix-Anbindung belastbar gemacht (B7 — P-API-1, P-API-3/E052, E059, E064).
- fix: E059/E064 — der Retry-Job zerstörte bestätigte Zuordnungen. Konzept 9.4 sagt „nachträgliche Treffer überschreiben nie bestätigte Daten"; ein Kommentar im Code behauptete das auch — die Zeile darunter tat das Gegenteil. Bei bestätigten Konten schrieb der Job die Abgleich-Zeile neu: ein Nachtreffer überschrieb
phoenix_matches.phoenix_person_idsamt Snapshot mit der fremden Person, „kein Treffer" nullte die gebundene Referenz — währendconfirmed_by_user_atstehen blieb (eine bestätigte Zuordnung ohne Zuordnung). Neu: bei bestätigten Stammdaten werden nur Status und Prüfzeitpunkt fortgeschrieben, Referenz und Snapshot bleiben unangetastet; ein Nachtreffer erzeugt ausschließlich einen Prüfhinweis (review_status) + Audit-Eintrag für die Verwaltung. Die führende Spalteprofiles.phoenix_person_idwar nie betroffen. - fix: P-API-1 — falsches Auth-Verfahren. Der Client schickte einen
Bearer-Token; it4sport authentifiziert laut Spec perX-Auth-Token-Header. Inlive/testhätte jeder Abruf mit HTTP 401 geendet. - fix: P-API-3/E052 — nur die erste Seite gelesen. it4sport paginiert; ein mehrseitiger Mehrfachtreffer sah dadurch wie ein eindeutiger Treffer aus — das Portal hätte die falsche Person vorbelegt und deren
phoenix_person_idgebunden. Neu sammeltphoenix_api_get_all()alle Seiten ein (überlinks.next, sonstmeta.current_page) und bricht laut ab, statt still unvollständig zu zählen. - Offen (O5, Entscheidung Stefan ↔ it4sport): Der Endpunkt
/persons?email=…ist erfunden — eine serverseitige Personensuche per E-Mail gibt es bei it4sport nicht (PHOENIX-API-ANALYSE.md, P-API-2). Vorphoenix.mode = liveist der Weg zu klären: (A) Personenquellen seitenweise ziehen und den E-Mail→Person-Index nur flüchtig im Abgleichlauf aufbauen, oder (B) Abgleich über Name + Geburtsdatum. Bis dahin trägtsimulate. Im Code als Warnblock markiert. - 4 neue Tests (Gegenprobe verifiziert: gegen den alten Code schlagen sie an —
NULLbzw. fremdeSIM-2001), 125 Tests grün.
[0.28.0] — 2026-07-16
Paket 4e: SSO-Über-Provisionierung geschlossen (B6 — E001, E004).
- fix: E001 — jedes Tenant-Member wurde automatisch
btv_hauptberuf. Die Gruppenprüfung war „optional": ohne gesetzteentra.group_idbekam jede·r im Tenant automatisch ein Konto mitusers.view/users.review/system.view. Neu: zwei Gruppen mit getrennter Bedeutung —entra.group_id= Zugang (alle mit BTV-Adresse),entra.group_hauptamt= Rollebtv_hauptberuf(nur BTV-Hauptamt). Wer Zugang hat, aber nicht im Hauptamt ist, bekommt ein Konto mit Basisrechten (profile.*) — keine Verwaltungs-Einsicht mehr per Autoanlage. - Fail-safe statt Aussperrung: Lässt sich die Mitgliedschaft nicht prüfen (keine
group_idkonfiguriert, oder der Token trägt keinengroups-Claim — fehlende Token-Konfiguration bzw. „groups overage"), entstehen keine neuen Konten mehr (Loch zu), bestehende Konten bleiben nutzbar (sonst wäre die gesamte Verwaltung ausgesperrt). Der Zustand wird geloggt. - fix: E004 — unverifizierte E-Mail überschrieb die Login-Kennung.
users.emailfolgte dememail-Claim ohne Prüfung. Jetzt wird die Adresse nur übernommen, wenn Entra sie peremail_verifiedals verifiziert meldet. - 5 neue Tests (kein Claim → Deny · falsche Gruppe → Deny · Zugang ohne Hauptamt → keine Einsichtsrechte · Hauptamt → Rolle · unverifizierte Adresse wird ignoriert), 121 Tests grün.
[0.27.0] — 2026-07-16
Paket 4d: Versand-Deckel gegen Mail-Bombing (B9 — E055, E034, E035). Damit ist B9 abgeschlossen.
- fix: E055/E034/E035 — Mail-Bombing über das Portal. Registrierungsstart, Passwort-Reset, 2FA-Reset und die „Konto existiert"-Hinweismail waren ungedeckelt und anonym auslösbar: Beliebig viele Mails an eine fremde Adresse — das Portal als Waffe. Neu: Deckel je (Adresse, Typ, Stunde) in
queue_mail(); Unterdrückung nur im Audit sichtbar (mail.gedeckelt), die Antwort nach außen bleibt neutral (12.2). Neue Spaltemails.mail_type+ Zähler-Index (Migration 0007). - Gedeckelte Kanäle (1/Stunde):
verify_email(Start),resume_registration,dublette,reset_password,passwort_sso_hinweis,reset_2fa. Nutzer-Antworten an die Hilfe-Adresse: 10/Stunde. Ohne Typ kein Deckel — vom Team ausgelöste Nachrichten bleiben frei. - Konflikt beim Bau erkannt und gelöst: Der „E-Mail erneut senden"-Klick nutzt dieselbe Funktion wie der Start. Mit einem harten 1/Stunde-Deckel hätte die Warteseite „wurde erneut verschickt" behauptet, während gar nichts rausging. Der ausdrücklich angeforderte Resend (sitzungsgebunden + 60-Sekunden-Sperre) hat deshalb einen eigenen, höheren Deckel (5/Stunde) — der anonyme Start bleibt bei 1.
- 3 neue Tests (Angriff, Resend-Fall, ungedeckelte Team-Mails), 116 Tests grün.
[0.26.0] — 2026-07-16
Paket 4c: Mail-Bodies sind at rest redigiert (B8 — E033, E056, E067).
- fix: E033/E056/E067 — kein Klartext-Link mehr in der Datenbank. Bisher lag
mails.bodybis zum Versand im Klartext und wurde erst danach redigiert: Bei Cron-Stillstand (oder acht fehlgeschlagenen Versuchen) blieb ein gültiger Verify-/Reset-Link dauerhaft lesbar in der DB und hebelte das Token-Hashing aus (12.2). Neu:queue_mail()speichert den Body sofort redigiert; die Geheimnisse liegen verschlüsselt (sodium, Format 12.3) in der neuen Spaltemails.secrets_encund werden nur im Versandmoment im Arbeitsspeicher wieder eingesetzt. Nach Versand (oder endgültigem Fehler) wirdsecrets_encgelöscht — der Body bleibt für immer redigiert. - Platzhalter sind jetzt nummeriert (
[[GEHEIM:0]]), weil ein gemeinsames[REDIGIERT]nicht umkehrbar wäre. - Migration 0006 legt
secrets_encan und bereinigt den Altbestand: bereits versendete/fehlgeschlagene Mails übernehmen ihre redigierte Fassung — damit sind auch die historischen Klartext-Links weg. Queued-Altfälle laufen über einen Kompatibilitätspfad im Worker. - Rundlauf verifiziert: redigiert in der DB → echter Link beim Empfänger → danach wieder redigiert. 3 neue/angepasste Tests, 113 Tests grün.
[0.25.0] — 2026-07-16
Paket 4b: Token-Hygiene (B8 — E043, E049, E032).
- fix: E043 —
token_issue()ist jetzt atomar. Invalidieren der alten und Ausstellen der neuen Tokens lief in zwei getrennten Statements; zwei parallele Anforderungen konnten beide erst invalidieren und dann einfügen → zwei gültige Tokens, obwohl „Neuausstellung invalidiert alte" gilt. Jetzt in einer Transaktion (db_tx), mit Test auf „nie mehr als ein offenes Token je (user, purpose)". - fix: E049 —
token_peek()respektiert die Wartefrist. Vorher meldete es ein noch nicht fälliges Token als gültig, die Landing-Page zeigte ein Formular, das der POST dann ablehnte.token_peek()ist jetzt strikt (sicherer Default); für die bewusste Wartefrist-Anzeige („verfügbar ab …", 2FA-Reset) gibt es neutoken_status(). Der bisherige Aufrufer wurde umgestellt, der Testfall auf den neuen Vertrag nachgezogen. - fix: E032 — Klartext-Tokens nicht mehr im Zugriffsprotokoll. Landing-Pages führen das Token im Pfad;
log_access()schrieb ihn roh inaccess_log.pathund hob damit das Token-Hashing aus. Neulog_path_maskieren(): lange Zufallssegmente →[TOKEN], Kennungen wieBTV-S5HBWHGEbleiben lesbar. Restrisiko dokumentiert: der Webserver protokolliert die volle URL weiterhin selbst (nur über Server-Logrotation adressierbar). - 5 neue Tests, 112 Tests grün.
[0.24.0] — 2026-07-16
Paket 4a: SMTP-Header-/Command-Injection geschlossen (B9/E054).
- fix: B9/E054 — Injection über die Empfängeradresse.
$toging mit reinemtrim()inRCPT TO:<…>und denTo:-Header; ein CR/LF darin schleust zusätzliche SMTP-Kommandos oder Header (z. B.Bcc:) ein. Neu:mail_addr_safe()— weist jedes Steuerzeichen ab (bewusst vor dem Trimmen geprüft, weil PHPstrim()\0\r\n\tsonst still wegputzt und der Wert „bereinigt" durchrutscht), prüft Länge und Format, normalisiert. Angewandt inqueue_mail()(vergiftete Adressen kommen gar nicht erst in die Outbox),mail_send_smtp()(auch für den Absender) undmail_send_graph()— überall fail-closed. Betreffzeilen werden von Steuerzeichen befreit. - 4 neue Tests mit echten Angriffs-Mustern (
tests/mail_injection.test.php), 109 Tests grün.
[0.23.0] — 2026-07-16
Paket 3 der Überarbeitung: Scoping + Audit-Dichtheit (B4, B5) — inkl. eines dabei gefundenen Live-Bugs.
- fix: B4 —
require_right_scoped()echt implementiert. War ein Stub, deruser_scopesstill ignorierte: ein Modul hätte dagegen entwickelt und wäre ungeschützt gewesen. Jetzt echte Prüfung mit dokumentierter Semantik (8.3): Scopes verengen ein bereits gehaltenes Recht ·'*'= alle Werte · kein Eintrag = unbeschränkt (Verwaltung). Dazuuser_scope_allows(), die testbarescope_werte_erlauben()undscope_filter_werte()als Listen-Filter für Modul-Übersichten. - fix: B5 — Audit prüft jetzt auch Werte, nicht nur Schlüssel (
audit_assert_value_safe): erkennt IBAN, Passwort-Hashes und Token/Geheimnisse unabhängig vom Schlüsselnamen; Muster bewusst konservativ, damit deutsche Fließtexte nicht anschlagen. - fix: E072 + Live-Bug — Blockliste war zu grob.
'konto'traf als Teilstring'kontostatus'; damit warfaudit('login.blocked', …, ['kontostatus' => …])inlogin.php:49— die Anmeldung an einem gesperrten Konto endete im 500er statt in der freundlichen Meldung. Blockliste jetzt präzise (kontonummer/kontoinhaber/bankverbindungstattkonto/bank,passwort/pwdstattpass);'code'bleibt bewusst grob (schützt die Notfall-Codes). Regressionstest ergänzt. - 8 neue Tests (
tests/scoping_audit.test.php), 105 Tests grün.
[0.22.0] — 2026-07-16
Paket 2 der Überarbeitung: Rechte-Eskalation geschlossen (B3-Rest, N-DELTA-1/2/3).
- fix: N-DELTA-1/E076 —
repo_admin_rights()vollständig.users.view,users.reviewundsystem.viewgalten nicht als Verwaltungsrechte. Dadurch konnte einusers.manage-Inhaber (z. B. GS-Admin) die Rollebtv_hauptberuf— die genau diese Rechte trägt — an beliebige Konten vergeben. Jetzt sind alle Verwaltungs-/Einsichtsrechte erfasst; solche Rollen vergibt nur ein Admin (settings.manage). - fix: E076 — Nicht-Eskalations-Gate. Niemand kann eine Rolle vergeben, die Rechte enthält, die er selbst nicht hat (greift auch für künftige, selbst angelegte Rollen).
- fix: E077 — interne Rollen nur an SSO-Konten. Neue Rollen-Eigenschaft
internal_only(Migration 0005, Marker an der Rolle statt hartkodierter Schlüssel → White-Label-konform);btv_hauptberufist intern,adminbewusst nicht (Anker-/Dienstleister-Konten). - fix: E078 — keine Selbst-Rollenvergabe. Eigene Rollen sind in der Verwaltung nicht mehr änderbar.
- fix: N-DELTA-3/E086 — Sperren/Deaktivieren jetzt geschützt wie das Löschen: kein Selbst-Sperren, kein Anker-Admin, kein letztes aktives Admin-Konto (
repo_admin_count()); der Letzter-Admin-Schutz gilt jetzt auch beim Löschen. - fix: N-DELTA-2 — Self-Lockout verhindert. Der Admin-Rolle können
settings.manage/roles.managenicht mehr entzogen werden (repo_role_locked_rights()), sonst könnte niemand mehr Rollen oder Einstellungen verwalten. - 6 neue Tests (
tests/rechte_eskalation.test.php), 97 Tests grün.
[0.21.0] — 2026-07-16
Paket 1 der Überarbeitung: Nutzer-Sackgassen beseitigt (B1, B2, P10-2).
- fix: B1 — unerreichbares Konto behoben. Ein Konto-Rumpf mit bestätigter E-Mail, aber ohne Passwort (Abbruch nach der Bestätigung) galt bei erneuter Registrierung als „Dublette" → „Konto existiert, bitte anmelden", obwohl weder Anmeldung (kein Passwort) noch „Passwort vergessen" (greift nur mit Hash) möglich waren. Jetzt: Registrierung fortsetzen über die vorhandene Resume-Strecke — sowohl bei erneuter Registrierung als auch über „Passwort vergessen". Entscheidung in der puren, getesteten Funktion
registrierung_weg(). - fix: B2 — 2FA-Reset erreichbar. Wer Handy und Notfall-Codes verloren hat, saß fest:
/2fa-resetwar von keiner Anmelde-/2FA-Seite verlinkt. Jetzt Link auf der Anmeldeseite und auf der Anmelde-Code-Seite (auch im Notfall-Code-Modus). - fix: P10-2 — Warteseite. Bei erkanntem Tippfehler ist die Korrektur jetzt der Primär-Klick („An <korrigiert> senden"); „Erneut senden" wird zurückgestuft (erneutes Senden an die falsche Adresse hilft nicht).
- Hinweis: E044 war ein Symptom von B1 — der CTA „Neuen Link anfordern" existierte bereits seit v0.14, lief aber ins Leere; mit dem B1-Fix funktioniert der Weg.
- 4 neue Tests (
tests/registrierung_weg.test.php), 91 Tests grün.
[0.20.0] — 2026-07-15
Hintergrundbild + Verwaltungs-Listen-Feinschliff:
- core: Turnverband-Hintergrundbild (
bg-turnverband.webp, 22 KB) als dezenter, fixierter Seitenhintergrund — global auf allen Seiten (zunächst zum Ansehen); ersetzt den Platzhalter-SVG auf der Anmeldeseite. - core: Benutzerliste mit farbigen Status-Badges (Zugang/Prüfung/Typ) statt Rohtext — orientiert an den Mockups.
[0.19.0] — 2026-07-15
Rollen/Rechte-Modell, Einstellungs-Reiter, Audit mit Rolle:
- core: Rechte mit Klarname + Beschreibung im Rollen-Editor (was bewirkt das Recht) statt nur technischer Schlüssel.
- core: Rollenverwaltung in die Benutzerverwaltung verschoben (
/verwaltung/rollen) und mit neuem Rechtroles.manageabgesichert — nur Admins. Rollen-Zuweisung/-Bestätigung an Personen bleibt beiusers.*. - core: Neue Systemrolle „GS-Admin" = normale Verwalterrolle (Benutzer verwalten, Freigaben/Rollen bestätigen, Nachrichten senden, Protokoll/Systemstatus). Ohne Einstellungen/Rollenverwaltung. (Migration 0004.)
- core: Einstellungsseite mit Reitern (Allgemein / Inhalte & Texte / später Module) + editierbare Systemwerte (Hilfe-/Team-Adresse, Freigabe-SLA-Text, 2FA-Self-Reset an/Frist, SSO-Domains, aktive Module). Modul-Reiter-Hook
<modul>_settings_tab(). - core: Audit protokolliert die handelnde Rolle (
audit_log.actor_role) — mit welcher Rolle eine Änderung/Nachricht ausgelöst wurde; sichtbar im Zugriffsprotokoll und im Konto-Verlauf.
[0.18.0] — 2026-07-15
Nachrichten, UI-Feinschliff, Rollen-Editor, Löschbestätigung:
- core: Nachrichten-System (Admin ↔ Nutzer) — Admin schreibt aus der Benutzerverwaltung; die Nachricht erscheint im Portal-Postfach (
/nachrichten, Kopf-Icon + Ungelesen-Badge) und kommt per E-Mail; Nutzer können antworten, Antworten laufen in der Verwaltung zusammen (E-Mail an die Hilfe-Adresse). Neue Tabelleuser_messages(Migration 0003), Router-Platzhalter{thread_id}. - fix: riesiger Anmelde-Button —
.icohatte keine Basisgröße und füllte den Button; Basis-Icon-Größe ergänzt (visuell verifiziert). - core: Rollen editierbar — der Rollen-Editor kann jetzt auch den angezeigten Namen ändern (alle Rollen, auch System; Schlüssel bleibt fest). Rolle „Referent/in bzw. Übungsleiter/in" → „Referent/in".
- core: Freigaben statt „Klärfälle" (verständlicherer Name; Registrierungen prüfen + ausstehende Rollen-Bestätigungen).
- core: Zweite Lösch-Bestätigung — eigene Bestätigungsseite („Wirklich löschen?") vor der endgültigen Löschung;
data-confirmwirkt jetzt auch auf Buttons/Links. - core: Anmeldeseite — „Honorarabrechnungen einreichen"; „Lizenzen & Fristen" als geplant markiert + Klarstellung, dass die BTV-Lizenzverwaltung nicht ersetzt wird; Hinweis, dass Daten über Schnittstellen mit anderen BTV-Systemen synchronisiert werden.
- core: Geburtsdatum im deutschen Format (Anzeige TT.MM.JJJJ, Eingabe als Datumsfeld).
[0.17.0] — 2026-07-15
Fixes (Mobil, Checkbox) + Benutzer löschen:
- fix: Datenschutz-Checkbox endgültig behoben —
.form label{display:block}(btv-ui.css) schlug per Spezifität den Flex; jetzt.form label.radio-row{display:flex}+ Checkboxdisplay:inline-block;width:auto(visuell verifiziert). - fix: Mobile Darstellung — Tabellen (
.check-table/.grid) werden bei ≤640px horizontal scrollbar (statt die Seite zu sprengen); Layout-Kindermin-width:0; Header-Icons brechen um;grid-2/daten-grideinspaltig; veraltete.topnav-Mobile-Regeln durch.headericons/.apptop-rightersetzt. (Headless-Chrome-Render geprüft: kein Seiten-Überlauf.) - core: Benutzer löschen (Admin, Recht
users.manage) — „Gefahrenzone" auf der Konto-Detailseite; löscht Konto +user_*-Kindtabellen (Kaskade), Audit/Zugriffs-/Mail-Protokolle bleiben (12.5); Schutz gegen Selbst- und Anker-Admin-Löschung; Audituser.geloescht. Bei abrechnungsrelevanten Daten später Pseudonymisierung (§147 AO).
[0.16.0] — 2026-07-15
Optimierungsschleife 3 (Rechtstexte, Verwaltung, Klärfälle, Breadcrumb, Fixes):
- core: Rechtstext-Entwürfe Impressum, Datenschutz, Erklärung zur Barrierefreiheit (neu,
/barrierefreiheit) — nach Vorlage der BTV-Hauptseite, auf das Portal angepasst, als HTML inapp/legal/*.html(im Content-Editor bearbeitbar); jeweils mit deutlichem „Entwurf — vom BTV freizugeben"-Hinweis und Platzhaltern. Footer-Link ergänzt. - core: Benutzerverwaltung — Stammdaten sicht- und änderbar (Recht
users.manage): Person/Adresse editierbar, Bank/Zuordnung als Hinweis; eine Transaktion + Audit (user.stammdaten_geaendert). - core: Klärfälle zeigen jetzt „Ausstehende Rollen-Bestätigungen" (selbst gewählte, unbestätigte Rollen wie Referent/in); Header-Badge zählt sie mit.
- core: Breadcrumb-Navigation in der Shell — datengetrieben aus Kernbereichen + Modul-Nav (
<modul>_nav()), damit spätere Module automatisch eine Sektion erhalten. - core: Registrierung/Anmeldung ist die öffentliche Startseite (bereits 0.15); Registrierung dort integriert.
- fix: Datenschutz-Checkbox (Registrierung) — Text rutschte in die nächste Zeile (Checkbox erbte
width:100%); jetzt natürliche Breite. - fix: Entra-Profilbild-Abruf — nutzt jetzt einen eigenen App-Token der SSO-App statt der Mail-App (deren Zugriff ist per Access-Policy aufs Absender-Postfach beschränkt). Braucht
ProfilePhoto.ReadWrite.All(Application) + Admin-Consent auf der SSO-App (O3-C). - core: Kontakt-/Hilfeadresse
info@btv-turnen.de(Default).
[0.15.0] — 2026-07-15
UI-Optimierungsschleife 2 (Ansprache, Auth-Seite, Navigation, Stammdaten):
- core: durchgaengige „Du"-Ansprache, genderneutral — gesamtes Portal (Views, Controller, E-Mails, Fehlermeldungen, Default-Inhalte) von Sie auf wertschaetzendes Du umgestellt; genderneutrale Rollen-/Personenbegriffe; im Konzept 4.7 als verbindliche Regel fuer alle kuenftigen Module verankert
- core: Registrierung in die Auth-Seite integriert — gemeinsame
auth-View mit Tabs „Anmelden"/„Registrieren";/(oeffentliche Startseite) = diese Anmelde-/Registrierungsseite (SSO-first) - core: Benutzerverwaltung zurueck in die linke Sidebar (Kernfunktion oben, darunter Module); die vier uebrigen Kernfunktionen (Klaerfaelle, Zugriffsprotokoll, Systemstatus, Hilfe) bleiben in der Kopfzeile, jetzt nur als Icons, rechtsbuendig
- core: Profil zeigt alle Stammdatenfelder (Person, Adresse, Bankverbindung, BTV-Zuordnung) — auch leere („noch nicht hinterlegt") mit Vollstaendigkeits-Badge; IBAN nie im Klartext (nur Vorhanden-Hinweis)
- core: Entra-Profilbild-Sync scharf (
entra.photo_sync=true,ProfilePhoto.ReadWrite.Allgesetzt — O3-C erledigt) - core: Kontakt-/Hilfeadresse Default
info@btv-turnen.de - core: Auth-Seite mit dezenter Deko-Grafik (Platzhalter fuer finales Illustrations-Asset)
[0.14.0] — 2026-07-15
UI-Optimierungsschleife (Navigation, Begruessung, Login, Avatar):
- core: Navigation umgebaut — Kern-Portalfunktionen (Benutzerverwaltung, Klaerfaelle, Zugriffsprotokoll, Systemstatus, Hilfe) in die Kopfzeile; Nutzername/Avatar -> Nutzermenue (Mein Profil & Einstellungen); Admin-Einstellungen ueber Zahnrad-Icon; linke Sidebar jetzt fuer Module reserviert (Aufklapp-Untermenues via
<modul>_nav()children), im MVP ausgeblendet - core: „Ansicht als Rolle"-Umschalter fuer Admins (wie Digitalportal) — nur Sicht, keine Rechte-Eskalation (immer Teilmenge), mit Ansichts-Banner + Zurueckschalten
- core: wertschaetzende Begruessung nach Tageszeit + erster Besuch heute vs. wiederkehrend
- core: Anmeldeseite nach Bild-5 — zweispaltig (Willkommen + Feature-Cards | Auth-Card mit SSO-first, E-Mail/Passwort, 2FA-Hinweis, Registrieren-Tab, Hilfe-Footer)
- core: Profilbild wird nach Upload auf 320x320 heruntergerendert (zentriert quadratisch, WebP/gd); Entra-Foto-Sync (Abruf beim SSO-Login + Rueckschreiben nach Aenderung) implementiert — gated per
entra.photo_sync, braucht Graph-PermissionProfilePhoto.ReadWrite.All(O3-C, sonst No-Op)
[0.13.0] — 2026-07-14
UI-Shell + Profilbild (orientiert an den Mockups, Konzept 4.7):
- core: Portal-Shell fuer angemeldete Seiten — feste linke Sidebar (datengetrieben aus Core-Rechten +
<modul>_nav()) + Topbar mit Nutzer-Chip/Avatar/Rolle; oeffentliche/Onboarding-Seiten behalten das zentrierte Layout - core:
nav.php—portal_nav(), Inline-SVG-Icons (CSP-konform), Rollen-Label, Initialen,portal_avatar() - core: Profilbild (Avatar) — Upload auf /profil (JPG/PNG/WebP, max 3 MB, finfo-MIME-Pruefung), Ablage ausserhalb des Webroots (storage/avatars/), Auslieferung nur ueber /profil/bild; Anzeige in Topbar + Profil-Header, Initialen als Fallback (Migration 0002
profiles.avatar_ext) - core: semantische Status-Badge-Klassen (fuer spaetere Vorgangslisten), mobile Sidebar-Kollaps
- docs: Kap. 4.7 „Portal-Shell & Modul-Oberflaechenintegration" ins Konzept (Modul-Hooks _nav/_widgets, Vorgangs-Aggregator, Notifications, Dokumenten-Panel); Konzept v1.10
[0.12.0] — 2026-07-14
Block 12 „Härtung + Livegang" — Code-Teil (Konzept Kap. 16, 12.x, 13.6):
- core: Löschkonzept scharf (12.5.4) — alle Aufbewahrungsklassen als Cron-Jobs:
mails12 Monate,access_log90 T,job_runs90 T, Trusted Devices 30 T nach Ablauf,phoenix_matches.candidates_json(Dritt-Daten) bei Klärung/90 T — zusätzlich zu den bestehenden (login_attempts, auth_tokens, form_drafts, Rümpfe, unvollständige Registrierungen) - core: Security-Header verschärft — CSP um
style-src 'self'/img-src 'self' data:ergänzt,X-Frame-Options: DENY,Permissions-Policy(Kamera/Mikro/Geo/… aus),X-Permitted-Cross-Domain-Policies, HSTS mitpreload - core: Barrierefreiheit (13.6) — Skip-Link,
<main id="inhalt">-Landmark,role="alert"/role="status"auf Meldungen (23 Views), sichtbarer:focus-visible-Fokus (autocomplete/Labels waren bereits durchgängig) - docs:
SECURITY-REVIEW.md— interner Security-Review gegen den Härtungskatalog (Auth/2FA/Krypto/Tokens/OIDC/Web/DSGVO/Barrierefreiheit, im Code verifiziert) + operative Livegang-Checkliste (Cron, DNS/TLS, SPF/DKIM/DMARC, Entra/Phönix/Erst-Admins, Rechtstexte, ZAP-Baseline, Restore-Test mit TOTP-Kanarienwert, externer Check O17) - core: 3 neue Tests (Cleanup-Registry vollständig, Retention-Löschung, Kandidaten-Purge) — 86 gesamt
Damit ist der MVP-Code aller 12 Blöcke fertig. Der eigentliche Livegang
(1.0.0) folgt nach der operativen Checkliste und den Zulieferungen O3/O4/O5/O7.
[0.11.0] — 2026-07-14
Block 11 „Hilfe & Inhalte" (Konzept 13.4, 12.5.5, Kap. 11):
- core:
content.php— pflegbare Text-/Mail-Bausteine übersettings(content.<key>) mit sinnvollen deutschen Defaults; Registry steuert den Editor; Hilfe-Adresse (O14) und Mail-Signatur zentral - core: Hilfe & Kontakt (
/hilfe) mit Hilfe-Adresse undpublic_idals Support-Referenz für Angemeldete; FAQ (/faq) mit den fünf Top-Fällen (13.4): Mail nicht angekommen · Authenticator einrichten · neues Handy/Codes weg · Passwort vergessen · Konto löschen/Auskunft - core: Impressum und Datenschutzerklärung als strukturierte Seiten mit klarem O7-Platzhalterhinweis (Texte liefert BTV/Projekt29 vor Livegang)
- core: statische Wege Konto löschen (
/konto-loeschen) und Datenauskunft (/datenauskunft) — je eine E-Mail an die Hilfe-Adresse (12.5.5, Selbstservice-Export bereits in der Verwaltung) - core: Content-Editor unter
/einstellungen/inhalte(Rechtsettings.manage) — alle Textblöcke + Hilfe-Adresse bearbeitbar; verifiziert, dass Änderungen sofort auf den öffentlichen Seiten wirken - core: Startseiten-Intro aus
settings; Platzhalter-Controller entfernt; Footer „Hilfe & Kontakt" auf jeder Seite (bereits im Layout) - core: 4 neue Tests (Registry, FAQ-Top-Fälle, Defaults, Settings-Override) — 83 gesamt; alle Seiten + Editor end-to-end verifiziert
[0.10.0] — 2026-07-14
Block 10 „Verwaltung + Startseiten" (Konzept 8.4/8.5/8.6, 7.4, 12.5.5):
- core:
account_state.php— Kontozustände als orthogonale Dimensionen (8.5), zwei Label-Sätze (intern exakt/extern laienverständlich, 13.5), Aufgabenliste für die eingeschränkte Startseite - core: Startseite (8.6) — vollständig freigegeben → Kacheln nach Rechten (mit Klärfall-Zähler-Badge für Interne); nicht freigegeben → eingeschränkte Ansicht mit Aufgabenliste, Erklärtext, Ziel-Bearbeitungszeit und E-Mail-Zusicherung
- core: Benutzerverwaltung (8.4) — Suche (Name/E-Mail/
public_id) + Filter, Detailseite mit allen Zuständen; Rollen einsehen/bestätigen/vergeben/entziehen (Rollen mit Verwaltungs-Rechten nur durch Admin), Konto sperren/entsperren/deaktivieren (session_epoch++, wirkt sofort), Prüfstatus setzen, Admin-2FA-Reset (mit dokumentierter Identitätsprüfung), Audit-Historie je Konto - core: Klärfall-Queue (8.4) — nach Alter sortiert mit Badges, Kandidaten-Gegenüberstellung (Nutzereingabe ↔ Phoenix-Kandidaten mit Auswahl), Ein-Klick-Auflösung mit Zuordnung, Sammelfreigabe, Zähler auf der internen Startseite; täglicher Digest ans Prüfteam (O15, Cron-Job)
- core: Datenauskunfts-Export (12.5.5) — JSON je Konto (Profil, Rollen, Status, Phoenix, Mail-Historie, Audit; ohne
password_hash), deckt Art. 15/20 ab - core: Self-Service-2FA-Reset (7.4, O12) —
reset_2fa-Token mit Sicherheits-Wartefrist (not_before, 24 h), sofortige Benachrichtigung mit „Das war ich nicht"-Abbruchlink; nach Ablauf geführte Neu-Einrichtung inkl.session_epoch++ - core: Rollen-Editor (8.2) unter
/einstellungen— Rollen anlegen (portalweit oder je Modul), Rechte per Checkliste (gefiltert gegen den Katalog, Verwaltungs-Rechte markiert), System-Rollen nicht löschbar;/zugriffe(Audit-Protokoll, Rechtaudit.view) - core: 8 neue Tests (Kontozustand/Labels, Suche, Rollen, Statuswechsel-Epoch, Klärfall-Zähler, Rollen-Editor) — 79 gesamt; alle Admin-Strecken + Self-Service-Reset end-to-end verifiziert
[0.9.0] — 2026-07-14
Block 9 „Phoenix" (Konzept Kap. 9):
- core:
app/integrations/phoenix.php— it4sport-Client mit Modi simulate/test/live, nur lesend,phoenix_find_person_by_email()/phoenix_get_person(), Timeouts ≤ 10 s; simulate-Fixtures für alle vier Ergebnispfade (Adress-Kennung+eindeutig/+mehrdeutig/+fehler/sonst); test/live-REST-Client vorbereitet (exakter Personensuch-Endpunkt = O5) - core: Abgleich im Registrierungs-Wizard vor der Stammdatenerfassung (9.3); Fehler blockieren nie (→
fehler+ Selbst-Erfassung + Retry-Job) - core: vier Ergebnispfade — eindeutig (Vorbefüllung, aktive Bestätigung,
phoenix_person_idwird führend) · mehrdeutig (keine Auto-Zuordnung, Kandidaten nur intern, Klärfallpruefung_erforderlich) · kein_treffer / fehler (Selbst-Erfassung) - core: Geburtsdatum-Härtung (9.3) — bei Treffer nicht vorbefüllt, sondern zur Bestätigung eingegeben und gegen die Mitgliederverwaltung geprüft; Abweichung → Klärfall statt Zuordnung
- core: Nutzer-Wording ohne Systemnamen (13.5); Bestätigungs-Snapshot mit Quelle (phoenix/selbst, 12.5.3); Adresse optional vorbefüllbar
- core: Re-Match-/Führungsregeln (9.4) —
phoenix_person_idführend, E-Mail-Änderung löst keinen Re-Match aus; Retry-Job wiederholtfehler-Läufe und überschreibt bestätigte Daten NIE (nur Referenz + Prüfhinweisphoenix.nachtreffer) - core: Datums-Helfer
valid_datum()/datum_iso()(TT.MM.JJJJ + ISO, keine Zukunft); Selfcheck-Zeile Phönix-Modus - core: 8 neue Tests (vier Pfade, Fixtures, Datums-Härtung) — 71 gesamt; alle vier Wizard-Pfade + Abschluss-Freigabe + Retry-Regel end-to-end verifiziert
[0.8.0] — 2026-07-14
Block 8 „Entra-SSO intern" (Konzept 6.2, Checkliste 6.2.2):
- core:
oidc.php— gehärteter, dependency-freier OIDC-Client: Authorization Code Flow + PKCE S256, State/Nonce einmalig (sofortiges unset, kein Callback-Replay),alg-Header explizit RS256, Signaturprüfung gegen JWKS (RSA-PEM aus JWK dependency-frei), iss/aud/exp/iat/nbf mit Uhren-Toleranz, tid-Prüfung gegen den Tenant; Discovery + JWKS 24 h dateigecacht; nuropenid profile email— keine Graph-Tokens in der Session - core: Konto-Zuordnung ausschließlich über
entra_oid— E-Mail-Match verknüpft nie: bestehendes lokales Konto → Login abgewiesen + Klärfall (pruefung_erforderlich), unbestätigte Rümpfe werden verworfen; Autoanlage nur Tenant-Member (kein Gast/#EXT#), optional an die Gruppe „BTV-Portal-Nutzer" gebunden (O3); neue Konten:btv_hauptberuf(source sso, bestätigt), sofort freigegeben; E-Mail/Name folgen dem Entra-Konto - core:
amr-Claim wird als MFA-Nachweis protokolliert;twofa_passedbeim Callback (2FA erbringt der IdP); IdP-Fehler (error-Param) mit freundlicher Seite - core: Erst-/Notfall-Admin-Anker (8.1):
emergency_admin_oidsaus config.php wirkt nur für Entra-Konten, perentra_oid, über die Web-UI nicht entziehbar - core: Selfcheck — Entra-Konfiguration (O3) + Ablaufwarnung Client-Secret (30 Tage); Login-Seite mit „Anmelden mit BTV-Konto (Microsoft)"
- core: 15 neue Tests (JWT-Verifier: gültig/none/HS256/kid/Signatur/exp/aud/tid/nonce/Toleranz · Zuordnung: Provisionierung, oid-Wiedererkennung, Gast, Kollision→Klärfall, Rumpf-Übernahme) — 64 gesamt
- Hinweis: Der Live-Roundtrip gegen Entra braucht die O3-Zulieferung (M3/Staging) — alle prüfbaren Teile sind lokal getestet
[0.7.0] — 2026-07-14
Block 7 „Passwort- & E-Mail-Strecken + Formularrettung" (Konzept 6.4/6.5, 13.3):
- core: Passwort vergessen — immer neutrale Antwort (Konto/Nicht-Konto identisch, Versand über die Outbox), Reset-Link 60 min, Policy-Fehler verbrauchen den Token nicht, Reset →
session_epoch++(alle Sessions/Geräte raus) + Info-Mail; ein Reset umgeht die 2FA nie; SSO-Konten erhalten den Microsoft-Verweis - core: Passwort ändern (angemeldet) — aktuelles Passwort nötig,
session_epoch++, eigene Session zieht nach, andere sterben; Info-Mail + Audit - core: E-Mail-Adresse ändern (6.5) — Re-Auth (Passwort + Anmelde-Code), Bestätigungslink an die NEUE Adresse (Konflikt neutral mit Hinweis-Mail), Umstellung erst nach POST-Einlösung, Info an die ALTE Adresse mit Widerspruchshinweis,
session_epoch++; Phoenix-Regel 9.4 (kein Re-Match) dokumentiert - core: Formulardaten-Rettung (13.3) — POST mit abgelaufener Sitzung sichert die Eingaben (24 h, Cookie- + Konto-gebunden, sensible Felder werden NIE gespeichert), nach dem Re-Login geht es direkt auf dem vorbefüllten Formular weiter; Cleanup-Job
- core:
/profil-Übersicht mit Strecken-Kacheln (SSO-Konten sehen den Microsoft-Hinweis) - core: Bugfix: Idle-/Absolut-Timeout griff nur für Dev-Sessions — echte Konten-Sessions liefen nie ab; Idle-Minuten hängen jetzt am Kontotyp (intern 30 / extern 60, O9)
- core: 3 neue Tests (Sensibel-Filter, Draft-Roundtrip, Redirect-Ziel-Härtung) — 49 gesamt
[0.6.0] — 2026-07-14
Block 6 „Registrierungs-Wizard" (Konzept 6.3, O6):
- core: Zustandsmaschine mit 6 sichtbaren Etappen („Schritt x von 6") — der Fortschritt liegt am Konto, nie in der Session; Abbruch/Wiedereinstieg/Gerätewechsel jederzeit möglich
- core: „Das brauchen Sie"-Seite vor Etappe 1 (Dauer, Postfach, Smartphone)
- core: Etappe 1 mit Datenschutz-Checkbox (Zeitpunkt + Dokumentversion), Tenant-Domain-Sperre mit SSO-Verweis, neutraler Dubletten-/Rumpf-Behandlung (identischer Bildschirm; Bestandskonten erhalten eine Hinweis-Mail, unbestätigte Rümpfe einen neuen Link — Übernahme durch den echten Postfach-Inhaber)
- core: Warteseite mit großer Adressanzeige, Adresse korrigieren, Tippfehler-Vorschlag (gmial→gmail …), „Erneut senden" mit sichtbarem Countdown, Spam-Hinweis mit Absender, „Ich habe bestätigt — weiter" (nur solange kein Passwort gesetzt ist)
- core: Landing-Pages mit POST-Einlösung für Bestätigen + Fortsetzen (Safe-Links-fest); Gerätewechsel: der Wizard läuft auf dem Gerät weiter, das den Link öffnet
- core: Etappen 2–5 — Passwort (Policy), 2FA (Block-5-Strecke, kehrt in den Wizard zurück), Rollen-Selbstauswahl (nur
selectable, unbestätigt), Stammdaten mit aktiver Bestätigung + Snapshot (Feldwerte, Quelle, Textversion/Hash, Zeit, IP — 12.5.3) - core: Etappe 6 — automatische Freigabe unauffälliger Registrierungen (
review_status='freigegeben', O6/8.1 v1.9); Klärfälle entstehen erst mit dem Mitgliederabgleich (Block 9) - core: Lebenszyklus-Jobs — Rümpfe 72 h, unvollständige Registrierungen 30 Tage, Erinnerung an Tag 23 mit 7-Tage-Fortsetzungslink (genau einmal je Konto)
- core: 3 neue Tests (Zustandsmaschine) — 46 gesamt
[0.5.0] — 2026-07-14
Block 5 „2FA" (Konzept Kap. 7, 12.3):
- core:
crypto.php— sodium secretbox im Format keyid‖nonce‖ciphertext, Key-Map mit Rotation (php cron.php reencrypt), Kanarienwert im Selfcheck - core:
totp.php— RFC-6238-TOTP dependency-frei (HMAC-SHA1, 6 Ziffern, 30 s, Toleranz ±1,hash_equals), gegen die offiziellen RFC-Testvektoren getestet; Replay-Schutz überlast_used_step; Brute-Force-Limits pro Konto (5 Versuche → progressive Sperre) - core: Pending-Session ≤ 5 min zwischen Passwort und Code (6.1); Login ohne eingerichtete 2FA führt zwingend ins Onboarding
- core: Onboarding mit drei gleichwertigen Wegen —
otpauth://-Button (ein Gerät genügt), lokal gerenderter QR-Code (phpqrcode, kein externer Dienst), manueller Schlüssel;Cache-Control: no-storeauf allen Geheimnis-Seiten - core: Recovery-Codes — 10 Einmal-Codes (gehasht, Batch), Anzeige genau einmal, PDF (tFPDF), Pflicht-Checkbox; Nachgenerieren unter
/profil/2famit Re-Auth (Passwort + Code); Recovery-Login erzwingt Neu-Einrichtung inkl.session_epoch++(alte Sessions + vertraute Geräte verfallen) und Info-Mail mit Restanzahl - core: „Diesem Gerät 30 Tage vertrauen" — Cookie-Token gehasht in
user_trusted_devices, ansession_epochgebunden, serverseitig widerrufbar (/profil/2fa) - core: Vendor-Bausteine
lib/phpqrcode/,lib/tfpdf/(aus btv-akkreditierung übernommen) - core: 14 neue Tests (RFC-Vektoren, Base32, Toleranz/Replay, Krypto-Roundtrip/Manipulation, Recovery-Batches) — 43 gesamt
[0.4.0] — 2026-07-14
Block 4 „Externer Login" (Konzept Kap. 6.1/6.4, 12.1/12.2):
- core:
password.php— Argon2id viapassword_hash()mit sodium-Fallback, Rehash-Migration beim Login, Policy (min. 12 Zeichen, lokale Top-Passwortliste, keine Sonderzeichen-Pflicht), Dummy-Vergleich für Timing-Gleichheit - core:
tokens.php— verbindliche auth_tokens-Semantik: nur Hashes, atomarer Verbrauch (rowCount===1), Neuausstellung invalidiert,not_before-Wartefrist (Fristen rechnet die DB — eine Uhr), Landing-Page + POST - core:
/loginmit neutralen Fehlern (Antwort + Timing identisch, Konto-Enumeration ausgeschlossen), harte Zugangsschalter (gesperrt/deaktiviert) - core: Login-Rate-Limit — Fehlversuche je E-Mail/IP-Bucket (IPv6 /64), progressive Verzögerung 2/4/8/15 min (Deckel), ehrliche Lockout-Meldung, Entsperr-Link per Mail (max. 1/h je Konto, Unterdrückung im Audit), keine harte Kontosperre durch Fremd-Fehlversuche
- core: echte Konten-Sessions — nur
user_id+session_epochin der Session, Konto je Request frisch aus der DB (Sperrung/Passwortänderung wirkt sofort); Dev-Login bleibt getrennt - core:
repo_user_create_local()mit public_id-Kollisions-Retry; Rollen für Rechte nurconfirmed=1(8.1) - core: Cleanup-Jobs
login_attempts(30 Tage) + abgelaufeneauth_tokens - core: 13 neue Tests (Policy, Hash-Roundtrip, ip_bucket, Delay-Progression, Token-Atomik/not_before/TTL-Validierung) — 29 gesamt
[0.3.0] — 2026-07-14
Block 3 „Mail-Outbox" (Konzept Kap. 11):
- core:
mailer.phpals Outbox —queue_mail()schreibt nur inmails, Versand ausschließlich über den Cron-Worker (kein Synchronversand, kein Timing-Leak) - core: Worker mit Backoff-Retry (2^n min, Deckel 60; max. 8 Versuche →
status='error'+ Auditmail.failed) - core: Token-Redaktion —
queue_mail(…, ['redact' => [$token]])speichert eine redigierte Fassung; nach Versand/endgültigem Fehlschlag ersetzt sie den Klartext-Body (Migrationmigrate_0001fügtbody_redactedhinzu) - core: Versandwege
simulate(Datei unter storage/mails/),smtp(dependency-frei, STARTTLS/SSL, AUTH LOGIN) undgraph(App-only sendMail, O3 App B) - core:
repo_schema_install()setzt jetzt eine Migrations-Baseline (Gesamtstand zählt vorhandene Migrationen als angewendet) - core: Selfcheck — Mail-Modus, dauerhaft fehlgeschlagene Mails, Outbox-Stau
- core: 3 neue Tests (Redaktion, Backoff-Progression) — 16 gesamt
[0.2.0] — 2026-07-14
Block 2 „Schema + Konto-Basis" (Konzept Kap. 16):
- core: Gesamtschema
sql/schema.sqlnach Konzept 5.2 — 20 Tabellen inkl. vorbereiteter Felder (users, profiles, roles/user_roles/role_rights, user_scopes, user_totp, user_recovery_codes, user_trusted_devices, auth_tokens, login_attempts, phoenix_matches, documents, form_drafts, audit_log, access_log, mails, settings, schema_migrations, job_runs) - core: Seeds — 4 System-Rollen (
is_system, referent/ehrenamtselectable) + Rechte-Katalog 8.2 +modules_enabled - core: Installations-/Migrations-Mechanik (5.3):
php cron.php install(nur leere DB) undphp cron.php migrate(explizit, protokolliert inschema_migrations; Selfcheck meldet nur) - core: Audit-Log
audit.phpmit erzwungener Inhalts-Blockliste (audit_assert_safe()wirft bei Passwort/Token/Code/IBAN/Phoenix-Rohdaten — 12.4) - core: Rechteprüfung liest jetzt aus
role_rights(DB) mit Request-Cache; statischer Katalog nur noch als Fallback ohne Schema - core: Selfcheck erweitert (Schema installiert, Rollen-Seeds); access_log aktiv
- core: O8-Konten dokumentiert (franz@/it-services@btv-turnen.de) — es fehlen die Entra Object-IDs
[0.1.0] — 2026-07-14
Block 1 „Gerüst + Fundament" (Konzept Kap. 16):
- core: Projektstruktur nach BTV-Webapp-Muster (public/, app/, views/, sql/, storage/, tests/)
- core: Front-Controller mit Routentabelle, Platzhaltern
{id}/{public_id}/{token}und Modul-Registrierung übermodules_enabled(4.5) - core: Helfer
config(),e(),view(),csrf_*,db_tx(),new_public_id()(Alphabet ohne 0/O/1/I),no_store() - core: zentrale Session-Härtung (
__Host--Cookie, Secure/HttpOnly/SameSite=Lax, strict_mode, Idle-/Absolut-Timeout, Dev-Ausnahme) inauth.php - core: Sicherheits-Header inkl. CSP (
script-src 'self',form-action 'self', kein Inline-JS/-Styles) - core: zentraler Error-/Exception-Handler mit CD-500-Seite (
errors.php) - core: Rechte-Guards
require_login()/require_right()/require_right_scoped()mit MVP-Rechtekatalog (8.2; DB-Anbindung folgt mit Block 2) - core: Dev-Login (Rollen-Umschalter) hart an
app_env='dev'gebunden; Logout nur per POST - core: Selfcheck (
/selfcheck, Rechtsystem.view) nach Anhang A und öffentlicher/health-Endpoint (200/500, spiegelt Cron-Ausfall) - core: Cron-Runner
cron.php(run/list/migrate) + Job-Registry mitGET_LOCK()-Schutz, Heartbeat als Dead-Man-Switch (14.5) - core: dependency-freier CLI-Testrunner
php tests/run.phpmit ersten Helfer-Tests (14.8) - core: btv-ui eingebunden (Tokens, Komponenten, Logo) + portal-eigenes
style.css/app.js(data-*-Muster)