feat: add client-side rate limiting to contact form
- Added `useRateLimit` hook - Integrated hook into `Contact.tsx` - Added translations for rate limit error - Added unit tests - Fixed type error in `Button.tsx` to allow build to pass
This commit is contained in:
@@ -2,7 +2,7 @@ import { type ReactNode, type ButtonHTMLAttributes } from 'react';
|
||||
import { motion } from 'motion/react';
|
||||
import styles from './Button.module.css';
|
||||
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
interface ButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'onAnimationStart' | 'onDragStart' | 'onDragEnd' | 'onDrag'> {
|
||||
variant?: 'primary' | 'secondary' | 'outline';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
children: ReactNode;
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import { render, screen, cleanup } from '@testing-library/react';
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { Button } from '../Button';
|
||||
import React from 'react';
|
||||
|
||||
describe('Button', () => {
|
||||
afterEach(() => {
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export { useTypingEffect } from './useTypingEffect';
|
||||
export { useRateLimit } from './useRateLimit';
|
||||
|
||||
64
src/hooks/useRateLimit.test.ts
Normal file
64
src/hooks/useRateLimit.test.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
// @vitest-environment jsdom
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
import { useRateLimit } from './useRateLimit';
|
||||
|
||||
describe('useRateLimit', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('should allow first attempt', () => {
|
||||
const { result } = renderHook(() => useRateLimit('test-key', 1000));
|
||||
|
||||
let allowed: boolean = false;
|
||||
act(() => {
|
||||
allowed = result.current.checkRateLimit();
|
||||
});
|
||||
|
||||
expect(allowed).toBe(true);
|
||||
expect(result.current.remainingTime).toBe(0);
|
||||
});
|
||||
|
||||
it('should block immediate second attempt', () => {
|
||||
const { result } = renderHook(() => useRateLimit('test-key', 1000));
|
||||
|
||||
act(() => {
|
||||
result.current.checkRateLimit();
|
||||
});
|
||||
|
||||
let allowed: boolean = true;
|
||||
act(() => {
|
||||
allowed = result.current.checkRateLimit();
|
||||
});
|
||||
|
||||
expect(allowed).toBe(false);
|
||||
expect(result.current.remainingTime).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should allow attempt after cooldown', () => {
|
||||
const { result } = renderHook(() => useRateLimit('test-key', 1000));
|
||||
|
||||
act(() => {
|
||||
result.current.checkRateLimit();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1100);
|
||||
});
|
||||
|
||||
let allowed: boolean = false;
|
||||
act(() => {
|
||||
allowed = result.current.checkRateLimit();
|
||||
});
|
||||
|
||||
expect(allowed).toBe(true);
|
||||
expect(result.current.remainingTime).toBe(0);
|
||||
});
|
||||
});
|
||||
38
src/hooks/useRateLimit.ts
Normal file
38
src/hooks/useRateLimit.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
interface UseRateLimitReturn {
|
||||
checkRateLimit: () => boolean;
|
||||
remainingTime: number;
|
||||
}
|
||||
|
||||
export function useRateLimit(key: string, cooldownMs: number): UseRateLimitReturn {
|
||||
const [remainingTime, setRemainingTime] = useState(0);
|
||||
|
||||
const checkRateLimit = useCallback(() => {
|
||||
try {
|
||||
const now = Date.now();
|
||||
const lastAttempt = localStorage.getItem(key);
|
||||
|
||||
if (lastAttempt) {
|
||||
const lastTime = parseInt(lastAttempt, 10);
|
||||
const timePassed = now - lastTime;
|
||||
|
||||
if (timePassed < cooldownMs) {
|
||||
const remaining = Math.ceil((cooldownMs - timePassed) / 1000);
|
||||
setRemainingTime(remaining);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
localStorage.setItem(key, now.toString());
|
||||
setRemainingTime(0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn('LocalStorage not available:', error);
|
||||
// Fail safe: allow action if storage fails
|
||||
return true;
|
||||
}
|
||||
}, [key, cooldownMs]);
|
||||
|
||||
return { checkRateLimit, remainingTime };
|
||||
}
|
||||
@@ -96,6 +96,7 @@ export const de = {
|
||||
sending: 'Wird gesendet...',
|
||||
success: 'Nachricht erfolgreich gesendet! Ich melde mich bald bei Ihnen.',
|
||||
error: 'Fehler beim Senden. Bitte versuchen Sie es erneut oder kontaktieren Sie mich direkt.',
|
||||
rateLimit: 'Zu viele Anfragen. Bitte warten Sie einen Moment.',
|
||||
},
|
||||
info: {
|
||||
title: 'Kontaktdaten',
|
||||
|
||||
@@ -98,6 +98,7 @@ export const en: Translations = {
|
||||
sending: 'Sending...',
|
||||
success: 'Message sent successfully! I\'ll get back to you soon.',
|
||||
error: 'Error sending message. Please try again or contact me directly.',
|
||||
rateLimit: 'Too many requests. Please wait a moment.',
|
||||
},
|
||||
info: {
|
||||
title: 'Contact Info',
|
||||
|
||||
@@ -2,6 +2,7 @@ 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 } from "../utils/security";
|
||||
@@ -38,6 +39,8 @@ export function Contact() {
|
||||
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 = {};
|
||||
@@ -73,8 +76,14 @@ export function Contact() {
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
setRateLimitError(false);
|
||||
if (!validateForm()) return;
|
||||
|
||||
if (!checkRateLimit()) {
|
||||
setRateLimitError(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setSubmitStatus("idle");
|
||||
|
||||
@@ -215,6 +224,16 @@ export function Contact() {
|
||||
{t.contact.form.error}
|
||||
</motion.p>
|
||||
)}
|
||||
|
||||
{rateLimitError && (
|
||||
<motion.p
|
||||
className={styles.error}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
>
|
||||
{t.contact.form.rateLimit}
|
||||
</motion.p>
|
||||
)}
|
||||
</form>
|
||||
</motion.div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user