mirror of
https://github.com/Ocelot-Social-Community/Ocelot-Social.git
synced 2026-04-03 08:05:37 +00:00
refactor(webapp): extract password form into its own component (#9469)
This commit is contained in:
parent
da95664285
commit
8849db6cbf
@ -1,166 +0,0 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import ChangePassword from './Change.vue'
|
||||
|
||||
const localVue = global.localVue
|
||||
|
||||
describe('ChangePassword.vue', () => {
|
||||
let mocks
|
||||
|
||||
beforeEach(() => {
|
||||
mocks = {
|
||||
$toast: {
|
||||
error: jest.fn(),
|
||||
success: jest.fn(),
|
||||
},
|
||||
$t: jest.fn(),
|
||||
$store: {
|
||||
commit: jest.fn(),
|
||||
},
|
||||
$apollo: {
|
||||
mutate: jest
|
||||
.fn()
|
||||
.mockRejectedValue({ message: 'Ouch!' })
|
||||
.mockResolvedValueOnce({ data: { changePassword: 'NEWTOKEN' } }),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
describe('mount', () => {
|
||||
let wrapper
|
||||
const Wrapper = () => {
|
||||
return mount(ChangePassword, { mocks, localVue })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
wrapper = Wrapper()
|
||||
})
|
||||
|
||||
it('renders three input fields', () => {
|
||||
expect(wrapper.findAll('input')).toHaveLength(3)
|
||||
})
|
||||
|
||||
describe('validations', () => {
|
||||
beforeEach(() => {
|
||||
// $t must return strings so async-validator produces proper error messages
|
||||
// (jest.fn() returns undefined which causes Error(undefined) → DsInputError type warning)
|
||||
mocks.$t = jest.fn((key) => key)
|
||||
wrapper = Wrapper()
|
||||
})
|
||||
|
||||
describe('new password and confirmation', () => {
|
||||
describe('mismatch', () => {
|
||||
beforeEach(async () => {
|
||||
await wrapper.find('input#oldPassword').setValue('oldsecret')
|
||||
await wrapper.find('input#password').setValue('superdupersecret')
|
||||
await wrapper.find('input#passwordConfirmation').setValue('different')
|
||||
})
|
||||
|
||||
it('does not submit the form', async () => {
|
||||
mocks.$apollo.mutate.mockReset()
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(mocks.$apollo.mutate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('displays a validation error', async () => {
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.vm.formErrors).toHaveProperty('passwordConfirmation')
|
||||
})
|
||||
})
|
||||
|
||||
describe('match', () => {
|
||||
beforeEach(async () => {
|
||||
await wrapper.find('input#oldPassword').setValue('oldsecret')
|
||||
await wrapper.find('input#password').setValue('superdupersecret')
|
||||
await wrapper.find('input#passwordConfirmation').setValue('superdupersecret')
|
||||
})
|
||||
|
||||
it('passes validation and submits', async () => {
|
||||
mocks.$apollo.mutate.mockReset()
|
||||
mocks.$apollo.mutate.mockResolvedValue({ data: { changePassword: 'TOKEN' } })
|
||||
await wrapper.find('form').trigger('submit')
|
||||
expect(mocks.$apollo.mutate).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
describe('while mutation is pending', () => {
|
||||
it('sets loading while mutation is pending', async () => {
|
||||
mocks.$apollo.mutate.mockReset()
|
||||
let resolvePromise
|
||||
mocks.$apollo.mutate.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolvePromise = resolve
|
||||
}),
|
||||
)
|
||||
|
||||
const promise = wrapper.vm.handleSubmit()
|
||||
expect(wrapper.vm.loading).toBe(true)
|
||||
|
||||
resolvePromise({ data: { changePassword: 'TOKEN' } })
|
||||
await promise
|
||||
expect(wrapper.vm.loading).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('given valid input', () => {
|
||||
beforeEach(() => {
|
||||
wrapper.find('input#oldPassword').setValue('supersecret')
|
||||
wrapper.find('input#password').setValue('superdupersecret')
|
||||
wrapper.find('input#passwordConfirmation').setValue('superdupersecret')
|
||||
})
|
||||
|
||||
describe('submit form', () => {
|
||||
beforeEach(async () => {
|
||||
await wrapper.find('form').trigger('submit')
|
||||
})
|
||||
|
||||
it('calls changePassword mutation', () => {
|
||||
expect(mocks.$apollo.mutate).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('passes form data as variables', () => {
|
||||
expect(mocks.$apollo.mutate.mock.calls[0][0]).toEqual(
|
||||
expect.objectContaining({
|
||||
variables: {
|
||||
oldPassword: 'supersecret',
|
||||
password: 'superdupersecret',
|
||||
passwordConfirmation: 'superdupersecret',
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe('mutation resolves', () => {
|
||||
beforeEach(() => {
|
||||
wrapper.find('form').trigger('submit')
|
||||
})
|
||||
|
||||
it('calls auth/SET_TOKEN with response', () => {
|
||||
expect(mocks.$store.commit).toHaveBeenCalledWith('auth/SET_TOKEN', 'NEWTOKEN')
|
||||
})
|
||||
|
||||
it('displays success message', () => {
|
||||
expect(mocks.$t).toHaveBeenCalledWith('settings.security.change-password.success')
|
||||
expect(mocks.$toast.success).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('mutation rejects', () => {
|
||||
beforeEach(async () => {
|
||||
await wrapper.find('input#oldPassword').setValue('supersecret')
|
||||
await wrapper.find('input#password').setValue('supersecret')
|
||||
await wrapper.find('input#passwordConfirmation').setValue('supersecret')
|
||||
await wrapper.find('form').trigger('submit')
|
||||
})
|
||||
|
||||
it('displays error message', async () => {
|
||||
expect(mocks.$toast.error).toHaveBeenCalledWith('Ouch!')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
183
webapp/components/Password/PasswordForm.spec.js
Normal file
183
webapp/components/Password/PasswordForm.spec.js
Normal file
@ -0,0 +1,183 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import PasswordForm from './PasswordForm.vue'
|
||||
|
||||
const localVue = global.localVue
|
||||
|
||||
describe('PasswordForm.vue', () => {
|
||||
let mocks
|
||||
|
||||
beforeEach(() => {
|
||||
mocks = {
|
||||
$t: jest.fn((key) => key),
|
||||
}
|
||||
})
|
||||
|
||||
const Wrapper = (props = {}) => {
|
||||
return mount(PasswordForm, { mocks, localVue, propsData: props })
|
||||
}
|
||||
|
||||
describe('without requireOldPassword', () => {
|
||||
let wrapper
|
||||
|
||||
beforeEach(() => {
|
||||
wrapper = Wrapper()
|
||||
})
|
||||
|
||||
it('renders two input fields (new password, confirm)', () => {
|
||||
expect(wrapper.findAll('input')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('does not render old password field', () => {
|
||||
expect(wrapper.find('input#oldPassword').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('renders new password field', () => {
|
||||
expect(wrapper.find('input#password').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders password confirmation field', () => {
|
||||
expect(wrapper.find('input#passwordConfirmation').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders submit button', () => {
|
||||
expect(wrapper.find('button[type="submit"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders password strength indicator', () => {
|
||||
expect(wrapper.findComponent({ name: 'PasswordMeter' }).exists()).toBe(true)
|
||||
})
|
||||
|
||||
describe('submit with empty fields', () => {
|
||||
it('does not emit submit', async () => {
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.emitted('submit')).toBeFalsy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('submit with mismatched passwords', () => {
|
||||
beforeEach(async () => {
|
||||
await wrapper.find('input#password').setValue('supersecret')
|
||||
await wrapper.find('input#passwordConfirmation').setValue('different')
|
||||
})
|
||||
|
||||
it('does not emit submit', async () => {
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.emitted('submit')).toBeFalsy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('submit with valid input', () => {
|
||||
beforeEach(async () => {
|
||||
await wrapper.find('input#password').setValue('supersecret')
|
||||
await wrapper.find('input#passwordConfirmation').setValue('supersecret')
|
||||
})
|
||||
|
||||
it('emits submit with form data', async () => {
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.emitted('submit')).toBeTruthy()
|
||||
expect(wrapper.emitted('submit')[0][0]).toEqual({
|
||||
password: 'supersecret',
|
||||
passwordConfirmation: 'supersecret',
|
||||
})
|
||||
})
|
||||
|
||||
it('sets loading state on submit', async () => {
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.vm.loading).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('done()', () => {
|
||||
it('resets loading and form fields', async () => {
|
||||
await wrapper.find('input#password').setValue('supersecret')
|
||||
await wrapper.find('input#passwordConfirmation').setValue('supersecret')
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
wrapper.vm.done()
|
||||
expect(wrapper.vm.loading).toBe(false)
|
||||
expect(wrapper.vm.formData.password).toBe('')
|
||||
expect(wrapper.vm.formData.passwordConfirmation).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('fail()', () => {
|
||||
it('resets loading but keeps form data', async () => {
|
||||
await wrapper.find('input#password').setValue('supersecret')
|
||||
await wrapper.find('input#passwordConfirmation').setValue('supersecret')
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
wrapper.vm.fail()
|
||||
expect(wrapper.vm.loading).toBe(false)
|
||||
expect(wrapper.vm.formData.password).toBe('supersecret')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('with requireOldPassword', () => {
|
||||
let wrapper
|
||||
|
||||
beforeEach(() => {
|
||||
wrapper = Wrapper({ requireOldPassword: true })
|
||||
})
|
||||
|
||||
it('renders three input fields', () => {
|
||||
expect(wrapper.findAll('input')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('renders old password field', () => {
|
||||
expect(wrapper.find('input#oldPassword').exists()).toBe(true)
|
||||
})
|
||||
|
||||
describe('submit without old password', () => {
|
||||
beforeEach(async () => {
|
||||
await wrapper.find('input#password').setValue('supersecret')
|
||||
await wrapper.find('input#passwordConfirmation').setValue('supersecret')
|
||||
})
|
||||
|
||||
it('does not emit submit', async () => {
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.emitted('submit')).toBeFalsy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('submit with all fields valid', () => {
|
||||
beforeEach(async () => {
|
||||
await wrapper.find('input#oldPassword').setValue('oldsecret')
|
||||
await wrapper.find('input#password').setValue('supersecret')
|
||||
await wrapper.find('input#passwordConfirmation').setValue('supersecret')
|
||||
})
|
||||
|
||||
it('emits submit with all form data including oldPassword', async () => {
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(wrapper.emitted('submit')[0][0]).toEqual({
|
||||
oldPassword: 'oldsecret',
|
||||
password: 'supersecret',
|
||||
passwordConfirmation: 'supersecret',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('done()', () => {
|
||||
it('also resets oldPassword field', async () => {
|
||||
await wrapper.find('input#oldPassword').setValue('oldsecret')
|
||||
await wrapper.find('input#password').setValue('supersecret')
|
||||
await wrapper.find('input#passwordConfirmation').setValue('supersecret')
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
wrapper.vm.done()
|
||||
expect(wrapper.vm.formData.oldPassword).toBe('')
|
||||
expect(wrapper.vm.formData.password).toBe('')
|
||||
expect(wrapper.vm.formData.passwordConfirmation).toBe('')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<form @submit.prevent="onSubmit" novalidate>
|
||||
<ocelot-input
|
||||
v-if="requireOldPassword"
|
||||
id="oldPassword"
|
||||
model="oldPassword"
|
||||
type="password"
|
||||
@ -42,14 +43,13 @@
|
||||
<script>
|
||||
import { OsButton, OsIcon } from '@ocelot-social/ui'
|
||||
import { iconRegistry } from '~/utils/iconRegistry'
|
||||
import gql from 'graphql-tag'
|
||||
import PasswordStrength from './Strength'
|
||||
import PasswordForm from '~/components/utils/PasswordFormHelper'
|
||||
import formValidation from '~/mixins/formValidation'
|
||||
import OcelotInput from '~/components/OcelotInput/OcelotInput.vue'
|
||||
|
||||
export default {
|
||||
name: 'ChangePassword',
|
||||
name: 'PasswordForm',
|
||||
mixins: [formValidation],
|
||||
components: {
|
||||
OsButton,
|
||||
@ -57,24 +57,31 @@ export default {
|
||||
PasswordStrength,
|
||||
OcelotInput,
|
||||
},
|
||||
props: {
|
||||
/** Require old password field (for authenticated password change) */
|
||||
requireOldPassword: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
created() {
|
||||
this.icons = iconRegistry
|
||||
},
|
||||
data() {
|
||||
const passwordForm = PasswordForm({ translate: this.$t })
|
||||
const formData = { ...passwordForm.formData }
|
||||
const formSchema = { ...passwordForm.formSchema }
|
||||
if (this.requireOldPassword) {
|
||||
formData.oldPassword = ''
|
||||
formSchema.oldPassword = {
|
||||
type: 'string',
|
||||
required: true,
|
||||
message: this.$t('settings.security.change-password.message-old-password-required'),
|
||||
}
|
||||
}
|
||||
return {
|
||||
formData: {
|
||||
oldPassword: '',
|
||||
...passwordForm.formData,
|
||||
},
|
||||
formSchema: {
|
||||
oldPassword: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
message: this.$t('settings.security.change-password.message-old-password-required'),
|
||||
},
|
||||
...passwordForm.formSchema,
|
||||
},
|
||||
formData,
|
||||
formSchema,
|
||||
loading: false,
|
||||
}
|
||||
},
|
||||
@ -82,29 +89,20 @@ export default {
|
||||
onSubmit() {
|
||||
this.formSubmit(this.handleSubmit)
|
||||
},
|
||||
async handleSubmit(data) {
|
||||
handleSubmit() {
|
||||
this.loading = true
|
||||
const mutation = gql`
|
||||
mutation ($oldPassword: String!, $password: String!) {
|
||||
changePassword(oldPassword: $oldPassword, newPassword: $password)
|
||||
}
|
||||
`
|
||||
const variables = this.formData
|
||||
|
||||
try {
|
||||
const { data } = await this.$apollo.mutate({ mutation, variables })
|
||||
this.$store.commit('auth/SET_TOKEN', data.changePassword)
|
||||
this.$toast.success(this.$t('settings.security.change-password.success'))
|
||||
this.formData = {
|
||||
oldPassword: '',
|
||||
password: '',
|
||||
passwordConfirmation: '',
|
||||
}
|
||||
} catch (err) {
|
||||
this.$toast.error(err.message)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
this.$emit('submit', { ...this.formData })
|
||||
},
|
||||
/** Called by parent after mutation completes */
|
||||
done() {
|
||||
this.loading = false
|
||||
const resetData = { password: '', passwordConfirmation: '' }
|
||||
if (this.requireOldPassword) resetData.oldPassword = ''
|
||||
this.formData = resetData
|
||||
},
|
||||
/** Called by parent on mutation error */
|
||||
fail() {
|
||||
this.loading = false
|
||||
},
|
||||
},
|
||||
}
|
||||
@ -1,103 +0,0 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import ChangePassword from './ChangePassword'
|
||||
|
||||
const localVue = global.localVue
|
||||
|
||||
const stubs = {
|
||||
'sweetalert-icon': true,
|
||||
}
|
||||
|
||||
describe('ChangePassword ', () => {
|
||||
let wrapper
|
||||
let Wrapper
|
||||
let mocks
|
||||
let propsData
|
||||
|
||||
beforeEach(() => {
|
||||
propsData = {}
|
||||
mocks = {
|
||||
$toast: {
|
||||
success: jest.fn(),
|
||||
error: jest.fn(),
|
||||
},
|
||||
$t: jest.fn(),
|
||||
$apollo: {
|
||||
loading: false,
|
||||
mutate: jest.fn().mockResolvedValue({ data: { resetPassword: true } }),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
describe('mount', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers()
|
||||
})
|
||||
|
||||
Wrapper = () => {
|
||||
return mount(ChangePassword, {
|
||||
mocks,
|
||||
propsData,
|
||||
localVue,
|
||||
stubs,
|
||||
})
|
||||
}
|
||||
|
||||
describe('given email and nonce', () => {
|
||||
beforeEach(() => {
|
||||
propsData.email = 'mail@example.org'
|
||||
propsData.nonce = '12345'
|
||||
})
|
||||
|
||||
describe('submitting new password', () => {
|
||||
beforeEach(() => {
|
||||
wrapper = Wrapper()
|
||||
wrapper.find('input#password').setValue('supersecret')
|
||||
wrapper.find('input#passwordConfirmation').setValue('supersecret')
|
||||
wrapper.find('form').trigger('submit')
|
||||
})
|
||||
|
||||
it('calls resetPassword graphql mutation', () => {
|
||||
expect(mocks.$apollo.mutate).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('delivers new password to backend', () => {
|
||||
const expected = expect.objectContaining({
|
||||
variables: { nonce: '12345', email: 'mail@example.org', password: 'supersecret' },
|
||||
})
|
||||
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expected)
|
||||
})
|
||||
|
||||
describe('password reset successful', () => {
|
||||
it('displays success message', () => {
|
||||
const expected = 'components.password-reset.change-password.success'
|
||||
expect(mocks.$t).toHaveBeenCalledWith(expected)
|
||||
})
|
||||
|
||||
describe('after animation', () => {
|
||||
beforeEach(jest.runAllTimers)
|
||||
|
||||
it('emits `change-password-sucess`', () => {
|
||||
expect(wrapper.emitted('passwordResetResponse')).toEqual([['success']])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('password reset not successful', () => {
|
||||
beforeEach(() => {
|
||||
mocks.$apollo.mutate = jest.fn().mockRejectedValue({
|
||||
message: 'Ouch!',
|
||||
})
|
||||
wrapper = Wrapper()
|
||||
wrapper.find('input#password').setValue('supersecret')
|
||||
wrapper.find('input#passwordConfirmation').setValue('supersecret')
|
||||
wrapper.find('form').trigger('submit')
|
||||
})
|
||||
|
||||
it('display a toast error', () => {
|
||||
expect(mocks.$toast.error).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,139 +0,0 @@
|
||||
<template>
|
||||
<div class="ds-mt-base ds-mb-xxx-small">
|
||||
<form
|
||||
v-if="!changePasswordResult"
|
||||
@submit.prevent="onSubmit"
|
||||
class="change-password"
|
||||
novalidate
|
||||
>
|
||||
<ocelot-input
|
||||
id="password"
|
||||
model="password"
|
||||
type="password"
|
||||
autocomplete="off"
|
||||
:label="$t('settings.security.change-password.label-new-password')"
|
||||
/>
|
||||
<ocelot-input
|
||||
id="passwordConfirmation"
|
||||
model="passwordConfirmation"
|
||||
type="password"
|
||||
autocomplete="off"
|
||||
:label="$t('settings.security.change-password.label-new-password-confirm')"
|
||||
/>
|
||||
<password-strength :password="formData.password" />
|
||||
<div class="ds-mt-base ds-mb-xxx-small">
|
||||
<os-button
|
||||
variant="primary"
|
||||
appearance="filled"
|
||||
:loading="$apollo.loading"
|
||||
:disabled="!!formErrors"
|
||||
type="submit"
|
||||
>
|
||||
<template #icon>
|
||||
<os-icon :icon="icons.lock" />
|
||||
</template>
|
||||
{{ $t('settings.security.change-password.button') }}
|
||||
</os-button>
|
||||
</div>
|
||||
</form>
|
||||
<div v-else class="ds-mb-large">
|
||||
<template v-if="changePasswordResult === 'success'">
|
||||
<transition name="ds-transition-fade">
|
||||
<sweetalert-icon icon="success" />
|
||||
</transition>
|
||||
<p class="ds-text">
|
||||
{{ $t('components.password-reset.change-password.success') }}
|
||||
</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<transition name="ds-transition-fade">
|
||||
<sweetalert-icon icon="error" />
|
||||
</transition>
|
||||
<p class="ds-text">
|
||||
{{ $t(`components.password-reset.change-password.error`) }}
|
||||
</p>
|
||||
<p class="ds-text">
|
||||
{{ $t('components.password-reset.change-password.help') }}
|
||||
</p>
|
||||
<p class="ds-text">
|
||||
<a :href="'mailto:' + supportEmail">{{ supportEmail }}</a>
|
||||
</p>
|
||||
</template>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { OsButton, OsIcon } from '@ocelot-social/ui'
|
||||
import { iconRegistry } from '~/utils/iconRegistry'
|
||||
import emails from '../../constants/emails.js'
|
||||
import PasswordStrength from '../Password/Strength'
|
||||
import gql from 'graphql-tag'
|
||||
import { SweetalertIcon } from 'vue-sweetalert-icons'
|
||||
import PasswordForm from '~/components/utils/PasswordFormHelper'
|
||||
import formValidation from '~/mixins/formValidation'
|
||||
import OcelotInput from '~/components/OcelotInput/OcelotInput.vue'
|
||||
|
||||
export default {
|
||||
mixins: [formValidation],
|
||||
components: {
|
||||
OsButton,
|
||||
OsIcon,
|
||||
SweetalertIcon,
|
||||
PasswordStrength,
|
||||
OcelotInput,
|
||||
},
|
||||
props: {
|
||||
email: { type: String, required: true },
|
||||
nonce: { type: String, required: true },
|
||||
},
|
||||
created() {
|
||||
this.icons = iconRegistry
|
||||
},
|
||||
data() {
|
||||
const passwordForm = PasswordForm({ translate: this.$t })
|
||||
return {
|
||||
supportEmail: emails.SUPPORT_EMAIL,
|
||||
formData: {
|
||||
...passwordForm.formData,
|
||||
},
|
||||
formSchema: {
|
||||
...passwordForm.formSchema,
|
||||
},
|
||||
disabled: true,
|
||||
changePasswordResult: null,
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onSubmit() {
|
||||
this.formSubmit(this.handleSubmitPassword)
|
||||
},
|
||||
async handleSubmitPassword() {
|
||||
const mutation = gql`
|
||||
mutation ($nonce: String!, $email: String!, $password: String!) {
|
||||
resetPassword(nonce: $nonce, email: $email, newPassword: $password)
|
||||
}
|
||||
`
|
||||
const { password } = this.formData
|
||||
const { email, nonce } = this
|
||||
const variables = { password, email, nonce }
|
||||
try {
|
||||
const {
|
||||
data: { resetPassword },
|
||||
} = await this.$apollo.mutate({ mutation, variables })
|
||||
this.changePasswordResult = resetPassword ? 'success' : 'error'
|
||||
setTimeout(() => {
|
||||
this.$emit('passwordResetResponse', this.changePasswordResult)
|
||||
}, 3000)
|
||||
this.formData = {
|
||||
password: '',
|
||||
passwordConfirmation: '',
|
||||
}
|
||||
} catch (err) {
|
||||
this.$toast.error(err.message)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
20
webapp/composables/useChangePassword.js
Normal file
20
webapp/composables/useChangePassword.js
Normal file
@ -0,0 +1,20 @@
|
||||
import { changePasswordMutation } from '~/graphql/Password'
|
||||
|
||||
export function useChangePassword({ apollo, store, toast, t }) {
|
||||
async function changePassword({ oldPassword, password }) {
|
||||
try {
|
||||
const { data } = await apollo.mutate({
|
||||
mutation: changePasswordMutation,
|
||||
variables: { oldPassword, password },
|
||||
})
|
||||
store.commit('auth/SET_TOKEN', data.changePassword)
|
||||
toast.success(t('settings.security.change-password.success'))
|
||||
return { success: true }
|
||||
} catch (err) {
|
||||
toast.error(err.message)
|
||||
return { success: false }
|
||||
}
|
||||
}
|
||||
|
||||
return { changePassword }
|
||||
}
|
||||
58
webapp/composables/useChangePassword.spec.js
Normal file
58
webapp/composables/useChangePassword.spec.js
Normal file
@ -0,0 +1,58 @@
|
||||
import { useChangePassword } from './useChangePassword'
|
||||
|
||||
describe('useChangePassword', () => {
|
||||
let apollo, store, toast, t, changePassword
|
||||
|
||||
beforeEach(() => {
|
||||
apollo = { mutate: jest.fn().mockResolvedValue({ data: { changePassword: 'NEWTOKEN' } }) }
|
||||
store = { commit: jest.fn() }
|
||||
toast = { success: jest.fn(), error: jest.fn() }
|
||||
t = jest.fn((key) => key)
|
||||
;({ changePassword } = useChangePassword({ apollo, store, toast, t }))
|
||||
})
|
||||
|
||||
it('calls apollo mutate with oldPassword and password', async () => {
|
||||
await changePassword({ oldPassword: 'old', password: 'new' })
|
||||
expect(apollo.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
variables: { oldPassword: 'old', password: 'new' },
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('commits new token to store on success', async () => {
|
||||
await changePassword({ oldPassword: 'old', password: 'new' })
|
||||
expect(store.commit).toHaveBeenCalledWith('auth/SET_TOKEN', 'NEWTOKEN')
|
||||
})
|
||||
|
||||
it('shows success toast', async () => {
|
||||
await changePassword({ oldPassword: 'old', password: 'new' })
|
||||
expect(toast.success).toHaveBeenCalledWith('settings.security.change-password.success')
|
||||
})
|
||||
|
||||
it('returns success true', async () => {
|
||||
const result = await changePassword({ oldPassword: 'old', password: 'new' })
|
||||
expect(result).toEqual({ success: true })
|
||||
})
|
||||
|
||||
describe('on error', () => {
|
||||
beforeEach(() => {
|
||||
apollo.mutate.mockRejectedValue({ message: 'Ouch!' })
|
||||
})
|
||||
|
||||
it('shows error toast', async () => {
|
||||
await changePassword({ oldPassword: 'old', password: 'new' })
|
||||
expect(toast.error).toHaveBeenCalledWith('Ouch!')
|
||||
})
|
||||
|
||||
it('returns success false', async () => {
|
||||
const result = await changePassword({ oldPassword: 'old', password: 'new' })
|
||||
expect(result).toEqual({ success: false })
|
||||
})
|
||||
|
||||
it('does not commit to store', async () => {
|
||||
await changePassword({ oldPassword: 'old', password: 'new' })
|
||||
expect(store.commit).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
19
webapp/composables/useResetPassword.js
Normal file
19
webapp/composables/useResetPassword.js
Normal file
@ -0,0 +1,19 @@
|
||||
import { resetPasswordMutation } from '~/graphql/Password'
|
||||
|
||||
export function useResetPassword({ apollo, toast }) {
|
||||
async function resetPassword({ password, email, nonce }) {
|
||||
try {
|
||||
const { data } = await apollo.mutate({
|
||||
mutation: resetPasswordMutation,
|
||||
variables: { password, email, nonce },
|
||||
})
|
||||
const success = !!data.resetPassword
|
||||
return { success, result: success ? 'success' : 'error' }
|
||||
} catch (err) {
|
||||
toast.error(err.message)
|
||||
return { success: false, result: null }
|
||||
}
|
||||
}
|
||||
|
||||
return { resetPassword }
|
||||
}
|
||||
52
webapp/composables/useResetPassword.spec.js
Normal file
52
webapp/composables/useResetPassword.spec.js
Normal file
@ -0,0 +1,52 @@
|
||||
import { useResetPassword } from './useResetPassword'
|
||||
|
||||
describe('useResetPassword', () => {
|
||||
let apollo, toast, resetPassword
|
||||
|
||||
beforeEach(() => {
|
||||
apollo = { mutate: jest.fn().mockResolvedValue({ data: { resetPassword: true } }) }
|
||||
toast = { error: jest.fn() }
|
||||
;({ resetPassword } = useResetPassword({ apollo, toast }))
|
||||
})
|
||||
|
||||
it('calls apollo mutate with password, email and nonce', async () => {
|
||||
await resetPassword({ password: 'secret', email: 'a@b.c', nonce: '123' })
|
||||
expect(apollo.mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
variables: { password: 'secret', email: 'a@b.c', nonce: '123' },
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('returns success and result on successful reset', async () => {
|
||||
const res = await resetPassword({ password: 'secret', email: 'a@b.c', nonce: '123' })
|
||||
expect(res).toEqual({ success: true, result: 'success' })
|
||||
})
|
||||
|
||||
describe('when backend returns false', () => {
|
||||
beforeEach(() => {
|
||||
apollo.mutate.mockResolvedValue({ data: { resetPassword: false } })
|
||||
})
|
||||
|
||||
it('returns error result', async () => {
|
||||
const res = await resetPassword({ password: 'secret', email: 'a@b.c', nonce: '123' })
|
||||
expect(res).toEqual({ success: false, result: 'error' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('on error', () => {
|
||||
beforeEach(() => {
|
||||
apollo.mutate.mockRejectedValue({ message: 'Ouch!' })
|
||||
})
|
||||
|
||||
it('shows error toast', async () => {
|
||||
await resetPassword({ password: 'secret', email: 'a@b.c', nonce: '123' })
|
||||
expect(toast.error).toHaveBeenCalledWith('Ouch!')
|
||||
})
|
||||
|
||||
it('returns failure', async () => {
|
||||
const res = await resetPassword({ password: 'secret', email: 'a@b.c', nonce: '123' })
|
||||
expect(res).toEqual({ success: false, result: null })
|
||||
})
|
||||
})
|
||||
})
|
||||
13
webapp/graphql/Password.js
Normal file
13
webapp/graphql/Password.js
Normal file
@ -0,0 +1,13 @@
|
||||
import gql from 'graphql-tag'
|
||||
|
||||
export const changePasswordMutation = gql`
|
||||
mutation ($oldPassword: String!, $password: String!) {
|
||||
changePassword(oldPassword: $oldPassword, newPassword: $password)
|
||||
}
|
||||
`
|
||||
|
||||
export const resetPasswordMutation = gql`
|
||||
mutation ($nonce: String!, $email: String!, $password: String!) {
|
||||
resetPassword(nonce: $nonce, email: $email, newPassword: $password)
|
||||
}
|
||||
`
|
||||
@ -3,33 +3,103 @@ import changePassword from './change-password'
|
||||
|
||||
const localVue = global.localVue
|
||||
|
||||
const stubs = {
|
||||
'sweetalert-icon': true,
|
||||
'nuxt-link': true,
|
||||
}
|
||||
|
||||
describe('change-password', () => {
|
||||
let wrapper
|
||||
let Wrapper
|
||||
let mocks
|
||||
|
||||
beforeEach(() => {
|
||||
mocks = {
|
||||
$t: jest.fn(),
|
||||
$toast: {
|
||||
success: jest.fn(),
|
||||
error: jest.fn(),
|
||||
},
|
||||
$t: jest.fn((key) => key),
|
||||
$route: {
|
||||
query: jest.fn().mockResolvedValue({ email: 'peter@lustig.de', nonce: '12345' }),
|
||||
query: { email: 'mail@example.org', nonce: '12345' },
|
||||
},
|
||||
$router: {
|
||||
push: jest.fn(),
|
||||
},
|
||||
$apollo: {
|
||||
loading: false,
|
||||
mutate: jest.fn().mockResolvedValue({ data: { resetPassword: true } }),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
describe('mount', () => {
|
||||
const Wrapper = () => {
|
||||
return mount(changePassword, { mocks, localVue })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
wrapper = Wrapper()
|
||||
jest.useFakeTimers()
|
||||
})
|
||||
|
||||
Wrapper = () => {
|
||||
return mount(changePassword, {
|
||||
mocks,
|
||||
localVue,
|
||||
stubs,
|
||||
})
|
||||
}
|
||||
|
||||
it('renders', () => {
|
||||
wrapper = Wrapper()
|
||||
expect(wrapper.findAll('form')).toHaveLength(1)
|
||||
})
|
||||
|
||||
describe('submitting new password', () => {
|
||||
beforeEach(() => {
|
||||
wrapper = Wrapper()
|
||||
wrapper.find('input#password').setValue('supersecret')
|
||||
wrapper.find('input#passwordConfirmation').setValue('supersecret')
|
||||
wrapper.find('form').trigger('submit')
|
||||
})
|
||||
|
||||
it('calls resetPassword graphql mutation', () => {
|
||||
expect(mocks.$apollo.mutate).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('delivers new password to backend', () => {
|
||||
const expected = expect.objectContaining({
|
||||
variables: { nonce: '12345', email: 'mail@example.org', password: 'supersecret' },
|
||||
})
|
||||
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(expected)
|
||||
})
|
||||
|
||||
describe('password reset successful', () => {
|
||||
it('displays success message', () => {
|
||||
const expected = 'components.password-reset.change-password.success'
|
||||
expect(mocks.$t).toHaveBeenCalledWith(expected)
|
||||
})
|
||||
|
||||
describe('after animation', () => {
|
||||
beforeEach(jest.runAllTimers)
|
||||
|
||||
it('redirects to login', () => {
|
||||
expect(mocks.$router.push).toHaveBeenCalledWith('/login')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('password reset not successful', () => {
|
||||
beforeEach(() => {
|
||||
mocks.$apollo.mutate = jest.fn().mockRejectedValue({
|
||||
message: 'Ouch!',
|
||||
})
|
||||
wrapper = Wrapper()
|
||||
wrapper.find('input#password').setValue('supersecret')
|
||||
wrapper.find('input#passwordConfirmation').setValue('supersecret')
|
||||
wrapper.find('form').trigger('submit')
|
||||
})
|
||||
|
||||
it('display a toast error', () => {
|
||||
expect(mocks.$toast.error).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,30 +1,78 @@
|
||||
<template>
|
||||
<change-password
|
||||
:email="email"
|
||||
:nonce="nonce"
|
||||
@passwordResetResponse="handlePasswordResetResponse"
|
||||
>
|
||||
<div class="ds-mb-large ds-space-centered">
|
||||
<nuxt-link to="/login">{{ $t('site.back-to-login') }}</nuxt-link>
|
||||
<div class="ds-mt-base ds-mb-xxx-small">
|
||||
<password-form v-if="!changePasswordResult" ref="form" @submit="handleSubmit" />
|
||||
<div v-else class="ds-mb-large">
|
||||
<template v-if="changePasswordResult === 'success'">
|
||||
<transition name="ds-transition-fade">
|
||||
<sweetalert-icon icon="success" />
|
||||
</transition>
|
||||
<p class="ds-text">
|
||||
{{ $t('components.password-reset.change-password.success') }}
|
||||
</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<transition name="ds-transition-fade">
|
||||
<sweetalert-icon icon="error" />
|
||||
</transition>
|
||||
<p class="ds-text">
|
||||
{{ $t(`components.password-reset.change-password.error`) }}
|
||||
</p>
|
||||
<p class="ds-text">
|
||||
{{ $t('components.password-reset.change-password.help') }}
|
||||
</p>
|
||||
<p class="ds-text">
|
||||
<a :href="'mailto:' + supportEmail">{{ supportEmail }}</a>
|
||||
</p>
|
||||
</template>
|
||||
<div class="ds-mb-large ds-space-centered">
|
||||
<nuxt-link to="/login">{{ $t('site.back-to-login') }}</nuxt-link>
|
||||
</div>
|
||||
</div>
|
||||
</change-password>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ChangePassword from '~/components/PasswordReset/ChangePassword'
|
||||
import { SweetalertIcon } from 'vue-sweetalert-icons'
|
||||
import emails from '~/constants/emails.js'
|
||||
import { useResetPassword } from '~/composables/useResetPassword'
|
||||
import PasswordForm from '~/components/Password/PasswordForm'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
SweetalertIcon,
|
||||
PasswordForm,
|
||||
},
|
||||
data() {
|
||||
const { email = '', nonce = '' } = this.$route.query
|
||||
return { email, nonce }
|
||||
return {
|
||||
email,
|
||||
nonce,
|
||||
supportEmail: emails.SUPPORT_EMAIL,
|
||||
changePasswordResult: null,
|
||||
}
|
||||
},
|
||||
components: {
|
||||
ChangePassword,
|
||||
created() {
|
||||
const { resetPassword } = useResetPassword({
|
||||
apollo: this.$apollo,
|
||||
toast: this.$toast,
|
||||
})
|
||||
this._resetPassword = resetPassword
|
||||
},
|
||||
methods: {
|
||||
handlePasswordResetResponse(response) {
|
||||
if (response === 'success') {
|
||||
this.$router.push('/login')
|
||||
async handleSubmit(formData) {
|
||||
const { password } = formData
|
||||
const { email, nonce } = this
|
||||
const { success, result } = await this._resetPassword({ password, email, nonce })
|
||||
this.changePasswordResult = result
|
||||
if (success) {
|
||||
this.$refs.form.done()
|
||||
setTimeout(() => {
|
||||
if (this.changePasswordResult === 'success') {
|
||||
this.$router.push('/login')
|
||||
}
|
||||
}, 3000)
|
||||
} else {
|
||||
this.$refs.form.fail()
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
@ -4,21 +4,31 @@ import Security from './security.vue'
|
||||
const localVue = global.localVue
|
||||
|
||||
describe('security.vue', () => {
|
||||
let wrapper
|
||||
let mocks
|
||||
|
||||
beforeEach(() => {
|
||||
mocks = {
|
||||
$t: jest.fn(),
|
||||
$toast: {
|
||||
error: jest.fn(),
|
||||
success: jest.fn(),
|
||||
},
|
||||
$t: jest.fn((key) => key),
|
||||
$store: {
|
||||
commit: jest.fn(),
|
||||
},
|
||||
$apollo: {
|
||||
mutate: jest
|
||||
.fn()
|
||||
.mockRejectedValue({ message: 'Ouch!' })
|
||||
.mockResolvedValueOnce({ data: { changePassword: 'NEWTOKEN' } }),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
describe('mount', () => {
|
||||
let wrapper
|
||||
const Wrapper = () => {
|
||||
return mount(Security, {
|
||||
mocks,
|
||||
localVue,
|
||||
})
|
||||
return mount(Security, { mocks, localVue })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
@ -28,5 +38,83 @@ describe('security.vue', () => {
|
||||
it('renders', () => {
|
||||
expect(wrapper.classes('os-card')).toBe(true)
|
||||
})
|
||||
|
||||
it('renders three input fields (old, new, confirm)', () => {
|
||||
expect(wrapper.findAll('input')).toHaveLength(3)
|
||||
})
|
||||
|
||||
describe('validations', () => {
|
||||
describe('new password and confirmation mismatch', () => {
|
||||
beforeEach(async () => {
|
||||
await wrapper.find('input#oldPassword').setValue('oldsecret')
|
||||
await wrapper.find('input#password').setValue('superdupersecret')
|
||||
await wrapper.find('input#passwordConfirmation').setValue('different')
|
||||
})
|
||||
|
||||
it('does not submit the form', async () => {
|
||||
mocks.$apollo.mutate.mockReset()
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await wrapper.vm.$nextTick()
|
||||
expect(mocks.$apollo.mutate).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('given valid input', () => {
|
||||
beforeEach(() => {
|
||||
wrapper.find('input#oldPassword').setValue('supersecret')
|
||||
wrapper.find('input#password').setValue('superdupersecret')
|
||||
wrapper.find('input#passwordConfirmation').setValue('superdupersecret')
|
||||
})
|
||||
|
||||
describe('submit form', () => {
|
||||
beforeEach(async () => {
|
||||
await wrapper.find('form').trigger('submit')
|
||||
})
|
||||
|
||||
it('calls changePassword mutation', () => {
|
||||
expect(mocks.$apollo.mutate).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('passes form data as variables', () => {
|
||||
expect(mocks.$apollo.mutate.mock.calls[0][0]).toEqual(
|
||||
expect.objectContaining({
|
||||
variables: {
|
||||
oldPassword: 'supersecret',
|
||||
password: 'superdupersecret',
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe('mutation resolves', () => {
|
||||
beforeEach(() => {
|
||||
wrapper.find('form').trigger('submit')
|
||||
})
|
||||
|
||||
it('calls auth/SET_TOKEN with response', () => {
|
||||
expect(mocks.$store.commit).toHaveBeenCalledWith('auth/SET_TOKEN', 'NEWTOKEN')
|
||||
})
|
||||
|
||||
it('displays success message', () => {
|
||||
expect(mocks.$t).toHaveBeenCalledWith('settings.security.change-password.success')
|
||||
expect(mocks.$toast.success).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('mutation rejects', () => {
|
||||
beforeEach(async () => {
|
||||
await wrapper.find('input#oldPassword').setValue('supersecret')
|
||||
await wrapper.find('input#password').setValue('supersecret')
|
||||
await wrapper.find('input#passwordConfirmation').setValue('supersecret')
|
||||
await wrapper.find('form').trigger('submit')
|
||||
})
|
||||
|
||||
it('displays error message', async () => {
|
||||
expect(mocks.$toast.error).toHaveBeenCalledWith('Ouch!')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,20 +1,40 @@
|
||||
<template>
|
||||
<os-card>
|
||||
<h2 class="title">{{ $t('settings.security.name') }}</h2>
|
||||
<change-password />
|
||||
<password-form ref="form" require-old-password @submit="handleSubmit" />
|
||||
</os-card>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { OsCard } from '@ocelot-social/ui'
|
||||
import ChangePassword from '~/components/Password/Change'
|
||||
import { useChangePassword } from '~/composables/useChangePassword'
|
||||
import PasswordForm from '~/components/Password/PasswordForm'
|
||||
import scrollToContent from './scroll-to-content.js'
|
||||
|
||||
export default {
|
||||
mixins: [scrollToContent],
|
||||
components: {
|
||||
OsCard,
|
||||
ChangePassword,
|
||||
PasswordForm,
|
||||
},
|
||||
created() {
|
||||
const { changePassword } = useChangePassword({
|
||||
apollo: this.$apollo,
|
||||
store: this.$store,
|
||||
toast: this.$toast,
|
||||
t: this.$t,
|
||||
})
|
||||
this._changePassword = changePassword
|
||||
},
|
||||
methods: {
|
||||
async handleSubmit(formData) {
|
||||
const { success } = await this._changePassword(formData)
|
||||
if (success) {
|
||||
this.$refs.form.done()
|
||||
} else {
|
||||
this.$refs.form.fail()
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user