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:
google-labs-jules[bot]
2026-01-26 01:49:05 +00:00
parent 13df58342a
commit 839e1bf82f
11 changed files with 190 additions and 2 deletions

View File

@@ -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;

View File

@@ -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(() => {

View File

@@ -1 +1,2 @@
export { useTypingEffect } from './useTypingEffect';
export { useRateLimit } from './useRateLimit';

View 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
View 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 };
}

View File

@@ -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',

View File

@@ -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',

View File

@@ -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>