web/admin: add application bindings to the application wizard (#11462)

* web: fix Flash of Unstructured Content while SearchSelect is loading from the backend

Provide an alternative, readonly, disabled, unindexed input object with the text "Loading...", to be
replaced with the _real_ input element after the content is loaded.

This provides the correct appearance and spacing so the content doesn't jiggle about between the
start of loading and the SearchSelect element being finalized.  It was visually distracting and
unappealing.

* web: comment on state management in API layer, move file to point to correct component under test.

* web: test for flash of unstructured content

- Add a unit test to ensure the "Loading..." element is displayed correctly before data arrives
- Demo how to mock a `fetchObjects()` call in testing. Very cool.
- Make distinguishing rule sets for code, tests, and scripts in nightmare mode
- In SearchSelect, Move the `styles()` declaration to the top of the class for consistency.

- To test for the FLOUC issue in SearchSelect.

This is both an exercise in mocking @beryju's `fetchObjects()` protocol, and shows how we can unit
test generic components that render API objects.

* web: interim commit of the basic sortable & selectable table.

* web: added basic unit testing to API-free tables

Mostly these tests assert that the table renders and that the content we give it
is where we expect it to be after sorting. For select tables, it also asserts that
the overall value of the table is what we expect it to be when we click on a
single row, or on the "select all" button.

* web: finalize testing for tables

Includes documentation updates and better tests for select-table.

* Provide unit test accessibility to Firefox and Safari; wrap calls to manipulate test DOMs directly in a browser.exec call so they run in the proper context and be await()ed properly

* web: repeat is needed to make sure sub-elements move around correctly. Map does not do full tracking.

* web: Update HorizontalLightComponent to accurately convey its value "upwards."

* interim commit, gods, the CSS is finally working.

* web: update

Got the binding editor in.  The tests complete.  Removed sonarjs.

* web: fixed tests to complete.

* web: fixed round-trip between binding list and binding editor. Fixed 'delete'.  TODO: Fix error reporting on home page, the edit button is ugly, and the height is off somehow, but I'm not yet sure how. I just know it bugs my eyes.

* core: add support to set policy bindings in transactional endpoint

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* improve permission checks

especially since we'll be using the wizard as default in the future, it shouldn't be superuser only

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* web: update api-less tables

- Replace `th` with `td` in `thead` components. Because Patternfly.
- Add @beryju's styling to the tables, which make it much better looking

* web: wizard for applications, now with bindings!

- Add policy bindings to the application wizard

- Restructures the Wizard base code.
  - ak-wizard-steps holds the steps and listens for NavigationRequest events to move
    from one step to the next.
  - WizardStep is a base class (no component registration provided) that provides the *whole frame*,
    not just the form.  It receives the navigation content for the sidebar from ak-wizard-steps,
    and provides the styling for the header, footer, sidebar, and main form.  It has abstractions
    for `buttons`, `renderMain()`, `handleButton()`, `handleEnable()`, in a section well-marked as
    "Public API".  Steps inherit from this class.

Conceptually:

- A wizard is a series of pages ("steps") with a distinct beginning and end, linked in a series,
  to complete a task.
- Later steps in the series are inaccessible until an earlier steps has granted access to it.
- Access is predicated on the earlier step being complete and valid. The developer is responsible
  for determining what "complete and valid" means.
- The series is visible, giving the customer a sense of how much effort is needed to complete the
  task.
- A parent object maintains (and can modify as needed) the list of steps. It *can* maintain the
  information being collected from the user. Alternatively, that information can be kept in each
  step.

Details:

- Keeping with the Lit paradigm, "requests to change the system flow up, information changed by
  valid requests flows down."
- The information flows up using events: WizardNavigation, WizardUpdate, WizardClose.
- The information flows down using properties.

- ak-application-wizard-main holds the list of steps, providing a unique slot name for each.
  - It maintains the ApplicationWizardState object.
- ApplicationWizardStep inherits from WizardStep and provides:
  - A means of extraction information from forms
  - A convenience method for updating the ApplicationWizardState object, enabling future steps, and
    navigating to a future step, in the correct order.
  - A method for cleaning error from the error reporting mechanism as the user navigates from an
    error-handling state.
  - The title, description, and cancelability of the wizard.
- Steps:
  - step: Handles the application. A good starting point for understanding the point of
    the Wizard.  Check the `handleButton()` method to understand how we enable or disable access to
    future steps.
  - provider-choice: Just a list. Shows validation without the form.
  - provider: Uses a *very* esoteric Lit feature, `unsafeStaticTag`, which enables
    the display to show anything that conforms to the expectations of ApplicationWizardProviderForm.
    - ApplicationWizardProviderForm repeats some of the base of ApplicationWizardStep, but allows us
      to provide multiple variants on a single form without having to create separate steps for each
      form.
    - The forms (`provider-for-ldap`, `provider-for-radius`) are therefore *just* the form and any
      fetchers needed to populate it.
  - bindings: Shows the table of bindings.  Has a custom display for "This table is empty."
  - edit-binding: Showcase for the `SearchSelectEZ` configuration format. Has an override on the
    `handleButton` feature to figure out which binding is about to be overridden. Is also a
    `.hidden` page; it doesn't show up on the navigation sidebar, as is only navigable-to by buttons
    not associated with the button bar at the bottom.
  - submit: Has a lot of machinery of state: Reviewing with errors, reviewing without errors,
    running submission, and success. Uses `ts-pattern` a lot to make sure the state/request pairs
    make sense.

The key insight is that, even though a wizard is a series in order, that order can't be simply
maintained in a list. The parent needs various strategies for swapping pages in and out of the
sequence, while still maintaining a coherent idea of "flow" and providing the visual cues the user
needs to feel confident that the work can be completed and completed quickly. The entire mechanism
for using an array and index to navigate, with index numbering, blocked the implementation of the
bindings pages.

One thing led to another.  *Sigh*  Really wish this hadn't been as much of a mess as it turned out.
The end result is pretty good, though.  Definitely re-usable.

One important feature to note is that the wizard is *not* tied to the ModalButton object; it's
simply embedded in a modal as-needed.  This allows us to use wizards in other places, such as just
being in a DIV, or just a page on its own.

* web: rollback dependabot "upgrade" that broke testing

Dependabot rolled us into WebdriverIO 9.  While that's probably the
right thing to do, right now it breaks out end-to-end tests badly.
Dependabot's mucking with infrastructure should not be taken lightly,
especially in cases when the infrastructure is for DX, not UX, and
doesn't create a bigger attack surface on the running product.

* web: small fixes for wdio and lint

- Roll back another dependabot breaking change, this time to WebdriverIO
- Remove the redundant scripts wrapping ESLint for Precommit mode. Access to those modes is
  available through the flags to the `./web/scripts/eslint.mjs` script.
- Remove SonarJS checks until SonarJS is ESLint 9 compatible.
- Minor nitpicking.

* web: not sure where all these getElement() additions come from; did I add them?  Anyway, they were breaking the tests, they're a Wdio9-ism.

* package-lock.json update

* web: small fixes for wdio and lint

**PLEASE** Stop trying to upgrade WebdriverIO following Dependabot's instructions. The changes
between wdio8 and wdio9 are extensive enough to require a lot more manual intervention. The unit
tests fail in wdio 9, with the testbed driver Wdio uses to compile content to push to the browser
([vite](https://vitejs.dev) complaining:

```
2024-09-27T15:30:03.672Z WARN @wdio/browser-runner:vite: warning: Unrecognized default export in file /Users/ken/projects/dev/web/node_modules/@patternfly/patternfly/components/Dropdown/dropdown.css
  Plugin: postcss-lit
  File: /Users/ken/projects/dev/web/node_modules/@patternfly/patternfly/components/Dropdown/dropdown.css
[0-6] 2024-09-27T15:30:04.083Z INFO webdriver: BIDI COMMAND script.callFunction {"functionDeclaration":"<Function[976 bytes]>","awaitPromise":true,"arguments":[],"target":{"context":"8E608E6D13E355DFFC28112C236B73AF"}}
[0-6]  Error:  Test failed due to following error(s):
  - ak-search-select.test.ts: The requested module '/src/common/styles/authentik.css' does not provide an export named 'default': SyntaxError: The requested module '/src/common/styles/authentik.css' does not provide an export named 'default'

```

So until we can figure out why the Vite installation isn't liking our CSS import scheme, we'll
have to soldier on with what we have.  At least with Wdio 8, we get:

```
Spec Files:      7 passed, 7 total (100% completed) in 00:00:19
```

* Forgot to run prettier.

* web: small fixes for elements and forms

- provides a new utility, `_isSlug_`, used to verify a user input
- extends the ak-horizontal-component wrapper to have a stronger identity and available value
- updates the types that use the wrapper to be typed more strongly
  - (Why) The above are used in the wizard to get and store values
- fixes a bug in SearchSelectEZ that broke the display if the user didn't supply a `groupBy` field.
- Adds `@wdio/types` to the package file so eslint is satisfied wdio builds correctly
- updates the end-to-end test to understand the revised button identities on the login page
  - Running the end-to-end tests verifies that changes to the components listed above did not break
    the semantics of those components.

* Prettier had opinions

* Fix the oauth2 provider test.

* web: fix oauth2 provider.  Fix resolutions in package-lock.json

* Provide an error field for the form errors on the OAuth2 form.  Unfortunately, this does not solve the general problem that we have a UX issue with which stage bindings to show where now that we've introduced the Invalidation Stage.

* web: Add InvalidationFlow to Radius Provider dialogues

## What

- Bugfix: adds the InvalidationFlow to the Radius Provider dialogues
  - Repairs: `{"invalidation_flow":["This field is required."]}` message, which was *not* propagated
    to the Notification.
- Nitpick: Pretties `?foo=${true}` expressions: `s/\?([^=]+)=\$\{true\}/\1/`

## Note

Yes, I know I'm going to have to do more magic when we harmonize the forms, and no, I didn't add the
Property Mappings to the wizard, and yes, I know I'm going to have pain with the *new* version of
the wizard. But this is a serious bug; you can't make Radius servers with *either* of the current
dialogues at the moment.

* web/admin: provide default invalidation flows for LDAP provider.

* admin/web: the default invalidation flows for LDAP and Radius are different from the others.

* Updating the SAML Wizard page to correspond to the provider page.  *This is an intermediate fix to get the tests passing. It will probably be mooted with the next revision.*

* Making progress...

* web/admin: provider formectomy complete

* fix minor issues

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* custom ordering for provider types

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix css

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix missing PFBase causing wrong font

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix missing card for type select

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix padding on last page

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add card to bindings

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* web/element/wizard: fix the CSS cascade so the modifications to the title display don't affect the wiard header.

* web/elements/wizard: fix logic on unavailable / available / current indicators in nav bar.

* Debugging code is not needed.

* web: small visual fixes

As requested by reviewers:

- Fixed the height to 75% of the viewport
- Put 1rem of whitespace between the hint label and the Wizard startup button.

* web: disable lint check for cAsEfUnNy AtTrIbUtE nAmEs.

* Apply suggestions from code review

Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
Signed-off-by: Jens L. <jens@beryju.org>

* rework title

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* format

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Signed-off-by: Jens L. <jens@beryju.org>
Co-authored-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
This commit is contained in:
Ken Sternberg
2024-12-18 09:44:27 -08:00
committed by GitHub
parent 821f06ffdf
commit c528a6c336
99 changed files with 6300 additions and 5950 deletions

View File

@ -0,0 +1,52 @@
import "@goauthentik/admin/common/ak-license-notice";
import { TemplateResult, html } from "lit";
import type { TypeCreate } from "@goauthentik/api";
type ProviderRenderer = () => TemplateResult;
export type LocalTypeCreate = TypeCreate & {
renderer: ProviderRenderer;
};
export const providerTypeRenderers: Record<
string,
{ render: () => TemplateResult; order: number }
> = {
oauth2provider: {
render: () =>
html`<ak-application-wizard-authentication-by-oauth></ak-application-wizard-authentication-by-oauth>`,
order: 90,
},
ldapprovider: {
render: () =>
html`<ak-application-wizard-authentication-by-ldap></ak-application-wizard-authentication-by-ldap>`,
order: 70,
},
proxyprovider: {
render: () =>
html`<ak-application-wizard-authentication-for-reverse-proxy></ak-application-wizard-authentication-for-reverse-proxy>`,
order: 75,
},
racprovider: {
render: () =>
html`<ak-application-wizard-authentication-for-rac></ak-application-wizard-authentication-for-rac>`,
order: 80,
},
samlprovider: {
render: () =>
html`<ak-application-wizard-authentication-by-saml-configuration></ak-application-wizard-authentication-by-saml-configuration>`,
order: 80,
},
radiusprovider: {
render: () =>
html`<ak-application-wizard-authentication-by-radius></ak-application-wizard-authentication-by-radius>`,
order: 70,
},
scimprovider: {
render: () =>
html`<ak-application-wizard-authentication-by-scim></ak-application-wizard-authentication-by-scim>`,
order: 60,
},
};

View File

@ -0,0 +1,152 @@
import {
type DescriptionPair,
renderDescriptionList,
} from "@goauthentik/components/DescriptionList.js";
import { match } from "ts-pattern";
import { msg } from "@lit/localize";
import { html } from "lit";
import {
ClientTypeEnum,
LDAPProvider,
MatchingModeEnum,
OAuth2Provider,
ProviderModelEnum,
ProxyMode,
ProxyProvider,
RACProvider,
RadiusProvider,
RedirectURI,
SAMLProvider,
SCIMProvider,
} from "@goauthentik/api";
import { OneOfProvider } from "../types.js";
const renderSummary = (type: string, name: string, fields: DescriptionPair[]) =>
renderDescriptionList([[msg("Type"), type], [msg("Name"), name], ...fields], {
threecolumn: true,
});
function renderSAMLOverview(rawProvider: OneOfProvider) {
const provider = rawProvider as SAMLProvider;
return renderSummary("SAML", provider.name, [
[msg("ACS URL"), provider.acsUrl],
[msg("Audience"), provider.audience || "-"],
[msg("Issuer"), provider.issuer],
]);
}
function renderSCIMOverview(rawProvider: OneOfProvider) {
const provider = rawProvider as SCIMProvider;
return renderSummary("SCIM", provider.name, [[msg("URL"), provider.url]]);
}
function renderRadiusOverview(rawProvider: OneOfProvider) {
const provider = rawProvider as RadiusProvider;
return renderSummary("Radius", provider.name, [
[msg("Client Networks"), provider.clientNetworks],
]);
}
function renderRACOverview(rawProvider: OneOfProvider) {
// @ts-expect-error TS6133
const _provider = rawProvider as RACProvider;
}
function formatRedirectUris(uris: RedirectURI[] = []) {
return uris.length > 0
? html`<ul class="pf-c-list pf-m-plain">
${uris.map(
(uri) =>
html`<li>
${uri.url}
(${uri.matchingMode === MatchingModeEnum.Strict
? msg("strict")
: msg("regexp")})
</li>`,
)}
</ul>`
: "-";
}
const proxyModeToLabel = new Map([
[ProxyMode.Proxy, msg("Proxy")],
[ProxyMode.ForwardSingle, msg("Forward auth (single application)")],
[ProxyMode.ForwardDomain, msg("Forward auth (domain-level)")],
[ProxyMode.UnknownDefaultOpenApi, msg("Unknown proxy mode")],
]);
function renderProxyOverview(rawProvider: OneOfProvider) {
const provider = rawProvider as ProxyProvider;
return renderSummary("Proxy", provider.name, [
[msg("Mode"), proxyModeToLabel.get(provider.mode ?? ProxyMode.Proxy)],
...match(provider.mode)
.with(
ProxyMode.Proxy,
() =>
[
[msg("Internal Host"), provider.internalHost],
[msg("External Host"), provider.externalHost],
] as DescriptionPair[],
)
.with(
ProxyMode.ForwardSingle,
() => [[msg("External Host"), provider.externalHost]] as DescriptionPair[],
)
.with(
ProxyMode.ForwardDomain,
() =>
[
[msg("Authentication URL"), provider.externalHost],
[msg("Cookie domain"), provider.cookieDomain],
] as DescriptionPair[],
)
.otherwise(() => {
throw new Error(
`Unrecognized proxy mode: ${provider.mode?.toString() ?? "-- undefined __"}`,
);
}),
[
msg("Basic-Auth"),
html` <ak-status-label
type="info"
?good=${provider.basicAuthEnabled}
></ak-status-label>`,
],
]);
}
const clientTypeToLabel = new Map<ClientTypeEnum, string>([
[ClientTypeEnum.Confidential, msg("Confidential")],
[ClientTypeEnum.Public, msg("Public")],
[ClientTypeEnum.UnknownDefaultOpenApi, msg("Unknown type")],
]);
function renderOAuth2Overview(rawProvider: OneOfProvider) {
const provider = rawProvider as OAuth2Provider;
return renderSummary("OAuth2", provider.name, [
[msg("Client type"), provider.clientType ? clientTypeToLabel.get(provider.clientType) : ""],
[msg("Client ID"), provider.clientId],
[msg("Redirect URIs"), formatRedirectUris(provider.redirectUris)],
]);
}
function renderLDAPOverview(rawProvider: OneOfProvider) {
const provider = rawProvider as LDAPProvider;
return renderSummary("Proxy", provider.name, [[msg("Base DN"), provider.baseDn]]);
}
const providerName = (p: ProviderModelEnum): string => p.toString().split(".")[1];
export const providerRenderers = new Map([
[providerName(ProviderModelEnum.SamlSamlprovider), renderSAMLOverview],
[providerName(ProviderModelEnum.ScimScimprovider), renderSCIMOverview],
[providerName(ProviderModelEnum.RadiusRadiusprovider), renderRadiusOverview],
[providerName(ProviderModelEnum.RacRacprovider), renderRACOverview],
[providerName(ProviderModelEnum.ProxyProxyprovider), renderProxyOverview],
[providerName(ProviderModelEnum.Oauth2Oauth2provider), renderOAuth2Overview],
[providerName(ProviderModelEnum.LdapLdapprovider), renderLDAPOverview],
]);

View File

@ -0,0 +1,192 @@
import { policyOptions } from "@goauthentik/admin/applications/PolicyOptions.js";
import { ApplicationWizardStep } from "@goauthentik/admin/applications/wizard/ApplicationWizardStep.js";
import "@goauthentik/admin/applications/wizard/ak-wizard-title.js";
import { isSlug } from "@goauthentik/common/utils.js";
import { camelToSnake } from "@goauthentik/common/utils.js";
import "@goauthentik/components/ak-radio-input";
import "@goauthentik/components/ak-slug-input";
import "@goauthentik/components/ak-switch-input";
import "@goauthentik/components/ak-text-input";
import { type NavigableButton, type WizardButton } from "@goauthentik/components/ak-wizard/types";
import { type KeyUnknown } from "@goauthentik/elements/forms/Form";
import "@goauthentik/elements/forms/FormGroup";
import "@goauthentik/elements/forms/HorizontalFormElement";
import { msg } from "@lit/localize";
import { html } from "lit";
import { customElement, query, state } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import { type ApplicationRequest } from "@goauthentik/api";
import { ApplicationWizardStateUpdate, ValidationRecord } from "../types";
const autoTrim = (v: unknown) => (typeof v === "string" ? v.trim() : v);
const trimMany = (o: KeyUnknown, vs: string[]) =>
Object.fromEntries(vs.map((v) => [v, autoTrim(o[v])]));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const isStr = (v: any): v is string => typeof v === "string";
@customElement("ak-application-wizard-application-step")
export class ApplicationWizardApplicationStep extends ApplicationWizardStep {
label = msg("Application");
@state()
errors = new Map<string, string>();
@query("form#applicationform")
form!: HTMLFormElement;
constructor() {
super();
// This is the first step. Ensure it is always enabled.
this.enabled = true;
}
errorMessages(name: string) {
return this.errors.has(name)
? [this.errors.get(name)]
: (this.wizard.errors?.app?.[name] ??
this.wizard.errors?.app?.[camelToSnake(name)] ??
[]);
}
get buttons(): WizardButton[] {
return [{ kind: "next", destination: "provider-choice" }, { kind: "cancel" }];
}
get valid() {
this.errors = new Map();
const values = trimMany(this.formValues ?? {}, ["metaLaunchUrl", "name", "slug"]);
if (values["name"] === "") {
this.errors.set("name", msg("An application name is required"));
}
if (
!(
isStr(values["metaLaunchUrl"]) &&
(values["metaLaunchUrl"] === "" || URL.canParse(values["metaLaunchUrl"]))
)
) {
this.errors.set("metaLaunchUrl", msg("Not a valid URL"));
}
if (!(isStr(values["slug"]) && values["slug"] !== "" && isSlug(values["slug"]))) {
this.errors.set("slug", msg("Not a valid slug"));
}
return this.errors.size === 0;
}
override handleButton(button: NavigableButton) {
if (button.kind === "next") {
if (!this.valid) {
this.handleEnabling({
disabled: ["provider-choice", "provider", "bindings", "submit"],
});
return;
}
const app: Partial<ApplicationRequest> = this.formValues as Partial<ApplicationRequest>;
let payload: ApplicationWizardStateUpdate = {
app: this.formValues,
errors: this.removeErrors("app"),
};
if (app.name && (this.wizard.provider?.name ?? "").trim() === "") {
payload = {
...payload,
provider: { name: `Provider for ${app.name}` },
};
}
this.handleUpdate(payload, button.destination, {
enable: "provider-choice",
});
return;
}
super.handleButton(button);
}
renderForm(app: Partial<ApplicationRequest>, errors: ValidationRecord) {
return html` <ak-wizard-title>${msg("Configure The Application")}</ak-wizard-title>
<form id="applicationform" class="pf-c-form pf-m-horizontal" slot="form">
<ak-text-input
name="name"
value=${ifDefined(app.name)}
label=${msg("Name")}
required
?invalid=${this.errors.has("name")}
.errorMessages=${errors.name ?? this.errorMessages("name")}
help=${msg("Application's display Name.")}
id="ak-application-wizard-details-name"
></ak-text-input>
<ak-slug-input
name="slug"
value=${ifDefined(app.slug)}
label=${msg("Slug")}
source="#ak-application-wizard-details-name"
required
?invalid=${errors.slug ?? this.errors.has("slug")}
.errorMessages=${this.errorMessages("slug")}
help=${msg("Internal application name used in URLs.")}
></ak-slug-input>
<ak-text-input
name="group"
value=${ifDefined(app.group)}
label=${msg("Group")}
.errorMessages=${errors.group ?? []}
help=${msg(
"Optionally enter a group name. Applications with identical groups are shown grouped together.",
)}
></ak-text-input>
<ak-radio-input
label=${msg("Policy engine mode")}
required
name="policyEngineMode"
.options=${policyOptions}
.value=${app.policyEngineMode}
.errorMessages=${errors.policyEngineMode ?? []}
></ak-radio-input>
<ak-form-group aria-label=${msg("UI Settings")}>
<span slot="header"> ${msg("UI Settings")} </span>
<div slot="body" class="pf-c-form">
<ak-text-input
name="metaLaunchUrl"
label=${msg("Launch URL")}
value=${ifDefined(app.metaLaunchUrl)}
?invalid=${this.errors.has("metaLaunchUrl")}
.errorMessages=${errors.metaLaunchUrl ??
this.errorMessages("metaLaunchUrl")}
help=${msg(
"If left empty, authentik will try to extract the launch URL based on the selected provider.",
)}
></ak-text-input>
<ak-switch-input
name="openInNewTab"
?checked=${app.openInNewTab ?? false}
label=${msg("Open in new tab")}
help=${msg(
"If checked, the launch URL will open in a new browser tab or window from the user's application library.",
)}
>
</ak-switch-input>
</div>
</ak-form-group>
</form>`;
}
renderMain() {
if (!(this.wizard.app && this.wizard.errors)) {
throw new Error("Application Step received uninitialized wizard context.");
}
return this.renderForm(
this.wizard.app as ApplicationRequest,
this.wizard.errors?.app ?? {},
);
}
}
declare global {
interface HTMLElementTagNameMap {
"ak-application-wizard-application-step": ApplicationWizardApplicationStep;
}
}

View File

@ -0,0 +1,163 @@
import { ApplicationWizardStep } from "@goauthentik/admin/applications/wizard/ApplicationWizardStep.js";
import "@goauthentik/admin/applications/wizard/ak-wizard-title.js";
import "@goauthentik/components/ak-radio-input";
import "@goauthentik/components/ak-slug-input";
import "@goauthentik/components/ak-status-label";
import "@goauthentik/components/ak-switch-input";
import "@goauthentik/components/ak-text-input";
import { type WizardButton } from "@goauthentik/components/ak-wizard/types";
import "@goauthentik/elements/ak-table/ak-select-table.js";
import { SelectTable } from "@goauthentik/elements/ak-table/ak-select-table.js";
import "@goauthentik/elements/forms/FormGroup";
import "@goauthentik/elements/forms/HorizontalFormElement";
import { P, match } from "ts-pattern";
import { msg, str } from "@lit/localize";
import { css, html } from "lit";
import { customElement, query } from "lit/decorators.js";
import PFCard from "@patternfly/patternfly/components/Card/card.css";
import { makeEditButton } from "./bindings/ak-application-wizard-bindings-edit-button.js";
import "./bindings/ak-application-wizard-bindings-toolbar.js";
const COLUMNS = [
[msg("Order"), "order"],
[msg("Binding")],
[msg("Enabled"), "enabled"],
[msg("Timeout"), "timeout"],
[msg("Actions")],
];
@customElement("ak-application-wizard-bindings-step")
export class ApplicationWizardBindingsStep extends ApplicationWizardStep {
label = msg("Configure Bindings");
get buttons(): WizardButton[] {
return [
{ kind: "next", destination: "submit" },
{ kind: "back", destination: "provider" },
{ kind: "cancel" },
];
}
@query("ak-select-table")
selectTable!: SelectTable;
static get styles() {
return super.styles.concat(
PFCard,
css`
.pf-c-card {
margin-top: 1em;
}
`,
);
}
get bindingsAsColumns() {
return this.wizard.bindings.map((binding, index) => {
const { order, enabled, timeout } = binding;
const isSet = P.string.minLength(1);
const policy = match(binding)
.with({ policy: isSet }, (v) => msg(str`Policy ${v.policyObj?.name}`))
.with({ group: isSet }, (v) => msg(str`Group ${v.groupObj?.name}`))
.with({ user: isSet }, (v) => msg(str`User ${v.userObj?.name}`))
.otherwise(() => msg("-"));
return {
key: index,
content: [
order,
policy,
html`<ak-status-label type="warning" ?good=${enabled}></ak-status-label>`,
timeout,
makeEditButton(msg("Edit"), index, (ev: CustomEvent<number>) =>
this.onBindingEvent(ev.detail),
),
],
};
});
}
// TODO Fix those dispatches so that we handle them here, in this component, and *choose* how to
// forward them.
onBindingEvent(binding?: number) {
this.handleUpdate({ currentBinding: binding ?? -1 }, "edit-binding", {
enable: "edit-binding",
});
}
onDeleteBindings() {
const toDelete = this.selectTable
.json()
.map((i) => (typeof i === "string" ? parseInt(i, 10) : i));
const bindings = this.wizard.bindings.filter((binding, index) => !toDelete.includes(index));
this.handleUpdate({ bindings }, "bindings");
}
renderEmptyCollection() {
return html`<ak-wizard-title
>${msg("Configure Policy/User/Group Bindings")}</ak-wizard-title
>
<h6 class="pf-c-title pf-m-md">
${msg("These policies control which users can access this application.")}
</h6>
<div class="pf-c-card">
<ak-application-wizard-bindings-toolbar
@clickNew=${() => this.onBindingEvent()}
@clickDelete=${() => this.onDeleteBindings()}
></ak-application-wizard-bindings-toolbar>
<ak-select-table
multiple
id="bindings"
order="order"
.columns=${COLUMNS}
.content=${[]}
></ak-select-table>
<ak-empty-state header=${msg("No bound policies.")} icon="pf-icon-module">
<div slot="body">${msg("No policies are currently bound to this object.")}</div>
<div slot="primary">
<button
@click=${() => this.onBindingEvent()}
class="pf-c-button pf-m-primary"
>
${msg("Bind policy/group/user")}
</button>
</div>
</ak-empty-state>
</div>`;
}
renderCollection() {
return html` <ak-wizard-title>${msg("Configure Policy Bindings")}</ak-wizard-title>
<h6 class="pf-c-title pf-m-md">
${msg("These policies control which users can access this application.")}
</h6>
<ak-application-wizard-bindings-toolbar
@clickNew=${() => this.onBindingEvent()}
@clickDelete=${() => this.onDeleteBindings()}
?can-delete=${this.wizard.bindings.length > 0}
></ak-application-wizard-bindings-toolbar>
<ak-select-table
multiple
id="bindings"
order="order"
.columns=${COLUMNS}
.content=${this.bindingsAsColumns}
></ak-select-table>`;
}
renderMain() {
if ((this.wizard.bindings ?? []).length === 0) {
return this.renderEmptyCollection();
}
return this.renderCollection();
}
}
declare global {
interface HTMLElementTagNameMap {
"ak-application-wizard-applications-step": ApplicationWizardBindingsStep;
}
}

View File

@ -0,0 +1,235 @@
import { ApplicationWizardStep } from "@goauthentik/admin/applications/wizard/ApplicationWizardStep.js";
import "@goauthentik/admin/applications/wizard/ak-wizard-title.js";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { groupBy } from "@goauthentik/common/utils";
import "@goauthentik/components/ak-radio-input";
import "@goauthentik/components/ak-switch-input";
import "@goauthentik/components/ak-text-input";
import "@goauthentik/components/ak-toggle-group";
import { type NavigableButton, type WizardButton } from "@goauthentik/components/ak-wizard/types";
import "@goauthentik/elements/forms/FormGroup";
import "@goauthentik/elements/forms/HorizontalFormElement";
import "@goauthentik/elements/forms/SearchSelect";
import { type SearchSelectBase } from "@goauthentik/elements/forms/SearchSelect/SearchSelect.js";
import "@goauthentik/elements/forms/SearchSelect/ak-search-select-ez.js";
import { msg } from "@lit/localize";
import { html, nothing } from "lit";
import { customElement, query, state } from "lit/decorators.js";
import { CoreApi, Group, PoliciesApi, Policy, PolicyBinding, User } from "@goauthentik/api";
const withQuery = <T>(search: string | undefined, args: T) => (search ? { ...args, search } : args);
enum target {
policy = "policy",
group = "group",
user = "user",
}
const policyObjectKeys: Record<target, keyof PolicyBinding> = {
[target.policy]: "policyObj",
[target.group]: "groupObj",
[target.user]: "userObj",
};
const PASS_FAIL = [
[msg("Pass"), true, false],
[msg("Don't Pass"), false, true],
].map(([label, value, d]) => ({ label, value, default: d }));
@customElement("ak-application-wizard-edit-binding-step")
export class ApplicationWizardEditBindingStep extends ApplicationWizardStep {
label = msg("Edit Binding");
hide = true;
@query("form#bindingform")
form!: HTMLFormElement;
@query(".policy-search-select")
searchSelect!: SearchSelectBase<Policy> | SearchSelectBase<Group> | SearchSelectBase<User>;
@state()
policyGroupUser: target = target.policy;
instanceId = -1;
instance?: PolicyBinding;
get buttons(): WizardButton[] {
return [
{ kind: "next", label: msg("Save Binding"), destination: "bindings" },
{ kind: "back", destination: "bindings" },
{ kind: "cancel" },
];
}
override handleButton(button: NavigableButton) {
if (button.kind === "next") {
if (!this.form.checkValidity()) {
return;
}
const policyObject = this.searchSelect.selectedObject;
const policyKey = policyObjectKeys[this.policyGroupUser];
const newBinding: PolicyBinding = {
...(this.formValues as unknown as PolicyBinding),
[policyKey]: policyObject,
};
const bindings = [...(this.wizard.bindings ?? [])];
if (this.instanceId === -1) {
bindings.push(newBinding);
} else {
bindings[this.instanceId] = newBinding;
}
this.instanceId = -1;
this.handleUpdate({ bindings }, "bindings");
return;
}
super.handleButton(button);
}
// The search select configurations for the three different types of fetches that we care about,
// policy, user, and group, all using the SearchSelectEZ protocol.
searchSelectConfigs(kind: target) {
switch (kind) {
case target.policy:
return {
fetchObjects: async (query?: string): Promise<Policy[]> => {
const policies = await new PoliciesApi(DEFAULT_CONFIG).policiesAllList(
withQuery(query, {
ordering: "name",
}),
);
return policies.results;
},
groupBy: (items: Policy[]) =>
groupBy(items, (policy) => policy.verboseNamePlural),
renderElement: (policy: Policy): string => policy.name,
value: (policy: Policy | undefined): string | undefined => policy?.pk,
selected: (policy: Policy): boolean => policy.pk === this.instance?.policy,
};
case target.group:
return {
fetchObjects: async (query?: string): Promise<Group[]> => {
const groups = await new CoreApi(DEFAULT_CONFIG).coreGroupsList(
withQuery(query, {
ordering: "name",
includeUsers: false,
}),
);
return groups.results;
},
renderElement: (group: Group): string => group.name,
value: (group: Group | undefined): string | undefined => group?.pk,
selected: (group: Group): boolean => group.pk === this.instance?.group,
};
case target.user:
return {
fetchObjects: async (query?: string): Promise<User[]> => {
const users = await new CoreApi(DEFAULT_CONFIG).coreUsersList(
withQuery(query, {
ordering: "username",
}),
);
return users.results;
},
renderElement: (user: User): string => user.username,
renderDescription: (user: User) => html`${user.name}`,
value: (user: User | undefined): number | undefined => user?.pk,
selected: (user: User): boolean => user.pk === this.instance?.user,
};
default:
throw new Error(`Unrecognized policy binding target ${kind}`);
}
}
renderSearch(title: string, policyKind: target) {
if (policyKind !== this.policyGroupUser) {
return nothing;
}
return html`<ak-form-element-horizontal label=${title} name=${policyKind}>
<ak-search-select-ez
.config=${this.searchSelectConfigs(policyKind)}
class="policy-search-select"
blankable
></ak-search-select-ez>
</ak-form-element-horizontal>`;
}
renderForm(instance?: PolicyBinding) {
return html`<ak-wizard-title>${msg("Create a Policy/User/Group Binding")}</ak-wizard-title>
<form id="bindingform" class="pf-c-form pf-m-horizontal" slot="form">
<div class="pf-c-card pf-m-selectable pf-m-selected">
<div class="pf-c-card__body">
<ak-toggle-group
value=${this.policyGroupUser}
@ak-toggle=${(ev: CustomEvent<{ value: target }>) => {
this.policyGroupUser = ev.detail.value;
}}
>
<option value=${target.policy}>${msg("Policy")}</option>
<option value=${target.group}>${msg("Group")}</option>
<option value=${target.user}>${msg("User")}</option>
</ak-toggle-group>
</div>
<div class="pf-c-card__footer">
${this.renderSearch(msg("Policy"), target.policy)}
${this.renderSearch(msg("Group"), target.group)}
${this.renderSearch(msg("User"), target.user)}
</div>
</div>
<ak-switch-input
name="enabled"
?checked=${instance?.enabled ?? true}
label=${msg("Enabled")}
></ak-switch-input>
<ak-switch-input
name="negate"
?checked=${instance?.negate ?? false}
label=${msg("Negate result")}
help=${msg("Negates the outcome of the binding. Messages are unaffected.")}
></ak-switch-input>
<ak-number-input
label=${msg("Order")}
name="order"
value="${instance?.order ?? 0}"
required
></ak-number-input>
<ak-number-input
label=${msg("Timeout")}
name="timeout"
value="${instance?.timeout ?? 30}"
required
></ak-number-input>
<ak-radio-input
name="failureResult"
label=${msg("Failure result")}
.options=${PASS_FAIL}
></ak-radio-input>
</form>`;
}
renderMain() {
if (!(this.wizard.bindings && this.wizard.errors)) {
throw new Error("Application Step received uninitialized wizard context.");
}
const currentBinding = this.wizard.currentBinding ?? -1;
if (this.instanceId !== currentBinding) {
this.instanceId = currentBinding;
this.instance =
this.instanceId === -1 ? undefined : this.wizard.bindings[this.instanceId];
}
return this.renderForm(this.instance);
}
}
declare global {
interface HTMLElementTagNameMap {
"ak-application-wizard-edit-binding-step": ApplicationWizardEditBindingStep;
}
}

View File

@ -0,0 +1,94 @@
import { ApplicationWizardStep } from "@goauthentik/admin/applications/wizard/ApplicationWizardStep.js";
import "@goauthentik/admin/applications/wizard/ak-wizard-title.js";
import type { NavigableButton, WizardButton } from "@goauthentik/components/ak-wizard/types";
import "@goauthentik/elements/EmptyState.js";
import { WithLicenseSummary } from "@goauthentik/elements/Interface/licenseSummaryProvider.js";
import { bound } from "@goauthentik/elements/decorators/bound.js";
import "@goauthentik/elements/forms/FormGroup.js";
import "@goauthentik/elements/forms/HorizontalFormElement.js";
import { TypeCreateWizardPageLayouts } from "@goauthentik/elements/wizard/TypeCreateWizardPage.js";
import "@goauthentik/elements/wizard/TypeCreateWizardPage.js";
import { consume } from "@lit/context";
import { msg } from "@lit/localize";
import { html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { TypeCreate } from "@goauthentik/api";
import { applicationWizardProvidersContext } from "../ContextIdentity";
import { type LocalTypeCreate } from "./ProviderChoices.js";
@customElement("ak-application-wizard-provider-choice-step")
export class ApplicationWizardProviderChoiceStep extends WithLicenseSummary(ApplicationWizardStep) {
label = msg("Choose A Provider");
@state()
failureMessage = "";
@consume({ context: applicationWizardProvidersContext, subscribe: true })
public providerModelsList!: LocalTypeCreate[];
get buttons(): WizardButton[] {
return [
{ kind: "next", destination: "provider" },
{ kind: "back", destination: "application" },
{ kind: "cancel" },
];
}
override handleButton(button: NavigableButton) {
this.failureMessage = "";
if (button.kind === "next") {
if (!this.wizard.providerModel) {
this.failureMessage = msg("Please choose a provider type before proceeding.");
this.handleEnabling({ disabled: ["provider", "bindings", "submit"] });
return;
}
this.handleUpdate(undefined, button.destination, { enable: "provider" });
return;
}
super.handleButton(button);
}
@bound
onSelect(ev: CustomEvent<LocalTypeCreate>) {
ev.stopPropagation();
const detail: TypeCreate = ev.detail;
this.handleUpdate({ providerModel: detail.modelName });
}
renderMain() {
const selectedTypes = this.providerModelsList.filter(
(t) => t.modelName === this.wizard.providerModel,
);
return this.providerModelsList.length > 0
? html` <ak-wizard-title>${msg("Choose a Provider Type")}</ak-wizard-title>
<form class="pf-c-form pf-m-horizontal">
<ak-wizard-page-type-create
.types=${this.providerModelsList}
name="selectProviderType"
layout=${TypeCreateWizardPageLayouts.grid}
.selectedType=${selectedTypes.length > 0 ? selectedTypes[0] : undefined}
@select=${(ev: CustomEvent<LocalTypeCreate>) => {
this.handleUpdate(
{
...this.wizard,
providerModel: ev.detail.modelName,
},
undefined,
{ enable: "provider" },
);
}}
></ak-wizard-page-type-create>
</form>`
: html`<ak-empty-state loading header=${msg("Loading")}></ak-empty-state>`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ak-application-wizard-provider-choice-step": ApplicationWizardProviderChoiceStep;
}
}

View File

@ -0,0 +1,113 @@
import { type NavigableButton, type WizardButton } from "@goauthentik/components/ak-wizard/types";
import { msg } from "@lit/localize";
import { PropertyValues, nothing } from "lit";
import { customElement, query, state } from "lit/decorators.js";
import { html, unsafeStatic } from "lit/static-html.js";
import { ApplicationWizardStep } from "../ApplicationWizardStep.js";
import { OneOfProvider } from "../types.js";
import { ApplicationWizardProviderForm } from "./providers/ApplicationWizardProviderForm.js";
import "./providers/ak-application-wizard-provider-for-ldap.js";
import "./providers/ak-application-wizard-provider-for-oauth.js";
import "./providers/ak-application-wizard-provider-for-proxy.js";
import "./providers/ak-application-wizard-provider-for-rac.js";
import "./providers/ak-application-wizard-provider-for-radius.js";
import "./providers/ak-application-wizard-provider-for-saml.js";
import "./providers/ak-application-wizard-provider-for-scim.js";
const providerToTag = new Map([
["ldapprovider", "ak-application-wizard-provider-for-ldap"],
["oauth2provider", "ak-application-wizard-provider-for-oauth"],
["proxyprovider", "ak-application-wizard-provider-for-proxy"],
["racprovider", "ak-application-wizard-provider-for-rac"],
["radiusprovider", "ak-application-wizard-provider-for-radius"],
["samlprovider", "ak-application-wizard-provider-for-saml"],
["scimprovider", "ak-application-wizard-provider-for-scim"],
]);
@customElement("ak-application-wizard-provider-step")
export class ApplicationWizardProviderStep extends ApplicationWizardStep {
@state()
label = msg("Configure Provider");
@query("#providerform")
element!: ApplicationWizardProviderForm<OneOfProvider>;
get valid() {
return this.element.valid;
}
get formValues() {
return this.element.formValues;
}
override handleButton(button: NavigableButton) {
if (button.kind === "next") {
if (!this.valid) {
this.handleEnabling({
disabled: ["bindings", "submit"],
});
return;
}
const payload = {
provider: {
...this.formValues,
mode: this.wizard.proxyMode,
},
errors: this.removeErrors("provider"),
};
this.handleUpdate(payload, button.destination, {
enable: ["bindings", "submit"],
});
return;
}
super.handleButton(button);
}
get buttons(): WizardButton[] {
return [
{ kind: "next", destination: "bindings" },
{ kind: "back", destination: "provider-choice" },
{ kind: "cancel" },
];
}
renderMain() {
if (!this.wizard.providerModel) {
throw new Error("Attempted to access provider page without providing a provider type.");
}
// This is, I'm afraid, some rather esoteric bit of Lit-ing, and it makes ESLint
// sad. It does allow us to get away with specifying very little about the
// provider here.
const tag = providerToTag.get(this.wizard.providerModel);
return tag
? // eslint-disable-next-line lit/binding-positions,lit/no-invalid-html
html`<${unsafeStatic(tag)}
id="providerform"
.wizard=${this.wizard}
.errors=${this.wizard.errors?.provider ?? {}}
></${
/* eslint-disable-next-line lit/binding-positions,lit/no-invalid-html */
unsafeStatic(tag)
}>`
: nothing;
}
updated(changed: PropertyValues<this>) {
if (changed.has("wizard")) {
const label = this.element?.label ?? this.label;
if (label !== this.label) {
this.label = label;
}
}
}
}
declare global {
interface HTMLElementTagNameMap {
"ak-application-wizard-provider-step": ApplicationWizardProviderStep;
}
}

View File

@ -0,0 +1,360 @@
import "@goauthentik/admin/applications/wizard/ak-wizard-title.js";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { EVENT_REFRESH } from "@goauthentik/common/constants";
import { parseAPIError } from "@goauthentik/common/errors";
import { WizardNavigationEvent } from "@goauthentik/components/ak-wizard/events.js";
import { type WizardButton } from "@goauthentik/components/ak-wizard/types";
import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter";
import { P, match } from "ts-pattern";
import { msg } from "@lit/localize";
import { TemplateResult, css, html, nothing } from "lit";
import { customElement, state } from "lit/decorators.js";
import { classMap } from "lit/directives/class-map.js";
// import { map } from "lit/directives/map.js";
import PFDescriptionList from "@patternfly/patternfly/components/DescriptionList/description-list.css";
import PFEmptyState from "@patternfly/patternfly/components/EmptyState/empty-state.css";
import PFProgressStepper from "@patternfly/patternfly/components/ProgressStepper/progress-stepper.css";
import PFTitle from "@patternfly/patternfly/components/Title/title.css";
import PFBullseye from "@patternfly/patternfly/layouts/Bullseye/bullseye.css";
import {
type ApplicationRequest,
CoreApi,
type ModelRequest,
type PolicyBinding,
ProviderModelEnum,
ProxyMode,
type ProxyProviderRequest,
type TransactionApplicationRequest,
type TransactionApplicationResponse,
type TransactionPolicyBindingRequest,
} from "@goauthentik/api";
import { ApplicationWizardStep } from "../ApplicationWizardStep.js";
import { ExtendedValidationError, OneOfProvider } from "../types.js";
import { providerRenderers } from "./SubmitStepOverviewRenderers.js";
const _submitStates = ["reviewing", "running", "submitted"] as const;
type SubmitStates = (typeof _submitStates)[number];
type StrictProviderModelEnum = Exclude<ProviderModelEnum, "11184809">;
const providerMap: Map<string, string> = Object.values(ProviderModelEnum)
.filter((value) => /^authentik_providers_/.test(value) && /provider$/.test(value))
.reduce((acc: Map<string, string>, value) => {
acc.set(value.split(".")[1], value);
return acc;
}, new Map());
type NonEmptyArray<T> = [T, ...T[]];
type MaybeTemplateResult = TemplateResult | typeof nothing;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const isNotEmpty = (arr: any): arr is NonEmptyArray<any> => Array.isArray(arr) && arr.length > 0;
const cleanApplication = (app: Partial<ApplicationRequest>): ApplicationRequest => ({
name: "",
slug: "",
...app,
});
const cleanBinding = (binding: PolicyBinding): TransactionPolicyBindingRequest => ({
policy: binding.policy,
group: binding.group,
user: binding.user,
negate: binding.negate,
enabled: binding.enabled,
order: binding.order,
timeout: binding.timeout,
failureResult: binding.failureResult,
});
@customElement("ak-application-wizard-submit-step")
export class ApplicationWizardSubmitStep extends CustomEmitterElement(ApplicationWizardStep) {
static get styles() {
return [
...ApplicationWizardStep.styles,
PFBullseye,
PFEmptyState,
PFTitle,
PFProgressStepper,
PFDescriptionList,
css`
.ak-wizard-main-content .pf-c-title {
padding-bottom: var(--pf-global--spacer--md);
padding-top: var(--pf-global--spacer--md);
}
`,
];
}
label = msg("Review and Submit Application");
@state()
state: SubmitStates = "reviewing";
async send() {
const app = this.wizard.app;
const provider = this.wizard.provider as ModelRequest;
if (app === undefined) {
throw new Error("Reached the submit state with the app undefined");
}
if (provider === undefined) {
throw new Error("Reached the submit state with the provider undefined");
}
// Stringly-based API. Not the best, but it works. Just be aware that it is
// stringly-based.
const providerModel = providerMap.get(this.wizard.providerModel) as StrictProviderModelEnum;
provider.providerModel = providerModel;
// Special case for the Proxy provider.
if (this.wizard.providerModel === "proxyprovider") {
(provider as ProxyProviderRequest).mode = this.wizard.proxyMode;
if ((provider as ProxyProviderRequest).mode !== ProxyMode.ForwardDomain) {
(provider as ProxyProviderRequest).cookieDomain = "";
}
}
const request: TransactionApplicationRequest = {
app: cleanApplication(this.wizard.app),
providerModel,
provider,
policyBindings: (this.wizard.bindings ?? []).map(cleanBinding),
};
this.state = "running";
return (
new CoreApi(DEFAULT_CONFIG)
.coreTransactionalApplicationsUpdate({
transactionApplicationRequest: request,
})
.then((_response: TransactionApplicationResponse) => {
this.dispatchCustomEvent(EVENT_REFRESH);
this.state = "submitted";
})
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.catch(async (resolution: any) => {
const errors = (await parseAPIError(
await resolution,
)) as ExtendedValidationError;
// THIS is a really gross special case; if the user is duplicating the name of
// an existing provider, the error appears on the `app` (!) error object. We
// have to move that to the `provider.name` error field so it shows up in the
// right place.
if (Array.isArray(errors?.app?.provider)) {
const providerError = errors.app.provider;
errors.provider = errors.provider ?? {};
errors.provider.name = providerError;
delete errors.app.provider;
if (Object.keys(errors.app).length === 0) {
delete errors.app;
}
}
this.handleUpdate({ errors });
this.state = "reviewing";
})
);
}
override handleButton(button: WizardButton) {
match([button.kind, this.state])
.with([P.union("back", "cancel"), P._], () => {
super.handleButton(button);
})
.with(["close", "submitted"], () => {
super.handleButton(button);
})
.with(["next", "reviewing"], () => {
this.send();
})
.with([P._, "running"], () => {
throw new Error("No buttons should be showing when running submit phase");
})
.otherwise(() => {
throw new Error(
`Submit step received incoherent button/state combination: ${[button.kind, state]}`,
);
});
}
get buttons(): WizardButton[] {
const forReview: WizardButton[] = [
{ kind: "next", label: msg("Submit"), destination: "here" },
{ kind: "back", destination: "bindings" },
{ kind: "cancel" },
];
const forSubmit: WizardButton[] = [{ kind: "close" }];
return match(this.state)
.with("submitted", () => forSubmit)
.with("running", () => [])
.with("reviewing", () => forReview)
.exhaustive();
}
renderInfo(
state: string,
label: string,
icons: string[],
extraInfo: MaybeTemplateResult = nothing,
) {
const icon = classMap(icons.reduce((acc, icon) => ({ ...acc, [icon]: true }), {}));
return html`<div data-ouid-component-state=${this.state} class="ak-wizard-main-content">
<div class="pf-l-bullseye">
<div class="pf-c-empty-state pf-m-lg">
<div class="pf-c-empty-state__content">
<i class="fas ${icon} pf-c-empty-state__icon" aria-hidden="true"></i>
<h1 data-ouia-commit-state=${state} class="pf-c-title pf-m-lg">${label}</h1>
${extraInfo}
</div>
</div>
</div>
</div>`;
}
renderError() {
if (Object.keys(this.wizard.errors).length === 0) {
return nothing;
}
const navTo = (step: string) => () => this.dispatchEvent(new WizardNavigationEvent(step));
const errors = this.wizard.errors;
return html` <hr class="pf-c-divider" />
${match(errors as ExtendedValidationError)
.with(
{ app: P.nonNullable },
() =>
html`<p>${msg("There was an error in the application.")}</p>
<p>
<a @click=${navTo("application")}
>${msg("Review the application.")}</a
>
</p>`,
)
.with(
{ provider: P.nonNullable },
() =>
html`<p>${msg("There was an error in the provider.")}</p>
<p>
<a @click=${navTo("provider")}>${msg("Review the provider.")}</a>
</p>`,
)
.with(
{ detail: P.nonNullable },
() =>
`<p>${msg("There was an error. Please go back and review the application.")}: ${errors.detail}</p>`,
)
.with(
{
nonFieldErrors: P.when(isNotEmpty),
},
() =>
html`<p>${msg("There was an error:")}:</p>
<ul>
${(errors.nonFieldErrors ?? []).map(
(e: string) => html`<li>${e}</li>`,
)}
</ul>
<p>${msg("Please go back and review the application.")}</p>`,
)
.otherwise(
() =>
html`<p>
${msg(
"There was an error creating the application, but no error message was sent. Please review the server logs.",
)}
</p>`,
)}`;
}
renderReview(app: Partial<ApplicationRequest>, provider: OneOfProvider) {
const renderer = providerRenderers.get(this.wizard.providerModel);
if (!renderer) {
throw new Error(
`Provider ${this.wizard.providerModel ?? "-- undefined --"} has no summary renderer.`,
);
}
return html`
<div class="ak-wizard-main-content">
<ak-wizard-title>${msg("Review the Application and Provider")}</ak-wizard-title>
<h2 class="pf-c-title pf-m-xl">${msg("Application")}</h2>
<dl class="pf-c-description-list">
<div class="pf-c-description-list__group">
<dt class="pf-c-description-list__term">${msg("Name")}</dt>
<dt class="pf-c-description-list__description">${app.name}</dt>
</div>
<div class="pf-c-description-list__group">
<dt class="pf-c-description-list__term">${msg("Group")}</dt>
<dt class="pf-c-description-list__description">${app.group || msg("-")}</dt>
</div>
<div class="pf-c-description-list__group">
<dt class="pf-c-description-list__term">${msg("Policy engine mode")}</dt>
<dt class="pf-c-description-list__description">
${app.policyEngineMode?.toUpperCase()}
</dt>
</div>
${(app.metaLaunchUrl ?? "").trim() !== ""
? html` <div class="pf-c-description-list__group">
<dt class="pf-c-description-list__term">${msg("Launch URL")}</dt>
<dt class="pf-c-description-list__description">
${app.metaLaunchUrl}
</dt>
</div>`
: nothing}
</dl>
${renderer
? html` <h2 class="pf-c-title pf-m-xl pf-u-pt-xl">${msg("Provider")}</h2>
${renderer(provider)}`
: nothing}
</div>
`;
}
renderMain() {
const app = this.wizard.app;
const provider = this.wizard.provider;
if (!(this.wizard && app && provider)) {
throw new Error("Submit step received uninitialized wizard context");
}
// An empty object is truthy, an empty array is falsey. *WAT Javascript*.
const keys = Object.keys(this.wizard.errors);
return match([this.state, keys])
.with(["submitted", P._], () =>
this.renderInfo("success", msg("Your application has been saved"), [
"fa-check-circle",
"pf-m-success",
]),
)
.with(["running", P._], () =>
this.renderInfo("running", msg("Saving application..."), ["fa-cogs", "pf-m-info"]),
)
.with(["reviewing", []], () => this.renderReview(app, provider))
.with(["reviewing", [P.any, ...P.array()]], () =>
this.renderInfo(
"error",
msg("authentik was unable to complete this process."),
["fa-times-circle", "pf-m-danger"],
this.renderError(),
),
)
.exhaustive();
}
}
declare global {
interface HTMLElementTagNameMap {
"ak-application-wizard-submit-step": ApplicationWizardSubmitStep;
}
}

View File

@ -0,0 +1,50 @@
import { AKElement } from "@goauthentik/elements/Base.js";
import { bound } from "@goauthentik/elements/decorators/bound.js";
import { msg } from "@lit/localize";
import { html } from "lit";
import { customElement, property } from "lit/decorators.js";
import PFButton from "@patternfly/patternfly/components/Button/button.css";
@customElement("ak-application-wizard-binding-step-edit-button")
export class ApplicationWizardBindingStepEditButton extends AKElement {
static get styles() {
return [PFButton];
}
@property({ type: Number })
value = -1;
@bound
onClick(ev: Event) {
ev.stopPropagation();
this.dispatchEvent(
new CustomEvent<number>("click-edit", {
bubbles: true,
composed: true,
detail: this.value,
}),
);
}
render() {
return html`<button class="pf-c-button pf-c-secondary" @click=${this.onClick}>
${msg("Edit")}
</button>`;
}
}
export function makeEditButton(
label: string,
value: number,
handler: (_: CustomEvent<number>) => void,
) {
return html`<ak-application-wizard-binding-step-edit-button
class="pf-c-button pf-m-secondary"
.value=${value}
@click-edit=${handler}
>
${label}
</ak-application-wizard-binding-step-edit-button>`;
}

View File

@ -0,0 +1,53 @@
import { AKElement } from "@goauthentik/elements/Base";
import { msg } from "@lit/localize";
import { html } from "lit";
import { customElement, property } from "lit/decorators.js";
import PFButton from "@patternfly/patternfly/components/Button/button.css";
import PFToolbar from "@patternfly/patternfly/components/Toolbar/toolbar.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
@customElement("ak-application-wizard-bindings-toolbar")
export class ApplicationWizardBindingsToolbar extends AKElement {
static get styles() {
return [PFBase, PFButton, PFToolbar];
}
@property({ type: Boolean, attribute: "can-delete", reflect: true })
canDelete = false;
notify(eventName: string) {
this.dispatchEvent(new Event(eventName, { bubbles: true, composed: true }));
}
render() {
return html`
<div class="pf-c-toolbar">
<div class="pf-c-toolbar__content">
<div class="pf-c-toolbar__group">
<button
class="pf-c-button pf-m-primary"
@click=${() => this.notify("clickNew")}
>
${msg("Bind existing policy/group/user")}
</button>
</div>
<button
class="pf-c-button pf-m-danger"
?disabled=${!this.canDelete}
@click=${() => this.notify("clickDelete")}
>
${msg("Delete")}
</button>
</div>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ak-application-wizard-bindings-toolbar": ApplicationWizardBindingsToolbar;
}
}

View File

@ -0,0 +1,62 @@
import { camelToSnake } from "@goauthentik/common/utils.js";
import "@goauthentik/components/ak-number-input";
import "@goauthentik/components/ak-radio-input";
import "@goauthentik/components/ak-switch-input";
import "@goauthentik/components/ak-text-input";
import { AKElement } from "@goauthentik/elements/Base.js";
import { KeyUnknown, serializeForm } from "@goauthentik/elements/forms/Form";
import "@goauthentik/elements/forms/FormGroup";
import "@goauthentik/elements/forms/HorizontalFormElement";
import { HorizontalFormElement } from "@goauthentik/elements/forms/HorizontalFormElement";
import { property, query } from "lit/decorators.js";
import { styles as AwadStyles } from "../../ApplicationWizardFormStepStyles.css.js";
import { type ApplicationWizardState, type OneOfProvider } from "../../types";
export class ApplicationWizardProviderForm<T extends OneOfProvider> extends AKElement {
static get styles() {
return AwadStyles;
}
label = "";
@property({ type: Object, attribute: false })
wizard!: ApplicationWizardState;
@property({ type: Object, attribute: false })
errors: Map<string | number | symbol, string> = new Map();
@query("form#providerform")
form!: HTMLFormElement;
get formValues(): KeyUnknown | undefined {
const elements = [
...Array.from(
this.form.querySelectorAll<HorizontalFormElement>("ak-form-element-horizontal"),
),
...Array.from(this.form.querySelectorAll<HTMLElement>("[data-ak-control=true]")),
];
return serializeForm(elements as unknown as NodeListOf<HorizontalFormElement>);
}
get valid() {
this.errors = new Map();
return this.form.checkValidity();
}
errorMessages(name: string) {
return this.errors.has(name)
? [this.errors.get(name)]
: (this.wizard.errors?.provider?.[name] ??
this.wizard.errors?.provider?.[camelToSnake(name)] ??
[]);
}
isValid(name: keyof T) {
return !(
(this.wizard.errors?.provider?.[name as string] ?? []).length > 0 ||
this.errors.has(name)
);
}
}

View File

@ -0,0 +1,44 @@
import "@goauthentik/admin/applications/wizard/ak-wizard-title.js";
import { ValidationRecord } from "@goauthentik/admin/applications/wizard/types";
import { renderForm } from "@goauthentik/admin/providers/ldap/LDAPProviderFormForm.js";
import { WithBrandConfig } from "@goauthentik/elements/Interface/brandProvider.js";
import { msg } from "@lit/localize";
import { html } from "lit";
import { customElement } from "lit/decorators.js";
import type { LDAPProvider } from "@goauthentik/api";
import { ApplicationWizardProviderForm } from "./ApplicationWizardProviderForm.js";
@customElement("ak-application-wizard-provider-for-ldap")
export class ApplicationWizardLdapProviderForm extends WithBrandConfig(
ApplicationWizardProviderForm<LDAPProvider>,
) {
label = msg("Configure LDAP Provider");
renderForm(provider: LDAPProvider, errors: ValidationRecord) {
return html`
<ak-wizard-title>${this.label}</ak-wizard-title>
<form id="providerform" class="pf-c-form pf-m-horizontal" slot="form">
${renderForm(provider ?? {}, errors, this.brand)}
</form>
`;
}
render() {
if (!(this.wizard.provider && this.wizard.errors)) {
throw new Error("LDAP Provider Step received uninitialized wizard context.");
}
return this.renderForm(
this.wizard.provider as LDAPProvider,
this.wizard.errors.provider ?? {},
);
}
}
declare global {
interface HTMLElementTagNameMap {
"ak-application-wizard-provider-for-ldap": ApplicationWizardLdapProviderForm;
}
}

View File

@ -0,0 +1,64 @@
import "@goauthentik/admin/applications/wizard/ak-wizard-title.js";
import { renderForm } from "@goauthentik/admin/providers/oauth2/OAuth2ProviderFormForm.js";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { msg } from "@lit/localize";
import { html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { OAuth2ProviderRequest, SourcesApi } from "@goauthentik/api";
import { type OAuth2Provider, type PaginatedOAuthSourceList } from "@goauthentik/api";
import { ExtendedValidationError } from "../../types.js";
import { ApplicationWizardProviderForm } from "./ApplicationWizardProviderForm.js";
@customElement("ak-application-wizard-provider-for-oauth")
export class ApplicationWizardOauth2ProviderForm extends ApplicationWizardProviderForm<OAuth2ProviderRequest> {
label = msg("Configure OAuth2 Provider");
@state()
showClientSecret = true;
@state()
oauthSources?: PaginatedOAuthSourceList;
constructor() {
super();
new SourcesApi(DEFAULT_CONFIG)
.sourcesOauthList({
ordering: "name",
hasJwks: true,
})
.then((oauthSources: PaginatedOAuthSourceList) => {
this.oauthSources = oauthSources;
});
}
renderForm(provider: OAuth2Provider, errors: ExtendedValidationError) {
const showClientSecretCallback = (show: boolean) => {
this.showClientSecret = show;
};
return html` <ak-wizard-title>${this.label}</ak-wizard-title>
<form id="providerform" class="pf-c-form pf-m-horizontal" slot="form">
${renderForm(
provider ?? {},
errors,
this.showClientSecret,
showClientSecretCallback,
)}
</form>`;
}
render() {
if (!(this.wizard.provider && this.wizard.errors)) {
throw new Error("Oauth2 Provider Step received uninitialized wizard context.");
}
return this.renderForm(this.wizard.provider as OAuth2Provider, this.wizard.errors);
}
}
declare global {
interface HTMLElementTagNameMap {
"ak-application-wizard-provider-for-oauth": ApplicationWizardOauth2ProviderForm;
}
}

View File

@ -0,0 +1,67 @@
import "@goauthentik/admin/applications/wizard/ak-wizard-title.js";
import { ValidationRecord } from "@goauthentik/admin/applications/wizard/types";
import {
ProxyModeValue,
type SetMode,
type SetShowHttpBasic,
renderForm,
} from "@goauthentik/admin/providers/proxy/ProxyProviderFormForm.js";
import { WizardUpdateEvent } from "@goauthentik/components/ak-wizard/events.js";
import { msg } from "@lit/localize";
import { html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { ProxyMode, ProxyProvider } from "@goauthentik/api";
import { ApplicationWizardProviderForm } from "./ApplicationWizardProviderForm";
@customElement("ak-application-wizard-provider-for-proxy")
export class ApplicationWizardProxyProviderForm extends ApplicationWizardProviderForm<ProxyProvider> {
label = msg("Configure Proxy Provider");
@state()
showHttpBasic = true;
renderForm(provider: ProxyProvider, errors: ValidationRecord) {
const onSetMode: SetMode = (ev: CustomEvent<ProxyModeValue>) => {
this.dispatchEvent(
new WizardUpdateEvent({ ...this.wizard, proxyMode: ev.detail.value }),
);
// We deliberately chose not to make the forms "controlled," but we do need this form to
// respond immediately to a state change in the wizard.
window.setTimeout(() => this.requestUpdate(), 0);
};
const onSetShowHttpBasic: SetShowHttpBasic = (ev: Event) => {
const el = ev.target as HTMLInputElement;
this.showHttpBasic = el.checked;
};
return html` <ak-wizard-title>${this.label}</ak-wizard-title>
<form id="providerform" class="pf-c-form pf-m-horizontal" slot="form">
${renderForm(provider ?? {}, errors ?? [], {
mode: this.wizard.proxyMode ?? ProxyMode.Proxy,
onSetMode,
showHttpBasic: this.showHttpBasic,
onSetShowHttpBasic,
})}
</form>`;
}
render() {
if (!(this.wizard.provider && this.wizard.errors)) {
throw new Error("Proxy Provider Step received uninitialized wizard context.");
}
return this.renderForm(
this.wizard.provider as ProxyProvider,
this.wizard.errors?.provider ?? {},
);
}
}
declare global {
interface HTMLElementTagNameMap {
"ak-application-wizard-provider-for-proxy": ApplicationWizardProxyProviderForm;
}
}

View File

@ -0,0 +1,94 @@
import "@goauthentik/admin/applications/wizard/ak-wizard-title.js";
import "@goauthentik/admin/common/ak-crypto-certificate-search.js";
import "@goauthentik/admin/common/ak-flow-search/ak-flow-search";
import {
propertyMappingsProvider,
propertyMappingsSelector,
} from "@goauthentik/admin/providers/rac/RACProviderFormHelpers.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 { msg } from "@lit/localize";
import { html } from "lit";
import { customElement } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import { FlowsInstancesListDesignationEnum, type RACProvider } from "@goauthentik/api";
import { ApplicationWizardProviderForm } from "./ApplicationWizardProviderForm.js";
@customElement("ak-application-wizard-provider-for-rac")
export class ApplicationWizardRACProviderForm extends ApplicationWizardProviderForm<RACProvider> {
label = msg("Configure Remote Access Provider");
renderForm(provider: RACProvider) {
return html`
<ak-wizard-title>${this.label}</ak-wizard-title>
<form id="providerform" class="pf-c-form pf-m-horizontal" slot="form">
<ak-text-input
name="name"
label=${msg("Name")}
value=${ifDefined(provider.name)}
.errorMessages=${this.errorMessages("name")}
required
></ak-text-input>
<ak-form-element-horizontal
name="authorizationFlow"
label=${msg("Authorization flow")}
?required=${true}
>
<ak-flow-search
flowType=${FlowsInstancesListDesignationEnum.Authorization}
.currentFlow=${provider.authorizationFlow}
required
></ak-flow-search>
<p class="pf-c-form__helper-text">
${msg("Flow used when authorizing this provider.")}
</p>
</ak-form-element-horizontal>
<ak-text-input
name="connectionExpiry"
label=${msg("Connection expiry")}
required
value="${provider.connectionExpiry ?? "hours=8"}"
help=${msg(
"Determines how long a session lasts before being disconnected and requiring re-authorization.",
)}
></ak-text-input>
<ak-form-group .expanded=${true}>
<span slot="header"> ${msg("Protocol settings")} </span>
<div slot="body" class="pf-c-form">
<ak-form-element-horizontal
label=${msg("Property mappings")}
name="propertyMappings"
>
<ak-dual-select-dynamic-selected
.provider=${propertyMappingsProvider}
.selector=${propertyMappingsSelector(provider?.propertyMappings)}
available-label="${msg("Available Property Mappings")}"
selected-label="${msg("Selected Property Mappings")}"
></ak-dual-select-dynamic-selected>
</ak-form-element-horizontal>
</div>
</ak-form-group>
</form>
`;
}
render() {
if (!(this.wizard.provider && this.wizard.errors)) {
throw new Error("RAC Provider Step received uninitialized wizard context.");
}
return this.renderForm(this.wizard.provider as RACProvider);
}
}
declare global {
interface HTMLElementTagNameMap {
"ak-application-wizard-provider-for-rac": ApplicationWizardRACProviderForm;
}
}

View File

@ -0,0 +1,42 @@
import "@goauthentik/admin/applications/wizard/ak-wizard-title.js";
import { ValidationRecord } from "@goauthentik/admin/applications/wizard/types";
import { renderForm } from "@goauthentik/admin/providers/radius/RadiusProviderFormForm.js";
import { WithBrandConfig } from "@goauthentik/elements/Interface/brandProvider";
import { msg } from "@lit/localize";
import { customElement } from "@lit/reactive-element/decorators.js";
import { html } from "lit";
import { RadiusProvider } from "@goauthentik/api";
import { ApplicationWizardProviderForm } from "./ApplicationWizardProviderForm.js";
@customElement("ak-application-wizard-provider-for-radius")
export class ApplicationWizardRadiusProviderForm extends WithBrandConfig(
ApplicationWizardProviderForm<RadiusProvider>,
) {
label = msg("Configure Radius Provider");
renderForm(provider: RadiusProvider, errors: ValidationRecord) {
return html` <ak-wizard-title>${this.label}</ak-wizard-title>
<form id="providerform" class="pf-c-form pf-m-horizontal" slot="form">
${renderForm(provider ?? {}, errors, this.brand)}
</form>`;
}
render() {
if (!(this.wizard.provider && this.wizard.errors)) {
throw new Error("RAC Provider Step received uninitialized wizard context.");
}
return this.renderForm(
this.wizard.provider as RadiusProvider,
this.wizard.errors?.provider ?? {},
);
}
}
declare global {
interface HTMLElementTagNameMap {
"ak-application-wizard-provider-for-radius": ApplicationWizardRadiusProviderForm;
}
}

View File

@ -0,0 +1,51 @@
import "@goauthentik/admin/applications/wizard/ak-wizard-title.js";
import { type AkCryptoCertificateSearch } from "@goauthentik/admin/common/ak-crypto-certificate-search";
import { renderForm } from "@goauthentik/admin/providers/saml/SAMLProviderFormForm.js";
import "@goauthentik/elements/forms/FormGroup";
import { msg } from "@lit/localize";
import { customElement, state } from "@lit/reactive-element/decorators.js";
import { html } from "lit";
import { SAMLProvider } from "@goauthentik/api";
import { ApplicationWizardProviderForm } from "./ApplicationWizardProviderForm";
@customElement("ak-application-wizard-provider-for-saml")
export class ApplicationWizardProviderSamlForm extends ApplicationWizardProviderForm<SAMLProvider> {
label = msg("Configure SAML Provider");
@state()
hasSigningKp = false;
renderForm() {
const setHasSigningKp = (ev: InputEvent) => {
const target = ev.target as AkCryptoCertificateSearch;
if (!target) return;
this.hasSigningKp = !!target.selectedKeypair;
};
return html` <ak-wizard-title>${this.label}</ak-wizard-title>
<form id="providerform" class="pf-c-form pf-m-horizontal" slot="form">
${renderForm(
(this.wizard.provider as SAMLProvider) ?? {},
this.wizard.errors?.provider ?? {},
setHasSigningKp,
this.hasSigningKp,
)}
</form>`;
}
render() {
if (!(this.wizard.provider && this.wizard.errors)) {
throw new Error("SAML Provider Step received uninitialized wizard context.");
}
return this.renderForm();
}
}
declare global {
interface HTMLElementTagNameMap {
"ak-application-wizard-provider-for-saml": ApplicationWizardProviderSamlForm;
}
}

View File

@ -0,0 +1,35 @@
import "@goauthentik/admin/applications/wizard/ak-wizard-title.js";
import { renderForm } from "@goauthentik/admin/providers/scim/SCIMProviderFormForm.js";
import "@goauthentik/elements/forms/FormGroup";
import { msg } from "@lit/localize";
import { customElement, state } from "@lit/reactive-element/decorators.js";
import { html } from "lit";
import { PaginatedSCIMMappingList, type SCIMProvider } from "@goauthentik/api";
import { ApplicationWizardProviderForm } from "./ApplicationWizardProviderForm";
@customElement("ak-application-wizard-provider-for-scim")
export class ApplicationWizardSCIMProvider extends ApplicationWizardProviderForm<SCIMProvider> {
label = msg("Configure SCIM Provider");
@state()
propertyMappings?: PaginatedSCIMMappingList;
render() {
return html`<ak-wizard-title>${this.label}</ak-wizard-title>
<form id="providerform" class="pf-c-form pf-m-horizontal" slot="form">
${renderForm(
(this.wizard.provider as SCIMProvider) ?? {},
this.wizard.errors.provider,
)}
</form>`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ak-application-wizard-provider-for-scim": ApplicationWizardSCIMProvider;
}
}