From aa835f7d036275ca53438e779263026074b9d701 Mon Sep 17 00:00:00 2001 From: Pig Fang Date: Fri, 27 Mar 2020 22:56:08 +0800 Subject: [PATCH] rewrite login page with React --- resources/assets/src/scripts/route.tsx | 4 +- resources/assets/src/views/auth/Login.tsx | 164 +++++++++++++++ resources/assets/src/views/auth/Login.vue | 138 ------------ .../assets/tests/views/auth/Login.test.ts | 85 -------- .../assets/tests/views/auth/Login.test.tsx | 197 ++++++++++++++++++ resources/views/auth/login.twig | 5 +- .../ControllersTest/AuthControllerTest.php | 3 - 7 files changed, 364 insertions(+), 232 deletions(-) create mode 100644 resources/assets/src/views/auth/Login.tsx delete mode 100644 resources/assets/src/views/auth/Login.vue delete mode 100644 resources/assets/tests/views/auth/Login.test.ts create mode 100644 resources/assets/tests/views/auth/Login.test.tsx diff --git a/resources/assets/src/scripts/route.tsx b/resources/assets/src/scripts/route.tsx index 0628d91b..0493ed1b 100644 --- a/resources/assets/src/scripts/route.tsx +++ b/resources/assets/src/scripts/route.tsx @@ -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', diff --git a/resources/assets/src/views/auth/Login.tsx b/resources/assets/src/views/auth/Login.tsx new file mode 100644 index 00000000..4cd17890 --- /dev/null +++ b/resources/assets/src/views/auth/Login.tsx @@ -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(null) + const recaptcha = useBlessingExtra('recaptcha') + const invisibleRecaptcha = useBlessingExtra('invisible') + + useEffect(() => { + setHasTooManyFails(blessing.extra.tooManyFails as boolean) + }, []) + + const handleIdentificationChange = ( + event: React.ChangeEvent, + ) => { + setIdentification(event.target.value) + } + + const handlePasswordChange = (event: React.ChangeEvent) => { + setPassword(event.target.value) + } + + const handleRememberChange = (event: React.ChangeEvent) => { + setRemember(event.target.checked) + } + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault() + setIsPending(true) + + const response = await fetch.post('/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 ( +
+
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+
+ + {hasTooManyFails && } + + {warningMessage} + +
+ + {t('auth.forgot-link')} +
+ + + + ) +} + +export default hot(Login) diff --git a/resources/assets/src/views/auth/Login.vue b/resources/assets/src/views/auth/Login.vue deleted file mode 100644 index eba5f3d5..00000000 --- a/resources/assets/src/views/auth/Login.vue +++ /dev/null @@ -1,138 +0,0 @@ - - - diff --git a/resources/assets/tests/views/auth/Login.test.ts b/resources/assets/tests/views/auth/Login.test.ts deleted file mode 100644 index 1040c601..00000000 --- a/resources/assets/tests/views/auth/Login.test.ts +++ /dev/null @@ -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: '', -}) - -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() -}) diff --git a/resources/assets/tests/views/auth/Login.test.tsx b/resources/assets/tests/views/auth/Login.test.tsx new file mode 100644 index 00000000..a1db267a --- /dev/null +++ b/resources/assets/tests/views/auth/Login.test.tsx @@ -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() + 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() + 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( + , + ) + 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() + 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() + 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() + 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() + 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'))) + }) +}) diff --git a/resources/views/auth/login.twig b/resources/views/auth/login.twig index b15a9502..3687a70c 100644 --- a/resources/views/auth/login.twig +++ b/resources/views/auth/login.twig @@ -9,7 +9,7 @@ {{ session_pull('msg') }} {% endif %} -
+

{{ include('auth.oauth') }}
@@ -18,9 +18,6 @@ {% endblock %} {% block before_foot %} - {% if enable_recaptcha %} - - {% endif %}