It looks like my brilliant strategy has hit a snag.
The idea is simple. Let's start with this picture: ``` <application-wizard .steps=${[... a collection of step objects ...]}> <wizard-main .steps=${(steps from above)}> <application-current-panel> <current-form> ``` - ApplicationWizard has a Context for the ApplicationProviderPair (or whatever it's going to be). This context does not know about the steps; it just knows about: the "application" object, the "provider" object, and a discriminator to know *which* provider the user has selected. - ApplicationWizard has Steps that, among other things, provides Panels for: - Application - Pick Provider - Configure Provider - Submit ApplicationProviderPair to the back-end - The WizardFrame renders the CurrentPanel for the CurrentStep The CurrentPanel gets its data from the ApplicationWizard in the form of a Context. It then sends messages (events) to ApplicationWizard about the contents of each field as the user is filling out the form, so that the ApplicationWizard can record those in the ApplicationProviderPair for later submission. When a CurrentForm is valid, the ApplicationWizard updates the Steps object to show that the "Next button" on the Wizard is now available. In this way, the user can progress through the system. When they get to the last page, we can provide in the ApplicationWizard with the means to submit the form and/or send the user back to the page with the validation failure. Problem: The context is being updated in real-time, which is triggering re-renders of the form. This leads to focus problems as the fields that are not yet valid are triggering "focus grab" behavior. This is a classic problem with "controlled" inputs. What we really want is for the CurrentPanel to not re-render at all, but to behave like a normal, uncontrolled form, and let the browser do most of the work. We still want the [Next] button to enable when the form is valid enough to permit that. --- Other details: I've ripped out a lot of Jen's work, which is probably a mistake. It's still preserved elsewhere. I've also cleaned up the various wizardly things to try and look organized. It *looks* like it should work, it just... doesn't. Not yet.
This commit is contained in:
parent
c0294191ad
commit
356488809c
|
@ -4,9 +4,10 @@ import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter";
|
|||
import { consume } from "@lit-labs/context";
|
||||
import { query, state } from "@lit/reactive-element/decorators.js";
|
||||
|
||||
import { styles as AwadStyles } from "./ApplicationWizardCss";
|
||||
import type { WizardState } from "./ak-application-wizard-context";
|
||||
import { styles as AwadStyles } from "./BasePanel.css";
|
||||
|
||||
import { applicationWizardContext } from "./ak-application-wizard-context-name";
|
||||
import type { WizardState } from "./types";
|
||||
|
||||
export class ApplicationWizardPageBase extends CustomEmitterElement(AKElement) {
|
||||
static get styles() {
|
|
@ -1,85 +0,0 @@
|
|||
import { WizardPage } from "@goauthentik/elements/wizard/WizardPage";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { customElement } from "@lit/reactive-element/decorators/custom-element.js";
|
||||
import { CSSResult, TemplateResult, html } from "lit";
|
||||
|
||||
import PFButton from "@patternfly/patternfly/components/Button/button.css";
|
||||
import PFForm from "@patternfly/patternfly/components/Form/form.css";
|
||||
import PFRadio from "@patternfly/patternfly/components/Radio/radio.css";
|
||||
import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
||||
|
||||
import { TypeCreate } from "@goauthentik/api";
|
||||
|
||||
@customElement("ak-application-wizard-type")
|
||||
export class TypeApplicationWizardPage extends WizardPage {
|
||||
applicationTypes: TypeCreate[] = [
|
||||
{
|
||||
component: "ak-application-wizard-type-oauth",
|
||||
name: msg("OAuth2/OIDC"),
|
||||
description: msg("Modern applications, APIs and Single-page applications."),
|
||||
modelName: "",
|
||||
},
|
||||
{
|
||||
component: "ak-application-wizard-type-saml",
|
||||
name: msg("SAML"),
|
||||
description: msg(
|
||||
"XML-based SSO standard. Use this if your application only supports SAML.",
|
||||
),
|
||||
modelName: "",
|
||||
},
|
||||
{
|
||||
component: "ak-application-wizard-type-proxy",
|
||||
name: msg("Proxy"),
|
||||
description: msg("Legacy applications which don't natively support SSO."),
|
||||
modelName: "",
|
||||
},
|
||||
{
|
||||
component: "ak-application-wizard-type-ldap",
|
||||
name: msg("LDAP"),
|
||||
description: msg(
|
||||
"Provide an LDAP interface for applications and users to authenticate against.",
|
||||
),
|
||||
modelName: "",
|
||||
},
|
||||
{
|
||||
component: "ak-application-wizard-type-link",
|
||||
name: msg("Link"),
|
||||
description: msg(
|
||||
"Provide an LDAP interface for applications and users to authenticate against.",
|
||||
),
|
||||
modelName: "",
|
||||
},
|
||||
];
|
||||
|
||||
sidebarLabel = () => msg("Authentication method");
|
||||
|
||||
static get styles(): CSSResult[] {
|
||||
return [PFBase, PFButton, PFForm, PFRadio];
|
||||
}
|
||||
|
||||
render(): TemplateResult {
|
||||
return html`<form class="pf-c-form pf-m-horizontal">
|
||||
${this.applicationTypes.map((type) => {
|
||||
return html`<div class="pf-c-radio">
|
||||
<input
|
||||
class="pf-c-radio__input"
|
||||
type="radio"
|
||||
name="type"
|
||||
id=${type.component}
|
||||
@change=${() => {
|
||||
this.host.steps = [
|
||||
"ak-application-wizard-initial",
|
||||
"ak-application-wizard-type",
|
||||
type.component,
|
||||
];
|
||||
this.host.isValid = true;
|
||||
}}
|
||||
/>
|
||||
<label class="pf-c-radio__label" for=${type.component}>${type.name}</label>
|
||||
<span class="pf-c-radio__description">${type.description}</span>
|
||||
</div>`;
|
||||
})}
|
||||
</form>`;
|
||||
}
|
||||
}
|
|
@ -1,78 +0,0 @@
|
|||
import { CustomListenerElement } from "@goauthentik/elements/utils/eventEmitter";
|
||||
|
||||
import { provide } from "@lit-labs/context";
|
||||
import { customElement } from "@lit/reactive-element/decorators/custom-element.js";
|
||||
import { property } from "@lit/reactive-element/decorators/property.js";
|
||||
import { LitElement, html } from "lit";
|
||||
|
||||
import {
|
||||
Application,
|
||||
LDAPProvider,
|
||||
OAuth2Provider,
|
||||
ProxyProvider,
|
||||
RadiusProvider,
|
||||
SAMLProvider,
|
||||
SCIMProvider,
|
||||
} from "@goauthentik/api";
|
||||
|
||||
import applicationWizardContext from "./ak-application-wizard-context-name";
|
||||
|
||||
// my-context.ts
|
||||
|
||||
type OneOfProvider =
|
||||
| Partial<SCIMProvider>
|
||||
| Partial<SAMLProvider>
|
||||
| Partial<RadiusProvider>
|
||||
| Partial<ProxyProvider>
|
||||
| Partial<OAuth2Provider>
|
||||
| Partial<LDAPProvider>;
|
||||
|
||||
export interface WizardState {
|
||||
step: number;
|
||||
providerType: string;
|
||||
application: Partial<Application>;
|
||||
provider: OneOfProvider;
|
||||
}
|
||||
|
||||
type WizardStateEvent = WizardState & { target?: HTMLInputElement };
|
||||
|
||||
@customElement("ak-application-wizard-context")
|
||||
export class AkApplicationWizardContext extends CustomListenerElement(LitElement) {
|
||||
/**
|
||||
* Providing a context at the root element
|
||||
*/
|
||||
@provide({ context: applicationWizardContext })
|
||||
@property({ attribute: false })
|
||||
wizardState: WizardState = {
|
||||
step: 0,
|
||||
providerType: "",
|
||||
application: {},
|
||||
provider: {},
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.handleUpdate = this.handleUpdate.bind(this);
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.addCustomListener("ak-wizard-update", this.handleUpdate);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this.removeCustomListener("ak-wizard-update", this.handleUpdate);
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
handleUpdate(event: CustomEvent<WizardStateEvent>) {
|
||||
delete event.detail.target;
|
||||
this.wizardState = event.detail;
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`<slot></slot>`;
|
||||
}
|
||||
}
|
||||
|
||||
export default AkApplicationWizardContext;
|
|
@ -11,12 +11,15 @@ import PFButton from "@patternfly/patternfly/components/Button/button.css";
|
|||
import PFRadio from "@patternfly/patternfly/components/Radio/radio.css";
|
||||
import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
||||
|
||||
import { steps } from "./ApplicationWizardSteps";
|
||||
import applicationWizardContext from "./ak-application-wizard-context-name";
|
||||
import { steps } from "./steps";
|
||||
import { WizardState, WizardStateEvent } from "./types";
|
||||
|
||||
// my-context.ts
|
||||
|
||||
// All this thing is doing is recording the input the user makes to the forms. It should NOT be
|
||||
// triggering re-renders; that's the wizard frame's jobs.
|
||||
|
||||
@customElement("ak-application-wizard")
|
||||
export class ApplicationWizard extends CustomListenerElement(AKElement) {
|
||||
static get styles(): CSSResult[] {
|
||||
|
@ -38,19 +41,8 @@ export class ApplicationWizard extends CustomListenerElement(AKElement) {
|
|||
@state()
|
||||
steps = steps;
|
||||
|
||||
@property({ type: Boolean })
|
||||
open = false;
|
||||
|
||||
@property()
|
||||
createText = msg("Create");
|
||||
|
||||
@property({ type: Boolean })
|
||||
showButton = true;
|
||||
|
||||
@property({ attribute: false })
|
||||
finalHandler: () => Promise<void> = () => {
|
||||
return Promise.resolve();
|
||||
};
|
||||
prompt = msg("Create");
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
@ -83,8 +75,6 @@ export class ApplicationWizard extends CustomListenerElement(AKElement) {
|
|||
method.disabled = false;
|
||||
this.steps = newSteps;
|
||||
}
|
||||
|
||||
console.log(newWizardState);
|
||||
this.wizardState = newWizardState;
|
||||
}
|
||||
|
||||
|
@ -94,12 +84,8 @@ export class ApplicationWizard extends CustomListenerElement(AKElement) {
|
|||
.steps=${this.steps}
|
||||
header=${msg("New application")}
|
||||
description=${msg("Create a new application.")}
|
||||
prompt=${this.prompt}
|
||||
>
|
||||
${this.showButton
|
||||
? html`<button slot="trigger" class="pf-c-button pf-m-primary">
|
||||
${this.createText}
|
||||
</button>`
|
||||
: html``}
|
||||
</ak-wizard-main>
|
||||
`;
|
||||
}
|
||||
|
|
|
@ -12,10 +12,10 @@ import { customElement } from "@lit/reactive-element/decorators/custom-element.j
|
|||
import { TemplateResult, html } from "lit";
|
||||
import { ifDefined } from "lit/directives/if-defined.js";
|
||||
|
||||
import ApplicationWizardPageBase from "../ApplicationWizardPageBase";
|
||||
import BasePanel from "../BasePanel";
|
||||
|
||||
@customElement("ak-application-wizard-application-details")
|
||||
export class ApplicationWizardApplicationDetails extends ApplicationWizardPageBase {
|
||||
export class ApplicationWizardApplicationDetails extends BasePanel {
|
||||
handleChange(ev: Event) {
|
||||
if (!ev.target) {
|
||||
console.warn(`Received event with no target: ${ev}`);
|
||||
|
|
|
@ -12,11 +12,11 @@ import { map } from "lit/directives/map.js";
|
|||
|
||||
import type { TypeCreate } from "@goauthentik/api";
|
||||
|
||||
import ApplicationWizardPageBase from "../ApplicationWizardPageBase";
|
||||
import BasePanel from "../BasePanel";
|
||||
import providerTypesList from "./ak-application-wizard-authentication-method-choice.choices";
|
||||
|
||||
@customElement("ak-application-wizard-authentication-method-choice")
|
||||
export class ApplicationWizardAuthenticationMethodChoice extends ApplicationWizardPageBase {
|
||||
export class ApplicationWizardAuthenticationMethodChoice extends BasePanel {
|
||||
constructor() {
|
||||
super();
|
||||
this.handleChoice = this.handleChoice.bind(this);
|
||||
|
|
|
@ -1,20 +0,0 @@
|
|||
The design of the wizard is actually very simple. There is an orchestrator in the Context object;
|
||||
it takes messages from the current page and grants permissions to proceed based on the content of
|
||||
the Context object after a message.
|
||||
|
||||
The fields of the Context object are:
|
||||
|
||||
```Javascript
|
||||
{
|
||||
step: number // The page currently being visited
|
||||
providerType: The provider type chosen in step 2. Dictates which view to show in step 3
|
||||
application: // The data collected from the ApplicationDetails page
|
||||
provider: // the data collected from the ProviderDetails page.
|
||||
|
||||
|
||||
```
|
||||
|
||||
The orchestrator leans on the per-page forms to tell it when a page is "valid enough to proceed".
|
||||
|
||||
When it reaches the last page, the transaction is triggered. If there are errors, the user is
|
||||
invited to "go back to the page where the error occurred" and try again.
|
|
@ -1,30 +0,0 @@
|
|||
import { KeyUnknown } from "@goauthentik/elements/forms/Form";
|
||||
import "@goauthentik/elements/forms/HorizontalFormElement";
|
||||
import { WizardFormPage } from "@goauthentik/elements/wizard/WizardFormPage";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { customElement } from "@lit/reactive-element/decorators/custom-element.js";
|
||||
import { TemplateResult, html } from "lit";
|
||||
|
||||
@customElement("ak-application-wizard-type-link")
|
||||
export class TypeLinkApplicationWizardPage extends WizardFormPage {
|
||||
sidebarLabel = () => msg("Application Link");
|
||||
|
||||
nextDataCallback = async (data: KeyUnknown): Promise<boolean> => {
|
||||
this.host.state["link"] = data.link;
|
||||
return true;
|
||||
};
|
||||
|
||||
renderForm(): TemplateResult {
|
||||
return html`
|
||||
<form class="pf-c-form pf-m-horizontal">
|
||||
<ak-form-element-horizontal label=${msg("Link")} ?required=${true} name="link">
|
||||
<input type="text" value="" class="pf-c-form-control" required />
|
||||
<p class="pf-c-form__helper-text">
|
||||
${msg("URL which will be opened when a user clicks on the application.")}
|
||||
</p>
|
||||
</ak-form-element-horizontal>
|
||||
</form>
|
||||
`;
|
||||
}
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
import ApplicationWizardPageBase from "./ApplicationWizardPageBase";
|
||||
import BasePanel from "../BasePanel";
|
||||
|
||||
export class ApplicationWizardProviderPageBase extends ApplicationWizardPageBase {
|
||||
export class ApplicationWizardProviderPageBase extends BasePanel {
|
||||
handleChange(ev: InputEvent) {
|
||||
if (!ev.target) {
|
||||
console.warn(`Received event with no target: ${ev}`);
|
|
@ -1,16 +1,16 @@
|
|||
import { customElement } from "@lit/reactive-element/decorators/custom-element.js";
|
||||
|
||||
import ApplicationWizardPageBase from "../ApplicationWizardPageBase";
|
||||
import BasePanel from "../BasePanel";
|
||||
import { providerRendererList } from "../auth-method-choice/ak-application-wizard-authentication-method-choice.choices";
|
||||
import "../ldap/ak-application-wizard-authentication-by-ldap";
|
||||
import "../oauth/ak-application-wizard-authentication-by-oauth";
|
||||
import "../proxy/ak-application-wizard-authentication-for-reverse-proxy";
|
||||
import "../proxy/ak-application-wizard-authentication-for-single-forward-proxy";
|
||||
import "./ldap/ak-application-wizard-authentication-by-ldap";
|
||||
import "./oauth/ak-application-wizard-authentication-by-oauth";
|
||||
import "./proxy/ak-application-wizard-authentication-for-reverse-proxy";
|
||||
import "./proxy/ak-application-wizard-authentication-for-single-forward-proxy";
|
||||
|
||||
// prettier-ignore
|
||||
|
||||
@customElement("ak-application-wizard-authentication-method")
|
||||
export class ApplicationWizardApplicationDetails extends ApplicationWizardPageBase {
|
||||
export class ApplicationWizardApplicationDetails extends BasePanel {
|
||||
render() {
|
||||
const handler = providerRendererList.get(this.wizard.providerType);
|
||||
if (!handler) {
|
|
@ -18,7 +18,7 @@ import { ifDefined } from "lit/directives/if-defined.js";
|
|||
import { FlowsInstancesListDesignationEnum } from "@goauthentik/api";
|
||||
import type { LDAPProvider } from "@goauthentik/api";
|
||||
|
||||
import ApplicationWizardProviderPageBase from "../ApplicationWizardProviderPageBase";
|
||||
import BaseProviderPanel from "../BaseProviderPanel";
|
||||
import {
|
||||
bindModeOptions,
|
||||
cryptoCertificateHelp,
|
||||
|
@ -31,7 +31,7 @@ import {
|
|||
} from "./LDAPOptionsAndHelp";
|
||||
|
||||
@customElement("ak-application-wizard-authentication-by-ldap")
|
||||
export class ApplicationWizardApplicationDetails extends ApplicationWizardProviderPageBase {
|
||||
export class ApplicationWizardApplicationDetails extends BaseProviderPanel {
|
||||
render() {
|
||||
const provider = this.wizard.provider as LDAPProvider | undefined;
|
||||
|
|
@ -33,10 +33,10 @@ import type {
|
|||
PaginatedScopeMappingList,
|
||||
} from "@goauthentik/api";
|
||||
|
||||
import ApplicationWizardProviderPageBase from "../ApplicationWizardProviderPageBase";
|
||||
import BaseProviderPanel from "../BaseProviderPanel";
|
||||
|
||||
@customElement("ak-application-wizard-authentication-by-oauth")
|
||||
export class ApplicationWizardAuthenticationByOauth extends ApplicationWizardProviderPageBase {
|
||||
export class ApplicationWizardAuthenticationByOauth extends BaseProviderPanel {
|
||||
@state()
|
||||
showClientSecret = false;
|
||||
|
|
@ -21,11 +21,11 @@ import {
|
|||
SourcesApi,
|
||||
} from "@goauthentik/api";
|
||||
|
||||
import ApplicationWizardProviderPageBase from "../ApplicationWizardProviderPageBase";
|
||||
import BaseProviderPanel from "../BaseProviderPanel";
|
||||
|
||||
type MaybeTemplateResult = TemplateResult | typeof nothing;
|
||||
|
||||
export class AkTypeProxyApplicationWizardPage extends ApplicationWizardProviderPageBase {
|
||||
export class AkTypeProxyApplicationWizardPage extends BaseProviderPanel {
|
||||
constructor() {
|
||||
super();
|
||||
new PropertymappingsApi(DEFAULT_CONFIG)
|
|
@ -21,7 +21,7 @@ import {
|
|||
SAMLProvider,
|
||||
} from "@goauthentik/api";
|
||||
|
||||
import ApplicationWizardProviderPageBase from "../ApplicationWizardProviderPageBase";
|
||||
import BaseProviderPanel from "../BaseProviderPanel";
|
||||
import {
|
||||
digestAlgorithmOptions,
|
||||
signatureAlgorithmOptions,
|
||||
|
@ -29,7 +29,7 @@ import {
|
|||
} from "./SamlProviderOptions";
|
||||
|
||||
@customElement("ak-application-wizard-authentication-by-saml-configuration")
|
||||
export class ApplicationWizardProviderSamlConfiguration extends ApplicationWizardProviderPageBase {
|
||||
export class ApplicationWizardProviderSamlConfiguration extends BaseProviderPanel {
|
||||
propertyMappings?: PaginatedSAMLPropertyMappingList;
|
||||
|
||||
constructor() {
|
|
@ -9,10 +9,10 @@ import { html } from "lit";
|
|||
|
||||
import { FlowsInstancesListDesignationEnum } from "@goauthentik/api";
|
||||
|
||||
import ApplicationWizardProviderPageBase from "../ApplicationWizardProviderPageBase";
|
||||
import BaseProviderPanel from "../BaseProviderPanel";
|
||||
|
||||
@customElement("ak-application-wizard-authentication-by-saml-import")
|
||||
export class ApplicationWizardProviderSamlImport extends ApplicationWizardProviderPageBase {
|
||||
export class ApplicationWizardProviderSamlImport extends BaseProviderPanel {
|
||||
render() {
|
||||
return html` <form class="pf-c-form pf-m-horizontal" @input=${this.handleChange}>
|
||||
<ak-text-input name="name" label=${msg("Name")} required></ak-text-input>
|
|
@ -1,35 +0,0 @@
|
|||
import "@goauthentik/elements/forms/HorizontalFormElement";
|
||||
import { WizardPage } from "@goauthentik/elements/wizard/WizardPage";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { customElement } from "@lit/reactive-element/decorators/custom-element.js";
|
||||
import { CSSResult, TemplateResult, html } from "lit";
|
||||
|
||||
import PFButton from "@patternfly/patternfly/components/Button/button.css";
|
||||
import PFForm from "@patternfly/patternfly/components/Form/form.css";
|
||||
import PFRadio from "@patternfly/patternfly/components/Radio/radio.css";
|
||||
import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
||||
|
||||
@customElement("ak-application-wizard-type-oauth-api")
|
||||
export class TypeOAuthAPIApplicationWizardPage extends WizardPage {
|
||||
static get styles(): CSSResult[] {
|
||||
return [PFBase, PFButton, PFForm, PFRadio];
|
||||
}
|
||||
|
||||
sidebarLabel = () => msg("Method details");
|
||||
|
||||
render(): TemplateResult {
|
||||
return html`<form class="pf-c-form pf-m-horizontal">
|
||||
<p>
|
||||
${msg(
|
||||
"This configuration can be used to authenticate to authentik with other APIs other otherwise programmatically.",
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
${msg(
|
||||
"By default, all service accounts can authenticate as this application, as long as they have a valid token of the type app-password.",
|
||||
)}
|
||||
</p>
|
||||
</form> `;
|
||||
}
|
||||
}
|
|
@ -1,84 +0,0 @@
|
|||
import "@goauthentik/elements/forms/HorizontalFormElement";
|
||||
import { WizardPage } from "@goauthentik/elements/wizard/WizardPage";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { customElement } from "@lit/reactive-element/decorators/custom-element.js";
|
||||
import { CSSResult, TemplateResult, html } from "lit";
|
||||
|
||||
import PFButton from "@patternfly/patternfly/components/Button/button.css";
|
||||
import PFForm from "@patternfly/patternfly/components/Form/form.css";
|
||||
import PFRadio from "@patternfly/patternfly/components/Radio/radio.css";
|
||||
import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
||||
|
||||
import { TypeCreate } from "@goauthentik/api";
|
||||
|
||||
@customElement("ak-application-wizard-type-oauth")
|
||||
export class TypeOAuthApplicationWizardPage extends WizardPage {
|
||||
applicationTypes: TypeCreate[] = [
|
||||
{
|
||||
component: "ak-application-wizard-type-oauth-code",
|
||||
name: msg("Web application"),
|
||||
description: msg(
|
||||
"Applications which handle the authentication server-side (for example, Python, Go, Rust, Java, PHP)",
|
||||
),
|
||||
modelName: "",
|
||||
},
|
||||
{
|
||||
component: "ak-application-wizard-type-oauth-implicit",
|
||||
name: msg("Single-page applications"),
|
||||
description: msg(
|
||||
"Single-page applications which handle authentication in the browser (for example, Javascript, Angular, React, Vue)",
|
||||
),
|
||||
modelName: "",
|
||||
},
|
||||
{
|
||||
component: "ak-application-wizard-type-oauth-implicit",
|
||||
name: msg("Native application"),
|
||||
description: msg(
|
||||
"Applications which redirect users to a non-web callback (for example, Android, iOS)",
|
||||
),
|
||||
modelName: "",
|
||||
},
|
||||
{
|
||||
component: "ak-application-wizard-type-oauth-api",
|
||||
name: msg("API"),
|
||||
description: msg(
|
||||
"Authentication without user interaction, or machine-to-machine authentication.",
|
||||
),
|
||||
modelName: "",
|
||||
},
|
||||
];
|
||||
|
||||
static get styles(): CSSResult[] {
|
||||
return [PFBase, PFButton, PFForm, PFRadio];
|
||||
}
|
||||
|
||||
sidebarLabel = () => msg("Application type");
|
||||
|
||||
render(): TemplateResult {
|
||||
return html`<form class="pf-c-form pf-m-horizontal">
|
||||
${this.applicationTypes.map((type) => {
|
||||
return html`<div class="pf-c-radio">
|
||||
<input
|
||||
class="pf-c-radio__input"
|
||||
type="radio"
|
||||
name="type"
|
||||
id=${type.component}
|
||||
@change=${() => {
|
||||
this.host.steps = [
|
||||
"ak-application-wizard-initial",
|
||||
"ak-application-wizard-type",
|
||||
"ak-application-wizard-type-oauth",
|
||||
type.component,
|
||||
];
|
||||
this.host.state["oauth-type"] = type.component;
|
||||
this.host.isValid = true;
|
||||
}}
|
||||
/>
|
||||
<label class="pf-c-radio__label" for=${type.component}>${type.name}</label>
|
||||
<span class="pf-c-radio__description">${type.description}</span>
|
||||
</div>`;
|
||||
})}
|
||||
</form> `;
|
||||
}
|
||||
}
|
|
@ -1,57 +0,0 @@
|
|||
import "@goauthentik/admin/common/ak-flow-search/ak-flow-search-no-default";
|
||||
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
||||
import { KeyUnknown } from "@goauthentik/elements/forms/Form";
|
||||
import "@goauthentik/elements/forms/HorizontalFormElement";
|
||||
import "@goauthentik/elements/forms/SearchSelect";
|
||||
import { WizardFormPage } from "@goauthentik/elements/wizard/WizardFormPage";
|
||||
import "@goauthentik/elements/wizard/WizardFormPage";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { customElement } from "@lit/reactive-element/decorators/custom-element.js";
|
||||
import { TemplateResult, html } from "lit";
|
||||
|
||||
import {
|
||||
ClientTypeEnum,
|
||||
FlowsInstancesListDesignationEnum,
|
||||
OAuth2ProviderRequest,
|
||||
ProvidersApi,
|
||||
} from "@goauthentik/api";
|
||||
|
||||
@customElement("ak-application-wizard-type-oauth-code")
|
||||
export class TypeOAuthCodeApplicationWizardPage extends WizardFormPage {
|
||||
sidebarLabel = () => msg("Method details");
|
||||
|
||||
nextDataCallback = async (data: KeyUnknown): Promise<boolean> => {
|
||||
this.host.addActionBefore(msg("Create provider"), async (): Promise<boolean> => {
|
||||
const req: OAuth2ProviderRequest = {
|
||||
name: this.host.state["name"] as string,
|
||||
clientType: ClientTypeEnum.Confidential,
|
||||
authorizationFlow: data.authorizationFlow as string,
|
||||
};
|
||||
const provider = await new ProvidersApi(DEFAULT_CONFIG).providersOauth2Create({
|
||||
oAuth2ProviderRequest: req,
|
||||
});
|
||||
this.host.state["provider"] = provider;
|
||||
return true;
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
renderForm(): TemplateResult {
|
||||
return html`<form class="pf-c-form pf-m-horizontal">
|
||||
<ak-form-element-horizontal
|
||||
label=${msg("Authorization flow")}
|
||||
?required=${true}
|
||||
name="authorizationFlow"
|
||||
>
|
||||
<ak-flow-search-no-default
|
||||
flowType=${FlowsInstancesListDesignationEnum.Authorization}
|
||||
required
|
||||
></ak-flow-search-no-default>
|
||||
<p class="pf-c-form__helper-text">
|
||||
${msg("Flow used when users access this application.")}
|
||||
</p>
|
||||
</ak-form-element-horizontal>
|
||||
</form>`;
|
||||
}
|
||||
}
|
|
@ -1,15 +0,0 @@
|
|||
import "@goauthentik/elements/forms/HorizontalFormElement";
|
||||
import { WizardFormPage } from "@goauthentik/elements/wizard/WizardFormPage";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { customElement } from "@lit/reactive-element/decorators/custom-element.js";
|
||||
import { TemplateResult, html } from "lit";
|
||||
|
||||
@customElement("ak-application-wizard-type-oauth-implicit")
|
||||
export class TypeOAuthImplicitApplicationWizardPage extends WizardFormPage {
|
||||
sidebarLabel = () => msg("Method details");
|
||||
|
||||
render(): TemplateResult {
|
||||
return html`<form class="pf-c-form pf-m-horizontal">some stuff idk</form> `;
|
||||
}
|
||||
}
|
|
@ -1,64 +0,0 @@
|
|||
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
||||
import { KeyUnknown } from "@goauthentik/elements/forms/Form";
|
||||
import "@goauthentik/elements/forms/HorizontalFormElement";
|
||||
import { WizardFormPage } from "@goauthentik/elements/wizard/WizardFormPage";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { customElement } from "@lit/reactive-element/decorators/custom-element.js";
|
||||
import { TemplateResult, html } from "lit";
|
||||
|
||||
import {
|
||||
FlowDesignationEnum,
|
||||
FlowsApi,
|
||||
ProvidersApi,
|
||||
ProxyProviderRequest,
|
||||
} from "@goauthentik/api";
|
||||
|
||||
@customElement("ak-application-wizard-type-proxy")
|
||||
export class TypeProxyApplicationWizardPage extends WizardFormPage {
|
||||
sidebarLabel = () => msg("Proxy details");
|
||||
|
||||
nextDataCallback = async (data: KeyUnknown): Promise<boolean> => {
|
||||
let name = this.host.state["name"] as string;
|
||||
// Check if a provider with the name already exists
|
||||
const providers = await new ProvidersApi(DEFAULT_CONFIG).providersAllList({
|
||||
search: name,
|
||||
});
|
||||
if (providers.results.filter((provider) => provider.name == name)) {
|
||||
name += "-1";
|
||||
}
|
||||
this.host.addActionBefore(msg("Create provider"), async (): Promise<boolean> => {
|
||||
// Get all flows and default to the implicit authorization
|
||||
const flows = await new FlowsApi(DEFAULT_CONFIG).flowsInstancesList({
|
||||
designation: FlowDesignationEnum.Authorization,
|
||||
ordering: "slug",
|
||||
});
|
||||
const req: ProxyProviderRequest = {
|
||||
name: name,
|
||||
authorizationFlow: flows.results[0].pk,
|
||||
externalHost: data.externalHost as string,
|
||||
};
|
||||
const provider = await new ProvidersApi(DEFAULT_CONFIG).providersProxyCreate({
|
||||
proxyProviderRequest: req,
|
||||
});
|
||||
this.host.state["provider"] = provider;
|
||||
return true;
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
renderForm(): TemplateResult {
|
||||
return html`<form class="pf-c-form pf-m-horizontal">
|
||||
<ak-form-element-horizontal
|
||||
label=${msg("External domain")}
|
||||
name="externalHost"
|
||||
?required=${true}
|
||||
>
|
||||
<input type="text" value="" class="pf-c-form-control" required />
|
||||
<p class="pf-c-form__helper-text">
|
||||
${msg("External domain you will be accessing the domain from.")}
|
||||
</p>
|
||||
</ak-form-element-horizontal>
|
||||
</form> `;
|
||||
}
|
||||
}
|
|
@ -1,66 +0,0 @@
|
|||
import "@goauthentik/elements/forms/HorizontalFormElement";
|
||||
import { WizardPage } from "@goauthentik/elements/wizard/WizardPage";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { customElement } from "@lit/reactive-element/decorators/custom-element.js";
|
||||
import { CSSResult, TemplateResult, html } from "lit";
|
||||
|
||||
import PFButton from "@patternfly/patternfly/components/Button/button.css";
|
||||
import PFForm from "@patternfly/patternfly/components/Form/form.css";
|
||||
import PFRadio from "@patternfly/patternfly/components/Radio/radio.css";
|
||||
import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
||||
|
||||
import { TypeCreate } from "@goauthentik/api";
|
||||
|
||||
@customElement("ak-application-wizard-type-saml")
|
||||
export class TypeOAuthApplicationWizardPage extends WizardPage {
|
||||
applicationTypes: TypeCreate[] = [
|
||||
{
|
||||
component: "ak-application-wizard-type-saml-import",
|
||||
name: msg("Import SAML Metadata"),
|
||||
description: msg(
|
||||
"Import the metadata document of the applicaation you want to configure.",
|
||||
),
|
||||
modelName: "",
|
||||
},
|
||||
{
|
||||
component: "ak-application-wizard-type-saml-config",
|
||||
name: msg("Manual configuration"),
|
||||
description: msg("Manually configure SAML"),
|
||||
modelName: "",
|
||||
},
|
||||
];
|
||||
|
||||
static get styles(): CSSResult[] {
|
||||
return [PFBase, PFButton, PFForm, PFRadio];
|
||||
}
|
||||
|
||||
sidebarLabel = () => msg("Application type");
|
||||
|
||||
render(): TemplateResult {
|
||||
return html`<form class="pf-c-form pf-m-horizontal">
|
||||
${this.applicationTypes.map((type) => {
|
||||
return html`<div class="pf-c-radio">
|
||||
<input
|
||||
class="pf-c-radio__input"
|
||||
type="radio"
|
||||
name="type"
|
||||
id=${type.component}
|
||||
@change=${() => {
|
||||
this.host.steps = [
|
||||
"ak-application-wizard-initial",
|
||||
"ak-application-wizard-type",
|
||||
"ak-application-wizard-type-saml",
|
||||
type.component,
|
||||
];
|
||||
this.host.state["saml-type"] = type.component;
|
||||
this.host.isValid = true;
|
||||
}}
|
||||
/>
|
||||
<label class="pf-c-radio__label" for=${type.component}>${type.name}</label>
|
||||
<span class="pf-c-radio__description">${type.description}</span>
|
||||
</div>`;
|
||||
})}
|
||||
</form> `;
|
||||
}
|
||||
}
|
|
@ -1,57 +0,0 @@
|
|||
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
||||
import { KeyUnknown } from "@goauthentik/elements/forms/Form";
|
||||
import "@goauthentik/elements/forms/HorizontalFormElement";
|
||||
import { WizardFormPage } from "@goauthentik/elements/wizard/WizardFormPage";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { customElement } from "@lit/reactive-element/decorators/custom-element.js";
|
||||
import { TemplateResult, html } from "lit";
|
||||
|
||||
import { FlowDesignationEnum, FlowsApi, ProvidersApi, SAMLProviderRequest } from "@goauthentik/api";
|
||||
|
||||
@customElement("ak-application-wizard-type-saml-config")
|
||||
export class TypeSAMLApplicationWizardPage extends WizardFormPage {
|
||||
sidebarLabel = () => msg("SAML details");
|
||||
|
||||
nextDataCallback = async (data: KeyUnknown): Promise<boolean> => {
|
||||
let name = this.host.state["name"] as string;
|
||||
// Check if a provider with the name already exists
|
||||
const providers = await new ProvidersApi(DEFAULT_CONFIG).providersAllList({
|
||||
search: name,
|
||||
});
|
||||
if (providers.results.filter((provider) => provider.name == name)) {
|
||||
name += "-1";
|
||||
}
|
||||
this.host.addActionBefore(msg("Create provider"), async (): Promise<boolean> => {
|
||||
// Get all flows and default to the implicit authorization
|
||||
const flows = await new FlowsApi(DEFAULT_CONFIG).flowsInstancesList({
|
||||
designation: FlowDesignationEnum.Authorization,
|
||||
ordering: "slug",
|
||||
});
|
||||
const req: SAMLProviderRequest = {
|
||||
name: name,
|
||||
authorizationFlow: flows.results[0].pk,
|
||||
acsUrl: data.acsUrl as string,
|
||||
};
|
||||
const provider = await new ProvidersApi(DEFAULT_CONFIG).providersSamlCreate({
|
||||
sAMLProviderRequest: req,
|
||||
});
|
||||
this.host.state["provider"] = provider;
|
||||
return true;
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
renderForm(): TemplateResult {
|
||||
return html`<form class="pf-c-form pf-m-horizontal">
|
||||
<ak-form-element-horizontal label=${msg("ACS URL")} name="acsUrl" ?required=${true}>
|
||||
<input type="text" value="" class="pf-c-form-control" required />
|
||||
<p class="pf-c-form__helper-text">
|
||||
${msg(
|
||||
"URL that authentik will redirect back to after successful authentication.",
|
||||
)}
|
||||
</p>
|
||||
</ak-form-element-horizontal>
|
||||
</form> `;
|
||||
}
|
||||
}
|
|
@ -1,57 +0,0 @@
|
|||
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
|
||||
import { KeyUnknown } from "@goauthentik/elements/forms/Form";
|
||||
import "@goauthentik/elements/forms/HorizontalFormElement";
|
||||
import { WizardFormPage } from "@goauthentik/elements/wizard/WizardFormPage";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { customElement } from "@lit/reactive-element/decorators/custom-element.js";
|
||||
import { TemplateResult, html } from "lit";
|
||||
|
||||
import {
|
||||
FlowDesignationEnum,
|
||||
FlowsApi,
|
||||
ProvidersApi,
|
||||
ProvidersSamlImportMetadataCreateRequest,
|
||||
} from "@goauthentik/api";
|
||||
|
||||
@customElement("ak-application-wizard-type-saml-import")
|
||||
export class TypeSAMLImportApplicationWizardPage extends WizardFormPage {
|
||||
sidebarLabel = () => msg("Import SAML metadata");
|
||||
|
||||
nextDataCallback = async (data: KeyUnknown): Promise<boolean> => {
|
||||
let name = this.host.state["name"] as string;
|
||||
// Check if a provider with the name already exists
|
||||
const providers = await new ProvidersApi(DEFAULT_CONFIG).providersAllList({
|
||||
search: name,
|
||||
});
|
||||
if (providers.results.filter((provider) => provider.name == name)) {
|
||||
name += "-1";
|
||||
}
|
||||
this.host.addActionBefore(msg("Create provider"), async (): Promise<boolean> => {
|
||||
// Get all flows and default to the implicit authorization
|
||||
const flows = await new FlowsApi(DEFAULT_CONFIG).flowsInstancesList({
|
||||
designation: FlowDesignationEnum.Authorization,
|
||||
ordering: "slug",
|
||||
});
|
||||
const req: ProvidersSamlImportMetadataCreateRequest = {
|
||||
name: name,
|
||||
authorizationFlow: flows.results[0].slug,
|
||||
file: data["metadata"] as Blob,
|
||||
};
|
||||
const provider = await new ProvidersApi(
|
||||
DEFAULT_CONFIG,
|
||||
).providersSamlImportMetadataCreate(req);
|
||||
this.host.state["provider"] = provider;
|
||||
return true;
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
renderForm(): TemplateResult {
|
||||
return html`<form class="pf-c-form pf-m-horizontal">
|
||||
<ak-form-element-horizontal label=${msg("Metadata")} name="metadata">
|
||||
<input type="file" value="" class="pf-c-form-control" />
|
||||
</ak-form-element-horizontal>
|
||||
</form> `;
|
||||
}
|
||||
}
|
|
@ -1,16 +1,15 @@
|
|||
import { WizardStep, makeWizardId } from "@goauthentik/components/ak-wizard-main";
|
||||
import { WizardStep } from "@goauthentik/components/ak-wizard-main";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { html } from "lit";
|
||||
|
||||
import "./application/ak-application-wizard-application-details";
|
||||
import "./auth-method-choice/ak-application-wizard-authentication-method-choice";
|
||||
import "./auth-method/ak-application-wizard-authentication-method";
|
||||
import "./methods/ak-application-wizard-authentication-method";
|
||||
|
||||
export const steps: WizardStep[] = [
|
||||
{
|
||||
id: makeWizardId("application"),
|
||||
nextStep: makeWizardId("auth-method-choice"),
|
||||
id: "application",
|
||||
label: "Application Details",
|
||||
renderer: () =>
|
||||
html`<ak-application-wizard-application-details></ak-application-wizard-application-details>`,
|
||||
|
@ -19,9 +18,7 @@ export const steps: WizardStep[] = [
|
|||
valid: true,
|
||||
},
|
||||
{
|
||||
id: makeWizardId("auth-method-choice"),
|
||||
backStep: makeWizardId("application"),
|
||||
nextStep: makeWizardId("auth-method"),
|
||||
id: "auth-method-choice",
|
||||
label: "Authentication Method",
|
||||
renderer: () =>
|
||||
html`<ak-application-wizard-authentication-method-choice></ak-application-wizard-authentication-method-choice>`,
|
||||
|
@ -31,8 +28,7 @@ export const steps: WizardStep[] = [
|
|||
valid: true,
|
||||
},
|
||||
{
|
||||
id: makeWizardId("auth-method"),
|
||||
backStep: makeWizardId("auth-method-choice"),
|
||||
id: "auth-method",
|
||||
label: "Authentication Details",
|
||||
renderer: () =>
|
||||
html`<ak-application-wizard-authentication-method></ak-application-wizard-authentication-method>`,
|
|
@ -3,8 +3,8 @@ import { customElement } from "@lit/reactive-element/decorators/custom-element.j
|
|||
import { state } from "@lit/reactive-element/decorators/state.js";
|
||||
import { LitElement, html } from "lit";
|
||||
|
||||
import type { WizardState } from "../ak-application-wizard-context";
|
||||
import applicationWizardContext from "../ak-application-wizard-context-name";
|
||||
import type { WizardState } from "../types";
|
||||
|
||||
@customElement("ak-application-context-display-for-test")
|
||||
export class ApplicationContextDisplayForTest extends LitElement {
|
||||
|
|
|
@ -63,6 +63,20 @@ export class AkWizardFrame extends CustomEmitterElement(ModalButton) {
|
|||
this.open = false;
|
||||
}
|
||||
|
||||
get maxStep() {
|
||||
return this.steps.length - 1;
|
||||
}
|
||||
|
||||
get nextStep() {
|
||||
const idx = this.steps.findIndex((step) => step === this.currentStep);
|
||||
return idx < this.maxStep ? this.steps[idx + 1] : undefined;
|
||||
}
|
||||
|
||||
get backStep() {
|
||||
const idx = this.steps.findIndex((step) => step === this.currentStep);
|
||||
return idx > 0 ? this.steps[idx - 1] : undefined;
|
||||
}
|
||||
|
||||
renderModalInner() {
|
||||
// prettier-ignore
|
||||
return html`<div class="pf-c-wizard">
|
||||
|
@ -134,32 +148,30 @@ export class AkWizardFrame extends CustomEmitterElement(ModalButton) {
|
|||
renderFooter() {
|
||||
return html`
|
||||
<footer class="pf-c-wizard__footer">
|
||||
${this.currentStep.nextStep ? this.renderFooterNextButton() : nothing}
|
||||
${this.currentStep.backStep ? this.renderFooterBackButton() : nothing}
|
||||
${this.nextStep ? this.renderFooterNextButton(this.nextStep) : nothing}
|
||||
${this.backStep ? this.renderFooterBackButton(this.backStep) : nothing}
|
||||
${this.canCancel ? this.renderFooterCancelButton() : nothing}
|
||||
</footer>
|
||||
`;
|
||||
}
|
||||
|
||||
renderFooterNextButton() {
|
||||
renderFooterNextButton(nextStep: WizardStep) {
|
||||
return html`<button
|
||||
class="pf-c-button pf-m-primary"
|
||||
type="submit"
|
||||
?disabled=${!this.currentStep.valid}
|
||||
@click=${() =>
|
||||
this.dispatchCustomEvent(this.eventName, { step: this.currentStep.nextStep })}
|
||||
@click=${() => this.dispatchCustomEvent(this.eventName, { step: nextStep.id })}
|
||||
>
|
||||
${this.currentStep.nextButtonLabel}
|
||||
</button>`;
|
||||
}
|
||||
|
||||
renderFooterBackButton() {
|
||||
renderFooterBackButton(backStep: WizardStep) {
|
||||
return html`
|
||||
<button
|
||||
class="pf-c-button pf-m-secondary"
|
||||
type="button"
|
||||
@click=${() =>
|
||||
this.dispatchCustomEvent(this.eventName, { step: this.currentStep.backStep })}
|
||||
@click=${() => this.dispatchCustomEvent(this.eventName, { step: backStep.id })}
|
||||
>
|
||||
${this.currentStep.backButtonLabel}
|
||||
</button>
|
||||
|
|
|
@ -20,8 +20,11 @@ import type { WizardStep } from "./types";
|
|||
*
|
||||
* @element ak-wizard-main
|
||||
*
|
||||
* This is the entry point for the wizard.
|
||||
*
|
||||
* This is the entry point for the wizard. Its tasks are:
|
||||
* - keep the collection of steps
|
||||
* - maintain the open/close status of the modal
|
||||
* - listens for navigation events
|
||||
* - if a navigation event is valid, switch to the panel requested
|
||||
*/
|
||||
|
||||
@customElement("ak-wizard-main")
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import "./ak-wizard-main";
|
||||
import type { WizardStep, WizardStepId } from "./types";
|
||||
import { makeWizardId } from "./types";
|
||||
import type { WizardStep } from "./types";
|
||||
|
||||
export { WizardStepId, WizardStep, makeWizardId };
|
||||
export { WizardStep };
|
||||
|
|
|
@ -6,7 +6,6 @@ import { TemplateResult, html } from "lit";
|
|||
import "../ak-wizard-main";
|
||||
import AkWizard from "../ak-wizard-main";
|
||||
import type { WizardStep } from "../types";
|
||||
import { makeWizardId } from "../types";
|
||||
|
||||
const metadata: Meta<AkWizard> = {
|
||||
title: "Components / Wizard / Basic",
|
||||
|
@ -38,22 +37,20 @@ const container = (testItem: TemplateResult) =>
|
|||
|
||||
const dummySteps: WizardStep[] = [
|
||||
{
|
||||
id: makeWizardId("0"),
|
||||
id: "0",
|
||||
label: "Test Step1",
|
||||
renderer: () => html`<h2>This space intentionally left blank today</h2>`,
|
||||
disabled: false,
|
||||
valid: true,
|
||||
nextStep: makeWizardId("1"),
|
||||
nextButtonLabel: "Next",
|
||||
backButtonLabel: undefined,
|
||||
},
|
||||
{
|
||||
id: makeWizardId("1"),
|
||||
id: "1",
|
||||
label: "Test Step 2",
|
||||
renderer: () => html`<h2>This space also intentionally left blank</h2>`,
|
||||
disabled: false,
|
||||
valid: true,
|
||||
backStep: makeWizardId("0"),
|
||||
nextButtonLabel: undefined,
|
||||
backButtonLabel: "Back",
|
||||
},
|
||||
|
|
|
@ -1,15 +1,7 @@
|
|||
import { TemplateResult } from "lit";
|
||||
|
||||
type PhantomType<Type, Data> = { _type: Type } & Data;
|
||||
|
||||
export type WizardStepId = PhantomType<"WizardId", string>;
|
||||
|
||||
export const makeWizardId = (id: string): WizardStepId => id as WizardStepId;
|
||||
|
||||
export interface WizardStep {
|
||||
id: WizardStepId;
|
||||
nextStep?: WizardStepId;
|
||||
backStep?: WizardStepId;
|
||||
id: string;
|
||||
label: string;
|
||||
valid: boolean;
|
||||
renderer: () => TemplateResult;
|
||||
|
|
Reference in New Issue