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:
Ken Sternberg 2023-08-11 15:48:31 -07:00
parent c0294191ad
commit 356488809c
36 changed files with 67 additions and 729 deletions

View File

@ -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() {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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() {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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>`,

View File

@ -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 {

View File

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

View File

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

View File

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

View File

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

View File

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