diff --git a/features/admin.ask-password-flow-builder.v1/api/use-get-ask-password-flow-builder-resources.ts b/features/admin.ask-password-flow-builder.v1/api/use-get-ask-password-flow-builder-resources.ts index 6e8fb0290ce..bb3229a9552 100644 --- a/features/admin.ask-password-flow-builder.v1/api/use-get-ask-password-flow-builder-resources.ts +++ b/features/admin.ask-password-flow-builder.v1/api/use-get-ask-password-flow-builder-resources.ts @@ -17,8 +17,12 @@ */ import { RequestErrorInterface, RequestResultInterface } from "@wso2is/admin.core.v1/hooks/use-request"; +import useGetExtensionExecutorSteps + from "@wso2is/admin.flow-builder-core.v1/api/use-get-extension-executor-steps"; import useGetFlowBuilderCoreResources from "@wso2is/admin.flow-builder-core.v1/api/use-get-flow-builder-core-resources"; import { Resources } from "@wso2is/admin.flow-builder-core.v1/models/resources"; +import { Step } from "@wso2is/admin.flow-builder-core.v1/models/steps"; +import { FlowTypes } from "@wso2is/admin.flows.v1/models/flows"; import elements from "../data/elements.json"; import steps from "../data/steps.json"; import templates from "../data/templates.json"; @@ -40,6 +44,9 @@ const useGetAskPasswordFlowBuilderResources = => { const { data: coreResources } = useGetFlowBuilderCoreResources(); + // Executors contributed by extensions deployed on the server. Appended to the palette at runtime. + const extensionExecutorSteps: Step[] = useGetExtensionExecutorSteps(FlowTypes.INVITED_USER_REGISTRATION); + return { data: ({ ...coreResources, @@ -49,7 +56,8 @@ const useGetAskPasswordFlowBuilderResources = = return resource?.variants?.find((_element: Element) => _element.variant === resource.variant); }, [ resource.variants, resource.variant ]); + const isConnectionlessExtensionExecutor: boolean = useIsConnectionlessExtensionExecutor( + resource?.data?.action?.executor?.name + ); + const renderElementId = (): ReactElement => { return ( = <> { renderElementId() } { + !isConnectionlessExtensionExecutor && !AskPasswordFlowBuilderConstants.FEDERATION_CONFIG_SKIPPED_EXECUTORS.includes( resource?.data?.action?.executor?.name) && ( { + const { data: metadata } = useGetMetadata(flowType, !!flowType); + + return useMemo( + () => buildExtensionExecutorSteps(metadata?.extensionExecutors), + [ metadata?.extensionExecutors ] + ); +}; + +export default useGetExtensionExecutorSteps; diff --git a/features/admin.flow-builder-core.v1/components/resources/steps/execution/execution-factory/extension-execution.tsx b/features/admin.flow-builder-core.v1/components/resources/steps/execution/execution-factory/extension-execution.tsx new file mode 100644 index 00000000000..12477fd66ea --- /dev/null +++ b/features/admin.flow-builder-core.v1/components/resources/steps/execution/execution-factory/extension-execution.tsx @@ -0,0 +1,82 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import Box from "@oxygen-ui/react/Box"; +import Typography from "@oxygen-ui/react/Typography"; +import loadStaticResource from "@wso2is/admin.core.v1/utils/load-static-resource"; +import { IdentifiableComponentInterface } from "@wso2is/core/models"; +import React, { ReactElement, useMemo, useState } from "react"; +import useAuthenticationFlowBuilderCore from "../../../../../hooks/use-authentication-flow-builder-core-context"; +import { ExtensionExecutorInterface } from "../../../../../models/metadata"; +import { DEFAULT_EXTENSION_EXECUTOR_ICON } from "../../../../../utils/build-extension-executor-steps"; +import { ExecutionMinimalPropsInterface } from "../execution-minimal"; + +/** + * Props interface of ExtensionExecution. + */ +type ExtensionExecutionPropsInterface = ExecutionMinimalPropsInterface & IdentifiableComponentInterface; + +/** + * Canvas node for an executor contributed by an extension deployed on the server. + */ +const ExtensionExecution = ({ + resource, + "data-componentid": componentId = "extension-execution" +}: ExtensionExecutionPropsInterface): ReactElement => { + const { metadata } = useAuthenticationFlowBuilderCore(); + const [ hasIconFailed, setHasIconFailed ] = useState(false); + + const executorName: string = resource?.data?.action?.executor?.name; + + const executor: ExtensionExecutorInterface | null = useMemo(() => { + if (!executorName || !metadata?.extensionExecutors?.length) { + return null; + } + + return metadata.extensionExecutors.find( + (candidate: ExtensionExecutorInterface) => candidate.name === executorName + ) || null; + }, [ executorName, metadata?.extensionExecutors ]); + + const isDefaultIcon: boolean = hasIconFailed || !executor?.icon; + const iconSrc: string = isDefaultIcon + ? loadStaticResource(DEFAULT_EXTENSION_EXECUTOR_ICON) + : loadStaticResource(executor.icon); + + const displayName: string = executor?.displayName || resource?.display?.label || executorName; + + return ( + + { setHasIconFailed(true) } + /> + { displayName } + + ); +}; + +export default ExtensionExecution; diff --git a/features/admin.flow-builder-core.v1/components/resources/steps/execution/execution-factory/index.tsx b/features/admin.flow-builder-core.v1/components/resources/steps/execution/execution-factory/index.tsx index 2057cbd9cf9..a6a18d89caf 100644 --- a/features/admin.flow-builder-core.v1/components/resources/steps/execution/execution-factory/index.tsx +++ b/features/admin.flow-builder-core.v1/components/resources/steps/execution/execution-factory/index.tsx @@ -22,11 +22,14 @@ import { IdentifiableComponentInterface } from "@wso2is/core/models"; import React, { FC, ReactElement } from "react"; import { useTranslation } from "react-i18next"; import AppleExecution from "./apple-execution"; +import ExtensionExecution from "./extension-execution"; import FacebookExecution from "./facebook-execution"; import GithubExecution from "./github-execution"; import GoogleExecution from "./google-execution"; import FlowExtensionExecution from "./flow-extension-execution"; import MicrosoftExecution from "./microsoft-execution"; +import useAuthenticationFlowBuilderCore from "../../../../../hooks/use-authentication-flow-builder-core-context"; +import { ExtensionExecutorInterface } from "../../../../../models/metadata"; import { ExecutionTypes } from "../../../../../models/steps"; import "./execution-factory.scss"; import { ExecutionMinimalPropsInterface } from "../execution-minimal"; @@ -48,6 +51,7 @@ const ExecutionFactory: FC = ({ "data-componentid": componentId = "execution-factory" }: ExecutionFactoryPropsInterface): ReactElement => { const { t } = useTranslation(); + const { metadata } = useAuthenticationFlowBuilderCore(); if ((resource.data?.action as any)?.executor?.name === ExecutionTypes.GoogleFederation) { return ( @@ -128,6 +132,20 @@ const ExecutionFactory: FC = ({ ); } + /* + * Flow metadata describes how to render executors that are dynamically registered. + */ + const isContributedByExtension: boolean = !!metadata?.extensionExecutors?.some( + (executor: ExtensionExecutorInterface) => + executor.name === (resource.data?.action as any)?.executor?.name + ); + + if (isContributedByExtension) { + return ( + + ); + } + return ( { t("flows:core.executions.names.default") } diff --git a/features/admin.flow-builder-core.v1/constants/validation-constants.ts b/features/admin.flow-builder-core.v1/constants/validation-constants.ts index 52ee67a1677..ed42d114832 100644 --- a/features/admin.flow-builder-core.v1/constants/validation-constants.ts +++ b/features/admin.flow-builder-core.v1/constants/validation-constants.ts @@ -23,6 +23,11 @@ class ValidationConstants { private constructor() {} public static readonly REQUIRED_FIELD_ERROR_CODE: string = "REQUIRED_FIELD_ERROR"; + + /** + * Behavior flag an executor declares to quality as a password recovery factor. + */ + public static readonly RECOVERY_FACTOR_BEHAVIOR_FLAG: string = "RECOVERY_FACTOR"; } export default ValidationConstants; diff --git a/features/admin.flow-builder-core.v1/hooks/use-extension-executor.ts b/features/admin.flow-builder-core.v1/hooks/use-extension-executor.ts new file mode 100644 index 00000000000..3eb3a9d0579 --- /dev/null +++ b/features/admin.flow-builder-core.v1/hooks/use-extension-executor.ts @@ -0,0 +1,47 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { useMemo } from "react"; +import useAuthenticationFlowBuilderCore from "./use-authentication-flow-builder-core-context"; +import { ExtensionExecutorInterface } from "../models/metadata"; + +/** + * Hook that resolves the metadata of an executor contributed by an extension deployed on the server. + */ +const useExtensionExecutor = (executorName: string): ExtensionExecutorInterface | null => { + const { metadata } = useAuthenticationFlowBuilderCore(); + + return useMemo(() => { + if (!executorName || !metadata?.extensionExecutors?.length) { + return null; + } + + return metadata.extensionExecutors.find( + (executor: ExtensionExecutorInterface) => executor.name === executorName + ) || null; + }, [ executorName, metadata?.extensionExecutors ]); +}; + +/** + * Hook that decides whether the connection picker applies to an dynamically registered executor. + */ +export const useIsConnectionlessExtensionExecutor = (executorName: string): boolean => { + const executor: ExtensionExecutorInterface | null = useExtensionExecutor(executorName); + + return !!executor && executor.requiresConnection === false; +}; diff --git a/features/admin.flow-builder-core.v1/hooks/use-factor-validation.ts b/features/admin.flow-builder-core.v1/hooks/use-factor-validation.ts index f713c005858..7897e7227ce 100644 --- a/features/admin.flow-builder-core.v1/hooks/use-factor-validation.ts +++ b/features/admin.flow-builder-core.v1/hooks/use-factor-validation.ts @@ -22,8 +22,11 @@ RegistrationFlowExecutorConstants import { Edge, Node, ReactFlowState, getIncomers, useStore } from "@xyflow/react"; import { useEffect, useMemo } from "react"; import { useTranslation } from "react-i18next"; +import useAuthenticationFlowBuilderCore from "./use-authentication-flow-builder-core-context"; import useValidationStatus from "./use-validation-status"; +import ValidationConstants from "../constants/validation-constants"; import { Action, Element } from "../models/elements"; +import { ExtensionExecutorInterface } from "../models/metadata"; import Notification, { NotificationType } from "../models/notification"; import { Resource } from "../public-api"; @@ -33,6 +36,7 @@ import { Resource } from "../public-api"; const useRecoveryFactorValidation = (node: Node): void => { const { t } = useTranslation(); const { addNotification, removeNotification, validationConfig } = useValidationStatus(); + const { metadata } = useAuthenticationFlowBuilderCore(); const nodes: Node[] = useStore((state: ReactFlowState) => state.nodes); const edges: Edge[] = useStore((state: ReactFlowState) => state.edges); @@ -76,19 +80,40 @@ const useRecoveryFactorValidation = (node: Node): void => { }; /** - * Finds factor views such as SMS OTP, Email OTP and Magic Link in a node's components. + * Executors that satisfy the recovery factor requirement. Covers the built-in factors and any + * executor contributed by a deployed extension that declares the RECOVERY_FACTOR behavior flag. + */ + const recoveryFactorExecutors: Set = useMemo(() => { + const factors: Set = new Set([ + RegistrationFlowExecutorConstants.EMAIL_OTP_EXECUTOR, + RegistrationFlowExecutorConstants.SMS_OTP_EXECUTOR, + RegistrationFlowExecutorConstants.MAGIC_LINK_EXECUTOR + ]); + + metadata?.extensionExecutors?.forEach((executor: ExtensionExecutorInterface) => { + if (executor?.name + && executor?.behaviorFlags?.includes(ValidationConstants.RECOVERY_FACTOR_BEHAVIOR_FLAG)) { + factors.add(executor.name); + } + }); + + return factors; + }, [ metadata?.extensionExecutors ]); + + /** + * Finds factor views such as SMS OTP, Email OTP, Magic Link or an extension contributed + * recovery factor in a node's components or in its own action. */ const FactorExistsInTheFlow = (node: Node): boolean => { + const isRecoveryFactor = (executorName: string): boolean => + !!executorName && recoveryFactorExecutors.has(executorName); + return (node?.data?.components as Element[])?.some((parent: Element) => parent?.components?.some( - (child: Element) => child?.action?.executor?.name === - RegistrationFlowExecutorConstants.EMAIL_OTP_EXECUTOR || - child?.action?.executor?.name === - RegistrationFlowExecutorConstants.SMS_OTP_EXECUTOR + (child: Element) => isRecoveryFactor(child?.action?.executor?.name) ) - ) || (node?.data?.action as Action)?.executor?.name === - RegistrationFlowExecutorConstants.MAGIC_LINK_EXECUTOR; + ) || isRecoveryFactor((node?.data?.action as Action)?.executor?.name); }; /** @@ -104,7 +129,7 @@ const useRecoveryFactorValidation = (node: Node): void => { const hasAncestors: boolean = ancestors?.length > 0; /** - * Checks if any ancestor nodes have factor views such as SMS OTP, Email OTP and Magic Link. + * Checks if any ancestor nodes have a recovery factor view. */ const checkIfAncestorsHaveFactors: boolean = useMemo((): boolean => { if (!isRecoveryFactorValidationEnabled || !containsResetPassword || !node) return false; @@ -116,7 +141,7 @@ const useRecoveryFactorValidation = (node: Node): void => { } return false; - }, [ isRecoveryFactorValidationEnabled, containsResetPassword, node, ancestors ]); + }, [ isRecoveryFactorValidationEnabled, containsResetPassword, node, ancestors, recoveryFactorExecutors ]); const errorNotificationId: string = `recovery-factor-validation-${node?.id}`; @@ -145,9 +170,8 @@ const useRecoveryFactorValidation = (node: Node): void => { errorNotificationId, t( "flowBuilder:validations.passwordRecoveryRequiresFactors.message", - "Password recovery requires at least one of the following " + - "factors to be present in the flow: Email OTP, SMS OTP, " + - "or Magic Link." + "Password recovery requires at least one recovery factor, such as " + + "Email OTP, SMS OTP or Magic Link, to be present in the flow." ), NotificationType.ERROR ); diff --git a/features/admin.flow-builder-core.v1/models/metadata.ts b/features/admin.flow-builder-core.v1/models/metadata.ts index 114fad1caa6..379efe97836 100644 --- a/features/admin.flow-builder-core.v1/models/metadata.ts +++ b/features/admin.flow-builder-core.v1/models/metadata.ts @@ -54,6 +54,10 @@ export interface MetadataInterface { * List of active flow extension connections from metadata. */ flowExtensionConnections?: FlowExtensionConnectionInterface[]; + /** + * Executors registered by an connector deployed on the server. + */ + extensionExecutors?: ExtensionExecutorInterface[]; /** * Is a Workflow engagement enabled? */ @@ -114,6 +118,40 @@ export interface ExecutorConnectionInterface { connections: string[]; } +/** + * Interface for an executor registered dynamically by an extension deployed on the server. + */ +export interface ExtensionExecutorInterface { + /** + * Unique name of the executor, as registered with the flow execution engine. + */ + name: string; + /** + * Human readable name shown in the palette and on the canvas node. + */ + displayName: string; + /** + * Short explanation of what the step does, shown under the label in the palette. + */ + description?: string; + /** + * Reserved behavior flags declared by the executor. Interpreted in API layer. + */ + behaviorFlags?: string[]; + /** + * Icon for the executor. + */ + icon?: string; + /** + * Whether a connection has to be selected before a step using this executor is valid. + */ + requiresConnection?: boolean; + /** + * Name of the authenticator backing this executor. + */ + associatedAuthenticator?: string; +} + /** * Interface for flow extension connection info from flow metadata. */ diff --git a/features/admin.flow-builder-core.v1/utils/build-extension-executor-steps.ts b/features/admin.flow-builder-core.v1/utils/build-extension-executor-steps.ts new file mode 100644 index 00000000000..a30cbe0a112 --- /dev/null +++ b/features/admin.flow-builder-core.v1/utils/build-extension-executor-steps.ts @@ -0,0 +1,63 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { ExtensionExecutorInterface } from "../models/metadata"; +import { ResourceTypes } from "../models/resources"; +import { Step, StepCategories, StepTypes } from "../models/steps"; + +/** + * Icon used when a contributed executor does not declare one, or declares one that fails to load. + */ +export const DEFAULT_EXTENSION_EXECUTOR_ICON: string = "assets/images/icons/step.svg"; + +/** + * Builds palette steps for the executors contributed by extensions deployed on the server. + */ +const buildExtensionExecutorSteps = (extensionExecutors: ExtensionExecutorInterface[]): Step[] => { + if (!extensionExecutors?.length) { + return []; + } + + return extensionExecutors + .filter((executor: ExtensionExecutorInterface) => !!executor?.name) + .map((executor: ExtensionExecutorInterface) => ({ + category: StepCategories.Workflow, + data: { + action: { + executor: { + name: executor.name, + ...(executor.requiresConnection ? { meta: { idpName: "" } } : {}) + }, + next: "", + type: "EXECUTOR" + } + }, + deprecated: false, + display: { + description: executor.description, + image: executor.icon || DEFAULT_EXTENSION_EXECUTOR_ICON, + label: executor.displayName, + showOnResourcePanel: true + }, + resourceType: ResourceTypes.Step, + type: StepTypes.Execution, + version: "0.1.0" + })) as unknown as Step[]; +}; + +export default buildExtensionExecutorSteps; diff --git a/features/admin.password-recovery-flow-builder.v1/api/use-get-password-recovery-flow-builder-resources.ts b/features/admin.password-recovery-flow-builder.v1/api/use-get-password-recovery-flow-builder-resources.ts index 02c1f94ed66..2d0a1972d31 100644 --- a/features/admin.password-recovery-flow-builder.v1/api/use-get-password-recovery-flow-builder-resources.ts +++ b/features/admin.password-recovery-flow-builder.v1/api/use-get-password-recovery-flow-builder-resources.ts @@ -17,8 +17,12 @@ */ import { RequestErrorInterface, RequestResultInterface } from "@wso2is/admin.core.v1/hooks/use-request"; +import useGetExtensionExecutorSteps + from "@wso2is/admin.flow-builder-core.v1/api/use-get-extension-executor-steps"; import useGetFlowBuilderCoreResources from "@wso2is/admin.flow-builder-core.v1/api/use-get-flow-builder-core-resources"; import { Resources } from "@wso2is/admin.flow-builder-core.v1/models/resources"; +import { Step } from "@wso2is/admin.flow-builder-core.v1/models/steps"; +import { FlowTypes } from "@wso2is/admin.flows.v1/models/flows"; import steps from "../data/steps.json"; import templates from "../data/templates.json"; import widgets from "../data/widgets.json"; @@ -39,12 +43,16 @@ const useGetPasswordRecoveryFlowBuilderResources = => { const { data: coreResources } = useGetFlowBuilderCoreResources(); + // Executors contributed by extensions deployed on the server. Appended to the palette at runtime. + const extensionExecutorSteps: Step[] = useGetExtensionExecutorSteps(FlowTypes.PASSWORD_RECOVERY); + return { data: ({ ...coreResources, steps: [ ...coreResources?.steps, - ...steps + ...steps, + ...extensionExecutorSteps ], templates: [ ...coreResources?.templates, diff --git a/features/admin.password-recovery-flow-builder.v1/components/resource-property-panel/resource-properties.tsx b/features/admin.password-recovery-flow-builder.v1/components/resource-property-panel/resource-properties.tsx index f896a619db5..97ebb1b5cad 100644 --- a/features/admin.password-recovery-flow-builder.v1/components/resource-property-panel/resource-properties.tsx +++ b/features/admin.password-recovery-flow-builder.v1/components/resource-property-panel/resource-properties.tsx @@ -26,6 +26,8 @@ import { FieldKey, FieldValue } from "@wso2is/admin.flow-builder-core.v1/models/ import { Element, ElementCategories, ElementTypes } from "@wso2is/admin.flow-builder-core.v1/models/elements"; import { Resource } from "@wso2is/admin.flow-builder-core.v1/models/resources"; import { ExecutionTypes, StepCategories, StepTypes } from "@wso2is/admin.flow-builder-core.v1/models/steps"; +import { useIsConnectionlessExtensionExecutor } + from "@wso2is/admin.flow-builder-core.v1/hooks/use-extension-executor"; import { IdentifiableComponentInterface } from "@wso2is/core/models"; import isEmpty from "lodash-es/isEmpty"; import React, { ChangeEvent, FunctionComponent, ReactElement, useMemo } from "react"; @@ -58,6 +60,10 @@ const ResourceProperties: FunctionComponent = return resource?.variants?.find((_element: Element) => _element.variant === resource.variant); }, [ resource.variants, resource.variant ]); + const isConnectionlessExtensionExecutor: boolean = useIsConnectionlessExtensionExecutor( + resource?.data?.action?.executor?.name + ); + const renderElementId = (): ReactElement => { return ( = <> { renderElementId() } { + !isConnectionlessExtensionExecutor && !PasswordRecoveryFlowBuilderConstants.FEDERATION_CONFIG_SKIPPED_EXECUTORS.includes( resource?.data?.action?.executor?.name) && ( => { const { data: coreResources } = useGetFlowBuilderCoreResources(); + // Executors contributed by extensions deployed on the server. Appended to the palette at runtime. + const extensionExecutorSteps: Step[] = useGetExtensionExecutorSteps(FlowTypes.REGISTRATION); + const aiFeature: FeatureAccessConfigInterface = useSelector( (state: AppState) => state.config.ui.features?.ai ); @@ -66,7 +73,8 @@ const useGetRegistrationFlowBuilderResources = = return resource?.variants?.find((_element: Element) => _element.variant === resource.variant); }, [ resource.variants, resource.variant ]); + const isConnectionlessExtensionExecutor: boolean = useIsConnectionlessExtensionExecutor( + resource?.data?.action?.executor?.name + ); + const renderElementId = (): ReactElement => { return ( = <> { renderElementId() } { + !isConnectionlessExtensionExecutor && !RegistrationFlowBuilderConstants.FEDERATION_CONFIG_SKIPPED_EXECUTORS.includes( resource?.data?.action?.executor?.name) && (