Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion jest.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ export default {
],
setupFilesAfterEnv: ['./test/helpers/setup.ts'],
moduleNameMapper: {
'^.+\\.css$': '<rootDir>/__mocks__/styleMock.js'
'^.+\\.css$': '<rootDir>/__mocks__/styleMock.js',
'^solid-logic$': '<rootDir>/../solid-logic/src',
'^@uvdsl/solid-oidc-client-browser$': '<rootDir>/test/mocks/solid-oidc-client-browser.ts'
},
Comment on lines 17 to 21
testMatch: ['**/?(*.)+(spec|test).[tj]s?(x)'],
roots: ['<rootDir>/src', '<rootDir>/test', '<rootDir>/__mocks__'],
Expand Down
17 changes: 10 additions & 7 deletions src/login/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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
}
Comment on lines 668 to 674
Expand Down Expand Up @@ -716,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
}
Expand Down
3 changes: 0 additions & 3 deletions src/v2/components/footer/Footer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down
62 changes: 58 additions & 4 deletions src/v2/components/header/Header.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { LitElement, html, css } from 'lit'
import { icons } from '../../../iconBase'
import { authSession } from 'solid-logic'
import { authSession, authn } from 'solid-logic'
import '../loginButton/index'
import '../signupButton/index'
import { ifDefined } from 'lit/directives/if-defined.js'
Expand Down Expand Up @@ -510,6 +510,9 @@ export class Header extends LitElement {
declare helpMenuOpen: boolean
declare hasSlottedAccountMenu: boolean
declare hasSlottedHelpMenu: boolean
private readonly handleAuthSessionChange = () => {
this.refreshAuthStateFromSession()
}

constructor () {
super()
Expand Down Expand Up @@ -540,14 +543,34 @@ export class Header extends LitElement {
super.connectedCallback()
document.addEventListener('click', this.handleDocumentClick)
window.addEventListener('keydown', this.handleWindowKeydown)
if (typeof authSession.events?.on === 'function') {
authSession.events.on('login', this.handleAuthSessionChange)
authSession.events.on('logout', this.handleAuthSessionChange)
authSession.events.on('sessionRestore', this.handleAuthSessionChange)
}
this.refreshAuthStateFromSession()
}

disconnectedCallback () {
document.removeEventListener('click', this.handleDocumentClick)
window.removeEventListener('keydown', this.handleWindowKeydown)
if (typeof authSession.events?.off === 'function') {
authSession.events.off('login', this.handleAuthSessionChange)
authSession.events.off('logout', this.handleAuthSessionChange)
authSession.events.off('sessionRestore', this.handleAuthSessionChange)
}
super.disconnectedCallback()
}

private async refreshAuthStateFromSession () {
try {
await authn.checkUser()
} catch (_err) {
// Keep rendering even if session refresh cannot complete.
}
this.authState = authn.currentUser() ? 'logged-in' : 'logged-out'
}

private handleHelpMenuClick (item: HeaderMenuItem, event: MouseEvent) {
event.preventDefault()
this.helpMenuOpen = false
Expand Down Expand Up @@ -665,8 +688,8 @@ export class Header extends LitElement {
`
}

private handleLoginSuccess () {
this.authState = 'logged-in'
private async handleLoginSuccess () {
await this.refreshAuthStateFromSession()
this.dispatchEvent(new CustomEvent('auth-action-select', {
detail: { role: 'login' },
bubbles: true,
Expand All @@ -676,19 +699,50 @@ 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,
composed: true
}))
}

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')}
Expand Down
68 changes: 68 additions & 0 deletions src/v2/components/header/header.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,39 @@
import { Header } from './Header'
import './index'
import { authn, authSession } from 'solid-logic'

type Listener = () => void
const mockSessionListeners = new Map<string, Set<Listener>>()

jest.mock('solid-logic', () => ({
authn: {
checkUser: jest.fn(async () => null),
currentUser: jest.fn(() => null)
},
authSession: {
logout: jest.fn(async () => undefined),
events: {
on: jest.fn((event: string, handler: Listener) => {
if (!mockSessionListeners.has(event)) mockSessionListeners.set(event, new Set())
mockSessionListeners.get(event)?.add(handler)
}),
off: jest.fn((event: string, handler: Listener) => {
mockSessionListeners.get(event)?.delete(handler)
}),
emit: jest.fn((event: string) => {
mockSessionListeners.get(event)?.forEach(handler => handler())
})
}
}
}))

describe('SolidUIHeaderElement', () => {
beforeEach(() => {
document.body.innerHTML = ''
jest.clearAllMocks()
mockSessionListeners.clear()
;(authn.currentUser as jest.Mock).mockReturnValue(null)
;(authn.checkUser as jest.Mock).mockResolvedValue(null)
Object.defineProperty(window, 'open', {
configurable: true,
writable: true,
Expand Down Expand Up @@ -77,6 +107,8 @@ describe('SolidUIHeaderElement', () => {
expect(signUpLink.getAttribute('icon')).toBe('https://example.com/signup-icon-top.svg')

loginButton.dispatchEvent(new CustomEvent('login-success', { bubbles: true, composed: true }))
await Promise.resolve()
await header.updateComplete

expect(authActionSelected).toHaveBeenCalledWith({
role: 'login'
Expand Down Expand Up @@ -105,6 +137,7 @@ describe('SolidUIHeaderElement', () => {

it('uses a custom fallback avatar when no accountAvatar is configured', async () => {
const header = new Header()
;(authn.currentUser as jest.Mock).mockReturnValue({ uri: 'https://alice.example/profile/card#me' })

header.authState = 'logged-in'
header.accountAvatar = ''
Expand All @@ -123,6 +156,7 @@ describe('SolidUIHeaderElement', () => {
it('renders an accounts dropdown with avatar when logged in', async () => {
const header = new Header()
const accountMenuSelected = jest.fn()
;(authn.currentUser as jest.Mock).mockReturnValue({ uri: 'https://alice.example/profile/card#me' })

header.authState = 'logged-in'
header.accountIcon = 'https://example.com/account-icon.svg'
Expand Down Expand Up @@ -173,6 +207,7 @@ describe('SolidUIHeaderElement', () => {

it('does not render the logout icon on mobile layout', async () => {
const header = new Header()
;(authn.currentUser as jest.Mock).mockReturnValue({ uri: 'https://alice.example/profile/card#me' })
header.layout = 'mobile'
header.authState = 'logged-in'
header.logoutIcon = 'https://example.com/logout-icon.svg'
Expand All @@ -196,6 +231,7 @@ describe('SolidUIHeaderElement', () => {

it('does not render account webid on mobile layout', async () => {
const header = new Header()
;(authn.currentUser as jest.Mock).mockReturnValue({ uri: 'https://alice.example/profile/card#me' })
header.layout = 'mobile'
header.authState = 'logged-in'
header.accountMenu = [
Expand Down Expand Up @@ -263,6 +299,7 @@ describe('SolidUIHeaderElement', () => {

it('renders helpMenuList inside the help dropdown and dispatches events', async () => {
const header = new Header()
;(authn.currentUser as jest.Mock).mockReturnValue({ uri: 'https://alice.example/profile/card#me' })

const helpMenuClicked = jest.fn()

Expand Down Expand Up @@ -304,4 +341,35 @@ describe('SolidUIHeaderElement', () => {

window.open = originalWindowOpen
})

it('derives auth state from session on connect', async () => {
const header = new Header()
;(authn.currentUser as jest.Mock).mockReturnValue({ uri: 'https://alice.example/profile/card#me' })

document.body.appendChild(header)
await header.updateComplete
await Promise.resolve()
await header.updateComplete

expect(authn.checkUser).toHaveBeenCalled()
expect(header.authState).toBe('logged-in')
})

it('refreshes auth state when session events fire', async () => {
const header = new Header()
document.body.appendChild(header)
await header.updateComplete

;(authn.currentUser as jest.Mock).mockReturnValue({ uri: 'https://alice.example/profile/card#me' })
;(authSession.events as any).emit('login')
await Promise.resolve()
await header.updateComplete
expect(header.authState).toBe('logged-in')

;(authn.currentUser as jest.Mock).mockReturnValue(null)
;(authSession.events as any).emit('logout')
await Promise.resolve()
await header.updateComplete
expect(header.authState).toBe('logged-out')
})
})
5 changes: 1 addition & 4 deletions src/v2/components/loginButton/LoginButton.ts
Original file line number Diff line number Diff line change
Expand Up @@ -377,10 +377,7 @@ export class LoginButton extends LitElement {

const locationUrl = new URL(window.location.href)
locationUrl.hash = ''
await authSession.login({
redirectUrl: locationUrl.href,
oidcIssuer: issuerUri
})
await authSession.login(issuerUri, locationUrl.href)
} catch (err: any) {
this._errorMsg = err.message || String(err)
this.requestUpdate()
Expand Down
73 changes: 73 additions & 0 deletions test/mocks/solid-oidc-client-browser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
type Listener = (...args: any[]) => void

class EventEmitterLike {
private listeners: Record<string, Listener[]> = {}

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<void> {

}

async handleRedirectFromLogin (): Promise<void> {

}

async restore (): Promise<void> {

}

async login (_idp?: string, _redirectUri?: string): Promise<void> {
}

async logout (): Promise<void> {
this.info = { isLoggedIn: false }
this.webId = undefined
this.isActive = false
}

fetch (input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
return globalThis.fetch(input, init)
}

authFetch (input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
return globalThis.fetch(input, init)
}
}
6 changes: 4 additions & 2 deletions tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,10 @@
"declarations.d.ts"
] /* List of folders to include type definitions from. */,
// "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
"esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
// "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
"esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
"preserveSymlinks": true, /* Do not resolve the real path of symlinks. Needed for local linked solid-logic. */
"baseUrl": ".", /* Base directory to resolve non-absolute module names. Needed for paths mapping. */
"paths": { "rdflib": ["./node_modules/rdflib"] }, /* Map rdflib to avoid duplicate type identity when linked with solid-logic. */

/* Source Map Options */
// "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */
Expand Down
Loading