diff --git a/web/src/admin/applications/wizard/saml/ak-application-wizard-authentication-by-saml-import.ts b/web/src/admin/applications/wizard/saml/ak-application-wizard-authentication-by-saml-import.ts new file mode 100644 index 000000000..49d03ef7f --- /dev/null +++ b/web/src/admin/applications/wizard/saml/ak-application-wizard-authentication-by-saml-import.ts @@ -0,0 +1,39 @@ +import "@goauthentik/admin/common/ak-flow-search/ak-flow-search-no-default"; +import "@goauthentik/components/ak-file-input"; +import "@goauthentik/components/ak-text-input"; +import "@goauthentik/elements/forms/HorizontalFormElement"; + +import { msg } from "@lit/localize"; +import { customElement } from "@lit/reactive-element/decorators/custom-element.js"; +import { html } from "lit"; + +import { FlowsInstancesListDesignationEnum } from "@goauthentik/api"; + +import ApplicationWizardProviderPageBase from "../ApplicationWizardProviderPageBase"; + +@customElement("ak-application-wizard-authentication-by-saml-import") +export class ApplicationWizardProviderSamlImport extends ApplicationWizardProviderPageBase { + render() { + return html`
+ + + + +

+ ${msg("Flow used when authorizing this provider.")} +

+
+ + +
`; + } +} + +export default ApplicationWizardProviderSamlImport; diff --git a/web/src/admin/applications/wizard/stories/ak-application-wizard.stories.ts b/web/src/admin/applications/wizard/stories/ak-application-wizard.stories.ts index 5a33ff249..41b8a22af 100644 --- a/web/src/admin/applications/wizard/stories/ak-application-wizard.stories.ts +++ b/web/src/admin/applications/wizard/stories/ak-application-wizard.stories.ts @@ -11,6 +11,7 @@ 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 "../saml/ak-application-wizard-authentication-by-saml-configuration"; +import "../saml/ak-application-wizard-authentication-by-saml-import"; import "./ak-application-context-display-for-test"; import { dummyAuthenticationFlowsSearch, @@ -182,3 +183,14 @@ export const ConfigureSamlManually = () => { `, ); }; + + +export const SamlImport = () => { + return container( + html` + +
+ +
`, + ); +}; diff --git a/web/src/components/ak-wizard-2/ak-wizard-2.ts b/web/src/components/ak-wizard-2/ak-wizard-2.ts new file mode 100644 index 000000000..2ec560d05 --- /dev/null +++ b/web/src/components/ak-wizard-2/ak-wizard-2.ts @@ -0,0 +1,178 @@ +import { ModalButton } from "@goauthentik/elements/buttons/ModalButton"; +import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter"; + +import { msg } from "@lit/localize"; +import { customElement, property, state } from "@lit/reactive-element/decorators.js"; +import { html, nothing } from "lit"; + + +import { classMap } from "lit/directives/class-map.js"; +import { consume } from "@lit-labs/context" +import PFWizard from "@patternfly/patternfly/components/Wizard/wizard.css"; + +import type { WizardStep } from "./types"; +import { akWizardCurrentStepContextName } from "./akWizardCurrentStepContextName"; +import { akWizardStepsContextName } from "./akWizardStepsContextName"; + +/** + * AKWizard is a container for displaying Wizard pages. + * + * AKWizard is one component of a total Wizard development environment. It provides the header, titled + * navigation sidebar, and bottom row button bar. It takes its cues about what to render from two + * data structure, `this.steps: WizardStep[]`, which lists all the current steps *in order* and + * doesn't care otherwise about their structure, and `this.currentStep: WizardStep` which must be a + * _reference_ to a member of `this.steps`. + * + * @element ak-wizard-2 + * + * @fires ak-wizard-nav - Tell the orchestrator what page the user wishes to move to. This is the + * only event that causes this wizard to change its appearance. + * + */ + +@customElement("ak-wizard-2") +export class AkWizard extends CustomEmitterElement(ModalButton) { + static get styles() { + return [...super.styles, PFWizard]; + } + + @property({ type: Boolean }) + canCancel = true; + + @property() + header?: string; + + @property() + description?: string; + + @property() + eventName: string = "ak-wizard-nav"; + + @property({ type: Boolean }) + isValid = false; + + // @ts-expect-error + @consume({ context: akWizardStepsContextName, subscribe: true }) + @state() + steps!: WizardStep[]; + + // @ts-expect-error + @consume({ context: akWizardCurrentStepContextName, subscribe: true }) + @state() + currentStep!: WizardStep; + + reset() { + this.open = false; + } + + renderModalInner() { + // prettier-ignore + return html`
+ ${this.renderHeader()} +
+
+ ${this.renderNavigation()} + ${this.renderMainSection()} +
+ ${this.renderFooter()} +
+
`; + } + + renderHeader() { + return html`
+ ${this.canCancel ? this.renderHeaderCancelIcon() : nothing} +

${this.header}

+

${this.description}

+
`; + } + + renderHeaderCancelIcon() { + return html``; + } + + renderNavigation() { + return html``; + } + + renderNavigationStep(step: WizardStep) { + const buttonClasses = { + "pf-c-wizard__nav-link": true, + "pf-m-current": step.id === this.currentStep.id, + }; + + return html` +
  • + +
  • + `; + } + + renderMainSection() { + return html`
    +
    ${this.currentStep.renderer()}
    +
    `; + } + + renderFooter() { + return html` + + `; + } + + renderFooterNextButton() { + return html``; + } + + renderFooterBackButton() { + return html` + + `; + } + + renderFooterCancelButton() { + return html``; + } +} + +export default AkWizard; diff --git a/web/src/components/ak-wizard-2/ak-wizard-context.ts b/web/src/components/ak-wizard-2/ak-wizard-context.ts new file mode 100644 index 000000000..19afc8cca --- /dev/null +++ b/web/src/components/ak-wizard-2/ak-wizard-context.ts @@ -0,0 +1,71 @@ +import { CustomListenerElement } from "@goauthentik/elements/utils/eventEmitter"; + +import { provide } from "@lit-labs/context"; +import { customElement, property, state } from "@lit/reactive-element/decorators.js"; +import { LitElement, html } from "lit"; + +import type { WizardStep, WizardStepId } from "./types"; +import { WizardStepEvent, } from "./types"; +import { akWizardCurrentStepContextName } from "./akWizardCurrentStepContextName"; +import { akWizardStepsContextName } from "./akWizardStepsContextName"; + + +@customElement("ak-wizard-context") +export class AkWizardContext extends CustomListenerElement(LitElement) { + + @property() + eventName: string = "ak-wizard-nav"; + + @provide({ context: akWizardStepsContextName }) + @property({ attribute: false }) + steps: WizardStep[] = []; + + @provide({ context: akWizardCurrentStepContextName }) + @state() + currentStep!: WizardStep; + + constructor() { + super(); + this.handleNavigation = this.handleNavigation.bind(this); + } + + // This is the only case where currentStep could be anything other than a valid entry. Unless, + // of course, a step itself is so badly messed up it can't point to a real object. + willUpdate(_changedProperties: Map) { + if (this.currentStep === undefined) { + this.currentStep = this.steps[0]; + } + } + + // Note that we always scan for the valid next step and throw an error if we can't find it. + // There should never be a question that the currentStep is a *valid* step. + handleNavigation(event: CustomEvent<{ step: WizardStepId | WizardStepEvent }>) { + const requestedStep = event.detail.step; + if (!requestedStep) { + throw new Error("Request for next step when no next step is available") + } + const step = this.steps.find(({ id }) => id === requestedStep); + if (!step) { + throw new Error("Request for next step when no next step is available."); + } + if (step.disabled) { + throw new Error("Request for next step when the next step is disabled."); + } + this.currentStep = step; + return; + } + + connectedCallback() { + super.connectedCallback(); + this.addCustomListener(this.eventName, this.handleNavigation); + } + + disconnectedCallback() { + this.removeCustomListener(this.eventName, this.handleNavigation); + super.disconnectedCallback(); + } + + render() { + return html``; + } +} diff --git a/web/src/components/ak-wizard-2/akWizardCurrentStepContextName.ts b/web/src/components/ak-wizard-2/akWizardCurrentStepContextName.ts new file mode 100644 index 000000000..3a5c289d8 --- /dev/null +++ b/web/src/components/ak-wizard-2/akWizardCurrentStepContextName.ts @@ -0,0 +1,5 @@ +import { createContext } from "@lit-labs/context"; + +export const akWizardCurrentStepContextName = createContext(Symbol("ak-wizard-current-step")); + +export default akWizardCurrentStepContextName; diff --git a/web/src/components/ak-wizard-2/akWizardStepsContextName.ts b/web/src/components/ak-wizard-2/akWizardStepsContextName.ts new file mode 100644 index 000000000..fd0613e99 --- /dev/null +++ b/web/src/components/ak-wizard-2/akWizardStepsContextName.ts @@ -0,0 +1,5 @@ +import { createContext } from "@lit-labs/context"; + +export const akWizardStepsContextName = createContext(Symbol("ak-wizard-steps")); + +export default akWizardStepsContextName; diff --git a/web/src/components/ak-wizard-2/stories/ak-demo-wizard.ts b/web/src/components/ak-wizard-2/stories/ak-demo-wizard.ts new file mode 100644 index 000000000..e8f9009af --- /dev/null +++ b/web/src/components/ak-wizard-2/stories/ak-demo-wizard.ts @@ -0,0 +1,40 @@ +import { AKElement } from "@goauthentik/elements/Base"; + +import { customElement } from "@lit/reactive-element/decorators/custom-element.js"; +import { html } from "lit"; +import { property } from "lit/decorators.js"; + +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 "../ak-wizard-context"; +import "../ak-wizard-2"; +import type { WizardStep } from "../types"; + +@customElement("ak-demo-wizard") +export class AkDemoWizard extends AKElement { + static get styles() { + return [PFBase, PFButton, PFRadio]; + } + + @property({ attribute: false }) + steps: WizardStep[] = []; + + @property({ type: Boolean }) + open = false; + + render() { + return html` + + + + + + `; + } +} diff --git a/web/src/components/ak-wizard-2/stories/ak-wizard-2.stories.ts b/web/src/components/ak-wizard-2/stories/ak-wizard-2.stories.ts new file mode 100644 index 000000000..71c7c0bfc --- /dev/null +++ b/web/src/components/ak-wizard-2/stories/ak-wizard-2.stories.ts @@ -0,0 +1,72 @@ +import "@goauthentik/elements/messages/MessageContainer"; +import { Meta } from "@storybook/web-components"; + +import { TemplateResult, html } from "lit"; + +import "../ak-wizard-2" +import "./ak-demo-wizard"; +import AkWizard from "../ak-wizard-2"; + +import type { WizardStep } from "../types"; +import { makeWizardId } from "../types"; + +const metadata: Meta = { + title: "Components / Wizard / Basic", + component: "ak-wizard-2", + parameters: { + docs: { + description: { + component: "A container for our wizard.", + }, + }, + }, +}; + +export default metadata; + +const container = (testItem: TemplateResult) => + html`
    + + + ${testItem} +

    Messages received from the button:

    +
      +
      `; + + + +const dummySteps: WizardStep[] = [ + { + id: makeWizardId("0"), + label: "Test Step1", + renderer: () => html`

      This space intentionally left blank today

      `, + disabled: false, + valid: true, + nextStep: makeWizardId("1"), + nextButtonLabel: "Next", + backButtonLabel: undefined, + }, + { + id: makeWizardId("1"), + label: "Test Step 2", + renderer: () => html`

      This space also intentionally left blank

      `, + disabled: false, + valid: true, + backStep: makeWizardId("0"), + nextButtonLabel: undefined, + backButtonLabel: "Back", + }, +]; + +export const OnePageWizard = () => { + return container( + html` ` + ); +}; diff --git a/web/src/components/ak-wizard-2/types.ts b/web/src/components/ak-wizard-2/types.ts new file mode 100644 index 000000000..272a6fc59 --- /dev/null +++ b/web/src/components/ak-wizard-2/types.ts @@ -0,0 +1,24 @@ +import { TemplateResult } from "lit"; + +type PhantomType = {_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, + label: string, + valid: boolean, + renderer: () => TemplateResult, + disabled: boolean, + nextButtonLabel?: string, + backButtonLabel?: string +} + +export enum WizardStepEvent { + next = "next", + back = "back" +}