diff options
author | guillaume-peoch-sonarsource <guillaume.peoch@sonarsource.com> | 2024-09-03 15:18:25 +0200 |
---|---|---|
committer | sonartech <sonartech@sonarsource.com> | 2024-09-11 20:03:48 +0000 |
commit | bdcd59aefa14abd2456db15b571fbaaeb681218c (patch) | |
tree | 26673e1d4b0512148ac2adc1aa1daae6655fbb6a /server/sonar-web/src | |
parent | 2923559d555c22042d0257547e52d7bdc7212da6 (diff) | |
download | sonarqube-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')
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')}; +`; |