rewrite login page with React
This commit is contained in:
parent
2e78da928b
commit
aa835f7d03
|
|
@ -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',
|
||||
|
|
|
|||
164
resources/assets/src/views/auth/Login.tsx
Normal file
164
resources/assets/src/views/auth/Login.tsx
Normal 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)
|
||||
|
|
@ -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>
|
||||
|
|
@ -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()
|
||||
})
|
||||
197
resources/assets/tests/views/auth/Login.test.tsx
Normal file
197
resources/assets/tests/views/auth/Login.test.tsx
Normal 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')))
|
||||
})
|
||||
})
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user