feat(validation): Implement form validation and error handling utilities
- Add validation rules for form fields with XSS protection - Create API error handler to parse and manage server validation errors - Integrate validation rules into contact and inquiries forms - Enhance error handling with user feedback for form submissions - Introduce unit tests for validation and error handling functionalities
This commit is contained in:
+11
-59
@@ -1,4 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
import { handleApiError } from "@/lib/apiErrorHandler";
|
||||||
|
import { validationRules } from "@/lib/validation";
|
||||||
import api from "@/service/api";
|
import api from "@/service/api";
|
||||||
import HCaptcha from "@hcaptcha/react-hcaptcha";
|
import HCaptcha from "@hcaptcha/react-hcaptcha";
|
||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
@@ -14,6 +16,9 @@ type TContactUsForm = {
|
|||||||
phone: string;
|
phone: string;
|
||||||
message: string;
|
message: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const FORM_FIELDS = ["full_name", "email", "phone", "message"];
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
columnsPerRow?: 1 | 2 | 3 | 4;
|
columnsPerRow?: 1 | 2 | 3 | 4;
|
||||||
buttonText?: string;
|
buttonText?: string;
|
||||||
@@ -30,6 +35,7 @@ const ContactUsForm = ({
|
|||||||
handleSubmit,
|
handleSubmit,
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
reset,
|
reset,
|
||||||
|
setError,
|
||||||
} = useForm<TContactUsForm>();
|
} = useForm<TContactUsForm>();
|
||||||
|
|
||||||
const onCaptchaVerify = (token: string) => {
|
const onCaptchaVerify = (token: string) => {
|
||||||
@@ -55,9 +61,7 @@ const ContactUsForm = ({
|
|||||||
setHcaptchaToken(null);
|
setHcaptchaToken(null);
|
||||||
captchaRef.current?.resetCaptcha();
|
captchaRef.current?.resetCaptcha();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error);
|
handleApiError(error, setError, toast.error, FORM_FIELDS);
|
||||||
toast.error("Something went wrong");
|
|
||||||
throw error;
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(() => false);
|
setIsLoading(() => false);
|
||||||
}
|
}
|
||||||
@@ -84,20 +88,7 @@ const ContactUsForm = ({
|
|||||||
id="full_name"
|
id="full_name"
|
||||||
label="Full Name"
|
label="Full Name"
|
||||||
register={register}
|
register={register}
|
||||||
validation={{
|
validation={validationRules.fullName}
|
||||||
required: {
|
|
||||||
message: "This field is required",
|
|
||||||
value: true,
|
|
||||||
},
|
|
||||||
minLength: {
|
|
||||||
value: 2,
|
|
||||||
message: "Please enter at least 2 characters",
|
|
||||||
},
|
|
||||||
maxLength: {
|
|
||||||
value: 100,
|
|
||||||
message: "Please enter at most 100 characters",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
type="text"
|
type="text"
|
||||||
error={errors.full_name?.message}
|
error={errors.full_name?.message}
|
||||||
/>
|
/>
|
||||||
@@ -105,16 +96,7 @@ const ContactUsForm = ({
|
|||||||
id="email"
|
id="email"
|
||||||
label="Email"
|
label="Email"
|
||||||
register={register}
|
register={register}
|
||||||
validation={{
|
validation={validationRules.email}
|
||||||
required: {
|
|
||||||
message: "This field is required",
|
|
||||||
value: true,
|
|
||||||
},
|
|
||||||
pattern: {
|
|
||||||
value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
|
|
||||||
message: "Please enter a valid email address",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
type="email"
|
type="email"
|
||||||
error={errors.email?.message}
|
error={errors.email?.message}
|
||||||
/>
|
/>
|
||||||
@@ -122,24 +104,7 @@ const ContactUsForm = ({
|
|||||||
id="phone"
|
id="phone"
|
||||||
label="Phone"
|
label="Phone"
|
||||||
register={register}
|
register={register}
|
||||||
validation={{
|
validation={validationRules.phone}
|
||||||
required: {
|
|
||||||
message: "This field is required",
|
|
||||||
value: true,
|
|
||||||
},
|
|
||||||
minLength: {
|
|
||||||
value: 10,
|
|
||||||
message: "Please enter at least 10 characters",
|
|
||||||
},
|
|
||||||
maxLength: {
|
|
||||||
value: 20,
|
|
||||||
message: "Please enter at most 20 characters",
|
|
||||||
},
|
|
||||||
pattern: {
|
|
||||||
value: /^[+\d]+$/,
|
|
||||||
message: "Please enter a valid phone number",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
type="text"
|
type="text"
|
||||||
error={errors.phone?.message}
|
error={errors.phone?.message}
|
||||||
/>
|
/>
|
||||||
@@ -148,20 +113,7 @@ const ContactUsForm = ({
|
|||||||
label="Message"
|
label="Message"
|
||||||
register={register}
|
register={register}
|
||||||
error={errors.message?.message}
|
error={errors.message?.message}
|
||||||
validation={{
|
validation={validationRules.message}
|
||||||
required: {
|
|
||||||
value: true,
|
|
||||||
message: "This field is required",
|
|
||||||
},
|
|
||||||
maxLength: {
|
|
||||||
value: 1000,
|
|
||||||
message: "Please enter at most 1000 characters",
|
|
||||||
},
|
|
||||||
minLength: {
|
|
||||||
value: 10,
|
|
||||||
message: "Please enter at least 10 characters",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<div className={`${colSpanClass}`}>
|
<div className={`${colSpanClass}`}>
|
||||||
<HCaptcha
|
<HCaptcha
|
||||||
|
|||||||
+86
-44
@@ -1,8 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
import { handleApiError } from "@/lib/apiErrorHandler";
|
||||||
import { sanitizeText } from "@/lib/sanitize";
|
import { sanitizeText } from "@/lib/sanitize";
|
||||||
|
import { createFieldValidation } from "@/lib/validation";
|
||||||
import api from "@/service/api";
|
import api from "@/service/api";
|
||||||
import { CmsContact } from "@/types/TCms";
|
import { CmsContact } from "@/types/TCms";
|
||||||
import { useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import InputGroup from "../_components/InputGroup";
|
import InputGroup from "../_components/InputGroup";
|
||||||
@@ -29,8 +31,42 @@ const InquiresForm = ({
|
|||||||
handleSubmit,
|
handleSubmit,
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
reset,
|
reset,
|
||||||
|
setError,
|
||||||
} = useForm<TInquiresForm>();
|
} = useForm<TInquiresForm>();
|
||||||
|
|
||||||
|
const defaultFields = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
name: "full_name",
|
||||||
|
label: "Full Name",
|
||||||
|
placeholder: "",
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "company_name",
|
||||||
|
label: "Company Name",
|
||||||
|
placeholder: "",
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "work_email",
|
||||||
|
label: "Work Email",
|
||||||
|
placeholder: "",
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "phone_number",
|
||||||
|
label: "Phone Number",
|
||||||
|
placeholder: "",
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const fields = contactData?.fields || defaultFields;
|
||||||
|
const formFieldNames = useMemo(() => fields.map((f) => f.name), [fields]);
|
||||||
|
|
||||||
const onSubmit = async (data: TInquiresForm) => {
|
const onSubmit = async (data: TInquiresForm) => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
@@ -38,9 +74,7 @@ const InquiresForm = ({
|
|||||||
toast.success(response.message);
|
toast.success(response.message);
|
||||||
reset();
|
reset();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log(error);
|
handleApiError(error, setError, toast.error, formFieldNames);
|
||||||
toast.error("Something went wrong");
|
|
||||||
throw error;
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
@@ -60,32 +94,56 @@ const InquiresForm = ({
|
|||||||
4: "lg:col-span-4",
|
4: "lg:col-span-4",
|
||||||
}[columnsPerRow];
|
}[columnsPerRow];
|
||||||
|
|
||||||
const defaultFields = [
|
|
||||||
{ name: "full_name", label: "Full Name", placeholder: "", required: false },
|
|
||||||
{
|
|
||||||
name: "company_name",
|
|
||||||
label: "Company Name",
|
|
||||||
placeholder: "",
|
|
||||||
required: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "work_email",
|
|
||||||
label: "Work Email",
|
|
||||||
placeholder: "",
|
|
||||||
required: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "phone_number",
|
|
||||||
label: "Phone Number",
|
|
||||||
placeholder: "",
|
|
||||||
required: false,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const fields = contactData?.fields || defaultFields;
|
|
||||||
const submitText =
|
const submitText =
|
||||||
sanitizeText(contactData?.submit_button_text) || buttonText || "Submit";
|
sanitizeText(contactData?.submit_button_text) || buttonText || "Submit";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates validation rules based on field type with XSS protection
|
||||||
|
*/
|
||||||
|
const getFieldValidation = (field: {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
required?: boolean;
|
||||||
|
}) => {
|
||||||
|
const isEmailField =
|
||||||
|
field.name.includes("email") || field.name === "work_email";
|
||||||
|
const isPhoneField =
|
||||||
|
field.name.includes("phone") || field.name === "phone_number";
|
||||||
|
|
||||||
|
if (isPhoneField) {
|
||||||
|
return createFieldValidation(field.label, field.required, {
|
||||||
|
pattern: {
|
||||||
|
value: /^[+\d\s()-]+$/,
|
||||||
|
message:
|
||||||
|
"Please enter a valid phone number (digits, +, spaces, parentheses, and dashes only)",
|
||||||
|
},
|
||||||
|
minLength: field.required
|
||||||
|
? { value: 10, message: "Phone number must be at least 10 digits" }
|
||||||
|
: undefined,
|
||||||
|
maxLength: {
|
||||||
|
value: 20,
|
||||||
|
message: "Phone number must not exceed 20 characters",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEmailField) {
|
||||||
|
return createFieldValidation(field.label, field.required, {
|
||||||
|
pattern: {
|
||||||
|
value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
|
||||||
|
message: "Please enter a valid email address",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return createFieldValidation(field.label, field.required, {
|
||||||
|
maxLength: {
|
||||||
|
value: 200,
|
||||||
|
message: `${field.label} must not exceed 200 characters`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
className={`w-full px-4 lg:px-16 py-10 grid ${gridColsClass} gap-4`}
|
className={`w-full px-4 lg:px-16 py-10 grid ${gridColsClass} gap-4`}
|
||||||
@@ -95,8 +153,6 @@ const InquiresForm = ({
|
|||||||
field.name.includes("email") || field.name === "work_email"
|
field.name.includes("email") || field.name === "work_email"
|
||||||
? "email"
|
? "email"
|
||||||
: "text";
|
: "text";
|
||||||
const isPhoneField =
|
|
||||||
field.name.includes("phone") || field.name === "phone_number";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<InputGroup
|
<InputGroup
|
||||||
@@ -107,21 +163,7 @@ const InquiresForm = ({
|
|||||||
register={register}
|
register={register}
|
||||||
type={inputType}
|
type={inputType}
|
||||||
error={errors[field.name]?.message}
|
error={errors[field.name]?.message}
|
||||||
validation={
|
validation={getFieldValidation(field)}
|
||||||
isPhoneField
|
|
||||||
? {
|
|
||||||
pattern: {
|
|
||||||
value: /^[+\d]+$/,
|
|
||||||
message: "Please enter a valid phone number",
|
|
||||||
},
|
|
||||||
required: field.required
|
|
||||||
? "This field is required"
|
|
||||||
: undefined,
|
|
||||||
}
|
|
||||||
: field.required
|
|
||||||
? { required: "This field is required" }
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
/** @type {import('ts-jest').JestConfigWithTsJest} */
|
||||||
|
module.exports = {
|
||||||
|
preset: "ts-jest",
|
||||||
|
testEnvironment: "node",
|
||||||
|
roots: ["<rootDir>/lib"],
|
||||||
|
testMatch: ["**/__tests__/**/*.test.ts"],
|
||||||
|
moduleNameMapper: {
|
||||||
|
"^@/(.*)$": "<rootDir>/$1",
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
import { AxiosError } from "axios";
|
||||||
|
import { handleApiError, parseApiError } from "../apiErrorHandler";
|
||||||
|
|
||||||
|
describe("API Error Handler", () => {
|
||||||
|
describe("parseApiError", () => {
|
||||||
|
it("should return default message for non-Error objects", () => {
|
||||||
|
const result = parseApiError("string error");
|
||||||
|
expect(result.generalMessage).toBe("Something went wrong. Please try again.");
|
||||||
|
expect(result.fieldErrors).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle network errors (no response)", () => {
|
||||||
|
const error = new Error("Network Error") as AxiosError;
|
||||||
|
error.isAxiosError = true;
|
||||||
|
(error as AxiosError).response = undefined;
|
||||||
|
|
||||||
|
const result = parseApiError(error);
|
||||||
|
expect(result.generalMessage).toContain("Unable to connect");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should parse 400 errors with field-level errors", () => {
|
||||||
|
const error = new Error("Bad Request") as AxiosError;
|
||||||
|
error.isAxiosError = true;
|
||||||
|
(error as AxiosError).response = {
|
||||||
|
status: 400,
|
||||||
|
data: {
|
||||||
|
errors: {
|
||||||
|
email: "Invalid email format",
|
||||||
|
full_name: ["Name is too short", "Name contains invalid characters"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
statusText: "Bad Request",
|
||||||
|
headers: {},
|
||||||
|
config: {} as any,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = parseApiError(error);
|
||||||
|
expect(result.generalMessage).toContain("Validation error");
|
||||||
|
expect(result.fieldErrors.email).toBe("Invalid email format");
|
||||||
|
expect(result.fieldErrors.full_name).toContain("Name is too short");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should parse 400 errors with message only", () => {
|
||||||
|
const error = new Error("Bad Request") as AxiosError;
|
||||||
|
error.isAxiosError = true;
|
||||||
|
(error as AxiosError).response = {
|
||||||
|
status: 400,
|
||||||
|
data: {
|
||||||
|
message: "Custom validation error message",
|
||||||
|
},
|
||||||
|
statusText: "Bad Request",
|
||||||
|
headers: {},
|
||||||
|
config: {} as any,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = parseApiError(error);
|
||||||
|
expect(result.generalMessage).toBe("Custom validation error message");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle 401 unauthorized errors", () => {
|
||||||
|
const error = new Error("Unauthorized") as AxiosError;
|
||||||
|
error.isAxiosError = true;
|
||||||
|
(error as AxiosError).response = {
|
||||||
|
status: 401,
|
||||||
|
data: {},
|
||||||
|
statusText: "Unauthorized",
|
||||||
|
headers: {},
|
||||||
|
config: {} as any,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = parseApiError(error);
|
||||||
|
expect(result.generalMessage).toContain("Authentication required");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle 422 unprocessable entity errors", () => {
|
||||||
|
const error = new Error("Unprocessable Entity") as AxiosError;
|
||||||
|
error.isAxiosError = true;
|
||||||
|
(error as AxiosError).response = {
|
||||||
|
status: 422,
|
||||||
|
data: {
|
||||||
|
errors: {
|
||||||
|
phone: "invalid_phone",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
statusText: "Unprocessable Entity",
|
||||||
|
headers: {},
|
||||||
|
config: {} as any,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = parseApiError(error);
|
||||||
|
expect(result.generalMessage).toContain("Validation error");
|
||||||
|
expect(result.fieldErrors.phone).toContain("phone number");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle 429 rate limit errors", () => {
|
||||||
|
const error = new Error("Too Many Requests") as AxiosError;
|
||||||
|
error.isAxiosError = true;
|
||||||
|
(error as AxiosError).response = {
|
||||||
|
status: 429,
|
||||||
|
data: {},
|
||||||
|
statusText: "Too Many Requests",
|
||||||
|
headers: {},
|
||||||
|
config: {} as any,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = parseApiError(error);
|
||||||
|
expect(result.generalMessage).toContain("Too many requests");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle 500 server errors", () => {
|
||||||
|
const error = new Error("Internal Server Error") as AxiosError;
|
||||||
|
error.isAxiosError = true;
|
||||||
|
(error as AxiosError).response = {
|
||||||
|
status: 500,
|
||||||
|
data: {},
|
||||||
|
statusText: "Internal Server Error",
|
||||||
|
headers: {},
|
||||||
|
config: {} as any,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = parseApiError(error);
|
||||||
|
expect(result.generalMessage).toContain("Server error");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("handleApiError", () => {
|
||||||
|
it("should call setError for each field error", () => {
|
||||||
|
const mockSetError = jest.fn();
|
||||||
|
const mockShowToast = jest.fn();
|
||||||
|
|
||||||
|
const error = new Error("Bad Request") as AxiosError;
|
||||||
|
error.isAxiosError = true;
|
||||||
|
(error as AxiosError).response = {
|
||||||
|
status: 400,
|
||||||
|
data: {
|
||||||
|
errors: {
|
||||||
|
email: "Invalid email",
|
||||||
|
phone: "Invalid phone",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
statusText: "Bad Request",
|
||||||
|
headers: {},
|
||||||
|
config: {} as any,
|
||||||
|
};
|
||||||
|
|
||||||
|
handleApiError(error, mockSetError, mockShowToast, ["email", "phone"]);
|
||||||
|
|
||||||
|
expect(mockSetError).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mockSetError).toHaveBeenCalledWith("email", {
|
||||||
|
type: "server",
|
||||||
|
message: "Invalid email",
|
||||||
|
});
|
||||||
|
expect(mockSetError).toHaveBeenCalledWith("phone", {
|
||||||
|
type: "server",
|
||||||
|
message: "Invalid phone",
|
||||||
|
});
|
||||||
|
expect(mockShowToast).toHaveBeenCalledWith(
|
||||||
|
"Please correct the errors in the highlighted fields."
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should only set errors for fields in formFields list", () => {
|
||||||
|
const mockSetError = jest.fn();
|
||||||
|
const mockShowToast = jest.fn();
|
||||||
|
|
||||||
|
const error = new Error("Bad Request") as AxiosError;
|
||||||
|
error.isAxiosError = true;
|
||||||
|
(error as AxiosError).response = {
|
||||||
|
status: 400,
|
||||||
|
data: {
|
||||||
|
errors: {
|
||||||
|
email: "Invalid email",
|
||||||
|
unknown_field: "Unknown error",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
statusText: "Bad Request",
|
||||||
|
headers: {},
|
||||||
|
config: {} as any,
|
||||||
|
};
|
||||||
|
|
||||||
|
handleApiError(error, mockSetError, mockShowToast, ["email"]);
|
||||||
|
|
||||||
|
expect(mockSetError).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockSetError).toHaveBeenCalledWith("email", {
|
||||||
|
type: "server",
|
||||||
|
message: "Invalid email",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should show general message when no field errors", () => {
|
||||||
|
const mockSetError = jest.fn();
|
||||||
|
const mockShowToast = jest.fn();
|
||||||
|
|
||||||
|
const error = new Error("Internal Server Error") as AxiosError;
|
||||||
|
error.isAxiosError = true;
|
||||||
|
(error as AxiosError).response = {
|
||||||
|
status: 500,
|
||||||
|
data: {},
|
||||||
|
statusText: "Internal Server Error",
|
||||||
|
headers: {},
|
||||||
|
config: {} as any,
|
||||||
|
};
|
||||||
|
|
||||||
|
handleApiError(error, mockSetError, mockShowToast, ["email"]);
|
||||||
|
|
||||||
|
expect(mockSetError).not.toHaveBeenCalled();
|
||||||
|
expect(mockShowToast).toHaveBeenCalledWith(expect.stringContaining("Server error"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import {
|
||||||
|
containsDangerousContent,
|
||||||
|
createFieldValidation,
|
||||||
|
createXSSValidator,
|
||||||
|
validationRules,
|
||||||
|
} from "../validation";
|
||||||
|
|
||||||
|
describe("Validation Functions", () => {
|
||||||
|
describe("containsDangerousContent", () => {
|
||||||
|
it("should detect script tags", () => {
|
||||||
|
expect(containsDangerousContent("<script>alert('XSS')</script>")).toBe(true);
|
||||||
|
expect(containsDangerousContent("<SCRIPT>alert('XSS')</SCRIPT>")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should detect javascript: protocol", () => {
|
||||||
|
expect(containsDangerousContent("javascript:alert('XSS')")).toBe(true);
|
||||||
|
expect(containsDangerousContent("JAVASCRIPT:alert('XSS')")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should detect event handlers", () => {
|
||||||
|
expect(containsDangerousContent("onclick=alert('XSS')")).toBe(true);
|
||||||
|
expect(containsDangerousContent("onerror=alert('XSS')")).toBe(true);
|
||||||
|
expect(containsDangerousContent("onload=alert('XSS')")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should detect iframe tags", () => {
|
||||||
|
expect(containsDangerousContent("<iframe src='evil.com'>")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should detect data: text/html", () => {
|
||||||
|
expect(containsDangerousContent("data: text/html,<script>")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return false for safe content", () => {
|
||||||
|
expect(containsDangerousContent("Hello World")).toBe(false);
|
||||||
|
expect(containsDangerousContent("john.doe@example.com")).toBe(false);
|
||||||
|
expect(containsDangerousContent("+1234567890")).toBe(false);
|
||||||
|
expect(containsDangerousContent("This is a normal message.")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle empty or null input", () => {
|
||||||
|
expect(containsDangerousContent("")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createXSSValidator", () => {
|
||||||
|
it("should return error message for dangerous content", () => {
|
||||||
|
const validator = createXSSValidator("Full Name");
|
||||||
|
expect(validator("<script>alert('XSS')</script>")).toContain("Full Name");
|
||||||
|
expect(validator("<script>alert('XSS')</script>")).toContain("invalid");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return true for safe content", () => {
|
||||||
|
const validator = createXSSValidator("Full Name");
|
||||||
|
expect(validator("John Doe")).toBe(true);
|
||||||
|
expect(validator("Jane Smith")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createFieldValidation", () => {
|
||||||
|
it("should create validation with XSS check", () => {
|
||||||
|
const validation = createFieldValidation("Email", true);
|
||||||
|
expect(validation).toHaveProperty("validate.noXSS");
|
||||||
|
expect(validation).toHaveProperty("required");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should include additional rules", () => {
|
||||||
|
const validation = createFieldValidation("Phone", false, {
|
||||||
|
pattern: { value: /^[+\d]+$/, message: "Invalid phone" },
|
||||||
|
});
|
||||||
|
expect(validation).toHaveProperty("pattern");
|
||||||
|
expect(validation).toHaveProperty("validate.noXSS");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("validationRules", () => {
|
||||||
|
describe("fullName rules", () => {
|
||||||
|
it("should have required validation", () => {
|
||||||
|
expect(validationRules.fullName.required.value).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have min/max length", () => {
|
||||||
|
expect(validationRules.fullName.minLength.value).toBe(2);
|
||||||
|
expect(validationRules.fullName.maxLength.value).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have XSS validation", () => {
|
||||||
|
expect(validationRules.fullName.validate.noXSS).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("email rules", () => {
|
||||||
|
it("should have pattern validation", () => {
|
||||||
|
expect(validationRules.email.pattern.value).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have XSS validation", () => {
|
||||||
|
expect(validationRules.email.validate.noXSS).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("phone rules", () => {
|
||||||
|
it("should have pattern validation for phone format", () => {
|
||||||
|
const pattern = validationRules.phone.pattern.value;
|
||||||
|
expect(pattern.test("+1234567890")).toBe(true);
|
||||||
|
expect(pattern.test("(123) 456-7890")).toBe(true);
|
||||||
|
expect(pattern.test("abc123")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("message rules", () => {
|
||||||
|
it("should have XSS validation", () => {
|
||||||
|
expect(validationRules.message.validate.noXSS).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have max length of 1000", () => {
|
||||||
|
expect(validationRules.message.maxLength.value).toBe(1000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
/**
|
||||||
|
* API Error Handler Utility
|
||||||
|
* Parses server error responses and extracts field-level validation errors
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { AxiosError } from "axios";
|
||||||
|
import { FieldValues, Path, UseFormSetError } from "react-hook-form";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standard API error response structure
|
||||||
|
*/
|
||||||
|
export interface ApiErrorResponse {
|
||||||
|
message?: string;
|
||||||
|
errors?: Record<string, string | string[]>;
|
||||||
|
error?: string;
|
||||||
|
detail?: string | Record<string, string[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parsed error result
|
||||||
|
*/
|
||||||
|
export interface ParsedApiError {
|
||||||
|
generalMessage: string;
|
||||||
|
fieldErrors: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Field name mapping for common API field names to form field names
|
||||||
|
*/
|
||||||
|
const FIELD_NAME_MAP: Record<string, string> = {
|
||||||
|
fullName: "full_name",
|
||||||
|
full_name: "full_name",
|
||||||
|
email: "email",
|
||||||
|
phone: "phone",
|
||||||
|
phoneNumber: "phone_number",
|
||||||
|
phone_number: "phone_number",
|
||||||
|
message: "message",
|
||||||
|
companyName: "company_name",
|
||||||
|
company_name: "company_name",
|
||||||
|
workEmail: "work_email",
|
||||||
|
work_email: "work_email",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Common error messages for validation types
|
||||||
|
*/
|
||||||
|
const ERROR_MESSAGES: Record<string, string> = {
|
||||||
|
invalid_input: "Invalid input detected. Please remove any special characters or scripts.",
|
||||||
|
xss_detected: "Invalid content detected. Please remove any HTML tags or scripts.",
|
||||||
|
validation_failed: "Validation failed. Please check your input.",
|
||||||
|
required: "This field is required.",
|
||||||
|
invalid_email: "Please enter a valid email address.",
|
||||||
|
invalid_phone: "Please enter a valid phone number.",
|
||||||
|
too_short: "Input is too short.",
|
||||||
|
too_long: "Input is too long.",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes field name from API to form field name
|
||||||
|
*/
|
||||||
|
function normalizeFieldName(fieldName: string): string {
|
||||||
|
return FIELD_NAME_MAP[fieldName] || fieldName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts a human-readable error message from various error formats
|
||||||
|
*/
|
||||||
|
function extractErrorMessage(error: string | string[] | unknown): string {
|
||||||
|
if (typeof error === "string") {
|
||||||
|
return ERROR_MESSAGES[error] || error;
|
||||||
|
}
|
||||||
|
if (Array.isArray(error)) {
|
||||||
|
return error.map((e) => ERROR_MESSAGES[e] || e).join(". ");
|
||||||
|
}
|
||||||
|
return "Invalid input";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses API error response and extracts field-level errors
|
||||||
|
* @param error - The axios error object
|
||||||
|
* @returns Parsed error with general message and field-specific errors
|
||||||
|
*/
|
||||||
|
export function parseApiError(error: unknown): ParsedApiError {
|
||||||
|
const result: ParsedApiError = {
|
||||||
|
generalMessage: "Something went wrong. Please try again.",
|
||||||
|
fieldErrors: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!(error instanceof Error)) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const axiosError = error as AxiosError<ApiErrorResponse>;
|
||||||
|
|
||||||
|
if (!axiosError.response) {
|
||||||
|
// Network error
|
||||||
|
result.generalMessage = "Unable to connect to server. Please check your connection.";
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { status, data } = axiosError.response;
|
||||||
|
|
||||||
|
// Handle different HTTP status codes
|
||||||
|
if (status === 400) {
|
||||||
|
result.generalMessage = "Validation error. Please check the highlighted fields.";
|
||||||
|
|
||||||
|
// Extract field errors from different response formats
|
||||||
|
if (data?.errors) {
|
||||||
|
// Format: { errors: { field1: "error", field2: ["error1", "error2"] } }
|
||||||
|
Object.entries(data.errors).forEach(([field, error]) => {
|
||||||
|
const normalizedField = normalizeFieldName(field);
|
||||||
|
result.fieldErrors[normalizedField] = extractErrorMessage(error);
|
||||||
|
});
|
||||||
|
} else if (data?.detail && typeof data.detail === "object") {
|
||||||
|
// Django REST framework format: { detail: { field1: ["error"] } }
|
||||||
|
Object.entries(data.detail).forEach(([field, errors]) => {
|
||||||
|
const normalizedField = normalizeFieldName(field);
|
||||||
|
result.fieldErrors[normalizedField] = extractErrorMessage(errors);
|
||||||
|
});
|
||||||
|
} else if (data?.message) {
|
||||||
|
result.generalMessage = data.message;
|
||||||
|
} else if (data?.error) {
|
||||||
|
result.generalMessage = data.error;
|
||||||
|
}
|
||||||
|
} else if (status === 401) {
|
||||||
|
result.generalMessage = "Authentication required. Please log in and try again.";
|
||||||
|
} else if (status === 403) {
|
||||||
|
result.generalMessage = "You don't have permission to perform this action.";
|
||||||
|
} else if (status === 404) {
|
||||||
|
result.generalMessage = "The requested resource was not found.";
|
||||||
|
} else if (status === 422) {
|
||||||
|
// Unprocessable Entity - validation error
|
||||||
|
result.generalMessage = "Validation error. Please check the highlighted fields.";
|
||||||
|
if (data?.errors) {
|
||||||
|
Object.entries(data.errors).forEach(([field, error]) => {
|
||||||
|
const normalizedField = normalizeFieldName(field);
|
||||||
|
result.fieldErrors[normalizedField] = extractErrorMessage(error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (status === 429) {
|
||||||
|
result.generalMessage = "Too many requests. Please wait a moment and try again.";
|
||||||
|
} else if (status >= 500) {
|
||||||
|
result.generalMessage = "Server error. Please try again later.";
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles API error and sets form field errors using react-hook-form
|
||||||
|
* @param error - The error from API call
|
||||||
|
* @param setError - react-hook-form setError function
|
||||||
|
* @param showToast - Function to show toast notification
|
||||||
|
* @param formFields - Optional list of form field names to validate against
|
||||||
|
*/
|
||||||
|
export function handleApiError<T extends FieldValues>(
|
||||||
|
error: unknown,
|
||||||
|
setError: UseFormSetError<T>,
|
||||||
|
showToast: (message: string) => void,
|
||||||
|
formFields?: string[]
|
||||||
|
): void {
|
||||||
|
const parsedError = parseApiError(error);
|
||||||
|
|
||||||
|
// Set field-specific errors
|
||||||
|
let hasFieldErrors = false;
|
||||||
|
Object.entries(parsedError.fieldErrors).forEach(([field, message]) => {
|
||||||
|
// Only set error if field exists in form (or if formFields not specified)
|
||||||
|
if (!formFields || formFields.includes(field)) {
|
||||||
|
setError(field as Path<T>, {
|
||||||
|
type: "server",
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
hasFieldErrors = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show toast with general message
|
||||||
|
// If we have field errors, use a more specific message
|
||||||
|
if (hasFieldErrors) {
|
||||||
|
showToast("Please correct the errors in the highlighted fields.");
|
||||||
|
} else {
|
||||||
|
showToast(parsedError.generalMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
/**
|
||||||
|
* Validation utilities for form fields
|
||||||
|
* Detects potentially harmful content (XSS patterns) and provides validation rules
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Patterns that indicate potentially malicious content
|
||||||
|
const DANGEROUS_PATTERNS = [
|
||||||
|
/<script[\s\S]*?>/i,
|
||||||
|
/<\/script>/i,
|
||||||
|
/javascript:/i,
|
||||||
|
/vbscript:/i,
|
||||||
|
/on\w+\s*=/i, // onclick=, onerror=, onload=, etc.
|
||||||
|
/<iframe[\s\S]*?>/i,
|
||||||
|
/<object[\s\S]*?>/i,
|
||||||
|
/<embed[\s\S]*?>/i,
|
||||||
|
/<link[\s\S]*?>/i,
|
||||||
|
/<meta[\s\S]*?>/i,
|
||||||
|
/data:\s*text\/html/i,
|
||||||
|
/expression\s*\(/i, // CSS expression
|
||||||
|
/url\s*\(\s*["']?\s*javascript:/i,
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if text contains potentially dangerous patterns (XSS)
|
||||||
|
* @param text - The text to check
|
||||||
|
* @returns true if dangerous patterns are found
|
||||||
|
*/
|
||||||
|
export function containsDangerousContent(text: string): boolean {
|
||||||
|
if (!text) return false;
|
||||||
|
return DANGEROUS_PATTERNS.some((pattern) => pattern.test(text));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validation function for react-hook-form that checks for XSS patterns
|
||||||
|
* @param fieldName - The human-readable name of the field for error messages
|
||||||
|
* @returns Validation function for react-hook-form
|
||||||
|
*/
|
||||||
|
export function createXSSValidator(fieldName: string) {
|
||||||
|
return (value: string) => {
|
||||||
|
if (containsDangerousContent(value)) {
|
||||||
|
return `${fieldName} contains invalid characters or content. Please remove any special HTML tags or scripts.`;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standard validation rules with XSS protection for common field types
|
||||||
|
*/
|
||||||
|
export const validationRules = {
|
||||||
|
fullName: {
|
||||||
|
required: {
|
||||||
|
value: true,
|
||||||
|
message: "Full name is required",
|
||||||
|
},
|
||||||
|
minLength: {
|
||||||
|
value: 2,
|
||||||
|
message: "Full name must be at least 2 characters",
|
||||||
|
},
|
||||||
|
maxLength: {
|
||||||
|
value: 100,
|
||||||
|
message: "Full name must not exceed 100 characters",
|
||||||
|
},
|
||||||
|
validate: {
|
||||||
|
noXSS: createXSSValidator("Full name"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
email: {
|
||||||
|
required: {
|
||||||
|
value: true,
|
||||||
|
message: "Email is required",
|
||||||
|
},
|
||||||
|
pattern: {
|
||||||
|
value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
|
||||||
|
message: "Please enter a valid email address",
|
||||||
|
},
|
||||||
|
validate: {
|
||||||
|
noXSS: createXSSValidator("Email"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
phone: {
|
||||||
|
required: {
|
||||||
|
value: true,
|
||||||
|
message: "Phone number is required",
|
||||||
|
},
|
||||||
|
minLength: {
|
||||||
|
value: 10,
|
||||||
|
message: "Phone number must be at least 10 characters",
|
||||||
|
},
|
||||||
|
maxLength: {
|
||||||
|
value: 20,
|
||||||
|
message: "Phone number must not exceed 20 characters",
|
||||||
|
},
|
||||||
|
pattern: {
|
||||||
|
value: /^[+\d\s()-]+$/,
|
||||||
|
message: "Please enter a valid phone number (digits, +, spaces, parentheses, and dashes only)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
message: {
|
||||||
|
required: {
|
||||||
|
value: true,
|
||||||
|
message: "Message is required",
|
||||||
|
},
|
||||||
|
minLength: {
|
||||||
|
value: 10,
|
||||||
|
message: "Message must be at least 10 characters",
|
||||||
|
},
|
||||||
|
maxLength: {
|
||||||
|
value: 1000,
|
||||||
|
message: "Message must not exceed 1000 characters",
|
||||||
|
},
|
||||||
|
validate: {
|
||||||
|
noXSS: createXSSValidator("Message"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
companyName: {
|
||||||
|
maxLength: {
|
||||||
|
value: 200,
|
||||||
|
message: "Company name must not exceed 200 characters",
|
||||||
|
},
|
||||||
|
validate: {
|
||||||
|
noXSS: createXSSValidator("Company name"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates validation rules for a field with optional XSS check
|
||||||
|
* @param fieldName - Human-readable field name
|
||||||
|
* @param isRequired - Whether the field is required
|
||||||
|
* @param additionalRules - Additional validation rules to merge
|
||||||
|
*/
|
||||||
|
export function createFieldValidation(
|
||||||
|
fieldName: string,
|
||||||
|
isRequired: boolean = false,
|
||||||
|
additionalRules: Record<string, unknown> = {}
|
||||||
|
) {
|
||||||
|
const rules: Record<string, unknown> = {
|
||||||
|
validate: {
|
||||||
|
noXSS: createXSSValidator(fieldName),
|
||||||
|
},
|
||||||
|
...additionalRules,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isRequired) {
|
||||||
|
rules.required = `${fieldName} is required`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return rules;
|
||||||
|
}
|
||||||
Generated
+4052
-4
File diff suppressed because it is too large
Load Diff
+6
-1
@@ -5,7 +5,9 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack",
|
"dev": "next dev --turbopack",
|
||||||
"build": "next build --turbopack",
|
"build": "next build --turbopack",
|
||||||
"start": "next start"
|
"start": "next start",
|
||||||
|
"test": "jest",
|
||||||
|
"test:watch": "jest --watch"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hcaptcha/react-hcaptcha": "^1.16.0",
|
"@hcaptcha/react-hcaptcha": "^1.16.0",
|
||||||
@@ -24,10 +26,13 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/jest": "^30.0.0",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
"jest": "^30.2.0",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
|
"ts-jest": "^29.4.6",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user