diff --git a/src/main/content-api-client.js b/src/main/content-api-client.js index 671dce5..d4c0d26 100644 --- a/src/main/content-api-client.js +++ b/src/main/content-api-client.js @@ -22,6 +22,7 @@ import { API_BACKEND_AEM_API, API_BACKEND_DA_LIVE, buildPostUploadRequest, + DA_UNAUTHORIZED_MESSAGE, isValidApiBackend, normalizeDaPath, } from './content-api-shared.js'; @@ -87,7 +88,7 @@ export class ContentApiClient { } const res = await this.fetch(url, { headers, cache: 'reload' }); // eslint-disable-line no-await-in-loop if (res.status === 401) { - throw new Error('Unauthorized: invalid or expired token'); + throw new Error(DA_UNAUTHORIZED_MESSAGE); } if (!res.ok) { throw await buildHttpError('GET', url, res, `List failed for ${normalized}`); // eslint-disable-line no-await-in-loop @@ -119,7 +120,7 @@ export class ContentApiClient { const url = buildAemApiListUrl(org, repo, normalized); const res = await this.fetch(url, { headers: this.authHeader, cache: 'reload' }); if (res.status === 401) { - throw new Error('Unauthorized: invalid or expired token'); + throw new Error(DA_UNAUTHORIZED_MESSAGE); } if (!res.ok) { throw await buildHttpError('GET', url, res, `List failed for ${normalized}`); @@ -146,7 +147,7 @@ export class ContentApiClient { : buildDaLiveSourceUrl(org, repo, normalized); const res = await this.fetch(url, { headers: this.authHeader, cache: 'reload' }); if (res.status === 401) { - throw new Error('Unauthorized: invalid or expired token'); + throw new Error(DA_UNAUTHORIZED_MESSAGE); } if (res.status === 404) { return null; @@ -175,7 +176,7 @@ export class ContentApiClient { : buildDaLiveSourceUrl(org, repo, normalized); const res = await this.fetch(url, { headers: this.authHeader, cache: 'reload' }); if (res.status === 401) { - throw new Error('Unauthorized: invalid or expired token'); + throw new Error(DA_UNAUTHORIZED_MESSAGE); } if (res.status === 404) { return null; @@ -221,7 +222,7 @@ export class ContentApiClient { body, }); if (putRes.status === 401) { - throw new Error('Unauthorized: invalid or expired token'); + throw new Error(DA_UNAUTHORIZED_MESSAGE); } if (putRes.ok) { return; @@ -234,7 +235,7 @@ export class ContentApiClient { body: post.body, }); if (postRes.status === 401) { - throw new Error('Unauthorized: invalid or expired token'); + throw new Error(DA_UNAUTHORIZED_MESSAGE); } if (postRes.ok) { return; @@ -262,7 +263,7 @@ export class ContentApiClient { headers: this.authHeader, }); if (res.status === 401) { - throw new Error('Unauthorized: invalid or expired token'); + throw new Error(DA_UNAUTHORIZED_MESSAGE); } if (!res.ok && res.status !== 404) { throw await buildHttpError('DELETE', url, res, `Delete failed for ${normalized}`); @@ -284,7 +285,7 @@ export class ContentApiClient { body: JSON.stringify({ paths, forceAsync: true }), }); if (res.status === 401) { - throw new Error('Unauthorized: invalid or expired token'); + throw new Error(DA_UNAUTHORIZED_MESSAGE); } if (!res.ok && res.status !== 202) { throw await buildHttpError('POST', url, res, 'Bulk preview failed'); @@ -307,7 +308,7 @@ export class ContentApiClient { body: JSON.stringify({ paths, forceAsync: true }), }); if (res.status === 401) { - throw new Error('Unauthorized: invalid or expired token'); + throw new Error(DA_UNAUTHORIZED_MESSAGE); } if (!res.ok && res.status !== 202) { throw await buildHttpError('POST', url, res, 'Bulk publish failed'); @@ -327,7 +328,7 @@ export class ContentApiClient { const url = buildAemApiJobUrl(org, repo, topic, jobName); const res = await this.fetch(url, { headers: this.authHeader }); if (res.status === 401) { - throw new Error('Unauthorized: invalid or expired token'); + throw new Error(DA_UNAUTHORIZED_MESSAGE); } if (!res.ok && res.status !== 202) { throw await buildHttpError('GET', url, res, 'Job status failed'); diff --git a/src/main/content-api-shared.js b/src/main/content-api-shared.js index b8bf2dc..0998c66 100644 --- a/src/main/content-api-shared.js +++ b/src/main/content-api-shared.js @@ -13,6 +13,18 @@ export const API_BACKEND_DA_LIVE = 'da.live'; export const API_BACKEND_AEM_API = 'api.aem.live'; +/** Thrown when da.live / api.aem.live rejects the IMS bearer token. */ +export const DA_UNAUTHORIZED_MESSAGE = 'Unauthorized: invalid or expired token'; + +/** + * @param {unknown} err + * @returns {boolean} + */ +export function isDaUnauthorizedError(err) { + const message = err instanceof Error ? err.message : String(err); + return message.includes(DA_UNAUTHORIZED_MESSAGE); +} + /** * @param {string} backend * @returns {boolean} diff --git a/src/main/index.js b/src/main/index.js index 3d67907..716ae87 100644 --- a/src/main/index.js +++ b/src/main/index.js @@ -20,11 +20,15 @@ import { createWindowOptions } from './window-options.js'; import { initAutoUpdater } from './updater.js'; import { screenshotFilename } from './dev-config.js'; import { toDaPath } from './aem-page-url.js'; -import { API_BACKEND_AEM_API, API_BACKEND_DA_LIVE } from './content-api-shared.js'; +import { + API_BACKEND_AEM_API, + API_BACKEND_DA_LIVE, + isDaUnauthorizedError, +} from './content-api-shared.js'; import { ContentApiClient } from './content-api-client.js'; import { HttpRequestError } from './http-request-error.js'; import { - DA_TOKEN_FILENAME, getAuthStatus, getValidToken, logout, + DA_TOKEN_FILENAME, clearStoredToken, getAuthStatus, getValidToken, logout, } from './da-auth.js'; import { isSiteTokenExpired, @@ -230,7 +234,18 @@ async function withContentClient(site, fn) { openBrowser: (url) => shell.openExternal(url), }); const backend = site.apiBackend || API_BACKEND_DA_LIVE; - return fn(new ContentApiClient(accessToken, backend)); + try { + return await fn(new ContentApiClient(accessToken, backend)); + } catch (err) { + if (isDaUnauthorizedError(err)) { + await clearStoredToken(tokenPath()); + contentDaLiveAuth?.clearCache(); + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('da:session-expired'); + } + } + throw err; + } } async function createWindow() { diff --git a/src/preload/index.cjs b/src/preload/index.cjs index d280574..85b3481 100644 --- a/src/preload/index.cjs +++ b/src/preload/index.cjs @@ -29,6 +29,11 @@ contextBridge.exposeInMainWorld('aemDesktop', { getDaAuthStatus: () => ipcRenderer.invoke('da:auth-status'), loginDa: () => ipcRenderer.invoke('da:login'), logoutDa: () => ipcRenderer.invoke('da:logout'), + onDaSessionExpired: (callback) => { + const handler = () => callback(); + ipcRenderer.on('da:session-expired', handler); + return () => ipcRenderer.removeListener('da:session-expired', handler); + }, listDa: (siteId, daPath) => ipcRenderer.invoke('da:list', { siteId, daPath }), getDaSource: (siteId, daPath) => ipcRenderer.invoke('da:get-source', { siteId, daPath }), parseDocumentView: (html) => ipcRenderer.invoke('document:parse', { html }), diff --git a/src/renderer/renderer.js b/src/renderer/renderer.js index 685cdc9..0c45701 100644 --- a/src/renderer/renderer.js +++ b/src/renderer/renderer.js @@ -234,11 +234,27 @@ function hide(element) { element.hidden = true; } +/** Matches {@link DA_UNAUTHORIZED_MESSAGE} in content-api-shared.js. */ +const DA_UNAUTHORIZED_SNIPPET = 'Unauthorized: invalid or expired token'; + +/** + * @param {unknown} err + * @returns {boolean} + */ +function isDaUnauthorizedError(err) { + const message = err instanceof Error ? err.message : String(err); + return message.includes(DA_UNAUTHORIZED_SNIPPET); +} + /** * @param {string} title * @param {{ message?: string, xError?: string|null, detail?: string }|Error|string} error */ async function showRequestErrorDialog(title, error) { + if (isDaUnauthorizedError(error)) { + await refreshAuthStatus(); + return; + } const payload = typeof error === 'string' ? { message: error } : error; @@ -809,6 +825,10 @@ async function loadDocumentViewForPath(daPath, pane) { const model = await window.aemDesktop.parseDocumentView(source.text); renderDocumentView(pane, model); } catch (err) { + if (isDaUnauthorizedError(err)) { + await refreshAuthStatus(); + return; + } setPanePlaceholder(pane, err.message || 'Failed to load document.'); } } @@ -1008,6 +1028,10 @@ async function loadFolder(daPath) { state.tree.cache[daPath] = items; await refreshLocalBadgesForFolder(daPath); } catch (err) { + if (isDaUnauthorizedError(err)) { + await refreshAuthStatus(); + return; + } if (daPath === '/') { state.tree.error = err.message || 'Failed to list folder'; } @@ -2898,6 +2922,9 @@ async function init() { await refreshAuthStatus(); await loadSites(); window.aemDesktop.onPreviewAuthRequired(handlePreviewAuthRequired); + window.aemDesktop.onDaSessionExpired(() => { + refreshAuthStatus(); + }); showView('home'); } diff --git a/test/content-api-shared.test.js b/test/content-api-shared.test.js index 594c5d2..486b1de 100644 --- a/test/content-api-shared.test.js +++ b/test/content-api-shared.test.js @@ -14,6 +14,8 @@ import assert from 'node:assert/strict'; import { API_BACKEND_DA_LIVE, buildPostUploadRequest, + DA_UNAUTHORIZED_MESSAGE, + isDaUnauthorizedError, normalizeDaPath, toApiRelativePath, } from '../src/main/content-api-shared.js'; @@ -34,3 +36,12 @@ test('buildPostUploadRequest sets filename from daPath for da.live', () => { ); assert.ok(body instanceof FormData); }); + +test('isDaUnauthorizedError detects API and IPC-wrapped unauthorized errors', () => { + assert.equal(isDaUnauthorizedError(new Error(DA_UNAUTHORIZED_MESSAGE)), true); + assert.equal( + isDaUnauthorizedError(new Error("Error invoking remote method 'da:list': Error: Unauthorized: invalid or expired token")), + true, + ); + assert.equal(isDaUnauthorizedError(new Error('List failed for /')), false); +});