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();
```
This commit is contained in:
Ken Sternberg 2023-11-29 14:32:54 -08:00
parent f559d2531f
commit 6228931305
25 changed files with 258 additions and 150 deletions

View File

@ -7,7 +7,7 @@ import {
import { configureSentry } from "@goauthentik/common/sentry"; import { configureSentry } from "@goauthentik/common/sentry";
import { me } from "@goauthentik/common/users"; import { me } from "@goauthentik/common/users";
import { WebsocketClient } from "@goauthentik/common/ws"; 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/ak-locale-context";
import "@goauthentik/elements/enterprise/EnterpriseStatusBanner"; import "@goauthentik/elements/enterprise/EnterpriseStatusBanner";
import "@goauthentik/elements/messages/MessageContainer"; import "@goauthentik/elements/messages/MessageContainer";

View File

@ -1,23 +1,25 @@
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { EVENT_SIDEBAR_TOGGLE, VERSION } from "@goauthentik/common/constants"; import { EVENT_SIDEBAR_TOGGLE, VERSION } from "@goauthentik/common/constants";
import { me } from "@goauthentik/common/users"; import { me } from "@goauthentik/common/users";
import { authentikConfigContext } from "@goauthentik/elements/AuthentikContexts";
import { AKElement } from "@goauthentik/elements/Base"; 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 { ID_REGEX, SLUG_REGEX, UUID_REGEX } from "@goauthentik/elements/router/Route";
import { getRootStyle } from "@goauthentik/elements/utils/getRootStyle"; import { getRootStyle } from "@goauthentik/elements/utils/getRootStyle";
import { spread } from "@open-wc/lit-helpers"; import { spread } from "@open-wc/lit-helpers";
import { consume } from "@lit-labs/context";
import { msg, str } from "@lit/localize"; import { msg, str } from "@lit/localize";
import { TemplateResult, html, nothing } from "lit"; import { TemplateResult, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators.js"; import { customElement, property, state } from "lit/decorators.js";
import { map } from "lit/directives/map.js"; import { map } from "lit/directives/map.js";
import { AdminApi, CapabilitiesEnum, CoreApi, UiThemeEnum, Version } from "@goauthentik/api"; import { AdminApi, CoreApi, UiThemeEnum, Version } from "@goauthentik/api";
import type { Config, SessionUser, UserSelf } from "@goauthentik/api"; import type { SessionUser, UserSelf } from "@goauthentik/api";
@customElement("ak-admin-sidebar") @customElement("ak-admin-sidebar")
export class AkAdminSidebar extends AKElement { export class AkAdminSidebar extends WithCapabilitiesConfig(AKElement) {
@property({ type: Boolean, reflect: true }) @property({ type: Boolean, reflect: true })
open = true; open = true;
@ -27,9 +29,6 @@ export class AkAdminSidebar extends AKElement {
@state() @state()
impersonation: UserSelf["username"] | null = null; impersonation: UserSelf["username"] | null = null;
@consume({ context: authentikConfigContext })
public config!: Config;
constructor() { constructor() {
super(); super();
new AdminApi(DEFAULT_CONFIG).adminVersionRetrieve().then((version) => { new AdminApi(DEFAULT_CONFIG).adminVersionRetrieve().then((version) => {
@ -200,7 +199,7 @@ export class AkAdminSidebar extends AKElement {
} }
renderEnterpriseMessage() { renderEnterpriseMessage() {
return this.config?.capabilities.includes(CapabilitiesEnum.IsEnterprise) return this.can(CapabilitiesEnum.IsEnterprise)
? html` ? html`
<ak-sidebar-item> <ak-sidebar-item>
<span slot="label">${msg("Enterprise")}</span> <span slot="label">${msg("Enterprise")}</span>

View File

@ -74,10 +74,7 @@ export class AdminOverviewPage extends AKElement {
} }
render(): TemplateResult { render(): TemplateResult {
let name = this.user?.user.username; const name = this.user?.user.name ?? this.user?.user.username;
if (this.user?.user.name) {
name = this.user.user.name;
}
return html`<ak-page-header icon="" header="" description=${msg("General system status")}> return html`<ak-page-header icon="" header="" description=${msg("General system status")}>
<span slot="header"> ${msg(str`Welcome, ${name}.`)} </span> <span slot="header"> ${msg(str`Welcome, ${name}.`)} </span>
</ak-page-header> </ak-page-header>

View File

@ -1,13 +1,16 @@
import "@goauthentik/admin/applications/ProviderSelectModal"; import "@goauthentik/admin/applications/ProviderSelectModal";
import { iconHelperText } from "@goauthentik/admin/helperText"; 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 { first } from "@goauthentik/common/utils";
import "@goauthentik/components/ak-file-input"; import "@goauthentik/components/ak-file-input";
import "@goauthentik/components/ak-radio-input"; import "@goauthentik/components/ak-radio-input";
import "@goauthentik/components/ak-switch-input"; import "@goauthentik/components/ak-switch-input";
import "@goauthentik/components/ak-text-input"; import "@goauthentik/components/ak-text-input";
import "@goauthentik/components/ak-textarea-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/FormGroup";
import "@goauthentik/elements/forms/HorizontalFormElement"; import "@goauthentik/elements/forms/HorizontalFormElement";
import "@goauthentik/elements/forms/ModalForm"; import "@goauthentik/elements/forms/ModalForm";
@ -22,13 +25,7 @@ import { TemplateResult, html } from "lit";
import { customElement, property, state } from "lit/decorators.js"; import { customElement, property, state } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js"; import { ifDefined } from "lit/directives/if-defined.js";
import { import { Application, CoreApi, PolicyEngineMode, Provider } from "@goauthentik/api";
Application,
CapabilitiesEnum,
CoreApi,
PolicyEngineMode,
Provider,
} from "@goauthentik/api";
import "./components/ak-backchannel-input"; import "./components/ak-backchannel-input";
import "./components/ak-provider-search-input"; import "./components/ak-provider-search-input";
@ -48,7 +45,7 @@ export const policyOptions = [
]; ];
@customElement("ak-application-form") @customElement("ak-application-form")
export class ApplicationForm extends ModelForm<Application, string> { export class ApplicationForm extends WithCapabilitiesConfig(ModelForm<Application, string>) {
constructor() { constructor() {
super(); super();
this.handleConfirmBackchannelProviders = this.handleConfirmBackchannelProviders.bind(this); this.handleConfirmBackchannelProviders = this.handleConfirmBackchannelProviders.bind(this);
@ -95,8 +92,7 @@ export class ApplicationForm extends ModelForm<Application, string> {
applicationRequest: data, applicationRequest: data,
}); });
} }
const c = await config(); if (this.can(CapabilitiesEnum.CanSaveMedia)) {
if (c.capabilities.includes(CapabilitiesEnum.CanSaveMedia)) {
const icon = this.getFormFiles()["metaIcon"]; const icon = this.getFormFiles()["metaIcon"];
if (icon || this.clearIcon) { if (icon || this.clearIcon) {
await new CoreApi(DEFAULT_CONFIG).coreApplicationsSetIconCreate({ await new CoreApi(DEFAULT_CONFIG).coreApplicationsSetIconCreate({
@ -142,21 +138,21 @@ export class ApplicationForm extends ModelForm<Application, string> {
return html`<form class="pf-c-form pf-m-horizontal"> return html`<form class="pf-c-form pf-m-horizontal">
<ak-text-input <ak-text-input
name="name" name="name"
value=${this.instance?.name} value=${ifDefined(this.instance?.name)}
label=${msg("Name")} label=${msg("Name")}
required required
help=${msg("Application's display Name.")} help=${msg("Application's display Name.")}
></ak-text-input> ></ak-text-input>
<ak-text-input <ak-text-input
name="slug" name="slug"
value=${this.instance?.slug} value=${ifDefined(this.instance?.slug)}
label=${msg("Slug")} label=${msg("Slug")}
required required
help=${msg("Internal application name used in URLs.")} help=${msg("Internal application name used in URLs.")}
></ak-text-input> ></ak-text-input>
<ak-text-input <ak-text-input
name="group" name="group"
value=${this.instance?.group} value=${ifDefined(this.instance?.group)}
label=${msg("Group")} label=${msg("Group")}
help=${msg( help=${msg(
"Optionally enter a group name. Applications with identical groups are shown grouped together.", "Optionally enter a group name. Applications with identical groups are shown grouped together.",
@ -165,7 +161,7 @@ export class ApplicationForm extends ModelForm<Application, string> {
<ak-provider-search-input <ak-provider-search-input
name="provider" name="provider"
label=${msg("Provider")} label=${msg("Provider")}
value=${this.instance?.provider} value=${ifDefined(this.instance?.provider ?? undefined)}
help=${msg("Select a provider that this application should use.")} help=${msg("Select a provider that this application should use.")}
blankable blankable
></ak-provider-search-input> ></ak-provider-search-input>
@ -211,11 +207,11 @@ export class ApplicationForm extends ModelForm<Application, string> {
)} )}
> >
</ak-switch-input> </ak-switch-input>
${rootInterface()?.config?.capabilities.includes(CapabilitiesEnum.CanSaveMedia) ${this.can(CapabilitiesEnum.CanSaveMedia)
? html`<ak-file-input ? html`<ak-file-input
label="${msg("Icon")}" label="${msg("Icon")}"
name="metaIcon" name="metaIcon"
value=${this.instance?.metaIcon} value=${ifDefined(this.instance?.metaIcon ?? undefined)}
current=${msg("Currently set to:")} current=${msg("Currently set to:")}
></ak-file-input> ></ak-file-input>
${this.instance?.metaIcon ${this.instance?.metaIcon

View File

@ -1,8 +1,11 @@
import { DesignationToLabel, LayoutToLabel } from "@goauthentik/admin/flows/utils"; import { DesignationToLabel, LayoutToLabel } from "@goauthentik/admin/flows/utils";
import { AuthenticationEnum } from "@goauthentik/api/dist/models/AuthenticationEnum"; 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 { 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/FormGroup";
import "@goauthentik/elements/forms/HorizontalFormElement"; import "@goauthentik/elements/forms/HorizontalFormElement";
import { ModelForm } from "@goauthentik/elements/forms/ModelForm"; 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 { ifDefined } from "lit/directives/if-defined.js";
import { import {
CapabilitiesEnum,
DeniedActionEnum, DeniedActionEnum,
Flow, Flow,
FlowDesignationEnum, FlowDesignationEnum,
@ -24,7 +26,7 @@ import {
} from "@goauthentik/api"; } from "@goauthentik/api";
@customElement("ak-flow-form") @customElement("ak-flow-form")
export class FlowForm extends ModelForm<Flow, string> { export class FlowForm extends WithCapabilitiesConfig(ModelForm<Flow, string>) {
async loadInstance(pk: string): Promise<Flow> { async loadInstance(pk: string): Promise<Flow> {
const flow = await new FlowsApi(DEFAULT_CONFIG).flowsInstancesRetrieve({ const flow = await new FlowsApi(DEFAULT_CONFIG).flowsInstancesRetrieve({
slug: pk, slug: pk,
@ -56,8 +58,8 @@ export class FlowForm extends ModelForm<Flow, string> {
flowRequest: data, flowRequest: data,
}); });
} }
const c = await config();
if (c.capabilities.includes(CapabilitiesEnum.CanSaveMedia)) { if (this.can(CapabilitiesEnum.CanSaveMedia)) {
const icon = this.getFormFiles()["background"]; const icon = this.getFormFiles()["background"];
if (icon || this.clearBackground) { if (icon || this.clearBackground) {
await new FlowsApi(DEFAULT_CONFIG).flowsInstancesSetBackgroundCreate({ await new FlowsApi(DEFAULT_CONFIG).flowsInstancesSetBackgroundCreate({
@ -335,7 +337,7 @@ export class FlowForm extends ModelForm<Flow, string> {
</option> </option>
</select> </select>
</ak-form-element-horizontal> </ak-form-element-horizontal>
${rootInterface()?.config?.capabilities.includes(CapabilitiesEnum.CanSaveMedia) ${this.can(CapabilitiesEnum.CanSaveMedia)
? html`<ak-form-element-horizontal ? html`<ak-form-element-horizontal
label=${msg("Background")} label=${msg("Background")}
name="background" name="background"

View File

@ -118,7 +118,7 @@ export class GroupViewPage extends AKElement {
<div class="pf-c-description-list__text"> <div class="pf-c-description-list__text">
<ak-status-label <ak-status-label
type="warning" type="warning"
?good=${this.group.isSuperuser} ?good${this.group.isSuperuser}
></ak-status-label> ></ak-status-label>
</div> </div>
</dd> </dd>

View File

@ -10,6 +10,10 @@ import { uiConfig } from "@goauthentik/common/ui/config";
import { first } from "@goauthentik/common/utils"; import { first } from "@goauthentik/common/utils";
import "@goauthentik/components/ak-status-label"; import "@goauthentik/components/ak-status-label";
import { rootInterface } from "@goauthentik/elements/Base"; import { rootInterface } from "@goauthentik/elements/Base";
import {
CapabilitiesEnum,
WithCapabilitiesConfig,
} from "@goauthentik/elements/Interface/capabilitiesProvider";
import "@goauthentik/elements/buttons/ActionButton"; import "@goauthentik/elements/buttons/ActionButton";
import "@goauthentik/elements/buttons/Dropdown"; import "@goauthentik/elements/buttons/Dropdown";
import "@goauthentik/elements/forms/DeleteBulkForm"; 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 PFDescriptionList from "@patternfly/patternfly/components/DescriptionList/description-list.css";
import { import {
CapabilitiesEnum,
CoreApi, CoreApi,
CoreUsersListTypeEnum, CoreUsersListTypeEnum,
Group, Group,
@ -107,7 +110,7 @@ export class RelatedUserAdd extends Form<{ users: number[] }> {
} }
@customElement("ak-user-related-list") @customElement("ak-user-related-list")
export class RelatedUserList extends Table<User> { export class RelatedUserList extends WithCapabilitiesConfig(Table<User>) {
expandable = true; expandable = true;
checkbox = true; checkbox = true;
@ -188,8 +191,7 @@ export class RelatedUserList extends Table<User> {
row(item: User): TemplateResult[] { row(item: User): TemplateResult[] {
const canImpersonate = const canImpersonate =
rootInterface()?.config?.capabilities.includes(CapabilitiesEnum.CanImpersonate) && this.can(CapabilitiesEnum.CanImpersonate) && item.pk !== this.me?.user.pk;
item.pk !== this.me?.user.pk;
return [ return [
html`<a href="#/identity/users/${item.pk}"> html`<a href="#/identity/users/${item.pk}">
<div>${item.username}</div> <div>${item.username}</div>

View File

@ -3,9 +3,12 @@ import { iconHelperText, placeholderHelperText } from "@goauthentik/admin/helper
import { UserMatchingModeToLabel } from "@goauthentik/admin/sources/oauth/utils"; import { UserMatchingModeToLabel } from "@goauthentik/admin/sources/oauth/utils";
import { DEFAULT_CONFIG, config } from "@goauthentik/common/api/config"; import { DEFAULT_CONFIG, config } from "@goauthentik/common/api/config";
import { first } from "@goauthentik/common/utils"; import { first } from "@goauthentik/common/utils";
import { rootInterface } from "@goauthentik/elements/Base";
import "@goauthentik/elements/CodeMirror"; import "@goauthentik/elements/CodeMirror";
import { CodeMirrorMode } from "@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/FormGroup";
import "@goauthentik/elements/forms/HorizontalFormElement"; import "@goauthentik/elements/forms/HorizontalFormElement";
import { ModelForm } from "@goauthentik/elements/forms/ModelForm"; 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 { ifDefined } from "lit/directives/if-defined.js";
import { import {
CapabilitiesEnum,
FlowsInstancesListDesignationEnum, FlowsInstancesListDesignationEnum,
OAuthSource, OAuthSource,
OAuthSourceRequest, OAuthSourceRequest,
@ -28,7 +30,7 @@ import {
} from "@goauthentik/api"; } from "@goauthentik/api";
@customElement("ak-source-oauth-form") @customElement("ak-source-oauth-form")
export class OAuthSourceForm extends ModelForm<OAuthSource, string> { export class OAuthSourceForm extends WithCapabilitiesConfig(ModelForm<OAuthSource, string>) {
async loadInstance(pk: string): Promise<OAuthSource> { async loadInstance(pk: string): Promise<OAuthSource> {
const source = await new SourcesApi(DEFAULT_CONFIG).sourcesOauthRetrieve({ const source = await new SourcesApi(DEFAULT_CONFIG).sourcesOauthRetrieve({
slug: pk, slug: pk,
@ -326,7 +328,7 @@ export class OAuthSourceForm extends ModelForm<OAuthSource, string> {
/> />
<p class="pf-c-form__helper-text">${placeholderHelperText}</p> <p class="pf-c-form__helper-text">${placeholderHelperText}</p>
</ak-form-element-horizontal> </ak-form-element-horizontal>
${rootInterface()?.config?.capabilities.includes(CapabilitiesEnum.CanSaveMedia) ${this.can(CapabilitiesEnum.CanSaveMedia)
? html`<ak-form-element-horizontal label=${msg("Icon")} name="icon"> ? html`<ak-form-element-horizontal label=${msg("Icon")} name="icon">
<input type="file" value="" class="pf-c-form-control" /> <input type="file" value="" class="pf-c-form-control" />
${this.instance?.icon ${this.instance?.icon

View File

@ -1,10 +1,13 @@
import "@goauthentik/admin/common/ak-flow-search/ak-source-flow-search"; import "@goauthentik/admin/common/ak-flow-search/ak-source-flow-search";
import { iconHelperText, placeholderHelperText } from "@goauthentik/admin/helperText"; import { iconHelperText, placeholderHelperText } from "@goauthentik/admin/helperText";
import { UserMatchingModeToLabel } from "@goauthentik/admin/sources/oauth/utils"; 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 { PlexAPIClient, PlexResource, popupCenterScreen } from "@goauthentik/common/helpers/plex";
import { ascii_letters, digits, first, randomString } from "@goauthentik/common/utils"; 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/FormGroup";
import "@goauthentik/elements/forms/HorizontalFormElement"; import "@goauthentik/elements/forms/HorizontalFormElement";
import { ModelForm } from "@goauthentik/elements/forms/ModelForm"; 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 { ifDefined } from "lit/directives/if-defined.js";
import { import {
CapabilitiesEnum,
FlowsInstancesListDesignationEnum, FlowsInstancesListDesignationEnum,
PlexSource, PlexSource,
SourcesApi, SourcesApi,
@ -24,7 +26,7 @@ import {
} from "@goauthentik/api"; } from "@goauthentik/api";
@customElement("ak-source-plex-form") @customElement("ak-source-plex-form")
export class PlexSourceForm extends ModelForm<PlexSource, string> { export class PlexSourceForm extends WithCapabilitiesConfig(ModelForm<PlexSource, string>) {
async loadInstance(pk: string): Promise<PlexSource> { async loadInstance(pk: string): Promise<PlexSource> {
const source = await new SourcesApi(DEFAULT_CONFIG).sourcesPlexRetrieve({ const source = await new SourcesApi(DEFAULT_CONFIG).sourcesPlexRetrieve({
slug: pk, slug: pk,
@ -71,8 +73,7 @@ export class PlexSourceForm extends ModelForm<PlexSource, string> {
plexSourceRequest: data, plexSourceRequest: data,
}); });
} }
const c = await config(); if (this.can(CapabilitiesEnum.CanSaveMedia)) {
if (c.capabilities.includes(CapabilitiesEnum.CanSaveMedia)) {
const icon = this.getFormFiles()["icon"]; const icon = this.getFormFiles()["icon"];
if (icon || this.clearIcon) { if (icon || this.clearIcon) {
await new SourcesApi(DEFAULT_CONFIG).sourcesAllSetIconCreate({ await new SourcesApi(DEFAULT_CONFIG).sourcesAllSetIconCreate({
@ -263,7 +264,7 @@ export class PlexSourceForm extends ModelForm<PlexSource, string> {
/> />
<p class="pf-c-form__helper-text">${placeholderHelperText}</p> <p class="pf-c-form__helper-text">${placeholderHelperText}</p>
</ak-form-element-horizontal> </ak-form-element-horizontal>
${rootInterface()?.config?.capabilities.includes(CapabilitiesEnum.CanSaveMedia) ${this.can(CapabilitiesEnum.CanSaveMedia)
? html`<ak-form-element-horizontal label=${msg("Icon")} name="icon"> ? html`<ak-form-element-horizontal label=${msg("Icon")} name="icon">
<input type="file" value="" class="pf-c-form-control" /> <input type="file" value="" class="pf-c-form-control" />
${this.instance?.icon ${this.instance?.icon

View File

@ -4,7 +4,10 @@ import { iconHelperText, placeholderHelperText } from "@goauthentik/admin/helper
import { UserMatchingModeToLabel } from "@goauthentik/admin/sources/oauth/utils"; import { UserMatchingModeToLabel } from "@goauthentik/admin/sources/oauth/utils";
import { DEFAULT_CONFIG, config } from "@goauthentik/common/api/config"; import { DEFAULT_CONFIG, config } from "@goauthentik/common/api/config";
import { first } from "@goauthentik/common/utils"; 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/FormGroup";
import "@goauthentik/elements/forms/HorizontalFormElement"; import "@goauthentik/elements/forms/HorizontalFormElement";
import { ModelForm } from "@goauthentik/elements/forms/ModelForm"; import { ModelForm } from "@goauthentik/elements/forms/ModelForm";
@ -18,7 +21,6 @@ import { ifDefined } from "lit/directives/if-defined.js";
import { import {
BindingTypeEnum, BindingTypeEnum,
CapabilitiesEnum,
DigestAlgorithmEnum, DigestAlgorithmEnum,
FlowsInstancesListDesignationEnum, FlowsInstancesListDesignationEnum,
NameIdPolicyEnum, NameIdPolicyEnum,
@ -29,7 +31,7 @@ import {
} from "@goauthentik/api"; } from "@goauthentik/api";
@customElement("ak-source-saml-form") @customElement("ak-source-saml-form")
export class SAMLSourceForm extends ModelForm<SAMLSource, string> { export class SAMLSourceForm extends WithCapabilitiesConfig(ModelForm<SAMLSource, string>) {
@state() @state()
clearIcon = false; clearIcon = false;
@ -157,7 +159,7 @@ export class SAMLSourceForm extends ModelForm<SAMLSource, string> {
</option> </option>
</select> </select>
</ak-form-element-horizontal> </ak-form-element-horizontal>
${rootInterface()?.config?.capabilities.includes(CapabilitiesEnum.CanSaveMedia) ${this.can(CapabilitiesEnum.CanSaveMedia)
? html`<ak-form-element-horizontal label=${msg("Icon")} name="icon"> ? html`<ak-form-element-horizontal label=${msg("Icon")} name="icon">
<input type="file" value="" class="pf-c-form-control" /> <input type="file" value="" class="pf-c-form-control" />
${this.instance?.icon ${this.instance?.icon

View File

@ -12,6 +12,10 @@ import { DefaultUIConfig, uiConfig } from "@goauthentik/common/ui/config";
import { first } from "@goauthentik/common/utils"; import { first } from "@goauthentik/common/utils";
import "@goauthentik/components/ak-status-label"; import "@goauthentik/components/ak-status-label";
import { rootInterface } from "@goauthentik/elements/Base"; import { rootInterface } from "@goauthentik/elements/Base";
import {
CapabilitiesEnum,
WithCapabilitiesConfig,
} from "@goauthentik/elements/Interface/capabilitiesProvider";
import { PFSize } from "@goauthentik/elements/Spinner"; import { PFSize } from "@goauthentik/elements/Spinner";
import "@goauthentik/elements/TreeView"; import "@goauthentik/elements/TreeView";
import "@goauthentik/elements/buttons/ActionButton"; 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 PFCard from "@patternfly/patternfly/components/Card/card.css";
import PFDescriptionList from "@patternfly/patternfly/components/DescriptionList/description-list.css"; import PFDescriptionList from "@patternfly/patternfly/components/DescriptionList/description-list.css";
import { import { CoreApi, ResponseError, SessionUser, User, UserPath } from "@goauthentik/api";
CapabilitiesEnum,
CoreApi,
ResponseError,
SessionUser,
User,
UserPath,
} from "@goauthentik/api";
export const requestRecoveryLink = (user: User) => export const requestRecoveryLink = (user: User) =>
new CoreApi(DEFAULT_CONFIG) new CoreApi(DEFAULT_CONFIG)
@ -93,7 +90,7 @@ const recoveryButtonStyles = css`
`; `;
@customElement("ak-user-list") @customElement("ak-user-list")
export class UserListPage extends TablePage<User> { export class UserListPage extends WithCapabilitiesConfig(TablePage<User>) {
expandable = true; expandable = true;
checkbox = true; checkbox = true;
@ -244,8 +241,7 @@ export class UserListPage extends TablePage<User> {
row(item: User): TemplateResult[] { row(item: User): TemplateResult[] {
const canImpersonate = const canImpersonate =
rootInterface()?.config?.capabilities.includes(CapabilitiesEnum.CanImpersonate) && this.can(CapabilitiesEnum.CanImpersonate) && item.pk !== this.me?.user.pk;
item.pk !== this.me?.user.pk;
return [ return [
html`<a href="#/identity/users/${item.pk}"> html`<a href="#/identity/users/${item.pk}">
<div>${item.username}</div> <div>${item.username}</div>

View File

@ -17,8 +17,9 @@ import { userTypeToLabel } from "@goauthentik/common/labels";
import "@goauthentik/components/ak-status-label"; import "@goauthentik/components/ak-status-label";
import "@goauthentik/components/events/ObjectChangelog"; import "@goauthentik/components/events/ObjectChangelog";
import "@goauthentik/components/events/UserEvents"; import "@goauthentik/components/events/UserEvents";
import { AKElement, rootInterface } from "@goauthentik/elements/Base"; import { AKElement } from "@goauthentik/elements/Base";
import "@goauthentik/elements/CodeMirror"; import "@goauthentik/elements/CodeMirror";
import { WithCapabilitiesConfig } from "@goauthentik/elements/Interface/capabilitiesProvider";
import "@goauthentik/elements/PageHeader"; import "@goauthentik/elements/PageHeader";
import { PFSize } from "@goauthentik/elements/Spinner"; import { PFSize } from "@goauthentik/elements/Spinner";
import "@goauthentik/elements/Tabs"; import "@goauthentik/elements/Tabs";
@ -55,7 +56,7 @@ import {
import "./UserDevicesTable"; import "./UserDevicesTable";
@customElement("ak-user-view") @customElement("ak-user-view")
export class UserViewPage extends AKElement { export class UserViewPage extends WithCapabilitiesConfig(AKElement) {
@property({ type: Number }) @property({ type: Number })
set userId(id: number) { set userId(id: number) {
me().then((me) => { me().then((me) => {
@ -138,8 +139,9 @@ export class UserViewPage extends AKElement {
const user = this.user; const user = this.user;
const canImpersonate = const canImpersonate =
rootInterface()?.config?.capabilities.includes(CapabilitiesEnum.CanImpersonate) && this.can(CapabilitiesEnum.CanImpersonate) && this.user.pk !== this.me?.user.pk;
this.user.pk !== this.me?.user.pk;
console.log(canImpersonate);
return html` return html`
<div class="pf-c-card__title">${msg("User Info")}</div> <div class="pf-c-card__title">${msg("User Info")}</div>

View File

@ -1,20 +1,18 @@
import { config, tenant } from "@goauthentik/common/api/config";
import { EVENT_THEME_CHANGE } from "@goauthentik/common/constants"; 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 { 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 { localized } from "@lit/localize";
import { CSSResult, LitElement } from "lit"; import { LitElement } from "lit";
import { state } from "lit/decorators.js";
import AKGlobal from "@goauthentik/common/styles/authentik.css"; import AKGlobal from "@goauthentik/common/styles/authentik.css";
import ThemeDark from "@goauthentik/common/styles/theme-dark.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 { Config, CurrentTenant, UiThemeEnum } from "@goauthentik/api";
import { AdoptedStyleSheetsElement } from "./types";
type AkInterface = HTMLElement & { type AkInterface = HTMLElement & {
getTheme: () => Promise<UiThemeEnum>; getTheme: () => Promise<UiThemeEnum>;
tenant?: CurrentTenant; tenant?: CurrentTenant;
@ -25,13 +23,6 @@ type AkInterface = HTMLElement & {
export const rootInterface = <T extends AkInterface>(): T | undefined => export const rootInterface = <T extends AkInterface>(): T | undefined =>
(document.body.querySelector("[data-ak-interface-root]") as 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<string[]> | undefined; let css: Promise<string[]> | undefined;
function fetchCustomCSS(): Promise<string[]> { function fetchCustomCSS(): Promise<string[]> {
if (!css) { if (!css) {
@ -52,10 +43,6 @@ function fetchCustomCSS(): Promise<string[]> {
return css; return css;
} }
export interface AdoptedStyleSheetsElement {
adoptedStyleSheets: readonly CSSStyleSheet[];
}
const QUERY_MEDIA_COLOR_LIGHT = "(prefers-color-scheme: light)"; const QUERY_MEDIA_COLOR_LIGHT = "(prefers-color-scheme: light)";
@localized() @localized()
@ -175,49 +162,3 @@ export class AKElement extends LitElement {
this.requestUpdate(); 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<UiThemeEnum> {
if (!this.uiConfig) {
this.uiConfig = await uiConfig();
}
return this.uiConfig.theme?.base || UiThemeEnum.Automatic;
}
}

View File

@ -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<UiThemeEnum>;
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<UiThemeEnum> {
if (!this.uiConfig) {
this.uiConfig = await uiConfig();
}
return this.uiConfig.theme?.base || UiThemeEnum.Automatic;
}
}

View File

@ -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<T = object> = new (...args: any[]) => T;
export function WithAuthentikConfig<T extends Constructor<LitElement>>(
superclass: T,
subscribe = false,
) {
class WithAkConfigProvider extends superclass {
@consume({ context: authentikConfigContext, subscribe })
public authentikConfig!: Config;
}
return WithAkConfigProvider;
}

View File

@ -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<T = object> = 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<T extends Constructor<LitElement>>(
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 };

View File

@ -0,0 +1,4 @@
import { Interface } from "./Interface";
export { Interface };
export default Interface;

View File

@ -0,0 +1,3 @@
export interface AdoptedStyleSheetsElement {
adoptedStyleSheets: readonly CSSStyleSheet[];
}

View File

@ -0,0 +1,4 @@
import { CSSResult } from "lit";
export const ensureCSSStyleSheet = (css: CSSStyleSheet | CSSResult): CSSStyleSheet =>
css instanceof CSSResult ? css.styleSheet! : css;

View File

@ -8,7 +8,7 @@ import { globalAK } from "@goauthentik/common/global";
import { configureSentry } from "@goauthentik/common/sentry"; import { configureSentry } from "@goauthentik/common/sentry";
import { first } from "@goauthentik/common/utils"; import { first } from "@goauthentik/common/utils";
import { WebsocketClient } from "@goauthentik/common/ws"; 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/LoadingOverlay";
import "@goauthentik/elements/ak-locale-context"; import "@goauthentik/elements/ak-locale-context";
import "@goauthentik/flow/sources/apple/AppleLoginInit"; import "@goauthentik/flow/sources/apple/AppleLoginInit";

View File

@ -1,6 +1,9 @@
import { rootInterface } from "@goauthentik/elements/Base";
import "@goauthentik/elements/Divider"; import "@goauthentik/elements/Divider";
import "@goauthentik/elements/EmptyState"; import "@goauthentik/elements/EmptyState";
import {
CapabilitiesEnum,
WithCapabilitiesConfig,
} from "@goauthentik/elements/Interface/capabilitiesProvider";
import { LOCALES } from "@goauthentik/elements/ak-locale-context/definitions"; import { LOCALES } from "@goauthentik/elements/ak-locale-context/definitions";
import "@goauthentik/elements/forms/FormElement"; import "@goauthentik/elements/forms/FormElement";
import { BaseStage } from "@goauthentik/flow/stages/base"; 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 PFBase from "@patternfly/patternfly/patternfly-base.css";
import { import {
CapabilitiesEnum,
PromptChallenge, PromptChallenge,
PromptChallengeResponseRequest, PromptChallengeResponseRequest,
PromptTypeEnum, PromptTypeEnum,
@ -28,7 +30,9 @@ import {
} from "@goauthentik/api"; } from "@goauthentik/api";
@customElement("ak-stage-prompt") @customElement("ak-stage-prompt")
export class PromptStage extends BaseStage<PromptChallenge, PromptChallengeResponseRequest> { export class PromptStage extends WithCapabilitiesConfig(
BaseStage<PromptChallenge, PromptChallengeResponseRequest>,
) {
static get styles(): CSSResult[] { static get styles(): CSSResult[] {
return [ return [
PFBase, PFBase,
@ -193,10 +197,7 @@ ${prompt.initialValue}</textarea
</div> `; </div> `;
})}`; })}`;
case PromptTypeEnum.AkLocale: { case PromptTypeEnum.AkLocale: {
const inDebug = rootInterface()?.config?.capabilities.includes( const locales = this.can(CapabilitiesEnum.CanDebug)
CapabilitiesEnum.CanDebug,
);
const locales = inDebug
? LOCALES ? LOCALES
: LOCALES.filter((locale) => locale.code !== "debug"); : LOCALES.filter((locale) => locale.code !== "debug");
const options = locales.map( const options = locales.map(

View File

@ -2,7 +2,7 @@ import { CSRFHeaderName } from "@goauthentik/common/api/middleware";
import { EVENT_THEME_CHANGE } from "@goauthentik/common/constants"; import { EVENT_THEME_CHANGE } from "@goauthentik/common/constants";
import { globalAK } from "@goauthentik/common/global"; import { globalAK } from "@goauthentik/common/global";
import { first, getCookie } from "@goauthentik/common/utils"; 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 "@goauthentik/elements/ak-locale-context";
import { DefaultTenant } from "@goauthentik/elements/sidebar/SidebarBrand"; import { DefaultTenant } from "@goauthentik/elements/sidebar/SidebarBrand";
import "rapidoc"; import "rapidoc";

View File

@ -1,5 +1,5 @@
import { globalAK } from "@goauthentik/common/global"; import { globalAK } from "@goauthentik/common/global";
import { Interface } from "@goauthentik/elements/Base"; import { Interface } from "@goauthentik/elements/Interface";
import { msg } from "@lit/localize"; import { msg } from "@lit/localize";
import { CSSResult, TemplateResult, css, html } from "lit"; import { CSSResult, TemplateResult, css, html } from "lit";

View File

@ -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"; import { customElement, property } from "lit/decorators.js";

View File

@ -9,7 +9,7 @@ import { UserDisplay } from "@goauthentik/common/ui/config";
import { me } from "@goauthentik/common/users"; import { me } from "@goauthentik/common/users";
import { first } from "@goauthentik/common/utils"; import { first } from "@goauthentik/common/utils";
import { WebsocketClient } from "@goauthentik/common/ws"; 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/ak-locale-context";
import "@goauthentik/elements/buttons/ActionButton"; import "@goauthentik/elements/buttons/ActionButton";
import "@goauthentik/elements/enterprise/EnterpriseStatusBanner"; import "@goauthentik/elements/enterprise/EnterpriseStatusBanner";