web: abstract `rootInterface()?.config?.capabilities.includes()` into `.can()` (#7737)

* 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.
This commit is contained in:
Ken Sternberg 2024-01-08 10:22:52 -08:00 committed by GitHub
parent c9dc500a2b
commit d555c0db41
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 343 additions and 151 deletions

View File

@ -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

View File

@ -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";

View File

@ -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`
<ak-sidebar-item>
<span slot="label">${msg("Enterprise")}</span>

View File

@ -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`<ak-page-header icon="" header="" description=${msg("General system status")}>
<span slot="header"> ${msg(str`Welcome, ${name}.`)} </span>
</ak-page-header>

View File

@ -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<Application, string> {
export class ApplicationForm extends WithCapabilitiesConfig(ModelForm<Application, string>) {
constructor() {
super();
this.handleConfirmBackchannelProviders = this.handleConfirmBackchannelProviders.bind(this);
@ -93,8 +90,7 @@ export class ApplicationForm extends ModelForm<Application, string> {
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<Application, string> {
return html`<form class="pf-c-form pf-m-horizontal">
<ak-text-input
name="name"
.value=${this.instance?.name}
value=${ifDefined(this.instance?.name)}
label=${msg("Name")}
required
help=${msg("Application's display Name.")}
></ak-text-input>
<ak-text-input
name="slug"
.value=${this.instance?.slug}
value=${ifDefined(this.instance?.slug)}
label=${msg("Slug")}
required
help=${msg("Internal application name used in URLs.")}
></ak-text-input>
<ak-text-input
name="group"
.value=${this.instance?.group}
value=${ifDefined(this.instance?.group)}
label=${msg("Group")}
help=${msg(
"Optionally enter a group name. Applications with identical groups are shown grouped together.",
@ -163,7 +159,7 @@ export class ApplicationForm extends ModelForm<Application, string> {
<ak-provider-search-input
name="provider"
label=${msg("Provider")}
.value=${this.instance?.provider}
value=${ifDefined(this.instance?.provider ?? undefined)}
help=${msg("Select a provider that this application should use.")}
blankable
></ak-provider-search-input>
@ -209,11 +205,11 @@ export class ApplicationForm extends ModelForm<Application, string> {
)}
>
</ak-switch-input>
${rootInterface()?.config?.capabilities.includes(CapabilitiesEnum.CanSaveMedia)
${this.can(CapabilitiesEnum.CanSaveMedia)
? html`<ak-file-input
label="${msg("Icon")}"
name="metaIcon"
.value=${this.instance?.metaIcon}
value=${ifDefined(this.instance?.metaIcon ?? undefined)}
current=${msg("Currently set to:")}
></ak-file-input>
${this.instance?.metaIcon

View File

@ -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<Flow, string> {
export class FlowForm extends WithCapabilitiesConfig(ModelForm<Flow, string>) {
async loadInstance(pk: string): Promise<Flow> {
const flow = await new FlowsApi(DEFAULT_CONFIG).flowsInstancesRetrieve({
slug: pk,
@ -54,8 +56,8 @@ export class FlowForm extends ModelForm<Flow, string> {
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<Flow, string> {
</option>
</select>
</ak-form-element-horizontal>
${rootInterface()?.config?.capabilities.includes(CapabilitiesEnum.CanSaveMedia)
${this.can(CapabilitiesEnum.CanSaveMedia)
? html`<ak-form-element-horizontal
label=${msg("Background")}
name="background"

View File

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

View File

@ -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<User> {
export class RelatedUserList extends WithCapabilitiesConfig(Table<User>) {
expandable = true;
checkbox = true;
@ -188,8 +191,7 @@ export class RelatedUserList extends Table<User> {
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`<a href="#/identity/users/${item.pk}">
<div>${item.username}</div>

View File

@ -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<OAuthSource> {
export class OAuthSourceForm extends WithCapabilitiesConfig(BaseSourceForm<OAuthSource>) {
async loadInstance(pk: string): Promise<OAuthSource> {
const source = await new SourcesApi(DEFAULT_CONFIG).sourcesOauthRetrieve({
slug: pk,
@ -318,7 +320,7 @@ export class OAuthSourceForm extends BaseSourceForm<OAuthSource> {
/>
<p class="pf-c-form__helper-text">${placeholderHelperText}</p>
</ak-form-element-horizontal>
${rootInterface()?.config?.capabilities.includes(CapabilitiesEnum.CanSaveMedia)
${this.can(CapabilitiesEnum.CanSaveMedia)
? html`<ak-form-element-horizontal label=${msg("Icon")} name="icon">
<input type="file" value="" class="pf-c-form-control" />
${this.instance?.icon

View File

@ -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<PlexSource> {
export class PlexSourceForm extends WithCapabilitiesConfig(BaseSourceForm<PlexSource>) {
async loadInstance(pk: string): Promise<PlexSource> {
const source = await new SourcesApi(DEFAULT_CONFIG).sourcesPlexRetrieve({
slug: pk,
@ -63,8 +65,7 @@ export class PlexSourceForm extends BaseSourceForm<PlexSource> {
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<PlexSource> {
/>
<p class="pf-c-form__helper-text">${placeholderHelperText}</p>
</ak-form-element-horizontal>
${rootInterface()?.config?.capabilities.includes(CapabilitiesEnum.CanSaveMedia)
${this.can(CapabilitiesEnum.CanSaveMedia)
? html`<ak-form-element-horizontal label=${msg("Icon")} name="icon">
<input type="file" value="" class="pf-c-form-control" />
${this.instance?.icon

View File

@ -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<SAMLSource> {
export class SAMLSourceForm extends WithCapabilitiesConfig(BaseSourceForm<SAMLSource>) {
@state()
clearIcon = false;
@ -149,7 +151,7 @@ export class SAMLSourceForm extends BaseSourceForm<SAMLSource> {
</option>
</select>
</ak-form-element-horizontal>
${rootInterface()?.config?.capabilities.includes(CapabilitiesEnum.CanSaveMedia)
${this.can(CapabilitiesEnum.CanSaveMedia)
? html`<ak-form-element-horizontal label=${msg("Icon")} name="icon">
<input type="file" value="" class="pf-c-form-control" />
${this.instance?.icon

View File

@ -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<User> {
export class UserListPage extends WithCapabilitiesConfig(TablePage<User>) {
expandable = true;
checkbox = true;
@ -244,8 +241,7 @@ export class UserListPage extends TablePage<User> {
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`<a href="#/identity/users/${item.pk}">
<div>${item.username}</div>

View File

@ -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`<div class="ak-button-collection">
<ak-forms-modal>

View File

@ -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<UiThemeEnum>;
tenant?: CurrentTenant;
@ -25,13 +23,6 @@ type AkInterface = HTMLElement & {
export const rootInterface = <T extends AkInterface>(): 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;
function fetchCustomCSS(): Promise<string[]> {
if (!css) {
@ -52,10 +43,6 @@ function fetchCustomCSS(): Promise<string[]> {
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<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 = true,
) {
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 = 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 };

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

@ -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";

View File

@ -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";

View File

@ -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<PromptChallenge, PromptChallengeResponseRequest> {
export class PromptStage extends WithCapabilitiesConfig(
BaseStage<PromptChallenge, PromptChallengeResponseRequest>,
) {
static get styles(): CSSResult[] {
return [
PFBase,
@ -193,10 +197,7 @@ ${prompt.initialValue}</textarea
</div> `;
})}`;
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(

View File

@ -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";

View File

@ -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";

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";

View File

@ -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";