From 50a9ba3e1b62246197928fbd48004a91daedefa3 Mon Sep 17 00:00:00 2001 From: Chris Peyer Date: Fri, 15 May 2026 07:04:53 -0400 Subject: [PATCH 01/18] Auto-recover collab connection on cross-tab IMS sign-in/out A storage event listener watches nx-ims so disconnected tabs reconnect automatically when the user signs back in elsewhere, instead of waiting for the user to hit back. The inverse signal (sign-out elsewhere) drops the connection so we stop hammering with a token that is about to be revoked. The listener is detached on navigation-flagged disconnects so it does not leak across editor instances. Co-Authored-By: Claude Sonnet 4.6 --- blocks/edit/prose/index.js | 31 +++++++ test/unit/blocks/edit/prose/index.test.js | 107 ++++++++++++++++++++-- 2 files changed, 131 insertions(+), 7 deletions(-) diff --git a/blocks/edit/prose/index.js b/blocks/edit/prose/index.js index a2459a60..0e7bc96f 100644 --- a/blocks/edit/prose/index.js +++ b/blocks/edit/prose/index.js @@ -91,6 +91,37 @@ export async function createConnection(path) { lastSentToken = fresh; }); + // Cross-tab sign-in/out propagation. The storage event fires only in OTHER + // tabs, so this lets a disconnected tab recover when the user signs back in + // elsewhere — and proactively drops the connection when another tab signs out. + const handleAuthStorage = async (event) => { + if (event.key !== 'nx-ims') return; + if (event.newValue && !event.oldValue) { + try { await window.adobeIMS?.refreshToken?.(); } catch { /* ignore */ } + const fresh = await getAuthToken(); + if (fresh) { + provider.protocols = ['yjs', fresh]; + lastSentToken = fresh; + } + provider.connect(); + } else if (!event.newValue && event.oldValue) { + provider.disconnect(); + } + }; + window.addEventListener('storage', handleAuthStorage); + + // Detach the storage listener when callers tear down the provider for + // navigation (edit.js / da-content.js pass { data: 'Client navigation' }). + // The focus/blur 10-minute disconnect passes no data hint, so the listener + // survives idle disconnects and can still recover on cross-tab sign-in. + const origDisconnect = provider.disconnect.bind(provider); + provider.disconnect = (disconnectOpts) => { + if (disconnectOpts?.data === 'Client navigation') { + window.removeEventListener('storage', handleAuthStorage); + } + return origDisconnect(); + }; + return { wsProvider: provider, ydoc }; } diff --git a/test/unit/blocks/edit/prose/index.test.js b/test/unit/blocks/edit/prose/index.test.js index 60bd0329..03849214 100644 --- a/test/unit/blocks/edit/prose/index.test.js +++ b/test/unit/blocks/edit/prose/index.test.js @@ -87,7 +87,7 @@ describe('prose/index createConnection', () => { expect(result.ydoc).to.exist; expect(result.wsProvider.maxBackoffTime).to.equal(30000); // Clean up the underlying WS connection - result.wsProvider.disconnect(); + result.wsProvider.disconnect({ data: 'Client navigation' }); result.wsProvider.destroy?.(); result.ydoc.destroy(); }); @@ -116,7 +116,7 @@ describe('prose/index createConnection', () => { expect(wsProvider.protocols).to.deep.equal(['yjs', 'T-rotated']); expect(wsProvider.shouldConnect).to.equal(true); - wsProvider.disconnect(); + wsProvider.disconnect({ data: 'Client navigation' }); wsProvider.destroy?.(); ydoc.destroy(); } finally { @@ -138,7 +138,7 @@ describe('prose/index createConnection', () => { expect(wsProvider.shouldConnect).to.equal(false); - wsProvider.disconnect(); + wsProvider.disconnect({ data: 'Client navigation' }); wsProvider.destroy?.(); ydoc.destroy(); } finally { @@ -171,7 +171,7 @@ describe('prose/index createConnection', () => { expect(wsProvider.shouldConnect).to.equal(false); expect(signInCalls).to.equal(1); - wsProvider.disconnect(); + wsProvider.disconnect({ data: 'Client navigation' }); wsProvider.destroy?.(); ydoc.destroy(); } finally { @@ -199,7 +199,7 @@ describe('prose/index createConnection', () => { expect(wsProvider.shouldConnect).to.equal(false); expect(signInCalls).to.equal(0); - wsProvider.disconnect(); + wsProvider.disconnect({ data: 'Client navigation' }); wsProvider.destroy?.(); ydoc.destroy(); } finally { @@ -225,7 +225,7 @@ describe('prose/index createConnection', () => { // No new token to try — don't loop. expect(wsProvider.shouldConnect).to.equal(false); - wsProvider.disconnect(); + wsProvider.disconnect({ data: 'Client navigation' }); wsProvider.destroy?.(); ydoc.destroy(); } finally { @@ -249,7 +249,100 @@ describe('prose/index createConnection', () => { expect(wsProvider.protocols).to.deep.equal(['yjs']); expect(wsProvider.shouldConnect).to.equal(true); - wsProvider.disconnect(); + wsProvider.disconnect({ data: 'Client navigation' }); + wsProvider.destroy?.(); + ydoc.destroy(); + } finally { + if (savedIMS === undefined) delete window.adobeIMS; else window.adobeIMS = savedIMS; + } + }); + + it('Storage event for nx-ims sign-in elsewhere refreshes token and reconnects', async () => { + window.localStorage.removeItem('nx-ims'); + const savedIMS = window.adobeIMS; + let refreshCalls = 0; + window.adobeIMS = { + // During createConnection, nx-ims is unset so getAuthToken short-circuits + // and never calls getAccessToken. Only the storage handler reaches it. + getAccessToken: () => ({ token: 'T-after-signin' }), + refreshToken: async () => { refreshCalls += 1; }, + }; + + try { + const { wsProvider, ydoc } = await createConnection('https://admin.da.live/source/org/repo/page.html'); + expect(wsProvider.protocols).to.deep.equal(['yjs']); + + window.localStorage.setItem('nx-ims', 'true'); + window.dispatchEvent(new StorageEvent('storage', { + key: 'nx-ims', + newValue: 'true', + oldValue: null, + })); + await new Promise((r) => { setTimeout(r, 50); }); + + expect(refreshCalls).to.equal(1); + expect(wsProvider.protocols).to.deep.equal(['yjs', 'T-after-signin']); + expect(wsProvider.shouldConnect).to.equal(true); + + wsProvider.disconnect({ data: 'Client navigation' }); + wsProvider.destroy?.(); + ydoc.destroy(); + } finally { + if (savedIMS === undefined) delete window.adobeIMS; else window.adobeIMS = savedIMS; + } + }); + + it('Storage event for nx-ims sign-out elsewhere disconnects', async () => { + window.localStorage.setItem('nx-ims', 'true'); + const savedIMS = window.adobeIMS; + window.adobeIMS = { + getAccessToken: () => ({ token: 'T-initial' }), + refreshToken: async () => {}, + }; + + try { + const { wsProvider, ydoc } = await createConnection('https://admin.da.live/source/org/repo/page.html'); + expect(wsProvider.shouldConnect).to.equal(true); + + window.dispatchEvent(new StorageEvent('storage', { + key: 'nx-ims', + newValue: null, + oldValue: 'true', + })); + await new Promise((r) => { setTimeout(r, 0); }); + + expect(wsProvider.shouldConnect).to.equal(false); + + wsProvider.disconnect({ data: 'Client navigation' }); + wsProvider.destroy?.(); + ydoc.destroy(); + } finally { + if (savedIMS === undefined) delete window.adobeIMS; else window.adobeIMS = savedIMS; + } + }); + + it('Navigation disconnect detaches the storage listener', async () => { + window.localStorage.removeItem('nx-ims'); + const savedIMS = window.adobeIMS; + let refreshCalls = 0; + window.adobeIMS = { + getAccessToken: () => ({ token: 'T-x' }), + refreshToken: async () => { refreshCalls += 1; }, + }; + + try { + const { wsProvider, ydoc } = await createConnection('https://admin.da.live/source/org/repo/page.html'); + wsProvider.disconnect({ data: 'Client navigation' }); + + window.dispatchEvent(new StorageEvent('storage', { + key: 'nx-ims', + newValue: 'true', + oldValue: null, + })); + await new Promise((r) => { setTimeout(r, 20); }); + + expect(refreshCalls).to.equal(0); + wsProvider.destroy?.(); ydoc.destroy(); } finally { From d9709fede15921ef743c00d36dd81763699f7c26 Mon Sep 17 00:00:00 2001 From: Chris Peyer Date: Fri, 15 May 2026 08:03:15 -0400 Subject: [PATCH 02/18] Surface auth failures with an in-page banner instead of redirecting Previously, a collab 4401 we couldn't recover from or a daFetch 401 with no token both navigated the tab to IMS sign-in, destroying any unsaved editor state. Now we mount a at the top of the viewport instead. The banner watches the nx-ims storage key and auto-dismisses when another tab signs in, calling refreshToken so pending state can pick up the new session. Manual "Sign in" still triggers handleSignIn for the no-other-tab case. daFetch now also tries window.adobeIMS.refreshToken() + a single retry on 401 before any user-visible disruption, which covers the cross-tab "just signed in" race. Co-Authored-By: Claude Sonnet 4.6 --- blocks/edit/prose/index.js | 11 +-- .../shared/da-auth-banner/da-auth-banner.css | 42 ++++++++ .../shared/da-auth-banner/da-auth-banner.js | 69 +++++++++++++ blocks/shared/utils.js | 44 +++++---- test/unit/blocks/edit/prose/index.test.js | 22 ++--- .../unit/blocks/shared/da-auth-banner.test.js | 96 +++++++++++++++++++ test/unit/blocks/shared/utils.test.js | 61 ++++++++++++ 7 files changed, 311 insertions(+), 34 deletions(-) create mode 100644 blocks/shared/da-auth-banner/da-auth-banner.css create mode 100644 blocks/shared/da-auth-banner/da-auth-banner.js create mode 100644 test/unit/blocks/shared/da-auth-banner.test.js diff --git a/blocks/edit/prose/index.js b/blocks/edit/prose/index.js index 0e7bc96f..d90461ce 100644 --- a/blocks/edit/prose/index.js +++ b/blocks/edit/prose/index.js @@ -23,7 +23,6 @@ import { } from 'da-y-wrapper'; import { getSchema } from 'da-parser'; -import { getNx } from '../../../scripts/utils.js'; import { COLLAB_ORIGIN, DA_ORIGIN } from '../../shared/constants.js'; import { daFetch, getAuthToken } from '../../shared/utils.js'; import { getDiffClass, checkForLocNodes, addActiveView } from './diff/diff-utils.js'; @@ -71,13 +70,13 @@ export async function createConnection(path) { if (!fresh || fresh === lastSentToken) { // No new token to try — retrying would loop on the same 4401 forever. provider.shouldConnect = false; - // If the user expected to be signed in, route them through IMS sign-in - // so collab can recover. Matches daFetch's 401 handling. Anonymous users - // (no nx-ims flag) hitting a private doc are left disconnected. + // If the user expected to be signed in, surface an in-page banner instead + // of navigating away — preserves the editor state and lets the user sign + // back in (or wait for a cross-tab sign-in) without losing context. if (localStorage.getItem('nx-ims')) { try { - const { handleSignIn } = await import(`${getNx()}/utils/ims.js`); - handleSignIn(); + const { showAuthBanner } = await import('../../shared/da-auth-banner/da-auth-banner.js'); + showAuthBanner(); } catch { /* nothing to do */ } } return; diff --git a/blocks/shared/da-auth-banner/da-auth-banner.css b/blocks/shared/da-auth-banner/da-auth-banner.css new file mode 100644 index 00000000..204a6941 --- /dev/null +++ b/blocks/shared/da-auth-banner/da-auth-banner.css @@ -0,0 +1,42 @@ +:host { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 9999; + display: block; + pointer-events: none; +} + +.da-auth-banner { + pointer-events: auto; + display: flex; + align-items: center; + justify-content: center; + gap: 16px; + padding: 12px 20px; + background: var(--s2-orange-700, #c4540c); + color: #fff; + font-size: 14px; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); +} + +.da-auth-banner-msg { + font-weight: 500; +} + +.da-auth-banner-action { + background: #fff; + color: var(--s2-orange-700, #c4540c); + border: none; + border-radius: 4px; + padding: 6px 16px; + font-size: 13px; + font-weight: 600; + cursor: pointer; +} + +.da-auth-banner-action:hover { + background: rgba(255, 255, 255, 0.9); +} diff --git a/blocks/shared/da-auth-banner/da-auth-banner.js b/blocks/shared/da-auth-banner/da-auth-banner.js new file mode 100644 index 00000000..d914f37d --- /dev/null +++ b/blocks/shared/da-auth-banner/da-auth-banner.js @@ -0,0 +1,69 @@ +import { LitElement, html } from 'da-lit'; +import { getNx } from '../../../scripts/utils.js'; +import { getAuthToken } from '../utils.js'; + +const { loadStyle } = await import(`${getNx()}/utils/utils.js`); +const STYLE = await loadStyle(import.meta.url); + +let mountedInstance = null; + +export class DaAuthBanner extends LitElement { + connectedCallback() { + super.connectedCallback(); + this.shadowRoot.adoptedStyleSheets = [STYLE]; + this._onStorage = this._onStorage.bind(this); + window.addEventListener('storage', this._onStorage); + } + + disconnectedCallback() { + super.disconnectedCallback(); + window.removeEventListener('storage', this._onStorage); + if (mountedInstance === this) mountedInstance = null; + } + + async _onStorage(event) { + if (event.key !== 'nx-ims' || !event.newValue || event.oldValue) return; + // Another tab signed in. Pull the new token and dismiss. + try { await window.adobeIMS?.refreshToken?.(); } catch { /* ignore */ } + if (await getAuthToken()) this._recover(); + } + + async _signIn() { + const { loadIms, handleSignIn } = await import(`${getNx()}/utils/ims.js`); + await loadIms(); + handleSignIn(); + } + + _recover() { + window.dispatchEvent(new CustomEvent('da-auth-recovered')); + this._dismiss(); + } + + _dismiss() { + if (mountedInstance === this) mountedInstance = null; + this.remove(); + } + + render() { + return html` + + `; + } +} + +customElements.define('da-auth-banner', DaAuthBanner); + +export function showAuthBanner() { + if (mountedInstance?.isConnected) return mountedInstance; + mountedInstance = document.createElement('da-auth-banner'); + document.body.appendChild(mountedInstance); + return mountedInstance; +} + +export function hideAuthBanner() { + // eslint-disable-next-line no-underscore-dangle + if (mountedInstance) mountedInstance._dismiss(); +} diff --git a/blocks/shared/utils.js b/blocks/shared/utils.js index a8a0ea4a..d58a5132 100644 --- a/blocks/shared/utils.js +++ b/blocks/shared/utils.js @@ -35,22 +35,33 @@ export async function getAuthToken() { export const daFetch = async (url, opts = {}) => { opts.headers = opts.headers || {}; - const accessToken = await getAuthToken(); - if (accessToken) { + const setBearer = (tok) => { const canToken = ALLOWED_TOKEN.some((origin) => new URL(url).origin === origin); - if (canToken) { - opts.headers.Authorization = `Bearer ${accessToken}`; - if (AEM_ORIGINS.some((origin) => new URL(url).origin === origin)) { - opts.headers['x-content-source-authorization'] = `Bearer ${accessToken}`; - } + if (!canToken) return; + opts.headers.Authorization = `Bearer ${tok}`; + if (AEM_ORIGINS.some((origin) => new URL(url).origin === origin)) { + opts.headers['x-content-source-authorization'] = `Bearer ${tok}`; } - } - const resp = await fetch(url, opts); - if (resp.status === 401 && opts.noRedirect !== true) { - // Only attempt sign-in if the request is for DA. - if (DA_ORIGINS.some((origin) => url.startsWith(origin))) { - // If the user has an access token, but are not permitted, redirect them to not found. - if (accessToken) { + }; + + const accessToken = await getAuthToken(); + if (accessToken) setBearer(accessToken); + + let resp = await fetch(url, opts); + + if (resp.status === 401 && opts.noRedirect !== true + && DA_ORIGINS.some((origin) => url.startsWith(origin))) { + // Silent recovery: another tab may have just refreshed/signed in. Ask imslib + // for a fresh token and retry once before any user-visible disruption. + let refreshed = null; + try { await window.adobeIMS?.refreshToken?.(); } catch { /* ignore */ } + refreshed = await getAuthToken(); + if (refreshed && refreshed !== accessToken) { + setBearer(refreshed); + resp = await fetch(url, opts); + } + if (resp.status === 401) { + if (refreshed || accessToken) { // eslint-disable-next-line no-console console.warn('You see the 404 page because you have no access to this page', url); window.location = `${window.location.origin}/not-found`; @@ -58,9 +69,8 @@ export const daFetch = async (url, opts = {}) => { } // eslint-disable-next-line no-console console.warn('You need to sign in because you are not authorized to access this page', url); - const { loadIms, handleSignIn } = await import(`${getNx()}/utils/ims.js`); - await loadIms(); - handleSignIn(); + const { showAuthBanner } = await import('./da-auth-banner/da-auth-banner.js'); + showAuthBanner(); } } diff --git a/test/unit/blocks/edit/prose/index.test.js b/test/unit/blocks/edit/prose/index.test.js index 03849214..ae3cdd99 100644 --- a/test/unit/blocks/edit/prose/index.test.js +++ b/test/unit/blocks/edit/prose/index.test.js @@ -79,6 +79,7 @@ describe('prose/index createConnection', () => { } else { window.localStorage.removeItem('nx-ims'); } + document.querySelectorAll('da-auth-banner').forEach((el) => el.remove()); }); it('Returns a wsProvider and a Y.Doc with maxBackoffTime configured', async () => { @@ -146,15 +147,14 @@ describe('prose/index createConnection', () => { } }); - it('Stops reconnecting on 4401 when imslib cannot produce a token, triggers sign-in', async () => { + it('Stops reconnecting on 4401 when imslib cannot produce a token, shows banner', async () => { window.localStorage.setItem('nx-ims', 'true'); const savedIMS = window.adobeIMS; let refreshCalls = 0; - let signInCalls = 0; window.adobeIMS = { getAccessToken: () => ({ token: 'T-initial' }), refreshToken: async () => { refreshCalls += 1; }, - signIn: () => { signInCalls += 1; }, + signIn: () => {}, }; try { @@ -164,13 +164,14 @@ describe('prose/index createConnection', () => { window.adobeIMS.getAccessToken = () => null; wsProvider.emit('connection-close', [{ code: 4401, reason: 'auth' }, wsProvider]); - // Allow the dynamic import + signIn call to settle - await new Promise((r) => { setTimeout(r, 50); }); + // Allow the dynamic banner import + mount to settle + await new Promise((r) => { setTimeout(r, 80); }); expect(refreshCalls).to.equal(1); expect(wsProvider.shouldConnect).to.equal(false); - expect(signInCalls).to.equal(1); + expect(document.querySelector('da-auth-banner')).to.exist; + document.querySelector('da-auth-banner')?.remove(); wsProvider.disconnect({ data: 'Client navigation' }); wsProvider.destroy?.(); ydoc.destroy(); @@ -179,14 +180,13 @@ describe('prose/index createConnection', () => { } }); - it('Anonymous user hitting a private doc bails on 4401 without sign-in redirect', async () => { + it('Anonymous user hitting a private doc bails on 4401 without showing banner', async () => { window.localStorage.removeItem('nx-ims'); const savedIMS = window.adobeIMS; - let signInCalls = 0; window.adobeIMS = { getAccessToken: () => null, refreshToken: async () => {}, - signIn: () => { signInCalls += 1; }, + signIn: () => {}, }; try { @@ -194,10 +194,10 @@ describe('prose/index createConnection', () => { expect(wsProvider.protocols).to.deep.equal(['yjs']); wsProvider.emit('connection-close', [{ code: 4401, reason: 'auth' }, wsProvider]); - await new Promise((r) => { setTimeout(r, 50); }); + await new Promise((r) => { setTimeout(r, 80); }); expect(wsProvider.shouldConnect).to.equal(false); - expect(signInCalls).to.equal(0); + expect(document.querySelector('da-auth-banner')).to.not.exist; wsProvider.disconnect({ data: 'Client navigation' }); wsProvider.destroy?.(); diff --git a/test/unit/blocks/shared/da-auth-banner.test.js b/test/unit/blocks/shared/da-auth-banner.test.js new file mode 100644 index 00000000..9b11c536 --- /dev/null +++ b/test/unit/blocks/shared/da-auth-banner.test.js @@ -0,0 +1,96 @@ +import { expect } from '@esm-bundle/chai'; +import { setNx } from '../../../../scripts/utils.js'; + +setNx('/test/fixtures/nx', { hostname: 'example.com' }); + +const { showAuthBanner, hideAuthBanner } = await import('../../../../blocks/shared/da-auth-banner/da-auth-banner.js'); + +const wait = (ms) => new Promise((r) => { setTimeout(r, ms); }); + +describe('da-auth-banner', () => { + let savedNxIms; + let savedIMS; + + beforeEach(() => { + savedNxIms = window.localStorage.getItem('nx-ims'); + savedIMS = window.adobeIMS; + document.querySelectorAll('da-auth-banner').forEach((el) => el.remove()); + }); + + afterEach(() => { + if (savedNxIms) { + window.localStorage.setItem('nx-ims', savedNxIms); + } else { + window.localStorage.removeItem('nx-ims'); + } + if (savedIMS === undefined) delete window.adobeIMS; else window.adobeIMS = savedIMS; + document.querySelectorAll('da-auth-banner').forEach((el) => el.remove()); + }); + + it('showAuthBanner mounts a single banner element', () => { + const a = showAuthBanner(); + const b = showAuthBanner(); + expect(a).to.equal(b); + expect(document.querySelectorAll('da-auth-banner').length).to.equal(1); + }); + + it('hideAuthBanner removes the banner', () => { + showAuthBanner(); + hideAuthBanner(); + expect(document.querySelector('da-auth-banner')).to.not.exist; + }); + + it('Sign-in button calls handleSignIn which invokes adobeIMS.signIn', async () => { + let signInCalls = 0; + window.adobeIMS = { signIn: () => { signInCalls += 1; } }; + + const banner = showAuthBanner(); + await banner.updateComplete; + banner.shadowRoot.querySelector('.da-auth-banner-action').click(); + await wait(50); + expect(signInCalls).to.equal(1); + }); + + it('Auto-dismisses on cross-tab sign-in (nx-ims storage event)', async () => { + let refreshCalls = 0; + window.adobeIMS = { + getAccessToken: () => ({ token: 'T-new' }), + refreshToken: async () => { refreshCalls += 1; }, + }; + window.localStorage.setItem('nx-ims', 'true'); + + showAuthBanner(); + let recovered = false; + const onRecovered = () => { recovered = true; }; + window.addEventListener('da-auth-recovered', onRecovered); + + window.dispatchEvent(new StorageEvent('storage', { + key: 'nx-ims', + newValue: 'true', + oldValue: null, + })); + await wait(50); + + window.removeEventListener('da-auth-recovered', onRecovered); + expect(refreshCalls).to.equal(1); + expect(recovered).to.equal(true); + expect(document.querySelector('da-auth-banner')).to.not.exist; + }); + + it('Ignores storage events for other keys', async () => { + window.adobeIMS = { + getAccessToken: () => ({ token: 'T' }), + refreshToken: async () => {}, + }; + window.localStorage.setItem('nx-ims', 'true'); + showAuthBanner(); + + window.dispatchEvent(new StorageEvent('storage', { + key: 'unrelated', + newValue: 'foo', + oldValue: null, + })); + await wait(20); + expect(document.querySelector('da-auth-banner')).to.exist; + }); +}); diff --git a/test/unit/blocks/shared/utils.test.js b/test/unit/blocks/shared/utils.test.js index f14e5115..4394c454 100644 --- a/test/unit/blocks/shared/utils.test.js +++ b/test/unit/blocks/shared/utils.test.js @@ -1,4 +1,5 @@ import { expect } from '@esm-bundle/chai'; +import { setNx } from '../../../../scripts/utils.js'; import { daFetch, etcFetch, @@ -14,6 +15,11 @@ import { getAuthToken, } from '../../../../blocks/shared/utils.js'; +// daFetch's 401-no-token path lazy-loads the banner module, which resolves +// `${getNx()}/utils/utils.js`. Configure nx for the test environment so that +// import works. +setNx('/test/fixtures/nx', { hostname: 'example.com' }); + describe('getSheetByIndex', () => { it('Returns data directly for non-multi-sheet', () => { const json = { ':type': 'sheet', data: [{ a: 1 }] }; @@ -303,6 +309,61 @@ describe('daFetch', () => { expect(resp.permissions).to.deep.equal(['read', 'write']); }); + it('On 401 with no token, dispatches banner instead of redirecting to IMS', async () => { + window.localStorage.removeItem('nx-ims'); + const savedIMS = window.adobeIMS; + delete window.adobeIMS; + + window.fetch = () => Promise.resolve(new Response('nope', { status: 401 })); + + try { + // DA_ORIGINS in utils.js includes http://localhost:8787 — use that so the + // 401 path triggers without us needing to monkey-patch the origin list. + const resp = await daFetch('http://localhost:8787/source/o/r/p.html'); + // Wait for the dynamic banner import to settle. + await new Promise((r) => { setTimeout(r, 80); }); + + expect(resp.ok).to.equal(false); + expect(document.querySelector('da-auth-banner')).to.exist; + } finally { + document.querySelector('da-auth-banner')?.remove(); + if (savedIMS === undefined) delete window.adobeIMS; else window.adobeIMS = savedIMS; + } + }); + + it('On 401 with a token, refreshToken-and-retry recovers without redirect', async () => { + window.localStorage.setItem('nx-ims', 'true'); + const savedIMS = window.adobeIMS; + let getCalls = 0; + const tokens = ['stale', 'fresh']; + window.adobeIMS = { + getAccessToken: () => { + const t = tokens[Math.min(getCalls, tokens.length - 1)]; + getCalls += 1; + return { token: t }; + }, + refreshToken: async () => {}, + }; + + let fetchCalls = 0; + const capturedAuth = []; + window.fetch = (url, opts) => { + fetchCalls += 1; + capturedAuth.push(opts?.headers?.Authorization); + // First call returns 401; second returns 200. + return Promise.resolve(new Response('ok', { status: fetchCalls === 1 ? 401 : 200 })); + }; + + try { + const resp = await daFetch('http://localhost:8787/source/o/r/p.html'); + expect(resp.ok).to.equal(true); + expect(fetchCalls).to.equal(2); + expect(capturedAuth).to.deep.equal(['Bearer stale', 'Bearer fresh']); + } finally { + if (savedIMS === undefined) delete window.adobeIMS; else window.adobeIMS = savedIMS; + } + }); + it('Returns 403 response directly', async () => { window.localStorage.removeItem('nx-ims'); window.fetch = () => Promise.resolve(new Response('forbidden', { status: 403 })); From bfc8d6f5f072b468cd70bd53f74618f296bac431 Mon Sep 17 00:00:00 2001 From: Chris Peyer Date: Fri, 15 May 2026 08:41:29 -0400 Subject: [PATCH 03/18] Auth dialog: redirect home on sign-out, reload on cross-tab sign-in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes addressing reported issues: 1. Global storage listener in scripts.js navigates every tab to '/' when any tab clears nx-ims, so an explicit sign-out is never followed by a session-expired dialog elsewhere. 2. The auth UI is now a modal opened with showModal() instead of an unobtrusive banner, blocking page interaction until the user signs in or another tab takes them somewhere. 3. On cross-tab sign-in, the dialog now reloads the page rather than trying to refresh imslib state in place — that path didn't reliably update the dialog tab's session, leaving the dialog stuck open. Co-Authored-By: Claude Sonnet 4.6 --- .../shared/da-auth-banner/da-auth-banner.css | 68 +++++++++++-------- .../shared/da-auth-banner/da-auth-banner.js | 50 ++++++++++---- scripts/scripts.js | 9 +++ .../unit/blocks/shared/da-auth-banner.test.js | 62 ++++++++++------- 4 files changed, 123 insertions(+), 66 deletions(-) diff --git a/blocks/shared/da-auth-banner/da-auth-banner.css b/blocks/shared/da-auth-banner/da-auth-banner.css index 204a6941..51d0b4cc 100644 --- a/blocks/shared/da-auth-banner/da-auth-banner.css +++ b/blocks/shared/da-auth-banner/da-auth-banner.css @@ -1,42 +1,56 @@ :host { - position: fixed; - top: 0; - left: 0; - right: 0; - z-index: 9999; - display: block; - pointer-events: none; + display: contents; } -.da-auth-banner { - pointer-events: auto; - display: flex; - align-items: center; - justify-content: center; - gap: 16px; - padding: 12px 20px; - background: var(--s2-orange-700, #c4540c); - color: #fff; - font-size: 14px; +dialog { + border: none; + border-radius: 8px; + padding: 24px 28px; + max-width: 420px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); + color: #222; +} + +dialog::backdrop { + background: rgba(0, 0, 0, 0.4); + backdrop-filter: blur(2px); +} + +.da-auth-title { + margin: 0 0 8px; + font-size: 18px; + font-weight: 700; } -.da-auth-banner-msg { - font-weight: 500; +.da-auth-msg { + margin: 0 0 20px; + font-size: 14px; + color: #555; +} + +.da-auth-actions { + display: flex; + justify-content: flex-end; } -.da-auth-banner-action { - background: #fff; - color: var(--s2-orange-700, #c4540c); +.da-auth-action { + background: #1473e6; + color: #fff; border: none; border-radius: 4px; - padding: 6px 16px; - font-size: 13px; + padding: 8px 20px; + font-size: 14px; font-weight: 600; cursor: pointer; } -.da-auth-banner-action:hover { - background: rgba(255, 255, 255, 0.9); +.da-auth-action:hover { + background: #1064c0; +} + +.da-auth-action:focus-visible { + outline: 2px solid #fff; + outline-offset: -4px; + box-shadow: 0 0 0 2px #1473e6; } diff --git a/blocks/shared/da-auth-banner/da-auth-banner.js b/blocks/shared/da-auth-banner/da-auth-banner.js index d914f37d..05fb2cae 100644 --- a/blocks/shared/da-auth-banner/da-auth-banner.js +++ b/blocks/shared/da-auth-banner/da-auth-banner.js @@ -1,6 +1,5 @@ import { LitElement, html } from 'da-lit'; import { getNx } from '../../../scripts/utils.js'; -import { getAuthToken } from '../utils.js'; const { loadStyle } = await import(`${getNx()}/utils/utils.js`); const STYLE = await loadStyle(import.meta.url); @@ -21,35 +20,56 @@ export class DaAuthBanner extends LitElement { if (mountedInstance === this) mountedInstance = null; } - async _onStorage(event) { - if (event.key !== 'nx-ims' || !event.newValue || event.oldValue) return; - // Another tab signed in. Pull the new token and dismiss. - try { await window.adobeIMS?.refreshToken?.(); } catch { /* ignore */ } - if (await getAuthToken()) this._recover(); + firstUpdated() { + if (!this.isConnected) return; + try { this.shadowRoot.querySelector('dialog')?.showModal(); } catch { /* detached */ } } + _onStorage(event) { + if (event.key !== 'nx-ims') return; + if (event.newValue && !event.oldValue) { + // Another tab signed in. imslib state in this tab may not pick up the + // new session in place reliably, so reload — that re-runs init and + // restores everything cleanly. + this._reload(); + } else if (!event.newValue && event.oldValue) { + // Another tab signed out — the global handler in scripts.js will + // navigate home; this is just a defensive secondary path. + this._goHome(); + } + } + + // Indirected for testability. + // eslint-disable-next-line class-methods-use-this + _reload() { window.location.reload(); } + + // eslint-disable-next-line class-methods-use-this + _goHome() { window.location = '/'; } + async _signIn() { const { loadIms, handleSignIn } = await import(`${getNx()}/utils/ims.js`); await loadIms(); handleSignIn(); } - _recover() { - window.dispatchEvent(new CustomEvent('da-auth-recovered')); - this._dismiss(); - } - _dismiss() { + const dlg = this.shadowRoot?.querySelector('dialog'); + if (dlg?.open) dlg.close(); if (mountedInstance === this) mountedInstance = null; this.remove(); } render() { return html` - + e.preventDefault()}> +

Your session has expired

+

Sign in again to continue.

+
+ +
+
`; } } diff --git a/scripts/scripts.js b/scripts/scripts.js index 4dee529c..c08895a4 100644 --- a/scripts/scripts.js +++ b/scripts/scripts.js @@ -57,6 +57,15 @@ const CONFIG = { imsScope: 'ab.manage,AdobeID,gnav,openid,org.read,read_organizations,session,aem.frontend.all,additional_info.ownerOrg,additional_info.projectedProductContext,account_cluster.read', }; +// Cross-tab sign-out propagation: when any tab signs out (handleSignOut removes +// the nx-ims flag), every other tab navigates to the home screen instead of +// sitting in a half-authed state or showing the session-expired dialog. +window.addEventListener('storage', (event) => { + if (event.key === 'nx-ims' && !event.newValue && event.oldValue) { + window.location = '/'; + } +}); + export default async function loadPage() { if (!nx2) { // pin to light scheme diff --git a/test/unit/blocks/shared/da-auth-banner.test.js b/test/unit/blocks/shared/da-auth-banner.test.js index 9b11c536..680c7e46 100644 --- a/test/unit/blocks/shared/da-auth-banner.test.js +++ b/test/unit/blocks/shared/da-auth-banner.test.js @@ -46,44 +46,55 @@ describe('da-auth-banner', () => { const banner = showAuthBanner(); await banner.updateComplete; - banner.shadowRoot.querySelector('.da-auth-banner-action').click(); + banner.shadowRoot.querySelector('.da-auth-action').click(); await wait(50); expect(signInCalls).to.equal(1); }); - it('Auto-dismisses on cross-tab sign-in (nx-ims storage event)', async () => { - let refreshCalls = 0; - window.adobeIMS = { - getAccessToken: () => ({ token: 'T-new' }), - refreshToken: async () => { refreshCalls += 1; }, - }; - window.localStorage.setItem('nx-ims', 'true'); - - showAuthBanner(); - let recovered = false; - const onRecovered = () => { recovered = true; }; - window.addEventListener('da-auth-recovered', onRecovered); + it('Reloads on cross-tab sign-in (nx-ims storage event)', async () => { + const banner = showAuthBanner(); + let reloadCalls = 0; + banner._reload = () => { reloadCalls += 1; }; window.dispatchEvent(new StorageEvent('storage', { key: 'nx-ims', newValue: 'true', oldValue: null, })); - await wait(50); + await wait(20); - window.removeEventListener('da-auth-recovered', onRecovered); - expect(refreshCalls).to.equal(1); - expect(recovered).to.equal(true); - expect(document.querySelector('da-auth-banner')).to.not.exist; + expect(reloadCalls).to.equal(1); + }); + + it('Navigates home on cross-tab sign-out (nx-ims removed)', async () => { + const banner = showAuthBanner(); + let goHomeCalls = 0; + banner._goHome = () => { goHomeCalls += 1; }; + + window.dispatchEvent(new StorageEvent('storage', { + key: 'nx-ims', + newValue: null, + oldValue: 'true', + })); + await wait(20); + + expect(goHomeCalls).to.equal(1); + }); + + it('Renders as a modal that blocks the page', async () => { + const banner = showAuthBanner(); + await banner.updateComplete; + const dlg = banner.shadowRoot.querySelector('dialog'); + expect(dlg).to.exist; + expect(dlg.open).to.equal(true); }); it('Ignores storage events for other keys', async () => { - window.adobeIMS = { - getAccessToken: () => ({ token: 'T' }), - refreshToken: async () => {}, - }; - window.localStorage.setItem('nx-ims', 'true'); - showAuthBanner(); + const banner = showAuthBanner(); + let reloadCalls = 0; + let goHomeCalls = 0; + banner._reload = () => { reloadCalls += 1; }; + banner._goHome = () => { goHomeCalls += 1; }; window.dispatchEvent(new StorageEvent('storage', { key: 'unrelated', @@ -91,6 +102,9 @@ describe('da-auth-banner', () => { oldValue: null, })); await wait(20); + + expect(reloadCalls).to.equal(0); + expect(goHomeCalls).to.equal(0); expect(document.querySelector('da-auth-banner')).to.exist; }); }); From bdd9f8085ae0b2834606b824fb8a47cda6d00c98 Mon Sep 17 00:00:00 2001 From: Chris Peyer Date: Fri, 15 May 2026 08:47:19 -0400 Subject: [PATCH 04/18] Redirect only the sign-out tab home, not every signed-in tab Reverts the global storage listener that navigated every tab to '/' on nx-ims removal. Other tabs now stay on their page and show the session dialog as normal. The originating tab is redirected to '/' by detecting the IMS callback URL after sign-out: returning from IMS without an nx-ims flag means the round-trip was a sign-out (sign-in would have populated nx-ims), so we replace location with '/' before loading the area that the user signed out from. Co-Authored-By: Claude Sonnet 4.6 --- scripts/scripts.js | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/scripts/scripts.js b/scripts/scripts.js index c08895a4..e94a0c18 100644 --- a/scripts/scripts.js +++ b/scripts/scripts.js @@ -57,15 +57,6 @@ const CONFIG = { imsScope: 'ab.manage,AdobeID,gnav,openid,org.read,read_organizations,session,aem.frontend.all,additional_info.ownerOrg,additional_info.projectedProductContext,account_cluster.read', }; -// Cross-tab sign-out propagation: when any tab signs out (handleSignOut removes -// the nx-ims flag), every other tab navigates to the home screen instead of -// sitting in a half-authed state or showing the session-expired dialog. -window.addEventListener('storage', (event) => { - if (event.key === 'nx-ims' && !event.newValue && event.oldValue) { - window.location = '/'; - } -}); - export default async function loadPage() { if (!nx2) { // pin to light scheme @@ -77,8 +68,17 @@ export default async function loadPage() { // Only block on IMS for OAuth-callback loads const { hash } = window.location; - if (hash.includes('access_token=') || hash.includes('old_hash=')) { + const isImsCallback = hash.includes('access_token=') || hash.includes('old_hash='); + if (isImsCallback) { await imsReady; + // Returning from IMS without a session means the round-trip was a + // sign-OUT (sign-in would have populated nx-ims). Land the user on home + // instead of whatever page they signed out from — that page would just + // show the session-expired dialog otherwise. + if (!localStorage.getItem('nx-ims')) { + window.location.replace('/'); + return; + } } await loadArea(); From 6a8ecb9fd4e03ad85a0b5fd84f9862429562f6f2 Mon Sep 17 00:00:00 2001 From: Chris Peyer Date: Fri, 15 May 2026 08:57:12 -0400 Subject: [PATCH 05/18] Don't redirect home on a failed sign-in attempt The previous logic redirected to '/' whenever a tab returned from IMS without a token, which also fired when the user clicked "Sign in" on the modal but IMS didn't grant a token (e.g. SSO cookie scrubbed by a recent sign-out, or user cancelled the form). Snapshot nx-ims before imsReady touches it so we can tell sign-in (was 'true') from sign-out (was empty) and only redirect for the sign-out case. Co-Authored-By: Claude Sonnet 4.6 --- scripts/scripts.js | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/scripts/scripts.js b/scripts/scripts.js index e94a0c18..5897213d 100644 --- a/scripts/scripts.js +++ b/scripts/scripts.js @@ -63,6 +63,10 @@ export default async function loadPage() { document.body.classList.remove('light-scheme', 'dark-scheme'); document.body.classList.add('light-scheme'); } + // Snapshot the auth flag BEFORE imsReady touches it. handleSignIn sets it + // and handleSignOut clears it synchronously before navigating to IMS, so + // this captures the user's intent across the IMS round-trip. + const wasSignedIn = !!localStorage.getItem('nx-ims'); const imsReady = initIms(); await setConfig(CONFIG); @@ -71,11 +75,13 @@ export default async function loadPage() { const isImsCallback = hash.includes('access_token=') || hash.includes('old_hash='); if (isImsCallback) { await imsReady; - // Returning from IMS without a session means the round-trip was a - // sign-OUT (sign-in would have populated nx-ims). Land the user on home - // instead of whatever page they signed out from — that page would just - // show the session-expired dialog otherwise. - if (!localStorage.getItem('nx-ims')) { + // No token after the round-trip AND we weren't expecting a sign-in + // (nx-ims was already empty when this page loaded) means the user just + // signed out. Land them on home rather than the page they signed out + // from, which would just show the session-expired dialog. + // A failed sign-in attempt (wasSignedIn=true, no token) falls through + // to loadArea so the modal can offer the user a retry. + if (!localStorage.getItem('nx-ims') && !wasSignedIn) { window.location.replace('/'); return; } From 0b376de484f010c1757b7c95d19dc9ae746901b8 Mon Sep 17 00:00:00 2001 From: Chris Peyer Date: Fri, 15 May 2026 09:09:10 -0400 Subject: [PATCH 06/18] Two fixes to the sign-in flow 1. Add type=\"button\" to the modal's Sign in button. Inside a native , a + `; diff --git a/scripts/scripts.js b/scripts/scripts.js index 5897213d..13544b7c 100644 --- a/scripts/scripts.js +++ b/scripts/scripts.js @@ -63,25 +63,22 @@ export default async function loadPage() { document.body.classList.remove('light-scheme', 'dark-scheme'); document.body.classList.add('light-scheme'); } - // Snapshot the auth flag BEFORE imsReady touches it. handleSignIn sets it - // and handleSignOut clears it synchronously before navigating to IMS, so - // this captures the user's intent across the IMS round-trip. - const wasSignedIn = !!localStorage.getItem('nx-ims'); + // Capture the hash BEFORE imsReady processes it. A sign-in callback + // includes access_token=...; a sign-out callback comes back with old_hash + // but no access_token. That distinction is the most reliable signal — no + // reliance on which nx version manages the nx-ims flag. + const { hash } = window.location; + const hadAccessToken = hash.includes('access_token='); + const isImsCallback = hadAccessToken || hash.includes('old_hash='); + const imsReady = initIms(); await setConfig(CONFIG); - // Only block on IMS for OAuth-callback loads - const { hash } = window.location; - const isImsCallback = hash.includes('access_token=') || hash.includes('old_hash='); if (isImsCallback) { await imsReady; - // No token after the round-trip AND we weren't expecting a sign-in - // (nx-ims was already empty when this page loaded) means the user just - // signed out. Land them on home rather than the page they signed out - // from, which would just show the session-expired dialog. - // A failed sign-in attempt (wasSignedIn=true, no token) falls through - // to loadArea so the modal can offer the user a retry. - if (!localStorage.getItem('nx-ims') && !wasSignedIn) { + if (!hadAccessToken) { + // Sign-out callback — land on home rather than the page they signed + // out from, which would just show the session-expired dialog. window.location.replace('/'); return; } From ce6f45201939516cbf88e96318fe51dd23b23da0 Mon Sep 17 00:00:00 2001 From: Chris Peyer Date: Fri, 15 May 2026 09:21:45 -0400 Subject: [PATCH 07/18] Detect cross-tab sign-out by re-checking adobeIMS state on storage events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The earlier per-feature storage listeners keyed off the nx-ims flag, but nx2's handleSignOut doesn't touch nx-ims — only imslib's internal session keys change. After imsReady, watch every storage event and re-evaluate adobeIMS.getAccessToken(): if we just lost the token, show the modal; if we just gained one, reload to pick up the fresh session. Co-Authored-By: Claude Sonnet 4.6 --- scripts/scripts.js | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/scripts/scripts.js b/scripts/scripts.js index 13544b7c..fef561e6 100644 --- a/scripts/scripts.js +++ b/scripts/scripts.js @@ -84,6 +84,26 @@ export default async function loadPage() { } } + // Cross-tab auth monitor: imslib persists its session in localStorage, so + // when another tab signs in/out, storage events fire for imslib's keys (not + // necessarily nx-ims — nx2's handleSignOut leaves that flag alone). Re-check + // the live auth state on every storage change so we react regardless of + // which keys flipped. + imsReady.then(() => { + let wasAuthed = !!window.adobeIMS?.getAccessToken(); + window.addEventListener('storage', async () => { + const isAuthed = !!window.adobeIMS?.getAccessToken(); + if (wasAuthed && !isAuthed) { + const { showAuthBanner } = await import('../blocks/shared/da-auth-banner/da-auth-banner.js'); + showAuthBanner(); + } else if (!wasAuthed && isAuthed) { + // Another tab signed back in — reload to pick up the fresh session. + window.location.reload(); + } + wasAuthed = isAuthed; + }); + }); + await loadArea(); } From 62ef9867d710595c9f8435f0210b2cc54fd626f4 Mon Sep 17 00:00:00 2001 From: Chris Peyer Date: Fri, 15 May 2026 09:30:18 -0400 Subject: [PATCH 08/18] =?UTF-8?q?Drop=20duplicate=20auth=20listeners=20?= =?UTF-8?q?=E2=80=94=20global=20monitor=20covers=20them?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three storage-event listeners were doing overlapping work: one in createConnection (nx-ims), one in the modal (nx-ims), and one global monitor in scripts.js (live adobeIMS state). The global monitor works under both nx and nx2 and supersedes the other two: - createConnection's listener and its disconnect monkey-patch are gone. The proactive provider.disconnect() it ran on sign-out only saved the brief window before the modal blocks the user anyway, and its sign-in reconnect was already dead code (the global monitor reloads first). - The modal's _onStorage / _reload / _goHome (and the dead _dismiss helper + hideAuthBanner export) are gone. The global monitor handles the same transitions. - The 4401 bail's signed-in check switched from localStorage.nx-ims (managed only by legacy nx) to lastSentToken, which is in scope and reliable under both nx and nx2. Net: ~234 lines removed from the working tree. Co-Authored-By: Claude Sonnet 4.6 --- blocks/edit/prose/index.js | 41 +------- .../shared/da-auth-banner/da-auth-banner.js | 36 ------- test/unit/blocks/edit/prose/index.test.js | 93 ------------------- .../unit/blocks/shared/da-auth-banner.test.js | 76 ++------------- 4 files changed, 12 insertions(+), 234 deletions(-) diff --git a/blocks/edit/prose/index.js b/blocks/edit/prose/index.js index d90461ce..c406c993 100644 --- a/blocks/edit/prose/index.js +++ b/blocks/edit/prose/index.js @@ -68,12 +68,12 @@ export async function createConnection(path) { try { await window.adobeIMS?.refreshToken?.(); } catch { /* ignore */ } const fresh = await getAuthToken(); if (!fresh || fresh === lastSentToken) { - // No new token to try — retrying would loop on the same 4401 forever. + // No new token to try — retrying would loop on the same 4401. Stop + // the reconnect loop, and surface the modal if the user had a token + // when collab started (i.e. they expected to be signed in). The + // cross-tab monitor in scripts.js handles cross-tab sign-in/out. provider.shouldConnect = false; - // If the user expected to be signed in, surface an in-page banner instead - // of navigating away — preserves the editor state and lets the user sign - // back in (or wait for a cross-tab sign-in) without losing context. - if (localStorage.getItem('nx-ims')) { + if (lastSentToken) { try { const { showAuthBanner } = await import('../../shared/da-auth-banner/da-auth-banner.js'); showAuthBanner(); @@ -90,37 +90,6 @@ export async function createConnection(path) { lastSentToken = fresh; }); - // Cross-tab sign-in/out propagation. The storage event fires only in OTHER - // tabs, so this lets a disconnected tab recover when the user signs back in - // elsewhere — and proactively drops the connection when another tab signs out. - const handleAuthStorage = async (event) => { - if (event.key !== 'nx-ims') return; - if (event.newValue && !event.oldValue) { - try { await window.adobeIMS?.refreshToken?.(); } catch { /* ignore */ } - const fresh = await getAuthToken(); - if (fresh) { - provider.protocols = ['yjs', fresh]; - lastSentToken = fresh; - } - provider.connect(); - } else if (!event.newValue && event.oldValue) { - provider.disconnect(); - } - }; - window.addEventListener('storage', handleAuthStorage); - - // Detach the storage listener when callers tear down the provider for - // navigation (edit.js / da-content.js pass { data: 'Client navigation' }). - // The focus/blur 10-minute disconnect passes no data hint, so the listener - // survives idle disconnects and can still recover on cross-tab sign-in. - const origDisconnect = provider.disconnect.bind(provider); - provider.disconnect = (disconnectOpts) => { - if (disconnectOpts?.data === 'Client navigation') { - window.removeEventListener('storage', handleAuthStorage); - } - return origDisconnect(); - }; - return { wsProvider: provider, ydoc }; } diff --git a/blocks/shared/da-auth-banner/da-auth-banner.js b/blocks/shared/da-auth-banner/da-auth-banner.js index cbd44c69..ff54d81e 100644 --- a/blocks/shared/da-auth-banner/da-auth-banner.js +++ b/blocks/shared/da-auth-banner/da-auth-banner.js @@ -10,13 +10,10 @@ export class DaAuthBanner extends LitElement { connectedCallback() { super.connectedCallback(); this.shadowRoot.adoptedStyleSheets = [STYLE]; - this._onStorage = this._onStorage.bind(this); - window.addEventListener('storage', this._onStorage); } disconnectedCallback() { super.disconnectedCallback(); - window.removeEventListener('storage', this._onStorage); if (mountedInstance === this) mountedInstance = null; } @@ -25,40 +22,12 @@ export class DaAuthBanner extends LitElement { try { this.shadowRoot.querySelector('dialog')?.showModal(); } catch { /* detached */ } } - _onStorage(event) { - if (event.key !== 'nx-ims') return; - if (event.newValue && !event.oldValue) { - // Another tab signed in. imslib state in this tab may not pick up the - // new session in place reliably, so reload — that re-runs init and - // restores everything cleanly. - this._reload(); - } else if (!event.newValue && event.oldValue) { - // Another tab signed out — the global handler in scripts.js will - // navigate home; this is just a defensive secondary path. - this._goHome(); - } - } - - // Indirected for testability. - // eslint-disable-next-line class-methods-use-this - _reload() { window.location.reload(); } - - // eslint-disable-next-line class-methods-use-this - _goHome() { window.location = '/'; } - async _signIn() { const { loadIms, handleSignIn } = await import(`${getNx()}/utils/ims.js`); await loadIms(); handleSignIn(); } - _dismiss() { - const dlg = this.shadowRoot?.querySelector('dialog'); - if (dlg?.open) dlg.close(); - if (mountedInstance === this) mountedInstance = null; - this.remove(); - } - render() { return html` { if (savedIMS === undefined) delete window.adobeIMS; else window.adobeIMS = savedIMS; } }); - - it('Storage event for nx-ims sign-in elsewhere refreshes token and reconnects', async () => { - window.localStorage.removeItem('nx-ims'); - const savedIMS = window.adobeIMS; - let refreshCalls = 0; - window.adobeIMS = { - // During createConnection, nx-ims is unset so getAuthToken short-circuits - // and never calls getAccessToken. Only the storage handler reaches it. - getAccessToken: () => ({ token: 'T-after-signin' }), - refreshToken: async () => { refreshCalls += 1; }, - }; - - try { - const { wsProvider, ydoc } = await createConnection('https://admin.da.live/source/org/repo/page.html'); - expect(wsProvider.protocols).to.deep.equal(['yjs']); - - window.localStorage.setItem('nx-ims', 'true'); - window.dispatchEvent(new StorageEvent('storage', { - key: 'nx-ims', - newValue: 'true', - oldValue: null, - })); - await new Promise((r) => { setTimeout(r, 50); }); - - expect(refreshCalls).to.equal(1); - expect(wsProvider.protocols).to.deep.equal(['yjs', 'T-after-signin']); - expect(wsProvider.shouldConnect).to.equal(true); - - wsProvider.disconnect({ data: 'Client navigation' }); - wsProvider.destroy?.(); - ydoc.destroy(); - } finally { - if (savedIMS === undefined) delete window.adobeIMS; else window.adobeIMS = savedIMS; - } - }); - - it('Storage event for nx-ims sign-out elsewhere disconnects', async () => { - window.localStorage.setItem('nx-ims', 'true'); - const savedIMS = window.adobeIMS; - window.adobeIMS = { - getAccessToken: () => ({ token: 'T-initial' }), - refreshToken: async () => {}, - }; - - try { - const { wsProvider, ydoc } = await createConnection('https://admin.da.live/source/org/repo/page.html'); - expect(wsProvider.shouldConnect).to.equal(true); - - window.dispatchEvent(new StorageEvent('storage', { - key: 'nx-ims', - newValue: null, - oldValue: 'true', - })); - await new Promise((r) => { setTimeout(r, 0); }); - - expect(wsProvider.shouldConnect).to.equal(false); - - wsProvider.disconnect({ data: 'Client navigation' }); - wsProvider.destroy?.(); - ydoc.destroy(); - } finally { - if (savedIMS === undefined) delete window.adobeIMS; else window.adobeIMS = savedIMS; - } - }); - - it('Navigation disconnect detaches the storage listener', async () => { - window.localStorage.removeItem('nx-ims'); - const savedIMS = window.adobeIMS; - let refreshCalls = 0; - window.adobeIMS = { - getAccessToken: () => ({ token: 'T-x' }), - refreshToken: async () => { refreshCalls += 1; }, - }; - - try { - const { wsProvider, ydoc } = await createConnection('https://admin.da.live/source/org/repo/page.html'); - wsProvider.disconnect({ data: 'Client navigation' }); - - window.dispatchEvent(new StorageEvent('storage', { - key: 'nx-ims', - newValue: 'true', - oldValue: null, - })); - await new Promise((r) => { setTimeout(r, 20); }); - - expect(refreshCalls).to.equal(0); - - wsProvider.destroy?.(); - ydoc.destroy(); - } finally { - if (savedIMS === undefined) delete window.adobeIMS; else window.adobeIMS = savedIMS; - } - }); }); describe('prose/index createAwarenessStatusWidget', () => { diff --git a/test/unit/blocks/shared/da-auth-banner.test.js b/test/unit/blocks/shared/da-auth-banner.test.js index 680c7e46..70f098e3 100644 --- a/test/unit/blocks/shared/da-auth-banner.test.js +++ b/test/unit/blocks/shared/da-auth-banner.test.js @@ -3,26 +3,19 @@ import { setNx } from '../../../../scripts/utils.js'; setNx('/test/fixtures/nx', { hostname: 'example.com' }); -const { showAuthBanner, hideAuthBanner } = await import('../../../../blocks/shared/da-auth-banner/da-auth-banner.js'); +const { showAuthBanner } = await import('../../../../blocks/shared/da-auth-banner/da-auth-banner.js'); const wait = (ms) => new Promise((r) => { setTimeout(r, ms); }); describe('da-auth-banner', () => { - let savedNxIms; let savedIMS; beforeEach(() => { - savedNxIms = window.localStorage.getItem('nx-ims'); savedIMS = window.adobeIMS; document.querySelectorAll('da-auth-banner').forEach((el) => el.remove()); }); afterEach(() => { - if (savedNxIms) { - window.localStorage.setItem('nx-ims', savedNxIms); - } else { - window.localStorage.removeItem('nx-ims'); - } if (savedIMS === undefined) delete window.adobeIMS; else window.adobeIMS = savedIMS; document.querySelectorAll('da-auth-banner').forEach((el) => el.remove()); }); @@ -34,10 +27,12 @@ describe('da-auth-banner', () => { expect(document.querySelectorAll('da-auth-banner').length).to.equal(1); }); - it('hideAuthBanner removes the banner', () => { - showAuthBanner(); - hideAuthBanner(); - expect(document.querySelector('da-auth-banner')).to.not.exist; + it('Renders as a modal that blocks the page', async () => { + const banner = showAuthBanner(); + await banner.updateComplete; + const dlg = banner.shadowRoot.querySelector('dialog'); + expect(dlg).to.exist; + expect(dlg.open).to.equal(true); }); it('Sign-in button calls handleSignIn which invokes adobeIMS.signIn', async () => { @@ -50,61 +45,4 @@ describe('da-auth-banner', () => { await wait(50); expect(signInCalls).to.equal(1); }); - - it('Reloads on cross-tab sign-in (nx-ims storage event)', async () => { - const banner = showAuthBanner(); - let reloadCalls = 0; - banner._reload = () => { reloadCalls += 1; }; - - window.dispatchEvent(new StorageEvent('storage', { - key: 'nx-ims', - newValue: 'true', - oldValue: null, - })); - await wait(20); - - expect(reloadCalls).to.equal(1); - }); - - it('Navigates home on cross-tab sign-out (nx-ims removed)', async () => { - const banner = showAuthBanner(); - let goHomeCalls = 0; - banner._goHome = () => { goHomeCalls += 1; }; - - window.dispatchEvent(new StorageEvent('storage', { - key: 'nx-ims', - newValue: null, - oldValue: 'true', - })); - await wait(20); - - expect(goHomeCalls).to.equal(1); - }); - - it('Renders as a modal that blocks the page', async () => { - const banner = showAuthBanner(); - await banner.updateComplete; - const dlg = banner.shadowRoot.querySelector('dialog'); - expect(dlg).to.exist; - expect(dlg.open).to.equal(true); - }); - - it('Ignores storage events for other keys', async () => { - const banner = showAuthBanner(); - let reloadCalls = 0; - let goHomeCalls = 0; - banner._reload = () => { reloadCalls += 1; }; - banner._goHome = () => { goHomeCalls += 1; }; - - window.dispatchEvent(new StorageEvent('storage', { - key: 'unrelated', - newValue: 'foo', - oldValue: null, - })); - await wait(20); - - expect(reloadCalls).to.equal(0); - expect(goHomeCalls).to.equal(0); - expect(document.querySelector('da-auth-banner')).to.exist; - }); }); From 50033ae02775571934e384ab9894e4de60fa189a Mon Sep 17 00:00:00 2001 From: Chris Peyer Date: Fri, 15 May 2026 09:40:34 -0400 Subject: [PATCH 09/18] Move auth monitor next to initIms; don't auto-focus modal button Two small refinements: 1. The cross-tab auth monitor moved from scripts.js into utils.js, where it sits next to initIms and getAuthToken. initIms attaches it once on first call as a side effect, so every page that touches auth gets the monitor for free without scripts.js needing to know about it. 2. The session-expired dialog now uses autofocus + tabindex=-1 on the dialog itself, so showModal() lands focus on the dialog rather than on the Sign-in button. Tab still reaches the button as before. Co-Authored-By: Claude Sonnet 4.6 --- .../shared/da-auth-banner/da-auth-banner.js | 2 ++ blocks/shared/utils.js | 23 +++++++++++++++++++ scripts/scripts.js | 20 ---------------- 3 files changed, 25 insertions(+), 20 deletions(-) diff --git a/blocks/shared/da-auth-banner/da-auth-banner.js b/blocks/shared/da-auth-banner/da-auth-banner.js index ff54d81e..6bd8d869 100644 --- a/blocks/shared/da-auth-banner/da-auth-banner.js +++ b/blocks/shared/da-auth-banner/da-auth-banner.js @@ -32,6 +32,8 @@ export class DaAuthBanner extends LitElement { return html` e.preventDefault()}>

Your session has expired

Sign in again to continue.

diff --git a/blocks/shared/utils.js b/blocks/shared/utils.js index d58a5132..479e028a 100644 --- a/blocks/shared/utils.js +++ b/blocks/shared/utils.js @@ -7,6 +7,28 @@ const ETC_ORIGINS = ['https://stage-content.da.live', 'https://helix-snapshot-sc const ALLOWED_TOKEN = [...DA_ORIGINS, ...AEM_ORIGINS, ...ETC_ORIGINS]; let imsDetails; +let authMonitorAttached = false; + +// Watch imslib's session for cross-tab sign-in/out. imslib persists its +// token in localStorage and other tabs' writes fire storage events here; +// re-check the live auth state on every storage change so we react +// regardless of which keys (nx-ims, imslib's own) flipped. +function attachAuthMonitor() { + if (authMonitorAttached) return; + authMonitorAttached = true; + let wasAuthed = !!window.adobeIMS?.getAccessToken(); + window.addEventListener('storage', async () => { + const isAuthed = !!window.adobeIMS?.getAccessToken(); + if (wasAuthed && !isAuthed) { + const { showAuthBanner } = await import('./da-auth-banner/da-auth-banner.js'); + showAuthBanner(); + } else if (!wasAuthed && isAuthed) { + // Another tab signed back in — reload to pick up the fresh session. + window.location.reload(); + } + wasAuthed = isAuthed; + }); +} export async function initIms() { if (imsDetails) return imsDetails; @@ -14,6 +36,7 @@ export async function initIms() { try { imsDetails = await loadIms(); + attachAuthMonitor(); return imsDetails; } catch { return null; diff --git a/scripts/scripts.js b/scripts/scripts.js index fef561e6..13544b7c 100644 --- a/scripts/scripts.js +++ b/scripts/scripts.js @@ -84,26 +84,6 @@ export default async function loadPage() { } } - // Cross-tab auth monitor: imslib persists its session in localStorage, so - // when another tab signs in/out, storage events fire for imslib's keys (not - // necessarily nx-ims — nx2's handleSignOut leaves that flag alone). Re-check - // the live auth state on every storage change so we react regardless of - // which keys flipped. - imsReady.then(() => { - let wasAuthed = !!window.adobeIMS?.getAccessToken(); - window.addEventListener('storage', async () => { - const isAuthed = !!window.adobeIMS?.getAccessToken(); - if (wasAuthed && !isAuthed) { - const { showAuthBanner } = await import('../blocks/shared/da-auth-banner/da-auth-banner.js'); - showAuthBanner(); - } else if (!wasAuthed && isAuthed) { - // Another tab signed back in — reload to pick up the fresh session. - window.location.reload(); - } - wasAuthed = isAuthed; - }); - }); - await loadArea(); } From 1fca19e8367f7d28fd72497d0ad68290be7ec8e4 Mon Sep 17 00:00:00 2001 From: Chris Peyer Date: Fri, 15 May 2026 10:00:35 -0400 Subject: [PATCH 10/18] Cycle the collab WS on cross-tab sign-out so the 4401 path engages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The auth monitor in utils.js detects cross-tab sign-out via storage events and mounts the modal, but never touches the WS — and the server only checks the token at WebSocket handshake, not on the established connection. So the prose/index.js connection-close handler's 4401 path never fires from cross-tab sign-out. Dispatch a da-auth-lost custom event from the auth monitor; the createConnection wires a one-line listener that disconnects + reconnects the provider, forcing a fresh handshake that hits the now-401 token and runs the 4401 branch (idempotent with the modal already shown). Co-Authored-By: Claude Sonnet 4.6 --- blocks/edit/prose/index.js | 8 ++++++++ blocks/shared/utils.js | 3 +++ 2 files changed, 11 insertions(+) diff --git a/blocks/edit/prose/index.js b/blocks/edit/prose/index.js index c406c993..1f85a585 100644 --- a/blocks/edit/prose/index.js +++ b/blocks/edit/prose/index.js @@ -90,6 +90,14 @@ export async function createConnection(path) { lastSentToken = fresh; }); + // Cross-tab sign-out: the auth monitor in utils.js has already shown the + // modal; cycle our WS so the server's 4401 handshake response drives the + // connection-close handler above (idempotent with the modal). + window.addEventListener('da-auth-lost', () => { + provider.disconnect(); + provider.connect(); + }); + return { wsProvider: provider, ydoc }; } diff --git a/blocks/shared/utils.js b/blocks/shared/utils.js index 479e028a..f115e688 100644 --- a/blocks/shared/utils.js +++ b/blocks/shared/utils.js @@ -22,6 +22,9 @@ function attachAuthMonitor() { if (wasAuthed && !isAuthed) { const { showAuthBanner } = await import('./da-auth-banner/da-auth-banner.js'); showAuthBanner(); + // Notify any open collab WS to cycle so the server's 4401 handshake + // response can run the connection-close handler in prose/index.js. + window.dispatchEvent(new CustomEvent('da-auth-lost')); } else if (!wasAuthed && isAuthed) { // Another tab signed back in — reload to pick up the fresh session. window.location.reload(); From 0de8ce772b41cd9aaa4a4559d0ac37936b749a5d Mon Sep 17 00:00:00 2001 From: Chris Peyer Date: Fri, 15 May 2026 10:06:00 -0400 Subject: [PATCH 11/18] Drop the da-auth-lost WS-cycle path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The auth monitor in utils.js already covers cross-tab sign-out by mounting the modal, and the prose 4401 handler fires naturally for the scenarios it's designed for (network blip with revoked token, 10-min blur+focus reconnect, initial connect with bad token). Forcing a WS disconnect+reconnect on every cross-tab sign-out just to re-mount the already-mounted modal added complexity without product value — the server-side 4401 can be verified directly via DevTools network toggle. Co-Authored-By: Claude Sonnet 4.6 --- blocks/edit/prose/index.js | 8 -------- blocks/shared/utils.js | 3 --- 2 files changed, 11 deletions(-) diff --git a/blocks/edit/prose/index.js b/blocks/edit/prose/index.js index 1f85a585..c406c993 100644 --- a/blocks/edit/prose/index.js +++ b/blocks/edit/prose/index.js @@ -90,14 +90,6 @@ export async function createConnection(path) { lastSentToken = fresh; }); - // Cross-tab sign-out: the auth monitor in utils.js has already shown the - // modal; cycle our WS so the server's 4401 handshake response drives the - // connection-close handler above (idempotent with the modal). - window.addEventListener('da-auth-lost', () => { - provider.disconnect(); - provider.connect(); - }); - return { wsProvider: provider, ydoc }; } diff --git a/blocks/shared/utils.js b/blocks/shared/utils.js index f115e688..479e028a 100644 --- a/blocks/shared/utils.js +++ b/blocks/shared/utils.js @@ -22,9 +22,6 @@ function attachAuthMonitor() { if (wasAuthed && !isAuthed) { const { showAuthBanner } = await import('./da-auth-banner/da-auth-banner.js'); showAuthBanner(); - // Notify any open collab WS to cycle so the server's 4401 handshake - // response can run the connection-close handler in prose/index.js. - window.dispatchEvent(new CustomEvent('da-auth-lost')); } else if (!wasAuthed && isAuthed) { // Another tab signed back in — reload to pick up the fresh session. window.location.reload(); From f3ffa48cfe0ccf3f89617b2b8ea3e70f189bef63 Mon Sep 17 00:00:00 2001 From: Chris Peyer Date: Fri, 15 May 2026 11:03:04 -0400 Subject: [PATCH 12/18] Drop the collab WS on cross-tab sign-out The modal blocks user input, but the underlying WebSocket stays open and da-collab still uses the cached webSocket.auth (captured at handshake) when writing to da-admin. Other users' edits flowing into the ydoc would be persisted under an auth list that includes the signed-out user's stale bearer. Disconnecting the active provider when the auth monitor detects sign-out closes that window without re-introducing the custom-event or per-component listener layer. Co-Authored-By: Claude Sonnet 4.6 --- blocks/shared/utils.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/blocks/shared/utils.js b/blocks/shared/utils.js index 479e028a..fc24a922 100644 --- a/blocks/shared/utils.js +++ b/blocks/shared/utils.js @@ -22,6 +22,9 @@ function attachAuthMonitor() { if (wasAuthed && !isAuthed) { const { showAuthBanner } = await import('./da-auth-banner/da-auth-banner.js'); showAuthBanner(); + // Drop any open collab WS so the stale auth cached on the server side + // can't keep authorizing persistence after the user signed out elsewhere. + document.querySelector('da-content')?.wsProvider?.disconnect(); } else if (!wasAuthed && isAuthed) { // Another tab signed back in — reload to pick up the fresh session. window.location.reload(); From e3fa06361b822932c7548e5ff5614228fbbba181 Mon Sep 17 00:00:00 2001 From: Chris Peyer Date: Fri, 15 May 2026 13:03:20 -0400 Subject: [PATCH 13/18] clean up comments Co-Authored-By: Claude Sonnet 4.6 --- blocks/edit/prose/index.js | 10 +++------- scripts/scripts.js | 8 ++------ 2 files changed, 5 insertions(+), 13 deletions(-) diff --git a/blocks/edit/prose/index.js b/blocks/edit/prose/index.js index c406c993..ebe8cf4e 100644 --- a/blocks/edit/prose/index.js +++ b/blocks/edit/prose/index.js @@ -54,11 +54,9 @@ export async function createConnection(path) { // (exponential backoff starting with 100ms) and then every 30s. provider.maxBackoffTime = 30000; - // y-websocket re-reads provider.protocols on each reconnect, so swapping in a - // fresh IMS token here is enough; no provider rebuild needed. - // Server close codes: 4401 = token expired (refresh + retry), 4403 = forbidden (stop). let lastSentToken = token || null; provider.on('connection-close', async (event) => { + // Server close codes: 4401 = token expired (refresh + retry), 4403 = forbidden (stop). if (event?.code === 4403) { provider.shouldConnect = false; return; @@ -69,15 +67,13 @@ export async function createConnection(path) { const fresh = await getAuthToken(); if (!fresh || fresh === lastSentToken) { // No new token to try — retrying would loop on the same 4401. Stop - // the reconnect loop, and surface the modal if the user had a token - // when collab started (i.e. they expected to be signed in). The - // cross-tab monitor in scripts.js handles cross-tab sign-in/out. + // the reconnect loop, and surface the modal if the user was signed in. provider.shouldConnect = false; if (lastSentToken) { try { const { showAuthBanner } = await import('../../shared/da-auth-banner/da-auth-banner.js'); showAuthBanner(); - } catch { /* nothing to do */ } + } catch { /* ignore */ } } return; } diff --git a/scripts/scripts.js b/scripts/scripts.js index 13544b7c..88af3df2 100644 --- a/scripts/scripts.js +++ b/scripts/scripts.js @@ -63,10 +63,7 @@ export default async function loadPage() { document.body.classList.remove('light-scheme', 'dark-scheme'); document.body.classList.add('light-scheme'); } - // Capture the hash BEFORE imsReady processes it. A sign-in callback - // includes access_token=...; a sign-out callback comes back with old_hash - // but no access_token. That distinction is the most reliable signal — no - // reliance on which nx version manages the nx-ims flag. + const { hash } = window.location; const hadAccessToken = hash.includes('access_token='); const isImsCallback = hadAccessToken || hash.includes('old_hash='); @@ -77,8 +74,7 @@ export default async function loadPage() { if (isImsCallback) { await imsReady; if (!hadAccessToken) { - // Sign-out callback — land on home rather than the page they signed - // out from, which would just show the session-expired dialog. + // User explicitly signed out — redirect to home window.location.replace('/'); return; } From a46f78736eec496e325ad56e9e3220ee74190d0a Mon Sep 17 00:00:00 2001 From: Chris Peyer Date: Fri, 15 May 2026 15:20:04 -0400 Subject: [PATCH 14/18] Break the nx-ims leak chain in test cleanups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "save and restore" afterEach pattern propagated any pre-existing leak — if nx-ims was set when the describe started, restoring it on the way out re-leaked it to subsequent test files whose getAuthToken then tripped initIms()'s dynamic import with an unset getNx. Always remove nx-ims in afterEach of the describe blocks I added or touched (utils.test.js getAuthToken/daFetch, prose/index.test.js createConnection, da-auth-banner.test.js). The flake reproduced in ~10% of full-suite runs; now stable across 15 consecutive runs. Co-Authored-By: Claude Sonnet 4.6 --- test/unit/blocks/edit/prose/index.test.js | 10 +++------- .../unit/blocks/shared/da-auth-banner.test.js | 4 ++++ test/unit/blocks/shared/utils.test.js | 20 ++++++------------- 3 files changed, 13 insertions(+), 21 deletions(-) diff --git a/test/unit/blocks/edit/prose/index.test.js b/test/unit/blocks/edit/prose/index.test.js index 7eaf9043..ef2dc844 100644 --- a/test/unit/blocks/edit/prose/index.test.js +++ b/test/unit/blocks/edit/prose/index.test.js @@ -68,17 +68,13 @@ function buildFakeWsProvider({ withSynced = false } = {}) { } describe('prose/index createConnection', () => { - let savedNxIms; beforeEach(() => { - savedNxIms = window.localStorage.getItem('nx-ims'); window.localStorage.removeItem('nx-ims'); }); afterEach(() => { - if (savedNxIms) { - window.localStorage.setItem('nx-ims', savedNxIms); - } else { - window.localStorage.removeItem('nx-ims'); - } + // Always remove rather than restoring a prior value — if a leak entered + // this block, restoring it would propagate the leak to later test files. + window.localStorage.removeItem('nx-ims'); document.querySelectorAll('da-auth-banner').forEach((el) => el.remove()); }); diff --git a/test/unit/blocks/shared/da-auth-banner.test.js b/test/unit/blocks/shared/da-auth-banner.test.js index 70f098e3..046ba107 100644 --- a/test/unit/blocks/shared/da-auth-banner.test.js +++ b/test/unit/blocks/shared/da-auth-banner.test.js @@ -12,11 +12,15 @@ describe('da-auth-banner', () => { beforeEach(() => { savedIMS = window.adobeIMS; + window.localStorage.removeItem('nx-ims'); document.querySelectorAll('da-auth-banner').forEach((el) => el.remove()); }); afterEach(() => { if (savedIMS === undefined) delete window.adobeIMS; else window.adobeIMS = savedIMS; + // The Sign-in button calls handleSignIn which sets nx-ims; always remove + // it so the leaked flag doesn't trip later tests that don't configure setNx. + window.localStorage.removeItem('nx-ims'); document.querySelectorAll('da-auth-banner').forEach((el) => el.remove()); }); diff --git a/test/unit/blocks/shared/utils.test.js b/test/unit/blocks/shared/utils.test.js index 4394c454..cd1e69ea 100644 --- a/test/unit/blocks/shared/utils.test.js +++ b/test/unit/blocks/shared/utils.test.js @@ -186,20 +186,17 @@ describe('sanitizeName', () => { }); describe('getAuthToken', () => { - let savedLocalStorage; let savedAdobeIMS; beforeEach(() => { - savedLocalStorage = window.localStorage.getItem('nx-ims'); savedAdobeIMS = window.adobeIMS; + window.localStorage.removeItem('nx-ims'); }); afterEach(() => { - if (savedLocalStorage) { - window.localStorage.setItem('nx-ims', savedLocalStorage); - } else { - window.localStorage.removeItem('nx-ims'); - } + // Always remove rather than restoring — preserving a leaked value would + // propagate it to later tests/files whose getAuthToken can't survive it. + window.localStorage.removeItem('nx-ims'); if (savedAdobeIMS === undefined) { delete window.adobeIMS; } else { @@ -238,20 +235,15 @@ describe('getAuthToken', () => { describe('daFetch', () => { let savedFetch; - let savedLocalStorage; beforeEach(() => { savedFetch = window.fetch; - savedLocalStorage = window.localStorage.getItem('nx-ims'); + window.localStorage.removeItem('nx-ims'); }); afterEach(() => { window.fetch = savedFetch; - if (savedLocalStorage) { - window.localStorage.setItem('nx-ims', savedLocalStorage); - } else { - window.localStorage.removeItem('nx-ims'); - } + window.localStorage.removeItem('nx-ims'); }); it('Defaults headers when none are provided', async () => { From ec40bd873f45adb507dd75c97e9944efc03f0495 Mon Sep 17 00:00:00 2001 From: Chris Peyer Date: Fri, 15 May 2026 16:34:19 -0400 Subject: [PATCH 15/18] Use da-dialog for the auth modal; harden initIms Replace the custom Lit element + CSS with a thin factory that creates a da-dialog (matching da-not-found's pattern): title, body paragraph, action button. Removes ~60 lines of duplicated modal scaffolding and the standalone CSS file. Also wrap initIms's dynamic import inside its try/catch so a stray nx-ims leak under a test session that hasn't called setNx no longer throws "undefined/utils/ims.js". This was the last remaining flake in the full-suite test runs (reproduced ~1-in-12 before, 0-in-30 after). Co-Authored-By: Claude Sonnet 4.6 --- .../shared/da-auth-banner/da-auth-banner.css | 56 -------------- .../shared/da-auth-banner/da-auth-banner.js | 74 +++++++------------ blocks/shared/utils.js | 3 +- test/unit/blocks/edit/prose/index.test.js | 8 +- .../unit/blocks/shared/da-auth-banner.test.js | 17 ++--- test/unit/blocks/shared/utils.test.js | 4 +- 6 files changed, 43 insertions(+), 119 deletions(-) delete mode 100644 blocks/shared/da-auth-banner/da-auth-banner.css diff --git a/blocks/shared/da-auth-banner/da-auth-banner.css b/blocks/shared/da-auth-banner/da-auth-banner.css deleted file mode 100644 index 51d0b4cc..00000000 --- a/blocks/shared/da-auth-banner/da-auth-banner.css +++ /dev/null @@ -1,56 +0,0 @@ -:host { - display: contents; -} - -dialog { - border: none; - border-radius: 8px; - padding: 24px 28px; - max-width: 420px; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2); - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; - color: #222; -} - -dialog::backdrop { - background: rgba(0, 0, 0, 0.4); - backdrop-filter: blur(2px); -} - -.da-auth-title { - margin: 0 0 8px; - font-size: 18px; - font-weight: 700; -} - -.da-auth-msg { - margin: 0 0 20px; - font-size: 14px; - color: #555; -} - -.da-auth-actions { - display: flex; - justify-content: flex-end; -} - -.da-auth-action { - background: #1473e6; - color: #fff; - border: none; - border-radius: 4px; - padding: 8px 20px; - font-size: 14px; - font-weight: 600; - cursor: pointer; -} - -.da-auth-action:hover { - background: #1064c0; -} - -.da-auth-action:focus-visible { - outline: 2px solid #fff; - outline-offset: -4px; - box-shadow: 0 0 0 2px #1473e6; -} diff --git a/blocks/shared/da-auth-banner/da-auth-banner.js b/blocks/shared/da-auth-banner/da-auth-banner.js index 6bd8d869..beaedfa5 100644 --- a/blocks/shared/da-auth-banner/da-auth-banner.js +++ b/blocks/shared/da-auth-banner/da-auth-banner.js @@ -1,55 +1,37 @@ -import { LitElement, html } from 'da-lit'; import { getNx } from '../../../scripts/utils.js'; - -const { loadStyle } = await import(`${getNx()}/utils/utils.js`); -const STYLE = await loadStyle(import.meta.url); +import '../da-dialog/da-dialog.js'; let mountedInstance = null; -export class DaAuthBanner extends LitElement { - connectedCallback() { - super.connectedCallback(); - this.shadowRoot.adoptedStyleSheets = [STYLE]; - } - - disconnectedCallback() { - super.disconnectedCallback(); - if (mountedInstance === this) mountedInstance = null; - } - - firstUpdated() { - if (!this.isConnected) return; - try { this.shadowRoot.querySelector('dialog')?.showModal(); } catch { /* detached */ } - } - - async _signIn() { - const { loadIms, handleSignIn } = await import(`${getNx()}/utils/ims.js`); - await loadIms(); - handleSignIn(); - } - - render() { - return html` - e.preventDefault()}> -

Your session has expired

-

Sign in again to continue.

-
- -
-
- `; - } +async function triggerSignIn() { + const { loadIms, handleSignIn } = await import(`${getNx()}/utils/ims.js`); + await loadIms(); + handleSignIn(); } -customElements.define('da-auth-banner', DaAuthBanner); - export function showAuthBanner() { if (mountedInstance?.isConnected) return mountedInstance; - mountedInstance = document.createElement('da-auth-banner'); - document.body.appendChild(mountedInstance); - return mountedInstance; + + const dialog = document.createElement('da-dialog'); + dialog.title = 'Your session has expired'; + dialog.classList.add('da-auth-banner'); + + const msg = document.createElement('p'); + msg.textContent = 'Sign in again to continue.'; + dialog.appendChild(msg); + + dialog.action = { + label: 'Sign in', + style: 'accent', + click: triggerSignIn, + }; + + dialog.addEventListener('close', () => { + if (mountedInstance === dialog) mountedInstance = null; + dialog.remove(); + }); + + document.body.appendChild(dialog); + mountedInstance = dialog; + return dialog; } diff --git a/blocks/shared/utils.js b/blocks/shared/utils.js index fc24a922..f540d51f 100644 --- a/blocks/shared/utils.js +++ b/blocks/shared/utils.js @@ -35,9 +35,8 @@ function attachAuthMonitor() { export async function initIms() { if (imsDetails) return imsDetails; - const { loadIms } = await import(`${getNx()}/utils/ims.js`); - try { + const { loadIms } = await import(`${getNx()}/utils/ims.js`); imsDetails = await loadIms(); attachAuthMonitor(); return imsDetails; diff --git a/test/unit/blocks/edit/prose/index.test.js b/test/unit/blocks/edit/prose/index.test.js index ef2dc844..8ad80a88 100644 --- a/test/unit/blocks/edit/prose/index.test.js +++ b/test/unit/blocks/edit/prose/index.test.js @@ -75,7 +75,7 @@ describe('prose/index createConnection', () => { // Always remove rather than restoring a prior value — if a leak entered // this block, restoring it would propagate the leak to later test files. window.localStorage.removeItem('nx-ims'); - document.querySelectorAll('da-auth-banner').forEach((el) => el.remove()); + document.querySelectorAll('da-dialog.da-auth-banner').forEach((el) => el.remove()); }); it('Returns a wsProvider and a Y.Doc with maxBackoffTime configured', async () => { @@ -165,9 +165,9 @@ describe('prose/index createConnection', () => { expect(refreshCalls).to.equal(1); expect(wsProvider.shouldConnect).to.equal(false); - expect(document.querySelector('da-auth-banner')).to.exist; + expect(document.querySelector('da-dialog.da-auth-banner')).to.exist; - document.querySelector('da-auth-banner')?.remove(); + document.querySelector('da-dialog.da-auth-banner')?.remove(); wsProvider.disconnect({ data: 'Client navigation' }); wsProvider.destroy?.(); ydoc.destroy(); @@ -193,7 +193,7 @@ describe('prose/index createConnection', () => { await new Promise((r) => { setTimeout(r, 80); }); expect(wsProvider.shouldConnect).to.equal(false); - expect(document.querySelector('da-auth-banner')).to.not.exist; + expect(document.querySelector('da-dialog.da-auth-banner')).to.not.exist; wsProvider.disconnect({ data: 'Client navigation' }); wsProvider.destroy?.(); diff --git a/test/unit/blocks/shared/da-auth-banner.test.js b/test/unit/blocks/shared/da-auth-banner.test.js index 046ba107..0cc2a000 100644 --- a/test/unit/blocks/shared/da-auth-banner.test.js +++ b/test/unit/blocks/shared/da-auth-banner.test.js @@ -13,7 +13,7 @@ describe('da-auth-banner', () => { beforeEach(() => { savedIMS = window.adobeIMS; window.localStorage.removeItem('nx-ims'); - document.querySelectorAll('da-auth-banner').forEach((el) => el.remove()); + document.querySelectorAll('da-dialog.da-auth-banner').forEach((el) => el.remove()); }); afterEach(() => { @@ -21,31 +21,30 @@ describe('da-auth-banner', () => { // The Sign-in button calls handleSignIn which sets nx-ims; always remove // it so the leaked flag doesn't trip later tests that don't configure setNx. window.localStorage.removeItem('nx-ims'); - document.querySelectorAll('da-auth-banner').forEach((el) => el.remove()); + document.querySelectorAll('da-dialog.da-auth-banner').forEach((el) => el.remove()); }); it('showAuthBanner mounts a single banner element', () => { const a = showAuthBanner(); const b = showAuthBanner(); expect(a).to.equal(b); - expect(document.querySelectorAll('da-auth-banner').length).to.equal(1); + expect(document.querySelectorAll('da-dialog.da-auth-banner').length).to.equal(1); }); - it('Renders as a modal that blocks the page', async () => { + it('Mounts a da-dialog with the expected title and action label', async () => { const banner = showAuthBanner(); await banner.updateComplete; - const dlg = banner.shadowRoot.querySelector('dialog'); - expect(dlg).to.exist; - expect(dlg.open).to.equal(true); + expect(banner.title).to.equal('Your session has expired'); + expect(banner.action?.label).to.equal('Sign in'); }); - it('Sign-in button calls handleSignIn which invokes adobeIMS.signIn', async () => { + it('Sign-in action calls handleSignIn which invokes adobeIMS.signIn', async () => { let signInCalls = 0; window.adobeIMS = { signIn: () => { signInCalls += 1; } }; const banner = showAuthBanner(); await banner.updateComplete; - banner.shadowRoot.querySelector('.da-auth-action').click(); + await banner.action.click(); await wait(50); expect(signInCalls).to.equal(1); }); diff --git a/test/unit/blocks/shared/utils.test.js b/test/unit/blocks/shared/utils.test.js index cd1e69ea..16a72b71 100644 --- a/test/unit/blocks/shared/utils.test.js +++ b/test/unit/blocks/shared/utils.test.js @@ -316,9 +316,9 @@ describe('daFetch', () => { await new Promise((r) => { setTimeout(r, 80); }); expect(resp.ok).to.equal(false); - expect(document.querySelector('da-auth-banner')).to.exist; + expect(document.querySelector('da-dialog.da-auth-banner')).to.exist; } finally { - document.querySelector('da-auth-banner')?.remove(); + document.querySelector('da-dialog.da-auth-banner')?.remove(); if (savedIMS === undefined) delete window.adobeIMS; else window.adobeIMS = savedIMS; } }); From d3a45a5862090468c585d7dc4bb216f13fe01e8a Mon Sep 17 00:00:00 2001 From: Chris Peyer Date: Fri, 15 May 2026 16:37:12 -0400 Subject: [PATCH 16/18] Hide da-dialog's close button on the auth modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit da-dialog's close button lives inside its shadow root, so external CSS can't reach it through the encapsulation boundary. Restore a da-auth-banner.css with just the .da-dialog-close-btn { display: none } rule and inject it into the dialog instance's shadow root via adoptedStyleSheets after updateComplete. The auth modal is intentionally blocking — the only escape is to sign in (or be reloaded via the cross-tab auth monitor). Co-Authored-By: Claude Sonnet 4.6 --- blocks/shared/da-auth-banner/da-auth-banner.css | 5 +++++ blocks/shared/da-auth-banner/da-auth-banner.js | 11 +++++++++++ 2 files changed, 16 insertions(+) create mode 100644 blocks/shared/da-auth-banner/da-auth-banner.css diff --git a/blocks/shared/da-auth-banner/da-auth-banner.css b/blocks/shared/da-auth-banner/da-auth-banner.css new file mode 100644 index 00000000..b8831c8a --- /dev/null +++ b/blocks/shared/da-auth-banner/da-auth-banner.css @@ -0,0 +1,5 @@ +/* Hide da-dialog's built-in close button — the auth modal is intentionally + blocking, the only escape is to sign in. */ +.da-dialog-close-btn { + display: none; +} diff --git a/blocks/shared/da-auth-banner/da-auth-banner.js b/blocks/shared/da-auth-banner/da-auth-banner.js index beaedfa5..f2b7694f 100644 --- a/blocks/shared/da-auth-banner/da-auth-banner.js +++ b/blocks/shared/da-auth-banner/da-auth-banner.js @@ -1,6 +1,9 @@ import { getNx } from '../../../scripts/utils.js'; import '../da-dialog/da-dialog.js'; +const { loadStyle } = await import(`${getNx()}/utils/utils.js`); +const STYLE = await loadStyle(import.meta.url); + let mountedInstance = null; async function triggerSignIn() { @@ -33,5 +36,13 @@ export function showAuthBanner() { document.body.appendChild(dialog); mountedInstance = dialog; + + // Inject our stylesheet into the dialog's shadow root so the close button + // CSS reaches inside the encapsulation boundary. + dialog.updateComplete.then(() => { + if (!dialog.shadowRoot) return; + dialog.shadowRoot.adoptedStyleSheets = [...dialog.shadowRoot.adoptedStyleSheets, STYLE]; + }); + return dialog; } From 28435da42c2e385342f9d7251e41824b3a45da4a Mon Sep 17 00:00:00 2001 From: Chris Peyer Date: Fri, 15 May 2026 16:38:57 -0400 Subject: [PATCH 17/18] Inline the close-button-hide rule One CSS declaration is shorter as a CSSStyleSheet literal at module init than as a separate .css file routed through nx's loadStyle. Co-Authored-By: Claude Sonnet 4.6 --- blocks/shared/da-auth-banner/da-auth-banner.css | 5 ----- blocks/shared/da-auth-banner/da-auth-banner.js | 8 +++++--- 2 files changed, 5 insertions(+), 8 deletions(-) delete mode 100644 blocks/shared/da-auth-banner/da-auth-banner.css diff --git a/blocks/shared/da-auth-banner/da-auth-banner.css b/blocks/shared/da-auth-banner/da-auth-banner.css deleted file mode 100644 index b8831c8a..00000000 --- a/blocks/shared/da-auth-banner/da-auth-banner.css +++ /dev/null @@ -1,5 +0,0 @@ -/* Hide da-dialog's built-in close button — the auth modal is intentionally - blocking, the only escape is to sign in. */ -.da-dialog-close-btn { - display: none; -} diff --git a/blocks/shared/da-auth-banner/da-auth-banner.js b/blocks/shared/da-auth-banner/da-auth-banner.js index f2b7694f..df8662e8 100644 --- a/blocks/shared/da-auth-banner/da-auth-banner.js +++ b/blocks/shared/da-auth-banner/da-auth-banner.js @@ -1,8 +1,10 @@ import { getNx } from '../../../scripts/utils.js'; import '../da-dialog/da-dialog.js'; -const { loadStyle } = await import(`${getNx()}/utils/utils.js`); -const STYLE = await loadStyle(import.meta.url); +// Hide da-dialog's close button — the auth modal is intentionally blocking, +// the only escape is to sign in (or cross-tab auth monitor reload). +const HIDE_CLOSE = new CSSStyleSheet(); +HIDE_CLOSE.replaceSync('.da-dialog-close-btn { display: none; }'); let mountedInstance = null; @@ -41,7 +43,7 @@ export function showAuthBanner() { // CSS reaches inside the encapsulation boundary. dialog.updateComplete.then(() => { if (!dialog.shadowRoot) return; - dialog.shadowRoot.adoptedStyleSheets = [...dialog.shadowRoot.adoptedStyleSheets, STYLE]; + dialog.shadowRoot.adoptedStyleSheets = [...dialog.shadowRoot.adoptedStyleSheets, HIDE_CLOSE]; }); return dialog; From fa26ad02e2460c64317f268f5da8172f8cc7116b Mon Sep 17 00:00:00 2001 From: Chris Peyer Date: Fri, 15 May 2026 16:44:13 -0400 Subject: [PATCH 18/18] Add ability to hide da-dialog close button Co-Authored-By: Claude Sonnet 4.6 --- blocks/shared/da-auth-banner/da-auth-banner.js | 13 +------------ blocks/shared/da-dialog/da-dialog.js | 10 ++++++++-- 2 files changed, 9 insertions(+), 14 deletions(-) diff --git a/blocks/shared/da-auth-banner/da-auth-banner.js b/blocks/shared/da-auth-banner/da-auth-banner.js index df8662e8..ec986000 100644 --- a/blocks/shared/da-auth-banner/da-auth-banner.js +++ b/blocks/shared/da-auth-banner/da-auth-banner.js @@ -1,11 +1,6 @@ import { getNx } from '../../../scripts/utils.js'; import '../da-dialog/da-dialog.js'; -// Hide da-dialog's close button — the auth modal is intentionally blocking, -// the only escape is to sign in (or cross-tab auth monitor reload). -const HIDE_CLOSE = new CSSStyleSheet(); -HIDE_CLOSE.replaceSync('.da-dialog-close-btn { display: none; }'); - let mountedInstance = null; async function triggerSignIn() { @@ -20,6 +15,7 @@ export function showAuthBanner() { const dialog = document.createElement('da-dialog'); dialog.title = 'Your session has expired'; dialog.classList.add('da-auth-banner'); + dialog.showCloseButton = false; const msg = document.createElement('p'); msg.textContent = 'Sign in again to continue.'; @@ -39,12 +35,5 @@ export function showAuthBanner() { document.body.appendChild(dialog); mountedInstance = dialog; - // Inject our stylesheet into the dialog's shadow root so the close button - // CSS reaches inside the encapsulation boundary. - dialog.updateComplete.then(() => { - if (!dialog.shadowRoot) return; - dialog.shadowRoot.adoptedStyleSheets = [...dialog.shadowRoot.adoptedStyleSheets, HIDE_CLOSE]; - }); - return dialog; } diff --git a/blocks/shared/da-dialog/da-dialog.js b/blocks/shared/da-dialog/da-dialog.js index 8fb7eb68..e4ab1dba 100644 --- a/blocks/shared/da-dialog/da-dialog.js +++ b/blocks/shared/da-dialog/da-dialog.js @@ -17,9 +17,15 @@ export default class DaDialog extends LitElement { action: { state: true }, emphasis: { type: String }, // quiet size: { type: String }, // 'small', 'medium', 'large', 'auto' + showCloseButton: { type: Boolean, attribute: 'show-close-button' }, _showLazyModal: { state: true }, }; + constructor() { + super(); + this.showCloseButton = true; + } + connectedCallback() { super.connectedCallback(); this.shadowRoot.adoptedStyleSheets = [STYLE]; @@ -65,12 +71,12 @@ export default class DaDialog extends LitElement {

${this.title}

- + ` : nothing}