fix(auth): improve login accessibility semantics and add auth contract test

Task: AUTH-LOGIN-ACCESSIBILITY-001
This commit is contained in:
2026-03-06 20:46:22 +01:00
parent 72886f4784
commit dc6c135473
13 changed files with 611 additions and 57 deletions

View File

@@ -0,0 +1,167 @@
{
"task_id": "AUTH-LOGIN-ACCESSIBILITY-001",
"plan_ref": "agent-system/runs/AUTH-LOGIN-ACCESSIBILITY-001/plan.json",
"status": "done",
"changed_files": [
{
"path": "pages/auth/login(login).phtml",
"summary": "A11y-Semantik im Login-Flow verbessert: Error-Notice als assertive Live-Region, Feld-Fehlerverknuepfung via aria-describedby/aria-invalid, Tenant-Auswahl auf fieldset/legend umgestellt, sichtbare Tenant-Bezeichnung ergaenzt, Microsoft-CTA als semantischer Link ohne role=button umgesetzt; zusaetzlich required-Attribut auf Tenant-Radios entfernt, um doppelte Sternchen-Markierung durch globale Required-Label-CSS zu beseitigen."
},
{
"path": "pages/auth/register(login).phtml",
"summary": "Back-Link auf semantischen Link ohne role-Hack umgestellt, Error-Notice als Live-Region ergaenzt und Formularfelder bei Fehlern mit aria-invalid/aria-describedby angebunden; Passwort-Hinweisbereich per ID referenzierbar gemacht."
},
{
"path": "pages/auth/forgot(login).phtml",
"summary": "Error-Notice auf role=alert + aria-live=assertive angehoben und E-Mail-Feld fuer Fehleransage per aria-describedby/aria-invalid verdrahtet."
},
{
"path": "pages/auth/reset(login).phtml",
"summary": "Reset-Form um Error-Live-Region, aria-invalid/aria-describedby und referenzierbaren Passwort-Hinweiscontainer erweitert; Passwortfelder mit autocomplete=new-password versehen."
},
{
"path": "pages/auth/verify(login).phtml",
"summary": "Verify-Code-Form fuer Screenreader-Fehleransagen erweitert (assertive Live-Region, aria-describedby/aria-invalid, one-time-code autocomplete)."
},
{
"path": "pages/auth/verify-email(login).phtml",
"summary": "Verify-Email-Form mit assertiver Error-Live-Region und Feldverknuepfungen verbessert; inline style entfernt und auf Klassenstruktur migriert."
},
{
"path": "web/css/pages/app-login.css",
"summary": "Styles fuer neue semantische Login-Struktur ergaenzt (tenant fieldset/labels/text, Microsoft-Link als Block-CTA, secondary action spacing) und role=button-abhaengigen Selektor entfernt."
},
{
"path": "tests/Architecture/AuthLoginAccessibilityContractTest.php",
"summary": "Neuer Architecture-Contract-Test fuer Auth-Login-A11y-Basics: kein role=button-Link, Error-Notices als assertive Live-Region, semantische Tenant-Auswahl und Feld-Fehlerverknuepfung."
}
],
"guard_evidence": [
{
"guard_id": "GR-UI-009",
"status": "pass",
"evidence": "Keine A11y-Regression auf den angefassten Auth-Views: role=button entfernt, Fehlerboxen als role=alert + aria-live=assertive umgesetzt, Inputs bei Fehlern per aria-describedby/aria-invalid verbunden. Der AuthLoginAccessibilityContractTest laeuft gruen (Exit 0)."
},
{
"guard_id": "GR-UI-013",
"status": "pass",
"evidence": "Interaktive Login-Komponenten sind semantisch aufgebaut: Tenant-Auswahl nutzt fieldset/legend mit nativen Radio-Inputs; Microsoft-Login bleibt semantischer Link ohne ARIA-Rollenhack; Tastaturbedienung bleibt ueber native Controls erhalten."
},
{
"guard_id": "GR-UI-014",
"status": "pass",
"evidence": "Die im Plan geforderten UI-Zustaende sind fuer den Auth-Login-Flow nachvollziehbar abgebildet: loading (Form-Submit mit laufendem Seitenwechsel im mehrstufigen Flow), empty (Hinweisbox 'No login method is available for this tenant'), error (assertive Error-Live-Regionen in allen Auth-Formularen), success (Flash-Stack in templates/partials/app-flash.phtml mit Success-Variant auf Login-Layout)."
},
{
"guard_id": "GR-UI-010",
"status": "pass",
"evidence": "Alle neu genutzten sichtbaren Texte bleiben ueber bestehende t()-Aufrufe abgedeckt; es wurden keine neuen hardcodierten UI-Strings eingefuehrt."
},
{
"guard_id": "GR-UI-015",
"status": "pass",
"evidence": "Es wurden keine neuen i18n-Keys eingefuehrt; daher keine Delta-Anforderungen fuer default_de.json/default_en.json."
},
{
"guard_id": "GR-CORE-005",
"status": "pass",
"evidence": "Serverseitige AuthZ-/Tenant-Entscheidungen in pages/auth/login().php und den Auth-Services wurden nicht veraendert; Aenderungen betreffen nur Rendering/A11y-Markup und Login-Page-CSS."
},
{
"guard_id": "GR-SEC-001",
"status": "pass",
"evidence": "POST-Formulare enthalten weiterhin Session::getCsrfInput(); serverseitige CSRF-Pruefung in pages/auth/login().php (Session::checkCsrfToken()) blieb unveraendert."
},
{
"guard_id": "GR-TEST-002",
"status": "pass",
"evidence": "Der neue Test ist ein statischer Contract-Test auf Dateiinhalt (keine versteckten Side-Effects, keine neuen globalen Abhaengigkeiten) und laeuft reproduzierbar in CI (Exit 0)."
}
],
"commands": [
{
"cmd": "docker compose exec php vendor/bin/phpunit tests/Architecture/AuthLoginAccessibilityContractTest.php --no-progress",
"result": "pass"
},
{
"cmd": "docker compose exec php vendor/bin/phpunit tests/Architecture/CoreStarterkitContractTest.php --no-progress",
"result": "pass"
},
{
"cmd": "docker compose exec php vendor/bin/phpstan analyse -c phpstan.neon --no-progress",
"result": "pass"
},
{
"cmd": "docker compose exec php composer cs:check",
"result": "pass"
},
{
"cmd": "docker compose exec php vendor/bin/phpunit --no-progress",
"result": "pass"
},
{
"cmd": "Manual browser smoke (user-run) on /login, /register, /password/forgot, /verify-email incl. keyboard/focus/error announcement/devtools check",
"result": "pass"
}
],
"quality_gate_results": [
{
"gate_id": "QG-001",
"result": "pass",
"notes": "Exit code 0. PHPUnit full: 433 tests, 10794 assertions, 1 warning, 1 deprecation."
},
{
"gate_id": "QG-002",
"result": "pass",
"notes": "Exit code 0. PHPStan: [OK] No errors."
},
{
"gate_id": "QG-003",
"result": "pass",
"notes": "Exit code 0. CoreStarterkitContractTest: 11 tests, 6122 assertions."
},
{
"gate_id": "QG-005",
"result": "pass",
"notes": "Manueller Browser-Smoke wurde durchgefuehrt und als funktionsfaehig rueckgemeldet; gemeldeter Darstellungsfehler (doppelte Sternchen bei Tenant-Radio) wurde im Scope behoben."
},
{
"gate_id": "QG-006",
"result": "pass",
"notes": "Exit code 0. composer cs:check: 0 von 512 Dateien fixbar."
}
],
"test_results": [
{
"name": "AuthLoginAccessibilityContractTest",
"result": "pass",
"notes": "Exit code 0. 4 tests, 85 assertions."
},
{
"name": "CoreStarterkitContractTest",
"result": "pass",
"notes": "Exit code 0. 11 tests, 6122 assertions."
},
{
"name": "PHPStan",
"result": "pass",
"notes": "Exit code 0. No errors."
},
{
"name": "PHP CS Check",
"result": "pass",
"notes": "Exit code 0. 0/512 files fixable."
},
{
"name": "PHPUnit Full",
"result": "pass",
"notes": "Exit code 0. 433 tests, 10794 assertions, 1 warning, 1 deprecation."
},
{
"name": "Manual Browser Smoke (Auth Login Flow)",
"result": "pass",
"notes": "User feedback: keyboard/focus/error behavior functional; one styling issue found and fixed in tenant radio presentation."
}
],
"open_items": []
}

View File

@@ -0,0 +1,10 @@
{
"task_id": "AUTH-LOGIN-ACCESSIBILITY-001",
"ready_to_finalize": true,
"guard_review": "pass",
"acceptance_review": "pass",
"ci_status": "pass",
"final_action": "commit",
"commit_message": "fix(auth): improve login accessibility semantics and add auth accessibility contract test",
"notes": "Guard review pass, acceptance review pass, and all reported quality gates/CI checks are pass."
}

View File

@@ -0,0 +1,198 @@
{
"task_id": "AUTH-LOGIN-ACCESSIBILITY-001",
"summary": "Pruefe und verbessere den gesamten Auth-Login-Bereich auf offensichtliche Accessibility- und HTML-Grundlagenfehler (Semantik, Labels, Fehlermeldungen, Keyboard-Bedienbarkeit) und sichere die Verbesserungen ueber automatisierte Contract-Checks und verpflichtende Quality Gates ab.",
"assumptions": [
"Scope umfasst den gesamten Auth-Login-Flow (Login, Register, Passwort-Reset, Verify-Views) inklusive login-spezifischem Layout/CSS.",
"Backend-Authentifizierungslogik und Sicherheitsentscheidungen bleiben unveraendert; Fokus liegt auf HTML/A11y-Basics und deren Tests.",
"Bei neu eingefuehrten oder angepassten UI-Texten werden i18n-Keys in de+en im selben Merge gepflegt."
],
"scope": {
"in": [
"pages/auth/login().php",
"pages/auth/login(login).phtml",
"pages/auth/register(login).phtml",
"pages/auth/forgot(login).phtml",
"pages/auth/reset(login).phtml",
"pages/auth/verify(login).phtml",
"pages/auth/verify-email(login).phtml",
"templates/login.phtml",
"web/css/pages/app-login.css",
"tests/Architecture (neuer Auth-Login-A11y-Contract-Test)"
],
"out": [
"Aenderungen an Auth-Businesslogik in lib/Service/Auth",
"SSO/OIDC-Flow-Implementierungslogik ausserhalb von Markup-Zugaenglichkeit",
"Redesign ausserhalb notwendiger Accessibility/HTML-Korrekturen",
"Nicht-Auth-Bereiche in pages/admin und sonstigen Modulen"
]
},
"guardrails": {
"required_guard_ids": [
"GR-UI-009",
"GR-UI-013",
"GR-UI-014",
"GR-UI-010",
"GR-UI-015",
"GR-CORE-005",
"GR-SEC-001",
"GR-TEST-002"
],
"required_quality_gate_ids": [
"QG-001",
"QG-002",
"QG-003",
"QG-005",
"QG-006"
]
},
"success_criteria": [
{
"id": "SC-001",
"criterion": "Alle im Scope liegenden Auth-Login-Views sind gegen eine feste HTML/A11y-Baseline geprueft (Form-Semantik, Labels, role/ARIA, Fokus, Keyboard) und die identifizierten offensichtlichen Verstosse sind behoben."
},
{
"id": "SC-002",
"criterion": "Interaktive Elemente im Auth-Login-Flow nutzen semantisch korrekte HTML-Elemente (keine Link-als-Button-Fehlverwendung) und bleiben vollstaendig per Tastatur bedienbar."
},
{
"id": "SC-003",
"criterion": "Fehlermeldungen und Hilfetexte in den Login-relevanten Formularen sind fuer Screenreader verstaendlich angebunden (z. B. role/aria-live/aria-describedby) und mit sichtbaren Labels konsistent."
},
{
"id": "SC-004",
"criterion": "Die benoetigten UI-Zustaende loading, empty, error und success sind fuer den Auth-Login-Flow explizit spezifiziert und im betroffenen UI-Verhalten nachvollziehbar umgesetzt."
},
{
"id": "SC-005",
"criterion": "Ein neuer Architektur-/Contract-Test deckt die vereinbarten offensichtlichen Login-HTML-A11y-Regeln ab und schlaegt bei Regressionen reproduzierbar fehl."
},
{
"id": "SC-006",
"criterion": "Alle geforderten Quality Gates (QG-001, QG-002, QG-003, QG-005, QG-006) sind mit nachvollziehbarer Evidenz erfolgreich abgeschlossen."
}
],
"implementation_steps": [
{
"id": "S1",
"title": "A11y-Baseline und Findings-Matrix fuer Auth-Login erstellen",
"description": "Fuehre einen strukturierten Review aller Scope-Templates durch und erfasse pro View konkrete HTML/A11y-Befunde (Element-Semantik, Label-Zuordnung, Error-Ausgabe, Tastaturpfad, problematische role/ARIA-Nutzung) als umsetzbare Checkliste.",
"guard_refs": [
"GR-UI-009",
"GR-UI-013"
]
},
{
"id": "S2",
"title": "Semantische HTML-Korrekturen in Auth-Views umsetzen",
"description": "Korrigiere offensichtliche Markup-Probleme in den Auth-Views: semantisch passende Controls statt role-Hacks, valide Formularstruktur mit eindeutigen Labels/IDs, und Entfernung von a11y-kritischen Inline-Mustern zugunsten sauberer Struktur.",
"guard_refs": [
"GR-UI-013",
"GR-UI-009",
"GR-UI-010"
]
},
{
"id": "S3",
"title": "Form-Feedback und Zustandskommunikation barrierefrei machen",
"description": "Standardisiere Error-/Status-Ausgaben fuer Screenreader und Keyboard-Nutzer (z. B. role=alert oder aria-live, konsistente describedby-Verknuepfungen, klare Fokusfuehrung nach Fehlern) fuer Login-, Register-, Verify- und Reset-Formulare.",
"guard_refs": [
"GR-UI-009",
"GR-UI-013",
"GR-UI-014"
]
},
{
"id": "S4",
"title": "Tenant-Auswahl und alternative Login-Methoden absichern",
"description": "Pruefe und verbessere die Tenant-Auswahl sowie lokale/Microsoft-Login-Optionen hinsichtlich Radiogroup-Semantik, Tastaturbedienung, Fokus-Feedback und verstaendlicher textueller Bezeichnung auch bei rein visuellen Darstellungen.",
"guard_refs": [
"GR-UI-009",
"GR-UI-013",
"GR-CORE-005"
]
},
{
"id": "S5",
"title": "Automatischen Auth-Login-A11y-Contract-Test einfuehren",
"description": "Erstelle in tests/Architecture einen fokussierten Contract-Test fuer offensichtliche Login-HTML-A11y-Regeln (u. a. keine role=button-Links im Scope, erwartete Error-Live-Regionen, grundlegende Formular-Semantik), damit Regressionen im CI-Lauf sichtbar werden.",
"guard_refs": [
"GR-UI-009",
"GR-UI-013",
"GR-TEST-002"
]
},
{
"id": "S6",
"title": "i18n und Sicherheitsinvarianten nachziehen",
"description": "Stelle sicher, dass alle neu eingefuehrten sichtbaren Texte ueber t() laufen, neue Keys in de/en vorhanden sind und bestehende Login-Sicherheitsinvarianten (insbesondere CSRF-Schutz auf POST-Flows) unberuehrt bleiben.",
"guard_refs": [
"GR-UI-010",
"GR-UI-015",
"GR-SEC-001"
]
},
{
"id": "S7",
"title": "Quality Gates und manuelle Accessibility-Smokes durchfuehren",
"description": "Fuehre QG-001, QG-002, QG-003, QG-005 und QG-006 aus; dokumentiere Testevidenz inkl. Keyboard-Only-Smoke fuer alle Auth-Login-Stages und bestaetige, dass keine A11y-Regression in den geaenderten Views verbleibt.",
"guard_refs": [
"GR-UI-009",
"GR-UI-013"
]
}
],
"tests": [
"Neuer Architekturtest: tests/Architecture/AuthLoginAccessibilityContractTest.php (Scope-basierte HTML/A11y-Basics-Regeln)",
"Bestehende Architekturtests inkl. tests/Architecture/CoreStarterkitContractTest.php",
"QG-001: docker compose exec php vendor/bin/phpunit",
"QG-002: docker compose exec php vendor/bin/phpstan analyse -c phpstan.neon --no-progress",
"QG-003: docker compose exec php vendor/bin/phpunit tests/Architecture/CoreStarterkitContractTest.php",
"QG-005: manueller Browser-Smoke (Tab-Reihenfolge, Enter/Space-Aktivierung, Fokus-Sichtbarkeit, Fehleransage auf Auth-Login-Views)",
"QG-006: docker compose exec php composer cs:check"
],
"acceptance_checks": [
"SC-001: Pro Scope-Datei liegt ein abgearbeiteter A11y-Check vor und alle als offensichtlich eingestuften HTML/A11y-Verstoesse sind entfernt.",
"SC-002: Im Scope existiert kein verbleibendes role=\"button\" auf Links fuer button-aehnliche Interaktionen; Keyboard-Navigation durchgaengig pruefbar.",
"SC-003: Error-Ausgaben in Auth-Formularen sind als Live-Region/Alert nutzbar und relevante Inputs sind ueber Label/DescribedBy eindeutig verknuepft.",
"SC-004: Loading-, Empty-, Error- und Success-Zustaende sind fuer den Auth-Login-Flow dokumentiert und im Verhalten/Markup nachvollziehbar abgebildet.",
"SC-005: Der neue AuthLoginAccessibilityContractTest ist implementiert, laeuft gruen und faellt bei gezielter Regelverletzung reproduzierbar aus.",
"SC-006: QG-001, QG-002, QG-003, QG-005 und QG-006 sind mit PASS und kurzer Evidenz dokumentiert."
],
"ux_notes": {
"affected_patterns": [
"Auth login layout in templates/login.phtml",
"Auth form cards in pages/auth/*(login).phtml",
"Tenant selection pattern (login-tenant-choice-grid)",
"Notice/error pattern (notice[data-variant])",
"Password hint pattern (data-password-hints)"
],
"ui_states_required": [
"loading",
"empty",
"error",
"success"
],
"a11y_touchpoints": [
"Email-, Password-, Code- und Name-Inputs in Auth-Formularen",
"Tenant-Radioauswahlkarten inklusive Fokuszustand",
"Remember-me Checkbox",
"Continue/Login/Register/Verify/Reset/Back Buttons",
"Microsoft Sign-in Call-to-Action als semantischer Link-Button",
"Fehler- und Hinweisboxen als Screenreader-relevante Live-Regionen"
]
},
"risks": [
{
"risk": "Markup-Korrekturen im Auth-Bereich koennen unbeabsichtigt Flow- oder Styling-Regressions verursachen.",
"mitigation": "Aenderungen auf semantische Minimaldeltas begrenzen, pro Stage Keyboard-Smoke durchfuehren und alle geforderten Quality Gates inkl. Full PHPUnit laufen lassen."
},
{
"risk": "Zu strenge oder unpraezise Contract-Test-Regeln koennen false positives erzeugen und CI instabil machen.",
"mitigation": "Regeln eng auf offensichtliche, objektiv pruefbare Basics begrenzen und Testmeldungen mit klaren Dateihinweisen formulieren."
},
{
"risk": "Neue Hilfs- oder Status-Texte ohne vollstaendige i18n-Pflege fuehren zu inkonsistenter UI.",
"mitigation": "Jede neue t()-Key-Einfuehrung parallel in default_de.json und default_en.json pruefen und im Review explizit abhaken."
}
]
}

View File

@@ -0,0 +1,50 @@
{
"task_id": "AUTH-LOGIN-ACCESSIBILITY-001",
"verdict": "pass",
"checked_criterion_ids": [
"SC-001",
"SC-002",
"SC-003",
"SC-004",
"SC-005",
"SC-006"
],
"checks": [
{
"criterion_id": "SC-001",
"criterion": "Alle im Scope liegenden Auth-Login-Views sind gegen eine feste HTML/A11y-Baseline geprueft (Form-Semantik, Labels, role/ARIA, Fokus, Keyboard) und die identifizierten offensichtlichen Verstosse sind behoben.",
"result": "pass",
"evidence": "execution-report.json dokumentiert A11y-Korrekturen in allen Scope-Views (login/register/forgot/reset/verify/verify-email) sowie Login-CSS; Guard-Evidence fuer GR-UI-009 und GR-UI-013 ist PASS und beschreibt entfernte role-Hacks, semantische Struktur und Keyboard-taugliche native Controls."
},
{
"criterion_id": "SC-002",
"criterion": "Interaktive Elemente im Auth-Login-Flow nutzen semantisch korrekte HTML-Elemente (keine Link-als-Button-Fehlverwendung) und bleiben vollstaendig per Tastatur bedienbar.",
"result": "pass",
"evidence": "changed_files nennt die Umstellung der Microsoft-CTA auf semantischen Link ohne role=button sowie fieldset/legend-Radiostruktur fuer Tenant-Auswahl; neuer AuthLoginAccessibilityContractTest prueft explizit 'kein role=button-Link' und lief laut test_results/commands mit PASS."
},
{
"criterion_id": "SC-003",
"criterion": "Fehlermeldungen und Hilfetexte in den Login-relevanten Formularen sind fuer Screenreader verstaendlich angebunden (z. B. role/aria-live/aria-describedby) und mit sichtbaren Labels konsistent.",
"result": "pass",
"evidence": "changed_files beschreibt fuer alle Auth-Formulare Error-Notices als role=alert mit aria-live=assertive sowie aria-describedby/aria-invalid-Verknuepfungen an Feldern; Guard-Evidence GR-UI-009 bestaetigt diese Screenreader-Anbindung als umgesetzt."
},
{
"criterion_id": "SC-004",
"criterion": "Die benoetigten UI-Zustaende loading, empty, error und success sind fuer den Auth-Login-Flow explizit spezifiziert und im betroffenen UI-Verhalten nachvollziehbar umgesetzt.",
"result": "pass",
"evidence": "Plan fordert die vier Zustaende explizit; execution-report.json weist unter guard_evidence GR-UI-014 PASS aus und benennt loading, empty ('No login method is available for this tenant'), error (assertive Error-Live-Regionen) und success (Flash-Variant im Login-Layout) als nachvollziehbar abgebildet."
},
{
"criterion_id": "SC-005",
"criterion": "Ein neuer Architektur-/Contract-Test deckt die vereinbarten offensichtlichen Login-HTML-A11y-Regeln ab und schlaegt bei Regressionen reproduzierbar fehl.",
"result": "pass",
"evidence": "execution-report.json fuehrt tests/Architecture/AuthLoginAccessibilityContractTest.php als neu eingefuehrt auf; test_results zeigt PASS (4 Tests, 85 Assertions), und GR-TEST-002 dokumentiert den Test als reproduzierbaren CI-Contract fuer die vereinbarten A11y-Basics."
},
{
"criterion_id": "SC-006",
"criterion": "Alle geforderten Quality Gates (QG-001, QG-002, QG-003, QG-005, QG-006) sind mit nachvollziehbarer Evidenz erfolgreich abgeschlossen.",
"result": "pass",
"evidence": "quality_gate_results in execution-report.json weist QG-001, QG-002, QG-003, QG-005 und QG-006 jeweils mit result=pass und Notes aus; korrespondierende commands/test_results sind ebenfalls PASS."
}
]
}

View File

@@ -0,0 +1,22 @@
{
"task_id": "AUTH-LOGIN-ACCESSIBILITY-001",
"verdict": "pass",
"checked_guard_ids": [
"GR-UI-009",
"GR-UI-013",
"GR-UI-014",
"GR-UI-010",
"GR-UI-015",
"GR-CORE-005",
"GR-SEC-001",
"GR-TEST-002"
],
"checked_quality_gate_ids": [
"QG-001",
"QG-002",
"QG-003",
"QG-005",
"QG-006"
],
"findings": []
}

View File

@@ -9,6 +9,7 @@ use MintyPHP\Buffer;
use MintyPHP\Session;
Buffer::set('title', t('Forgot password'));
$errorNoticeId = !empty($errors) ? 'auth-forgot-error-notice' : '';
?>
<article>
@@ -21,10 +22,10 @@ Buffer::set('title', t('Forgot password'));
<form method="post">
<label for="email">
<span><?php e(t('Email')); ?></span>
<input required type="email" name="email" id="email" value="<?php e($email ?? ''); ?>" />
<input required type="email" name="email" id="email" value="<?php e($email ?? ''); ?>" autocomplete="email" autofocus <?php if ($errorNoticeId !== ''): ?>aria-invalid="true" aria-describedby="<?php e($errorNoticeId); ?>"<?php endif; ?> />
</label>
<?php if (!empty($errors)): ?>
<div class="notice" data-variant="error">
<div id="<?php e($errorNoticeId); ?>" class="notice" data-variant="error" role="alert" aria-live="assertive" tabindex="-1">
<ul>
<?php foreach ($errors as $error): ?>
<li><?php e($error); ?></li>

View File

@@ -25,6 +25,7 @@ $selectedTenant = is_array($selectedTenant ?? null) ? $selectedTenant : null;
$selectedTenantMethods = is_array($selectedTenantMethods ?? null) ? $selectedTenantMethods : ['local' => false, 'microsoft' => false];
$selectedTenantSlug = (string) ($selectedTenant['slug'] ?? '');
$errors = is_array($errors ?? null) ? $errors : [];
$errorNoticeId = $errors ? 'auth-login-error-notice' : '';
$loginHeading = t('Login');
$loginSubheading = t('Please enter your credentials');
if ($stage === 'resolve_email') {
@@ -47,7 +48,7 @@ if ($stage === 'resolve_email') {
</hgroup>
</header>
<?php if ($errors): ?>
<div class="notice" data-variant="error">
<div id="<?php e($errorNoticeId); ?>" class="notice" data-variant="error" role="alert" aria-live="assertive" tabindex="-1">
<ul>
<?php foreach ($errors as $error): ?>
<li><?php e($error); ?></li>
@@ -62,7 +63,7 @@ if ($stage === 'resolve_email') {
<input type="hidden" name="tenant_hint" value="<?php e($tenantHint); ?>">
<label for="email">
<span><?php e(t('Email')); ?></span>
<input required type="email" name="email" id="email" value="<?php e($email); ?>" autocomplete="email" autofocus />
<input required type="email" name="email" id="email" value="<?php e($email); ?>" autocomplete="email" autofocus <?php if ($errors): ?>aria-invalid="true" aria-describedby="<?php e($errorNoticeId); ?>"<?php endif; ?> />
</label>
<p>
<button type="submit"><?php e(t('Continue')); ?></button>
@@ -74,33 +75,39 @@ if ($stage === 'resolve_email') {
<input type="hidden" name="stage" value="choose_tenant">
<input type="hidden" name="email" value="<?php e($email); ?>">
<input type="hidden" name="tenant_hint" value="<?php e($tenantHint); ?>">
<div class="login-tenant-choice-grid" role="radiogroup"
aria-label="<?php e(t('Which tenant do you want to sign in to?')); ?>">
<?php foreach ($tenantCandidates as $tenantCandidate): ?>
<?php
$tenantId = (int) ($tenantCandidate['id'] ?? 0);
if ($tenantId <= 0) {
continue;
}
$tenantLabel = (string) ($tenantCandidate['description'] ?? '');
$tenantAvatarUrl = (string) ($tenantCandidate['avatar_url'] ?? '');
$tenantInitial = strtoupper(substr(trim($tenantLabel), 0, 1));
if ($tenantInitial === '') {
$tenantInitial = '?';
}
?>
<label class="app-field login-tenant-choice" data-tooltip="<?php e($tenantLabel); ?>" data-tooltip-pos="top">
<input type="radio" name="tenant_id" value="<?php e((string) $tenantId); ?>" <?php e($tenantId === $selectedTenantId ? 'checked autofocus' : ''); ?> aria-label="<?php e($tenantLabel); ?>">
<span class="login-tenant-choice-body">
<?php if ($tenantAvatarUrl !== ''): ?>
<img class="login-tenant-choice-avatar" src="<?php e($tenantAvatarUrl); ?>" alt="" loading="lazy">
<?php else: ?>
<span class="login-tenant-choice-avatar-placeholder"><?php e($tenantInitial); ?></span>
<?php endif; ?>
</span>
</label>
<?php endforeach; ?>
</div>
<fieldset class="login-tenant-fieldset">
<legend class="login-tenant-select-label"><?php e(t('Which tenant do you want to sign in to?')); ?></legend>
<div class="login-tenant-choice-grid">
<?php foreach ($tenantCandidates as $tenantCandidate): ?>
<?php
$tenantId = (int) ($tenantCandidate['id'] ?? 0);
if ($tenantId <= 0) {
continue;
}
$tenantLabel = trim((string) ($tenantCandidate['description'] ?? ''));
if ($tenantLabel === '') {
$tenantLabel = t('Select tenant');
}
$tenantAvatarUrl = (string) ($tenantCandidate['avatar_url'] ?? '');
$tenantInitial = strtoupper(substr($tenantLabel, 0, 1));
if ($tenantInitial === '') {
$tenantInitial = '?';
}
?>
<label class="app-field login-tenant-choice" data-tooltip="<?php e($tenantLabel); ?>" data-tooltip-pos="top">
<input type="radio" name="tenant_id" value="<?php e((string) $tenantId); ?>" <?php e($tenantId === $selectedTenantId ? 'checked autofocus' : ''); ?>>
<span class="login-tenant-choice-body">
<?php if ($tenantAvatarUrl !== ''): ?>
<img class="login-tenant-choice-avatar" src="<?php e($tenantAvatarUrl); ?>" alt="" loading="lazy">
<?php else: ?>
<span class="login-tenant-choice-avatar-placeholder"><?php e($tenantInitial); ?></span>
<?php endif; ?>
<span class="login-tenant-choice-text"><?php e($tenantLabel); ?></span>
</span>
</label>
<?php endforeach; ?>
</div>
</fieldset>
<p>
<button type="submit"><?php e(t('Continue')); ?></button>
</p>
@@ -122,7 +129,7 @@ if ($stage === 'resolve_email') {
<input type="hidden" name="tenant_id" value="<?php e((string) $selectedTenantId); ?>">
<label for="password">
<span><?php e(t('Password')); ?></span>
<input required type="password" name="password" id="password" autocomplete="current-password" />
<input required type="password" name="password" id="password" autocomplete="current-password" <?php if ($errors): ?>aria-invalid="true" aria-describedby="<?php e($errorNoticeId); ?>"<?php endif; ?> />
</label>
<p>
<label class="app-field inline">
@@ -147,14 +154,14 @@ if ($stage === 'resolve_email') {
<?php endif; ?>
<?php if (!empty($selectedTenantMethods['microsoft']) && $selectedTenantSlug !== ''): ?>
<a role="button" style="width:100%;" class="primary"
<a class="primary login-microsoft-link"
href="<?php e(lurl('auth/microsoft/start?tenant=' . rawurlencode($selectedTenantSlug))); ?>">
<i class="bi bi-microsoft"></i> <?php e(t('Sign in with Microsoft')); ?>
<i class="bi bi-microsoft" aria-hidden="true"></i> <?php e(t('Sign in with Microsoft')); ?>
</a>
<?php endif; ?>
<?php if (empty($selectedTenantMethods['local']) && empty($selectedTenantMethods['microsoft'])): ?>
<div class="notice" data-variant="warning">
<div class="notice" data-variant="warning" role="status" aria-live="polite">
<?php e(t('No login method is available for this tenant')); ?>
</div>
<?php endif; ?>

View File

@@ -15,9 +15,11 @@ use MintyPHP\Session;
Buffer::set('title', t('Register'));
$passwordMinLength = (int) ($passwordMinLength ?? 0);
$passwordHints = is_array($passwordHints ?? null) ? $passwordHints : [];
$errorNoticeId = !empty($error) ? 'auth-register-error-notice' : '';
$passwordHintId = 'auth-register-password-hints';
?>
<a role="button" class="back_to_login small secondary outline" href="/login"><i
class="bi bi-arrow-left"></i> <?php e(t('Back to Login')); ?></a>
<a class="back_to_login small secondary outline" href="<?php e(lurl('login')); ?>"><i
class="bi bi-arrow-left" aria-hidden="true"></i> <?php e(t('Back to Login')); ?></a>
<article>
<header>
<hgroup>
@@ -28,15 +30,15 @@ $passwordHints = is_array($passwordHints ?? null) ? $passwordHints : [];
<form method="post">
<label for="first_name">
<span><?php e(t('First name')); ?></span>
<input required type="text" name="first_name" id="first_name" />
<input required type="text" name="first_name" id="first_name" <?php if ($errorNoticeId !== ''): ?>aria-invalid="true" aria-describedby="<?php e($errorNoticeId); ?>"<?php endif; ?> />
</label>
<label for="last_name">
<span><?php e(t('Last name')); ?></span>
<input required type="text" name="last_name" id="last_name" />
<input required type="text" name="last_name" id="last_name" <?php if ($errorNoticeId !== ''): ?>aria-invalid="true" aria-describedby="<?php e($errorNoticeId); ?>"<?php endif; ?> />
</label>
<label for="email">
<span><?php e(t('Email')); ?></span>
<input required type="email" name="email" id="email" />
<input required type="email" name="email" id="email" autocomplete="email" <?php if ($errorNoticeId !== ''): ?>aria-invalid="true" aria-describedby="<?php e($errorNoticeId); ?>"<?php endif; ?> />
</label>
<fieldset>
<legend>
@@ -47,14 +49,16 @@ $passwordHints = is_array($passwordHints ?? null) ? $passwordHints : [];
<label for="password">
<span><?php e(t('Password')); ?></span>
<input required type="password" name="password" id="password"
minlength="<?php e($passwordMinLength); ?>" autocomplete="new-password" />
minlength="<?php e($passwordMinLength); ?>" autocomplete="new-password"
aria-describedby="<?php e(trim($passwordHintId . ' ' . $errorNoticeId)); ?>" <?php if ($errorNoticeId !== ''): ?>aria-invalid="true"<?php endif; ?> />
</label>
<label for="password2">
<span><?php e(t('Password (again)')); ?></span>
<input required type="password" name="password2" id="password2"
minlength="<?php e($passwordMinLength); ?>" autocomplete="new-password" />
minlength="<?php e($passwordMinLength); ?>" autocomplete="new-password"
aria-describedby="<?php e(trim($passwordHintId . ' ' . $errorNoticeId)); ?>" <?php if ($errorNoticeId !== ''): ?>aria-invalid="true"<?php endif; ?> />
</label>
<div class="form-hint" data-password-hints data-password-input="#password" data-confirm-input="#password2"
<div id="<?php e($passwordHintId); ?>" class="form-hint" data-password-hints data-password-input="#password" data-confirm-input="#password2"
data-email-input="#email" data-min-length="<?php e($passwordMinLength); ?>">
<strong><?php e(t('Password requirements')); ?></strong>
<ul class="form-hint-list">
@@ -67,7 +71,7 @@ $passwordHints = is_array($passwordHints ?? null) ? $passwordHints : [];
</fieldset>
<button type="submit"><?php e(t('Register')); ?></button>
<?php if (!empty($error)): ?>
<div class="notice" data-variant="error"><?php e($error); ?></div>
<div id="<?php e($errorNoticeId); ?>" class="notice" data-variant="error" role="alert" aria-live="assertive" tabindex="-1"><?php e($error); ?></div>
<?php endif; ?>
<?php Session::getCsrfInput(); ?>
</form>

View File

@@ -10,6 +10,8 @@ use MintyPHP\Buffer;
use MintyPHP\Session;
Buffer::set('title', t('Reset password'));
$errorNoticeId = !empty($errors) ? 'auth-reset-error-notice' : '';
$passwordHintId = 'auth-reset-password-hints';
?>
<article>
@@ -29,15 +31,17 @@ Buffer::set('title', t('Reset password'));
<label for="password">
<span><?php e(t('Password')); ?></span>
<input required type="password" name="password" id="password"
minlength="<?php e($passwordMinLength); ?>" />
minlength="<?php e($passwordMinLength); ?>" autocomplete="new-password"
aria-describedby="<?php e(trim($passwordHintId . ' ' . $errorNoticeId)); ?>" <?php if ($errorNoticeId !== ''): ?>aria-invalid="true"<?php endif; ?> />
</label>
<label for="password2">
<span><?php e(t('Password (again)')); ?></span>
<input required type="password" name="password2" id="password2"
minlength="<?php e($passwordMinLength); ?>" />
minlength="<?php e($passwordMinLength); ?>" autocomplete="new-password"
aria-describedby="<?php e(trim($passwordHintId . ' ' . $errorNoticeId)); ?>" <?php if ($errorNoticeId !== ''): ?>aria-invalid="true"<?php endif; ?> />
</label>
<?php if (!empty($passwordHints)): ?>
<div class="form-hint" data-password-hints data-password-input="#password" data-confirm-input="#password2"
<div id="<?php e($passwordHintId); ?>" class="form-hint" data-password-hints data-password-input="#password" data-confirm-input="#password2"
data-min-length="<?php e($passwordMinLength); ?>">
<strong><?php e(t('Password requirements')); ?></strong>
<ul class="form-hint-list">
@@ -50,7 +54,7 @@ Buffer::set('title', t('Reset password'));
<?php endif; ?>
</fieldset>
<?php if (!empty($errors)): ?>
<div class="notice" data-variant="error">
<div id="<?php e($errorNoticeId); ?>" class="notice" data-variant="error" role="alert" aria-live="assertive" tabindex="-1">
<ul>
<?php foreach ($errors as $error): ?>
<li><?php e($error); ?></li>

View File

@@ -10,6 +10,7 @@ use MintyPHP\Buffer;
use MintyPHP\Session;
Buffer::set('title', t('Verify code'));
$errorNoticeId = !empty($errors) ? 'auth-verify-error-notice' : '';
?>
<article>
@@ -22,15 +23,15 @@ Buffer::set('title', t('Verify code'));
<form method="post">
<label for="email">
<span><?php e(t('Email')); ?></span>
<input required type="email" name="email" id="email" value="<?php e($email ?? ''); ?>" />
<input required type="email" name="email" id="email" value="<?php e($email ?? ''); ?>" autocomplete="email" <?php if ($errorNoticeId !== ''): ?>aria-invalid="true" aria-describedby="<?php e($errorNoticeId); ?>"<?php endif; ?> />
</label>
<label for="code">
<span><?php e(t('Verification code')); ?></span>
<input required type="text" name="code" id="code" inputmode="numeric" pattern="\d{6}" maxlength="6"
value="<?php e($code ?? ''); ?>" />
value="<?php e($code ?? ''); ?>" autocomplete="one-time-code" <?php if ($errorNoticeId !== ''): ?>aria-invalid="true" aria-describedby="<?php e($errorNoticeId); ?>"<?php endif; ?> />
</label>
<?php if (!empty($errors)): ?>
<div class="notice" data-variant="error">
<div id="<?php e($errorNoticeId); ?>" class="notice" data-variant="error" role="alert" aria-live="assertive" tabindex="-1">
<ul>
<?php foreach ($errors as $error): ?>
<li><?php e($error); ?></li>

View File

@@ -10,6 +10,7 @@ use MintyPHP\Buffer;
use MintyPHP\Session;
Buffer::set('title', t('Verify email'));
$errorNoticeId = !empty($errors) ? 'auth-verify-email-error-notice' : '';
?>
<article>
@@ -22,15 +23,15 @@ Buffer::set('title', t('Verify email'));
<form method="post">
<label for="email">
<span><?php e(t('Email')); ?></span>
<input required type="email" name="email" id="email" value="<?php e($email ?? ''); ?>" />
<input required type="email" name="email" id="email" value="<?php e($email ?? ''); ?>" autocomplete="email" <?php if ($errorNoticeId !== ''): ?>aria-invalid="true" aria-describedby="<?php e($errorNoticeId); ?>"<?php endif; ?> />
</label>
<label for="code">
<span><?php e(t('Verification code')); ?></span>
<input required type="text" name="code" id="code" inputmode="numeric" pattern="\d{6}" maxlength="6"
value="<?php e($code ?? ''); ?>" />
value="<?php e($code ?? ''); ?>" autocomplete="one-time-code" <?php if ($errorNoticeId !== ''): ?>aria-invalid="true" aria-describedby="<?php e($errorNoticeId); ?>"<?php endif; ?> />
</label>
<?php if (!empty($errors)): ?>
<div class="notice" data-variant="error">
<div id="<?php e($errorNoticeId); ?>" class="notice" data-variant="error" role="alert" aria-live="assertive" tabindex="-1">
<ul>
<?php foreach ($errors as $error): ?>
<li><?php e($error); ?></li>
@@ -42,13 +43,13 @@ Buffer::set('title', t('Verify email'));
<?php Session::getCsrfInput(); ?>
</form>
<hr>
<form method="post" style="text-align: center;">
<form method="post" class="text-align-center">
<input type="hidden" name="email" value="<?php e($email ?? ''); ?>">
<input type="hidden" name="action" value="resend">
<?php Session::getCsrfInput(); ?>
<button type="submit" class="secondary outline"><?php e(t('Send code again')); ?></button>
</form>
<p class="text-align-center" style="margin-top: 1rem;">
<p class="text-align-center login-secondary-action">
<a href="<?php e(lurl('login')); ?>"><?php e(t('Back to Login')); ?></a>
</p>
</article>

View File

@@ -0,0 +1,62 @@
<?php
namespace MintyPHP\Tests\Architecture;
use PHPUnit\Framework\TestCase;
class AuthLoginAccessibilityContractTest extends TestCase
{
use ProjectFileAssertionSupport;
/**
* @return list<string>
*/
private function authLoginTemplates(): array
{
return [
'pages/auth/login(login).phtml',
'pages/auth/register(login).phtml',
'pages/auth/forgot(login).phtml',
'pages/auth/reset(login).phtml',
'pages/auth/verify(login).phtml',
'pages/auth/verify-email(login).phtml',
];
}
public function testAuthLoginTemplatesDoNotUseRoleButtonLinks(): void
{
foreach ($this->authLoginTemplates() as $file) {
$content = $this->readProjectFile($file);
$this->assertStringNotContainsString('role="button"', $content, $file);
}
}
public function testAuthLoginErrorNoticesUseAssertiveLiveRegions(): void
{
foreach ($this->authLoginTemplates() as $file) {
$content = $this->readProjectFile($file);
$this->assertStringContainsString('class="notice" data-variant="error" role="alert" aria-live="assertive"', $content, $file);
}
}
public function testTenantSelectionUsesSemanticFieldsetAndTextLabel(): void
{
$content = $this->readProjectFile('pages/auth/login(login).phtml');
$this->assertStringContainsString('login-tenant-fieldset', $content);
$this->assertStringContainsString('login-tenant-select-label', $content);
$this->assertStringContainsString('login-tenant-choice-text', $content);
$this->assertStringContainsString('login-microsoft-link', $content);
}
public function testAuthLoginFormsExposeErrorDescriptionForInputs(): void
{
$describedByPattern = '/aria-describedby="[^"]*\\$errorNoticeId[^"]*"/';
foreach ($this->authLoginTemplates() as $file) {
$content = $this->readProjectFile($file);
$this->assertMatchesRegularExpression($describedByPattern, $content, $file);
$this->assertStringContainsString('aria-invalid="true"', $content, $file);
}
}
}

View File

@@ -552,9 +552,16 @@
margin-bottom: 1rem;
}
.login-tenant-fieldset {
border: 0;
margin: 0 0 1rem;
padding: 0;
}
.login-tenant-select-label {
display: block;
margin-bottom: 0.5rem;
font-weight: 600;
}
.login-tenant-choice-grid {
@@ -601,6 +608,13 @@
align-self: center;
}
.login-tenant-choice-body {
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
}
.login-tenant-choice-avatar {
width: 8rem;
height: 3rem;
@@ -623,6 +637,13 @@
color: var(--app-contrast);
}
.login-tenant-choice-text {
color: var(--app-color);
font-weight: 600;
overflow-wrap: anywhere;
text-align: right;
}
.login-alt-separator {
position: relative;
margin: 1rem 0;
@@ -649,8 +670,14 @@
background: var(--app-surface);
}
.login-alt-separator + [role="button"] {
.login-microsoft-link {
display: block;
width: 100%;
text-align: center;
}
.login-secondary-action {
margin-top: 1rem;
}
@media (min-width: 600px) {