diff --git a/app/saas-auth.php b/app/saas-auth.php index 1644f76..aeb6c40 100644 --- a/app/saas-auth.php +++ b/app/saas-auth.php @@ -34,6 +34,70 @@ function saas_token_hash(string $token): string return hash('sha256', $token); } +function saas_normalize_host(?string $host): string +{ + $host = strtolower(trim((string)$host)); + if ($host === '') { + return ''; + } + + if (str_contains($host, '://')) { + $parsedHost = parse_url($host, PHP_URL_HOST); + $host = is_string($parsedHost) ? $parsedHost : $host; + } + + $host = preg_replace('/:\d+$/', '', $host) ?? $host; + + return trim($host, " \t\n\r\0\x0B."); +} + +function saas_primary_app_host(): string +{ + return saas_normalize_host(app_env('APP_PRIMARY_HOST', '')); +} + +function saas_is_primary_app_host(?string $host = null): bool +{ + $primaryHost = saas_primary_app_host(); + if ($primaryHost === '') { + return false; + } + + $host = $host ?? ($_SERVER['HTTP_HOST'] ?? ''); + + return saas_normalize_host($host) === $primaryHost; +} + +function saas_resolve_tenant_domain(PDO $pdo, ?string $host = null): ?array +{ + $host = saas_normalize_host($host ?? ($_SERVER['HTTP_HOST'] ?? '')); + if ($host === '' || saas_is_primary_app_host($host)) { + return null; + } + + $stmt = $pdo->prepare( + 'SELECT + td.id AS tenant_domain_id, + td.domain, + td.domain_norm, + td.domain_type, + t.id AS tenant_id, + t.slug AS tenant_slug, + t.name AS tenant_name, + t.status AS tenant_status + FROM tenant_domains td + JOIN tenants t ON t.id = td.tenant_id + WHERE td.domain_norm = ? + AND td.status = ? + AND t.status = ? + LIMIT 1' + ); + $stmt->execute([$host, 'active', 'active']); + $row = $stmt->fetch(); + + return $row !== false ? $row : null; +} + function saas_parse_money_cents(mixed $value): ?int { $normalized = trim((string)$value); @@ -137,12 +201,19 @@ function saas_session_login(array $identity): void $_SESSION['saas_user_id'] = (int)$identity['user_id']; $_SESSION['saas_tenant_id'] = (int)$identity['tenant_id']; $_SESSION['saas_role'] = (string)$identity['role']; + saas_clear_pending_tenant_selection(); } function saas_logout(): void { app_start_session(); - unset($_SESSION['saas_user_id'], $_SESSION['saas_tenant_id'], $_SESSION['saas_role']); + unset( + $_SESSION['saas_user_id'], + $_SESSION['saas_tenant_id'], + $_SESSION['saas_role'], + $_SESSION['saas_pending_user_id'], + $_SESSION['saas_pending_started_at'] + ); } function saas_require_login(): array @@ -380,6 +451,107 @@ function saas_find_auth_identity(PDO $pdo, string $email, string $tenantSlug = ' return $identity !== false ? $identity : null; } +function saas_list_user_memberships(PDO $pdo, int $userId): array +{ + $stmt = $pdo->prepare( + 'SELECT + u.id AS user_id, + u.email, + u.email_norm, + u.display_name, + u.status AS user_status, + u.email_verified_at, + t.id AS tenant_id, + t.slug AS tenant_slug, + t.name AS tenant_name, + t.status AS tenant_status, + tm.role, + tm.status AS membership_status + FROM users u + JOIN tenant_memberships tm ON tm.user_id = u.id + JOIN tenants t ON t.id = tm.tenant_id + WHERE u.id = ? + AND u.status = ? + AND tm.status = ? + AND t.status = ? + ORDER BY + CASE tm.role + WHEN \'owner\' THEN 1 + WHEN \'admin\' THEN 2 + ELSE 3 + END, + t.name' + ); + $stmt->execute([$userId, 'active', 'active', 'active']); + + return $stmt->fetchAll(); +} + +function saas_identity_for_user_tenant(PDO $pdo, int $userId, int $tenantId): ?array +{ + $stmt = $pdo->prepare( + 'SELECT + u.id AS user_id, + u.email, + u.email_norm, + u.display_name, + u.status AS user_status, + u.email_verified_at, + t.id AS tenant_id, + t.slug AS tenant_slug, + t.name AS tenant_name, + t.status AS tenant_status, + tm.role, + tm.status AS membership_status + FROM users u + JOIN tenant_memberships tm ON tm.user_id = u.id + JOIN tenants t ON t.id = tm.tenant_id + WHERE u.id = ? + AND t.id = ? + AND u.status = ? + AND tm.status = ? + AND t.status = ? + LIMIT 1' + ); + $stmt->execute([$userId, $tenantId, 'active', 'active', 'active']); + $identity = $stmt->fetch(); + + return $identity !== false ? $identity : null; +} + +function saas_start_pending_tenant_selection(int $userId): void +{ + app_start_session(); + session_regenerate_id(true); + + $_SESSION['saas_pending_user_id'] = $userId; + $_SESSION['saas_pending_started_at'] = time(); + unset($_SESSION['saas_user_id'], $_SESSION['saas_tenant_id'], $_SESSION['saas_role']); +} + +function saas_pending_tenant_user_id(): ?int +{ + app_start_session(); + + $userId = isset($_SESSION['saas_pending_user_id']) ? (int)$_SESSION['saas_pending_user_id'] : 0; + $startedAt = isset($_SESSION['saas_pending_started_at']) ? (int)$_SESSION['saas_pending_started_at'] : 0; + if ($userId <= 0 || $startedAt <= 0 || $startedAt < time() - 900) { + saas_clear_pending_tenant_selection(); + return null; + } + + return $userId; +} + +function saas_clear_pending_tenant_selection(): void +{ + if (PHP_SAPI !== 'cli') { + app_start_session(); + } + + unset($_SESSION['saas_pending_user_id'], $_SESSION['saas_pending_started_at']); +} + function saas_create_auth_token(PDO $pdo, int $userId, string $tokenType, ?int $tenantId, int $ttlMinutes): array { $token = bin2hex(random_bytes(32)); @@ -625,11 +797,7 @@ function saas_authenticate(PDO $pdo, string $email, string $password, string $te } $stmt = $pdo->prepare( - 'SELECT - t.id AS tenant_id, - t.slug AS tenant_slug, - t.name AS tenant_name, - tm.role + 'SELECT t.id AS tenant_id FROM tenant_memberships tm JOIN tenants t ON t.id = tm.tenant_id WHERE tm.user_id = ? @@ -641,13 +809,30 @@ function saas_authenticate(PDO $pdo, string $email, string $password, string $te WHEN \'admin\' THEN 2 ELSE 3 END, - t.name - LIMIT 1' + t.name' ); $stmt->execute($params); - $membership = $stmt->fetch(); + $tenantIds = array_map('intval', $stmt->fetchAll(PDO::FETCH_COLUMN)); - if ($membership === false) { + if ($tenantIds === []) { + return [ + 'ok' => false, + 'errors' => ['Fuer dieses Konto ist kein aktiver Mandant verfuegbar.'], + ]; + } + + if (count($tenantIds) > 1 && trim($tenantSlug) === '') { + return [ + 'ok' => true, + 'needs_tenant_selection' => true, + 'user_id' => (int)$user['id'], + 'memberships' => saas_list_user_memberships($pdo, (int)$user['id']), + 'errors' => [], + ]; + } + + $identity = saas_identity_for_user_tenant($pdo, (int)$user['id'], $tenantIds[0]); + if ($identity === null) { return [ 'ok' => false, 'errors' => ['Fuer dieses Konto ist kein aktiver Mandant verfuegbar.'], @@ -658,16 +843,7 @@ function saas_authenticate(PDO $pdo, string $email, string $password, string $te return [ 'ok' => true, - 'identity' => [ - 'user_id' => (int)$user['id'], - 'email' => (string)$user['email'], - 'email_norm' => (string)$user['email_norm'], - 'display_name' => (string)$user['display_name'], - 'tenant_id' => (int)$membership['tenant_id'], - 'tenant_slug' => (string)$membership['tenant_slug'], - 'tenant_name' => (string)$membership['tenant_name'], - 'role' => (string)$membership['role'], - ], + 'identity' => $identity, 'errors' => [], ]; } diff --git a/database/migrations/0005_saas_tenant_resolution.sql b/database/migrations/0005_saas_tenant_resolution.sql new file mode 100644 index 0000000..0193f44 --- /dev/null +++ b/database/migrations/0005_saas_tenant_resolution.sql @@ -0,0 +1,16 @@ +CREATE TABLE IF NOT EXISTS tenant_domains ( + id INT AUTO_INCREMENT PRIMARY KEY, + tenant_id INT NOT NULL, + domain VARCHAR(255) NOT NULL, + domain_norm VARCHAR(255) NOT NULL, + domain_type VARCHAR(30) NOT NULL DEFAULT 'custom', + status VARCHAR(30) NOT NULL DEFAULT 'active', + verified_at DATETIME NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uq_tenant_domains_domain_norm (domain_norm), + KEY idx_tenant_domains_tenant_status (tenant_id, status), + CONSTRAINT fk_tenant_domains_tenant + FOREIGN KEY (tenant_id) REFERENCES tenants(id) + ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/docs/m2-technical-foundation.md b/docs/m2-technical-foundation.md index b696e79..4aeacd2 100644 --- a/docs/m2-technical-foundation.md +++ b/docs/m2-technical-foundation.md @@ -140,9 +140,9 @@ Die Skripte erwarten die bekannten Dev-Umgebungsvariablen `DB_HOST`, `DB_NAME`, eine PayPal-Alias-Dublette und hinterlaesst keine Datei in `var/uploads`. - CSV-Upload negative Tests: Nicht-CSV-Dateien werden abgewiesen. - Golden Master weiterhin gruen mit 104 Assertions. -- HTTP-Smoke weiterhin gruen mit 20 sicheren Seiten inklusive Login, +- HTTP-Smoke weiterhin gruen mit 21 sicheren Seiten inklusive Login, Registrierung, Passwort-Reset, E-Mail-Verifikation und geschuetzter - Mandant-Einstellungen. + Mandant-Einstellungen sowie geschuetzter Mandantenauswahl. ## Bewusste Grenzen diff --git a/docs/m3-saas-basis-vorbereitung.md b/docs/m3-saas-basis-vorbereitung.md index d7b485c..835472a 100644 --- a/docs/m3-saas-basis-vorbereitung.md +++ b/docs/m3-saas-basis-vorbereitung.md @@ -32,6 +32,7 @@ Umgesetzte Migrationen: database/migrations/0002_saas_identity_tenants.sql database/migrations/0003_saas_auth_account_fields.sql database/migrations/0004_saas_auth_tokens.sql +database/migrations/0005_saas_tenant_resolution.sql ``` Tabellen: @@ -41,6 +42,7 @@ Tabellen: - `users` - `tenant_memberships` - `participants` +- `tenant_domains` Ergaenzende Skripte: @@ -50,6 +52,7 @@ scripts/check-m3-saas-basis.php scripts/check-m3-auth-flow.php scripts/check-m3-settings-flow.php scripts/check-m3-password-email-flow.php +scripts/check-m3-tenant-resolution-flow.php ``` Wichtige Regeln: @@ -97,9 +100,12 @@ Vorgang: erzeugen. 5. `admin = 1` als Rolle `admin` abbilden; ein konfigurierter erster Benutzer kann Rolle `owner` erhalten. +6. Verwaiste `participants` mit nicht mehr vorhandener `legacy_mitarbeiter_id` + aus dem Default-Tenant entfernen. Der Backfill muss idempotent sein und darf keine bestehenden Legacy-Daten -loeschen. +loeschen. Geloescht werden nur SaaS-Spiegelungen, deren Legacy-Mitarbeiter in +`kl_Mitarbeiter` nicht mehr existiert. Ausgefuehrter Dev-Stand: @@ -120,6 +126,7 @@ login.php register.php konto.php logout.php +mandant-auswahl.php passwort-vergessen.php passwort-zuruecksetzen.php email-verifikation-senden.php @@ -131,6 +138,10 @@ Umgesetzter Umfang: - Registrierung legt Tenant, Owner-User, Default-Settings, Membership und Owner-Participant in einer Transaktion an. - Login prueft `users.password_hash`, aktive Membership und aktiven Tenant. +- Hat ein User genau einen aktiven Mandanten, wird dieser direkt in die Session + gelegt. +- Hat ein User mehrere aktive Mandanten, fuehrt der Login zur + Mandantenauswahl. - Logout beendet die neue PHP-Login-Session. - `konto.php` zeigt den aktuellen SaaS-Kontext fuer den angemeldeten User. - `functions.php` akzeptiert eine SaaS-Login-Session als erste Identitaetsquelle, @@ -145,10 +156,28 @@ Umgesetzter Umfang: Noch offen im M3-Auth-Scope: -- Tenant-Aufloesung ueber Subdomain oder Custom Domain. - Weitergehende Rollenmatrix fuer spaetere SaaS-Seiten. - Produktiver Mailversand fuer Reset- und Verifikationslinks. +## Tenant-Aufloesung + +Entscheidung fuer den Webspace-Betrieb: + +- Keine Wildcard-Subdomains im ersten Schritt. +- Primaere App-Adresse ist eine zentrale App-Domain wie `app.kaffeeliste.de`. +- Der aktive Mandant wird nach Login ueber `tenant_memberships` und die PHP- + Session gesetzt. +- Bei genau einem Mandanten wird automatisch weitergeleitet. +- Bei mehreren Mandanten nutzt der User `mandant-auswahl.php`. +- `tenant_domains` ist vorbereitet fuer spaeter gezielt eingerichtete feste + Domains oder Subdomains, aber nicht Voraussetzung fuer den Start. + +Noch offen: + +- `APP_PRIMARY_HOST` in der Zielumgebung setzen. +- Produktive Domain-/Zertifikatspruefung fuer einzelne feste Domains + definieren. + ## Rollen und Grundeinstellungen Umgesetzte Dateien: @@ -183,6 +212,7 @@ Umgesetzter Umfang: - Owner-/Admin-Grundeinstellungen koennen aktualisiert werden: erfuellt. - Passwort-Reset und E-Mail-Verifikation funktionieren mit Single-Use-Tokens: erfuellt. +- Mandantenauswahl funktioniert fuer User mit mehreren Mandanten: erfuellt. - Golden-Master und HTTP-Smoke bleiben gruen: erfuellt. ## Risiken @@ -209,6 +239,9 @@ Umgesetzter Umfang: 10. Migration `0004_saas_auth_tokens.sql` erstellen: erledigt. 11. Passwort-Reset und E-Mail-Verifikation bauen: erledigt. 12. Password-/E-Mail-Flow-Kontrollskript schreiben: erledigt. -13. Golden-Master und HTTP-Smoke ausfuehren: erledigt. -14. Tenant-Aufloesung planen: +13. Migration `0005_saas_tenant_resolution.sql` erstellen: erledigt. +14. Mandantenauswahl und zentrale Session-Aufloesung bauen: erledigt. +15. Tenant-Resolution-Kontrollskript schreiben: erledigt. +16. Golden-Master und HTTP-Smoke ausfuehren: erledigt. +17. Produktiven Mailversand oder Landingpage vorbereiten: naechster Schritt. diff --git a/docs/saas-umstrukturierungsplan.md b/docs/saas-umstrukturierungsplan.md index 6f054e6..b84dfca 100644 --- a/docs/saas-umstrukturierungsplan.md +++ b/docs/saas-umstrukturierungsplan.md @@ -422,7 +422,8 @@ Schritte: - Login und Logout bauen: erster Flow erledigt. - Passwort-Reset und E-Mail-Verifikation bauen: Dev-Flow mit Single-Use-Tokens erledigt. -- Tenant-Aufloesung definieren. +- Tenant-Aufloesung definieren: zentrale App-Domain mit Session-Kontext und + Mandantenauswahl erledigt; feste Domains vorbereitet, keine Wildcards. - Rollenpruefung zentralisieren: erster Owner/Admin-Check erledigt. - Erste Admin-/Owner-Seite fuer Grundeinstellungen: erledigt. diff --git a/login.php b/login.php index dd2cd94..b7a48a5 100644 --- a/login.php +++ b/login.php @@ -17,6 +17,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $tenantSlug ); + if ($result['ok'] && !empty($result['needs_tenant_selection'])) { + saas_start_pending_tenant_selection((int)$result['user_id']); + header('Location: mandant-auswahl.php'); + exit; + } + if ($result['ok']) { saas_session_login($result['identity']); header('Location: konto.php'); diff --git a/mandant-auswahl.php b/mandant-auswahl.php new file mode 100644 index 0000000..181b41a --- /dev/null +++ b/mandant-auswahl.php @@ -0,0 +1,74 @@ + 0 ? saas_identity_for_user_tenant($pdo, $pendingUserId, $tenantId) : null; + + if ($identity === null) { + $errors[] = 'Dieser Mandant ist fuer dein Konto nicht verfuegbar.'; + } else { + saas_session_login($identity); + header('Location: konto.php'); + exit; + } +} + +include 'header.php'; +include 'headerline.php'; +include 'nav.php'; +?> + + + + diff --git a/scripts/backfill-default-tenant.php b/scripts/backfill-default-tenant.php index 150bd41..6626711 100644 --- a/scripts/backfill-default-tenant.php +++ b/scripts/backfill-default-tenant.php @@ -89,6 +89,7 @@ $ownerEmailNorm = m3_email_norm(getenv('M3_DEFAULT_TENANT_OWNER_EMAIL') ?: null) $stats = [ 'applied_migrations' => count($applied), 'participants' => 0, + 'stale_participants_removed' => 0, 'users' => 0, 'memberships' => 0, ]; @@ -154,7 +155,8 @@ try { email = VALUES(email), email_norm = VALUES(email_norm), paypal_name = VALUES(paypal_name), - active = VALUES(active)' + active = VALUES(active), + legacy_mitarbeiter_id = VALUES(legacy_mitarbeiter_id)' ); $upsertMembership = $pdo->prepare( 'INSERT INTO tenant_memberships (tenant_id, user_id, role, status, joined_at) @@ -206,6 +208,18 @@ try { $stats['memberships']++; } + $removeStaleParticipants = $pdo->prepare( + 'DELETE p + FROM participants p + LEFT JOIN kl_Mitarbeiter m + ON m.MitarbeiterID = p.legacy_mitarbeiter_id + WHERE p.tenant_id = ? + AND p.legacy_mitarbeiter_id IS NOT NULL + AND m.MitarbeiterID IS NULL' + ); + $removeStaleParticipants->execute([$tenantId]); + $stats['stale_participants_removed'] = $removeStaleParticipants->rowCount(); + $pdo->commit(); } catch (Throwable $e) { $pdo->rollBack(); @@ -217,5 +231,6 @@ echo "M3 default tenant backfill complete.\n"; echo "Tenant: {$tenantSlug}\n"; echo "Applied migrations: {$stats['applied_migrations']}\n"; echo "Participants mirrored: {$stats['participants']}\n"; +echo "Stale participants removed: {$stats['stale_participants_removed']}\n"; echo "Users upserted: {$stats['users']}\n"; echo "Memberships upserted: {$stats['memberships']}\n"; diff --git a/scripts/check-m3-tenant-resolution-flow.php b/scripts/check-m3-tenant-resolution-flow.php new file mode 100644 index 0000000..0bd4f6e --- /dev/null +++ b/scripts/check-m3-tenant-resolution-flow.php @@ -0,0 +1,121 @@ +prepare('SELECT id FROM tenants WHERE slug = ?'); + $stmt->execute([$slug]); + $tenantId = $stmt->fetchColumn(); + + if ($tenantId === false) { + continue; + } + + $tenantId = (int)$tenantId; + $pdo->prepare('DELETE FROM tenant_domains WHERE tenant_id = ?')->execute([$tenantId]); + $pdo->prepare('DELETE FROM participants WHERE tenant_id = ?')->execute([$tenantId]); + $pdo->prepare('DELETE FROM tenant_memberships WHERE tenant_id = ?')->execute([$tenantId]); + $pdo->prepare('DELETE FROM tenant_settings WHERE tenant_id = ?')->execute([$tenantId]); + $pdo->prepare('DELETE FROM tenants WHERE id = ?')->execute([$tenantId]); + } + + $pdo->prepare('DELETE FROM users WHERE email_norm = ?')->execute([$emailNorm]); +} + +$pdo = dev_pdo(); +dev_apply_schema($pdo); + +$slugs = ['m3-tenant-choice-a', 'm3-tenant-choice-b']; +$email = 'm3-tenant-choice@test.local'; +$emailNorm = saas_email_norm($email); +$password = 'M3-tenant-choice-123'; +$failures = []; +$passes = 0; + +tenant_resolution_cleanup($pdo, $slugs, $emailNorm); + +$first = saas_register_tenant_owner($pdo, [ + 'tenant_name' => 'M3 Tenant Choice A', + 'tenant_slug' => $slugs[0], + 'display_name' => 'M3 Tenant Choice', + 'email' => $email, + 'password' => $password, + 'password_confirm' => $password, +]); +$second = saas_register_tenant_owner($pdo, [ + 'tenant_name' => 'M3 Tenant Choice B', + 'tenant_slug' => $slugs[1], + 'display_name' => 'M3 Tenant Choice', + 'email' => $email, + 'password' => $password, + 'password_confirm' => $password, +]); + +tenant_resolution_assert('first registration succeeds', $first['ok'] === true, $failures, $passes); +tenant_resolution_assert('second registration succeeds', $second['ok'] === true, $failures, $passes); + +if ($first['ok'] && $second['ok']) { + $userId = (int)$first['identity']['user_id']; + $memberships = saas_list_user_memberships($pdo, $userId); + tenant_resolution_assert('user has two memberships', count($memberships) === 2, $failures, $passes); + + $genericLogin = saas_authenticate($pdo, $email, $password); + tenant_resolution_assert('generic login succeeds', $genericLogin['ok'] === true, $failures, $passes); + tenant_resolution_assert('generic login needs tenant selection', !empty($genericLogin['needs_tenant_selection']), $failures, $passes); + tenant_resolution_assert('generic login exposes memberships', count($genericLogin['memberships'] ?? []) === 2, $failures, $passes); + + $specificLogin = saas_authenticate($pdo, $email, $password, $slugs[1]); + tenant_resolution_assert('specific login succeeds', $specificLogin['ok'] === true, $failures, $passes); + tenant_resolution_assert( + 'specific login selects requested tenant', + ($specificLogin['identity']['tenant_slug'] ?? null) === $slugs[1], + $failures, + $passes + ); + + $identity = saas_identity_for_user_tenant($pdo, $userId, (int)$second['identity']['tenant_id']); + tenant_resolution_assert('tenant selection identity exists', $identity !== null, $failures, $passes); + if ($identity !== null) { + tenant_resolution_assert('tenant selection returns requested tenant', $identity['tenant_slug'] === $slugs[1], $failures, $passes); + } + + $pdo->prepare( + 'INSERT INTO tenant_domains (tenant_id, domain, domain_norm, domain_type, status, verified_at) + VALUES (?, ?, ?, ?, ?, NOW())' + )->execute([(int)$second['identity']['tenant_id'], 'kaffee.example.test', 'kaffee.example.test', 'custom', 'active']); + + $domainTenant = saas_resolve_tenant_domain($pdo, 'kaffee.example.test:443'); + tenant_resolution_assert('fixed domain resolves tenant', $domainTenant !== null, $failures, $passes); + if ($domainTenant !== null) { + tenant_resolution_assert('fixed domain returns requested tenant', $domainTenant['tenant_slug'] === $slugs[1], $failures, $passes); + } +} + +tenant_resolution_cleanup($pdo, $slugs, $emailNorm); + +if ($failures !== []) { + echo "\nM3 tenant resolution check failed with " . count($failures) . " failure(s):\n"; + foreach ($failures as $failure) { + echo "- {$failure}\n"; + } + exit(1); +} + +echo "\nM3 tenant resolution check passed with {$passes} assertions.\n"; diff --git a/scripts/http-smoke.php b/scripts/http-smoke.php index b56321a..876023d 100644 --- a/scripts/http-smoke.php +++ b/scripts/http-smoke.php @@ -66,6 +66,11 @@ $checks = [ 'path' => 'passwort-vergessen.php', 'contains' => ['Passwort vergessen', 'Link vorbereiten'], ], + [ + 'label' => 'Mandantenauswahl Login-Schutz', + 'path' => 'mandant-auswahl.php', + 'contains' => ['Login'], + ], [ 'label' => 'Passwort zuruecksetzen Invalid', 'path' => 'passwort-zuruecksetzen.php',