aboutsummaryrefslogtreecommitdiffstats
path: root/server/sonar-web/src
diff options
context:
space:
mode:
authorguillaume-peoch-sonarsource <guillaume.peoch@sonarsource.com>2024-09-03 15:18:25 +0200
committersonartech <sonartech@sonarsource.com>2024-09-11 20:03:48 +0000
commitbdcd59aefa14abd2456db15b571fbaaeb681218c (patch)
tree26673e1d4b0512148ac2adc1aa1daae6655fbb6a /server/sonar-web/src
parent2923559d555c22042d0257547e52d7bdc7212da6 (diff)
downloadsonarqube-bdcd59aefa14abd2456db15b571fbaaeb681218c.tar.gz
sonarqube-bdcd59aefa14abd2456db15b571fbaaeb681218c.zip
SONAR-22931 Add password complexity when creating users as admin
Removing zxcvbn Confirm Password Fix CI issues Fix SQ analysis issue
Diffstat (limited to 'server/sonar-web/src')
-rw-r--r--server/sonar-web/src/main/js/apps/users/__tests__/UsersApp-it.tsx6
-rw-r--r--server/sonar-web/src/main/js/apps/users/components/UserForm.tsx23
-rw-r--r--server/sonar-web/src/main/js/components/common/EmailInput.tsx53
-rw-r--r--server/sonar-web/src/main/js/components/common/UserPasswordInput.tsx185
4 files changed, 252 insertions, 15 deletions
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 97c1404bf7c..58dd60c1e49 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
@@ -138,6 +138,7 @@ const ui = {
userNameInput: byRole('textbox', { name: /name/ }),
emailInput: byRole('textbox', { name: /email/ }),
passwordInput: byLabelText(/password/),
+ confirmPasswordInput: byLabelText(/confirm_password/),
dialogSCMInputs: byRole('textbox', { name: /users.create_user.scm_account/ }),
dialogSCMInput: (value?: string) =>
byRole('textbox', { name: `users.create_user.scm_account_${value ? `x.${value}` : 'new'}` }),
@@ -264,7 +265,8 @@ describe('in non managed mode', () => {
await user.type(ui.loginInput.get(), 'Login');
await user.type(ui.userNameInput.get(), 'Jack');
- await user.type(ui.passwordInput.get(), 'Password');
+ await user.type(ui.passwordInput.getAll()[0], 'P@ssword12345');
+ await user.type(ui.passwordInput.getAll()[1], 'P@ssword12345');
// Add SCM account
expect(ui.dialogSCMInputs.queryAll()).toHaveLength(0);
await user.click(ui.scmAddButton.get());
@@ -736,7 +738,7 @@ it('accessibility', async () => {
// user creation dialog should be accessible
await user.click(await ui.createUserButton.find());
expect(await ui.dialogCreateUser.find()).toBeInTheDocument();
- await expect(ui.dialogCreateUser.get()).toHaveNoA11yViolations();
+ await expect(await ui.dialogCreateUser.find()).toHaveNoA11yViolations();
await user.click(ui.cancelButton.get());
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 408bf512da0..ae546371e94 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
@@ -30,6 +30,7 @@ import {
addGlobalErrorMessage,
} from 'design-system';
import * as React from 'react';
+import UserPasswordInput from '../../../components/common/UserPasswordInput';
import MandatoryFieldsExplanation from '../../../components/ui/MandatoryFieldsExplanation';
import { translate, translateWithParameters } from '../../../helpers/l10n';
import { parseErrorResponse } from '../../../helpers/request';
@@ -55,7 +56,10 @@ export default function UserForm(props: Props) {
const [email, setEmail] = React.useState<string>(user?.email ?? '');
const [login, setLogin] = React.useState<string>(user?.login ?? '');
const [name, setName] = React.useState<string>(user?.name ?? '');
- const [password, setPassword] = React.useState<string>('');
+ const [password, setPassword] = React.useState<{ isValid: boolean; value: string }>({
+ value: '',
+ isValid: false,
+ });
const [scmAccounts, setScmAccounts] = React.useState<string[]>(user?.scmAccounts ?? []);
const [error, setError] = React.useState<string | undefined>(undefined);
@@ -88,7 +92,7 @@ export default function UserForm(props: Props) {
email: email || undefined,
login,
name,
- password,
+ password: password.value,
scmAccounts,
},
{ onSuccess: props.onClose, onError: handleError },
@@ -215,17 +219,10 @@ export default function UserForm(props: Props) {
</FormField>
{!user && (
- <FormField required label={translate('password')} htmlFor="create-user-password">
- <InputField
- autoComplete="off"
- size="full"
- id="create-user-password"
- name="password"
- onChange={(e) => setPassword(e.currentTarget.value)}
- type="password"
- value={password}
- />
- </FormField>
+ <UserPasswordInput
+ value={password.value}
+ onChange={(password) => setPassword(password)}
+ />
)}
<FormField
diff --git a/server/sonar-web/src/main/js/components/common/EmailInput.tsx b/server/sonar-web/src/main/js/components/common/EmailInput.tsx
new file mode 100644
index 00000000000..ff27ace6670
--- /dev/null
+++ b/server/sonar-web/src/main/js/components/common/EmailInput.tsx
@@ -0,0 +1,53 @@
+/*
+ * 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 { InputField, TextError } from 'design-system';
+import * as React from 'react';
+import isEmail from 'validator/lib/isEmail';
+import { translate } from '../../helpers/l10n';
+import FocusOutHandler from '../controls/FocusOutHandler';
+
+export interface Props {
+ id: string;
+ onChange: (email: { isValid: boolean; value: string }) => void;
+ value: string;
+}
+
+export default function EmailIput(props: Readonly<Props>) {
+ const { id, value, onChange } = props;
+
+ const [isEmailValid, setIsEmailValid] = React.useState(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}
+ />
+ {isEmailValid === false && (
+ <TextError className="sw-mt-2" text={translate('user.email.invalid')} />
+ )}
+ </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
new file mode 100644
index 00000000000..8f43ca1c716
--- /dev/null
+++ b/server/sonar-web/src/main/js/components/common/UserPasswordInput.tsx
@@ -0,0 +1,185 @@
+/*
+ * 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 styled from '@emotion/styled';
+import { IconCheck, IconX } from '@sonarsource/echoes-react';
+import {
+ FlagErrorIcon,
+ FlagSuccessIcon,
+ FormField,
+ InputField,
+ LightLabel,
+ TextError,
+ themeColor,
+} from 'design-system';
+import * as React from 'react';
+import { translate } from '../../helpers/l10n';
+import FocusOutHandler from '../controls/FocusOutHandler';
+
+const MIN_PASSWORD_LENGTH = 12;
+
+export interface Props {
+ onChange: (password: { isValid: boolean; value: string }) => void;
+ value: string;
+}
+
+export default function UserPasswordInput(props: Readonly<Props>) {
+ const { value, onChange } = props;
+
+ const [isFocused, setIsFocused] = React.useState(false);
+ const [confirmValue, setConfirmValue] = React.useState('');
+
+ const isInvalid = !isFocused && value !== '' && !isPasswordValid(value);
+ const isValid = !isFocused && isPasswordValid(value);
+ const passwordMatch = isPasswordConfirmed(value, confirmValue);
+ const passwordDontMatch = value !== confirmValue && confirmValue !== '';
+
+ return (
+ <>
+ <FocusOutHandler className="sw-flex sw-items-center" onFocusOut={() => setIsFocused(false)}>
+ <FormField required label={translate('password')} htmlFor="create-password">
+ <div className="sw-flex sw-items-center">
+ <InputField
+ isInvalid={isInvalid}
+ isValid={isValid}
+ onFocus={() => setIsFocused(true)}
+ id="create-password"
+ size="full"
+ onChange={({ currentTarget }) => {
+ onChange({
+ value: currentTarget.value,
+ isValid:
+ isPasswordValid(currentTarget.value) &&
+ isPasswordConfirmed(currentTarget.value, confirmValue),
+ });
+ }}
+ type="password"
+ value={value}
+ />
+ {isInvalid && <FlagErrorIcon className="sw-ml-2" />}
+ {isValid && <FlagSuccessIcon className="sw-ml-2" />}
+ </div>
+ </FormField>
+ </FocusOutHandler>
+ {isInvalid && <TextError className="sw-mt-2" text={translate('user.password.invalid')} />}
+ {isFocused && <PasswordConstraint value={value} />}
+
+ <FormField
+ className="sw-mt-4"
+ required
+ label={translate('confirm_password')}
+ htmlFor="confirm-password"
+ >
+ <div className="sw-flex sw-items-center">
+ <InputField
+ isInvalid={passwordDontMatch}
+ isValid={passwordMatch}
+ onFocus={() => setIsFocused(true)}
+ id="confirm-password"
+ size="full"
+ onChange={({ currentTarget }) => {
+ setConfirmValue(currentTarget.value);
+ onChange({
+ value,
+ isValid:
+ isPasswordValid(currentTarget.value) &&
+ isPasswordConfirmed(value, currentTarget.value),
+ });
+ }}
+ type="password"
+ value={confirmValue}
+ />
+ {passwordDontMatch && <FlagErrorIcon className="sw-ml-2" />}
+ {passwordMatch && <FlagSuccessIcon className="sw-ml-2" />}
+ </div>
+ {passwordDontMatch && (
+ <TextError className="sw-mt-2" text={translate('user.password.do_not_match')} />
+ )}
+ </FormField>
+ </>
+ );
+}
+
+function PasswordConstraint({ value }: Readonly<{ value: string }>) {
+ return (
+ <div className="sw-mt-2">
+ <LightLabel>{translate('user.password.conditions')}</LightLabel>
+ <ul className="sw-list-none sw-p-0 sw-mt-1">
+ <Condition
+ condition={contains12Characters(value)}
+ label={translate('user.password.condition.12_characters')}
+ />
+ <Condition
+ condition={containsUppercase(value)}
+ label={translate('user.password.condition.1_upper_case')}
+ />
+ <Condition
+ condition={containsLowercase(value)}
+ label={translate('user.password.condition.1_lower_case')}
+ />
+ <Condition
+ condition={containsDigit(value)}
+ label={translate('user.password.condition.1_number')}
+ />
+ <Condition
+ condition={containsSpecialCharacter(value)}
+ label={translate('user.password.condition.1_special_character')}
+ />
+ </ul>
+ </div>
+ );
+}
+
+function Condition({ condition, label }: Readonly<{ condition: boolean; label: string }>) {
+ return (
+ <li className="sw-mb-1">
+ {condition ? (
+ <SuccessLabel>
+ <IconCheck />
+ {label}
+ </SuccessLabel>
+ ) : (
+ <LightLabel>
+ <IconX />
+ {label}
+ </LightLabel>
+ )}
+ </li>
+ );
+}
+
+const contains12Characters = (password: string) => password.length >= MIN_PASSWORD_LENGTH;
+const containsUppercase = (password: string) => /[A-Z]/.test(password);
+const containsLowercase = (password: string) => /[a-z]/.test(password);
+const containsDigit = (password: string) => /\d/.test(password);
+const containsSpecialCharacter = (password: string) => /[^a-zA-Z0-9]/.test(password);
+
+const isPasswordValid = (password: string) =>
+ contains12Characters(password) &&
+ containsUppercase(password) &&
+ containsLowercase(password) &&
+ containsDigit(password) &&
+ containsSpecialCharacter(password);
+
+const isPasswordConfirmed = (password: string, confirm: string) =>
+ password === confirm && password !== '';
+
+const SuccessLabel = styled(LightLabel)`
+ color: ${themeColor('textSuccess')};
+`;