diff --git a/web/.eslintrc.precommit.json b/web/.eslintrc.precommit.json index c2c06589ce..6e81348788 100644 --- a/web/.eslintrc.precommit.json +++ b/web/.eslintrc.precommit.json @@ -23,7 +23,7 @@ "quotes": ["error", "double", { "avoidEscape": true }], "semi": ["error", "always"], "@typescript-eslint/ban-ts-comment": "off", - "sonarjs/cognitive-complexity": ["error", 9], + "sonarjs/cognitive-complexity": ["warn", 9], "sonarjs/no-duplicate-string": "off", "sonarjs/no-nested-template-literals": "off" } diff --git a/web/scripts/eslint-precommit.mjs b/web/scripts/eslint-precommit.mjs index c65f953e21..b225c31e85 100644 --- a/web/scripts/eslint-precommit.mjs +++ b/web/scripts/eslint-precommit.mjs @@ -35,7 +35,7 @@ const eslintConfig = { "quotes": ["error", "double", { avoidEscape: true }], "semi": ["error", "always"], "@typescript-eslint/ban-ts-comment": "off", - "sonarjs/cognitive-complexity": ["error", 9], + "sonarjs/cognitive-complexity": ["warn", 9], "sonarjs/no-duplicate-string": "off", "sonarjs/no-nested-template-literals": "off", }, @@ -72,5 +72,6 @@ const formatter = await eslint.loadFormatter("stylish"); const resultText = formatter.format(results); const errors = results.reduce((acc, result) => acc + result.errorCount, 0); +// eslint-disable-next-line no-console console.log(resultText); process.exit(errors > 1 ? 1 : 0); diff --git a/web/src/admin/applications/wizard/methods/oauth/ak-application-wizard-authentication-by-oauth.ts b/web/src/admin/applications/wizard/methods/oauth/ak-application-wizard-authentication-by-oauth.ts index 54bc7d2eae..2e83858380 100644 --- a/web/src/admin/applications/wizard/methods/oauth/ak-application-wizard-authentication-by-oauth.ts +++ b/web/src/admin/applications/wizard/methods/oauth/ak-application-wizard-authentication-by-oauth.ts @@ -3,11 +3,14 @@ import "@goauthentik/admin/common/ak-crypto-certificate-search"; import "@goauthentik/admin/common/ak-flow-search/ak-branded-flow-search"; import { clientTypeOptions, - defaultScopes, issuerModeOptions, redirectUriHelp, subjectModeOptions, } from "@goauthentik/admin/providers/oauth2/OAuth2ProviderForm"; +import { + makeOAuth2PropertyMappingsSelector, + oauth2PropertyMappingsProvider, +} from "@goauthentik/admin/providers/oauth2/Oauth2PropertyMappings.js"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { ascii_letters, digits, first, randomString } from "@goauthentik/common/utils"; import "@goauthentik/components/ak-number-input"; @@ -15,6 +18,7 @@ import "@goauthentik/components/ak-radio-input"; import "@goauthentik/components/ak-switch-input"; import "@goauthentik/components/ak-text-input"; import "@goauthentik/components/ak-textarea-input"; +import "@goauthentik/elements/ak-dual-select/ak-dual-select-dynamic-selected-provider.js"; import "@goauthentik/elements/forms/FormGroup"; import "@goauthentik/elements/forms/HorizontalFormElement"; @@ -23,17 +27,8 @@ import { customElement, state } from "@lit/reactive-element/decorators.js"; import { html, nothing } from "lit"; import { ifDefined } from "lit/directives/if-defined.js"; -import { - ClientTypeEnum, - FlowsInstancesListDesignationEnum, - PropertymappingsApi, - SourcesApi, -} from "@goauthentik/api"; -import { - type OAuth2Provider, - type PaginatedOAuthSourceList, - type PaginatedScopeMappingList, -} from "@goauthentik/api"; +import { ClientTypeEnum, FlowsInstancesListDesignationEnum, SourcesApi } from "@goauthentik/api"; +import { type OAuth2Provider, type PaginatedOAuthSourceList } from "@goauthentik/api"; import BaseProviderPanel from "../BaseProviderPanel"; @@ -42,22 +37,11 @@ export class ApplicationWizardAuthenticationByOauth extends BaseProviderPanel { @state() showClientSecret = true; - @state() - propertyMappings?: PaginatedScopeMappingList; - @state() oauthSources?: PaginatedOAuthSourceList; constructor() { super(); - new PropertymappingsApi(DEFAULT_CONFIG) - .propertymappingsScopeList({ - ordering: "scope_name", - }) - .then((propertyMappings: PaginatedScopeMappingList) => { - this.propertyMappings = propertyMappings; - }); - new SourcesApi(DEFAULT_CONFIG) .sourcesOauthList({ ordering: "name", @@ -222,36 +206,19 @@ export class ApplicationWizardAuthenticationByOauth extends BaseProviderPanel { name="propertyMappings" .errorMessages=${errors?.propertyMappings ?? []} > - +

${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,