Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions dashboard/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down
49 changes: 1 addition & 48 deletions dashboard/src/components/WithdrawalForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<FormValues>(() =>
Expand Down
193 changes: 193 additions & 0 deletions dashboard/src/lib/validation.test.ts
Original file line number Diff line number Diff line change
@@ -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({});
});
});
Loading