@@ -17,11 +17,20 @@ | |||
* along with this program; if not, write to the Free Software Foundation, | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
import { shallow } from 'enzyme'; | |||
import * as React from 'react'; | |||
import { mockLoggedInUser } from '../../../../helpers/testMocks'; | |||
import Password from '../Password'; | |||
it('renders correctly', () => { | |||
expect(shallow(<Password user={mockLoggedInUser()} />)).toMatchSnapshot(); | |||
}); | |||
.reset-page { | |||
padding-top: 10vh; | |||
} | |||
.reset-page h1 { | |||
line-height: 1.5; | |||
font-size: 24px; | |||
font-weight: 300; | |||
text-align: center; | |||
} | |||
.reset-form { | |||
width: 300px; | |||
margin-left: auto; | |||
margin-right: auto; | |||
} |
@@ -0,0 +1,53 @@ | |||
/* | |||
* SonarQube | |||
* Copyright (C) 2009-2020 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 * as React from 'react'; | |||
import { translate } from 'sonar-ui-common/helpers/l10n'; | |||
import ResetPasswordForm from '../../components/common/ResetPassword'; | |||
import { whenLoggedIn } from '../../components/hoc/whenLoggedIn'; | |||
import { Router, withRouter } from '../../components/hoc/withRouter'; | |||
import GlobalMessagesContainer from './GlobalMessagesContainer'; | |||
import './ResetPassword.css'; | |||
export interface ResetPasswordProps { | |||
currentUser: T.LoggedInUser; | |||
router: Router; | |||
} | |||
export function ResetPassword(props: ResetPasswordProps) { | |||
const { router, currentUser } = props; | |||
const redirect = () => { | |||
router.replace('/'); | |||
}; | |||
return ( | |||
<div className="reset-page"> | |||
<h1 className="text-center spacer-bottom">{translate('my_account.reset_password')}</h1> | |||
<h2 className="text-center huge-spacer-bottom"> | |||
{translate('my_account.reset_password.explain')} | |||
</h2> | |||
<GlobalMessagesContainer /> | |||
<div className="reset-form"> | |||
<ResetPasswordForm user={currentUser} onPasswordChange={redirect} /> | |||
</div> | |||
</div> | |||
); | |||
} | |||
export default whenLoggedIn(withRouter(ResetPassword)); |
@@ -0,0 +1,33 @@ | |||
/* | |||
* SonarQube | |||
* Copyright (C) 2009-2020 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 { shallow } from 'enzyme'; | |||
import * as React from 'react'; | |||
import { mockLoggedInUser, mockRouter } from '../../../helpers/testMocks'; | |||
import { ResetPassword, ResetPasswordProps } from '../ResetPassword'; | |||
it('should render correctly', () => { | |||
expect(shallowRender()).toMatchSnapshot(); | |||
}); | |||
function shallowRender(props: Partial<ResetPasswordProps> = {}) { | |||
return shallow( | |||
<ResetPassword currentUser={mockLoggedInUser()} router={mockRouter()} {...props} /> | |||
); | |||
} |
@@ -0,0 +1,35 @@ | |||
// Jest Snapshot v1, https://goo.gl/fbAQLP | |||
exports[`should render correctly 1`] = ` | |||
<div | |||
className="reset-page" | |||
> | |||
<h1 | |||
className="text-center spacer-bottom" | |||
> | |||
my_account.reset_password | |||
</h1> | |||
<h2 | |||
className="text-center huge-spacer-bottom" | |||
> | |||
my_account.reset_password.explain | |||
</h2> | |||
<Connect(GlobalMessages) /> | |||
<div | |||
className="reset-form" | |||
> | |||
<ResetPassword | |||
onPasswordChange={[Function]} | |||
user={ | |||
Object { | |||
"groups": Array [], | |||
"isLoggedIn": true, | |||
"login": "luke", | |||
"name": "Skywalker", | |||
"scmAccounts": Array [], | |||
} | |||
} | |||
/> | |||
</div> | |||
</div> | |||
`; |
@@ -315,6 +315,12 @@ export default function startReactApp( | |||
{renderAdminRoutes()} | |||
</Route> | |||
<Route | |||
// We don't want this route to have any menu. | |||
// That is why we can not have it under the accountRoutes | |||
path="account/reset_password" | |||
component={lazyLoadComponent(() => import('../components/ResetPassword'))} | |||
/> | |||
<Route | |||
path="not_found" | |||
component={lazyLoadComponent(() => import('../components/NotFound'))} |
@@ -21,8 +21,8 @@ import * as React from 'react'; | |||
import { Helmet } from 'react-helmet-async'; | |||
import { connect } from 'react-redux'; | |||
import { translate } from 'sonar-ui-common/helpers/l10n'; | |||
import ResetPassword from '../../../components/common/ResetPassword'; | |||
import { getCurrentUser, Store } from '../../../store/rootReducer'; | |||
import Password from './Password'; | |||
import Tokens from './Tokens'; | |||
export interface SecurityProps { | |||
@@ -34,7 +34,7 @@ export function Security({ user }: SecurityProps) { | |||
<div className="account-body account-container"> | |||
<Helmet defer={false} title={translate('my_account.security')} /> | |||
<Tokens login={user.login} /> | |||
{user.local && <Password user={user} />} | |||
{user.local && <ResetPassword user={user} />} | |||
</div> | |||
); | |||
} |
@@ -12,7 +12,7 @@ exports[`should render correctly: local user 1`] = ` | |||
<Tokens | |||
login="luke" | |||
/> | |||
<Password | |||
<ResetPassword | |||
user={ | |||
Object { | |||
"groups": Array [], |
@@ -21,10 +21,11 @@ import * as React from 'react'; | |||
import { SubmitButton } from 'sonar-ui-common/components/controls/buttons'; | |||
import { Alert } from 'sonar-ui-common/components/ui/Alert'; | |||
import { translate } from 'sonar-ui-common/helpers/l10n'; | |||
import { changePassword } from '../../../api/users'; | |||
import { changePassword } from '../../api/users'; | |||
interface Props { | |||
user: T.LoggedInUser; | |||
onPasswordChange?: () => void; | |||
} | |||
interface State { | |||
@@ -32,19 +33,25 @@ interface State { | |||
success: boolean; | |||
} | |||
export default class Password extends React.Component<Props, State> { | |||
oldPassword!: HTMLInputElement; | |||
password!: HTMLInputElement; | |||
passwordConfirmation!: HTMLInputElement; | |||
export default class ResetPassword extends React.Component<Props, State> { | |||
oldPassword: HTMLInputElement | null = null; | |||
password: HTMLInputElement | null = null; | |||
passwordConfirmation: HTMLInputElement | null = null; | |||
state: State = { | |||
success: false | |||
}; | |||
handleSuccessfulChange = () => { | |||
if (!this.oldPassword || !this.password || !this.passwordConfirmation) { | |||
return; | |||
} | |||
this.oldPassword.value = ''; | |||
this.password.value = ''; | |||
this.passwordConfirmation.value = ''; | |||
this.setState({ success: true, errors: undefined }); | |||
if (this.props.onPasswordChange) { | |||
this.props.onPasswordChange(); | |||
} | |||
}; | |||
setErrors = (errors: string[]) => { | |||
@@ -53,7 +60,9 @@ export default class Password extends React.Component<Props, State> { | |||
handleChangePassword = (event: React.FormEvent) => { | |||
event.preventDefault(); | |||
if (!this.oldPassword || !this.password || !this.passwordConfirmation) { | |||
return; | |||
} | |||
const { user } = this.props; | |||
const previousPassword = this.oldPassword.value; | |||
const password = this.password.value; | |||
@@ -65,7 +74,9 @@ export default class Password extends React.Component<Props, State> { | |||
} else { | |||
changePassword({ login: user.login, password, previousPassword }).then( | |||
this.handleSuccessfulChange, | |||
() => {} | |||
() => { | |||
// error already reported. | |||
} | |||
); | |||
} | |||
}; | |||
@@ -82,6 +93,7 @@ export default class Password extends React.Component<Props, State> { | |||
{errors && | |||
errors.map((e, i) => ( | |||
/* eslint-disable-next-line react/no-array-index-key */ | |||
<Alert key={i} variant="error"> | |||
{e} | |||
</Alert> | |||
@@ -96,7 +108,7 @@ export default class Password extends React.Component<Props, State> { | |||
autoComplete="off" | |||
id="old_password" | |||
name="old_password" | |||
ref={elem => (this.oldPassword = elem!)} | |||
ref={elem => (this.oldPassword = elem)} | |||
required={true} | |||
type="password" | |||
/> | |||
@@ -110,7 +122,7 @@ export default class Password extends React.Component<Props, State> { | |||
autoComplete="off" | |||
id="password" | |||
name="password" | |||
ref={elem => (this.password = elem!)} | |||
ref={elem => (this.password = elem)} | |||
required={true} | |||
type="password" | |||
/> | |||
@@ -124,15 +136,13 @@ export default class Password extends React.Component<Props, State> { | |||
autoComplete="off" | |||
id="password_confirmation" | |||
name="password_confirmation" | |||
ref={elem => (this.passwordConfirmation = elem!)} | |||
ref={elem => (this.passwordConfirmation = elem)} | |||
required={true} | |||
type="password" | |||
/> | |||
</div> | |||
<div className="form-field"> | |||
<SubmitButton id="change-password"> | |||
{translate('my_profile.password.submit')} | |||
</SubmitButton> | |||
<SubmitButton id="change-password">{translate('update_verb')}</SubmitButton> | |||
</div> | |||
</form> | |||
</section> |
@@ -0,0 +1,79 @@ | |||
/* | |||
* SonarQube | |||
* Copyright (C) 2009-2020 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 { shallow } from 'enzyme'; | |||
import * as React from 'react'; | |||
import { waitAndUpdate } from 'sonar-ui-common/helpers/testUtils'; | |||
import { changePassword } from '../../../api/users'; | |||
import { mockEvent, mockLoggedInUser } from '../../../helpers/testMocks'; | |||
import ResetPassword from '../ResetPassword'; | |||
jest.mock('../../../api/users', () => ({ | |||
changePassword: jest.fn().mockResolvedValue({}) | |||
})); | |||
it('should trigger on password change prop', () => { | |||
const onPasswordChange = jest.fn(); | |||
const wrapper = shallowRender({ onPasswordChange }); | |||
wrapper.instance().handleSuccessfulChange(); | |||
expect(onPasswordChange).not.toBeCalled(); | |||
wrapper.instance().oldPassword = { value: '' } as HTMLInputElement; | |||
wrapper.instance().password = { value: '' } as HTMLInputElement; | |||
wrapper.instance().passwordConfirmation = { value: '' } as HTMLInputElement; | |||
wrapper.instance().handleSuccessfulChange(); | |||
expect(onPasswordChange).toBeCalled(); | |||
}); | |||
it('should not trigger password change', () => { | |||
const wrapper = shallowRender(); | |||
wrapper.instance().oldPassword = { value: 'testold' } as HTMLInputElement; | |||
wrapper.instance().password = { value: 'test', focus: () => {} } as HTMLInputElement; | |||
wrapper.instance().passwordConfirmation = { value: 'test1' } as HTMLInputElement; | |||
wrapper.instance().handleChangePassword(mockEvent()); | |||
expect(changePassword).not.toBeCalled(); | |||
expect(wrapper.state().errors).toBeDefined(); | |||
}); | |||
it('should trigger password change', async () => { | |||
const user = mockLoggedInUser(); | |||
const wrapper = shallowRender({ user }); | |||
wrapper.instance().handleChangePassword(mockEvent()); | |||
await waitAndUpdate(wrapper); | |||
expect(changePassword).not.toBeCalled(); | |||
wrapper.instance().oldPassword = { value: 'testold' } as HTMLInputElement; | |||
wrapper.instance().password = { value: 'test' } as HTMLInputElement; | |||
wrapper.instance().passwordConfirmation = { value: 'test' } as HTMLInputElement; | |||
wrapper.instance().handleChangePassword(mockEvent()); | |||
await waitAndUpdate(wrapper); | |||
expect(changePassword).toBeCalledWith({ | |||
login: user.login, | |||
password: 'test', | |||
previousPassword: 'testold' | |||
}); | |||
}); | |||
it('renders correctly', () => { | |||
expect(shallowRender()).toMatchSnapshot(); | |||
}); | |||
function shallowRender(props?: Partial<ResetPassword['props']>) { | |||
return shallow<ResetPassword>(<ResetPassword user={mockLoggedInUser()} {...props} />); | |||
} |
@@ -82,7 +82,7 @@ exports[`renders correctly 1`] = ` | |||
<SubmitButton | |||
id="change-password" | |||
> | |||
my_profile.password.submit | |||
update_verb | |||
</SubmitButton> | |||
</div> | |||
</form> |
@@ -1824,11 +1824,10 @@ my_profile.email=Email | |||
my_profile.groups=Groups | |||
my_profile.scm_accounts=SCM Accounts | |||
my_profile.scm_accounts.tooltip=SCM accounts are used for automatic issue assignment. Login and email are automatically considered as SCM account. | |||
my_profile.password.title=Change password | |||
my_profile.password.title=Enter a new password | |||
my_profile.password.old=Old Password | |||
my_profile.password.new=New Password | |||
my_profile.password.confirm=Confirm Password | |||
my_profile.password.submit=Change password | |||
my_profile.password.changed=The password has been changed! | |||
my_profile.notifications.submit=Save changes | |||
my_profile.overall_notifications.title=Overall notifications | |||
@@ -1866,6 +1865,8 @@ my_account.add_project.azure=Azure DevOps | |||
my_account.add_project.bitbucket=Bitbucket | |||
my_account.add_project.github=GitHub | |||
my_account.add_project.gitlab=GitLab | |||
my_account.reset_password=Update your password | |||
my_account.reset_password.explain=This account should not use the default password. | |||
my_account.create_new_project_portfolio_or_application=Analyze new project / Create new portfolio or application | |||