diff --git a/frontend/apps/remark42/app/components/auth/auth.hooks.ts b/frontend/apps/remark42/app/components/auth/auth.hooks.ts index 7c7465b6..64271ea1 100644 --- a/frontend/apps/remark42/app/components/auth/auth.hooks.ts +++ b/frontend/apps/remark42/app/components/auth/auth.hooks.ts @@ -23,9 +23,16 @@ export function useDropdown(disableClosing?: boolean) { } function handleMessageFromParent(evt: MessageEvent) { + // only the embedding page may close the dropdown, and only by reporting a click + // outside the widget. the parent also posts hash, title and theme messages, and + // extensions post their own, none of which should interrupt an active login + if (evt.source !== window.parent) { + return; + } + const data = parseMessage(evt); - if (disableClosing && data.clickOutside) { + if (!data.clickOutside || disableClosing) { return; } diff --git a/frontend/apps/remark42/app/components/auth/auth.spec.tsx b/frontend/apps/remark42/app/components/auth/auth.spec.tsx index 4a8966e5..f1737189 100644 --- a/frontend/apps/remark42/app/components/auth/auth.spec.tsx +++ b/frontend/apps/remark42/app/components/auth/auth.spec.tsx @@ -22,6 +22,13 @@ describe('', () => { StaticStore.config.auth_providers = defaultProviders; }); + // the embedding page posts into the widget with iframe.contentWindow.postMessage, which + // arrives with source set to the parent. jsdom leaves source unset on window.postMessage, + // so it is set here explicitly + function postFromParent(data: unknown) { + window.dispatchEvent(new MessageEvent('message', { data, source: window.parent })); + } + // TODO: separate tests of `useDropdown` mechanics with the hook describe('useDropdown', () => { it('should render auth with hidden dropdown', () => { @@ -54,7 +61,7 @@ describe('', () => { expect(container.querySelector('.auth-dropdown')).not.toBeInTheDocument(); }); - it('should close dropdown by message from parent', async () => { + it('should close dropdown by clickOutside message from parent', async () => { const { container } = render(); expect(container.querySelector('.auth-dropdown')).not.toBeInTheDocument(); @@ -62,9 +69,38 @@ describe('', () => { fireEvent.click(screen.getByText('Sign In')); expect(container.querySelector('.auth-dropdown')).toBeInTheDocument(); - window.postMessage('{"clickOutside": true}', '*'); + postFromParent({ clickOutside: true }); await waitFor(() => expect(container.querySelector('.auth-dropdown')).not.toBeInTheDocument()); }); + + it.each([ + ['title', { title: 'New title' }], + ['hash', { hash: '#comment-1' }], + ['theme', { theme: 'dark' }], + ['an unparsable payload', 'clickOutside'], + ])('should not close dropdown by %s message from parent', async (_, message) => { + const { container } = render(); + + fireEvent.click(screen.getByText('Sign In')); + expect(container.querySelector('.auth-dropdown')).toBeInTheDocument(); + + postFromParent(message); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(container.querySelector('.auth-dropdown')).toBeInTheDocument(); + }); + + it('should not close dropdown by clickOutside message from a foreign source', async () => { + const { container } = render(); + + fireEvent.click(screen.getByText('Sign In')); + expect(container.querySelector('.auth-dropdown')).toBeInTheDocument(); + + window.dispatchEvent(new MessageEvent('message', { data: { clickOutside: true }, source: null })); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(container.querySelector('.auth-dropdown')).toBeInTheDocument(); + }); }); it.each([