From 9f680203761424a41883e0975a19f2483bae18bc Mon Sep 17 00:00:00 2001 From: Abdulmalik Ojo Date: Sat, 27 Jun 2026 11:20:44 +0000 Subject: [PATCH] feat(dashboard): [dashboard] standardize form validation in sep-24 interactive pages (closes #586) --- dashboard/src/App.tsx | 2 + dashboard/src/components/WithdrawalForm.tsx | 49 +---- dashboard/src/lib/validation.test.ts | 193 +++++++++++++++++++ dashboard/src/lib/validation.ts | 194 ++++++++++++++++++++ 4 files changed, 390 insertions(+), 48 deletions(-) create mode 100644 dashboard/src/lib/validation.test.ts create mode 100644 dashboard/src/lib/validation.ts diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx index 4679ae02..302f63d5 100644 --- a/dashboard/src/App.tsx +++ b/dashboard/src/App.tsx @@ -43,7 +43,9 @@ const defaultUiConfig: UiConfig = { { key: 'amount', label: 'Amount', required: true, placeholder: '500.00' }, ], withdraw: [ + { key: 'iban', label: 'IBAN', required: true, placeholder: 'DE89370400440532013000' }, { key: 'bankAccount', label: 'Bank Account', required: true, placeholder: 'Account number' }, + { key: 'beneficiaryAddress', label: 'Beneficiary Address', required: true, placeholder: 'Street, city, postal code' }, { key: 'amount', label: 'Amount', required: true, placeholder: '120.50' }, ], kyc: [ diff --git a/dashboard/src/components/WithdrawalForm.tsx b/dashboard/src/components/WithdrawalForm.tsx index 47dbe499..33b0ef36 100644 --- a/dashboard/src/components/WithdrawalForm.tsx +++ b/dashboard/src/components/WithdrawalForm.tsx @@ -2,6 +2,7 @@ import { useState, useId } from 'react'; import type { FormEvent } from 'react'; import { AlertCircle, CheckCircle2 } from 'lucide-react'; import type { FieldRequirement } from '../types'; +import { validateField, validateAll } from '../lib/validation'; interface FormValues { [key: string]: string; @@ -18,60 +19,12 @@ interface WithdrawalFormProps { onSubmit: (values: FormValues) => void; } -const AMOUNT_PATTERN = /^\d+(\.\d{1,2})?$/; -const BANK_ACCOUNT_PATTERN = /^\d{6,20}$/; -const WALLET_PATTERN = /^G[A-Z0-9]{55}$/; - const getFieldType = (key: string): React.HTMLInputTypeAttribute => { if (key.toLowerCase().includes('amount')) return 'number'; if (key.toLowerCase().includes('email')) return 'email'; return 'text'; }; -const validateField = (field: FieldRequirement, value: string): string => { - const trimmed = value.trim(); - - if (field.required && !trimmed) { - return `${field.label} is required.`; - } - - if (!trimmed) return ''; - - const key = field.key.toLowerCase(); - - if (key.includes('amount')) { - if (!AMOUNT_PATTERN.test(trimmed)) { - return 'Enter a valid amount (e.g. 120.50).'; - } - const num = parseFloat(trimmed); - if (num <= 0) return 'Amount must be greater than zero.'; - if (num > 1_000_000) return 'Amount exceeds the maximum single-transaction limit.'; - } - - if (key.includes('bankaccount') || key.includes('bank_account') || key.includes('account')) { - if (!BANK_ACCOUNT_PATTERN.test(trimmed)) { - return 'Bank account must be 6–20 digits.'; - } - } - - if (key.includes('wallet') || key.includes('address')) { - if (!WALLET_PATTERN.test(trimmed)) { - return 'Enter a valid Stellar wallet address starting with G.'; - } - } - - return ''; -}; - -const validateAll = (fields: FieldRequirement[], values: FormValues): FieldError => { - const errors: FieldError = {}; - for (const field of fields) { - const err = validateField(field, values[field.key] ?? ''); - if (err) errors[field.key] = err; - } - return errors; -}; - export const WithdrawalForm = ({ fields, onSubmit }: WithdrawalFormProps) => { const formId = useId(); const [values, setValues] = useState(() => diff --git a/dashboard/src/lib/validation.test.ts b/dashboard/src/lib/validation.test.ts new file mode 100644 index 00000000..d24831bd --- /dev/null +++ b/dashboard/src/lib/validation.test.ts @@ -0,0 +1,193 @@ +import { describe, it, expect } from 'vitest'; +import { + validateIBAN, + validateAmount, + validateBankAccount, + validateStellarAddress, + validateGenericAddress, + validateEmail, + validateLength, + validateField, + validateAll, +} from './validation'; + +describe('validateIBAN', () => { + it('accepts a valid German IBAN', () => { + expect(validateIBAN('DE89370400440532013000')).toBeNull(); + }); + + it('accepts an IBAN with spaces', () => { + expect(validateIBAN('DE89 3704 0044 0532 0130 00')).toBeNull(); + }); + + it('accepts a valid French IBAN', () => { + expect(validateIBAN('FR1420041010050500013M02606')).toBeNull(); + }); + + it('accepts a valid UK IBAN', () => { + expect(validateIBAN('GB29NWBK60161331926819')).toBeNull(); + }); + + it('rejects an IBAN with wrong country length', () => { + const result = validateIBAN('DE123456'); + expect(result).toMatch(/IBAN for DE must be exactly/); + }); + + it('rejects an IBAN with invalid check digits', () => { + const result = validateIBAN('DE00370400440532013000'); + expect(result).toBe('IBAN check digits are invalid.'); + }); + + it('rejects a malformed IBAN', () => { + expect(validateIBAN('12345')).toMatch(/must start with a 2-letter country code/); + }); + + it('rejects an empty string', () => { + expect(validateIBAN('')).toBeNull(); + }); +}); + +describe('validateAmount', () => { + it('accepts a valid amount', () => { + expect(validateAmount('120.50')).toBeNull(); + }); + + it('accepts a whole number', () => { + expect(validateAmount('500')).toBeNull(); + }); + + it('rejects negative amount', () => { + expect(validateAmount('-10')).toMatch(/Enter a valid amount/); + }); + + it('rejects zero', () => { + expect(validateAmount('0')).toBe('Amount must be greater than zero.'); + }); + + it('rejects over limit', () => { + expect(validateAmount('9999999')).toBe('Amount exceeds the maximum single-transaction limit.'); + }); + + it('rejects non-numeric input', () => { + expect(validateAmount('abc')).toMatch(/Enter a valid amount/); + }); +}); + +describe('validateBankAccount', () => { + it('accepts a valid bank account', () => { + expect(validateBankAccount('123456789')).toBeNull(); + }); + + it('rejects too short', () => { + expect(validateBankAccount('12345')).toMatch(/Bank account must be 6–20 digits/); + }); + + it('rejects letters', () => { + expect(validateBankAccount('abcdef')).toMatch(/6–20 digits/); + }); +}); + +describe('validateStellarAddress', () => { + it('accepts a valid Stellar address', () => { + expect(validateStellarAddress('GBD6D6J42WQ2G2Z7ZXM4H74J7ZQZQZQZQZQZQZQZQZQZQZQZQZQZQZQZ')).toBeNull(); + }); + + it('rejects a short address', () => { + expect(validateStellarAddress('GABC')).toMatch(/Enter a valid Stellar wallet address/); + }); +}); + +describe('validateGenericAddress', () => { + it('accepts a valid address', () => { + expect(validateGenericAddress('123 Main St, Springfield')).toBeNull(); + }); + + it('rejects too short', () => { + expect(validateGenericAddress('AB')).toMatch(/at least 5 characters/); + }); + + it('rejects numeric-only input', () => { + expect(validateGenericAddress('12345')).toMatch(/must contain letters/); + }); +}); + +describe('validateEmail', () => { + it('accepts a valid email', () => { + expect(validateEmail('test@example.com')).toBeNull(); + }); + + it('rejects invalid email', () => { + expect(validateEmail('not-an-email')).toMatch(/Enter a valid email address/); + }); +}); + +describe('validateLength', () => { + it('accepts a value within range', () => { + expect(validateLength('hello', 1, 10)).toBeNull(); + }); + + it('rejects too short', () => { + expect(validateLength('ab', 3)).toMatch(/at least 3 characters/); + }); + + it('rejects too long', () => { + expect(validateLength('hello world', undefined, 5)).toMatch(/not exceed 5 characters/); + }); +}); + +describe('validateField', () => { + const field = (key: string, required = true) => ({ key, label: 'Test', required }); + + it('returns required error for empty required field', () => { + expect(validateField(field('name'), '')).toBe('Test is required.'); + }); + + it('returns empty for optional empty field', () => { + expect(validateField(field('name', false), '')).toBe(''); + }); + + it('routes IBAN fields correctly', () => { + const result = validateField(field('iban'), 'DE89370400440532013000'); + expect(result).toBe(''); + }); + + it('routes amount fields correctly', () => { + expect(validateField(field('amount'), 'abc')).toMatch(/Enter a valid amount/); + }); + + it('routes bank account fields correctly', () => { + expect(validateField(field('bank_account'), '12')).toMatch(/6–20 digits/); + }); + + it('routes wallet address fields correctly', () => { + expect(validateField(field('wallet_address'), 'abc')).toMatch(/Enter a valid Stellar wallet address/); + }); + + it('routes generic address fields correctly', () => { + expect(validateField(field('beneficiaryAddress'), 'AB')).toMatch(/at least 5 characters/); + }); + + it('routes email fields correctly', () => { + expect(validateField(field('email'), 'bad')).toMatch(/Enter a valid email address/); + }); +}); + +describe('validateAll', () => { + it('returns errors for all invalid fields', () => { + const fields = [ + { key: 'amount', label: 'Amount', required: true }, + { key: 'iban', label: 'IBAN', required: true }, + ]; + const values = { amount: '-5', iban: 'DE00' }; + const errors = validateAll(fields, values); + expect(Object.keys(errors).length).toBeGreaterThan(0); + }); + + it('returns empty when all fields are valid', () => { + const fields = [ + { key: 'iban', label: 'IBAN', required: true }, + ]; + const values = { iban: 'DE89370400440532013000' }; + expect(validateAll(fields, values)).toEqual({}); + }); +}); diff --git a/dashboard/src/lib/validation.ts b/dashboard/src/lib/validation.ts new file mode 100644 index 00000000..0abc1166 --- /dev/null +++ b/dashboard/src/lib/validation.ts @@ -0,0 +1,194 @@ +export const PATTERNS = { + AMOUNT: /^\d+(\.\d{1,2})?$/, + BANK_ACCOUNT: /^\d{6,20}$/, + STELLAR_WALLET: /^G[A-Z0-9]{55}$/, + IBAN_FORMAT: /^[A-Z]{2}\d{2}[A-Z0-9]{1,30}$/, + EMAIL: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, +} as const; + +const IBAN_COUNTRY_LENGTHS: Record = { + AD: 24, AE: 23, AL: 28, AT: 20, AZ: 28, BA: 20, BE: 16, BG: 22, + BH: 22, BR: 29, CH: 21, CR: 22, CY: 28, CZ: 24, DE: 22, DK: 18, + DO: 28, EE: 20, ES: 24, FI: 18, FO: 18, FR: 27, GB: 22, GE: 22, + GI: 23, GL: 18, GR: 27, GT: 28, HR: 21, HU: 28, IE: 22, IL: 23, + IS: 26, IT: 27, JO: 30, KW: 30, KZ: 20, LB: 28, LI: 21, LT: 20, + LU: 20, LV: 21, MC: 27, MD: 24, ME: 22, MK: 19, MR: 27, MT: 31, + MU: 30, NL: 18, NO: 15, PK: 24, PL: 28, PS: 29, PT: 25, RO: 24, + RS: 22, SA: 24, SE: 24, SI: 19, SK: 24, SM: 27, TL: 23, TN: 24, + TR: 26, UA: 29, VA: 22, VG: 24, XK: 20, +}; + +function mod97(value: string): number { + let remainder = 0; + for (let i = 0; i < value.length; i++) { + remainder = (remainder * 10 + parseInt(value[i], 10)) % 97; + } + return remainder; +} + +export function validateIBAN(value: string): string | null { + const trimmed = value.trim(); + if (!trimmed) return null; + + const cleaned = trimmed.replace(/\s/g, '').toUpperCase(); + + if (!PATTERNS.IBAN_FORMAT.test(cleaned)) { + return 'IBAN must start with a 2-letter country code followed by 2 check digits.'; + } + + const countryCode = cleaned.slice(0, 2); + const expectedLength = IBAN_COUNTRY_LENGTHS[countryCode]; + if (expectedLength && cleaned.length !== expectedLength) { + return `IBAN for ${countryCode} must be exactly ${expectedLength} characters (got ${cleaned.length}).`; + } + + if (cleaned.length < 8 || cleaned.length > 34) { + return 'IBAN must be between 8 and 34 characters.'; + } + + const reordered = cleaned.slice(4) + cleaned.slice(0, 4); + const digits = reordered.split('').map((ch) => + /\d/.test(ch) ? ch : String(ch.charCodeAt(0) - 55), + ).join(''); + + if (mod97(digits) !== 1) { + return 'IBAN check digits are invalid.'; + } + + return null; +} + +export function validateAmount(value: string): string | null { + const trimmed = value.trim(); + + if (!trimmed) return null; + + if (!PATTERNS.AMOUNT.test(trimmed)) { + return 'Enter a valid amount (e.g. 120.50).'; + } + + const num = parseFloat(trimmed); + if (num <= 0) return 'Amount must be greater than zero.'; + if (num > 1_000_000) return 'Amount exceeds the maximum single-transaction limit.'; + + return null; +} + +export function validateBankAccount(value: string): string | null { + const trimmed = value.trim(); + if (!trimmed) return null; + + if (!PATTERNS.BANK_ACCOUNT.test(trimmed)) { + return 'Bank account must be 6–20 digits.'; + } + + return null; +} + +export function validateStellarAddress(value: string): string | null { + const trimmed = value.trim(); + if (!trimmed) return null; + + if (!PATTERNS.STELLAR_WALLET.test(trimmed)) { + return 'Enter a valid Stellar wallet address starting with G (56 characters).'; + } + + return null; +} + +export function validateGenericAddress(value: string): string | null { + const trimmed = value.trim(); + if (!trimmed) return null; + + if (trimmed.length < 5) { + return 'Address must be at least 5 characters.'; + } + if (trimmed.length > 200) { + return 'Address must not exceed 200 characters.'; + } + if (/^[0-9\s]+$/.test(trimmed)) { + return 'Address must contain letters.'; + } + + return null; +} + +export function validateLength(value: string, min?: number, max?: number): string | null { + const trimmed = value.trim(); + if (!trimmed) return null; + + const len = trimmed.length; + if (min !== undefined && len < min) { + return `Must be at least ${min} characters (${len} current).`; + } + if (max !== undefined && len > max) { + return `Must not exceed ${max} characters (${len} current).`; + } + + return null; +} + +export function validateEmail(value: string): string | null { + const trimmed = value.trim(); + if (!trimmed) return null; + + if (!PATTERNS.EMAIL.test(trimmed)) { + return 'Enter a valid email address.'; + } + + if (trimmed.length > 254) { + return 'Email must not exceed 254 characters.'; + } + + return null; +} + +export function validateField( + field: { key: string; label: string; required: boolean }, + value: string, +): string { + const trimmed = value.trim(); + + if (field.required && !trimmed) { + return `${field.label} is required.`; + } + + if (!trimmed) return ''; + + const key = field.key.toLowerCase(); + + let error: string | null = null; + + if (key.includes('iban')) { + error = validateIBAN(value); + } else if (key.includes('amount')) { + error = validateAmount(value); + } else if (key.includes('bankaccount') || key.includes('bank_account') || key === 'account') { + error = validateBankAccount(value); + } else if (key.includes('wallet') || (key.includes('address') && key.includes('stellar'))) { + error = validateStellarAddress(value); + } else if (key.includes('address')) { + error = validateGenericAddress(value); + } else if (key.includes('email')) { + error = validateEmail(value); + } + + if (error) return error; + + const lengthError = validateLength(value, 1); + if (lengthError) return lengthError; + + return ''; +} + +export function validateAll( + fields: { key: string; label: string; required: boolean }[], + values: Record, +): Record { + const errors: Record = {}; + for (const field of fields) { + const err = validateField(field, values[field.key] ?? ''); + if (err) errors[field.key] = err; + } + return errors; +}