rewrite login page with React

This commit is contained in:
Pig Fang 2020-03-27 22:56:08 +08:00
parent 2e78da928b
commit aa835f7d03
7 changed files with 364 additions and 232 deletions

View File

@ -91,8 +91,8 @@ export default [
},
{
path: 'auth/login',
component: () => import('../views/auth/Login.vue'),
el: 'form',
react: () => import('../views/auth/Login'),
el: 'main',
},
{
path: 'auth/register',

View File

@ -0,0 +1,164 @@
import React, { useState, useRef, useEffect } from 'react'
import { hot } from 'react-hot-loader/root'
import useBlessingExtra from '@/scripts/hooks/useBlessingExtra'
import { t } from '@/scripts/i18n'
import * as fetch from '@/scripts/net'
import { showModal } from '@/scripts/notify'
import Alert from '@/components/Alert'
import Captcha from '@/components/Captcha'
type SuccessfulResponse = {
code: 0
message: string
data: { redirectTo: string }
}
type FailedResponse = {
code: number
message: string
data: { login_fails: number }
}
type Response = SuccessfulResponse | FailedResponse
function isSuccessfulResponse(
response: Response,
): response is SuccessfulResponse {
return response.code === 0
}
const Login: React.FC = () => {
const [identification, setIdentification] = useState('')
const [password, setPassword] = useState('')
const [remember, setRemember] = useState(false)
const [hasTooManyFails, setHasTooManyFails] = useState(false)
const [isPending, setIsPending] = useState(false)
const [warningMessage, setWarningMessage] = useState('')
const ref = useRef<Captcha | null>(null)
const recaptcha = useBlessingExtra<string>('recaptcha')
const invisibleRecaptcha = useBlessingExtra<boolean>('invisible')
useEffect(() => {
setHasTooManyFails(blessing.extra.tooManyFails as boolean)
}, [])
const handleIdentificationChange = (
event: React.ChangeEvent<HTMLInputElement>,
) => {
setIdentification(event.target.value)
}
const handlePasswordChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setPassword(event.target.value)
}
const handleRememberChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setRemember(event.target.checked)
}
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
setIsPending(true)
const response = await fetch.post<Response>('/auth/login', {
identification,
password,
keep: remember,
captcha: hasTooManyFails ? await ref.current!.execute() : undefined,
})
if (isSuccessfulResponse(response)) {
window.location.href = response.data.redirectTo
} else {
setWarningMessage(response.message)
setIsPending(false)
ref.current?.reset()
// only notify user if he/she fails too much at the first time
if (response.data.login_fails > 3 && !hasTooManyFails) {
setHasTooManyFails(true)
if (recaptcha) {
// no need to notify if using invisible recaptcha
if (!invisibleRecaptcha) {
showModal({
mode: 'alert',
text: t('auth.tooManyFails.recaptcha'),
})
}
} else {
showModal({
mode: 'alert',
text: t('auth.tooManyFails.captcha'),
})
}
}
}
}
return (
<form onSubmit={handleSubmit}>
<div className="input-group mb-3">
<input
type="text"
className="form-control"
placeholder={t('auth.identification')}
value={identification}
onChange={handleIdentificationChange}
required
/>
<div className="input-group-append">
<div className="input-group-text">
<i className="fas fa-envelope"></i>
</div>
</div>
</div>
<div className="input-group mb-3">
<input
type="password"
className="form-control"
placeholder={t('auth.password')}
value={password}
onChange={handlePasswordChange}
required
/>
<div className="input-group-append">
<div className="input-group-text">
<i className="fas fa-lock"></i>
</div>
</div>
</div>
{hasTooManyFails && <Captcha ref={ref} />}
<Alert type="warning">{warningMessage}</Alert>
<div className="d-flex justify-content-between mb-3">
<label>
<input
type="checkbox"
className="mr-1"
checked={remember}
onChange={handleRememberChange}
/>
{t('auth.keep')}
</label>
<a href={`${blessing.base_url}/auth/forgot`}>{t('auth.forgot-link')}</a>
</div>
<button
className="btn btn-primary btn-block"
type="submit"
disabled={isPending}
>
{isPending ? (
<>
<i className="fas fa-spinner fa-spin mr-1"></i>
{t('auth.loggingIn')}
</>
) : (
t('auth.login')
)}
</button>
</form>
)
}
export default hot(Login)

View File

@ -1,138 +0,0 @@
<template>
<form @submit.prevent="login">
<div class="input-group mb-3">
<input
v-model="identification"
class="form-control"
:placeholder="$t('auth.identification')"
required
>
<div class="input-group-append">
<div class="input-group-text">
<span class="fas fa-envelope" />
</div>
</div>
</div>
<div class="input-group mb-3">
<input
v-model="password"
type="password"
class="form-control"
:placeholder="$t('auth.password')"
required
>
<div class="input-group-append">
<div class="input-group-text">
<span class="fas fa-lock" />
</div>
</div>
</div>
<captcha v-if="tooManyFails" ref="captcha" />
<div class="alert alert-warning" :class="{ 'd-none': !warningMsg }">
<i class="icon fas fa-exclamation-triangle" />
{{ warningMsg }}
</div>
<div class="d-flex justify-content-between mb-3">
<div>
<label>
<input v-model="remember" type="checkbox">
{{ $t('auth.keep') }}
</label>
</div>
<a v-t="'auth.forgot-link'" :href="`${baseUrl}/auth/forgot`" />
</div>
<button
class="btn btn-block btn-primary"
type="submit"
:disabled="pending"
>
<template v-if="pending">
<i class="fa fa-spinner fa-spin" /> {{ $t('auth.loggingIn') }}
</template>
<span v-else>{{ $t('auth.login') }}</span>
</button>
</form>
</template>
<script>
import Captcha from '../../components/Captcha.vue'
import emitMounted from '../../components/mixins/emitMounted'
import { showModal } from '../../scripts/notify'
export default {
name: 'Login',
components: {
Captcha,
},
mixins: [
emitMounted,
],
props: {
baseUrl: {
type: String,
default: blessing.base_url,
},
},
data() {
return {
identification: '',
password: '',
remember: false,
tooManyFails: blessing.extra.tooManyFails,
recaptcha: blessing.extra.recaptcha,
invisible: blessing.extra.invisible,
warningMsg: '',
pending: false,
}
},
methods: {
async login() {
const {
identification, password, remember,
} = this
this.pending = true
const {
code, message, data: { login_fails: loginFails, redirectTo } = { login_fails: 0 },
} = await this.$http.post(
'/auth/login',
{
identification,
password,
keep: remember,
captcha: this.tooManyFails
? await this.$refs.captcha.execute()
: void 0,
},
)
if (code === 0) {
window.location = redirectTo
} else {
if (loginFails > 3 && !this.tooManyFails) {
if (this.recaptcha) {
if (!this.invisible) {
showModal({
mode: 'alert',
text: this.$t('auth.tooManyFails.recaptcha'),
})
}
} else {
showModal({
mode: 'alert',
text: this.$t('auth.tooManyFails.captcha'),
})
}
this.tooManyFails = true
}
this.warningMsg = message
this.pending = false
this.$refs.captcha.refresh()
}
},
},
}
</script>

View File

@ -1,85 +0,0 @@
import Vue from 'vue'
import { mount } from '@vue/test-utils'
import { flushPromises } from '../../utils'
import { showModal } from '@/scripts/notify'
import Login from '@/views/auth/Login.vue'
jest.mock('@/scripts/notify')
const Captcha = Vue.extend({
methods: {
execute() {
return Promise.resolve('a')
},
refresh() { /* */ },
},
template: '<img>',
})
test('show captcha if too many login fails', () => {
window.blessing.extra = { tooManyFails: true }
const wrapper = mount(Login)
expect(wrapper.find('img').attributes('src')).toMatch(/\/auth\/captcha\?v=\d+/)
})
test('login', async () => {
window.blessing.extra = { tooManyFails: false }
Vue.prototype.$http.post
.mockResolvedValueOnce({ code: 1, message: 'fail' })
.mockResolvedValueOnce({ code: 1, data: { login_fails: 4 } })
.mockResolvedValueOnce({ code: 1, data: { login_fails: 4 } })
.mockResolvedValueOnce({ code: 1, data: { login_fails: 4 } })
.mockResolvedValueOnce({
code: 0, message: 'ok', data: { redirectTo: '' },
})
const wrapper = mount(Login, { stubs: { Captcha } })
const form = wrapper.find('form')
const warning = wrapper.find('.alert-warning')
wrapper.find('input').setValue('a@b.c')
wrapper.find('[type="password"]').setValue('123')
form.trigger('submit')
await flushPromises()
expect(Vue.prototype.$http.post).toBeCalledWith(
'/auth/login',
{
identification: 'a@b.c', password: '123', keep: false,
},
)
expect(warning.text()).toBe('fail')
form.trigger('submit')
await flushPromises()
expect(showModal).toBeCalledWith({
mode: 'alert',
text: 'auth.tooManyFails.captcha',
})
expect(wrapper.find('img').exists()).toBeTrue()
wrapper.setData({
recaptcha: 'sitekey', invisible: true, tooManyFails: false,
})
form.trigger('submit')
await flushPromises()
wrapper.setData({
recaptcha: 'sitekey', invisible: false, tooManyFails: false,
})
form.trigger('submit')
await flushPromises()
expect(showModal).toBeCalledWith({
mode: 'alert',
text: 'auth.tooManyFails.recaptcha',
})
wrapper.find('[type="checkbox"]').setChecked()
form.trigger('submit')
await flushPromises()
expect(Vue.prototype.$http.post).toBeCalledWith(
'/auth/login',
{
identification: 'a@b.c', password: '123', keep: true, captcha: 'a',
},
)
await flushPromises()
})

View File

@ -0,0 +1,197 @@
import React from 'react'
import { render, wait, fireEvent } from '@testing-library/react'
import { t } from '@/scripts/i18n'
import * as fetch from '@/scripts/net'
import Login from '@/views/auth/Login'
jest.mock('@/scripts/net')
beforeEach(() => {
window.blessing.extra = { tooManyFails: false }
})
test('show captcha if too many login fails', () => {
window.blessing.extra = { tooManyFails: true }
const { queryByAltText } = render(<Login />)
expect(queryByAltText(t('auth.captcha'))).toBeInTheDocument()
})
describe('submit form', () => {
it('succeeded', async () => {
fetch.post.mockResolvedValue({
code: 0,
message: 'ok',
data: { redirectTo: '/user' },
})
const { getByPlaceholderText, getByText } = render(<Login />)
fireEvent.input(getByPlaceholderText(t('auth.identification')), {
target: { value: 'a@b.c' },
})
fireEvent.input(getByPlaceholderText(t('auth.password')), {
target: { value: 'password' },
})
fireEvent.click(getByText(t('auth.login')))
await wait()
expect(fetch.post).toBeCalledWith('/auth/login', {
identification: 'a@b.c',
password: 'password',
keep: false,
})
})
it('remember me', async () => {
fetch.post.mockResolvedValue({
code: 0,
message: 'ok',
data: { redirectTo: '/user' },
})
const { getByPlaceholderText, getByText, getByLabelText } = render(
<Login />,
)
fireEvent.input(getByPlaceholderText(t('auth.identification')), {
target: { value: 'a@b.c' },
})
fireEvent.input(getByPlaceholderText(t('auth.password')), {
target: { value: 'password' },
})
fireEvent.click(getByLabelText(t('auth.keep')))
fireEvent.click(getByText(t('auth.login')))
await wait()
expect(fetch.post).toBeCalledWith('/auth/login', {
identification: 'a@b.c',
password: 'password',
keep: true,
})
})
it('failed', async () => {
fetch.post.mockResolvedValue({
code: 1,
message: 'failed',
data: { login_fails: 1 },
})
const { getByPlaceholderText, getByText, queryByText } = render(<Login />)
fireEvent.input(getByPlaceholderText(t('auth.identification')), {
target: { value: 'a@b.c' },
})
fireEvent.input(getByPlaceholderText(t('auth.password')), {
target: { value: 'password' },
})
fireEvent.click(getByText(t('auth.login')))
await wait()
expect(fetch.post).toBeCalledWith('/auth/login', {
identification: 'a@b.c',
password: 'password',
keep: false,
})
expect(queryByText('failed')).toBeInTheDocument()
})
it('too many fails', async () => {
fetch.post.mockResolvedValue({
code: 1,
message: 'failed',
data: { login_fails: 4 },
})
const {
getByPlaceholderText,
getByText,
queryByText,
queryByAltText,
} = render(<Login />)
fireEvent.input(getByPlaceholderText(t('auth.identification')), {
target: { value: 'a@b.c' },
})
fireEvent.input(getByPlaceholderText(t('auth.password')), {
target: { value: 'password' },
})
fireEvent.click(getByText(t('auth.login')))
await wait()
expect(fetch.post).toBeCalledWith('/auth/login', {
identification: 'a@b.c',
password: 'password',
keep: false,
})
expect(queryByText('failed')).toBeInTheDocument()
expect(queryByText(t('auth.tooManyFails.captcha'))).toBeInTheDocument()
expect(queryByAltText(t('auth.captcha'))).toBeInTheDocument()
fireEvent.click(getByText(t('general.confirm')))
fireEvent.input(getByPlaceholderText(t('auth.captcha')), {
target: { value: 'captcha' },
})
fireEvent.click(getByText(t('auth.login')))
await wait()
expect(fetch.post).toBeCalledWith('/auth/login', {
identification: 'a@b.c',
password: 'password',
keep: false,
captcha: 'captcha',
})
})
it('too many fails with normal recaptcha', async () => {
window.blessing.extra.recaptcha = 'sitekey'
fetch.post.mockResolvedValue({
code: 1,
message: 'failed',
data: { login_fails: 4 },
})
const { getByPlaceholderText, getByText, queryByText } = render(<Login />)
fireEvent.input(getByPlaceholderText(t('auth.identification')), {
target: { value: 'a@b.c' },
})
fireEvent.input(getByPlaceholderText(t('auth.password')), {
target: { value: 'password' },
})
fireEvent.click(getByText(t('auth.login')))
await wait()
expect(fetch.post).toBeCalledWith('/auth/login', {
identification: 'a@b.c',
password: 'password',
keep: false,
})
expect(queryByText('failed')).toBeInTheDocument()
})
it('too many fails with invisible recaptcha', async () => {
window.blessing.extra.recaptcha = 'sitekey'
window.blessing.extra.invisible = true
fetch.post.mockResolvedValue({
code: 1,
message: 'failed',
data: { login_fails: 4 },
})
const { getByPlaceholderText, getByText, queryByText } = render(<Login />)
fireEvent.input(getByPlaceholderText(t('auth.identification')), {
target: { value: 'a@b.c' },
})
fireEvent.input(getByPlaceholderText(t('auth.password')), {
target: { value: 'password' },
})
fireEvent.click(getByText(t('auth.login')))
await wait()
expect(fetch.post).toBeCalledWith('/auth/login', {
identification: 'a@b.c',
password: 'password',
keep: false,
})
expect(queryByText('failed')).toBeInTheDocument()
expect(queryByText(t('auth.tooManyFails.recaptcha'))).toBeInTheDocument()
fireEvent.click(getByText(t('general.confirm')))
})
})

View File

@ -9,7 +9,7 @@
{{ session_pull('msg') }}
</div>
{% endif %}
<form></form>
<main></main>
<br>
{{ include('auth.oauth') }}
<div>
@ -18,9 +18,6 @@
{% endblock %}
{% block before_foot %}
{% if enable_recaptcha %}
<script src="{{ recaptcha_url }}" async defer></script>
{% endif %}
<script>
Object.defineProperty(blessing, 'extra', {
configurable: false,

View File

@ -37,9 +37,6 @@ class AuthControllerTest extends TestCase
public function testLogin()
{
$this->get('/auth/login')->assertSee('Log in');
option(['recaptcha_sitekey' => 'key']);
$this->get('/auth/login')->assertSee('recaptcha.net');
}
public function testHandleLogin()