Kontofunktionen vor dem Go-Live und Landingpage-Kuerzung
Vier Luecken geschlossen, die im Alltag sofort aufgefallen waeren: - Passwort aendern war im eingeloggten Zustand gar nicht moeglich; es gab nur den Reset per Mail-Link. saas_change_password() prueft das aktuelle Passwort, verlangt ein tatsaechlich anderes und erneuert danach die Session-ID. Formular in konto.php. - mandant-auswahl.php war nur direkt nach dem Login erreichbar. Wer bei mehreren Mandanten Mitglied ist, musste sich zum Wechseln abmelden. Die Seite bedient jetzt beide Wege, die Mandantenpruefung bleibt unveraendert ueber saas_identity_for_user_tenant(). Menuepunkt ab zwei Mitgliedschaften. - email_verified_at wurde nirgends geprueft, nur angezeigt - bei offener Selbstregistrierung konnte sich jemand mit fremder Adresse anmelden und alles nutzen. Erzwungen wird jetzt gezielt dort, wo eine Aktion nach aussen wirkt: Einladung, Info-Mail, Jahresabschluss. Der Login selbst bleibt bewusst frei, sonst waeren alle migrierten Bestandsnutzer mit NULL-Verifikation ausgesperrt. Zusaetzlich setzt der Passwort-Reset die Verifikation mit, weil der Mail-Link den Postfachzugriff nachweist - sonst blieben eingeladene Mitglieder dauerhaft unbestaetigt. - Login landete auf konto.php statt auf dem Dashboard. Landingpage: die gruene Vertrauenszeile auf "DSGVO-konform" gekuerzt, die Eintraege zu Paragraf 19 UStG und "Bestehende Ablaeufe bleiben" entfernt. Geprueft: neues scripts/check-konto-und-mandantenwechsel.php mit 18 Assertions gruen, Passwortformular zusaetzlich manuell inkl. CSRF (419). Bestehende Suiten unveraendert gruen: HTTP-Smoke 34 Seiten, Rollenmatrix 55, Mandanten-Isolation 12, M3-Auth 9, M3-Settings 15. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+102
-2
@@ -760,8 +760,17 @@ function saas_reset_password_with_token(PDO $pdo, string $token, string $passwor
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
$pdo->beginTransaction();
|
$pdo->beginTransaction();
|
||||||
$pdo->prepare('UPDATE users SET password_hash = ? WHERE id = ?')
|
// Wer den per Mail zugestellten Link oeffnen konnte, hat den Zugriff
|
||||||
->execute([password_hash($password, PASSWORD_DEFAULT), (int)$tokenRow['user_id']]);
|
// auf das Postfach nachgewiesen - fachlich dasselbe wie eine
|
||||||
|
// Verifikation. Das betrifft vor allem eingeladene Mitglieder: die
|
||||||
|
// Zugangsvergabe laeuft ueber genau diesen Reset-Link, ohne dass je
|
||||||
|
// eine separate Verifikationsmail verschickt wird.
|
||||||
|
$pdo->prepare(
|
||||||
|
'UPDATE users
|
||||||
|
SET password_hash = ?,
|
||||||
|
email_verified_at = COALESCE(email_verified_at, NOW())
|
||||||
|
WHERE id = ?'
|
||||||
|
)->execute([password_hash($password, PASSWORD_DEFAULT), (int)$tokenRow['user_id']]);
|
||||||
$pdo->prepare('UPDATE user_auth_tokens SET consumed_at = NOW() WHERE id = ?')
|
$pdo->prepare('UPDATE user_auth_tokens SET consumed_at = NOW() WHERE id = ?')
|
||||||
->execute([(int)$tokenRow['id']]);
|
->execute([(int)$tokenRow['id']]);
|
||||||
$pdo->commit();
|
$pdo->commit();
|
||||||
@@ -779,6 +788,97 @@ function saas_reset_password_with_token(PDO $pdo, string $token, string $passwor
|
|||||||
return ['ok' => true, 'errors' => []];
|
return ['ok' => true, 'errors' => []];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Passwortwechsel fuer den bereits angemeldeten Nutzer. Bewusst getrennt vom
|
||||||
|
* Token-Reset: hier weist das aktuelle Passwort die Identitaet nach, nicht
|
||||||
|
* ein Mail-Link. Setzt anders als der Reset keine E-Mail-Verifikation, weil
|
||||||
|
* dabei nichts ueber das Postfach nachgewiesen wird.
|
||||||
|
*/
|
||||||
|
function saas_change_password(
|
||||||
|
PDO $pdo,
|
||||||
|
int $userId,
|
||||||
|
string $currentPassword,
|
||||||
|
string $password,
|
||||||
|
string $passwordConfirm
|
||||||
|
): array {
|
||||||
|
$errors = [];
|
||||||
|
if (strlen($password) < 8) {
|
||||||
|
$errors[] = 'Das neue Passwort muss mindestens 8 Zeichen lang sein.';
|
||||||
|
}
|
||||||
|
if ($password !== $passwordConfirm) {
|
||||||
|
$errors[] = 'Die Passwort-Wiederholung stimmt nicht.';
|
||||||
|
}
|
||||||
|
if ($currentPassword !== '' && $password === $currentPassword) {
|
||||||
|
$errors[] = 'Das neue Passwort muss sich vom bisherigen unterscheiden.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($errors !== []) {
|
||||||
|
return ['ok' => false, 'errors' => $errors];
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('SELECT password_hash, status FROM users WHERE id = ? LIMIT 1');
|
||||||
|
$stmt->execute([$userId]);
|
||||||
|
$user = $stmt->fetch();
|
||||||
|
|
||||||
|
if ($user === false || (string)$user['status'] !== 'active') {
|
||||||
|
return ['ok' => false, 'errors' => ['Dieses Benutzerkonto ist nicht aktiv.']];
|
||||||
|
}
|
||||||
|
|
||||||
|
$hash = (string)($user['password_hash'] ?? '');
|
||||||
|
if ($hash === '' || !password_verify($currentPassword, $hash)) {
|
||||||
|
return ['ok' => false, 'errors' => ['Das aktuelle Passwort ist nicht korrekt.']];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$pdo->prepare('UPDATE users SET password_hash = ? WHERE id = ?')
|
||||||
|
->execute([password_hash($password, PASSWORD_DEFAULT), $userId]);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
return ['ok' => false, 'errors' => ['Das Passwort konnte nicht gespeichert werden.']];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Die eigene Session bleibt bestehen, bekommt aber eine neue ID, damit ein
|
||||||
|
// eventuell mitgelesener Session-Bezeichner nach dem Wechsel wertlos ist.
|
||||||
|
if (PHP_SAPI !== 'cli' && session_status() === PHP_SESSION_ACTIVE) {
|
||||||
|
session_regenerate_id(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['ok' => true, 'errors' => []];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hat der Nutzer seine E-Mail-Adresse nachgewiesen? Wird fuer Aktionen
|
||||||
|
* geprueft, die Mails an Dritte ausloesen oder fremde Konten anlegen -
|
||||||
|
* siehe saas_require_verified_email().
|
||||||
|
*/
|
||||||
|
function saas_email_verified(?array $user = null): bool
|
||||||
|
{
|
||||||
|
$user = $user ?? saas_current_user();
|
||||||
|
|
||||||
|
return $user !== null && ($user['email_verified_at'] ?? null) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sperrt Aktionen, die aus dem Konto heraus nach aussen wirken (Mitglieder
|
||||||
|
* einladen, Massenmail, Jahresabschluss-Mails), solange die eigene Adresse
|
||||||
|
* nicht bestaetigt ist. Bewusst nicht auf den gesamten Login angewendet: die
|
||||||
|
* eigene Kaffeeliste darf man auch unbestaetigt fuehren, nur nicht im Namen
|
||||||
|
* einer moeglicherweise fremden Adresse Mails an Dritte ausloesen.
|
||||||
|
*/
|
||||||
|
function saas_require_verified_email(?array $user = null): array
|
||||||
|
{
|
||||||
|
$user = $user ?? saas_require_login();
|
||||||
|
|
||||||
|
if (saas_email_verified($user)) {
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
http_response_code(403);
|
||||||
|
die(
|
||||||
|
'Für diese Aktion muss die eigene E-Mail-Adresse bestätigt sein. '
|
||||||
|
. 'Den Bestätigungslink kannst du unter "Kundenkonto" erneut anfordern.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function saas_request_email_verification(PDO $pdo, int $userId, ?int $tenantId = null): array
|
function saas_request_email_verification(PDO $pdo, int $userId, ?int $tenantId = null): array
|
||||||
{
|
{
|
||||||
$stmt = $pdo->prepare(
|
$stmt = $pdo->prepare(
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ Vorschlag: Gruppierung in Abschnitte mit Zwischenüberschriften, z. B.:
|
|||||||
verwalten.
|
verwalten.
|
||||||
- **Konto** – Kundenkonto, Mandant-Einstellungen, FAQ, Logout.
|
- **Konto** – Kundenkonto, Mandant-Einstellungen, FAQ, Logout.
|
||||||
|
|
||||||
Noch nicht entschieden/umgesetzt.
|
**Erledigt.** `footer.php` gruppiert die Sidebar inzwischen in
|
||||||
|
„Kaffeeliste", „Verwaltung" (rollenabhängig eingeblendet) und „Konto";
|
||||||
|
dort ist ab zwei Mitgliedschaften auch „Mandant wechseln" ergänzt.
|
||||||
|
|
||||||
## Kaffeeliste-Ausdruck (PDF-Export) als zentrale Funktion
|
## Kaffeeliste-Ausdruck (PDF-Export) als zentrale Funktion
|
||||||
|
|
||||||
|
|||||||
+85
-1
@@ -207,4 +207,88 @@ Tabellen leer sind und die globale `users`-Zeile erhalten bleibt.
|
|||||||
## Noch offen in M8
|
## Noch offen in M8
|
||||||
|
|
||||||
- Content-Security-Policy (braucht Template-Bereinigung der Inline-Styles).
|
- Content-Security-Policy (braucht Template-Bereinigung der Inline-Styles).
|
||||||
- Backup-/Restore-Prozess und Monitoring/Fehlerlogging dokumentieren.
|
- ~~Backup-/Restore-Prozess und Monitoring/Fehlerlogging dokumentieren.~~
|
||||||
|
Erledigt in `docs/betrieb-backup-monitoring.md`.
|
||||||
|
|
||||||
|
## Nachtrag: Kontofunktionen vor dem Go-Live (2026-08-06)
|
||||||
|
|
||||||
|
Vier Lücken, die im Alltag sofort aufgefallen wären, geschlossen.
|
||||||
|
|
||||||
|
Umgesetzte Dateien:
|
||||||
|
|
||||||
|
```text
|
||||||
|
app/saas-auth.php (saas_change_password, saas_email_verified,
|
||||||
|
saas_require_verified_email)
|
||||||
|
konto.php (Formular "Passwort ändern")
|
||||||
|
mandant-auswahl.php(Wechsel aus bestehender Sitzung)
|
||||||
|
footer.php ("Mandant wechseln" ab zwei Mandanten)
|
||||||
|
header.php (Hinweisbanner bei unbestätigter Adresse)
|
||||||
|
login.php (Ziel nach Login)
|
||||||
|
mitarbeiterverwalten.php, mailversenden.php, jahresauswertung.php
|
||||||
|
(Verifikationspflicht)
|
||||||
|
scripts/check-konto-und-mandantenwechsel.php
|
||||||
|
```
|
||||||
|
|
||||||
|
### Passwortwechsel im eingeloggten Zustand
|
||||||
|
|
||||||
|
Bisher gab es ausschließlich den Reset über einen Mail-Link; wer sein
|
||||||
|
Passwort ändern wollte, musste sich abmelden und „Passwort vergessen"
|
||||||
|
benutzen. `saas_change_password()` verlangt das aktuelle Passwort als
|
||||||
|
Identitätsnachweis, erzwingt dieselbe Mindestlänge wie der Reset und
|
||||||
|
verlangt ein tatsächlich anderes Passwort. Nach dem Wechsel wird die
|
||||||
|
Session-ID erneuert.
|
||||||
|
|
||||||
|
**Bewusst nicht umgesetzt:** ein Abmelden aller anderen Sitzungen. Die
|
||||||
|
Sessions liegen als Dateien ohne Zuordnung zur User-ID; das bräuchte
|
||||||
|
einen eigenen Session-Store und lohnt beim aktuellen Umfang nicht.
|
||||||
|
|
||||||
|
### Mandantenwechsel ohne Logout
|
||||||
|
|
||||||
|
`mandant-auswahl.php` war nur direkt nach dem Login erreichbar (die Seite
|
||||||
|
stieg ohne `saas_pending_tenant_user_id()` sofort aus). Wer bei mehreren
|
||||||
|
Mandanten Mitglied ist, musste sich zum Wechseln abmelden. Die Seite
|
||||||
|
bedient jetzt beide Wege; die Mandantenprüfung läuft unverändert über
|
||||||
|
`saas_identity_for_user_tenant()`, ein fremder Mandant wird also auch aus
|
||||||
|
einer bestehenden Sitzung heraus abgewiesen. Der Menüpunkt erscheint nur
|
||||||
|
ab zwei Mitgliedschaften.
|
||||||
|
|
||||||
|
### E-Mail-Verifikation wird erzwungen
|
||||||
|
|
||||||
|
`email_verified_at` wurde bisher nur angezeigt, nie geprüft — bei
|
||||||
|
öffentlicher Selbstregistrierung konnte sich also jemand mit einer
|
||||||
|
fremden Adresse anmelden und das Produkt voll nutzen.
|
||||||
|
|
||||||
|
Erzwungen wird jetzt gezielt dort, wo eine Aktion **nach außen** wirkt:
|
||||||
|
Mitglieder einladen, Info-Mail an alle, Jahresabschluss mit
|
||||||
|
Benachrichtigung. Bei den beiden Mailflows bleibt der Dry-Run erlaubt,
|
||||||
|
die Einladung wird ganz abgewiesen.
|
||||||
|
|
||||||
|
Bewusst **nicht** erzwungen wird der Login selbst: die eigene Kaffeeliste
|
||||||
|
darf man auch unbestätigt führen. Der Grund ist der migrierte Bestand —
|
||||||
|
eine harte Loginsperre hätte alle Nutzer ausgesperrt, deren
|
||||||
|
`email_verified_at` aus der Legacy-Migration `NULL` ist.
|
||||||
|
|
||||||
|
Ergänzend setzt `saas_reset_password_with_token()` jetzt
|
||||||
|
`email_verified_at` mit, denn wer den zugestellten Link öffnen konnte,
|
||||||
|
hat den Zugriff auf das Postfach nachgewiesen. Das betrifft vor allem
|
||||||
|
eingeladene Mitglieder: deren Zugangsvergabe läuft über genau diesen
|
||||||
|
Link, ohne dass je eine separate Verifikationsmail verschickt wird — ohne
|
||||||
|
diese Ergänzung wären eingeladene Admins dauerhaft als unbestätigt
|
||||||
|
geführt worden.
|
||||||
|
|
||||||
|
### Login-Ziel
|
||||||
|
|
||||||
|
Nach dem Login ging es auf `konto.php`, also auf eine Stammdatentabelle.
|
||||||
|
Ziel ist jetzt `index.php`.
|
||||||
|
|
||||||
|
### Prüfstatus
|
||||||
|
|
||||||
|
`scripts/check-konto-und-mandantenwechsel.php` deckt alle vier Punkte ab
|
||||||
|
(18 Assertions, grün): Login-Ziel, abgewiesene und erlaubte Einladung,
|
||||||
|
Mandantenwechsel inklusive Negativfall fremder Mandant, alle vier
|
||||||
|
Fehlerfälle des Passwortwechsels plus echter Login mit altem und neuem
|
||||||
|
Passwort, und die Verifikation über den Reset-Link. Zusätzlich manuell
|
||||||
|
über das Formular in `konto.php` geprüft: Erfolgs- und Fehlermeldung,
|
||||||
|
CSRF-Schutz (419 ohne Token). Alle bestehenden Suiten bleiben grün
|
||||||
|
(HTTP-Smoke 34 Seiten, Rollenmatrix 55, Mandanten-Isolation 12,
|
||||||
|
M3-Auth 9, M3-Settings 15).
|
||||||
|
|||||||
+16
@@ -17,6 +17,19 @@ $saasCanManageMembersNav = $saasNavUser !== null
|
|||||||
&& function_exists('saas_user_has_role')
|
&& function_exists('saas_user_has_role')
|
||||||
&& saas_user_has_role(['owner', 'admin'], $saasNavUser);
|
&& saas_user_has_role(['owner', 'admin'], $saasNavUser);
|
||||||
$canUseVerwaltungGroup = $saasCanUseLedgerNav;
|
$canUseVerwaltungGroup = $saasCanUseLedgerNav;
|
||||||
|
|
||||||
|
// Der Mandantenwechsel wird nur eingeblendet, wenn es ueberhaupt etwas zu
|
||||||
|
// wechseln gibt - bei einem einzigen Mandanten waere der Punkt nur Ballast.
|
||||||
|
$saasHasMultipleTenants = false;
|
||||||
|
if ($saasNavUser !== null && function_exists('saas_list_user_memberships')) {
|
||||||
|
try {
|
||||||
|
$saasHasMultipleTenants = count(
|
||||||
|
saas_list_user_memberships(app_db_pdo(), (int)$saasNavUser['user_id'])
|
||||||
|
) > 1;
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$saasHasMultipleTenants = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
?>
|
?>
|
||||||
<ul>
|
<ul>
|
||||||
<li>
|
<li>
|
||||||
@@ -49,6 +62,9 @@ $canUseVerwaltungGroup = $saasCanUseLedgerNav;
|
|||||||
<span class="menu-group-label">Konto</span>
|
<span class="menu-group-label">Konto</span>
|
||||||
<ul class="menu-group-items">
|
<ul class="menu-group-items">
|
||||||
<li><a href="konto.php">Kundenkonto</a></li>
|
<li><a href="konto.php">Kundenkonto</a></li>
|
||||||
|
<?php if ($saasHasMultipleTenants): ?>
|
||||||
|
<li><a href="mandant-auswahl.php">Mandant wechseln</a></li>
|
||||||
|
<?php endif; ?>
|
||||||
<?php if (($saasNavUser['email_verified_at'] ?? null) === null): ?>
|
<?php if (($saasNavUser['email_verified_at'] ?? null) === null): ?>
|
||||||
<li><a href="email-verifikation-senden.php">E-Mail verifizieren</a></li>
|
<li><a href="email-verifikation-senden.php">E-Mail verifizieren</a></li>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|||||||
+11
@@ -42,6 +42,17 @@ if ($headerNoticeTenantId > 0) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Unbestaetigte Adresse: die eigene Kaffeeliste laesst sich weiter fuehren,
|
||||||
|
// aber Einladungen, Info-Mails und der Jahresabschluss sind gesperrt. Der
|
||||||
|
// Hinweis erklaert das, bevor jemand in die Sperre laeuft.
|
||||||
|
if ($headerSaasUser !== null && ($headerSaasUser['email_verified_at'] ?? null) === null) {
|
||||||
|
echo "<div style='background-color: #fff3cd; padding: 15px; text-align: center;'>"
|
||||||
|
. 'Deine E-Mail-Adresse ist noch nicht bestätigt. Mitglieder einladen, '
|
||||||
|
. 'Info-Mails und der Jahresabschluss sind bis dahin gesperrt. '
|
||||||
|
. '<a href="email-verifikation-senden.php">Bestätigungslink erneut anfordern</a>'
|
||||||
|
. "</div>";
|
||||||
|
}
|
||||||
|
|
||||||
?>
|
?>
|
||||||
<!-- Wrapper -->
|
<!-- Wrapper -->
|
||||||
<div id="wrapper">
|
<div id="wrapper">
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ $ergebnisse = null;
|
|||||||
$dryRun = true;
|
$dryRun = true;
|
||||||
$gesamtbetragEingabe = '';
|
$gesamtbetragEingabe = '';
|
||||||
$gebuchtFlash = false;
|
$gebuchtFlash = false;
|
||||||
|
$verifikationHinweis = null;
|
||||||
|
|
||||||
// Ergebnis einer vorangegangenen echten Buchung (Post-Redirect-Get): nach dem
|
// Ergebnis einer vorangegangenen echten Buchung (Post-Redirect-Get): nach dem
|
||||||
// Buchen wird auf diese Seite zurueckgeleitet, damit ein Browser-Refresh die
|
// Buchen wird auf diese Seite zurueckgeleitet, damit ein Browser-Refresh die
|
||||||
@@ -47,6 +48,13 @@ if ($hasAccess && $_SERVER["REQUEST_METHOD"] === "POST") {
|
|||||||
$gesamtbetrag = filter_var(str_replace(',', '.', $gesamtbetragEingabe), FILTER_VALIDATE_FLOAT);
|
$gesamtbetrag = filter_var(str_replace(',', '.', $gesamtbetragEingabe), FILTER_VALIDATE_FLOAT);
|
||||||
$totalCents = $gesamtbetrag !== false ? (int)round($gesamtbetrag * 100) : 0;
|
$totalCents = $gesamtbetrag !== false ? (int)round($gesamtbetrag * 100) : 0;
|
||||||
|
|
||||||
|
// Die echte Verteilung bucht nicht nur, sie mailt auch jedem Teilnehmer
|
||||||
|
// seinen Anteil. Ohne bestaetigte eigene Adresse bleibt nur die Vorschau.
|
||||||
|
if (!$dryRun && !saas_email_verified($saasUser)) {
|
||||||
|
$dryRun = true;
|
||||||
|
$verifikationHinweis = 'Es wurde nur eine Vorschau berechnet: Für die echte Buchung muss deine E-Mail-Adresse bestätigt sein. Den Link kannst du unter "Kundenkonto" erneut anfordern.';
|
||||||
|
}
|
||||||
|
|
||||||
if ($totalCents <= 0) {
|
if ($totalCents <= 0) {
|
||||||
$fehler = 'Bitte einen Gesamtbetrag größer 0 eingeben.';
|
$fehler = 'Bitte einen Gesamtbetrag größer 0 eingeben.';
|
||||||
} elseif ($gesamtJahresstriche <= 0) {
|
} elseif ($gesamtJahresstriche <= 0) {
|
||||||
@@ -185,6 +193,10 @@ es wird nichts gebucht und keine Mail verschickt.</p>
|
|||||||
|
|
||||||
<p>Jahresstriche gesamt: <?php echo number_format($gesamtJahresstriche, 0, ',', '.'); ?></p>
|
<p>Jahresstriche gesamt: <?php echo number_format($gesamtJahresstriche, 0, ',', '.'); ?></p>
|
||||||
|
|
||||||
|
<?php if ($verifikationHinweis !== null): ?>
|
||||||
|
<div class="hint-box error"><p><?php echo saas_html($verifikationHinweis); ?></p></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
<?php if ($fehler !== null): ?>
|
<?php if ($fehler !== null): ?>
|
||||||
<div class="hint-box error"><p><?php echo saas_html($fehler); ?></p></div>
|
<div class="hint-box error"><p><?php echo saas_html($fehler); ?></p></div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|||||||
@@ -9,6 +9,28 @@ if (saas_should_show_auth_links() && !empty($_SESSION['saas_dev_email_verificati
|
|||||||
unset($_SESSION['saas_dev_email_verification_token']);
|
unset($_SESSION['saas_dev_email_verification_token']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$passwortFehler = [];
|
||||||
|
$passwortGeaendert = false;
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['aktion'] ?? '') === 'passwort_aendern') {
|
||||||
|
app_require_csrf();
|
||||||
|
|
||||||
|
$pdo = app_db_pdo();
|
||||||
|
$ergebnis = saas_change_password(
|
||||||
|
$pdo,
|
||||||
|
(int)$user['user_id'],
|
||||||
|
(string)($_POST['aktuelles_passwort'] ?? ''),
|
||||||
|
(string)($_POST['neues_passwort'] ?? ''),
|
||||||
|
(string)($_POST['neues_passwort_confirm'] ?? '')
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($ergebnis['ok']) {
|
||||||
|
$passwortGeaendert = true;
|
||||||
|
} else {
|
||||||
|
$passwortFehler = $ergebnis['errors'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
include 'header.php';
|
include 'header.php';
|
||||||
include 'headerline.php';
|
include 'headerline.php';
|
||||||
include 'nav.php';
|
include 'nav.php';
|
||||||
@@ -63,6 +85,42 @@ include 'nav.php';
|
|||||||
</form>
|
</form>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<h3>Passwort ändern</h3>
|
||||||
|
|
||||||
|
<?php if ($passwortGeaendert): ?>
|
||||||
|
<div class="hint-box success"><p>Das Passwort wurde geändert.</p></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if ($passwortFehler !== []): ?>
|
||||||
|
<div class="hint-box error">
|
||||||
|
<?php foreach ($passwortFehler as $fehler): ?>
|
||||||
|
<p><?php echo saas_html($fehler); ?></p>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<form method="post" action="konto.php">
|
||||||
|
<?php echo app_csrf_field(); ?>
|
||||||
|
<input type="hidden" name="aktion" value="passwort_aendern">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<label for="aktuelles_passwort">Aktuelles Passwort</label>
|
||||||
|
<input type="password" name="aktuelles_passwort" id="aktuelles_passwort" autocomplete="current-password" required>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label for="neues_passwort">Neues Passwort (mindestens 8 Zeichen)</label>
|
||||||
|
<input type="password" name="neues_passwort" id="neues_passwort" autocomplete="new-password" required>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label for="neues_passwort_confirm">Neues Passwort wiederholen</label>
|
||||||
|
<input type="password" name="neues_passwort_confirm" id="neues_passwort_confirm" autocomplete="new-password" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ul class="actions">
|
||||||
|
<li><button type="submit">Passwort ändern</button></li>
|
||||||
|
</ul>
|
||||||
|
</form>
|
||||||
|
|
||||||
<?php if (saas_can_manage_tenant_settings($user)): ?>
|
<?php if (saas_can_manage_tenant_settings($user)): ?>
|
||||||
<ul class="actions">
|
<ul class="actions">
|
||||||
<li><a href="mandant-einstellungen.php" class="button">Mandant-Einstellungen</a></li>
|
<li><a href="mandant-einstellungen.php" class="button">Mandant-Einstellungen</a></li>
|
||||||
|
|||||||
+1
-3
@@ -105,9 +105,7 @@ $jsonFlags = JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE;
|
|||||||
<ul>
|
<ul>
|
||||||
<li>Bis 10 Personen kostenlos</li>
|
<li>Bis 10 Personen kostenlos</li>
|
||||||
<li>Ohne Kreditkarte starten</li>
|
<li>Ohne Kreditkarte starten</li>
|
||||||
<li>DSGVO-konform: Daten jederzeit export- & löschbar</li>
|
<li>DSGVO-konform</li>
|
||||||
<li>§ 19 UStG – keine Mehrwertsteuer</li>
|
|
||||||
<li>Bestehende Abläufe bleiben</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
|
|
||||||
if ($result['ok']) {
|
if ($result['ok']) {
|
||||||
saas_session_login($result['identity']);
|
saas_session_login($result['identity']);
|
||||||
header('Location: konto.php');
|
// Nach dem Login gehoert das Dashboard nach vorne, nicht die
|
||||||
|
// Stammdatenseite: fuer die meisten Nutzer ist die eigene
|
||||||
|
// Kaffeeliste der einzige Grund, sich anzumelden.
|
||||||
|
header('Location: index.php');
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ if ($saasUser !== null && saas_user_has_role(['owner', 'admin', 'treasurer'], $s
|
|||||||
$ergebnisse = null;
|
$ergebnisse = null;
|
||||||
$dryRun = true;
|
$dryRun = true;
|
||||||
$liveFlash = false;
|
$liveFlash = false;
|
||||||
|
$verifikationHinweis = null;
|
||||||
|
|
||||||
// Ergebnis eines vorangegangenen Live-Versands (Post-Redirect-Get): nach dem
|
// Ergebnis eines vorangegangenen Live-Versands (Post-Redirect-Get): nach dem
|
||||||
// echten Versand wird auf diese Seite zurueckgeleitet, damit ein Refresh nicht
|
// echten Versand wird auf diese Seite zurueckgeleitet, damit ein Refresh nicht
|
||||||
@@ -35,6 +36,15 @@ if ($hasAccess && isset($_SESSION['flash_mailversand'])) {
|
|||||||
|
|
||||||
if ($hasAccess && $_SERVER["REQUEST_METHOD"] === "POST") {
|
if ($hasAccess && $_SERVER["REQUEST_METHOD"] === "POST") {
|
||||||
$dryRun = !empty($_POST['dry_run']);
|
$dryRun = !empty($_POST['dry_run']);
|
||||||
|
|
||||||
|
// Echter Versand geht an alle Mitglieder des Mandanten. Solange die
|
||||||
|
// eigene Adresse nicht bestaetigt ist, bleibt nur der Dry-Run - der
|
||||||
|
// protokolliert, verschickt aber nichts nach aussen.
|
||||||
|
if (!$dryRun && !saas_email_verified($saasUser)) {
|
||||||
|
$dryRun = true;
|
||||||
|
$verifikationHinweis = 'Es wurde nur ein Dry-Run ausgeführt: Für den echten Versand muss deine E-Mail-Adresse bestätigt sein. Den Link kannst du unter "Kundenkonto" erneut anfordern.';
|
||||||
|
}
|
||||||
|
|
||||||
$settings = saas_fetch_tenant_settings($pdo, $tenantId);
|
$settings = saas_fetch_tenant_settings($pdo, $tenantId);
|
||||||
$dashboardUrl = saas_app_url('index.php');
|
$dashboardUrl = saas_app_url('index.php');
|
||||||
$createdByUserId = $saasUser['user_id'] ?? null;
|
$createdByUserId = $saasUser['user_id'] ?? null;
|
||||||
@@ -120,6 +130,10 @@ Empfänger wird im Versandlog unten protokolliert.</p>
|
|||||||
<div class="hint-box success"><p>Die Info-Mails wurden versendet.</p></div>
|
<div class="hint-box success"><p>Die Info-Mails wurden versendet.</p></div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if ($verifikationHinweis !== null): ?>
|
||||||
|
<div class="hint-box error"><p><?php echo saas_html($verifikationHinweis); ?></p></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
<?php if ($ergebnisse !== null): ?>
|
<?php if ($ergebnisse !== null): ?>
|
||||||
<h3>Ergebnis dieses Laufs (<?php echo $dryRun ? 'Dry-Run' : 'Live-Versand'; ?>)</h3>
|
<h3>Ergebnis dieses Laufs (<?php echo $dryRun ? 'Dry-Run' : 'Live-Versand'; ?>)</h3>
|
||||||
<table>
|
<table>
|
||||||
|
|||||||
+21
-7
@@ -3,26 +3,35 @@
|
|||||||
require_once __DIR__ . '/functions.php';
|
require_once __DIR__ . '/functions.php';
|
||||||
|
|
||||||
$pdo = app_db_pdo();
|
$pdo = app_db_pdo();
|
||||||
|
|
||||||
|
// Zwei Wege auf diese Seite: direkt nach dem Login (noch keine Session,
|
||||||
|
// nur eine vorgemerkte User-ID) oder als Wechsel aus einer bestehenden
|
||||||
|
// Sitzung heraus. Ohne den zweiten Weg muesste sich jemand, der bei
|
||||||
|
// mehreren Mandanten Mitglied ist, zum Wechseln erst abmelden.
|
||||||
$pendingUserId = saas_pending_tenant_user_id();
|
$pendingUserId = saas_pending_tenant_user_id();
|
||||||
if ($pendingUserId === null) {
|
$currentUser = $pendingUserId === null ? saas_current_user($pdo) : null;
|
||||||
|
$isSwitching = $currentUser !== null;
|
||||||
|
$userId = $pendingUserId ?? ($currentUser !== null ? (int)$currentUser['user_id'] : null);
|
||||||
|
|
||||||
|
if ($userId === null) {
|
||||||
header('Location: login.php');
|
header('Location: login.php');
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
$errors = [];
|
$errors = [];
|
||||||
$memberships = saas_list_user_memberships($pdo, $pendingUserId);
|
$memberships = saas_list_user_memberships($pdo, $userId);
|
||||||
|
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
app_require_csrf();
|
app_require_csrf();
|
||||||
|
|
||||||
$tenantId = isset($_POST['tenant_id']) ? (int)$_POST['tenant_id'] : 0;
|
$tenantId = isset($_POST['tenant_id']) ? (int)$_POST['tenant_id'] : 0;
|
||||||
$identity = $tenantId > 0 ? saas_identity_for_user_tenant($pdo, $pendingUserId, $tenantId) : null;
|
$identity = $tenantId > 0 ? saas_identity_for_user_tenant($pdo, $userId, $tenantId) : null;
|
||||||
|
|
||||||
if ($identity === null) {
|
if ($identity === null) {
|
||||||
$errors[] = 'Dieser Mandant ist für dein Konto nicht verfügbar.';
|
$errors[] = 'Dieser Mandant ist für dein Konto nicht verfügbar.';
|
||||||
} else {
|
} else {
|
||||||
saas_session_login($identity);
|
saas_session_login($identity);
|
||||||
header('Location: konto.php');
|
header('Location: index.php');
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -34,7 +43,7 @@ include 'nav.php';
|
|||||||
|
|
||||||
<section id="banner">
|
<section id="banner">
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<h2>Mandant auswählen</h2>
|
<h2><?php echo $isSwitching ? 'Mandant wechseln' : 'Mandant auswählen'; ?></h2>
|
||||||
|
|
||||||
<?php if ($errors !== []): ?>
|
<?php if ($errors !== []): ?>
|
||||||
<div class="hint-box error">
|
<div class="hint-box error">
|
||||||
@@ -54,7 +63,8 @@ include 'nav.php';
|
|||||||
<label for="tenant_id">Mandant</label>
|
<label for="tenant_id">Mandant</label>
|
||||||
<select name="tenant_id" id="tenant_id" required>
|
<select name="tenant_id" id="tenant_id" required>
|
||||||
<?php foreach ($memberships as $membership): ?>
|
<?php foreach ($memberships as $membership): ?>
|
||||||
<option value="<?php echo saas_html($membership['tenant_id']); ?>">
|
<option value="<?php echo saas_html($membership['tenant_id']); ?>"
|
||||||
|
<?php echo $isSwitching && (int)$membership['tenant_id'] === (int)$currentUser['tenant_id'] ? 'selected' : ''; ?>>
|
||||||
<?php echo saas_html($membership['tenant_name']); ?>
|
<?php echo saas_html($membership['tenant_name']); ?>
|
||||||
(<?php echo saas_html($membership['role']); ?>)
|
(<?php echo saas_html($membership['role']); ?>)
|
||||||
</option>
|
</option>
|
||||||
@@ -63,8 +73,12 @@ include 'nav.php';
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ul class="actions">
|
<ul class="actions">
|
||||||
<li><button type="submit">Weiter</button></li>
|
<li><button type="submit"><?php echo $isSwitching ? 'Wechseln' : 'Weiter'; ?></button></li>
|
||||||
|
<?php if ($isSwitching): ?>
|
||||||
|
<li><a href="index.php" class="button alt">Abbrechen</a></li>
|
||||||
|
<?php else: ?>
|
||||||
<li><a href="logout.php" class="button alt">Abbrechen</a></li>
|
<li><a href="logout.php" class="button alt">Abbrechen</a></li>
|
||||||
|
<?php endif; ?>
|
||||||
</ul>
|
</ul>
|
||||||
</form>
|
</form>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|||||||
@@ -103,7 +103,16 @@ if($hasAccess){
|
|||||||
$participantId = (int)$_POST["mitgliedID"];
|
$participantId = (int)$_POST["mitgliedID"];
|
||||||
$rolle = (string)($_POST["rolle"] ?? '');
|
$rolle = (string)($_POST["rolle"] ?? '');
|
||||||
|
|
||||||
|
// Einladungen legen fremde Konten an und verschicken Mails nach
|
||||||
|
// aussen. Wer die eigene Adresse noch nicht bestaetigt hat, kann
|
||||||
|
// das nicht im Namen eines womoeglich fremden Postfachs tun.
|
||||||
|
if (!saas_email_verified($saasUser)) {
|
||||||
|
$ergebnis = ['ok' => false, 'errors' => [
|
||||||
|
'Bevor du Mitglieder einladen kannst, muss deine eigene E-Mail-Adresse bestätigt sein. Den Link kannst du unter "Kundenkonto" erneut anfordern.',
|
||||||
|
]];
|
||||||
|
} else {
|
||||||
$ergebnis = saas_grant_participant_access($pdo, $tenantId, $participantId, $rolle);
|
$ergebnis = saas_grant_participant_access($pdo, $tenantId, $participantId, $rolle);
|
||||||
|
}
|
||||||
if ($ergebnis['ok']) {
|
if ($ergebnis['ok']) {
|
||||||
$einladendeTenantSettings = saas_fetch_tenant_settings($pdo, $tenantId);
|
$einladendeTenantSettings = saas_fetch_tenant_settings($pdo, $tenantId);
|
||||||
$mailErgebnis = saas_send_invite_mail(
|
$mailErgebnis = saas_send_invite_mail(
|
||||||
|
|||||||
@@ -0,0 +1,311 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prueft die vier Kontofunktionen, die vor dem Go-Live ergaenzt wurden:
|
||||||
|
*
|
||||||
|
* 1. Passwortwechsel im eingeloggten Zustand (saas_change_password).
|
||||||
|
* 2. Mandantenwechsel aus einer bestehenden Sitzung heraus.
|
||||||
|
* 3. Erzwungene E-Mail-Verifikation fuer Aktionen, die nach aussen mailen.
|
||||||
|
* 4. Login landet auf dem Dashboard statt auf der Kontoseite.
|
||||||
|
*
|
||||||
|
* Braucht einen laufenden Dev-Server (scripts/run-dev-server.sh).
|
||||||
|
* Legt eigene Testmandanten an und raeumt sie am Ende wieder ab.
|
||||||
|
*/
|
||||||
|
|
||||||
|
require __DIR__ . '/dev-db.php';
|
||||||
|
require_once __DIR__ . '/../app/saas-auth.php';
|
||||||
|
|
||||||
|
$baseUrl = rtrim((string)(getenv('SMOKE_BASE_URL') ?: 'http://127.0.0.1:8080'), '/');
|
||||||
|
$pdo = dev_pdo();
|
||||||
|
$suffix = bin2hex(random_bytes(4));
|
||||||
|
$altesPasswort = 'KontoCheckAlt123!';
|
||||||
|
$neuesPasswort = 'KontoCheckNeu456!';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimaler Cookie-Jar auf Basis von file_get_contents, weil diese
|
||||||
|
* PHP-Installation keine curl-Extension hat - gleiches Vorgehen wie in
|
||||||
|
* scripts/check-m8-role-matrix.php.
|
||||||
|
*
|
||||||
|
* @param array<string,string> $cookies
|
||||||
|
* @return array{status:int, body:string, location:?string}
|
||||||
|
*/
|
||||||
|
function konto_request(string $url, array &$cookies, string $method = 'GET', ?string $postBody = null): array
|
||||||
|
{
|
||||||
|
$cookieHeader = '';
|
||||||
|
if ($cookies !== []) {
|
||||||
|
$pairs = [];
|
||||||
|
foreach ($cookies as $name => $value) {
|
||||||
|
$pairs[] = "{$name}={$value}";
|
||||||
|
}
|
||||||
|
$cookieHeader = 'Cookie: ' . implode('; ', $pairs) . "\r\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
$headers = "User-Agent: KaffeelisteKontoCheck/1.0\r\n" . $cookieHeader;
|
||||||
|
if ($postBody !== null) {
|
||||||
|
$headers .= "Content-Type: application/x-www-form-urlencoded\r\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
$context = stream_context_create([
|
||||||
|
'http' => [
|
||||||
|
'method' => $method,
|
||||||
|
'header' => $headers,
|
||||||
|
'content' => $postBody,
|
||||||
|
'timeout' => 15,
|
||||||
|
'ignore_errors' => true,
|
||||||
|
'follow_location' => 0,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$body = @file_get_contents($url, false, $context);
|
||||||
|
$responseHeaders = $http_response_header ?? [];
|
||||||
|
$status = 0;
|
||||||
|
$location = null;
|
||||||
|
|
||||||
|
foreach ($responseHeaders as $header) {
|
||||||
|
if (preg_match('~^HTTP/\S+\s+(\d{3})~', $header, $m) === 1) {
|
||||||
|
$status = (int)$m[1];
|
||||||
|
}
|
||||||
|
if (preg_match('/^Set-Cookie:\s*([^=;]+)=([^;]+)/i', $header, $m) === 1) {
|
||||||
|
$cookies[$m[1]] = $m[2];
|
||||||
|
}
|
||||||
|
if (preg_match('/^Location:\s*(.+)$/i', $header, $m) === 1) {
|
||||||
|
$location = trim($m[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['status' => $status, 'body' => (string)$body, 'location' => $location];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string,string> $cookies */
|
||||||
|
function konto_csrf(string $url, array &$cookies): string
|
||||||
|
{
|
||||||
|
$page = konto_request($url, $cookies);
|
||||||
|
preg_match('/name="csrf_token" value="([^"]+)"/', $page['body'], $m);
|
||||||
|
|
||||||
|
return $m[1] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$failures = [];
|
||||||
|
$passes = 0;
|
||||||
|
|
||||||
|
function pruefe(string $label, bool $ok): void
|
||||||
|
{
|
||||||
|
global $failures, $passes;
|
||||||
|
if ($ok) {
|
||||||
|
$passes++;
|
||||||
|
echo "PASS {$label}\n";
|
||||||
|
} else {
|
||||||
|
$failures[] = $label;
|
||||||
|
echo "FAIL {$label}\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenantAId = null;
|
||||||
|
$tenantBId = null;
|
||||||
|
$tenantCId = null;
|
||||||
|
$userId = null;
|
||||||
|
$participantId = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Zwei Mandanten fuer den Wechsel plus ein dritter, in dem der Testnutzer
|
||||||
|
// ausdruecklich kein Mitglied ist (Negativfall).
|
||||||
|
foreach (['a', 'b', 'c'] as $key) {
|
||||||
|
$stmt = $pdo->prepare('INSERT INTO tenants (slug, name, status) VALUES (?, ?, ?)');
|
||||||
|
$stmt->execute(["kontocheck-{$key}-{$suffix}", "Kontocheck {$key}", 'active']);
|
||||||
|
$id = (int)$pdo->lastInsertId();
|
||||||
|
$pdo->prepare('INSERT INTO tenant_settings (tenant_id) VALUES (?)')->execute([$id]);
|
||||||
|
${'tenant' . strtoupper($key) . 'Id'} = $id;
|
||||||
|
}
|
||||||
|
|
||||||
|
$email = "kontocheck-{$suffix}@test.local";
|
||||||
|
$pdo->prepare(
|
||||||
|
'INSERT INTO users (email, email_norm, display_name, password_hash, status) VALUES (?, ?, ?, ?, ?)'
|
||||||
|
)->execute([$email, $email, 'Kontocheck Nutzer', password_hash($altesPasswort, PASSWORD_DEFAULT), 'active']);
|
||||||
|
$userId = (int)$pdo->lastInsertId();
|
||||||
|
|
||||||
|
foreach ([$tenantAId, $tenantBId] as $tid) {
|
||||||
|
$pdo->prepare(
|
||||||
|
'INSERT INTO tenant_memberships (tenant_id, user_id, role, status, joined_at) VALUES (?, ?, ?, ?, NOW())'
|
||||||
|
)->execute([$tid, $userId, 'owner', 'active']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Teilnehmer in Mandant A, dem die Einladung gelten soll.
|
||||||
|
$einladungsMail = "kontocheck-invite-{$suffix}@test.local";
|
||||||
|
$pdo->prepare(
|
||||||
|
'INSERT INTO participants (tenant_id, display_name, email, email_norm, active) VALUES (?, ?, ?, ?, 1)'
|
||||||
|
)->execute([$tenantAId, 'Einzuladendes Mitglied', $einladungsMail, $einladungsMail]);
|
||||||
|
$participantId = (int)$pdo->lastInsertId();
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
fwrite(STDERR, "Kontocheck-Setup fehlgeschlagen: {$e->getMessage()}\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
// 4. Login landet auf dem Dashboard
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
$cookies = [];
|
||||||
|
$csrf = konto_csrf("{$baseUrl}/login.php", $cookies);
|
||||||
|
$login = konto_request("{$baseUrl}/login.php", $cookies, 'POST', http_build_query([
|
||||||
|
'csrf_token' => $csrf,
|
||||||
|
'email' => "kontocheck-{$suffix}@test.local",
|
||||||
|
'password' => $altesPasswort,
|
||||||
|
'tenant_slug' => "kontocheck-a-{$suffix}",
|
||||||
|
]));
|
||||||
|
pruefe('Login leitet auf index.php statt konto.php', $login['location'] === 'index.php');
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
// 3. Verifikation wird fuer Einladungen erzwungen
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
$csrf = konto_csrf("{$baseUrl}/mitarbeiterverwalten.php", $cookies);
|
||||||
|
$einladung = konto_request("{$baseUrl}/mitarbeiterverwalten.php", $cookies, 'POST', http_build_query([
|
||||||
|
'csrf_token' => $csrf,
|
||||||
|
'aktion' => 'zugang_gewaehren',
|
||||||
|
'mitgliedID' => (string)$participantId,
|
||||||
|
'rolle' => 'member',
|
||||||
|
]));
|
||||||
|
pruefe(
|
||||||
|
'Einladung ohne bestaetigte Adresse wird abgelehnt',
|
||||||
|
str_contains($einladung['body'], 'bestätigt sein')
|
||||||
|
);
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('SELECT user_id FROM participants WHERE id = ?');
|
||||||
|
$stmt->execute([$participantId]);
|
||||||
|
pruefe('Ohne Verifikation wurde kein Konto angelegt', (int)($stmt->fetchColumn() ?: 0) === 0);
|
||||||
|
|
||||||
|
// Adresse bestaetigen und dieselbe Aktion erneut versuchen.
|
||||||
|
$pdo->prepare('UPDATE users SET email_verified_at = NOW() WHERE id = ?')->execute([$userId]);
|
||||||
|
|
||||||
|
$csrf = konto_csrf("{$baseUrl}/mitarbeiterverwalten.php", $cookies);
|
||||||
|
$einladung = konto_request("{$baseUrl}/mitarbeiterverwalten.php", $cookies, 'POST', http_build_query([
|
||||||
|
'csrf_token' => $csrf,
|
||||||
|
'aktion' => 'zugang_gewaehren',
|
||||||
|
'mitgliedID' => (string)$participantId,
|
||||||
|
'rolle' => 'member',
|
||||||
|
]));
|
||||||
|
pruefe(
|
||||||
|
'Einladung mit bestaetigter Adresse funktioniert',
|
||||||
|
str_contains($einladung['body'], 'Zugang wurde gewährt')
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
// 2. Mandantenwechsel aus bestehender Sitzung
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
$auswahl = konto_request("{$baseUrl}/mandant-auswahl.php", $cookies);
|
||||||
|
pruefe(
|
||||||
|
'Mandantenwechsel ist eingeloggt erreichbar (kein Redirect auf login.php)',
|
||||||
|
$auswahl['status'] === 200 && str_contains($auswahl['body'], 'Mandant wechseln')
|
||||||
|
);
|
||||||
|
|
||||||
|
$csrf = konto_csrf("{$baseUrl}/mandant-auswahl.php", $cookies);
|
||||||
|
$wechsel = konto_request("{$baseUrl}/mandant-auswahl.php", $cookies, 'POST', http_build_query([
|
||||||
|
'csrf_token' => $csrf,
|
||||||
|
'tenant_id' => (string)$tenantBId,
|
||||||
|
]));
|
||||||
|
pruefe('Wechsel auf eigenen zweiten Mandanten leitet aufs Dashboard', $wechsel['location'] === 'index.php');
|
||||||
|
|
||||||
|
$konto = konto_request("{$baseUrl}/konto.php", $cookies);
|
||||||
|
pruefe(
|
||||||
|
'Nach dem Wechsel ist Mandant B aktiv',
|
||||||
|
str_contains($konto['body'], "kontocheck-b-{$suffix}")
|
||||||
|
);
|
||||||
|
|
||||||
|
$csrf = konto_csrf("{$baseUrl}/mandant-auswahl.php", $cookies);
|
||||||
|
$fremd = konto_request("{$baseUrl}/mandant-auswahl.php", $cookies, 'POST', http_build_query([
|
||||||
|
'csrf_token' => $csrf,
|
||||||
|
'tenant_id' => (string)$tenantCId,
|
||||||
|
]));
|
||||||
|
pruefe(
|
||||||
|
'Wechsel auf fremden Mandanten wird abgelehnt',
|
||||||
|
$fremd['location'] === null && str_contains($fremd['body'], 'nicht verfügbar')
|
||||||
|
);
|
||||||
|
|
||||||
|
$konto = konto_request("{$baseUrl}/konto.php", $cookies);
|
||||||
|
pruefe(
|
||||||
|
'Nach abgelehntem Wechsel ist weiterhin Mandant B aktiv',
|
||||||
|
str_contains($konto['body'], "kontocheck-b-{$suffix}")
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
// 1. Passwortwechsel
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
$falsch = saas_change_password($pdo, $userId, 'VoelligFalsch999!', $neuesPasswort, $neuesPasswort);
|
||||||
|
pruefe('Falsches aktuelles Passwort wird abgelehnt', $falsch['ok'] === false);
|
||||||
|
|
||||||
|
$ungleich = saas_change_password($pdo, $userId, $altesPasswort, $neuesPasswort, 'tippfehler');
|
||||||
|
pruefe('Abweichende Wiederholung wird abgelehnt', $ungleich['ok'] === false);
|
||||||
|
|
||||||
|
$zuKurz = saas_change_password($pdo, $userId, $altesPasswort, 'kurz', 'kurz');
|
||||||
|
pruefe('Zu kurzes Passwort wird abgelehnt', $zuKurz['ok'] === false);
|
||||||
|
|
||||||
|
$gleich = saas_change_password($pdo, $userId, $altesPasswort, $altesPasswort, $altesPasswort);
|
||||||
|
pruefe('Unveraendertes Passwort wird abgelehnt', $gleich['ok'] === false);
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare('SELECT password_hash FROM users WHERE id = ?');
|
||||||
|
$stmt->execute([$userId]);
|
||||||
|
pruefe(
|
||||||
|
'Nach allen Fehlversuchen gilt weiterhin das alte Passwort',
|
||||||
|
password_verify($altesPasswort, (string)$stmt->fetchColumn())
|
||||||
|
);
|
||||||
|
|
||||||
|
$ok = saas_change_password($pdo, $userId, $altesPasswort, $neuesPasswort, $neuesPasswort);
|
||||||
|
pruefe('Korrekter Passwortwechsel wird gespeichert', $ok['ok'] === true);
|
||||||
|
|
||||||
|
// Der Wechsel muss sich auch am echten Login zeigen.
|
||||||
|
$neueCookies = [];
|
||||||
|
$csrf = konto_csrf("{$baseUrl}/login.php", $neueCookies);
|
||||||
|
$altLogin = konto_request("{$baseUrl}/login.php", $neueCookies, 'POST', http_build_query([
|
||||||
|
'csrf_token' => $csrf,
|
||||||
|
'email' => "kontocheck-{$suffix}@test.local",
|
||||||
|
'password' => $altesPasswort,
|
||||||
|
'tenant_slug' => "kontocheck-a-{$suffix}",
|
||||||
|
]));
|
||||||
|
pruefe('Login mit altem Passwort schlaegt fehl', $altLogin['location'] === null);
|
||||||
|
|
||||||
|
$neueCookies = [];
|
||||||
|
$csrf = konto_csrf("{$baseUrl}/login.php", $neueCookies);
|
||||||
|
$neuLogin = konto_request("{$baseUrl}/login.php", $neueCookies, 'POST', http_build_query([
|
||||||
|
'csrf_token' => $csrf,
|
||||||
|
'email' => "kontocheck-{$suffix}@test.local",
|
||||||
|
'password' => $neuesPasswort,
|
||||||
|
'tenant_slug' => "kontocheck-a-{$suffix}",
|
||||||
|
]));
|
||||||
|
pruefe('Login mit neuem Passwort funktioniert', $neuLogin['location'] === 'index.php');
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
// Reset-Link bestaetigt die Adresse mit
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
$pdo->prepare('UPDATE users SET email_verified_at = NULL WHERE id = ?')->execute([$userId]);
|
||||||
|
$token = saas_create_auth_token($pdo, $userId, 'password_reset', $tenantAId, 60);
|
||||||
|
$reset = saas_reset_password_with_token($pdo, $token['token'], 'ResetCheck789!', 'ResetCheck789!');
|
||||||
|
$stmt = $pdo->prepare('SELECT email_verified_at FROM users WHERE id = ?');
|
||||||
|
$stmt->execute([$userId]);
|
||||||
|
pruefe(
|
||||||
|
'Passwort-Reset per Mail-Link bestaetigt die Adresse mit',
|
||||||
|
$reset['ok'] === true && $stmt->fetchColumn() !== null
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
// Aufraeumen: Mitgliedschaften/Teilnehmer haengen per Fremdschluessel an
|
||||||
|
// den Mandanten, der Nutzer wird separat entfernt.
|
||||||
|
foreach ([$tenantAId, $tenantBId, $tenantCId] as $tid) {
|
||||||
|
if ($tid !== null) {
|
||||||
|
$pdo->prepare('DELETE FROM tenants WHERE id = ?')->execute([$tid]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($userId !== null) {
|
||||||
|
$pdo->prepare('DELETE FROM user_auth_tokens WHERE user_id = ?')->execute([$userId]);
|
||||||
|
$pdo->prepare('DELETE FROM users WHERE id = ?')->execute([$userId]);
|
||||||
|
}
|
||||||
|
// Das eingeladene Mitglied bekam bei Erfolg ein eigenes Benutzerkonto.
|
||||||
|
$pdo->prepare('DELETE FROM users WHERE email_norm = ?')
|
||||||
|
->execute(["kontocheck-invite-{$suffix}@test.local"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($failures !== []) {
|
||||||
|
fwrite(STDERR, "\nKontocheck fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "\nKonto- und Mandantenwechsel-Check bestanden mit {$passes} Assertions.\n";
|
||||||
Reference in New Issue
Block a user