- Updates `isValidEmail` to strictly reject double quotes and backticks while allowing apostrophes. - Applies `sanitizeInput` to email fields in Contact form payload (Defense in Depth). - Adds tests for email validation edge cases. - Updates Sentinel journal. Co-authored-by: ragusa-it <196988693+ragusa-it@users.noreply.github.com>
317 lines
10 KiB
TypeScript
317 lines
10 KiB
TypeScript
import { useState, type FormEvent } from "react";
|
|
import { motion } from "motion/react";
|
|
import emailjs from "@emailjs/browser";
|
|
import { useTranslation } from "../i18n";
|
|
import { useRateLimit } from "../hooks";
|
|
import { config } from "../config";
|
|
import { Button, Input, Textarea } from "../components/ui";
|
|
import { sanitizeInput, isValidEmail } from "../utils/security";
|
|
import styles from "./Contact.module.css";
|
|
|
|
const NAME_MAX_LENGTH = 100;
|
|
const EMAIL_MAX_LENGTH = 254;
|
|
const SUBJECT_MAX_LENGTH = 200;
|
|
const MESSAGE_MAX_LENGTH = 5000;
|
|
|
|
interface FormData {
|
|
name: string;
|
|
email: string;
|
|
subject: string;
|
|
message: string;
|
|
}
|
|
|
|
interface FormErrors {
|
|
name?: string;
|
|
email?: string;
|
|
subject?: string;
|
|
message?: string;
|
|
}
|
|
|
|
export function Contact() {
|
|
const { t } = useTranslation();
|
|
const [formData, setFormData] = useState<FormData>({
|
|
name: "",
|
|
email: "",
|
|
subject: "",
|
|
message: "",
|
|
});
|
|
const [errors, setErrors] = useState<FormErrors>({});
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
const [submitStatus, setSubmitStatus] = useState<
|
|
"idle" | "success" | "error"
|
|
>("idle");
|
|
const [rateLimitError, setRateLimitError] = useState(false);
|
|
const { checkRateLimit } = useRateLimit("contact-form", 60000); // 1 minute cooldown
|
|
|
|
const validateForm = (): boolean => {
|
|
const newErrors: FormErrors = {};
|
|
|
|
if (!formData.name.trim()) {
|
|
newErrors.name = "Required";
|
|
} else if (formData.name.length > NAME_MAX_LENGTH) {
|
|
newErrors.name = `Max ${NAME_MAX_LENGTH} characters`;
|
|
}
|
|
|
|
if (!formData.email.trim()) {
|
|
newErrors.email = "Required";
|
|
} else if (formData.email.length > EMAIL_MAX_LENGTH) {
|
|
newErrors.email = `Max ${EMAIL_MAX_LENGTH} characters`;
|
|
} else if (!isValidEmail(formData.email)) {
|
|
newErrors.email = "Invalid email";
|
|
}
|
|
|
|
if (!formData.subject.trim()) {
|
|
newErrors.subject = "Required";
|
|
} else if (formData.subject.length > SUBJECT_MAX_LENGTH) {
|
|
newErrors.subject = `Max ${SUBJECT_MAX_LENGTH} characters`;
|
|
}
|
|
|
|
if (!formData.message.trim()) {
|
|
newErrors.message = "Required";
|
|
} else if (formData.message.length > MESSAGE_MAX_LENGTH) {
|
|
newErrors.message = `Max ${MESSAGE_MAX_LENGTH} characters`;
|
|
}
|
|
|
|
setErrors(newErrors);
|
|
return Object.keys(newErrors).length === 0;
|
|
};
|
|
|
|
const handleSubmit = async (e: FormEvent) => {
|
|
e.preventDefault();
|
|
|
|
setRateLimitError(false);
|
|
if (!validateForm()) return;
|
|
|
|
if (!checkRateLimit()) {
|
|
setRateLimitError(true);
|
|
return;
|
|
}
|
|
|
|
setIsSubmitting(true);
|
|
setSubmitStatus("idle");
|
|
|
|
try {
|
|
const templateParams = {
|
|
name: sanitizeInput(formData.name),
|
|
email: sanitizeInput(formData.email),
|
|
title: sanitizeInput(formData.subject),
|
|
message: sanitizeInput(formData.message),
|
|
reply_to: formData.email,
|
|
};
|
|
|
|
// Send to Admin
|
|
await emailjs.send(
|
|
config.emailJs.serviceId,
|
|
config.emailJs.templateIdAdmin,
|
|
templateParams,
|
|
{ publicKey: config.emailJs.publicKey },
|
|
);
|
|
|
|
// Send Auto-reply to User
|
|
await emailjs.send(
|
|
config.emailJs.serviceId,
|
|
config.emailJs.templateIdUser,
|
|
templateParams,
|
|
{ publicKey: config.emailJs.publicKey },
|
|
);
|
|
|
|
setSubmitStatus("success");
|
|
setFormData({ name: "", email: "", subject: "", message: "" });
|
|
} catch (error) {
|
|
console.error("EmailJS Error:", error);
|
|
setSubmitStatus("error");
|
|
} finally {
|
|
setIsSubmitting(false);
|
|
}
|
|
};
|
|
|
|
const handleChange = (field: keyof FormData, value: string) => {
|
|
setFormData((prev) => ({ ...prev, [field]: value }));
|
|
if (errors[field]) {
|
|
setErrors((prev) => ({ ...prev, [field]: undefined }));
|
|
}
|
|
};
|
|
|
|
return (
|
|
<main className={styles.contact}>
|
|
{/* Hero */}
|
|
<section className={styles.hero}>
|
|
<div className="container">
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 30 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.6 }}
|
|
>
|
|
<h1 className={styles.title}>{t.contact.title}</h1>
|
|
<p className={styles.subtitle}>{t.contact.subtitle}</p>
|
|
</motion.div>
|
|
</div>
|
|
</section>
|
|
|
|
{/* Content */}
|
|
<section className={styles.content}>
|
|
<div className="container">
|
|
<div className={styles.grid}>
|
|
{/* Form */}
|
|
<motion.div
|
|
className={styles.formSection}
|
|
initial={{ opacity: 0, x: -30 }}
|
|
animate={{ opacity: 1, x: 0 }}
|
|
transition={{ duration: 0.5, delay: 0.2 }}
|
|
>
|
|
<p className={styles.intro}>{t.contact.intro}</p>
|
|
|
|
<form
|
|
onSubmit={handleSubmit}
|
|
className={styles.form}
|
|
noValidate
|
|
>
|
|
<Input
|
|
label={t.contact.form.name}
|
|
required
|
|
placeholder={t.contact.form.namePlaceholder}
|
|
value={formData.name}
|
|
onChange={(e) => handleChange("name", e.target.value)}
|
|
error={errors.name}
|
|
maxLength={NAME_MAX_LENGTH}
|
|
/>
|
|
|
|
<Input
|
|
label={t.contact.form.email}
|
|
type="email"
|
|
required
|
|
placeholder={t.contact.form.emailPlaceholder}
|
|
value={formData.email}
|
|
onChange={(e) => handleChange("email", e.target.value)}
|
|
error={errors.email}
|
|
maxLength={EMAIL_MAX_LENGTH}
|
|
/>
|
|
|
|
<Input
|
|
label={t.contact.form.subject}
|
|
required
|
|
placeholder={t.contact.form.subjectPlaceholder}
|
|
value={formData.subject}
|
|
onChange={(e) => handleChange("subject", e.target.value)}
|
|
error={errors.subject}
|
|
maxLength={SUBJECT_MAX_LENGTH}
|
|
/>
|
|
|
|
<Textarea
|
|
label={t.contact.form.message}
|
|
required
|
|
placeholder={t.contact.form.messagePlaceholder}
|
|
value={formData.message}
|
|
onChange={(e) => handleChange("message", e.target.value)}
|
|
error={errors.message}
|
|
rows={6}
|
|
maxLength={MESSAGE_MAX_LENGTH}
|
|
/>
|
|
|
|
<Button
|
|
type="submit"
|
|
variant="primary"
|
|
size="lg"
|
|
isLoading={isSubmitting}
|
|
disabled={isSubmitting}
|
|
>
|
|
{isSubmitting
|
|
? t.contact.form.sending
|
|
: t.contact.form.submit}
|
|
</Button>
|
|
|
|
{submitStatus === "success" && (
|
|
<motion.p
|
|
className={styles.success}
|
|
initial={{ opacity: 0, y: 10 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
role="alert"
|
|
aria-live="polite"
|
|
>
|
|
{t.contact.form.success}
|
|
</motion.p>
|
|
)}
|
|
|
|
{submitStatus === "error" && (
|
|
<motion.p
|
|
className={styles.error}
|
|
initial={{ opacity: 0, y: 10 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
role="alert"
|
|
aria-live="polite"
|
|
>
|
|
{t.contact.form.error}
|
|
</motion.p>
|
|
)}
|
|
|
|
{rateLimitError && (
|
|
<motion.p
|
|
className={styles.error}
|
|
initial={{ opacity: 0, y: 10 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
role="alert"
|
|
aria-live="polite"
|
|
>
|
|
{t.contact.form.rateLimit}
|
|
</motion.p>
|
|
)}
|
|
</form>
|
|
</motion.div>
|
|
|
|
{/* Info */}
|
|
<motion.div
|
|
className={styles.infoSection}
|
|
initial={{ opacity: 0, x: 30 }}
|
|
animate={{ opacity: 1, x: 0 }}
|
|
transition={{ duration: 0.5, delay: 0.3 }}
|
|
>
|
|
<h2 className={styles.infoTitle}>{t.contact.info.title}</h2>
|
|
|
|
<div className={styles.infoList}>
|
|
<div className={styles.infoItem}>
|
|
<div className={styles.infoIcon}>
|
|
<svg
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
aria-hidden="true"
|
|
>
|
|
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z" />
|
|
<polyline points="22,6 12,13 2,6" />
|
|
</svg>
|
|
</div>
|
|
<div>
|
|
<h3>{t.contact.info.email}</h3>
|
|
<a href="mailto:kontakt@ragusa-it.dev">
|
|
kontakt@ragusa-it.dev
|
|
</a>
|
|
</div>
|
|
</div>
|
|
|
|
<div className={styles.infoItem}>
|
|
<div className={styles.infoIcon}>
|
|
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
|
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z" />
|
|
</svg>
|
|
</div>
|
|
<div>
|
|
<h3>{t.contact.info.github}</h3>
|
|
<a
|
|
href="https://github.com/ragusa-it"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
>
|
|
github.com/ragusa-it
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</main>
|
|
);
|
|
}
|