Просмотр исходного кода

adapting admin panel:
- authentication
- better display of data

Medowar 1 месяц назад
Родитель
Сommit
d0a7e5f5c0
10 измененных файлов с 462 добавлено и 56 удалено
  1. 5 4
      README.md
  2. 103 24
      admin/application.php
  3. 53 0
      admin/export-pdf.php
  4. 32 10
      admin/index.php
  5. 15 2
      admin/login.php
  6. 162 0
      assets/css/base.css
  7. 8 4
      config/app.sample.php
  8. 48 0
      docs/AUTH_INTEGRATION.md
  9. 1 1
      docs/OPERATIONS.md
  10. 35 11
      src/Admin/Auth.php

+ 5 - 4
README.md

@@ -44,12 +44,12 @@ Schlankes PHP-Flatfile-Projekt für einen digitalen Mitgliedsantrag (deutsches F
    - `cp config/app.sample.php config/app.local.php`
    - `cp config/app.sample.php config/app.local.php`
    - `cp config/mail.sample.php config/mail.local.php`
    - `cp config/mail.sample.php config/mail.local.php`
 6. Konfiguration anpassen:
 6. Konfiguration anpassen:
-   - `config/app.local.php` (Admin-Passwort, Kontakt, Disclaimer, Retention, Rate Limit)
+   - `config/app.local.php` (Admin-Credentials, Kontakt, Disclaimer, Retention, Rate Limit)
    - `config/mail.local.php` (Absender, Empfänger)
    - `config/mail.local.php` (Absender, Empfänger)
-7. Admin-Hash setzen:
+7. Admin-Credential setzen:
    - Auf Server: `php -r "echo password_hash('DEIN-PASSWORT', PASSWORD_DEFAULT), PHP_EOL;"`
    - Auf Server: `php -r "echo password_hash('DEIN-PASSWORT', PASSWORD_DEFAULT), PHP_EOL;"`
-   - Ergebnis in `config/app.local.php -> admin.password_hash`
-   - Danach `password_plain_fallback` entfernen/leer setzen.
+   - Ergebnis in `config/app.local.php -> admin.credentials[*].password_hash`
+   - Benutzername in `config/app.local.php -> admin.credentials[*].username`
 8. Cronjob einrichten (täglich):
 8. Cronjob einrichten (täglich):
    - `php /pfad/zum/projekt/bin/cleanup.php`
    - `php /pfad/zum/projekt/bin/cleanup.php`
 
 
@@ -82,3 +82,4 @@ Lokale PHP-Laufzeit wird benötigt (CLI + Webserver), um Syntaxchecks/Tests ausz
 - `docs/FORM_SCHEMA.md`
 - `docs/FORM_SCHEMA.md`
 - `docs/OPERATIONS.md`
 - `docs/OPERATIONS.md`
 - `docs/RATE_LIMITING.md`
 - `docs/RATE_LIMITING.md`
+- `docs/AUTH_INTEGRATION.md`

+ 103 - 24
admin/application.php

@@ -4,6 +4,8 @@ declare(strict_types=1);
 
 
 use App\App\Bootstrap;
 use App\App\Bootstrap;
 use App\Admin\Auth;
 use App\Admin\Auth;
+use App\Form\FormSchema;
+use App\Mail\SubmissionFormatter;
 use App\Security\Csrf;
 use App\Security\Csrf;
 use App\Storage\JsonStore;
 use App\Storage\JsonStore;
 
 
@@ -24,6 +26,16 @@ if ($submission === null) {
     exit;
     exit;
 }
 }
 
 
+$schema = new FormSchema();
+$formatter = new SubmissionFormatter($schema);
+$formattedSteps = $formatter->formatSteps($submission);
+$uploadFields = $schema->getUploadFields();
+
+$formData = (array) ($submission['form_data'] ?? []);
+$uploads = (array) ($submission['uploads'] ?? []);
+$firstName = (string) ($formData['vorname'] ?? '');
+$lastName = (string) ($formData['nachname'] ?? '');
+
 $csrf = Csrf::token();
 $csrf = Csrf::token();
 ?><!doctype html>
 ?><!doctype html>
 <html lang="de">
 <html lang="de">
@@ -46,44 +58,111 @@ $csrf = Csrf::token();
 <main class="container">
 <main class="container">
     <section class="card">
     <section class="card">
         <p><a href="<?= htmlspecialchars(Bootstrap::url('admin/index.php')) ?>">Zur Übersicht</a></p>
         <p><a href="<?= htmlspecialchars(Bootstrap::url('admin/index.php')) ?>">Zur Übersicht</a></p>
-        <h1>Antragsdetails</h1>
-        <p><strong>E-Mail:</strong> <?= htmlspecialchars((string) ($submission['email'] ?? '')) ?></p>
-        <p><strong>Eingereicht:</strong> <?= htmlspecialchars((string) ($submission['submitted_at'] ?? '')) ?></p>
 
 
-        <h2>Formulardaten</h2>
+        <div class="admin-detail-header">
+            <h1>Antragsdetails</h1>
+            <div class="admin-inline-actions">
+                <form method="get" action="<?= htmlspecialchars(Bootstrap::url('admin/export-pdf.php')) ?>">
+                    <input type="hidden" name="id" value="<?= htmlspecialchars((string) ($submission['application_key'] ?? '')) ?>">
+                    <button type="submit" class="btn btn-small">Export als PDF</button>
+                </form>
+                <?php if (!empty($uploads)): ?>
+                    <form method="get" action="<?= htmlspecialchars(Bootstrap::url('admin/download-zip.php')) ?>">
+                        <input type="hidden" name="id" value="<?= htmlspecialchars((string) ($submission['application_key'] ?? '')) ?>">
+                        <button type="submit" class="btn btn-small">Alle Uploads als ZIP herunterladen</button>
+                    </form>
+                <?php endif; ?>
+            </div>
+        </div>
+
         <div class="table-responsive">
         <div class="table-responsive">
-            <table class="table-compact">
+            <table class="table-compact table-dense admin-meta-table">
                 <tbody>
                 <tbody>
-                    <?php foreach ((array) ($submission['form_data'] ?? []) as $key => $value): ?>
-                        <tr>
-                            <th><?= htmlspecialchars((string) $key) ?></th>
-                            <td><?= htmlspecialchars(is_scalar($value) ? (string) $value : json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)) ?></td>
-                        </tr>
-                    <?php endforeach; ?>
+                    <tr>
+                        <th>Vorname</th>
+                        <td><?= htmlspecialchars($firstName !== '' ? $firstName : '-') ?></td>
+                        <th>Nachname</th>
+                        <td><?= htmlspecialchars($lastName !== '' ? $lastName : '-') ?></td>
+                    </tr>
+                    <tr>
+                        <th>E-Mail</th>
+                        <td><?= htmlspecialchars((string) ($submission['email'] ?? '')) ?></td>
+                        <th>Eingereicht</th>
+                        <td><?= htmlspecialchars((string) ($submission['submitted_at'] ?? '')) ?></td>
+                    </tr>
                 </tbody>
                 </tbody>
             </table>
             </table>
         </div>
         </div>
 
 
+        <h2>Formulardaten</h2>
+        <?php if ($formattedSteps === []): ?>
+            <p>Keine Formulardaten vorhanden.</p>
+        <?php else: ?>
+            <?php foreach ($formattedSteps as $step): ?>
+                <section class="admin-step-block">
+                    <h3><?= htmlspecialchars((string) ($step['title'] ?? '')) ?></h3>
+                    <div class="table-responsive">
+                        <table class="table-compact table-dense admin-form-data-table">
+                            <tbody>
+                                <?php foreach ((array) ($step['fields'] ?? []) as $field): ?>
+                                    <tr>
+                                        <th><?= htmlspecialchars((string) ($field['label'] ?? '')) ?></th>
+                                        <td><?= nl2br(htmlspecialchars((string) ($field['value'] ?? '')), false) ?></td>
+                                    </tr>
+                                <?php endforeach; ?>
+                            </tbody>
+                        </table>
+                    </div>
+                </section>
+            <?php endforeach; ?>
+        <?php endif; ?>
+
         <h2>Uploads</h2>
         <h2>Uploads</h2>
-        <?php if (empty($submission['uploads'])): ?>
+        <?php if ($uploads === []): ?>
             <p>Keine Uploads vorhanden.</p>
             <p>Keine Uploads vorhanden.</p>
         <?php else: ?>
         <?php else: ?>
-            <p><a href="<?= htmlspecialchars(Bootstrap::url('admin/download-zip.php?id=' . urlencode((string) ($submission['application_key'] ?? '')))) ?>">Alle Uploads als ZIP herunterladen</a></p>
-            <?php foreach ((array) $submission['uploads'] as $field => $files): ?>
-                <h3><?= htmlspecialchars((string) $field) ?></h3>
-                <ul>
-                    <?php foreach ((array) $files as $idx => $file): ?>
-                        <li>
-                            <?= htmlspecialchars((string) ($file['original_filename'] ?? 'Datei')) ?>
-                            - <a href="<?= htmlspecialchars(Bootstrap::url('admin/download.php?id=' . urlencode((string) ($submission['application_key'] ?? '')) . '&field=' . urlencode((string) $field) . '&index=' . urlencode((string) $idx))) ?>">Download</a>
-                        </li>
-                    <?php endforeach; ?>
-                </ul>
+            <?php $shownUploadKeys = []; ?>
+            <?php foreach ($uploadFields as $fieldKey => $fieldDef):
+                $files = $uploads[$fieldKey] ?? [];
+                if (!is_array($files) || $files === []) {
+                    continue;
+                }
+                $shownUploadKeys[] = $fieldKey;
+                $uploadLabel = (string) ($fieldDef['label'] ?? $fieldKey);
+            ?>
+                <div class="admin-upload-group">
+                    <h3><?= htmlspecialchars($uploadLabel) ?></h3>
+                    <ul class="admin-uploads-list">
+                        <?php foreach ($files as $idx => $file): ?>
+                            <li>
+                                <?= htmlspecialchars((string) ($file['original_filename'] ?? 'Datei')) ?>
+                                - <a href="<?= htmlspecialchars(Bootstrap::url('admin/download.php?id=' . urlencode((string) ($submission['application_key'] ?? '')) . '&field=' . urlencode((string) $fieldKey) . '&index=' . urlencode((string) $idx))) ?>">Download</a>
+                            </li>
+                        <?php endforeach; ?>
+                    </ul>
+                </div>
+            <?php endforeach; ?>
+            <?php foreach ($uploads as $fieldKey => $files):
+                if (in_array((string) $fieldKey, $shownUploadKeys, true) || !is_array($files) || $files === []) {
+                    continue;
+                }
+            ?>
+                <div class="admin-upload-group">
+                    <h3><?= htmlspecialchars((string) $fieldKey) ?></h3>
+                    <ul class="admin-uploads-list">
+                        <?php foreach ($files as $idx => $file): ?>
+                            <li>
+                                <?= htmlspecialchars((string) ($file['original_filename'] ?? 'Datei')) ?>
+                                - <a href="<?= htmlspecialchars(Bootstrap::url('admin/download.php?id=' . urlencode((string) ($submission['application_key'] ?? '')) . '&field=' . urlencode((string) $fieldKey) . '&index=' . urlencode((string) $idx))) ?>">Download</a>
+                            </li>
+                        <?php endforeach; ?>
+                    </ul>
+                </div>
             <?php endforeach; ?>
             <?php endforeach; ?>
         <?php endif; ?>
         <?php endif; ?>
 
 
         <h2>Löschen</h2>
         <h2>Löschen</h2>
-        <form method="post" action="<?= htmlspecialchars(Bootstrap::url('admin/delete.php')) ?>" onsubmit="return confirm('Antrag wirklich löschen?');">
+        <form method="post" action="<?= htmlspecialchars(Bootstrap::url('admin/delete.php')) ?>" onsubmit="return confirm('Antrag wirklich löschen? Der Antrag wird für alle Benutzer unwiederbringlich entfernt.');">
             <input type="hidden" name="csrf" value="<?= htmlspecialchars($csrf) ?>">
             <input type="hidden" name="csrf" value="<?= htmlspecialchars($csrf) ?>">
             <input type="hidden" name="id" value="<?= htmlspecialchars((string) ($submission['application_key'] ?? '')) ?>">
             <input type="hidden" name="id" value="<?= htmlspecialchars((string) ($submission['application_key'] ?? '')) ?>">
             <button type="submit" class="btn">Antrag löschen</button>
             <button type="submit" class="btn">Antrag löschen</button>

+ 53 - 0
admin/export-pdf.php

@@ -0,0 +1,53 @@
+<?php
+
+declare(strict_types=1);
+
+use App\Admin\Auth;
+use App\App\Bootstrap;
+use App\Form\FormSchema;
+use App\Mail\PdfGenerator;
+use App\Mail\SubmissionFormatter;
+use App\Storage\JsonStore;
+
+require dirname(__DIR__) . '/src/autoload.php';
+Bootstrap::init();
+
+$auth = new Auth();
+$auth->requireLogin();
+
+$id = trim((string) ($_GET['id'] ?? ''));
+$store = new JsonStore();
+$submission = $store->getSubmissionByKey($id);
+if ($submission === null) {
+    http_response_code(404);
+    echo 'Antrag nicht gefunden.';
+    exit;
+}
+
+$schema = new FormSchema();
+$formatter = new SubmissionFormatter($schema);
+$pdfGenerator = new PdfGenerator($formatter, $schema);
+$pdfPath = $pdfGenerator->generateFormDataPdf($submission);
+
+if ($pdfPath === null || !is_file($pdfPath)) {
+    http_response_code(500);
+    echo 'PDF konnte nicht erstellt werden.';
+    exit;
+}
+
+$formData = (array) ($submission['form_data'] ?? []);
+$firstName = trim((string) ($formData['vorname'] ?? ''));
+$lastName = trim((string) ($formData['nachname'] ?? ''));
+$namePart = trim($firstName . '_' . $lastName, '_');
+
+$downloadName = $namePart !== '' ? 'Antragsdaten_' . $namePart . '.pdf' : 'Antragsdaten.pdf';
+$downloadName = str_replace(["\r", "\n"], '', $downloadName);
+$fallbackName = preg_replace('/[^A-Za-z0-9._-]/', '_', $downloadName) ?: 'Antragsdaten.pdf';
+$encodedName = rawurlencode($downloadName);
+
+header('Content-Type: application/pdf');
+header('Content-Length: ' . (string) filesize($pdfPath));
+header('Content-Disposition: attachment; filename="' . $fallbackName . '"; filename*=UTF-8\'\'' . $encodedName);
+readfile($pdfPath);
+unlink($pdfPath);
+exit;

+ 32 - 10
admin/index.php

@@ -19,7 +19,20 @@ $list = $store->listSubmissions();
 $query = trim((string) ($_GET['q'] ?? ''));
 $query = trim((string) ($_GET['q'] ?? ''));
 if ($query !== '') {
 if ($query !== '') {
     $list = array_values(array_filter($list, static function (array $item) use ($query): bool {
     $list = array_values(array_filter($list, static function (array $item) use ($query): bool {
-        return strpos(strtolower((string) ($item['email'] ?? '')), strtolower($query)) !== false;
+        $formData = (array) ($item['form_data'] ?? []);
+        $haystack = [
+            strtolower((string) ($formData['vorname'] ?? '')),
+            strtolower((string) ($formData['nachname'] ?? '')),
+            strtolower((string) ($item['email'] ?? '')),
+        ];
+
+        foreach ($haystack as $value) {
+            if ($value !== '' && strpos($value, strtolower($query)) !== false) {
+                return true;
+            }
+        }
+
+        return false;
     }));
     }));
 }
 }
 ?><!doctype html>
 ?><!doctype html>
@@ -42,29 +55,38 @@ if ($query !== '') {
 </header>
 </header>
 <main class="container">
 <main class="container">
     <section class="card">
     <section class="card">
-        <h1>Abgeschlossene Anträge</h1>
-        <p><a href="<?= htmlspecialchars(Bootstrap::url('admin/login.php?logout=1')) ?>">Abmelden</a></p>
-
-        <form method="get" class="field">
-            <label for="q">Suche E-Mail</label>
-            <input id="q" name="q" value="<?= htmlspecialchars($query) ?>">
-        </form>
+        <div class="admin-toolbar">
+            <div>
+                <h1>Abgeschlossene Anträge</h1>
+                <a href="<?= htmlspecialchars(Bootstrap::url('admin/login.php?logout=1')) ?>">Abmelden</a>
+            </div>
+            <form method="get" class="field">
+                <label for="q">Suche Name oder E-Mail</label>
+                <input id="q" name="q" value="<?= htmlspecialchars($query) ?>">
+            </form>
+        </div>
 
 
         <?php if (empty($list)): ?>
         <?php if (empty($list)): ?>
             <p>Keine Anträge vorhanden.</p>
             <p>Keine Anträge vorhanden.</p>
         <?php else: ?>
         <?php else: ?>
             <div class="table-responsive">
             <div class="table-responsive">
-                <table class="responsive-table">
+                <table class="responsive-table table-dense admin-submissions-table">
                     <thead>
                     <thead>
                         <tr>
                         <tr>
+                            <th>Vorname</th>
+                            <th>Nachname</th>
                             <th>E-Mail</th>
                             <th>E-Mail</th>
                             <th>Eingereicht</th>
                             <th>Eingereicht</th>
                             <th>Aktion</th>
                             <th>Aktion</th>
                         </tr>
                         </tr>
                     </thead>
                     </thead>
                     <tbody>
                     <tbody>
-                        <?php foreach ($list as $item): ?>
+                        <?php foreach ($list as $item):
+                            $formData = (array) ($item['form_data'] ?? []);
+                        ?>
                             <tr>
                             <tr>
+                                <td data-label="Vorname"><?= htmlspecialchars((string) ($formData['vorname'] ?? '')) ?></td>
+                                <td data-label="Nachname"><?= htmlspecialchars((string) ($formData['nachname'] ?? '')) ?></td>
                                 <td data-label="E-Mail"><?= htmlspecialchars((string) ($item['email'] ?? '')) ?></td>
                                 <td data-label="E-Mail"><?= htmlspecialchars((string) ($item['email'] ?? '')) ?></td>
                                 <td data-label="Eingereicht"><?= htmlspecialchars((string) ($item['submitted_at'] ?? '')) ?></td>
                                 <td data-label="Eingereicht"><?= htmlspecialchars((string) ($item['submitted_at'] ?? '')) ?></td>
                                 <td data-label="Aktion"><a href="<?= htmlspecialchars(Bootstrap::url('admin/application.php?id=' . urlencode((string) ($item['application_key'] ?? '')))) ?>">Details</a></td>
                                 <td data-label="Aktion"><a href="<?= htmlspecialchars(Bootstrap::url('admin/application.php?id=' . urlencode((string) ($item['application_key'] ?? '')))) ?>">Details</a></td>

+ 15 - 2
admin/login.php

@@ -24,12 +24,14 @@ if ($auth->isLoggedIn()) {
 }
 }
 
 
 $error = '';
 $error = '';
+$username = '';
 if ($_SERVER['REQUEST_METHOD'] === 'POST') {
 if ($_SERVER['REQUEST_METHOD'] === 'POST') {
     if (!Csrf::validate((string) ($_POST['csrf'] ?? ''))) {
     if (!Csrf::validate((string) ($_POST['csrf'] ?? ''))) {
         $error = 'Ungültiges CSRF-Token.';
         $error = 'Ungültiges CSRF-Token.';
     } else {
     } else {
+        $username = trim((string) ($_POST['username'] ?? ''));
         $password = (string) ($_POST['password'] ?? '');
         $password = (string) ($_POST['password'] ?? '');
-        if ($auth->login($password)) {
+        if ($auth->login($username, $password)) {
             header('Location: ' . Bootstrap::url('admin/index.php'));
             header('Location: ' . Bootstrap::url('admin/index.php'));
             exit;
             exit;
         }
         }
@@ -64,9 +66,20 @@ $csrf = Csrf::token();
         <?php endif; ?>
         <?php endif; ?>
         <form method="post">
         <form method="post">
             <input type="hidden" name="csrf" value="<?= htmlspecialchars($csrf) ?>">
             <input type="hidden" name="csrf" value="<?= htmlspecialchars($csrf) ?>">
+            <div class="field">
+                <label for="username">Benutzername</label>
+                <input
+                    id="username"
+                    name="username"
+                    type="text"
+                    required
+                    autocomplete="username"
+                    value="<?= htmlspecialchars($username) ?>"
+                >
+            </div>
             <div class="field">
             <div class="field">
                 <label for="password">Passwort</label>
                 <label for="password">Passwort</label>
-                <input id="password" name="password" type="password" required>
+                <input id="password" name="password" type="password" required autocomplete="current-password">
             </div>
             </div>
             <button type="submit" class="btn">Anmelden</button>
             <button type="submit" class="btn">Anmelden</button>
         </form>
         </form>

+ 162 - 0
assets/css/base.css

@@ -138,6 +138,125 @@ p {
   flex-wrap: wrap;
   flex-wrap: wrap;
 }
 }
 
 
+body.admin-page main {
+  min-height: calc(100vh - 140px);
+  padding: 1rem 0 1.5rem;
+}
+
+body.admin-page .card {
+  padding: 1rem 1.1rem;
+  margin-bottom: 1rem;
+}
+
+body.admin-page h1 {
+  margin-bottom: 0.45rem;
+  font-size: 1.35rem;
+}
+
+body.admin-page h2 {
+  margin: 1rem 0 0.55rem;
+  font-size: 1.1rem;
+}
+
+body.admin-page h3 {
+  margin: 0.65rem 0 0.4rem;
+  font-size: 0.98rem;
+}
+
+.admin-toolbar {
+  display: flex;
+  align-items: flex-end;
+  justify-content: space-between;
+  gap: 0.75rem;
+  margin-bottom: 0.9rem;
+  flex-wrap: wrap;
+}
+
+.admin-toolbar .field {
+  margin-bottom: 0;
+  flex: 1;
+  min-width: 260px;
+}
+
+.admin-toolbar .field input {
+  max-width: 440px;
+}
+
+.admin-inline-actions {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 0.5rem;
+  margin-bottom: 1rem;
+}
+
+.admin-inline-actions form {
+  margin: 0;
+}
+
+.admin-inline-actions .btn {
+  width: auto;
+  padding: 0.48rem 0.75rem;
+}
+
+.admin-detail-header {
+  display: flex;
+  align-items: flex-start;
+  justify-content: space-between;
+  gap: 0.75rem;
+  margin-bottom: 0.75rem;
+}
+
+.admin-detail-header h1 {
+  margin: 0;
+}
+
+.admin-detail-header .admin-inline-actions {
+  justify-content: flex-end;
+  margin-bottom: 0;
+}
+
+.admin-meta-table {
+  margin-bottom: 0.9rem;
+}
+
+.admin-meta-table th {
+  background: var(--brand-surface-alt);
+  color: var(--brand-text);
+  font-weight: 700;
+  white-space: nowrap;
+}
+
+.admin-meta-table td {
+  background: var(--brand-surface);
+}
+
+.admin-form-data-table {
+  table-layout: fixed;
+}
+
+.admin-form-data-table th,
+.admin-form-data-table td {
+  width: 50%;
+  word-break: break-word;
+}
+
+.admin-step-block + .admin-step-block {
+  margin-top: 0.75rem;
+}
+
+.admin-upload-group + .admin-upload-group {
+  margin-top: 0.75rem;
+}
+
+.admin-uploads-list {
+  margin: 0;
+  padding-left: 1.2rem;
+}
+
+.admin-uploads-list li {
+  margin: 0.2rem 0;
+}
+
 .admin-stats {
 .admin-stats {
   display: grid;
   display: grid;
   grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
   grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
@@ -708,6 +827,18 @@ tr:hover td {
   font-size: 0.9rem;
   font-size: 0.9rem;
 }
 }
 
 
+.table-dense th,
+.table-dense td {
+  padding: 0.4rem 0.55rem;
+  font-size: 0.88rem;
+  line-height: 1.35;
+}
+
+.admin-submissions-table td:last-child {
+  width: 1%;
+  white-space: nowrap;
+}
+
 .products-grid {
 .products-grid {
   display: grid;
   display: grid;
   grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
   grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
@@ -927,6 +1058,28 @@ tr:hover td {
     align-items: flex-start;
     align-items: flex-start;
   }
   }
 
 
+  .admin-toolbar {
+    align-items: stretch;
+  }
+
+  .admin-toolbar .field {
+    width: 100%;
+    min-width: 0;
+  }
+
+  .admin-toolbar .field input {
+    max-width: none;
+  }
+
+  .admin-detail-header {
+    align-items: stretch;
+    flex-direction: column;
+  }
+
+  .admin-detail-header .admin-inline-actions {
+    justify-content: flex-start;
+  }
+
   th,
   th,
   td {
   td {
     padding: 0.65rem;
     padding: 0.65rem;
@@ -1015,6 +1168,15 @@ tr:hover td {
   .table-responsive table:not(.responsive-table) {
   .table-responsive table:not(.responsive-table) {
     min-width: 520px;
     min-width: 520px;
   }
   }
+
+  .admin-meta-table,
+  .admin-meta-table tbody,
+  .admin-meta-table tr,
+  .admin-meta-table th,
+  .admin-meta-table td {
+    display: block;
+    width: 100%;
+  }
 }
 }
 
 
 @media print {
 @media print {

+ 8 - 4
config/app.sample.php

@@ -36,10 +36,14 @@ return [
         'window_seconds' => 300,
         'window_seconds' => 300,
     ],
     ],
     'admin' => [
     'admin' => [
-        // Hash mit password_hash('DEIN-PASSWORT', PASSWORD_DEFAULT) erzeugen.
-        'password_hash' => '',
-        // Fallback nur fuer initiales Setup, danach loeschen.
-        'password_plain_fallback' => '',
+        // Feste Zugangsdaten als Tabelle (hardcoded).
+        // Hash mit: php -r "echo password_hash('DEIN-PASSWORT', PASSWORD_DEFAULT), PHP_EOL;"
+        'credentials' => [
+            [
+                'username' => 'admin',
+                'password_hash' => '',
+            ],
+        ],
         'session_timeout_seconds' => 3600,
         'session_timeout_seconds' => 3600,
     ],
     ],
     'storage' => [
     'storage' => [

+ 48 - 0
docs/AUTH_INTEGRATION.md

@@ -0,0 +1,48 @@
+# Auth Integration (Username + Password)
+
+Diese Anwendung nutzt eine statische Credential-Tabelle in `config/app.local.php`.
+
+## 1) Konfigurationsformat
+
+```php
+'admin' => [
+    'credentials' => [
+        [
+            'username' => 'admin',
+            'password_hash' => '$2y$12$...',
+        ],
+    ],
+    'session_timeout_seconds' => 3600,
+],
+```
+
+- `username`: Klartextname fuer Login.
+- `password_hash`: Ergebnis von `password_hash(...)`.
+- Keine Plaintext-Passwoerter speichern.
+
+## 2) Passwort-Hash erzeugen
+
+```bash
+php -r "echo password_hash('DEIN-PASSWORT', PASSWORD_DEFAULT), PHP_EOL;"
+```
+
+Hash in `admin.credentials[*].password_hash` eintragen.
+
+## 3) Login-Pruefung (Prinzip)
+
+1. Benutzername + Passwort entgegennehmen.
+2. Passenden Eintrag in `admin.credentials` anhand `username` suchen.
+3. Passwort mit `password_verify($plain, $hash)` pruefen.
+4. Bei Erfolg Session setzen.
+
+## 4) Integration in weitere Services (gleiches Subdomain-Umfeld)
+
+Einfachster Weg:
+
+1. Gleiches Tabellenformat (`admin.credentials`) in jeder App verwenden.
+2. Dieselben User/Hashes in den lokalen Configs pflegen.
+3. In jeder App denselben Ablauf mit `password_verify(...)` verwenden.
+
+Hinweis:
+- Das ist kein SSO. Jede App hat weiterhin ihre eigene Session.
+- Fuer spaeteres SSO kann dieselbe Credential-Tabelle als gemeinsame Basis dienen.

+ 1 - 1
docs/OPERATIONS.md

@@ -67,7 +67,7 @@ Regelmäßig sichern:
 
 
 - Keine Mails: Mailfunktion des Hosters prüfen, `mail.log` ansehen.
 - Keine Mails: Mailfunktion des Hosters prüfen, `mail.log` ansehen.
 - Upload Fehler: `upload_max_filesize` / `post_max_size` und Schema-Limits prüfen.
 - Upload Fehler: `upload_max_filesize` / `post_max_size` und Schema-Limits prüfen.
-- Login geht nicht: `config/app.local.php -> admin.password_hash` prüfen, ggf. temporär `password_plain_fallback` nutzen.
+- Login geht nicht: `config/app.local.php -> admin.credentials` prüfen (username + password_hash).
 - ZIP Download fehlgeschlagen: `ZipArchive` Erweiterung auf Hosting prüfen.
 - ZIP Download fehlgeschlagen: `ZipArchive` Erweiterung auf Hosting prüfen.
 - Viele `429` Antworten: `docs/RATE_LIMITING.md` prüfen, Limits anpassen oder `storage/rate_limit/` kontrollieren.
 - Viele `429` Antworten: `docs/RATE_LIMITING.md` prüfen, Limits anpassen oder `storage/rate_limit/` kontrollieren.
 - 500 ohne Apache/PHP-Fehlerausgabe: `storage/logs/php_fatal.log` und `storage/logs/php_runtime.log` prüfen.
 - 500 ohne Apache/PHP-Fehlerausgabe: `storage/logs/php_fatal.log` und `storage/logs/php_runtime.log` prüfen.

+ 35 - 11
src/Admin/Auth.php

@@ -18,24 +18,41 @@ final class Auth
 
 
     public function isLoggedIn(): bool
     public function isLoggedIn(): bool
     {
     {
-        return isset($_SESSION['admin_logged_in']) && $_SESSION['admin_logged_in'] === true;
+        return isset($_SESSION['admin_logged_in'], $_SESSION['admin_username'])
+            && $_SESSION['admin_logged_in'] === true
+            && is_string($_SESSION['admin_username'])
+            && $_SESSION['admin_username'] !== '';
     }
     }
 
 
-    public function login(string $password): bool
+    public function login(string $username, string $password): bool
     {
     {
-        $hash = (string) ($this->app['admin']['password_hash'] ?? '');
-        $plainFallback = (string) ($this->app['admin']['password_plain_fallback'] ?? '');
+        $username = trim($username);
+        if ($username === '' || $password === '') {
+            return false;
+        }
 
 
-        $valid = false;
+        $credentials = $this->credentialTable();
 
 
-        if ($hash !== '' && strncmp($hash, '$2', 2) === 0) {
-            $valid = password_verify($password, $hash);
-        } elseif ($plainFallback !== '') {
-            $valid = hash_equals($plainFallback, $password);
-        }
+        foreach ($credentials as $entry) {
+            if (!is_array($entry)) {
+                continue;
+            }
+
+            $configuredUsername = trim((string) ($entry['username'] ?? ''));
+            $hash = (string) ($entry['password_hash'] ?? '');
+
+            if ($configuredUsername === '' || $hash === '') {
+                continue;
+            }
+            if (!hash_equals(strtolower($configuredUsername), strtolower($username))) {
+                continue;
+            }
+            if (!password_verify($password, $hash)) {
+                return false;
+            }
 
 
-        if ($valid) {
             $_SESSION['admin_logged_in'] = true;
             $_SESSION['admin_logged_in'] = true;
+            $_SESSION['admin_username'] = $configuredUsername;
             $_SESSION['admin_login_at'] = time();
             $_SESSION['admin_login_at'] = time();
             session_regenerate_id(true);
             session_regenerate_id(true);
             return true;
             return true;
@@ -68,4 +85,11 @@ final class Auth
             exit;
             exit;
         }
         }
     }
     }
+
+    /** @return array<int, array{username?: mixed, password_hash?: mixed}> */
+    private function credentialTable(): array
+    {
+        $table = $this->app['admin']['credentials'] ?? [];
+        return is_array($table) ? $table : [];
+    }
 }
 }