Prechádzať zdrojové kódy

adapted email otp to be less strict.
fixed some small bugs

Medowar 3 týždňov pred
rodič
commit
76ce7706bf

+ 4 - 4
api/request-otp.php

@@ -17,7 +17,7 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
 
 $csrf = $_POST['csrf'] ?? '';
 if (!Csrf::validate(is_string($csrf) ? $csrf : null)) {
-    Bootstrap::jsonResponse(['ok' => false, 'message' => 'Ungueltiges CSRF-Token.'], 419);
+    Bootstrap::jsonResponse(['ok' => false, 'message' => 'Ungültiges CSRF-Token.'], 419);
 }
 
 if (trim((string) ($_POST['website'] ?? '')) !== '') {
@@ -26,7 +26,7 @@ if (trim((string) ($_POST['website'] ?? '')) !== '') {
 
 $email = strtolower(trim((string) ($_POST['email'] ?? '')));
 if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
-    Bootstrap::jsonResponse(['ok' => false, 'message' => 'Bitte gueltige E-Mail eingeben.'], 422);
+    Bootstrap::jsonResponse(['ok' => false, 'message' => 'Bitte gültige E-Mail eingeben.'], 422);
 }
 
 $autoStartRaw = strtolower(trim((string) ($_POST['auto_start'] ?? '0')));
@@ -37,7 +37,7 @@ $limiter = new RateLimiter();
 $ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
 $rateKey = sprintf('otp-request:%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 Anfragen. Bitte spaeter erneut versuchen.'], 429);
+    Bootstrap::jsonResponse(['ok' => false, 'message' => 'Zu viele Anfragen. Bitte später erneut versuchen.'], 429);
 }
 
 $formAccess = new FormAccess();
@@ -66,7 +66,7 @@ if (!$mailer->sendOtpMail($email, $otpCode, $ttlSeconds)) {
     $formAccess->clearPendingOtp();
     Bootstrap::jsonResponse([
         'ok' => false,
-        'message' => 'Code konnte nicht per E-Mail gesendet werden. Bitte spaeter erneut versuchen.',
+        'message' => 'Code konnte nicht per E-Mail gesendet werden. Bitte später erneut versuchen.',
     ], 500);
 }
 

+ 5 - 5
api/verify-otp.php

@@ -16,7 +16,7 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
 
 $csrf = $_POST['csrf'] ?? '';
 if (!Csrf::validate(is_string($csrf) ? $csrf : null)) {
-    Bootstrap::jsonResponse(['ok' => false, 'message' => 'Ungueltiges CSRF-Token.'], 419);
+    Bootstrap::jsonResponse(['ok' => false, 'message' => 'Ungültiges CSRF-Token.'], 419);
 }
 
 if (trim((string) ($_POST['website'] ?? '')) !== '') {
@@ -25,7 +25,7 @@ if (trim((string) ($_POST['website'] ?? '')) !== '') {
 
 $email = strtolower(trim((string) ($_POST['email'] ?? '')));
 if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
-    Bootstrap::jsonResponse(['ok' => false, 'message' => 'Bitte gueltige E-Mail eingeben.'], 422);
+    Bootstrap::jsonResponse(['ok' => false, 'message' => 'Bitte gültige E-Mail eingeben.'], 422);
 }
 
 $code = trim((string) ($_POST['otp_code'] ?? ''));
@@ -35,7 +35,7 @@ $limiter = new RateLimiter();
 $ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown';
 $rateKey = sprintf('otp-verify:%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 Anfragen. Bitte spaeter erneut versuchen.'], 429);
+    Bootstrap::jsonResponse(['ok' => false, 'message' => 'Zu viele Anfragen. Bitte später erneut versuchen.'], 429);
 }
 
 $formAccess = new FormAccess();
@@ -44,7 +44,7 @@ if (($result['ok'] ?? false) !== true) {
     $reason = (string) ($result['reason'] ?? '');
     Bootstrap::jsonResponse([
         'ok' => false,
-        'message' => (string) ($result['message'] ?? 'Code konnte nicht bestaetigt werden.'),
+        'message' => (string) ($result['message'] ?? 'Code konnte nicht bestätigt werden.'),
         'auth_required' => in_array($reason, ['auth_required', 'expired', 'attempt_limit'], true),
         'auth_expired' => false,
         'attempts_left' => isset($result['attempts_left']) ? (int) $result['attempts_left'] : null,
@@ -53,5 +53,5 @@ if (($result['ok'] ?? false) !== true) {
 
 Bootstrap::jsonResponse([
     'ok' => true,
-    'message' => 'E-Mail erfolgreich bestaetigt.',
+    'message' => 'E-Mail erfolgreich bestätigt.',
 ]);

+ 41 - 20
assets/js/form.js

@@ -357,7 +357,7 @@
         return;
       }
 
-      otpCooldownMessage.textContent = 'Neuer Code in ' + String(state.otpCooldownRemaining) + 's verfuegbar.';
+      otpCooldownMessage.textContent = 'Neuer Code in ' + String(state.otpCooldownRemaining) + 's verfügbar.';
       state.otpCooldownRemaining -= 1;
     };
 
@@ -1298,7 +1298,7 @@
             await deleteUploadedFile(field, index);
             setFeedback('Upload gelöscht.', false);
           } catch (err) {
-            handleProtectedError(err, 'Loeschen fehlgeschlagen.', 'wizard');
+            handleProtectedError(err, 'Löschen fehlgeschlagen.', 'wizard');
           } finally {
             deleteBtn.disabled = false;
           }
@@ -1432,9 +1432,9 @@
     const inactivityMinutes = Math.round(inactivitySeconds / 60);
     if (inactivityMinutes % 60 === 0) {
       const hours = inactivityMinutes / 60;
-      return 'nach ' + String(hours) + ' Stunde' + (hours === 1 ? '' : 'n') + ' Inaktivitaet';
+      return 'nach ' + String(hours) + ' Stunde' + (hours === 1 ? '' : 'n') + ' Inaktivität';
     }
-    return 'nach ' + String(inactivityMinutes) + ' Minuten Inaktivitaet';
+    return 'nach ' + String(inactivityMinutes) + ' Minuten Inaktivität';
   }
 
   function handleProtectedAuthFailure(payload) {
@@ -1454,7 +1454,7 @@
     const isExpired = Boolean(payload && payload.auth_expired);
     const defaultMessage = isExpired
       ? 'Ihre Sitzung ist ' + inactivityInfoText() + ' abgelaufen. Bitte erneut verifizieren.'
-      : 'Bitte zuerst E-Mail und Sicherheitscode bestaetigen.';
+      : 'Bitte zuerst E-Mail und Sicherheitscode bestätigen.';
 
     setFeedback((payload && payload.message) || defaultMessage, true, 'start');
     setDraftStatus(isExpired ? 'Sitzung abgelaufen' : 'Verifizierung erforderlich', true);
@@ -1737,7 +1737,7 @@
       lockEmail(email);
       setResetActionVisible(true);
       hideOtpSection();
-      setFeedback(result.message || 'E-Mail erfolgreich bestaetigt.', false, 'start');
+      setFeedback(result.message || 'E-Mail erfolgreich bestätigt.', false, 'start');
       markUserActivity();
       disclaimerSection.classList.remove('hidden');
       wizardSection.classList.add('hidden');
@@ -1750,11 +1750,11 @@
       const payload = (err && err.payload) || {};
       const attemptsLeft = Number(payload.attempts_left);
       if (Number.isFinite(attemptsLeft) && attemptsLeft >= 0 && attemptsLeft < 5) {
-        setStartOtpError((payload.message || 'Code ungueltig.') + ' Verbleibende Versuche: ' + String(attemptsLeft));
+        setStartOtpError((payload.message || 'Code ungültig.') + ' Verbleibende Versuche: ' + String(attemptsLeft));
       } else {
-        setStartOtpError(payload.message || err.message || 'Code konnte nicht bestaetigt werden.');
+        setStartOtpError(payload.message || err.message || 'Code konnte nicht bestätigt werden.');
       }
-      setFeedback(payload.message || err.message || 'Code konnte nicht bestaetigt werden.', true, 'start');
+      setFeedback(payload.message || err.message || 'Code konnte nicht bestätigt werden.', true, 'start');
       startOtpInput.focus();
     } finally {
       if (verifyOtpBtn) {
@@ -1768,8 +1768,8 @@
 
   async function openWizardForVerifiedEmail() {
     if (!state.email || !state.isVerified) {
-      setFeedback('Bitte zuerst E-Mail und Sicherheitscode bestaetigen.', true, 'start');
-      return;
+      setFeedback('Bitte zuerst E-Mail und Sicherheitscode bestätigen.', true, 'start');
+      return false;
     }
 
     try {
@@ -1788,7 +1788,7 @@
         );
         setResetActionVisible(false);
         stopAutosave();
-        return;
+        return true;
       }
 
       disclaimerSection.classList.add('hidden');
@@ -1808,15 +1808,25 @@
         setDraftStatus('Neuer Entwurf gestartet', false);
       }
       setFeedback('', false, 'wizard');
+      return true;
     } catch (err) {
-      const handled = handleProtectedError(err, 'Laden fehlgeschlagen.', 'start');
-      if (!handled) {
-        // keep default message already set
-      }
+      handleProtectedError(err, 'Laden fehlgeschlagen.', 'start');
+      return false;
     }
   }
 
-  function initAutoOtpForRememberedEmail() {
+  async function resumeVerifiedSession(email) {
+    const normalized = normalizeEmail(email);
+    if (!isValidEmail(normalized)) {
+      return false;
+    }
+
+    lockEmail(normalized);
+    setResetActionVisible(true);
+    return openWizardForVerifiedEmail();
+  }
+
+  async function initAutoOtpForRememberedEmail() {
     const rememberedEmail = normalizeEmail(getRememberedEmail());
     if (rememberedEmail === '' || !isValidEmail(rememberedEmail)) {
       return;
@@ -1825,8 +1835,19 @@
     startEmailInput.value = rememberedEmail;
     updateStartEmailRequiredMarker();
 
-    if (!hasAutoOtpSessionFlag(rememberedEmail)) {
-      requestOtpFlow(rememberedEmail, { autoStart: true });
+    const resumed = await resumeVerifiedSession(rememberedEmail);
+    if (resumed) {
+      return;
+    }
+
+    if (state.isVerified) {
+      unlockEmail(false, false);
+      startEmailInput.value = rememberedEmail;
+      updateStartEmailRequiredMarker();
+    }
+
+    if (!state.isVerified && !hasAutoOtpSessionFlag(rememberedEmail)) {
+      await requestOtpFlow(rememberedEmail, { autoStart: true });
     }
   }
 
@@ -1888,7 +1909,7 @@
       setFeedback('Alle gespeicherten Daten wurden gelöscht. Sie können neu starten.', false);
       startEmailInput.focus();
     } catch (err) {
-      handleProtectedError(err, 'Loeschen fehlgeschlagen.', 'start');
+      handleProtectedError(err, 'Löschen fehlgeschlagen.', 'start');
     }
   });
 

+ 3 - 3
config/mail.sample.php

@@ -11,10 +11,10 @@ return [
     'subjects' => [
         'admin' => 'Neuer Mitgliedsantrag',
         'applicant' => 'Bestaetigung deines Mitgliedsantrags',
-        'otp' => 'Ihr Sicherheitscode fuer den Mitgliedsantrag',
+        'otp' => 'Ihr Sicherheitscode für den Mitgliedsantrag',
     ],
     'otp' => [
-        'text_template' => "Ihr Sicherheitscode lautet: {{code}}\nDer Code ist {{ttl_minutes}} Minuten gueltig.",
-        'html_template' => '<p>Ihr Sicherheitscode lautet: <strong>{{code}}</strong></p><p>Der Code ist {{ttl_minutes}} Minuten gueltig.</p>',
+        'text_template' => "Ihr Sicherheitscode lautet: {{code}}\nDer Code ist {{ttl_minutes}} Minuten gültig.",
+        'html_template' => '<p>Ihr Sicherheitscode lautet: <strong>{{code}}</strong></p><p>Der Code ist {{ttl_minutes}} Minuten gültig.</p>',
     ],
 ];

+ 39 - 39
index.php

@@ -215,45 +215,6 @@ function renderField(array $field, string $addressDisclaimerText): void
 <main class="container">
     <h1>Digitaler Mitgliedsantrag Feuerwehrverein</h1>
 
-    <section id="startSection" class="card">
-        <h2>Sicherheitspruefung</h2>
-        <p id="startIntroText">Bitte E-Mail eingeben. Danach senden wir einen 6-stelligen Sicherheitscode.</p>
-        <form id="startForm" novalidate>
-            <input type="hidden" name="csrf" value="<?= htmlspecialchars($csrf) ?>">
-            <div class="hp-field" aria-hidden="true">
-                <label for="website">Website</label>
-                <input id="website" type="text" name="website" autocomplete="off" tabindex="-1">
-            </div>
-            <div class="field" id="startEmailField">
-                <label for="startEmail">E-Mail <span class="required-mark required-mark-field-start" aria-hidden="true">* Pflichtfeld</span></label>
-                <input id="startEmail" type="email" name="email" required inputmode="email" autocomplete="email">
-                <div id="startEmailError" class="error"></div>
-            </div>
-            <div class="inline-actions" id="startActions">
-                <button id="startSubmitBtn" type="submit" class="btn">Code senden</button>
-            </div>
-            <section id="otpSection" class="hidden" aria-live="polite">
-                <p id="otpInfoText" class="status-text"></p>
-                <div class="field" id="startOtpField">
-                    <label for="startOtp">Sicherheitscode <span class="required-mark required-mark-field-start" aria-hidden="true">* Pflichtfeld</span></label>
-                    <input id="startOtp" type="text" inputmode="numeric" autocomplete="one-time-code" maxlength="6" pattern="[0-9]{6}">
-                    <div id="startOtpError" class="error"></div>
-                </div>
-                <div class="inline-actions" id="otpActions">
-                    <button id="verifyOtpBtn" type="button" class="btn">Code bestaetigen</button>
-                    <button id="resendOtpBtn" type="button" class="btn btn-secondary">Code erneut senden</button>
-                </div>
-                <p id="otpCooldownMessage" class="status-text"></p>
-            </section>
-            <div id="compactStatusBox" class="compact-status hidden">
-                <p><strong>E-Mail:</strong> <span id="statusEmailValue">-</span></p>
-                <p><strong>Speicherstatus:</strong> <span id="draftStatusValue">Noch nicht gespeichert</span></p>
-                <button id="resetDataBtn" type="button" class="btn btn-small">Gespeicherte Daten löschen und neu starten</button>
-            </div>
-            <p id="startFeedbackMessage" class="status-text" role="status" aria-live="polite"></p>
-        </form>
-    </section>
-
     <section id="disclaimerSection" class="card hidden">
         <h2><?= htmlspecialchars($disclaimerTitle) ?></h2>
         <p class="disclaimer-text"><?= nl2br(htmlspecialchars($disclaimerText)) ?></p>
@@ -305,6 +266,45 @@ function renderField(array $field, string $addressDisclaimerText): void
             <p id="feedbackMessage" class="status-text" role="status" aria-live="polite"></p>
         </form>
     </section>
+
+    <section id="startSection" class="card">
+        <h2>Status</h2>
+        <p id="startIntroText">Bitte E-Mail eingeben. Danach senden wir einen 6-stelligen Sicherheitscode.</p>
+        <form id="startForm" novalidate>
+            <input type="hidden" name="csrf" value="<?= htmlspecialchars($csrf) ?>">
+            <div class="hp-field" aria-hidden="true">
+                <label for="website">Website</label>
+                <input id="website" type="text" name="website" autocomplete="off" tabindex="-1">
+            </div>
+            <div class="field" id="startEmailField">
+                <label for="startEmail">E-Mail <span class="required-mark required-mark-field-start" aria-hidden="true">* Pflichtfeld</span></label>
+                <input id="startEmail" type="email" name="email" required inputmode="email" autocomplete="email">
+                <div id="startEmailError" class="error"></div>
+            </div>
+            <div class="inline-actions" id="startActions">
+                <button id="startSubmitBtn" type="submit" class="btn">Code senden</button>
+            </div>
+            <section id="otpSection" class="hidden" aria-live="polite">
+                <p id="otpInfoText" class="status-text"></p>
+                <div class="field" id="startOtpField">
+                    <label for="startOtp">Sicherheitscode <span class="required-mark required-mark-field-start" aria-hidden="true">* Pflichtfeld</span></label>
+                    <input id="startOtp" type="text" inputmode="numeric" autocomplete="one-time-code" maxlength="6" pattern="[0-9]{6}">
+                    <div id="startOtpError" class="error"></div>
+                </div>
+                <div class="inline-actions" id="otpActions">
+                    <button id="verifyOtpBtn" type="button" class="btn">Code bestätigen</button>
+                    <button id="resendOtpBtn" type="button" class="btn btn-secondary">Code erneut senden</button>
+                </div>
+                <p id="otpCooldownMessage" class="status-text"></p>
+            </section>
+            <div id="compactStatusBox" class="compact-status hidden">
+                <p><strong>E-Mail:</strong> <span id="statusEmailValue">-</span></p>
+                <p><strong>Speicherstatus:</strong> <span id="draftStatusValue">Noch nicht gespeichert</span></p>
+                <button id="resetDataBtn" type="button" class="btn btn-small">Gespeicherte Daten löschen und neu starten</button>
+            </div>
+            <p id="startFeedbackMessage" class="status-text" role="status" aria-live="polite"></p>
+        </form>
+    </section>
 </main>
 <script>
 window.APP_BOOT = {

+ 2 - 2
src/mail/mailer.php

@@ -326,7 +326,7 @@ final class Mailer
         $configured = (string) ($this->mailConfig['otp']['text_template'] ?? '');
         $template = trim($configured);
         if ($template === '') {
-            $template = "Ihr Sicherheitscode lautet: {{code}}\nDer Code ist {{ttl_minutes}} Minuten gueltig.";
+            $template = "Ihr Sicherheitscode lautet: {{code}}\nDer Code ist {{ttl_minutes}} Minuten gültig.";
         }
 
         return $this->replaceOtpTemplateVars($template, $code, $ttlSeconds, false);
@@ -337,7 +337,7 @@ final class Mailer
         $configured = (string) ($this->mailConfig['otp']['html_template'] ?? '');
         $template = trim($configured);
         if ($template === '') {
-            $template = '<p>Ihr Sicherheitscode lautet: <strong>{{code}}</strong></p><p>Der Code ist {{ttl_minutes}} Minuten gueltig.</p>';
+            $template = '<p>Ihr Sicherheitscode lautet: <strong>{{code}}</strong></p><p>Der Code ist {{ttl_minutes}} Minuten gültig.</p>';
         }
 
         return $this->replaceOtpTemplateVars($template, $code, $ttlSeconds, true);

+ 5 - 5
src/security/formaccess.php

@@ -73,7 +73,7 @@ final class FormAccess
                 'ok' => false,
                 'reason' => 'invalid_email',
                 'status_code' => 422,
-                'message' => 'Bitte gueltige E-Mail eingeben.',
+                'message' => 'Bitte gültige E-Mail eingeben.',
             ];
         }
 
@@ -150,7 +150,7 @@ final class FormAccess
                 'ok' => false,
                 'reason' => 'invalid_code',
                 'status_code' => 422,
-                'message' => 'Bitte einen gueltigen 6-stelligen Code eingeben.',
+                'message' => 'Bitte einen gültigen 6-stelligen Code eingeben.',
             ];
         }
 
@@ -202,7 +202,7 @@ final class FormAccess
                 'ok' => false,
                 'reason' => 'invalid_code',
                 'status_code' => 401,
-                'message' => 'Der Code ist ungueltig.',
+                'message' => 'Der Code ist ungültig.',
                 'attempts_left' => $attemptsLeft,
             ];
         }
@@ -262,7 +262,7 @@ final class FormAccess
                 'ok' => false,
                 'reason' => 'auth_required',
                 'status_code' => 401,
-                'message' => 'Bitte zuerst E-Mail und Code bestaetigen.',
+                'message' => 'Bitte zuerst E-Mail und Code bestätigen.',
             ];
         }
 
@@ -273,7 +273,7 @@ final class FormAccess
                 'ok' => false,
                 'reason' => 'auth_required',
                 'status_code' => 401,
-                'message' => 'Bitte zuerst E-Mail und Code bestaetigen.',
+                'message' => 'Bitte zuerst E-Mail und Code bestätigen.',
             ];
         }