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:
@ -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;
|
||||
}
|
||||
}
|
||||
|
67
web/src/elements/Interface/Interface.ts
Normal file
67
web/src/elements/Interface/Interface.ts
Normal 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;
|
||||
}
|
||||
}
|
20
web/src/elements/Interface/authentikConfigProvider.ts
Normal file
20
web/src/elements/Interface/authentikConfigProvider.ts
Normal 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;
|
||||
}
|
69
web/src/elements/Interface/capabilitiesProvider.ts
Normal file
69
web/src/elements/Interface/capabilitiesProvider.ts
Normal 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 };
|
4
web/src/elements/Interface/index.ts
Normal file
4
web/src/elements/Interface/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { Interface } from "./Interface";
|
||||
|
||||
export { Interface };
|
||||
export default Interface;
|
3
web/src/elements/types.ts
Normal file
3
web/src/elements/types.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export interface AdoptedStyleSheetsElement {
|
||||
adoptedStyleSheets: readonly CSSStyleSheet[];
|
||||
}
|
4
web/src/elements/utils/ensureCSSStyleSheet.ts
Normal file
4
web/src/elements/utils/ensureCSSStyleSheet.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { CSSResult } from "lit";
|
||||
|
||||
export const ensureCSSStyleSheet = (css: CSSStyleSheet | CSSResult): CSSStyleSheet =>
|
||||
css instanceof CSSResult ? css.styleSheet! : css;
|
Reference in New Issue
Block a user