From 4b51838f428287e40f041420728680c01e051a03 Mon Sep 17 00:00:00 2001 From: Ken Sternberg Date: Wed, 18 Mar 2026 15:25:11 -0700 Subject: [PATCH 01/10] ## What MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit window.authentik.flow = { "layout": "{{ flow.layout }}", + "background": "{{ flow.background }}", + "title": "{{ flow.title }}", }; Amends the `flow.html` template and `GlobalAuthentik` parser to include new parameters, `background` and `title`, in the flow-specific part of the configuration written to the HTML `` object, and to provide those parameters to client code. ## Why The `layout` is start-up critical: it tells the Flow interface how the admin wants the Flow page to look, and allows the HTML and CSS to be pre-aligned to that condition. `layout` is determined on a per-Flow bases, not a per-Stage basis; Flows are derived from a tuple of `(Brand, Application?)`, where the opening policy *may* direct a user to a different flow if the user reached authentik via a redirect from a specific application, but will otherwise fall back to the default Flow for the Brand. The `background` is a field that is required if the `Flow`’s layout is of type `frame_background`; in this case, the part of the viewport not dedicated to the FlowExecutor is reserved for an ` + + `; + }); + } + + 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..64789bc2653e 100644 --- a/web/src/flow/FlowExecutor.ts +++ b/web/src/flow/FlowExecutor.ts @@ -7,26 +7,22 @@ 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 +36,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 } 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. @@ -84,62 +87,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 +130,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 +144,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 +162,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 +179,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 +233,37 @@ export class FlowExecutor extends WithBrandConfig(Interface) implements StageHos .catch((error: APIError) => { this.setFlowErrorChallenge(error); return false; - }) - .finally(() => { - this.loading = false; }); }; + public override connectedCallback() { + super.connectedCallback(); + this.refresh().then(() => { + window.dispatchEvent(new AKFlowAdvanceEvent()); + }); + } + //#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,103 +286,37 @@ 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 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..8faee3a414c7 100644 --- a/web/src/flow/index.entrypoint.ts +++ b/web/src/flow/index.entrypoint.ts @@ -1,5 +1,6 @@ import "#elements/messages/MessageContainer"; import "#elements/ak-drawer/ak-drawer"; +import "#flow/Flow"; import "#flow/FlowExecutor"; // Statically import some stages to speed up load speed import "#flow/stages/access_denied/AccessDeniedStage"; From a438eda64be89a58226500a1b180e87eb6a78047 Mon Sep 17 00:00:00 2001 From: Ken Sternberg Date: Wed, 6 May 2026 12:51:47 -0700 Subject: [PATCH 03/10] End-to-end lightDOM! --- .../identification/IdentificationStage.ts | 72 ++++++++----------- web/src/styles/authentik/flows.global.css | 4 ++ 2 files changed, 34 insertions(+), 42 deletions(-) diff --git a/web/src/flow/stages/identification/IdentificationStage.ts b/web/src/flow/stages/identification/IdentificationStage.ts index 0f0846509310..062e2c71fe92 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"; @@ -69,10 +70,7 @@ const sortLoginSources = (a: LoginSource, b: LoginSource) => .otherwise(() => 0); @customElement("ak-stage-identification") -export class IdentificationStage extends BaseStage< - IdentificationChallenge, - IdentificationChallengeResponseRequest -> { +export class IdentificationStage extends BaseStage { static styles = [ PFAlert, PFInputGroup, @@ -258,13 +256,11 @@ export class IdentificationStage extends BaseStage< 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.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); @@ -286,9 +282,7 @@ export class IdentificationStage extends BaseStage< }; protected renderRecoveryMessage() { - return html` -

${msg("Enter the email address or username associated with your account.")}

- `; + return html`

${msg("Enter the email address or username associated with your account.")}

`; } protected renderUidField( @@ -296,7 +290,7 @@ export class IdentificationStage extends BaseStage< type: string, label: string, initialUserIdentification: string | null, - passwordFields?: boolean, + passwordFields?: boolean ) { // When webauthn is enabled, add "webauthn" to autocomplete to enable passkey autofill let autocomplete: AutoFill = type === "email" ? "email" : "username"; @@ -337,19 +331,14 @@ export class IdentificationStage extends BaseStage< } protected renderInput(challenge: IdentificationChallenge) { - const { flowDesignation, passwordFields, passwordlessUrl, primaryAction, userFields } = - challenge; + const { flowDesignation, passwordFields, passwordlessUrl, primaryAction, userFields } = challenge; const fields = (userFields || []).sort(); if (fields.length === 0) { return html`

${msg("Select one of the options below to continue.")}

`; } - const { - inputID, - defaultUserIdentification: initialUserIdentification, - rememberMeController, - } = this; + const { inputID, defaultUserIdentification: initialUserIdentification, rememberMeController } = this; const offerRecovery = flowDesignation === FlowDesignationEnum.Recovery; const type = fields.length === 1 && fields[0] === UserFieldsEnum.Email ? "email" : "text"; @@ -357,7 +346,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} @@ -383,11 +372,7 @@ export class IdentificationStage extends BaseStage< } protected renderPasswordlessUrl(url: string) { - return html` + return html` ${msg("Use a security key")} `; } @@ -426,9 +411,7 @@ export class IdentificationStage extends BaseStage< } protected renderLoginSource(source: LoginSource, showLabels: boolean) { - return source.promoted - ? this.renderPromotedSource(source) - : this.renderDefaultSource(source, showLabels); + return source.promoted ? this.renderPromotedSource(source) : this.renderDefaultSource(source, showLabels); } protected renderLoginSources(sources: LoginSource[], showLabels: boolean) { @@ -442,7 +425,7 @@ export class IdentificationStage extends BaseStage< ${repeat( [...sources].sort(sortLoginSources), (source, idx) => source.name + idx, - (source) => this.renderLoginSource(source, showLabels), + (source) => this.renderLoginSource(source, showLabels) )} `; } @@ -451,11 +434,12 @@ export class IdentificationStage extends BaseStage< const { applicationPre, passwordlessUrl, showSourceLabels, sources = [] } = challenge; return html` -
- ${applicationPre ? this.renderPrelude(applicationPre) : nothing} - ${this.renderInput(challenge)} - ${passwordlessUrl ? this.renderPasswordlessUrl(passwordlessUrl) : nothing} -
+ ${light( + html`
+ ${applicationPre ? this.renderPrelude(applicationPre) : nothing} ${this.renderInput(challenge)} + ${passwordlessUrl ? this.renderPasswordlessUrl(passwordlessUrl) : nothing} +
` + )} ${sources.length ? this.renderLoginSources(sources, showSourceLabels) : nothing} `; } @@ -474,15 +458,19 @@ 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..2f6a2e7a2992 100644 --- a/web/src/styles/authentik/flows.global.css +++ b/web/src/styles/authentik/flows.global.css @@ -5,6 +5,9 @@ @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/Button/button.css"; @import "#fonts/RedHat/faces.css"; @@ -24,6 +27,7 @@ @import "#elements/locale/ak-locale-select.css"; @import "#elements/locale/ak-locale-select.css"; @import "#flow/FlowExecutor.css"; +@import "#flow/stages/identification/styles.css"; /** * @file Static global styles for authentik. From da79d23b7940fe7d8f48c63085aa958a8aaee0b6 Mon Sep 17 00:00:00 2001 From: Ken Sternberg Date: Wed, 6 May 2026 14:33:58 -0700 Subject: [PATCH 04/10] No idea what's happening here. --- web/src/flow/components/ak-flow-card.ts | 12 +++--------- .../stages/identification/IdentificationStage.ts | 10 ++++++---- web/src/styles/authentik/flows.global.css | 3 +++ 3 files changed, 12 insertions(+), 13 deletions(-) diff --git a/web/src/flow/components/ak-flow-card.ts b/web/src/flow/components/ak-flow-card.ts index a89230f37ec0..a0709b80ce63 100644 --- a/web/src/flow/components/ak-flow-card.ts +++ b/web/src/flow/components/ak-flow-card.ts @@ -46,20 +46,14 @@ export class FlowCard extends AKElement { `; } else if (this.challenge?.flowInfo?.title) { - title = html`

- ${this.challenge.flowInfo.title} -

`; + title = html`

${thi / us.challenge.flowInfo.title}

`; } const footer = this.findSlotted("footer") ? html`` : null; - const footerBand = this.findSlotted("footer-band") - ? html`` - : null; + const footerBand = this.findSlotted("footer-band") ? html`` : null; return html`${title ? html`` : null} - ${footer || footerBand - ? html`` - : null}`; + ${footer || footerBand ? html`` : null}`; } } diff --git a/web/src/flow/stages/identification/IdentificationStage.ts b/web/src/flow/stages/identification/IdentificationStage.ts index 062e2c71fe92..fbb4e5c69b3e 100644 --- a/web/src/flow/stages/identification/IdentificationStage.ts +++ b/web/src/flow/stages/identification/IdentificationStage.ts @@ -433,7 +433,7 @@ export class IdentificationStage extends BaseStage ${light( html`
${applicationPre ? this.renderPrelude(applicationPre) : nothing} ${this.renderInput(challenge)} @@ -441,7 +441,7 @@ export class IdentificationStage extends BaseStage` )} ${sources.length ? this.renderLoginSources(sources, showSourceLabels) : nothing} - `; +
`; } protected renderFooter({ enrollUrl, recoveryUrl }: IdentificationFooter) { @@ -459,8 +459,10 @@ export class IdentificationStage extends BaseStage
` : nothing} diff --git a/web/src/styles/authentik/flows.global.css b/web/src/styles/authentik/flows.global.css index 2f6a2e7a2992..e159e253716a 100644 --- a/web/src/styles/authentik/flows.global.css +++ b/web/src/styles/authentik/flows.global.css @@ -1,5 +1,6 @@ @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"; @@ -8,6 +9,7 @@ @import "@patternfly/patternfly/components/InputGroup/input-group.css"; @import "@patternfly/patternfly/components/FormControl/form-control.css"; @import "@patternfly/patternfly/components/Button/button.css"; +@import "@patternfly/patternfly/components/Login/login.css"; @import "#fonts/RedHat/faces.css"; @@ -28,6 +30,7 @@ @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. From 19f3b80f85331418a13ca527af8e207eea8c68d8 Mon Sep 17 00:00:00 2001 From: Ken Sternberg Date: Wed, 6 May 2026 15:11:41 -0700 Subject: [PATCH 05/10] Fix typo; ensure form is defined in global space. --- web/src/flow/components/ak-flow-card.ts | 2 +- web/src/styles/authentik/flows.global.css | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/web/src/flow/components/ak-flow-card.ts b/web/src/flow/components/ak-flow-card.ts index a0709b80ce63..cc0542fe3762 100644 --- a/web/src/flow/components/ak-flow-card.ts +++ b/web/src/flow/components/ak-flow-card.ts @@ -46,7 +46,7 @@ export class FlowCard extends AKElement { `; } else if (this.challenge?.flowInfo?.title) { - title = html`

${thi / us.challenge.flowInfo.title}

`; + title = html`

${this.challenge.flowInfo.title}

`; } const footer = this.findSlotted("footer") ? html`` : null; const footerBand = this.findSlotted("footer-band") ? html`` : null; diff --git a/web/src/styles/authentik/flows.global.css b/web/src/styles/authentik/flows.global.css index e159e253716a..8901fc2a490e 100644 --- a/web/src/styles/authentik/flows.global.css +++ b/web/src/styles/authentik/flows.global.css @@ -8,6 +8,7 @@ @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"; From a3e9788f79e6da0c2989250dcb3bd2d3ec3bc416 Mon Sep 17 00:00:00 2001 From: Ken Sternberg Date: Wed, 6 May 2026 15:39:02 -0700 Subject: [PATCH 06/10] Prettier having opinions. --- web/src/flow/components/ak-flow-card.ts | 12 +++- .../identification/IdentificationStage.ts | 57 +++++++++++++------ web/src/styles/authentik/flows.global.css | 2 +- 3 files changed, 49 insertions(+), 22 deletions(-) diff --git a/web/src/flow/components/ak-flow-card.ts b/web/src/flow/components/ak-flow-card.ts index cc0542fe3762..a89230f37ec0 100644 --- a/web/src/flow/components/ak-flow-card.ts +++ b/web/src/flow/components/ak-flow-card.ts @@ -46,14 +46,20 @@ export class FlowCard extends AKElement { `; } else if (this.challenge?.flowInfo?.title) { - title = html`

${this.challenge.flowInfo.title}

`; + title = html`

+ ${this.challenge.flowInfo.title} +

`; } const footer = this.findSlotted("footer") ? html`` : null; - const footerBand = this.findSlotted("footer-band") ? html`` : null; + const footerBand = this.findSlotted("footer-band") + ? html`` + : null; return html`${title ? html`` : null} - ${footer || footerBand ? html`` : null}`; + ${footer || footerBand + ? html`` + : null}`; } } diff --git a/web/src/flow/stages/identification/IdentificationStage.ts b/web/src/flow/stages/identification/IdentificationStage.ts index fbb4e5c69b3e..91eb49a0d119 100644 --- a/web/src/flow/stages/identification/IdentificationStage.ts +++ b/web/src/flow/stages/identification/IdentificationStage.ts @@ -70,7 +70,10 @@ const sortLoginSources = (a: LoginSource, b: LoginSource) => .otherwise(() => 0); @customElement("ak-stage-identification") -export class IdentificationStage extends BaseStage { +export class IdentificationStage extends BaseStage< + IdentificationChallenge, + IdentificationChallengeResponseRequest +> { static styles = [ PFAlert, PFInputGroup, @@ -256,11 +259,13 @@ export class IdentificationStage extends BaseStage("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.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); @@ -282,7 +287,9 @@ export class IdentificationStage extends BaseStage${msg("Enter the email address or username associated with your account.")}

`; + return html` +

${msg("Enter the email address or username associated with your account.")}

+ `; } protected renderUidField( @@ -290,7 +297,7 @@ export class IdentificationStage extends BaseStage${msg("Select one of the options below to continue.")}

`; } - const { inputID, defaultUserIdentification: initialUserIdentification, rememberMeController } = this; + const { + inputID, + defaultUserIdentification: initialUserIdentification, + rememberMeController, + } = this; const offerRecovery = flowDesignation === FlowDesignationEnum.Recovery; const type = fields.length === 1 && fields[0] === UserFieldsEnum.Email ? "email" : "text"; @@ -372,7 +384,11 @@ export class IdentificationStage extends BaseStage + return html` ${msg("Use a security key")} `; } @@ -411,7 +427,9 @@ export class IdentificationStage extends BaseStage source.name + idx, - (source) => this.renderLoginSource(source, showLabels) + (source) => this.renderLoginSource(source, showLabels), )} `; } @@ -436,9 +454,10 @@ export class IdentificationStage extends BaseStage ${light( html` - ${applicationPre ? this.renderPrelude(applicationPre) : nothing} ${this.renderInput(challenge)} + ${applicationPre ? this.renderPrelude(applicationPre) : nothing} + ${this.renderInput(challenge)} ${passwordlessUrl ? this.renderPasswordlessUrl(passwordlessUrl) : nothing} - ` + `, )} ${sources.length ? this.renderLoginSources(sources, showSourceLabels) : nothing} `; @@ -461,8 +480,10 @@ export class IdentificationStage extends BaseStage${msg("Need an account?")} - ${msg("Sign up.")}` + ${msg("Sign up.")}`, )}; ` : nothing} @@ -471,7 +492,7 @@ export class IdentificationStage extends BaseStage${msg("Forgot username or password?")}` + >`, )} ` : nothing} diff --git a/web/src/styles/authentik/flows.global.css b/web/src/styles/authentik/flows.global.css index 8901fc2a490e..7d936b9628e7 100644 --- a/web/src/styles/authentik/flows.global.css +++ b/web/src/styles/authentik/flows.global.css @@ -8,7 +8,7 @@ @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/Form/form.css"; @import "@patternfly/patternfly/components/Button/button.css"; @import "@patternfly/patternfly/components/Login/login.css"; From 72d42bc32b1bce6deade38c9bdc82d23d3b4224d Mon Sep 17 00:00:00 2001 From: Ken Sternberg Date: Wed, 6 May 2026 15:40:37 -0700 Subject: [PATCH 07/10] Removed the helper. --- .../identification/IdentificationStage.ts | 93 ------------------- 1 file changed, 93 deletions(-) diff --git a/web/src/flow/stages/identification/IdentificationStage.ts b/web/src/flow/stages/identification/IdentificationStage.ts index 91eb49a0d119..517c8922658d 100644 --- a/web/src/flow/stages/identification/IdentificationStage.ts +++ b/web/src/flow/stages/identification/IdentificationStage.ts @@ -130,8 +130,6 @@ export class IdentificationStage extends BaseStage< this.#prepareRememberMeFrame = requestAnimationFrame(() => { this.prepareRememberMeController(); }); - - this.#createHelperForm(); } } @@ -182,97 +180,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(); } From e751386d4da1f2c5ab1767ecc411f9f94ca53037 Mon Sep 17 00:00:00 2001 From: Ken Sternberg Date: Thu, 7 May 2026 09:05:03 -0700 Subject: [PATCH 08/10] This commit puts the Login input sequence into the lightDOM. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # WHAT Fixes a number of nitpicks with the code: 1. Removes the sentinel when our node is disconnected while the component remains connected; this happens when a predicated chooses to, and then not to, render a part of the host template. 2. Avoids re-rendering the slot when it’s already present. 3. Fixes a type-checking issue in `light` directive that forbade the DOM elements from being exported correctly. 4. Removes dead code about `#form` from the Executor. 5. Removes a stray semicolon from the footer template. --- web/src/elements/directives/light.ts | 77 +++++++++++-------- web/src/flow/FlowExecutor.ts | 16 ++-- .../identification/IdentificationStage.ts | 8 +- 3 files changed, 53 insertions(+), 48 deletions(-) diff --git a/web/src/elements/directives/light.ts b/web/src/elements/directives/light.ts index 7bb7751463e9..e7094ec9d9fa 100644 --- a/web/src/elements/directives/light.ts +++ b/web/src/elements/directives/light.ts @@ -1,4 +1,4 @@ -import { html, nothing, render, TemplateResult } from "lit"; +import { html, noChange, nothing, render, TemplateResult } from "lit"; import { AsyncDirective, DirectiveResult } from "lit/async-directive.js"; import { ChildPart, directive, PartInfo, PartType } from "lit/directive.js"; import { RootPart } from "lit/html.js"; @@ -14,11 +14,14 @@ export interface LightChildOptions { } class LightChildDirective extends AsyncDirective { - #slotName: string | null = null; - #slot: HTMLSlotElement | null = null; - #host: Element | null = null; - #rootPart: RootPart | null = null; - #sentinel: Comment | null = null; + // These must remain public: the dependency tree of all of these leads to their being imported + // into parts of the DOM by the host, and TSC complains otherwise. + + public slotName: string | null = null; + public slot: HTMLSlotElement | null = null; + public host: Element | null = null; + public rootPart: RootPart | null = null; + public sentinel: Comment | null = null; constructor(partInfo: PartInfo) { super(partInfo); @@ -27,37 +30,38 @@ class LightChildDirective extends AsyncDirective { } } + // This is for SSR only. render(_template?: TemplateResult | DirectiveResult, options?: LightChildOptions) { - this.#slotName ??= options?.slotName ?? `lc-${Math.random().toString(36).slice(2, 8)}`; - return html``; + this.slotName ??= options?.slotName ?? `lc-${Math.random().toString(36).slice(2, 8)}`; + return html``; } update( part: ChildPart, [template, options = {}]: [TemplateResult | DirectiveResult, LightChildOptions], ) { - this.#slotName ??= options?.slotName ?? `lc-${Math.random().toString(36).slice(2, 8)}`; + 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) { + if (!this.sentinel) { const rootNode = part.parentNode.getRootNode(); - this.#host ??= (part.options?.host || + this.host ??= (part.options?.host || (rootNode instanceof ShadowRoot ? rootNode.host : null)) as Element | null; - if (!this.#host) { + 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); + this.sentinel = document.createComment(""); + this.host.appendChild(this.sentinel); } - if (!this.#sentinel.parentNode) { + if (!this.sentinel.parentNode) { throw new Error("Could not assign sentinel to element."); } @@ -65,38 +69,45 @@ class LightChildDirective extends AsyncDirective { Object.entries(options).filter(([key]) => ["host"].includes(key)), ); - this.#rootPart = render(template, this.#sentinel.parentNode as HTMLElement, { - renderBefore: this.#sentinel, + this.rootPart = render(template, this.sentinel.parentNode as HTMLElement, { + renderBefore: this.sentinel, ...renderOptions, }); - const rendered = this.#sentinel.previousSibling; + const rendered = this.sentinel.previousSibling; if (rendered instanceof Element) { - rendered.slot = this.#slotName; + rendered.slot = this.slotName; } - return (this.#slot ??= Object.assign(document.createElement("slot"), { - name: 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 content being rendered this way, with the `render()` *function*, has its own Lit - // VDOM comment nodes in the HTML unrelated to the `host` context. Rendering `nothing` - // here ensures that any children of the lightDOM component receive clean-up signals and - // correctly disconnect (including listeners, etc.) from the current display as well. - // This is what lets us receive other DirectiveResults as template content. - - render(nothing, this.#sentinel.parentNode as HTMLElement, { - renderBefore: this.#sentinel, + 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; } - this.#rootPart?.setConnected(false); + + // The host has been disconnected. Inform any child components. + this.rootPart?.setConnected(false); } reconnected() { - this.#rootPart?.setConnected(true); + this.rootPart?.setConnected(true); } } diff --git a/web/src/flow/FlowExecutor.ts b/web/src/flow/FlowExecutor.ts index 64789bc2653e..a24e8382b6c2 100644 --- a/web/src/flow/FlowExecutor.ts +++ b/web/src/flow/FlowExecutor.ts @@ -43,7 +43,7 @@ import { spread } from "@open-wc/lit-helpers"; import { observed } from "@patternfly/pfe-core/decorators/observed.js"; import { match } from "ts-pattern"; -import { html } from "lit"; +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"; @@ -236,13 +236,6 @@ export class FlowExecutor extends WithBrandConfig(Interface) implements StageHos }); }; - public override connectedCallback() { - super.connectedCallback(); - this.refresh().then(() => { - window.dispatchEvent(new AKFlowAdvanceEvent()); - }); - } - //#region Render Challenge protected async renderChallengeSpecialCases(challenge: ChallengeTypes) { @@ -320,6 +313,13 @@ export class FlowExecutor extends WithBrandConfig(Interface) implements StageHos } //#endregion + + public override firstUpdated(changed: PropertyValues) { + super.firstUpdated(changed); + this.refresh().then(() => { + window.dispatchEvent(new AKFlowAdvanceEvent()); + }); + } } declare global { diff --git a/web/src/flow/stages/identification/IdentificationStage.ts b/web/src/flow/stages/identification/IdentificationStage.ts index 517c8922658d..6714fa5d7f6c 100644 --- a/web/src/flow/stages/identification/IdentificationStage.ts +++ b/web/src/flow/stages/identification/IdentificationStage.ts @@ -96,8 +96,6 @@ export class IdentificationStage extends BaseStage< protected passwordFieldRef = createRef(); - #form?: HTMLFormElement; - public defaultUserIdentification: string | null = null; protected rememberMeController: RememberMeController | null = null; @@ -180,10 +178,6 @@ export class IdentificationStage extends BaseStage< //#endregion - protected override onSubmitSuccess(): void { - this.#form?.remove(); - } - protected override onSubmitFailure(): void { this.#captcha.onFailure(); } @@ -391,7 +385,7 @@ export class IdentificationStage extends BaseStage< >${msg("Sign up.")}`, - )}; + )} ` : nothing} ${recoveryUrl From 3d38c5b42817873055285faf9893b9735d74eff7 Mon Sep 17 00:00:00 2001 From: Ken Sternberg Date: Thu, 7 May 2026 15:16:44 -0700 Subject: [PATCH 09/10] Some minor cleanup of Flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # What 1. Rename `ak-brand-footer` to `ak-brand-links`; this brings the filename in-line with the component name. 2. Clean up the imports; the initial load does not need both ak-flow and ak-flow-executor, since the first imports the second. ak-flow does not need ak-flow-card; in fact, ak-flow-executor should not need it either, since that’s a stage thing. On the other hand, `ak-brand-links` *does* need to be in the `index.entrypoint.ts` file, since it will be needed by the Django template. --- web/src/flow/Flow.css | 88 ------------------- web/src/flow/Flow.ts | 3 +- web/src/flow/FlowExecutor.ts | 7 +- .../{ak-brand-footer.ts => ak-brand-links.ts} | 0 web/src/flow/index.entrypoint.ts | 2 +- 5 files changed, 3 insertions(+), 97 deletions(-) delete mode 100644 web/src/flow/Flow.css rename web/src/flow/components/{ak-brand-footer.ts => ak-brand-links.ts} (100%) diff --git a/web/src/flow/Flow.css b/web/src/flow/Flow.css deleted file mode 100644 index 95f7ac7dea43..000000000000 --- a/web/src/flow/Flow.css +++ /dev/null @@ -1,88 +0,0 @@ -@import "../styles/authentik/components/Login/login.css"; - -:host, -ak-flow-executor.style-scope { - display: flex; - min-height: 100dvh; - flex-flow: column nowrap; -} - -ak-flow-inspector-button { - position: absolute; - inset-inline-end: var(--pf-global--spacer--md); - inset-block-start: var(--pf-global--spacer--md); - z-index: 100; -} - -[part="locale-select"], -[part="locale-select"].style-scope { - --pf-global--Color--100: var(--pf-global--Color--light-100) !important; - --ak-c-flow-executor__locale-select--Padding: var(--pf-global--spacer--md); - --ak-c-flow-executor__locale-select--Color: var(--pf-global--Color--100); - --ak-c-locale-select--label--Color: var(--ak-c-flow-executor__locale-select--Color); - - /* Compatibility mode */ - color: var(--ak-c-flow-executor__locale-select--Color); - position: absolute; - inset-block-start: var(--ak-c-flow-executor__locale-select--Padding); - inset-inline-start: var(--ak-c-flow-executor__locale-select--Padding); - font-weight: 500; - z-index: 100; - - /* Slight differences in browser hover states. */ - &:has(select:hover), - &:hover { - --ak-c-locale-select--label--Color: var( - --ak-c-flow-executor__locale-select--Color--hover, - var(--ak-c-flow-executor__locale-select--Color) - ); - --ak-c-locale-select--BackgroundColor: var( - --ak-c-flow-executor__locale-select--BackgroundColor--hover - ); - --ak-c-locale-select--TextDecorationColor: var(--ak-c-locale-select--label--Color); - --ak-c-locale-select__after--Opacity: 1; - - --ak-c-locale-select--Color: var(--ak-c-flow-executor__locale-select--Color--hover); - - @media (prefers-contrast: more) { - --ak-c-locale--select--OutlineColor: var(--pf-global--primary-color--dark-100); - } - } - - filter: var(--ak-global--BackgroundContrastFilter); - - grid-area: header; - - /* At least a third of the card cut-off is available. */ - @media (width <= 61.25rem) and (height <= 61.25rem) { - --ak-global--BackgroundContrastFilter: none; - --ak-c-flow-executor__locale-select--Color: var(--ak-c-login__main--Color); - - grid-area: main; - } - - @media (width <= 61.25rem) and (height <= 61.25rem) and (not (prefers-contrast: more)) { - --ak-c-locale-select--Opacity: 0; - - &:hover { - --ak-c-locale-select--Opacity: 1; - --ak-c-locale-select__after--Opacity: 1; - } - } - - /* Card is fully masked to mobile background. */ - @media (width <= 35rem) { - grid-row: header; - } -} - -@media (min-width: 70rem) and (min-height: 17.5rem) { - :host([data-layout^="sidebar"]), - [data-layout^="sidebar"] /* Compatibility mode */ { - --ak-global--BackgroundContrastFilter: none !important; - - [part="locale-select"] { - --ak-c-flow-executor__locale-select--Color: inherit !important; - } - } -} diff --git a/web/src/flow/Flow.ts b/web/src/flow/Flow.ts index bd0611c4a603..8f98aee77d25 100644 --- a/web/src/flow/Flow.ts +++ b/web/src/flow/Flow.ts @@ -1,8 +1,7 @@ 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/FlowExecutor"; import "#flow/tabs/broadcast"; import { FlowWebsocketClientController } from "./controllers/FlowWebsocketClientController"; diff --git a/web/src/flow/FlowExecutor.ts b/web/src/flow/FlowExecutor.ts index a24e8382b6c2..b7299c61c73a 100644 --- a/web/src/flow/FlowExecutor.ts +++ b/web/src/flow/FlowExecutor.ts @@ -1,8 +1,4 @@ -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"; @@ -79,7 +75,6 @@ type ChallengeProps = LitPropertyRecord, o * @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. 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/index.entrypoint.ts b/web/src/flow/index.entrypoint.ts index 8faee3a414c7..45390cbc8593 100644 --- a/web/src/flow/index.entrypoint.ts +++ b/web/src/flow/index.entrypoint.ts @@ -1,7 +1,7 @@ import "#elements/messages/MessageContainer"; import "#elements/ak-drawer/ak-drawer"; import "#flow/Flow"; -import "#flow/FlowExecutor"; +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 From 8a1563c8058e4625365711e2409c7a2f158dce82 Mon Sep 17 00:00:00 2001 From: Ken Sternberg Date: Thu, 7 May 2026 15:41:54 -0700 Subject: [PATCH 10/10] Add the `part` detail to the overlay, so that it can be addressed by customizing CSS. Updated the documenting comment to show the changes and call out the use of `light()` as an idiom. \# What \# Why \# How \# Designs \# Test Steps \# Other Notes --- web/src/flow/Flow.ts | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/web/src/flow/Flow.ts b/web/src/flow/Flow.ts index 8f98aee77d25..446f119cac2c 100644 --- a/web/src/flow/Flow.ts +++ b/web/src/flow/Flow.ts @@ -42,24 +42,35 @@ const isContextualFlowInfo = (v: unknown): v is ContextualFlowInfo => typeof v === "object" && v !== null; /** - * An executor for authentik flows. + * The application shell for authentik flows and the Flow Executor. * - * @attr {string} slug - The slug of the flow to execute. - * @prop {ChallengeTypes | null} challenge - The current challenge to render. + * 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 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. * @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 @@ -264,7 +275,9 @@ export class Flow extends WithBrandConfig(Interface) { - ${loading ? html`` : nothing} + ${loading + ? html`` + : nothing}
${light(html``)}