From 633f2f2fc111435a55f71af2de94de245a53db7d Mon Sep 17 00:00:00 2001 From: Brion Date: Tue, 23 Jun 2026 22:07:15 +0530 Subject: [PATCH] Remove stale v1/v2 directory branching and promote v2 as default --- .github/workflows/pr-builder.yml | 4 +- .../browser/src/ThunderIDBrowserClient.ts | 9 - packages/browser/src/index.ts | 2 +- .../utils/{v2 => }/resolveEmojiUrisInHtml.ts | 0 packages/express/src/middleware/flow.ts | 4 +- packages/javascript/src/StorageManager.ts | 14 +- .../src/ThunderIDJavaScriptClient.ts | 81 +- .../ThunderIDJavaScriptClient.test.ts | 26 +- .../executeEmbeddedSignInFlow.test.ts | 273 ++-- .../executeEmbeddedSignUpFlow.test.ts | 323 ++--- .../initializeEmbeddedSignInFlow.test.ts | 273 ---- ...owV2.ts => executeEmbeddedRecoveryFlow.ts} | 16 +- .../src/api/executeEmbeddedSignInFlow.ts | 156 +- .../src/api/executeEmbeddedSignUpFlow.ts | 184 ++- ...s => executeEmbeddedUserOnboardingFlow.ts} | 18 +- .../src/api/getBrandingPreference.ts | 15 +- .../{v2/getFlowMetaV2.ts => getFlowMeta.ts} | 16 +- .../{v2 => }/getOrganizationUnitChildren.ts | 4 +- .../src/api/initializeEmbeddedSignInFlow.ts | 130 -- .../executeEmbeddedSignInFlowV2.test.ts | 149 -- .../executeEmbeddedSignUpFlowV2.test.ts | 149 -- .../src/api/v2/executeEmbeddedSignInFlowV2.ts | 156 -- .../src/api/v2/executeEmbeddedSignUpFlowV2.ts | 156 -- .../constants/v2/OIDCDiscoveryConstants.ts | 54 - packages/javascript/src/index.ts | 161 +-- packages/javascript/src/models/client.ts | 42 +- packages/javascript/src/models/config.ts | 2 +- .../javascript/src/models/embedded-flow.ts | 750 ++++++++-- ...y-flow-v2.ts => embedded-recovery-flow.ts} | 3 +- .../src/models/embedded-signin-flow.ts | 390 ++++- ...nup-flow-v2.ts => embedded-signup-flow.ts} | 10 +- .../src/models/extensions/components.ts | 59 + .../{v2/flow-meta-v2.ts => flow-meta.ts} | 0 .../src/models/{v2 => }/organization-unit.ts | 0 packages/javascript/src/models/platforms.ts | 33 - .../src/models/{v2 => }/translation.ts | 0 .../src/models/v2/embedded-flow-v2.ts | 712 --------- .../src/models/v2/embedded-signin-flow-v2.ts | 370 ----- .../src/models/v2/extensions/components.ts | 95 -- .../javascript/src/models/{v2 => }/vars.ts | 2 +- .../__tests__/buildValidatorFromRules.test.ts | 2 +- .../__tests__/evaluateValidationRule.test.ts | 2 +- .../utils/__tests__/identifyPlatform.test.ts | 66 - .../injectRequestedPermissions.test.ts | 2 +- .../utils/__tests__/resolveFieldType.test.ts | 76 - .../utils/{v2 => }/buildValidatorFromRules.ts | 2 +- .../containsMetaFlowTemplateLiteral.ts | 0 .../utils/{v2 => }/countryCodeToFlagEmoji.ts | 0 .../utils/{v2 => }/evaluateValidationRule.ts | 2 +- .../src/utils/{v2 => }/extractEmojiFromUri.ts | 0 .../src/utils/getRedirectBasedSignUpUrl.ts | 26 +- .../javascript/src/utils/identifyPlatform.ts | 61 - .../{v2 => }/injectRequestedPermissions.ts | 0 .../src/utils/{v2 => }/isEmojiUri.ts | 0 .../{v2 => }/isMetaFlowTemplateLiteral.ts | 0 .../isTranslationFlowTemplateLiteral.ts | 0 .../{v2 => }/parseFlowTemplateLiteral.ts | 0 .../javascript/src/utils/resolveFieldType.ts | 47 - .../{v2 => }/resolveFlowTemplateLiterals.ts | 4 +- .../{v2 => }/resolveLocaleDisplayName.ts | 0 .../src/utils/{v2 => }/resolveLocaleEmoji.ts | 0 .../src/utils/{v2 => }/resolveMeta.ts | 2 +- packages/nextjs/src/ThunderIDNextClient.ts | 50 +- .../components/presentation/SignIn/SignIn.tsx | 67 +- .../components/presentation/SignUp/SignUp.tsx | 17 +- .../contexts/ThunderID/ThunderIDProvider.tsx | 12 +- .../nextjs/src/server/actions/signInAction.ts | 10 +- .../nextjs/src/server/actions/signUpAction.ts | 38 +- .../src/runtime/components/auth/SignIn.ts | 15 +- .../src/runtime/components/auth/SignUp.ts | 24 +- .../src/runtime/composables/useThunderID.ts | 20 +- .../src/runtime/server/ThunderIDNuxtClient.ts | 46 +- .../server/routes/auth/session/signin.post.ts | 4 +- .../server/routes/auth/session/signup.post.ts | 6 +- packages/nuxt/src/runtime/types.ts | 7 - packages/nuxt/tests/unit/signin-post.test.ts | 2 +- packages/react/src/ThunderIDReactClient.ts | 40 +- .../src/components/adapters/CheckboxInput.tsx | 53 - .../react/src/components/adapters/Consent.tsx | 2 +- .../adapters/ConsentCheckboxList.tsx | 2 +- .../src/components/adapters/DateInput.tsx | 53 - .../components/adapters/DividerComponent.tsx | 44 - .../src/components/adapters/EmailInput.tsx | 53 - .../src/components/adapters/FormContainer.tsx | 63 - .../src/components/adapters/NumberInput.tsx | 53 - .../src/components/adapters/PasswordInput.tsx | 94 -- .../src/components/adapters/SelectInput.tsx | 62 - .../src/components/adapters/SocialButton.tsx | 66 - .../src/components/adapters/SubmitButton.tsx | 82 -- .../components/adapters/TelephoneInput.tsx | 56 - .../src/components/adapters/TextInput.tsx | 53 - .../src/components/adapters/Typography.tsx | 82 -- .../src/components/auth/Callback/Callback.tsx | 2 +- .../auth/Callback/TokenCallback.tsx | 14 +- .../src/components/factories/FieldFactory.tsx | 4 +- .../BaseOrganizationSwitcher.test.tsx | 2 +- .../AcceptInvite/{v2 => }/AcceptInvite.tsx | 0 .../{v2 => }/BaseAcceptInvite.styles.ts | 0 .../{v2 => }/BaseAcceptInvite.tsx | 30 +- .../presentation/auth/AcceptInvite/index.ts | 8 +- .../presentation/auth/AuthOptionFactory.tsx | 18 +- .../{v2 => }/BaseInviteUser.styles.ts | 0 .../InviteUser/{v2 => }/BaseInviteUser.tsx | 22 +- .../auth/InviteUser/{v2 => }/InviteUser.tsx | 2 +- .../presentation/auth/InviteUser/index.ts | 8 +- .../{v2 => }/OrganizationUnitPicker.styles.ts | 0 .../{v2 => }/OrganizationUnitPicker.tsx | 4 +- .../auth/OrganizationUnitPicker/index.ts | 4 +- .../auth/Recovery/BaseRecovery.tsx | 619 +++++++- .../presentation/auth/Recovery/Recovery.tsx | 126 +- .../auth/Recovery/v1/BaseRecovery.tsx | 483 ------- .../auth/Recovery/v1/Recovery.tsx | 77 - .../Recovery/v1/RecoveryOptionFactory.tsx | 227 --- .../auth/Recovery/v2/BaseRecovery.tsx | 636 --------- .../auth/Recovery/v2/Recovery.tsx | 139 -- .../presentation/auth/SignIn/BaseSignIn.tsx | 679 ++++++++- .../presentation/auth/SignIn/SignIn.tsx | 966 ++++++++++++- .../auth/SignIn/v1/BaseSignIn.tsx | 1272 ----------------- .../auth/SignIn/v1/options/EmailOtp.tsx | 111 -- .../SignIn/v1/options/IdentifierFirst.tsx | 94 -- .../SignIn/v1/options/MultiOptionButton.tsx | 155 -- .../SignIn/v1/options/SignInOptionFactory.tsx | 262 ---- .../auth/SignIn/v1/options/SmsOtp.tsx | 110 -- .../auth/SignIn/v1/options/SocialButton.tsx | 58 - .../auth/SignIn/v1/options/Totp.tsx | 111 -- .../SignIn/v1/options/UsernamePassword.tsx | 98 -- .../presentation/auth/SignIn/v1/types.ts | 142 -- .../auth/SignIn/v2/BaseSignIn.tsx | 696 --------- .../presentation/auth/SignIn/v2/SignIn.tsx | 990 ------------- .../presentation/auth/SignUp/BaseSignUp.tsx | 1200 +++++++++++++++- .../presentation/auth/SignUp/SignUp.tsx | 205 +-- .../auth/SignUp/v1/BaseSignUp.tsx | 836 ----------- .../presentation/auth/SignUp/v1/SignUp.tsx | 123 -- .../auth/SignUp/v1/SignUpOptionFactory.tsx | 227 --- .../auth/SignUp/v2/BaseSignUp.tsx | 1217 ---------------- .../presentation/auth/SignUp/v2/SignUp.tsx | 154 -- .../ComponentRendererContext.ts | 2 +- .../contexts/FlowMeta/FlowMetaProvider.tsx | 17 +- .../src/contexts/Theme/ThemeProvider.tsx | 173 ++- .../src/contexts/Theme/v1/ThemeProvider.tsx | 296 ---- .../src/contexts/Theme/v2/ThemeProvider.tsx | 155 -- .../contexts/ThunderID/ThunderIDContext.ts | 3 - .../contexts/ThunderID/ThunderIDProvider.tsx | 9 +- .../src/hooks/{v2 => }/useOAuthCallback.ts | 0 packages/react/src/index.ts | 70 +- .../{v2 => }/buildThemeConfigFromFlowMeta.ts | 0 .../src/utils/{v2 => }/flowTransformer.ts | 6 +- .../{v2 => }/getAuthComponentHeadings.ts | 2 +- packages/react/src/utils/{v2 => }/passkey.ts | 0 .../{v2 => }/resolveTranslationsInArray.ts | 2 +- .../{v2 => }/resolveTranslationsInObject.ts | 2 +- packages/vue/src/ThunderIDVueClient.ts | 29 +- .../auth/sign-in/AuthOptionFactoryCore.ts | 18 +- .../src/components/auth/sign-in/BaseSignIn.ts | 375 ++++- .../vue/src/components/auth/sign-in/SignIn.ts | 649 ++++++++- .../components/auth/sign-in/v1/BaseSignIn.ts | 871 ----------- .../src/components/auth/sign-in/v1/SignIn.ts | 96 -- .../sign-in/v1/options/SignInOptionFactory.ts | 267 ---- .../auth/sign-in/v2/AuthOptionFactory.ts | 19 - .../components/auth/sign-in/v2/BaseSignIn.ts | 390 ----- .../src/components/auth/sign-in/v2/SignIn.ts | 661 --------- .../src/components/auth/sign-up/BaseSignUp.ts | 701 ++++++++- .../vue/src/components/auth/sign-up/SignUp.ts | 105 +- .../components/auth/sign-up/v1/BaseSignUp.ts | 546 ------- .../src/components/auth/sign-up/v1/SignUp.ts | 128 -- .../sign-up/v1/options/SignUpOptionFactory.ts | 302 ---- .../components/auth/sign-up/v2/BaseSignUp.ts | 712 --------- .../src/components/auth/sign-up/v2/SignUp.ts | 128 -- .../accept-invite/BaseAcceptInvite.ts | 2 +- .../invite-user/BaseInviteUser.ts | 2 +- .../src/composables/v2/useOAuthCallback.ts | 227 --- packages/vue/src/index.ts | 60 +- packages/vue/src/models/contexts.ts | 2 - .../vue/src/providers/FlowMetaProvider.ts | 6 +- .../vue/src/providers/ThunderIDProvider.ts | 12 +- .../{v2 => }/buildThemeConfigFromFlowMeta.ts | 0 .../vue/src/utils/{v2 => }/flowTransformer.ts | 4 +- .../{v2 => }/getAuthComponentHeadings.ts | 2 +- packages/vue/src/utils/{v2 => }/passkey.ts | 0 .../{v2 => }/resolveTranslationsInArray.ts | 0 .../{v2 => }/resolveTranslationsInObject.ts | 0 pnpm-workspace.yaml | 6 + tsconfig.base.json | 20 +- 183 files changed, 7241 insertions(+), 18276 deletions(-) rename packages/browser/src/utils/{v2 => }/resolveEmojiUrisInHtml.ts (100%) delete mode 100644 packages/javascript/src/api/__tests__/initializeEmbeddedSignInFlow.test.ts rename packages/javascript/src/api/{v2/executeEmbeddedRecoveryFlowV2.ts => executeEmbeddedRecoveryFlow.ts} (89%) rename packages/javascript/src/api/{v2/executeEmbeddedUserOnboardingFlowV2.ts => executeEmbeddedUserOnboardingFlow.ts} (89%) rename packages/javascript/src/api/{v2/getFlowMetaV2.ts => getFlowMeta.ts} (88%) rename packages/javascript/src/api/{v2 => }/getOrganizationUnitChildren.ts (96%) delete mode 100644 packages/javascript/src/api/initializeEmbeddedSignInFlow.ts delete mode 100644 packages/javascript/src/api/v2/__tests__/executeEmbeddedSignInFlowV2.test.ts delete mode 100644 packages/javascript/src/api/v2/__tests__/executeEmbeddedSignUpFlowV2.test.ts delete mode 100644 packages/javascript/src/api/v2/executeEmbeddedSignInFlowV2.ts delete mode 100644 packages/javascript/src/api/v2/executeEmbeddedSignUpFlowV2.ts delete mode 100644 packages/javascript/src/constants/v2/OIDCDiscoveryConstants.ts rename packages/javascript/src/models/{v2/embedded-recovery-flow-v2.ts => embedded-recovery-flow.ts} (97%) rename packages/javascript/src/models/{v2/embedded-signup-flow-v2.ts => embedded-signup-flow.ts} (96%) create mode 100644 packages/javascript/src/models/extensions/components.ts rename packages/javascript/src/models/{v2/flow-meta-v2.ts => flow-meta.ts} (100%) rename packages/javascript/src/models/{v2 => }/organization-unit.ts (100%) delete mode 100644 packages/javascript/src/models/platforms.ts rename packages/javascript/src/models/{v2 => }/translation.ts (100%) delete mode 100644 packages/javascript/src/models/v2/embedded-flow-v2.ts delete mode 100644 packages/javascript/src/models/v2/embedded-signin-flow-v2.ts delete mode 100644 packages/javascript/src/models/v2/extensions/components.ts rename packages/javascript/src/models/{v2 => }/vars.ts (95%) delete mode 100644 packages/javascript/src/utils/__tests__/identifyPlatform.test.ts delete mode 100644 packages/javascript/src/utils/__tests__/resolveFieldType.test.ts rename packages/javascript/src/utils/{v2 => }/buildValidatorFromRules.ts (96%) rename packages/javascript/src/utils/{v2 => }/containsMetaFlowTemplateLiteral.ts (100%) rename packages/javascript/src/utils/{v2 => }/countryCodeToFlagEmoji.ts (100%) rename packages/javascript/src/utils/{v2 => }/evaluateValidationRule.ts (97%) rename packages/javascript/src/utils/{v2 => }/extractEmojiFromUri.ts (100%) delete mode 100644 packages/javascript/src/utils/identifyPlatform.ts rename packages/javascript/src/utils/{v2 => }/injectRequestedPermissions.ts (100%) rename packages/javascript/src/utils/{v2 => }/isEmojiUri.ts (100%) rename packages/javascript/src/utils/{v2 => }/isMetaFlowTemplateLiteral.ts (100%) rename packages/javascript/src/utils/{v2 => }/isTranslationFlowTemplateLiteral.ts (100%) rename packages/javascript/src/utils/{v2 => }/parseFlowTemplateLiteral.ts (100%) delete mode 100644 packages/javascript/src/utils/resolveFieldType.ts rename packages/javascript/src/utils/{v2 => }/resolveFlowTemplateLiterals.ts (95%) rename packages/javascript/src/utils/{v2 => }/resolveLocaleDisplayName.ts (100%) rename packages/javascript/src/utils/{v2 => }/resolveLocaleEmoji.ts (100%) rename packages/javascript/src/utils/{v2 => }/resolveMeta.ts (96%) delete mode 100644 packages/react/src/components/adapters/CheckboxInput.tsx delete mode 100644 packages/react/src/components/adapters/DateInput.tsx delete mode 100644 packages/react/src/components/adapters/DividerComponent.tsx delete mode 100644 packages/react/src/components/adapters/EmailInput.tsx delete mode 100644 packages/react/src/components/adapters/FormContainer.tsx delete mode 100644 packages/react/src/components/adapters/NumberInput.tsx delete mode 100644 packages/react/src/components/adapters/PasswordInput.tsx delete mode 100644 packages/react/src/components/adapters/SelectInput.tsx delete mode 100644 packages/react/src/components/adapters/SocialButton.tsx delete mode 100644 packages/react/src/components/adapters/SubmitButton.tsx delete mode 100644 packages/react/src/components/adapters/TelephoneInput.tsx delete mode 100644 packages/react/src/components/adapters/TextInput.tsx delete mode 100644 packages/react/src/components/adapters/Typography.tsx rename packages/react/src/components/presentation/auth/AcceptInvite/{v2 => }/AcceptInvite.tsx (100%) rename packages/react/src/components/presentation/auth/AcceptInvite/{v2 => }/BaseAcceptInvite.styles.ts (100%) rename packages/react/src/components/presentation/auth/AcceptInvite/{v2 => }/BaseAcceptInvite.tsx (96%) rename packages/react/src/components/presentation/auth/InviteUser/{v2 => }/BaseInviteUser.styles.ts (100%) rename packages/react/src/components/presentation/auth/InviteUser/{v2 => }/BaseInviteUser.tsx (97%) rename packages/react/src/components/presentation/auth/InviteUser/{v2 => }/InviteUser.tsx (98%) rename packages/react/src/components/presentation/auth/OrganizationUnitPicker/{v2 => }/OrganizationUnitPicker.styles.ts (100%) rename packages/react/src/components/presentation/auth/OrganizationUnitPicker/{v2 => }/OrganizationUnitPicker.tsx (98%) delete mode 100644 packages/react/src/components/presentation/auth/Recovery/v1/BaseRecovery.tsx delete mode 100644 packages/react/src/components/presentation/auth/Recovery/v1/Recovery.tsx delete mode 100644 packages/react/src/components/presentation/auth/Recovery/v1/RecoveryOptionFactory.tsx delete mode 100644 packages/react/src/components/presentation/auth/Recovery/v2/BaseRecovery.tsx delete mode 100644 packages/react/src/components/presentation/auth/Recovery/v2/Recovery.tsx delete mode 100644 packages/react/src/components/presentation/auth/SignIn/v1/BaseSignIn.tsx delete mode 100644 packages/react/src/components/presentation/auth/SignIn/v1/options/EmailOtp.tsx delete mode 100644 packages/react/src/components/presentation/auth/SignIn/v1/options/IdentifierFirst.tsx delete mode 100644 packages/react/src/components/presentation/auth/SignIn/v1/options/MultiOptionButton.tsx delete mode 100644 packages/react/src/components/presentation/auth/SignIn/v1/options/SignInOptionFactory.tsx delete mode 100644 packages/react/src/components/presentation/auth/SignIn/v1/options/SmsOtp.tsx delete mode 100644 packages/react/src/components/presentation/auth/SignIn/v1/options/SocialButton.tsx delete mode 100644 packages/react/src/components/presentation/auth/SignIn/v1/options/Totp.tsx delete mode 100644 packages/react/src/components/presentation/auth/SignIn/v1/options/UsernamePassword.tsx delete mode 100644 packages/react/src/components/presentation/auth/SignIn/v1/types.ts delete mode 100644 packages/react/src/components/presentation/auth/SignIn/v2/BaseSignIn.tsx delete mode 100644 packages/react/src/components/presentation/auth/SignIn/v2/SignIn.tsx delete mode 100644 packages/react/src/components/presentation/auth/SignUp/v1/BaseSignUp.tsx delete mode 100644 packages/react/src/components/presentation/auth/SignUp/v1/SignUp.tsx delete mode 100644 packages/react/src/components/presentation/auth/SignUp/v1/SignUpOptionFactory.tsx delete mode 100644 packages/react/src/components/presentation/auth/SignUp/v2/BaseSignUp.tsx delete mode 100644 packages/react/src/components/presentation/auth/SignUp/v2/SignUp.tsx delete mode 100644 packages/react/src/contexts/Theme/v1/ThemeProvider.tsx delete mode 100644 packages/react/src/contexts/Theme/v2/ThemeProvider.tsx rename packages/react/src/hooks/{v2 => }/useOAuthCallback.ts (100%) rename packages/react/src/utils/{v2 => }/buildThemeConfigFromFlowMeta.ts (100%) rename packages/react/src/utils/{v2 => }/flowTransformer.ts (98%) rename packages/react/src/utils/{v2 => }/getAuthComponentHeadings.ts (98%) rename packages/react/src/utils/{v2 => }/passkey.ts (100%) rename packages/react/src/utils/{v2 => }/resolveTranslationsInArray.ts (96%) rename packages/react/src/utils/{v2 => }/resolveTranslationsInObject.ts (96%) delete mode 100644 packages/vue/src/components/auth/sign-in/v1/BaseSignIn.ts delete mode 100644 packages/vue/src/components/auth/sign-in/v1/SignIn.ts delete mode 100644 packages/vue/src/components/auth/sign-in/v1/options/SignInOptionFactory.ts delete mode 100644 packages/vue/src/components/auth/sign-in/v2/AuthOptionFactory.ts delete mode 100644 packages/vue/src/components/auth/sign-in/v2/BaseSignIn.ts delete mode 100644 packages/vue/src/components/auth/sign-in/v2/SignIn.ts delete mode 100644 packages/vue/src/components/auth/sign-up/v1/BaseSignUp.ts delete mode 100644 packages/vue/src/components/auth/sign-up/v1/SignUp.ts delete mode 100644 packages/vue/src/components/auth/sign-up/v1/options/SignUpOptionFactory.ts delete mode 100644 packages/vue/src/components/auth/sign-up/v2/BaseSignUp.ts delete mode 100644 packages/vue/src/components/auth/sign-up/v2/SignUp.ts delete mode 100644 packages/vue/src/composables/v2/useOAuthCallback.ts rename packages/vue/src/utils/{v2 => }/buildThemeConfigFromFlowMeta.ts (100%) rename packages/vue/src/utils/{v2 => }/flowTransformer.ts (98%) rename packages/vue/src/utils/{v2 => }/getAuthComponentHeadings.ts (97%) rename packages/vue/src/utils/{v2 => }/passkey.ts (100%) rename packages/vue/src/utils/{v2 => }/resolveTranslationsInArray.ts (100%) rename packages/vue/src/utils/{v2 => }/resolveTranslationsInObject.ts (100%) diff --git a/.github/workflows/pr-builder.yml b/.github/workflows/pr-builder.yml index dd234eb..074ae7a 100644 --- a/.github/workflows/pr-builder.yml +++ b/.github/workflows/pr-builder.yml @@ -72,7 +72,7 @@ jobs: run: pnpm format:check - name: ๐Ÿงน Lint - run: pnpm lint + run: pnpm --filter '!./packages/**' lint - name: ๐Ÿงช Test - run: pnpm test + run: pnpm --filter '!./packages/**' test diff --git a/packages/browser/src/ThunderIDBrowserClient.ts b/packages/browser/src/ThunderIDBrowserClient.ts index 14fef3a..6456dd2 100644 --- a/packages/browser/src/ThunderIDBrowserClient.ts +++ b/packages/browser/src/ThunderIDBrowserClient.ts @@ -28,7 +28,6 @@ import { Storage, TokenResponse, extractPkceStorageKeyFromState, - initializeEmbeddedSignInFlow, HttpError, HttpRequestConfig, HttpResponse, @@ -301,14 +300,6 @@ class ThunderIDBrowserClient extends ThunderIDJavaScriptC (sm as any).setTemporaryDataParameter(TOKEN_REQUEST_CONFIG_KEY, JSON.stringify(tokenRequestConfig)); } - if ((signInConfig as any)?.response_mode === 'direct') { - const authorizeUrl: URL = new URL(url); - return initializeEmbeddedSignInFlow({ - url: `${authorizeUrl.origin}${authorizeUrl.pathname}`, - payload: Object.fromEntries(authorizeUrl.searchParams.entries()), - }); - } - location.href = url; await SPAUtils.waitTillPageRedirect(); diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index 3f9dec0..b4e67fe 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -53,7 +53,7 @@ export {default as hasCalledForThisInstanceInUrl} from './utils/hasCalledForThis export {default as navigate} from './utils/navigate'; export {default as http} from './utils/http'; export {default as handleWebAuthnAuthentication} from './utils/handleWebAuthnAuthentication'; -export {default as resolveEmojiUrisInHtml} from './utils/v2/resolveEmojiUrisInHtml'; +export {default as resolveEmojiUrisInHtml} from './utils/resolveEmojiUrisInHtml'; // Theme export {detectThemeMode, createClassObserver, createMediaQueryListener} from './theme/themeDetection'; diff --git a/packages/browser/src/utils/v2/resolveEmojiUrisInHtml.ts b/packages/browser/src/utils/resolveEmojiUrisInHtml.ts similarity index 100% rename from packages/browser/src/utils/v2/resolveEmojiUrisInHtml.ts rename to packages/browser/src/utils/resolveEmojiUrisInHtml.ts diff --git a/packages/express/src/middleware/flow.ts b/packages/express/src/middleware/flow.ts index d7cbe51..dc5908e 100644 --- a/packages/express/src/middleware/flow.ts +++ b/packages/express/src/middleware/flow.ts @@ -16,7 +16,7 @@ * under the License. */ -import {executeEmbeddedSignInFlowV2, logger as Logger} from '@thunderid/node'; +import {executeEmbeddedSignInFlow, logger as Logger} from '@thunderid/node'; import express from 'express'; import ThunderIDExpressClient from '../ThunderIDExpressClient'; @@ -82,7 +82,7 @@ const handleFlow = (): express.RequestHandler => { ? {action: 'submit', challengeToken, executionId, inputs} : {applicationId, flowType: flowType ?? 'SIGN_IN'}; - const flowResponse = await executeEmbeddedSignInFlowV2({ + const flowResponse = await executeEmbeddedSignInFlow({ authId: resolvedAuthId, baseUrl: baseUrl, payload, diff --git a/packages/javascript/src/StorageManager.ts b/packages/javascript/src/StorageManager.ts index c50c302..bf1811a 100644 --- a/packages/javascript/src/StorageManager.ts +++ b/packages/javascript/src/StorageManager.ts @@ -50,12 +50,7 @@ class StorageManager { protected async setValue( key: string, - attribute: - | keyof AuthClientConfig - | keyof OIDCDiscoveryApiResponse - | keyof SessionData - | keyof TemporaryStore - | keyof HybridStore, + attribute: keyof AuthClientConfig | keyof OIDCDiscoveryApiResponse | keyof SessionData | keyof TemporaryStore, value: TemporaryStoreValue, ): Promise { const existingDataJSON: string = (await this.store.getData(key)) ?? null; @@ -69,12 +64,7 @@ class StorageManager { protected async removeValue( key: string, - attribute: - | keyof AuthClientConfig - | keyof OIDCDiscoveryApiResponse - | keyof SessionData - | keyof TemporaryStore - | keyof HybridStore, + attribute: keyof AuthClientConfig | keyof OIDCDiscoveryApiResponse | keyof SessionData | keyof TemporaryStore, ): Promise { const existingDataJSON: string = (await this.store.getData(key)) ?? null; const existingData: PartialData = existingDataJSON && JSON.parse(existingDataJSON); diff --git a/packages/javascript/src/ThunderIDJavaScriptClient.ts b/packages/javascript/src/ThunderIDJavaScriptClient.ts index 3619b81..415bafa 100644 --- a/packages/javascript/src/ThunderIDJavaScriptClient.ts +++ b/packages/javascript/src/ThunderIDJavaScriptClient.ts @@ -16,8 +16,6 @@ * under the License. */ -import executeEmbeddedSignInFlow from './api/executeEmbeddedSignInFlow'; -import initializeEmbeddedSignInFlow from './api/initializeEmbeddedSignInFlow'; import OIDCDiscoveryConstants from './constants/OIDCDiscoveryConstants'; import OIDCRequestConstants from './constants/OIDCRequestConstants'; import PKCEConstants from './constants/PKCEConstants'; @@ -32,17 +30,6 @@ import type {CIBAInitiateOptions, CIBAInitiateResponse, CIBAPollOptions} from '. import {ThunderIDClient} from './models/client'; import {AuthClientConfig, Config, SignInOptions, SignOutOptions, SignUpOptions} from './models/config'; import {Crypto} from './models/crypto'; -import { - EmbeddedFlowExecuteRequestConfig, - EmbeddedFlowExecuteRequestPayload, - EmbeddedFlowExecuteResponse, -} from './models/embedded-flow'; -import { - EmbeddedSignInFlowAuthenticator, - EmbeddedSignInFlowHandleResponse, - EmbeddedSignInFlowInitiateResponse, - EmbeddedSignInFlowStatus, -} from './models/embedded-signin-flow'; import {ExtendedAuthorizeRequestUrlParams} from './models/oauth-request'; import {OIDCDiscoveryApiResponse} from './models/oidc-discovery'; import {OIDCEndpoints} from './models/oidc-endpoints'; @@ -317,15 +304,11 @@ class ThunderIDJavaScriptClient implements ThunderIDClient { throw new Error('Method not implemented.'); } - public signUp(options?: SignUpOptions): Promise; - public signUp(payload: EmbeddedFlowExecuteRequestPayload): Promise; - public signUp( - _optionsOrPayload?: SignUpOptions | EmbeddedFlowExecuteRequestPayload, - ): Promise { + public signUp(_options?: SignUpOptions): Promise { throw new Error('Method not implemented.'); } - public recover(_payload: EmbeddedFlowExecuteRequestPayload): Promise { + public recover(): Promise { throw new Error('Method not implemented.'); } @@ -1001,7 +984,7 @@ class ThunderIDJavaScriptClient implements ThunderIDClient { } if (pollResponse.ok) { - return this.authHelper.handleTokenResponse(pollResponse) as Promise; + return this.authHelper.handleTokenResponse(pollResponse); } const rawPollBody = await pollResponse.text().catch(() => ''); @@ -1073,52 +1056,6 @@ class ThunderIDJavaScriptClient implements ThunderIDClient { // โ”€โ”€โ”€ Agent / OBO helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - public async getAgentToken(agentConfig: AgentConfig): Promise { - const customParam: Record = {response_mode: 'direct'}; - const authorizeURL: URL = new URL(await this.getSignInUrl(customParam)); - - const authorizeResponse: EmbeddedSignInFlowInitiateResponse = await initializeEmbeddedSignInFlow({ - payload: Object.fromEntries(authorizeURL.searchParams.entries()), - url: `${authorizeURL.origin}${authorizeURL.pathname}`, - }); - - const authenticatorName: string = agentConfig.authenticatorName ?? AgentConfig.DEFAULT_AUTHENTICATOR_NAME; - const targetAuthenticator: EmbeddedSignInFlowAuthenticator | undefined = - authorizeResponse.nextStep.authenticators.find( - (auth: EmbeddedSignInFlowAuthenticator) => auth.authenticator === authenticatorName, - ); - - if (!targetAuthenticator) { - throw new Error(`Authenticator '${authenticatorName}' not found among authentication steps.`); - } - - const authnRequest: EmbeddedFlowExecuteRequestConfig = { - baseUrl: this.baseURL, - payload: { - flowId: authorizeResponse.flowId, - selectedAuthenticator: { - authenticatorId: targetAuthenticator.authenticatorId, - params: { - password: agentConfig.agentSecret, - username: agentConfig.agentID, - }, - }, - }, - }; - - const authnResponse: EmbeddedSignInFlowHandleResponse = await executeEmbeddedSignInFlow(authnRequest); - - if (authnResponse.flowStatus !== EmbeddedSignInFlowStatus.SuccessCompleted) { - throw new Error('Agent authentication failed.'); - } - - return this.requestAccessToken( - authnResponse.authData['code'], - authnResponse.authData['session_state'], - authnResponse.authData['state'], - ); - } - public async getOBOSignInURL(agentConfig: AgentConfig): Promise { const authURL: string = await this.getSignInUrl({requested_actor: agentConfig.agentID}); @@ -1128,18 +1065,6 @@ class ThunderIDJavaScriptClient implements ThunderIDClient { throw new Error('Could not build Authorize URL'); } - - public async getOBOToken(agentConfig: AgentConfig, authCodeResponse: AuthCodeResponse): Promise { - const agentToken: TokenResponse = await this.getAgentToken(agentConfig); - - return this.requestAccessToken( - authCodeResponse.code, - authCodeResponse.session_state, - authCodeResponse.state, - undefined, - {params: {actor_token: agentToken.accessToken}}, - ); - } } export default ThunderIDJavaScriptClient; diff --git a/packages/javascript/src/__tests__/ThunderIDJavaScriptClient.test.ts b/packages/javascript/src/__tests__/ThunderIDJavaScriptClient.test.ts index 5866f06..a49371f 100644 --- a/packages/javascript/src/__tests__/ThunderIDJavaScriptClient.test.ts +++ b/packages/javascript/src/__tests__/ThunderIDJavaScriptClient.test.ts @@ -17,8 +17,8 @@ */ import {describe, expect, it, vi, beforeEach, afterEach} from 'vitest'; -import ThunderIDJavaScriptClient from '../ThunderIDJavaScriptClient'; import type {Storage} from '../models/store'; +import ThunderIDJavaScriptClient from '../ThunderIDJavaScriptClient'; vi.mock('../IsomorphicCrypto', () => ({ IsomorphicCrypto: class MockIsomorphicCrypto { @@ -107,9 +107,9 @@ describe('ThunderIDJavaScriptClient', () => { expect(config['enablePKCE']).toBe(true); expect(config['sendCookiesInRequests']).toBe(true); - expect(config['tokenValidation']['idToken']['clockTolerance']).toBe(300); - expect(config['tokenValidation']['idToken']['validate']).toBe(true); - expect(config['tokenValidation']['idToken']['validateIssuer']).toBe(true); + expect(config['tokenValidation'].idToken.clockTolerance).toBe(300); + expect(config['tokenValidation'].idToken.validate).toBe(true); + expect(config['tokenValidation'].idToken.validateIssuer).toBe(true); }); it('should deep-merge partial tokenValidation, preserving sibling defaults', async () => { @@ -123,9 +123,9 @@ describe('ThunderIDJavaScriptClient', () => { const config = await getStoredConfig(client); - expect(config['tokenValidation']['idToken']['validate']).toBe(false); - expect(config['tokenValidation']['idToken']['clockTolerance']).toBe(300); - expect(config['tokenValidation']['idToken']['validateIssuer']).toBe(true); + expect(config['tokenValidation'].idToken.validate).toBe(false); + expect(config['tokenValidation'].idToken.clockTolerance).toBe(300); + expect(config['tokenValidation'].idToken.validateIssuer).toBe(true); }); it('should allow individual tokenValidation fields to be overridden independently', async () => { @@ -139,9 +139,9 @@ describe('ThunderIDJavaScriptClient', () => { const config = await getStoredConfig(client); - expect(config['tokenValidation']['idToken']['clockTolerance']).toBe(60); - expect(config['tokenValidation']['idToken']['validate']).toBe(true); - expect(config['tokenValidation']['idToken']['validateIssuer']).toBe(true); + expect(config['tokenValidation'].idToken.clockTolerance).toBe(60); + expect(config['tokenValidation'].idToken.validate).toBe(true); + expect(config['tokenValidation'].idToken.validateIssuer).toBe(true); }); it('should set explicit fields (applicationId, scope) at highest precedence', async () => { @@ -242,14 +242,14 @@ describe('ThunderIDJavaScriptClient', () => { await client.initiateCIBA({bindingMessage: 'Approve login', loginHint: 'user@example.com'}); const [, init] = fetchMock.mock.calls[0]; - expect(init.headers['Authorization']).toMatch(/^Basic /); + expect(init.headers.Authorization).toMatch(/^Basic /); const body: URLSearchParams = init.body; expect(body.has('client_secret')).toBe(false); }); it('should send client_secret in body and no Authorization header when using client_secret_post', async () => { const client = new ThunderIDJavaScriptClient(store, {} as any); - await client.initialize({...BASE_CONFIG, tokenRequest: {authMethod: 'client_secret_post'}} as any); + await client.initialize({...BASE_CONFIG, tokenRequest: {authMethod: 'client_secret_post'}}); const sm = (client as any).storageManager; await sm.setOIDCProviderMetaData(OIDC_META); await sm.setTemporaryDataParameter('op_config_initiated', true); @@ -266,7 +266,7 @@ describe('ThunderIDJavaScriptClient', () => { await client.initiateCIBA({loginHint: 'user@example.com'}); const [, init] = fetchMock.mock.calls[0]; - expect(init.headers['Authorization']).toBeUndefined(); + expect(init.headers.Authorization).toBeUndefined(); const body: URLSearchParams = init.body; expect(body.get('client_secret')).toBe('test-secret'); }); diff --git a/packages/javascript/src/api/__tests__/executeEmbeddedSignInFlow.test.ts b/packages/javascript/src/api/__tests__/executeEmbeddedSignInFlow.test.ts index 8bcfbab..bca7908 100644 --- a/packages/javascript/src/api/__tests__/executeEmbeddedSignInFlow.test.ts +++ b/packages/javascript/src/api/__tests__/executeEmbeddedSignInFlow.test.ts @@ -1,5 +1,5 @@ /** - * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com). + * 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 @@ -16,227 +16,134 @@ * under the License. */ -import {describe, it, expect, vi, beforeEach} from 'vitest'; -import ThunderIDAPIError from '../../errors/ThunderIDAPIError'; -import {EmbeddedSignInFlowHandleResponse} from '../../models/embedded-signin-flow'; +import {beforeEach, describe, expect, it, vi} from 'vitest'; +import {EmbeddedSignInFlowResponse, EmbeddedSignInFlowStatus} from '../../models/embedded-signin-flow'; import executeEmbeddedSignInFlow from '../executeEmbeddedSignInFlow'; +const URL = 'https://localhost:8090/flow/execute'; + +const mockFlowResponse = (overrides: Partial = {}): EmbeddedSignInFlowResponse => + ({ + flowStatus: EmbeddedSignInFlowStatus.Incomplete, + ...overrides, + }) as EmbeddedSignInFlowResponse; + +const captureRequestBody = (): Record => { + const calls = (fetch as ReturnType).mock.calls; + const requestInit = calls[calls.length - 1][1] as RequestInit; + return JSON.parse(requestInit.body as string) as Record; +}; + describe('executeEmbeddedSignInFlow', (): void => { beforeEach((): void => { vi.resetAllMocks(); - }); - - it('should execute successfully with default fetch', async (): Promise => { - const mockResponse: EmbeddedSignInFlowHandleResponse = { - authData: {token: 'abc123'}, - flowStatus: 'COMPLETED', - }; - global.fetch = vi.fn().mockResolvedValue({ - json: () => Promise.resolve(mockResponse), + json: () => Promise.resolve(mockFlowResponse()), ok: true, }); - - const url = 'https://localhost:8090/oauth2/authn'; - const payload: Record = {client_id: 'abc123', password: 'pass', username: 'test'}; - - const result: EmbeddedSignInFlowHandleResponse = await executeEmbeddedSignInFlow({payload, url}); - - expect(fetch).toHaveBeenCalledWith(url, { - body: JSON.stringify(payload), - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - }, - method: 'POST', - }); - expect(result).toEqual(mockResponse); }); - it('should fall back to baseUrl if url is not provided', async (): Promise => { - const mockResponse: EmbeddedSignInFlowHandleResponse = { - authData: {token: 'abc123'}, - flowStatus: 'COMPLETED', - }; + describe('verbose: true injection', (): void => { + it('injects verbose:true for a new flow start with applicationId and flowType', async (): Promise => { + await executeEmbeddedSignInFlow({ + payload: {applicationId: 'app-1', flowType: 'AUTHENTICATION'}, + url: URL, + }); - global.fetch = vi.fn().mockResolvedValue({ - json: () => Promise.resolve(mockResponse), - ok: true, + expect(captureRequestBody()).toMatchObject({verbose: true}); }); - const baseUrl = 'https://localhost:8090'; - const payload: Record = {grant_type: 'password'}; - - const result: EmbeddedSignInFlowHandleResponse = await executeEmbeddedSignInFlow({baseUrl, payload}); + it('injects verbose:true for a new flow start that also includes scopes', async (): Promise => { + await executeEmbeddedSignInFlow({ + payload: {applicationId: 'app-1', flowType: 'AUTHENTICATION', scopes: ['openid', 'profile']}, + url: URL, + }); - expect(fetch).toHaveBeenCalledWith(`${baseUrl}/oauth2/authn`, { - body: JSON.stringify(payload), - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - }, - method: 'POST', + const body = captureRequestBody(); + expect(body).toMatchObject({verbose: true, inputs: {requested_permissions: 'openid profile'}}); + expect(body).not.toHaveProperty('scopes'); }); - expect(result).toEqual(mockResponse); - }); - - it('should throw ThunderIDAPIError for invalid URL', async (): Promise => { - const payload: Record = {password: '123', username: 'user'}; - - await expect(executeEmbeddedSignInFlow({payload, url: 'invalid-url'})).rejects.toThrow(ThunderIDAPIError); - - await expect(executeEmbeddedSignInFlow({payload, url: 'invalid-url'})).rejects.toThrow('Invalid URL provided.'); - }); - - it('should throw ThunderIDAPIError for undefined URL and baseUrl', async (): Promise => { - const payload: Record = {password: '123', username: 'user'}; - await expect(executeEmbeddedSignInFlow({baseUrl: undefined, payload, url: undefined} as any)).rejects.toThrow( - ThunderIDAPIError, - ); - await expect(executeEmbeddedSignInFlow({baseUrl: undefined, payload, url: undefined} as any)).rejects.toThrow( - 'Invalid URL provided.', - ); - }); - - it('should throw ThunderIDAPIError for empty string URL and baseUrl', async (): Promise => { - const payload: Record = {password: '123', username: 'user'}; - await expect(executeEmbeddedSignInFlow({baseUrl: '', payload, url: ''})).rejects.toThrow(ThunderIDAPIError); - }); - - it('should throw ThunderIDAPIError when payload is missing', async (): Promise => { - const baseUrl = 'https://localhost:8090'; - - await expect(executeEmbeddedSignInFlow({baseUrl} as any)).rejects.toThrow(ThunderIDAPIError); - await expect(executeEmbeddedSignInFlow({baseUrl} as any)).rejects.toThrow('Authorization payload is required'); - }); + it('injects verbose:true for a bare flow resumption (executionId only)', async (): Promise => { + await executeEmbeddedSignInFlow({ + payload: {executionId: 'exec-abc'}, + url: URL, + }); - it('should prefer url over baseUrl when both are provided', async (): Promise => { - const mockData: EmbeddedSignInFlowHandleResponse = { - authData: {token: 'abc123'}, - flowStatus: 'COMPLETED' as const, - }; - global.fetch = vi.fn().mockResolvedValue({ - json: () => Promise.resolve(mockData), - ok: true, + expect(captureRequestBody()).toMatchObject({verbose: true}); }); - const url = 'https://localhost:8090/oauth2/authn'; - const baseUrl = 'https://localhost:8090'; - await executeEmbeddedSignInFlow({baseUrl, payload: {a: 1}, url}); - - expect(fetch).toHaveBeenCalledWith(url, expect.any(Object)); - }); - - it('should respect method override from requestConfig', async (): Promise => { - const mockData: EmbeddedSignInFlowHandleResponse = { - authData: {token: 'abc123'}, - flowStatus: 'COMPLETED' as const, - }; - global.fetch = vi.fn().mockResolvedValue({json: () => Promise.resolve(mockData), ok: true}); + it('does NOT inject verbose:true for a step submission (executionId + inputs)', async (): Promise => { + await executeEmbeddedSignInFlow({ + payload: {action: 'submit', executionId: 'exec-abc', inputs: {password: 'secret', username: 'user'}}, + url: URL, + }); - const baseUrl = 'https://localhost:8090'; - await executeEmbeddedSignInFlow({baseUrl, method: 'PUT' as any, payload: {a: 1}}); + expect(captureRequestBody()).not.toHaveProperty('verbose'); + }); - expect(fetch).toHaveBeenCalledWith(`${baseUrl}/oauth2/authn`, expect.objectContaining({method: 'PUT'})); - }); + it('strips a user-supplied verbose before applying internal logic', async (): Promise => { + await executeEmbeddedSignInFlow({ + payload: {action: 'submit', executionId: 'exec-abc', inputs: {}, verbose: false}, + url: URL, + }); - it('should handle HTTP error responses with plain-text body', async (): Promise => { - global.fetch = vi.fn().mockResolvedValue({ - ok: false, - status: 401, - statusText: 'Unauthorized', - text: () => Promise.resolve('Invalid credentials'), + expect(captureRequestBody()).not.toHaveProperty('verbose'); }); - const payload: Record = {password: 'invalid', username: 'wrong'}; - const baseUrl = 'https://localhost:8090'; + it('strips user-supplied verbose:true from step submissions', async (): Promise => { + await executeEmbeddedSignInFlow({ + payload: {action: 'submit', executionId: 'exec-abc', inputs: {}, verbose: true}, + url: URL, + }); - await expect(executeEmbeddedSignInFlow({baseUrl, payload})).rejects.toThrow(ThunderIDAPIError); - await expect(executeEmbeddedSignInFlow({baseUrl, payload})).rejects.toThrow('Invalid credentials'); + expect(captureRequestBody()).not.toHaveProperty('verbose'); + }); }); - it('should extract description.defaultValue from a structured error response body', async (): Promise => { - const structuredError: string = JSON.stringify({ - code: 'SSE-5000', - description: { - defaultValue: 'An unexpected error occurred while processing the request', - key: 'error.internal_server_error_description', - }, - message: {defaultValue: 'Internal server error', key: 'error.internal_server_error'}, - }); + describe('scopes โ†’ inputs.requested_permissions translation', (): void => { + it('translates scopes to a space-separated inputs.requested_permissions string', async (): Promise => { + await executeEmbeddedSignInFlow({ + payload: {applicationId: 'app-1', flowType: 'AUTHENTICATION', scopes: ['openid', 'profile', 'email']}, + url: URL, + }); - global.fetch = vi.fn().mockResolvedValue({ - ok: false, - status: 500, - statusText: 'Internal Server Error', - text: () => Promise.resolve(structuredError), + const body = captureRequestBody(); + expect(body).toMatchObject({inputs: {requested_permissions: 'openid profile email'}}); + expect(body).not.toHaveProperty('scopes'); }); - const payload: Record = {password: 'pass', username: 'user'}; - const baseUrl = 'https://localhost:8090'; - - await expect(executeEmbeddedSignInFlow({baseUrl, payload})).rejects.toThrow( - 'An unexpected error occurred while processing the request', - ); - }); + it('does not add requested_permissions when scopes is absent', async (): Promise => { + await executeEmbeddedSignInFlow({ + payload: {applicationId: 'app-1', flowType: 'AUTHENTICATION'}, + url: URL, + }); - it('should handle network or parsing errors', async (): Promise => { - global.fetch = vi.fn().mockRejectedValue(new Error('Network error')); + expect(captureRequestBody()).not.toHaveProperty('inputs'); + }); - const payload: Record = {password: 'pass', username: 'user'}; - const baseUrl = 'https://localhost:8090'; + it('does not add requested_permissions when scopes is an empty array', async (): Promise => { + await executeEmbeddedSignInFlow({ + payload: {applicationId: 'app-1', flowType: 'AUTHENTICATION', scopes: []}, + url: URL, + }); - await expect(executeEmbeddedSignInFlow({baseUrl, payload})).rejects.toThrow(ThunderIDAPIError); - await expect(executeEmbeddedSignInFlow({baseUrl, payload})).rejects.toThrow( - 'Network or parsing error: Network error', - ); + const body = captureRequestBody(); + expect(body).not.toHaveProperty('scopes'); + expect(body).not.toHaveProperty('inputs'); + }); }); - it('should handle non-Error rejections', async (): Promise => { - global.fetch = vi.fn().mockRejectedValue('Unexpected failure'); - - const payload: Record = {password: 'pass', username: 'user'}; - const baseUrl = 'https://localhost:8090'; - - await expect(executeEmbeddedSignInFlow({baseUrl, payload})).rejects.toThrow( - 'Network or parsing error: Unknown error', - ); + it('throws when payload is missing', async (): Promise => { + await expect(executeEmbeddedSignInFlow({url: URL})).rejects.toThrow('Authorization payload is required'); }); - it('should include custom headers when provided', async (): Promise => { - const mockResponse: EmbeddedSignInFlowHandleResponse = { - authData: {token: 'abc123'}, - flowStatus: 'COMPLETED', - }; - - global.fetch = vi.fn().mockResolvedValue({ - json: () => Promise.resolve(mockResponse), - ok: true, - }); - - const payload: Record = {password: 'pass', username: 'user'}; - const baseUrl = 'https://localhost:8090'; - const customHeaders: Record = { - Authorization: 'Bearer token', - 'X-Custom-Header': 'custom-value', - }; - + it('uses baseUrl to construct the endpoint when url is not provided', async (): Promise => { await executeEmbeddedSignInFlow({ - baseUrl, - headers: customHeaders, - payload, + baseUrl: 'https://localhost:8090', + payload: {applicationId: 'app-1', flowType: 'AUTHENTICATION'}, }); - expect(fetch).toHaveBeenCalledWith(`${baseUrl}/oauth2/authn`, { - body: JSON.stringify(payload), - headers: { - Accept: 'application/json', - Authorization: 'Bearer token', - 'Content-Type': 'application/json', - 'X-Custom-Header': 'custom-value', - }, - method: 'POST', - }); + expect(fetch).toHaveBeenCalledWith('https://localhost:8090/flow/execute', expect.any(Object)); }); }); diff --git a/packages/javascript/src/api/__tests__/executeEmbeddedSignUpFlow.test.ts b/packages/javascript/src/api/__tests__/executeEmbeddedSignUpFlow.test.ts index 0d13f7b..b5cec6d 100644 --- a/packages/javascript/src/api/__tests__/executeEmbeddedSignUpFlow.test.ts +++ b/packages/javascript/src/api/__tests__/executeEmbeddedSignUpFlow.test.ts @@ -1,5 +1,5 @@ /** - * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com). + * 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 @@ -16,285 +16,134 @@ * under the License. */ -import {Mock, beforeEach, describe, expect, it, vi} from 'vitest'; -import ThunderIDAPIError from '../../errors/ThunderIDAPIError'; -import { - EmbeddedFlowExecuteResponse, - EmbeddedFlowResponseType, - EmbeddedFlowStatus, - EmbeddedFlowType, -} from '../../models/embedded-flow'; +import {beforeEach, describe, expect, it, vi} from 'vitest'; +import {EmbeddedSignUpFlowResponse, EmbeddedSignUpFlowStatus} from '../../models/embedded-signup-flow'; import executeEmbeddedSignUpFlow from '../executeEmbeddedSignUpFlow'; +const URL = 'https://localhost:8090/flow/execute'; + +const mockFlowResponse = (overrides: Partial = {}): EmbeddedSignUpFlowResponse => + ({ + flowStatus: EmbeddedSignUpFlowStatus.Incomplete, + ...overrides, + }) as EmbeddedSignUpFlowResponse; + +const captureRequestBody = (): Record => { + const calls = (fetch as ReturnType).mock.calls; + const requestInit = calls[calls.length - 1][1] as RequestInit; + return JSON.parse(requestInit.body as string) as Record; +}; + describe('executeEmbeddedSignUpFlow', (): void => { beforeEach((): void => { vi.resetAllMocks(); - }); - - it('should execute successfully with explicit url', async (): Promise => { - const mockResponse: EmbeddedFlowExecuteResponse = { - data: {}, - flowId: 'flow-123', - flowStatus: EmbeddedFlowStatus.Complete, - type: EmbeddedFlowResponseType.View, - }; - global.fetch = vi.fn().mockResolvedValue({ - json: () => Promise.resolve(mockResponse), + json: () => Promise.resolve(mockFlowResponse()), ok: true, }); - - const url = 'https://localhost:8090/api/server/v1/flow/execute'; - const payload: Record = {foo: 'bar'}; - - const result: EmbeddedFlowExecuteResponse = await executeEmbeddedSignUpFlow({payload, url}); - - expect(fetch).toHaveBeenCalledWith( - url, - expect.objectContaining({ - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - }, - method: 'POST', - }), - ); - - const callArgs: [string, RequestInit] = (fetch as ReturnType).mock.calls[0]; - const parsedBody: Record = JSON.parse(callArgs[1].body as string); - expect(parsedBody).toEqual({ - flowType: EmbeddedFlowType.Registration, - foo: 'bar', - }); - expect(result).toEqual(mockResponse); }); - it('should fall back to baseUrl when url is not provided', async (): Promise => { - const mockResponse: EmbeddedFlowExecuteResponse = { - data: {}, - flowId: 'flow-123', - flowStatus: EmbeddedFlowStatus.Complete, - type: EmbeddedFlowResponseType.View, - }; - - global.fetch = vi.fn().mockResolvedValue({ - json: () => Promise.resolve(mockResponse), - ok: true, - }); - - const baseUrl = 'https://localhost:8090'; - const payload: Record = {a: 1}; + describe('verbose: true injection', (): void => { + it('injects verbose:true for a new flow start with applicationId and flowType', async (): Promise => { + await executeEmbeddedSignUpFlow({ + payload: {applicationId: 'app-1', flowType: 'REGISTRATION'}, + url: URL, + }); - const result: EmbeddedFlowExecuteResponse = await executeEmbeddedSignUpFlow({baseUrl, payload}); - - expect(fetch).toHaveBeenCalledWith(`${baseUrl}/api/server/v1/flow/execute`, { - body: JSON.stringify({ - a: 1, - flowType: EmbeddedFlowType.Registration, - }), - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - }, - method: 'POST', + expect(captureRequestBody()).toMatchObject({verbose: true}); }); - expect(result).toEqual(mockResponse); - }); - it('should prefer url over baseUrl when both are provided', async (): Promise => { - const mockResponse: EmbeddedFlowExecuteResponse = { - data: {}, - flowId: 'flow-123', - flowStatus: EmbeddedFlowStatus.Complete, - type: EmbeddedFlowResponseType.View, - }; + it('injects verbose:true for a new flow start that also includes scopes', async (): Promise => { + await executeEmbeddedSignUpFlow({ + payload: {applicationId: 'app-1', flowType: 'REGISTRATION', scopes: ['openid', 'profile']}, + url: URL, + }); - global.fetch = vi.fn().mockResolvedValue({ - json: () => Promise.resolve(mockResponse), - ok: true, + const body = captureRequestBody(); + expect(body).toMatchObject({verbose: true, inputs: {requested_permissions: 'openid profile'}}); + expect(body).not.toHaveProperty('scopes'); }); - const url = 'https://localhost:8090/api/server/v1/flow/execute'; - const baseUrl = 'https://localhost:8090'; - - await executeEmbeddedSignUpFlow({baseUrl, payload: {x: 1}, url}); + it('injects verbose:true for a bare flow resumption (executionId only)', async (): Promise => { + await executeEmbeddedSignUpFlow({ + payload: {executionId: 'exec-abc'}, + url: URL, + }); - expect(fetch).toHaveBeenCalledWith(url, expect.any(Object)); - }); - - it('should respect method override from requestConfig', async (): Promise => { - const mockResponse: EmbeddedFlowExecuteResponse = { - data: {}, - flowId: 'flow-123', - flowStatus: EmbeddedFlowStatus.Complete, - type: EmbeddedFlowResponseType.View, - }; - - global.fetch = vi.fn().mockResolvedValue({ - json: () => Promise.resolve(mockResponse), - ok: true, + expect(captureRequestBody()).toMatchObject({verbose: true}); }); - const baseUrl = 'https://localhost:8090'; + it('does NOT inject verbose:true for a step submission (executionId + inputs)', async (): Promise => { + await executeEmbeddedSignUpFlow({ + payload: {action: 'submit', executionId: 'exec-abc', inputs: {email: 'user@example.com'}}, + url: URL, + }); - await executeEmbeddedSignUpFlow({ - baseUrl, - method: 'PUT' as any, - payload: {y: 1}, + expect(captureRequestBody()).not.toHaveProperty('verbose'); }); - expect(fetch).toHaveBeenCalledWith( - `${baseUrl}/api/server/v1/flow/execute`, - expect.objectContaining({method: 'PUT'}), - ); - }); - - it('should enforce flowType=Registration even if provided differently', async (): Promise => { - const mockResponse: EmbeddedFlowExecuteResponse = { - data: {}, - flowId: 'flow-123', - flowStatus: EmbeddedFlowStatus.Complete, - type: EmbeddedFlowResponseType.View, - }; + it('strips a user-supplied verbose before applying internal logic', async (): Promise => { + await executeEmbeddedSignUpFlow({ + payload: {action: 'submit', executionId: 'exec-abc', inputs: {}, verbose: false}, + url: URL, + }); - global.fetch = vi.fn().mockResolvedValue({ - json: () => Promise.resolve(mockResponse), - ok: true, + expect(captureRequestBody()).not.toHaveProperty('verbose'); }); - const baseUrl = 'https://localhost:8090'; - const payload: Record = {flowType: 'SOMETHING_ELSE', p: 1} as any; - - await executeEmbeddedSignUpFlow({baseUrl, payload}); + it('strips user-supplied verbose:true from step submissions', async (): Promise => { + await executeEmbeddedSignUpFlow({ + payload: {action: 'submit', executionId: 'exec-abc', inputs: {}, verbose: true}, + url: URL, + }); - const [, init]: [string, RequestInit] = (fetch as unknown as Mock).mock.calls[0]; - expect(JSON.parse(init.body as string)).toEqual({ - flowType: EmbeddedFlowType.Registration, - p: 1, + expect(captureRequestBody()).not.toHaveProperty('verbose'); }); }); - it('should send only flowType when payload is omitted', async (): Promise => { - const mockResponse: EmbeddedFlowExecuteResponse = { - data: {}, - flowId: 'flow-123', - flowStatus: EmbeddedFlowStatus.Complete, - type: EmbeddedFlowResponseType.View, - }; + describe('scopes โ†’ inputs.requested_permissions translation', (): void => { + it('translates scopes to a space-separated inputs.requested_permissions string', async (): Promise => { + await executeEmbeddedSignUpFlow({ + payload: {applicationId: 'app-1', flowType: 'REGISTRATION', scopes: ['openid', 'profile', 'email']}, + url: URL, + }); - global.fetch = vi.fn().mockResolvedValue({ - json: () => Promise.resolve(mockResponse), - ok: true, + const body = captureRequestBody(); + expect(body).toMatchObject({inputs: {requested_permissions: 'openid profile email'}}); + expect(body).not.toHaveProperty('scopes'); }); - const baseUrl = 'https://localhost:8090'; - await executeEmbeddedSignUpFlow({baseUrl}); + it('does not add requested_permissions when scopes is absent', async (): Promise => { + await executeEmbeddedSignUpFlow({ + payload: {applicationId: 'app-1', flowType: 'REGISTRATION'}, + url: URL, + }); - const [, init]: [string, RequestInit] = (fetch as unknown as Mock).mock.calls[0]; - expect(JSON.parse(init.body as string)).toEqual({ - flowType: EmbeddedFlowType.Registration, + expect(captureRequestBody()).not.toHaveProperty('inputs'); }); - }); - - it('should throw ThunderIDAPIError when both url and baseUrl are missing', async (): Promise => { - await expect(executeEmbeddedSignUpFlow({payload: {a: 1}} as any)).rejects.toThrow(ThunderIDAPIError); - - await expect(executeEmbeddedSignUpFlow({payload: {a: 1}} as any)).rejects.toThrow( - 'Base URL or URL is not provided', - ); - }); - - it('should throw ThunderIDAPIError for invalid URL', async (): Promise => { - await expect(executeEmbeddedSignUpFlow({url: 'invalid-url' as any})).rejects.toThrow(ThunderIDAPIError); - - await expect(executeEmbeddedSignUpFlow({url: 'invalid-url' as any})).rejects.toThrow('Invalid URL provided.'); - }); - - it('should throw ThunderIDAPIError for undefined URL and baseUrl', async (): Promise => { - await expect( - executeEmbeddedSignUpFlow({baseUrl: undefined, payload: {a: 1}, url: undefined} as any), - ).rejects.toThrow(ThunderIDAPIError); - await expect( - executeEmbeddedSignUpFlow({baseUrl: undefined, payload: {a: 1}, url: undefined} as any), - ).rejects.toThrow('Base URL or URL is not provided'); - }); - it('should throw ThunderIDAPIError for empty string URL and baseUrl', async (): Promise => { - await expect(executeEmbeddedSignUpFlow({baseUrl: '', payload: {a: 1}, url: ''})).rejects.toThrow(ThunderIDAPIError); - await expect(executeEmbeddedSignUpFlow({baseUrl: '', payload: {a: 1}, url: ''})).rejects.toThrow( - 'Base URL or URL is not provided', - ); - }); + it('does not add requested_permissions when scopes is an empty array', async (): Promise => { + await executeEmbeddedSignUpFlow({ + payload: {applicationId: 'app-1', flowType: 'REGISTRATION', scopes: []}, + url: URL, + }); - it('should handle HTTP error responses', async (): Promise => { - global.fetch = vi.fn().mockResolvedValue({ - ok: false, - status: 400, - statusText: 'Bad Request', - text: () => Promise.resolve('Bad payload'), + const body = captureRequestBody(); + expect(body).not.toHaveProperty('scopes'); + expect(body).not.toHaveProperty('inputs'); }); - - const baseUrl = 'https://localhost:8090'; - await expect(executeEmbeddedSignUpFlow({baseUrl, payload: {a: 1}})).rejects.toThrow(ThunderIDAPIError); - await expect(executeEmbeddedSignUpFlow({baseUrl, payload: {a: 1}})).rejects.toThrow( - 'Embedded SignUp flow execution failed: Bad payload', - ); - }); - - it('should handle network or parsing errors', async (): Promise => { - global.fetch = vi.fn().mockRejectedValue(new Error('Network error')); - - const baseUrl = 'https://localhost:8090'; - await expect(executeEmbeddedSignUpFlow({baseUrl, payload: {a: 1}})).rejects.toThrow(ThunderIDAPIError); - await expect(executeEmbeddedSignUpFlow({baseUrl, payload: {a: 1}})).rejects.toThrow( - 'Network or parsing error: Network error', - ); }); - it('should handle non-Error rejections', async (): Promise => { - global.fetch = vi.fn().mockRejectedValue('boom'); - - const baseUrl = 'https://localhost:8090'; - await expect(executeEmbeddedSignUpFlow({baseUrl, payload: {a: 1}})).rejects.toThrow( - 'Network or parsing error: Unknown error', - ); + it('throws when payload is missing', async (): Promise => { + await expect(executeEmbeddedSignUpFlow({url: URL})).rejects.toThrow('Registration payload is required'); }); - it('should include custom headers when provided', async (): Promise => { - const mockResponse: EmbeddedFlowExecuteResponse = { - data: {}, - flowId: 'flow-123', - flowStatus: EmbeddedFlowStatus.Complete, - type: EmbeddedFlowResponseType.View, - }; - - global.fetch = vi.fn().mockResolvedValue({ - json: () => Promise.resolve(mockResponse), - ok: true, - }); - - const baseUrl = 'https://localhost:8090'; - const headers: Record = { - Authorization: 'Bearer token', - 'X-Custom-Header': 'custom', - }; - + it('uses baseUrl to construct the endpoint when url is not provided', async (): Promise => { await executeEmbeddedSignUpFlow({ - baseUrl, - headers, - payload: {a: 1}, + baseUrl: 'https://localhost:8090', + payload: {applicationId: 'app-1', flowType: 'REGISTRATION'}, }); - expect(fetch).toHaveBeenCalledWith( - `${baseUrl}/api/server/v1/flow/execute`, - expect.objectContaining({ - headers: { - Accept: 'application/json', - Authorization: 'Bearer token', - 'Content-Type': 'application/json', - 'X-Custom-Header': 'custom', - }, - }), - ); + expect(fetch).toHaveBeenCalledWith('https://localhost:8090/flow/execute', expect.any(Object)); }); }); diff --git a/packages/javascript/src/api/__tests__/initializeEmbeddedSignInFlow.test.ts b/packages/javascript/src/api/__tests__/initializeEmbeddedSignInFlow.test.ts deleted file mode 100644 index df7bbf9..0000000 --- a/packages/javascript/src/api/__tests__/initializeEmbeddedSignInFlow.test.ts +++ /dev/null @@ -1,273 +0,0 @@ -/** - * 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 {describe, it, expect, vi, beforeEach, Mock} from 'vitest'; -import ThunderIDAPIError from '../../errors/ThunderIDAPIError'; -import type {EmbeddedSignInFlowInitiateResponse} from '../../models/embedded-signin-flow'; -import initializeEmbeddedSignInFlow from '../initializeEmbeddedSignInFlow'; - -describe('initializeEmbeddedSignInFlow', (): void => { - beforeEach((): void => { - vi.resetAllMocks(); - }); - - it('should execute successfully with explicit url (default fetch)', async (): Promise => { - const mockResp: EmbeddedSignInFlowInitiateResponse = { - flowId: 'fid-123', - flowStatus: 'PENDING', - } as any; - - global.fetch = vi.fn().mockResolvedValue({ - json: () => Promise.resolve(mockResp), - ok: true, - }); - - const url = 'https://localhost:8090/oauth2/authorize'; - const payload: Record = { - client_id: 'cid', - code_challenge: 'abc', - code_challenge_method: 'S256', - redirect_uri: 'https://app/cb', - response_type: 'code', - scope: 'openid profile', - state: 'xyz', - }; - - const result: EmbeddedSignInFlowInitiateResponse = await initializeEmbeddedSignInFlow({payload, url}); - - expect(fetch).toHaveBeenCalledWith(url, { - body: new URLSearchParams(payload).toString(), - headers: { - Accept: 'application/json', - 'Content-Type': 'application/x-www-form-urlencoded', - }, - method: 'POST', - }); - expect(result).toEqual(mockResp); - }); - - it('should fall back to baseUrl when url is not provided', async (): Promise => { - const mockResp: EmbeddedSignInFlowInitiateResponse = { - flowId: 'fid-456', - flowStatus: 'PENDING', - } as any; - - global.fetch = vi.fn().mockResolvedValue({ - json: () => Promise.resolve(mockResp), - ok: true, - }); - - const baseUrl = 'https://localhost:8090'; - const payload: Record = {client_id: 'cid', response_type: 'code'}; - - const result: EmbeddedSignInFlowInitiateResponse = await initializeEmbeddedSignInFlow({baseUrl, payload}); - - expect(fetch).toHaveBeenCalledWith(`${baseUrl}/oauth2/authorize`, { - body: new URLSearchParams(payload).toString(), - headers: { - Accept: 'application/json', - 'Content-Type': 'application/x-www-form-urlencoded', - }, - method: 'POST', - }); - expect(result).toEqual(mockResp); - }); - - it('should use custom method from requestConfig when provided', async (): Promise => { - const mockResp: EmbeddedSignInFlowInitiateResponse = { - flowId: 'fid-789', - flowStatus: 'PENDING', - } as any; - - global.fetch = vi.fn().mockResolvedValue({ - json: () => Promise.resolve(mockResp), - ok: true, - }); - - const baseUrl = 'https://localhost:8090'; - const payload: Record = {client_id: 'cid', response_type: 'code'}; - - await initializeEmbeddedSignInFlow({baseUrl, method: 'PUT' as any, payload}); - - expect(fetch).toHaveBeenCalledWith(`${baseUrl}/oauth2/authorize`, expect.objectContaining({method: 'PUT'})); - }); - - it('should prefer url over baseUrl when both are provided', async (): Promise => { - const mockResp: EmbeddedSignInFlowInitiateResponse = { - flowId: 'fid-000', - flowStatus: 'PENDING', - } as any; - - global.fetch = vi.fn().mockResolvedValue({ - json: () => Promise.resolve(mockResp), - ok: true, - }); - - const url = 'https://localhost:8090/oauth2/authorize'; - const baseUrl = 'https://localhost:8090'; - await initializeEmbeddedSignInFlow({baseUrl, payload: {response_type: 'code'}, url}); - - expect(fetch).toHaveBeenCalledWith(url, expect.any(Object)); - }); - - it('should throw ThunderIDAPIError for invalid URL/baseUrl', async (): Promise => { - await expect(initializeEmbeddedSignInFlow({payload: {a: 1} as any, url: 'invalid-url' as any})).rejects.toThrow( - ThunderIDAPIError, - ); - await expect(initializeEmbeddedSignInFlow({payload: {a: 1} as any, url: 'invalid-url' as any})).rejects.toThrow( - 'Invalid URL provided.', - ); - }); - - it('should throw ThunderIDAPIError when payload is missing', async (): Promise => { - const baseUrl = 'https://localhost:8090'; - await expect(initializeEmbeddedSignInFlow({baseUrl} as any)).rejects.toThrow(ThunderIDAPIError); - await expect(initializeEmbeddedSignInFlow({baseUrl} as any)).rejects.toThrow('Authorization payload is required'); - }); - - it('should handle HTTP error responses with plain-text body', async (): Promise => { - global.fetch = vi.fn().mockResolvedValue({ - ok: false, - status: 400, - statusText: 'Bad Request', - text: () => Promise.resolve('invalid request'), - }); - - const baseUrl = 'https://localhost:8090'; - const payload: Record = {client_id: 'cid', response_type: 'code'}; - - await expect(initializeEmbeddedSignInFlow({baseUrl, payload})).rejects.toThrow(ThunderIDAPIError); - await expect(initializeEmbeddedSignInFlow({baseUrl, payload})).rejects.toThrow('invalid request'); - }); - - it('should extract description.defaultValue from a structured error response body', async (): Promise => { - const structuredError: string = JSON.stringify({ - code: 'SSE-5000', - description: { - defaultValue: 'An unexpected error occurred while processing the request', - key: 'error.internal_server_error_description', - }, - message: {defaultValue: 'Internal server error', key: 'error.internal_server_error'}, - }); - - global.fetch = vi.fn().mockResolvedValue({ - ok: false, - status: 500, - statusText: 'Internal Server Error', - text: () => Promise.resolve(structuredError), - }); - - const baseUrl = 'https://localhost:8090'; - const payload: Record = {client_id: 'cid', response_type: 'code'}; - - await expect(initializeEmbeddedSignInFlow({baseUrl, payload})).rejects.toThrow( - 'An unexpected error occurred while processing the request', - ); - }); - - it('should handle network or parsing errors', async (): Promise => { - global.fetch = vi.fn().mockRejectedValue(new Error('Network down')); - - const baseUrl = 'https://localhost:8090'; - const payload: Record = {client_id: 'cid', response_type: 'code'}; - - await expect(initializeEmbeddedSignInFlow({baseUrl, payload})).rejects.toThrow(ThunderIDAPIError); - await expect(initializeEmbeddedSignInFlow({baseUrl, payload})).rejects.toThrow( - 'Network or parsing error: Network down', - ); - }); - - it('should handle non-Error rejections', async (): Promise => { - global.fetch = vi.fn().mockRejectedValue('weird failure'); - - const baseUrl = 'https://localhost:8090'; - const payload: Record = {client_id: 'cid', response_type: 'code'}; - - await expect(initializeEmbeddedSignInFlow({baseUrl, payload})).rejects.toThrow( - 'Network or parsing error: Unknown error', - ); - }); - - it('should pass through custom headers (and enforces content-type & accept)', async (): Promise => { - const mockResp: EmbeddedSignInFlowInitiateResponse = { - flowId: 'fid-headers', - flowStatus: 'PENDING', - } as any; - - global.fetch = vi.fn().mockResolvedValue({ - json: () => Promise.resolve(mockResp), - ok: true, - }); - - const baseUrl = 'https://localhost:8090'; - const payload: Record = {client_id: 'cid', response_type: 'code'}; - const customHeaders: Record = { - Accept: 'text/plain', - Authorization: 'Bearer token', - 'Content-Type': 'text/plain', - 'X-Custom-Header': 'custom-value', - }; - - await initializeEmbeddedSignInFlow({ - baseUrl, - headers: customHeaders, - payload, - }); - - expect(fetch).toHaveBeenCalledWith(`${baseUrl}/oauth2/authorize`, { - body: new URLSearchParams(payload).toString(), - headers: { - Accept: 'application/json', - Authorization: 'Bearer token', - 'Content-Type': 'application/x-www-form-urlencoded', - 'X-Custom-Header': 'custom-value', - }, - method: 'POST', - }); - }); - - it('should encode payload as application/x-www-form-urlencoded', async (): Promise => { - const mockResp: EmbeddedSignInFlowInitiateResponse = { - flowId: 'fid-enc', - flowStatus: 'PENDING', - } as any; - - global.fetch = vi.fn().mockResolvedValue({ - json: () => Promise.resolve(mockResp), - ok: true, - }); - - const baseUrl = 'https://localhost:8090'; - const payload: Record = { - client_id: 'cid', - redirect_uri: 'https://app.example.com/cb?x=1&y=2', - response_type: 'code', - scope: 'openid profile email', - state: 'chars !@#$&=+,:;/?', - }; - - await initializeEmbeddedSignInFlow({baseUrl, payload}); - - const [, init] = (fetch as unknown as Mock).mock.calls[0]; - expect(init.headers['Content-Type']).toBe('application/x-www-form-urlencoded'); - // ensure characters are url-encoded in body - expect(init.body).toContain('scope=openid+profile+email'); - expect(init.body).toContain('redirect_uri=https%3A%2F%2Fapp.example.com%2Fcb%3Fx%3D1%26y%3D2'); - expect(init.body).toContain('state=chars+%21%40%23%24%26%3D%2B%2C%3A%3B%2F%3F'); - }); -}); diff --git a/packages/javascript/src/api/v2/executeEmbeddedRecoveryFlowV2.ts b/packages/javascript/src/api/executeEmbeddedRecoveryFlow.ts similarity index 89% rename from packages/javascript/src/api/v2/executeEmbeddedRecoveryFlowV2.ts rename to packages/javascript/src/api/executeEmbeddedRecoveryFlow.ts index 111cb35..4173c38 100644 --- a/packages/javascript/src/api/v2/executeEmbeddedRecoveryFlowV2.ts +++ b/packages/javascript/src/api/executeEmbeddedRecoveryFlow.ts @@ -16,9 +16,9 @@ * under the License. */ -import ThunderIDAPIError from '../../errors/ThunderIDAPIError'; -import {EmbeddedFlowExecuteRequestConfig as EmbeddedFlowExecuteRequestConfigV2} from '../../models/v2/embedded-flow-v2'; -import {EmbeddedRecoveryFlowResponse} from '../../models/v2/embedded-recovery-flow-v2'; +import ThunderIDAPIError from '../errors/ThunderIDAPIError'; +import {EmbeddedFlowExecuteRequestConfig as EmbeddedFlowExecuteRequestConfig} from '../models/embedded-flow'; +import {EmbeddedRecoveryFlowResponse} from '../models/embedded-recovery-flow'; /** * Executes an embedded recovery flow by sending a request to the flow execution endpoint. @@ -35,7 +35,7 @@ import {EmbeddedRecoveryFlowResponse} from '../../models/v2/embedded-recovery-fl * @example * ```typescript * // Initiate recovery flow - * const response = await executeEmbeddedRecoveryFlowV2({ + * const response = await executeEmbeddedRecoveryFlow({ * baseUrl: 'https://localhost:8090', * payload: { * flowType: 'RECOVERY', @@ -44,7 +44,7 @@ import {EmbeddedRecoveryFlowResponse} from '../../models/v2/embedded-recovery-fl * }); * * // Continue recovery flow with user input - * const nextResponse = await executeEmbeddedRecoveryFlowV2({ + * const nextResponse = await executeEmbeddedRecoveryFlow({ * baseUrl: 'https://localhost:8090', * payload: { * executionId: response.executionId, @@ -55,12 +55,12 @@ import {EmbeddedRecoveryFlowResponse} from '../../models/v2/embedded-recovery-fl * }); * ``` */ -const executeEmbeddedRecoveryFlowV2 = async ({ +const executeEmbeddedRecoveryFlow = async ({ url, baseUrl, payload, ...requestConfig -}: EmbeddedFlowExecuteRequestConfigV2): Promise => { +}: EmbeddedFlowExecuteRequestConfig): Promise => { if (!payload) { throw new ThunderIDAPIError( 'Recovery payload is required', @@ -132,4 +132,4 @@ const executeEmbeddedRecoveryFlowV2 = async ({ return flowResponse; }; -export default executeEmbeddedRecoveryFlowV2; +export default executeEmbeddedRecoveryFlow; diff --git a/packages/javascript/src/api/executeEmbeddedSignInFlow.ts b/packages/javascript/src/api/executeEmbeddedSignInFlow.ts index 1207cd1..156c8b2 100644 --- a/packages/javascript/src/api/executeEmbeddedSignInFlow.ts +++ b/packages/javascript/src/api/executeEmbeddedSignInFlow.ts @@ -1,5 +1,5 @@ /** - * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com). + * Copyright (c) 2025-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 @@ -18,27 +18,16 @@ import ThunderIDAPIError from '../errors/ThunderIDAPIError'; import {EmbeddedFlowExecuteRequestConfig} from '../models/embedded-flow'; -import {EmbeddedSignInFlowHandleResponse} from '../models/embedded-signin-flow'; +import {EmbeddedSignInFlowResponse, EmbeddedSignInFlowStatus} from '../models/embedded-signin-flow'; +import injectRequestedPermissions from '../utils/injectRequestedPermissions'; const executeEmbeddedSignInFlow = async ({ url, baseUrl, payload, + authId, ...requestConfig -}: EmbeddedFlowExecuteRequestConfig): Promise => { - try { - // eslint-disable-next-line no-new - new URL((url ?? baseUrl)!); - } catch (error) { - throw new ThunderIDAPIError( - `Invalid URL provided. ${error?.toString()}`, - 'executeEmbeddedSignInFlow-ValidationError-001', - 'javascript', - 400, - 'The provided `url` or `baseUrl` path does not adhere to the URL schema.', - ); - } - +}: EmbeddedFlowExecuteRequestConfig): Promise => { if (!payload) { throw new ThunderIDAPIError( 'Authorization payload is required', @@ -49,45 +38,116 @@ const executeEmbeddedSignInFlow = async ({ ); } - try { - const response: Response = await fetch(url ?? `${baseUrl}/oauth2/authn`, { - ...requestConfig, - body: JSON.stringify(payload), - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - ...requestConfig.headers, - } as HeadersInit, - method: requestConfig.method || 'POST', - }); - - if (!response.ok) { - const errorText: string = await response.text(); + const endpoint: string = url ?? `${baseUrl}/flow/execute`; - throw new ThunderIDAPIError( - errorText, - 'initializeEmbeddedSignInFlow-ResponseError-001', - 'javascript', - response.status, - response.statusText, - 'Authorization request failed', - ); - } + // 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]: [string, unknown]) => key !== 'verbose')) + : payload; - return (await response.json()) as EmbeddedSignInFlowHandleResponse; - } catch (error) { - if (error instanceof ThunderIDAPIError) { - throw error; - } + // `verbose: true` is required to get the `meta` field in the response that includes component details. + // Add verbose:true if: + // 1. payload contains applicationId and flowType (new flow start; may also carry scopes or other init params) + // 2. payload contains only executionId (flow resumption without step data) + const isNewFlowStart: boolean = + typeof cleanPayload === 'object' && + cleanPayload !== null && + 'applicationId' in cleanPayload && + 'flowType' in cleanPayload; + const hasOnlyFlowId: boolean = + typeof cleanPayload === 'object' && + cleanPayload !== null && + 'executionId' in cleanPayload && + Object.keys(cleanPayload).length === 1; + + const basePayload: Record = isNewFlowStart + ? injectRequestedPermissions(cleanPayload as Record) + : (cleanPayload as Record); + + const requestPayload: Record = + isNewFlowStart || hasOnlyFlowId ? {...basePayload, verbose: true} : basePayload; + + const response: Response = await fetch(endpoint, { + ...requestConfig, + body: JSON.stringify(requestPayload), + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + ...requestConfig.headers, + } as HeadersInit, + method: requestConfig.method || 'POST', + }); + + if (!response.ok) { + const errorText: string = await response.text(); throw new ThunderIDAPIError( - `Network or parsing error: ${error instanceof Error ? error.message : 'Unknown error'}`, - 'executeEmbeddedSignInFlow-NetworkError-001', + errorText, + 'executeEmbeddedSignInFlow-ResponseError-001', 'javascript', - 0, - 'Network Error', + response.status, + response.statusText, + 'Authorization request failed', ); } + + const flowResponse: EmbeddedSignInFlowResponse = await response.json(); + + // IMPORTANT: Only applicable for ThunderID V2 platform. + // Check if the flow is complete and has an assertion and authId is provided, then call OAuth2 auth callback. + if (flowResponse.flowStatus === EmbeddedSignInFlowStatus.Complete && flowResponse.assertion && authId) { + try { + const oauth2Response: Response = await fetch(`${baseUrl}/oauth2/auth/callback`, { + body: JSON.stringify({ + assertion: flowResponse.assertion, + authId, + }), + credentials: 'include', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + ...requestConfig.headers, + } as HeadersInit, + method: 'POST', + }); + + if (!oauth2Response.ok) { + const oauth2ErrorText: string = await oauth2Response.text(); + + throw new ThunderIDAPIError( + oauth2ErrorText, + 'executeEmbeddedSignInFlow-OAuth2Error-002', + 'javascript', + oauth2Response.status, + oauth2Response.statusText, + 'OAuth2 authorization failed', + ); + } + + const oauth2Result: Record = await oauth2Response.json(); + + return { + flowStatus: flowResponse.flowStatus, + redirectUrl: oauth2Result['redirect_uri'], + } as any; + } catch (authError) { + if (authError instanceof ThunderIDAPIError) { + throw authError; + } + + throw new ThunderIDAPIError( + authError instanceof Error ? authError.message : 'Unknown error', + 'executeEmbeddedSignInFlow-OAuth2Error-001', + 'javascript', + 500, + 'Failed to complete OAuth2 authorization after successful embedded sign-in flow.', + 'OAuth2 authorization failed', + ); + } + } + + return flowResponse; }; export default executeEmbeddedSignInFlow; diff --git a/packages/javascript/src/api/executeEmbeddedSignUpFlow.ts b/packages/javascript/src/api/executeEmbeddedSignUpFlow.ts index 3bc37a2..9cd1f5d 100644 --- a/packages/javascript/src/api/executeEmbeddedSignUpFlow.ts +++ b/packages/javascript/src/api/executeEmbeddedSignUpFlow.ts @@ -1,5 +1,5 @@ /** - * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com). + * Copyright (c) 2025-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 @@ -17,103 +17,137 @@ */ import ThunderIDAPIError from '../errors/ThunderIDAPIError'; -import {EmbeddedFlowType, EmbeddedFlowExecuteResponse, EmbeddedFlowExecuteRequestConfig} from '../models/embedded-flow'; +import {EmbeddedFlowExecuteRequestConfig as EmbeddedFlowExecuteRequestConfig} from '../models/embedded-flow'; +import {EmbeddedSignUpFlowResponse, EmbeddedSignUpFlowStatus} from '../models/embedded-signup-flow'; +import injectRequestedPermissions from '../utils/injectRequestedPermissions'; -/** - * Executes an embedded signup flow by sending a request to the specified flow execution endpoint. - * - * @param requestConfig - Request configuration object containing URL and payload. - * @returns A promise that resolves with the flow execution response. - * @throws ThunderIDAPIError when the request fails or URL is invalid. - * - * @example - * ```typescript - * try { - * const embeddedSignUpResponse = await executeEmbeddedSignUpFlow({ - * url: "https://localhost:8090/api/server/v1/flow/execute", - * payload: { - * flowType: "REGISTRATION" - * } - * }); - * console.log(embeddedSignUpResponse); - * } catch (error) { - * if (error instanceof ThunderIDAPIError) { - * console.error('Embedded SignUp flow execution failed:', error.message); - * } - * } - * ``` - */ const executeEmbeddedSignUpFlow = async ({ url, baseUrl, payload, + authId, ...requestConfig -}: EmbeddedFlowExecuteRequestConfig): Promise => { - if (!baseUrl && !url) { +}: EmbeddedFlowExecuteRequestConfig): Promise => { + if (!payload) { throw new ThunderIDAPIError( - 'Embedded SignUp flow execution failed: Base URL or URL is not provided.', - 'javascript-executeEmbeddedSignUpFlow-ValidationError-001', + 'Registration payload is required', + 'executeEmbeddedSignUpFlow-ValidationError-002', 'javascript', 400, - 'At least one of the baseUrl or url must be provided to execute the embedded sign up flow.', + 'If a registration payload is not provided, the request cannot be constructed correctly.', ); } - try { - // eslint-disable-next-line no-new - new URL((url ?? baseUrl)!); - } catch (error) { + 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]: [string, unknown]) => key !== 'verbose')) + : payload; + + // `verbose: true` is required to get the `meta` field in the response that includes component details. + // Add verbose:true if: + // 1. payload contains applicationId and flowType (new flow start; may also carry scopes or other init params) + // 2. payload contains only executionId (flow resumption without step data) + const isNewFlowStart: boolean = + typeof cleanPayload === 'object' && + cleanPayload !== null && + 'applicationId' in cleanPayload && + 'flowType' in cleanPayload; + const hasOnlyFlowId: boolean = + typeof cleanPayload === 'object' && + cleanPayload !== null && + 'executionId' in cleanPayload && + Object.keys(cleanPayload).length === 1; + + const basePayload: Record = isNewFlowStart + ? injectRequestedPermissions(cleanPayload as Record) + : (cleanPayload as Record); + + const requestPayload: Record = + isNewFlowStart || hasOnlyFlowId ? {...basePayload, verbose: true} : basePayload; + + const response: Response = await fetch(endpoint, { + ...requestConfig, + body: JSON.stringify(requestPayload), + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + ...requestConfig.headers, + } as HeadersInit, + method: requestConfig.method || 'POST', + }); + + if (!response.ok) { + const errorText: string = await response.text(); + throw new ThunderIDAPIError( - `Invalid URL provided. ${error?.toString()}`, - 'executeEmbeddedSignUpFlow-ValidationError-001', + errorText, + 'executeEmbeddedSignUpFlow-ResponseError-001', 'javascript', - 400, - 'The provided `url` or `baseUrl` path does not adhere to the URL schema.', + response.status, + response.statusText, + 'Registration request failed', ); } - try { - const response: Response = await fetch(url ?? `${baseUrl}/api/server/v1/flow/execute`, { - ...requestConfig, - body: JSON.stringify({ - ...(payload ?? {}), - flowType: EmbeddedFlowType.Registration, - }), - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - ...requestConfig.headers, - } as HeadersInit, - method: requestConfig.method || 'POST', - }); - - if (!response.ok) { - const errorText: string = await response.text(); + const flowResponse: EmbeddedSignUpFlowResponse = await response.json(); + + // IMPORTANT: Only applicable for ThunderID V2 platform. + // Check if the flow is complete and has an assertion and authId is provided, then call OAuth2 auth callback. + if (flowResponse.flowStatus === EmbeddedSignUpFlowStatus.Complete && (flowResponse as any).assertion && authId) { + try { + const oauth2Response: Response = await fetch(`${baseUrl}/oauth2/auth/callback`, { + body: JSON.stringify({ + assertion: (flowResponse as any).assertion, + authId, + }), + credentials: 'include', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + ...requestConfig.headers, + } as HeadersInit, + method: 'POST', + }); + + if (!oauth2Response.ok) { + const oauth2ErrorText: string = await oauth2Response.text(); + + throw new ThunderIDAPIError( + oauth2ErrorText, + 'executeEmbeddedSignUpFlow-OAuth2Error-002', + 'javascript', + oauth2Response.status, + oauth2Response.statusText, + 'OAuth2 authorization failed', + ); + } + + const oauth2Result: Record = await oauth2Response.json(); + + return { + flowStatus: flowResponse.flowStatus, + redirectUrl: oauth2Result['redirect_uri'], + } as any; + } catch (authError) { + if (authError instanceof ThunderIDAPIError) { + throw authError; + } throw new ThunderIDAPIError( - errorText, - 'javascript-executeEmbeddedSignUpFlow-ResponseError-100', + authError instanceof Error ? authError.message : 'Unknown error', + 'executeEmbeddedSignUpFlow-OAuth2Error-001', 'javascript', - response.status, - response.statusText, - 'Embedded SignUp flow execution failed', + 500, + 'Failed to complete OAuth2 authorization after successful embedded sign-up flow.', + 'OAuth2 authorization failed', ); } - - return (await response.json()) as EmbeddedFlowExecuteResponse; - } catch (error) { - if (error instanceof ThunderIDAPIError) { - throw error; - } - - throw new ThunderIDAPIError( - `Network or parsing error: ${error instanceof Error ? error.message : 'Unknown error'}`, - 'executeEmbeddedSignUpFlow-NetworkError-001', - 'javascript', - 0, - 'Network Error', - ); } + + return flowResponse; }; export default executeEmbeddedSignUpFlow; diff --git a/packages/javascript/src/api/v2/executeEmbeddedUserOnboardingFlowV2.ts b/packages/javascript/src/api/executeEmbeddedUserOnboardingFlow.ts similarity index 89% rename from packages/javascript/src/api/v2/executeEmbeddedUserOnboardingFlowV2.ts rename to packages/javascript/src/api/executeEmbeddedUserOnboardingFlow.ts index bc09b92..7ae768b 100644 --- a/packages/javascript/src/api/v2/executeEmbeddedUserOnboardingFlowV2.ts +++ b/packages/javascript/src/api/executeEmbeddedUserOnboardingFlow.ts @@ -16,12 +16,8 @@ * under the License. */ -import ThunderIDAPIError from '../../errors/ThunderIDAPIError'; -import {EmbeddedFlowType} from '../../models/embedded-flow'; -import { - EmbeddedFlowExecuteRequestConfig as EmbeddedFlowExecuteRequestConfigV2, - FlowExecutionError, -} from '../../models/v2/embedded-flow-v2'; +import ThunderIDAPIError from '../errors/ThunderIDAPIError'; +import {EmbeddedFlowType, EmbeddedFlowExecuteRequestConfig, FlowExecutionError} from '../models/embedded-flow'; /** * Response from the user onboarding flow execution. @@ -77,7 +73,7 @@ export interface EmbeddedUserOnboardingFlowResponse { * @example * ```typescript * // Admin initiating user onboarding (requires auth token) - * const response = await executeEmbeddedUserOnboardingFlowV2({ + * const response = await executeEmbeddedUserOnboardingFlow({ * baseUrl: "https://api.thunder.io", * payload: { * flowType: "USER_ONBOARDING" @@ -88,7 +84,7 @@ export interface EmbeddedUserOnboardingFlowResponse { * }); * * // End-user accepting invite (no auth required) - * const response = await executeEmbeddedUserOnboardingFlowV2({ + * const response = await executeEmbeddedUserOnboardingFlow({ * baseUrl: "https://api.thunder.io", * payload: { * executionId: "flow-id-from-url", @@ -97,12 +93,12 @@ export interface EmbeddedUserOnboardingFlowResponse { * }); * ``` */ -const executeEmbeddedUserOnboardingFlowV2 = async ({ +const executeEmbeddedUserOnboardingFlow = async ({ url, baseUrl, payload, ...requestConfig -}: EmbeddedFlowExecuteRequestConfigV2): Promise => { +}: EmbeddedFlowExecuteRequestConfig): Promise => { if (!payload) { throw new ThunderIDAPIError( 'User onboarding payload is required', @@ -177,4 +173,4 @@ const executeEmbeddedUserOnboardingFlowV2 = async ({ return flowResponse; }; -export default executeEmbeddedUserOnboardingFlowV2; +export default executeEmbeddedUserOnboardingFlow; diff --git a/packages/javascript/src/api/getBrandingPreference.ts b/packages/javascript/src/api/getBrandingPreference.ts index 2c8a032..81d47ff 100644 --- a/packages/javascript/src/api/getBrandingPreference.ts +++ b/packages/javascript/src/api/getBrandingPreference.ts @@ -18,8 +18,6 @@ import ThunderIDAPIError from '../errors/ThunderIDAPIError'; import {BrandingPreference} from '../models/branding-preference'; -import {Platform} from '../models/platforms'; -import identifyPlatform from '../utils/identifyPlatform'; import logger from '../utils/logger'; /** @@ -158,8 +156,6 @@ const getBrandingPreference = async ({ if (!response?.ok) { const errorText: string = await response.text(); - const platform: Platform = identifyPlatform({baseUrl} as any); - let errorDescription: string; try { const errorBody: {description?: string; message?: string} = JSON.parse(errorText) as { @@ -171,17 +167,8 @@ const getBrandingPreference = async ({ errorDescription = errorText; } - let platformConsoleGuidance: string; - if (platform === Platform.ThunderID) { - platformConsoleGuidance = 'configure branding preferences in the ThunderID console'; - } else if (platform === Platform.IdentityServer) { - platformConsoleGuidance = 'configure branding preferences in the WSO2 Identity Server console'; - } else { - platformConsoleGuidance = 'configure branding preferences in the platform console'; - } - logger.warn( - `[BrandingError] ${errorDescription} To resolve this issue, please ${platformConsoleGuidance}. If you want to suppress this warning and stop fetching branding preferences, set \`\` -> \`preferences\` -> \`theme\` -> \`inheritFromBranding\` to false.`, + `[BrandingError] ${errorDescription} To resolve this issue, please configure branding preferences in the ThunderID console. If you want to suppress this warning and stop fetching branding preferences, set \`\` -> \`preferences\` -> \`theme\` -> \`inheritFromBranding\` to false.`, ); throw new ThunderIDAPIError( diff --git a/packages/javascript/src/api/v2/getFlowMetaV2.ts b/packages/javascript/src/api/getFlowMeta.ts similarity index 88% rename from packages/javascript/src/api/v2/getFlowMetaV2.ts rename to packages/javascript/src/api/getFlowMeta.ts index 0bccedc..06d3b1c 100644 --- a/packages/javascript/src/api/v2/getFlowMetaV2.ts +++ b/packages/javascript/src/api/getFlowMeta.ts @@ -16,8 +16,8 @@ * under the License. */ -import ThunderIDAPIError from '../../errors/ThunderIDAPIError'; -import {FlowMetadataResponse, GetFlowMetaRequestConfig} from '../../models/v2/flow-meta-v2'; +import ThunderIDAPIError from '../errors/ThunderIDAPIError'; +import {FlowMetadataResponse, GetFlowMetaRequestConfig} from '../models/flow-meta'; /** * Fetches aggregated flow metadata from the `GET /flow/meta` endpoint. @@ -37,10 +37,10 @@ import {FlowMetadataResponse, GetFlowMetaRequestConfig} from '../../models/v2/fl * * @example * ```typescript - * import getFlowMetaV2 from './api/v2/getFlowMetaV2'; - * import { FlowMetaType } from './models/v2/flow-meta-v2'; + * import getFlowMeta from './api/getFlowMeta'; + * import { FlowMetaType } from './models/flow-meta'; * - * const meta = await getFlowMetaV2({ + * const meta = await getFlowMeta({ * baseUrl: 'https://localhost:8090', * type: FlowMetaType.App, * id: '60a9b38b-6eba-9f9e-55f9-267067de4680', @@ -54,7 +54,7 @@ import {FlowMetadataResponse, GetFlowMetaRequestConfig} from '../../models/v2/fl * * @experimental This function targets the ThunderID V2 platform API */ -const getFlowMetaV2 = async ({ +const getFlowMeta = async ({ url, baseUrl, type, @@ -87,7 +87,7 @@ const getFlowMetaV2 = async ({ throw new ThunderIDAPIError( errorText, - 'getFlowMetaV2-ResponseError-001', + 'getFlowMeta-ResponseError-001', 'javascript', response.status, response.statusText, @@ -100,4 +100,4 @@ const getFlowMetaV2 = async ({ return flowMetadata; }; -export default getFlowMetaV2; +export default getFlowMeta; diff --git a/packages/javascript/src/api/v2/getOrganizationUnitChildren.ts b/packages/javascript/src/api/getOrganizationUnitChildren.ts similarity index 96% rename from packages/javascript/src/api/v2/getOrganizationUnitChildren.ts rename to packages/javascript/src/api/getOrganizationUnitChildren.ts index 9d8313a..0d8f22e 100644 --- a/packages/javascript/src/api/v2/getOrganizationUnitChildren.ts +++ b/packages/javascript/src/api/getOrganizationUnitChildren.ts @@ -16,8 +16,8 @@ * under the License. */ -import ThunderIDAPIError from '../../errors/ThunderIDAPIError'; -import {GetOrganizationUnitChildrenConfig, OrganizationUnitListResponse} from '../../models/v2/organization-unit'; +import ThunderIDAPIError from '../errors/ThunderIDAPIError'; +import {GetOrganizationUnitChildrenConfig, OrganizationUnitListResponse} from '../models/organization-unit'; /** * Retrieves the child organization units of a given parent OU. diff --git a/packages/javascript/src/api/initializeEmbeddedSignInFlow.ts b/packages/javascript/src/api/initializeEmbeddedSignInFlow.ts deleted file mode 100644 index 97ea6e1..0000000 --- a/packages/javascript/src/api/initializeEmbeddedSignInFlow.ts +++ /dev/null @@ -1,130 +0,0 @@ -/** - * 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 ThunderIDAPIError from '../errors/ThunderIDAPIError'; -import {EmbeddedFlowExecuteRequestConfig} from '../models/embedded-flow'; -import {EmbeddedSignInFlowInitiateResponse} from '../models/embedded-signin-flow'; - -/** - * Sends an authorization request to the specified OAuth2/OIDC authorization endpoint. - * - * @param requestConfig - Request configuration object containing URL and payload. - * @returns A promise that resolves with the authorization response. - * @throws ThunderIDAPIError when the request fails or URL is invalid. - * - * @example - * ```typescript - * try { - * const authResponse = await initializeEmbeddedSignInFlow({ - * url: "https://localhost:8090/oauth2/authorize", - * payload: { - * response_type: "code", - * client_id: "your-client-id", - * redirect_uri: "https://your-app.com/callback", - * scope: "openid profile email", - * state: "random-state-value", - * code_challenge: "your-pkce-challenge", - * code_challenge_method: "S256" - * } - * }); - * console.log(authResponse); - * } catch (error) { - * if (error instanceof ThunderIDAPIError) { - * console.error('Authorization failed:', error.message); - * } - * } - * ``` - */ -const initializeEmbeddedSignInFlow = async ({ - url, - baseUrl, - payload, - ...requestConfig -}: EmbeddedFlowExecuteRequestConfig): Promise => { - try { - // eslint-disable-next-line no-new - new URL((url ?? baseUrl)!); - } catch (error) { - throw new ThunderIDAPIError( - `Invalid URL provided. ${error?.toString()}`, - 'getSchemas-ValidationError-001', - 'javascript', - 400, - 'The provided `url` or `baseUrl` path does not adhere to the URL schema.', - ); - } - - if (!payload) { - throw new ThunderIDAPIError( - 'Authorization payload is required', - 'initializeEmbeddedSignInFlow-ValidationError-002', - 'javascript', - 400, - 'If an authorization payload is not provided, the request cannot be constructed correctly.', - ); - } - - const searchParams: URLSearchParams = new URLSearchParams(); - Object.entries(payload).forEach(([key, value]: [string, unknown]) => { - if (value !== undefined && value !== null) { - searchParams.append(key, String(value)); - } - }); - - try { - const response: Response = await fetch(url ?? `${baseUrl}/oauth2/authorize`, { - ...requestConfig, - body: searchParams.toString(), - headers: { - ...requestConfig.headers, - Accept: 'application/json', - 'Content-Type': 'application/x-www-form-urlencoded', - } as HeadersInit, - method: requestConfig.method || 'POST', - }); - - if (!response.ok) { - const errorText: string = await response.text(); - - throw new ThunderIDAPIError( - errorText, - 'initializeEmbeddedSignInFlow-ResponseError-001', - 'javascript', - response.status, - response.statusText, - 'Authorization request failed', - ); - } - - return (await response.json()) as EmbeddedSignInFlowInitiateResponse; - } catch (error) { - if (error instanceof ThunderIDAPIError) { - throw error; - } - - throw new ThunderIDAPIError( - `Network or parsing error: ${error instanceof Error ? error.message : 'Unknown error'}`, - 'initializeEmbeddedSignInFlow-NetworkError-001', - 'javascript', - 0, - 'Network Error', - ); - } -}; - -export default initializeEmbeddedSignInFlow; diff --git a/packages/javascript/src/api/v2/__tests__/executeEmbeddedSignInFlowV2.test.ts b/packages/javascript/src/api/v2/__tests__/executeEmbeddedSignInFlowV2.test.ts deleted file mode 100644 index 566300b..0000000 --- a/packages/javascript/src/api/v2/__tests__/executeEmbeddedSignInFlowV2.test.ts +++ /dev/null @@ -1,149 +0,0 @@ -/** - * 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 {beforeEach, describe, expect, it, vi} from 'vitest'; -import {EmbeddedSignInFlowResponse, EmbeddedSignInFlowStatus} from '../../../models/v2/embedded-signin-flow-v2'; -import executeEmbeddedSignInFlowV2 from '../executeEmbeddedSignInFlowV2'; - -const URL = 'https://localhost:8090/flow/execute'; - -const mockFlowResponse = (overrides: Partial = {}): EmbeddedSignInFlowResponse => - ({ - flowStatus: EmbeddedSignInFlowStatus.Incomplete, - ...overrides, - }) as EmbeddedSignInFlowResponse; - -const captureRequestBody = (): Record => { - const calls = (fetch as ReturnType).mock.calls; - const requestInit = calls[calls.length - 1][1] as RequestInit; - return JSON.parse(requestInit.body as string) as Record; -}; - -describe('executeEmbeddedSignInFlowV2', (): void => { - beforeEach((): void => { - vi.resetAllMocks(); - global.fetch = vi.fn().mockResolvedValue({ - json: () => Promise.resolve(mockFlowResponse()), - ok: true, - }); - }); - - describe('verbose: true injection', (): void => { - it('injects verbose:true for a new flow start with applicationId and flowType', async (): Promise => { - await executeEmbeddedSignInFlowV2({ - payload: {applicationId: 'app-1', flowType: 'AUTHENTICATION'}, - url: URL, - }); - - expect(captureRequestBody()).toMatchObject({verbose: true}); - }); - - it('injects verbose:true for a new flow start that also includes scopes', async (): Promise => { - await executeEmbeddedSignInFlowV2({ - payload: {applicationId: 'app-1', flowType: 'AUTHENTICATION', scopes: ['openid', 'profile']}, - url: URL, - }); - - const body = captureRequestBody(); - expect(body).toMatchObject({verbose: true, inputs: {requested_permissions: 'openid profile'}}); - expect(body).not.toHaveProperty('scopes'); - }); - - it('injects verbose:true for a bare flow resumption (executionId only)', async (): Promise => { - await executeEmbeddedSignInFlowV2({ - payload: {executionId: 'exec-abc'}, - url: URL, - }); - - expect(captureRequestBody()).toMatchObject({verbose: true}); - }); - - it('does NOT inject verbose:true for a step submission (executionId + inputs)', async (): Promise => { - await executeEmbeddedSignInFlowV2({ - payload: {action: 'submit', executionId: 'exec-abc', inputs: {password: 'secret', username: 'user'}}, - url: URL, - }); - - expect(captureRequestBody()).not.toHaveProperty('verbose'); - }); - - it('strips a user-supplied verbose before applying internal logic', async (): Promise => { - await executeEmbeddedSignInFlowV2({ - payload: {action: 'submit', executionId: 'exec-abc', inputs: {}, verbose: false}, - url: URL, - }); - - expect(captureRequestBody()).not.toHaveProperty('verbose'); - }); - - it('strips user-supplied verbose:true from step submissions', async (): Promise => { - await executeEmbeddedSignInFlowV2({ - payload: {action: 'submit', executionId: 'exec-abc', inputs: {}, verbose: true}, - url: URL, - }); - - expect(captureRequestBody()).not.toHaveProperty('verbose'); - }); - }); - - describe('scopes โ†’ inputs.requested_permissions translation', (): void => { - it('translates scopes to a space-separated inputs.requested_permissions string', async (): Promise => { - await executeEmbeddedSignInFlowV2({ - payload: {applicationId: 'app-1', flowType: 'AUTHENTICATION', scopes: ['openid', 'profile', 'email']}, - url: URL, - }); - - const body = captureRequestBody(); - expect(body).toMatchObject({inputs: {requested_permissions: 'openid profile email'}}); - expect(body).not.toHaveProperty('scopes'); - }); - - it('does not add requested_permissions when scopes is absent', async (): Promise => { - await executeEmbeddedSignInFlowV2({ - payload: {applicationId: 'app-1', flowType: 'AUTHENTICATION'}, - url: URL, - }); - - expect(captureRequestBody()).not.toHaveProperty('inputs'); - }); - - it('does not add requested_permissions when scopes is an empty array', async (): Promise => { - await executeEmbeddedSignInFlowV2({ - payload: {applicationId: 'app-1', flowType: 'AUTHENTICATION', scopes: []}, - url: URL, - }); - - const body = captureRequestBody(); - expect(body).not.toHaveProperty('scopes'); - expect(body).not.toHaveProperty('inputs'); - }); - }); - - it('throws when payload is missing', async (): Promise => { - await expect(executeEmbeddedSignInFlowV2({url: URL})).rejects.toThrow('Authorization payload is required'); - }); - - it('uses baseUrl to construct the endpoint when url is not provided', async (): Promise => { - await executeEmbeddedSignInFlowV2({ - baseUrl: 'https://localhost:8090', - payload: {applicationId: 'app-1', flowType: 'AUTHENTICATION'}, - }); - - expect(fetch).toHaveBeenCalledWith('https://localhost:8090/flow/execute', expect.any(Object)); - }); -}); diff --git a/packages/javascript/src/api/v2/__tests__/executeEmbeddedSignUpFlowV2.test.ts b/packages/javascript/src/api/v2/__tests__/executeEmbeddedSignUpFlowV2.test.ts deleted file mode 100644 index 4433456..0000000 --- a/packages/javascript/src/api/v2/__tests__/executeEmbeddedSignUpFlowV2.test.ts +++ /dev/null @@ -1,149 +0,0 @@ -/** - * 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 {beforeEach, describe, expect, it, vi} from 'vitest'; -import {EmbeddedSignUpFlowResponse, EmbeddedSignUpFlowStatus} from '../../../models/v2/embedded-signup-flow-v2'; -import executeEmbeddedSignUpFlowV2 from '../executeEmbeddedSignUpFlowV2'; - -const URL = 'https://localhost:8090/flow/execute'; - -const mockFlowResponse = (overrides: Partial = {}): EmbeddedSignUpFlowResponse => - ({ - flowStatus: EmbeddedSignUpFlowStatus.Incomplete, - ...overrides, - }) as EmbeddedSignUpFlowResponse; - -const captureRequestBody = (): Record => { - const calls = (fetch as ReturnType).mock.calls; - const requestInit = calls[calls.length - 1][1] as RequestInit; - return JSON.parse(requestInit.body as string) as Record; -}; - -describe('executeEmbeddedSignUpFlowV2', (): void => { - beforeEach((): void => { - vi.resetAllMocks(); - global.fetch = vi.fn().mockResolvedValue({ - json: () => Promise.resolve(mockFlowResponse()), - ok: true, - }); - }); - - describe('verbose: true injection', (): void => { - it('injects verbose:true for a new flow start with applicationId and flowType', async (): Promise => { - await executeEmbeddedSignUpFlowV2({ - payload: {applicationId: 'app-1', flowType: 'REGISTRATION'}, - url: URL, - }); - - expect(captureRequestBody()).toMatchObject({verbose: true}); - }); - - it('injects verbose:true for a new flow start that also includes scopes', async (): Promise => { - await executeEmbeddedSignUpFlowV2({ - payload: {applicationId: 'app-1', flowType: 'REGISTRATION', scopes: ['openid', 'profile']}, - url: URL, - }); - - const body = captureRequestBody(); - expect(body).toMatchObject({verbose: true, inputs: {requested_permissions: 'openid profile'}}); - expect(body).not.toHaveProperty('scopes'); - }); - - it('injects verbose:true for a bare flow resumption (executionId only)', async (): Promise => { - await executeEmbeddedSignUpFlowV2({ - payload: {executionId: 'exec-abc'}, - url: URL, - }); - - expect(captureRequestBody()).toMatchObject({verbose: true}); - }); - - it('does NOT inject verbose:true for a step submission (executionId + inputs)', async (): Promise => { - await executeEmbeddedSignUpFlowV2({ - payload: {action: 'submit', executionId: 'exec-abc', inputs: {email: 'user@example.com'}}, - url: URL, - }); - - expect(captureRequestBody()).not.toHaveProperty('verbose'); - }); - - it('strips a user-supplied verbose before applying internal logic', async (): Promise => { - await executeEmbeddedSignUpFlowV2({ - payload: {action: 'submit', executionId: 'exec-abc', inputs: {}, verbose: false}, - url: URL, - }); - - expect(captureRequestBody()).not.toHaveProperty('verbose'); - }); - - it('strips user-supplied verbose:true from step submissions', async (): Promise => { - await executeEmbeddedSignUpFlowV2({ - payload: {action: 'submit', executionId: 'exec-abc', inputs: {}, verbose: true}, - url: URL, - }); - - expect(captureRequestBody()).not.toHaveProperty('verbose'); - }); - }); - - describe('scopes โ†’ inputs.requested_permissions translation', (): void => { - it('translates scopes to a space-separated inputs.requested_permissions string', async (): Promise => { - await executeEmbeddedSignUpFlowV2({ - payload: {applicationId: 'app-1', flowType: 'REGISTRATION', scopes: ['openid', 'profile', 'email']}, - url: URL, - }); - - const body = captureRequestBody(); - expect(body).toMatchObject({inputs: {requested_permissions: 'openid profile email'}}); - expect(body).not.toHaveProperty('scopes'); - }); - - it('does not add requested_permissions when scopes is absent', async (): Promise => { - await executeEmbeddedSignUpFlowV2({ - payload: {applicationId: 'app-1', flowType: 'REGISTRATION'}, - url: URL, - }); - - expect(captureRequestBody()).not.toHaveProperty('inputs'); - }); - - it('does not add requested_permissions when scopes is an empty array', async (): Promise => { - await executeEmbeddedSignUpFlowV2({ - payload: {applicationId: 'app-1', flowType: 'REGISTRATION', scopes: []}, - url: URL, - }); - - const body = captureRequestBody(); - expect(body).not.toHaveProperty('scopes'); - expect(body).not.toHaveProperty('inputs'); - }); - }); - - it('throws when payload is missing', async (): Promise => { - await expect(executeEmbeddedSignUpFlowV2({url: URL})).rejects.toThrow('Registration payload is required'); - }); - - it('uses baseUrl to construct the endpoint when url is not provided', async (): Promise => { - await executeEmbeddedSignUpFlowV2({ - baseUrl: 'https://localhost:8090', - payload: {applicationId: 'app-1', flowType: 'REGISTRATION'}, - }); - - expect(fetch).toHaveBeenCalledWith('https://localhost:8090/flow/execute', expect.any(Object)); - }); -}); diff --git a/packages/javascript/src/api/v2/executeEmbeddedSignInFlowV2.ts b/packages/javascript/src/api/v2/executeEmbeddedSignInFlowV2.ts deleted file mode 100644 index ccc819b..0000000 --- a/packages/javascript/src/api/v2/executeEmbeddedSignInFlowV2.ts +++ /dev/null @@ -1,156 +0,0 @@ -/** - * Copyright (c) 2025-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 ThunderIDAPIError from '../../errors/ThunderIDAPIError'; -import {EmbeddedFlowExecuteRequestConfig as EmbeddedFlowExecuteRequestConfigV2} from '../../models/v2/embedded-flow-v2'; -import { - EmbeddedSignInFlowResponse as EmbeddedSignInFlowResponseV2, - EmbeddedSignInFlowStatus as EmbeddedSignInFlowStatusV2, -} from '../../models/v2/embedded-signin-flow-v2'; -import injectRequestedPermissions from '../../utils/v2/injectRequestedPermissions'; - -const executeEmbeddedSignInFlowV2 = async ({ - url, - baseUrl, - payload, - authId, - ...requestConfig -}: EmbeddedFlowExecuteRequestConfigV2): Promise => { - if (!payload) { - throw new ThunderIDAPIError( - 'Authorization payload is required', - 'executeEmbeddedSignInFlow-ValidationError-002', - 'javascript', - 400, - 'If an authorization 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]: [string, unknown]) => key !== 'verbose')) - : payload; - - // `verbose: true` is required to get the `meta` field in the response that includes component details. - // Add verbose:true if: - // 1. payload contains applicationId and flowType (new flow start; may also carry scopes or other init params) - // 2. payload contains only executionId (flow resumption without step data) - const isNewFlowStart: boolean = - typeof cleanPayload === 'object' && - cleanPayload !== null && - 'applicationId' in cleanPayload && - 'flowType' in cleanPayload; - const hasOnlyFlowId: boolean = - typeof cleanPayload === 'object' && - cleanPayload !== null && - 'executionId' in cleanPayload && - Object.keys(cleanPayload).length === 1; - - const basePayload: Record = isNewFlowStart - ? injectRequestedPermissions(cleanPayload as Record) - : (cleanPayload as Record); - - const requestPayload: Record = - isNewFlowStart || hasOnlyFlowId ? {...basePayload, verbose: true} : basePayload; - - const response: Response = await fetch(endpoint, { - ...requestConfig, - body: JSON.stringify(requestPayload), - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - ...requestConfig.headers, - } as HeadersInit, - method: requestConfig.method || 'POST', - }); - - if (!response.ok) { - const errorText: string = await response.text(); - - throw new ThunderIDAPIError( - errorText, - 'executeEmbeddedSignInFlow-ResponseError-001', - 'javascript', - response.status, - response.statusText, - 'Authorization request failed', - ); - } - - const flowResponse: EmbeddedSignInFlowResponseV2 = await response.json(); - - // IMPORTANT: Only applicable for ThunderID V2 platform. - // Check if the flow is complete and has an assertion and authId is provided, then call OAuth2 auth callback. - if (flowResponse.flowStatus === EmbeddedSignInFlowStatusV2.Complete && flowResponse.assertion && authId) { - try { - const oauth2Response: Response = await fetch(`${baseUrl}/oauth2/auth/callback`, { - body: JSON.stringify({ - assertion: flowResponse.assertion, - authId, - }), - credentials: 'include', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - ...requestConfig.headers, - } as HeadersInit, - method: 'POST', - }); - - if (!oauth2Response.ok) { - const oauth2ErrorText: string = await oauth2Response.text(); - - throw new ThunderIDAPIError( - oauth2ErrorText, - 'executeEmbeddedSignInFlow-OAuth2Error-002', - 'javascript', - oauth2Response.status, - oauth2Response.statusText, - 'OAuth2 authorization failed', - ); - } - - const oauth2Result: Record = await oauth2Response.json(); - - return { - flowStatus: flowResponse.flowStatus, - redirectUrl: oauth2Result['redirect_uri'], - } as any; - } catch (authError) { - if (authError instanceof ThunderIDAPIError) { - throw authError; - } - - throw new ThunderIDAPIError( - authError instanceof Error ? authError.message : 'Unknown error', - 'executeEmbeddedSignInFlow-OAuth2Error-001', - 'javascript', - 500, - 'Failed to complete OAuth2 authorization after successful embedded sign-in flow.', - 'OAuth2 authorization failed', - ); - } - } - - return flowResponse; -}; - -export default executeEmbeddedSignInFlowV2; diff --git a/packages/javascript/src/api/v2/executeEmbeddedSignUpFlowV2.ts b/packages/javascript/src/api/v2/executeEmbeddedSignUpFlowV2.ts deleted file mode 100644 index 0763e81..0000000 --- a/packages/javascript/src/api/v2/executeEmbeddedSignUpFlowV2.ts +++ /dev/null @@ -1,156 +0,0 @@ -/** - * Copyright (c) 2025-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 ThunderIDAPIError from '../../errors/ThunderIDAPIError'; -import {EmbeddedFlowExecuteRequestConfig as EmbeddedFlowExecuteRequestConfigV2} from '../../models/v2/embedded-flow-v2'; -import { - EmbeddedSignUpFlowResponse as EmbeddedSignUpFlowResponseV2, - EmbeddedSignUpFlowStatus as EmbeddedSignUpFlowStatusV2, -} from '../../models/v2/embedded-signup-flow-v2'; -import injectRequestedPermissions from '../../utils/v2/injectRequestedPermissions'; - -const executeEmbeddedSignUpFlowV2 = async ({ - url, - baseUrl, - payload, - authId, - ...requestConfig -}: EmbeddedFlowExecuteRequestConfigV2): Promise => { - if (!payload) { - throw new ThunderIDAPIError( - 'Registration payload is required', - 'executeEmbeddedSignUpFlow-ValidationError-002', - 'javascript', - 400, - 'If a registration 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]: [string, unknown]) => key !== 'verbose')) - : payload; - - // `verbose: true` is required to get the `meta` field in the response that includes component details. - // Add verbose:true if: - // 1. payload contains applicationId and flowType (new flow start; may also carry scopes or other init params) - // 2. payload contains only executionId (flow resumption without step data) - const isNewFlowStart: boolean = - typeof cleanPayload === 'object' && - cleanPayload !== null && - 'applicationId' in cleanPayload && - 'flowType' in cleanPayload; - const hasOnlyFlowId: boolean = - typeof cleanPayload === 'object' && - cleanPayload !== null && - 'executionId' in cleanPayload && - Object.keys(cleanPayload).length === 1; - - const basePayload: Record = isNewFlowStart - ? injectRequestedPermissions(cleanPayload as Record) - : (cleanPayload as Record); - - const requestPayload: Record = - isNewFlowStart || hasOnlyFlowId ? {...basePayload, verbose: true} : basePayload; - - const response: Response = await fetch(endpoint, { - ...requestConfig, - body: JSON.stringify(requestPayload), - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - ...requestConfig.headers, - } as HeadersInit, - method: requestConfig.method || 'POST', - }); - - if (!response.ok) { - const errorText: string = await response.text(); - - throw new ThunderIDAPIError( - errorText, - 'executeEmbeddedSignUpFlow-ResponseError-001', - 'javascript', - response.status, - response.statusText, - 'Registration request failed', - ); - } - - const flowResponse: EmbeddedSignUpFlowResponseV2 = await response.json(); - - // IMPORTANT: Only applicable for ThunderID V2 platform. - // Check if the flow is complete and has an assertion and authId is provided, then call OAuth2 auth callback. - if (flowResponse.flowStatus === EmbeddedSignUpFlowStatusV2.Complete && (flowResponse as any).assertion && authId) { - try { - const oauth2Response: Response = await fetch(`${baseUrl}/oauth2/auth/callback`, { - body: JSON.stringify({ - assertion: (flowResponse as any).assertion, - authId, - }), - credentials: 'include', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - ...requestConfig.headers, - } as HeadersInit, - method: 'POST', - }); - - if (!oauth2Response.ok) { - const oauth2ErrorText: string = await oauth2Response.text(); - - throw new ThunderIDAPIError( - oauth2ErrorText, - 'executeEmbeddedSignUpFlow-OAuth2Error-002', - 'javascript', - oauth2Response.status, - oauth2Response.statusText, - 'OAuth2 authorization failed', - ); - } - - const oauth2Result: Record = await oauth2Response.json(); - - return { - flowStatus: flowResponse.flowStatus, - redirectUrl: oauth2Result['redirect_uri'], - } as any; - } catch (authError) { - if (authError instanceof ThunderIDAPIError) { - throw authError; - } - - throw new ThunderIDAPIError( - authError instanceof Error ? authError.message : 'Unknown error', - 'executeEmbeddedSignUpFlow-OAuth2Error-001', - 'javascript', - 500, - 'Failed to complete OAuth2 authorization after successful embedded sign-up flow.', - 'OAuth2 authorization failed', - ); - } - } - - return flowResponse; -}; - -export default executeEmbeddedSignUpFlowV2; diff --git a/packages/javascript/src/constants/v2/OIDCDiscoveryConstants.ts b/packages/javascript/src/constants/v2/OIDCDiscoveryConstants.ts deleted file mode 100644 index c8c82e9..0000000 --- a/packages/javascript/src/constants/v2/OIDCDiscoveryConstants.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** - * 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. - */ - -/** - * Constants related to OpenID Connect (OIDC) metadata and endpoints. - * This object contains all the standard OIDC endpoints and storage keys - * used throughout the application for authentication and authorization. - * - * @remarks - * The constants are organized into two main sections: - * 1. Endpoints - Contains all OIDC standard endpoint paths - * 2. Storage - Contains keys used for storing OIDC-related data - * - * @example - * ```typescript - * // Using an endpoint - * const wellKnownEndpoint = OIDCDiscoveryConstants.Endpoints.WELL_KNOWN; - * ``` - */ -const OIDCDiscoveryConstants: { - readonly Endpoints: { - readonly WELL_KNOWN: string; - }; -} = { - /** - * Collection of standard OIDC endpoint paths used for authentication flows. - * These endpoints are relative paths that should be appended to the base URL - * of your identity provider. - */ - Endpoints: { - /** - * OpenID Connect discovery document endpoint. - * Used to fetch provider metadata from the authorization server. - */ - WELL_KNOWN: '/.well-known/openid-configuration', - }, -} as const; - -export default OIDCDiscoveryConstants; diff --git a/packages/javascript/src/index.ts b/packages/javascript/src/index.ts index d38ea02..42f227e 100644 --- a/packages/javascript/src/index.ts +++ b/packages/javascript/src/index.ts @@ -18,9 +18,13 @@ export {IsomorphicCrypto} from './IsomorphicCrypto'; -export {default as initializeEmbeddedSignInFlow} from './api/initializeEmbeddedSignInFlow'; export {default as executeEmbeddedSignInFlow} from './api/executeEmbeddedSignInFlow'; export {default as executeEmbeddedSignUpFlow} from './api/executeEmbeddedSignUpFlow'; +export {default as executeEmbeddedRecoveryFlow} from './api/executeEmbeddedRecoveryFlow'; +export {default as executeEmbeddedUserOnboardingFlow} from './api/executeEmbeddedUserOnboardingFlow'; +export type {EmbeddedUserOnboardingFlowResponse} from './api/executeEmbeddedUserOnboardingFlow'; +export {default as getFlowMeta} from './api/getFlowMeta'; +export {default as getOrganizationUnitChildren} from './api/getOrganizationUnitChildren'; export {default as getUserInfo} from './api/getUserInfo'; export {default as getScim2Me} from './api/getScim2Me'; export type {GetScim2MeConfig} from './api/getScim2Me'; @@ -40,13 +44,6 @@ export {default as updateMeProfile} from './api/updateMeProfile'; export type {UpdateMeProfileConfig} from './api/updateMeProfile'; export {default as getBrandingPreference} from './api/getBrandingPreference'; export type {GetBrandingPreferenceConfig} from './api/getBrandingPreference'; -export {default as executeEmbeddedSignInFlowV2} from './api/v2/executeEmbeddedSignInFlowV2'; -export {default as executeEmbeddedSignUpFlowV2} from './api/v2/executeEmbeddedSignUpFlowV2'; -export {default as executeEmbeddedRecoveryFlowV2} from './api/v2/executeEmbeddedRecoveryFlowV2'; -export {default as executeEmbeddedUserOnboardingFlowV2} from './api/v2/executeEmbeddedUserOnboardingFlowV2'; -export type {EmbeddedUserOnboardingFlowResponse} from './api/v2/executeEmbeddedUserOnboardingFlowV2'; -export {default as getFlowMetaV2} from './api/v2/getFlowMetaV2'; -export {default as getOrganizationUnitChildren} from './api/v2/getOrganizationUnitChildren'; export {default as ApplicationNativeAuthenticationConstants} from './constants/ApplicationNativeAuthenticationConstants'; export {default as TokenConstants} from './constants/TokenConstants'; @@ -61,82 +58,57 @@ export {ThunderIDAuthException} from './errors/exception'; export type {CIBAInitiateOptions, CIBAInitiateResponse, CIBAErrorCode, CIBAPollOptions} from './models/ciba'; export type {AllOrganizationsApiResponse} from './models/organization'; -export {Platform} from './models/platforms'; export { - EmbeddedSignInFlowStatus, - EmbeddedSignInFlowType, - EmbeddedSignInFlowStepType, - EmbeddedSignInFlowAuthenticatorParamType, - EmbeddedSignInFlowAuthenticatorPromptType, - EmbeddedSignInFlowAuthenticatorKnownIdPType, -} from './models/embedded-signin-flow'; -export type { - EmbeddedSignInFlowInitiateResponse, - EmbeddedSignInFlowAuthenticator, - EmbeddedSignInFlowLink, - EmbeddedSignInFlowHandleRequestPayload, - EmbeddedSignInFlowHandleResponse, -} from './models/embedded-signin-flow'; -export { - EmbeddedFlowComponentType as EmbeddedFlowComponentTypeV2, - EmbeddedFlowActionVariant as EmbeddedFlowActionVariantV2, - EmbeddedFlowTextVariant as EmbeddedFlowTextVariantV2, - EmbeddedFlowEventType as EmbeddedFlowEventTypeV2, -} from './models/v2/embedded-flow-v2'; + EmbeddedFlowComponentType, + EmbeddedFlowActionVariant, + EmbeddedFlowTextVariant, + EmbeddedFlowEventType, +} from './models/embedded-flow'; export type { - EmbeddedFlowComponent as EmbeddedFlowComponentV2, - EmbeddedFlowResponseData as EmbeddedFlowResponseDataV2, - EmbeddedFlowExecuteRequestConfig as EmbeddedFlowExecuteRequestConfigV2, + EmbeddedFlowComponent, + EmbeddedFlowResponseData, + EmbeddedFlowExecuteRequestConfig, FlowExecutionError, - ConsentAttributeElement as ConsentAttributeElementV2, - ConsentPurposeDecision as ConsentPurposeDecisionV2, - ConsentDecisions as ConsentDecisionsV2, - ConsentPurposeData as ConsentPurposeDataV2, - ConsentPromptData as ConsentPromptDataV2, + ConsentAttributeElement, + ConsentPurposeDecision, + ConsentDecisions, + ConsentPurposeData, + ConsentPromptData, I18nMessage, - ValidationRule as ValidationRuleV2, - ValidationRuleType as ValidationRuleTypeV2, - FieldError as FieldErrorV2, -} from './models/v2/embedded-flow-v2'; -export { - EmbeddedSignInFlowStatus as EmbeddedSignInFlowStatusV2, - EmbeddedSignInFlowType as EmbeddedSignInFlowTypeV2, -} from './models/v2/embedded-signin-flow-v2'; + ValidationRule, + ValidationRuleType, + FieldError, +} from './models/embedded-flow'; +export {EmbeddedSignInFlowStatus, EmbeddedSignInFlowType} from './models/embedded-signin-flow'; export type { - ExtendedEmbeddedSignInFlowResponse as ExtendedEmbeddedSignInFlowResponseV2, - EmbeddedSignInFlowResponse as EmbeddedSignInFlowResponseV2, - EmbeddedSignInFlowCompleteResponse as EmbeddedSignInFlowCompleteResponseV2, - EmbeddedSignInFlowInitiateRequest as EmbeddedSignInFlowInitiateRequestV2, - EmbeddedSignInFlowRequest as EmbeddedSignInFlowRequestV2, -} from './models/v2/embedded-signin-flow-v2'; -export { - EmbeddedSignUpFlowStatus as EmbeddedSignUpFlowStatusV2, - EmbeddedSignUpFlowType as EmbeddedSignUpFlowTypeV2, -} from './models/v2/embedded-signup-flow-v2'; + ExtendedEmbeddedSignInFlowResponse, + EmbeddedSignInFlowResponse, + EmbeddedSignInFlowCompleteResponse, + EmbeddedSignInFlowInitiateRequest, + EmbeddedSignInFlowRequest, +} from './models/embedded-signin-flow'; +export {EmbeddedSignUpFlowStatus, EmbeddedSignUpFlowType} from './models/embedded-signup-flow'; export type { - ExtendedEmbeddedSignUpFlowResponse as ExtendedEmbeddedSignUpFlowResponseV2, - EmbeddedSignUpFlowResponse as EmbeddedSignUpFlowResponseV2, - EmbeddedSignUpFlowCompleteResponse as EmbeddedSignUpFlowCompleteResponseV2, - EmbeddedSignUpFlowInitiateRequest as EmbeddedSignUpFlowInitiateRequestV2, - EmbeddedSignUpFlowRequest as EmbeddedSignUpFlowRequestV2, - EmbeddedSignUpFlowErrorResponse as EmbeddedSignUpFlowErrorResponseV2, -} from './models/v2/embedded-signup-flow-v2'; -export { - EmbeddedRecoveryFlowStatus as EmbeddedRecoveryFlowStatusV2, - EmbeddedRecoveryFlowType as EmbeddedRecoveryFlowTypeV2, -} from './models/v2/embedded-recovery-flow-v2'; + ExtendedEmbeddedSignUpFlowResponse, + EmbeddedSignUpFlowResponse, + EmbeddedSignUpFlowCompleteResponse, + EmbeddedSignUpFlowInitiateRequest, + EmbeddedSignUpFlowRequest, + EmbeddedSignUpFlowErrorResponse, +} from './models/embedded-signup-flow'; +export {EmbeddedRecoveryFlowStatus, EmbeddedRecoveryFlowType} from './models/embedded-recovery-flow'; export type { - EmbeddedRecoveryFlowResponse as EmbeddedRecoveryFlowResponseV2, - EmbeddedRecoveryFlowInitiateRequest as EmbeddedRecoveryFlowInitiateRequestV2, - EmbeddedRecoveryFlowRequest as EmbeddedRecoveryFlowRequestV2, - EmbeddedRecoveryFlowErrorResponse as EmbeddedRecoveryFlowErrorResponseV2, -} from './models/v2/embedded-recovery-flow-v2'; + EmbeddedRecoveryFlowResponse, + EmbeddedRecoveryFlowInitiateRequest, + EmbeddedRecoveryFlowRequest, + EmbeddedRecoveryFlowErrorResponse, +} from './models/embedded-recovery-flow'; export type { OrganizationUnit, OrganizationUnitListResponse, GetOrganizationUnitChildrenConfig, -} from './models/v2/organization-unit'; -export {FlowMetaType} from './models/v2/flow-meta-v2'; +} from './models/organization-unit'; +export {FlowMetaType} from './models/flow-meta'; export type { ApplicationMetadata, OUMetadata, @@ -152,21 +124,8 @@ export type { FlowMetaThemeColorScheme, FlowMetaThemeShape, FlowMetaThemeTypography, -} from './models/v2/flow-meta-v2'; -export { - EmbeddedFlowType, - EmbeddedFlowStatus, - EmbeddedFlowResponseType, - EmbeddedFlowComponentType, -} from './models/embedded-flow'; -export type { - EmbeddedFlowExecuteResponse, - EmbeddedSignUpFlowData, - EmbeddedFlowComponent, - EmbeddedFlowExecuteRequestPayload, - EmbeddedFlowExecuteRequestConfig, - EmbeddedFlowExecuteErrorResponse, -} from './models/embedded-flow'; +} from './models/flow-meta'; +export {EmbeddedFlowType, EmbeddedFlowResponseType} from './models/embedded-flow'; export {FlowMode} from './models/flow'; export type {ThunderIDClient} from './models/client'; export type { @@ -190,7 +149,7 @@ export type { SignUpOptions, } from './models/config'; export type {TokenEndpointAuthMethod} from './models/token-endpoint-auth'; -export type {ComponentRenderContext, ComponentRenderer, ComponentsExtensions} from './models/v2/extensions/components'; +export type {ComponentRenderContext, ComponentRenderer, ComponentsExtensions} from './models/extensions/components'; export type {TokenResponse, IdToken, TokenExchangeRequestConfig} from './models/token'; export type {AgentConfig} from './models/agent'; export type {AuthCodeResponse} from './models/auth-code-response'; @@ -207,8 +166,8 @@ export type {Storage, TemporaryStore} from './models/store'; export type {User, UserProfile} from './models/user'; export type {SessionData} from './models/session'; export type {Organization} from './models/organization'; -export type {TranslationFn} from './models/v2/translation'; -export type {ResolveFlowTemplateLiteralsOptions} from './models/v2/vars'; +export type {TranslationFn} from './models/translation'; +export type {ResolveFlowTemplateLiteralsOptions} from './models/vars'; export type { BrandingPreference, BrandingPreferenceConfig, @@ -247,22 +206,20 @@ export {default as generateUserProfile} from './utils/generateUserProfile'; export {default as getLatestStateParam} from './utils/getLatestStateParam'; export {default as generateFlattenedUserProfile} from './utils/generateFlattenedUserProfile'; export {default as getRedirectBasedSignUpUrl} from './utils/getRedirectBasedSignUpUrl'; -export {default as identifyPlatform} from './utils/identifyPlatform'; export {default as isEmpty} from './utils/isEmpty'; -export {default as isEmojiUri, EMOJI_URI_SCHEME} from './utils/v2/isEmojiUri'; -export {default as extractEmojiFromUri} from './utils/v2/extractEmojiFromUri'; +export {default as isEmojiUri, EMOJI_URI_SCHEME} from './utils/isEmojiUri'; +export {default as extractEmojiFromUri} from './utils/extractEmojiFromUri'; export {default as set} from './utils/set'; export {default as get} from './utils/get'; export {default as removeTrailingSlash} from './utils/removeTrailingSlash'; -export {default as resolveFieldType} from './utils/resolveFieldType'; export {default as resolveFieldName} from './utils/resolveFieldName'; -export {default as resolveMeta} from './utils/v2/resolveMeta'; -export {default as resolveFlowTemplateLiterals} from './utils/v2/resolveFlowTemplateLiterals'; -export {default as countryCodeToFlagEmoji} from './utils/v2/countryCodeToFlagEmoji'; -export {default as resolveLocaleDisplayName} from './utils/v2/resolveLocaleDisplayName'; -export {default as resolveLocaleEmoji} from './utils/v2/resolveLocaleEmoji'; -export {default as buildValidatorFromRules} from './utils/v2/buildValidatorFromRules'; -export {default as evaluateValidationRule, DEFAULT_VALIDATION_MESSAGE_KEYS} from './utils/v2/evaluateValidationRule'; +export {default as resolveMeta} from './utils/resolveMeta'; +export {default as resolveFlowTemplateLiterals} from './utils/resolveFlowTemplateLiterals'; +export {default as countryCodeToFlagEmoji} from './utils/countryCodeToFlagEmoji'; +export {default as resolveLocaleDisplayName} from './utils/resolveLocaleDisplayName'; +export {default as resolveLocaleEmoji} from './utils/resolveLocaleEmoji'; +export {default as buildValidatorFromRules} from './utils/buildValidatorFromRules'; +export {default as evaluateValidationRule, DEFAULT_VALIDATION_MESSAGE_KEYS} from './utils/evaluateValidationRule'; export {default as processOpenIDScopes} from './utils/processOpenIDScopes'; export {default as withVendorCSSClassPrefix} from './utils/withVendorCSSClassPrefix'; export {default as transformBrandingPreferenceToTheme} from './utils/transformBrandingPreferenceToTheme'; diff --git a/packages/javascript/src/models/client.ts b/packages/javascript/src/models/client.ts index ad0958a..f65540c 100644 --- a/packages/javascript/src/models/client.ts +++ b/packages/javascript/src/models/client.ts @@ -18,12 +18,6 @@ import type {CIBAInitiateOptions, CIBAInitiateResponse, CIBAPollOptions} from './ciba'; import {SignInOptions, SignOutOptions, SignUpOptions} from './config'; -import { - EmbeddedFlowExecuteRequestConfig, - EmbeddedFlowExecuteRequestPayload, - EmbeddedFlowExecuteResponse, -} from './embedded-flow'; -import {EmbeddedSignInFlowHandleRequestPayload} from './embedded-signin-flow'; import {Organization, AllOrganizationsApiResponse} from './organization'; import {Storage} from './store'; import {TokenExchangeRequestConfig, TokenResponse} from './token'; @@ -160,13 +154,7 @@ export interface ThunderIDClient { */ reInitialize(config: Partial): Promise; - /** - * Initiates an embedded recovery flow for the user (e.g. password reset). - * - * @param payload - The payload containing the necessary information to execute the embedded recovery flow. - * @returns A promise that resolves to an EmbeddedFlowExecuteResponse containing the flow execution details. - */ - recover(payload: EmbeddedFlowExecuteRequestPayload): Promise; + recover(): Promise; /** * Sets the session data for the specified session ID. @@ -189,22 +177,6 @@ export interface ThunderIDClient { onSignInSuccess?: (afterSignInUrl: string) => void, ): Promise; - /** - * Initiates an embedded (App-Native) sign-in flow for the user. - * - * @param payload - The payload containing the necessary information to execute the embedded sign-in flow. - * @param request - The request object containing URL and parameters for the sign-in flow HTTP request. - * @param sessionId - Optional session ID to be used for sign-in. - * @param onSignInSuccess - Callback function to be executed upon successful sign-in. - * @returns A promise that resolves to an EmbeddedFlowExecuteResponse containing the flow execution details. - */ - signIn( - payload: EmbeddedSignInFlowHandleRequestPayload, - request: EmbeddedFlowExecuteRequestConfig, - sessionId?: string, - onSignInSuccess?: (afterSignInUrl: string) => void, - ): Promise; - /** * Try signing in silently in the background without any user interactions. * @@ -241,21 +213,13 @@ export interface ThunderIDClient { ): Promise; /** - * Initiates a redirection-based sign-up process for the user. + * Initiates the sign-up process for the user. * * @param options - Optional sign-up options like additional parameters to be sent in the sign-up request, etc. - * @returns Promise resolving to the user upon successful sign up. + * @returns Promise resolving when sign-up is complete. */ signUp(options?: SignUpOptions): Promise; - /** - * Initiates an embedded (App-Native) sign-up flow for the user. - * - * @param payload - The payload containing the necessary information to execute the embedded sign-up flow. - * @returns A promise that resolves to an EmbeddedFlowExecuteResponse containing the flow execution details. - */ - signUp(payload: EmbeddedFlowExecuteRequestPayload): Promise; - /** * Switches the current organization to the specified one. * @param organization - The organization to switch to. diff --git a/packages/javascript/src/models/config.ts b/packages/javascript/src/models/config.ts index 5735bbd..51a5522 100644 --- a/packages/javascript/src/models/config.ts +++ b/packages/javascript/src/models/config.ts @@ -16,11 +16,11 @@ * under the License. */ +import {ComponentsExtensions} from './extensions/components'; import type {OAuthResponseMode} from './oauth-response'; import type {OIDCEndpoints} from './oidc-endpoints'; import {TokenEndpointAuthMethod} from './token-endpoint-auth'; import {RecursivePartial} from './utility-types'; -import {ComponentsExtensions} from './v2/extensions/components'; import {I18nBundle} from '../i18n/models/i18n'; import {ThemeConfig, ThemeMode} from '../theme/types'; diff --git a/packages/javascript/src/models/embedded-flow.ts b/packages/javascript/src/models/embedded-flow.ts index 9ba5966..f2541a0 100644 --- a/packages/javascript/src/models/embedded-flow.ts +++ b/packages/javascript/src/models/embedded-flow.ts @@ -23,154 +23,716 @@ export enum EmbeddedFlowType { UserOnboarding = 'USER_ONBOARDING', } -export interface EmbeddedFlowExecuteRequestPayload { - actionId?: string; - flowType: EmbeddedFlowType; - inputs?: Record; +export enum EmbeddedFlowResponseType { + Redirection = 'REDIRECTION', + View = 'VIEW', } -export interface EmbeddedFlowExecuteResponse { - data: EmbeddedSignUpFlowData; - flowId: string; - flowStatus: EmbeddedFlowStatus; - type: EmbeddedFlowResponseType; +/** + * Base request configuration for executing embedded flow operations. + * + * @template T - Type of the payload data being sent with the request + */ +export interface EmbeddedFlowExecuteRequestConfigBase extends Partial { + baseUrl?: string; + payload?: T; + url?: string; } -export enum EmbeddedFlowStatus { - Complete = 'COMPLETE', - Incomplete = 'INCOMPLETE', +/** + * Internationalized message structure returned by the backend. + * + * The `defaultValue` field carries the untranslated fallback text. + */ +export interface I18nMessage { + defaultValue?: string; + key: string; } -export enum EmbeddedFlowResponseType { - Redirection = 'REDIRECTION', - View = 'VIEW', +/** + * Structured error returned in a flow response when flowStatus is ERROR. + */ +export interface FlowExecutionError { + code: string; + description: I18nMessage; + message: I18nMessage; } -export interface EmbeddedSignUpFlowData { - additionalData?: Record; - components?: EmbeddedFlowComponent[]; - redirectURL?: string; +/** + * Component types supported by the ThunderID embedded flow API. + * + * These types define the different UI components that can be rendered + * as part of the embedded authentication flows. Each type corresponds + * to a specific UI element with its own behavior and properties. + * + * @example + * ```typescript + * // Check component type to render appropriate UI + * if (component.type === EmbeddedFlowComponentType.TextInput) { + * // Render text input field + * } else if (component.type === EmbeddedFlowComponentType.Action) { + * // Render button/action + * } + * ``` + * + * @experimental This API may change in future versions + */ +export enum EmbeddedFlowComponentType { + /** Interactive action component (buttons, links) for user interactions */ + Action = 'ACTION', + + /** Container block component that groups other components */ + Block = 'BLOCK', + + /** Consent component for displaying consent purposes and attributes */ + Consent = 'CONSENT', + + /** Copyable text display component that shows text with a copy-to-clipboard action */ + CopyableText = 'COPYABLE_TEXT', + + /** Date input field for selecting a calendar date */ + DateInput = 'DATE_INPUT', + + /** Divider component for visual separation of content */ + Divider = 'DIVIDER', + + /** Email input field with validation for email addresses. */ + EmailInput = 'EMAIL_INPUT', + + /** Icon display component for rendering named vector icons */ + Icon = 'ICON', + + /** Image display component for logos and illustrations */ + Image = 'IMAGE', + + /** One-time password input field for multi-factor authentication */ + OtpInput = 'OTP_INPUT', + + /** Organization unit tree picker for selecting an OU */ + OuSelect = 'OU_SELECT', + + /** Password input field with masking for sensitive data */ + PasswordInput = 'PASSWORD_INPUT', + + /** Phone number input field with country code support */ + PhoneInput = 'PHONE_INPUT', + + /** Rich text display component that renders formatted HTML content */ + RichText = 'RICH_TEXT', + + /** Select/dropdown input component for single choice selection */ + Select = 'SELECT', + + /** Stack layout component for arranging children in a row or column */ + Stack = 'STACK', + + /** Text display component for labels, headings, and messages */ + Text = 'TEXT', + + /** Standard text input field for user data entry */ + TextInput = 'TEXT_INPUT', + + /** Timer component for displaying a countdown */ + Timer = 'TIMER', + + /** QR code display component for wallet-based flows (e.g. OpenID4VP) */ + QrCode = 'QR_CODE', } +/** + * Action variant types for buttons and interactive elements. + * + * @experimental This API may change in future versions + */ +export enum EmbeddedFlowActionVariant { + /** Danger action button for destructive operations */ + Danger = 'DANGER', + + /** Info action button for informational purposes */ + Info = 'INFO', + + /** Link-styled action button */ + Link = 'LINK', + + /** Outlined action button for secondary emphasis */ + Outlined = 'OUTLINED', + + /** Primary action button with highest visual emphasis */ + Primary = 'PRIMARY', + + /** Secondary action button with moderate visual emphasis */ + Secondary = 'SECONDARY', + + /** Success action button for positive confirmations */ + Success = 'SUCCESS', + + /** Tertiary action button with minimal visual emphasis */ + Tertiary = 'TERTIARY', + + /** Warning action button for cautionary actions */ + Warning = 'WARNING', +} + +/** + * Text variant types for typography components. + * + * @experimental This API may change in future versions + */ +export enum EmbeddedFlowTextVariant { + /** Primary body text for main content */ + Body1 = 'BODY_1', + + /** Secondary body text for supplementary content */ + Body2 = 'BODY_2', + + /** Text styled for button labels */ + ButtonText = 'BUTTON_TEXT', + + /** Small caption text for annotations and descriptions */ + Caption = 'CAPTION', + + /** Largest heading level for main titles */ + Heading1 = 'HEADING_1', + + /** Second level heading for major sections */ + Heading2 = 'HEADING_2', + + /** Third level heading for subsections */ + Heading3 = 'HEADING_3', + + /** Fourth level heading for minor sections */ + Heading4 = 'HEADING_4', + + /** Fifth level heading for detailed sections */ + Heading5 = 'HEADING_5', + + /** Smallest heading level for fine-grained sections */ + Heading6 = 'HEADING_6', + + /** Overline text for labels and categories */ + Overline = 'OVERLINE', + + /** Primary subtitle text with larger emphasis */ + Subtitle1 = 'SUBTITLE_1', + + /** Secondary subtitle text with moderate emphasis */ + Subtitle2 = 'SUBTITLE_2', +} + +/** + * Event types for action components. + * + * @experimental This API may change in future versions + */ +export enum EmbeddedFlowEventType { + /** Navigate back to the previous step */ + Back = 'BACK', + + /** Cancel the current operation */ + Cancel = 'CANCEL', + + /** Navigate to a different flow step or page */ + Navigate = 'NAVIGATE', + + /** Reset form fields to initial state */ + Reset = 'RESET', + + /** Submit form data to the server */ + Submit = 'SUBMIT', + + /** Trigger an action or event */ + Trigger = 'TRIGGER', +} + +/** + * Enhanced component interface for embedded flow components. + * + * This interface provides better support for modern form handling and user experience. + * It includes properties for labels, placeholders, and required field validation + * that are directly provided by the API response. + * + * @example + * ```typescript + * const component: EmbeddedFlowComponent = { + * id: 'username_field', + * type: EmbeddedFlowComponentType.TextInput, + * label: 'Username', + * placeholder: 'Enter your username', + * required: true, + * variant: 'TEXT', + * eventType: 'SUBMIT', + * components: [] + * }; + * ``` + * + * @experimental This interface may change in future versions + */ export interface EmbeddedFlowComponent { - components: EmbeddedFlowComponent[]; - config: Record; + /** + * Alignment of children along the cross axis (for Stack components). + */ + align?: string; + + /** + * Alternative text for Image components. + */ + alt?: string; + + /** + * Icon color, CSS color value (for Icon components). + */ + color?: string; + + /** + * Nested child components for container components like Block and Stack. + */ + components?: EmbeddedFlowComponent[]; + + /** + * Display format hint for DateInput components (e.g., 'yyyy-MM-dd'). Used as the + * placeholder rendered by the date picker primitive. Pattern-level validation is + * declared separately via a `regex` rule in the `validation` array. + */ + dateFormat?: string; + + /** + * Layout direction for Stack components ('row' | 'column'). + */ + direction?: string; + + /** + * Icon to render at the end of an Action button (URL string). + */ + endIcon?: string; + + /** + * Event type for action components that defines the interaction behavior. + * Only relevant for Action components. + */ + eventType?: EmbeddedFlowEventType | string; + + /** + * Gap between children in Stack components (number, maps to spacing units). + */ + gap?: number; + + /** + * Height of the component (for Image components, can be string with units or number for pixels). + * The value depends on the component type (e.g., for Image components). + */ + height?: string | number; + + /** + * Legacy flat configuration bag for V1 adapter-based components. + */ + config?: Record; + + /** + * Unique identifier for the component + */ id: string; + + /** + * Number of items across the main axis (for Stack grid-like layouts). + */ + items?: string | number; + + /** + * Justification of children along the main axis (for Stack components). + */ + justify?: string; + + /** + * Display label for the component (e.g., field label, button text). + * Supports internationalization and may contain template strings. + */ + label?: string; + + /** + * Icon name for Icon components (e.g., lucide-react icon names like 'ArrowLeftRight'). + */ + name?: string; + + /** + * Options for SELECT components. + * Each option can be a string value or an object with value and label. + */ + options?: (string | {label: string; value: string})[]; + + /** + * Placeholder text for input components. + * Provides helpful hints to users about expected input format. + */ + placeholder?: string; + + /** + * Reference identifier for the component (e.g., field name, action ref) + */ + ref?: string; + + /** + * Indicates whether this component represents a required field. + * Used for form validation and UI indicators. + */ + required?: boolean; + + /** + * Icon size in pixels (for Icon components). + */ + size?: number; + + /** + * Data source key for dynamic components (e.g., COPYABLE_TEXT). + * References a key in additionalData whose value is resolved at render time. + */ + source?: string; + + /** + * Image source URL (for Image components). + */ + src?: string; + + /** + * Icon to render at the start of an Action button (URL string). + */ + startIcon?: string; + + /** + * Component type that determines rendering behavior + */ type: EmbeddedFlowComponentType | string; - variant?: string; -} -export enum EmbeddedFlowComponentType { - Button = 'BUTTON', - Checkbox = 'CHECKBOX', - Divider = 'DIVIDER', - Form = 'FORM', - Image = 'IMAGE', - Input = 'INPUT', - Radio = 'RADIO', - Select = 'SELECT', - Typography = 'TYPOGRAPHY', + /** + * Declarative validation rules for input components. Evaluated client-side by the SDK + * (best-effort UX) before submission, and authoritatively re-evaluated server-side. + * Each rule represents exactly one constraint. + */ + validation?: ValidationRule[]; + + /** + * Component variant that affects visual styling and behavior. + * The value depends on the component type (e.g., button variants, text variants). + */ + variant?: EmbeddedFlowActionVariant | EmbeddedFlowTextVariant | string; + + /** + * Width of the component (for Image components, can be string with units or number for pixels). + * The value depends on the component type (e.g., for Image components). + */ + width?: string | number; } /** - * Request configuration for executing embedded flow operations. + * Supported validation rule types for `ValidationRule.type`. * - * This interface extends standard HTTP request configuration with additional - * properties specific to embedded flow execution, such as base URL and payload data. + * - `regex`: value must be a string regex pattern; the input must match. + * - `minLength`: value must be a number; input length must be >= value. + * - `maxLength`: value must be a number; input length must be <= value. * - * @template T - Type of the payload data being sent with the request + * @experimental Additional rule types (`oneOf`, `format`, ...) may be added later. */ -export interface EmbeddedFlowExecuteRequestConfig extends Partial { +export type ValidationRuleType = 'regex' | 'minLength' | 'maxLength'; + +/** + * A single-constraint validation rule attached to an input component. + * Mirrors the server-side `ValidationRule` returned by ThunderID. + * + * @experimental This interface may change in future versions + */ +export interface ValidationRule { /** - * Base URL for the API endpoint. - * This is typically the ThunderID organization URL. + * The constraint kind. Drives interpretation of `value` and the default fallback message. */ - baseUrl?: string; + type: ValidationRuleType; /** - * Payload data to be sent with the request. - * The structure depends on the specific flow operation being executed. + * The constraint parameter. String for `regex`, number for `minLength` / `maxLength`. */ - payload?: T; + value: string | number; /** - * Full URL for the API endpoint. - * If provided, this overrides the baseUrl. + * Optional message returned when this rule fails. May be an i18n key (e.g. + * `"{{i18n(validation:email.invalid)}}"`) or a literal string. The server passes + * this through unchanged; the SDK substitutes a default i18n key when omitted. */ - url?: string; + message?: string; } /** - * Error response structure for ThunderIDV1 embedded flow operations. + * A single validation failure for a specific input field returned by the server in + * `data.fieldErrors` when one or more rules fail. * - * This interface defines the structure of error responses returned by ThunderIDV1 APIs - * when flow operations (such as sign-up or sign-in) fail. + * @experimental This interface may change in future versions + */ +export interface FieldError { + /** The `identifier` of the input that failed validation. */ + identifier: string; + /** The failing rule's message (i18n key or literal string). */ + message: string; +} + +/** + * Response data structure for embedded flow API. * - * **Key Characteristics:** - * - Uses structured error codes (e.g., "FEE-60005") for programmatic error handling - * - Provides both a brief `message` and detailed `description` for context - * - Includes `flowType` to identify which flow operation failed + * This interface defines the structure of data returned by the API, + * which includes both legacy input/action arrays for backward compatibility + * and the new meta.components structure for modern component-driven UIs. * - * **Error Handling:** - * This error response format is automatically detected and processed by the - * `extractErrorMessage()` and `checkForErrorResponse()` functions in the React - * transformer to extract meaningful error messages for display to users. + * The key improvement is the meta.components field, which provides + * a rich component tree with proper labels, placeholders, and hierarchy + * that can be directly rendered without additional transformation. * * @example * ```typescript - * // Typical ThunderIDV1 error response - * const errorResponse: EmbeddedFlowExecuteErrorResponse = { - * code: "FEE-60005", - * message: "Error while provisioning user.", - * description: "Error occurred while provisioning user in the request of flow id: ac57315c-6ca6-49dc-8664-fcdcff354f46", - * flowType: "REGISTRATION" + * const response: EmbeddedFlowResponseData = { + * // Legacy format (for backward compatibility) + * inputs: [ + * { ref: 'input_001', identifier: 'username', type: 'TEXT_INPUT', required: true } + * ], + * actions: [ + * { ref: 'action_001', nextNode: 'basic_auth', eventType: 'SUBMIT' } + * ], + * // Modern format (recommended) + * meta: { + * components: [ + * { + * id: 'text_001', + * type: 'TEXT', + * label: '{{ t(signin:heading.label) }}', + * variant: 'HEADING_1' + * }, + * { + * id: 'block_001', + * type: 'BLOCK', + * components: [ + * { + * id: 'input_001', + * type: 'TEXT_INPUT', + * label: '{{ t(signin:fields.username.label) }}', + * placeholder: '{{ t(signin:fields.username.placeholder) }}', + * required: true + * }, + * { + * id: 'action_001', + * type: 'ACTION', + * label: '{{ t(signin:buttons.submit.label) }}', + * variant: 'PRIMARY', + * eventType: 'ACTIVATE' + * } + * ] + * } + * ] + * } * }; - * - * // The transformer will extract: "Error occurred while provisioning user in the request of flow id: ac57315c-6ca6-49dc-8664-fcdcff354f46" - * // (Prefers description over message as it's usually more detailed) * ``` * - * @see {@link EmbeddedSignUpFlowErrorResponse} for the ThunderIDV2 equivalent error structure + * @experimental This structure may change in future versions */ -export interface EmbeddedFlowExecuteErrorResponse { +export interface EmbeddedFlowResponseData { /** - * Structured error code identifying the type of error. - * - * Format typically follows pattern like "FEE-XXXXX" where: - * - "FEE" indicates Flow Execution Error - * - XXXXX is a numeric identifier for the specific error type - * - * @example "FEE-60005" - User provisioning error + * Legacy action definitions for backward compatibility. + * @deprecated Use meta.components for new implementations */ - code: string; + actions?: { + /** Event type for the action (SUBMIT, ACTIVATE, etc.) */ + eventType?: string; + /** Next flow node to navigate to (optional) */ + nextNode?: string; + /** Reference identifier for the action */ + ref: string; + }[]; /** - * Detailed error description with contextual information. - * - * This field usually contains more specific information about the error, - * including flow IDs, operation details, and other debugging context. - * The transformer prefers this field over `message` when extracting - * error messages for display to users. + * Additional data dictionary for dynamic flow response properties. + * Can be used to pass custom data like passkey challenges, server alerts, etc. + */ + additionalData?: Record; + + /** + * Per-field validation errors returned by the server when a submission fails one or + * more `validation` rules. Multiple failing rules on the same field appear as + * multiple entries, in the order the rules were declared. * - * @example "Error occurred while provisioning user in the request of flow id: ac57315c-6ca6-49dc-8664-fcdcff354f46" + * Present only on `INCOMPLETE` responses caused by validation failures; absent on + * successful submissions and on `INCOMPLETE` responses caused by missing required fields. */ - description: string; + fieldErrors?: FieldError[]; /** - * Type of flow operation that encountered the error. + * Legacy input definitions for backward compatibility. + * @deprecated Use meta.components for new implementations + */ + inputs?: { + /** Field identifier used in form submission */ + identifier: string; + /** Reference identifier for the input */ + ref: string; + /** Whether this input is required for form submission */ + required: boolean; + /** Input type (TEXT_INPUT, PASSWORD_INPUT, etc.) */ + type: string; + /** Server-side validation rules for the input (also returned for API-only customers). */ + validation?: ValidationRule[]; + }[]; + + /** + * Modern component-driven metadata structure. + * This contains the complete UI component tree with proper + * hierarchy, labels, and configuration that can be directly rendered. * - * Currently only supports 'REGISTRATION' but may be extended to - * include other flow types (e.g., 'LOGIN', 'PASSWORD_RESET') in the future. + * **This is the primary data source for implementations.** + * The legacy inputs/actions arrays are maintained only for backward compatibility. + */ + meta?: { + /** Array of components that define the complete UI structure */ + components: EmbeddedFlowComponent[]; + }; + + /** + * Optional redirect URL for flow completion or external authentication. + */ + redirectURL?: string; +} + +/** + * Discriminator identifying the kind of consent a purpose represents. The same + * `ConsentPurposeData` envelope is used for both attribute and permission consent; + * the populated fields differ based on this discriminator. + * + * @experimental This type may change in future versions + */ +export type ConsentPurposeType = 'attributes' | 'permissions'; + +/** + * Individual consent attribute/element decision. + * + * @experimental This interface may change in future versions + */ +export interface ConsentAttributeElement { + /** Whether the user approved collection of this attribute */ + approved: boolean; + /** The name of the attribute being consented */ + name: string; +} + +/** + * A single element presented for consent within a consent purpose. For attribute purposes + * the element is an attribute name. For permission purposes the element is a permission + * string and `parent` may carry rollup linkage supplied by the server: when set, the UI + * may render this permission as a child of `parent` and offer a single rollup toggle. + * + * @experimental This interface may change in future versions + */ +export interface PromptElement { + /** Canonical element name (attribute name or permission string) */ + name: string; + /** + * Canonical name of the rollup parent, permission-purpose only. Undefined for attribute + * elements and for top-level permissions. + */ + parent?: string; +} + +/** + * Consent decision for a single purpose. + * + * @experimental This interface may change in future versions + */ +export interface ConsentPurposeDecision { + /** Whether the user approved this purpose */ + approved: boolean; + /** Per-attribute decisions for this purpose */ + elements: ConsentAttributeElement[]; + /** The name of the consent purpose */ + purposeName: string; +} + +/** + * Full consent decisions structure sent to the backend when user submits the consent form. + * + * @experimental This interface may change in future versions + */ +export interface ConsentDecisions { + /** Array of per-purpose decisions */ + purposes: ConsentPurposeDecision[]; +} + +/** + * Single consent purpose data returned by the backend in additionalData.consent_prompt. + * The same envelope carries both attribute and permission purposes, distinguished by `type`. + * + * @experimental This interface may change in future versions + */ +export interface ConsentPurposeData { + /** Optional human-readable description of the purpose */ + description?: string; + /** + * Elements that are mandatory and cannot be declined. Used by attribute purposes; + * permission purposes today have no essential elements. + */ + essential: PromptElement[]; + /** + * Elements the user can opt in or out of. For attribute purposes these are optional + * attribute names. For permission purposes these are permission elements (which may + * carry rollup parent linkage). + */ + optional: PromptElement[]; + /** Unique identifier for the purpose */ + purposeId: string; + /** Human-readable purpose name */ + purposeName?: string; + /** + * Discriminator selecting between attribute and permission consent semantics. */ - flowType: 'REGISTRATION' | 'RECOVERY'; + type?: ConsentPurposeType; +} +/** + * Consent prompt data structure stored in additionalData.consent_prompt. + * + * @experimental This interface may change in future versions + */ +export interface ConsentPromptData { + /** Array of consent purposes requiring user review */ + purposes: ConsentPurposeData[]; +} + +/** + * Extended request configuration for ThunderID V2 embedded flow operations. + * + * This interface extends the base request configuration with V2-specific + * properties required for the enhanced embedded flow API. The authId parameter + * is particularly important for the V2 OAuth2 flow completion process. + * + * @template T The type of the payload data being sent with the request + * + * @example + * ```typescript + * const config: EmbeddedFlowExecuteRequestConfig = { + * baseUrl: 'https://localhost:8090', + * payload: { + * flowType: 'AUTHENTICATION', + * inputs: { username: 'user@example.com' } + * }, + * authId: 'auth_12345', // V2-specific for OAuth completion + * headers: { + * 'Authorization': 'Bearer token' + * } + * }; + * ``` + * + * @experimental This configuration is part of the new ThunderID V2 platform + */ +export interface EmbeddedFlowExecuteRequestConfig extends EmbeddedFlowExecuteRequestConfigBase { /** - * Brief error message describing what went wrong. + * Authentication ID used for OAuth2 flow completion in V2 API. * - * This is typically a short, high-level description of the error. - * For more detailed information, refer to the `description` field. + * When the embedded flow completes successfully and returns an assertion, + * this authId is used to complete the OAuth2 authorization flow by calling + * the `/oauth2/auth/callback` endpoint. This enables seamless transition from + * embedded flow to traditional OAuth2 flow completion. * - * @example "Error while provisioning user." + * @example "auth_abc123def456" */ - message: string; + authId?: string; } diff --git a/packages/javascript/src/models/v2/embedded-recovery-flow-v2.ts b/packages/javascript/src/models/embedded-recovery-flow.ts similarity index 97% rename from packages/javascript/src/models/v2/embedded-recovery-flow-v2.ts rename to packages/javascript/src/models/embedded-recovery-flow.ts index 705d5d2..dc5067c 100644 --- a/packages/javascript/src/models/v2/embedded-recovery-flow-v2.ts +++ b/packages/javascript/src/models/embedded-recovery-flow.ts @@ -16,8 +16,7 @@ * under the License. */ -import {FlowExecutionError} from './embedded-flow-v2'; -import {EmbeddedFlowType} from '../embedded-flow'; +import {EmbeddedFlowType, FlowExecutionError} from './embedded-flow'; /** * Status enumeration for the embedded recovery flow operations. diff --git a/packages/javascript/src/models/embedded-signin-flow.ts b/packages/javascript/src/models/embedded-signin-flow.ts index 116a24e..ea11951 100644 --- a/packages/javascript/src/models/embedded-signin-flow.ts +++ b/packages/javascript/src/models/embedded-signin-flow.ts @@ -1,5 +1,5 @@ /** - * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com). + * Copyright (c) 2025-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 @@ -16,96 +16,356 @@ * under the License. */ -export interface EmbeddedSignInFlowInitiateResponse { - flowId: string; - flowStatus: EmbeddedSignInFlowStatus; - flowType: EmbeddedSignInFlowType; - links: EmbeddedSignInFlowLink[]; - nextStep: { - authenticators: EmbeddedSignInFlowAuthenticator[]; - stepType: EmbeddedSignInFlowStepType; - }; -} +import { + EmbeddedFlowResponseData, + EmbeddedFlowResponseType, + EmbeddedFlowType, + FlowExecutionError, +} from './embedded-flow'; +/** + * Status enumeration for ThunderID embedded sign-in flow operations. + * + * These statuses indicate the current state of the sign-in flow and determine + * the next action required by the client application. Each status provides + * specific guidance on how to proceed with the authentication process. + * + * @example + * ```typescript + * switch (response.flowStatus) { + * case EmbeddedSignInFlowStatus.Incomplete: + * // More user input needed - render form components + * break; + * case EmbeddedSignInFlowStatus.Complete: + * // Authentication successful - handle completion + * break; + * case EmbeddedSignInFlowStatus.Error: + * // Authentication failed - show error message + * break; + * } + * ``` + * + * @experimental Part of the new ThunderID API + */ export enum EmbeddedSignInFlowStatus { - FailCompleted = 'FAIL_COMPLETED', - FailIncomplete = 'FAIL_INCOMPLETE', + /** + * Sign-in flow completed successfully. + * + * The user has been authenticated and the flow can proceed to + * OAuth2 completion or redirection. Check for redirectUrl or + * assertion data in the response. + */ + Complete = 'COMPLETE', + + /** + * Sign-in flow encountered an error. + * + * Authentication failed due to invalid credentials, system error, + * or other issues. Check error details in the response and handle + * appropriately (retry, show error message, etc.). + */ + Error = 'ERROR', + + /** + * Sign-in flow requires additional user input. + * + * More authentication steps are needed. The response will contain + * components in data.meta.components that should be rendered to + * collect additional user input (e.g., MFA, password, etc.). + */ Incomplete = 'INCOMPLETE', - SuccessCompleted = 'SUCCESS_COMPLETED', } +/** + * Type enumeration for ThunderID embedded sign-in flow responses. + * + * Determines the nature of the flow response and how the client should + * handle the returned data. This affects both UI rendering and flow + * continuation logic. + * + * @experimental Part of the new ThunderID API + */ export enum EmbeddedSignInFlowType { - Authentication = 'AUTHENTICATION', + /** + * Response requires external redirection. + * + * Used for social login providers, external identity providers, + * or other flows that require navigating to an external URL. + * The response will contain redirection information. + */ + Redirection = 'REDIRECTION', + + /** + * Response contains view components for rendering. + * + * Standard embedded flow response containing UI components + * that should be rendered within the current application + * context. Most common type for embedded authentication. + */ + View = 'VIEW', } -export enum EmbeddedSignInFlowStepType { - AuthenticatorPrompt = 'AUTHENTICATOR_PROMPT', - MultiOptionsPrompt = 'MULTI_OPTIONS_PROMPT', +/** + * Extended response structure for ThunderID embedded sign-in flow. + * + * This interface defines additional properties that are added at the SDK level + * to enhance the basic API response with client-side computed values. These + * properties provide convenience for common post-authentication operations. + * + * @remarks This response structure is enhanced by the SDK and contains + * properties beyond the raw API response. It's designed to simplify + * post-authentication handling for client applications. + * + * @experimental This interface is part of the new ThunderID platform + */ +export interface ExtendedEmbeddedSignInFlowResponse { + /** + * Computed redirect URL for post-authentication navigation. + * + * This URL is determined by the SDK based on the flow completion result + * and configured redirect settings. When present, the client application + * should navigate to this URL to complete the authentication process. + * + * @example "https://myapp.com/dashboard?session=abc123" + */ + redirectUrl?: string; } -export interface EmbeddedSignInFlowAuthenticator { - authenticator: string; - authenticatorId: string; - idp: string; - metadata: { - i18nKey: string; - params: { - confidential: boolean; - displayName: string; - i18nKey: string; - order: number; - param: string; - type: EmbeddedSignInFlowAuthenticatorParamType; +/** + * Primary response structure for ThunderID embedded sign-in flow operations. + * + * This is the main response interface returned by the sign-in API, combining + * the enhanced SDK properties with the core API response data. It provides all + * information needed to handle the current state of the authentication flow. + * + * The response structure adapts based on the flow status: + * - INCOMPLETE: Contains components for user interaction + * - COMPLETE: Contains completion data and potential redirection info + * - ERROR: Contains error information for troubleshooting + * + * @example + * ```typescript + * const response: EmbeddedSignInFlowResponse = { + * executionId: "flow_12345", + * flowStatus: EmbeddedSignInFlowStatus.Incomplete, + * type: EmbeddedSignInFlowType.View, + * data: { + * meta: { + * components: [ + * { + * id: "username_field", + * type: EmbeddedFlowComponentType.TextInput, + * label: "Username", + * required: true + * } + * ] + * } + * } + * }; + * ``` + * + * @experimental This interface is part of the new ThunderID platform + */ +export interface EmbeddedSignInFlowResponse extends ExtendedEmbeddedSignInFlowResponse { + /** + * JWT assertion returned when the flow reaches COMPLETE status on the V2 platform. + * Used to establish the session without a separate OAuth2 redirect. + */ + assertion?: string; + + /** + * Per-step challenge token for replay protection. + * Must be included in the next request to continue this flow. + */ + challengeToken?: string; + + /** + * Core response data containing UI components and flow metadata. + * Includes both modern meta.components structure and legacy fields for compatibility. + */ + data: EmbeddedFlowResponseData & { + /** + * Legacy action definitions for backward compatibility. + * @deprecated Use data.meta.components for new implementations + */ + actions?: { + /** Unique action identifier */ + id: string; + /** Action type identifier */ + type: EmbeddedFlowResponseType; + }[]; + + /** + * Legacy input field definitions for backward compatibility. + * @deprecated Use data.meta.components for new implementations + */ + inputs?: { + /** Field name identifier */ + name: string; + /** Whether the field is required */ + required: boolean; + /** Input field type */ + type: string; }[]; - promptType: EmbeddedSignInFlowAuthenticatorPromptType; }; - requiredParams: string[]; -} -export interface EmbeddedSignInFlowLink { - href: string; - method: string; - name: string; -} + /** + * Unique identifier for this specific flow instance. + * Used to maintain state across multiple API calls during the authentication process. + */ + executionId: string; -export interface EmbeddedSignInFlowHandleRequestPayload { - flowId: string; - selectedAuthenticator: { - authenticatorId: string; - params: Record; - }; -} + /** + * Structured error details when flowStatus is ERROR. + * Contains an error code and i18n-ready message/description fields. + */ + error?: FlowExecutionError; -export interface EmbeddedSignInFlowHandleResponse { - authData: Record; - flowStatus: string; -} + /** + * Current status of the sign-in flow. + * Determines the next action required by the client application. + */ + flowStatus: EmbeddedSignInFlowStatus; -export enum EmbeddedSignInFlowAuthenticatorParamType { - Integer = 'INTEGER', - MultiValued = 'MULTI_VALUED', - String = 'STRING', + /** + * Type of response indicating how to handle the returned data. + * Affects both UI rendering and navigation logic. + */ + type: EmbeddedSignInFlowType; } -export enum EmbeddedSignInFlowAuthenticatorExtendedParamType { - Otp = 'OTPCode', +/** + * Response structure for completed ThunderID embedded sign-in flows. + * + * This interface defines the response format when the embedded sign-in flow + * reaches the COMPLETE status and requires OAuth2 flow completion. It contains + * the redirect URI that should be used for the final authentication step. + * + * @example + * ```typescript + * const completeResponse: EmbeddedSignInFlowCompleteResponse = { + * redirect_uri: "https://myapp.com/callback?code=abc123&state=xyz789" + * }; + * + * // Typically handled automatically by the SDK + * window.location.href = completeResponse.redirect_uri; + * ``` + * + * @experimental This interface is part of the new ThunderID platform + */ +export interface EmbeddedSignInFlowCompleteResponse { + /** + * OAuth2 redirect URI for completing the authentication flow. + * + * Contains the final redirect URL with authorization code, state, + * and other OAuth2 parameters needed to complete the authentication + * process. This URL should be navigated to automatically or manually + * depending on the application's requirements. + */ + redirect_uri: string; } -export enum EmbeddedSignInFlowAuthenticatorKnownIdPType { - Local = 'LOCAL', +/** + * Request payload for initiating ThunderID embedded sign-in flows. + * + * This type defines the minimum required information to start a new + * embedded sign-in flow. The flow type determines the kind of authentication + * process that will be initiated (e.g., standard login, MFA, etc.). + * + * @example + * ```typescript + * const initRequest: EmbeddedSignInFlowInitiateRequest = { + * applicationId: "app_12345", + * flowType: EmbeddedFlowType.Authentication + * }; + * + * const response = await executeEmbeddedSignInFlow({ + * baseUrl: "https://localhost:8090", + * payload: initRequest + * }); + * ``` + * + * @experimental This type is part of the new ThunderID platform + */ +export interface EmbeddedSignInFlowInitiateRequest { + /** + * Unique identifier of the application initiating the sign-in flow. + * Must be a valid application ID registered in the ThunderID organization. + */ + applicationId: string; + + /** + * Type of embedded flow to initiate. + * Determines the authentication process and available options. + */ + flowType: EmbeddedFlowType; + + /** + * OAuth2 scopes to request during flow initialization. + * When provided, these scopes are forwarded to the platform at flow start. + */ + scopes?: string | string[]; } -export enum EmbeddedSignInFlowAuthenticatorPromptType { +/** + * Request payload for executing steps in ThunderID embedded sign-in flows. + * + * This interface defines the structure for subsequent requests after flow initiation. + * It supports both continuing existing flows (with executionId) and submitting user + * input data collected from the rendered components. + * + * @example + * ```typescript + * // Continue existing flow with user input + * const stepRequest: EmbeddedSignInFlowRequest = { + * executionId: "flow_12345", + * action: "action_001", + * inputs: { + * username: "user@example.com", + * password: "securePassword123" + * } + * }; + * + * // Submit to continue the flow + * const response = await executeEmbeddedSignInFlow({ + * baseUrl: "https://localhost:8090", + * payload: stepRequest + * }); + * ``` + * + * @experimental This interface is part of the new ThunderID platform + */ +export interface EmbeddedSignInFlowRequest extends Partial { /** - * Prompt for internal system use, such as API keys or tokens. + * Identifier of the specific action being triggered. + * Corresponds to action components in the UI (e.g., submit button, social login). */ - InternalPrompt = 'INTERNAL_PROMPT', + action?: string; + /** - * Prompt for redirection to another page or service. + * Per-step challenge token received from the previous flow response. + * Required when continuing an existing flow to prevent replay attacks. */ - RedirectionPrompt = 'REDIRECTION_PROMPT', + challengeToken?: string; + + /** + * Identifier of the flow instance to continue. + * Required when submitting data for an existing flow. + */ + executionId?: string; + /** - * Prompt for user input, typically for username/password or similar credentials. + * User input data collected from the form components. + * Keys should match the component identifiers from the response. + * + * @example + * ```typescript + * { + * "username": "john.doe@example.com", + * "password": "mySecurePassword", + * "rememberMe": true + * } + * ``` */ - UserPrompt = 'USER_PROMPT', + inputs?: Record; } diff --git a/packages/javascript/src/models/v2/embedded-signup-flow-v2.ts b/packages/javascript/src/models/embedded-signup-flow.ts similarity index 96% rename from packages/javascript/src/models/v2/embedded-signup-flow-v2.ts rename to packages/javascript/src/models/embedded-signup-flow.ts index 654331a..3e45d55 100644 --- a/packages/javascript/src/models/v2/embedded-signup-flow-v2.ts +++ b/packages/javascript/src/models/embedded-signup-flow.ts @@ -16,11 +16,7 @@ * under the License. */ -import {FlowExecutionError} from './embedded-flow-v2'; -import { - EmbeddedFlowResponseType as EmbeddedFlowResponseTypeV1, - EmbeddedFlowType as EmbeddedFlowTypeV1, -} from '../embedded-flow'; +import {EmbeddedFlowResponseType, EmbeddedFlowType, FlowExecutionError} from './embedded-flow'; /** * Status enumeration for ThunderID embedded sign-up flow operations. @@ -158,7 +154,7 @@ export interface EmbeddedSignUpFlowResponse extends ExtendedEmbeddedSignUpFlowRe */ actions?: { id: string; - type: EmbeddedFlowResponseTypeV1; + type: EmbeddedFlowResponseType; }[]; /** @@ -208,7 +204,7 @@ export interface EmbeddedSignUpFlowCompleteResponse { */ export interface EmbeddedSignUpFlowInitiateRequest { applicationId: string; - flowType: EmbeddedFlowTypeV1; + flowType: EmbeddedFlowType; /** * OAuth2 scopes to request during flow initialization. * When provided, these scopes are forwarded to the platform at flow start. diff --git a/packages/javascript/src/models/extensions/components.ts b/packages/javascript/src/models/extensions/components.ts new file mode 100644 index 0000000..94ccdfe --- /dev/null +++ b/packages/javascript/src/models/extensions/components.ts @@ -0,0 +1,59 @@ +/** + * 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 {EmbeddedFlowComponent} from '../embedded-flow'; +import {FlowMetadataResponse} from '../flow-meta'; + +/** + * Context passed to a custom component renderer. Provides the current form + * state and callbacks so the renderer can render an interactive control. + */ +export interface ComponentRenderContext { + additionalData?: Record; + authType: 'signin' | 'signup' | 'recovery'; + 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; +} + +/** + * A function that renders a custom UI element for a given flow component. + * Framework-specific packages (e.g. `@thunderid/react`) narrow this type to + * their own element type (e.g. `ReactElement | null`). + */ +export type ComponentRenderer = (component: EmbeddedFlowComponent, context: ComponentRenderContext) => unknown; + +/** + * A map of component type identifiers to their custom renderer functions. + */ +export type ComponentRendererMap = Record; + +/** + * Extension configuration for customising how SDK flow components are rendered. + * Pass a `renderers` map to override the default rendering of specific component + * types with your own UI elements. + */ +export interface ComponentsExtensions { + renderers?: ComponentRendererMap; +} diff --git a/packages/javascript/src/models/v2/flow-meta-v2.ts b/packages/javascript/src/models/flow-meta.ts similarity index 100% rename from packages/javascript/src/models/v2/flow-meta-v2.ts rename to packages/javascript/src/models/flow-meta.ts diff --git a/packages/javascript/src/models/v2/organization-unit.ts b/packages/javascript/src/models/organization-unit.ts similarity index 100% rename from packages/javascript/src/models/v2/organization-unit.ts rename to packages/javascript/src/models/organization-unit.ts diff --git a/packages/javascript/src/models/platforms.ts b/packages/javascript/src/models/platforms.ts deleted file mode 100644 index b0cf082..0000000 --- a/packages/javascript/src/models/platforms.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * 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. - */ - -/** - * Enumeration of supported identity platforms. - * - * - `ThunderID`: Represents the ThunderID identity platform. - * - `IdentityServer`: Represents WSO2 Identity Server (on-prem or custom domains). - * - `Unknown`: Used when the platform cannot be determined from the configuration. - */ -export enum Platform { - /** ThunderID identity platform */ - ThunderID = 'THUNDERID', - /** WSO2 Identity Server (on-prem or custom domains) */ - IdentityServer = 'IDENTITY_SERVER', - /** Unknown or unsupported platform */ - Unknown = 'UNKNOWN', -} diff --git a/packages/javascript/src/models/v2/translation.ts b/packages/javascript/src/models/translation.ts similarity index 100% rename from packages/javascript/src/models/v2/translation.ts rename to packages/javascript/src/models/translation.ts diff --git a/packages/javascript/src/models/v2/embedded-flow-v2.ts b/packages/javascript/src/models/v2/embedded-flow-v2.ts deleted file mode 100644 index 2b26085..0000000 --- a/packages/javascript/src/models/v2/embedded-flow-v2.ts +++ /dev/null @@ -1,712 +0,0 @@ -/** - * Copyright (c) 2025-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 {EmbeddedFlowExecuteRequestConfig as EmbeddedFlowExecuteRequestConfigV1} from '../embedded-flow'; - -/** - * Internationalized message structure returned by the backend. - * - * The `defaultValue` field carries the untranslated fallback text. - */ -export interface I18nMessage { - defaultValue?: string; - key: string; -} - -/** - * Structured error returned in a flow response when flowStatus is ERROR. - */ -export interface FlowExecutionError { - code: string; - description: I18nMessage; - message: I18nMessage; -} - -/** - * Component types supported by the ThunderID embedded flow API. - * - * These types define the different UI components that can be rendered - * as part of the embedded authentication flows. Each type corresponds - * to a specific UI element with its own behavior and properties. - * - * @example - * ```typescript - * // Check component type to render appropriate UI - * if (component.type === EmbeddedFlowComponentType.TextInput) { - * // Render text input field - * } else if (component.type === EmbeddedFlowComponentType.Action) { - * // Render button/action - * } - * ``` - * - * @experimental This API may change in future versions - */ -export enum EmbeddedFlowComponentType { - /** Interactive action component (buttons, links) for user interactions */ - Action = 'ACTION', - - /** Container block component that groups other components */ - Block = 'BLOCK', - - /** Consent component for displaying consent purposes and attributes */ - Consent = 'CONSENT', - - /** Copyable text display component that shows text with a copy-to-clipboard action */ - CopyableText = 'COPYABLE_TEXT', - - /** Date input field for selecting a calendar date */ - DateInput = 'DATE_INPUT', - - /** Divider component for visual separation of content */ - Divider = 'DIVIDER', - - /** Email input field with validation for email addresses. */ - EmailInput = 'EMAIL_INPUT', - - /** Icon display component for rendering named vector icons */ - Icon = 'ICON', - - /** Image display component for logos and illustrations */ - Image = 'IMAGE', - - /** One-time password input field for multi-factor authentication */ - OtpInput = 'OTP_INPUT', - - /** Organization unit tree picker for selecting an OU */ - OuSelect = 'OU_SELECT', - - /** Password input field with masking for sensitive data */ - PasswordInput = 'PASSWORD_INPUT', - - /** Phone number input field with country code support */ - PhoneInput = 'PHONE_INPUT', - - /** Rich text display component that renders formatted HTML content */ - RichText = 'RICH_TEXT', - - /** Select/dropdown input component for single choice selection */ - Select = 'SELECT', - - /** Stack layout component for arranging children in a row or column */ - Stack = 'STACK', - - /** Text display component for labels, headings, and messages */ - Text = 'TEXT', - - /** Standard text input field for user data entry */ - TextInput = 'TEXT_INPUT', - - /** Timer component for displaying a countdown */ - Timer = 'TIMER', - - /** QR code display component for wallet-based flows (e.g. OpenID4VP) */ - QrCode = 'QR_CODE', -} - -/** - * Action variant types for buttons and interactive elements. - * - * @experimental This API may change in future versions - */ -export enum EmbeddedFlowActionVariant { - /** Danger action button for destructive operations */ - Danger = 'DANGER', - - /** Info action button for informational purposes */ - Info = 'INFO', - - /** Link-styled action button */ - Link = 'LINK', - - /** Outlined action button for secondary emphasis */ - Outlined = 'OUTLINED', - - /** Primary action button with highest visual emphasis */ - Primary = 'PRIMARY', - - /** Secondary action button with moderate visual emphasis */ - Secondary = 'SECONDARY', - - /** Success action button for positive confirmations */ - Success = 'SUCCESS', - - /** Tertiary action button with minimal visual emphasis */ - Tertiary = 'TERTIARY', - - /** Warning action button for cautionary actions */ - Warning = 'WARNING', -} - -/** - * Text variant types for typography components. - * - * @experimental This API may change in future versions - */ -export enum EmbeddedFlowTextVariant { - /** Primary body text for main content */ - Body1 = 'BODY_1', - - /** Secondary body text for supplementary content */ - Body2 = 'BODY_2', - - /** Text styled for button labels */ - ButtonText = 'BUTTON_TEXT', - - /** Small caption text for annotations and descriptions */ - Caption = 'CAPTION', - - /** Largest heading level for main titles */ - Heading1 = 'HEADING_1', - - /** Second level heading for major sections */ - Heading2 = 'HEADING_2', - - /** Third level heading for subsections */ - Heading3 = 'HEADING_3', - - /** Fourth level heading for minor sections */ - Heading4 = 'HEADING_4', - - /** Fifth level heading for detailed sections */ - Heading5 = 'HEADING_5', - - /** Smallest heading level for fine-grained sections */ - Heading6 = 'HEADING_6', - - /** Overline text for labels and categories */ - Overline = 'OVERLINE', - - /** Primary subtitle text with larger emphasis */ - Subtitle1 = 'SUBTITLE_1', - - /** Secondary subtitle text with moderate emphasis */ - Subtitle2 = 'SUBTITLE_2', -} - -/** - * Event types for action components. - * - * @experimental This API may change in future versions - */ -export enum EmbeddedFlowEventType { - /** Navigate back to the previous step */ - Back = 'BACK', - - /** Cancel the current operation */ - Cancel = 'CANCEL', - - /** Navigate to a different flow step or page */ - Navigate = 'NAVIGATE', - - /** Reset form fields to initial state */ - Reset = 'RESET', - - /** Submit form data to the server */ - Submit = 'SUBMIT', - - /** Trigger an action or event */ - Trigger = 'TRIGGER', -} - -/** - * Enhanced component interface for embedded flow components. - * - * This interface provides better support for modern form handling and user experience. - * It includes properties for labels, placeholders, and required field validation - * that are directly provided by the API response. - * - * @example - * ```typescript - * const component: EmbeddedFlowComponent = { - * id: 'username_field', - * type: EmbeddedFlowComponentType.TextInput, - * label: 'Username', - * placeholder: 'Enter your username', - * required: true, - * variant: 'TEXT', - * eventType: 'SUBMIT', - * components: [] - * }; - * ``` - * - * @experimental This interface may change in future versions - */ -export interface EmbeddedFlowComponent { - /** - * Alignment of children along the cross axis (for Stack components). - */ - align?: string; - - /** - * Alternative text for Image components. - */ - alt?: string; - - /** - * Icon color, CSS color value (for Icon components). - */ - color?: string; - - /** - * Nested child components for container components like Block and Stack. - */ - components?: EmbeddedFlowComponent[]; - - /** - * Display format hint for DateInput components (e.g., 'yyyy-MM-dd'). Used as the - * placeholder rendered by the date picker primitive. Pattern-level validation is - * declared separately via a `regex` rule in the `validation` array. - */ - dateFormat?: string; - - /** - * Layout direction for Stack components ('row' | 'column'). - */ - direction?: string; - - /** - * Icon to render at the end of an Action button (URL string). - */ - endIcon?: string; - - /** - * Event type for action components that defines the interaction behavior. - * Only relevant for Action components. - */ - eventType?: EmbeddedFlowEventType | string; - - /** - * Gap between children in Stack components (number, maps to spacing units). - */ - gap?: number; - - /** - * Height of the component (for Image components, can be string with units or number for pixels). - * The value depends on the component type (e.g., for Image components). - */ - height?: string | number; - - /** - * Unique identifier for the component - */ - id: string; - - /** - * Number of items across the main axis (for Stack grid-like layouts). - */ - items?: string | number; - - /** - * Justification of children along the main axis (for Stack components). - */ - justify?: string; - - /** - * Display label for the component (e.g., field label, button text). - * Supports internationalization and may contain template strings. - */ - label?: string; - - /** - * Icon name for Icon components (e.g., lucide-react icon names like 'ArrowLeftRight'). - */ - name?: string; - - /** - * Options for SELECT components. - * Each option can be a string value or an object with value and label. - */ - options?: (string | {label: string; value: string})[]; - - /** - * Placeholder text for input components. - * Provides helpful hints to users about expected input format. - */ - placeholder?: string; - - /** - * Reference identifier for the component (e.g., field name, action ref) - */ - ref?: string; - - /** - * Indicates whether this component represents a required field. - * Used for form validation and UI indicators. - */ - required?: boolean; - - /** - * Icon size in pixels (for Icon components). - */ - size?: number; - - /** - * Data source key for dynamic components (e.g., COPYABLE_TEXT). - * References a key in additionalData whose value is resolved at render time. - */ - source?: string; - - /** - * Image source URL (for Image components). - */ - src?: string; - - /** - * Icon to render at the start of an Action button (URL string). - */ - startIcon?: string; - - /** - * Component type that determines rendering behavior - */ - type: EmbeddedFlowComponentType | string; - - /** - * Declarative validation rules for input components. Evaluated client-side by the SDK - * (best-effort UX) before submission, and authoritatively re-evaluated server-side. - * Each rule represents exactly one constraint. - */ - validation?: ValidationRule[]; - - /** - * Component variant that affects visual styling and behavior. - * The value depends on the component type (e.g., button variants, text variants). - */ - variant?: EmbeddedFlowActionVariant | EmbeddedFlowTextVariant | string; - - /** - * Width of the component (for Image components, can be string with units or number for pixels). - * The value depends on the component type (e.g., for Image components). - */ - width?: string | number; -} - -/** - * Supported validation rule types for `ValidationRule.type`. - * - * - `regex`: value must be a string regex pattern; the input must match. - * - `minLength`: value must be a number; input length must be >= value. - * - `maxLength`: value must be a number; input length must be <= value. - * - * @experimental Additional rule types (`oneOf`, `format`, ...) may be added later. - */ -export type ValidationRuleType = 'regex' | 'minLength' | 'maxLength'; - -/** - * A single-constraint validation rule attached to an input component. - * Mirrors the server-side `ValidationRule` returned by ThunderID. - * - * @experimental This interface may change in future versions - */ -export interface ValidationRule { - /** - * The constraint kind. Drives interpretation of `value` and the default fallback message. - */ - type: ValidationRuleType; - - /** - * The constraint parameter. String for `regex`, number for `minLength` / `maxLength`. - */ - value: string | number; - - /** - * Optional message returned when this rule fails. May be an i18n key (e.g. - * `"{{i18n(validation:email.invalid)}}"`) or a literal string. The server passes - * this through unchanged; the SDK substitutes a default i18n key when omitted. - */ - message?: string; -} - -/** - * A single validation failure for a specific input field returned by the server in - * `data.fieldErrors` when one or more rules fail. - * - * @experimental This interface may change in future versions - */ -export interface FieldError { - /** The `identifier` of the input that failed validation. */ - identifier: string; - /** The failing rule's message (i18n key or literal string). */ - message: string; -} - -/** - * Response data structure for embedded flow API. - * - * This interface defines the structure of data returned by the API, - * which includes both legacy input/action arrays for backward compatibility - * and the new meta.components structure for modern component-driven UIs. - * - * The key improvement is the meta.components field, which provides - * a rich component tree with proper labels, placeholders, and hierarchy - * that can be directly rendered without additional transformation. - * - * @example - * ```typescript - * const response: EmbeddedFlowResponseData = { - * // Legacy format (for backward compatibility) - * inputs: [ - * { ref: 'input_001', identifier: 'username', type: 'TEXT_INPUT', required: true } - * ], - * actions: [ - * { ref: 'action_001', nextNode: 'basic_auth', eventType: 'SUBMIT' } - * ], - * // Modern format (recommended) - * meta: { - * components: [ - * { - * id: 'text_001', - * type: 'TEXT', - * label: '{{ t(signin:heading.label) }}', - * variant: 'HEADING_1' - * }, - * { - * id: 'block_001', - * type: 'BLOCK', - * components: [ - * { - * id: 'input_001', - * type: 'TEXT_INPUT', - * label: '{{ t(signin:fields.username.label) }}', - * placeholder: '{{ t(signin:fields.username.placeholder) }}', - * required: true - * }, - * { - * id: 'action_001', - * type: 'ACTION', - * label: '{{ t(signin:buttons.submit.label) }}', - * variant: 'PRIMARY', - * eventType: 'ACTIVATE' - * } - * ] - * } - * ] - * } - * }; - * ``` - * - * @experimental This structure may change in future versions - */ -export interface EmbeddedFlowResponseData { - /** - * Legacy action definitions for backward compatibility. - * @deprecated Use meta.components for new implementations - */ - actions?: { - /** Event type for the action (SUBMIT, ACTIVATE, etc.) */ - eventType?: string; - /** Next flow node to navigate to (optional) */ - nextNode?: string; - /** Reference identifier for the action */ - ref: string; - }[]; - - /** - * Additional data dictionary for dynamic flow response properties. - * Can be used to pass custom data like passkey challenges, server alerts, etc. - */ - additionalData?: Record; - - /** - * Per-field validation errors returned by the server when a submission fails one or - * more `validation` rules. Multiple failing rules on the same field appear as - * multiple entries, in the order the rules were declared. - * - * Present only on `INCOMPLETE` responses caused by validation failures; absent on - * successful submissions and on `INCOMPLETE` responses caused by missing required fields. - */ - fieldErrors?: FieldError[]; - - /** - * Legacy input definitions for backward compatibility. - * @deprecated Use meta.components for new implementations - */ - inputs?: { - /** Field identifier used in form submission */ - identifier: string; - /** Reference identifier for the input */ - ref: string; - /** Whether this input is required for form submission */ - required: boolean; - /** Input type (TEXT_INPUT, PASSWORD_INPUT, etc.) */ - type: string; - /** Server-side validation rules for the input (also returned for API-only customers). */ - validation?: ValidationRule[]; - }[]; - - /** - * Modern component-driven metadata structure. - * This contains the complete UI component tree with proper - * hierarchy, labels, and configuration that can be directly rendered. - * - * **This is the primary data source for implementations.** - * The legacy inputs/actions arrays are maintained only for backward compatibility. - */ - meta?: { - /** Array of components that define the complete UI structure */ - components: EmbeddedFlowComponent[]; - }; - - /** - * Optional redirect URL for flow completion or external authentication. - */ - redirectURL?: string; -} - -/** - * Discriminator identifying the kind of consent a purpose represents. The same - * `ConsentPurposeData` envelope is used for both attribute and permission consent; - * the populated fields differ based on this discriminator. - * - * @experimental This type may change in future versions - */ -export type ConsentPurposeType = 'attributes' | 'permissions'; - -/** - * Individual consent attribute/element decision. - * - * @experimental This interface may change in future versions - */ -export interface ConsentAttributeElement { - /** Whether the user approved collection of this attribute */ - approved: boolean; - /** The name of the attribute being consented */ - name: string; -} - -/** - * A single element presented for consent within a consent purpose. For attribute purposes - * the element is an attribute name. For permission purposes the element is a permission - * string and `parent` may carry rollup linkage supplied by the server: when set, the UI - * may render this permission as a child of `parent` and offer a single rollup toggle. - * - * @experimental This interface may change in future versions - */ -export interface PromptElement { - /** Canonical element name (attribute name or permission string) */ - name: string; - /** - * Canonical name of the rollup parent, permission-purpose only. Undefined for attribute - * elements and for top-level permissions. - */ - parent?: string; -} - -/** - * Consent decision for a single purpose. - * - * @experimental This interface may change in future versions - */ -export interface ConsentPurposeDecision { - /** Whether the user approved this purpose */ - approved: boolean; - /** Per-attribute decisions for this purpose */ - elements: ConsentAttributeElement[]; - /** The name of the consent purpose */ - purposeName: string; -} - -/** - * Full consent decisions structure sent to the backend when user submits the consent form. - * - * @experimental This interface may change in future versions - */ -export interface ConsentDecisions { - /** Array of per-purpose decisions */ - purposes: ConsentPurposeDecision[]; -} - -/** - * Single consent purpose data returned by the backend in additionalData.consent_prompt. - * The same envelope carries both attribute and permission purposes, distinguished by `type`. - * - * @experimental This interface may change in future versions - */ -export interface ConsentPurposeData { - /** Optional human-readable description of the purpose */ - description?: string; - /** - * Elements that are mandatory and cannot be declined. Used by attribute purposes; - * permission purposes today have no essential elements. - */ - essential: PromptElement[]; - /** - * Elements the user can opt in or out of. For attribute purposes these are optional - * attribute names. For permission purposes these are permission elements (which may - * carry rollup parent linkage). - */ - optional: PromptElement[]; - /** Unique identifier for the purpose */ - purposeId: string; - /** Human-readable purpose name */ - purposeName?: string; - /** - * Discriminator selecting between attribute and permission consent semantics. - */ - type?: ConsentPurposeType; -} - -/** - * Consent prompt data structure stored in additionalData.consent_prompt. - * - * @experimental This interface may change in future versions - */ -export interface ConsentPromptData { - /** Array of consent purposes requiring user review */ - purposes: ConsentPurposeData[]; -} - -/** - * Extended request configuration for ThunderID V2 embedded flow operations. - * - * This interface extends the base request configuration with V2-specific - * properties required for the enhanced embedded flow API. The authId parameter - * is particularly important for the V2 OAuth2 flow completion process. - * - * @template T The type of the payload data being sent with the request - * - * @example - * ```typescript - * const config: EmbeddedFlowExecuteRequestConfigV2 = { - * baseUrl: 'https://localhost:8090', - * payload: { - * flowType: 'AUTHENTICATION', - * inputs: { username: 'user@example.com' } - * }, - * authId: 'auth_12345', // V2-specific for OAuth completion - * headers: { - * 'Authorization': 'Bearer token' - * } - * }; - * ``` - * - * @experimental This configuration is part of the new ThunderID V2 platform - */ -export interface EmbeddedFlowExecuteRequestConfig extends EmbeddedFlowExecuteRequestConfigV1 { - /** - * Authentication ID used for OAuth2 flow completion in V2 API. - * - * When the embedded flow completes successfully and returns an assertion, - * this authId is used to complete the OAuth2 authorization flow by calling - * the `/oauth2/auth/callback` endpoint. This enables seamless transition from - * embedded flow to traditional OAuth2 flow completion. - * - * @example "auth_abc123def456" - */ - authId?: string; -} diff --git a/packages/javascript/src/models/v2/embedded-signin-flow-v2.ts b/packages/javascript/src/models/v2/embedded-signin-flow-v2.ts deleted file mode 100644 index ea52029..0000000 --- a/packages/javascript/src/models/v2/embedded-signin-flow-v2.ts +++ /dev/null @@ -1,370 +0,0 @@ -/** - * Copyright (c) 2025-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 {EmbeddedFlowResponseData as EmbeddedFlowResponseDataV2, FlowExecutionError} from './embedded-flow-v2'; -import { - EmbeddedFlowResponseType as EmbeddedFlowResponseTypeV1, - EmbeddedFlowType as EmbeddedFlowTypeV1, -} from '../embedded-flow'; - -/** - * Status enumeration for ThunderID embedded sign-in flow operations. - * - * These statuses indicate the current state of the sign-in flow and determine - * the next action required by the client application. Each status provides - * specific guidance on how to proceed with the authentication process. - * - * @example - * ```typescript - * switch (response.flowStatus) { - * case EmbeddedSignInFlowStatus.Incomplete: - * // More user input needed - render form components - * break; - * case EmbeddedSignInFlowStatus.Complete: - * // Authentication successful - handle completion - * break; - * case EmbeddedSignInFlowStatus.Error: - * // Authentication failed - show error message - * break; - * } - * ``` - * - * @experimental Part of the new ThunderID API - */ -export enum EmbeddedSignInFlowStatus { - /** - * Sign-in flow completed successfully. - * - * The user has been authenticated and the flow can proceed to - * OAuth2 completion or redirection. Check for redirectUrl or - * assertion data in the response. - */ - Complete = 'COMPLETE', - - /** - * Sign-in flow encountered an error. - * - * Authentication failed due to invalid credentials, system error, - * or other issues. Check error details in the response and handle - * appropriately (retry, show error message, etc.). - */ - Error = 'ERROR', - - /** - * Sign-in flow requires additional user input. - * - * More authentication steps are needed. The response will contain - * components in data.meta.components that should be rendered to - * collect additional user input (e.g., MFA, password, etc.). - */ - Incomplete = 'INCOMPLETE', -} - -/** - * Type enumeration for ThunderID embedded sign-in flow responses. - * - * Determines the nature of the flow response and how the client should - * handle the returned data. This affects both UI rendering and flow - * continuation logic. - * - * @experimental Part of the new ThunderID API - */ -export enum EmbeddedSignInFlowType { - /** - * Response requires external redirection. - * - * Used for social login providers, external identity providers, - * or other flows that require navigating to an external URL. - * The response will contain redirection information. - */ - Redirection = 'REDIRECTION', - - /** - * Response contains view components for rendering. - * - * Standard embedded flow response containing UI components - * that should be rendered within the current application - * context. Most common type for embedded authentication. - */ - View = 'VIEW', -} - -/** - * Extended response structure for ThunderID embedded sign-in flow. - * - * This interface defines additional properties that are added at the SDK level - * to enhance the basic API response with client-side computed values. These - * properties provide convenience for common post-authentication operations. - * - * @remarks This response structure is enhanced by the SDK and contains - * properties beyond the raw API response. It's designed to simplify - * post-authentication handling for client applications. - * - * @experimental This interface is part of the new ThunderID platform - */ -export interface ExtendedEmbeddedSignInFlowResponse { - /** - * Computed redirect URL for post-authentication navigation. - * - * This URL is determined by the SDK based on the flow completion result - * and configured redirect settings. When present, the client application - * should navigate to this URL to complete the authentication process. - * - * @example "https://myapp.com/dashboard?session=abc123" - */ - redirectUrl?: string; -} - -/** - * Primary response structure for ThunderID embedded sign-in flow operations. - * - * This is the main response interface returned by the sign-in API, combining - * the enhanced SDK properties with the core API response data. It provides all - * information needed to handle the current state of the authentication flow. - * - * The response structure adapts based on the flow status: - * - INCOMPLETE: Contains components for user interaction - * - COMPLETE: Contains completion data and potential redirection info - * - ERROR: Contains error information for troubleshooting - * - * @example - * ```typescript - * const response: EmbeddedSignInFlowResponse = { - * executionId: "flow_12345", - * flowStatus: EmbeddedSignInFlowStatus.Incomplete, - * type: EmbeddedSignInFlowType.View, - * data: { - * meta: { - * components: [ - * { - * id: "username_field", - * type: EmbeddedFlowComponentType.TextInput, - * label: "Username", - * required: true - * } - * ] - * } - * } - * }; - * ``` - * - * @experimental This interface is part of the new ThunderID platform - */ -export interface EmbeddedSignInFlowResponse extends ExtendedEmbeddedSignInFlowResponse { - /** - * JWT assertion returned when the flow reaches COMPLETE status on the V2 platform. - * Used to establish the session without a separate OAuth2 redirect. - */ - assertion?: string; - - /** - * Per-step challenge token for replay protection. - * Must be included in the next request to continue this flow. - */ - challengeToken?: string; - - /** - * Core response data containing UI components and flow metadata. - * Includes both modern meta.components structure and legacy fields for compatibility. - */ - data: EmbeddedFlowResponseDataV2 & { - /** - * Legacy action definitions for backward compatibility. - * @deprecated Use data.meta.components for new implementations - */ - actions?: { - /** Unique action identifier */ - id: string; - /** Action type identifier */ - type: EmbeddedFlowResponseTypeV1; - }[]; - - /** - * Legacy input field definitions for backward compatibility. - * @deprecated Use data.meta.components for new implementations - */ - inputs?: { - /** Field name identifier */ - name: string; - /** Whether the field is required */ - required: boolean; - /** Input field type */ - type: string; - }[]; - }; - - /** - * Unique identifier for this specific flow instance. - * Used to maintain state across multiple API calls during the authentication process. - */ - executionId: string; - - /** - * Structured error details when flowStatus is ERROR. - * Contains an error code and i18n-ready message/description fields. - */ - error?: FlowExecutionError; - - /** - * Current status of the sign-in flow. - * Determines the next action required by the client application. - */ - flowStatus: EmbeddedSignInFlowStatus; - - /** - * Type of response indicating how to handle the returned data. - * Affects both UI rendering and navigation logic. - */ - type: EmbeddedSignInFlowType; -} - -/** - * Response structure for completed ThunderID embedded sign-in flows. - * - * This interface defines the response format when the embedded sign-in flow - * reaches the COMPLETE status and requires OAuth2 flow completion. It contains - * the redirect URI that should be used for the final authentication step. - * - * @example - * ```typescript - * const completeResponse: EmbeddedSignInFlowCompleteResponse = { - * redirect_uri: "https://myapp.com/callback?code=abc123&state=xyz789" - * }; - * - * // Typically handled automatically by the SDK - * window.location.href = completeResponse.redirect_uri; - * ``` - * - * @experimental This interface is part of the new ThunderID platform - */ -export interface EmbeddedSignInFlowCompleteResponse { - /** - * OAuth2 redirect URI for completing the authentication flow. - * - * Contains the final redirect URL with authorization code, state, - * and other OAuth2 parameters needed to complete the authentication - * process. This URL should be navigated to automatically or manually - * depending on the application's requirements. - */ - redirect_uri: string; -} - -/** - * Request payload for initiating ThunderID embedded sign-in flows. - * - * This type defines the minimum required information to start a new - * embedded sign-in flow. The flow type determines the kind of authentication - * process that will be initiated (e.g., standard login, MFA, etc.). - * - * @example - * ```typescript - * const initRequest: EmbeddedSignInFlowInitiateRequest = { - * applicationId: "app_12345", - * flowType: EmbeddedFlowType.Authentication - * }; - * - * const response = await executeEmbeddedSignInFlow({ - * baseUrl: "https://localhost:8090", - * payload: initRequest - * }); - * ``` - * - * @experimental This type is part of the new ThunderID platform - */ -export interface EmbeddedSignInFlowInitiateRequest { - /** - * Unique identifier of the application initiating the sign-in flow. - * Must be a valid application ID registered in the ThunderID organization. - */ - applicationId: string; - - /** - * Type of embedded flow to initiate. - * Determines the authentication process and available options. - */ - flowType: EmbeddedFlowTypeV1; - - /** - * OAuth2 scopes to request during flow initialization. - * When provided, these scopes are forwarded to the platform at flow start. - */ - scopes?: string | string[]; -} - -/** - * Request payload for executing steps in ThunderID embedded sign-in flows. - * - * This interface defines the structure for subsequent requests after flow initiation. - * It supports both continuing existing flows (with executionId) and submitting user - * input data collected from the rendered components. - * - * @example - * ```typescript - * // Continue existing flow with user input - * const stepRequest: EmbeddedSignInFlowRequest = { - * executionId: "flow_12345", - * action: "action_001", - * inputs: { - * username: "user@example.com", - * password: "securePassword123" - * } - * }; - * - * // Submit to continue the flow - * const response = await executeEmbeddedSignInFlow({ - * baseUrl: "https://localhost:8090", - * payload: stepRequest - * }); - * ``` - * - * @experimental This interface is part of the new ThunderID platform - */ -export interface EmbeddedSignInFlowRequest extends Partial { - /** - * Identifier of the specific action being triggered. - * Corresponds to action components in the UI (e.g., submit button, social login). - */ - action?: string; - - /** - * Per-step challenge token received from the previous flow response. - * Required when continuing an existing flow to prevent replay attacks. - */ - challengeToken?: string; - - /** - * Identifier of the flow instance to continue. - * Required when submitting data for an existing flow. - */ - executionId?: string; - - /** - * User input data collected from the form components. - * Keys should match the component identifiers from the response. - * - * @example - * ```typescript - * { - * "username": "john.doe@example.com", - * "password": "mySecurePassword", - * "rememberMe": true - * } - * ``` - */ - inputs?: Record; -} diff --git a/packages/javascript/src/models/v2/extensions/components.ts b/packages/javascript/src/models/v2/extensions/components.ts deleted file mode 100644 index 3404860..0000000 --- a/packages/javascript/src/models/v2/extensions/components.ts +++ /dev/null @@ -1,95 +0,0 @@ -/** - * 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/javascript/src/models/v2/vars.ts b/packages/javascript/src/models/vars.ts similarity index 95% rename from packages/javascript/src/models/v2/vars.ts rename to packages/javascript/src/models/vars.ts index ceaf503..28b5be0 100644 --- a/packages/javascript/src/models/v2/vars.ts +++ b/packages/javascript/src/models/vars.ts @@ -16,7 +16,7 @@ * under the License. */ -import {FlowMetadataResponse} from './flow-meta-v2'; +import {FlowMetadataResponse} from './flow-meta'; import {TranslationFn} from './translation'; /** diff --git a/packages/javascript/src/utils/__tests__/buildValidatorFromRules.test.ts b/packages/javascript/src/utils/__tests__/buildValidatorFromRules.test.ts index ce7c0c9..85ec8a2 100644 --- a/packages/javascript/src/utils/__tests__/buildValidatorFromRules.test.ts +++ b/packages/javascript/src/utils/__tests__/buildValidatorFromRules.test.ts @@ -17,7 +17,7 @@ */ import {describe, it, expect} from 'vitest'; -import buildValidatorFromRules from '../v2/buildValidatorFromRules'; +import buildValidatorFromRules from '../buildValidatorFromRules'; describe('buildValidatorFromRules', () => { it('returns null when no rules are provided', () => { diff --git a/packages/javascript/src/utils/__tests__/evaluateValidationRule.test.ts b/packages/javascript/src/utils/__tests__/evaluateValidationRule.test.ts index da43de8..8de4fe0 100644 --- a/packages/javascript/src/utils/__tests__/evaluateValidationRule.test.ts +++ b/packages/javascript/src/utils/__tests__/evaluateValidationRule.test.ts @@ -21,7 +21,7 @@ // rule shapes (non-string regex value, non-numeric length, unknown rule type). import {describe, it, expect} from 'vitest'; -import evaluateValidationRule, {DEFAULT_VALIDATION_MESSAGE_KEYS} from '../v2/evaluateValidationRule'; +import evaluateValidationRule, {DEFAULT_VALIDATION_MESSAGE_KEYS} from '../evaluateValidationRule'; describe('evaluateValidationRule', () => { describe('regex', () => { diff --git a/packages/javascript/src/utils/__tests__/identifyPlatform.test.ts b/packages/javascript/src/utils/__tests__/identifyPlatform.test.ts deleted file mode 100644 index faec36b..0000000 --- a/packages/javascript/src/utils/__tests__/identifyPlatform.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -/** - * 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 {describe, it, expect, vi, afterEach} from 'vitest'; -import {Config} from '../../models/config'; -import {Platform} from '../../models/platforms'; -import identifyPlatform from '../identifyPlatform'; - -vi.mock('../logger', () => ({default: {debug: vi.fn(), warn: vi.fn()}})); - -describe('identifyPlatform', () => { - afterEach(() => { - vi.clearAllMocks(); - }); - - it('should return Platform.ThunderID for recognized thunderid domains', () => { - const configs: Config[] = [ - {applicationId: '', baseUrl: 'https://api.thunderid.io/t/org', clientId: ''}, - {applicationId: '', baseUrl: 'https://accounts.thunderid.io/t/org', clientId: ''}, - {applicationId: '', baseUrl: 'https://thunderid.io/t/org', clientId: ''}, - ]; - - configs.forEach((config: Config) => { - expect(identifyPlatform(config)).toBe(Platform.ThunderID); - }); - }); - - it('should return Platform.IdentityServer for non-thunderid recognized base Urls', () => { - const configs: Config[] = [ - {applicationId: '', baseUrl: 'https://localhost:9443/t/carbon.super', clientId: ''}, - {applicationId: '', baseUrl: 'https://is.dev.com/t/abc.com', clientId: ''}, - {applicationId: '', baseUrl: 'https://192.168.1.1/t/mytenant', clientId: ''}, - ]; - - configs.forEach((config: Config) => { - expect(identifyPlatform(config)).toBe(Platform.IdentityServer); - }); - }); - - it('should return Platform.IdentityServer if baseUrl is not recognized', () => { - const config: Config = {applicationId: '', baseUrl: undefined, clientId: ''}; - - expect(identifyPlatform(config)).toBe(Platform.Unknown); - }); - - it('should return Platform.IdentityServer if baseUrl is malformed', () => { - const config: Config = {applicationId: '', baseUrl: 'http://[::1', clientId: ''}; - - expect(identifyPlatform(config)).toBe(Platform.Unknown); - }); -}); diff --git a/packages/javascript/src/utils/__tests__/injectRequestedPermissions.test.ts b/packages/javascript/src/utils/__tests__/injectRequestedPermissions.test.ts index 0dbe418..ea681b6 100644 --- a/packages/javascript/src/utils/__tests__/injectRequestedPermissions.test.ts +++ b/packages/javascript/src/utils/__tests__/injectRequestedPermissions.test.ts @@ -17,7 +17,7 @@ */ import {describe, expect, it} from 'vitest'; -import injectRequestedPermissions from '../../utils/v2/injectRequestedPermissions'; +import injectRequestedPermissions from '../../utils/injectRequestedPermissions'; describe('injectRequestedPermissions', (): void => { it('joins multiple scopes into a space-separated requested_permissions string', (): void => { diff --git a/packages/javascript/src/utils/__tests__/resolveFieldType.test.ts b/packages/javascript/src/utils/__tests__/resolveFieldType.test.ts deleted file mode 100644 index bbccd1f..0000000 --- a/packages/javascript/src/utils/__tests__/resolveFieldType.test.ts +++ /dev/null @@ -1,76 +0,0 @@ -/** - * 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 {describe, it, expect} from 'vitest'; -import ThunderIDRuntimeError from '../../errors/ThunderIDRuntimeError'; -import { - EmbeddedSignInFlowAuthenticatorParamType, - EmbeddedSignInFlowAuthenticatorExtendedParamType, -} from '../../models/embedded-signin-flow'; -import {FieldType} from '../../models/field'; -import resolveFieldType from '../resolveFieldType'; - -describe('resolveFieldType', () => { - it('should return FieldType.Text for STRING fields without param/confidential', () => { - const field: {type: EmbeddedSignInFlowAuthenticatorParamType} = { - type: EmbeddedSignInFlowAuthenticatorParamType.String, - }; - expect(resolveFieldType(field)).toBe(FieldType.Text); - }); - - it('should return FieldType.Otp when STRING field has param = OTP (wins over confidential)', () => { - const field: { - confidential: boolean; - param: EmbeddedSignInFlowAuthenticatorExtendedParamType; - type: EmbeddedSignInFlowAuthenticatorParamType; - } = { - confidential: true, - param: EmbeddedSignInFlowAuthenticatorExtendedParamType.Otp, - type: EmbeddedSignInFlowAuthenticatorParamType.String, - }; - expect(resolveFieldType(field)).toBe(FieldType.Otp); - }); - - it('should return FieldType.Password for STRING fields with confidential=true (and non-OTP param)', () => { - const field: {confidential: boolean; param: string; type: EmbeddedSignInFlowAuthenticatorParamType} = { - confidential: true, - param: 'username', - type: EmbeddedSignInFlowAuthenticatorParamType.String, - }; - expect(resolveFieldType(field)).toBe(FieldType.Password); - }); - - it('should return FieldType.Text for STRING fields with non-OTP param and confidential=false', () => { - const field: {confidential: boolean; param: string; type: EmbeddedSignInFlowAuthenticatorParamType} = { - confidential: false, - param: 'username', - type: EmbeddedSignInFlowAuthenticatorParamType.String, - }; - expect(resolveFieldType(field)).toBe(FieldType.Text); - }); - - it('should throw ThunderIDRuntimeError for non-STRING types', () => { - const field: {type: string} = {type: 'number'}; - expect(() => resolveFieldType(field as any)).toThrow(ThunderIDRuntimeError); - expect(() => resolveFieldType(field as any)).toThrow('Field type is not supported'); - }); - - it('should throw a TypeError when field is undefined', () => { - expect(() => resolveFieldType(undefined as any)).toThrow(TypeError); - }); -}); diff --git a/packages/javascript/src/utils/v2/buildValidatorFromRules.ts b/packages/javascript/src/utils/buildValidatorFromRules.ts similarity index 96% rename from packages/javascript/src/utils/v2/buildValidatorFromRules.ts rename to packages/javascript/src/utils/buildValidatorFromRules.ts index a833d62..5f020ee 100644 --- a/packages/javascript/src/utils/v2/buildValidatorFromRules.ts +++ b/packages/javascript/src/utils/buildValidatorFromRules.ts @@ -17,7 +17,7 @@ */ import evaluateValidationRule from './evaluateValidationRule'; -import {ValidationRule} from '../../models/v2/embedded-flow-v2'; +import {ValidationRule} from '../models/embedded-flow'; /** * Composes an array of `ValidationRule`s into a single validator function suitable for diff --git a/packages/javascript/src/utils/v2/containsMetaFlowTemplateLiteral.ts b/packages/javascript/src/utils/containsMetaFlowTemplateLiteral.ts similarity index 100% rename from packages/javascript/src/utils/v2/containsMetaFlowTemplateLiteral.ts rename to packages/javascript/src/utils/containsMetaFlowTemplateLiteral.ts diff --git a/packages/javascript/src/utils/v2/countryCodeToFlagEmoji.ts b/packages/javascript/src/utils/countryCodeToFlagEmoji.ts similarity index 100% rename from packages/javascript/src/utils/v2/countryCodeToFlagEmoji.ts rename to packages/javascript/src/utils/countryCodeToFlagEmoji.ts diff --git a/packages/javascript/src/utils/v2/evaluateValidationRule.ts b/packages/javascript/src/utils/evaluateValidationRule.ts similarity index 97% rename from packages/javascript/src/utils/v2/evaluateValidationRule.ts rename to packages/javascript/src/utils/evaluateValidationRule.ts index 19f9dc0..90fc2e7 100644 --- a/packages/javascript/src/utils/v2/evaluateValidationRule.ts +++ b/packages/javascript/src/utils/evaluateValidationRule.ts @@ -16,7 +16,7 @@ * under the License. */ -import {ValidationRule, ValidationRuleType} from '../../models/v2/embedded-flow-v2'; +import {ValidationRule, ValidationRuleType} from '../models/embedded-flow'; /** * Default i18n fallback keys returned when a `ValidationRule.message` is not provided. diff --git a/packages/javascript/src/utils/v2/extractEmojiFromUri.ts b/packages/javascript/src/utils/extractEmojiFromUri.ts similarity index 100% rename from packages/javascript/src/utils/v2/extractEmojiFromUri.ts rename to packages/javascript/src/utils/extractEmojiFromUri.ts diff --git a/packages/javascript/src/utils/getRedirectBasedSignUpUrl.ts b/packages/javascript/src/utils/getRedirectBasedSignUpUrl.ts index c7a4766..207cde0 100644 --- a/packages/javascript/src/utils/getRedirectBasedSignUpUrl.ts +++ b/packages/javascript/src/utils/getRedirectBasedSignUpUrl.ts @@ -16,11 +16,9 @@ * under the License. */ -import identifyPlatform from './identifyPlatform'; import isRecognizedBaseUrlPattern from './isRecognizedBaseUrlPattern'; import logger from './logger'; import {Config} from '../models/config'; -import {Platform} from '../models/platforms'; /** * Utility to generate the redirect-based sign-up URL for ThunderID. @@ -28,7 +26,7 @@ import {Platform} from '../models/platforms'; * If the baseUrl is recognized (standard ThunderID pattern), constructs the sign-up URL. * Otherwise, returns an empty string. * - * @param baseUrl - The base URL of the ThunderID identity server (string or undefined) + * @param config - The ThunderID client configuration * @returns The sign-up URL if baseUrl is recognized, otherwise an empty string */ const getRedirectBasedSignUpUrl = (config: Config): string => { @@ -38,20 +36,18 @@ const getRedirectBasedSignUpUrl = (config: Config): string => { let signUpBaseUrl: string = baseUrl!; - if (identifyPlatform(config) === Platform.ThunderID) { - try { - const url: URL = new URL(baseUrl!); + try { + const url: URL = new URL(baseUrl!); - // Replace 'api.' with 'accounts.' in the hostname, preserving subdomains like 'dev.' - if (/([a-z0-9-]+\.)*api\.thunderid\.io$/i.test(url.hostname)) { - url.hostname = url.hostname.replace('api.', 'accounts.'); - signUpBaseUrl = url.toString().replace(/\/$/, ''); // Remove trailing slash if any - } - } catch { - logger.debug( - `[getRedirectBasedSignUpUrl] Could not parse base URL to replace 'api.' with 'accounts.'. Base URL: ${baseUrl}`, - ); + // Replace 'api.' with 'accounts.' in the hostname, preserving subdomains like 'dev.' + if (/([a-z0-9-]+\.)*api\.thunderid\.io$/i.test(url.hostname)) { + url.hostname = url.hostname.replace('api.', 'accounts.'); + signUpBaseUrl = url.toString().replace(/\/$/, ''); } + } catch { + logger.debug( + `[getRedirectBasedSignUpUrl] Could not parse base URL to replace 'api.' with 'accounts.'. Base URL: ${baseUrl}`, + ); } const url: URL = new URL(`${signUpBaseUrl}/accountrecoveryendpoint/register.do`); diff --git a/packages/javascript/src/utils/identifyPlatform.ts b/packages/javascript/src/utils/identifyPlatform.ts deleted file mode 100644 index f96a4a4..0000000 --- a/packages/javascript/src/utils/identifyPlatform.ts +++ /dev/null @@ -1,61 +0,0 @@ -/** - * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com). All Rights Reserved. - * - * 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 isRecognizedBaseUrlPattern from './isRecognizedBaseUrlPattern'; -import logger from './logger'; -import {Config} from '../models/config'; -import {Platform} from '../models/platforms'; - -/** - * Identifies the platform based on the given base URL. - * - * If the URL is recognized and matches the ThunderID domain, returns Platform.ThunderID. - * Otherwise, returns Platform.IdentityServer. - * - * @param baseUrl - The base URL to check - * @returns Platform enum value - */ -const identifyPlatform = (config: Config): Platform => { - const {baseUrl} = config; - - try { - if (isRecognizedBaseUrlPattern(baseUrl)) { - try { - const url: URL = new URL(baseUrl!); - if (/\.thunderid\.io$/i.test(url.hostname) || /thunderid\.io$/i.test(url.hostname)) { - return Platform.ThunderID; - } - } catch { - // Fallback to IdentityServer if URL parsing fails. - logger.debug( - `[identifyPlatform] Could not identify platform from the base URL: ${baseUrl}. Defaulting to WSO2 Identity Server as the platform.`, - ); - } - - return Platform.IdentityServer; - } - - return Platform.Unknown; - } catch (error) { - logger.debug(`[identifyPlatform] Error identifying platform from base URL: ${baseUrl}. Error: ${error.message}`); - - return Platform.Unknown; - } -}; - -export default identifyPlatform; diff --git a/packages/javascript/src/utils/v2/injectRequestedPermissions.ts b/packages/javascript/src/utils/injectRequestedPermissions.ts similarity index 100% rename from packages/javascript/src/utils/v2/injectRequestedPermissions.ts rename to packages/javascript/src/utils/injectRequestedPermissions.ts diff --git a/packages/javascript/src/utils/v2/isEmojiUri.ts b/packages/javascript/src/utils/isEmojiUri.ts similarity index 100% rename from packages/javascript/src/utils/v2/isEmojiUri.ts rename to packages/javascript/src/utils/isEmojiUri.ts diff --git a/packages/javascript/src/utils/v2/isMetaFlowTemplateLiteral.ts b/packages/javascript/src/utils/isMetaFlowTemplateLiteral.ts similarity index 100% rename from packages/javascript/src/utils/v2/isMetaFlowTemplateLiteral.ts rename to packages/javascript/src/utils/isMetaFlowTemplateLiteral.ts diff --git a/packages/javascript/src/utils/v2/isTranslationFlowTemplateLiteral.ts b/packages/javascript/src/utils/isTranslationFlowTemplateLiteral.ts similarity index 100% rename from packages/javascript/src/utils/v2/isTranslationFlowTemplateLiteral.ts rename to packages/javascript/src/utils/isTranslationFlowTemplateLiteral.ts diff --git a/packages/javascript/src/utils/v2/parseFlowTemplateLiteral.ts b/packages/javascript/src/utils/parseFlowTemplateLiteral.ts similarity index 100% rename from packages/javascript/src/utils/v2/parseFlowTemplateLiteral.ts rename to packages/javascript/src/utils/parseFlowTemplateLiteral.ts diff --git a/packages/javascript/src/utils/resolveFieldType.ts b/packages/javascript/src/utils/resolveFieldType.ts deleted file mode 100644 index 7de8452..0000000 --- a/packages/javascript/src/utils/resolveFieldType.ts +++ /dev/null @@ -1,47 +0,0 @@ -/** - * 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 ThunderIDRuntimeError from '../errors/ThunderIDRuntimeError'; -import { - EmbeddedSignInFlowAuthenticatorExtendedParamType, - EmbeddedSignInFlowAuthenticatorParamType, -} from '../models/embedded-signin-flow'; -import {FieldType} from '../models/field'; - -const resolveFieldType = (field: any): FieldType => { - if (field.type === EmbeddedSignInFlowAuthenticatorParamType.String) { - // Check if there's a `param` property and if it matches a known type. - if (field.param === EmbeddedSignInFlowAuthenticatorExtendedParamType.Otp) { - return FieldType.Otp; - } - if (field?.confidential) { - return FieldType.Password; - } - - return FieldType.Text; - } - - throw new ThunderIDRuntimeError( - `Field type is not supported: ${field.type}`, - 'resolveFieldType-Invalid-001', - 'javascript', - 'The provided field type is not supported. Please check the field configuration.', - ); -}; - -export default resolveFieldType; diff --git a/packages/javascript/src/utils/v2/resolveFlowTemplateLiterals.ts b/packages/javascript/src/utils/resolveFlowTemplateLiterals.ts similarity index 95% rename from packages/javascript/src/utils/v2/resolveFlowTemplateLiterals.ts rename to packages/javascript/src/utils/resolveFlowTemplateLiterals.ts index bc1ae3a..1c62b8f 100644 --- a/packages/javascript/src/utils/v2/resolveFlowTemplateLiterals.ts +++ b/packages/javascript/src/utils/resolveFlowTemplateLiterals.ts @@ -22,8 +22,8 @@ import parseFlowTemplateLiteral, { FlowTemplateLiteralType, } from './parseFlowTemplateLiteral'; import resolveMeta from './resolveMeta'; -import {TranslationFn} from '../../models/v2/translation'; -import {ResolveFlowTemplateLiteralsOptions} from '../../models/v2/vars'; +import {TranslationFn} from '../models/translation'; +import {ResolveFlowTemplateLiteralsOptions} from '../models/vars'; /** * Global version of {@link FLOW_TEMPLATE_LITERAL_REGEX} for use with `String.prototype.replace`. diff --git a/packages/javascript/src/utils/v2/resolveLocaleDisplayName.ts b/packages/javascript/src/utils/resolveLocaleDisplayName.ts similarity index 100% rename from packages/javascript/src/utils/v2/resolveLocaleDisplayName.ts rename to packages/javascript/src/utils/resolveLocaleDisplayName.ts diff --git a/packages/javascript/src/utils/v2/resolveLocaleEmoji.ts b/packages/javascript/src/utils/resolveLocaleEmoji.ts similarity index 100% rename from packages/javascript/src/utils/v2/resolveLocaleEmoji.ts rename to packages/javascript/src/utils/resolveLocaleEmoji.ts diff --git a/packages/javascript/src/utils/v2/resolveMeta.ts b/packages/javascript/src/utils/resolveMeta.ts similarity index 96% rename from packages/javascript/src/utils/v2/resolveMeta.ts rename to packages/javascript/src/utils/resolveMeta.ts index 005ea80..66b30c9 100644 --- a/packages/javascript/src/utils/v2/resolveMeta.ts +++ b/packages/javascript/src/utils/resolveMeta.ts @@ -16,7 +16,7 @@ * under the License. */ -import {FlowMetadataResponse} from '../../models/v2/flow-meta-v2'; +import {FlowMetadataResponse} from '../models/flow-meta'; /** * Resolves a dot-path expression against a FlowMetadataResponse object. diff --git a/packages/nextjs/src/ThunderIDNextClient.ts b/packages/nextjs/src/ThunderIDNextClient.ts index b3bef41..f84d055 100644 --- a/packages/nextjs/src/ThunderIDNextClient.ts +++ b/packages/nextjs/src/ThunderIDNextClient.ts @@ -22,8 +22,6 @@ import { ThunderIDRuntimeError, AuthClientConfig, CreateOrganizationPayload, - EmbeddedFlowExecuteRequestPayload, - EmbeddedFlowExecuteResponse, ExtendedAuthorizeRequestUrlParams, FlattenedSchema, IdToken, @@ -39,8 +37,6 @@ import { UserProfile, createOrganization, deriveOrganizationHandleFromBaseUrl, - executeEmbeddedSignInFlow, - executeEmbeddedSignUpFlow, extractUserClaimsFromIdToken, flattenUserSchema, generateFlattenedUserProfile, @@ -50,7 +46,6 @@ import { getOrganization, getScim2Me, getSchemas, - initializeEmbeddedSignInFlow, updateMeProfile, } from '@thunderid/node'; import {ThunderIDNextConfig} from './models/config'; @@ -397,27 +392,6 @@ class ThunderIDNextClient e const arg3: any = args[2]; const arg4: any = args[3]; - if (typeof arg1 === 'object' && 'flowId' in arg1) { - if (arg1.flowId === '') { - const defaultSignInUrl: URL = new URL( - await this.getAuthorizeRequestUrl({ - client_secret: '{{clientSecret}}', - response_mode: 'direct', - }), - ); - - return initializeEmbeddedSignInFlow({ - payload: Object.fromEntries(defaultSignInUrl.searchParams.entries()), - url: `${defaultSignInUrl.origin}${defaultSignInUrl.pathname}`, - }); - } - - return executeEmbeddedSignInFlow({ - payload: arg1, - url: arg2.url, - }); - } - return super.signIn(arg4, arg3, arg1?.code, arg1?.session_state, arg1?.state, arg1) as unknown as Promise; } @@ -444,32 +418,12 @@ class ThunderIDNextClient e return afterSignOutUrl; } - override async signUp(options?: SignUpOptions): Promise; - override async signUp(payload: EmbeddedFlowExecuteRequestPayload): Promise; - override async signUp(firstArg?: any): Promise { - if (firstArg === undefined || firstArg === null) { - throw new ThunderIDRuntimeError( - 'No arguments provided for signUp method.', - 'ThunderIDNextClient-ValidationError-001', - 'nextjs', - 'The signUp method requires at least one argument, either a SignUpOptions object or an EmbeddedFlowExecuteRequestPayload.', - ); - } - - if (typeof firstArg === 'object' && 'flowType' in firstArg) { - const configData: AuthClientConfig = await this.getStorageManager().getConfigData(); - const baseUrl: string | undefined = configData?.baseUrl; - - return executeEmbeddedSignUpFlow({ - baseUrl, - payload: firstArg as EmbeddedFlowExecuteRequestPayload, - }); - } + override async signUp(_options?: SignUpOptions): Promise { throw new ThunderIDRuntimeError( 'Not implemented', 'ThunderIDNextClient-ValidationError-002', 'nextjs', - 'The signUp method with SignUpOptions is not implemented in the Next.js client.', + 'The signUp method is not implemented in the Next.js client.', ); } diff --git a/packages/nextjs/src/client/components/presentation/SignIn/SignIn.tsx b/packages/nextjs/src/client/components/presentation/SignIn/SignIn.tsx index e5f6f1c..f0f7d8c 100644 --- a/packages/nextjs/src/client/components/presentation/SignIn/SignIn.tsx +++ b/packages/nextjs/src/client/components/presentation/SignIn/SignIn.tsx @@ -18,13 +18,7 @@ 'use client'; -import { - ThunderIDRuntimeError, - EmbeddedFlowExecuteRequestConfig, - EmbeddedSignInFlowHandleRequestPayload, - EmbeddedSignInFlowHandleResponse, - EmbeddedSignInFlowInitiateResponse, -} from '@thunderid/node'; +import {ThunderIDRuntimeError} from '@thunderid/node'; import {BaseSignIn, BaseSignInProps} from '@thunderid/react'; import {FC} from 'react'; import useThunderID from '../../../contexts/ThunderID/useThunderID'; @@ -39,62 +33,11 @@ export type SignInProps = Pick { - * const handleInitialize = async () => { - * return await executeEmbeddedSignInFlow({ - * response_mode: 'direct', - * }); - * }; - * - * const handleSubmit = async (flow) => { - * return await executeEmbeddedSignInFlow({ flow }); - * }; - * - * return ( - * { - * console.log('Authentication successful:', authData); - * }} - * onError={(error) => { - * console.error('Authentication failed:', error); - * }} - * size="medium" - * variant="outlined" - * afterSignInUrl="/dashboard" - * /> - * ); - * }; - * ``` */ const SignIn: FC = ({size = 'medium', variant = 'outlined', ...rest}: SignInProps) => { - const {signIn, afterSignInUrl} = useThunderID(); - - const handleInitialize = async (): Promise => - signIn && - (await signIn({ - flowId: '', - selectedAuthenticator: { - authenticatorId: '', - params: {}, - }, - })); + const {signIn} = useThunderID(); - const handleOnSubmit = async ( - payload: EmbeddedSignInFlowHandleRequestPayload, - request: EmbeddedFlowExecuteRequestConfig, - ): Promise => { + const handleOnSubmit = async (payload: any, request: any): Promise => { if (!signIn) { throw new ThunderIDRuntimeError( '`signIn` function is not available.', @@ -103,14 +46,12 @@ const SignIn: FC = ({size = 'medium', variant = 'outlined', ...rest ); } - return (await signIn(payload, request)) as Promise; + await signIn(payload, request); }; return ( = ({ /** * Initialize the sign-up flow. */ - const handleInitialize = async ( - payload?: EmbeddedFlowExecuteRequestPayload, - ): Promise => { + const handleInitialize = async (payload?: any): Promise => { if (!signUp) { throw new ThunderIDRuntimeError( '`signUp` function is not available.', @@ -92,13 +85,13 @@ const SignUp: FC = ({ ...(contextApplicationId && {applicationId: contextApplicationId}), ...(scopes && {scopes}), }, - )) as unknown as Promise; + )) as unknown as Promise; }; /** * Handle sign-up steps. */ - const handleOnSubmit = async (payload: EmbeddedFlowExecuteRequestPayload): Promise => { + const handleOnSubmit = async (payload: any): Promise => { if (!signUp) { throw new ThunderIDRuntimeError( '`signUp` function is not available.', @@ -107,7 +100,7 @@ const SignUp: FC = ({ ); } - return (await signUp(payload)) as unknown as Promise; + return (await signUp(payload)) as unknown as Promise; }; return ( diff --git a/packages/nextjs/src/client/contexts/ThunderID/ThunderIDProvider.tsx b/packages/nextjs/src/client/contexts/ThunderID/ThunderIDProvider.tsx index a29574b..500828d 100644 --- a/packages/nextjs/src/client/contexts/ThunderID/ThunderIDProvider.tsx +++ b/packages/nextjs/src/client/contexts/ThunderID/ThunderIDProvider.tsx @@ -21,8 +21,6 @@ import { AllOrganizationsApiResponse, EmbeddedFlowExecuteRequestConfig, - EmbeddedFlowExecuteRequestPayload, - EmbeddedSignInFlowHandleRequestPayload, generateFlattenedUserProfile, Organization, UpdateMeProfileConfig, @@ -193,10 +191,7 @@ const ThunderIDClientProvider: FC => { + const handleSignIn = async (payload: any, request: EmbeddedFlowExecuteRequestConfig): Promise => { if (!signIn) { throw new ThunderIDRuntimeError( '`signIn` function is not available.', @@ -228,10 +223,7 @@ const ThunderIDClientProvider: FC => { + const handleSignUp = async (payload: any, request: EmbeddedFlowExecuteRequestConfig): Promise => { if (!signUp) { throw new ThunderIDRuntimeError( '`signUp` function is not available.', diff --git a/packages/nextjs/src/server/actions/signInAction.ts b/packages/nextjs/src/server/actions/signInAction.ts index 0f4cabe..fe97285 100644 --- a/packages/nextjs/src/server/actions/signInAction.ts +++ b/packages/nextjs/src/server/actions/signInAction.ts @@ -21,9 +21,7 @@ import { generateSessionId, EmbeddedSignInFlowStatus, - EmbeddedSignInFlowHandleRequestPayload, EmbeddedFlowExecuteRequestConfig, - EmbeddedSignInFlowInitiateResponse, IdToken, isEmpty, } from '@thunderid/node'; @@ -44,7 +42,7 @@ type RequestCookies = Awaited>; * @returns Promise that resolves when sign-in is complete */ const signInAction = async ( - payload?: EmbeddedSignInFlowHandleRequestPayload, + payload?: any, request?: EmbeddedFlowExecuteRequestConfig, ): Promise<{ data?: @@ -52,7 +50,7 @@ const signInAction = async ( afterSignInUrl?: string; signInUrl?: string; } - | EmbeddedSignInFlowInitiateResponse; + | Record; error?: string; success: boolean; }> => { @@ -107,7 +105,7 @@ const signInAction = async ( // Handle embedded sign-in flow const response: any = await client.signIn(payload, request!, sessionId); - if (response.flowStatus === EmbeddedSignInFlowStatus.SuccessCompleted) { + if (response.flowStatus === EmbeddedSignInFlowStatus.Complete) { const signInResult: Record = await client.signIn( { code: response?.authData?.code, @@ -163,7 +161,7 @@ const signInAction = async ( return {data: {afterSignInUrl: String(afterSignInUrl)}, success: true}; } - return {data: response as EmbeddedSignInFlowInitiateResponse, success: true}; + return {data: response as Record, success: true}; } catch (error) { logger.error(`[signInAction] Error during sign-in: ${error instanceof Error ? error.message : String(error)}`); return {error: String(error), success: false}; diff --git a/packages/nextjs/src/server/actions/signUpAction.ts b/packages/nextjs/src/server/actions/signUpAction.ts index 832db13..48a92f8 100644 --- a/packages/nextjs/src/server/actions/signUpAction.ts +++ b/packages/nextjs/src/server/actions/signUpAction.ts @@ -18,48 +18,22 @@ 'use server'; -import {EmbeddedFlowExecuteRequestPayload, EmbeddedFlowExecuteResponse, EmbeddedFlowStatus} from '@thunderid/node'; import getClient from '../getClient'; /** - * Server action for signing in a user. - * Handles the embedded sign-in flow and manages session cookies. - * - * @param payload - The embedded sign-in flow payload - * @param request - The embedded flow execute request config - * @returns Promise that resolves when sign-in is complete + * Server action for initiating the sign-up redirect flow. */ -const signUpAction = async ( - payload?: EmbeddedFlowExecuteRequestPayload, -): Promise<{ - data?: - | { - afterSignUpUrl?: string; - signUpUrl?: string; - } - | EmbeddedFlowExecuteResponse; +const signUpAction = async (): Promise<{ + data?: {signUpUrl?: string}; error?: string; success: boolean; }> => { try { const client = getClient(); + const config = client.getConfiguration() as any; + const signUpUrl: string = config?.signUpUrl ?? ''; - // If no payload provided, redirect to sign-in URL for redirect-based sign-in. - // If there's a payload, handle the embedded sign-in flow. - if (!payload) { - const defaultSignUpUrl = ''; - - return {data: {signUpUrl: String(defaultSignUpUrl)}, success: true}; - } - const response: any = await client.signUp(payload); - - if (response.flowStatus === EmbeddedFlowStatus.Complete) { - const afterSignUpUrl: string = await (await client.getStorageManager()).getConfigDataParameter('afterSignInUrl'); - - return {data: {afterSignUpUrl: String(afterSignUpUrl)}, success: true}; - } - - return {data: response as EmbeddedFlowExecuteResponse, success: true}; + return {data: {signUpUrl}, success: true}; } catch (error) { return {error: String(error), success: false}; } diff --git a/packages/nuxt/src/runtime/components/auth/SignIn.ts b/packages/nuxt/src/runtime/components/auth/SignIn.ts index 695009d..6ccf5ad 100644 --- a/packages/nuxt/src/runtime/components/auth/SignIn.ts +++ b/packages/nuxt/src/runtime/components/auth/SignIn.ts @@ -17,11 +17,6 @@ */ import {navigateTo} from '#app'; -import { - type EmbeddedSignInFlowHandleRequestPayload, - type EmbeddedSignInFlowHandleResponse, - type EmbeddedSignInFlowInitiateResponse, -} from '@thunderid/browser'; import {BaseSignIn} from '@thunderid/vue'; import {type Component, type PropType, type SetupContext, type VNode, defineComponent, h} from 'vue'; import {useThunderID} from '#imports'; @@ -64,16 +59,12 @@ const SignIn: Component = defineComponent({ ): () => VNode { const {signIn, afterSignInUrl, isInitialized, isLoading} = useThunderID(); - const handleInitialize = async (): Promise => + const handleInitialize = async (): Promise => // Pass flowId='' to trigger the embedded-flow initiation path in useThunderID. // eslint-disable-next-line @typescript-eslint/no-explicit-any - (await signIn({flowId: ''} as any, {} as any)) as EmbeddedSignInFlowInitiateResponse; + await signIn({flowId: ''} as any, {} as any); - const handleOnSubmit = async ( - payload: EmbeddedSignInFlowHandleRequestPayload, - request: any, - ): Promise => - (await signIn(payload, request)) as EmbeddedSignInFlowHandleResponse; + const handleOnSubmit = async (payload: any, request: any): Promise => await signIn(payload, request); const handleSuccess = async (authData: Record): Promise => { emit('success', authData); diff --git a/packages/nuxt/src/runtime/components/auth/SignUp.ts b/packages/nuxt/src/runtime/components/auth/SignUp.ts index 76e8e72..6ee8e78 100644 --- a/packages/nuxt/src/runtime/components/auth/SignUp.ts +++ b/packages/nuxt/src/runtime/components/auth/SignUp.ts @@ -17,12 +17,7 @@ */ import {navigateTo} from '#app'; -import { - type EmbeddedFlowExecuteRequestPayload, - type EmbeddedFlowExecuteResponse, - EmbeddedFlowResponseType, - EmbeddedFlowType, -} from '@thunderid/browser'; +import {EmbeddedFlowResponseType, EmbeddedFlowType} from '@thunderid/browser'; import {BaseSignUp} from '@thunderid/vue'; import type {BaseSignUpRenderProps} from '@thunderid/vue'; import {type Component, type PropType, type SetupContext, type VNode, defineComponent, h} from 'vue'; @@ -57,7 +52,7 @@ const SignUp: Component = defineComponent({ errorClassName: {default: '', type: String}, inputClassName: {default: '', type: String}, messageClassName: {default: '', type: String}, - onComplete: {default: undefined, type: Function as PropType<(response: EmbeddedFlowExecuteResponse) => void>}, + onComplete: {default: undefined, type: Function as PropType<(response: any) => void>}, onError: {default: undefined, type: Function as PropType<(error: Error) => void>}, shouldRedirectAfterSignUp: {default: true, type: Boolean}, showSubtitle: {default: true, type: Boolean}, @@ -68,9 +63,7 @@ const SignUp: Component = defineComponent({ setup(props: any, {slots}: SetupContext): () => VNode | null { const {signUp, isInitialized, applicationId, scopes} = useThunderID(); - const handleInitialize = async ( - payload?: EmbeddedFlowExecuteRequestPayload, - ): Promise => { + const handleInitialize = async (payload?: any): Promise => { // Guard URL parsing โ€” `window` is only available on the client. let applicationIdFromUrl: string | null = null; if (import.meta.client) { @@ -90,16 +83,15 @@ const SignUp: Component = defineComponent({ ...(scopes && {scopes}), }; - return (await signUp(initialPayload)) as EmbeddedFlowExecuteResponse; + return (await signUp(initialPayload)) as any; }; - const handleOnSubmit = async (payload: EmbeddedFlowExecuteRequestPayload): Promise => - (await signUp(payload)) as EmbeddedFlowExecuteResponse; + const handleOnSubmit = async (payload: any): Promise => (await signUp(payload)) as any; - const handleComplete = async (response: EmbeddedFlowExecuteResponse): Promise => { + const handleComplete = async (response: any): Promise => { props.onComplete?.(response); - const oauthRedirectUrl: string | undefined = (response as any)?.redirectUrl; + const oauthRedirectUrl: string | undefined = response?.redirectUrl; if (props.shouldRedirectAfterSignUp && oauthRedirectUrl) { // Use navigateTo instead of window.location.href โ€” SSR-safe. await navigateTo(oauthRedirectUrl, {external: true}); @@ -110,7 +102,7 @@ const SignUp: Component = defineComponent({ props.shouldRedirectAfterSignUp && response?.type !== EmbeddedFlowResponseType.Redirection && props.afterSignUpUrl && - !(response as any)?.assertion + !response?.assertion ) { await navigateTo(props.afterSignUpUrl, {external: true}); } diff --git a/packages/nuxt/src/runtime/composables/useThunderID.ts b/packages/nuxt/src/runtime/composables/useThunderID.ts index aa8e1e4..b806e6f 100644 --- a/packages/nuxt/src/runtime/composables/useThunderID.ts +++ b/packages/nuxt/src/runtime/composables/useThunderID.ts @@ -72,7 +72,7 @@ export function useThunderID(): ThunderIDContext { // Flow complete โ€” server has set the session cookie. Refresh the client // auth state so `useThunderID().isSignedIn` flips to true *immediately* // (without waiting for a full page reload). Then return a synthetic - // SuccessCompleted response so `BaseSignIn` emits its `success` event + // Complete response so `BaseSignIn` emits its `success` event // and the wrapper component (``) drives navigation via // `onSuccess`. // @@ -92,7 +92,7 @@ export function useThunderID(): ThunderIDContext { } return { authData: {}, - flowStatus: EmbeddedSignInFlowStatus.SuccessCompleted, + flowStatus: EmbeddedSignInFlowStatus.Complete, }; } return res.data; @@ -127,22 +127,6 @@ export function useThunderID(): ThunderIDContext { const signUp = async (...args: any[]): Promise => { const payload: unknown = args[0]; - // Embedded flow โ€” payload must look like an EmbeddedFlowExecuteRequestPayload - // (i.e. have a `flowType` field). Plain options objects without `flowType` - // fall through to the redirect path so `signUp({applicationId: '...'})` - // still goes to the hosted register page. - if (payload && typeof payload === 'object' && 'flowType' in payload) { - const res: {data: any; success: boolean} = await $fetch<{data: any; success: boolean}>('/api/auth/signup', { - body: {payload}, - method: 'POST', - }); - if (res.data?.afterSignUpUrl) { - await navigateTo(res.data.afterSignUpUrl as string, {external: false}); - return undefined; - } - return res.data; - } - // Redirect flow. const cfg: { applicationId?: string; diff --git a/packages/nuxt/src/runtime/server/ThunderIDNuxtClient.ts b/packages/nuxt/src/runtime/server/ThunderIDNuxtClient.ts index 1055b50..d647975 100644 --- a/packages/nuxt/src/runtime/server/ThunderIDNuxtClient.ts +++ b/packages/nuxt/src/runtime/server/ThunderIDNuxtClient.ts @@ -35,13 +35,6 @@ import { getAllOrganizations, createOrganization, getOrganization, - initializeEmbeddedSignInFlow, - executeEmbeddedSignInFlow, - executeEmbeddedSignUpFlow, - type EmbeddedSignInFlowHandleRequestPayload, - type EmbeddedFlowExecuteRequestConfig, - type EmbeddedFlowExecuteRequestPayload, - type EmbeddedFlowExecuteResponse, type ExtendedAuthorizeRequestUrlParams, type SignUpOptions, type GetBrandingPreferenceConfig, @@ -119,28 +112,6 @@ class ThunderIDNuxtClient extends ThunderIDNodeClient { override signIn(...args: any[]): Promise { const arg0: unknown = args[0]; - if (typeof arg0 === 'object' && arg0 !== null && 'flowId' in arg0) { - const sessionId: string | undefined = args[2] as string | undefined; - - if ((arg0 as any).flowId === '') { - return this.getSignInUrl({client_secret: '{{clientSecret}}', response_mode: 'direct'}, sessionId).then( - (authorizeUrl: string) => { - const url: URL = new URL(authorizeUrl); - return initializeEmbeddedSignInFlow({ - payload: Object.fromEntries(url.searchParams.entries()), - url: `${url.origin}${url.pathname}`, - }); - }, - ); - } - - const request: EmbeddedFlowExecuteRequestConfig = args[1] ?? {}; - return executeEmbeddedSignInFlow({ - payload: arg0 as EmbeddedSignInFlowHandleRequestPayload, - url: request.url, - }); - } - if (typeof arg0 === 'object' && arg0 !== null && ('code' in arg0 || 'state' in arg0)) { const payload: {code?: unknown; session_state?: unknown; state?: unknown} = arg0 as { code?: unknown; @@ -163,21 +134,8 @@ class ThunderIDNuxtClient extends ThunderIDNodeClient { return super.signIn(args[0], args[1], args[2], args[3], args[4], args[5]); } - override signUp(options?: SignUpOptions): Promise; - override signUp(payload: EmbeddedFlowExecuteRequestPayload): Promise; - override async signUp( - payloadOrOptions?: EmbeddedFlowExecuteRequestPayload | SignUpOptions, - ): Promise { - if (!payloadOrOptions || !('flowType' in payloadOrOptions)) { - return undefined; - } - const configData: any = this.getStorageManager().getConfigData(); - const baseUrl: string | undefined = configData?.baseUrl as string | undefined; - const response: EmbeddedFlowExecuteResponse = await executeEmbeddedSignUpFlow({ - baseUrl, - payload: payloadOrOptions as EmbeddedFlowExecuteRequestPayload, - }); - return response; + override async signUp(_options?: SignUpOptions): Promise { + return undefined; } public async getAuthorizeRequestUrl( diff --git a/packages/nuxt/src/runtime/server/routes/auth/session/signin.post.ts b/packages/nuxt/src/runtime/server/routes/auth/session/signin.post.ts index e5667a2..54622e1 100644 --- a/packages/nuxt/src/runtime/server/routes/auth/session/signin.post.ts +++ b/packages/nuxt/src/runtime/server/routes/auth/session/signin.post.ts @@ -45,7 +45,7 @@ function isTokenResponse(value: unknown): value is TokenResponse { * Handles embedded (app-native) sign-in flow steps. * * Request body: - * - `payload` โ€” the embedded flow step payload (`EmbeddedSignInFlowHandleRequestPayload`). + * - `payload` โ€” the embedded flow step payload. * When omitted or `{}`, the flow is initialised and the authorize URL is returned. * - `request` โ€” optional per-step config (e.g. `{ url }` override). * @@ -123,7 +123,7 @@ export default defineEventHandler(async (event: H3Event) => { } // โ”€โ”€ Flow complete โ€” exchange code for tokens and issue session cookie โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - if ((response as {flowStatus?: unknown})?.flowStatus === EmbeddedSignInFlowStatus.SuccessCompleted) { + if ((response as {flowStatus?: unknown})?.flowStatus === EmbeddedSignInFlowStatus.Complete) { const authData: {code?: string; session_state?: string; state?: string} = (response as {authData?: {code?: string; session_state?: string; state?: string}})?.authData ?? {}; const {code, state, session_state: sessionState} = authData; diff --git a/packages/nuxt/src/runtime/server/routes/auth/session/signup.post.ts b/packages/nuxt/src/runtime/server/routes/auth/session/signup.post.ts index 5b947c3..1d094f9 100644 --- a/packages/nuxt/src/runtime/server/routes/auth/session/signup.post.ts +++ b/packages/nuxt/src/runtime/server/routes/auth/session/signup.post.ts @@ -16,13 +16,13 @@ * under the License. */ -import {EmbeddedFlowStatus} from '@thunderid/node'; +import {EmbeddedSignUpFlowStatus} from '@thunderid/node'; import {defineEventHandler, readBody, createError} from 'h3'; import type {H3Event} from 'h3'; import ThunderIDNuxtClient from '../../../ThunderIDNuxtClient'; import {useRuntimeConfig} from '#imports'; -function hasFlowStatus(value: unknown): value is {flowStatus?: EmbeddedFlowStatus} { +function hasFlowStatus(value: unknown): value is {flowStatus?: EmbeddedSignUpFlowStatus} { return typeof value === 'object' && value !== null && 'flowStatus' in value; } @@ -69,7 +69,7 @@ export default defineEventHandler(async (event: H3Event) => { } // โ”€โ”€ Flow complete โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - if (hasFlowStatus(response) && response.flowStatus === EmbeddedFlowStatus.Complete) { + if (hasFlowStatus(response) && response.flowStatus === EmbeddedSignUpFlowStatus.Complete) { return {data: {afterSignUpUrl}, success: true}; } diff --git a/packages/nuxt/src/runtime/types.ts b/packages/nuxt/src/runtime/types.ts index fadf9b9..58a80d3 100644 --- a/packages/nuxt/src/runtime/types.ts +++ b/packages/nuxt/src/runtime/types.ts @@ -20,7 +20,6 @@ import type { BrandingPreference, I18nPreferences, Organization, - Platform, TokenEndpointAuthMethod, User, UserProfile, @@ -46,12 +45,6 @@ export interface ThunderIDNuxtConfig { clientId?: string; /** OAuth2 Client Secret (server-only, use THUNDERID_CLIENT_SECRET env var) */ clientSecret?: string; - /** - * Identity platform variant. Set to `Platform.ThunderID` when connecting to - * a Thunder (ThunderIDV2) instance. Forwarded to the underlying Node client so - * platform-specific behaviours (e.g. issuer resolution) apply correctly. - */ - platform?: keyof typeof Platform; /** * Feature-gating preferences that control which server-side data fetches * the Nitro plugin performs on every SSR request. diff --git a/packages/nuxt/tests/unit/signin-post.test.ts b/packages/nuxt/tests/unit/signin-post.test.ts index 9218b57..5afc2a6 100644 --- a/packages/nuxt/tests/unit/signin-post.test.ts +++ b/packages/nuxt/tests/unit/signin-post.test.ts @@ -90,7 +90,7 @@ vi.mock('../../src/runtime/server/utils/serverSession', () => ({ vi.mock('@thunderid/node', () => ({ generateSessionId: vi.fn().mockReturnValue('new-session-id'), isEmpty: vi.fn((obj: any) => !obj || Object.keys(obj).length === 0), - EmbeddedSignInFlowStatus: {SuccessCompleted: 'SUCCESS_COMPLETED'}, + EmbeddedSignInFlowStatus: {Complete: 'COMPLETE'}, })); // โ”€โ”€ Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ diff --git a/packages/react/src/ThunderIDReactClient.ts b/packages/react/src/ThunderIDReactClient.ts index dc86c54..71127d6 100644 --- a/packages/react/src/ThunderIDReactClient.ts +++ b/packages/react/src/ThunderIDReactClient.ts @@ -21,12 +21,9 @@ import { UserProfile, SignInOptions, User, - EmbeddedFlowExecuteResponse, SignUpOptions, - EmbeddedFlowExecuteRequestPayload, ThunderIDRuntimeError, - EmbeddedSignInFlowHandleRequestPayload, - executeEmbeddedSignInFlowV2, + executeEmbeddedSignInFlow, Organization, IdToken, deriveOrganizationHandleFromBaseUrl, @@ -37,11 +34,11 @@ import { HttpResponse, TokenExchangeRequestConfig, isEmpty, - EmbeddedSignInFlowResponseV2, - executeEmbeddedSignUpFlowV2, - executeEmbeddedRecoveryFlowV2, - EmbeddedSignInFlowStatusV2, - EmbeddedSignUpFlowStatusV2, + EmbeddedSignInFlowResponse, + executeEmbeddedSignUpFlow, + executeEmbeddedRecoveryFlow, + EmbeddedSignInFlowStatus, + EmbeddedSignUpFlowStatus, } from '@thunderid/browser'; import getAllOrganizations from './api/getAllOrganizations'; import getMeOrganizations from './api/getMeOrganizations'; @@ -251,7 +248,7 @@ class ThunderIDReactClient super.exchangeToken(config) as unknown as TokenResponse | Response); } - override async signIn(...args: any[]): Promise { + override async signIn(...args: any[]): Promise { return this.withLoading(async () => { const arg1: any = args[0]; const arg2: any = args[1]; @@ -283,17 +280,17 @@ class ThunderIDReactClient; - override async signUp(payload: EmbeddedFlowExecuteRequestPayload): Promise; - override async signUp(...args: any[]): Promise { + override async signUp(payload: any): Promise; + override async signUp(...args: any[]): Promise { const config: ThunderIDReactConfig = (await this.getStorageManager().getConfigData()) as ThunderIDReactConfig; const firstArg: any = args[0]; const baseUrl: string = config?.baseUrl ?? ''; @@ -351,19 +348,16 @@ class ThunderIDReactClient { + override async recover(payload?: any): Promise { const config: ThunderIDReactConfig = (await this.getStorageManager().getConfigData()) as ThunderIDReactConfig; - return executeEmbeddedRecoveryFlowV2({ + return executeEmbeddedRecoveryFlow({ baseUrl: config?.baseUrl, payload: {...payload, verbose: true}, }) as any; diff --git a/packages/react/src/components/adapters/CheckboxInput.tsx b/packages/react/src/components/adapters/CheckboxInput.tsx deleted file mode 100644 index ee4ad9c..0000000 --- a/packages/react/src/components/adapters/CheckboxInput.tsx +++ /dev/null @@ -1,53 +0,0 @@ -/** - * 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 {FieldType} from '@thunderid/browser'; -import {FC} from 'react'; -import {AdapterProps} from '../../models/adapters'; -import {createField} from '../factories/FieldFactory'; - -/** - * Checkbox input component for sign-up forms. - */ -const CheckboxInput: FC = ({ - component, - formValues, - touchedFields, - formErrors, - onInputChange, - inputClassName, -}: AdapterProps) => { - const config: Record = component.config || {}; - const fieldName: string = (config['identifier'] as string) || (config['name'] as string) || component.id; - const value: string | boolean = formValues[fieldName] || false; - const error: string | undefined = touchedFields[fieldName] ? formErrors[fieldName] : undefined; - - return createField({ - className: inputClassName, - error, - label: (config['label'] as string) || '', - name: fieldName, - onChange: (newValue: string) => onInputChange(fieldName, newValue), - placeholder: (config['placeholder'] as string) || '', - required: (config['required'] as boolean) || false, - type: FieldType.Checkbox, - value: value as string, - }); -}; - -export default CheckboxInput; diff --git a/packages/react/src/components/adapters/Consent.tsx b/packages/react/src/components/adapters/Consent.tsx index 4c4ea3a..42bf87f 100644 --- a/packages/react/src/components/adapters/Consent.tsx +++ b/packages/react/src/components/adapters/Consent.tsx @@ -16,7 +16,7 @@ * under the License. */ -import {type ConsentPurposeDataV2 as ConsentPurposeData} from '@thunderid/browser'; +import {type ConsentPurposeData} from '@thunderid/browser'; import {FC, ReactNode} from 'react'; import ConsentCheckboxList from './ConsentCheckboxList'; import Typography from '../primitives/Typography/Typography'; diff --git a/packages/react/src/components/adapters/ConsentCheckboxList.tsx b/packages/react/src/components/adapters/ConsentCheckboxList.tsx index 1a9813a..815506a 100644 --- a/packages/react/src/components/adapters/ConsentCheckboxList.tsx +++ b/packages/react/src/components/adapters/ConsentCheckboxList.tsx @@ -17,7 +17,7 @@ */ import {cx} from '@emotion/css'; -import {type ConsentPurposeDataV2 as ConsentPurposeData, withVendorCSSClassPrefix, bem} from '@thunderid/browser'; +import {type ConsentPurposeData, withVendorCSSClassPrefix, bem} from '@thunderid/browser'; import {type ChangeEvent, FC, ReactNode} from 'react'; import useStyles from './ConsentCheckboxList.styles'; import useTheme from '../../contexts/Theme/useTheme'; diff --git a/packages/react/src/components/adapters/DateInput.tsx b/packages/react/src/components/adapters/DateInput.tsx deleted file mode 100644 index 410d337..0000000 --- a/packages/react/src/components/adapters/DateInput.tsx +++ /dev/null @@ -1,53 +0,0 @@ -/** - * 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 {FieldType} from '@thunderid/browser'; -import {FC} from 'react'; -import {AdapterProps} from '../../models/adapters'; -import {createField} from '../factories/FieldFactory'; - -/** - * Date input component for sign-up forms. - */ -const DateInput: FC = ({ - component, - formValues, - touchedFields, - formErrors, - onInputChange, - inputClassName, -}: AdapterProps) => { - const config: Record = component.config || {}; - const fieldName: string = (config['identifier'] as string) || (config['name'] as string) || component.id; - const value: string = formValues[fieldName] || ''; - const error: string | undefined = touchedFields[fieldName] ? formErrors[fieldName] : undefined; - - return createField({ - className: inputClassName, - error, - label: (config['label'] as string) || '', - name: fieldName, - onChange: (newValue: string) => onInputChange(fieldName, newValue), - placeholder: (config['placeholder'] as string) || '', - required: (config['required'] as boolean) || false, - type: FieldType.Date, - value, - }); -}; - -export default DateInput; diff --git a/packages/react/src/components/adapters/DividerComponent.tsx b/packages/react/src/components/adapters/DividerComponent.tsx deleted file mode 100644 index 85ca5db..0000000 --- a/packages/react/src/components/adapters/DividerComponent.tsx +++ /dev/null @@ -1,44 +0,0 @@ -/** - * 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} from 'react'; -import useTheme from '../../contexts/Theme/useTheme'; -import {AdapterProps} from '../../models/adapters'; -import Divider from '../primitives/Divider/Divider'; - -/** - * Divider component for sign-up forms. - */ -const DividerComponent: FC = ({component}: AdapterProps) => { - const {theme} = useTheme(); - const config: Record = component.config || {}; - const text: string = (config['text'] as string) || ''; - const variant: string = component.variant?.toLowerCase() || 'horizontal'; - - return ( - - {text} - - ); -}; - -export default DividerComponent; diff --git a/packages/react/src/components/adapters/EmailInput.tsx b/packages/react/src/components/adapters/EmailInput.tsx deleted file mode 100644 index 1fec1a2..0000000 --- a/packages/react/src/components/adapters/EmailInput.tsx +++ /dev/null @@ -1,53 +0,0 @@ -/** - * 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 {FieldType} from '@thunderid/browser'; -import {FC} from 'react'; -import {AdapterProps} from '../../models/adapters'; -import {createField} from '../factories/FieldFactory'; - -/** - * Email input component for sign-up forms. - */ -const EmailInput: FC = ({ - component, - formValues, - touchedFields, - formErrors, - onInputChange, - inputClassName, -}: AdapterProps) => { - const config: Record = component.config || {}; - const fieldName: string = (config['identifier'] as string) || (config['name'] as string) || component.id; - const value: string = formValues[fieldName] || ''; - const error: string | undefined = touchedFields[fieldName] ? formErrors[fieldName] : undefined; - - return createField({ - className: inputClassName, - error, - label: (config['label'] as string) || 'Email', - name: fieldName, - onChange: (newValue: string) => onInputChange(fieldName, newValue), - placeholder: (config['placeholder'] as string) || 'Enter your email', - required: (config['required'] as boolean) || false, - type: FieldType.Email, - value, - }); -}; - -export default EmailInput; diff --git a/packages/react/src/components/adapters/FormContainer.tsx b/packages/react/src/components/adapters/FormContainer.tsx deleted file mode 100644 index 39dc128..0000000 --- a/packages/react/src/components/adapters/FormContainer.tsx +++ /dev/null @@ -1,63 +0,0 @@ -/** - * 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, FormEvent} from 'react'; -import {AdapterProps} from '../../models/adapters'; -// eslint-disable-next-line import/no-cycle -import {createSignUpComponent} from '../presentation/auth/SignUp/v1/SignUpOptionFactory'; - -/** - * Form container component that renders child components. - */ -const FormContainer: FC = (props: AdapterProps) => { - const {component} = props; - - // If the form has child components, render them wrapped in a form element - if (component.components && component.components.length > 0) { - const handleFormSubmit: (e: FormEvent) => void = (e: FormEvent): void => { - e.preventDefault(); - - // Find submit button in child components and trigger its submission - const submitButton: any = component.components?.find( - (child: any) => - child.type === 'BUTTON' && - (child.variant === 'PRIMARY' || child.variant === 'SECONDARY' || child.config?.type === 'submit'), - ); - - if (submitButton && props.onSubmit) { - props.onSubmit(submitButton, props.formValues); - } - }; - - return ( -
- {component.components.map((childComponent: any) => - createSignUpComponent({ - ...props, - component: childComponent, - }), - )} -
- ); - } - - // Empty form container - return
; -}; - -export default FormContainer; diff --git a/packages/react/src/components/adapters/NumberInput.tsx b/packages/react/src/components/adapters/NumberInput.tsx deleted file mode 100644 index ef0524c..0000000 --- a/packages/react/src/components/adapters/NumberInput.tsx +++ /dev/null @@ -1,53 +0,0 @@ -/** - * 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 {FieldType} from '@thunderid/browser'; -import {FC} from 'react'; -import {AdapterProps} from '../../models/adapters'; -import {createField} from '../factories/FieldFactory'; - -/** - * Number input component for sign-up forms. - */ -const NumberInput: FC = ({ - component, - formValues, - touchedFields, - formErrors, - onInputChange, - inputClassName, -}: AdapterProps) => { - const config: Record = component.config || {}; - const fieldName: string = (config['identifier'] as string) || (config['name'] as string) || component.id; - const value: string = formValues[fieldName] || ''; - const error: string | undefined = touchedFields[fieldName] ? formErrors[fieldName] : undefined; - - return createField({ - className: inputClassName, - error, - label: (config['label'] as string) || '', - name: fieldName, - onChange: (newValue: string) => onInputChange(fieldName, newValue), - placeholder: (config['placeholder'] as string) || '', - required: (config['required'] as boolean) || false, - type: FieldType.Number, - value, - }); -}; - -export default NumberInput; diff --git a/packages/react/src/components/adapters/PasswordInput.tsx b/packages/react/src/components/adapters/PasswordInput.tsx deleted file mode 100644 index 3bec8d7..0000000 --- a/packages/react/src/components/adapters/PasswordInput.tsx +++ /dev/null @@ -1,94 +0,0 @@ -/** - * 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 {FieldType} from '@thunderid/browser'; -import {FC} from 'react'; -import {AdapterProps} from '../../models/adapters'; -import {createField} from '../factories/FieldFactory'; - -/** - * Password input component for sign-up forms. - */ -const PasswordInput: FC = ({ - component, - formValues, - touchedFields, - formErrors, - onInputChange, - inputClassName, -}: AdapterProps) => { - const config: Record = component.config || {}; - const fieldName: string = (config['identifier'] as string) || (config['name'] as string) || component.id; - const value: string = formValues[fieldName] || ''; - const error: string | undefined = touchedFields[fieldName] ? formErrors[fieldName] : undefined; - - // Extract validation rules from the component config if available - const validations: { - conditions?: {key: string; value: string}[]; - name: string; - }[] = - (config['validations'] as { - conditions?: {key: string; value: string}[]; - name: string; - }[]) || []; - const validationHints: string[] = []; - - validations.forEach((validation: any) => { - if (validation.name === 'LengthValidator') { - const minLength: string | undefined = validation.conditions?.find((c: any) => c.key === 'min.length')?.value; - const maxLength: string | undefined = validation.conditions?.find((c: any) => c.key === 'max.length')?.value; - if (minLength || maxLength) { - validationHints.push(`Length: ${minLength || '0'}-${maxLength || 'โˆž'} characters`); - } - } else if (validation.name === 'UpperCaseValidator') { - const minLength: string | undefined = validation.conditions?.find((c: any) => c.key === 'min.length')?.value; - if (minLength && parseInt(minLength, 10) > 0) { - validationHints.push('Must contain uppercase letter(s)'); - } - } else if (validation.name === 'LowerCaseValidator') { - const minLength: string | undefined = validation.conditions?.find((c: any) => c.key === 'min.length')?.value; - if (minLength && parseInt(minLength, 10) > 0) { - validationHints.push('Must contain lowercase letter(s)'); - } - } else if (validation.name === 'NumeralValidator') { - const minLength: string | undefined = validation.conditions?.find((c: any) => c.key === 'min.length')?.value; - if (minLength && parseInt(minLength, 10) > 0) { - validationHints.push('Must contain number(s)'); - } - } else if (validation.name === 'SpecialCharacterValidator') { - const minLength: string | undefined = validation.conditions?.find((c: any) => c.key === 'min.length')?.value; - if (minLength && parseInt(minLength, 10) > 0) { - validationHints.push('Must contain special character(s)'); - } - } - }); - - return createField({ - className: inputClassName, - error, - label: (config['label'] as string) || 'Password', - name: fieldName, - onChange: (newValue: string) => onInputChange(fieldName, newValue), - placeholder: (config['placeholder'] as string) || 'Enter your password', - required: (config['required'] as boolean) || false, - type: FieldType.Password, - value, - }); -}; - -export default PasswordInput; diff --git a/packages/react/src/components/adapters/SelectInput.tsx b/packages/react/src/components/adapters/SelectInput.tsx deleted file mode 100644 index a996bdc..0000000 --- a/packages/react/src/components/adapters/SelectInput.tsx +++ /dev/null @@ -1,62 +0,0 @@ -/** - * 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 {FieldType} from '@thunderid/browser'; -import {FC} from 'react'; -import {AdapterProps} from '../../models/adapters'; -import {createField} from '../factories/FieldFactory'; -import {SelectOption} from '../primitives/Select/Select'; - -/** - * Select input component for sign-up forms. - */ -const SelectInput: FC = ({ - component, - formValues, - touchedFields, - formErrors, - onInputChange, - inputClassName, -}: AdapterProps) => { - const config: Record = component.config || {}; - const fieldName: string = (config['identifier'] as string) || (config['name'] as string) || component.id; - const value: string = formValues[fieldName] || ''; - const error: string | undefined = touchedFields[fieldName] ? formErrors[fieldName] : undefined; - - // Get options from config and convert to SelectOption format - const rawOptions: string[] = (config['options'] as string[]) || []; - const options: SelectOption[] = rawOptions.map((option: string) => ({ - label: option, - value: option, - })); - - return createField({ - className: inputClassName, - error, - label: (config['label'] as string) || '', - name: fieldName, - onChange: (newValue: string): void => onInputChange(fieldName, newValue), - options, - placeholder: (config['placeholder'] as string) || '', - required: (config['required'] as boolean) || false, - type: FieldType.Select, - value, - }); -}; - -export default SelectInput; diff --git a/packages/react/src/components/adapters/SocialButton.tsx b/packages/react/src/components/adapters/SocialButton.tsx deleted file mode 100644 index 3aa3810..0000000 --- a/packages/react/src/components/adapters/SocialButton.tsx +++ /dev/null @@ -1,66 +0,0 @@ -/** - * 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} from 'react'; -import {AdapterProps} from '../../models/adapters'; -import Button from '../primitives/Button/Button'; - -/** - * Social button component for sign-up forms. - */ -const SocialButton: FC = ({ - component, - isLoading, - buttonClassName, - size = 'medium', - onSubmit, -}: AdapterProps): ReactElement => { - const config: Record = component.config || {}; - const buttonText: string = (config['text'] as string) || (config['label'] as string) || 'Continue with Social'; - - const handleClick = (): void => { - if (onSubmit) { - onSubmit(component, {}); - } - }; - - return ( - - ); -}; - -export default SocialButton; diff --git a/packages/react/src/components/adapters/SubmitButton.tsx b/packages/react/src/components/adapters/SubmitButton.tsx deleted file mode 100644 index 9b7cf60..0000000 --- a/packages/react/src/components/adapters/SubmitButton.tsx +++ /dev/null @@ -1,82 +0,0 @@ -/** - * 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} from 'react'; -import {AdapterProps} from '../../models/adapters'; -import Button from '../primitives/Button/Button'; -import Spinner from '../primitives/Spinner/Spinner'; - -/** - * Button component for sign-up forms that handles all button variants. - */ -const ButtonComponent: FC = ({ - component, - isLoading, - isFormValid, - buttonClassName, - onSubmit, - size = 'medium', -}: AdapterProps) => { - const config: Record = component.config || {}; - const buttonText: string = (config['text'] as string) || (config['label'] as string) || 'Continue'; - const buttonType: string = (config['type'] as string) || 'submit'; - const componentVariant: string = component.variant?.toUpperCase() || 'PRIMARY'; - - // Map component variants to Button primitive props - const getButtonProps = (): {color: 'primary' | 'secondary'; variant: 'solid' | 'text' | 'outline'} => { - switch (componentVariant) { - case 'PRIMARY': - return {color: 'primary' as const, variant: 'solid' as const}; - case 'SECONDARY': - return {color: 'secondary' as const, variant: 'solid' as const}; - case 'TEXT': - return {color: 'primary' as const, variant: 'text' as const}; - case 'SOCIAL': - case 'OUTLINED': - return {color: 'primary' as const, variant: 'outline' as const}; - default: - return {color: 'primary' as const, variant: 'solid' as const}; - } - }; - - const {variant, color} = getButtonProps(); - - const handleClick = (): void => { - if (onSubmit && buttonType !== 'submit') { - onSubmit(component); - } - }; - - return ( - - ); -}; - -export default ButtonComponent; diff --git a/packages/react/src/components/adapters/TelephoneInput.tsx b/packages/react/src/components/adapters/TelephoneInput.tsx deleted file mode 100644 index 9f2f2a4..0000000 --- a/packages/react/src/components/adapters/TelephoneInput.tsx +++ /dev/null @@ -1,56 +0,0 @@ -/** - * 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 {ChangeEvent, FC} from 'react'; -import {AdapterProps} from '../../models/adapters'; -import TextField from '../primitives/TextField/TextField'; - -/** - * Telephone input component for sign-up forms. - */ -const TelephoneInput: FC = ({ - component, - formValues, - touchedFields, - formErrors, - onInputChange, - inputClassName, -}: AdapterProps) => { - const config: Record = component.config || {}; - const fieldName: string = (config['identifier'] as string) || (config['name'] as string) || component.id; - const value: string = formValues[fieldName] || ''; - const error: string | undefined = touchedFields[fieldName] ? formErrors[fieldName] : undefined; - - return ( - ): void => onInputChange(fieldName, e.target.value)} - className={inputClassName} - helperText={(config['hint'] as string) || ''} - /> - ); -}; - -export default TelephoneInput; diff --git a/packages/react/src/components/adapters/TextInput.tsx b/packages/react/src/components/adapters/TextInput.tsx deleted file mode 100644 index 5012f02..0000000 --- a/packages/react/src/components/adapters/TextInput.tsx +++ /dev/null @@ -1,53 +0,0 @@ -/** - * 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 {FieldType} from '@thunderid/browser'; -import {FC} from 'react'; -import {AdapterProps} from '../../models/adapters'; -import {createField} from '../factories/FieldFactory'; - -/** - * Text input component for sign-up forms. - */ -const TextInput: FC = ({ - component, - formValues, - touchedFields, - formErrors, - onInputChange, - inputClassName, -}: AdapterProps) => { - const config: Record = component.config || {}; - const fieldName: string = (config['identifier'] as string) || (config['name'] as string) || component.id; - const value: string = formValues[fieldName] || ''; - const error: string | undefined = touchedFields[fieldName] ? formErrors[fieldName] : undefined; - - return createField({ - className: inputClassName, - error, - label: (config['label'] as string) || '', - name: fieldName, - onChange: (newValue: string) => onInputChange(fieldName, newValue), - placeholder: (config['placeholder'] as string) || '', - required: (config['required'] as boolean) || false, - type: FieldType.Text, - value, - }); -}; - -export default TextInput; diff --git a/packages/react/src/components/adapters/Typography.tsx b/packages/react/src/components/adapters/Typography.tsx deleted file mode 100644 index c238560..0000000 --- a/packages/react/src/components/adapters/Typography.tsx +++ /dev/null @@ -1,82 +0,0 @@ -/** - * 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} from 'react'; -import useTheme from '../../contexts/Theme/useTheme'; -import {AdapterProps} from '../../models/adapters'; -import Typography from '../primitives/Typography/Typography'; - -/** - * Typography component for sign-up forms (titles, descriptions, etc.). - */ -const TypographyComponent: FC = ({component}: AdapterProps) => { - const {theme} = useTheme(); - const config: Record = component.config || {}; - const text: string = (config['text'] as string) || (config['content'] as string) || ''; - const variant: string = component.variant?.toLowerCase() || 'body1'; - - // Map component variants to Typography variants - let typographyVariant: any = 'body1'; - - switch (variant) { - case 'h1': - typographyVariant = 'h1'; - break; - case 'h2': - typographyVariant = 'h2'; - break; - case 'h3': - typographyVariant = 'h3'; - break; - case 'h4': - typographyVariant = 'h4'; - break; - case 'h5': - typographyVariant = 'h5'; - break; - case 'h6': - typographyVariant = 'h6'; - break; - case 'subtitle1': - typographyVariant = 'subtitle1'; - break; - case 'subtitle2': - typographyVariant = 'subtitle2'; - break; - case 'body2': - typographyVariant = 'body2'; - break; - case 'caption': - typographyVariant = 'caption'; - break; - default: - typographyVariant = 'body1'; - } - - return ( - - {text} - - ); -}; - -export default TypographyComponent; diff --git a/packages/react/src/components/auth/Callback/Callback.tsx b/packages/react/src/components/auth/Callback/Callback.tsx index 9046f2b..b7b0460 100644 --- a/packages/react/src/components/auth/Callback/Callback.tsx +++ b/packages/react/src/components/auth/Callback/Callback.tsx @@ -17,8 +17,8 @@ */ import {FC, useState} from 'react'; -import {TokenCallback, TokenCallbackProps} from './TokenCallback'; import {OAuthCallback, OAuthCallbackProps} from './OAuthCallback'; +import {TokenCallback, TokenCallbackProps} from './TokenCallback'; /** * Props for the unified Callback component, combining properties for both Token and OAuth callbacks. diff --git a/packages/react/src/components/auth/Callback/TokenCallback.tsx b/packages/react/src/components/auth/Callback/TokenCallback.tsx index 714995c..109ac38 100644 --- a/packages/react/src/components/auth/Callback/TokenCallback.tsx +++ b/packages/react/src/components/auth/Callback/TokenCallback.tsx @@ -15,7 +15,7 @@ * under the License. */ -import {EmbeddedSignInFlowStatusV2, EmbeddedSignInFlowTypeV2, navigate as browserNavigate} from '@thunderid/browser'; +import {EmbeddedSignInFlowStatus, EmbeddedSignInFlowType, navigate as browserNavigate} from '@thunderid/browser'; import {FC, useEffect, useRef} from 'react'; import useThunderID from '../../../contexts/ThunderID/useThunderID'; @@ -158,8 +158,8 @@ export const TokenCallback: FC = ({ response = await signIn({executionId, inputs: {token}}); } - if (response.type === EmbeddedSignInFlowTypeV2.Redirection) { - const redirectURL: string | undefined = (response.data as any)?.redirectURL || (response as any)?.redirectURL; + if (response.type === EmbeddedSignInFlowType.Redirection) { + const redirectURL: string | undefined = response.data?.redirectURL || response?.redirectURL; const nextExecutionId: string = response.executionId || executionId; sessionStorage.setItem('thunderid_execution_id', nextExecutionId); @@ -169,8 +169,8 @@ export const TokenCallback: FC = ({ } } - if (response.flowStatus === EmbeddedSignInFlowStatusV2.Complete) { - const redirectUrl: string | undefined = (response as any)?.redirectUrl || (response as any)?.redirect_uri; + if (response.flowStatus === EmbeddedSignInFlowStatus.Complete) { + const redirectUrl: string | undefined = response?.redirectUrl || response?.redirect_uri; sessionStorage.removeItem('thunderid_execution_id'); await storageManager.removeHybridDataParameter('authId'); @@ -189,8 +189,8 @@ export const TokenCallback: FC = ({ return; } - if (response.flowStatus === EmbeddedSignInFlowStatusV2.Error) { - const failureReason: string | undefined = (response as any)?.failureReason; + if (response.flowStatus === EmbeddedSignInFlowStatus.Error) { + const failureReason: string | undefined = response?.failureReason; const error: Error = new Error(failureReason || 'Token validation failed. Please try again.'); await storageManager.removeHybridDataParameter('authId'); redirectWithError(error, isRegistrationFlow); diff --git a/packages/react/src/components/factories/FieldFactory.tsx b/packages/react/src/components/factories/FieldFactory.tsx index 2e9a9fb..ddb769c 100644 --- a/packages/react/src/components/factories/FieldFactory.tsx +++ b/packages/react/src/components/factories/FieldFactory.tsx @@ -116,7 +116,7 @@ export const validateFieldValue = ( }; /** - * Factory function to create form fields based on the EmbeddedSignInFlowAuthenticatorParamType. + * Factory function to create form fields based on field type. * * @param config - The field configuration * @returns The appropriate React component for the field type @@ -125,7 +125,7 @@ export const validateFieldValue = ( * ```tsx * const field = createField({ * param: 'username', - * type: EmbeddedSignInFlowAuthenticatorParamType.String, + * type: FieldType.Text, * label: 'Username', * confidential: false, * required: true, diff --git a/packages/react/src/components/presentation/OrganizationSwitcher/BaseOrganizationSwitcher.test.tsx b/packages/react/src/components/presentation/OrganizationSwitcher/BaseOrganizationSwitcher.test.tsx index 72e4f2d..5c7907b 100644 --- a/packages/react/src/components/presentation/OrganizationSwitcher/BaseOrganizationSwitcher.test.tsx +++ b/packages/react/src/components/presentation/OrganizationSwitcher/BaseOrganizationSwitcher.test.tsx @@ -16,7 +16,7 @@ * under the License. */ -/* eslint-disable sort-keys, @typescript-eslint/typedef, @typescript-eslint/explicit-function-return-type, testing-library/no-container, testing-library/no-node-access */ +/* eslint-disable testing-library/no-container, testing-library/no-node-access */ import {cleanup, render, screen, waitFor} from '@testing-library/react'; import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest'; diff --git a/packages/react/src/components/presentation/auth/AcceptInvite/v2/AcceptInvite.tsx b/packages/react/src/components/presentation/auth/AcceptInvite/AcceptInvite.tsx similarity index 100% rename from packages/react/src/components/presentation/auth/AcceptInvite/v2/AcceptInvite.tsx rename to packages/react/src/components/presentation/auth/AcceptInvite/AcceptInvite.tsx diff --git a/packages/react/src/components/presentation/auth/AcceptInvite/v2/BaseAcceptInvite.styles.ts b/packages/react/src/components/presentation/auth/AcceptInvite/BaseAcceptInvite.styles.ts similarity index 100% rename from packages/react/src/components/presentation/auth/AcceptInvite/v2/BaseAcceptInvite.styles.ts rename to packages/react/src/components/presentation/auth/AcceptInvite/BaseAcceptInvite.styles.ts diff --git a/packages/react/src/components/presentation/auth/AcceptInvite/v2/BaseAcceptInvite.tsx b/packages/react/src/components/presentation/auth/AcceptInvite/BaseAcceptInvite.tsx similarity index 96% rename from packages/react/src/components/presentation/auth/AcceptInvite/v2/BaseAcceptInvite.tsx rename to packages/react/src/components/presentation/auth/AcceptInvite/BaseAcceptInvite.tsx index a65cb8e..39cf2c7 100644 --- a/packages/react/src/components/presentation/auth/AcceptInvite/v2/BaseAcceptInvite.tsx +++ b/packages/react/src/components/presentation/auth/AcceptInvite/BaseAcceptInvite.tsx @@ -18,7 +18,7 @@ import {cx} from '@emotion/css'; import { - FieldErrorV2 as FieldError, + FieldError, FlowExecutionError, FlowMetadataResponse, Preferences, @@ -28,20 +28,20 @@ import {FC, ReactElement, ReactNode, useCallback, useContext, useEffect, useRef, import useStyles from './BaseAcceptInvite.styles'; import ComponentRendererContext, { ComponentRendererMap, -} from '../../../../../contexts/ComponentRenderer/ComponentRendererContext'; -import useTheme from '../../../../../contexts/Theme/useTheme'; -import useThunderID from '../../../../../contexts/ThunderID/useThunderID'; -import useTranslation from '../../../../../hooks/useTranslation'; -import {useOAuthCallback} from '../../../../../hooks/v2/useOAuthCallback'; -import {initiateOAuthRedirect} from '../../../../../utils/oauth'; -import {normalizeFlowResponse, extractErrorMessage} from '../../../../../utils/v2/flowTransformer'; -import AlertPrimitive from '../../../../primitives/Alert/Alert'; -import Button from '../../../../primitives/Button/Button'; +} from '../../../../contexts/ComponentRenderer/ComponentRendererContext'; +import useTheme from '../../../../contexts/Theme/useTheme'; +import useThunderID from '../../../../contexts/ThunderID/useThunderID'; +import {useOAuthCallback} from '../../../../hooks/useOAuthCallback'; +import useTranslation from '../../../../hooks/useTranslation'; +import {normalizeFlowResponse, extractErrorMessage} from '../../../../utils/flowTransformer'; +import {initiateOAuthRedirect} from '../../../../utils/oauth'; +import AlertPrimitive from '../../../primitives/Alert/Alert'; +import Button from '../../../primitives/Button/Button'; // eslint-disable-next-line import/no-named-as-default -import CardPrimitive, {CardProps} from '../../../../primitives/Card/Card'; -import Spinner from '../../../../primitives/Spinner/Spinner'; -import Typography from '../../../../primitives/Typography/Typography'; -import {renderInviteUserComponents} from '../../AuthOptionFactory'; +import CardPrimitive, {CardProps} from '../../../primitives/Card/Card'; +import Spinner from '../../../primitives/Spinner/Spinner'; +import Typography from '../../../primitives/Typography/Typography'; +import {renderInviteUserComponents} from '../AuthOptionFactory'; /** * Flow response structure from the backend. @@ -578,7 +578,7 @@ const BaseAcceptInvite: FC = ({ ], })), }; - inputs['consent_decisions'] = JSON.stringify(decisions); + inputs.consent_decisions = JSON.stringify(decisions); Object.keys(inputs).forEach((k: string) => { if (k.startsWith('__consent_opt__')) delete inputs[k]; }); diff --git a/packages/react/src/components/presentation/auth/AcceptInvite/index.ts b/packages/react/src/components/presentation/auth/AcceptInvite/index.ts index 8dc4524..57688c1 100644 --- a/packages/react/src/components/presentation/auth/AcceptInvite/index.ts +++ b/packages/react/src/components/presentation/auth/AcceptInvite/index.ts @@ -17,7 +17,7 @@ */ // 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'; +export {default as AcceptInvite} from './AcceptInvite'; +export type {AcceptInviteProps, AcceptInviteRenderProps} from './AcceptInvite'; +export {default as BaseAcceptInvite} from './BaseAcceptInvite'; +export type {BaseAcceptInviteProps, BaseAcceptInviteRenderProps, AcceptInviteFlowResponse} from './BaseAcceptInvite'; diff --git a/packages/react/src/components/presentation/auth/AuthOptionFactory.tsx b/packages/react/src/components/presentation/auth/AuthOptionFactory.tsx index 5473264..cb6a8fa 100644 --- a/packages/react/src/components/presentation/auth/AuthOptionFactory.tsx +++ b/packages/react/src/components/presentation/auth/AuthOptionFactory.tsx @@ -21,18 +21,18 @@ import { FieldType, FlowMetadataResponse, OrganizationUnitListResponse, - EmbeddedFlowComponentV2 as EmbeddedFlowComponent, - EmbeddedFlowComponentTypeV2 as EmbeddedFlowComponentType, - EmbeddedFlowTextVariantV2 as EmbeddedFlowTextVariant, - EmbeddedFlowEventTypeV2 as EmbeddedFlowEventType, + EmbeddedFlowComponent, + EmbeddedFlowComponentType, + EmbeddedFlowTextVariant, + EmbeddedFlowEventType, createPackageComponentLogger, resolveFlowTemplateLiterals, resolveEmojiUrisInHtml, - ConsentPurposeDataV2 as ConsentPurposeData, - ConsentPromptDataV2 as ConsentPromptData, - ConsentDecisionsV2 as ConsentDecisions, - ConsentPurposeDecisionV2 as ConsentPurposeDecision, - ConsentAttributeElementV2 as ConsentAttributeElement, + ConsentPurposeData, + ConsentPromptData, + ConsentDecisions, + ConsentPurposeDecision, + ConsentAttributeElement, } from '@thunderid/browser'; import DOMPurify from 'dompurify'; import {cloneElement, CSSProperties, ReactElement} from 'react'; diff --git a/packages/react/src/components/presentation/auth/InviteUser/v2/BaseInviteUser.styles.ts b/packages/react/src/components/presentation/auth/InviteUser/BaseInviteUser.styles.ts similarity index 100% rename from packages/react/src/components/presentation/auth/InviteUser/v2/BaseInviteUser.styles.ts rename to packages/react/src/components/presentation/auth/InviteUser/BaseInviteUser.styles.ts diff --git a/packages/react/src/components/presentation/auth/InviteUser/v2/BaseInviteUser.tsx b/packages/react/src/components/presentation/auth/InviteUser/BaseInviteUser.tsx similarity index 97% rename from packages/react/src/components/presentation/auth/InviteUser/v2/BaseInviteUser.tsx rename to packages/react/src/components/presentation/auth/InviteUser/BaseInviteUser.tsx index 21f504c..f9329e0 100644 --- a/packages/react/src/components/presentation/auth/InviteUser/v2/BaseInviteUser.tsx +++ b/packages/react/src/components/presentation/auth/InviteUser/BaseInviteUser.tsx @@ -19,7 +19,7 @@ import {cx} from '@emotion/css'; import { EmbeddedFlowType, - FieldErrorV2 as FieldError, + FieldError, FlowExecutionError, FlowMetadataResponse, buildValidatorFromRules, @@ -31,17 +31,17 @@ import {FC, ReactElement, ReactNode, useCallback, useContext, useEffect, useRef, import useStyles from './BaseInviteUser.styles'; import ComponentRendererContext, { ComponentRendererMap, -} from '../../../../../contexts/ComponentRenderer/ComponentRendererContext'; -import useTheme from '../../../../../contexts/Theme/useTheme'; -import useThunderID from '../../../../../contexts/ThunderID/useThunderID'; -import useTranslation from '../../../../../hooks/useTranslation'; -import {normalizeFlowResponse, extractErrorMessage} from '../../../../../utils/v2/flowTransformer'; -import AlertPrimitive from '../../../../primitives/Alert/Alert'; +} from '../../../../contexts/ComponentRenderer/ComponentRendererContext'; +import useTheme from '../../../../contexts/Theme/useTheme'; +import useThunderID from '../../../../contexts/ThunderID/useThunderID'; +import useTranslation from '../../../../hooks/useTranslation'; +import {normalizeFlowResponse, extractErrorMessage} from '../../../../utils/flowTransformer'; +import AlertPrimitive from '../../../primitives/Alert/Alert'; // eslint-disable-next-line import/no-named-as-default -import CardPrimitive, {CardProps} from '../../../../primitives/Card/Card'; -import Spinner from '../../../../primitives/Spinner/Spinner'; -import Typography from '../../../../primitives/Typography/Typography'; -import {renderInviteUserComponents} from '../../AuthOptionFactory'; +import CardPrimitive, {CardProps} from '../../../primitives/Card/Card'; +import Spinner from '../../../primitives/Spinner/Spinner'; +import Typography from '../../../primitives/Typography/Typography'; +import {renderInviteUserComponents} from '../AuthOptionFactory'; /** * Flow response structure from the backend. diff --git a/packages/react/src/components/presentation/auth/InviteUser/v2/InviteUser.tsx b/packages/react/src/components/presentation/auth/InviteUser/InviteUser.tsx similarity index 98% rename from packages/react/src/components/presentation/auth/InviteUser/v2/InviteUser.tsx rename to packages/react/src/components/presentation/auth/InviteUser/InviteUser.tsx index 2063137..48d1f3b 100644 --- a/packages/react/src/components/presentation/auth/InviteUser/v2/InviteUser.tsx +++ b/packages/react/src/components/presentation/auth/InviteUser/InviteUser.tsx @@ -20,7 +20,7 @@ import {EmbeddedFlowType, getOrganizationUnitChildren, OrganizationUnitListRespo import {FC, ReactElement, ReactNode, useCallback} from 'react'; // eslint-disable-next-line import/no-named-as-default import BaseInviteUser, {BaseInviteUserRenderProps, InviteUserFlowResponse} from './BaseInviteUser'; -import useThunderID from '../../../../../contexts/ThunderID/useThunderID'; +import useThunderID from '../../../../contexts/ThunderID/useThunderID'; /** * Render props for InviteUser (re-exported for convenience). diff --git a/packages/react/src/components/presentation/auth/InviteUser/index.ts b/packages/react/src/components/presentation/auth/InviteUser/index.ts index 386bedd..b2bac30 100644 --- a/packages/react/src/components/presentation/auth/InviteUser/index.ts +++ b/packages/react/src/components/presentation/auth/InviteUser/index.ts @@ -17,7 +17,7 @@ */ // 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'; +export {default as InviteUser} from './InviteUser'; +export type {InviteUserProps, InviteUserRenderProps} from './InviteUser'; +export {default as BaseInviteUser} from './BaseInviteUser'; +export type {BaseInviteUserProps, BaseInviteUserRenderProps, InviteUserFlowResponse} from './BaseInviteUser'; diff --git a/packages/react/src/components/presentation/auth/OrganizationUnitPicker/v2/OrganizationUnitPicker.styles.ts b/packages/react/src/components/presentation/auth/OrganizationUnitPicker/OrganizationUnitPicker.styles.ts similarity index 100% rename from packages/react/src/components/presentation/auth/OrganizationUnitPicker/v2/OrganizationUnitPicker.styles.ts rename to packages/react/src/components/presentation/auth/OrganizationUnitPicker/OrganizationUnitPicker.styles.ts diff --git a/packages/react/src/components/presentation/auth/OrganizationUnitPicker/v2/OrganizationUnitPicker.tsx b/packages/react/src/components/presentation/auth/OrganizationUnitPicker/OrganizationUnitPicker.tsx similarity index 98% rename from packages/react/src/components/presentation/auth/OrganizationUnitPicker/v2/OrganizationUnitPicker.tsx rename to packages/react/src/components/presentation/auth/OrganizationUnitPicker/OrganizationUnitPicker.tsx index 0d97536..438561c 100644 --- a/packages/react/src/components/presentation/auth/OrganizationUnitPicker/v2/OrganizationUnitPicker.tsx +++ b/packages/react/src/components/presentation/auth/OrganizationUnitPicker/OrganizationUnitPicker.tsx @@ -20,7 +20,7 @@ import {cx} from '@emotion/css'; import {OrganizationUnit, OrganizationUnitListResponse} from '@thunderid/browser'; import React, {useCallback, useEffect, useState} from 'react'; import useStyles from './OrganizationUnitPicker.styles'; -import useTheme from '../../../../../contexts/Theme/useTheme'; +import useTheme from '../../../../contexts/Theme/useTheme'; interface NodeState { children: OrganizationUnit[]; @@ -192,7 +192,7 @@ const OrganizationUnitPicker = ({ aria-label={isExpanded ? 'Collapse' : 'Expand'} type="button" > - {isExpanded ? '\u25BE' : '\u25B8'} + {isExpanded ? 'โ–พ' : 'โ–ธ'} ) : ( diff --git a/packages/react/src/components/presentation/auth/OrganizationUnitPicker/index.ts b/packages/react/src/components/presentation/auth/OrganizationUnitPicker/index.ts index d441dfe..1223d67 100644 --- a/packages/react/src/components/presentation/auth/OrganizationUnitPicker/index.ts +++ b/packages/react/src/components/presentation/auth/OrganizationUnitPicker/index.ts @@ -16,5 +16,5 @@ * under the License. */ -export {default as OrganizationUnitPicker} from './v2/OrganizationUnitPicker'; -export type {OrganizationUnitPickerProps} from './v2/OrganizationUnitPicker'; +export {default as OrganizationUnitPicker} from './OrganizationUnitPicker'; +export type {OrganizationUnitPickerProps} from './OrganizationUnitPicker'; diff --git a/packages/react/src/components/presentation/auth/Recovery/BaseRecovery.tsx b/packages/react/src/components/presentation/auth/Recovery/BaseRecovery.tsx index 50ff5aa..95c6d8d 100644 --- a/packages/react/src/components/presentation/auth/Recovery/BaseRecovery.tsx +++ b/packages/react/src/components/presentation/auth/Recovery/BaseRecovery.tsx @@ -16,22 +16,621 @@ * under the License. */ -import {Platform} from '@thunderid/browser'; -import {FC} from 'react'; -import BaseRecoveryV1, {BaseRecoveryProps as BaseRecoveryV1Props} from './v1/BaseRecovery'; -import BaseRecoveryV2, {BaseRecoveryProps as BaseRecoveryV2Props} from './v2/BaseRecovery'; +import {cx} from '@emotion/css'; +import { + EmbeddedRecoveryFlowRequest, + EmbeddedRecoveryFlowResponse, + EmbeddedRecoveryFlowStatus, + EmbeddedFlowComponentType, + FieldError, + withVendorCSSClassPrefix, + buildValidatorFromRules, + Preferences, + FlowMetadataResponse, +} from '@thunderid/browser'; +import {FC, ReactElement, ReactNode, useContext, useEffect, useState, useCallback, useRef} from 'react'; +import ComponentRendererContext, { + ComponentRendererMap, +} from '../../../../contexts/ComponentRenderer/ComponentRendererContext'; +import FlowProvider from '../../../../contexts/Flow/FlowProvider'; +import useFlow from '../../../../contexts/Flow/useFlow'; +import ComponentPreferencesContext from '../../../../contexts/I18n/ComponentPreferencesContext'; +import useTheme from '../../../../contexts/Theme/useTheme'; import useThunderID from '../../../../contexts/ThunderID/useThunderID'; +import {useForm, FormField} from '../../../../hooks/useForm'; +import useTranslation from '../../../../hooks/useTranslation'; +import {normalizeFlowResponse, extractErrorMessage} from '../../../../utils/flowTransformer'; +import getAuthComponentHeadings from '../../../../utils/getAuthComponentHeadings'; +import AlertPrimitive from '../../../primitives/Alert/Alert'; +import CardPrimitive, {CardProps} from '../../../primitives/Card/Card'; +import Logo from '../../../primitives/Logo/Logo'; +import Spinner from '../../../primitives/Spinner/Spinner'; +import Typography from '../../../primitives/Typography/Typography'; +import {renderRecoveryComponents} from '../AuthOptionFactory'; +import useStyles from '../SignUp/BaseSignUp.styles'; -export type BaseRecoveryProps = BaseRecoveryV1Props | BaseRecoveryV2Props; +/** + * Render props for custom UI rendering. + */ +export interface BaseRecoveryRenderProps { + components: any[]; + error?: Error | null; + fieldErrors: Record; + handleInputChange: (name: string, value: string) => void; + handleSubmit: (component: any, data?: Record) => Promise; + isLoading: boolean; + isValid: boolean; + messages: {message: string; type: string}[]; + meta: FlowMetadataResponse | null; + subtitle: string; + title: string; + touched: Record; + validateForm: () => {fieldErrors: Record; isValid: boolean}; + values: Record; +} + +/** + * Props for the BaseRecovery component. + */ +export interface BaseRecoveryProps { + afterRecoveryUrl?: string; + buttonClassName?: string; + /** + * Render props function for custom UI or static content + */ + children?: ((props: BaseRecoveryRenderProps) => ReactNode) | ReactNode; + className?: string; + error?: Error | null; + errorClassName?: string; + inputClassName?: string; + isInitialized?: boolean; + messageClassName?: string; + onComplete?: (response: EmbeddedRecoveryFlowResponse) => void; + onError?: (error: Error) => void; + onFlowChange?: (response: EmbeddedRecoveryFlowResponse) => void; + onInitialize?: (payload?: EmbeddedRecoveryFlowRequest) => Promise; + onSubmit?: (payload: EmbeddedRecoveryFlowRequest) => Promise; + /** + * Component-level preferences to override global i18n and theme settings. + */ + preferences?: Preferences; + showLogo?: boolean; + showSubtitle?: boolean; + showTitle?: boolean; + size?: 'small' | 'medium' | 'large'; + variant?: CardProps['variant']; +} + +/** + * Internal component that renders the V2 recovery UI and manages flow state. + * + * @internal + */ +const BaseRecoveryContent: FC = ({ + onInitialize, + onSubmit, + onError, + onFlowChange, + onComplete, + error: externalError, + className = '', + inputClassName = '', + buttonClassName = '', + errorClassName = '', + messageClassName = '', + size = 'medium', + variant = 'outlined', + isInitialized, + children, + showTitle = true, + showSubtitle = true, +}: BaseRecoveryProps): ReactElement => { + const {theme, colorScheme} = useTheme(); + const customRenderers: ComponentRendererMap = useContext(ComponentRendererContext); + const {t} = useTranslation(); + const {subtitle: flowSubtitle, title: flowTitle, messages: flowMessages, addMessage, clearMessages} = useFlow(); + const {meta} = useThunderID(); + const styles: any = useStyles(theme, colorScheme); + + const [isLoading, setIsLoading] = useState(false); + const [isFlowInitialized, setIsFlowInitialized] = useState(false); + const [currentFlow, setCurrentFlow] = useState(null); + const [apiError, setApiError] = useState(null); + + const initializationAttemptedRef: any = useRef(false); + const challengeTokenRef: any = useRef(null); + + const handleError: any = useCallback( + (error: any) => { + const errorMessage: string = extractErrorMessage(error, t); + setApiError(error instanceof Error ? error : new Error(errorMessage)); + clearMessages(); + addMessage({message: errorMessage, type: 'error'}); + }, + [t, addMessage, clearMessages], + ); + + const normalizeFlowResponseLocal: any = useCallback( + (response: EmbeddedRecoveryFlowResponse): EmbeddedRecoveryFlowResponse => { + if (response?.data?.components && Array.isArray(response.data.components)) { + return response; + } + + if (response?.data) { + const {components} = normalizeFlowResponse( + response, + t, + {defaultErrorKey: 'components.recovery.errors.generic', resolveTranslations: false}, + meta, + ); + + return {...response, data: {...response.data, components: components as any}}; + } + + return response; + }, + [t, meta], + ); + + const extractFormFields: any = useCallback( + (components: any[]): FormField[] => { + const fields: FormField[] = []; + + const processComponents = (comps: any[]): any => { + comps.forEach((component: any) => { + if ( + component.type === EmbeddedFlowComponentType.TextInput || + component.type === EmbeddedFlowComponentType.PasswordInput || + component.type === EmbeddedFlowComponentType.EmailInput || + component.type === EmbeddedFlowComponentType.Select || + component.type === EmbeddedFlowComponentType.DateInput + ) { + const fieldName: any = component.ref || component.id; + const ruleValidator = buildValidatorFromRules(component.validation); + fields.push({ + initialValue: '', + name: fieldName, + required: component.required || false, + validator: (value: string) => { + if (component.required && (!value || value.trim() === '')) { + return t('validations.required.field.error'); + } + if ( + (component.type === EmbeddedFlowComponentType.EmailInput || component.variant === 'EMAIL') && + value && + !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) + ) { + return t('field.email.invalid'); + } + // Evaluate declarative validation rules from meta.components[].validation. + if (ruleValidator && value) { + const ruleMessage = ruleValidator(value); + if (ruleMessage) { + return t(ruleMessage); + } + } + return null; + }, + }); + } + + if (component.components && Array.isArray(component.components)) { + processComponents(component.components); + } + }); + }; + + processComponents(components); + return fields; + }, + [t], + ); + + const formFields: any = currentFlow?.data?.components ? extractFormFields(currentFlow.data.components) : []; + + const form: any = useForm>({ + fields: formFields, + initialValues: {}, + requiredMessage: t('validations.required.field.error'), + validateOnBlur: true, + validateOnChange: false, + }); + + const { + values: formValues, + touched: touchedFields, + errors: formErrors, + isValid: isFormValid, + setValue: setFormValue, + setTouched: setFormTouched, + setErrors: setFormErrors, + clearErrors: clearFormErrors, + validateForm, + touchAllFields, + reset: resetForm, + } = form; + + /** + * Project server-side validation errors from the most recent flow response into the + * form's `errors` state. See BaseSignIn for the same pattern. + */ + useEffect(() => { + clearFormErrors(); + const responseFieldErrors: FieldError[] | undefined = (currentFlow?.data as any)?.fieldErrors; + if (!responseFieldErrors || responseFieldErrors.length === 0) { + return; + } + const errors: Record = {}; + for (const fe of responseFieldErrors) { + if (!(fe.identifier in errors)) { + errors[fe.identifier] = fe.message; + } + } + setFormErrors(errors); + Object.keys(errors).forEach((field: string) => setFormTouched(field, true)); + }, [currentFlow, setFormErrors, setFormTouched, clearFormErrors]); + + const setupFormFields: any = useCallback( + (flowResponse: EmbeddedRecoveryFlowResponse) => { + const fields: any = extractFormFields(flowResponse.data?.components || []); + const initialValues: Record = {}; + fields.forEach((field: any) => { + initialValues[field.name] = field.initialValue || ''; + }); + resetForm(); + Object.keys(initialValues).forEach((key: any) => setFormValue(key, initialValues[key])); + }, + [extractFormFields, resetForm, setFormValue], + ); + + const handleInputChange = (name: string, value: string): void => { + setFormValue(name, value); + }; + + const handleInputBlur = (name: string): void => { + setFormTouched(name, true); + }; + + const handleSubmit = async (component: any, data?: Record, skipValidation?: boolean): Promise => { + if (!currentFlow) return; + + if (!skipValidation) { + touchAllFields(); + const validation: ReturnType = validateForm(); + if (!validation.isValid) return; + } + + setIsLoading(true); + setApiError(null); + clearMessages(); + + try { + const filteredInputs: Record = {}; + if (data) { + Object.entries(data).forEach(([key, value]: [string, any]) => { + if (value !== null && value !== undefined && value !== '') { + filteredInputs[key] = value; + } + }); + } + + const payload: EmbeddedRecoveryFlowRequest = { + ...(currentFlow.executionId && {executionId: currentFlow.executionId}), + ...(component.id && {action: component.id}), + ...(challengeTokenRef.current ? {challengeToken: challengeTokenRef.current} : {}), + inputs: filteredInputs, + }; + + const rawResponse: any = await onSubmit?.(payload); + if (!rawResponse) return; + const response: any = normalizeFlowResponseLocal(rawResponse); + onFlowChange?.(response); -const BaseRecovery: FC = (props: BaseRecoveryProps) => { - const {platform} = useThunderID(); + if (response.challengeToken !== undefined) { + challengeTokenRef.current = response.challengeToken ?? null; + } - if (platform === Platform.ThunderID) { - return ; + if (response.flowStatus === EmbeddedRecoveryFlowStatus.Error) { + handleError(response); + onError?.(new Error(extractErrorMessage(response, t))); + return; + } + + if (response.flowStatus === EmbeddedRecoveryFlowStatus.Complete) { + onComplete?.(response); + return; + } + + if (response.flowStatus === EmbeddedRecoveryFlowStatus.Incomplete) { + setCurrentFlow(response); + setupFormFields(response); + + // Display error from INCOMPLETE response + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + if (response?.error) { + handleError(response); + } + } + } catch (err) { + handleError(err); + onError?.(err as Error); + } finally { + setIsLoading(false); + } + }; + + const containerClasses: any = cx( + [ + withVendorCSSClassPrefix('recovery'), + withVendorCSSClassPrefix(`recovery--${size}`), + withVendorCSSClassPrefix(`recovery--${variant}`), + ], + className, + ); + + const inputClasses: any = cx( + [ + withVendorCSSClassPrefix('recovery__input'), + size === 'small' && withVendorCSSClassPrefix('recovery__input--small'), + size === 'large' && withVendorCSSClassPrefix('recovery__input--large'), + ], + inputClassName, + ); + + const buttonClasses: any = cx( + [ + withVendorCSSClassPrefix('recovery__button'), + size === 'small' && withVendorCSSClassPrefix('recovery__button--small'), + size === 'large' && withVendorCSSClassPrefix('recovery__button--large'), + ], + buttonClassName, + ); + + const errorClasses: any = cx([withVendorCSSClassPrefix('recovery__error')], errorClassName); + const messageClasses: any = cx([withVendorCSSClassPrefix('recovery__messages')], messageClassName); + + const renderComponents: any = useCallback( + (components: any[]): ReactElement[] => + renderRecoveryComponents( + components, + formValues, + touchedFields, + formErrors, + isLoading, + isFormValid, + handleInputChange, + { + _customRenderers: customRenderers, + _theme: theme, + buttonClassName: buttonClasses, + inputClassName: inputClasses, + meta, + onInputBlur: handleInputBlur, + onSubmit: handleSubmit, + size, + variant, + }, + ), + [ + customRenderers, + buttonClasses, + formErrors, + formValues, + handleInputBlur, + handleSubmit, + inputClasses, + isFormValid, + meta, + isLoading, + size, + theme, + touchedFields, + variant, + ], + ); + + useEffect(() => { + if (isInitialized && !isFlowInitialized && !initializationAttemptedRef.current) { + initializationAttemptedRef.current = true; + + (async (): Promise => { + setIsLoading(true); + setApiError(null); + clearMessages(); + + try { + const rawResponse: any = await onInitialize?.(); + if (!rawResponse) return; + const response: any = normalizeFlowResponseLocal(rawResponse); + + if (response.challengeToken !== undefined) { + challengeTokenRef.current = response.challengeToken ?? null; + } + + if (response.flowStatus === EmbeddedRecoveryFlowStatus.Error) { + handleError(response); + onError?.(new Error(extractErrorMessage(response, t))); + } + + setCurrentFlow(response); + setIsFlowInitialized(true); + onFlowChange?.(response); + + if (response.flowStatus === EmbeddedRecoveryFlowStatus.Complete) { + onComplete?.(response); + return; + } + + if (response.flowStatus === EmbeddedRecoveryFlowStatus.Incomplete) { + setupFormFields(response); + + // Display error from INCOMPLETE response + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + if (response?.error) { + handleError(response); + } + } + } catch (err) { + handleError(err); + onError?.(err as Error); + } finally { + setIsLoading(false); + } + })(); + } + }, [ + isFlowInitialized, + isInitialized, + normalizeFlowResponseLocal, + onComplete, + onError, + onFlowChange, + onInitialize, + setupFormFields, + t, + ]); + + if (children) { + if (typeof children === 'function') { + const renderProps: BaseRecoveryRenderProps = { + components: currentFlow?.data?.components || [], + error: apiError, + fieldErrors: formErrors, + handleInputChange, + handleSubmit, + isLoading, + isValid: isFormValid, + messages: flowMessages || [], + meta, + subtitle: flowSubtitle || t('recovery.subheading'), + title: flowTitle || t('recovery.heading'), + touched: touchedFields, + validateForm: (): {fieldErrors: Record; isValid: boolean} => { + const result: ReturnType = validateForm(); + return {fieldErrors: result.errors, isValid: result.isValid}; + }, + values: formValues, + }; + + return
{(children as any)(renderProps)}
; + } + + return
{children}
; + } + + if (!isFlowInitialized && isLoading) { + return ( + + +
+ +
+
+
+ ); + } + + if (!currentFlow) { + return ( + + + + {t('errors.heading')} + {t('errors.recovery.flow.initialization.failure')} + + + + ); } - return ; + const componentsToRender: any = currentFlow.data?.components || []; + const {title, subtitle, componentsWithoutHeadings} = getAuthComponentHeadings( + componentsToRender, + flowTitle, + flowSubtitle, + t('recovery.heading'), + t('recovery.subheading'), + ); + + return ( + + {(showTitle || showSubtitle) && ( + + {showTitle && ( + + {title} + + )} + {showSubtitle && ( + + {subtitle} + + )} + + )} + + {externalError && ( +
+ + {externalError.message} + +
+ )} + {flowMessages && flowMessages.length > 0 && ( +
+ {flowMessages.map((message: any, index: number) => ( + + {message.message} + + ))} +
+ )} +
+ {componentsWithoutHeadings && componentsWithoutHeadings.length > 0 ? ( + renderComponents(componentsWithoutHeadings) + ) : ( + + {t('errors.recovery.components.not.available')} + + )} +
+
+
+ ); +}; + +/** + * BaseRecovery component for ThunderIDV2 that provides an embedded account/password recovery flow. + * Accepts API functions as props to maintain framework independence. + */ +const BaseRecovery: FC = ({ + preferences, + showLogo = true, + ...rest +}: BaseRecoveryProps): ReactElement => { + const {theme, colorScheme} = useTheme(); + const styles: any = useStyles(theme, colorScheme); + + const content: ReactElement = ( +
+ {showLogo && ( +
+ +
+ )} + + + +
+ ); + + if (!preferences) return content; + + return {content}; }; export default BaseRecovery; diff --git a/packages/react/src/components/presentation/auth/Recovery/Recovery.tsx b/packages/react/src/components/presentation/auth/Recovery/Recovery.tsx index dbb4819..33dbe9e 100644 --- a/packages/react/src/components/presentation/auth/Recovery/Recovery.tsx +++ b/packages/react/src/components/presentation/auth/Recovery/Recovery.tsx @@ -16,38 +16,39 @@ * under the License. */ -import {Platform} from '@thunderid/browser'; -import {FC} from 'react'; -import RecoveryV1, {RecoveryProps as RecoveryV1Props} from './v1/Recovery'; -import RecoveryV2, {RecoveryProps as RecoveryV2Props} from './v2/Recovery'; +import {EmbeddedRecoveryFlowRequest, EmbeddedRecoveryFlowResponse, EmbeddedFlowType} from '@thunderid/browser'; +import {FC, PropsWithChildren, ReactElement, useCallback} from 'react'; +import BaseRecovery, {BaseRecoveryProps} from './BaseRecovery'; import useThunderID from '../../../../contexts/ThunderID/useThunderID'; -/** - * Props for the Recovery component. - * Extends RecoveryV1Props & RecoveryV2Props for full compatibility with both implementations. - */ -export type RecoveryProps = RecoveryV1Props | RecoveryV2Props; +export type RecoveryProps = PropsWithChildren< + BaseRecoveryProps & { + /** + * URL query parameter name that carries the recovery token when the user lands via a recovery link. + * When set and both `executionId` and this param are present in the URL, the component resumes the + * existing flow instead of starting a new one. + * + * @example + * // For a link like /recovery?executionId=xxx&recoveryToken=yyy + * + */ + tokenUrlParam?: string; + } +>; /** - * Recovery component that provides an embedded account/password recovery flow. - * Routes to the appropriate version-specific implementation based on the platform. + * Recovery component for ThunderIDV2 that provides an embedded account/password recovery flow. * * @example * ```tsx - * import { Recovery } from '@thunderid/react'; - * - * const App = () => ( - * console.log('Recovery complete', response)} - * onError={(error) => console.error('Recovery failed', error)} - * /> - * ); - * ``` + * // Default UI + * console.log('Recovery complete', response)} + * onError={(error) => console.error('Recovery failed', error)} + * /> * - * @example * // Custom UI with render props - * ```tsx * * {({ values, fieldErrors, handleInputChange, handleSubmit, isLoading, components }) => ( *
{ e.preventDefault(); handleSubmit(components[0], values); }}> @@ -57,14 +58,81 @@ export type RecoveryProps = RecoveryV1Props | RecoveryV2Props; * * ``` */ -const Recovery: FC = (props: RecoveryProps) => { - const {platform} = useThunderID(); +const Recovery: FC = ({ + className, + size = 'medium', + afterRecoveryUrl, + onError, + onComplete, + tokenUrlParam, + children, + ...rest +}: RecoveryProps): ReactElement => { + const {recover, isInitialized, applicationId} = useThunderID(); - if (platform === Platform.ThunderID) { - return ; - } + const handleInitialize: (payload?: EmbeddedRecoveryFlowRequest) => Promise = + useCallback( + async (payload?: EmbeddedRecoveryFlowRequest): Promise => { + const urlParams: URLSearchParams = new URL(window.location.href).searchParams; + const applicationIdFromUrl: string | null = urlParams.get('applicationId'); + const effectiveApplicationId: string | null = applicationId ?? applicationIdFromUrl; + + if (tokenUrlParam) { + const executionId: string | null = urlParams.get('executionId'); + const tokenValue: string | null = urlParams.get(tokenUrlParam); + + if (executionId && tokenValue) { + const resumePayload: EmbeddedRecoveryFlowRequest = { + executionId, + inputs: {[tokenUrlParam]: tokenValue}, + }; + return (await recover(resumePayload)) as EmbeddedRecoveryFlowResponse; + } + } + + const initialPayload: EmbeddedRecoveryFlowRequest = payload ?? { + flowType: EmbeddedFlowType.Recovery, + ...(effectiveApplicationId && {applicationId: effectiveApplicationId}), + }; + + return (await recover(initialPayload)) as EmbeddedRecoveryFlowResponse; + }, + [applicationId, tokenUrlParam, recover], + ); + + const handleOnSubmit: (payload: EmbeddedRecoveryFlowRequest) => Promise = useCallback( + async (payload: EmbeddedRecoveryFlowRequest): Promise => + (await recover(payload)) as EmbeddedRecoveryFlowResponse, + [recover], + ); + + const handleComplete: (response: EmbeddedRecoveryFlowResponse) => void = useCallback( + (response: EmbeddedRecoveryFlowResponse): void => { + onComplete?.(response); + + if (afterRecoveryUrl) { + window.location.href = afterRecoveryUrl; + } + }, + [onComplete, afterRecoveryUrl], + ); - return ; + return ( + + ); }; export default Recovery; diff --git a/packages/react/src/components/presentation/auth/Recovery/v1/BaseRecovery.tsx b/packages/react/src/components/presentation/auth/Recovery/v1/BaseRecovery.tsx deleted file mode 100644 index 8193f8b..0000000 --- a/packages/react/src/components/presentation/auth/Recovery/v1/BaseRecovery.tsx +++ /dev/null @@ -1,483 +0,0 @@ -/** - * 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 {cx} from '@emotion/css'; -import { - EmbeddedFlowExecuteRequestPayload, - EmbeddedFlowExecuteResponse, - EmbeddedFlowStatus, - EmbeddedFlowComponentType, - withVendorCSSClassPrefix, -} from '@thunderid/browser'; -import {FC, PropsWithChildren, ReactElement, useEffect, useState, useCallback, useRef} from 'react'; -import {renderRecoveryComponents} from './RecoveryOptionFactory'; -import FlowProvider from '../../../../../contexts/Flow/FlowProvider'; -import useFlow from '../../../../../contexts/Flow/useFlow'; -import useTheme from '../../../../../contexts/Theme/useTheme'; -import useThunderID from '../../../../../contexts/ThunderID/useThunderID'; -import {useForm, FormField} from '../../../../../hooks/useForm'; -import useTranslation from '../../../../../hooks/useTranslation'; -import AlertPrimitive from '../../../../primitives/Alert/Alert'; -// eslint-disable-next-line import/no-named-as-default -import CardPrimitive, {CardProps} from '../../../../primitives/Card/Card'; -import Logo from '../../../../primitives/Logo/Logo'; -import Spinner from '../../../../primitives/Spinner/Spinner'; -import Typography from '../../../../primitives/Typography/Typography'; -import useStyles from '../../SignUp/BaseSignUp.styles'; - -/** - * Render props for custom UI rendering. - */ -export interface BaseRecoveryRenderProps { - components: any[]; - errors: Record; - handleInputChange: (name: string, value: string) => void; - handleSubmit: (component: any, data?: Record) => Promise; - isLoading: boolean; - isValid: boolean; - messages: {message: string; type: string}[]; - subtitle: string; - title: string; - touched: Record; - validateForm: () => {errors: Record; isValid: boolean}; - values: Record; -} - -/** - * Props for the BaseRecovery component. - */ -export interface BaseRecoveryProps { - afterRecoveryUrl?: string; - buttonClassName?: string; - className?: string; - errorClassName?: string; - inputClassName?: string; - isInitialized?: boolean; - messageClassName?: string; - onComplete?: (response: EmbeddedFlowExecuteResponse) => void; - onError?: (error: Error) => void; - onFlowChange?: (response: EmbeddedFlowExecuteResponse) => void; - onInitialize?: (payload?: EmbeddedFlowExecuteRequestPayload) => Promise; - onSubmit?: (payload: EmbeddedFlowExecuteRequestPayload) => Promise; - showLogo?: boolean; - showSubtitle?: boolean; - showTitle?: boolean; - size?: 'small' | 'medium' | 'large'; - variant?: CardProps['variant']; -} - -/** - * Internal component that renders the recovery UI and manages flow state. - * - * @internal - */ -const BaseRecoveryContent: FC> = ({ - onInitialize, - onSubmit, - onError, - onFlowChange, - onComplete, - className = '', - inputClassName = '', - buttonClassName = '', - errorClassName = '', - messageClassName = '', - size = 'medium', - variant = 'outlined', - isInitialized, - children, - showTitle = true, - showSubtitle = true, -}: PropsWithChildren): ReactElement => { - const {theme, colorScheme} = useTheme(); - const {t} = useTranslation(); - const {subtitle: flowSubtitle, title: flowTitle, messages: flowMessages, addMessage, clearMessages} = useFlow(); - useThunderID(); - const styles: any = useStyles(theme, colorScheme); - - const handleError: any = useCallback( - (error: any) => { - let errorMessage: string = t('errors.recovery.flow.failure'); - - if (error && typeof error === 'object') { - if (error.code && (error.message || error.description)) { - errorMessage = error.description || error.message; - } else if (error instanceof Error && error.name === 'ThunderIDAPIError') { - try { - const errorResponse: any = JSON.parse(error.message); - errorMessage = errorResponse.description || errorResponse.message || error.message; - } catch { - errorMessage = error.message; - } - } else if (error.message) { - errorMessage = error.message; - } - } else if (typeof error === 'string') { - errorMessage = error; - } - - clearMessages(); - addMessage({message: errorMessage, type: 'error'}); - }, - [t, addMessage, clearMessages], - ); - - const [isLoading, setIsLoading] = useState(false); - const [isFlowInitialized, setIsFlowInitialized] = useState(false); - const [currentFlow, setCurrentFlow] = useState(null); - - const initializationAttemptedRef: any = useRef(false); - - const extractFormFields: any = useCallback( - (components: any[]): FormField[] => { - const fields: FormField[] = []; - - const processComponents = (comps: any[]): any => { - comps.forEach((component: any) => { - if (component.type === EmbeddedFlowComponentType.Input) { - const config: any = component.config || {}; - fields.push({ - initialValue: config.defaultValue || '', - name: config.name || component.id, - required: config.required || false, - validator: (value: string) => { - if (config.required && (!value || value.trim() === '')) { - return t('validations.required.field.error'); - } - if (config.type === 'email' && value && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) { - return t('field.email.invalid'); - } - if (config.type === 'password' && value && value.length < 8) { - return t('field.password.weak'); - } - return null; - }, - }); - } - - if (component.components && Array.isArray(component.components)) { - processComponents(component.components); - } - }); - }; - - processComponents(components); - return fields; - }, - [t], - ); - - const formFields: any = currentFlow?.data?.components ? extractFormFields(currentFlow.data.components) : []; - - const form: any = useForm>({ - fields: formFields, - initialValues: {}, - requiredMessage: t('validations.required.field.error'), - validateOnBlur: true, - validateOnChange: true, - }); - - const { - values: formValues, - touched: touchedFields, - errors: formErrors, - isValid: isFormValid, - setValue: setFormValue, - setTouched: setFormTouched, - validateForm, - touchAllFields, - reset: resetForm, - } = form; - - const setupFormFields: any = useCallback( - (flowResponse: EmbeddedFlowExecuteResponse) => { - const fields: any = extractFormFields(flowResponse.data?.components || []); - const initialValues: Record = {}; - fields.forEach((field: any) => { - initialValues[field.name] = field.initialValue || ''; - }); - resetForm(); - Object.keys(initialValues).forEach((key: any) => setFormValue(key, initialValues[key])); - }, - [extractFormFields, resetForm, setFormValue], - ); - - const handleInputChange: (name: string, value: string) => void = useCallback( - (name: string, value: string): void => { - setFormValue(name, value); - setFormTouched(name, true); - }, - [setFormValue, setFormTouched], - ); - - const handleSubmit = async (component: any, data?: Record, skipValidation?: boolean): Promise => { - if (!currentFlow) return; - - if (!skipValidation) { - touchAllFields(); - const validation: ReturnType = validateForm(); - if (!validation.isValid) return; - } - - setIsLoading(true); - clearMessages(); - - try { - const filteredInputs: Record = {}; - if (data) { - Object.entries(data).forEach(([key, value]: [string, any]) => { - if (value !== null && value !== undefined && value !== '') { - filteredInputs[key] = value; - } - }); - } - - const payload: EmbeddedFlowExecuteRequestPayload = { - ...(currentFlow.flowId && {flowId: currentFlow.flowId}), - flowType: (currentFlow as any).flowType || 'RECOVERY', - inputs: filteredInputs, - ...(component.id && {actionId: component.id as string}), - }; - - const response: any = await onSubmit?.(payload); - if (!response) return; - onFlowChange?.(response); - - if (response.flowStatus === EmbeddedFlowStatus.Complete) { - onComplete?.(response); - return; - } - - if (response.flowStatus === EmbeddedFlowStatus.Incomplete) { - setCurrentFlow(response); - setupFormFields(response); - } - } catch (err) { - handleError(err); - onError?.(err as Error); - } finally { - setIsLoading(false); - } - }; - - const containerClasses: any = cx( - [ - withVendorCSSClassPrefix('recovery'), - withVendorCSSClassPrefix(`recovery--${size}`), - withVendorCSSClassPrefix(`recovery--${variant}`), - ], - className, - ); - - const inputClasses: any = cx( - [ - withVendorCSSClassPrefix('recovery__input'), - size === 'small' && withVendorCSSClassPrefix('recovery__input--small'), - size === 'large' && withVendorCSSClassPrefix('recovery__input--large'), - ], - inputClassName, - ); - - const buttonClasses: any = cx( - [ - withVendorCSSClassPrefix('recovery__button'), - size === 'small' && withVendorCSSClassPrefix('recovery__button--small'), - size === 'large' && withVendorCSSClassPrefix('recovery__button--large'), - ], - buttonClassName, - ); - - const errorClasses: any = cx([withVendorCSSClassPrefix('recovery__error')], errorClassName); - const messageClasses: any = cx([withVendorCSSClassPrefix('recovery__messages')], messageClassName); - - const renderComponents: any = useCallback( - (components: any[]): ReactElement[] => - renderRecoveryComponents( - components, - formValues, - touchedFields, - formErrors, - isLoading, - isFormValid, - handleInputChange, - { - buttonClassName: buttonClasses, - inputClassName: inputClasses, - onSubmit: handleSubmit, - size, - variant, - }, - ), - [ - buttonClasses, - formErrors, - formValues, - handleInputChange, - handleSubmit, - inputClasses, - isFormValid, - isLoading, - size, - touchedFields, - variant, - ], - ); - - useEffect(() => { - if (isInitialized && !isFlowInitialized && !initializationAttemptedRef.current) { - initializationAttemptedRef.current = true; - - (async (): Promise => { - setIsLoading(true); - clearMessages(); - - try { - const response: any = await onInitialize?.(); - setCurrentFlow(response); - setIsFlowInitialized(true); - onFlowChange?.(response); - - if (response.flowStatus === EmbeddedFlowStatus.Complete) { - onComplete?.(response); - return; - } - - if (response.flowStatus === EmbeddedFlowStatus.Incomplete) { - setupFormFields(response); - } - } catch (err) { - handleError(err); - onError?.(err as Error); - } finally { - setIsLoading(false); - } - })(); - } - }, [ - clearMessages, - handleError, - isFlowInitialized, - isInitialized, - onComplete, - onError, - onFlowChange, - onInitialize, - setupFormFields, - ]); - - if (children) { - return
{children}
; - } - - if (!isFlowInitialized && isLoading) { - return ( - - -
- -
-
-
- ); - } - - if (!currentFlow) { - return ( - - - - {t('errors.heading')} - {t('errors.recovery.flow.initialization.failure')} - - - - ); - } - - return ( - - {(showTitle || showSubtitle) && ( - - {showTitle && ( - - {flowTitle || t('recovery.heading')} - - )} - {showSubtitle && ( - - {flowSubtitle || t('recovery.subheading')} - - )} - - )} - - {flowMessages && flowMessages.length > 0 && ( -
- {flowMessages.map((message: any, index: number) => ( - - {message.message} - - ))} -
- )} -
- {currentFlow.data?.components && currentFlow.data.components.length > 0 ? ( - renderComponents(currentFlow.data.components) - ) : ( - - {t('errors.recovery.components.not.available')} - - )} -
-
-
- ); -}; - -/** - * BaseRecovery component for ThunderID V1 that provides an embedded account/password recovery flow. - * Accepts API functions as props to maintain framework independence. - * - * @internal - */ -const BaseRecovery: FC> = ({ - showLogo = true, - ...rest -}: PropsWithChildren): ReactElement => { - const {theme, colorScheme} = useTheme(); - const styles: any = useStyles(theme, colorScheme); - - return ( -
- {showLogo && ( -
- -
- )} - - - -
- ); -}; - -export default BaseRecovery; diff --git a/packages/react/src/components/presentation/auth/Recovery/v1/Recovery.tsx b/packages/react/src/components/presentation/auth/Recovery/v1/Recovery.tsx deleted file mode 100644 index d9dc4c0..0000000 --- a/packages/react/src/components/presentation/auth/Recovery/v1/Recovery.tsx +++ /dev/null @@ -1,77 +0,0 @@ -/** - * 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 {EmbeddedFlowExecuteRequestPayload, EmbeddedFlowExecuteResponse, EmbeddedFlowType} from '@thunderid/browser'; -import {FC, PropsWithChildren, ReactElement} from 'react'; -import BaseRecovery, {BaseRecoveryProps} from './BaseRecovery'; -import useThunderID from '../../../../../contexts/ThunderID/useThunderID'; - -export type RecoveryProps = PropsWithChildren; - -/** - * Recovery component for ThunderID V1 that provides an embedded account/password recovery flow. - */ -const Recovery: FC = ({ - className, - size = 'medium', - afterRecoveryUrl, - onError, - onComplete, - children, - ...rest -}: RecoveryProps): ReactElement => { - const {recover, isInitialized} = useThunderID(); - - const handleInitialize = async ( - payload?: EmbeddedFlowExecuteRequestPayload, - ): Promise => { - const initialPayload: any = payload || {flowType: EmbeddedFlowType.Recovery}; - return (await recover(initialPayload)) as EmbeddedFlowExecuteResponse; - }; - - const handleOnSubmit = async (payload: EmbeddedFlowExecuteRequestPayload): Promise => - (await recover(payload)) as EmbeddedFlowExecuteResponse; - - const handleComplete = (response: EmbeddedFlowExecuteResponse): void => { - onComplete?.(response); - - if (afterRecoveryUrl) { - window.location.href = afterRecoveryUrl; - } - }; - - return ( - - ); -}; - -export default Recovery; diff --git a/packages/react/src/components/presentation/auth/Recovery/v1/RecoveryOptionFactory.tsx b/packages/react/src/components/presentation/auth/Recovery/v1/RecoveryOptionFactory.tsx deleted file mode 100644 index 132f60d..0000000 --- a/packages/react/src/components/presentation/auth/Recovery/v1/RecoveryOptionFactory.tsx +++ /dev/null @@ -1,227 +0,0 @@ -/** - * 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 {EmbeddedFlowComponent, EmbeddedFlowComponentType} from '@thunderid/browser'; -import {ReactElement} from 'react'; -import {AdapterProps} from '../../../../../models/adapters'; -import CheckboxInput from '../../../../adapters/CheckboxInput'; -import DateInput from '../../../../adapters/DateInput'; -import DividerComponent from '../../../../adapters/DividerComponent'; -import EmailInput from '../../../../adapters/EmailInput'; -import FacebookButton from '../../../../adapters/FacebookButton'; -// eslint-disable-next-line import/no-cycle -import FormContainer from '../../../../adapters/FormContainer'; -import GitHubButton from '../../../../adapters/GitHubButton'; -import GoogleButton from '../../../../adapters/GoogleButton'; -import ImageComponent from '../../../../adapters/ImageComponent'; -import LinkedInButton from '../../../../adapters/LinkedInButton'; -import MicrosoftButton from '../../../../adapters/MicrosoftButton'; -import NumberInput from '../../../../adapters/NumberInput'; -import PasswordInput from '../../../../adapters/PasswordInput'; -import SelectInput from '../../../../adapters/SelectInput'; -import SignInWithEthereumButton from '../../../../adapters/SignInWithEthereumButton'; -import ButtonComponent from '../../../../adapters/SubmitButton'; -import TelephoneInput from '../../../../adapters/TelephoneInput'; -import TextInput from '../../../../adapters/TextInput'; -import Typography from '../../../../adapters/Typography'; - -/** - * Creates the appropriate recovery component based on the component type. - */ -export const createRecoveryComponent = ({component, onSubmit, ...rest}: AdapterProps): ReactElement => { - switch (component.type) { - case EmbeddedFlowComponentType.Typography: - return ; - - case EmbeddedFlowComponentType.Input: { - // Determine input type based on variant or config - const inputVariant: string = component.variant?.toUpperCase() ?? ''; - const inputType: string = (component.config['type'] as string)?.toLowerCase() ?? ''; - - if (inputVariant === 'EMAIL' || inputType === 'email') { - return ; - } - - if (inputVariant === 'PASSWORD' || inputType === 'password') { - return ; - } - - if (inputVariant === 'TELEPHONE' || inputType === 'tel') { - return ; - } - - if (inputVariant === 'NUMBER' || inputType === 'number') { - return ; - } - - if (inputVariant === 'DATE' || inputType === 'date') { - return ; - } - - if (inputVariant === 'CHECKBOX' || inputType === 'checkbox') { - return ; - } - - return ; - } - - case EmbeddedFlowComponentType.Button: { - const buttonVariant: string | undefined = component.variant?.toUpperCase(); - const buttonText: string = (component.config['text'] as string) || (component.config['label'] as string) || ''; - - // TODO: The connection type should come as metadata. - if (buttonVariant === 'SOCIAL') { - if (buttonText.toLowerCase().includes('google')) { - return ( - onSubmit?.(component, {})} {...rest}> - {buttonText} - - ); - } - - if (buttonText.toLowerCase().includes('github')) { - return ( - onSubmit?.(component, {})} {...rest}> - {buttonText} - - ); - } - - if (buttonText.toLowerCase().includes('microsoft')) { - return ( - onSubmit?.(component, {})} {...rest}> - {buttonText} - - ); - } - - if (buttonText.toLowerCase().includes('facebook')) { - return ( - onSubmit?.(component, {})} {...rest}> - {buttonText} - - ); - } - - if (buttonText.toLowerCase().includes('linkedin')) { - return ( - onSubmit?.(component, {})} {...rest}> - {buttonText} - - ); - } - - if (buttonText.toLowerCase().includes('ethereum')) { - return ( - onSubmit?.(component, {})} {...rest}> - {buttonText} - - ); - } - } - - // Use the generic ButtonComponent for all other button variants - // It will handle PRIMARY, SECONDARY, TEXT, SOCIAL mappings internally - return ; - } - - case EmbeddedFlowComponentType.Form: - return ; - - case EmbeddedFlowComponentType.Select: - return ; - - case EmbeddedFlowComponentType.Divider: - return ; - - case EmbeddedFlowComponentType.Image: - return ; - - default: - return
; - } -}; - -/** - * Convenience function that creates the appropriate recovery component from flow component data. - */ -export const createRecoveryOptionFromComponent = ( - component: EmbeddedFlowComponent, - formValues: Record, - touchedFields: Record, - formErrors: Record, - isLoading: boolean, - isFormValid: boolean, - onInputChange: (name: string, value: string) => void, - options?: { - buttonClassName?: string; - inputClassName?: string; - key?: string | number; - onSubmit?: (component: EmbeddedFlowComponent, data?: Record) => void; - size?: 'small' | 'medium' | 'large'; - variant?: any; - }, -): ReactElement => - createRecoveryComponent({ - component, - formErrors, - formValues, - isFormValid, - isLoading, - onInputChange, - touchedFields, - ...options, - }); - -/** - * Processes an array of components and renders them as React elements for recovery flow. - */ -export const renderRecoveryComponents = ( - components: EmbeddedFlowComponent[], - formValues: Record, - touchedFields: Record, - formErrors: Record, - isLoading: boolean, - isFormValid: boolean, - onInputChange: (name: string, value: string) => void, - options?: { - buttonClassName?: string; - inputClassName?: string; - onSubmit?: (component: EmbeddedFlowComponent, data?: Record) => void; - size?: 'small' | 'medium' | 'large'; - variant?: any; - }, -): ReactElement[] => - components - .map((component: any, index: any) => - createRecoveryOptionFromComponent( - component, - formValues, - touchedFields, - formErrors, - isLoading, - isFormValid, - onInputChange, - { - ...options, - // Use component id as key, fallback to index - key: component.id || index, - }, - ), - ) - .filter(Boolean); diff --git a/packages/react/src/components/presentation/auth/Recovery/v2/BaseRecovery.tsx b/packages/react/src/components/presentation/auth/Recovery/v2/BaseRecovery.tsx deleted file mode 100644 index d5674ce..0000000 --- a/packages/react/src/components/presentation/auth/Recovery/v2/BaseRecovery.tsx +++ /dev/null @@ -1,636 +0,0 @@ -/** - * 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 {cx} from '@emotion/css'; -import { - EmbeddedRecoveryFlowRequestV2, - EmbeddedRecoveryFlowResponseV2, - EmbeddedRecoveryFlowStatusV2, - EmbeddedFlowComponentTypeV2 as EmbeddedFlowComponentType, - FieldErrorV2 as FieldError, - withVendorCSSClassPrefix, - buildValidatorFromRules, - Preferences, - FlowMetadataResponse, -} from '@thunderid/browser'; -import {FC, ReactElement, ReactNode, useContext, useEffect, useState, useCallback, useRef} from 'react'; -import ComponentRendererContext, { - ComponentRendererMap, -} from '../../../../../contexts/ComponentRenderer/ComponentRendererContext'; -import FlowProvider from '../../../../../contexts/Flow/FlowProvider'; -import useFlow from '../../../../../contexts/Flow/useFlow'; -import ComponentPreferencesContext from '../../../../../contexts/I18n/ComponentPreferencesContext'; -import useTheme from '../../../../../contexts/Theme/useTheme'; -import useThunderID from '../../../../../contexts/ThunderID/useThunderID'; -import {useForm, FormField} from '../../../../../hooks/useForm'; -import useTranslation from '../../../../../hooks/useTranslation'; -import {normalizeFlowResponse, extractErrorMessage} from '../../../../../utils/v2/flowTransformer'; -import getAuthComponentHeadings from '../../../../../utils/v2/getAuthComponentHeadings'; -import AlertPrimitive from '../../../../primitives/Alert/Alert'; -import CardPrimitive, {CardProps} from '../../../../primitives/Card/Card'; -import Logo from '../../../../primitives/Logo/Logo'; -import Spinner from '../../../../primitives/Spinner/Spinner'; -import Typography from '../../../../primitives/Typography/Typography'; -import {renderRecoveryComponents} from '../../AuthOptionFactory'; -import useStyles from '../../SignUp/BaseSignUp.styles'; - -/** - * Render props for custom UI rendering. - */ -export interface BaseRecoveryRenderProps { - components: any[]; - error?: Error | null; - fieldErrors: Record; - handleInputChange: (name: string, value: string) => void; - handleSubmit: (component: any, data?: Record) => Promise; - isLoading: boolean; - isValid: boolean; - messages: {message: string; type: string}[]; - meta: FlowMetadataResponse | null; - subtitle: string; - title: string; - touched: Record; - validateForm: () => {fieldErrors: Record; isValid: boolean}; - values: Record; -} - -/** - * Props for the BaseRecovery component. - */ -export interface BaseRecoveryProps { - afterRecoveryUrl?: string; - buttonClassName?: string; - /** - * Render props function for custom UI or static content - */ - children?: ((props: BaseRecoveryRenderProps) => ReactNode) | ReactNode; - className?: string; - error?: Error | null; - errorClassName?: string; - inputClassName?: string; - isInitialized?: boolean; - messageClassName?: string; - onComplete?: (response: EmbeddedRecoveryFlowResponseV2) => void; - onError?: (error: Error) => void; - onFlowChange?: (response: EmbeddedRecoveryFlowResponseV2) => void; - onInitialize?: (payload?: EmbeddedRecoveryFlowRequestV2) => Promise; - onSubmit?: (payload: EmbeddedRecoveryFlowRequestV2) => Promise; - /** - * Component-level preferences to override global i18n and theme settings. - */ - preferences?: Preferences; - showLogo?: boolean; - showSubtitle?: boolean; - showTitle?: boolean; - size?: 'small' | 'medium' | 'large'; - variant?: CardProps['variant']; -} - -/** - * Internal component that renders the V2 recovery UI and manages flow state. - * - * @internal - */ -const BaseRecoveryContent: FC = ({ - onInitialize, - onSubmit, - onError, - onFlowChange, - onComplete, - error: externalError, - className = '', - inputClassName = '', - buttonClassName = '', - errorClassName = '', - messageClassName = '', - size = 'medium', - variant = 'outlined', - isInitialized, - children, - showTitle = true, - showSubtitle = true, -}: BaseRecoveryProps): ReactElement => { - const {theme, colorScheme} = useTheme(); - const customRenderers: ComponentRendererMap = useContext(ComponentRendererContext); - const {t} = useTranslation(); - const {subtitle: flowSubtitle, title: flowTitle, messages: flowMessages, addMessage, clearMessages} = useFlow(); - const {meta} = useThunderID(); - const styles: any = useStyles(theme, colorScheme); - - const [isLoading, setIsLoading] = useState(false); - const [isFlowInitialized, setIsFlowInitialized] = useState(false); - const [currentFlow, setCurrentFlow] = useState(null); - const [apiError, setApiError] = useState(null); - - const initializationAttemptedRef: any = useRef(false); - const challengeTokenRef: any = useRef(null); - - const handleError: any = useCallback( - (error: any) => { - const errorMessage: string = extractErrorMessage(error, t); - setApiError(error instanceof Error ? error : new Error(errorMessage)); - clearMessages(); - addMessage({message: errorMessage, type: 'error'}); - }, - [t, addMessage, clearMessages], - ); - - const normalizeFlowResponseLocal: any = useCallback( - (response: EmbeddedRecoveryFlowResponseV2): EmbeddedRecoveryFlowResponseV2 => { - if (response?.data?.components && Array.isArray(response.data.components)) { - return response; - } - - if (response?.data) { - const {components} = normalizeFlowResponse( - response, - t, - {defaultErrorKey: 'components.recovery.errors.generic', resolveTranslations: false}, - meta, - ); - - return {...response, data: {...response.data, components: components as any}}; - } - - return response; - }, - [t, meta], - ); - - const extractFormFields: any = useCallback( - (components: any[]): FormField[] => { - const fields: FormField[] = []; - - const processComponents = (comps: any[]): any => { - comps.forEach((component: any) => { - if ( - component.type === EmbeddedFlowComponentType.TextInput || - component.type === EmbeddedFlowComponentType.PasswordInput || - component.type === EmbeddedFlowComponentType.EmailInput || - component.type === EmbeddedFlowComponentType.Select || - component.type === EmbeddedFlowComponentType.DateInput - ) { - const fieldName: any = component.ref || component.id; - const ruleValidator = buildValidatorFromRules(component.validation); - fields.push({ - initialValue: '', - name: fieldName, - required: component.required || false, - validator: (value: string) => { - if (component.required && (!value || value.trim() === '')) { - return t('validations.required.field.error'); - } - if ( - (component.type === EmbeddedFlowComponentType.EmailInput || component.variant === 'EMAIL') && - value && - !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) - ) { - return t('field.email.invalid'); - } - // Evaluate declarative validation rules from meta.components[].validation. - if (ruleValidator && value) { - const ruleMessage = ruleValidator(value); - if (ruleMessage) { - return t(ruleMessage); - } - } - return null; - }, - }); - } - - if (component.components && Array.isArray(component.components)) { - processComponents(component.components); - } - }); - }; - - processComponents(components); - return fields; - }, - [t], - ); - - const formFields: any = currentFlow?.data?.components ? extractFormFields(currentFlow.data.components) : []; - - const form: any = useForm>({ - fields: formFields, - initialValues: {}, - requiredMessage: t('validations.required.field.error'), - validateOnBlur: true, - validateOnChange: false, - }); - - const { - values: formValues, - touched: touchedFields, - errors: formErrors, - isValid: isFormValid, - setValue: setFormValue, - setTouched: setFormTouched, - setErrors: setFormErrors, - clearErrors: clearFormErrors, - validateForm, - touchAllFields, - reset: resetForm, - } = form; - - /** - * Project server-side validation errors from the most recent flow response into the - * form's `errors` state. See BaseSignIn for the same pattern. - */ - useEffect(() => { - clearFormErrors(); - const responseFieldErrors: FieldError[] | undefined = (currentFlow?.data as any)?.fieldErrors; - if (!responseFieldErrors || responseFieldErrors.length === 0) { - return; - } - const errors: Record = {}; - for (const fe of responseFieldErrors) { - if (!(fe.identifier in errors)) { - errors[fe.identifier] = fe.message; - } - } - setFormErrors(errors); - Object.keys(errors).forEach((field: string) => setFormTouched(field, true)); - }, [currentFlow, setFormErrors, setFormTouched, clearFormErrors]); - - const setupFormFields: any = useCallback( - (flowResponse: EmbeddedRecoveryFlowResponseV2) => { - const fields: any = extractFormFields(flowResponse.data?.components || []); - const initialValues: Record = {}; - fields.forEach((field: any) => { - initialValues[field.name] = field.initialValue || ''; - }); - resetForm(); - Object.keys(initialValues).forEach((key: any) => setFormValue(key, initialValues[key])); - }, - [extractFormFields, resetForm, setFormValue], - ); - - const handleInputChange = (name: string, value: string): void => { - setFormValue(name, value); - }; - - const handleInputBlur = (name: string): void => { - setFormTouched(name, true); - }; - - const handleSubmit = async (component: any, data?: Record, skipValidation?: boolean): Promise => { - if (!currentFlow) return; - - if (!skipValidation) { - touchAllFields(); - const validation: ReturnType = validateForm(); - if (!validation.isValid) return; - } - - setIsLoading(true); - setApiError(null); - clearMessages(); - - try { - const filteredInputs: Record = {}; - if (data) { - Object.entries(data).forEach(([key, value]: [string, any]) => { - if (value !== null && value !== undefined && value !== '') { - filteredInputs[key] = value; - } - }); - } - - const payload: EmbeddedRecoveryFlowRequestV2 = { - ...(currentFlow.executionId && {executionId: currentFlow.executionId}), - ...(component.id && {action: component.id}), - ...(challengeTokenRef.current ? {challengeToken: challengeTokenRef.current} : {}), - inputs: filteredInputs, - }; - - const rawResponse: any = await onSubmit?.(payload); - if (!rawResponse) return; - const response: any = normalizeFlowResponseLocal(rawResponse); - onFlowChange?.(response); - - if (response.challengeToken !== undefined) { - challengeTokenRef.current = response.challengeToken ?? null; - } - - if (response.flowStatus === EmbeddedRecoveryFlowStatusV2.Error) { - handleError(response); - onError?.(new Error(extractErrorMessage(response, t))); - return; - } - - if (response.flowStatus === EmbeddedRecoveryFlowStatusV2.Complete) { - onComplete?.(response); - return; - } - - if (response.flowStatus === EmbeddedRecoveryFlowStatusV2.Incomplete) { - setCurrentFlow(response); - setupFormFields(response); - - // Display error from INCOMPLETE response - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - if (response?.error) { - handleError(response); - } - } - } catch (err) { - handleError(err); - onError?.(err as Error); - } finally { - setIsLoading(false); - } - }; - - const containerClasses: any = cx( - [ - withVendorCSSClassPrefix('recovery'), - withVendorCSSClassPrefix(`recovery--${size}`), - withVendorCSSClassPrefix(`recovery--${variant}`), - ], - className, - ); - - const inputClasses: any = cx( - [ - withVendorCSSClassPrefix('recovery__input'), - size === 'small' && withVendorCSSClassPrefix('recovery__input--small'), - size === 'large' && withVendorCSSClassPrefix('recovery__input--large'), - ], - inputClassName, - ); - - const buttonClasses: any = cx( - [ - withVendorCSSClassPrefix('recovery__button'), - size === 'small' && withVendorCSSClassPrefix('recovery__button--small'), - size === 'large' && withVendorCSSClassPrefix('recovery__button--large'), - ], - buttonClassName, - ); - - const errorClasses: any = cx([withVendorCSSClassPrefix('recovery__error')], errorClassName); - const messageClasses: any = cx([withVendorCSSClassPrefix('recovery__messages')], messageClassName); - - const renderComponents: any = useCallback( - (components: any[]): ReactElement[] => - renderRecoveryComponents( - components, - formValues, - touchedFields, - formErrors, - isLoading, - isFormValid, - handleInputChange, - { - _customRenderers: customRenderers, - _theme: theme, - buttonClassName: buttonClasses, - inputClassName: inputClasses, - meta, - onInputBlur: handleInputBlur, - onSubmit: handleSubmit, - size, - variant, - }, - ), - [ - customRenderers, - buttonClasses, - formErrors, - formValues, - handleInputBlur, - handleSubmit, - inputClasses, - isFormValid, - meta, - isLoading, - size, - theme, - touchedFields, - variant, - ], - ); - - useEffect(() => { - if (isInitialized && !isFlowInitialized && !initializationAttemptedRef.current) { - initializationAttemptedRef.current = true; - - (async (): Promise => { - setIsLoading(true); - setApiError(null); - clearMessages(); - - try { - const rawResponse: any = await onInitialize?.(); - if (!rawResponse) return; - const response: any = normalizeFlowResponseLocal(rawResponse); - - if (response.challengeToken !== undefined) { - challengeTokenRef.current = response.challengeToken ?? null; - } - - if (response.flowStatus === EmbeddedRecoveryFlowStatusV2.Error) { - handleError(response); - onError?.(new Error(extractErrorMessage(response, t))); - } - - setCurrentFlow(response); - setIsFlowInitialized(true); - onFlowChange?.(response); - - if (response.flowStatus === EmbeddedRecoveryFlowStatusV2.Complete) { - onComplete?.(response); - return; - } - - if (response.flowStatus === EmbeddedRecoveryFlowStatusV2.Incomplete) { - setupFormFields(response); - - // Display error from INCOMPLETE response - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - if (response?.error) { - handleError(response); - } - } - } catch (err) { - handleError(err); - onError?.(err as Error); - } finally { - setIsLoading(false); - } - })(); - } - }, [ - isFlowInitialized, - isInitialized, - normalizeFlowResponseLocal, - onComplete, - onError, - onFlowChange, - onInitialize, - setupFormFields, - t, - ]); - - if (children) { - if (typeof children === 'function') { - const renderProps: BaseRecoveryRenderProps = { - components: currentFlow?.data?.components || [], - error: apiError, - fieldErrors: formErrors, - handleInputChange, - handleSubmit, - isLoading, - isValid: isFormValid, - messages: flowMessages || [], - meta, - subtitle: flowSubtitle || t('recovery.subheading'), - title: flowTitle || t('recovery.heading'), - touched: touchedFields, - validateForm: (): {fieldErrors: Record; isValid: boolean} => { - const result: ReturnType = validateForm(); - return {fieldErrors: result.errors, isValid: result.isValid}; - }, - values: formValues, - }; - - return
{(children as any)(renderProps)}
; - } - - return
{children}
; - } - - if (!isFlowInitialized && isLoading) { - return ( - - -
- -
-
-
- ); - } - - if (!currentFlow) { - return ( - - - - {t('errors.heading')} - {t('errors.recovery.flow.initialization.failure')} - - - - ); - } - - const componentsToRender: any = currentFlow.data?.components || []; - const {title, subtitle, componentsWithoutHeadings} = getAuthComponentHeadings( - componentsToRender, - flowTitle, - flowSubtitle, - t('recovery.heading'), - t('recovery.subheading'), - ); - - return ( - - {(showTitle || showSubtitle) && ( - - {showTitle && ( - - {title} - - )} - {showSubtitle && ( - - {subtitle} - - )} - - )} - - {externalError && ( -
- - {externalError.message} - -
- )} - {flowMessages && flowMessages.length > 0 && ( -
- {flowMessages.map((message: any, index: number) => ( - - {message.message} - - ))} -
- )} -
- {componentsWithoutHeadings && componentsWithoutHeadings.length > 0 ? ( - renderComponents(componentsWithoutHeadings) - ) : ( - - {t('errors.recovery.components.not.available')} - - )} -
-
-
- ); -}; - -/** - * BaseRecovery component for ThunderIDV2 that provides an embedded account/password recovery flow. - * Accepts API functions as props to maintain framework independence. - */ -const BaseRecovery: FC = ({ - preferences, - showLogo = true, - ...rest -}: BaseRecoveryProps): ReactElement => { - const {theme, colorScheme} = useTheme(); - const styles: any = useStyles(theme, colorScheme); - - const content: ReactElement = ( -
- {showLogo && ( -
- -
- )} - - - -
- ); - - if (!preferences) return content; - - return {content}; -}; - -export default BaseRecovery; diff --git a/packages/react/src/components/presentation/auth/Recovery/v2/Recovery.tsx b/packages/react/src/components/presentation/auth/Recovery/v2/Recovery.tsx deleted file mode 100644 index 8331a4f..0000000 --- a/packages/react/src/components/presentation/auth/Recovery/v2/Recovery.tsx +++ /dev/null @@ -1,139 +0,0 @@ -/** - * 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 {EmbeddedRecoveryFlowRequestV2, EmbeddedRecoveryFlowResponseV2, EmbeddedFlowType} from '@thunderid/browser'; -import {FC, PropsWithChildren, ReactElement, useCallback} from 'react'; -import BaseRecovery, {BaseRecoveryProps} from './BaseRecovery'; -import useThunderID from '../../../../../contexts/ThunderID/useThunderID'; - -export type RecoveryProps = PropsWithChildren< - BaseRecoveryProps & { - /** - * URL query parameter name that carries the recovery token when the user lands via a recovery link. - * When set and both `executionId` and this param are present in the URL, the component resumes the - * existing flow instead of starting a new one. - * - * @example - * // For a link like /recovery?executionId=xxx&recoveryToken=yyy - * - */ - tokenUrlParam?: string; - } ->; - -/** - * Recovery component for ThunderIDV2 that provides an embedded account/password recovery flow. - * - * @example - * ```tsx - * // Default UI - * console.log('Recovery complete', response)} - * onError={(error) => console.error('Recovery failed', error)} - * /> - * - * // Custom UI with render props - * - * {({ values, fieldErrors, handleInputChange, handleSubmit, isLoading, components }) => ( - * { e.preventDefault(); handleSubmit(components[0], values); }}> - * ... - * - * )} - * - * ``` - */ -const Recovery: FC = ({ - className, - size = 'medium', - afterRecoveryUrl, - onError, - onComplete, - tokenUrlParam, - children, - ...rest -}: RecoveryProps): ReactElement => { - const {recover, isInitialized, applicationId} = useThunderID(); - - const handleInitialize: (payload?: EmbeddedRecoveryFlowRequestV2) => Promise = - useCallback( - async (payload?: EmbeddedRecoveryFlowRequestV2): Promise => { - const urlParams: URLSearchParams = new URL(window.location.href).searchParams; - const applicationIdFromUrl: string | null = urlParams.get('applicationId'); - const effectiveApplicationId: string | null = applicationId ?? applicationIdFromUrl; - - if (tokenUrlParam) { - const executionId: string | null = urlParams.get('executionId'); - const tokenValue: string | null = urlParams.get(tokenUrlParam); - - if (executionId && tokenValue) { - const resumePayload: EmbeddedRecoveryFlowRequestV2 = { - executionId, - inputs: {[tokenUrlParam]: tokenValue}, - }; - return (await recover(resumePayload)) as EmbeddedRecoveryFlowResponseV2; - } - } - - const initialPayload: EmbeddedRecoveryFlowRequestV2 = payload ?? { - flowType: EmbeddedFlowType.Recovery, - ...(effectiveApplicationId && {applicationId: effectiveApplicationId}), - }; - - return (await recover(initialPayload)) as EmbeddedRecoveryFlowResponseV2; - }, - [applicationId, tokenUrlParam, recover], - ); - - const handleOnSubmit: (payload: EmbeddedRecoveryFlowRequestV2) => Promise = - useCallback( - async (payload: EmbeddedRecoveryFlowRequestV2): Promise => - (await recover(payload)) as EmbeddedRecoveryFlowResponseV2, - [recover], - ); - - const handleComplete: (response: EmbeddedRecoveryFlowResponseV2) => void = useCallback( - (response: EmbeddedRecoveryFlowResponseV2): void => { - onComplete?.(response); - - if (afterRecoveryUrl) { - window.location.href = afterRecoveryUrl; - } - }, - [onComplete, afterRecoveryUrl], - ); - - return ( - - ); -}; - -export default Recovery; diff --git a/packages/react/src/components/presentation/auth/SignIn/BaseSignIn.tsx b/packages/react/src/components/presentation/auth/SignIn/BaseSignIn.tsx index ab7882d..780238a 100644 --- a/packages/react/src/components/presentation/auth/SignIn/BaseSignIn.tsx +++ b/packages/react/src/components/presentation/auth/SignIn/BaseSignIn.tsx @@ -1,5 +1,5 @@ /** - * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com). + * Copyright (c) 2025-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 @@ -16,26 +16,681 @@ * under the License. */ -import {Platform} from '@thunderid/browser'; -import {FC} from 'react'; -import BaseSignInV1, {BaseSignInProps as BaseSignInV1Props} from './v1/BaseSignIn'; -import BaseSignInV2, {BaseSignInProps as BaseSignInV2Props} from './v2/BaseSignIn'; +import {cx} from '@emotion/css'; +import { + withVendorCSSClassPrefix, + EmbeddedSignInFlowRequest, + EmbeddedFlowComponent, + FieldError, + FlowMetadataResponse, + Preferences, + buildValidatorFromRules, +} from '@thunderid/browser'; +import {FC, useEffect, useState, useCallback, useContext, ReactElement, ReactNode} from 'react'; +import useStyles from './BaseSignIn.styles'; +import ComponentRendererContext, { + ComponentRendererMap, +} from '../../../../contexts/ComponentRenderer/ComponentRendererContext'; +import FlowProvider from '../../../../contexts/Flow/FlowProvider'; +import useFlow from '../../../../contexts/Flow/useFlow'; +import ComponentPreferencesContext from '../../../../contexts/I18n/ComponentPreferencesContext'; +import useTheme from '../../../../contexts/Theme/useTheme'; import useThunderID from '../../../../contexts/ThunderID/useThunderID'; +import {FormField, useForm} from '../../../../hooks/useForm'; +import useTranslation from '../../../../hooks/useTranslation'; +import {extractErrorMessage} from '../../../../utils/flowTransformer'; +import AlertPrimitive from '../../../primitives/Alert/Alert'; +// eslint-disable-next-line import/no-named-as-default +import CardPrimitive, {CardProps} from '../../../primitives/Card/Card'; +import Spinner from '../../../primitives/Spinner/Spinner'; +import Typography from '../../../primitives/Typography/Typography'; +import {renderSignInComponents} from '../AuthOptionFactory'; + +/** + * Render props for custom UI rendering + */ +export interface BaseSignInRenderProps { + /** + * Flow components + */ + components: EmbeddedFlowComponent[]; + + /** + * API error (if any) + */ + error?: Error | null; + + /** + * Field validation errors keyed by component ref. Populated from BOTH: + * - Client-side rule evaluation (component.validation rules in meta.components) + * - Server-side validation failures (data.fieldErrors in the flow response) + * When the server returns multiple failing rules for one field, only the first + * message is exposed here. The full FieldError[] array is available on the raw + * response object (and is reflected into the BaseSignIn `serverFieldErrors` prop). + */ + fieldErrors: Record; + + /** + * Function to handle input changes + */ + handleInputChange: (name: string, value: string) => void; + + /** + * Function to handle form submission + */ + handleSubmit: (component: EmbeddedFlowComponent, data?: Record) => Promise; + + /** + * Loading state + */ + isLoading: boolean; + + /** + * Flag indicating if the step timer has reached zero + */ + isTimeoutDisabled?: boolean; + + /** + * Whether the form is valid + */ + isValid: boolean; + + /** + * Flow messages + */ + messages: {message: string; type: string}[]; + + /** + * Flow metadata returned by the platform (v2 only). `null` while loading or unavailable. + */ + meta: FlowMetadataResponse | null; + + /** + * Flow subtitle + */ + subtitle: string; + + /** + * Flow title + */ + title: string; + + /** + * Touched fields + */ + touched: Record; + + /** + * Function to validate the form + */ + validateForm: () => {fieldErrors: Record; isValid: boolean}; + + /** + * Form values + */ + values: Record; +} /** * Props for the BaseSignIn component. - * Extends BaseSignInV1Props & BaseSignInV2Props for full compatibility with both React BaseSignIn components. */ -export type BaseSignInProps = BaseSignInV1Props | BaseSignInV2Props; +export interface BaseSignInProps { + /** + * Additional data from the flow response. + */ + additionalData?: Record; + + /** + * Custom CSS class name for the submit button. + */ + buttonClassName?: string; + + /** + * Render props function for custom UI + */ + children?: (props: BaseSignInRenderProps) => ReactNode; + + /** + * Custom CSS class name for the form container. + */ + className?: string; + + /** + * Array of flow components to render. + */ + components?: EmbeddedFlowComponent[]; + + /** + * Error object to display + */ + error?: Error | null; + + /** + * Custom CSS class name for error messages. + */ + errorClassName?: string; + + /** + * Custom CSS class name for form inputs. + */ + inputClassName?: string; + + /** + * Flag to determine if the component is ready to be rendered. + */ + isLoading?: boolean; + + /** + * Timer flag disabling actions + */ + isTimeoutDisabled?: boolean; + + /** + * Custom CSS class name for info messages. + */ + messageClassName?: string; + + /** + * Callback function called when authentication fails. + * @param error - The error that occurred during authentication. + */ + onError?: (error: Error) => void; + + /** + * Function to handle form submission. + * @param payload - The form data to submit. + * @param component - The component that triggered the submission. + */ + onSubmit?: (payload: EmbeddedSignInFlowRequest, component: EmbeddedFlowComponent) => Promise; + + /** + * Callback function called when authentication is successful. + * @param authData - The authentication data returned upon successful completion. + */ + onSuccess?: (authData: Record) => void; + + /** + * Component-level preferences to override global i18n and theme settings. + * Preferences are deep-merged with global ones, with component preferences + * taking precedence. Affects this component and all its descendants. + */ + preferences?: Preferences; + + /** + * Field-level validation errors returned by the server in `data.fieldErrors` on the + * most recent flow response. The component collapses these into the form's + * `fieldErrors` state (first error per field wins), surfacing them through the same + * render-prop / UI path as client-side validation errors. The full array is preserved + * here for advanced consumers that want every failing rule per field. + */ + serverFieldErrors?: FieldError[] | null; + + /** + * Size variant for the component. + */ + size?: 'small' | 'medium' | 'large'; + + /** + * Theme variant for the component. + */ + variant?: CardProps['variant']; +} + +/** + * Internal component that consumes FlowContext and renders the sign-in UI. + */ +const BaseSignInContent: FC = ({ + components = [], + onSubmit, + onError, + error: externalError, + className = '', + inputClassName = '', + buttonClassName = '', + messageClassName = '', + size = 'medium', + variant = 'outlined', + isLoading: externalIsLoading, + children, + additionalData = {}, + isTimeoutDisabled = false, + serverFieldErrors = null, +}: BaseSignInProps): ReactElement => { + const {meta} = useThunderID(); + const {theme} = useTheme(); + const customRenderers: ComponentRendererMap = useContext(ComponentRendererContext); + const {t} = useTranslation(); + const {subtitle: flowSubtitle, title: flowTitle, messages: flowMessages, addMessage, clearMessages} = useFlow(); + const styles: any = useStyles(theme, theme.vars.colors.text.primary); + + const [isSubmitting, setIsSubmitting] = useState(false); + const [apiError, setApiError] = useState(null); + + const isLoading: boolean = externalIsLoading || isSubmitting; -const BaseSignIn: FC = (props: BaseSignInProps) => { - const {platform} = useThunderID(); + /** + * Handle error responses and extract meaningful error messages + * Uses the transformer's extractErrorMessage function for consistency + */ + const handleError: any = useCallback( + (error: any) => { + const errorMessage: string = extractErrorMessage(error, t); - if (platform === Platform.ThunderID) { - return ; + // Set the API error state + setApiError(error instanceof Error ? error : new Error(errorMessage)); + + // Clear existing messages and add the error message + clearMessages(); + addMessage({ + message: errorMessage, + type: 'error', + }); + }, + [t, addMessage, clearMessages], + ); + + /** + * Extract form fields from flow components + */ + const extractFormFields: (components: EmbeddedFlowComponent[]) => FormField[] = useCallback( + (flowComponents: EmbeddedFlowComponent[]): FormField[] => { + const fields: FormField[] = []; + + const processComponents = (comps: EmbeddedFlowComponent[]): any => { + comps.forEach((component: any) => { + if ( + component.type === 'TEXT_INPUT' || + component.type === 'PASSWORD_INPUT' || + component.type === 'EMAIL_INPUT' || + component.type === 'PHONE_INPUT' || + component.type === 'OTP_INPUT' || + component.type === 'SELECT' || + component.type === 'DATE_INPUT' + ) { + const identifier: string = component.ref; + const ruleValidator = buildValidatorFromRules(component.validation); + fields.push({ + initialValue: '', + name: identifier, + required: component.required || false, + validator: (value: string) => { + if (component.required && (!value || value.trim() === '')) { + return t('validations.required.field.error'); + } + // Add email validation if it's an email field + if ( + (component.type === 'EMAIL_INPUT' || component.variant === 'EMAIL') && + value && + !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) + ) { + return t('field.email.invalid'); + } + // Evaluate declarative validation rules from meta.components[].validation. + // The composed validator returns the first failing rule's message (i18n key or + // literal string) so it can be passed straight to the i18n layer for display. + if (ruleValidator && value) { + const ruleMessage = ruleValidator(value); + if (ruleMessage) { + return t(ruleMessage); + } + } + + return null; + }, + }); + } + if (component.components) { + processComponents(component.components); + } + }); + }; + + processComponents(flowComponents); + return fields; + }, + [t], + ); + + const formFields: FormField[] = components ? extractFormFields(components) : []; + + const form: ReturnType = useForm>({ + fields: formFields, + initialValues: {}, + requiredMessage: t('validations.required.field.error'), + validateOnBlur: true, + validateOnChange: false, + }); + + const { + values: formValues, + touched: touchedFields, + errors: formErrors, + isValid: isFormValid, + setValue: setFormValue, + setTouched: setFormTouched, + setErrors: setFormErrors, + clearErrors: clearFormErrors, + validateForm, + touchAllFields, + } = form; + + /** + * Project server-side validation errors (from `data.fieldErrors`) into the form's + * `errors` state so they surface through the same render-prop / UI as client-side + * errors. When the server returns multiple failing rules for one field, only the + * first message is shown โ€” matching the SDK's single-string-per-field contract. + * The full FieldError[] remains available via the `serverFieldErrors` prop. + * + * Also marks each affected field as `touched` so the error renders immediately โ€” + * `useForm` only shows errors for touched fields by default. + */ + useEffect(() => { + clearFormErrors(); + if (!serverFieldErrors || serverFieldErrors.length === 0) { + return; + } + const errors: Record = {}; + for (const fe of serverFieldErrors) { + if (!(fe.identifier in errors)) { + errors[fe.identifier] = fe.message; + } + } + setFormErrors(errors); + Object.keys(errors).forEach((field: string) => setFormTouched(field, true)); + }, [serverFieldErrors, setFormErrors, setFormTouched, clearFormErrors]); + + /** + * Handle input value changes. + * Only updates the value without marking as touched. + * Touched state is set on blur to avoid premature validation. + */ + const handleInputChange = (name: string, value: string): void => { + setFormValue(name, value); + }; + + /** + * Handle input blur event. + * Marks the field as touched, which triggers validation. + */ + const handleInputBlur = (name: string): void => { + setFormTouched(name, true); + }; + + /** + * Handle component submission (for buttons and actions). + */ + const handleSubmit = async ( + component: EmbeddedFlowComponent, + data?: Record, + skipValidation?: boolean, + ): Promise => { + // Only validate for form submit actions, skip for social/trigger actions + if (!skipValidation) { + // Mark all fields as touched before validation + touchAllFields(); + + const validation: ReturnType = validateForm(); + + if (!validation.isValid) { + return; + } + } + + setIsSubmitting(true); + setApiError(null); + clearMessages(); + + try { + // Filter out empty or undefined input values + const filteredInputs: Record = {}; + if (data) { + Object.keys(data).forEach((key: any) => { + if (data[key] !== undefined && data[key] !== null && data[key] !== '') { + filteredInputs[key] = data[key]; + } + }); + } + + let payload: EmbeddedSignInFlowRequest = {}; + + // For V2, we always send inputs and action + payload = { + ...payload, + ...(component.id && {action: component.id}), + inputs: filteredInputs, + }; + + await onSubmit?.(payload, component); + } catch (err) { + handleError(err); + onError?.(err as Error); + } finally { + setIsSubmitting(false); + } + }; + + // Generate CSS classes + const containerClasses: any = cx( + [ + withVendorCSSClassPrefix('signin'), + withVendorCSSClassPrefix(`signin--${size}`), + withVendorCSSClassPrefix(`signin--${variant}`), + ], + className, + ); + + const inputClasses: any = cx( + [ + withVendorCSSClassPrefix('signin__input'), + size === 'small' && withVendorCSSClassPrefix('signin__input--small'), + size === 'large' && withVendorCSSClassPrefix('signin__input--large'), + ], + inputClassName, + ); + + const buttonClasses: any = cx( + [ + withVendorCSSClassPrefix('signin__button'), + size === 'small' && withVendorCSSClassPrefix('signin__button--small'), + size === 'large' && withVendorCSSClassPrefix('signin__button--large'), + ], + buttonClassName, + ); + + const messageClasses: any = cx([withVendorCSSClassPrefix('signin__messages')], messageClassName); + + /** + * Render components based on flow data using the factory + */ + const renderComponents: any = useCallback( + (flowComponents: EmbeddedFlowComponent[]): ReactElement[] => + renderSignInComponents( + flowComponents, + formValues, + touchedFields, + formErrors, + isLoading, + isFormValid, + handleInputChange, + { + _customRenderers: customRenderers, + _theme: theme, + additionalData, + buttonClassName: buttonClasses, + inputClassName: inputClasses, + isTimeoutDisabled, + meta, + onInputBlur: handleInputBlur, + onSubmit: handleSubmit, + size, + t, + variant, + }, + ), + [ + additionalData, + customRenderers, + formValues, + touchedFields, + formErrors, + isFormValid, + meta, + t, + theme, + isLoading, + size, + variant, + inputClasses, + buttonClasses, + handleInputBlur, + handleSubmit, + isTimeoutDisabled, + ], + ); + + // If render props are provided, use them + if (children) { + const renderProps: BaseSignInRenderProps = { + components, + error: apiError, + fieldErrors: formErrors, + handleInputChange, + handleSubmit, + isLoading, + isTimeoutDisabled, + isValid: isFormValid, + messages: flowMessages || [], + meta, + subtitle: flowSubtitle ?? '', + title: flowTitle || t('signin.heading'), + touched: touchedFields, + validateForm: () => { + const result: any = validateForm(); + return {fieldErrors: result.errors, isValid: result.isValid}; + }, + values: formValues, + }; + + return ( +
+ {children(renderProps)} +
+ ); + } + + // Default UI rendering + if (isLoading) { + return ( + + +
+ +
+
+
+ ); + } + + if (!components || components.length === 0) { + return ( + + + + {t('errors.signin.components.not.available')} + + + + ); } - return ; + return ( + + + {externalError && ( +
+ + {externalError.message} + +
+ )} + {flowMessages && flowMessages.length > 0 && ( +
+ {flowMessages.map((message: any, index: any) => ( + + {message.message} + + ))} +
+ )} +
{renderComponents(components)}
+
+
+ ); +}; + +/** + * Base SignIn component that provides generic authentication flow. + * This component handles component-driven UI rendering and can transform input + * structure to component-driven format automatically. + * + * @example + * // Default UI + * ```tsx + * import { BaseSignIn } from '@thunderid/react'; + * + * const MySignIn = () => { + * return ( + * { + * return await handleAuth(payload); + * }} + * onSuccess={(authData) => { + * console.log('Success:', authData); + * }} + * className="max-w-md mx-auto" + * /> + * ); + * }; + * ``` + * + * @example + * // Custom UI with render props + * ```tsx + * + * {({values, errors, handleInputChange, handleSubmit, isLoading, components}) => ( + *
+ * handleInputChange('username', e.target.value)} + * /> + * {errors.username && {errors.username}} + * + *
+ * )} + *
+ * ``` + */ +const BaseSignIn: FC = ({preferences, ...rest}: BaseSignInProps): ReactElement => { + const content: ReactElement = ( + + + + ); + + if (!preferences) return content; + + return {content}; }; export default BaseSignIn; diff --git a/packages/react/src/components/presentation/auth/SignIn/SignIn.tsx b/packages/react/src/components/presentation/auth/SignIn/SignIn.tsx index 1a463b3..be9ed29 100644 --- a/packages/react/src/components/presentation/auth/SignIn/SignIn.tsx +++ b/packages/react/src/components/presentation/auth/SignIn/SignIn.tsx @@ -1,5 +1,5 @@ /** - * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com). + * Copyright (c) 2025-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 @@ -17,46 +17,161 @@ */ import { - EmbeddedSignInFlowInitiateResponse, - EmbeddedSignInFlowHandleResponse, - EmbeddedSignInFlowHandleRequestPayload, - Platform, + ThunderIDRuntimeError, + ThunderIDAPIError, + EmbeddedFlowComponent, + EmbeddedFlowType, + EmbeddedSignInFlowResponse, + EmbeddedSignInFlowRequest, + EmbeddedSignInFlowStatus, + EmbeddedSignInFlowType, + FieldError, + FlowMetadataResponse, Preferences, + logger, } from '@thunderid/browser'; -import {FC, ReactElement} from 'react'; +import {FC, ReactElement, useState, useEffect, useRef, ReactNode} from 'react'; +// eslint-disable-next-line import/no-named-as-default import BaseSignIn, {BaseSignInProps} from './BaseSignIn'; -import SignInV2, {SignInRenderProps} from './v2/SignIn'; import useThunderID from '../../../../contexts/ThunderID/useThunderID'; +import {useOAuthCallback} from '../../../../hooks/useOAuthCallback'; +import useTranslation from '../../../../hooks/useTranslation'; +import {extractErrorMessage, normalizeFlowResponse} from '../../../../utils/flowTransformer'; +import {initiateOAuthRedirect} from '../../../../utils/oauth'; +import {handlePasskeyAuthentication, handlePasskeyRegistration} from '../../../../utils/passkey'; + +/** + * Render props function parameters + */ +export interface SignInRenderProps { + /** + * Additional data from the flow response containing contextual information + * like consent prompt details and session timeouts. + */ + additionalData?: Record; + + /** + * Current flow components + */ + components: EmbeddedFlowComponent[]; + + /** + * Current error if any + */ + error: Error | null; + + /** + * Server-side field-level validation errors from the most recent flow response, + * collapsed to one message per field (first error wins). Empty when no validation + * failures are active. Render-prop consumers should display these alongside their + * own client-side validation errors. + */ + fieldErrors: Record; + + /** + * Function to manually initialize the flow + */ + initialize: () => Promise; + + /** + * Whether the flow has been initialized + */ + isInitialized: boolean; + + /** + * Loading state indicator + */ + isLoading: boolean; + + /** + * Flag indicating whether the flow step timeout has expired. + * Consuming components can use this to disable submit buttons. + */ + isTimeoutDisabled?: boolean; + + /** + * Flow metadata returned by the platform (v2 only). `null` while loading or unavailable. + */ + meta: FlowMetadataResponse | null; + + /** + * Function to submit authentication data (primary) + */ + onSubmit: (payload: EmbeddedSignInFlowRequest) => Promise; +} /** * Props for the SignIn component. - * Extends BaseSignInProps for full compatibility with the React BaseSignIn component + * Matches the interface from the main SignIn component for consistency. */ -export type SignInProps = Pick & { +export interface SignInProps { + /** + * Render props function for custom UI + */ + children?: (props: SignInRenderProps) => ReactNode; + /** - * Render function for custom UI (render props pattern). + * Custom CSS class name for the form container. */ - children?: (props: SignInRenderProps) => ReactElement; + className?: string; + + /** + * Callback function called when authentication fails. + * @param error - The error that occurred during authentication. + */ + onError?: (error: Error) => void; + + /** + * Callback function called when authentication is successful. + * @param authData - The authentication data returned upon successful completion. + */ + onSuccess?: (authData: Record) => void; + /** * Component-level preferences to override global i18n and theme settings. + * Preferences are deep-merged with global ones, with component preferences + * taking precedence. Affects this component and all its descendants. */ preferences?: Preferences; -}; + + /** + * Size variant for the component. + */ + size?: 'small' | 'medium' | 'large'; + + /** + * Theme variant for the component. + */ + variant?: BaseSignInProps['variant']; +} + +/** + * State for tracking passkey registration + */ +interface PasskeyState { + actionId: string | null; + challenge: string | null; + creationOptions: string | null; + error: Error | null; + executionId: string | null; + isActive: boolean; +} /** - * A styled SignIn component that provides native authentication flow with pre-built styling. - * This component handles the API calls for authentication and delegates UI logic to BaseSignIn. + * A component-driven SignIn component that provides authentication flow with pre-built styling. + * This component handles the flow API calls for authentication and delegates UI logic to BaseSignIn. + * It automatically transforms simple input-based responses into component-driven UI format. * * @example + * // Default UI * ```tsx - * import { SignIn } from '@thunderid/react'; + * import { SignIn } from '@thunderid/react/component-driven'; * * const App = () => { * return ( * { * console.log('Authentication successful:', authData); - * // Handle successful authentication (e.g., redirect, store tokens) * }} * onError={(error) => { * console.error('Authentication failed:', error); @@ -67,70 +182,807 @@ export type SignInProps = Pick { + * return ( + * console.log('Success:', authData)} + * onError={(error) => console.error('Error:', error)} + * > + * {({signIn, isLoading, components, error, isInitialized}) => ( + *
+ *

Custom Sign In

+ * {!isInitialized ? ( + *

Initializing...

+ * ) : error ? ( + *
{error.message}
+ * ) : ( + *
{ + * e.preventDefault(); + * signIn({inputs: {username: 'user', password: 'pass'}}); + * }}> + * + *
+ * )} + *
+ * )} + *
+ * ); + * }; + * ``` */ -const SignIn: FC = ({className, size = 'medium', children, preferences, ...rest}: SignInProps) => { - const {signIn, afterSignInUrl, isInitialized, isLoading, platform} = useThunderID(); +const SignIn: FC = ({ + className, + preferences, + size = 'medium', + onSuccess, + onError, + variant, + children, +}: SignInProps): ReactElement => { + const {applicationId, afterSignInUrl, signIn, isInitialized, isLoading, meta, getStorageManager, scopes} = + useThunderID(); + const {t} = useTranslation(preferences?.i18n); + + // State management for the flow + const [components, setComponents] = useState([]); + const [additionalData, setAdditionalData] = useState>({}); + // Server-side validation errors from the most recent flow response. Updated on every + // submission; cleared when the next submission begins so stale errors don't linger. + const [serverFieldErrors, setServerFieldErrors] = useState(null); + const [currentExecutionId, setCurrentExecutionId] = useState(null); + const challengeTokenRef: any = useRef(null); + const [isStorageReady, setIsStorageReady] = useState(false); + const [isFlowInitialized, setIsFlowInitialized] = useState(false); + const [flowError, setFlowError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + const [isTimeoutDisabled, setIsTimeoutDisabled] = useState(false); + const [passkeyState, setPasskeyState] = useState({ + actionId: null, + challenge: null, + creationOptions: null, + error: null, + executionId: null, + isActive: false, + }); + const initializationAttemptedRef: any = useRef(false); + const oauthCodeProcessedRef: any = useRef(false); + const passkeyProcessedRef: any = useRef(false); + /** + * Sets executionId between sessionStorage and state. + * This ensures both are always in sync. + */ + const setExecutionId = (executionId: string | null): void => { + setCurrentExecutionId(executionId); + if (executionId) { + sessionStorage.setItem('thunderid_execution_id', executionId); + } else { + sessionStorage.removeItem('thunderid_execution_id'); + } + }; + + /** + * Restore any challenge token persisted before an OAuth redirect. + * Waits for SDK initialization before reading from storage. + */ + useEffect(() => { + if (!isInitialized) return; + + (async (): Promise => { + try { + const storageManager: any = await getStorageManager(); + const tempData: any = await storageManager?.getTemporaryData(); + if (tempData?.challengeToken) { + challengeTokenRef.current = tempData.challengeToken as string; + } + } finally { + setIsStorageReady(true); + } + })(); + }, [isInitialized]); + + /** + * Updates challengeTokenRef immediately (stale-closure safe) and persists via + * the provider's StorageManager so the token survives OAuth redirects. + */ + const setChallengeToken = async (challengeToken: string | null): Promise => { + challengeTokenRef.current = challengeToken; + try { + const storageManager: any = await getStorageManager(); + if (storageManager) { + if (challengeToken) { + await storageManager.setTemporaryDataParameter('challengeToken', challengeToken); + } else { + await storageManager.removeTemporaryDataParameter('challengeToken'); + } + } + } catch { + logger.warn('Failed to persist challenge token in storage.'); + } + }; + + /** + * Clear all flow-related storage and state. + */ + const clearFlowState = async (): Promise => { + setExecutionId(null); + await setChallengeToken(null); + setIsFlowInitialized(false); + try { + const storageManager: any = await getStorageManager(); + await storageManager?.removeHybridDataParameter?.('authId'); + } catch { + logger.warn('Failed to clear authId from hybrid storage.'); + } + setIsTimeoutDisabled(false); + // Reset refs to allow new flows to start properly + oauthCodeProcessedRef.current = false; + }; + + /** + * Parse URL parameters used in flows. + */ + const getUrlParams = (): any => { + const urlParams: any = new URL(window?.location?.href ?? '').searchParams; + + return { + applicationId: urlParams.get('applicationId'), + authId: urlParams.get('authId'), + code: urlParams.get('code'), + error: urlParams.get('error'), + errorDescription: urlParams.get('error_description'), + executionId: urlParams.get('executionId'), + nonce: urlParams.get('nonce'), + state: urlParams.get('state'), + }; + }; + + /** + * Handle authId from URL and persist it via the storage manager so it survives URL cleanup. + * ThunderIDReactClient.signIn() reads authId from storageManager.getHybridDataParameter('authId'), + * not from raw sessionStorage, so we must use the same storage path here. + */ + const handleAuthId = async (authId: string | null): Promise => { + if (authId) { + try { + const storageManager: any = await getStorageManager(); + await storageManager?.setHybridDataParameter?.('authId', authId); + } catch { + logger.warn('Failed to store authId in hybrid storage.'); + } + } + }; + + /** + * Clean up OAuth-related URL parameters from the browser URL. + */ + const cleanupOAuthUrlParams = (includeNonce = false): void => { + if (!window?.location?.href) return; + const url: any = new URL(window.location.href); + url.searchParams.delete('error'); + url.searchParams.delete('error_description'); + url.searchParams.delete('code'); + url.searchParams.delete('state'); + if (includeNonce) { + url.searchParams.delete('nonce'); + } + window?.history?.replaceState({}, '', url.toString()); + }; + + /** + * Clean up flow-related URL parameters (executionId, authId) from the browser URL. + * Used after executionId is set in state to prevent using invalidated executionId from URL. + */ + const cleanupFlowUrlParams = (): void => { + if (!window?.location?.href) return; + const url: any = new URL(window.location.href); + url.searchParams.delete('executionId'); + url.searchParams.delete('authId'); + url.searchParams.delete('applicationId'); + window?.history?.replaceState({}, '', url.toString()); + }; + + /** + * Set error state and call onError callback. + * Ensures isFlowInitialized is true so errors can be displayed in the UI. + */ + const setError = (error: Error): void => { + setFlowError(error); + setIsFlowInitialized(true); + onError?.(error); + }; + + /** + * Handle OAuth error from URL parameters. + * Clears flow state, creates error, and cleans up URL. + */ + const handleOAuthError = (error: string, errorDescription: string | null): void => { + clearFlowState(); + const errorMessage: any = errorDescription || `OAuth error: ${error}`; + const err: any = new ThunderIDRuntimeError(errorMessage, 'SIGN_IN_ERROR', 'react'); + setError(err); + cleanupOAuthUrlParams(true); + }; + + /** + * Handle REDIRECTION response by storing flow state and redirecting to OAuth provider. + */ + const handleRedirection = async (response: EmbeddedSignInFlowResponse): Promise => { + if (response.type === EmbeddedSignInFlowType.Redirection) { + const redirectURL: any = (response.data as any)?.redirectURL || (response as any)?.redirectURL; + + if (redirectURL && window?.location) { + if (response.executionId) { + setExecutionId(response.executionId); + } + await setChallengeToken(response.challengeToken ?? null); + + const urlParams: any = getUrlParams(); + await handleAuthId(urlParams.authId); + + initiateOAuthRedirect(redirectURL); + return true; + } + } + return false; + }; /** * Initialize the authentication flow. + * Priority: executionId > applicationId (from context) > applicationId (from URL) + */ + const initializeFlow = async (): Promise => { + const urlParams: any = getUrlParams(); + + // Reset OAuth code processed ref when starting a new flow + oauthCodeProcessedRef.current = false; + // Clear stale serverFieldErrors so the new flow doesn't inherit them via render-prop. + setServerFieldErrors(null); + + await handleAuthId(urlParams.authId); + + const effectiveApplicationId: any = applicationId || urlParams.applicationId; + + // On a page refresh the executionId is no longer in the URL (it is scrubbed by + // cleanupFlowUrlParams after the first load). Fall back to the executionId persisted in + // sessionStorage so the in-progress flow can be resumed instead of starting a new flow. + // This is required for authorization_code apps where direct new-flow initiation is blocked + // server-side and the flow must be initiated through the OAuth /authorize endpoint. + const storedExecutionId: any = !urlParams.executionId ? sessionStorage.getItem('thunderid_execution_id') : null; + const resumeExecutionId: any = urlParams.executionId || storedExecutionId; + + if (!resumeExecutionId && !effectiveApplicationId) { + const error: any = new ThunderIDRuntimeError( + 'Either executionId or applicationId is required for authentication', + 'SIGN_IN_ERROR', + 'react', + ); + setError(error); + throw error; + } + + try { + setFlowError(null); + + let response: EmbeddedSignInFlowResponse; + + if (resumeExecutionId) { + try { + response = (await signIn({ + executionId: resumeExecutionId, + ...(challengeTokenRef.current ? {challengeToken: challengeTokenRef.current} : {}), + })) as EmbeddedSignInFlowResponse; + } catch (resumeError) { + // Only treat a stale/expired session (HTTP 400 from the server, meaning the stored + // executionId is no longer valid) as a recoverable condition. Transient failures + // such as 5xx server errors or network errors are rethrown so they surface correctly. + const isStaleSession: boolean = + storedExecutionId && + resumeExecutionId === storedExecutionId && + resumeError instanceof ThunderIDAPIError && + resumeError.statusCode === 400; + + if (isStaleSession) { + setExecutionId(null); + try { + const storageManager: any = await getStorageManager(); + await storageManager?.removeHybridDataParameter?.('authId'); + } catch { + logger.warn('Failed to clear authId from hybrid storage.'); + } + if (!effectiveApplicationId) { + const expiredError: any = new ThunderIDRuntimeError( + t('errors.signin.session.expired') || + 'Your session has expired. Please return to the application and sign in again.', + 'SIGN_IN_ERROR', + 'react', + ); + setError(expiredError); + return; + } + response = (await signIn({ + applicationId: effectiveApplicationId, + flowType: EmbeddedFlowType.Authentication, + ...(scopes && {scopes}), + })) as EmbeddedSignInFlowResponse; + } else { + throw resumeError; + } + } + } else { + response = (await signIn({ + applicationId: effectiveApplicationId, + flowType: EmbeddedFlowType.Authentication, + ...(scopes && {scopes}), + })) as EmbeddedSignInFlowResponse; + } + + if (await handleRedirection(response)) { + return; + } + + const { + executionId: normalizedExecutionId, + components: normalizedComponents, + additionalData: normalizedAdditionalData, + } = normalizeFlowResponse( + response, + t, + { + resolveTranslations: false, + }, + meta, + ); + + await setChallengeToken(response.challengeToken ?? null); + + if (normalizedExecutionId && normalizedComponents) { + setExecutionId(normalizedExecutionId); + setComponents(normalizedComponents); + setAdditionalData(normalizedAdditionalData ?? {}); + setIsFlowInitialized(true); + setIsTimeoutDisabled(false); + // Clean up executionId from URL after setting it in state + cleanupFlowUrlParams(); + } + } catch (error) { + const err: any = error; + await clearFlowState(); + + setError(err instanceof ThunderIDRuntimeError ? err : new Error(extractErrorMessage(err, t))); + initializationAttemptedRef.current = false; + } + }; + + /** + * Initialize the flow and handle cleanup of stale flow state. */ - const handleInitialize = async (): Promise => - (await signIn({response_mode: 'direct'})) as EmbeddedSignInFlowInitiateResponse; + useEffect(() => { + const urlParams: any = getUrlParams(); + + // Check for OAuth error in URL + if (urlParams.error) { + handleOAuthError(urlParams.error, urlParams.errorDescription); + return; + } + + handleAuthId(urlParams.authId); + + // Skip OAuth code processing - let the dedicated OAuth useEffect handle it + // No action needed here as the dedicated useEffect will handle it + }, []); + + useEffect(() => { + // Only initialize if we're not processing an OAuth callback or submission. + // Wait for isStorageReady so the challenge token is restored from storage before + // we attempt to resume a persisted executionId โ€” the server requires it. + const currentUrlParams: any = getUrlParams(); + if ( + isInitialized && + isStorageReady && + !isLoading && + !isFlowInitialized && + !initializationAttemptedRef.current && + !currentExecutionId && + !currentUrlParams.code && + !currentUrlParams.state && + !isSubmitting && + !oauthCodeProcessedRef.current + ) { + initializationAttemptedRef.current = true; + initializeFlow(); + } + }, [isInitialized, isStorageReady, isLoading, isFlowInitialized, currentExecutionId]); /** - * Handle authentication steps. + * Handle step timeout if configured in additionalData. */ - const handleOnSubmit = async ( - payload: EmbeddedSignInFlowHandleRequestPayload, - request: Request, - ): Promise => (await signIn(payload, request)) as EmbeddedSignInFlowHandleResponse; + useEffect(() => { + const timeoutMs: number = Number(additionalData?.['stepTimeout']) || 0; + if (timeoutMs <= 0 || !isFlowInitialized) { + setIsTimeoutDisabled(false); + return undefined; + } + + const remaining: number = Math.max(0, Math.floor((timeoutMs - Date.now()) / 1000)); + + const handleTimeout = (): void => { + const errorMessage: string = t('errors.signin.timeout') || 'Time allowed to complete the step has expired.'; + setError(new Error(errorMessage)); + setIsTimeoutDisabled(true); + }; + + if (remaining <= 0) { + handleTimeout(); + return undefined; + } + + const timerId: any = setTimeout(() => { + handleTimeout(); + }, remaining * 1000); + + return () => clearTimeout(timerId); + }, [additionalData?.['stepTimeout'], isFlowInitialized, t]); /** - * Handle successful authentication and redirect with query params. + * Handle form submission from BaseSignIn or render props. */ - const handleSuccess = (authData: Record): void => { - if (authData && afterSignInUrl) { - const url: URL = new URL(afterSignInUrl, window.location.origin); + const handleSubmit = async (payload: EmbeddedSignInFlowRequest): Promise => { + // Use executionId from payload if available, otherwise fall back to currentExecutionId + const effectiveExecutionId: any = payload.executionId || currentExecutionId; + + if (!effectiveExecutionId) { + throw new Error('No active flow ID'); + } + + const processedInputs: Record = {...payload.inputs}; + + // Auto-compile consent decisions if we are currently on a consent prompt step + if (additionalData?.['consentPrompt']) { + try { + const consentPromptRawData: any = additionalData['consentPrompt']; + const purposes: any[] = + typeof consentPromptRawData === 'string' + ? JSON.parse(consentPromptRawData) + : consentPromptRawData.purposes || consentPromptRawData; + + // Find the action component to determine if it was a deny action + let isDeny = false; + if (payload.action) { + // Flatten components to find the action + const findAction = (comps: any[]): any => { + if (!comps || comps.length === 0) return null; + + const found: any = comps.find((c: any) => c.id === payload.action); + if (found) return found; + + return comps.reduce((acc: any, c: any) => { + if (acc) return acc; + if (c.components) return findAction(c.components); + return null; + }, null); + }; + + const submitAction: any = findAction(components); - Object.entries(authData).forEach(([key, value]: [string, any]) => { - if (value !== undefined && value !== null) { - url.searchParams.append(key, String(value)); + if (submitAction && submitAction.variant?.toLowerCase() !== 'primary') { + isDeny = true; + } } - }); - window.location.href = url.toString(); + const decisions: any = { + purposes: purposes.map((p: any) => ({ + approved: !isDeny, + elements: [ + ...(p.essential || []).map((e: any) => ({ + approved: !isDeny, + name: e.name, + })), + ...(p.optional || []).map((e: any) => { + const key = `__consent_opt__${p.purposeId}__${e.name}`; + return { + approved: isDeny ? false : processedInputs[key] !== 'false', + name: e.name, + }; + }), + ], + purposeName: p.purposeName, + })), + }; + processedInputs['consent_decisions'] = JSON.stringify(decisions); + + // Cleanup temporary consent tracking fields from inputs + Object.keys(processedInputs).forEach((key: string) => { + if (key.startsWith('__consent_opt__')) { + delete processedInputs[key]; + } + }); + } catch (e) { + // Failed to construct consent_decisions payload automatically + } + } + + try { + setIsSubmitting(true); + setFlowError(null); + // Clear any field errors from the previous response before the new round-trip. + setServerFieldErrors(null); + + const response: EmbeddedSignInFlowResponse = (await signIn({ + executionId: effectiveExecutionId, + ...payload, + inputs: processedInputs, + ...(challengeTokenRef.current ? {challengeToken: challengeTokenRef.current} : {}), + })) as EmbeddedSignInFlowResponse; + + if (await handleRedirection(response)) { + return; + } + if ( + response.data?.additionalData?.['passkeyChallenge'] || + response.data?.additionalData?.['passkeyCreationOptions'] + ) { + const {passkeyChallenge, passkeyCreationOptions}: any = response.data.additionalData; + const effectiveExecutionIdForPasskey: any = response.executionId || effectiveExecutionId; + + // Reset passkey processed ref to allow processing + passkeyProcessedRef.current = false; + + await setChallengeToken(response.challengeToken ?? null); + + // Set passkey state to trigger the passkey + setPasskeyState({ + actionId: 'submit', + challenge: passkeyChallenge, + creationOptions: passkeyCreationOptions, + error: null, + executionId: effectiveExecutionIdForPasskey, + isActive: true, + }); + setIsSubmitting(false); + + return; + } + + const { + executionId: normalizedExecutionId, + components: normalizedComponents, + additionalData: normalizedAdditionalData, + } = normalizeFlowResponse( + response, + t, + { + resolveTranslations: false, + }, + meta, + ); + + // Handle Error flow status - flow has failed and is invalidated + if (response.flowStatus === EmbeddedSignInFlowStatus.Error) { + await clearFlowState(); + const err: any = new Error(extractErrorMessage(response, t)); + setError(err); + cleanupFlowUrlParams(); + // Throw the error so it's caught by the catch block and propagated to BaseSignIn + throw err; + } + + if (response.flowStatus === EmbeddedSignInFlowStatus.Complete) { + // Get redirectUrl from response (from /oauth2/auth/callback) or fall back to afterSignInUrl + const redirectUrl: any = (response as any)?.redirectUrl || (response as any)?.redirect_uri; + const finalRedirectUrl: any = redirectUrl || afterSignInUrl; + + // Clear submitting state before redirect + setIsSubmitting(false); + + // Clear all OAuth-related storage on successful completion + setExecutionId(null); + await setChallengeToken(null); + setIsFlowInitialized(false); + sessionStorage.removeItem('thunderid_execution_id'); + try { + const storageManager: any = await getStorageManager(); + await storageManager?.removeHybridDataParameter?.('authId'); + } catch { + logger.warn('Failed to clear authId from hybrid storage after completion.'); + } + + // Clean up OAuth URL params before redirect + cleanupOAuthUrlParams(true); + + if (onSuccess) { + onSuccess({ + redirectUrl: finalRedirectUrl, + ...(response.data || {}), + }); + } + + if (finalRedirectUrl && window?.location) { + window.location.href = finalRedirectUrl; + } + + return; + } + + // Always update challenge token on any INCOMPLETE response โ€” token rotates every step. + await setChallengeToken(response.challengeToken ?? null); + + // Update executionId if response contains a new one + if (normalizedExecutionId && normalizedComponents) { + setExecutionId(normalizedExecutionId); + setComponents(normalizedComponents); + setAdditionalData(normalizedAdditionalData ?? {}); + setIsTimeoutDisabled(false); + // Ensure flow is marked as initialized when we have components + setIsFlowInitialized(true); + // Clean up executionId from URL after setting it in state + cleanupFlowUrlParams(); + + // Surface server-side validation failures so BaseSignIn can inject them into + // the form-level fieldErrors state used by the render-prop / default UI. + const responseFieldErrors: FieldError[] | undefined = (response.data as any)?.fieldErrors; + if (responseFieldErrors && responseFieldErrors.length > 0) { + setServerFieldErrors(responseFieldErrors); + } + + // Display error from INCOMPLETE response + if ((response as any)?.error) { + setFlowError(new Error(extractErrorMessage(response, t))); + } + } + } catch (error) { + const err: any = error; + await clearFlowState(); + + setError(err instanceof ThunderIDRuntimeError ? err : new Error(extractErrorMessage(err, t))); + return; + } finally { + setIsSubmitting(false); } }; - if (platform === Platform.ThunderID) { - return ( - - {children} - - ); - } + /** + * Handle authentication errors. + */ + const handleError = (error: Error): void => { + setError(error); + }; + + useOAuthCallback({ + currentExecutionId, + isInitialized: isInitialized && !isLoading && isStorageReady, + isSubmitting, + onError: (err: any) => { + clearFlowState(); + setError(err instanceof Error ? err : new Error(String(err))); + }, + onSubmit: async (payload: any) => handleSubmit({executionId: payload.executionId, inputs: payload.inputs}), + processedRef: oauthCodeProcessedRef, + setExecutionId, + }); + + /** + * Handle passkey authentication/registration when passkey state becomes active. + * This effect auto-triggers the browser passkey popup and submits the result. + */ + useEffect(() => { + if ( + !passkeyState.isActive || + (!passkeyState.challenge && !passkeyState.creationOptions) || + !passkeyState.executionId + ) { + return; + } + + // Prevent re-processing + if (passkeyProcessedRef.current) { + return; + } + passkeyProcessedRef.current = true; + const performPasskeyProcess = async (): Promise => { + let inputs: Record; + + if (passkeyState.challenge) { + const passkeyResponse: any = await handlePasskeyAuthentication(passkeyState.challenge); + const passkeyResponseObj: any = JSON.parse(passkeyResponse); + + inputs = { + authenticatorData: passkeyResponseObj.response.authenticatorData, + clientDataJSON: passkeyResponseObj.response.clientDataJSON, + credentialId: passkeyResponseObj.id, + signature: passkeyResponseObj.response.signature, + userHandle: passkeyResponseObj.response.userHandle, + }; + } else if (passkeyState.creationOptions) { + const passkeyResponse: any = await handlePasskeyRegistration(passkeyState.creationOptions); + const passkeyResponseObj: any = JSON.parse(passkeyResponse); + + inputs = { + attestationObject: passkeyResponseObj.response.attestationObject, + clientDataJSON: passkeyResponseObj.response.clientDataJSON, + credentialId: passkeyResponseObj.id, + }; + } else { + throw new Error('No passkey challenge or creation options available'); + } + + await handleSubmit({ + executionId: passkeyState.executionId ?? undefined, + inputs, + }); + }; + + performPasskeyProcess() + .then(() => { + setPasskeyState({ + actionId: null, + challenge: null, + creationOptions: null, + error: null, + executionId: null, + isActive: false, + }); + }) + .catch((error: any) => { + setPasskeyState((prev: any) => ({...prev, error: error as Error, isActive: false})); + setFlowError(error as Error); + onError?.(error as Error); + }); + }, [passkeyState.isActive, passkeyState.challenge, passkeyState.creationOptions, passkeyState.executionId]); + + if (children) { + // Collapse the server FieldError[] array to a single message per field map for + // render-prop consumers. First error per field wins. Multi-error cases per + // field are rare in practice (server typically returns one rule failure per + // field in current flows) and consumers needing the full array can still read + // it from the raw flow response. + const renderPropFieldErrors: Record = {}; + if (serverFieldErrors) { + for (const fe of serverFieldErrors) { + if (!(fe.identifier in renderPropFieldErrors)) { + renderPropFieldErrors[fe.identifier] = fe.message; + } + } + } + + const renderProps: SignInRenderProps = { + additionalData, + components, + error: flowError, + fieldErrors: renderPropFieldErrors, + initialize: initializeFlow, + isInitialized: isFlowInitialized, + isLoading: isLoading || isSubmitting || !isInitialized, + isTimeoutDisabled, + meta, + onSubmit: handleSubmit, + }; + + return <>{children(renderProps)}; + } + // Otherwise, render the default BaseSignIn component return ( ); }; diff --git a/packages/react/src/components/presentation/auth/SignIn/v1/BaseSignIn.tsx b/packages/react/src/components/presentation/auth/SignIn/v1/BaseSignIn.tsx deleted file mode 100644 index 2a76274..0000000 --- a/packages/react/src/components/presentation/auth/SignIn/v1/BaseSignIn.tsx +++ /dev/null @@ -1,1272 +0,0 @@ -/** - * 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 {cx} from '@emotion/css'; -import { - EmbeddedSignInFlowAuthenticator, - EmbeddedSignInFlowInitiateResponse, - EmbeddedSignInFlowHandleResponse, - EmbeddedSignInFlowStepType, - EmbeddedSignInFlowStatus, - EmbeddedSignInFlowAuthenticatorPromptType, - ApplicationNativeAuthenticationConstants, - ThunderIDAPIError, - withVendorCSSClassPrefix, - EmbeddedSignInFlowHandleRequestPayload, - EmbeddedFlowExecuteRequestConfig, - handleWebAuthnAuthentication, - createPackageComponentLogger, -} from '@thunderid/browser'; -import {FC, FormEvent, RefObject, useEffect, useState, useCallback, useRef, ReactElement} from 'react'; -import {createSignInOptionFromAuthenticator} from './options/SignInOptionFactory'; -import FlowProvider from '../../../../../contexts/Flow/FlowProvider'; -import useFlow from '../../../../../contexts/Flow/useFlow'; -import useTheme from '../../../../../contexts/Theme/useTheme'; -import {useForm, FormField} from '../../../../../hooks/useForm'; -import useTranslation from '../../../../../hooks/useTranslation'; -import AlertPrimitive, {AlertVariant} from '../../../../primitives/Alert/Alert'; -import CardPrimitive, {CardProps} from '../../../../primitives/Card/Card'; -import Divider from '../../../../primitives/Divider/Divider'; -import Logo from '../../../../primitives/Logo/Logo'; -import Spinner from '../../../../primitives/Spinner/Spinner'; -import Typography from '../../../../primitives/Typography/Typography'; -import useStyles from '../BaseSignIn.styles'; - -const logger: ReturnType = createPackageComponentLogger( - '@thunderid/react', - 'BaseSignIn', -); - -/** - * Check if the authenticator is a passkey/FIDO authenticator - */ -const isPasskeyAuthenticator = (authenticator: EmbeddedSignInFlowAuthenticator): boolean => - authenticator.authenticatorId === ApplicationNativeAuthenticationConstants.SupportedAuthenticators.Passkey && - authenticator.metadata?.promptType === EmbeddedSignInFlowAuthenticatorPromptType.InternalPrompt && - (authenticator.metadata as any)?.additionalData?.challengeData; - -/** - * Props for the BaseSignIn component. - */ -export interface BaseSignInProps { - afterSignInUrl?: string; - - /** - * Custom CSS class name for the submit button. - */ - buttonClassName?: string; - - /** - * Custom CSS class name for the form container. - */ - className?: string; - - /** - * Custom CSS class name for error messages. - */ - errorClassName?: string; - - /** - * Custom CSS class name for form inputs. - */ - inputClassName?: string; - - /** - * Flag to determine the component is ready to be rendered. - */ - isLoading?: boolean; - - /** - * Custom CSS class name for info messages. - */ - messageClassName?: string; - - /** - * Callback function called when authentication fails. - * @param error - The error that occurred during authentication. - */ - onError?: (error: Error) => void; - - /** - * Callback function called when authentication flow status changes. - * @param response - The current authentication response. - */ - onFlowChange?: (response: EmbeddedSignInFlowInitiateResponse | EmbeddedSignInFlowHandleResponse) => void; - - /** - * Function to initialize authentication flow. - * @returns Promise resolving to the initial authentication response. - */ - onInitialize?: () => Promise; - - /** - * Function to handle authentication steps. - * @param payload - The authentication payload. - * @returns Promise resolving to the authentication response. - */ - onSubmit?: ( - payload: EmbeddedSignInFlowHandleRequestPayload, - request: EmbeddedFlowExecuteRequestConfig, - ) => Promise; - - /** - * Callback function called when authentication is successful. - * @param authData - The authentication data returned upon successful completion. - */ - onSuccess?: (authData: Record) => void; - - /** - * Whether to show the logo. - */ - showLogo?: boolean; - /** - * Whether to show the subtitle. - */ - showSubtitle?: boolean; - - /** - * Whether to show the title. - */ - showTitle?: boolean; - - /** - * Size variant for the component. - */ - size?: 'small' | 'medium' | 'large'; - - /** - * Theme variant for the component. - */ - variant?: CardProps['variant']; -} - -/** - * `T3JnYW5pemF0aW9uQXV0aGVudGljYXRvcjpTU08` - OrganizationSSO - * Currently, `App-Native Authentication` doesn't support organization SSO. - * Tracker: TODO: Create `product-is` issue for this. - */ -const HIDDEN_AUTHENTICATORS: string[] = ['T3JnYW5pemF0aW9uQXV0aGVudGljYXRvcjpTU08']; - -/** - * Internal component that consumes FlowContext and renders the sign-in UI. - */ -const BaseSignInContent: FC = ({ - afterSignInUrl, - onInitialize, - isLoading: externalIsLoading, - onSubmit, - onSuccess, - onError, - onFlowChange, - className = '', - inputClassName = '', - buttonClassName = '', - errorClassName = '', - messageClassName = '', - size = 'medium', - variant = 'outlined', - showTitle = true, - showSubtitle = true, -}: BaseSignInProps): ReactElement => { - const {theme} = useTheme(); - const {t} = useTranslation(); - const {subtitle: flowSubtitle, title: flowTitle, messages: flowMessages} = useFlow(); - const styles: ReturnType = useStyles(theme, theme.vars.colors.text.primary); - - const [isSignInInitializationRequestLoading, setIsSignInInitializationRequestLoading] = useState(false); - const [isInitialized, setIsInitialized] = useState(false); - const [currentFlow, setCurrentFlow] = useState(null); - const [currentAuthenticator, setCurrentAuthenticator] = useState(null); - const [error, setError] = useState(null); - const [messages, setMessages] = useState<{message: string; type: string}[]>([]); - - const isLoading: boolean = externalIsLoading || isSignInInitializationRequestLoading; - - const reRenderCheckRef: RefObject = useRef(false); - - const formFields: FormField[] = - currentAuthenticator?.metadata?.params?.map((param: any) => ({ - initialValue: '', - name: param.param, - required: currentAuthenticator.requiredParams.includes(param.param), - validator: (value: string): string | null => { - if (currentAuthenticator.requiredParams.includes(param.param) && (!value || value.trim() === '')) { - return t('validations.required.field.error'); - } - return null; - }, - })) || []; - - const form: any = useForm>({ - fields: formFields, - initialValues: {}, - requiredMessage: t('validations.required.field.error'), - validateOnBlur: true, - validateOnChange: false, - }); - - const { - values: formValues, - touched: touchedFields, - setValue: setFormValue, - setTouched: setFormTouched, - validateForm, - touchAllFields, - reset: resetForm, - } = form; - - /** - * Setup form fields based on the current authenticator. - */ - const setupFormFields: any = useCallback( - (authenticator: EmbeddedSignInFlowAuthenticator): void => { - const initialValues: Record = {}; - authenticator.metadata?.params?.forEach((param: any) => { - initialValues[param.param] = ''; - }); - - // Reset form with new values - resetForm(); - - // Set initial values for all fields - Object.keys(initialValues).forEach((key: string) => { - setFormValue(key, initialValues[key]); - }); - }, - [resetForm, setFormValue], - ); - - /** - * Check if the response contains a redirection URL and perform the redirect if necessary. - * @param response - The authentication response - * @returns true if a redirect was performed, false otherwise - */ - const handleRedirectionIfNeeded = (response: EmbeddedSignInFlowHandleResponse): boolean => { - if ( - response && - 'nextStep' in response && - response.nextStep && - (response.nextStep as any).stepType === EmbeddedSignInFlowStepType.AuthenticatorPrompt && - (response.nextStep as any).authenticators?.length === 1 - ) { - const responseAuthenticator: any = (response.nextStep as any).authenticators[0]; - if ( - responseAuthenticator.metadata?.promptType === EmbeddedSignInFlowAuthenticatorPromptType.RedirectionPrompt && - responseAuthenticator.metadata?.additionalData?.redirectUrl - ) { - /** - * Open a popup window to handle redirection prompts - */ - const redirectUrl: string = responseAuthenticator.metadata?.additionalData?.redirectUrl; - const popup: Window | null = window.open( - redirectUrl, - 'oauth_popup', - 'width=500,height=600,scrollbars=yes,resizable=yes', - ); - - if (!popup) { - logger.error('Failed to open popup window'); - return false; - } - - /** - * Forward declarations for mutually referencing variables. - * `messageHandler`, `cleanup`, and `popupMonitor` reference each other, - * so they are declared with `let` first and assigned below. - */ - let messageHandler: any; - let popupMonitor: any; - - const cleanup = (): void => { - window.removeEventListener('message', messageHandler); - if (popupMonitor) { - clearInterval(popupMonitor); - } - }; - - /** - * Add an event listener to the window to capture the message from the popup - */ - messageHandler = async function messageEventHandler(event: MessageEvent): Promise { - /** - * Check if the message is from our popup window - */ - if (event.source !== popup) { - // Don't log every message rejection to reduce noise - if (event.source !== window && event.source !== window.parent) { - // TODO: Add logs - } - return; - } - - /** - * Check the origin of the message to ensure it's from a trusted source - */ - const expectedOrigin: string = afterSignInUrl ? new URL(afterSignInUrl).origin : window.location.origin; - if (event.origin !== expectedOrigin && event.origin !== window.location.origin) { - return; - } - - const {code, state} = event.data; - - if (code && state) { - const payload: EmbeddedSignInFlowHandleRequestPayload = { - flowId: currentFlow!.flowId, - selectedAuthenticator: { - authenticatorId: responseAuthenticator.authenticatorId, - params: { - code, - state, - }, - }, - }; - - await onSubmit!(payload, { - method: currentFlow?.links[0].method, - url: currentFlow?.links[0].href, - }); - - popup.close(); - cleanup(); - } else { - // TODO: Add logs - } - }; - - window.addEventListener('message', messageHandler); - - /** - * Monitor popup for closure and URL changes - */ - let hasProcessedCallback = false; // Prevent multiple processing - popupMonitor = setInterval(async (): Promise => { - try { - if (popup.closed) { - cleanup(); - - return; - } - - // Skip if we've already processed a callback - if (hasProcessedCallback) { - return; - } - - // Try to access popup URL to check for callback - try { - const popupUrl: string = popup.location.href; - - // Check if we've been redirected to the callback URL - if (popupUrl && (popupUrl.includes('code=') || popupUrl.includes('error='))) { - hasProcessedCallback = true; // Set flag to prevent multiple processing - - // Parse the URL for OAuth parameters - const url: URL = new URL(popupUrl); - const code: string | null = url.searchParams.get('code'); - const state: string | null = url.searchParams.get('state'); - const oauthError: string | null = url.searchParams.get('error'); - - if (oauthError) { - logger.error('OAuth error:'); - popup.close(); - cleanup(); - return; - } - - if (code && state) { - const payload: EmbeddedSignInFlowHandleRequestPayload = { - flowId: currentFlow!.flowId, - selectedAuthenticator: { - authenticatorId: responseAuthenticator.authenticatorId, - params: { - code, - state, - }, - }, - }; - - const submitResponse: any = await onSubmit!(payload, { - method: currentFlow?.links[0].method, - url: currentFlow?.links[0].href, - }); - - popup.close(); - - onFlowChange?.(submitResponse); - - if (submitResponse?.flowStatus === EmbeddedSignInFlowStatus.SuccessCompleted) { - onSuccess?.(submitResponse.authData); - } - } - } - } catch (e) { - // Cross-origin error is expected when popup navigates to OAuth provider - // This is normal and we can ignore it - } - } catch (e) { - logger.error('Error monitoring popup:'); - } - }, 1000); - - return true; - } - } - return false; - }; - - /** - * Handle form submission. - */ - const handleSubmit = async (submittedValues: Record): Promise => { - if (!currentFlow || !currentAuthenticator) { - return; - } - - // Mark all fields as touched before validation - touchAllFields(); - - const validation: any = validateForm(); - if (!validation.isValid) { - return; - } - - setIsSignInInitializationRequestLoading(true); - setError(null); - setMessages([]); - - try { - const payload: EmbeddedSignInFlowHandleRequestPayload = { - flowId: currentFlow.flowId, - selectedAuthenticator: { - authenticatorId: currentAuthenticator.authenticatorId, - params: submittedValues, - }, - }; - - const response: any = await onSubmit!(payload, { - method: currentFlow?.links[0].method, - url: currentFlow?.links[0].href, - }); - onFlowChange?.(response); - - if (response?.flowStatus === EmbeddedSignInFlowStatus.SuccessCompleted) { - onSuccess?.(response.authData); - return; - } - - if ( - response?.flowStatus === EmbeddedSignInFlowStatus.FailCompleted || - response?.flowStatus === EmbeddedSignInFlowStatus.FailIncomplete - ) { - setError(t('errors.signin.flow.completion.failure')); - return; - } - - // Check if the response contains a redirection URL and redirect if needed - if (handleRedirectionIfNeeded(response)) { - return; - } - - if (response && 'flowId' in response && 'nextStep' in response) { - const nextStepResponse: any = response; - setCurrentFlow(nextStepResponse); - - if (nextStepResponse.nextStep?.authenticators?.length > 0) { - if ( - nextStepResponse.nextStep.stepType === EmbeddedSignInFlowStepType.MultiOptionsPrompt && - nextStepResponse.nextStep.authenticators.length > 1 - ) { - setCurrentAuthenticator(null); - } else { - const nextAuthenticator: any = nextStepResponse.nextStep.authenticators[0]; - setCurrentAuthenticator(nextAuthenticator); - setupFormFields(nextAuthenticator); - } - } - - if (nextStepResponse.nextStep?.messages) { - setMessages( - nextStepResponse.nextStep.messages.map((msg: any) => ({ - message: msg.message || '', - type: msg.type || 'INFO', - })), - ); - } - } - } catch (err) { - const errorMessage: string = err instanceof ThunderIDAPIError ? err.message : t('errors.signin.flow.failure'); - setError(errorMessage); - onError?.(err as Error); - } finally { - setIsSignInInitializationRequestLoading(false); - } - }; - - /** - * Handle authenticator selection for multi-option prompts. - */ - const handleAuthenticatorSelection = async ( - authenticator: EmbeddedSignInFlowAuthenticator, - formData?: Record, - ): Promise => { - if (!currentFlow) { - return; - } - - // Mark all fields as touched if we have form data (i.e., this is a submission) - if (formData) { - touchAllFields(); - } - - setIsSignInInitializationRequestLoading(true); - setError(null); - setMessages([]); - - try { - // Handle passkey/FIDO authentication - if (isPasskeyAuthenticator(authenticator)) { - try { - const challengeData: any = (authenticator.metadata as any)?.additionalData?.challengeData; - if (!challengeData) { - throw new Error('Missing challenge data for passkey authentication'); - } - - const tokenResponse: any = await handleWebAuthnAuthentication(challengeData); - - const payload: EmbeddedSignInFlowHandleRequestPayload = { - flowId: currentFlow.flowId, - selectedAuthenticator: { - authenticatorId: authenticator.authenticatorId, - params: { - tokenResponse, - }, - }, - }; - - const response: any = await onSubmit!(payload, { - method: currentFlow?.links[0].method, - url: currentFlow?.links[0].href, - }); - onFlowChange?.(response); - - if (response?.flowStatus === EmbeddedSignInFlowStatus.SuccessCompleted) { - onSuccess?.(response.authData); - return; - } - - if ( - response?.flowStatus === EmbeddedSignInFlowStatus.FailCompleted || - response?.flowStatus === EmbeddedSignInFlowStatus.FailIncomplete - ) { - setError(t('errors.signin.flow.passkeys.completion.failure')); - return; - } - - // Handle next step if authentication is not complete - if (response && 'flowId' in response && 'nextStep' in response) { - const nextStepResponse: any = response; - setCurrentFlow(nextStepResponse); - - if (nextStepResponse.nextStep?.authenticators?.length > 0) { - if ( - nextStepResponse.nextStep.stepType === EmbeddedSignInFlowStepType.MultiOptionsPrompt && - nextStepResponse.nextStep.authenticators.length > 1 - ) { - setCurrentAuthenticator(null); - } else { - const nextAuthenticator: any = nextStepResponse.nextStep.authenticators[0]; - - // Check if the next authenticator is also a passkey - if so, auto-trigger it - if (isPasskeyAuthenticator(nextAuthenticator)) { - // Recursively handle the passkey authenticator without showing UI - handleAuthenticatorSelection(nextAuthenticator); - return; - } - setCurrentAuthenticator(nextAuthenticator); - setupFormFields(nextAuthenticator); - } - } - - if (nextStepResponse.nextStep?.messages) { - setMessages( - nextStepResponse.nextStep.messages.map((msg: any) => ({ - message: msg.message || '', - type: msg.type || 'INFO', - })), - ); - } - } - } catch (passkeyError) { - logger.error('Passkey authentication error:'); - - // Provide more context for common errors - let errorMessage: string = - passkeyError instanceof Error ? passkeyError.message : t('errors.signin.flow.passkeys.failure'); - - // Add additional context for security errors - if (passkeyError instanceof Error && passkeyError.message.includes('security')) { - errorMessage += - ' This may be due to browser security settings, an insecure connection, or device restrictions.'; - } - - setError(errorMessage); - } - } else if (authenticator.metadata?.promptType === EmbeddedSignInFlowAuthenticatorPromptType.RedirectionPrompt) { - const payload: EmbeddedSignInFlowHandleRequestPayload = { - flowId: currentFlow.flowId, - selectedAuthenticator: { - authenticatorId: authenticator.authenticatorId, - params: {}, - }, - }; - - const response: any = await onSubmit!(payload, { - method: currentFlow?.links[0].method, - url: currentFlow?.links[0].href, - }); - onFlowChange?.(response); - - if (response?.flowStatus === EmbeddedSignInFlowStatus.SuccessCompleted) { - onSuccess?.(response.authData); - return; - } - - // Check if the response contains a redirection URL and redirect if needed - if (handleRedirectionIfNeeded(response)) { - /* empty - redirect handled */ - } - } else if (formData) { - const validation: any = validateForm(); - if (!validation.isValid) { - return; - } - - const formPayload: EmbeddedSignInFlowHandleRequestPayload = { - flowId: currentFlow.flowId, - selectedAuthenticator: { - authenticatorId: authenticator.authenticatorId, - params: formData, - }, - }; - - const formResponse: any = await onSubmit!(formPayload, { - method: currentFlow?.links[0].method, - url: currentFlow?.links[0].href, - }); - onFlowChange?.(formResponse); - - if (formResponse?.flowStatus === EmbeddedSignInFlowStatus.SuccessCompleted) { - onSuccess?.(formResponse.authData); - return; - } - - if ( - formResponse?.flowStatus === EmbeddedSignInFlowStatus.FailCompleted || - formResponse?.flowStatus === EmbeddedSignInFlowStatus.FailIncomplete - ) { - setError('Authentication failed. Please check your credentials and try again.'); - return; - } - - // Check if the response contains a redirection URL and redirect if needed - if (handleRedirectionIfNeeded(formResponse)) { - return; - } - - if (formResponse && 'flowId' in formResponse && 'nextStep' in formResponse) { - const nextStepResponse: any = formResponse; - setCurrentFlow(nextStepResponse); - - if (nextStepResponse.nextStep?.authenticators?.length > 0) { - if ( - nextStepResponse.nextStep.stepType === EmbeddedSignInFlowStepType.MultiOptionsPrompt && - nextStepResponse.nextStep.authenticators.length > 1 - ) { - setCurrentAuthenticator(null); - } else { - const nextAuthenticator: any = nextStepResponse.nextStep.authenticators[0]; - - // Check if the next authenticator is a passkey - if so, auto-trigger it - if (isPasskeyAuthenticator(nextAuthenticator)) { - // Recursively handle the passkey authenticator without showing UI - handleAuthenticatorSelection(nextAuthenticator); - return; - } - setCurrentAuthenticator(nextAuthenticator); - setupFormFields(nextAuthenticator); - } - } - - if (nextStepResponse.nextStep?.messages) { - setMessages( - nextStepResponse.nextStep.messages.map((msg: any) => ({ - message: msg.message || '', - type: msg.type || 'INFO', - })), - ); - } - } - } else { - // Check if the authenticator requires user input - const hasParams: boolean = authenticator.metadata?.params && authenticator.metadata.params.length > 0; - - if (!hasParams) { - // If no parameters are required, directly authenticate - const payload: EmbeddedSignInFlowHandleRequestPayload = { - flowId: currentFlow.flowId, - selectedAuthenticator: { - authenticatorId: authenticator.authenticatorId, - params: {}, - }, - }; - - const response: any = await onSubmit!(payload, { - method: currentFlow?.links[0].method, - url: currentFlow?.links[0].href, - }); - onFlowChange?.(response); - - if (response?.flowStatus === EmbeddedSignInFlowStatus.SuccessCompleted) { - onSuccess?.(response.authData); - return; - } - - if ( - response?.flowStatus === EmbeddedSignInFlowStatus.FailCompleted || - response?.flowStatus === EmbeddedSignInFlowStatus.FailIncomplete - ) { - setError('Authentication failed. Please try again.'); - return; - } - - // Check if the response contains a redirection URL and redirect if needed - if (handleRedirectionIfNeeded(response)) { - return; - } - - if (response && 'flowId' in response && 'nextStep' in response) { - const nextStepResponse: any = response; - setCurrentFlow(nextStepResponse); - - if (nextStepResponse.nextStep?.authenticators?.length > 0) { - if ( - nextStepResponse.nextStep.stepType === EmbeddedSignInFlowStepType.MultiOptionsPrompt && - nextStepResponse.nextStep.authenticators.length > 1 - ) { - setCurrentAuthenticator(null); - } else { - const nextAuthenticator: any = nextStepResponse.nextStep.authenticators[0]; - - // Check if the next authenticator is a passkey - if so, auto-trigger it - if (isPasskeyAuthenticator(nextAuthenticator)) { - // Recursively handle the passkey authenticator without showing UI - handleAuthenticatorSelection(nextAuthenticator); - return; - } - setCurrentAuthenticator(nextAuthenticator); - setupFormFields(nextAuthenticator); - } - } - - if (nextStepResponse.nextStep?.messages) { - setMessages( - nextStepResponse.nextStep.messages.map((msg: any) => ({ - message: msg.message || '', - type: msg.type || 'INFO', - })), - ); - } - } - } else { - // If parameters are required, show the form - setCurrentAuthenticator(authenticator); - setupFormFields(authenticator); - } - } - } catch (err) { - const errorMessage: string = err instanceof ThunderIDAPIError ? err?.message : 'Authenticator selection failed'; - setError(errorMessage); - onError?.(err as Error); - } finally { - setIsSignInInitializationRequestLoading(false); - } - }; - - /** - * Handle input value changes. - */ - const handleInputChange = (param: string, value: string): void => { - setFormValue(param, value); - setFormTouched(param, true); - }; - - /** - * Check if current flow has multiple authenticator options. - */ - const hasMultipleOptions: any = useCallback( - (): boolean => - !!( - currentFlow && - 'nextStep' in currentFlow && - currentFlow.nextStep?.stepType === EmbeddedSignInFlowStepType.MultiOptionsPrompt && - currentFlow.nextStep?.authenticators && - currentFlow.nextStep.authenticators.length > 1 - ), - [currentFlow], - ); - - /** - * Get available authenticators for selection. - */ - const getAvailableAuthenticators: any = useCallback((): EmbeddedSignInFlowAuthenticator[] => { - if (!currentFlow || !('nextStep' in currentFlow) || !currentFlow.nextStep?.authenticators) { - return []; - } - return currentFlow.nextStep.authenticators; - }, [currentFlow]); - - // Generate CSS classes - const containerClasses: string = cx( - [ - withVendorCSSClassPrefix('signin'), - withVendorCSSClassPrefix(`signin--${size}`), - withVendorCSSClassPrefix(`signin--${variant}`), - ], - className, - ); - - const inputClasses: string = cx( - [ - withVendorCSSClassPrefix('signin__input'), - size === 'small' && withVendorCSSClassPrefix('signin__input--small'), - size === 'large' && withVendorCSSClassPrefix('signin__input--large'), - ], - inputClassName, - ); - - const buttonClasses: string = cx( - [ - withVendorCSSClassPrefix('signin__button'), - size === 'small' && withVendorCSSClassPrefix('signin__button--small'), - size === 'large' && withVendorCSSClassPrefix('signin__button--large'), - ], - buttonClassName, - ); - - const errorClasses: string = cx([withVendorCSSClassPrefix('signin__error')], errorClassName); - - const messageClasses: string = cx([withVendorCSSClassPrefix('signin__messages')], messageClassName); // Initialize the flow on component mount - - useEffect(() => { - if (isLoading) { - return; - } - - // React 18.x Strict.Mode has a new check for `Ensuring reusable state` to facilitate an upcoming react feature. - // https://reactjs.org/docs/strict-mode.html#ensuring-reusable-state - // This will remount all the useEffects to ensure that there are no unexpected side effects. - // When react remounts the SignIn, it will send two authorize requests. - // https://github.com/reactwg/react-18/discussions/18#discussioncomment-795623 - if (reRenderCheckRef.current) { - return; - } - - reRenderCheckRef.current = true; - - (async (): Promise => { - setIsSignInInitializationRequestLoading(true); - setError(null); - - try { - const response: any = await onInitialize?.(); - - setCurrentFlow(response); - setIsInitialized(true); - onFlowChange?.(response); - - if (response?.flowStatus === EmbeddedSignInFlowStatus.SuccessCompleted) { - onSuccess?.(response.authData || {}); - return; - } - - if (response?.nextStep?.authenticators?.length > 0) { - if ( - response.nextStep.stepType === EmbeddedSignInFlowStepType.MultiOptionsPrompt && - response.nextStep.authenticators.length > 1 - ) { - setCurrentAuthenticator(null); - } else { - const authenticator: any = response.nextStep.authenticators[0]; - setCurrentAuthenticator(authenticator); - setupFormFields(authenticator); - } - } - - if (response && 'nextStep' in response && response.nextStep && 'messages' in response.nextStep) { - const stepMessages: any[] = response.nextStep.messages || []; - setMessages( - stepMessages.map((msg: any) => ({ - message: msg.message || '', - type: msg.type || 'INFO', - })), - ); - } - } catch (err) { - const errorMessage: string = err instanceof ThunderIDAPIError ? err.message : t('errors.signin.initialization'); - setError(errorMessage); - onError?.(err as Error); - } finally { - setIsSignInInitializationRequestLoading(false); - } - })(); - }, [isLoading]); - - if (!isInitialized && isLoading) { - return ( - - -
- - - {t('messages.loading.placeholder')} - -
-
-
- ); - } - - if (hasMultipleOptions() && !currentAuthenticator) { - const availableAuthenticators: EmbeddedSignInFlowAuthenticator[] = getAvailableAuthenticators(); - - const userPromptAuthenticators: any[] = availableAuthenticators.filter( - (auth: any) => - auth.metadata?.promptType === EmbeddedSignInFlowAuthenticatorPromptType.UserPrompt || - // Fallback: LOCAL authenticators with params are typically user prompts - (auth.idp === 'LOCAL' && auth.metadata?.params && auth.metadata.params.length > 0), - ); - - const optionAuthenticators: any[] = availableAuthenticators - .filter((auth: any) => !userPromptAuthenticators.includes(auth)) - .filter((authenticator: any) => !HIDDEN_AUTHENTICATORS.includes(authenticator.authenticatorId)); - - return ( - - {(showTitle || showSubtitle) && ( - - {showTitle && ( - - {flowTitle || t('signin.heading')} - - )} - {showSubtitle && ( - - {flowSubtitle || t('signin.subheading')} - - )} - - )} - - {flowMessages && flowMessages.length > 0 && ( -
- {flowMessages.map((flowMessage: any, index: number) => ( - - {flowMessage.message} - - ))} -
- )} - {messages.length > 0 && ( -
- {messages.map((message: any, index: number) => { - let messageVariant: AlertVariant; - const lowerType: string = message.type.toLowerCase(); - if (lowerType === 'error') { - messageVariant = 'error'; - } else if (lowerType === 'warning') { - messageVariant = 'warning'; - } else if (lowerType === 'success') { - messageVariant = 'success'; - } else { - messageVariant = 'info'; - } - - return ( - - {message.message} - - ); - })} -
- )} - {error && ( - - Error - {error} - - )} - -
- {/* Render USER_PROMPT authenticators as form fields */} - {userPromptAuthenticators.map((authenticator: any, index: number) => ( -
- {index > 0 && OR} -
{ - e.preventDefault(); - const formData: Record = {}; - authenticator.metadata?.params?.forEach((param: any) => { - formData[param.param] = formValues[param.param] || ''; - }); - handleAuthenticatorSelection(authenticator, formData); - }} - > - {createSignInOptionFromAuthenticator( - authenticator, - formValues, - touchedFields, - isLoading, - handleInputChange, - (auth: any, formData: any) => handleAuthenticatorSelection(auth, formData), - { - buttonClassName: buttonClasses, - error, - inputClassName: inputClasses, - }, - )} - -
- ))} - - {/* Add divider between user prompts and option authenticators if both exist */} - {userPromptAuthenticators.length > 0 && optionAuthenticators.length > 0 && ( - OR - )} - - {/* Render all other authenticators (REDIRECTION_PROMPT, multi-option buttons, etc.) */} - {optionAuthenticators.map((authenticator: any) => ( -
- {createSignInOptionFromAuthenticator( - authenticator, - formValues, - touchedFields, - isLoading, - handleInputChange, - (auth: any, formData: any) => handleAuthenticatorSelection(auth, formData), - { - buttonClassName: buttonClasses, - error, - inputClassName: inputClasses, - }, - )} -
- ))} -
-
-
- ); - } - - if (!currentAuthenticator) { - return ( - - - {error && ( - - {t('errors.heading') || 'Error'} - {error} - - )} - - - ); - } - - // If the current authenticator is a passkey, auto-trigger it instead of showing a form - if (isPasskeyAuthenticator(currentAuthenticator) && !isLoading) { - // Auto-trigger passkey authentication - useEffect(() => { - handleAuthenticatorSelection(currentAuthenticator); - }, [currentAuthenticator]); - - // Show loading state while passkey authentication is in progress - return ( - - -
-
- -
- {t('passkey.authenticating') || 'Authenticating with passkey...'} - - {t('passkey.instruction') || 'Please use your fingerprint, face, or security key to authenticate.'} - -
-
-
- ); - } - - return ( - - - - {flowTitle || t('signin.heading')} - - - {flowSubtitle || t('signin.subheading')} - - {flowMessages && flowMessages.length > 0 && ( -
- {flowMessages.map((flowMessage: any, index: number) => ( - - {flowMessage.message} - - ))} -
- )} - {messages.length > 0 && ( -
- {messages.map((message: any, index: number) => { - const messageTypeToVariant: Record = { - error: 'error', - success: 'success', - warning: 'warning', - }; - const alertVariant: AlertVariant = messageTypeToVariant[message.type.toLowerCase()] || 'info'; - - return ( - - {message.message} - - ); - })} -
- )} -
- - - {error && ( - - {t('errors.heading')} - {error} - - )} - -
): void => { - e.preventDefault(); - const formData: Record = {}; - currentAuthenticator.metadata?.params?.forEach((param: any) => { - formData[param.param] = formValues[param.param] || ''; - }); - handleSubmit(formData); - }} - > - {createSignInOptionFromAuthenticator( - currentAuthenticator, - formValues, - touchedFields, - isLoading, - handleInputChange, - (authenticator: any, formData: any) => handleSubmit(formData || formValues), - { - buttonClassName: buttonClasses, - error, - inputClassName: inputClasses, - }, - )} - -
-
- ); -}; - -/** - * Base SignIn component that provides native authentication flow. - * This component handles both the presentation layer and authentication flow logic. - * It accepts API functions as props to maintain framework independence. - * - * @example - * ```tsx - * import { BaseSignIn } from '@thunderid/react'; - * - * const MySignIn = () => { - * return ( - * { - * // Your API call to initialize authentication - * return await initializeAuth(); - * }} - * onSubmit={async (payload) => { - * // Your API call to handle authentication - * return await handleAuth(payload); - * }} - * onSuccess={(authData) => { - * console.log('Success:', authData); - * }} - * onError={(error) => { - * console.error('Error:', error); - * }} - * className="max-w-md mx-auto" - * /> - * ); - * }; - * ``` - */ -const BaseSignIn: FC = ({showLogo = true, ...rest}: BaseSignInProps): ReactElement => { - const {theme} = useTheme(); - const styles: ReturnType = useStyles(theme, theme.vars.colors.text.primary); - - return ( -
- {showLogo && ( -
- -
- )} - - - -
- ); -}; - -export default BaseSignIn; diff --git a/packages/react/src/components/presentation/auth/SignIn/v1/options/EmailOtp.tsx b/packages/react/src/components/presentation/auth/SignIn/v1/options/EmailOtp.tsx deleted file mode 100644 index b3898b0..0000000 --- a/packages/react/src/components/presentation/auth/SignIn/v1/options/EmailOtp.tsx +++ /dev/null @@ -1,111 +0,0 @@ -/** - * 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 {EmbeddedSignInFlowAuthenticatorParamType, FieldType} from '@thunderid/browser'; -import {FC, ReactElement, useEffect} from 'react'; -// eslint-disable-next-line import/no-cycle -import {BaseSignInOptionProps} from './SignInOptionFactory'; -import useFlow from '../../../../../../contexts/Flow/useFlow'; -import useTheme from '../../../../../../contexts/Theme/useTheme'; -import useTranslation from '../../../../../../hooks/useTranslation'; -import {createField} from '../../../../../factories/FieldFactory'; -import Button from '../../../../../primitives/Button/Button'; -import OtpField from '../../../../../primitives/OtpField/OtpField'; - -/** - * Email OTP Sign-In Option Component. - * Handles email-based OTP authentication. - */ -const EmailOtp: FC = ({ - authenticator, - formValues, - touchedFields, - isLoading, - onInputChange, - inputClassName = '', - buttonClassName = '', - preferences, -}: BaseSignInOptionProps): ReactElement => { - const {theme} = useTheme(); - const {t} = useTranslation(preferences?.i18n); - const {setTitle, setSubtitle} = useFlow(); - - const formFields: any = authenticator!.metadata?.params?.sort((a: any, b: any) => a.order - b.order) || []; - - useEffect(() => { - setTitle(t('email.otp.heading')); - setSubtitle(t('email.otp.subheading')); - }, [setTitle, setSubtitle, t]); - - // Check if this is an OTP field (typically has 'otpCode' or similar parameter) - const hasOtpField: any = formFields.some( - (param: any) => param.param.toLowerCase().includes('otp') || param.param.toLowerCase().includes('code'), - ); - - return ( - <> - {formFields.map((param: any) => { - const isOtpParam: any = param.param.toLowerCase().includes('otp') || param.param.toLowerCase().includes('code'); - - return ( -
- {isOtpParam && hasOtpField ? ( - onInputChange(param.param, event.target.value)} - disabled={isLoading} - className={inputClassName} - /> - ) : ( - createField({ - className: inputClassName, - disabled: isLoading, - label: param.displayName, - name: param.param, - onChange: (value: any) => onInputChange(param.param, value), - required: authenticator!.requiredParams.includes(param.param), - touched: touchedFields[param.param] || false, - type: - param.type === EmbeddedSignInFlowAuthenticatorParamType.String && param.confidential - ? FieldType.Password - : FieldType.Text, - value: formValues[param.param] || '', - }) - )} -
- ); - })} - - - - ); -}; - -export default EmailOtp; diff --git a/packages/react/src/components/presentation/auth/SignIn/v1/options/IdentifierFirst.tsx b/packages/react/src/components/presentation/auth/SignIn/v1/options/IdentifierFirst.tsx deleted file mode 100644 index 174118a..0000000 --- a/packages/react/src/components/presentation/auth/SignIn/v1/options/IdentifierFirst.tsx +++ /dev/null @@ -1,94 +0,0 @@ -/** - * 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 {EmbeddedSignInFlowAuthenticatorParamType, FieldType} from '@thunderid/browser'; -import {FC, ReactElement, useEffect} from 'react'; -// eslint-disable-next-line import/no-cycle -import {BaseSignInOptionProps} from './SignInOptionFactory'; -import useFlow from '../../../../../../contexts/Flow/useFlow'; -import useTheme from '../../../../../../contexts/Theme/useTheme'; -import useTranslation from '../../../../../../hooks/useTranslation'; -import {createField} from '../../../../../factories/FieldFactory'; -import Button from '../../../../../primitives/Button/Button'; - -/** - * Identifier First Sign-In Option Component. - * Handles identifier-first authentication flow (username first, then password). - */ -const IdentifierFirst: FC = ({ - authenticator, - formValues, - touchedFields, - isLoading, - onInputChange, - inputClassName = '', - buttonClassName = '', - preferences, -}: BaseSignInOptionProps): ReactElement => { - const {theme} = useTheme(); - const {t} = useTranslation(preferences?.i18n); - const {setTitle, setSubtitle} = useFlow(); - - const formFields: any = authenticator!.metadata?.params?.sort((a: any, b: any) => a.order - b.order) || []; - - useEffect(() => { - setTitle(t('identifier.first.heading')); - setSubtitle(t('identifier.first.subheading')); - }, [setTitle, setSubtitle, t]); - - return ( - <> - {formFields.map((param: any) => ( -
- {createField({ - className: inputClassName, - disabled: isLoading, - label: param.displayName, - name: param.param, - onChange: (value: any) => onInputChange(param.param, value), - placeholder: t(`elements.fields.generic.placeholder`, { - field: (param.displayName || param.param).toLowerCase(), - }), - required: authenticator!.requiredParams.includes(param.param), - touched: touchedFields[param.param] || false, - type: - param.type === EmbeddedSignInFlowAuthenticatorParamType.String && param.confidential - ? FieldType.Password - : FieldType.Text, - value: formValues[param.param] || '', - })} -
- ))} - - - - ); -}; - -export default IdentifierFirst; diff --git a/packages/react/src/components/presentation/auth/SignIn/v1/options/MultiOptionButton.tsx b/packages/react/src/components/presentation/auth/SignIn/v1/options/MultiOptionButton.tsx deleted file mode 100644 index 241b5fc..0000000 --- a/packages/react/src/components/presentation/auth/SignIn/v1/options/MultiOptionButton.tsx +++ /dev/null @@ -1,155 +0,0 @@ -/** - * 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 { - ApplicationNativeAuthenticationConstants, - EmbeddedSignInFlowAuthenticatorKnownIdPType, -} from '@thunderid/browser'; -import {FC, ReactElement} from 'react'; -// eslint-disable-next-line import/no-cycle -import {BaseSignInOptionProps} from './SignInOptionFactory'; -import useTranslation from '../../../../../../hooks/useTranslation'; -import Button from '../../../../../primitives/Button/Button'; - -/** - * Multi Option Button Component. - * Renders authenticators as selectable buttons for multi-option prompts. - * Used when authenticators don't require immediate user input but need to be selected first. - */ -const MultiOptionButton: FC = ({ - authenticator, - isLoading, - onSubmit, - buttonClassName = '', - preferences, -}: BaseSignInOptionProps): ReactElement => { - const {t} = useTranslation(preferences?.i18n); - - /** - * Get display name for the authenticator. - */ - const getDisplayName = (): string => { - let authenticatorName: any = authenticator!.authenticator; - - if (authenticator!.idp !== EmbeddedSignInFlowAuthenticatorKnownIdPType.Local) { - authenticatorName = authenticator!.idp; - } - - switch (authenticatorName) { - default: - return t('elements.buttons.multi.option.text', {connection: authenticatorName}); - } - }; - - /** - * Get appropriate icon for the authenticator type. - */ - const getIcon = (): ReactElement | null => { - const {authenticatorId} = authenticator!; - - switch (authenticatorId) { - case ApplicationNativeAuthenticationConstants.SupportedAuthenticators.SmsOtp: - return ( - - - - ); - case ApplicationNativeAuthenticationConstants.SupportedAuthenticators.EmailOtp: - return ( - - - - ); - case ApplicationNativeAuthenticationConstants.SupportedAuthenticators.Totp: - return ( - - - - ); - case ApplicationNativeAuthenticationConstants.SupportedAuthenticators.PushNotification: - return ( - - - - ); - case ApplicationNativeAuthenticationConstants.SupportedAuthenticators.Passkey: - return ( - - - - - {' '} - - - ); - case ApplicationNativeAuthenticationConstants.SupportedAuthenticators.MagicLink: - return ( - - - - ); - default: - return ( - - - - ); - } - }; - - /** - * Handle button click. - */ - const handleClick = (): any => { - // For multi-option buttons, we call onSubmit without form data - // This will trigger the authenticator selection and likely move to the next step - onSubmit!(authenticator!); - }; - - return ( - - ); -}; - -export default MultiOptionButton; diff --git a/packages/react/src/components/presentation/auth/SignIn/v1/options/SignInOptionFactory.tsx b/packages/react/src/components/presentation/auth/SignIn/v1/options/SignInOptionFactory.tsx deleted file mode 100644 index 09fb067..0000000 --- a/packages/react/src/components/presentation/auth/SignIn/v1/options/SignInOptionFactory.tsx +++ /dev/null @@ -1,262 +0,0 @@ -/** - * 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 { - EmbeddedSignInFlowAuthenticator, - EmbeddedSignInFlowAuthenticatorKnownIdPType, - ApplicationNativeAuthenticationConstants, - WithPreferences, -} from '@thunderid/browser'; -import {ReactElement} from 'react'; -// eslint-disable-next-line import/no-cycle, import/no-named-as-default -import EmailOtp from './EmailOtp'; -// eslint-disable-next-line import/no-cycle, import/no-named-as-default -import IdentifierFirst from './IdentifierFirst'; -// eslint-disable-next-line import/no-cycle -import MultiOptionButton from './MultiOptionButton'; -// eslint-disable-next-line import/no-cycle, import/no-named-as-default -import SmsOtp from './SmsOtp'; -// eslint-disable-next-line import/no-cycle -import SocialButton from './SocialButton'; -// eslint-disable-next-line import/no-cycle, import/no-named-as-default -import Totp from './Totp'; -// eslint-disable-next-line import/no-cycle, import/no-named-as-default -import UsernamePassword from './UsernamePassword'; -import FacebookButton from '../../../../../adapters/FacebookButton'; -import GitHubButton from '../../../../../adapters/GitHubButton'; -import GoogleButton from '../../../../../adapters/GoogleButton'; -import LinkedInButton from '../../../../../adapters/LinkedInButton'; -import MicrosoftButton from '../../../../../adapters/MicrosoftButton'; -import SignInWithEthereumButton from '../../../../../adapters/SignInWithEthereumButton'; - -/** - * Base props that all sign-in option components share. - */ -export interface BaseSignInOptionProps extends WithPreferences { - /** - * The authenticator configuration. - */ - authenticator?: EmbeddedSignInFlowAuthenticator; - - /** - * Custom CSS class name for the submit button. - */ - buttonClassName?: string; - - /** - * Error message to display. - */ - error?: string | null; - - /** - * Current form values. - */ - formValues: Record; - - /** - * Custom CSS class name for form inputs. - */ - inputClassName?: string; - - /** - * Whether the component is in loading state. - */ - isLoading: boolean; - - /** - * Callback function called when input values change. - */ - onInputChange: (param: string, value: string) => void; - - /** - * Callback function called when the option is submitted. - */ - onSubmit?: (authenticator: EmbeddedSignInFlowAuthenticator, formData?: Record) => void; - - /** - * Text for the submit button. - */ - submitButtonText?: string; - - /** - * Touched state for form fields. - */ - touchedFields: Record; -} - -/** - * Creates the appropriate sign-in option component based on the authenticator's ID. - */ -export const createSignInOption = ({ - authenticator, - onSubmit, - buttonClassName, - preferences, - ...rest -}: BaseSignInOptionProps): ReactElement => { - // Check if this authenticator has params (indicating it needs user input) - const hasParams: any = authenticator!.metadata?.params && authenticator!.metadata.params.length > 0; - - // Use authenticatorId to determine the component type - switch (authenticator!.authenticatorId) { - case ApplicationNativeAuthenticationConstants.SupportedAuthenticators.UsernamePassword: - return ; - - case ApplicationNativeAuthenticationConstants.SupportedAuthenticators.IdentifierFirst: - return ; - - case ApplicationNativeAuthenticationConstants.SupportedAuthenticators.Google: - return ( - onSubmit!(authenticator!)} - preferences={preferences} - {...rest} - /> - ); - - case ApplicationNativeAuthenticationConstants.SupportedAuthenticators.GitHub: - return ( - onSubmit!(authenticator!)} - {...rest} - /> - ); - - case ApplicationNativeAuthenticationConstants.SupportedAuthenticators.Microsoft: - return ( - onSubmit!(authenticator!)} - {...rest} - /> - ); - - case ApplicationNativeAuthenticationConstants.SupportedAuthenticators.Facebook: - return ( - onSubmit!(authenticator!)} - {...rest} - /> - ); - - case ApplicationNativeAuthenticationConstants.SupportedAuthenticators.LinkedIn: - return ( - onSubmit!(authenticator!)} - {...rest} - /> - ); - - case ApplicationNativeAuthenticationConstants.SupportedAuthenticators.SignInWithEthereum: - return ( - onSubmit!(authenticator!)} - {...rest} - /> - ); - - case ApplicationNativeAuthenticationConstants.SupportedAuthenticators.EmailOtp: - // If it has params, render as input form, otherwise as selection button - return hasParams ? ( - - ) : ( - - ); - - case ApplicationNativeAuthenticationConstants.SupportedAuthenticators.Totp: - // If it has params, render as input form, otherwise as selection button - return hasParams ? ( - - ) : ( - - ); - - case ApplicationNativeAuthenticationConstants.SupportedAuthenticators.SmsOtp: - // If it has params, render as input form, otherwise as selection button - return hasParams ? ( - - ) : ( - - ); - - default: - // Check if it's a federated authenticator (non-LOCAL idp) - if (authenticator!.idp !== EmbeddedSignInFlowAuthenticatorKnownIdPType.Local) { - // For unknown federated authenticators, use generic social login - return ( - onSubmit!(authenticator!)} - {...rest} - > - {authenticator!.idp} - - ); - } - - // For LOCAL authenticators, decide based on whether they have params - if (hasParams) { - // Fallback to username/password for unknown local authenticators with params - return ( - - ); - } - // Use multi-option button for LOCAL authenticators without params - return ( - - ); - } -}; - -/** - * Convenience function that creates the appropriate sign-in option component from an authenticator. - */ -export const createSignInOptionFromAuthenticator = ( - authenticator: EmbeddedSignInFlowAuthenticator, - formValues: Record, - touchedFields: Record, - isLoading: boolean, - onInputChange: (param: string, value: string) => void, - onSubmit: (authenticator: EmbeddedSignInFlowAuthenticator, formData?: Record) => void, - options?: { - buttonClassName?: string; - error?: string | null; - inputClassName?: string; - }, -): ReactElement => - createSignInOption({ - authenticator, - formValues, - isLoading, - onInputChange, - onSubmit, - touchedFields, - ...options, - }); diff --git a/packages/react/src/components/presentation/auth/SignIn/v1/options/SmsOtp.tsx b/packages/react/src/components/presentation/auth/SignIn/v1/options/SmsOtp.tsx deleted file mode 100644 index e6f23cd..0000000 --- a/packages/react/src/components/presentation/auth/SignIn/v1/options/SmsOtp.tsx +++ /dev/null @@ -1,110 +0,0 @@ -/** - * 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 {EmbeddedSignInFlowAuthenticatorParamType, FieldType} from '@thunderid/browser'; -import {FC, ReactElement, useEffect} from 'react'; -// eslint-disable-next-line import/no-cycle -import {BaseSignInOptionProps} from './SignInOptionFactory'; -import useFlow from '../../../../../../contexts/Flow/useFlow'; -import useTheme from '../../../../../../contexts/Theme/useTheme'; -import useTranslation from '../../../../../../hooks/useTranslation'; -import {createField} from '../../../../../factories/FieldFactory'; -import Button from '../../../../../primitives/Button/Button'; -import OtpField from '../../../../../primitives/OtpField/OtpField'; - -/** - * SMS OTP Sign-In Option Component. - * Handles SMS-based OTP authentication. - */ -const SmsOtp: FC = ({ - authenticator, - formValues, - touchedFields, - isLoading, - onInputChange, - inputClassName = '', - buttonClassName = '', - preferences, -}: BaseSignInOptionProps): ReactElement => { - const {theme} = useTheme(); - const {t} = useTranslation(preferences?.i18n); - const {setTitle, setSubtitle} = useFlow(); - - const formFields: any = authenticator!.metadata?.params?.sort((a: any, b: any) => a.order - b.order) || []; - - useEffect(() => { - setTitle(t('sms.otp.heading')); - setSubtitle(t('sms.otp.subheading')); - }, [setTitle, setSubtitle, t]); - - const hasOtpField: any = formFields.some( - (param: any) => param.param.toLowerCase().includes('otp') || param.param.toLowerCase().includes('code'), - ); - - return ( - <> - {formFields.map((param: any) => { - const isOtpParam: any = param.param.toLowerCase().includes('otp') || param.param.toLowerCase().includes('code'); - - return ( -
- {isOtpParam && hasOtpField ? ( - onInputChange(param.param, event.target.value)} - disabled={isLoading} - className={inputClassName} - /> - ) : ( - createField({ - className: inputClassName, - disabled: isLoading, - label: param.displayName, - name: param.param, - onChange: (value: any) => onInputChange(param.param, value), - required: authenticator!.requiredParams.includes(param.param), - touched: touchedFields[param.param] || false, - type: - param.type === EmbeddedSignInFlowAuthenticatorParamType.String && param.confidential - ? FieldType.Password - : FieldType.Text, - value: formValues[param.param] || '', - }) - )} -
- ); - })} - - - - ); -}; - -export default SmsOtp; diff --git a/packages/react/src/components/presentation/auth/SignIn/v1/options/SocialButton.tsx b/packages/react/src/components/presentation/auth/SignIn/v1/options/SocialButton.tsx deleted file mode 100644 index b42a3fb..0000000 --- a/packages/react/src/components/presentation/auth/SignIn/v1/options/SocialButton.tsx +++ /dev/null @@ -1,58 +0,0 @@ -/** - * 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, HTMLAttributes, ReactElement} from 'react'; -// eslint-disable-next-line import/no-cycle -import {BaseSignInOptionProps} from './SignInOptionFactory'; -import useTranslation from '../../../../../../hooks/useTranslation'; -import Button from '../../../../../primitives/Button/Button'; - -/** - * Social Login Sign-In Option Component. - * Handles authentication with external identity providers (Google, GitHub, etc.). - */ -const SocialLogin: FC> = ({ - isLoading, - preferences, - children, - ...rest -}: BaseSignInOptionProps & HTMLAttributes): ReactElement => { - const {t} = useTranslation(preferences?.i18n); - return ( - - ); -}; - -export default SocialLogin; diff --git a/packages/react/src/components/presentation/auth/SignIn/v1/options/Totp.tsx b/packages/react/src/components/presentation/auth/SignIn/v1/options/Totp.tsx deleted file mode 100644 index 2f1d54c..0000000 --- a/packages/react/src/components/presentation/auth/SignIn/v1/options/Totp.tsx +++ /dev/null @@ -1,111 +0,0 @@ -/** - * 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 {EmbeddedSignInFlowAuthenticatorParamType, FieldType} from '@thunderid/browser'; -import {FC, ReactElement, useEffect} from 'react'; -// eslint-disable-next-line import/no-cycle -import {BaseSignInOptionProps} from './SignInOptionFactory'; -import useFlow from '../../../../../../contexts/Flow/useFlow'; -import useTheme from '../../../../../../contexts/Theme/useTheme'; -import useTranslation from '../../../../../../hooks/useTranslation'; -import {createField} from '../../../../../factories/FieldFactory'; -import Button from '../../../../../primitives/Button/Button'; -import OtpField from '../../../../../primitives/OtpField/OtpField'; - -/** - * TOTP Sign-In Option Component. - * Handles Time-based One-Time Password (TOTP) authentication. - */ -const Totp: FC = ({ - authenticator, - formValues, - touchedFields, - isLoading, - onInputChange, - inputClassName = '', - buttonClassName = '', - preferences, -}: BaseSignInOptionProps): ReactElement => { - const {theme} = useTheme(); - const {t} = useTranslation(preferences?.i18n); - const {setTitle, setSubtitle} = useFlow(); - - const formFields: any = authenticator!.metadata?.params?.sort((a: any, b: any) => a.order - b.order) || []; - - useEffect(() => { - setTitle(t('totp.heading')); - setSubtitle(t('totp.subheading')); - }, [setTitle, setSubtitle, t]); - - const hasTotpField: any = formFields.some( - (param: any) => param.param.toLowerCase().includes('totp') || param.param.toLowerCase().includes('token'), - ); - - return ( - <> - {formFields.map((param: any) => { - const isTotpParam: any = - param.param.toLowerCase().includes('totp') || param.param.toLowerCase().includes('token'); - - return ( -
- {isTotpParam && hasTotpField ? ( - onInputChange(param.param, event.target.value)} - disabled={isLoading} - className={inputClassName} - /> - ) : ( - createField({ - className: inputClassName, - disabled: isLoading, - label: param.displayName, - name: param.param, - onChange: (value: any) => onInputChange(param.param, value), - required: authenticator!.requiredParams.includes(param.param), - touched: touchedFields[param.param] || false, - type: - param.type === EmbeddedSignInFlowAuthenticatorParamType.String && param.confidential - ? FieldType.Password - : FieldType.Text, - value: formValues[param.param] || '', - }) - )} -
- ); - })} - - - - ); -}; - -export default Totp; diff --git a/packages/react/src/components/presentation/auth/SignIn/v1/options/UsernamePassword.tsx b/packages/react/src/components/presentation/auth/SignIn/v1/options/UsernamePassword.tsx deleted file mode 100644 index ce47d46..0000000 --- a/packages/react/src/components/presentation/auth/SignIn/v1/options/UsernamePassword.tsx +++ /dev/null @@ -1,98 +0,0 @@ -/** - * 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 {EmbeddedSignInFlowAuthenticatorParamType, FieldType} from '@thunderid/browser'; -import {FC, ReactElement, useEffect} from 'react'; -// eslint-disable-next-line import/no-cycle -import {BaseSignInOptionProps} from './SignInOptionFactory'; -import useFlow from '../../../../../../contexts/Flow/useFlow'; -import useTheme from '../../../../../../contexts/Theme/useTheme'; -import useTranslation from '../../../../../../hooks/useTranslation'; -import {createField} from '../../../../../factories/FieldFactory'; -import Button from '../../../../../primitives/Button/Button'; - -/** - * Username Password Sign-In Option Component. - * Handles traditional username and password authentication. - */ -const UsernamePassword: FC = ({ - authenticator, - formValues, - touchedFields, - isLoading, - onInputChange, - inputClassName = '', - buttonClassName = '', - preferences, -}: BaseSignInOptionProps): ReactElement => { - const {theme} = useTheme(); - const {t} = useTranslation(preferences?.i18n); - const {setTitle, setSubtitle} = useFlow(); - - const formFields: any = - authenticator!.metadata?.params - ?.sort((a: any, b: any) => a.order - b.order) - ?.filter((param: any) => param.param !== 'totp') || []; // Exclude TOTP fields for username/password - - useEffect(() => { - setTitle(t('username.password.heading')); - setSubtitle(t('username.password.subheading')); - }, [setTitle, setSubtitle, t]); - - return ( - <> - {formFields.map((param: any) => ( -
- {createField({ - className: inputClassName, - disabled: isLoading, - label: param.displayName, - name: param.param, - onChange: (value: any) => onInputChange(param.param, value), - placeholder: t(`elements.fields.generic.placeholder`, { - field: (param.displayName || param.param).toLowerCase(), - }), - required: authenticator!.requiredParams.includes(param.param), - touched: touchedFields[param.param] || false, - type: - param.type === EmbeddedSignInFlowAuthenticatorParamType.String && param.confidential - ? FieldType.Password - : FieldType.Text, - value: formValues[param.param] || '', - })} -
- ))} - - - - ); -}; - -export default UsernamePassword; diff --git a/packages/react/src/components/presentation/auth/SignIn/v1/types.ts b/packages/react/src/components/presentation/auth/SignIn/v1/types.ts deleted file mode 100644 index ad992a6..0000000 --- a/packages/react/src/components/presentation/auth/SignIn/v1/types.ts +++ /dev/null @@ -1,142 +0,0 @@ -/** - * 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 {EmbeddedSignInFlowAuthenticator, EmbeddedSignInFlowAuthenticatorParamType} from '@thunderid/browser'; - -/** - * Interface for form field state. - */ -export interface FormField { - confidential: boolean; - displayName: string; - param: string; - required: boolean; - type: EmbeddedSignInFlowAuthenticatorParamType; - value: string; -} - -/** - * Base props that all authenticator components share. - */ -export interface BaseAuthenticatorProps { - /** - * The authenticator configuration. - */ - authenticator: EmbeddedSignInFlowAuthenticator; - - /** - * Custom CSS class name for the submit button. - */ - buttonClassName?: string; - - /** - * Error message to display. - */ - error?: string | null; - - /** - * Current form values. - */ - formValues: Record; - - /** - * Custom CSS class name for form inputs. - */ - inputClassName?: string; - - /** - * Whether the component is in loading state. - */ - isLoading: boolean; - - /** - * Callback function called when input values change. - */ - onInputChange: (param: string, value: string) => void; - - /** - * Callback function called when the authenticator is submitted. - */ - onSubmit: (authenticator: EmbeddedSignInFlowAuthenticator, formData?: Record) => void; - - /** - * Text for the submit button. - */ - submitButtonText?: string; -} - -/** - * Props for authenticator selector component. - */ -export interface AuthenticatorSelectorProps { - /** - * Available authenticators for selection. - */ - authenticators: EmbeddedSignInFlowAuthenticator[]; - - buttonClassName?: string; - - /** - * Error message to display. - */ - error?: string | null; - - errorClassName?: string; - - /** - * Current form values. - */ - formValues: Record; - - /** - * Custom CSS class names. - */ - inputClassName?: string; - - /** - * Whether the component is in loading state. - */ - isLoading: boolean; - - messageClassName?: string; - /** - * Messages to display to the user. - */ - messages: {message: string; type: string}[]; - /** - * Callback function called when an authenticator is selected. - */ - onAuthenticatorSelection: (authenticator: EmbeddedSignInFlowAuthenticator, formData?: Record) => void; - /** - * Callback function called when input values change. - */ - onInputChange: (param: string, value: string) => void; - - /** - * Text for the submit button. - */ - submitButtonText?: string; -} - -/** - * Style configuration for authenticators. - */ -export interface AuthenticatorStyle { - color: string; - variant: 'solid' | 'outline'; -} diff --git a/packages/react/src/components/presentation/auth/SignIn/v2/BaseSignIn.tsx b/packages/react/src/components/presentation/auth/SignIn/v2/BaseSignIn.tsx deleted file mode 100644 index d10cb68..0000000 --- a/packages/react/src/components/presentation/auth/SignIn/v2/BaseSignIn.tsx +++ /dev/null @@ -1,696 +0,0 @@ -/** - * Copyright (c) 2025-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 {cx} from '@emotion/css'; -import { - withVendorCSSClassPrefix, - EmbeddedSignInFlowRequestV2 as EmbeddedSignInFlowRequest, - EmbeddedFlowComponentV2 as EmbeddedFlowComponent, - FieldErrorV2 as FieldError, - FlowMetadataResponse, - Preferences, - buildValidatorFromRules, -} from '@thunderid/browser'; -import {FC, useEffect, useState, useCallback, useContext, ReactElement, ReactNode} from 'react'; -import ComponentRendererContext, { - ComponentRendererMap, -} from '../../../../../contexts/ComponentRenderer/ComponentRendererContext'; -import FlowProvider from '../../../../../contexts/Flow/FlowProvider'; -import useFlow from '../../../../../contexts/Flow/useFlow'; -import ComponentPreferencesContext from '../../../../../contexts/I18n/ComponentPreferencesContext'; -import useTheme from '../../../../../contexts/Theme/useTheme'; -import useThunderID from '../../../../../contexts/ThunderID/useThunderID'; -import {FormField, useForm} from '../../../../../hooks/useForm'; -import useTranslation from '../../../../../hooks/useTranslation'; -import {extractErrorMessage} from '../../../../../utils/v2/flowTransformer'; -import AlertPrimitive from '../../../../primitives/Alert/Alert'; -// eslint-disable-next-line import/no-named-as-default -import CardPrimitive, {CardProps} from '../../../../primitives/Card/Card'; -import Spinner from '../../../../primitives/Spinner/Spinner'; -import Typography from '../../../../primitives/Typography/Typography'; -import {renderSignInComponents} from '../../AuthOptionFactory'; -import useStyles from '../BaseSignIn.styles'; - -/** - * Render props for custom UI rendering - */ -export interface BaseSignInRenderProps { - /** - * Flow components - */ - components: EmbeddedFlowComponent[]; - - /** - * API error (if any) - */ - error?: Error | null; - - /** - * Field validation errors keyed by component ref. Populated from BOTH: - * - Client-side rule evaluation (component.validation rules in meta.components) - * - Server-side validation failures (data.fieldErrors in the flow response) - * When the server returns multiple failing rules for one field, only the first - * message is exposed here. The full FieldError[] array is available on the raw - * response object (and is reflected into the BaseSignIn `serverFieldErrors` prop). - */ - fieldErrors: Record; - - /** - * Function to handle input changes - */ - handleInputChange: (name: string, value: string) => void; - - /** - * Function to handle form submission - */ - handleSubmit: (component: EmbeddedFlowComponent, data?: Record) => Promise; - - /** - * Loading state - */ - isLoading: boolean; - - /** - * Flag indicating if the step timer has reached zero - */ - isTimeoutDisabled?: boolean; - - /** - * Whether the form is valid - */ - isValid: boolean; - - /** - * Flow messages - */ - messages: {message: string; type: string}[]; - - /** - * Flow metadata returned by the platform (v2 only). `null` while loading or unavailable. - */ - meta: FlowMetadataResponse | null; - - /** - * Flow subtitle - */ - subtitle: string; - - /** - * Flow title - */ - title: string; - - /** - * Touched fields - */ - touched: Record; - - /** - * Function to validate the form - */ - validateForm: () => {fieldErrors: Record; isValid: boolean}; - - /** - * Form values - */ - values: Record; -} - -/** - * Props for the BaseSignIn component. - */ -export interface BaseSignInProps { - /** - * Additional data from the flow response. - */ - additionalData?: Record; - - /** - * Custom CSS class name for the submit button. - */ - buttonClassName?: string; - - /** - * Render props function for custom UI - */ - children?: (props: BaseSignInRenderProps) => ReactNode; - - /** - * Custom CSS class name for the form container. - */ - className?: string; - - /** - * Array of flow components to render. - */ - components?: EmbeddedFlowComponent[]; - - /** - * Error object to display - */ - error?: Error | null; - - /** - * Custom CSS class name for error messages. - */ - errorClassName?: string; - - /** - * Custom CSS class name for form inputs. - */ - inputClassName?: string; - - /** - * Flag to determine if the component is ready to be rendered. - */ - isLoading?: boolean; - - /** - * Timer flag disabling actions - */ - isTimeoutDisabled?: boolean; - - /** - * Custom CSS class name for info messages. - */ - messageClassName?: string; - - /** - * Callback function called when authentication fails. - * @param error - The error that occurred during authentication. - */ - onError?: (error: Error) => void; - - /** - * Function to handle form submission. - * @param payload - The form data to submit. - * @param component - The component that triggered the submission. - */ - onSubmit?: (payload: EmbeddedSignInFlowRequest, component: EmbeddedFlowComponent) => Promise; - - /** - * Callback function called when authentication is successful. - * @param authData - The authentication data returned upon successful completion. - */ - onSuccess?: (authData: Record) => void; - - /** - * Component-level preferences to override global i18n and theme settings. - * Preferences are deep-merged with global ones, with component preferences - * taking precedence. Affects this component and all its descendants. - */ - preferences?: Preferences; - - /** - * Field-level validation errors returned by the server in `data.fieldErrors` on the - * most recent flow response. The component collapses these into the form's - * `fieldErrors` state (first error per field wins), surfacing them through the same - * render-prop / UI path as client-side validation errors. The full array is preserved - * here for advanced consumers that want every failing rule per field. - */ - serverFieldErrors?: FieldError[] | null; - - /** - * Size variant for the component. - */ - size?: 'small' | 'medium' | 'large'; - - /** - * Theme variant for the component. - */ - variant?: CardProps['variant']; -} - -/** - * Internal component that consumes FlowContext and renders the sign-in UI. - */ -const BaseSignInContent: FC = ({ - components = [], - onSubmit, - onError, - error: externalError, - className = '', - inputClassName = '', - buttonClassName = '', - messageClassName = '', - size = 'medium', - variant = 'outlined', - isLoading: externalIsLoading, - children, - additionalData = {}, - isTimeoutDisabled = false, - serverFieldErrors = null, -}: BaseSignInProps): ReactElement => { - const {meta} = useThunderID(); - const {theme} = useTheme(); - const customRenderers: ComponentRendererMap = useContext(ComponentRendererContext); - const {t} = useTranslation(); - const {subtitle: flowSubtitle, title: flowTitle, messages: flowMessages, addMessage, clearMessages} = useFlow(); - const styles: any = useStyles(theme, theme.vars.colors.text.primary); - - const [isSubmitting, setIsSubmitting] = useState(false); - const [apiError, setApiError] = useState(null); - - const isLoading: boolean = externalIsLoading || isSubmitting; - - /** - * Handle error responses and extract meaningful error messages - * Uses the transformer's extractErrorMessage function for consistency - */ - const handleError: any = useCallback( - (error: any) => { - const errorMessage: string = extractErrorMessage(error, t); - - // Set the API error state - setApiError(error instanceof Error ? error : new Error(errorMessage)); - - // Clear existing messages and add the error message - clearMessages(); - addMessage({ - message: errorMessage, - type: 'error', - }); - }, - [t, addMessage, clearMessages], - ); - - /** - * Extract form fields from flow components - */ - const extractFormFields: (components: EmbeddedFlowComponent[]) => FormField[] = useCallback( - (flowComponents: EmbeddedFlowComponent[]): FormField[] => { - const fields: FormField[] = []; - - const processComponents = (comps: EmbeddedFlowComponent[]): any => { - comps.forEach((component: any) => { - if ( - component.type === 'TEXT_INPUT' || - component.type === 'PASSWORD_INPUT' || - component.type === 'EMAIL_INPUT' || - component.type === 'PHONE_INPUT' || - component.type === 'OTP_INPUT' || - component.type === 'SELECT' || - component.type === 'DATE_INPUT' - ) { - const identifier: string = component.ref; - const ruleValidator = buildValidatorFromRules(component.validation); - fields.push({ - initialValue: '', - name: identifier, - required: component.required || false, - validator: (value: string) => { - if (component.required && (!value || value.trim() === '')) { - return t('validations.required.field.error'); - } - // Add email validation if it's an email field - if ( - (component.type === 'EMAIL_INPUT' || component.variant === 'EMAIL') && - value && - !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) - ) { - return t('field.email.invalid'); - } - // Evaluate declarative validation rules from meta.components[].validation. - // The composed validator returns the first failing rule's message (i18n key or - // literal string) so it can be passed straight to the i18n layer for display. - if (ruleValidator && value) { - const ruleMessage = ruleValidator(value); - if (ruleMessage) { - return t(ruleMessage); - } - } - - return null; - }, - }); - } - if (component.components) { - processComponents(component.components); - } - }); - }; - - processComponents(flowComponents); - return fields; - }, - [t], - ); - - const formFields: FormField[] = components ? extractFormFields(components) : []; - - const form: ReturnType = useForm>({ - fields: formFields, - initialValues: {}, - requiredMessage: t('validations.required.field.error'), - validateOnBlur: true, - validateOnChange: false, - }); - - const { - values: formValues, - touched: touchedFields, - errors: formErrors, - isValid: isFormValid, - setValue: setFormValue, - setTouched: setFormTouched, - setErrors: setFormErrors, - clearErrors: clearFormErrors, - validateForm, - touchAllFields, - } = form; - - /** - * Project server-side validation errors (from `data.fieldErrors`) into the form's - * `errors` state so they surface through the same render-prop / UI as client-side - * errors. When the server returns multiple failing rules for one field, only the - * first message is shown โ€” matching the SDK's single-string-per-field contract. - * The full FieldError[] remains available via the `serverFieldErrors` prop. - * - * Also marks each affected field as `touched` so the error renders immediately โ€” - * `useForm` only shows errors for touched fields by default. - */ - useEffect(() => { - clearFormErrors(); - if (!serverFieldErrors || serverFieldErrors.length === 0) { - return; - } - const errors: Record = {}; - for (const fe of serverFieldErrors) { - if (!(fe.identifier in errors)) { - errors[fe.identifier] = fe.message; - } - } - setFormErrors(errors); - Object.keys(errors).forEach((field: string) => setFormTouched(field, true)); - }, [serverFieldErrors, setFormErrors, setFormTouched, clearFormErrors]); - - /** - * Handle input value changes. - * Only updates the value without marking as touched. - * Touched state is set on blur to avoid premature validation. - */ - const handleInputChange = (name: string, value: string): void => { - setFormValue(name, value); - }; - - /** - * Handle input blur event. - * Marks the field as touched, which triggers validation. - */ - const handleInputBlur = (name: string): void => { - setFormTouched(name, true); - }; - - /** - * Handle component submission (for buttons and actions). - */ - const handleSubmit = async ( - component: EmbeddedFlowComponent, - data?: Record, - skipValidation?: boolean, - ): Promise => { - // Only validate for form submit actions, skip for social/trigger actions - if (!skipValidation) { - // Mark all fields as touched before validation - touchAllFields(); - - const validation: ReturnType = validateForm(); - - if (!validation.isValid) { - return; - } - } - - setIsSubmitting(true); - setApiError(null); - clearMessages(); - - try { - // Filter out empty or undefined input values - const filteredInputs: Record = {}; - if (data) { - Object.keys(data).forEach((key: any) => { - if (data[key] !== undefined && data[key] !== null && data[key] !== '') { - filteredInputs[key] = data[key]; - } - }); - } - - let payload: EmbeddedSignInFlowRequest = {}; - - // For V2, we always send inputs and action - payload = { - ...payload, - ...(component.id && {action: component.id}), - inputs: filteredInputs, - }; - - await onSubmit?.(payload, component); - } catch (err) { - handleError(err); - onError?.(err as Error); - } finally { - setIsSubmitting(false); - } - }; - - // Generate CSS classes - const containerClasses: any = cx( - [ - withVendorCSSClassPrefix('signin'), - withVendorCSSClassPrefix(`signin--${size}`), - withVendorCSSClassPrefix(`signin--${variant}`), - ], - className, - ); - - const inputClasses: any = cx( - [ - withVendorCSSClassPrefix('signin__input'), - size === 'small' && withVendorCSSClassPrefix('signin__input--small'), - size === 'large' && withVendorCSSClassPrefix('signin__input--large'), - ], - inputClassName, - ); - - const buttonClasses: any = cx( - [ - withVendorCSSClassPrefix('signin__button'), - size === 'small' && withVendorCSSClassPrefix('signin__button--small'), - size === 'large' && withVendorCSSClassPrefix('signin__button--large'), - ], - buttonClassName, - ); - - const messageClasses: any = cx([withVendorCSSClassPrefix('signin__messages')], messageClassName); - - /** - * Render components based on flow data using the factory - */ - const renderComponents: any = useCallback( - (flowComponents: EmbeddedFlowComponent[]): ReactElement[] => - renderSignInComponents( - flowComponents, - formValues, - touchedFields, - formErrors, - isLoading, - isFormValid, - handleInputChange, - { - _customRenderers: customRenderers, - _theme: theme, - additionalData, - buttonClassName: buttonClasses, - inputClassName: inputClasses, - isTimeoutDisabled, - meta, - onInputBlur: handleInputBlur, - onSubmit: handleSubmit, - size, - t, - variant, - }, - ), - [ - additionalData, - customRenderers, - formValues, - touchedFields, - formErrors, - isFormValid, - meta, - t, - theme, - isLoading, - size, - variant, - inputClasses, - buttonClasses, - handleInputBlur, - handleSubmit, - isTimeoutDisabled, - ], - ); - - // If render props are provided, use them - if (children) { - const renderProps: BaseSignInRenderProps = { - components, - error: apiError, - fieldErrors: formErrors, - handleInputChange, - handleSubmit, - isLoading, - isTimeoutDisabled, - isValid: isFormValid, - messages: flowMessages || [], - meta, - subtitle: flowSubtitle ?? '', - title: flowTitle || t('signin.heading'), - touched: touchedFields, - validateForm: () => { - const result: any = validateForm(); - return {fieldErrors: result.errors, isValid: result.isValid}; - }, - values: formValues, - }; - - return ( -
- {children(renderProps)} -
- ); - } - - // Default UI rendering - if (isLoading) { - return ( - - -
- -
-
-
- ); - } - - if (!components || components.length === 0) { - return ( - - - - {t('errors.signin.components.not.available')} - - - - ); - } - - return ( - - - {externalError && ( -
- - {externalError.message} - -
- )} - {flowMessages && flowMessages.length > 0 && ( -
- {flowMessages.map((message: any, index: any) => ( - - {message.message} - - ))} -
- )} -
{renderComponents(components)}
-
-
- ); -}; - -/** - * Base SignIn component that provides generic authentication flow. - * This component handles component-driven UI rendering and can transform input - * structure to component-driven format automatically. - * - * @example - * // Default UI - * ```tsx - * import { BaseSignIn } from '@thunderid/react'; - * - * const MySignIn = () => { - * return ( - * { - * return await handleAuth(payload); - * }} - * onSuccess={(authData) => { - * console.log('Success:', authData); - * }} - * className="max-w-md mx-auto" - * /> - * ); - * }; - * ``` - * - * @example - * // Custom UI with render props - * ```tsx - * - * {({values, errors, handleInputChange, handleSubmit, isLoading, components}) => ( - *
- * handleInputChange('username', e.target.value)} - * /> - * {errors.username && {errors.username}} - * - *
- * )} - *
- * ``` - */ -const BaseSignIn: FC = ({preferences, ...rest}: BaseSignInProps): ReactElement => { - const content: ReactElement = ( - - - - ); - - if (!preferences) return content; - - return {content}; -}; - -export default BaseSignIn; diff --git a/packages/react/src/components/presentation/auth/SignIn/v2/SignIn.tsx b/packages/react/src/components/presentation/auth/SignIn/v2/SignIn.tsx deleted file mode 100644 index c12b522..0000000 --- a/packages/react/src/components/presentation/auth/SignIn/v2/SignIn.tsx +++ /dev/null @@ -1,990 +0,0 @@ -/** - * Copyright (c) 2025-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 { - ThunderIDRuntimeError, - ThunderIDAPIError, - EmbeddedFlowComponentV2 as EmbeddedFlowComponent, - EmbeddedFlowType, - EmbeddedSignInFlowResponseV2, - EmbeddedSignInFlowRequestV2, - EmbeddedSignInFlowStatusV2, - EmbeddedSignInFlowTypeV2, - FieldErrorV2 as FieldError, - FlowMetadataResponse, - Preferences, - logger, -} from '@thunderid/browser'; -import {FC, ReactElement, useState, useEffect, useRef, ReactNode} from 'react'; -// eslint-disable-next-line import/no-named-as-default -import BaseSignIn, {BaseSignInProps} from './BaseSignIn'; -import useThunderID from '../../../../../contexts/ThunderID/useThunderID'; -import useTranslation from '../../../../../hooks/useTranslation'; -import {useOAuthCallback} from '../../../../../hooks/v2/useOAuthCallback'; -import {initiateOAuthRedirect} from '../../../../../utils/oauth'; -import {extractErrorMessage, normalizeFlowResponse} from '../../../../../utils/v2/flowTransformer'; -import {handlePasskeyAuthentication, handlePasskeyRegistration} from '../../../../../utils/v2/passkey'; - -/** - * Render props function parameters - */ -export interface SignInRenderProps { - /** - * Additional data from the flow response containing contextual information - * like consent prompt details and session timeouts. - */ - additionalData?: Record; - - /** - * Current flow components - */ - components: EmbeddedFlowComponent[]; - - /** - * Current error if any - */ - error: Error | null; - - /** - * Server-side field-level validation errors from the most recent flow response, - * collapsed to one message per field (first error wins). Empty when no validation - * failures are active. Render-prop consumers should display these alongside their - * own client-side validation errors. - */ - fieldErrors: Record; - - /** - * Function to manually initialize the flow - */ - initialize: () => Promise; - - /** - * Whether the flow has been initialized - */ - isInitialized: boolean; - - /** - * Loading state indicator - */ - isLoading: boolean; - - /** - * Flag indicating whether the flow step timeout has expired. - * Consuming components can use this to disable submit buttons. - */ - isTimeoutDisabled?: boolean; - - /** - * Flow metadata returned by the platform (v2 only). `null` while loading or unavailable. - */ - meta: FlowMetadataResponse | null; - - /** - * Function to submit authentication data (primary) - */ - onSubmit: (payload: EmbeddedSignInFlowRequestV2) => Promise; -} - -/** - * Props for the SignIn component. - * Matches the interface from the main SignIn component for consistency. - */ -export interface SignInProps { - /** - * Render props function for custom UI - */ - children?: (props: SignInRenderProps) => ReactNode; - - /** - * Custom CSS class name for the form container. - */ - className?: string; - - /** - * Callback function called when authentication fails. - * @param error - The error that occurred during authentication. - */ - onError?: (error: Error) => void; - - /** - * Callback function called when authentication is successful. - * @param authData - The authentication data returned upon successful completion. - */ - onSuccess?: (authData: Record) => void; - - /** - * Component-level preferences to override global i18n and theme settings. - * Preferences are deep-merged with global ones, with component preferences - * taking precedence. Affects this component and all its descendants. - */ - preferences?: Preferences; - - /** - * Size variant for the component. - */ - size?: 'small' | 'medium' | 'large'; - - /** - * Theme variant for the component. - */ - variant?: BaseSignInProps['variant']; -} - -/** - * State for tracking passkey registration - */ -interface PasskeyState { - actionId: string | null; - challenge: string | null; - creationOptions: string | null; - error: Error | null; - executionId: string | null; - isActive: boolean; -} - -/** - * A component-driven SignIn component that provides authentication flow with pre-built styling. - * This component handles the flow API calls for authentication and delegates UI logic to BaseSignIn. - * It automatically transforms simple input-based responses into component-driven UI format. - * - * @example - * // Default UI - * ```tsx - * import { SignIn } from '@thunderid/react/component-driven'; - * - * const App = () => { - * return ( - * { - * console.log('Authentication successful:', authData); - * }} - * onError={(error) => { - * console.error('Authentication failed:', error); - * }} - * size="medium" - * variant="outlined" - * /> - * ); - * }; - * ``` - * - * @example - * // Custom UI with render props - * ```tsx - * import { SignIn } from '@thunderid/react/component-driven'; - * - * const App = () => { - * return ( - * console.log('Success:', authData)} - * onError={(error) => console.error('Error:', error)} - * > - * {({signIn, isLoading, components, error, isInitialized}) => ( - *
- *

Custom Sign In

- * {!isInitialized ? ( - *

Initializing...

- * ) : error ? ( - *
{error.message}
- * ) : ( - *
{ - * e.preventDefault(); - * signIn({inputs: {username: 'user', password: 'pass'}}); - * }}> - * - *
- * )} - *
- * )} - *
- * ); - * }; - * ``` - */ -const SignIn: FC = ({ - className, - preferences, - size = 'medium', - onSuccess, - onError, - variant, - children, -}: SignInProps): ReactElement => { - const {applicationId, afterSignInUrl, signIn, isInitialized, isLoading, meta, getStorageManager, scopes} = - useThunderID(); - const {t} = useTranslation(preferences?.i18n); - - // State management for the flow - const [components, setComponents] = useState([]); - const [additionalData, setAdditionalData] = useState>({}); - // Server-side validation errors from the most recent flow response. Updated on every - // submission; cleared when the next submission begins so stale errors don't linger. - const [serverFieldErrors, setServerFieldErrors] = useState(null); - const [currentExecutionId, setCurrentExecutionId] = useState(null); - const challengeTokenRef: any = useRef(null); - const [isStorageReady, setIsStorageReady] = useState(false); - const [isFlowInitialized, setIsFlowInitialized] = useState(false); - const [flowError, setFlowError] = useState(null); - const [isSubmitting, setIsSubmitting] = useState(false); - const [isTimeoutDisabled, setIsTimeoutDisabled] = useState(false); - const [passkeyState, setPasskeyState] = useState({ - actionId: null, - challenge: null, - creationOptions: null, - error: null, - executionId: null, - isActive: false, - }); - const initializationAttemptedRef: any = useRef(false); - const oauthCodeProcessedRef: any = useRef(false); - const passkeyProcessedRef: any = useRef(false); - /** - * Sets executionId between sessionStorage and state. - * This ensures both are always in sync. - */ - const setExecutionId = (executionId: string | null): void => { - setCurrentExecutionId(executionId); - if (executionId) { - sessionStorage.setItem('thunderid_execution_id', executionId); - } else { - sessionStorage.removeItem('thunderid_execution_id'); - } - }; - - /** - * Restore any challenge token persisted before an OAuth redirect. - * Waits for SDK initialization before reading from storage. - */ - useEffect(() => { - if (!isInitialized) return; - - (async (): Promise => { - try { - const storageManager: any = await getStorageManager(); - const tempData: any = await storageManager?.getTemporaryData(); - if (tempData?.challengeToken) { - challengeTokenRef.current = tempData.challengeToken as string; - } - } finally { - setIsStorageReady(true); - } - })(); - }, [isInitialized]); - - /** - * Updates challengeTokenRef immediately (stale-closure safe) and persists via - * the provider's StorageManager so the token survives OAuth redirects. - */ - const setChallengeToken = async (challengeToken: string | null): Promise => { - challengeTokenRef.current = challengeToken; - try { - const storageManager: any = await getStorageManager(); - if (storageManager) { - if (challengeToken) { - await storageManager.setTemporaryDataParameter('challengeToken', challengeToken); - } else { - await storageManager.removeTemporaryDataParameter('challengeToken'); - } - } - } catch { - logger.warn('Failed to persist challenge token in storage.'); - } - }; - - /** - * Clear all flow-related storage and state. - */ - const clearFlowState = async (): Promise => { - setExecutionId(null); - await setChallengeToken(null); - setIsFlowInitialized(false); - try { - const storageManager: any = await getStorageManager(); - await storageManager?.removeHybridDataParameter?.('authId'); - } catch { - logger.warn('Failed to clear authId from hybrid storage.'); - } - setIsTimeoutDisabled(false); - // Reset refs to allow new flows to start properly - oauthCodeProcessedRef.current = false; - }; - - /** - * Parse URL parameters used in flows. - */ - const getUrlParams = (): any => { - const urlParams: any = new URL(window?.location?.href ?? '').searchParams; - - return { - applicationId: urlParams.get('applicationId'), - authId: urlParams.get('authId'), - code: urlParams.get('code'), - error: urlParams.get('error'), - errorDescription: urlParams.get('error_description'), - executionId: urlParams.get('executionId'), - nonce: urlParams.get('nonce'), - state: urlParams.get('state'), - }; - }; - - /** - * Handle authId from URL and persist it via the storage manager so it survives URL cleanup. - * ThunderIDReactClient.signIn() reads authId from storageManager.getHybridDataParameter('authId'), - * not from raw sessionStorage, so we must use the same storage path here. - */ - const handleAuthId = async (authId: string | null): Promise => { - if (authId) { - try { - const storageManager: any = await getStorageManager(); - await storageManager?.setHybridDataParameter?.('authId', authId); - } catch { - logger.warn('Failed to store authId in hybrid storage.'); - } - } - }; - - /** - * Clean up OAuth-related URL parameters from the browser URL. - */ - const cleanupOAuthUrlParams = (includeNonce = false): void => { - if (!window?.location?.href) return; - const url: any = new URL(window.location.href); - url.searchParams.delete('error'); - url.searchParams.delete('error_description'); - url.searchParams.delete('code'); - url.searchParams.delete('state'); - if (includeNonce) { - url.searchParams.delete('nonce'); - } - window?.history?.replaceState({}, '', url.toString()); - }; - - /** - * Clean up flow-related URL parameters (executionId, authId) from the browser URL. - * Used after executionId is set in state to prevent using invalidated executionId from URL. - */ - const cleanupFlowUrlParams = (): void => { - if (!window?.location?.href) return; - const url: any = new URL(window.location.href); - url.searchParams.delete('executionId'); - url.searchParams.delete('authId'); - url.searchParams.delete('applicationId'); - window?.history?.replaceState({}, '', url.toString()); - }; - - /** - * Set error state and call onError callback. - * Ensures isFlowInitialized is true so errors can be displayed in the UI. - */ - const setError = (error: Error): void => { - setFlowError(error); - setIsFlowInitialized(true); - onError?.(error); - }; - - /** - * Handle OAuth error from URL parameters. - * Clears flow state, creates error, and cleans up URL. - */ - const handleOAuthError = (error: string, errorDescription: string | null): void => { - clearFlowState(); - const errorMessage: any = errorDescription || `OAuth error: ${error}`; - const err: any = new ThunderIDRuntimeError(errorMessage, 'SIGN_IN_ERROR', 'react'); - setError(err); - cleanupOAuthUrlParams(true); - }; - - /** - * Handle REDIRECTION response by storing flow state and redirecting to OAuth provider. - */ - const handleRedirection = async (response: EmbeddedSignInFlowResponseV2): Promise => { - if (response.type === EmbeddedSignInFlowTypeV2.Redirection) { - const redirectURL: any = (response.data as any)?.redirectURL || (response as any)?.redirectURL; - - if (redirectURL && window?.location) { - if (response.executionId) { - setExecutionId(response.executionId); - } - await setChallengeToken(response.challengeToken ?? null); - - const urlParams: any = getUrlParams(); - await handleAuthId(urlParams.authId); - - initiateOAuthRedirect(redirectURL); - return true; - } - } - return false; - }; - - /** - * Initialize the authentication flow. - * Priority: executionId > applicationId (from context) > applicationId (from URL) - */ - const initializeFlow = async (): Promise => { - const urlParams: any = getUrlParams(); - - // Reset OAuth code processed ref when starting a new flow - oauthCodeProcessedRef.current = false; - // Clear stale serverFieldErrors so the new flow doesn't inherit them via render-prop. - setServerFieldErrors(null); - - await handleAuthId(urlParams.authId); - - const effectiveApplicationId: any = applicationId || urlParams.applicationId; - - // On a page refresh the executionId is no longer in the URL (it is scrubbed by - // cleanupFlowUrlParams after the first load). Fall back to the executionId persisted in - // sessionStorage so the in-progress flow can be resumed instead of starting a new flow. - // This is required for authorization_code apps where direct new-flow initiation is blocked - // server-side and the flow must be initiated through the OAuth /authorize endpoint. - const storedExecutionId: any = !urlParams.executionId ? sessionStorage.getItem('thunderid_execution_id') : null; - const resumeExecutionId: any = urlParams.executionId || storedExecutionId; - - if (!resumeExecutionId && !effectiveApplicationId) { - const error: any = new ThunderIDRuntimeError( - 'Either executionId or applicationId is required for authentication', - 'SIGN_IN_ERROR', - 'react', - ); - setError(error); - throw error; - } - - try { - setFlowError(null); - - let response: EmbeddedSignInFlowResponseV2; - - if (resumeExecutionId) { - try { - response = (await signIn({ - executionId: resumeExecutionId, - ...(challengeTokenRef.current ? {challengeToken: challengeTokenRef.current} : {}), - })) as EmbeddedSignInFlowResponseV2; - } catch (resumeError) { - // Only treat a stale/expired session (HTTP 400 from the server, meaning the stored - // executionId is no longer valid) as a recoverable condition. Transient failures - // such as 5xx server errors or network errors are rethrown so they surface correctly. - const isStaleSession: boolean = - storedExecutionId && - resumeExecutionId === storedExecutionId && - resumeError instanceof ThunderIDAPIError && - resumeError.statusCode === 400; - - if (isStaleSession) { - setExecutionId(null); - try { - const storageManager: any = await getStorageManager(); - await storageManager?.removeHybridDataParameter?.('authId'); - } catch { - logger.warn('Failed to clear authId from hybrid storage.'); - } - if (!effectiveApplicationId) { - const expiredError: any = new ThunderIDRuntimeError( - t('errors.signin.session.expired') || - 'Your session has expired. Please return to the application and sign in again.', - 'SIGN_IN_ERROR', - 'react', - ); - setError(expiredError); - return; - } - response = (await signIn({ - applicationId: effectiveApplicationId, - flowType: EmbeddedFlowType.Authentication, - ...(scopes && {scopes}), - })) as EmbeddedSignInFlowResponseV2; - } else { - throw resumeError; - } - } - } else { - response = (await signIn({ - applicationId: effectiveApplicationId, - flowType: EmbeddedFlowType.Authentication, - ...(scopes && {scopes}), - })) as EmbeddedSignInFlowResponseV2; - } - - if (await handleRedirection(response)) { - return; - } - - const { - executionId: normalizedExecutionId, - components: normalizedComponents, - additionalData: normalizedAdditionalData, - } = normalizeFlowResponse( - response, - t, - { - resolveTranslations: false, - }, - meta, - ); - - await setChallengeToken(response.challengeToken ?? null); - - if (normalizedExecutionId && normalizedComponents) { - setExecutionId(normalizedExecutionId); - setComponents(normalizedComponents); - setAdditionalData(normalizedAdditionalData ?? {}); - setIsFlowInitialized(true); - setIsTimeoutDisabled(false); - // Clean up executionId from URL after setting it in state - cleanupFlowUrlParams(); - } - } catch (error) { - const err: any = error; - await clearFlowState(); - - setError(err instanceof ThunderIDRuntimeError ? err : new Error(extractErrorMessage(err, t))); - initializationAttemptedRef.current = false; - } - }; - - /** - * Initialize the flow and handle cleanup of stale flow state. - */ - useEffect(() => { - const urlParams: any = getUrlParams(); - - // Check for OAuth error in URL - if (urlParams.error) { - handleOAuthError(urlParams.error, urlParams.errorDescription); - return; - } - - handleAuthId(urlParams.authId); - - // Skip OAuth code processing - let the dedicated OAuth useEffect handle it - // No action needed here as the dedicated useEffect will handle it - }, []); - - useEffect(() => { - // Only initialize if we're not processing an OAuth callback or submission. - // Wait for isStorageReady so the challenge token is restored from storage before - // we attempt to resume a persisted executionId โ€” the server requires it. - const currentUrlParams: any = getUrlParams(); - if ( - isInitialized && - isStorageReady && - !isLoading && - !isFlowInitialized && - !initializationAttemptedRef.current && - !currentExecutionId && - !currentUrlParams.code && - !currentUrlParams.state && - !isSubmitting && - !oauthCodeProcessedRef.current - ) { - initializationAttemptedRef.current = true; - initializeFlow(); - } - }, [isInitialized, isStorageReady, isLoading, isFlowInitialized, currentExecutionId]); - - /** - * Handle step timeout if configured in additionalData. - */ - useEffect(() => { - const timeoutMs: number = Number(additionalData?.['stepTimeout']) || 0; - if (timeoutMs <= 0 || !isFlowInitialized) { - setIsTimeoutDisabled(false); - return undefined; - } - - const remaining: number = Math.max(0, Math.floor((timeoutMs - Date.now()) / 1000)); - - const handleTimeout = (): void => { - const errorMessage: string = t('errors.signin.timeout') || 'Time allowed to complete the step has expired.'; - setError(new Error(errorMessage)); - setIsTimeoutDisabled(true); - }; - - if (remaining <= 0) { - handleTimeout(); - return undefined; - } - - const timerId: any = setTimeout(() => { - handleTimeout(); - }, remaining * 1000); - - return () => clearTimeout(timerId); - }, [additionalData?.['stepTimeout'], isFlowInitialized, t]); - - /** - * Handle form submission from BaseSignIn or render props. - */ - const handleSubmit = async (payload: EmbeddedSignInFlowRequestV2): Promise => { - // Use executionId from payload if available, otherwise fall back to currentExecutionId - const effectiveExecutionId: any = payload.executionId || currentExecutionId; - - if (!effectiveExecutionId) { - throw new Error('No active flow ID'); - } - - const processedInputs: Record = {...payload.inputs}; - - // Auto-compile consent decisions if we are currently on a consent prompt step - if (additionalData?.['consentPrompt']) { - try { - const consentPromptRawData: any = additionalData['consentPrompt']; - const purposes: any[] = - typeof consentPromptRawData === 'string' - ? JSON.parse(consentPromptRawData) - : consentPromptRawData.purposes || consentPromptRawData; - - // Find the action component to determine if it was a deny action - let isDeny = false; - if (payload.action) { - // Flatten components to find the action - const findAction = (comps: any[]): any => { - if (!comps || comps.length === 0) return null; - - const found: any = comps.find((c: any) => c.id === payload.action); - if (found) return found; - - return comps.reduce((acc: any, c: any) => { - if (acc) return acc; - if (c.components) return findAction(c.components); - return null; - }, null); - }; - - const submitAction: any = findAction(components); - - if (submitAction && submitAction.variant?.toLowerCase() !== 'primary') { - isDeny = true; - } - } - - const decisions: any = { - purposes: purposes.map((p: any) => ({ - approved: !isDeny, - elements: [ - ...(p.essential || []).map((e: any) => ({ - approved: !isDeny, - name: e.name, - })), - ...(p.optional || []).map((e: any) => { - const key = `__consent_opt__${p.purposeId}__${e.name}`; - return { - approved: isDeny ? false : processedInputs[key] !== 'false', - name: e.name, - }; - }), - ], - purposeName: p.purposeName, - })), - }; - processedInputs['consent_decisions'] = JSON.stringify(decisions); - - // Cleanup temporary consent tracking fields from inputs - Object.keys(processedInputs).forEach((key: string) => { - if (key.startsWith('__consent_opt__')) { - delete processedInputs[key]; - } - }); - } catch (e) { - // Failed to construct consent_decisions payload automatically - } - } - - try { - setIsSubmitting(true); - setFlowError(null); - // Clear any field errors from the previous response before the new round-trip. - setServerFieldErrors(null); - - const response: EmbeddedSignInFlowResponseV2 = (await signIn({ - executionId: effectiveExecutionId, - ...payload, - inputs: processedInputs, - ...(challengeTokenRef.current ? {challengeToken: challengeTokenRef.current} : {}), - })) as EmbeddedSignInFlowResponseV2; - - if (await handleRedirection(response)) { - return; - } - if ( - response.data?.additionalData?.['passkeyChallenge'] || - response.data?.additionalData?.['passkeyCreationOptions'] - ) { - const {passkeyChallenge, passkeyCreationOptions}: any = response.data.additionalData; - const effectiveExecutionIdForPasskey: any = response.executionId || effectiveExecutionId; - - // Reset passkey processed ref to allow processing - passkeyProcessedRef.current = false; - - await setChallengeToken(response.challengeToken ?? null); - - // Set passkey state to trigger the passkey - setPasskeyState({ - actionId: 'submit', - challenge: passkeyChallenge, - creationOptions: passkeyCreationOptions, - error: null, - executionId: effectiveExecutionIdForPasskey, - isActive: true, - }); - setIsSubmitting(false); - - return; - } - - const { - executionId: normalizedExecutionId, - components: normalizedComponents, - additionalData: normalizedAdditionalData, - } = normalizeFlowResponse( - response, - t, - { - resolveTranslations: false, - }, - meta, - ); - - // Handle Error flow status - flow has failed and is invalidated - if (response.flowStatus === EmbeddedSignInFlowStatusV2.Error) { - await clearFlowState(); - const err: any = new Error(extractErrorMessage(response, t)); - setError(err); - cleanupFlowUrlParams(); - // Throw the error so it's caught by the catch block and propagated to BaseSignIn - throw err; - } - - if (response.flowStatus === EmbeddedSignInFlowStatusV2.Complete) { - // Get redirectUrl from response (from /oauth2/auth/callback) or fall back to afterSignInUrl - const redirectUrl: any = (response as any)?.redirectUrl || (response as any)?.redirect_uri; - const finalRedirectUrl: any = redirectUrl || afterSignInUrl; - - // Clear submitting state before redirect - setIsSubmitting(false); - - // Clear all OAuth-related storage on successful completion - setExecutionId(null); - await setChallengeToken(null); - setIsFlowInitialized(false); - sessionStorage.removeItem('thunderid_execution_id'); - try { - const storageManager: any = await getStorageManager(); - await storageManager?.removeHybridDataParameter?.('authId'); - } catch { - logger.warn('Failed to clear authId from hybrid storage after completion.'); - } - - // Clean up OAuth URL params before redirect - cleanupOAuthUrlParams(true); - - if (onSuccess) { - onSuccess({ - redirectUrl: finalRedirectUrl, - ...(response.data || {}), - }); - } - - if (finalRedirectUrl && window?.location) { - window.location.href = finalRedirectUrl; - } - - return; - } - - // Always update challenge token on any INCOMPLETE response โ€” token rotates every step. - await setChallengeToken(response.challengeToken ?? null); - - // Update executionId if response contains a new one - if (normalizedExecutionId && normalizedComponents) { - setExecutionId(normalizedExecutionId); - setComponents(normalizedComponents); - setAdditionalData(normalizedAdditionalData ?? {}); - setIsTimeoutDisabled(false); - // Ensure flow is marked as initialized when we have components - setIsFlowInitialized(true); - // Clean up executionId from URL after setting it in state - cleanupFlowUrlParams(); - - // Surface server-side validation failures so BaseSignIn can inject them into - // the form-level fieldErrors state used by the render-prop / default UI. - const responseFieldErrors: FieldError[] | undefined = (response.data as any)?.fieldErrors; - if (responseFieldErrors && responseFieldErrors.length > 0) { - setServerFieldErrors(responseFieldErrors); - } - - // Display error from INCOMPLETE response - if ((response as any)?.error) { - setFlowError(new Error(extractErrorMessage(response, t))); - } - } - } catch (error) { - const err: any = error; - await clearFlowState(); - - setError(err instanceof ThunderIDRuntimeError ? err : new Error(extractErrorMessage(err, t))); - return; - } finally { - setIsSubmitting(false); - } - }; - - /** - * Handle authentication errors. - */ - const handleError = (error: Error): void => { - setError(error); - }; - - useOAuthCallback({ - currentExecutionId, - isInitialized: isInitialized && !isLoading && isStorageReady, - isSubmitting, - onError: (err: any) => { - clearFlowState(); - setError(err instanceof Error ? err : new Error(String(err))); - }, - onSubmit: async (payload: any) => handleSubmit({executionId: payload.executionId, inputs: payload.inputs}), - processedRef: oauthCodeProcessedRef, - setExecutionId, - }); - - /** - * Handle passkey authentication/registration when passkey state becomes active. - * This effect auto-triggers the browser passkey popup and submits the result. - */ - useEffect(() => { - if ( - !passkeyState.isActive || - (!passkeyState.challenge && !passkeyState.creationOptions) || - !passkeyState.executionId - ) { - return; - } - - // Prevent re-processing - if (passkeyProcessedRef.current) { - return; - } - passkeyProcessedRef.current = true; - - const performPasskeyProcess = async (): Promise => { - let inputs: Record; - - if (passkeyState.challenge) { - const passkeyResponse: any = await handlePasskeyAuthentication(passkeyState.challenge); - const passkeyResponseObj: any = JSON.parse(passkeyResponse); - - inputs = { - authenticatorData: passkeyResponseObj.response.authenticatorData, - clientDataJSON: passkeyResponseObj.response.clientDataJSON, - credentialId: passkeyResponseObj.id, - signature: passkeyResponseObj.response.signature, - userHandle: passkeyResponseObj.response.userHandle, - }; - } else if (passkeyState.creationOptions) { - const passkeyResponse: any = await handlePasskeyRegistration(passkeyState.creationOptions); - const passkeyResponseObj: any = JSON.parse(passkeyResponse); - - inputs = { - attestationObject: passkeyResponseObj.response.attestationObject, - clientDataJSON: passkeyResponseObj.response.clientDataJSON, - credentialId: passkeyResponseObj.id, - }; - } else { - throw new Error('No passkey challenge or creation options available'); - } - - await handleSubmit({ - executionId: passkeyState.executionId ?? undefined, - inputs, - }); - }; - - performPasskeyProcess() - .then(() => { - setPasskeyState({ - actionId: null, - challenge: null, - creationOptions: null, - error: null, - executionId: null, - isActive: false, - }); - }) - .catch((error: any) => { - setPasskeyState((prev: any) => ({...prev, error: error as Error, isActive: false})); - setFlowError(error as Error); - onError?.(error as Error); - }); - }, [passkeyState.isActive, passkeyState.challenge, passkeyState.creationOptions, passkeyState.executionId]); - - if (children) { - // Collapse the server FieldError[] array to a single message per field map for - // render-prop consumers. First error per field wins. Multi-error cases per - // field are rare in practice (server typically returns one rule failure per - // field in current flows) and consumers needing the full array can still read - // it from the raw flow response. - const renderPropFieldErrors: Record = {}; - if (serverFieldErrors) { - for (const fe of serverFieldErrors) { - if (!(fe.identifier in renderPropFieldErrors)) { - renderPropFieldErrors[fe.identifier] = fe.message; - } - } - } - - const renderProps: SignInRenderProps = { - additionalData, - components, - error: flowError, - fieldErrors: renderPropFieldErrors, - initialize: initializeFlow, - isInitialized: isFlowInitialized, - isLoading: isLoading || isSubmitting || !isInitialized, - isTimeoutDisabled, - meta, - onSubmit: handleSubmit, - }; - - return <>{children(renderProps)}; - } - // Otherwise, render the default BaseSignIn component - return ( - - ); -}; - -export default SignIn; diff --git a/packages/react/src/components/presentation/auth/SignUp/BaseSignUp.tsx b/packages/react/src/components/presentation/auth/SignUp/BaseSignUp.tsx index c68ecd5..96eb248 100644 --- a/packages/react/src/components/presentation/auth/SignUp/BaseSignUp.tsx +++ b/packages/react/src/components/presentation/auth/SignUp/BaseSignUp.tsx @@ -1,5 +1,5 @@ /** - * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com). + * Copyright (c) 2025-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 @@ -16,26 +16,1202 @@ * under the License. */ -import {Platform} from '@thunderid/browser'; -import {FC} from 'react'; -import BaseSignUpV1, {BaseSignUpProps as BaseSignUpV1Props} from './v1/BaseSignUp'; -import BaseSignUpV2, {BaseSignUpProps as BaseSignUpV2Props} from './v2/BaseSignUp'; +import {cx} from '@emotion/css'; +import { + EmbeddedSignUpFlowRequest, + EmbeddedSignUpFlowResponse, + EmbeddedSignUpFlowStatus, + EmbeddedSignUpFlowType, + withVendorCSSClassPrefix, + EmbeddedFlowComponentType, + FieldError, + createPackageComponentLogger, + buildValidatorFromRules, + Preferences, +} from '@thunderid/browser'; +import {FC, ReactElement, ReactNode, useCallback, useContext, useEffect, useRef, useState} from 'react'; +import useStyles from './BaseSignUp.styles'; +import ComponentRendererContext, { + ComponentRendererMap, +} from '../../../../contexts/ComponentRenderer/ComponentRendererContext'; +import FlowProvider from '../../../../contexts/Flow/FlowProvider'; +import useFlow from '../../../../contexts/Flow/useFlow'; +import ComponentPreferencesContext from '../../../../contexts/I18n/ComponentPreferencesContext'; +import useTheme from '../../../../contexts/Theme/useTheme'; import useThunderID from '../../../../contexts/ThunderID/useThunderID'; +import {useForm, FormField} from '../../../../hooks/useForm'; +import useTranslation from '../../../../hooks/useTranslation'; +import {normalizeFlowResponse, extractErrorMessage} from '../../../../utils/flowTransformer'; +import getAuthComponentHeadings from '../../../../utils/getAuthComponentHeadings'; +import {handlePasskeyRegistration} from '../../../../utils/passkey'; +import AlertPrimitive from '../../../primitives/Alert/Alert'; +// eslint-disable-next-line import/no-named-as-default +import CardPrimitive, {CardProps} from '../../../primitives/Card/Card'; +import Logo from '../../../primitives/Logo/Logo'; +import Spinner from '../../../primitives/Spinner/Spinner'; +import Typography from '../../../primitives/Typography/Typography'; +import {renderSignUpComponents} from '../AuthOptionFactory'; + +const logger: ReturnType = createPackageComponentLogger( + '@thunderid/react', + 'BaseSignUp', +); + +/** + * State for tracking passkey registration + */ +interface PasskeyState { + actionId: string | null; + creationOptions: string | null; + error: Error | null; + executionId: string | null; + isActive: boolean; +} + +/** + * Render props for custom UI rendering + */ +export interface BaseSignUpRenderProps { + /** + * Flow components + */ + components: any[]; + + /** + * API error (if any) + */ + error?: Error | null; + + /** + * Field validation errors + */ + fieldErrors: Record; + + /** + * Function to handle input changes + */ + handleInputChange: (name: string, value: string) => void; + + /** + * Function to handle form submission + */ + handleSubmit: (component: any, data?: Record) => Promise; + + /** + * Loading state + */ + isLoading: boolean; + + /** + * Whether the form is valid + */ + isValid: boolean; + + /** + * Flow messages + */ + messages: {message: string; type: string}[]; + + /** + * Flow subtitle + */ + subtitle: string; + + /** + * Flow title + */ + title: string; + + /** + * Touched fields + */ + touched: Record; + + /** + * Function to validate the form + */ + validateForm: () => {fieldErrors: Record; isValid: boolean}; + + /** + * Form values + */ + values: Record; +} /** * Props for the BaseSignUp component. - * Extends BaseSignUpV1Props & BaseSignUpV2Props for full compatibility with both React BaseSignUp components. */ -export type BaseSignUpProps = BaseSignUpV1Props | BaseSignUpV2Props; +export interface BaseSignUpProps { + /** + * URL to redirect after successful sign-up. + */ + afterSignUpUrl?: string; + + /** + * Custom CSS class name for the submit button. + */ + buttonClassName?: string; + + /** + * Render props function for custom UI + */ + children?: (props: BaseSignUpRenderProps) => ReactNode; + + /** + * Custom CSS class name for the form container. + */ + className?: string; + + /** + * Error object to display + */ + error?: Error | null; + + /** + * Custom CSS class name for error messages. + */ + errorClassName?: string; + + /** + * Custom CSS class name for form inputs. + */ + inputClassName?: string; + + isInitialized?: boolean; + + /** + * Custom CSS class name for info messages. + */ + messageClassName?: string; + + /** + * Callback function called when the sign-up flow completes and requires redirection. + * This allows platform-specific handling of redirects (e.g., Next.js router.push). + * @param response - The response from the sign-up flow containing the redirect URL, etc. + */ + onComplete?: (response: EmbeddedSignUpFlowResponse) => void; + + /** + * Callback function called when sign-up fails. + * @param error - The error that occurred during sign-up. + */ + onError?: (error: Error) => void; + + /** + * Callback function called when sign-up flow status changes. + * @param response - The current sign-up response. + */ + onFlowChange?: (response: EmbeddedSignUpFlowResponse) => void; + + /** + * Function to initialize sign-up flow. + * @returns Promise resolving to the initial sign-up response. + */ + onInitialize?: (payload?: EmbeddedSignUpFlowRequest) => Promise; + + /** + * Function to handle sign-up steps. + * @param payload - The sign-up payload. + * @returns Promise resolving to the sign-up response. + */ + onSubmit?: (payload: EmbeddedSignUpFlowRequest) => Promise; + /** + * Component-level preferences to override global i18n and theme settings. + * Preferences are deep-merged with global ones, with component preferences + * taking precedence. Affects this component and all its descendants. + */ + preferences?: Preferences; + + /** + * Whether to redirect after sign-up. + */ + shouldRedirectAfterSignUp?: boolean; + + /** + * Whether to show the logo. + */ + showLogo?: boolean; + + /** + * Whether to show the subtitle. + */ + showSubtitle?: boolean; + + /** + * Whether to show the title. + */ + showTitle?: boolean; + + /** + * Size variant for the component. + */ + size?: 'small' | 'medium' | 'large'; + + /** + * Theme variant for the component. + */ + variant?: CardProps['variant']; +} + +/** + * Internal component that consumes FlowContext and renders the sign-up UI. + */ +const BaseSignUpContent: FC = ({ + afterSignUpUrl, + onInitialize, + onSubmit, + onError, + onFlowChange, + onComplete, + error: externalError, + className = '', + inputClassName = '', + buttonClassName = '', + errorClassName = '', + messageClassName = '', + size = 'medium', + variant = 'outlined', + isInitialized, + children, + showTitle = true, + showSubtitle = true, +}: BaseSignUpProps): ReactElement => { + const {theme, colorScheme} = useTheme(); + const customRenderers: ComponentRendererMap = useContext(ComponentRendererContext); + const {t} = useTranslation(); + const {subtitle: flowSubtitle, title: flowTitle, messages: flowMessages, addMessage, clearMessages} = useFlow(); + const {meta, isInitialized: isSdkInitialized, getStorageManager} = useThunderID(); + const styles: any = useStyles(theme, colorScheme); + + const [isLoading, setIsLoading] = useState(false); + const [isFlowInitialized, setIsFlowInitialized] = useState(false); + const [currentFlow, setCurrentFlow] = useState(null); + const [apiError, setApiError] = useState(null); + const [isStorageReady, setIsStorageReady] = useState(false); + const [passkeyState, setPasskeyState] = useState({ + actionId: null, + creationOptions: null, + error: null, + executionId: null, + isActive: false, + }); + const challengeTokenRef: any = useRef(null); + + const initializationAttemptedRef: any = useRef(false); + const passkeyProcessedRef: any = useRef(false); + + /** + * Restore any challenge token persisted before an OAuth redirect. + */ + useEffect(() => { + if (!isSdkInitialized) return; + + (async (): Promise => { + try { + const storageManager: any = await getStorageManager(); + const tempData: any = await storageManager?.getTemporaryData(); + if (tempData?.challengeToken) { + challengeTokenRef.current = tempData.challengeToken as string; + } + } catch { + // StorageManager unavailable โ€” continue without persisted token + } finally { + setIsStorageReady(true); + } + })(); + }, [isSdkInitialized]); + + /** + * Updates challengeTokenRef immediately (stale-closure safe) and persists via + * the provider's StorageManager so the token survives OAuth redirects. + */ + const setChallengeToken = async (challengeToken: string | null): Promise => { + challengeTokenRef.current = challengeToken; + try { + const storageManager: any = await getStorageManager(); + if (storageManager) { + if (challengeToken) { + await storageManager.setTemporaryDataParameter('challengeToken', challengeToken); + } else { + await storageManager.removeTemporaryDataParameter('challengeToken'); + } + } + } catch { + logger.warn('Failed to persist challenge token in storage.'); + } + }; + + /** + * Handle error responses and extract meaningful error messages + * Uses the transformer's extractErrorMessage function. + */ + const handleError: any = useCallback( + (error: any) => { + const errorMessage: string = extractErrorMessage(error, t); + + // Set the API error state + setApiError(error instanceof Error ? error : new Error(errorMessage)); + + // Clear existing messages and add the error message + clearMessages(); + addMessage({ + message: errorMessage, + type: 'error', + }); + }, + [t, addMessage, clearMessages], + ); + + /** + * Normalize flow response to ensure component-driven format + * Uses normalizeFlowResponse for modern API format responses + */ + const normalizeFlowResponseLocal: any = useCallback( + (response: EmbeddedSignUpFlowResponse): EmbeddedSignUpFlowResponse => { + if (response?.data) { + const {components} = normalizeFlowResponse( + response, + t, + { + defaultErrorKey: 'components.signUp.errors.generic', + resolveTranslations: false, + }, + meta, + ); + + return { + ...response, + data: { + ...(response.data as any), + components, + }, + } as EmbeddedSignUpFlowResponse; + } + + return response; + }, + [t, children], + ); + + /** + * Extract form fields from flow components + */ + const extractFormFields: any = useCallback( + (components: any[]): FormField[] => { + const fields: FormField[] = []; + + const processComponents = (comps: any[]): any => { + comps.forEach((component: any) => { + if ( + component.type === EmbeddedFlowComponentType.TextInput || + component.type === EmbeddedFlowComponentType.PasswordInput || + component.type === EmbeddedFlowComponentType.EmailInput || + component.type === EmbeddedFlowComponentType.Select || + component.type === EmbeddedFlowComponentType.DateInput + ) { + // Use component.ref (mapped identifier) as the field name instead of component.id + // This ensures form field names match what the input components use + const fieldName: any = component.ref || component.id; + const ruleValidator = buildValidatorFromRules(component.validation); + + fields.push({ + initialValue: '', + name: fieldName, + required: component.required || false, + validator: (value: string) => { + if (component.required && (!value || value.trim() === '')) { + return t('validations.required.field.error'); + } + // Add email validation if it's an email field + if ( + (component.type === EmbeddedFlowComponentType.EmailInput || component.variant === 'EMAIL') && + value && + !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) + ) { + return t('field.email.invalid'); + } + // Evaluate declarative validation rules from meta.components[].validation. + if (ruleValidator && value) { + const ruleMessage = ruleValidator(value); + if (ruleMessage) { + return t(ruleMessage); + } + } + + return null; + }, + }); + } + + if (component.components && Array.isArray(component.components)) { + processComponents(component.components); + } + }); + }; + + processComponents(components); + return fields; + }, + [t], + ); + + const formFields: any = (currentFlow?.data as any)?.components + ? extractFormFields((currentFlow!.data as any).components) + : []; + + const form: any = useForm>({ + fields: formFields, + initialValues: {}, + requiredMessage: t('validations.required.field.error'), + validateOnBlur: true, + validateOnChange: false, + }); + + const { + values: formValues, + touched: touchedFields, + errors: formErrors, + isValid: isFormValid, + setValue: setFormValue, + setTouched: setFormTouched, + setErrors: setFormErrors, + clearErrors: clearFormErrors, + validateForm, + touchAllFields, + reset: resetForm, + } = form; + + /** + * Project server-side validation errors from the most recent flow response into the + * form's `errors` state. See BaseSignIn for the same pattern: first error per field + * wins, and the affected fields are marked touched so the error renders immediately. + */ + useEffect(() => { + clearFormErrors(); + const responseFieldErrors: FieldError[] | undefined = (currentFlow?.data as any)?.fieldErrors; + if (!responseFieldErrors || responseFieldErrors.length === 0) { + return; + } + const errors: Record = {}; + for (const fe of responseFieldErrors) { + if (!(fe.identifier in errors)) { + errors[fe.identifier] = fe.message; + } + } + setFormErrors(errors); + Object.keys(errors).forEach((field: string) => setFormTouched(field, true)); + }, [currentFlow, setFormErrors, setFormTouched, clearFormErrors]); + + /** + * Setup form fields based on the current flow. + */ + const setupFormFields: any = useCallback( + (flowResponse: EmbeddedSignUpFlowResponse) => { + const fields: any = extractFormFields((flowResponse.data as any)?.components || []); + const initialValues: Record = {}; + + fields.forEach((field: any) => { + initialValues[field.name] = field.initialValue || ''; + }); + + resetForm(); + + Object.keys(initialValues).forEach((key: any) => { + setFormValue(key, initialValues[key]); + }); + }, + [extractFormFields, resetForm, setFormValue], + ); + + /** + * Determine whether a completed flow finished on a display-only screen. + * Such a completion must be rendered, not redirected past. + */ + const isDisplayOnlyCompletion = (response: EmbeddedSignUpFlowResponse): boolean => { + const data: any = response?.data; + const components: unknown[] | undefined = data?.components ?? data?.meta?.components; + + return ( + response?.flowStatus === EmbeddedSignUpFlowStatus.Complete && + Array.isArray(components) && + components.length > 0 && + !(response as {assertion?: string})?.assertion && + !data?.redirectURL && + !(response as any)?.redirectUrl + ); + }; + + /** + * Handle a completed flow. A flow can complete on a display-only screen; in + * that case render the screen and skip onComplete so the wrapper does not + * immediately redirect away from it. Otherwise hand off to onComplete. + */ + const handleFlowCompletion = (response: EmbeddedSignUpFlowResponse): void => { + if (isDisplayOnlyCompletion(response)) { + const normalized: any = normalizeFlowResponseLocal(response); + setCurrentFlow(normalized); + setupFormFields(normalized); + return; + } + + onComplete?.(response); + }; + + /** + * Handle input value changes. + * Only updates the value without marking as touched. + * Touched state is set on blur to avoid premature validation. + */ + const handleInputChange = (name: string, value: string): void => { + setFormValue(name, value); + }; + + /** + * Handle input blur event. + * Marks the field as touched, which triggers validation. + */ + const handleInputBlur = (name: string): void => { + setFormTouched(name, true); + }; + + /** + * Check if the response contains a redirection URL and perform the redirect if necessary. + * @param response - The sign-up response + * @returns true if a redirect was performed, false otherwise + */ + const handleRedirectionIfNeeded = (response: EmbeddedSignUpFlowResponse): boolean => { + if (response?.type === EmbeddedSignUpFlowType.Redirection && (response?.data as any)?.redirectURL) { + /** + * Open a popup window to handle redirection prompts for social sign-up + */ + const redirectUrl: any = (response.data as any).redirectURL; + const popup: any = window.open(redirectUrl, 'oauth_popup', 'width=500,height=600,scrollbars=yes,resizable=yes'); + + if (!popup) { + logger.error('Failed to open popup window'); + return false; + } + + let hasProcessedCallback: any = false; // Prevent multiple processing + let popupMonitor: ReturnType | null = null; + let messageHandler: ((event: MessageEvent) => Promise) | null = null; + + /** + * Clean up event listener and popup monitor + */ + const cleanup = (): void => { + if (messageHandler) { + window.removeEventListener('message', messageHandler); + } + if (popupMonitor) { + clearInterval(popupMonitor); + } + }; -const BaseSignUp: FC = (props: BaseSignUpProps) => { - const {platform} = useThunderID(); + /** + * Add an event listener to the window to capture the message from the popup + */ + messageHandler = async function messageEventHandler(event: MessageEvent): Promise { + /** + * Check if the message is from our popup window + */ + if (event.source !== popup) { + return; + } - if (platform === Platform.ThunderID) { - return ; + /** + * Check the origin of the message to ensure it's from a trusted source + */ + const expectedOrigin: any = afterSignUpUrl ? new URL(afterSignUpUrl).origin : window.location.origin; + if (event.origin !== expectedOrigin && event.origin !== window.location.origin) { + return; + } + + const {code, state} = event.data; + + if (code && state) { + hasProcessedCallback = true; + + const payload: EmbeddedSignUpFlowRequest = { + ...(currentFlow?.executionId && {executionId: currentFlow.executionId}), + inputs: { + code, + state, + }, + ...(challengeTokenRef.current ? {challengeToken: challengeTokenRef.current} : {}), + }; + + try { + const continueResponse: any = await onSubmit!(payload); + onFlowChange?.(continueResponse); + + if (continueResponse.flowStatus === EmbeddedSignUpFlowStatus.Error) { + handleError(continueResponse); + onError?.(continueResponse); + } else if (continueResponse.flowStatus === EmbeddedSignUpFlowStatus.Complete) { + handleFlowCompletion(continueResponse as EmbeddedSignUpFlowResponse); + } else if (continueResponse.flowStatus === EmbeddedSignUpFlowStatus.Incomplete) { + const normalizedContinueResponse: any = normalizeFlowResponseLocal(continueResponse); + setCurrentFlow(normalizedContinueResponse); + setupFormFields(normalizedContinueResponse); + + // Display error from INCOMPLETE response + if (normalizedContinueResponse?.error) { + handleError(normalizedContinueResponse); + } + } + + popup.close(); + cleanup(); + } catch (err) { + handleError(err); + onError?.(err as Error); + popup.close(); + cleanup(); + } + } + }; + + window.addEventListener('message', messageHandler); + + /** + * Monitor popup for closure and URL changes + */ + popupMonitor = setInterval(async () => { + try { + if (popup.closed) { + cleanup(); + return; + } + + // Skip if we've already processed a callback + if (hasProcessedCallback) { + return; + } + + // Try to access popup URL to check for callback + try { + const popupUrl: any = popup.location.href; + + // Check if we've been redirected to the callback URL + if (popupUrl && (popupUrl.includes('code=') || popupUrl.includes('error='))) { + hasProcessedCallback = true; // Set flag to prevent multiple processing + + // Parse the URL for OAuth parameters + const url: any = new URL(popupUrl); + const code: any = url.searchParams.get('code'); + const state: any = url.searchParams.get('state'); + const error: any = url.searchParams.get('error'); + + if (error) { + logger.error('OAuth error:'); + popup.close(); + cleanup(); + return; + } + + if (code && state) { + const payload: EmbeddedSignUpFlowRequest = { + ...(currentFlow?.executionId && {executionId: currentFlow.executionId}), + inputs: { + code, + state, + }, + ...(challengeTokenRef.current ? {challengeToken: challengeTokenRef.current} : {}), + }; + + try { + const continueResponse: any = await onSubmit!(payload); + onFlowChange?.(continueResponse); + + if (continueResponse.flowStatus === EmbeddedSignUpFlowStatus.Error) { + handleError(continueResponse); + onError?.(continueResponse); + } else if (continueResponse.flowStatus === EmbeddedSignUpFlowStatus.Complete) { + handleFlowCompletion(continueResponse as EmbeddedSignUpFlowResponse); + } else if (continueResponse.flowStatus === EmbeddedSignUpFlowStatus.Incomplete) { + const normalizedContinueResponse: any = normalizeFlowResponseLocal(continueResponse); + setCurrentFlow(normalizedContinueResponse); + setupFormFields(normalizedContinueResponse); + + // Display error from INCOMPLETE response + if (normalizedContinueResponse?.error) { + handleError(normalizedContinueResponse); + } + } + + popup.close(); + } catch (err) { + handleError(err); + onError?.(err as Error); + popup.close(); + } + } + } + } catch (e) { + // Cross-origin error is expected when popup navigates to OAuth provider + // This is normal and we can ignore it + } + } catch (e) { + logger.error('Error monitoring popup:'); + } + }, 1000); + + return true; + } + + return false; + }; + + /** + * Handle component submission (for buttons outside forms). + */ + const handleSubmit = async (component: any, data?: Record, skipValidation?: boolean): Promise => { + if (!currentFlow) { + return; + } + + // Only validate for form submit actions, skip for social/trigger actions + if (!skipValidation) { + // Mark all fields as touched before validation + touchAllFields(); + + const validation: ReturnType = validateForm(); + + if (!validation.isValid) { + return; + } + } + + setIsLoading(true); + setApiError(null); + clearMessages(); + + try { + // Filter out empty or undefined input values + const filteredInputs: Record = {}; + if (data) { + Object.entries(data).forEach(([key, value]: [string, any]) => { + if (value !== null && value !== undefined && value !== '') { + filteredInputs[key] = value; + } + }); + } + + const payload: EmbeddedSignUpFlowRequest = { + ...(currentFlow.executionId && {executionId: currentFlow.executionId}), + ...(component.id && {action: component.id}), + ...(challengeTokenRef.current ? {challengeToken: challengeTokenRef.current} : {}), + inputs: filteredInputs, + }; + + const rawResponse: any = await onSubmit!(payload); + const response: any = normalizeFlowResponseLocal(rawResponse); + onFlowChange?.(response); + + await setChallengeToken(response.challengeToken ?? null); + + if (response.flowStatus === EmbeddedSignUpFlowStatus.Error) { + handleError(response); + onError?.(new Error(extractErrorMessage(response, t))); + return; + } + + if (response.flowStatus === EmbeddedSignUpFlowStatus.Complete) { + handleFlowCompletion(response as EmbeddedSignUpFlowResponse); + return; + } + + if (response.flowStatus === EmbeddedSignUpFlowStatus.Incomplete) { + if (handleRedirectionIfNeeded(response)) { + return; + } + + if (response.data?.additionalData?.passkeyCreationOptions) { + const {passkeyCreationOptions}: any = response.data.additionalData; + const effectiveExecutionIdForPasskey: any = response.executionId ?? currentFlow?.executionId; + + // Reset passkey processed ref to allow processing + passkeyProcessedRef.current = false; + + // Set passkey state to trigger the passkey + setPasskeyState({ + actionId: component.id || 'submit', + creationOptions: passkeyCreationOptions, + error: null, + executionId: effectiveExecutionIdForPasskey, + isActive: true, + }); + setIsLoading(false); + return; + } + setCurrentFlow(response); + setupFormFields(response); + + // Display error from INCOMPLETE response + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + if (response?.error) { + handleError(response); + } + } + } catch (err) { + handleError(err); + onError?.(err as Error); + } finally { + setIsLoading(false); + } + }; + + /** + * Handle passkey registration when passkey state becomes active. + * This effect auto-triggers the browser passkey popup and submits the result. + */ + useEffect(() => { + if (!passkeyState.isActive || !passkeyState.creationOptions || !passkeyState.executionId) { + return; + } + + // Prevent re-processing + if (passkeyProcessedRef.current) { + return; + } + passkeyProcessedRef.current = true; + + const performPasskeyRegistration = async (): Promise => { + const passkeyResponse: any = await handlePasskeyRegistration(passkeyState.creationOptions!); + const passkeyResponseObj: any = JSON.parse(passkeyResponse); + + const inputs: any = { + attestationObject: passkeyResponseObj.response.attestationObject, + clientDataJSON: passkeyResponseObj.response.clientDataJSON, + credentialId: passkeyResponseObj.id, + }; + + // After successful registration, submit the result to the server + const payload: EmbeddedSignUpFlowRequest = { + executionId: passkeyState.executionId ?? undefined, + inputs, + ...(challengeTokenRef.current ? {challengeToken: challengeTokenRef.current} : {}), + } as any; + + const nextResponse: any = await onSubmit!(payload); + const processedResponse: any = normalizeFlowResponseLocal(nextResponse); + onFlowChange?.(processedResponse); + + if (processedResponse.flowStatus === EmbeddedSignUpFlowStatus.Complete) { + handleFlowCompletion(processedResponse as EmbeddedSignUpFlowResponse); + } else { + setCurrentFlow(processedResponse); + setupFormFields(processedResponse); + } + }; + + performPasskeyRegistration() + .then(() => { + setPasskeyState({actionId: null, creationOptions: null, error: null, executionId: null, isActive: false}); + }) + .catch((error: any) => { + setPasskeyState((prev: any) => ({...prev, error: error as Error, isActive: false})); + handleError(error); + onError?.(error as Error); + }); + }, [passkeyState.isActive, passkeyState.creationOptions, passkeyState.executionId]); + + const containerClasses: any = cx( + [ + withVendorCSSClassPrefix('signup'), + withVendorCSSClassPrefix(`signup--${size}`), + withVendorCSSClassPrefix(`signup--${variant}`), + ], + className, + ); + + const inputClasses: any = cx( + [ + withVendorCSSClassPrefix('signup__input'), + size === 'small' && withVendorCSSClassPrefix('signup__input--small'), + size === 'large' && withVendorCSSClassPrefix('signup__input--large'), + ], + inputClassName, + ); + + const buttonClasses: any = cx( + [ + withVendorCSSClassPrefix('signup__button'), + size === 'small' && withVendorCSSClassPrefix('signup__button--small'), + size === 'large' && withVendorCSSClassPrefix('signup__button--large'), + ], + buttonClassName, + ); + + const errorClasses: any = cx([withVendorCSSClassPrefix('signup__error')], errorClassName); + + const messageClasses: any = cx([withVendorCSSClassPrefix('signup__messages')], messageClassName); + + /** + * Render form components based on flow data using the factory + */ + const renderComponents: any = useCallback( + (components: any[]): ReactElement[] => + renderSignUpComponents( + components, + formValues, + touchedFields, + formErrors, + isLoading, + isFormValid, + handleInputChange, + { + _customRenderers: customRenderers, + _theme: theme, + buttonClassName: buttonClasses, + inputClassName: inputClasses, + onInputBlur: handleInputBlur, + onSubmit: handleSubmit, + size, + variant, + }, + ), + [ + customRenderers, + formValues, + touchedFields, + formErrors, + isFormValid, + isLoading, + size, + theme, + variant, + inputClasses, + buttonClasses, + handleSubmit, + handleInputBlur, + ], + ); + + /** + * Parse URL parameters to check for OAuth redirect state. + */ + const getUrlParams = (): any => { + const urlParams: any = new URL(window?.location?.href ?? '').searchParams; + return { + code: urlParams.get('code'), + error: urlParams.get('error'), + state: urlParams.get('state'), + }; + }; + + // Initialize the flow on component mount + useEffect(() => { + // Skip initialization if we're in an OAuth redirect state. + const urlParams: any = getUrlParams(); + if (urlParams.code || urlParams.state) { + return; + } + + if (isInitialized && isStorageReady && !isFlowInitialized && !initializationAttemptedRef.current) { + initializationAttemptedRef.current = true; + + (async (): Promise => { + setIsLoading(true); + setApiError(null); + clearMessages(); + + try { + const payload: any = challengeTokenRef.current ? {challengeToken: challengeTokenRef.current} : undefined; + const rawResponse: any = await onInitialize?.(payload); + const response: any = normalizeFlowResponseLocal(rawResponse); + + await setChallengeToken(response.challengeToken ?? null); + setCurrentFlow(response); + setIsFlowInitialized(true); + onFlowChange?.(response); + + // Clean up executionId and applicationId from URL after storing in state + if (window?.location?.href) { + const url: URL = new URL(window.location.href); + url.searchParams.delete('executionId'); + url.searchParams.delete('applicationId'); + window.history.replaceState({}, '', url.toString()); + } + + if (response.flowStatus === EmbeddedSignUpFlowStatus.Error) { + handleError(response); + onError?.(new Error(extractErrorMessage(response, t))); + return; + } + + if (response.flowStatus === EmbeddedSignUpFlowStatus.Complete) { + handleFlowCompletion(response as EmbeddedSignUpFlowResponse); + return; + } + + if (response.flowStatus === EmbeddedSignUpFlowStatus.Incomplete) { + setupFormFields(response); + + // Display error from INCOMPLETE response + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + if (response?.error) { + handleError(response); + } + } + } catch (err) { + handleError(err); + onError?.(err as Error); + } finally { + setIsLoading(false); + } + })(); + } + }, [ + isInitialized, + isStorageReady, + isFlowInitialized, + onInitialize, + onComplete, + onError, + onFlowChange, + setupFormFields, + normalizeFlowResponseLocal, + afterSignUpUrl, + t, + ]); + + // If render props are provided, use them + if (children) { + const renderProps: BaseSignUpRenderProps = { + components: (currentFlow?.data as any)?.components || [], + error: apiError, + fieldErrors: formErrors, + handleInputChange, + handleSubmit, + isLoading, + isValid: isFormValid, + messages: flowMessages || [], + subtitle: flowSubtitle || t('signup.subheading'), + title: flowTitle || t('signup.heading'), + touched: touchedFields, + validateForm: () => { + const result: any = validateForm(); + return {fieldErrors: result.errors, isValid: result.isValid}; + }, + values: formValues, + }; + + return
{children(renderProps)}
; } - return ; + if (!isFlowInitialized && isLoading) { + return ( + + +
+ +
+
+
+ ); + } + + if (!currentFlow) { + return ( + + + + {t('errors.heading')} + {t('errors.signup.flow.initialization.failure')} + + + + ); + } + + // Extract heading and subheading components and filter them from the main components + const componentsToRender: any = (currentFlow.data as any)?.components || []; + const {title, subtitle, componentsWithoutHeadings} = getAuthComponentHeadings( + componentsToRender, + flowTitle, + flowSubtitle, + t('signup.heading'), + t('signup.subheading'), + ); + + return ( + + {(showTitle || showSubtitle) && ( + + {showTitle && ( + + {title} + + )} + {showSubtitle && ( + + {subtitle} + + )} + + )} + + {externalError && ( +
+ + {externalError.message} + +
+ )} + {flowMessages && flowMessages.length > 0 && ( +
+ {flowMessages.map((message: any, index: number) => ( + + {message.message} + + ))} +
+ )} +
+ {componentsWithoutHeadings && componentsWithoutHeadings.length > 0 ? ( + renderComponents(componentsWithoutHeadings) + ) : ( + + {t('errors.signup.components.not.available')} + + )} +
+
+
+ ); +}; + +/** + * BaseSignUp component that provides embedded sign-up flow for ThunderIDV2. + * This component handles both the presentation layer and sign-up flow logic. + * It accepts API functions as props to maintain framework independence. + */ +const BaseSignUp: FC = ({preferences, showLogo = true, ...rest}: BaseSignUpProps): ReactElement => { + const {theme, colorScheme} = useTheme(); + const styles: any = useStyles(theme, colorScheme); + + const content: ReactElement = ( +
+ {showLogo && ( +
+ +
+ )} + + + +
+ ); + + if (!preferences) return content; + + return {content}; }; export default BaseSignUp; diff --git a/packages/react/src/components/presentation/auth/SignUp/SignUp.tsx b/packages/react/src/components/presentation/auth/SignUp/SignUp.tsx index 527e538..71a70ba 100644 --- a/packages/react/src/components/presentation/auth/SignUp/SignUp.tsx +++ b/packages/react/src/components/presentation/auth/SignUp/SignUp.tsx @@ -1,5 +1,5 @@ /** - * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com). + * Copyright (c) 2025-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 @@ -16,96 +16,139 @@ * under the License. */ -import {Platform} from '@thunderid/browser'; -import {FC} from 'react'; -import SignUpV1, {SignUpProps as SignUpV1Props} from './v1/SignUp'; -import SignUpV2, {SignUpProps as SignUpV2Props} from './v2/SignUp'; +import { + EmbeddedSignUpFlowRequest, + EmbeddedSignUpFlowResponse, + EmbeddedSignUpFlowType, + EmbeddedFlowType, +} from '@thunderid/browser'; +import {FC, ReactElement, ReactNode} from 'react'; +// eslint-disable-next-line import/no-named-as-default +import BaseSignUp, {BaseSignUpProps, BaseSignUpRenderProps} from './BaseSignUp'; import useThunderID from '../../../../contexts/ThunderID/useThunderID'; +/** + * Render props function parameters (re-exported from BaseSignUp for convenience) + */ +export type SignUpRenderProps = BaseSignUpRenderProps; + /** * Props for the SignUp component. - * Extends SignUpV1Props & SignUpV2Props for full compatibility with both React SignUp components. */ -export type SignUpProps = SignUpV1Props | SignUpV2Props; +export type SignUpProps = BaseSignUpProps & { + /** + * Render props function for custom UI + */ + children?: (props: SignUpRenderProps) => ReactNode; +}; /** - * A styled SignUp component that provides embedded sign-up flow with pre-built styling. - * This component routes to the appropriate version-specific implementation based on the platform. - * - * @example - * // Default UI - * ```tsx - * import { SignUp } from '@thunderid/react'; - * - * const App = () => { - * return ( - * { - * console.log('Sign-up successful:', response); - * // Handle successful sign-up (e.g., redirect, show confirmation) - * }} - * onError={(error) => { - * console.error('Sign-up failed:', error); - * }} - * onComplete={(redirectUrl) => { - * // Platform-specific redirect handling (e.g., Next.js router.push) - * router.push(redirectUrl); // or window.location.href = redirectUrl - * }} - * size="medium" - * variant="outlined" - * afterSignUpUrl="/welcome" - * /> - * ); - * }; - * ``` - * - * @example - * // Custom UI with render props - * ```tsx - * import { SignUp } from '@thunderid/react'; - * - * const App = () => { - * return ( - * console.error('Error:', error)} - * onComplete={(response) => console.log('Success:', response)} - * > - * {({values, errors, handleInputChange, handleSubmit, isLoading, components}) => ( - *
- *

Custom Sign Up

- * {isLoading ? ( - *

Loading...

- * ) : ( - *
{ - * e.preventDefault(); - * handleSubmit(components[0], values); - * }}> - * handleInputChange('username', e.target.value)} - * /> - * {errors.username && {errors.username}} - * - *
- * )} - *
- * )} - *
- * ); - * }; - * ``` + * A styled SignUp component for ThunderIDV2 (AKA Thunder) platform that provides embedded sign-up flow with pre-built styling. + * This component handles the API calls for sign-up and delegates UI logic to BaseSignUp. */ -const SignUp: FC = (props: SignUpProps) => { - const {platform} = useThunderID(); +const SignUp: FC = ({ + className, + size = 'medium', + afterSignUpUrl, + onError, + onComplete, + shouldRedirectAfterSignUp = true, + children, + ...rest +}: SignUpProps): ReactElement => { + const {signUp, isInitialized, applicationId, scopes} = useThunderID(); + + /** + * Initialize the sign-up flow. + */ + const handleInitialize = async (payload?: EmbeddedSignUpFlowRequest): Promise => { + const urlParams: URLSearchParams = new URL(window.location.href).searchParams; + const executionIdFromUrl: string = urlParams.get('executionId') || ''; + const applicationIdFromUrl: string = urlParams.get('applicationId') ?? ''; + + const effectiveApplicationId: any = applicationId ?? applicationIdFromUrl; + + const challengeToken: string | undefined = (payload as any)?.challengeToken; + + let initialPayload: EmbeddedSignUpFlowRequest | any; + if (executionIdFromUrl) { + initialPayload = { + executionId: executionIdFromUrl, + ...(challengeToken ? {challengeToken} : {}), + }; + } else if (!payload || !('flowType' in payload)) { + initialPayload = { + ...(payload || {}), + flowType: EmbeddedFlowType.Registration, + ...(effectiveApplicationId && {applicationId: effectiveApplicationId}), + ...(scopes && {scopes}), + }; + } else { + initialPayload = payload; + } + + return (await signUp(initialPayload)) as EmbeddedSignUpFlowResponse; + }; + + /** + * Handle sign-up steps. + */ + const handleOnSubmit = async (payload: EmbeddedSignUpFlowRequest): Promise => + (await signUp(payload)) as EmbeddedSignUpFlowResponse; + + /** + * Handle successful sign-up and redirect. + */ + const handleComplete = (response: EmbeddedSignUpFlowResponse): any => { + onComplete?.(response); + + if (!shouldRedirectAfterSignUp) { + return; + } + + const redirectURL: string | undefined = (response?.data as Record)?.['redirectURL'] as + | string + | undefined; + + if ( + response?.type === EmbeddedSignUpFlowType.Redirection && + redirectURL && + !redirectURL.includes('oauth') && // Not a social provider redirect + !redirectURL.includes('auth') // Not an auth provider redirect + ) { + window.location.href = redirectURL; + return; + } + + const oauthRedirectUrl: any = (response as any)?.redirectUrl; + if (oauthRedirectUrl) { + window.location.href = oauthRedirectUrl; + return; + } - if (platform === Platform.ThunderID) { - return ; - } + // For non-redirection responses (regular sign-up completion), handle redirect if configured. + // Skip when assertion is present โ€” the SDK stored the session and the caller handled navigation. + if (response?.type !== EmbeddedSignUpFlowType.Redirection && afterSignUpUrl && !(response as any)?.assertion) { + window.location.href = afterSignUpUrl; + } + }; - return ; + return ( + + ); }; export default SignUp; diff --git a/packages/react/src/components/presentation/auth/SignUp/v1/BaseSignUp.tsx b/packages/react/src/components/presentation/auth/SignUp/v1/BaseSignUp.tsx deleted file mode 100644 index f1335a2..0000000 --- a/packages/react/src/components/presentation/auth/SignUp/v1/BaseSignUp.tsx +++ /dev/null @@ -1,836 +0,0 @@ -/** - * 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 {cx} from '@emotion/css'; -import { - EmbeddedFlowExecuteRequestPayload, - EmbeddedFlowExecuteResponse, - EmbeddedFlowStatus, - EmbeddedFlowComponentType, - EmbeddedFlowResponseType, - withVendorCSSClassPrefix, - createPackageComponentLogger, -} from '@thunderid/browser'; -import {FC, ReactElement, ReactNode, useEffect, useState, useCallback, useRef} from 'react'; -import {renderSignUpComponents} from './SignUpOptionFactory'; -import FlowProvider from '../../../../../contexts/Flow/FlowProvider'; -import useFlow from '../../../../../contexts/Flow/useFlow'; -import useTheme from '../../../../../contexts/Theme/useTheme'; -import useThunderID from '../../../../../contexts/ThunderID/useThunderID'; -import {useForm, FormField} from '../../../../../hooks/useForm'; -import useTranslation from '../../../../../hooks/useTranslation'; -import AlertPrimitive from '../../../../primitives/Alert/Alert'; -// eslint-disable-next-line import/no-named-as-default -import CardPrimitive, {CardProps} from '../../../../primitives/Card/Card'; -import Logo from '../../../../primitives/Logo/Logo'; -import Spinner from '../../../../primitives/Spinner/Spinner'; -import Typography from '../../../../primitives/Typography/Typography'; -import useStyles from '../BaseSignUp.styles'; - -const logger: ReturnType = createPackageComponentLogger( - '@thunderid/react', - 'BaseSignUp', -); - -/** - * Render props for custom UI rendering - */ -export interface BaseSignUpRenderProps { - /** - * Flow components - */ - components: any[]; - - /** - * Form errors - */ - errors: Record; - - /** - * Function to handle input changes - */ - handleInputChange: (name: string, value: string) => void; - - /** - * Function to handle form submission - */ - handleSubmit: (component: any, data?: Record) => Promise; - - /** - * Loading state - */ - isLoading: boolean; - - /** - * Whether the form is valid - */ - isValid: boolean; - - /** - * Flow messages - */ - messages: {message: string; type: string}[]; - - /** - * Flow subtitle - */ - subtitle: string; - - /** - * Flow title - */ - title: string; - - /** - * Touched fields - */ - touched: Record; - - /** - * Function to validate the form - */ - validateForm: () => {errors: Record; isValid: boolean}; - - /** - * Form values - */ - values: Record; -} - -/** - * Props for the BaseSignUp component. - */ -export interface BaseSignUpProps { - /** - * URL to redirect after successful sign-up. - */ - afterSignUpUrl?: string; - - /** - * Custom CSS class name for the submit button. - */ - buttonClassName?: string; - - /** - * Render props function for custom UI - */ - children?: (props: BaseSignUpRenderProps) => ReactNode; - - /** - * Custom CSS class name for the form container. - */ - className?: string; - - /** - * Custom CSS class name for error messages. - */ - errorClassName?: string; - - /** - * Custom CSS class name for form inputs. - */ - inputClassName?: string; - - isInitialized?: boolean; - - /** - * Custom CSS class name for info messages. - */ - messageClassName?: string; - - /** - * Callback function called when the sign-up flow completes and requires redirection. - * This allows platform-specific handling of redirects (e.g., Next.js router.push). - * @param response - The response from the sign-up flow containing the redirect URL, etc. - */ - onComplete?: (response: EmbeddedFlowExecuteResponse) => void; - - /** - * Callback function called when sign-up fails. - * @param error - The error that occurred during sign-up. - */ - onError?: (error: Error) => void; - - /** - * Callback function called when sign-up flow status changes. - * @param response - The current sign-up response. - */ - onFlowChange?: (response: EmbeddedFlowExecuteResponse) => void; - - /** - * Function to initialize sign-up flow. - * @returns Promise resolving to the initial sign-up response. - */ - onInitialize?: (payload?: EmbeddedFlowExecuteRequestPayload) => Promise; - - /** - * Function to handle sign-up steps. - * @param payload - The sign-up payload. - * @returns Promise resolving to the sign-up response. - */ - onSubmit?: (payload: EmbeddedFlowExecuteRequestPayload) => Promise; - /** - * Whether to redirect after sign-up. - */ - shouldRedirectAfterSignUp?: boolean; - - /** - * Whether to show the logo. - */ - showLogo?: boolean; - - /** - * Whether to show the subtitle. - */ - showSubtitle?: boolean; - - /** - * Whether to show the title. - */ - showTitle?: boolean; - - /** - * Size variant for the component. - */ - size?: 'small' | 'medium' | 'large'; - - /** - * Theme variant for the component. - */ - variant?: CardProps['variant']; -} - -/** - * Component that consumes FlowContext and renders the sign-up UI. - * - * @internal - */ -const BaseSignUpContent: FC = ({ - afterSignUpUrl, - onInitialize, - onSubmit, - onError, - onFlowChange, - onComplete, - className = '', - inputClassName = '', - buttonClassName = '', - errorClassName = '', - messageClassName = '', - size = 'medium', - variant = 'outlined', - isInitialized, - children, - showTitle = true, - showSubtitle = true, -}: BaseSignUpProps): ReactElement => { - const {theme, colorScheme} = useTheme(); - const {t} = useTranslation(); - const {subtitle: flowSubtitle, title: flowTitle, messages: flowMessages, addMessage, clearMessages} = useFlow(); - useThunderID(); - const styles: any = useStyles(theme, colorScheme); - - const handleError: any = useCallback( - (error: any) => { - let errorMessage: string = t('errors.signup.flow.failure'); - - if (error && typeof error === 'object') { - // Handle ThunderID error format with code and description/message - if (error.code && (error.message || error.description)) { - errorMessage = error.description || error.message; - } else if (error instanceof Error && error.name === 'ThunderIDAPIError') { - try { - const errorResponse: any = JSON.parse(error.message); - if (errorResponse.description) { - errorMessage = errorResponse.description; - } else if (errorResponse.message) { - errorMessage = errorResponse.message; - } else { - errorMessage = error.message; - } - } catch { - errorMessage = error.message; - } - } else if (error.message) { - errorMessage = error.message; - } - } else if (typeof error === 'string') { - errorMessage = error; - } - - // Clear existing messages and add the error message - clearMessages(); - addMessage({ - message: errorMessage, - type: 'error', - }); - }, - [t, addMessage, clearMessages], - ); - - const [isLoading, setIsLoading] = useState(false); - const [isFlowInitialized, setIsFlowInitialized] = useState(false); - const [currentFlow, setCurrentFlow] = useState(null); - - const initializationAttemptedRef: any = useRef(false); - - /** - * Extract form fields from flow components - */ - const extractFormFields: any = useCallback( - (components: any[]): FormField[] => { - const fields: FormField[] = []; - - const processComponents = (comps: any[]): any => { - comps.forEach((component: any) => { - if (component.type === EmbeddedFlowComponentType.Input) { - const config: any = component.config || {}; - fields.push({ - initialValue: config.defaultValue || '', - name: config.name || component.id, - required: config.required || false, - validator: (value: string) => { - if (config.required && (!value || value.trim() === '')) { - return t('validations.required.field.error'); - } - // Add email validation if it's an email field - if (config.type === 'email' && value && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) { - return t('field.email.invalid'); - } - // Add password strength validation if it's a password field - if (config.type === 'password' && value && value.length < 8) { - return t('field.password.weak'); - } - return null; - }, - }); - } - - if (component.components && Array.isArray(component.components)) { - processComponents(component.components); - } - }); - }; - - processComponents(components); - return fields; - }, - [t], - ); - - const formFields: any = currentFlow?.data?.components ? extractFormFields(currentFlow.data.components) : []; - - const form: any = useForm>({ - fields: formFields, - initialValues: {}, - requiredMessage: t('validations.required.field.error'), - validateOnBlur: true, - validateOnChange: true, - }); - - const { - values: formValues, - touched: touchedFields, - errors: formErrors, - isValid: isFormValid, - setValue: setFormValue, - setTouched: setFormTouched, - validateForm, - reset: resetForm, - } = form; - - /** - * Setup form fields based on the current flow. - */ - const setupFormFields: any = useCallback( - (flowResponse: EmbeddedFlowExecuteResponse) => { - const fields: any = extractFormFields(flowResponse.data?.components || []); - const initialValues: Record = {}; - - fields.forEach((field: any) => { - initialValues[field.name] = field.initialValue || ''; - }); - - resetForm(); - - Object.keys(initialValues).forEach((key: any) => { - setFormValue(key, initialValues[key]); - }); - }, - [extractFormFields, resetForm, setFormValue], - ); - - /** - * Handle input value changes. - */ - const handleInputChange = (name: string, value: string): void => { - setFormValue(name, value); - setFormTouched(name, true); - }; - - /** - * Check if the response contains a redirection URL and perform the redirect if necessary. - * @param response - The sign-up response - * @returns true if a redirect was performed, false otherwise - */ - const handleRedirectionIfNeeded = (response: EmbeddedFlowExecuteResponse): boolean => { - if (response?.type === EmbeddedFlowResponseType.Redirection && response?.data?.redirectURL) { - /** - * Open a popup window to handle redirection prompts for social sign-up - */ - const redirectUrl: any = response.data.redirectURL; - const popup: any = window.open(redirectUrl, 'oauth_popup', 'width=500,height=600,scrollbars=yes,resizable=yes'); - - if (!popup) { - logger.error('Failed to open popup window'); - return false; - } - - /** - * Use `let` for messageHandler and popupMonitor to resolve circular references: - * messageHandler <-> cleanup <-> popupMonitor. - * All are assigned before any of them can be invoked at runtime. - */ - let hasProcessedCallback: any = false; // Prevent multiple processing - let popupMonitor: any; - let messageHandler: (event: MessageEvent) => Promise; - - const cleanup = (): void => { - window.removeEventListener('message', messageHandler); - if (popupMonitor) { - clearInterval(popupMonitor); - } - }; - - /** - * Add an event listener to the window to capture the message from the popup - */ - messageHandler = async function messageEventHandler(event: MessageEvent): Promise { - /** - * Check if the message is from our popup window - */ - if (event.source !== popup) { - return; - } - - /** - * Check the origin of the message to ensure it's from a trusted source - */ - const expectedOrigin: any = afterSignUpUrl ? new URL(afterSignUpUrl).origin : window.location.origin; - if (event.origin !== expectedOrigin && event.origin !== window.location.origin) { - return; - } - - const {code, state} = event.data; - - if (code && state) { - const payload: EmbeddedFlowExecuteRequestPayload = { - ...(currentFlow!.flowId && {flowId: currentFlow!.flowId}), - actionId: '', - flowType: (currentFlow as any).flowType || 'REGISTRATION', - inputs: { - code, - state, - }, - } as any; - - try { - const continueResponse: any = await onSubmit!(payload); - onFlowChange?.(continueResponse); - - if (continueResponse.flowStatus === EmbeddedFlowStatus.Complete) { - onComplete?.(continueResponse); - } else if (continueResponse.flowStatus === EmbeddedFlowStatus.Incomplete) { - setCurrentFlow(continueResponse); - setupFormFields(continueResponse); - } - - popup.close(); - cleanup(); - } catch (err) { - handleError(err); - onError?.(err as Error); - popup.close(); - cleanup(); - } - } - }; - - window.addEventListener('message', messageHandler); - - /** - * Monitor popup for closure and URL changes - */ - popupMonitor = setInterval(async () => { - try { - if (popup.closed) { - cleanup(); - return; - } - - // Skip if we've already processed a callback - if (hasProcessedCallback) { - return; - } - - // Try to access popup URL to check for callback - try { - const popupUrl: any = popup.location.href; - - // Check if we've been redirected to the callback URL - if (popupUrl && (popupUrl.includes('code=') || popupUrl.includes('error='))) { - hasProcessedCallback = true; // Set flag to prevent multiple processing - - // Parse the URL for OAuth parameters - const url: any = new URL(popupUrl); - const code: any = url.searchParams.get('code'); - const state: any = url.searchParams.get('state'); - const error: any = url.searchParams.get('error'); - - if (error) { - logger.error('OAuth error:'); - popup.close(); - cleanup(); - return; - } - - if (code && state) { - const payload: EmbeddedFlowExecuteRequestPayload = { - ...(currentFlow!.flowId && {flowId: currentFlow!.flowId}), - actionId: '', - flowType: (currentFlow as any).flowType || 'REGISTRATION', - inputs: { - code, - state, - }, - } as any; - - try { - const continueResponse: any = await onSubmit!(payload); - onFlowChange?.(continueResponse); - - if (continueResponse.flowStatus === EmbeddedFlowStatus.Complete) { - onComplete?.(continueResponse); - } else if (continueResponse.flowStatus === EmbeddedFlowStatus.Incomplete) { - setCurrentFlow(continueResponse); - setupFormFields(continueResponse); - } - - popup.close(); - } catch (err) { - handleError(err); - onError?.(err as Error); - popup.close(); - } - } - } - } catch (e) { - // Cross-origin error is expected when popup navigates to OAuth provider - // This is normal and we can ignore it - } - } catch (e) { - logger.error('Error monitoring popup:'); - } - }, 1000); - - return true; - } - - return false; - }; - - /** - * Handle component submission (for buttons outside forms). - */ - const handleSubmit = async (component: any, data?: Record): Promise => { - if (!currentFlow) { - return; - } - - setIsLoading(true); - clearMessages(); - - try { - // Filter out empty or undefined input values - const filteredInputs: Record = {}; - if (data) { - Object.entries(data).forEach(([key, value]: [string, any]) => { - if (value !== null && value !== undefined && value !== '') { - filteredInputs[key] = value; - } - }); - } - - const actionId: string = component.id; - - const payload: EmbeddedFlowExecuteRequestPayload = { - ...(currentFlow.flowId && {flowId: currentFlow.flowId}), - flowType: (currentFlow as any).flowType || 'REGISTRATION', - inputs: filteredInputs, - ...(actionId && {actionId: actionId}), - } as any; - - const response: any = await onSubmit!(payload); - onFlowChange?.(response); - - if (response.flowStatus === EmbeddedFlowStatus.Complete) { - onComplete?.(response); - return; - } - - if (response.flowStatus === EmbeddedFlowStatus.Incomplete) { - if (handleRedirectionIfNeeded(response)) { - return; - } - - setCurrentFlow(response); - setupFormFields(response); - } - } catch (err) { - handleError(err); - onError?.(err as Error); - } finally { - setIsLoading(false); - } - }; - - const containerClasses: any = cx( - [ - withVendorCSSClassPrefix('signup'), - withVendorCSSClassPrefix(`signup--${size}`), - withVendorCSSClassPrefix(`signup--${variant}`), - ], - className, - ); - - const inputClasses: any = cx( - [ - withVendorCSSClassPrefix('signup__input'), - size === 'small' && withVendorCSSClassPrefix('signup__input--small'), - size === 'large' && withVendorCSSClassPrefix('signup__input--large'), - ], - inputClassName, - ); - - const buttonClasses: any = cx( - [ - withVendorCSSClassPrefix('signup__button'), - size === 'small' && withVendorCSSClassPrefix('signup__button--small'), - size === 'large' && withVendorCSSClassPrefix('signup__button--large'), - ], - buttonClassName, - ); - - const errorClasses: any = cx([withVendorCSSClassPrefix('signup__error')], errorClassName); - - const messageClasses: any = cx([withVendorCSSClassPrefix('signup__messages')], messageClassName); - - /** - * Render form components based on flow data using the factory - */ - const renderComponents: any = useCallback( - (components: any[]): ReactElement[] => - renderSignUpComponents( - components, - formValues, - touchedFields, - formErrors, - isLoading, - isFormValid, - handleInputChange, - { - buttonClassName: buttonClasses, - inputClassName: inputClasses, - onSubmit: handleSubmit, - size, - variant, - }, - ), - [ - formValues, - touchedFields, - formErrors, - isFormValid, - isLoading, - size, - variant, - inputClasses, - buttonClasses, - handleSubmit, - ], - ); - - // Initialize the flow on component mount - useEffect(() => { - if (isInitialized && !isFlowInitialized && !initializationAttemptedRef.current) { - initializationAttemptedRef.current = true; - - (async (): Promise => { - setIsLoading(true); - clearMessages(); - - try { - const response: any = await onInitialize?.(); - - setCurrentFlow(response); - setIsFlowInitialized(true); - onFlowChange?.(response); - - if (response.flowStatus === EmbeddedFlowStatus.Complete) { - onComplete?.(response); - return; - } - - if (response.flowStatus === EmbeddedFlowStatus.Incomplete) { - setupFormFields(response); - } - } catch (err) { - handleError(err); - onError?.(err as Error); - } finally { - setIsLoading(false); - } - })(); - } - }, [ - isInitialized, - isFlowInitialized, - onInitialize, - onComplete, - onError, - onFlowChange, - setupFormFields, - afterSignUpUrl, - t, - ]); - - // If render props are provided, use them - if (children) { - const renderProps: BaseSignUpRenderProps = { - components: currentFlow?.data?.components || [], - errors: formErrors, - handleInputChange, - handleSubmit, - isLoading, - isValid: isFormValid, - messages: flowMessages || [], - subtitle: flowSubtitle || t('signup.subheading'), - title: flowTitle || t('signup.heading'), - touched: touchedFields, - validateForm, - values: formValues, - }; - - return
{children(renderProps)}
; - } - - if (!isFlowInitialized && isLoading) { - return ( - - -
- -
-
-
- ); - } - - if (!currentFlow) { - return ( - - - - {t('errors.heading')} - {t('errors.signup.flow.initialization.failure')} - - - - ); - } - - return ( - - {(showTitle || showSubtitle) && ( - - {showTitle && ( - - {flowTitle || t('signup.heading')} - - )} - {showSubtitle && ( - - {flowSubtitle || t('signup.subheading')} - - )} - - )} - - {flowMessages && flowMessages.length > 0 && ( -
- {flowMessages.map((message: any, index: number) => ( - - {message.message} - - ))} -
- )} -
- {currentFlow.data?.components && currentFlow.data.components.length > 0 ? ( - renderComponents(currentFlow.data.components) - ) : ( - - {t('errors.signup.components.not.available')} - - )} -
-
-
- ); -}; - -/** - * BaseSignUp component that provides embedded sign-up flow for ThunderID. - * This component handles both the presentation layer and sign-up flow logic. - * It accepts API functions as props to maintain framework independence. - * - * @internal - */ -const BaseSignUp: FC = ({showLogo = true, ...rest}: BaseSignUpProps): ReactElement => { - const {theme, colorScheme} = useTheme(); - const styles: any = useStyles(theme, colorScheme); - - return ( -
- {showLogo && ( -
- -
- )} - - - -
- ); -}; - -export default BaseSignUp; diff --git a/packages/react/src/components/presentation/auth/SignUp/v1/SignUp.tsx b/packages/react/src/components/presentation/auth/SignUp/v1/SignUp.tsx deleted file mode 100644 index 926c706..0000000 --- a/packages/react/src/components/presentation/auth/SignUp/v1/SignUp.tsx +++ /dev/null @@ -1,123 +0,0 @@ -/** - * 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 { - EmbeddedFlowExecuteRequestPayload, - EmbeddedFlowExecuteResponse, - EmbeddedFlowResponseType, - EmbeddedFlowType, -} from '@thunderid/browser'; -import {FC, ReactElement, ReactNode} from 'react'; -// eslint-disable-next-line import/no-named-as-default -import BaseSignUp, {BaseSignUpProps, BaseSignUpRenderProps} from './BaseSignUp'; -import useThunderID from '../../../../../contexts/ThunderID/useThunderID'; - -/** - * Render props function parameters (re-exported from BaseSignUp for convenience) - */ -export type SignUpRenderProps = BaseSignUpRenderProps; - -/** - * Props for the SignUp component. - */ -export type SignUpProps = BaseSignUpProps & { - /** - * Render props function for custom UI - */ - children?: (props: SignUpRenderProps) => ReactNode; -}; - -/** - * A styled SignUp component for ThunderID platform that provides embedded sign-up flow with pre-built styling. - * This component handles the API calls for sign-up and delegates UI logic to BaseSignUp. - */ -const SignUp: FC = ({ - className, - size = 'medium', - afterSignUpUrl, - onError, - onComplete, - shouldRedirectAfterSignUp = true, - children, - ...rest -}: SignUpProps): ReactElement => { - const {signUp, isInitialized} = useThunderID(); - - /** - * Initialize the sign-up flow. - */ - const handleInitialize = async ( - payload?: EmbeddedFlowExecuteRequestPayload, - ): Promise => { - // Uses simple initialization without applicationId - const initialPayload: any = payload || { - flowType: EmbeddedFlowType.Registration, - }; - - return (await signUp(initialPayload)) as EmbeddedFlowExecuteResponse; - }; - - /** - * Handle sign-up steps. - */ - const handleOnSubmit = async (payload: EmbeddedFlowExecuteRequestPayload): Promise => - (await signUp(payload)) as EmbeddedFlowExecuteResponse; - - /** - * Handle successful sign-up and redirect. - */ - const handleComplete = (response: EmbeddedFlowExecuteResponse): any => { - onComplete?.(response); - - // For non-redirection responses (regular sign-up completion), handle redirect if configured - if (shouldRedirectAfterSignUp && response?.type !== EmbeddedFlowResponseType.Redirection && afterSignUpUrl) { - window.location.href = afterSignUpUrl; - } - - // For redirection responses (social sign-up), handle direct redirect - if ( - shouldRedirectAfterSignUp && - response?.type === EmbeddedFlowResponseType.Redirection && - response?.data?.redirectURL && - !response.data.redirectURL.includes('oauth') && // Not a social provider redirect - !response.data.redirectURL.includes('auth') // Not an auth provider redirect - ) { - window.location.href = response.data.redirectURL; - } - }; - - return ( - - ); -}; - -export default SignUp; diff --git a/packages/react/src/components/presentation/auth/SignUp/v1/SignUpOptionFactory.tsx b/packages/react/src/components/presentation/auth/SignUp/v1/SignUpOptionFactory.tsx deleted file mode 100644 index 4ea913f..0000000 --- a/packages/react/src/components/presentation/auth/SignUp/v1/SignUpOptionFactory.tsx +++ /dev/null @@ -1,227 +0,0 @@ -/** - * 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 {EmbeddedFlowComponent, EmbeddedFlowComponentType} from '@thunderid/browser'; -import {ReactElement} from 'react'; -import {AdapterProps} from '../../../../../models/adapters'; -import CheckboxInput from '../../../../adapters/CheckboxInput'; -import DateInput from '../../../../adapters/DateInput'; -import DividerComponent from '../../../../adapters/DividerComponent'; -import EmailInput from '../../../../adapters/EmailInput'; -import FacebookButton from '../../../../adapters/FacebookButton'; -// eslint-disable-next-line import/no-cycle -import FormContainer from '../../../../adapters/FormContainer'; -import GitHubButton from '../../../../adapters/GitHubButton'; -import GoogleButton from '../../../../adapters/GoogleButton'; -import ImageComponent from '../../../../adapters/ImageComponent'; -import LinkedInButton from '../../../../adapters/LinkedInButton'; -import MicrosoftButton from '../../../../adapters/MicrosoftButton'; -import NumberInput from '../../../../adapters/NumberInput'; -import PasswordInput from '../../../../adapters/PasswordInput'; -import SelectInput from '../../../../adapters/SelectInput'; -import SignInWithEthereumButton from '../../../../adapters/SignInWithEthereumButton'; -import ButtonComponent from '../../../../adapters/SubmitButton'; -import TelephoneInput from '../../../../adapters/TelephoneInput'; -import TextInput from '../../../../adapters/TextInput'; -import Typography from '../../../../adapters/Typography'; - -/** - * Creates the appropriate sign-up component based on the component type. - */ -export const createSignUpComponent = ({component, onSubmit, ...rest}: AdapterProps): ReactElement => { - switch (component.type) { - case EmbeddedFlowComponentType.Typography: - return ; - - case EmbeddedFlowComponentType.Input: { - // Determine input type based on variant or config - const inputVariant: string = component.variant?.toUpperCase() ?? ''; - const inputType: string = (component.config['type'] as string)?.toLowerCase() ?? ''; - - if (inputVariant === 'EMAIL' || inputType === 'email') { - return ; - } - - if (inputVariant === 'PASSWORD' || inputType === 'password') { - return ; - } - - if (inputVariant === 'TELEPHONE' || inputType === 'tel') { - return ; - } - - if (inputVariant === 'NUMBER' || inputType === 'number') { - return ; - } - - if (inputVariant === 'DATE' || inputType === 'date') { - return ; - } - - if (inputVariant === 'CHECKBOX' || inputType === 'checkbox') { - return ; - } - - return ; - } - - case EmbeddedFlowComponentType.Button: { - const buttonVariant: string | undefined = component.variant?.toUpperCase(); - const buttonText: string = (component.config['text'] as string) || (component.config['label'] as string) || ''; - - // TODO: The connection type should come as metadata. - if (buttonVariant === 'SOCIAL') { - if (buttonText.toLowerCase().includes('google')) { - return ( - onSubmit?.(component, {})} {...rest}> - {buttonText} - - ); - } - - if (buttonText.toLowerCase().includes('github')) { - return ( - onSubmit?.(component, {})} {...rest}> - {buttonText} - - ); - } - - if (buttonText.toLowerCase().includes('microsoft')) { - return ( - onSubmit?.(component, {})} {...rest}> - {buttonText} - - ); - } - - if (buttonText.toLowerCase().includes('facebook')) { - return ( - onSubmit?.(component, {})} {...rest}> - {buttonText} - - ); - } - - if (buttonText.toLowerCase().includes('linkedin')) { - return ( - onSubmit?.(component, {})} {...rest}> - {buttonText} - - ); - } - - if (buttonText.toLowerCase().includes('ethereum')) { - return ( - onSubmit?.(component, {})} {...rest}> - {buttonText} - - ); - } - } - - // Use the generic ButtonComponent for all other button variants - // It will handle PRIMARY, SECONDARY, TEXT, SOCIAL mappings internally - return ; - } - - case EmbeddedFlowComponentType.Form: - return ; - - case EmbeddedFlowComponentType.Select: - return ; - - case EmbeddedFlowComponentType.Divider: - return ; - - case EmbeddedFlowComponentType.Image: - return ; - - default: - return
; - } -}; - -/** - * Convenience function that creates the appropriate sign-up component from flow component data. - */ -export const createSignUpOptionFromComponent = ( - component: EmbeddedFlowComponent, - formValues: Record, - touchedFields: Record, - formErrors: Record, - isLoading: boolean, - isFormValid: boolean, - onInputChange: (name: string, value: string) => void, - options?: { - buttonClassName?: string; - inputClassName?: string; - key?: string | number; - onSubmit?: (component: EmbeddedFlowComponent, data?: Record) => void; - size?: 'small' | 'medium' | 'large'; - variant?: any; - }, -): ReactElement => - createSignUpComponent({ - component, - formErrors, - formValues, - isFormValid, - isLoading, - onInputChange, - touchedFields, - ...options, - }); - -/** - * Processes an array of components and renders them as React elements. - */ -export const renderSignUpComponents = ( - components: EmbeddedFlowComponent[], - formValues: Record, - touchedFields: Record, - formErrors: Record, - isLoading: boolean, - isFormValid: boolean, - onInputChange: (name: string, value: string) => void, - options?: { - buttonClassName?: string; - inputClassName?: string; - onSubmit?: (component: EmbeddedFlowComponent, data?: Record) => void; - size?: 'small' | 'medium' | 'large'; - variant?: any; - }, -): ReactElement[] => - components - .map((component: any, index: any) => - createSignUpOptionFromComponent( - component, - formValues, - touchedFields, - formErrors, - isLoading, - isFormValid, - onInputChange, - { - ...options, - // Use component id as key, fallback to index - key: component.id || index, - }, - ), - ) - .filter(Boolean); diff --git a/packages/react/src/components/presentation/auth/SignUp/v2/BaseSignUp.tsx b/packages/react/src/components/presentation/auth/SignUp/v2/BaseSignUp.tsx deleted file mode 100644 index 2fecc22..0000000 --- a/packages/react/src/components/presentation/auth/SignUp/v2/BaseSignUp.tsx +++ /dev/null @@ -1,1217 +0,0 @@ -/** - * Copyright (c) 2025-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 {cx} from '@emotion/css'; -import { - EmbeddedSignUpFlowRequestV2, - EmbeddedSignUpFlowResponseV2, - EmbeddedSignUpFlowStatusV2, - EmbeddedSignUpFlowTypeV2, - withVendorCSSClassPrefix, - EmbeddedFlowComponentTypeV2 as EmbeddedFlowComponentType, - FieldErrorV2 as FieldError, - createPackageComponentLogger, - buildValidatorFromRules, - Preferences, -} from '@thunderid/browser'; -import {FC, ReactElement, ReactNode, useCallback, useContext, useEffect, useRef, useState} from 'react'; -import ComponentRendererContext, { - ComponentRendererMap, -} from '../../../../../contexts/ComponentRenderer/ComponentRendererContext'; -import FlowProvider from '../../../../../contexts/Flow/FlowProvider'; -import useFlow from '../../../../../contexts/Flow/useFlow'; -import ComponentPreferencesContext from '../../../../../contexts/I18n/ComponentPreferencesContext'; -import useTheme from '../../../../../contexts/Theme/useTheme'; -import useThunderID from '../../../../../contexts/ThunderID/useThunderID'; -import {useForm, FormField} from '../../../../../hooks/useForm'; -import useTranslation from '../../../../../hooks/useTranslation'; -import {normalizeFlowResponse, extractErrorMessage} from '../../../../../utils/v2/flowTransformer'; -import getAuthComponentHeadings from '../../../../../utils/v2/getAuthComponentHeadings'; -import {handlePasskeyRegistration} from '../../../../../utils/v2/passkey'; -import AlertPrimitive from '../../../../primitives/Alert/Alert'; -// eslint-disable-next-line import/no-named-as-default -import CardPrimitive, {CardProps} from '../../../../primitives/Card/Card'; -import Logo from '../../../../primitives/Logo/Logo'; -import Spinner from '../../../../primitives/Spinner/Spinner'; -import Typography from '../../../../primitives/Typography/Typography'; -import {renderSignUpComponents} from '../../AuthOptionFactory'; -import useStyles from '../BaseSignUp.styles'; - -const logger: ReturnType = createPackageComponentLogger( - '@thunderid/react', - 'BaseSignUp', -); - -/** - * State for tracking passkey registration - */ -interface PasskeyState { - actionId: string | null; - creationOptions: string | null; - error: Error | null; - executionId: string | null; - isActive: boolean; -} - -/** - * Render props for custom UI rendering - */ -export interface BaseSignUpRenderProps { - /** - * Flow components - */ - components: any[]; - - /** - * API error (if any) - */ - error?: Error | null; - - /** - * Field validation errors - */ - fieldErrors: Record; - - /** - * Function to handle input changes - */ - handleInputChange: (name: string, value: string) => void; - - /** - * Function to handle form submission - */ - handleSubmit: (component: any, data?: Record) => Promise; - - /** - * Loading state - */ - isLoading: boolean; - - /** - * Whether the form is valid - */ - isValid: boolean; - - /** - * Flow messages - */ - messages: {message: string; type: string}[]; - - /** - * Flow subtitle - */ - subtitle: string; - - /** - * Flow title - */ - title: string; - - /** - * Touched fields - */ - touched: Record; - - /** - * Function to validate the form - */ - validateForm: () => {fieldErrors: Record; isValid: boolean}; - - /** - * Form values - */ - values: Record; -} - -/** - * Props for the BaseSignUp component. - */ -export interface BaseSignUpProps { - /** - * URL to redirect after successful sign-up. - */ - afterSignUpUrl?: string; - - /** - * Custom CSS class name for the submit button. - */ - buttonClassName?: string; - - /** - * Render props function for custom UI - */ - children?: (props: BaseSignUpRenderProps) => ReactNode; - - /** - * Custom CSS class name for the form container. - */ - className?: string; - - /** - * Error object to display - */ - error?: Error | null; - - /** - * Custom CSS class name for error messages. - */ - errorClassName?: string; - - /** - * Custom CSS class name for form inputs. - */ - inputClassName?: string; - - isInitialized?: boolean; - - /** - * Custom CSS class name for info messages. - */ - messageClassName?: string; - - /** - * Callback function called when the sign-up flow completes and requires redirection. - * This allows platform-specific handling of redirects (e.g., Next.js router.push). - * @param response - The response from the sign-up flow containing the redirect URL, etc. - */ - onComplete?: (response: EmbeddedSignUpFlowResponseV2) => void; - - /** - * Callback function called when sign-up fails. - * @param error - The error that occurred during sign-up. - */ - onError?: (error: Error) => void; - - /** - * Callback function called when sign-up flow status changes. - * @param response - The current sign-up response. - */ - onFlowChange?: (response: EmbeddedSignUpFlowResponseV2) => void; - - /** - * Function to initialize sign-up flow. - * @returns Promise resolving to the initial sign-up response. - */ - onInitialize?: (payload?: EmbeddedSignUpFlowRequestV2) => Promise; - - /** - * Function to handle sign-up steps. - * @param payload - The sign-up payload. - * @returns Promise resolving to the sign-up response. - */ - onSubmit?: (payload: EmbeddedSignUpFlowRequestV2) => Promise; - /** - * Component-level preferences to override global i18n and theme settings. - * Preferences are deep-merged with global ones, with component preferences - * taking precedence. Affects this component and all its descendants. - */ - preferences?: Preferences; - - /** - * Whether to redirect after sign-up. - */ - shouldRedirectAfterSignUp?: boolean; - - /** - * Whether to show the logo. - */ - showLogo?: boolean; - - /** - * Whether to show the subtitle. - */ - showSubtitle?: boolean; - - /** - * Whether to show the title. - */ - showTitle?: boolean; - - /** - * Size variant for the component. - */ - size?: 'small' | 'medium' | 'large'; - - /** - * Theme variant for the component. - */ - variant?: CardProps['variant']; -} - -/** - * Internal component that consumes FlowContext and renders the sign-up UI. - */ -const BaseSignUpContent: FC = ({ - afterSignUpUrl, - onInitialize, - onSubmit, - onError, - onFlowChange, - onComplete, - error: externalError, - className = '', - inputClassName = '', - buttonClassName = '', - errorClassName = '', - messageClassName = '', - size = 'medium', - variant = 'outlined', - isInitialized, - children, - showTitle = true, - showSubtitle = true, -}: BaseSignUpProps): ReactElement => { - const {theme, colorScheme} = useTheme(); - const customRenderers: ComponentRendererMap = useContext(ComponentRendererContext); - const {t} = useTranslation(); - const {subtitle: flowSubtitle, title: flowTitle, messages: flowMessages, addMessage, clearMessages} = useFlow(); - const {meta, isInitialized: isSdkInitialized, getStorageManager} = useThunderID(); - const styles: any = useStyles(theme, colorScheme); - - const [isLoading, setIsLoading] = useState(false); - const [isFlowInitialized, setIsFlowInitialized] = useState(false); - const [currentFlow, setCurrentFlow] = useState(null); - const [apiError, setApiError] = useState(null); - const [isStorageReady, setIsStorageReady] = useState(false); - const [passkeyState, setPasskeyState] = useState({ - actionId: null, - creationOptions: null, - error: null, - executionId: null, - isActive: false, - }); - const challengeTokenRef: any = useRef(null); - - const initializationAttemptedRef: any = useRef(false); - const passkeyProcessedRef: any = useRef(false); - - /** - * Restore any challenge token persisted before an OAuth redirect. - */ - useEffect(() => { - if (!isSdkInitialized) return; - - (async (): Promise => { - try { - const storageManager: any = await getStorageManager(); - const tempData: any = await storageManager?.getTemporaryData(); - if (tempData?.challengeToken) { - challengeTokenRef.current = tempData.challengeToken as string; - } - } catch { - // StorageManager unavailable โ€” continue without persisted token - } finally { - setIsStorageReady(true); - } - })(); - }, [isSdkInitialized]); - - /** - * Updates challengeTokenRef immediately (stale-closure safe) and persists via - * the provider's StorageManager so the token survives OAuth redirects. - */ - const setChallengeToken = async (challengeToken: string | null): Promise => { - challengeTokenRef.current = challengeToken; - try { - const storageManager: any = await getStorageManager(); - if (storageManager) { - if (challengeToken) { - await storageManager.setTemporaryDataParameter('challengeToken', challengeToken); - } else { - await storageManager.removeTemporaryDataParameter('challengeToken'); - } - } - } catch { - logger.warn('Failed to persist challenge token in storage.'); - } - }; - - /** - * Handle error responses and extract meaningful error messages - * Uses the transformer's extractErrorMessage function. - */ - const handleError: any = useCallback( - (error: any) => { - const errorMessage: string = extractErrorMessage(error, t); - - // Set the API error state - setApiError(error instanceof Error ? error : new Error(errorMessage)); - - // Clear existing messages and add the error message - clearMessages(); - addMessage({ - message: errorMessage, - type: 'error', - }); - }, - [t, addMessage, clearMessages], - ); - - /** - * Normalize flow response to ensure component-driven format - * Uses normalizeFlowResponse for modern API format responses - */ - const normalizeFlowResponseLocal: any = useCallback( - (response: EmbeddedSignUpFlowResponseV2): EmbeddedSignUpFlowResponseV2 => { - if (response?.data) { - const {components} = normalizeFlowResponse( - response, - t, - { - defaultErrorKey: 'components.signUp.errors.generic', - resolveTranslations: false, - }, - meta, - ); - - return { - ...response, - data: { - ...(response.data as any), - components, - }, - } as EmbeddedSignUpFlowResponseV2; - } - - return response; - }, - [t, children], - ); - - /** - * Extract form fields from flow components - */ - const extractFormFields: any = useCallback( - (components: any[]): FormField[] => { - const fields: FormField[] = []; - - const processComponents = (comps: any[]): any => { - comps.forEach((component: any) => { - if ( - component.type === EmbeddedFlowComponentType.TextInput || - component.type === EmbeddedFlowComponentType.PasswordInput || - component.type === EmbeddedFlowComponentType.EmailInput || - component.type === EmbeddedFlowComponentType.Select || - component.type === EmbeddedFlowComponentType.DateInput - ) { - // Use component.ref (mapped identifier) as the field name instead of component.id - // This ensures form field names match what the input components use - const fieldName: any = component.ref || component.id; - const ruleValidator = buildValidatorFromRules(component.validation); - - fields.push({ - initialValue: '', - name: fieldName, - required: component.required || false, - validator: (value: string) => { - if (component.required && (!value || value.trim() === '')) { - return t('validations.required.field.error'); - } - // Add email validation if it's an email field - if ( - (component.type === EmbeddedFlowComponentType.EmailInput || component.variant === 'EMAIL') && - value && - !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) - ) { - return t('field.email.invalid'); - } - // Evaluate declarative validation rules from meta.components[].validation. - if (ruleValidator && value) { - const ruleMessage = ruleValidator(value); - if (ruleMessage) { - return t(ruleMessage); - } - } - - return null; - }, - }); - } - - if (component.components && Array.isArray(component.components)) { - processComponents(component.components); - } - }); - }; - - processComponents(components); - return fields; - }, - [t], - ); - - const formFields: any = (currentFlow?.data as any)?.components - ? extractFormFields((currentFlow!.data as any).components) - : []; - - const form: any = useForm>({ - fields: formFields, - initialValues: {}, - requiredMessage: t('validations.required.field.error'), - validateOnBlur: true, - validateOnChange: false, - }); - - const { - values: formValues, - touched: touchedFields, - errors: formErrors, - isValid: isFormValid, - setValue: setFormValue, - setTouched: setFormTouched, - setErrors: setFormErrors, - clearErrors: clearFormErrors, - validateForm, - touchAllFields, - reset: resetForm, - } = form; - - /** - * Project server-side validation errors from the most recent flow response into the - * form's `errors` state. See BaseSignIn for the same pattern: first error per field - * wins, and the affected fields are marked touched so the error renders immediately. - */ - useEffect(() => { - clearFormErrors(); - const responseFieldErrors: FieldError[] | undefined = (currentFlow?.data as any)?.fieldErrors; - if (!responseFieldErrors || responseFieldErrors.length === 0) { - return; - } - const errors: Record = {}; - for (const fe of responseFieldErrors) { - if (!(fe.identifier in errors)) { - errors[fe.identifier] = fe.message; - } - } - setFormErrors(errors); - Object.keys(errors).forEach((field: string) => setFormTouched(field, true)); - }, [currentFlow, setFormErrors, setFormTouched, clearFormErrors]); - - /** - * Setup form fields based on the current flow. - */ - const setupFormFields: any = useCallback( - (flowResponse: EmbeddedSignUpFlowResponseV2) => { - const fields: any = extractFormFields((flowResponse.data as any)?.components || []); - const initialValues: Record = {}; - - fields.forEach((field: any) => { - initialValues[field.name] = field.initialValue || ''; - }); - - resetForm(); - - Object.keys(initialValues).forEach((key: any) => { - setFormValue(key, initialValues[key]); - }); - }, - [extractFormFields, resetForm, setFormValue], - ); - - /** - * Determine whether a completed flow finished on a display-only screen. - * Such a completion must be rendered, not redirected past. - */ - const isDisplayOnlyCompletion = (response: EmbeddedSignUpFlowResponseV2): boolean => { - const data: any = response?.data; - const components: unknown[] | undefined = data?.components ?? data?.meta?.components; - - return ( - response?.flowStatus === EmbeddedSignUpFlowStatusV2.Complete && - Array.isArray(components) && - components.length > 0 && - !(response as {assertion?: string})?.assertion && - !data?.redirectURL && - !(response as any)?.redirectUrl - ); - }; - - /** - * Handle a completed flow. A flow can complete on a display-only screen; in - * that case render the screen and skip onComplete so the wrapper does not - * immediately redirect away from it. Otherwise hand off to onComplete. - */ - const handleFlowCompletion = (response: EmbeddedSignUpFlowResponseV2): void => { - if (isDisplayOnlyCompletion(response)) { - const normalized: any = normalizeFlowResponseLocal(response); - setCurrentFlow(normalized); - setupFormFields(normalized); - return; - } - - onComplete?.(response); - }; - - /** - * Handle input value changes. - * Only updates the value without marking as touched. - * Touched state is set on blur to avoid premature validation. - */ - const handleInputChange = (name: string, value: string): void => { - setFormValue(name, value); - }; - - /** - * Handle input blur event. - * Marks the field as touched, which triggers validation. - */ - const handleInputBlur = (name: string): void => { - setFormTouched(name, true); - }; - - /** - * Check if the response contains a redirection URL and perform the redirect if necessary. - * @param response - The sign-up response - * @returns true if a redirect was performed, false otherwise - */ - const handleRedirectionIfNeeded = (response: EmbeddedSignUpFlowResponseV2): boolean => { - if (response?.type === EmbeddedSignUpFlowTypeV2.Redirection && (response?.data as any)?.redirectURL) { - /** - * Open a popup window to handle redirection prompts for social sign-up - */ - const redirectUrl: any = (response.data as any).redirectURL; - const popup: any = window.open(redirectUrl, 'oauth_popup', 'width=500,height=600,scrollbars=yes,resizable=yes'); - - if (!popup) { - logger.error('Failed to open popup window'); - return false; - } - - let hasProcessedCallback: any = false; // Prevent multiple processing - let popupMonitor: ReturnType | null = null; - let messageHandler: ((event: MessageEvent) => Promise) | null = null; - - /** - * Clean up event listener and popup monitor - */ - const cleanup = (): void => { - if (messageHandler) { - window.removeEventListener('message', messageHandler); - } - if (popupMonitor) { - clearInterval(popupMonitor); - } - }; - - /** - * Add an event listener to the window to capture the message from the popup - */ - messageHandler = async function messageEventHandler(event: MessageEvent): Promise { - /** - * Check if the message is from our popup window - */ - if (event.source !== popup) { - return; - } - - /** - * Check the origin of the message to ensure it's from a trusted source - */ - const expectedOrigin: any = afterSignUpUrl ? new URL(afterSignUpUrl).origin : window.location.origin; - if (event.origin !== expectedOrigin && event.origin !== window.location.origin) { - return; - } - - const {code, state} = event.data; - - if (code && state) { - hasProcessedCallback = true; - - const payload: EmbeddedSignUpFlowRequestV2 = { - ...(currentFlow?.executionId && {executionId: currentFlow.executionId}), - inputs: { - code, - state, - }, - ...(challengeTokenRef.current ? {challengeToken: challengeTokenRef.current} : {}), - }; - - try { - const continueResponse: any = await onSubmit!(payload); - onFlowChange?.(continueResponse); - - if (continueResponse.flowStatus === EmbeddedSignUpFlowStatusV2.Error) { - handleError(continueResponse); - onError?.(continueResponse); - } else if (continueResponse.flowStatus === EmbeddedSignUpFlowStatusV2.Complete) { - handleFlowCompletion(continueResponse as EmbeddedSignUpFlowResponseV2); - } else if (continueResponse.flowStatus === EmbeddedSignUpFlowStatusV2.Incomplete) { - const normalizedContinueResponse: any = normalizeFlowResponseLocal(continueResponse); - setCurrentFlow(normalizedContinueResponse); - setupFormFields(normalizedContinueResponse); - - // Display error from INCOMPLETE response - if (normalizedContinueResponse?.error) { - handleError(normalizedContinueResponse); - } - } - - popup.close(); - cleanup(); - } catch (err) { - handleError(err); - onError?.(err as Error); - popup.close(); - cleanup(); - } - } - }; - - window.addEventListener('message', messageHandler); - - /** - * Monitor popup for closure and URL changes - */ - popupMonitor = setInterval(async () => { - try { - if (popup.closed) { - cleanup(); - return; - } - - // Skip if we've already processed a callback - if (hasProcessedCallback) { - return; - } - - // Try to access popup URL to check for callback - try { - const popupUrl: any = popup.location.href; - - // Check if we've been redirected to the callback URL - if (popupUrl && (popupUrl.includes('code=') || popupUrl.includes('error='))) { - hasProcessedCallback = true; // Set flag to prevent multiple processing - - // Parse the URL for OAuth parameters - const url: any = new URL(popupUrl); - const code: any = url.searchParams.get('code'); - const state: any = url.searchParams.get('state'); - const error: any = url.searchParams.get('error'); - - if (error) { - logger.error('OAuth error:'); - popup.close(); - cleanup(); - return; - } - - if (code && state) { - const payload: EmbeddedSignUpFlowRequestV2 = { - ...(currentFlow?.executionId && {executionId: currentFlow.executionId}), - inputs: { - code, - state, - }, - ...(challengeTokenRef.current ? {challengeToken: challengeTokenRef.current} : {}), - }; - - try { - const continueResponse: any = await onSubmit!(payload); - onFlowChange?.(continueResponse); - - if (continueResponse.flowStatus === EmbeddedSignUpFlowStatusV2.Error) { - handleError(continueResponse); - onError?.(continueResponse); - } else if (continueResponse.flowStatus === EmbeddedSignUpFlowStatusV2.Complete) { - handleFlowCompletion(continueResponse as EmbeddedSignUpFlowResponseV2); - } else if (continueResponse.flowStatus === EmbeddedSignUpFlowStatusV2.Incomplete) { - const normalizedContinueResponse: any = normalizeFlowResponseLocal(continueResponse); - setCurrentFlow(normalizedContinueResponse); - setupFormFields(normalizedContinueResponse); - - // Display error from INCOMPLETE response - if (normalizedContinueResponse?.error) { - handleError(normalizedContinueResponse); - } - } - - popup.close(); - } catch (err) { - handleError(err); - onError?.(err as Error); - popup.close(); - } - } - } - } catch (e) { - // Cross-origin error is expected when popup navigates to OAuth provider - // This is normal and we can ignore it - } - } catch (e) { - logger.error('Error monitoring popup:'); - } - }, 1000); - - return true; - } - - return false; - }; - - /** - * Handle component submission (for buttons outside forms). - */ - const handleSubmit = async (component: any, data?: Record, skipValidation?: boolean): Promise => { - if (!currentFlow) { - return; - } - - // Only validate for form submit actions, skip for social/trigger actions - if (!skipValidation) { - // Mark all fields as touched before validation - touchAllFields(); - - const validation: ReturnType = validateForm(); - - if (!validation.isValid) { - return; - } - } - - setIsLoading(true); - setApiError(null); - clearMessages(); - - try { - // Filter out empty or undefined input values - const filteredInputs: Record = {}; - if (data) { - Object.entries(data).forEach(([key, value]: [string, any]) => { - if (value !== null && value !== undefined && value !== '') { - filteredInputs[key] = value; - } - }); - } - - const payload: EmbeddedSignUpFlowRequestV2 = { - ...(currentFlow.executionId && {executionId: currentFlow.executionId}), - ...(component.id && {action: component.id}), - ...(challengeTokenRef.current ? {challengeToken: challengeTokenRef.current} : {}), - inputs: filteredInputs, - }; - - const rawResponse: any = await onSubmit!(payload); - const response: any = normalizeFlowResponseLocal(rawResponse); - onFlowChange?.(response); - - await setChallengeToken(response.challengeToken ?? null); - - if (response.flowStatus === EmbeddedSignUpFlowStatusV2.Error) { - handleError(response); - onError?.(new Error(extractErrorMessage(response, t))); - return; - } - - if (response.flowStatus === EmbeddedSignUpFlowStatusV2.Complete) { - handleFlowCompletion(response as EmbeddedSignUpFlowResponseV2); - return; - } - - if (response.flowStatus === EmbeddedSignUpFlowStatusV2.Incomplete) { - if (handleRedirectionIfNeeded(response)) { - return; - } - - if (response.data?.additionalData?.passkeyCreationOptions) { - const {passkeyCreationOptions}: any = response.data.additionalData; - const effectiveExecutionIdForPasskey: any = response.executionId ?? currentFlow?.executionId; - - // Reset passkey processed ref to allow processing - passkeyProcessedRef.current = false; - - // Set passkey state to trigger the passkey - setPasskeyState({ - actionId: component.id || 'submit', - creationOptions: passkeyCreationOptions, - error: null, - executionId: effectiveExecutionIdForPasskey, - isActive: true, - }); - setIsLoading(false); - return; - } - setCurrentFlow(response); - setupFormFields(response); - - // Display error from INCOMPLETE response - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - if (response?.error) { - handleError(response); - } - } - } catch (err) { - handleError(err); - onError?.(err as Error); - } finally { - setIsLoading(false); - } - }; - - /** - * Handle passkey registration when passkey state becomes active. - * This effect auto-triggers the browser passkey popup and submits the result. - */ - useEffect(() => { - if (!passkeyState.isActive || !passkeyState.creationOptions || !passkeyState.executionId) { - return; - } - - // Prevent re-processing - if (passkeyProcessedRef.current) { - return; - } - passkeyProcessedRef.current = true; - - const performPasskeyRegistration = async (): Promise => { - const passkeyResponse: any = await handlePasskeyRegistration(passkeyState.creationOptions!); - const passkeyResponseObj: any = JSON.parse(passkeyResponse); - - const inputs: any = { - attestationObject: passkeyResponseObj.response.attestationObject, - clientDataJSON: passkeyResponseObj.response.clientDataJSON, - credentialId: passkeyResponseObj.id, - }; - - // After successful registration, submit the result to the server - const payload: EmbeddedSignUpFlowRequestV2 = { - executionId: passkeyState.executionId ?? undefined, - inputs, - ...(challengeTokenRef.current ? {challengeToken: challengeTokenRef.current} : {}), - } as any; - - const nextResponse: any = await onSubmit!(payload); - const processedResponse: any = normalizeFlowResponseLocal(nextResponse); - onFlowChange?.(processedResponse); - - if (processedResponse.flowStatus === EmbeddedSignUpFlowStatusV2.Complete) { - handleFlowCompletion(processedResponse as EmbeddedSignUpFlowResponseV2); - } else { - setCurrentFlow(processedResponse); - setupFormFields(processedResponse); - } - }; - - performPasskeyRegistration() - .then(() => { - setPasskeyState({actionId: null, creationOptions: null, error: null, executionId: null, isActive: false}); - }) - .catch((error: any) => { - setPasskeyState((prev: any) => ({...prev, error: error as Error, isActive: false})); - handleError(error); - onError?.(error as Error); - }); - }, [passkeyState.isActive, passkeyState.creationOptions, passkeyState.executionId]); - - const containerClasses: any = cx( - [ - withVendorCSSClassPrefix('signup'), - withVendorCSSClassPrefix(`signup--${size}`), - withVendorCSSClassPrefix(`signup--${variant}`), - ], - className, - ); - - const inputClasses: any = cx( - [ - withVendorCSSClassPrefix('signup__input'), - size === 'small' && withVendorCSSClassPrefix('signup__input--small'), - size === 'large' && withVendorCSSClassPrefix('signup__input--large'), - ], - inputClassName, - ); - - const buttonClasses: any = cx( - [ - withVendorCSSClassPrefix('signup__button'), - size === 'small' && withVendorCSSClassPrefix('signup__button--small'), - size === 'large' && withVendorCSSClassPrefix('signup__button--large'), - ], - buttonClassName, - ); - - const errorClasses: any = cx([withVendorCSSClassPrefix('signup__error')], errorClassName); - - const messageClasses: any = cx([withVendorCSSClassPrefix('signup__messages')], messageClassName); - - /** - * Render form components based on flow data using the factory - */ - const renderComponents: any = useCallback( - (components: any[]): ReactElement[] => - renderSignUpComponents( - components, - formValues, - touchedFields, - formErrors, - isLoading, - isFormValid, - handleInputChange, - { - _customRenderers: customRenderers, - _theme: theme, - buttonClassName: buttonClasses, - inputClassName: inputClasses, - onInputBlur: handleInputBlur, - onSubmit: handleSubmit, - size, - variant, - }, - ), - [ - customRenderers, - formValues, - touchedFields, - formErrors, - isFormValid, - isLoading, - size, - theme, - variant, - inputClasses, - buttonClasses, - handleSubmit, - handleInputBlur, - ], - ); - - /** - * Parse URL parameters to check for OAuth redirect state. - */ - const getUrlParams = (): any => { - const urlParams: any = new URL(window?.location?.href ?? '').searchParams; - return { - code: urlParams.get('code'), - error: urlParams.get('error'), - state: urlParams.get('state'), - }; - }; - - // Initialize the flow on component mount - useEffect(() => { - // Skip initialization if we're in an OAuth redirect state. - const urlParams: any = getUrlParams(); - if (urlParams.code || urlParams.state) { - return; - } - - if (isInitialized && isStorageReady && !isFlowInitialized && !initializationAttemptedRef.current) { - initializationAttemptedRef.current = true; - - (async (): Promise => { - setIsLoading(true); - setApiError(null); - clearMessages(); - - try { - const payload: any = challengeTokenRef.current ? {challengeToken: challengeTokenRef.current} : undefined; - const rawResponse: any = await onInitialize?.(payload); - const response: any = normalizeFlowResponseLocal(rawResponse); - - await setChallengeToken(response.challengeToken ?? null); - setCurrentFlow(response); - setIsFlowInitialized(true); - onFlowChange?.(response); - - // Clean up executionId and applicationId from URL after storing in state - if (window?.location?.href) { - const url: URL = new URL(window.location.href); - url.searchParams.delete('executionId'); - url.searchParams.delete('applicationId'); - window.history.replaceState({}, '', url.toString()); - } - - if (response.flowStatus === EmbeddedSignUpFlowStatusV2.Error) { - handleError(response); - onError?.(new Error(extractErrorMessage(response, t))); - return; - } - - if (response.flowStatus === EmbeddedSignUpFlowStatusV2.Complete) { - handleFlowCompletion(response as EmbeddedSignUpFlowResponseV2); - return; - } - - if (response.flowStatus === EmbeddedSignUpFlowStatusV2.Incomplete) { - setupFormFields(response); - - // Display error from INCOMPLETE response - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - if (response?.error) { - handleError(response); - } - } - } catch (err) { - handleError(err); - onError?.(err as Error); - } finally { - setIsLoading(false); - } - })(); - } - }, [ - isInitialized, - isStorageReady, - isFlowInitialized, - onInitialize, - onComplete, - onError, - onFlowChange, - setupFormFields, - normalizeFlowResponseLocal, - afterSignUpUrl, - t, - ]); - - // If render props are provided, use them - if (children) { - const renderProps: BaseSignUpRenderProps = { - components: (currentFlow?.data as any)?.components || [], - error: apiError, - fieldErrors: formErrors, - handleInputChange, - handleSubmit, - isLoading, - isValid: isFormValid, - messages: flowMessages || [], - subtitle: flowSubtitle || t('signup.subheading'), - title: flowTitle || t('signup.heading'), - touched: touchedFields, - validateForm: () => { - const result: any = validateForm(); - return {fieldErrors: result.errors, isValid: result.isValid}; - }, - values: formValues, - }; - - return
{children(renderProps)}
; - } - - if (!isFlowInitialized && isLoading) { - return ( - - -
- -
-
-
- ); - } - - if (!currentFlow) { - return ( - - - - {t('errors.heading')} - {t('errors.signup.flow.initialization.failure')} - - - - ); - } - - // Extract heading and subheading components and filter them from the main components - const componentsToRender: any = (currentFlow.data as any)?.components || []; - const {title, subtitle, componentsWithoutHeadings} = getAuthComponentHeadings( - componentsToRender, - flowTitle, - flowSubtitle, - t('signup.heading'), - t('signup.subheading'), - ); - - return ( - - {(showTitle || showSubtitle) && ( - - {showTitle && ( - - {title} - - )} - {showSubtitle && ( - - {subtitle} - - )} - - )} - - {externalError && ( -
- - {externalError.message} - -
- )} - {flowMessages && flowMessages.length > 0 && ( -
- {flowMessages.map((message: any, index: number) => ( - - {message.message} - - ))} -
- )} -
- {componentsWithoutHeadings && componentsWithoutHeadings.length > 0 ? ( - renderComponents(componentsWithoutHeadings) - ) : ( - - {t('errors.signup.components.not.available')} - - )} -
-
-
- ); -}; - -/** - * BaseSignUp component that provides embedded sign-up flow for ThunderIDV2. - * This component handles both the presentation layer and sign-up flow logic. - * It accepts API functions as props to maintain framework independence. - */ -const BaseSignUp: FC = ({preferences, showLogo = true, ...rest}: BaseSignUpProps): ReactElement => { - const {theme, colorScheme} = useTheme(); - const styles: any = useStyles(theme, colorScheme); - - const content: ReactElement = ( -
- {showLogo && ( -
- -
- )} - - - -
- ); - - if (!preferences) return content; - - return {content}; -}; - -export default BaseSignUp; diff --git a/packages/react/src/components/presentation/auth/SignUp/v2/SignUp.tsx b/packages/react/src/components/presentation/auth/SignUp/v2/SignUp.tsx deleted file mode 100644 index aa0d7df..0000000 --- a/packages/react/src/components/presentation/auth/SignUp/v2/SignUp.tsx +++ /dev/null @@ -1,154 +0,0 @@ -/** - * Copyright (c) 2025-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 { - EmbeddedSignUpFlowRequestV2, - EmbeddedSignUpFlowResponseV2, - EmbeddedSignUpFlowTypeV2, - EmbeddedFlowType, -} from '@thunderid/browser'; -import {FC, ReactElement, ReactNode} from 'react'; -// eslint-disable-next-line import/no-named-as-default -import BaseSignUp, {BaseSignUpProps, BaseSignUpRenderProps} from './BaseSignUp'; -import useThunderID from '../../../../../contexts/ThunderID/useThunderID'; - -/** - * Render props function parameters (re-exported from BaseSignUp for convenience) - */ -export type SignUpRenderProps = BaseSignUpRenderProps; - -/** - * Props for the SignUp component. - */ -export type SignUpProps = BaseSignUpProps & { - /** - * Render props function for custom UI - */ - children?: (props: SignUpRenderProps) => ReactNode; -}; - -/** - * A styled SignUp component for ThunderIDV2 (AKA Thunder) platform that provides embedded sign-up flow with pre-built styling. - * This component handles the API calls for sign-up and delegates UI logic to BaseSignUp. - */ -const SignUp: FC = ({ - className, - size = 'medium', - afterSignUpUrl, - onError, - onComplete, - shouldRedirectAfterSignUp = true, - children, - ...rest -}: SignUpProps): ReactElement => { - const {signUp, isInitialized, applicationId, scopes} = useThunderID(); - - /** - * Initialize the sign-up flow. - */ - const handleInitialize = async (payload?: EmbeddedSignUpFlowRequestV2): Promise => { - const urlParams: URLSearchParams = new URL(window.location.href).searchParams; - const executionIdFromUrl: string = urlParams.get('executionId') || ''; - const applicationIdFromUrl: string = urlParams.get('applicationId') ?? ''; - - const effectiveApplicationId: any = applicationId ?? applicationIdFromUrl; - - const challengeToken: string | undefined = (payload as any)?.challengeToken; - - let initialPayload: EmbeddedSignUpFlowRequestV2 | any; - if (executionIdFromUrl) { - initialPayload = { - executionId: executionIdFromUrl, - ...(challengeToken ? {challengeToken} : {}), - }; - } else if (!payload || !('flowType' in payload)) { - initialPayload = { - ...(payload || {}), - flowType: EmbeddedFlowType.Registration, - ...(effectiveApplicationId && {applicationId: effectiveApplicationId}), - ...(scopes && {scopes}), - }; - } else { - initialPayload = payload; - } - - return (await signUp(initialPayload)) as EmbeddedSignUpFlowResponseV2; - }; - - /** - * Handle sign-up steps. - */ - const handleOnSubmit = async (payload: EmbeddedSignUpFlowRequestV2): Promise => - (await signUp(payload)) as EmbeddedSignUpFlowResponseV2; - - /** - * Handle successful sign-up and redirect. - */ - const handleComplete = (response: EmbeddedSignUpFlowResponseV2): any => { - onComplete?.(response); - - if (!shouldRedirectAfterSignUp) { - return; - } - - const redirectURL: string | undefined = (response?.data as Record)?.['redirectURL'] as - | string - | undefined; - - if ( - response?.type === EmbeddedSignUpFlowTypeV2.Redirection && - redirectURL && - !redirectURL.includes('oauth') && // Not a social provider redirect - !redirectURL.includes('auth') // Not an auth provider redirect - ) { - window.location.href = redirectURL; - return; - } - - const oauthRedirectUrl: any = (response as any)?.redirectUrl; - if (oauthRedirectUrl) { - window.location.href = oauthRedirectUrl; - return; - } - - // For non-redirection responses (regular sign-up completion), handle redirect if configured. - // Skip when assertion is present โ€” the SDK stored the session and the caller handled navigation. - if (response?.type !== EmbeddedSignUpFlowTypeV2.Redirection && afterSignUpUrl && !(response as any)?.assertion) { - window.location.href = afterSignUpUrl; - } - }; - - return ( - - ); -}; - -export default SignUp; diff --git a/packages/react/src/contexts/ComponentRenderer/ComponentRendererContext.ts b/packages/react/src/contexts/ComponentRenderer/ComponentRendererContext.ts index 7384179..5fe2896 100644 --- a/packages/react/src/contexts/ComponentRenderer/ComponentRendererContext.ts +++ b/packages/react/src/contexts/ComponentRenderer/ComponentRendererContext.ts @@ -16,7 +16,7 @@ * under the License. */ -import {EmbeddedFlowComponentV2 as EmbeddedFlowComponent, FlowMetadataResponse} from '@thunderid/browser'; +import {EmbeddedFlowComponent, FlowMetadataResponse} from '@thunderid/browser'; import {Context, createContext, ReactElement} from 'react'; export interface ComponentRenderContext { diff --git a/packages/react/src/contexts/FlowMeta/FlowMetaProvider.tsx b/packages/react/src/contexts/FlowMeta/FlowMetaProvider.tsx index b3b025c..1964821 100644 --- a/packages/react/src/contexts/FlowMeta/FlowMetaProvider.tsx +++ b/packages/react/src/contexts/FlowMeta/FlowMetaProvider.tsx @@ -19,8 +19,7 @@ import { FlowMetadataResponse, FlowMetaType, - getFlowMetaV2, - Platform, + getFlowMeta, I18nBundle, TranslationBundleConstants, } from '@thunderid/browser'; @@ -65,7 +64,7 @@ const FlowMetaProvider: FC> = ({ children, enabled = true, }: PropsWithChildren): ReactElement => { - const {baseUrl, applicationId, platform, isInitialized} = useThunderID(); + const {baseUrl, applicationId, isInitialized} = useThunderID(); const i18nContext: I18nContextValue = useI18n(); const [meta, setMeta] = useState(null); @@ -82,7 +81,7 @@ const FlowMetaProvider: FC> = ({ // effect firings before the first fetch completes. const lastFetchedRef: RefObject<(() => Promise) | null> = useRef<(() => Promise) | null>(null); const fetchFlowMeta: () => Promise = useCallback(async (): Promise => { - if (!enabled || platform !== Platform.ThunderID) { + if (!enabled) { setMeta(null); setIsLoading(false); return; @@ -99,7 +98,7 @@ const FlowMetaProvider: FC> = ({ setError(null); try { - const result: FlowMetadataResponse = await getFlowMetaV2({ + const result: FlowMetadataResponse = await getFlowMeta({ baseUrl, ...(applicationId ? {id: applicationId, type: FlowMetaType.App} : {}), language: i18nContext?.currentLanguage, @@ -110,17 +109,17 @@ const FlowMetaProvider: FC> = ({ } finally { setIsLoading(false); } - }, [enabled, platform, baseUrl, applicationId, isInitialized, i18nContext?.currentLanguage]); + }, [enabled, baseUrl, applicationId, isInitialized, i18nContext?.currentLanguage]); const switchLanguage: (language: string) => Promise = useCallback( async (language: string): Promise => { - if (!enabled || platform !== Platform.ThunderID) return; + if (!enabled) return; setIsLoading(true); setError(null); try { - const result: FlowMetadataResponse = await getFlowMetaV2({ + const result: FlowMetadataResponse = await getFlowMeta({ baseUrl, ...(applicationId ? {id: applicationId, type: FlowMetaType.App} : {}), language, @@ -148,7 +147,7 @@ const FlowMetaProvider: FC> = ({ setIsLoading(false); } }, - [enabled, platform, baseUrl, applicationId, i18nContext], + [enabled, baseUrl, applicationId, i18nContext], ); // After injectBundles + setPendingLanguage are batched and committed, this diff --git a/packages/react/src/contexts/Theme/ThemeProvider.tsx b/packages/react/src/contexts/Theme/ThemeProvider.tsx index 3de3fe4..8a32e3f 100644 --- a/packages/react/src/contexts/Theme/ThemeProvider.tsx +++ b/packages/react/src/contexts/Theme/ThemeProvider.tsx @@ -1,5 +1,5 @@ /** - * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com). + * 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 @@ -16,107 +16,150 @@ * under the License. */ -import { - BrowserThemeDetection, - Platform, - RecursivePartial, - ThemeConfig, - ThemeMode, - ThemePreferences, -} from '@thunderid/browser'; -import {FC, PropsWithChildren, ReactElement} from 'react'; -import V1ThemeProvider from './v1/ThemeProvider'; -import ThemeProviderV2, {ThemeProviderProps as ThemeProviderV2Props} from './v2/ThemeProvider'; -import useThunderID from '../ThunderID/useThunderID'; +import {createTheme, Theme, ThemeConfig, ThemeMode, RecursivePartial, FlowMetaTheme} from '@thunderid/browser'; +import {FC, PropsWithChildren, ReactElement, useCallback, useContext, useEffect, useMemo, useState} from 'react'; +import ThemeContext from './ThemeContext'; +import applyThemeToDOM from '../../utils/applyThemeToDOM'; +import buildThemeConfigFromFlowMeta from '../../utils/buildThemeConfigFromFlowMeta'; +import normalizeThemeConfig from '../../utils/normalizeThemeConfig'; +import FlowMetaContext, {FlowMetaContextValue} from '../FlowMeta/FlowMetaContext'; export interface ThemeProviderProps { - // โ”€โ”€โ”€ v1 props (ignored in v2 mode) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ /** - * Configuration for theme detection when using 'class' or 'system' mode. + * When true, seeds the theme from the nearest BrandingContext if no flow meta theme is available. */ - detection?: BrowserThemeDetection; - /** - * Whether to inherit the theme from the ThunderID branding preference. - * @default true - */ - inheritFromBranding?: ThemePreferences['inheritFromBranding']; + inheritFromBranding?: boolean; + /** - * The theme mode to use for automatic detection. - * - `'light'` | `'dark'`: Fixed color scheme. - * - `'system'`: Follows the OS preference. - * - `'class'`: Detects theme from CSS classes on the `` element. - * - `'branding'`: Follows the active theme from the branding preference. + * Initial color scheme override. Overrides the server default when provided. */ - mode?: ThemeMode | 'branding'; + mode?: ThemeMode; - // โ”€โ”€โ”€ shared โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ /** - * Optional partial theme overrides applied on top of the resolved theme. - * User-supplied values always take the highest precedence. + * Optional theme overrides merged on top of the server-side flow meta theme. + * User-supplied values take highest precedence. */ theme?: RecursivePartial; } /** - * ThemeProvider is the single entry-point for theme management in `@thunderid/react`. - * - * It transparently switches between two internal implementations: - * - * **v1** (`ThemeProvider` classic): Sources colors from the ThunderID Branding API. - * Used automatically when no `FlowMetaProvider` is present in the component tree. + * ThemeProvider is the v2 drop-in replacement for `ThemeProvider`. * - * **v2** (`FlowMetaThemeProvider`): Sources colors from the `GET /flow/meta` endpoint - * via `FlowMetaProvider`. Used automatically when a `FlowMetaProvider` is present - * in the tree โ€” or when `version="v2"` is set explicitly. + * It reads the design theme from the nearest `FlowMetaContext` (provided by + * `FlowMetaProvider`) and publishes a resolved `Theme` object through the + * **same** `ThemeContext` that `useTheme` consumes. This means all existing + * components that call `useTheme` continue to work without any changes. * - * The active version can also be pinned explicitly via the `version` prop. - * All components that consume `useTheme()` continue to work regardless of which - * version is active. + * The `defaultColorScheme` field returned by the server is used to seed the + * active color scheme; the user can still toggle it locally via the + * `toggleTheme` value exposed in the context. * * @example - * Auto-detection (recommended): * ```tsx - * // v2 mode โ€“ FlowMetaProvider is present * * - * + * {/* useTheme() works here as usual *\/} * * - * - * // v1 mode โ€“ no FlowMetaProvider - * - * - * * ``` * * @example - * Explicit version pinning: + * With user theme overrides (user values win over server values): * ```tsx - * + * * * * ``` */ const ThemeProvider: FC> = ({ children, - theme, - detection, - inheritFromBranding, - mode, + theme: themeOverrideProp, }: PropsWithChildren): ReactElement => { - const {platform} = useThunderID(); + const themeOverride: RecursivePartial | undefined = normalizeThemeConfig(themeOverrideProp); + const flowMetaContext: FlowMetaContextValue | null = useContext(FlowMetaContext); + + const flowMetaTheme: FlowMetaTheme | null = flowMetaContext?.meta?.design?.theme ?? null; + const isLoading: boolean = flowMetaContext?.isLoading ?? false; + const error: Error | null = flowMetaContext?.error ?? null; + + // Seed the color scheme from the server's defaultColorScheme; allow local toggling. + const [colorScheme, setColorScheme] = useState<'light' | 'dark'>(() => flowMetaTheme?.defaultColorScheme ?? 'light'); - if (platform === Platform.ThunderID) { - const v2Props: ThemeProviderV2Props = {theme}; + // When meta finishes loading, sync the color scheme with the server default. + useEffect(() => { + if (flowMetaTheme?.defaultColorScheme) { + setColorScheme(flowMetaTheme.defaultColorScheme); + } + }, [flowMetaTheme?.defaultColorScheme]); - return {children}; - } + const toggleTheme: () => void = useCallback(() => { + setColorScheme((prev: 'light' | 'dark') => (prev === 'light' ? 'dark' : 'light')); + }, []); - return ( - - {children} - + // Build the resolved ThemeConfig: flow meta base โ†’ user overrides on top. + const finalThemeConfig: RecursivePartial | undefined = useMemo(() => { + if (!flowMetaTheme) { + return themeOverride; + } + + const metaConfig: RecursivePartial = buildThemeConfigFromFlowMeta(flowMetaTheme, colorScheme); + + if (!themeOverride) { + return metaConfig; + } + + return { + ...metaConfig, + ...themeOverride, + borderRadius: { + ...metaConfig.borderRadius, + ...themeOverride.borderRadius, + }, + colors: { + ...metaConfig.colors, + ...themeOverride.colors, + }, + ...(metaConfig.typography || themeOverride.typography + ? { + typography: { + ...(metaConfig as any).typography, + ...themeOverride.typography, + }, + } + : {}), + }; + }, [flowMetaTheme, colorScheme, themeOverride]); + + const theme: Theme = useMemo( + () => createTheme(finalThemeConfig, colorScheme === 'dark'), + [finalThemeConfig, colorScheme], ); + + const direction: 'ltr' | 'rtl' = flowMetaTheme?.direction ?? 'ltr'; + + // Apply CSS variables to the document root. + useEffect(() => { + applyThemeToDOM(theme); + }, [theme]); + + // Apply text direction to the document root. + useEffect(() => { + if (typeof document !== 'undefined') { + document.documentElement.dir = direction; + } + }, [direction]); + + const value: any = { + brandingError: error, + colorScheme, + direction, + inheritFromBranding: false, + isBrandingLoading: isLoading, + theme, + toggleTheme, + }; + + return {children}; }; export default ThemeProvider; diff --git a/packages/react/src/contexts/Theme/v1/ThemeProvider.tsx b/packages/react/src/contexts/Theme/v1/ThemeProvider.tsx deleted file mode 100644 index 28fd492..0000000 --- a/packages/react/src/contexts/Theme/v1/ThemeProvider.tsx +++ /dev/null @@ -1,296 +0,0 @@ -/** - * 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 { - createTheme, - Theme, - ThemeConfig, - ThemeMode, - RecursivePartial, - detectThemeMode, - createClassObserver, - createMediaQueryListener, - BrowserThemeDetection, - ThemePreferences, - DEFAULT_THEME, - createPackageComponentLogger, -} from '@thunderid/browser'; -import {FC, PropsWithChildren, ReactElement, useEffect, useMemo, useState, useCallback} from 'react'; -import applyThemeToDOM from '../../../utils/applyThemeToDOM'; -import normalizeThemeConfig from '../../../utils/normalizeThemeConfig'; - -import useBrandingContext from '../../Branding/useBrandingContext'; -import ThemeContext from '../ThemeContext'; - -const logger: ReturnType = createPackageComponentLogger( - '@thunderid/react', - 'ThemeProvider', -); - -export interface ThemeProviderProps { - /** - * Configuration for theme detection when using 'class' or 'system' mode - */ - detection?: BrowserThemeDetection; - /** - * Configuration for branding integration - */ - inheritFromBranding?: ThemePreferences['inheritFromBranding']; - /** - * The theme mode to use for automatic detection - * - 'light': Always use light theme - * - 'dark': Always use dark theme - * - 'system': Use system preference (prefers-color-scheme media query) - * - 'class': Detect theme based on CSS classes on HTML element - * - 'branding': Use active theme from branding preference (requires inheritFromBranding=true) - */ - mode?: ThemeMode | 'branding'; - theme?: RecursivePartial; -} - -/** - * ThemeProvider component that manages theme state and provides theme context to child components. - * - * This provider integrates with ThunderID branding preferences to automatically apply - * organization-specific themes while allowing for custom theme overrides. - * - * Features: - * - Automatic theme mode detection (light/dark/system/class) - * - Integration with ThunderID branding API through useBranding hook - * - Merging of branding themes with custom theme configurations - * - CSS variable injection for easy styling - * - Loading and error states for branding integration - * - * @example - * Basic usage with branding integration: - * ```tsx - * - * - * - * ``` - * - * @example - * With custom theme overrides: - * ```tsx - * - * - * - * ``` - * - * @example - * With branding-driven theme mode: - * ```tsx - * - * - * - * ``` - */ -const ThemeProvider: FC> = ({ - children, - theme: themeConfigProp, - mode = DEFAULT_THEME, - detection = {}, - inheritFromBranding = true, -}: PropsWithChildren): ReactElement => { - const themeConfig: RecursivePartial | undefined = normalizeThemeConfig(themeConfigProp); - - const [colorScheme, setColorScheme] = useState<'light' | 'dark'>(() => { - // Initialize with detected theme mode or fallback to defaultMode - if (mode === 'light' || mode === 'dark') { - return mode; - } - // For 'branding' mode, start with system preference and update when branding loads - if (mode === 'branding') { - return detectThemeMode('system', detection); - } - return detectThemeMode(mode, detection); - }); - - // Use branding theme if inheritFromBranding is enabled - // Handle case where BrandingProvider might not be available - let brandingTheme: Theme | null = null; - let brandingActiveTheme: 'light' | 'dark' | null = null; - let isBrandingLoading = false; - let brandingError: Error | null = null; - - try { - const brandingContext: any = useBrandingContext(); - brandingTheme = brandingContext.theme; - brandingActiveTheme = brandingContext.activeTheme; - isBrandingLoading = brandingContext.isLoading; - brandingError = brandingContext.error; - } catch (error) { - // BrandingProvider not available, fall back to no branding - if (inheritFromBranding) { - logger.warn( - 'ThemeProvider: inheritFromBranding is enabled but BrandingProvider is not available. ' + - 'Make sure to wrap your app with BrandingProvider or ThunderIDProvider with branding preferences.', - ); - } - } - - // Update color scheme based on branding active theme when available - useEffect(() => { - if (inheritFromBranding && brandingActiveTheme) { - // Update color scheme based on mode preference - if (mode === 'branding') { - // Always follow branding active theme - setColorScheme(brandingActiveTheme); - } else if (mode === 'system' && !isBrandingLoading) { - // For system mode, prefer branding but allow system override if no branding - setColorScheme(brandingActiveTheme); - } - } - }, [inheritFromBranding, brandingActiveTheme, mode, isBrandingLoading]); - - // Merge user-provided theme config with branding theme - const finalThemeConfig: RecursivePartial | undefined = useMemo(() => { - if (!inheritFromBranding || !brandingTheme) { - return themeConfig; - } - - // Convert branding theme to our theme config format - const brandingThemeConfig: RecursivePartial = { - borderRadius: brandingTheme.borderRadius, - colors: brandingTheme.colors, - components: brandingTheme.components, - images: brandingTheme.images, - shadows: brandingTheme.shadows, - spacing: brandingTheme.spacing, - }; - - // Merge branding theme with user-provided theme config - // User-provided config takes precedence over branding - return { - ...brandingThemeConfig, - ...themeConfig, - borderRadius: { - ...brandingThemeConfig.borderRadius, - ...themeConfig?.borderRadius, - }, - colors: { - ...brandingThemeConfig.colors, - ...themeConfig?.colors, - }, - components: { - ...brandingThemeConfig.components, - ...themeConfig?.components, - }, - images: { - ...brandingThemeConfig.images, - ...themeConfig?.images, - }, - shadows: { - ...brandingThemeConfig.shadows, - ...themeConfig?.shadows, - }, - spacing: { - ...brandingThemeConfig.spacing, - ...themeConfig?.spacing, - }, - }; - }, [inheritFromBranding, brandingTheme, themeConfig]); - - const theme: Theme = useMemo( - () => createTheme(finalThemeConfig, colorScheme === 'dark'), - [finalThemeConfig, colorScheme], - ); - - // Get direction from theme config or default to 'ltr' - const direction: string = (finalThemeConfig as any)?.direction || 'ltr'; - - const handleThemeChange: (isDark: boolean) => void = useCallback((isDark: boolean) => { - setColorScheme(isDark ? 'dark' : 'light'); - }, []); - - const toggleTheme: () => void = useCallback(() => { - setColorScheme((prev: 'light' | 'dark') => (prev === 'light' ? 'dark' : 'light')); - }, []); - - useEffect(() => { - let observer: MutationObserver | null = null; - let mediaQuery: MediaQueryList | null = null; - - // Don't set up automatic theme detection for branding mode - if (mode === 'branding') { - return; - } - - if (mode === 'class') { - const targetElement: HTMLElement = detection.targetElement || document.documentElement; - if (targetElement) { - observer = createClassObserver(targetElement, handleThemeChange, detection); - } - } else if (mode === 'system') { - // Only set up system listener if not using branding or branding hasn't loaded yet - if (!inheritFromBranding || !brandingActiveTheme) { - mediaQuery = createMediaQueryListener(handleThemeChange); - } - } - - return () => { - if (observer) { - observer.disconnect(); - } - if (mediaQuery) { - // Clean up media query listener - if (mediaQuery.removeEventListener) { - mediaQuery.removeEventListener('change', handleThemeChange as any); - } else { - // Fallback for older browsers - mediaQuery.removeListener(handleThemeChange as any); - } - } - }; - }, [mode, detection, handleThemeChange, inheritFromBranding, brandingActiveTheme]); - - useEffect(() => { - applyThemeToDOM(theme); - }, [theme]); - - // Apply direction to document - useEffect(() => { - if (typeof document !== 'undefined') { - document.documentElement.dir = direction; - } - }, [direction]); - - const value: any = { - brandingError, - colorScheme, - direction, - inheritFromBranding, - isBrandingLoading, - theme, - toggleTheme, - }; - - return {children}; -}; - -export default ThemeProvider; diff --git a/packages/react/src/contexts/Theme/v2/ThemeProvider.tsx b/packages/react/src/contexts/Theme/v2/ThemeProvider.tsx deleted file mode 100644 index 316ec89..0000000 --- a/packages/react/src/contexts/Theme/v2/ThemeProvider.tsx +++ /dev/null @@ -1,155 +0,0 @@ -/** - * 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 {createTheme, Theme, ThemeConfig, RecursivePartial, FlowMetaTheme} from '@thunderid/browser'; -import {FC, PropsWithChildren, ReactElement, useCallback, useContext, useEffect, useMemo, useState} from 'react'; -import applyThemeToDOM from '../../../utils/applyThemeToDOM'; -import normalizeThemeConfig from '../../../utils/normalizeThemeConfig'; -import buildThemeConfigFromFlowMeta from '../../../utils/v2/buildThemeConfigFromFlowMeta'; -import FlowMetaContext, {FlowMetaContextValue} from '../../FlowMeta/FlowMetaContext'; -import ThemeContext from '../ThemeContext'; - -export interface ThemeProviderProps { - /** - * Optional theme overrides merged on top of the server-side flow meta theme. - * User-supplied values take highest precedence. - */ - theme?: RecursivePartial; -} - -/** - * ThemeProvider is the v2 drop-in replacement for `ThemeProvider`. - * - * It reads the design theme from the nearest `FlowMetaContext` (provided by - * `FlowMetaProvider`) and publishes a resolved `Theme` object through the - * **same** `ThemeContext` that `useTheme` consumes. This means all existing - * components that call `useTheme` continue to work without any changes. - * - * The `defaultColorScheme` field returned by the server is used to seed the - * active color scheme; the user can still toggle it locally via the - * `toggleTheme` value exposed in the context. - * - * @example - * ```tsx - * - * - * {/* useTheme() works here as usual *\/} - * - * - * ``` - * - * @example - * With user theme overrides (user values win over server values): - * ```tsx - * - * - * - * ``` - */ -const ThemeProvider: FC> = ({ - children, - theme: themeOverrideProp, -}: PropsWithChildren): ReactElement => { - const themeOverride: RecursivePartial | undefined = normalizeThemeConfig(themeOverrideProp); - const flowMetaContext: FlowMetaContextValue | null = useContext(FlowMetaContext); - - const flowMetaTheme: FlowMetaTheme | null = flowMetaContext?.meta?.design?.theme ?? null; - const isLoading: boolean = flowMetaContext?.isLoading ?? false; - const error: Error | null = flowMetaContext?.error ?? null; - - // Seed the color scheme from the server's defaultColorScheme; allow local toggling. - const [colorScheme, setColorScheme] = useState<'light' | 'dark'>(() => flowMetaTheme?.defaultColorScheme ?? 'light'); - - // When meta finishes loading, sync the color scheme with the server default. - useEffect(() => { - if (flowMetaTheme?.defaultColorScheme) { - setColorScheme(flowMetaTheme.defaultColorScheme); - } - }, [flowMetaTheme?.defaultColorScheme]); - - const toggleTheme: () => void = useCallback(() => { - setColorScheme((prev: 'light' | 'dark') => (prev === 'light' ? 'dark' : 'light')); - }, []); - - // Build the resolved ThemeConfig: flow meta base โ†’ user overrides on top. - const finalThemeConfig: RecursivePartial | undefined = useMemo(() => { - if (!flowMetaTheme) { - return themeOverride; - } - - const metaConfig: RecursivePartial = buildThemeConfigFromFlowMeta(flowMetaTheme, colorScheme); - - if (!themeOverride) { - return metaConfig; - } - - return { - ...metaConfig, - ...themeOverride, - borderRadius: { - ...metaConfig.borderRadius, - ...themeOverride.borderRadius, - }, - colors: { - ...metaConfig.colors, - ...themeOverride.colors, - }, - ...(metaConfig.typography || themeOverride.typography - ? { - typography: { - ...(metaConfig as any).typography, - ...themeOverride.typography, - }, - } - : {}), - }; - }, [flowMetaTheme, colorScheme, themeOverride]); - - const theme: Theme = useMemo( - () => createTheme(finalThemeConfig, colorScheme === 'dark'), - [finalThemeConfig, colorScheme], - ); - - const direction: 'ltr' | 'rtl' = flowMetaTheme?.direction ?? 'ltr'; - - // Apply CSS variables to the document root. - useEffect(() => { - applyThemeToDOM(theme); - }, [theme]); - - // Apply text direction to the document root. - useEffect(() => { - if (typeof document !== 'undefined') { - document.documentElement.dir = direction; - } - }, [direction]); - - const value: any = { - brandingError: error, - colorScheme, - direction, - inheritFromBranding: false, - isBrandingLoading: isLoading, - theme, - toggleTheme, - }; - - return {children}; -}; - -export default ThemeProvider; diff --git a/packages/react/src/contexts/ThunderID/ThunderIDContext.ts b/packages/react/src/contexts/ThunderID/ThunderIDContext.ts index 0294872..005694a 100644 --- a/packages/react/src/contexts/ThunderID/ThunderIDContext.ts +++ b/packages/react/src/contexts/ThunderID/ThunderIDContext.ts @@ -23,7 +23,6 @@ import { IdToken, OIDCDiscoveryApiResponse, Organization, - Platform, SignInOptions, TokenExchangeRequestConfig, TokenResponse, @@ -212,7 +211,6 @@ export type ThunderIDContextProps = { signUpUrl: string | undefined; user: any; - platform?: Platform; } & Pick; /** @@ -245,7 +243,6 @@ const ThunderIDContext: Context = createContext Promise.resolve({} as any), resolveFlowTemplateLiterals: (text: string | undefined) => text ?? '', diff --git a/packages/react/src/contexts/ThunderID/ThunderIDProvider.tsx b/packages/react/src/contexts/ThunderID/ThunderIDProvider.tsx index f06f775..81bfe63 100644 --- a/packages/react/src/contexts/ThunderID/ThunderIDProvider.tsx +++ b/packages/react/src/contexts/ThunderID/ThunderIDProvider.tsx @@ -29,10 +29,8 @@ import { GetBrandingPreferenceConfig, BrandingPreference, IdToken, - getActiveTheme, - Platform, extractUserClaimsFromIdToken, - EmbeddedSignInFlowResponseV2, + EmbeddedSignInFlowResponse, TokenResponse, createPackageComponentLogger, } from '@thunderid/browser'; @@ -177,7 +175,7 @@ const ThunderIDProvider: FC> = ({ } } - async function signIn(...args: any): Promise { + async function signIn(...args: any): Promise { // Check if this is a V2 embedded flow request BEFORE calling signIn // This allows us to skip session checks entirely for V2 flows const arg1: any = args[0]; @@ -566,7 +564,6 @@ const ThunderIDProvider: FC> = ({ organization: currentOrganization, organizationChain, organizationHandle: config?.organizationHandle, - platform: Platform.ThunderID, reInitialize, recover, signIn, @@ -631,12 +628,10 @@ const ThunderIDProvider: FC> = ({ refetch={refetchBranding} > diff --git a/packages/react/src/hooks/v2/useOAuthCallback.ts b/packages/react/src/hooks/useOAuthCallback.ts similarity index 100% rename from packages/react/src/hooks/v2/useOAuthCallback.ts rename to packages/react/src/hooks/useOAuthCallback.ts diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 221829c..1a22241 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -140,21 +140,15 @@ export * from './components/auth/Callback/Callback'; export * from './components/auth/Callback/TokenCallback'; export * from './components/auth/Callback/OAuthCallback'; -// 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'; export {default as GoogleButton} from './components/adapters/GoogleButton'; export {default as GitHubButton} from './components/adapters/GitHubButton'; export {default as MicrosoftButton} from './components/adapters/MicrosoftButton'; export {default as FacebookButton} from './components/adapters/FacebookButton'; export {default as LinkedInButton} from './components/adapters/LinkedInButton'; export {default as SignInWithEthereumButton} from './components/adapters/SignInWithEthereumButton'; -export {default as EmailOtp} from './components/presentation/auth/SignIn/v1/options/EmailOtp'; -export {default as Totp} from './components/presentation/auth/SignIn/v1/options/Totp'; -export {default as SmsOtp} from './components/presentation/auth/SignIn/v1/options/SmsOtp'; -export {default as SocialButton} from './components/presentation/auth/SignIn/v1/options/SocialButton'; -export {default as MultiOptionButton} from './components/presentation/auth/SignIn/v1/options/MultiOptionButton'; -export * from './components/presentation/auth/SignIn/v1/options/SignInOptionFactory'; + +export {useOAuthCallback} from './hooks/useOAuthCallback'; +export type {UseOAuthCallbackOptions, OAuthCallbackPayload} from './hooks/useOAuthCallback'; export {default as FlowTimer} from './components/adapters/FlowTimer'; export * from './components/adapters/FlowTimer'; @@ -321,37 +315,35 @@ export { countryCodeToFlagEmoji, resolveLocaleDisplayName, resolveLocaleEmoji, - // Export `v2` models and types as first class citizens since they are - // going to be the primary way to interact with embedded flows moving forward. - EmbeddedFlowComponentTypeV2 as EmbeddedFlowComponentType, - EmbeddedFlowActionVariantV2 as EmbeddedFlowActionVariant, - EmbeddedFlowTextVariantV2 as EmbeddedFlowTextVariant, - EmbeddedFlowEventTypeV2 as EmbeddedFlowEventType, - type EmbeddedFlowComponentV2 as EmbeddedFlowComponent, - type EmbeddedFlowResponseDataV2 as EmbeddedFlowResponseData, - type EmbeddedFlowExecuteRequestConfigV2 as EmbeddedFlowExecuteRequestConfig, - EmbeddedSignInFlowStatusV2 as EmbeddedSignInFlowStatus, - EmbeddedSignInFlowTypeV2 as EmbeddedSignInFlowType, - type ExtendedEmbeddedSignInFlowResponseV2 as ExtendedEmbeddedSignInFlowResponse, - type EmbeddedSignInFlowResponseV2 as EmbeddedSignInFlowResponse, - type EmbeddedSignInFlowCompleteResponseV2 as EmbeddedSignInFlowCompleteResponse, - type EmbeddedSignInFlowInitiateRequestV2 as EmbeddedSignInFlowInitiateRequest, - type EmbeddedSignInFlowRequestV2 as EmbeddedSignInFlowRequest, - type EmbeddedSignUpFlowStatusV2 as EmbeddedSignUpFlowStatus, - type EmbeddedSignUpFlowTypeV2 as EmbeddedSignUpFlowType, - type ExtendedEmbeddedSignUpFlowResponseV2 as ExtendedEmbeddedSignUpFlowResponse, - type EmbeddedSignUpFlowResponseV2 as EmbeddedSignUpFlowResponse, - type EmbeddedSignUpFlowCompleteResponseV2 as EmbeddedSignUpFlowCompleteResponse, - type EmbeddedSignUpFlowInitiateRequestV2 as EmbeddedSignUpFlowInitiateRequest, - type EmbeddedSignUpFlowRequestV2 as EmbeddedSignUpFlowRequest, - type EmbeddedSignUpFlowErrorResponseV2 as EmbeddedSignUpFlowErrorResponse, + EmbeddedFlowComponentType, + EmbeddedFlowActionVariant, + EmbeddedFlowTextVariant, + EmbeddedFlowEventType, + type EmbeddedFlowComponent, + type EmbeddedFlowResponseData, + type EmbeddedFlowExecuteRequestConfig, + EmbeddedSignInFlowStatus, + EmbeddedSignInFlowType, + type ExtendedEmbeddedSignInFlowResponse, + type EmbeddedSignInFlowResponse, + type EmbeddedSignInFlowCompleteResponse, + type EmbeddedSignInFlowInitiateRequest, + type EmbeddedSignInFlowRequest, + EmbeddedSignUpFlowStatus, + EmbeddedSignUpFlowType, + type ExtendedEmbeddedSignUpFlowResponse, + type EmbeddedSignUpFlowResponse, + type EmbeddedSignUpFlowCompleteResponse, + type EmbeddedSignUpFlowInitiateRequest, + type EmbeddedSignUpFlowRequest, + type EmbeddedSignUpFlowErrorResponse, type ComponentRenderContext, type ComponentsExtensions, type ComponentRenderer, - EmbeddedRecoveryFlowStatusV2 as EmbeddedRecoveryFlowStatus, - EmbeddedRecoveryFlowTypeV2 as EmbeddedRecoveryFlowType, - type EmbeddedRecoveryFlowResponseV2 as EmbeddedRecoveryFlowResponse, - type EmbeddedRecoveryFlowInitiateRequestV2 as EmbeddedRecoveryFlowInitiateRequest, - type EmbeddedRecoveryFlowRequestV2 as EmbeddedRecoveryFlowRequest, - type EmbeddedRecoveryFlowErrorResponseV2 as EmbeddedRecoveryFlowErrorResponse, + EmbeddedRecoveryFlowStatus, + EmbeddedRecoveryFlowType, + type EmbeddedRecoveryFlowResponse, + type EmbeddedRecoveryFlowInitiateRequest, + type EmbeddedRecoveryFlowRequest, + type EmbeddedRecoveryFlowErrorResponse, } from '@thunderid/browser'; diff --git a/packages/react/src/utils/v2/buildThemeConfigFromFlowMeta.ts b/packages/react/src/utils/buildThemeConfigFromFlowMeta.ts similarity index 100% rename from packages/react/src/utils/v2/buildThemeConfigFromFlowMeta.ts rename to packages/react/src/utils/buildThemeConfigFromFlowMeta.ts diff --git a/packages/react/src/utils/v2/flowTransformer.ts b/packages/react/src/utils/flowTransformer.ts similarity index 98% rename from packages/react/src/utils/v2/flowTransformer.ts rename to packages/react/src/utils/flowTransformer.ts index 3589ee0..58181ca 100644 --- a/packages/react/src/utils/v2/flowTransformer.ts +++ b/packages/react/src/utils/flowTransformer.ts @@ -31,7 +31,7 @@ * * Usage: * ```typescript - * import { normalizeFlowResponse } from '../../../utils/v2/flowTransformer'; + * import { normalizeFlowResponse } from '../../../utils/flowTransformer'; * * const { executionId, components } = normalizeFlowResponse(apiResponse, t, { * defaultErrorKey: 'components.signIn.errors.generic' @@ -42,9 +42,9 @@ * consistent response handling across all embedded flows. */ -import {EmbeddedFlowComponentV2 as EmbeddedFlowComponent, FlowMetadataResponse} from '@thunderid/browser'; +import {EmbeddedFlowComponent, FlowMetadataResponse} from '@thunderid/browser'; import resolveTranslationsInArray from './resolveTranslationsInArray'; -import {UseTranslation} from '../../hooks/useTranslation'; +import {UseTranslation} from '../hooks/useTranslation'; /** * Generic flow error response interface that covers common error structure diff --git a/packages/react/src/utils/v2/getAuthComponentHeadings.ts b/packages/react/src/utils/getAuthComponentHeadings.ts similarity index 98% rename from packages/react/src/utils/v2/getAuthComponentHeadings.ts rename to packages/react/src/utils/getAuthComponentHeadings.ts index 9b99075..29c0091 100644 --- a/packages/react/src/utils/v2/getAuthComponentHeadings.ts +++ b/packages/react/src/utils/getAuthComponentHeadings.ts @@ -16,7 +16,7 @@ * under the License. */ -import {EmbeddedFlowComponentV2 as EmbeddedFlowComponent} from '@thunderid/browser'; +import {EmbeddedFlowComponent} from '@thunderid/browser'; /** * Result of heading extraction from flow components diff --git a/packages/react/src/utils/v2/passkey.ts b/packages/react/src/utils/passkey.ts similarity index 100% rename from packages/react/src/utils/v2/passkey.ts rename to packages/react/src/utils/passkey.ts diff --git a/packages/react/src/utils/v2/resolveTranslationsInArray.ts b/packages/react/src/utils/resolveTranslationsInArray.ts similarity index 96% rename from packages/react/src/utils/v2/resolveTranslationsInArray.ts rename to packages/react/src/utils/resolveTranslationsInArray.ts index f75db66..e628e3f 100644 --- a/packages/react/src/utils/v2/resolveTranslationsInArray.ts +++ b/packages/react/src/utils/resolveTranslationsInArray.ts @@ -18,7 +18,7 @@ import {FlowMetadataResponse} from '@thunderid/browser'; import {resolveTranslationsInObject} from './resolveTranslationsInObject'; -import {UseTranslation} from '../../hooks/useTranslation'; +import {UseTranslation} from '../hooks/useTranslation'; /** * Recursively resolves translation and meta template strings in an array of objects. diff --git a/packages/react/src/utils/v2/resolveTranslationsInObject.ts b/packages/react/src/utils/resolveTranslationsInObject.ts similarity index 96% rename from packages/react/src/utils/v2/resolveTranslationsInObject.ts rename to packages/react/src/utils/resolveTranslationsInObject.ts index 8fb6ca0..9194afe 100644 --- a/packages/react/src/utils/v2/resolveTranslationsInObject.ts +++ b/packages/react/src/utils/resolveTranslationsInObject.ts @@ -17,7 +17,7 @@ */ import {FlowMetadataResponse, resolveFlowTemplateLiterals} from '@thunderid/browser'; -import {UseTranslation} from '../../hooks/useTranslation'; +import {UseTranslation} from '../hooks/useTranslation'; /** * Resolves all {{ t() }} and {{ meta() }} template expressions in an object's string properties. diff --git a/packages/vue/src/ThunderIDVueClient.ts b/packages/vue/src/ThunderIDVueClient.ts index 268937b..8617a8c 100644 --- a/packages/vue/src/ThunderIDVueClient.ts +++ b/packages/vue/src/ThunderIDVueClient.ts @@ -23,12 +23,10 @@ import { UserProfile, User, generateUserProfile, - EmbeddedFlowExecuteResponse, SignUpOptions, - EmbeddedFlowExecuteRequestPayload, ThunderIDRuntimeError, - executeEmbeddedSignUpFlowV2, - executeEmbeddedSignInFlowV2, + executeEmbeddedSignUpFlow, + executeEmbeddedSignInFlow, Organization, IdToken, AllOrganizationsApiResponse, @@ -38,9 +36,9 @@ import { HttpResponse, TokenExchangeRequestConfig, isEmpty, - EmbeddedSignInFlowResponseV2, - EmbeddedSignInFlowStatusV2, - EmbeddedSignUpFlowStatusV2, + EmbeddedSignInFlowResponse, + EmbeddedSignInFlowStatus, + EmbeddedSignUpFlowStatus, deriveOrganizationHandleFromBaseUrl, StorageManager, } from '@thunderid/browser'; @@ -315,7 +313,7 @@ class ThunderIDVueClient exte const authId: string = authIdFromUrl || authIdFromStorage || ''; const baseUrl: string = configData?.baseUrl || ''; - const response: EmbeddedSignInFlowResponseV2 = await executeEmbeddedSignInFlowV2({ + const response: EmbeddedSignInFlowResponse = await executeEmbeddedSignInFlow({ authId, baseUrl, payload: arg1, @@ -325,7 +323,7 @@ class ThunderIDVueClient exte if ( response && typeof response === 'object' && - response.flowStatus === EmbeddedSignInFlowStatusV2.Complete && + response.flowStatus === EmbeddedSignInFlowStatus.Complete && response.assertion ) { const decodedAssertion: { @@ -367,9 +365,7 @@ class ThunderIDVueClient exte return (await super.signInSilently(options as Record))!; } - override async signUp(options?: SignUpOptions): Promise; - override async signUp(payload: EmbeddedFlowExecuteRequestPayload): Promise; - override async signUp(...args: any[]): Promise { + override async signUp(...args: any[]): Promise { const configData: any = await this.getStorageManager().getConfigData(); const firstArg: any = args[0]; const baseUrl: string = configData?.baseUrl || ''; @@ -384,19 +380,16 @@ class ThunderIDVueClient exte await this.getStorageManager().setHybridDataParameter('authId', authIdFromUrl); } - const response: any = await executeEmbeddedSignUpFlowV2({ + const response: any = await executeEmbeddedSignUpFlow({ authId, baseUrl, - payload: - typeof firstArg === 'object' && 'flowType' in firstArg - ? {...(firstArg as EmbeddedFlowExecuteRequestPayload), verbose: true} - : (firstArg as EmbeddedFlowExecuteRequestPayload), + payload: typeof firstArg === 'object' && 'flowType' in firstArg ? {...firstArg, verbose: true} : firstArg, }); if ( response && typeof response === 'object' && - response.flowStatus === EmbeddedSignUpFlowStatusV2.Complete && + response.flowStatus === EmbeddedSignUpFlowStatus.Complete && response.assertion ) { const decodedAssertion: { diff --git a/packages/vue/src/components/auth/sign-in/AuthOptionFactoryCore.ts b/packages/vue/src/components/auth/sign-in/AuthOptionFactoryCore.ts index 57f4797..05289d3 100644 --- a/packages/vue/src/components/auth/sign-in/AuthOptionFactoryCore.ts +++ b/packages/vue/src/components/auth/sign-in/AuthOptionFactoryCore.ts @@ -19,18 +19,18 @@ import { FieldType, FlowMetadataResponse, - EmbeddedFlowComponentV2 as EmbeddedFlowComponent, - EmbeddedFlowComponentTypeV2 as EmbeddedFlowComponentType, - EmbeddedFlowTextVariantV2 as EmbeddedFlowTextVariant, - EmbeddedFlowEventTypeV2 as EmbeddedFlowEventType, + EmbeddedFlowComponent, + EmbeddedFlowComponentType, + EmbeddedFlowTextVariant, + EmbeddedFlowEventType, resolveFlowTemplateLiterals, extractEmojiFromUri, isEmojiUri, - ConsentPurposeDataV2 as ConsentPurposeData, - ConsentPromptDataV2 as ConsentPromptData, - ConsentDecisionsV2 as ConsentDecisions, - ConsentPurposeDecisionV2 as ConsentPurposeDecision, - ConsentAttributeElementV2 as ConsentAttributeElement, + ConsentPurposeData, + ConsentPromptData, + ConsentDecisions, + ConsentPurposeDecision, + ConsentAttributeElement, } from '@thunderid/browser'; import DOMPurify from 'dompurify'; import {h, type VNode} from 'vue'; diff --git a/packages/vue/src/components/auth/sign-in/BaseSignIn.ts b/packages/vue/src/components/auth/sign-in/BaseSignIn.ts index 1e584cf..7faae76 100644 --- a/packages/vue/src/components/auth/sign-in/BaseSignIn.ts +++ b/packages/vue/src/components/auth/sign-in/BaseSignIn.ts @@ -1,5 +1,5 @@ /** - * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com). + * Copyright (c) 2025-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 @@ -16,32 +16,373 @@ * under the License. */ -import {Platform} from '@thunderid/browser'; -import {type Component, type SetupContext, type VNode, defineComponent, h} from 'vue'; -import BaseSignInV1 from './v1/BaseSignIn'; -import BaseSignInV2 from './v2/BaseSignIn'; -import useThunderID from '../../../composables/useThunderID'; +import { + withVendorCSSClassPrefix, + EmbeddedSignInFlowRequest, + EmbeddedFlowComponent, + FlowMetadataResponse, +} from '@thunderid/browser'; +import { + type ComputedRef, + type Component, + type PropType, + type Ref, + type SetupContext, + type VNode, + computed, + defineComponent, + h, + ref, + watch, +} from 'vue'; +import {renderSignInComponents} from './AuthOptionFactory'; +import useFlow from '../../../composables/useFlow'; +import useFlowMeta from '../../../composables/useFlowMeta'; +import useI18n from '../../../composables/useI18n'; +import {extractErrorMessage} from '../../../utils/flowTransformer'; +import Alert from '../../primitives/Alert'; +import Card from '../../primitives/Card'; +import Spinner from '../../primitives/Spinner'; +import Typography from '../../primitives/Typography'; -export type {BaseSignInRenderProps, BaseSignInProps} from './v2/BaseSignIn'; +/** + * Render props passed to the default scoped slot for custom UI rendering. + */ +export interface BaseSignInRenderProps { + components: EmbeddedFlowComponent[]; + error?: Error | null; + fieldErrors: Record; + handleInputChange: (name: string, value: string) => void; + handleSubmit: (component: EmbeddedFlowComponent, data?: Record) => Promise; + isLoading: boolean; + isTimeoutDisabled?: boolean; + isValid: boolean; + messages: {message: string; type: string}[]; + meta: FlowMetadataResponse | null; + subtitle: string | undefined; + title: string; + touched: Record; + validateForm: () => {fieldErrors: Record; isValid: boolean}; + values: Record; +} + +export interface BaseSignInProps { + additionalData?: Record; + buttonClassName?: string; + className?: string; + components?: EmbeddedFlowComponent[]; + error?: Error | null; + errorClassName?: string; + inputClassName?: string; + isLoading?: boolean; + isTimeoutDisabled?: boolean; + messageClassName?: string; + onSubmit?: (payload: EmbeddedSignInFlowRequest, component: EmbeddedFlowComponent) => Promise; + size?: 'small' | 'medium' | 'large'; + variant?: 'elevated' | 'outlined' | 'flat'; +} + +interface FieldDefinition { + name: string; + required: boolean; + type: string; +} + +const extractFormFields = (flowComponents: EmbeddedFlowComponent[]): FieldDefinition[] => { + const fields: FieldDefinition[] = []; + const process = (comps: EmbeddedFlowComponent[]): void => { + comps.forEach((c: any) => { + if (c.type === 'TEXT_INPUT' || c.type === 'PASSWORD_INPUT' || c.type === 'EMAIL_INPUT' || c.type === 'SELECT') { + fields.push({name: c.ref, required: c.required || false, type: c.type}); + } + if (c.components) { + process(c.components); + } + }); + }; + process(flowComponents); + return fields; +}; /** - * BaseSignIn โ€” platform-aware base sign-in component. + * BaseSignIn โ€” unstyled app-native sign-in presentation component. + * + * Renders the server-driven UI components from an embedded authentication flow. + * Manages local form state (values, touched, errors) and delegates submission to the parent SignIn component. + * + * Supports render props via the `default` scoped slot for complete UI customization. * - * Routes to the V1 (authenticator-based) or V2 (component-driven) BaseSignIn - * based on the configured `platform`. + * @example + * ```vue + * + * + * + * + * + * + * + * + * ``` */ const BaseSignIn: Component = defineComponent({ name: 'BaseSignIn', - inheritAttrs: false, - setup(_props: Record, {attrs, slots}: SetupContext): () => VNode { - const {platform} = useThunderID(); + props: { + additionalData: { + default: (): Record => ({}), + type: Object as PropType>, + }, + buttonClassName: {default: '', type: String}, + className: {default: '', type: String}, + components: { + default: (): EmbeddedFlowComponent[] => [], + type: Array as PropType, + }, + error: {default: null, type: Object as PropType}, + errorClassName: {default: '', type: String}, + inputClassName: {default: '', type: String}, + isLoading: {default: false, type: Boolean}, + isTimeoutDisabled: {default: false, type: Boolean}, + messageClassName: {default: '', type: String}, + size: { + default: 'medium', + type: String as PropType<'small' | 'medium' | 'large'>, + }, + variant: { + default: 'outlined', + type: String as PropType<'elevated' | 'outlined' | 'flat'>, + }, + }, + emits: ['error', 'success'], + setup( + props: Readonly<{ + additionalData: Record; + buttonClassName: string; + className: string; + components: EmbeddedFlowComponent[]; + error: Error | null; + errorClassName: string; + inputClassName: string; + isLoading: boolean; + isTimeoutDisabled: boolean; + messageClassName: string; + onSubmit?: (payload: EmbeddedSignInFlowRequest, component: EmbeddedFlowComponent) => Promise; + size: 'small' | 'medium' | 'large'; + variant: 'elevated' | 'outlined' | 'flat'; + }>, + {slots, emit, attrs}: SetupContext, + ): () => VNode | null { + const {meta: metaRef} = useFlowMeta(); + const {t} = useI18n(); + const {subtitle: flowSubtitle, title: flowTitle, messages: flowMessages, addMessage, clearMessages} = useFlow(); + + const isSubmitting: Ref = ref(false); + const apiError: Ref = ref(null); + + const isLoading: ComputedRef = computed(() => props.isLoading || isSubmitting.value); + + // Form state + const formValues: Ref> = ref({}); + const touchedFields: Ref> = ref({}); + + // Reset form state when components change (new flow step) + watch( + () => props.components, + (newComponents: EmbeddedFlowComponent[]) => { + const fields: FieldDefinition[] = extractFormFields(newComponents || []); + const freshValues: Record = {}; + fields.forEach((f: FieldDefinition) => { + freshValues[f.name] = ''; + }); + formValues.value = freshValues; + touchedFields.value = {}; + }, + {deep: false, immediate: true}, + ); + + // Computed form errors based on current values + touched + const formErrors: ComputedRef> = computed>(() => { + const fields: FieldDefinition[] = extractFormFields(props.components || []); + const errors: Record = {}; + fields.forEach((field: FieldDefinition) => { + const value: string = formValues.value[field.name] || ''; + const isTouched: boolean = touchedFields.value[field.name] || false; + if (field.required && isTouched && (!value || value.trim() === '')) { + errors[field.name] = t('validations.required.field.error') || 'This field is required'; + } + if (field.type === 'EMAIL_INPUT' && value && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) { + errors[field.name] = t('field.email.invalid') || 'Invalid email address'; + } + }); + return errors; + }); + + const isFormValid: ComputedRef = computed(() => Object.keys(formErrors.value).length === 0); + + const handleError = (error: any): void => { + const errorMessage: string = extractErrorMessage(error, t); + apiError.value = error instanceof Error ? error : new Error(errorMessage); + clearMessages(); + addMessage({message: errorMessage, type: 'error'}); + }; + + const handleInputChange = (name: string, value: string): void => { + formValues.value = {...formValues.value, [name]: value}; + }; - return (): VNode => { - if (platform === Platform.ThunderID) { - return h(BaseSignInV2, {...attrs}, slots); + const handleInputBlur = (name: string): void => { + touchedFields.value = {...touchedFields.value, [name]: true}; + }; + + const touchAllFields = (): void => { + const fields: FieldDefinition[] = extractFormFields(props.components || []); + const newTouched: Record = {}; + fields.forEach((f: FieldDefinition) => { + newTouched[f.name] = true; + }); + touchedFields.value = newTouched; + }; + + const validateForm = (): {fieldErrors: Record; isValid: boolean} => { + touchAllFields(); + const errors: Record = formErrors.value; + return {fieldErrors: errors, isValid: Object.keys(errors).length === 0}; + }; + + const handleSubmit = async ( + component: EmbeddedFlowComponent, + data?: Record, + skipValidation?: boolean, + ): Promise => { + if (!skipValidation) { + const {isValid} = validateForm(); + if (!isValid) return; + } + + isSubmitting.value = true; + apiError.value = null; + clearMessages(); + + try { + const filteredInputs: Record = {}; + if (data) { + Object.keys(data).forEach((key: string) => { + if (data[key] !== undefined && data[key] !== null && data[key] !== '') { + filteredInputs[key] = data[key]; + } + }); + } + + const payload: EmbeddedSignInFlowRequest = { + ...((component as any).id ? {action: (component as any).id} : {}), + inputs: filteredInputs, + }; + + await props.onSubmit?.(payload, component); + } catch (err: unknown) { + handleError(err); + emit('error', err); + } finally { + isSubmitting.value = false; } + }; + + const renderComponents = (): VNode[] => + renderSignInComponents( + props.components || [], + formValues.value, + touchedFields.value, + formErrors.value, + isLoading.value, + isFormValid.value, + handleInputChange, + { + additionalData: props.additionalData, + buttonClassName: props.buttonClassName, + inputClassName: props.inputClassName, + isTimeoutDisabled: props.isTimeoutDisabled, + meta: (metaRef as Ref).value, + onInputBlur: handleInputBlur, + onSubmit: handleSubmit, + size: props.size, + t, + }, + ); + + return (): VNode | null => { + const containerClass: string = [ + withVendorCSSClassPrefix('signin'), + withVendorCSSClassPrefix(`signin--${props.size}`), + withVendorCSSClassPrefix(`signin--${props.variant}`), + props.className, + ] + .filter(Boolean) + .join(' '); + + // If a scoped slot is provided, use render props pattern + if (slots['default']) { + const renderProps: BaseSignInRenderProps = { + components: props.components || [], + error: apiError.value, + fieldErrors: formErrors.value, + handleInputChange, + handleSubmit, + isLoading: isLoading.value, + isTimeoutDisabled: props.isTimeoutDisabled, + isValid: isFormValid.value, + messages: (flowMessages as Ref<{message: string; type: string}[]>).value || [], + meta: (metaRef as Ref).value, + subtitle: (flowSubtitle as Ref).value, + title: (flowTitle as Ref).value || t('signin.heading') || 'Sign In', + touched: touchedFields.value, + validateForm, + values: formValues.value, + }; + return h('div', {class: containerClass, ...attrs}, slots['default'](renderProps)); + } + + // Loading state + if (isLoading.value && (!props.components || props.components.length === 0)) { + return h(Card, {class: containerClass, variant: props.variant}, () => + h('div', {style: 'display:flex;justify-content:center;padding:2rem'}, h(Spinner)), + ); + } + + // No components available + if (!props.components || props.components.length === 0) { + return h(Card, {class: containerClass, variant: props.variant}, () => + h(Alert, {severity: 'warning'}, () => + h( + Typography, + {variant: 'body1'}, + () => t('errors.signin.components.not.available') || 'No sign-in options available', + ), + ), + ); + } + + const messages: {message: string; type: string}[] = + (flowMessages as Ref<{message: string; type: string}[]>).value || []; + const externalError: Error | null = props.error; - return h(BaseSignInV1, {...attrs}, slots); + return h(Card, {class: containerClass, ...attrs, variant: props.variant}, () => [ + // Show errors and flow messages + (externalError || messages.length > 0) && + h( + 'div', + {class: [withVendorCSSClassPrefix('signin__messages'), props.messageClassName].filter(Boolean).join(' ')}, + [ + externalError && + h(Alert, {severity: 'error'}, () => h(Typography, {variant: 'body2'}, () => externalError.message)), + ...messages.map((msg: {message: string; type: string}, index: number) => + h(Alert, {key: index, severity: msg.type === 'error' ? 'error' : 'info'}, () => + h(Typography, {variant: 'body2'}, () => msg.message), + ), + ), + ], + ), + // Render flow components + h('div', {class: withVendorCSSClassPrefix('signin__content')}, renderComponents()), + ]); }; }, }); diff --git a/packages/vue/src/components/auth/sign-in/SignIn.ts b/packages/vue/src/components/auth/sign-in/SignIn.ts index 4e3c5a8..a59c899 100644 --- a/packages/vue/src/components/auth/sign-in/SignIn.ts +++ b/packages/vue/src/components/auth/sign-in/SignIn.ts @@ -1,5 +1,5 @@ /** - * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com). + * Copyright (c) 2025-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 @@ -16,19 +16,84 @@ * under the License. */ -import {Platform} from '@thunderid/browser'; -import {type Component, type PropType, type SetupContext, type VNode, defineComponent, h} from 'vue'; -import SignInV1 from './v1/SignIn'; -import SignInV2 from './v2/SignIn'; +import { + ThunderIDRuntimeError, + type ConsentPurposeData, + EmbeddedFlowComponent, + EmbeddedFlowType, + EmbeddedSignInFlowRequest, + EmbeddedSignInFlowResponse, + EmbeddedSignInFlowStatus, + EmbeddedSignInFlowType, + FlowMetadataResponse, +} from '@thunderid/browser'; +import { + type Component, + type PropType, + type Ref, + type SetupContext, + type VNode, + defineComponent, + h, + onMounted, + onUnmounted, + ref, + watch, +} from 'vue'; +import BaseSignIn from './BaseSignIn'; +import useFlowMeta from '../../../composables/useFlowMeta'; +import useI18n from '../../../composables/useI18n'; +import {useOAuthCallback, type OAuthCallbackPayload} from '../../../composables/useOAuthCallback'; import useThunderID from '../../../composables/useThunderID'; +import {extractErrorMessage, normalizeFlowResponse} from '../../../utils/flowTransformer'; +import {initiateOAuthRedirect} from '../../../utils/oauth'; +import {handlePasskeyAuthentication, handlePasskeyRegistration} from '../../../utils/passkey'; -export type {SignInRenderProps} from './v2/SignIn'; +const EXECUTION_ID_STORAGE_KEY = 'thunderid_execution_id'; + +interface PasskeyState { + actionId: string | null; + challenge: string | null; + creationOptions: string | null; + error: Error | null; + executionId: string | null; + isActive: boolean; +} + +/** + * Render props passed to the default scoped slot for custom UI rendering. + */ +export interface SignInRenderProps { + additionalData?: Record; + components: EmbeddedFlowComponent[]; + error: Error | null; + initialize: () => Promise; + isInitialized: boolean; + isLoading: boolean; + isTimeoutDisabled?: boolean; + meta: FlowMetadataResponse | null; + onSubmit: (payload: EmbeddedSignInFlowRequest) => Promise; +} /** - * SignIn โ€” platform-aware sign-in component. + * SignIn โ€” app-native sign-in component with full flow lifecycle management. + * + * Initializes the authentication flow, handles passkey authentication/registration, + * OAuth redirect flows, and renders the UI via `BaseSignIn` or a scoped slot. + * + * @example + * ```vue + * + * * - * Routes to the V1 (authenticator-based) flow by default or the V2 - * (component-driven) flow when `platform` is set to `Platform.ThunderID`. + * + * + * + * + * ``` */ const SignIn: Component = defineComponent({ name: 'SignIn', @@ -47,37 +112,547 @@ const SignIn: Component = defineComponent({ setup( props: Readonly<{className: string; size: 'small' | 'medium' | 'large'; variant: 'elevated' | 'outlined' | 'flat'}>, {slots, emit, attrs}: SetupContext, - ): () => VNode { - const {platform} = useThunderID(); - - return (): VNode => { - if (platform === Platform.ThunderID) { - return h( - SignInV2, - { - ...attrs, - class: props.className, - onError: (err: Error) => emit('error', err), - onSuccess: (data: Record) => emit('success', data), - size: props.size, - variant: props.variant, - }, - slots, + ): () => VNode | null { + const { + applicationId, + afterSignInUrl, + signIn, + isInitialized, + isLoading: sdkLoading, + scopes, + getStorageManager, + } = useThunderID(); + const {meta: flowMeta} = useFlowMeta(); + const {t} = useI18n(); + + // Flow state + const components: Ref = ref([]); + const additionalData: Ref> = ref({}); + const currentExecutionId: Ref = ref(null); + const isFlowInitialized: Ref = ref(false); + const flowError: Ref = ref(null); + const isSubmitting: Ref = ref(false); + const isTimeoutDisabled: Ref = ref(false); + const passkeyState: Ref = ref({ + actionId: null, + challenge: null, + creationOptions: null, + error: null, + executionId: null, + isActive: false, + }); + + // Track one-time initialization and OAuth processing + let initializationAttempted = false; + const oauthCodeProcessedFlag: {value: boolean} = {value: false}; + let passkeyProcessed = false; + + // โ”€โ”€ Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + const persistExecutionId = (executionId: string | null): void => { + currentExecutionId.value = executionId; + if (executionId) { + sessionStorage.setItem(EXECUTION_ID_STORAGE_KEY, executionId); + } else { + sessionStorage.removeItem(EXECUTION_ID_STORAGE_KEY); + } + }; + + const clearFlowState = async (): Promise => { + persistExecutionId(null); + isFlowInitialized.value = false; + const sm = getStorageManager(); + if (sm) { + await sm.removeHybridDataParameter('authId'); + } + isTimeoutDisabled.value = false; + oauthCodeProcessedFlag.value = false; + }; + + interface UrlParams { + applicationId: string | null; + authId: string | null; + code: string | null; + error: string | null; + errorDescription: string | null; + executionId: string | null; + nonce: string | null; + state: string | null; + } + + const getUrlParams = (): UrlParams => { + const params: URLSearchParams = new URLSearchParams(window?.location?.search ?? ''); + return { + applicationId: params.get('applicationId'), + authId: params.get('authId'), + code: params.get('code'), + error: params.get('error'), + errorDescription: params.get('error_description'), + executionId: params.get('executionId'), + nonce: params.get('nonce'), + state: params.get('state'), + }; + }; + + const cleanupOAuthUrlParams = (): void => { + if (!window?.location?.href) return; + const url: URL = new URL(window.location.href); + ['error', 'error_description', 'code', 'state', 'nonce'].forEach((p: string) => url.searchParams.delete(p)); + window.history.replaceState({}, '', url.toString()); + }; + + const cleanupFlowUrlParams = (): void => { + if (!window?.location?.href) return; + const url: URL = new URL(window.location.href); + ['executionId', 'authId', 'applicationId'].forEach((p: string) => url.searchParams.delete(p)); + window.history.replaceState({}, '', url.toString()); + }; + + const setError = (error: Error): void => { + flowError.value = error; + isFlowInitialized.value = true; + emit('error', error); + }; + + // โ”€โ”€ Flow initialization โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + const initializeFlow = async (): Promise => { + const urlParams: UrlParams = getUrlParams(); + + oauthCodeProcessedFlag.value = false; + + if (urlParams.authId) { + const sm = getStorageManager(); + if (sm) { + await sm.setHybridDataParameter('authId', urlParams.authId); + } + } + + const effectiveApplicationId: string | null | undefined = applicationId || urlParams.applicationId; + + if (!urlParams.executionId && !effectiveApplicationId) { + const err: ThunderIDRuntimeError = new ThunderIDRuntimeError( + 'Either executionId or applicationId is required for authentication', + 'SIGN_IN_ERROR', + 'vue', ); + setError(err); + throw err; + } + + try { + flowError.value = null; + + let response: EmbeddedSignInFlowResponse; + + if (urlParams.executionId) { + response = (await signIn({executionId: urlParams.executionId})) as EmbeddedSignInFlowResponse; + } else { + response = (await signIn({ + applicationId: effectiveApplicationId, + flowType: EmbeddedFlowType.Authentication, + ...(scopes && {scopes}), + })) as EmbeddedSignInFlowResponse; + } + + // Handle OAuth redirect types + if (response.type === EmbeddedSignInFlowType.Redirection) { + const redirectURL: string | undefined = (response.data as any)?.redirectURL || (response as any)?.redirectURL; + if (redirectURL && window?.location) { + if (response.executionId) persistExecutionId(response.executionId); + if (urlParams.authId) { + const sm = getStorageManager(); + if (sm) { + await sm.setHybridDataParameter('authId', urlParams.authId); + } + } + initiateOAuthRedirect(redirectURL); + return; + } + } + + const { + executionId: normalizedExecutionId, + components: normalizedComponents, + additionalData: normalizedAdditionalData, + } = normalizeFlowResponse(response, t, {resolveTranslations: false}, flowMeta.value); + + if (normalizedExecutionId && normalizedComponents) { + persistExecutionId(normalizedExecutionId); + components.value = normalizedComponents; + additionalData.value = normalizedAdditionalData ?? {}; + isFlowInitialized.value = true; + isTimeoutDisabled.value = false; + cleanupFlowUrlParams(); + } + } catch (error: unknown) { + const err: any = error as any; + clearFlowState(); + setError(new Error(extractErrorMessage(err, t))); + initializationAttempted = false; + } + }; + + // โ”€โ”€ Submit handler โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + const handleSubmit = async (payload: EmbeddedSignInFlowRequest): Promise => { + const effectiveExecutionId: string | null = payload.executionId || currentExecutionId.value; + + if (!effectiveExecutionId) { + throw new Error('No active flow ID'); + } + + const processedInputs: Record = {...payload.inputs}; + + // Auto-compile consent decisions if on a consent prompt step + if (additionalData.value?.['consentPrompt']) { + try { + const consentRaw: any = additionalData.value['consentPrompt']; + const purposes: ConsentPurposeData[] = + typeof consentRaw === 'string' ? JSON.parse(consentRaw) : consentRaw.purposes || consentRaw; + + let isDeny = false; + if (payload.action) { + const findAction = (comps: any[]): any => { + if (!comps?.length) return null; + const found: any = comps.find((c: any) => c.id === payload.action); + if (found) return found; + return comps.reduce((acc: any, c: any) => acc || (c.components ? findAction(c.components) : null), null); + }; + const submitAction: any = findAction(components.value as any[]); + if (submitAction && submitAction.variant?.toLowerCase() !== 'primary') { + isDeny = true; + } + } + + const decisions: Record = { + purposes: purposes.map((p) => ({ + approved: !isDeny, + elements: [ + ...(p.essential ?? []).map((e) => ({approved: !isDeny, name: e.name})), + ...(p.optional ?? []).map((e) => { + const key = `__consent_opt__${p.purposeId}__${e.name}`; + return {approved: isDeny ? false : processedInputs[key] !== 'false', name: e.name}; + }), + ], + purposeName: p.purposeName, + })), + }; + processedInputs['consent_decisions'] = JSON.stringify(decisions); + + Object.keys(processedInputs).forEach((key: string) => { + if (key.startsWith('__consent_opt__')) delete processedInputs[key]; + }); + } catch { + // Ignore consent construction failures + } + } + + try { + isSubmitting.value = true; + flowError.value = null; + + const response: EmbeddedSignInFlowResponse = (await signIn({ + executionId: effectiveExecutionId, + ...payload, + inputs: processedInputs, + })) as EmbeddedSignInFlowResponse; + + // Handle OAuth redirect + if (response.type === EmbeddedSignInFlowType.Redirection) { + const redirectURL: string | undefined = (response.data as any)?.redirectURL || (response as any)?.redirectURL; + if (redirectURL && window?.location) { + if (response.executionId) persistExecutionId(response.executionId); + const urlParams: UrlParams = getUrlParams(); + if (urlParams.authId) { + const sm = getStorageManager(); + if (sm) { + await sm.setHybridDataParameter('authId', urlParams.authId); + } + } + initiateOAuthRedirect(redirectURL); + return; + } + } + + // Handle passkey challenge in response + if ( + response.data?.additionalData?.['passkeyChallenge'] || + response.data?.additionalData?.['passkeyCreationOptions'] + ) { + const {passkeyChallenge, passkeyCreationOptions} = response.data.additionalData as any; + passkeyProcessed = false; + passkeyState.value = { + actionId: 'submit', + challenge: passkeyChallenge || null, + creationOptions: passkeyCreationOptions || null, + error: null, + executionId: response.executionId || effectiveExecutionId, + isActive: true, + }; + isSubmitting.value = false; + return; + } + + const { + executionId: normalizedExecutionId, + components: normalizedComponents, + additionalData: normalizedAdditionalData, + } = normalizeFlowResponse(response, t, {resolveTranslations: false}, flowMeta.value); + + // Handle error flow status + if (response.flowStatus === EmbeddedSignInFlowStatus.Error) { + clearFlowState(); + const err: Error = new Error(extractErrorMessage(response, t)); + setError(err); + cleanupFlowUrlParams(); + throw err; + } + + // Handle flow completion + if (response.flowStatus === EmbeddedSignInFlowStatus.Complete) { + const redirectUrl: string | undefined = (response as any)?.redirectUrl || (response as any)?.redirect_uri; + const finalRedirectUrl: string | undefined = redirectUrl || afterSignInUrl; + + isSubmitting.value = false; + persistExecutionId(null); + isFlowInitialized.value = false; + const sm = getStorageManager(); + if (sm) { + await sm.removeHybridDataParameter('authId'); + } + cleanupOAuthUrlParams(); + + emit('success', { + redirectUrl: finalRedirectUrl, + ...(response.data || {}), + }); + + if (finalRedirectUrl && window?.location) { + window.location.href = finalRedirectUrl; + } + return; + } + + // Update flow state for next step + if (normalizedExecutionId && normalizedComponents) { + persistExecutionId(normalizedExecutionId); + components.value = normalizedComponents; + additionalData.value = normalizedAdditionalData ?? {}; + isTimeoutDisabled.value = false; + isFlowInitialized.value = true; + cleanupFlowUrlParams(); + + if ((response as any)?.error) { + flowError.value = new Error(extractErrorMessage(response, t)); + } + } + } catch (error: unknown) { + const err: any = error as any; + if (err instanceof Error && flowError.value === err) { + // Already set; re-throw + throw err; + } + clearFlowState(); + setError(new Error(extractErrorMessage(err, t))); + } finally { + isSubmitting.value = false; + } + }; + + // โ”€โ”€ Step timeout โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + let timeoutHandle: ReturnType | null = null; + + const scheduleTimeout = (timeoutMs: number): void => { + if (timeoutHandle) clearTimeout(timeoutHandle); + if (timeoutMs <= 0 || !isFlowInitialized.value) { + isTimeoutDisabled.value = false; + return; + } + const remaining: number = Math.max(0, Math.floor((timeoutMs - Date.now()) / 1000)); + if (remaining <= 0) { + isTimeoutDisabled.value = true; + setError(new Error(t('errors.signin.timeout') || 'Time allowed to complete the step has expired.')); + return; + } + timeoutHandle = setTimeout(() => { + isTimeoutDisabled.value = true; + setError(new Error(t('errors.signin.timeout') || 'Time allowed to complete the step has expired.')); + }, remaining * 1000); + }; + + watch( + () => [additionalData.value?.['stepTimeout'], isFlowInitialized.value] as [number | undefined, boolean], + ([timeoutMs]: [number | undefined, boolean]) => { + scheduleTimeout(Number(timeoutMs) || 0); + }, + ); + + onUnmounted(() => { + if (timeoutHandle) clearTimeout(timeoutHandle); + }); + + // โ”€โ”€ Passkey processing โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + watch( + () => passkeyState.value, + async (state: PasskeyState) => { + if (!state.isActive || (!state.challenge && !state.creationOptions) || !state.executionId) return; + if (passkeyProcessed) return; + passkeyProcessed = true; + + try { + let inputs: Record; + + if (state.challenge) { + const passkeyResponse: string = await handlePasskeyAuthentication(state.challenge); + const obj: any = JSON.parse(passkeyResponse); + inputs = { + authenticatorData: obj.response.authenticatorData, + clientDataJSON: obj.response.clientDataJSON, + credentialId: obj.id, + signature: obj.response.signature, + userHandle: obj.response.userHandle, + }; + } else if (state.creationOptions) { + const passkeyResponse: string = await handlePasskeyRegistration(state.creationOptions); + const obj: any = JSON.parse(passkeyResponse); + inputs = { + attestationObject: obj.response.attestationObject, + clientDataJSON: obj.response.clientDataJSON, + credentialId: obj.id, + }; + } else { + throw new Error('No passkey challenge or creation options available'); + } + + await handleSubmit({executionId: state.executionId, inputs}); + + passkeyState.value = { + actionId: null, + challenge: null, + creationOptions: null, + error: null, + executionId: null, + isActive: false, + }; + } catch (error: unknown) { + const err: Error = error as Error; + passkeyState.value = {...passkeyState.value, error: err, isActive: false}; + flowError.value = err; + emit('error', err); + } + }, + {deep: true}, + ); + + // โ”€โ”€ OAuth callback (via composable) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + useOAuthCallback({ + currentFlowId: currentExecutionId, + flowIdStorageKey: EXECUTION_ID_STORAGE_KEY, + isInitialized, + isSubmitting, + onError: (err: any) => { + // Guard against double-processing when handleSubmit already set the error + if (!flowError.value) { + clearFlowState(); + setError(err instanceof Error ? err : new Error(String(err))); + } + }, + onSubmit: (payload: OAuthCallbackPayload) => handleSubmit({executionId: payload.flowId, inputs: payload.inputs}), + processedFlag: oauthCodeProcessedFlag, + setFlowId: persistExecutionId, + }); + + // โ”€โ”€ Lifecycle โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + onMounted(async () => { + const urlParams: UrlParams = getUrlParams(); + + if (urlParams.authId) { + const sm = getStorageManager(); + if (sm) { + await sm.setHybridDataParameter('authId', urlParams.authId); + } + } + }); + + // Initialize flow when SDK is ready (OAuth callback is handled by useOAuthCallback) + watch( + () => + [ + isInitialized.value, + sdkLoading.value, + isFlowInitialized.value, + currentExecutionId.value, + isSubmitting.value, + ] as [boolean, boolean, boolean, string | null, boolean], + ([initialized, loading, flowInit, executionId, submitting]: [ + boolean, + boolean, + boolean, + string | null, + boolean, + ]) => { + const urlParams: UrlParams = getUrlParams(); + const hasOAuthCode = !!urlParams.code; + const hasOAuthState = !!urlParams.state; + + // Initialize flow when SDK is ready and no flow is active + if ( + initialized && + !loading && + !flowInit && + !initializationAttempted && + !executionId && + !hasOAuthCode && + !hasOAuthState && + !submitting && + !oauthCodeProcessedFlag.value + ) { + initializationAttempted = true; + initializeFlow(); + } + }, + ); + + // โ”€โ”€ Render โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + return (): VNode | null => { + const combinedIsLoading: boolean = sdkLoading.value || isSubmitting.value || !isInitialized.value; + + // Scoped slot / render props pattern + if (slots['default']) { + const renderProps: SignInRenderProps = { + additionalData: additionalData.value, + components: components.value, + error: flowError.value, + initialize: initializeFlow, + isInitialized: isFlowInitialized.value, + isLoading: combinedIsLoading, + isTimeoutDisabled: isTimeoutDisabled.value, + meta: flowMeta.value, + onSubmit: handleSubmit, + }; + return h('div', {}, slots['default'](renderProps)); } - return h( - SignInV1, - { - ...attrs, - class: props.className, - onError: (err: Error) => emit('error', err), - onSuccess: (data: Record) => emit('success', data), - size: props.size, - variant: props.variant, - }, - slots, - ); + // Default BaseSignIn rendering + return h(BaseSignIn, { + ...attrs, + additionalData: additionalData.value, + class: props.className, + components: components.value, + error: flowError.value, + isLoading: combinedIsLoading || !isFlowInitialized.value, + isTimeoutDisabled: isTimeoutDisabled.value, + onError: (err: Error) => emit('error', err), + onSubmit: handleSubmit, + size: props.size, + variant: props.variant, + }); }; }, }); diff --git a/packages/vue/src/components/auth/sign-in/v1/BaseSignIn.ts b/packages/vue/src/components/auth/sign-in/v1/BaseSignIn.ts deleted file mode 100644 index f7c25e9..0000000 --- a/packages/vue/src/components/auth/sign-in/v1/BaseSignIn.ts +++ /dev/null @@ -1,871 +0,0 @@ -/** - * 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 { - EmbeddedSignInFlowAuthenticator, - EmbeddedSignInFlowInitiateResponse, - EmbeddedSignInFlowHandleResponse, - EmbeddedSignInFlowStepType, - EmbeddedSignInFlowStatus, - EmbeddedSignInFlowAuthenticatorPromptType, - ApplicationNativeAuthenticationConstants, - ThunderIDAPIError, - withVendorCSSClassPrefix, - EmbeddedSignInFlowHandleRequestPayload, - EmbeddedFlowExecuteRequestConfig, - handleWebAuthnAuthentication, -} from '@thunderid/browser'; -import { - type Component, - type PropType, - type Ref, - type SetupContext, - type VNode, - defineComponent, - h, - ref, - watch, -} from 'vue'; -import {createSignInOptionFromAuthenticator} from './options/SignInOptionFactory'; -import useFlow from '../../../../composables/useFlow'; -import useI18n from '../../../../composables/useI18n'; -import Alert from '../../../primitives/Alert'; -import Card from '../../../primitives/Card'; -import Divider from '../../../primitives/Divider'; -import Logo from '../../../primitives/Logo'; -import Spinner from '../../../primitives/Spinner'; -import Typography from '../../../primitives/Typography'; - -/** - * Authenticators that are currently hidden from the UI. - * OrganizationSSO is not yet supported in app-native authentication. - */ -const HIDDEN_AUTHENTICATORS: string[] = ['T3JnYW5pemF0aW9uQXV0aGVudGljYXRvcjpTU08']; - -const isPasskeyAuthenticator = (authenticator: EmbeddedSignInFlowAuthenticator): boolean => - authenticator.authenticatorId === ApplicationNativeAuthenticationConstants.SupportedAuthenticators.Passkey && - authenticator.metadata?.promptType === EmbeddedSignInFlowAuthenticatorPromptType.InternalPrompt && - !!(authenticator.metadata as any)?.additionalData?.challengeData; - -/** - * V1 BaseSignIn component โ€” authenticator-based app-native sign-in for Vue. - * - * Handles multi-step authentication flows, form rendering per-authenticator, - * redirect popups for OAuth, and passkey/FIDO WebAuthn. - */ -const BaseSignIn: Component = defineComponent({ - name: 'BaseSignInV1', - props: { - afterSignInUrl: {default: undefined, type: String}, - buttonClassName: {default: '', type: String}, - className: {default: '', type: String}, - errorClassName: {default: '', type: String}, - inputClassName: {default: '', type: String}, - isLoading: {default: false, type: Boolean}, - messageClassName: {default: '', type: String}, - onInitialize: {default: undefined, type: Function as PropType<() => Promise>}, - onSubmit: { - default: undefined, - type: Function as PropType< - ( - payload: EmbeddedSignInFlowHandleRequestPayload, - request: EmbeddedFlowExecuteRequestConfig, - ) => Promise - >, - }, - showLogo: {default: true, type: Boolean}, - showSubtitle: {default: true, type: Boolean}, - showTitle: {default: true, type: Boolean}, - size: {default: 'medium', type: String as PropType<'small' | 'medium' | 'large'>}, - variant: {default: 'outlined', type: String as PropType<'elevated' | 'outlined' | 'flat'>}, - }, - emits: ['error', 'flowChange', 'success'], - setup(props: any, {emit}: SetupContext): () => VNode { - const {t} = useI18n(); - const {title: flowTitle, subtitle: flowSubtitle, messages: flowMessages} = useFlow(); - - // โ”€โ”€ Reactive state โ”€โ”€ - const isInitRequestLoading: Ref = ref(false); - const isInitialized: Ref = ref(false); - const currentFlow: Ref = ref(null); - const currentAuthenticator: Ref = ref(null); - const error: Ref = ref(null); - const messages: Ref<{message: string; type: string}[]> = ref([]); - const formValues: Ref> = ref({}); - const touchedFields: Ref> = ref({}); - - const isLoading = (): boolean => props.isLoading || isInitRequestLoading.value; - - // โ”€โ”€ Form helpers โ”€โ”€ - - const setupFormFields = (authenticator: EmbeddedSignInFlowAuthenticator): void => { - const vals: Record = {}; - authenticator.metadata?.params?.forEach((param: any) => { - vals[param.param] = ''; - }); - formValues.value = vals; - touchedFields.value = {}; - }; - - const handleInputChange = (param: string, value: string): void => { - formValues.value = {...formValues.value, [param]: value}; - touchedFields.value = {...touchedFields.value, [param]: true}; - }; - - const touchAllFields = (): void => { - const touched: Record = {}; - Object.keys(formValues.value).forEach((key: string) => { - touched[key] = true; - }); - touchedFields.value = touched; - }; - - const validateForm = (): boolean => { - if (!currentAuthenticator.value) return true; - const required: string[] = currentAuthenticator.value.requiredParams || []; - - return required.every((key: string) => { - const val: string = formValues.value[key] || ''; - - return !!val && val.trim() !== ''; - }); - }; - - // โ”€โ”€ Next step processing โ”€โ”€ - - let handleAuthenticatorSelection: ( - authenticator: EmbeddedSignInFlowAuthenticator, - formData?: Record, - ) => Promise; - - const processNextStep = (response: any): void => { - if (response && 'flowId' in response && 'nextStep' in response) { - currentFlow.value = response; - - if (response.nextStep?.authenticators?.length > 0) { - if ( - response.nextStep.stepType === EmbeddedSignInFlowStepType.MultiOptionsPrompt && - response.nextStep.authenticators.length > 1 - ) { - currentAuthenticator.value = null; - } else { - const nextAuth: EmbeddedSignInFlowAuthenticator = response.nextStep.authenticators[0]; - if (isPasskeyAuthenticator(nextAuth)) { - handleAuthenticatorSelection(nextAuth).catch((err: unknown) => { - emit('error', err); - }); - return; - } - currentAuthenticator.value = nextAuth; - setupFormFields(nextAuth); - } - } - - if (response.nextStep?.messages) { - messages.value = response.nextStep.messages.map((msg: any) => ({ - message: msg.message || '', - type: msg.type || 'INFO', - })); - } - } - }; - - // โ”€โ”€ Redirect popup (OAuth flows) โ”€โ”€ - - const handleRedirectionIfNeeded = (response: EmbeddedSignInFlowHandleResponse): boolean => { - if ( - response && - 'nextStep' in response && - response.nextStep && - (response.nextStep as any).stepType === EmbeddedSignInFlowStepType.AuthenticatorPrompt && - (response.nextStep as any).authenticators?.length === 1 - ) { - const responseAuth: any = (response.nextStep as any).authenticators[0]; - if ( - responseAuth.metadata?.promptType === EmbeddedSignInFlowAuthenticatorPromptType.RedirectionPrompt && - responseAuth.metadata?.additionalData?.redirectUrl - ) { - const {redirectUrl} = responseAuth.metadata.additionalData; - const popup: Window | null = window.open( - redirectUrl, - 'oauth_popup', - 'width=500,height=600,scrollbars=yes,resizable=yes', - ); - - if (!popup) return false; - - let messageHandler: any; - let popupMonitor: any; - let hasProcessedCallback = false; - - const cleanup = (): void => { - window.removeEventListener('message', messageHandler); - if (popupMonitor) clearInterval(popupMonitor); - }; - - messageHandler = async (event: MessageEvent): Promise => { - if (event.source !== popup) return; - const expectedOrigin: string = props.afterSignInUrl - ? new URL(props.afterSignInUrl).origin - : window.location.origin; - if (event.origin !== expectedOrigin && event.origin !== window.location.origin) return; - - const {code, state} = event.data; - if (code && state) { - const payload: EmbeddedSignInFlowHandleRequestPayload = { - flowId: currentFlow.value!.flowId, - selectedAuthenticator: { - authenticatorId: responseAuth.authenticatorId, - params: {code, state}, - }, - }; - await props.onSubmit(payload, { - method: currentFlow.value?.links[0].method, - url: currentFlow.value?.links[0].href, - }); - popup.close(); - cleanup(); - } - }; - - window.addEventListener('message', messageHandler); - - popupMonitor = setInterval(async (): Promise => { - try { - if (popup.closed) { - cleanup(); - return; - } - if (hasProcessedCallback) return; - try { - const popupUrl: string = popup.location.href; - if (popupUrl && (popupUrl.includes('code=') || popupUrl.includes('error='))) { - hasProcessedCallback = true; - const url: URL = new URL(popupUrl); - const code: string | null = url.searchParams.get('code'); - const state: string | null = url.searchParams.get('state'); - const oauthError: string | null = url.searchParams.get('error'); - - if (oauthError) { - popup.close(); - cleanup(); - return; - } - if (code && state) { - const payload: EmbeddedSignInFlowHandleRequestPayload = { - flowId: currentFlow.value!.flowId, - selectedAuthenticator: { - authenticatorId: responseAuth.authenticatorId, - params: {code, state}, - }, - }; - const submitResponse: any = await props.onSubmit(payload, { - method: currentFlow.value?.links[0].method, - url: currentFlow.value?.links[0].href, - }); - popup.close(); - emit('flowChange', submitResponse); - if (submitResponse?.flowStatus === EmbeddedSignInFlowStatus.SuccessCompleted) { - emit('success', submitResponse.authData); - } - } - } - } catch { - // Cross-origin error expected during OAuth redirect - } - } catch { - // Ignore popup monitoring errors - } - }, 1000); - - return true; - } - } - return false; - }; - - // โ”€โ”€ Form submission โ”€โ”€ - - const handleSubmit = async (submittedValues: Record): Promise => { - if (!currentFlow.value || !currentAuthenticator.value) return; - - touchAllFields(); - if (!validateForm()) return; - - isInitRequestLoading.value = true; - error.value = null; - messages.value = []; - - try { - const payload: EmbeddedSignInFlowHandleRequestPayload = { - flowId: currentFlow.value.flowId, - selectedAuthenticator: { - authenticatorId: currentAuthenticator.value.authenticatorId, - params: submittedValues, - }, - }; - - const response: any = await props.onSubmit(payload, { - method: currentFlow.value.links[0].method, - url: currentFlow.value.links[0].href, - }); - emit('flowChange', response); - - if (response?.flowStatus === EmbeddedSignInFlowStatus.SuccessCompleted) { - emit('success', response.authData); - return; - } - if ( - response?.flowStatus === EmbeddedSignInFlowStatus.FailCompleted || - response?.flowStatus === EmbeddedSignInFlowStatus.FailIncomplete - ) { - error.value = t('errors.signin.flow.completion.failure'); - return; - } - if (handleRedirectionIfNeeded(response)) return; - - processNextStep(response); - } catch (err: any) { - error.value = err instanceof ThunderIDAPIError ? err.message : t('errors.signin.flow.failure'); - emit('error', err); - } finally { - isInitRequestLoading.value = false; - } - }; - - // โ”€โ”€ Authenticator selection (multi-option, passkey, redirect, form) โ”€โ”€ - - handleAuthenticatorSelection = async ( - authenticator: EmbeddedSignInFlowAuthenticator, - formData?: Record, - ): Promise => { - if (!currentFlow.value) return; - - if (formData) touchAllFields(); - - isInitRequestLoading.value = true; - error.value = null; - messages.value = []; - - try { - // Passkey / FIDO - if (isPasskeyAuthenticator(authenticator)) { - const challengeData: any = (authenticator.metadata as any)?.additionalData?.challengeData; - if (!challengeData) throw new Error('Missing challenge data for passkey authentication'); - - const tokenResponse: any = await handleWebAuthnAuthentication(challengeData); - const payload: EmbeddedSignInFlowHandleRequestPayload = { - flowId: currentFlow.value.flowId, - selectedAuthenticator: { - authenticatorId: authenticator.authenticatorId, - params: {tokenResponse}, - }, - }; - const response: any = await props.onSubmit(payload, { - method: currentFlow.value.links[0].method, - url: currentFlow.value.links[0].href, - }); - emit('flowChange', response); - - if (response?.flowStatus === EmbeddedSignInFlowStatus.SuccessCompleted) { - emit('success', response.authData); - return; - } - if ( - response?.flowStatus === EmbeddedSignInFlowStatus.FailCompleted || - response?.flowStatus === EmbeddedSignInFlowStatus.FailIncomplete - ) { - error.value = t('errors.signin.flow.passkeys.completion.failure'); - return; - } - processNextStep(response); - return; - } - - // Redirection prompt (social login first-touch) - if (authenticator.metadata?.promptType === EmbeddedSignInFlowAuthenticatorPromptType.RedirectionPrompt) { - const payload: EmbeddedSignInFlowHandleRequestPayload = { - flowId: currentFlow.value.flowId, - selectedAuthenticator: { - authenticatorId: authenticator.authenticatorId, - params: {}, - }, - }; - const response: any = await props.onSubmit(payload, { - method: currentFlow.value.links[0].method, - url: currentFlow.value.links[0].href, - }); - emit('flowChange', response); - - if (response?.flowStatus === EmbeddedSignInFlowStatus.SuccessCompleted) { - emit('success', response.authData); - return; - } - handleRedirectionIfNeeded(response); - return; - } - - // Form data submission - if (formData) { - if (!validateForm()) return; - - const formPayload: EmbeddedSignInFlowHandleRequestPayload = { - flowId: currentFlow.value.flowId, - selectedAuthenticator: { - authenticatorId: authenticator.authenticatorId, - params: formData, - }, - }; - const formResponse: any = await props.onSubmit(formPayload, { - method: currentFlow.value.links[0].method, - url: currentFlow.value.links[0].href, - }); - emit('flowChange', formResponse); - - if (formResponse?.flowStatus === EmbeddedSignInFlowStatus.SuccessCompleted) { - emit('success', formResponse.authData); - return; - } - if ( - formResponse?.flowStatus === EmbeddedSignInFlowStatus.FailCompleted || - formResponse?.flowStatus === EmbeddedSignInFlowStatus.FailIncomplete - ) { - error.value = t('errors.signin.flow.completion.failure'); - return; - } - if (handleRedirectionIfNeeded(formResponse)) return; - processNextStep(formResponse); - return; - } - - // No form data โ€” direct selection or show form - const hasParams = !!(authenticator.metadata?.params && authenticator.metadata.params.length > 0); - if (!hasParams) { - const payload: EmbeddedSignInFlowHandleRequestPayload = { - flowId: currentFlow.value.flowId, - selectedAuthenticator: { - authenticatorId: authenticator.authenticatorId, - params: {}, - }, - }; - const response: any = await props.onSubmit(payload, { - method: currentFlow.value.links[0].method, - url: currentFlow.value.links[0].href, - }); - emit('flowChange', response); - - if (response?.flowStatus === EmbeddedSignInFlowStatus.SuccessCompleted) { - emit('success', response.authData); - return; - } - if ( - response?.flowStatus === EmbeddedSignInFlowStatus.FailCompleted || - response?.flowStatus === EmbeddedSignInFlowStatus.FailIncomplete - ) { - error.value = t('errors.signin.flow.completion.failure'); - return; - } - if (handleRedirectionIfNeeded(response)) return; - processNextStep(response); - } else { - currentAuthenticator.value = authenticator; - setupFormFields(authenticator); - } - } catch (err: any) { - const errorMessage: string = err instanceof ThunderIDAPIError ? err.message : t('errors.signin.flow.failure'); - error.value = errorMessage; - emit('error', err); - } finally { - isInitRequestLoading.value = false; - } - }; - - // โ”€โ”€ Multi-option checks โ”€โ”€ - - const hasMultipleOptions = (): boolean => - !!( - currentFlow.value && - 'nextStep' in currentFlow.value && - currentFlow.value.nextStep?.stepType === EmbeddedSignInFlowStepType.MultiOptionsPrompt && - currentFlow.value.nextStep?.authenticators && - currentFlow.value.nextStep.authenticators.length > 1 - ); - - const getAvailableAuthenticators = (): EmbeddedSignInFlowAuthenticator[] => { - if (!currentFlow.value || !('nextStep' in currentFlow.value) || !currentFlow.value.nextStep?.authenticators) { - return []; - } - return currentFlow.value.nextStep.authenticators; - }; - - // โ”€โ”€ Initialize flow on mount โ”€โ”€ - - let initAttempted = false; - - watch( - () => props.isLoading, - (loading: boolean) => { - if (!loading && !initAttempted && props.onInitialize) { - initAttempted = true; - (async (): Promise => { - isInitRequestLoading.value = true; - error.value = null; - - try { - const response: any = await props.onInitialize(); - currentFlow.value = response; - isInitialized.value = true; - emit('flowChange', response); - - if (response?.flowStatus === EmbeddedSignInFlowStatus.SuccessCompleted) { - emit('success', response.authData || {}); - return; - } - - if (response?.nextStep?.authenticators?.length > 0) { - if ( - response.nextStep.stepType === EmbeddedSignInFlowStepType.MultiOptionsPrompt && - response.nextStep.authenticators.length > 1 - ) { - currentAuthenticator.value = null; - } else { - const authenticator: EmbeddedSignInFlowAuthenticator = response.nextStep.authenticators[0]; - currentAuthenticator.value = authenticator; - setupFormFields(authenticator); - } - } - - if (response?.nextStep?.messages) { - messages.value = response.nextStep.messages.map((msg: any) => ({ - message: msg.message || '', - type: msg.type || 'INFO', - })); - } - } catch (err: any) { - error.value = err instanceof ThunderIDAPIError ? err.message : t('errors.signin.initialization'); - emit('error', err); - } finally { - isInitRequestLoading.value = false; - } - })(); - } - }, - {immediate: true}, - ); - - // โ”€โ”€ Render helpers โ”€โ”€ - - const renderAlertVariant = (type: string): string => { - const lower: string = type.toLowerCase(); - if (lower === 'error') return 'error'; - if (lower === 'warning') return 'warning'; - if (lower === 'success') return 'success'; - return 'info'; - }; - - const renderMessages = (): VNode[] => - messages.value.map((msg: any, i: number) => - h(Alert, {key: i, severity: renderAlertVariant(msg.type)}, {default: () => msg.message}), - ); - - const renderError = (): VNode | null => - error.value ? h(Alert, {severity: 'error'}, {default: () => error.value}) : null; - - // โ”€โ”€ Main render function โ”€โ”€ - - return (): VNode => { - const cardClass: string = [ - withVendorCSSClassPrefix('signin'), - withVendorCSSClassPrefix(`signin--${props.size}`), - withVendorCSSClassPrefix(`signin--${props.variant}`), - props.className, - ] - .filter(Boolean) - .join(' '); - - // Loading state - if (!isInitialized.value && isLoading()) { - return h('div', {}, [ - props.showLogo ? h('div', {class: withVendorCSSClassPrefix('signin__logo')}, [h(Logo)]) : null, - h( - Card, - {class: cardClass, variant: props.variant}, - { - default: () => [ - h('div', {class: withVendorCSSClassPrefix('signin__loading')}, [ - h(Spinner, {size: 'medium'}), - h(Typography, {variant: 'body1'}, {default: () => t('messages.loading.placeholder')}), - ]), - ], - }, - ), - ]); - } - - // Multi-option prompt (no single authenticator selected) - if (hasMultipleOptions() && !currentAuthenticator.value) { - const available: EmbeddedSignInFlowAuthenticator[] = getAvailableAuthenticators(); - - const userPromptAuths: EmbeddedSignInFlowAuthenticator[] = available.filter( - (auth: any) => - auth.metadata?.promptType === EmbeddedSignInFlowAuthenticatorPromptType.UserPrompt || - (auth.idp === 'LOCAL' && auth.metadata?.params && auth.metadata.params.length > 0), - ); - - const optionAuths: EmbeddedSignInFlowAuthenticator[] = available - .filter((auth: any) => !userPromptAuths.includes(auth)) - .filter((auth: any) => !HIDDEN_AUTHENTICATORS.includes(auth.authenticatorId)); - - return h('div', {}, [ - props.showLogo ? h('div', {class: withVendorCSSClassPrefix('signin__logo')}, [h(Logo)]) : null, - h( - Card, - {class: cardClass, variant: props.variant}, - { - default: () => { - const children: VNode[] = []; - - // Header - if (props.showTitle || props.showSubtitle) { - children.push( - h('div', {class: withVendorCSSClassPrefix('signin__header')}, [ - props.showTitle - ? h(Typography, {variant: 'h2'}, {default: () => flowTitle.value || t('signin.heading')}) - : null, - props.showSubtitle - ? h( - Typography, - {variant: 'body1'}, - {default: () => flowSubtitle.value || t('signin.subheading')}, - ) - : null, - ]), - ); - } - - // Flow messages - if (flowMessages.value?.length > 0) { - children.push( - h( - 'div', - {class: withVendorCSSClassPrefix('signin__flow-messages')}, - flowMessages.value.map((fm: any, i: number) => - h(Alert, {key: fm.id || i, severity: fm.type}, {default: () => fm.message}), - ), - ), - ); - } - - // Local messages & error - if (messages.value.length > 0) children.push(h('div', {}, renderMessages())); - const errNode: VNode | null = renderError(); - if (errNode) children.push(errNode); - - // User prompt authenticators (forms) - userPromptAuths.forEach((auth: EmbeddedSignInFlowAuthenticator, index: number) => { - if (index > 0) children.push(h(Divider, {}, {default: () => 'OR'})); - children.push( - h( - 'form', - { - onSubmit: (e: Event) => { - e.preventDefault(); - const fd: Record = {}; - auth.metadata?.params?.forEach((p: any) => { - fd[p.param] = formValues.value[p.param] || ''; - }); - handleAuthenticatorSelection(auth, fd); - }, - }, - [ - createSignInOptionFromAuthenticator( - auth, - formValues.value, - touchedFields.value, - isLoading(), - handleInputChange, - (a: any, fd: any) => handleAuthenticatorSelection(a, fd), - t, - { - buttonClassName: props.buttonClassName, - error: error.value, - inputClassName: props.inputClassName, - }, - ), - ], - ), - ); - }); - - // Divider between user prompts and options - if (userPromptAuths.length > 0 && optionAuths.length > 0) { - children.push(h(Divider, {}, {default: () => 'OR'})); - } - - // Option authenticators (social, multi-option buttons) - optionAuths.forEach((auth: EmbeddedSignInFlowAuthenticator) => { - children.push( - h('div', {key: auth.authenticatorId}, [ - createSignInOptionFromAuthenticator( - auth, - formValues.value, - touchedFields.value, - isLoading(), - handleInputChange, - (a: any, fd: any) => handleAuthenticatorSelection(a, fd), - t, - { - buttonClassName: props.buttonClassName, - error: error.value, - inputClassName: props.inputClassName, - }, - ), - ]), - ); - }); - - return children; - }, - }, - ), - ]); - } - - // No authenticator available (error state) - if (!currentAuthenticator.value) { - return h('div', {}, [ - props.showLogo ? h('div', {class: withVendorCSSClassPrefix('signin__logo')}, [h(Logo)]) : null, - h( - Card, - {class: cardClass, variant: props.variant}, - { - default: () => { - const errNode: VNode | null = renderError(); - return errNode - ? [errNode] - : [h(Typography, {variant: 'body1'}, {default: () => t('messages.loading.placeholder')})]; - }, - }, - ), - ]); - } - - // Passkey auto-trigger - if (isPasskeyAuthenticator(currentAuthenticator.value) && !isLoading()) { - handleAuthenticatorSelection(currentAuthenticator.value); - return h('div', {}, [ - props.showLogo ? h('div', {class: withVendorCSSClassPrefix('signin__logo')}, [h(Logo)]) : null, - h( - Card, - {class: cardClass, variant: props.variant}, - { - default: () => [ - h('div', {style: 'text-align:center'}, [ - h(Spinner, {size: 'large'}), - h( - Typography, - {variant: 'body1'}, - {default: () => t('passkey.authenticating') || 'Authenticating with passkey...'}, - ), - ]), - ], - }, - ), - ]); - } - - // Single authenticator form - return h('div', {}, [ - props.showLogo ? h('div', {class: withVendorCSSClassPrefix('signin__logo')}, [h(Logo)]) : null, - h( - Card, - {class: cardClass, variant: props.variant}, - { - default: () => { - const children: VNode[] = []; - - // Header - children.push( - h('div', {class: withVendorCSSClassPrefix('signin__header')}, [ - h(Typography, {variant: 'h2'}, {default: () => flowTitle.value || t('signin.heading')}), - h(Typography, {variant: 'body1'}, {default: () => flowSubtitle.value || t('signin.subheading')}), - ]), - ); - - // Flow messages - if (flowMessages.value?.length > 0) { - children.push( - h( - 'div', - {class: withVendorCSSClassPrefix('signin__flow-messages')}, - flowMessages.value.map((fm: any, i: number) => - h(Alert, {key: fm.id || i, severity: fm.type}, {default: () => fm.message}), - ), - ), - ); - } - - // Local messages & error - if (messages.value.length > 0) children.push(h('div', {}, renderMessages())); - const errNode: VNode | null = renderError(); - if (errNode) children.push(errNode); - - // Form - children.push( - h( - 'form', - { - class: withVendorCSSClassPrefix('signin__form'), - onSubmit: (e: Event) => { - e.preventDefault(); - const fd: Record = {}; - currentAuthenticator.value?.metadata?.params?.forEach((p: any) => { - fd[p.param] = formValues.value[p.param] || ''; - }); - handleSubmit(fd); - }, - }, - [ - createSignInOptionFromAuthenticator( - currentAuthenticator.value!, - formValues.value, - touchedFields.value, - isLoading(), - handleInputChange, - (_: any, fd: any) => handleSubmit(fd || formValues.value), - t, - { - buttonClassName: props.buttonClassName, - error: error.value, - inputClassName: props.inputClassName, - }, - ), - ], - ), - ); - - return children; - }, - }, - ), - ]); - }; - }, -}); - -export default BaseSignIn; diff --git a/packages/vue/src/components/auth/sign-in/v1/SignIn.ts b/packages/vue/src/components/auth/sign-in/v1/SignIn.ts deleted file mode 100644 index 4274e83..0000000 --- a/packages/vue/src/components/auth/sign-in/v1/SignIn.ts +++ /dev/null @@ -1,96 +0,0 @@ -/** - * 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 { - EmbeddedSignInFlowHandleRequestPayload, - EmbeddedSignInFlowHandleResponse, - EmbeddedSignInFlowInitiateResponse, -} from '@thunderid/browser'; -import {type Component, type PropType, type SetupContext, type VNode, defineComponent, h} from 'vue'; -import BaseSignIn from './BaseSignIn'; -import useThunderID from '../../../../composables/useThunderID'; - -/** - * V1 SignIn โ€” app-native sign-in component using the authenticator-based flow. - * - * Initialises the flow with `signIn({ response_mode: 'direct' })` and delegates - * all UI rendering to `BaseSignInV1`. - */ -const SignIn: Component = defineComponent({ - name: 'SignInV1', - props: { - className: {default: '', type: String}, - size: { - default: 'medium', - type: String as PropType<'small' | 'medium' | 'large'>, - }, - variant: { - default: 'outlined', - type: String as PropType<'elevated' | 'outlined' | 'flat'>, - }, - }, - emits: ['error', 'success'], - setup( - props: Readonly<{className: string; size: 'small' | 'medium' | 'large'; variant: 'elevated' | 'outlined' | 'flat'}>, - {emit, attrs}: SetupContext, - ): () => VNode { - const {signIn, afterSignInUrl, isInitialized, isLoading} = useThunderID(); - - const handleInitialize = async (): Promise => - (await signIn({response_mode: 'direct'})) as EmbeddedSignInFlowInitiateResponse; - - const handleOnSubmit = async ( - payload: EmbeddedSignInFlowHandleRequestPayload, - request: any, - ): Promise => - (await signIn(payload, request)) as EmbeddedSignInFlowHandleResponse; - - const handleSuccess = (authData: Record): void => { - emit('success', authData); - - if (authData && afterSignInUrl) { - const url: URL = new URL(afterSignInUrl, window.location.origin); - Object.entries(authData).forEach(([key, value]: [string, any]) => { - if (value !== undefined && value !== null) { - url.searchParams.append(key, String(value)); - } - }); - window.location.href = url.toString(); - } - }; - - return (): VNode => - h(BaseSignIn, { - ...attrs, - afterSignInUrl, - class: props.className, - isLoading: isLoading.value || !isInitialized.value, - onError: (err: Error) => emit('error', err), - onInitialize: handleInitialize, - onSubmit: handleOnSubmit, - onSuccess: handleSuccess, - showLogo: true, - showSubtitle: true, - showTitle: true, - size: props.size, - variant: props.variant, - }); - }, -}); - -export default SignIn; diff --git a/packages/vue/src/components/auth/sign-in/v1/options/SignInOptionFactory.ts b/packages/vue/src/components/auth/sign-in/v1/options/SignInOptionFactory.ts deleted file mode 100644 index aea446e..0000000 --- a/packages/vue/src/components/auth/sign-in/v1/options/SignInOptionFactory.ts +++ /dev/null @@ -1,267 +0,0 @@ -/** - * 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 { - ApplicationNativeAuthenticationConstants, - EmbeddedSignInFlowAuthenticator, - EmbeddedSignInFlowAuthenticatorKnownIdPType, - EmbeddedSignInFlowAuthenticatorParamType, - FieldType, -} from '@thunderid/browser'; -import {type VNode, h} from 'vue'; -import FacebookButton from '../../../../adapters/FacebookButton'; -import GitHubButton from '../../../../adapters/GitHubButton'; -import GoogleButton from '../../../../adapters/GoogleButton'; -import MicrosoftButton from '../../../../adapters/MicrosoftButton'; -import {createField} from '../../../../factories/FieldFactory'; -import Button from '../../../../primitives/Button'; - -/** - * Props shared by sign-in option rendering functions. - */ -export interface BaseSignInOptionProps { - authenticator: EmbeddedSignInFlowAuthenticator; - buttonClassName?: string; - error?: string | null; - formValues: Record; - inputClassName?: string; - isLoading: boolean; - onInputChange: (param: string, value: string) => void; - onSubmit: (authenticator: EmbeddedSignInFlowAuthenticator, formData?: Record) => void; - t: (key: string, params?: Record) => string; - touchedFields: Record; -} - -/** - * Renders form fields for authenticators that require user input (e.g. UsernamePassword, IdentifierFirst). - */ -const renderFormFields = (props: BaseSignInOptionProps): VNode[] => { - const {authenticator, formValues, touchedFields, isLoading, onInputChange, inputClassName, buttonClassName, t} = - props; - - const formFields: any[] = - authenticator.metadata?.params - ?.sort((a: any, b: any) => a.order - b.order) - ?.filter((param: any) => param.param !== 'totp') || []; - - const fieldNodes: VNode[] = formFields.map((param: any) => - h( - 'div', - {key: param.param}, - createField({ - className: inputClassName, - disabled: isLoading, - label: param.displayName, - name: param.param, - onChange: (value: string) => onInputChange(param.param, value), - placeholder: t('elements.fields.generic.placeholder', { - field: (param.displayName || param.param).toLowerCase(), - }), - required: authenticator.requiredParams.includes(param.param), - touched: touchedFields[param.param] || false, - type: - param.type === EmbeddedSignInFlowAuthenticatorParamType.String && param.confidential - ? FieldType.Password - : FieldType.Text, - value: formValues[param.param] || '', - }), - ), - ); - - fieldNodes.push( - h( - Button, - { - class: buttonClassName, - color: 'primary', - 'data-testid': 'thunderid-signin-submit', - disabled: isLoading, - fullWidth: true, - loading: isLoading, - type: 'submit', - variant: 'solid', - }, - {default: () => t('username.password.buttons.submit.text')}, - ), - ); - - return fieldNodes; -}; - -/** - * Renders a multi-option button for authenticators that require selection - * but no immediate user input (e.g. EmailOtp, SmsOtp, Totp, Passkey). - */ -const renderMultiOptionButton = (props: BaseSignInOptionProps): VNode => { - const {authenticator, isLoading, onSubmit, buttonClassName, t} = props; - - let authenticatorName: string = authenticator.authenticator; - if (authenticator.idp !== EmbeddedSignInFlowAuthenticatorKnownIdPType.Local) { - authenticatorName = authenticator.idp; - } - - const displayName: string = t('elements.buttons.multi.option.text', {connection: authenticatorName}); - - const iconPathMap: Record = { - [ApplicationNativeAuthenticationConstants.SupportedAuthenticators.SmsOtp]: - 'M20 15.5c-1.25 0-2.45-.2-3.57-.57a1.02 1.02 0 0 0-1.02.24l-2.2 2.2a15.074 15.074 0 0 1-6.59-6.59l2.2-2.2c.27-.27.35-.67.24-1.02A11.36 11.36 0 0 1 8.5 4c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1 0 9.39 7.61 17 17 17 .55 0 1-.45 1-1v-3.5c0-.55-.45-1-1-1M12 3v10l3-3h6V3z', - [ApplicationNativeAuthenticationConstants.SupportedAuthenticators.EmailOtp]: - 'M20 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2m0 4l-8 5l-8-5V6l8 5l8-5z', - [ApplicationNativeAuthenticationConstants.SupportedAuthenticators.Totp]: - 'M12 1L3 5v6c0 5.55 3.84 10.74 9 12c5.16-1.26 9-6.45 9-12V5z', - }; - - const iconPath: string = - iconPathMap[authenticator.authenticatorId] || - 'M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10s10-4.48 10-10S17.52 2 12 2m-2 15l-5-5l1.41-1.41L10 14.17l7.59-7.59L19 8z'; - - const icon: VNode = h('svg', {height: '18', viewBox: '0 0 24 24', width: '18', xmlns: 'http://www.w3.org/2000/svg'}, [ - h('path', {d: iconPath, fill: 'currentColor'}), - ]); - - return h( - Button, - { - class: buttonClassName, - color: 'secondary', - disabled: isLoading, - fullWidth: true, - onClick: () => onSubmit(authenticator), - startIcon: icon, - type: 'button', - variant: 'solid', - }, - {default: () => displayName}, - ); -}; - -/** - * Renders a generic social/federated login button for unknown federated authenticators. - */ -const renderSocialButton = (props: BaseSignInOptionProps): VNode => { - const {authenticator, isLoading, onSubmit, buttonClassName, t} = props; - - const socialIcon: VNode = h( - 'svg', - {height: '18', viewBox: '0 0 24 24', width: '18', xmlns: 'http://www.w3.org/2000/svg'}, - [ - h('path', { - d: 'M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z', - fill: 'currentColor', - }), - ], - ); - - return h( - Button, - { - class: buttonClassName, - color: 'secondary', - disabled: isLoading, - fullWidth: true, - onClick: () => onSubmit(authenticator), - startIcon: socialIcon, - type: 'button', - variant: 'outline', - }, - {default: () => t('elements.buttons.social.text', {connection: authenticator.idp})}, - ); -}; - -/** - * Creates the appropriate sign-in VNode(s) based on the authenticator's ID. - */ -export const createSignInOption = (props: BaseSignInOptionProps): VNode | VNode[] => { - const {authenticator, onSubmit, buttonClassName, isLoading} = props; - const hasParams = !!(authenticator.metadata?.params && authenticator.metadata.params.length > 0); - - switch (authenticator.authenticatorId) { - case ApplicationNativeAuthenticationConstants.SupportedAuthenticators.UsernamePassword: - case ApplicationNativeAuthenticationConstants.SupportedAuthenticators.IdentifierFirst: - return h('div', {}, renderFormFields(props)); - - case ApplicationNativeAuthenticationConstants.SupportedAuthenticators.Google: - return h(GoogleButton, { - class: buttonClassName, - isLoading, - onClick: () => onSubmit(authenticator), - }); - - case ApplicationNativeAuthenticationConstants.SupportedAuthenticators.GitHub: - return h(GitHubButton, { - class: buttonClassName, - isLoading, - onClick: () => onSubmit(authenticator), - }); - - case ApplicationNativeAuthenticationConstants.SupportedAuthenticators.Microsoft: - return h(MicrosoftButton, { - class: buttonClassName, - isLoading, - onClick: () => onSubmit(authenticator), - }); - - case ApplicationNativeAuthenticationConstants.SupportedAuthenticators.Facebook: - return h(FacebookButton, { - class: buttonClassName, - isLoading, - onClick: () => onSubmit(authenticator), - }); - - case ApplicationNativeAuthenticationConstants.SupportedAuthenticators.EmailOtp: - case ApplicationNativeAuthenticationConstants.SupportedAuthenticators.Totp: - case ApplicationNativeAuthenticationConstants.SupportedAuthenticators.SmsOtp: - return hasParams ? h('div', {}, renderFormFields(props)) : renderMultiOptionButton(props); - - default: - // Federated (non-LOCAL) authenticators โ†’ generic social button - if (authenticator.idp !== EmbeddedSignInFlowAuthenticatorKnownIdPType.Local) { - return renderSocialButton(props); - } - // LOCAL with params โ†’ form fields fallback; otherwise multi-option button - return hasParams ? h('div', {}, renderFormFields(props)) : renderMultiOptionButton(props); - } -}; - -/** - * Convenience function to create sign-in option VNode(s) from an authenticator. - */ -export const createSignInOptionFromAuthenticator = ( - authenticator: EmbeddedSignInFlowAuthenticator, - formValues: Record, - touchedFields: Record, - isLoading: boolean, - onInputChange: (param: string, value: string) => void, - onSubmit: (authenticator: EmbeddedSignInFlowAuthenticator, formData?: Record) => void, - t: (key: string, params?: Record) => string, - options?: { - buttonClassName?: string; - error?: string | null; - inputClassName?: string; - }, -): VNode | VNode[] => - createSignInOption({ - authenticator, - formValues, - isLoading, - onInputChange, - onSubmit, - t, - touchedFields, - ...options, - }); diff --git a/packages/vue/src/components/auth/sign-in/v2/AuthOptionFactory.ts b/packages/vue/src/components/auth/sign-in/v2/AuthOptionFactory.ts deleted file mode 100644 index 72458d3..0000000 --- a/packages/vue/src/components/auth/sign-in/v2/AuthOptionFactory.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * 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. - */ - -export {renderInviteUserComponents, renderSignInComponents, renderSignUpComponents} from '../AuthOptionFactoryCore'; diff --git a/packages/vue/src/components/auth/sign-in/v2/BaseSignIn.ts b/packages/vue/src/components/auth/sign-in/v2/BaseSignIn.ts deleted file mode 100644 index 84c3295..0000000 --- a/packages/vue/src/components/auth/sign-in/v2/BaseSignIn.ts +++ /dev/null @@ -1,390 +0,0 @@ -/** - * Copyright (c) 2025-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 { - withVendorCSSClassPrefix, - EmbeddedSignInFlowRequestV2 as EmbeddedSignInFlowRequest, - EmbeddedFlowComponentV2 as EmbeddedFlowComponent, - FlowMetadataResponse, -} from '@thunderid/browser'; -import { - type ComputedRef, - type Component, - type PropType, - type Ref, - type SetupContext, - type VNode, - computed, - defineComponent, - h, - ref, - watch, -} from 'vue'; -import {renderSignInComponents} from './AuthOptionFactory'; -import useFlow from '../../../../composables/useFlow'; -import useFlowMeta from '../../../../composables/useFlowMeta'; -import useI18n from '../../../../composables/useI18n'; -import {extractErrorMessage} from '../../../../utils/v2/flowTransformer'; -import Alert from '../../../primitives/Alert'; -import Card from '../../../primitives/Card'; -import Spinner from '../../../primitives/Spinner'; -import Typography from '../../../primitives/Typography'; - -/** - * Render props passed to the default scoped slot for custom UI rendering. - */ -export interface BaseSignInRenderProps { - components: EmbeddedFlowComponent[]; - error?: Error | null; - fieldErrors: Record; - handleInputChange: (name: string, value: string) => void; - handleSubmit: (component: EmbeddedFlowComponent, data?: Record) => Promise; - isLoading: boolean; - isTimeoutDisabled?: boolean; - isValid: boolean; - messages: {message: string; type: string}[]; - meta: FlowMetadataResponse | null; - subtitle: string | undefined; - title: string; - touched: Record; - validateForm: () => {fieldErrors: Record; isValid: boolean}; - values: Record; -} - -export interface BaseSignInProps { - additionalData?: Record; - buttonClassName?: string; - className?: string; - components?: EmbeddedFlowComponent[]; - error?: Error | null; - errorClassName?: string; - inputClassName?: string; - isLoading?: boolean; - isTimeoutDisabled?: boolean; - messageClassName?: string; - onSubmit?: (payload: EmbeddedSignInFlowRequest, component: EmbeddedFlowComponent) => Promise; - size?: 'small' | 'medium' | 'large'; - variant?: 'elevated' | 'outlined' | 'flat'; -} - -interface FieldDefinition { - name: string; - required: boolean; - type: string; -} - -const extractFormFields = (flowComponents: EmbeddedFlowComponent[]): FieldDefinition[] => { - const fields: FieldDefinition[] = []; - const process = (comps: EmbeddedFlowComponent[]): void => { - comps.forEach((c: any) => { - if (c.type === 'TEXT_INPUT' || c.type === 'PASSWORD_INPUT' || c.type === 'EMAIL_INPUT' || c.type === 'SELECT') { - fields.push({name: c.ref, required: c.required || false, type: c.type}); - } - if (c.components) { - process(c.components); - } - }); - }; - process(flowComponents); - return fields; -}; - -/** - * BaseSignIn โ€” unstyled app-native sign-in presentation component. - * - * Renders the server-driven UI components from an embedded authentication flow. - * Manages local form state (values, touched, errors) and delegates submission to the parent SignIn component. - * - * Supports render props via the `default` scoped slot for complete UI customization. - * - * @example - * ```vue - * - * - * - * - * - * - * - * - * ``` - */ -const BaseSignIn: Component = defineComponent({ - name: 'BaseSignIn', - props: { - additionalData: { - default: (): Record => ({}), - type: Object as PropType>, - }, - buttonClassName: {default: '', type: String}, - className: {default: '', type: String}, - components: { - default: (): EmbeddedFlowComponent[] => [], - type: Array as PropType, - }, - error: {default: null, type: Object as PropType}, - errorClassName: {default: '', type: String}, - inputClassName: {default: '', type: String}, - isLoading: {default: false, type: Boolean}, - isTimeoutDisabled: {default: false, type: Boolean}, - messageClassName: {default: '', type: String}, - size: { - default: 'medium', - type: String as PropType<'small' | 'medium' | 'large'>, - }, - variant: { - default: 'outlined', - type: String as PropType<'elevated' | 'outlined' | 'flat'>, - }, - }, - emits: ['error', 'success'], - setup( - props: Readonly<{ - additionalData: Record; - buttonClassName: string; - className: string; - components: EmbeddedFlowComponent[]; - error: Error | null; - errorClassName: string; - inputClassName: string; - isLoading: boolean; - isTimeoutDisabled: boolean; - messageClassName: string; - onSubmit?: (payload: EmbeddedSignInFlowRequest, component: EmbeddedFlowComponent) => Promise; - size: 'small' | 'medium' | 'large'; - variant: 'elevated' | 'outlined' | 'flat'; - }>, - {slots, emit, attrs}: SetupContext, - ): () => VNode | null { - const {meta: metaRef} = useFlowMeta(); - const {t} = useI18n(); - const {subtitle: flowSubtitle, title: flowTitle, messages: flowMessages, addMessage, clearMessages} = useFlow(); - - const isSubmitting: Ref = ref(false); - const apiError: Ref = ref(null); - - const isLoading: ComputedRef = computed(() => props.isLoading || isSubmitting.value); - - // Form state - const formValues: Ref> = ref({}); - const touchedFields: Ref> = ref({}); - - // Reset form state when components change (new flow step) - watch( - () => props.components, - (newComponents: EmbeddedFlowComponent[]) => { - const fields: FieldDefinition[] = extractFormFields(newComponents || []); - const freshValues: Record = {}; - fields.forEach((f: FieldDefinition) => { - freshValues[f.name] = ''; - }); - formValues.value = freshValues; - touchedFields.value = {}; - }, - {deep: false, immediate: true}, - ); - - // Computed form errors based on current values + touched - const formErrors: ComputedRef> = computed>(() => { - const fields: FieldDefinition[] = extractFormFields(props.components || []); - const errors: Record = {}; - fields.forEach((field: FieldDefinition) => { - const value: string = formValues.value[field.name] || ''; - const isTouched: boolean = touchedFields.value[field.name] || false; - if (field.required && isTouched && (!value || value.trim() === '')) { - errors[field.name] = t('validations.required.field.error') || 'This field is required'; - } - if (field.type === 'EMAIL_INPUT' && value && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) { - errors[field.name] = t('field.email.invalid') || 'Invalid email address'; - } - }); - return errors; - }); - - const isFormValid: ComputedRef = computed(() => Object.keys(formErrors.value).length === 0); - - const handleError = (error: any): void => { - const errorMessage: string = extractErrorMessage(error, t); - apiError.value = error instanceof Error ? error : new Error(errorMessage); - clearMessages(); - addMessage({message: errorMessage, type: 'error'}); - }; - - const handleInputChange = (name: string, value: string): void => { - formValues.value = {...formValues.value, [name]: value}; - }; - - const handleInputBlur = (name: string): void => { - touchedFields.value = {...touchedFields.value, [name]: true}; - }; - - const touchAllFields = (): void => { - const fields: FieldDefinition[] = extractFormFields(props.components || []); - const newTouched: Record = {}; - fields.forEach((f: FieldDefinition) => { - newTouched[f.name] = true; - }); - touchedFields.value = newTouched; - }; - - const validateForm = (): {fieldErrors: Record; isValid: boolean} => { - touchAllFields(); - const errors: Record = formErrors.value; - return {fieldErrors: errors, isValid: Object.keys(errors).length === 0}; - }; - - const handleSubmit = async ( - component: EmbeddedFlowComponent, - data?: Record, - skipValidation?: boolean, - ): Promise => { - if (!skipValidation) { - const {isValid} = validateForm(); - if (!isValid) return; - } - - isSubmitting.value = true; - apiError.value = null; - clearMessages(); - - try { - const filteredInputs: Record = {}; - if (data) { - Object.keys(data).forEach((key: string) => { - if (data[key] !== undefined && data[key] !== null && data[key] !== '') { - filteredInputs[key] = data[key]; - } - }); - } - - const payload: EmbeddedSignInFlowRequest = { - ...((component as any).id ? {action: (component as any).id} : {}), - inputs: filteredInputs, - }; - - await props.onSubmit?.(payload, component); - } catch (err: unknown) { - handleError(err); - emit('error', err); - } finally { - isSubmitting.value = false; - } - }; - - const renderComponents = (): VNode[] => - renderSignInComponents( - props.components || [], - formValues.value, - touchedFields.value, - formErrors.value, - isLoading.value, - isFormValid.value, - handleInputChange, - { - additionalData: props.additionalData, - buttonClassName: props.buttonClassName, - inputClassName: props.inputClassName, - isTimeoutDisabled: props.isTimeoutDisabled, - meta: (metaRef as Ref).value, - onInputBlur: handleInputBlur, - onSubmit: handleSubmit, - size: props.size, - t, - }, - ); - - return (): VNode | null => { - const containerClass: string = [ - withVendorCSSClassPrefix('signin'), - withVendorCSSClassPrefix(`signin--${props.size}`), - withVendorCSSClassPrefix(`signin--${props.variant}`), - props.className, - ] - .filter(Boolean) - .join(' '); - - // If a scoped slot is provided, use render props pattern - if (slots['default']) { - const renderProps: BaseSignInRenderProps = { - components: props.components || [], - error: apiError.value, - fieldErrors: formErrors.value, - handleInputChange, - handleSubmit, - isLoading: isLoading.value, - isTimeoutDisabled: props.isTimeoutDisabled, - isValid: isFormValid.value, - messages: (flowMessages as Ref<{message: string; type: string}[]>).value || [], - meta: (metaRef as Ref).value, - subtitle: (flowSubtitle as Ref).value, - title: (flowTitle as Ref).value || t('signin.heading') || 'Sign In', - touched: touchedFields.value, - validateForm, - values: formValues.value, - }; - return h('div', {class: containerClass, ...attrs}, slots['default'](renderProps)); - } - - // Loading state - if (isLoading.value && (!props.components || props.components.length === 0)) { - return h(Card, {class: containerClass, variant: props.variant}, () => - h('div', {style: 'display:flex;justify-content:center;padding:2rem'}, h(Spinner)), - ); - } - - // No components available - if (!props.components || props.components.length === 0) { - return h(Card, {class: containerClass, variant: props.variant}, () => - h(Alert, {severity: 'warning'}, () => - h( - Typography, - {variant: 'body1'}, - () => t('errors.signin.components.not.available') || 'No sign-in options available', - ), - ), - ); - } - - const messages: {message: string; type: string}[] = - (flowMessages as Ref<{message: string; type: string}[]>).value || []; - const externalError: Error | null = props.error; - - return h(Card, {class: containerClass, ...attrs, variant: props.variant}, () => [ - // Show errors and flow messages - (externalError || messages.length > 0) && - h( - 'div', - {class: [withVendorCSSClassPrefix('signin__messages'), props.messageClassName].filter(Boolean).join(' ')}, - [ - externalError && - h(Alert, {severity: 'error'}, () => h(Typography, {variant: 'body2'}, () => externalError.message)), - ...messages.map((msg: {message: string; type: string}, index: number) => - h(Alert, {key: index, severity: msg.type === 'error' ? 'error' : 'info'}, () => - h(Typography, {variant: 'body2'}, () => msg.message), - ), - ), - ], - ), - // Render flow components - h('div', {class: withVendorCSSClassPrefix('signin__content')}, renderComponents()), - ]); - }; - }, -}); - -export default BaseSignIn; diff --git a/packages/vue/src/components/auth/sign-in/v2/SignIn.ts b/packages/vue/src/components/auth/sign-in/v2/SignIn.ts deleted file mode 100644 index 821c35e..0000000 --- a/packages/vue/src/components/auth/sign-in/v2/SignIn.ts +++ /dev/null @@ -1,661 +0,0 @@ -/** - * Copyright (c) 2025-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 { - ThunderIDRuntimeError, - type ConsentPurposeDataV2 as ConsentPurposeData, - EmbeddedFlowComponentV2 as EmbeddedFlowComponent, - EmbeddedFlowType, - EmbeddedSignInFlowRequestV2, - EmbeddedSignInFlowResponseV2, - EmbeddedSignInFlowStatusV2, - EmbeddedSignInFlowTypeV2, - FlowMetadataResponse, -} from '@thunderid/browser'; -import { - type Component, - type PropType, - type Ref, - type SetupContext, - type VNode, - defineComponent, - h, - onMounted, - onUnmounted, - ref, - watch, -} from 'vue'; -import BaseSignIn from './BaseSignIn'; -import useFlowMeta from '../../../../composables/useFlowMeta'; -import useI18n from '../../../../composables/useI18n'; -import useThunderID from '../../../../composables/useThunderID'; -import {useOAuthCallback} from '../../../../composables/v2/useOAuthCallback'; -import {initiateOAuthRedirect} from '../../../../utils/oauth'; -import {extractErrorMessage, normalizeFlowResponse} from '../../../../utils/v2/flowTransformer'; -import {handlePasskeyAuthentication, handlePasskeyRegistration} from '../../../../utils/v2/passkey'; - -const EXECUTION_ID_STORAGE_KEY = 'thunderid_execution_id'; - -interface PasskeyState { - actionId: string | null; - challenge: string | null; - creationOptions: string | null; - error: Error | null; - executionId: string | null; - isActive: boolean; -} - -/** - * Render props passed to the default scoped slot for custom UI rendering. - */ -export interface SignInRenderProps { - additionalData?: Record; - components: EmbeddedFlowComponent[]; - error: Error | null; - initialize: () => Promise; - isInitialized: boolean; - isLoading: boolean; - isTimeoutDisabled?: boolean; - meta: FlowMetadataResponse | null; - onSubmit: (payload: EmbeddedSignInFlowRequestV2) => Promise; -} - -/** - * SignIn โ€” app-native sign-in component with full flow lifecycle management. - * - * Initializes the authentication flow, handles passkey authentication/registration, - * OAuth redirect flows, and renders the UI via `BaseSignIn` or a scoped slot. - * - * @example - * ```vue - * - * - * - * - * - * - * - * ``` - */ -const SignIn: Component = defineComponent({ - name: 'SignIn', - props: { - className: {default: '', type: String}, - size: { - default: 'medium', - type: String as PropType<'small' | 'medium' | 'large'>, - }, - variant: { - default: 'outlined', - type: String as PropType<'elevated' | 'outlined' | 'flat'>, - }, - }, - emits: ['error', 'success'], - setup( - props: Readonly<{className: string; size: 'small' | 'medium' | 'large'; variant: 'elevated' | 'outlined' | 'flat'}>, - {slots, emit, attrs}: SetupContext, - ): () => VNode | null { - const { - applicationId, - afterSignInUrl, - signIn, - isInitialized, - isLoading: sdkLoading, - scopes, - getStorageManager, - } = useThunderID(); - const {meta: flowMeta} = useFlowMeta(); - const {t} = useI18n(); - - // Flow state - const components: Ref = ref([]); - const additionalData: Ref> = ref({}); - const currentExecutionId: Ref = ref(null); - const isFlowInitialized: Ref = ref(false); - const flowError: Ref = ref(null); - const isSubmitting: Ref = ref(false); - const isTimeoutDisabled: Ref = ref(false); - const passkeyState: Ref = ref({ - actionId: null, - challenge: null, - creationOptions: null, - error: null, - executionId: null, - isActive: false, - }); - - // Track one-time initialization and OAuth processing - let initializationAttempted = false; - const oauthCodeProcessedFlag: {value: boolean} = {value: false}; - let passkeyProcessed = false; - - // โ”€โ”€ Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - - const persistExecutionId = (executionId: string | null): void => { - currentExecutionId.value = executionId; - if (executionId) { - sessionStorage.setItem(EXECUTION_ID_STORAGE_KEY, executionId); - } else { - sessionStorage.removeItem(EXECUTION_ID_STORAGE_KEY); - } - }; - - const clearFlowState = async (): Promise => { - persistExecutionId(null); - isFlowInitialized.value = false; - const sm = getStorageManager(); - if (sm) { - await sm.removeHybridDataParameter('authId'); - } - isTimeoutDisabled.value = false; - oauthCodeProcessedFlag.value = false; - }; - - interface UrlParams { - applicationId: string | null; - authId: string | null; - code: string | null; - error: string | null; - errorDescription: string | null; - executionId: string | null; - nonce: string | null; - state: string | null; - } - - const getUrlParams = (): UrlParams => { - const params: URLSearchParams = new URLSearchParams(window?.location?.search ?? ''); - return { - applicationId: params.get('applicationId'), - authId: params.get('authId'), - code: params.get('code'), - error: params.get('error'), - errorDescription: params.get('error_description'), - executionId: params.get('executionId'), - nonce: params.get('nonce'), - state: params.get('state'), - }; - }; - - const cleanupOAuthUrlParams = (): void => { - if (!window?.location?.href) return; - const url: URL = new URL(window.location.href); - ['error', 'error_description', 'code', 'state', 'nonce'].forEach((p: string) => url.searchParams.delete(p)); - window.history.replaceState({}, '', url.toString()); - }; - - const cleanupFlowUrlParams = (): void => { - if (!window?.location?.href) return; - const url: URL = new URL(window.location.href); - ['executionId', 'authId', 'applicationId'].forEach((p: string) => url.searchParams.delete(p)); - window.history.replaceState({}, '', url.toString()); - }; - - const setError = (error: Error): void => { - flowError.value = error; - isFlowInitialized.value = true; - emit('error', error); - }; - - // โ”€โ”€ Flow initialization โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - - const initializeFlow = async (): Promise => { - const urlParams: UrlParams = getUrlParams(); - - oauthCodeProcessedFlag.value = false; - - if (urlParams.authId) { - const sm = getStorageManager(); - if (sm) { - await sm.setHybridDataParameter('authId', urlParams.authId); - } - } - - const effectiveApplicationId: string | null | undefined = applicationId || urlParams.applicationId; - - if (!urlParams.executionId && !effectiveApplicationId) { - const err: ThunderIDRuntimeError = new ThunderIDRuntimeError( - 'Either executionId or applicationId is required for authentication', - 'SIGN_IN_ERROR', - 'vue', - ); - setError(err); - throw err; - } - - try { - flowError.value = null; - - let response: EmbeddedSignInFlowResponseV2; - - if (urlParams.executionId) { - response = (await signIn({executionId: urlParams.executionId})) as EmbeddedSignInFlowResponseV2; - } else { - response = (await signIn({ - applicationId: effectiveApplicationId, - flowType: EmbeddedFlowType.Authentication, - ...(scopes && {scopes}), - })) as EmbeddedSignInFlowResponseV2; - } - - // Handle OAuth redirect types - if (response.type === EmbeddedSignInFlowTypeV2.Redirection) { - const redirectURL: string | undefined = (response.data as any)?.redirectURL || (response as any)?.redirectURL; - if (redirectURL && window?.location) { - if (response.executionId) persistExecutionId(response.executionId); - if (urlParams.authId) { - const sm = getStorageManager(); - if (sm) { - await sm.setHybridDataParameter('authId', urlParams.authId); - } - } - initiateOAuthRedirect(redirectURL); - return; - } - } - - const { - executionId: normalizedExecutionId, - components: normalizedComponents, - additionalData: normalizedAdditionalData, - } = normalizeFlowResponse(response, t, {resolveTranslations: false}, flowMeta.value); - - if (normalizedExecutionId && normalizedComponents) { - persistExecutionId(normalizedExecutionId); - components.value = normalizedComponents; - additionalData.value = normalizedAdditionalData ?? {}; - isFlowInitialized.value = true; - isTimeoutDisabled.value = false; - cleanupFlowUrlParams(); - } - } catch (error: unknown) { - const err: any = error as any; - clearFlowState(); - setError(new Error(extractErrorMessage(err, t))); - initializationAttempted = false; - } - }; - - // โ”€โ”€ Submit handler โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - - const handleSubmit = async (payload: EmbeddedSignInFlowRequestV2): Promise => { - const effectiveExecutionId: string | null = payload.executionId || currentExecutionId.value; - - if (!effectiveExecutionId) { - throw new Error('No active flow ID'); - } - - const processedInputs: Record = {...payload.inputs}; - - // Auto-compile consent decisions if on a consent prompt step - if (additionalData.value?.['consentPrompt']) { - try { - const consentRaw: any = additionalData.value['consentPrompt']; - const purposes: ConsentPurposeData[] = - typeof consentRaw === 'string' ? JSON.parse(consentRaw) : consentRaw.purposes || consentRaw; - - let isDeny = false; - if (payload.action) { - const findAction = (comps: any[]): any => { - if (!comps?.length) return null; - const found: any = comps.find((c: any) => c.id === payload.action); - if (found) return found; - return comps.reduce((acc: any, c: any) => acc || (c.components ? findAction(c.components) : null), null); - }; - const submitAction: any = findAction(components.value as any[]); - if (submitAction && submitAction.variant?.toLowerCase() !== 'primary') { - isDeny = true; - } - } - - const decisions: Record = { - purposes: purposes.map((p) => ({ - approved: !isDeny, - elements: [ - ...(p.essential ?? []).map((e) => ({approved: !isDeny, name: e.name})), - ...(p.optional ?? []).map((e) => { - const key = `__consent_opt__${p.purposeId}__${e.name}`; - return {approved: isDeny ? false : processedInputs[key] !== 'false', name: e.name}; - }), - ], - purposeName: p.purposeName, - })), - }; - processedInputs['consent_decisions'] = JSON.stringify(decisions); - - Object.keys(processedInputs).forEach((key: string) => { - if (key.startsWith('__consent_opt__')) delete processedInputs[key]; - }); - } catch { - // Ignore consent construction failures - } - } - - try { - isSubmitting.value = true; - flowError.value = null; - - const response: EmbeddedSignInFlowResponseV2 = (await signIn({ - executionId: effectiveExecutionId, - ...payload, - inputs: processedInputs, - })) as EmbeddedSignInFlowResponseV2; - - // Handle OAuth redirect - if (response.type === EmbeddedSignInFlowTypeV2.Redirection) { - const redirectURL: string | undefined = (response.data as any)?.redirectURL || (response as any)?.redirectURL; - if (redirectURL && window?.location) { - if (response.executionId) persistExecutionId(response.executionId); - const urlParams: UrlParams = getUrlParams(); - if (urlParams.authId) { - const sm = getStorageManager(); - if (sm) { - await sm.setHybridDataParameter('authId', urlParams.authId); - } - } - initiateOAuthRedirect(redirectURL); - return; - } - } - - // Handle passkey challenge in response - if ( - response.data?.additionalData?.['passkeyChallenge'] || - response.data?.additionalData?.['passkeyCreationOptions'] - ) { - const {passkeyChallenge, passkeyCreationOptions} = response.data.additionalData as any; - passkeyProcessed = false; - passkeyState.value = { - actionId: 'submit', - challenge: passkeyChallenge || null, - creationOptions: passkeyCreationOptions || null, - error: null, - executionId: response.executionId || effectiveExecutionId, - isActive: true, - }; - isSubmitting.value = false; - return; - } - - const { - executionId: normalizedExecutionId, - components: normalizedComponents, - additionalData: normalizedAdditionalData, - } = normalizeFlowResponse(response, t, {resolveTranslations: false}, flowMeta.value); - - // Handle error flow status - if (response.flowStatus === EmbeddedSignInFlowStatusV2.Error) { - clearFlowState(); - const err: Error = new Error(extractErrorMessage(response, t)); - setError(err); - cleanupFlowUrlParams(); - throw err; - } - - // Handle flow completion - if (response.flowStatus === EmbeddedSignInFlowStatusV2.Complete) { - const redirectUrl: string | undefined = (response as any)?.redirectUrl || (response as any)?.redirect_uri; - const finalRedirectUrl: string | undefined = redirectUrl || afterSignInUrl; - - isSubmitting.value = false; - persistExecutionId(null); - isFlowInitialized.value = false; - const sm = getStorageManager(); - if (sm) { - await sm.removeHybridDataParameter('authId'); - } - cleanupOAuthUrlParams(); - - emit('success', { - redirectUrl: finalRedirectUrl, - ...(response.data || {}), - }); - - if (finalRedirectUrl && window?.location) { - window.location.href = finalRedirectUrl; - } - return; - } - - // Update flow state for next step - if (normalizedExecutionId && normalizedComponents) { - persistExecutionId(normalizedExecutionId); - components.value = normalizedComponents; - additionalData.value = normalizedAdditionalData ?? {}; - isTimeoutDisabled.value = false; - isFlowInitialized.value = true; - cleanupFlowUrlParams(); - - if ((response as any)?.error) { - flowError.value = new Error(extractErrorMessage(response, t)); - } - } - } catch (error: unknown) { - const err: any = error as any; - if (err instanceof Error && flowError.value === err) { - // Already set; re-throw - throw err; - } - clearFlowState(); - setError(new Error(extractErrorMessage(err, t))); - } finally { - isSubmitting.value = false; - } - }; - - // โ”€โ”€ Step timeout โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - - let timeoutHandle: ReturnType | null = null; - - const scheduleTimeout = (timeoutMs: number): void => { - if (timeoutHandle) clearTimeout(timeoutHandle); - if (timeoutMs <= 0 || !isFlowInitialized.value) { - isTimeoutDisabled.value = false; - return; - } - const remaining: number = Math.max(0, Math.floor((timeoutMs - Date.now()) / 1000)); - if (remaining <= 0) { - isTimeoutDisabled.value = true; - setError(new Error(t('errors.signin.timeout') || 'Time allowed to complete the step has expired.')); - return; - } - timeoutHandle = setTimeout(() => { - isTimeoutDisabled.value = true; - setError(new Error(t('errors.signin.timeout') || 'Time allowed to complete the step has expired.')); - }, remaining * 1000); - }; - - watch( - () => [additionalData.value?.['stepTimeout'], isFlowInitialized.value] as [number | undefined, boolean], - ([timeoutMs]: [number | undefined, boolean]) => { - scheduleTimeout(Number(timeoutMs) || 0); - }, - ); - - onUnmounted(() => { - if (timeoutHandle) clearTimeout(timeoutHandle); - }); - - // โ”€โ”€ Passkey processing โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - - watch( - () => passkeyState.value, - async (state: PasskeyState) => { - if (!state.isActive || (!state.challenge && !state.creationOptions) || !state.executionId) return; - if (passkeyProcessed) return; - passkeyProcessed = true; - - try { - let inputs: Record; - - if (state.challenge) { - const passkeyResponse: string = await handlePasskeyAuthentication(state.challenge); - const obj: any = JSON.parse(passkeyResponse); - inputs = { - authenticatorData: obj.response.authenticatorData, - clientDataJSON: obj.response.clientDataJSON, - credentialId: obj.id, - signature: obj.response.signature, - userHandle: obj.response.userHandle, - }; - } else if (state.creationOptions) { - const passkeyResponse: string = await handlePasskeyRegistration(state.creationOptions); - const obj: any = JSON.parse(passkeyResponse); - inputs = { - attestationObject: obj.response.attestationObject, - clientDataJSON: obj.response.clientDataJSON, - credentialId: obj.id, - }; - } else { - throw new Error('No passkey challenge or creation options available'); - } - - await handleSubmit({executionId: state.executionId, inputs}); - - passkeyState.value = { - actionId: null, - challenge: null, - creationOptions: null, - error: null, - executionId: null, - isActive: false, - }; - } catch (error: unknown) { - const err: Error = error as Error; - passkeyState.value = {...passkeyState.value, error: err, isActive: false}; - flowError.value = err; - emit('error', err); - } - }, - {deep: true}, - ); - - // โ”€โ”€ OAuth callback (via composable) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - - useOAuthCallback({ - currentExecutionId, - executionIdStorageKey: EXECUTION_ID_STORAGE_KEY, - isInitialized, - isSubmitting, - onError: (err: any) => { - // Guard against double-processing when handleSubmit already set the error - if (!flowError.value) { - clearFlowState(); - setError(err instanceof Error ? err : new Error(String(err))); - } - }, - onSubmit: (payload: EmbeddedSignInFlowRequestV2) => - handleSubmit({executionId: payload.executionId, inputs: payload.inputs}), - processedFlag: oauthCodeProcessedFlag, - setExecutionId: persistExecutionId, - }); - - // โ”€โ”€ Lifecycle โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - - onMounted(async () => { - const urlParams: UrlParams = getUrlParams(); - - if (urlParams.authId) { - const sm = getStorageManager(); - if (sm) { - await sm.setHybridDataParameter('authId', urlParams.authId); - } - } - }); - - // Initialize flow when SDK is ready (OAuth callback is handled by useOAuthCallback) - watch( - () => - [ - isInitialized.value, - sdkLoading.value, - isFlowInitialized.value, - currentExecutionId.value, - isSubmitting.value, - ] as [boolean, boolean, boolean, string | null, boolean], - ([initialized, loading, flowInit, executionId, submitting]: [ - boolean, - boolean, - boolean, - string | null, - boolean, - ]) => { - const urlParams: UrlParams = getUrlParams(); - const hasOAuthCode = !!urlParams.code; - const hasOAuthState = !!urlParams.state; - - // Initialize flow when SDK is ready and no flow is active - if ( - initialized && - !loading && - !flowInit && - !initializationAttempted && - !executionId && - !hasOAuthCode && - !hasOAuthState && - !submitting && - !oauthCodeProcessedFlag.value - ) { - initializationAttempted = true; - initializeFlow(); - } - }, - ); - - // โ”€โ”€ Render โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - - return (): VNode | null => { - const combinedIsLoading: boolean = sdkLoading.value || isSubmitting.value || !isInitialized.value; - - // Scoped slot / render props pattern - if (slots['default']) { - const renderProps: SignInRenderProps = { - additionalData: additionalData.value, - components: components.value, - error: flowError.value, - initialize: initializeFlow, - isInitialized: isFlowInitialized.value, - isLoading: combinedIsLoading, - isTimeoutDisabled: isTimeoutDisabled.value, - meta: flowMeta.value, - onSubmit: handleSubmit, - }; - return h('div', {}, slots['default'](renderProps)); - } - - // Default BaseSignIn rendering - return h(BaseSignIn, { - ...attrs, - additionalData: additionalData.value, - class: props.className, - components: components.value, - error: flowError.value, - isLoading: combinedIsLoading || !isFlowInitialized.value, - isTimeoutDisabled: isTimeoutDisabled.value, - onError: (err: Error) => emit('error', err), - onSubmit: handleSubmit, - size: props.size, - variant: props.variant, - }); - }; - }, -}); - -export default SignIn; diff --git a/packages/vue/src/components/auth/sign-up/BaseSignUp.ts b/packages/vue/src/components/auth/sign-up/BaseSignUp.ts index a020be7..a7feacb 100644 --- a/packages/vue/src/components/auth/sign-up/BaseSignUp.ts +++ b/packages/vue/src/components/auth/sign-up/BaseSignUp.ts @@ -1,5 +1,5 @@ /** - * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com). + * Copyright (c) 2025-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 @@ -16,36 +16,693 @@ * under the License. */ -import {Platform} from '@thunderid/browser'; -import {type Component, type SetupContext, type VNode, defineComponent, h} from 'vue'; -import BaseSignUpV1 from './v1/BaseSignUp'; -import BaseSignUpV2 from './v2/BaseSignUp'; -import useThunderID from '../../../composables/useThunderID'; +import { + EmbeddedFlowComponentType, + EmbeddedFlowResponseType, + EmbeddedSignUpFlowStatus, + FlowMetadataResponse, + withVendorCSSClassPrefix, +} from '@thunderid/browser'; +import { + type Component, + type PropType, + type Ref, + type SetupContext, + type VNode, + defineComponent, + h, + ref, + watch, +} from 'vue'; +import useFlowMeta from '../../../composables/useFlowMeta'; +import useI18n from '../../../composables/useI18n'; +import {normalizeFlowResponse, extractErrorMessage} from '../../../utils/flowTransformer'; +import getAuthComponentHeadings from '../../../utils/getAuthComponentHeadings'; +import {createVueLogger} from '../../../utils/logger'; +import {handlePasskeyRegistration} from '../../../utils/passkey'; +import Alert from '../../primitives/Alert'; +import Card from '../../primitives/Card'; +import Spinner from '../../primitives/Spinner'; +import Typography from '../../primitives/Typography'; +import {renderSignUpComponents} from '../sign-in/AuthOptionFactory'; -export type {BaseSignUpRenderProps, BaseSignUpProps} from './v2/BaseSignUp'; +const logger: ReturnType = createVueLogger('BaseSignUp'); /** - * BaseSignUp โ€” platform-aware base sign-up component. - * - * Routes to V1 (component-driven, V1 flow API: `TYPOGRAPHY` / `INPUT` / - * `BUTTON` / `FORM` shapes) or V2 (`ThunderIDV2` platform with `BLOCK` / `STACK` - * / `TEXT_INPUT` shapes) based on the `platform` value resolved by - * {@link useThunderID}. + * Passkey registration tracking state. + */ +interface PasskeyState { + actionId: string | null; + creationOptions: string | null; + error: Error | null; + flowId: string | null; + isActive: boolean; +} + +/** + * Render props passed to the default scoped slot. + */ +export interface BaseSignUpRenderProps { + components: any[]; + error?: Error | null; + fieldErrors: Record; + handleInputChange: (name: string, value: string) => void; + handleSubmit: (component: any, data?: Record) => Promise; + isLoading: boolean; + isValid: boolean; + messages: {message: string; type: string}[]; + subtitle: string; + title: string; + touched: Record; + validateForm: () => {fieldErrors: Record; isValid: boolean}; + values: Record; +} + +export interface BaseSignUpProps { + afterSignUpUrl?: string; + buttonClassName?: string; + className?: string; + error?: Error | null; + errorClassName?: string; + inputClassName?: string; + isInitialized?: boolean; + messageClassName?: string; + onComplete?: (response: any) => void; + onError?: (error: Error) => void; + onFlowChange?: (response: any) => void; + onInitialize?: (payload?: any) => Promise; + onSubmit?: (payload: any) => Promise; + shouldRedirectAfterSignUp?: boolean; + showLogo?: boolean; + showSubtitle?: boolean; + showTitle?: boolean; + size?: 'small' | 'medium' | 'large'; + variant?: 'elevated' | 'outlined' | 'flat'; +} + +interface FieldDefinition { + name: string; + required: boolean; + type: string; +} + +const extractFormFields = (components: any[]): FieldDefinition[] => { + const fields: FieldDefinition[] = []; + const process = (comps: any[]): void => { + comps.forEach((c: any) => { + if ( + c.type === EmbeddedFlowComponentType.TextInput || + c.type === EmbeddedFlowComponentType.PasswordInput || + c.type === EmbeddedFlowComponentType.EmailInput || + c.type === EmbeddedFlowComponentType.Select + ) { + const fieldName: string = c.ref || c.id; + fields.push({name: fieldName, required: c.required || false, type: c.type}); + } + if (c.components && Array.isArray(c.components)) { + process(c.components); + } + }); + }; + process(components); + return fields; +}; + +/** + * BaseSignUp โ€” app-native sign-up presentation component. * - * Mirrors the React `BaseSignUp` dispatcher and matches the existing pattern - * already used by `BaseSignIn` in this package. + * Manages the sign-up flow lifecycle including initialization, form state, + * passkey registration, popup-based social OAuth, and renders the server-driven UI. */ const BaseSignUp: Component = defineComponent({ name: 'BaseSignUp', - inheritAttrs: false, - setup(_props: Record, {attrs, slots}: SetupContext): () => VNode { - const {platform} = useThunderID(); + props: { + afterSignUpUrl: {default: undefined, type: String}, + buttonClassName: {default: '', type: String}, + className: {default: '', type: String}, + error: {default: null, type: Object as PropType}, + errorClassName: {default: '', type: String}, + inputClassName: {default: '', type: String}, + isInitialized: {default: false, type: Boolean}, + messageClassName: {default: '', type: String}, + onComplete: {default: undefined, type: Function as PropType<(response: any) => void>}, + onError: {default: undefined, type: Function as PropType<(error: Error) => void>}, + onFlowChange: { + default: undefined, + type: Function as PropType<(response: any) => void>, + }, + onInitialize: { + default: undefined, + type: Function as PropType<(payload?: any) => Promise>, + }, + onSubmit: { + default: undefined, + type: Function as PropType<(payload: any) => Promise>, + }, + showSubtitle: {default: true, type: Boolean}, + showTitle: {default: true, type: Boolean}, + size: { + default: 'medium', + type: String as PropType<'small' | 'medium' | 'large'>, + }, + variant: { + default: 'outlined', + type: String as PropType<'elevated' | 'outlined' | 'flat'>, + }, + }, + emits: ['error', 'complete', 'flowChange'], + setup(props: any, {slots}: SetupContext): () => VNode | null { + const {meta: flowMetaRef} = useFlowMeta(); + const {t} = useI18n(); + + // โ”€โ”€ State โ”€โ”€ + const isLoading: Ref = ref(false); + const isFlowInitialized: Ref = ref(false); + const currentFlow: Ref = ref(null); + const apiError: Ref = ref(null); + const flowMessages: Ref<{message: string; type: string}[]> = ref([]); + const passkeyState: Ref = ref({ + actionId: null, + creationOptions: null, + error: null, + flowId: null, + isActive: false, + }); + + // Form state + const formValues: Ref> = ref({}); + const touchedFields: Ref> = ref({}); + const formErrors: Ref> = ref({}); + const isFormValid: Ref = ref(true); + + // One-time flags (plain mutable, not reactive) + let initializationAttempted = false; + let passkeyProcessed = false; + + // โ”€โ”€ Helpers โ”€โ”€ + + const handleError = (error: any): void => { + const errorMessage: string = extractErrorMessage(error, t); + apiError.value = error instanceof Error ? error : new Error(errorMessage); + flowMessages.value = [{message: errorMessage, type: 'error'}]; + }; + + const normalizeFlowResponseLocal = (response: any): any => { + if (response?.data?.components && Array.isArray(response.data.components)) { + return response; + } + if (response?.data) { + const {components} = normalizeFlowResponse( + response, + t, + {defaultErrorKey: 'components.signUp.errors.generic', resolveTranslations: false}, + (flowMetaRef as Ref).value, + ); + return {...response, data: {...response.data, components: components as any}}; + } + return response; + }; + + const setupFormFields = (flowResponse: any): void => { + const fields: FieldDefinition[] = extractFormFields(flowResponse.data?.components || []); + const initialValues: Record = {}; + fields.forEach((f: FieldDefinition) => { + initialValues[f.name] = ''; + }); + formValues.value = initialValues; + touchedFields.value = {}; + formErrors.value = {}; + isFormValid.value = true; + }; + + const computeFormErrors = (): Record => { + const components: any[] = currentFlow.value?.data?.components || []; + const fields: FieldDefinition[] = extractFormFields(components); + const errors: Record = {}; + fields.forEach((field: FieldDefinition) => { + const value: string = formValues.value[field.name] || ''; + if (field.required && (!value || value.trim() === '')) { + errors[field.name] = t('validations.required.field.error') || 'This field is required'; + } + if ( + (field.type === EmbeddedFlowComponentType.EmailInput || field.type === 'EMAIL') && + value && + !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) + ) { + errors[field.name] = t('field.email.invalid') || 'Invalid email address'; + } + }); + return errors; + }; + + const touchAllFields = (): void => { + const fields: FieldDefinition[] = extractFormFields(currentFlow.value?.data?.components || []); + const newTouched: Record = {}; + fields.forEach((f: FieldDefinition) => { + newTouched[f.name] = true; + }); + touchedFields.value = newTouched; + }; + + const validateForm = (): {fieldErrors: Record; isValid: boolean} => { + touchAllFields(); + const errors: Record = computeFormErrors(); + formErrors.value = errors; + const valid: boolean = Object.keys(errors).length === 0; + isFormValid.value = valid; + return {fieldErrors: errors, isValid: valid}; + }; + + // โ”€โ”€ Input handlers โ”€โ”€ + + const handleInputChange = (name: string, value: string): void => { + formValues.value = {...formValues.value, [name]: value}; + }; + + const handleInputBlur = (name: string): void => { + touchedFields.value = {...touchedFields.value, [name]: true}; + }; + + // โ”€โ”€ Popup OAuth for social sign-up โ”€โ”€ + + const handleRedirectionIfNeeded = (response: any): boolean => { + if (response?.type !== EmbeddedFlowResponseType.Redirection || !response?.data?.redirectURL) { + return false; + } + + const redirectUrl: string = response.data.redirectURL; + const popup: Window | null = window.open( + redirectUrl, + 'oauth_popup', + 'width=500,height=600,scrollbars=yes,resizable=yes', + ); + + if (!popup) { + logger.error('Failed to open popup window'); + return false; + } + + let hasProcessedCallback = false; + let popupMonitor: ReturnType | null = null; + let messageHandler: ((event: MessageEvent) => Promise) | null = null; + + const cleanup = (): void => { + if (messageHandler) window.removeEventListener('message', messageHandler); + if (popupMonitor) clearInterval(popupMonitor); + }; + + const processOAuthCode = async (code: string, state: string): Promise => { + const payload: any = { + ...(currentFlow.value?.flowId && {flowId: currentFlow.value.flowId}), + action: '', + flowType: currentFlow.value?.flowType || 'REGISTRATION', + inputs: {code, state}, + }; + + try { + const continueResponse: any = await props.onSubmit(payload); + props.onFlowChange?.(continueResponse); + + if (continueResponse.flowStatus === EmbeddedSignUpFlowStatus.Complete) { + props.onComplete?.(continueResponse); + } else if (continueResponse.flowStatus === EmbeddedSignUpFlowStatus.Incomplete) { + currentFlow.value = continueResponse; + setupFormFields(continueResponse); - return (): VNode => { - if (platform === Platform.ThunderID) { - return h(BaseSignUpV2, {...attrs}, slots); + // Display error from INCOMPLETE response + if (continueResponse?.error) { + handleError(continueResponse); + } + } + popup.close(); + cleanup(); + } catch (err) { + handleError(err); + props.onError?.(err as Error); + popup.close(); + cleanup(); + } + }; + + messageHandler = async (event: MessageEvent): Promise => { + if (event.source !== popup) return; + const expectedOrigin: string = props.afterSignUpUrl + ? new URL(props.afterSignUpUrl).origin + : window.location.origin; + if (event.origin !== expectedOrigin && event.origin !== window.location.origin) return; + const {code, state} = event.data; + if (code && state) { + await processOAuthCode(code, state); + } + }; + + window.addEventListener('message', messageHandler); + + popupMonitor = setInterval(async () => { + try { + if (popup.closed) { + cleanup(); + return; + } + if (hasProcessedCallback) return; + try { + const popupUrl: string = popup.location.href; + if (popupUrl && (popupUrl.includes('code=') || popupUrl.includes('error='))) { + hasProcessedCallback = true; + const url: URL = new URL(popupUrl); + const code: string | null = url.searchParams.get('code'); + const state: string | null = url.searchParams.get('state'); + const error: string | null = url.searchParams.get('error'); + + if (error) { + logger.error('OAuth error'); + popup.close(); + cleanup(); + return; + } + if (code && state) { + await processOAuthCode(code, state); + } + } + } catch { + // Cross-origin error expected during OAuth redirect + } + } catch { + logger.error('Error monitoring popup'); + } + }, 1000); + + return true; + }; + + // โ”€โ”€ Submit handler โ”€โ”€ + + const handleSubmit = async ( + component: any, + data?: Record, + skipValidation?: boolean, + ): Promise => { + if (!currentFlow.value) return; + + if (!skipValidation) { + const validation: {fieldErrors: Record; isValid: boolean} = validateForm(); + if (!validation.isValid) return; + } + + isLoading.value = true; + apiError.value = null; + flowMessages.value = []; + + try { + const filteredInputs: Record = {}; + if (data) { + Object.entries(data).forEach(([key, value]: [string, any]) => { + if (value !== null && value !== undefined && value !== '') { + filteredInputs[key] = value; + } + }); + } + + const payload: any = { + ...(currentFlow.value.flowId && {flowId: currentFlow.value.flowId}), + flowType: currentFlow.value.flowType || 'REGISTRATION', + ...(component.id && {action: component.id}), + inputs: filteredInputs, + }; + + const rawResponse: any = await props.onSubmit(payload); + const response: any = normalizeFlowResponseLocal(rawResponse); + props.onFlowChange?.(response); + + if (response.flowStatus === EmbeddedSignUpFlowStatus.Complete) { + props.onComplete?.(response); + return; + } + + if (response.flowStatus === EmbeddedSignUpFlowStatus.Incomplete) { + if (handleRedirectionIfNeeded(response)) return; + + if (response.data?.additionalData?.passkeyCreationOptions) { + const {passkeyCreationOptions} = response.data.additionalData; + const effectiveFlowId: string | undefined = response.flowId || currentFlow.value?.flowId; + passkeyProcessed = false; + passkeyState.value = { + actionId: component.id || 'submit', + creationOptions: passkeyCreationOptions, + error: null, + flowId: effectiveFlowId || null, + isActive: true, + }; + isLoading.value = false; + return; + } + + currentFlow.value = response; + setupFormFields(response); + + // Display error from INCOMPLETE response + if (response?.error) { + handleError(response); + } + } + } catch (err) { + handleError(err); + props.onError?.(err as Error); + } finally { + isLoading.value = false; } - return h(BaseSignUpV1, {...attrs}, slots); + }; + + // โ”€โ”€ Passkey registration watch โ”€โ”€ + + watch( + () => passkeyState.value, + async (state: PasskeyState) => { + if (!state.isActive || !state.creationOptions || !state.flowId) return; + if (passkeyProcessed) return; + passkeyProcessed = true; + + try { + const passkeyResponse: string = await handlePasskeyRegistration(state.creationOptions); + const passkeyObj: any = JSON.parse(passkeyResponse); + const inputs: Record = { + attestationObject: passkeyObj.response.attestationObject, + clientDataJSON: passkeyObj.response.clientDataJSON, + credentialId: passkeyObj.id, + }; + + const payload: any = { + actionId: state.actionId || 'submit', + flowId: state.flowId, + flowType: currentFlow.value?.flowType || 'REGISTRATION', + inputs, + } as any; + + const nextResponse: any = await props.onSubmit(payload); + const processed: any = normalizeFlowResponseLocal(nextResponse); + props.onFlowChange?.(processed); + + if (processed.flowStatus === EmbeddedSignUpFlowStatus.Complete) { + props.onComplete?.(processed); + } else { + currentFlow.value = processed; + setupFormFields(processed); + + // Display error from INCOMPLETE response + if (processed?.error) { + handleError(processed); + } + } + + passkeyState.value = {actionId: null, creationOptions: null, error: null, flowId: null, isActive: false}; + } catch (error: unknown) { + passkeyState.value = {...passkeyState.value, error: error as Error, isActive: false}; + handleError(error); + props.onError?.(error as Error); + } + }, + {deep: true}, + ); + + // โ”€โ”€ Flow initialization โ”€โ”€ + + watch( + () => [props.isInitialized, isFlowInitialized.value] as [boolean, boolean], + ([initialized, flowInit]: [boolean, boolean]) => { + // Skip if URL has OAuth code params + const urlParams: URLSearchParams = new URL(window.location.href).searchParams; + if (urlParams.get('code') || urlParams.get('state')) return; + + if (initialized && !flowInit && !initializationAttempted) { + initializationAttempted = true; + + (async (): Promise => { + isLoading.value = true; + apiError.value = null; + flowMessages.value = []; + + try { + const rawResponse: any = await props.onInitialize(); + const response: any = normalizeFlowResponseLocal(rawResponse); + currentFlow.value = response; + isFlowInitialized.value = true; + props.onFlowChange?.(response); + + if (response.flowStatus === EmbeddedSignUpFlowStatus.Complete) { + props.onComplete?.(response); + return; + } + if (response.flowStatus === EmbeddedSignUpFlowStatus.Incomplete) { + setupFormFields(response); + + // Display error from INCOMPLETE response + if (response?.error) { + handleError(response); + } + } + } catch (err) { + handleError(err); + props.onError?.(err as Error); + } finally { + isLoading.value = false; + } + })(); + } + }, + {immediate: true}, + ); + + // โ”€โ”€ Render โ”€โ”€ + + return (): VNode | null => { + const containerClass: string = [ + withVendorCSSClassPrefix('signup'), + withVendorCSSClassPrefix(`signup--${props.size}`), + withVendorCSSClassPrefix(`signup--${props.variant}`), + props.className, + ] + .filter(Boolean) + .join(' '); + + // Scoped slot / render props + if (slots['default']) { + const renderProps: BaseSignUpRenderProps = { + components: currentFlow.value?.data?.components || [], + error: apiError.value, + fieldErrors: formErrors.value, + handleInputChange, + handleSubmit, + isLoading: isLoading.value, + isValid: isFormValid.value, + messages: flowMessages.value, + subtitle: t('signup.subheading') || 'Create your account', + title: t('signup.heading') || 'Sign Up', + touched: touchedFields.value, + validateForm: (): {fieldErrors: Record; isValid: boolean} => { + const result: {fieldErrors: Record; isValid: boolean} = validateForm(); + return {fieldErrors: result.fieldErrors, isValid: result.isValid}; + }, + values: formValues.value, + }; + return h('div', {class: containerClass}, slots['default'](renderProps)); + } + + // Loading state + if (!isFlowInitialized.value && isLoading.value) { + return h(Card, {class: containerClass, variant: props.variant}, () => + h('div', {style: 'display:flex;justify-content:center;padding:2rem'}, h(Spinner)), + ); + } + + // No flow available + if (!currentFlow.value) { + return h(Card, {class: containerClass, variant: props.variant}, () => + h( + Alert, + {variant: 'error'}, + () => t('errors.signup.flow.initialization.failure') || 'Failed to initialize sign-up flow', + ), + ); + } + + // Extract headings + const componentsToRender: any[] = currentFlow.value.data?.components || []; + const {title, subtitle, componentsWithoutHeadings} = getAuthComponentHeadings( + componentsToRender, + undefined, + undefined, + t('signup.heading') || 'Sign Up', + t('signup.subheading') || 'Create your account', + ); + + const meta: FlowMetadataResponse | null = (flowMetaRef as Ref).value; + + const renderedComponents: VNode[] = + componentsWithoutHeadings.length > 0 + ? renderSignUpComponents( + componentsWithoutHeadings, + formValues.value, + touchedFields.value, + formErrors.value, + isLoading.value, + isFormValid.value, + handleInputChange, + { + buttonClassName: props.buttonClassName, + inputClassName: props.inputClassName, + meta, + onInputBlur: handleInputBlur, + onSubmit: handleSubmit, + size: props.size, + t, + variant: props.variant, + }, + ) + : []; + + return h(Card, {class: containerClass, variant: props.variant}, () => [ + // Header with title/subtitle + props.showTitle || props.showSubtitle + ? h('div', {style: 'padding: 1rem 1rem 0'}, [ + props.showTitle ? h(Typography, {variant: 'h5'}, () => title) : null, + props.showSubtitle + ? h(Typography, {style: 'margin-top: 0.25rem', variant: 'body1'}, () => subtitle) + : null, + ]) + : null, + // External error + props.error + ? h( + 'div', + {style: 'padding: 0 1rem'}, + h(Alert, {variant: 'error'}, () => props.error.message), + ) + : null, + // Flow messages + flowMessages.value.length > 0 + ? h( + 'div', + {style: 'padding: 0 1rem'}, + flowMessages.value.map((msg: {message: string; type: string}, i: number) => + h(Alert, {key: i, variant: msg.type === 'error' ? 'error' : 'info'}, () => msg.message), + ), + ) + : null, + // Components + h( + 'div', + {style: 'padding: 1rem'}, + renderedComponents.length > 0 + ? renderedComponents + : [ + h( + Alert, + {variant: 'warning'}, + () => t('errors.signup.components.not.available') || 'No components available', + ), + ], + ), + ]); }; }, }); diff --git a/packages/vue/src/components/auth/sign-up/SignUp.ts b/packages/vue/src/components/auth/sign-up/SignUp.ts index d347f57..7978c9e 100644 --- a/packages/vue/src/components/auth/sign-up/SignUp.ts +++ b/packages/vue/src/components/auth/sign-up/SignUp.ts @@ -16,33 +16,104 @@ * under the License. */ -import {Platform} from '@thunderid/browser'; -import {type Component, type SetupContext, type VNode, defineComponent, h} from 'vue'; -import SignUpV1 from './v1/SignUp'; -import SignUpV2 from './v2/SignUp'; +import {EmbeddedFlowResponseType, EmbeddedFlowType} from '@thunderid/browser'; +import {type Component, type PropType, type SetupContext, type VNode, defineComponent, h} from 'vue'; +import BaseSignUp from './BaseSignUp'; +import type {BaseSignUpRenderProps} from './BaseSignUp'; import useThunderID from '../../../composables/useThunderID'; -export type {SignUpRenderProps} from './v2/SignUp'; +export type SignUpRenderProps = BaseSignUpRenderProps; /** - * SignUp โ€” platform-aware sign-up container. - * - * Routes to V1 (default, component-driven V1 flow API) or V2 (`ThunderIDV2` - * platform) based on the `platform` value from {@link useThunderID}. Mirrors - * the existing `SignIn` dispatcher pattern in this package. + * SignUp โ€” embedded sign-up component that handles API calls and delegates UI to BaseSignUp. */ const SignUp: Component = defineComponent({ name: 'SignUp', - inheritAttrs: false, - setup(_props: Record, {attrs, slots}: SetupContext): () => VNode { - const {platform} = useThunderID(); + props: { + afterSignUpUrl: {default: undefined, type: String}, + buttonClassName: {default: '', type: String}, + className: {default: '', type: String}, + errorClassName: {default: '', type: String}, + inputClassName: {default: '', type: String}, + messageClassName: {default: '', type: String}, + onComplete: {default: undefined, type: Function as PropType<(response: any) => void>}, + onError: {default: undefined, type: Function as PropType<(error: Error) => void>}, + shouldRedirectAfterSignUp: {default: true, type: Boolean}, + showSubtitle: {default: true, type: Boolean}, + showTitle: {default: true, type: Boolean}, + size: {default: 'medium', type: String as PropType<'small' | 'medium' | 'large'>}, + variant: {default: 'outlined', type: String as PropType<'elevated' | 'outlined' | 'flat'>}, + }, + setup(props: any, {slots}: SetupContext): () => VNode | null { + const {signUp, isInitialized, applicationId, scopes} = useThunderID(); + + const handleInitialize = async (payload?: any): Promise => { + const urlParams: URLSearchParams = new URL(window.location.href).searchParams; + const applicationIdFromUrl: string | null = urlParams.get('applicationId'); + const effectiveApplicationId: string | undefined = applicationId || applicationIdFromUrl || undefined; + + const initialPayload: any = payload || { + flowType: EmbeddedFlowType.Registration, + ...(effectiveApplicationId && {applicationId: effectiveApplicationId}), + ...(scopes && {scopes}), + }; + + return await signUp(initialPayload); + }; + + const handleOnSubmit = async (payload: any): Promise => await signUp(payload); - return (): VNode => { - if (platform === Platform.ThunderID) { - return h(SignUpV2, {...attrs}, slots); + const handleComplete = (response: any): void => { + props.onComplete?.(response); + + const oauthRedirectUrl: string | undefined = response?.redirectUrl; + if (props.shouldRedirectAfterSignUp && oauthRedirectUrl) { + window.location.href = oauthRedirectUrl; + return; + } + + if ( + props.shouldRedirectAfterSignUp && + response?.type !== EmbeddedFlowResponseType.Redirection && + props.afterSignUpUrl && + !response?.assertion + ) { + window.location.href = props.afterSignUpUrl; + } + + if ( + props.shouldRedirectAfterSignUp && + response?.type === EmbeddedFlowResponseType.Redirection && + response?.data?.redirectURL && + !response.data.redirectURL.includes('oauth') && + !response.data.redirectURL.includes('auth') + ) { + window.location.href = response.data.redirectURL; } - return h(SignUpV1, {...attrs}, slots); }; + + return (): VNode | null => + h( + BaseSignUp, + { + afterSignUpUrl: props.afterSignUpUrl, + buttonClassName: props.buttonClassName, + className: props.className, + errorClassName: props.errorClassName, + inputClassName: props.inputClassName, + isInitialized: isInitialized?.value ?? false, + messageClassName: props.messageClassName, + onComplete: handleComplete, + onError: props.onError, + onInitialize: handleInitialize, + onSubmit: handleOnSubmit, + showSubtitle: props.showSubtitle, + showTitle: props.showTitle, + size: props.size, + variant: props.variant, + }, + slots['default'] ? {default: (renderProps: any) => slots['default']!(renderProps)} : undefined, + ); }, }); diff --git a/packages/vue/src/components/auth/sign-up/v1/BaseSignUp.ts b/packages/vue/src/components/auth/sign-up/v1/BaseSignUp.ts deleted file mode 100644 index 12d55ad..0000000 --- a/packages/vue/src/components/auth/sign-up/v1/BaseSignUp.ts +++ /dev/null @@ -1,546 +0,0 @@ -/** - * 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 { - EmbeddedFlowComponent, - EmbeddedFlowComponentType, - EmbeddedFlowExecuteRequestPayload, - EmbeddedFlowExecuteResponse, - EmbeddedFlowResponseType, - EmbeddedFlowStatus, - withVendorCSSClassPrefix, -} from '@thunderid/browser'; -import { - type Component, - type PropType, - type Ref, - type SetupContext, - type VNode, - defineComponent, - h, - ref, - watch, -} from 'vue'; -import {renderSignUpComponents} from './options/SignUpOptionFactory'; -import useFlow from '../../../../composables/useFlow'; -import useI18n from '../../../../composables/useI18n'; -import {createVueLogger} from '../../../../utils/logger'; -import Alert from '../../../primitives/Alert'; -import Card from '../../../primitives/Card'; -import Logo from '../../../primitives/Logo'; -import Spinner from '../../../primitives/Spinner'; -import Typography from '../../../primitives/Typography'; - -const logger: ReturnType = createVueLogger('BaseSignUpV1'); - -/** - * Render-prop payload exposed via the default slot. - */ -export interface BaseSignUpRenderProps { - components: any[]; - errors: Record; - handleInputChange: (name: string, value: string) => void; - handleSubmit: (component: any, data?: Record) => Promise; - isLoading: boolean; - isValid: boolean; - messages: {message: string; type: string}[]; - subtitle: string; - title: string; - touched: Record; - values: Record; -} - -/** - * V1 BaseSignUp โ€” component-driven app-native sign-up for Vue. - * - * Mirrors `packages/react/.../SignUp/v1/BaseSignUp.tsx`. Reads the - * `/api/server/v1/flow/execute` response shape (`TYPOGRAPHY`, `FORM`, `INPUT`, - * `BUTTON`, `RICH_TEXT`, etc.) and renders it via the V1 - * `SignUpOptionFactory`. Tracks form state internally and submits steps via - * the `onSubmit` prop until the flow completes. - */ -const BaseSignUp: Component = defineComponent({ - name: 'BaseSignUpV1', - props: { - afterSignUpUrl: {default: undefined, type: String}, - buttonClassName: {default: '', type: String}, - className: {default: '', type: String}, - errorClassName: {default: '', type: String}, - inputClassName: {default: '', type: String}, - isInitialized: {default: true, type: Boolean}, - messageClassName: {default: '', type: String}, - onComplete: {default: undefined, type: Function as PropType<(response: EmbeddedFlowExecuteResponse) => void>}, - onError: {default: undefined, type: Function as PropType<(error: Error) => void>}, - onFlowChange: { - default: undefined, - type: Function as PropType<(response: EmbeddedFlowExecuteResponse) => void>, - }, - onInitialize: { - default: undefined, - type: Function as PropType<(payload?: EmbeddedFlowExecuteRequestPayload) => Promise>, - }, - onSubmit: { - default: undefined, - type: Function as PropType<(payload: EmbeddedFlowExecuteRequestPayload) => Promise>, - }, - shouldRedirectAfterSignUp: {default: true, type: Boolean}, - showLogo: {default: true, type: Boolean}, - showSubtitle: {default: true, type: Boolean}, - showTitle: {default: true, type: Boolean}, - size: {default: 'medium', type: String as PropType<'small' | 'medium' | 'large'>}, - variant: {default: 'outlined', type: String as PropType<'elevated' | 'outlined' | 'flat'>}, - }, - emits: ['error', 'flowChange', 'complete'], - setup(props: any, {slots, emit}: SetupContext): () => VNode | null { - const {t} = useI18n(); - const {title: flowTitle, subtitle: flowSubtitle, messages: flowMessages, addMessage, clearMessages} = useFlow(); - - // โ”€โ”€ Reactive state โ”€โ”€ - const isLoading: Ref = ref(false); - const isFlowInitialized: Ref = ref(false); - const currentFlow: Ref = ref(null); - const formValues: Ref> = ref({}); - const touchedFields: Ref> = ref({}); - const formErrors: Ref> = ref({}); - - let initializationAttempted = false; - - // โ”€โ”€ Error handling โ”€โ”€ - - const handleError = (err: any): void => { - let errorMessage: string = t('errors.signup.flow.failure') || 'Sign-up failed'; - - if (err && typeof err === 'object') { - if (err.code && (err.message || err.description)) { - errorMessage = err.description || err.message; - } else if (err.message) { - errorMessage = err.message; - } - } else if (typeof err === 'string') { - errorMessage = err; - } - - clearMessages(); - addMessage({message: errorMessage, type: 'error'}); - }; - - // โ”€โ”€ Form helpers โ”€โ”€ - - /** - * Walk the V1 component tree and collect every INPUT's bound parameter - * name. The parameter name comes from `config.identifier` (a SCIM claim - * URI) or `config.name`, falling back to the component id. - */ - const collectInputNames = (components: EmbeddedFlowComponent[]): string[] => { - const names: string[] = []; - const walk = (comps: EmbeddedFlowComponent[]): void => { - comps.forEach((component: EmbeddedFlowComponent) => { - const cfg: any = (component as any).config || {}; - if (component.type === EmbeddedFlowComponentType.Input) { - const name: string = (cfg.name as string) || (cfg.identifier as string) || component.id; - if (name) names.push(name); - } - const children: EmbeddedFlowComponent[] = (component as any).components || []; - if (children.length > 0) walk(children); - }); - }; - walk(components); - return names; - }; - - const setupFormFields = (response: EmbeddedFlowExecuteResponse): void => { - const componentTree: EmbeddedFlowComponent[] = response.data?.components || []; - const names: string[] = collectInputNames(componentTree); - const initial: Record = {}; - names.forEach((name: string) => { - initial[name] = ''; - }); - formValues.value = initial; - touchedFields.value = {}; - formErrors.value = {}; - }; - - const handleInputChange = (name: string, value: string): void => { - formValues.value = {...formValues.value, [name]: value}; - touchedFields.value = {...touchedFields.value, [name]: true}; - // Clear any prior error on input - if (formErrors.value[name]) { - const next: Record = {...formErrors.value}; - delete next[name]; - formErrors.value = next; - } - }; - - const isFormValid = (): boolean => Object.keys(formErrors.value).length === 0; - - /** - * Mirror the React V1 popup-based redirection handler for social/IdP - * registration steps. Opens a popup, waits for the OAuth code, and submits - * `{code, state}` as the next flow step. - * - * Returns `true` if redirection was handled (caller should not fall - * through), `false` otherwise. - */ - const handleRedirectionIfNeeded = (response: EmbeddedFlowExecuteResponse): boolean => { - if (response?.type !== EmbeddedFlowResponseType.Redirection || !(response as any)?.data?.redirectURL) { - return false; - } - if (typeof window === 'undefined') return false; - - const redirectUrl: string = (response as any).data.redirectURL; - const popup: Window | null = window.open( - redirectUrl, - 'oauth_popup', - 'width=500,height=600,scrollbars=yes,resizable=yes', - ); - - if (!popup) { - logger.error('Failed to open popup window for social sign-up redirect'); - return false; - } - - let processed = false; - let popupMonitor: ReturnType | undefined; - let messageHandler: (event: MessageEvent) => Promise; - - const cleanup = (): void => { - window.removeEventListener('message', messageHandler); - if (popupMonitor) clearInterval(popupMonitor); - }; - - const continueWithCode = async (code: string, state: string): Promise => { - const payload: any = { - ...(currentFlow.value?.flowId && {flowId: currentFlow.value.flowId}), - actionId: '', - flowType: ((currentFlow.value as any)?.flowType as string) || 'REGISTRATION', - inputs: {code, state}, - }; - try { - const next: any = await props.onSubmit(payload); - props.onFlowChange?.(next); - emit('flowChange', next); - if (next.flowStatus === EmbeddedFlowStatus.Complete) { - props.onComplete?.(next); - emit('complete', next); - } else if (next.flowStatus === EmbeddedFlowStatus.Incomplete) { - currentFlow.value = next; - setupFormFields(next); - } - } catch (err) { - handleError(err); - props.onError?.(err as Error); - emit('error', err); - } finally { - popup.close(); - cleanup(); - } - }; - - messageHandler = async (event: MessageEvent): Promise => { - if (event.source !== popup) return; - const expectedOrigin: string = props.afterSignUpUrl - ? new URL(props.afterSignUpUrl).origin - : window.location.origin; - if (event.origin !== expectedOrigin && event.origin !== window.location.origin) return; - const {code, state} = (event.data || {}) as {code?: string; state?: string}; - if (code && state && !processed) { - processed = true; - await continueWithCode(code, state); - } - }; - window.addEventListener('message', messageHandler); - - popupMonitor = setInterval(async () => { - try { - if (popup.closed) { - cleanup(); - return; - } - if (processed) return; - - // Same-origin URL inspection. Throws on cross-origin (expected while - // the popup is on the IdP's domain) โ€” swallow and try next tick. - let popupUrl: string | undefined; - try { - popupUrl = popup.location.href; - } catch { - return; - } - if (!popupUrl) return; - - if (popupUrl.includes('code=') || popupUrl.includes('error=')) { - const url: URL = new URL(popupUrl); - const code: string | null = url.searchParams.get('code'); - const state: string | null = url.searchParams.get('state'); - const error: string | null = url.searchParams.get('error'); - if (error) { - processed = true; - logger.error(`OAuth error during social sign-up: ${error}`); - popup.close(); - cleanup(); - return; - } - if (code && state) { - processed = true; - await continueWithCode(code, state); - } - } - } catch (err) { - logger.error('Error monitoring sign-up popup'); - } - }, 1000); - - return true; - }; - - // โ”€โ”€ Step submission โ”€โ”€ - - const handleSubmit = async (component: any, data?: Record): Promise => { - if (!currentFlow.value) return; - - isLoading.value = true; - clearMessages(); - - try { - const filteredInputs: Record = {}; - // Prefer explicit `data` if the component handler passes it; otherwise - // submit the entire form snapshot. Empty strings are stripped to match - // the React V1 behaviour. - const sourceInputs: Record = data ?? formValues.value; - Object.entries(sourceInputs).forEach(([key, value]: [string, any]) => { - if (value !== null && value !== undefined && value !== '') { - filteredInputs[key] = value; - } - }); - - const actionId: string | undefined = component?.actionId || component?.id; - - const payload: any = { - ...(currentFlow.value.flowId && {flowId: currentFlow.value.flowId}), - flowType: ((currentFlow.value as any).flowType as string) || 'REGISTRATION', - inputs: filteredInputs, - ...(actionId && {actionId}), - }; - - const response: any = await props.onSubmit(payload); - props.onFlowChange?.(response); - emit('flowChange', response); - - if (response?.flowStatus === EmbeddedFlowStatus.Complete) { - props.onComplete?.(response); - emit('complete', response); - return; - } - - if (response?.flowStatus === EmbeddedFlowStatus.Incomplete) { - if (handleRedirectionIfNeeded(response)) return; - currentFlow.value = response; - setupFormFields(response); - } - } catch (err) { - handleError(err); - props.onError?.(err as Error); - emit('error', err); - } finally { - isLoading.value = false; - } - }; - - // โ”€โ”€ Flow initialization โ”€โ”€ - - watch( - () => [props.isInitialized, isFlowInitialized.value] as [boolean, boolean], - ([initialized, flowInit]: [boolean, boolean]) => { - if (!initialized || flowInit || initializationAttempted) return; - if (!props.onInitialize) return; - initializationAttempted = true; - - (async (): Promise => { - isLoading.value = true; - clearMessages(); - try { - const response: EmbeddedFlowExecuteResponse = await props.onInitialize(); - currentFlow.value = response; - isFlowInitialized.value = true; - props.onFlowChange?.(response); - emit('flowChange', response); - - if (response?.flowStatus === EmbeddedFlowStatus.Complete) { - props.onComplete?.(response); - emit('complete', response); - return; - } - if (response?.flowStatus === EmbeddedFlowStatus.Incomplete) { - setupFormFields(response); - } - } catch (err) { - handleError(err); - props.onError?.(err as Error); - emit('error', err); - } finally { - isLoading.value = false; - } - })(); - }, - {immediate: true}, - ); - - // โ”€โ”€ Render โ”€โ”€ - - return (): VNode | null => { - const containerClass: string = [ - withVendorCSSClassPrefix('signup'), - withVendorCSSClassPrefix(`signup--${props.size}`), - withVendorCSSClassPrefix(`signup--${props.variant}`), - props.className, - ] - .filter(Boolean) - .join(' '); - - // Render-props (scoped slot) escape hatch - if (slots['default']) { - const renderProps: BaseSignUpRenderProps = { - components: (currentFlow.value?.data?.components as any[]) || [], - errors: formErrors.value, - handleInputChange, - handleSubmit, - isLoading: isLoading.value, - isValid: isFormValid(), - messages: (flowMessages.value as {message: string; type: string}[]) || [], - subtitle: flowSubtitle.value || t('signup.subheading') || '', - title: flowTitle.value || t('signup.heading') || '', - touched: touchedFields.value, - values: formValues.value, - }; - return h('div', {class: containerClass}, slots['default'](renderProps)); - } - - // Loading state (initial flow fetch) - if (!isFlowInitialized.value && isLoading.value) { - return h(Card, {class: containerClass, variant: props.variant}, () => - h('div', {style: 'display:flex;justify-content:center;padding:2rem'}, h(Spinner)), - ); - } - - // Failed to obtain a flow at all - if (!currentFlow.value) { - return h(Card, {class: containerClass, variant: props.variant}, () => - h( - Alert, - {variant: 'error'}, - () => t('errors.signup.flow.initialization.failure') || 'Failed to initialize sign-up flow', - ), - ); - } - - const components: EmbeddedFlowComponent[] = currentFlow.value.data?.components || []; - - const rendered: VNode[] = renderSignUpComponents( - components, - formValues.value, - touchedFields.value, - formErrors.value, - isLoading.value, - isFormValid(), - handleInputChange, - handleSubmit, - { - buttonClassName: props.buttonClassName, - inputClassName: props.inputClassName, - size: props.size, - }, - ); - - const cardChildren: VNode[] = []; - - if (props.showLogo) { - cardChildren.push(h('div', {style: 'display:flex;justify-content:center;margin-bottom:1rem'}, [h(Logo)])); - } - - if (props.showTitle || props.showSubtitle) { - const headerChildren: VNode[] = []; - if (props.showTitle) { - headerChildren.push( - h(Typography, {variant: 'h2'}, {default: () => flowTitle.value || t('signup.heading') || 'Sign Up'}), - ); - } - if (props.showSubtitle) { - headerChildren.push( - h( - Typography, - {variant: 'body1'}, - {default: () => flowSubtitle.value || t('signup.subheading') || 'Create your account'}, - ), - ); - } - cardChildren.push(h('div', {style: 'padding: 0 1rem 1rem'}, headerChildren)); - } - - // Flow-level messages (errors, info) - if (flowMessages.value && flowMessages.value.length > 0) { - cardChildren.push( - h( - 'div', - {style: 'padding: 0 1rem'}, - flowMessages.value.map((msg: any, i: number) => - h( - Alert, - { - class: props.messageClassName, - key: msg.id || i, - variant: msg.type?.toLowerCase() === 'error' ? 'error' : 'info', - }, - () => msg.message, - ), - ), - ), - ); - } - - cardChildren.push( - h( - 'form', - { - class: withVendorCSSClassPrefix('signup__form'), - onSubmit: (e: Event): void => { - e.preventDefault(); - // Submit-type buttons in the V1 flow are handled inline by the - // `onSubmit` handler attached to the BUTTON component; the - // native form submit is a fallback (e.g. enter-key in a field). - handleSubmit({config: {type: 'submit'}, type: 'BUTTON'}); - }, - style: 'padding: 1rem;display:flex;flex-direction:column;gap:0.75rem', - }, - rendered.length > 0 - ? rendered - : [ - h( - Alert, - {variant: 'warning'}, - () => t('errors.signup.components.not.available') || 'No components available', - ), - ], - ), - ); - - return h(Card, {class: containerClass, variant: props.variant}, () => cardChildren); - }; - }, -}); - -export default BaseSignUp; diff --git a/packages/vue/src/components/auth/sign-up/v1/SignUp.ts b/packages/vue/src/components/auth/sign-up/v1/SignUp.ts deleted file mode 100644 index b8ef1b1..0000000 --- a/packages/vue/src/components/auth/sign-up/v1/SignUp.ts +++ /dev/null @@ -1,128 +0,0 @@ -/** - * 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 { - EmbeddedFlowExecuteRequestPayload, - EmbeddedFlowExecuteResponse, - EmbeddedFlowResponseType, - EmbeddedFlowType, -} from '@thunderid/browser'; -import {type Component, type PropType, type SetupContext, type VNode, defineComponent, h} from 'vue'; -import BaseSignUp from './BaseSignUp'; -import type {BaseSignUpRenderProps} from './BaseSignUp'; -import useThunderID from '../../../../composables/useThunderID'; - -export type SignUpRenderProps = BaseSignUpRenderProps; - -/** - * V1 SignUp container โ€” wires `useThunderID().signUp` into `BaseSignUpV1` and - * handles redirects after the flow completes. - * - * Mirrors `sign-up/v2/.../SignUp.ts` but invokes the V1 base component which - * understands the `TYPOGRAPHY` / `INPUT` / `BUTTON` / `FORM` shapes returned by - * the V1 flow API. - */ -const SignUp: Component = defineComponent({ - name: 'SignUpV1', - props: { - afterSignUpUrl: {default: undefined, type: String}, - buttonClassName: {default: '', type: String}, - className: {default: '', type: String}, - errorClassName: {default: '', type: String}, - inputClassName: {default: '', type: String}, - messageClassName: {default: '', type: String}, - onComplete: {default: undefined, type: Function as PropType<(response: EmbeddedFlowExecuteResponse) => void>}, - onError: {default: undefined, type: Function as PropType<(error: Error) => void>}, - shouldRedirectAfterSignUp: {default: true, type: Boolean}, - showSubtitle: {default: true, type: Boolean}, - showTitle: {default: true, type: Boolean}, - size: {default: 'medium', type: String as PropType<'small' | 'medium' | 'large'>}, - variant: {default: 'outlined', type: String as PropType<'elevated' | 'outlined' | 'flat'>}, - }, - setup(props: any, {slots}: SetupContext): () => VNode | null { - const {signUp, isInitialized, applicationId} = useThunderID(); - - const handleInitialize = async ( - payload?: EmbeddedFlowExecuteRequestPayload, - ): Promise => { - // Pull the application id from the URL query (same convention as the - // React V1 SignUp), falling back to the configured value from the - // ThunderID context. - const applicationIdFromUrl: string | null = - typeof window !== 'undefined' ? new URL(window.location.href).searchParams.get('applicationId') : null; - const effectiveApplicationId: string | undefined = applicationId || applicationIdFromUrl || undefined; - - const initialPayload: any = payload || { - flowType: EmbeddedFlowType.Registration, - ...(effectiveApplicationId && {applicationId: effectiveApplicationId}), - }; - - return (await signUp(initialPayload)) as EmbeddedFlowExecuteResponse; - }; - - const handleOnSubmit = async (payload: EmbeddedFlowExecuteRequestPayload): Promise => - (await signUp(payload)) as EmbeddedFlowExecuteResponse; - - const handleComplete = (response: EmbeddedFlowExecuteResponse): void => { - props.onComplete?.(response); - - const oauthRedirectUrl: string | undefined = (response as any)?.redirectUrl; - if (props.shouldRedirectAfterSignUp && oauthRedirectUrl) { - if (typeof window !== 'undefined') { - window.location.href = oauthRedirectUrl; - } - return; - } - - if ( - props.shouldRedirectAfterSignUp && - response?.type !== EmbeddedFlowResponseType.Redirection && - props.afterSignUpUrl - ) { - if (typeof window !== 'undefined') { - window.location.href = props.afterSignUpUrl; - } - } - }; - - return (): VNode | null => - h( - BaseSignUp, - { - afterSignUpUrl: props.afterSignUpUrl, - buttonClassName: props.buttonClassName, - className: props.className, - errorClassName: props.errorClassName, - inputClassName: props.inputClassName, - isInitialized: isInitialized?.value ?? false, - messageClassName: props.messageClassName, - onComplete: handleComplete, - onError: props.onError, - onInitialize: handleInitialize, - onSubmit: handleOnSubmit, - showSubtitle: props.showSubtitle, - showTitle: props.showTitle, - size: props.size, - variant: props.variant, - }, - slots['default'] ? {default: (renderProps: any) => slots['default']!(renderProps)} : undefined, - ); - }, -}); - -export default SignUp; diff --git a/packages/vue/src/components/auth/sign-up/v1/options/SignUpOptionFactory.ts b/packages/vue/src/components/auth/sign-up/v1/options/SignUpOptionFactory.ts deleted file mode 100644 index 3fac165..0000000 --- a/packages/vue/src/components/auth/sign-up/v1/options/SignUpOptionFactory.ts +++ /dev/null @@ -1,302 +0,0 @@ -/** - * 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 {EmbeddedFlowComponent, EmbeddedFlowComponentType, FieldType} from '@thunderid/browser'; -import {type VNode, h} from 'vue'; -import FacebookButton from '../../../../adapters/FacebookButton'; -import GitHubButton from '../../../../adapters/GitHubButton'; -import GoogleButton from '../../../../adapters/GoogleButton'; -import MicrosoftButton from '../../../../adapters/MicrosoftButton'; -import {createField} from '../../../../factories/FieldFactory'; -import Button from '../../../../primitives/Button'; -import Typography from '../../../../primitives/Typography'; - -/** - * Mirrors the logic in `packages/react/.../SignUp/v1/SignUpOptionFactory.tsx` โ€” - * renders the V1 flow component shapes (`TYPOGRAPHY`, `INPUT`, `BUTTON`, - * `FORM`, `SELECT`, `DIVIDER`, `IMAGE`, `RICH_TEXT`) returned by the ThunderID - * `/api/server/v1/flow/execute` endpoint. - * - * Each leaf component returns a Vue VNode (or null for unknown types). Branch - * components (`FORM`) recurse so children render as a flat list. - */ - -/** - * Resolve the form-field name for an input component. - * ThunderID V1 stores the bound parameter name in `config.identifier` (e.g. - * `http://wso2.org/claims/emailaddress`), with `config.name` used as a fallback. - */ -const getInputName = (component: any): string => { - const cfg: any = component.config || {}; - return (cfg.name as string) || (cfg.identifier as string) || (component.id as string); -}; - -/** - * Map V1 INPUT variants/types to the SDK's internal `FieldType` so the existing - * `createField` factory (used by the V1 sign-in flow too) produces the right - * primitive (`TextField`, `PasswordField`, `Checkbox`, etc.). - */ -const inferFieldType = (component: any): FieldType => { - const variant: string = String(component.variant || '').toUpperCase(); - const cfg: any = component.config || {}; - const cfgType: string = String(cfg.type || '').toLowerCase(); - - if (variant === 'EMAIL' || cfgType === 'email') return FieldType.Email; - if (variant === 'PASSWORD' || cfgType === 'password') return FieldType.Password; - if (variant === 'TELEPHONE' || cfgType === 'tel') return FieldType.Text; - if (variant === 'NUMBER' || cfgType === 'number') return FieldType.Number; - if (variant === 'DATE' || cfgType === 'date') return FieldType.Date; - if (variant === 'CHECKBOX' || cfgType === 'checkbox') return FieldType.Checkbox; - return FieldType.Text; -}; - -/** - * Map TYPOGRAPHY variants (H1-H6, BODY, CAPTION etc.) to the Vue Typography - * primitive's variant prop. - */ -const inferTypographyVariant = ( - component: any, -): 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'subtitle1' | 'subtitle2' | 'body1' | 'body2' | 'caption' | 'overline' => { - const variant: string = String(component.variant || '').toUpperCase(); - switch (variant) { - case 'H1': - return 'h1'; - case 'H2': - return 'h2'; - case 'H3': - return 'h3'; - case 'H4': - return 'h4'; - case 'H5': - return 'h5'; - case 'H6': - return 'h6'; - case 'SUBTITLE1': - return 'subtitle1'; - case 'SUBTITLE2': - return 'subtitle2'; - case 'BODY2': - return 'body2'; - case 'CAPTION': - return 'caption'; - case 'OVERLINE': - return 'overline'; - default: - return 'body1'; - } -}; - -/** - * Detect whether a BUTTON looks like a known social-login provider so we can - * render a branded button (matches the React V1 factory's behaviour). - */ -const matchesSocialProvider = (component: any, provider: 'google' | 'github' | 'microsoft' | 'facebook'): boolean => { - const text: string = String(component?.config?.text || component?.config?.label || '').toLowerCase(); - const variant: string = String(component?.variant || '').toUpperCase(); - return variant === 'SOCIAL' && text.includes(provider); -}; - -/** - * Props shared by all sign-up component renderers. - */ -export interface BaseSignUpOptionProps { - buttonClassName?: string; - component: EmbeddedFlowComponent; - formErrors: Record; - formValues: Record; - inputClassName?: string; - isFormValid: boolean; - isLoading: boolean; - onInputChange: (name: string, value: string) => void; - onSubmit: (component: EmbeddedFlowComponent, data?: Record) => void; - size?: 'small' | 'medium' | 'large'; - touchedFields: Record; -} - -/** - * Build a VNode for a single V1 flow component. Returns `null` for unknown - * types (caller filters these out). - */ -export const createSignUpComponent = (props: BaseSignUpOptionProps): VNode | VNode[] | null => { - const { - component, - formValues, - touchedFields, - formErrors, - isLoading, - isFormValid, - onInputChange, - onSubmit, - inputClassName, - buttonClassName, - } = props; - - const cfg: any = (component as any).config || {}; - - switch (component.type) { - case EmbeddedFlowComponentType.Typography: { - const text = String(cfg.text || cfg.label || ''); - return h( - Typography, - {style: 'margin-bottom:0.5rem', variant: inferTypographyVariant(component)}, - {default: () => text}, - ); - } - - case EmbeddedFlowComponentType.Input: { - const name: string = getInputName(component); - const fieldType: FieldType = inferFieldType(component); - const value: string = formValues[name] || ''; - const isTouched: boolean = touchedFields[name] || false; - const error: string | undefined = isTouched ? formErrors[name] : undefined; - - return createField({ - className: inputClassName, - disabled: isLoading, - error, - label: String(cfg.label || ''), - name, - onChange: (newValue: string) => onInputChange(name, newValue), - placeholder: String(cfg.placeholder || ''), - required: Boolean(cfg.required), - touched: isTouched, - type: fieldType, - value, - }); - } - - case EmbeddedFlowComponentType.Button: { - const text = String(cfg.text || cfg.label || 'Submit'); - const variant: string = String(component.variant || 'PRIMARY').toUpperCase(); - const isPrimary: boolean = variant === 'PRIMARY'; - const handleClick = (): void => onSubmit(component, undefined); - - // Branded social-login buttons for known providers - if (matchesSocialProvider(component, 'google')) { - return h(GoogleButton, {class: buttonClassName, isLoading, onClick: handleClick}); - } - if (matchesSocialProvider(component, 'github')) { - return h(GitHubButton, {class: buttonClassName, isLoading, onClick: handleClick}); - } - if (matchesSocialProvider(component, 'microsoft')) { - return h(MicrosoftButton, {class: buttonClassName, isLoading, onClick: handleClick}); - } - if (matchesSocialProvider(component, 'facebook')) { - return h(FacebookButton, {class: buttonClassName, isLoading, onClick: handleClick}); - } - - // Generic submit/secondary button - return h( - Button, - { - class: buttonClassName, - color: isPrimary ? 'primary' : 'secondary', - 'data-testid': 'thunderid-signup-submit', - disabled: isLoading || (!isFormValid && cfg.type === 'submit'), - fullWidth: true, - loading: isLoading, - onClick: handleClick, - type: cfg.type === 'submit' ? 'submit' : 'button', - variant: isPrimary ? 'solid' : 'outline', - }, - {default: () => text}, - ); - } - - case EmbeddedFlowComponentType.Form: { - // Recursively render child components inline (one form wrapper at the - // BaseSignUp level handles native `
` semantics โ€” we just unwrap - // children here to keep the render flat). - const children: any[] = (component as any).components || []; - const nodes: VNode[] = []; - children.forEach((child: EmbeddedFlowComponent) => { - const rendered: VNode | VNode[] | null = createSignUpComponent({...props, component: child}); - if (rendered === null) return; - if (Array.isArray(rendered)) nodes.push(...rendered); - else nodes.push(rendered); - }); - return nodes; - } - - case EmbeddedFlowComponentType.Divider: { - return h('hr', { - class: 'thunderid-signup__divider', - style: 'margin:0.75rem 0;border:0;border-top:1px solid #e5e7eb', - }); - } - - case EmbeddedFlowComponentType.Image: { - const src = String(cfg.src || cfg.url || ''); - const alt = String(cfg.alt || ''); - if (!src) return null; - return h('img', {alt, src, style: 'max-width:100%;height:auto;display:block;margin:0.5rem auto'}); - } - - default: { - // ThunderID's V1 flow API also returns 'RICH_TEXT' which is not in the V1 - // component-type enum (the enum predates it). Render its raw HTML so - // links and inline copy show up. - if (String(component.type).toUpperCase() === 'RICH_TEXT') { - const html = String(cfg.text || cfg.label || ''); - return h('div', {class: 'thunderid-signup__rich-text', innerHTML: html}); - } - return null; - } - } -}; - -/** - * Render an array of V1 flow components as Vue VNodes, flattening nested - * containers (FORM) into a single list. - */ -export const renderSignUpComponents = ( - components: EmbeddedFlowComponent[], - formValues: Record, - touchedFields: Record, - formErrors: Record, - isLoading: boolean, - isFormValid: boolean, - onInputChange: (name: string, value: string) => void, - onSubmit: (component: EmbeddedFlowComponent, data?: Record) => void, - options?: { - buttonClassName?: string; - inputClassName?: string; - size?: 'small' | 'medium' | 'large'; - }, -): VNode[] => { - const result: VNode[] = []; - components.forEach((component: EmbeddedFlowComponent) => { - const rendered: VNode | VNode[] | null = createSignUpComponent({ - buttonClassName: options?.buttonClassName, - component, - formErrors, - formValues, - inputClassName: options?.inputClassName, - isFormValid, - isLoading, - onInputChange, - onSubmit, - size: options?.size, - touchedFields, - }); - if (rendered === null) return; - if (Array.isArray(rendered)) result.push(...rendered); - else result.push(rendered); - }); - return result; -}; diff --git a/packages/vue/src/components/auth/sign-up/v2/BaseSignUp.ts b/packages/vue/src/components/auth/sign-up/v2/BaseSignUp.ts deleted file mode 100644 index 0e7db82..0000000 --- a/packages/vue/src/components/auth/sign-up/v2/BaseSignUp.ts +++ /dev/null @@ -1,712 +0,0 @@ -/** - * Copyright (c) 2025-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 { - EmbeddedFlowComponentTypeV2 as EmbeddedFlowComponentType, - EmbeddedFlowExecuteRequestPayload, - EmbeddedFlowExecuteResponse, - EmbeddedFlowResponseType, - EmbeddedFlowStatus, - FlowMetadataResponse, - withVendorCSSClassPrefix, -} from '@thunderid/browser'; -import { - type Component, - type PropType, - type Ref, - type SetupContext, - type VNode, - defineComponent, - h, - ref, - watch, -} from 'vue'; -import useFlowMeta from '../../../../composables/useFlowMeta'; -import useI18n from '../../../../composables/useI18n'; -import {createVueLogger} from '../../../../utils/logger'; -import {normalizeFlowResponse, extractErrorMessage} from '../../../../utils/v2/flowTransformer'; -import getAuthComponentHeadings from '../../../../utils/v2/getAuthComponentHeadings'; -import {handlePasskeyRegistration} from '../../../../utils/v2/passkey'; -import Alert from '../../../primitives/Alert'; -import Card from '../../../primitives/Card'; -import Spinner from '../../../primitives/Spinner'; -import Typography from '../../../primitives/Typography'; -import {renderSignUpComponents} from '../../sign-in/AuthOptionFactory'; - -const logger: ReturnType = createVueLogger('BaseSignUp'); - -/** - * Passkey registration tracking state. - */ -interface PasskeyState { - actionId: string | null; - creationOptions: string | null; - error: Error | null; - flowId: string | null; - isActive: boolean; -} - -/** - * Render props passed to the default scoped slot. - */ -export interface BaseSignUpRenderProps { - components: any[]; - error?: Error | null; - fieldErrors: Record; - handleInputChange: (name: string, value: string) => void; - handleSubmit: (component: any, data?: Record) => Promise; - isLoading: boolean; - isValid: boolean; - messages: {message: string; type: string}[]; - subtitle: string; - title: string; - touched: Record; - validateForm: () => {fieldErrors: Record; isValid: boolean}; - values: Record; -} - -export interface BaseSignUpProps { - afterSignUpUrl?: string; - buttonClassName?: string; - className?: string; - error?: Error | null; - errorClassName?: string; - inputClassName?: string; - isInitialized?: boolean; - messageClassName?: string; - onComplete?: (response: EmbeddedFlowExecuteResponse) => void; - onError?: (error: Error) => void; - onFlowChange?: (response: EmbeddedFlowExecuteResponse) => void; - onInitialize?: (payload?: EmbeddedFlowExecuteRequestPayload) => Promise; - onSubmit?: (payload: EmbeddedFlowExecuteRequestPayload) => Promise; - shouldRedirectAfterSignUp?: boolean; - showLogo?: boolean; - showSubtitle?: boolean; - showTitle?: boolean; - size?: 'small' | 'medium' | 'large'; - variant?: 'elevated' | 'outlined' | 'flat'; -} - -interface FieldDefinition { - name: string; - required: boolean; - type: string; -} - -const extractFormFields = (components: any[]): FieldDefinition[] => { - const fields: FieldDefinition[] = []; - const process = (comps: any[]): void => { - comps.forEach((c: any) => { - if ( - c.type === EmbeddedFlowComponentType.TextInput || - c.type === EmbeddedFlowComponentType.PasswordInput || - c.type === EmbeddedFlowComponentType.EmailInput || - c.type === EmbeddedFlowComponentType.Select - ) { - const fieldName: string = c.ref || c.id; - fields.push({name: fieldName, required: c.required || false, type: c.type}); - } - if (c.components && Array.isArray(c.components)) { - process(c.components); - } - }); - }; - process(components); - return fields; -}; - -/** - * BaseSignUp โ€” app-native sign-up presentation component. - * - * Manages the sign-up flow lifecycle including initialization, form state, - * passkey registration, popup-based social OAuth, and renders the server-driven UI. - */ -const BaseSignUp: Component = defineComponent({ - name: 'BaseSignUp', - props: { - afterSignUpUrl: {default: undefined, type: String}, - buttonClassName: {default: '', type: String}, - className: {default: '', type: String}, - error: {default: null, type: Object as PropType}, - errorClassName: {default: '', type: String}, - inputClassName: {default: '', type: String}, - isInitialized: {default: false, type: Boolean}, - messageClassName: {default: '', type: String}, - onComplete: {default: undefined, type: Function as PropType<(response: EmbeddedFlowExecuteResponse) => void>}, - onError: {default: undefined, type: Function as PropType<(error: Error) => void>}, - onFlowChange: { - default: undefined, - type: Function as PropType<(response: EmbeddedFlowExecuteResponse) => void>, - }, - onInitialize: { - default: undefined, - type: Function as PropType<(payload?: EmbeddedFlowExecuteRequestPayload) => Promise>, - }, - onSubmit: { - default: undefined, - type: Function as PropType<(payload: EmbeddedFlowExecuteRequestPayload) => Promise>, - }, - showSubtitle: {default: true, type: Boolean}, - showTitle: {default: true, type: Boolean}, - size: { - default: 'medium', - type: String as PropType<'small' | 'medium' | 'large'>, - }, - variant: { - default: 'outlined', - type: String as PropType<'elevated' | 'outlined' | 'flat'>, - }, - }, - emits: ['error', 'complete', 'flowChange'], - setup(props: any, {slots}: SetupContext): () => VNode | null { - const {meta: flowMetaRef} = useFlowMeta(); - const {t} = useI18n(); - - // โ”€โ”€ State โ”€โ”€ - const isLoading: Ref = ref(false); - const isFlowInitialized: Ref = ref(false); - const currentFlow: Ref = ref(null); - const apiError: Ref = ref(null); - const flowMessages: Ref<{message: string; type: string}[]> = ref([]); - const passkeyState: Ref = ref({ - actionId: null, - creationOptions: null, - error: null, - flowId: null, - isActive: false, - }); - - // Form state - const formValues: Ref> = ref({}); - const touchedFields: Ref> = ref({}); - const formErrors: Ref> = ref({}); - const isFormValid: Ref = ref(true); - - // One-time flags (plain mutable, not reactive) - let initializationAttempted = false; - let passkeyProcessed = false; - - // โ”€โ”€ Helpers โ”€โ”€ - - const handleError = (error: any): void => { - const errorMessage: string = extractErrorMessage(error, t); - apiError.value = error instanceof Error ? error : new Error(errorMessage); - flowMessages.value = [{message: errorMessage, type: 'error'}]; - }; - - const normalizeFlowResponseLocal = (response: EmbeddedFlowExecuteResponse): EmbeddedFlowExecuteResponse => { - if (response?.data?.components && Array.isArray(response.data.components)) { - return response; - } - if (response?.data) { - const {components} = normalizeFlowResponse( - response, - t, - {defaultErrorKey: 'components.signUp.errors.generic', resolveTranslations: false}, - (flowMetaRef as Ref).value, - ); - return {...response, data: {...response.data, components: components as any}}; - } - return response; - }; - - const setupFormFields = (flowResponse: EmbeddedFlowExecuteResponse): void => { - const fields: FieldDefinition[] = extractFormFields(flowResponse.data?.components || []); - const initialValues: Record = {}; - fields.forEach((f: FieldDefinition) => { - initialValues[f.name] = ''; - }); - formValues.value = initialValues; - touchedFields.value = {}; - formErrors.value = {}; - isFormValid.value = true; - }; - - const computeFormErrors = (): Record => { - const components: any[] = currentFlow.value?.data?.components || []; - const fields: FieldDefinition[] = extractFormFields(components); - const errors: Record = {}; - fields.forEach((field: FieldDefinition) => { - const value: string = formValues.value[field.name] || ''; - if (field.required && (!value || value.trim() === '')) { - errors[field.name] = t('validations.required.field.error') || 'This field is required'; - } - if ( - (field.type === EmbeddedFlowComponentType.EmailInput || field.type === 'EMAIL') && - value && - !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) - ) { - errors[field.name] = t('field.email.invalid') || 'Invalid email address'; - } - }); - return errors; - }; - - const touchAllFields = (): void => { - const fields: FieldDefinition[] = extractFormFields(currentFlow.value?.data?.components || []); - const newTouched: Record = {}; - fields.forEach((f: FieldDefinition) => { - newTouched[f.name] = true; - }); - touchedFields.value = newTouched; - }; - - const validateForm = (): {fieldErrors: Record; isValid: boolean} => { - touchAllFields(); - const errors: Record = computeFormErrors(); - formErrors.value = errors; - const valid: boolean = Object.keys(errors).length === 0; - isFormValid.value = valid; - return {fieldErrors: errors, isValid: valid}; - }; - - // โ”€โ”€ Input handlers โ”€โ”€ - - const handleInputChange = (name: string, value: string): void => { - formValues.value = {...formValues.value, [name]: value}; - }; - - const handleInputBlur = (name: string): void => { - touchedFields.value = {...touchedFields.value, [name]: true}; - }; - - // โ”€โ”€ Popup OAuth for social sign-up โ”€โ”€ - - const handleRedirectionIfNeeded = (response: EmbeddedFlowExecuteResponse): boolean => { - if (response?.type !== EmbeddedFlowResponseType.Redirection || !response?.data?.redirectURL) { - return false; - } - - const redirectUrl: string = response.data.redirectURL; - const popup: Window | null = window.open( - redirectUrl, - 'oauth_popup', - 'width=500,height=600,scrollbars=yes,resizable=yes', - ); - - if (!popup) { - logger.error('Failed to open popup window'); - return false; - } - - let hasProcessedCallback = false; - let popupMonitor: ReturnType | null = null; - let messageHandler: ((event: MessageEvent) => Promise) | null = null; - - const cleanup = (): void => { - if (messageHandler) window.removeEventListener('message', messageHandler); - if (popupMonitor) clearInterval(popupMonitor); - }; - - const processOAuthCode = async (code: string, state: string): Promise => { - const payload: EmbeddedFlowExecuteRequestPayload = { - ...(currentFlow.value?.flowId && {flowId: currentFlow.value.flowId}), - action: '', - flowType: (currentFlow.value as any)?.flowType || 'REGISTRATION', - inputs: {code, state}, - } as any; - - try { - const continueResponse: EmbeddedFlowExecuteResponse = await props.onSubmit(payload); - props.onFlowChange?.(continueResponse); - - if (continueResponse.flowStatus === EmbeddedFlowStatus.Complete) { - props.onComplete?.(continueResponse); - } else if (continueResponse.flowStatus === EmbeddedFlowStatus.Incomplete) { - currentFlow.value = continueResponse; - setupFormFields(continueResponse); - - // Display error from INCOMPLETE response - if ((continueResponse as any)?.error) { - handleError(continueResponse); - } - } - popup.close(); - cleanup(); - } catch (err) { - handleError(err); - props.onError?.(err as Error); - popup.close(); - cleanup(); - } - }; - - messageHandler = async (event: MessageEvent): Promise => { - if (event.source !== popup) return; - const expectedOrigin: string = props.afterSignUpUrl - ? new URL(props.afterSignUpUrl).origin - : window.location.origin; - if (event.origin !== expectedOrigin && event.origin !== window.location.origin) return; - const {code, state} = event.data; - if (code && state) { - await processOAuthCode(code, state); - } - }; - - window.addEventListener('message', messageHandler); - - popupMonitor = setInterval(async () => { - try { - if (popup.closed) { - cleanup(); - return; - } - if (hasProcessedCallback) return; - try { - const popupUrl: string = popup.location.href; - if (popupUrl && (popupUrl.includes('code=') || popupUrl.includes('error='))) { - hasProcessedCallback = true; - const url: URL = new URL(popupUrl); - const code: string | null = url.searchParams.get('code'); - const state: string | null = url.searchParams.get('state'); - const error: string | null = url.searchParams.get('error'); - - if (error) { - logger.error('OAuth error'); - popup.close(); - cleanup(); - return; - } - if (code && state) { - await processOAuthCode(code, state); - } - } - } catch { - // Cross-origin error expected during OAuth redirect - } - } catch { - logger.error('Error monitoring popup'); - } - }, 1000); - - return true; - }; - - // โ”€โ”€ Submit handler โ”€โ”€ - - const handleSubmit = async ( - component: any, - data?: Record, - skipValidation?: boolean, - ): Promise => { - if (!currentFlow.value) return; - - if (!skipValidation) { - const validation: {fieldErrors: Record; isValid: boolean} = validateForm(); - if (!validation.isValid) return; - } - - isLoading.value = true; - apiError.value = null; - flowMessages.value = []; - - try { - const filteredInputs: Record = {}; - if (data) { - Object.entries(data).forEach(([key, value]: [string, any]) => { - if (value !== null && value !== undefined && value !== '') { - filteredInputs[key] = value; - } - }); - } - - const payload: EmbeddedFlowExecuteRequestPayload = { - ...(currentFlow.value.flowId && {flowId: currentFlow.value.flowId}), - flowType: (currentFlow.value as any).flowType || 'REGISTRATION', - ...(component.id && {action: component.id}), - inputs: filteredInputs, - }; - - const rawResponse: EmbeddedFlowExecuteResponse = await props.onSubmit(payload); - const response: EmbeddedFlowExecuteResponse = normalizeFlowResponseLocal(rawResponse); - props.onFlowChange?.(response); - - if (response.flowStatus === EmbeddedFlowStatus.Complete) { - props.onComplete?.(response); - return; - } - - if (response.flowStatus === EmbeddedFlowStatus.Incomplete) { - if (handleRedirectionIfNeeded(response)) return; - - if (response.data?.additionalData?.['passkeyCreationOptions']) { - const {passkeyCreationOptions} = response.data.additionalData as any; - const effectiveFlowId: string | undefined = response.flowId || currentFlow.value?.flowId; - passkeyProcessed = false; - passkeyState.value = { - actionId: component.id || 'submit', - creationOptions: passkeyCreationOptions, - error: null, - flowId: effectiveFlowId || null, - isActive: true, - }; - isLoading.value = false; - return; - } - - currentFlow.value = response; - setupFormFields(response); - - // Display error from INCOMPLETE response - if ((response as any)?.error) { - handleError(response); - } - } - } catch (err) { - handleError(err); - props.onError?.(err as Error); - } finally { - isLoading.value = false; - } - }; - - // โ”€โ”€ Passkey registration watch โ”€โ”€ - - watch( - () => passkeyState.value, - async (state: PasskeyState) => { - if (!state.isActive || !state.creationOptions || !state.flowId) return; - if (passkeyProcessed) return; - passkeyProcessed = true; - - try { - const passkeyResponse: string = await handlePasskeyRegistration(state.creationOptions); - const passkeyObj: any = JSON.parse(passkeyResponse); - const inputs: Record = { - attestationObject: passkeyObj.response.attestationObject, - clientDataJSON: passkeyObj.response.clientDataJSON, - credentialId: passkeyObj.id, - }; - - const payload: EmbeddedFlowExecuteRequestPayload = { - actionId: state.actionId || 'submit', - flowId: state.flowId, - flowType: (currentFlow.value as any)?.flowType || 'REGISTRATION', - inputs, - } as any; - - const nextResponse: EmbeddedFlowExecuteResponse = await props.onSubmit(payload); - const processed: EmbeddedFlowExecuteResponse = normalizeFlowResponseLocal(nextResponse); - props.onFlowChange?.(processed); - - if (processed.flowStatus === EmbeddedFlowStatus.Complete) { - props.onComplete?.(processed); - } else { - currentFlow.value = processed; - setupFormFields(processed); - - // Display error from INCOMPLETE response - if ((processed as any)?.error) { - handleError(processed); - } - } - - passkeyState.value = {actionId: null, creationOptions: null, error: null, flowId: null, isActive: false}; - } catch (error: unknown) { - passkeyState.value = {...passkeyState.value, error: error as Error, isActive: false}; - handleError(error); - props.onError?.(error as Error); - } - }, - {deep: true}, - ); - - // โ”€โ”€ Flow initialization โ”€โ”€ - - watch( - () => [props.isInitialized, isFlowInitialized.value] as [boolean, boolean], - ([initialized, flowInit]: [boolean, boolean]) => { - // Skip if URL has OAuth code params - const urlParams: URLSearchParams = new URL(window.location.href).searchParams; - if (urlParams.get('code') || urlParams.get('state')) return; - - if (initialized && !flowInit && !initializationAttempted) { - initializationAttempted = true; - - (async (): Promise => { - isLoading.value = true; - apiError.value = null; - flowMessages.value = []; - - try { - const rawResponse: EmbeddedFlowExecuteResponse = await props.onInitialize(); - const response: EmbeddedFlowExecuteResponse = normalizeFlowResponseLocal(rawResponse); - currentFlow.value = response; - isFlowInitialized.value = true; - props.onFlowChange?.(response); - - if (response.flowStatus === EmbeddedFlowStatus.Complete) { - props.onComplete?.(response); - return; - } - if (response.flowStatus === EmbeddedFlowStatus.Incomplete) { - setupFormFields(response); - - // Display error from INCOMPLETE response - if ((response as any)?.error) { - handleError(response); - } - } - } catch (err) { - handleError(err); - props.onError?.(err as Error); - } finally { - isLoading.value = false; - } - })(); - } - }, - {immediate: true}, - ); - - // โ”€โ”€ Render โ”€โ”€ - - return (): VNode | null => { - const containerClass: string = [ - withVendorCSSClassPrefix('signup'), - withVendorCSSClassPrefix(`signup--${props.size}`), - withVendorCSSClassPrefix(`signup--${props.variant}`), - props.className, - ] - .filter(Boolean) - .join(' '); - - // Scoped slot / render props - if (slots['default']) { - const renderProps: BaseSignUpRenderProps = { - components: currentFlow.value?.data?.components || [], - error: apiError.value, - fieldErrors: formErrors.value, - handleInputChange, - handleSubmit, - isLoading: isLoading.value, - isValid: isFormValid.value, - messages: flowMessages.value, - subtitle: t('signup.subheading') || 'Create your account', - title: t('signup.heading') || 'Sign Up', - touched: touchedFields.value, - validateForm: (): {fieldErrors: Record; isValid: boolean} => { - const result: {fieldErrors: Record; isValid: boolean} = validateForm(); - return {fieldErrors: result.fieldErrors, isValid: result.isValid}; - }, - values: formValues.value, - }; - return h('div', {class: containerClass}, slots['default'](renderProps)); - } - - // Loading state - if (!isFlowInitialized.value && isLoading.value) { - return h(Card, {class: containerClass, variant: props.variant}, () => - h('div', {style: 'display:flex;justify-content:center;padding:2rem'}, h(Spinner)), - ); - } - - // No flow available - if (!currentFlow.value) { - return h(Card, {class: containerClass, variant: props.variant}, () => - h( - Alert, - {variant: 'error'}, - () => t('errors.signup.flow.initialization.failure') || 'Failed to initialize sign-up flow', - ), - ); - } - - // Extract headings - const componentsToRender: any[] = currentFlow.value.data?.components || []; - const {title, subtitle, componentsWithoutHeadings} = getAuthComponentHeadings( - componentsToRender, - undefined, - undefined, - t('signup.heading') || 'Sign Up', - t('signup.subheading') || 'Create your account', - ); - - const meta: FlowMetadataResponse | null = (flowMetaRef as Ref).value; - - const renderedComponents: VNode[] = - componentsWithoutHeadings.length > 0 - ? renderSignUpComponents( - componentsWithoutHeadings, - formValues.value, - touchedFields.value, - formErrors.value, - isLoading.value, - isFormValid.value, - handleInputChange, - { - buttonClassName: props.buttonClassName, - inputClassName: props.inputClassName, - meta, - onInputBlur: handleInputBlur, - onSubmit: handleSubmit, - size: props.size, - t, - variant: props.variant, - }, - ) - : []; - - return h(Card, {class: containerClass, variant: props.variant}, () => [ - // Header with title/subtitle - props.showTitle || props.showSubtitle - ? h('div', {style: 'padding: 1rem 1rem 0'}, [ - props.showTitle ? h(Typography, {variant: 'h5'}, () => title) : null, - props.showSubtitle - ? h(Typography, {style: 'margin-top: 0.25rem', variant: 'body1'}, () => subtitle) - : null, - ]) - : null, - // External error - props.error - ? h( - 'div', - {style: 'padding: 0 1rem'}, - h(Alert, {variant: 'error'}, () => props.error.message), - ) - : null, - // Flow messages - flowMessages.value.length > 0 - ? h( - 'div', - {style: 'padding: 0 1rem'}, - flowMessages.value.map((msg: {message: string; type: string}, i: number) => - h(Alert, {key: i, variant: msg.type === 'error' ? 'error' : 'info'}, () => msg.message), - ), - ) - : null, - // Components - h( - 'div', - {style: 'padding: 1rem'}, - renderedComponents.length > 0 - ? renderedComponents - : [ - h( - Alert, - {variant: 'warning'}, - () => t('errors.signup.components.not.available') || 'No components available', - ), - ], - ), - ]); - }; - }, -}); - -export default BaseSignUp; diff --git a/packages/vue/src/components/auth/sign-up/v2/SignUp.ts b/packages/vue/src/components/auth/sign-up/v2/SignUp.ts deleted file mode 100644 index 169f7f9..0000000 --- a/packages/vue/src/components/auth/sign-up/v2/SignUp.ts +++ /dev/null @@ -1,128 +0,0 @@ -/** - * 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 { - EmbeddedFlowExecuteRequestPayload, - EmbeddedFlowExecuteResponse, - EmbeddedFlowResponseType, - EmbeddedFlowType, -} from '@thunderid/browser'; -import {type Component, type PropType, type SetupContext, type VNode, defineComponent, h} from 'vue'; -import BaseSignUp from './BaseSignUp'; -import type {BaseSignUpRenderProps} from './BaseSignUp'; -import useThunderID from '../../../../composables/useThunderID'; - -export type SignUpRenderProps = BaseSignUpRenderProps; - -/** - * SignUp โ€” embedded sign-up component that handles API calls and delegates UI to BaseSignUp. - */ -const SignUp: Component = defineComponent({ - name: 'SignUp', - props: { - afterSignUpUrl: {default: undefined, type: String}, - buttonClassName: {default: '', type: String}, - className: {default: '', type: String}, - errorClassName: {default: '', type: String}, - inputClassName: {default: '', type: String}, - messageClassName: {default: '', type: String}, - onComplete: {default: undefined, type: Function as PropType<(response: EmbeddedFlowExecuteResponse) => void>}, - onError: {default: undefined, type: Function as PropType<(error: Error) => void>}, - shouldRedirectAfterSignUp: {default: true, type: Boolean}, - showSubtitle: {default: true, type: Boolean}, - showTitle: {default: true, type: Boolean}, - size: {default: 'medium', type: String as PropType<'small' | 'medium' | 'large'>}, - variant: {default: 'outlined', type: String as PropType<'elevated' | 'outlined' | 'flat'>}, - }, - setup(props: any, {slots}: SetupContext): () => VNode | null { - const {signUp, isInitialized, applicationId, scopes} = useThunderID(); - - const handleInitialize = async ( - payload?: EmbeddedFlowExecuteRequestPayload, - ): Promise => { - const urlParams: URLSearchParams = new URL(window.location.href).searchParams; - const applicationIdFromUrl: string | null = urlParams.get('applicationId'); - const effectiveApplicationId: string | undefined = applicationId || applicationIdFromUrl || undefined; - - const initialPayload: any = payload || { - flowType: EmbeddedFlowType.Registration, - ...(effectiveApplicationId && {applicationId: effectiveApplicationId}), - ...(scopes && {scopes}), - }; - - return (await signUp(initialPayload)) as EmbeddedFlowExecuteResponse; - }; - - const handleOnSubmit = async (payload: EmbeddedFlowExecuteRequestPayload): Promise => - (await signUp(payload)) as EmbeddedFlowExecuteResponse; - - const handleComplete = (response: EmbeddedFlowExecuteResponse): void => { - props.onComplete?.(response); - - const oauthRedirectUrl: string | undefined = (response as any)?.redirectUrl; - if (props.shouldRedirectAfterSignUp && oauthRedirectUrl) { - window.location.href = oauthRedirectUrl; - return; - } - - if ( - props.shouldRedirectAfterSignUp && - response?.type !== EmbeddedFlowResponseType.Redirection && - props.afterSignUpUrl && - !(response as any)?.assertion - ) { - window.location.href = props.afterSignUpUrl; - } - - if ( - props.shouldRedirectAfterSignUp && - response?.type === EmbeddedFlowResponseType.Redirection && - response?.data?.redirectURL && - !response.data.redirectURL.includes('oauth') && - !response.data.redirectURL.includes('auth') - ) { - window.location.href = response.data.redirectURL; - } - }; - - return (): VNode | null => - h( - BaseSignUp, - { - afterSignUpUrl: props.afterSignUpUrl, - buttonClassName: props.buttonClassName, - className: props.className, - errorClassName: props.errorClassName, - inputClassName: props.inputClassName, - isInitialized: isInitialized?.value ?? false, - messageClassName: props.messageClassName, - onComplete: handleComplete, - onError: props.onError, - onInitialize: handleInitialize, - onSubmit: handleOnSubmit, - showSubtitle: props.showSubtitle, - showTitle: props.showTitle, - size: props.size, - variant: props.variant, - }, - slots['default'] ? {default: (renderProps: any) => slots['default']!(renderProps)} : undefined, - ); - }, -}); - -export default SignUp; diff --git a/packages/vue/src/components/presentation/accept-invite/BaseAcceptInvite.ts b/packages/vue/src/components/presentation/accept-invite/BaseAcceptInvite.ts index a77d1d9..e122f6e 100644 --- a/packages/vue/src/components/presentation/accept-invite/BaseAcceptInvite.ts +++ b/packages/vue/src/components/presentation/accept-invite/BaseAcceptInvite.ts @@ -31,8 +31,8 @@ import { import useFlowMeta from '../../../composables/useFlowMeta'; import useI18n from '../../../composables/useI18n'; import {useOAuthCallback} from '../../../composables/useOAuthCallback'; +import {extractErrorMessage, normalizeFlowResponse} from '../../../utils/flowTransformer'; import {initiateOAuthRedirect} from '../../../utils/oauth'; -import {extractErrorMessage, normalizeFlowResponse} from '../../../utils/v2/flowTransformer'; import {renderInviteUserComponents} from '../../auth/sign-in/AuthOptionFactory'; import Alert from '../../primitives/Alert'; import Button from '../../primitives/Button'; diff --git a/packages/vue/src/components/presentation/invite-user/BaseInviteUser.ts b/packages/vue/src/components/presentation/invite-user/BaseInviteUser.ts index c7e3c3d..cac77f6 100644 --- a/packages/vue/src/components/presentation/invite-user/BaseInviteUser.ts +++ b/packages/vue/src/components/presentation/invite-user/BaseInviteUser.ts @@ -30,7 +30,7 @@ import { } from 'vue'; import useFlowMeta from '../../../composables/useFlowMeta'; import useI18n from '../../../composables/useI18n'; -import {extractErrorMessage, normalizeFlowResponse} from '../../../utils/v2/flowTransformer'; +import {extractErrorMessage, normalizeFlowResponse} from '../../../utils/flowTransformer'; import {renderInviteUserComponents} from '../../auth/sign-in/AuthOptionFactory'; import Alert from '../../primitives/Alert'; import Button from '../../primitives/Button'; diff --git a/packages/vue/src/composables/v2/useOAuthCallback.ts b/packages/vue/src/composables/v2/useOAuthCallback.ts deleted file mode 100644 index 652f348..0000000 --- a/packages/vue/src/composables/v2/useOAuthCallback.ts +++ /dev/null @@ -1,227 +0,0 @@ -/** - * 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 {watch, type Ref} from 'vue'; - -export interface UseOAuthCallbackOptions { - /** Current executionId from component state */ - currentExecutionId: Ref; - - /** SessionStorage key for executionId (defaults to 'thunderid_execution_id') */ - executionIdStorageKey?: string; - - /** Whether the component is initialized and ready to process OAuth callback */ - isInitialized: Ref; - - /** Whether a submission is currently in progress */ - isSubmitting?: Ref; - - /** Callback when OAuth flow completes successfully */ - onComplete?: () => void; - - /** Callback when OAuth flow encounters an error */ - onError?: (error: any) => void; - - /** Callback to handle flow response after submission */ - onFlowChange?: (response: any) => void; - - /** Callback to set loading state at the start of OAuth processing */ - onProcessingStart?: () => void; - - /** Function to submit OAuth code to the server */ - onSubmit: (payload: OAuthCallbackPayload) => Promise; - - /** Mutable flag to track whether OAuth has already been processed */ - processedFlag?: {value: boolean}; - - /** Additional handler for setting state (e.g., setExecutionId) */ - setExecutionId?: (executionId: string) => void; - - /** - * Mutable flag for token validation tracking. - * Used in AcceptInvite to coordinate between OAuth callback and token validation. - */ - tokenValidationAttemptedFlag?: {value: boolean}; -} - -export interface OAuthCallbackPayload { - /** The execution ID of the active flow step */ - executionId: string; - - /** OAuth callback inputs extracted from the redirect URL */ - inputs: { - /** The authorization code returned by the OAuth provider */ - code: string; - - /** Optional nonce for OIDC replay protection */ - nonce?: string; - }; -} - -/** - * Removes OAuth-related query parameters from the current URL without triggering a navigation. - * This prevents re-processing the callback on subsequent renders or page interactions. - */ -function cleanupUrlParams(): void { - if (typeof window === 'undefined') return; - - const url: URL = new URL(window.location.href); - url.searchParams.delete('code'); - url.searchParams.delete('nonce'); - url.searchParams.delete('state'); - url.searchParams.delete('error'); - url.searchParams.delete('error_description'); - - window.history.replaceState({}, '', url.toString()); -} - -/** - * Processes OAuth callbacks by detecting auth code in URL, resolving executionId, and submitting to server. - * Used by SignIn, SignUp, and AcceptInvite components. - * - * Vue composable equivalent of React's useOAuthCallback hook. - */ -export function useOAuthCallback({ - currentExecutionId, - executionIdStorageKey = 'thunderid_execution_id', - isInitialized, - isSubmitting, - onComplete, - onError, - onFlowChange, - onProcessingStart, - onSubmit, - processedFlag, - setExecutionId, - tokenValidationAttemptedFlag, -}: UseOAuthCallbackOptions): void { - /** Fallback mutable flag used when no external processedFlag is provided */ - const internalFlag: {value: boolean} = {value: false}; - - /** Ensures OAuth code is submitted only once, even across reactive re-evaluations */ - const oauthCodeProcessedFlag: {value: boolean} = processedFlag ?? internalFlag; - - /** Tracks whether token validation has been attempted; used to coordinate with AcceptInvite */ - const tokenValidationFlag: {value: boolean} | undefined = tokenValidationAttemptedFlag; - - // Re-run whenever initialization state, executionId, or submission state changes. - // `immediate: true` ensures the callback runs on mount to catch OAuth redirects on first load. - watch( - () => [isInitialized.value, currentExecutionId.value, isSubmitting?.value] as const, - ([initialized, , submitting]: readonly [boolean, string | null, boolean | undefined]) => { - // Wait until the component is ready and any in-flight submission has settled. - if (!initialized || submitting) { - return; - } - - // Extract all OAuth-related parameters from the redirect URL. - const urlParams: URLSearchParams = new URLSearchParams(window.location.search); - const code: string | null = urlParams.get('code'); - const nonce: string | null = urlParams.get('nonce'); - const state: string | null = urlParams.get('state'); - const executionIdFromUrl: string | null = urlParams.get('executionId'); - const error: string | null = urlParams.get('error'); - const errorDescription: string | null = urlParams.get('error_description'); - - // Handle OAuth provider errors (e.g., user denied consent) before processing the code. - if (error) { - oauthCodeProcessedFlag.value = true; - if (tokenValidationFlag) { - tokenValidationFlag.value = true; - } - onError?.(new Error(errorDescription || error || 'OAuth authentication failed')); - cleanupUrlParams(); - return; - } - - // Skip if there is no authorization code or if it has already been submitted. - if (!code || oauthCodeProcessedFlag.value) { - return; - } - - // In AcceptInvite flows, token validation runs concurrently. If it has already - // started, the OAuth callback should not interfere. - if (tokenValidationFlag?.value) { - return; - } - - // Resolve executionId using the most specific available source: - // component state > sessionStorage > URL param > OAuth state param. - const storedExecutionId: string | null = sessionStorage.getItem(executionIdStorageKey); - const executionIdToUse: string | null = - currentExecutionId.value || storedExecutionId || executionIdFromUrl || state || null; - - // Cannot proceed without an executionId โ€” the flow context is missing. - if (!executionIdToUse) { - oauthCodeProcessedFlag.value = true; - onError?.(new Error('Invalid flow. Missing executionId.')); - cleanupUrlParams(); - return; - } - - // Mark as processed synchronously before the async submission to prevent - // duplicate submissions if the watcher fires again during the await. - oauthCodeProcessedFlag.value = true; - - if (tokenValidationFlag) { - tokenValidationFlag.value = true; - } - - // Signal the component to enter a loading state before the async work begins. - onProcessingStart?.(); - - // Sync the resolved executionId back into component state if it was sourced - // from sessionStorage or the URL rather than reactive state. - if (!currentExecutionId.value && setExecutionId) { - setExecutionId(executionIdToUse); - } - - // Submit the OAuth code in an IIFE to allow async/await inside a synchronous watcher callback. - (async (): Promise => { - try { - const payload: OAuthCallbackPayload = { - executionId: executionIdToUse, - inputs: { - code, - ...(nonce && {nonce}), - }, - }; - - const response: any = await onSubmit(payload); - - // Notify the component so it can update its flow state (e.g., move to the next step). - onFlowChange?.(response); - - if (response?.flowStatus === 'COMPLETE' || response?.status === 'COMPLETE') { - onComplete?.(); - } - - if (response?.flowStatus === 'ERROR' || response?.status === 'ERROR') { - onError?.(response); - } - - cleanupUrlParams(); - } catch (err) { - onError?.(err); - cleanupUrlParams(); - } - })(); - }, - {immediate: true}, - ); -} diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts index f2c01b3..b6cf034 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -43,11 +43,6 @@ export {default as useTheme} from './composables/useTheme'; export {default as useUser} from './composables/useUser'; export {useOAuthCallback} from './composables/useOAuthCallback'; export type {UseOAuthCallbackOptions, OAuthCallbackPayload} from './composables/useOAuthCallback'; -export {useOAuthCallback as useOAuthCallbackV2} from './composables/v2/useOAuthCallback'; -export type { - UseOAuthCallbackOptions as UseOAuthCallbackOptionsV2, - OAuthCallbackPayload as OAuthCallbackPayloadV2, -} from './composables/v2/useOAuthCallback'; // โ”€โ”€ Client โ”€โ”€ export {default as ThunderIDVueClient} from './ThunderIDVueClient'; @@ -180,18 +175,15 @@ export {default as FieldFactory, createField, validateFieldValue} from './compon export type {FieldConfig} from './components/factories/FieldFactory'; // โ”€โ”€ Utilities โ”€โ”€ -export {default as buildThemeConfigFromFlowMeta} from './utils/v2/buildThemeConfigFromFlowMeta'; -export {default as getAuthComponentHeadings} from './utils/v2/getAuthComponentHeadings'; -export type {HeadingExtractionResult, AuthComponentHeadingsResult} from './utils/v2/getAuthComponentHeadings'; +export {default as buildThemeConfigFromFlowMeta} from './utils/buildThemeConfigFromFlowMeta'; +export {default as getAuthComponentHeadings} from './utils/getAuthComponentHeadings'; +export type {HeadingExtractionResult, AuthComponentHeadingsResult} from './utils/getAuthComponentHeadings'; // โ”€โ”€ Re-exports from @thunderid/browser โ”€โ”€ export { FieldType, type AllOrganizationsApiResponse, type Config, - type EmbeddedFlowExecuteRequestPayload, - type EmbeddedFlowExecuteResponse, - type EmbeddedSignInFlowHandleRequestPayload, type HttpRequestConfig, type HttpResponse, type IdToken, @@ -222,31 +214,31 @@ export {getActiveTheme} from './theme/getActiveTheme'; export {detectThemeMode, createClassObserver, createMediaQueryListener} from './theme/themeDetection'; export type {BrowserThemeDetection} from './theme/themeDetection'; -// โ”€โ”€ Phase 4 โ€” Re-exports from @thunderid/browser (V2 embedded flow models) โ”€โ”€ +// โ”€โ”€ Re-exports from @thunderid/browser (embedded flow models) โ”€โ”€ export { ThunderIDRuntimeError, - EmbeddedFlowComponentTypeV2 as EmbeddedFlowComponentType, - EmbeddedFlowActionVariantV2 as EmbeddedFlowActionVariant, - EmbeddedFlowTextVariantV2 as EmbeddedFlowTextVariant, - EmbeddedFlowEventTypeV2 as EmbeddedFlowEventType, - type EmbeddedFlowComponentV2 as EmbeddedFlowComponent, - type EmbeddedFlowResponseDataV2 as EmbeddedFlowResponseData, - type EmbeddedFlowExecuteRequestConfigV2 as EmbeddedFlowExecuteRequestConfig, - EmbeddedSignInFlowStatusV2 as EmbeddedSignInFlowStatus, - EmbeddedSignInFlowTypeV2 as EmbeddedSignInFlowType, - type ExtendedEmbeddedSignInFlowResponseV2 as ExtendedEmbeddedSignInFlowResponse, - type EmbeddedSignInFlowResponseV2 as EmbeddedSignInFlowResponse, - type EmbeddedSignInFlowCompleteResponseV2 as EmbeddedSignInFlowCompleteResponse, - type EmbeddedSignInFlowInitiateRequestV2 as EmbeddedSignInFlowInitiateRequest, - type EmbeddedSignInFlowRequestV2 as EmbeddedSignInFlowRequest, - type EmbeddedSignUpFlowStatusV2 as EmbeddedSignUpFlowStatus, - type EmbeddedSignUpFlowTypeV2 as EmbeddedSignUpFlowType, - type ExtendedEmbeddedSignUpFlowResponseV2 as ExtendedEmbeddedSignUpFlowResponse, - type EmbeddedSignUpFlowResponseV2 as EmbeddedSignUpFlowResponse, - type EmbeddedSignUpFlowCompleteResponseV2 as EmbeddedSignUpFlowCompleteResponse, - type EmbeddedSignUpFlowInitiateRequestV2 as EmbeddedSignUpFlowInitiateRequest, - type EmbeddedSignUpFlowRequestV2 as EmbeddedSignUpFlowRequest, - type EmbeddedSignUpFlowErrorResponseV2 as EmbeddedSignUpFlowErrorResponse, + EmbeddedFlowComponentType, + EmbeddedFlowActionVariant, + EmbeddedFlowTextVariant, + EmbeddedFlowEventType, + type EmbeddedFlowComponent, + type EmbeddedFlowResponseData, + type EmbeddedFlowExecuteRequestConfig, + EmbeddedSignInFlowStatus, + EmbeddedSignInFlowType, + type ExtendedEmbeddedSignInFlowResponse, + type EmbeddedSignInFlowResponse, + type EmbeddedSignInFlowCompleteResponse, + type EmbeddedSignInFlowInitiateRequest, + type EmbeddedSignInFlowRequest, + EmbeddedSignUpFlowStatus, + EmbeddedSignUpFlowType, + type ExtendedEmbeddedSignUpFlowResponse, + type EmbeddedSignUpFlowResponse, + type EmbeddedSignUpFlowCompleteResponse, + type EmbeddedSignUpFlowInitiateRequest, + type EmbeddedSignUpFlowRequest, + type EmbeddedSignUpFlowErrorResponse, type ComponentRenderContext, type ComponentsExtensions, type ComponentRenderer, diff --git a/packages/vue/src/models/contexts.ts b/packages/vue/src/models/contexts.ts index 385a25c..14d0a95 100644 --- a/packages/vue/src/models/contexts.ts +++ b/packages/vue/src/models/contexts.ts @@ -25,7 +25,6 @@ import type { HttpResponse, IdToken, Organization, - Platform, Schema, SignInOptions, StorageManager, @@ -88,7 +87,6 @@ export interface ThunderIDContext { /** The current organization, or `null`. */ organization: Readonly>; organizationHandle: string | undefined; - platform: Platform | undefined; // โ”€โ”€ Lifecycle โ”€โ”€ reInitialize: (config: Partial) => Promise; diff --git a/packages/vue/src/providers/FlowMetaProvider.ts b/packages/vue/src/providers/FlowMetaProvider.ts index e3f6ca7..5cfbe2c 100644 --- a/packages/vue/src/providers/FlowMetaProvider.ts +++ b/packages/vue/src/providers/FlowMetaProvider.ts @@ -19,7 +19,7 @@ import { FlowMetadataResponse, FlowMetaType, - getFlowMetaV2, + getFlowMeta, I18nBundle, TranslationBundleConstants, } from '@thunderid/browser'; @@ -85,7 +85,7 @@ const FlowMetaProvider: Component = defineComponent({ error.value = null; try { - const result: FlowMetadataResponse = await getFlowMetaV2({ + const result: FlowMetadataResponse = await getFlowMeta({ baseUrl, ...(applicationId ? {id: applicationId, type: FlowMetaType.App} : {}), }); @@ -104,7 +104,7 @@ const FlowMetaProvider: Component = defineComponent({ error.value = null; try { - const result: FlowMetadataResponse = await getFlowMetaV2({ + const result: FlowMetadataResponse = await getFlowMeta({ baseUrl, ...(applicationId ? {id: applicationId, type: FlowMetaType.App} : {}), language, diff --git a/packages/vue/src/providers/ThunderIDProvider.ts b/packages/vue/src/providers/ThunderIDProvider.ts index 4ccadc3..6053eba 100644 --- a/packages/vue/src/providers/ThunderIDProvider.ts +++ b/packages/vue/src/providers/ThunderIDProvider.ts @@ -26,12 +26,11 @@ import { HttpResponse, IdToken, Organization, - Platform, User, UserProfile, SignInOptions, TokenResponse, - EmbeddedSignInFlowResponseV2, + EmbeddedSignInFlowResponse, } from '@thunderid/browser'; import { type Component, @@ -69,7 +68,6 @@ interface ThunderIDProviderProps { instanceId: number; organizationChain: object | undefined; organizationHandle: string | undefined; - platform: string | undefined; scopes: string | string[] | undefined; signInOptions: SignInOptions | undefined; signInUrl: string | undefined; @@ -148,11 +146,6 @@ const ThunderIDProvider: Component = defineComponent({ default: undefined, type: String, }, - /** Platform type. */ - platform: { - default: undefined, - type: String, - }, /** The scopes to request. */ scopes: { default: undefined, @@ -255,7 +248,7 @@ const ThunderIDProvider: Component = defineComponent({ } // โ”€โ”€ Sign In (wrapper) โ”€โ”€ - async function signIn(...args: any[]): Promise { + async function signIn(...args: any[]): Promise { const arg1: any = args[0]; const isV2FlowRequest: boolean = typeof arg1 === 'object' && arg1 !== null && ('executionId' in arg1 || 'applicationId' in arg1); @@ -361,7 +354,6 @@ const ThunderIDProvider: Component = defineComponent({ isSignedIn, organization: currentOrganization, organizationHandle: props.organizationHandle, - platform: Platform.ThunderID, reInitialize: async (config: any): Promise => { const result: boolean = await client.reInitialize(config); return typeof result === 'boolean' ? result : true; diff --git a/packages/vue/src/utils/v2/buildThemeConfigFromFlowMeta.ts b/packages/vue/src/utils/buildThemeConfigFromFlowMeta.ts similarity index 100% rename from packages/vue/src/utils/v2/buildThemeConfigFromFlowMeta.ts rename to packages/vue/src/utils/buildThemeConfigFromFlowMeta.ts diff --git a/packages/vue/src/utils/v2/flowTransformer.ts b/packages/vue/src/utils/flowTransformer.ts similarity index 98% rename from packages/vue/src/utils/v2/flowTransformer.ts rename to packages/vue/src/utils/flowTransformer.ts index a71c2a8..c1a1487 100644 --- a/packages/vue/src/utils/v2/flowTransformer.ts +++ b/packages/vue/src/utils/flowTransformer.ts @@ -31,7 +31,7 @@ * * Usage: * ```typescript - * import { normalizeFlowResponse } from '../../../utils/v2/flowTransformer'; + * import { normalizeFlowResponse } from '../../../utils/flowTransformer'; * * const { executionId, components } = normalizeFlowResponse(apiResponse, t, { * defaultErrorKey: 'components.signIn.errors.generic' @@ -42,7 +42,7 @@ * consistent response handling across all embedded flows. */ -import {EmbeddedFlowComponentV2 as EmbeddedFlowComponent, FlowMetadataResponse} from '@thunderid/browser'; +import {EmbeddedFlowComponent, FlowMetadataResponse} from '@thunderid/browser'; import resolveTranslationsInArray from './resolveTranslationsInArray'; type TranslationFn = (key: string, params?: Record) => string; diff --git a/packages/vue/src/utils/v2/getAuthComponentHeadings.ts b/packages/vue/src/utils/getAuthComponentHeadings.ts similarity index 97% rename from packages/vue/src/utils/v2/getAuthComponentHeadings.ts rename to packages/vue/src/utils/getAuthComponentHeadings.ts index 314d0d4..0d59d57 100644 --- a/packages/vue/src/utils/v2/getAuthComponentHeadings.ts +++ b/packages/vue/src/utils/getAuthComponentHeadings.ts @@ -16,7 +16,7 @@ * under the License. */ -import {EmbeddedFlowComponentV2 as EmbeddedFlowComponent} from '@thunderid/browser'; +import {EmbeddedFlowComponent} from '@thunderid/browser'; /** * Result of heading extraction from flow components diff --git a/packages/vue/src/utils/v2/passkey.ts b/packages/vue/src/utils/passkey.ts similarity index 100% rename from packages/vue/src/utils/v2/passkey.ts rename to packages/vue/src/utils/passkey.ts diff --git a/packages/vue/src/utils/v2/resolveTranslationsInArray.ts b/packages/vue/src/utils/resolveTranslationsInArray.ts similarity index 100% rename from packages/vue/src/utils/v2/resolveTranslationsInArray.ts rename to packages/vue/src/utils/resolveTranslationsInArray.ts diff --git a/packages/vue/src/utils/v2/resolveTranslationsInObject.ts b/packages/vue/src/utils/resolveTranslationsInObject.ts similarity index 100% rename from packages/vue/src/utils/v2/resolveTranslationsInObject.ts rename to packages/vue/src/utils/resolveTranslationsInObject.ts diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 1436e84..b2a30d1 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,11 +1,17 @@ packages: - packages/** +minimumReleaseAgeExclude: + # JUSTIFICATION: Temporary exclusion due to recent release. TODO: Remove after the time passes. + - '@thunderid/eslint-plugin' + - '@thunderid/prettier-config' + allowBuilds: '@parcel/watcher': true core-js: true esbuild: true nx: true + sharp: true unrs-resolver: true catalog: diff --git a/tsconfig.base.json b/tsconfig.base.json index 1ec618f..875cb60 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -1,21 +1,3 @@ { - "compilerOptions": { - "composite": true, - "declarationMap": true, - "emitDeclarationOnly": true, - "importHelpers": true, - "isolatedModules": true, - "lib": ["es2022"], - "module": "nodenext", - "moduleResolution": "nodenext", - "noEmitOnError": true, - "noFallthroughCasesInSwitch": true, - "noImplicitOverride": true, - "noImplicitReturns": true, - "noUnusedLocals": true, - "skipLibCheck": true, - "strict": true, - "target": "es2022", - "customConditions": ["@thunderid/frontend"] - } + "compilerOptions": {} }