diff --git a/.changeset/khaki-ends-begin.md b/.changeset/khaki-ends-begin.md new file mode 100644 index 00000000..180effd3 --- /dev/null +++ b/.changeset/khaki-ends-begin.md @@ -0,0 +1,9 @@ +--- +'@asgardeo/javascript': patch +'@asgardeo/browser': patch +'@asgardeo/nextjs': patch +'@asgardeo/react': patch +'@asgardeo/i18n': patch +--- + +Add `asagrdeo/thunder` support diff --git a/packages/browser/src/__legacy__/client.ts b/packages/browser/src/__legacy__/client.ts index d47a8950..2e4c4061 100755 --- a/packages/browser/src/__legacy__/client.ts +++ b/packages/browser/src/__legacy__/client.ts @@ -1157,4 +1157,12 @@ export class AsgardeoSPAClient { return; } + + /** + * This method clears the session information from the storage. + * @param sessionId - The session ID of the session to be cleared. If not provided, the current session will be cleared. + */ + public clearSession(sessionId?: string): void { + AsgardeoAuthClient.clearSession(sessionId); + } } diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index 739e4a69..5b687a17 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -61,4 +61,5 @@ export { } from './theme/themeDetection'; export {default as getActiveTheme} from './theme/getActiveTheme'; +export {default as handleWebAuthnAuthentication} from './utils/handleWebAuthnAuthentication'; export {default as http} from './utils/http'; diff --git a/packages/browser/src/utils/handleWebAuthnAuthentication.ts b/packages/browser/src/utils/handleWebAuthnAuthentication.ts new file mode 100644 index 00000000..d7148f60 --- /dev/null +++ b/packages/browser/src/utils/handleWebAuthnAuthentication.ts @@ -0,0 +1,264 @@ +/** + * 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 {arrayBufferToBase64url, base64urlToArrayBuffer, AsgardeoRuntimeError} from '@asgardeo/javascript'; + +/** + * Handles WebAuthn/Passkey authentication flow for browser environments. + * + * This function processes a WebAuthn challenge, performs the authentication ceremony, + * and returns the authentication response that can be sent to the server for verification. + * + * The function handles various aspects of WebAuthn authentication including: + * - Browser compatibility checks for WebAuthn support + * - HTTPS requirement validation (except for localhost development) + * - Relying Party ID validation and domain compatibility + * - Challenge data decoding and credential request options processing + * - User authentication ceremony via navigator.credentials.get() + * - Response formatting for server consumption + * + * @param challengeData - Base64-encoded challenge data containing WebAuthn request options. + * This data typically includes the challenge, RP ID, allowed credentials, + * user verification requirements, and other authentication parameters. + * + * @returns Promise that resolves to a JSON string containing the WebAuthn authentication response. + * The response includes the credential ID, authenticator data, client data JSON, + * signature, and optional user handle that can be verified by the server. + * + * @throws {AsgardeoRuntimeError} When WebAuthn is not supported in the current browser + * @throws {AsgardeoRuntimeError} When the page is not served over HTTPS (except localhost) + * @throws {AsgardeoRuntimeError} When the user cancels or times out the authentication + * @throws {AsgardeoRuntimeError} When there's a domain/RP ID mismatch + * @throws {AsgardeoRuntimeError} When no valid passkey is found for the account + * @throws {AsgardeoRuntimeError} When WebAuthn is not supported on the device/browser + * @throws {AsgardeoRuntimeError} When there's a network error during authentication + * @throws {AsgardeoRuntimeError} For any other authentication failures + * + * @example + * ```typescript + * try { + * const challengeData = 'eyJwdWJsaWNLZXlDcmVkZW50aWFsUmVxdWVzdE9wdGlvbnMiOi4uLn0='; + * const authResponse = await handleWebAuthnAuthentication(challengeData); + * + * // Send the response to your server for verification + * const result = await fetch('/api/verify-webauthn', { + * method: 'POST', + * headers: { 'Content-Type': 'application/json' }, + * body: authResponse + * }); + * } catch (error) { + * if (error instanceof AsgardeoRuntimeError) { + * console.error('WebAuthn authentication failed:', error.message); + * } + * } + * ``` + * + * @example + * ```typescript + * // Usage in an authentication flow + * const authenticateWithPasskey = async (challengeFromServer: string) => { + * try { + * const response = await handleWebAuthnAuthentication(challengeFromServer); + * return JSON.parse(response); + * } catch (error) { + * // Handle specific error cases + * if (error instanceof AsgardeoRuntimeError) { + * switch (error.code) { + * case 'browser-webauthn-not-supported': + * showFallbackAuth(); + * break; + * case 'browser-webauthn-user-cancelled': + * showRetryOption(); + * break; + * default: + * showGenericError(); + * } + * } + * } + * }; + * ``` + * + * @see {@link https://webauthn.guide/} - WebAuthn specification guide + * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/Web_Authentication_API} - MDN WebAuthn API documentation + */ +const handleWebAuthnAuthentication = async (challengeData: string): Promise => { + if (!window.navigator.credentials || !window.navigator.credentials.get) { + throw new AsgardeoRuntimeError( + 'WebAuthn is not supported in this browser. Please use a modern browser or try a different authentication method.', + 'browser-webauthn-not-supported', + 'browser', + 'WebAuthn/Passkey authentication requires a browser that supports the Web Authentication API.', + ); + } + + if (window.location.protocol !== 'https:' && window.location.hostname !== 'localhost') { + throw new AsgardeoRuntimeError( + 'Passkey authentication requires a secure connection (HTTPS). Please ensure you are accessing this site over HTTPS.', + 'browser-webauthn-insecure-connection', + 'browser', + 'WebAuthn authentication requires HTTPS for security reasons, except when running on localhost for development.', + ); + } + + try { + const decodedChallenge = JSON.parse(atob(challengeData)); + const {publicKeyCredentialRequestOptions} = decodedChallenge; + + const currentDomain = window.location.hostname; + const challengeRpId = publicKeyCredentialRequestOptions.rpId; + + let rpIdToUse = challengeRpId; + + if (challengeRpId && !currentDomain.endsWith(challengeRpId) && challengeRpId !== currentDomain) { + console.warn(`RP ID mismatch detected. Challenge RP ID: ${challengeRpId}, Current domain: ${currentDomain}`); + rpIdToUse = currentDomain; + } + + const adjustedOptions = { + ...publicKeyCredentialRequestOptions, + rpId: rpIdToUse, + challenge: base64urlToArrayBuffer(publicKeyCredentialRequestOptions.challenge), + ...(publicKeyCredentialRequestOptions.userVerification && { + userVerification: publicKeyCredentialRequestOptions.userVerification, + }), + ...(publicKeyCredentialRequestOptions.allowCredentials && { + allowCredentials: publicKeyCredentialRequestOptions.allowCredentials.map((cred: any) => ({ + ...cred, + id: base64urlToArrayBuffer(cred.id), + })), + }), + }; + + const credential = (await navigator.credentials.get({ + publicKey: adjustedOptions, + })) as PublicKeyCredential; + + if (!credential) { + throw new AsgardeoRuntimeError( + 'No credential returned from WebAuthn authentication', + 'browser-webauthn-no-credential', + 'browser', + 'The WebAuthn authentication ceremony completed but did not return a valid credential.', + ); + } + + const authData = credential.response as AuthenticatorAssertionResponse; + + const tokenResponse = { + requestId: decodedChallenge.requestId, + credential: { + id: credential.id, + rawId: arrayBufferToBase64url(credential.rawId), + response: { + authenticatorData: arrayBufferToBase64url(authData.authenticatorData), + clientDataJSON: arrayBufferToBase64url(authData.clientDataJSON), + signature: arrayBufferToBase64url(authData.signature), + ...(authData.userHandle && { + userHandle: arrayBufferToBase64url(authData.userHandle), + }), + }, + type: credential.type, + }, + }; + + return JSON.stringify(tokenResponse); + } catch (error) { + console.error('WebAuthn authentication failed:', error); + + if (error instanceof AsgardeoRuntimeError) { + throw error; + } + + if (error instanceof Error) { + switch (error.name) { + case 'NotAllowedError': + throw new AsgardeoRuntimeError( + 'Passkey authentication was cancelled or timed out. Please try again.', + 'browser-webauthn-user-cancelled', + 'browser', + 'The user cancelled the WebAuthn authentication request or the request timed out.', + ); + + case 'SecurityError': + if (error.message.includes('relying party ID') || error.message.includes('RP ID')) { + throw new AsgardeoRuntimeError( + 'Domain mismatch error. The passkey was registered for a different domain. Please contact support or try a different authentication method.', + 'browser-webauthn-domain-mismatch', + 'browser', + 'The WebAuthn relying party ID does not match the current domain.', + ); + } + throw new AsgardeoRuntimeError( + 'Passkey authentication failed due to a security error. Please ensure you are using HTTPS and that your browser supports passkeys.', + 'browser-webauthn-security-error', + 'browser', + 'A security error occurred during WebAuthn authentication.', + ); + + case 'InvalidStateError': + throw new AsgardeoRuntimeError( + 'No valid passkey found for this account. Please register a passkey first or use a different authentication method.', + 'browser-webauthn-no-passkey', + 'browser', + 'No registered passkey credentials were found for the current user account.', + ); + + case 'NotSupportedError': + throw new AsgardeoRuntimeError( + 'Passkey authentication is not supported on this device or browser. Please use a different authentication method.', + 'browser-webauthn-not-supported', + 'browser', + 'WebAuthn is not supported on the current device or browser configuration.', + ); + + case 'NetworkError': + throw new AsgardeoRuntimeError( + 'Network error during passkey authentication. Please check your connection and try again.', + 'browser-webauthn-network-error', + 'browser', + 'A network error occurred while communicating with the authenticator.', + ); + + case 'UnknownError': + throw new AsgardeoRuntimeError( + 'An unknown error occurred during passkey authentication. Please try again or use a different authentication method.', + 'browser-webauthn-unknown-error', + 'browser', + 'An unidentified error occurred during the WebAuthn authentication process.', + ); + + default: + throw new AsgardeoRuntimeError( + `Passkey authentication failed: ${error.message}`, + 'browser-webauthn-general-error', + 'browser', + `WebAuthn authentication failed with error: ${error.name}`, + ); + } + } + + throw new AsgardeoRuntimeError( + 'Passkey authentication failed due to an unexpected error.', + 'browser-webauthn-unexpected-error', + 'browser', + 'An unexpected error occurred during WebAuthn authentication.', + ); + } +}; + +export default handleWebAuthnAuthentication; diff --git a/packages/i18n/src/models/i18n.ts b/packages/i18n/src/models/i18n.ts index 39afb9c9..7fa72be4 100644 --- a/packages/i18n/src/models/i18n.ts +++ b/packages/i18n/src/models/i18n.ts @@ -33,11 +33,14 @@ export interface I18nTranslations { 'elements.buttons.microsoft': string; 'elements.buttons.linkedin': string; 'elements.buttons.ethereum': string; + 'elements.buttons.smsotp': string; 'elements.buttons.multi.option': string; 'elements.buttons.social': string; /* Fields */ 'elements.fields.placeholder': string; + 'elements.fields.username': string; + 'elements.fields.password': string; /* |---------------------------------------------------------------| */ /* | Widgets | */ diff --git a/packages/i18n/src/translations/en-US.ts b/packages/i18n/src/translations/en-US.ts index d09039c3..74a139bd 100644 --- a/packages/i18n/src/translations/en-US.ts +++ b/packages/i18n/src/translations/en-US.ts @@ -36,11 +36,14 @@ const translations: I18nTranslations = { 'elements.buttons.microsoft': 'Continue with Microsoft', 'elements.buttons.linkedin': 'Continue with LinkedIn', 'elements.buttons.ethereum': 'Continue with Sign In Ethereum', + 'elements.buttons.smsotp': 'Continue with SMS OTP', 'elements.buttons.multi.option': 'Continue with {connection}', 'elements.buttons.social': 'Continue with {connection}', /* Fields */ 'elements.fields.placeholder': 'Enter your {field}', + 'elements.fields.username': 'Username', + 'elements.fields.password': 'Password', /* |---------------------------------------------------------------| */ /* | Widgets | */ diff --git a/packages/i18n/src/translations/fr-FR.ts b/packages/i18n/src/translations/fr-FR.ts index e377185e..fac146a2 100644 --- a/packages/i18n/src/translations/fr-FR.ts +++ b/packages/i18n/src/translations/fr-FR.ts @@ -36,11 +36,14 @@ const translations: I18nTranslations = { 'elements.buttons.microsoft': 'Continuer avec Microsoft', 'elements.buttons.linkedin': 'Continuer with LinkedIn', 'elements.buttons.ethereum': 'Continuer avec Sign In Ethereum', + 'elements.buttons.smsotp': 'Continuer avec SMS', 'elements.buttons.multi.option': 'Continuer avec {connection}', 'elements.buttons.social': 'Continuer avec {connection}', /* Fields */ 'elements.fields.placeholder': 'Entrez votre {field}', + 'elements.fields.username': "Nom d'utilisateur", + 'elements.fields.password': 'Mot de passe', /* |---------------------------------------------------------------| */ /* | Widgets | */ diff --git a/packages/i18n/src/translations/hi-IN.ts b/packages/i18n/src/translations/hi-IN.ts index 031ee773..cdd7dab2 100644 --- a/packages/i18n/src/translations/hi-IN.ts +++ b/packages/i18n/src/translations/hi-IN.ts @@ -35,11 +35,14 @@ const translations: I18nTranslations = { 'elements.buttons.microsoft': 'Microsoft के साथ जारी रखें', 'elements.buttons.linkedin': 'LinkedIn के साथ जारी रखें', 'elements.buttons.ethereum': 'Ethereum के साथ साइन इन करें', + 'elements.buttons.smsotp': 'SMS के साथ जारी रखें', 'elements.buttons.multi.option': '{connection} के साथ जारी रखें', 'elements.buttons.social': '{connection} के साथ जारी रखें', /* Fields */ 'elements.fields.placeholder': '{field} दर्ज करें', + 'elements.fields.username': 'उपयोगकर्ता नाम', + 'elements.fields.password': 'पासवर्ड', /* |---------------------------------------------------------------| */ /* | Widgets | */ diff --git a/packages/i18n/src/translations/ja-JP.ts b/packages/i18n/src/translations/ja-JP.ts index 39173e2e..ed3c97e1 100644 --- a/packages/i18n/src/translations/ja-JP.ts +++ b/packages/i18n/src/translations/ja-JP.ts @@ -36,11 +36,14 @@ const translations: I18nTranslations = { 'elements.buttons.microsoft': 'Microsoftで続行', 'elements.buttons.linkedin': 'LinkedInで続行', 'elements.buttons.ethereum': 'Ethereumでサインイン', + 'elements.buttons.smsotp': 'SMSで続行', 'elements.buttons.multi.option': '{connection}で続行', 'elements.buttons.social': '{connection}で続行', /* Fields */ 'elements.fields.placeholder': '{field}を入力してください', + 'elements.fields.username': 'ユーザー名', + 'elements.fields.password': 'パスワード', /* |---------------------------------------------------------------| */ /* | Widgets | */ diff --git a/packages/i18n/src/translations/pt-BR.ts b/packages/i18n/src/translations/pt-BR.ts index fb372e98..a4003971 100644 --- a/packages/i18n/src/translations/pt-BR.ts +++ b/packages/i18n/src/translations/pt-BR.ts @@ -36,11 +36,14 @@ const translations: I18nTranslations = { 'elements.buttons.microsoft': 'Entrar com Microsoft', 'elements.buttons.linkedin': 'Entrar com LinkedIn', 'elements.buttons.ethereum': 'Entrar com Ethereum', + 'elements.buttons.smsotp': 'Entrar com SMS', 'elements.buttons.multi.option': 'Entrar com {connection}', 'elements.buttons.social': 'Entrar com {connection}', /* Fields */ 'elements.fields.placeholder': 'Digite seu {field}', + 'elements.fields.username': 'Nome de usuário', + 'elements.fields.password': 'Senha', /* |---------------------------------------------------------------| */ /* | Widgets | */ diff --git a/packages/i18n/src/translations/pt-PT.ts b/packages/i18n/src/translations/pt-PT.ts index 0c1f06a5..f66fad9b 100644 --- a/packages/i18n/src/translations/pt-PT.ts +++ b/packages/i18n/src/translations/pt-PT.ts @@ -36,11 +36,14 @@ const translations: I18nTranslations = { 'elements.buttons.microsoft': 'Iniciar Sessão com Microsoft', 'elements.buttons.linkedin': 'Iniciar Sessão com LinkedIn', 'elements.buttons.ethereum': 'Iniciar Sessão com Ethereum', + 'elements.buttons.smsotp': 'Iniciar Sessão com SMS', 'elements.buttons.multi.option': 'Iniciar Sessão com {connection}', 'elements.buttons.social': 'Iniciar Sessão com {connection}', /* Fields */ 'elements.fields.placeholder': 'Introduza o seu {field}', + 'elements.fields.username': 'Nome de utilizador', + 'elements.fields.password': 'Palavra-passe', /* |---------------------------------------------------------------| */ /* | Widgets | */ diff --git a/packages/i18n/src/translations/si-LK.ts b/packages/i18n/src/translations/si-LK.ts index f558264c..750e6726 100644 --- a/packages/i18n/src/translations/si-LK.ts +++ b/packages/i18n/src/translations/si-LK.ts @@ -36,11 +36,14 @@ const translations: I18nTranslations = { 'elements.buttons.microsoft': 'Microsoft සමග ඉදිරියට යන්න', 'elements.buttons.linkedin': 'LinkedIn සමග ඉදිරියට යන්න', 'elements.buttons.ethereum': 'Ethereum සමග ඉදිරියට යන්න', + 'elements.buttons.smsotp': 'SMS සමග ඉදිරියට යන්න', 'elements.buttons.multi.option': '{connection} සමග ඉදිරියට යන්න', 'elements.buttons.social': '{connection} සමග ඉදිරියට යන්න', /* Fields */ 'elements.fields.placeholder': 'ඔබේ {field} ඇතුලත් කරන්න', + 'elements.fields.username': 'පරිශීලක නාමය', + 'elements.fields.password': 'මුරපදය', /* |---------------------------------------------------------------| */ /* | Widgets | */ diff --git a/packages/i18n/src/translations/ta-IN.ts b/packages/i18n/src/translations/ta-IN.ts index 12437f86..28c1dcf6 100644 --- a/packages/i18n/src/translations/ta-IN.ts +++ b/packages/i18n/src/translations/ta-IN.ts @@ -36,11 +36,14 @@ const translations: I18nTranslations = { 'elements.buttons.microsoft': 'Microsoft மூலம் தொடரவும்', 'elements.buttons.linkedin': 'LinkedIn மூலம் தொடரவும்', 'elements.buttons.ethereum': 'Ethereum மூலம் உள்நுழை', + 'elements.buttons.smsotp': 'SMS மூலம் தொடரவும்', 'elements.buttons.multi.option': '{connection} மூலம் தொடரவும்', 'elements.buttons.social': '{connection} மூலம் தொடரவும்', /* Fields */ 'elements.fields.placeholder': '{field} உள்ளிடவும்', + 'elements.fields.username': 'பயனர்பெயர்', + 'elements.fields.password': 'கடவுச்சொல்', /* |---------------------------------------------------------------| */ /* | Widgets | */ diff --git a/packages/i18n/src/translations/te-IN.ts b/packages/i18n/src/translations/te-IN.ts index 30601496..c6b81eb6 100644 --- a/packages/i18n/src/translations/te-IN.ts +++ b/packages/i18n/src/translations/te-IN.ts @@ -36,11 +36,14 @@ const translations: I18nTranslations = { 'elements.buttons.microsoft': 'Microsoft తో కొనసాగించండి', 'elements.buttons.linkedin': 'LinkedIn తో కొనసాగించండి', 'elements.buttons.ethereum': 'Ethereum తో సైన్ ఇన్ చేయండి', + 'elements.buttons.smsotp': 'SMS తో కొనసాగించండి', 'elements.buttons.multi.option': '{connection} తో కొనసాగించండి', 'elements.buttons.social': '{connection} తో కొనసాగించండి', /* Fields */ 'elements.fields.placeholder': 'మీ {field} ను నమోదు చేయండి', + 'elements.fields.username': 'వినియోగదారు పేరు', + 'elements.fields.password': 'పాస్వర్డ్', /* |---------------------------------------------------------------| */ /* | Widgets | */ diff --git a/packages/javascript/src/AsgardeoJavaScriptClient.ts b/packages/javascript/src/AsgardeoJavaScriptClient.ts index aef6e370..b98b0f35 100644 --- a/packages/javascript/src/AsgardeoJavaScriptClient.ts +++ b/packages/javascript/src/AsgardeoJavaScriptClient.ts @@ -85,6 +85,8 @@ abstract class AsgardeoJavaScriptClient implements AsgardeoClient abstract signUp(payload?: unknown): Promise | Promise; abstract getAccessToken(sessionId?: string): Promise; + + abstract clearSession(sessionId?: string): void; } export default AsgardeoJavaScriptClient; diff --git a/packages/javascript/src/__legacy__/models/client-config.ts b/packages/javascript/src/__legacy__/models/client-config.ts index 27f58bae..3e230062 100644 --- a/packages/javascript/src/__legacy__/models/client-config.ts +++ b/packages/javascript/src/__legacy__/models/client-config.ts @@ -23,7 +23,7 @@ export interface DefaultAuthClientConfig { afterSignInUrl: string; afterSignOutUrl?: string; clientHost?: string; - clientId: string; + clientId?: string; clientSecret?: string; enablePKCE?: boolean; prompt?: string; diff --git a/packages/javascript/src/api/v2/executeEmbeddedSignInFlowV2.ts b/packages/javascript/src/api/v2/executeEmbeddedSignInFlowV2.ts new file mode 100644 index 00000000..26898e66 --- /dev/null +++ b/packages/javascript/src/api/v2/executeEmbeddedSignInFlowV2.ts @@ -0,0 +1,124 @@ +/** + * 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 { + EmbeddedFlowExecuteRequestConfigV2, + EmbeddedSignInFlowResponseV2, + EmbeddedSignInFlowStatusV2, +} from '../../models/v2/embedded-signin-flow-v2'; +import AsgardeoAPIError from '../../errors/AsgardeoAPIError'; + +const executeEmbeddedSignInFlowV2 = async ({ + url, + baseUrl, + payload, + sessionDataKey, + ...requestConfig +}: EmbeddedFlowExecuteRequestConfigV2): Promise => { + if (!payload) { + throw new AsgardeoAPIError( + 'Authorization payload is required', + 'executeEmbeddedSignInFlow-ValidationError-002', + 'javascript', + 400, + 'If an authorization payload is not provided, the request cannot be constructed correctly.', + ); + } + + let endpoint: string = url ?? `${baseUrl}/flow/execute`; + + const response: Response = await fetch(endpoint, { + ...requestConfig, + method: requestConfig.method || 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + ...requestConfig.headers, + }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + const errorText = await response.text(); + + throw new AsgardeoAPIError( + `Authorization request failed: ${errorText}`, + 'executeEmbeddedSignInFlow-ResponseError-001', + 'javascript', + response.status, + response.statusText, + ); + } + + const flowResponse: EmbeddedSignInFlowResponseV2 = await response.json(); + + // IMPORTANT: Only applicable for Asgardeo V2 platform. + // Check if the flow is complete and has an assertion and sessionDataKey is provided, then call OAuth2 authorize. + if ( + flowResponse.flowStatus === EmbeddedSignInFlowStatusV2.Complete && + (flowResponse as any).assertion && + sessionDataKey + ) { + try { + const oauth2Response: Response = await fetch(`${baseUrl}/oauth2/authorize`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + ...requestConfig.headers, + }, + body: JSON.stringify({ + assertion: (flowResponse as any).assertion, + sessionDataKey, + }), + credentials: 'include', + }); + + if (!oauth2Response.ok) { + const oauth2ErrorText: string = await oauth2Response.text(); + + throw new AsgardeoAPIError( + `OAuth2 authorization failed: ${oauth2ErrorText}`, + 'executeEmbeddedSignInFlow-OAuth2Error-002', + 'javascript', + oauth2Response.status, + oauth2Response.statusText, + ); + } + + const oauth2Result = await oauth2Response.json(); + + return { + flowStatus: flowResponse.flowStatus, + redirectUrl: oauth2Result.redirect_uri, + } as any; + } catch (authError) { + throw new AsgardeoAPIError( + `OAuth2 authorization failed: ${authError instanceof Error ? authError.message : 'Unknown error'}`, + 'executeEmbeddedSignInFlow-OAuth2Error-001', + 'javascript', + 500, + 'Failed to complete OAuth2 authorization after successful embedded sign-in flow.', + ); + } + } + + return flowResponse; +}; + +export default executeEmbeddedSignInFlowV2; diff --git a/packages/javascript/src/index.ts b/packages/javascript/src/index.ts index bbed3bc6..7e0717c1 100644 --- a/packages/javascript/src/index.ts +++ b/packages/javascript/src/index.ts @@ -45,6 +45,7 @@ export {default as getOrganization, OrganizationDetails, GetOrganizationConfig} export {default as updateOrganization, createPatchOperations, UpdateOrganizationConfig} from './api/updateOrganization'; export {default as updateMeProfile, UpdateMeProfileConfig} from './api/updateMeProfile'; export {default as getBrandingPreference, GetBrandingPreferenceConfig} from './api/getBrandingPreference'; +export {default as executeEmbeddedSignInFlowV2} from './api/v2/executeEmbeddedSignInFlowV2'; export {default as ApplicationNativeAuthenticationConstants} from './constants/ApplicationNativeAuthenticationConstants'; export {default as TokenConstants} from './constants/TokenConstants'; @@ -71,6 +72,13 @@ export { EmbeddedSignInFlowAuthenticatorPromptType, EmbeddedSignInFlowAuthenticatorKnownIdPType, } from './models/embedded-signin-flow'; +export { + EmbeddedSignInFlowResponseV2, + EmbeddedSignInFlowStatusV2, + EmbeddedSignInFlowTypeV2, + EmbeddedSignInFlowInitiateRequestV2, + EmbeddedSignInFlowRequestV2, +} from './models/v2/embedded-signin-flow-v2'; export { EmbeddedFlowType, EmbeddedFlowStatus, @@ -129,6 +137,8 @@ export {default as AsgardeoJavaScriptClient} from './AsgardeoJavaScriptClient'; export {default as createTheme, DEFAULT_THEME} from './theme/createTheme'; export {ThemeColors, ThemeConfig, Theme, ThemeMode, ThemeDetection} from './theme/types'; +export {default as arrayBufferToBase64url} from './utils/arrayBufferToBase64url'; +export {default as base64urlToArrayBuffer} from './utils/base64urlToArrayBuffer'; export {default as bem} from './utils/bem'; export {default as formatDate} from './utils/formatDate'; export {default as processUsername} from './utils/processUsername'; diff --git a/packages/javascript/src/models/client.ts b/packages/javascript/src/models/client.ts index f52ada30..67529089 100644 --- a/packages/javascript/src/models/client.ts +++ b/packages/javascript/src/models/client.ts @@ -47,6 +47,11 @@ export interface AsgardeoClient { */ getMyOrganizations(options?: any, sessionId?: string): Promise; + /** + * Gets all organizations available to the user. + * @param options - Optional parameters for the request. + * @param sessionId - Optional session ID to be used for the request. + */ getAllOrganizations(options?: any, sessionId?: string): Promise; /** @@ -63,6 +68,10 @@ export interface AsgardeoClient { */ switchOrganization(organization: Organization, sessionId?: string): Promise; + /** + * Gets the client configuration. + * @returns The client configuration. + */ getConfiguration(): T; /** @@ -72,6 +81,11 @@ export interface AsgardeoClient { */ exchangeToken(config: TokenExchangeRequestConfig, sessionId?: string): Promise; + /** + * Updates the user profile with the provided payload. + * @param payload - The new user profile data. + * @param userId - Optional user ID to specify which user's profile to update. + */ updateUserProfile(payload: any, userId?: string): Promise; /** @@ -211,4 +225,10 @@ export interface AsgardeoClient { * @returns A promise that resolves to the access token string. */ getAccessToken(sessionId?: string): Promise; + + /** + * Clears the session for the specified session ID. + * @param sessionId - Optional session ID to clear the session for. + */ + clearSession(sessionId?: string): void; } diff --git a/packages/javascript/src/models/config.ts b/packages/javascript/src/models/config.ts index 57f3c084..297a6d4d 100644 --- a/packages/javascript/src/models/config.ts +++ b/packages/javascript/src/models/config.ts @@ -19,6 +19,7 @@ import {I18nBundle} from '@asgardeo/i18n'; import {RecursivePartial} from './utility-types'; import {ThemeConfig, ThemeMode} from '../theme/types'; +import {Platform} from './platforms'; /** * Interface representing the additional parameters to be sent in the sign-in request. @@ -112,6 +113,13 @@ export interface BaseConfig extends WithPreferences { */ clientSecret?: string | undefined; + /** + * Optional platform where the application is running. + * This helps the SDK to optimize its behavior based on the platform. + * If not provided, the SDK will attempt to auto-detect the platform. + */ + platform?: keyof typeof Platform; + /** * The scopes to request during authentication. * Accepts either a space-separated string or an array of strings. diff --git a/packages/javascript/src/models/embedded-flow.ts b/packages/javascript/src/models/embedded-flow.ts index 68846b19..5fd7c125 100644 --- a/packages/javascript/src/models/embedded-flow.ts +++ b/packages/javascript/src/models/embedded-flow.ts @@ -16,7 +16,10 @@ * under the License. */ +import {Platform} from './platforms'; + export enum EmbeddedFlowType { + Authentication = 'AUTHENTICATION', Registration = 'REGISTRATION', } diff --git a/packages/javascript/src/models/platforms.ts b/packages/javascript/src/models/platforms.ts index 370fd799..a2f1b24b 100644 --- a/packages/javascript/src/models/platforms.ts +++ b/packages/javascript/src/models/platforms.ts @@ -31,6 +31,8 @@ export enum Platform { Asgardeo = 'ASGARDEO', /** WSO2 Identity Server (on-prem or custom domains) */ IdentityServer = 'IDENTITY_SERVER', + /** @experimental WSO2 Asgardeo V2 */ + AsgardeoV2 = 'AsgardeoV2', /** Unknown or unsupported platform */ Unknown = 'UNKNOWN', } diff --git a/packages/javascript/src/models/v2/embedded-signin-flow-v2.ts b/packages/javascript/src/models/v2/embedded-signin-flow-v2.ts new file mode 100644 index 00000000..03e2ba2c --- /dev/null +++ b/packages/javascript/src/models/v2/embedded-signin-flow-v2.ts @@ -0,0 +1,86 @@ +/** + * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import {EmbeddedFlowExecuteRequestConfig, EmbeddedFlowResponseType, EmbeddedFlowType} from '../embedded-flow'; + +export enum EmbeddedSignInFlowStatusV2 { + Complete = 'COMPLETE', + Incomplete = 'INCOMPLETE', + Error = 'ERROR', +} + +export enum EmbeddedSignInFlowTypeV2 { + Redirection = 'REDIRECTION', + View = 'VIEW', +} + +/** + * Response structure for the new Asgardeo V2 embedded sign-in flow. + * @experimental + */ +export interface EmbeddedSignInFlowResponseV2 { + flowId: string; + flowStatus: EmbeddedSignInFlowStatusV2; + type: EmbeddedSignInFlowTypeV2; + data: { + actions?: { + type: EmbeddedFlowResponseType; + id: string; + }[]; + inputs?: { + name: string; + type: string; + required: boolean; + }[]; + }; +} + +/** + * Response structure for the new Asgardeo V2 embedded sign-in flow when the flow is complete. + * @experimental + */ +export interface EmbeddedSignInFlowCompleteResponse { + redirect_uri: string; +} + +/** + * Request payload for initiating the new Asgardeo V2 embedded sign-in flow. + * @experimental + */ +export type EmbeddedSignInFlowInitiateRequestV2 = { + applicationId: string; + flowType: EmbeddedFlowType; +}; + +/** + * Request payload for executing steps in the new Asgardeo V2 embedded sign-in flow. + * @experimental + */ +export interface EmbeddedSignInFlowRequestV2 extends Partial { + flowId?: string; + actionId?: string; + inputs?: Record; +} + +/** + * Request config for executing the new Asgardeo V2 embedded sign-in flow. + * @experimental + */ +export interface EmbeddedFlowExecuteRequestConfigV2 extends EmbeddedFlowExecuteRequestConfig { + sessionDataKey?: string; +} diff --git a/packages/javascript/src/utils/arrayBufferToBase64url.ts b/packages/javascript/src/utils/arrayBufferToBase64url.ts new file mode 100644 index 00000000..e08d55be --- /dev/null +++ b/packages/javascript/src/utils/arrayBufferToBase64url.ts @@ -0,0 +1,59 @@ +/** + * 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. + */ + +/** + * Converts an ArrayBuffer to a base64url encoded string. + * + * Base64url encoding is a URL-safe variant of base64 encoding that: + * - Replaces '+' with '-' + * - Replaces '/' with '_' + * - Removes padding '=' characters + * + * This encoding is commonly used in JWT tokens, OAuth2 PKCE challenges, + * and other web standards where the encoded data needs to be safely + * transmitted in URLs or HTTP headers. + * + * @param buffer - The ArrayBuffer to convert to base64url string + * @returns The base64url encoded string representation of the input buffer + * + * @example + * ```typescript + * const buffer = new TextEncoder().encode('Hello World'); + * const encoded = arrayBufferToBase64url(buffer); + * console.log(encoded); // "SGVsbG8gV29ybGQ" + * ``` + * + * @example + * ```typescript + * // Converting crypto random bytes for PKCE challenge + * const randomBytes = crypto.getRandomValues(new Uint8Array(32)); + * const codeVerifier = arrayBufferToBase64url(randomBytes.buffer); + * ``` + */ +const arrayBufferToBase64url = (buffer: ArrayBuffer): string => { + const bytes: Uint8Array = new Uint8Array(buffer); + let binary: string = ''; + + for (let i = 0; i < bytes.byteLength; i++) { + binary += String.fromCharCode(bytes[i]); + } + + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); +}; + +export default arrayBufferToBase64url; diff --git a/packages/javascript/src/utils/base64urlToArrayBuffer.ts b/packages/javascript/src/utils/base64urlToArrayBuffer.ts new file mode 100644 index 00000000..1a3d8452 --- /dev/null +++ b/packages/javascript/src/utils/base64urlToArrayBuffer.ts @@ -0,0 +1,69 @@ +/** + * 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. + */ + +/** + * Converts a base64url encoded string back to an ArrayBuffer. + * + * This function performs the inverse operation of base64url encoding by: + * - Replacing URL-safe characters: '-' becomes '+', '_' becomes '/' + * - Adding back padding '=' characters that were removed during base64url encoding + * - Decoding the resulting base64 string to binary data + * - Converting the binary data to an ArrayBuffer + * + * This is commonly used for decoding JWT tokens, OAuth2 PKCE code verifiers, + * and other cryptographic data that was encoded using base64url format. + * + * @param base64url - The base64url encoded string to decode + * @returns The ArrayBuffer containing the decoded binary data + * + * @throws {DOMException} Throws an error if the input string is not valid base64url + * + * @example + * ```typescript + * const encoded = 'SGVsbG8gV29ybGQ'; + * const buffer = base64urlToArrayBuffer(encoded); + * const text = new TextDecoder().decode(buffer); + * console.log(text); // "Hello World" + * ``` + * + * @example + * ```typescript + * // Decoding a JWT payload + * const jwtPayload = 'eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ'; + * const payloadBuffer = base64urlToArrayBuffer(jwtPayload); + * const payloadJson = new TextDecoder().decode(payloadBuffer); + * const payload = JSON.parse(payloadJson); + * ``` + * + * @see {@link arrayBufferToBase64url} - The inverse function for encoding ArrayBuffer to base64url + */ +const base64urlToArrayBuffer = (base64url: string): ArrayBuffer => { + const padding: string = '='.repeat((4 - (base64url.length % 4)) % 4); + const base64: string = base64url.replace(/-/g, '+').replace(/_/g, '/') + padding; + + const binaryString: string = atob(base64); + const bytes: Uint8Array = new Uint8Array(binaryString.length); + + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + + return bytes.buffer; +}; + +export default base64urlToArrayBuffer; diff --git a/packages/nextjs/src/AsgardeoNextClient.ts b/packages/nextjs/src/AsgardeoNextClient.ts index 75675bce..03c76061 100644 --- a/packages/nextjs/src/AsgardeoNextClient.ts +++ b/packages/nextjs/src/AsgardeoNextClient.ts @@ -567,6 +567,15 @@ class AsgardeoNextClient exte await this.ensureInitialized(); return this.asgardeo.getStorageManager(); } + + public async clearSession(): Promise { + throw new AsgardeoRuntimeError( + 'Not implemented', + 'AsgardeoNextClient-clearSession-NotImplementedError-001', + 'nextjs', + 'The clearSession method is not implemented in the Next.js client.', + ); + } } export default AsgardeoNextClient; diff --git a/packages/react/src/AsgardeoReactClient.ts b/packages/react/src/AsgardeoReactClient.ts index 9108b0e6..db773b65 100644 --- a/packages/react/src/AsgardeoReactClient.ts +++ b/packages/react/src/AsgardeoReactClient.ts @@ -32,6 +32,7 @@ import { executeEmbeddedSignUpFlow, EmbeddedSignInFlowHandleRequestPayload, executeEmbeddedSignInFlow, + executeEmbeddedSignInFlowV2, Organization, IdToken, EmbeddedFlowExecuteRequestConfig, @@ -46,6 +47,7 @@ import { getRedirectBasedSignUpUrl, Config, TokenExchangeRequestConfig, + Platform, } from '@asgardeo/browser'; import AuthAPI from './__temp__/api'; import getMeOrganizations from './api/getMeOrganizations'; @@ -230,14 +232,23 @@ class AsgardeoReactClient e } override async getCurrentOrganization(): Promise { - return this.withLoading(async () => { - const idToken: IdToken = await this.getDecodedIdToken(); - return { - orgHandle: idToken?.org_handle, - name: idToken?.org_name, - id: idToken?.org_id, - }; - }); + try { + return this.withLoading(async () => { + const idToken: IdToken = await this.getDecodedIdToken(); + return { + orgHandle: idToken?.org_handle, + name: idToken?.org_name, + id: idToken?.org_id, + }; + }); + } catch (error) { + throw new AsgardeoRuntimeError( + `Failed to fetch the current organization: ${error instanceof Error ? error.message : String(error)}`, + 'AsgardeoReactClient-getCurrentOrganization-RuntimeError-001', + 'react', + 'An error occurred while fetching the current organization of the signed-in user.', + ); + } } override async switchOrganization(organization: Organization, sessionId?: string): Promise { @@ -321,6 +332,19 @@ class AsgardeoReactClient e const arg1 = args[0]; const arg2 = args[1]; + const config: AsgardeoReactConfig = (await this.asgardeo.getConfigData()) as AsgardeoReactConfig; + + if (config.platform === Platform.AsgardeoV2) { + const sessionDataKey: string = new URL(window.location.href).searchParams.get('sessionDataKey'); + + return executeEmbeddedSignInFlowV2({ + payload: arg1 as EmbeddedSignInFlowHandleRequestPayload, + url: arg2?.url, + baseUrl: config?.baseUrl, + sessionDataKey, + }); + } + if (typeof arg1 === 'object' && 'flowId' in arg1 && typeof arg2 === 'object' && 'url' in arg2) { return executeEmbeddedSignInFlow({ payload: arg1, @@ -349,6 +373,17 @@ class AsgardeoReactClient e throw new Error('The second argument must be a function.'); } + const config: AsgardeoReactConfig = (await this.asgardeo.getConfigData()) as AsgardeoReactConfig; + + // TEMPORARY: Handle Asgardeo V2 sign-out differently until the sign-out flow is implemented in the platform. + // Tracker: https://github.com/asgardeo/javascript/issues/212#issuecomment-3435713699 + if (config.platform === Platform.AsgardeoV2) { + this.asgardeo.clearSession(); + args[1]?.(config.afterSignOutUrl || ''); + + return Promise.resolve(config.afterSignOutUrl || ''); + } + const response: boolean = await this.asgardeo.signOut(args[1]); return Promise.resolve(String(response)); @@ -385,6 +420,10 @@ class AsgardeoReactClient e return this.asgardeo.getAccessToken(sessionId); }); } + + override clearSession(sessionId?: string): void { + this.asgardeo.clearSession(sessionId); + } } export default AsgardeoReactClient; diff --git a/packages/react/src/__temp__/api.ts b/packages/react/src/__temp__/api.ts index f9dc3834..35a21908 100644 --- a/packages/react/src/__temp__/api.ts +++ b/packages/react/src/__temp__/api.ts @@ -439,6 +439,17 @@ class AuthAPI { }) .catch(error => Promise.reject(error)); } + + /** + * This method clears the session for the specified session ID. + * + * @param sessionId - Optional session ID to clear the session for. + * + * @return void + */ + public clearSession(sessionId?: string): void { + this._client.clearSession(sessionId); + } } AuthAPI.DEFAULT_STATE = { diff --git a/packages/react/src/components/presentation/SignUp/options/CheckboxInput.tsx b/packages/react/src/components/adapters/CheckboxInput.tsx similarity index 91% rename from packages/react/src/components/presentation/SignUp/options/CheckboxInput.tsx rename to packages/react/src/components/adapters/CheckboxInput.tsx index 5eb4164d..c7ea51a9 100644 --- a/packages/react/src/components/presentation/SignUp/options/CheckboxInput.tsx +++ b/packages/react/src/components/adapters/CheckboxInput.tsx @@ -18,8 +18,8 @@ import {FieldType} from '@asgardeo/browser'; import {FC} from 'react'; -import {BaseSignUpOptionProps} from './SignUpOptionFactory'; -import {createField} from '../../../factories/FieldFactory'; +import {BaseSignUpOptionProps} from '../presentation/SignUp/SignUpOptionFactory'; +import {createField} from '../factories/FieldFactory'; /** * Checkbox input component for sign-up forms. diff --git a/packages/react/src/components/presentation/SignUp/options/DateInput.tsx b/packages/react/src/components/adapters/DateInput.tsx similarity index 91% rename from packages/react/src/components/presentation/SignUp/options/DateInput.tsx rename to packages/react/src/components/adapters/DateInput.tsx index 781415ce..26550dbc 100644 --- a/packages/react/src/components/presentation/SignUp/options/DateInput.tsx +++ b/packages/react/src/components/adapters/DateInput.tsx @@ -18,8 +18,8 @@ import {FieldType} from '@asgardeo/browser'; import {FC} from 'react'; -import {BaseSignUpOptionProps} from './SignUpOptionFactory'; -import {createField} from '../../../factories/FieldFactory'; +import {BaseSignUpOptionProps} from '../presentation/SignUp/SignUpOptionFactory'; +import {createField} from '../factories/FieldFactory'; /** * Date input component for sign-up forms. diff --git a/packages/react/src/components/presentation/SignUp/options/DividerComponent.tsx b/packages/react/src/components/adapters/DividerComponent.tsx similarity index 86% rename from packages/react/src/components/presentation/SignUp/options/DividerComponent.tsx rename to packages/react/src/components/adapters/DividerComponent.tsx index a02a5acb..60b8afbb 100644 --- a/packages/react/src/components/presentation/SignUp/options/DividerComponent.tsx +++ b/packages/react/src/components/adapters/DividerComponent.tsx @@ -17,9 +17,9 @@ */ import {FC} from 'react'; -import {BaseSignUpOptionProps} from './SignUpOptionFactory'; -import Divider from '../../../primitives/Divider/Divider'; -import useTheme from '../../../../contexts/Theme/useTheme'; +import {BaseSignUpOptionProps} from '../presentation/SignUp/SignUpOptionFactory'; +import Divider from '../primitives/Divider/Divider'; +import useTheme from '../../contexts/Theme/useTheme'; /** * Divider component for sign-up forms. diff --git a/packages/react/src/components/presentation/SignUp/options/EmailInput.tsx b/packages/react/src/components/adapters/EmailInput.tsx similarity index 91% rename from packages/react/src/components/presentation/SignUp/options/EmailInput.tsx rename to packages/react/src/components/adapters/EmailInput.tsx index 3f49117a..1df1f367 100644 --- a/packages/react/src/components/presentation/SignUp/options/EmailInput.tsx +++ b/packages/react/src/components/adapters/EmailInput.tsx @@ -18,8 +18,8 @@ import {FieldType} from '@asgardeo/browser'; import {FC} from 'react'; -import {BaseSignUpOptionProps} from './SignUpOptionFactory'; -import {createField} from '../../../factories/FieldFactory'; +import {BaseSignUpOptionProps} from '../presentation/SignUp/SignUpOptionFactory'; +import {createField} from '../factories/FieldFactory'; /** * Email input component for sign-up forms. diff --git a/packages/react/src/components/presentation/options/FacebookButton.tsx b/packages/react/src/components/adapters/FacebookButton.tsx similarity index 81% rename from packages/react/src/components/presentation/options/FacebookButton.tsx rename to packages/react/src/components/adapters/FacebookButton.tsx index f6742d1d..fb02d01c 100644 --- a/packages/react/src/components/presentation/options/FacebookButton.tsx +++ b/packages/react/src/components/adapters/FacebookButton.tsx @@ -17,15 +17,22 @@ */ import {FC, HTMLAttributes} from 'react'; -import Button from '../../primitives/Button/Button'; -import {BaseSignInOptionProps} from '../SignIn/options/SignInOptionFactory'; -import useTranslation from '../../../hooks/useTranslation'; +import {WithPreferences} from '@asgardeo/browser'; +import Button from '../primitives/Button/Button'; +import useTranslation from '../../hooks/useTranslation'; + +export interface FacebookButtonProps extends WithPreferences { + /** + * Whether the component is in loading state. + */ + isLoading?: boolean; +} /** * Facebook Sign-In Button Component. * Handles authentication with Facebook identity provider. */ -const FacebookButton: FC> = ({ +const FacebookButton: FC> = ({ isLoading, preferences, children, diff --git a/packages/react/src/components/presentation/SignUp/options/FormContainer.tsx b/packages/react/src/components/adapters/FormContainer.tsx similarity index 94% rename from packages/react/src/components/presentation/SignUp/options/FormContainer.tsx rename to packages/react/src/components/adapters/FormContainer.tsx index 453a8fe8..1c6eb9c7 100644 --- a/packages/react/src/components/presentation/SignUp/options/FormContainer.tsx +++ b/packages/react/src/components/adapters/FormContainer.tsx @@ -17,7 +17,7 @@ */ import {FC} from 'react'; -import {createSignUpComponent, BaseSignUpOptionProps} from './SignUpOptionFactory'; +import {createSignUpComponent, BaseSignUpOptionProps} from '../presentation/SignUp/SignUpOptionFactory'; /** * Form container component that renders child components. diff --git a/packages/react/src/components/presentation/options/GitHubButton.tsx b/packages/react/src/components/adapters/GitHubButton.tsx similarity index 85% rename from packages/react/src/components/presentation/options/GitHubButton.tsx rename to packages/react/src/components/adapters/GitHubButton.tsx index ba200da2..ef8875af 100644 --- a/packages/react/src/components/presentation/options/GitHubButton.tsx +++ b/packages/react/src/components/adapters/GitHubButton.tsx @@ -17,15 +17,22 @@ */ import {FC, HTMLAttributes} from 'react'; -import Button from '../../primitives/Button/Button'; -import {BaseSignInOptionProps} from '../SignIn/options/SignInOptionFactory'; -import useTranslation from '../../../hooks/useTranslation'; +import {WithPreferences} from '@asgardeo/browser'; +import Button from '../primitives/Button/Button'; +import useTranslation from '../../hooks/useTranslation'; + +export interface GithubButtonProps extends WithPreferences { + /** + * Whether the component is in loading state. + */ + isLoading?: boolean; +} /** * GitHub Sign-In Button Component. * Handles authentication with GitHub identity provider. */ -const GitHubButton: FC> = ({ +const GitHubButton: FC> = ({ isLoading, preferences, children, diff --git a/packages/react/src/components/presentation/options/GoogleButton.tsx b/packages/react/src/components/adapters/GoogleButton.tsx similarity index 85% rename from packages/react/src/components/presentation/options/GoogleButton.tsx rename to packages/react/src/components/adapters/GoogleButton.tsx index 7ad736e4..bb1e3336 100644 --- a/packages/react/src/components/presentation/options/GoogleButton.tsx +++ b/packages/react/src/components/adapters/GoogleButton.tsx @@ -17,15 +17,22 @@ */ import {FC, HTMLAttributes} from 'react'; -import {BaseSignInOptionProps} from '../SignIn/options/SignInOptionFactory'; -import useTranslation from '../../../hooks/useTranslation'; -import Button from '../../primitives/Button/Button'; +import {WithPreferences} from '@asgardeo/browser'; +import useTranslation from '../../hooks/useTranslation'; +import Button from '../primitives/Button/Button'; + +export interface GoogleButtonProps extends WithPreferences { + /** + * Whether the component is in loading state. + */ + isLoading?: boolean; +} /** * Google Sign-In Button Component. * Handles authentication with Google identity provider. */ -const GoogleButton: FC> = ({ +const GoogleButton: FC> = ({ isLoading, preferences, children, diff --git a/packages/react/src/components/presentation/SignUp/options/ImageComponent.tsx b/packages/react/src/components/adapters/ImageComponent.tsx similarity index 92% rename from packages/react/src/components/presentation/SignUp/options/ImageComponent.tsx rename to packages/react/src/components/adapters/ImageComponent.tsx index ce753778..7df86377 100644 --- a/packages/react/src/components/presentation/SignUp/options/ImageComponent.tsx +++ b/packages/react/src/components/adapters/ImageComponent.tsx @@ -17,8 +17,8 @@ */ import {FC} from 'react'; -import {BaseSignUpOptionProps} from './SignUpOptionFactory'; -import useTheme from '../../../../contexts/Theme/useTheme'; +import {BaseSignUpOptionProps} from '../presentation/SignUp/SignUpOptionFactory'; +import useTheme from '../../contexts/Theme/useTheme'; /** * Image component for sign-up forms. diff --git a/packages/react/src/components/presentation/options/LinkedInButton.tsx b/packages/react/src/components/adapters/LinkedInButton.tsx similarity index 82% rename from packages/react/src/components/presentation/options/LinkedInButton.tsx rename to packages/react/src/components/adapters/LinkedInButton.tsx index 1eb0a90b..02669129 100644 --- a/packages/react/src/components/presentation/options/LinkedInButton.tsx +++ b/packages/react/src/components/adapters/LinkedInButton.tsx @@ -17,15 +17,22 @@ */ import {FC, HTMLAttributes} from 'react'; -import Button from '../../primitives/Button/Button'; -import {BaseSignInOptionProps} from '../SignIn/options/SignInOptionFactory'; -import useTranslation from '../../../hooks/useTranslation'; +import Button from '../primitives/Button/Button'; +import {WithPreferences} from '@asgardeo/browser'; +import useTranslation from '../../hooks/useTranslation'; + +export interface LinkedInButtonProps extends WithPreferences { + /** + * Whether the component is in loading state. + */ + isLoading?: boolean; +} /** * LinkedIn Sign-In Button Component. * Handles authentication with LinkedIn identity provider. */ -const LinkedInButton: FC> = ({ +const LinkedInButton: FC> = ({ isLoading, preferences, children, diff --git a/packages/react/src/components/presentation/options/MicrosoftButton.tsx b/packages/react/src/components/adapters/MicrosoftButton.tsx similarity index 79% rename from packages/react/src/components/presentation/options/MicrosoftButton.tsx rename to packages/react/src/components/adapters/MicrosoftButton.tsx index dfd8c10f..490e4517 100644 --- a/packages/react/src/components/presentation/options/MicrosoftButton.tsx +++ b/packages/react/src/components/adapters/MicrosoftButton.tsx @@ -17,15 +17,22 @@ */ import {FC, HTMLAttributes} from 'react'; -import Button from '../../primitives/Button/Button'; -import {BaseSignInOptionProps} from '../SignIn/options/SignInOptionFactory'; -import useTranslation from '../../../hooks/useTranslation'; +import Button from '../primitives/Button/Button'; +import {WithPreferences} from '@asgardeo/browser'; +import useTranslation from '../../hooks/useTranslation'; + +export interface MicrosoftButtonProps extends WithPreferences { + /** + * Whether the component is in loading state. + */ + isLoading?: boolean; +} /** * Microsoft Sign-In Button Component. * Handles authentication with Microsoft identity provider. */ -const MicrosoftButton: FC> = ({ +const MicrosoftButton: FC> = ({ isLoading, preferences, children, diff --git a/packages/react/src/components/presentation/SignUp/options/NumberInput.tsx b/packages/react/src/components/adapters/NumberInput.tsx similarity index 91% rename from packages/react/src/components/presentation/SignUp/options/NumberInput.tsx rename to packages/react/src/components/adapters/NumberInput.tsx index f03ddbaa..9bb3cced 100644 --- a/packages/react/src/components/presentation/SignUp/options/NumberInput.tsx +++ b/packages/react/src/components/adapters/NumberInput.tsx @@ -18,8 +18,8 @@ import {FieldType} from '@asgardeo/browser'; import {FC} from 'react'; -import {BaseSignUpOptionProps} from './SignUpOptionFactory'; -import {createField} from '../../../factories/FieldFactory'; +import {BaseSignUpOptionProps} from '../presentation/SignUp/SignUpOptionFactory'; +import {createField} from '../factories/FieldFactory'; /** * Number input component for sign-up forms. diff --git a/packages/react/src/components/presentation/SignUp/options/PasswordInput.tsx b/packages/react/src/components/adapters/PasswordInput.tsx similarity index 96% rename from packages/react/src/components/presentation/SignUp/options/PasswordInput.tsx rename to packages/react/src/components/adapters/PasswordInput.tsx index ee4424d2..2d154f25 100644 --- a/packages/react/src/components/presentation/SignUp/options/PasswordInput.tsx +++ b/packages/react/src/components/adapters/PasswordInput.tsx @@ -18,8 +18,8 @@ import {FieldType} from '@asgardeo/browser'; import {FC} from 'react'; -import {BaseSignUpOptionProps} from './SignUpOptionFactory'; -import {createField} from '../../../factories/FieldFactory'; +import {BaseSignUpOptionProps} from '../presentation/SignUp/SignUpOptionFactory'; +import {createField} from '../factories/FieldFactory'; /** * Password input component for sign-up forms. diff --git a/packages/react/src/components/presentation/options/SignInWithEthereumButton.tsx b/packages/react/src/components/adapters/SignInWithEthereumButton.tsx similarity index 78% rename from packages/react/src/components/presentation/options/SignInWithEthereumButton.tsx rename to packages/react/src/components/adapters/SignInWithEthereumButton.tsx index 34d1d5c8..0007c402 100644 --- a/packages/react/src/components/presentation/options/SignInWithEthereumButton.tsx +++ b/packages/react/src/components/adapters/SignInWithEthereumButton.tsx @@ -17,15 +17,22 @@ */ import {FC, HTMLAttributes} from 'react'; -import Button from '../../primitives/Button/Button'; -import useTranslation from '../../../hooks/useTranslation'; -import {BaseSignInOptionProps} from '../SignIn/options/SignInOptionFactory'; +import Button from '../primitives/Button/Button'; +import useTranslation from '../../hooks/useTranslation'; +import {WithPreferences} from '@asgardeo/browser'; + +export interface SignInWithEthereumButtonProps extends WithPreferences { + /** + * Whether the component is in loading state. + */ + isLoading?: boolean; +} /** * Sign In With Ethereum Button Component. * Handles authentication with Sign In With Ethereum identity provider. */ -const SignInWithEthereumButton: FC> = ({ +const SignInWithEthereumButton: FC> = ({ isLoading, preferences, children, diff --git a/packages/react/src/components/adapters/SmsOtpButton.tsx b/packages/react/src/components/adapters/SmsOtpButton.tsx new file mode 100644 index 00000000..bbcdfd24 --- /dev/null +++ b/packages/react/src/components/adapters/SmsOtpButton.tsx @@ -0,0 +1,60 @@ +/** + * 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} from 'react'; +import Button from '../primitives/Button/Button'; +import {WithPreferences} from '@asgardeo/browser'; +import useTranslation from '../../hooks/useTranslation'; + +export interface SmsOtpButtonProps extends WithPreferences { + /** + * Whether the component is in loading state. + */ + isLoading?: boolean; +} + +/** + * SMS OTP Sign-In Button Component. + * Handles authentication with SMS OTP. + */ +const SmsOtpButton: FC> = ({isLoading, preferences, children, ...rest}) => { + const {t} = useTranslation(preferences?.i18n); + + return ( + + ); +}; + +export default SmsOtpButton; diff --git a/packages/react/src/components/presentation/SignUp/options/SocialButton.tsx b/packages/react/src/components/adapters/SocialButton.tsx similarity index 92% rename from packages/react/src/components/presentation/SignUp/options/SocialButton.tsx rename to packages/react/src/components/adapters/SocialButton.tsx index 57f09240..ec57ed46 100644 --- a/packages/react/src/components/presentation/SignUp/options/SocialButton.tsx +++ b/packages/react/src/components/adapters/SocialButton.tsx @@ -18,8 +18,8 @@ import {FC} from 'react'; -import {BaseSignUpOptionProps} from './SignUpOptionFactory'; -import Button from '../../../primitives/Button/Button'; +import {BaseSignUpOptionProps} from '../presentation/SignUp/SignUpOptionFactory'; +import Button from '../primitives/Button/Button'; /** * Social button component for sign-up forms. diff --git a/packages/react/src/components/presentation/SignUp/options/SubmitButton.tsx b/packages/react/src/components/adapters/SubmitButton.tsx similarity index 92% rename from packages/react/src/components/presentation/SignUp/options/SubmitButton.tsx rename to packages/react/src/components/adapters/SubmitButton.tsx index 610d0598..b6643b31 100644 --- a/packages/react/src/components/presentation/SignUp/options/SubmitButton.tsx +++ b/packages/react/src/components/adapters/SubmitButton.tsx @@ -18,9 +18,9 @@ import {FC} from 'react'; -import {BaseSignUpOptionProps} from './SignUpOptionFactory'; -import Button from '../../../primitives/Button/Button'; -import Spinner from '../../../primitives/Spinner/Spinner'; +import {BaseSignUpOptionProps} from '../presentation/SignUp/SignUpOptionFactory'; +import Button from '../primitives/Button/Button'; +import Spinner from '../primitives/Spinner/Spinner'; /** * Button component for sign-up forms that handles all button variants. diff --git a/packages/react/src/components/presentation/SignUp/options/TelephoneInput.tsx b/packages/react/src/components/adapters/TelephoneInput.tsx similarity index 91% rename from packages/react/src/components/presentation/SignUp/options/TelephoneInput.tsx rename to packages/react/src/components/adapters/TelephoneInput.tsx index 60424111..300059f4 100644 --- a/packages/react/src/components/presentation/SignUp/options/TelephoneInput.tsx +++ b/packages/react/src/components/adapters/TelephoneInput.tsx @@ -17,8 +17,8 @@ */ import {FC} from 'react'; -import {BaseSignUpOptionProps} from './SignUpOptionFactory'; -import TextField from '../../../primitives/TextField/TextField'; +import {BaseSignUpOptionProps} from '../presentation/SignUp/SignUpOptionFactory'; +import TextField from '../primitives/TextField/TextField'; /** * Telephone input component for sign-up forms. diff --git a/packages/react/src/components/presentation/SignUp/options/TextInput.tsx b/packages/react/src/components/adapters/TextInput.tsx similarity index 91% rename from packages/react/src/components/presentation/SignUp/options/TextInput.tsx rename to packages/react/src/components/adapters/TextInput.tsx index 43e5887d..7c6d0969 100644 --- a/packages/react/src/components/presentation/SignUp/options/TextInput.tsx +++ b/packages/react/src/components/adapters/TextInput.tsx @@ -18,8 +18,8 @@ import {FieldType} from '@asgardeo/browser'; import {FC} from 'react'; -import {BaseSignUpOptionProps} from './SignUpOptionFactory'; -import {createField} from '../../../factories/FieldFactory'; +import {BaseSignUpOptionProps} from '../presentation/SignUp/SignUpOptionFactory'; +import {createField} from '../factories/FieldFactory'; /** * Text input component for sign-up forms. diff --git a/packages/react/src/components/presentation/SignUp/options/Typography.tsx b/packages/react/src/components/adapters/Typography.tsx similarity index 91% rename from packages/react/src/components/presentation/SignUp/options/Typography.tsx rename to packages/react/src/components/adapters/Typography.tsx index 8bec84fd..4752dde4 100644 --- a/packages/react/src/components/presentation/SignUp/options/Typography.tsx +++ b/packages/react/src/components/adapters/Typography.tsx @@ -17,9 +17,9 @@ */ import {FC} from 'react'; -import {BaseSignUpOptionProps} from './SignUpOptionFactory'; -import Typography from '../../../primitives/Typography/Typography'; -import useTheme from '../../../../contexts/Theme/useTheme'; +import {BaseSignUpOptionProps} from '../presentation/SignUp/SignUpOptionFactory'; +import Typography from '../primitives/Typography/Typography'; +import useTheme from '../../contexts/Theme/useTheme'; /** * Typography component for sign-up forms (titles, descriptions, etc.). diff --git a/packages/react/src/components/control/Loading/Loading.tsx b/packages/react/src/components/control/Loading.tsx similarity index 96% rename from packages/react/src/components/control/Loading/Loading.tsx rename to packages/react/src/components/control/Loading.tsx index 1aed3c1e..13db912d 100644 --- a/packages/react/src/components/control/Loading/Loading.tsx +++ b/packages/react/src/components/control/Loading.tsx @@ -17,7 +17,7 @@ */ import {FC, PropsWithChildren, ReactNode} from 'react'; -import useAsgardeo from '../../../contexts/Asgardeo/useAsgardeo'; +import useAsgardeo from '../../contexts/Asgardeo/useAsgardeo'; /** * Props for the Loading component. diff --git a/packages/react/src/components/control/SignedIn/SignedIn.tsx b/packages/react/src/components/control/SignedIn.tsx similarity index 96% rename from packages/react/src/components/control/SignedIn/SignedIn.tsx rename to packages/react/src/components/control/SignedIn.tsx index 2a656712..76c9502c 100644 --- a/packages/react/src/components/control/SignedIn/SignedIn.tsx +++ b/packages/react/src/components/control/SignedIn.tsx @@ -17,7 +17,7 @@ */ import {FC, PropsWithChildren, ReactNode} from 'react'; -import useAsgardeo from '../../../contexts/Asgardeo/useAsgardeo'; +import useAsgardeo from '../../contexts/Asgardeo/useAsgardeo'; /** * Props for the SignedIn component. diff --git a/packages/react/src/components/control/SignedOut/SignedOut.tsx b/packages/react/src/components/control/SignedOut.tsx similarity index 96% rename from packages/react/src/components/control/SignedOut/SignedOut.tsx rename to packages/react/src/components/control/SignedOut.tsx index cfad0b76..af89c52b 100644 --- a/packages/react/src/components/control/SignedOut/SignedOut.tsx +++ b/packages/react/src/components/control/SignedOut.tsx @@ -17,7 +17,7 @@ */ import {FC, PropsWithChildren, ReactNode} from 'react'; -import useAsgardeo from '../../../contexts/Asgardeo/useAsgardeo'; +import useAsgardeo from '../../contexts/Asgardeo/useAsgardeo'; /** * Props for the SignedOut component. diff --git a/packages/react/src/components/presentation/SignIn/SignIn.tsx b/packages/react/src/components/presentation/SignIn/SignIn.tsx index da9c5b80..dfc37218 100644 --- a/packages/react/src/components/presentation/SignIn/SignIn.tsx +++ b/packages/react/src/components/presentation/SignIn/SignIn.tsx @@ -20,17 +20,23 @@ import { EmbeddedSignInFlowInitiateResponse, EmbeddedSignInFlowHandleResponse, EmbeddedSignInFlowHandleRequestPayload, + Platform, } from '@asgardeo/browser'; -import {FC} from 'react'; -import BaseSignIn, {BaseSignInProps} from './BaseSignIn'; +import {FC, ReactElement} from 'react'; +import BaseSignIn, {BaseSignInProps} from './non-component-driven/BaseSignIn'; +import ComponentDrivenSignIn, {SignInRenderProps} from './component-driven/SignIn'; import useAsgardeo from '../../../contexts/Asgardeo/useAsgardeo'; -import {CardProps} from '../../primitives/Card/Card'; /** * Props for the SignIn component. * Extends BaseSignInProps for full compatibility with the React BaseSignIn component */ -export type SignInProps = Pick; +export type SignInProps = Pick & { + /** + * Render function for custom UI (render props pattern). + */ + children?: (props: SignInRenderProps) => ReactElement; +}; /** * A styled SignIn component that provides native authentication flow with pre-built styling. @@ -57,8 +63,8 @@ export type SignInProps = Pick = ({className, size = 'medium', ...rest}: SignInProps) => { - const {signIn, afterSignInUrl, isInitialized, isLoading} = useAsgardeo(); +const SignIn: FC = ({className, size = 'medium', children, ...rest}: SignInProps) => { + const {signIn, afterSignInUrl, isInitialized, isLoading, platform} = useAsgardeo(); /** * Initialize the authentication flow. @@ -94,6 +100,20 @@ const SignIn: FC = ({className, size = 'medium', ...rest}: SignInPr } }; + if (platform === Platform.AsgardeoV2) { + return ( + + {children} + + ); + } + return ( ; + + /** + * Form errors + */ + errors: Record; + + /** + * Touched fields + */ + touched: Record; + + /** + * Whether the form is valid + */ + isValid: boolean; + + /** + * Loading state + */ + isLoading: boolean; + + /** + * Current error message + */ + error: string | null; + + /** + * Flow components + */ + components: EmbeddedFlowComponent[]; + + /** + * Function to handle input changes + */ + handleInputChange: (name: string, value: string) => void; + + /** + * Function to handle form submission + */ + handleSubmit: (component: EmbeddedFlowComponent, data?: Record) => Promise; + + /** + * Function to validate the form + */ + validateForm: () => {isValid: boolean; errors: Record}; + + /** + * Flow title + */ + title: string; + + /** + * Flow subtitle + */ + subtitle: string; + + /** + * Flow messages + */ + messages: Array<{message: string; type: string}>; +} + +/** + * Props for the BaseSignIn component. + */ +export interface BaseSignInProps { + /** + * Custom CSS class name for the submit button. + */ + buttonClassName?: string; + + /** + * Custom CSS class name for the form container. + */ + className?: string; + + /** + * Array of flow components to render. + */ + components?: EmbeddedFlowComponent[]; + + /** + * Custom CSS class name for error messages. + */ + errorClassName?: string; + + /** + * Flag to determine if the component is ready to be rendered. + */ + isLoading?: boolean; + + /** + * Custom CSS class name for form inputs. + */ + inputClassName?: string; + + /** + * 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: EmbeddedSignInFlowRequestV2, component: EmbeddedFlowComponent) => Promise; + + /** + * Callback function called when authentication is successful. + * @param authData - The authentication data returned upon successful completion. + */ + onSuccess?: (authData: Record) => void; + + /** + * Size variant for the component. + */ + size?: 'small' | 'medium' | 'large'; + + /** + * Theme variant for the component. + */ + variant?: CardProps['variant']; + + /** + * Render props function for custom UI + */ + children?: (props: BaseSignInRenderProps) => ReactNode; +} + +/** + * 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 '@asgardeo/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 = props => { + const {theme} = useTheme(); + const styles = useStyles(theme, theme.vars.colors.text.primary); + + return ( +
+
+ +
+ + + +
+ ); +}; + +/** + * Internal component that consumes FlowContext and renders the sign-in UI. + */ +const BaseSignInContent: FC = ({ + components = [], + onSubmit, + onError, + className = '', + inputClassName = '', + buttonClassName = '', + errorClassName = '', + messageClassName = '', + size = 'medium', + variant = 'outlined', + isLoading: externalIsLoading, + children, +}) => { + const {theme} = useTheme(); + const {t} = useTranslation(); + const {subtitle: flowSubtitle, title: flowTitle, messages: flowMessages} = useFlow(); + const styles = useStyles(theme, theme.vars.colors.text.primary); + + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); + + const isLoading = externalIsLoading || isSubmitting; + + /** + * Extract form fields from flow components + */ + const extractFormFields = useCallback( + (components: EmbeddedFlowComponent[]): FormField[] => { + const fields: FormField[] = []; + + const processComponents = (comps: EmbeddedFlowComponent[]) => { + comps.forEach(component => { + if (component.type === 'INPUT' && component.config) { + const identifier = component.config['identifier'] || component.id; + fields.push({ + name: identifier, + required: component.config['required'] || false, + initialValue: '', + validator: (value: string) => { + if (component.config['required'] && (!value || value.trim() === '')) { + return t('field.required'); + } + return null; + }, + }); + } + if (component.components) { + processComponents(component.components); + } + }); + }; + + processComponents(components); + return fields; + }, + [t], + ); + + const formFields = components ? extractFormFields(components) : []; + + const form = useForm>({ + initialValues: {}, + fields: formFields, + validateOnBlur: true, + validateOnChange: false, + requiredMessage: t('field.required'), + }); + + const { + values: formValues, + touched: touchedFields, + errors: formErrors, + isValid: isFormValid, + setValue: setFormValue, + setTouched: setFormTouched, + validateForm, + touchAllFields, + } = form; + + /** + * Handle input value changes. + */ + const handleInputChange = (name: string, value: string) => { + setFormValue(name, value); + setFormTouched(name, true); + }; + + /** + * Handle component submission (for buttons and actions). + */ + const handleSubmit = async (component: EmbeddedFlowComponent, data?: Record) => { + // Mark all fields as touched before validation + touchAllFields(); + + const validation = validateForm(); + if (!validation.isValid) { + return; + } + + setIsSubmitting(true); + setError(null); + + try { + // Filter out empty or undefined input values + const filteredInputs: Record = {}; + if (data) { + Object.keys(data).forEach(key => { + if (data[key] !== undefined && data[key] !== null && data[key] !== '') { + filteredInputs[key] = data[key]; + } + }); + } + + let payload: EmbeddedSignInFlowRequestV2 = {}; + + if (component.config['actionId']) { + payload = { + ...payload, + actionId: component.config['actionId'], + }; + } else { + payload = { + ...payload, + inputs: filteredInputs, + }; + } + + await onSubmit?.(payload, component); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : t('errors.sign.in.flow.failure'); + setError(errorMessage); + onError?.(err as Error); + } finally { + setIsSubmitting(false); + } + }; + + // Generate CSS classes + const containerClasses = cx( + [ + withVendorCSSClassPrefix('signin'), + withVendorCSSClassPrefix(`signin--${size}`), + withVendorCSSClassPrefix(`signin--${variant}`), + ], + className, + ); + + const inputClasses = cx( + [ + withVendorCSSClassPrefix('signin__input'), + size === 'small' && withVendorCSSClassPrefix('signin__input--small'), + size === 'large' && withVendorCSSClassPrefix('signin__input--large'), + ], + inputClassName, + ); + + const buttonClasses = cx( + [ + withVendorCSSClassPrefix('signin__button'), + size === 'small' && withVendorCSSClassPrefix('signin__button--small'), + size === 'large' && withVendorCSSClassPrefix('signin__button--large'), + ], + buttonClassName, + ); + + const errorClasses = cx([withVendorCSSClassPrefix('signin__error')], errorClassName); + + const messageClasses = cx([withVendorCSSClassPrefix('signin__messages')], messageClassName); + + /** + * Render components based on flow data using the factory + */ + const renderComponents = useCallback( + (components: EmbeddedFlowComponent[]): ReactElement[] => + renderSignInComponents( + components, + formValues, + touchedFields, + formErrors, + isLoading, + isFormValid, + handleInputChange, + { + buttonClassName: buttonClasses, + error, + inputClassName: inputClasses, + onSubmit: handleSubmit, + size, + variant, + }, + ), + [ + formValues, + touchedFields, + formErrors, + isFormValid, + isLoading, + size, + variant, + error, + inputClasses, + buttonClasses, + handleSubmit, + ], + ); + + // If render props are provided, use them + if (children) { + const renderProps: BaseSignInRenderProps = { + values: formValues, + errors: formErrors, + touched: touchedFields, + isValid: isFormValid, + isLoading, + error, + components, + handleInputChange, + handleSubmit, + validateForm, + title: flowTitle || t('signin.title'), + subtitle: flowSubtitle || t('signin.subtitle'), + messages: flowMessages || [], + }; + + return
{children(renderProps)}
; + } + + // Default UI rendering + if (isLoading) { + return ( + + +
+ +
+
+
+ ); + } + + if (!components || components.length === 0) { + return ( + + + + {t('errors.sign.in.components.not.available')} + + + + ); + } + + return ( + + + + {flowTitle || t('signin.title')} + + + {flowSubtitle || t('signin.subtitle')} + + {flowMessages && flowMessages.length > 0 && ( +
+ {flowMessages.map((message, index) => ( + + {message.message} + + ))} +
+ )} +
+ + + {error && ( + + {t('errors.title')} + {error} + + )} + +
{components && renderComponents(components)}
+
+
+ ); +}; + +export default BaseSignIn; diff --git a/packages/react/src/components/presentation/SignIn/component-driven/SignIn.tsx b/packages/react/src/components/presentation/SignIn/component-driven/SignIn.tsx new file mode 100644 index 00000000..1ca66989 --- /dev/null +++ b/packages/react/src/components/presentation/SignIn/component-driven/SignIn.tsx @@ -0,0 +1,320 @@ +/** + * 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, useState, useEffect, useRef, ReactNode} from 'react'; +import BaseSignIn, {BaseSignInProps} from './BaseSignIn'; +import useAsgardeo from '../../../../contexts/Asgardeo/useAsgardeo'; +import { + AsgardeoRuntimeError, + EmbeddedFlowComponent, + EmbeddedFlowType, + EmbeddedSignInFlowResponseV2, + EmbeddedSignInFlowRequestV2, + EmbeddedSignInFlowStatusV2, +} from '@asgardeo/browser'; +import {normalizeFlowResponse} from './transformer'; +import useTranslation from '../../../../hooks/useTranslation'; + +/** + * Render props function parameters + */ +export interface SignInRenderProps { + /** + * Function to manually initialize the flow + */ + initialize: () => Promise; + + /** + * Function to submit authentication data (primary) + */ + onSubmit: (payload: EmbeddedSignInFlowRequestV2) => Promise; + + /** + * Loading state indicator + */ + isLoading: boolean; + + /** + * Whether the flow has been initialized + */ + isInitialized: boolean; + + /** + * Current flow components + */ + components: EmbeddedFlowComponent[]; + + /** + * Current error if any + */ + error: Error | null; +} + +/** + * Props for the SignIn component. + * Matches the interface from the main SignIn component for consistency. + */ +export type SignInProps = { + /** + * Custom CSS class name for the form container. + */ + className?: string; + + /** + * Callback function called when authentication is successful. + * @param authData - The authentication data returned upon successful completion. + */ + onSuccess?: (authData: Record) => void; + + /** + * Callback function called when authentication fails. + * @param error - The error that occurred during authentication. + */ + onError?: (error: Error) => void; + + /** + * Theme variant for the component. + */ + variant?: BaseSignInProps['variant']; + + /** + * Size variant for the component. + */ + size?: 'small' | 'medium' | 'large'; + + /** + * Render props function for custom UI + */ + children?: (props: SignInRenderProps) => ReactNode; +}; + +/** + * 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 '@asgardeo/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 '@asgardeo/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, size = 'medium', onSuccess, onError, variant, children}) => { + const {applicationId, afterSignInUrl, signIn, isInitialized, isLoading, baseUrl} = useAsgardeo(); + const {t} = useTranslation(); + + // State management for the flow + const [components, setComponents] = useState([]); + const [currentFlowId, setCurrentFlowId] = useState(null); + const [isFlowInitialized, setIsFlowInitialized] = useState(false); + const [flowError, setFlowError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + const initializationAttemptedRef = useRef(false); + + // Initialize the flow on component mount (always initialize) + useEffect(() => { + if (isInitialized && !isLoading && !isFlowInitialized && !initializationAttemptedRef.current) { + initializationAttemptedRef.current = true; + initializeFlow(); + } + }, [isInitialized, isLoading, isFlowInitialized]); + + /** + * Initialize the authentication flow. + */ + const initializeFlow = async (): Promise => { + const applicationIdFromUrl: string = new URL(window.location.href).searchParams.get('applicationId'); + + if (!applicationIdFromUrl && !applicationId) { + const error = new AsgardeoRuntimeError( + `Application ID is required for authentication`, + 'SignIn-initializeFlow-RuntimeError-001', + 'react', + 'Something went wrong while trying to sign in. Please try again later.', + ); + setFlowError(error); + throw error; + } + + try { + setFlowError(null); + const response: EmbeddedSignInFlowResponseV2 = await signIn({ + applicationId: applicationId || applicationIdFromUrl, + flowType: EmbeddedFlowType.Authentication, + }); + + const {flowId, components} = normalizeFlowResponse(response, t); + + if (flowId && components) { + setCurrentFlowId(flowId); + setComponents(components); + setIsFlowInitialized(true); + } + } catch (error) { + const err = error as Error; + setFlowError(err); + onError?.(err); + + throw new AsgardeoRuntimeError( + `Failed to initialize authentication flow: ${error instanceof Error ? error.message : String(error)}`, + 'SignIn-initializeFlow-RuntimeError-002', + 'react', + 'Something went wrong while trying to sign in. Please try again later.', + ); + } + }; + + /** + * Handle form submission from BaseSignIn or render props. + */ + const handleSubmit = async (payload: EmbeddedSignInFlowRequestV2): Promise => { + if (!currentFlowId) { + throw new Error('No active flow ID'); + } + + try { + setIsSubmitting(true); + setFlowError(null); + + const response = await signIn({ + flowId: currentFlowId, + ...payload, + }); + + const {flowId, components} = normalizeFlowResponse(response, t); + + if (response.flowStatus === EmbeddedSignInFlowStatusV2.Complete) { + onSuccess && + onSuccess({ + redirectUrl: response.redirectUrl || afterSignInUrl, + ...response.data, + }); + + window.location.href = response.redirectUrl || afterSignInUrl; + + return; + } + + if (flowId && components) { + setCurrentFlowId(flowId); + setComponents(components); + } + } catch (error) { + const err = error as Error; + setFlowError(err); + onError?.(err); + + throw new AsgardeoRuntimeError( + `Failed to submit authentication flow: ${error instanceof Error ? error.message : String(error)}`, + 'SignIn-handleSubmit-RuntimeError-001', + 'react', + 'Something went wrong while trying to sign in. Please try again later.', + ); + } finally { + setIsSubmitting(false); + } + }; + + /** + * Handle authentication errors. + */ + const handleError = (error: Error): void => { + console.error('Authentication error:', error); + setFlowError(error); + onError?.(error); + }; + + // If render props are provided, use them + if (children) { + const renderProps: SignInRenderProps = { + initialize: initializeFlow, + onSubmit: handleSubmit, + isLoading: isLoading || isSubmitting || !isInitialized, + isInitialized: isFlowInitialized, + components, + error: flowError, + }; + + return <>{children(renderProps)}; + } + + // Otherwise, render the default BaseSignIn component + return ( + + ); +}; + +export default SignIn; diff --git a/packages/react/src/components/presentation/SignIn/component-driven/SignInOptionFactory.tsx b/packages/react/src/components/presentation/SignIn/component-driven/SignInOptionFactory.tsx new file mode 100644 index 00000000..092d8c8c --- /dev/null +++ b/packages/react/src/components/presentation/SignIn/component-driven/SignInOptionFactory.tsx @@ -0,0 +1,240 @@ +/** + * 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 React, {ReactElement} from 'react'; +import {AsgardeoRuntimeError, EmbeddedFlowComponent, FieldType} from '@asgardeo/browser'; +import {EmbeddedFlowComponentType} from '@asgardeo/browser'; +import {createField} from '../../../factories/FieldFactory'; +import Button from '../../../primitives/Button/Button'; +import GoogleButton from '../../../adapters/GoogleButton'; +import GitHubButton from '../../../adapters/GitHubButton'; +import FacebookButton from '../../../adapters/FacebookButton'; +import Typography from '../../../primitives/Typography/Typography'; +import Divider from '../../../primitives/Divider/Divider'; +import SmsOtpButton from '../../../adapters/SmsOtpButton'; + +/** + * Get the appropriate FieldType for an input component. + */ +const getFieldType = (variant: string): FieldType => { + switch (variant) { + case 'EMAIL': + return FieldType.Email; + case 'PASSWORD': + return FieldType.Password; + case 'TEXT': + default: + return FieldType.Text; + } +}; + +/** + * Get typography variant from component variant. + */ +const getTypographyVariant = (variant: string) => { + const variantMap: Record = { + H1: 'h1', + H2: 'h2', + H3: 'h3', + H4: 'h4', + H5: 'h5', + H6: 'h6', + }; + return variantMap[variant] || 'h3'; +}; + +/** + * Create a sign-in component from flow component configuration. + */ +const createSignInComponentFromFlow = ( + component: EmbeddedFlowComponent, + formValues: Record, + touchedFields: Record, + formErrors: Record, + isLoading: boolean, + isFormValid: boolean, + onInputChange: (name: string, value: string) => void, + options: { + buttonClassName?: string; + error?: string | null; + inputClassName?: string; + key?: string | number; + onSubmit?: (component: EmbeddedFlowComponent, data?: Record) => void; + size?: 'small' | 'medium' | 'large'; + variant?: any; + } = {}, +): ReactElement | null => { + const key = options.key || component.id; + + switch (component.type) { + case EmbeddedFlowComponentType.Input: { + const identifier = component.config['identifier'] || component.id; + const value = formValues[identifier] || ''; + const isTouched = touchedFields[identifier] || false; + const error = isTouched ? formErrors[identifier] : undefined; + const fieldType = getFieldType(component.variant || 'TEXT'); + + const field = createField({ + type: fieldType, + name: identifier, + label: component.config['label'] || '', + placeholder: component.config['placeholder'] || '', + required: component.config['required'] || false, + value, + error, + onChange: (newValue: string) => onInputChange(identifier, newValue), + className: options.inputClassName, + }); + + return React.cloneElement(field, {key}); + } + + case EmbeddedFlowComponentType.Button: { + const handleClick = () => { + if (options.onSubmit) { + const formData: Record = {}; + Object.keys(formValues).forEach(field => { + if (formValues[field]) { + formData[field] = formValues[field]; + } + }); + options.onSubmit(component, formData); + } + }; + + // Render branded social login buttons for known action IDs + const actionId: string = component.config['actionId']; + + if (actionId === 'google_auth') { + return ; + } + if (actionId === 'github_auth') { + return ; + } + if (actionId === 'facebook_auth') { + return ; + } + if (actionId === 'prompt_mobile') { + return ; + } + + // Fallback to generic button + return ( + + ); + } + + case EmbeddedFlowComponentType.Typography: { + const variant = getTypographyVariant(component.variant || 'H3'); + return ( + + {component.config['text'] || ''} + + ); + } + + case EmbeddedFlowComponentType.Form: { + if (component.components && component.components.length > 0) { + const formComponents = component.components + .map((childComponent, index) => + createSignInComponentFromFlow( + childComponent, + formValues, + touchedFields, + formErrors, + isLoading, + isFormValid, + onInputChange, + { + ...options, + key: childComponent.id || `${component.id}_${index}`, + }, + ), + ) + .filter(Boolean); + + return ( +
e.preventDefault()}> + {formComponents} +
+ ); + } + return null; + } + + case EmbeddedFlowComponentType.Divider: { + return ; + } + + default: + throw new AsgardeoRuntimeError( + `Unsupported component type: ${component.type}`, + 'SignIn-UnsupportedComponentType-001', + 'react', + 'Something went wrong while rendering the sign-in component. Please try again later.', + ); + } +}; + +/** + * Processes an array of components and renders them as React elements. + */ +export const renderSignInComponents = ( + components: EmbeddedFlowComponent[], + formValues: Record, + touchedFields: Record, + formErrors: Record, + isLoading: boolean, + isFormValid: boolean, + onInputChange: (name: string, value: string) => void, + options?: { + buttonClassName?: string; + error?: string | null; + inputClassName?: string; + onSubmit?: (component: EmbeddedFlowComponent, data?: Record) => void; + size?: 'small' | 'medium' | 'large'; + variant?: any; + }, +): ReactElement[] => + components + .map((component, index) => + createSignInComponentFromFlow( + component, + formValues, + touchedFields, + formErrors, + isLoading, + isFormValid, + onInputChange, + { + ...options, + key: component.id || index, + }, + ), + ) + .filter(Boolean); diff --git a/packages/react/src/components/presentation/SignIn/component-driven/transformer.ts b/packages/react/src/components/presentation/SignIn/component-driven/transformer.ts new file mode 100644 index 00000000..647c8eec --- /dev/null +++ b/packages/react/src/components/presentation/SignIn/component-driven/transformer.ts @@ -0,0 +1,195 @@ +/** + * 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 '@asgardeo/browser'; +import useTranslation, {UseTranslation} from '../../../../hooks/useTranslation'; + +/** + * Generate a unique ID for components + */ +const generateId = (prefix: string): string => { + const suffix = Math.random().toString(36).substring(2, 6); + return `${prefix}_${suffix}`; +}; + +/** + * Convert simple input type to component variant + */ +const getInputVariant = (type: string): 'TEXT' | 'EMAIL' | 'PASSWORD' => { + switch (type.toLowerCase()) { + case 'email': + return 'EMAIL'; + case 'password': + return 'PASSWORD'; + default: + return 'TEXT'; + } +}; + +/** + * Get appropriate label for input based on name and type + */ +const getInputLabel = (name: string, type: string, t: UseTranslation['t']): string => { + // Use i18n keys for labels, fallback to capitalized name + const i18nKey = `elements.fields.${name}`; + // Try translation, fallback to capitalized name + const label = t(i18nKey); + + if (label === i18nKey || !label) { + return name.charAt(0).toUpperCase() + name.slice(1); + } + + return label; +}; + +/** + * Get appropriate placeholder for input based on name and type + */ +const getInputPlaceholder = (name: string, type: string, t: UseTranslation['t']): string => { + const label = getInputLabel(name, type, t); + const placeholder = t('elements.fields.placeholder', {field: label}); + // If translation not found, fallback + if (!placeholder || placeholder === 'elements.fields.placeholder') { + return `Enter your ${label}`; + } + return placeholder; +}; + +/** + * Convert simple input to component-driven input component + */ +const convertSimpleInputToComponent = ( + input: { + name: string; + type: string; + required: boolean; + }, + t: UseTranslation['t'], +): EmbeddedFlowComponent => { + const variant = getInputVariant(input.type); + const label = getInputLabel(input.name, input.type, t); + const placeholder = getInputPlaceholder(input.name, input.type, t); + + return { + id: generateId('input'), + type: EmbeddedFlowComponentType.Input, + variant, + config: { + type: input.type === 'string' ? 'text' : input.type, + label, + placeholder, + required: input.required, + identifier: input.name, + hint: '', + }, + components: [], + }; +}; + +/** + * Convert action to component-driven button component + */ +const convertActionToComponent = ( + action: {type: string; id: string}, + t: UseTranslation['t'], +): EmbeddedFlowComponent => { + // Use i18n key for button text, fallback to capitalized id + const i18nKey = `elements.buttons.${action.id}`; + let text = t(i18nKey); + if (!text || text === i18nKey) { + text = action.id.replace(/_/g, ' '); + text = text.charAt(0).toUpperCase() + text.slice(1); + } + return { + id: generateId('action'), + type: EmbeddedFlowComponentType.Button, + variant: 'SECONDARY', + config: { + type: 'button', + text, + actionId: action.id, + actionType: action.type, + }, + components: [], + }; +}; + +/** + * Transform simple flow response to component-driven format + */ +export const transformSimpleToComponentDriven = (response: any, t: UseTranslation['t']): EmbeddedFlowComponent[] => { + // Create input components if present + const inputComponents = response?.data?.inputs?.map((input: any) => convertSimpleInputToComponent(input, t)) || []; + + // Create action buttons if present + const actionComponents = response?.data?.actions?.map((action: any) => convertActionToComponent(action, t)) || []; + + // Add a submit button if there are inputs + const submitButton: EmbeddedFlowComponent | null = + inputComponents.length > 0 + ? { + id: generateId('button'), + type: EmbeddedFlowComponentType.Button, + variant: 'PRIMARY', + config: { + type: 'submit', + text: t('elements.buttons.signIn'), + }, + components: [], + } + : null; + + // Compose form components (inputs + submit only) + const formComponents: EmbeddedFlowComponent[] = []; + if (inputComponents.length > 0) { + formComponents.push(...inputComponents); + if (submitButton) formComponents.push(submitButton); + } + + const result: EmbeddedFlowComponent[] = []; + // Add form if there are input fields + if (formComponents.length > 0) { + result.push({ + id: generateId('form'), + type: EmbeddedFlowComponentType.Form, + config: {}, + components: formComponents, + }); + } + // Add actions outside the form + if (actionComponents.length > 0) { + result.push(...actionComponents); + } + return result; +}; + +/** + * Generic transformer that handles both simple and component-driven responses + */ +export const normalizeFlowResponse = ( + response: any, + t: UseTranslation['t'], +): { + flowId: string; + components: EmbeddedFlowComponent[]; +} => { + return { + flowId: response.flowId, + components: transformSimpleToComponentDriven(response, t), + }; +}; diff --git a/packages/react/src/components/presentation/SignIn/non-component-driven/BaseSignIn.styles.ts b/packages/react/src/components/presentation/SignIn/non-component-driven/BaseSignIn.styles.ts new file mode 100644 index 00000000..0b86e3db --- /dev/null +++ b/packages/react/src/components/presentation/SignIn/non-component-driven/BaseSignIn.styles.ts @@ -0,0 +1,191 @@ +/** + * 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 {css} from '@emotion/css'; +import {useMemo} from 'react'; +import {Theme} from '@asgardeo/browser'; + +/** + * Creates styles for the BaseSignIn component + * @param theme - The theme object containing design tokens + * @param colorScheme - The current color scheme (used for memoization) + * @returns Object containing CSS class names for component styling + */ +const useStyles = (theme: Theme, colorScheme: string) => { + return useMemo(() => { + const signIn = css` + min-width: 420px; + margin: 0 auto; + `; + + const card = css` + background: ${theme.vars.colors.background.surface}; + border-radius: ${theme.vars.borderRadius.large}; + gap: calc(${theme.vars.spacing.unit} * 2); + min-width: 420px; + `; + + const logoContainer = css` + display: flex; + flex-direction: column; + align-items: center; + margin-bottom: calc(${theme.vars.spacing.unit} * 3); + `; + + const header = css` + gap: 0; + `; + + const title = css` + margin: 0 0 calc(${theme.vars.spacing.unit} * 1) 0; + color: ${theme.vars.colors.text.primary}; + `; + + const subtitle = css` + margin-top: calc(${theme.vars.spacing.unit} * 1); + color: ${theme.vars.colors.text.secondary}; + `; + + const messagesContainer = css` + margin-top: calc(${theme.vars.spacing.unit} * 2); + `; + + const messageItem = css` + margin-bottom: calc(${theme.vars.spacing.unit} * 1); + `; + + const errorContainer = css` + margin-bottom: calc(${theme.vars.spacing.unit} * 2); + `; + + const contentContainer = css` + display: flex; + flex-direction: column; + gap: calc(${theme.vars.spacing.unit} * 2); + `; + + const loadingContainer = css` + display: flex; + flex-direction: column; + align-items: center; + padding: calc(${theme.vars.spacing.unit} * 4); + `; + + const loadingText = css` + margin-top: calc(${theme.vars.spacing.unit} * 2); + color: ${theme.vars.colors.text.secondary}; + `; + + const divider = css` + margin: calc(${theme.vars.spacing.unit} * 1) 0; + `; + + const centeredContainer = css` + text-align: center; + padding: calc(${theme.vars.spacing.unit} * 4); + `; + + const passkeyContainer = css` + margin-bottom: calc(${theme.vars.spacing.unit} * 2); + `; + + const passkeyText = css` + margin-top: calc(${theme.vars.spacing.unit} * 1); + color: ${theme.vars.colors.text.secondary}; + `; + + const form = css` + display: flex; + flex-direction: column; + gap: calc(${theme.vars.spacing.unit} * 2); + `; + + const formDivider = css` + margin: calc(${theme.vars.spacing.unit} * 1) 0; + `; + + const authenticatorSection = css` + display: flex; + flex-direction: column; + gap: calc(${theme.vars.spacing.unit} * 1); + `; + + const authenticatorItem = css` + width: 100%; + `; + + const noAuthenticatorCard = css` + background: ${theme.vars.colors.background.surface}; + border-radius: ${theme.vars.borderRadius.large}; + padding: calc(${theme.vars.spacing.unit} * 2); + `; + + const errorAlert = css` + margin-bottom: calc(${theme.vars.spacing.unit} * 2); + `; + + const messagesAlert = css` + margin-bottom: calc(${theme.vars.spacing.unit} * 1); + `; + + const flowMessagesContainer = css` + margin-top: calc(${theme.vars.spacing.unit} * 2); + `; + + const flowMessageItem = css` + margin-bottom: calc(${theme.vars.spacing.unit} * 1); + `; + + return { + signIn, + card, + logoContainer, + header, + title, + subtitle, + messagesContainer, + messageItem, + errorContainer, + contentContainer, + loadingContainer, + loadingText, + divider, + centeredContainer, + passkeyContainer, + passkeyText, + form, + formDivider, + authenticatorSection, + authenticatorItem, + noAuthenticatorCard, + errorAlert, + messagesAlert, + flowMessagesContainer, + flowMessageItem, + }; + }, [ + theme.vars.colors.background.surface, + theme.vars.colors.text.primary, + theme.vars.colors.text.secondary, + theme.vars.borderRadius.large, + theme.vars.spacing.unit, + colorScheme, + ]); +}; + +export default useStyles; diff --git a/packages/react/src/components/presentation/SignIn/BaseSignIn.tsx b/packages/react/src/components/presentation/SignIn/non-component-driven/BaseSignIn.tsx similarity index 86% rename from packages/react/src/components/presentation/SignIn/BaseSignIn.tsx rename to packages/react/src/components/presentation/SignIn/non-component-driven/BaseSignIn.tsx index 7e377e68..c959d222 100644 --- a/packages/react/src/components/presentation/SignIn/BaseSignIn.tsx +++ b/packages/react/src/components/presentation/SignIn/non-component-driven/BaseSignIn.tsx @@ -28,176 +28,24 @@ import { withVendorCSSClassPrefix, EmbeddedSignInFlowHandleRequestPayload, EmbeddedFlowExecuteRequestConfig, + handleWebAuthnAuthentication, } from '@asgardeo/browser'; import {cx} from '@emotion/css'; -import {FC, ReactElement, FormEvent, useEffect, useState, useCallback, useRef} from 'react'; +import {FC, FormEvent, useEffect, useState, useCallback, useRef} from 'react'; import {createSignInOptionFromAuthenticator} from './options/SignInOptionFactory'; -import FlowProvider from '../../../contexts/Flow/FlowProvider'; -import useFlow from '../../../contexts/Flow/useFlow'; -import {useForm, FormField} from '../../../hooks/useForm'; -import useTranslation from '../../../hooks/useTranslation'; -import useTheme from '../../../contexts/Theme/useTheme'; -import Alert from '../../primitives/Alert/Alert'; -import Card, {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 FlowProvider from '../../../../contexts/Flow/FlowProvider'; +import useFlow from '../../../../contexts/Flow/useFlow'; +import {useForm, FormField} from '../../../../hooks/useForm'; +import useTranslation from '../../../../hooks/useTranslation'; +import useTheme from '../../../../contexts/Theme/useTheme'; +import Alert from '../../../primitives/Alert/Alert'; +import Card, {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'; -/** - * Utility functions for WebAuthn/Passkey operations - */ - -/** - * Convert base64url string to ArrayBuffer - */ -const base64urlToArrayBuffer = (base64url: string): ArrayBuffer => { - // Add padding if needed - const padding = '='.repeat((4 - (base64url.length % 4)) % 4); - const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/') + padding; - - const binaryString = atob(base64); - const bytes = new Uint8Array(binaryString.length); - for (let i = 0; i < binaryString.length; i++) { - bytes[i] = binaryString.charCodeAt(i); - } - return bytes.buffer; -}; - -/** - * Convert ArrayBuffer to base64url string - */ -const arrayBufferToBase64url = (buffer: ArrayBuffer): string => { - const bytes = new Uint8Array(buffer); - let binary = ''; - for (let i = 0; i < bytes.byteLength; i++) { - binary += String.fromCharCode(bytes[i]); - } - return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); -}; - -/** - * Handle WebAuthn authentication - */ -const handleWebAuthnAuthentication = async (challengeData: string): Promise => { - // Check if WebAuthn is supported - if (!window.navigator.credentials || !window.navigator.credentials.get) { - throw new Error( - 'WebAuthn is not supported in this browser. Please use a modern browser or try a different authentication method.', - ); - } - - // Check if we're on HTTPS (required for WebAuthn) - if (window.location.protocol !== 'https:' && window.location.hostname !== 'localhost') { - throw new Error( - 'Passkey authentication requires a secure connection (HTTPS). Please ensure you are accessing this site over HTTPS.', - ); - } - - try { - // Decode the challenge data - const decodedChallenge = JSON.parse(atob(challengeData)); - const {publicKeyCredentialRequestOptions} = decodedChallenge; - - // Handle RP ID mismatch by checking domain compatibility - const currentDomain = window.location.hostname; - const challengeRpId = publicKeyCredentialRequestOptions.rpId; - - let rpIdToUse = challengeRpId; - - // Check if the challenge RP ID is compatible with current domain - if (challengeRpId && !currentDomain.endsWith(challengeRpId) && challengeRpId !== currentDomain) { - console.warn(`RP ID mismatch detected. Challenge RP ID: ${challengeRpId}, Current domain: ${currentDomain}`); - // Use current domain as fallback to avoid errors - rpIdToUse = currentDomain; - } - - const adjustedOptions = { - ...publicKeyCredentialRequestOptions, - rpId: rpIdToUse, - challenge: base64urlToArrayBuffer(publicKeyCredentialRequestOptions.challenge), - // Convert user handle if present - ...(publicKeyCredentialRequestOptions.userVerification && { - userVerification: publicKeyCredentialRequestOptions.userVerification, - }), - // Convert allowCredentials if present - ...(publicKeyCredentialRequestOptions.allowCredentials && { - allowCredentials: publicKeyCredentialRequestOptions.allowCredentials.map((cred: any) => ({ - ...cred, - id: base64urlToArrayBuffer(cred.id), - })), - }), - }; - - // Convert challenge from base64url to ArrayBuffer - const credential = (await navigator.credentials.get({ - publicKey: adjustedOptions, - })) as PublicKeyCredential; - - if (!credential) { - throw new Error('No credential returned from WebAuthn'); - } - - const authData = credential.response as AuthenticatorAssertionResponse; - - // Create the token response for the server - const tokenResponse = { - requestId: decodedChallenge.requestId, - credential: { - id: credential.id, - rawId: arrayBufferToBase64url(credential.rawId), - response: { - authenticatorData: arrayBufferToBase64url(authData.authenticatorData), - clientDataJSON: arrayBufferToBase64url(authData.clientDataJSON), - signature: arrayBufferToBase64url(authData.signature), - ...(authData.userHandle && { - userHandle: arrayBufferToBase64url(authData.userHandle), - }), - }, - type: credential.type, - }, - }; - - return JSON.stringify(tokenResponse); - } catch (error) { - console.error('WebAuthn authentication failed:', error); - - // Handle specific error cases - if (error instanceof Error) { - if (error.name === 'NotAllowedError') { - throw new Error('Passkey authentication was cancelled or timed out. Please try again.'); - } else if (error.name === 'SecurityError') { - if (error.message.includes('relying party ID') || error.message.includes('RP ID')) { - throw new Error( - 'Domain mismatch error. The passkey was registered for a different domain. Please contact support or try a different authentication method.', - ); - } else { - throw new Error( - 'Passkey authentication failed. Please ensure you are using HTTPS and that your browser supports passkeys.', - ); - } - } else if (error.name === 'InvalidStateError') { - throw new Error( - 'No valid passkey found for this account. Please register a passkey first or use a different authentication method.', - ); - } else if (error.name === 'NotSupportedError') { - throw new Error( - 'Passkey authentication is not supported on this device or browser. Please use a different authentication method.', - ); - } else if (error.name === 'NetworkError') { - throw new Error('Network error during passkey authentication. Please check your connection and try again.'); - } else if (error.name === 'UnknownError') { - throw new Error( - 'An unknown error occurred during passkey authentication. Please try again or use a different authentication method.', - ); - } - } - - throw new Error(`Passkey authentication failed: ${error instanceof Error ? error.message : 'Unknown error'}`); - } -}; - /** * Check if the authenticator is a passkey/FIDO authenticator */ diff --git a/packages/react/src/components/presentation/SignIn/options/EmailOtp.tsx b/packages/react/src/components/presentation/SignIn/non-component-driven/options/EmailOtp.tsx similarity index 90% rename from packages/react/src/components/presentation/SignIn/options/EmailOtp.tsx rename to packages/react/src/components/presentation/SignIn/non-component-driven/options/EmailOtp.tsx index f854e897..1c190fb8 100644 --- a/packages/react/src/components/presentation/SignIn/options/EmailOtp.tsx +++ b/packages/react/src/components/presentation/SignIn/non-component-driven/options/EmailOtp.tsx @@ -18,13 +18,13 @@ import {EmbeddedSignInFlowAuthenticator, EmbeddedSignInFlowAuthenticatorParamType, FieldType} from '@asgardeo/browser'; import {FC, useEffect} from 'react'; -import {createField} from '../../../factories/FieldFactory'; -import Button from '../../../primitives/Button/Button'; -import OtpField from '../../../primitives/OtpField/OtpField'; +import {createField} from '../../../../factories/FieldFactory'; +import Button from '../../../../primitives/Button/Button'; +import OtpField from '../../../../primitives/OtpField/OtpField'; import {BaseSignInOptionProps} from './SignInOptionFactory'; -import useTranslation from '../../../../hooks/useTranslation'; -import useFlow from '../../../../contexts/Flow/useFlow'; -import useTheme from '../../../../contexts/Theme/useTheme'; +import useTranslation from '../../../../../hooks/useTranslation'; +import useFlow from '../../../../../contexts/Flow/useFlow'; +import useTheme from '../../../../../contexts/Theme/useTheme'; /** * Email OTP Sign-In Option Component. diff --git a/packages/react/src/components/presentation/SignIn/options/IdentifierFirst.tsx b/packages/react/src/components/presentation/SignIn/non-component-driven/options/IdentifierFirst.tsx similarity index 90% rename from packages/react/src/components/presentation/SignIn/options/IdentifierFirst.tsx rename to packages/react/src/components/presentation/SignIn/non-component-driven/options/IdentifierFirst.tsx index 5998c2d2..7621b71e 100644 --- a/packages/react/src/components/presentation/SignIn/options/IdentifierFirst.tsx +++ b/packages/react/src/components/presentation/SignIn/non-component-driven/options/IdentifierFirst.tsx @@ -18,12 +18,12 @@ import {EmbeddedSignInFlowAuthenticator, EmbeddedSignInFlowAuthenticatorParamType, FieldType} from '@asgardeo/browser'; import {FC, useEffect} from 'react'; -import {createField} from '../../../factories/FieldFactory'; -import Button from '../../../primitives/Button/Button'; +import {createField} from '../../../../factories/FieldFactory'; +import Button from '../../../../primitives/Button/Button'; import {BaseSignInOptionProps} from './SignInOptionFactory'; -import useTranslation from '../../../../hooks/useTranslation'; -import useFlow from '../../../../contexts/Flow/useFlow'; -import useTheme from '../../../../contexts/Theme/useTheme'; +import useTranslation from '../../../../../hooks/useTranslation'; +import useFlow from '../../../../../contexts/Flow/useFlow'; +import useTheme from '../../../../../contexts/Theme/useTheme'; /** * Identifier First Sign-In Option Component. diff --git a/packages/react/src/components/presentation/SignIn/options/MultiOptionButton.tsx b/packages/react/src/components/presentation/SignIn/non-component-driven/options/MultiOptionButton.tsx similarity index 98% rename from packages/react/src/components/presentation/SignIn/options/MultiOptionButton.tsx rename to packages/react/src/components/presentation/SignIn/non-component-driven/options/MultiOptionButton.tsx index 9cf3a592..4c597c31 100644 --- a/packages/react/src/components/presentation/SignIn/options/MultiOptionButton.tsx +++ b/packages/react/src/components/presentation/SignIn/non-component-driven/options/MultiOptionButton.tsx @@ -17,10 +17,10 @@ */ import {FC, JSX, ReactElement} from 'react'; -import Button from '../../../primitives/Button/Button'; +import Button from '../../../../primitives/Button/Button'; import {BaseSignInOptionProps} from './SignInOptionFactory'; import {ApplicationNativeAuthenticationConstants, EmbeddedSignInFlowAuthenticatorKnownIdPType} from '@asgardeo/browser'; -import useTranslation from '../../../../hooks/useTranslation'; +import useTranslation from '../../../../../hooks/useTranslation'; /** * Multi Option Button Component. diff --git a/packages/react/src/components/presentation/SignIn/options/SignInOptionFactory.tsx b/packages/react/src/components/presentation/SignIn/non-component-driven/options/SignInOptionFactory.tsx similarity index 92% rename from packages/react/src/components/presentation/SignIn/options/SignInOptionFactory.tsx rename to packages/react/src/components/presentation/SignIn/non-component-driven/options/SignInOptionFactory.tsx index 7adef99f..7292ee8c 100644 --- a/packages/react/src/components/presentation/SignIn/options/SignInOptionFactory.tsx +++ b/packages/react/src/components/presentation/SignIn/non-component-driven/options/SignInOptionFactory.tsx @@ -25,16 +25,16 @@ import { import {ReactElement} from 'react'; import UsernamePassword from './UsernamePassword'; import IdentifierFirst from './IdentifierFirst'; -import GoogleButton from '../../options/GoogleButton'; -import GitHubButton from '../../options/GitHubButton'; -import MicrosoftButton from '../../options/MicrosoftButton'; -import FacebookButton from '../../options/FacebookButton'; -import LinkedInButton from '../../options/LinkedInButton'; -import SignInWithEthereumButton from '../../options/SignInWithEthereumButton'; +import GoogleButton from '../../../../adapters/GoogleButton'; +import GitHubButton from '../../../../adapters/GitHubButton'; +import MicrosoftButton from '../../../../adapters/MicrosoftButton'; +import FacebookButton from '../../../../adapters/FacebookButton'; +import LinkedInButton from '../../../../adapters/LinkedInButton'; +import SignInWithEthereumButton from '../../../../adapters/SignInWithEthereumButton'; import EmailOtp from './EmailOtp'; import Totp from './Totp'; import SmsOtp from './SmsOtp'; -import SocialButton from '../../options/SocialButton'; +import SocialButton from './SocialButton'; import MultiOptionButton from './MultiOptionButton'; /** @@ -118,7 +118,6 @@ export const createSignInOption = ({ onSubmit(authenticator)} - authenticator={authenticator} preferences={preferences} {...rest} /> @@ -127,7 +126,6 @@ export const createSignInOption = ({ case ApplicationNativeAuthenticationConstants.SupportedAuthenticators.GitHub: return ( onSubmit(authenticator)} @@ -138,7 +136,6 @@ export const createSignInOption = ({ case ApplicationNativeAuthenticationConstants.SupportedAuthenticators.Microsoft: return ( onSubmit(authenticator)} @@ -149,7 +146,6 @@ export const createSignInOption = ({ case ApplicationNativeAuthenticationConstants.SupportedAuthenticators.Facebook: return ( onSubmit(authenticator)} @@ -160,7 +156,6 @@ export const createSignInOption = ({ case ApplicationNativeAuthenticationConstants.SupportedAuthenticators.LinkedIn: return ( onSubmit(authenticator)} @@ -171,7 +166,6 @@ export const createSignInOption = ({ case ApplicationNativeAuthenticationConstants.SupportedAuthenticators.SignInWithEthereum: return ( onSubmit(authenticator)} diff --git a/packages/react/src/components/presentation/SignIn/options/SmsOtp.tsx b/packages/react/src/components/presentation/SignIn/non-component-driven/options/SmsOtp.tsx similarity index 89% rename from packages/react/src/components/presentation/SignIn/options/SmsOtp.tsx rename to packages/react/src/components/presentation/SignIn/non-component-driven/options/SmsOtp.tsx index 2c2041ab..a95d8d0d 100644 --- a/packages/react/src/components/presentation/SignIn/options/SmsOtp.tsx +++ b/packages/react/src/components/presentation/SignIn/non-component-driven/options/SmsOtp.tsx @@ -18,13 +18,13 @@ import {EmbeddedSignInFlowAuthenticator, EmbeddedSignInFlowAuthenticatorParamType, FieldType} from '@asgardeo/browser'; import {FC, useEffect} from 'react'; -import {createField} from '../../../factories/FieldFactory'; -import Button from '../../../primitives/Button/Button'; -import OtpField from '../../../primitives/OtpField/OtpField'; +import {createField} from '../../../../factories/FieldFactory'; +import Button from '../../../../primitives/Button/Button'; +import OtpField from '../../../../primitives/OtpField/OtpField'; import {BaseSignInOptionProps} from './SignInOptionFactory'; -import useTranslation from '../../../../hooks/useTranslation'; -import useFlow from '../../../../contexts/Flow/useFlow'; -import useTheme from '../../../../contexts/Theme/useTheme'; +import useTranslation from '../../../../../hooks/useTranslation'; +import useFlow from '../../../../../contexts/Flow/useFlow'; +import useTheme from '../../../../../contexts/Theme/useTheme'; /** * SMS OTP Sign-In Option Component. diff --git a/packages/react/src/components/presentation/options/SocialButton.tsx b/packages/react/src/components/presentation/SignIn/non-component-driven/options/SocialButton.tsx similarity index 89% rename from packages/react/src/components/presentation/options/SocialButton.tsx rename to packages/react/src/components/presentation/SignIn/non-component-driven/options/SocialButton.tsx index 8c20fc14..35152bf6 100644 --- a/packages/react/src/components/presentation/options/SocialButton.tsx +++ b/packages/react/src/components/presentation/SignIn/non-component-driven/options/SocialButton.tsx @@ -17,9 +17,9 @@ */ import {FC, HTMLAttributes} from 'react'; -import Button from '../../primitives/Button/Button'; -import {BaseSignInOptionProps} from '../SignIn/options/SignInOptionFactory'; -import useTranslation from '../../../hooks/useTranslation'; +import Button from '../../../../primitives/Button/Button'; +import {BaseSignInOptionProps} from './SignInOptionFactory'; +import useTranslation from '../../../../../hooks/useTranslation'; /** * Social Login Sign-In Option Component. diff --git a/packages/react/src/components/presentation/SignIn/options/Totp.tsx b/packages/react/src/components/presentation/SignIn/non-component-driven/options/Totp.tsx similarity index 90% rename from packages/react/src/components/presentation/SignIn/options/Totp.tsx rename to packages/react/src/components/presentation/SignIn/non-component-driven/options/Totp.tsx index 067212ee..7a177173 100644 --- a/packages/react/src/components/presentation/SignIn/options/Totp.tsx +++ b/packages/react/src/components/presentation/SignIn/non-component-driven/options/Totp.tsx @@ -18,13 +18,13 @@ import {EmbeddedSignInFlowAuthenticator, EmbeddedSignInFlowAuthenticatorParamType, FieldType} from '@asgardeo/browser'; import {FC, useEffect} from 'react'; -import {createField} from '../../../factories/FieldFactory'; -import Button from '../../../primitives/Button/Button'; -import OtpField from '../../../primitives/OtpField/OtpField'; +import {createField} from '../../../../factories/FieldFactory'; +import Button from '../../../../primitives/Button/Button'; +import OtpField from '../../../../primitives/OtpField/OtpField'; import {BaseSignInOptionProps} from './SignInOptionFactory'; -import useTranslation from '../../../../hooks/useTranslation'; -import useFlow from '../../../../contexts/Flow/useFlow'; -import useTheme from '../../../../contexts/Theme/useTheme'; +import useTranslation from '../../../../../hooks/useTranslation'; +import useFlow from '../../../../../contexts/Flow/useFlow'; +import useTheme from '../../../../../contexts/Theme/useTheme'; /** * TOTP Sign-In Option Component. diff --git a/packages/react/src/components/presentation/SignIn/options/UsernamePassword.tsx b/packages/react/src/components/presentation/SignIn/non-component-driven/options/UsernamePassword.tsx similarity index 90% rename from packages/react/src/components/presentation/SignIn/options/UsernamePassword.tsx rename to packages/react/src/components/presentation/SignIn/non-component-driven/options/UsernamePassword.tsx index 469f4976..0733f798 100644 --- a/packages/react/src/components/presentation/SignIn/options/UsernamePassword.tsx +++ b/packages/react/src/components/presentation/SignIn/non-component-driven/options/UsernamePassword.tsx @@ -18,12 +18,12 @@ import {EmbeddedSignInFlowAuthenticator, EmbeddedSignInFlowAuthenticatorParamType, FieldType} from '@asgardeo/browser'; import {FC, useEffect} from 'react'; -import {createField} from '../../../factories/FieldFactory'; -import Button from '../../../primitives/Button/Button'; +import {createField} from '../../../../factories/FieldFactory'; +import Button from '../../../../primitives/Button/Button'; import {BaseSignInOptionProps} from './SignInOptionFactory'; -import useTranslation from '../../../../hooks/useTranslation'; -import useFlow from '../../../../contexts/Flow/useFlow'; -import useTheme from '../../../../contexts/Theme/useTheme'; +import useTranslation from '../../../../../hooks/useTranslation'; +import useFlow from '../../../../../contexts/Flow/useFlow'; +import useTheme from '../../../../../contexts/Theme/useTheme'; /** * Username Password Sign-In Option Component. diff --git a/packages/react/src/components/presentation/SignIn/types.ts b/packages/react/src/components/presentation/SignIn/non-component-driven/types.ts similarity index 100% rename from packages/react/src/components/presentation/SignIn/types.ts rename to packages/react/src/components/presentation/SignIn/non-component-driven/types.ts diff --git a/packages/react/src/components/presentation/SignUp/BaseSignUp.tsx b/packages/react/src/components/presentation/SignUp/BaseSignUp.tsx index 0121d8ad..f911d844 100644 --- a/packages/react/src/components/presentation/SignUp/BaseSignUp.tsx +++ b/packages/react/src/components/presentation/SignUp/BaseSignUp.tsx @@ -27,7 +27,7 @@ import { } from '@asgardeo/browser'; import {cx} from '@emotion/css'; import {FC, ReactElement, useEffect, useState, useCallback, useRef} from 'react'; -import {renderSignUpComponents} from './options/SignUpOptionFactory'; +import {renderSignUpComponents} from './SignUpOptionFactory'; import FlowProvider from '../../../contexts/Flow/FlowProvider'; import useFlow from '../../../contexts/Flow/useFlow'; import {useForm, FormField} from '../../../hooks/useForm'; diff --git a/packages/react/src/components/presentation/SignUp/options/SignUpOptionFactory.tsx b/packages/react/src/components/presentation/SignUp/SignUpOptionFactory.tsx similarity index 88% rename from packages/react/src/components/presentation/SignUp/options/SignUpOptionFactory.tsx rename to packages/react/src/components/presentation/SignUp/SignUpOptionFactory.tsx index c27c795e..691d62d3 100644 --- a/packages/react/src/components/presentation/SignUp/options/SignUpOptionFactory.tsx +++ b/packages/react/src/components/presentation/SignUp/SignUpOptionFactory.tsx @@ -18,24 +18,24 @@ import {EmbeddedFlowComponent, EmbeddedFlowComponentType, WithPreferences} from '@asgardeo/browser'; import {ReactElement} from 'react'; -import CheckboxInput from './CheckboxInput'; -import DateInput from './DateInput'; -import DividerComponent from './DividerComponent'; -import EmailInput from './EmailInput'; -import FormContainer from './FormContainer'; -import ImageComponent from './ImageComponent'; -import NumberInput from './NumberInput'; -import PasswordInput from './PasswordInput'; -import ButtonComponent from './SubmitButton'; -import TelephoneInput from './TelephoneInput'; -import TextInput from './TextInput'; -import Typography from './Typography'; -import GoogleButton from '../../options/GoogleButton'; -import GitHubButton from '../../options/GitHubButton'; -import MicrosoftButton from '../../options/MicrosoftButton'; -import LinkedInButton from '../../options/LinkedInButton'; -import FacebookButton from '../../options/FacebookButton'; -import SignInWithEthereumButton from '../../options/SignInWithEthereumButton'; +import CheckboxInput from '../../adapters/CheckboxInput'; +import DateInput from '../../adapters/DateInput'; +import DividerComponent from '../../adapters/DividerComponent'; +import EmailInput from '../../adapters/EmailInput'; +import FormContainer from '../../adapters/FormContainer'; +import ImageComponent from '../../adapters/ImageComponent'; +import NumberInput from '../../adapters/NumberInput'; +import PasswordInput from '../../adapters/PasswordInput'; +import ButtonComponent from '../../adapters/SubmitButton'; +import TelephoneInput from '../../adapters/TelephoneInput'; +import TextInput from '../../adapters/TextInput'; +import Typography from '../../adapters/Typography'; +import GoogleButton from '../../adapters/GoogleButton'; +import GitHubButton from '../../adapters/GitHubButton'; +import MicrosoftButton from '../../adapters/MicrosoftButton'; +import LinkedInButton from '../../adapters/LinkedInButton'; +import FacebookButton from '../../adapters/FacebookButton'; +import SignInWithEthereumButton from '../../adapters/SignInWithEthereumButton'; /** * Base props that all sign-up option components share. diff --git a/packages/react/src/components/presentation/UserDropdown/BaseUserDropdown.tsx b/packages/react/src/components/presentation/UserDropdown/BaseUserDropdown.tsx index 25166955..064e0581 100644 --- a/packages/react/src/components/presentation/UserDropdown/BaseUserDropdown.tsx +++ b/packages/react/src/components/presentation/UserDropdown/BaseUserDropdown.tsx @@ -40,6 +40,7 @@ import LogOut from '../../primitives/Icons/LogOut'; import User from '../../primitives/Icons/User'; import Typography from '../../primitives/Typography/Typography'; import useStyles from './BaseUserDropdown.styles'; +import getDisplayName from '../../../utils/getDisplayName'; interface MenuItem { href?: string; @@ -143,7 +144,7 @@ export const BaseUserDropdown: FC = ({ const {getReferenceProps, getFloatingProps} = useInteractions([click, dismiss, role]); - const defaultAttributeMappings = { + const defaultAttributeMappings: {[key: string]: string | string[] | undefined} = { picture: ['profile', 'profileUrl', 'picture', 'URL'], firstName: ['name.givenName', 'given_name'], lastName: ['name.familyName', 'family_name'], @@ -151,17 +152,9 @@ export const BaseUserDropdown: FC = ({ username: ['userName', 'username', 'user_name'], }; - const mergedMappings = {...defaultAttributeMappings, ...attributeMapping}; - - const getDisplayName = () => { - const firstName = getMappedUserProfileValue('firstName', mergedMappings, user); - const lastName = getMappedUserProfileValue('lastName', mergedMappings, user); - - if (firstName && lastName) { - return `${firstName} ${lastName}`; - } - - return getMappedUserProfileValue('username', mergedMappings, user) || ''; + const mergedMappings: {[key: string]: string | string[] | undefined} = { + ...defaultAttributeMappings, + ...attributeMapping, }; if (fallback && !user && !isLoading) { @@ -215,16 +208,16 @@ export const BaseUserDropdown: FC = ({ > {showTriggerLabel && ( - {getDisplayName()} + {getDisplayName(mergedMappings, user)} )} @@ -235,15 +228,20 @@ export const BaseUserDropdown: FC = ({
= ({ variant="body1" fontWeight="medium" > - {getDisplayName()} + {getDisplayName(mergedMappings, user)} = ({ const styles = useStyles(theme, colorScheme); - const defaultAttributeMappings = { + const defaultAttributeMappings: {[key: string]: string | string[] | undefined} = { picture: ['profile', 'profileUrl', 'picture', 'URL'], firstName: ['name.givenName', 'given_name'], lastName: ['name.familyName', 'family_name'], @@ -311,7 +312,10 @@ const BaseUserProfile: FC = ({ username: ['userName', 'username', 'user_name'], }; - const mergedMappings = {...defaultAttributeMappings, ...attributeMapping}; + const mergedMappings: {[key: string]: string | string[] | undefined} = { + ...defaultAttributeMappings, + ...attributeMapping, + }; const renderSchemaField = ( schema: Schema, @@ -574,17 +578,6 @@ const BaseUserProfile: FC = ({ ); }; - const getDisplayName = () => { - const firstName = getMappedUserProfileValue('firstName', mergedMappings, profile); - const lastName = getMappedUserProfileValue('lastName', mergedMappings, profile); - - if (firstName && lastName) { - return `${firstName} ${lastName}`; - } - - return getMappedUserProfileValue('username', mergedMappings, profile) || ''; - }; - if (!profile && !flattenedProfile) { return fallback; } @@ -636,9 +629,9 @@ const BaseUserProfile: FC = ({
diff --git a/packages/react/src/contexts/Asgardeo/AsgardeoContext.ts b/packages/react/src/contexts/Asgardeo/AsgardeoContext.ts index fe6964b2..2907b3ab 100644 --- a/packages/react/src/contexts/Asgardeo/AsgardeoContext.ts +++ b/packages/react/src/contexts/Asgardeo/AsgardeoContext.ts @@ -132,7 +132,8 @@ export type AsgardeoContextProps = { * @returns Promise resolving to boolean indicating success. */ reInitialize: (config: Partial) => Promise; -} & Pick; +} & Pick & + Pick; /** * Context object for managing the Authentication flow builder core context. @@ -144,6 +145,7 @@ const AsgardeoContext: Context = createContext {}, isInitialized: false, isLoading: true, isSignedIn: false, @@ -163,6 +165,7 @@ const AsgardeoContext: Context = createContext> = ({ setBaseUrl(_baseUrl); } - const user: User = await asgardeo.getUser({baseUrl: _baseUrl}); - const userProfile: UserProfile = await asgardeo.getUserProfile({baseUrl: _baseUrl}); - const currentOrganization: Organization = await asgardeo.getCurrentOrganization(); - const myOrganizations: Organization[] = await asgardeo.getMyOrganizations(); + // TEMPORARY: Asgardeo V2 platform does not support SCIM2, Organizations endpoints yet. + // Tracker: https://github.com/asgardeo/javascript/issues/212 + if (config.platform !== Platform.AsgardeoV2) { + setUser(extractUserClaimsFromIdToken(decodedToken)); + } else { + try { + const user: User = await asgardeo.getUser({baseUrl: _baseUrl}); + setUser(user); + } catch (error) { + // TODO: Add an error log. + } - // Update user data first - setUser(user); - setUserProfile(userProfile); - setCurrentOrganization(currentOrganization); - setMyOrganizations(myOrganizations); + try { + const userProfile: UserProfile = await asgardeo.getUserProfile({baseUrl: _baseUrl}); + setUserProfile(userProfile); + } catch (error) { + // TODO: Add an error log. + } + + try { + const currentOrganization: Organization = await asgardeo.getCurrentOrganization(); + setCurrentOrganization(currentOrganization); + } catch (error) { + // TODO: Add an error log. + } + + try { + const myOrganizations: Organization[] = await asgardeo.getMyOrganizations(); + setMyOrganizations(myOrganizations); + } catch (error) { + // TODO: Add an error log. + } + } // CRITICAL: Update sign-in status BEFORE setting loading to false // This prevents the race condition where ProtectedRoute sees isLoading=false but isSignedIn=false const currentSignInStatus = await asgardeo.isSignedIn(); - setIsSignedInSync(await asgardeo.isSignedIn()); + setIsSignedInSync(currentSignInStatus); } catch (error) { // TODO: Add an error log. } finally { @@ -319,6 +344,12 @@ const AsgardeoProvider: FC> = ({ // Auto-fetch branding when initialized and configured useEffect(() => { + // TEMPORARY: Asgardeo V2 platform does not support branding preference yet. + // Tracker: https://github.com/asgardeo/javascript/issues/212 + if (config.platform !== Platform.AsgardeoV2) { + return; + } + // Enable branding by default or when explicitly enabled const shouldFetchBranding = preferences?.theme?.inheritFromBranding !== false; @@ -416,6 +447,7 @@ const AsgardeoProvider: FC> = ({ signUpUrl, afterSignInUrl, baseUrl, + clearSession: asgardeo.clearSession.bind(asgardeo), getAccessToken: asgardeo.getAccessToken.bind(asgardeo), isInitialized: isInitializedSync, isLoading: isLoadingSync, @@ -435,6 +467,7 @@ const AsgardeoProvider: FC> = ({ getDecodedIdToken: asgardeo.getDecodedIdToken.bind(asgardeo), exchangeToken: asgardeo.exchangeToken.bind(asgardeo), syncSession, + platform: config?.platform, }), [ applicationId, diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index b740f8ad..01f68131 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -109,17 +109,17 @@ export * from './components/actions/SignUpButton/BaseSignUpButton'; export {default as SignUpButton} from './components/actions/SignUpButton/SignUpButton'; export * from './components/actions/SignUpButton/SignUpButton'; -export {default as SignedIn} from './components/control/SignedIn/SignedIn'; -export * from './components/control/SignedIn/SignedIn'; +export {default as SignedIn} from './components/control/SignedIn'; +export * from './components/control/SignedIn'; -export {default as SignedOut} from './components/control/SignedOut/SignedOut'; -export * from './components/control/SignedOut/SignedOut'; +export {default as SignedOut} from './components/control/SignedOut'; +export * from './components/control/SignedOut'; -export {default as Loading} from './components/control/Loading/Loading'; -export * from './components/control/Loading/Loading'; +export {default as Loading} from './components/control/Loading'; +export * from './components/control/Loading'; -export {default as BaseSignIn} from './components/presentation/SignIn/BaseSignIn'; -export * from './components/presentation/SignIn/BaseSignIn'; +export {default as BaseSignIn} from './components/presentation/SignIn/non-component-driven/BaseSignIn'; +export * from './components/presentation/SignIn/non-component-driven/BaseSignIn'; export {default as SignIn} from './components/presentation/SignIn/SignIn'; export * from './components/presentation/SignIn/SignIn'; @@ -131,20 +131,20 @@ export {default as SignUp} from './components/presentation/SignUp/SignUp'; export * from './components/presentation/SignUp/SignUp'; // Sign-In Options -export {default as IdentifierFirst} from './components/presentation/SignIn/options/IdentifierFirst'; -export {default as UsernamePassword} from './components/presentation/SignIn/options/UsernamePassword'; -export {default as GoogleButton} from './components/presentation/options/GoogleButton'; -export {default as GitHubButton} from './components/presentation/options/GitHubButton'; -export {default as MicrosoftButton} from './components/presentation/options/MicrosoftButton'; -export {default as FacebookButton} from './components/presentation/options/FacebookButton'; -export {default as LinkedInButton} from './components/presentation/options/LinkedInButton'; -export {default as SignInWithEthereumButton} from './components/presentation/options/SignInWithEthereumButton'; -export {default as EmailOtp} from './components/presentation/SignIn/options/EmailOtp'; -export {default as Totp} from './components/presentation/SignIn/options/Totp'; -export {default as SmsOtp} from './components/presentation/SignIn/options/SmsOtp'; -export {default as SocialButton} from './components/presentation/options/SocialButton'; -export {default as MultiOptionButton} from './components/presentation/SignIn/options/MultiOptionButton'; -export * from './components/presentation/SignIn/options/SignInOptionFactory'; +export {default as IdentifierFirst} from './components/presentation/SignIn/non-component-driven/options/IdentifierFirst'; +export {default as UsernamePassword} from './components/presentation/SignIn/non-component-driven/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/SignIn/non-component-driven/options/EmailOtp'; +export {default as Totp} from './components/presentation/SignIn/non-component-driven/options/Totp'; +export {default as SmsOtp} from './components/presentation/SignIn/non-component-driven/options/SmsOtp'; +export {default as SocialButton} from './components/presentation/SignIn/non-component-driven/options/SocialButton'; +export {default as MultiOptionButton} from './components/presentation/SignIn/non-component-driven/options/MultiOptionButton'; +export * from './components/presentation/SignIn/non-component-driven/options/SignInOptionFactory'; export {default as BaseUser} from './components/presentation/User/BaseUser'; export * from './components/presentation/User/BaseUser'; diff --git a/packages/react/src/utils/getDisplayName.ts b/packages/react/src/utils/getDisplayName.ts new file mode 100644 index 00000000..c07f60ee --- /dev/null +++ b/packages/react/src/utils/getDisplayName.ts @@ -0,0 +1,65 @@ +/** + * 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 {User} from '@asgardeo/browser'; +import getMappedUserProfileValue from './getMappedUserProfileValue'; + +/** + * Get the display name of a user by mapping their profile attributes. + * + * @param mergedMappings - The merged attribute mappings. + * @param user - The user object containing profile information. + * + * @example + * ```ts + * const mergedMappings = { + * firstName: ['name.givenName', 'given_name'], + * lastName: ['name.familyName', 'family_name'], + * username: ['userName', 'username', 'user_name'], + * email: ['emails[0].value', 'email'], + * name: ['name', 'fullName'], + * }; + * + * const user: User = { + * id: '1', + * name: 'John Doe', + * email: 'john.doe@example.com', + * }; + * + * const displayName = getDisplayName(mergedMappings, user); + * ``` + * + * @returns The display name of the user. + */ +const getDisplayName = (mergedMappings: {[key: string]: string | string[] | undefined}, user: User): string => { + const firstName = getMappedUserProfileValue('firstName', mergedMappings, user); + const lastName = getMappedUserProfileValue('lastName', mergedMappings, user); + + if (firstName && lastName) { + return `${firstName} ${lastName}`; + } + + return ( + getMappedUserProfileValue('username', mergedMappings, user) || + getMappedUserProfileValue('email', mergedMappings, user) || + getMappedUserProfileValue('name', mergedMappings, user) || + 'User' + ); +}; + +export default getDisplayName;