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 (<anonymous>)
    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 <select> to show propertyMappings have been replaced with an invocation to a
variant of dual select that allows for dynamic production of the "selected" list.  Instead of giving
a "selected" list of elements, a "selector" function is passed that can, given the elements listed
by the provider, generated the "selected" list dynamically.

This feature is required for propertyMappings because many of the propertyMappings have an alternative
"default selected" feature whereby an object with no property mappings is automatically granted some
by the `.managed` field of the property mapping.  The `DualSelectPair` type is now tragically
mis-named, as it it's now a 4-tuple, the fourth being whatever object or field is necessary to
figure out what the default value might be.  For example, the Oauth2PropertyMappingsSelector looks
like this:

```
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<ScopeMapping>) =>
              scope?.managed?.startsWith("goauthentik.io/providers/oauth2/scope-") &&
              scope?.managed !== "goauthentik.io/providers/oauth2/scope-offline_access";
}
```

If there are instanceMappings, we create a Set of them and just look up the pk for "is this
selected" as we generate the component.

If there is not, we look at the `scope` object itself (Oauth2PropertyMappings were called "scopes"
in the original source) and perform a token analysis.

It works well, is reasonably fast, and reasonably memory-friendly.

In the case of RAC, OAuth2, and ProxyProviders, I've also provided external definitions of the
MappingProvider and MappingSelector, so that they can be shared between the Provider and the
ApplicationWizard.

The algorithm for finding the "alternative (default) selections" was *different* between the two
instances of both Oauth and Proxy. I'm not marking this as "ready" until Jens (@BeryJu) and I can go
over why that might have been so, and decide if using a common implementation for both is the
correct thing to do.

Also, a lot of this is (still) cut-and-paste; the dual-select invocation, and the definitions of
Providers and Selectors have a bit of boilerplate that it just didn't make sense to try and abstract
away; the code is DAMP (Descriptive and Meaningful Phrases), and I can live with it.  Unfortunately,
that also points to the possibility of something being off; the wrong default token, or the wrong
phrase to describe the "Available" and "Selected" columns.  So this is not (yet) ready for a full
pull review.

On the other hand, if this passes muster and we're happy with it, there are 11 more places to put
DualSelect, four of which are pure cut-and-paste lookups of the PaginatedOauthSourceList, plus a
miscellany of Prompts, Sources, Stages, Roles, EventTransports and Policies.

Despite the churn, the difference between the two implementations is 438 lines removed, 231 lines
added, 121 lines new.  86 LOC deleted.  Could be better.  :-)

* web: make the ...Selector semantics uniform across the definition set.

* web: fix proxy property mapping default criteria

* web: restoring dropped message to user.

* Ensuring the neccessary components are imported.

* web: fix problem with 'selector' overselecting

The 'selector' feature was overselecting, preventing items from
being removed from the "selected" list if they were part of the
host object.  This has the shortcoming that `default` items *must*
be in the first page of options from the server, or they probably
won't be registered.  Fortunately, that's currently the case.
This commit is contained in:
Ken Sternberg
2024-07-15 09:49:03 -07:00
committed by GitHub
parent 303ba13791
commit 259537ee34
18 changed files with 389 additions and 433 deletions

View File

@ -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"
}

View File

@ -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);

View File

@ -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 ?? []}
>
<select class="pf-c-form-control" multiple>
${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`<option
value=${ifDefined(scope.pk)}
?selected=${selected}
>
${scope.name}
</option>`;
})}
</select>
<ak-dual-select-dynamic-selected
.provider=${oauth2PropertyMappingsProvider}
.selector=${makeOAuth2PropertyMappingsSelector(
provider?.propertyMappings,
)}
available-label=${msg("Available Scopes")}
selected-label=${msg("Selected Scopes")}
></ak-dual-select-dynamic-selected>
<p class="pf-c-form__helper-text">
${msg(
"Select which scopes can be used by the client. The client still has to specify the scope to access the data.",
)}
</p>
<p class="pf-c-form__helper-text">
${msg("Hold control/command to select multiple items.")}
</p>
</ak-form-element-horizontal>
<ak-radio-input

View File

@ -1,10 +1,15 @@
import "@goauthentik/admin/applications/wizard/ak-wizard-title";
import {
makeProxyPropertyMappingsSelector,
proxyPropertyMappingsProvider,
} from "@goauthentik/admin/providers/proxy/ProxyProviderPropertyMappings.js";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { first } from "@goauthentik/common/utils";
import "@goauthentik/components/ak-switch-input";
import "@goauthentik/components/ak-text-input";
import "@goauthentik/components/ak-textarea-input";
import "@goauthentik/components/ak-toggle-group";
import "@goauthentik/elements/ak-dual-select/ak-dual-select-dynamic-selected-provider.js";
import "@goauthentik/elements/forms/HorizontalFormElement";
import { msg } from "@lit/localize";
@ -16,7 +21,6 @@ import {
FlowsInstancesListDesignationEnum,
PaginatedOAuthSourceList,
PaginatedScopeMappingList,
PropertymappingsApi,
ProxyMode,
ProxyProvider,
SourcesApi,
@ -29,12 +33,6 @@ type MaybeTemplateResult = TemplateResult | typeof nothing;
export class AkTypeProxyApplicationWizardPage extends BaseProviderPanel {
constructor() {
super();
new PropertymappingsApi(DEFAULT_CONFIG)
.propertymappingsScopeList({ ordering: "scope_name" })
.then((propertyMappings: PaginatedScopeMappingList) => {
this.propertyMappings = propertyMappings;
});
new SourcesApi(DEFAULT_CONFIG)
.sourcesOauthList({
ordering: "name",
@ -88,29 +86,8 @@ export class AkTypeProxyApplicationWizardPage extends BaseProviderPanel {
</ak-text-input>`;
}
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` <ak-wizard-title>${msg("Configure Proxy Provider")}</ak-wizard-title>
<form class="pf-c-form pf-m-horizontal" @input=${this.handleChange}>
@ -179,24 +156,22 @@ export class AkTypeProxyApplicationWizardPage extends BaseProviderPanel {
certificate=${ifDefined(this.instance?.certificate ?? undefined)}
></ak-crypto-certificate-search>
</ak-form-element-horizontal>
<ak-multi-select
label=${msg("AdditionalScopes")}
<ak-form-element-horizontal
label=${msg("Additional scopes")}
name="propertyMappings"
.options=${scopePairs}
.values=${scopeValues}
.errorMessages=${errors?.propertyMappings ?? []}
.richhelp=${html`
<p class="pf-c-form__helper-text">
${msg(
"Additional scope mappings, which are passed to the proxy.",
)}
</p>
<p class="pf-c-form__helper-text">
${msg("Hold control/command to select multiple items.")}
</p>
`}
></ak-multi-select>
>
<ak-dual-select-dynamic-selected
.provider=${proxyPropertyMappingsProvider}
.selector=${makeProxyPropertyMappingsSelector(
this.instance?.propertyMappings,
)}
available-label="${msg("Available Scopes")}"
selected-label="${msg("Selected Scopes")}"
></ak-dual-select-dynamic-selected>
<p class="pf-c-form__helper-text">
${msg("Additional scope mappings, which are passed to the proxy.")}
</p>
</ak-form-element-horizontal>
<ak-textarea-input
name="skipPathRegex"

View File

@ -1,44 +1,28 @@
import "@goauthentik/admin/applications/wizard/ak-wizard-title";
import "@goauthentik/admin/common/ak-flow-search/ak-flow-search";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import {
makeRACPropertyMappingsSelector,
racPropertyMappingsProvider,
} from "@goauthentik/admin/providers/rac/RACPropertyMappings.js";
import "@goauthentik/components/ak-text-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 { msg } from "@lit/localize";
import { 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,
PaginatedRACPropertyMappingList,
PropertymappingsApi,
RACProvider,
} from "@goauthentik/api";
import { FlowsInstancesListDesignationEnum, RACProvider } from "@goauthentik/api";
import BaseProviderPanel from "../BaseProviderPanel";
@customElement("ak-application-wizard-authentication-for-rac")
export class ApplicationWizardAuthenticationByRAC extends BaseProviderPanel {
@state()
propertyMappings?: PaginatedRACPropertyMappingList;
constructor() {
super();
new PropertymappingsApi(DEFAULT_CONFIG)
.propertymappingsRacList({
ordering: "name",
})
.then((propertyMappings) => {
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`<ak-wizard-title
@ -85,17 +69,14 @@ export class ApplicationWizardAuthenticationByRAC extends BaseProviderPanel {
label=${msg("Property mappings")}
name="propertyMappings"
>
<select class="pf-c-form-control" multiple>
${this.propertyMappings?.results.map(
(mapping) =>
html`<option
value=${ifDefined(mapping.pk)}
?selected=${selected.has(mapping.pk)}
>
${mapping.name}
</option>`,
<ak-dual-select-dynamic-selected
.provider=${racPropertyMappingsProvider}
.selector=${makeRACPropertyMappingsSelector(
provider?.propertyMappings,
)}
</select>
available-label="${msg("Available Property Mappings")}"
selected-label="${msg("Selected Property Mappings")}"
></ak-dual-select-dynamic-selected>
<p class="pf-c-form__helper-text">
${msg("Hold control/command to select multiple items.")}
</p>

View File

@ -6,6 +6,7 @@ import { ascii_letters, digits, first, randomString } from "@goauthentik/common/
import "@goauthentik/components/ak-radio-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";
import "@goauthentik/elements/forms/Radio";
@ -23,13 +24,16 @@ import {
IssuerModeEnum,
OAuth2Provider,
PaginatedOAuthSourceList,
PaginatedScopeMappingList,
PropertymappingsApi,
ProvidersApi,
SourcesApi,
SubModeEnum,
} from "@goauthentik/api";
import {
makeOAuth2PropertyMappingsSelector,
oauth2PropertyMappingsProvider,
} from "./Oauth2PropertyMappings.js";
export const clientTypeOptions = [
{
label: msg("Confidential"),
@ -123,7 +127,6 @@ export const redirectUriHelp = html`${redirectUriHelpMessages.map(
@customElement("ak-provider-oauth2-form")
export class OAuth2ProviderFormPage extends BaseProviderForm<OAuth2Provider> {
propertyMappings?: PaginatedScopeMappingList;
oauthSources?: PaginatedOAuthSourceList;
@state()
@ -138,11 +141,6 @@ export class OAuth2ProviderFormPage extends BaseProviderForm<OAuth2Provider> {
}
async load(): Promise<void> {
this.propertyMappings = await new PropertymappingsApi(
DEFAULT_CONFIG,
).propertymappingsScopeList({
ordering: "scope_name",
});
this.oauthSources = await new SourcesApi(DEFAULT_CONFIG).sourcesOauthList({
ordering: "name",
hasJwks: true,
@ -291,34 +289,20 @@ export class OAuth2ProviderFormPage extends BaseProviderForm<OAuth2Provider> {
>
</ak-text-input>
<ak-form-element-horizontal label=${msg("Scopes")} name="propertyMappings">
<select class="pf-c-form-control" multiple>
${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`<option
value=${ifDefined(scope.pk)}
?selected=${selected}
>
${scope.name}
</option>`;
})}
</select>
<ak-dual-select-dynamic-selected
.provider=${oauth2PropertyMappingsProvider}
.selector=${makeOAuth2PropertyMappingsSelector(
provider?.propertyMappings,
)}
available-label=${msg("Available Scopes")}
selected-label=${msg("Selected Scopes")}
></ak-dual-select-dynamic-selected>
<p class="pf-c-form__helper-text">
${msg(
"Select which scopes can be used by the client. The client still has to specify the scope to access the data.",
)}
</p>
<p class="pf-c-form__helper-text">
${msg("Hold control/command to select multiple items.")}
</p>
</ak-form-element-horizontal>
<ak-radio-input
name="subMode"

View File

@ -0,0 +1,28 @@
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { DualSelectPair } from "@goauthentik/elements/ak-dual-select/types.js";
import { PropertymappingsApi, ScopeMapping } from "@goauthentik/api";
export async function oauth2PropertyMappingsProvider(page = 1, search = "") {
const propertyMappings = await new PropertymappingsApi(
DEFAULT_CONFIG,
).propertymappingsScopeList({
ordering: "scope_name",
pageSize: 20,
search: search.trim(),
page,
});
return {
pagination: propertyMappings.pagination,
options: propertyMappings.results.map((scope) => [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<ScopeMapping>) =>
scope?.managed?.startsWith("goauthentik.io/providers/oauth2/scope-") &&
scope?.managed !== "goauthentik.io/providers/oauth2/scope-offline_access";
}

View File

@ -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<ProxyProvider> {
static get styles(): CSSResult[] {
@ -45,18 +49,12 @@ export class ProxyProviderFormPage extends BaseProviderForm<ProxyProvider> {
}
async load(): Promise<void> {
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<ProxyProvider> {
label=${msg("Additional scopes")}
name="propertyMappings"
>
<select class="pf-c-form-control" multiple>
${this.propertyMappings?.results
.filter((scope) => {
return !scope.managed?.startsWith("goauthentik.io/providers");
})
.map((scope) => {
const selected = (this.instance?.propertyMappings || []).some(
(su) => {
return su == scope.pk;
},
);
return html`<option
value=${ifDefined(scope.pk)}
?selected=${selected}
>
${scope.name}
</option>`;
})}
</select>
<ak-dual-select-dynamic-selected
.provider=${proxyPropertyMappingsProvider}
.selector=${makeProxyPropertyMappingsSelector(
this.instance?.propertyMappings,
)}
available-label="${msg("Available Scopes")}"
selected-label="${msg("Selected Scopes")}"
></ak-dual-select-dynamic-selected>
<p class="pf-c-form__helper-text">
${msg("Additional scope mappings, which are passed to the proxy.")}
</p>
<p class="pf-c-form__helper-text">
${msg("Hold control/command to select multiple items.")}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal

View File

@ -0,0 +1,27 @@
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { DualSelectPair } from "@goauthentik/elements/ak-dual-select/types.js";
import { PropertymappingsApi, ScopeMapping } from "@goauthentik/api";
export async function proxyPropertyMappingsProvider(page = 1, search = "") {
const propertyMappings = await new PropertymappingsApi(
DEFAULT_CONFIG,
).propertymappingsScopeList({
ordering: "scope_name",
pageSize: 20,
search: search.trim(),
page,
});
return {
pagination: propertyMappings.pagination,
options: propertyMappings.results.map((scope) => [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<ScopeMapping>) =>
!(scope?.managed ?? "").startsWith("goauthentik.io/providers");
}

View File

@ -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<Endpoint, string> {
@property({ type: Number })
providerID?: number;
propertyMappings?: PaginatedRACPropertyMappingList;
async load(): Promise<void> {
this.propertyMappings = await new PropertymappingsApi(
DEFAULT_CONFIG,
).propertymappingsRacList({
ordering: "name",
});
}
loadInstance(pk: string): Promise<Endpoint> {
return new RacApi(DEFAULT_CONFIG).racEndpointsRetrieve({
pbmUuid: pk,
@ -124,22 +113,12 @@ export class EndpointForm extends ModelForm<Endpoint, string> {
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal label=${msg("Property mappings")} name="propertyMappings">
<select class="pf-c-form-control" multiple>
${this.propertyMappings?.results.map((mapping) => {
let selected = false;
if (this.instance?.propertyMappings) {
selected = Array.from(this.instance?.propertyMappings).some((su) => {
return su == mapping.pk;
});
}
return html`<option value=${ifDefined(mapping.pk)} ?selected=${selected}>
${mapping.name}
</option>`;
})}
</select>
<p class="pf-c-form__helper-text">
${msg("Hold control/command to select multiple items.")}
</p>
<ak-dual-select-dynamic-selected
.provider=${racPropertyMappingsProvider}
.selector=${makeRACPropertyMappingsSelector(this.instance?.propertyMappings)}
available-label="${msg("Available User Property Mappings")}"
selected-label="${msg("Selected User Property Mappings")}"
></ak-dual-select-dynamic-selected>
</ak-form-element-horizontal>
<ak-form-group>
<span slot="header"> ${msg("Advanced settings")} </span>

View File

@ -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);
}

View File

@ -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<RACProvider, number> {
@state()
propertyMappings?: PaginatedRACPropertyMappingList;
async load(): Promise<void> {
this.propertyMappings = await new PropertymappingsApi(
DEFAULT_CONFIG,
).propertymappingsRacList({
ordering: "name",
});
}
async loadInstance(pk: number): Promise<RACProvider> {
return new ProvidersApi(DEFAULT_CONFIG).providersRacRetrieve({
id: pk,
@ -137,27 +126,14 @@ export class RACProviderFormPage extends ModelForm<RACProvider, number> {
label=${msg("Property mappings")}
name="propertyMappings"
>
<select class="pf-c-form-control" multiple>
${this.propertyMappings?.results.map((mapping) => {
let selected = false;
if (this.instance?.propertyMappings) {
selected = Array.from(this.instance?.propertyMappings).some(
(su) => {
return su == mapping.pk;
},
);
}
return html`<option
value=${ifDefined(mapping.pk)}
?selected=${selected}
>
${mapping.name}
</option>`;
})}
</select>
<p class="pf-c-form__helper-text">
${msg("Hold control/command to select multiple items.")}
</p>
<ak-dual-select-dynamic-selected
.provider=${racPropertyMappingsProvider}
.selector=${makeRACPropertyMappingsSelector(
this.instance?.propertyMappings,
)}
available-label="${msg("Available Property Mappings")}"
selected-label="${msg("Selected Property Mappings")}"
></ak-dual-select-dynamic-selected>
</ak-form-element-horizontal>
<ak-form-element-horizontal label=${msg("Settings")} name="settings">
<ak-codemirror

View File

@ -2,6 +2,8 @@ import "@goauthentik/admin/common/ak-crypto-certificate-search";
import "@goauthentik/admin/common/ak-flow-search/ak-flow-search";
import { BaseProviderForm } from "@goauthentik/admin/providers/BaseProviderForm";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
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";
@ -16,7 +18,6 @@ import { ifDefined } from "lit/directives/if-defined.js";
import {
DigestAlgorithmEnum,
FlowsInstancesListDesignationEnum,
PaginatedSAMLPropertyMappingList,
PropertymappingsApi,
PropertymappingsSamlListRequest,
ProvidersApi,
@ -26,6 +27,29 @@ import {
SpBindingEnum,
} from "@goauthentik/api";
export async function samlPropertyMappingsProvider(page = 1, search = "") {
const propertyMappings = await new PropertymappingsApi(DEFAULT_CONFIG).propertymappingsSamlList(
{
ordering: "saml_name",
pageSize: 20,
search: search.trim(),
page,
},
);
return {
pagination: propertyMappings.pagination,
options: propertyMappings.results.map((m) => [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<SAMLPropertyMapping>) =>
mapping?.managed?.startsWith("goauthentik.io/providers/saml");
}
@customElement("ak-provider-saml-form")
export class SAMLProviderFormPage extends BaseProviderForm<SAMLProvider> {
loadInstance(pk: number): Promise<SAMLProvider> {
@ -34,16 +58,6 @@ export class SAMLProviderFormPage extends BaseProviderForm<SAMLProvider> {
});
}
async load(): Promise<void> {
this.propertyMappings = await new PropertymappingsApi(
DEFAULT_CONFIG,
).propertymappingsSamlList({
ordering: "saml_name",
});
}
propertyMappings?: PaginatedSAMLPropertyMappingList;
async send(data: SAMLProvider): Promise<SAMLProvider> {
if (this.instance) {
return new ProvidersApi(DEFAULT_CONFIG).providersSamlUpdate({
@ -193,29 +207,14 @@ export class SAMLProviderFormPage extends BaseProviderForm<SAMLProvider> {
label=${msg("Property mappings")}
name="propertyMappings"
>
<select class="pf-c-form-control" multiple>
${this.propertyMappings?.results.map((mapping) => {
let selected = false;
if (!this.instance?.propertyMappings) {
selected =
mapping.managed?.startsWith(
"goauthentik.io/providers/saml",
) || false;
} else {
selected = Array.from(this.instance?.propertyMappings).some(
(su) => {
return su == mapping.pk;
},
);
}
return html`<option
value=${ifDefined(mapping.pk)}
?selected=${selected}
>
${mapping.name}
</option>`;
})}
</select>
<ak-dual-select-dynamic-selected
.provider=${samlPropertyMappingsProvider}
.selector=${makeSAMLPropertyMappingsSelector(
this.instance?.propertyMappings,
)}
available-label=${msg("Available User Property Mappings")}
selected-label=${msg("Selected User Property Mappings")}
></ak-dual-select-dynamic-selected>
<p class="pf-c-form__helper-text">
${msg("Hold control/command to select multiple items.")}
</p>

View File

@ -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<SCIMMapping>) =>
mapping?.managed === "goauthentik.io/providers/scim/user";
}
@customElement("ak-provider-scim-form")
export class SCIMProviderFormPage extends BaseProviderForm<SCIMProvider> {
loadInstance(pk: number): Promise<SCIMProvider> {
@ -29,16 +54,6 @@ export class SCIMProviderFormPage extends BaseProviderForm<SCIMProvider> {
});
}
async load(): Promise<void> {
this.propertyMappings = await new PropertymappingsApi(
DEFAULT_CONFIG,
).propertymappingsScimList({
ordering: "managed",
});
}
propertyMappings?: PaginatedSCIMMappingList;
async send(data: SCIMProvider): Promise<SCIMProvider> {
if (this.instance) {
return new ProvidersApi(DEFAULT_CONFIG).providersScimUpdate({
@ -152,68 +167,34 @@ export class SCIMProviderFormPage extends BaseProviderForm<SCIMProvider> {
<div slot="body" class="pf-c-form">
<ak-form-element-horizontal
label=${msg("User Property Mappings")}
name="propertyMappings"
>
<select class="pf-c-form-control" multiple>
${this.propertyMappings?.results.map((mapping) => {
let selected = false;
if (!this.instance?.propertyMappings) {
selected =
mapping.managed === "goauthentik.io/providers/scim/user" ||
false;
} else {
selected = Array.from(this.instance?.propertyMappings).some(
(su) => {
return su == mapping.pk;
},
);
}
return html`<option
value=${ifDefined(mapping.pk)}
?selected=${selected}
>
${mapping.name}
</option>`;
})}
name="propertyMappings">
<ak-dual-select-dynamic-selected
.provider=${scimPropertyMappingsProvider}
.selector=${makeSCIMPropertyMappingsSelector(
this.instance?.propertyMappings,
)}
available-label=${msg("Available User Property Mappings")}
selected-label=${msg("Selected User Property Mappings")}
></ak-dual-select-dynamic-selected>
</select>
<p class="pf-c-form__helper-text">
${msg("Property mappings used to user mapping.")}
</p>
<p class="pf-c-form__helper-text">
${msg("Hold control/command to select multiple items.")}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${msg("Group Property Mappings")}
name="propertyMappingsGroup"
>
<select class="pf-c-form-control" multiple>
${this.propertyMappings?.results.map((mapping) => {
let selected = false;
if (!this.instance?.propertyMappingsGroup) {
selected =
mapping.managed === "goauthentik.io/providers/scim/group";
} else {
selected = Array.from(
this.instance?.propertyMappingsGroup,
).some((su) => {
return su == mapping.pk;
});
}
return html`<option
value=${ifDefined(mapping.pk)}
?selected=${selected}
>
${mapping.name}
</option>`;
})}
</select>
name="propertyMappingsGroup">
<ak-dual-select-dynamic-selected
.provider=${scimPropertyMappingsProvider}
.selector=${makeSCIMPropertyMappingsSelector(
this.instance?.propertyMappingsGroup,
)}
available-label=${msg("Available Group Property Mappings")}
selected-label=${msg("Selected Group Property Mappings")}
></ak-dual-select-dynamic-selected>
<p class="pf-c-form__helper-text">
${msg("Property mappings used to group creation.")}
</p>
<p class="pf-c-form__helper-text">
${msg("Hold control/command to select multiple items.")}
</p>
</ak-form-element-horizontal>
</div>
</ak-form-group>`;

View File

@ -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<LDAPPropertyMapping>) =>
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<LDAPSource> {
loadInstance(pk: string): Promise<LDAPSource> {
@ -31,16 +57,6 @@ export class LDAPSourceForm extends BaseSourceForm<LDAPSource> {
});
}
async load(): Promise<void> {
this.propertyMappings = await new PropertymappingsApi(
DEFAULT_CONFIG,
).propertymappingsLdapList({
ordering: "managed,object_field",
});
}
propertyMappings?: PaginatedLDAPPropertyMappingList;
async send(data: LDAPSource): Promise<LDAPSource> {
if (this.instance) {
return new SourcesApi(DEFAULT_CONFIG).sourcesLdapPartialUpdate({
@ -277,71 +293,32 @@ export class LDAPSourceForm extends BaseSourceForm<LDAPSource> {
label=${msg("User Property Mappings")}
name="propertyMappings"
>
<select class="pf-c-form-control" multiple>
${this.propertyMappings?.results.map((mapping) => {
let selected = false;
if (!this.instance?.propertyMappings) {
selected =
mapping.managed?.startsWith(
"goauthentik.io/sources/ldap/default",
) ||
mapping.managed?.startsWith(
"goauthentik.io/sources/ldap/ms",
) ||
false;
} else {
selected = Array.from(this.instance?.propertyMappings).some(
(su) => {
return su == mapping.pk;
},
);
}
return html`<option
value=${ifDefined(mapping.pk)}
?selected=${selected}
>
${mapping.name}
</option>`;
})}
</select>
<ak-dual-select-dynamic-selected
.provider=${propertyMappingsProvider}
.selector=${makePropertyMappingsSelector(
this.instance?.propertyMappings,
)}
available-label="${msg("Available User Property Mappings")}"
selected-label="${msg("Selected User Property Mappings")}"
></ak-dual-select-dynamic-selected>
<p class="pf-c-form__helper-text">
${msg("Property mappings used to user creation.")}
</p>
<p class="pf-c-form__helper-text">
${msg("Hold control/command to select multiple items.")}
${msg("Property mappings for user creation.")}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${msg("Group Property Mappings")}
name="propertyMappingsGroup"
>
<select class="pf-c-form-control" multiple>
${this.propertyMappings?.results.map((mapping) => {
let selected = false;
if (!this.instance?.propertyMappingsGroup) {
selected =
mapping.managed ===
"goauthentik.io/sources/ldap/default-name";
} else {
selected = Array.from(
this.instance?.propertyMappingsGroup,
).some((su) => {
return su == mapping.pk;
});
}
return html`<option
value=${ifDefined(mapping.pk)}
?selected=${selected}
>
${mapping.name}
</option>`;
})}
</select>
<ak-dual-select-dynamic-selected
.provider=${propertyMappingsProvider}
.selector=${makePropertyMappingsSelector(
this.instance?.propertyMappingsGroup,
)}
available-label="${msg("Available Group Property Mappings")}"
selected-label="${msg("Selected Group Property Mappings")}"
></ak-dual-select-dynamic-selected>
<p class="pf-c-form__helper-text">
${msg("Property mappings used to group creation.")}
</p>
<p class="pf-c-form__helper-text">
${msg("Hold control/command to select multiple items.")}
${msg("Property mappings for group creation.")}
</p>
</ak-form-element-horizontal>
</div>

View File

@ -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<this>) {
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`<ak-dual-select
${ref(this.dualSelector)}
.options=${this.options}
.pages=${this.pagination}
.selected=${this.selected}
available-label=${this.availableLabel}
selected-label=${this.selectedLabel}
></ak-dual-select>`;
}
}

View File

@ -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<AkDualSelect> = createRef();
protected dualSelector: Ref<AkDualSelect> = createRef();
private isLoading = false;
protected isLoading = false;
private doneFirstUpdate = false;
private internalSelected: DualSelectPair[] = [];
private pagination?: Pagination;
protected pagination?: Pagination;
constructor() {
super();

View File

@ -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<T = never> = [string, string | TemplateResult, string?, T?];
export type BasePagination = Pick<
Pagination,