diff --git a/apps/console/src/public/resources/assets/images/icons/flow-extension.svg b/apps/console/src/public/resources/assets/images/icons/flow-extension.svg index 0de05b0316a..361d566c588 100644 --- a/apps/console/src/public/resources/assets/images/icons/flow-extension.svg +++ b/apps/console/src/public/resources/assets/images/icons/flow-extension.svg @@ -1,23 +1,4 @@ - - - - + @@ -27,7 +8,6 @@ - diff --git a/apps/console/src/public/resources/connections/assets/images/logos/flow-extension.svg b/apps/console/src/public/resources/connections/assets/images/logos/flow-extension.svg new file mode 100644 index 00000000000..361d566c588 --- /dev/null +++ b/apps/console/src/public/resources/connections/assets/images/logos/flow-extension.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/features/admin.actions.v1/models/actions.ts b/features/admin.actions.v1/models/actions.ts index a7ea94c46fc..a0de9eb323a 100644 --- a/features/admin.actions.v1/models/actions.ts +++ b/features/admin.actions.v1/models/actions.ts @@ -616,3 +616,20 @@ export interface ActionTypeCardInterface { */ disabled?: boolean } + +/** + * Expose/modify entry in access config. + * Same structure is used for both expose and modify entries: a path plus an optional encryption flag. + */ +export interface ContextPathInterface { + path: string; + encrypted: boolean; +} + +/** + * Access config for Flow Extension actions. + */ +export interface AccessConfigInterface { + expose: ContextPathInterface[]; + modify: ContextPathInterface[]; +} diff --git a/features/admin.connections.v1/api/create-flow-extension.ts b/features/admin.connections.v1/api/create-flow-extension.ts new file mode 100644 index 00000000000..bb26d18625a --- /dev/null +++ b/features/admin.connections.v1/api/create-flow-extension.ts @@ -0,0 +1,76 @@ +/** + * 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 { AsgardeoSPAClient, HttpClientInstance } from "@asgardeo/auth-react"; +import { RequestConfigInterface } from "@wso2is/admin.core.v1/hooks/use-request"; +import { store } from "@wso2is/admin.core.v1/store"; +import { IdentityAppsApiException } from "@wso2is/core/exceptions"; +import { HttpErrorResponseDataInterface, HttpMethods } from "@wso2is/core/models"; +import { AxiosError, AxiosResponse } from "axios"; +import { + FlowExtensionCreateRequestInterface, + FlowExtensionResponseInterface +} from "../models/flow-extension"; + +const httpClient: HttpClientInstance = AsgardeoSPAClient.getInstance() + .httpRequest.bind(AsgardeoSPAClient.getInstance()); + +/** + * Create a Flow Extension connection. + * + * @param body - Flow Extension create request body. + * @returns A promise that resolves with the created Flow Extension. + * @throws Throws an IdentityAppsApiException if the request fails. + */ +const createFlowExtension = ( + body: FlowExtensionCreateRequestInterface +): Promise => { + const requestConfig: RequestConfigInterface = { + data: body, + method: HttpMethods.POST, + url: store.getState().config.endpoints.flowExtension + }; + + return httpClient(requestConfig) + .then((response: AxiosResponse) => { + if (response.status !== 201) { + throw new IdentityAppsApiException( + "Failed to create the flow extension.", + null, + response.status, + response.request, + response, + response.config + ); + } + + return Promise.resolve(response.data as FlowExtensionResponseInterface); + }) + .catch((error: AxiosError) => { + throw new IdentityAppsApiException( + error.message, + error.stack, + error.response?.data?.code, + error.request, + error.response, + error.config + ); + }); +}; + +export default createFlowExtension; diff --git a/features/admin.connections.v1/api/delete-flow-extension.ts b/features/admin.connections.v1/api/delete-flow-extension.ts new file mode 100644 index 00000000000..de8ddfccf46 --- /dev/null +++ b/features/admin.connections.v1/api/delete-flow-extension.ts @@ -0,0 +1,69 @@ +/** + * 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 { AsgardeoSPAClient, HttpClientInstance } from "@asgardeo/auth-react"; +import { RequestConfigInterface } from "@wso2is/admin.core.v1/hooks/use-request"; +import { store } from "@wso2is/admin.core.v1/store"; +import { IdentityAppsApiException } from "@wso2is/core/exceptions"; +import { HttpErrorResponseDataInterface, HttpMethods } from "@wso2is/core/models"; +import { AxiosError, AxiosResponse } from "axios"; + +const httpClient: HttpClientInstance = AsgardeoSPAClient.getInstance() + .httpRequest.bind(AsgardeoSPAClient.getInstance()); + +/** + * Delete a Flow Extension connection. + * + * @param id - ID of the Flow Extension to delete. + * @returns A promise resolving with the delete response. + * @throws Throws an IdentityAppsApiException if the request fails. + */ +const deleteFlowExtension = (id: string): Promise => { + const requestConfig: RequestConfigInterface = { + method: HttpMethods.DELETE, + url: `${ store.getState().config.endpoints.flowExtension }/${ id }` + }; + + return httpClient(requestConfig) + .then((response: AxiosResponse) => { + if (response.status !== 204) { + throw new IdentityAppsApiException( + "Failed to delete the flow extension.", + null, + response.status, + response.request, + response, + response.config + ); + } + + return response; + }) + .catch((error: AxiosError) => { + throw new IdentityAppsApiException( + error.message, + error.stack, + error.response?.data?.code, + error.request, + error.response, + error.config + ); + }); +}; + +export default deleteFlowExtension; diff --git a/features/admin.connections.v1/api/use-get-flow-extension.ts b/features/admin.connections.v1/api/use-get-flow-extension.ts new file mode 100644 index 00000000000..d0c4bdda102 --- /dev/null +++ b/features/admin.connections.v1/api/use-get-flow-extension.ts @@ -0,0 +1,58 @@ +/** + * 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 useRequest, { + RequestConfigInterface, + RequestErrorInterface, + RequestResultInterface +} from "@wso2is/admin.core.v1/hooks/use-request"; +import { store } from "@wso2is/admin.core.v1/store"; +import { HttpMethods } from "@wso2is/core/models"; +import { FlowExtensionBasicResponseInterface } from "../models/flow-extension"; + +/** + * Hook to get the list of Flow Extensions. + * + * @param shouldFetch - Whether the request should be sent. + * @returns The list of Flow Extensions as an SWR response. + */ +const useGetFlowExtension = < + Data = FlowExtensionBasicResponseInterface[], + Error = RequestErrorInterface +>( + shouldFetch: boolean = true + ): RequestResultInterface => { + + const requestConfig: RequestConfigInterface = { + headers: { + "Accept": "application/json", + "Content-Type": "application/json" + }, + method: HttpMethods.GET, + url: store.getState().config.endpoints.flowExtension + }; + + const { data, error, isLoading, isValidating, mutate } = useRequest( + shouldFetch ? requestConfig : null, + { shouldRetryOnError: false } + ); + + return { data, error, isLoading, isValidating, mutate }; +}; + +export default useGetFlowExtension; diff --git a/features/admin.connections.v1/components/authenticator-grid.tsx b/features/admin.connections.v1/components/authenticator-grid.tsx index 40ba73a7e15..2a627edefd1 100644 --- a/features/admin.connections.v1/components/authenticator-grid.tsx +++ b/features/admin.connections.v1/components/authenticator-grid.tsx @@ -67,6 +67,7 @@ import { getConnectedApps, getConnectedAppsOfAuthenticator } from "../api/connections"; +import deleteFlowExtension from "../api/delete-flow-extension"; import { getConnectionIcons } from "../configs/ui"; import { AuthenticatorMeta } from "../meta/authenticator-meta"; import { @@ -212,6 +213,11 @@ export const AuthenticatorGrid: FunctionComponent => { - // If the connection is an Identity Verification Provider, then skip checking for connected apps. - if (connectionType === ConnectionTypes.IDVP) { + // Identity Verification Providers and Flow Extensions have no connected apps to check. + if (connectionType === ConnectionTypes.IDVP || connectionType === ConnectionTypes.FLOW_EXTENSION) { setDeletingIDP(authenticators.find( (idp: ConnectionInterface | AuthenticatorInterface) => idp.id === idpId) ); @@ -383,6 +389,8 @@ export const AuthenticatorGrid: FunctionComponent { if (connectionsFetchRequestError) { diff --git a/features/admin.connections.v1/components/create/connection-create-wizard-factory.tsx b/features/admin.connections.v1/components/create/connection-create-wizard-factory.tsx index a1c8491cf4e..d1a054274fe 100644 --- a/features/admin.connections.v1/components/create/connection-create-wizard-factory.tsx +++ b/features/admin.connections.v1/components/create/connection-create-wizard-factory.tsx @@ -16,10 +16,16 @@ * under the License. */ +import { AppState } from "@wso2is/admin.core.v1/store"; import IdVPCreationModal from "@wso2is/admin.identity-verification-providers.v1/components/create/idvp-creation-modal"; -import { IdentifiableComponentInterface } from "@wso2is/core/models"; +import { useGetCurrentOrganizationType } from "@wso2is/admin.organizations.v1/hooks/use-get-organization-type"; +import { isFeatureEnabled } from "@wso2is/core/helpers"; +import { FeatureAccessConfigInterface, IdentifiableComponentInterface } from "@wso2is/core/models"; import React, { FC, ReactElement } from "react"; +import { useSelector } from "react-redux"; import { AuthenticatorCreateWizardFactory } from "./authenticator-create-wizard-factory"; +import FlowExtensionCreateWizard from "./flow-extension-create-wizard"; +import { CommonAuthenticatorConstants } from "../../constants/common-authenticator-constants"; import { ConnectionTemplateInterface, ConnectionTypes, @@ -82,29 +88,53 @@ export const ConnectionCreateWizardFactory: FC { + const actionsFeatureConfig: FeatureAccessConfigInterface = useSelector( + (state: AppState) => state.config.ui.features?.actions); + const { isSubOrganization } = useGetCurrentOrganizationType(); + const flowExtensionFeatureKey: string = isSubOrganization() + ? "actions.types.org.list.flowExtension" + : "actions.types.list.flowExtension"; + if (!isModalOpen) { return null; } - switch (connectionType) { - case ConnectionTypes.IDVP: - return ( - - ); + if (connectionType === ConnectionTypes.IDVP) { + return ( + + ); + } + + // Match either by the connection type or by the template id, since the template is + // typed as "DEFAULT" while the create flow is keyed on the `flow-extension` template id. + if ( + (connectionType === ConnectionTypes.FLOW_EXTENSION + || type === CommonAuthenticatorConstants.CONNECTION_TEMPLATE_IDS.FLOW_EXTENSION) + && isFeatureEnabled(actionsFeatureConfig, flowExtensionFeatureKey) + ) { + return ( + + ); + } - default: - return ( - - ); - }; + return ( + + ); }; diff --git a/features/admin.connections.v1/components/create/flow-extension-create-wizard.tsx b/features/admin.connections.v1/components/create/flow-extension-create-wizard.tsx new file mode 100644 index 00000000000..95ca7e8d033 --- /dev/null +++ b/features/admin.connections.v1/components/create/flow-extension-create-wizard.tsx @@ -0,0 +1,408 @@ +/** + * 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 ActionEndpointConfigForm from "@wso2is/admin.actions.v1/components/action-endpoint-config-form"; +import { + AuthenticationType, + EndpointConfigFormPropertyInterface +} from "@wso2is/admin.actions.v1/models/actions"; +import { validateActionEndpointFields } from "@wso2is/admin.actions.v1/util/form-field-util"; +import { ModalWithSidePanel } from "@wso2is/admin.core.v1/components/modals/modal-with-side-panel"; +import { AppConstants } from "@wso2is/admin.core.v1/constants/app-constants"; +import { history } from "@wso2is/admin.core.v1/helpers/history"; +import { IdentityAppsError } from "@wso2is/core/errors"; +import { AlertLevels, HttpErrorResponseDataInterface, IdentifiableComponentInterface } from "@wso2is/core/models"; +import { addAlert } from "@wso2is/core/store"; +import { Field, Wizard2, WizardPage } from "@wso2is/forms"; +import { + GenericIcon, + Heading, + Hint, + LinkButton, + PrimaryButton, + Steps, + useWizardAlert +} from "@wso2is/react-components"; +import { AxiosError } from "axios"; +import React, { FunctionComponent, MutableRefObject, ReactElement, useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useDispatch } from "react-redux"; +import { Dispatch } from "redux"; +import { Icon, Grid as SemanticGrid } from "semantic-ui-react"; +import createFlowExtension from "../../api/create-flow-extension"; +import { getConnectionWizardStepIcons } from "../../configs/ui"; +import { ConnectionUIConstants } from "../../constants/connection-ui-constants"; +import { AuthenticatorMeta } from "../../meta/authenticator-meta"; +import { + GenericConnectionCreateWizardPropsInterface, + WizardStepInterface, + WizardStepsFlowExtension +} from "../../models/connection"; +import { + FlowExtensionCreateRequestInterface, + FlowExtensionResponseInterface +} from "../../models/flow-extension"; + +/** + * Props for the Flow Extension create wizard. + */ +interface FlowExtensionCreateWizardPropsInterface + extends GenericConnectionCreateWizardPropsInterface, IdentifiableComponentInterface {} + +/** + * Imperative handle exposed by the underlying Wizard2 component. + */ +interface WizardRefInterface { + gotoNextPage: () => void; + gotoPreviousPage: () => void; +} + +/** + * Form values collected across the Flow Extension create wizard pages. + */ +interface FlowExtensionWizardFormValues extends EndpointConfigFormPropertyInterface { + name: string; + description?: string; +} + +const FLOW_EXTENSION_NAME_REGEX: RegExp = /^[a-zA-Z0-9][a-zA-Z0-9 _-]{0,254}$/; +const MAX_DESCRIPTION_LENGTH: number = 255; + +/** + * Flow Extension connection create wizard. + * + * @param props - Props injected to the component. + * @returns Flow Extension create wizard component. + */ +const FlowExtensionCreateWizard: FunctionComponent = ({ + title, + subTitle, + onWizardClose, + ["data-componentid"]: componentId = "flow-extension-create-wizard" +}: FlowExtensionCreateWizardPropsInterface): ReactElement => { + + const dispatch: Dispatch = useDispatch(); + const { t } = useTranslation(); + const [ alert, setAlert, alertComponent ] = useWizardAlert(); + + const wizardRef: MutableRefObject = useRef(null); + + const [ wizardSteps, setWizardSteps ] = useState([]); + const [ currentWizardStep, setCurrentWizardStep ] = useState(0); + const [ isSubmitting, setIsSubmitting ] = useState(false); + const [ endpointAuthType, setEndpointAuthType ] = useState(AuthenticationType.NONE); + const [ nextShouldBeDisabled, setNextShouldBeDisabled ] = useState(true); + + useEffect(() => { + setWizardSteps([ + { + icon: getConnectionWizardStepIcons().general, + name: WizardStepsFlowExtension.GENERAL_SETTINGS, + submitCallback: null, + title: t("flowExtension:createWizard.steps.generalSettings.title") + }, + { + icon: getConnectionWizardStepIcons().authenticatorSettings, + name: WizardStepsFlowExtension.ENDPOINT_CONFIG, + submitCallback: null, + title: t("flowExtension:createWizard.steps.endpointConfig.title") + } + ]); + }, []); + + const hasValidationErrors = (errors: Record): boolean => + Object.keys(errors).some((key: string) => Boolean(errors[key])); + + const validateGeneralSettings = ( + values: Pick + ): Partial> => { + const errors: Partial> = {}; + + if (!values?.name || !FLOW_EXTENSION_NAME_REGEX.test(values.name)) { + errors.name = t("flowExtension:createWizard.steps.generalSettings.name.validations.invalid"); + } + + if (values?.description && values.description.length > MAX_DESCRIPTION_LENGTH) { + errors.description = t( + "flowExtension:createWizard.steps.generalSettings.description.validations.maxLength" + ); + } + + setNextShouldBeDisabled(hasValidationErrors(errors)); + + return errors; + }; + + const validateEndpointConfigs = ( + values: EndpointConfigFormPropertyInterface + ): Partial => { + const errors: Partial = validateActionEndpointFields(values, { + authenticationType: endpointAuthType, + isAuthenticationUpdateFormState: true, + isCreateFormState: true + }); + + setNextShouldBeDisabled(hasValidationErrors(errors as Record)); + + return errors; + }; + + const resolveAuthProperties = (values: FlowExtensionWizardFormValues): Record => { + switch (endpointAuthType) { + case AuthenticationType.BASIC: + return { + password: values.passwordAuthProperty ?? "", + username: values.usernameAuthProperty ?? "" + }; + case AuthenticationType.BEARER: + return { accessToken: values.accessTokenAuthProperty ?? "" }; + case AuthenticationType.API_KEY: + return { + header: values.headerAuthProperty ?? "", + value: values.valueAuthProperty ?? "" + }; + case AuthenticationType.CLIENT_CREDENTIAL: + return { + clientId: values.clientIdAuthProperty ?? "", + clientSecret: values.clientSecretAuthProperty ?? "", + tokenEndpoint: values.tokenEndpointAuthProperty ?? "", + ...(values.scopesAuthProperty ? { scopes: values.scopesAuthProperty } : {}) + }; + default: + return {}; + } + }; + + const handleCreateError = (error: AxiosError): void => { + const limitReachedError: IdentityAppsError = ConnectionUIConstants.ERROR_CREATE_LIMIT_REACHED; + + if (error?.response?.status === 403 && error?.response?.data?.code === limitReachedError.getErrorCode()) { + setAlert({ + code: limitReachedError.getErrorCode(), + description: t(limitReachedError.getErrorDescription()), + level: AlertLevels.ERROR, + message: t(limitReachedError.getErrorMessage()), + traceId: limitReachedError.getErrorTraceId() + }); + } else if (error?.response?.data?.description) { + setAlert({ + description: error.response.data.description, + level: AlertLevels.ERROR, + message: t("flowExtension:notifications.createError.message") + }); + } else { + setAlert({ + description: t("flowExtension:notifications.createGenericError.description"), + level: AlertLevels.ERROR, + message: t("flowExtension:notifications.createGenericError.message") + }); + } + + setTimeout(() => setAlert(undefined), ConnectionUIConstants.WIZARD_ERROR_CLEAR_TIMEOUT); + }; + + const handleFormSubmit = (values: FlowExtensionWizardFormValues): void => { + const requestBody: FlowExtensionCreateRequestInterface = { + description: values.description ?? "", + endpoint: { + authentication: { + properties: resolveAuthProperties(values), + type: endpointAuthType + }, + uri: values.endpointUri + }, + name: values.name + }; + + setIsSubmitting(true); + createFlowExtension(requestBody) + .then((response: FlowExtensionResponseInterface): void => { + dispatch(addAlert({ + description: t("flowExtension:notifications.createSuccess.description"), + level: AlertLevels.SUCCESS, + message: t("flowExtension:notifications.createSuccess.message") + })); + + const editPath: string = AppConstants.getPaths() + .get("FLOW_EXTENSION_EDIT") + .replace(":id", response.id); + + history.push(editPath); + }) + .catch((error: AxiosError): void => handleCreateError(error)) + .finally((): void => setIsSubmitting(false)); + }; + + const generalSettingsPage = (): ReactElement => ( + + + { t("flowExtension:createWizard.steps.generalSettings.name.hint") } + + + ); + + const endpointConfigPage = (): ReactElement => ( + + setEndpointAuthType(type) } + data-componentid={ `${componentId}-endpoint-config-form` } + /> + + ); + + const resolveWizardPages = (): ReactElement[] => [ generalSettingsPage(), endpointConfigPage() ]; + + return ( + + + +
+ +
+ { title } + { subTitle && { subTitle } } +
+
+ + + + { wizardSteps.map((step: WizardStepInterface, index: number) => ( + + )) } + + + + { alert && alertComponent } + setCurrentWizardStep(index) } + data-componentid={ componentId } + > + { resolveWizardPages() } + + + + + + + + { t("common:cancel") } + + + + { currentWizardStep < wizardSteps.length - 1 && ( + wizardRef.current?.gotoNextPage() } + data-componentid={ `${componentId}-next-button` } + > + { t("authenticationProvider:wizards.buttons.next") } + + + ) } + { currentWizardStep === wizardSteps.length - 1 && ( + wizardRef.current?.gotoNextPage() } + data-componentid={ `${componentId}-submit-button` } + > + { t("authenticationProvider:wizards.buttons.finish") } + + ) } + { currentWizardStep > 0 && ( + wizardRef.current?.gotoPreviousPage() } + data-componentid={ `${componentId}-previous-button` } + > + + { t("authenticationProvider:wizards.buttons.previous") } + + ) } + + + + + + + ); +}; + +export default FlowExtensionCreateWizard; diff --git a/features/admin.connections.v1/configs/endpoints.ts b/features/admin.connections.v1/configs/endpoints.ts index a4484863472..05d9b11123c 100644 --- a/features/admin.connections.v1/configs/endpoints.ts +++ b/features/admin.connections.v1/configs/endpoints.ts @@ -33,6 +33,8 @@ export const getConnectionResourceEndpoints = (serverHost: string): ConnectionRe customAuthenticators: `${ serverHost }/api/server/v1/authenticators/custom`, extensions: `${ serverHost }/api/server/v1/extensions`, fidoConfigs: `${ serverHost }/api/identity/config-mgt/v1.0/resource/fido-config`, + flowExtension: `${ serverHost }/api/server/v1/flow/extension`, + flowExtensionContextTree: `${ serverHost }/api/server/v1/flow/extension/meta`, identityProviders: `${ serverHost }/api/server/v1/identity-providers`, localAuthenticators: `${ serverHost }/api/server/v1/configs/authenticators`, multiFactorAuthenticators: `${ serverHost }/api/server/v1/identity-governance/${ diff --git a/features/admin.connections.v1/configs/templates.ts b/features/admin.connections.v1/configs/templates.ts index c67ba587ed8..3a7c168650a 100644 --- a/features/admin.connections.v1/configs/templates.ts +++ b/features/admin.connections.v1/configs/templates.ts @@ -21,6 +21,7 @@ import values from "lodash-es/values"; import DefaultConnectionTemplateCategory from "../meta/templates-meta/categories/default.json"; import CustomAuthenticatorTemplateGroup from "../meta/templates-meta/groups/custom-authenticator.json"; import EnterpriseConnetionTemplateGroup from "../meta/templates-meta/groups/enterprise.json"; +import FlowExtensionTemplateGroup from "../meta/templates-meta/groups/flow-extension.json"; import { ConnectionTemplatesConfigInterface } from "../models/connection"; export const getConnectionTemplatesConfig = (): ConnectionTemplatesConfigInterface => { @@ -50,6 +51,11 @@ export const getConnectionTemplatesConfig = (): ConnectionTemplatesConfigInterfa enabled: CustomAuthenticatorTemplateGroup.enabled, id: CustomAuthenticatorTemplateGroup.id, resource: CustomAuthenticatorTemplateGroup + }, + { + enabled: FlowExtensionTemplateGroup.enabled, + id: FlowExtensionTemplateGroup.id, + resource: FlowExtensionTemplateGroup } ], "id" diff --git a/features/admin.connections.v1/configs/ui.ts b/features/admin.connections.v1/configs/ui.ts index 5256149ce4f..1850c6587a1 100644 --- a/features/admin.connections.v1/configs/ui.ts +++ b/features/admin.connections.v1/configs/ui.ts @@ -45,6 +45,7 @@ import { ReactComponent as DocumentIcon } from "../resources/assets/images/icons import EmailOTPIcon from "../resources/assets/images/icons/email-solid.svg"; import { ReactComponent as EnterpriseConnectionIcon } from "../resources/assets/images/icons/enterprise-icon.svg"; +import { ReactComponent as FlowExtensionIcon } from "../resources/assets/images/icons/flow-extension.svg"; import { ReactComponent as GearsIcon } from "../resources/assets/images/icons/gears-icon.svg"; import MagicLinkIcon from "../resources/assets/images/icons/magic-link-icon.svg"; import OIDCConnectionIcon from "../resources/assets/images/icons/oidc-connection-icon.png"; @@ -96,6 +97,7 @@ export const getConnectionIcons = (): any => { expert: ExpertIcon, facebook: FacebookLogo, fido: FIDOIcon, + flowExtension: FlowExtensionIcon, githubAuthenticator: GithubIdPIcon, google: GoogleLogo, googleOIDCAuthenticator: GoogleLogo, diff --git a/features/admin.connections.v1/constants/common-authenticator-constants.ts b/features/admin.connections.v1/constants/common-authenticator-constants.ts index 705e3614e0c..60c33c465b0 100644 --- a/features/admin.connections.v1/constants/common-authenticator-constants.ts +++ b/features/admin.connections.v1/constants/common-authenticator-constants.ts @@ -49,6 +49,7 @@ export class CommonAuthenticatorConstants { EXPERT_MODE: string; EXTERNAL_CUSTOM_AUTHENTICATOR: string; FACEBOOK: string; + FLOW_EXTENSION: string; GITHUB: string; GOOGLE: string; HYPR: string; @@ -72,6 +73,7 @@ export class CommonAuthenticatorConstants { EXPERT_MODE: "expert-mode-idp", EXTERNAL_CUSTOM_AUTHENTICATOR: "external-custom-authenticator", FACEBOOK: "facebook-idp", + FLOW_EXTENSION: "flow-extension", GITHUB: "github-idp", GOOGLE: "google-idp", HYPR: "hypr-idp", diff --git a/features/admin.connections.v1/constants/connection-ui-constants.ts b/features/admin.connections.v1/constants/connection-ui-constants.ts index 260901f351d..d7b56769683 100644 --- a/features/admin.connections.v1/constants/connection-ui-constants.ts +++ b/features/admin.connections.v1/constants/connection-ui-constants.ts @@ -16,6 +16,10 @@ * under the License. */ +import { + AuthenticationType as ActionAuthenticationType, + AuthenticationTypeDropdownOption as ActionAuthenticationTypeDropdownOption +} from "@wso2is/admin.actions.v1/models/actions"; import { ClaimManagementConstants } from "@wso2is/admin.claims.v1/constants"; import { IdentityAppsError } from "@wso2is/core/errors"; import { @@ -462,6 +466,40 @@ export class ConnectionUIConstants { } ]; + /** + * Authentication types exposed in the Flow Extension endpoint configuration. + * Typed with the admin.actions.v1 authentication types since the shared + * `ActionEndpointConfigForm` consumes this list. Excludes Password Credential, + * which is not supported for flow extensions. + */ + public static readonly FLOW_EXTENSION_AUTH_TYPES: ActionAuthenticationTypeDropdownOption[] = [ + { + key: ActionAuthenticationType.NONE, + text: "actions:fields.authentication.types.none.name", + value: ActionAuthenticationType.NONE + }, + { + key: ActionAuthenticationType.BASIC, + text: "actions:fields.authentication.types.basic.name", + value: ActionAuthenticationType.BASIC + }, + { + key: ActionAuthenticationType.BEARER, + text: "actions:fields.authentication.types.bearer.name", + value: ActionAuthenticationType.BEARER + }, + { + key: ActionAuthenticationType.API_KEY, + text: "actions:fields.authentication.types.apiKey.name", + value: ActionAuthenticationType.API_KEY + }, + { + key: ActionAuthenticationType.CLIENT_CREDENTIAL, + text: "actions:fields.authentication.types.clientCredential.name", + value: ActionAuthenticationType.CLIENT_CREDENTIAL + } + ]; + /** * Number of seconds to wait before clearing the wizard error. */ diff --git a/features/admin.connections.v1/hooks/use-get-combined-connection-list.ts b/features/admin.connections.v1/hooks/use-get-combined-connection-list.ts index 9668d0d4c43..4b273017fce 100644 --- a/features/admin.connections.v1/hooks/use-get-combined-connection-list.ts +++ b/features/admin.connections.v1/hooks/use-get-combined-connection-list.ts @@ -27,10 +27,12 @@ import { import { AxiosError } from "axios"; import get from "lodash-es/get"; import { useGetAuthenticators } from "../api/authenticators"; +import useGetFlowExtension from "../api/use-get-flow-extension"; import { LocalAuthenticatorConstants } from "../constants/local-authenticator-constants"; import { AuthenticatorMeta } from "../meta/authenticator-meta"; import { AuthenticatorTypes } from "../models/authenticators"; import { ConnectionInterface, ConnectionTypes } from "../models/connection"; +import { FlowExtensionBasicResponseInterface } from "../models/flow-extension"; import { ConnectionsManagementUtils } from "../utils/connection-utils"; /** @@ -69,7 +71,8 @@ export const useGetCombinedConnectionList = , "mutate"> & { mutate: () => void } => { const { @@ -105,9 +108,20 @@ export const useGetCombinedConnectionList = ( + shouldFetchFlowExtensions && offset === 0 + ); + const combinedData: ConnectionInterface[] = []; - if (!isAuthenticatorsFetchRequestLoading && !isIdVPListFetchRequestLoading) { + if (!isAuthenticatorsFetchRequestLoading && !isIdVPListFetchRequestLoading + && !isFlowExtensionListFetchRequestLoading) { // Add Local Authenticators to the beginning of the list. for (const authenticator of fetchedAuthenticatorsList) { @@ -167,19 +181,36 @@ export const useGetCombinedConnectionList = - || idVPListFetchRequestError as AxiosError, + || idVPListFetchRequestError as AxiosError + || flowExtensionListFetchRequestError as AxiosError, isLoading: isAuthenticatorsFetchRequestLoading - || isIdVPListFetchRequestLoading, + || isIdVPListFetchRequestLoading + || isFlowExtensionListFetchRequestLoading, isValidating: isAuthenticatorsFetchRequestValidating - || isIdVPListFetchRequestValidating, + || isIdVPListFetchRequestValidating + || isFlowExtensionListFetchRequestValidating, mutate: () => { mutateAuthenticatorsFetchRequest(); mutateIdVPListFetchRequest(); + mutateFlowExtensionListFetchRequest(); } }; }; diff --git a/features/admin.connections.v1/meta/authenticator-meta.ts b/features/admin.connections.v1/meta/authenticator-meta.ts index 10605d8929f..d66eb8dc963 100644 --- a/features/admin.connections.v1/meta/authenticator-meta.ts +++ b/features/admin.connections.v1/meta/authenticator-meta.ts @@ -236,6 +236,16 @@ export class AuthenticatorMeta { }, type); } + /** + * Get Flow Extension icon. + * + * @returns Flow Extension icon. + */ + public static getFlowExtensionIcon(): string { + + return getConnectionIcons()?.flowExtension; + } + /** * Get Authenticator Icon. * diff --git a/features/admin.connections.v1/meta/templates-meta/groups/flow-extension.json b/features/admin.connections.v1/meta/templates-meta/groups/flow-extension.json new file mode 100644 index 00000000000..9056e4b9596 --- /dev/null +++ b/features/admin.connections.v1/meta/templates-meta/groups/flow-extension.json @@ -0,0 +1,15 @@ +{ + "category": "DEFAULT", + "description": "Extend authentication flows with external services.", + "enabled": true, + "displayOrder": 13, + "id": "flow-extension", + "docLink": "", + "image": "assets/images/logos/flow-extension.svg", + "name": "Flow Extension", + "services": [], + "disabled": false, + "type": "DEFAULT", + "tags": [ "Custom" ], + "templateId": "flow-extension" +} diff --git a/features/admin.connections.v1/models/connection.ts b/features/admin.connections.v1/models/connection.ts index 23b583f5839..4e4bbba5e74 100644 --- a/features/admin.connections.v1/models/connection.ts +++ b/features/admin.connections.v1/models/connection.ts @@ -956,7 +956,8 @@ export interface AuthenticationTypeDropdownOption { */ export enum ConnectionTypes { CONNECTION = "connections", - IDVP = "identity-verification-providers" + IDVP = "identity-verification-providers", + FLOW_EXTENSION = "flow-extension" } /** @@ -982,11 +983,20 @@ export enum WizardStepsCustomAuth { GENERAL_SETTINGS = "General Settings", CONFIGURATION = "Configuration" } + +/** + * Enum for the steps of the Flow Extension create wizard. + */ +export enum WizardStepsFlowExtension { + GENERAL_SETTINGS = "General Settings", + ENDPOINT_CONFIG = "Endpoint Configuration" +} + export interface WizardStepInterface { icon: any; title: string; submitCallback: any; - name: WizardStepsCustomAuth; + name: WizardStepsCustomAuth | WizardStepsFlowExtension; } export type AvailableCustomAuthenticators = "external" | "internal" | "two-factor"; diff --git a/features/admin.connections.v1/models/endpoints.ts b/features/admin.connections.v1/models/endpoints.ts index 39740a60a0e..0d725a963d7 100644 --- a/features/admin.connections.v1/models/endpoints.ts +++ b/features/admin.connections.v1/models/endpoints.ts @@ -25,6 +25,14 @@ export interface ConnectionResourceEndpointsInterface { customAuthenticators: string; extensions: string; fidoConfigs: string; + /** + * API base URL for Flow Extension CRUD operations. + */ + flowExtension: string; + /** + * API to retrieve the Flow Extension context tree. + */ + flowExtensionContextTree: string; identityProviders: string; localAuthenticators: string; multiFactorAuthenticators: string; diff --git a/features/admin.connections.v1/models/flow-extension.ts b/features/admin.connections.v1/models/flow-extension.ts new file mode 100644 index 00000000000..6b153ce972c --- /dev/null +++ b/features/admin.connections.v1/models/flow-extension.ts @@ -0,0 +1,102 @@ +/** + * 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 { + AccessConfigInterface +} from "@wso2is/admin.actions.v1/models/actions"; + +// Re-export shared sub-types from admin.actions.v1 to keep one source of truth. +export type { + AccessConfigInterface, + AuthenticationType, + AuthenticationPropertiesInterface, + ContextPathInterface +} from "@wso2is/admin.actions.v1/models/actions"; + +/** + * Endpoint response shape returned by the Flow Extension API. + * Defined locally because EndpointResponseInterface in admin.actions.v1 is not exported. + */ +export interface FlowExtensionEndpointResponseInterface { + uri: string; + authentication: { + type: string; + properties?: Record; + }; + allowedHeaders?: string[]; + allowedParameters?: string[]; +} + +/** + * Endpoint configuration used in Flow Extension create/update requests. + */ +export interface FlowExtensionEndpointInterface { + uri: string; + authentication: { + type: string; + properties?: Record; + }; + allowedHeaders?: string[]; +} + +/** + * Request body for creating a Flow Extension. + */ +export interface FlowExtensionCreateRequestInterface { + name: string; + description?: string; + iconUrl?: string; + endpoint: FlowExtensionEndpointInterface; + accessConfig?: AccessConfigInterface; +} + +/** + * Request body for updating a Flow Extension (all fields optional). + */ +export interface FlowExtensionUpdateRequestInterface { + name?: string; + description?: string; + iconUrl?: string; + endpoint?: Partial; + accessConfig?: AccessConfigInterface; + flowTypeOverrides?: Record; +} + +/** + * Full response for a single Flow Extension from the flow management API. + */ +export interface FlowExtensionResponseInterface { + id: string; + name: string; + description?: string; + iconUrl?: string; + endpoint: FlowExtensionEndpointResponseInterface; + accessConfig?: AccessConfigInterface; + flowTypeOverrides?: Record; + createdAt?: string; +} + +/** + * List item response for a Flow Extension. + */ +export interface FlowExtensionBasicResponseInterface { + id: string; + name: string; + description?: string; + iconUrl?: string; +} diff --git a/features/admin.connections.v1/resources/assets/images/icons/flow-extension.svg b/features/admin.connections.v1/resources/assets/images/icons/flow-extension.svg new file mode 100644 index 00000000000..361d566c588 --- /dev/null +++ b/features/admin.connections.v1/resources/assets/images/icons/flow-extension.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/features/admin.core.v1/models/config.ts b/features/admin.core.v1/models/config.ts index b21d9cdb4fe..3a5c502ef80 100644 --- a/features/admin.core.v1/models/config.ts +++ b/features/admin.core.v1/models/config.ts @@ -151,6 +151,10 @@ export interface FeatureConfigInterface { * Flow orchestration feature. */ flows?: FeatureAccessConfigInterface; + /** + * Flow Extension feature. + */ + flowExtension?: FeatureAccessConfigInterface; /** * Getting started feature. */ diff --git a/features/admin.core.v1/store/reducers/config.ts b/features/admin.core.v1/store/reducers/config.ts index faf4e04465f..5dff01a8a33 100644 --- a/features/admin.core.v1/store/reducers/config.ts +++ b/features/admin.core.v1/store/reducers/config.ts @@ -132,6 +132,8 @@ export const commonConfigReducerInitialState: CommonConfigReducerStateInterface< externalClaims: "", fidoConfigs: "", flow: "", + flowExtension: "", + flowExtensionContextTree: "", flowMeta: "", fraudDetectionConfigurations: "", getSecret: "", diff --git a/features/admin.feature-gate.v1/constants/feature-flag-constants.ts b/features/admin.feature-gate.v1/constants/feature-flag-constants.ts index d1326077b59..daf1e4ce323 100644 --- a/features/admin.feature-gate.v1/constants/feature-flag-constants.ts +++ b/features/admin.feature-gate.v1/constants/feature-flag-constants.ts @@ -54,6 +54,7 @@ class FeatureFlagConstants { BRANDING_STYLES_AND_TEXT_TITLE: "branding.stylesAndText.application.title", CONNECTIONS_CUSTOM_AUTHENTICATOR: "console.connections.templates.customAuthenticator", CONNECTIONS_ENTERPRISE: "console.connections.templates.enterprise", + CONNECTIONS_FLOW_EXTENSION: "console.connections.templates.flowExtension", CONNECTIONS_IDVP: "console.connections.templates.idvp", CONSOLE_SETTINGS: "console.consoleSettings", CUSTOMER_DATA_PROFILES: "customerDataProfiles", diff --git a/modules/i18n/src/models/namespaces/flow-extension-ns.ts b/modules/i18n/src/models/namespaces/flow-extension-ns.ts index 5b98a45d426..aeb4f956632 100644 --- a/modules/i18n/src/models/namespaces/flow-extension-ns.ts +++ b/modules/i18n/src/models/namespaces/flow-extension-ns.ts @@ -17,6 +17,44 @@ */ export interface flowExtensionNS { + createWizard: { + steps: { + generalSettings: { + title: string; + name: { + label: string; + placeholder: string; + hint: string; + validations: { + invalid: string; + }; + }; + description: { + label: string; + placeholder: string; + validations: { + maxLength: string; + }; + }; + }; + endpointConfig: { + title: string; + }; + }; + }; + notifications: { + createSuccess: { + message: string; + description: string; + }; + createError: { + message: string; + }; + createGenericError: { + message: string; + description: string; + }; + }; properties: { description: string; connectionLabel: string; diff --git a/modules/i18n/src/translations/en-US/portals/flow-extension.ts b/modules/i18n/src/translations/en-US/portals/flow-extension.ts index 20444f895fd..0312fd4447f 100644 --- a/modules/i18n/src/translations/en-US/portals/flow-extension.ts +++ b/modules/i18n/src/translations/en-US/portals/flow-extension.ts @@ -19,6 +19,45 @@ import { flowExtensionNS } from "../../../models"; export const flowExtension: flowExtensionNS = { + createWizard: { + steps: { + endpointConfig: { + title: "Endpoint Configuration" + }, + generalSettings: { + description: { + label: "Description", + placeholder: "Enter a description for the flow extension.", + validations: { + maxLength: "The description cannot exceed 255 characters." + } + }, + name: { + hint: "Enter a unique name to identify this flow extension.", + label: "Name", + placeholder: "Enter a name for the flow extension.", + validations: { + invalid: "Please enter a valid name. It must start with an alphanumeric character and" + + " can contain letters, numbers, spaces, hyphens and underscores (max 255 characters)." + } + }, + title: "General Settings" + } + } + }, + notifications: { + createError: { + message: "Flow Extension Creation Error" + }, + createGenericError: { + description: "An error occurred while creating the flow extension.", + message: "Flow Extension Creation Error" + }, + createSuccess: { + description: "Successfully created the flow extension.", + message: "Creation Successful" + } + }, properties: { connectionLabel: "Connection", connectionPlaceholder: "Select a connection",