web: Application Wizard

This commit combines a working (but very unpolished) version of the Application Wizard with Jen's
code for the CoreTransactionApplicationRequest, resulting in a successful round trip.

It fixes a number of bugs with the way ContextProducer decorators were being processed, such that
they just weren't working with our current configuration (although they did work fine in Storybook);
consumers didn't need to be fixed.

It also *removes* the steps-aware context from the Wizard.

That *may* be a mistake.  To re-iterate, the `WizardFrame` provides the chrome for a Wizard: the
button bar div, the breadcrumbs div, the header div, and it takes the steps object as its source of
truth for all of the content.  The `WizardContent` part of the application has two parts: The
`WizardMain`, which wraps the frame and supplies the context for all the `WizardPanels`, and the
`WizardPanels` themselves, which are dependent on a context from `WizardMain` for the data that
populates each panel. YAGNI right now that the panels need to know anything about the steps, and the
`WizardMain` can just pass a fresh `.steps` object to the `WizardFrame` when they need updating.
Using props drilling may make more sense here.

It certainy does *not* make sense for the panels.  They need to be renderable on-demand, and they
need to make sense of what they're rendering on-demand, so the function is

```
(panel code) => (context) => (rendered panel)
```

(Yes, that's curried notation. Deal.)
This commit is contained in:
Ken Sternberg 2023-08-24 14:22:32 -07:00
parent cbdca55e57
commit 58bc1c3656
22 changed files with 463 additions and 214 deletions

View File

@ -1,4 +1,5 @@
import "@goauthentik/admin/applications/ApplicationForm"; import "@goauthentik/admin/applications/ApplicationForm";
import "@goauthentik/admin/applications/wizard/ak-application-wizard";
import { PFSize } from "@goauthentik/app/elements/Spinner"; import { PFSize } from "@goauthentik/app/elements/Spinner";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { uiConfig } from "@goauthentik/common/ui/config"; import { uiConfig } from "@goauthentik/common/ui/config";
@ -7,7 +8,7 @@ import "@goauthentik/elements/Markdown";
import "@goauthentik/elements/buttons/SpinnerButton"; import "@goauthentik/elements/buttons/SpinnerButton";
import "@goauthentik/elements/forms/DeleteBulkForm"; import "@goauthentik/elements/forms/DeleteBulkForm";
import "@goauthentik/elements/forms/ModalForm"; import "@goauthentik/elements/forms/ModalForm";
import { getURLParam } from "@goauthentik/elements/router/RouteMatch"; // import { getURLParam } from "@goauthentik/elements/router/RouteMatch";
import { PaginatedResponse } from "@goauthentik/elements/table/Table"; import { PaginatedResponse } from "@goauthentik/elements/table/Table";
import { TableColumn } from "@goauthentik/elements/table/Table"; import { TableColumn } from "@goauthentik/elements/table/Table";
import { TablePage } from "@goauthentik/elements/table/TablePage"; import { TablePage } from "@goauthentik/elements/table/TablePage";
@ -162,6 +163,7 @@ export class ApplicationListPage extends TablePage<Application> {
]; ];
} }
/*
renderObjectCreate(): TemplateResult { renderObjectCreate(): TemplateResult {
return html`<ak-forms-modal .open=${getURLParam("createForm", false)}> return html`<ak-forms-modal .open=${getURLParam("createForm", false)}>
<span slot="submit"> ${msg("Create")} </span> <span slot="submit"> ${msg("Create")} </span>
@ -170,4 +172,9 @@ export class ApplicationListPage extends TablePage<Application> {
<button slot="trigger" class="pf-c-button pf-m-primary">${msg("Create")}</button> <button slot="trigger" class="pf-c-button pf-m-primary">${msg("Create")}</button>
</ak-forms-modal>`; </ak-forms-modal>`;
} }
*/
renderObjectCreate(): TemplateResult {
return html`<ak-application-wizard></ak-application-wizard>`;
}
} }

View File

@ -1,3 +1,4 @@
import { WizardPanel } from "@goauthentik/components/ak-wizard-main/types";
import { AKElement } from "@goauthentik/elements/Base"; import { AKElement } from "@goauthentik/elements/Base";
import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter"; import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter";
@ -9,7 +10,10 @@ import { styles as AwadStyles } from "./BasePanel.css";
import { applicationWizardContext } from "./ak-application-wizard-context-name"; import { applicationWizardContext } from "./ak-application-wizard-context-name";
import type { WizardState } from "./types"; import type { WizardState } from "./types";
export class ApplicationWizardPageBase extends CustomEmitterElement(AKElement) { export class ApplicationWizardPageBase
extends CustomEmitterElement(AKElement)
implements WizardPanel
{
static get styles() { static get styles() {
return AwadStyles; return AwadStyles;
} }
@ -19,7 +23,6 @@ export class ApplicationWizardPageBase extends CustomEmitterElement(AKElement) {
rendered = false; rendered = false;
// @ts-expect-error
@consume({ context: applicationWizardContext }) @consume({ context: applicationWizardContext })
public wizard!: WizardState; public wizard!: WizardState;

View File

@ -1,5 +1,8 @@
import { createContext } from "@lit-labs/context"; import { createContext } from "@lit-labs/context";
export const applicationWizardContext = createContext(Symbol("ak-application-wizard-context")); import { WizardState } from "./types";
export const applicationWizardContext = createContext<WizardState>(
Symbol("ak-application-wizard-state-context"),
);
export default applicationWizardContext; export default applicationWizardContext;

View File

@ -3,9 +3,9 @@ import "@goauthentik/components/ak-wizard-main";
import { AKElement } from "@goauthentik/elements/Base"; import { AKElement } from "@goauthentik/elements/Base";
import { CustomListenerElement } from "@goauthentik/elements/utils/eventEmitter"; import { CustomListenerElement } from "@goauthentik/elements/utils/eventEmitter";
import { provide } from "@lit-labs/context"; import { ContextProvider, ContextRoot } from "@lit-labs/context";
import { msg } from "@lit/localize"; import { msg } from "@lit/localize";
import { CSSResult, TemplateResult, html } from "lit"; import { CSSResult, html } from "lit";
import { customElement, property, state } from "lit/decorators.js"; import { customElement, property, state } from "lit/decorators.js";
import PFButton from "@patternfly/patternfly/components/Button/button.css"; import PFButton from "@patternfly/patternfly/components/Button/button.css";
@ -27,19 +27,22 @@ export class ApplicationWizard extends CustomListenerElement(AKElement) {
return [PFBase, PFButton, PFRadio]; return [PFBase, PFButton, PFRadio];
} }
/**
* Providing a context at the root element
*/
@provide({ context: applicationWizardContext })
@state() @state()
wizardState: WizardState = { wizardState: WizardState = {
step: 0, step: 0,
providerType: "", providerModel: "",
application: {}, app: {},
provider: {}, provider: {},
}; };
@state() /**
* Providing a context at the root element
*/
wizardStateProvider = new ContextProvider(this, {
context: applicationWizardContext,
initialValue: this.wizardState,
});
steps = steps; steps = steps;
@property() @property()
@ -54,6 +57,7 @@ export class ApplicationWizard extends CustomListenerElement(AKElement) {
connectedCallback() { connectedCallback() {
super.connectedCallback(); super.connectedCallback();
new ContextRoot().attach(this.parentElement!);
this.addCustomListener("ak-application-wizard-update", this.handleUpdate); this.addCustomListener("ak-application-wizard-update", this.handleUpdate);
} }
@ -68,14 +72,14 @@ export class ApplicationWizard extends CustomListenerElement(AKElement) {
// Are we changing provider type? If so, swap the caches of the various provider types the // Are we changing provider type? If so, swap the caches of the various provider types the
// user may have filled in, and enable the next step. // user may have filled in, and enable the next step.
const providerType = update.providerType; const providerModel = update.providerModel;
if ( if (
providerType && providerModel &&
typeof providerType === "string" && typeof providerModel === "string" &&
providerType !== this.wizardState.providerType providerModel !== this.wizardState.providerModel
) { ) {
this.providerCache.set(this.wizardState.providerType, this.wizardState.provider); this.providerCache.set(this.wizardState.providerModel, this.wizardState.provider);
const prevProvider = this.providerCache.get(providerType); const prevProvider = this.providerCache.get(providerModel);
this.wizardState.provider = prevProvider ?? {}; this.wizardState.provider = prevProvider ?? {};
const newSteps = [...this.steps]; const newSteps = [...this.steps];
const method = newSteps.find(({ id }) => id === "auth-method"); const method = newSteps.find(({ id }) => id === "auth-method");
@ -87,10 +91,10 @@ export class ApplicationWizard extends CustomListenerElement(AKElement) {
} }
this.wizardState = merge(this.wizardState, update) as WizardState; this.wizardState = merge(this.wizardState, update) as WizardState;
console.log(JSON.stringify(this.wizardState, null, 2)); this.wizardStateProvider.setValue(this.wizardState);
} }
render(): TemplateResult { render() {
return html` return html`
<ak-wizard-main <ak-wizard-main
.steps=${this.steps} .steps=${this.steps}

View File

@ -1,6 +1,7 @@
import { policyOptions } from "@goauthentik/admin/applications/ApplicationForm"; import { policyOptions } from "@goauthentik/admin/applications/ApplicationForm";
import { first } from "@goauthentik/common/utils"; import { first } from "@goauthentik/common/utils";
import "@goauthentik/components/ak-radio-input"; import "@goauthentik/components/ak-radio-input";
import "@goauthentik/components/ak-slug-input";
import "@goauthentik/components/ak-switch-input"; import "@goauthentik/components/ak-switch-input";
import "@goauthentik/components/ak-text-input"; import "@goauthentik/components/ak-text-input";
import "@goauthentik/elements/forms/FormGroup"; import "@goauthentik/elements/forms/FormGroup";
@ -21,34 +22,41 @@ export class ApplicationWizardApplicationDetails extends BasePanel {
console.warn(`Received event with no target: ${ev}`); console.warn(`Received event with no target: ${ev}`);
return; return;
} }
const target = ev.target as HTMLInputElement; const target = ev.target as HTMLInputElement;
const value = target.type === "checkbox" ? target.checked : target.value; const value = target.type === "checkbox" ? target.checked : target.value;
this.dispatchWizardUpdate({ this.dispatchWizardUpdate({
application: { app: {
[target.name]: value, [target.name]: value,
}, },
}); });
} }
validator() {
return this.form.reportValidity();
}
render(): TemplateResult { render(): TemplateResult {
return html` <form class="pf-c-form pf-m-horizontal" @input=${this.handleChange}> return html` <form class="pf-c-form pf-m-horizontal" @input=${this.handleChange}>
<ak-text-input <ak-text-input
name="name" name="name"
value=${ifDefined(this.wizard.application?.name)} value=${ifDefined(this.wizard.app?.name)}
label=${msg("Name")} label=${msg("Name")}
required required
help=${msg("Application's display Name.")} help=${msg("Application's display Name.")}
id="ak-application-wizard-details-name"
></ak-text-input> ></ak-text-input>
<ak-text-input <ak-slug-input
name="slug" name="slug"
value=${ifDefined(this.wizard.application?.slug)} value=${ifDefined(this.wizard.app?.slug)}
label=${msg("Slug")} label=${msg("Slug")}
source="#ak-application-wizard-details-name"
required required
help=${msg("Internal application name used in URLs.")} help=${msg("Internal application name used in URLs.")}
></ak-text-input> ></ak-slug-input>
<ak-text-input <ak-text-input
name="group" name="group"
value=${ifDefined(this.wizard.application?.group)} value=${ifDefined(this.wizard.app?.group)}
label=${msg("Group")} label=${msg("Group")}
help=${msg( help=${msg(
"Optionally enter a group name. Applications with identical groups are shown grouped together.", "Optionally enter a group name. Applications with identical groups are shown grouped together.",
@ -59,7 +67,7 @@ export class ApplicationWizardApplicationDetails extends BasePanel {
required required
name="policyEngineMode" name="policyEngineMode"
.options=${policyOptions} .options=${policyOptions}
.value=${this.wizard.application?.policyEngineMode} .value=${this.wizard.app?.policyEngineMode}
></ak-radio-input> ></ak-radio-input>
<ak-form-group> <ak-form-group>
<span slot="header"> ${msg("UI settings")} </span> <span slot="header"> ${msg("UI settings")} </span>
@ -67,14 +75,14 @@ export class ApplicationWizardApplicationDetails extends BasePanel {
<ak-text-input <ak-text-input
name="metaLaunchUrl" name="metaLaunchUrl"
label=${msg("Launch URL")} label=${msg("Launch URL")}
value=${ifDefined(this.wizard.application?.metaLaunchUrl)} value=${ifDefined(this.wizard.app?.metaLaunchUrl)}
help=${msg( help=${msg(
"If left empty, authentik will try to extract the launch URL based on the selected provider.", "If left empty, authentik will try to extract the launch URL based on the selected provider.",
)} )}
></ak-text-input> ></ak-text-input>
<ak-switch-input <ak-switch-input
name="openInNewTab" name="openInNewTab"
?checked=${first(this.wizard.application?.openInNewTab, false)} ?checked=${first(this.wizard.app?.openInNewTab, false)}
label=${msg("Open in new tab")} label=${msg("Open in new tab")}
help=${msg( help=${msg(
"If checked, the launch URL will open in a new browser tab or window from the user's application library.", "If checked, the launch URL will open in a new browser tab or window from the user's application library.",

View File

@ -1,70 +1,132 @@
import { msg } from "@lit/localize"; import { msg } from "@lit/localize";
import { TemplateResult, html } from "lit"; import { TemplateResult, html } from "lit";
import type { TypeCreate } from "@goauthentik/api"; import type { ProviderModelEnum as ProviderModelEnumType, TypeCreate } from "@goauthentik/api";
import { ProviderModelEnum } from "@goauthentik/api";
import type {
LDAPProviderRequest,
ModelRequest,
OAuth2ProviderRequest,
ProxyProviderRequest,
SAMLProviderRequest,
} from "@goauthentik/api";
import { OneOfProvider } from "../types";
type ProviderRenderer = () => TemplateResult; type ProviderRenderer = () => TemplateResult;
type ProviderType = [string, string, string, ProviderRenderer]; type ProviderType = [string, string, string, ProviderRenderer, ProviderModelEnumType];
type ModelConverter = (provider: OneOfProvider) => ModelRequest;
export type LocalTypeCreate = TypeCreate & {
formName: string;
modelName: ProviderModelEnumType;
converter: ModelConverter;
};
// prettier-ignore // prettier-ignore
const _providerTypesTable: ProviderType[] = [ const _providerModelsTable: ProviderType[] = [
[ [
"oauth2provider", "oauth2provider",
msg("OAuth2/OpenID"), msg("OAuth2/OpenID"),
msg("Modern applications, APIs and Single-page applications."), msg("Modern applications, APIs and Single-page applications."),
() => html`<ak-application-wizard-authentication-by-oauth></ak-application-wizard-authentication-by-oauth>` () => html`<ak-application-wizard-authentication-by-oauth></ak-application-wizard-authentication-by-oauth>`,
ProviderModelEnum.Oauth2Oauth2provider,
], ],
[ [
"ldapprovider", "ldapprovider",
msg("LDAP"), msg("LDAP"),
msg("Provide an LDAP interface for applications and users to authenticate against."), msg("Provide an LDAP interface for applications and users to authenticate against."),
() => html`<ak-application-wizard-authentication-by-ldap></ak-application-wizard-authentication-by-ldap>` () => html`<ak-application-wizard-authentication-by-ldap></ak-application-wizard-authentication-by-ldap>`,
ProviderModelEnum.LdapLdapprovider,
], ],
[ [
"proxyprovider-proxy", "proxyprovider-proxy",
msg("Transparent Reverse Proxy"), msg("Transparent Reverse Proxy"),
msg("For transparent reverse proxies with required authentication"), msg("For transparent reverse proxies with required authentication"),
() => html`<ak-application-wizard-authentication-for-reverse-proxy></ak-application-wizard-authentication-for-reverse-proxy>` () => html`<ak-application-wizard-authentication-for-reverse-proxy></ak-application-wizard-authentication-for-reverse-proxy>`,
ProviderModelEnum.ProxyProxyprovider
], ],
[ [
"proxyprovider-forwardsingle", "proxyprovider-forwardsingle",
msg("Forward Single Proxy"), msg("Forward Single Proxy"),
msg("For nginx's auth_request or traefix's forwardAuth"), msg("For nginx's auth_request or traefix's forwardAuth"),
() => html`<ak-application-wizard-authentication-for-single-forward-proxy></ak-application-wizard-authentication-for-single-forward-proxy>` () => html`<ak-application-wizard-authentication-for-single-forward-proxy></ak-application-wizard-authentication-for-single-forward-proxy>`,
], ProviderModelEnum.ProxyProxyprovider
],
[ [
"samlprovider-manual", "samlprovider-manual",
msg("SAML Manual configuration"), msg("SAML Manual configuration"),
msg("Configure SAML provider manually"), msg("Configure SAML provider manually"),
() => html`<p>Under construction</p>` () => html`<p>Under construction</p>`,
ProviderModelEnum.SamlSamlprovider
], ],
[ [
"samlprovider-import", "samlprovider-import",
msg("SAML Import Configuration"), msg("SAML Import Configuration"),
msg("Create a SAML provider by importing its metadata"), msg("Create a SAML provider by importing its metadata"),
() => html`<p>Under construction</p>` () => html`<p>Under construction</p>`,
ProviderModelEnum.SamlSamlprovider
], ],
]; ];
function mapProviders([modelName, name, description]: ProviderType): TypeCreate { const converters = new Map<ProviderModelEnumType, ModelConverter>([
[
ProviderModelEnum.Oauth2Oauth2provider,
(provider: OneOfProvider) => ({
providerModel: ProviderModelEnum.Oauth2Oauth2provider,
...(provider as OAuth2ProviderRequest),
}),
],
[
ProviderModelEnum.LdapLdapprovider,
(provider: OneOfProvider) => ({
providerModel: ProviderModelEnum.LdapLdapprovider,
...(provider as LDAPProviderRequest),
}),
],
[
ProviderModelEnum.ProxyProxyprovider,
(provider: OneOfProvider) => ({
providerModel: ProviderModelEnum.ProxyProxyprovider,
...(provider as ProxyProviderRequest),
}),
],
[
ProviderModelEnum.SamlSamlprovider,
(provider: OneOfProvider) => ({
providerModel: ProviderModelEnum.SamlSamlprovider,
...(provider as SAMLProviderRequest),
}),
],
]);
// Contract enforcement
const getConverter = (modelName: ProviderModelEnumType): ModelConverter => {
const maybeConverter = converters.get(modelName);
if (!maybeConverter) {
throw new Error(`ModelName lookup failed in model converter definition: ${"modelName"}`);
}
return maybeConverter;
};
function mapProviders([formName, name, description, _, modelName]: ProviderType): LocalTypeCreate {
return { return {
modelName, formName,
name, name,
description, description,
component: "", component: "",
modelName,
converter: getConverter(modelName),
}; };
} }
export const providerTypesList = _providerTypesTable.map(mapProviders); export const providerModelsList = _providerModelsTable.map(mapProviders);
export const providerRendererList = new Map<string, ProviderRenderer>( export const providerRendererList = new Map<string, ProviderRenderer>(
_providerTypesTable.map(([modelName, _0, _1, renderer]) => [modelName, renderer]), _providerModelsTable.map(([modelName, _0, _1, renderer]) => [modelName, renderer]),
); );
export default providerTypesList; export default providerModelsList;

View File

@ -10,10 +10,9 @@ import { customElement } from "@lit/reactive-element/decorators/custom-element.j
import { html } from "lit"; import { html } from "lit";
import { map } from "lit/directives/map.js"; import { map } from "lit/directives/map.js";
import type { TypeCreate } from "@goauthentik/api";
import BasePanel from "../BasePanel"; import BasePanel from "../BasePanel";
import providerTypesList from "./ak-application-wizard-authentication-method-choice.choices"; import providerModelsList from "./ak-application-wizard-authentication-method-choice.choices";
import type { LocalTypeCreate } from "./ak-application-wizard-authentication-method-choice.choices";
@customElement("ak-application-wizard-authentication-method-choice") @customElement("ak-application-wizard-authentication-method-choice")
export class ApplicationWizardAuthenticationMethodChoice extends BasePanel { export class ApplicationWizardAuthenticationMethodChoice extends BasePanel {
@ -25,31 +24,31 @@ export class ApplicationWizardAuthenticationMethodChoice extends BasePanel {
handleChoice(ev: InputEvent) { handleChoice(ev: InputEvent) {
const target = ev.target as HTMLInputElement; const target = ev.target as HTMLInputElement;
this.dispatchWizardUpdate({ providerType: target.value }); this.dispatchWizardUpdate({ providerModel: target.value });
} }
renderProvider(type: TypeCreate) { renderProvider(type: LocalTypeCreate) {
const method = this.wizard.providerType; const method = this.wizard.providerModel;
return html`<div class="pf-c-radio"> return html`<div class="pf-c-radio">
<input <input
class="pf-c-radio__input" class="pf-c-radio__input"
type="radio" type="radio"
name="type" name="type"
id="provider-${type.modelName}" id="provider-${type.formName}"
value=${type.modelName} value=${type.formName}
?checked=${type.modelName === method} ?checked=${type.formName === method}
@change=${this.handleChoice} @change=${this.handleChoice}
/> />
<label class="pf-c-radio__label" for="provider-${type.modelName}">${type.name}</label> <label class="pf-c-radio__label" for="provider-${type.formName}">${type.name}</label>
<span class="pf-c-radio__description">${type.description}</span> <span class="pf-c-radio__description">${type.description}</span>
</div>`; </div>`;
} }
render() { render() {
return providerTypesList.length > 0 return providerModelsList.length > 0
? html`<form class="pf-c-form pf-m-horizontal"> ? html`<form class="pf-c-form pf-m-horizontal">
${map(providerTypesList, this.renderProvider)} ${map(providerModelsList, this.renderProvider)}
</form>` </form>`
: html`<ak-empty-state loading header=${msg("Loading")}></ak-empty-state>`; : html`<ak-empty-state loading header=${msg("Loading")}></ak-empty-state>`;
} }

View File

@ -1,5 +1,4 @@
import { policyOptions } from "@goauthentik/admin/applications/ApplicationForm"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { first } from "@goauthentik/common/utils";
import "@goauthentik/components/ak-radio-input"; import "@goauthentik/components/ak-radio-input";
import "@goauthentik/components/ak-switch-input"; import "@goauthentik/components/ak-switch-input";
import "@goauthentik/components/ak-text-input"; import "@goauthentik/components/ak-text-input";
@ -7,84 +6,91 @@ import "@goauthentik/elements/forms/FormGroup";
import "@goauthentik/elements/forms/FormGroup"; import "@goauthentik/elements/forms/FormGroup";
import "@goauthentik/elements/forms/HorizontalFormElement"; import "@goauthentik/elements/forms/HorizontalFormElement";
import { msg } from "@lit/localize";
import { customElement } from "@lit/reactive-element/decorators/custom-element.js"; import { customElement } from "@lit/reactive-element/decorators/custom-element.js";
import { TemplateResult, html } from "lit"; import { TemplateResult, html } from "lit";
import { ifDefined } from "lit/directives/if-defined.js";
import {
ApplicationRequest,
CoreApi,
TransactionApplicationRequest,
TransactionApplicationResponse,
} from "@goauthentik/api";
import type { ModelRequest } from "@goauthentik/api";
import BasePanel from "../BasePanel"; import BasePanel from "../BasePanel";
import providerModelsList from "../auth-method-choice/ak-application-wizard-authentication-method-choice.choices";
function cleanApplication(app: Partial<ApplicationRequest>): ApplicationRequest {
return {
name: "",
slug: "",
...app,
};
}
type ProviderModelType = Exclude<ModelRequest["providerModel"], "11184809">;
@customElement("ak-application-wizard-commit-application") @customElement("ak-application-wizard-commit-application")
export class ApplicationWizardCommitApplication extends BasePanel { export class ApplicationWizardCommitApplication extends BasePanel {
handleChange(ev: Event) { state: "idle" | "running" | "done" = "idle";
if (!ev.target) { response?: TransactionApplicationResponse;
console.warn(`Received event with no target: ${ev}`);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
willUpdate(_changedProperties: Map<string, any>) {
if (this.state === "idle") {
this.response = undefined;
this.state = "running";
const provider = providerModelsList.find(
({ formName }) => formName === this.wizard.providerModel,
);
if (!provider) {
throw new Error(
`Could not determine provider model from user request: ${JSON.stringify(
this.wizard,
null,
2,
)}`,
);
}
const request: TransactionApplicationRequest = {
providerModel: provider.modelName as ProviderModelType,
app: cleanApplication(this.wizard.app),
provider: provider.converter(this.wizard.provider),
};
this.send(request);
return; return;
} }
const target = ev.target as HTMLInputElement; }
const value = target.type === "checkbox" ? target.checked : target.value;
this.dispatchWizardUpdate({ async send(
application: { data: TransactionApplicationRequest,
[target.name]: value, ): Promise<TransactionApplicationResponse | void> {
}, new CoreApi(DEFAULT_CONFIG)
}); .coreTransactionalApplicationsUpdate({ transactionApplicationRequest: data })
.then(
(response) => {
this.response = response;
this.state = "done";
},
(error) => {
console.log(error);
},
);
} }
render(): TemplateResult { render(): TemplateResult {
return html` <form class="pf-c-form pf-m-horizontal" @input=${this.handleChange}> return html`
<ak-text-input <div>
name="name" <h3>Current result:</h3>
value=${ifDefined(this.wizard.application?.name)} <p>State: ${this.state}</p>
label=${msg("Name")} <pre>${JSON.stringify(this.wizard, null, 2)}</pre>
required <p>Response:</p>
help=${msg("Application's display Name.")} <pre>${JSON.stringify(this.response, null, 2)}</pre>
></ak-text-input> </div>
<ak-text-input `;
name="slug"
value=${ifDefined(this.wizard.application?.slug)}
label=${msg("Slug")}
required
help=${msg("Internal application name used in URLs.")}
></ak-text-input>
<ak-text-input
name="group"
value=${ifDefined(this.wizard.application?.group)}
label=${msg("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=${this.wizard.application?.policyEngineMode}
></ak-radio-input>
<ak-form-group>
<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(this.wizard.application?.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=${first(this.wizard.application?.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>`;
} }
} }
export default ApplicationWizardApplicationDetails; export default ApplicationWizardCommitApplication;

View File

@ -14,6 +14,10 @@ export class ApplicationWizardProviderPageBase extends BasePanel {
}, },
}); });
} }
validator() {
return this.form.reportValidity();
}
} }
export default ApplicationWizardProviderPageBase; export default ApplicationWizardProviderPageBase;

View File

@ -12,7 +12,7 @@ import "./proxy/ak-application-wizard-authentication-for-single-forward-proxy";
@customElement("ak-application-wizard-authentication-method") @customElement("ak-application-wizard-authentication-method")
export class ApplicationWizardApplicationDetails extends BasePanel { export class ApplicationWizardApplicationDetails extends BasePanel {
render() { render() {
const handler = providerRendererList.get(this.wizard.providerType); const handler = providerRendererList.get(this.wizard.providerModel);
if (!handler) { if (!handler) {
throw new Error( throw new Error(
"Unrecognized authentication method in ak-application-wizard-authentication-method", "Unrecognized authentication method in ak-application-wizard-authentication-method",

View File

@ -5,6 +5,7 @@ import { html } from "lit";
import "./application/ak-application-wizard-application-details"; import "./application/ak-application-wizard-application-details";
import "./auth-method-choice/ak-application-wizard-authentication-method-choice"; import "./auth-method-choice/ak-application-wizard-authentication-method-choice";
import "./commit/ak-application-wizard-commit-application";
import "./methods/ak-application-wizard-authentication-method"; import "./methods/ak-application-wizard-authentication-method";
export const steps: WizardStep[] = [ export const steps: WizardStep[] = [
@ -47,5 +48,4 @@ export const steps: WizardStep[] = [
backButtonLabel: msg("Back"), backButtonLabel: msg("Back"),
valid: true, valid: true,
}, },
]; ];

View File

@ -47,7 +47,7 @@ const container = (testItem: TemplateResult) => {
export const MainPage = () => { export const MainPage = () => {
return container(html` return container(html`
<ak-application-wizard>></ak-application-wizard> <ak-application-wizard></ak-application-wizard>
<hr /> <hr />
<ak-application-context-display-for-test></ak-application-context-display-for-test> <ak-application-context-display-for-test></ak-application-context-display-for-test>
`); `);

View File

@ -1,25 +1,25 @@
import { import {
Application, ApplicationRequest,
LDAPProvider, LDAPProviderRequest,
OAuth2Provider, OAuth2ProviderRequest,
ProxyProvider, ProxyProviderRequest,
RadiusProvider, RadiusProviderRequest,
SAMLProvider, SAMLProviderRequest,
SCIMProvider, SCIMProviderRequest,
} from "@goauthentik/api"; } from "@goauthentik/api";
export type OneOfProvider = export type OneOfProvider =
| Partial<SCIMProvider> | Partial<SCIMProviderRequest>
| Partial<SAMLProvider> | Partial<SAMLProviderRequest>
| Partial<RadiusProvider> | Partial<RadiusProviderRequest>
| Partial<ProxyProvider> | Partial<ProxyProviderRequest>
| Partial<OAuth2Provider> | Partial<OAuth2ProviderRequest>
| Partial<LDAPProvider>; | Partial<LDAPProviderRequest>;
export interface WizardState { export interface WizardState {
step: number; step: number;
providerType: string; providerModel: string;
application: Partial<Application>; app: Partial<ApplicationRequest>;
provider: OneOfProvider; provider: OneOfProvider;
} }

View File

@ -1,28 +0,0 @@
import { ReactiveController } from 'lit';
import type { ReactiveControllerHost } from 'lit';
export class ApplicationWizardController implements ReactiveController {
host: ReactiveControllerHost;
value = new Date();
timeout: number;
private _timerID?: number;
constructor(host: ReactiveControllerHost, timeout = 1000) {
(this.host = host).addController(this);
this.timeout = timeout;
}
hostConnected() {
// Start a timer when the host is connected
this._timerID = setInterval(() => {
this.value = new Date();
// Update the host with new value
this.host.requestUpdate();
}, this.timeout);
}
hostDisconnected() {
// Clear the timer when the host is disconnected
clearInterval(this._timerID);
this._timerID = undefined;
}
}

View File

@ -0,0 +1,162 @@
import { convertToSlug } from "@goauthentik/common/utils";
import { AKElement } from "@goauthentik/elements/Base";
import { TemplateResult, html, nothing } from "lit";
import { customElement, property, query } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
@customElement("ak-slug-input")
export class AkSlugInput extends AKElement {
// Render into the lightDOM. This effectively erases the shadowDOM nature of this component, but
// we're not actually using that and, for the meantime, we need the form handlers to be able to
// find the children of this component.
//
// TODO: This abstraction is wrong; it's putting *more* layers in as a way of managing the
// visual clutter and legibility issues of ak-form-elemental-horizontal and patternfly in
// general.
protected createRenderRoot() {
return this;
}
@property({ type: String })
name!: string;
@property({ type: String })
label = "";
@property({ type: String, reflect: true })
value = "";
@property({ type: Boolean })
required = false;
@property({ type: String })
help = "";
@property({ type: Boolean })
hidden = false;
@property({ type: Object })
bighelp!: TemplateResult | TemplateResult[];
@property({ type: String })
source = "";
origin?: HTMLInputElement | null;
@query("input")
input!: HTMLInputElement;
touched: boolean = false;
constructor() {
super();
this.slugify = this.slugify.bind(this);
this.handleTouch = this.handleTouch.bind(this);
}
firstUpdated() {
this.input.addEventListener("input", this.handleTouch);
}
renderHelp() {
return [
this.help ? html`<p class="pf-c-form__helper-text">${this.help}</p>` : nothing,
this.bighelp ? this.bighelp : nothing,
];
}
// Do not stop propagation of this event; it must be sent up the tree so that a parent
// component, such as a custom forms manager, may receive it.
handleTouch(ev: Event) {
this.input.value = convertToSlug(this.input.value);
this.value = this.input.value;
if (this.origin && this.origin.value === "" && this.input.value === "") {
this.touched = false;
return;
}
if (ev && ev.target && ev.target instanceof HTMLInputElement) {
this.touched = true;
}
}
slugify(ev: Event) {
// A very primitive heuristic: if the previous iteration of the slug and the current
// iteration are *similar enough*, set the input value. "Similar enough" here is defined as
// "any event which adds or removes a character but leaves the rest of the slug looking like
// the previous iteration, set it to the current iteration."
if (ev && ev.target && ev.target instanceof HTMLInputElement) {
if (this.touched) {
if (ev.target.value === "" && this.input.value === "") {
this.touched = false;
} else {
return;
}
}
const newSlug = convertToSlug(ev.target.value);
const oldSlug = this.input.value;
const [shorter, longer] =
newSlug.length < oldSlug.length ? [newSlug, oldSlug] : [oldSlug, newSlug];
if (longer.substring(0, shorter.length) === shorter) {
this.input.value = newSlug;
// The browser, as a security measure, sets the originating HTML object to be the
// target; developers cannot change it. In order to provide a meaningful value
// to listeners, both the name and value of the host must match those of the target
// input. The name is already handled since it's both required and automatically
// forwarded to our templated input, but the value must also be set.
this.value = this.input.value;
this.dispatchEvent(
new Event("input", {
bubbles: true,
cancelable: true,
}),
);
}
}
}
connectedCallback() {
super.connectedCallback();
// Set up listener on source element, so we can slugify the content.
setTimeout(() => {
if (this.source) {
const rootNode = this.getRootNode();
if (rootNode instanceof ShadowRoot || rootNode instanceof Document) {
this.origin = rootNode.querySelector(this.source);
}
if (this.origin) {
this.origin.addEventListener("input", this.slugify);
}
}
}, 0);
}
disconnectedCallback() {
if (this.origin) {
this.origin.removeEventListener("input", this.slugify);
}
super.disconnectedCallback();
}
render() {
return html`<ak-form-element-horizontal
label=${this.label}
?required=${this.required}
?hidden=${this.hidden}
name=${this.name}
>
<input
type="text"
value=${ifDefined(this.value)}
class="pf-c-form-control"
?required=${this.required}
/>
${this.renderHelp()}
</ak-form-element-horizontal> `;
}
}

View File

@ -1,16 +1,13 @@
import { ModalButton } from "@goauthentik/elements/buttons/ModalButton"; import { ModalButton } from "@goauthentik/elements/buttons/ModalButton";
import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter"; import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter";
import { consume } from "@lit-labs/context";
import { msg } from "@lit/localize"; import { msg } from "@lit/localize";
import { customElement, property, state } from "@lit/reactive-element/decorators.js"; import { customElement, property, query } from "@lit/reactive-element/decorators.js";
import { html, nothing } from "lit"; import { html, nothing } from "lit";
import { classMap } from "lit/directives/class-map.js"; import { classMap } from "lit/directives/class-map.js";
import PFWizard from "@patternfly/patternfly/components/Wizard/wizard.css"; import PFWizard from "@patternfly/patternfly/components/Wizard/wizard.css";
import { akWizardCurrentStepContextName } from "./akWizardCurrentStepContextName";
import { akWizardStepsContextName } from "./akWizardStepsContextName";
import type { WizardStep } from "./types"; import type { WizardStep } from "./types";
/** /**
@ -49,16 +46,15 @@ export class AkWizardFrame extends CustomEmitterElement(ModalButton) {
@property() @property()
eventName: string = "ak-wizard-nav"; eventName: string = "ak-wizard-nav";
// @ts-expect-error @property({ attribute: false, type: Array })
@consume({ context: akWizardStepsContextName, subscribe: true })
@state()
steps!: WizardStep[]; steps!: WizardStep[];
// @ts-expect-error @property({ attribute: false, type: Object })
@consume({ context: akWizardCurrentStepContextName, subscribe: true })
@state()
currentStep!: WizardStep; currentStep!: WizardStep;
@query("#main-content *:first-child")
content!: HTMLElement;
reset() { reset() {
this.open = false; this.open = false;
} }
@ -141,7 +137,9 @@ export class AkWizardFrame extends CustomEmitterElement(ModalButton) {
// independent context. // independent context.
renderMainSection() { renderMainSection() {
return html`<main class="pf-c-wizard__main"> return html`<main class="pf-c-wizard__main">
<div class="pf-c-wizard__main-body">${this.currentStep.renderer()}</div> <div id="main-content" class="pf-c-wizard__main-body">
${this.currentStep.renderer()}
</div>
</main>`; </main>`;
} }
@ -159,23 +157,22 @@ export class AkWizardFrame extends CustomEmitterElement(ModalButton) {
return html`<button return html`<button
class="pf-c-button pf-m-primary" class="pf-c-button pf-m-primary"
type="submit" type="submit"
?disabled=${!this.currentStep.valid} @click=${() =>
@click=${() => this.dispatchCustomEvent(this.eventName, { step: nextStep.id })} this.dispatchCustomEvent(this.eventName, { step: nextStep.id, action: "next" })}
> >
${this.currentStep.nextButtonLabel} ${this.currentStep.nextButtonLabel}
</button>`; </button>`;
} }
renderFooterBackButton(backStep: WizardStep) { renderFooterBackButton(backStep: WizardStep) {
return html` return html`<button
<button class="pf-c-button pf-m-secondary"
class="pf-c-button pf-m-secondary" type="button"
type="button" @click=${() =>
@click=${() => this.dispatchCustomEvent(this.eventName, { step: backStep.id })} this.dispatchCustomEvent(this.eventName, { step: backStep.id, action: "back" })}
> >
${this.currentStep.backButtonLabel} ${this.currentStep.backButtonLabel}
</button> </button> `;
`;
} }
renderFooterCancelButton() { renderFooterCancelButton() {

View File

@ -1,9 +1,8 @@
import { AKElement } from "@goauthentik/elements/Base"; import { AKElement } from "@goauthentik/elements/Base";
import { CustomListenerElement } from "@goauthentik/elements/utils/eventEmitter"; import { CustomListenerElement } from "@goauthentik/elements/utils/eventEmitter";
import { provide } from "@lit-labs/context";
import { html } from "lit"; import { html } from "lit";
import { customElement, property, state } from "lit/decorators.js"; import { customElement, property, query, state } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js"; import { ifDefined } from "lit/directives/if-defined.js";
import PFButton from "@patternfly/patternfly/components/Button/button.css"; import PFButton from "@patternfly/patternfly/components/Button/button.css";
@ -11,9 +10,16 @@ import PFRadio from "@patternfly/patternfly/components/Radio/radio.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css"; import PFBase from "@patternfly/patternfly/patternfly-base.css";
import "./ak-wizard-frame"; import "./ak-wizard-frame";
import { akWizardCurrentStepContextName } from "./akWizardCurrentStepContextName"; import { AkWizardFrame } from "./ak-wizard-frame";
import { akWizardStepsContextName } from "./akWizardStepsContextName"; import type { WizardPanel, WizardStep } from "./types";
import type { WizardStep } from "./types";
// Not just a check that it has a validator, but a check that satisfies Typescript that we're using
// it correctly; anything within the hasValidator conditional block will know it's dealing with
// a fully operational WizardPanel.
//
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const hasValidator = (v: any): v is Required<Pick<WizardPanel, "validator">> =>
"validator" in v && typeof v.validator === "function";
/** /**
* AKWizardMain * AKWizardMain
@ -41,7 +47,6 @@ export class AkWizardMain extends CustomListenerElement(AKElement) {
* *
* @attribute * @attribute
*/ */
@provide({ context: akWizardStepsContextName })
@property({ attribute: false }) @property({ attribute: false })
steps: WizardStep[] = []; steps: WizardStep[] = [];
@ -50,7 +55,6 @@ export class AkWizardMain extends CustomListenerElement(AKElement) {
* *
* @attribute * @attribute
*/ */
@provide({ context: akWizardCurrentStepContextName })
@state() @state()
currentStep!: WizardStep; currentStep!: WizardStep;
@ -91,6 +95,9 @@ export class AkWizardMain extends CustomListenerElement(AKElement) {
@property() @property()
description?: string; description?: string;
@query("ak-wizard-frame")
frame!: AkWizardFrame;
// Guarantee that if the current step was not passed in by the client, that we know // Guarantee that if the current step was not passed in by the client, that we know
// and set to the first step. // and set to the first step.
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -117,7 +124,7 @@ export class AkWizardMain extends CustomListenerElement(AKElement) {
// before setting the currentStep. Especially since setting the currentStep triggers a second // before setting the currentStep. Especially since setting the currentStep triggers a second
// asynchronous event-- scheduling a re-render of everything interested in the currentStep // asynchronous event-- scheduling a re-render of everything interested in the currentStep
// object. // object.
handleNavigation(event: CustomEvent<{ step: string }>) { handleNavigation(event: CustomEvent<{ step: string; action: string }>) {
const requestedStep = event.detail.step; const requestedStep = event.detail.step;
if (!requestedStep) { if (!requestedStep) {
throw new Error("Request for next step when no next step is available"); throw new Error("Request for next step when no next step is available");
@ -126,11 +133,18 @@ export class AkWizardMain extends CustomListenerElement(AKElement) {
if (!step) { if (!step) {
throw new Error("Request for next step when no next step is available."); throw new Error("Request for next step when no next step is available.");
} }
if (step.disabled) { if (event.detail.action === "next" && !this.validated()) {
throw new Error("Request for next step when the next step is disabled."); return false;
} }
this.currentStep = step; this.currentStep = step;
return; return true;
}
validated() {
if (hasValidator(this.frame.content)) {
return this.frame.content.validator();
}
return true;
} }
render() { render() {
@ -140,6 +154,8 @@ export class AkWizardMain extends CustomListenerElement(AKElement) {
header=${this.header} header=${this.header}
description=${ifDefined(this.description)} description=${ifDefined(this.description)}
eventName=${this.eventName} eventName=${this.eventName}
.steps=${this.steps}
.currentStep=${this.currentStep}
> >
<button slot="trigger" class="pf-c-button pf-m-primary">${this.prompt}</button> <button slot="trigger" class="pf-c-button pf-m-primary">${this.prompt}</button>
</ak-wizard-frame> </ak-wizard-frame>

View File

@ -1,5 +1,9 @@
import { createContext } from "@lit-labs/context"; import { createContext } from "@lit-labs/context";
export const akWizardCurrentStepContextName = createContext(Symbol("ak-wizard-current-step")); import { WizardStep } from "./types";
export const akWizardCurrentStepContextName = createContext<WizardStep>(
Symbol("ak-wizard-current-step"),
);
export default akWizardCurrentStepContextName; export default akWizardCurrentStepContextName;

View File

@ -1,5 +1,7 @@
import { createContext } from "@lit-labs/context"; import { createContext } from "@lit-labs/context";
export const akWizardStepsContextName = createContext(Symbol("ak-wizard-steps")); import { WizardStep } from "./types";
export const akWizardStepsContextName = createContext<WizardStep[]>(Symbol("ak-wizard-steps"));
export default akWizardStepsContextName; export default akWizardStepsContextName;

View File

@ -9,3 +9,7 @@ export interface WizardStep {
nextButtonLabel?: string; nextButtonLabel?: string;
backButtonLabel?: string; backButtonLabel?: string;
} }
export interface WizardPanel extends HTMLElement {
validator?: () => boolean;
}

View File

@ -45,7 +45,6 @@ export const ButtonWithSuccess = () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const displayChange = (ev: any) => { const displayChange = (ev: any) => {
console.log(ev.type, ev.target.name, ev.target.value, ev.detail);
document.getElementById("radio-message-pad")!.innerText = `Value selected: ${JSON.stringify( document.getElementById("radio-message-pad")!.innerText = `Value selected: ${JSON.stringify(
ev.target.value, ev.target.value,
null, null,

View File

@ -2,13 +2,11 @@ import { EVENT_LOCALE_CHANGE } from "@goauthentik/common/constants";
import { EVENT_LOCALE_REQUEST } from "@goauthentik/common/constants"; import { EVENT_LOCALE_REQUEST } from "@goauthentik/common/constants";
import { customEvent, isCustomEvent } from "@goauthentik/elements/utils/customEvents"; import { customEvent, isCustomEvent } from "@goauthentik/elements/utils/customEvents";
import { provide } from "@lit-labs/context";
import { LitElement, html } from "lit"; import { LitElement, html } from "lit";
import { customElement, property } from "lit/decorators.js"; import { customElement, property } from "lit/decorators.js";
import { initializeLocalization } from "./configureLocale"; import { initializeLocalization } from "./configureLocale";
import type { LocaleGetter, LocaleSetter } from "./configureLocale"; import type { LocaleGetter, LocaleSetter } from "./configureLocale";
import locale from "./context";
import { import {
DEFAULT_LOCALE, DEFAULT_LOCALE,
autoDetectLanguage, autoDetectLanguage,
@ -32,7 +30,6 @@ import {
@customElement("ak-locale-context") @customElement("ak-locale-context")
export class LocaleContext extends LitElement { export class LocaleContext extends LitElement {
/// @attribute The text representation of the current locale */ /// @attribute The text representation of the current locale */
@provide({ context: locale })
@property({ attribute: true, type: String }) @property({ attribute: true, type: String })
locale = DEFAULT_LOCALE; locale = DEFAULT_LOCALE;