web: Controller refinements, error handling (#14700)

* web: Partial fix for issue where config is not consistently available.

* web: Fix issues surrounding controller readiness.

* web: Catch abort errors when originating when wrapped by OpenAPI or Sentry.

* web: Fix color on dark mode.

---------

Co-authored-by: Simonyi Gergő <gergo@goauthentik.io>
This commit is contained in:
Teffen Ellis
2025-05-28 13:08:09 +02:00
committed by GitHub
parent 134eb126b6
commit fa66195619
16 changed files with 122 additions and 86 deletions

View File

@ -1,3 +1,4 @@
import { $PFBase } from "#common/theme";
import { WithLicenseSummary } from "#elements/mixins/license"; import { WithLicenseSummary } from "#elements/mixins/license";
import "@goauthentik/elements/Alert"; import "@goauthentik/elements/Alert";
import { AKElement } from "@goauthentik/elements/Base"; import { AKElement } from "@goauthentik/elements/Base";
@ -8,6 +9,8 @@ import { customElement, property } from "lit/decorators.js";
@customElement("ak-license-notice") @customElement("ak-license-notice")
export class AkLicenceNotice extends WithLicenseSummary(AKElement) { export class AkLicenceNotice extends WithLicenseSummary(AKElement) {
static styles = [$PFBase];
@property() @property()
notice = msg("Enterprise only"); notice = msg("Enterprise only");

View File

@ -57,6 +57,19 @@ export function isAbortError(error: unknown): error is AbortErrorLike {
return error instanceof DOMException && error.name === "AbortError"; return error instanceof DOMException && error.name === "AbortError";
} }
/**
* Type predicate to check if an error originates from an aborted request.
*
* @see {@linkcode isAbortError} for the underlying implementation.
*/
export function isCausedByAbortError(error: unknown): error is AbortErrorLike {
return (
error instanceof Error &&
// ---
(isAbortError(error) || isAbortError(error.cause))
);
}
//#endregion //#endregion
//#region API //#region API

View File

@ -1,5 +1,5 @@
import { Interface } from "#elements/Interface"; import { Interface } from "#elements/Interface";
import { LicenseContextController } from "#elements/controllers/EnterpriseContextController"; import { LicenseContextController } from "#elements/controllers/LicenseContextController";
import { VersionContextController } from "#elements/controllers/VersionContextController"; import { VersionContextController } from "#elements/controllers/VersionContextController";
export class AuthenticatedInterface extends Interface { export class AuthenticatedInterface extends Interface {

View File

@ -4,14 +4,13 @@ import { AKElement } from "#elements/Base";
import { BrandingContextController } from "#elements/controllers/BrandContextController"; import { BrandingContextController } from "#elements/controllers/BrandContextController";
import { ConfigContextController } from "#elements/controllers/ConfigContextController"; import { ConfigContextController } from "#elements/controllers/ConfigContextController";
import { ModalOrchestrationController } from "#elements/controllers/ModalOrchestrationController"; import { ModalOrchestrationController } from "#elements/controllers/ModalOrchestrationController";
import { WithAuthentikConfig } from "#elements/mixins/config";
import PFBase from "@patternfly/patternfly/patternfly-base.css"; import PFBase from "@patternfly/patternfly/patternfly-base.css";
/** /**
* The base interface element for the application. * The base interface element for the application.
*/ */
export abstract class Interface extends WithAuthentikConfig(AKElement) { export abstract class Interface extends AKElement {
static styles = [PFBase]; static styles = [PFBase];
constructor() { constructor() {

View File

@ -54,7 +54,7 @@ const highlightThemeOptions: HighlightOptions = {
export type Replacer = (input: string) => string; export type Replacer = (input: string) => string;
@customElement("ak-mdx") @customElement("ak-mdx")
export class AKMDX extends WithAuthentikConfig(AKElement) { export class AKMDX extends AKElement {
@property({ @property({
reflect: true, reflect: true,
}) })

View File

@ -1,10 +1,10 @@
import { DEFAULT_CONFIG } from "#common/api/config"; import { DEFAULT_CONFIG } from "#common/api/config";
import { EVENT_REFRESH } from "#common/constants"; import { EVENT_REFRESH } from "#common/constants";
import { isAbortError } from "#common/errors/network"; import { isCausedByAbortError } from "#common/errors/network";
import { BrandingContext, BrandingMixin } from "#elements/mixins/branding"; import { BrandingContext, BrandingMixin } from "#elements/mixins/branding";
import type { ReactiveElementHost } from "#elements/types"; import type { ReactiveElementHost } from "#elements/types";
import { Context, ContextProvider } from "@lit/context"; import { ContextProvider } from "@lit/context";
import type { ReactiveController } from "lit"; import type { ReactiveController } from "lit";
import { CoreApi, CurrentBrand } from "@goauthentik/api"; import { CoreApi, CurrentBrand } from "@goauthentik/api";
@ -14,7 +14,7 @@ export class BrandingContextController implements ReactiveController {
#abortController: null | AbortController = null; #abortController: null | AbortController = null;
#host: ReactiveElementHost<BrandingMixin>; #host: ReactiveElementHost<BrandingMixin>;
#context: ContextProvider<Context<unknown, CurrentBrand>>; #context: ContextProvider<BrandingContext>;
constructor(host: ReactiveElementHost<BrandingMixin>, initialValue: CurrentBrand) { constructor(host: ReactiveElementHost<BrandingMixin>, initialValue: CurrentBrand) {
this.#host = host; this.#host = host;
@ -42,7 +42,7 @@ export class BrandingContextController implements ReactiveController {
}) })
.catch((error: unknown) => { .catch((error: unknown) => {
if (isAbortError(error)) { if (isCausedByAbortError(error)) {
this.#log("Aborted fetching brand"); this.#log("Aborted fetching brand");
return; return;
} }

View File

@ -1,10 +1,10 @@
import { DEFAULT_CONFIG } from "#common/api/config"; import { DEFAULT_CONFIG } from "#common/api/config";
import { EVENT_REFRESH } from "#common/constants"; import { EVENT_REFRESH } from "#common/constants";
import { isAbortError } from "#common/errors/network"; import { isCausedByAbortError } from "#common/errors/network";
import { AKConfigMixin, AuthentikConfigContext } from "#elements/mixins/config"; import { AKConfigMixin, AuthentikConfigContext, kAKConfig } from "#elements/mixins/config";
import type { ReactiveElementHost } from "#elements/types"; import type { ReactiveElementHost } from "#elements/types";
import { Context, ContextProvider } from "@lit/context"; import { ContextProvider } from "@lit/context";
import type { ReactiveController } from "lit"; import type { ReactiveController } from "lit";
import { Config, RootApi } from "@goauthentik/api"; import { Config, RootApi } from "@goauthentik/api";
@ -17,7 +17,7 @@ export class ConfigContextController implements ReactiveController {
#abortController: null | AbortController = null; #abortController: null | AbortController = null;
#host: ReactiveElementHost<AKConfigMixin>; #host: ReactiveElementHost<AKConfigMixin>;
#context: ContextProvider<Context<unknown, Config>>; #context: ContextProvider<AuthentikConfigContext>;
constructor(host: ReactiveElementHost<AKConfigMixin>, initialValue: Config) { constructor(host: ReactiveElementHost<AKConfigMixin>, initialValue: Config) {
this.#host = host; this.#host = host;
@ -27,7 +27,7 @@ export class ConfigContextController implements ReactiveController {
initialValue, initialValue,
}); });
this.#host.authentikConfig = initialValue; this.#host[kAKConfig] = initialValue;
} }
#fetch = () => { #fetch = () => {
@ -43,10 +43,10 @@ export class ConfigContextController implements ReactiveController {
}) })
.then((authentikConfig) => { .then((authentikConfig) => {
this.#context.setValue(authentikConfig); this.#context.setValue(authentikConfig);
this.#host.authentikConfig = authentikConfig; this.#host[kAKConfig] = authentikConfig;
}) })
.catch((error: unknown) => { .catch((error: unknown) => {
if (isAbortError(error)) { if (isCausedByAbortError(error)) {
this.#log("Aborted fetching configuration"); this.#log("Aborted fetching configuration");
return; return;
} }
@ -72,8 +72,8 @@ export class ConfigContextController implements ReactiveController {
// If the Interface changes its config information, we should notify all // If the Interface changes its config information, we should notify all
// users of the context of that change, without creating an infinite // users of the context of that change, without creating an infinite
// loop of resets. // loop of resets.
if (this.#host.authentikConfig && this.#host.authentikConfig !== this.#context.value) { if (this.#host[kAKConfig] && this.#host[kAKConfig] !== this.#context.value) {
this.#context.setValue(this.#host.authentikConfig); this.#context.setValue(this.#host[kAKConfig]);
} }
} }
} }

View File

@ -1,10 +1,10 @@
import { DEFAULT_CONFIG } from "#common/api/config"; import { DEFAULT_CONFIG } from "#common/api/config";
import { EVENT_REFRESH_ENTERPRISE } from "#common/constants"; import { EVENT_REFRESH_ENTERPRISE } from "#common/constants";
import { isAbortError } from "#common/errors/network"; import { isCausedByAbortError } from "#common/errors/network";
import { LicenseContext, LicenseMixin } from "#elements/mixins/license"; import { LicenseContext, LicenseMixin } from "#elements/mixins/license";
import type { ReactiveElementHost } from "#elements/types"; import type { ReactiveElementHost } from "#elements/types";
import { Context, ContextProvider } from "@lit/context"; import { ContextProvider } from "@lit/context";
import type { ReactiveController } from "lit"; import type { ReactiveController } from "lit";
import { EnterpriseApi, LicenseSummary } from "@goauthentik/api"; import { EnterpriseApi, LicenseSummary } from "@goauthentik/api";
@ -14,7 +14,7 @@ export class LicenseContextController implements ReactiveController {
#abortController: null | AbortController = null; #abortController: null | AbortController = null;
#host: ReactiveElementHost<LicenseMixin>; #host: ReactiveElementHost<LicenseMixin>;
#context: ContextProvider<Context<unknown, LicenseSummary>>; #context: ContextProvider<LicenseContext>;
constructor(host: ReactiveElementHost<LicenseMixin>, initialValue?: LicenseSummary) { constructor(host: ReactiveElementHost<LicenseMixin>, initialValue?: LicenseSummary) {
this.#host = host; this.#host = host;
@ -44,7 +44,7 @@ export class LicenseContextController implements ReactiveController {
}) })
.catch((error: unknown) => { .catch((error: unknown) => {
if (isAbortError(error)) { if (isCausedByAbortError(error)) {
this.#log("Aborted fetching license summary"); this.#log("Aborted fetching license summary");
return; return;
} }

View File

@ -1,10 +1,10 @@
import { DEFAULT_CONFIG } from "#common/api/config"; import { DEFAULT_CONFIG } from "#common/api/config";
import { EVENT_REFRESH } from "#common/constants"; import { EVENT_REFRESH } from "#common/constants";
import { isAbortError } from "#common/errors/network"; import { isCausedByAbortError } from "#common/errors/network";
import { VersionContext, VersionMixin } from "#elements/mixins/version"; import { VersionContext, VersionMixin } from "#elements/mixins/version";
import type { ReactiveElementHost } from "#elements/types"; import type { ReactiveElementHost } from "#elements/types";
import { Context, ContextProvider } from "@lit/context"; import { ContextProvider } from "@lit/context";
import type { ReactiveController } from "lit"; import type { ReactiveController } from "lit";
import { AdminApi, Version } from "@goauthentik/api"; import { AdminApi, Version } from "@goauthentik/api";
@ -14,7 +14,7 @@ export class VersionContextController implements ReactiveController {
#abortController: null | AbortController = null; #abortController: null | AbortController = null;
#host: ReactiveElementHost<VersionMixin>; #host: ReactiveElementHost<VersionMixin>;
#context: ContextProvider<Context<unknown, Version>>; #context: ContextProvider<VersionContext>;
constructor(host: ReactiveElementHost<VersionMixin>, initialValue?: Version) { constructor(host: ReactiveElementHost<VersionMixin>, initialValue?: Version) {
this.#host = host; this.#host = host;
@ -41,7 +41,7 @@ export class VersionContextController implements ReactiveController {
}) })
.catch((error: unknown) => { .catch((error: unknown) => {
if (isAbortError(error)) { if (isCausedByAbortError(error)) {
this.#log("Aborted fetching license summary"); this.#log("Aborted fetching license summary");
return; return;
} }

View File

@ -37,6 +37,9 @@ export class ModalForm extends ModalButton {
if (this.closeAfterSuccessfulSubmit) { if (this.closeAfterSuccessfulSubmit) {
this.open = false; this.open = false;
form?.resetForm(); form?.resetForm();
// TODO: We may be fetching too frequently.
// Repeat dispatching will prematurely abort refresh listeners and cause several fetches and re-renders.
this.dispatchEvent( this.dispatchEvent(
new CustomEvent(EVENT_REFRESH, { new CustomEvent(EVENT_REFRESH, {
bubbles: true, bubbles: true,

View File

@ -1,7 +1,7 @@
import { DefaultBrand } from "#common/ui/config"; import { DefaultBrand } from "#common/ui/config";
import { createMixin } from "#elements/types"; import { createMixin } from "#elements/types";
import { consume, createContext } from "@lit/context"; import { Context, consume, createContext } from "@lit/context";
import type { CurrentBrand, FooterLink } from "@goauthentik/api"; import type { CurrentBrand, FooterLink } from "@goauthentik/api";
@ -16,6 +16,8 @@ export const BrandingContext = createContext<CurrentBrand>(
Symbol.for("authentik-branding-context"), Symbol.for("authentik-branding-context"),
); );
export type BrandingContext = Context<symbol, CurrentBrand>;
/** /**
* A mixin that provides the current brand to the element. * A mixin that provides the current brand to the element.
* *
@ -27,9 +29,30 @@ export interface BrandingMixin {
*/ */
readonly brand: Readonly<CurrentBrand>; readonly brand: Readonly<CurrentBrand>;
/**
* The application title.
*
* @see {@linkcode DefaultBrand.brandingTitle}
*/
readonly brandingTitle: string; readonly brandingTitle: string;
/**
* The application logo.
*
* @see {@linkcode DefaultBrand.brandingLogo}
*/
readonly brandingLogo: string; readonly brandingLogo: string;
/**
* The application favicon.
*
* @see {@linkcode DefaultBrand.brandingFavicon}
*/
readonly brandingFavicon: string; readonly brandingFavicon: string;
/**
* Footer links provided by the brand configuration.
*/
readonly brandingFooterLinks: FooterLink[]; readonly brandingFooterLinks: FooterLink[];
} }
@ -37,18 +60,11 @@ export interface BrandingMixin {
* A mixin that provides the current brand to the element. * A mixin that provides the current brand to the element.
* *
* @category Mixin * @category Mixin
*
* @see {@link https://lit.dev/docs/composition/mixins/#mixins-in-typescript | Lit Mixins}
*/ */
export const WithBrandConfig = createMixin<BrandingMixin>( export const WithBrandConfig = createMixin<BrandingMixin>(
({ ({
/** // ---
* The superclass constructor to extend.
*/
SuperClass, SuperClass,
/**
* Whether or not to subscribe to the context.
*/
subscribe = true, subscribe = true,
}) => { }) => {
abstract class BrandingProvider extends SuperClass implements BrandingMixin { abstract class BrandingProvider extends SuperClass implements BrandingMixin {

View File

@ -1,5 +1,6 @@
import { AKConfigMixin } from "#elements/mixins/config"; import { WithAuthentikConfig } from "#elements/mixins/config";
import { createMixin } from "@goauthentik/elements/types"; import { kAKConfig } from "#elements/mixins/config";
import { LitElementConstructor, createMixin } from "#elements/types";
import { CapabilitiesEnum } from "@goauthentik/api"; import { CapabilitiesEnum } from "@goauthentik/api";
@ -43,25 +44,26 @@ export interface CapabilitiesMixin {
* @category Mixin * @category Mixin
* *
*/ */
export const WithCapabilitiesConfig = createMixin<CapabilitiesMixin, AKConfigMixin>( export const WithCapabilitiesConfig = createMixin<CapabilitiesMixin>(({ SuperClass }) => {
({ SuperClass }) => { abstract class CapabilitiesProvider
abstract class CapabilitiesProvider extends SuperClass implements CapabilitiesMixin { extends WithAuthentikConfig(SuperClass)
public can(capability: CapabilitiesEnum) { implements CapabilitiesMixin
const config = this.authentikConfig; {
public can(capability: CapabilitiesEnum) {
const config = this[kAKConfig];
if (!config) { if (!config) {
throw new Error( throw new Error(
`ConfigContext: Attempted to check capability "${capability}" before initialization. Does the element have the AuthentikConfigMixin applied?`, `CapabilitiesMixin: Attempted to check capability "${capability}" before initialization. Does the element have the AuthentikConfigMixin applied?`,
); );
}
return config.capabilities.includes(capability);
} }
}
return CapabilitiesProvider; return config.capabilities.includes(capability);
}, }
); }
return CapabilitiesProvider;
});
// Re-export `CapabilitiesEnum`, so you won't have to import it on a separate line if you // Re-export `CapabilitiesEnum`, so you won't have to import it on a separate line if you
// don't need anything else from the API. // don't need anything else from the API.

View File

@ -1,9 +1,11 @@
import { createMixin } from "@goauthentik/elements/types"; import { createMixin } from "@goauthentik/elements/types";
import { consume, createContext } from "@lit/context"; import { Context, consume, createContext } from "@lit/context";
import type { Config } from "@goauthentik/api"; import type { Config } from "@goauthentik/api";
export const kAKConfig = Symbol("kAKConfig");
/** /**
* The Lit context for the application configuration. * The Lit context for the application configuration.
* *
@ -13,6 +15,8 @@ import type { Config } from "@goauthentik/api";
*/ */
export const AuthentikConfigContext = createContext<Config>(Symbol.for("authentik-config-context")); export const AuthentikConfigContext = createContext<Config>(Symbol.for("authentik-config-context"));
export type AuthentikConfigContext = Context<symbol, Config>;
/** /**
* A consumer that provides the application configuration to the element. * A consumer that provides the application configuration to the element.
* *
@ -23,7 +27,7 @@ export interface AKConfigMixin {
/** /**
* The current configuration of the application. * The current configuration of the application.
*/ */
readonly authentikConfig: Readonly<Config>; readonly [kAKConfig]: Readonly<Config>;
} }
/** /**
@ -33,13 +37,8 @@ export interface AKConfigMixin {
*/ */
export const WithAuthentikConfig = createMixin<AKConfigMixin>( export const WithAuthentikConfig = createMixin<AKConfigMixin>(
({ ({
/** // ---
* The superclass constructor to extend.
*/
SuperClass, SuperClass,
/**
* Whether or not to subscribe to the context.
*/
subscribe = true, subscribe = true,
}) => { }) => {
abstract class AKConfigProvider extends SuperClass implements AKConfigMixin { abstract class AKConfigProvider extends SuperClass implements AKConfigMixin {
@ -47,7 +46,7 @@ export const WithAuthentikConfig = createMixin<AKConfigMixin>(
context: AuthentikConfigContext, context: AuthentikConfigContext,
subscribe, subscribe,
}) })
public readonly authentikConfig!: Readonly<Config>; public readonly [kAKConfig]!: Readonly<Config>;
} }
return AKConfigProvider; return AKConfigProvider;

View File

@ -1,6 +1,6 @@
import { createMixin } from "#elements/types"; import { createMixin } from "#elements/types";
import { consume, createContext } from "@lit/context"; import { Context, consume, createContext } from "@lit/context";
import { type LicenseSummary, LicenseSummaryStatusEnum } from "@goauthentik/api"; import { type LicenseSummary, LicenseSummaryStatusEnum } from "@goauthentik/api";
@ -8,6 +8,8 @@ export const LicenseContext = createContext<LicenseSummary>(
Symbol.for("authentik-license-context"), Symbol.for("authentik-license-context"),
); );
export type LicenseContext = Context<symbol, LicenseSummary>;
/** /**
* A consumer that provides license information to the element. * A consumer that provides license information to the element.
*/ */
@ -26,18 +28,24 @@ export interface LicenseMixin {
/** /**
* A mixin that provides the license information to the element. * A mixin that provides the license information to the element.
*/ */
export const WithLicenseSummary = createMixin<LicenseMixin>(({ SuperClass, subscribe = true }) => { export const WithLicenseSummary = createMixin<LicenseMixin>(
abstract class LicenseProvider extends SuperClass implements LicenseMixin { ({
@consume({ // ---
context: LicenseContext, SuperClass,
subscribe, subscribe = true,
}) }) => {
public readonly licenseSummary!: LicenseSummary; abstract class LicenseProvider extends SuperClass implements LicenseMixin {
@consume({
context: LicenseContext,
subscribe,
})
public readonly licenseSummary!: LicenseSummary;
get hasEnterpriseLicense() { get hasEnterpriseLicense() {
return this.licenseSummary?.status !== LicenseSummaryStatusEnum.Unlicensed; return this.licenseSummary?.status !== LicenseSummaryStatusEnum.Unlicensed;
}
} }
}
return LicenseProvider; return LicenseProvider;
}); },
);

View File

@ -1,7 +1,6 @@
import { createMixin } from "#elements/types"; import { createMixin } from "#elements/types";
import { consume, createContext } from "@lit/context"; import { Context, consume, createContext } from "@lit/context";
import { state } from "lit/decorators.js";
import type { Version } from "@goauthentik/api"; import type { Version } from "@goauthentik/api";
@ -15,6 +14,8 @@ import type { Version } from "@goauthentik/api";
export const VersionContext = createContext<Version>(Symbol.for("authentik-version-context")); export const VersionContext = createContext<Version>(Symbol.for("authentik-version-context"));
export type VersionContext = Context<symbol, Version>;
/** /**
* A mixin that provides the current version to the element. * A mixin that provides the current version to the element.
* *
@ -33,18 +34,11 @@ export interface VersionMixin {
* A mixin that provides the current authentik version to the element. * A mixin that provides the current authentik version to the element.
* *
* @category Mixin * @category Mixin
*
* @see {@link https://lit.dev/docs/composition/mixins/#mixins-in-typescript | Lit Mixins}
*/ */
export const WithVersion = createMixin<VersionMixin>( export const WithVersion = createMixin<VersionMixin>(
({ ({
/** // ---
* The superclass constructor to extend.
*/
SuperClass, SuperClass,
/**
* Whether or not to subscribe to the context.
*/
subscribe = true, subscribe = true,
}) => { }) => {
abstract class VersionProvider extends SuperClass implements VersionMixin { abstract class VersionProvider extends SuperClass implements VersionMixin {
@ -52,7 +46,6 @@ export const WithVersion = createMixin<VersionMixin>(
context: VersionContext, context: VersionContext,
subscribe, subscribe,
}) })
@state()
public version!: Version; public version!: Version;
} }

View File

@ -519,7 +519,7 @@ export class FlowExecutor
class="pf-c-login__main-header pf-c-brand ak-brand" class="pf-c-login__main-header pf-c-brand ak-brand"
> >
<img <img
src="${themeImage(this.brand.brandingLogo)}" src="${themeImage(this.brandingLogo)}"
alt="${msg("authentik Logo")}" alt="${msg("authentik Logo")}"
/> />
</div> </div>