diff --git a/app/soapbox/components/__tests__/validation-checkmark.test.tsx b/app/soapbox/components/__tests__/validation-checkmark.test.tsx new file mode 100644 index 000000000..c9e204a6e --- /dev/null +++ b/app/soapbox/components/__tests__/validation-checkmark.test.tsx @@ -0,0 +1,29 @@ +import React from 'react'; + +import { render, screen } from '../../jest/test-helpers'; +import ValidationCheckmark from '../validation-checkmark'; + +describe('', () => { + it('renders text', () => { + const text = 'some validation'; + render(); + + expect(screen.getByTestId('validation-checkmark')).toHaveTextContent(text); + }); + + it('uses a green check when valid', () => { + const text = 'some validation'; + render(); + + expect(screen.getByTestId('svg-icon-loader')).toHaveClass('text-success-500'); + expect(screen.getByTestId('svg-icon-loader')).not.toHaveClass('text-gray-400'); + }); + + it('uses a gray check when valid', () => { + const text = 'some validation'; + render(); + + expect(screen.getByTestId('svg-icon-loader')).toHaveClass('text-gray-400'); + expect(screen.getByTestId('svg-icon-loader')).not.toHaveClass('text-success-500'); + }); +}); diff --git a/app/soapbox/components/validation-checkmark.tsx b/app/soapbox/components/validation-checkmark.tsx new file mode 100644 index 000000000..111066ccf --- /dev/null +++ b/app/soapbox/components/validation-checkmark.tsx @@ -0,0 +1,28 @@ +import classNames from 'classnames'; +import React from 'react'; + +import { HStack, Icon, Text } from 'soapbox/components/ui'; + +interface IValidationCheckmark { + isValid: boolean + text: string +} + +const ValidationCheckmark = ({ isValid, text }: IValidationCheckmark) => { + return ( + + + + {text} + + ); +}; + +export default ValidationCheckmark; diff --git a/app/soapbox/features/auth_login/components/password_reset_confirm.tsx b/app/soapbox/features/auth_login/components/password_reset_confirm.tsx index 2f30e700f..a7709834f 100644 --- a/app/soapbox/features/auth_login/components/password_reset_confirm.tsx +++ b/app/soapbox/features/auth_login/components/password_reset_confirm.tsx @@ -4,7 +4,8 @@ import { Redirect } from 'react-router-dom'; import { resetPasswordConfirm } from 'soapbox/actions/security'; import { Button, Form, FormActions, FormGroup, Input } from 'soapbox/components/ui'; -import { useAppDispatch } from 'soapbox/hooks'; +import PasswordIndicator from 'soapbox/features/verification/components/password-indicator'; +import { useAppDispatch, useFeatures } from 'soapbox/hooks'; const token = new URLSearchParams(window.location.search).get('reset_password_token'); @@ -22,9 +23,11 @@ const Statuses = { const PasswordResetConfirm = () => { const intl = useIntl(); const dispatch = useAppDispatch(); + const { passwordRequirements } = useFeatures(); const [password, setPassword] = React.useState(''); const [status, setStatus] = React.useState(Statuses.IDLE); + const [hasValidPassword, setHasValidPassword] = React.useState(passwordRequirements ? false : true); const isLoading = status === Statuses.LOADING; @@ -71,10 +74,14 @@ const PasswordResetConfirm = () => { onChange={onChange} required /> + + {passwordRequirements && ( + + )} - diff --git a/app/soapbox/features/edit_password/index.tsx b/app/soapbox/features/edit_password/index.tsx index e95e6dec4..5cebc3c98 100644 --- a/app/soapbox/features/edit_password/index.tsx +++ b/app/soapbox/features/edit_password/index.tsx @@ -4,7 +4,9 @@ import { defineMessages, useIntl } from 'react-intl'; import { changePassword } from 'soapbox/actions/security'; import snackbar from 'soapbox/actions/snackbar'; import { Button, Card, CardBody, CardHeader, CardTitle, Column, Form, FormActions, FormGroup, Input } from 'soapbox/components/ui'; -import { useAppDispatch } from 'soapbox/hooks'; +import { useAppDispatch, useFeatures } from 'soapbox/hooks'; + +import PasswordIndicator from '../verification/components/password-indicator'; const messages = defineMessages({ updatePasswordSuccess: { id: 'security.update_password.success', defaultMessage: 'Password successfully updated.' }, @@ -22,9 +24,11 @@ const initialState = { currentPassword: '', newPassword: '', newPasswordConfirma const EditPassword = () => { const intl = useIntl(); const dispatch = useAppDispatch(); + const { passwordRequirements } = useFeatures(); const [state, setState] = React.useState(initialState); const [isLoading, setLoading] = React.useState(false); + const [hasValidPassword, setHasValidPassword] = React.useState(passwordRequirements ? false : true); const { currentPassword, newPassword, newPasswordConfirmation } = state; @@ -75,6 +79,10 @@ const EditPassword = () => { onChange={handleInputChange} value={newPassword} /> + + {passwordRequirements && ( + + )} @@ -91,7 +99,7 @@ const EditPassword = () => { {intl.formatMessage(messages.cancel)} - diff --git a/app/soapbox/features/verification/__tests__/registration.test.tsx b/app/soapbox/features/verification/__tests__/registration.test.tsx index ff5da7e97..f82c0dab4 100644 --- a/app/soapbox/features/verification/__tests__/registration.test.tsx +++ b/app/soapbox/features/verification/__tests__/registration.test.tsx @@ -64,4 +64,21 @@ describe('', () => { expect(screen.getByTestId('toast')).toHaveTextContent(/failed to register your account/i); }); }); + + describe('validations', () => { + it('should undisable button with valid password', async() => { + render(); + + expect(screen.getByTestId('button')).toBeDisabled(); + fireEvent.change(screen.getByTestId('password-input'), { target: { value: 'Password' } }); + expect(screen.getByTestId('button')).not.toBeDisabled(); + }); + + it('should disable button with invalid password', async() => { + render(); + + fireEvent.change(screen.getByTestId('password-input'), { target: { value: 'Passwor' } }); + expect(screen.getByTestId('button')).toBeDisabled(); + }); + }); }); diff --git a/app/soapbox/features/verification/components/password-indicator.tsx b/app/soapbox/features/verification/components/password-indicator.tsx new file mode 100644 index 000000000..7b804d3d6 --- /dev/null +++ b/app/soapbox/features/verification/components/password-indicator.tsx @@ -0,0 +1,72 @@ +import React, { useEffect, useMemo } from 'react'; +import { defineMessages, useIntl } from 'react-intl'; + +import { Stack } from 'soapbox/components/ui'; +import ValidationCheckmark from 'soapbox/components/validation-checkmark'; + +const messages = defineMessages({ + minimumCharacters: { + id: 'registration.validation.minimum_characters', + defaultMessage: '8 characters', + }, + capitalLetter: { + id: 'registration.validation.capital_letter', + defaultMessage: '1 capital letter', + }, + lowercaseLetter: { + id: 'registration.validation.lowercase_letter', + defaultMessage: '1 lowercase letter', + }, +}); + +const hasUppercaseCharacter = (string: string) => { + for (let i = 0; i < string.length; i++) { + if (string.charAt(i) === string.charAt(i).toUpperCase() && string.charAt(i).match(/[a-z]/i)) { + return true; + } + } + return false; +}; + +const hasLowercaseCharacter = (string: string) => { + return string.toUpperCase() !== string; +}; + +interface IPasswordIndicator { + onChange(isValid: boolean): void + password: string +} + +const PasswordIndicator = ({ onChange, password }: IPasswordIndicator) => { + const intl = useIntl(); + + const meetsLengthRequirements = useMemo(() => password.length >= 8, [password]); + const meetsCapitalLetterRequirements = useMemo(() => hasUppercaseCharacter(password), [password]); + const meetsLowercaseLetterRequirements = useMemo(() => hasLowercaseCharacter(password), [password]); + const hasValidPassword = meetsLengthRequirements && meetsCapitalLetterRequirements && meetsLowercaseLetterRequirements; + + useEffect(() => { + onChange(hasValidPassword); + }, [hasValidPassword]); + + return ( + + + + + + + + ); +}; + +export default PasswordIndicator; diff --git a/app/soapbox/features/verification/registration.tsx b/app/soapbox/features/verification/registration.tsx index 8b76dce34..c2c09fbce 100644 --- a/app/soapbox/features/verification/registration.tsx +++ b/app/soapbox/features/verification/registration.tsx @@ -13,6 +13,8 @@ import { Button, Form, FormGroup, Input } from 'soapbox/components/ui'; import { useAppSelector } from 'soapbox/hooks'; import { getRedirectUrl } from 'soapbox/utils/redirect'; +import PasswordIndicator from './components/password-indicator'; + const messages = defineMessages({ success: { id: 'registrations.success', @@ -42,12 +44,12 @@ const Registration = () => { const [state, setState] = React.useState(initialState); const [shouldRedirect, setShouldRedirect] = React.useState(false); + const [hasValidPassword, setHasValidPassword] = React.useState(false); const { username, password } = state; const handleSubmit = React.useCallback((event) => { event.preventDefault(); - // TODO: handle validation errors from Pepe dispatch(createAccount(username, password)) .then(() => dispatch(logIn(intl, username, password))) .then(({ access_token }: any) => dispatch(verifyCredentials(access_token))) @@ -118,11 +120,21 @@ const Registration = () => { value={password} onChange={handleInputChange} required + data-testid='password-input' /> + +
- +
diff --git a/app/soapbox/locales/en.json b/app/soapbox/locales/en.json index 9f3894ac5..d6808069a 100644 --- a/app/soapbox/locales/en.json +++ b/app/soapbox/locales/en.json @@ -833,6 +833,9 @@ "registration.sign_up": "Sign up", "registration.tos": "Terms of Service", "registration.username_unavailable": "Username is already taken.", + "registration.validation.minimum_characters": "8 characters", + "registration.validation.capital_letter": "1 capital letter", + "registration.validation.lowercase_letter": "1 lowercase letter", "relative_time.days": "{number}d", "relative_time.hours": "{number}h", "relative_time.just_now": "now", diff --git a/app/soapbox/utils/features.ts b/app/soapbox/utils/features.ts index 891cdd621..8f8a65cc1 100644 --- a/app/soapbox/utils/features.ts +++ b/app/soapbox/utils/features.ts @@ -380,6 +380,14 @@ const getInstanceFeatures = (instance: Instance) => { */ paginatedContext: v.software === TRUTHSOCIAL, + /** + * Require minimum password requirements. + * - 8 characters + * - 1 uppercase + * - 1 lowercase + */ + passwordRequirements: v.software === TRUTHSOCIAL, + /** * Displays a form to follow a user when logged out. * @see POST /main/ostatus