diff options
7 files changed, 508 insertions, 150 deletions
diff --git a/server/sonar-web/src/main/js/api/users.ts b/server/sonar-web/src/main/js/api/users.ts index 02c631b1a72..ac0d45f45eb 100644 --- a/server/sonar-web/src/main/js/api/users.ts +++ b/server/sonar-web/src/main/js/api/users.ts @@ -20,7 +20,7 @@ import axios from 'axios'; import { throwGlobalError } from '~sonar-aligned/helpers/error'; import { getJSON } from '~sonar-aligned/helpers/request'; -import { HttpStatus, axiosToCatch, parseJSON, post } from '../helpers/request'; +import { HttpStatus, parseJSON, post } from '../helpers/request'; import { IdentityProvider, Paging } from '../types/types'; import { ChangePasswordResults, @@ -85,14 +85,14 @@ export function postUser(data: { password?: string; scmAccounts: string[]; }) { - return axiosToCatch.post<RestUserDetailed>(USERS_ENDPOINT, data); + return axios.post<RestUserDetailed>(USERS_ENDPOINT, data); } export function updateUser( id: string, data: Partial<Pick<RestUserDetailed, 'email' | 'name' | 'scmAccounts'>>, ) { - return axiosToCatch.patch<RestUserDetailed>(`${USERS_ENDPOINT}/${id}`, data); + return axios.patch<RestUserDetailed>(`${USERS_ENDPOINT}/${id}`, data); } export function deleteUser({ id, anonymize }: { anonymize?: boolean; id: string }) { diff --git a/server/sonar-web/src/main/js/apps/users/__tests__/UserForm-it.tsx b/server/sonar-web/src/main/js/apps/users/__tests__/UserForm-it.tsx new file mode 100644 index 00000000000..8898d1de6f2 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/users/__tests__/UserForm-it.tsx @@ -0,0 +1,274 @@ +/* + * SonarQube + * Copyright (C) 2009-2024 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import UsersServiceMock from '../../../api/mocks/UsersServiceMock'; +import { mockRestUser } from '../../../helpers/testMocks'; +import { renderComponent } from '../../../helpers/testReactTestingUtils'; +import { byLabelText, byRole, byTestId, byText } from '../../../sonar-aligned/helpers/testSelector'; +import { FCProps } from '../../../types/misc'; +import UserForm from '../components/UserForm'; + +const userHandler = new UsersServiceMock(); + +const ui = { + loginInput: byRole('textbox', { name: /login/ }), + userNameInput: byRole('textbox', { name: /name/ }), + emailInput: byRole('textbox', { name: /email/ }), + passwordInput: byLabelText(/^password/), + scmAddButton: byRole('button', { name: 'add_verb' }), + dialogSCMInputs: byRole('textbox', { name: /users.create_user.scm_account/ }), + confirmPassword: byLabelText(/confirm_password\*/i), + createButton: byRole('button', { name: 'create' }), + updateButton: byRole('button', { name: 'update_verb' }), + + errorMinimum3Charatecters: byText('users.minimum_x_characters.3'), + errorLoginAlreadyTaken: byText('users.login_already_used'), + errorInvalidCharacter: byText('users.login_invalid_characters'), + errorStartWithLetterOrNumber: byText('users.login_start_with_letter_or_number'), + errorEmailInvalid: byText('users.email.invalid'), + + validCondition: byTestId('valid-condition'), + failedCondition: byTestId('failed-condition'), + + condition1Uppercase: byText('user.password.condition.1_upper_case'), + condition1Lowercase: byText('user.password.condition.1_lower_case'), + condition1Number: byText('user.password.condition.1_number'), + condition1SpecialCharacter: byText('user.password.condition.1_special_character'), + condition12Characters: byText('user.password.condition.12_characters'), +}; + +beforeEach(() => { + userHandler.reset(); +}); + +describe('in non-managed mode', () => { + describe('when creating', () => { + it('should render correctly', async () => { + renderUserForm(); + + expect(await ui.loginInput.find()).toBeInTheDocument(); + expect(ui.userNameInput.get()).toBeInTheDocument(); + expect(ui.emailInput.get()).toBeInTheDocument(); + expect(ui.passwordInput.get()).toBeInTheDocument(); + expect(ui.scmAddButton.get()).toBeInTheDocument(); + }); + + it('should have proper validation for login', async () => { + const user = userEvent.setup(); + renderUserForm(); + + expect(await ui.loginInput.find()).toHaveValue(''); + await user.type(ui.userNameInput.get(), 'Ken Samaras'); + await user.type(ui.emailInput.get(), 'nekfeu@screw.fr'); + await user.type(ui.passwordInput.get(), 'P@ssword12345'); + await user.type(ui.confirmPassword.get(), 'P@ssword12345'); + + // Login should have at least 3 characters + expect(ui.createButton.get()).toBeDisabled(); + await user.type(ui.loginInput.get(), 'a'); + expect(ui.errorMinimum3Charatecters.get()).toBeInTheDocument(); + expect(ui.createButton.get()).toBeDisabled(); + await user.type(ui.loginInput.get(), 'b'); + expect(ui.errorMinimum3Charatecters.get()).toBeInTheDocument(); + await user.type(ui.loginInput.get(), 'c'); + expect(ui.errorMinimum3Charatecters.query()).not.toBeInTheDocument(); + expect(ui.createButton.get()).toBeEnabled(); + await user.clear(ui.loginInput.get()); + + // Login should not already be taken + await user.type(ui.loginInput.get(), 'bob.marley'); + expect(ui.errorLoginAlreadyTaken.get()).toBeInTheDocument(); + expect(ui.createButton.get()).toBeDisabled(); + await user.clear(ui.loginInput.get()); + + // login should only used valid characters + await user.type(ui.loginInput.get(), 'abc!@#'); + expect(ui.errorInvalidCharacter.get()).toBeInTheDocument(); + expect(ui.createButton.get()).toBeDisabled(); + await user.clear(ui.loginInput.get()); + await user.type(ui.loginInput.get(), 'abc-_@.'); + expect(ui.errorInvalidCharacter.query()).not.toBeInTheDocument(); + expect(ui.createButton.get()).toBeEnabled(); + await user.clear(ui.loginInput.get()); + + // login should start with a letter, a number or _ + await user.type(ui.loginInput.get(), '@abc'); + expect(ui.errorStartWithLetterOrNumber.get()).toBeInTheDocument(); + expect(ui.createButton.get()).toBeDisabled(); + await user.clear(ui.loginInput.get()); + await user.type(ui.loginInput.get(), '.abc'); + expect(ui.errorStartWithLetterOrNumber.get()).toBeInTheDocument(); + expect(ui.createButton.get()).toBeDisabled(); + await user.clear(ui.loginInput.get()); + await user.type(ui.loginInput.get(), '-abc'); + expect(ui.errorStartWithLetterOrNumber.get()).toBeInTheDocument(); + expect(ui.createButton.get()).toBeDisabled(); + await user.clear(ui.loginInput.get()); + await user.type(ui.loginInput.get(), '_abc'); + expect(ui.errorStartWithLetterOrNumber.query()).not.toBeInTheDocument(); + expect(ui.createButton.get()).toBeEnabled(); + await user.clear(ui.loginInput.get()); + await user.type(ui.loginInput.get(), '1abc'); + expect(ui.errorStartWithLetterOrNumber.query()).not.toBeInTheDocument(); + expect(ui.createButton.get()).toBeEnabled(); + }); + + it('should have proper validation for email', async () => { + const user = userEvent.setup(); + renderUserForm(); + + expect(await ui.loginInput.find()).toHaveValue(''); + await user.type(ui.loginInput.get(), 'Nekfeu'); + await user.type(ui.userNameInput.get(), 'Ken Samaras'); + await user.type(ui.passwordInput.get(), 'P@ssword12345'); + await user.type(ui.confirmPassword.get(), 'P@ssword12345'); + + // Email is not mandatory + expect(ui.createButton.get()).toBeEnabled(); + + // Email should be valid though + await user.type(ui.emailInput.get(), 'nekfeu'); + expect(ui.createButton.get()).toBeDisabled(); + // just to loose focus... + await user.click(ui.loginInput.get()); + expect(ui.errorEmailInvalid.get()).toBeInTheDocument(); + await user.type(ui.emailInput.get(), '@screw.fr'); + expect(ui.errorEmailInvalid.query()).not.toBeInTheDocument(); + expect(ui.createButton.get()).toBeEnabled(); + }); + + it('should have proper validation for password', async () => { + const user = userEvent.setup(); + renderUserForm(); + + expect(await ui.loginInput.find()).toHaveValue(''); + await user.type(ui.loginInput.get(), 'Nekfeu'); + await user.type(ui.userNameInput.get(), 'Ken Samaras'); + await user.type(ui.emailInput.get(), 'nekfeu@screw.fr'); + expect(ui.createButton.get()).toBeDisabled(); + + // Password should have at least 1 Uppercase + await user.type(ui.passwordInput.get(), 'P'); + expect(ui.createButton.get()).toBeDisabled(); + expect(ui.validCondition.getAll()).toContain(ui.condition1Uppercase.get()); + expect(ui.failedCondition.getAll()).toContain(ui.condition1Lowercase.get()); + expect(ui.failedCondition.getAll()).toContain(ui.condition1Number.get()); + expect(ui.failedCondition.getAll()).toContain(ui.condition1SpecialCharacter.get()); + expect(ui.failedCondition.getAll()).toContain(ui.condition12Characters.get()); + + // Password should have at least 1 Lowercase + await user.type(ui.passwordInput.get(), 'assword'); + expect(ui.createButton.get()).toBeDisabled(); + expect(ui.validCondition.getAll()).toContain(ui.condition1Uppercase.get()); + expect(ui.validCondition.getAll()).toContain(ui.condition1Lowercase.get()); + expect(ui.failedCondition.getAll()).toContain(ui.condition1Number.get()); + expect(ui.failedCondition.getAll()).toContain(ui.condition1SpecialCharacter.get()); + expect(ui.failedCondition.getAll()).toContain(ui.condition12Characters.get()); + + // Password should have at least 1 Number + await user.type(ui.passwordInput.get(), '1'); + expect(ui.createButton.get()).toBeDisabled(); + expect(ui.validCondition.getAll()).toContain(ui.condition1Uppercase.get()); + expect(ui.validCondition.getAll()).toContain(ui.condition1Lowercase.get()); + expect(ui.validCondition.getAll()).toContain(ui.condition1Number.get()); + expect(ui.failedCondition.getAll()).toContain(ui.condition1SpecialCharacter.get()); + expect(ui.failedCondition.getAll()).toContain(ui.condition12Characters.get()); + + // Password should have at least 1 Special Character + await user.type(ui.passwordInput.get(), '$'); + expect(ui.createButton.get()).toBeDisabled(); + expect(ui.validCondition.getAll()).toContain(ui.condition1Uppercase.get()); + expect(ui.validCondition.getAll()).toContain(ui.condition1Lowercase.get()); + expect(ui.validCondition.getAll()).toContain(ui.condition1Number.get()); + expect(ui.validCondition.getAll()).toContain(ui.condition1SpecialCharacter.get()); + expect(ui.failedCondition.getAll()).toContain(ui.condition12Characters.get()); + + // Password should have at least 12 characters + await user.type(ui.passwordInput.get(), '74'); + expect(ui.passwordInput.get()).toHaveValue('Password1$74'); + expect(ui.createButton.get()).toBeDisabled(); + expect(ui.validCondition.getAll()).toContain(ui.condition1Uppercase.get()); + expect(ui.validCondition.getAll()).toContain(ui.condition1Lowercase.get()); + expect(ui.validCondition.getAll()).toContain(ui.condition1Number.get()); + expect(ui.validCondition.getAll()).toContain(ui.condition1SpecialCharacter.get()); + expect(ui.validCondition.getAll()).toContain(ui.condition12Characters.get()); + + // Password should match + await user.type(ui.confirmPassword.get(), 'Password1$'); + expect(ui.condition1Uppercase.query()).not.toBeInTheDocument(); + expect(ui.condition1Lowercase.query()).not.toBeInTheDocument(); + expect(ui.condition1Number.query()).not.toBeInTheDocument(); + expect(ui.condition1SpecialCharacter.query()).not.toBeInTheDocument(); + expect(ui.condition12Characters.query()).not.toBeInTheDocument(); + expect(ui.createButton.get()).toBeDisabled(); + await user.type(ui.confirmPassword.get(), '74'); + expect(ui.createButton.get()).toBeEnabled(); + }); + }); + + describe('when updating', () => { + it('should render correctly', async () => { + renderUserForm({ user: mockRestUser({ login: 'nekfeu', name: 'Ken Samaras', email: '' }) }); + + expect(await ui.userNameInput.get()).toBeInTheDocument(); + expect(ui.emailInput.get()).toBeInTheDocument(); + expect(ui.scmAddButton.get()).toBeInTheDocument(); + }); + + it('should validate email', async () => { + const user = userEvent.setup(); + renderUserForm({ user: mockRestUser({ login: 'nekfeu', name: 'Ken Samaras', email: '' }) }); + + expect(await ui.userNameInput.find()).toHaveValue('Ken Samaras'); + expect(ui.emailInput.get()).toHaveValue(''); + expect(ui.updateButton.get()).toBeEnabled(); + + await user.type(ui.emailInput.get(), 'nekfeu'); + expect(ui.updateButton.get()).toBeDisabled(); + // just to loose focus... + await user.click(ui.userNameInput.get()); + expect(ui.errorEmailInvalid.get()).toBeInTheDocument(); + await user.type(ui.emailInput.get(), '@screw.fr'); + expect(ui.errorEmailInvalid.query()).not.toBeInTheDocument(); + expect(ui.updateButton.get()).toBeEnabled(); + }); + }); +}); + +describe('in managed mode', () => { + describe('when updating', () => { + it('should render correctly', async () => { + renderUserForm({ + isInstanceManaged: true, + user: mockRestUser({ login: 'nekfeu', name: 'Ken Samaras', email: '' }), + }); + + expect(await ui.userNameInput.find()).toBeDisabled(); + expect(ui.emailInput.get()).toBeDisabled(); + expect(ui.scmAddButton.get()).toBeInTheDocument(); + }); + }); +}); + +function renderUserForm(props: Partial<FCProps<typeof UserForm>> = {}) { + return renderComponent(<UserForm isInstanceManaged={false} onClose={jest.fn()} {...props} />); +} diff --git a/server/sonar-web/src/main/js/apps/users/__tests__/UsersApp-it.tsx b/server/sonar-web/src/main/js/apps/users/__tests__/UsersApp-it.tsx index b0a6876f2c1..db081974e0f 100644 --- a/server/sonar-web/src/main/js/apps/users/__tests__/UsersApp-it.tsx +++ b/server/sonar-web/src/main/js/apps/users/__tests__/UsersApp-it.tsx @@ -278,8 +278,8 @@ describe('in non managed mode', () => { // Clear input to get an error on save await user.clear(ui.dialogSCMInput('SCM').get()); await user.click(ui.createUserDialogButton.get()); + // addGlobalError should be called with `Error: Empty SCM` expect(ui.dialogCreateUser.get()).toBeInTheDocument(); - expect(await ui.dialogCreateUser.byText('Error: Empty SCM').find()).toBeInTheDocument(); // Remove SCM account await user.click(ui.deleteSCMButton().get()); expect(ui.dialogSCMInputs.queryAll()).toHaveLength(0); diff --git a/server/sonar-web/src/main/js/apps/users/components/UserForm.tsx b/server/sonar-web/src/main/js/apps/users/components/UserForm.tsx index 0c6d2279823..6f8dbc6d68a 100644 --- a/server/sonar-web/src/main/js/apps/users/components/UserForm.tsx +++ b/server/sonar-web/src/main/js/apps/users/components/UserForm.tsx @@ -18,26 +18,23 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { AxiosError, AxiosResponse } from 'axios'; -import { - ButtonPrimary, - ButtonSecondary, - FlagMessage, - FormField, - InputField, - Modal, - Spinner, - addGlobalErrorMessage, -} from 'design-system'; +import { Button, ButtonVariety, IconCheckCircle, IconError, Text } from '@sonarsource/echoes-react'; +import { FlagMessage, FormField, InputField, Modal, Spinner } from 'design-system'; +import { debounce } from 'lodash'; import * as React from 'react'; +import EmailIput, { EmailChangeHandlerParams } from '../../../components/common/EmailInput'; import UserPasswordInput, { PasswordChangeHandlerParams, } from '../../../components/common/UserPasswordInput'; import MandatoryFieldsExplanation from '../../../components/ui/MandatoryFieldsExplanation'; import { translate, translateWithParameters } from '../../../helpers/l10n'; -import { parseErrorResponse } from '../../../helpers/request'; -import { usePostUserMutation, useUpdateUserMutation } from '../../../queries/users'; +import { + usePostUserMutation, + useUpdateUserMutation, + useUsersQueries, +} from '../../../queries/users'; import { RestUserDetailed } from '../../../types/users'; +import { DEBOUNCE_DELAY } from '../../background-tasks/constants'; import UserScmAccountInput from './UserScmAccountInput'; export interface Props { @@ -46,58 +43,67 @@ export interface Props { user?: RestUserDetailed; } -const BAD_REQUEST = 400; -const INTERNAL_SERVER_ERROR = 500; +const MINIMUM_LOGIN_LENGTH = 3; +const MAXIMUM_LOGIN_LENGTH = 255; +const MINIMUM_NAME_LENGTH = 1; +const MAXIMUM_NAME_LENGTH = 200; export default function UserForm(props: Props) { - const { user, isInstanceManaged } = props; - - const { mutate: createUser, isPending: isLoadingCreate } = usePostUserMutation(); - const { mutate: updateUser, isPending: isLoadingUserUpdate } = useUpdateUserMutation(); - - const [email, setEmail] = React.useState<string>(user?.email ?? ''); + const { user, isInstanceManaged, onClose } = props; + const isCreateUserForm = !user; + const [email, setEmail] = React.useState<EmailChangeHandlerParams>({ + value: user?.email ?? '', + isValid: false, + }); const [login, setLogin] = React.useState<string>(user?.login ?? ''); - const [name, setName] = React.useState<string>(user?.name ?? ''); + const [name, setName] = React.useState<string | undefined>(user?.name); const [password, setPassword] = React.useState<PasswordChangeHandlerParams>({ value: '', isValid: false, }); const [scmAccounts, setScmAccounts] = React.useState<string[]>(user?.scmAccounts ?? []); - const [error, setError] = React.useState<string | undefined>(undefined); - - const handleError = (error: AxiosError<AxiosResponse>) => { - const { response } = error; - const message = parseErrorResponse(response); - if (!response || ![BAD_REQUEST, INTERNAL_SERVER_ERROR].includes(response.status)) { - addGlobalErrorMessage(message); - } else { - setError(message); - } - }; + const { mutate: createUser, isPending: isLoadingCreate } = usePostUserMutation(); + const { mutate: updateUser, isPending: isLoadingUserUpdate } = useUpdateUserMutation(); - React.useEffect(() => { - document.getElementById('it__error-message')?.scrollIntoView({ - block: 'start', - }); - }, [error]); + const { data } = useUsersQueries<RestUserDetailed>( + { + q: login, + }, + Boolean(login !== '' && isCreateUserForm), + ); - const handleClose = () => { - props.onClose(); - }; + const users = React.useMemo(() => data?.pages.flatMap((page) => page.users) ?? [], [data]); + const isLoginTooShort = login.length < MINIMUM_LOGIN_LENGTH && login !== ''; + const isLoginAlreadyUsed = users.some((u) => u.login === login); + const doesLoginHaveValidCharacter = login !== '' ? /^[a-zA-Z0-9._@-]+$/.test(login) : true; + const doesLoginStartWithLetterOrNumber = login !== '' ? /^\w.*/.test(login) : true; + const isLoginValid = + login.length >= MINIMUM_LOGIN_LENGTH && + !isLoginAlreadyUsed && + doesLoginHaveValidCharacter && + doesLoginStartWithLetterOrNumber; + const fieldsdMissing = user ? false : name === '' || login === '' || !password.isValid; + const fieldsValid = user + ? false + : name === undefined || name.trim() === '' || !isLoginValid || !password.isValid; + const nameIsValid = name !== undefined && name.trim() !== ''; + const nameIsInvalid = name !== undefined && name.trim() === ''; + const isEmailValid = + (user && !user.local) || isInstanceManaged || email.value === '' ? false : !email.isValid; const handleCreateUser = (e: React.SyntheticEvent<HTMLFormElement>) => { e.preventDefault(); createUser( { - email: email || undefined, + email: email.value !== '' ? email.value : undefined, login, - name, + name: name !== undefined ? name : '', password: password.value, scmAccounts, }, - { onSuccess: props.onClose, onError: handleError }, + { onSuccess: onClose }, ); }; @@ -112,12 +118,12 @@ export default function UserForm(props: Props) { isInstanceManaged || !user?.local ? { scmAccounts } : { - email: email !== '' ? email : null, + email: email.value !== '' ? email.value : null, name, scmAccounts, }, }, - { onSuccess: props.onClose, onError: handleError }, + { onSuccess: onClose }, ); }; @@ -137,26 +143,23 @@ export default function UserForm(props: Props) { setScmAccounts((scmAccounts) => scmAccounts.slice(0, idx).concat(scmAccounts.slice(idx + 1))); }; - const header = user ? translate('users.update_user') : translate('users.create_user'); - const fieldsdMissing = user ? false : name === '' || login === '' || !password.isValid; + const changeHandler = (event: React.ChangeEvent<HTMLInputElement>) => { + setLogin(event.target.value); + }; + + const debouncedChangeHandler = React.useMemo(() => debounce(changeHandler, DEBOUNCE_DELAY), []); return ( <Modal - headerTitle={header} - onClose={handleClose} + headerTitle={user ? translate('users.update_user') : translate('users.create_user')} + onClose={onClose} body={ <form autoComplete="off" id="user-form" onSubmit={user ? handleUpdateUser : handleCreateUser} > - {error && ( - <FlagMessage id="it__error-message" className="sw-mb-4" variant="error"> - {error} - </FlagMessage> - )} - - {!error && user && !user.local && ( + {user && !user.local && ( <FlagMessage className="sw-mb-4" variant="warning"> {translate('users.cannot_update_delegated_user')} </FlagMessage> @@ -166,68 +169,113 @@ export default function UserForm(props: Props) { <MandatoryFieldsExplanation /> </div> - {!user && ( + {isCreateUserForm && ( <FormField - description={translateWithParameters('users.minimum_x_characters', 3)} label={translate('login')} htmlFor="create-user-login" required={!isInstanceManaged} > - <InputField - autoFocus - autoComplete="off" - maxLength={255} - minLength={3} - size="full" - id="create-user-login" - name="login" - onChange={(e) => setLogin(e.currentTarget.value)} - type="text" - value={login} - /> + <div className="sw-flex sw-items-center"> + <InputField + autoFocus + autoComplete="off" + isInvalid={ + isLoginAlreadyUsed || + isLoginTooShort || + !doesLoginHaveValidCharacter || + !doesLoginStartWithLetterOrNumber + } + isValid={!isLoginAlreadyUsed && login.length >= MINIMUM_LOGIN_LENGTH} + maxLength={MAXIMUM_LOGIN_LENGTH} + minLength={MINIMUM_LOGIN_LENGTH} + size="full" + id="create-user-login" + name="login" + onChange={debouncedChangeHandler} + type="text" + /> + {(isLoginTooShort || isLoginAlreadyUsed) && ( + <IconError color="echoes-color-icon-danger" className="sw-ml-2" /> + )} + {isLoginValid && ( + <IconCheckCircle color="echoes-color-icon-success" className="sw-ml-2" /> + )} + </div> + + {!doesLoginHaveValidCharacter && ( + <Text colorOverride="echoes-color-text-danger" className="sw-mt-2"> + {translate('users.login_invalid_characters')} + </Text> + )} + + {isLoginAlreadyUsed && ( + <Text colorOverride="echoes-color-text-danger" className="sw-mt-2"> + {translate('users.login_already_used')} + </Text> + )} + + {!doesLoginStartWithLetterOrNumber && ( + <Text colorOverride="echoes-color-text-danger" className="sw-mt-2"> + {translate('users.login_start_with_letter_or_number')} + </Text> + )} + + {isLoginTooShort && login !== '' && ( + <Text colorOverride="echoes-color-text-danger" className="sw-mt-2"> + {translateWithParameters('users.minimum_x_characters', MINIMUM_LOGIN_LENGTH)} + </Text> + )} </FormField> )} + {isCreateUserForm && ( + <UserPasswordInput + value={password.value} + onChange={(password) => setPassword(password)} + /> + )} + <FormField label={translate('name')} htmlFor="create-user-name" required={!isInstanceManaged} > - <InputField - autoFocus={!!user} - autoComplete="off" - disabled={(user && !user.local) || isInstanceManaged} - size="full" - maxLength={200} - id="create-user-name" - name="name" - onChange={(e) => setName(e.currentTarget.value)} - type="text" - value={name} - /> + <div className="sw-flex sw-items-center"> + <InputField + isValid={isCreateUserForm ? nameIsValid : undefined} + isInvalid={nameIsInvalid} + autoFocus={!!user} + autoComplete="off" + disabled={(user && !user.local) || isInstanceManaged} + size="full" + maxLength={MAXIMUM_NAME_LENGTH} + id="create-user-name" + name="name" + onChange={(e) => setName(e.currentTarget.value)} + type="text" + value={name === undefined ? '' : name} + /> + {nameIsInvalid && <IconError color="echoes-color-icon-danger" className="sw-ml-2" />} + {isCreateUserForm && nameIsValid && ( + <IconCheckCircle color="echoes-color-icon-success" className="sw-ml-2" /> + )} + </div> + {nameIsInvalid && ( + <Text colorOverride="echoes-color-text-danger" className="sw-mt-2"> + {translateWithParameters('users.minimum_x_characters', MINIMUM_NAME_LENGTH)} + </Text> + )} </FormField> <FormField label={translate('users.email')} htmlFor="create-user-email"> - <InputField - autoComplete="off" - disabled={(user && !user.local) || isInstanceManaged} - size="full" - maxLength={100} + <EmailIput id="create-user-email" - name="email" - onChange={(e) => setEmail(e.currentTarget.value)} - type="email" - value={email} + isDisabled={(user && !user.local) || isInstanceManaged} + onChange={setEmail} + value={email.value} /> </FormField> - {!user && ( - <UserPasswordInput - value={password.value} - onChange={(password) => setPassword(password)} - /> - )} - <FormField description={translate('user.login_or_email_used_as_scm_account')} label={translate('my_profile.scm_accounts')} @@ -243,9 +291,9 @@ export default function UserForm(props: Props) { ))} <div> - <ButtonSecondary className="it__scm-account-add" onClick={handleAddScmAccount}> + <Button className="it__scm-account-add" onClick={handleAddScmAccount}> {translate('add_verb')} - </ButtonSecondary> + </Button> </div> </FormField> </form> @@ -254,13 +302,20 @@ export default function UserForm(props: Props) { <> <Spinner loading={isLoadingCreate || isLoadingUserUpdate} /> - <ButtonPrimary - disabled={isLoadingCreate || isLoadingUserUpdate || fieldsdMissing} + <Button + variety={ButtonVariety.Primary} + isDisabled={ + isLoadingCreate || + isLoadingUserUpdate || + fieldsdMissing || + isEmailValid || + fieldsValid + } type="submit" form="user-form" > {user ? translate('update_verb') : translate('create')} - </ButtonPrimary> + </Button> </> } secondaryButtonLabel={translate('cancel')} diff --git a/server/sonar-web/src/main/js/components/common/EmailInput.tsx b/server/sonar-web/src/main/js/components/common/EmailInput.tsx index ff27ace6670..54b4c96782d 100644 --- a/server/sonar-web/src/main/js/components/common/EmailInput.tsx +++ b/server/sonar-web/src/main/js/components/common/EmailInput.tsx @@ -17,7 +17,8 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { InputField, TextError } from 'design-system'; +import { IconCheckCircle, IconError, Text } from '@sonarsource/echoes-react'; +import { InputField } from 'design-system'; import * as React from 'react'; import isEmail from 'validator/lib/isEmail'; import { translate } from '../../helpers/l10n'; @@ -25,28 +26,58 @@ import FocusOutHandler from '../controls/FocusOutHandler'; export interface Props { id: string; + isDisabled?: boolean; + isMandotory?: boolean; onChange: (email: { isValid: boolean; value: string }) => void; value: string; } +export type EmailChangeHandlerParams = { isValid: boolean; value: string }; + export default function EmailIput(props: Readonly<Props>) { - const { id, value, onChange } = props; + const { id, value, onChange, isDisabled, isMandotory = false } = props; + + const [isEmailValid, setIsEmailValid] = React.useState<boolean>(); - const [isEmailValid, setIsEmailValid] = React.useState(true); + React.useEffect(() => { + if (!isMandotory) { + onChange({ value, isValid: true }); + } + }, []); return ( - <FocusOutHandler onFocusOut={() => setIsEmailValid(isEmail(value))}> - <InputField - id={id} - size="full" - onChange={({ currentTarget }) => { - onChange({ value: currentTarget.value, isValid: isEmail(currentTarget.value) }); - }} - type="email" - value={value} - /> + <FocusOutHandler onFocusOut={() => value !== '' && setIsEmailValid(isEmail(value))}> + <div className="sw-flex sw-items-center"> + <InputField + id={id} + isInvalid={isEmailValid === false} + isValid={isEmailValid === true} + size="full" + onChange={({ currentTarget }) => { + const isValid = isMandotory + ? isEmail(currentTarget.value) + : currentTarget.value === '' || isEmail(currentTarget.value); + onChange({ value: currentTarget.value, isValid }); + if (!isMandotory && currentTarget.value === '') { + setIsEmailValid(undefined); + } else if (isValid) { + setIsEmailValid(true); + } + }} + value={value} + disabled={isDisabled === true} + /> + {isEmailValid === false && ( + <IconError className="sw-ml-2" color="echoes-color-icon-danger" /> + )} + {isEmailValid === true && ( + <IconCheckCircle color="echoes-color-icon-success" className="sw-ml-2" /> + )} + </div> {isEmailValid === false && ( - <TextError className="sw-mt-2" text={translate('user.email.invalid')} /> + <Text className="sw-mt-2" colorOverride="echoes-color-text-danger"> + {translate('users.email.invalid')} + </Text> )} </FocusOutHandler> ); diff --git a/server/sonar-web/src/main/js/components/common/UserPasswordInput.tsx b/server/sonar-web/src/main/js/components/common/UserPasswordInput.tsx index 0183c4bffb2..98ccd7077cb 100644 --- a/server/sonar-web/src/main/js/components/common/UserPasswordInput.tsx +++ b/server/sonar-web/src/main/js/components/common/UserPasswordInput.tsx @@ -17,18 +17,8 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import styled from '@emotion/styled'; -import { IconCheck, IconX } from '@sonarsource/echoes-react'; -import { - FlagErrorIcon, - FlagSuccessIcon, - FormField, - InputField, - InputSizeKeys, - LightLabel, - TextError, - themeColor, -} from 'design-system'; +import { IconCheck, IconCheckCircle, IconError, IconX, Text } from '@sonarsource/echoes-react'; +import { FormField, InputField, InputSizeKeys } from 'design-system'; import * as React from 'react'; import { translate } from '../../helpers/l10n'; import FocusOutHandler from '../controls/FocusOutHandler'; @@ -82,13 +72,17 @@ export default function UserPasswordInput(props: Readonly<Props>) { type="password" value={value} /> - {isInvalid && <FlagErrorIcon className="sw-ml-2" />} - {isValid && <FlagSuccessIcon className="sw-ml-2" />} + {isInvalid && <IconError className="sw-ml-2" color="echoes-color-icon-danger" />} + {isValid && <IconCheckCircle color="echoes-color-icon-success" className="sw-ml-2" />} </div> + {isInvalid && ( + <Text colorOverride="echoes-color-text-danger" className="sw-mt-2"> + {translate('user.password.invalid')} + </Text> + )} + {isFocused && <PasswordConstraint value={value} />} </FormField> </FocusOutHandler> - {isInvalid && <TextError className="sw-mt-2" text={translate('user.password.invalid')} />} - {isFocused && <PasswordConstraint value={value} />} <FormField className="sw-mt-4" @@ -115,11 +109,15 @@ export default function UserPasswordInput(props: Readonly<Props>) { type="password" value={confirmValue} /> - {passwordDontMatch && <FlagErrorIcon className="sw-ml-2" />} - {passwordMatch && <FlagSuccessIcon className="sw-ml-2" />} + {passwordDontMatch && <IconError className="sw-ml-2" color="echoes-color-icon-danger" />} + {passwordMatch && ( + <IconCheckCircle color="echoes-color-icon-success" className="sw-ml-2" /> + )} </div> {passwordDontMatch && ( - <TextError className="sw-mt-2" text={translate('user.password.do_not_match')} /> + <Text colorOverride="echoes-color-text-danger" className="sw-mt-2"> + {translate('user.password.do_not_match')} + </Text> )} </FormField> </> @@ -129,7 +127,7 @@ export default function UserPasswordInput(props: Readonly<Props>) { function PasswordConstraint({ value }: Readonly<{ value: string }>) { return ( <div className="sw-mt-2"> - <LightLabel>{translate('user.password.conditions')}</LightLabel> + <Text isSubdued>{translate('user.password.conditions')}</Text> <ul className="sw-list-none sw-p-0 sw-mt-1"> <Condition condition={contains12Characters(value)} @@ -160,15 +158,15 @@ function Condition({ condition, label }: Readonly<{ condition: boolean; label: s return ( <li className="sw-mb-1"> {condition ? ( - <SuccessLabel> + <Text colorOverride="echoes-color-text-success" data-testid="valid-condition"> <IconCheck className="sw-mr-1" /> {label} - </SuccessLabel> + </Text> ) : ( - <LightLabel> + <Text isSubdued data-testid="failed-condition"> <IconX className="sw-mr-1" /> {label} - </LightLabel> + </Text> )} </li> ); @@ -189,7 +187,3 @@ const isPasswordValid = (password: string) => const isPasswordConfirmed = (password: string, confirm: string) => password === confirm && password !== ''; - -const SuccessLabel = styled(LightLabel)` - color: ${themeColor('textSuccess')}; -`; diff --git a/sonar-core/src/main/resources/org/sonar/l10n/core.properties b/sonar-core/src/main/resources/org/sonar/l10n/core.properties index 13b129c101b..fcc4c001880 100644 --- a/sonar-core/src/main/resources/org/sonar/l10n/core.properties +++ b/sonar-core/src/main/resources/org/sonar/l10n/core.properties @@ -5486,7 +5486,11 @@ users.list=Users list users.update_user=Update User users.cannot_update_delegated_user=You cannot update the name and email of this user, as it is controlled by an external identity provider. users.minimum_x_characters=Minimum {0} characters +users.login_already_used=This login is already in use. Please choose another one. +users.login_invalid_characters=Login should contain only letters, numbers, and .-_@ +users.login_start_with_letter_or_number=Login should start with _ or alphanumeric. users.email=Email +users.email.invalid=Invalid email users.last_connection=Last connection users.last_sonarlint_connection=Last SonarLint connection users.last_sonarlint_connection.help_text=The time of the last connection from SonarLint indicates that the user used SonarLint in connected mode. |