web: provide a context for enterprise license status

There are a few places (currently 5) in our code where we have checks for the current enterprise
licensing status of our product. While not particularly heavy or onerous, there's no reason to
repeat those same lines, and since our UI is always running in the context of authentik, may as well
make that status a client-side context in its own right. The status will update with an
EVENT_REFRESH request.

A context-aware custom alert has also been provided; it draws itself (or `nothing`) depending on the
state of the license, and the default message, "This feature requires an enterprise license," can be
overriden with the `notice` property.

These two changes reduce the amount of code needed to manage our license alerting from 67 to 38
lines code, and while removing 29 lines from a product with 54,145 lines of code (a savings of
0.05%, oh boy!) isn't a miracle, it does mean there's a single source of truth for "Is this instance
enterprise-licensed?" that's easy to access and use.
This commit is contained in:
Ken Sternberg 2024-01-12 14:21:16 -08:00
parent be66ee52cd
commit b36e057a92
7 changed files with 115 additions and 66 deletions

View File

@ -0,0 +1,24 @@
import "@goauthentik/elements/Alert";
import { AKElement } from "@goauthentik/elements/Base";
import { WithLicenseSummary } from "@goauthentik/elements/Interface/licenseSummaryProvider";
import { msg } from "@lit/localize";
import { html, nothing } from "lit";
import { customElement, property } from "lit/decorators.js";
@customElement("ak-license-notice")
export class AkLicenceNotice extends WithLicenseSummary(AKElement) {
@property()
notice = msg("This feature requires an enterprise license.");
render() {
return this.hasEnterpriseLicense
? nothing
: html`
<ak-alert class="pf-c-radio__description" inline>
${this.notice}
<a href="#/enterprise/licenses">${msg("Learn more")}</a>
</ak-alert>
`;
}
}

View File

@ -1,9 +1,11 @@
import "@goauthentik/admin/common/ak-license-notice";
import "@goauthentik/admin/property-mappings/PropertyMappingLDAPForm";
import "@goauthentik/admin/property-mappings/PropertyMappingNotification";
import "@goauthentik/admin/property-mappings/PropertyMappingRACForm";
import "@goauthentik/admin/property-mappings/PropertyMappingSAMLForm";
import "@goauthentik/admin/property-mappings/PropertyMappingScopeForm";
import "@goauthentik/admin/property-mappings/PropertyMappingTestForm";
import { WithLicenseSummary } from "@goauthentik/app/elements/Interface/licenseSummaryProvider";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { AKElement } from "@goauthentik/elements/Base";
import "@goauthentik/elements/forms/ProxyForm";
@ -14,23 +16,20 @@ import { WizardPage } from "@goauthentik/elements/wizard/WizardPage";
import { msg, str } from "@lit/localize";
import { customElement } from "@lit/reactive-element/decorators/custom-element.js";
import { CSSResult, TemplateResult, html, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import { property } from "lit/decorators.js";
import PFButton from "@patternfly/patternfly/components/Button/button.css";
import PFForm from "@patternfly/patternfly/components/Form/form.css";
import PFRadio from "@patternfly/patternfly/components/Radio/radio.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import { EnterpriseApi, LicenseSummary, PropertymappingsApi, TypeCreate } from "@goauthentik/api";
import { PropertymappingsApi, TypeCreate } from "@goauthentik/api";
@customElement("ak-property-mapping-wizard-initial")
export class InitialPropertyMappingWizardPage extends WizardPage {
export class InitialPropertyMappingWizardPage extends WithLicenseSummary(WizardPage) {
@property({ attribute: false })
mappingTypes: TypeCreate[] = [];
@property({ attribute: false })
enterprise?: LicenseSummary;
static get styles(): CSSResult[] {
return [PFBase, PFForm, PFButton, PFRadio];
}
@ -50,6 +49,7 @@ export class InitialPropertyMappingWizardPage extends WizardPage {
render(): TemplateResult {
return html`<form class="pf-c-form pf-m-horizontal">
${this.mappingTypes.map((type) => {
const requiresEnteprise = type.requiresEnterprise && !this.hasEnterpriseLicense;
return html`<div class="pf-c-radio">
<input
class="pf-c-radio__input"
@ -63,20 +63,17 @@ export class InitialPropertyMappingWizardPage extends WizardPage {
];
this.host.isValid = true;
}}
?disabled=${type.requiresEnterprise ? !this.enterprise?.hasLicense : false}
?disabled=${requiresEnteprise}
/>
<label class="pf-c-radio__label" for=${`${type.component}-${type.modelName}`}
>${type.name}</label
>
<span class="pf-c-radio__description">${type.description}</span>
${type.requiresEnterprise && !this.enterprise?.hasLicense
? html`
<ak-alert class="pf-c-radio__description" ?inline=${true}>
${msg("Provider require enterprise.")}
<a href="#/enterprise/licenses">${msg("Learn more")}</a>
</ak-alert>
`
: nothing}
<span class="pf-c-radio__description"
>${type.description}
${requiresEnteprise
? html`<ak-license-notice></ak-license-notice>`
: nothing}</span
>
</div>`;
})}
</form>`;
@ -92,16 +89,10 @@ export class PropertyMappingWizard extends AKElement {
@property({ attribute: false })
mappingTypes: TypeCreate[] = [];
@state()
enterprise?: LicenseSummary;
async firstUpdated(): Promise<void> {
this.mappingTypes = await new PropertymappingsApi(
DEFAULT_CONFIG,
).propertymappingsAllTypesList();
this.enterprise = await new EnterpriseApi(
DEFAULT_CONFIG,
).enterpriseLicenseSummaryRetrieve();
}
render(): TemplateResult {

View File

@ -1,8 +1,10 @@
import "@goauthentik/admin/common/ak-license-notice";
import "@goauthentik/admin/providers/ldap/LDAPProviderForm";
import "@goauthentik/admin/providers/oauth2/OAuth2ProviderForm";
import "@goauthentik/admin/providers/proxy/ProxyProviderForm";
import "@goauthentik/admin/providers/saml/SAMLProviderForm";
import "@goauthentik/admin/providers/saml/SAMLProviderImportForm";
import { WithLicenseSummary } from "@goauthentik/app/elements/Interface/licenseSummaryProvider";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import "@goauthentik/elements/Alert";
import { AKElement } from "@goauthentik/elements/Base";
@ -15,7 +17,7 @@ import { WizardPage } from "@goauthentik/elements/wizard/WizardPage";
import { msg, str } from "@lit/localize";
import { customElement } from "@lit/reactive-element/decorators/custom-element.js";
import { CSSResult, TemplateResult, html, nothing } from "lit";
import { property, state } from "lit/decorators.js";
import { property } from "lit/decorators.js";
import PFButton from "@patternfly/patternfly/components/Button/button.css";
import PFForm from "@patternfly/patternfly/components/Form/form.css";
@ -23,16 +25,13 @@ import PFHint from "@patternfly/patternfly/components/Hint/hint.css";
import PFRadio from "@patternfly/patternfly/components/Radio/radio.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import { EnterpriseApi, LicenseSummary, ProvidersApi, TypeCreate } from "@goauthentik/api";
import { ProvidersApi, TypeCreate } from "@goauthentik/api";
@customElement("ak-provider-wizard-initial")
export class InitialProviderWizardPage extends WizardPage {
export class InitialProviderWizardPage extends WithLicenseSummary(WizardPage) {
@property({ attribute: false })
providerTypes: TypeCreate[] = [];
@property({ attribute: false })
enterprise?: LicenseSummary;
static get styles(): CSSResult[] {
return [PFBase, PFForm, PFHint, PFButton, PFRadio];
}
@ -73,6 +72,7 @@ export class InitialProviderWizardPage extends WizardPage {
render(): TemplateResult {
return html`<form class="pf-c-form pf-m-horizontal">
${this.providerTypes.map((type) => {
const requiresEnterprise = type.requiresEnterprise && !this.hasEnterpriseLicense;
return html`<div class="pf-c-radio">
<input
class="pf-c-radio__input"
@ -83,18 +83,15 @@ export class InitialProviderWizardPage extends WizardPage {
this.host.steps = ["initial", `type-${type.component}`];
this.host.isValid = true;
}}
?disabled=${type.requiresEnterprise ? !this.enterprise?.hasLicense : false}
?disabled=${requiresEnterprise}
/>
<label class="pf-c-radio__label" for=${type.component}>${type.name}</label>
<span class="pf-c-radio__description">${type.description}</span>
${type.requiresEnterprise && !this.enterprise?.hasLicense
? html`
<ak-alert class="pf-c-radio__description" ?inline=${true}>
${msg("Provider require enterprise.")}
<a href="#/enterprise/licenses">${msg("Learn more")}</a>
</ak-alert>
`
: nothing}
<span class="pf-c-radio__description"
>${type.description}
${requiresEnterprise
? html`<ak-license-notice></ak-license-notice>`
: nothing}
</span>
</div>`;
})}
</form>`;
@ -113,9 +110,6 @@ export class ProviderWizard extends AKElement {
@property({ attribute: false })
providerTypes: TypeCreate[] = [];
@state()
enterprise?: LicenseSummary;
@property({ attribute: false })
finalHandler: () => Promise<void> = () => {
return Promise.resolve();
@ -123,9 +117,6 @@ export class ProviderWizard extends AKElement {
async firstUpdated(): Promise<void> {
this.providerTypes = await new ProvidersApi(DEFAULT_CONFIG).providersAllTypesList();
this.enterprise = await new EnterpriseApi(
DEFAULT_CONFIG,
).enterpriseLicenseSummaryRetrieve();
}
render(): TemplateResult {
@ -138,11 +129,7 @@ export class ProviderWizard extends AKElement {
return this.finalHandler();
}}
>
<ak-provider-wizard-initial
slot="initial"
.providerTypes=${this.providerTypes}
.enterprise=${this.enterprise}
>
<ak-provider-wizard-initial slot="initial" .providerTypes=${this.providerTypes}>
</ak-provider-wizard-initial>
${this.providerTypes.map((type) => {
return html`

View File

@ -1,9 +1,13 @@
import { createContext } from "@lit-labs/context";
import type { Config, CurrentTenant } from "@goauthentik/api";
import type { Config, CurrentTenant, LicenseSummary } from "@goauthentik/api";
export const authentikConfigContext = createContext<Config>(Symbol("authentik-config-context"));
export const authentikEnterpriseContext = createContext<LicenseSummary>(
Symbol("authentik-enterprise-context"),
);
export const authentikTenantContext = createContext<CurrentTenant>(
Symbol("authentik-tenant-context"),
);

View File

@ -1,7 +1,9 @@
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { config, tenant } from "@goauthentik/common/api/config";
import { UIConfig, uiConfig } from "@goauthentik/common/ui/config";
import {
authentikConfigContext,
authentikEnterpriseContext,
authentikTenantContext,
} from "@goauthentik/elements/AuthentikContexts";
import type { AdoptedStyleSheetsElement } from "@goauthentik/elements/types";
@ -12,7 +14,8 @@ import { state } from "lit/decorators.js";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import { Config, CurrentTenant, UiThemeEnum } from "@goauthentik/api";
import type { Config, CurrentTenant, LicenseSummary } from "@goauthentik/api";
import { EnterpriseApi, UiThemeEnum } from "@goauthentik/api";
import { AKElement } from "../Base";
@ -63,11 +66,33 @@ export class Interface extends AKElement implements AkInterface {
return this._tenant;
}
_licenseSummaryContext = new ContextProvider(this, {
context: authentikEnterpriseContext,
initialValue: undefined,
});
_licenseSummary?: LicenseSummary;
@state()
set licenseSummary(c: LicenseSummary) {
this._licenseSummary = c;
this._licenseSummaryContext.setValue(c);
this.requestUpdate();
}
get licenseSummary(): LicenseSummary | undefined {
return this._licenseSummary;
}
constructor() {
super();
document.adoptedStyleSheets = [...document.adoptedStyleSheets, ensureCSSStyleSheet(PFBase)];
tenant().then((tenant) => (this.tenant = tenant));
config().then((config) => (this.config = config));
new EnterpriseApi(DEFAULT_CONFIG).enterpriseLicenseSummaryRetrieve().then((enterprise) => {
this.licenseSummary = enterprise;
});
this.dataset.akInterfaceRoot = "true";
}

View File

@ -0,0 +1,25 @@
import { authentikEnterpriseContext } from "@goauthentik/elements/AuthentikContexts";
import { consume } from "@lit-labs/context";
import type { LitElement } from "lit";
import type { LicenseSummary } from "@goauthentik/api";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type Constructor<T = object> = abstract new (...args: any[]) => T;
export function WithLicenseSummary<T extends Constructor<LitElement>>(
superclass: T,
subscribe = true,
) {
abstract class WithEnterpriseProvider extends superclass {
@consume({ context: authentikEnterpriseContext, subscribe })
public licenseSummary!: LicenseSummary;
get hasEnterpriseLicense() {
return this.licenseSummary?.hasLicense;
}
}
return WithEnterpriseProvider;
}

View File

@ -1,19 +1,14 @@
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { AKElement } from "@goauthentik/elements/Base";
import { WithLicenseSummary } from "@goauthentik/elements/Interface/licenseSummaryProvider";
import { msg } from "@lit/localize";
import { CSSResult, TemplateResult, html } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { customElement, property } from "lit/decorators.js";
import PFBanner from "@patternfly/patternfly/components/Banner/banner.css";
import { EnterpriseApi, LicenseSummary } from "@goauthentik/api";
@customElement("ak-enterprise-status")
export class EnterpriseStatusBanner extends AKElement {
@state()
summary?: LicenseSummary;
export class EnterpriseStatusBanner extends WithLicenseSummary(AKElement) {
@property()
interface: "admin" | "user" | "" = "";
@ -21,12 +16,10 @@ export class EnterpriseStatusBanner extends AKElement {
return [PFBanner];
}
async firstUpdated(): Promise<void> {
this.summary = await new EnterpriseApi(DEFAULT_CONFIG).enterpriseLicenseSummaryRetrieve();
}
renderBanner(): TemplateResult {
return html`<div class="pf-c-banner ${this.summary?.readOnly ? "pf-m-red" : "pf-m-gold"}">
return html`<div
class="pf-c-banner ${this.licenseSummary?.readOnly ? "pf-m-red" : "pf-m-gold"}"
>
${msg("Warning: The current user count has exceeded the configured licenses.")}
<a href="/if/admin/#/enterprise/licenses"> ${msg("Click here for more info.")} </a>
</div>`;
@ -35,12 +28,12 @@ export class EnterpriseStatusBanner extends AKElement {
render(): TemplateResult {
switch (this.interface.toLowerCase()) {
case "admin":
if (this.summary?.showAdminWarning || this.summary?.readOnly) {
if (this.licenseSummary?.showAdminWarning || this.licenseSummary?.readOnly) {
return this.renderBanner();
}
break;
case "user":
if (this.summary?.showUserWarning || this.summary?.readOnly) {
if (this.licenseSummary?.showUserWarning || this.licenseSummary?.readOnly) {
return this.renderBanner();
}
break;