From 622893130518723b58f375176bb8cb83d949da79 Mon Sep 17 00:00:00 2001 From: Ken Sternberg Date: Wed, 29 Nov 2023 14:32:54 -0800 Subject: [PATCH] This commit abstracts access to the object `rootInterface()?.config?` into a single accessor, `authentikConfig`, that can be mixed into any AKElement object that requires access to it. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Since access to `rootInterface()?.config?` is _universally_ used for a single (and repetitive) boolean check, a separate accessor has been provided that converts all calls of the form: ``` javascript rootInterface()?.config?.capabilities.includes(CapabilitiesEnum.CanImpersonate) ``` into: ``` javascript this.can(CapabilitiesEnum.CanImpersonate) ``` It does this via a Mixin, `WithCapabilitiesConfig`, which understands that these calls only make sense in the context of a running, fully configured authentik instance, and that their purpose is to inform authentik components of a user’s capabilities. The latter is why I don’t feel uncomfortable turning a function call into a method; we should make it explicit that this is a relationship between components. The mixin has a single single field, `[WCC.capabilitiesConfig]`, where its association with the upper-level configuration is made. If that syntax looks peculiar to you, good! I’ve used an explict unique symbol as the field name; it is inaccessable an innumerable in the object list. The debugger shows it only as: Symbol(): { cacheTimeout: 300 cacheTimeoutFlows: 300 cacheTimeoutPolicies: 300 cacheTimeoutReputation: 300 capabilities: (5) ['can_save_media', 'can_geo_ip', 'can_impersonate', 'can_debug', 'is_enterprise'] } Since you can’t reference it by identity, you can’t write to it. Until every browser supports actual private fields, this is the best we can do; it does guarantee that field name collisions are impossible, which is a win. The mixin takes a second optional boolean; setting this to true will cause any web component using the mixin to automatically schedule a re-render if the capabilities list changes. The mixin is also generic; despite the "...into a Lit-Context" in the title, the internals of the Mixin can be replaced with anything so long as the signature of `.can()` is preserved. Because this work builds off the work I did to give the Sidebar access to the configuration without ad-hoc retrieval or prop-drilling, it wasn’t necessary to create a new context for it. That will be necessary for the following: TODO: ``` javascript rootInterface()?.uiConfig; rootInterface()?.tenant; me(); ``` --- .../admin/AdminInterface/AdminInterface.ts | 2 +- web/src/admin/AdminInterface/AdminSidebar.ts | 17 +++-- .../admin/admin-overview/AdminOverviewPage.ts | 5 +- web/src/admin/applications/ApplicationForm.ts | 32 ++++----- web/src/admin/flows/FlowForm.ts | 16 +++-- web/src/admin/groups/GroupViewPage.ts | 2 +- web/src/admin/groups/RelatedUserList.ts | 10 +-- .../admin/sources/oauth/OAuthSourceForm.ts | 10 +-- web/src/admin/sources/plex/PlexSourceForm.ts | 15 ++-- web/src/admin/sources/saml/SAMLSourceForm.ts | 10 +-- web/src/admin/users/UserListPage.ts | 18 ++--- web/src/admin/users/UserViewPage.ts | 10 +-- web/src/elements/Base.ts | 69 ++----------------- web/src/elements/Interface/Interface.ts | 67 ++++++++++++++++++ .../Interface/authentikConfigProvider.ts | 20 ++++++ .../Interface/capabilitiesProvider.ts | 69 +++++++++++++++++++ web/src/elements/Interface/index.ts | 4 ++ web/src/elements/types.ts | 3 + web/src/elements/utils/ensureCSSStyleSheet.ts | 4 ++ web/src/flow/FlowExecutor.ts | 2 +- web/src/flow/stages/prompt/PromptStage.ts | 15 ++-- web/src/standalone/api-browser/index.ts | 2 +- web/src/standalone/loading/index.ts | 2 +- web/src/stories/interface.ts | 2 +- web/src/user/UserInterface.ts | 2 +- 25 files changed, 258 insertions(+), 150 deletions(-) create mode 100644 web/src/elements/Interface/Interface.ts create mode 100644 web/src/elements/Interface/authentikConfigProvider.ts create mode 100644 web/src/elements/Interface/capabilitiesProvider.ts create mode 100644 web/src/elements/Interface/index.ts create mode 100644 web/src/elements/types.ts create mode 100644 web/src/elements/utils/ensureCSSStyleSheet.ts diff --git a/web/src/admin/AdminInterface/AdminInterface.ts b/web/src/admin/AdminInterface/AdminInterface.ts index 834c98f37..0f1a59ccd 100644 --- a/web/src/admin/AdminInterface/AdminInterface.ts +++ b/web/src/admin/AdminInterface/AdminInterface.ts @@ -7,7 +7,7 @@ import { import { configureSentry } from "@goauthentik/common/sentry"; import { me } from "@goauthentik/common/users"; import { WebsocketClient } from "@goauthentik/common/ws"; -import { Interface } from "@goauthentik/elements/Base"; +import { Interface } from "@goauthentik/elements/Interface"; import "@goauthentik/elements/ak-locale-context"; import "@goauthentik/elements/enterprise/EnterpriseStatusBanner"; import "@goauthentik/elements/messages/MessageContainer"; diff --git a/web/src/admin/AdminInterface/AdminSidebar.ts b/web/src/admin/AdminInterface/AdminSidebar.ts index 2f973ca7e..dcced7304 100644 --- a/web/src/admin/AdminInterface/AdminSidebar.ts +++ b/web/src/admin/AdminInterface/AdminSidebar.ts @@ -1,23 +1,25 @@ import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { EVENT_SIDEBAR_TOGGLE, VERSION } from "@goauthentik/common/constants"; import { me } from "@goauthentik/common/users"; -import { authentikConfigContext } from "@goauthentik/elements/AuthentikContexts"; import { AKElement } from "@goauthentik/elements/Base"; +import { + CapabilitiesEnum, + WithCapabilitiesConfig, +} from "@goauthentik/elements/Interface/capabilitiesProvider"; import { ID_REGEX, SLUG_REGEX, UUID_REGEX } from "@goauthentik/elements/router/Route"; import { getRootStyle } from "@goauthentik/elements/utils/getRootStyle"; import { spread } from "@open-wc/lit-helpers"; -import { consume } from "@lit-labs/context"; import { msg, str } from "@lit/localize"; import { TemplateResult, html, nothing } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import { map } from "lit/directives/map.js"; -import { AdminApi, CapabilitiesEnum, CoreApi, UiThemeEnum, Version } from "@goauthentik/api"; -import type { Config, SessionUser, UserSelf } from "@goauthentik/api"; +import { AdminApi, CoreApi, UiThemeEnum, Version } from "@goauthentik/api"; +import type { SessionUser, UserSelf } from "@goauthentik/api"; @customElement("ak-admin-sidebar") -export class AkAdminSidebar extends AKElement { +export class AkAdminSidebar extends WithCapabilitiesConfig(AKElement) { @property({ type: Boolean, reflect: true }) open = true; @@ -27,9 +29,6 @@ export class AkAdminSidebar extends AKElement { @state() impersonation: UserSelf["username"] | null = null; - @consume({ context: authentikConfigContext }) - public config!: Config; - constructor() { super(); new AdminApi(DEFAULT_CONFIG).adminVersionRetrieve().then((version) => { @@ -200,7 +199,7 @@ export class AkAdminSidebar extends AKElement { } renderEnterpriseMessage() { - return this.config?.capabilities.includes(CapabilitiesEnum.IsEnterprise) + return this.can(CapabilitiesEnum.IsEnterprise) ? html` ${msg("Enterprise")} diff --git a/web/src/admin/admin-overview/AdminOverviewPage.ts b/web/src/admin/admin-overview/AdminOverviewPage.ts index a7e210a7c..9b79f5334 100644 --- a/web/src/admin/admin-overview/AdminOverviewPage.ts +++ b/web/src/admin/admin-overview/AdminOverviewPage.ts @@ -74,10 +74,7 @@ export class AdminOverviewPage extends AKElement { } render(): TemplateResult { - let name = this.user?.user.username; - if (this.user?.user.name) { - name = this.user.user.name; - } + const name = this.user?.user.name ?? this.user?.user.username; return html` ${msg(str`Welcome, ${name}.`)} diff --git a/web/src/admin/applications/ApplicationForm.ts b/web/src/admin/applications/ApplicationForm.ts index 3401b1d6d..07d9af36e 100644 --- a/web/src/admin/applications/ApplicationForm.ts +++ b/web/src/admin/applications/ApplicationForm.ts @@ -1,13 +1,16 @@ import "@goauthentik/admin/applications/ProviderSelectModal"; import { iconHelperText } from "@goauthentik/admin/helperText"; -import { DEFAULT_CONFIG, config } from "@goauthentik/common/api/config"; +import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { first } from "@goauthentik/common/utils"; import "@goauthentik/components/ak-file-input"; import "@goauthentik/components/ak-radio-input"; import "@goauthentik/components/ak-switch-input"; import "@goauthentik/components/ak-text-input"; import "@goauthentik/components/ak-textarea-input"; -import { rootInterface } from "@goauthentik/elements/Base"; +import { + CapabilitiesEnum, + WithCapabilitiesConfig, +} from "@goauthentik/elements/Interface/capabilitiesProvider"; import "@goauthentik/elements/forms/FormGroup"; import "@goauthentik/elements/forms/HorizontalFormElement"; import "@goauthentik/elements/forms/ModalForm"; @@ -22,13 +25,7 @@ import { TemplateResult, html } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import { ifDefined } from "lit/directives/if-defined.js"; -import { - Application, - CapabilitiesEnum, - CoreApi, - PolicyEngineMode, - Provider, -} from "@goauthentik/api"; +import { Application, CoreApi, PolicyEngineMode, Provider } from "@goauthentik/api"; import "./components/ak-backchannel-input"; import "./components/ak-provider-search-input"; @@ -48,7 +45,7 @@ export const policyOptions = [ ]; @customElement("ak-application-form") -export class ApplicationForm extends ModelForm { +export class ApplicationForm extends WithCapabilitiesConfig(ModelForm) { constructor() { super(); this.handleConfirmBackchannelProviders = this.handleConfirmBackchannelProviders.bind(this); @@ -95,8 +92,7 @@ export class ApplicationForm extends ModelForm { applicationRequest: data, }); } - const c = await config(); - if (c.capabilities.includes(CapabilitiesEnum.CanSaveMedia)) { + if (this.can(CapabilitiesEnum.CanSaveMedia)) { const icon = this.getFormFiles()["metaIcon"]; if (icon || this.clearIcon) { await new CoreApi(DEFAULT_CONFIG).coreApplicationsSetIconCreate({ @@ -142,21 +138,21 @@ export class ApplicationForm extends ModelForm { return html`
{ @@ -211,11 +207,11 @@ export class ApplicationForm extends ModelForm { )} > - ${rootInterface()?.config?.capabilities.includes(CapabilitiesEnum.CanSaveMedia) + ${this.can(CapabilitiesEnum.CanSaveMedia) ? html` ${this.instance?.metaIcon diff --git a/web/src/admin/flows/FlowForm.ts b/web/src/admin/flows/FlowForm.ts index a78c0f4fa..9b6a1ed47 100644 --- a/web/src/admin/flows/FlowForm.ts +++ b/web/src/admin/flows/FlowForm.ts @@ -1,8 +1,11 @@ import { DesignationToLabel, LayoutToLabel } from "@goauthentik/admin/flows/utils"; import { AuthenticationEnum } from "@goauthentik/api/dist/models/AuthenticationEnum"; -import { DEFAULT_CONFIG, config } from "@goauthentik/common/api/config"; +import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { first } from "@goauthentik/common/utils"; -import { rootInterface } from "@goauthentik/elements/Base"; +import { + CapabilitiesEnum, + WithCapabilitiesConfig, +} from "@goauthentik/elements/Interface/capabilitiesProvider"; import "@goauthentik/elements/forms/FormGroup"; import "@goauthentik/elements/forms/HorizontalFormElement"; import { ModelForm } from "@goauthentik/elements/forms/ModelForm"; @@ -14,7 +17,6 @@ import { customElement, property } from "lit/decorators.js"; import { ifDefined } from "lit/directives/if-defined.js"; import { - CapabilitiesEnum, DeniedActionEnum, Flow, FlowDesignationEnum, @@ -24,7 +26,7 @@ import { } from "@goauthentik/api"; @customElement("ak-flow-form") -export class FlowForm extends ModelForm { +export class FlowForm extends WithCapabilitiesConfig(ModelForm) { async loadInstance(pk: string): Promise { const flow = await new FlowsApi(DEFAULT_CONFIG).flowsInstancesRetrieve({ slug: pk, @@ -56,8 +58,8 @@ export class FlowForm extends ModelForm { flowRequest: data, }); } - const c = await config(); - if (c.capabilities.includes(CapabilitiesEnum.CanSaveMedia)) { + + if (this.can(CapabilitiesEnum.CanSaveMedia)) { const icon = this.getFormFiles()["background"]; if (icon || this.clearBackground) { await new FlowsApi(DEFAULT_CONFIG).flowsInstancesSetBackgroundCreate({ @@ -335,7 +337,7 @@ export class FlowForm extends ModelForm { - ${rootInterface()?.config?.capabilities.includes(CapabilitiesEnum.CanSaveMedia) + ${this.can(CapabilitiesEnum.CanSaveMedia) ? html` diff --git a/web/src/admin/groups/RelatedUserList.ts b/web/src/admin/groups/RelatedUserList.ts index 2474ee2fe..27450fb82 100644 --- a/web/src/admin/groups/RelatedUserList.ts +++ b/web/src/admin/groups/RelatedUserList.ts @@ -10,6 +10,10 @@ import { uiConfig } from "@goauthentik/common/ui/config"; import { first } from "@goauthentik/common/utils"; import "@goauthentik/components/ak-status-label"; import { rootInterface } from "@goauthentik/elements/Base"; +import { + CapabilitiesEnum, + WithCapabilitiesConfig, +} from "@goauthentik/elements/Interface/capabilitiesProvider"; import "@goauthentik/elements/buttons/ActionButton"; import "@goauthentik/elements/buttons/Dropdown"; import "@goauthentik/elements/forms/DeleteBulkForm"; @@ -33,7 +37,6 @@ import PFBanner from "@patternfly/patternfly/components/Banner/banner.css"; import PFDescriptionList from "@patternfly/patternfly/components/DescriptionList/description-list.css"; import { - CapabilitiesEnum, CoreApi, CoreUsersListTypeEnum, Group, @@ -107,7 +110,7 @@ export class RelatedUserAdd extends Form<{ users: number[] }> { } @customElement("ak-user-related-list") -export class RelatedUserList extends Table { +export class RelatedUserList extends WithCapabilitiesConfig(Table) { expandable = true; checkbox = true; @@ -188,8 +191,7 @@ export class RelatedUserList extends Table { row(item: User): TemplateResult[] { const canImpersonate = - rootInterface()?.config?.capabilities.includes(CapabilitiesEnum.CanImpersonate) && - item.pk !== this.me?.user.pk; + this.can(CapabilitiesEnum.CanImpersonate) && item.pk !== this.me?.user.pk; return [ html`
${item.username}
diff --git a/web/src/admin/sources/oauth/OAuthSourceForm.ts b/web/src/admin/sources/oauth/OAuthSourceForm.ts index 51b1c6e42..e15639cc4 100644 --- a/web/src/admin/sources/oauth/OAuthSourceForm.ts +++ b/web/src/admin/sources/oauth/OAuthSourceForm.ts @@ -3,9 +3,12 @@ import { iconHelperText, placeholderHelperText } from "@goauthentik/admin/helper import { UserMatchingModeToLabel } from "@goauthentik/admin/sources/oauth/utils"; import { DEFAULT_CONFIG, config } from "@goauthentik/common/api/config"; import { first } from "@goauthentik/common/utils"; -import { rootInterface } from "@goauthentik/elements/Base"; import "@goauthentik/elements/CodeMirror"; import { CodeMirrorMode } from "@goauthentik/elements/CodeMirror"; +import { + CapabilitiesEnum, + WithCapabilitiesConfig, +} from "@goauthentik/elements/Interface/capabilitiesProvider"; import "@goauthentik/elements/forms/FormGroup"; import "@goauthentik/elements/forms/HorizontalFormElement"; import { ModelForm } from "@goauthentik/elements/forms/ModelForm"; @@ -17,7 +20,6 @@ import { customElement, property, state } from "lit/decorators.js"; import { ifDefined } from "lit/directives/if-defined.js"; import { - CapabilitiesEnum, FlowsInstancesListDesignationEnum, OAuthSource, OAuthSourceRequest, @@ -28,7 +30,7 @@ import { } from "@goauthentik/api"; @customElement("ak-source-oauth-form") -export class OAuthSourceForm extends ModelForm { +export class OAuthSourceForm extends WithCapabilitiesConfig(ModelForm) { async loadInstance(pk: string): Promise { const source = await new SourcesApi(DEFAULT_CONFIG).sourcesOauthRetrieve({ slug: pk, @@ -326,7 +328,7 @@ export class OAuthSourceForm extends ModelForm { />

${placeholderHelperText}

- ${rootInterface()?.config?.capabilities.includes(CapabilitiesEnum.CanSaveMedia) + ${this.can(CapabilitiesEnum.CanSaveMedia) ? html` ${this.instance?.icon diff --git a/web/src/admin/sources/plex/PlexSourceForm.ts b/web/src/admin/sources/plex/PlexSourceForm.ts index 1318a1ae9..81a76f2b4 100644 --- a/web/src/admin/sources/plex/PlexSourceForm.ts +++ b/web/src/admin/sources/plex/PlexSourceForm.ts @@ -1,10 +1,13 @@ import "@goauthentik/admin/common/ak-flow-search/ak-source-flow-search"; import { iconHelperText, placeholderHelperText } from "@goauthentik/admin/helperText"; import { UserMatchingModeToLabel } from "@goauthentik/admin/sources/oauth/utils"; -import { DEFAULT_CONFIG, config } from "@goauthentik/common/api/config"; +import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { PlexAPIClient, PlexResource, popupCenterScreen } from "@goauthentik/common/helpers/plex"; import { ascii_letters, digits, first, randomString } from "@goauthentik/common/utils"; -import { rootInterface } from "@goauthentik/elements/Base"; +import { + CapabilitiesEnum, + WithCapabilitiesConfig, +} from "@goauthentik/elements/Interface/capabilitiesProvider"; import "@goauthentik/elements/forms/FormGroup"; import "@goauthentik/elements/forms/HorizontalFormElement"; import { ModelForm } from "@goauthentik/elements/forms/ModelForm"; @@ -16,7 +19,6 @@ import { customElement, property, state } from "lit/decorators.js"; import { ifDefined } from "lit/directives/if-defined.js"; import { - CapabilitiesEnum, FlowsInstancesListDesignationEnum, PlexSource, SourcesApi, @@ -24,7 +26,7 @@ import { } from "@goauthentik/api"; @customElement("ak-source-plex-form") -export class PlexSourceForm extends ModelForm { +export class PlexSourceForm extends WithCapabilitiesConfig(ModelForm) { async loadInstance(pk: string): Promise { const source = await new SourcesApi(DEFAULT_CONFIG).sourcesPlexRetrieve({ slug: pk, @@ -71,8 +73,7 @@ export class PlexSourceForm extends ModelForm { plexSourceRequest: data, }); } - const c = await config(); - if (c.capabilities.includes(CapabilitiesEnum.CanSaveMedia)) { + if (this.can(CapabilitiesEnum.CanSaveMedia)) { const icon = this.getFormFiles()["icon"]; if (icon || this.clearIcon) { await new SourcesApi(DEFAULT_CONFIG).sourcesAllSetIconCreate({ @@ -263,7 +264,7 @@ export class PlexSourceForm extends ModelForm { />

${placeholderHelperText}

- ${rootInterface()?.config?.capabilities.includes(CapabilitiesEnum.CanSaveMedia) + ${this.can(CapabilitiesEnum.CanSaveMedia) ? html` ${this.instance?.icon diff --git a/web/src/admin/sources/saml/SAMLSourceForm.ts b/web/src/admin/sources/saml/SAMLSourceForm.ts index 9e9fb8392..2dde099f0 100644 --- a/web/src/admin/sources/saml/SAMLSourceForm.ts +++ b/web/src/admin/sources/saml/SAMLSourceForm.ts @@ -4,7 +4,10 @@ import { iconHelperText, placeholderHelperText } from "@goauthentik/admin/helper import { UserMatchingModeToLabel } from "@goauthentik/admin/sources/oauth/utils"; import { DEFAULT_CONFIG, config } from "@goauthentik/common/api/config"; import { first } from "@goauthentik/common/utils"; -import { rootInterface } from "@goauthentik/elements/Base"; +import { + CapabilitiesEnum, + WithCapabilitiesConfig, +} from "@goauthentik/elements/Interface/capabilitiesProvider"; import "@goauthentik/elements/forms/FormGroup"; import "@goauthentik/elements/forms/HorizontalFormElement"; import { ModelForm } from "@goauthentik/elements/forms/ModelForm"; @@ -18,7 +21,6 @@ import { ifDefined } from "lit/directives/if-defined.js"; import { BindingTypeEnum, - CapabilitiesEnum, DigestAlgorithmEnum, FlowsInstancesListDesignationEnum, NameIdPolicyEnum, @@ -29,7 +31,7 @@ import { } from "@goauthentik/api"; @customElement("ak-source-saml-form") -export class SAMLSourceForm extends ModelForm { +export class SAMLSourceForm extends WithCapabilitiesConfig(ModelForm) { @state() clearIcon = false; @@ -157,7 +159,7 @@ export class SAMLSourceForm extends ModelForm { - ${rootInterface()?.config?.capabilities.includes(CapabilitiesEnum.CanSaveMedia) + ${this.can(CapabilitiesEnum.CanSaveMedia) ? html` ${this.instance?.icon diff --git a/web/src/admin/users/UserListPage.ts b/web/src/admin/users/UserListPage.ts index 1d861b568..e9d0c6f09 100644 --- a/web/src/admin/users/UserListPage.ts +++ b/web/src/admin/users/UserListPage.ts @@ -12,6 +12,10 @@ import { DefaultUIConfig, uiConfig } from "@goauthentik/common/ui/config"; import { first } from "@goauthentik/common/utils"; import "@goauthentik/components/ak-status-label"; import { rootInterface } from "@goauthentik/elements/Base"; +import { + CapabilitiesEnum, + WithCapabilitiesConfig, +} from "@goauthentik/elements/Interface/capabilitiesProvider"; import { PFSize } from "@goauthentik/elements/Spinner"; import "@goauthentik/elements/TreeView"; import "@goauthentik/elements/buttons/ActionButton"; @@ -33,14 +37,7 @@ import PFAlert from "@patternfly/patternfly/components/Alert/alert.css"; import PFCard from "@patternfly/patternfly/components/Card/card.css"; import PFDescriptionList from "@patternfly/patternfly/components/DescriptionList/description-list.css"; -import { - CapabilitiesEnum, - CoreApi, - ResponseError, - SessionUser, - User, - UserPath, -} from "@goauthentik/api"; +import { CoreApi, ResponseError, SessionUser, User, UserPath } from "@goauthentik/api"; export const requestRecoveryLink = (user: User) => new CoreApi(DEFAULT_CONFIG) @@ -93,7 +90,7 @@ const recoveryButtonStyles = css` `; @customElement("ak-user-list") -export class UserListPage extends TablePage { +export class UserListPage extends WithCapabilitiesConfig(TablePage) { expandable = true; checkbox = true; @@ -244,8 +241,7 @@ export class UserListPage extends TablePage { row(item: User): TemplateResult[] { const canImpersonate = - rootInterface()?.config?.capabilities.includes(CapabilitiesEnum.CanImpersonate) && - item.pk !== this.me?.user.pk; + this.can(CapabilitiesEnum.CanImpersonate) && item.pk !== this.me?.user.pk; return [ html`
${item.username}
diff --git a/web/src/admin/users/UserViewPage.ts b/web/src/admin/users/UserViewPage.ts index 25915b64a..6c49cbafa 100644 --- a/web/src/admin/users/UserViewPage.ts +++ b/web/src/admin/users/UserViewPage.ts @@ -17,8 +17,9 @@ import { userTypeToLabel } from "@goauthentik/common/labels"; import "@goauthentik/components/ak-status-label"; import "@goauthentik/components/events/ObjectChangelog"; import "@goauthentik/components/events/UserEvents"; -import { AKElement, rootInterface } from "@goauthentik/elements/Base"; +import { AKElement } from "@goauthentik/elements/Base"; import "@goauthentik/elements/CodeMirror"; +import { WithCapabilitiesConfig } from "@goauthentik/elements/Interface/capabilitiesProvider"; import "@goauthentik/elements/PageHeader"; import { PFSize } from "@goauthentik/elements/Spinner"; import "@goauthentik/elements/Tabs"; @@ -55,7 +56,7 @@ import { import "./UserDevicesTable"; @customElement("ak-user-view") -export class UserViewPage extends AKElement { +export class UserViewPage extends WithCapabilitiesConfig(AKElement) { @property({ type: Number }) set userId(id: number) { me().then((me) => { @@ -138,8 +139,9 @@ export class UserViewPage extends AKElement { const user = this.user; const canImpersonate = - rootInterface()?.config?.capabilities.includes(CapabilitiesEnum.CanImpersonate) && - this.user.pk !== this.me?.user.pk; + this.can(CapabilitiesEnum.CanImpersonate) && this.user.pk !== this.me?.user.pk; + + console.log(canImpersonate); return html`
${msg("User Info")}
diff --git a/web/src/elements/Base.ts b/web/src/elements/Base.ts index 46c983aad..09a2d2858 100644 --- a/web/src/elements/Base.ts +++ b/web/src/elements/Base.ts @@ -1,20 +1,18 @@ -import { config, tenant } from "@goauthentik/common/api/config"; import { EVENT_THEME_CHANGE } from "@goauthentik/common/constants"; -import { UIConfig, uiConfig } from "@goauthentik/common/ui/config"; +import { UIConfig } from "@goauthentik/common/ui/config"; import { adaptCSS } from "@goauthentik/common/utils"; -import { authentikConfigContext } from "@goauthentik/elements/AuthentikContexts"; +import { ensureCSSStyleSheet } from "@goauthentik/elements/utils/ensureCSSStyleSheet"; -import { ContextProvider } from "@lit-labs/context"; import { localized } from "@lit/localize"; -import { CSSResult, LitElement } from "lit"; -import { state } from "lit/decorators.js"; +import { LitElement } from "lit"; import AKGlobal from "@goauthentik/common/styles/authentik.css"; import ThemeDark from "@goauthentik/common/styles/theme-dark.css"; -import PFBase from "@patternfly/patternfly/patternfly-base.css"; import { Config, CurrentTenant, UiThemeEnum } from "@goauthentik/api"; +import { AdoptedStyleSheetsElement } from "./types"; + type AkInterface = HTMLElement & { getTheme: () => Promise; tenant?: CurrentTenant; @@ -25,13 +23,6 @@ type AkInterface = HTMLElement & { export const rootInterface = (): T | undefined => (document.body.querySelector("[data-ak-interface-root]") as T) ?? undefined; -export function ensureCSSStyleSheet(css: CSSStyleSheet | CSSResult): CSSStyleSheet { - if (css instanceof CSSResult) { - return css.styleSheet!; - } - return css; -} - let css: Promise | undefined; function fetchCustomCSS(): Promise { if (!css) { @@ -52,10 +43,6 @@ function fetchCustomCSS(): Promise { return css; } -export interface AdoptedStyleSheetsElement { - adoptedStyleSheets: readonly CSSStyleSheet[]; -} - const QUERY_MEDIA_COLOR_LIGHT = "(prefers-color-scheme: light)"; @localized() @@ -175,49 +162,3 @@ export class AKElement extends LitElement { this.requestUpdate(); } } - -export class Interface extends AKElement implements AkInterface { - @state() - tenant?: CurrentTenant; - - @state() - uiConfig?: UIConfig; - - _configContext = new ContextProvider(this, { - context: authentikConfigContext, - initialValue: undefined, - }); - - _config?: Config; - - @state() - set config(c: Config) { - this._config = c; - this._configContext.setValue(c); - this.requestUpdate(); - } - - get config(): Config | undefined { - return this._config; - } - - constructor() { - super(); - document.adoptedStyleSheets = [...document.adoptedStyleSheets, ensureCSSStyleSheet(PFBase)]; - tenant().then((tenant) => (this.tenant = tenant)); - config().then((config) => (this.config = config)); - this.dataset.akInterfaceRoot = "true"; - } - - _activateTheme(root: AdoptedStyleSheetsElement, theme: UiThemeEnum): void { - super._activateTheme(root, theme); - super._activateTheme(document, theme); - } - - async getTheme(): Promise { - if (!this.uiConfig) { - this.uiConfig = await uiConfig(); - } - return this.uiConfig.theme?.base || UiThemeEnum.Automatic; - } -} diff --git a/web/src/elements/Interface/Interface.ts b/web/src/elements/Interface/Interface.ts new file mode 100644 index 000000000..744a16095 --- /dev/null +++ b/web/src/elements/Interface/Interface.ts @@ -0,0 +1,67 @@ +import { config, tenant } from "@goauthentik/common/api/config"; +import { UIConfig, uiConfig } from "@goauthentik/common/ui/config"; +import { authentikConfigContext } from "@goauthentik/elements/AuthentikContexts"; +import type { AdoptedStyleSheetsElement } from "@goauthentik/elements/types"; +import { ensureCSSStyleSheet } from "@goauthentik/elements/utils/ensureCSSStyleSheet"; + +import { ContextProvider } from "@lit-labs/context"; +import { state } from "lit/decorators.js"; + +import PFBase from "@patternfly/patternfly/patternfly-base.css"; + +import { Config, CurrentTenant, UiThemeEnum } from "@goauthentik/api"; + +import { AKElement } from "../Base"; + +type AkInterface = HTMLElement & { + getTheme: () => Promise; + tenant?: CurrentTenant; + uiConfig?: UIConfig; + config?: Config; +}; + +export class Interface extends AKElement implements AkInterface { + @state() + tenant?: CurrentTenant; + + @state() + uiConfig?: UIConfig; + + _configContext = new ContextProvider(this, { + context: authentikConfigContext, + initialValue: undefined, + }); + + _config?: Config; + + @state() + set config(c: Config) { + this._config = c; + this._configContext.setValue(c); + this.requestUpdate(); + } + + get config(): Config | undefined { + return this._config; + } + + constructor() { + super(); + document.adoptedStyleSheets = [...document.adoptedStyleSheets, ensureCSSStyleSheet(PFBase)]; + tenant().then((tenant) => (this.tenant = tenant)); + config().then((config) => (this.config = config)); + this.dataset.akInterfaceRoot = "true"; + } + + _activateTheme(root: AdoptedStyleSheetsElement, theme: UiThemeEnum): void { + super._activateTheme(root, theme); + super._activateTheme(document, theme); + } + + async getTheme(): Promise { + if (!this.uiConfig) { + this.uiConfig = await uiConfig(); + } + return this.uiConfig.theme?.base || UiThemeEnum.Automatic; + } +} diff --git a/web/src/elements/Interface/authentikConfigProvider.ts b/web/src/elements/Interface/authentikConfigProvider.ts new file mode 100644 index 000000000..7fba78923 --- /dev/null +++ b/web/src/elements/Interface/authentikConfigProvider.ts @@ -0,0 +1,20 @@ +import { authentikConfigContext } from "@goauthentik/elements/AuthentikContexts"; + +import { consume } from "@lit-labs/context"; +import type { LitElement } from "lit"; + +import type { Config } from "@goauthentik/api"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type Constructor = new (...args: any[]) => T; + +export function WithAuthentikConfig>( + superclass: T, + subscribe = false, +) { + class WithAkConfigProvider extends superclass { + @consume({ context: authentikConfigContext, subscribe }) + public authentikConfig!: Config; + } + return WithAkConfigProvider; +} diff --git a/web/src/elements/Interface/capabilitiesProvider.ts b/web/src/elements/Interface/capabilitiesProvider.ts new file mode 100644 index 000000000..cf2740d9c --- /dev/null +++ b/web/src/elements/Interface/capabilitiesProvider.ts @@ -0,0 +1,69 @@ +import { authentikConfigContext } from "@goauthentik/elements/AuthentikContexts"; + +import { consume } from "@lit-labs/context"; +import type { LitElement } from "lit"; + +import { CapabilitiesEnum } from "@goauthentik/api"; +import { Config } from "@goauthentik/api"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type Constructor = abstract new (...args: any[]) => T; + +// 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(); +} + +/** + * withCapabilitiesContext mixes in a single method to any LitElement, `can()`, which takes a + * CapabilitiesEnum and returns true or false. + * + * Usage: + * + * After importing, simply mixin this function: + * + * ``` + * export class AkMyNiftyNewFeature extends withCapabilitiesContext(AKElement) { + * ``` + * + * And then if you need to check on a capability: + * + * ``` + * 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. + * + */ + +export function WithCapabilitiesConfig>( + superclass: T, + subscribe = false, +) { + abstract class CapabilitiesContext extends superclass { + @consume({ context: authentikConfigContext, subscribe }) + private [WCC.capabilitiesConfig]!: Config; + + can(c: CapabilitiesEnum) { + if (!this[WCC.capabilitiesConfig]) { + throw new Error( + "ConfigContext: Attempted to access site configuration before initialization.", + ); + } + return this[WCC.capabilitiesConfig].capabilities.includes(c); + } + } + + return CapabilitiesContext; +} + +export { CapabilitiesEnum }; diff --git a/web/src/elements/Interface/index.ts b/web/src/elements/Interface/index.ts new file mode 100644 index 000000000..e7d946cf6 --- /dev/null +++ b/web/src/elements/Interface/index.ts @@ -0,0 +1,4 @@ +import { Interface } from "./Interface"; + +export { Interface }; +export default Interface; diff --git a/web/src/elements/types.ts b/web/src/elements/types.ts new file mode 100644 index 000000000..4273ab6f9 --- /dev/null +++ b/web/src/elements/types.ts @@ -0,0 +1,3 @@ +export interface AdoptedStyleSheetsElement { + adoptedStyleSheets: readonly CSSStyleSheet[]; +} diff --git a/web/src/elements/utils/ensureCSSStyleSheet.ts b/web/src/elements/utils/ensureCSSStyleSheet.ts new file mode 100644 index 000000000..26f2ff898 --- /dev/null +++ b/web/src/elements/utils/ensureCSSStyleSheet.ts @@ -0,0 +1,4 @@ +import { CSSResult } from "lit"; + +export const ensureCSSStyleSheet = (css: CSSStyleSheet | CSSResult): CSSStyleSheet => + css instanceof CSSResult ? css.styleSheet! : css; diff --git a/web/src/flow/FlowExecutor.ts b/web/src/flow/FlowExecutor.ts index 29a90b428..0322a0c47 100644 --- a/web/src/flow/FlowExecutor.ts +++ b/web/src/flow/FlowExecutor.ts @@ -8,7 +8,7 @@ import { globalAK } from "@goauthentik/common/global"; import { configureSentry } from "@goauthentik/common/sentry"; import { first } from "@goauthentik/common/utils"; import { WebsocketClient } from "@goauthentik/common/ws"; -import { Interface } from "@goauthentik/elements/Base"; +import { Interface } from "@goauthentik/elements/Interface"; import "@goauthentik/elements/LoadingOverlay"; import "@goauthentik/elements/ak-locale-context"; import "@goauthentik/flow/sources/apple/AppleLoginInit"; diff --git a/web/src/flow/stages/prompt/PromptStage.ts b/web/src/flow/stages/prompt/PromptStage.ts index 877d02119..09cc6959e 100644 --- a/web/src/flow/stages/prompt/PromptStage.ts +++ b/web/src/flow/stages/prompt/PromptStage.ts @@ -1,6 +1,9 @@ -import { rootInterface } from "@goauthentik/elements/Base"; import "@goauthentik/elements/Divider"; import "@goauthentik/elements/EmptyState"; +import { + CapabilitiesEnum, + WithCapabilitiesConfig, +} from "@goauthentik/elements/Interface/capabilitiesProvider"; import { LOCALES } from "@goauthentik/elements/ak-locale-context/definitions"; import "@goauthentik/elements/forms/FormElement"; import { BaseStage } from "@goauthentik/flow/stages/base"; @@ -20,7 +23,6 @@ import PFTitle from "@patternfly/patternfly/components/Title/title.css"; import PFBase from "@patternfly/patternfly/patternfly-base.css"; import { - CapabilitiesEnum, PromptChallenge, PromptChallengeResponseRequest, PromptTypeEnum, @@ -28,7 +30,9 @@ import { } from "@goauthentik/api"; @customElement("ak-stage-prompt") -export class PromptStage extends BaseStage { +export class PromptStage extends WithCapabilitiesConfig( + BaseStage, +) { static get styles(): CSSResult[] { return [ PFBase, @@ -193,10 +197,7 @@ ${prompt.initialValue} `; })}`; case PromptTypeEnum.AkLocale: { - const inDebug = rootInterface()?.config?.capabilities.includes( - CapabilitiesEnum.CanDebug, - ); - const locales = inDebug + const locales = this.can(CapabilitiesEnum.CanDebug) ? LOCALES : LOCALES.filter((locale) => locale.code !== "debug"); const options = locales.map( diff --git a/web/src/standalone/api-browser/index.ts b/web/src/standalone/api-browser/index.ts index b0c5849ed..c6a98159c 100644 --- a/web/src/standalone/api-browser/index.ts +++ b/web/src/standalone/api-browser/index.ts @@ -2,7 +2,7 @@ import { CSRFHeaderName } from "@goauthentik/common/api/middleware"; import { EVENT_THEME_CHANGE } from "@goauthentik/common/constants"; import { globalAK } from "@goauthentik/common/global"; import { first, getCookie } from "@goauthentik/common/utils"; -import { Interface } from "@goauthentik/elements/Base"; +import { Interface } from "@goauthentik/elements/Interface"; import "@goauthentik/elements/ak-locale-context"; import { DefaultTenant } from "@goauthentik/elements/sidebar/SidebarBrand"; import "rapidoc"; diff --git a/web/src/standalone/loading/index.ts b/web/src/standalone/loading/index.ts index 907a05140..24e8c47ef 100644 --- a/web/src/standalone/loading/index.ts +++ b/web/src/standalone/loading/index.ts @@ -1,5 +1,5 @@ import { globalAK } from "@goauthentik/common/global"; -import { Interface } from "@goauthentik/elements/Base"; +import { Interface } from "@goauthentik/elements/Interface"; import { msg } from "@lit/localize"; import { CSSResult, TemplateResult, css, html } from "lit"; diff --git a/web/src/stories/interface.ts b/web/src/stories/interface.ts index c4e2dc03d..1eafc6204 100644 --- a/web/src/stories/interface.ts +++ b/web/src/stories/interface.ts @@ -1,4 +1,4 @@ -import { Interface } from "@goauthentik/app/elements/Base"; +import { Interface } from "@goauthentik/app/elements/Interface"; import { customElement, property } from "lit/decorators.js"; diff --git a/web/src/user/UserInterface.ts b/web/src/user/UserInterface.ts index fbd21d347..f45f64789 100644 --- a/web/src/user/UserInterface.ts +++ b/web/src/user/UserInterface.ts @@ -9,7 +9,7 @@ import { UserDisplay } from "@goauthentik/common/ui/config"; import { me } from "@goauthentik/common/users"; import { first } from "@goauthentik/common/utils"; import { WebsocketClient } from "@goauthentik/common/ws"; -import { Interface } from "@goauthentik/elements/Base"; +import { Interface } from "@goauthentik/elements/Interface"; import "@goauthentik/elements/ak-locale-context"; import "@goauthentik/elements/buttons/ActionButton"; import "@goauthentik/elements/enterprise/EnterpriseStatusBanner";