diff --git a/.changeset/purple-paths-retire.md b/.changeset/purple-paths-retire.md new file mode 100644 index 00000000..e4cfe782 --- /dev/null +++ b/.changeset/purple-paths-retire.md @@ -0,0 +1,7 @@ +--- +'@asgardeo/javascript': minor +'@asgardeo/browser': minor +'@asgardeo/react': minor +--- + +Implement component rendering extensions and context for customizable SDK behavior diff --git a/packages/javascript/src/index.ts b/packages/javascript/src/index.ts index 64ae8279..0cfbf568 100644 --- a/packages/javascript/src/index.ts +++ b/packages/javascript/src/index.ts @@ -156,10 +156,13 @@ export { I18nPreferences, I18nStorageStrategy, WithPreferences, + Extensions, + WithExtensions, SignInOptions, SignOutOptions, SignUpOptions, } from './models/config'; +export type {ComponentRenderContext, ComponentRenderer, ComponentsExtensions} from './models/v2/extensions/components'; export {TokenResponse, IdToken, TokenExchangeRequestConfig} from './models/token'; export {AgentConfig} from './models/agent'; export {AuthCodeResponse} from './models/auth-code-response'; diff --git a/packages/javascript/src/models/config.ts b/packages/javascript/src/models/config.ts index 7d671c03..8af5e166 100644 --- a/packages/javascript/src/models/config.ts +++ b/packages/javascript/src/models/config.ts @@ -17,6 +17,7 @@ */ import {I18nBundle} from '@asgardeo/i18n'; +import {ComponentsExtensions} from './v2/extensions/components'; import {Platform} from './platforms'; import {RecursivePartial} from './utility-types'; import {ThemeConfig, ThemeMode} from '../theme/types'; @@ -54,7 +55,7 @@ export type SignOutOptions = Record; */ export type SignUpOptions = Record; -export interface BaseConfig extends WithPreferences { +export interface BaseConfig extends WithPreferences, WithExtensions { /** * Optional URL where the authorization server should redirect after authentication. * This must match one of the allowed redirect URIs configured in your IdP. @@ -357,6 +358,20 @@ export interface WithPreferences { preferences?: Preferences; } +export interface Extensions { + /** + * Extension configuration for flow component rendering. + */ + components?: ComponentsExtensions; +} + +export interface WithExtensions { + /** + * Extensions for customizing SDK behavior at defined integration points. + */ + extensions?: Extensions; +} + export type Config = BaseConfig; export interface ThemePreferences { diff --git a/packages/javascript/src/models/v2/extensions/components.ts b/packages/javascript/src/models/v2/extensions/components.ts new file mode 100644 index 00000000..3404860c --- /dev/null +++ b/packages/javascript/src/models/v2/extensions/components.ts @@ -0,0 +1,95 @@ +/** + * 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 type {EmbeddedFlowComponent as EmbeddedFlowComponentV2} from '../embedded-flow-v2'; +import type {FlowMetadataResponse} from '../flow-meta-v2'; + +/** + * Framework-agnostic context passed to every custom component renderer. + * Contains form state and callbacks needed to render and submit flow components. + */ +export interface ComponentRenderContext { + /** + * Extra payload propagated by the flow engine for component rendering. + */ + additionalData?: Record; + /** + * Authentication flow type currently being rendered. + */ + authType: 'signin' | 'signup'; + /** + * Validation messages keyed by field name. + */ + formErrors: Record; + /** + * Current form values keyed by field name. + */ + formValues: Record; + /** + * Whether the current form state passes validation. + */ + isFormValid: boolean; + /** + * Indicates whether a submit action is currently in progress. + */ + isLoading: boolean; + /** + * Optional flow metadata associated with the current step. + */ + meta?: FlowMetadataResponse | null; + /** + * Optional callback fired when an input loses focus. + */ + onInputBlur?: (name: string) => void; + /** + * Callback to update the value of a named input field. + */ + onInputChange: (name: string, value: string) => void; + /** + * Optional submit handler for progressing the flow. + */ + onSubmit?: (component: EmbeddedFlowComponentV2, data?: Record, skipValidation?: boolean) => void; + /** + * Tracks whether each field has been interacted with. + */ + touchedFields: Record; +} + +/** + * A function that renders a flow component of a given type. + * `TElement` is `unknown` at the JS SDK level; each framework narrows it + * (React: `ReactElement`, Vue: `VNode`, etc.). + * + * Returning `null` hides the component. If no renderer is registered for a + * component type, the SDK falls back to its built-in rendering. + */ +export type ComponentRenderer = ( + component: EmbeddedFlowComponentV2, + context: ComponentRenderContext, +) => TElement | null; + +/** + * Extension configuration for flow component rendering. + * Keyed by component type string (e.g. `"PASSWORD_INPUT"`, `"ACTION"`). + */ +export interface ComponentsExtensions { + /** + * Custom renderers keyed by flow component type. + */ + renderers?: Record>; +} diff --git a/packages/react/src/components/presentation/auth/AuthOptionFactory.tsx b/packages/react/src/components/presentation/auth/AuthOptionFactory.tsx index 4634c74b..8e1d7d9d 100644 --- a/packages/react/src/components/presentation/auth/AuthOptionFactory.tsx +++ b/packages/react/src/components/presentation/auth/AuthOptionFactory.tsx @@ -35,8 +35,13 @@ import { } from '@asgardeo/browser'; import {css} from '@emotion/css'; import DOMPurify from 'dompurify'; -import {cloneElement, CSSProperties, ReactElement} from 'react'; +import {cloneElement, CSSProperties, ReactElement, useContext} from 'react'; import {OrganizationUnitPicker} from './OrganizationUnitPicker'; +import ComponentRendererContext, { + ComponentRenderer, + ComponentRenderContext, + ComponentRendererMap, +} from '../../../contexts/ComponentRenderer/ComponentRendererContext'; import useTheme from '../../../contexts/Theme/useTheme'; import {UseTranslation} from '../../../hooks/useTranslation'; import Consent from '../../adapters/Consent'; @@ -212,9 +217,29 @@ const createAuthComponentFromFlow = ( } = {}, ): ReactElement | null => { const {theme} = useTheme(); + const customRenderers: ComponentRendererMap = useContext(ComponentRendererContext); const key: string | number = options.key || component.id; + const customRenderer: ComponentRenderer | undefined = + customRenderers[component.id] ?? customRenderers[component.type as string]; + if (customRenderer) { + const renderCtx: ComponentRenderContext = { + additionalData: options.additionalData, + authType, + formErrors, + formValues, + isFormValid, + isLoading, + meta: options.meta, + onInputBlur: options.onInputBlur, + onInputChange, + onSubmit: options.onSubmit, + touchedFields, + }; + return customRenderer(component, renderCtx); + } + /** Resolve any remaining {{t()}} or {{meta()}} template expressions in a string at render time. */ const resolve = (text: string | undefined): string => { if (!text || (!options.t && !options.meta)) { diff --git a/packages/react/src/contexts/Asgardeo/AsgardeoProvider.tsx b/packages/react/src/contexts/Asgardeo/AsgardeoProvider.tsx index e91d67a3..05a5f950 100644 --- a/packages/react/src/contexts/Asgardeo/AsgardeoProvider.tsx +++ b/packages/react/src/contexts/Asgardeo/AsgardeoProvider.tsx @@ -42,6 +42,7 @@ import AsgardeoReactClient from '../../AsgardeoReactClient'; import useBrowserUrl from '../../hooks/useBrowserUrl'; import {AsgardeoReactConfig} from '../../models/config'; import BrandingProvider from '../Branding/BrandingProvider'; +import ComponentRendererProvider from '../ComponentRenderer/ComponentRendererProvider'; import FlowProvider from '../Flow/FlowProvider'; import FlowMetaProvider from '../FlowMeta/FlowMetaProvider'; import I18nProvider from '../I18n/I18nProvider'; @@ -65,6 +66,7 @@ const AsgardeoProvider: FC> = ({ baseUrl: initialBaseUrl, clientId, children, + extensions, scopes, preferences, signInUrl, @@ -708,7 +710,9 @@ const AsgardeoProvider: FC> = ({ onOrganizationSwitch={switchOrganization} revalidateMyOrganizations={async (): Promise => asgardeo.getMyOrganizations()} > - {children} + + {children} + diff --git a/packages/react/src/contexts/ComponentRenderer/ComponentRendererContext.ts b/packages/react/src/contexts/ComponentRenderer/ComponentRendererContext.ts new file mode 100644 index 00000000..842390dc --- /dev/null +++ b/packages/react/src/contexts/ComponentRenderer/ComponentRendererContext.ts @@ -0,0 +1,45 @@ +/** + * 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 {EmbeddedFlowComponentV2 as EmbeddedFlowComponent, FlowMetadataResponse} from '@asgardeo/browser'; +import {Context, createContext, ReactElement} from 'react'; + +export interface ComponentRenderContext { + additionalData?: Record; + authType: 'signin' | 'signup'; + formErrors: Record; + formValues: Record; + isFormValid: boolean; + isLoading: boolean; + meta?: FlowMetadataResponse | null; + onInputBlur?: (name: string) => void; + onInputChange: (name: string, value: string) => void; + onSubmit?: (component: EmbeddedFlowComponent, data?: Record, skipValidation?: boolean) => void; + touchedFields: Record; +} + +export type ComponentRenderer = ( + component: EmbeddedFlowComponent, + context: ComponentRenderContext, +) => ReactElement | null; + +export type ComponentRendererMap = Record; + +const ComponentRendererContext: Context = createContext({}); + +export default ComponentRendererContext; diff --git a/packages/react/src/contexts/ComponentRenderer/ComponentRendererProvider.tsx b/packages/react/src/contexts/ComponentRenderer/ComponentRendererProvider.tsx new file mode 100644 index 00000000..97465d58 --- /dev/null +++ b/packages/react/src/contexts/ComponentRenderer/ComponentRendererProvider.tsx @@ -0,0 +1,33 @@ +/** + * 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 {FC, PropsWithChildren, ReactElement} from 'react'; +import ComponentRendererContext, {ComponentRendererMap} from './ComponentRendererContext'; + +interface ComponentRendererProviderProps { + renderers: ComponentRendererMap; +} + +const ComponentRendererProvider: FC> = ({ + renderers, + children, +}: PropsWithChildren): ReactElement => ( + {children} +); + +export default ComponentRendererProvider;