From aeb687f42e6abec6e3b46bcaa45e3e2be7447694 Mon Sep 17 00:00:00 2001 From: Clemens Creutzburg Date: Wed, 15 Jul 2026 00:18:55 +0200 Subject: [PATCH] M5: Hinweise auf tenant-scoped Notices umziehen, Mitgliederzugang auf Rollen umstellen Hinweise: - Neue Tabelle notices (tenant-scoped, Soft-Delete via deleted_at) loest die global unscoped kl_hinweise als aktive Datenquelle ab; Migration uebernimmt einmalig aktuell gueltige kl_hinweise-Eintraege fuer den Default-Mandanten. kl_hinweise bleibt als Golden-Master-Referenz stehen. - hinweise.php und der Banner in header.php sind tenant-scoped umgestellt. Mitgliederverwaltung: - mitarbeiterverwalten.php verwaltet jetzt participants (tenant-scoped) statt der global unscoped kl_Mitarbeiter-Tabelle als primaere Quelle. Das behebt nebenbei ein Mandanten-Datenleck: jeder SaaS-Mandant mit Owner/Admin-Rolle haette zuvor die komplette Default-Mandanten- Mitgliederliste sehen und bearbeiten koennen. - Fuer den Default-Mandanten bleibt Dual-Write nach kl_Mitarbeiter bestehen, damit stricheintragen.php/einzahlung.php weiter funktionieren; andere Mandanten werden rein participant-nativ verwaltet. - Die Legacy-Administrator-Checkbox ist raus. Stattdessen kann ein Admin je Mitglied unabhaengig von Name/E-Mail einen Login-Zugang mit Rolle (member/treasurer/admin) gewaehren oder entziehen (saas_grant_participant_access / saas_revoke_participant_access). Einladung laeuft ueber den bestehenden Passwort-Reset-Mechanismus, Entzug setzt die Mitgliedschaft auf revoked statt sie zu loeschen. - Kompletter Flow live getestet: anlegen, Zugang gewaehren, Einladungsmail, Passwort setzen, Login, Rollenschutz, Zugang entziehen, Login-Sperre. Co-Authored-By: Claude Sonnet 5 --- app/ledger.php | 193 ++++++++-- app/notices.php | 72 ++++ app/saas-auth.php | 126 +++++++ app/saas-mail.php | 20 ++ database/migrations/0007_saas_notices.sql | 29 ++ docs/m5-app-kern.md | 95 ++++- docs/saas-umstrukturierungsplan.md | 24 +- header.php | 32 +- hinweise.php | 58 +-- mitarbeiterverwalten.php | 414 +++++++++++----------- 10 files changed, 790 insertions(+), 273 deletions(-) create mode 100644 app/notices.php create mode 100644 database/migrations/0007_saas_notices.sql diff --git a/app/ledger.php b/app/ledger.php index 1593b33..2640ac9 100644 --- a/app/ledger.php +++ b/app/ledger.php @@ -319,34 +319,181 @@ function ledger_mirror_legacy_consumption(PDO $pdo, int $tenantId, int $legacyCo } } +function ledger_is_default_tenant(PDO $pdo, int $tenantId): bool +{ + $tenant = ledger_fetch_default_tenant($pdo); + + return $tenant !== null && (int)$tenant['id'] === $tenantId; +} + /** - * Mirrors a single kl_Mitarbeiter row into participants, so member changes - * made through the legacy admin UI are immediately visible to ledger-backed - * reads instead of waiting for the next scripts/backfill-default-tenant.php run. + * @return list */ -function ledger_mirror_legacy_participant(PDO $pdo, int $tenantId, int $legacyMitarbeiterId): void +function ledger_fetch_participants_for_admin(PDO $pdo, int $tenantId): array { $stmt = $pdo->prepare( - "INSERT INTO participants - (tenant_id, display_name, email, email_norm, paypal_name, active, legacy_mitarbeiter_id) - SELECT - ?, - NULLIF(TRIM(m.Name), ''), - NULLIF(TRIM(m.Email), ''), - NULLIF(LOWER(TRIM(m.Email)), ''), - NULLIF(TRIM(m.paypalname), ''), - m.aktiv, - m.MitarbeiterID - FROM kl_Mitarbeiter m - WHERE m.MitarbeiterID = ? - ON DUPLICATE KEY UPDATE - display_name = COALESCE(VALUES(display_name), participants.display_name), - email = VALUES(email), - email_norm = VALUES(email_norm), - paypal_name = VALUES(paypal_name), - active = VALUES(active)" + "SELECT + p.id AS participant_id, + p.display_name, + p.email, + p.email_norm, + p.paypal_name, + p.active, + p.legacy_mitarbeiter_id, + p.user_id, + tm.role, + tm.status AS membership_status + FROM participants p + LEFT JOIN tenant_memberships tm + ON tm.tenant_id = p.tenant_id + AND tm.user_id = p.user_id + WHERE p.tenant_id = ? + ORDER BY p.display_name, p.id" ); - $stmt->execute([$tenantId, $legacyMitarbeiterId]); + $stmt->execute([$tenantId]); + + $rows = []; + while ($row = $stmt->fetch()) { + $rows[] = [ + 'participant_id' => (int)$row['participant_id'], + 'display_name' => (string)$row['display_name'], + 'email' => $row['email'] !== null ? (string)$row['email'] : null, + 'email_norm' => $row['email_norm'] !== null ? (string)$row['email_norm'] : null, + 'paypal_name' => $row['paypal_name'] !== null ? (string)$row['paypal_name'] : null, + 'active' => (int)$row['active'] === 1, + 'legacy_mitarbeiter_id' => $row['legacy_mitarbeiter_id'] !== null ? (int)$row['legacy_mitarbeiter_id'] : null, + 'user_id' => $row['user_id'] !== null ? (int)$row['user_id'] : null, + 'role' => $row['role'] !== null ? (string)$row['role'] : null, + 'membership_status' => $row['membership_status'] !== null ? (string)$row['membership_status'] : null, + ]; + } + + return $rows; +} + +/** + * Creates a tenant-scoped participant. For the default tenant, a matching + * kl_Mitarbeiter row is dual-written too, so the still-legacy bulk entry + * pages (stricheintragen.php, einzahlung.php) keep listing the member; other + * tenants have no legacy shadow table and get a participants-only row. + * + * @throws Throwable on constraint violations (e.g. duplicate email) + */ +function ledger_create_participant(PDO $pdo, int $tenantId, string $displayName, string $email, ?string $paypalName, bool $active): int +{ + $displayName = trim($displayName); + $email = trim($email); + $emailNorm = strtolower($email); + $paypalName = $paypalName !== null ? trim($paypalName) : null; + $paypalName = $paypalName !== '' ? $paypalName : null; + + $pdo->beginTransaction(); + try { + $legacyMitarbeiterId = null; + if (ledger_is_default_tenant($pdo, $tenantId)) { + $stmt = $pdo->prepare( + 'INSERT INTO kl_Mitarbeiter (Name, Email, paypalname, aktiv, admin) VALUES (?, ?, ?, ?, 0)' + ); + $stmt->execute([$displayName, $email, $paypalName, $active ? 1 : 0]); + $legacyMitarbeiterId = (int)$pdo->lastInsertId(); + } + + $stmt = $pdo->prepare( + 'INSERT INTO participants + (tenant_id, display_name, email, email_norm, paypal_name, active, legacy_mitarbeiter_id) + VALUES (?, ?, ?, ?, ?, ?, ?)' + ); + $stmt->execute([$tenantId, $displayName, $email, $emailNorm, $paypalName, $active ? 1 : 0, $legacyMitarbeiterId]); + $participantId = (int)$pdo->lastInsertId(); + + $pdo->commit(); + + return $participantId; + } catch (Throwable $e) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + + throw $e; + } +} + +function ledger_update_participant(PDO $pdo, int $tenantId, int $participantId, string $displayName, string $email, ?string $paypalName, bool $active): bool +{ + $displayName = trim($displayName); + $email = trim($email); + $emailNorm = strtolower($email); + $paypalName = $paypalName !== null ? trim($paypalName) : null; + $paypalName = $paypalName !== '' ? $paypalName : null; + + $stmt = $pdo->prepare('SELECT legacy_mitarbeiter_id FROM participants WHERE id = ? AND tenant_id = ?'); + $stmt->execute([$participantId, $tenantId]); + $row = $stmt->fetch(); + if ($row === false) { + return false; + } + $legacyMitarbeiterId = $row['legacy_mitarbeiter_id'] !== null ? (int)$row['legacy_mitarbeiter_id'] : null; + + try { + $pdo->beginTransaction(); + + if ($legacyMitarbeiterId !== null) { + $stmt = $pdo->prepare( + 'UPDATE kl_Mitarbeiter SET Name = ?, Email = ?, paypalname = ?, aktiv = ? WHERE MitarbeiterID = ?' + ); + $stmt->execute([$displayName, $email, $paypalName, $active ? 1 : 0, $legacyMitarbeiterId]); + } + + $stmt = $pdo->prepare( + 'UPDATE participants + SET display_name = ?, email = ?, email_norm = ?, paypal_name = ?, active = ? + WHERE id = ? AND tenant_id = ?' + ); + $stmt->execute([$displayName, $email, $emailNorm, $paypalName, $active ? 1 : 0, $participantId, $tenantId]); + + $pdo->commit(); + + return true; + } catch (Throwable $e) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + + return false; + } +} + +function ledger_set_participant_active(PDO $pdo, int $tenantId, int $participantId, bool $active): bool +{ + $stmt = $pdo->prepare('SELECT legacy_mitarbeiter_id FROM participants WHERE id = ? AND tenant_id = ?'); + $stmt->execute([$participantId, $tenantId]); + $row = $stmt->fetch(); + if ($row === false) { + return false; + } + $legacyMitarbeiterId = $row['legacy_mitarbeiter_id'] !== null ? (int)$row['legacy_mitarbeiter_id'] : null; + + try { + $pdo->beginTransaction(); + + if ($legacyMitarbeiterId !== null) { + $pdo->prepare('UPDATE kl_Mitarbeiter SET aktiv = ? WHERE MitarbeiterID = ?') + ->execute([$active ? 1 : 0, $legacyMitarbeiterId]); + } + + $pdo->prepare('UPDATE participants SET active = ? WHERE id = ? AND tenant_id = ?') + ->execute([$active ? 1 : 0, $participantId, $tenantId]); + + $pdo->commit(); + + return true; + } catch (Throwable $e) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + + return false; + } } function ledger_mirror_legacy_payment(PDO $pdo, int $tenantId, int $legacyPaymentId): void diff --git a/app/notices.php b/app/notices.php new file mode 100644 index 0000000..1a8cf5c --- /dev/null +++ b/app/notices.php @@ -0,0 +1,72 @@ +prepare( + "SELECT id, message, valid_from, valid_until + FROM notices + WHERE tenant_id = ? + AND deleted_at IS NULL + AND valid_from <= NOW() + AND valid_until >= NOW() + ORDER BY valid_until ASC + LIMIT 1" + ); + $stmt->execute([$tenantId]); + $row = $stmt->fetch(); + + return $row !== false ? $row : null; +} + +/** + * @return list + */ +function notices_fetch_all(PDO $pdo, int $tenantId): array +{ + $stmt = $pdo->prepare( + "SELECT id, message, valid_from, valid_until + FROM notices + WHERE tenant_id = ? + AND deleted_at IS NULL + ORDER BY valid_until DESC" + ); + $stmt->execute([$tenantId]); + + return $stmt->fetchAll(); +} + +function notices_create(PDO $pdo, int $tenantId, string $message, string $validUntil, ?int $createdByUserId): bool +{ + $message = trim($message); + if ($message === '' || $validUntil === '') { + return false; + } + + $stmt = $pdo->prepare( + 'INSERT INTO notices (tenant_id, message, valid_from, valid_until, created_by_user_id) + VALUES (?, ?, NOW(), ?, ?)' + ); + + return $stmt->execute([$tenantId, $message, $validUntil, $createdByUserId]); +} + +function notices_soft_delete(PDO $pdo, int $tenantId, int $noticeId): bool +{ + $stmt = $pdo->prepare( + 'UPDATE notices + SET deleted_at = NOW() + WHERE id = ? + AND tenant_id = ? + AND deleted_at IS NULL' + ); + $stmt->execute([$noticeId, $tenantId]); + + return $stmt->rowCount() > 0; +} diff --git a/app/saas-auth.php b/app/saas-auth.php index 9207358..f4673fa 100644 --- a/app/saas-auth.php +++ b/app/saas-auth.php @@ -848,6 +848,132 @@ function saas_authenticate(PDO $pdo, string $email, string $password, string $te ]; } +/** + * Roles an admin can hand out through the member management UI. "owner" is + * deliberately excluded here; it is only ever set during registration. + * + * @return list + */ +function saas_grantable_roles(): array +{ + return ['member', 'treasurer', 'admin']; +} + +/** + * Grants (or updates) tenant access for an existing participant: creates the + * login account if needed, links it to the participant, and (re)activates + * the tenant membership with the given role. The participant's name/email + * stay the notification identity regardless of login state. + * + * @return array{ok: bool, errors: list, token?: string, user_email?: string} + */ +function saas_grant_participant_access(PDO $pdo, int $tenantId, int $participantId, string $role): array +{ + if (!in_array($role, saas_grantable_roles(), true)) { + return ['ok' => false, 'errors' => ['Ungültige Rolle.']]; + } + + $stmt = $pdo->prepare( + 'SELECT id, display_name, email, email_norm + FROM participants + WHERE id = ? AND tenant_id = ?' + ); + $stmt->execute([$participantId, $tenantId]); + $participant = $stmt->fetch(); + if ($participant === false) { + return ['ok' => false, 'errors' => ['Teilnehmer wurde nicht gefunden.']]; + } + + $emailNorm = (string)($participant['email_norm'] ?? ''); + if ($emailNorm === '' || !filter_var($participant['email'], FILTER_VALIDATE_EMAIL)) { + return ['ok' => false, 'errors' => ['Für den Zugang wird eine gültige E-Mail-Adresse benötigt.']]; + } + + try { + $pdo->beginTransaction(); + + $stmt = $pdo->prepare('SELECT id, status FROM users WHERE email_norm = ? LIMIT 1'); + $stmt->execute([$emailNorm]); + $user = $stmt->fetch(); + + if ($user === false) { + $stmt = $pdo->prepare( + 'INSERT INTO users (email, email_norm, display_name, status) VALUES (?, ?, ?, ?)' + ); + $stmt->execute([$participant['email'], $emailNorm, $participant['display_name'], 'active']); + $userId = (int)$pdo->lastInsertId(); + } elseif ((string)$user['status'] !== 'active') { + $pdo->rollBack(); + + return ['ok' => false, 'errors' => ['Dieses Benutzerkonto ist nicht aktiv.']]; + } else { + $userId = (int)$user['id']; + } + + $stmt = $pdo->prepare( + 'INSERT INTO tenant_memberships (tenant_id, user_id, role, status, invited_at, joined_at) + VALUES (?, ?, ?, ?, NOW(), NOW()) + ON DUPLICATE KEY UPDATE + role = VALUES(role), + status = VALUES(status), + invited_at = COALESCE(tenant_memberships.invited_at, VALUES(invited_at)), + joined_at = COALESCE(tenant_memberships.joined_at, VALUES(joined_at))' + ); + $stmt->execute([$tenantId, $userId, $role, 'active']); + + $pdo->prepare('UPDATE participants SET user_id = ? WHERE id = ? AND tenant_id = ?') + ->execute([$userId, $participantId, $tenantId]); + + $pdo->commit(); + } catch (Throwable $e) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + + return ['ok' => false, 'errors' => ['Der Zugang konnte nicht angelegt werden.']]; + } + + $token = saas_create_auth_token($pdo, $userId, 'password_reset', $tenantId, 1440); + + return [ + 'ok' => true, + 'errors' => [], + 'token' => $token['token'], + 'user_email' => (string)$participant['email'], + ]; +} + +/** + * Revokes tenant access without touching the participant's name/email or + * deleting the membership row, so the grant can be reinstated later and the + * history stays auditable. Owners cannot be revoked here. + * + * @return array{ok: bool, errors: list} + */ +function saas_revoke_participant_access(PDO $pdo, int $tenantId, int $participantId): array +{ + $stmt = $pdo->prepare('SELECT user_id FROM participants WHERE id = ? AND tenant_id = ?'); + $stmt->execute([$participantId, $tenantId]); + $participant = $stmt->fetch(); + if ($participant === false || $participant['user_id'] === null) { + return ['ok' => true, 'errors' => []]; + } + + $userId = (int)$participant['user_id']; + + $stmt = $pdo->prepare('SELECT role FROM tenant_memberships WHERE tenant_id = ? AND user_id = ?'); + $stmt->execute([$tenantId, $userId]); + $membership = $stmt->fetch(); + if ($membership !== false && (string)$membership['role'] === 'owner') { + return ['ok' => false, 'errors' => ['Dem Inhaber kann der Zugang nicht entzogen werden.']]; + } + + $pdo->prepare("UPDATE tenant_memberships SET status = 'revoked' WHERE tenant_id = ? AND user_id = ?") + ->execute([$tenantId, $userId]); + + return ['ok' => true, 'errors' => []]; +} + function saas_register_tenant_owner(PDO $pdo, array $input): array { $tenantName = trim((string)($input['tenant_name'] ?? '')); diff --git a/app/saas-mail.php b/app/saas-mail.php index 90aeaac..4080471 100644 --- a/app/saas-mail.php +++ b/app/saas-mail.php @@ -131,6 +131,26 @@ function saas_send_password_reset_mail(string $to, string $token): array return saas_send_mail($to, 'Kaffeeliste Passwort zurücksetzen', $body); } +function saas_send_invite_mail(string $to, string $tenantName, string $role, string $token): array +{ + $link = saas_app_url('passwort-zuruecksetzen.php?token=' . urlencode($token)); + $body = implode("\n", [ + 'Hallo,', + '', + "du wurdest für \"{$tenantName}\" in der Kaffeeliste als {$role} eingeladen.", + 'Öffne diesen Link, um ein Passwort zu setzen und dich anzumelden:', + '', + $link, + '', + 'Der Link ist zeitlich begrenzt und kann nur einmal verwendet werden.', + 'Falls du diese Einladung nicht erwartet hast, kannst du diese Nachricht ignorieren.', + '', + 'Deine Kaffeeliste', + ]); + + return saas_send_mail($to, 'Einladung zur Kaffeeliste', $body); +} + function saas_send_email_verification_mail(string $to, string $token): array { $link = saas_app_url('email-verifizieren.php?token=' . urlencode($token)); diff --git a/database/migrations/0007_saas_notices.sql b/database/migrations/0007_saas_notices.sql new file mode 100644 index 0000000..45a9328 --- /dev/null +++ b/database/migrations/0007_saas_notices.sql @@ -0,0 +1,29 @@ +CREATE TABLE IF NOT EXISTS notices ( + id INT AUTO_INCREMENT PRIMARY KEY, + tenant_id INT NOT NULL, + message TEXT NOT NULL, + valid_from DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + valid_until DATETIME NOT NULL, + created_by_user_id INT NULL, + deleted_at DATETIME NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_notices_tenant_valid_until (tenant_id, valid_until), + KEY idx_notices_created_by_user (created_by_user_id), + CONSTRAINT fk_notices_tenant + FOREIGN KEY (tenant_id) REFERENCES tenants(id) + ON DELETE CASCADE, + CONSTRAINT fk_notices_created_by_user + FOREIGN KEY (created_by_user_id) REFERENCES users(id) + ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- One-time carry-over so currently visible legacy banners are not lost when +-- hinweise.php switches from kl_hinweise to the tenant-scoped notices table. +-- kl_hinweise itself is left untouched; it stays as the golden-master +-- reference for the legacy system. +INSERT INTO notices (tenant_id, message, valid_from, valid_until) +SELECT t.id, h.nachricht, NOW(), h.gueltig_bis +FROM kl_hinweise h +JOIN tenants t ON t.slug = 'default' +WHERE h.gueltig_bis >= NOW(); diff --git a/docs/m5-app-kern.md b/docs/m5-app-kern.md index e31e671..5be5260 100644 --- a/docs/m5-app-kern.md +++ b/docs/m5-app-kern.md @@ -167,13 +167,98 @@ Bewusst nicht umgesetzt: Admin-Rollen für neue SaaS-Nutzer laufen bis dahin weiter über `scripts/backfill-default-tenant.php` oder die Registrierung. +## Fortsetzung: Hinweise und rollenbasierter Zugang + +Umgesetzte Dateien: + +```text +database/migrations/0007_saas_notices.sql +app/notices.php +hinweise.php +header.php +mitarbeiterverwalten.php (grundlegend neu) +app/ledger.php (Participant-CRUD) +app/saas-auth.php (Zugangsvergabe/-entzug) +app/saas-mail.php (Einladungsmail) +``` + +### Hinweise auf Notices umgezogen + +- Neue Tabelle `notices` (tenant-scoped, mit `deleted_at` für Soft-Delete) + ersetzt `kl_hinweise` als aktive Datenquelle. `kl_hinweise` bleibt + unangetastet als Golden-Master-Referenz. +- Die Migration übernimmt einmalig alle zum Migrationszeitpunkt noch + gültigen `kl_hinweise`-Einträge in `notices` des Default-Mandanten, damit + keine sichtbaren Banner verloren gehen. +- `hinweise.php` liest/schreibt jetzt ausschließlich `notices`, tenant-scoped + mit dem gleichen Rollen-/Legacy-Fallback-Muster wie andere Admin-Seiten. + Löschen ist ein Soft-Delete (`deleted_at`), kein Hard-Delete. +- `header.php` (Banner-Anzeige auf allen App-Seiten) löst den Mandanten + jetzt selbst auf (SaaS-Session oder Default-Tenant-Fallback) und zeigt den + aktuell gültigen Hinweis dieses Mandanten statt eines global-legacy + Hinweises. + +### Mitgliederverwaltung: participant-nativ mit Rollen-Zugang + +`mitarbeiterverwalten.php` wurde grundlegend umgebaut, nicht mehr additiv +gepatcht: + +- Datenquelle ist jetzt `participants` (tenant-scoped), nicht mehr die + global unscoped `kl_Mitarbeiter`-Tabelle. Das behebt nebenbei ein + Datenleck: Da `kl_Mitarbeiter` keine `tenant_id` hat, hätte jeder + SaaS-Mandant mit Owner/Admin-Rolle über die vorherige Version dieser + Seite die komplette Mitgliederliste des Default-Mandanten sehen und + bearbeiten können. +- Für den Default-Mandanten wird weiterhin dual-write nach + `kl_Mitarbeiter` betrieben (`ledger_create_participant()`, + `ledger_update_participant()`, `ledger_set_participant_active()`), damit + `stricheintragen.php` und `einzahlung.php` (deren Mitarbeiter-Picker + weiterhin direkt aus `kl_Mitarbeiter` liest) neue Mitglieder sofort + anzeigen. Andere Mandanten haben keine Legacy-Schattentabelle und werden + rein participant-nativ verwaltet. +- Die Legacy-„Administrator"-Checkbox ist aus dem Anlegen-/Bearbeiten- + Formular entfernt. Stattdessen gibt es je Mitglied eine eigene + „Zugang"-Spalte: Rolle wählen (`member`, `treasurer`, `admin`) und + „Zugang gewähren", oder bei bestehendem Zugang Rolle ändern beziehungsweise + „Zugang entziehen". `owner` ist über diese UI nicht vergebbar oder + entziehbar (nur bei Registrierung gesetzt). +- Name und E-Mail eines Mitglieds (`participants.display_name`/`email`) + bleiben unabhängig vom Login: Ein Mitglied kann ohne jeden Zugang + existieren (nur für Kaffeeliste/Benachrichtigung), und ein Zugang kann + jederzeit gewährt oder entzogen werden, ohne den Mitgliedsdatensatz zu + berühren. +- Zugangsvergabe (`saas_grant_participant_access()`) legt bei Bedarf einen + `users`-Datensatz ohne Passwort an, setzt/aktualisiert die + `tenant_memberships`-Rolle und verknüpft `participants.user_id`. + Anschließend wird ein Einladungslink über den bestehenden + Passwort-Reset-Mechanismus verschickt (`saas_send_invite_mail()`, gleicher + Token-Typ `password_reset`, gleiche Zielseite + `passwort-zuruecksetzen.php` wie beim regulären Passwort-Reset). +- Zugangsentzug (`saas_revoke_participant_access()`) setzt die + `tenant_memberships`-Zeile auf `status = 'revoked'`, statt sie zu löschen + oder den `user_id`-Verweis zu entfernen. Der Zugang kann später erneut + gewährt werden, ohne den Account neu anzulegen. Der Login prüft bereits + überall auf `tm.status = 'active'`, wodurch ein entzogener Zugang sofort + wirkt. +- Live gegen die Dev-Datenbank getestet: Mitglied anlegen (inklusive + Dual-Write-Check), Zugang mit Rolle `treasurer` gewähren, Einladungsmail + geprüft, Passwort über den Einladungslink gesetzt, erfolgreicher Login, + Rollenschutz geprüft (kein Zugriff auf `mitarbeiterverwalten.php`, Zugriff + auf `kaffeeliste.php`), Zugang entzogen, anschließender Login-Versuch + korrekt mit „kein aktiver Mandant" abgelehnt. + ## Noch offen - Eigene PayPal-/Zahlungsbereich als eigenständiger App-Screen (aktuell nur im Dashboard integriert). -- Einladungs-Flow, um bestehenden Teilnehmern nachträglich einen - Login-Account mit `tenant_memberships`-Rolle zuzuweisen. -- Hinweise als tenant-spezifische Notices umsetzen. +- `stricheintragen.php` und `einzahlung.php` lesen ihre Mitarbeiter-Picker + weiterhin aus der global unscoped `kl_Mitarbeiter`-Tabelle. Für den + Default-Mandanten funktioniert das unverändert; für jeden anderen Mandanten + ist die Liste faktisch leer beziehungsweise zeigt (nur lesend, Schreiben + schlägt dank Tenant-Scope in `ledger_mirror_legacy_*` sicher fehl) die + Namen der Default-Mandanten-Mitglieder an. Das ist ein bestehendes, + eigenständiges Scope-Thema für eine spätere Iteration, keine Regression + dieser Session. - Export, Mail und Jahresprozesse bleiben M6-Themen. ## Aktueller Prüfstatus @@ -187,3 +272,7 @@ Bewusst nicht umgesetzt: - Live-Test der Mitgliederverwaltung gegen die Dev-DB: Anlegen (inklusive XSS-Payload-Check), Deaktivieren, participants-Spiegelung erfolgreich geprüft. +- Live-Test Hinweise/Notices: Carry-over-Migration, Anlegen mit HTML-Payload + (korrekt escaped), Soft-Delete, Banner-Anzeige auf Mandant geprüft. +- Live-Test Zugangsvergabe/-entzug: kompletter Flow von Einladung bis + Login-Sperre nach Entzug erfolgreich geprüft (siehe oben). diff --git a/docs/saas-umstrukturierungsplan.md b/docs/saas-umstrukturierungsplan.md index 181f857..21452d0 100644 --- a/docs/saas-umstrukturierungsplan.md +++ b/docs/saas-umstrukturierungsplan.md @@ -305,7 +305,7 @@ Nicht tun: | M2 | Technisches Fundament | Abgeschlossen: Migrationen, Bootstrap, Session, CSRF-Helper und Legacy-Schreibseitenschutz stehen | | M3 | SaaS-Basis | Abgeschlossen: Tenants, User, Registrierung, Login, Rollen, Mail-Links und zentrale Mandantenauswahl funktionieren | | M4 | Datenmigration | Gestartet: Ledger-Tabelle, Legacy-Backfill, Paritätscheck, Ledger-Service und Preview sind umgesetzt | -| M5 | App-Kern | Weit fortgeschritten: Kernseiten lesen und schreiben tenant-sicher gegen das Ledger; offen sind Hinweise und ein eigener Zahlungs-Screen | +| M5 | App-Kern | Weit fortgeschritten: Kernseiten lesen und schreiben tenant-sicher gegen das Ledger, inklusive Hinweise und rollenbasiertem Zugang; offen ist ein eigener Zahlungs-Screen | | M6 | Betriebsflows | Import, Export, Mail und Jahresprozesse sind auditierbar | | M7 | Landingpage | Public-Seite und Auth-Seiten sind im gemeinsamen Stil nutzbar; spätere Ausbaustufen folgen | | M8 | Härtung | Betrieb, Datenschutz, Monitoring und Isolation sind geprüft | @@ -515,9 +515,15 @@ Schritte: `index.php`. Ein eigener `/app/einzahlungen`-Screen wie ursprünglich in der Zielarchitektur skizziert existiert nicht separat; die Funktion ist bewusst im Dashboard gebündelt statt als eigene Route. -- Mitgliederverwaltung tenant- und rollenbasiert umsetzen: erledigt in - `mitarbeiterverwalten.php`, inklusive Spiegelung nach `participants` und - Behebung einer gespeicherten XSS-Lücke bei Name/E-Mail. +- Mitgliederverwaltung tenant- und rollenbasiert umsetzen: erledigt. + `mitarbeiterverwalten.php` verwaltet jetzt `participants` als primäre, + tenant-scoped Quelle (nicht mehr die global unscoped `kl_Mitarbeiter`- + Tabelle) und erlaubt Admins, Mitgliedern unabhängig von Name/E-Mail einen + Login-Zugang mit Rolle zu gewähren oder zu entziehen (Einladung per Mail + über den bestehenden Passwort-Reset-Mechanismus, Entzug als Statuswechsel + statt Delete). Nebenbei behoben: eine gespeicherte XSS-Lücke bei Name/ + E-Mail und ein Mandanten-Datenleck, weil die alte Version jedem + SaaS-Mandanten die komplette Default-Mandanten-Mitgliederliste zeigte. - Gesamtübersicht umsetzen: erster read-only Stand in `kaffeeliste.php` erledigt. - Teilnehmerauswertung umsetzen: read-only Stand in `teilnehmerauswertung.php` @@ -529,8 +535,10 @@ Schritte: - Sammelerfassung (`stricheintragen.php`, `einzahlung.php`) tenant-sicher absichern und ans Ledger anbinden: erledigt. Beide Seiten hatten zuvor keine Zugriffskontrolle außer CSRF; das ist behoben. -- Hinweise als tenant-spezifische Notices umsetzen: offen, `hinweise.php` - ist noch vollständig Legacy ohne Tenant-Bezug. +- Hinweise als tenant-spezifische Notices umsetzen: erledigt. Neue Tabelle + `notices` mit Soft-Delete, `hinweise.php` und die Banner-Anzeige in + `header.php` sind tenant-scoped umgestellt; `kl_hinweise` bleibt nur noch + als Golden-Master-Referenz bestehen. Ergebnis: @@ -717,7 +725,9 @@ UX-Prüfungen: werden? - Wie sollen Kunden-Tenants adressiert werden: Subdomain, Pfad oder Auswahl nach Login? -- Welche Rolle soll `treasurer` haben: eigene Rolle oder Teil von `admin`? +- ~~Welche Rolle soll `treasurer` haben: eigene Rolle oder Teil von `admin`?~~ + Entschieden mit der M5-Zugangsvergabe: `treasurer` ist eine eigene, + über `mitarbeiterverwalten.php` vergebbare Rolle, getrennt von `admin`. - Wird das Umfrage-Modul Teil des SaaS-Produkts oder nur archiviert? - Braucht der MVP schon Tarife/Billing oder erstmal nur Registrierung? - Sollen bestehende Kunden per Einladung oder per Self-Service onboarden? diff --git a/header.php b/header.php index d4bd0cb..ca276ec 100644 --- a/header.php +++ b/header.php @@ -14,19 +14,31 @@ 1"; -// Aktuelle Hinweise abrufen -$sql = "SELECT nachricht FROM kl_hinweise WHERE gueltig_bis >= SYSDATETIME() ORDER BY gueltig_bis ASC"; -$stmt = sqlsrv_query($conn, $sql); +require_once __DIR__ . '/app/ledger.php'; +require_once __DIR__ . '/app/notices.php'; -if ($stmt === false) { - die(print_r(sqlsrv_errors(), true)); +// Aktuellen Hinweis fuer den passenden Mandanten abrufen: eingeloggte +// SaaS-Nutzer sehen den Hinweis ihres Mandanten, der Legacy-/Dev-Fallback +// zeigt den Hinweis des Default-Mandanten. +$headerPdo = app_db_pdo(); +$headerSaasUser = saas_current_user($headerPdo); +$headerNoticeTenantId = 0; +if ($headerSaasUser !== null) { + $headerNoticeTenantId = (int)$headerSaasUser['tenant_id']; +} else { + $headerDefaultTenant = ledger_fetch_default_tenant($headerPdo); + if ($headerDefaultTenant !== null) { + $headerNoticeTenantId = (int)$headerDefaultTenant['id']; + } } -if ($row = sqlsrv_fetch_array($stmt, SQLSRV_FETCH_ASSOC)) { - echo "
" - . htmlspecialchars($row['nachricht']) . - "
"; +if ($headerNoticeTenantId > 0) { + $headerNotice = notices_fetch_active($headerPdo, $headerNoticeTenantId); + if ($headerNotice !== null) { + echo "
" + . saas_html($headerNotice['message']) . + "
"; + } } ?> diff --git a/hinweise.php b/hinweise.php index 64b578a..37578ce 100644 --- a/hinweise.php +++ b/hinweise.php @@ -1,6 +1,8 @@ Kaffeeliste - Hinweise"; - // Hinweis speichern oder löschen + // Hinweis speichern oder als geloescht markieren if ($_SERVER['REQUEST_METHOD'] === 'POST') { $aktion = $_POST['aktion'] ?? 'speichern'; if ($aktion === 'loeschen') { $id = (int)($_POST['id'] ?? 0); if ($id > 0) { - $stmt = sqlsrv_query($conn, "DELETE FROM kl_hinweise WHERE id = ?", [$id]); + notices_soft_delete($pdo, $tenantId, $id); } } else { - $nachricht = $_POST['nachricht']; - $gueltig_bis = $_POST['gueltig_bis']; // z.B. "2025-09-03T14:00" + $nachricht = $_POST['nachricht'] ?? ''; + $gueltig_bis = $_POST['gueltig_bis'] ?? ''; // z.B. "2025-09-03T14:00" $dt = DateTime::createFromFormat('Y-m-d\TH:i', $gueltig_bis); if ($dt) { $gueltig_bis_sql = $dt->format('Y-m-d H:i:s'); // z.B. "2025-09-03 14:00:00" - } else { - die("Ungültiges Datumsformat"); - } - - if (!empty($nachricht) && !empty($gueltig_bis_sql)) { - - $stmt = sqlsrv_query($conn, - "INSERT INTO kl_hinweise (nachricht, gueltig_bis) VALUES (?, ?)", - [$nachricht, $gueltig_bis_sql] - ); - + $createdByUserId = $saasUser !== null ? (int)$saasUser['user_id'] : null; + notices_create($pdo, $tenantId, (string)$nachricht, $gueltig_bis_sql, $createdByUserId); } } } - // Hinweise abrufen - $hinweise = []; - $stmt = sqlsrv_query($conn, "SELECT id, nachricht, gueltig_bis FROM kl_hinweise ORDER BY gueltig_bis DESC"); - while ($row = sqlsrv_fetch_array($stmt, SQLSRV_FETCH_ASSOC)) { - $hinweise[] = $row; - } + $hinweise = notices_fetch_all($pdo, $tenantId); ?> @@ -75,11 +81,11 @@ if(checkKaffeelisteAdmin($conn, $mailadress)){

Alle Hinweise

-
- Gültig bis: format('d.m.Y H:i') ?>
+
+ Gültig bis: format('d.m.Y H:i')); ?>
" onsubmit="return confirm('Diesen Hinweis wirklich löschen?')"> - +
@@ -99,6 +105,6 @@ if(checkKaffeelisteAdmin($conn, $mailadress)){
- diff --git a/mitarbeiterverwalten.php b/mitarbeiterverwalten.php index cca06fa..0476d1c 100644 --- a/mitarbeiterverwalten.php +++ b/mitarbeiterverwalten.php @@ -3,6 +3,7 @@ include "functions.php"; require_once __DIR__ . "/app/ledger.php"; +require_once __DIR__ . "/app/saas-mail.php"; app_require_csrf(); include "header.php"; include "headerline.php"; @@ -38,134 +39,96 @@ if (!$hasAccess && $saasUser === null && checkKaffeelisteAdmin($conn, $mailadres if($hasAccess){ + $meldung = null; + $fehler = null; + $einladungslink = null; + $bearbeitenId = null; - // Funktion zum Anlegen, Bearbeiten und Deaktivieren von Mitgliedern. Der - // gespiegelte participants-Datensatz wird in derselben Transaktion - // nachgezogen, damit Ledger-Ansichten sofort den neuen Stand zeigen. - function bearbeiteMitglied($aktion, $mitgliedID, $name, $email, $paypalname, $aktiv, $admin, PDO $pdo, int $tenantId) { - try { - if ($aktion === 'anlegen') { - $sql = "INSERT INTO kl_Mitarbeiter (Name, Email, paypalname, aktiv, admin) VALUES (?, ?, ?, ?, ?)"; - $params = array($name, $email, $paypalname, $aktiv, $admin); - } elseif ($aktion === 'bearbeitenspeichern') { - $sql = "UPDATE kl_Mitarbeiter SET Name = ?, Email = ?, paypalname = ?, aktiv = ?, admin = ? WHERE MitarbeiterID = ?"; - $params = array($name, $email, $paypalname, $aktiv, $admin, $mitgliedID); - } elseif ($aktion === 'aktivieren') { - $sql = "UPDATE kl_Mitarbeiter SET aktiv = 1 WHERE MitarbeiterID = ?"; - $params = array($mitgliedID); - } elseif ($aktion === 'deaktivieren') { - $sql = "UPDATE kl_Mitarbeiter SET aktiv = 0 WHERE MitarbeiterID = ?"; - $params = array($mitgliedID); + if ($_SERVER["REQUEST_METHOD"] == "POST") { + $aktion = $_POST["aktion"] ?? ''; + + if ($aktion === 'bearbeiten') { + $bearbeitenId = (int)$_POST["mitgliedID"]; + } elseif ($aktion === 'anlegen') { + $name = trim((string)($_POST["name"] ?? '')); + $email = trim((string)($_POST["email"] ?? '')); + $paypalname = trim((string)($_POST["paypalname"] ?? '')); + $aktiv = isset($_POST["aktiv"]); + + if ($name === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) { + $fehler = 'Bitte einen Namen und eine gültige E-Mail-Adresse angeben.'; } else { - throw new Exception("Ungültige Aktion."); + try { + ledger_create_participant($pdo, $tenantId, $name, $email, $paypalname, $aktiv); + $meldung = 'Mitglied wurde angelegt.'; + } catch (Throwable $e) { + $fehler = 'Das Mitglied konnte nicht angelegt werden (E-Mail eventuell schon vergeben).'; + } } + } elseif ($aktion === 'bearbeitenspeichern') { + $participantId = (int)$_POST["mitgliedID"]; + $name = trim((string)($_POST["name"] ?? '')); + $email = trim((string)($_POST["email"] ?? '')); + $paypalname = trim((string)($_POST["paypalname"] ?? '')); + $aktiv = isset($_POST["aktiv"]); - $pdo->beginTransaction(); - - $stmt = $pdo->prepare($sql); - $stmt->execute($params); - - $legacyMitarbeiterId = $aktion === 'anlegen' ? (int)$pdo->lastInsertId() : (int)$mitgliedID; - ledger_mirror_legacy_participant($pdo, $tenantId, $legacyMitarbeiterId); - - $pdo->commit(); - - return true; // Erfolgreich - } catch (Throwable $e) { - if ($pdo->inTransaction()) { - $pdo->rollBack(); + if ($name === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) { + $fehler = 'Bitte einen Namen und eine gültige E-Mail-Adresse angeben.'; + } elseif (ledger_update_participant($pdo, $tenantId, $participantId, $name, $email, $paypalname, $aktiv)) { + $meldung = 'Mitglied wurde gespeichert.'; + } else { + $fehler = 'Das Mitglied konnte nicht gespeichert werden.'; } + } elseif ($aktion === 'aktivieren' || $aktion === 'deaktivieren') { + $participantId = (int)$_POST["mitgliedID"]; + if (ledger_set_participant_active($pdo, $tenantId, $participantId, $aktion === 'aktivieren')) { + $meldung = $aktion === 'aktivieren' ? 'Mitglied wurde aktiviert.' : 'Mitglied wurde deaktiviert.'; + } else { + $fehler = 'Der Status konnte nicht geändert werden.'; + } + } elseif ($aktion === 'zugang_gewaehren') { + $participantId = (int)$_POST["mitgliedID"]; + $rolle = (string)($_POST["rolle"] ?? ''); - return 'Die Aktion konnte nicht gespeichert werden.'; + $ergebnis = saas_grant_participant_access($pdo, $tenantId, $participantId, $rolle); + if ($ergebnis['ok']) { + $einladendeTenantSettings = saas_fetch_tenant_settings($pdo, $tenantId); + $mailErgebnis = saas_send_invite_mail( + $ergebnis['user_email'], + $einladendeTenantSettings['name'] ?? 'Kaffeeliste', + $rolle, + $ergebnis['token'] + ); + $meldung = 'Zugang wurde gewährt, Einladung wurde verschickt.'; + if (saas_should_show_auth_links()) { + $einladungslink = saas_app_url('passwort-zuruecksetzen.php?token=' . urlencode($ergebnis['token'])); + } + } else { + $fehler = implode(' ', $ergebnis['errors']); + } + } elseif ($aktion === 'zugang_entziehen') { + $participantId = (int)$_POST["mitgliedID"]; + + $ergebnis = saas_revoke_participant_access($pdo, $tenantId, $participantId); + if ($ergebnis['ok']) { + $meldung = 'Zugang wurde entzogen.'; + } else { + $fehler = implode(' ', $ergebnis['errors']); + } } - }?> - - - prepare("SELECT * FROM kl_Mitarbeiter WHERE MitarbeiterID = ?"); - $stmtEinzelmitglied->execute([$mitgliedID]); - $einzelmitglied = $stmtEinzelmitglied->fetch(); - ?> -

Bearbeiten von

-
"> - - - - - - - - - - - - -
-
- > - - -
-
- > - - -
- -
- 'Inhaber', 'admin' => 'Administrator', 'treasurer' => 'Kassenwart', 'member' => 'Mitglied', 'viewer' => 'Betrachter']; + $bearbeitenMitglied = null; + if ($bearbeitenId !== null) { + foreach ($mitglieder as $m) { + if ($m['participant_id'] === $bearbeitenId) { + $bearbeitenMitglied = $m; + break; } } - }else{ - - - // Mitglieder aus der Datenbank abrufen - $stmtMitglieder = $pdo->query("SELECT MitarbeiterID, Name, Email, paypalname, aktiv, admin FROM kl_Mitarbeiter ORDER BY Name"); + } ?> @@ -179,123 +142,166 @@ if($hasAccess){

Mitglieder verwalten

- -
"> + +

+ + +

+ + +

Dev-Link zur Einladung:

+ + + +

Bearbeiten von

+ "> + + + + + + + + + + + + +
+
+ > + +
+ +
+ + + +

Neues Mitglied anlegen

+
"> - - - - - - - - + + + + + + + +
- - - -
-
- - - + +
+

Name und E-Mail dienen der Kaffeeliste und Benachrichtigungen. Ein Login-Zugang + ist davon unabhängig und wird separat je Mitglied gewährt oder entzogen.

- - + - fetch()) { - $mitgliedIdRow = (int)$row['MitarbeiterID']; - - echo ''; - echo ""; - echo ""; - echo ""; - echo ""; - echo ""; - echo ""; - echo ""; - echo ""; - - } - ?> + + + + + + + + + +
Mitglied ID Name E-Mail PayPal-Name AktivAdministratorZugang Aktionen
{$mitgliedIdRow}" . saas_html($row['Name']) . "" . saas_html($row['Email']) . "" . saas_html($row['paypalname'] ?? '') . "{$row['aktiv']}{$row['admin']}"; - echo '
    -
  • '; - - echo "
    "; - echo ""; - echo ""; - echo app_csrf_field(); - echo ""; - echo "
  • "; - - - if ($row['aktiv'] == 1) { - echo "
    "; - echo ""; - echo ""; - echo app_csrf_field(); - echo ""; - echo "
    "; - } else { - echo "
    "; - echo ""; - echo ""; - echo app_csrf_field(); - echo ""; - echo "
    "; - } - echo "
+ + + Inhaber + + +
" style="display:inline"> + + + + + +
+
" style="display:inline"> + + + + +
+ + + + +
" style="display:inline"> + + + + + +
+ + +
+
    +
  • +
    "> + + + + +
    +
  • +
  • + +
    "> + + + + +
    + +
    "> + + + + +
    + +
  • +
+
- - - - - - - - + + Sie haben keine Zugang zu dieser Webseite"; } -## Auskommentierung -## - ?> - - - - - -