From 8511abfa3d250c592aaf7eb3e17f18e7ae1400e7 Mon Sep 17 00:00:00 2001 From: Eddy Zheng Date: Mon, 22 Sep 2025 00:12:32 -0400 Subject: [PATCH 1/4] add display on frontend and update backend checker --- backend/app/schemas/user.py | 16 +- frontend/src/APIClients/authAPIClient.ts | 22 +- frontend/src/pages/participant-form.tsx | 338 ++++++++++++++--------- 3 files changed, 240 insertions(+), 136 deletions(-) diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index ab1d4658..138a9b10 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("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("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("contain at least one lowercase letter") + if not any(char in "!@#$%^&*" for char in password): + errors.append("contain at least one special character (!, @, #, $, %, ^, &, or *)") + + if errors: + raise ValueError(f"Password must {', '.join(errors)}") return password 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..231f2a93 100644 --- a/frontend/src/pages/participant-form.tsx +++ b/frontend/src/pages/participant-form.tsx @@ -18,6 +18,7 @@ export function ParticipantFormPage() { const [password, setPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState(''); const [error, setError] = useState(''); + const [passwordValidationErrors, setPasswordValidationErrors] = useState([]); const router = useRouter(); const handleSubmit = async (e: React.FormEvent) => { @@ -27,34 +28,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); + 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 { + // Handle registration failure + if (result.error === 'password_validation' && result.validationErrors) { + setError(''); // Clear any previous errors + setPasswordValidationErrors(result.validationErrors); } else { - setError('Registration failed'); + setPasswordValidationErrors([]); // Clear validation errors + setError(result.error || 'Registration failed'); } } }; @@ -106,111 +104,190 @@ export function ParticipantFormPage() { Let's start by creating an account.
- - Email - - } - mb={4} - > - - setEmail(e.target.value)} - /> - - - - Password - - } - mb={4} - > - - setPassword(e.target.value)} - /> - - - - Confirm Password - - } - mb={4} - > - - setConfirmPassword(e.target.value)} - /> - - + + + Email + + } + > + + setEmail(e.target.value)} + /> + + + + + + Password + + } + > + + { + setPassword(e.target.value); + // Clear password validation errors when user starts typing + if (passwordValidationErrors.length > 0) { + setPasswordValidationErrors([]); + } + }} + /> + + + + + + Confirm Password + + } + > + + setConfirmPassword(e.target.value)} + /> + + + + + {/* Password Requirements - Only show when there are validation errors */} + {passwordValidationErrors.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 */} Date: Mon, 22 Sep 2025 11:18:36 -0400 Subject: [PATCH 2/4] add tests --- backend/tests/unit/test_user_schema.py | 161 +++++++++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 backend/tests/unit/test_user_schema.py diff --git a/backend/tests/unit/test_user_schema.py b/backend/tests/unit/test_user_schema.py new file mode 100644 index 00000000..dc2e94cd --- /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 UserCreateRequest, UserRole, SignUpMethod + + +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 "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 "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 "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 "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 "be at least 8 characters long" in error_msg + assert "contain at least one uppercase letter" in error_msg + assert "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}" \ No newline at end of file From 0f04d27402a0d20667e48d92a7e28a67c047707b Mon Sep 17 00:00:00 2001 From: Eddy Zheng Date: Mon, 22 Sep 2025 11:27:17 -0400 Subject: [PATCH 3/4] dont do unnecessary changes to form --- backend/app/schemas/user.py | 10 +- backend/tests/unit/test_user_schema.py | 58 +++---- frontend/src/pages/participant-form.tsx | 219 ++++++++++++------------ 3 files changed, 139 insertions(+), 148 deletions(-) diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index 138a9b10..e8040ce5 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -67,16 +67,16 @@ def validate_password(cls, password: Optional[str], info): if password: errors = [] if len(password) < 8: - errors.append("be at least 8 characters long") + errors.append("Password must be at least 8 characters long") if not any(char.isupper() for char in password): - errors.append("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): - errors.append("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("contain at least one special character (!, @, #, $, %, ^, &, or *)") + errors.append("Password must contain at least one special character (!, @, #, $, %, ^, &, or *)") if errors: - raise ValueError(f"Password must {', '.join(errors)}") + raise ValueError(errors) return password diff --git a/backend/tests/unit/test_user_schema.py b/backend/tests/unit/test_user_schema.py index dc2e94cd..325edea1 100644 --- a/backend/tests/unit/test_user_schema.py +++ b/backend/tests/unit/test_user_schema.py @@ -1,7 +1,7 @@ import pytest from pydantic import ValidationError -from app.schemas.user import UserCreateRequest, UserRole, SignUpMethod +from app.schemas.user import SignUpMethod, UserCreateRequest, UserRole class TestUserCreateRequestPasswordValidation: @@ -11,13 +11,13 @@ def test_password_validation_success_valid_password(self): """Test that a valid password passes validation""" user_data = { "first_name": "Test", - "last_name": "User", + "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!" @@ -27,18 +27,18 @@ def test_password_validation_too_short(self): user_data = { "first_name": "Test", "last_name": "User", - "email": "test@example.com", + "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 "be at least 8 characters long" in error_msg + 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""" @@ -50,12 +50,12 @@ def test_password_validation_missing_uppercase(self): "role": UserRole.PARTICIPANT, "signup_method": SignUpMethod.PASSWORD, } - + with pytest.raises(ValidationError) as exc_info: UserCreateRequest(**user_data) - + error_msg = str(exc_info.value) - assert "contain at least one uppercase letter" in error_msg + 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""" @@ -67,12 +67,12 @@ def test_password_validation_missing_lowercase(self): "role": UserRole.PARTICIPANT, "signup_method": SignUpMethod.PASSWORD, } - + with pytest.raises(ValidationError) as exc_info: UserCreateRequest(**user_data) - + error_msg = str(exc_info.value) - assert "contain at least one lowercase letter" in error_msg + 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""" @@ -84,32 +84,32 @@ def test_password_validation_missing_special_character(self): "role": UserRole.PARTICIPANT, "signup_method": SignUpMethod.PASSWORD, } - + with pytest.raises(ValidationError) as exc_info: UserCreateRequest(**user_data) - + error_msg = str(exc_info.value) - assert "contain at least one special character" in error_msg + 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", + "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 "be at least 8 characters long" in error_msg - assert "contain at least one uppercase letter" in error_msg - assert "contain at least one special character" in error_msg + 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""" @@ -121,7 +121,7 @@ def test_password_validation_google_signup_no_password_required(self): "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 @@ -131,13 +131,13 @@ def test_password_validation_edge_cases(self): # Test with exactly 8 characters user_data = { "first_name": "Test", - "last_name": "User", + "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!" @@ -145,17 +145,17 @@ def test_password_validation_edge_cases(self): 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", + "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}" \ No newline at end of file + assert user_request.password == f"ValidPass1{char}" diff --git a/frontend/src/pages/participant-form.tsx b/frontend/src/pages/participant-form.tsx index 231f2a93..f6115951 100644 --- a/frontend/src/pages/participant-form.tsx +++ b/frontend/src/pages/participant-form.tsx @@ -104,120 +104,111 @@ export function ParticipantFormPage() { Let's start by creating an account. - - - Email - - } - > - - setEmail(e.target.value)} - /> - - - - - - Password - - } - > - - { - setPassword(e.target.value); - // Clear password validation errors when user starts typing - if (passwordValidationErrors.length > 0) { - setPasswordValidationErrors([]); - } - }} - /> - - - - - - Confirm Password - - } - > - - setConfirmPassword(e.target.value)} - /> - - - + + Email + + } + mb={4} + > + + setEmail(e.target.value)} + /> + + + + Password + + } + mb={4} + > + + setPassword(e.target.value)} + /> + + + + Confirm Password + + } + mb={4} + > + + setConfirmPassword(e.target.value)} + /> + + {/* Password Requirements - Only show when there are validation errors */} {passwordValidationErrors.length > 0 && ( From ca49afb5c522bb68410496c1eb837a34c871626f Mon Sep 17 00:00:00 2001 From: Eddy Zheng Date: Fri, 26 Sep 2025 17:49:46 -0400 Subject: [PATCH 4/4] password validation as user types. don't let the user submit if password doesn't pass validation --- frontend/src/pages/participant-form.tsx | 47 +++++++++++++++++++------ 1 file changed, 36 insertions(+), 11 deletions(-) diff --git a/frontend/src/pages/participant-form.tsx b/frontend/src/pages/participant-form.tsx index f6115951..3e15575c 100644 --- a/frontend/src/pages/participant-form.tsx +++ b/frontend/src/pages/participant-form.tsx @@ -21,6 +21,26 @@ export function ParticipantFormPage() { 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(''); @@ -28,6 +48,12 @@ export function ParticipantFormPage() { setError('Passwords do not match'); return; } + + if (passwordValidationErrors.length > 0) { + setError('Please fix the password requirements above'); + return; + } + const userData = { first_name: '', last_name: '', @@ -46,14 +72,7 @@ export function ParticipantFormPage() { setError(''); // Clear any error messages router.push(`/verify?email=${encodeURIComponent(email)}&role=${signupType}`); } else { - // Handle registration failure - if (result.error === 'password_validation' && result.validationErrors) { - setError(''); // Clear any previous errors - setPasswordValidationErrors(result.validationErrors); - } else { - setPasswordValidationErrors([]); // Clear validation errors - setError(result.error || 'Registration failed'); - } + setError(result.error || 'Registration failed'); } }; @@ -170,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); + }} /> @@ -210,8 +235,8 @@ export function ParticipantFormPage() { - {/* Password Requirements - Only show when there are validation errors */} - {passwordValidationErrors.length > 0 && ( + {/* Password Requirements - Show when user starts typing */} + {password.length > 0 && ( {[