From fc3319e95deaa6e94b236ea9e1b5087a29297b96 Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Wed, 20 May 2026 19:40:35 +0200 Subject: [PATCH 1/7] replace Inrupt by Uvdsl OIDC client --- jest.config.mjs | 4 +- src/login/login.ts | 11 ++- src/v2/components/footer/Footer.ts | 3 - src/v2/components/loginButton/LoginButton.ts | 5 +- test/mocks/solid-oidc-client-browser.ts | 73 ++++++++++++++++++++ tsconfig.json | 6 +- 6 files changed, 85 insertions(+), 17 deletions(-) create mode 100644 test/mocks/solid-oidc-client-browser.ts diff --git a/jest.config.mjs b/jest.config.mjs index 407d9597..9861e34b 100644 --- a/jest.config.mjs +++ b/jest.config.mjs @@ -15,7 +15,9 @@ export default { ], setupFilesAfterEnv: ['./test/helpers/setup.ts'], moduleNameMapper: { - '^.+\\.css$': '/__mocks__/styleMock.js' + '^.+\\.css$': '/__mocks__/styleMock.js', + '^solid-logic$': '/../solid-logic/src', + '^@uvdsl/solid-oidc-client-browser$': '/test/mocks/solid-oidc-client-browser.ts' }, testMatch: ['**/?(*.)+(spec|test).[tj]s?(x)'], roots: ['/src', '/test', '/__mocks__'], diff --git a/src/login/login.ts b/src/login/login.ts index b93e07f1..f99175a9 100644 --- a/src/login/login.ts +++ b/src/login/login.ts @@ -513,10 +513,7 @@ export function renderSignInPopup (dom: HTMLDocument) { // Login const locationUrl = new URL(window.location.href) locationUrl.hash = '' // remove hash part - await authSession.login({ - redirectUrl: locationUrl.href, - oidcIssuer: issuerUri - }) + await authSession.login(issuerUri, locationUrl.href) } catch (err) { alert(err.message) } @@ -669,9 +666,9 @@ export function loginStatusBox ( } box.refresh = function () { - const sessionInfo = authSession.info - if (sessionInfo && sessionInfo.webId && sessionInfo.isLoggedIn) { - me = solidLogicSingleton.store.sym(sessionInfo.webId) + const webId = authSession.webId + if (webId) { + me = solidLogicSingleton.store.sym(webId) } else { me = null } diff --git a/src/v2/components/footer/Footer.ts b/src/v2/components/footer/Footer.ts index e3277dc5..efec2c61 100644 --- a/src/v2/components/footer/Footer.ts +++ b/src/v2/components/footer/Footer.ts @@ -107,9 +107,6 @@ export class Footer extends LitElement { if (typeof authSession.events.off === 'function') { authSession.events.off('login', this._updateFooter) authSession.events.off('logout', this._updateFooter) - } else if (typeof authSession.events.removeListener === 'function') { - authSession.events.removeListener('login', this._updateFooter) - authSession.events.removeListener('logout', this._updateFooter) } super.disconnectedCallback() } diff --git a/src/v2/components/loginButton/LoginButton.ts b/src/v2/components/loginButton/LoginButton.ts index e5758112..5444c94a 100644 --- a/src/v2/components/loginButton/LoginButton.ts +++ b/src/v2/components/loginButton/LoginButton.ts @@ -377,10 +377,7 @@ export class LoginButton extends LitElement { const locationUrl = new URL(window.location.href) locationUrl.hash = '' - await authSession.login({ - redirectUrl: locationUrl.href, - oidcIssuer: issuerUri - }) + await authSession.login(issuerUri, locationUrl.href) } catch (err: any) { this._errorMsg = err.message || String(err) this.requestUpdate() diff --git a/test/mocks/solid-oidc-client-browser.ts b/test/mocks/solid-oidc-client-browser.ts new file mode 100644 index 00000000..bebc302e --- /dev/null +++ b/test/mocks/solid-oidc-client-browser.ts @@ -0,0 +1,73 @@ +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 + } + + off (event: string, listener: Listener): void { + const list = this.listeners[event] || [] + this.listeners[event] = list.filter(item => item !== listener) + } + + 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() + + private eventTarget = new EventTarget() + + addEventListener (type: string, listener: EventListenerOrEventListenerObject | null): void { + if (!listener) return + this.eventTarget.addEventListener(type, listener) + } + + removeEventListener (type: string, listener: EventListenerOrEventListenerObject | null): void { + if (!listener) return + this.eventTarget.removeEventListener(type, listener) + } + + dispatchEvent (event: Event): boolean { + return this.eventTarget.dispatchEvent(event) + } + + async handleIncomingRedirect (): Promise { + + } + + async handleRedirectFromLogin (): Promise { + + } + + async restore (): Promise { + + } + + async login (_idp?: string, _redirectUri?: string): 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) + } +} diff --git a/tsconfig.json b/tsconfig.json index 20ab8849..babcfa81 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -63,8 +63,10 @@ "declarations.d.ts" ] /* List of folders to include type definitions from. */, // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ - "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ - // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ + "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ + "preserveSymlinks": true, /* Do not resolve the real path of symlinks. Needed for local linked solid-logic. */ + "baseUrl": ".", /* Base directory to resolve non-absolute module names. Needed for paths mapping. */ + "paths": { "rdflib": ["./node_modules/rdflib"] }, /* Map rdflib to avoid duplicate type identity when linked with solid-logic. */ /* Source Map Options */ // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ From 7039622494c2ff87c6d318fd48f970d8acf7aa96 Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Sun, 24 May 2026 19:59:36 +0200 Subject: [PATCH 2/7] fix(auth): sync header auth state with session and enforce server logout endpoints 1. derive header auth state from auth session checks/events 2. call end_session and NSS well-known logout on logout 3. add/update header tests for session-driven state transitions --- src/login/login.ts | 6 +++ src/v2/components/header/Header.ts | 62 ++++++++++++++++++++-- src/v2/components/header/header.test.ts | 68 +++++++++++++++++++++++++ 3 files changed, 132 insertions(+), 4 deletions(-) diff --git a/src/login/login.ts b/src/login/login.ts index f99175a9..07817f61 100644 --- a/src/login/login.ts +++ b/src/login/login.ts @@ -713,6 +713,12 @@ authSession.events.on('logout', async () => { await fetch(openidConfiguration.end_session_endpoint, { credentials: 'include' }) } } + + try { + await fetch('/.well-known/solid/logout', { credentials: 'include' }) + } catch (_err) { + // Not all deployments expose NSS-compatible well-known logout endpoint. + } } catch (_err) { // Do nothing } diff --git a/src/v2/components/header/Header.ts b/src/v2/components/header/Header.ts index 0f1774e6..f083f363 100644 --- a/src/v2/components/header/Header.ts +++ b/src/v2/components/header/Header.ts @@ -1,6 +1,6 @@ import { LitElement, html, css } from 'lit' import { icons } from '../../../iconBase' -import { authSession } from 'solid-logic' +import { authSession, authn } from 'solid-logic' import '../loginButton/index' import '../signupButton/index' import { ifDefined } from 'lit/directives/if-defined.js' @@ -510,6 +510,9 @@ export class Header extends LitElement { declare helpMenuOpen: boolean declare hasSlottedAccountMenu: boolean declare hasSlottedHelpMenu: boolean + private readonly handleAuthSessionChange = () => { + this.refreshAuthStateFromSession() + } constructor () { super() @@ -540,14 +543,34 @@ export class Header extends LitElement { super.connectedCallback() document.addEventListener('click', this.handleDocumentClick) window.addEventListener('keydown', this.handleWindowKeydown) + if (typeof authSession.events?.on === 'function') { + authSession.events.on('login', this.handleAuthSessionChange) + authSession.events.on('logout', this.handleAuthSessionChange) + authSession.events.on('sessionRestore', this.handleAuthSessionChange) + } + this.refreshAuthStateFromSession() } disconnectedCallback () { document.removeEventListener('click', this.handleDocumentClick) window.removeEventListener('keydown', this.handleWindowKeydown) + if (typeof authSession.events?.off === 'function') { + authSession.events.off('login', this.handleAuthSessionChange) + authSession.events.off('logout', this.handleAuthSessionChange) + authSession.events.off('sessionRestore', this.handleAuthSessionChange) + } super.disconnectedCallback() } + private async refreshAuthStateFromSession () { + try { + await authn.checkUser() + } catch (_err) { + // Keep rendering even if session refresh cannot complete. + } + this.authState = authn.currentUser() ? 'logged-in' : 'logged-out' + } + private handleHelpMenuClick (item: HeaderMenuItem, event: MouseEvent) { event.preventDefault() this.helpMenuOpen = false @@ -665,8 +688,8 @@ export class Header extends LitElement { ` } - private handleLoginSuccess () { - this.authState = 'logged-in' + private async handleLoginSuccess () { + await this.refreshAuthStateFromSession() this.dispatchEvent(new CustomEvent('auth-action-select', { detail: { role: 'login' }, bubbles: true, @@ -676,12 +699,17 @@ export class Header extends LitElement { private async handleLogout () { this.accountMenuOpen = false + const issuer = window.localStorage.getItem('loginIssuer') || '' + try { await authSession.logout() } catch (_err) { // logout errors are non-fatal — proceed to clear state } - this.authState = 'logged-out' + + await this.performServerLogout(issuer) + + await this.refreshAuthStateFromSession() this.dispatchEvent(new CustomEvent('logout-select', { detail: { role: 'logout' }, bubbles: true, @@ -689,6 +717,32 @@ export class Header extends LitElement { })) } + private async performServerLogout (issuer: string) { + // Best-effort server logout for cookie-backed sessions on NSS-like servers. + try { + if (issuer) { + const wellKnownUri = new URL(issuer) + wellKnownUri.pathname = '/.well-known/openid-configuration' + const wellKnownResult = await fetch(wellKnownUri.toString(), { credentials: 'include' }) + + if (wellKnownResult.status === 200) { + const openidConfiguration = await wellKnownResult.json() + if (openidConfiguration && openidConfiguration.end_session_endpoint) { + await fetch(openidConfiguration.end_session_endpoint, { credentials: 'include' }) + } + } + } + } catch (_err) { + // Continue with local logout state even if remote IdP logout is unavailable. + } + + try { + await fetch('/.well-known/solid/logout', { credentials: 'include' }) + } catch (_err) { + // Not all deployments expose NSS-compatible well-known logout. + } + } + private renderAccountMenuItem (item: HeaderAccountMenuItem) { const content = html` ${this.renderLoggedInAvatar(item.avatar, 'account-menu-avatar')} diff --git a/src/v2/components/header/header.test.ts b/src/v2/components/header/header.test.ts index db621f23..55c4ee28 100644 --- a/src/v2/components/header/header.test.ts +++ b/src/v2/components/header/header.test.ts @@ -1,9 +1,39 @@ import { Header } from './Header' import './index' +import { authn, authSession } from 'solid-logic' + +type Listener = () => void +const mockSessionListeners = new Map>() + +jest.mock('solid-logic', () => ({ + authn: { + checkUser: jest.fn(async () => null), + currentUser: jest.fn(() => null) + }, + authSession: { + logout: jest.fn(async () => undefined), + events: { + on: jest.fn((event: string, handler: Listener) => { + if (!mockSessionListeners.has(event)) mockSessionListeners.set(event, new Set()) + mockSessionListeners.get(event)?.add(handler) + }), + off: jest.fn((event: string, handler: Listener) => { + mockSessionListeners.get(event)?.delete(handler) + }), + emit: jest.fn((event: string) => { + mockSessionListeners.get(event)?.forEach(handler => handler()) + }) + } + } +})) describe('SolidUIHeaderElement', () => { beforeEach(() => { document.body.innerHTML = '' + jest.clearAllMocks() + mockSessionListeners.clear() + ;(authn.currentUser as jest.Mock).mockReturnValue(null) + ;(authn.checkUser as jest.Mock).mockResolvedValue(null) Object.defineProperty(window, 'open', { configurable: true, writable: true, @@ -77,6 +107,8 @@ describe('SolidUIHeaderElement', () => { expect(signUpLink.getAttribute('icon')).toBe('https://example.com/signup-icon-top.svg') loginButton.dispatchEvent(new CustomEvent('login-success', { bubbles: true, composed: true })) + await Promise.resolve() + await header.updateComplete expect(authActionSelected).toHaveBeenCalledWith({ role: 'login' @@ -105,6 +137,7 @@ describe('SolidUIHeaderElement', () => { it('uses a custom fallback avatar when no accountAvatar is configured', async () => { const header = new Header() + ;(authn.currentUser as jest.Mock).mockReturnValue({ uri: 'https://alice.example/profile/card#me' }) header.authState = 'logged-in' header.accountAvatar = '' @@ -123,6 +156,7 @@ describe('SolidUIHeaderElement', () => { it('renders an accounts dropdown with avatar when logged in', async () => { const header = new Header() const accountMenuSelected = jest.fn() + ;(authn.currentUser as jest.Mock).mockReturnValue({ uri: 'https://alice.example/profile/card#me' }) header.authState = 'logged-in' header.accountIcon = 'https://example.com/account-icon.svg' @@ -173,6 +207,7 @@ describe('SolidUIHeaderElement', () => { it('does not render the logout icon on mobile layout', async () => { const header = new Header() + ;(authn.currentUser as jest.Mock).mockReturnValue({ uri: 'https://alice.example/profile/card#me' }) header.layout = 'mobile' header.authState = 'logged-in' header.logoutIcon = 'https://example.com/logout-icon.svg' @@ -196,6 +231,7 @@ describe('SolidUIHeaderElement', () => { it('does not render account webid on mobile layout', async () => { const header = new Header() + ;(authn.currentUser as jest.Mock).mockReturnValue({ uri: 'https://alice.example/profile/card#me' }) header.layout = 'mobile' header.authState = 'logged-in' header.accountMenu = [ @@ -263,6 +299,7 @@ describe('SolidUIHeaderElement', () => { it('renders helpMenuList inside the help dropdown and dispatches events', async () => { const header = new Header() + ;(authn.currentUser as jest.Mock).mockReturnValue({ uri: 'https://alice.example/profile/card#me' }) const helpMenuClicked = jest.fn() @@ -304,4 +341,35 @@ describe('SolidUIHeaderElement', () => { window.open = originalWindowOpen }) + + it('derives auth state from session on connect', async () => { + const header = new Header() + ;(authn.currentUser as jest.Mock).mockReturnValue({ uri: 'https://alice.example/profile/card#me' }) + + document.body.appendChild(header) + await header.updateComplete + await Promise.resolve() + await header.updateComplete + + expect(authn.checkUser).toHaveBeenCalled() + expect(header.authState).toBe('logged-in') + }) + + it('refreshes auth state when session events fire', async () => { + const header = new Header() + document.body.appendChild(header) + await header.updateComplete + + ;(authn.currentUser as jest.Mock).mockReturnValue({ uri: 'https://alice.example/profile/card#me' }) + ;(authSession.events as any).emit('login') + await Promise.resolve() + await header.updateComplete + expect(header.authState).toBe('logged-in') + + ;(authn.currentUser as jest.Mock).mockReturnValue(null) + ;(authSession.events as any).emit('logout') + await Promise.resolve() + await header.updateComplete + expect(header.authState).toBe('logged-out') + }) }) From ac0d2e67c3f8ca9e5caf7fbba86ce1ab6276e02d Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Fri, 29 May 2026 12:02:08 +0200 Subject: [PATCH 3/7] fix(header): stabilize auth resolution and delegate server logout --- src/v2/components/layout/header/Header.ts | 105 ++++++++++++------ .../components/layout/header/header.test.ts | 22 ++++ 2 files changed, 93 insertions(+), 34 deletions(-) diff --git a/src/v2/components/layout/header/Header.ts b/src/v2/components/layout/header/Header.ts index 63051df2..0e5ae968 100644 --- a/src/v2/components/layout/header/Header.ts +++ b/src/v2/components/layout/header/Header.ts @@ -1,6 +1,6 @@ import { LitElement, html, css } from 'lit' import { icons } from '../../../../iconBase' -import { authSession } from 'solid-logic' +import { authSession, authn, performServerSideLogout } from 'solid-logic' import '../../auth/loginButton/index' import '../../auth/signupButton/index' import { ifDefined } from 'lit/directives/if-defined.js' @@ -10,6 +10,36 @@ const DEFAULT_SOLID_ICON_URL = 'https://solidproject.org/assets/img/solid-emblem const DEFAULT_SIGNUP_URL = 'https://solidproject.org/get_a_pod' const DEFAULT_LOGGEDIN_MENU_BUTTON_AVATAR = icons.iconBase + 'emptyProfileAvatar.png' +async function clearPersistedAuthState (): Promise { + if (typeof window === 'undefined') { + return + } + + const explicitKeys = ['loginIssuer', 'preLoginRedirectHash'] + for (const key of explicitKeys) { + window.localStorage.removeItem(key) + window.sessionStorage.removeItem(key) + } + + if (typeof indexedDB === 'undefined') { + return + } + + const databases = ['soidc', 'solid-client-authn-store', 'solid-client-authn'] + for (const dbName of databases) { + await new Promise((resolve) => { + try { + const request = indexedDB.deleteDatabase(dbName) + request.onsuccess = () => resolve() + request.onerror = () => resolve() + request.onblocked = () => resolve() + } catch (_err) { + resolve() + } + }) + } +} + export type HeaderAuthState = 'logged-out' | 'logged-in' export type HeaderMenuItem = { @@ -47,7 +77,8 @@ export class Header extends LitElement { accountMenuOpen: { state: true }, helpMenuOpen: { state: true }, hasSlottedAccountMenu: { state: true }, - hasSlottedHelpMenu: { state: true } + hasSlottedHelpMenu: { state: true }, + authResolved: { state: true } } static styles = css` @@ -510,8 +541,11 @@ export class Header extends LitElement { declare helpMenuOpen: boolean declare hasSlottedAccountMenu: boolean declare hasSlottedHelpMenu: boolean + declare authResolved: boolean + private _refreshPromise: Promise | null = null + private readonly handleAuthSessionChange = () => { - this.refreshAuthStateFromSession() + void this.refreshAuthStateFromSession() } constructor () { @@ -537,6 +571,7 @@ export class Header extends LitElement { this.helpMenuOpen = false this.hasSlottedAccountMenu = false this.hasSlottedHelpMenu = false + this.authResolved = false } connectedCallback () { @@ -548,7 +583,7 @@ export class Header extends LitElement { authSession.events.on('logout', this.handleAuthSessionChange) authSession.events.on('sessionRestore', this.handleAuthSessionChange) } - this.refreshAuthStateFromSession() + void this.refreshAuthStateFromSession() } disconnectedCallback () { @@ -563,12 +598,28 @@ export class Header extends LitElement { } private async refreshAuthStateFromSession () { + if (!this._refreshPromise) { + this._refreshPromise = (async () => { + try { + await authn.checkUser() + // Some auth stacks resolve session state asynchronously after first check. + if (!authn.currentUser()) { + await authn.checkUser() + } + } catch (_err) { + // Keep rendering even if session refresh cannot complete. + } + })() + } + try { - await authn.checkUser() - } catch (_err) { - // Keep rendering even if session refresh cannot complete. + await this._refreshPromise + } finally { + this._refreshPromise = null } + this.authState = authn.currentUser() ? 'logged-in' : 'logged-out' + this.authResolved = true } private handleHelpMenuClick (item: HeaderMenuItem, event: MouseEvent) { @@ -707,7 +758,15 @@ export class Header extends LitElement { // logout errors are non-fatal — proceed to clear state } - await this.performServerLogout(issuer) + await clearPersistedAuthState() + + const redirectedToServerLogout = await performServerSideLogout({ + issuer, + postLogoutRedirectPath: '/' + }) + if (redirectedToServerLogout) { + return + } await this.refreshAuthStateFromSession() this.dispatchEvent(new CustomEvent('logout-select', { @@ -717,32 +776,6 @@ export class Header extends LitElement { })) } - private async performServerLogout (issuer: string) { - // Best-effort server logout for cookie-backed sessions on NSS-like servers. - try { - if (issuer) { - const wellKnownUri = new URL(issuer) - wellKnownUri.pathname = '/.well-known/openid-configuration' - const wellKnownResult = await fetch(wellKnownUri.toString(), { credentials: 'include' }) - - if (wellKnownResult.status === 200) { - const openidConfiguration = await wellKnownResult.json() - if (openidConfiguration && openidConfiguration.end_session_endpoint) { - await fetch(openidConfiguration.end_session_endpoint, { credentials: 'include' }) - } - } - } - } catch (_err) { - // Continue with local logout state even if remote IdP logout is unavailable. - } - - try { - await fetch('/.well-known/solid/logout', { credentials: 'include' }) - } catch (_err) { - // Not all deployments expose NSS-compatible well-known logout. - } - } - private renderAccountMenuItem (item: HeaderAccountMenuItem) { const content = html` ${this.renderLoggedInAvatar(item.avatar, 'account-menu-avatar')} @@ -844,6 +877,10 @@ export class Header extends LitElement { } private renderUserArea () { + if (!this.authResolved) { + return html`
` + } + if (this.authState === 'logged-out') { return this.renderLoggedOutActions() } diff --git a/src/v2/components/layout/header/header.test.ts b/src/v2/components/layout/header/header.test.ts index 55c4ee28..b75b11e9 100644 --- a/src/v2/components/layout/header/header.test.ts +++ b/src/v2/components/layout/header/header.test.ts @@ -10,6 +10,7 @@ jest.mock('solid-logic', () => ({ checkUser: jest.fn(async () => null), currentUser: jest.fn(() => null) }, + performServerSideLogout: jest.fn(async () => false), authSession: { logout: jest.fn(async () => undefined), events: { @@ -355,6 +356,27 @@ describe('SolidUIHeaderElement', () => { expect(header.authState).toBe('logged-in') }) + it('retries session resolution once before settling logged-out state', async () => { + const header = new Header() + let callCount = 0 + ;(authn.currentUser as jest.Mock).mockImplementation(() => { + return callCount >= 2 ? { uri: 'https://alice.example/profile/card#me' } : null + }) + ;(authn.checkUser as jest.Mock).mockImplementation(async () => { + callCount += 1 + return callCount >= 2 ? { uri: 'https://alice.example/profile/card#me' } : null + }) + + document.body.appendChild(header) + await header.updateComplete + await Promise.resolve() + await header.updateComplete + + expect(authn.checkUser).toHaveBeenCalledTimes(2) + expect(header.authResolved).toBe(true) + expect(header.authState).toBe('logged-in') + }) + it('refreshes auth state when session events fire', async () => { const header = new Header() document.body.appendChild(header) From 69366caca958c26aa309a1e78ed384c28ad6ba33 Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Fri, 29 May 2026 14:43:30 +0200 Subject: [PATCH 4/7] from Copilot review: use the sibling checkout when present, otherwise fall back to node_modules so CI/standalone clones work. --- jest.config.mjs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/jest.config.mjs b/jest.config.mjs index 50862a9e..f7f7fac6 100644 --- a/jest.config.mjs +++ b/jest.config.mjs @@ -1,3 +1,14 @@ +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 localSolidLogicSrc = path.resolve(__dirname, '../solid-logic/src') +const solidLogicMapper = existsSync(localSolidLogicSrc) + ? localSolidLogicSrc + : '/node_modules/solid-logic/src' + export default { // verbose: true, // Uncomment for detailed test output collectCoverage: true, @@ -16,7 +27,7 @@ export default { setupFilesAfterEnv: ['./test/helpers/setup.ts'], moduleNameMapper: { '^.+\\.css$': '/__mocks__/styleMock.js', - '^solid-logic$': '/../solid-logic/src', + '^solid-logic$': solidLogicMapper, '^@uvdsl/solid-oidc-client-browser$': '/test/mocks/solid-oidc-client-browser.ts' }, testMatch: ['**/?(*.)+(spec|test).[tj]s?(x)'], From 712c3f8cb1dbb7c2aed4a90cd107ab81d203ba8a Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Fri, 29 May 2026 15:06:51 +0200 Subject: [PATCH 5/7] lint errors --- src/v2/components/layout/header/Header.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/v2/components/layout/header/Header.ts b/src/v2/components/layout/header/Header.ts index 0e5ae968..ac5fa714 100644 --- a/src/v2/components/layout/header/Header.ts +++ b/src/v2/components/layout/header/Header.ts @@ -545,7 +545,9 @@ export class Header extends LitElement { private _refreshPromise: Promise | null = null private readonly handleAuthSessionChange = () => { - void this.refreshAuthStateFromSession() + this.refreshAuthStateFromSession().catch(() => { + // Keep auth event handling resilient on transient refresh failures. + }) } constructor () { @@ -583,7 +585,9 @@ export class Header extends LitElement { authSession.events.on('logout', this.handleAuthSessionChange) authSession.events.on('sessionRestore', this.handleAuthSessionChange) } - void this.refreshAuthStateFromSession() + this.refreshAuthStateFromSession().catch(() => { + // Keep initial header render resilient on transient refresh failures. + }) } disconnectedCallback () { From 5657bd4bf32bab301e32a9f2824729065d3d32f8 Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Fri, 29 May 2026 15:19:42 +0200 Subject: [PATCH 6/7] update header tests --- .../components/layout/header/header.test.ts | 26 +++++++++++++------ 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/src/v2/components/layout/header/header.test.ts b/src/v2/components/layout/header/header.test.ts index b75b11e9..3af81608 100644 --- a/src/v2/components/layout/header/header.test.ts +++ b/src/v2/components/layout/header/header.test.ts @@ -29,6 +29,12 @@ jest.mock('solid-logic', () => ({ })) describe('SolidUIHeaderElement', () => { + async function waitForAuthRefresh (header: Header): Promise { + await Promise.resolve() + await Promise.resolve() + await header.updateComplete + } + beforeEach(() => { document.body.innerHTML = '' jest.clearAllMocks() @@ -53,6 +59,7 @@ describe('SolidUIHeaderElement', () => { header.setAttribute('help-icon', 'https://example.com/help.png') header.setAttribute('brand-link', '/home') header.authState = 'logged-out' + header.authResolved = true header.helpMenuList = [{ label: 'Help', action: 'open-help' }] header.innerHTML = '' @@ -83,6 +90,7 @@ describe('SolidUIHeaderElement', () => { const authActionSelected = jest.fn() header.authState = 'logged-out' + header.authResolved = true header.loginAction = { label: 'Log in', action: 'login', icon: 'https://example.com/login-icon.svg' } header.signUpAction = { label: 'Sign Up', url: '/signup', icon: 'https://example.com/signup-icon.svg' } header.loginIcon = 'https://example.com/login-icon-top.svg' @@ -108,8 +116,7 @@ describe('SolidUIHeaderElement', () => { expect(signUpLink.getAttribute('icon')).toBe('https://example.com/signup-icon-top.svg') loginButton.dispatchEvent(new CustomEvent('login-success', { bubbles: true, composed: true })) - await Promise.resolve() - await header.updateComplete + await waitForAuthRefresh(header) expect(authActionSelected).toHaveBeenCalledWith({ role: 'login' @@ -119,6 +126,7 @@ describe('SolidUIHeaderElement', () => { it('does not show login or signup icons on mobile layout', async () => { const header = new Header() header.authState = 'logged-out' + header.authResolved = true header.layout = 'mobile' header.loginAction = { label: 'Log in', action: 'login', icon: 'https://example.com/login-icon.svg' } header.signUpAction = { label: 'Sign Up', url: '/signup', icon: 'https://example.com/signup-icon.svg' } @@ -141,6 +149,7 @@ describe('SolidUIHeaderElement', () => { ;(authn.currentUser as jest.Mock).mockReturnValue({ uri: 'https://alice.example/profile/card#me' }) header.authState = 'logged-in' + header.authResolved = true header.accountAvatar = '' header.accountAvatarFallback = 'https://example.com/fallback-avatar.png' @@ -160,6 +169,7 @@ describe('SolidUIHeaderElement', () => { ;(authn.currentUser as jest.Mock).mockReturnValue({ uri: 'https://alice.example/profile/card#me' }) header.authState = 'logged-in' + header.authResolved = true header.accountIcon = 'https://example.com/account-icon.svg' header.accountAvatar = 'https://example.com/avatar.png' header.logoutIcon = 'https://example.com/logout-icon.svg' @@ -211,6 +221,7 @@ describe('SolidUIHeaderElement', () => { ;(authn.currentUser as jest.Mock).mockReturnValue({ uri: 'https://alice.example/profile/card#me' }) header.layout = 'mobile' header.authState = 'logged-in' + header.authResolved = true header.logoutIcon = 'https://example.com/logout-icon.svg' header.logoutLabel = 'Log Out' @@ -235,6 +246,7 @@ describe('SolidUIHeaderElement', () => { ;(authn.currentUser as jest.Mock).mockReturnValue({ uri: 'https://alice.example/profile/card#me' }) header.layout = 'mobile' header.authState = 'logged-in' + header.authResolved = true header.accountMenu = [ { label: 'Personal Pod', webid: 'https://pod.example/profile/card#me', action: 'switch-personal' } ] @@ -305,6 +317,7 @@ describe('SolidUIHeaderElement', () => { const helpMenuClicked = jest.fn() header.authState = 'logged-in' + header.authResolved = true header.helpIcon = '' header.helpMenuList = [{ label: 'Docs', url: 'https://example.com/docs', target: '_blank' }] @@ -349,8 +362,7 @@ describe('SolidUIHeaderElement', () => { document.body.appendChild(header) await header.updateComplete - await Promise.resolve() - await header.updateComplete + await waitForAuthRefresh(header) expect(authn.checkUser).toHaveBeenCalled() expect(header.authState).toBe('logged-in') @@ -384,14 +396,12 @@ describe('SolidUIHeaderElement', () => { ;(authn.currentUser as jest.Mock).mockReturnValue({ uri: 'https://alice.example/profile/card#me' }) ;(authSession.events as any).emit('login') - await Promise.resolve() - await header.updateComplete + await waitForAuthRefresh(header) expect(header.authState).toBe('logged-in') ;(authn.currentUser as jest.Mock).mockReturnValue(null) ;(authSession.events as any).emit('logout') - await Promise.resolve() - await header.updateComplete + await waitForAuthRefresh(header) expect(header.authState).toBe('logged-out') }) }) From e04a7e7758ea770c87d9a9ba67e1d4cda301ed1c Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Tue, 2 Jun 2026 23:05:37 +0200 Subject: [PATCH 7/7] fix(login): finalize OIDC callback in loginStatusBox and avoid logout callback loops add guarded OIDC callback bootstrap in solid-ui loginStatusBox trigger authn.checkUser only when callback params are present keep login flow stable by not stripping callback params during bootstrap sanitize code/state/iss only in logout handler before reload avoid stale callback URL causing re-entry into provider selection after logout --- src/login/login.ts | 2247 ++++++++++++++++++++++---------------------- 1 file changed, 1149 insertions(+), 1098 deletions(-) diff --git a/src/login/login.ts b/src/login/login.ts index fdbace80..c4049249 100644 --- a/src/login/login.ts +++ b/src/login/login.ts @@ -1,1098 +1,1149 @@ -/* eslint-disable camelcase */ -/** - * Signing in, signing up, profile and preferences reloading - * Type index management - * - * Many functions in this module take a context object which - * holds various RDF symbols, add to it, and return a promise of it. - * - * * `me` RDF symbol for the user's WebID - * * `publicProfile` The user's public profile, iff loaded - * * `preferencesFile` The user's personal preference file, iff loaded - * * `index.public` The user's public type index file - * * `index.private` The user's private type index file - * - * Not RDF symbols: - * * `noun` A string in english for the type of thing -- like "address book" - * * `instance` An array of nodes which are existing instances - * * `containers` An array of nodes of containers of instances - * * `div` A DOM element where UI can be displayed - * * `statusArea` A DOM element (opt) progress stuff can be displayed, or error messages - * * - * * Vocabulary: "load" loads a file if it exists; - * * 'Ensure" CREATES the file if it does not exist (if it can) and then loads it. - * @packageDocumentation - */ -import { PaneDefinition } from 'pane-registry' -import { BlankNode, NamedNode, st } from 'rdflib' - -import { Quad_Object } from 'rdflib/lib/tf-types' -import { - AppDetails, - AuthenticationContext, - authn, - authSession, - CrossOriginForbiddenError, - FetchError, - getSuggestedIssuers, - NotEditableError, - offlineTestID, - SameOriginForbiddenError, - solidLogicSingleton, - UnauthorizedError, - WebOperationError -} from 'solid-logic' -import * as debug from '../debug' -import { style } from '../style' -import { alert } from '../log' -import ns from '../ns' -import { Signup } from '../signup/signup.js' -import * as utils from '../utils' -import * as widgets from '../widgets' - -const store = solidLogicSingleton.store - -const { - loadPreferences, - loadProfile -} = solidLogicSingleton.profile - -const { - getScopedAppInstances, - getRegistrations, - loadAllTypeIndexes, - getScopedAppsFromIndex, - deleteTypeIndexRegistration -} = solidLogicSingleton.typeIndex - -/** - * Resolves with the logged in user's WebID - * - * @param context - */ -// used to be logIn -export function ensureLoggedIn (context: AuthenticationContext): Promise { - const me = authn.currentUser() - if (me) { - authn.saveUser(me, context) - return Promise.resolve(context) - } - - return new Promise((resolve) => { - authn.checkUser().then((webId) => { - // Already logged in? - if (webId) { - debug.log(`logIn: Already logged in as ${webId}`) - return resolve(context) - } - if (!context.div || !context.dom) { - return resolve(context) - } - const box = loginStatusBox(context.dom, (webIdUri) => { - authn.saveUser(webIdUri, context) - resolve(context) // always pass growing context - }) - context.div.appendChild(box) - }) - }) -} - -/** - * Loads preference file - * Do this after having done log in and load profile - * - * @private - * - * @param context - */ -// used to be logInLoadPreferences -export async function ensureLoadedPreferences ( - context: AuthenticationContext -): Promise { - if (context.preferencesFile) return Promise.resolve(context) // already done - - // const statusArea = context.statusArea || context.div || null - let progressDisplay - /* COMPLAIN FUNCTION NOT USED/TAKING IT OUT FOR NOW - function complain (message) { - message = `ensureLoadedPreferences: ${message}` - if (statusArea) { - // statusArea.innerHTML = '' - statusArea.appendChild(widgets.errorMessageBlock(context.dom, message)) - } - debug.log(message) - // reject(new Error(message)) - } */ - try { - context = await ensureLoadedProfile(context) - - // console.log('back in Solid UI after logInLoadProfile', context) - const preferencesFile = await loadPreferences(context.me as NamedNode) - if (progressDisplay) { - progressDisplay.parentNode.removeChild(progressDisplay) - } - context.preferencesFile = preferencesFile - } catch (err) { - let m2: string - if (err instanceof UnauthorizedError) { - m2 = - 'Oops — you are not authenticated (properly logged in), so SolidOS cannot read your preferences file. Try logging out and then logging back in.' - alert(m2) - } else if (err instanceof CrossOriginForbiddenError) { - m2 = `Unauthorized: Assuming preference file blocked for origin ${window.location.origin}` - context.preferencesFileError = m2 - return context - } else if (err instanceof SameOriginForbiddenError) { - m2 = - 'You are not authorized to read your preference file. This may be because you are using an untrusted web app.' - debug.warn(m2) - return context - } else if (err instanceof NotEditableError) { - m2 = - 'You are not authorized to edit your preference file. This may be because you are using an untrusted web app.' - debug.warn(m2) - return context - } else if (err instanceof WebOperationError) { - m2 = - 'You are not authorized to edit your preference file. This may be because you are using an untrusted web app.' - debug.warn(m2) - } else if (err instanceof FetchError) { - m2 = `Strange: Error ${err.status} trying to read your preference file.${err.message}` - alert(m2) - } else { - throw new Error(`(via loadPrefs) ${err}`) - } - } - return context -} - -/** - * Logs the user in and loads their WebID profile document into the store - * - * @param context - * - * @returns Resolves with the context after login / fetch - */ -// used to be logInLoadProfile -export async function ensureLoadedProfile ( - context: AuthenticationContext -): Promise { - if (context.publicProfile) { - return context - } // already done - try { - const logInContext = await ensureLoggedIn(context) - if (!logInContext.me) { - throw new Error('Could not log in') - } - context.publicProfile = await loadProfile(logInContext.me) - } catch (err) { - if (context.div && context.dom) { - context.div.appendChild(widgets.errorMessageBlock(context.dom, err.message)) - } - throw new Error(`Can't log in: ${err}`) - } - return context -} - -/** - * Returns promise of context with arrays of symbols - * - * leaving the `isPublic` param undefined will bring in community index things, too - */ -export async function findAppInstances ( - context: AuthenticationContext, - theClass: NamedNode, - isPublic?: boolean -): Promise { - let items = context.me ? await getScopedAppInstances(theClass, context.me) : [] - if (isPublic === true) { // old API - not recommended! - items = items.filter(item => item.scope.label === 'public') - } else if (isPublic === false) { - items = items.filter(item => item.scope.label === 'private') - } - context.instances = items.map(item => item.instance) - return context -} - -export function scopeLabel (context, scope) { - const mine = context.me && context.me.sameTerm(scope.agent) - const name = mine ? '' : utils.label(scope.agent) + ' ' - return `${name}${scope.label}` -} -/** - * UI to control registration of instance - */ -export async function registrationControl ( - context: AuthenticationContext, - instance, - theClass -): Promise { - function registrationStatements (index) { - const registrations = getRegistrations(instance, theClass) - const reg = registrations.length ? registrations[0] : widgets.newThing(index) - return [ - st(reg, ns.solid('instance'), instance, index), - st(reg, ns.solid('forClass'), theClass, index) - ] - } - - function renderScopeCheckbox (scope) { - const statements = registrationStatements(scope.index) - const name = scopeLabel(context, scope) - const label = `${name} link to this ${context.noun}` - return widgets.buildCheckboxForm( - context.dom, - solidLogicSingleton.store, - label, - null, - statements, - form, - scope.index - ) - } - /// / body of registrationControl - const dom = context.dom - if (!dom || !context.div) { - throw new Error('registrationControl: need dom and div') - } - const box = dom.createElement('div') - context.div.appendChild(box) - context.me = authn.currentUser() // @@ - const me = context.me - if (!me) { - box.innerHTML = '

(Log in to save a link to this)

' - return context - } - - let scopes // @@ const - try { - scopes = await loadAllTypeIndexes(me) - } catch (e) { - let msg - if (context.div && context.preferencesFileError) { - msg = '(Lists of stuff not available)' - context.div.appendChild(dom.createElement('p')).textContent = msg - } else if (context.div) { - msg = `registrationControl: Type indexes not available: ${e}` - context.div.appendChild(widgets.errorMessageBlock(context.dom, e)) - } - debug.log(msg) - return context - } - - box.innerHTML = '
' // tbody will be inserted anyway - box.setAttribute('style', 'font-size: 120%; text-align: right; padding: 1em; border: solid gray 0.05em;') - const tbody = box.children[0].children[0] - const form = new BlankNode() // @@ say for now - - for (const scope of scopes) { - const row = tbody.appendChild(dom.createElement('tr')) - row.appendChild(renderScopeCheckbox(scope)) // @@ index - } - return context -} - -export function renderScopeHeadingRow (context, store, scope) { - const backgroundColor = { private: '#fee', public: '#efe' } - const { dom } = context - const name = scopeLabel(context, scope) - const row = dom.createElement('tr') - const cell = row.appendChild(dom.createElement('td')) - cell.setAttribute('colspan', '3') - cell.style.backgoundColor = backgroundColor[scope.label] || 'white' - const header = cell.appendChild(dom.createElement('h3')) - header.textContent = name + ' links' - header.style.textAlign = 'left' - return row -} -/** - * UI to List at all registered things - */ -export async function registrationList (context: AuthenticationContext, options: { - private?: boolean - public?: boolean - type?: NamedNode -}): Promise { - const dom = context.dom as HTMLDocument - const div = context.div as HTMLElement - - const box = dom.createElement('div') - div.appendChild(box) - context.me = authn.currentUser() // @@ - if (!context.me) { - box.innerHTML = '

(Log in list your stuff)

' - return context - } - - const scopes = await loadAllTypeIndexes(context.me) // includes community indexes - - // console.log('@@ registrationList ', scopes) - box.innerHTML = '
' // tbody will be inserted anyway - box.setAttribute('style', 'font-size: 120%; text-align: right; padding: 1em; border: solid #eee 0.5em;') - const table = box.firstChild as HTMLElement - const tbody = table.firstChild as HTMLElement - - for (const scope of scopes) { // need some predicate for listing/adding agents - const headingRow = renderScopeHeadingRow(context, store, scope) - tbody.appendChild(headingRow) - const items = await getScopedAppsFromIndex(scope, options.type || null) // any class - if (items.length === 0) headingRow.style.display = 'none' - // console.log(`registrationList: @@ instance items for class ${options.type || 'undefined' }:`, items) - for (const item of items) { - const row = widgets.personTR(dom, ns.solid('instance'), item.instance, { - deleteFunction: async () => { - await deleteTypeIndexRegistration(item) - tbody.removeChild(row) - } - }) - row.children[0].style.paddingLeft = '3em' - - tbody.appendChild(row) - } - } - return context -} // registrationList - -/** - * Bootstrapping identity - * (Called by `loginStatusBox()`) - * - * @param dom - * @param setUserCallback - * - * @returns - */ -function signInOrSignUpBox ( - dom: HTMLDocument, - setUserCallback: (user: string) => void, - options: { - buttonStyle?: string; - } = {} -): HTMLElement { - options = options || {} - const signInButtonStyle = options.buttonStyle || style.signInAndUpButtonStyle - - const box: any = dom.createElement('div') - const magicClassName = 'SolidSignInOrSignUpBox' - debug.log('widgets.signInOrSignUpBox') - box.setUserCallback = setUserCallback - box.setAttribute('class', magicClassName) - box.setAttribute('style', 'display:flex;') - - // Sign in button with PopUP - const signInPopUpButton = dom.createElement('input') // multi - box.appendChild(signInPopUpButton) - signInPopUpButton.setAttribute('type', 'button') - signInPopUpButton.setAttribute('value', 'Log in') - signInPopUpButton.setAttribute('style', `${signInButtonStyle}${style.headerBannerLoginInput}` + style.signUpBackground) - - authSession.events.on('login', () => { - const me = authn.currentUser() - // const sessionInfo = authSession.info - // if (sessionInfo && sessionInfo.isLoggedIn) { - if (me) { - // const webIdURI = sessionInfo.webId - const webIdURI = me.uri - // setUserCallback(webIdURI) - const divs = dom.getElementsByClassName(magicClassName) - debug.log(`Logged in, ${divs.length} panels to be serviced`) - // At the same time, satisfy all the other login boxes - for (let i = 0; i < divs.length; i++) { - const div: any = divs[i] - // @@ TODO Remove the need to manipulate HTML elements - if (div.setUserCallback) { - try { - div.setUserCallback(webIdURI) - const parent = div.parentNode - if (parent) { - parent.removeChild(div) - } - } catch (e) { - debug.log(`## Error satisfying login box: ${e}`) - div.appendChild(widgets.errorMessageBlock(dom, e)) - } - } - } - } - }) - - signInPopUpButton.addEventListener( - 'click', - () => { - const offline = offlineTestID() - if (offline) return setUserCallback(offline.uri) - - renderSignInPopup(dom) - }, - false - ) - - // Sign up button - const signupButton = dom.createElement('input') - box.appendChild(signupButton) - signupButton.setAttribute('type', 'button') - signupButton.setAttribute('value', 'Sign Up for Solid') - signupButton.setAttribute('style', `${signInButtonStyle}${style.headerBannerLoginInput}` + style.signInBackground) - - signupButton.addEventListener( - 'click', - function (_event) { - const signupMgr = new Signup() - signupMgr.signup().then(function (uri) { - debug.log('signInOrSignUpBox signed up ' + uri) - setUserCallback(uri) - }) - }, - false - ) - return box -} - -export function renderSignInPopup (dom: HTMLDocument) { - /** - * Issuer Menu - */ - const issuerPopup = dom.createElement('div') - issuerPopup.setAttribute( - 'style', - 'position: fixed; top: 0; left: 0; right: 0; bottom: 0; display: flex; justify-content: center; align-items: center;' - ) - dom.body.appendChild(issuerPopup) - const issuerPopupBox = dom.createElement('div') - issuerPopupBox.setAttribute( - 'style', - ` - background-color: white; - box-shadow: 0px 1px 4px rgba(0, 0, 0, 0.2); - -webkit-box-shadow: 0px 1px 4px rgba(0, 0, 0, 0.2); - -moz-box-shadow: 0px 1px 4px rgba(0, 0, 0, 0.2); - -o-box-shadow: 0px 1px 4px rgba(0, 0, 0, 0.2); - border-radius: 4px; - min-width: 400px; - padding: 10px; - z-index : 10; - ` - ) - issuerPopup.appendChild(issuerPopupBox) - const issuerPopupBoxTopMenu = dom.createElement('div') - issuerPopupBoxTopMenu.setAttribute( - 'style', - ` - border-bottom: 1px solid #DDD; - display: flex; - flex-direction: row; - align-items: center; - justify-content: space-between; - ` - ) - issuerPopupBox.appendChild(issuerPopupBoxTopMenu) - const issuerPopupBoxLabel = dom.createElement('label') - issuerPopupBoxLabel.setAttribute('style', 'margin-right: 5px; font-weight: 800') - issuerPopupBoxLabel.innerText = 'Select an identity provider' - const issuerPopupBoxCloseButton = dom.createElement('button') - issuerPopupBoxCloseButton.innerHTML = - '' - issuerPopupBoxCloseButton.setAttribute('style', 'background-color: transparent; border: none;') - issuerPopupBoxCloseButton.addEventListener('click', () => { - issuerPopup.remove() - }) - issuerPopupBoxTopMenu.appendChild(issuerPopupBoxLabel) - issuerPopupBoxTopMenu.appendChild(issuerPopupBoxCloseButton) - - const loginToIssuer = async (issuerUri: string) => { - try { - // clear authorization metadata from store - solidLogicSingleton.store.updater.flagAuthorizationMetadata() as any - // Save hash - const preLoginRedirectHash = new URL(window.location.href).hash - if (preLoginRedirectHash) { - window.localStorage.setItem('preLoginRedirectHash', preLoginRedirectHash) - } - window.localStorage.setItem('loginIssuer', issuerUri) - // Login - const locationUrl = new URL(window.location.href) - locationUrl.hash = '' // remove hash part - await authSession.login(issuerUri, locationUrl.href) - } catch (err) { - alert(err.message) - } - } - - /** - * Text-based idp selection - */ - const issuerTextContainer = dom.createElement('div') - issuerTextContainer.setAttribute( - 'style', - ` - border-bottom: 1px solid #DDD; - display: flex; - flex-direction: column; - padding-top: 10px; - ` - ) - const issuerTextInputContainer = dom.createElement('div') - issuerTextInputContainer.setAttribute( - 'style', - ` - display: flex; - flex-direction: row; - ` - ) - const issuerTextLabel = dom.createElement('label') - issuerTextLabel.innerText = 'Enter the URL of your identity provider:' - issuerTextLabel.setAttribute('style', 'color: #888') - const issuerTextInput = dom.createElement('input') - issuerTextInput.setAttribute('type', 'text') - issuerTextInput.setAttribute( - 'style', - 'margin-left: 0 !important; flex: 1; margin-right: 5px !important' - ) - issuerTextInput.setAttribute('placeholder', 'https://example.com') - issuerTextInput.value = localStorage.getItem('loginIssuer') || '' - const issuerTextGoButton = dom.createElement('button') - issuerTextGoButton.innerText = 'Go' - issuerTextGoButton.setAttribute('style', 'margin-top: 12px; margin-bottom: 12px;') - issuerTextGoButton.addEventListener('click', () => { - loginToIssuer(issuerTextInput.value) - }) - issuerTextContainer.appendChild(issuerTextLabel) - issuerTextInputContainer.appendChild(issuerTextInput) - issuerTextInputContainer.appendChild(issuerTextGoButton) - issuerTextContainer.appendChild(issuerTextInputContainer) - issuerPopupBox.appendChild(issuerTextContainer) - - /** - * Button-based idp selection - */ - const issuerButtonContainer = dom.createElement('div') - issuerButtonContainer.setAttribute( - 'style', - ` - display: flex; - flex-direction: column; - padding-top: 10px; - ` - ) - const issuerBottonLabel = dom.createElement('label') - issuerBottonLabel.innerText = 'Or pick an identity provider from the list below:' - issuerBottonLabel.setAttribute('style', 'color: #888') - issuerButtonContainer.appendChild(issuerBottonLabel) - getSuggestedIssuers().forEach((issuerInfo) => { - const issuerButton = dom.createElement('button') - issuerButton.innerText = issuerInfo.name - issuerButton.setAttribute('style', 'height: 38px; margin-top: 10px') - issuerButton.addEventListener('click', () => { - loginToIssuer(issuerInfo.uri) - }) - issuerButtonContainer.appendChild(issuerButton) - }) - issuerPopupBox.appendChild(issuerButtonContainer) -} - -/** - * Login status box - * - * A big sign-up/sign in box or a logout box depending on the state - * - * @param dom - * @param listener - * - * @returns - */ -export function loginStatusBox ( - dom: HTMLDocument, - listener: ((uri: string | null) => void) | null = null, - options: { - buttonStyle?: string; - } = {} -): HTMLElement { - // 20190630 - let me = offlineTestID() - // @@ TODO Remove the need to cast HTML element to any - const box: any = dom.createElement('div') - - function setIt (newidURI) { - if (!newidURI) { - return - } - - // const uri = newidURI.uri || newidURI - // me = sym(uri) - me = authn.saveUser(newidURI) - box.refresh() - if (listener) listener(me!.uri) - } - - function logoutButtonHandler (_event) { - const oldMe = me - authSession.logout().then( - function () { - const message = `Your WebID was ${oldMe}. It has been forgotten.` - me = null - try { - alert(message) - } catch (_e) { - window.alert(message) - } - box.refresh() - if (listener) listener(null) - }, - (err) => { - alert('Fail to log out:' + err) - } - ) - } - - function logoutButton (me, options) { - const signInButtonStyle = options.buttonStyle || style.signInAndUpButtonStyle - let logoutLabel = 'WebID logout' - if (me) { - const nick = - solidLogicSingleton.store.any(me, ns.foaf('nick')) || - solidLogicSingleton.store.any(me, ns.foaf('name')) - if (nick) { - logoutLabel = 'Logout ' + nick.value - } - } - const signOutButton = dom.createElement('input') - // signOutButton.className = 'WebIDCancelButton' - signOutButton.setAttribute('type', 'button') - signOutButton.setAttribute('value', logoutLabel) - signOutButton.setAttribute('style', `${signInButtonStyle}`) - signOutButton.addEventListener('click', logoutButtonHandler, false) - return signOutButton - } - - box.refresh = function () { - const webId = authSession.webId - if (webId) { - me = solidLogicSingleton.store.sym(webId) - } else { - me = null - } - if ((me && box.me !== me.uri) || (!me && box.me)) { - widgets.clearElement(box) - if (me) { - box.appendChild(logoutButton(me, options)) - } else { - box.appendChild(signInOrSignUpBox(dom, setIt, options)) - } - } - box.me = me ? me.uri : null - } - box.refresh() - - function trackSession () { - me = authn.currentUser() - box.refresh() - } - trackSession() - - authSession.events.on('login', trackSession) - authSession.events.on('logout', trackSession) - box.me = '99999' // Force refresh - box.refresh() - return box -} - -authSession.events.on('logout', async () => { - const issuer = window.localStorage.getItem('loginIssuer') - if (issuer) { - try { - // clear authorization metadata from store - solidLogicSingleton.store.updater.flagAuthorizationMetadata() as any - - const wellKnownUri = new URL(issuer) - wellKnownUri.pathname = '/.well-known/openid-configuration' - const wellKnownResult = await fetch(wellKnownUri.toString()) - if (wellKnownResult.status === 200) { - const openidConfiguration = await wellKnownResult.json() - if (openidConfiguration && openidConfiguration.end_session_endpoint) { - await fetch(openidConfiguration.end_session_endpoint, { credentials: 'include' }) - } - } - - try { - await fetch('/.well-known/solid/logout', { credentials: 'include' }) - } catch (_err) { - // Not all deployments expose NSS-compatible well-known logout endpoint. - } - } catch (_err) { - // Do nothing - } - } - window.location.reload() -}) - -/** - * Workspace selection etc - * See https://github.com/solidos/userguide/issues/16 - */ - -/** - * Returns a UI object which, if it selects a workspace, - * will callback(workspace, newBase). - * See https://github.com/solidos/userguide/issues/16 for more info on workspaces. - * - * If necessary, will get an account, preference file, etc. In sequence: - * - * - If not logged in, log in. - * - Load preference file - * - Prompt user for workspaces - * - Allows the user to just type in a URI by hand - * - * Calls back with the workspace and the base URI - * - * @param dom - * @param appDetails - * @param callbackWS - */ -export function selectWorkspace ( - dom: HTMLDocument, - appDetails: AppDetails, - callbackWS: (workspace: string | null, newBase: string) => void -): HTMLElement { - const noun = appDetails.noun - const appPathSegment = appDetails.appPathSegment - - const me = offlineTestID() - const box = dom.createElement('div') - const context: AuthenticationContext = { me, dom, div: box } - - function say (s, background?) { - box.appendChild(widgets.errorMessageBlock(dom, s, background)) - } - - function figureOutBase (ws) { - const newBaseNode: NamedNode = solidLogicSingleton.store.any( - ws, - ns.space('uriPrefix') - ) as NamedNode - let newBaseString: string - if (!newBaseNode) { - newBaseString = ws.uri.split('#')[0] - } else { - newBaseString = newBaseNode.value - } - if (newBaseString.slice(-1) !== '/') { - debug.log(`${appPathSegment}: No / at end of uriPrefix ${newBaseString}`) // @@ paramater? - newBaseString = `${newBaseString}/` - } - const now = new Date() - newBaseString += `${appPathSegment}/id${now.getTime()}/` // unique id - return newBaseString - } - - function displayOptions (context) { - // console.log('displayOptions!', context) - async function makeNewWorkspace (_event) { - const row = table.appendChild(dom.createElement('tr')) - const cell = row.appendChild(dom.createElement('td')) - cell.setAttribute('colspan', '3') - cell.style.padding = '0.5em' - const newBase = encodeURI( - await widgets.askName( - dom, - solidLogicSingleton.store, - cell, - ns.solid('URL'), - ns.space('Workspace'), - 'Workspace' - ) - ) - const newWs = widgets.newThing(context.preferencesFile) - const newData = [ - st(context.me, ns.space('workspace'), newWs, context.preferencesFile), - - st( - newWs, - ns.space('uriPrefix'), - newBase as unknown as Quad_Object, - context.preferencesFile - ) - ] - if (!solidLogicSingleton.store.updater) { - throw new Error('store has no updater') - } - await solidLogicSingleton.store.updater.update([], newData) - // @@ now refresh list of workspaces - } - - // const status = '' - const id = context.me - const preferencesFile = context.preferencesFile - let newBase: any = null - - // A workspace specifically defined in the private preference file: - let w: any = solidLogicSingleton.store.each( - id, - ns.space('workspace'), - undefined, - preferencesFile - ) // Only trust preference file here - - // A workspace in a storage in the public profile: - const storages = solidLogicSingleton.store.each(id, ns.space('storage')) // @@ No provenance requirement at the moment - if (w.length === 0 && storages) { - say( - `You don't seem to have any workspaces. You have ${storages.length} storage spaces.`, - 'white' - ) - storages - .map(function (s: any) { - w = w.concat(solidLogicSingleton.store.each(s, ns.ldp('contains'))) - return w - }) - .filter((file) => { - return file.id ? ['public', 'private'].includes(file.id().toLowerCase()) : '' - }) - } - - if (w.length === 1) { - say(`Workspace used: ${w[0].uri}`, 'white') // @@ allow user to see URI - newBase = figureOutBase(w[0]) - // callbackWS(w[0], newBase) - // } else if (w.length === 0) { - } - - // Prompt for ws selection or creation - // say( w.length + " workspaces for " + id + "Choose one."); - const table = dom.createElement('table') - table.setAttribute('style', 'border-collapse:separate; border-spacing: 0.5em;') - - // const popup = window.open(undefined, '_blank', { height: 300, width:400 }, false) - box.appendChild(table) - - // Add a field for directly adding the URI yourself - - // const hr = box.appendChild(dom.createElement('hr')) // @@ - box.appendChild(dom.createElement('hr')) // @@ - - const p = box.appendChild(dom.createElement('p')) - p.setAttribute('style', style.commentStyle) - p.textContent = `Where would you like to store the data for the ${noun}? - Give the URL of the folder where you would like the data stored. - It can be anywhere in solid world - this URI is just an idea.` - // @@ TODO Remove the need to cast baseField to any - const baseField: any = box.appendChild(dom.createElement('input')) - baseField.setAttribute('type', 'text') - baseField.setAttribute('style', style.textInputStyle) - baseField.size = 80 // really a string - baseField.label = 'base URL' - baseField.autocomplete = 'on' - if (newBase) { - // set to default - baseField.value = newBase - } - - context.baseField = baseField - - box.appendChild(dom.createElement('br')) // @@ - - const button = box.appendChild(dom.createElement('button')) - button.setAttribute('style', style.buttonStyle) - button.textContent = `Start new ${noun} at this URI` - button.addEventListener('click', function (_event) { - let newBase = baseField.value.replace(' ', '%20') // do not re-encode in general, as % encodings may exist - if (newBase.slice(-1) !== '/') { - newBase += '/' - } - callbackWS(null, newBase) - }) - - // Now go set up the table of spaces - - // const row = 0 - w = w.filter(function (x) { - return !solidLogicSingleton.store.holds( - x, - ns.rdf('type'), // Ignore master workspaces - ns.space('MasterWorkspace') - ) - }) - let col1, col2, col3, tr, ws, localStyle, comment - const cellStyle = 'height: 3em; margin: 1em; padding: 1em white; border-radius: 0.3em;' - const deselectedStyle = `${cellStyle}border: 0px;` - // const selectedStyle = cellStyle + 'border: 1px solid black;' - for (let i = 0; i < w.length; i++) { - ws = w[i] - tr = dom.createElement('tr') - if (i === 0) { - col1 = dom.createElement('td') - col1.setAttribute('rowspan', `${w.length}`) - col1.textContent = 'Choose a workspace for this:' - col1.setAttribute('style', 'vertical-align:middle;') - tr.appendChild(col1) - } - col2 = dom.createElement('td') - localStyle = solidLogicSingleton.store.anyValue(ws, ns.ui('style')) - if (!localStyle) { - // Otherwise make up arbitrary colour - const hash = function (x) { - return x.split('').reduce(function (a, b) { - a = (a << 5) - a + b.charCodeAt(0) - return a & a - }, 0) - } - const bgcolor = `#${((hash(ws.uri) & 0xffffff) | 0xc0c0c0).toString(16)}` // c0c0c0 forces pale - localStyle = `color: black ; background-color: ${bgcolor};` - } - col2.setAttribute('style', deselectedStyle + localStyle) - tr.target = ws.uri - let label = solidLogicSingleton.store.any(ws, ns.rdfs('label')) - if (!label) { - label = ws.uri.split('/').slice(-1)[0] || ws.uri.split('/').slice(-2)[0] - } - col2.textContent = label || '???' - tr.appendChild(col2) - if (i === 0) { - col3 = dom.createElement('td') - col3.setAttribute('rowspan', `${w.length}1`) - // col3.textContent = '@@@@@ remove'; - col3.setAttribute('style', 'width:50%;') - tr.appendChild(col3) - } - table.appendChild(tr) - - comment = solidLogicSingleton.store.any(ws, ns.rdfs('comment')) - comment = comment ? comment.value : 'Use this workspace' - col2.addEventListener( - 'click', - function (_event) { - col3.textContent = comment ? comment.value : '' - col3.setAttribute('style', deselectedStyle + localStyle) - const button = dom.createElement('button') - button.textContent = 'Continue' - // button.setAttribute('style', style); - const newBase = figureOutBase(ws) - baseField.value = newBase // show user proposed URI - - button.addEventListener( - 'click', - function (_event) { - button.disabled = true - callbackWS(ws, newBase) - button.textContent = '---->' - }, - true - ) // capture vs bubble - col3.appendChild(button) - }, - true - ) // capture vs bubble - } - - // last line with "Make new workspace" - const trLast = dom.createElement('tr') - col2 = dom.createElement('td') - col2.setAttribute('style', cellStyle) - col2.textContent = '+ Make a new workspace' - col2.addEventListener('click', makeNewWorkspace) - trLast.appendChild(col2) - table.appendChild(trLast) - } // displayOptions - - // console.log('kicking off async operation') - ensureLoadedPreferences(context) // kick off async operation - .then(displayOptions) - .catch((err) => { - // console.log("err from async op") - box.appendChild(widgets.errorMessageBlock(context.dom, err)) - }) - - return box // return the box element, while login proceeds -} // selectWorkspace - -/** - * Creates a new instance of an app. - * - * An instance of an app could be e.g. an issue tracker for a given project, - * or a chess game, or calendar, or a health/fitness record for a person. - * - * Note that this use of the term 'app' refers more to entries in the user's - * type index than to actual software applications that use the personal data - * to which these entries point. - * - * @param dom - * @param appDetails - * @param callback - * - * @returns A div with a button in it for making a new app instance - */ -export function newAppInstance ( - dom: HTMLDocument, - appDetails: AppDetails, - callback: (workspace: string | null, newBase: string) => void -): HTMLElement { - const gotWS = function (ws, base) { - // log.debug("newAppInstance: Selected workspace = " + (ws? ws.uri : 'none')) - callback(ws, base) - } - const div = dom.createElement('div') - const b = dom.createElement('button') - b.setAttribute('type', 'button') - div.appendChild(b) - b.innerHTML = `Make new ${appDetails.noun}` - b.addEventListener( - 'click', - (_event) => { - div.appendChild(selectWorkspace(dom, appDetails, gotWS)) - }, - false - ) - div.appendChild(b) - return div -} -/** - * Retrieves whether the currently logged in user is a power user - * and/or a developer - */ -export async function getUserRoles (): Promise> { - const sessionInfo = authSession.info - if (!sessionInfo?.isLoggedIn || !sessionInfo?.webId) { - return [] - } - - const currentUser = authn.currentUser() - if (!currentUser) { - return [] - } - - try { - const { me, preferencesFile, preferencesFileError } = await ensureLoadedPreferences({ - me: currentUser - }) - if (!preferencesFile || preferencesFileError) { - throw new Error(preferencesFileError || 'Unable to load user preferences file.') - } - return solidLogicSingleton.store.each( - me, - ns.rdf('type'), - null, - preferencesFile.doc() - ) as NamedNode[] - } catch (error) { - debug.warn('Unable to fetch your preferences - this was the error: ', error) - } - return [] -} - -/** - * Filters which panes should be available, based on the result of [[getUserRoles]] - */ -export async function filterAvailablePanes ( - panes: Array -): Promise> { - const userRoles = await getUserRoles() - return panes.filter((pane) => isMatchingAudience(pane, userRoles)) -} - -function isMatchingAudience (pane: PaneDefinition, userRoles: Array): boolean { - const audience = pane.audience || [] - return audience.reduce( - (isMatch, audienceRole) => isMatch && !!userRoles.find((role) => role.equals(audienceRole)), - true as boolean - ) -} +/* eslint-disable camelcase */ +/** + * Signing in, signing up, profile and preferences reloading + * Type index management + * + * Many functions in this module take a context object which + * holds various RDF symbols, add to it, and return a promise of it. + * + * * `me` RDF symbol for the user's WebID + * * `publicProfile` The user's public profile, iff loaded + * * `preferencesFile` The user's personal preference file, iff loaded + * * `index.public` The user's public type index file + * * `index.private` The user's private type index file + * + * Not RDF symbols: + * * `noun` A string in english for the type of thing -- like "address book" + * * `instance` An array of nodes which are existing instances + * * `containers` An array of nodes of containers of instances + * * `div` A DOM element where UI can be displayed + * * `statusArea` A DOM element (opt) progress stuff can be displayed, or error messages + * * + * * Vocabulary: "load" loads a file if it exists; + * * 'Ensure" CREATES the file if it does not exist (if it can) and then loads it. + * @packageDocumentation + */ +import { PaneDefinition } from 'pane-registry' +import { BlankNode, NamedNode, st } from 'rdflib' + +import { Quad_Object } from 'rdflib/lib/tf-types' +import { + AppDetails, + AuthenticationContext, + authn, + authSession, + CrossOriginForbiddenError, + FetchError, + getSuggestedIssuers, + NotEditableError, + offlineTestID, + SameOriginForbiddenError, + solidLogicSingleton, + UnauthorizedError, + WebOperationError +} from 'solid-logic' +import * as debug from '../debug' +import { style } from '../style' +import { alert } from '../log' +import ns from '../ns' +import { Signup } from '../signup/signup.js' +import * as utils from '../utils' +import * as widgets from '../widgets' + +const store = solidLogicSingleton.store + +let oidcBootstrapInFlight: Promise | null = null + +function hasOidcCallbackParams (): boolean { + if (typeof window === 'undefined') { + return false + } + try { + const params = new URL(window.location.href).searchParams + return params.has('code') && params.has('state') + } catch (_err) { + return false + } +} + +function ensureOidcCallbackBootstrap (): Promise { + if (!hasOidcCallbackParams()) { + return Promise.resolve() + } + if (oidcBootstrapInFlight) { + return oidcBootstrapInFlight + } + + oidcBootstrapInFlight = (async () => { + try { + await authn.checkUser() + // Some auth stacks settle session state asynchronously after first check. + if (!authn.currentUser() && hasOidcCallbackParams()) { + await authn.checkUser() + } + } catch (err) { + debug.log('OIDC callback bootstrap failed in loginStatusBox: ' + err) + } finally { + oidcBootstrapInFlight = null + } + })() + + return oidcBootstrapInFlight +} + +const { + loadPreferences, + loadProfile +} = solidLogicSingleton.profile + +const { + getScopedAppInstances, + getRegistrations, + loadAllTypeIndexes, + getScopedAppsFromIndex, + deleteTypeIndexRegistration +} = solidLogicSingleton.typeIndex + +/** + * Resolves with the logged in user's WebID + * + * @param context + */ +// used to be logIn +export function ensureLoggedIn (context: AuthenticationContext): Promise { + const me = authn.currentUser() + if (me) { + authn.saveUser(me, context) + return Promise.resolve(context) + } + + return new Promise((resolve) => { + authn.checkUser().then((webId) => { + // Already logged in? + if (webId) { + debug.log(`logIn: Already logged in as ${webId}`) + return resolve(context) + } + if (!context.div || !context.dom) { + return resolve(context) + } + const box = loginStatusBox(context.dom, (webIdUri) => { + authn.saveUser(webIdUri, context) + resolve(context) // always pass growing context + }) + context.div.appendChild(box) + }) + }) +} + +/** + * Loads preference file + * Do this after having done log in and load profile + * + * @private + * + * @param context + */ +// used to be logInLoadPreferences +export async function ensureLoadedPreferences ( + context: AuthenticationContext +): Promise { + if (context.preferencesFile) return Promise.resolve(context) // already done + + // const statusArea = context.statusArea || context.div || null + let progressDisplay + /* COMPLAIN FUNCTION NOT USED/TAKING IT OUT FOR NOW + function complain (message) { + message = `ensureLoadedPreferences: ${message}` + if (statusArea) { + // statusArea.innerHTML = '' + statusArea.appendChild(widgets.errorMessageBlock(context.dom, message)) + } + debug.log(message) + // reject(new Error(message)) + } */ + try { + context = await ensureLoadedProfile(context) + + // console.log('back in Solid UI after logInLoadProfile', context) + const preferencesFile = await loadPreferences(context.me as NamedNode) + if (progressDisplay) { + progressDisplay.parentNode.removeChild(progressDisplay) + } + context.preferencesFile = preferencesFile + } catch (err) { + let m2: string + if (err instanceof UnauthorizedError) { + m2 = + 'Oops — you are not authenticated (properly logged in), so SolidOS cannot read your preferences file. Try logging out and then logging back in.' + alert(m2) + } else if (err instanceof CrossOriginForbiddenError) { + m2 = `Unauthorized: Assuming preference file blocked for origin ${window.location.origin}` + context.preferencesFileError = m2 + return context + } else if (err instanceof SameOriginForbiddenError) { + m2 = + 'You are not authorized to read your preference file. This may be because you are using an untrusted web app.' + debug.warn(m2) + return context + } else if (err instanceof NotEditableError) { + m2 = + 'You are not authorized to edit your preference file. This may be because you are using an untrusted web app.' + debug.warn(m2) + return context + } else if (err instanceof WebOperationError) { + m2 = + 'You are not authorized to edit your preference file. This may be because you are using an untrusted web app.' + debug.warn(m2) + } else if (err instanceof FetchError) { + m2 = `Strange: Error ${err.status} trying to read your preference file.${err.message}` + alert(m2) + } else { + throw new Error(`(via loadPrefs) ${err}`) + } + } + return context +} + +/** + * Logs the user in and loads their WebID profile document into the store + * + * @param context + * + * @returns Resolves with the context after login / fetch + */ +// used to be logInLoadProfile +export async function ensureLoadedProfile ( + context: AuthenticationContext +): Promise { + if (context.publicProfile) { + return context + } // already done + try { + const logInContext = await ensureLoggedIn(context) + if (!logInContext.me) { + throw new Error('Could not log in') + } + context.publicProfile = await loadProfile(logInContext.me) + } catch (err) { + if (context.div && context.dom) { + context.div.appendChild(widgets.errorMessageBlock(context.dom, err.message)) + } + throw new Error(`Can't log in: ${err}`) + } + return context +} + +/** + * Returns promise of context with arrays of symbols + * + * leaving the `isPublic` param undefined will bring in community index things, too + */ +export async function findAppInstances ( + context: AuthenticationContext, + theClass: NamedNode, + isPublic?: boolean +): Promise { + let items = context.me ? await getScopedAppInstances(theClass, context.me) : [] + if (isPublic === true) { // old API - not recommended! + items = items.filter(item => item.scope.label === 'public') + } else if (isPublic === false) { + items = items.filter(item => item.scope.label === 'private') + } + context.instances = items.map(item => item.instance) + return context +} + +export function scopeLabel (context, scope) { + const mine = context.me && context.me.sameTerm(scope.agent) + const name = mine ? '' : utils.label(scope.agent) + ' ' + return `${name}${scope.label}` +} +/** + * UI to control registration of instance + */ +export async function registrationControl ( + context: AuthenticationContext, + instance, + theClass +): Promise { + function registrationStatements (index) { + const registrations = getRegistrations(instance, theClass) + const reg = registrations.length ? registrations[0] : widgets.newThing(index) + return [ + st(reg, ns.solid('instance'), instance, index), + st(reg, ns.solid('forClass'), theClass, index) + ] + } + + function renderScopeCheckbox (scope) { + const statements = registrationStatements(scope.index) + const name = scopeLabel(context, scope) + const label = `${name} link to this ${context.noun}` + return widgets.buildCheckboxForm( + context.dom, + solidLogicSingleton.store, + label, + null, + statements, + form, + scope.index + ) + } + /// / body of registrationControl + const dom = context.dom + if (!dom || !context.div) { + throw new Error('registrationControl: need dom and div') + } + const box = dom.createElement('div') + context.div.appendChild(box) + context.me = authn.currentUser() // @@ + const me = context.me + if (!me) { + box.innerHTML = '

(Log in to save a link to this)

' + return context + } + + let scopes // @@ const + try { + scopes = await loadAllTypeIndexes(me) + } catch (e) { + let msg + if (context.div && context.preferencesFileError) { + msg = '(Lists of stuff not available)' + context.div.appendChild(dom.createElement('p')).textContent = msg + } else if (context.div) { + msg = `registrationControl: Type indexes not available: ${e}` + context.div.appendChild(widgets.errorMessageBlock(context.dom, e)) + } + debug.log(msg) + return context + } + + box.innerHTML = '
' // tbody will be inserted anyway + box.setAttribute('style', 'font-size: 120%; text-align: right; padding: 1em; border: solid gray 0.05em;') + const tbody = box.children[0].children[0] + const form = new BlankNode() // @@ say for now + + for (const scope of scopes) { + const row = tbody.appendChild(dom.createElement('tr')) + row.appendChild(renderScopeCheckbox(scope)) // @@ index + } + return context +} + +export function renderScopeHeadingRow (context, store, scope) { + const backgroundColor = { private: '#fee', public: '#efe' } + const { dom } = context + const name = scopeLabel(context, scope) + const row = dom.createElement('tr') + const cell = row.appendChild(dom.createElement('td')) + cell.setAttribute('colspan', '3') + cell.style.backgoundColor = backgroundColor[scope.label] || 'white' + const header = cell.appendChild(dom.createElement('h3')) + header.textContent = name + ' links' + header.style.textAlign = 'left' + return row +} +/** + * UI to List at all registered things + */ +export async function registrationList (context: AuthenticationContext, options: { + private?: boolean + public?: boolean + type?: NamedNode +}): Promise { + const dom = context.dom as HTMLDocument + const div = context.div as HTMLElement + + const box = dom.createElement('div') + div.appendChild(box) + context.me = authn.currentUser() // @@ + if (!context.me) { + box.innerHTML = '

(Log in list your stuff)

' + return context + } + + const scopes = await loadAllTypeIndexes(context.me) // includes community indexes + + // console.log('@@ registrationList ', scopes) + box.innerHTML = '
' // tbody will be inserted anyway + box.setAttribute('style', 'font-size: 120%; text-align: right; padding: 1em; border: solid #eee 0.5em;') + const table = box.firstChild as HTMLElement + const tbody = table.firstChild as HTMLElement + + for (const scope of scopes) { // need some predicate for listing/adding agents + const headingRow = renderScopeHeadingRow(context, store, scope) + tbody.appendChild(headingRow) + const items = await getScopedAppsFromIndex(scope, options.type || null) // any class + if (items.length === 0) headingRow.style.display = 'none' + // console.log(`registrationList: @@ instance items for class ${options.type || 'undefined' }:`, items) + for (const item of items) { + const row = widgets.personTR(dom, ns.solid('instance'), item.instance, { + deleteFunction: async () => { + await deleteTypeIndexRegistration(item) + tbody.removeChild(row) + } + }) + row.children[0].style.paddingLeft = '3em' + + tbody.appendChild(row) + } + } + return context +} // registrationList + +/** + * Bootstrapping identity + * (Called by `loginStatusBox()`) + * + * @param dom + * @param setUserCallback + * + * @returns + */ +function signInOrSignUpBox ( + dom: HTMLDocument, + setUserCallback: (user: string) => void, + options: { + buttonStyle?: string; + } = {} +): HTMLElement { + options = options || {} + const signInButtonStyle = options.buttonStyle || style.signInAndUpButtonStyle + + const box: any = dom.createElement('div') + const magicClassName = 'SolidSignInOrSignUpBox' + debug.log('widgets.signInOrSignUpBox') + box.setUserCallback = setUserCallback + box.setAttribute('class', magicClassName) + box.setAttribute('style', 'display:flex;') + + // Sign in button with PopUP + const signInPopUpButton = dom.createElement('input') // multi + box.appendChild(signInPopUpButton) + signInPopUpButton.setAttribute('type', 'button') + signInPopUpButton.setAttribute('value', 'Log in') + signInPopUpButton.setAttribute('style', `${signInButtonStyle}${style.headerBannerLoginInput}` + style.signUpBackground) + + authSession.events.on('login', () => { + const me = authn.currentUser() + // const sessionInfo = authSession.info + // if (sessionInfo && sessionInfo.isLoggedIn) { + if (me) { + // const webIdURI = sessionInfo.webId + const webIdURI = me.uri + // setUserCallback(webIdURI) + const divs = dom.getElementsByClassName(magicClassName) + debug.log(`Logged in, ${divs.length} panels to be serviced`) + // At the same time, satisfy all the other login boxes + for (let i = 0; i < divs.length; i++) { + const div: any = divs[i] + // @@ TODO Remove the need to manipulate HTML elements + if (div.setUserCallback) { + try { + div.setUserCallback(webIdURI) + const parent = div.parentNode + if (parent) { + parent.removeChild(div) + } + } catch (e) { + debug.log(`## Error satisfying login box: ${e}`) + div.appendChild(widgets.errorMessageBlock(dom, e)) + } + } + } + } + }) + + signInPopUpButton.addEventListener( + 'click', + () => { + const offline = offlineTestID() + if (offline) return setUserCallback(offline.uri) + + renderSignInPopup(dom) + }, + false + ) + + // Sign up button + const signupButton = dom.createElement('input') + box.appendChild(signupButton) + signupButton.setAttribute('type', 'button') + signupButton.setAttribute('value', 'Sign Up for Solid') + signupButton.setAttribute('style', `${signInButtonStyle}${style.headerBannerLoginInput}` + style.signInBackground) + + signupButton.addEventListener( + 'click', + function (_event) { + const signupMgr = new Signup() + signupMgr.signup().then(function (uri) { + debug.log('signInOrSignUpBox signed up ' + uri) + setUserCallback(uri) + }) + }, + false + ) + return box +} + +export function renderSignInPopup (dom: HTMLDocument) { + /** + * Issuer Menu + */ + const issuerPopup = dom.createElement('div') + issuerPopup.setAttribute( + 'style', + 'position: fixed; top: 0; left: 0; right: 0; bottom: 0; display: flex; justify-content: center; align-items: center;' + ) + dom.body.appendChild(issuerPopup) + const issuerPopupBox = dom.createElement('div') + issuerPopupBox.setAttribute( + 'style', + ` + background-color: white; + box-shadow: 0px 1px 4px rgba(0, 0, 0, 0.2); + -webkit-box-shadow: 0px 1px 4px rgba(0, 0, 0, 0.2); + -moz-box-shadow: 0px 1px 4px rgba(0, 0, 0, 0.2); + -o-box-shadow: 0px 1px 4px rgba(0, 0, 0, 0.2); + border-radius: 4px; + min-width: 400px; + padding: 10px; + z-index : 10; + ` + ) + issuerPopup.appendChild(issuerPopupBox) + const issuerPopupBoxTopMenu = dom.createElement('div') + issuerPopupBoxTopMenu.setAttribute( + 'style', + ` + border-bottom: 1px solid #DDD; + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + ` + ) + issuerPopupBox.appendChild(issuerPopupBoxTopMenu) + const issuerPopupBoxLabel = dom.createElement('label') + issuerPopupBoxLabel.setAttribute('style', 'margin-right: 5px; font-weight: 800') + issuerPopupBoxLabel.innerText = 'Select an identity provider' + const issuerPopupBoxCloseButton = dom.createElement('button') + issuerPopupBoxCloseButton.innerHTML = + '' + issuerPopupBoxCloseButton.setAttribute('style', 'background-color: transparent; border: none;') + issuerPopupBoxCloseButton.addEventListener('click', () => { + issuerPopup.remove() + }) + issuerPopupBoxTopMenu.appendChild(issuerPopupBoxLabel) + issuerPopupBoxTopMenu.appendChild(issuerPopupBoxCloseButton) + + const loginToIssuer = async (issuerUri: string) => { + try { + // clear authorization metadata from store + solidLogicSingleton.store.updater.flagAuthorizationMetadata() as any + // Save hash + const preLoginRedirectHash = new URL(window.location.href).hash + if (preLoginRedirectHash) { + window.localStorage.setItem('preLoginRedirectHash', preLoginRedirectHash) + } + window.localStorage.setItem('loginIssuer', issuerUri) + // Login + const locationUrl = new URL(window.location.href) + locationUrl.hash = '' // remove hash part + await authSession.login(issuerUri, locationUrl.href) + } catch (err) { + alert(err.message) + } + } + + /** + * Text-based idp selection + */ + const issuerTextContainer = dom.createElement('div') + issuerTextContainer.setAttribute( + 'style', + ` + border-bottom: 1px solid #DDD; + display: flex; + flex-direction: column; + padding-top: 10px; + ` + ) + const issuerTextInputContainer = dom.createElement('div') + issuerTextInputContainer.setAttribute( + 'style', + ` + display: flex; + flex-direction: row; + ` + ) + const issuerTextLabel = dom.createElement('label') + issuerTextLabel.innerText = 'Enter the URL of your identity provider:' + issuerTextLabel.setAttribute('style', 'color: #888') + const issuerTextInput = dom.createElement('input') + issuerTextInput.setAttribute('type', 'text') + issuerTextInput.setAttribute( + 'style', + 'margin-left: 0 !important; flex: 1; margin-right: 5px !important' + ) + issuerTextInput.setAttribute('placeholder', 'https://example.com') + issuerTextInput.value = localStorage.getItem('loginIssuer') || '' + const issuerTextGoButton = dom.createElement('button') + issuerTextGoButton.innerText = 'Go' + issuerTextGoButton.setAttribute('style', 'margin-top: 12px; margin-bottom: 12px;') + issuerTextGoButton.addEventListener('click', () => { + loginToIssuer(issuerTextInput.value) + }) + issuerTextContainer.appendChild(issuerTextLabel) + issuerTextInputContainer.appendChild(issuerTextInput) + issuerTextInputContainer.appendChild(issuerTextGoButton) + issuerTextContainer.appendChild(issuerTextInputContainer) + issuerPopupBox.appendChild(issuerTextContainer) + + /** + * Button-based idp selection + */ + const issuerButtonContainer = dom.createElement('div') + issuerButtonContainer.setAttribute( + 'style', + ` + display: flex; + flex-direction: column; + padding-top: 10px; + ` + ) + const issuerBottonLabel = dom.createElement('label') + issuerBottonLabel.innerText = 'Or pick an identity provider from the list below:' + issuerBottonLabel.setAttribute('style', 'color: #888') + issuerButtonContainer.appendChild(issuerBottonLabel) + getSuggestedIssuers().forEach((issuerInfo) => { + const issuerButton = dom.createElement('button') + issuerButton.innerText = issuerInfo.name + issuerButton.setAttribute('style', 'height: 38px; margin-top: 10px') + issuerButton.addEventListener('click', () => { + loginToIssuer(issuerInfo.uri) + }) + issuerButtonContainer.appendChild(issuerButton) + }) + issuerPopupBox.appendChild(issuerButtonContainer) +} + +/** + * Login status box + * + * A big sign-up/sign in box or a logout box depending on the state + * + * @param dom + * @param listener + * + * @returns + */ +export function loginStatusBox ( + dom: HTMLDocument, + listener: ((uri: string | null) => void) | null = null, + options: { + buttonStyle?: string; + } = {} +): HTMLElement { + // 20190630 + let me = offlineTestID() + // @@ TODO Remove the need to cast HTML element to any + const box: any = dom.createElement('div') + + function setIt (newidURI) { + if (!newidURI) { + return + } + + // const uri = newidURI.uri || newidURI + // me = sym(uri) + me = authn.saveUser(newidURI) + box.refresh() + if (listener) listener(me!.uri) + } + + function logoutButtonHandler (_event) { + const oldMe = me + authSession.logout().then( + function () { + const message = `Your WebID was ${oldMe}. It has been forgotten.` + me = null + try { + alert(message) + } catch (_e) { + window.alert(message) + } + box.refresh() + if (listener) listener(null) + }, + (err) => { + alert('Fail to log out:' + err) + } + ) + } + + function logoutButton (me, options) { + const signInButtonStyle = options.buttonStyle || style.signInAndUpButtonStyle + let logoutLabel = 'WebID logout' + if (me) { + const nick = + solidLogicSingleton.store.any(me, ns.foaf('nick')) || + solidLogicSingleton.store.any(me, ns.foaf('name')) + if (nick) { + logoutLabel = 'Logout ' + nick.value + } + } + const signOutButton = dom.createElement('input') + // signOutButton.className = 'WebIDCancelButton' + signOutButton.setAttribute('type', 'button') + signOutButton.setAttribute('value', logoutLabel) + signOutButton.setAttribute('style', `${signInButtonStyle}`) + signOutButton.addEventListener('click', logoutButtonHandler, false) + return signOutButton + } + + box.refresh = function () { + const webId = authSession.webId + if (webId) { + me = solidLogicSingleton.store.sym(webId) + } else { + me = null + } + if ((me && box.me !== me.uri) || (!me && box.me)) { + widgets.clearElement(box) + if (me) { + box.appendChild(logoutButton(me, options)) + } else { + box.appendChild(signInOrSignUpBox(dom, setIt, options)) + } + } + box.me = me ? me.uri : null + } + box.refresh() + + function trackSession () { + me = authn.currentUser() + box.refresh() + } + trackSession() + void ensureOidcCallbackBootstrap().then(trackSession) + + authSession.events.on('login', trackSession) + authSession.events.on('logout', trackSession) + box.me = '99999' // Force refresh + box.refresh() + return box +} + +authSession.events.on('logout', async () => { + const issuer = window.localStorage.getItem('loginIssuer') + if (issuer) { + try { + // clear authorization metadata from store + solidLogicSingleton.store.updater.flagAuthorizationMetadata() as any + + const wellKnownUri = new URL(issuer) + wellKnownUri.pathname = '/.well-known/openid-configuration' + const wellKnownResult = await fetch(wellKnownUri.toString()) + if (wellKnownResult.status === 200) { + const openidConfiguration = await wellKnownResult.json() + if (openidConfiguration && openidConfiguration.end_session_endpoint) { + await fetch(openidConfiguration.end_session_endpoint, { credentials: 'include' }) + } + } + + try { + await fetch('/.well-known/solid/logout', { credentials: 'include' }) + } catch (_err) { + // Not all deployments expose NSS-compatible well-known logout endpoint. + } + } catch (_err) { + // Do nothing + } + } + + // Prevent re-processing stale OIDC callback parameters after logout reload. + try { + const url = new URL(window.location.href) + url.searchParams.delete('code') + url.searchParams.delete('state') + url.searchParams.delete('iss') + history.replaceState(null, document.title, `${url.pathname}${url.search}${url.hash}`) + } catch (_err) { + // Keep current URL if normalization fails. + } + window.location.reload() +}) + +/** + * Workspace selection etc + * See https://github.com/solidos/userguide/issues/16 + */ + +/** + * Returns a UI object which, if it selects a workspace, + * will callback(workspace, newBase). + * See https://github.com/solidos/userguide/issues/16 for more info on workspaces. + * + * If necessary, will get an account, preference file, etc. In sequence: + * + * - If not logged in, log in. + * - Load preference file + * - Prompt user for workspaces + * - Allows the user to just type in a URI by hand + * + * Calls back with the workspace and the base URI + * + * @param dom + * @param appDetails + * @param callbackWS + */ +export function selectWorkspace ( + dom: HTMLDocument, + appDetails: AppDetails, + callbackWS: (workspace: string | null, newBase: string) => void +): HTMLElement { + const noun = appDetails.noun + const appPathSegment = appDetails.appPathSegment + + const me = offlineTestID() + const box = dom.createElement('div') + const context: AuthenticationContext = { me, dom, div: box } + + function say (s, background?) { + box.appendChild(widgets.errorMessageBlock(dom, s, background)) + } + + function figureOutBase (ws) { + const newBaseNode: NamedNode = solidLogicSingleton.store.any( + ws, + ns.space('uriPrefix') + ) as NamedNode + let newBaseString: string + if (!newBaseNode) { + newBaseString = ws.uri.split('#')[0] + } else { + newBaseString = newBaseNode.value + } + if (newBaseString.slice(-1) !== '/') { + debug.log(`${appPathSegment}: No / at end of uriPrefix ${newBaseString}`) // @@ paramater? + newBaseString = `${newBaseString}/` + } + const now = new Date() + newBaseString += `${appPathSegment}/id${now.getTime()}/` // unique id + return newBaseString + } + + function displayOptions (context) { + // console.log('displayOptions!', context) + async function makeNewWorkspace (_event) { + const row = table.appendChild(dom.createElement('tr')) + const cell = row.appendChild(dom.createElement('td')) + cell.setAttribute('colspan', '3') + cell.style.padding = '0.5em' + const newBase = encodeURI( + await widgets.askName( + dom, + solidLogicSingleton.store, + cell, + ns.solid('URL'), + ns.space('Workspace'), + 'Workspace' + ) + ) + const newWs = widgets.newThing(context.preferencesFile) + const newData = [ + st(context.me, ns.space('workspace'), newWs, context.preferencesFile), + + st( + newWs, + ns.space('uriPrefix'), + newBase as unknown as Quad_Object, + context.preferencesFile + ) + ] + if (!solidLogicSingleton.store.updater) { + throw new Error('store has no updater') + } + await solidLogicSingleton.store.updater.update([], newData) + // @@ now refresh list of workspaces + } + + // const status = '' + const id = context.me + const preferencesFile = context.preferencesFile + let newBase: any = null + + // A workspace specifically defined in the private preference file: + let w: any = solidLogicSingleton.store.each( + id, + ns.space('workspace'), + undefined, + preferencesFile + ) // Only trust preference file here + + // A workspace in a storage in the public profile: + const storages = solidLogicSingleton.store.each(id, ns.space('storage')) // @@ No provenance requirement at the moment + if (w.length === 0 && storages) { + say( + `You don't seem to have any workspaces. You have ${storages.length} storage spaces.`, + 'white' + ) + storages + .map(function (s: any) { + w = w.concat(solidLogicSingleton.store.each(s, ns.ldp('contains'))) + return w + }) + .filter((file) => { + return file.id ? ['public', 'private'].includes(file.id().toLowerCase()) : '' + }) + } + + if (w.length === 1) { + say(`Workspace used: ${w[0].uri}`, 'white') // @@ allow user to see URI + newBase = figureOutBase(w[0]) + // callbackWS(w[0], newBase) + // } else if (w.length === 0) { + } + + // Prompt for ws selection or creation + // say( w.length + " workspaces for " + id + "Choose one."); + const table = dom.createElement('table') + table.setAttribute('style', 'border-collapse:separate; border-spacing: 0.5em;') + + // const popup = window.open(undefined, '_blank', { height: 300, width:400 }, false) + box.appendChild(table) + + // Add a field for directly adding the URI yourself + + // const hr = box.appendChild(dom.createElement('hr')) // @@ + box.appendChild(dom.createElement('hr')) // @@ + + const p = box.appendChild(dom.createElement('p')) + p.setAttribute('style', style.commentStyle) + p.textContent = `Where would you like to store the data for the ${noun}? + Give the URL of the folder where you would like the data stored. + It can be anywhere in solid world - this URI is just an idea.` + // @@ TODO Remove the need to cast baseField to any + const baseField: any = box.appendChild(dom.createElement('input')) + baseField.setAttribute('type', 'text') + baseField.setAttribute('style', style.textInputStyle) + baseField.size = 80 // really a string + baseField.label = 'base URL' + baseField.autocomplete = 'on' + if (newBase) { + // set to default + baseField.value = newBase + } + + context.baseField = baseField + + box.appendChild(dom.createElement('br')) // @@ + + const button = box.appendChild(dom.createElement('button')) + button.setAttribute('style', style.buttonStyle) + button.textContent = `Start new ${noun} at this URI` + button.addEventListener('click', function (_event) { + let newBase = baseField.value.replace(' ', '%20') // do not re-encode in general, as % encodings may exist + if (newBase.slice(-1) !== '/') { + newBase += '/' + } + callbackWS(null, newBase) + }) + + // Now go set up the table of spaces + + // const row = 0 + w = w.filter(function (x) { + return !solidLogicSingleton.store.holds( + x, + ns.rdf('type'), // Ignore master workspaces + ns.space('MasterWorkspace') + ) + }) + let col1, col2, col3, tr, ws, localStyle, comment + const cellStyle = 'height: 3em; margin: 1em; padding: 1em white; border-radius: 0.3em;' + const deselectedStyle = `${cellStyle}border: 0px;` + // const selectedStyle = cellStyle + 'border: 1px solid black;' + for (let i = 0; i < w.length; i++) { + ws = w[i] + tr = dom.createElement('tr') + if (i === 0) { + col1 = dom.createElement('td') + col1.setAttribute('rowspan', `${w.length}`) + col1.textContent = 'Choose a workspace for this:' + col1.setAttribute('style', 'vertical-align:middle;') + tr.appendChild(col1) + } + col2 = dom.createElement('td') + localStyle = solidLogicSingleton.store.anyValue(ws, ns.ui('style')) + if (!localStyle) { + // Otherwise make up arbitrary colour + const hash = function (x) { + return x.split('').reduce(function (a, b) { + a = (a << 5) - a + b.charCodeAt(0) + return a & a + }, 0) + } + const bgcolor = `#${((hash(ws.uri) & 0xffffff) | 0xc0c0c0).toString(16)}` // c0c0c0 forces pale + localStyle = `color: black ; background-color: ${bgcolor};` + } + col2.setAttribute('style', deselectedStyle + localStyle) + tr.target = ws.uri + let label = solidLogicSingleton.store.any(ws, ns.rdfs('label')) + if (!label) { + label = ws.uri.split('/').slice(-1)[0] || ws.uri.split('/').slice(-2)[0] + } + col2.textContent = label || '???' + tr.appendChild(col2) + if (i === 0) { + col3 = dom.createElement('td') + col3.setAttribute('rowspan', `${w.length}1`) + // col3.textContent = '@@@@@ remove'; + col3.setAttribute('style', 'width:50%;') + tr.appendChild(col3) + } + table.appendChild(tr) + + comment = solidLogicSingleton.store.any(ws, ns.rdfs('comment')) + comment = comment ? comment.value : 'Use this workspace' + col2.addEventListener( + 'click', + function (_event) { + col3.textContent = comment ? comment.value : '' + col3.setAttribute('style', deselectedStyle + localStyle) + const button = dom.createElement('button') + button.textContent = 'Continue' + // button.setAttribute('style', style); + const newBase = figureOutBase(ws) + baseField.value = newBase // show user proposed URI + + button.addEventListener( + 'click', + function (_event) { + button.disabled = true + callbackWS(ws, newBase) + button.textContent = '---->' + }, + true + ) // capture vs bubble + col3.appendChild(button) + }, + true + ) // capture vs bubble + } + + // last line with "Make new workspace" + const trLast = dom.createElement('tr') + col2 = dom.createElement('td') + col2.setAttribute('style', cellStyle) + col2.textContent = '+ Make a new workspace' + col2.addEventListener('click', makeNewWorkspace) + trLast.appendChild(col2) + table.appendChild(trLast) + } // displayOptions + + // console.log('kicking off async operation') + ensureLoadedPreferences(context) // kick off async operation + .then(displayOptions) + .catch((err) => { + // console.log("err from async op") + box.appendChild(widgets.errorMessageBlock(context.dom, err)) + }) + + return box // return the box element, while login proceeds +} // selectWorkspace + +/** + * Creates a new instance of an app. + * + * An instance of an app could be e.g. an issue tracker for a given project, + * or a chess game, or calendar, or a health/fitness record for a person. + * + * Note that this use of the term 'app' refers more to entries in the user's + * type index than to actual software applications that use the personal data + * to which these entries point. + * + * @param dom + * @param appDetails + * @param callback + * + * @returns A div with a button in it for making a new app instance + */ +export function newAppInstance ( + dom: HTMLDocument, + appDetails: AppDetails, + callback: (workspace: string | null, newBase: string) => void +): HTMLElement { + const gotWS = function (ws, base) { + // log.debug("newAppInstance: Selected workspace = " + (ws? ws.uri : 'none')) + callback(ws, base) + } + const div = dom.createElement('div') + const b = dom.createElement('button') + b.setAttribute('type', 'button') + div.appendChild(b) + b.innerHTML = `Make new ${appDetails.noun}` + b.addEventListener( + 'click', + (_event) => { + div.appendChild(selectWorkspace(dom, appDetails, gotWS)) + }, + false + ) + div.appendChild(b) + return div +} +/** + * Retrieves whether the currently logged in user is a power user + * and/or a developer + */ +export async function getUserRoles (): Promise> { + const sessionInfo = authSession.info + if (!sessionInfo?.isLoggedIn || !sessionInfo?.webId) { + return [] + } + + const currentUser = authn.currentUser() + if (!currentUser) { + return [] + } + + try { + const { me, preferencesFile, preferencesFileError } = await ensureLoadedPreferences({ + me: currentUser + }) + if (!preferencesFile || preferencesFileError) { + throw new Error(preferencesFileError || 'Unable to load user preferences file.') + } + return solidLogicSingleton.store.each( + me, + ns.rdf('type'), + null, + preferencesFile.doc() + ) as NamedNode[] + } catch (error) { + debug.warn('Unable to fetch your preferences - this was the error: ', error) + } + return [] +} + +/** + * Filters which panes should be available, based on the result of [[getUserRoles]] + */ +export async function filterAvailablePanes ( + panes: Array +): Promise> { + const userRoles = await getUserRoles() + return panes.filter((pane) => isMatchingAudience(pane, userRoles)) +} + +function isMatchingAudience (pane: PaneDefinition, userRoles: Array): boolean { + const audience = pane.audience || [] + return audience.reduce( + (isMatch, audienceRole) => isMatch && !!userRoles.find((role) => role.equals(audienceRole)), + true as boolean + ) +}