Skip to content

Commit

Permalink
Feature: Desktop view - Implement personal setting section to change …
Browse files Browse the repository at this point in the history
…the password.

Co-authored-by: Florian Liebe <fl@zammad.com>
  • Loading branch information
tschaefer and fliebe92 committed Apr 24, 2024
1 parent 818035e commit 33992f1
Show file tree
Hide file tree
Showing 17 changed files with 728 additions and 22 deletions.
33 changes: 11 additions & 22 deletions app/controllers/users_controller.rb
Expand Up @@ -577,41 +577,30 @@ def password_reset_verify
=end

def password_change

# check old password
if !params[:password_old] || !PasswordPolicy::MaxLength.valid?(params[:password_old])
render json: { message: 'failed', notice: [__('Please provide your current password.')] }, status: :unprocessable_entity
return
end

current_password_verified = PasswordHash.verified?(current_user.password, params[:password_old])
if !current_password_verified
render json: { message: 'failed', notice: [__('The current password you provided is incorrect.')] }, status: :unprocessable_entity
return
end

# set new password
if !params[:password_new]
render json: { message: 'failed', notice: [__('Please provide your new password.')] }, status: :unprocessable_entity
return
end

result = PasswordPolicy.new(params[:password_new])
if !result.valid?
render json: { message: 'failed', notice: result.error }, status: :unprocessable_entity
begin
Service::User::ChangePassword.new(
user: current_user,
current_password: params[:password_old],
new_password: params[:password_new]
).execute
rescue PasswordPolicy::Error => e
render json: { message: 'failed', notice: [e.message] }, status: :unprocessable_entity
return
rescue PasswordHash::Error
render json: { message: 'failed', notice: [__('The current password you provided is incorrect.')] }, status: :unprocessable_entity
return
end

current_user.update!(password: params[:password_new])

if current_user.email.present?
NotificationFactory::Mailer.notification(
template: 'password_change',
user: current_user,
objects: {
user: current_user,
}
)
end

render json: { message: 'ok', user_login: current_user.login }, status: :ok
Expand Down
@@ -0,0 +1,20 @@
// Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/

import { axe } from 'vitest-axe'

import { visitView } from '#tests/support/components/visitView.ts'
import { mockApplicationConfig } from '#tests/support/mock-applicationConfig.ts'

describe('testing locale a11y view', async () => {
beforeEach(() => {
mockApplicationConfig({
user_show_password_login: true,
})
})

it('has no accessibility violations', async () => {
const view = await visitView('/personal-setting/password')
const results = await axe(view.html())
expect(results).toHaveNoViolations()
})
})
@@ -0,0 +1,119 @@
// Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/

import type { ExtendedRenderResult } from '#tests/support/components/renderComponent.ts'
import { visitView } from '#tests/support/components/visitView.ts'
import { mockAccount } from '#tests/support/mock-account.ts'
import { mockApplicationConfig } from '#tests/support/mock-applicationConfig.ts'

import { mockAccountChangePasswordMutation } from '../graphql/mutations/accountChangePassword.mocks.ts'

const changePassword = async (
view: ExtendedRenderResult,
currentPassword: string,
newPassword: string,
newPasswordConfirm?: string,
) => {
await view.events.type(
await view.findByLabelText('Current password'),
currentPassword,
)
await view.events.type(
await view.findByLabelText('New password'),
newPassword,
)
await view.events.type(
await view.findByLabelText('Confirm new password'),
newPasswordConfirm || newPassword,
)
await view.events.click(view.getByRole('button', { name: 'Change Password' }))
}

describe('password personal settings', () => {
beforeEach(() => {
mockAccount({
firstname: 'John',
lastname: 'Doe',
})

mockApplicationConfig({
user_show_password_login: true,
})
})

it('redirects to the error page when password login is disabled', async () => {
mockApplicationConfig({
user_show_password_login: false,
})

const view = await visitView('/personal-setting/password')

await vi.waitFor(() => {
expect(view, 'correctly redirects to error page').toHaveCurrentUrl(
'/error',
)
})
})

it('shows the form to change the password', async () => {
const view = await visitView('/personal-setting/password')

expect(view.getByText('Current password')).toBeInTheDocument()
expect(view.getByText('New password')).toBeInTheDocument()
expect(view.getByText('Confirm new password')).toBeInTheDocument()

expect(
view.getByRole('button', { name: 'Change Password' }),
).toBeInTheDocument()
})

it('shows an error message when e.g. current password is incorrect', async () => {
mockAccountChangePasswordMutation({
accountChangePassword: {
success: false,
errors: [
{
message: 'The current password you provided is incorrect.',
field: 'current_password',
},
],
},
})

const view = await visitView('/personal-setting/password')

await changePassword(view, 'wrong-password', 'new-password')

expect(
await view.findByText('The current password you provided is incorrect.'),
).toBeInTheDocument()
})

it('shows an error message when new password and confirmation do not match', async () => {
const view = await visitView('/personal-setting/password')

await changePassword(view, 'old-password', 'new-password', 'wrong-password')

expect(
await view.findByText(
"This field doesn't correspond to the expected value.",
),
).toBeInTheDocument()
})

it('shows a success message when password was changed successfully', async () => {
mockAccountChangePasswordMutation({
accountChangePassword: {
success: true,
errors: null,
},
})

const view = await visitView('/personal-setting/password')

await changePassword(view, 'old-password', 'new-password')

expect(
await view.findByText('Password changed successfully.'),
).toBeInTheDocument()
})
})
@@ -0,0 +1,25 @@
import * as Types from '#shared/graphql/types.ts';

import gql from 'graphql-tag';
import { ErrorsFragmentDoc } from '../../../../../../shared/graphql/fragments/errors.api';
import * as VueApolloComposable from '@vue/apollo-composable';
import * as VueCompositionApi from 'vue';
export type ReactiveFunction<TParam> = () => TParam;

export const AccountChangePasswordDocument = gql`
mutation accountChangePassword($currentPassword: String!, $newPassword: String!) {
accountChangePassword(
currentPassword: $currentPassword
newPassword: $newPassword
) {
success
errors {
...errors
}
}
}
${ErrorsFragmentDoc}`;
export function useAccountChangePasswordMutation(options: VueApolloComposable.UseMutationOptions<Types.AccountChangePasswordMutation, Types.AccountChangePasswordMutationVariables> | ReactiveFunction<VueApolloComposable.UseMutationOptions<Types.AccountChangePasswordMutation, Types.AccountChangePasswordMutationVariables>> = {}) {
return VueApolloComposable.useMutation<Types.AccountChangePasswordMutation, Types.AccountChangePasswordMutationVariables>(AccountChangePasswordDocument, options);
}
export type AccountChangePasswordMutationCompositionFunctionResult = VueApolloComposable.UseMutationReturn<Types.AccountChangePasswordMutation, Types.AccountChangePasswordMutationVariables>;
@@ -0,0 +1,8 @@
mutation accountChangePassword($currentPassword: String!, $newPassword: String!) {
accountChangePassword(currentPassword: $currentPassword, newPassword: $newPassword) {
success
errors {
...errors
}
}
}
@@ -0,0 +1,12 @@
import * as Types from '#shared/graphql/types.ts';

import * as Mocks from '#tests/graphql/builders/mocks.ts'
import * as Operations from './accountChangePassword.api.ts'

export function mockAccountChangePasswordMutation(defaults: Mocks.MockDefaultsValue<Types.AccountChangePasswordMutation, Types.AccountChangePasswordMutationVariables>) {
return Mocks.mockGraphQLResult(Operations.AccountChangePasswordDocument, defaults)
}

export function waitForAccountChangePasswordMutationCalls() {
return Mocks.waitForGraphQLMockCalls<Types.AccountChangePasswordMutation>(Operations.AccountChangePasswordDocument)
}
@@ -0,0 +1,9 @@
// Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/

import type { FormValues } from '#shared/components/Form/types.ts'

export interface ChangePasswordFormData extends FormValues {
currentPassword: string
newPassword: string
newPasswordConfirmation: string
}
@@ -0,0 +1,35 @@
// Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/

import { useApplicationStore } from '#shared/stores/application.ts'

import type { PersonalSettingPlugin } from './types.ts'

export default <PersonalSettingPlugin>{
label: __('Password'),
category: {
label: __('Security'),
id: 'category-security',
order: 9000,
},
route: {
path: 'password',
name: 'PersonalSettingPassword',
component: () => import('../../PersonalSettingPassword.vue'),
level: 2,
meta: {
title: __('Password'),
requiresAuth: true,
requiredPermission: 'user_preferences.password',
},
},
order: 1000,
keywords: __(
'current,new,confirm,change,current password,new password,confirm password,change password',
),
show: () => {
const { config } = useApplicationStore()

if (!config.user_show_password_login) return false
return true
},
}

0 comments on commit 33992f1

Please sign in to comment.