diff --git a/apps/web/src/components/forms/SavingsDepositForm.module.css b/apps/web/src/components/forms/SavingsDepositForm.module.css new file mode 100644 index 00000000..2f0e2f0c --- /dev/null +++ b/apps/web/src/components/forms/SavingsDepositForm.module.css @@ -0,0 +1,112 @@ +.form { + display: flex; + flex-direction: column; + gap: 20px; + max-width: 480px; + margin: 0 auto; + padding: 24px; + background: #ffffff; + border-radius: 16px; + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); +} + +.title { + font-size: 1.5rem; + font-weight: 700; + color: #1f2937; + margin: 0 0 8px 0; + text-align: center; +} + +.success { + padding: 12px 16px; + background: #d1fae5; + border: 1px solid #10b981; + border-radius: 8px; + color: #065f46; + font-size: 0.875rem; + font-weight: 500; + text-align: center; +} + +.submitError { + padding: 12px 16px; + background: #fee2e2; + border: 1px solid #ef4444; + border-radius: 8px; + color: #991b1b; + font-size: 0.875rem; + font-weight: 500; + text-align: center; +} + +.submitButton { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + width: 100%; + padding: 12px 24px; + font-size: 1rem; + font-weight: 600; + color: #ffffff; + background: #6366f1; + border: none; + border-radius: 8px; + cursor: pointer; + transition: background 150ms ease, transform 150ms ease; + min-height: 48px; +} + +.submitButton:hover:not(:disabled) { + background: #4f46e5; +} + +.submitButton:focus-visible { + outline: 3px solid #6366f1; + outline-offset: 2px; +} + +.submitButton:active:not(:disabled) { + transform: scale(0.98); +} + +.submitButton:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.buttonSpinner { + width: 16px; + height: 16px; + border: 2px solid rgba(255, 255, 255, 0.3); + border-top-color: #ffffff; + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +/* Dark mode */ +@media (prefers-color-scheme: dark) { + .form { + background: #1f2937; + } + + .title { + color: #f3f4f6; + } +} + +/* Reduced motion */ +@media (prefers-reduced-motion: reduce) { + .submitButton, + .buttonSpinner { + transition: none; + animation: none; + } +} \ No newline at end of file diff --git a/apps/web/src/components/forms/SavingsDepositForm.tsx b/apps/web/src/components/forms/SavingsDepositForm.tsx new file mode 100644 index 00000000..2adcb215 --- /dev/null +++ b/apps/web/src/components/forms/SavingsDepositForm.tsx @@ -0,0 +1,223 @@ +import React, { useState, useCallback } from 'react'; +import { useValidation } from '@/lib/validation/useValidation'; +import { + isValidStellarAddress, + isValidAmount, + isValidGoalName, + isValidDuration, + sanitizeInput, + validateForm, + isFormValid, +} from '@/lib/validation/validators'; +import { ValidatedInput } from '@/components/ui/ValidatedInput'; +import styles from './SavingsDepositForm.module.css'; + +interface SavingsDepositFormData { + walletAddress: string; + amount: string; + goalName: string; + duration: string; +} + +export const SavingsDepositForm: React.FC = () => { + const [isSubmitting, setIsSubmitting] = useState(false); + const [submitError, setSubmitError] = useState(null); + const [submitSuccess, setSubmitSuccess] = useState(false); + + // Real-time validation hooks + const walletValidation = useValidation({ + validator: isValidStellarAddress, + debounceMs: 200, + }); + + const amountValidation = useValidation({ + validator: (value) => + isValidAmount(value, { min: 1, max: 100000, decimals: 7, currency: 'USDC' }), + debounceMs: 150, + }); + + const goalValidation = useValidation({ + validator: isValidGoalName, + debounceMs: 300, + }); + + const durationValidation = useValidation({ + validator: isValidDuration, + debounceMs: 200, + }); + + // Handle form submission + const handleSubmit = useCallback( + async (e: React.FormEvent) => { + e.preventDefault(); + setSubmitError(null); + setSubmitSuccess(false); + + // Mark all fields as touched + walletValidation.bind.onBlur(); + amountValidation.bind.onBlur(); + goalValidation.bind.onBlur(); + durationValidation.bind.onBlur(); + + // Force validate all + const results = validateForm({ + walletAddress: { + value: walletValidation.value, + validator: isValidStellarAddress, + required: true, + }, + amount: { + value: amountValidation.value, + validator: (v) => isValidAmount(v, { min: 1, max: 100000, decimals: 7 }), + required: true, + }, + goalName: { + value: goalValidation.value, + validator: isValidGoalName, + required: true, + }, + duration: { + value: durationValidation.value, + validator: isValidDuration, + required: true, + }, + }); + + if (!isFormValid(results)) { + setSubmitError('Please fix the errors above before submitting.'); + return; + } + + setIsSubmitting(true); + + try { + // Sanitize all inputs before sending + const sanitizedData: SavingsDepositFormData = { + walletAddress: sanitizeInput(walletValidation.value).trim(), + amount: sanitizeInput(amountValidation.value).trim(), + goalName: sanitizeInput(goalValidation.value).trim(), + duration: sanitizeInput(durationValidation.value).trim(), + }; + + // Submit to API + const response = await fetch('/api/savings/deposit', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(sanitizedData), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.message || 'Failed to create savings deposit'); + } + + setSubmitSuccess(true); + + // Reset form + walletValidation.reset(); + amountValidation.reset(); + goalValidation.reset(); + durationValidation.reset(); + } catch (err) { + setSubmitError(err instanceof Error ? err.message : 'An unexpected error occurred'); + } finally { + setIsSubmitting(false); + } + }, + [ + walletValidation, + amountValidation, + goalValidation, + durationValidation, + ] + ); + + return ( +
+

Create Savings Deposit

+ + {/* Success message */} + {submitSuccess && ( +
+ ✅ Savings deposit created successfully! +
+ )} + + {/* Submit error */} + {submitError && ( +
+ {submitError} +
+ )} + + {/* Wallet Address */} + 👛} + validation={walletValidation.validation} + showError={walletValidation.showError} + {...walletValidation.bind} + /> + + {/* Amount */} + 💰} + validation={amountValidation.validation} + showError={amountValidation.showError} + {...amountValidation.bind} + /> + + {/* Goal Name */} + 🎯} + validation={goalValidation.validation} + showError={goalValidation.showError} + {...goalValidation.bind} + /> + + {/* Duration */} + 📅} + validation={durationValidation.validation} + showError={durationValidation.showError} + {...durationValidation.bind} + /> + + {/* Submit button */} + + + ); +}; \ No newline at end of file diff --git a/apps/web/src/components/ui/ValidatedInput.module.css b/apps/web/src/components/ui/ValidatedInput.module.css new file mode 100644 index 00000000..38c038bd --- /dev/null +++ b/apps/web/src/components/ui/ValidatedInput.module.css @@ -0,0 +1,197 @@ +.container { + display: flex; + flex-direction: column; + gap: 6px; + width: 100%; +} + +.label { + font-size: 0.875rem; + font-weight: 600; + color: #374151; + line-height: 1.4; +} + +.required { + color: #ef4444; + margin-left: 2px; +} + +.inputWrapper { + position: relative; + display: flex; + align-items: center; +} + +.input { + width: 100%; + padding: 10px 14px; + font-size: 1rem; + line-height: 1.5; + color: #1f2937; + background: #ffffff; + border: 2px solid #d1d5db; + border-radius: 8px; + transition: border-color 150ms ease, box-shadow 150ms ease, background 150ms ease; + outline: none; +} + +.input::placeholder { + color: #9ca3af; +} + +/* Focus state - visible focus ring */ +.input:focus { + border-color: #6366f1; + box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2); + background: #ffffff; +} + +/* Error state */ +.inputError { + border-color: #ef4444; + background: #fef2f2; +} + +.inputError:focus { + border-color: #ef4444; + box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.2); +} + +/* Icon padding */ +.inputWithLeftIcon { + padding-left: 42px; +} + +.inputWithRightIcon { + padding-right: 42px; +} + +/* Icons */ +.leftIcon, +.rightIcon, +.spinner { + position: absolute; + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + color: #6b7280; + pointer-events: none; +} + +.leftIcon { + left: 14px; +} + +.rightIcon, +.spinner { + right: 14px; +} + +.spinner { + color: #6366f1; + animation: spin 1s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +/* Helper text */ +.helper { + font-size: 0.8125rem; + color: #6b7280; + line-height: 1.4; +} + +/* Error message */ +.error { + display: flex; + align-items: flex-start; + gap: 6px; + font-size: 0.8125rem; + font-weight: 500; + color: #dc2626; + line-height: 1.4; + animation: slideIn 150ms ease-out; +} + +.errorIcon { + flex-shrink: 0; + font-size: 0.875rem; + margin-top: 1px; +} + +@keyframes slideIn { + from { + opacity: 0; + transform: translateY(-4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* High contrast mode */ +@media (prefers-contrast: high) { + .input { + border-width: 3px; + } + + .input:focus { + outline: 3px solid #000000; + outline-offset: 2px; + } + + .inputError { + border-width: 3px; + } +} + +/* Reduced motion */ +@media (prefers-reduced-motion: reduce) { + .input, + .error { + transition: none; + animation: none; + } + + .spinner { + animation: none; + opacity: 0.5; + } +} + +/* Dark mode support */ +@media (prefers-color-scheme: dark) { + .label { + color: #e5e7eb; + } + + .input { + color: #f3f4f6; + background: #1f2937; + border-color: #4b5563; + } + + .input::placeholder { + color: #9ca3af; + } + + .input:focus { + background: #1f2937; + } + + .inputError { + background: rgba(239, 68, 68, 0.1); + } + + .helper { + color: #9ca3af; + } +} \ No newline at end of file diff --git a/apps/web/src/components/ui/ValidatedInput.tsx b/apps/web/src/components/ui/ValidatedInput.tsx new file mode 100644 index 00000000..fc5725c5 --- /dev/null +++ b/apps/web/src/components/ui/ValidatedInput.tsx @@ -0,0 +1,140 @@ +import React, { forwardRef, useId } from 'react'; +import type { ValidationResult } from '@/lib/validation/validators'; +import styles from './ValidatedInput.module.css'; + +export interface ValidatedInputProps extends React.InputHTMLAttributes { + /** Input label */ + label: string; + /** Validation result */ + validation: ValidationResult; + /** Whether to show error (typically: touched && !valid) */ + showError: boolean; + /** Helper text shown below input */ + helperText?: string; + /** Icon to display before input */ + leftIcon?: React.ReactNode; + /** Icon to display after input */ + rightIcon?: React.ReactNode; + /** Loading state */ + isLoading?: boolean; + /** Whether field is required */ + required?: boolean; +} + +export const ValidatedInput = forwardRef( + ( + { + label, + validation, + showError, + helperText, + leftIcon, + rightIcon, + isLoading, + required, + className = '', + id: propId, + ...inputProps + }, + ref + ) => { + const generatedId = useId(); + const id = propId || generatedId; + const errorId = `${id}-error`; + const helperId = `${id}-helper`; + + const hasError = showError && !validation.isValid; + const inputClasses = [ + styles.input, + hasError ? styles.inputError : '', + leftIcon ? styles.inputWithLeftIcon : '', + rightIcon || isLoading ? styles.inputWithRightIcon : '', + className, + ] + .filter(Boolean) + .join(' '); + + return ( +
+ {/* Label */} + + + {/* Input wrapper */} +
+ {leftIcon && ( + + )} + + + + {isLoading && ( + + )} + + {rightIcon && !isLoading && ( + + )} +
+ + {/* Helper text */} + {helperText && !hasError && ( + + {helperText} + + )} + + {/* Error message */} + {hasError && ( + + + {validation.error} + + )} +
+ ); + } +); + +ValidatedInput.displayName = 'ValidatedInput'; \ No newline at end of file diff --git a/apps/web/src/lib/validation/__tests__/validators.test.ts b/apps/web/src/lib/validation/__tests__/validators.test.ts new file mode 100644 index 00000000..e1968bdb --- /dev/null +++ b/apps/web/src/lib/validation/__tests__/validators.test.ts @@ -0,0 +1,278 @@ +/** + * validators.test.ts + * + * Comprehensive unit tests for all validation utilities. + * Covers: Stellar addresses, amounts, emails, XSS prevention, + * edge cases, and boundary values. + */ + +import { + isValidStellarAddress, + isValidContractId, + isValidSecretKey, + isValidAmount, + isValidInterestRate, + isValidDuration, + isValidEmail, + isValidDisplayName, + isValidGoalName, + isValidDescription, + sanitizeInput, + isXSSSafe, + isValidPhoneNumber, + isValidURL, + isValidFutureDate, + isValidMemberCount, + isValidContributionFrequency, + validateForm, + isFormValid, + getFirstError, +} from '../validators'; + +// ============================================================================ +// Stellar Address Tests +// ============================================================================ + +describe('isValidStellarAddress', () => { + it('validates correct Stellar public key', () => { + const valid = 'GAA5Z5XNNHHKPHHMA2X7J5L3J5K3J5K3J5K3J5K3J5K3J5K3J5K3J5K3J5'; + expect(isValidStellarAddress(valid).isValid).toBe(true); + }); + + it('rejects empty string', () => { + expect(isValidStellarAddress('').isValid).toBe(false); + expect(isValidStellarAddress('').error).toBe('Wallet address is required'); + }); + + it('rejects too short', () => { + expect(isValidStellarAddress('GABC').isValid).toBe(false); + }); + + it('rejects wrong prefix', () => { + expect(isValidStellarAddress('SAA5Z5XNNHHKPHHMA2X7J5L3J5K3J5K3J5K3J5K3J5K3J5K3J5K3J5K3J5').isValid).toBe(false); + }); + + it('rejects lowercase', () => { + const lower = 'gaa5z5xnnhhkphhma2x7j5l3j5k3j5k3j5k3j5k3j5k3j5k3j5k3j5k3j5'; + expect(isValidStellarAddress(lower).isValid).toBe(false); + }); + + it('rejects special characters', () => { + expect(isValidStellarAddress('GAA5Z5XNNHHKPHHMA2X7J5L3J5K3J5K3J5K3J5K3J5K3J5K3J5K3J5K3J!').isValid).toBe(false); + }); + + it('trims whitespace', () => { + const withSpace = ' GAA5Z5XNNHHKPHHMA2X7J5L3J5K3J5K3J5K3J5K3J5K3J5K3J5K3J5K3J5 '; + expect(isValidStellarAddress(withSpace).isValid).toBe(true); + }); +}); + +// ============================================================================ +// Amount Tests +// ============================================================================ + +describe('isValidAmount', () => { + it('validates correct amount', () => { + expect(isValidAmount('100').isValid).toBe(true); + }); + + it('validates decimal amount', () => { + expect(isValidAmount('100.50').isValid).toBe(true); + }); + + it('rejects negative', () => { + expect(isValidAmount('-10').isValid).toBe(false); + }); + + it('rejects zero when not allowed', () => { + expect(isValidAmount('0', { allowZero: false }).isValid).toBe(false); + }); + + it('allows zero when allowed', () => { + expect(isValidAmount('0', { allowZero: true }).isValid).toBe(true); + }); + + it('rejects below minimum', () => { + const result = isValidAmount('0.5', { min: 1 }); + expect(result.isValid).toBe(false); + expect(result.error).toContain('Minimum'); + }); + + it('rejects above maximum', () => { + const result = isValidAmount('200', { max: 100 }); + expect(result.isValid).toBe(false); + expect(result.error).toContain('Maximum'); + }); + + it('rejects too many decimals', () => { + const result = isValidAmount('1.12345678', { decimals: 7 }); + expect(result.isValid).toBe(false); + expect(result.error).toContain('decimal'); + }); + + it('rejects non-numeric', () => { + expect(isValidAmount('abc').isValid).toBe(false); + }); + + it('rejects empty', () => { + expect(isValidAmount('').isValid).toBe(false); + }); + + it('rejects leading zeros', () => { + expect(isValidAmount('0123').isValid).toBe(false); + }); + + it('allows zero prefix for decimals', () => { + expect(isValidAmount('0.5').isValid).toBe(true); + }); +}); + +// ============================================================================ +// Email Tests +// ============================================================================ + +describe('isValidEmail', () => { + it('validates correct email', () => { + expect(isValidEmail('user@example.com').isValid).toBe(true); + }); + + it('validates with subdomain', () => { + expect(isValidEmail('user@mail.example.com').isValid).toBe(true); + }); + + it('rejects missing @', () => { + expect(isValidEmail('userexample.com').isValid).toBe(false); + }); + + it('rejects missing domain', () => { + expect(isValidEmail('user@').isValid).toBe(false); + }); + + it('rejects missing local part', () => { + expect(isValidEmail('@example.com').isValid).toBe(false); + }); + + it('rejects double @', () => { + expect(isValidEmail('user@@example.com').isValid).toBe(false); + }); + + it('rejects spaces', () => { + expect(isValidEmail('user @example.com').isValid).toBe(false); + }); + + it('lowercases input', () => { + expect(isValidEmail('USER@EXAMPLE.COM').isValid).toBe(true); + }); +}); + +// ============================================================================ +// XSS Prevention Tests +// ============================================================================ + +describe('sanitizeInput', () => { + it('removes script tags', () => { + const dirty = 'Hello'; + expect(sanitizeInput(dirty)).toBe('Hello'); + }); + + it('removes event handlers', () => { + const dirty = '
text
'; + expect(sanitizeInput(dirty)).not.toContain('onmouseover'); + }); + + it('removes javascript: URLs', () => { + const dirty = 'link'; + expect(sanitizeInput(dirty)).not.toContain('javascript:'); + }); + + it('removes iframe tags', () => { + const dirty = ''; + expect(sanitizeInput(dirty)).toBe(''); + }); + + it('preserves safe text', () => { + const safe = 'Hello World 123'; + expect(sanitizeInput(safe)).toBe('Hello World 123'); + }); +}); + +describe('isXSSSafe', () => { + it('accepts safe text', () => { + expect(isXSSSafe('Hello World').isValid).toBe(true); + }); + + it('rejects script tags', () => { + expect(isXSSSafe('').isValid).toBe(false); + }); + + it('rejects event handlers', () => { + expect(isXSSSafe('').isValid).toBe(false); + }); + + it('rejects javascript protocol', () => { + expect(isXSSSafe('javascript:alert(1)').isValid).toBe(false); + }); + + it('accepts empty string', () => { + expect(isXSSSafe('').isValid).toBe(true); + }); +}); + +// ============================================================================ +// Form Validation Tests +// ============================================================================ + +describe('validateForm', () => { + it('validates all fields', () => { + const result = validateForm({ + email: { value: 'test@example.com', validator: isValidEmail, required: true }, + amount: { value: '100', validator: (v) => isValidAmount(v), required: true }, + }); + + expect(result.email.isValid).toBe(true); + expect(result.amount.isValid).toBe(true); + }); + + it('catches invalid fields', () => { + const result = validateForm({ + email: { value: 'invalid', validator: isValidEmail, required: true }, + amount: { value: '100', validator: (v) => isValidAmount(v), required: true }, + }); + + expect(result.email.isValid).toBe(false); + expect(result.amount.isValid).toBe(true); + }); + + it('skips optional empty fields', () => { + const result = validateForm({ + email: { value: '', validator: isValidEmail, required: false }, + }); + + expect(result.email.isValid).toBe(true); + }); +}); + +describe('isFormValid', () => { + it('returns true when all valid', () => { + expect(isFormValid({ a: { isValid: true }, b: { isValid: true } })).toBe(true); + }); + + it('returns false when any invalid', () => { + expect(isFormValid({ a: { isValid: true }, b: { isValid: false } })).toBe(false); + }); +}); + +describe('getFirstError', () => { + it('returns first error', () => { + const error = getFirstError({ + a: { isValid: true }, + b: { isValid: false, error: 'Second error' }, + c: { isValid: false, error: 'Third error' }, + }); + expect(error).toBe('Second error'); + }); + + it('returns undefined when all valid', () => { + expect(getFirstError({ a: { isValid: true } })).toBeUndefined(); + }); +}); \ No newline at end of file diff --git a/apps/web/src/lib/validation/index.ts b/apps/web/src/lib/validation/index.ts new file mode 100644 index 00000000..6e52b5f9 --- /dev/null +++ b/apps/web/src/lib/validation/index.ts @@ -0,0 +1,2 @@ +export * from './validators'; +export { useValidation } from './useValidation'; \ No newline at end of file diff --git a/apps/web/src/lib/validation/useValidation.ts b/apps/web/src/lib/validation/useValidation.ts new file mode 100644 index 00000000..7304d6ef --- /dev/null +++ b/apps/web/src/lib/validation/useValidation.ts @@ -0,0 +1,172 @@ +import { useState, useCallback, useRef, useEffect } from 'react'; +import type { ValidationResult, Validator } from './validators'; + +interface UseValidationOptions { + /** Validator function to apply */ + validator: Validator; + /** Initial value */ + initialValue?: string; + /** Debounce delay in ms (default: 300) */ + debounceMs?: number; + /** Validate on blur immediately (default: true) */ + validateOnBlur?: boolean; + /** Validate on change (default: true) */ + validateOnChange?: boolean; + /** Sanitize input before validation (default: true) */ + sanitize?: boolean; +} + +interface UseValidationReturn { + /** Current input value */ + value: string; + /** Set value programmatically */ + setValue: (value: string) => void; + /** Validation result */ + validation: ValidationResult; + /** Whether the field has been touched (blurred) */ + isTouched: boolean; + /** Whether the field is currently focused */ + isFocused: boolean; + /** Event handlers to bind to input */ + bind: { + value: string; + onChange: (e: React.ChangeEvent) => void; + onBlur: () => void; + onFocus: () => void; + }; + /** Reset to initial state */ + reset: () => void; + /** Force validation */ + validate: () => ValidationResult; + /** Whether to show error (touched and invalid) */ + showError: boolean; +} + +export function useValidation(options: UseValidationOptions): UseValidationReturn { + const { + validator, + initialValue = '', + debounceMs = 300, + validateOnBlur = true, + validateOnChange = true, + sanitize = true, + } = options; + + const [value, setValueState] = useState(initialValue); + const [validation, setValidation] = useState({ isValid: true }); + const [isTouched, setIsTouched] = useState(false); + const [isFocused, setIsFocused] = useState(false); + const debounceTimer = useRef | null>(null); + + // Sanitize helper + const sanitizeValue = useCallback((input: string): string => { + if (!sanitize) return input; + // Basic sanitization - remove script tags and dangerous patterns + return input + .replace(/]*>[\s\S]*?<\/script>/gi, '') + .replace(/javascript:/gi, '') + .replace(/on\w+\s*=/gi, ''); + }, [sanitize]); + + // Validate immediately + const validate = useCallback( + (inputValue: string): ValidationResult => { + const sanitized = sanitizeValue(inputValue); + const result = validator(sanitized); + setValidation(result); + return result; + }, + [validator, sanitizeValue] + ); + + // Debounced validation + const debouncedValidate = useCallback( + (inputValue: string) => { + if (debounceTimer.current) { + clearTimeout(debounceTimer.current); + } + + debounceTimer.current = setTimeout(() => { + validate(inputValue); + }, debounceMs); + }, + [validate, debounceMs] + ); + + // Handle change + const handleChange = useCallback( + (e: React.ChangeEvent) => { + const newValue = e.target.value; + setValueState(newValue); + + if (validateOnChange) { + debouncedValidate(newValue); + } + }, + [validateOnChange, debouncedValidate] + ); + + // Handle blur + const handleBlur = useCallback(() => { + setIsTouched(true); + setIsFocused(false); + + if (validateOnBlur) { + // Immediate validation on blur (no debounce) + validate(value); + } + }, [validateOnBlur, validate, value]); + + // Handle focus + const handleFocus = useCallback(() => { + setIsFocused(true); + }, []); + + // Set value programmatically + const setValue = useCallback( + (newValue: string) => { + setValueState(newValue); + validate(newValue); + }, + [validate] + ); + + // Reset + const reset = useCallback(() => { + setValueState(initialValue); + setValidation({ isValid: true }); + setIsTouched(false); + setIsFocused(false); + if (debounceTimer.current) { + clearTimeout(debounceTimer.current); + } + }, [initialValue]); + + // Cleanup on unmount + useEffect(() => { + return () => { + if (debounceTimer.current) { + clearTimeout(debounceTimer.current); + } + }; + }, []); + + const showError = isTouched && !validation.isValid && !isFocused; + + return { + value, + setValue, + validation, + isTouched, + isFocused, + bind: { + value, + onChange: handleChange, + onBlur: handleBlur, + onFocus: handleFocus, + }, + reset, + validate: () => validate(value), + showError, + }; +} \ No newline at end of file diff --git a/apps/web/src/lib/validation/validators.ts b/apps/web/src/lib/validation/validators.ts new file mode 100644 index 00000000..1c713e8f --- /dev/null +++ b/apps/web/src/lib/validation/validators.ts @@ -0,0 +1,580 @@ +export interface ValidationResult { + isValid: boolean; + error?: string; +} + +export type Validator = (value: T) => ValidationResult; + +// ============================================================================ +// Stellar / Blockchain Validators +// ============================================================================ + +/** + * Validates a Stellar public key (G... format) + * Stellar addresses are 56-character base32 strings starting with 'G' + */ +export const isValidStellarAddress: Validator = (address) => { + if (!address || typeof address !== 'string') { + return { isValid: false, error: 'Wallet address is required' }; + } + + const trimmed = address.trim(); + + if (trimmed.length === 0) { + return { isValid: false, error: 'Wallet address is required' }; + } + + // Stellar public key: starts with G, 56 chars, alphanumeric only + const STELLAR_PUBKEY_REGEX = /^G[A-Z0-9]{55}$/; + if (!STELLAR_PUBKEY_REGEX.test(trimmed)) { + return { + isValid: false, + error: 'Invalid Stellar address. Must start with G and be 56 characters long.', + }; + } + + // Additional checksum validation could go here using StrKey.decode + return { isValid: true }; +}; + +/** + * Validates a Stellar contract ID (C... format) + * Soroban contract IDs are 56-character base32 strings starting with 'C' + */ +export const isValidContractId: Validator = (contractId) => { + if (!contractId || typeof contractId !== 'string') { + return { isValid: false, error: 'Contract ID is required' }; + } + + const trimmed = contractId.trim(); + + if (trimmed.length === 0) { + return { isValid: false, error: 'Contract ID is required' }; + } + + const CONTRACT_ID_REGEX = /^C[A-Z0-9]{55}$/; + if (!CONTRACT_ID_REGEX.test(trimmed)) { + return { + isValid: false, + error: 'Invalid contract ID. Must start with C and be 56 characters long.', + }; + } + + return { isValid: true }; +}; + +/** + * Validates a Stellar secret key (S... format) + * WARNING: Only use for client-side formatting checks. Never log or transmit. + */ +export const isValidSecretKey: Validator = (secretKey) => { + if (!secretKey || typeof secretKey !== 'string') { + return { isValid: false, error: 'Secret key is required' }; + } + + const trimmed = secretKey.trim(); + + const SECRET_KEY_REGEX = /^S[A-Z0-9]{55}$/; + if (!SECRET_KEY_REGEX.test(trimmed)) { + return { + isValid: false, + error: 'Invalid secret key format.', + }; + } + + return { isValid: true }; +}; + +// ============================================================================ +// Amount / Numeric Validators +// ============================================================================ + +export interface AmountValidationOptions { + min?: number; + max?: number; + decimals?: number; + allowZero?: boolean; + currency?: string; +} + +/** + * Validates a monetary amount string + */ +export const isValidAmount = ( + amount: string, + options: AmountValidationOptions = {} +): ValidationResult => { + const { + min = 0, + max = Number.MAX_SAFE_INTEGER, + decimals = 7, + allowZero = false, + currency = 'USDC', + } = options; + + if (!amount || typeof amount !== 'string') { + return { isValid: false, error: `Amount is required` }; + } + + const trimmed = amount.trim(); + + if (trimmed.length === 0) { + return { isValid: false, error: `Amount is required` }; + } + + // Check for valid numeric format (allows decimals, no leading zeros unless 0.x) + const NUMERIC_REGEX = /^(0|[1-9]\d*)(\.\d+)?$/; + if (!NUMERIC_REGEX.test(trimmed)) { + return { + isValid: false, + error: `Invalid amount format. Use numbers only (e.g., 100.50)`, + }; + } + + const num = parseFloat(trimmed); + + if (isNaN(num)) { + return { isValid: false, error: `Invalid amount` }; + } + + // Check decimal places + const decimalParts = trimmed.split('.'); + if (decimalParts[1] && decimalParts[1].length > decimals) { + return { + isValid: false, + error: `Amount cannot have more than ${decimals} decimal places`, + }; + } + + // Check zero + if (!allowZero && num === 0) { + return { + isValid: false, + error: `Amount must be greater than 0 ${currency}`, + }; + } + + // Check negative (should be caught by regex, but defensive) + if (num < 0) { + return { + isValid: false, + error: `Amount cannot be negative`, + }; + } + + // Check minimum + if (num < min) { + return { + isValid: false, + error: `Minimum amount is ${min} ${currency}`, + }; + } + + // Check maximum + if (num > max) { + return { + isValid: false, + error: `Maximum amount is ${max} ${currency}`, + }; + } + + return { isValid: true }; +}; + +/** + * Validates an interest rate percentage + */ +export const isValidInterestRate: Validator = (rate) => { + const result = isValidAmount(rate, { + min: 0, + max: 100, + decimals: 2, + allowZero: true, + currency: '%', + }); + + if (!result.isValid) { + // Override currency in error message + return { + ...result, + error: result.error?.replace('USDC', '%'), + }; + } + + return result; +}; + +/** + * Validates a savings duration in days + */ +export const isValidDuration: Validator = (days) => { + return isValidAmount(days, { + min: 1, + max: 3650, // ~10 years + decimals: 0, + allowZero: false, + currency: 'days', + }); +}; + +// ============================================================================ +// String Validators +// ============================================================================ + +/** + * Validates an email address + */ +export const isValidEmail: Validator = (email) => { + if (!email || typeof email !== 'string') { + return { isValid: false, error: 'Email is required' }; + } + + const trimmed = email.trim().toLowerCase(); + + if (trimmed.length === 0) { + return { isValid: false, error: 'Email is required' }; + } + + // RFC 5322 compliant simplified regex + const EMAIL_REGEX = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/; + + if (!EMAIL_REGEX.test(trimmed)) { + return { isValid: false, error: 'Please enter a valid email address' }; + } + + // Check length + if (trimmed.length > 254) { + return { isValid: false, error: 'Email is too long' }; + } + + return { isValid: true }; +}; + +/** + * Validates a user's display name + */ +export const isValidDisplayName: Validator = (name) => { + if (!name || typeof name !== 'string') { + return { isValid: false, error: 'Name is required' }; + } + + const trimmed = name.trim(); + + if (trimmed.length === 0) { + return { isValid: false, error: 'Name is required' }; + } + + if (trimmed.length < 2) { + return { isValid: false, error: 'Name must be at least 2 characters' }; + } + + if (trimmed.length > 50) { + return { isValid: false, error: 'Name must be less than 50 characters' }; + } + + // Allow letters, numbers, spaces, hyphens, apostrophes + const NAME_REGEX = /^[a-zA-Z0-9\s\-'.]+$/; + if (!NAME_REGEX.test(trimmed)) { + return { + isValid: false, + error: 'Name can only contain letters, numbers, spaces, hyphens, and apostrophes', + }; + } + + return { isValid: true }; +}; + +/** + * Validates a savings goal name + */ +export const isValidGoalName: Validator = (name) => { + if (!name || typeof name !== 'string') { + return { isValid: false, error: 'Goal name is required' }; + } + + const trimmed = name.trim(); + + if (trimmed.length === 0) { + return { isValid: false, error: 'Goal name is required' }; + } + + if (trimmed.length < 3) { + return { isValid: false, error: 'Goal name must be at least 3 characters' }; + } + + if (trimmed.length > 100) { + return { isValid: false, error: 'Goal name must be less than 100 characters' }; + } + + return { isValid: true }; +}; + +/** + * Validates a description/text field + */ +export const isValidDescription: Validator = (text) => { + if (!text || typeof text !== 'string') { + return { isValid: false, error: 'Description is required' }; + } + + const trimmed = text.trim(); + + if (trimmed.length === 0) { + return { isValid: false, error: 'Description is required' }; + } + + if (trimmed.length > 1000) { + return { isValid: false, error: 'Description must be less than 1000 characters' }; + } + + return { isValid: true }; +}; + +// ============================================================================ +// Security / XSS Prevention +// ============================================================================ + +/** + * Sanitizes user input to prevent XSS attacks + * Removes script tags, event handlers, and dangerous HTML + */ +export const sanitizeInput = (input: string): string => { + if (!input || typeof input !== 'string') { + return ''; + } + + return ( + input + // Remove script tags and contents + .replace(/]*>[\s\S]*?<\/script>/gi, '') + // Remove event handlers + .replace(/\s*on\w+\s*=\s*["'][^"']*["']/gi, '') + // Remove javascript: and data: URLs + .replace(/(javascript|data):/gi, '') + // Remove iframe, object, embed tags + .replace(/<(iframe|object|embed)[^>]*>[\s\S]*?<\/\1>/gi, '') + // Remove style tags + .replace(/]*>[\s\S]*?<\/style>/gi, '') + // Remove remaining HTML tags (optional - uncomment if plain text only) + // .replace(/<[^>]+>/g, '') + // Trim + .trim() + ); +}; + +/** + * Validates that input does not contain XSS payloads + */ +export const isXSSSafe: Validator = (input) => { + if (!input || typeof input !== 'string') { + return { isValid: true }; // Empty is safe + } + + const XSS_PATTERNS = [ + /)<[^<]*)*<\/script>/i, + /javascript:/i, + /on\w+\s*=/i, + /