diff --git a/authentik/flows/templates/if/flow.html b/authentik/flows/templates/if/flow.html index 848e53504013..f618ea537235 100644 --- a/authentik/flows/templates/if/flow.html +++ b/authentik/flows/templates/if/flow.html @@ -48,7 +48,7 @@ - - + `; + } + + update( + part: ChildPart, + [template, options = {}]: [TemplateResult | DirectiveResult, LightChildOptions], + ) { + this.slotName ??= options?.slotName ?? `lc-${Math.random().toString(36).slice(2, 8)}`; + + // This places a comment in the LightDom that belongs to this directive. Comments are not + // part of the DOM tree for the purposes of CSS, so it will be possible to style this child + // directly without a wrapper. + + if (!this.sentinel) { + const rootNode = part.parentNode.getRootNode(); + this.host ??= (part.options?.host || + (rootNode instanceof ShadowRoot ? rootNode.host : null)) as Element | null; + + if (!this.host) { + throw new Error( + "light() must be used inside a shadow root or a valid options.host", + ); + } + + this.sentinel = document.createComment(""); + this.host.appendChild(this.sentinel); + } + + if (!this.sentinel.parentNode) { + throw new Error("Could not assign sentinel to element."); + } + + const renderOptions = Object.fromEntries( + Object.entries(options).filter(([key]) => ["host"].includes(key)), + ); + + this.rootPart = render(template, this.sentinel.parentNode as HTMLElement, { + renderBefore: this.sentinel, + ...renderOptions, + }); + + const rendered = this.sentinel.previousSibling; + if (rendered instanceof Element) { + rendered.slot = this.slotName; + } + + if (!this.slot) { + this.slot = Object.assign(document.createElement("slot"), { + name: this.slotName, + }); + return this.slot; + } + + return noChange; + } + + disconnected() { + if (this.sentinel?.parentNode && this.host?.isConnected) { + // The node that contains the directive has been disconnected, *not* the host. We need + // to clean up the associated lightDOM element. + render(nothing, this.sentinel.parentNode as HTMLElement, { + renderBefore: this.sentinel, + }); + this.sentinel.remove(); + this.sentinel = null; + this.rootPart = null; + return; + } + + // The host has been disconnected. Inform any child components. + this.rootPart?.setConnected(false); + } + + reconnected() { + this.rootPart?.setConnected(true); + } +} + +export const light = directive(LightChildDirective); diff --git a/web/src/flow/Flow.stories.ts b/web/src/flow/Flow.stories.ts new file mode 100644 index 000000000000..3ead62272b1f --- /dev/null +++ b/web/src/flow/Flow.stories.ts @@ -0,0 +1,11 @@ +import "@patternfly/patternfly/components/Login/login.css"; +import "#stories/flow-interface"; +import "#flow/stages/dummy/DummyStage"; + +import { flowFactory } from "#stories/flow-interface"; + +export default { + title: "Flow / ak-flow-executor", +}; + +export const BackgroundImage = flowFactory("ak-stage-dummy"); diff --git a/web/src/flow/Flow.ts b/web/src/flow/Flow.ts new file mode 100644 index 000000000000..446f119cac2c --- /dev/null +++ b/web/src/flow/Flow.ts @@ -0,0 +1,307 @@ +import "#elements/LoadingOverlay"; +import "#elements/locale/ak-locale-select"; +import "#flow/inspector/FlowInspectorButton"; +import "#flow/FlowExecutor"; +import "#flow/tabs/broadcast"; + +import { FlowWebsocketClientController } from "./controllers/FlowWebsocketClientController"; +import Styles from "./FlowExecutor.css" with { type: "bundled-text" }; + +import { globalAK } from "#common/global"; +import { applyBackgroundImageProperty } from "#common/theme"; +import { AKSessionAuthenticatedEvent } from "#common/ws/events"; + +import { listen } from "#elements/decorators/listen"; +import { light } from "#elements/directives/light"; +import { Interface } from "#elements/Interface"; +import { WithBrandConfig } from "#elements/mixins/branding"; +import { SlottedTemplateResult } from "#elements/types"; +import { ThemedImage } from "#elements/utils/images"; + +import { AKFlowInfoUpdateEvent, AKFlowLoadingEvent } from "#flow/events"; + +import { ConsoleLogger } from "#logger/browser"; + +import { ContextualFlowInfo, FlowLayoutEnum } from "@goauthentik/api"; + +import { msg } from "@lit/localize"; +import { CSSResult, html, nothing, PropertyValues } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; +import { guard } from "lit/directives/guard.js"; + +import PFBackgroundImage from "@patternfly/patternfly/components/BackgroundImage/background-image.css"; +import PFButton from "@patternfly/patternfly/components/Button/button.css"; +import PFDrawer from "@patternfly/patternfly/components/Drawer/drawer.css"; +import PFList from "@patternfly/patternfly/components/List/list.css"; +import PFLogin from "@patternfly/patternfly/components/Login/login.css"; +import PFTitle from "@patternfly/patternfly/components/Title/title.css"; + +/// + +const isContextualFlowInfo = (v: unknown): v is ContextualFlowInfo => + typeof v === "object" && v !== null; + +/** + * The application shell for authentik flows and the Flow Executor. + * + * Provides the decorations and features that go around the executor: background, layout, locale + * selector, flow inspector button, headers, footers, and the iframe if provided. + * + * @attr {string} slug - The slug of the flow to execute. Prop-drilled to the executor. + * @attr {FlowLayoutEnum} data-layout - Page layout variant. Defaults to `globalAK().flow.layout` or + * just `stacked` + * + * @slot footer - The page-level footer content. Currently filled by `ak-brand-links`. + * + * @part main - The main container for the flow content. + * @part flow-executor - Wrapper around ak-flow-executor + * @part content - The container for the stage content. + * @part content-iframe - The iframe element when using a frame background layout. + * @part footer - The footer container. + * @part locale-select - The locale select component. + * @part branding - The branding element, used for the background image in some layouts. + * @part loading-overlay - The loading overlay element. + * @part locale-select-label - The label of the locale select component. + * @part locale-select-select - The select element of the locale select component. + * + * NOTE: This is the application shell, the top-level component. From here, we invoke the + * flow-executor in-line in the template rendered, but use the `light()` directive to inject it into + * the Flow element's lightDOM, and a slot is emplaced where the flow-executor's part of the + * template would go. This enables password managers to traverse down into the flow and its stages + * without having to cross or know about shadowDOM boundaries. + * + */ +@customElement("ak-flow") +export class Flow extends WithBrandConfig(Interface) { + //#region Static + + public static readonly DefaultLayout: FlowLayoutEnum = + globalAK()?.flow?.layout || FlowLayoutEnum.Stacked; + + static styles: CSSResult[] = [ + PFLogin, + PFDrawer, + PFButton, + PFTitle, + PFList, + PFBackgroundImage, + Styles, + ]; + + //#endregion + + //#region Properties + + @property() + public slug: string = window.location.pathname.split("/")[3]; + + // Reflection is required to trigger the correct behavior with CSS; + @property({ attribute: "data-layout", reflect: true }) + public layout: FlowLayoutEnum = Flow.DefaultLayout; + + @state() + protected loading = false; + + @state() + public title: string = ""; + + @state() + public background: ContextualFlowInfo["background"]; + + @state() + protected backgroundThemedUrls?: ContextualFlowInfo["backgroundThemedUrls"]; + + #abortController: AbortController | null = null; + + readonly #wsController = new FlowWebsocketClientController(this); + + readonly #logger = ConsoleLogger.prefix("flow"); + + //#region Render + + constructor() { + super(); + this.addController(this.#wsController); + } + + #handleFlowUpdate = (event: AKFlowInfoUpdateEvent) => { + const { flowInfo } = event; + if (!isContextualFlowInfo(flowInfo)) { + return; + } + + if ("title" in flowInfo && flowInfo.title !== undefined) { + this.title = flowInfo.title; + } + + if ("background" in flowInfo && flowInfo.background !== undefined) { + this.background = flowInfo.background; + } + + if ("backgroundThemedUrls" in flowInfo && flowInfo.backgroundThemedUrls !== undefined) { + this.backgroundThemedUrls = flowInfo.backgroundThemedUrls; + } + + if ("layout" in flowInfo && flowInfo.layout !== undefined) { + this.layout = flowInfo.layout; + } + }; + + #handleLoading = (event: AKFlowLoadingEvent) => { + this.loading = true; + // The event comes with a payload: a protected boolean promise that reflects the pending + // state of whatever triggered the "loading" state deep down. Neat trick here: we simply + // await on it and, when it's done, we trigger a state change. No other system needs to + // track "loading" states at all. + event.awaiter.finally(() => { + this.loading = false; + }); + }; + + public override connectedCallback(): void { + super.connectedCallback(); + + if (this.#abortController) { + this.#abortController.abort(); + } + + this.#abortController = new AbortController(); + const { signal } = this.#abortController; + this.addEventListener(AKFlowInfoUpdateEvent.eventName, this.#handleFlowUpdate, { signal }); + this.addEventListener(AKFlowLoadingEvent.eventName, this.#handleLoading, { signal }); + } + + public override disconnectedCallback(): void { + super.disconnectedCallback(); + this.#abortController?.abort(); + this.#abortController = null; + } + + @listen(AKSessionAuthenticatedEvent) + protected onSessionAuthenticated = () => { + if (document.hidden) { + this.#logger.debug("Reloading after session authenticated in background tab"); + window.location.reload(); + } + }; + + get #layoutUsesSidebarFrames(): boolean { + return ( + this.layout === FlowLayoutEnum.SidebarLeftFrameBackground || + this.layout === FlowLayoutEnum.SidebarRightFrameBackground + ); + } + + #synchronizeBackground() { + if (!(this.background || this.backgroundThemedUrls) || this.#layoutUsesSidebarFrames) + return; + + const background = this.backgroundThemedUrls?.[this.activeTheme] || this.background; + + // Storybook has a different document structure. + const target = + import.meta.env.AK_BUNDLER === "storybook" + ? this.closest(".docs-story") + : this.ownerDocument.body; + + applyBackgroundImageProperty(background, { target }); + } + + protected renderHeader() { + return ThemedImage({ + src: this.brandingLogo, + alt: msg("authentik Logo"), + className: "branding-logo", + theme: this.activeTheme, + themedUrls: this.brandingLogoThemedUrls, + }); + } + + // Only used by the `sidebar_*_frame_backgrounds` to give customers a place to put their + // branding visuals, if they like. + // + protected renderFrameBackground() { + return guard([this.layout, this.background], () => { + if (!this.#layoutUsesSidebarFrames) return nothing; + + const { background } = this; + if (!background) return nothing; + + return html` + + `; + }); + } + + protected renderFooter() { + return guard([this.layout], () => { + return html`
+ +
`; + }); + } + + protected override render(): SlottedTemplateResult { + const { loading } = this; + + return html` + + ${this.renderFrameBackground()} + +
+ + ${loading + ? html`` + : nothing} +
+ ${light(html``)} +
+
+ ${this.renderFooter()}`; + } + + //#endregion + + public override updated(changed: PropertyValues) { + super.updated(changed); + if (changed.has("title")) { + const brand = this.brandingTitle; + document.title = this.title ? `${this.title} - ${brand}` : brand; + } + + if (changed.has("activeTheme") || changed.has("background" satisfies keyof Flow)) { + this.#synchronizeBackground(); + } + } +} + +declare global { + interface HTMLElementTagNameMap { + "ak-flow": Flow; + } +} diff --git a/web/src/flow/FlowExecutor.ts b/web/src/flow/FlowExecutor.ts index b25e9bfbe223..b7299c61c73a 100644 --- a/web/src/flow/FlowExecutor.ts +++ b/web/src/flow/FlowExecutor.ts @@ -1,32 +1,24 @@ -import "#elements/LoadingOverlay"; -import "#elements/locale/ak-locale-select"; -import "#flow/components/ak-brand-footer"; -import "#flow/components/ak-flow-card"; -import "#flow/inspector/FlowInspectorButton"; +import "#flow/stages/FlowErrorStage"; import "#flow/tabs/broadcast"; import { FlowIframeMessageController } from "./controllers/FlowIframeMessageController"; import { FlowMultitabController } from "./controllers/FlowMultitabController"; -import { FlowWebsocketClientController } from "./controllers/FlowWebsocketClientController"; -import Styles from "./FlowExecutor.css" with { type: "bundled-text" }; import { DEFAULT_CONFIG } from "#common/api/config"; import { APIError, parseAPIResponseError, pluckErrorDetail } from "#common/errors/network"; -import { globalAK } from "#common/global"; import { configureSentry } from "#common/sentry/index"; -import { applyBackgroundImageProperty } from "#common/theme"; -import { AKSessionAuthenticatedEvent } from "#common/ws/events"; -import { listen } from "#elements/decorators/listen"; +import { light } from "#elements/directives/light"; import { Interface } from "#elements/Interface"; import { showAPIErrorMessage } from "#elements/messages/MessageContainer"; import { WithBrandConfig } from "#elements/mixins/branding"; import { LitPropertyRecord, SlottedTemplateResult } from "#elements/types"; import { exportParts } from "#elements/utils/attributes"; -import { ThemedImage } from "#elements/utils/images"; import { AKFlowAdvanceEvent, + AKFlowInfoUpdateEvent, + AKFlowLoadingEvent, AKFlowSubmitRequest, AKFlowUpdateChallengeRequest, } from "#flow/events"; @@ -40,32 +32,39 @@ import { ChallengeTypes, FlowChallengeResponseRequest, FlowErrorChallenge, - FlowLayoutEnum, FlowsApi, } from "@goauthentik/api"; import { spread } from "@open-wc/lit-helpers"; -import { match, P } from "ts-pattern"; +import { observed } from "@patternfly/pfe-core/decorators/observed.js"; +import { match } from "ts-pattern"; -import { msg } from "@lit/localize"; -import { CSSResult, html, nothing, PropertyValues } from "lit"; -import { customElement, property } from "lit/decorators.js"; +import { html, PropertyValues } from "lit"; +import { customElement, property, state } from "lit/decorators.js"; import { guard } from "lit/directives/guard.js"; import { unsafeHTML } from "lit/directives/unsafe-html.js"; import { until } from "lit/directives/until.js"; import { html as staticHTML, unsafeStatic } from "lit/static-html.js"; -import PFBackgroundImage from "@patternfly/patternfly/components/BackgroundImage/background-image.css"; -import PFButton from "@patternfly/patternfly/components/Button/button.css"; -import PFDrawer from "@patternfly/patternfly/components/Drawer/drawer.css"; -import PFList from "@patternfly/patternfly/components/List/list.css"; -import PFLogin from "@patternfly/patternfly/components/Login/login.css"; -import PFTitle from "@patternfly/patternfly/components/Title/title.css"; - /// +type ChallengeProps = LitPropertyRecord, object>>; + /** - * An executor for authentik flows. + * An executor for authentik flows + * + * @remarks + * + * A *Flow* is a series of steps the authentik server takes to perform one of + * its core functions, such as authentication, enrollment, or account recovery. + * A *stage* is one step; a *challenge* is a stage that requires user input to + * complete: you username, your password, an action with an MFA. + * + * The purpose of the FlowExecutor is to receive a challenge, select the + * client-side component (also called "stages") best suited to showing the + * request to the user, send the input to the server and deal with the response. + * + * * @attr {string} slug - The slug of the flow to execute. * @prop {ChallengeTypes | null} challenge - The current challenge to render. @@ -76,7 +75,6 @@ import PFTitle from "@patternfly/patternfly/components/Title/title.css"; * @part footer - The footer container. * @part locale-select - The locale select component. * @part branding - The branding element, used for the background image in some layouts. - * @part loading-overlay - The loading overlay element. * @part challenge-additional-actions - Container in stages which have additional actions. * @part challenge-footer-band - Container for the stage footer, used for additional actions in some stages. * @part locale-select-label - The label of the locale select component. @@ -84,62 +82,37 @@ import PFTitle from "@patternfly/patternfly/components/Title/title.css"; */ @customElement("ak-flow-executor") export class FlowExecutor extends WithBrandConfig(Interface) implements StageHost { - public static readonly DefaultLayout: FlowLayoutEnum = - globalAK()?.flow?.layout || FlowLayoutEnum.Stacked; - - //#region Styles - - static styles: CSSResult[] = [ - PFLogin, - PFDrawer, - PFButton, - PFTitle, - PFList, - PFBackgroundImage, - Styles, - ]; - - //#endregion - //#region Properties @property({ type: String, attribute: "slug", useDefault: true }) public flowSlug: string = window.location.pathname.split("/")[3]; + // A new challenge can contain data that clients may want to use to alter + // the look of the executor's container. Provide notice of those changes. + + @observed("handleFlowUpdate") @property({ attribute: false }) public challenge: ChallengeTypes | null = null; - @property({ type: Boolean }) + @state() public loading = false; - @property({ type: String, attribute: "data-layout", useDefault: true, reflect: true }) - public layout: FlowLayoutEnum = FlowExecutor.DefaultLayout; - //#endregion //#region Internal State - #logger = ConsoleLogger.prefix("flow-executor"); + readonly #logger = ConsoleLogger.prefix("flow-executor"); - #api: FlowsApi; + readonly #api: FlowsApi; // Listen for challenge-forwarding events from iframe-based third-party verifiers (Device Compliance) - #flowIframeMessageController = new FlowIframeMessageController(this); + readonly #flowIframeMessageController = new FlowIframeMessageController(this); // Listen for authentik state-change events from other tabs - #flowMultitabController = new FlowMultitabController(this); - - // Listen for server-side events and forward them to the notification handler - #flowWebsocketClientController = new FlowWebsocketClientController(this); + readonly #flowMultitabController = new FlowMultitabController(this); //#endregion - //#region Accessors - - public get flowInfo() { - return this.challenge?.flowInfo ?? null; - } - //region Live event handlers handleChallengeRequest = (event: AKFlowUpdateChallengeRequest) => { @@ -152,6 +125,10 @@ export class FlowExecutor extends WithBrandConfig(Interface) implements StageHos this.submit(payload, options); }; + handleFlowUpdate() { + this.dispatchEvent(new AKFlowInfoUpdateEvent(this.challenge?.flowInfo)); + } + //endregion //#region Lifecycle @@ -162,48 +139,10 @@ export class FlowExecutor extends WithBrandConfig(Interface) implements StageHos this.#api = new FlowsApi(DEFAULT_CONFIG); this.addController(this.#flowIframeMessageController); this.addController(this.#flowMultitabController); - this.addController(this.#flowWebsocketClientController); this.addEventListener(AKFlowUpdateChallengeRequest.eventName, this.handleChallengeRequest); this.addEventListener(AKFlowSubmitRequest.eventName, this.handleSubordinateSubmit); } - /** - * Synchronize flow info such as background image with the current state. - */ - get #layoutUsesSidebarFrames() { - return ( - this.layout === FlowLayoutEnum.SidebarLeftFrameBackground || - this.layout === FlowLayoutEnum.SidebarRightFrameBackground - ); - } - - #synchronizeFlowInfo() { - if (!this.flowInfo || this.#layoutUsesSidebarFrames) return; - - const background = - this.flowInfo.backgroundThemedUrls?.[this.activeTheme] || this.flowInfo.background; - - // Storybook has a different document structure, so we need to adjust the target accordingly. - const target = - import.meta.env.AK_BUNDLER === "storybook" - ? this.closest(".docs-story") - : this.ownerDocument.body; - - applyBackgroundImageProperty(background, { target }); - } - - //#region Listeners - - @listen(AKSessionAuthenticatedEvent, { target: window }) - protected sessionAuthenticatedListener = () => { - if (!document.hidden) { - return; - } - - console.debug("authentik/ws: Reloading after session authenticated event"); - window.location.reload(); - }; - private setFlowErrorChallenge(error: APIError) { this.challenge = { component: "ak-stage-flow-error", @@ -218,13 +157,14 @@ export class FlowExecutor extends WithBrandConfig(Interface) implements StageHos return Promise.resolve(); } - this.loading = true; + const fetch = this.#api.flowsExecutorGet({ + flowSlug: this.flowSlug, + query: window.location.search.substring(1), + }); - return this.#api - .flowsExecutorGet({ - flowSlug: this.flowSlug, - query: window.location.search.substring(1), - }) + this.dispatchEvent(new AKFlowLoadingEvent(fetch)); + + return fetch .then((challenge) => { this.challenge = challenge; return !!this.challenge; @@ -234,74 +174,52 @@ export class FlowExecutor extends WithBrandConfig(Interface) implements StageHos showAPIErrorMessage(parsedError); this.setFlowErrorChallenge(parsedError); return false; - }) - .finally(() => { - this.loading = false; }); }; - public async firstUpdated(changed: PropertyValues): Promise { - super.firstUpdated(changed); - - this.refresh().then(() => { - window.dispatchEvent(new AKFlowAdvanceEvent()); - }); - } - - // DOM post-processing has to happen after the render. - public updated(changedProperties: PropertyValues) { - super.updated(changedProperties); - - document.title = match(this.challenge?.flowInfo?.title) - .with(P.nullish, () => this.brandingTitle) - .otherwise((title) => `${title} - ${this.brandingTitle}`); - - if (changedProperties.has("challenge") && this.challenge?.flowInfo) { - this.layout = this.challenge?.flowInfo?.layout || FlowExecutor.DefaultLayout; - } - - if (changedProperties.has("flowInfo") || changedProperties.has("activeTheme")) { - this.#synchronizeFlowInfo(); - } - } - //#endregion //#region Public Methods - public submit = async ( - payload?: FlowChallengeResponseRequestBody, - options?: SubmitOptions, - ): Promise => { + public submit = async (payload?: FlowChallengeResponseRequestBody, options?: SubmitOptions) => { if (!payload) throw new Error("No payload provided"); if (!this.challenge) throw new Error("No challenge provided"); if (!this.flowSlug) { if (import.meta.env.AK_BUNDLER === "storybook") { this.#logger.debug("Skipping submit flow slug check in storybook"); - return true; } throw new Error("No flow slug provided"); } + // The `as` clauses are necessary because OpenAPI doesn't really do enums, it does records + // and unions of records. Alternatives to using `as` would require putting the type being + // submitted into the `submit` method's definition, and then modifying every stage to tell + // the executor what type is being submitted. That would be lots of code for no win; it's + // not coherent to think a stage for a request type will submit a different request type. + // (It's possible, but if that doesn't show up in testing we're in a mess anyway. + // This order is deliberate; the executor always specifies the component token. + const { component } = this.challenge as FlowChallengeResponseRequest; + const flowChallengeResponseRequest = { ...payload, - component: this.challenge.component as FlowChallengeResponseRequest["component"], + component, } as FlowChallengeResponseRequest; + const solve = this.#api.flowsExecutorSolve({ + flowSlug: this.flowSlug, + query: window.location.search.substring(1), + flowChallengeResponseRequest, + }); + if (!options?.invisible) { - this.loading = true; + this.dispatchEvent(new AKFlowLoadingEvent(solve)); } - return this.#api - .flowsExecutorSolve({ - flowSlug: this.flowSlug, - query: window.location.search.substring(1), - flowChallengeResponseRequest, - }) + return solve .then((challenge) => { window.dispatchEvent(new AKFlowAdvanceEvent()); this.challenge = challenge; @@ -310,33 +228,30 @@ export class FlowExecutor extends WithBrandConfig(Interface) implements StageHos .catch((error: APIError) => { this.setFlowErrorChallenge(error); return false; - }) - .finally(() => { - this.loading = false; }); }; //#region Render Challenge + protected async renderChallengeSpecialCases(challenge: ChallengeTypes) { + if (challenge.component === "xak-flow-shell") { + return html`${unsafeHTML(challenge.body)}`; + } + + return this.renderChallengeError(`No stage found for component: ${challenge.component}`); + } + protected async renderChallenge(challenge: ChallengeTypes) { const stageEntry = StageMapping.registry.get(challenge.component); - // The special cases! if (!stageEntry) { - if (challenge.component === "xak-flow-shell") { - return html`${unsafeHTML(challenge.body)}`; - } - - return this.renderChallengeError( - `No stage found for component: ${challenge.component}`, - ); + return this.renderChallengeSpecialCases(challenge); } - const challengeProps: LitPropertyRecord, object>> = - { - ".challenge": challenge, - ".host": this, - }; + const challengeProps: ChallengeProps = { + ".challenge": challenge, + ".host": this, + }; const litParts = { part: "challenge", @@ -359,106 +274,47 @@ export class FlowExecutor extends WithBrandConfig(Interface) implements StageHos .with("standard", () => ({ ...challengeProps, ...litParts })) .exhaustive(), ); - - return staticHTML`<${unsafeStatic(tag)} ${props}>`; + return light(staticHTML`<${unsafeStatic(tag)} ${props}>`); } protected renderChallengeError(error: unknown): SlottedTemplateResult { - const detail = pluckErrorDetail(error); - // eslint-disable-next-line no-console console.trace(error); const errorChallenge: FlowErrorChallenge = { component: "ak-stage-flow-error", - error: detail, + error: pluckErrorDetail(error), requestId: "", }; return html``; } - //#endregion - - //#region Render - - protected renderLoading(): SlottedTemplateResult { + protected renderPlaceholder() { return html``; } - protected renderFrameBackground(): SlottedTemplateResult { - return guard([this.layout, this.challenge], () => { - if (!this.#layoutUsesSidebarFrames) return; - - const src = this.challenge?.flowInfo?.background; - if (!src) return nothing; - - return html` - - `; - }); - } + //#endregion - protected renderFooter(): SlottedTemplateResult { - return guard([this.layout], () => { - return html`
- -
`; - }); - } + //#region Render protected override render(): SlottedTemplateResult { - const { challenge, loading } = this; - - return html` - ${this.renderFrameBackground()} - -
- - ${loading && challenge ? html`` : nothing} - ${guard([challenge], () => { - return challenge?.component - ? until(this.renderChallenge(challenge)) - : this.renderLoading(); - })} -
- ${this.renderFooter()}`; + const { challenge } = this; + return guard([challenge], () => + challenge?.component + ? until(this.renderChallenge(challenge), this.renderPlaceholder()) + : this.renderPlaceholder(), + ); } //#endregion + + public override firstUpdated(changed: PropertyValues) { + super.firstUpdated(changed); + this.refresh().then(() => { + window.dispatchEvent(new AKFlowAdvanceEvent()); + }); + } } declare global { diff --git a/web/src/flow/components/ak-brand-footer.ts b/web/src/flow/components/ak-brand-links.ts similarity index 100% rename from web/src/flow/components/ak-brand-footer.ts rename to web/src/flow/components/ak-brand-links.ts diff --git a/web/src/flow/events.ts b/web/src/flow/events.ts index 5739d75fb233..400f1adcf129 100644 --- a/web/src/flow/events.ts +++ b/web/src/flow/events.ts @@ -1,6 +1,8 @@ import type { FlowChallengeResponseRequestBody, SubmitOptions, SubmitRequest } from "#flow/types"; -import { ChallengeTypes } from "@goauthentik/api"; +import { ChallengeTypes, ContextualFlowInfo } from "@goauthentik/api"; + +const PROPAGATES = { bubbles: true, composed: true }; /** * @file Flow event utilities. @@ -17,7 +19,7 @@ export class AKFlowInspectorChangeEvent extends Event { public readonly open: boolean; constructor(open: boolean) { - super(AKFlowInspectorChangeEvent.eventName, { bubbles: true, composed: true }); + super(AKFlowInspectorChangeEvent.eventName, PROPAGATES); this.open = open; } @@ -46,7 +48,7 @@ export class AKFlowAdvanceEvent extends Event { public static readonly eventName = "ak-flow-advance"; constructor() { - super(AKFlowAdvanceEvent.eventName, { bubbles: true, composed: true }); + super(AKFlowAdvanceEvent.eventName, PROPAGATES); } } @@ -62,7 +64,7 @@ export class AKFlowUpdateChallengeRequest extends Event { public challenge: ChallengeTypes; constructor(challenge: ChallengeTypes) { - super(AKFlowUpdateChallengeRequest.eventName, { bubbles: true, composed: true }); + super(AKFlowUpdateChallengeRequest.eventName, PROPAGATES); this.challenge = challenge; } } @@ -75,7 +77,7 @@ export class AKFlowSubmitRequest extends Event { payload: FlowChallengeResponseRequestBody, options: SubmitOptions = { invisible: false }, ) { - super(AKFlowSubmitRequest.eventName, { bubbles: true, composed: true }); + super(AKFlowSubmitRequest.eventName, PROPAGATES); this.request = { payload, options, @@ -83,6 +85,31 @@ export class AKFlowSubmitRequest extends Event { } } +export class AKFlowInfoUpdateEvent extends Event { + public static readonly eventName = "ak-flow-info-update-event"; + public flowInfo: ContextualFlowInfo | null = null; + + constructor(flowInfo?: ContextualFlowInfo) { + super(AKFlowInfoUpdateEvent.eventName, PROPAGATES); + this.flowInfo = flowInfo ?? null; + } +} + +// This is subtle: we don't actually *care* about the Promise's payload; we only care to show some +// "loading" message (spinner, skeleton, whatever) when there's a network transaction underway. So +// when we start a transaction, we send a copy of its promise in an event; upon receipt, a listener +// can show whatever visual effect is desired, then listen for the promise to resolve, then stop the +// visual effect. Complete separation and independence. +// +export class AKFlowLoadingEvent extends Event { + public static readonly eventName = "ak-flow-loading-event"; + public awaiter: Promise; + constructor(awaiter: Promise) { + super(AKFlowLoadingEvent.eventName, PROPAGATES); + this.awaiter = awaiter; + } +} + //#endregion declare global { @@ -93,6 +120,8 @@ declare global { interface HTMLElementEventMap { [AKFlowSubmitRequest.eventName]: AKFlowSubmitRequest; + [AKFlowInfoUpdateEvent.eventName]: AKFlowInfoUpdateEvent; + [AKFlowLoadingEvent.eventName]: AKFlowLoadingEvent; [AKFlowUpdateChallengeRequest.eventName]: AKFlowUpdateChallengeRequest; } } diff --git a/web/src/flow/index.entrypoint.ts b/web/src/flow/index.entrypoint.ts index 1f4ce40284e9..45390cbc8593 100644 --- a/web/src/flow/index.entrypoint.ts +++ b/web/src/flow/index.entrypoint.ts @@ -1,6 +1,7 @@ import "#elements/messages/MessageContainer"; import "#elements/ak-drawer/ak-drawer"; -import "#flow/FlowExecutor"; +import "#flow/Flow"; +import "#flow/components/ak-brand-links"; // Statically import some stages to speed up load speed import "#flow/stages/access_denied/AccessDeniedStage"; // Import webauthn-related stages to prevent issues on safari diff --git a/web/src/flow/stages/identification/IdentificationStage.ts b/web/src/flow/stages/identification/IdentificationStage.ts index 0f0846509310..6714fa5d7f6c 100644 --- a/web/src/flow/stages/identification/IdentificationStage.ts +++ b/web/src/flow/stages/identification/IdentificationStage.ts @@ -4,6 +4,7 @@ import "#flow/components/ak-flow-card"; import "#flow/components/ak-flow-password-input"; import "#flow/stages/captcha/CaptchaStage"; +import { light } from "#elements/directives/light"; import { renderSourceIcon } from "#elements/sources/utils"; import { AKFormErrors } from "#components/ak-field-errors"; @@ -95,8 +96,6 @@ export class IdentificationStage extends BaseStage< protected passwordFieldRef = createRef(); - #form?: HTMLFormElement; - public defaultUserIdentification: string | null = null; protected rememberMeController: RememberMeController | null = null; @@ -129,8 +128,6 @@ export class IdentificationStage extends BaseStage< this.#prepareRememberMeFrame = requestAnimationFrame(() => { this.prepareRememberMeController(); }); - - this.#createHelperForm(); } } @@ -181,101 +178,6 @@ export class IdentificationStage extends BaseStage< //#endregion - //#region Helper Form - - #createHelperForm(): void { - const compatMode = "ShadyDOM" in window; - this.#form = document.createElement("form"); - document.documentElement.appendChild(this.#form); - // Only add the additional username input if we're in a shadow dom - // otherwise it just confuses browsers - if (!compatMode) { - // This is a workaround for the fact that we're in a shadow dom - // adapted from https://github.com/home-assistant/frontend/issues/3133 - const username = document.createElement("input"); - username.setAttribute("type", "text"); - username.setAttribute("name", "username"); // username as name for high compatibility - username.setAttribute("autocomplete", "username"); - username.onkeyup = (ev: Event) => { - const el = ev.target as HTMLInputElement; - (this.shadowRoot || this) - .querySelectorAll("input[name=uidField]") - .forEach((input) => { - input.value = el.value; - // Because we assume only one input field exists that matches this - // call focus so the user can press enter - input.focus(); - }); - }; - this.#form.appendChild(username); - } - // Only add the password field when we don't already show a password field - if (!compatMode && !this.challenge?.passwordFields) { - const password = document.createElement("input"); - password.setAttribute("type", "password"); - password.setAttribute("name", "password"); - password.setAttribute("autocomplete", "current-password"); - password.onkeyup = (event: KeyboardEvent) => { - if (event.key === "Enter") { - event.preventDefault(); - this.submitForm(); - } - - const el = event.target as HTMLInputElement; - // Because the password field is not actually on this page, - // and we want to 'prefill' the password for the user, - // save it globally - PasswordManagerPrefill.password = el.value; - // Because password managers fill username, then password, - // we need to re-focus the uid_field here too - (this.shadowRoot || this) - .querySelectorAll("input[name=uidField]") - .forEach((input) => { - // Because we assume only one input field exists that matches this - // call focus so the user can press enter - input.focus(); - }); - }; - - this.#form.appendChild(password); - } - - const totp = document.createElement("input"); - - totp.setAttribute("type", "text"); - totp.setAttribute("name", "code"); - totp.setAttribute("autocomplete", "one-time-code"); - totp.onkeyup = (event: KeyboardEvent) => { - if (event.key === "Enter") { - event.preventDefault(); - this.submitForm(); - } - - const el = event.target as HTMLInputElement; - // Because the totp field is not actually on this page, - // and we want to 'prefill' the totp for the user, - // save it globally - PasswordManagerPrefill.totp = el.value; - // Because totp managers fill username, then password, then optionally, - // we need to re-focus the uid_field here too - (this.shadowRoot || this) - .querySelectorAll("input[name=uidField]") - .forEach((input) => { - // Because we assume only one input field exists that matches this - // call focus so the user can press enter - input.focus(); - }); - }; - - this.#form.appendChild(totp); - } - - //#endregion - - protected override onSubmitSuccess(): void { - this.#form?.remove(); - } - protected override onSubmitFailure(): void { this.#captcha.onFailure(); } @@ -357,7 +259,7 @@ export class IdentificationStage extends BaseStage< // prettier-ignore return html`${offerRecovery ? this.renderRecoveryMessage() : nothing} -
+
${AKLabel({ required: true, htmlFor: inputID }, label)} ${this.renderUidField(inputID, type, label, initialUserIdentification, passwordFields)} ${rememberMeController?.renderToggleInput() ?? null} @@ -450,14 +352,16 @@ export class IdentificationStage extends BaseStage< protected renderIdentificationStage(challenge: IdentificationChallenge) { const { applicationPre, passwordlessUrl, showSourceLabels, sources = [] } = challenge; - return html` -
- ${applicationPre ? this.renderPrelude(applicationPre) : nothing} - ${this.renderInput(challenge)} - ${passwordlessUrl ? this.renderPasswordlessUrl(passwordlessUrl) : nothing} -
+ return html`
+ ${light( + html`
+ ${applicationPre ? this.renderPrelude(applicationPre) : nothing} + ${this.renderInput(challenge)} + ${passwordlessUrl ? this.renderPasswordlessUrl(passwordlessUrl) : nothing} +
`, + )} ${sources.length ? this.renderLoginSources(sources, showSourceLabels) : nothing} - `; +
`; } protected renderFooter({ enrollUrl, recoveryUrl }: IdentificationFooter) { @@ -474,15 +378,23 @@ export class IdentificationStage extends BaseStage< ${msg("Additional actions")} ${enrollUrl ? html`` : nothing} ${recoveryUrl ? html`` : nothing} `; diff --git a/web/src/styles/authentik/flows.global.css b/web/src/styles/authentik/flows.global.css index c5db595bbf56..7d936b9628e7 100644 --- a/web/src/styles/authentik/flows.global.css +++ b/web/src/styles/authentik/flows.global.css @@ -1,10 +1,16 @@ @import "@patternfly/patternfly/base/patternfly-common.css"; @import "@patternfly/patternfly/base/patternfly-globals.css"; +@import "@patternfly/patternfly/base/patternfly-variables.css"; @import "@patternfly/patternfly/base/patternfly-themes.css"; @import "@patternfly/patternfly/base/patternfly-fa-icons.css"; @import "@patternfly/patternfly/base/patternfly-pf-icons.css"; @import "@patternfly/patternfly/components/Spinner/spinner.css"; +@import "@patternfly/patternfly/components/InputGroup/input-group.css"; +@import "@patternfly/patternfly/components/FormControl/form-control.css"; +@import "@patternfly/patternfly/components/Form/form.css"; +@import "@patternfly/patternfly/components/Button/button.css"; +@import "@patternfly/patternfly/components/Login/login.css"; @import "#fonts/RedHat/faces.css"; @@ -24,6 +30,8 @@ @import "#elements/locale/ak-locale-select.css"; @import "#elements/locale/ak-locale-select.css"; @import "#flow/FlowExecutor.css"; +@import "#flow/stages/identification/styles.css"; +@import "./components/Form/form.css"; /** * @file Static global styles for authentik.