diff --git a/blocks/edit/prose/index.js b/blocks/edit/prose/index.js
index a2459a60..ebe8cf4e 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';
@@ -55,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,16 +66,14 @@ 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 was signed in.
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 (localStorage.getItem('nx-ims')) {
+ if (lastSentToken) {
try {
- const { handleSignIn } = await import(`${getNx()}/utils/ims.js`);
- handleSignIn();
- } catch { /* nothing to do */ }
+ const { showAuthBanner } = await import('../../shared/da-auth-banner/da-auth-banner.js');
+ showAuthBanner();
+ } catch { /* ignore */ }
}
return;
}
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..ec986000
--- /dev/null
+++ b/blocks/shared/da-auth-banner/da-auth-banner.js
@@ -0,0 +1,39 @@
+import { getNx } from '../../../scripts/utils.js';
+import '../da-dialog/da-dialog.js';
+
+let mountedInstance = null;
+
+async function triggerSignIn() {
+ const { loadIms, handleSignIn } = await import(`${getNx()}/utils/ims.js`);
+ await loadIms();
+ handleSignIn();
+}
+
+export function showAuthBanner() {
+ if (mountedInstance?.isConnected) return mountedInstance;
+
+ 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.';
+ 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/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 {
diff --git a/blocks/shared/utils.js b/blocks/shared/utils.js
index a8a0ea4a..f540d51f 100644
--- a/blocks/shared/utils.js
+++ b/blocks/shared/utils.js
@@ -7,13 +7,38 @@ 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();
+ // 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();
+ }
+ wasAuthed = isAuthed;
+ });
+}
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;
} catch {
return null;
@@ -35,22 +60,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 +94,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/scripts/scripts.js b/scripts/scripts.js
index 4dee529c..88af3df2 100644
--- a/scripts/scripts.js
+++ b/scripts/scripts.js
@@ -63,13 +63,21 @@ export default async function loadPage() {
document.body.classList.remove('light-scheme', 'dark-scheme');
document.body.classList.add('light-scheme');
}
+
+ 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;
- if (hash.includes('access_token=') || hash.includes('old_hash=')) {
+ if (isImsCallback) {
await imsReady;
+ if (!hadAccessToken) {
+ // User explicitly signed out — redirect to home
+ window.location.replace('/');
+ return;
+ }
}
await loadArea();
diff --git a/test/unit/blocks/edit/prose/index.test.js b/test/unit/blocks/edit/prose/index.test.js
index 60bd0329..8ad80a88 100644
--- a/test/unit/blocks/edit/prose/index.test.js
+++ b/test/unit/blocks/edit/prose/index.test.js
@@ -68,17 +68,14 @@ 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-dialog.da-auth-banner').forEach((el) => el.remove());
});
it('Returns a wsProvider and a Y.Doc with maxBackoffTime configured', async () => {
@@ -87,7 +84,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 +113,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 +135,7 @@ describe('prose/index createConnection', () => {
expect(wsProvider.shouldConnect).to.equal(false);
- wsProvider.disconnect();
+ wsProvider.disconnect({ data: 'Client navigation' });
wsProvider.destroy?.();
ydoc.destroy();
} finally {
@@ -146,15 +143,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,14 +160,15 @@ 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-dialog.da-auth-banner')).to.exist;
- wsProvider.disconnect();
+ document.querySelector('da-dialog.da-auth-banner')?.remove();
+ wsProvider.disconnect({ data: 'Client navigation' });
wsProvider.destroy?.();
ydoc.destroy();
} finally {
@@ -179,14 +176,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,12 +190,12 @@ 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-dialog.da-auth-banner')).to.not.exist;
- wsProvider.disconnect();
+ wsProvider.disconnect({ data: 'Client navigation' });
wsProvider.destroy?.();
ydoc.destroy();
} finally {
@@ -225,7 +221,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 +245,7 @@ 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 {
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..0cc2a000
--- /dev/null
+++ b/test/unit/blocks/shared/da-auth-banner.test.js
@@ -0,0 +1,51 @@
+import { expect } from '@esm-bundle/chai';
+import { setNx } from '../../../../scripts/utils.js';
+
+setNx('/test/fixtures/nx', { hostname: 'example.com' });
+
+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 savedIMS;
+
+ beforeEach(() => {
+ savedIMS = window.adobeIMS;
+ window.localStorage.removeItem('nx-ims');
+ document.querySelectorAll('da-dialog.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-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-dialog.da-auth-banner').length).to.equal(1);
+ });
+
+ it('Mounts a da-dialog with the expected title and action label', async () => {
+ const banner = showAuthBanner();
+ await banner.updateComplete;
+ expect(banner.title).to.equal('Your session has expired');
+ expect(banner.action?.label).to.equal('Sign in');
+ });
+
+ 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;
+ 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 f14e5115..16a72b71 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 }] };
@@ -180,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 {
@@ -232,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 () => {
@@ -303,6 +301,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-dialog.da-auth-banner')).to.exist;
+ } finally {
+ document.querySelector('da-dialog.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 }));