From a561c33cf8c3f21ffdd58c699e3eb3b88015ae76 Mon Sep 17 00:00:00 2001 From: Noel De Martin Date: Thu, 9 Jul 2026 12:13:19 +0200 Subject: [PATCH] #832 Implement initializing auth state This will show a spinner when authentication is still being initialized in the Account component, which should be helpful to distinguish whether you're looking at a logged-out page, or the authentication is still initializing. It also opens the door to implementing more involved loading UIs, with the initialization state exposed in the Auth context and a new slot in the Guard component. --- src/components/account/Account.stories.ts | 12 +++---- src/components/account/Account.styles.css | 10 +++++- src/components/account/Account.ts | 8 +++++ src/components/guard/Guard.stories.ts | 2 ++ src/components/guard/Guard.ts | 20 +++++++++++ src/components/provider/Provider.ts | 6 ++++ src/lib/auth/NoopAuth.ts | 6 ++-- src/lib/auth/SolidAuth.ts | 36 ++++++++++++++----- src/lib/auth/context.ts | 1 + src/storybook/auth/StorybookAuth.ts | 1 + src/storybook/components/StorybookProvider.ts | 5 +++ src/storybook/helpers.ts | 13 +++++-- src/storybook/stubs.ts | 5 +-- 13 files changed, 100 insertions(+), 25 deletions(-) diff --git a/src/components/account/Account.stories.ts b/src/components/account/Account.stories.ts index 7e0f2977b..78995baaf 100644 --- a/src/components/account/Account.stories.ts +++ b/src/components/account/Account.stories.ts @@ -15,12 +15,8 @@ const meta = { const render = defineAuthStoryRender(() => html``) -export default meta - export const Primary = { render } -export const Guest = { - render, - args: { - user: 'Guest', - } -} +export const Guest = { render, args: { user: 'Guest' } } +export const Initializing = { render, args: { user: 'Initializing' } } + +export default meta diff --git a/src/components/account/Account.styles.css b/src/components/account/Account.styles.css index 58f6d7cae..34ab7aa01 100644 --- a/src/components/account/Account.styles.css +++ b/src/components/account/Account.styles.css @@ -1,13 +1,21 @@ :host { + --image-size: 1.875rem; /* 30px */ + display: inline-flex; flex-direction: row; gap: 10px; } +:host([data-state-initializing]) { + icon-svg-spinners-180-ring { + width: var(--image-size); + height: var(--image-size); + } +} + :host([data-state-loggedIn]) { --padding: 4px; --border-width: 1px; - --image-size: 1.875rem; /* 30px */ button { display: inline-flex; diff --git a/src/components/account/Account.ts b/src/components/account/Account.ts index a2069f070..4e6c82611 100644 --- a/src/components/account/Account.ts +++ b/src/components/account/Account.ts @@ -15,6 +15,7 @@ import '~icons/lucide/chevron-down' import '~icons/lucide/log-in' import '~icons/lucide/log-out' import '~icons/lucide/user' +import '~icons/svg-spinners/180-ring' import styles from './Account.styles.css' @@ -28,6 +29,7 @@ export interface AccountMenuItem { export default class Account extends WebComponent { static styles = styles static states = { + initializing: (component: Account) => !component.auth.initialized, loggedIn: (component: Account) => !!component.auth.account, } @@ -52,6 +54,12 @@ export default class Account extends WebComponent { } protected render () { + if (!this.auth.initialized) { + return html` + + ` + } + if (!this.auth.account) { return html` diff --git a/src/components/guard/Guard.stories.ts b/src/components/guard/Guard.stories.ts index 881fc8857..e09d3e9b0 100644 --- a/src/components/guard/Guard.stories.ts +++ b/src/components/guard/Guard.stories.ts @@ -16,6 +16,7 @@ const meta = { const render = defineAuthStoryRender(() => html` + Initializing content Guest content Logged in content @@ -23,5 +24,6 @@ const render = defineAuthStoryRender(() => html` export const Primary = { render } export const Guest = { render, args: { user: 'Guest' } } +export const Initializing = { render, args: { user: 'Initializing' } } export default meta diff --git a/src/components/guard/Guard.ts b/src/components/guard/Guard.ts index 4d29dec53..416dcb8e7 100644 --- a/src/components/guard/Guard.ts +++ b/src/components/guard/Guard.ts @@ -8,7 +8,27 @@ export default class Guard extends WebComponent { @consume({ context: authContext, subscribe: true }) private accessor auth: AuthContext = DEFAULT_AUTH_CONTEXT + private unsubscribeSessionUpdated?: () => void + + connectedCallback () { + super.connectedCallback() + + this.unsubscribeSessionUpdated = this.auth.onSessionUpdated(() => this.requestUpdate()) + } + + disconnectedCallback () { + super.disconnectedCallback() + + this.unsubscribeSessionUpdated?.() + } + protected render () { + if (!this.auth.initialized) { + return html` + + ` + } + if (!this.auth.account) { return html` diff --git a/src/components/provider/Provider.ts b/src/components/provider/Provider.ts index 9786e4f72..8020c4f89 100644 --- a/src/components/provider/Provider.ts +++ b/src/components/provider/Provider.ts @@ -15,6 +15,12 @@ export default class Provider extends WebComponent { @provide({ context: authContext }) private accessor auth = new SolidAuth() + async connectedCallback () { + super.connectedCallback() + + await this.auth.initialize() + } + protected willUpdate (changedProperties: Map) { super.willUpdate(changedProperties) diff --git a/src/lib/auth/NoopAuth.ts b/src/lib/auth/NoopAuth.ts index d8d852533..dc0c28fea 100644 --- a/src/lib/auth/NoopAuth.ts +++ b/src/lib/auth/NoopAuth.ts @@ -1,10 +1,8 @@ import { AuthContext } from './context' -import Account from './Account' export default class NoopAuth implements AuthContext { - get account (): Account | null { - return null - } + public readonly initialized = false + public readonly account = null async login () { throw new Error('Can\'t use auth, missing context provider') diff --git a/src/lib/auth/SolidAuth.ts b/src/lib/auth/SolidAuth.ts index ef16e2c18..d69b32e1e 100644 --- a/src/lib/auth/SolidAuth.ts +++ b/src/lib/auth/SolidAuth.ts @@ -1,9 +1,9 @@ -import { authSession, solidLogicSingleton } from 'solid-logic' import Account from '@/lib/auth/Account' +import ns from '@/lib/ns' +import { authn, authSession, solidLogicSingleton } from 'solid-logic' import { AuthContext } from '@/lib/auth' import { showDialog } from '@/lib/dialogs' import { html } from 'lit' -import ns from '@/lib/ns' import '@/components/login-modal' @@ -24,11 +24,26 @@ function findAccountImage (webId: string): string | undefined { } export default class SolidAuth implements AuthContext { + private _initialized = false + private listeners: (() => unknown)[] = [] + constructor (public signupUrl: string = DEFAULT_SIGNUP_URL) {} + async initialize () { + await authn.checkUser() + + this._initialized = true + this.listeners.forEach(listener => listener()) + } + + get initialized (): boolean { + return this._initialized + } + get account (): Account | null { const webId: string | undefined = authSession.webId ?? authSession.info?.webId const isActive: boolean = authSession.isActive ?? authSession.info?.isLoggedIn ?? Boolean(webId) + if (!isActive || !webId) { return null } @@ -74,21 +89,26 @@ export default class SolidAuth implements AuthContext { const listener = () => { callback() } + + this.listeners.push(listener) + if (typeof sessionEventTarget.addEventListener === 'function') { sessionEventTarget.addEventListener('sessionStateChange', listener) } else { - authSession.events.on('login', callback) - authSession.events.on('logout', callback) - authSession.events.on('sessionRestore', callback) + authSession.events.on('login', listener) + authSession.events.on('logout', listener) + authSession.events.on('sessionRestore', listener) } return () => { + this.listeners = this.listeners.filter(_listener => _listener !== listener) + if (typeof sessionEventTarget.removeEventListener === 'function') { sessionEventTarget.removeEventListener('sessionStateChange', listener) } else { - authSession.events.off('login', callback) - authSession.events.off('logout', callback) - authSession.events.off('sessionRestore', callback) + authSession.events.off('login', listener) + authSession.events.off('logout', listener) + authSession.events.off('sessionRestore', listener) } } } diff --git a/src/lib/auth/context.ts b/src/lib/auth/context.ts index 822bb6ad2..14fc82a7e 100644 --- a/src/lib/auth/context.ts +++ b/src/lib/auth/context.ts @@ -3,6 +3,7 @@ import NoopAuth from './NoopAuth' import Account from './Account' export interface AuthContext { + initialized: boolean; account: Account | null; login(loginUrl?: string): Promise; signup(): Promise; diff --git a/src/storybook/auth/StorybookAuth.ts b/src/storybook/auth/StorybookAuth.ts index 291004bc6..110ce4aa6 100644 --- a/src/storybook/auth/StorybookAuth.ts +++ b/src/storybook/auth/StorybookAuth.ts @@ -1,6 +1,7 @@ import { Account, AuthContext } from '@/lib/auth' export default class StorybookAuth implements AuthContext { + public initialized = true public account: Account | null = null async login (loginUrl?: string) { diff --git a/src/storybook/components/StorybookProvider.ts b/src/storybook/components/StorybookProvider.ts index b34d4391c..b7bfbc7a1 100644 --- a/src/storybook/components/StorybookProvider.ts +++ b/src/storybook/components/StorybookProvider.ts @@ -15,12 +15,17 @@ export class StorybookProvider extends WebComponent { @property({ type: String, reflect: true }) accessor avatarUrl: string | undefined + @property({ type: Boolean, reflect: true }) + accessor initialized = true + @provide({ context: authContext }) private accessor auth = new StorybookAuth() willUpdate (changedProperties: Map) { super.willUpdate(changedProperties) + this.auth.initialized = this.initialized + if (!this.webId) { this.auth.account = null diff --git a/src/storybook/helpers.ts b/src/storybook/helpers.ts index c1fa21033..2606149fe 100644 --- a/src/storybook/helpers.ts +++ b/src/storybook/helpers.ts @@ -9,11 +9,20 @@ export type GetStoryArgs = { [K in keyof T]: T[K] extends { options: ArrayLike } ? TValue : T[K] extends { control: 'text' } ? string : never } -function renderStorybook (content: TemplateResult, user: ReturnType = null) { +function renderStorybook (content: TemplateResult, user?: ReturnType) { + user ??= USER_OPTIONS.resolve('Guest') + const container = document.createElement('div') + const attributes = 'initialized' in user + ? { initialized: user.initialized } + : { webId: user.webId, avatarUrl: user.avatarUrl, initialized: true } render(html` - + ${content} `, container) diff --git a/src/storybook/stubs.ts b/src/storybook/stubs.ts index 89bb914c4..f46beec9e 100644 --- a/src/storybook/stubs.ts +++ b/src/storybook/stubs.ts @@ -13,6 +13,7 @@ export const users = { } as const export const USER_OPTIONS = defineControlOptions([ - ...objectEntries(users).map(([label, value]) => [label, value]) as [keyof typeof users | 'Guest', typeof users[keyof typeof users] | null][], - ['Guest', null], + ...objectEntries(users).map(([label, value]) => [label, value]) as [keyof typeof users | 'Guest', typeof users[keyof typeof users]][], + ['Guest', { initialized: true }], + ['Initializing', { initialized: false }], ])