From d555c0db41cf20b4b1d427a65a4f10f24ce6500c Mon Sep 17 00:00:00 2001 From: Ken Sternberg <133134217+kensternberg-authentik@users.noreply.github.com> Date: Mon, 8 Jan 2024 10:22:52 -0800 Subject: [PATCH] web: abstract `rootInterface()?.config?.capabilities.includes()` into `.can()` (#7737) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. 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(); ``` * web: Added a README with a description of the applications' "mental model," essentially an architectural description. * web: prettier had opinions about the README * web: Jens requested that subscription be by default, and it's the right call. * 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. 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(); ``` * web: Added a README with a description of the applications' "mental model," essentially an architectural description. * web: prettier had opinions about the README * web: Jens requested that subscription be by default, and it's the right call. * web: adjust RAC to point to the (now independent) Interface. - Also, removed redundant check. --- web/README.md | 86 +++++++++++++++++++ .../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 | 8 +- 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/enterprise/rac/index.ts | 2 +- 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 +- 27 files changed, 343 insertions(+), 151 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/README.md b/web/README.md index 24cc5c622..9a8c1c846 100644 --- a/web/README.md +++ b/web/README.md @@ -3,6 +3,92 @@ This is the default UI for the authentik server. The documentation is going to be a little sparse for awhile, but at least let's get started. +# The Theory of the authentik UI + +In Peter Naur's 1985 essay [Programming as Theory +Building](https://pages.cs.wisc.edu/~remzi/Naur.pdf), programming is described as creating a mental +model of how a program _should_ run, then writing the code to test if the program _can_ run that +way. + +The mental model for the authentik UI is straightforward. There are five "applications" within the +UI, each with its own base URL, router, and responsibilities, and each application needs as many as +three contexts in which to run. + +The three contexts corresponds to objects in the API's `model` section, so let's use those names. + +- The root `Config`. The root configuration object of the server, containing mostly caching and + error reporting information. This is misleading, however; the `Config` object contains some user + information, specifically a list of permissions the current user (or "no user") has. +- The root `CurrentTenant`. This describes the `Brand` information UIs should use, such as themes, + logos, favicon, and specific default flows for logging in, logging out, and recovering a user + password. +- The current `SessionUser`, the person logged in: username, display name, and various states. + (Note: the authentik server permits administrators to "impersonate" any other user in order to + debug their authentikation experience. If impersonation is active, the `user` field reflects that + user, but it also includes a field, `original`, with the administrator's information.) + +(There is a fourth context object, Version, but its use is limited to displaying version information +and checking for upgrades. Just be aware that you will see it, but you will probably never interact +with it.) + +There are five applications. Two (`loading` and `api-browser`) are trivial applications whose +insides are provided by third-party libraries (Patternfly and Rapidoc, respectively). The other +three are actual applications. The descriptions below are wholly from the view of the user's +experience: + +- `Flow`: From a given URL, displays a form that requests information from the user to accomplish a + task. Some tasks require the user to be logged in, but many (such as logging in itself!) + obviously do not. +- `User`: Provides the user with access to the applications they can access, plus a few user + settings. +- `Admin`: Provides someone with super-user permissions access to the administrative functions of + the authentik server. + +**Mental Model** + +- Upon initialization, _every_ authentik UI application fetches `Config` and `CurrentTenant`. `User` + and `Admin` will also attempt to load the `SessionUser`; if there is none, the user is kicked out + to the `Flow` for logging into authentik itself. +- `Config`, `CurrentTenant`, and `SessionUser`, are provided by the `@goauthentik/api` application, + not by the codebase under `./web`. (Where you are now). +- `Flow`, `User`, and `Admin` are all called `Interfaces` and are found in + `./web/src/flow/FlowInterface`, `./web/src/user/UserInterface`, `./web/src/admin/AdminInterface`, + respectively. + +Inside each of these you will find, in a hierarchal order: + +- The context layer described above + - A theme managing layer + - The orchestration layer: + - web socket handler for server-generated events + - The router + - Individual routes for each vertical slice and its relationship to other objects: + +Each slice corresponds to an object table on the server, and each slice _usually_ consists of the +following: + +- A paginated collection display, usually using the `Table` foundation (found in + `./web/src/elements/Table`) +- The ability to view an individual object from the collection, which you may be able to: + - Edit + - Delete +- A form for creating a new object +- Tabs showing that object's relationship to other objects + - Interactive elements for changing or deleting those relationships, or creating new ones. + - The ability to create new objects with which to have that relationship, if they're not part of + the core objects (such as User->MFA authenticator apps, since the latter is not a "core" object + and has no tab of its own). + +We are still a bit "all over the place" with respect to sub-units and common units; there are +folders `common`, `elements`, and `components`, and ideally they would be: + +- `common`: non-UI related libraries all of our applications need +- `elements`: UI elements shared among multiple applications that do not need context +- `components`: UI elements shared among multiple that use one or more context + +... but at the moment there are some context-sensitive elements, and some UI-related stuff in +`common`. + # Comments **NOTE:** The comments in this section are for specific changes to this repository that cannot be 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 970b3638e..ead17e9b2 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); @@ -93,8 +90,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({ @@ -140,21 +136,21 @@ export class ApplicationForm extends ModelForm { return html`
{ @@ -209,11 +205,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 1d279070f..3925f6db9 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, @@ -54,8 +56,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({ @@ -340,7 +342,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 cf0209fcb..92763bf7b 100644 --- a/web/src/admin/sources/oauth/OAuthSourceForm.ts +++ b/web/src/admin/sources/oauth/OAuthSourceForm.ts @@ -4,9 +4,12 @@ import { BaseSourceForm } from "@goauthentik/admin/sources/BaseSourceForm"; 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 "@goauthentik/elements/forms/SearchSelect"; @@ -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 BaseSourceForm { +export class OAuthSourceForm extends WithCapabilitiesConfig(BaseSourceForm) { async loadInstance(pk: string): Promise { const source = await new SourcesApi(DEFAULT_CONFIG).sourcesOauthRetrieve({ slug: pk, @@ -318,7 +320,7 @@ export class OAuthSourceForm extends BaseSourceForm { />

${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 8091067ff..8444e1112 100644 --- a/web/src/admin/sources/plex/PlexSourceForm.ts +++ b/web/src/admin/sources/plex/PlexSourceForm.ts @@ -2,10 +2,13 @@ import "@goauthentik/admin/common/ak-flow-search/ak-source-flow-search"; import { iconHelperText, placeholderHelperText } from "@goauthentik/admin/helperText"; import { BaseSourceForm } from "@goauthentik/admin/sources/BaseSourceForm"; 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 "@goauthentik/elements/forms/SearchSelect"; @@ -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 BaseSourceForm { +export class PlexSourceForm extends WithCapabilitiesConfig(BaseSourceForm) { async loadInstance(pk: string): Promise { const source = await new SourcesApi(DEFAULT_CONFIG).sourcesPlexRetrieve({ slug: pk, @@ -63,8 +65,7 @@ export class PlexSourceForm extends BaseSourceForm { 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({ @@ -255,7 +256,7 @@ export class PlexSourceForm extends BaseSourceForm { />

${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 76e996322..c969411fb 100644 --- a/web/src/admin/sources/saml/SAMLSourceForm.ts +++ b/web/src/admin/sources/saml/SAMLSourceForm.ts @@ -5,7 +5,10 @@ import { BaseSourceForm } from "@goauthentik/admin/sources/BaseSourceForm"; 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 "@goauthentik/elements/forms/Radio"; @@ -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 BaseSourceForm { +export class SAMLSourceForm extends WithCapabilitiesConfig(BaseSourceForm) { @state() clearIcon = false; @@ -149,7 +151,7 @@ export class SAMLSourceForm extends BaseSourceForm { - ${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 6ef77aa51..ddc9e92ba 100644 --- a/web/src/admin/users/UserViewPage.ts +++ b/web/src/admin/users/UserViewPage.ts @@ -22,8 +22,9 @@ import { 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"; @@ -60,7 +61,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) => { @@ -163,8 +164,7 @@ export class UserViewPage extends AKElement { renderActionButtons(user: User) { const canImpersonate = - rootInterface()?.config?.capabilities.includes(CapabilitiesEnum.CanImpersonate) && - user.pk !== this.me?.user.pk; + this.can(CapabilitiesEnum.CanImpersonate) && user.pk !== this.me?.user.pk; return html`
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..2f2bbcf43 --- /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 = true, +) { + 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..402653880 --- /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 = true, +) { + 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/enterprise/rac/index.ts b/web/src/enterprise/rac/index.ts index 272ba2211..e5a9e04e7 100644 --- a/web/src/enterprise/rac/index.ts +++ b/web/src/enterprise/rac/index.ts @@ -1,5 +1,5 @@ import { TITLE_DEFAULT } from "@goauthentik/app/common/constants"; -import { Interface } from "@goauthentik/elements/Base"; +import { Interface } from "@goauthentik/elements/Interface"; import "@goauthentik/elements/LoadingOverlay"; import Guacamole from "guacamole-common-js"; diff --git a/web/src/flow/FlowExecutor.ts b/web/src/flow/FlowExecutor.ts index bc1dec3a8..e0d31e421 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 09b10d632..aad94e73d 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";