fix: Blocked login when dismissed 2FA modal (#32482)
Co-authored-by: Tasso Evangelista <2263066+tassoevan@users.noreply.github.com>pull/32176/head^2
parent
ebc858fcbe
commit
4e8aa575a6
@ -0,0 +1,8 @@ |
||||
--- |
||||
"@rocket.chat/meteor": patch |
||||
"@rocket.chat/mock-providers": patch |
||||
"@rocket.chat/ui-contexts": patch |
||||
"@rocket.chat/web-ui-registration": patch |
||||
--- |
||||
|
||||
Fixed an issue with blocked login when dismissed 2FA modal by clicking outside of it or pressing the escape key |
||||
@ -0,0 +1,87 @@ |
||||
import { useSetModal } from '@rocket.chat/ui-contexts'; |
||||
import { act, screen } from '@testing-library/react'; |
||||
import { renderHook } from '@testing-library/react-hooks'; |
||||
import userEvent from '@testing-library/user-event'; |
||||
import type { ReactElement } from 'react'; |
||||
import React, { Suspense } from 'react'; |
||||
|
||||
import ModalProviderWithRegion from '../../providers/ModalProvider/ModalProviderWithRegion'; |
||||
import GenericModal from './GenericModal'; |
||||
|
||||
import '@testing-library/jest-dom'; |
||||
|
||||
const renderModal = (modalElement: ReactElement) => { |
||||
const { |
||||
result: { current: setModal }, |
||||
} = renderHook(() => useSetModal(), { |
||||
wrapper: ({ children }) => ( |
||||
<Suspense fallback={null}> |
||||
<ModalProviderWithRegion>{children}</ModalProviderWithRegion> |
||||
</Suspense> |
||||
), |
||||
}); |
||||
|
||||
act(() => { |
||||
setModal(modalElement); |
||||
}); |
||||
|
||||
return { setModal }; |
||||
}; |
||||
|
||||
describe('callbacks', () => { |
||||
it('should call onClose callback when dismissed', async () => { |
||||
const handleClose = jest.fn(); |
||||
|
||||
renderModal(<GenericModal title='Modal' onClose={handleClose} />); |
||||
|
||||
expect(await screen.findByRole('heading', { name: 'Modal', exact: true })).toBeInTheDocument(); |
||||
|
||||
userEvent.keyboard('{Escape}'); |
||||
|
||||
expect(screen.queryByRole('heading', { name: 'Modal', exact: true })).not.toBeInTheDocument(); |
||||
|
||||
expect(handleClose).toHaveBeenCalled(); |
||||
}); |
||||
|
||||
it('should NOT call onClose callback when confirmed', async () => { |
||||
const handleConfirm = jest.fn(); |
||||
const handleClose = jest.fn(); |
||||
|
||||
const { setModal } = renderModal(<GenericModal title='Modal' onConfirm={handleConfirm} onClose={handleClose} />); |
||||
|
||||
expect(await screen.findByRole('heading', { name: 'Modal', exact: true })).toBeInTheDocument(); |
||||
|
||||
userEvent.click(screen.getByRole('button', { name: 'Ok', exact: true })); |
||||
|
||||
expect(handleConfirm).toHaveBeenCalled(); |
||||
|
||||
act(() => { |
||||
setModal(null); |
||||
}); |
||||
|
||||
expect(screen.queryByRole('heading', { name: 'Modal', exact: true })).not.toBeInTheDocument(); |
||||
|
||||
expect(handleClose).not.toHaveBeenCalled(); |
||||
}); |
||||
|
||||
it('should NOT call onClose callback when cancelled', async () => { |
||||
const handleCancel = jest.fn(); |
||||
const handleClose = jest.fn(); |
||||
|
||||
const { setModal } = renderModal(<GenericModal title='Modal' onCancel={handleCancel} onClose={handleClose} />); |
||||
|
||||
expect(await screen.findByRole('heading', { name: 'Modal', exact: true })).toBeInTheDocument(); |
||||
|
||||
userEvent.click(screen.getByRole('button', { name: 'Cancel', exact: true })); |
||||
|
||||
expect(handleCancel).toHaveBeenCalled(); |
||||
|
||||
act(() => { |
||||
setModal(null); |
||||
}); |
||||
|
||||
expect(screen.queryByRole('heading', { name: 'Modal', exact: true })).not.toBeInTheDocument(); |
||||
|
||||
expect(handleClose).not.toHaveBeenCalled(); |
||||
}); |
||||
}); |
||||
@ -1,18 +1,32 @@ |
||||
import type { ReactElement, ReactNode } from 'react'; |
||||
import React, { memo, useEffect, useState } from 'react'; |
||||
import type { ReactNode } from 'react'; |
||||
import { memo } from 'react'; |
||||
import { createPortal } from 'react-dom'; |
||||
|
||||
import { createAnchor } from '../lib/utils/createAnchor'; |
||||
import { deleteAnchor } from '../lib/utils/deleteAnchor'; |
||||
const createModalRoot = (): HTMLElement => { |
||||
const id = 'modal-root'; |
||||
const existing = document.getElementById(id); |
||||
|
||||
if (existing) return existing; |
||||
|
||||
const newOne = document.createElement('div'); |
||||
newOne.id = id; |
||||
document.body.append(newOne); |
||||
|
||||
return newOne; |
||||
}; |
||||
|
||||
let modalRoot: HTMLElement | null = null; |
||||
|
||||
type ModalPortalProps = { |
||||
children?: ReactNode; |
||||
}; |
||||
|
||||
const ModalPortal = ({ children }: ModalPortalProps): ReactElement => { |
||||
const [modalRoot] = useState(() => createAnchor('modal-root')); |
||||
useEffect(() => (): void => deleteAnchor(modalRoot), [modalRoot]); |
||||
return <>{createPortal(children, modalRoot)}</>; |
||||
const ModalPortal = ({ children }: ModalPortalProps) => { |
||||
if (!modalRoot) { |
||||
modalRoot = createModalRoot(); |
||||
} |
||||
|
||||
return createPortal(children, modalRoot); |
||||
}; |
||||
|
||||
export default memo(ModalPortal); |
||||
|
||||
@ -1,115 +1,138 @@ |
||||
// import type { IMessage } from '@rocket.chat/core-typings';
|
||||
import { Emitter } from '@rocket.chat/emitter'; |
||||
import { useSetModal } from '@rocket.chat/ui-contexts'; |
||||
import { render, screen } from '@testing-library/react'; |
||||
import { expect } from 'chai'; |
||||
import type { ReactNode } from 'react'; |
||||
import React, { Suspense, createContext, useContext, useEffect } from 'react'; |
||||
import { act, render, screen } from '@testing-library/react'; |
||||
import type { ForwardedRef, ReactElement } from 'react'; |
||||
import React, { Suspense, createContext, createRef, forwardRef, useContext, useImperativeHandle } from 'react'; |
||||
|
||||
import GenericModal from '../../components/GenericModal'; |
||||
import { imperativeModal } from '../../lib/imperativeModal'; |
||||
import ModalRegion from '../../views/modal/ModalRegion'; |
||||
import ModalProvider from './ModalProvider'; |
||||
import ModalProviderWithRegion from './ModalProviderWithRegion'; |
||||
import '@testing-library/jest-dom'; |
||||
|
||||
const TestContext = createContext({ title: 'default' }); |
||||
const emitter = new Emitter(); |
||||
const renderWithSuspense = (ui: ReactElement) => |
||||
render(ui, { |
||||
wrapper: ({ children }) => <Suspense fallback={null}>{children}</Suspense>, |
||||
}); |
||||
|
||||
const TestModal = ({ emitterEvent, modalFunc }: { emitterEvent: string; modalFunc?: () => ReactNode }) => { |
||||
const setModal = useSetModal(); |
||||
const { title } = useContext(TestContext); |
||||
describe('via useSetModal', () => { |
||||
const ModalTitleContext = createContext('default'); |
||||
|
||||
useEffect(() => { |
||||
emitter.on(emitterEvent, () => { |
||||
setModal(modalFunc || <GenericModal title={title} onClose={() => undefined}></GenericModal>); |
||||
}); |
||||
}, [emitterEvent, setModal, title, modalFunc]); |
||||
type ModalOpenerAPI = { open: () => void }; |
||||
|
||||
return <></>; |
||||
}; |
||||
const ModalOpener = forwardRef((_: unknown, ref: ForwardedRef<ModalOpenerAPI>) => { |
||||
const setModal = useSetModal(); |
||||
const title = useContext(ModalTitleContext); |
||||
useImperativeHandle(ref, () => ({ |
||||
open: () => { |
||||
setModal(<GenericModal open title={title} />); |
||||
}, |
||||
})); |
||||
|
||||
return null; |
||||
}); |
||||
|
||||
describe('Modal Provider', () => { |
||||
it('should render a modal', async () => { |
||||
render( |
||||
<Suspense fallback={null}> |
||||
const modalOpenerRef = createRef<ModalOpenerAPI>(); |
||||
|
||||
renderWithSuspense( |
||||
<ModalProviderWithRegion> |
||||
<ModalOpener ref={modalOpenerRef} /> |
||||
</ModalProviderWithRegion>, |
||||
); |
||||
|
||||
act(() => { |
||||
modalOpenerRef.current?.open(); |
||||
}); |
||||
|
||||
expect(await screen.findByRole('dialog', { name: 'default' })).toBeInTheDocument(); |
||||
}); |
||||
|
||||
it('should render a modal that consumes a context', async () => { |
||||
const modalOpenerRef = createRef<ModalOpenerAPI>(); |
||||
|
||||
renderWithSuspense( |
||||
<ModalTitleContext.Provider value='title from context'> |
||||
<ModalProviderWithRegion> |
||||
<TestModal emitterEvent='open' /> |
||||
<ModalOpener ref={modalOpenerRef} /> |
||||
</ModalProviderWithRegion> |
||||
</Suspense>, |
||||
</ModalTitleContext.Provider>, |
||||
); |
||||
emitter.emit('open'); |
||||
expect(await screen.findByText('default')).to.exist; |
||||
act(() => { |
||||
modalOpenerRef.current?.open(); |
||||
}); |
||||
|
||||
expect(await screen.findByRole('dialog', { name: 'title from context' })).toBeInTheDocument(); |
||||
}); |
||||
|
||||
it('should render a modal that is passed as a function', async () => { |
||||
render( |
||||
<Suspense fallback={null}> |
||||
it('should render a modal in another region', async () => { |
||||
const modalOpener1Ref = createRef<ModalOpenerAPI>(); |
||||
const modalOpener2Ref = createRef<ModalOpenerAPI>(); |
||||
|
||||
renderWithSuspense( |
||||
<ModalTitleContext.Provider value='modal1'> |
||||
<ModalProviderWithRegion> |
||||
<TestModal emitterEvent='open' modalFunc={() => <GenericModal title='function modal' onClose={() => undefined} />} /> |
||||
<ModalOpener ref={modalOpener1Ref} /> |
||||
</ModalProviderWithRegion> |
||||
</Suspense>, |
||||
<ModalTitleContext.Provider value='modal2'> |
||||
<ModalProviderWithRegion> |
||||
<ModalOpener ref={modalOpener2Ref} /> |
||||
</ModalProviderWithRegion> |
||||
</ModalTitleContext.Provider> |
||||
</ModalTitleContext.Provider>, |
||||
); |
||||
emitter.emit('open'); |
||||
expect(await screen.findByText('function modal')).to.exist; |
||||
|
||||
act(() => { |
||||
modalOpener1Ref.current?.open(); |
||||
}); |
||||
|
||||
expect(await screen.findByRole('dialog', { name: 'modal1' })).toBeInTheDocument(); |
||||
|
||||
act(() => { |
||||
modalOpener2Ref.current?.open(); |
||||
}); |
||||
|
||||
expect(await screen.findByRole('dialog', { name: 'modal2' })).toBeInTheDocument(); |
||||
}); |
||||
}); |
||||
|
||||
describe('via imperativeModal', () => { |
||||
it('should render a modal through imperative modal', async () => { |
||||
renderWithSuspense( |
||||
<ModalProvider> |
||||
<ModalRegion /> |
||||
</ModalProvider>, |
||||
); |
||||
|
||||
it('should render a modal through imperative modal', () => { |
||||
async () => { |
||||
render( |
||||
<Suspense fallback={null}> |
||||
<ModalProvider> |
||||
<ModalRegion /> |
||||
</ModalProvider> |
||||
</Suspense>, |
||||
); |
||||
|
||||
const { close } = imperativeModal.open({ |
||||
act(() => { |
||||
imperativeModal.open({ |
||||
component: GenericModal, |
||||
props: { title: 'imperativeModal' }, |
||||
props: { title: 'imperativeModal', open: true }, |
||||
}); |
||||
}); |
||||
|
||||
expect(await screen.findByText('imperativeModal')).to.exist; |
||||
expect(await screen.findByRole('dialog', { name: 'imperativeModal' })).toBeInTheDocument(); |
||||
|
||||
close(); |
||||
act(() => { |
||||
imperativeModal.close(); |
||||
}); |
||||
|
||||
expect(screen.queryByText('imperativeModal')).to.not.exist; |
||||
}; |
||||
expect(screen.queryByText('imperativeModal')).not.toBeInTheDocument(); |
||||
}); |
||||
|
||||
it('should not render a modal if no corresponding region exists', async () => { |
||||
// ModalProviderWithRegion will always have a region identifier set
|
||||
// and imperativeModal will only render modals in the default region (e.g no region identifier)
|
||||
render( |
||||
<Suspense fallback={null}> |
||||
<ModalProviderWithRegion /> |
||||
</Suspense>, |
||||
); |
||||
|
||||
imperativeModal.open({ |
||||
component: GenericModal, |
||||
props: { title: 'imperativeModal' }, |
||||
}); |
||||
|
||||
expect(screen.queryByText('imperativeModal')).to.not.exist; |
||||
}); |
||||
renderWithSuspense(<ModalProviderWithRegion />); |
||||
|
||||
it('should render a modal in another region', () => { |
||||
render( |
||||
<TestContext.Provider value={{ title: 'modal1' }}> |
||||
<ModalProviderWithRegion> |
||||
<TestModal emitterEvent='openModal1' /> |
||||
</ModalProviderWithRegion> |
||||
<TestContext.Provider value={{ title: 'modal2' }}> |
||||
<ModalProviderWithRegion> |
||||
<TestModal emitterEvent='openModal2' /> |
||||
</ModalProviderWithRegion> |
||||
</TestContext.Provider> |
||||
</TestContext.Provider>, |
||||
); |
||||
act(() => { |
||||
imperativeModal.open({ |
||||
component: GenericModal, |
||||
props: { title: 'imperativeModal', open: true }, |
||||
}); |
||||
}); |
||||
|
||||
emitter.emit('openModal1'); |
||||
expect(screen.getByText('modal1')).to.exist; |
||||
emitter.emit('openModal2'); |
||||
expect(screen.getByText('modal2')).to.exist; |
||||
expect(screen.queryByRole('dialog', { name: 'imperativeModal' })).not.toBeInTheDocument(); |
||||
}); |
||||
}); |
||||
|
||||
Loading…
Reference in new issue