diff --git a/web/.storybook/manager.ts b/web/.storybook/manager.ts index ab364c0c4..2ac3045a4 100644 --- a/web/.storybook/manager.ts +++ b/web/.storybook/manager.ts @@ -5,4 +5,5 @@ import authentikTheme from "./authentikTheme"; addons.setConfig({ theme: authentikTheme, + enableShortcuts: false, }); diff --git a/web/src/admin/applications/ApplicationForm.ts b/web/src/admin/applications/ApplicationForm.ts index f987f7080..2ddd7a725 100644 --- a/web/src/admin/applications/ApplicationForm.ts +++ b/web/src/admin/applications/ApplicationForm.ts @@ -32,7 +32,7 @@ import { import "./components/ak-backchannel-input"; import "./components/ak-provider-search-input"; -const policyOptions = [ +export const policyOptions = [ { label: "any", value: PolicyEngineMode.Any, diff --git a/web/src/admin/applications/wizard/InitialApplicationWizardPage.ts b/web/src/admin/applications/wizard/InitialApplicationWizardPage.ts deleted file mode 100644 index 1e4388e70..000000000 --- a/web/src/admin/applications/wizard/InitialApplicationWizardPage.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; -import { convertToSlug } from "@goauthentik/common/utils"; -import { KeyUnknown } from "@goauthentik/elements/forms/Form"; -import "@goauthentik/elements/forms/FormGroup"; -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 { ApplicationRequest, CoreApi, Provider } from "@goauthentik/api"; - -@customElement("ak-application-wizard-initial") -export class InitialApplicationWizardPage extends WizardFormPage { - sidebarLabel = () => msg("Application details"); - - nextDataCallback = async (data: KeyUnknown): Promise => { - const name = data.name as string; - let slug = convertToSlug(name || ""); - // Check if an application with the generated slug already exists - const apps = await new CoreApi(DEFAULT_CONFIG).coreApplicationsList({ - search: slug, - }); - if (apps.results.filter((app) => app.slug == slug)) { - slug += "-1"; - } - this.host.state["slug"] = slug; - this.host.state["name"] = name; - this.host.addActionBefore(msg("Create application"), async (): Promise => { - const req: ApplicationRequest = { - name: name || "", - slug: slug, - metaPublisher: data.metaPublisher as string, - metaDescription: data.metaDescription as string, - }; - if ("provider" in this.host.state) { - req.provider = (this.host.state["provider"] as Provider).pk; - } - if ("link" in this.host.state) { - req.metaLaunchUrl = this.host.state["link"] as string; - } - this.host.state["app"] = await new CoreApi(DEFAULT_CONFIG).coreApplicationsCreate({ - applicationRequest: req, - }); - return true; - }); - return true; - }; - - renderForm(): TemplateResult { - return html` -
- - -

${msg("Application's display Name.")}

-
- - ${msg("Additional UI settings")} -
- - - - - - -
-
-
- `; - } -} diff --git a/web/src/admin/applications/wizard/ak-application-wizard-application-details.css.ts b/web/src/admin/applications/wizard/ak-application-wizard-application-details.css.ts new file mode 100644 index 000000000..141d32dee --- /dev/null +++ b/web/src/admin/applications/wizard/ak-application-wizard-application-details.css.ts @@ -0,0 +1,26 @@ +import { css } from "lit"; + +import PFAlert from "@patternfly/patternfly/components/Alert/alert.css"; +import PFButton from "@patternfly/patternfly/components/Button/button.css"; +import PFCard from "@patternfly/patternfly/components/Card/card.css"; +import PFForm from "@patternfly/patternfly/components/Form/form.css"; +import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css"; +import PFInputGroup from "@patternfly/patternfly/components/InputGroup/input-group.css"; +import PFSwitch from "@patternfly/patternfly/components/Switch/switch.css"; +import PFBase from "@patternfly/patternfly/patternfly-base.css"; + +export const styles = [ + PFBase, + PFCard, + PFButton, + PFForm, + PFAlert, + PFInputGroup, + PFFormControl, + PFSwitch, + css` +select[multiple] { +height: 15em; +} +`, +]; diff --git a/web/src/admin/applications/wizard/ak-application-wizard-application-details.ts b/web/src/admin/applications/wizard/ak-application-wizard-application-details.ts new file mode 100644 index 000000000..f10cd3006 --- /dev/null +++ b/web/src/admin/applications/wizard/ak-application-wizard-application-details.ts @@ -0,0 +1,103 @@ +import { policyOptions } from "@goauthentik/admin/applications/ApplicationForm"; +import { first } from "@goauthentik/common/utils"; +import "@goauthentik/components/ak-radio-input"; +import "@goauthentik/components/ak-switch-input"; +import "@goauthentik/components/ak-text-input"; +import { AKElement } from "@goauthentik/elements/Base"; +import "@goauthentik/elements/forms/FormGroup"; +import "@goauthentik/elements/forms/FormGroup"; +import "@goauthentik/elements/forms/HorizontalFormElement"; +import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter"; + +import { consume } from "@lit-labs/context"; +import { msg } from "@lit/localize"; +import { customElement } from "@lit/reactive-element/decorators/custom-element.js"; +import { state } from "@lit/reactive-element/decorators/state.js"; +import { TemplateResult, html } from "lit"; +import { ifDefined } from "lit/directives/if-defined.js"; + +import { styles as AwadStyles } from "./ak-application-wizard-application-details.css"; + +import type { WizardState } from "./ak-application-wizard-context"; +import applicationWizardContext from "./ak-application-wizard-context-name"; + +@customElement("ak-application-wizard-application-details") +export class ApplicationWizardApplicationDetails extends CustomEmitterElement(AKElement) { + static get styles() { + return AwadStyles; + } + + @consume({ context: applicationWizardContext, subscribe: true }) + @state() + private wizard!: WizardState; + + handleChange(ev: Event) { + const value = ev.target.type === "checkbox" ? ev.target.checked : ev.target.value; + + this.dispatchCustomEvent("ak-wizard-update", { + ...this.wizard, + application: { + ...this.wizard.application, + [ev.target.name]: value, + }, + }); + } + + render(): TemplateResult { + return html`
+ + + + + + ${msg("UI settings")} +
+ + + +
+
+
`; + } +} + +export default ApplicationWizardApplicationDetails; diff --git a/web/src/admin/applications/wizard/ak-application-wizard-context-name.ts b/web/src/admin/applications/wizard/ak-application-wizard-context-name.ts new file mode 100644 index 000000000..b6be30adb --- /dev/null +++ b/web/src/admin/applications/wizard/ak-application-wizard-context-name.ts @@ -0,0 +1,5 @@ +import {createContext} from '@lit-labs/context'; + +export const ApplicationWizardContext = createContext(Symbol('ak-application-wizard-context')); + +export default ApplicationWizardContext; diff --git a/web/src/admin/applications/wizard/ak-application-wizard-context.ts b/web/src/admin/applications/wizard/ak-application-wizard-context.ts new file mode 100644 index 000000000..3279cda78 --- /dev/null +++ b/web/src/admin/applications/wizard/ak-application-wizard-context.ts @@ -0,0 +1,74 @@ +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 + | Partial + | Partial + | Partial + | Partial + | Partial; + +export type WizardState = { + step: number; + application: Partial; + provider: OneOfProvider; +}; + +@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, + 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) { + delete event.detail.target; + this.wizardState = event.detail; + } + + render() { + return html``; + } +} + +export default AkApplicationWizardContext; diff --git a/web/src/admin/applications/wizard/ApplicationWizard.ts b/web/src/admin/applications/wizard/ak-application-wizard.ts similarity index 78% rename from web/src/admin/applications/wizard/ApplicationWizard.ts rename to web/src/admin/applications/wizard/ak-application-wizard.ts index d8ceb9749..4aa5a9ae8 100644 --- a/web/src/admin/applications/wizard/ApplicationWizard.ts +++ b/web/src/admin/applications/wizard/ak-application-wizard.ts @@ -22,6 +22,29 @@ 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"; +const steps = [ + { + name: msg("Application Details"), + view: () => + html``, + }, + { + name: msg("Authentication Method"), + view: () => + html``, + }, + { + name: msg("Authentication Details"), + view: () => + html``, + }, + { + name: msg("Save Application"), + view: () => + html``, + }, +]; + @customElement("ak-application-wizard") export class ApplicationWizard extends AKElement { static get styles(): CSSResult[] { diff --git a/web/src/admin/applications/wizard/stories/ak-application-context-display-for-test.ts b/web/src/admin/applications/wizard/stories/ak-application-context-display-for-test.ts new file mode 100644 index 000000000..3f8e0ce26 --- /dev/null +++ b/web/src/admin/applications/wizard/stories/ak-application-context-display-for-test.ts @@ -0,0 +1,18 @@ +import { consume } from "@lit-labs/context"; +import { customElement } from "@lit/reactive-element/decorators/custom-element.js"; +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"; + +@customElement("ak-application-context-display-for-test") +export class ApplicationContextDisplayForTest extends LitElement { + @consume({ context: applicationWizardContext, subscribe: true }) + @state() + private wizard!: WizardState; + + render() { + return html`
${JSON.stringify(this.wizard, null, 2)}
`; + } +} diff --git a/web/src/admin/applications/wizard/stories/ak-application-wizard-application-details.stories.ts b/web/src/admin/applications/wizard/stories/ak-application-wizard-application-details.stories.ts new file mode 100644 index 000000000..18beccf20 --- /dev/null +++ b/web/src/admin/applications/wizard/stories/ak-application-wizard-application-details.stories.ts @@ -0,0 +1,44 @@ +import { Meta } from "@storybook/web-components"; + +import { TemplateResult, html } from "lit"; + +import "../ak-application-wizard-application-details"; +import AkApplicationWizardApplicationDetails from "../ak-application-wizard-application-details"; +import "../ak-application-wizard-context"; +import "./ak-application-context-display-for-test"; + +const metadata: Meta = { + title: "Elements / Application Wizard / Page 1", + component: "ak-application-wizard-application-details", + parameters: { + docs: { + description: { + component: "The first page of the application wizard", + }, + }, + }, +}; + +export default metadata; + +const container = (testItem: TemplateResult) => + html`
+ + ${testItem} +
`; + +export const PageOne = () => { + return container( + html` + + + ` + ); +}; diff --git a/web/src/components/ak-wizard/ak-wizard.stories.ts b/web/src/components/ak-wizard/ak-wizard.stories.ts new file mode 100644 index 000000000..7d81d2eeb --- /dev/null +++ b/web/src/components/ak-wizard/ak-wizard.stories.ts @@ -0,0 +1,82 @@ +import { Meta } from "@storybook/web-components"; + +import { TemplateResult, html } from "lit"; + +import "./ak-wizard"; +import AkWizard from "./ak-wizard"; + +const metadata: Meta = { + title: "Components / Wizard", + component: "ak-wizard", + parameters: { + docs: { + description: { + component: "A Wizard for wrapping multiple steps", + }, + }, + }, +}; + +export default metadata; + +const container = (testItem: TemplateResult) => + html`
+ + + ${testItem} +

Messages received from the button:

+
    +
    `; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const displayMessage = (result: any) => { + const doc = new DOMParser().parseFromString( + `
  • Event: ${ + "result" in result.detail ? result.detail.result : result.detail.error + }
  • `, + "text/xml", + ); + const target = document.querySelector("#action-button-message-pad"); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + target!.appendChild(doc.firstChild!); +}; + +window.addEventListener("ak-button-success", displayMessage); +window.addEventListener("ak-button-failure", displayMessage); + +export const ButtonWithSuccess = () => { + const run = () => + new Promise(function (resolve) { + setTimeout(function () { + resolve("Success!"); + }, 3000); + }); + + return container( + html`3 Seconds`, + ); +}; + +export const ButtonWithError = () => { + const run = () => + new Promise((resolve, reject) => { + setTimeout(() => { + reject(new Error("This is the error message.")); + }, 3000); + }); + + return container( + html` 3 Seconds`, + ); +}; diff --git a/web/src/components/ak-wizard/ak-wizard.ts b/web/src/components/ak-wizard/ak-wizard.ts new file mode 100644 index 000000000..ca915c4fb --- /dev/null +++ b/web/src/components/ak-wizard/ak-wizard.ts @@ -0,0 +1,99 @@ +import { ModalButton } from "@goauthentik/elements/buttons/ModalButton"; + +import { msg } from "@lit/localize"; +import { property } from "@lit/reactive-element/decorators/property.js"; +import { state } from "@lit/reactive-element/decorators/state.js"; +import { html, nothing } from "lit"; +import type { TemplateResult } from "lit"; + +/** + * @class AkWizard + * + * @element ak-wizard + * + * The ak-wizard element exists to guide users through a complex task by dividing it into sections + * and granting them successive access to future sections. Our wizard has four "zones": The header, + * the breadcrumb toolbar, the navigation controls, and the content of the panel. + * + */ + +type WizardStep = { + name: string; + constructor: () => TemplateResult; +}; + +export class AkWizard extends ModalButton { + @property({ type: Boolean }) + required = false; + + @property() + wizardtitle?: string; + + @property() + description?: string; + + constructor() { + super(); + this.handleClose = this.handleClose.bind(this); + } + + handleClose() { + this.open = false; + } + + renderModalInner() { + return html`
    + ${this.renderWizardHeader()} +
    +
    ${this.renderWizardNavigation()}
    +
    +
    `; + } + + renderWizardHeader() { + const renderCancelButton = () => + html``; + + return html`
    + ${this.required ? nothing : renderCancelButton()} +

    ${this.wizardtitle}

    +

    ${this.description}

    +
    `; + } + + renderWizardNavigation() { + const currentIdx = this.currentStep ? this.steps.indexOf(this.currentStep.slot) : 0; + + const renderNavStep = (step: string, idx: number) => { + return html` +
  • + +
  • + `; + }; + + return html` `; + } +}