ソースを参照

reworking Status

Josef Straßl 1 ヶ月 前
コミット
0f07ac43b7
7 ファイル変更253 行追加50 行削除
  1. 2 0
      README.md
  2. 73 0
      api/reset.php
  3. 12 0
      assets/css/base.css
  4. 154 35
      assets/js/form.js
  5. 1 0
      docs/AI_OVERVIEW.md
  6. 3 2
      docs/RATE_LIMITING.md
  7. 8 13
      index.php

+ 2 - 0
README.md

@@ -6,8 +6,10 @@ Schlankes PHP-Flatfile-Projekt für einen digitalen Mitgliedsantrag (deutsches F
 
 - Mehrstufiges Wizard-Formular
 - Autosave + Wiederaufnahme über E-Mail
+- E-Mail wird im Browser gemerkt und Formular beim Besuch automatisch geladen
 - Genau ein Antrag pro E-Mail
 - Uploads mit Original-Dateiname in kurzem Zufallsordner
+- Selbstbedienung: Gespeicherte Daten zur aktuellen E-Mail löschen und neu starten
 - Abschlussversand per E-Mail (intern + Antragsteller)
 - Admin-Bereich mit Login, Detailansicht, Download einzeln/ZIP, Löschen
 - Cleanup per Cron (Entwürfe 14 Tage, Submissions 90 Tage)

+ 73 - 0
api/reset.php

@@ -0,0 +1,73 @@
+<?php
+
+declare(strict_types=1);
+
+use App\App\Bootstrap;
+use App\Security\Csrf;
+use App\Security\RateLimiter;
+use App\Storage\FileSystem;
+use App\Storage\JsonStore;
+
+require dirname(__DIR__) . '/src/autoload.php';
+Bootstrap::init();
+
+if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
+    Bootstrap::jsonResponse(['ok' => false, 'message' => 'Method not allowed'], 405);
+}
+
+$csrf = $_POST['csrf'] ?? '';
+if (!Csrf::validate(is_string($csrf) ? $csrf : null)) {
+    Bootstrap::jsonResponse(['ok' => false, 'message' => 'Ungültiges CSRF-Token.'], 419);
+}
+
+if (trim((string) ($_POST['website'] ?? '')) !== '') {
+    Bootstrap::jsonResponse(['ok' => false, 'message' => 'Anfrage blockiert.'], 400);
+}
+
+$email = strtolower(trim((string) ($_POST['email'] ?? '')));
+if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
+    Bootstrap::jsonResponse(['ok' => false, 'message' => 'Bitte gültige E-Mail eingeben.'], 422);
+}
+
+$app = Bootstrap::config('app');
+$limiter = new RateLimiter();
+$ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
+$rateKey = sprintf('reset:%s:%s', $ip, $email);
+if (!$limiter->allow($rateKey, (int) $app['rate_limit']['requests'], (int) $app['rate_limit']['window_seconds'])) {
+    Bootstrap::jsonResponse(['ok' => false, 'message' => 'Zu viele Löschanfragen. Bitte später erneut versuchen.'], 429);
+}
+
+$store = new JsonStore();
+
+try {
+    $result = $store->withEmailLock($email, static function () use ($store, $app, $email): array {
+        $hadDraft = $store->getDraft($email) !== null;
+        $submission = $store->getSubmissionByEmail($email);
+        $hadSubmission = $submission !== null;
+
+        if ($hadSubmission) {
+            $submissionKey = (string) ($submission['application_key'] ?? $store->emailKey($email));
+            $store->deleteSubmissionByKey($submissionKey);
+        }
+
+        $store->deleteDraft($email);
+
+        $uploadDir = rtrim((string) $app['storage']['uploads'], '/') . '/' . $store->emailKey($email);
+        FileSystem::removeTree($uploadDir);
+
+        return [
+            'had_draft' => $hadDraft,
+            'had_submission' => $hadSubmission,
+        ];
+    });
+} catch (Throwable $e) {
+    Bootstrap::log('app', 'reset error: ' . $e->getMessage());
+    Bootstrap::jsonResponse(['ok' => false, 'message' => 'Daten konnten nicht gelöscht werden.'], 500);
+}
+
+Bootstrap::jsonResponse([
+    'ok' => true,
+    'message' => 'Gespeicherte Daten wurden gelöscht.',
+    'had_draft' => (bool) ($result['had_draft'] ?? false),
+    'had_submission' => (bool) ($result['had_submission'] ?? false),
+]);

+ 12 - 0
assets/css/base.css

@@ -74,6 +74,12 @@ button:hover {
   gap: var(--space-2);
 }
 
+.inline-actions {
+  display: grid;
+  grid-template-columns: 1fr;
+  gap: var(--space-2);
+}
+
 .progress {
   margin-bottom: var(--space-3);
   color: var(--muted);
@@ -105,6 +111,12 @@ small {
   margin-top: 0.25rem;
 }
 
+.status-text {
+  margin-top: var(--space-2);
+  color: var(--muted);
+  min-height: 1.2rem;
+}
+
 table {
   width: 100%;
   border-collapse: collapse;

+ 154 - 35
assets/js/form.js

@@ -1,5 +1,7 @@
 (function () {
-  const boot = window.APP_BOOT || { steps: [], csrf: '' };
+  const EMAIL_STORAGE_KEY = 'ff_member_form_email_v1';
+  const boot = window.APP_BOOT || { steps: [], csrf: '', contactEmail: '' };
+
   const state = {
     email: '',
     currentStep: 1,
@@ -8,9 +10,9 @@
   };
 
   const startForm = document.getElementById('startForm');
+  const startEmailInput = document.getElementById('startEmail');
+  const resetDataBtn = document.getElementById('resetDataBtn');
   const wizardSection = document.getElementById('wizardSection');
-  const blockedSection = document.getElementById('blockedSection');
-  const statusSection = document.getElementById('statusSection');
   const statusMessage = document.getElementById('statusMessage');
   const applicationForm = document.getElementById('applicationForm');
   const applicationEmail = document.getElementById('applicationEmail');
@@ -23,8 +25,82 @@
   const stepElements = Array.from(document.querySelectorAll('.step'));
 
   function showMessage(text) {
-    statusSection.classList.remove('hidden');
-    statusMessage.textContent = text;
+    statusMessage.textContent = text || '';
+  }
+
+  function normalizeEmail(email) {
+    return (email || '').trim().toLowerCase();
+  }
+
+  function isValidEmail(email) {
+    return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
+  }
+
+  function rememberEmail(email) {
+    try {
+      localStorage.setItem(EMAIL_STORAGE_KEY, email);
+    } catch (_err) {
+      // ignore localStorage errors
+    }
+  }
+
+  function getRememberedEmail() {
+    try {
+      return localStorage.getItem(EMAIL_STORAGE_KEY) || '';
+    } catch (_err) {
+      return '';
+    }
+  }
+
+  function forgetRememberedEmail() {
+    try {
+      localStorage.removeItem(EMAIL_STORAGE_KEY);
+    } catch (_err) {
+      // ignore localStorage errors
+    }
+  }
+
+  function lockEmail(email) {
+    state.email = email;
+    applicationEmail.value = email;
+    startEmailInput.value = email;
+    startEmailInput.readOnly = true;
+    startEmailInput.setAttribute('aria-readonly', 'true');
+    resetDataBtn.classList.remove('hidden');
+    rememberEmail(email);
+  }
+
+  function unlockEmail(clearInput) {
+    state.email = '';
+    applicationEmail.value = '';
+    startEmailInput.readOnly = false;
+    startEmailInput.removeAttribute('aria-readonly');
+    resetDataBtn.classList.add('hidden');
+    if (clearInput) {
+      startEmailInput.value = '';
+    }
+    forgetRememberedEmail();
+  }
+
+  function stopAutosave() {
+    if (state.autosaveId) {
+      clearInterval(state.autosaveId);
+      state.autosaveId = null;
+    }
+  }
+
+  function startAutosave() {
+    stopAutosave();
+    state.autosaveId = setInterval(async () => {
+      if (!state.email || wizardSection.classList.contains('hidden')) {
+        return;
+      }
+      try {
+        await saveDraft(false, false);
+      } catch (_err) {
+        // visible on next manual action
+      }
+    }, 15000);
   }
 
   function clearErrors() {
@@ -43,6 +119,17 @@
     });
   }
 
+  function clearWizardData() {
+    applicationForm.reset();
+    clearErrors();
+    renderUploadInfo({});
+    state.currentStep = 1;
+    submitBtn.disabled = false;
+    nextBtn.disabled = false;
+    prevBtn.disabled = false;
+    updateProgress();
+  }
+
   function updateProgress() {
     progress.textContent = 'Schritt ' + state.currentStep + ' von ' + state.totalSteps;
     stepElements.forEach((el) => {
@@ -138,10 +225,17 @@
     fd.append('csrf', boot.csrf);
     fd.append('email', email);
     fd.append('website', '');
-
     return postForm('/api/load-draft.php', fd);
   }
 
+  async function resetSavedData(email) {
+    const fd = new FormData();
+    fd.append('csrf', boot.csrf);
+    fd.append('email', email);
+    fd.append('website', '');
+    return postForm('/api/reset.php', fd);
+  }
+
   async function saveDraft(includeFiles, showSavedText) {
     const payload = collectPayload(includeFiles);
     const response = await postForm('/api/save-draft.php', payload);
@@ -150,7 +244,7 @@
       showErrors(response.upload_errors);
       showMessage('Einige Dateien konnten nicht gespeichert werden.');
     } else if (showSavedText) {
-      showMessage('Entwurf gespeichert: ' + (response.updated_at || '')); 
+      showMessage('Entwurf gespeichert: ' + (response.updated_at || ''));
     }
 
     if (response.uploads) {
@@ -177,56 +271,71 @@
     return response;
   }
 
-  startForm.addEventListener('submit', async (event) => {
-    event.preventDefault();
-
-    const emailInput = document.getElementById('startEmail');
-    const email = (emailInput.value || '').trim().toLowerCase();
-    if (!email) {
-      showMessage('Bitte E-Mail eingeben.');
+  async function startProcess(rawEmail) {
+    const email = normalizeEmail(rawEmail);
+    if (!isValidEmail(email)) {
+      showMessage('Bitte eine gültige E-Mail-Adresse eingeben.');
+      startEmailInput.focus();
       return;
     }
 
     try {
       const result = await loadDraft(email);
-      state.email = email;
-      applicationEmail.value = email;
+      lockEmail(email);
 
       if (result.already_submitted) {
         wizardSection.classList.add('hidden');
-        blockedSection.classList.remove('hidden');
-        showMessage(result.message || 'Antrag bereits abgeschlossen.');
+        showMessage(
+          (result.message || 'Antrag bereits abgeschlossen.') +
+          (boot.contactEmail ? ' Kontakt: ' + boot.contactEmail : '')
+        );
+        stopAutosave();
         return;
       }
 
-      blockedSection.classList.add('hidden');
       wizardSection.classList.remove('hidden');
-
       fillFormData(result.data || {});
       renderUploadInfo(result.uploads || {});
 
       state.currentStep = Math.min(Math.max(Number(result.step || 1), 1), state.totalSteps);
       updateProgress();
+      startAutosave();
 
       showMessage('Formular geladen. Entwurf wird automatisch gespeichert.');
-
-      if (state.autosaveId) {
-        clearInterval(state.autosaveId);
-      }
-      state.autosaveId = setInterval(async () => {
-        if (!state.email || wizardSection.classList.contains('hidden')) {
-          return;
-        }
-        try {
-          await saveDraft(false, false);
-        } catch (_err) {
-          // Autsave errors are visible on next manual action.
-        }
-      }, 15000);
     } catch (err) {
       const msg = (err.payload && err.payload.message) || err.message || 'Laden fehlgeschlagen.';
       showMessage(msg);
     }
+  }
+
+  startForm.addEventListener('submit', async (event) => {
+    event.preventDefault();
+    await startProcess(startEmailInput.value || '');
+  });
+
+  resetDataBtn.addEventListener('click', async () => {
+    if (!state.email) {
+      showMessage('Keine aktive E-Mail vorhanden.');
+      return;
+    }
+
+    const confirmed = window.confirm('Alle gespeicherten Daten zu dieser E-Mail endgültig löschen und neu starten?');
+    if (!confirmed) {
+      return;
+    }
+
+    try {
+      await resetSavedData(state.email);
+      stopAutosave();
+      wizardSection.classList.add('hidden');
+      clearWizardData();
+      unlockEmail(true);
+      showMessage('Alle gespeicherten Daten wurden gelöscht. Sie können neu starten.');
+      startEmailInput.focus();
+    } catch (err) {
+      const msg = (err.payload && err.payload.message) || err.message || 'Löschen fehlgeschlagen.';
+      showMessage(msg);
+    }
   });
 
   prevBtn.addEventListener('click', async () => {
@@ -277,11 +386,21 @@
       const msg = payload.message || err.message || 'Absenden fehlgeschlagen.';
       showMessage(msg);
       if (payload.already_submitted) {
-        blockedSection.classList.remove('hidden');
         wizardSection.classList.add('hidden');
       }
     }
   });
 
+  const rememberedEmail = normalizeEmail(getRememberedEmail());
+  if (rememberedEmail !== '') {
+    if (isValidEmail(rememberedEmail)) {
+      startEmailInput.value = rememberedEmail;
+      showMessage('Gespeicherte E-Mail erkannt. Formular wird geladen ...');
+      startProcess(rememberedEmail);
+    } else {
+      forgetRememberedEmail();
+    }
+  }
+
   updateProgress();
 })();

+ 1 - 0
docs/AI_OVERVIEW.md

@@ -11,6 +11,7 @@ Digitaler Mitgliedsantrag für Feuerwehrverein mit Flatfile-Speicherung und Admi
   - `api/load-draft.php`
   - `api/save-draft.php`
   - `api/submit.php`
+  - `api/reset.php`
 - Admin:
   - `admin/login.php`
   - `admin/index.php`

+ 3 - 2
docs/RATE_LIMITING.md

@@ -34,8 +34,10 @@ Default:
   - Key: `save:{ip}:{email}`
 - `POST /api/submit.php`
   - Key: `submit:{ip}:{email}`
+- `POST /api/reset.php`
+  - Key: `reset:{ip}:{email}`
 
-Hinweis: Jeder Endpunkt hat einen eigenen Prefix (`load/save/submit`) und damit ein separates Limit-Fenster.
+Hinweis: Jeder Endpunkt hat einen eigenen Prefix (`load/save/submit/reset`) und damit ein separates Limit-Fenster.
 
 ## Verhalten bei Limitüberschreitung
 
@@ -58,4 +60,3 @@ API antwortet mit HTTP `429` und einer Fehlermeldung (z. B. „Zu viele Anfragen
 
 - Bei Dateisystemproblemen (Datei kann nicht geöffnet/gesperrt werden) erlaubt der Limiter aktuell die Anfrage (fail-open), um legitime Nutzer nicht zu blockieren.
 - NAT/Shared-IP kann mehrere legitime Nutzer unter derselben IP bündeln; durch Kombination mit E-Mail ist das Risiko reduziert.
-

+ 8 - 13
index.php

@@ -77,7 +77,7 @@ function renderField(array $field): void
     <h1>Digitaler Mitgliedsantrag Feuerwehrverein</h1>
 
     <section id="startSection" class="card">
-        <h2>Start</h2>
+        <h2>Start & Status</h2>
         <p>Bitte E-Mail eingeben. Bestehende Entwürfe werden automatisch geladen.</p>
         <form id="startForm" novalidate>
             <input type="hidden" name="csrf" value="<?= htmlspecialchars($csrf) ?>">
@@ -89,15 +89,14 @@ function renderField(array $field): void
                 <label for="startEmail">E-Mail</label>
                 <input id="startEmail" type="email" name="email" required>
             </div>
-            <button type="submit">Formular laden</button>
+            <div class="inline-actions">
+                <button id="startSubmitBtn" type="submit">Formular laden</button>
+                <button id="resetDataBtn" type="button" class="hidden">Gespeicherte Daten löschen und neu starten</button>
+            </div>
+            <p id="statusMessage" class="status-text" role="status" aria-live="polite"></p>
         </form>
     </section>
 
-    <section id="blockedSection" class="card hidden">
-        <h2>Antrag bereits vorhanden</h2>
-        <p>Für diese E-Mail wurde bereits ein Antrag abgeschlossen. Für Rückfragen kontaktieren Sie bitte <?= htmlspecialchars((string) $app['contact_email']) ?>.</p>
-    </section>
-
     <section id="wizardSection" class="card hidden">
         <h2>Mitgliedsantrag</h2>
         <div id="progress" class="progress"></div>
@@ -128,16 +127,12 @@ function renderField(array $field): void
             </div>
         </form>
     </section>
-
-    <section id="statusSection" class="card hidden">
-        <h2>Status</h2>
-        <p id="statusMessage"></p>
-    </section>
 </main>
 <script>
 window.APP_BOOT = {
     steps: <?= json_encode($steps, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>,
-    csrf: <?= json_encode($csrf, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>
+    csrf: <?= json_encode($csrf, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>,
+    contactEmail: <?= json_encode((string) ($app['contact_email'] ?? ''), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?>
 };
 </script>
 <script src="/assets/js/form.js"></script>