diff --git a/.changeset/chilled-suits-tease.md b/.changeset/chilled-suits-tease.md new file mode 100644 index 0000000..2981e51 --- /dev/null +++ b/.changeset/chilled-suits-tease.md @@ -0,0 +1,38 @@ +--- +"@open-pioneer/authentication-keycloak": minor +"@open-pioneer/authentication": minor +--- + +Replace change events for auth state wiht signals from Reactivity API + +watch for updates of the auth state +```typescript +const myAuthService = ... +watch( + () => [myAuthService.getAuthState()], + ([state]) => { + console.log(state); + }, + { + immediate: true + } +); +``` + +The Auth Service forwards the auth state from the underlying AuthPlugin. +Therefore, the plugin implementation must use reactive signals when its auth state changes in order to signal changes to the service. +```typescript +class DummyPlugin implements AuthPlugin { + #state = reactive( { + kind: "not-authenticated" + }); + + getAuthState(): AuthState { + return this.#state.value; + } + + $setAuthState(newState: AuthState) { + this.#state.value = newState; + } +} +``` diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b23e62e..2f1b4f3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -304,12 +304,18 @@ importers: src/packages/authentication: dependencies: + '@conterra/reactivity-core': + specifier: 'catalog:' + version: 0.4.3 '@open-pioneer/chakra-integration': specifier: workspace:^ version: link:../chakra-integration '@open-pioneer/core': specifier: workspace:^ version: link:../core + '@open-pioneer/reactivity': + specifier: workspace:^ + version: link:../reactivity '@open-pioneer/runtime': specifier: workspace:^ version: link:../runtime diff --git a/src/packages/authentication-keycloak/KeycloakAuthPlugin.ts b/src/packages/authentication-keycloak/KeycloakAuthPlugin.ts index a89c20e..60fb772 100644 --- a/src/packages/authentication-keycloak/KeycloakAuthPlugin.ts +++ b/src/packages/authentication-keycloak/KeycloakAuthPlugin.ts @@ -1,13 +1,8 @@ // SPDX-FileCopyrightText: 2023 Open Pioneer project (https://github.com/open-pioneer) // SPDX-License-Identifier: Apache-2.0 -import { reactive, watch } from "@conterra/reactivity-core"; -import { - AuthPlugin, - AuthPluginEvents, - AuthState, - LoginBehavior -} from "@open-pioneer/authentication"; -import { EventEmitter, Resource, createLogger, destroyResource } from "@open-pioneer/core"; +import { reactive } from "@conterra/reactivity-core"; +import { AuthPlugin, AuthState, LoginBehavior } from "@open-pioneer/authentication"; +import { Resource, createLogger, destroyResource } from "@open-pioneer/core"; import { NotificationService } from "@open-pioneer/notifier"; import { PackageIntl, @@ -29,10 +24,7 @@ interface References { notifier: NotificationService; } -export class KeycloakAuthPlugin - extends EventEmitter - implements Service, AuthPlugin -{ +export class KeycloakAuthPlugin implements Service, AuthPlugin { declare [DECLARE_SERVICE_INTERFACE]: "authentication-keycloak.KeycloakAuthPlugin"; #notifier: NotificationService; @@ -50,20 +42,11 @@ export class KeycloakAuthPlugin }); constructor(options: ServiceOptions) { - super(); this.#notifier = options.references.notifier; this.#intl = options.intl; this.#logoutOptions = { redirectUri: undefined }; this.#loginOptions = { redirectUri: undefined }; - // Backwards compatibility: emit "changed" event when the state changes - this.#watcher = watch( - () => [this.#state.value], - () => { - this.emit("changed"); - } - ); - try { this.#keycloakOptions = getKeycloakConfig(options.properties); } catch (e) { @@ -173,7 +156,6 @@ export class KeycloakAuthPlugin this.#updateState({ kind: "not-authenticated" }); - this.emit("changed"); this.destroy(); }); }, interval); diff --git a/src/packages/authentication/AuthServiceImpl.test.ts b/src/packages/authentication/AuthServiceImpl.test.ts index d077147..1d59edd 100644 --- a/src/packages/authentication/AuthServiceImpl.test.ts +++ b/src/packages/authentication/AuthServiceImpl.test.ts @@ -3,12 +3,12 @@ /** * @vitest-environment node */ -import { EventEmitter } from "@open-pioneer/core"; import { it, expect } from "vitest"; -import { AuthPlugin, AuthPluginEvents, AuthState, LoginFallback } from "./api"; +import { AuthPlugin, AuthState, LoginFallback } from "./api"; import { createElement } from "react"; import { createService } from "@open-pioneer/test-utils/services"; import { AuthServiceImpl } from "./AuthServiceImpl"; +import { reactive, syncWatch } from "@conterra/reactivity-core"; it("forwards the authentication plugin's state changes", async () => { const plugin = new TestPlugin(); @@ -18,10 +18,16 @@ it("forwards the authentication plugin's state changes", async () => { } }); - const observedStates: AuthState[] = [authService.getAuthState()]; - authService.on("changed", () => { - observedStates.push(authService.getAuthState()); - }); + const observedStates: AuthState[] = []; + syncWatch( + () => [authService.getAuthState()], + ([state]) => { + observedStates.push(state); + }, + { + immediate: true + } + ); plugin.$setAuthState({ kind: "pending" }); plugin.$setAuthState({ @@ -112,15 +118,15 @@ it("calls the plugin's logout method", async () => { expect(plugin.$logoutCalled).toBe(1); }); -class TestPlugin extends EventEmitter implements AuthPlugin { - #state: AuthState = { +class TestPlugin implements AuthPlugin { + #state = reactive({ kind: "not-authenticated" - }; + }); $logoutCalled = 0; getAuthState(): AuthState { - return this.#state; + return this.#state.value; } getLoginBehavior(): LoginFallback { @@ -135,8 +141,7 @@ class TestPlugin extends EventEmitter implements AuthPlugin { } $setAuthState(newState: AuthState) { - this.#state = newState; - this.emit("changed"); + this.#state.value = newState; } } diff --git a/src/packages/authentication/AuthServiceImpl.ts b/src/packages/authentication/AuthServiceImpl.ts index c67b6f7..72e1989 100644 --- a/src/packages/authentication/AuthServiceImpl.ts +++ b/src/packages/authentication/AuthServiceImpl.ts @@ -1,7 +1,6 @@ // SPDX-FileCopyrightText: 2023 Open Pioneer project (https://github.com/open-pioneer) // SPDX-License-Identifier: Apache-2.0 import { - EventEmitter, ManualPromise, Resource, createAbortError, @@ -9,50 +8,48 @@ import { destroyResource, createLogger } from "@open-pioneer/core"; -import type { - AuthEvents, - AuthPlugin, - AuthService, - AuthState, - LoginBehavior, - SessionInfo -} from "./api"; +import type { AuthPlugin, AuthService, AuthState, LoginBehavior, SessionInfo } from "./api"; import type { Service, ServiceOptions } from "@open-pioneer/runtime"; +import { syncWatch } from "@conterra/reactivity-core"; const LOG = createLogger("authentication:AuthService"); -export class AuthServiceImpl extends EventEmitter implements AuthService, Service { +export class AuthServiceImpl implements AuthService, Service { #plugin: AuthPlugin; - #currentState: AuthState; #whenUserInfo: ManualPromise | undefined; - #eventHandle: Resource | undefined; + #watchPluginStateHandle: Resource | undefined; constructor(serviceOptions: ServiceOptions<{ plugin: AuthPlugin }>) { - super(); this.#plugin = serviceOptions.references.plugin; - // Init from plugin state and watch for changes. - this.#currentState = this.#plugin.getAuthState(); - this.#eventHandle = this.#plugin.on?.("changed", () => this.#onPluginStateChanged()); + this.#watchPluginStateHandle = syncWatch( + () => [this.#plugin.getAuthState()], + ([state]) => { + this.#onPluginStateChanged(state); + }, + { + immediate: false + } + ); LOG.debug( - `Constructed with initial auth state '${this.#currentState.kind}'`, - this.#currentState + `Constructed with initial auth state '${this.getAuthState().kind}'`, + this.getAuthState() ); } destroy(): void { this.#whenUserInfo?.reject(createAbortError()); this.#whenUserInfo = undefined; - this.#eventHandle = destroyResource(this.#eventHandle); + this.#watchPluginStateHandle = destroyResource(this.#watchPluginStateHandle); } getAuthState(): AuthState { - return this.#currentState; + return this.#plugin.getAuthState(); } getSessionInfo(): Promise { - if (this.#currentState.kind !== "pending") { - return Promise.resolve(getSessionInfo(this.#currentState)); + if (this.getAuthState().kind !== "pending") { + return Promise.resolve(getSessionInfo(this.getAuthState())); } if (!this.#whenUserInfo) { @@ -70,15 +67,12 @@ export class AuthServiceImpl extends EventEmitter implements AuthSer this.#plugin.logout(); } - #onPluginStateChanged() { - const newState = this.#plugin.getAuthState(); - this.#currentState = newState; + #onPluginStateChanged(newState: AuthState) { if (newState.kind !== "pending" && this.#whenUserInfo) { this.#whenUserInfo.resolve(getSessionInfo(newState)); this.#whenUserInfo = undefined; } - LOG.debug(`Auth state changed to '${this.#currentState.kind}'`, this.#currentState); - this.emit("changed"); + LOG.debug(`Auth state changed to '${newState.kind}'`, newState); } } diff --git a/src/packages/authentication/ForceAuth.test.tsx b/src/packages/authentication/ForceAuth.test.tsx index d6bb10c..f1ece95 100644 --- a/src/packages/authentication/ForceAuth.test.tsx +++ b/src/packages/authentication/ForceAuth.test.tsx @@ -1,11 +1,11 @@ // SPDX-FileCopyrightText: 2023 Open Pioneer project (https://github.com/open-pioneer) // SPDX-License-Identifier: Apache-2.0 -import { EventEmitter } from "@open-pioneer/core"; import { PackageContextProvider } from "@open-pioneer/test-utils/react"; import { act, render, screen, waitFor } from "@testing-library/react"; import { expect, it } from "vitest"; +import { reactive, Reactive } from "@conterra/reactivity-core"; import { ErrorFallbackProps, ForceAuth } from "./ForceAuth"; -import { AuthEvents, AuthService, AuthState, LoginBehavior, SessionInfo } from "./api"; +import { AuthState, LoginBehavior, SessionInfo } from "./api"; import { Box } from "@open-pioneer/chakra-integration"; it("renders children if the user is authenticated", async () => { @@ -283,12 +283,11 @@ it("should use renderErrorFallback property rather than errorFallback property i expect(result.innerHTML).toEqual(renderErrorFallbackInner); }); -class TestAuthService extends EventEmitter implements AuthService { - #currentState: AuthState; +class TestAuthService { + #currentState: Reactive; #behavior: LoginBehavior; constructor(initState: AuthState, loginBehavior?: LoginBehavior) { - super(); - this.#currentState = initState; + this.#currentState = reactive(initState); this.#behavior = loginBehavior ?? { kind: "fallback", Fallback(props: Record) { @@ -297,7 +296,7 @@ class TestAuthService extends EventEmitter implements AuthService { }; } getAuthState(): AuthState { - return this.#currentState; + return this.#currentState.value; } getSessionInfo(): Promise { throw new Error("Method not implemented."); @@ -309,7 +308,6 @@ class TestAuthService extends EventEmitter implements AuthService { throw new Error("Method not implemented."); } setAuthState(newState: AuthState) { - this.#currentState = newState; - this.emit("changed"); + this.#currentState.value = newState; } } diff --git a/src/packages/authentication/api.ts b/src/packages/authentication/api.ts index 2d88511..5fb4b38 100644 --- a/src/packages/authentication/api.ts +++ b/src/packages/authentication/api.ts @@ -1,17 +1,8 @@ // SPDX-FileCopyrightText: 2023 Open Pioneer project (https://github.com/open-pioneer) // SPDX-License-Identifier: Apache-2.0 -import type { EventSource } from "@open-pioneer/core"; import type { DeclaredService } from "@open-pioneer/runtime"; import type { ComponentType } from "react"; -/** - * Events emitted by the {@link AuthService}. - */ -export interface AuthEvents { - /** Emitted when there were any changes to the service's state. */ - changed: void; -} - /** * Information about the authenticated user's session. */ @@ -106,16 +97,14 @@ export interface LoginEffect { * * The current state (such as session info) can be retrieved and watched for changes. */ -export interface AuthService - extends EventSource, - DeclaredService<"authentication.AuthService"> { +export interface AuthService extends DeclaredService<"authentication.AuthService"> { /** * Returns the current authentication state. * * The state may initially be `pending` to allow for async initialization in the authentication plugin. * After initialization, the state is either `not-authenticated` or `authenticated`. - * - This method must be called again after the {@link AuthService} has emitted the `changed` event. + * + * Use Reactivity API to watch the auth state. */ getAuthState(): AuthState; @@ -123,8 +112,6 @@ export interface AuthService * Returns the current user's {@link SessionInfo} or `undefined`, if the current user is not authenticated. * * The method is asynchronous to allow for async initialization in the authentication plugin. - * - * This method must be called again after the {@link AuthService} has emitted the `changed` event. */ getSessionInfo(): Promise; @@ -141,29 +128,15 @@ export interface AuthService logout(): void; } -/** Events that may be emitted by an authentication plugin. */ -export interface AuthPluginEvents { - changed: void; -} - -/** Optional base type for an authentication plugin: the event emitter interface is not required. */ -export type AuthPluginEventBase = EventSource; - /** * The authentication service requires an AuthPlugin to implement a concrete authentication flow. * * The plugin provides the current authentication state and the authentication fallback to the service. * * The current authentication state returned by {@link getAuthState} may change. - * If that is the case, the plugin must also emit the `changed` event to notify the service. - * - * The implementation of `AuthPluginEventBase` is optional: it is only necessary if the state changes - * during the lifetime of the plugin. - * To implement the event, you can write `class MyPlugin extends EventEmitter`. + * If that is the case, the plugin must implement its auth state with Reactivity API. */ -export interface AuthPlugin - extends Partial, - DeclaredService<"authentication.AuthPlugin"> { +export interface AuthPlugin extends DeclaredService<"authentication.AuthPlugin"> { /** * Returns the current authentication state. * diff --git a/src/packages/authentication/package.json b/src/packages/authentication/package.json index 5750df1..d7b6a05 100644 --- a/src/packages/authentication/package.json +++ b/src/packages/authentication/package.json @@ -21,7 +21,9 @@ "@open-pioneer/runtime": "workspace:^", "@open-pioneer/chakra-integration": "workspace:^", "react": "catalog:", - "react-use": "catalog:" + "react-use": "catalog:", + "@conterra/reactivity-core": "catalog:", + "@open-pioneer/reactivity": "workspace:^" }, "devDependencies": { "@open-pioneer/test-utils": "workspace:^", diff --git a/src/packages/authentication/useAuthState.ts b/src/packages/authentication/useAuthState.ts index 8949895..a7ad078 100644 --- a/src/packages/authentication/useAuthState.ts +++ b/src/packages/authentication/useAuthState.ts @@ -1,25 +1,12 @@ // SPDX-FileCopyrightText: 2023 Open Pioneer project (https://github.com/open-pioneer) // SPDX-License-Identifier: Apache-2.0 -import { useCallback, useSyncExternalStore } from "react"; import { AuthService, AuthState } from "./api"; +import { useReactiveSnapshot } from "@open-pioneer/reactivity"; /** * React hook that always returns the `authService`'s current auth state. */ export function useAuthState(authService: AuthService): AuthState { - // subscribe to changes of the auth service. - // useCallback (or useMemo) is needed for stable function references: - // otherwise `useSyncExternalStore` would re-subscribe on every render. - const subscribe = useCallback( - (callback: () => void) => { - const handle = authService.on("changed", callback); - return () => handle.destroy(); - }, - [authService] - ); - const getSnapshot = useCallback(() => { - return authService.getAuthState(); - }, [authService]); - const state = useSyncExternalStore(subscribe, getSnapshot); + const state = useReactiveSnapshot(() => authService.getAuthState(), [authService]); return state; } diff --git a/src/samples/auth-sample/auth-app/auth-plugin/TestAuthPlugin.ts b/src/samples/auth-sample/auth-app/auth-plugin/TestAuthPlugin.ts index 66e2631..d7be590 100644 --- a/src/samples/auth-sample/auth-app/auth-plugin/TestAuthPlugin.ts +++ b/src/samples/auth-sample/auth-app/auth-plugin/TestAuthPlugin.ts @@ -1,34 +1,26 @@ // SPDX-FileCopyrightText: 2023 Open Pioneer project (https://github.com/open-pioneer) // SPDX-License-Identifier: Apache-2.0 -import { - AuthPlugin, - AuthPluginEvents, - AuthState, - LoginBehavior -} from "@open-pioneer/authentication"; -import { EventEmitter } from "@open-pioneer/core"; +import { AuthPlugin, AuthState, LoginBehavior } from "@open-pioneer/authentication"; import { Service } from "@open-pioneer/runtime"; import { createElement } from "react"; import { LoginMask } from "./LoginMask"; +import { reactive } from "@conterra/reactivity-core"; -export class TestAuthPlugin extends EventEmitter implements Service, AuthPlugin { - #state: AuthState = { +export class TestAuthPlugin implements Service, AuthPlugin { + #state = reactive({ kind: "pending" - }; + }); // eslint-disable-next-line @typescript-eslint/no-explicit-any #timerId: any; #wasLoggedIn = false; constructor() { - super(); - // Delay state change to simulate a delay that may be needed to // determine the user's login state (e.g. rest request). this.#timerId = setTimeout(() => { - this.#state = { + this.#state.value = { kind: "not-authenticated" }; - this.emit("changed"); }, 500); } @@ -38,7 +30,7 @@ export class TestAuthPlugin extends EventEmitter implements Se } getAuthState(): AuthState { - return this.#state; + return this.#state.value; } getLoginBehavior(): LoginBehavior { @@ -46,7 +38,7 @@ export class TestAuthPlugin extends EventEmitter implements Se // The plugin's state changes if the credentials are correct. const doLogin = (userName: string, password: string): string | undefined => { if (userName === "admin" && password === "admin") { - this.#state = { + this.#state.value = { kind: "authenticated", sessionInfo: { userId: "admin", @@ -54,18 +46,16 @@ export class TestAuthPlugin extends EventEmitter implements Se } }; this.#wasLoggedIn = true; - this.emit("changed"); } else { return "Invalid user name or password!"; } }; const doFail = () => { - this.#state = { + this.#state.value = { kind: "error", error: new Error("Login failed!") }; - this.emit("changed"); }; // This component is rendered when the user is not logged in, for example @@ -83,13 +73,12 @@ export class TestAuthPlugin extends EventEmitter implements Se } logout() { - if (this.#state.kind === "authenticated" || this.#state.kind === "pending") { - this.#state = { + if (this.#state.value.kind === "authenticated" || this.#state.value.kind === "pending") { + this.#state.value = { kind: "not-authenticated" }; clearTimeout(this.#timerId); this.#timerId = undefined; - this.emit("changed"); } } }