rewrite "password reset" with React
This commit is contained in:
parent
b7ac9bbfa1
commit
fd2e3f2c1b
|
|
@ -106,8 +106,8 @@ export default [
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'auth/reset/(\\d+)',
|
path: 'auth/reset/(\\d+)',
|
||||||
component: () => import('../views/auth/Reset.vue'),
|
react: () => import('../views/auth/Reset'),
|
||||||
el: 'form',
|
el: 'main',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'skinlib',
|
path: 'skinlib',
|
||||||
|
|
|
||||||
105
resources/assets/src/views/auth/Reset.tsx
Normal file
105
resources/assets/src/views/auth/Reset.tsx
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
import { hot } from 'react-hot-loader/root'
|
||||||
|
import { t } from '@/scripts/i18n'
|
||||||
|
import * as fetch from '@/scripts/net'
|
||||||
|
import { toast } from '@/scripts/notify'
|
||||||
|
import Alert from '@/components/Alert'
|
||||||
|
|
||||||
|
const Reset: React.FC = () => {
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [confirmation, setConfirmation] = useState('')
|
||||||
|
const [warningMessage, setWarningMessage] = useState('')
|
||||||
|
const [isPending, setIsPending] = useState(false)
|
||||||
|
|
||||||
|
const handlePasswordChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setPassword(event.target.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleConfirmationChange = (
|
||||||
|
event: React.ChangeEvent<HTMLInputElement>,
|
||||||
|
) => {
|
||||||
|
setConfirmation(event.target.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault()
|
||||||
|
|
||||||
|
if (password !== confirmation) {
|
||||||
|
setWarningMessage(t('auth.invalidConfirmPwd'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsPending(true)
|
||||||
|
const { code, message } = await fetch.post<fetch.ResponseBody>(
|
||||||
|
location.href.replace(blessing.base_url, ''),
|
||||||
|
{ password },
|
||||||
|
)
|
||||||
|
if (code === 0) {
|
||||||
|
toast.success(message)
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.href = `${blessing.base_url}/auth/login`
|
||||||
|
}, 2000)
|
||||||
|
} else {
|
||||||
|
setWarningMessage(message)
|
||||||
|
setIsPending(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="input-group mb-3">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
maxLength={32}
|
||||||
|
className="form-control"
|
||||||
|
placeholder={t('auth.password')}
|
||||||
|
value={password}
|
||||||
|
onChange={handlePasswordChange}
|
||||||
|
/>
|
||||||
|
<div className="input-group-append">
|
||||||
|
<div className="input-group-text">
|
||||||
|
<i className="fas fa-lock"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="input-group mb-3">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
maxLength={32}
|
||||||
|
className="form-control"
|
||||||
|
placeholder={t('auth.repeat-pwd')}
|
||||||
|
value={confirmation}
|
||||||
|
onChange={handleConfirmationChange}
|
||||||
|
/>
|
||||||
|
<div className="input-group-append">
|
||||||
|
<div className="input-group-text">
|
||||||
|
<i className="fas fa-sign-in-alt"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Alert type="warning">{warningMessage}</Alert>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="btn btn-primary float-right"
|
||||||
|
type="submit"
|
||||||
|
disabled={isPending}
|
||||||
|
>
|
||||||
|
{isPending ? (
|
||||||
|
<>
|
||||||
|
<i className="fas fa-spinner fa-spin mr-1"></i>
|
||||||
|
{t('auth.resetting')}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
t('auth.reset-button')
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default hot(Reset)
|
||||||
|
|
@ -1,106 +0,0 @@
|
||||||
<template>
|
|
||||||
<form @submit.prevent="reset">
|
|
||||||
<div class="input-group mb-3">
|
|
||||||
<input
|
|
||||||
v-model="password"
|
|
||||||
type="password"
|
|
||||||
class="form-control"
|
|
||||||
:placeholder="$t('auth.password')"
|
|
||||||
required
|
|
||||||
minlength="8"
|
|
||||||
maxlength="32"
|
|
||||||
>
|
|
||||||
<div class="input-group-append">
|
|
||||||
<div class="input-group-text">
|
|
||||||
<span class="fas fa-lock" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="input-group mb-3">
|
|
||||||
<input
|
|
||||||
ref="confirm"
|
|
||||||
v-model="confirm"
|
|
||||||
type="password"
|
|
||||||
class="form-control"
|
|
||||||
:placeholder="$t('auth.repeat-pwd')"
|
|
||||||
required
|
|
||||||
minlength="8"
|
|
||||||
maxlength="32"
|
|
||||||
>
|
|
||||||
<div class="input-group-append">
|
|
||||||
<div class="input-group-text">
|
|
||||||
<span class="fas fa-sign-in-alt" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="alert alert-info" :class="{ 'd-none': !infoMsg }">
|
|
||||||
<i class="icon fas fa-info" />
|
|
||||||
{{ infoMsg }}
|
|
||||||
</div>
|
|
||||||
<div class="alert alert-warning" :class="{ 'd-none': !warningMsg }">
|
|
||||||
<i class="icon fas fa-exclamation-triangle" />
|
|
||||||
{{ warningMsg }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
class="btn btn-primary float-right"
|
|
||||||
type="submit"
|
|
||||||
:disabled="pending"
|
|
||||||
>
|
|
||||||
<template v-if="pending">
|
|
||||||
<i class="fa fa-spinner fa-spin" /> {{ $t('auth.resetting') }}
|
|
||||||
</template>
|
|
||||||
<span v-else>{{ $t('auth.reset-button') }}</span>
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
import emitMounted from '../../components/mixins/emitMounted'
|
|
||||||
import { toast } from '../../scripts/notify'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'Reset',
|
|
||||||
mixins: [
|
|
||||||
emitMounted,
|
|
||||||
],
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
uid: +this.$route[1],
|
|
||||||
password: '',
|
|
||||||
confirm: '',
|
|
||||||
infoMsg: '',
|
|
||||||
warningMsg: '',
|
|
||||||
pending: false,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
async reset() {
|
|
||||||
const { password, confirm } = this
|
|
||||||
|
|
||||||
if (password !== confirm) {
|
|
||||||
this.infoMsg = this.$t('auth.invalidConfirmPwd')
|
|
||||||
this.$refs.confirm.focus()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
this.pending = true
|
|
||||||
const { code, message } = await this.$http.post(
|
|
||||||
`/auth/reset/${this.uid}${location.search}`,
|
|
||||||
{ password },
|
|
||||||
)
|
|
||||||
if (code === 0) {
|
|
||||||
toast.success(message)
|
|
||||||
setTimeout(() => {
|
|
||||||
window.location = `${blessing.base_url}/auth/login`
|
|
||||||
}, 2000)
|
|
||||||
} else {
|
|
||||||
this.infoMsg = ''
|
|
||||||
this.warningMsg = message
|
|
||||||
this.pending = false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
import Vue from 'vue'
|
|
||||||
import { mount } from '@vue/test-utils'
|
|
||||||
import { flushPromises } from '../../utils'
|
|
||||||
import { toast } from '@/scripts/notify'
|
|
||||||
import Reset from '@/views/auth/Reset.vue'
|
|
||||||
|
|
||||||
jest.mock('@/scripts/notify')
|
|
||||||
|
|
||||||
test('reset password', async () => {
|
|
||||||
Vue.prototype.$http.post
|
|
||||||
.mockResolvedValueOnce({ code: 1, message: 'fail' })
|
|
||||||
.mockResolvedValueOnce({ code: 0, message: 'ok' })
|
|
||||||
const wrapper = mount(Reset, {
|
|
||||||
mocks: {
|
|
||||||
$route: ['/auth/reset/1', '1'],
|
|
||||||
},
|
|
||||||
})
|
|
||||||
const form = wrapper.find('form')
|
|
||||||
const info = wrapper.find('.alert-info')
|
|
||||||
const warning = wrapper.find('.alert-warning')
|
|
||||||
|
|
||||||
wrapper.findAll('[type="password"]').at(0)
|
|
||||||
.setValue('12345678')
|
|
||||||
wrapper.findAll('[type="password"]').at(1)
|
|
||||||
.setValue('123456')
|
|
||||||
form.trigger('submit')
|
|
||||||
expect(Vue.prototype.$http.post).not.toBeCalled()
|
|
||||||
expect(info.text()).toBe('auth.invalidConfirmPwd')
|
|
||||||
|
|
||||||
wrapper.findAll('[type="password"]').at(1)
|
|
||||||
.setValue('12345678')
|
|
||||||
form.trigger('submit')
|
|
||||||
expect(Vue.prototype.$http.post).toBeCalledWith(
|
|
||||||
'/auth/reset/1', // Ignore `location.search`
|
|
||||||
{ password: '12345678' },
|
|
||||||
)
|
|
||||||
await flushPromises()
|
|
||||||
expect(warning.text()).toBe('fail')
|
|
||||||
|
|
||||||
form.trigger('submit')
|
|
||||||
await flushPromises()
|
|
||||||
expect(toast.success).toBeCalledWith('ok')
|
|
||||||
jest.runAllTimers()
|
|
||||||
})
|
|
||||||
66
resources/assets/tests/views/auth/Reset.test.tsx
Normal file
66
resources/assets/tests/views/auth/Reset.test.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
import React from 'react'
|
||||||
|
import { render, waitFor, fireEvent } from '@testing-library/react'
|
||||||
|
import { t } from '@/scripts/i18n'
|
||||||
|
import * as fetch from '@/scripts/net'
|
||||||
|
import Reset from '@/views/auth/Reset'
|
||||||
|
|
||||||
|
jest.mock('@/scripts/net')
|
||||||
|
|
||||||
|
test('confirmation is not matched', () => {
|
||||||
|
const { getByText, getByPlaceholderText, queryByText } = render(<Reset />)
|
||||||
|
|
||||||
|
fireEvent.input(getByPlaceholderText(t('auth.password')), {
|
||||||
|
target: { value: 'password' },
|
||||||
|
})
|
||||||
|
fireEvent.input(getByPlaceholderText(t('auth.repeat-pwd')), {
|
||||||
|
target: { value: 'password1' },
|
||||||
|
})
|
||||||
|
fireEvent.click(getByText(t('auth.reset-button')))
|
||||||
|
|
||||||
|
expect(queryByText(t('auth.invalidConfirmPwd'))).toBeInTheDocument()
|
||||||
|
expect(fetch.post).not.toBeCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('succeeded', async () => {
|
||||||
|
fetch.post.mockResolvedValue({ code: 0, message: 'ok' })
|
||||||
|
const { getByText, getByPlaceholderText, getByRole, queryByText } = render(
|
||||||
|
<Reset />,
|
||||||
|
)
|
||||||
|
|
||||||
|
fireEvent.input(getByPlaceholderText(t('auth.password')), {
|
||||||
|
target: { value: 'password' },
|
||||||
|
})
|
||||||
|
fireEvent.input(getByPlaceholderText(t('auth.repeat-pwd')), {
|
||||||
|
target: { value: 'password' },
|
||||||
|
})
|
||||||
|
fireEvent.click(getByText(t('auth.reset-button')))
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(fetch.post).toBeCalledWith(
|
||||||
|
location.href.replace(blessing.base_url, ''),
|
||||||
|
{ password: 'password' },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
expect(queryByText('ok')).toBeInTheDocument()
|
||||||
|
expect(getByRole('status')).toHaveClass('alert-success')
|
||||||
|
jest.runAllTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('failed', async () => {
|
||||||
|
fetch.post.mockResolvedValue({ code: 1, message: 'failed' })
|
||||||
|
const { getByText, getByPlaceholderText, queryByText } = render(<Reset />)
|
||||||
|
|
||||||
|
fireEvent.input(getByPlaceholderText(t('auth.password')), {
|
||||||
|
target: { value: 'password' },
|
||||||
|
})
|
||||||
|
fireEvent.input(getByPlaceholderText(t('auth.repeat-pwd')), {
|
||||||
|
target: { value: 'password' },
|
||||||
|
})
|
||||||
|
fireEvent.click(getByText(t('auth.reset-button')))
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(fetch.post).toBeCalledWith(
|
||||||
|
location.href.replace(blessing.base_url, ''),
|
||||||
|
{ password: 'password' },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
expect(queryByText('failed')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
@ -6,5 +6,5 @@
|
||||||
<p class="login-box-msg">
|
<p class="login-box-msg">
|
||||||
{{ trans('auth.reset.message', {username: user.nickname}) }}
|
{{ trans('auth.reset.message', {username: user.nickname}) }}
|
||||||
</p>
|
</p>
|
||||||
<form></form>
|
<main></main>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user