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
+
+
+
+
+
+
+
+ = admin_h((string) ($flash['message'] ?? '')) ?>
+
+
+
+
+ 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.
+
+ = admin_h((string) $adminLogin['error']) ?>
+
+
+
+ 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.
+
+
+
+
+ = $editingTenant !== null ? 'Tenant bearbeiten' : 'Neuen Tenant anlegen' ?>
+
+
+
+ Globaler Kontext
+
+
Angemeldet als = admin_h((string) ($admin['display_name'] ?? '')) ?>
= admin_h((string) ($admin['email'] ?? '')) ?>
+
Naechster Schritt Nach dem Anlegen eines Tenants kann die Legacy-Datenmigration direkt ueber die Weboberflaeche gestartet werden.
+
+
+
+
+ Vorhandene Tenants
+
+
+ Name Key Status Mitglieder Admins SSO Aktion
+
+
+
+ = admin_h((string) $tenant['name']) ?>
+ = admin_h((string) $tenant['tenant_key']) ?>
+ = admin_badge(((string) $tenant['status']) === 'active' ? 'Aktiv' : ucfirst((string) $tenant['status']), ((string) $tenant['status']) === 'active' ? 'success' : 'warning') ?>
+ = admin_h((string) $tenant['member_count']) ?>
+ = admin_h((string) $tenant['admin_count']) ?>
+ = admin_h((string) $tenant['provider_count']) ?>
+ 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: = admin_h((string) ($env['DB_CONNECTION'] ?? 'mysql')) ?>
+ Host: = admin_h((string) ($env['DB_HOST'] ?? '')) ?>
+ Datenbank: = admin_h((string) ($env['DB_DATABASE'] ?? '')) ?>
+ 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): ?>
+
+ = admin_h(str_replace('_', ' ', $label)) ?>
+ = admin_h((string) $value) ?>
+
+
+
+
+
+ Ergebnis
+
+ Schema neu angelegt: = admin_h(($migrationResult['schema_created'] ?? false) ? 'ja' : 'nein') ?>
+ Target Tenant: = admin_h((string) ($migrationResult['tenant']['tenant_key'] ?? '')) ?> - = admin_h((string) ($migrationResult['tenant']['name'] ?? '')) ?>
+
+
+
+ Erkannte Legacy-Tabellen
+
+
+ = admin_h((string) $entry['table']) ?>: = admin_h($entry['exists'] ? 'vorhanden' : 'fehlt') ?>
+
+
+
+
+
+ Warnungen = admin_h((string) $warning) ?>
+
+
+ Nicht automatisch uebernommen = admin_h((string) $warning) ?>
+
+
+
+
+
+
+
+
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);
Start
Anmeldung
- = $auth === null ? 'Fuer Betreiber' : 'Betreiber-Uebersicht' ?>
+ = $auth === null ? 'Fuer Betreiber' : 'Betreiber-Uebersicht' ?>
Dashboard
Hinweise
@@ -197,7 +197,7 @@ $canManageTenant = app_can_manage_tenant($auth);
= h((string) $marketing['subhero']) ?>
diff --git a/saas-app/public/install-support.php b/saas-app/public/install-support.php
index 7d76afa..e32decb 100644
--- a/saas-app/public/install-support.php
+++ b/saas-app/public/install-support.php
@@ -491,6 +491,741 @@ function scripts_installer_lock(array $meta = []): string
return $path;
}
+function scripts_query_all(PDO $pdo, string $sql, array $params = []): array
+{
+ $statement = $pdo->prepare($sql);
+ $statement->execute($params);
+
+ return $statement->fetchAll(PDO::FETCH_ASSOC) ?: [];
+}
+
+function scripts_query_one(PDO $pdo, string $sql, array $params = []): ?array
+{
+ $statement = $pdo->prepare($sql);
+ $statement->execute($params);
+ $row = $statement->fetch(PDO::FETCH_ASSOC);
+
+ return is_array($row) ? $row : null;
+}
+
+function scripts_execute(PDO $pdo, string $sql, array $params = []): void
+{
+ $statement = $pdo->prepare($sql);
+ $statement->execute($params);
+}
+
+function scripts_uuid(): string
+{
+ $bytes = random_bytes(16);
+ $bytes[6] = chr((ord($bytes[6]) & 0x0f) | 0x40);
+ $bytes[8] = chr((ord($bytes[8]) & 0x3f) | 0x80);
+
+ return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($bytes), 4));
+}
+
+function scripts_uuid_from_string(string $input): string
+{
+ $hash = md5($input);
+
+ return sprintf(
+ '%08s-%04s-%04s-%04s-%012s',
+ substr($hash, 0, 8),
+ substr($hash, 8, 4),
+ substr($hash, 12, 4),
+ substr($hash, 16, 4),
+ substr($hash, 20, 12)
+ );
+}
+
+function scripts_connect_pdo(array $config): PDO
+{
+ $connection = scripts_normalize_db_connection((string) ($config['connection'] ?? 'mysql'));
+ $server = (string) ($config['server'] ?? '');
+ $database = (string) ($config['database'] ?? '');
+ $port = (string) ($config['port'] ?? scripts_default_db_port($connection));
+ $username = (string) ($config['username'] ?? '');
+ $password = (string) ($config['password'] ?? '');
+
+ if ($server === '' || $database === '') {
+ throw new RuntimeException('Bitte Server und Datenbank angeben.');
+ }
+
+ $requiredExtension = scripts_required_pdo_extension($connection);
+
+ if (!extension_loaded($requiredExtension)) {
+ throw new RuntimeException('Die PHP-Erweiterung ' . $requiredExtension . ' ist nicht geladen.');
+ }
+
+ if ($connection === 'sqlsrv') {
+ $dsn = sprintf('sqlsrv:Server=%s,%s;Database=%s;TrustServerCertificate=1', $server, $port, $database);
+
+ return new PDO($dsn, $username, $password, [
+ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
+ PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
+ ]);
+ }
+
+ $dsn = sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $server, $port, $database);
+
+ return new PDO($dsn, $username, $password, [
+ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
+ PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
+ ]);
+}
+
+function scripts_table_exists(PDO $pdo, string $tableName): bool
+{
+ if (preg_match('/^[A-Za-z0-9_.]+$/', $tableName) !== 1) {
+ throw new RuntimeException('Ungueltiger Tabellenname: ' . $tableName);
+ }
+
+ try {
+ $pdo->query('SELECT 1 FROM ' . $tableName . ' WHERE 1 = 0');
+
+ return true;
+ } catch (Throwable $exception) {
+ return false;
+ }
+}
+
+function scripts_schema_is_installed(PDO $pdo): bool
+{
+ return scripts_table_exists($pdo, 'tenants')
+ && scripts_table_exists($pdo, 'users')
+ && scripts_table_exists($pdo, 'tenant_users')
+ && scripts_table_exists($pdo, 'members');
+}
+
+function scripts_ensure_schema(array $config): bool
+{
+ $pdo = scripts_connect_pdo($config);
+
+ if (scripts_schema_is_installed($pdo)) {
+ return false;
+ }
+
+ scripts_run_sql_migrations($config);
+
+ return true;
+}
+
+function scripts_role_id(string $roleKey, string $scope): string
+{
+ return scripts_uuid_from_string('role:' . $scope . ':' . $roleKey);
+}
+
+function scripts_ensure_role(PDO $pdo, string $roleKey, string $name, string $scope): string
+{
+ $role = scripts_query_one(
+ $pdo,
+ 'SELECT id FROM roles WHERE role_key = :role_key AND scope = :scope LIMIT 1',
+ ['role_key' => $roleKey, 'scope' => $scope]
+ );
+
+ if ($role !== null) {
+ return (string) $role['id'];
+ }
+
+ $id = scripts_role_id($roleKey, $scope);
+ $now = date('Y-m-d H:i:s');
+
+ scripts_execute(
+ $pdo,
+ 'INSERT INTO roles (id, role_key, name, scope, created_at, updated_at) VALUES (:id, :role_key, :name, :scope, :created_at, :updated_at)',
+ [
+ 'id' => $id,
+ 'role_key' => $roleKey,
+ 'name' => $name,
+ 'scope' => $scope,
+ 'created_at' => $now,
+ 'updated_at' => $now,
+ ]
+ );
+
+ return $id;
+}
+
+function scripts_ensure_core_roles(PDO $pdo): array
+{
+ return [
+ 'tenant_admin' => scripts_ensure_role($pdo, 'tenant_admin', 'Tenant Admin', 'tenant'),
+ 'platform_admin' => scripts_ensure_role($pdo, 'platform_admin', 'Platform Admin', 'platform'),
+ ];
+}
+
+function scripts_create_platform_admin(array $targetConfig, string $email, string $displayName, string $password): array
+{
+ $email = strtolower(trim($email));
+ $displayName = trim($displayName);
+
+ if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
+ throw new RuntimeException('Bitte eine gueltige E-Mail-Adresse fuer den Global-Admin angeben.');
+ }
+
+ if ($displayName === '') {
+ throw new RuntimeException('Bitte einen Anzeigenamen fuer den Global-Admin angeben.');
+ }
+
+ if (strlen($password) < 8) {
+ throw new RuntimeException('Das Global-Admin-Passwort muss mindestens 8 Zeichen lang sein.');
+ }
+
+ $pdo = scripts_connect_pdo($targetConfig);
+ scripts_ensure_core_roles($pdo);
+
+ $now = date('Y-m-d H:i:s');
+ $hash = password_hash($password, PASSWORD_BCRYPT);
+ $existing = scripts_query_one($pdo, 'SELECT id FROM users WHERE LOWER(email) = LOWER(:email) LIMIT 1', ['email' => $email]);
+
+ if ($existing !== null) {
+ scripts_execute(
+ $pdo,
+ 'UPDATE users SET display_name = :display_name, password_hash = :password_hash, is_platform_admin = 1, updated_at = :updated_at WHERE id = :id',
+ [
+ 'display_name' => $displayName,
+ 'password_hash' => $hash,
+ 'updated_at' => $now,
+ 'id' => $existing['id'],
+ ]
+ );
+
+ return [
+ 'user_id' => $existing['id'],
+ 'created' => false,
+ ];
+ }
+
+ $userId = scripts_uuid_from_string('platform-user:' . $email);
+
+ scripts_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, 1, :created_at, :updated_at)',
+ [
+ 'id' => $userId,
+ 'email' => $email,
+ 'password_hash' => $hash,
+ 'display_name' => $displayName,
+ 'created_at' => $now,
+ 'updated_at' => $now,
+ ]
+ );
+
+ return [
+ 'user_id' => $userId,
+ 'created' => true,
+ ];
+}
+
+function scripts_datetime_string(mixed $value): string
+{
+ if ($value instanceof DateTimeInterface) {
+ return $value->format('Y-m-d H:i:s');
+ }
+
+ $normalized = trim((string) $value);
+
+ if ($normalized === '') {
+ return date('Y-m-d H:i:s');
+ }
+
+ $timestamp = strtotime($normalized);
+
+ return $timestamp === false ? date('Y-m-d H:i:s') : date('Y-m-d H:i:s', $timestamp);
+}
+
+function scripts_legacy_source_tables(PDO $pdo): array
+{
+ $tables = [
+ 'members' => 'kl_Mitarbeiter',
+ 'coffee' => 'kl_Kaffeeverbrauch',
+ 'payments' => 'kl_Einzahlungen',
+ 'announcements' => 'kl_hinweise',
+ 'config' => 'kl_config',
+ 'survey_votes' => 'CoffeeSurveyVotedEmails',
+ 'survey_responses' => 'CoffeeSurveyResponses',
+ ];
+
+ $result = [];
+
+ foreach ($tables as $key => $table) {
+ $result[$key] = [
+ 'table' => $table,
+ 'exists' => scripts_table_exists($pdo, $table),
+ ];
+ }
+
+ return $result;
+}
+
+function scripts_ensure_tenant(PDO $pdo, string $tenantKey, string $tenantName): array
+{
+ $existing = scripts_query_one(
+ $pdo,
+ 'SELECT id, tenant_key, name, status FROM tenants WHERE tenant_key = :tenant_key LIMIT 1',
+ ['tenant_key' => $tenantKey]
+ );
+
+ if ($existing !== null) {
+ scripts_execute(
+ $pdo,
+ 'UPDATE tenants SET name = :name, updated_at = :updated_at WHERE id = :id',
+ [
+ 'name' => $tenantName,
+ 'updated_at' => date('Y-m-d H:i:s'),
+ 'id' => $existing['id'],
+ ]
+ );
+ $existing['created'] = false;
+
+ return $existing;
+ }
+
+ $id = scripts_uuid_from_string('tenant:' . $tenantKey);
+ $now = date('Y-m-d H:i:s');
+
+ scripts_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' => $id,
+ 'tenant_key' => $tenantKey,
+ 'name' => $tenantName,
+ 'status' => 'active',
+ 'created_at' => $now,
+ 'updated_at' => $now,
+ ]
+ );
+
+ return [
+ 'id' => $id,
+ 'tenant_key' => $tenantKey,
+ 'name' => $tenantName,
+ 'status' => 'active',
+ 'created' => true,
+ ];
+}
+
+function scripts_migrate_legacy_data(array $sourceConfig, array $targetConfig, array $options = []): array
+{
+ $tenantKey = trim((string) ($options['tenant_key'] ?? 'legacy-import'));
+ $tenantName = trim((string) ($options['tenant_name'] ?? 'Legacy Import'));
+
+ if ($tenantKey === '' || $tenantName === '') {
+ throw new RuntimeException('Bitte Tenant-Key und Tenant-Name fuer die Migration angeben.');
+ }
+
+ $schemaCreated = scripts_ensure_schema($targetConfig);
+ $sourcePdo = scripts_connect_pdo($sourceConfig);
+ $targetPdo = scripts_connect_pdo($targetConfig);
+ $roles = scripts_ensure_core_roles($targetPdo);
+ $tenant = scripts_ensure_tenant($targetPdo, $tenantKey, $tenantName);
+ $legacyTables = scripts_legacy_source_tables($sourcePdo);
+ $counts = [
+ 'users_created' => 0,
+ 'users_updated' => 0,
+ 'tenant_users_created' => 0,
+ 'members_created' => 0,
+ 'members_updated' => 0,
+ 'tenant_roles_created' => 0,
+ 'coffee_entries_created' => 0,
+ 'payment_entries_created' => 0,
+ 'ledger_entries_created' => 0,
+ 'announcements_created' => 0,
+ ];
+ $warnings = [];
+ $skipped = [];
+ $memberMap = [];
+
+ $targetPdo->beginTransaction();
+
+ try {
+ if (!$legacyTables['members']['exists']) {
+ throw new RuntimeException('Die Legacy-Tabelle kl_Mitarbeiter wurde in der Quelldatenbank nicht gefunden.');
+ }
+
+ $legacyMembers = scripts_query_all(
+ $sourcePdo,
+ 'SELECT MitarbeiterID, Name, Email, aktiv, admin FROM kl_Mitarbeiter ORDER BY MitarbeiterID ASC'
+ );
+
+ foreach ($legacyMembers as $row) {
+ $sourceMemberId = (string) ($row['MitarbeiterID'] ?? '');
+ $email = strtolower(trim((string) ($row['Email'] ?? '')));
+ $displayName = trim((string) ($row['Name'] ?? ''));
+ $isActive = (int) ($row['aktiv'] ?? 0) === 1;
+ $isAdmin = (int) ($row['admin'] ?? 0) === 1;
+
+ if ($sourceMemberId === '' || $email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
+ $warnings[] = 'Mitglied ohne gueltige E-Mail wurde uebersprungen: ' . ($displayName !== '' ? $displayName : $sourceMemberId);
+ continue;
+ }
+
+ $user = scripts_query_one($targetPdo, 'SELECT id FROM users WHERE LOWER(email) = LOWER(:email) LIMIT 1', ['email' => $email]);
+
+ if ($user === null) {
+ $userId = scripts_uuid_from_string('legacy-user:' . $email);
+ $now = date('Y-m-d H:i:s');
+ scripts_execute(
+ $targetPdo,
+ 'INSERT INTO users (id, email, password_hash, display_name, is_platform_admin, created_at, updated_at) VALUES (:id, :email, NULL, :display_name, 0, :created_at, :updated_at)',
+ [
+ 'id' => $userId,
+ 'email' => $email,
+ 'display_name' => $displayName !== '' ? $displayName : $email,
+ 'created_at' => $now,
+ 'updated_at' => $now,
+ ]
+ );
+ $counts['users_created']++;
+ } else {
+ $userId = (string) $user['id'];
+ scripts_execute(
+ $targetPdo,
+ 'UPDATE users SET display_name = :display_name, updated_at = :updated_at WHERE id = :id',
+ [
+ 'display_name' => $displayName !== '' ? $displayName : $email,
+ 'updated_at' => date('Y-m-d H:i:s'),
+ 'id' => $userId,
+ ]
+ );
+ $counts['users_updated']++;
+ }
+
+ $tenantUser = scripts_query_one(
+ $targetPdo,
+ 'SELECT id FROM tenant_users WHERE tenant_id = :tenant_id AND user_id = :user_id LIMIT 1',
+ ['tenant_id' => $tenant['id'], 'user_id' => $userId]
+ );
+
+ if ($tenantUser === null) {
+ $tenantUserId = scripts_uuid_from_string('tenant-user:' . $tenant['id'] . ':' . $userId);
+ $now = date('Y-m-d H:i:s');
+ scripts_execute(
+ $targetPdo,
+ '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' => $tenant['id'],
+ 'user_id' => $userId,
+ 'status' => $isActive ? 'active' : 'inactive',
+ 'created_at' => $now,
+ 'updated_at' => $now,
+ ]
+ );
+ $counts['tenant_users_created']++;
+ } else {
+ $tenantUserId = (string) $tenantUser['id'];
+ scripts_execute(
+ $targetPdo,
+ 'UPDATE tenant_users SET status = :status, updated_at = :updated_at WHERE id = :id',
+ [
+ 'status' => $isActive ? 'active' : 'inactive',
+ 'updated_at' => date('Y-m-d H:i:s'),
+ 'id' => $tenantUserId,
+ ]
+ );
+ }
+
+ $member = scripts_query_one(
+ $targetPdo,
+ 'SELECT id FROM members WHERE tenant_id = :tenant_id AND email = :email LIMIT 1',
+ ['tenant_id' => $tenant['id'], 'email' => $email]
+ );
+
+ if ($member === null) {
+ $memberId = scripts_uuid_from_string('member:' . $tenant['id'] . ':' . $email);
+ $now = date('Y-m-d H:i:s');
+ scripts_execute(
+ $targetPdo,
+ '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' => $memberId,
+ 'tenant_id' => $tenant['id'],
+ 'tenant_user_id' => $tenantUserId,
+ 'display_name' => $displayName !== '' ? $displayName : $email,
+ 'email' => $email,
+ 'status' => $isActive ? 'active' : 'inactive',
+ 'created_at' => $now,
+ 'updated_at' => $now,
+ ]
+ );
+ $counts['members_created']++;
+ } else {
+ $memberId = (string) $member['id'];
+ scripts_execute(
+ $targetPdo,
+ 'UPDATE members SET tenant_user_id = :tenant_user_id, display_name = :display_name, status = :status, updated_at = :updated_at WHERE id = :id',
+ [
+ 'tenant_user_id' => $tenantUserId,
+ 'display_name' => $displayName !== '' ? $displayName : $email,
+ 'status' => $isActive ? 'active' : 'inactive',
+ 'updated_at' => date('Y-m-d H:i:s'),
+ 'id' => $memberId,
+ ]
+ );
+ $counts['members_updated']++;
+ }
+
+ if ($isAdmin) {
+ $existingRole = scripts_query_one(
+ $targetPdo,
+ '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' => $roles['tenant_admin']]
+ );
+
+ if ($existingRole === null) {
+ scripts_execute(
+ $targetPdo,
+ 'INSERT INTO tenant_user_roles (id, tenant_user_id, role_id, created_at) VALUES (:id, :tenant_user_id, :role_id, :created_at)',
+ [
+ 'id' => scripts_uuid_from_string('tenant-role:' . $tenantUserId . ':' . $roles['tenant_admin']),
+ 'tenant_user_id' => $tenantUserId,
+ 'role_id' => $roles['tenant_admin'],
+ 'created_at' => date('Y-m-d H:i:s'),
+ ]
+ );
+ $counts['tenant_roles_created']++;
+ }
+ }
+
+ $memberMap[$sourceMemberId] = [
+ 'member_id' => $memberId,
+ 'tenant_user_id' => $tenantUserId,
+ 'display_name' => $displayName !== '' ? $displayName : $email,
+ 'email' => $email,
+ ];
+ }
+
+ if ($legacyTables['coffee']['exists']) {
+ $coffeeEntries = scripts_query_all(
+ $sourcePdo,
+ 'SELECT MitarbeiterID, AnzahlStriche, Kosten, KostenproStrich, Datum FROM kl_Kaffeeverbrauch ORDER BY Datum ASC, MitarbeiterID ASC, Kosten ASC, AnzahlStriche ASC'
+ );
+
+ foreach ($coffeeEntries as $index => $row) {
+ $sourceMemberId = (string) ($row['MitarbeiterID'] ?? '');
+
+ if (!isset($memberMap[$sourceMemberId])) {
+ $warnings[] = 'Kaffeeeintrag ohne migriertes Mitglied wurde uebersprungen: ' . $sourceMemberId;
+ continue;
+ }
+
+ $signature = implode('|', [
+ 'coffee',
+ $tenant['id'],
+ $sourceMemberId,
+ (string) ($row['AnzahlStriche'] ?? ''),
+ (string) ($row['Kosten'] ?? ''),
+ (string) ($row['KostenproStrich'] ?? ''),
+ scripts_datetime_string($row['Datum'] ?? null),
+ (string) $index,
+ ]);
+ $entryId = scripts_uuid_from_string($signature);
+ $ledgerId = scripts_uuid_from_string('ledger:' . $signature);
+
+ if (scripts_query_one($targetPdo, 'SELECT id FROM coffee_entries WHERE id = :id LIMIT 1', ['id' => $entryId]) !== null) {
+ continue;
+ }
+
+ $strokes = max(0, (int) ($row['AnzahlStriche'] ?? 0));
+ $totalCost = (float) ($row['Kosten'] ?? 0);
+ $unitPrice = (float) ($row['KostenproStrich'] ?? 0);
+ $bookedAt = scripts_datetime_string($row['Datum'] ?? null);
+
+ if ($strokes < 1) {
+ continue;
+ }
+
+ if ($unitPrice <= 0 && $totalCost > 0) {
+ $unitPrice = $totalCost / $strokes;
+ }
+
+ $now = date('Y-m-d H:i:s');
+
+ scripts_execute(
+ $targetPdo,
+ 'INSERT INTO coffee_entries (id, tenant_id, member_id, strokes, unit_price, total_cost, booking_source, booked_at, created_at, updated_at) VALUES (:id, :tenant_id, :member_id, :strokes, :unit_price, :total_cost, :booking_source, :booked_at, :created_at, :updated_at)',
+ [
+ 'id' => $entryId,
+ 'tenant_id' => $tenant['id'],
+ 'member_id' => $memberMap[$sourceMemberId]['member_id'],
+ 'strokes' => $strokes,
+ 'unit_price' => number_format($unitPrice, 2, '.', ''),
+ 'total_cost' => number_format($totalCost, 2, '.', ''),
+ 'booking_source' => 'legacy-import',
+ 'booked_at' => $bookedAt,
+ 'created_at' => $now,
+ 'updated_at' => $now,
+ ]
+ );
+ $counts['coffee_entries_created']++;
+
+ scripts_execute(
+ $targetPdo,
+ 'INSERT INTO ledger_entries (id, tenant_id, member_id, entry_type, amount, reference_type, reference_id, booked_at, created_at, updated_at) VALUES (:id, :tenant_id, :member_id, :entry_type, :amount, :reference_type, :reference_id, :booked_at, :created_at, :updated_at)',
+ [
+ 'id' => $ledgerId,
+ 'tenant_id' => $tenant['id'],
+ 'member_id' => $memberMap[$sourceMemberId]['member_id'],
+ 'entry_type' => 'coffee_charge',
+ 'amount' => number_format($totalCost * -1, 2, '.', ''),
+ 'reference_type' => 'coffee_entry',
+ 'reference_id' => $entryId,
+ 'booked_at' => $bookedAt,
+ 'created_at' => $now,
+ 'updated_at' => $now,
+ ]
+ );
+ $counts['ledger_entries_created']++;
+ }
+ } else {
+ $skipped[] = 'Die Legacy-Tabelle kl_Kaffeeverbrauch wurde nicht gefunden.';
+ }
+
+ if ($legacyTables['payments']['exists']) {
+ $paymentEntries = scripts_query_all(
+ $sourcePdo,
+ 'SELECT MitarbeiterID, Betrag, Datum FROM kl_Einzahlungen ORDER BY Datum ASC, MitarbeiterID ASC, Betrag ASC'
+ );
+
+ foreach ($paymentEntries as $index => $row) {
+ $sourceMemberId = (string) ($row['MitarbeiterID'] ?? '');
+
+ if (!isset($memberMap[$sourceMemberId])) {
+ $warnings[] = 'Einzahlung ohne migriertes Mitglied wurde uebersprungen: ' . $sourceMemberId;
+ continue;
+ }
+
+ $signature = implode('|', [
+ 'payment',
+ $tenant['id'],
+ $sourceMemberId,
+ (string) ($row['Betrag'] ?? ''),
+ scripts_datetime_string($row['Datum'] ?? null),
+ (string) $index,
+ ]);
+ $entryId = scripts_uuid_from_string($signature);
+ $ledgerId = scripts_uuid_from_string('ledger:' . $signature);
+
+ if (scripts_query_one($targetPdo, 'SELECT id FROM payment_entries WHERE id = :id LIMIT 1', ['id' => $entryId]) !== null) {
+ continue;
+ }
+
+ $amount = (float) ($row['Betrag'] ?? 0);
+
+ if ($amount === 0.0) {
+ continue;
+ }
+
+ $bookedAt = scripts_datetime_string($row['Datum'] ?? null);
+ $now = date('Y-m-d H:i:s');
+
+ scripts_execute(
+ $targetPdo,
+ 'INSERT INTO payment_entries (id, tenant_id, member_id, amount, payment_method, booked_at, created_at, updated_at) VALUES (:id, :tenant_id, :member_id, :amount, :payment_method, :booked_at, :created_at, :updated_at)',
+ [
+ 'id' => $entryId,
+ 'tenant_id' => $tenant['id'],
+ 'member_id' => $memberMap[$sourceMemberId]['member_id'],
+ 'amount' => number_format($amount, 2, '.', ''),
+ 'payment_method' => 'legacy-import',
+ 'booked_at' => $bookedAt,
+ 'created_at' => $now,
+ 'updated_at' => $now,
+ ]
+ );
+ $counts['payment_entries_created']++;
+
+ scripts_execute(
+ $targetPdo,
+ 'INSERT INTO ledger_entries (id, tenant_id, member_id, entry_type, amount, reference_type, reference_id, booked_at, created_at, updated_at) VALUES (:id, :tenant_id, :member_id, :entry_type, :amount, :reference_type, :reference_id, :booked_at, :created_at, :updated_at)',
+ [
+ 'id' => $ledgerId,
+ 'tenant_id' => $tenant['id'],
+ 'member_id' => $memberMap[$sourceMemberId]['member_id'],
+ 'entry_type' => 'payment_credit',
+ 'amount' => number_format($amount, 2, '.', ''),
+ 'reference_type' => 'payment_entry',
+ 'reference_id' => $entryId,
+ 'booked_at' => $bookedAt,
+ 'created_at' => $now,
+ 'updated_at' => $now,
+ ]
+ );
+ $counts['ledger_entries_created']++;
+ }
+ } else {
+ $skipped[] = 'Die Legacy-Tabelle kl_Einzahlungen wurde nicht gefunden.';
+ }
+
+ if ($legacyTables['announcements']['exists']) {
+ $announcements = scripts_query_all(
+ $sourcePdo,
+ 'SELECT id, nachricht, gueltig_bis FROM kl_hinweise ORDER BY gueltig_bis ASC, id ASC'
+ );
+
+ foreach ($announcements as $row) {
+ $message = trim((string) ($row['nachricht'] ?? ''));
+
+ if ($message === '') {
+ continue;
+ }
+
+ $sourceId = (string) ($row['id'] ?? $message);
+ $announcementId = scripts_uuid_from_string('announcement:' . $tenant['id'] . ':' . $sourceId);
+
+ if (scripts_query_one($targetPdo, 'SELECT id FROM announcements WHERE id = :id LIMIT 1', ['id' => $announcementId]) !== null) {
+ continue;
+ }
+
+ $title = mb_substr(strip_tags($message), 0, 80);
+ $title = $title !== '' ? $title : 'Legacy Hinweis';
+ $now = date('Y-m-d H:i:s');
+
+ scripts_execute(
+ $targetPdo,
+ 'INSERT INTO announcements (id, tenant_id, title, message, visible_until, is_active, created_at, updated_at) VALUES (:id, :tenant_id, :title, :message, :visible_until, 1, :created_at, :updated_at)',
+ [
+ 'id' => $announcementId,
+ 'tenant_id' => $tenant['id'],
+ 'title' => $title,
+ 'message' => $message,
+ 'visible_until' => scripts_datetime_string($row['gueltig_bis'] ?? null),
+ 'created_at' => $now,
+ 'updated_at' => $now,
+ ]
+ );
+ $counts['announcements_created']++;
+ }
+ }
+
+ if ($legacyTables['config']['exists']) {
+ $skipped[] = 'kl_config wurde erkannt, hat aber aktuell kein direktes Zielmodell im neuen Schema.';
+ }
+
+ if ($legacyTables['survey_votes']['exists'] || $legacyTables['survey_responses']['exists']) {
+ $skipped[] = 'Die alten Umfrage-Tabellen wurden erkannt, werden aber noch nicht automatisch migriert.';
+ }
+
+ $targetPdo->commit();
+ } catch (Throwable $exception) {
+ if ($targetPdo->inTransaction()) {
+ $targetPdo->rollBack();
+ }
+
+ throw $exception;
+ }
+
+ return [
+ 'schema_created' => $schemaCreated,
+ 'tenant' => $tenant,
+ 'counts' => $counts,
+ 'warnings' => $warnings,
+ 'skipped' => $skipped,
+ 'source_tables' => $legacyTables,
+ ];
+}
+
function scripts_stdout(string $message): void
{
fwrite(STDOUT, $message . PHP_EOL);
diff --git a/saas-app/public/install.php b/saas-app/public/install.php
index fd36534..7e8ac76 100644
--- a/saas-app/public/install.php
+++ b/saas-app/public/install.php
@@ -54,6 +54,11 @@ $values = [
'OIDC_ENABLED' => $currentEnv['OIDC_ENABLED'] ?? ($defaults['OIDC_ENABLED'] ?? 'false'),
'OIDC_DEFAULT_PROVIDER' => $currentEnv['OIDC_DEFAULT_PROVIDER'] ?? ($defaults['OIDC_DEFAULT_PROVIDER'] ?? ''),
];
+$adminValues = [
+ 'ADMIN_DISPLAY_NAME' => '',
+ 'ADMIN_EMAIL' => '',
+ 'ADMIN_PASSWORD' => '',
+];
$messages = [];
$errors = [];
@@ -88,6 +93,12 @@ if ($requestMethod === 'POST' && !$locked) {
}
}
+ foreach (array_keys($adminValues) as $key) {
+ if (array_key_exists($key, $_POST)) {
+ $adminValues[$key] = trim((string) $_POST[$key]);
+ }
+ }
+
$values['DB_CONNECTION'] = scripts_normalize_db_connection($values['DB_CONNECTION']);
if ($values['DB_PORT'] === '') {
@@ -115,6 +126,12 @@ if ($requestMethod === 'POST' && !$locked) {
}
}
+ foreach (['ADMIN_DISPLAY_NAME', 'ADMIN_EMAIL', 'ADMIN_PASSWORD'] as $required) {
+ if ($adminValues[$required] === '') {
+ $errors[] = $required . ' darf nicht leer sein.';
+ }
+ }
+
if ($errors === []) {
try {
scripts_write_env_file($values);
@@ -135,6 +152,36 @@ if ($requestMethod === 'POST' && !$locked) {
$messages[] = 'Migrationen wurden direkt ueber PHP fuer ' . $activeConnectionLabel . ' ausgefuehrt.';
}
+ if (!$requiredExtensionLoaded) {
+ throw new RuntimeException('Global-Admin konnte nicht angelegt werden, weil ' . $requiredExtension . ' nicht geladen ist.');
+ }
+
+ scripts_ensure_schema([
+ 'connection' => $values['DB_CONNECTION'],
+ 'server' => $values['DB_HOST'],
+ 'database' => $values['DB_DATABASE'],
+ 'port' => $values['DB_PORT'],
+ 'username' => $values['DB_USERNAME'],
+ 'password' => $values['DB_PASSWORD'],
+ ]);
+
+ $adminResult = scripts_create_platform_admin(
+ [
+ 'connection' => $values['DB_CONNECTION'],
+ 'server' => $values['DB_HOST'],
+ 'database' => $values['DB_DATABASE'],
+ 'port' => $values['DB_PORT'],
+ 'username' => $values['DB_USERNAME'],
+ 'password' => $values['DB_PASSWORD'],
+ ],
+ $adminValues['ADMIN_EMAIL'],
+ $adminValues['ADMIN_DISPLAY_NAME'],
+ $adminValues['ADMIN_PASSWORD']
+ );
+ $messages[] = $adminResult['created']
+ ? 'Der erste Global-Admin wurde angelegt.'
+ : 'Der bestehende Global-Admin wurde aktualisiert.';
+
if (isset($_POST['lock_installer'])) {
scripts_installer_lock([
'app_url' => $values['APP_URL'],
@@ -155,7 +202,7 @@ if ($requestMethod === 'POST' && !$locked) {
$errors[] = 'PHP kann den SaaS-App-Ordner nicht lesen. Bitte Rechte, Document-Root und `open_basedir` pruefen.';
}
} else {
- $errors[] = 'Die Installation konnte nicht abgeschlossen werden. Bitte Eingaben, DB-Zugang und Dateirechte pruefen.';
+ $errors[] = 'Die Installation konnte nicht abgeschlossen werden: ' . $message;
}
}
}
@@ -338,6 +385,7 @@ function h(string $value): string
@@ -453,6 +501,18 @@ function h(string $value): string
OIDC aktivieren
+
+ Global-Admin Name
+
+
+
+ Global-Admin E-Mail
+
+
+
+ Global-Admin Passwort
+
+