From e7fc9b6c872d1e5e02b50b807138e042e73494fc Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Thu, 11 Jun 2026 17:56:53 +0200 Subject: [PATCH 01/13] resolve: finalize rebase conflicts in jest config and tsconfig --- jest.config.mjs | 4 +- src/login/login.ts | 11 +-- .../auth/loginButton/LoginButton.ts | 5 +- src/v2/components/layout/footer/Footer.ts | 3 - test/mocks/solid-oidc-client-browser.ts | 73 +++++++++++++++++++ tsconfig.json | 4 +- 6 files changed, 84 insertions(+), 16 deletions(-) create mode 100644 test/mocks/solid-oidc-client-browser.ts diff --git a/jest.config.mjs b/jest.config.mjs index 875c85280..aa7781395 100644 --- a/jest.config.mjs +++ b/jest.config.mjs @@ -16,7 +16,9 @@ export default { setupFilesAfterEnv: ['./test/helpers/setup.ts'], moduleNameMapper: { '^~icons/(.*)$': '/__mocks__/iconsMock.js', - '^.+\\.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 6f7dfe74a..ec4a4f75c 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/auth/loginButton/LoginButton.ts b/src/v2/components/auth/loginButton/LoginButton.ts index e1aa22cd9..bb27879a6 100644 --- a/src/v2/components/auth/loginButton/LoginButton.ts +++ b/src/v2/components/auth/loginButton/LoginButton.ts @@ -419,10 +419,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/src/v2/components/layout/footer/Footer.ts b/src/v2/components/layout/footer/Footer.ts index 2d0158fc0..cdbe3ff71 100644 --- a/src/v2/components/layout/footer/Footer.ts +++ b/src/v2/components/layout/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/test/mocks/solid-oidc-client-browser.ts b/test/mocks/solid-oidc-client-browser.ts new file mode 100644 index 000000000..bebc302e6 --- /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 c557f221a..fd4e064a4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -64,7 +64,9 @@ ] /* 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. */ + "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 20b1ef675fbda902f7fff3c136855bf2e01b40a6 Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Sun, 24 May 2026 19:59:36 +0200 Subject: [PATCH 02/13] 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/layout/header/Header.ts | 62 +++++++++++++++-- .../components/layout/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 ec4a4f75c..fdbace809 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/layout/header/Header.ts b/src/v2/components/layout/header/Header.ts index b034d5b89..39cd59a46 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 } from 'solid-logic' import '../../auth/loginButton/index' import '../../auth/signupButton/index' import { ifDefined } from 'lit/directives/if-defined.js' @@ -514,6 +514,9 @@ export class Header extends LitElement { declare helpMenuOpen: boolean declare hasSlottedAccountMenu: boolean declare hasSlottedHelpMenu: boolean + private readonly handleAuthSessionChange = () => { + this.refreshAuthStateFromSession() + } constructor () { super() @@ -544,14 +547,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 @@ -669,8 +692,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, @@ -680,12 +703,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, @@ -693,6 +721,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/layout/header/header.test.ts b/src/v2/components/layout/header/header.test.ts index 48a46fbca..3129744f4 100644 --- a/src/v2/components/layout/header/header.test.ts +++ b/src/v2/components/layout/header/header.test.ts @@ -1,11 +1,41 @@ import Features from '../../../../features' 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(() => { Features.DESIGN_SYSTEM_HEADER_ACCOUNT = false 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, @@ -79,6 +109,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' @@ -107,6 +139,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 = '' @@ -125,6 +158,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' @@ -175,6 +209,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' @@ -198,6 +233,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 = [ @@ -265,6 +301,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() @@ -306,4 +343,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 f7a65538a94e278c97e6eb07709cc121eb6080ff Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Fri, 29 May 2026 12:02:08 +0200 Subject: [PATCH 03/13] 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 39cd59a46..2cdf266c9 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, authn } 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' @@ -14,6 +14,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 = { @@ -51,7 +81,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` @@ -514,8 +545,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 () { @@ -541,6 +575,7 @@ export class Header extends LitElement { this.helpMenuOpen = false this.hasSlottedAccountMenu = false this.hasSlottedHelpMenu = false + this.authResolved = false } connectedCallback () { @@ -552,7 +587,7 @@ export class Header extends LitElement { authSession.events.on('logout', this.handleAuthSessionChange) authSession.events.on('sessionRestore', this.handleAuthSessionChange) } - this.refreshAuthStateFromSession() + void this.refreshAuthStateFromSession() } disconnectedCallback () { @@ -567,12 +602,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) { @@ -711,7 +762,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', { @@ -721,32 +780,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')} @@ -856,6 +889,10 @@ export class Header extends LitElement { ` } + 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 3129744f4..f5b9ac21e 100644 --- a/src/v2/components/layout/header/header.test.ts +++ b/src/v2/components/layout/header/header.test.ts @@ -11,6 +11,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: { @@ -357,6 +358,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 5ea02597c6784cc646979a2d7cca572ae31e2337 Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Fri, 29 May 2026 14:43:30 +0200 Subject: [PATCH 04/13] 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 aa7781395..4522c5ff0 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, @@ -17,7 +28,7 @@ export default { moduleNameMapper: { '^~icons/(.*)$': '/__mocks__/iconsMock.js', '^.+\\.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 c0e04840b4e02291e64cd943bef35ffbb9ee27d3 Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Fri, 29 May 2026 15:06:51 +0200 Subject: [PATCH 05/13] 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 2cdf266c9..1c293fa2b 100644 --- a/src/v2/components/layout/header/Header.ts +++ b/src/v2/components/layout/header/Header.ts @@ -549,7 +549,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 () { @@ -587,7 +589,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 a1e28f2ec7d01763da0484b8de3458050af350d1 Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Fri, 29 May 2026 15:19:42 +0200 Subject: [PATCH 06/13] 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 f5b9ac21e..6f6d3c76d 100644 --- a/src/v2/components/layout/header/header.test.ts +++ b/src/v2/components/layout/header/header.test.ts @@ -30,6 +30,12 @@ jest.mock('solid-logic', () => ({ })) describe('SolidUIHeaderElement', () => { + async function waitForAuthRefresh (header: Header): Promise { + await Promise.resolve() + await Promise.resolve() + await header.updateComplete + } + beforeEach(() => { Features.DESIGN_SYSTEM_HEADER_ACCOUNT = false document.body.innerHTML = '' @@ -55,6 +61,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 = '' @@ -85,6 +92,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' @@ -110,8 +118,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' @@ -121,6 +128,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' } @@ -143,6 +151,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' @@ -162,6 +171,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' @@ -213,6 +223,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' @@ -237,6 +248,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' } ] @@ -307,6 +319,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' }] @@ -351,8 +364,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') @@ -386,14 +398,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 ddf00300308530d9cf26d026f34b6adfb7420879 Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Tue, 2 Jun 2026 23:05:37 +0200 Subject: [PATCH 07/13] 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 fdbace809..c40492498 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 + ) +} From 225295c154b6a5bb6637d2c7bfa6250bdcad6234 Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Thu, 4 Jun 2026 11:36:00 +0200 Subject: [PATCH 08/13] Revert "fix(login): finalize OIDC callback in loginStatusBox and avoid logout callback loops" This reverts commit e04a7e7758ea770c87d9a9ba67e1d4cda301ed1c. --- src/login/login.ts | 2247 ++++++++++++++++++++++---------------------- 1 file changed, 1098 insertions(+), 1149 deletions(-) diff --git a/src/login/login.ts b/src/login/login.ts index c40492498..fdbace809 100644 --- a/src/login/login.ts +++ b/src/login/login.ts @@ -1,1149 +1,1098 @@ -/* 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 - ) -} +/* 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 + ) +} From d373fb03317c954b908cbc22bacc5dc1860886f8 Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Thu, 4 Jun 2026 14:57:13 +0200 Subject: [PATCH 09/13] browse.html login issue --- src/login/login.ts | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/src/login/login.ts b/src/login/login.ts index fdbace809..36c1bc9af 100644 --- a/src/login/login.ts +++ b/src/login/login.ts @@ -162,6 +162,8 @@ export async function ensureLoadedPreferences ( } else { throw new Error(`(via loadPrefs) ${err}`) } + + context.preferencesFileError = m2 } return context } @@ -1050,22 +1052,10 @@ export function newAppInstance ( * 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 - }) + const { me, preferencesFile, preferencesFileError } = await ensureLoadedPreferences({}) if (!preferencesFile || preferencesFileError) { - throw new Error(preferencesFileError || 'Unable to load user preferences file.') + throw new Error(preferencesFileError) } return solidLogicSingleton.store.each( me, From 02cef1d747cd7390fb3cf66c0374d9c67aafeeb4 Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Thu, 4 Jun 2026 15:15:45 +0200 Subject: [PATCH 10/13] 3.1.2 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index a92b085ba..e3b4509b1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "solid-ui", - "version": "3.1.1", + "version": "3.1.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "solid-ui", - "version": "3.1.1", + "version": "3.1.2", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 525c05ccf..076990482 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "solid-ui", - "version": "3.1.1", + "version": "3.1.2", "description": "UI library for Solid applications", "main": "dist/solid-ui.js", "types": "dist/index.d.ts", From 166965eba05e9c567e618f76b77607eec34b29a0 Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Fri, 12 Jun 2026 02:50:46 +0200 Subject: [PATCH 11/13] update design-system auth workflow to UVDSL --- src/design-system/lib/auth/SolidAuth.ts | 36 ++++++++++++++++--------- 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/src/design-system/lib/auth/SolidAuth.ts b/src/design-system/lib/auth/SolidAuth.ts index 31e272ec8..c7b2ad939 100644 --- a/src/design-system/lib/auth/SolidAuth.ts +++ b/src/design-system/lib/auth/SolidAuth.ts @@ -13,11 +13,13 @@ export default class SolidAuth implements AuthContext { constructor (public signupUrl: string = DEFAULT_SIGNUP_URL) {} get account (): Account | null { - if (!authSession.info?.isLoggedIn || !authSession.info?.webId) { + const sessionAny = authSession as any + const webId: string | undefined = sessionAny.webId ?? sessionAny.info?.webId + const isActive: boolean = sessionAny.isActive ?? sessionAny.info?.isLoggedIn ?? Boolean(webId) + if (!isActive || !webId) { return null } - const webId = authSession.info.webId const me = solidLogicSingleton.store.sym(webId) const avatarUrl = findImage(me) ?? undefined @@ -44,10 +46,7 @@ export default class SolidAuth implements AuthContext { locationUrl.hash = '' - await authSession.login({ - redirectUrl: locationUrl.href, - oidcIssuer: loginUrl - }) + await (authSession as any).login(loginUrl, locationUrl.href) } async signup () { @@ -59,14 +58,27 @@ export default class SolidAuth implements AuthContext { } onSessionUpdated (callback: () => unknown) { - authSession.events.on('login', callback) - authSession.events.on('logout', callback) - authSession.events.on('sessionRestore', callback) + const sessionEventTarget = authSession as unknown as EventTarget + const sessionAny = authSession as any + const listener = () => { + callback() + } + if (typeof sessionEventTarget.addEventListener === 'function') { + sessionEventTarget.addEventListener('sessionStateChange', listener) + } else { + sessionAny.events.on('login', callback) + sessionAny.events.on('logout', callback) + sessionAny.events.on('sessionRestore', callback) + } return () => { - authSession.events.off('login', callback) - authSession.events.off('logout', callback) - authSession.events.off('sessionRestore', callback) + if (typeof sessionEventTarget.removeEventListener === 'function') { + sessionEventTarget.removeEventListener('sessionStateChange', listener) + } else { + sessionAny.events.off('login', callback) + sessionAny.events.off('logout', callback) + sessionAny.events.off('sessionRestore', callback) + } } } } From f146f976e4a4198327cc63a4327a5d8dc7da00a7 Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Fri, 12 Jun 2026 03:34:25 +0200 Subject: [PATCH 12/13] Title: fix(auth): decouple SolidAuth from widget import chain and complete UVDSL session migration Body: complete UVDSL auth contract migration in the design-system auth adapter: use session root fields for state resolution (webId / isActive) keep compatibility fallback to legacy info fields during transition switch login call to positional signature (issuer, redirectUrl) switch session updates to EventTarget sessionStateChange with legacy events fallback remove transitive dependency on the heavy widget stack from auth bootstrap path: stop importing avatar lookup from widgets/buttons in auth adapter add a lightweight local avatar resolver using RDF predicates already used elsewhere fix test bootstrap regression where importing auth pulled in people picker and touched solidLogicSingleton.store too early under Jest preserve existing login UX behavior: keep loginIssuer and preLoginRedirectHash persistence keep authorization metadata reset before login redirect --- src/design-system/lib/auth/SolidAuth.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/design-system/lib/auth/SolidAuth.ts b/src/design-system/lib/auth/SolidAuth.ts index c7b2ad939..52140af5b 100644 --- a/src/design-system/lib/auth/SolidAuth.ts +++ b/src/design-system/lib/auth/SolidAuth.ts @@ -1,14 +1,28 @@ import { authSession, solidLogicSingleton } from 'solid-logic' import Account from '../../../primitives/lib/auth/Account' -import { findImage } from '../../../widgets/buttons' import { AuthContext } from '../../../primitives/lib/auth/context' import { showDialog } from '../dialogs' import { html } from 'lit' +import ns from '../../../ns' import '../../components/login-modal' export const DEFAULT_SIGNUP_URL = 'https://solidproject.org/get_a_pod' +function findAccountImage (webId: string): string | undefined { + const store = solidLogicSingleton.store + const me = store.sym(webId) + const image = + store.any(me, ns.sioc('avatar')) || + store.any(me, ns.foaf('img')) || + store.any(me, ns.vcard('logo')) || + store.any(me, ns.vcard('hasPhoto')) || + store.any(me, ns.vcard('photo')) || + store.any(me, ns.foaf('depiction')) + + return image ? (image as any).value : undefined +} + export default class SolidAuth implements AuthContext { constructor (public signupUrl: string = DEFAULT_SIGNUP_URL) {} @@ -20,8 +34,7 @@ export default class SolidAuth implements AuthContext { return null } - const me = solidLogicSingleton.store.sym(webId) - const avatarUrl = findImage(me) ?? undefined + const avatarUrl = findAccountImage(webId) return new Account(webId, avatarUrl) } From bd2212d72e580cd73d120f7c95cab19e3892de26 Mon Sep 17 00:00:00 2001 From: bourgeoa Date: Fri, 12 Jun 2026 14:29:49 +0200 Subject: [PATCH 13/13] add missing function and typed authSession --- src/design-system/lib/auth/SolidAuth.ts | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/src/design-system/lib/auth/SolidAuth.ts b/src/design-system/lib/auth/SolidAuth.ts index 52140af5b..736999a0a 100644 --- a/src/design-system/lib/auth/SolidAuth.ts +++ b/src/design-system/lib/auth/SolidAuth.ts @@ -27,9 +27,8 @@ export default class SolidAuth implements AuthContext { constructor (public signupUrl: string = DEFAULT_SIGNUP_URL) {} get account (): Account | null { - const sessionAny = authSession as any - const webId: string | undefined = sessionAny.webId ?? sessionAny.info?.webId - const isActive: boolean = sessionAny.isActive ?? sessionAny.info?.isLoggedIn ?? Boolean(webId) + const webId: string | undefined = authSession.webId ?? authSession.info?.webId + const isActive: boolean = authSession.isActive ?? authSession.info?.isLoggedIn ?? Boolean(webId) if (!isActive || !webId) { return null } @@ -59,7 +58,7 @@ export default class SolidAuth implements AuthContext { locationUrl.hash = '' - await (authSession as any).login(loginUrl, locationUrl.href) + await authSession.login(loginUrl, locationUrl.href) } async signup () { @@ -72,25 +71,24 @@ export default class SolidAuth implements AuthContext { onSessionUpdated (callback: () => unknown) { const sessionEventTarget = authSession as unknown as EventTarget - const sessionAny = authSession as any const listener = () => { callback() } if (typeof sessionEventTarget.addEventListener === 'function') { sessionEventTarget.addEventListener('sessionStateChange', listener) } else { - sessionAny.events.on('login', callback) - sessionAny.events.on('logout', callback) - sessionAny.events.on('sessionRestore', callback) + authSession.events.on('login', callback) + authSession.events.on('logout', callback) + authSession.events.on('sessionRestore', callback) } return () => { if (typeof sessionEventTarget.removeEventListener === 'function') { sessionEventTarget.removeEventListener('sessionStateChange', listener) } else { - sessionAny.events.off('login', callback) - sessionAny.events.off('logout', callback) - sessionAny.events.off('sessionRestore', callback) + authSession.events.off('login', callback) + authSession.events.off('logout', callback) + authSession.events.off('sessionRestore', callback) } } }