- => {
- const args: FlowsInstancesListRequest = {
- ordering: "slug",
- designation: FlowsInstancesListDesignationEnum.Recovery,
- };
- if (query !== undefined) {
- args.search = query;
- }
- const flows = await new FlowsApi(DEFAULT_CONFIG).flowsInstancesList(
- args,
- );
- return flows.results;
- }}
- .renderElement=${(flow: Flow): string => {
- return flow.slug;
- }}
- .renderDescription=${(flow: Flow): TemplateResult => {
- return html`${flow.name}`;
- }}
- .value=${(flow: Flow | undefined): string | undefined => {
- return flow?.pk;
- }}
- .selected=${(flow: Flow): boolean => {
- return this.instance?.recoveryFlow == flow.pk;
- }}
- ?blankable=${true}
- >
-
+
${msg(
"Optional recovery flow, which is linked at the bottom of the page.",
diff --git a/web/src/admin/stages/invitation/InvitationForm.ts b/web/src/admin/stages/invitation/InvitationForm.ts
index 42080eed1..0ee755389 100644
--- a/web/src/admin/stages/invitation/InvitationForm.ts
+++ b/web/src/admin/stages/invitation/InvitationForm.ts
@@ -1,4 +1,4 @@
-import { RenderFlowOption } from "@goauthentik/admin/flows/utils";
+import "@goauthentik/admin/common/ak-flow-search/ak-flow-search";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { dateTimeLocal, first } from "@goauthentik/common/utils";
import "@goauthentik/elements/CodeMirror";
@@ -11,14 +11,7 @@ import { msg } from "@lit/localize";
import { TemplateResult, html } from "lit";
import { customElement } from "lit/decorators.js";
-import {
- Flow,
- FlowsApi,
- FlowsInstancesListDesignationEnum,
- FlowsInstancesListRequest,
- Invitation,
- StagesApi,
-} from "@goauthentik/api";
+import { FlowsInstancesListDesignationEnum, Invitation, StagesApi } from "@goauthentik/api";
@customElement("ak-invitation-form")
export class InvitationForm extends ModelForm {
@@ -75,33 +68,10 @@ export class InvitationForm extends ModelForm {
/>
- => {
- const args: FlowsInstancesListRequest = {
- ordering: "slug",
- designation: FlowsInstancesListDesignationEnum.Enrollment,
- };
- if (query !== undefined) {
- args.search = query;
- }
- const flows = await new FlowsApi(DEFAULT_CONFIG).flowsInstancesList(args);
- return flows.results;
- }}
- .renderElement=${(flow: Flow): string => {
- return RenderFlowOption(flow);
- }}
- .renderDescription=${(flow: Flow): TemplateResult => {
- return html`${flow.name}`;
- }}
- .value=${(flow: Flow | undefined): string | undefined => {
- return flow?.pk;
- }}
- .selected=${(flow: Flow): boolean => {
- return flow.pk === this.instance?.flow;
- }}
- ?blankable=${true}
- >
-
+
${msg(
"When selected, the invite will only be usable with the flow. By default the invite is accepted on all flows with invitation stages.",
diff --git a/web/src/admin/tenants/TenantForm.ts b/web/src/admin/tenants/TenantForm.ts
index 1095be2bb..34420b367 100644
--- a/web/src/admin/tenants/TenantForm.ts
+++ b/web/src/admin/tenants/TenantForm.ts
@@ -1,4 +1,4 @@
-import { RenderFlowOption } from "@goauthentik/admin/flows/utils";
+import "@goauthentik/admin/common/ak-flow-search/ak-flow-search";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { first } from "@goauthentik/common/utils";
import "@goauthentik/elements/CodeMirror";
@@ -18,10 +18,7 @@ import {
CoreApi,
CryptoApi,
CryptoCertificatekeypairsListRequest,
- Flow,
- FlowsApi,
FlowsInstancesListDesignationEnum,
- FlowsInstancesListRequest,
Tenant,
} from "@goauthentik/api";
@@ -154,35 +151,10 @@ export class TenantForm extends ModelForm {
label=${msg("Authentication flow")}
name="flowAuthentication"
>
- => {
- const args: FlowsInstancesListRequest = {
- ordering: "slug",
- designation: FlowsInstancesListDesignationEnum.Authentication,
- };
- if (query !== undefined) {
- args.search = query;
- }
- const flows = await new FlowsApi(DEFAULT_CONFIG).flowsInstancesList(
- args,
- );
- return flows.results;
- }}
- .renderElement=${(flow: Flow): string => {
- return RenderFlowOption(flow);
- }}
- .renderDescription=${(flow: Flow): TemplateResult => {
- return html`${flow.name}`;
- }}
- .value=${(flow: Flow | undefined): string | undefined => {
- return flow?.pk;
- }}
- .selected=${(flow: Flow): boolean => {
- return this.instance?.flowAuthentication === flow.pk;
- }}
- ?blankable=${true}
- >
-
+
${msg(
"Flow used to authenticate users. If left empty, the first applicable flow sorted by the slug is used.",
@@ -193,35 +165,10 @@ export class TenantForm extends ModelForm {
label=${msg("Invalidation flow")}
name="flowInvalidation"
>
- => {
- const args: FlowsInstancesListRequest = {
- ordering: "slug",
- designation: FlowsInstancesListDesignationEnum.Invalidation,
- };
- if (query !== undefined) {
- args.search = query;
- }
- const flows = await new FlowsApi(DEFAULT_CONFIG).flowsInstancesList(
- args,
- );
- return flows.results;
- }}
- .renderElement=${(flow: Flow): string => {
- return RenderFlowOption(flow);
- }}
- .renderDescription=${(flow: Flow): TemplateResult => {
- return html`${flow.name}`;
- }}
- .value=${(flow: Flow | undefined): string | undefined => {
- return flow?.pk;
- }}
- .selected=${(flow: Flow): boolean => {
- return this.instance?.flowInvalidation === flow.pk;
- }}
- ?blankable=${true}
- >
-
+
${msg(
@@ -230,35 +177,10 @@ export class TenantForm extends ModelForm {
- => {
- const args: FlowsInstancesListRequest = {
- ordering: "slug",
- designation: FlowsInstancesListDesignationEnum.Recovery,
- };
- if (query !== undefined) {
- args.search = query;
- }
- const flows = await new FlowsApi(DEFAULT_CONFIG).flowsInstancesList(
- args,
- );
- return flows.results;
- }}
- .renderElement=${(flow: Flow): string => {
- return RenderFlowOption(flow);
- }}
- .renderDescription=${(flow: Flow): TemplateResult => {
- return html`${flow.name}`;
- }}
- .value=${(flow: Flow | undefined): string | undefined => {
- return flow?.pk;
- }}
- .selected=${(flow: Flow): boolean => {
- return this.instance?.flowRecovery === flow.pk;
- }}
- ?blankable=${true}
- >
-
+
${msg(
"Recovery flow. If left empty, the first applicable flow sorted by the slug is used.",
@@ -269,35 +191,10 @@ export class TenantForm extends ModelForm {
label=${msg("Unenrollment flow")}
name="flowUnenrollment"
>
- => {
- const args: FlowsInstancesListRequest = {
- ordering: "slug",
- designation: FlowsInstancesListDesignationEnum.Unenrollment,
- };
- if (query !== undefined) {
- args.search = query;
- }
- const flows = await new FlowsApi(DEFAULT_CONFIG).flowsInstancesList(
- args,
- );
- return flows.results;
- }}
- .renderElement=${(flow: Flow): string => {
- return RenderFlowOption(flow);
- }}
- .renderDescription=${(flow: Flow): TemplateResult => {
- return html`${flow.name}`;
- }}
- .value=${(flow: Flow | undefined): string | undefined => {
- return flow?.pk;
- }}
- .selected=${(flow: Flow): boolean => {
- return this.instance?.flowUnenrollment === flow.pk;
- }}
- ?blankable=${true}
- >
-
+
${msg(
"If set, users are able to unenroll themselves using this flow. If no flow is set, option is not shown.",
@@ -308,36 +205,10 @@ export class TenantForm extends ModelForm {
label=${msg("User settings flow")}
name="flowUserSettings"
>
- => {
- const args: FlowsInstancesListRequest = {
- ordering: "slug",
- designation:
- FlowsInstancesListDesignationEnum.StageConfiguration,
- };
- if (query !== undefined) {
- args.search = query;
- }
- const flows = await new FlowsApi(DEFAULT_CONFIG).flowsInstancesList(
- args,
- );
- return flows.results;
- }}
- .renderElement=${(flow: Flow): string => {
- return RenderFlowOption(flow);
- }}
- .renderDescription=${(flow: Flow): TemplateResult => {
- return html`${flow.name}`;
- }}
- .value=${(flow: Flow | undefined): string | undefined => {
- return flow?.pk;
- }}
- .selected=${(flow: Flow): boolean => {
- return this.instance?.flowUserSettings === flow.pk;
- }}
- ?blankable=${true}
- >
-
+
${msg("If set, users are able to configure details of their profile.")}
@@ -346,36 +217,10 @@ export class TenantForm extends ModelForm {
label=${msg("Device code flow")}
name="flowDeviceCode"
>
- => {
- const args: FlowsInstancesListRequest = {
- ordering: "slug",
- designation:
- FlowsInstancesListDesignationEnum.StageConfiguration,
- };
- if (query !== undefined) {
- args.search = query;
- }
- const flows = await new FlowsApi(DEFAULT_CONFIG).flowsInstancesList(
- args,
- );
- return flows.results;
- }}
- .renderElement=${(flow: Flow): string => {
- return RenderFlowOption(flow);
- }}
- .renderDescription=${(flow: Flow): TemplateResult => {
- return html`${flow.name}`;
- }}
- .value=${(flow: Flow | undefined): string | undefined => {
- return flow?.pk;
- }}
- .selected=${(flow: Flow): boolean => {
- return this.instance?.flowDeviceCode === flow.pk;
- }}
- ?blankable=${true}
- >
-
+
${msg(
"If set, the OAuth Device Code profile can be used, and the selected flow will be used to enter the code.",
diff --git a/web/src/elements/forms/Form.ts b/web/src/elements/forms/Form.ts
index 734e6eb51..c3f3f4b7a 100644
--- a/web/src/elements/forms/Form.ts
+++ b/web/src/elements/forms/Form.ts
@@ -1,3 +1,4 @@
+import { FlowSearch } from "@goauthentik/admin/common/ak-flow-search/FlowSearch";
import { EVENT_REFRESH } from "@goauthentik/common/constants";
import { MessageLevel } from "@goauthentik/common/messages";
import { camelToSnake, convertToSlug } from "@goauthentik/common/utils";
@@ -178,6 +179,8 @@ export abstract class Form extends AKElement {
inputElement.type === "checkbox"
) {
json[element.name] = inputElement.checked;
+ } else if (inputElement instanceof FlowSearch) {
+ json[element.name] = inputElement.value;
} else if (inputElement.tagName.toLowerCase() === "ak-search-select") {
const select = inputElement as unknown as SearchSelect;
try {
diff --git a/web/src/elements/forms/SearchSelect.ts b/web/src/elements/forms/SearchSelect.ts
index 6c803c243..02d1f1fc2 100644
--- a/web/src/elements/forms/SearchSelect.ts
+++ b/web/src/elements/forms/SearchSelect.ts
@@ -2,6 +2,7 @@ import { EVENT_REFRESH } from "@goauthentik/common/constants";
import { ascii_letters, digits, groupBy, randomString } from "@goauthentik/common/utils";
import { AKElement } from "@goauthentik/elements/Base";
import { PreventFormSubmit } from "@goauthentik/elements/forms/Form";
+import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter";
import { msg } from "@lit/localize";
import { CSSResult, TemplateResult, html, render } from "lit";
@@ -14,7 +15,7 @@ import PFSelect from "@patternfly/patternfly/components/Select/select.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
@customElement("ak-search-select")
-export class SearchSelect extends AKElement {
+export class SearchSelect extends CustomEmitterElement(AKElement) {
@property()
query?: string;
@@ -91,6 +92,16 @@ export class SearchSelect extends AKElement {
this.dropdownUID = `dropdown-${randomString(10, ascii_letters + digits)}`;
}
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ shouldUpdate(changedProperties: Map) {
+ if (changedProperties.has("selectedObject")) {
+ this.dispatchCustomEvent("ak-change", {
+ value: this.selectedObject,
+ });
+ }
+ return true;
+ }
+
toForm(): unknown {
if (!this.objects) {
throw new PreventFormSubmit(msg("Loading options..."));
diff --git a/web/src/elements/utils/eventEmitter.ts b/web/src/elements/utils/eventEmitter.ts
index 2d62b5676..9afa8be3b 100644
--- a/web/src/elements/utils/eventEmitter.ts
+++ b/web/src/elements/utils/eventEmitter.ts
@@ -25,10 +25,66 @@ export function CustomEmitterElement>(supercla
};
}
+/**
+ * Mixin that enables Lit Elements to handle custom events in a more straightforward manner.
+ *
+ */
+
+// This is a neat trick: this static "class" is just a namespace for these unique symbols. Because
+// of all the constraints on them, they're legal field names in Typescript objects! Which means that
+// we can use them as identifiers for internal references in a Typescript class with absolutely no
+// risk that a future user who wants a name like 'addHandler' or 'removeHandler' will override any
+// of those, either in this mixin or in any class that this is mixed into, past or present along the
+// chain of inheritance.
+
+class HK {
+ public static readonly listenHandlers: unique symbol = Symbol();
+ public static readonly addHandler: unique symbol = Symbol();
+ public static readonly removeHandler: unique symbol = Symbol();
+ public static readonly getHandler: unique symbol = Symbol();
+}
+
+type EventHandler = (ev: CustomEvent) => void;
+type EventMap = WeakMap;
+
export function CustomListenerElement>(superclass: T) {
return class ListenerElementHandler extends superclass {
- addCustomListener(eventName: string, handler: (ev: CustomEvent) => void) {
- this.addEventListener(eventName, (ev: Event) => {
+ private [HK.listenHandlers] = new Map();
+
+ private [HK.getHandler](eventName: string, handler: EventHandler) {
+ const internalMap = this[HK.listenHandlers].get(eventName);
+ return internalMap ? internalMap.get(handler) : undefined;
+ }
+
+ // For every event NAME, we create a WeakMap that pairs the event handler given to us by the
+ // class that uses this method to the custom, wrapped handler we create to manage the types
+ // and handlings. If the wrapped handler disappears due to garbage collection, no harm done;
+ // meanwhile, this allows us to remove it from the event listeners if it's still around
+ // using the original handler's identity as the key.
+ //
+ private [HK.addHandler](
+ eventName: string,
+ handler: EventHandler,
+ internalHandler: EventHandler,
+ ) {
+ if (!this[HK.listenHandlers].has(eventName)) {
+ this[HK.listenHandlers].set(eventName, new WeakMap());
+ }
+ const internalMap = this[HK.listenHandlers].get(eventName);
+ if (internalMap) {
+ internalMap.set(handler, internalHandler);
+ }
+ }
+
+ private [HK.removeHandler](eventName: string, handler: EventHandler) {
+ const internalMap = this[HK.listenHandlers].get(eventName);
+ if (internalMap) {
+ internalMap.delete(handler);
+ }
+ }
+
+ addCustomListener(eventName: string, handler: EventHandler) {
+ const internalHandler = (ev: Event) => {
if (!isCustomEvent(ev)) {
console.error(
`Received a standard event for custom event ${eventName}; event will not be handled.`,
@@ -36,7 +92,20 @@ export function CustomListenerElement>(supercl
return;
}
handler(ev);
- });
+ };
+ this[HK.addHandler](eventName, handler, internalHandler);
+ this.addEventListener(eventName, internalHandler);
+ }
+
+ removeCustomListener(eventName: string, handler: EventHandler) {
+ const realHandler = this[HK.getHandler](eventName, handler);
+ if (realHandler) {
+ this.removeEventListener(
+ eventName,
+ realHandler as EventListenerOrEventListenerObject,
+ );
+ }
+ this[HK.removeHandler](eventName, handler);
}
};
}