diff --git a/saas-app/public/admin.php b/saas-app/public/admin.php index f7654a2..c83a010 100644 --- a/saas-app/public/admin.php +++ b/saas-app/public/admin.php @@ -256,7 +256,7 @@ if (in_array($page, ['overview', 'tenants'], true) && $pdo instanceof PDO) {

Vorhandene Mandanten

- + @@ -266,7 +266,17 @@ if (in_array($page, ['overview', 'tenants'], true) && $pdo instanceof PDO) { - + diff --git a/saas-app/public/app-support.php b/saas-app/public/app-support.php index 6fdcc9a..44f6076 100644 --- a/saas-app/public/app-support.php +++ b/saas-app/public/app-support.php @@ -419,24 +419,394 @@ function app_handle_platform_tenant_action(PDO $pdo): void $action = (string) ($_POST['action'] ?? ''); - if ($action !== 'save-tenant') { + app_require_platform_admin(); + + if ($action === 'save-tenant') { + try { + app_upsert_tenant($pdo, [ + 'tenant_id' => (string) ($_POST['tenant_id'] ?? ''), + 'tenant_key' => (string) ($_POST['tenant_key'] ?? ''), + 'name' => (string) ($_POST['name'] ?? ''), + 'status' => (string) ($_POST['status'] ?? 'active'), + ]); + } catch (Throwable $exception) { + app_flash($exception->getMessage(), 'error'); + } + + app_redirect('/admin/tenants/'); + } + + if ($action === 'switch-tenant') { + $tenantId = trim((string) ($_POST['tenant_id'] ?? '')); + $admin = app_require_platform_admin(); + + try { + app_enter_tenant_as_platform_admin($pdo, $admin, $tenantId); + app_flash('Der Mandant wurde geöffnet. Du arbeitest jetzt mit erweitertem Global-Admin-Zugriff im Mandanten.', 'success'); + app_redirect('/dashboard/'); + } catch (Throwable $exception) { + app_flash($exception->getMessage(), 'error'); + app_redirect('/admin/tenants/'); + } + } +} + +function app_tenant_by_id(PDO $pdo, string $tenantId): ?array +{ + return app_query_one( + $pdo, + 'SELECT id, tenant_key, name, status FROM tenants WHERE id = :id LIMIT 1', + ['id' => $tenantId] + ); +} + +function app_enter_tenant_as_platform_admin(PDO $pdo, array $admin, string $tenantId): void +{ + if ($tenantId === '') { + throw new RuntimeException('Bitte wähle einen Mandanten aus.'); + } + + $tenant = app_tenant_by_id($pdo, $tenantId); + + if ($tenant === null) { + throw new RuntimeException('Der ausgewählte Mandant konnte nicht gefunden werden.'); + } + + app_set_auth_user([ + 'user_id' => $admin['user_id'], + 'email' => $admin['email'], + 'display_name' => $admin['display_name'], + 'is_platform_admin' => true, + 'tenant_id' => $tenant['id'], + 'tenant_key' => $tenant['tenant_key'], + 'tenant_name' => $tenant['name'], + 'tenant_user_id' => null, + 'member_id' => null, + 'roles' => ['tenant_admin'], + 'acting_as_platform_admin' => true, + ]); +} + +function app_user_by_email(PDO $pdo, string $email): ?array +{ + return app_query_one( + $pdo, + 'SELECT id, email, password_hash, display_name, is_platform_admin FROM users WHERE LOWER(email) = LOWER(:email) LIMIT 1', + ['email' => $email] + ); +} + +function app_tenant_user_by_id(PDO $pdo, string $tenantUserId, string $tenantId): ?array +{ + return app_query_one( + $pdo, + <<<'SQL' +SELECT + tu.id, + tu.tenant_id, + tu.user_id, + tu.status, + u.email, + u.display_name, + u.password_hash, + m.id AS member_id, + m.display_name AS member_display_name, + m.email AS member_email, + m.status AS member_status, + GROUP_CONCAT(DISTINCT r.role_key ORDER BY r.role_key SEPARATOR ',') AS role_keys +FROM tenant_users tu +INNER JOIN users u ON u.id = tu.user_id +LEFT JOIN members m ON m.tenant_user_id = tu.id +LEFT JOIN tenant_user_roles tur ON tur.tenant_user_id = tu.id +LEFT JOIN roles r ON r.id = tur.role_id +WHERE tu.id = :tenant_user_id + AND tu.tenant_id = :tenant_id +GROUP BY + tu.id, tu.tenant_id, tu.user_id, tu.status, + u.email, u.display_name, u.password_hash, + m.id, m.display_name, m.email, m.status +LIMIT 1 +SQL, + ['tenant_user_id' => $tenantUserId, 'tenant_id' => $tenantId] + ); +} + +function app_membership_row_for_user(PDO $pdo, string $tenantId, string $userId): ?array +{ + return app_query_one( + $pdo, + <<<'SQL' +SELECT + tu.id, + tu.tenant_id, + tu.user_id, + tu.status, + u.email, + u.display_name, + u.password_hash, + m.id AS member_id, + m.display_name AS member_display_name, + m.email AS member_email, + m.status AS member_status, + GROUP_CONCAT(DISTINCT r.role_key ORDER BY r.role_key SEPARATOR ',') AS role_keys +FROM tenant_users tu +INNER JOIN users u ON u.id = tu.user_id +LEFT JOIN members m ON m.tenant_user_id = tu.id +LEFT JOIN tenant_user_roles tur ON tur.tenant_user_id = tu.id +LEFT JOIN roles r ON r.id = tur.role_id +WHERE tu.tenant_id = :tenant_id + AND tu.user_id = :user_id +GROUP BY + tu.id, tu.tenant_id, tu.user_id, tu.status, + u.email, u.display_name, u.password_hash, + m.id, m.display_name, m.email, m.status +LIMIT 1 +SQL, + ['tenant_id' => $tenantId, 'user_id' => $userId] + ); +} + +function app_member_form_defaults(?array $member = null): array +{ + $roleKeys = array_filter(array_map('trim', explode(',', (string) ($member['role_keys'] ?? '')))); + + return [ + 'tenant_user_id' => (string) ($member['id'] ?? ''), + 'display_name' => (string) ($member['member_display_name'] ?? $member['display_name'] ?? ''), + 'email' => (string) ($member['member_email'] ?? $member['email'] ?? ''), + 'status' => (string) ($member['member_status'] ?? $member['status'] ?? 'active'), + 'password' => '', + 'is_tenant_admin' => in_array('tenant_admin', $roleKeys, true), + ]; +} + +function app_upsert_member(PDO $pdo, string $tenantId, array $data): void +{ + $tenantUserId = trim((string) ($data['tenant_user_id'] ?? '')); + $displayName = trim((string) ($data['display_name'] ?? '')); + $email = strtolower(trim((string) ($data['email'] ?? ''))); + $status = trim((string) ($data['status'] ?? 'active')); + $password = (string) ($data['password'] ?? ''); + $isTenantAdmin = !empty($data['is_tenant_admin']); + + if ($displayName === '') { + throw new RuntimeException('Bitte gib einen Namen für die Person an.'); + } + + if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) { + throw new RuntimeException('Bitte gib eine gültige E-Mail-Adresse an.'); + } + + if (!in_array($status, ['active', 'inactive'], true)) { + $status = 'active'; + } + + $tenant = app_tenant_by_id($pdo, $tenantId); + + if ($tenant === null) { + throw new RuntimeException('Der Mandant konnte nicht gefunden werden.'); + } + + $existingUser = app_user_by_email($pdo, $email); + $editingMembership = $tenantUserId !== '' ? app_tenant_user_by_id($pdo, $tenantUserId, $tenantId) : null; + + if ($editingMembership === null && $tenantUserId !== '') { + throw new RuntimeException('Die ausgewählte Person konnte im Mandanten nicht gefunden werden.'); + } + + if ($existingUser === null && $password === '') { + throw new RuntimeException('Für neue Personen ist ein Passwort erforderlich, damit die zentrale Anmeldung möglich ist.'); + } + + if ($existingUser !== null && $editingMembership === null) { + $otherTenantMembership = app_membership_row_for_user($pdo, $tenantId, (string) $existingUser['id']); + + if ($otherTenantMembership !== null) { + throw new RuntimeException('Diese E-Mail-Adresse ist im Mandanten bereits vorhanden.'); + } + } + + if ($editingMembership !== null && $existingUser !== null && (string) $existingUser['id'] !== (string) $editingMembership['user_id']) { + throw new RuntimeException('Diese E-Mail-Adresse gehört bereits zu einer anderen Person im Mandanten.'); + } + + scripts_ensure_core_roles($pdo); + $tenantAdminRoleId = scripts_role_id('tenant_admin', 'tenant'); + $now = date('Y-m-d H:i:s'); + + $pdo->beginTransaction(); + + try { + if ($editingMembership !== null) { + $userId = (string) $editingMembership['user_id']; + + app_execute( + $pdo, + 'UPDATE users SET email = :email, display_name = :display_name, updated_at = :updated_at' . ($password !== '' ? ', password_hash = :password_hash' : '') . ' WHERE id = :id', + array_filter([ + 'email' => $email, + 'display_name' => $displayName, + 'updated_at' => $now, + 'password_hash' => $password !== '' ? password_hash($password, PASSWORD_BCRYPT) : null, + 'id' => $userId, + ], static fn($value, $key): bool => $key !== 'password_hash' || $value !== null, ARRAY_FILTER_USE_BOTH) + ); + + app_execute( + $pdo, + 'UPDATE tenant_users SET status = :status, updated_at = :updated_at WHERE id = :id', + [ + 'status' => $status, + 'updated_at' => $now, + 'id' => $tenantUserId, + ] + ); + + app_execute( + $pdo, + 'UPDATE members SET display_name = :display_name, email = :email, status = :status, updated_at = :updated_at WHERE tenant_user_id = :tenant_user_id AND tenant_id = :tenant_id', + [ + 'display_name' => $displayName, + 'email' => $email, + 'status' => $status, + 'updated_at' => $now, + 'tenant_user_id' => $tenantUserId, + 'tenant_id' => $tenantId, + ] + ); + } else { + if ($existingUser !== null) { + $userId = (string) $existingUser['id']; + + app_execute( + $pdo, + 'UPDATE users SET display_name = :display_name, updated_at = :updated_at' . ($password !== '' ? ', password_hash = :password_hash' : '') . ' WHERE id = :id', + array_filter([ + 'display_name' => $displayName, + 'updated_at' => $now, + 'password_hash' => $password !== '' ? password_hash($password, PASSWORD_BCRYPT) : null, + 'id' => $userId, + ], static fn($value, $key): bool => $key !== 'password_hash' || $value !== null, ARRAY_FILTER_USE_BOTH) + ); + } else { + $userId = app_uuid(); + + app_execute( + $pdo, + 'INSERT INTO users (id, email, password_hash, display_name, is_platform_admin, created_at, updated_at) VALUES (:id, :email, :password_hash, :display_name, 0, :created_at, :updated_at)', + [ + 'id' => $userId, + 'email' => $email, + 'password_hash' => password_hash($password, PASSWORD_BCRYPT), + 'display_name' => $displayName, + 'created_at' => $now, + 'updated_at' => $now, + ] + ); + } + + $tenantUserId = app_uuid(); + + app_execute( + $pdo, + 'INSERT INTO tenant_users (id, tenant_id, user_id, status, created_at, updated_at) VALUES (:id, :tenant_id, :user_id, :status, :created_at, :updated_at)', + [ + 'id' => $tenantUserId, + 'tenant_id' => $tenantId, + 'user_id' => $userId, + 'status' => $status, + 'created_at' => $now, + 'updated_at' => $now, + ] + ); + + app_execute( + $pdo, + 'INSERT INTO members (id, tenant_id, tenant_user_id, display_name, email, status, created_at, updated_at) VALUES (:id, :tenant_id, :tenant_user_id, :display_name, :email, :status, :created_at, :updated_at)', + [ + 'id' => app_uuid(), + 'tenant_id' => $tenantId, + 'tenant_user_id' => $tenantUserId, + 'display_name' => $displayName, + 'email' => $email, + 'status' => $status, + 'created_at' => $now, + 'updated_at' => $now, + ] + ); + } + + $existingRoleAssignment = app_query_one( + $pdo, + 'SELECT id FROM tenant_user_roles WHERE tenant_user_id = :tenant_user_id AND role_id = :role_id LIMIT 1', + ['tenant_user_id' => $tenantUserId, 'role_id' => $tenantAdminRoleId] + ); + + if ($isTenantAdmin && $existingRoleAssignment === null) { + app_execute( + $pdo, + 'INSERT INTO tenant_user_roles (id, tenant_user_id, role_id, created_at) VALUES (:id, :tenant_user_id, :role_id, :created_at)', + [ + 'id' => app_uuid(), + 'tenant_user_id' => $tenantUserId, + 'role_id' => $tenantAdminRoleId, + 'created_at' => $now, + ] + ); + } + + if (!$isTenantAdmin && $existingRoleAssignment !== null) { + app_execute( + $pdo, + 'DELETE FROM tenant_user_roles WHERE id = :id', + ['id' => $existingRoleAssignment['id']] + ); + } + + $pdo->commit(); + } catch (Throwable $exception) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + + throw $exception; + } +} + +function app_handle_member_action(PDO $pdo, array $auth): void +{ + if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') { return; } - app_require_platform_admin(); + $action = (string) ($_POST['action'] ?? ''); + + if ($action !== 'save-member') { + return; + } + + if (!app_can_manage_tenant($auth)) { + app_flash('Für diese Aktion brauchst du Tenant-Admin-Rechte.', 'warning'); + app_redirect('/members/'); + } + + $tenantId = (string) ($auth['tenant_id'] ?? ''); try { - app_upsert_tenant($pdo, [ - 'tenant_id' => (string) ($_POST['tenant_id'] ?? ''), - 'tenant_key' => (string) ($_POST['tenant_key'] ?? ''), - 'name' => (string) ($_POST['name'] ?? ''), + app_upsert_member($pdo, $tenantId, [ + 'tenant_user_id' => (string) ($_POST['tenant_user_id'] ?? ''), + 'display_name' => (string) ($_POST['display_name'] ?? ''), + 'email' => (string) ($_POST['email'] ?? ''), 'status' => (string) ($_POST['status'] ?? 'active'), + 'password' => (string) ($_POST['password'] ?? ''), + 'is_tenant_admin' => isset($_POST['is_tenant_admin']), ]); + app_flash('Die Person wurde im Mandanten gespeichert.', 'success'); } catch (Throwable $exception) { app_flash($exception->getMessage(), 'error'); } - app_redirect('/admin/tenants/'); + app_redirect('/members/'); } function app_memberships_by_email(PDO $pdo, string $email): array @@ -827,18 +1197,21 @@ function app_members_for_tenant(PDO $pdo, string $tenantId): array $sql = <<<'SQL' SELECT m.id, + tu.id AS tenant_user_id, + tu.user_id, m.display_name, m.email, m.status, u.display_name AS user_display_name, - GROUP_CONCAT(DISTINCT r.name ORDER BY r.name SEPARATOR ', ') AS roles + GROUP_CONCAT(DISTINCT r.name ORDER BY r.name SEPARATOR ', ') AS roles, + GROUP_CONCAT(DISTINCT r.role_key ORDER BY r.role_key SEPARATOR ',') AS role_keys FROM members m LEFT JOIN tenant_users tu ON tu.id = m.tenant_user_id LEFT JOIN users u ON u.id = tu.user_id LEFT JOIN tenant_user_roles tur ON tur.tenant_user_id = tu.id LEFT JOIN roles r ON r.id = tur.role_id WHERE m.tenant_id = :tenant_id -GROUP BY m.id, m.display_name, m.email, m.status, u.display_name +GROUP BY m.id, tu.id, tu.user_id, m.display_name, m.email, m.status, u.display_name ORDER BY m.display_name ASC SQL; diff --git a/saas-app/public/index.php b/saas-app/public/index.php index c63ff32..6d310a6 100644 --- a/saas-app/public/index.php +++ b/saas-app/public/index.php @@ -80,6 +80,8 @@ $payments = []; $content = ['announcements' => [], 'faq' => []]; $memberSummary = null; $loginFlow = ['state' => app_login_state(), 'message' => null, 'error' => null]; +$editingMember = null; +$memberForm = app_member_form_defaults(); try { $pdo = app_pdo(); @@ -108,6 +110,10 @@ if ($auth !== null && $pdo instanceof PDO) { app_handle_tenant_action($pdo, $auth); } + if ($page === 'members') { + app_handle_member_action($pdo, $auth); + } + try { $tenantDashboard = app_tenant_dashboard($pdo, (string) $auth['tenant_id']); $members = app_members_for_tenant($pdo, (string) $auth['tenant_id']); @@ -124,6 +130,11 @@ if ($auth !== null && $pdo instanceof PDO) { if ($page === 'content') { $content = app_content_for_tenant($pdo, (string) $auth['tenant_id']); } + + if ($page === 'members' && isset($_GET['edit']) && $_GET['edit'] !== '') { + $editingMember = app_tenant_user_by_id($pdo, (string) $_GET['edit'], (string) $auth['tenant_id']); + $memberForm = app_member_form_defaults($editingMember); + } } catch (Throwable $exception) { $dbError = $exception->getMessage(); } @@ -164,6 +175,9 @@ $canManageTenant = app_can_manage_tenant($auth); Anmeldung + + Zentrale Verwaltung + DashboardHinweise @@ -340,6 +354,12 @@ $canManageTenant = app_can_manage_tenant($auth);
Tenant-Dashboard

auf einen Blick

Kontostand, Buchungen, Einzahlungen und die letzten Aktivitäten stehen direkt für diesen Tenant bereit.

+ +
+ Zur zentralen Verwaltung + Personen und Admins verwalten +
+
@@ -406,8 +426,67 @@ $canManageTenant = app_can_manage_tenant($auth);
-
Mitgliederverwaltung

Mitglieder, Rollen und Aktivstatus

Die zentrale Übersicht für Mitglieder, Rollen und den aktuellen Status innerhalb eines Tenants.

-
NameKeyStatusMitgliederAdminsSSOAktion
NameKeyStatusMitgliederAdminsSSOAktionen
Bearbeiten +
+ Bearbeiten +
+ + + + +
+
+
NameE-MailStatusBenutzerRollen
+
+
Mitgliederverwaltung
+

Personen, Admins und Zugänge im Mandanten verwalten

+

Hier legst du neue Personen an, gibst Zugänge frei und weist bei Bedarf die Rolle als Mandanten-Admin zu.

+ +
+ Zur zentralen Verwaltung + Mandanten-Dashboard +
+ +
+
+
+

+
+ + + + + + + +
+ + + Neue Person erfassen + +
+
+
+
+

Was der Global-Admin hier sehen kann

+ +
+
+
+
+ + + + + + + + + + + + + + +
NameE-MailStatusBenutzerRollenAktion
Bearbeiten
+
+
Buchungen

Striche und Buchungen

Kaffeeeinträge und alle zugehörigen Buchungen bleiben tenantweise nachvollziehbar.