refactor(webapp): extract password form into its own component (#9469)

This commit is contained in:
Ulf Gebhardt 2026-03-29 17:27:12 +02:00 committed by GitHub
parent da95664285
commit 8849db6cbf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 636 additions and 475 deletions

View File

@ -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!')
})
})
})
})
})
})

View 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('')
})
})
})
})

View File

@ -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
},
},
}

View File

@ -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()
})
})
})
})
})
})

View File

@ -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>

View 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 }
}

View 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()
})
})
})

View 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 }
}

View 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 })
})
})
})

View 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)
}
`

View File

@ -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()
})
})
})
})
})

View File

@ -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()
}
},
},

View File

@ -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!')
})
})
})
})
})
})

View File

@ -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>