diff --git a/packages/web-ui-registration/src/RegisterForm.spec.tsx b/packages/web-ui-registration/src/RegisterForm.spec.tsx new file mode 100644 index 0000000000000..02725abc0f8fb --- /dev/null +++ b/packages/web-ui-registration/src/RegisterForm.spec.tsx @@ -0,0 +1,379 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { RegisterForm } from './RegisterForm'; + +const mockSetLoginRoute = jest.fn(); +const mockRegisterMethod = jest.fn(); + +// Per-test user instance +let user: ReturnType; + +// Mock the useRegisterMethod hook +jest.mock('./hooks/useRegisterMethod', () => ({ + useRegisterMethod: () => ({ + mutate: mockRegisterMethod, + isPending: false, + }), +})); + +const defaultAppRoot = mockAppRoot() + .withTranslations('en', 'core', { + Required_field: '{{field}} is required', + 'registration.component.form.createAnAccount': 'Create an account', + 'registration.component.form.name': 'Name', + 'registration.component.form.email': 'Email', + 'registration.component.form.username': 'Username', + 'registration.component.form.password': 'Password', + 'registration.component.form.confirmPassword': 'Confirm password', + 'registration.component.form.reasonToJoin': 'Reason to join', + 'registration.component.form.joinYourTeam': 'Join your team', + 'registration.component.form.emailPlaceholder': 'Enter your email', + 'registration.component.form.invalidEmail': 'Invalid email format', + 'registration.component.form.invalidConfirmPass': "Passwords don't match", + 'onboarding.form.adminInfoForm.fields.fullName.placeholder': 'Enter your full name', + Create_a_password: 'Create a password', + Confirm_password: 'Confirm your password', + Password_must_meet_the_complexity_requirements: 'Password must meet complexity requirements', + 'registration.page.register.back': 'Back to Login', + }) + .withSetting('Accounts_RequireNameForSignUp', true) + .withSetting('Accounts_RequirePasswordConfirmation', true) + .withSetting('Accounts_ManuallyApproveNewUsers', false) + .withSetting('Accounts_EmailOrUsernamePlaceholder', '') + .withSetting('Accounts_PasswordPlaceholder', '') + .withSetting('Accounts_ConfirmPasswordPlaceholder', '') + .withSetting('Accounts_Password_Policy_Enabled', false) + .build(); + +describe('RegisterForm', () => { + beforeEach(() => { + jest.clearAllMocks(); + user = userEvent.setup(); + }); + + describe('Rendering', () => { + it('should render all required fields', () => { + render(, { wrapper: defaultAppRoot }); + + expect(screen.getByRole('textbox', { name: /name/i })).toBeInTheDocument(); + expect(screen.getByRole('textbox', { name: /email/i })).toBeInTheDocument(); + expect(screen.getByRole('textbox', { name: /username/i })).toBeInTheDocument(); + expect(screen.getByLabelText(/^password/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /join your team/i })).toBeInTheDocument(); + }); + + it('should render form title', () => { + render(, { wrapper: defaultAppRoot }); + + expect(screen.getByText('Create an account')).toBeInTheDocument(); + }); + + it('should render back to login link', () => { + render(, { wrapper: defaultAppRoot }); + + expect(screen.getByText(/back to login/i)).toBeInTheDocument(); + }); + }); + + describe('Conditional Fields', () => { + it('should render name field as optional when not required', () => { + const appRoot = mockAppRoot() + .withTranslations('en', 'core', { + 'registration.component.form.name': 'Name', + }) + .withSetting('Accounts_RequireNameForSignUp', false) + .build(); + + render(, { wrapper: appRoot }); + + const nameField = screen.getByRole('textbox', { name: /name/i }); + expect(nameField).toBeInTheDocument(); + expect(nameField).not.toHaveAttribute('aria-required', 'true'); + }); + + it('should render password confirmation field when enabled', () => { + const appRoot = mockAppRoot() + .withTranslations('en', 'core', { + 'registration.component.form.confirmPassword': 'Confirm password', + }) + .withSetting('Accounts_RequirePasswordConfirmation', true) + .withSetting('Accounts_Password_Policy_Enabled', false) + .build(); + + render(, { wrapper: appRoot }); + + expect(screen.getByLabelText(/confirm password/i)).toBeInTheDocument(); + }); + + it('should NOT render password confirmation field when disabled', () => { + const appRoot = mockAppRoot() + .withTranslations('en', 'core', { + 'registration.component.form.password': 'Password', + }) + .withSetting('Accounts_RequirePasswordConfirmation', false) + .withSetting('Accounts_Password_Policy_Enabled', false) + .build(); + + render(, { wrapper: appRoot }); + + expect(screen.queryByLabelText(/confirm password/i)).not.toBeInTheDocument(); + }); + + it('should render reason field when manual approval is required', () => { + const appRoot = mockAppRoot() + .withTranslations('en', 'core', { + 'registration.component.form.reasonToJoin': 'Reason to join', + }) + .withSetting('Accounts_ManuallyApproveNewUsers', true) + .build(); + + render(, { wrapper: appRoot }); + + expect(screen.getByLabelText(/reason to join/i)).toBeInTheDocument(); + }); + + it('should NOT render reason field when manual approval is not required', () => { + const appRoot = mockAppRoot() + .withSetting('Accounts_ManuallyApproveNewUsers', false) + .build(); + + render(, { wrapper: appRoot }); + + expect(screen.queryByLabelText(/reason to join/i)).not.toBeInTheDocument(); + }); + }); + + describe('Validation', () => { + it('should show required error for name field', async () => { + const appRoot = mockAppRoot() + .withTranslations('en', 'core', { + Required_field: '{{field}} is required', + 'registration.component.form.name': 'Name', + }) + .withSetting('Accounts_RequireNameForSignUp', true) + .build(); + + render(, { wrapper: appRoot }); + + const nameInput = screen.getByRole('textbox', { name: /name/i }); + await user.click(nameInput); + await user.tab(); + + await waitFor(() => { + expect(nameInput).toHaveAccessibleDescription(/name is required/i); + }); + }); + + it('should show required error for email field', async () => { + const appRoot = mockAppRoot() + .withTranslations('en', 'core', { + Required_field: '{{field}} is required', + 'registration.component.form.email': 'Email', + }) + .build(); + + render(, { wrapper: appRoot }); + + const emailInput = screen.getByRole('textbox', { name: /email/i }); + await user.click(emailInput); + await user.tab(); + + await waitFor(() => { + expect(emailInput).toHaveAccessibleDescription(/email is required/i); + }); + }); + + it('should show invalid email format error', async () => { + const appRoot = mockAppRoot() + .withTranslations('en', 'core', { + 'registration.component.form.invalidEmail': 'Invalid email format', + }) + .build(); + + render(, { wrapper: appRoot }); + + const emailInput = screen.getByRole('textbox', { name: /email/i }); + await user.type(emailInput, 'invalid-email'); + await user.tab(); + + await waitFor(() => { + expect(emailInput).toHaveAccessibleDescription(/invalid email format/i); + }); + }); + + it('should accept valid email format', async () => { + render(, { wrapper: defaultAppRoot }); + + const emailInput = screen.getByRole('textbox', { name: /email/i }); + await user.type(emailInput, 'test@example.com'); + await user.tab(); + + await waitFor(() => { + expect(emailInput).not.toHaveAccessibleDescription(); + }); + }); + + it('should show required error for username field', async () => { + const appRoot = mockAppRoot() + .withTranslations('en', 'core', { + Required_field: '{{field}} is required', + 'registration.component.form.username': 'Username', + }) + .build(); + + render(, { wrapper: appRoot }); + + const usernameInput = screen.getByRole('textbox', { name: /username/i }); + await user.click(usernameInput); + await user.tab(); + + await waitFor(() => { + expect(usernameInput).toHaveAccessibleDescription(/username is required/i); + }); + }); + + it('should show password mismatch error', async () => { + const appRoot = mockAppRoot() + .withTranslations('en', 'core', { + 'registration.component.form.password': 'Password', + 'registration.component.form.confirmPassword': 'Confirm password', + 'registration.component.form.invalidConfirmPass': "Passwords don't match", + }) + .withSetting('Accounts_RequirePasswordConfirmation', true) + .withSetting('Accounts_Password_Policy_Enabled', false) + .build(); + + render(, { wrapper: appRoot }); + + const passwordInput = screen.getByLabelText(/^password/i); + const confirmPasswordInput = screen.getByLabelText(/confirm password/i); + + await user.type(passwordInput, 'password123'); + await user.type(confirmPasswordInput, 'password456'); + await user.tab(); + + await waitFor(() => { + expect(confirmPasswordInput).toHaveAccessibleDescription(/passwords don't match/i); + }); + }); + }); + + describe('Form Submission', () => { + it('should call register mutation with correct data on valid submission', async () => { + const appRoot = mockAppRoot() + .withTranslations('en', 'core', { + 'registration.component.form.name': 'Name', + 'registration.component.form.email': 'Email', + 'registration.component.form.username': 'Username', + 'registration.component.form.password': 'Password', + 'registration.component.form.joinYourTeam': 'Join your team', + 'onboarding.form.adminInfoForm.fields.fullName.placeholder': 'Full Name', + }) + .withSetting('Accounts_RequirePasswordConfirmation', false) + .withSetting('Accounts_Password_Policy_Enabled', false) + .build(); + + render(, { wrapper: appRoot }); + + // Fill in the form + await user.type(screen.getByRole('textbox', { name: /name/i }), 'John Doe'); + await user.type(screen.getByRole('textbox', { name: /email/i }), 'john@example.com'); + await user.type(screen.getByRole('textbox', { name: /username/i }), 'johndoe'); + await user.type(screen.getByLabelText(/password/i), 'password123'); + + // Submit the form + await user.click(screen.getByRole('button', { name: /join your team/i })); + + await waitFor(() => { + expect(mockRegisterMethod).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'John Doe', + email: 'john@example.com', + username: 'johndoe', + pass: 'password123', + }), + expect.any(Object), + ); + }); + }); + + it('should not include passwordConfirmation in submitted data', async () => { + const appRoot = mockAppRoot() + .withTranslations('en', 'core', { + 'registration.component.form.password': 'Password', + 'registration.component.form.confirmPassword': 'Confirm password', + }) + .withSetting('Accounts_RequirePasswordConfirmation', true) + .withSetting('Accounts_Password_Policy_Enabled', false) + .build(); + + render(, { wrapper: appRoot }); + + await user.type(screen.getByRole('textbox', { name: /name/i }), 'John Doe'); + await user.type(screen.getByRole('textbox', { name: /email/i }), 'john@example.com'); + await user.type(screen.getByRole('textbox', { name: /username/i }), 'johndoe'); + await user.type(screen.getByLabelText(/^password/i), 'password123'); + await user.type(screen.getByLabelText(/confirm password/i), 'password123'); + + await user.click(screen.getByRole('button', { name: /join your team/i })); + + await waitFor(() => { + expect(mockRegisterMethod).toHaveBeenCalled(); + const callArgs = mockRegisterMethod.mock.calls[0][0]; + expect(callArgs).not.toHaveProperty('passwordConfirmation'); + expect(callArgs).toHaveProperty('pass', 'password123'); + }); + }); + }); + + describe('Accessibility', () => { + it('should have proper ARIA labels', () => { + render(, { wrapper: defaultAppRoot }); + + const form = screen.getByRole('form'); + expect(form).toHaveAttribute('aria-labelledby'); + expect(form).toHaveAttribute('aria-describedby', 'welcomeTitle'); + }); + + it('should have proper ARIA attributes on input fields', () => { + render(, { wrapper: defaultAppRoot }); + + const emailInput = screen.getByRole('textbox', { name: /email/i }); + expect(emailInput).toHaveAttribute('aria-required', 'true'); + expect(emailInput).toHaveAttribute('aria-describedby'); + }); + }); + + describe('Back to Login', () => { + it('should call setLoginRoute when back to login is clicked', async () => { + render(, { wrapper: defaultAppRoot }); + + const backLink = screen.getByText(/back to login/i); + await user.click(backLink); + + expect(mockSetLoginRoute).toHaveBeenCalledWith('login'); + }); + }); + + describe('Custom Fields Integration', () => { + it('should render custom fields when available', () => { + // Mock custom fields to verify CustomFieldsForm integration + const mockCustomFields = [ + { _id: 'field1', type: 'text', label: 'Custom Field 1', required: true, defaultValue: '', public: true }, + ]; + + const appRoot = mockAppRoot() + .withTranslations('en', 'core', {}) + .withEndpoint('GET', '/v1/custom-fields.public', () => ({ customFields: mockCustomFields })) + .build(); + + render(, { wrapper: appRoot }); + + // Verify CustomFieldsForm integration - detailed tests are in ui-client + const form = screen.getByRole('form'); + expect(form).toBeInTheDocument(); + }); + }); +}); diff --git a/packages/web-ui-registration/src/RegisterForm.tsx b/packages/web-ui-registration/src/RegisterForm.tsx index 9b7f3e548d3eb..326a352ec5692 100644 --- a/packages/web-ui-registration/src/RegisterForm.tsx +++ b/packages/web-ui-registration/src/RegisterForm.tsx @@ -1,20 +1,7 @@ -/* eslint-disable complexity */ -import { - FieldGroup, - TextInput, - Field, - FieldLabel, - FieldRow, - FieldError, - PasswordInput, - ButtonGroup, - Button, - TextAreaInput, - Callout, -} from '@rocket.chat/fuselage'; +import { FieldGroup, ButtonGroup, Button, Callout } from '@rocket.chat/fuselage'; import { Form, ActionLink } from '@rocket.chat/layout'; -import { CustomFieldsForm, PasswordVerifier, useValidatePassword } from '@rocket.chat/ui-client'; -import { useAccountsCustomFields, useSetting, useToastMessageDispatch } from '@rocket.chat/ui-contexts'; +import { CustomFieldsForm } from '@rocket.chat/ui-client'; +import { useAccountsCustomFields, useSetting } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; import { useEffect, useId, useRef, useState } from 'react'; import { useForm } from 'react-hook-form'; @@ -23,6 +10,10 @@ import { Trans, useTranslation } from 'react-i18next'; import EmailConfirmationForm from './EmailConfirmationForm'; import type { DispatchLoginRouter } from './hooks/useLoginRouter'; import { useRegisterMethod } from './hooks/useRegisterMethod'; +import { useRegisterFormValidation } from './hooks/useRegisterFormValidation'; +import { useRegisterErrorHandler } from './hooks/useRegisterErrorHandler'; +import { FormFieldInput } from './components/FormFieldInput'; +import { PasswordFieldWithVerifier } from './components/PasswordFieldWithVerifier'; type LoginRegisterPayload = { name: string; @@ -36,10 +27,6 @@ type LoginRegisterPayload = { export const RegisterForm = ({ setLoginRoute }: { setLoginRoute: DispatchLoginRouter }): ReactElement => { const { t } = useTranslation(); - const requireNameForRegister = useSetting('Accounts_RequireNameForSignUp', true); - const requiresPasswordConfirmation = useSetting('Accounts_RequirePasswordConfirmation', true); - const manuallyApproveNewUsersRequired = useSetting('Accounts_ManuallyApproveNewUsers', false); - const usernameOrEmailPlaceholder = useSetting('Accounts_EmailOrUsernamePlaceholder', ''); const passwordPlaceholder = useSetting('Accounts_PasswordPlaceholder', ''); const passwordConfirmationPlaceholder = useSetting('Accounts_ConfirmPasswordPlaceholder', ''); @@ -58,8 +45,6 @@ export const RegisterForm = ({ setLoginRoute }: { setLoginRoute: DispatchLoginRo const [serverError, setServerError] = useState(undefined); - const dispatchToastMessage = useToastMessageDispatch(); - const { register, handleSubmit, @@ -71,8 +56,10 @@ export const RegisterForm = ({ setLoginRoute }: { setLoginRoute: DispatchLoginRo formState: { errors }, } = useForm({ mode: 'onBlur' }); - const { password } = watch(); - const passwordIsValid = useValidatePassword(password); + const { validationRules, requireNameForRegister, requiresPasswordConfirmation, manuallyApproveNewUsersRequired, passwordIsValid } = + useRegisterFormValidation(watch); + + const { handleRegisterError } = useRegisterErrorHandler(setError, setServerError, setLoginRoute); const registerFormRef = useRef(null); @@ -86,39 +73,7 @@ export const RegisterForm = ({ setLoginRoute }: { setLoginRoute: DispatchLoginRo registerUser.mutate( { pass: password, ...formData }, { - onError: (error: any) => { - if ([error.error, error.errorType].includes('error-invalid-email')) { - setError('email', { type: 'invalid-email', message: t('registration.component.form.invalidEmail') }); - } - if (error.errorType === 'error-user-already-exists') { - setError('username', { type: 'user-already-exists', message: t('registration.component.form.usernameAlreadyExists') }); - } - if (/Email already exists/.test(error.error)) { - setError('email', { type: 'email-already-exists', message: t('registration.component.form.emailAlreadyExists') }); - } - if (/Username is already in use/.test(error.error)) { - setError('username', { type: 'username-already-exists', message: t('registration.component.form.userAlreadyExist') }); - } - if (/The username provided is not valid/.test(error.error)) { - setError('username', { - type: 'username-contains-invalid-chars', - message: t('registration.component.form.usernameContainsInvalidChars'), - }); - } - if (/Name contains invalid characters/.test(error.error)) { - setError('name', { type: 'name-contains-invalid-chars', message: t('registration.component.form.nameContainsInvalidChars') }); - } - if (/error-too-many-requests/.test(error.error)) { - dispatchToastMessage({ type: 'error', message: error.error }); - } - if (/error-user-is-not-activated/.test(error.error)) { - dispatchToastMessage({ type: 'info', message: t('registration.page.registration.waitActivationWarning') }); - setLoginRoute('login'); - } - if (error.error === 'error-user-registration-custom-field') { - setServerError(error.message); - } - }, + onError: handleRegisterError, }, ); }; @@ -140,155 +95,55 @@ export const RegisterForm = ({ setLoginRoute }: { setLoginRoute: DispatchLoginRo - - - {t('registration.component.form.name')} - - - - - {errors.name && ( - - {errors.name.message} - - )} - - - - {t('registration.component.form.email')} - - - - - {errors.email && ( - - {errors.email.message} - - )} - - - - {t('registration.component.form.username')} - - - - - {errors.username && ( - - {errors.username.message} - - )} - - - - {t('registration.component.form.password')} - - - (!passwordIsValid ? t('Password_must_meet_the_complexity_requirements') : true), - })} - error={errors.password?.message} - aria-required='true' - aria-invalid={errors.password ? 'true' : undefined} - id={passwordId} - placeholder={passwordPlaceholder || t('Create_a_password')} - aria-describedby={`${passwordVerifierId} ${passwordId}-error`} - /> - - {errors?.password && ( - - {errors.password.message} - - )} - - - {requiresPasswordConfirmation && ( - - - {t('registration.component.form.confirmPassword')} - - - (watch('password') === val ? true : t('registration.component.form.invalidConfirmPass')), - })} - error={errors.passwordConfirmation?.message} - aria-required='true' - aria-invalid={errors.passwordConfirmation ? 'true' : 'false'} - id={passwordConfirmationId} - aria-describedby={`${passwordConfirmationId}-error`} - placeholder={passwordConfirmationPlaceholder || t('Confirm_password')} - disabled={!passwordIsValid} - /> - - {errors.passwordConfirmation && ( - - {errors.passwordConfirmation.message} - - )} - - )} + + + + {manuallyApproveNewUsersRequired && ( - - - {t('registration.component.form.reasonToJoin')} - - - - - {errors.reason && ( - - {errors.reason.message} - - )} - + )} {serverError && {serverError}} diff --git a/packages/web-ui-registration/src/components/FormFieldInput.spec.tsx b/packages/web-ui-registration/src/components/FormFieldInput.spec.tsx new file mode 100644 index 0000000000000..4b11b6df38ccf --- /dev/null +++ b/packages/web-ui-registration/src/components/FormFieldInput.spec.tsx @@ -0,0 +1,118 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { render, screen } from '@testing-library/react'; + +import { FormFieldInput } from './FormFieldInput'; + +const mockRegister = { + onChange: jest.fn(), + onBlur: jest.fn(), + ref: jest.fn(), + name: 'testField', +}; + +const defaultAppRoot = mockAppRoot().build(); + +describe('FormFieldInput', () => { + it('should render text input with label', () => { + render( + , + { wrapper: defaultAppRoot }, + ); + + expect(screen.getByLabelText('Test Field')).toBeInTheDocument(); + expect(screen.getByRole('textbox')).toBeInTheDocument(); + }); + + it('should render textarea when type is textarea', () => { + render( + , + { wrapper: defaultAppRoot }, + ); + + const textarea = screen.getByLabelText('Test Field'); + expect(textarea.tagName.toLowerCase()).toBe('textarea'); + }); + + it('should mark field as required', () => { + render( + , + { wrapper: defaultAppRoot }, + ); + + const input = screen.getByRole('textbox'); + expect(input).toHaveAttribute('aria-required', 'true'); + }); + + it('should not mark field as required when not specified', () => { + render( + , + { wrapper: defaultAppRoot }, + ); + + const input = screen.getByRole('textbox'); + expect(input).toHaveAttribute('aria-required', 'false'); + }); + + it('should display error message', () => { + const error = { type: 'required', message: 'This field is required' }; + + render( + , + { wrapper: defaultAppRoot }, + ); + + expect(screen.getByText('This field is required')).toBeInTheDocument(); + expect(screen.getByRole('alert')).toBeInTheDocument(); + }); + + it('should not display error when no error provided', () => { + render( + , + { wrapper: defaultAppRoot }, + ); + + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + + it('should set aria-invalid to true when error exists', () => { + const error = { type: 'required', message: 'Error' }; + + render( + , + { wrapper: defaultAppRoot }, + ); + + expect(screen.getByRole('textbox')).toHaveAttribute('aria-invalid', 'true'); + }); + + it('should set aria-invalid to false when no error', () => { + render( + , + { wrapper: defaultAppRoot }, + ); + + expect(screen.getByRole('textbox')).toHaveAttribute('aria-invalid', 'false'); + }); + + it('should apply placeholder', () => { + render( + , + { wrapper: defaultAppRoot }, + ); + + expect(screen.getByPlaceholderText('Enter text')).toBeInTheDocument(); + }); + + it('should have proper aria-describedby for error', () => { + const error = { type: 'required', message: 'Error' }; + + render( + , + { wrapper: defaultAppRoot }, + ); + + const input = screen.getByRole('textbox'); + expect(input).toHaveAttribute('aria-describedby', 'test-id-error'); + expect(screen.getByRole('alert')).toHaveAttribute('id', 'test-id-error'); + }); +}); diff --git a/packages/web-ui-registration/src/components/FormFieldInput.tsx b/packages/web-ui-registration/src/components/FormFieldInput.tsx new file mode 100644 index 0000000000000..2fc1a61edebdf --- /dev/null +++ b/packages/web-ui-registration/src/components/FormFieldInput.tsx @@ -0,0 +1,49 @@ +import { Field, FieldLabel, FieldRow, FieldError, TextInput, TextAreaInput } from '@rocket.chat/fuselage'; +import type { ComponentProps, ReactElement } from 'react'; +import type { FieldError as FieldErrorType, UseFormRegisterReturn } from 'react-hook-form'; + +type FormFieldInputProps = { + label: string; + fieldId: string; + required?: boolean; + error?: FieldErrorType; + placeholder?: string; + register: UseFormRegisterReturn; + type?: 'text' | 'textarea'; +}; + +export const FormFieldInput = ({ + label, + fieldId, + required = false, + error, + placeholder, + register, + type = 'text', +}: FormFieldInputProps): ReactElement => { + const InputComponent = type === 'textarea' ? TextAreaInput : TextInput; + + return ( + + + {label} + + + + + {error && ( + + {error.message} + + )} + + ); +}; diff --git a/packages/web-ui-registration/src/components/PasswordFieldWithVerifier.spec.tsx b/packages/web-ui-registration/src/components/PasswordFieldWithVerifier.spec.tsx new file mode 100644 index 0000000000000..123b073734aed --- /dev/null +++ b/packages/web-ui-registration/src/components/PasswordFieldWithVerifier.spec.tsx @@ -0,0 +1,210 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { render, screen } from '@testing-library/react'; + +import { PasswordFieldWithVerifier } from './PasswordFieldWithVerifier'; + +const mockPasswordRegister = { + onChange: jest.fn(), + onBlur: jest.fn(), + ref: jest.fn(), + name: 'password', +}; + +const mockConfirmRegister = { + onChange: jest.fn(), + onBlur: jest.fn(), + ref: jest.fn(), + name: 'passwordConfirmation', +}; + +const defaultAppRoot = mockAppRoot() + .withTranslations('en', 'core', { + 'registration.component.form.password': 'Password', + 'registration.component.form.confirmPassword': 'Confirm password', + Create_a_password: 'Create a password', + Confirm_password: 'Confirm your password', + }) + .withSetting('Accounts_Password_Policy_Enabled', false) + .build(); + +describe('PasswordFieldWithVerifier', () => { + it('should render password field', () => { + render( + , + { wrapper: defaultAppRoot }, + ); + + expect(screen.getByLabelText(/^password/i)).toBeInTheDocument(); + }); + + it('should render password confirmation when required', () => { + render( + , + { wrapper: defaultAppRoot }, + ); + + expect(screen.getByLabelText(/confirm password/i)).toBeInTheDocument(); + }); + + it('should NOT render password confirmation when not required', () => { + render( + , + { wrapper: defaultAppRoot }, + ); + + expect(screen.queryByLabelText(/confirm password/i)).not.toBeInTheDocument(); + }); + + it('should disable confirmation field when password is invalid', () => { + render( + , + { wrapper: defaultAppRoot }, + ); + + expect(screen.getByLabelText(/confirm password/i)).toBeDisabled(); + }); + + it('should enable confirmation field when password is valid', () => { + render( + , + { wrapper: defaultAppRoot }, + ); + + expect(screen.getByLabelText(/confirm password/i)).not.toBeDisabled(); + }); + + it('should display password error', () => { + const error = { type: 'required', message: 'Password is required' }; + + render( + , + { wrapper: defaultAppRoot }, + ); + + expect(screen.getByText('Password is required')).toBeInTheDocument(); + }); + + it('should display password confirmation error', () => { + const error = { type: 'validate', message: "Passwords don't match" }; + + render( + , + { wrapper: defaultAppRoot }, + ); + + expect(screen.getByText("Passwords don't match")).toBeInTheDocument(); + }); + + it('should apply password placeholder', () => { + render( + , + { wrapper: defaultAppRoot }, + ); + + expect(screen.getByPlaceholderText('Enter strong password')).toBeInTheDocument(); + }); + + it('should apply confirmation placeholder', () => { + render( + , + { wrapper: defaultAppRoot }, + ); + + expect(screen.getByPlaceholderText('Re-enter password')).toBeInTheDocument(); + }); + + it('should have proper aria attributes', () => { + render( + , + { wrapper: defaultAppRoot }, + ); + + const passwordInput = screen.getByLabelText(/^password/i); + expect(passwordInput).toHaveAttribute('aria-required', 'true'); + expect(passwordInput).toHaveAttribute('aria-describedby'); + }); +}); diff --git a/packages/web-ui-registration/src/components/PasswordFieldWithVerifier.tsx b/packages/web-ui-registration/src/components/PasswordFieldWithVerifier.tsx new file mode 100644 index 0000000000000..fe39c54250186 --- /dev/null +++ b/packages/web-ui-registration/src/components/PasswordFieldWithVerifier.tsx @@ -0,0 +1,88 @@ +import { Field, FieldLabel, FieldRow, FieldError, PasswordInput } from '@rocket.chat/fuselage'; +import { PasswordVerifier } from '@rocket.chat/ui-client'; +import type { ReactElement } from 'react'; +import type { FieldError as FieldErrorType, UseFormRegisterReturn } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; + +type PasswordFieldWithVerifierProps = { + passwordId: string; + passwordVerifierId: string; + passwordConfirmationId?: string; + passwordError?: FieldErrorType; + passwordConfirmationError?: FieldErrorType; + passwordRegister: UseFormRegisterReturn; + passwordConfirmationRegister?: UseFormRegisterReturn; + password: string; + passwordIsValid: boolean; + requiresPasswordConfirmation: boolean; + passwordPlaceholder?: string; + passwordConfirmationPlaceholder?: string; +}; + +export const PasswordFieldWithVerifier = ({ + passwordId, + passwordVerifierId, + passwordConfirmationId, + passwordError, + passwordConfirmationError, + passwordRegister, + passwordConfirmationRegister, + password, + passwordIsValid, + requiresPasswordConfirmation, + passwordPlaceholder, + passwordConfirmationPlaceholder, +}: PasswordFieldWithVerifierProps): ReactElement => { + const { t } = useTranslation(); + + return ( + <> + + + {t('registration.component.form.password')} + + + + + {passwordError && ( + + {passwordError.message} + + )} + + + {requiresPasswordConfirmation && passwordConfirmationId && passwordConfirmationRegister && ( + + + {t('registration.component.form.confirmPassword')} + + + + + {passwordConfirmationError && ( + + {passwordConfirmationError.message} + + )} + + )} + + ); +}; diff --git a/packages/web-ui-registration/src/hooks/useRegisterErrorHandler.spec.tsx b/packages/web-ui-registration/src/hooks/useRegisterErrorHandler.spec.tsx new file mode 100644 index 0000000000000..b68ede23bde43 --- /dev/null +++ b/packages/web-ui-registration/src/hooks/useRegisterErrorHandler.spec.tsx @@ -0,0 +1,160 @@ +import { renderHook } from '@testing-library/react'; +import { mockAppRoot } from '@rocket.chat/mock-providers'; + +import { useRegisterErrorHandler } from './useRegisterErrorHandler'; + +const mockSetError = jest.fn(); +const mockSetServerError = jest.fn(); +const mockSetLoginRoute = jest.fn(); +const mockDispatchToastMessage = jest.fn(); + +// Mock the useToastMessageDispatch hook +jest.mock('@rocket.chat/ui-contexts', () => ({ + ...jest.requireActual('@rocket.chat/ui-contexts'), + useToastMessageDispatch: () => mockDispatchToastMessage, +})); + +const defaultAppRoot = mockAppRoot() + .withTranslations('en', 'core', { + 'registration.component.form.invalidEmail': 'Invalid email format', + 'registration.component.form.usernameAlreadyExists': 'Username already exists', + 'registration.component.form.emailAlreadyExists': 'Email already exists', + 'registration.component.form.userAlreadyExist': 'User already exists', + 'registration.component.form.usernameContainsInvalidChars': 'Username contains invalid characters', + 'registration.component.form.nameContainsInvalidChars': 'Name contains invalid characters', + 'registration.page.registration.waitActivationWarning': 'Please wait for activation', + }) + .build(); + +describe('useRegisterErrorHandler', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should handle error-invalid-email error', () => { + const { result } = renderHook(() => useRegisterErrorHandler(mockSetError, mockSetServerError, mockSetLoginRoute), { + wrapper: defaultAppRoot, + }); + + const error = { error: 'error-invalid-email' }; + result.current.handleRegisterError(error); + + expect(mockSetError).toHaveBeenCalledWith('email', { + type: 'invalid-email', + message: 'Invalid email format', + }); + }); + + it('should handle error-user-already-exists error', () => { + const { result } = renderHook(() => useRegisterErrorHandler(mockSetError, mockSetServerError, mockSetLoginRoute), { + wrapper: defaultAppRoot, + }); + + const error = { errorType: 'error-user-already-exists' }; + result.current.handleRegisterError(error); + + expect(mockSetError).toHaveBeenCalledWith('username', { + type: 'user-already-exists', + message: 'Username already exists', + }); + }); + + it('should handle "Email already exists" error', () => { + const { result } = renderHook(() => useRegisterErrorHandler(mockSetError, mockSetServerError, mockSetLoginRoute), { + wrapper: defaultAppRoot, + }); + + const error = { error: 'Email already exists for user' }; + result.current.handleRegisterError(error); + + expect(mockSetError).toHaveBeenCalledWith('email', { + type: 'email-already-exists', + message: 'Email already exists', + }); + }); + + it('should handle "Username is already in use" error', () => { + const { result } = renderHook(() => useRegisterErrorHandler(mockSetError, mockSetServerError, mockSetLoginRoute), { + wrapper: defaultAppRoot, + }); + + const error = { error: 'Username is already in use' }; + result.current.handleRegisterError(error); + + expect(mockSetError).toHaveBeenCalledWith('username', { + type: 'username-already-exists', + message: 'User already exists', + }); + }); + + it('should handle "The username provided is not valid" error', () => { + const { result } = renderHook(() => useRegisterErrorHandler(mockSetError, mockSetServerError, mockSetLoginRoute), { + wrapper: defaultAppRoot, + }); + + const error = { error: 'The username provided is not valid' }; + result.current.handleRegisterError(error); + + expect(mockSetError).toHaveBeenCalledWith('username', { + type: 'username-contains-invalid-chars', + message: 'Username contains invalid characters', + }); + }); + + it('should handle "Name contains invalid characters" error', () => { + const { result } = renderHook(() => useRegisterErrorHandler(mockSetError, mockSetServerError, mockSetLoginRoute), { + wrapper: defaultAppRoot, + }); + + const error = { error: 'Name contains invalid characters' }; + result.current.handleRegisterError(error); + + expect(mockSetError).toHaveBeenCalledWith('name', { + type: 'name-contains-invalid-chars', + message: 'Name contains invalid characters', + }); + }); + + it('should handle error-too-many-requests error with toast', () => { + const { result } = renderHook(() => useRegisterErrorHandler(mockSetError, mockSetServerError, mockSetLoginRoute), { + wrapper: defaultAppRoot, + }); + + const error = { error: 'error-too-many-requests' }; + result.current.handleRegisterError(error); + + expect(mockDispatchToastMessage).toHaveBeenCalledWith({ + type: 'error', + message: 'error-too-many-requests', + }); + }); + + it('should handle error-user-is-not-activated error', () => { + const { result } = renderHook(() => useRegisterErrorHandler(mockSetError, mockSetServerError, mockSetLoginRoute), { + wrapper: defaultAppRoot, + }); + + const error = { error: 'error-user-is-not-activated' }; + result.current.handleRegisterError(error); + + expect(mockDispatchToastMessage).toHaveBeenCalledWith({ + type: 'info', + message: 'Please wait for activation', + }); + expect(mockSetLoginRoute).toHaveBeenCalledWith('login'); + }); + + it('should handle error-user-registration-custom-field error', () => { + const { result } = renderHook(() => useRegisterErrorHandler(mockSetError, mockSetServerError, mockSetLoginRoute), { + wrapper: defaultAppRoot, + }); + + const error = { + error: 'error-user-registration-custom-field', + message: 'Custom field validation failed', + }; + result.current.handleRegisterError(error); + + expect(mockSetServerError).toHaveBeenCalledWith('Custom field validation failed'); + }); +}); diff --git a/packages/web-ui-registration/src/hooks/useRegisterErrorHandler.ts b/packages/web-ui-registration/src/hooks/useRegisterErrorHandler.ts new file mode 100644 index 0000000000000..5677e84074f0e --- /dev/null +++ b/packages/web-ui-registration/src/hooks/useRegisterErrorHandler.ts @@ -0,0 +1,66 @@ +import type { UseFormSetError } from 'react-hook-form'; +import { useToastMessageDispatch } from '@rocket.chat/ui-contexts'; +import { useTranslation } from 'react-i18next'; +import type { DispatchLoginRouter } from './useLoginRouter'; + +type LoginRegisterPayload = { + name: string; + passwordConfirmation: string; + username: string; + password: string; + email: string; + reason: string; +}; + +export const useRegisterErrorHandler = ( + setError: UseFormSetError, + setServerError: (error: string | undefined) => void, + setLoginRoute: DispatchLoginRouter, +) => { + const { t } = useTranslation(); + const dispatchToastMessage = useToastMessageDispatch(); + + const handleRegisterError = (error: any) => { + if ([error.error, error.errorType].includes('error-invalid-email')) { + setError('email', { type: 'invalid-email', message: t('registration.component.form.invalidEmail') }); + } + + if (error.errorType === 'error-user-already-exists') { + setError('username', { type: 'user-already-exists', message: t('registration.component.form.usernameAlreadyExists') }); + } + + if (/Email already exists/.test(error.error)) { + setError('email', { type: 'email-already-exists', message: t('registration.component.form.emailAlreadyExists') }); + } + + if (/Username is already in use/.test(error.error)) { + setError('username', { type: 'username-already-exists', message: t('registration.component.form.userAlreadyExist') }); + } + + if (/The username provided is not valid/.test(error.error)) { + setError('username', { + type: 'username-contains-invalid-chars', + message: t('registration.component.form.usernameContainsInvalidChars'), + }); + } + + if (/Name contains invalid characters/.test(error.error)) { + setError('name', { type: 'name-contains-invalid-chars', message: t('registration.component.form.nameContainsInvalidChars') }); + } + + if (/error-too-many-requests/.test(error.error)) { + dispatchToastMessage({ type: 'error', message: error.error }); + } + + if (/error-user-is-not-activated/.test(error.error)) { + dispatchToastMessage({ type: 'info', message: t('registration.page.registration.waitActivationWarning') }); + setLoginRoute('login'); + } + + if (error.error === 'error-user-registration-custom-field') { + setServerError(error.message); + } + }; + + return { handleRegisterError }; +}; diff --git a/packages/web-ui-registration/src/hooks/useRegisterFormValidation.spec.tsx b/packages/web-ui-registration/src/hooks/useRegisterFormValidation.spec.tsx new file mode 100644 index 0000000000000..a16644a525268 --- /dev/null +++ b/packages/web-ui-registration/src/hooks/useRegisterFormValidation.spec.tsx @@ -0,0 +1,114 @@ +import { renderHook } from '@testing-library/react'; +import { mockAppRoot } from '@rocket.chat/mock-providers'; + +import { useRegisterFormValidation } from './useRegisterFormValidation'; + +const mockWatch = jest.fn(); + +const defaultAppRoot = mockAppRoot() + .withTranslations('en', 'core', { + Required_field: '{{field}} is required', + 'registration.component.form.name': 'Name', + 'registration.component.form.email': 'Email', + 'registration.component.form.username': 'Username', + 'registration.component.form.password': 'Password', + 'registration.component.form.confirmPassword': 'Confirm password', + 'registration.component.form.reasonToJoin': 'Reason to join', + 'registration.component.form.invalidEmail': 'Invalid email format', + 'registration.component.form.invalidConfirmPass': "Passwords don't match", + Password_must_meet_the_complexity_requirements: 'Password must meet complexity requirements', + }) + .withSetting('Accounts_RequireNameForSignUp', true) + .withSetting('Accounts_RequirePasswordConfirmation', true) + .withSetting('Accounts_ManuallyApproveNewUsers', false) + .withSetting('Accounts_Password_Policy_Enabled', false) + .build(); + +describe('useRegisterFormValidation', () => { + beforeEach(() => { + mockWatch.mockReturnValue({ password: '' }); + }); + + it('should return validation rules', () => { + const { result } = renderHook(() => useRegisterFormValidation(mockWatch as any), { + wrapper: defaultAppRoot, + }); + + expect(result.current.validationRules).toBeDefined(); + expect(result.current.validationRules.name).toBeDefined(); + expect(result.current.validationRules.email).toBeDefined(); + expect(result.current.validationRules.username).toBeDefined(); + expect(result.current.validationRules.password).toBeDefined(); + expect(result.current.validationRules.passwordConfirmation).toBeDefined(); + expect(result.current.validationRules.reason).toBeDefined(); + }); + + it('should return settings values', () => { + const { result } = renderHook(() => useRegisterFormValidation(mockWatch as any), { + wrapper: defaultAppRoot, + }); + + expect(result.current.requireNameForRegister).toBe(true); + expect(result.current.requiresPasswordConfirmation).toBe(true); + expect(result.current.manuallyApproveNewUsersRequired).toBe(false); + }); + + it('should set name as optional when not required', () => { + const appRoot = mockAppRoot() + .withTranslations('en', 'core', { + 'registration.component.form.name': 'Name', + }) + .withSetting('Accounts_RequireNameForSignUp', false) + .build(); + + const { result } = renderHook(() => useRegisterFormValidation(mockWatch as any), { + wrapper: appRoot, + }); + + expect(result.current.validationRules.name.required).toBe(false); + expect(result.current.requireNameForRegister).toBe(false); + }); + + it('should include email pattern validation', () => { + const { result } = renderHook(() => useRegisterFormValidation(mockWatch as any), { + wrapper: defaultAppRoot, + }); + + expect(result.current.validationRules.email.pattern).toBeDefined(); + expect(result.current.validationRules.email.pattern.value).toBeInstanceOf(RegExp); + }); + + it('should validate password confirmation matches password', () => { + mockWatch.mockImplementation((field?: string) => { + if (field === 'password') return 'test123'; + return { password: 'test123' }; + }); + + const { result } = renderHook(() => useRegisterFormValidation(mockWatch as any), { + wrapper: defaultAppRoot, + }); + + const validateFn = result.current.validationRules.passwordConfirmation.validate; + expect(validateFn('test123')).toBe(true); + expect(validateFn('different')).toBe("Passwords don't match"); + }); + + it('should return passwordIsValid based on password policy', () => { + mockWatch.mockReturnValue({ password: 'ValidPassword123!' }); + + const { result } = renderHook(() => useRegisterFormValidation(mockWatch as any), { + wrapper: defaultAppRoot, + }); + + // With disabled password policy, any password is valid + expect(result.current.passwordIsValid).toBe(true); + }); + + it('should have correct password dependencies', () => { + const { result } = renderHook(() => useRegisterFormValidation(mockWatch as any), { + wrapper: defaultAppRoot, + }); + + expect(result.current.validationRules.passwordConfirmation.deps).toEqual(['password']); + }); +}); diff --git a/packages/web-ui-registration/src/hooks/useRegisterFormValidation.ts b/packages/web-ui-registration/src/hooks/useRegisterFormValidation.ts new file mode 100644 index 0000000000000..6f2214482ebc0 --- /dev/null +++ b/packages/web-ui-registration/src/hooks/useRegisterFormValidation.ts @@ -0,0 +1,59 @@ +import { useSetting } from '@rocket.chat/ui-contexts'; +import { useValidatePassword } from '@rocket.chat/ui-client'; +import type { UseFormWatch } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; + +type LoginRegisterPayload = { + name: string; + passwordConfirmation: string; + username: string; + password: string; + email: string; + reason: string; +}; + +export const useRegisterFormValidation = (watch: UseFormWatch) => { + const { t } = useTranslation(); + const requireNameForRegister = useSetting('Accounts_RequireNameForSignUp', true); + const requiresPasswordConfirmation = useSetting('Accounts_RequirePasswordConfirmation', true); + const manuallyApproveNewUsersRequired = useSetting('Accounts_ManuallyApproveNewUsers', false); + + const { password } = watch(); + const passwordIsValid = useValidatePassword(password); + + const validationRules = { + name: { + required: requireNameForRegister ? t('Required_field', { field: t('registration.component.form.name') }) : false, + }, + email: { + required: t('Required_field', { field: t('registration.component.form.email') }), + pattern: { + value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i, + message: t('registration.component.form.invalidEmail'), + }, + }, + username: { + required: t('Required_field', { field: t('registration.component.form.username') }), + }, + password: { + required: t('Required_field', { field: t('registration.component.form.password') }), + validate: () => (!passwordIsValid ? t('Password_must_meet_the_complexity_requirements') : true), + }, + passwordConfirmation: { + required: t('Required_field', { field: t('registration.component.form.confirmPassword') }), + deps: ['password'] as const, + validate: (val: string) => (watch('password') === val ? true : t('registration.component.form.invalidConfirmPass')), + }, + reason: { + required: t('Required_field', { field: t('registration.component.form.reasonToJoin') }), + }, + }; + + return { + validationRules, + requireNameForRegister, + requiresPasswordConfirmation, + manuallyApproveNewUsersRequired, + passwordIsValid, + }; +};