diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index ab1d4658..e8040ce5 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -53,7 +53,7 @@ class UserCreateRequest(UserBase): Request schema for user creation with conditional password validation """ - password: Optional[str] = Field(None, min_length=8) + password: Optional[str] = Field(None) auth_id: Optional[str] = Field(None) signup_method: SignUpMethod = Field(default=SignUpMethod.PASSWORD) @@ -65,12 +65,18 @@ def validate_password(cls, password: Optional[str], info): raise ValueError("Password is required for password signup") if password: - if not any(char.isdigit() for char in password): - raise ValueError("Password must contain at least one digit") + errors = [] + if len(password) < 8: + errors.append("Password must be at least 8 characters long") if not any(char.isupper() for char in password): - raise ValueError("Password must contain at least one uppercase letter") + errors.append("Password must contain at least one uppercase letter") if not any(char.islower() for char in password): - raise ValueError("Password must contain at least one lowercase letter") + errors.append("Password must contain at least one lowercase letter") + if not any(char in "!@#$%^&*" for char in password): + errors.append("Password must contain at least one special character (!, @, #, $, %, ^, &, or *)") + + if errors: + raise ValueError(errors) return password diff --git a/backend/tests/unit/test_user_schema.py b/backend/tests/unit/test_user_schema.py new file mode 100644 index 00000000..325edea1 --- /dev/null +++ b/backend/tests/unit/test_user_schema.py @@ -0,0 +1,161 @@ +import pytest +from pydantic import ValidationError + +from app.schemas.user import SignUpMethod, UserCreateRequest, UserRole + + +class TestUserCreateRequestPasswordValidation: + """Test password validation in UserCreateRequest schema""" + + def test_password_validation_success_valid_password(self): + """Test that a valid password passes validation""" + user_data = { + "first_name": "Test", + "last_name": "User", + "email": "test@example.com", + "password": "ValidPass123!", + "role": UserRole.PARTICIPANT, + "signup_method": SignUpMethod.PASSWORD, + } + + # Should not raise any exception + user_request = UserCreateRequest(**user_data) + assert user_request.password == "ValidPass123!" + + def test_password_validation_too_short(self): + """Test that password less than 8 characters fails validation""" + user_data = { + "first_name": "Test", + "last_name": "User", + "email": "test@example.com", + "password": "short1!", # Only 7 characters + "role": UserRole.PARTICIPANT, + "signup_method": SignUpMethod.PASSWORD, + } + + with pytest.raises(ValidationError) as exc_info: + UserCreateRequest(**user_data) + + # Check that the error message contains the expected validation message + error_msg = str(exc_info.value) + assert "Password must be at least 8 characters long" in error_msg + + def test_password_validation_missing_uppercase(self): + """Test that password without uppercase letter fails validation""" + user_data = { + "first_name": "Test", + "last_name": "User", + "email": "test@example.com", + "password": "lowercase123!", # No uppercase + "role": UserRole.PARTICIPANT, + "signup_method": SignUpMethod.PASSWORD, + } + + with pytest.raises(ValidationError) as exc_info: + UserCreateRequest(**user_data) + + error_msg = str(exc_info.value) + assert "Password must contain at least one uppercase letter" in error_msg + + def test_password_validation_missing_lowercase(self): + """Test that password without lowercase letter fails validation""" + user_data = { + "first_name": "Test", + "last_name": "User", + "email": "test@example.com", + "password": "UPPERCASE123!", # No lowercase + "role": UserRole.PARTICIPANT, + "signup_method": SignUpMethod.PASSWORD, + } + + with pytest.raises(ValidationError) as exc_info: + UserCreateRequest(**user_data) + + error_msg = str(exc_info.value) + assert "Password must contain at least one lowercase letter" in error_msg + + def test_password_validation_missing_special_character(self): + """Test that password without special character fails validation""" + user_data = { + "first_name": "Test", + "last_name": "User", + "email": "test@example.com", + "password": "NoSpecial123", # No special characters + "role": UserRole.PARTICIPANT, + "signup_method": SignUpMethod.PASSWORD, + } + + with pytest.raises(ValidationError) as exc_info: + UserCreateRequest(**user_data) + + error_msg = str(exc_info.value) + assert "Password must contain at least one special character" in error_msg + + def test_password_validation_multiple_errors(self): + """Test that password with multiple issues returns all errors""" + user_data = { + "first_name": "Test", + "last_name": "User", + "email": "test@example.com", + "password": "short", # Too short, no uppercase, no special char + "role": UserRole.PARTICIPANT, + "signup_method": SignUpMethod.PASSWORD, + } + + with pytest.raises(ValidationError) as exc_info: + UserCreateRequest(**user_data) + + error_msg = str(exc_info.value) + # Should contain multiple validation errors + assert "Password must be at least 8 characters long" in error_msg + assert "Password must contain at least one uppercase letter" in error_msg + assert "Password must contain at least one special character" in error_msg + + def test_password_validation_google_signup_no_password_required(self): + """Test that Google signup doesn't require password validation""" + user_data = { + "first_name": "Test", + "last_name": "User", + "email": "test@example.com", + # No password provided + "role": UserRole.PARTICIPANT, + "signup_method": SignUpMethod.GOOGLE, + } + + # Should not raise any exception for Google signup + user_request = UserCreateRequest(**user_data) + assert user_request.password is None + + def test_password_validation_edge_cases(self): + """Test edge cases for password validation""" + # Test with exactly 8 characters + user_data = { + "first_name": "Test", + "last_name": "User", + "email": "test@example.com", + "password": "MinPass1!", # Exactly 8 chars, meets all requirements + "role": UserRole.PARTICIPANT, + "signup_method": SignUpMethod.PASSWORD, + } + + # Should not raise any exception + user_request = UserCreateRequest(**user_data) + assert user_request.password == "MinPass1!" + + def test_password_validation_all_special_characters(self): + """Test that all allowed special characters work""" + special_chars = ["!", "@", "#", "$", "%", "^", "&", "*"] + + for char in special_chars: + user_data = { + "first_name": "Test", + "last_name": "User", + "email": "test@example.com", + "password": f"ValidPass1{char}", + "role": UserRole.PARTICIPANT, + "signup_method": SignUpMethod.PASSWORD, + } + + # Should not raise any exception + user_request = UserCreateRequest(**user_data) + assert user_request.password == f"ValidPass1{char}" diff --git a/frontend/src/APIClients/authAPIClient.ts b/frontend/src/APIClients/authAPIClient.ts index f2d73f31..e9465c7f 100644 --- a/frontend/src/APIClients/authAPIClient.ts +++ b/frontend/src/APIClients/authAPIClient.ts @@ -28,6 +28,7 @@ export interface AuthResult { user?: AuthenticatedUser; error?: string; errorCode?: string; + validationErrors?: string[]; } const login = async (email: string, password: string): Promise => { @@ -223,9 +224,28 @@ export const register = async ({ } else if (response?.status === 400) { const detail = response?.data?.detail || 'Invalid registration data'; return { success: false, error: detail }; + } else if (response?.status === 422) { + // Pydantic validation errors + const validationErrors = response?.data?.detail; + console.log('[REGISTER] Validation errors:', validationErrors); + if (Array.isArray(validationErrors)) { + // Extract password validation errors specifically + const passwordErrors = validationErrors + .filter((err) => err.loc && err.loc.includes('password')) + .map((err) => err.msg); + + if (passwordErrors.length > 0) { + return { + success: false, + error: 'password_validation', + validationErrors: passwordErrors, + }; + } + } + // Fallback for other validation errors + return { success: false, error: response?.data?.detail || 'Validation failed' }; } } - return { success: false, error: 'Registration failed. Please try again.' }; } }; diff --git a/frontend/src/pages/participant-form.tsx b/frontend/src/pages/participant-form.tsx index 7243a6f7..3e15575c 100644 --- a/frontend/src/pages/participant-form.tsx +++ b/frontend/src/pages/participant-form.tsx @@ -18,8 +18,29 @@ export function ParticipantFormPage() { const [password, setPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState(''); const [error, setError] = useState(''); + const [passwordValidationErrors, setPasswordValidationErrors] = useState([]); const router = useRouter(); + // Frontend password validation function that mirrors backend logic + const validatePasswordFrontend = (password: string): string[] => { + const errors: string[] = []; + if (password.length < 8) { + errors.push('Password must be at least 8 characters long'); + } + if (!/[A-Z]/.test(password)) { + errors.push('Password must contain at least one uppercase letter'); + } + if (!/[a-z]/.test(password)) { + errors.push('Password must contain at least one lowercase letter'); + } + if (!/[!@#$%^&*]/.test(password)) { + errors.push( + 'Password must contain at least one special character (!, @, #, $, %, ^, &, or *)', + ); + } + return errors; + }; + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(''); @@ -27,35 +48,31 @@ export function ParticipantFormPage() { setError('Passwords do not match'); return; } - try { - const userData = { - first_name: '', - last_name: '', - email, - password, - role: signupType === 'volunteer' ? UserRole.VOLUNTEER : UserRole.PARTICIPANT, - signupMethod: SignUpMethod.PASSWORD, - }; - const result = await register(userData); + + if (passwordValidationErrors.length > 0) { + setError('Please fix the password requirements above'); + return; + } + + const userData = { + first_name: '', + last_name: '', + email, + password, + role: signupType === 'volunteer' ? UserRole.VOLUNTEER : UserRole.PARTICIPANT, + signupMethod: SignUpMethod.PASSWORD, + }; + + const result = await register(userData); + console.log('Registration result:', result); + + if (result.success) { console.log('Registration success:', result); + setPasswordValidationErrors([]); // Clear validation errors on success + setError(''); // Clear any error messages router.push(`/verify?email=${encodeURIComponent(email)}&role=${signupType}`); - } catch (err: unknown) { - console.error('Registration error:', err); - if ( - err && - typeof err === 'object' && - 'response' in err && - err.response && - typeof err.response === 'object' && - 'data' in err.response && - err.response.data && - typeof err.response.data === 'object' && - 'detail' in err.response.data - ) { - setError((err.response.data as { detail: string }).detail || 'Registration failed'); - } else { - setError('Registration failed'); - } + } else { + setError(result.error || 'Registration failed'); } }; @@ -172,7 +189,13 @@ export function ParticipantFormPage() { borderColor="#D5D7DA" _placeholder={{ color: '#A0AEC0', fontWeight: 400 }} value={password} - onChange={(e) => setPassword(e.target.value)} + onChange={(e) => { + const newPassword = e.target.value; + setPassword(newPassword); + // Real-time validation as user types + const errors = validatePasswordFrontend(newPassword); + setPasswordValidationErrors(errors); + }} /> @@ -211,6 +234,76 @@ export function ParticipantFormPage() { /> + + {/* Password Requirements - Show when user starts typing */} + {password.length > 0 && ( + + + {[ + { text: 'At least 8 characters', key: 'long' }, + { text: 'At least 1 uppercase letter', key: 'uppercase' }, + { text: 'At least 1 lowercase letter', key: 'lowercase' }, + { + text: 'At least 1 special character (!, @, #, $, %, ^, &, or *)', + key: 'special', + }, + ].map((requirement, index) => { + const hasError = passwordValidationErrors.some((error) => { + if (requirement.key === 'uppercase' && error.includes('uppercase')) + return true; + if (requirement.key === 'lowercase' && error.includes('lowercase')) + return true; + if (requirement.key === 'long' && error.includes('long')) return true; + if (requirement.key === 'special' && error.includes('special')) return true; + return false; + }); + + return ( + + + {hasError ? ( + + + + ) : ( + + + + )} + + + {requirement.text} + + + ); + })} + + + )} + + {/* Right: Image */}