Repository: adobecom/milo
Pull request #3816: MWPW-169072: Handle messages from iframe
Author: login: zagi25
Labels: name: high priority
description: Why is this a high priority? Blocker? Critical? Dependency?
name: Ready for Stage
Created: 2025-03-13T16:33:19Z | Merged: 2025-03-21T01:14:05Z

* Added event listener in `iframe` block for messages from Manage Plan iframe
* Added `hide-close-button` style to the modal
* Added event listener for default modal close event

Resolves: [MWPW-169072](https://jira.corp.adobe.com/browse/MWPW-169072)

**Test URLs:**
- Before: https://main--milo--adobecom.aem.page/?martech=off
- After: https://mwpw-169072-cancellation-modal--milo--adobecom.aem.page/?martech=off

**Federal Test URLs:**
- https://main--federal--adobecom.hlx.page/de/federal/footer/fragments/footer-copyright#cancel
https://main--federal--adobecom.hlx.page/de/federal/footer/fragments/footer-copyright?milolibs=mwpw-169072-cancellation-modal#cancel



Diff:
diff --git a/libs/blocks/iframe/iframe.js b/libs/blocks/iframe/iframe.js
index b24fd057ba6..1f9b4f61ba3 100644
--- a/libs/blocks/iframe/iframe.js
+++ b/libs/blocks/iframe/iframe.js
@@ -1,13 +1,51 @@
 import { createTag } from '../../utils/utils.js';
 
+const ALLOWED_MESSAGE_ORIGINS = [
+  'https://stage.plan.adobe.com',
+  'https://plan.adobe.com',
+];
+
+function handleManagePlanEvents(message) {
+  const { subType, data } = message;
+  switch (subType) {
+    case 'EXTERNAL':
+      if (!data?.externalUrl || !data?.target) return;
+      window.open(data.externalUrl, data.target);
+      break;
+    case 'SWITCH':
+      if (!data?.externalUrl || !data?.target) return;
+      window.open(data.externalUrl, data.target);
+      break;
+    case 'Close':
+      document.querySelector('.dialog-modal')?.dispatchEvent(new Event('iframe:modal:closed'));
+      break;
+    default:
+      break;
+  }
+}
+
+export function handleIFrameEvents({ data }) {
+  try {
+    const parsedMsg = JSON.parse(data);
+    if (parsedMsg.app === 'ManagePlan') handleManagePlanEvents(parsedMsg);
+  } catch (error) {
+    window.lana?.log(`Error while attempting to parse JSON from an iframe message: ${error}`);
+  }
+}
+
 export default function init(el) {
-  const url = el.href ?? el.querySelector('a')?.href;
+  const linkHref = el.href ?? el.querySelector('a')?.href;
   el.classList.remove('iframe');
   const classes = [...el.classList].join(' ');
 
-  if (!url) return;
+  if (!linkHref) return;
+  const url = new URL(linkHref);
+
+  if (ALLOWED_MESSAGE_ORIGINS.includes(url.origin) || window.location.origin === url.origin) {
+    window.addEventListener('message', handleIFrameEvents);
+  }
 
-  const iframe = createTag('iframe', { src: url, allowfullscreen: true });
+  const iframe = createTag('iframe', { src: linkHref, allowfullscreen: true });
   const embed = createTag('div', { class: `milo-iframe ${classes}` }, iframe);
 
   el.insertAdjacentElement('afterend', embed);
diff --git a/libs/blocks/modal/modal.css b/libs/blocks/modal/modal.css
index 7a019560f71..16e3a14bb24 100644
--- a/libs/blocks/modal/modal.css
+++ b/libs/blocks/modal/modal.css
@@ -30,7 +30,8 @@
   overflow: hidden;
 }
 
-.upgrade-flow-modal .dialog-close {
+.upgrade-flow-modal .dialog-close,
+.dialog-modal.hide-close-button .dialog-close {
   display: none;
 }
 
@@ -388,6 +389,11 @@
   .dialog-modal.tall-video .milo-video {
     --modal-width-var: 35vw;
   }
+
+  .dialog-modal.manage-plan-cancel {
+    max-width: 100%;
+    width: 1280px;
+  }
 }
 
 @media (min-width: 1200px) and (orientation: landscape) {
diff --git a/libs/blocks/modal/modal.js b/libs/blocks/modal/modal.js
index 2e95a2f60be..d6979fbeb2a 100644
--- a/libs/blocks/modal/modal.js
+++ b/libs/blocks/modal/modal.js
@@ -211,6 +211,7 @@ export async function getModal(details, custom) {
       with the `commerce-frame` or `dynamic-height` classes */
       iframe.style.height = '100%';
     }
+    if (!custom?.closeEvent) dialog.addEventListener('iframe:modal:closed', () => closeModal(dialog));
   }
 
   return dialog;
diff --git a/test/blocks/iframe/iframe.test.js b/test/blocks/iframe/iframe.test.js
index 2b0b0465e7f..c041197de68 100644
--- a/test/blocks/iframe/iframe.test.js
+++ b/test/blocks/iframe/iframe.test.js
@@ -1,7 +1,10 @@
 import { expect } from '@esm-bundle/chai';
+import sinon from 'sinon';
 import { setConfig } from '../../../libs/utils/utils.js';
+import { handleIFrameEvents } from '../../../libs/blocks/iframe/iframe.js';
 
 const { default: init } = await import('../../../libs/blocks/iframe/iframe.js');
+
 const emptyHTML = '<div class="iframe"><div></div></div>';
 const blockHTML = `<div class="iframe additional">
   <div>
@@ -45,4 +48,45 @@ describe('iframe', () => {
 
     expect(document.querySelector('.additional')).to.exist;
   });
+  describe('handleIFrameEvents', () => {
+    const originalOpen = window.open;
+    beforeEach(async () => {
+      window.open = sinon.stub(window, 'open');
+    });
+    afterEach(() => {
+      window.open = originalOpen;
+    });
+    it('should open external url if Type External', async () => {
+      const message = { data: '{"app":"ManagePlan","subType":"EXTERNAL","data":{"externalUrl":"https://www.example.com","target":"_blank"}}' };
+      handleIFrameEvents(message);
+      expect(window.open.calledOnceWith('https://www.example.com', '_blank')).to.be.true;
+    });
+
+    it('should open external url if Type SWITCH', async () => {
+      const message = { data: '{"app":"ManagePlan","subType":"SWITCH","data":{"externalUrl":"https://www.example.com","target":"_self"}}' };
+      handleIFrameEvents(message);
+      expect(window.open.calledOnceWith('https://www.example.com', '_self')).to.be.true;
+    });
+
+    it('should emit close event on modal if Type Close', async () => {
+      const dialog = document.createElement('div');
+      dialog.classList.add('dialog-modal');
+      document.body.appendChild(dialog);
+      const dispatchSpy = sinon.spy(dialog, 'dispatchEvent');
+      const message = { data: '{"app":"ManagePlan","subType":"Close"}' };
+      handleIFrameEvents(message);
+      expect(dispatchSpy.calledOnce).to.be.true;
+    });
+
+    [
+      [{ data: {} }, 'should do nothing if message is not parseble'],
+      [{ data: '{"app":"ManagePlan","subType":"Invalid","data":{"actionRequired":false}}' }, 'should do nothing if message type is not valid'],
+      [{ data: '{"app":"ManagePlan","subType":"Error","data":{"actionRequired":false}}' }, 'should do nothing if message type is error'],
+    ].forEach(([message, desc]) => {
+      it(desc, () => {
+        expect(() => { handleIFrameEvents(message); }).not.to.throw();
+        expect(window.open.calledOnce).to.be.false;
+      });
+    });
+  });
 });


Reviews:
user: login: github-advanced-security[bot]
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-13T16:35:52Z
user: login: mokimo
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-19T11:06:41Z
user: login: overmyheadandbody
body: I just had a quick look over the code, I'll get back with eventual notes after having a look over the functionality.
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-19T11:27:12Z
user: login: zagi25
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-19T15:24:11Z
user: login: overmyheadandbody
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-20T09:49:37Z
user: login: overmyheadandbody
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-20T13:01:55Z
user: login: NadiiaSokolova
body: Verified. Ready for Stage.
Testing details https://jira.corp.adobe.com/browse/MWPW-169072
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-20T13:05:37Z

Review comments:
diff_hunk: @@ -1,13 +1,57 @@
 import { createTag } from '../../utils/utils.js';
 
+const ALLOWED_MESSAGE_ORIGINS = [
+  'https://stage.plan.adobe.com',
+  'https://plan.adobe.com',
+];
+
+function handleManagePlanEvents(message) {
+  const { subType, data } = message;
+  switch (subType) {
+    case 'EXTERNAL':
+      if (!data?.externalUrl || !data?.target) return;
+      window.open(data.externalUrl, data.target);
path: libs/blocks/iframe/iframe.js
user: login: github-advanced-security[bot]
body: ## Client-side URL redirect

Untrusted URL redirection depends on a [user-provided value](1).

[Show more details](https://github.com/adobecom/milo/security/code-scanning/298)
created_at: 2025-03-13T16:35:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,13 +1,57 @@
 import { createTag } from '../../utils/utils.js';
 
+const ALLOWED_MESSAGE_ORIGINS = [
+  'https://stage.plan.adobe.com',
+  'https://plan.adobe.com',
+];
+
+function handleManagePlanEvents(message) {
+  const { subType, data } = message;
+  switch (subType) {
+    case 'EXTERNAL':
+      if (!data?.externalUrl || !data?.target) return;
+      window.open(data.externalUrl, data.target);
+      break;
+    case 'SWITCH':
+      if (!data?.externalUrl || !data?.target) return;
+      window.open(data.externalUrl, data.target);
path: libs/blocks/iframe/iframe.js
user: login: github-advanced-security[bot]
body: ## Client-side URL redirect

Untrusted URL redirection depends on a [user-provided value](1).

[Show more details](https://github.com/adobecom/milo/security/code-scanning/299)
created_at: 2025-03-13T16:35:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,13 +1,57 @@
 import { createTag } from '../../utils/utils.js';
 
+const ALLOWED_MESSAGE_ORIGINS = [
+  'https://stage.plan.adobe.com',
+  'https://plan.adobe.com',
+];
+
+function handleManagePlanEvents(message) {
+  const { subType, data } = message;
+  switch (subType) {
+    case 'EXTERNAL':
+      if (!data?.externalUrl || !data?.target) return;
+      window.open(data.externalUrl, data.target);
+      break;
+    case 'SWITCH':
+      if (!data?.externalUrl || !data?.target) return;
+      window.open(data.externalUrl, data.target);
+      break;
+    case 'Close':
+      document.querySelector('.dialog-modal')?.dispatchEvent(new Event('iframe:modal:closed'));
+      break;
+    default:
+      break;
+  }
+}
+
+export function handleIFrameEvents({ data }) {
+  let parsedMsg = null;
+  try {
+    parsedMsg = JSON.parse(data);
+  } catch (error) {
+    return;
+  }
+  const { app } = parsedMsg;
+  if (app === 'ManagePlan') handleManagePlanEvents(parsedMsg);
+}
+
 export default function init(el) {
-  const url = el.href ?? el.querySelector('a')?.href;
+  const linkHref = el.href ?? el.querySelector('a')?.href;
   el.classList.remove('iframe');
   const classes = [...el.classList].join(' ');
 
-  if (!url) return;
+  let url = null;
+  try {
+    url = new URL(linkHref);
+  } catch (e) {
+    return;
+  }
path: libs/blocks/iframe/iframe.js
user: login: mokimo
body: According to https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#href the href should always be a valid url, so I think we can just do:
```suggestion
  if(!linkHref) return
  let url = new URL(linkHref)
```
created_at: 2025-03-19T11:04:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,13 +1,57 @@
 import { createTag } from '../../utils/utils.js';
 
+const ALLOWED_MESSAGE_ORIGINS = [
+  'https://stage.plan.adobe.com',
+  'https://plan.adobe.com',
+];
+
+function handleManagePlanEvents(message) {
+  const { subType, data } = message;
+  switch (subType) {
+    case 'EXTERNAL':
+      if (!data?.externalUrl || !data?.target) return;
+      window.open(data.externalUrl, data.target);
+      break;
+    case 'SWITCH':
+      if (!data?.externalUrl || !data?.target) return;
+      window.open(data.externalUrl, data.target);
+      break;
+    case 'Close':
+      document.querySelector('.dialog-modal')?.dispatchEvent(new Event('iframe:modal:closed'));
+      break;
+    default:
+      break;
+  }
+}
+
+export function handleIFrameEvents({ data }) {
+  let parsedMsg = null;
+  try {
+    parsedMsg = JSON.parse(data);
+  } catch (error) {
+    return;
+  }
+  const { app } = parsedMsg;
+  if (app === 'ManagePlan') handleManagePlanEvents(parsedMsg);
+}
path: libs/blocks/iframe/iframe.js
user: login: mokimo
body: Nit: let's move everything into the try block
```suggestion
export function handleIFrameEvents({ data }) {
  try {
    const parsedMsg = JSON.parse(data);
    if (parsedMsg.app === 'ManagePlan') handleManagePlanEvents(parsedMsg);
  } catch (error) {
    return;
  }
}
```
created_at: 2025-03-19T11:05:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -388,6 +389,10 @@
   .dialog-modal.tall-video .milo-video {
     --modal-width-var: 35vw;
   }
+
+  .dialog-modal.manage-plan-cancel {
+    width: 1280px;
path: libs/blocks/modal/modal.css
user: login: overmyheadandbody
body: Should there also be a `max-width: 100%`? What would happen at `1200px` (the breakpoint above which this rule get applied), could the modal overflow the page? 🤔 
created_at: 2025-03-19T11:21:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -388,6 +389,10 @@
   .dialog-modal.tall-video .milo-video {
     --modal-width-var: 35vw;
   }
+
+  .dialog-modal.manage-plan-cancel {
+    width: 1280px;
path: libs/blocks/modal/modal.css
user: login: zagi25
body: It doesn't overflow, there is other `max-width` rule, but I think you're right it is better to keep it as wide as possible until the break point. 
created_at: 2025-03-19T15:24:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,13 +1,52 @@
 import { createTag } from '../../utils/utils.js';
 
+const ALLOWED_MESSAGE_ORIGINS = [
+  'https://stage.plan.adobe.com',
+  'https://plan.adobe.com',
+];
+
+function handleManagePlanEvents(message) {
+  const { subType, data } = message;
+  switch (subType) {
+    case 'EXTERNAL':
+      if (!data?.externalUrl || !data?.target) return;
+      window.open(data.externalUrl, data.target);
+      break;
+    case 'SWITCH':
+      if (!data?.externalUrl || !data?.target) return;
+      window.open(data.externalUrl, data.target);
+      break;
+    case 'Close':
+      document.querySelector('.dialog-modal')?.dispatchEvent(new Event('iframe:modal:closed'));
+      break;
+    default:
+      break;
+  }
+}
+
+export function handleIFrameEvents({ data }) {
+  try {
+    const parsedMsg = JSON.parse(data);
+    if (parsedMsg.app === 'ManagePlan') handleManagePlanEvents(parsedMsg);
+  } catch (error) {
+    // eslint-disable-next-line no-console
+    console.log(error);
path: libs/blocks/iframe/iframe.js
user: login: overmyheadandbody
body: Would this be better suited to be a LANA log?
created_at: 2025-03-20T09:49:05Z
author_association: CONTRIBUTOR

PR comments:
user: login: aem-code-sync[bot]
created_at: 2025-03-13T16:33:23Z
body: <!-- aem-bot-dash -->
Hello, I'm the AEM Code Sync Bot and I will run some actions to deploy your branch and validate page speed.
In case there are problems, just click a checkbox below to rerun the respective action.

- [ ] <!-- action-psi --> Re-run PSI checks
- [ ] <!-- action-sync --> Re-sync branch

<details>
<summary>Commits</summary>
<!-- commit-history -->

* [4afa2e5](https://github.com/adobecom/milo/commit/4afa2e5e8e6d2ae1ffa6572bb0ff5224ff0c794e) :white_check_mark: (latest)
* [a883bfe](https://github.com/adobecom/milo/commit/a883bfe878d3e6dd34dfde6f73e3bd114b20695c) :white_check_mark:
* [ba9af67](https://github.com/adobecom/milo/commit/ba9af67eb67688ff49c62fbfe2a60753c0a42393) :white_check_mark:
* [6dfe4bf](https://github.com/adobecom/milo/commit/6dfe4bfcc72c728a77b23926185197ec91486a26) :white_check_mark:
* [3c22171](https://github.com/adobecom/milo/commit/3c22171fe33fdd9d480df08ba1618d63e18d6d06) :white_check_mark:

<!-- commit-history-end -->
</details>
author_association: CONTRIBUTOR
user: login: aem-code-sync[bot]
created_at: 2025-03-13T16:33:42Z
body: <!-- aem-bot-psi -->
| | Page | Scores | Audits | Google |
|-|------|--------|--------|--------|
| :iphone: | [/?martech=off](https://mwpw-169072-cancellation-modal--milo--adobecom.aem.page/?martech=off) | [![PERFORMANCE](https://img.shields.io/badge/PERFORMANCE-99-green?style=for-the-badge "Performance")](https://googlechrome.github.io/lighthouse/scorecalc/#device=mobile&version=12.4.0&SI=990.5124080132252&FCP=855&LCP=2113.5&TBT=0&CLS=0 "See Calculator") ![A11Y](https://img.shields.io/badge/A11Y-96-green?style=for-the-badge "Accessibility") ![SEO](https://img.shields.io/badge/SEO-69-orange?style=for-the-badge "SEO") ![BEST PRACTICES](https://img.shields.io/badge/BEST%20PRACTICES-96-green?style=for-the-badge "Best Practices") | ![SI](https://img.shields.io/badge/SI-1.0s-green?style=for-the-badge "Speed Index") ![FCP](https://img.shields.io/badge/FCP-0.9s-green?style=for-the-badge "First Contentful Paint") ![LCP](https://img.shields.io/badge/LCP-2.1s-green?style=for-the-badge "Largest Contentful Paint") ![TBT](https://img.shields.io/badge/TBT-0ms-green?style=for-the-badge "Total Blocking Time") ![CLS](https://img.shields.io/badge/CLS-0-green?style=for-the-badge "Cumulative Layout Shift") | [![PSI](https://www.gstatic.com/images/icons/material/product/2x/pagespeed_16dp.png)](https://developers.google.com/speed/pagespeed/insights/?url=https%3A%2F%2Fmwpw-169072-cancellation-modal--milo--adobecom.aem.page%2F%3Fmartech%3Doff "Run again in Google PSI") |
| :desktop_computer: | [/?martech=off](https://mwpw-169072-cancellation-modal--milo--adobecom.aem.page/?martech=off) | [![PERFORMANCE](https://img.shields.io/badge/PERFORMANCE-100-green?style=for-the-badge "Performance")](https://googlechrome.github.io/lighthouse/scorecalc/#device=mobile&version=12.4.0&SI=371.38128785120443&FCP=232&LCP=327&TBT=0&CLS=0 "See Calculator") ![A11Y](https://img.shields.io/badge/A11Y-96-green?style=for-the-badge "Accessibility") ![SEO](https://img.shields.io/badge/SEO-69-orange?style=for-the-badge "SEO") ![BEST PRACTICES](https://img.shields.io/badge/BEST%20PRACTICES-93-green?style=for-the-badge "Best Practices") | ![SI](https://img.shields.io/badge/SI-0.4s-green?style=for-the-badge "Speed Index") ![FCP](https://img.shields.io/badge/FCP-0.2s-green?style=for-the-badge "First Contentful Paint") ![LCP](https://img.shields.io/badge/LCP-0.3s-green?style=for-the-badge "Largest Contentful Paint") ![TBT](https://img.shields.io/badge/TBT-0ms-green?style=for-the-badge "Total Blocking Time") ![CLS](https://img.shields.io/badge/CLS-0-green?style=for-the-badge "Cumulative Layout Shift") | [![PSI](https://www.gstatic.com/images/icons/material/product/2x/pagespeed_16dp.png)](https://developers.google.com/speed/pagespeed/insights/?url=https%3A%2F%2Fmwpw-169072-cancellation-modal--milo--adobecom.aem.page%2F%3Fmartech%3Doff "Run again in Google PSI") |

author_association: CONTRIBUTOR