From 3f02534eb1fd1056fb6d64328da633075a26697e Mon Sep 17 00:00:00 2001 From: Ken Sternberg <133134217+kensternberg-authentik@users.noreply.github.com> Date: Fri, 28 Jul 2023 13:57:14 -0700 Subject: [PATCH] web: weightloss program, part 1: FlowSearch (#6332) * web: weightloss program, part 1: FlowSearch This commit extracts the multiple uses of SearchSelect for Flow lookups in the `providers` collection and replaces them with a slightly more legible format, from: ```HTML => { 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 flow.pk === this.instance?.authenticationFlow; }} > ``` ... to: ```HTML ``` All of those middle methods, like `renderElement`, `renderDescription`, etc, are *completely the same* for *all* of the searches, and there are something like 25 of them; this commit only covers the 8 in `providers`, but the next commit should carry all of them. The topmost example has been extracted into its own Web Component, `ak-flow-search`, that takes only two arguments: the type of `FlowInstanceListDesignation` and the current instance of the flow. The static methods for `renderElement`, `renderDescription` and `value` (which are all the same in all 25 instances of `FlowInstancesListRequest`) have been made into standalone functions. `fetchObjects` has been made into a method that takes the parameter from the `designation` property, and `selected` has been turned into a method that takes the comparator instance from the `currentFlow` property. That's it. That's the whole of it. `SearchSelect` now emits an event whenever the user changes the field, and `ak-flow-search` intercepts that event to mirror the value locally. `Form` has been adapted to recognize the `ak-flow-search` element and extract the current value. There are a number of legibility issues remaining, even with this fix. The Authentik Form manager is dependent upon a component named `ak-form-element-horizontal`, which is a container for a single displayed element in a form: ```HTML

${msg("Flow used when authorizing this provider.")}

``` Imagine, instead, if we could write: ```HTML ${msg("Flow used when authorizing this provider.")} ``` Starting with a superclass that understands the need for `label` and `help` slots, it would automatically configure the input object that would be used. We've already specified multiple identical copies of this thing in multiple different places; centralizing their definition and then re-using them would be classic code re-use. Even better, since the Authorization flow is used 10 times in the whole of our code base, and the Authentication flow 8 times, and they are *all identical*, it would be fitting if we just created wrappers: ```HTML ``` That's really all that's needed. There are *hundreds* (about 470 total) cases where nine or more lines of repetitious HTML could be replaced with a one-liner like the above. A "narrow waist" design is one that allows for a system to communicate between two different components through a small but consistent collection of calls. The Form manager needs to be narrowed hard. The `ak-form-element-horizontal` is a wrapper around an input object, and it has this at its core for extracting that information. This forwards the name component to the containing input object so that when the input object generates an event, we can identify the field it's associated with. ```Javascript this.querySelectorAll("*").forEach((input) => { switch (input.tagName.toLowerCase()) { case "input": case "textarea": case "select": case "ak-codemirror": case "ak-chip-group": case "ak-search-select": case "ak-radio": input.setAttribute("name", this.name); break; default: return; } ``` A *temporary* variant of this is in the `ak-flow-search` component, to support this API without having to modify `ak-form-element-horizontal`. And then `ak-form` itself has this: ```Javascript if ( inputElement.tagName.toLowerCase() === "select" && "multiple" in inputElement.attributes ) { const selectElement = inputElement as unknown as HTMLSelectElement; json[element.name] = Array.from(selectElement.selectedOptions).map((v) => v.value); } else if ( inputElement.tagName.toLowerCase() === "input" && inputElement.type === "date" ) { json[element.name] = inputElement.valueAsDate; } else if ( inputElement.tagName.toLowerCase() === "input" && inputElement.type === "datetime-local" ) { json[element.name] = new Date(inputElement.valueAsNumber); } // ... another 20 lines removed ``` This ought to read: ```Javascript const json = elements.filter((element => element instanceof AkFormComponent) .reduce((acc, element) => ({ ...acc, [element.name]: element.value] }); ``` Where, instead of hand-writing all the different input objects for date and datetime and checkbox into our forms, and then having to craft custom value extractors for each and every one of them, just write *one* version of each with all the wrappers and bells and whistles already attached, and have each one of them have a `value` getter descriptor that returns the value expected by our form handler. A back-of-the-envelope estimation is that there's about four *thousand* lines that could disappear if we did this right. More importantly, it would be possible to create new `AkFormComponent`s without having to register them or define them for `ak-form`; as long as they conformed to the AkFormComponent's expectations for "what is a source of values for a Form", `ak-form` would understand how to handle it. Ultimately, what I want is to be able to do this: ``` HTML ``` And it will (1) go out and find the right kind of search to put there, (2) conduct the right kind of fetch to fill that search, (3) pre-configure it with the user's current choice in that locale. I don't think this is possible-- for one thing, it would be very expensive in terms of development, and it may break the "narrow waist" ideal by require that the `ak-input-form` object know all the different kinds of searches that are available. The old Midgardian dream was that the object would have *just* the identity triple (A table, a row of that table, a field of that row), and the Javascript would go out and, using the identity, *find* the right object for CRUD (Creating, Retrieving, Updating, and Deleting) it. But that inspiration, as unreachable as it is, is where I'm headed. Where our objects are both *smart* and *standalone*. Where they're polite citizens in an ordered universe, capable of independence sufficient to be tested and validated and trusted, but working in concert to achieve our aims. * web: unravel the search-select for flows completely. This commit removes *all* instances of the search-select for flows, classifying them into four different categories: - a search with no default - a search with a default - a search with a default and a fallback to a static default if non specified - a search with a default and a fallback to the tenant's preferred default if this is a new instance and no flow specified. It's not humanly possible to test all the instances where this has been committed, but the linters are very happy with the results, and I'm going to eyeball every one of them in the github presentation before I move this out of draft. * web: several were declared 'required' that were not. * web: I can't believe this was rejected because of a misspelling in a code comment. Well done\! * web: another codespell fix for a comment. * web: adding 'codespell' to the pre-commit command. Fixed spelling error in eventEmitter. --- web/package.json | 3 +- .../TypeOAuthCodeApplicationWizardPage.ts | 32 +-- .../admin/common/ak-flow-search/FlowSearch.ts | 131 +++++++++++ .../ak-flow-search-no-default.ts | 34 +++ .../common/ak-flow-search/ak-flow-search.ts | 16 ++ .../ak-flow-search/ak-source-flow-search.ts | 50 +++++ .../ak-flow-search/ak-tenanted-flow-search.ts | 34 +++ web/src/admin/flows/StageBindingForm.ts | 34 +-- .../admin/providers/ldap/LDAPProviderForm.ts | 47 ++-- .../providers/oauth2/OAuth2ProviderForm.ts | 67 +----- .../providers/proxy/ProxyProviderForm.ts | 67 +----- .../providers/radius/RadiusProviderForm.ts | 52 ++--- .../admin/providers/saml/SAMLProviderForm.ts | 67 +----- .../providers/saml/SAMLProviderImportForm.ts | 38 +--- .../admin/sources/oauth/OAuthSourceForm.ts | 91 ++------ web/src/admin/sources/plex/PlexSourceForm.ts | 91 ++------ web/src/admin/sources/saml/SAMLSourceForm.ts | 135 ++---------- .../identification/IdentificationStageForm.ts | 105 ++------- .../admin/stages/invitation/InvitationForm.ts | 42 +--- web/src/admin/tenants/TenantForm.ts | 205 +++--------------- web/src/elements/forms/Form.ts | 3 + web/src/elements/forms/SearchSelect.ts | 13 +- web/src/elements/utils/eventEmitter.ts | 75 ++++++- 23 files changed, 519 insertions(+), 913 deletions(-) create mode 100644 web/src/admin/common/ak-flow-search/FlowSearch.ts create mode 100644 web/src/admin/common/ak-flow-search/ak-flow-search-no-default.ts create mode 100644 web/src/admin/common/ak-flow-search/ak-flow-search.ts create mode 100644 web/src/admin/common/ak-flow-search/ak-source-flow-search.ts create mode 100644 web/src/admin/common/ak-flow-search/ak-tenanted-flow-search.ts diff --git a/web/package.json b/web/package.json index a81048023..7f5a86692 100644 --- a/web/package.json +++ b/web/package.json @@ -15,8 +15,9 @@ "build-proxy": "run-s build-locales rollup:build-proxy", "watch": "run-s build-locales rollup:watch", "lint": "eslint . --max-warnings 0 --fix", + "lint:spelling": "codespell -D - -D ../.github/codespell-dictionary.txt -I ../.github/codespell-words.txt -S './src/locales/**' ./src -s", "lit-analyse": "lit-analyzer src", - "precommit": "run-s tsc lit-analyse lint prettier", + "precommit": "run-s tsc lit-analyse lint lint:spelling prettier", "prettier-check": "prettier --check .", "prettier": "prettier --write .", "tsc:execute": "tsc --noEmit -p .", diff --git a/web/src/admin/applications/wizard/oauth/TypeOAuthCodeApplicationWizardPage.ts b/web/src/admin/applications/wizard/oauth/TypeOAuthCodeApplicationWizardPage.ts index 6a7b5c473..458def24b 100644 --- a/web/src/admin/applications/wizard/oauth/TypeOAuthCodeApplicationWizardPage.ts +++ b/web/src/admin/applications/wizard/oauth/TypeOAuthCodeApplicationWizardPage.ts @@ -1,4 +1,4 @@ -import { RenderFlowOption } from "@goauthentik/admin/flows/utils"; +import "@goauthentik/admin/common/ak-flow-search/ak-flow-search-no-default"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { KeyUnknown } from "@goauthentik/elements/forms/Form"; import "@goauthentik/elements/forms/HorizontalFormElement"; @@ -12,10 +12,7 @@ import { TemplateResult, html } from "lit"; import { ClientTypeEnum, - Flow, - FlowsApi, FlowsInstancesListDesignationEnum, - FlowsInstancesListRequest, OAuth2ProviderRequest, ProvidersApi, } from "@goauthentik/api"; @@ -47,29 +44,10 @@ export class TypeOAuthCodeApplicationWizardPage extends WizardFormPage { ?required=${true} name="authorizationFlow" > - => { - const args: FlowsInstancesListRequest = { - ordering: "slug", - designation: FlowsInstancesListDesignationEnum.Authorization, - }; - 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; - }} - > - +

${msg("Flow used when users access this application.")}

diff --git a/web/src/admin/common/ak-flow-search/FlowSearch.ts b/web/src/admin/common/ak-flow-search/FlowSearch.ts new file mode 100644 index 000000000..484df6d6e --- /dev/null +++ b/web/src/admin/common/ak-flow-search/FlowSearch.ts @@ -0,0 +1,131 @@ +import { RenderFlowOption } from "@goauthentik/admin/flows/utils"; +import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; +import { AKElement } from "@goauthentik/elements/Base"; +import { SearchSelect } from "@goauthentik/elements/forms/SearchSelect"; +import { CustomListenerElement } from "@goauthentik/elements/utils/eventEmitter"; + +import { html } from "lit"; +import { property, query } from "lit/decorators.js"; + +import { FlowsApi, FlowsInstancesListDesignationEnum } from "@goauthentik/api"; +import type { Flow, FlowsInstancesListRequest } from "@goauthentik/api"; + +export function renderElement(flow: Flow) { + return RenderFlowOption(flow); +} + +export function renderDescription(flow: Flow) { + return html`${flow.slug}`; +} + +export function getFlowValue(flow: Flow | undefined): string | undefined { + return flow?.pk; +} + +/** + * FlowSearch + * + * A wrapper around SearchSelect that understands the basic semantics of querying about Flows. This + * code eliminates the long blocks of unreadable invocation that were embedded in every provider, as well as in + * sources, tenants, and applications. + * + */ + +export class FlowSearch extends CustomListenerElement(AKElement) { + /** + * The type of flow we're looking for. + * + * @attr + */ + @property({ type: String }) + flowType?: FlowsInstancesListDesignationEnum; + + /** + * The id of the current flow, if any. For stages where the flow is already defined. + * + * @attr + */ + @property({ attribute: false }) + currentFlow: string | undefined; + + /** + * If true, it is not valid to leave the flow blank. + * + * @attr + */ + @property({ type: Boolean }) + required?: boolean = false; + + @query("ak-search-select") + search!: SearchSelect; + + @property({ type: String }) + name: string | null | undefined; + + selectedFlow?: T; + + get value() { + return this.selectedFlow ? getFlowValue(this.selectedFlow) : undefined; + } + + constructor() { + super(); + this.fetchObjects = this.fetchObjects.bind(this); + this.selected = this.selected.bind(this); + this.handleSearchUpdate = this.handleSearchUpdate.bind(this); + this.addCustomListener("ak-change", this.handleSearchUpdate); + } + + handleSearchUpdate(ev: CustomEvent) { + ev.stopPropagation(); + this.selectedFlow = ev.detail.value; + } + + async fetchObjects(query?: string): Promise { + const args: FlowsInstancesListRequest = { + ordering: "slug", + designation: this.flowType, + ...(query !== undefined ? { search: query } : {}), + }; + const flows = await new FlowsApi(DEFAULT_CONFIG).flowsInstancesList(args); + return flows.results; + } + + /* This is the most commonly overridden method of this class. About half of the Flow Searches + * use this method, but several have more complex needs, such as relating to the tenant, or just + * returning false. + */ + + selected(flow: Flow): boolean { + return this.currentFlow === flow.pk; + } + + connectedCallback() { + super.connectedCallback(); + const horizontalContainer = this.closest("ak-form-element-horizontal[name]"); + if (!horizontalContainer) { + throw new Error("This search can only be used in a named ak-form-element-horizontal"); + } + const name = horizontalContainer.getAttribute("name"); + const myName = this.getAttribute("name"); + if (name !== null && name !== myName) { + this.setAttribute("name", name); + } + } + + render() { + return html` + + + `; + } +} + +export default FlowSearch; diff --git a/web/src/admin/common/ak-flow-search/ak-flow-search-no-default.ts b/web/src/admin/common/ak-flow-search/ak-flow-search-no-default.ts new file mode 100644 index 000000000..4942d1590 --- /dev/null +++ b/web/src/admin/common/ak-flow-search/ak-flow-search-no-default.ts @@ -0,0 +1,34 @@ +import "@goauthentik/elements/forms/SearchSelect"; + +import { html } from "lit"; +import { customElement } from "lit/decorators.js"; + +import type { Flow } from "@goauthentik/api"; + +import { FlowSearch, getFlowValue, renderDescription, renderElement } from "./FlowSearch"; + +/** + * @element ak-flow-search-no-default + * + * A variant of the Flow Search that doesn't look for a current flow-of-flowtype according to the + * user's settings because there shouldn't be one. Currently only used for uploading providers via + * metadata, as that scenario can only happen when no current instance is available. + */ + +@customElement("ak-flow-search-no-default") +export class AkFlowSearchNoDefault extends FlowSearch { + render() { + return html` + + + `; + } +} + +export default AkFlowSearchNoDefault; diff --git a/web/src/admin/common/ak-flow-search/ak-flow-search.ts b/web/src/admin/common/ak-flow-search/ak-flow-search.ts new file mode 100644 index 000000000..57fd7270d --- /dev/null +++ b/web/src/admin/common/ak-flow-search/ak-flow-search.ts @@ -0,0 +1,16 @@ +import { customElement } from "lit/decorators.js"; + +import type { Flow } from "@goauthentik/api"; + +import FlowSearch from "./FlowSearch"; + +/** + * @element ak-flow-search + * + * The default flow search experience. + */ + +@customElement("ak-flow-search") +export class AkFlowSearch extends FlowSearch {} + +export default AkFlowSearch; diff --git a/web/src/admin/common/ak-flow-search/ak-source-flow-search.ts b/web/src/admin/common/ak-flow-search/ak-source-flow-search.ts new file mode 100644 index 000000000..56624c0d1 --- /dev/null +++ b/web/src/admin/common/ak-flow-search/ak-source-flow-search.ts @@ -0,0 +1,50 @@ +import { customElement } from "lit/decorators.js"; +import { property } from "lit/decorators.js"; + +import type { Flow } from "@goauthentik/api"; + +import FlowSearch from "./FlowSearch"; + +/** + * Search for flows that connect to user sources + * + * @element ak-source-flow-search + * + */ + +@customElement("ak-source-flow-search") +export class AkSourceFlowSearch extends FlowSearch { + /** + * The fallback flow if none specified AND the instance has no set flow and the instance is new. + * + * @attr + */ + + @property({ type: String }) + fallback: string | undefined; + + /** + * The primary key of the Source (not the Flow). Mostly the instancePk itself, used to affirm + * that we're working on a new stage and so falling back to the default is appropriate. + * + * @attr + */ + @property({ type: String }) + instanceId: string | undefined; + + constructor() { + super(); + this.selected = this.selected.bind(this); + } + + // If there's no instance or no currentFlowId for it and the flow resembles the fallback, + // otherwise defer to the parent class. + selected(flow: Flow): boolean { + return ( + (!this.instanceId && !this.currentFlow && flow.slug === this.fallback) || + super.selected(flow) + ); + } +} + +export default AkSourceFlowSearch; diff --git a/web/src/admin/common/ak-flow-search/ak-tenanted-flow-search.ts b/web/src/admin/common/ak-flow-search/ak-tenanted-flow-search.ts new file mode 100644 index 000000000..6c89e1baa --- /dev/null +++ b/web/src/admin/common/ak-flow-search/ak-tenanted-flow-search.ts @@ -0,0 +1,34 @@ +import { customElement, property } from "lit/decorators.js"; + +import type { Flow } from "@goauthentik/api"; + +import FlowSearch from "./FlowSearch"; + +/** + * Search for flows that may have a fallback specified by the tenant settings + * + * @element ak-tenanted-flow-search + * + */ + +@customElement("ak-tenanted-flow-search") +export class AkTenantedFlowSearch extends FlowSearch { + /** + * The Associated ID of the flow the tenant has, to compare if possible + * + * @attr + */ + @property({ attribute: false, type: String }) + tenantFlow?: string; + + constructor() { + super(); + this.selected = this.selected.bind(this); + } + + selected(flow: Flow): boolean { + return super.selected(flow) || flow.pk === this.tenantFlow; + } +} + +export default AkTenantedFlowSearch; diff --git a/web/src/admin/flows/StageBindingForm.ts b/web/src/admin/flows/StageBindingForm.ts index d1f736cb8..8c5492be2 100644 --- a/web/src/admin/flows/StageBindingForm.ts +++ b/web/src/admin/flows/StageBindingForm.ts @@ -1,4 +1,3 @@ -import { RenderFlowOption } from "@goauthentik/admin/flows/utils"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { first, groupBy } from "@goauthentik/common/utils"; import "@goauthentik/elements/forms/HorizontalFormElement"; @@ -11,11 +10,9 @@ import { TemplateResult, html } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import { - Flow, FlowStageBinding, FlowsApi, FlowsInstancesListDesignationEnum, - FlowsInstancesListRequest, InvalidResponseActionEnum, PolicyEngineMode, Stage, @@ -86,32 +83,11 @@ export class StageBindingForm extends ModelForm { ?required=${true} name="target" > - => { - const args: FlowsInstancesListRequest = { - ordering: "slug", - designation: FlowsInstancesListDesignationEnum.Authorization, - }; - 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?.target; - }} - > - + `; } diff --git a/web/src/admin/providers/ldap/LDAPProviderForm.ts b/web/src/admin/providers/ldap/LDAPProviderForm.ts index 0a4df7ada..c7adc1002 100644 --- a/web/src/admin/providers/ldap/LDAPProviderForm.ts +++ b/web/src/admin/providers/ldap/LDAPProviderForm.ts @@ -1,4 +1,4 @@ -import { RenderFlowOption } from "@goauthentik/admin/flows/utils"; +import "@goauthentik/admin/common/ak-flow-search/ak-tenanted-flow-search"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { first } from "@goauthentik/common/utils"; import { rootInterface } from "@goauthentik/elements/Base"; @@ -19,10 +19,7 @@ import { CoreGroupsListRequest, CryptoApi, CryptoCertificatekeypairsListRequest, - Flow, - FlowsApi, FlowsInstancesListDesignationEnum, - FlowsInstancesListRequest, Group, LDAPAPIAccessMode, LDAPProvider, @@ -58,6 +55,12 @@ export class LDAPProviderFormPage extends ModelForm { } } + // All Provider objects have an Authorization flow, but not all providers have an Authentication + // flow. LDAP needs only one field, but it is not an Authorization field, it is an + // Authentication field. So, yeah, we're using the authorization field to store the + // authentication information, which is why the ak-tenanted-flow-search call down there looks so + // weird-- we're looking up Authentication flows, but we're storing them in the Authorization + // field of the target Provider. renderForm(): TemplateResult { return html`
@@ -73,36 +76,12 @@ export class LDAPProviderFormPage extends ModelForm { ?required=${true} name="authorizationFlow" > - => { - 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.slug}`; - }} - .value=${(flow: Flow | undefined): string | undefined => { - return flow?.pk; - }} - .selected=${(flow: Flow): boolean => { - let selected = flow.pk === rootInterface()?.tenant?.flowAuthentication; - if (this.instance?.authorizationFlow === flow.pk) { - selected = true; - } - return selected; - }} - > - +

${msg("Flow used for users to authenticate.")}

diff --git a/web/src/admin/providers/oauth2/OAuth2ProviderForm.ts b/web/src/admin/providers/oauth2/OAuth2ProviderForm.ts index 87d06d7d3..8e9bdbb02 100644 --- a/web/src/admin/providers/oauth2/OAuth2ProviderForm.ts +++ b/web/src/admin/providers/oauth2/OAuth2ProviderForm.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 { ascii_letters, digits, first, randomString } from "@goauthentik/common/utils"; import "@goauthentik/elements/forms/FormGroup"; @@ -18,10 +18,7 @@ import { ClientTypeEnum, CryptoApi, CryptoCertificatekeypairsListRequest, - Flow, - FlowsApi, FlowsInstancesListDesignationEnum, - FlowsInstancesListRequest, IssuerModeEnum, OAuth2Provider, PaginatedOAuthSourceList, @@ -95,32 +92,11 @@ export class OAuth2ProviderFormPage extends ModelForm { label=${msg("Authentication flow")} name="authenticationFlow" > - => { - 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 flow.pk === this.instance?.authenticationFlow; - }} - > - +

${msg("Flow used when a user access this provider and is not authenticated.")}

@@ -130,32 +106,11 @@ export class OAuth2ProviderFormPage extends ModelForm { ?required=${true} name="authorizationFlow" > - => { - const args: FlowsInstancesListRequest = { - ordering: "slug", - designation: FlowsInstancesListDesignationEnum.Authorization, - }; - 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?.authorizationFlow; - }} - > - +

${msg("Flow used when authorizing this provider.")}

diff --git a/web/src/admin/providers/proxy/ProxyProviderForm.ts b/web/src/admin/providers/proxy/ProxyProviderForm.ts index 86e2cd024..e3d76c752 100644 --- a/web/src/admin/providers/proxy/ProxyProviderForm.ts +++ b/web/src/admin/providers/proxy/ProxyProviderForm.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/forms/FormGroup"; @@ -22,10 +22,7 @@ import { CertificateKeyPair, CryptoApi, CryptoCertificatekeypairsListRequest, - Flow, - FlowsApi, FlowsInstancesListDesignationEnum, - FlowsInstancesListRequest, PaginatedOAuthSourceList, PaginatedScopeMappingList, PropertymappingsApi, @@ -340,32 +337,11 @@ export class ProxyProviderFormPage extends ModelForm { ?required=${false} name="authenticationFlow" > - => { - 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 flow.pk === this.instance?.authenticationFlow; - }} - > - +

${msg("Flow used when a user access this provider and is not authenticated.")}

@@ -375,32 +351,11 @@ export class ProxyProviderFormPage extends ModelForm { ?required=${true} name="authorizationFlow" > - => { - const args: FlowsInstancesListRequest = { - ordering: "slug", - designation: FlowsInstancesListDesignationEnum.Authorization, - }; - 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?.authorizationFlow; - }} - > - +

${msg("Flow used when authorizing this provider.")}

diff --git a/web/src/admin/providers/radius/RadiusProviderForm.ts b/web/src/admin/providers/radius/RadiusProviderForm.ts index 33841858c..9b1dc5452 100644 --- a/web/src/admin/providers/radius/RadiusProviderForm.ts +++ b/web/src/admin/providers/radius/RadiusProviderForm.ts @@ -1,4 +1,3 @@ -import { RenderFlowOption } from "@goauthentik/admin/flows/utils"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { ascii_letters, digits, first, randomString } from "@goauthentik/common/utils"; import { rootInterface } from "@goauthentik/elements/Base"; @@ -12,14 +11,7 @@ import { TemplateResult, html } from "lit"; import { ifDefined } from "lit-html/directives/if-defined.js"; import { customElement } from "lit/decorators.js"; -import { - Flow, - FlowsApi, - FlowsInstancesListDesignationEnum, - FlowsInstancesListRequest, - ProvidersApi, - RadiusProvider, -} from "@goauthentik/api"; +import { FlowsInstancesListDesignationEnum, ProvidersApi, RadiusProvider } from "@goauthentik/api"; @customElement("ak-provider-radius-form") export class RadiusProviderFormPage extends ModelForm { @@ -50,6 +42,12 @@ export class RadiusProviderFormPage extends ModelForm { } } + // All Provider objects have an Authorization flow, but not all providers have an Authentication + // flow. Radius needs only one field, but it is not the Authorization field, it is an + // Authentication field. So, yeah, we're using the authorization field to store the + // authentication information, which is why the ak-tenanted-flow-search call down there looks so + // weird-- we're looking up Authentication flows, but we're storing them in the Authorization + // field of the target Provider. renderForm(): TemplateResult { return html` @@ -65,36 +63,12 @@ export class RadiusProviderFormPage extends ModelForm { ?required=${true} name="authorizationFlow" > - => { - 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.slug}`; - }} - .value=${(flow: Flow | undefined): string | undefined => { - return flow?.pk; - }} - .selected=${(flow: Flow): boolean => { - let selected = flow.pk === rootInterface()?.tenant?.flowAuthentication; - if (this.instance?.authorizationFlow === flow.pk) { - selected = true; - } - return selected; - }} - > - +

${msg("Flow used for users to authenticate.")}

diff --git a/web/src/admin/providers/saml/SAMLProviderForm.ts b/web/src/admin/providers/saml/SAMLProviderForm.ts index eee46e3bf..2d92d3a08 100644 --- a/web/src/admin/providers/saml/SAMLProviderForm.ts +++ b/web/src/admin/providers/saml/SAMLProviderForm.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 "@goauthentik/elements/forms/FormGroup"; import "@goauthentik/elements/forms/HorizontalFormElement"; @@ -17,10 +17,7 @@ import { CryptoApi, CryptoCertificatekeypairsListRequest, DigestAlgorithmEnum, - Flow, - FlowsApi, FlowsInstancesListDesignationEnum, - FlowsInstancesListRequest, PaginatedSAMLPropertyMappingList, PropertymappingsApi, PropertymappingsSamlListRequest, @@ -85,32 +82,11 @@ export class SAMLProviderFormPage extends ModelForm { ?required=${false} name="authenticationFlow" > - => { - 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 flow.pk === this.instance?.authenticationFlow; - }} - > - +

${msg("Flow used when a user access this provider and is not authenticated.")}

@@ -120,32 +96,11 @@ export class SAMLProviderFormPage extends ModelForm { ?required=${true} name="authorizationFlow" > - => { - const args: FlowsInstancesListRequest = { - ordering: "slug", - designation: FlowsInstancesListDesignationEnum.Authorization, - }; - 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?.authorizationFlow; - }} - > - +

${msg("Flow used when authorizing this provider.")}

diff --git a/web/src/admin/providers/saml/SAMLProviderImportForm.ts b/web/src/admin/providers/saml/SAMLProviderImportForm.ts index d2730a708..b9eb8efce 100644 --- a/web/src/admin/providers/saml/SAMLProviderImportForm.ts +++ b/web/src/admin/providers/saml/SAMLProviderImportForm.ts @@ -1,4 +1,4 @@ -import { RenderFlowOption } from "@goauthentik/admin/flows/utils"; +import "@goauthentik/admin/common/ak-flow-search/ak-flow-search-no-default"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { SentryIgnoredError } from "@goauthentik/common/errors"; import { Form } from "@goauthentik/elements/forms/Form"; @@ -9,14 +9,7 @@ import { msg } from "@lit/localize"; import { TemplateResult, html } from "lit"; import { customElement } from "lit/decorators.js"; -import { - Flow, - FlowsApi, - FlowsInstancesListDesignationEnum, - FlowsInstancesListRequest, - ProvidersApi, - SAMLProvider, -} from "@goauthentik/api"; +import { FlowsInstancesListDesignationEnum, ProvidersApi, SAMLProvider } from "@goauthentik/api"; @customElement("ak-provider-saml-import-form") export class SAMLProviderImportForm extends Form { @@ -45,29 +38,10 @@ export class SAMLProviderImportForm extends Form { ?required=${true} name="authorizationFlow" > - => { - const args: FlowsInstancesListRequest = { - ordering: "slug", - designation: FlowsInstancesListDesignationEnum.Authorization, - }; - 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?.slug; - }} - > - +

${msg("Flow used when authorizing this provider.")}

diff --git a/web/src/admin/sources/oauth/OAuthSourceForm.ts b/web/src/admin/sources/oauth/OAuthSourceForm.ts index 63dddcea0..fdf4d22b0 100644 --- a/web/src/admin/sources/oauth/OAuthSourceForm.ts +++ b/web/src/admin/sources/oauth/OAuthSourceForm.ts @@ -1,4 +1,4 @@ -import { RenderFlowOption } from "@goauthentik/admin/flows/utils"; +import "@goauthentik/admin/common/ak-flow-search/ak-source-flow-search"; import { iconHelperText, placeholderHelperText } from "@goauthentik/admin/helperText"; import { UserMatchingModeToLabel } from "@goauthentik/admin/sources/oauth/utils"; import { DEFAULT_CONFIG, config } from "@goauthentik/common/api/config"; @@ -17,10 +17,7 @@ import { ifDefined } from "lit/directives/if-defined.js"; import { CapabilitiesEnum, - Flow, - FlowsApi, FlowsInstancesListDesignationEnum, - FlowsInstancesListRequest, OAuthSource, OAuthSourceRequest, ProviderTypeEnum, @@ -413,43 +410,12 @@ export class OAuthSourceForm extends ModelForm { ?required=${true} name="authenticationFlow" > - => { - 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 => { - let selected = this.instance?.authenticationFlow === flow.pk; - if ( - !this.instance?.pk && - !this.instance?.authenticationFlow && - flow.slug === "default-source-authentication" - ) { - selected = true; - } - return selected; - }} - ?blankable=${true} - > - +

${msg("Flow to use when authenticating existing users.")}

@@ -459,43 +425,12 @@ export class OAuthSourceForm extends ModelForm { ?required=${true} name="enrollmentFlow" > - => { - 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 => { - let selected = this.instance?.enrollmentFlow === flow.pk; - if ( - !this.instance?.pk && - !this.instance?.enrollmentFlow && - flow.slug === "default-source-enrollment" - ) { - selected = true; - } - return selected; - }} - ?blankable=${true} - > - +

${msg("Flow to use when enrolling new users.")}

diff --git a/web/src/admin/sources/plex/PlexSourceForm.ts b/web/src/admin/sources/plex/PlexSourceForm.ts index a84b987d1..729654edb 100644 --- a/web/src/admin/sources/plex/PlexSourceForm.ts +++ b/web/src/admin/sources/plex/PlexSourceForm.ts @@ -1,4 +1,4 @@ -import { RenderFlowOption } from "@goauthentik/admin/flows/utils"; +import "@goauthentik/admin/common/ak-flow-search/ak-source-flow-search"; import { iconHelperText, placeholderHelperText } from "@goauthentik/admin/helperText"; import { UserMatchingModeToLabel } from "@goauthentik/admin/sources/oauth/utils"; import { DEFAULT_CONFIG, config } from "@goauthentik/common/api/config"; @@ -17,10 +17,7 @@ import { ifDefined } from "lit/directives/if-defined.js"; import { CapabilitiesEnum, - Flow, - FlowsApi, FlowsInstancesListDesignationEnum, - FlowsInstancesListRequest, PlexSource, SourcesApi, UserMatchingModeEnum, @@ -339,43 +336,12 @@ export class PlexSourceForm extends ModelForm { ?required=${true} name="authenticationFlow" > - => { - 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 => { - let selected = this.instance?.authenticationFlow === flow.pk; - if ( - !this.instance?.pk && - !this.instance?.authenticationFlow && - flow.slug === "default-source-authentication" - ) { - selected = true; - } - return selected; - }} - ?blankable=${true} - > - +

${msg("Flow to use when authenticating existing users.")}

@@ -385,43 +351,12 @@ export class PlexSourceForm extends ModelForm { ?required=${true} name="enrollmentFlow" > - => { - 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 => { - let selected = this.instance?.enrollmentFlow === flow.pk; - if ( - !this.instance?.pk && - !this.instance?.enrollmentFlow && - flow.slug === "default-source-enrollment" - ) { - selected = true; - } - return selected; - }} - ?blankable=${true} - > - +

${msg("Flow to use when enrolling new users.")}

diff --git a/web/src/admin/sources/saml/SAMLSourceForm.ts b/web/src/admin/sources/saml/SAMLSourceForm.ts index d41b3de62..86e7e3bb8 100644 --- a/web/src/admin/sources/saml/SAMLSourceForm.ts +++ b/web/src/admin/sources/saml/SAMLSourceForm.ts @@ -1,4 +1,4 @@ -import { RenderFlowOption } from "@goauthentik/admin/flows/utils"; +import "@goauthentik/admin/common/ak-flow-search/ak-source-flow-search"; import { iconHelperText, placeholderHelperText } from "@goauthentik/admin/helperText"; import { UserMatchingModeToLabel } from "@goauthentik/admin/sources/oauth/utils"; import { DEFAULT_CONFIG, config } from "@goauthentik/common/api/config"; @@ -22,10 +22,7 @@ import { CryptoApi, CryptoCertificatekeypairsListRequest, DigestAlgorithmEnum, - Flow, - FlowsApi, FlowsInstancesListDesignationEnum, - FlowsInstancesListRequest, NameIdPolicyEnum, SAMLSource, SignatureAlgorithmEnum, @@ -521,44 +518,12 @@ export class SAMLSourceForm extends ModelForm { ?required=${true} name="preAuthenticationFlow" > - => { - 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 => { - let selected = this.instance?.preAuthenticationFlow === flow.pk; - if ( - !this.instance?.pk && - !this.instance?.preAuthenticationFlow && - flow.slug === "default-source-pre-authentication" - ) { - selected = true; - } - return selected; - }} - ?blankable=${true} - > - +

${msg("Flow used before authentication.")}

@@ -568,43 +533,12 @@ export class SAMLSourceForm extends ModelForm { ?required=${true} name="authenticationFlow" > - => { - 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 => { - let selected = this.instance?.authenticationFlow === flow.pk; - if ( - !this.instance?.pk && - !this.instance?.authenticationFlow && - flow.slug === "default-source-authentication" - ) { - selected = true; - } - return selected; - }} - ?blankable=${true} - > - +

${msg("Flow to use when authenticating existing users.")}

@@ -614,43 +548,12 @@ export class SAMLSourceForm extends ModelForm { ?required=${true} name="enrollmentFlow" > - => { - 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 => { - let selected = this.instance?.enrollmentFlow === flow.pk; - if ( - !this.instance?.pk && - !this.instance?.enrollmentFlow && - flow.slug === "default-source-enrollment" - ) { - selected = true; - } - return selected; - }} - ?blankable=${true} - > - +

${msg("Flow to use when enrolling new users.")}

diff --git a/web/src/admin/stages/identification/IdentificationStageForm.ts b/web/src/admin/stages/identification/IdentificationStageForm.ts index c3c5ee137..2a808d123 100644 --- a/web/src/admin/stages/identification/IdentificationStageForm.ts +++ b/web/src/admin/stages/identification/IdentificationStageForm.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, groupBy } from "@goauthentik/common/utils"; import "@goauthentik/elements/forms/FormGroup"; @@ -12,10 +12,7 @@ import { customElement } from "lit/decorators.js"; import { ifDefined } from "lit/directives/if-defined.js"; import { - Flow, - FlowsApi, FlowsInstancesListDesignationEnum, - FlowsInstancesListRequest, IdentificationStage, PaginatedSourceList, SourcesApi, @@ -265,35 +262,10 @@ export class IdentificationStageForm extends ModelForm - => { - 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?.passwordlessFlow == flow.pk; - }} - ?blankable=${true} - > - +

${msg( "Optional passwordless flow, which is linked at the bottom of the page. When configured, users can use this flow to authenticate with a WebAuthn authenticator, without entering any details.", @@ -304,35 +276,11 @@ export class IdentificationStageForm 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 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?.enrollmentFlow == flow.pk; - }} - ?blankable=${true} - > - + +

${msg( "Optional enrollment flow, which is linked at the bottom of the page.", @@ -340,35 +288,10 @@ export class IdentificationStageForm 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 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); } }; }