diff --git a/.changeset/lovely-regions-camp.md b/.changeset/lovely-regions-camp.md new file mode 100644 index 00000000..5300a6fc --- /dev/null +++ b/.changeset/lovely-regions-camp.md @@ -0,0 +1,6 @@ +--- +'@asgardeo/javascript': minor +'@asgardeo/react': minor +--- + +Add user onboarding components for v2 diff --git a/packages/javascript/src/api/v2/executeEmbeddedUserOnboardingFlowV2.ts b/packages/javascript/src/api/v2/executeEmbeddedUserOnboardingFlowV2.ts new file mode 100644 index 00000000..286a2c18 --- /dev/null +++ b/packages/javascript/src/api/v2/executeEmbeddedUserOnboardingFlowV2.ts @@ -0,0 +1,176 @@ +/** + * Copyright (c) 2025, 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 { EmbeddedFlowExecuteRequestConfig as EmbeddedFlowExecuteRequestConfigV2 } from '../../models/v2/embedded-flow-v2'; +import { EmbeddedFlowType } from '../../models/embedded-flow'; +import AsgardeoAPIError from '../../errors/AsgardeoAPIError'; + +/** + * Response from the user onboarding flow execution. + */ +export interface EmbeddedUserOnboardingFlowResponse { + /** + * Unique identifier for the flow execution. + */ + flowId: string; + + /** + * Current status of the flow. + */ + flowStatus: 'INCOMPLETE' | 'COMPLETE' | 'ERROR'; + + /** + * Type of the current step in the flow. + */ + type?: 'VIEW' | 'REDIRECTION'; + + /** + * Data for the current step including components and additional data. + */ + data?: { + /** + * UI components to render for the current step. + */ + components?: any[]; + + /** + * Additional data from the flow step (e.g., inviteLink). + */ + additionalData?: Record; + }; + + /** + * Reason for failure if flowStatus is ERROR. + */ + failureReason?: string; +} + +/** + * Executes an embedded user onboarding flow by sending a request to the flow execution endpoint. + * + * This function handles both: + * - Admin flow: Initiates onboarding, collects user details, generates invite link + * - End-user flow: Validates invite token and allows password setting + * + * @param requestConfig - Request configuration object containing URL, payload, and optional auth token. + * @returns A promise that resolves with the flow execution response. + * @throws AsgardeoAPIError when the request fails or URL is invalid. + * + * @example + * ```typescript + * // Admin initiating user onboarding (requires auth token) + * const response = await executeEmbeddedUserOnboardingFlowV2({ + * baseUrl: "https://api.thunder.io", + * payload: { + * flowType: "USER_ONBOARDING" + * }, + * headers: { + * Authorization: `Bearer ${accessToken}` + * } + * }); + * + * // End-user accepting invite (no auth required) + * const response = await executeEmbeddedUserOnboardingFlowV2({ + * baseUrl: "https://api.thunder.io", + * payload: { + * flowId: "flow-id-from-url", + * inputs: { inviteToken: "token-from-url" } + * } + * }); + * ``` + */ +const executeEmbeddedUserOnboardingFlowV2 = async ({ + url, + baseUrl, + payload, + ...requestConfig +}: EmbeddedFlowExecuteRequestConfigV2): Promise => { + if (!payload) { + throw new AsgardeoAPIError( + 'User onboarding payload is required', + 'executeEmbeddedUserOnboardingFlow-ValidationError-002', + 'javascript', + 400, + 'If a user onboarding payload is not provided, the request cannot be constructed correctly.', + ); + } + + const endpoint: string = url ?? `${baseUrl}/flow/execute`; + + // Strip any user-provided 'verbose' parameter as it should only be used internally + const cleanPayload: typeof payload = + typeof payload === 'object' && payload !== null + ? Object.fromEntries(Object.entries(payload).filter(([key]) => key !== 'verbose')) + : payload; + + // `verbose: true` is required to get the `meta` field in the response that includes component details. + // Add verbose:true for initial requests or flow continuation without inputs + const hasOnlyFlowType: boolean = + typeof cleanPayload === 'object' && + cleanPayload !== null && + 'flowType' in cleanPayload && + Object.keys(cleanPayload).length === 1; + const hasOnlyFlowId: boolean = + typeof cleanPayload === 'object' && + cleanPayload !== null && + 'flowId' in cleanPayload && + Object.keys(cleanPayload).length === 1; + const hasFlowIdWithInputs: boolean = + typeof cleanPayload === 'object' && + cleanPayload !== null && + 'flowId' in cleanPayload && + 'inputs' in cleanPayload; + + // Add verbose for initial requests and when continuing with inputs + const requestPayload: Record = + hasOnlyFlowType || hasOnlyFlowId || hasFlowIdWithInputs ? { ...cleanPayload, verbose: true } : cleanPayload; + + // Ensure flowType is USER_ONBOARDING for initial requests + if ('flowType' in requestPayload && requestPayload['flowType'] !== EmbeddedFlowType.UserOnboarding) { + requestPayload['flowType'] = EmbeddedFlowType.UserOnboarding; + } + + const response: Response = await fetch(endpoint, { + ...requestConfig, + method: requestConfig.method || 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + ...requestConfig.headers, + }, + body: JSON.stringify(requestPayload), + }); + + if (!response.ok) { + const errorText = await response.text(); + + throw new AsgardeoAPIError( + `User onboarding request failed: ${errorText}`, + 'executeEmbeddedUserOnboardingFlow-ResponseError-001', + 'javascript', + response.status, + response.statusText, + ); + } + + const flowResponse: EmbeddedUserOnboardingFlowResponse = await response.json(); + + return flowResponse; +}; + +export default executeEmbeddedUserOnboardingFlowV2; diff --git a/packages/javascript/src/index.ts b/packages/javascript/src/index.ts index 37dbec4d..be36ada1 100644 --- a/packages/javascript/src/index.ts +++ b/packages/javascript/src/index.ts @@ -47,6 +47,10 @@ export {default as updateMeProfile, UpdateMeProfileConfig} from './api/updateMeP export {default as getBrandingPreference, GetBrandingPreferenceConfig} from './api/getBrandingPreference'; export {default as executeEmbeddedSignInFlowV2} from './api/v2/executeEmbeddedSignInFlowV2'; export {default as executeEmbeddedSignUpFlowV2} from './api/v2/executeEmbeddedSignUpFlowV2'; +export { + default as executeEmbeddedUserOnboardingFlowV2, + EmbeddedUserOnboardingFlowResponse, +} from './api/v2/executeEmbeddedUserOnboardingFlowV2'; export {default as ApplicationNativeAuthenticationConstants} from './constants/ApplicationNativeAuthenticationConstants'; export {default as TokenConstants} from './constants/TokenConstants'; diff --git a/packages/javascript/src/models/embedded-flow.ts b/packages/javascript/src/models/embedded-flow.ts index 3432985f..c8876f8f 100644 --- a/packages/javascript/src/models/embedded-flow.ts +++ b/packages/javascript/src/models/embedded-flow.ts @@ -19,6 +19,7 @@ export enum EmbeddedFlowType { Authentication = 'AUTHENTICATION', Registration = 'REGISTRATION', + UserOnboarding = 'USER_ONBOARDING', } export interface EmbeddedFlowExecuteRequestPayload { diff --git a/packages/react/src/components/presentation/auth/AcceptInvite/index.ts b/packages/react/src/components/presentation/auth/AcceptInvite/index.ts new file mode 100644 index 00000000..f2fabfad --- /dev/null +++ b/packages/react/src/components/presentation/auth/AcceptInvite/index.ts @@ -0,0 +1,27 @@ +/** + * Copyright (c) 2025, 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. + */ + +// v2 exports (current) +export { default as AcceptInvite } from './v2/AcceptInvite'; +export type { AcceptInviteProps, AcceptInviteRenderProps } from './v2/AcceptInvite'; +export { default as BaseAcceptInvite } from './v2/BaseAcceptInvite'; +export type { + BaseAcceptInviteProps, + BaseAcceptInviteRenderProps, + AcceptInviteFlowResponse, +} from './v2/BaseAcceptInvite'; diff --git a/packages/react/src/components/presentation/auth/AcceptInvite/v2/AcceptInvite.tsx b/packages/react/src/components/presentation/auth/AcceptInvite/v2/AcceptInvite.tsx new file mode 100644 index 00000000..97c64fbc --- /dev/null +++ b/packages/react/src/components/presentation/auth/AcceptInvite/v2/AcceptInvite.tsx @@ -0,0 +1,235 @@ +/** + * Copyright (c) 2025, 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, ReactNode, useMemo } from 'react'; +import BaseAcceptInvite, { + BaseAcceptInviteRenderProps, + AcceptInviteFlowResponse, +} from './BaseAcceptInvite'; + +/** + * Render props for AcceptInvite (re-exported for convenience). + */ +export type AcceptInviteRenderProps = BaseAcceptInviteRenderProps; + +/** + * Props for the AcceptInvite component. + */ +export interface AcceptInviteProps { + /** + * Base URL for the Thunder API server. + * If not provided, will try to read from window location. + */ + baseUrl?: string; + + /** + * Flow ID from the invite link. + * If not provided, will be extracted from URL query parameters. + */ + flowId?: string; + + /** + * Invite token from the invite link. + * If not provided, will be extracted from URL query parameters. + */ + inviteToken?: string; + + /** + * Callback when the flow completes successfully. + */ + onComplete?: () => void; + + /** + * Callback when an error occurs. + */ + onError?: (error: Error) => void; + + /** + * Callback when the flow state changes. + */ + onFlowChange?: (response: AcceptInviteFlowResponse) => void; + + /** + * Callback to navigate to sign in page. + */ + onGoToSignIn?: () => void; + + /** + * Custom CSS class name. + */ + className?: string; + + /** + * Render props function for custom UI. + * If not provided, default UI will be rendered by the SDK. + */ + children?: (props: AcceptInviteRenderProps) => ReactNode; + + /** + * Size variant for the component. + */ + size?: 'small' | 'medium' | 'large'; + + /** + * Theme variant for the component card. + */ + variant?: 'outlined' | 'elevated'; + + /** + * Whether to show the title. + */ + showTitle?: boolean; + + /** + * Whether to show the subtitle. + */ + showSubtitle?: boolean; +} + +/** + * Helper to extract query parameters from URL. + */ +const getUrlParams = (): { flowId?: string; inviteToken?: string } => { + if (typeof window === 'undefined') { + return {}; + } + + const params = new URLSearchParams(window.location.search); + return { + flowId: params.get('flowId') || undefined, + inviteToken: params.get('inviteToken') || undefined, + }; +}; + +/** + * AcceptInvite component for end-users to accept an invite and set their password. + * + * This component is designed for end users accessing the thunder-gate app via an invite link. + * It automatically: + * 1. Extracts flowId and inviteToken from URL query parameters + * 2. Validates the invite token with the backend + * 3. Displays the password form if token is valid + * 4. Completes the accept invite when password is set + * + * @example + * ```tsx + * import { AcceptInvite } from '@asgardeo/react'; + * + * // URL: /invite?flowId=xxx&inviteToken=yyy + * + * const AcceptInvitePage = () => { + * return ( + * navigate('/signin')} + * onError={(error) => console.error(error)} + * > + * {({ values, components, isLoading, isComplete, isValidatingToken, isTokenInvalid, error, handleInputChange, handleSubmit }) => ( + *
+ * {isValidatingToken &&

Validating your invite...

} + * {isTokenInvalid &&

Invalid or expired invite link

} + * {isComplete &&

Registration complete! You can now sign in.

} + * {!isComplete && !isValidatingToken && !isTokenInvalid && ( + * // Render password form based on components + * )} + *
+ * )} + *
+ * ); + * }; + * ``` + */ +const AcceptInvite: FC = ({ + baseUrl, + flowId: flowIdProp, + inviteToken: inviteTokenProp, + onComplete, + onError, + onFlowChange, + onGoToSignIn, + className, + children, + size = 'medium', + variant = 'outlined', + showTitle = true, + showSubtitle = true, +}) => { + // Extract from URL if not provided as props + const { flowId: urlFlowId, inviteToken: urlInviteToken } = useMemo(() => getUrlParams(), []); + + const flowId = flowIdProp || urlFlowId; + const inviteToken = inviteTokenProp || urlInviteToken; + + // Determine base URL + const apiBaseUrl = useMemo(() => { + if (baseUrl) { + return baseUrl; + } + // Try to construct from current location (assuming same origin) + if (typeof window !== 'undefined') { + return window.location.origin; + } + return ''; + }, [baseUrl]); + + /** + * Submit flow step data. + * Makes an unauthenticated request to /flow/execute endpoint. + */ + const handleSubmit = async (payload: Record): Promise => { + const response = await fetch(`${apiBaseUrl}/flow/execute`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify({ + ...payload, + verbose: true, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Request failed: ${errorText}`); + } + + return response.json(); + }; + + return ( + + {children} + + ); +}; + +export default AcceptInvite; diff --git a/packages/react/src/components/presentation/auth/AcceptInvite/v2/BaseAcceptInvite.styles.ts b/packages/react/src/components/presentation/auth/AcceptInvite/v2/BaseAcceptInvite.styles.ts new file mode 100644 index 00000000..e4ad1049 --- /dev/null +++ b/packages/react/src/components/presentation/auth/AcceptInvite/v2/BaseAcceptInvite.styles.ts @@ -0,0 +1,68 @@ +/** + * Copyright (c) 2025, 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 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 {css} from '@emotion/css'; +import {useMemo} from 'react'; +import {Theme} from '@asgardeo/browser'; + +/** + * Creates styles for the BaseAcceptInvite component + * @param theme - The theme object containing design tokens + * @param colorScheme - The current color scheme (used for memoization) + * @returns Object containing CSS class names for component styling + */ +const useStyles = (theme: Theme, colorScheme: string) => { + return useMemo(() => { + const card = css` + background: ${theme.vars.colors.background.surface}; + border-radius: ${theme.vars.borderRadius.large}; + gap: calc(${theme.vars.spacing.unit} * 2); + min-width: 420px; + `; + + const header = css` + gap: 0; + align-items: center; + `; + + const title = css` + margin: 0 0 calc(${theme.vars.spacing.unit} * 1) 0; + color: ${theme.vars.colors.text.primary}; + `; + + const subtitle = css` + margin-bottom: calc(${theme.vars.spacing.unit} * 1); + color: ${theme.vars.colors.text.secondary}; + `; + + return { + card, + header, + title, + subtitle, + }; + }, [ + theme.vars.colors.background.surface, + theme.vars.colors.text.primary, + theme.vars.colors.text.secondary, + theme.vars.borderRadius.large, + theme.vars.spacing.unit, + colorScheme, + ]); +}; + +export default useStyles; diff --git a/packages/react/src/components/presentation/auth/AcceptInvite/v2/BaseAcceptInvite.tsx b/packages/react/src/components/presentation/auth/AcceptInvite/v2/BaseAcceptInvite.tsx new file mode 100644 index 00000000..0c391fee --- /dev/null +++ b/packages/react/src/components/presentation/auth/AcceptInvite/v2/BaseAcceptInvite.tsx @@ -0,0 +1,701 @@ +/** + * Copyright (c) 2025, 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, ReactElement, ReactNode, useCallback, useEffect, useRef, useState } from 'react'; +import { cx } from '@emotion/css'; +import { renderInviteUserComponents } from '../../AuthOptionFactory'; +import { normalizeFlowResponse, extractErrorMessage } from '../../../../../utils/v2/flowTransformer'; +import useTranslation from '../../../../../hooks/useTranslation'; +import useTheme from '../../../../../contexts/Theme/useTheme'; +import useStyles from './BaseAcceptInvite.styles'; +import Card, { CardProps } from '../../../../primitives/Card/Card'; +import Typography from '../../../../primitives/Typography/Typography'; +import Alert from '../../../../primitives/Alert/Alert'; +import Spinner from '../../../../primitives/Spinner/Spinner'; +import Button from '../../../../primitives/Button/Button'; + +/** + * Flow response structure from the backend. + */ +export interface AcceptInviteFlowResponse { + flowId: string; + flowStatus: 'INCOMPLETE' | 'COMPLETE' | 'ERROR'; + type?: 'VIEW' | 'REDIRECTION'; + data?: { + components?: any[]; + meta?: { + components?: any[]; + }; + additionalData?: Record; + }; + failureReason?: string; +} + +/** + * Render props for custom UI rendering of AcceptInvite. + */ +export interface BaseAcceptInviteRenderProps { + /** + * Form values for the current step. + */ + values: Record; + + /** + * Field validation errors. + */ + fieldErrors: Record; + + /** + * API error (if any). + */ + error?: Error | null; + + /** + * Touched fields. + */ + touched: Record; + + /** + * Loading state. + */ + isLoading: boolean; + + /** + * Flow components from the current step. + */ + components: any[]; + + /** + * Current flow ID from URL. + */ + flowId?: string; + + /** + * Invite token from URL. + */ + inviteToken?: string; + + /** + * Function to handle input changes. + */ + handleInputChange: (name: string, value: string) => void; + + /** + * Function to handle input blur. + */ + handleInputBlur: (name: string) => void; + + /** + * Function to handle form submission. + */ + handleSubmit: (component: any, data?: Record) => Promise; + + /** + * Whether the flow has completed successfully. + */ + isComplete: boolean; + + /** + * Whether the invite token is being validated. + */ + isValidatingToken: boolean; + + /** + * Whether the token validation failed. + */ + isTokenInvalid: boolean; + + /** + * Title for the current step. + */ + title?: string; + + /** + * Subtitle for the current step. + */ + subtitle?: string; + + /** + * Whether the form is valid. + */ + isValid: boolean; + + /** + * Navigate to sign in page. + */ + goToSignIn?: () => void; + + /** + * Title from the password screen (for use in completion screen). + */ + completionTitle?: string; +} + +/** + * Props for the BaseAcceptInvite component. + */ +export interface BaseAcceptInviteProps { + /** + * Flow ID from the invite link URL. + */ + flowId?: string; + + /** + * Invite token from the invite link URL. + */ + inviteToken?: string; + + /** + * Callback when the flow completes successfully. + */ + onComplete?: () => void; + + /** + * Callback when an error occurs. + */ + onError?: (error: Error) => void; + + /** + * Callback when the flow state changes. + */ + onFlowChange?: (response: AcceptInviteFlowResponse) => void; + + /** + * Function to submit flow step data. + * This makes a request to the flow/execute endpoint. + */ + onSubmit: (payload: Record) => Promise; + + /** + * Callback to navigate to sign in page. + */ + onGoToSignIn?: () => void; + + /** + * Custom CSS class name. + */ + className?: string; + + /** + * Render props function for custom UI. + * If not provided, default UI will be rendered. + */ + children?: (props: BaseAcceptInviteRenderProps) => ReactNode; + + /** + * Size variant for the component. + */ + size?: 'small' | 'medium' | 'large'; + + /** + * Theme variant for the component. + */ + variant?: CardProps['variant']; + + /** + * Whether to show the title. + */ + showTitle?: boolean; + + /** + * Whether to show the subtitle. + */ + showSubtitle?: boolean; +} + +/** + * Base component for accept invite flow (end-user). + * Handles the flow logic for validating an invite token and setting a password. + * + * When no children are provided, renders a default UI with: + * - Loading spinner during token validation + * - Error alerts for invalid/expired tokens + * - Password form with validation + * - Success state with sign-in redirect + * + * Flow steps handled: + * 1. Validate invite token (automatic on mount) + * 2. Password input + * 3. Flow completion + */ +const BaseAcceptInvite: FC = ({ + flowId, + inviteToken, + onSubmit, + onComplete, + onError, + onFlowChange, + onGoToSignIn, + className = '', + children, + size = 'medium', + variant = 'outlined', + showTitle = true, + showSubtitle = true, +}) => { + const { t } = useTranslation(); + const { theme } = useTheme(); + const styles = useStyles(theme, theme.vars.colors.text.primary); + const [isLoading, setIsLoading] = useState(false); + const [isValidatingToken, setIsValidatingToken] = useState(true); + const [isTokenInvalid, setIsTokenInvalid] = useState(false); + const [isComplete, setIsComplete] = useState(false); + const [currentFlow, setCurrentFlow] = useState(null); + const [apiError, setApiError] = useState(null); + const [formValues, setFormValues] = useState>({}); + const [formErrors, setFormErrors] = useState>({}); + const [touchedFields, setTouchedFields] = useState>({}); + const [isFormValid, setIsFormValid] = useState(true); + const [completionTitle, setCompletionTitle] = useState(undefined); + + const tokenValidationAttemptedRef = useRef(false); + + /** + * Handle error responses and extract meaningful error messages. + * Uses the transformer's extractErrorMessage function for consistency. + */ + const handleError = useCallback( + (error: any) => { + // Extract error message from response failureReason or use extractErrorMessage + const errorMessage: string = error?.failureReason || extractErrorMessage(error, t, 'components.acceptInvite.errors.generic'); + + // Set the API error state + setApiError(error instanceof Error ? error : new Error(errorMessage)); + + // Call the onError callback if provided + onError?.(error instanceof Error ? error : new Error(errorMessage)); + }, + [t, onError], + ); + + /** + * Normalize flow response to ensure component-driven format. + * Transforms data.meta.components to data.components. + */ + const normalizeFlowResponseLocal = useCallback( + (response: AcceptInviteFlowResponse): AcceptInviteFlowResponse => { + if (!response?.data?.meta?.components) { + return response; + } + + try { + const { components } = normalizeFlowResponse(response, t, { + defaultErrorKey: 'components.acceptInvite.errors.generic', + resolveTranslations: !children, + }); + + return { + ...response, + data: { + ...response.data, + components: components as any, + }, + }; + } catch { + // If transformer throws (e.g., error response), return as-is + return response; + } + }, + [t, children], + ); + + /** + * Handle input value changes. + */ + const handleInputChange = useCallback((name: string, value: string) => { + setFormValues(prev => ({ ...prev, [name]: value })); + // Clear error when user starts typing + setFormErrors(prev => { + const newErrors = { ...prev }; + delete newErrors[name]; + return newErrors; + }); + }, []); + + /** + * Handle input blur. + */ + const handleInputBlur = useCallback((name: string) => { + setTouchedFields(prev => ({ ...prev, [name]: true })); + }, []); + + /** + * Validate required fields based on components. + */ + const validateForm = useCallback( + (components: any[]): { isValid: boolean; errors: Record } => { + const errors: Record = {}; + + const validateComponents = (comps: any[]) => { + comps.forEach(comp => { + if ( + (comp.type === 'PASSWORD_INPUT' || comp.type === 'TEXT_INPUT' || comp.type === 'EMAIL_INPUT') && + comp.required && + comp.ref + ) { + const value = formValues[comp.ref]; + if (!value || value.trim() === '') { + errors[comp.ref] = `${comp.label || comp.ref} is required`; + } + } + if (comp.components && Array.isArray(comp.components)) { + validateComponents(comp.components); + } + }); + }; + + validateComponents(components); + + return { isValid: Object.keys(errors).length === 0, errors }; + }, + [formValues], + ); + + /** + * Handle form submission. + */ + const handleSubmit = useCallback( + async (component: any, data?: Record) => { + if (!currentFlow) { + return; + } + + // Validate form before submission + const components = currentFlow.data?.components || []; + const validation = validateForm(components); + + if (!validation.isValid) { + setFormErrors(validation.errors); + setIsFormValid(false); + // Mark all fields as touched + const touched: Record = {}; + Object.keys(validation.errors).forEach(key => { + touched[key] = true; + }); + setTouchedFields(prev => ({ ...prev, ...touched })); + return; + } + + setIsLoading(true); + setApiError(null); + setIsFormValid(true); + + try { + // Build payload with form values + const inputs = data || formValues; + + const payload: Record = { + flowId: currentFlow.flowId, + inputs, + verbose: true, + }; + + // Add action ID if component has one + if (component?.id) { + payload['action'] = component.id; + } + + const rawResponse = await onSubmit(payload); + const response = normalizeFlowResponseLocal(rawResponse); + onFlowChange?.(response); + + // Store the heading from current flow before completion + if (currentFlow?.data?.components || currentFlow?.data?.meta?.components) { + const currentComponents = currentFlow.data.components || currentFlow.data.meta?.components || []; + const heading = currentComponents.find( + (comp: any) => comp.type === 'TEXT' && comp.variant === 'HEADING_1' + ); + if (heading?.label) { + setCompletionTitle(heading.label); + } + } + + // Check for completion + if (response.flowStatus === 'COMPLETE') { + setIsComplete(true); + onComplete?.(); + return; + } + + // Check for error status + if (response.flowStatus === 'ERROR') { + handleError(response); + return; + } + + // Update current flow and reset form for next step + setCurrentFlow(response); + setFormValues({}); + setFormErrors({}); + setTouchedFields({}); + } catch (err) { + handleError(err); + } finally { + setIsLoading(false); + } + }, + [currentFlow, formValues, validateForm, onSubmit, onFlowChange, onComplete, handleError, normalizeFlowResponseLocal], + ); + + /** + * Validate invite token on component mount. + */ + useEffect(() => { + if (!flowId || !inviteToken || tokenValidationAttemptedRef.current) { + if (!flowId || !inviteToken) { + setIsValidatingToken(false); + setIsTokenInvalid(true); + handleError(new Error('Invalid invite link. Missing flowId or inviteToken.')); + } + return; + } + + tokenValidationAttemptedRef.current = true; + + (async () => { + setIsValidatingToken(true); + setApiError(null); + + try { + // Send the invite token to validate and continue the flow + const payload = { + flowId, + inputs: { + inviteToken, + }, + verbose: true, + }; + + const rawResponse = await onSubmit(payload); + const response = normalizeFlowResponseLocal(rawResponse); + onFlowChange?.(response); + + // Check for error (invalid token) + if (response.flowStatus === 'ERROR') { + setIsTokenInvalid(true); + handleError(response); + return; + } + + // Token is valid, show the password form + setCurrentFlow(response); + } catch (err) { + setIsTokenInvalid(true); + handleError(err); + } finally { + setIsValidatingToken(false); + } + })(); + }, [flowId, inviteToken, onSubmit, onFlowChange, handleError, normalizeFlowResponseLocal]); + + /** + * Extract title and subtitle from components. + */ + const extractHeadings = useCallback((components: any[]): { title?: string; subtitle?: string } => { + let title: string | undefined; + let subtitle: string | undefined; + + components.forEach(comp => { + if (comp.type === 'TEXT') { + if (comp.variant === 'HEADING_1' && !title) { + title = comp.label; + } else if ((comp.variant === 'HEADING_2' || comp.variant === 'SUBTITLE_1') && !subtitle) { + subtitle = comp.label; + } + } + }); + + return { title, subtitle }; + }, []); + + /** + * Filter out heading components for default rendering. + */ + const filterHeadings = useCallback((components: any[]): any[] => { + return components.filter( + comp => !(comp.type === 'TEXT' && (comp.variant === 'HEADING_1' || comp.variant === 'HEADING_2')), + ); + }, []); + + /** + * Render form components using the factory. + */ + const renderComponents = useCallback( + (components: any[]): ReactElement[] => + renderInviteUserComponents(components, formValues, touchedFields, formErrors, isLoading, isFormValid, handleInputChange, { + onInputBlur: handleInputBlur, + onSubmit: handleSubmit, + size, + variant, + }), + [formValues, touchedFields, formErrors, isLoading, isFormValid, handleInputChange, handleInputBlur, handleSubmit, size, variant], + ); + + // Get components from normalized response, with fallback to meta.components + const components = currentFlow?.data?.components || currentFlow?.data?.meta?.components || []; + const { title, subtitle } = extractHeadings(components); + const componentsWithoutHeadings = filterHeadings(components); + + // Render props + const renderProps: BaseAcceptInviteRenderProps = { + values: formValues, + fieldErrors: formErrors, + error: apiError, + touched: touchedFields, + isLoading, + components, + flowId, + inviteToken, + handleInputChange, + handleInputBlur, + handleSubmit, + isComplete, + isValidatingToken, + isTokenInvalid, + title, + subtitle, + isValid: isFormValid, + goToSignIn: onGoToSignIn, + completionTitle, + }; + + // If children render prop is provided, use it for custom UI + if (children) { + return
{children(renderProps)}
; + } + + // Default rendering + + // Loading state during token validation + if (isValidatingToken) { + return ( + + +
+ + Validating your invite link... +
+
+
+ ); + } + + // Invalid token state + if (isTokenInvalid) { + return ( + + + Invalid Invite Link + + + + Unable to verify invite + + {apiError?.message || 'This invite link is invalid or has expired. Please contact your administrator for a new invite.'} + + + {onGoToSignIn && ( +
+ +
+ )} +
+
+ ); + } + + // Completion state + if (isComplete) { + return ( + + + Account Setup Complete! + + + + + Your account has been successfully set up. You can now sign in with your credentials. + + + {onGoToSignIn && ( +
+ +
+ )} +
+
+ ); + } + + // Flow components (password form) + return ( + + {(showTitle || showSubtitle) && (title || subtitle) && ( + + {showTitle && title && {title}} + {showSubtitle && subtitle && {subtitle}} + + )} + + {apiError && ( +
+ + {apiError.message} + +
+ )} +
+ {componentsWithoutHeadings && componentsWithoutHeadings.length > 0 ? ( + renderComponents(componentsWithoutHeadings) + ) : ( + !isLoading && ( + + No form components available + + ) + )} + {isLoading && ( +
+ +
+ )} +
+ {onGoToSignIn && ( +
+ + Already have an account?{' '} + + +
+ )} +
+
+ ); +}; + +export default BaseAcceptInvite; diff --git a/packages/react/src/components/presentation/auth/AuthOptionFactory.tsx b/packages/react/src/components/presentation/auth/AuthOptionFactory.tsx index 714a75d9..639eb005 100644 --- a/packages/react/src/components/presentation/auth/AuthOptionFactory.tsx +++ b/packages/react/src/components/presentation/auth/AuthOptionFactory.tsx @@ -373,3 +373,43 @@ export const renderSignUpComponents = ( ), ) .filter(Boolean); + +/** + * Processes an array of components and renders them as React elements for invite user. + * This is used by both InviteUser and AcceptInvite components. + */ +export const renderInviteUserComponents = ( + components: EmbeddedFlowComponent[], + formValues: Record, + touchedFields: Record, + formErrors: Record, + isLoading: boolean, + isFormValid: boolean, + onInputChange: (name: string, value: string) => void, + options?: { + buttonClassName?: string; + inputClassName?: string; + onInputBlur?: (name: string) => void; + onSubmit?: (component: EmbeddedFlowComponent, data?: Record, skipValidation?: boolean) => void; + size?: 'small' | 'medium' | 'large'; + variant?: any; + }, +): ReactElement[] => + components + .map((component, index) => + createAuthComponentFromFlow( + component, + formValues, + touchedFields, + formErrors, + isLoading, + isFormValid, + onInputChange, + 'signup', + { + ...options, + key: component.id || index, + }, + ), + ) + .filter(Boolean); diff --git a/packages/react/src/components/presentation/auth/InviteUser/index.ts b/packages/react/src/components/presentation/auth/InviteUser/index.ts new file mode 100644 index 00000000..23ad4139 --- /dev/null +++ b/packages/react/src/components/presentation/auth/InviteUser/index.ts @@ -0,0 +1,27 @@ +/** + * Copyright (c) 2025, 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. + */ + +// v2 exports (current) +export { default as InviteUser } from './v2/InviteUser'; +export type { InviteUserProps, InviteUserRenderProps } from './v2/InviteUser'; +export { default as BaseInviteUser } from './v2/BaseInviteUser'; +export type { + BaseInviteUserProps, + BaseInviteUserRenderProps, + InviteUserFlowResponse, +} from './v2/BaseInviteUser'; diff --git a/packages/react/src/components/presentation/auth/InviteUser/v2/BaseInviteUser.styles.ts b/packages/react/src/components/presentation/auth/InviteUser/v2/BaseInviteUser.styles.ts new file mode 100644 index 00000000..aea81f08 --- /dev/null +++ b/packages/react/src/components/presentation/auth/InviteUser/v2/BaseInviteUser.styles.ts @@ -0,0 +1,68 @@ +/** + * Copyright (c) 2025, 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 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 {css} from '@emotion/css'; +import {useMemo} from 'react'; +import {Theme} from '@asgardeo/browser'; + +/** + * Creates styles for the BaseInviteUser component + * @param theme - The theme object containing design tokens + * @param colorScheme - The current color scheme (used for memoization) + * @returns Object containing CSS class names for component styling + */ +const useStyles = (theme: Theme, colorScheme: string) => { + return useMemo(() => { + const card = css` + background: ${theme.vars.colors.background.surface}; + border-radius: ${theme.vars.borderRadius.large}; + gap: calc(${theme.vars.spacing.unit} * 2); + min-width: 420px; + `; + + const header = css` + gap: 0; + align-items: center; + `; + + const title = css` + margin: 0 0 calc(${theme.vars.spacing.unit} * 1) 0; + color: ${theme.vars.colors.text.primary}; + `; + + const subtitle = css` + margin-bottom: calc(${theme.vars.spacing.unit} * 1); + color: ${theme.vars.colors.text.secondary}; + `; + + return { + card, + header, + title, + subtitle, + }; + }, [ + theme.vars.colors.background.surface, + theme.vars.colors.text.primary, + theme.vars.colors.text.secondary, + theme.vars.borderRadius.large, + theme.vars.spacing.unit, + colorScheme, + ]); +}; + +export default useStyles; diff --git a/packages/react/src/components/presentation/auth/InviteUser/v2/BaseInviteUser.tsx b/packages/react/src/components/presentation/auth/InviteUser/v2/BaseInviteUser.tsx new file mode 100644 index 00000000..909c8d61 --- /dev/null +++ b/packages/react/src/components/presentation/auth/InviteUser/v2/BaseInviteUser.tsx @@ -0,0 +1,726 @@ +/** + * Copyright (c) 2025, 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, ReactElement, ReactNode, useCallback, useEffect, useRef, useState } from 'react'; +import { EmbeddedFlowType } from '@asgardeo/browser'; +import { cx } from '@emotion/css'; +import { renderInviteUserComponents } from '../../AuthOptionFactory'; +import { normalizeFlowResponse, extractErrorMessage } from '../../../../../utils/v2/flowTransformer'; +import useTranslation from '../../../../../hooks/useTranslation'; +import useTheme from '../../../../../contexts/Theme/useTheme'; +import useStyles from './BaseInviteUser.styles'; +import Card, { CardProps } from '../../../../primitives/Card/Card'; +import Typography from '../../../../primitives/Typography/Typography'; +import Alert from '../../../../primitives/Alert/Alert'; +import Spinner from '../../../../primitives/Spinner/Spinner'; +import Button from '../../../../primitives/Button/Button'; + +/** + * Flow response structure from the backend. + */ +export interface InviteUserFlowResponse { + flowId: string; + flowStatus: 'INCOMPLETE' | 'COMPLETE' | 'ERROR'; + type?: 'VIEW' | 'REDIRECTION'; + data?: { + components?: any[]; + meta?: { + components?: any[]; + }; + additionalData?: Record; + }; + failureReason?: string; +} + +/** + * Render props for custom UI rendering of InviteUser. + */ +export interface BaseInviteUserRenderProps { + /** + * Form values for the current step. + */ + values: Record; + + /** + * Field validation errors. + */ + fieldErrors: Record; + + /** + * API error (if any). + */ + error?: Error | null; + + /** + * Touched fields. + */ + touched: Record; + + /** + * Loading state. + */ + isLoading: boolean; + + /** + * Flow components from the current step. + */ + components: any[]; + + /** + * Generated invite link (available after user provisioning). + */ + inviteLink?: string; + + /** + * Current flow ID. + */ + flowId?: string; + + /** + * Function to handle input changes. + */ + handleInputChange: (name: string, value: string) => void; + + /** + * Function to handle input blur. + */ + handleInputBlur: (name: string) => void; + + /** + * Function to handle form submission. + */ + handleSubmit: (component: any, data?: Record) => Promise; + + /** + * Whether the invite link has been generated (admin flow complete). + */ + isInviteGenerated: boolean; + + /** + * Title for the current step. + */ + title?: string; + + /** + * Subtitle for the current step. + */ + subtitle?: string; + + /** + * Whether the form is valid. + */ + isValid: boolean; + + /** + * Copy invite link to clipboard. + */ + copyInviteLink: () => Promise; + + /** + * Whether the invite link was copied. + */ + inviteLinkCopied: boolean; + + /** + * Reset the flow to invite another user. + */ + resetFlow: () => void; +} + +/** + * Props for the BaseInviteUser component. + */ +export interface BaseInviteUserProps { + /** + * Callback when the invite link is generated successfully. + */ + onInviteLinkGenerated?: (inviteLink: string, flowId: string) => void; + + /** + * Callback when an error occurs. + */ + onError?: (error: Error) => void; + + /** + * Callback when the flow state changes. + */ + onFlowChange?: (response: InviteUserFlowResponse) => void; + + /** + * Function to initialize the invite user flow. + * This should make an authenticated request to the flow/execute endpoint. + */ + onInitialize: (payload: Record) => Promise; + + /** + * Function to submit flow step data. + * This should make an authenticated request to the flow/execute endpoint. + */ + onSubmit: (payload: Record) => Promise; + + /** + * Custom CSS class name. + */ + className?: string; + + /** + * Render props function for custom UI. + * If not provided, default UI will be rendered. + */ + children?: (props: BaseInviteUserRenderProps) => ReactNode; + + /** + * Whether the SDK is initialized. + */ + isInitialized?: boolean; + + /** + * Size variant for the component. + */ + size?: 'small' | 'medium' | 'large'; + + /** + * Theme variant for the component. + */ + variant?: CardProps['variant']; + + /** + * Whether to show the title. + */ + showTitle?: boolean; + + /** + * Whether to show the subtitle. + */ + showSubtitle?: boolean; +} + +/** + * Base component for invite user flow. + * Handles the flow logic for creating a user and generating an invite link. + * + * When no children are provided, renders a default UI with: + * - Loading spinner during initialization + * - Error alerts for failures + * - Flow components (user type selection, user details form) + * - Invite link display with copy functionality + * + * Flow steps handled: + * 1. User type selection (if multiple types available) + * 2. User details input (username, email) + * 3. Invite link generation + */ +const BaseInviteUser: FC = ({ + onInitialize, + onSubmit, + onError, + onFlowChange, + onInviteLinkGenerated, + className = '', + children, + isInitialized = true, + size = 'medium', + variant = 'outlined', + showTitle = true, + showSubtitle = true, +}) => { + const { t } = useTranslation(); + const { theme } = useTheme(); + const styles = useStyles(theme, theme.vars.colors.text.primary); + const [isLoading, setIsLoading] = useState(false); + const [isFlowInitialized, setIsFlowInitialized] = useState(false); + const [currentFlow, setCurrentFlow] = useState(null); + const [apiError, setApiError] = useState(null); + const [formValues, setFormValues] = useState>({}); + const [formErrors, setFormErrors] = useState>({}); + const [touchedFields, setTouchedFields] = useState>({}); + const [inviteLink, setInviteLink] = useState(); + const [inviteLinkCopied, setInviteLinkCopied] = useState(false); + const [isFormValid, setIsFormValid] = useState(true); + + const initializationAttemptedRef = useRef(false); + + /** + * Handle error responses and extract meaningful error messages. + * Uses the transformer's extractErrorMessage function for consistency. + */ + const handleError = useCallback( + (error: any) => { + // Extract error message from response failureReason or use extractErrorMessage + const errorMessage: string = error?.failureReason || extractErrorMessage(error, t, 'components.inviteUser.errors.generic'); + + // Set the API error state + setApiError(error instanceof Error ? error : new Error(errorMessage)); + + // Call the onError callback if provided + onError?.(error instanceof Error ? error : new Error(errorMessage)); + }, + [t, onError], + ); + + /** + * Normalize flow response to ensure component-driven format. + * Transforms data.meta.components to data.components. + */ + const normalizeFlowResponseLocal = useCallback( + (response: InviteUserFlowResponse): InviteUserFlowResponse => { + if (!response?.data?.meta?.components) { + return response; + } + + try { + const { components } = normalizeFlowResponse(response, t, { + defaultErrorKey: 'components.inviteUser.errors.generic', + resolveTranslations: !children, + }); + + return { + ...response, + data: { + ...response.data, + components: components as any, + }, + }; + } catch { + // If transformer throws (e.g., error response), return as-is + return response; + } + }, + [t, children], + ); + + /** + * Handle input value changes. + */ + const handleInputChange = useCallback((name: string, value: string) => { + setFormValues(prev => ({ ...prev, [name]: value })); + // Clear error when user starts typing + setFormErrors(prev => { + const newErrors = { ...prev }; + delete newErrors[name]; + return newErrors; + }); + }, []); + + /** + * Handle input blur. + */ + const handleInputBlur = useCallback((name: string) => { + setTouchedFields(prev => ({ ...prev, [name]: true })); + }, []); + + /** + * Validate required fields based on components. + */ + const validateForm = useCallback( + (components: any[]): { isValid: boolean; errors: Record } => { + const errors: Record = {}; + + const validateComponents = (comps: any[]) => { + comps.forEach(comp => { + if ( + (comp.type === 'TEXT_INPUT' || comp.type === 'EMAIL_INPUT' || comp.type === 'SELECT') && + comp.required && + comp.ref + ) { + const value = formValues[comp.ref]; + if (!value || value.trim() === '') { + errors[comp.ref] = `${comp.label || comp.ref} is required`; + } + // Email validation + if (comp.type === 'EMAIL_INPUT' && value && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) { + errors[comp.ref] = 'Please enter a valid email address'; + } + } + if (comp.components && Array.isArray(comp.components)) { + validateComponents(comp.components); + } + }); + }; + + validateComponents(components); + + return { isValid: Object.keys(errors).length === 0, errors }; + }, + [formValues], + ); + + /** + * Handle form submission. + */ + const handleSubmit = useCallback( + async (component: any, data?: Record) => { + if (!currentFlow) { + return; + } + + // Validate form before submission + const components = currentFlow.data?.components || []; + const validation = validateForm(components); + + if (!validation.isValid) { + setFormErrors(validation.errors); + setIsFormValid(false); + // Mark all fields as touched + const touched: Record = {}; + Object.keys(validation.errors).forEach(key => { + touched[key] = true; + }); + setTouchedFields(prev => ({ ...prev, ...touched })); + return; + } + + setIsLoading(true); + setApiError(null); + setIsFormValid(true); + + try { + // Build payload with form values + const inputs = data || formValues; + + const payload: Record = { + flowId: currentFlow.flowId, + inputs, + verbose: true, + }; + + // Add action ID if component has one + if (component?.id) { + payload['action'] = component.id; + } + + const rawResponse = await onSubmit(payload); + const response = normalizeFlowResponseLocal(rawResponse); + onFlowChange?.(response); + + // Check if invite link is in the response + if (response.data?.additionalData?.['inviteLink']) { + const inviteLinkValue = response.data.additionalData['inviteLink']; + setInviteLink(inviteLinkValue); + onInviteLinkGenerated?.(inviteLinkValue, response.flowId); + } + + // Check for error status + if (response.flowStatus === 'ERROR') { + handleError(response); + return; + } + + // Update current flow and reset form for next step + setCurrentFlow(response); + setFormValues({}); + setFormErrors({}); + setTouchedFields({}); + } catch (err) { + handleError(err); + } finally { + setIsLoading(false); + } + }, + [currentFlow, formValues, validateForm, onSubmit, onFlowChange, onInviteLinkGenerated, handleError, normalizeFlowResponseLocal], + ); + + /** + * Copy invite link to clipboard. + */ + const copyInviteLink = useCallback(async () => { + if (!inviteLink) return; + + try { + await navigator.clipboard.writeText(inviteLink); + setInviteLinkCopied(true); + setTimeout(() => setInviteLinkCopied(false), 3000); + } catch { + // Fallback for browsers that don't support clipboard API + const textArea = document.createElement('textarea'); + textArea.value = inviteLink; + document.body.appendChild(textArea); + textArea.select(); + document.execCommand('copy'); + document.body.removeChild(textArea); + setInviteLinkCopied(true); + setTimeout(() => setInviteLinkCopied(false), 3000); + } + }, [inviteLink]); + + /** + * Reset the flow to invite another user. + */ + const resetFlow = useCallback(() => { + setIsFlowInitialized(false); + setCurrentFlow(null); + setApiError(null); + setFormValues({}); + setFormErrors({}); + setTouchedFields({}); + setInviteLink(undefined); + setInviteLinkCopied(false); + initializationAttemptedRef.current = false; + }, []); + + /** + * Initialize the flow on component mount. + */ + useEffect(() => { + if (isInitialized && !isFlowInitialized && !initializationAttemptedRef.current) { + initializationAttemptedRef.current = true; + + (async () => { + setIsLoading(true); + setApiError(null); + + try { + const payload = { + flowType: EmbeddedFlowType.UserOnboarding, + verbose: true, + }; + + const rawResponse = await onInitialize(payload); + const response = normalizeFlowResponseLocal(rawResponse); + setCurrentFlow(response); + setIsFlowInitialized(true); + onFlowChange?.(response); + + // Check for immediate error + if (response.flowStatus === 'ERROR') { + handleError(response); + } + } catch (err) { + handleError(err); + } finally { + setIsLoading(false); + } + })(); + } + }, [isInitialized, isFlowInitialized, onInitialize, onFlowChange, handleError, normalizeFlowResponseLocal]); + + /** + * Recalculate form validity whenever form values or components change. + * This ensures the submit button is enabled/disabled correctly as the user types. + */ + useEffect(() => { + if (currentFlow && isFlowInitialized) { + const components = currentFlow.data?.components || []; + if (components.length > 0) { + const validation = validateForm(components); + setIsFormValid(validation.isValid); + } + } + }, [formValues, currentFlow, isFlowInitialized, validateForm]); + + /** + * Extract title and subtitle from components. + */ + const extractHeadings = useCallback((components: any[]): { title?: string; subtitle?: string } => { + let title: string | undefined; + let subtitle: string | undefined; + + components.forEach(comp => { + if (comp.type === 'TEXT') { + if (comp.variant === 'HEADING_1' && !title) { + title = comp.label; + } else if ((comp.variant === 'HEADING_2' || comp.variant === 'SUBTITLE_1') && !subtitle) { + subtitle = comp.label; + } + } + }); + + return { title, subtitle }; + }, []); + + /** + * Filter out heading components for default rendering. + */ + const filterHeadings = useCallback((components: any[]): any[] => { + return components.filter( + comp => !(comp.type === 'TEXT' && (comp.variant === 'HEADING_1' || comp.variant === 'HEADING_2')), + ); + }, []); + + /** + * Render form components using the factory. + */ + const renderComponents = useCallback( + (components: any[]): ReactElement[] => + renderInviteUserComponents(components, formValues, touchedFields, formErrors, isLoading, isFormValid, handleInputChange, { + onInputBlur: handleInputBlur, + onSubmit: handleSubmit, + size, + variant, + }), + [formValues, touchedFields, formErrors, isLoading, isFormValid, handleInputChange, handleInputBlur, handleSubmit, size, variant], + ); + + // Get components from normalized response, with fallback to meta.components + const components = currentFlow?.data?.components || currentFlow?.data?.meta?.components || []; + const { title, subtitle } = extractHeadings(components); + const componentsWithoutHeadings = filterHeadings(components); + const isInviteGenerated = !!inviteLink; + + // Render props + const renderProps: BaseInviteUserRenderProps = { + values: formValues, + fieldErrors: formErrors, + error: apiError, + touched: touchedFields, + isLoading, + components, + inviteLink, + flowId: currentFlow?.flowId, + handleInputChange, + handleInputBlur, + handleSubmit, + isInviteGenerated, + title, + subtitle, + isValid: isFormValid, + copyInviteLink, + inviteLinkCopied, + resetFlow, + }; + + // If children render prop is provided, use it for custom UI + if (children) { + return
{children(renderProps)}
; + } + + // Default rendering + + // Waiting for SDK initialization + if (!isInitialized) { + return ( + + +
+ +
+
+
+ ); + } + + // Loading state during initialization + if (!isFlowInitialized && isLoading) { + return ( + + +
+ +
+
+
+ ); + } + + // Error state during initialization + if (!currentFlow && apiError) { + return ( + + + + Error + {apiError.message} + + + + ); + } + + // Invite link generated - success state + if (isInviteGenerated && inviteLink) { + return ( + + + Invite Link Generated! + + + + Share this link with the user to complete their registration. + +
+ + Invite Link + +
+ + {inviteLink} + + +
+
+
+ +
+
+
+ ); + } + + // Flow components + return ( + + {(showTitle || showSubtitle) && (title || subtitle) && ( + + {showTitle && title && {title}} + {showSubtitle && subtitle && {subtitle}} + + )} + + {apiError && ( +
+ + {apiError.message} + +
+ )} +
+ {componentsWithoutHeadings && componentsWithoutHeadings.length > 0 ? ( + renderComponents(componentsWithoutHeadings) + ) : ( + !isLoading && ( + + No form components available + + ) + )} + {isLoading && ( +
+ +
+ )} +
+
+
+ ); +}; + +export default BaseInviteUser; diff --git a/packages/react/src/components/presentation/auth/InviteUser/v2/InviteUser.tsx b/packages/react/src/components/presentation/auth/InviteUser/v2/InviteUser.tsx new file mode 100644 index 00000000..f285831d --- /dev/null +++ b/packages/react/src/components/presentation/auth/InviteUser/v2/InviteUser.tsx @@ -0,0 +1,198 @@ +/** + * Copyright (c) 2025, 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, ReactNode} from 'react'; +import {EmbeddedFlowType} from '@asgardeo/browser'; +import BaseInviteUser, { + BaseInviteUserRenderProps, + InviteUserFlowResponse, +} from './BaseInviteUser'; +import useAsgardeo from '../../../../../contexts/Asgardeo/useAsgardeo'; + +/** + * Render props for InviteUser (re-exported for convenience). + */ +export type InviteUserRenderProps = BaseInviteUserRenderProps; + +/** + * Props for the InviteUser component. + */ +export interface InviteUserProps { + /** + * Callback when the invite link is generated successfully. + */ + onInviteLinkGenerated?: (inviteLink: string, flowId: string) => void; + + /** + * Callback when an error occurs. + */ + onError?: (error: Error) => void; + + /** + * Callback when the flow state changes. + */ + onFlowChange?: (response: InviteUserFlowResponse) => void; + + /** + * Custom CSS class name. + */ + className?: string; + + /** + * Render props function for custom UI. + * If not provided, default UI will be rendered by the SDK. + */ + children?: (props: InviteUserRenderProps) => ReactNode; + + /** + * Size variant for the component. + */ + size?: 'small' | 'medium' | 'large'; + + /** + * Theme variant for the component card. + */ + variant?: 'outlined' | 'elevated'; + + /** + * Whether to show the title. + */ + showTitle?: boolean; + + /** + * Whether to show the subtitle. + */ + showSubtitle?: boolean; +} + +/** + * InviteUser component for initiating invite user flow. + * + * This component is designed for admin users in the thunder-develop app to: + * 1. Select a user type (if multiple available) + * 2. Enter user details (username, email) + * 3. Generate an invite link for the end user + * + * The component uses the authenticated Asgardeo SDK context to make API calls + * with the admin's access token (requires 'system' scope). + * + * @example + * ```tsx + * import { InviteUser } from '@asgardeo/react'; + * + * const InviteUserPage = () => { + * const [inviteLink, setInviteLink] = useState(); + * + * return ( + * setInviteLink(link)} + * onError={(error) => console.error(error)} + * > + * {({ values, components, isLoading, handleInputChange, handleSubmit, inviteLink, isInviteGenerated }) => ( + *
+ * {isInviteGenerated ? ( + *
+ *

Invite Link Generated!

+ *

{inviteLink}

+ *
+ * ) : ( + * // Render form based on components + * )} + *
+ * )} + *
+ * ); + * }; + * ``` + */ +const InviteUser: FC = ({ + onInviteLinkGenerated, + onError, + onFlowChange, + className, + children, + size = 'medium', + variant = 'outlined', + showTitle = true, + showSubtitle = true, +}) => { + const {http, baseUrl, isInitialized} = useAsgardeo(); + + /** + * Initialize the invite user flow. + * Makes an authenticated request to /flow/execute with flowType: USER_ONBOARDING. + */ + const handleInitialize = async (payload: Record): Promise => { + const response = await http.request({ + url: `${baseUrl}/flow/execute`, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + data: { + ...payload, + flowType: EmbeddedFlowType.UserOnboarding, + verbose: true, + }, + } as any); + + return response.data as InviteUserFlowResponse; + }; + + /** + * Submit flow step data. + * Makes an authenticated request to /flow/execute with the step data. + */ + const handleSubmit = async (payload: Record): Promise => { + const response = await http.request({ + url: `${baseUrl}/flow/execute`, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + data: { + ...payload, + verbose: true, + }, + } as any); + + return response.data as InviteUserFlowResponse; + }; + + return ( + + {children} + + ); +}; + +export default InviteUser; diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 4f8e6f89..594f462b 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -130,6 +130,12 @@ export * from './components/presentation/auth/SignUp/BaseSignUp'; export {default as SignUp} from './components/presentation/auth/SignUp/SignUp'; export * from './components/presentation/auth/SignUp/SignUp'; +export { BaseInviteUser, InviteUser } from './components/presentation/auth/InviteUser'; +export * from './components/presentation/auth/InviteUser'; + +export { BaseAcceptInvite, AcceptInvite } from './components/presentation/auth/AcceptInvite'; +export * from './components/presentation/auth/AcceptInvite'; + // Sign-In Options export {default as IdentifierFirst} from './components/presentation/auth/SignIn/v1/options/IdentifierFirst'; export {default as UsernamePassword} from './components/presentation/auth/SignIn/v1/options/UsernamePassword';