From 220378b3f2bc7bf503b595b275e82f025e763ec7 Mon Sep 17 00:00:00 2001 From: Teffen Ellis <592134+GirlBossRush@users.noreply.github.com> Date: Mon, 7 Apr 2025 19:53:51 +0200 Subject: [PATCH] web: Fix TypeScript compilation issues for mixins, events. (#13766) --- web/src/admin/AdminInterface/AboutModal.ts | 2 +- .../admin/applications/ApplicationListPage.ts | 2 +- web/src/admin/groups/RelatedUserList.ts | 2 +- web/src/admin/users/UserListPage.ts | 2 +- .../Interface/authentikConfigProvider.ts | 48 +++++-- web/src/elements/Interface/brandProvider.ts | 52 ++++++-- .../Interface/capabilitiesProvider.ts | 86 +++++++----- .../Interface/licenseSummaryProvider.ts | 39 ++++-- web/src/elements/Interface/versionProvider.ts | 27 +++- web/src/elements/ak-dual-select/types.ts | 10 +- web/src/elements/types.ts | 125 ++++++++++++++---- web/src/elements/utils/customEvents.ts | 18 ++- web/src/elements/utils/eventEmitter.ts | 72 ++++++---- 13 files changed, 343 insertions(+), 142 deletions(-) diff --git a/web/src/admin/AdminInterface/AboutModal.ts b/web/src/admin/AdminInterface/AboutModal.ts index b575d5f111..a519ee5359 100644 --- a/web/src/admin/AdminInterface/AboutModal.ts +++ b/web/src/admin/AdminInterface/AboutModal.ts @@ -19,7 +19,7 @@ import { AdminApi, CapabilitiesEnum, LicenseSummaryStatusEnum } from "@goauthent @customElement("ak-about-modal") export class AboutModal extends WithLicenseSummary(WithBrandConfig(ModalButton)) { static get styles() { - return super.styles.concat( + return ModalButton.styles.concat( PFAbout, css` .pf-c-about-modal-box__hero { diff --git a/web/src/admin/applications/ApplicationListPage.ts b/web/src/admin/applications/ApplicationListPage.ts index 4d9c2bcea5..da60282a18 100644 --- a/web/src/admin/applications/ApplicationListPage.ts +++ b/web/src/admin/applications/ApplicationListPage.ts @@ -71,7 +71,7 @@ export class ApplicationListPage extends WithBrandConfig(TablePage) } static get styles(): CSSResult[] { - return super.styles.concat(PFCard, applicationListStyle); + return TablePage.styles.concat(PFCard, applicationListStyle); } columns(): TableColumn[] { diff --git a/web/src/admin/groups/RelatedUserList.ts b/web/src/admin/groups/RelatedUserList.ts index 5370dd6b02..65158e10c9 100644 --- a/web/src/admin/groups/RelatedUserList.ts +++ b/web/src/admin/groups/RelatedUserList.ts @@ -127,7 +127,7 @@ export class RelatedUserList extends WithBrandConfig(WithCapabilitiesConfig(Tabl me?: SessionUser; static get styles(): CSSResult[] { - return super.styles.concat(PFDescriptionList, PFAlert, PFBanner); + return Table.styles.concat(PFDescriptionList, PFAlert, PFBanner); } async apiEndpoint(): Promise> { diff --git a/web/src/admin/users/UserListPage.ts b/web/src/admin/users/UserListPage.ts index c259ec6623..596bf94862 100644 --- a/web/src/admin/users/UserListPage.ts +++ b/web/src/admin/users/UserListPage.ts @@ -118,7 +118,7 @@ export class UserListPage extends WithBrandConfig(WithCapabilitiesConfig(TablePa me?: SessionUser; static get styles(): CSSResult[] { - return [...super.styles, PFDescriptionList, PFCard, PFAlert, recoveryButtonStyles]; + return [...TablePage.styles, PFDescriptionList, PFCard, PFAlert, recoveryButtonStyles]; } constructor() { diff --git a/web/src/elements/Interface/authentikConfigProvider.ts b/web/src/elements/Interface/authentikConfigProvider.ts index 79f1c960bb..52e8ded40a 100644 --- a/web/src/elements/Interface/authentikConfigProvider.ts +++ b/web/src/elements/Interface/authentikConfigProvider.ts @@ -1,18 +1,44 @@ import { authentikConfigContext } from "@goauthentik/elements/AuthentikContexts"; -import type { Constructor } from "@goauthentik/elements/types.js"; +import { createMixin } from "@goauthentik/elements/types"; import { consume } from "@lit/context"; -import type { LitElement } from "lit"; import type { Config } from "@goauthentik/api"; -export function WithAuthentikConfig>( - superclass: T, - subscribe = true, -) { - abstract class WithAkConfigProvider extends superclass { - @consume({ context: authentikConfigContext, subscribe }) - public authentikConfig!: Config; - } - return WithAkConfigProvider; +/** + * A consumer that provides the application configuration to the element. + */ +export interface AKConfigMixin { + /** + * The current configuration of the application. + */ + readonly authentikConfig: Readonly; } + +/** + * A mixin that provides the application configuration to the element. + * + * @category Mixin + */ +export const WithAuthentikConfig = createMixin( + ({ + /** + * The superclass constructor to extend. + */ + SuperClass, + /** + * Whether or not to subscribe to the context. + */ + subscribe = true, + }) => { + abstract class AKConfigProvider extends SuperClass implements AKConfigMixin { + @consume({ + context: authentikConfigContext, + subscribe, + }) + public readonly authentikConfig!: Readonly; + } + + return AKConfigProvider; + }, +); diff --git a/web/src/elements/Interface/brandProvider.ts b/web/src/elements/Interface/brandProvider.ts index 170225e462..dcc327ea98 100644 --- a/web/src/elements/Interface/brandProvider.ts +++ b/web/src/elements/Interface/brandProvider.ts @@ -1,20 +1,48 @@ import { authentikBrandContext } from "@goauthentik/elements/AuthentikContexts"; -import type { AbstractConstructor } from "@goauthentik/elements/types.js"; +import { createMixin } from "@goauthentik/elements/types"; import { consume } from "@lit/context"; -import type { LitElement } from "lit"; import { state } from "lit/decorators.js"; import type { CurrentBrand } from "@goauthentik/api"; -export function WithBrandConfig>( - superclass: T, - subscribe = true, -) { - abstract class WithBrandProvider extends superclass { - @consume({ context: authentikBrandContext, subscribe }) - @state() - public brand!: CurrentBrand; - } - return WithBrandProvider; +/** + * A mixin that provides the current brand to the element. + */ +export interface StyleBrandMixin { + /** + * The current style branding configuration. + */ + brand: CurrentBrand; } + +/** + * A mixin that provides the current brand to the element. + * + * @category Mixin + * + * @see {@link https://lit.dev/docs/composition/mixins/#mixins-in-typescript | Lit Mixins} + */ +export const WithBrandConfig = createMixin( + ({ + /** + * The superclass constructor to extend. + */ + SuperClass, + /** + * Whether or not to subscribe to the context. + */ + subscribe = true, + }) => { + abstract class StyleBrandProvider extends SuperClass implements StyleBrandMixin { + @consume({ + context: authentikBrandContext, + subscribe, + }) + @state() + public brand!: CurrentBrand; + } + + return StyleBrandProvider; + }, +); diff --git a/web/src/elements/Interface/capabilitiesProvider.ts b/web/src/elements/Interface/capabilitiesProvider.ts index c8841d880b..4776b200bb 100644 --- a/web/src/elements/Interface/capabilitiesProvider.ts +++ b/web/src/elements/Interface/capabilitiesProvider.ts @@ -1,67 +1,85 @@ import { authentikConfigContext } from "@goauthentik/elements/AuthentikContexts"; -import type { AbstractConstructor } from "@goauthentik/elements/types.js"; +import { createMixin } from "@goauthentik/elements/types"; import { consume } from "@lit/context"; -import type { LitElement } from "lit"; import { CapabilitiesEnum } from "@goauthentik/api"; import { Config } from "@goauthentik/api"; -// Using a unique, lexically scoped, and locally static symbol as the field name for the context -// means that it's inaccessible to any child class looking for it. It's one of the strongest privacy -// guarantees in JavaScript. - -class WCC { - public static readonly capabilitiesConfig: unique symbol = Symbol(); +/** + * A consumer that provides the capability methods to the element. + * + */ +export interface CapabilitiesMixin { + /** + * Predicate to determine if the current user has a given capability. + */ + can( + /** + * The capability enum to check. + */ + capability: CapabilitiesEnum, + ): boolean; } /** - * withCapabilitiesContext mixes in a single method to any LitElement, `can()`, which takes a - * CapabilitiesEnum and returns true or false. + * Lexically-scoped symbol for the capabilities configuration. + * + * @internal + */ +const kCapabilitiesConfig: unique symbol = Symbol("capabilitiesConfig"); + +/** + * A mixin that provides the capability methods to the element. * * Usage: * * After importing, simply mixin this function: * - * ``` + * ```ts * export class AkMyNiftyNewFeature extends withCapabilitiesContext(AKElement) { + * } * ``` * * And then if you need to check on a capability: * - * ``` + * ```ts * if (this.can(CapabilitiesEnum.IsEnterprise) { ... } * ``` * - * This code re-exports CapabilitiesEnum, so you won't have to import it on a separate line if you - * don't need anything else from the API. * - * Passing `true` as the second mixin argument will cause the inheriting class to subscribe to the - * configuration context. Should the context be explicitly reset, all active web components that are - * currently active and subscribed to the context will automatically have a `requestUpdate()` - * triggered with the new configuration. + * Passing `true` as the second mixin argument + * + * @category Mixin * */ +export const WithCapabilitiesConfig = createMixin( + ({ SuperClass, subscribe = true }) => { + abstract class CapabilitiesProvider extends SuperClass implements CapabilitiesMixin { + @consume({ + context: authentikConfigContext, + subscribe, + }) + private readonly [kCapabilitiesConfig]: Config | undefined; -export function WithCapabilitiesConfig>( - superclass: T, - subscribe = true, -) { - abstract class CapabilitiesContext extends superclass { - @consume({ context: authentikConfigContext, subscribe }) - private [WCC.capabilitiesConfig]!: Config; + public can(capability: CapabilitiesEnum) { + const config = this[kCapabilitiesConfig]; - can(c: CapabilitiesEnum) { - if (!this[WCC.capabilitiesConfig]) { - throw new Error( - "ConfigContext: Attempted to access site configuration before initialization.", - ); + if (!config) { + throw new Error( + "ConfigContext: Attempted to access site configuration before initialization.", + ); + } + + return config.capabilities.includes(capability); } - return this[WCC.capabilitiesConfig].capabilities.includes(c); } - } - return CapabilitiesContext; -} + return CapabilitiesProvider; + }, +); + +// Re-export `CapabilitiesEnum`, so you won't have to import it on a separate line if you +// don't need anything else from the API. export { CapabilitiesEnum }; diff --git a/web/src/elements/Interface/licenseSummaryProvider.ts b/web/src/elements/Interface/licenseSummaryProvider.ts index a7e6b9fa93..7ec08518e6 100644 --- a/web/src/elements/Interface/licenseSummaryProvider.ts +++ b/web/src/elements/Interface/licenseSummaryProvider.ts @@ -1,23 +1,40 @@ import { authentikEnterpriseContext } from "@goauthentik/elements/AuthentikContexts"; -import { Constructor } from "@goauthentik/elements/types.js"; +import { createMixin } from "@goauthentik/elements/types"; import { consume } from "@lit/context"; -import type { LitElement } from "lit"; import { type LicenseSummary, LicenseSummaryStatusEnum } from "@goauthentik/api"; -export function WithLicenseSummary>( - superclass: T, - subscribe = true, -) { - abstract class WithEnterpriseProvider extends superclass { - @consume({ context: authentikEnterpriseContext, subscribe }) - public licenseSummary!: LicenseSummary; +/** + * A consumer that provides license information to the element. + */ +export interface LicenseMixin { + /** + * Summary of the current license. + */ + readonly licenseSummary: LicenseSummary; + + /** + * Whether or not the current license is an enterprise license. + */ + readonly hasEnterpriseLicense: boolean; +} + +/** + * A mixin that provides the license information to the element. + */ +export const WithLicenseSummary = createMixin(({ SuperClass, subscribe = true }) => { + abstract class LicenseProvider extends SuperClass implements LicenseMixin { + @consume({ + context: authentikEnterpriseContext, + subscribe, + }) + public readonly licenseSummary!: LicenseSummary; get hasEnterpriseLicense() { return this.licenseSummary?.status !== LicenseSummaryStatusEnum.Unlicensed; } } - return WithEnterpriseProvider; -} + return LicenseProvider; +}); diff --git a/web/src/elements/Interface/versionProvider.ts b/web/src/elements/Interface/versionProvider.ts index 5bea7eb29e..b8dc5dade9 100644 --- a/web/src/elements/Interface/versionProvider.ts +++ b/web/src/elements/Interface/versionProvider.ts @@ -1,18 +1,35 @@ import { authentikVersionContext } from "@goauthentik/elements/AuthentikContexts"; -import type { AbstractConstructor } from "@goauthentik/elements/types.js"; import { consume } from "@lit/context"; +import { Constructor } from "@lit/reactive-element/decorators/base"; import type { LitElement } from "lit"; import type { Version } from "@goauthentik/api"; -export function WithVersion>( - superclass: T, +/** + * A consumer that provides version information to the element. + */ +export declare class VersionConsumer { + /** + * The current version of the application. + */ + public readonly version: Version; +} + +export function WithVersion>( + /** + * The superclass constructor to extend. + */ + SuperClass: T, + /** + * Whether or not to subscribe to the context. + */ subscribe = true, ) { - abstract class WithBrandProvider extends superclass { + class VersionProvider extends SuperClass { @consume({ context: authentikVersionContext, subscribe }) public version!: Version; } - return WithBrandProvider; + + return VersionProvider as Constructor & T; } diff --git a/web/src/elements/ak-dual-select/types.ts b/web/src/elements/ak-dual-select/types.ts index e10d16296d..57634dfac1 100644 --- a/web/src/elements/ak-dual-select/types.ts +++ b/web/src/elements/ak-dual-select/types.ts @@ -30,9 +30,9 @@ export type DataProvision = { export type DataProvider = (page: number, search?: string) => Promise; -export interface SearchbarEvent extends CustomEvent { - detail: { - source: string; - value: string; - }; +export interface SearchbarEventDetail { + source: string; + value: string; } + +export type SearchbarEvent = CustomEvent; diff --git a/web/src/elements/types.ts b/web/src/elements/types.ts index 141e596846..c579b534e4 100644 --- a/web/src/elements/types.ts +++ b/web/src/elements/types.ts @@ -1,41 +1,107 @@ import { AKElement } from "@goauthentik/elements/Base"; -import { TemplateResult, nothing } from "lit"; -import { ReactiveControllerHost } from "lit"; +import { type LitElement, type ReactiveControllerHost, type TemplateResult, nothing } from "lit"; +import "lit"; export type ReactiveElementHost = Partial & T; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export type Constructor = new (...args: any[]) => T; +export type AbstractLitElementConstructor = abstract new (...args: never[]) => LitElement; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export type AbstractConstructor = abstract new (...args: any[]) => T; +export type LitElementConstructor = new (...args: never[]) => LitElement; -// authentik Search/List types -// -// authentik's list types (ak-dual-select, ak-list-select, ak-search-select) all take a tuple of two -// or three items, or a collection of groups of such tuples. In order to push dynamic checking -// around, we also allow the inclusion of a fourth component, which is just a scratchpad the -// developer can use for their own reasons. +/** + * A constructor that has been extended with a mixin. + */ +export type ConstructorWithMixin = + // Is the superclass abstract? + SuperClass extends abstract new (...args: never[]) => unknown + ? // Lift the abstractness to of the mixin. + new (...args: ConstructorParameters) => InstanceType & Mixin + : // Is the superclass **not** abstract? + SuperClass extends new (...args: never[]) => unknown + ? // So shall be the mixin. + new (...args: ConstructorParameters) => InstanceType & Mixin + : never; -// The displayed element for our list can be a TemplateResult. If it is, we *strongly* recommend -// that you include the `sortBy` string as well, which is used for sorting but is also used for our -// autocomplete element (ak-search-select) both for tracking the user's input and for what we -// display in the autocomplete input box. +/** + * The init object passed to the `createMixin` callback. + */ +export interface CreateMixinInit { + /** + * The superclass constructor to extend. + */ + SuperClass: T; + /** + * Whether or not to subscribe to the context. + * + * Should the context be explicitly reset, all active web components that are + * currently active and subscribed to the context will automatically have a `requestUpdate()` + * triggered with the new configuration. + */ + subscribe?: boolean; +} -// - key: string -// - label (string). This is the field that will be sorted and used for filtering and searching. -// - desc (optional) A string or TemplateResult used to describe the option. -// - localMapping: The object the key represents; used by some specific apps. API layers may use -// this as a way to find the referenced object, rather than the string and keeping a local map. -// -// Note that this is a *tuple*, not a record or map! +/** + * Create a mixin for a LitElement. + * + * @param mixinCallback The callback that will be called to create the mixin. + * @template Mixin The mixin class to union with the superclass. + */ +export function createMixin(mixinCallback: (init: CreateMixinInit) => unknown) { + return ( + /** + * The superclass constructor to extend. + */ SuperClass: T, + /** + * Whether or not to subscribe to the context. + * + * Should the context be explicitly reset, all active web components that are + * currently active and subscribed to the context will automatically have a `requestUpdate()` + * triggered with the new configuration. + */ + subscribe?: boolean, + ) => { + const MixinClass = mixinCallback({ + SuperClass: SuperClass as LitElementConstructor, + subscribe, + }); -// prettier-ignore + return MixinClass as ConstructorWithMixin; + }; +} + +//#region Search/List types + +/** + * authentik's list types (ak-dual-select, ak-list-select, ak-search-select) all take a tuple of two + * or three items, or a collection of groups of such tuples. In order to push dynamic checking + * around, we also allow the inclusion of a fourth component, which is just a scratchpad the + * developer can use for their own reasons. + * + * The displayed element for our list can be a TemplateResult. + * + * If it is, we *strongly* recommend that you include the `sortBy` string as well, which is used for sorting but is also used for our autocomplete element (ak-search-select), + * both for tracking the user's input and for what we display in the autocomplete input box. + * + * Note that this is a *tuple*, not a record or map! + */ export type SelectOption = [ + /** + * The key that will be used for sorting and filtering. + */ key: string, + /** + * The field that will be sorted and used for filtering and searching. + */ label: string, + /** + * A string or TemplateResult used to describe the option. + */ desc?: string | TemplateResult, + /** + * The object the key represents; used by some specific apps. API layers may use + * this as a way to find the referenced object, rather than the string and keeping a local map. + */ localMapping?: T, ]; @@ -44,8 +110,8 @@ export type SelectOption = [ * `grouped: false` flag. Note that it *is* possible to pass to any of the rendering components an * array of SelectTuples; they will be automatically mapped to a SelectFlat object. * + * @internal */ -/* PRIVATE */ export type SelectFlat = { grouped: false; options: SelectOption[]; @@ -74,5 +140,14 @@ export type SelectGrouped = { export type GroupedOptions = SelectGrouped | SelectFlat; export type SelectOptions = SelectOption[] | GroupedOptions; +//#endregion + +/** + * A convenience type representing the result of a slotted template, i.e. + * + * - A string, which will be rendered as text. + * - A TemplateResult, which will be rendered as HTML. + * - `nothing`, which will not be rendered. + */ export type SlottedTemplateResult = string | TemplateResult | typeof nothing; export type Spread = { [key: string]: unknown }; diff --git a/web/src/elements/utils/customEvents.ts b/web/src/elements/utils/customEvents.ts index f0dab467a2..3d40bf2e29 100644 --- a/web/src/elements/utils/customEvents.ts +++ b/web/src/elements/utils/customEvents.ts @@ -5,9 +5,15 @@ export const customEvent = (name: string, details = {}) => detail: details, }); -// "Unknown" seems to violate some obscure Typescript rule and doesn't work here, although it -// should. -// -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export const isCustomEvent = (v: any): v is CustomEvent => - v instanceof CustomEvent && "detail" in v; +export type SerializablePrimitive = number | string; +export type SerializableArray = SerializablePrimitive[]; +export type CustomEventDetail = SerializablePrimitive | SerializableArray | object; + +/** + * Type guard to determine if an event has a `detail` property. + */ +export function isCustomEvent( + eventLike: Event, +): eventLike is CustomEvent { + return eventLike instanceof CustomEvent && "detail" in eventLike; +} diff --git a/web/src/elements/utils/eventEmitter.ts b/web/src/elements/utils/eventEmitter.ts index 1a3555eb24..a3748c6e22 100644 --- a/web/src/elements/utils/eventEmitter.ts +++ b/web/src/elements/utils/eventEmitter.ts @@ -1,38 +1,41 @@ -import type { LitElement } from "lit"; +import { createMixin } from "@goauthentik/elements/types"; +import { CustomEventDetail, isCustomEvent } from "@goauthentik/elements/utils/customEvents"; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type Constructor = new (...args: any[]) => T; +export interface EmmiterElementHandler { + dispatchCustomEvent( + eventName: string, + detail?: T extends CustomEvent ? U : T, + eventInit?: EventInit, + ): void; +} -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export const isCustomEvent = (v: any): v is CustomEvent => - v instanceof CustomEvent && "detail" in v; - -export function CustomEmitterElement>(superclass: T) { - return class EmmiterElementHandler extends superclass { - dispatchCustomEvent( +export const CustomEmitterElement = createMixin(({ SuperClass }) => { + return class EmmiterElementHandler extends SuperClass { + public dispatchCustomEvent( eventName: string, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - detail: any = {}, - options = {}, + detail: D = {} as D, + eventInit: EventInit = {}, ) { - const fullDetail = - typeof detail === "object" && !Array.isArray(detail) - ? { - ...detail, - } - : detail; + let normalizedDetail: CustomEventDetail; + + if (detail && typeof detail === "object" && !Array.isArray(detail)) { + // TODO: Is this destructuring still necessary to shallow copy the object? + normalizedDetail = { ...detail }; + } else { + normalizedDetail = detail; + } this.dispatchEvent( new CustomEvent(eventName, { composed: true, bubbles: true, - ...options, - detail: fullDetail, - }) as F, + ...eventInit, + detail: normalizedDetail, + }), ); } }; -} +}); /** * Mixin that enables Lit Elements to handle custom events in a more straightforward manner. @@ -56,8 +59,19 @@ class HK { type EventHandler = (ev: CustomEvent) => void; type EventMap = WeakMap; -export function CustomListenerElement>(superclass: T) { - return class ListenerElementHandler extends superclass { +export interface CustomEventTarget { + addCustomListener(eventName: string, handler: EventHandler): void; + removeCustomListener(eventName: string, handler: EventHandler): void; +} + +/** + * A mixin that enables Lit Elements to handle custom events in a more straightforward manner. + * + * @todo Can we lean on the native `EventTarget` class for this? + * @category Mixin + */ +export const CustomListenerElement = createMixin(({ SuperClass }) => { + return class ListenerElementHandler extends SuperClass implements CustomEventTarget { private [HK.listenHandlers] = new Map(); private [HK.getHandler](eventName: string, handler: EventHandler) { @@ -93,14 +107,14 @@ export function CustomListenerElement>(supercl } addCustomListener(eventName: string, handler: EventHandler) { - const internalHandler = (ev: Event) => { - if (!isCustomEvent(ev)) { + const internalHandler = (event: Event) => { + if (!isCustomEvent(event)) { console.error( `Received a standard event for custom event ${eventName}; event will not be handled.`, ); return; } - handler(ev); + handler(event); }; this[HK.addHandler](eventName, handler, internalHandler); this.addEventListener(eventName, internalHandler); @@ -117,4 +131,4 @@ export function CustomListenerElement>(supercl this[HK.removeHandler](eventName, handler); } }; -} +});