From 259537ee3453579e2d39fea0a083b9796da6d0fe Mon Sep 17 00:00:00 2001 From: Ken Sternberg <133134217+kensternberg-authentik@users.noreply.github.com> Date: Mon, 15 Jul 2024 09:49:03 -0700 Subject: [PATCH] web: replace multi-select with dual-select for all propertyMapping invocations (#9359) * web: fix esbuild issue with style sheets Getting ESBuild, Lit, and Storybook to all agree on how to read and parse stylesheets is a serious pain. This fix better identifies the value types (instances) being passed from various sources in the repo to the three *different* kinds of style processors we're using (the native one, the polyfill one, and whatever the heck Storybook does internally). Falling back to using older CSS instantiating techniques one era at a time seems to do the trick. It's ugly, but in the face of the aggressive styling we use to avoid Flashes of Unstyled Content (FLoUC), it's the logic with which we're left. In standard mode, the following warning appears on the console when running a Flow: ``` Autofocus processing was blocked because a document already has a focused element. ``` In compatibility mode, the following **error** appears on the console when running a Flow: ``` crawler-inject.js:1106 Uncaught TypeError: Failed to execute 'observe' on 'MutationObserver': parameter 1 is not of type 'Node'. at initDomMutationObservers (crawler-inject.js:1106:18) at crawler-inject.js:1114:24 at Array.forEach () at initDomMutationObservers (crawler-inject.js:1114:10) at crawler-inject.js:1549:1 initDomMutationObservers @ crawler-inject.js:1106 (anonymous) @ crawler-inject.js:1114 initDomMutationObservers @ crawler-inject.js:1114 (anonymous) @ crawler-inject.js:1549 ``` Despite this error, nothing seems to be broken and flows work as anticipated. * web: replace multi-select with dual-select for all propertyMapping invocations All of the uses of - ${this.propertyMappings?.results.map((scope) => { - let selected = false; - if (!provider?.propertyMappings) { - selected = scope.managed - ? defaultScopes.includes(scope.managed) - : false; - } else { - selected = Array.from(provider?.propertyMappings).some( - (su) => { - return su == scope.pk; - }, - ); - } - return html``; - })} - +

${msg( "Select which scopes can be used by the client. The client still has to specify the scope to access the data.", )}

-

- ${msg("Hold control/command to select multiple items.")} -

{ - this.propertyMappings = propertyMappings; - }); - new SourcesApi(DEFAULT_CONFIG) .sourcesOauthList({ ordering: "name", @@ -88,29 +86,8 @@ export class AkTypeProxyApplicationWizardPage extends BaseProviderPanel { `; } - scopeMappingConfiguration(provider?: ProxyProvider) { - const propertyMappings = this.propertyMappings?.results ?? []; - - const defaultScopes = () => - propertyMappings - .filter((scope) => !(scope?.managed ?? "").startsWith("goauthentik.io/providers")) - .map((pm) => pm.pk); - - const configuredScopes = (providerMappings: string[]) => - propertyMappings.map((scope) => scope.pk).filter((pk) => providerMappings.includes(pk)); - - const scopeValues = provider?.propertyMappings - ? configuredScopes(provider?.propertyMappings ?? []) - : defaultScopes(); - - const scopePairs = propertyMappings.map((scope) => [scope.pk, scope.name]); - - return { scopePairs, scopeValues }; - } - render() { const errors = this.wizard.errors.provider; - const { scopePairs, scopeValues } = this.scopeMappingConfiguration(this.instance); return html` ${msg("Configure Proxy Provider")}
@@ -179,24 +156,22 @@ export class AkTypeProxyApplicationWizardPage extends BaseProviderPanel { certificate=${ifDefined(this.instance?.certificate ?? undefined)} > - - - ${msg( - "Additional scope mappings, which are passed to the proxy.", - )} -

-

- ${msg("Hold control/command to select multiple items.")} -

- `} - >
+ > + +

+ ${msg("Additional scope mappings, which are passed to the proxy.")} +

+ { - this.propertyMappings = propertyMappings; - }); - } - render() { const provider = this.wizard.provider as RACProvider | undefined; - const selected = new Set(Array.from(provider?.propertyMappings ?? [])); const errors = this.wizard.errors.provider; return html` - - ${this.propertyMappings?.results.map((scope) => { - let selected = false; - if (!provider?.propertyMappings) { - selected = scope.managed - ? defaultScopes.includes(scope.managed) - : false; - } else { - selected = Array.from(provider?.propertyMappings).some((su) => { - return su == scope.pk; - }); - } - return html``; - })} - + +

${msg( "Select which scopes can be used by the client. The client still has to specify the scope to access the data.", )}

-

- ${msg("Hold control/command to select multiple items.")} -

[scope.pk, scope.name, scope.name, scope]), + }; +} + +export function makeOAuth2PropertyMappingsSelector(instanceMappings: string[] | undefined) { + const localMappings = instanceMappings ? new Set(instanceMappings) : undefined; + return localMappings + ? ([pk, _]: DualSelectPair) => localMappings.has(pk) + : ([_0, _1, _2, scope]: DualSelectPair) => + scope?.managed?.startsWith("goauthentik.io/providers/oauth2/scope-") && + scope?.managed !== "goauthentik.io/providers/oauth2/scope-offline_access"; +} diff --git a/web/src/admin/providers/proxy/ProxyProviderForm.ts b/web/src/admin/providers/proxy/ProxyProviderForm.ts index d05c433912..613ba78c36 100644 --- a/web/src/admin/providers/proxy/ProxyProviderForm.ts +++ b/web/src/admin/providers/proxy/ProxyProviderForm.ts @@ -4,6 +4,7 @@ import { BaseProviderForm } from "@goauthentik/admin/providers/BaseProviderForm" import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { first } from "@goauthentik/common/utils"; import "@goauthentik/components/ak-toggle-group"; +import "@goauthentik/elements/ak-dual-select/ak-dual-select-dynamic-selected-provider.js"; import "@goauthentik/elements/forms/FormGroup"; import "@goauthentik/elements/forms/HorizontalFormElement"; import "@goauthentik/elements/forms/SearchSelect"; @@ -21,14 +22,17 @@ import PFSpacing from "@patternfly/patternfly/utilities/Spacing/spacing.css"; import { FlowsInstancesListDesignationEnum, PaginatedOAuthSourceList, - PaginatedScopeMappingList, - PropertymappingsApi, ProvidersApi, ProxyMode, ProxyProvider, SourcesApi, } from "@goauthentik/api"; +import { + makeProxyPropertyMappingsSelector, + proxyPropertyMappingsProvider, +} from "./ProxyProviderPropertyMappings.js"; + @customElement("ak-provider-proxy-form") export class ProxyProviderFormPage extends BaseProviderForm { static get styles(): CSSResult[] { @@ -45,18 +49,12 @@ export class ProxyProviderFormPage extends BaseProviderForm { } async load(): Promise { - this.propertyMappings = await new PropertymappingsApi( - DEFAULT_CONFIG, - ).propertymappingsScopeList({ - ordering: "scope_name", - }); this.oauthSources = await new SourcesApi(DEFAULT_CONFIG).sourcesOauthList({ ordering: "name", hasJwks: true, }); } - propertyMappings?: PaginatedScopeMappingList; oauthSources?: PaginatedOAuthSourceList; @state() @@ -323,31 +321,17 @@ export class ProxyProviderFormPage extends BaseProviderForm { label=${msg("Additional scopes")} name="propertyMappings" > - +

${msg("Additional scope mappings, which are passed to the proxy.")}

-

- ${msg("Hold control/command to select multiple items.")} -

[scope.pk, scope.name, scope.name, scope]), + }; +} + +export function makeProxyPropertyMappingsSelector(mappings?: string[]) { + const localMappings = mappings ? new Set(mappings) : undefined; + return localMappings + ? ([pk, _]: DualSelectPair) => localMappings.has(pk) + : ([_0, _1, _2, scope]: DualSelectPair) => + !(scope?.managed ?? "").startsWith("goauthentik.io/providers"); +} diff --git a/web/src/admin/providers/rac/EndpointForm.ts b/web/src/admin/providers/rac/EndpointForm.ts index c14feee462..eb84b6f415 100644 --- a/web/src/admin/providers/rac/EndpointForm.ts +++ b/web/src/admin/providers/rac/EndpointForm.ts @@ -2,6 +2,7 @@ import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { first } from "@goauthentik/common/utils"; import "@goauthentik/components/ak-radio-input"; import "@goauthentik/elements/CodeMirror"; +import "@goauthentik/elements/ak-dual-select/ak-dual-select-dynamic-selected-provider.js"; import "@goauthentik/elements/forms/FormGroup"; import "@goauthentik/elements/forms/HorizontalFormElement"; import { ModelForm } from "@goauthentik/elements/forms/ModelForm"; @@ -12,30 +13,18 @@ import { TemplateResult, html } from "lit"; import { customElement, property } from "lit/decorators.js"; import { ifDefined } from "lit/directives/if-defined.js"; +import { AuthModeEnum, Endpoint, ProtocolEnum, RacApi } from "@goauthentik/api"; + import { - AuthModeEnum, - Endpoint, - PaginatedRACPropertyMappingList, - PropertymappingsApi, - ProtocolEnum, - RacApi, -} from "@goauthentik/api"; + makeRACPropertyMappingsSelector, + racPropertyMappingsProvider, +} from "./RACPropertyMappings.js"; @customElement("ak-rac-endpoint-form") export class EndpointForm extends ModelForm { @property({ type: Number }) providerID?: number; - propertyMappings?: PaginatedRACPropertyMappingList; - - async load(): Promise { - this.propertyMappings = await new PropertymappingsApi( - DEFAULT_CONFIG, - ).propertymappingsRacList({ - ordering: "name", - }); - } - loadInstance(pk: string): Promise { return new RacApi(DEFAULT_CONFIG).racEndpointsRetrieve({ pbmUuid: pk, @@ -124,22 +113,12 @@ export class EndpointForm extends ModelForm {

- -

- ${msg("Hold control/command to select multiple items.")} -

+
${msg("Advanced settings")} diff --git a/web/src/admin/providers/rac/RACPropertyMappings.ts b/web/src/admin/providers/rac/RACPropertyMappings.ts new file mode 100644 index 0000000000..8fa0669a0a --- /dev/null +++ b/web/src/admin/providers/rac/RACPropertyMappings.ts @@ -0,0 +1,22 @@ +import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; +import { DualSelectPair } from "@goauthentik/elements/ak-dual-select/types.js"; + +import { PropertymappingsApi } from "@goauthentik/api"; + +export async function racPropertyMappingsProvider(page = 1, search = "") { + const propertyMappings = await new PropertymappingsApi(DEFAULT_CONFIG).propertymappingsRacList({ + ordering: "name", + pageSize: 20, + search: search.trim(), + page, + }); + return { + pagination: propertyMappings.pagination, + options: propertyMappings.results.map((mapping) => [mapping.pk, mapping.name]), + }; +} + +export function makeRACPropertyMappingsSelector(instanceMappings?: string[]) { + const localMappings = new Set(instanceMappings ?? []); + return ([pk, _]: DualSelectPair) => localMappings.has(pk); +} diff --git a/web/src/admin/providers/rac/RACProviderForm.ts b/web/src/admin/providers/rac/RACProviderForm.ts index 0fef06697e..80bab110c2 100644 --- a/web/src/admin/providers/rac/RACProviderForm.ts +++ b/web/src/admin/providers/rac/RACProviderForm.ts @@ -3,6 +3,7 @@ import "@goauthentik/admin/common/ak-flow-search/ak-branded-flow-search"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { first } from "@goauthentik/common/utils"; import "@goauthentik/elements/CodeMirror"; +import "@goauthentik/elements/ak-dual-select/ak-dual-select-dynamic-selected-provider.js"; import "@goauthentik/elements/forms/FormGroup"; import "@goauthentik/elements/forms/HorizontalFormElement"; import { ModelForm } from "@goauthentik/elements/forms/ModelForm"; @@ -13,30 +14,18 @@ import YAML from "yaml"; import { msg } from "@lit/localize"; import { TemplateResult, html } from "lit"; -import { customElement, state } from "lit/decorators.js"; +import { customElement } from "lit/decorators.js"; import { ifDefined } from "lit/directives/if-defined.js"; +import { FlowsInstancesListDesignationEnum, ProvidersApi, RACProvider } from "@goauthentik/api"; + import { - FlowsInstancesListDesignationEnum, - PaginatedRACPropertyMappingList, - PropertymappingsApi, - ProvidersApi, - RACProvider, -} from "@goauthentik/api"; + makeRACPropertyMappingsSelector, + racPropertyMappingsProvider, +} from "./RACPropertyMappings.js"; @customElement("ak-provider-rac-form") export class RACProviderFormPage extends ModelForm { - @state() - propertyMappings?: PaginatedRACPropertyMappingList; - - async load(): Promise { - this.propertyMappings = await new PropertymappingsApi( - DEFAULT_CONFIG, - ).propertymappingsRacList({ - ordering: "name", - }); - } - async loadInstance(pk: number): Promise { return new ProvidersApi(DEFAULT_CONFIG).providersRacRetrieve({ id: pk, @@ -137,27 +126,14 @@ export class RACProviderFormPage extends ModelForm { label=${msg("Property mappings")} name="propertyMappings" > - -

- ${msg("Hold control/command to select multiple items.")} -

+ [m.pk, m.name, m.name, m]), + }; +} + +export function makeSAMLPropertyMappingsSelector(instanceMappings?: string[]) { + const localMappings = instanceMappings ? new Set(instanceMappings) : undefined; + return localMappings + ? ([pk, _]: DualSelectPair) => localMappings.has(pk) + : ([_0, _1, _2, mapping]: DualSelectPair) => + mapping?.managed?.startsWith("goauthentik.io/providers/saml"); +} + @customElement("ak-provider-saml-form") export class SAMLProviderFormPage extends BaseProviderForm { loadInstance(pk: number): Promise { @@ -34,16 +58,6 @@ export class SAMLProviderFormPage extends BaseProviderForm { }); } - async load(): Promise { - this.propertyMappings = await new PropertymappingsApi( - DEFAULT_CONFIG, - ).propertymappingsSamlList({ - ordering: "saml_name", - }); - } - - propertyMappings?: PaginatedSAMLPropertyMappingList; - async send(data: SAMLProvider): Promise { if (this.instance) { return new ProvidersApi(DEFAULT_CONFIG).providersSamlUpdate({ @@ -193,29 +207,14 @@ export class SAMLProviderFormPage extends BaseProviderForm { label=${msg("Property mappings")} name="propertyMappings" > - +

${msg("Hold control/command to select multiple items.")}

diff --git a/web/src/admin/providers/scim/SCIMProviderForm.ts b/web/src/admin/providers/scim/SCIMProviderForm.ts index afcba41b35..7ea49efb04 100644 --- a/web/src/admin/providers/scim/SCIMProviderForm.ts +++ b/web/src/admin/providers/scim/SCIMProviderForm.ts @@ -1,6 +1,8 @@ import { BaseProviderForm } from "@goauthentik/admin/providers/BaseProviderForm"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { first } from "@goauthentik/common/utils"; +import "@goauthentik/elements/ak-dual-select/ak-dual-select-dynamic-selected-provider.js"; +import { DualSelectPair } from "@goauthentik/elements/ak-dual-select/types.js"; import "@goauthentik/elements/forms/FormGroup"; import "@goauthentik/elements/forms/HorizontalFormElement"; import "@goauthentik/elements/forms/Radio"; @@ -15,12 +17,35 @@ import { CoreApi, CoreGroupsListRequest, Group, - PaginatedSCIMMappingList, PropertymappingsApi, ProvidersApi, + SCIMMapping, SCIMProvider, } from "@goauthentik/api"; +export async function scimPropertyMappingsProvider(page = 1, search = "") { + const propertyMappings = await new PropertymappingsApi(DEFAULT_CONFIG).propertymappingsScimList( + { + ordering: "managed", + pageSize: 20, + search: search.trim(), + page, + }, + ); + return { + pagination: propertyMappings.pagination, + options: propertyMappings.results.map((m) => [m.pk, m.name, m.name, m]), + }; +} + +export function makeSCIMPropertyMappingsSelector(instanceMappings: string[] | undefined) { + const localMappings = instanceMappings ? new Set(instanceMappings) : undefined; + return localMappings + ? ([pk, _]: DualSelectPair) => localMappings.has(pk) + : ([_0, _1, _2, mapping]: DualSelectPair) => + mapping?.managed === "goauthentik.io/providers/scim/user"; +} + @customElement("ak-provider-scim-form") export class SCIMProviderFormPage extends BaseProviderForm { loadInstance(pk: number): Promise { @@ -29,16 +54,6 @@ export class SCIMProviderFormPage extends BaseProviderForm { }); } - async load(): Promise { - this.propertyMappings = await new PropertymappingsApi( - DEFAULT_CONFIG, - ).propertymappingsScimList({ - ordering: "managed", - }); - } - - propertyMappings?: PaginatedSCIMMappingList; - async send(data: SCIMProvider): Promise { if (this.instance) { return new ProvidersApi(DEFAULT_CONFIG).providersScimUpdate({ @@ -152,68 +167,34 @@ export class SCIMProviderFormPage extends BaseProviderForm {
-

${msg("Property mappings used to user mapping.")}

-

- ${msg("Hold control/command to select multiple items.")} -

- + name="propertyMappingsGroup"> +

${msg("Property mappings used to group creation.")}

-

- ${msg("Hold control/command to select multiple items.")} -

`; diff --git a/web/src/admin/sources/ldap/LDAPSourceForm.ts b/web/src/admin/sources/ldap/LDAPSourceForm.ts index 64aa9d0b88..2fde36516b 100644 --- a/web/src/admin/sources/ldap/LDAPSourceForm.ts +++ b/web/src/admin/sources/ldap/LDAPSourceForm.ts @@ -3,6 +3,8 @@ import { placeholderHelperText } from "@goauthentik/admin/helperText"; import { BaseSourceForm } from "@goauthentik/admin/sources/BaseSourceForm"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { first } from "@goauthentik/common/utils"; +import "@goauthentik/elements/ak-dual-select/ak-dual-select-dynamic-selected-provider.js"; +import { DualSelectPair } from "@goauthentik/elements/ak-dual-select/types.js"; import "@goauthentik/elements/forms/FormGroup"; import "@goauthentik/elements/forms/HorizontalFormElement"; import "@goauthentik/elements/forms/SearchSelect"; @@ -16,13 +18,37 @@ import { CoreApi, CoreGroupsListRequest, Group, + LDAPPropertyMapping, LDAPSource, LDAPSourceRequest, - PaginatedLDAPPropertyMappingList, PropertymappingsApi, SourcesApi, } from "@goauthentik/api"; +async function propertyMappingsProvider(page = 1, search = "") { + const propertyMappings = await new PropertymappingsApi(DEFAULT_CONFIG).propertymappingsLdapList( + { + ordering: "managed,object_field", + pageSize: 20, + search: search.trim(), + page, + }, + ); + return { + pagination: propertyMappings.pagination, + options: propertyMappings.results.map((m) => [m.pk, m.name, m.name, m]), + }; +} + +function makePropertyMappingsSelector(instanceMappings?: string[]) { + const localMappings = instanceMappings ? new Set(instanceMappings) : undefined; + return localMappings + ? ([pk, _]: DualSelectPair) => localMappings.has(pk) + : ([_0, _1, _2, mapping]: DualSelectPair) => + mapping?.managed?.startsWith("goauthentik.io/sources/ldap/default") || + mapping?.managed?.startsWith("goauthentik.io/sources/ldap/ms"); +} + @customElement("ak-source-ldap-form") export class LDAPSourceForm extends BaseSourceForm { loadInstance(pk: string): Promise { @@ -31,16 +57,6 @@ export class LDAPSourceForm extends BaseSourceForm { }); } - async load(): Promise { - this.propertyMappings = await new PropertymappingsApi( - DEFAULT_CONFIG, - ).propertymappingsLdapList({ - ordering: "managed,object_field", - }); - } - - propertyMappings?: PaginatedLDAPPropertyMappingList; - async send(data: LDAPSource): Promise { if (this.instance) { return new SourcesApi(DEFAULT_CONFIG).sourcesLdapPartialUpdate({ @@ -277,71 +293,32 @@ export class LDAPSourceForm extends BaseSourceForm { label=${msg("User Property Mappings")} name="propertyMappings" > - +

- ${msg("Property mappings used to user creation.")} -

-

- ${msg("Hold control/command to select multiple items.")} + ${msg("Property mappings for user creation.")}

- +

- ${msg("Property mappings used to group creation.")} -

-

- ${msg("Hold control/command to select multiple items.")} + ${msg("Property mappings for group creation.")}

diff --git a/web/src/elements/ak-dual-select/ak-dual-select-dynamic-selected-provider.ts b/web/src/elements/ak-dual-select/ak-dual-select-dynamic-selected-provider.ts new file mode 100644 index 0000000000..e5d834f3c1 --- /dev/null +++ b/web/src/elements/ak-dual-select/ak-dual-select-dynamic-selected-provider.ts @@ -0,0 +1,52 @@ +import { PropertyValues, html } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { ref } from "lit/directives/ref.js"; + +import { AkDualSelectProvider } from "./ak-dual-select-provider.js"; +import "./ak-dual-select.js"; +import type { DualSelectPair } from "./types.js"; + +/** + * @element ak-dual-select-dynamic-provider + * + * A top-level component for multi-select elements have dynamically generated "selected" + * lists. + */ + +@customElement("ak-dual-select-dynamic-selected") +export class AkDualSelectDynamic extends AkDualSelectProvider { + /** + * An extra source of "default" entries. A number of our collections have an alternative default + * source when initializing a new component instance of that collection's host object. Only run + * on start-up. + * + * @attr + */ + @property({ attribute: false }) + selector: ([key, _]: DualSelectPair) => boolean = ([_key, _]) => false; + + private firstUpdateHasRun = false; + + willUpdate(changed: PropertyValues) { + super.willUpdate(changed); + // On the first update *only*, even before rendering, when the options are handed up, update + // the selected list with the contents derived from the selector. + if (!this.firstUpdateHasRun && this.options.length > 0) { + this.firstUpdateHasRun = true; + this.selected = Array.from( + new Set([...this.selected, ...this.options.filter(this.selector)]), + ); + } + } + + render() { + return html``; + } +} diff --git a/web/src/elements/ak-dual-select/ak-dual-select-provider.ts b/web/src/elements/ak-dual-select/ak-dual-select-provider.ts index 15f274460a..36694a4923 100644 --- a/web/src/elements/ak-dual-select/ak-dual-select-provider.ts +++ b/web/src/elements/ak-dual-select/ak-dual-select-provider.ts @@ -27,36 +27,59 @@ import type { DataProvider, DualSelectPair } from "./types"; @customElement("ak-dual-select-provider") export class AkDualSelectProvider extends CustomListenerElement(AKElement) { - /** A function that takes a page and returns the DualSelectPair[] collection with which to update + /** + * A function that takes a page and returns the DualSelectPair[] collection with which to update * the "Available" pane. + * + * @attr */ @property({ type: Object }) provider!: DataProvider; + /** + * The list of selected items. This is the *complete* list, not paginated, as presented by a + * component with a multi-select list of items to track. + * + * @attr + */ @property({ type: Array }) selected: DualSelectPair[] = []; + /** + * The label for the left ("available") pane + * + * @attr + */ @property({ attribute: "available-label" }) availableLabel = msg("Available options"); + /** + * The label for the right ("selected") pane + * + * @attr + */ @property({ attribute: "selected-label" }) selectedLabel = msg("Selected options"); - /** The remote lists are debounced by definition. This is the interval for the debounce. */ + /** + * The debounce for the search as the user is typing in a request + * + * @attr + */ @property({ attribute: "search-delay", type: Number }) searchDelay = 250; @state() - private options: DualSelectPair[] = []; + options: DualSelectPair[] = []; - private dualSelector: Ref = createRef(); + protected dualSelector: Ref = createRef(); - private isLoading = false; + protected isLoading = false; private doneFirstUpdate = false; private internalSelected: DualSelectPair[] = []; - private pagination?: Pagination; + protected pagination?: Pagination; constructor() { super(); diff --git a/web/src/elements/ak-dual-select/types.ts b/web/src/elements/ak-dual-select/types.ts index 2e7cea13a0..10da0a56e1 100644 --- a/web/src/elements/ak-dual-select/types.ts +++ b/web/src/elements/ak-dual-select/types.ts @@ -4,7 +4,7 @@ import { Pagination } from "@goauthentik/api"; // Key, Label (string or TemplateResult), (optional) string to sort by. If the sort string is // missing, it will use the label, which doesn't always work for TemplateResults). -export type DualSelectPair = [string, string | TemplateResult, string?]; +export type DualSelectPair = [string, string | TemplateResult, string?, T?]; export type BasePagination = Pick< Pagination,