aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--server/sonar-web/src/main/js/api/users.ts6
-rw-r--r--server/sonar-web/src/main/js/apps/users/__tests__/UserForm-it.tsx274
-rw-r--r--server/sonar-web/src/main/js/apps/users/__tests__/UsersApp-it.tsx2
-rw-r--r--server/sonar-web/src/main/js/apps/users/components/UserForm.tsx263
-rw-r--r--server/sonar-web/src/main/js/components/common/EmailInput.tsx59
-rw-r--r--server/sonar-web/src/main/js/components/common/UserPasswordInput.tsx50
-rw-r--r--sonar-core/src/main/resources/org/sonar/l10n/core.properties4
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.