UNPKG

@frank-auth/react

Version:

Flexible and customizable React UI components for Frank Authentication

600 lines (495 loc) 17 kB
// Validation result interface export interface ValidationResult { isValid: boolean; error?: string; errors?: string[]; } // Validation rule interface export type ValidationRule = (value: any) => ValidationResult | Promise<ValidationResult> // Common validation rules export const required = (message = 'This field is required'): ValidationRule => { return (value: any): ValidationResult => { const isEmpty = value === null || value === undefined || (typeof value === 'string' && value.trim() === '') || (Array.isArray(value) && value.length === 0); return { isValid: !isEmpty, error: isEmpty ? message : undefined, }; }; }; export const minLength = (min: number, message?: string): ValidationRule => { return (value: string): ValidationResult => { const actualMessage = message || `Must be at least ${min} characters`; const isValid = typeof value === 'string' && value.length >= min; return { isValid, error: isValid ? undefined : actualMessage, }; }; }; export const maxLength = (max: number, message?: string): ValidationRule => { return (value: string): ValidationResult => { const actualMessage = message || `Must be no more than ${max} characters`; const isValid = typeof value === 'string' && value.length <= max; return { isValid, error: isValid ? undefined : actualMessage, }; }; }; export const pattern = (regex: RegExp, message = 'Invalid format'): ValidationRule => { return (value: string): ValidationResult => { const isValid = typeof value === 'string' && regex.test(value); return { isValid, error: isValid ? undefined : message, }; }; }; export const email = (message = 'Invalid email address'): ValidationRule => { const emailRegex = /^[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])?)*$/; return (value: string): ValidationResult => { if (!value) return { isValid: true }; // Allow empty for optional fields const isValid = emailRegex.test(value); return { isValid, error: isValid ? undefined : message, }; }; }; export const phone = (message = 'Invalid phone number'): ValidationRule => { // Basic international phone number regex const phoneRegex = /^\+?[1-9]\d{1,14}$/; return (value: string): ValidationResult => { if (!value) return { isValid: true }; // Allow empty for optional fields // Remove all non-digit characters except + const cleaned = value.replace(/[^\d+]/g, ''); const isValid = phoneRegex.test(cleaned); return { isValid, error: isValid ? undefined : message, }; }; }; export const username = (message = 'Username must be 3-30 characters and contain only letters, numbers, and underscores'): ValidationRule => { const usernameRegex = /^[a-zA-Z0-9_]{3,30}$/; return (value: string): ValidationResult => { if (!value) return { isValid: true }; // Allow empty for optional fields const isValid = usernameRegex.test(value); return { isValid, error: isValid ? undefined : message, }; }; }; export const password = (options: { minLength?: number; requireUppercase?: boolean; requireLowercase?: boolean; requireNumbers?: boolean; requireSymbols?: boolean; message?: string; } = {}): ValidationRule => { const { minLength: min = 8, requireUppercase = true, requireLowercase = true, requireNumbers = true, requireSymbols = false, message, } = options; return (value: string): ValidationResult => { if (!value) return { isValid: true }; // Allow empty for optional fields const errors: string[] = []; if (value.length < min) { errors.push(`Must be at least ${min} characters long`); } if (requireUppercase && !/[A-Z]/.test(value)) { errors.push('Must contain at least one uppercase letter'); } if (requireLowercase && !/[a-z]/.test(value)) { errors.push('Must contain at least one lowercase letter'); } if (requireNumbers && !/\d/.test(value)) { errors.push('Must contain at least one number'); } if (requireSymbols && !/[^A-Za-z0-9]/.test(value)) { errors.push('Must contain at least one special character'); } const isValid = errors.length === 0; return { isValid, error: message || (isValid ? undefined : errors[0]), errors: errors.length > 0 ? errors : undefined, }; }; }; export const confirmPassword = (originalPassword: string, message = 'Passwords do not match'): ValidationRule => { return (value: string): ValidationResult => { const isValid = value === originalPassword; return { isValid, error: isValid ? undefined : message, }; }; }; export const url = (message = 'Invalid URL'): ValidationRule => { return (value: string): ValidationResult => { if (!value) return { isValid: true }; // Allow empty for optional fields try { new URL(value); return { isValid: true }; } catch { return { isValid: false, error: message, }; } }; }; export const number = (options: { min?: number; max?: number; integer?: boolean; message?: string; } = {}): ValidationRule => { const { min, max, integer = false, message } = options; return (value: any): ValidationResult => { if (value === null || value === undefined || value === '') { return { isValid: true }; // Allow empty for optional fields } const num = Number(value); if (isNaN(num)) { return { isValid: false, error: message || 'Must be a valid number', }; } if (integer && !Number.isInteger(num)) { return { isValid: false, error: message || 'Must be a whole number', }; } if (min !== undefined && num < min) { return { isValid: false, error: message || `Must be at least ${min}`, }; } if (max !== undefined && num > max) { return { isValid: false, error: message || `Must be no more than ${max}`, }; } return { isValid: true }; }; }; export const oneOf = (options: any[], message?: string): ValidationRule => { return (value: any): ValidationResult => { if (!value) return { isValid: true }; // Allow empty for optional fields const isValid = options.includes(value); const actualMessage = message || `Must be one of: ${options.join(', ')}`; return { isValid, error: isValid ? undefined : actualMessage, }; }; }; export const custom = (validator: (value: any) => boolean | string, message = 'Invalid value'): ValidationRule => { return (value: any): ValidationResult => { const result = validator(value); if (typeof result === 'boolean') { return { isValid: result, error: result ? undefined : message, }; } // If validator returns a string, it's an error message return { isValid: false, error: result, }; }; }; export const asyncValidation = (validator: (value: any) => Promise<boolean | string>, message = 'Invalid value'): ValidationRule => { return async (value: any): Promise<ValidationResult> => { try { const result = await validator(value); if (typeof result === 'boolean') { return { isValid: result, error: result ? undefined : message, }; } // If validator returns a string, it's an error message return { isValid: false, error: result, }; } catch (error) { return { isValid: false, error: error instanceof Error ? error.message : 'Validation failed', }; } }; }; // Combine multiple validation rules export const combine = (...rules: ValidationRule[]): ValidationRule => { return async (value: any): Promise<ValidationResult> => { for (const rule of rules) { const result = await rule(value); if (!result.isValid) { return result; } } return { isValid: true }; }; }; // Form validation utilities export interface FormValidationRules { [fieldName: string]: ValidationRule | ValidationRule[]; } export interface FormValidationResult { isValid: boolean; errors: Record<string, string>; fieldErrors: Record<string, string[]>; } export const validateForm = async ( data: Record<string, any>, rules: FormValidationRules ): Promise<FormValidationResult> => { const errors: Record<string, string> = {}; const fieldErrors: Record<string, string[]> = {}; for (const [fieldName, fieldRules] of Object.entries(rules)) { const value = data[fieldName]; const rulesArray = Array.isArray(fieldRules) ? fieldRules : [fieldRules]; for (const rule of rulesArray) { const result = await rule(value); if (!result.isValid) { errors[fieldName] = result.error || 'Invalid value'; if (result.errors) { fieldErrors[fieldName] = result.errors; } else if (result.error) { fieldErrors[fieldName] = [result.error]; } break; // Stop at first error for this field } } } return { isValid: Object.keys(errors).length === 0, errors, fieldErrors, }; }; export const validateField = async ( value: any, rules: ValidationRule | ValidationRule[] ): Promise<ValidationResult> => { const rulesArray = Array.isArray(rules) ? rules : [rules]; for (const rule of rulesArray) { const result = await rule(value); if (!result.isValid) { return result; } } return { isValid: true }; }; // Specific validation functions for auth forms export const validateSignInForm = async (data: { identifier: string; password?: string; }): Promise<FormValidationResult> => { const rules: FormValidationRules = { identifier: [ required('Email or username is required'), // Could be email or username, so we don't validate format here ], }; if (data.password !== undefined) { rules.password = required('Password is required'); } return validateForm(data, rules); }; export const validateSignUpForm = async (data: { emailAddress?: string; username?: string; password?: string; confirmPassword?: string; firstName?: string; lastName?: string; phoneNumber?: string; }): Promise<FormValidationResult> => { const rules: FormValidationRules = {}; if (data.emailAddress !== undefined) { rules.emailAddress = [required('Email is required'), email()]; } if (data.username !== undefined) { rules.username = [required('Username is required'), username()]; } if (data.password !== undefined) { rules.password = [required('Password is required'), password()]; } if (data.confirmPassword !== undefined) { rules.confirmPassword = [ required('Please confirm your password'), confirmPassword(data.password || ''), ]; } if (data.firstName !== undefined) { rules.firstName = [ required('First name is required'), minLength(1), maxLength(50), ]; } if (data.lastName !== undefined) { rules.lastName = [ required('Last name is required'), minLength(1), maxLength(50), ]; } if (data.phoneNumber !== undefined) { rules.phoneNumber = phone(); } return validateForm(data, rules); }; export const validatePasswordResetForm = async (data: { emailAddress: string; }): Promise<FormValidationResult> => { const rules: FormValidationRules = { emailAddress: [required('Email is required'), email()], }; return validateForm(data, rules); }; export const validatePasswordChangeForm = async (data: { currentPassword: string; newPassword: string; confirmPassword: string; }): Promise<FormValidationResult> => { const rules: FormValidationRules = { currentPassword: required('Current password is required'), newPassword: [required('New password is required'), password()], confirmPassword: [ required('Please confirm your new password'), confirmPassword(data.newPassword), ], }; return validateForm(data, rules); }; export const validateProfileForm = async (data: { firstName?: string; lastName?: string; username?: string; emailAddress?: string; phoneNumber?: string; bio?: string; website?: string; }): Promise<FormValidationResult> => { const rules: FormValidationRules = {}; if (data.firstName !== undefined) { rules.firstName = [minLength(1), maxLength(50)]; } if (data.lastName !== undefined) { rules.lastName = [minLength(1), maxLength(50)]; } if (data.username !== undefined) { rules.username = username(); } if (data.emailAddress !== undefined) { rules.emailAddress = email(); } if (data.phoneNumber !== undefined) { rules.phoneNumber = phone(); } if (data.bio !== undefined) { rules.bio = maxLength(500); } if (data.website !== undefined) { rules.website = url(); } return validateForm(data, rules); }; export const validateOrganizationForm = async (data: { name: string; slug?: string; description?: string; website?: string; billingEmail?: string; }): Promise<FormValidationResult> => { const rules: FormValidationRules = { name: [ required('Organization name is required'), minLength(1), maxLength(100), ], }; if (data.slug !== undefined) { rules.slug = [ pattern(/^[a-z0-9-]+$/, 'Slug can only contain lowercase letters, numbers, and hyphens'), minLength(3), maxLength(50), ]; } if (data.description !== undefined) { rules.description = maxLength(500); } if (data.website !== undefined) { rules.website = url(); } if (data.billingEmail !== undefined) { rules.billingEmail = email(); } return validateForm(data, rules); }; export const validateInvitationForm = async (data: { emailAddress: string; roleId: string; customMessage?: string; }): Promise<FormValidationResult> => { const rules: FormValidationRules = { emailAddress: [required('Email is required'), email()], roleId: required('Role is required'), }; if (data.customMessage !== undefined) { rules.customMessage = maxLength(500); } return validateForm(data, rules); }; // Utility functions for validation export const isValidEmail = (email: string): boolean => { const emailRegex = /^[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])?)*$/; return emailRegex.test(email); }; export const isValidPhone = (phone: string): boolean => { const phoneRegex = /^\+?[1-9]\d{1,14}$/; const cleaned = phone.replace(/[^\d+]/g, ''); return phoneRegex.test(cleaned); }; export const isValidUsername = (username: string): boolean => { const usernameRegex = /^[a-zA-Z0-9_]{3,30}$/; return usernameRegex.test(username); }; export const isValidUrl = (url: string): boolean => { try { new URL(url); return true; } catch { return false; } }; export const sanitizeInput = (input: string): string => { return input.trim().replace(/[<>]/g, ''); }; export const normalizeEmail = (email: string): string => { return email.toLowerCase().trim(); }; export const normalizePhone = (phone: string): string => { return phone.replace(/[^\d+]/g, ''); };