diff --git a/web/src/admin/stages/prompt/PromptForm.ts b/web/src/admin/stages/prompt/PromptForm.ts index 8a7983d725..dc5ce11c92 100644 --- a/web/src/admin/stages/prompt/PromptForm.ts +++ b/web/src/admin/stages/prompt/PromptForm.ts @@ -11,7 +11,7 @@ import { SlottedTemplateResult } from "#elements/types"; import { AKFormErrors, ErrorProp } from "#components/ak-field-errors"; -import { StageHost } from "#flow/stages/base"; +import type { StageHost } from "#flow/types"; import { Prompt, PromptChallenge, PromptTypeEnum, StagesApi } from "@goauthentik/api"; diff --git a/web/src/common/modules/types.ts b/web/src/common/modules/types.ts new file mode 100644 index 0000000000..bfda50563b --- /dev/null +++ b/web/src/common/modules/types.ts @@ -0,0 +1,41 @@ +/** + * @file Type utilities for ESM modules. + */ + +/** + * A type representing a resolved ES module with a default export of type `DefaultExport`. + * + * ```ts + * const mod: ResolvedESModule = await import('./my-module.js'); + * const myValue: MyType = mod.default; + * ``` + */ +export interface ResolvedDefaultESModule { + default: DefaultExport; +} + +/** + * A callback that returns a promise resolving to a module of type `T`. + */ +export type ImportCallback = () => Promise; + +/** + * A callback that returns a promise resolving to a module with a default export of type `T`. + */ +export type DefaultImportCallback = ImportCallback>; + +/** + * Asserts that the given module has a default export and is an object. + * + * @param mod The module to check. + * @throws {TypeError} If the module is not an object or does not have a default export. + */ +export function assertDefaultExport(mod: unknown): asserts mod is ResolvedDefaultESModule { + if (!mod || typeof mod !== "object") { + throw new TypeError("Module is not an object"); + } + + if (!Object.hasOwn(mod, "default")) { + throw new TypeError("Module does not have a default export"); + } +} diff --git a/web/src/flow/FlowExecutor.ts b/web/src/flow/FlowExecutor.ts index 21fd4c3c69..85fd481e1e 100644 --- a/web/src/flow/FlowExecutor.ts +++ b/web/src/flow/FlowExecutor.ts @@ -5,7 +5,6 @@ import "#flow/components/ak-brand-footer"; import "#flow/components/ak-flow-card"; import Styles from "./FlowExecutor.css" with { type: "bundled-text" }; -import { stages } from "./FlowExecutorSelections"; import { DEFAULT_CONFIG } from "#common/api/config"; import { parseAPIResponseError, pluckErrorDetail } from "#common/errors/network"; @@ -25,7 +24,9 @@ import { exportParts } from "#elements/utils/attributes"; import { ThemedImage } from "#elements/utils/images"; import { AKFlowAdvanceEvent, AKFlowInspectorChangeEvent } from "#flow/events"; -import { BaseStage, StageHost, SubmitOptions } from "#flow/stages/base"; +import { readStageModuleTag, StageMappings } from "#flow/FlowExecutorSelections"; +import { BaseStage } from "#flow/stages/base"; +import type { FlowChallengeComponentName, StageHost, SubmitOptions } from "#flow/types"; import { ConsoleLogger } from "#logger/browser"; @@ -354,21 +355,22 @@ export class FlowExecutor //#region Render Challenge protected async renderChallenge( - component: ChallengeTypes["component"], + component: FlowChallengeComponentName, ): Promise { const { challenge, inspectorOpen } = this; - const stage = stages.get(component); + const stage = StageMappings.get(component); // The special cases! if (!stage) { if (component === "xak-flow-shell") { return html`${unsafeHTML((challenge as ShellChallenge).body)}`; } + return html`Invalid native challenge element`; } - const challengeProps: LitPropertyRecord, unknown>> = + const challengeProps: LitPropertyRecord, object>> = { ".challenge": challenge!, ".host": this }; const litParts = { @@ -376,13 +378,10 @@ export class FlowExecutor exportparts: exportParts(["additional-actions", "footer-band"], "challenge"), }; - const { tag, variant, importfn } = stage; - if (importfn) { - await importfn(); - } + const tag = await readStageModuleTag(stage); const props = spread( - match(variant) + match(stage.variant) .with("challenge", () => challengeProps) .with("standard", () => ({ ...challengeProps, ...litParts })) .with("inspect", () => ({ ...challengeProps, "?promptUser": inspectorOpen })) diff --git a/web/src/flow/FlowExecutorSelections.ts b/web/src/flow/FlowExecutorSelections.ts index ccf10aeb2c..3d2861f350 100644 --- a/web/src/flow/FlowExecutorSelections.ts +++ b/web/src/flow/FlowExecutorSelections.ts @@ -5,111 +5,148 @@ import "#flow/stages/FlowErrorStage"; import "#flow/stages/FlowFrameStage"; import "#flow/stages/RedirectStage"; -import { isMatching, match, P } from "ts-pattern"; +import type { UnwrapSet } from "#common/sets"; -export const propVariants = ["standard", "challenge", "inspect"] as const; -type PropVariant = (typeof propVariants)[number]; +import type { FlowChallengeComponentName, StageModuleCallback } from "#flow/types"; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type ComponentImport = () => Promise; +export const propVariants = new Set(["standard", "challenge", "inspect"] as const); +type PropVariant = UnwrapSet; -// Any of these are valid variations, so you don't have to specify that this stage takes the -// "standard" set of props, or if the server-side stage token and the client-side component tag are -// the same you only need to specify one. Although the PropVariants and TagName are both strings on -// the wire, custom element tags always contain a dash and the PropVariants don't, making them -// easy to distinguish. -// -type BaseStage = - | [token: string, tag: string, variant: PropVariant] - | [token: string, variant: PropVariant] - | [token: string, tag: string] - | [token: string]; - -type RawStage = - | BaseStage - | [token: string, tag: string, variant: PropVariant, import: ComponentImport] - | [token: string, variant: PropVariant, import: ComponentImport] - | [token: string, tag: string, import: ComponentImport] - | [token: string, import: ComponentImport]; - -type Stage = { - tag: string; - variant: PropVariant; - importfn?: ComponentImport; -}; +/** + * @remarks + * Any of these are valid variations, so you don't have to specify that this stage takes the + * "standard" set of props, or if the server-side stage token and the client-side component tag are + * the same you only need to specify one. Although the PropVariants and TagName are both strings on + * the wire, custom element tags always contain a dash and the PropVariants don't, making them + * easy to distinguish. + */ +type StageEntry = + | StageModuleCallback + | [PropVariant?, tagName?: string] + | [PropVariant, StageModuleCallback] + | null; // Tables are allowed to go wide, just for readability. Sorting them alphabetically helps, too. -// // prettier-ignore -const rawStages: RawStage[] = [ - ["ak-provider-iframe-logout", async () => await import("#flow/providers/IFrameLogoutStage")], - ["ak-provider-oauth2-device-code", "ak-flow-provider-oauth2-code", async () => await import("#flow/providers/oauth2/DeviceCode")], - ["ak-provider-oauth2-device-code-finish", "ak-flow-provider-oauth2-code-finish", async () => await import("#flow/providers/oauth2/DeviceCodeFinish")], - ["ak-provider-saml-native-logout", async () => await import("#flow/providers/saml/NativeLogoutStage")], - ["ak-source-oauth-apple", "ak-flow-source-oauth-apple"], - ["ak-source-plex", "ak-flow-source-plex"], - ["ak-source-telegram", "ak-flow-source-telegram"], - ["ak-stage-access-denied", async () => await import("#flow/stages/access_denied/AccessDeniedStage")], - ["ak-stage-authenticator-duo", async () => await import("#flow/stages/authenticator_duo/AuthenticatorDuoStage")], - ["ak-stage-authenticator-email", async () => await import("#flow/stages/authenticator_email/AuthenticatorEmailStage")], - ["ak-stage-authenticator-sms", async () => await import("#flow/stages/authenticator_sms/AuthenticatorSMSStage")], - ["ak-stage-authenticator-static", async () => await import("#flow/stages/authenticator_static/AuthenticatorStaticStage")], - ["ak-stage-authenticator-totp", async () => await import("#flow/stages/authenticator_totp/AuthenticatorTOTPStage")], - ["ak-stage-authenticator-validate", async () => await import("#flow/stages/authenticator_validate/AuthenticatorValidateStage")], - ["ak-stage-authenticator-webauthn"], - ["ak-stage-autosubmit", async () => await import("#flow/stages/autosubmit/AutosubmitStage")], - ["ak-stage-captcha", async () => await import("#flow/stages/captcha/CaptchaStage")], - ["ak-stage-consent", async () => await import("#flow/stages/consent/ConsentStage")], - ["ak-stage-dummy", async () => await import("#flow/stages/dummy/DummyStage")], - ["ak-stage-email", async () => await import("#flow/stages/email/EmailStage")], - ["ak-stage-endpoint-agent", "challenge", async () => await import("#flow/stages/endpoint/agent/EndpointAgentStage")], - ["ak-stage-flow-error"], - ["ak-stage-identification", async () => await import("#flow/stages/identification/IdentificationStage")], - ["ak-stage-password", async () => await import("#flow/stages/password/PasswordStage")], - ["ak-stage-prompt", async () => await import("#flow/stages/prompt/PromptStage")], - ["ak-stage-session-end", async () => await import("#flow/providers/SessionEnd")], - ["ak-stage-user-login", "challenge", async () => await import("#flow/stages/user_login/UserLoginStage")], - ["xak-flow-redirect", "ak-stage-redirect", "inspect"], - ["xak-flow-frame", "challenge"], -]; +const StageModuleRecord: Record = { + "ak-provider-iframe-logout": () => import("#flow/providers/IFrameLogoutStage"), + "ak-provider-oauth2-device-code-finish": () => import("#flow/providers/oauth2/DeviceCodeFinish"), + "ak-provider-oauth2-device-code": () => import("#flow/providers/oauth2/DeviceCode"), + "ak-provider-saml-native-logout": () => import("#flow/providers/saml/NativeLogoutStage"), -const isImport = isMatching(P.when((x): x is ComponentImport => typeof x === "function")); + "ak-stage-access-denied": () => import("#flow/stages/access_denied/AccessDeniedStage"), + "ak-stage-authenticator-duo": () => import("#flow/stages/authenticator_duo/AuthenticatorDuoStage"), + "ak-stage-authenticator-email": () => import("#flow/stages/authenticator_email/AuthenticatorEmailStage"), + "ak-stage-authenticator-sms": () => import("#flow/stages/authenticator_sms/AuthenticatorSMSStage"), + "ak-stage-authenticator-static": () => import("#flow/stages/authenticator_static/AuthenticatorStaticStage"), + "ak-stage-authenticator-totp": () => import("#flow/stages/authenticator_totp/AuthenticatorTOTPStage"), + "ak-stage-authenticator-validate": () => import("#flow/stages/authenticator_validate/AuthenticatorValidateStage"), + "ak-stage-autosubmit": () => import("#flow/stages/autosubmit/AutosubmitStage"), + "ak-stage-captcha": () => import("#flow/stages/captcha/CaptchaStage"), + "ak-stage-consent": () => import("#flow/stages/consent/ConsentStage"), + "ak-stage-dummy": () => import("#flow/stages/dummy/DummyStage"), + "ak-stage-email": () => import("#flow/stages/email/EmailStage"), + "ak-stage-identification": () => import("#flow/stages/identification/IdentificationStage"), + "ak-stage-password": () => import("#flow/stages/password/PasswordStage"), + "ak-stage-prompt": () => import("#flow/stages/prompt/PromptStage"), + "ak-stage-session-end": () => import("#flow/providers/SessionEnd"), -const PVariant = P.when( - (x): x is PropVariant => typeof x === "string" && propVariants.includes(x as PropVariant), + "ak-stage-endpoint-agent": ["challenge", () => import("#flow/stages/endpoint/agent/EndpointAgentStage")], + "ak-stage-user-login": ["challenge", () => import("#flow/stages/user_login/UserLoginStage")], + + "ak-source-oauth-apple": ["standard", "ak-flow-source-oauth-apple"], + "ak-stage-authenticator-webauthn": [], + "ak-stage-flow-error": [], + + "ak-source-plex": ["standard", "ak-flow-source-plex"], + "ak-source-telegram": ["standard", "ak-flow-source-telegram"], + + "xak-flow-frame": ["challenge"], + "xak-flow-redirect": ["inspect", "ak-stage-redirect"], + "xak-flow-shell": null, +} + +type StageMapping = + | { + variant: PropVariant; + tag: string; + importCallback: null; + } + | { + variant: PropVariant; + tag: null; + importCallback: StageModuleCallback; + }; + +/** + * A mapping of server-side stage tokens to client-side custom element tags, along with the variant of props they consume and an optional import callback for lazy-loading. + * + * @remarks + * This is the actual table of stages consumed by the FlowExecutor. + * It is generated from the more concise `StageModuleRecord` above, which is easier to read and maintain. + * The `StageModuleRecord` allows for specifying just the token, or the token and variant, + * or the token and tag, or all three, and it can also include an import callback if the stage should be lazy-loaded. + * The code below normalizes all of these possibilities into a consistent format that the FlowExecutor can use. + */ +export const StageMappings: ReadonlyMap = new Map( + Object.entries(StageModuleRecord).map( + (foo): [FlowChallengeComponentName, StageMapping | null] => { + const [token, entry] = foo as [FlowChallengeComponentName, StageEntry | null]; + + if (entry === null) { + return [token, null]; + } + + if (typeof entry === "function") { + return [token, { tag: null, variant: "standard", importCallback: entry }]; + } + + const [variant = "standard", maybeTagOrImport = token] = entry; + + if (typeof maybeTagOrImport === "function") { + return [token, { tag: null, variant, importCallback: maybeTagOrImport }]; + } + + return [token, { tag: maybeTagOrImport, variant, importCallback: null }]; + }, + ), ); -// Don't have to type-check what you get from the tap. -const STANDARD = propVariants[0]; +/** + * A cache for storing the resolved custom element tag names for stage mappings that require lazy-loading. + */ +const StageMappingTagNameCache = new WeakMap(); -type InStage = [token: string, tag: string, variant: PropVariant]; +/** + * Given a stage mapping, returns the custom element tag name for that stage, loading the module if necessary. + * + * @param stageMapping The mapping for the stage, which may include a direct tag name or an import callback for lazy-loading. + * @returns The custom element tag name for the stage. + * @throws {TypeError} If the module fails to load or does not define a custom element. + */ +export async function readStageModuleTag(stageMapping: StageMapping): Promise { + if (stageMapping.importCallback === null) { + return stageMapping.tag; + } -export const stages: Map = new Map( - rawStages.map((rawstage: RawStage) => { - // The RawStages table above is clear and lacks the repetition of the original, but it does - // mean that doing all the pattern matching to normalize this to the format consumed by the - // FlowExecutor looks a little hairy. Repetition, defaults, and optional values can be - // ignored, making the table small and elegant, and this runs exactly once at start-time, so - // its run-time cost is well worth the savings. The use of `.exhaustive()` at the bottom - // guarantees that every variant specified in the `RawStage` type is handled. - // - // P.optional(PImport) does not work with tuples, but since it *is* optional and it *is* the - // last thing when it's present, we lop it off for the purpose of checking the rest, so the - // actual comparison table is for all the other variants, then put it back when we assemble - // the map. This eliminates half the variant checks. - // - const last = rawstage.at(-1); - const importfn = isImport(last) ? last : undefined; - const rest: BaseStage = (importfn ? rawstage.slice(0, -1) : rawstage) as BaseStage; + if (StageMappingTagNameCache.has(stageMapping)) { + return StageMappingTagNameCache.get(stageMapping)!; + } - // prettier-ignore - const [token, tag, variant] = match(rest) - .with([P.string, P.string, PVariant], ([token, tag, variant]) => [token, tag, variant]) - .with([P.string, PVariant], ([token, variant]) => [token, token, variant]) - .with([P.string, P.string], ([token, tag]) => [token, tag, STANDARD]) - .with([P.string], ([token])=> [token, token, STANDARD]) - .exhaustive(); + const module = await stageMapping.importCallback(); + const StageConstructor = module.default; + const tag = window.customElements.getName(StageConstructor); - return [token, { tag, variant, importfn }]; - }), -); + if (!tag) { + // eslint-disable-next-line no-console + console.trace( + `Failed to load stage module: no custom element found in module`, + stageMapping, + ); + throw new TypeError("Failed to load stage module: no custom element found"); + } + + StageMappingTagNameCache.set(stageMapping, tag); + + return tag; +} diff --git a/web/src/flow/FormStatic.ts b/web/src/flow/FormStatic.ts index 00ec74d663..f5237041c7 100644 --- a/web/src/flow/FormStatic.ts +++ b/web/src/flow/FormStatic.ts @@ -3,19 +3,7 @@ import { LitFC } from "#elements/types"; import { ifPresent } from "#elements/utils/attributes"; import { isDefaultAvatar } from "#elements/utils/images"; -import { - AccessDeniedChallenge, - AuthenticatorDuoChallenge, - AuthenticatorEmailChallenge, - AuthenticatorStaticChallenge, - AuthenticatorTOTPChallenge, - AuthenticatorWebAuthnChallenge, - CaptchaChallenge, - ConsentChallenge, - PasswordChallenge, - SessionEndChallenge, - UserLoginChallenge, -} from "@goauthentik/api"; +import { StageChallengeLike } from "#flow/types"; import { msg, str } from "@lit/localize"; import { css, CSSResult, html, nothing } from "lit"; @@ -110,26 +98,8 @@ export class AKFormStatic extends AKElement { } } -/** - * @internal - */ -export type FormStaticChallenge = - | SessionEndChallenge - | AccessDeniedChallenge - | AuthenticatorDuoChallenge - | AuthenticatorEmailChallenge - | AuthenticatorStaticChallenge - | AuthenticatorTOTPChallenge - | AuthenticatorWebAuthnChallenge - | CaptchaChallenge - | ConsentChallenge - | PasswordChallenge - | UserLoginChallenge; - export interface FlowUserDetailsProps { - challenge?: Partial< - Pick - > | null; + challenge?: StageChallengeLike | null; } export const FlowUserDetails: LitFC = ({ challenge }) => { diff --git a/web/src/flow/components/ak-flow-card.ts b/web/src/flow/components/ak-flow-card.ts index 5db1652411..8b25ab5585 100644 --- a/web/src/flow/components/ak-flow-card.ts +++ b/web/src/flow/components/ak-flow-card.ts @@ -5,7 +5,7 @@ import Styles from "./ak-flow-card.css"; import { AKElement } from "#elements/Base"; import { SlottedTemplateResult } from "#elements/types"; -import { FlowChallengeLike } from "#flow/components/types"; +import { FormStaticChallenge } from "#flow/types"; import { CSSResult, html } from "lit"; import { customElement, property } from "lit/decorators.js"; @@ -27,7 +27,7 @@ export class FlowCard extends AKElement { role = "presentation"; @property({ type: Object }) - challenge?: Pick; + challenge?: Pick; @property({ type: Boolean }) loading = false; diff --git a/web/src/flow/components/types.ts b/web/src/flow/components/types.ts deleted file mode 100644 index f8def28bc0..0000000000 --- a/web/src/flow/components/types.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { ChallengeTypes } from "@goauthentik/api"; - -/** - * Type utility to exclude the `component` property. - */ -export type ExcludeComponent = T extends { component: string } ? Omit : T; - -/** - * A {@link ChallengeTypes} without the `component` property. - */ -export type FlowChallengeLike = ExcludeComponent; diff --git a/web/src/flow/providers/IFrameLogoutStage.ts b/web/src/flow/providers/IFrameLogoutStage.ts index a26a8cdf7f..fc69c4470d 100644 --- a/web/src/flow/providers/IFrameLogoutStage.ts +++ b/web/src/flow/providers/IFrameLogoutStage.ts @@ -106,7 +106,7 @@ export class IFrameLogoutStage extends BaseStage< `, ]; - public override firstUpdated(changedProperties: PropertyValues): void { + public override firstUpdated(changedProperties: PropertyValues): void { super.firstUpdated(changedProperties); // Initialize status tracking @@ -263,6 +263,8 @@ export class IFrameLogoutStage extends BaseStage< } } +export default IFrameLogoutStage; + declare global { interface HTMLElementTagNameMap { "ak-provider-iframe-logout": IFrameLogoutStage; diff --git a/web/src/flow/providers/SessionEnd.ts b/web/src/flow/providers/SessionEnd.ts index 66613f5906..8612b544fb 100644 --- a/web/src/flow/providers/SessionEnd.ts +++ b/web/src/flow/providers/SessionEnd.ts @@ -69,6 +69,8 @@ export class SessionEnd extends BaseStage { } } +export default SessionEnd; + declare global { interface HTMLElementTagNameMap { "ak-stage-session-end": SessionEnd; diff --git a/web/src/flow/providers/oauth2/DeviceCode.ts b/web/src/flow/providers/oauth2/DeviceCode.ts index f29bb22d39..f24a1fec8a 100644 --- a/web/src/flow/providers/oauth2/DeviceCode.ts +++ b/web/src/flow/providers/oauth2/DeviceCode.ts @@ -68,6 +68,8 @@ export class OAuth2DeviceCode extends BaseStage< } } +export default OAuth2DeviceCode; + declare global { interface HTMLElementTagNameMap { "ak-flow-provider-oauth2-code": OAuth2DeviceCode; diff --git a/web/src/flow/providers/oauth2/DeviceCodeFinish.ts b/web/src/flow/providers/oauth2/DeviceCodeFinish.ts index 9ab403fee1..0a7ee501d4 100644 --- a/web/src/flow/providers/oauth2/DeviceCodeFinish.ts +++ b/web/src/flow/providers/oauth2/DeviceCodeFinish.ts @@ -25,6 +25,8 @@ export class DeviceCodeFinish extends BaseStage< } } +export default DeviceCodeFinish; + declare global { interface HTMLElementTagNameMap { "ak-flow-provider-oauth2-code-finish": DeviceCodeFinish; diff --git a/web/src/flow/providers/saml/NativeLogoutStage.ts b/web/src/flow/providers/saml/NativeLogoutStage.ts index 87f7765eb6..d9777b5263 100644 --- a/web/src/flow/providers/saml/NativeLogoutStage.ts +++ b/web/src/flow/providers/saml/NativeLogoutStage.ts @@ -31,7 +31,7 @@ export class NativeLogoutStage extends BaseStage< public static styles: CSSResult[] = [PFLogin, PFForm, PFButton, PFFormControl, PFTitle]; - public override firstUpdated(changedProperties: PropertyValues): void { + public override firstUpdated(changedProperties: PropertyValues): void { super.firstUpdated(changedProperties); if (!this.challenge) { @@ -121,6 +121,8 @@ export class NativeLogoutStage extends BaseStage< } } +export default NativeLogoutStage; + declare global { interface HTMLElementTagNameMap { "ak-provider-saml-native-logout": NativeLogoutStage; diff --git a/web/src/flow/sources/apple/AppleLoginInit.ts b/web/src/flow/sources/apple/AppleLoginInit.ts index 9d99a0985e..fa43245547 100644 --- a/web/src/flow/sources/apple/AppleLoginInit.ts +++ b/web/src/flow/sources/apple/AppleLoginInit.ts @@ -74,6 +74,8 @@ export class AppleLoginInit extends BaseStage extends BaseStage { +export class BaseDeviceStage extends BaseStage { @property({ attribute: false }) deviceChallenge?: DeviceChallenge; diff --git a/web/src/flow/stages/autosubmit/AutosubmitStage.ts b/web/src/flow/stages/autosubmit/AutosubmitStage.ts index a226088a78..782f3f3102 100644 --- a/web/src/flow/stages/autosubmit/AutosubmitStage.ts +++ b/web/src/flow/stages/autosubmit/AutosubmitStage.ts @@ -51,6 +51,8 @@ export class AutosubmitStage extends BaseStage< } } +export default AutosubmitStage; + declare global { interface HTMLElementTagNameMap { "ak-stage-autosubmit": AutosubmitStage; diff --git a/web/src/flow/stages/base.ts b/web/src/flow/stages/base.ts index 4f86fc6eaf..c4bdade8d9 100644 --- a/web/src/flow/stages/base.ts +++ b/web/src/flow/stages/base.ts @@ -8,28 +8,13 @@ import { WithLocale } from "#elements/mixins/locale"; import { FocusTarget } from "#elements/utils/focus"; import { FlowUserDetails } from "#flow/FormStatic"; +import { IBaseStage, StageChallengeLike, StageHost } from "#flow/types"; import { ConsoleLogger } from "#logger/browser"; -import { ContextualFlowInfo, CurrentBrand, ErrorDetail } from "@goauthentik/api"; - -import { html, LitElement, nothing, PropertyValues } from "lit"; +import { html, nothing, PropertyValues } from "lit"; import { property } from "lit/decorators.js"; -export interface SubmitOptions { - invisible: boolean; -} - -export interface StageHost { - challenge?: unknown; - flowSlug?: string; - loading: boolean; - reset?: () => void; - submit(payload: unknown, options?: SubmitOptions): Promise; - - readonly brand?: CurrentBrand; -} - export function readFileAsync(file: Blob) { return new Promise((resolve, reject) => { const reader = new FileReader(); @@ -41,22 +26,7 @@ export function readFileAsync(file: Blob) { }); } -// Challenge which contains flow info -export interface FlowInfoChallenge { - flowInfo?: ContextualFlowInfo; -} - // Challenge which has a pending user -export interface PendingUserChallenge { - pendingUser?: string; - pendingUserAvatar?: string; -} - -export interface ResponseErrorsChallenge { - responseErrors?: { - [key: string]: ErrorDetail[]; - }; -} /** * Base class for all flow stages. @@ -65,12 +35,12 @@ export interface ResponseErrorsChallenge { * @prop {StageHost} host The host managing this stage. * @prop {Tin} challenge The challenge provided to this stage. */ -export abstract class BaseStage< - Tin extends FlowInfoChallenge & PendingUserChallenge & ResponseErrorsChallenge, - Tout, -> extends WithLocale(AKElement) { +export abstract class BaseStage + extends WithLocale(AKElement) + implements IBaseStage +{ static shadowRootOptions: ShadowRootInit = { - ...LitElement.shadowRootOptions, + ...AKElement.shadowRootOptions, delegatesFocus: true, }; diff --git a/web/src/flow/stages/captcha/CaptchaStage.ts b/web/src/flow/stages/captcha/CaptchaStage.ts index 5878563f41..834093e48b 100644 --- a/web/src/flow/stages/captcha/CaptchaStage.ts +++ b/web/src/flow/stages/captcha/CaptchaStage.ts @@ -559,6 +559,8 @@ export class CaptchaStage } } +export default CaptchaStage; + declare global { interface HTMLElementTagNameMap { "ak-stage-captcha": CaptchaStage; diff --git a/web/src/flow/stages/consent/ConsentStage.ts b/web/src/flow/stages/consent/ConsentStage.ts index 8c69af9d7c..8f32dd56ad 100644 --- a/web/src/flow/stages/consent/ConsentStage.ts +++ b/web/src/flow/stages/consent/ConsentStage.ts @@ -141,6 +141,8 @@ export class ConsentStage extends BaseStage, ) { + static shadowRootOptions: ShadowRootInit = BaseStage.shadowRootOptions; + static styles: CSSResult[] = [ PFLogin, PFAlert, @@ -307,6 +309,8 @@ ${prompt.initialValue} = T extends { component: string } ? Omit : T; + +/** + * A {@link ChallengeTypes} without the `component` property. + */ +export type FlowChallengeLike = ExcludeComponent; + +export type FlowChallengeComponentName = ChallengeTypes["component"]; + +/** + * @internal + */ +export type FormStaticChallenge = + | SessionEndChallenge + | AccessDeniedChallenge + | AuthenticatorDuoChallenge + | AuthenticatorEmailChallenge + | AuthenticatorStaticChallenge + | AuthenticatorTOTPChallenge + | AuthenticatorWebAuthnChallenge + | CaptchaChallenge + | ConsentChallenge + | PasswordChallenge + | UserLoginChallenge; + +export type StageChallengeLike = Partial< + Pick +>; + +export interface SubmitOptions { + invisible: boolean; +} + +export interface StageHost { + challenge?: unknown; + flowSlug?: string; + loading: boolean; + reset?: () => void; + submit(payload: unknown, options?: SubmitOptions): Promise; + + readonly brand?: CurrentBrand; +} + +export interface IBaseStage + extends HTMLElement, ReactiveControllerHost { + host?: StageHost; + challenge: Tin | null; + submitForm: (event?: SubmitEvent, defaults?: Tout) => Promise; + reset?(): void; +} + +export type BaseStageConstructor< + Tin extends StageChallengeLike = StageChallengeLike, + Tout = never, +> = new () => IBaseStage; + +export type StageModuleConstructor< + Tin extends StageChallengeLike = StageChallengeLike, + Tout = never, +> = DefaultImportCallback>; + +/** + * A type representing an ES module that exports a stage constructor as its default export. + * This is used for dynamic imports of stages. + */ +export type StageModuleCallback = DefaultImportCallback; diff --git a/web/src/stories/flow-interface.ts b/web/src/stories/flow-interface.ts index 14898949ad..52dba526c9 100644 --- a/web/src/stories/flow-interface.ts +++ b/web/src/stories/flow-interface.ts @@ -5,7 +5,7 @@ import { DeepPartial } from "#common/types"; import { AKElement } from "#elements/Base"; -import { FlowChallengeLike } from "#flow/components/types"; +import { FlowChallengeLike } from "#flow/types"; import { ChallengeTypes, ContextualFlowInfoLayoutEnum, UiThemeEnum } from "@goauthentik/api"; diff --git a/web/src/user/user-settings/details/UserSettingsFlowExecutor.ts b/web/src/user/user-settings/details/UserSettingsFlowExecutor.ts index 87676c3dbb..40880604bc 100644 --- a/web/src/user/user-settings/details/UserSettingsFlowExecutor.ts +++ b/web/src/user/user-settings/details/UserSettingsFlowExecutor.ts @@ -11,7 +11,7 @@ import { WithBrandConfig } from "#elements/mixins/branding"; import { WithSession } from "#elements/mixins/session"; import { SlottedTemplateResult } from "#elements/types"; -import { StageHost } from "#flow/stages/base"; +import type { StageHost } from "#flow/types"; import { ChallengeTypes,