From 33992f11773a549b1a350e495e1e8ab2a4fbd4b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20Sch=C3=A4fer?= Date: Wed, 24 Apr 2024 13:37:15 +0200 Subject: [PATCH] Feature: Desktop view - Implement personal setting section to change the password. Co-authored-by: Florian Liebe --- app/controllers/users_controller.rb | 33 ++--- .../personal-setting-password-a11y.spec.ts | 20 +++ .../personal-setting-password.spec.ts | 119 +++++++++++++++ .../mutations/accountChangePassword.api.ts | 25 ++++ .../mutations/accountChangePassword.graphql | 8 + .../mutations/accountChangePassword.mocks.ts | 12 ++ .../personal-setting/types/change-password.ts | 9 ++ .../views/PersonalSetting/plugins/password.ts | 35 +++++ .../views/PersonalSettingPassword.vue | 137 ++++++++++++++++++ app/frontend/shared/graphql/types.ts | 26 ++++ .../gql/mutations/account/change_password.rb | 34 +++++ app/graphql/graphql_introspection.json | 90 ++++++++++++ app/services/service/user/change_password.rb | 38 +++++ i18n/zammad.pot | 30 ++++ lib/password_hash.rb | 8 + .../mutations/account/change_password_spec.rb | 70 +++++++++ .../service/user/change_password_spec.rb | 56 +++++++ 17 files changed, 728 insertions(+), 22 deletions(-) create mode 100644 app/frontend/apps/desktop/pages/personal-setting/__tests__/personal-setting-password-a11y.spec.ts create mode 100644 app/frontend/apps/desktop/pages/personal-setting/__tests__/personal-setting-password.spec.ts create mode 100644 app/frontend/apps/desktop/pages/personal-setting/graphql/mutations/accountChangePassword.api.ts create mode 100644 app/frontend/apps/desktop/pages/personal-setting/graphql/mutations/accountChangePassword.graphql create mode 100644 app/frontend/apps/desktop/pages/personal-setting/graphql/mutations/accountChangePassword.mocks.ts create mode 100644 app/frontend/apps/desktop/pages/personal-setting/types/change-password.ts create mode 100644 app/frontend/apps/desktop/pages/personal-setting/views/PersonalSetting/plugins/password.ts create mode 100644 app/frontend/apps/desktop/pages/personal-setting/views/PersonalSettingPassword.vue create mode 100644 app/graphql/gql/mutations/account/change_password.rb create mode 100644 app/services/service/user/change_password.rb create mode 100644 spec/graphql/gql/mutations/account/change_password_spec.rb create mode 100644 spec/services/service/user/change_password_spec.rb diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index e7008e3fa67f..09daa0046af4 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -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 diff --git a/app/frontend/apps/desktop/pages/personal-setting/__tests__/personal-setting-password-a11y.spec.ts b/app/frontend/apps/desktop/pages/personal-setting/__tests__/personal-setting-password-a11y.spec.ts new file mode 100644 index 000000000000..73176cbb62a8 --- /dev/null +++ b/app/frontend/apps/desktop/pages/personal-setting/__tests__/personal-setting-password-a11y.spec.ts @@ -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() + }) +}) diff --git a/app/frontend/apps/desktop/pages/personal-setting/__tests__/personal-setting-password.spec.ts b/app/frontend/apps/desktop/pages/personal-setting/__tests__/personal-setting-password.spec.ts new file mode 100644 index 000000000000..649991aa2655 --- /dev/null +++ b/app/frontend/apps/desktop/pages/personal-setting/__tests__/personal-setting-password.spec.ts @@ -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() + }) +}) diff --git a/app/frontend/apps/desktop/pages/personal-setting/graphql/mutations/accountChangePassword.api.ts b/app/frontend/apps/desktop/pages/personal-setting/graphql/mutations/accountChangePassword.api.ts new file mode 100644 index 000000000000..2d0e45fc7df3 --- /dev/null +++ b/app/frontend/apps/desktop/pages/personal-setting/graphql/mutations/accountChangePassword.api.ts @@ -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; + +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 | ReactiveFunction> = {}) { + return VueApolloComposable.useMutation(AccountChangePasswordDocument, options); +} +export type AccountChangePasswordMutationCompositionFunctionResult = VueApolloComposable.UseMutationReturn; \ No newline at end of file diff --git a/app/frontend/apps/desktop/pages/personal-setting/graphql/mutations/accountChangePassword.graphql b/app/frontend/apps/desktop/pages/personal-setting/graphql/mutations/accountChangePassword.graphql new file mode 100644 index 000000000000..f24fc635e804 --- /dev/null +++ b/app/frontend/apps/desktop/pages/personal-setting/graphql/mutations/accountChangePassword.graphql @@ -0,0 +1,8 @@ +mutation accountChangePassword($currentPassword: String!, $newPassword: String!) { + accountChangePassword(currentPassword: $currentPassword, newPassword: $newPassword) { + success + errors { + ...errors + } + } +} diff --git a/app/frontend/apps/desktop/pages/personal-setting/graphql/mutations/accountChangePassword.mocks.ts b/app/frontend/apps/desktop/pages/personal-setting/graphql/mutations/accountChangePassword.mocks.ts new file mode 100644 index 000000000000..b36c97942343 --- /dev/null +++ b/app/frontend/apps/desktop/pages/personal-setting/graphql/mutations/accountChangePassword.mocks.ts @@ -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) { + return Mocks.mockGraphQLResult(Operations.AccountChangePasswordDocument, defaults) +} + +export function waitForAccountChangePasswordMutationCalls() { + return Mocks.waitForGraphQLMockCalls(Operations.AccountChangePasswordDocument) +} diff --git a/app/frontend/apps/desktop/pages/personal-setting/types/change-password.ts b/app/frontend/apps/desktop/pages/personal-setting/types/change-password.ts new file mode 100644 index 000000000000..ed44c614faa3 --- /dev/null +++ b/app/frontend/apps/desktop/pages/personal-setting/types/change-password.ts @@ -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 +} diff --git a/app/frontend/apps/desktop/pages/personal-setting/views/PersonalSetting/plugins/password.ts b/app/frontend/apps/desktop/pages/personal-setting/views/PersonalSetting/plugins/password.ts new file mode 100644 index 000000000000..2e93e4915e7d --- /dev/null +++ b/app/frontend/apps/desktop/pages/personal-setting/views/PersonalSetting/plugins/password.ts @@ -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 { + 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 + }, +} diff --git a/app/frontend/apps/desktop/pages/personal-setting/views/PersonalSettingPassword.vue b/app/frontend/apps/desktop/pages/personal-setting/views/PersonalSettingPassword.vue new file mode 100644 index 000000000000..1dcd0c1956ac --- /dev/null +++ b/app/frontend/apps/desktop/pages/personal-setting/views/PersonalSettingPassword.vue @@ -0,0 +1,137 @@ + + + + + diff --git a/app/frontend/shared/graphql/types.ts b/app/frontend/shared/graphql/types.ts index 06168f2438a4..3e433aab729f 100644 --- a/app/frontend/shared/graphql/types.ts +++ b/app/frontend/shared/graphql/types.ts @@ -64,6 +64,15 @@ export type AccountAvatarUpdatesPayload = { avatars?: Maybe>; }; +/** Autogenerated return type of AccountChangePassword. */ +export type AccountChangePasswordPayload = { + __typename?: 'AccountChangePasswordPayload'; + /** Errors encountered during execution of the mutation. */ + errors?: Maybe>; + /** This indicates if changing the password was successful. */ + success?: Maybe; +}; + /** Autogenerated return type of AccountLocale. */ export type AccountLocalePayload = { __typename?: 'AccountLocalePayload'; @@ -1160,6 +1169,8 @@ export type Mutations = { accountAvatarDelete?: Maybe; /** Select avatar for the currently logged in user. */ accountAvatarSelect?: Maybe; + /** Change user password. */ + accountChangePassword?: Maybe; /** Update the language of the currently logged in user */ accountLocale?: Maybe; /** Update user profile out of office settings */ @@ -1289,6 +1300,13 @@ export type MutationsAccountAvatarSelectArgs = { }; +/** All available mutations */ +export type MutationsAccountChangePasswordArgs = { + currentPassword: Scalars['String']['input']; + newPassword: Scalars['String']['input']; +}; + + /** All available mutations */ export type MutationsAccountLocaleArgs = { locale: Scalars['String']['input']; @@ -3551,6 +3569,14 @@ export type AccountAvatarSelectMutationVariables = Exact<{ export type AccountAvatarSelectMutation = { __typename?: 'Mutations', accountAvatarSelect?: { __typename?: 'AccountAvatarSelectPayload', success: boolean, errors?: Array<{ __typename?: 'UserError', message: string, field?: string | null }> | null } | null }; +export type AccountChangePasswordMutationVariables = Exact<{ + currentPassword: Scalars['String']['input']; + newPassword: Scalars['String']['input']; +}>; + + +export type AccountChangePasswordMutation = { __typename?: 'Mutations', accountChangePassword?: { __typename?: 'AccountChangePasswordPayload', success?: boolean | null, errors?: Array<{ __typename?: 'UserError', message: string, field?: string | null }> | null } | null }; + export type AccountOutOfOfficeMutationVariables = Exact<{ input: OutOfOfficeInput; }>; diff --git a/app/graphql/gql/mutations/account/change_password.rb b/app/graphql/gql/mutations/account/change_password.rb new file mode 100644 index 000000000000..081885ebc404 --- /dev/null +++ b/app/graphql/gql/mutations/account/change_password.rb @@ -0,0 +1,34 @@ +# Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ + +module Gql::Mutations + class Account::ChangePassword < BaseMutation + include Gql::Concerns::HandlesThrottling + + description 'Change user password.' + + argument :current_password, String, required: true, description: 'The current password of the user.' + argument :new_password, String, required: true, description: 'The new password of the user.' + + field :success, Boolean, description: 'This indicates if changing the password was successful.' + + def ready?(...) + throttle!(limit: 3, period: 1.minute, by_identifier: context.current_user.login) + end + + def resolve(current_password:, new_password:) + begin + Service::User::ChangePassword.new( + user: context.current_user, + current_password: current_password, + new_password: new_password + ).execute + rescue PasswordHash::Error + return error_response({ message: __('The current password you provided is incorrect.'), field: 'current_password' }) + rescue PasswordPolicy::Error => e + return error_response({ message: e.message, field: 'new_password' }) + end + + { success: true } + end + end +end diff --git a/app/graphql/graphql_introspection.json b/app/graphql/graphql_introspection.json index 46b9c3f3d8c2..01352b9897bc 100644 --- a/app/graphql/graphql_introspection.json +++ b/app/graphql/graphql_introspection.json @@ -254,6 +254,55 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "OBJECT", + "name": "AccountChangePasswordPayload", + "description": "Autogenerated return type of AccountChangePassword.", + "fields": [ + { + "name": "errors", + "description": "Errors encountered during execution of the mutation.", + "args": [ + + ], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "UserError", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "success", + "description": "This indicates if changing the password was successful.", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, { "kind": "OBJECT", "name": "AccountLocalePayload", @@ -7568,6 +7617,47 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "accountChangePassword", + "description": "Change user password.", + "args": [ + { + "name": "currentPassword", + "description": "The current password of the user.", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "newPassword", + "description": "The new password of the user.", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null + } + ], + "type": { + "kind": "OBJECT", + "name": "AccountChangePasswordPayload", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "accountLocale", "description": "Update the language of the currently logged in user", diff --git a/app/services/service/user/change_password.rb b/app/services/service/user/change_password.rb new file mode 100644 index 000000000000..4957fe4a1ee4 --- /dev/null +++ b/app/services/service/user/change_password.rb @@ -0,0 +1,38 @@ +# Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ + +class Service::User::ChangePassword < Service::Base + + attr_reader :user, :current_password, :new_password + + def initialize(user:, current_password:, new_password:) + super() + + @user = user + @current_password = current_password + @new_password = new_password + end + + def execute + PasswordHash.verified!(@user.password, @current_password) + PasswordPolicy.new(@new_password).valid! + + @user.update!(password: @new_password) + notify_user + + true + end + + private + + def notify_user + return if @user.email.blank? + + NotificationFactory::Mailer.notification( + template: 'password_change', + user: @user, + objects: { + user: @user, + } + ) + end +end diff --git a/i18n/zammad.pot b/i18n/zammad.pot index 0d96968b5f0a..3ba79e8acc51 100644 --- a/i18n/zammad.pot +++ b/i18n/zammad.pot @@ -2294,6 +2294,10 @@ msgstr "" msgid "Change Objects" msgstr "" +#: app/frontend/apps/desktop/pages/personal-setting/views/PersonalSettingPassword.vue +msgid "Change Password" +msgstr "" + #: app/assets/javascripts/app/views/profile/password.jst.eco msgid "Change Your Password" msgstr "" @@ -2824,6 +2828,10 @@ msgstr "" msgid "Confirm merge" msgstr "" +#: app/frontend/apps/desktop/pages/personal-setting/views/PersonalSettingPassword.vue +msgid "Confirm new password" +msgstr "" + #: app/frontend/apps/desktop/composables/authentication/useSignupForm.ts #: app/frontend/apps/desktop/pages/authentication/views/PasswordResetVerify.vue msgid "Confirm password" @@ -3321,6 +3329,7 @@ msgid "Current User" msgstr "" #: app/assets/javascripts/app/controllers/_profile/password.coffee +#: app/frontend/apps/desktop/pages/personal-setting/views/PersonalSettingPassword.vue msgid "Current password" msgstr "" @@ -8500,6 +8509,7 @@ msgid "New organizations are shared." msgstr "" #: app/assets/javascripts/app/controllers/_profile/password.coffee +#: app/frontend/apps/desktop/pages/personal-setting/views/PersonalSettingPassword.vue msgid "New password" msgstr "" @@ -9506,6 +9516,8 @@ msgstr "" #: app/frontend/apps/desktop/pages/authentication/views/Login.vue #: app/frontend/apps/desktop/pages/authentication/views/PasswordResetVerify.vue #: app/frontend/apps/desktop/pages/guided-setup/components/GuidedSetupImport/GuidedSetupImportSource/GuidedSetupImportSourceKayako.vue +#: app/frontend/apps/desktop/pages/personal-setting/views/PersonalSetting/plugins/password.ts +#: app/frontend/apps/desktop/pages/personal-setting/views/PersonalSettingPassword.vue #: app/frontend/apps/mobile/pages/authentication/components/LoginCredentialsForm.vue #: db/seeds/object_manager_attributes.rb #: db/seeds/permissions.rb @@ -9529,6 +9541,14 @@ msgstr "" msgid "Password changed successfully!" msgstr "" +#: app/frontend/apps/desktop/pages/personal-setting/views/PersonalSettingPassword.vue +msgid "Password changed successfully." +msgstr "" + +#: app/frontend/apps/desktop/pages/personal-setting/views/PersonalSettingPassword.vue +msgid "Password could not be changed." +msgstr "" + #: app/assets/javascripts/app/views/settings/proxy.jst.eco msgid "Password for proxy connection" msgstr "" @@ -10990,6 +11010,7 @@ msgstr "" #: app/assets/javascripts/app/views/ticket_zoom/article_new.jst.eco #: app/assets/javascripts/app/views/ticket_zoom/article_view.jst.eco #: app/frontend/apps/desktop/pages/personal-setting/views/PersonalSetting/plugins/devices.ts +#: app/frontend/apps/desktop/pages/personal-setting/views/PersonalSetting/plugins/password.ts #: app/frontend/apps/mobile/pages/ticket/components/TicketDetailView/ArticleMetadataDialog.vue #: app/frontend/apps/mobile/pages/ticket/composable/useTicketEditForm.ts #: app/frontend/apps/mobile/pages/ticket/views/TicketCreate.vue @@ -12595,6 +12616,7 @@ msgstr "" #: app/assets/javascripts/app/controllers/widget/two_factor_configuration/modal/password_check.coffee #: app/controllers/users_controller.rb +#: app/graphql/gql/mutations/account/change_password.rb msgid "The current password you provided is incorrect." msgstr "" @@ -12806,6 +12828,10 @@ msgstr "" msgid "The password could not be set. Please contact your administrator." msgstr "" +#: lib/password_hash.rb +msgid "The password is invalid." +msgstr "" + #: app/frontend/apps/desktop/pages/authentication/views/PasswordReset.vue msgid "The password reset request was successful." msgstr "" @@ -16136,6 +16162,10 @@ msgstr "" msgid "current user organization" msgstr "" +#: app/frontend/apps/desktop/pages/personal-setting/views/PersonalSetting/plugins/password.ts +msgid "current,new,confirm,change,current password,new password,confirm password,change password" +msgstr "" + #: app/assets/javascripts/app/controllers/translation.coffee msgid "custom" msgstr "" diff --git a/lib/password_hash.rb b/lib/password_hash.rb index 4df66c85319c..f90d6fec3941 100644 --- a/lib/password_hash.rb +++ b/lib/password_hash.rb @@ -3,6 +3,8 @@ module PasswordHash include ApplicationLib + class PasswordHash::Error < StandardError; end + extend self def crypt(password) @@ -16,6 +18,12 @@ def verified?(pw_hash, password) false end + def verified!(pw_hash, password) + return if verified?(pw_hash, password) + + raise PasswordHash::Error, __('The password is invalid.') + end + def crypted?(pw_hash) return false if !pw_hash return true if hashed_argon2?(pw_hash) diff --git a/spec/graphql/gql/mutations/account/change_password_spec.rb b/spec/graphql/gql/mutations/account/change_password_spec.rb new file mode 100644 index 000000000000..db6764ee9907 --- /dev/null +++ b/spec/graphql/gql/mutations/account/change_password_spec.rb @@ -0,0 +1,70 @@ +# Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ + +require 'rails_helper' + +RSpec.describe Gql::Mutations::Account::ChangePassword, type: :graphql do + context 'when changing the password', authenticated_as: :agent do + let(:agent) { create(:agent, password: 'password') } + let(:mutation) do + <<~MUTATION + mutation accountChangePassword($currentPassword: String!, $newPassword: String!) { + accountChangePassword(currentPassword: $currentPassword, newPassword: $newPassword) { + success + errors { + message + field + } + } + } + MUTATION + end + let(:variables) { {} } + + before do + gql.execute(mutation, variables: variables) + end + + context 'with invalid current password' do + let(:variables) do + { + currentPassword: 'foobar', + newPassword: 'new_password' + } + end + + it 'fails with error message', :aggregate_failures do + errors = gql.result.data['errors'].first + expect(errors['message']).to eq('The current password you provided is incorrect.') + expect(errors['field']).to eq('current_password') + end + end + + context 'with password policy violation' do + let(:variables) do + { + currentPassword: 'password', + newPassword: 'FooBarbazbaz' + } + end + + it 'fails with error message', :aggregate_failures do + errors = gql.result.data['errors'].first + expect(errors['message']).to eq('Invalid password, it must contain at least 1 digit!') + expect(errors['field']).to eq('new_password') + end + end + + context 'with valid passwords' do + let(:variables) do + { + currentPassword: 'password', + newPassword: 'IamAValidPassword111einseinself' + } + end + + it 'succeeds' do + expect(gql.result.data['success']).to be_truthy + end + end + end +end diff --git a/spec/services/service/user/change_password_spec.rb b/spec/services/service/user/change_password_spec.rb new file mode 100644 index 000000000000..94c60843b723 --- /dev/null +++ b/spec/services/service/user/change_password_spec.rb @@ -0,0 +1,56 @@ +# Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ + +require 'rails_helper' + +RSpec.describe Service::User::ChangePassword do + let(:user) { create(:user, password: 'password') } + let(:service) { described_class.new(user: user, current_password: current_password, new_password: new_password) } + + shared_examples 'raising an error' do |klass, message| + it 'raises an error' do + expect { service.execute }.to raise_error(klass, message) + end + end + + describe '#execute' do + context 'with not matching current password' do + let(:current_password) { 'foobar' } + let(:new_password) { 'new_password' } + + it_behaves_like 'raising an error', PasswordHash::Error, 'The password is invalid.' + end + + context 'with password policy violation' do + let(:current_password) { 'password' } + let(:new_password) { 'FooBarbazbaz' } + + it_behaves_like 'raising an error', PasswordPolicy::Error, 'Invalid password, it must contain at least 1 digit!' + end + + context 'with valid passwords' do + let(:current_password) { 'password' } + let(:new_password) { 'IamAnValidPassword111einseinself' } + + it 'returns true' do + expect(service.execute).to be_truthy + end + + it 'changes the password' do + expect { service.execute }.to change { user.reload.password } + end + + it 'notifies the user' do + allow(NotificationFactory::Mailer).to receive(:notification).with( + template: 'password_change', + user: user, + objects: { + user: user, + } + ) + service.execute + + expect(NotificationFactory::Mailer).to have_received(:notification) + end + end + end +end