@frank-auth/react
Version:
Flexible and customizable React UI components for Frank Authentication
694 lines (578 loc) • 23.3 kB
text/typescript
/**
* @frank-auth/react - Configuration Validators
*
* Comprehensive validation system for all configuration types with
* detailed error reporting and type safety.
*/
import type {
AppearanceConfig,
AppearanceMode,
ColorVariant,
ComponentOverrides,
ComponentSize,
ConfigValidationError,
ConfigValidationResult,
FrankAuthUIConfig,
Locale,
LocalizationConfig,
OrganizationConfig,
Theme,
ThemeMode,
UserType,
} from './types';
// ============================================================================
// Validation Utilities
// ============================================================================
/**
* Creates a validation error
*/
function createError(path: string, message: string, value?: any): ConfigValidationError {
return {path, message, value};
}
/**
* Creates a validation warning
*/
function createWarning(path: string, message: string, value?: any): ConfigValidationError {
return {path, message, value};
}
/**
* Validates if a value is a valid URL
*/
function isValidUrl(url: string): boolean {
try {
new URL(url);
return true;
} catch {
return false;
}
}
/**
* Validates if a value is a valid hex color
*/
function isValidHexColor(color: string): boolean {
return /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/.test(color);
}
/**
* Validates if a value is a valid CSS value
*/
function isValidCSSValue(value: string): boolean {
// Basic CSS value validation - can be extended
return typeof value === 'string' && value.length > 0;
}
/**
* Validates if a value is one of the allowed options
*/
function isValidOption<T extends string>(value: string, options: readonly T[]): value is T {
return options.includes(value as T);
}
/**
* Validates if an object has required properties
*/
function hasRequiredProperties(obj: any, properties: string[]): boolean {
return properties.every(prop => prop in obj && obj[prop] !== undefined);
}
// ============================================================================
// Specific Validators
// ============================================================================
/**
* Validates publishable key format
*/
export function validatePublishableKey(key: string): ConfigValidationError[] {
const errors: ConfigValidationError[] = [];
if (!key) {
errors.push(createError('publishableKey', 'Publishable key is required'));
return errors;
}
if (typeof key !== 'string') {
errors.push(createError('publishableKey', 'Publishable key must be a string', key));
return errors;
}
// Check format: pk_test_... or pk_live_...
if (!/^pk_(test|live)_[a-zA-Z0-9_]+$/.test(key)) {
errors.push(createError('publishableKey', 'Invalid publishable key format. Expected: pk_test_... or pk_live_...', key));
}
if (key.length < 20) {
errors.push(createError('publishableKey', 'Publishable key appears to be too short', key));
}
return errors;
}
/**
* Validates API URL
*/
export function validateApiUrl(url?: string): ConfigValidationError[] {
const errors: ConfigValidationError[] = [];
if (!url) {
return errors; // API URL is optional
}
if (typeof url !== 'string') {
errors.push(createError('apiUrl', 'API URL must be a string', url));
return errors;
}
if (!isValidUrl(url)) {
errors.push(createError('apiUrl', 'Invalid API URL format', url));
}
// Check for HTTPS in production
if (url.startsWith('http://') && !url.includes('localhost') && !url.includes('127.0.0.1')) {
errors.push(createWarning('apiUrl', 'Consider using HTTPS for production API URL', url));
}
return errors;
}
/**
* Validates user type
*/
export function validateUserType(userType: string): ConfigValidationError[] {
const errors: ConfigValidationError[] = [];
const validUserTypes: UserType[] = ['internal', 'external', 'end_user'];
if (!isValidOption(userType, validUserTypes)) {
errors.push(createError('userType', `Invalid user type. Must be one of: ${validUserTypes.join(', ')}`, userType));
}
return errors;
}
/**
* Validates locale
*/
export function validateLocale(locale: string): ConfigValidationError[] {
const errors: ConfigValidationError[] = [];
const validLocales: Locale[] = ['en', 'es', 'fr', 'de', 'pt', 'it', 'ja', 'ko', 'zh'];
if (!isValidOption(locale, validLocales)) {
errors.push(createError('locale', `Invalid locale. Must be one of: ${validLocales.join(', ')}`, locale));
}
return errors;
}
// ============================================================================
// Theme Validation
// ============================================================================
/**
* Validates theme mode
*/
export function validateThemeMode(mode: string): ConfigValidationError[] {
const errors: ConfigValidationError[] = [];
const validModes: ThemeMode[] = ['light', 'dark', 'system'];
if (!isValidOption(mode, validModes)) {
errors.push(createError('theme.mode', `Invalid theme mode. Must be one of: ${validModes.join(', ')}`, mode));
}
return errors;
}
/**
* Validates color palette
*/
export function validateColorPalette(colors: any, path = 'theme.colors'): ConfigValidationError[] {
const errors: ConfigValidationError[] = [];
if (!colors || typeof colors !== 'object') {
errors.push(createError(path, 'Colors must be an object', colors));
return errors;
}
// Validate required color properties
const requiredColors = ['primary', 'secondary', 'background', 'foreground'];
for (const colorKey of requiredColors) {
if (!(colorKey in colors)) {
errors.push(createError(`${path}.${colorKey}`, `Missing required color: ${colorKey}`));
continue;
}
const colorValue = colors[colorKey];
// Primary and secondary should be objects with shades
if (colorKey === 'primary' || colorKey === 'secondary') {
if (typeof colorValue !== 'object') {
errors.push(createError(`${path}.${colorKey}`, `${colorKey} must be an object with color shades`, colorValue));
continue;
}
// Check for required shades
const requiredShades = ['DEFAULT', 'foreground'];
for (const shade of requiredShades) {
if (!(shade in colorValue)) {
errors.push(createError(`${path}.${colorKey}.${shade}`, `Missing required shade: ${shade}`));
} else if (!isValidHexColor(colorValue[shade])) {
errors.push(createError(`${path}.${colorKey}.${shade}`, 'Invalid hex color format', colorValue[shade]));
}
}
} else {
// Background, foreground should be hex colors
if (!isValidHexColor(colorValue)) {
errors.push(createError(`${path}.${colorKey}`, 'Invalid hex color format', colorValue));
}
}
}
return errors;
}
/**
* Validates typography configuration
*/
export function validateTypography(typography: any, path = 'theme.typography'): ConfigValidationError[] {
const errors: ConfigValidationError[] = [];
if (!typography || typeof typography !== 'object') {
errors.push(createError(path, 'Typography must be an object', typography));
return errors;
}
// Validate font families
if (typography.fontFamily) {
if (typeof typography.fontFamily !== 'object') {
errors.push(createError(`${path}.fontFamily`, 'Font family must be an object', typography.fontFamily));
} else {
if (typography.fontFamily.sans && !Array.isArray(typography.fontFamily.sans)) {
errors.push(createError(`${path}.fontFamily.sans`, 'Sans font family must be an array', typography.fontFamily.sans));
}
if (typography.fontFamily.mono && !Array.isArray(typography.fontFamily.mono)) {
errors.push(createError(`${path}.fontFamily.mono`, 'Mono font family must be an array', typography.fontFamily.mono));
}
}
}
// Validate font sizes
if (typography.fontSize) {
if (typeof typography.fontSize !== 'object') {
errors.push(createError(`${path}.fontSize`, 'Font size must be an object', typography.fontSize));
} else {
Object.entries(typography.fontSize).forEach(([size, value]) => {
if (!Array.isArray(value) || value.length !== 2) {
errors.push(createError(`${path}.fontSize.${size}`, 'Font size value must be an array with [size, lineHeight]', value));
}
});
}
}
return errors;
}
/**
* Validates complete theme configuration
*/
export function validateThemeConfig(theme: Partial<Theme>): ConfigValidationError[] {
const errors: ConfigValidationError[] = [];
if (!theme || typeof theme !== 'object') {
errors.push(createError('theme', 'Theme must be an object', theme));
return errors;
}
// Validate theme mode
if (theme.mode) {
errors.push(...validateThemeMode(theme.mode));
}
// Validate colors
if (theme.colors) {
errors.push(...validateColorPalette(theme.colors));
}
// Validate typography
if (theme.typography) {
errors.push(...validateTypography(theme.typography));
}
// Validate spacing
if (theme.spacing) {
if (typeof theme.spacing !== 'object') {
errors.push(createError('theme.spacing', 'Spacing must be an object', theme.spacing));
} else {
Object.entries(theme.spacing).forEach(([key, value]) => {
if (!isValidCSSValue(value as string)) {
errors.push(createError(`theme.spacing.${key}`, 'Invalid CSS value for spacing', value));
}
});
}
}
return errors;
}
// ============================================================================
// Appearance Validation
// ============================================================================
/**
* Validates appearance mode
*/
export function validateAppearanceMode(mode: string): ConfigValidationError[] {
const errors: ConfigValidationError[] = [];
const validModes: AppearanceMode[] = ['system', 'light', 'dark'];
if (!isValidOption(mode, validModes)) {
errors.push(createError('appearance.mode', `Invalid appearance mode. Must be one of: ${validModes.join(', ')}`, mode));
}
return errors;
}
/**
* Validates component size
*/
export function validateComponentSize(size: string): ConfigValidationError[] {
const errors: ConfigValidationError[] = [];
const validSizes: ComponentSize[] = ['sm', 'md', 'lg'];
if (!isValidOption(size, validSizes)) {
errors.push(createError('size', `Invalid component size. Must be one of: ${validSizes.join(', ')}`, size));
}
return errors;
}
/**
* Validates color variant
*/
export function validateColorVariant(variant: string): ConfigValidationError[] {
const errors: ConfigValidationError[] = [];
const validVariants: ColorVariant[] = ['default', 'primary', 'secondary', 'success', 'warning', 'danger'];
if (!isValidOption(variant, validVariants)) {
errors.push(createError('color', `Invalid color variant. Must be one of: ${validVariants.join(', ')}`, variant));
}
return errors;
}
/**
* Validates branding configuration
*/
export function validateBrandingConfig(branding: any, path = 'appearance.branding'): ConfigValidationError[] {
const errors: ConfigValidationError[] = [];
if (!branding || typeof branding !== 'object') {
errors.push(createError(path, 'Branding must be an object', branding));
return errors;
}
// Validate logo
if (branding.logo) {
if (branding.logo.url && !isValidUrl(branding.logo.url)) {
errors.push(createError(`${path}.logo.url`, 'Invalid logo URL', branding.logo.url));
}
if (!branding.logo.alt) {
errors.push(createError(`${path}.logo.alt`, 'Logo alt text is required for accessibility'));
}
}
// Validate colors
if (branding.colors) {
if (branding.colors.primary && !isValidHexColor(branding.colors.primary)) {
errors.push(createError(`${path}.colors.primary`, 'Invalid hex color format', branding.colors.primary));
}
if (branding.colors.secondary && !isValidHexColor(branding.colors.secondary)) {
errors.push(createError(`${path}.colors.secondary`, 'Invalid hex color format', branding.colors.secondary));
}
}
return errors;
}
/**
* Validates appearance configuration
*/
export function validateAppearanceConfig(appearance: Partial<AppearanceConfig>): ConfigValidationError[] {
const errors: ConfigValidationError[] = [];
if (!appearance || typeof appearance !== 'object') {
errors.push(createError('appearance', 'Appearance must be an object', appearance));
return errors;
}
// Validate appearance mode
if (appearance.mode) {
errors.push(...validateAppearanceMode(appearance.mode));
}
// Validate branding
if (appearance.branding) {
errors.push(...validateBrandingConfig(appearance.branding));
}
// Validate component appearance
if (appearance.components) {
const {components} = appearance;
if (components.input?.size) {
errors.push(...validateComponentSize(components.input.size));
}
if (components.input?.color) {
errors.push(...validateColorVariant(components.input.color));
}
if (components.button?.size) {
errors.push(...validateComponentSize(components.button.size));
}
if (components.button?.color) {
errors.push(...validateColorVariant(components.button.color));
}
}
return errors;
}
// ============================================================================
// Localization Validation
// ============================================================================
/**
* Validates localization configuration
*/
export function validateLocalizationConfig(localization: Partial<LocalizationConfig>): ConfigValidationError[] {
const errors: ConfigValidationError[] = [];
if (!localization || typeof localization !== 'object') {
errors.push(createError('localization', 'Localization must be an object', localization));
return errors;
}
// Validate default locale
if (localization.defaultLocale) {
errors.push(...validateLocale(localization.defaultLocale).map(error => ({
...error,
path: `localization.${error.path}`,
})));
}
// Validate fallback locale
if (localization.fallbackLocale) {
errors.push(...validateLocale(localization.fallbackLocale).map(error => ({
...error,
path: `localization.${error.path}`,
})));
}
// Validate supported locales
if (localization.supportedLocales) {
if (!Array.isArray(localization.supportedLocales)) {
errors.push(createError('localization.supportedLocales', 'Supported locales must be an array', localization.supportedLocales));
} else {
localization.supportedLocales.forEach((locale, index) => {
errors.push(...validateLocale(locale).map(error => ({
...error,
path: `localization.supportedLocales[${index}]`,
})));
});
}
}
// Validate date/time formats
if (localization.dateFormat && typeof localization.dateFormat !== 'string') {
errors.push(createError('localization.dateFormat', 'Date format must be a string', localization.dateFormat));
}
if (localization.timeFormat && typeof localization.timeFormat !== 'string') {
errors.push(createError('localization.timeFormat', 'Time format must be a string', localization.timeFormat));
}
return errors;
}
// ============================================================================
// Organization Validation
// ============================================================================
/**
* Validates organization configuration
*/
export function validateOrganizationConfig(organization: Partial<OrganizationConfig>): ConfigValidationError[] {
const errors: ConfigValidationError[] = [];
if (!organization || typeof organization !== 'object') {
errors.push(createError('organization', 'Organization must be an object', organization));
return errors;
}
// Validate organization ID
if (organization.id && typeof organization.id !== 'string') {
errors.push(createError('organization.id', 'Organization ID must be a string', organization.id));
}
// Validate organization name
if (organization.name && typeof organization.name !== 'string') {
errors.push(createError('organization.name', 'Organization name must be a string', organization.name));
}
// Validate settings
if (organization.settings) {
const {settings} = organization;
// Validate password policy
if (settings.passwordPolicy) {
if (settings.passwordPolicy.minLength && typeof settings.passwordPolicy.minLength !== 'number') {
errors.push(createError('organization.settings.passwordPolicy.minLength', 'Min length must be a number', settings.passwordPolicy.minLength));
}
if (settings.passwordPolicy.minLength && settings.passwordPolicy.minLength < 4) {
errors.push(createWarning('organization.settings.passwordPolicy.minLength', 'Minimum password length should be at least 4 characters'));
}
}
// Validate session settings
if (settings.sessionSettings) {
if (settings.sessionSettings.maxDuration && typeof settings.sessionSettings.maxDuration !== 'number') {
errors.push(createError('organization.settings.sessionSettings.maxDuration', 'Max duration must be a number', settings.sessionSettings.maxDuration));
}
}
// Validate branding
if (settings.branding) {
errors.push(...validateBrandingConfig(settings.branding, 'organization.settings.branding'));
}
}
return errors;
}
// ============================================================================
// Component Override Validation
// ============================================================================
/**
* Validates component overrides
*/
export function validateComponentOverrides(components: ComponentOverrides): ConfigValidationError[] {
const errors: ConfigValidationError[] = [];
if (!components || typeof components !== 'object') {
errors.push(createError('components', 'Components must be an object', components));
return errors;
}
// Validate that each override is a valid React component
Object.entries(components).forEach(([componentName, Component]) => {
if (Component && typeof Component !== 'function') {
errors.push(createError(`components.${componentName}`, 'Component override must be a React component (function)', Component));
}
});
return errors;
}
// ============================================================================
// Main Configuration Validation
// ============================================================================
/**
* Validates the complete Frank Auth UI configuration
*/
export function validateFrankAuthConfig(config: Partial<FrankAuthUIConfig>): ConfigValidationResult {
const errors: ConfigValidationError[] = [];
const warnings: ConfigValidationError[] = [];
if (!config || typeof config !== 'object') {
return {
isValid: false,
errors: [createError('config', 'Configuration must be an object', config)],
warnings: [],
};
}
// Validate required fields
if (!config.publishableKey) {
errors.push(createError('publishableKey', 'Publishable key is required'));
} else {
errors.push(...validatePublishableKey(config.publishableKey));
}
if (!config.userType) {
errors.push(createError('userType', 'User type is required'));
} else {
errors.push(...validateUserType(config.userType));
}
// Validate optional fields
if (config.apiUrl) {
const apiUrlErrors = validateApiUrl(config.apiUrl);
errors.push(...apiUrlErrors.filter(e => e.path.includes('error')));
warnings.push(...apiUrlErrors.filter(e => e.path.includes('warning')));
}
if (config.theme) {
errors.push(...validateThemeConfig(config.theme));
}
if (config.appearance) {
errors.push(...validateAppearanceConfig(config.appearance));
}
if (config.localization) {
errors.push(...validateLocalizationConfig(config.localization));
}
if (config.organization) {
errors.push(...validateOrganizationConfig(config.organization));
}
if (config.components) {
errors.push(...validateComponentOverrides(config.components));
}
// Validate features
if (config.features) {
if (typeof config.features !== 'object') {
errors.push(createError('features', 'Features must be an object', config.features));
} else {
// Check for at least one authentication method enabled
if (!config.features.signIn && !config.features.sso) {
warnings.push(createWarning('features', 'At least one authentication method (signIn or sso) should be enabled'));
}
}
}
return {
isValid: errors.length === 0,
errors,
warnings,
};
}
// ============================================================================
// Quick Validation Functions
// ============================================================================
/**
* Quick validation that throws on errors
*/
export function assertValidConfig(config: Partial<FrankAuthUIConfig>): void {
const result = validateFrankAuthConfig(config);
if (!result.isValid) {
const errorMessages = result.errors.map(error => `${error.path}: ${error.message}`);
throw new Error(`Invalid Frank Auth configuration:\n${errorMessages.join('\n')}`);
}
}
/**
* Validates configuration and returns boolean
*/
export function isValidConfig(config: Partial<FrankAuthUIConfig>): boolean {
return validateFrankAuthConfig(config).isValid;
}
/**
* Gets validation errors as formatted strings
*/
export function getConfigErrors(config: Partial<FrankAuthUIConfig>): string[] {
const result = validateFrankAuthConfig(config);
return result.errors.map(error => `${error.path}: ${error.message}`);
}
/**
* Gets validation warnings as formatted strings
*/
export function getConfigWarnings(config: Partial<FrankAuthUIConfig>): string[] {
const result = validateFrankAuthConfig(config);
return result.warnings.map(warning => `${warning.path}: ${warning.message}`);
}