diff --git a/README.md b/README.md index 80d85ef..282933e 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,11 @@ Archivbestand erhalten. Auf Webspace ohne Shell ist der bevorzugte Einstieg die gefuehrte Installation unter `saas-app/public/install/`. +Nach dem Setup gibt es jetzt zwei zentrale Web-Einstiege: + +- `saas-app/public/admin/login/` fuer den Global-Admin +- `saas-app/public/admin/migration/` fuer die webbasierte Legacy-Migration + ## Hilfsskripte - `scripts/check-prerequisites.php` prueft lokale Voraussetzungen. diff --git a/docs/installationshandbuch.md b/docs/installationshandbuch.md index 814df4f..a72ab70 100644 --- a/docs/installationshandbuch.md +++ b/docs/installationshandbuch.md @@ -16,6 +16,7 @@ Der Installer kann: - die `.env` speichern - das SQL-Bundle erzeugen - Migrationen direkt per PHP ausfuehren, wenn die zum Treiber passende PDO-Erweiterung verfuegbar ist +- den ersten Global-Admin direkt anlegen - sich nach erfolgreicher Einrichtung sperren Die CLI-Skripte unter `scripts/*.php` bleiben als Alternative fuer lokale @@ -91,8 +92,10 @@ eigene Umgebung angepasst werden: 3. Schreibrechte fuer `saas-app/`, `.env`, `.installer.lock` und `database/migrations/generated/` sicherstellen. 4. Sicherstellen, dass `open_basedir` nicht nur auf `public/` eingeschraenkt ist. PHP muss mindestens auf den kompletten Ordner `saas-app/` zugreifen duerfen, obwohl der Document-Root auf `public/` zeigt. 5. Den Installer unter `/install/` aufrufen und die Einrichtung durchfuehren. -6. Nach erfolgreicher Einrichtung den Installer sperren. -7. Die Anwendung einmal per Browser aufrufen und die Grundseiten pruefen. +6. Danach den Global-Admin unter `/admin/login` anmelden. +7. Bei Bedarf die Legacy-Datenmigration unter `/admin/migration` starten. +8. Nach erfolgreicher Einrichtung den Installer sperren. +9. Die Anwendung einmal per Browser aufrufen und die Grundseiten pruefen. ## MySQL Und MariaDB @@ -132,6 +135,12 @@ Reihenfolge: 4. Mandanten-Zuordnung pruefen. 5. Danach alte Root-Seiten nur noch lesend oder gar nicht mehr betreiben. +Der neue Webweg dafuer ist jetzt: + +- `/admin/login` fuer den Global-Admin +- `/admin/tenants` fuer die zentrale Tenant-Verwaltung +- `/admin/migration` fuer die Legacy-Datenmigration + ## Betriebscheck Nach dem Setup sollten diese Punkte funktionieren: diff --git a/saas-app/public/admin.php b/saas-app/public/admin.php new file mode 100644 index 0000000..ccee97b --- /dev/null +++ b/saas-app/public/admin.php @@ -0,0 +1,331 @@ +' . admin_h($label) . ''; +} + +$path = parse_url((string) ($_SERVER['REQUEST_URI'] ?? '/admin/login'), PHP_URL_PATH); +$path = is_string($path) ? rtrim($path, '/') : '/admin/login'; +$page = match ($path) { + '/admin', '/admin/' => 'tenants', + '/admin/login' => 'login', + '/admin/tenants' => 'tenants', + '/admin/migration' => 'migration', + '/admin/logout' => 'logout', + default => 'login', +}; + +$flash = app_flash(); +$admin = app_admin_user(); +$dbError = null; +$pdo = null; +$adminLogin = ['state' => app_admin_login_state(), 'message' => null, 'error' => null]; +$tenants = []; +$editingTenant = null; +$migrationResult = null; +$migrationErrors = []; +$env = app_env(); +$migrationValues = [ + 'SOURCE_CONNECTION' => 'sqlsrv', + 'SOURCE_HOST' => '127.0.0.1', + 'SOURCE_PORT' => '1433', + 'SOURCE_DATABASE' => '', + 'SOURCE_USERNAME' => '', + 'SOURCE_PASSWORD' => '', + 'TARGET_TENANT_KEY' => 'legacy-import', + 'TARGET_TENANT_NAME' => 'Legacy Import', +]; + +if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST' && $page === 'logout') { + if (hash_equals($_SESSION['admin_csrf'], (string) ($_POST['csrf'] ?? ''))) { + app_logout(); + app_flash('Global-Admin wurde abgemeldet.', 'success'); + } + + app_redirect('/admin/login'); +} + +try { + $pdo = app_pdo(); +} catch (Throwable $exception) { + $dbError = $exception->getMessage(); +} + +if ($page === 'login' && $pdo instanceof PDO) { + $adminLogin = app_handle_platform_admin_login($pdo); + $admin = app_admin_user(); +} + +if (in_array($page, ['tenants', 'migration'], true)) { + $admin = app_require_platform_admin(); + + if (!$pdo instanceof PDO) { + $dbError = $dbError ?? 'Die Zieldatenbank ist aktuell nicht erreichbar.'; + } +} + +if ($page === 'tenants' && $pdo instanceof PDO) { + if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST' && !hash_equals($_SESSION['admin_csrf'], (string) ($_POST['csrf'] ?? ''))) { + app_flash('CSRF-Status ungueltig. Bitte Seite neu laden.', 'error'); + app_redirect('/admin/tenants'); + } + + app_handle_platform_tenant_action($pdo); + $tenants = app_admin_tenant_list($pdo); + + if (isset($_GET['edit']) && $_GET['edit'] !== '') { + $editingTenant = app_query_one($pdo, 'SELECT id, tenant_key, name, status FROM tenants WHERE id = :id LIMIT 1', ['id' => (string) $_GET['edit']]); + } +} + +if ($page === 'migration') { + foreach (array_keys($migrationValues) as $key) { + if (isset($_POST[$key])) { + $migrationValues[$key] = trim((string) $_POST[$key]); + } + } + + if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST' && (string) ($_POST['action'] ?? '') === 'run-legacy-migration') { + if (!hash_equals($_SESSION['admin_csrf'], (string) ($_POST['csrf'] ?? ''))) { + $migrationErrors[] = 'CSRF-Status ungueltig. Bitte Seite neu laden.'; + } elseif (!$pdo instanceof PDO) { + $migrationErrors[] = $dbError ?? 'Die Zieldatenbank ist aktuell nicht erreichbar.'; + } else { + try { + $migrationResult = scripts_migrate_legacy_data( + [ + 'connection' => $migrationValues['SOURCE_CONNECTION'], + 'server' => $migrationValues['SOURCE_HOST'], + 'port' => $migrationValues['SOURCE_PORT'], + 'database' => $migrationValues['SOURCE_DATABASE'], + 'username' => $migrationValues['SOURCE_USERNAME'], + 'password' => $migrationValues['SOURCE_PASSWORD'], + ], + [ + 'connection' => $env['DB_CONNECTION'] ?? 'mysql', + 'server' => $env['DB_HOST'] ?? '127.0.0.1', + 'port' => $env['DB_PORT'] ?? '3306', + 'database' => $env['DB_DATABASE'] ?? '', + 'username' => $env['DB_USERNAME'] ?? '', + 'password' => $env['DB_PASSWORD'] ?? '', + ], + [ + 'tenant_key' => $migrationValues['TARGET_TENANT_KEY'], + 'tenant_name' => $migrationValues['TARGET_TENANT_NAME'], + ] + ); + } catch (Throwable $exception) { + $migrationErrors[] = $exception->getMessage(); + } + } + } +} + +?> + + + + + Kaffeeliste Admin + + + +
+
+
+ Kaffeeliste Admin +

Global-Admin Login, Tenant-Verwaltung und Legacy-Migration direkt ueber PHP-Webseiten.

+
+ +
+ + +
+ + + +
+
Global-Admin
+

Tenant-Verwaltung nur fuer die zentrale Administration.

+

Dieser Login ist bewusst vom Mitglieder-Login getrennt und fuehrt direkt in die globale Tenant-Verwaltung.

+
+
+
+
+
+

Anmelden

+
+ + + + +
+
+
+
+

Einrichtung

+
    +
  • Der erste Global-Admin wird im Installer angelegt.
  • +
  • Die Anmeldung prueft `users.is_platform_admin = 1` und `password_hash`.
  • +
  • Nach dem Login stehen Tenant-Verwaltung und Legacy-Migration bereit.
  • +
+
+
+ +
+
Tenant-Verwaltung
+

Mandanten zentral anlegen und pflegen

+

Hier verwaltet der Global-Admin die grundlegenden Tenant-Stammdaten und kommt direkt zur Datenmigration.

+
+
+
+
+

+
+ + + + + + +
+ + Zur Migration +
+
+
+
+

Globaler Kontext

+
+

Angemeldet als

+

Naechster Schritt

Nach dem Anlegen eines Tenants kann die Legacy-Datenmigration direkt ueber die Weboberflaeche gestartet werden.

+
+
+
+
+

Vorhandene Tenants

+
+ + + + + + + + + + + + + + + +
NameKeyStatusMitgliederAdminsSSOAktion
Bearbeiten
+
+
+ +
+
Legacy-Migration
+

Alte Kaffeelisten-Daten in das neue Zielformat uebernehmen

+

Die Seite verbindet sich mit der Quelldatenbank, legt bei Bedarf das neue Schema an und migriert Mitglieder, Striche, Einzahlungen und Hinweise in den ausgewaehlten Tenant.

+
+
+
+
+

Quelle konfigurieren

+
+ + + + + + + + + + +
+
+
+
+

Ziel

+
    +
  • Ziel-Datenbank:
  • +
  • Host:
  • +
  • Datenbank:
  • +
  • Die Migration ist idempotent fuer Mitglieder, Tenants und die importierten Kernbuchungen ausgelegt.
  • +
  • Fuer SQL Server als Quelle wird auf dem Hosting pdo_sqlsrv benoetigt. Ohne diesen Treiber sollte die Legacy-Datenbank zuerst nach MySQL/MariaDB exportiert werden.
  • +
+
+
+ + +
+ $value): ?> +
+

+

+
+ +
+
+
+

Ergebnis

+
    +
  • Schema neu angelegt:
  • +
  • Target Tenant: -
  • +
+
+
+

Erkannte Legacy-Tabellen

+
    + +
  • :
  • + +
+
+
+ +

Warnungen

+ + +

Nicht automatisch uebernommen

+ + + + + +
+ + diff --git a/saas-app/public/admin/index.php b/saas-app/public/admin/index.php new file mode 100644 index 0000000..a7ee0b1 --- /dev/null +++ b/saas-app/public/admin/index.php @@ -0,0 +1,5 @@ + $email] + ); +} + +function app_handle_platform_admin_login(PDO $pdo): array +{ + $message = null; + $error = null; + $state = app_admin_login_state(); + + if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') { + $action = (string) ($_POST['action'] ?? ''); + + if ($action === 'admin-login') { + $csrf = (string) ($_POST['csrf'] ?? ''); + $email = strtolower(trim((string) ($_POST['email'] ?? ''))); + $password = (string) ($_POST['password'] ?? ''); + $state = ['email' => $email]; + + if (!isset($_SESSION['admin_csrf']) || !hash_equals((string) $_SESSION['admin_csrf'], $csrf)) { + $error = 'CSRF-Status ungueltig. Bitte Seite neu laden.'; + } elseif ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) { + $error = 'Bitte eine gueltige E-Mail-Adresse angeben.'; + } elseif ($password === '') { + $error = 'Bitte das Passwort eingeben.'; + } else { + $admin = app_platform_admin_by_email($pdo, $email); + + if ($admin === null) { + $error = 'Zu dieser E-Mail-Adresse wurde kein Global-Admin gefunden.'; + } elseif (!app_verify_password((string) ($admin['password_hash'] ?? ''), $password)) { + $error = 'Das Passwort ist ungueltig oder fuer diesen Global-Admin noch nicht gesetzt.'; + } else { + app_set_admin_user([ + 'user_id' => $admin['id'], + 'email' => $admin['email'], + 'display_name' => $admin['display_name'], + 'is_platform_admin' => (int) ($admin['is_platform_admin'] ?? 0) === 1, + ]); + app_clear_admin_login_state(); + app_flash('Global-Admin erfolgreich angemeldet.', 'success'); + app_redirect('/admin/tenants'); + } + } + + $_SESSION['admin_login_state'] = $state; + } + } + + return [ + 'state' => $state, + 'message' => $message, + 'error' => $error, + ]; +} + +function app_admin_tenant_list(PDO $pdo): array +{ + return app_query_all( + $pdo, + <<<'SQL' +SELECT + t.id, + t.tenant_key, + t.name, + t.status, + COUNT(DISTINCT CASE WHEN m.status = 'active' THEN m.id END) AS member_count, + COUNT(DISTINCT CASE WHEN r.role_key = 'tenant_admin' THEN tu.id END) AS admin_count, + COUNT(DISTINCT CASE WHEN tip.is_enabled = 1 THEN tip.id END) AS provider_count +FROM tenants t +LEFT JOIN tenant_users tu ON tu.tenant_id = t.id AND tu.status = 'active' +LEFT JOIN members m ON m.tenant_id = t.id +LEFT JOIN tenant_user_roles tur ON tur.tenant_user_id = tu.id +LEFT JOIN roles r ON r.id = tur.role_id +LEFT JOIN tenant_identity_providers tip ON tip.tenant_id = t.id +GROUP BY t.id, t.tenant_key, t.name, t.status +ORDER BY t.name ASC +SQL + ); +} + +function app_upsert_tenant(PDO $pdo, array $data): void +{ + $tenantKey = strtolower(trim((string) ($data['tenant_key'] ?? ''))); + $name = trim((string) ($data['name'] ?? '')); + $status = trim((string) ($data['status'] ?? 'active')); + $tenantId = trim((string) ($data['tenant_id'] ?? '')); + + if ($tenantKey === '' || !preg_match('/^[a-z0-9-]+$/', $tenantKey)) { + throw new RuntimeException('Der Tenant-Key darf nur Kleinbuchstaben, Zahlen und Bindestriche enthalten.'); + } + + if ($name === '') { + throw new RuntimeException('Bitte einen Tenant-Namen angeben.'); + } + + if (!in_array($status, ['active', 'inactive', 'sandbox'], true)) { + $status = 'active'; + } + + $existing = app_query_one( + $pdo, + 'SELECT id FROM tenants WHERE tenant_key = :tenant_key AND (:tenant_id = "" OR id <> :tenant_id) LIMIT 1', + ['tenant_key' => $tenantKey, 'tenant_id' => $tenantId] + ); + + if ($existing !== null) { + throw new RuntimeException('Der Tenant-Key ist bereits vergeben.'); + } + + $now = date('Y-m-d H:i:s'); + + if ($tenantId !== '') { + app_execute( + $pdo, + 'UPDATE tenants SET tenant_key = :tenant_key, name = :name, status = :status, updated_at = :updated_at WHERE id = :id', + [ + 'tenant_key' => $tenantKey, + 'name' => $name, + 'status' => $status, + 'updated_at' => $now, + 'id' => $tenantId, + ] + ); + + app_flash('Tenant wurde aktualisiert.', 'success'); + + return; + } + + app_execute( + $pdo, + 'INSERT INTO tenants (id, tenant_key, name, status, created_at, updated_at) VALUES (:id, :tenant_key, :name, :status, :created_at, :updated_at)', + [ + 'id' => app_uuid(), + 'tenant_key' => $tenantKey, + 'name' => $name, + 'status' => $status, + 'created_at' => $now, + 'updated_at' => $now, + ] + ); + + app_flash('Tenant wurde angelegt.', 'success'); +} + +function app_handle_platform_tenant_action(PDO $pdo): void +{ + if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') { + return; + } + + $action = (string) ($_POST['action'] ?? ''); + + if ($action !== 'save-tenant') { + return; + } + + app_require_platform_admin(); + + 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'); +} + function app_memberships_by_email(PDO $pdo, string $email): array { $sql = <<<'SQL' @@ -431,7 +636,7 @@ SQL; $sharedSql = <<<'SQL' SELECT base.email, - base.tenant_count, + agg.tenant_count, GROUP_CONCAT(base.tenant_name ORDER BY base.tenant_name SEPARATOR ', ') AS tenant_names FROM ( SELECT DISTINCT diff --git a/saas-app/public/index.php b/saas-app/public/index.php index 86aa26b..9e6a5c2 100644 --- a/saas-app/public/index.php +++ b/saas-app/public/index.php @@ -162,7 +162,7 @@ $canManageTenant = app_can_manage_tenant($auth);