Tenant Verwaltung angelegt.
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
if (session_status() !== PHP_SESSION_ACTIVE) {
|
||||
session_start();
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/app-support.php';
|
||||
|
||||
if (!isset($_SESSION['admin_csrf'])) {
|
||||
$_SESSION['admin_csrf'] = bin2hex(random_bytes(24));
|
||||
}
|
||||
|
||||
function admin_h(string $value): string
|
||||
{
|
||||
return app_h($value);
|
||||
}
|
||||
|
||||
function admin_badge(string $label, string $tone = 'neutral'): string
|
||||
{
|
||||
return '<span class="badge badge-' . admin_h($tone) . '">' . admin_h($label) . '</span>';
|
||||
}
|
||||
|
||||
$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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
?><!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Kaffeeliste Admin</title>
|
||||
<style>
|
||||
:root{--bg:#f4efe7;--card:#fffaf4;--ink:#24160e;--muted:#675546;--brand:#0c6b66;--accent:#a34b12;--line:rgba(36,22,14,.12);--radius:24px;--shadow:0 24px 54px rgba(57,35,22,.11)}
|
||||
*{box-sizing:border-box}body{margin:0;font-family:"Segoe UI",sans-serif;color:var(--ink);background:linear-gradient(180deg,#fbf8f2 0%,var(--bg) 100%)}a{color:inherit}.shell{width:min(1180px,calc(100vw - 32px));margin:24px auto 40px}.bar,.hero,.card,.alert{border:1px solid var(--line);border-radius:var(--radius);background:var(--card);box-shadow:var(--shadow)}.bar,.links,.actions{display:flex;flex-wrap:wrap;gap:10px}.bar{justify-content:space-between;align-items:center;padding:18px 22px;margin-bottom:18px}.hero,.card{padding:24px}.hero{margin-bottom:18px}.grid{display:grid;gap:18px}.grid-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-3{grid-template-columns:repeat(3,minmax(0,1fr))}.links a,.button,button{display:inline-flex;align-items:center;justify-content:center;padding:11px 16px;border-radius:999px;text-decoration:none;font-weight:700;border:0;cursor:pointer}.links a{background:rgba(12,107,102,.08);color:var(--brand)}.links a.active{background:linear-gradient(135deg,var(--brand),#084d49);color:#fff}.button,button{background:linear-gradient(135deg,var(--brand),#084d49);color:#fff}.button.secondary{background:transparent;color:var(--brand);border:1px solid rgba(12,107,102,.18)}h1,h2,h3{margin:0 0 12px;font-family:Georgia,serif}h1{font-size:clamp(2rem,4vw,3.4rem)}h2{font-size:1.35rem}h3{font-size:1.02rem}.eyebrow{display:inline-block;margin-bottom:12px;color:var(--accent);font-size:.82rem;font-weight:800;letter-spacing:.14em;text-transform:uppercase}p,.muted{margin:0;color:var(--muted);line-height:1.65}.stack{display:grid;gap:12px}.metric{padding:18px;border:1px solid var(--line);border-radius:18px;background:#fffdf9}.alert{padding:16px 18px;margin-bottom:18px}.alert-success{background:rgba(17,98,61,.08)}.alert-warning{background:rgba(163,75,18,.08)}.alert-error{background:rgba(154,31,31,.08)}.badge{display:inline-flex;align-items:center;padding:7px 12px;border-radius:999px;font-size:.86rem;font-weight:700}.badge-neutral{background:rgba(12,107,102,.1);color:var(--brand)}.badge-success{background:rgba(17,98,61,.12);color:#11623d}.badge-warning{background:rgba(163,75,18,.12);color:#98510c}.table{overflow-x:auto}.table table{width:100%;border-collapse:collapse;min-width:720px}.table th,.table td{padding:13px 10px;border-bottom:1px solid var(--line);text-align:left;vertical-align:top}.table th{font-size:.85rem;letter-spacing:.08em;text-transform:uppercase;color:var(--muted)}form.grid{grid-template-columns:repeat(2,minmax(0,1fr))}label{display:flex;flex-direction:column;gap:8px;font-weight:700}input,select{width:100%;padding:12px 14px;border-radius:16px;border:1px solid rgba(36,22,14,.15);font:inherit;background:#fffdfa;color:var(--ink)}ul{margin:0;padding-left:18px;color:var(--muted);line-height:1.7}.footer{margin-top:18px;text-align:center;color:var(--muted);font-size:.92rem}@media(max-width:960px){.grid-2,.grid-3,form.grid{grid-template-columns:1fr}.bar{align-items:flex-start;flex-direction:column}.table table{min-width:0}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="shell">
|
||||
<header class="bar">
|
||||
<div>
|
||||
<strong style="font-family:Georgia,serif;font-size:1.28rem;">Kaffeeliste Admin</strong>
|
||||
<p>Global-Admin Login, Tenant-Verwaltung und Legacy-Migration direkt ueber PHP-Webseiten.</p>
|
||||
</div>
|
||||
<nav class="links">
|
||||
<a href="/admin/login" class="<?= $page === 'login' ? 'active' : '' ?>">Login</a>
|
||||
<?php if ($admin !== null): ?>
|
||||
<a href="/admin/tenants" class="<?= $page === 'tenants' ? 'active' : '' ?>">Tenants</a>
|
||||
<a href="/admin/migration" class="<?= $page === 'migration' ? 'active' : '' ?>">Migration</a>
|
||||
<form method="post" action="/admin/logout">
|
||||
<input type="hidden" name="csrf" value="<?= admin_h($_SESSION['admin_csrf']) ?>">
|
||||
<button type="submit" class="button secondary">Abmelden</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<?php if ($flash !== null): ?>
|
||||
<section class="alert alert-<?= admin_h((string) ($flash['type'] ?? 'success')) ?>"><?= admin_h((string) ($flash['message'] ?? '')) ?></section>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($page === 'login'): ?>
|
||||
<section class="hero">
|
||||
<div class="eyebrow">Global-Admin</div>
|
||||
<h1>Tenant-Verwaltung nur fuer die zentrale Administration.</h1>
|
||||
<p>Dieser Login ist bewusst vom Mitglieder-Login getrennt und fuehrt direkt in die globale Tenant-Verwaltung.</p>
|
||||
</section>
|
||||
<?php if ($adminLogin['error'] !== null): ?><section class="alert alert-error"><?= admin_h((string) $adminLogin['error']) ?></section><?php endif; ?>
|
||||
<?php if ($dbError !== null): ?><section class="alert alert-warning"><?= admin_h($dbError) ?></section><?php endif; ?>
|
||||
<section class="grid grid-2">
|
||||
<article class="card">
|
||||
<h2>Anmelden</h2>
|
||||
<form method="post" action="/admin/login" class="stack">
|
||||
<input type="hidden" name="action" value="admin-login">
|
||||
<input type="hidden" name="csrf" value="<?= admin_h($_SESSION['admin_csrf']) ?>">
|
||||
<label>E-Mail-Adresse<input type="email" name="email" value="<?= admin_h((string) (($adminLogin['state']['email'] ?? ''))) ?>" autocomplete="email"></label>
|
||||
<label>Passwort<input type="password" name="password" autocomplete="current-password"></label>
|
||||
<div class="actions"><button type="submit">Als Global-Admin anmelden</button></div>
|
||||
</form>
|
||||
</article>
|
||||
<article class="card">
|
||||
<h2>Einrichtung</h2>
|
||||
<ul>
|
||||
<li>Der erste Global-Admin wird im Installer angelegt.</li>
|
||||
<li>Die Anmeldung prueft `users.is_platform_admin = 1` und `password_hash`.</li>
|
||||
<li>Nach dem Login stehen Tenant-Verwaltung und Legacy-Migration bereit.</li>
|
||||
</ul>
|
||||
</article>
|
||||
</section>
|
||||
<?php elseif ($page === 'tenants'): ?>
|
||||
<section class="hero">
|
||||
<div class="eyebrow">Tenant-Verwaltung</div>
|
||||
<h1>Mandanten zentral anlegen und pflegen</h1>
|
||||
<p>Hier verwaltet der Global-Admin die grundlegenden Tenant-Stammdaten und kommt direkt zur Datenmigration.</p>
|
||||
</section>
|
||||
<?php if ($dbError !== null): ?><section class="alert alert-warning"><?= admin_h($dbError) ?></section><?php endif; ?>
|
||||
<section class="grid grid-2">
|
||||
<article class="card">
|
||||
<h2><?= $editingTenant !== null ? 'Tenant bearbeiten' : 'Neuen Tenant anlegen' ?></h2>
|
||||
<form method="post" action="/admin/tenants" class="grid">
|
||||
<input type="hidden" name="csrf" value="<?= admin_h($_SESSION['admin_csrf']) ?>">
|
||||
<input type="hidden" name="action" value="save-tenant">
|
||||
<input type="hidden" name="tenant_id" value="<?= admin_h((string) ($editingTenant['id'] ?? '')) ?>">
|
||||
<label>Tenant-Key<input name="tenant_key" value="<?= admin_h((string) ($editingTenant['tenant_key'] ?? '')) ?>" placeholder="werk-berlin"></label>
|
||||
<label>Name<input name="name" value="<?= admin_h((string) ($editingTenant['name'] ?? '')) ?>" placeholder="Werk Berlin"></label>
|
||||
<label>Status<select name="status"><?php foreach (['active' => 'Aktiv', 'inactive' => 'Inaktiv', 'sandbox' => 'Sandbox'] as $value => $label): ?><option value="<?= admin_h($value) ?>"<?= (($editingTenant['status'] ?? 'active') === $value) ? ' selected' : '' ?>><?= admin_h($label) ?></option><?php endforeach; ?></select></label>
|
||||
<div class="actions">
|
||||
<button type="submit">Speichern</button>
|
||||
<a class="button secondary" href="/admin/migration">Zur Migration</a>
|
||||
</div>
|
||||
</form>
|
||||
</article>
|
||||
<article class="card">
|
||||
<h2>Globaler Kontext</h2>
|
||||
<div class="stack">
|
||||
<div class="metric"><h3>Angemeldet als</h3><p><?= admin_h((string) ($admin['display_name'] ?? '')) ?></p><p class="muted"><?= admin_h((string) ($admin['email'] ?? '')) ?></p></div>
|
||||
<div class="metric"><h3>Naechster Schritt</h3><p>Nach dem Anlegen eines Tenants kann die Legacy-Datenmigration direkt ueber die Weboberflaeche gestartet werden.</p></div>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
<section class="card" style="margin-top:18px">
|
||||
<h2>Vorhandene Tenants</h2>
|
||||
<div class="table">
|
||||
<table>
|
||||
<thead><tr><th>Name</th><th>Key</th><th>Status</th><th>Mitglieder</th><th>Admins</th><th>SSO</th><th>Aktion</th></tr></thead>
|
||||
<tbody>
|
||||
<?php foreach ($tenants as $tenant): ?>
|
||||
<tr>
|
||||
<td><strong><?= admin_h((string) $tenant['name']) ?></strong></td>
|
||||
<td><?= admin_h((string) $tenant['tenant_key']) ?></td>
|
||||
<td><?= admin_badge(((string) $tenant['status']) === 'active' ? 'Aktiv' : ucfirst((string) $tenant['status']), ((string) $tenant['status']) === 'active' ? 'success' : 'warning') ?></td>
|
||||
<td><?= admin_h((string) $tenant['member_count']) ?></td>
|
||||
<td><?= admin_h((string) $tenant['admin_count']) ?></td>
|
||||
<td><?= admin_h((string) $tenant['provider_count']) ?></td>
|
||||
<td><a class="button secondary" href="/admin/tenants?edit=<?= admin_h((string) $tenant['id']) ?>">Bearbeiten</a></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
<?php else: ?>
|
||||
<section class="hero">
|
||||
<div class="eyebrow">Legacy-Migration</div>
|
||||
<h1>Alte Kaffeelisten-Daten in das neue Zielformat uebernehmen</h1>
|
||||
<p>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.</p>
|
||||
</section>
|
||||
<?php foreach ($migrationErrors as $error): ?><section class="alert alert-error"><?= admin_h($error) ?></section><?php endforeach; ?>
|
||||
<section class="grid grid-2">
|
||||
<article class="card">
|
||||
<h2>Quelle konfigurieren</h2>
|
||||
<form method="post" action="/admin/migration" class="grid">
|
||||
<input type="hidden" name="csrf" value="<?= admin_h($_SESSION['admin_csrf']) ?>">
|
||||
<input type="hidden" name="action" value="run-legacy-migration">
|
||||
<label>Quelle<input name="SOURCE_DATABASE" value="<?= admin_h($migrationValues['SOURCE_DATABASE']) ?>" placeholder="Legacy-Datenbank"></label>
|
||||
<label>Verbindung<select name="SOURCE_CONNECTION"><?php foreach (scripts_supported_connections() as $value => $label): ?><option value="<?= admin_h($value) ?>"<?= $migrationValues['SOURCE_CONNECTION'] === $value ? ' selected' : '' ?>><?= admin_h($label) ?></option><?php endforeach; ?></select></label>
|
||||
<label>Host<input name="SOURCE_HOST" value="<?= admin_h($migrationValues['SOURCE_HOST']) ?>"></label>
|
||||
<label>Port<input name="SOURCE_PORT" value="<?= admin_h($migrationValues['SOURCE_PORT']) ?>"></label>
|
||||
<label>Benutzer<input name="SOURCE_USERNAME" value="<?= admin_h($migrationValues['SOURCE_USERNAME']) ?>"></label>
|
||||
<label>Passwort<input type="password" name="SOURCE_PASSWORD" value="<?= admin_h($migrationValues['SOURCE_PASSWORD']) ?>"></label>
|
||||
<label>Target Tenant-Key<input name="TARGET_TENANT_KEY" value="<?= admin_h($migrationValues['TARGET_TENANT_KEY']) ?>"></label>
|
||||
<label>Target Tenant-Name<input name="TARGET_TENANT_NAME" value="<?= admin_h($migrationValues['TARGET_TENANT_NAME']) ?>"></label>
|
||||
<div class="actions"><button type="submit">Migration starten</button></div>
|
||||
</form>
|
||||
</article>
|
||||
<article class="card">
|
||||
<h2>Ziel</h2>
|
||||
<ul>
|
||||
<li>Ziel-Datenbank: <code><?= admin_h((string) ($env['DB_CONNECTION'] ?? 'mysql')) ?></code></li>
|
||||
<li>Host: <code><?= admin_h((string) ($env['DB_HOST'] ?? '')) ?></code></li>
|
||||
<li>Datenbank: <code><?= admin_h((string) ($env['DB_DATABASE'] ?? '')) ?></code></li>
|
||||
<li>Die Migration ist idempotent fuer Mitglieder, Tenants und die importierten Kernbuchungen ausgelegt.</li>
|
||||
<li>Fuer SQL Server als Quelle wird auf dem Hosting <code>pdo_sqlsrv</code> benoetigt. Ohne diesen Treiber sollte die Legacy-Datenbank zuerst nach MySQL/MariaDB exportiert werden.</li>
|
||||
</ul>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<?php if (is_array($migrationResult)): ?>
|
||||
<section class="grid grid-3" style="margin-top:18px">
|
||||
<?php foreach (($migrationResult['counts'] ?? []) as $label => $value): ?>
|
||||
<article class="metric">
|
||||
<h3><?= admin_h(str_replace('_', ' ', $label)) ?></h3>
|
||||
<p><?= admin_h((string) $value) ?></p>
|
||||
</article>
|
||||
<?php endforeach; ?>
|
||||
</section>
|
||||
<section class="grid grid-2" style="margin-top:18px">
|
||||
<article class="card">
|
||||
<h2>Ergebnis</h2>
|
||||
<ul>
|
||||
<li>Schema neu angelegt: <?= admin_h(($migrationResult['schema_created'] ?? false) ? 'ja' : 'nein') ?></li>
|
||||
<li>Target Tenant: <code><?= admin_h((string) ($migrationResult['tenant']['tenant_key'] ?? '')) ?></code> - <?= admin_h((string) ($migrationResult['tenant']['name'] ?? '')) ?></li>
|
||||
</ul>
|
||||
</article>
|
||||
<article class="card">
|
||||
<h2>Erkannte Legacy-Tabellen</h2>
|
||||
<ul>
|
||||
<?php foreach (($migrationResult['source_tables'] ?? []) as $entry): ?>
|
||||
<li><code><?= admin_h((string) $entry['table']) ?></code>: <?= admin_h($entry['exists'] ? 'vorhanden' : 'fehlt') ?></li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</article>
|
||||
</section>
|
||||
<?php if (($migrationResult['warnings'] ?? []) !== []): ?>
|
||||
<section class="card" style="margin-top:18px"><h2>Warnungen</h2><ul><?php foreach ($migrationResult['warnings'] as $warning): ?><li><?= admin_h((string) $warning) ?></li><?php endforeach; ?></ul></section>
|
||||
<?php endif; ?>
|
||||
<?php if (($migrationResult['skipped'] ?? []) !== []): ?>
|
||||
<section class="card" style="margin-top:18px"><h2>Nicht automatisch uebernommen</h2><ul><?php foreach ($migrationResult['skipped'] as $warning): ?><li><?= admin_h((string) $warning) ?></li><?php endforeach; ?></ul></section>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
|
||||
<p class="footer">Kaffeeliste Admin | Global-Admin Login, Tenant-Verwaltung und Legacy-Migration ueber PHP-Webseiten</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require dirname(__DIR__) . '/admin.php';
|
||||
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require dirname(__DIR__, 2) . '/admin.php';
|
||||
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
require dirname(__DIR__, 2) . '/admin.php';
|
||||
@@ -2,6 +2,4 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$_GET['page'] = 'tenants';
|
||||
|
||||
require dirname(__DIR__, 2) . '/index.php';
|
||||
require dirname(__DIR__, 2) . '/admin.php';
|
||||
|
||||
@@ -137,6 +137,11 @@ function app_login_state(): array
|
||||
return $_SESSION['login_state'] ?? [];
|
||||
}
|
||||
|
||||
function app_admin_login_state(): array
|
||||
{
|
||||
return $_SESSION['admin_login_state'] ?? [];
|
||||
}
|
||||
|
||||
function app_set_login_state(array $state): void
|
||||
{
|
||||
$_SESSION['login_state'] = $state;
|
||||
@@ -147,19 +152,34 @@ function app_clear_login_state(): void
|
||||
unset($_SESSION['login_state']);
|
||||
}
|
||||
|
||||
function app_clear_admin_login_state(): void
|
||||
{
|
||||
unset($_SESSION['admin_login_state']);
|
||||
}
|
||||
|
||||
function app_auth_user(): ?array
|
||||
{
|
||||
return $_SESSION['auth_user'] ?? null;
|
||||
}
|
||||
|
||||
function app_admin_user(): ?array
|
||||
{
|
||||
return $_SESSION['admin_user'] ?? null;
|
||||
}
|
||||
|
||||
function app_set_auth_user(array $user): void
|
||||
{
|
||||
$_SESSION['auth_user'] = $user;
|
||||
}
|
||||
|
||||
function app_set_admin_user(array $user): void
|
||||
{
|
||||
$_SESSION['admin_user'] = $user;
|
||||
}
|
||||
|
||||
function app_logout(): void
|
||||
{
|
||||
unset($_SESSION['auth_user'], $_SESSION['login_state']);
|
||||
unset($_SESSION['auth_user'], $_SESSION['admin_user'], $_SESSION['login_state'], $_SESSION['admin_login_state']);
|
||||
}
|
||||
|
||||
function app_redirect(string $path): never
|
||||
@@ -189,6 +209,18 @@ function app_require_auth(): array
|
||||
return $auth;
|
||||
}
|
||||
|
||||
function app_require_platform_admin(): array
|
||||
{
|
||||
$admin = app_admin_user();
|
||||
|
||||
if ($admin === null || empty($admin['is_platform_admin'])) {
|
||||
app_flash('Bitte zuerst als Global-Admin einloggen.', 'warning');
|
||||
app_redirect('/admin/login');
|
||||
}
|
||||
|
||||
return $admin;
|
||||
}
|
||||
|
||||
function app_is_platform_admin(?array $auth): bool
|
||||
{
|
||||
return is_array($auth) && !empty($auth['is_platform_admin']);
|
||||
@@ -200,13 +232,7 @@ function app_is_tenant_admin(?array $auth): bool
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (($auth['roles'] ?? []) as $role) {
|
||||
if (str_contains((string) $role, 'admin')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
return in_array('tenant_admin', $auth['roles'] ?? [], true);
|
||||
}
|
||||
|
||||
function app_can_manage_tenant(?array $auth): bool
|
||||
@@ -214,6 +240,185 @@ function app_can_manage_tenant(?array $auth): bool
|
||||
return app_is_platform_admin($auth) || app_is_tenant_admin($auth);
|
||||
}
|
||||
|
||||
function app_platform_admin_by_email(PDO $pdo, string $email): ?array
|
||||
{
|
||||
return app_query_one(
|
||||
$pdo,
|
||||
'SELECT id, email, password_hash, display_name, is_platform_admin, created_at, updated_at FROM users WHERE LOWER(email) = LOWER(:email) AND is_platform_admin = 1 LIMIT 1',
|
||||
['email' => $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
|
||||
|
||||
@@ -162,7 +162,7 @@ $canManageTenant = app_can_manage_tenant($auth);
|
||||
<nav class="links" aria-label="Navigation">
|
||||
<a href="/" class="<?= $page === 'home' ? 'active' : '' ?>">Start</a>
|
||||
<a href="/login" class="<?= $page === 'login' ? 'active' : '' ?>">Anmeldung</a>
|
||||
<a href="/tenants" class="<?= $page === 'tenants' ? 'active' : '' ?>"><?= $auth === null ? 'Fuer Betreiber' : 'Betreiber-Uebersicht' ?></a>
|
||||
<a href="<?= $auth !== null && app_is_platform_admin($auth) ? '/admin/tenants' : '/admin/login' ?>" class="<?= $page === 'tenants' ? 'active' : '' ?>"><?= $auth === null ? 'Fuer Betreiber' : 'Betreiber-Uebersicht' ?></a>
|
||||
<?php if ($auth !== null): ?>
|
||||
<a href="/dashboard" class="<?= $page === 'dashboard' ? 'active' : '' ?>">Dashboard</a>
|
||||
<a href="/content" class="<?= $page === 'content' ? 'active' : '' ?>">Hinweise</a>
|
||||
@@ -197,7 +197,7 @@ $canManageTenant = app_can_manage_tenant($auth);
|
||||
<p><?= h((string) $marketing['subhero']) ?></p>
|
||||
<div class="actions" style="margin-top:18px">
|
||||
<a class="button" href="/login">Mitglieder anmelden</a>
|
||||
<a class="button secondary" href="/tenants">Fuer Betreiber ansehen</a>
|
||||
<a class="button secondary" href="/admin/login">Fuer Betreiber anmelden</a>
|
||||
</div>
|
||||
<ul class="list">
|
||||
<?php foreach (($marketing['benefits'] ?? []) as $benefit): ?>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
</p>
|
||||
<div class="actions" style="margin-top: 18px;">
|
||||
<a class="link-button" href="./../index.php">Zur Preview</a>
|
||||
<a class="link-button" href="/admin/login">Zum Global-Admin</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -453,6 +501,18 @@ function h(string $value): string
|
||||
<span>OIDC aktivieren</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="field full">
|
||||
<label for="ADMIN_DISPLAY_NAME">Global-Admin Name</label>
|
||||
<input id="ADMIN_DISPLAY_NAME" name="ADMIN_DISPLAY_NAME" value="<?= h($adminValues['ADMIN_DISPLAY_NAME']) ?>" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="ADMIN_EMAIL">Global-Admin E-Mail</label>
|
||||
<input id="ADMIN_EMAIL" name="ADMIN_EMAIL" type="email" value="<?= h($adminValues['ADMIN_EMAIL']) ?>" required>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="ADMIN_PASSWORD">Global-Admin Passwort</label>
|
||||
<input id="ADMIN_PASSWORD" name="ADMIN_PASSWORD" type="password" value="<?= h($adminValues['ADMIN_PASSWORD']) ?>" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stack">
|
||||
|
||||
Reference in New Issue
Block a user