Enhance ContactForm and CookieBanner components for improved accessibility and user feedback

- Added CSRF token handling in the ContactForm for enhanced security.
- Introduced a feedback div for displaying form submission results instead of alerts.
- Updated the CookieBanner to include ARIA roles and improved focus management for better accessibility.
- Refactored manual review email handling to escape HTML special characters, enhancing security.
This commit is contained in:
2025-06-26 22:38:06 +02:00
parent dde3fb1923
commit cb64f7f76c
4 changed files with 48 additions and 10 deletions

View File

@@ -1,4 +1,5 @@
<form id="contact-form"> <form id="contact-form">
<input type="hidden" name="csrf_token" id="csrf_token" />
<label for="email">Email</label> <label for="email">Email</label>
<input type="email" name="email" id="email" required aria-describedby="email-help" /> <input type="email" name="email" id="email" required aria-describedby="email-help" />
<div id="email-help" class="sr-only">Enter your email address</div> <div id="email-help" class="sr-only">Enter your email address</div>
@@ -8,7 +9,9 @@
<button type="submit">Send</button> <button type="submit">Send</button>
</form> </form>
<div id="spam-warning" style="display:none;"> <div id="form-feedback" role="alert" aria-live="assertive"></div>
<div id="spam-warning" style="display:none;" tabindex="-1">
<p> <p>
Your message was detected as spam and was not sent.<br> Your message was detected as spam and was not sent.<br>
If you believe this is a mistake, you can request a manual review. If you believe this is a mistake, you can request a manual review.
@@ -16,7 +19,7 @@
<form id="manual-review-form"> <form id="manual-review-form">
<label for="manual-email">Email</label> <label for="manual-email">Email</label>
<input type="email" id="manual-email" readonly aria-describedby="manual-email-help" /> <input type="email" id="manual-email" readonly aria-describedby="manual-email-help" />
<div id="manual-email-help" class="sr-only">Re-enter your email address for confirmation</div> <div id="manual-email-help" class="sr-only">Your email address.</div>
<label for="manual-justification">Why is this not spam? (optional)</label> <label for="manual-justification">Why is this not spam? (optional)</label>
<textarea id="manual-justification" placeholder="Why is this not spam? (optional)" aria-describedby="manual-justification-help"></textarea> <textarea id="manual-justification" placeholder="Why is this not spam? (optional)" aria-describedby="manual-justification-help"></textarea>
<div id="manual-justification-help" class="sr-only">Explain why your message is legitimate</div> <div id="manual-justification-help" class="sr-only">Explain why your message is legitimate</div>
@@ -27,8 +30,25 @@
</div> </div>
<script> <script>
async function fetchCsrfToken() {
try {
const response = await fetch('/api/contact?csrf=true');
const data = await response.json();
const csrfTokenInput = document.getElementById('csrf_token');
if (csrfTokenInput) {
(csrfTokenInput as HTMLInputElement).value = data.csrfToken;
}
} catch (error) {
console.error('Error fetching CSRF token:', error);
}
}
document.addEventListener('DOMContentLoaded', fetchCsrfToken);
const contactForm = document.getElementById('contact-form'); const contactForm = document.getElementById('contact-form');
if (contactForm) { const feedbackDiv = document.getElementById('form-feedback');
if (contactForm && feedbackDiv) {
contactForm.onsubmit = async function (e) { contactForm.onsubmit = async function (e) {
e.preventDefault(); e.preventDefault();
const formData = new FormData(this as HTMLFormElement); const formData = new FormData(this as HTMLFormElement);
@@ -38,7 +58,7 @@ if (contactForm) {
data = await res.json(); data = await res.json();
console.log('Contact API response:', data); console.log('Contact API response:', data);
} catch (_err) { } catch (_err) {
alert('Unexpected server response.'); feedbackDiv.textContent = 'Unexpected server response.';
return; return;
} }
@@ -51,14 +71,15 @@ if (contactForm) {
spamWarning.style.display = 'block'; spamWarning.style.display = 'block';
manualEmail.value = String(formData.get('email')); manualEmail.value = String(formData.get('email'));
manualToken.value = data.token; manualToken.value = data.token;
spamWarning.focus();
} }
return; return;
} }
if (!res.ok) { if (!res.ok) {
alert(data.error || 'There was an error sending your message.'); feedbackDiv.textContent = data.error || 'There was an error sending your message.';
} else { } else {
alert('Your message was sent successfully!'); feedbackDiv.textContent = 'Your message was sent successfully!';
} }
}; };
} }

View File

@@ -9,9 +9,14 @@ const t = getTranslation(lang);
id="cookie-banner" id="cookie-banner"
class="fixed bottom-0 left-0 right-0 z-50 p-4 content-backdrop shadow-lg transform transition-transform duration-300 translate-y-full" class="fixed bottom-0 left-0 right-0 z-50 p-4 content-backdrop shadow-lg transform transition-transform duration-300 translate-y-full"
style="display: none;" style="display: none;"
role="dialog"
aria-modal="true"
aria-labelledby="cookie-banner-title"
tabindex="-1"
> >
<div class="container mx-auto max-w-6xl flex flex-col sm:flex-row items-center justify-between gap-4"> <div class="container mx-auto max-w-6xl flex flex-col sm:flex-row items-center justify-between gap-4">
<div class="text-sm text-gray-800 dark:text-gray-200 font-medium"> <div class="text-sm text-gray-800 dark:text-gray-200 font-medium">
<h2 id="cookie-banner-title" class="sr-only">Cookie Consent</h2>
<p> <p>
{t.cookies.message} {t.cookies.message}
<a href={`/${lang}/privacy#cookie-usage`} class="text-blue-600 dark:text-blue-400 hover:underline" <a href={`/${lang}/privacy#cookie-usage`} class="text-blue-600 dark:text-blue-400 hover:underline"
@@ -83,6 +88,7 @@ const t = getTranslation(lang);
// Show the banner with a slight delay for better UX // Show the banner with a slight delay for better UX
setTimeout(() => { setTimeout(() => {
cookieBanner.classList.remove('translate-y-full'); cookieBanner.classList.remove('translate-y-full');
cookieBanner.focus();
}, 500); }, 500);
// Handle accept button click // Handle accept button click

View File

@@ -10,7 +10,7 @@ import {
import { isSpamWithGemini } from "../../utils/gemini-spam-check"; import { isSpamWithGemini } from "../../utils/gemini-spam-check";
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
const MANUAL_REVIEW_SECRET = process.env.MANUAL_REVIEW_SECRET || 'dev-secret'; const MANUAL_REVIEW_SECRET = process.env.MANUAL_REVIEW_SECRET;
const MANUAL_REVIEW_EMAIL = 'manual-review@365devnet.eu'; const MANUAL_REVIEW_EMAIL = 'manual-review@365devnet.eu';
// Enhanced email validation with more comprehensive regex // Enhanced email validation with more comprehensive regex

View File

@@ -2,9 +2,19 @@ import type { APIRoute } from 'astro';
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import { sendEmail } from '../../../utils/email-handler'; import { sendEmail } from '../../../utils/email-handler';
const MANUAL_REVIEW_SECRET = process.env.MANUAL_REVIEW_SECRET || 'dev-secret'; const MANUAL_REVIEW_SECRET = process.env.MANUAL_REVIEW_SECRET;
const MANUAL_REVIEW_EMAIL = 'manual-review@365devnet.eu'; const MANUAL_REVIEW_EMAIL = 'manual-review@365devnet.eu';
// Utility to escape HTML special characters
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
export const POST: APIRoute = async ({ request }) => { export const POST: APIRoute = async ({ request }) => {
const { token, email: submittedEmail, justification } = await request.json(); const { token, email: submittedEmail, justification } = await request.json();
try { try {
@@ -16,11 +26,12 @@ export const POST: APIRoute = async ({ request }) => {
await sendEmail( await sendEmail(
MANUAL_REVIEW_EMAIL, MANUAL_REVIEW_EMAIL,
'Manual Review Requested: Contact Form Submission', 'Manual Review Requested: Contact Form Submission',
`<p><strong>Email:</strong> ${submittedEmail}</p><p><strong>Message:</strong> ${payload.message}</p><p><strong>Justification:</strong> ${justification || 'None provided'}</p>`, `<p><strong>Email:</strong> ${escapeHtml(submittedEmail)}</p><p><strong>Message:</strong> ${escapeHtml(payload.message)}</p><p><strong>Justification:</strong> ${escapeHtml(justification || 'None provided')}</p>`,
`Email: ${submittedEmail}\nMessage: ${payload.message}\nJustification: ${justification || 'None provided'}` `Email: ${submittedEmail}\nMessage: ${payload.message}\nJustification: ${justification || 'None provided'}`
); );
return new Response(JSON.stringify({ success: true })); return new Response(JSON.stringify({ success: true }));
} catch (_err) { } catch (_err) {
return new Response(JSON.stringify({ error: 'Invalid or expired token.' }), { status: 400 }); return new Response(JSON.stringify({ error: 'Invalid or expired token.' }), { status: 400 });
} }
}; };