]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-23001 Improve validation form when creating a new user
authorguillaume-peoch-sonarsource <guillaume.peoch@sonarsource.com>
Mon, 30 Sep 2024 12:00:20 +0000 (14:00 +0200)
committersonartech <sonartech@sonarsource.com>
Mon, 7 Oct 2024 20:03:16 +0000 (20:03 +0000)
server/sonar-web/src/main/js/api/users.ts
server/sonar-web/src/main/js/apps/users/__tests__/UserForm-it.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/users/__tests__/UsersApp-it.tsx
server/sonar-web/src/main/js/apps/users/components/UserForm.tsx
server/sonar-web/src/main/js/components/common/EmailInput.tsx
server/sonar-web/src/main/js/components/common/UserPasswordInput.tsx
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index 02c631b1a722659199c2b23e3174e2596324edfa..ac0d45f45eb6730df4cfa3f78a007debe26108bf 100644 (file)
@@ -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 (file)
index 0000000..8898d1d
--- /dev/null
@@ -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} />);
+}
index b0a6876f2c17a0b28c74fbd12e2dcece9d2ae7de..db081974e0f165f7a5bca7796b2db3490c8a8e97 100644 (file)
@@ -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);
index 0c6d227982334b19e1d1adb620ca680358ac34e8..6f8dbc6d68af50784c3855f69982cf60be23c3e0 100644 (file)
  * 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')}
index ff27ace6670442356bdcb3ef77f7396ff3aed187..54b4c96782dd551efaff4a8feab30f416ed939c7 100644 (file)
@@ -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>
   );
index 0183c4bffb23b59fd39ebdcf2b6e1b634a23a3fe..98ccd7077cb108e3833e0cda380f901a42acaf60 100644 (file)
  * 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')};
-`;
index 13b129c101b043d796ed4f52f50d5072894fb00b..fcc4c0018809ac7140ff13de1df3a54dfe74e302 100644 (file)
@@ -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.