diff --git a/README.md b/README.md index 4e6f08b1..a3deb270 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,9 @@ so typically a hand-written user interface will be chosen over a generic machine These panes are used in the Data Browser - see mashlib [https://github.com/linkeddata/mashlib](https://github.com/linkeddata/mashlib) +When panes are hosted through mashlib and use solid-logic authentication, ensure the refresh worker is served same-origin. The worker export and runtime override contract are documented in solid-logic: +https://github.com/solidos/solid-logic#worker-asset-and-runtime-configuration + Currently the panes available include: - A default pane which lists the properties of any object diff --git a/dev/loader.ts b/dev/loader.ts index 6636a2f5..fb843e80 100644 --- a/dev/loader.ts +++ b/dev/loader.ts @@ -130,22 +130,22 @@ window.onload = async () => { // registerPanes((cjsOrEsModule: any) => paneRegistry.register(cjsOrEsModule.default || cjsOrEsModule)) const contactsPane = await import('contacts-pane') paneRegistry.register((contactsPane as any).default || contactsPane) - await authSession.handleIncomingRedirect({ - restorePreviousSession: true - }) - const session = await authSession - if (!session.info.isLoggedIn) { + await solidLogicSingleton.authn.checkUser() + const session = authSession + const isLoggedIn = session?.info?.isLoggedIn ?? session?.isActive ?? Boolean(session?.webId) + if (!isLoggedIn) { console.log('The user is not logged in') const loginBanner = document.getElementById('loginBanner'); if (loginBanner) { loginBanner.innerHTML = ''; } } else { - console.log(`Logged in as ${session.info.webId}`) + const loggedWebId = session?.info?.webId || session?.webId + console.log(`Logged in as ${loggedWebId}`) const loginBanner = document.getElementById('loginBanner'); if (loginBanner) { - loginBanner.innerHTML = `Logged in as ${session.info.webId} `; + loginBanner.innerHTML = `Logged in as ${loggedWebId} `; } } addLayoutButtons() @@ -156,8 +156,9 @@ window.logout = () => { window.location.href = '' } window.login = async function () { - const session = await authSession - if (!session.info.isLoggedIn) { + const session = authSession + const isLoggedIn = session?.info?.isLoggedIn ?? session?.isActive ?? Boolean(session?.webId) + if (!isLoggedIn) { const issuer = prompt('Please enter an issuer URI', 'https://solidcommunity.net') if (issuer) { await authSession.login({ diff --git a/jest.config.mjs b/jest.config.mjs index 7d1c1632..5e114e41 100644 --- a/jest.config.mjs +++ b/jest.config.mjs @@ -1,3 +1,12 @@ +import { existsSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) +const localSolidLogicIndex = path.resolve(__dirname, '../solid-logic/src/index.ts') +const useLocalSolidLogic = existsSync(localSolidLogicIndex) + export default { collectCoverage: true, coverageDirectory: 'coverage', @@ -19,7 +28,11 @@ export default { '\\.svg\\?raw$': '/test/__mocks__/fileMock.js', '\\.(svg)$': '/test/__mocks__/fileMock.js', '\\.(png|jpe?g|gif|webp|avif)$': '/test/__mocks__/fileMock.js', - '\\.css$': '/test/__mocks__/styleMock.js' + '\\.css$': '/test/__mocks__/styleMock.js', + ...(useLocalSolidLogic ? { '^solid-logic$': localSolidLogicIndex } : {}), + '^@uvdsl/solid-oidc-client-browser$': '/test/mocks/solid-oidc-client-browser.ts', + '^@uvdsl/solid-oidc-client-browser/core$': '/test/mocks/solid-oidc-client-browser.ts', + '^solid-oidc-client-browser$': '/test/mocks/solid-oidc-client-browser.ts' }, setupFilesAfterEnv: ['./test/helpers/setup.ts'], testMatch: ['**/?(*.)+(spec|test).[tj]s?(x)'], diff --git a/src/mainPage/header.ts b/src/mainPage/header.ts index f6546e7f..b525c268 100644 --- a/src/mainPage/header.ts +++ b/src/mainPage/header.ts @@ -38,6 +38,33 @@ export const HELP_MENU_LIST = [ ] const HEADER_MOBILE_STYLE_ID = 'solid-ui-header-mobile-style' +let authRefreshInFlight: Promise | null = null + +async function ensureAuthUserResolved (): Promise { + if (authn.currentUser()) return + if (authRefreshInFlight) { + await authRefreshInFlight + return + } + + authRefreshInFlight = (async () => { + try { + await authn.checkUser() + // Some auth stacks resolve session state asynchronously after first check. + if (!authn.currentUser()) { + await authn.checkUser() + } + } catch (_err) { + // Keep header rendering resilient when auth refresh fails. + } + })() + + try { + await authRefreshInFlight + } finally { + authRefreshInFlight = null + } +} type ManagedHeader = Header & { __solidPanesListenersAttached?: boolean @@ -139,6 +166,7 @@ function attachHeaderListeners (header: ManagedHeader) { export async function refreshHeader (outliner: OutlineManager, headerElement?: Header) { ensureMobileHeaderStyles() + await ensureAuthUserResolved() const headerOptions = setHeaderOptions(outliner) const header = headerElement || document.querySelector('solid-ui-header') as Header | null if (!header) return null diff --git a/src/mainPage/menu.ts b/src/mainPage/menu.ts index b43be7a4..a6261f11 100644 --- a/src/mainPage/menu.ts +++ b/src/mainPage/menu.ts @@ -59,7 +59,11 @@ const applyMenuCollapsedState = (navMenu: HTMLElement | null): void => { updateCollapseButtonPosition(navMenu, collapseBtn) } -const isLoggedIn = (): boolean => Boolean(authSession?.info?.isLoggedIn) +// Compatibility: solid-logic Session shape differs across stacks (info.isLoggedIn vs isActive/webId). +const isLoggedIn = (): boolean => { + const sessionAny = authSession as any + return Boolean(sessionAny?.info?.isLoggedIn ?? sessionAny?.isActive ?? sessionAny?.webId) +} const setFooterVisibility = (loggedIn: boolean): void => { const footer = document.querySelector('solid-ui-footer') as HTMLElement | null diff --git a/test/mocks/solid-oidc-client-browser.ts b/test/mocks/solid-oidc-client-browser.ts new file mode 100644 index 00000000..7e883922 --- /dev/null +++ b/test/mocks/solid-oidc-client-browser.ts @@ -0,0 +1,59 @@ +type Listener = (...args: any[]) => void + +class EventEmitterLike { + private listeners: Record = {} + + on (event: string, listener: Listener): void { + const list = this.listeners[event] || [] + list.push(listener) + this.listeners[event] = list + } + + emit (event: string, ...args: any[]): void { + const list = this.listeners[event] || [] + list.forEach(listener => listener(...args)) + } +} + +export class Session { + info: { webId?: string, isLoggedIn: boolean } = { isLoggedIn: false } + webId?: string + isActive = false + events = new EventEmitterLike() + + addEventListener (event: string, listener: Listener): void { + this.events.on(event, listener) + } + + async handleIncomingRedirect (): Promise { + + } + + async handleRedirectFromLogin (): Promise { + + } + + async restore (): Promise { + + } + + async login (): Promise { + + } + + async logout (): Promise { + this.info = { isLoggedIn: false } + this.webId = undefined + this.isActive = false + } + + fetch (input: RequestInfo | URL, init?: RequestInit): Promise { + return globalThis.fetch(input, init) + } + + authFetch (input: RequestInfo | URL, init?: RequestInit): Promise { + return globalThis.fetch(input, init) + } +} + +export class SessionCore extends Session {}