From 2c64f72ebc57dad9789c1fb799dd7cd39003d043 Mon Sep 17 00:00:00 2001 From: Ken Sternberg <133134217+kensternberg-authentik@users.noreply.github.com> Date: Fri, 29 Mar 2024 11:59:17 -0700 Subject: [PATCH] web: move context controllers into reactive controller plugins (#8996) * web: fix esbuild issue with style sheets Getting ESBuild, Lit, and Storybook to all agree on how to read and parse stylesheets is a serious pain. This fix better identifies the value types (instances) being passed from various sources in the repo to the three *different* kinds of style processors we're using (the native one, the polyfill one, and whatever the heck Storybook does internally). Falling back to using older CSS instantiating techniques one era at a time seems to do the trick. It's ugly, but in the face of the aggressive styling we use to avoid Flashes of Unstyled Content (FLoUC), it's the logic with which we're left. In standard mode, the following warning appears on the console when running a Flow: ``` Autofocus processing was blocked because a document already has a focused element. ``` In compatibility mode, the following **error** appears on the console when running a Flow: ``` crawler-inject.js:1106 Uncaught TypeError: Failed to execute 'observe' on 'MutationObserver': parameter 1 is not of type 'Node'. at initDomMutationObservers (crawler-inject.js:1106:18) at crawler-inject.js:1114:24 at Array.forEach () at initDomMutationObservers (crawler-inject.js:1114:10) at crawler-inject.js:1549:1 initDomMutationObservers @ crawler-inject.js:1106 (anonymous) @ crawler-inject.js:1114 initDomMutationObservers @ crawler-inject.js:1114 (anonymous) @ crawler-inject.js:1549 ``` Despite this error, nothing seems to be broken and flows work as anticipated. * web: move context controllers into reactive controller plugins While I was working on the Patternfly 5 thing, I found myself cleaning up the way our context controllers are plugged into the Interfaces. I realized a couple of things that had bothered me before: 1. It does not matter where the context controller lives so long as the context controller has a references to the LitElement that hosts it. ReactiveControllers provide that reference. 2. ReactiveControllers are a perfect place to hide some of these details, so that they don't have to clutter up our Interface declaration. 3. The ReactiveController `hostConnected()/hostDisconnected()` lifecycle is a much better place to hook up our EVENT_REFRESH events to the contexts and controllers that care about them than some random place in the loader cycle. 4. It's much easier to detect and control when an external change to a context's state object, which is supposed to be a mirror of the context, changes outside the controller, by using the `hostUpdate()` method. When the controller causes a state change, the states will be the same, allowing us to short out the potential infinite loop. This commit also uses the symbol-as-property-name trick to guarantee the privacy of some fields that should truly be private. They're unfindable and inaddressible from the outside world. This is preferable to using the Private Member syntax (the `#` prefix) because Babel, TypeScript, and ESBuild all use an underlying registry of private names that "do not have good performance characteristics if you create many instances of classes with private fields" [ESBuild Caveats](https://esbuild.github.io/content-types/#javascript-caveats). --- web/src/common/api/config.ts | 11 +-- web/src/elements/AuthentikContexts.ts | 4 +- .../Interface/BrandContextController.ts | 52 +++++++++++ .../Interface/ConfigContextController.ts | 53 +++++++++++ .../Interface/EnterpriseContextController.ts | 53 +++++++++++ web/src/elements/Interface/Interface.ts | 93 +++++-------------- 6 files changed, 185 insertions(+), 81 deletions(-) create mode 100644 web/src/elements/Interface/BrandContextController.ts create mode 100644 web/src/elements/Interface/ConfigContextController.ts create mode 100644 web/src/elements/Interface/EnterpriseContextController.ts diff --git a/web/src/common/api/config.ts b/web/src/common/api/config.ts index dd1a2c1b75..52b2f8f55c 100644 --- a/web/src/common/api/config.ts +++ b/web/src/common/api/config.ts @@ -3,7 +3,7 @@ import { EventMiddleware, LoggingMiddleware, } from "@goauthentik/common/api/middleware"; -import { EVENT_LOCALE_REQUEST, EVENT_REFRESH, VERSION } from "@goauthentik/common/constants"; +import { EVENT_LOCALE_REQUEST, VERSION } from "@goauthentik/common/constants"; import { globalAK } from "@goauthentik/common/global"; import { Config, Configuration, CoreApi, CurrentBrand, RootApi } from "@goauthentik/api"; @@ -86,13 +86,4 @@ export function AndNext(url: string): string { return `?next=${encodeURIComponent(url)}`; } -window.addEventListener(EVENT_REFRESH, () => { - // Upon global refresh, disregard whatever was pre-hydrated and - // actually load info from API - globalConfigPromise = undefined; - globalBrandPromise = undefined; - config(); - brand(); -}); - console.debug(`authentik(early): version ${VERSION}, apiBase ${DEFAULT_CONFIG.basePath}`); diff --git a/web/src/elements/AuthentikContexts.ts b/web/src/elements/AuthentikContexts.ts index 4b05f68b62..df38f6fcf5 100644 --- a/web/src/elements/AuthentikContexts.ts +++ b/web/src/elements/AuthentikContexts.ts @@ -1,9 +1,11 @@ import { createContext } from "@lit/context"; -import type { Config, CurrentBrand, LicenseSummary } from "@goauthentik/api"; +import type { Config, CurrentBrand, LicenseSummary, SessionUser } from "@goauthentik/api"; export const authentikConfigContext = createContext(Symbol("authentik-config-context")); +export const authentikUserContext = createContext(Symbol("authentik-user-context")); + export const authentikEnterpriseContext = createContext( Symbol("authentik-enterprise-context"), ); diff --git a/web/src/elements/Interface/BrandContextController.ts b/web/src/elements/Interface/BrandContextController.ts new file mode 100644 index 0000000000..216d930bf0 --- /dev/null +++ b/web/src/elements/Interface/BrandContextController.ts @@ -0,0 +1,52 @@ +import { EVENT_REFRESH } from "@goauthentik/authentik/common/constants"; +import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; +import { authentikBrandContext } from "@goauthentik/elements/AuthentikContexts"; + +import { ContextProvider } from "@lit/context"; +import { ReactiveController, ReactiveControllerHost } from "lit"; + +import type { CurrentBrand } from "@goauthentik/api"; +import { CoreApi } from "@goauthentik/api"; + +import type { AkInterface } from "./Interface"; + +type ReactiveElementHost = Partial & AkInterface; + +export class BrandContextController implements ReactiveController { + host!: ReactiveElementHost; + + context!: ContextProvider<{ __context__: CurrentBrand | undefined }>; + + constructor(host: ReactiveElementHost) { + this.host = host; + this.context = new ContextProvider(this.host, { + context: authentikBrandContext, + initialValue: undefined, + }); + this.fetch = this.fetch.bind(this); + this.fetch(); + } + + fetch() { + new CoreApi(DEFAULT_CONFIG).coreBrandsCurrentRetrieve().then((brand) => { + this.context.setValue(brand); + this.host.brand = brand; + }); + } + + hostConnected() { + window.addEventListener(EVENT_REFRESH, this.fetch); + } + + hostDisconnected() { + window.removeEventListener(EVENT_REFRESH, this.fetch); + } + + hostUpdate() { + // If the Interface changes its brand information for some reason, + // we should notify all users of the context of that change. doesn't + if (this.host.brand !== this.context.value) { + this.context.setValue(this.host.brand); + } + } +} diff --git a/web/src/elements/Interface/ConfigContextController.ts b/web/src/elements/Interface/ConfigContextController.ts new file mode 100644 index 0000000000..b9cf088f81 --- /dev/null +++ b/web/src/elements/Interface/ConfigContextController.ts @@ -0,0 +1,53 @@ +import { EVENT_REFRESH } from "@goauthentik/authentik/common/constants"; +import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; +import { authentikConfigContext } from "@goauthentik/elements/AuthentikContexts"; + +import { ContextProvider } from "@lit/context"; +import { ReactiveController, ReactiveControllerHost } from "lit"; + +import type { Config } from "@goauthentik/api"; +import { RootApi } from "@goauthentik/api"; + +import type { AkInterface } from "./Interface"; + +type ReactiveElementHost = Partial & AkInterface; + +export class ConfigContextController implements ReactiveController { + host!: ReactiveElementHost; + + context!: ContextProvider<{ __context__: Config | undefined }>; + + constructor(host: ReactiveElementHost) { + this.host = host; + this.context = new ContextProvider(this.host, { + context: authentikConfigContext, + initialValue: undefined, + }); + this.fetch = this.fetch.bind(this); + this.fetch(); + } + + fetch() { + new RootApi(DEFAULT_CONFIG).rootConfigRetrieve().then((config) => { + this.context.setValue(config); + this.host.config = config; + }); + } + + hostConnected() { + window.addEventListener(EVENT_REFRESH, this.fetch); + } + + hostDisconnected() { + window.removeEventListener(EVENT_REFRESH, this.fetch); + } + + hostUpdate() { + // If the Interface changes its config information, we should notify all + // users of the context of that change, without creating an infinite + // loop of resets. + if (this.host.config !== this.context.value) { + this.context.setValue(this.host.config); + } + } +} diff --git a/web/src/elements/Interface/EnterpriseContextController.ts b/web/src/elements/Interface/EnterpriseContextController.ts new file mode 100644 index 0000000000..30871a75a4 --- /dev/null +++ b/web/src/elements/Interface/EnterpriseContextController.ts @@ -0,0 +1,53 @@ +import { EVENT_REFRESH_ENTERPRISE } from "@goauthentik/authentik/common/constants"; +import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; +import { authentikEnterpriseContext } from "@goauthentik/elements/AuthentikContexts"; + +import { ContextProvider } from "@lit/context"; +import { ReactiveController, ReactiveControllerHost } from "lit"; + +import type { LicenseSummary } from "@goauthentik/api"; +import { EnterpriseApi } from "@goauthentik/api"; + +import type { AkEnterpriseInterface } from "./Interface"; + +type ReactiveElementHost = Partial & AkEnterpriseInterface; + +export class EnterpriseContextController implements ReactiveController { + host!: ReactiveElementHost; + + context!: ContextProvider<{ __context__: LicenseSummary | undefined }>; + + constructor(host: ReactiveElementHost) { + this.host = host; + this.context = new ContextProvider(this.host, { + context: authentikEnterpriseContext, + initialValue: undefined, + }); + this.fetch = this.fetch.bind(this); + this.fetch(); + } + + fetch() { + new EnterpriseApi(DEFAULT_CONFIG).enterpriseLicenseSummaryRetrieve().then((enterprise) => { + this.context.setValue(enterprise); + this.host.licenseSummary = enterprise; + }); + } + + hostConnected() { + window.addEventListener(EVENT_REFRESH_ENTERPRISE, this.fetch); + } + + hostDisconnected() { + window.removeEventListener(EVENT_REFRESH_ENTERPRISE, this.fetch); + } + + hostUpdate() { + // If the Interface changes its config information, we should notify all + // users of the context of that change, without creating an infinite + // loop of resets. + if (this.host.licenseSummary !== this.context.value) { + this.context.setValue(this.host.licenseSummary); + } + } +} diff --git a/web/src/elements/Interface/Interface.ts b/web/src/elements/Interface/Interface.ts index 38099a564f..8da9454603 100644 --- a/web/src/elements/Interface/Interface.ts +++ b/web/src/elements/Interface/Interface.ts @@ -1,77 +1,47 @@ -import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; -import { brand, config } from "@goauthentik/common/api/config"; -import { EVENT_REFRESH_ENTERPRISE } from "@goauthentik/common/constants"; import { UIConfig, uiConfig } from "@goauthentik/common/ui/config"; -import { - authentikBrandContext, - authentikConfigContext, - authentikEnterpriseContext, -} from "@goauthentik/elements/AuthentikContexts"; import { ensureCSSStyleSheet } from "@goauthentik/elements/utils/ensureCSSStyleSheet"; -import { ContextProvider } from "@lit/context"; import { state } from "lit/decorators.js"; import PFBase from "@patternfly/patternfly/patternfly-base.css"; import type { Config, CurrentBrand, LicenseSummary } from "@goauthentik/api"; -import { EnterpriseApi, UiThemeEnum } from "@goauthentik/api"; +import { UiThemeEnum } from "@goauthentik/api"; import { AKElement } from "../Base"; +import { BrandContextController } from "./BrandContextController"; +import { ConfigContextController } from "./ConfigContextController"; +import { EnterpriseContextController } from "./EnterpriseContextController"; -type AkInterface = HTMLElement & { +export type AkInterface = HTMLElement & { getTheme: () => Promise; brand?: CurrentBrand; uiConfig?: UIConfig; config?: Config; }; +const brandContext = Symbol("brandContext"); +const configContext = Symbol("configContext"); + export class Interface extends AKElement implements AkInterface { @state() uiConfig?: UIConfig; - _configContext = new ContextProvider(this, { - context: authentikConfigContext, - initialValue: undefined, - }); + [brandContext]!: BrandContextController; - _config?: Config; + [configContext]!: ConfigContextController; @state() - set config(c: Config) { - this._config = c; - this._configContext.setValue(c); - this.requestUpdate(); - } - - get config(): Config | undefined { - return this._config; - } - - _brandContext = new ContextProvider(this, { - context: authentikBrandContext, - initialValue: undefined, - }); - - _brand?: CurrentBrand; + config?: Config; @state() - set brand(c: CurrentBrand) { - this._brand = c; - this._brandContext.setValue(c); - this.requestUpdate(); - } - - get brand(): CurrentBrand | undefined { - return this._brand; - } + brand?: CurrentBrand; constructor() { super(); document.adoptedStyleSheets = [...document.adoptedStyleSheets, ensureCSSStyleSheet(PFBase)]; - brand().then((brand) => (this.brand = brand)); - config().then((config) => (this.config = config)); - + this[brandContext] = new BrandContextController(this); + this[configContext] = new ConfigContextController(this); this.dataset.akInterfaceRoot = "true"; } @@ -88,37 +58,20 @@ export class Interface extends AKElement implements AkInterface { } } -export class EnterpriseAwareInterface extends Interface { - _licenseSummaryContext = new ContextProvider(this, { - context: authentikEnterpriseContext, - initialValue: undefined, - }); +export type AkEnterpriseInterface = AkInterface & { + licenseSummary?: LicenseSummary; +}; - _licenseSummary?: LicenseSummary; +const enterpriseContext = Symbol("enterpriseContext"); + +export class EnterpriseAwareInterface extends Interface { + [enterpriseContext]!: EnterpriseContextController; @state() - set licenseSummary(c: LicenseSummary) { - this._licenseSummary = c; - this._licenseSummaryContext.setValue(c); - this.requestUpdate(); - } - - get licenseSummary(): LicenseSummary | undefined { - return this._licenseSummary; - } + licenseSummary?: LicenseSummary; constructor() { super(); - const refreshStatus = () => { - new EnterpriseApi(DEFAULT_CONFIG) - .enterpriseLicenseSummaryRetrieve() - .then((enterprise) => { - this.licenseSummary = enterprise; - }); - }; - refreshStatus(); - window.addEventListener(EVENT_REFRESH_ENTERPRISE, () => { - refreshStatus(); - }); + this[enterpriseContext] = new EnterpriseContextController(this); } }