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([