diff --git a/.changeset/real-dolls-build.md b/.changeset/real-dolls-build.md new file mode 100644 index 00000000..4b21f979 --- /dev/null +++ b/.changeset/real-dolls-build.md @@ -0,0 +1,5 @@ +--- +'@asgardeo/react': minor +--- + +Introduce `SignUp` & B2B components. diff --git a/.gitignore b/.gitignore index cf3dd4a4..9b89913d 100644 --- a/.gitignore +++ b/.gitignore @@ -43,4 +43,6 @@ Thumbs.db # Environment files *.env -.env* \ No newline at end of file +.env* +.cursor/rules/nx-rules.mdc +.github/instructions/nx.instructions.md diff --git a/.vscode/settings.json b/.vscode/settings.json index 1ed1b3fa..8c6c7b13 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,22 +1,13 @@ { - "conventionalCommits.scopes": [ - "workspace", - "core", - "react", - "auth-components", - "sample-app", - "docs" - ], - "editor.codeActionsOnSave": { - "source.fixAll.eslint": "explicit" - }, - "css.validate": false, - "less.validate": false, - "scss.validate": false, - "stylelint.validate": [ - "css", - "scss" - ], - "typescript.tsdk": "node_modules/typescript/lib", - "editor.formatOnSave": true + "conventionalCommits.scopes": ["workspace", "core", "react", "auth-components", "sample-app", "docs"], + "editor.codeActionsOnSave": { + "source.fixAll.eslint": "explicit" + }, + "css.validate": false, + "less.validate": false, + "scss.validate": false, + "stylelint.validate": ["css", "scss"], + "typescript.tsdk": "node_modules/typescript/lib", + "editor.formatOnSave": true, + "nxConsole.generateAiAgentRules": true } diff --git a/ERROR_CODES.md b/ERROR_CODES.md new file mode 100644 index 00000000..c277aa6e --- /dev/null +++ b/ERROR_CODES.md @@ -0,0 +1,154 @@ +# Error Code Convention + +## Overview +This document defines the error code convention used throughout the Asgardeo JavaScript SDK to ensure consistency and maintainability. + +## Format +Error codes follow this format: +``` +[{packageName}-]{functionName}-{ErrorCategory}-{SequentialNumber} +``` + +### Components + +#### 1. Package Name (Optional) +- Use when the function might exist in multiple packages or when disambiguation is needed +- Use the package identifier (e.g., "javascript", "react", "node") +- Examples: `javascript-`, `react-`, `node-` + +#### 2. Function Name +- Use the exact function name as defined in the code +- Use camelCase format matching the function declaration +- Examples: `getUserInfo`, `executeEmbeddedSignUpFlow`, `initializeEmbeddedSignInFlow` + +#### 3. Error Category +Categories represent the type of error: + +- **ValidationError**: Input validation failures, missing required parameters, invalid parameter values +- **ResponseError**: HTTP response errors, network failures, server errors +- **ConfigurationError**: Configuration-related errors, missing configuration, invalid settings +- **AuthenticationError**: Authentication-specific errors, token issues, credential problems +- **AuthorizationError**: Authorization-specific errors, permission denied, access control +- **NetworkError**: Network connectivity issues, timeout errors +- **ParseError**: JSON parsing errors, response format issues + +#### 4. Sequential Number +- Three-digit zero-padded format: `001`, `002`, `003`, etc. +- Start from `001` for each function +- Increment sequentially within each function +- Group by error category for readability + +## Numbering Strategy + +For each function, allocate number ranges by category: +- **001-099**: ValidationError +- **100-199**: ResponseError +- **200-299**: ConfigurationError +- **300-399**: AuthenticationError +- **400-499**: AuthorizationError +- **500-599**: NetworkError +- **600-699**: ParseError + +## Package Prefix Guidelines + +Use the package prefix when: +1. **Multi-package scenarios**: When the same function name exists across different packages +2. **Public APIs**: For functions that are part of the public API and might be referenced externally +3. **Complex projects**: In large codebases where disambiguation aids debugging and maintenance + +Examples of when to use prefixes: +- `javascript-executeEmbeddedSignUpFlow-ValidationError-001` (public API function) +- `react-useAuth-ConfigurationError-201` (React-specific hook) +- `node-createServer-NetworkError-501` (Node.js-specific function) + +## Examples + +### With Package Prefix (Recommended for Public APIs) +```typescript +// executeEmbeddedSignUpFlow Function (JavaScript package) +'javascript-executeEmbeddedSignUpFlow-ValidationError-001' // Missing payload +'javascript-executeEmbeddedSignUpFlow-ValidationError-002' // Invalid flowType +'javascript-executeEmbeddedSignUpFlow-ResponseError-100' // HTTP error response +``` + +### Without Package Prefix (Internal/Simple Functions) +```typescript +// getUserInfo Function (internal utility) +'getUserInfo-ValidationError-001' // Invalid URL +'getUserInfo-ValidationError-002' // Missing access token +'getUserInfo-ResponseError-100' // HTTP error response +'getUserInfo-ResponseError-101' // Invalid response format +``` + +## Implementation Guidelines + +1. **Consistency**: Always use the exact function name in error codes +2. **Package Prefix**: Use package prefixes for public APIs and when disambiguation is needed +3. **Documentation**: Document each error code with clear description +4. **Categorization**: Choose the most appropriate category for each error +5. **Numbering**: Use the range-based numbering system for better organization +6. **Future-proofing**: Leave gaps in numbering for future error codes + +## Current Implementation Status + +### Updated Functions (Following New Convention) +- ✅ `executeEmbeddedSignUpFlow` - Uses `javascript-` prefix with range-based numbering + +### Functions Needing Updates +- ⏳ `getUserInfo` - Currently uses simple format, needs prefix evaluation +- ⏳ `initializeEmbeddedSignInFlow` - Currently uses simple format, needs prefix evaluation +- ⏳ `executeEmbeddedSignInFlow` - Currently uses simple format, needs prefix evaluation + +## Migration Notes + +When updating existing error codes: +1. **Evaluate prefix necessity**: Determine if the function needs a package prefix +2. **Update numbering**: Move to range-based numbering (ValidationError: 001-099, ResponseError: 100-199, etc.) +3. **Update tests**: Ensure all tests use the new error codes +4. **Update documentation**: Document the new error codes +5. **Consider backward compatibility**: If codes are exposed in public APIs, plan migration strategy + +## Example Migration + +### Before (Old Convention) +```typescript +'getUserInfo-ValidationError-001' +'getUserInfo-ResponseError-001' +``` + +### After (New Convention) +```typescript +// Option 1: With prefix (for public API) +'javascript-getUserInfo-ValidationError-001' +'javascript-getUserInfo-ResponseError-100' + +// Option 2: Without prefix (for internal use) +'getUserInfo-ValidationError-001' +'getUserInfo-ResponseError-100' +``` + +## Current Error Code Registry + +### executeEmbeddedSignUpFlow (Updated - New Convention) +- `javascript-executeEmbeddedSignUpFlow-ValidationError-001` - Missing payload +- `javascript-executeEmbeddedSignUpFlow-ValidationError-002` - Invalid flowType +- `javascript-executeEmbeddedSignUpFlow-ResponseError-100` - HTTP error response + +### getUserInfo (Legacy Format) +- `getUserInfo-ValidationError-001` - Invalid endpoint URL +- `getUserInfo-ResponseError-001` - Failed to fetch user info + +### initializeEmbeddedSignInFlow (Legacy Format) +- `initializeEmbeddedSignInFlow-ValidationError-002` - Missing authorization payload +- `initializeEmbeddedSignInFlow-ResponseError-001` - Authorization request failed + +### executeEmbeddedSignInFlow (Legacy Format) +- `executeEmbeddedSignInFlow-ValidationError-002` - Missing required parameter +- `initializeEmbeddedSignInFlow-ResponseError-001` - Response error (Note: incorrect function name reference) + +## Recommended Actions + +1. **Standardize numbering**: Update legacy functions to use range-based numbering +2. **Fix inconsistencies**: Correct the error code in `executeEmbeddedSignInFlow` that references the wrong function +3. **Add prefixes**: Evaluate which functions need package prefixes based on their public API status +4. **Document usage**: Add inline documentation in each file listing the error codes used diff --git a/packages/browser/src/__legacy__/client.ts b/packages/browser/src/__legacy__/client.ts index 11190d60..f25e14dd 100755 --- a/packages/browser/src/__legacy__/client.ts +++ b/packages/browser/src/__legacy__/client.ts @@ -23,7 +23,7 @@ import { IsomorphicCrypto, TokenExchangeRequestConfig, StorageManager, - IdTokenPayload, + IdToken, OIDCEndpoints, User, } from '@asgardeo/javascript'; @@ -737,7 +737,7 @@ export class AsgardeoSPAClient { * * @preserve */ - public async getDecodedIdToken(): Promise { + public async getDecodedIdToken(): Promise { await this._validateMethod(); return this._client?.getDecodedIdToken(); diff --git a/packages/browser/src/__legacy__/clients/main-thread-client.ts b/packages/browser/src/__legacy__/clients/main-thread-client.ts index 0abfa445..29425620 100755 --- a/packages/browser/src/__legacy__/clients/main-thread-client.ts +++ b/packages/browser/src/__legacy__/clients/main-thread-client.ts @@ -1,7 +1,7 @@ /** - * Copyright (c) 2020, WSO2 Inc. (http://www.wso2.org) All Rights Reserved. + * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com). * - * WSO2 Inc. licenses this file to you under the Apache License, + * 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 @@ -22,19 +22,19 @@ import { User, IsomorphicCrypto, StorageManager, - IdTokenPayload, + IdToken, ExtendedAuthorizeRequestUrlParams, OIDCEndpoints, OIDCRequestConstants, SessionData, Storage, extractPkceStorageKeyFromState, - initializeApplicationNativeAuthentication, - handleApplicationNativeAuthentication, - TemporaryStore, + initializeEmbeddedSignInFlow, } from '@asgardeo/javascript'; import {SILENT_SIGN_IN_STATE, TOKEN_REQUEST_CONFIG_KEY} from '../constants'; -import {AuthenticationHelper, SPAHelper, SessionManagementHelper} from '../helpers'; +import {AuthenticationHelper} from '../helpers/authentication-helper'; +import {SessionManagementHelper} from '../helpers/session-management-helper'; +import {SPAHelper} from '../helpers/spa-helper'; import {HttpClient, HttpClientInstance} from '../http-client'; import {HttpError, HttpRequestConfig, HttpResponse, MainThreadClientConfig, MainThreadClientInterface} from '../models'; import {SPACustomGrantConfig} from '../models/request-custom-grant'; @@ -72,9 +72,7 @@ export const MainThreadClient = async ( const _spaHelper = new SPAHelper(_authenticationClient); const _dataLayer = _authenticationClient.getStorageManager(); const _sessionManagementHelper = await SessionManagementHelper( - async () => { - return _authenticationClient.getSignOutUrl(); - }, + async () => _authenticationClient.getSignOutUrl(), (config.storage as BrowserStorage) ?? BrowserStorage.SessionStorage, (sessionState: string) => _dataLayer.setSessionDataParameter( @@ -114,29 +112,25 @@ export const MainThreadClient = async ( _httpErrorCallback = callback; }; - const httpRequest = async (requestConfig: HttpRequestConfig): Promise => { - return await _authenticationHelper.httpRequest( + const httpRequest = async (requestConfig: HttpRequestConfig): Promise => + _authenticationHelper.httpRequest( _httpClient, requestConfig, _isHttpHandlerEnabled, _httpErrorCallback, _httpFinishCallback, ); - }; - const httpRequestAll = async (requestConfigs: HttpRequestConfig[]): Promise => { - return await _authenticationHelper.httpRequestAll( + const httpRequestAll = async (requestConfigs: HttpRequestConfig[]): Promise => + _authenticationHelper.httpRequestAll( requestConfigs, _httpClient, _isHttpHandlerEnabled, _httpErrorCallback, _httpFinishCallback, ); - }; - const getHttpClient = (): HttpClientInstance => { - return _httpClient; - }; + const getHttpClient = (): HttpClientInstance => _httpClient; const enableHttpHandler = (): boolean => { _authenticationHelper.enableHttpHandler(_httpClient); @@ -165,15 +159,13 @@ export const MainThreadClient = async ( ); }; - const shouldStopAuthn = async (): Promise => { - return await _sessionManagementHelper.receivePromptNoneResponse(async (sessionState: string | null) => { + const shouldStopAuthn = async (): Promise => + _sessionManagementHelper.receivePromptNoneResponse(async (sessionState: string | null) => { await _dataLayer.setSessionDataParameter( OIDCRequestConstants.Params.SESSION_STATE as keyof SessionData, sessionState ?? '', ); - return; }); - }; const setSessionStatus = async (sessionStatus: string): Promise => { await _dataLayer.setSessionStatus(sessionStatus); @@ -188,88 +180,81 @@ export const MainThreadClient = async ( params: Record; }, ): Promise => { - if (signInConfig['flow']) { - return handleApplicationNativeAuthentication({ - url: signInConfig['flow']['requestConfig']['url'], - payload: signInConfig['flow']['payload'], - }); - } - const basicUserInfo = await _authenticationHelper.handleSignIn(shouldStopAuthn, checkSession, undefined); if (basicUserInfo) { return basicUserInfo; + } + let resolvedAuthorizationCode: string; + let resolvedSessionState: string; + let resolvedState: string; + let resolvedTokenRequestConfig: { + params: Record; + } = {params: {}}; + + if (config?.responseMode === 'form_post' && authorizationCode) { + resolvedAuthorizationCode = authorizationCode; + resolvedSessionState = sessionState ?? ''; + resolvedState = state ?? ''; } else { - let resolvedAuthorizationCode: string; - let resolvedSessionState: string; - let resolvedState: string; - let resolvedTokenRequestConfig: { - params: Record; - } = {params: {}}; - - if (config?.responseMode === 'form_post' && authorizationCode) { - resolvedAuthorizationCode = authorizationCode; - resolvedSessionState = sessionState ?? ''; - resolvedState = state ?? ''; - } else { - resolvedAuthorizationCode = - new URL(window.location.href).searchParams.get(OIDCRequestConstants.Params.AUTHORIZATION_CODE) ?? ''; - resolvedSessionState = - new URL(window.location.href).searchParams.get(OIDCRequestConstants.Params.SESSION_STATE) ?? ''; - resolvedState = new URL(window.location.href).searchParams.get(OIDCRequestConstants.Params.STATE) ?? ''; - - SPAUtils.removeAuthorizationCode(); + resolvedAuthorizationCode = + new URL(window.location.href).searchParams.get(OIDCRequestConstants.Params.AUTHORIZATION_CODE) ?? ''; + resolvedSessionState = + new URL(window.location.href).searchParams.get(OIDCRequestConstants.Params.SESSION_STATE) ?? ''; + resolvedState = new URL(window.location.href).searchParams.get(OIDCRequestConstants.Params.STATE) ?? ''; + + SPAUtils.removeAuthorizationCode(); + } + + if (resolvedAuthorizationCode && resolvedState) { + setSessionStatus('true'); + const storedTokenRequestConfig = await _dataLayer.getTemporaryDataParameter(TOKEN_REQUEST_CONFIG_KEY); + if (storedTokenRequestConfig && typeof storedTokenRequestConfig === 'string') { + resolvedTokenRequestConfig = JSON.parse(storedTokenRequestConfig); + } + return requestAccessToken( + resolvedAuthorizationCode, + resolvedSessionState, + resolvedState, + resolvedTokenRequestConfig, + ); + } + + return _authenticationClient.getSignInUrl(signInConfig).then(async (url: string) => { + if (config.storage === BrowserStorage.BrowserMemory && config.enablePKCE) { + const pkceKey: string = extractPkceStorageKeyFromState(resolvedState); + + SPAUtils.setPKCE(pkceKey, (await _authenticationClient.getPKCECode(resolvedState)) as string); } - if (resolvedAuthorizationCode && resolvedState) { - setSessionStatus('true'); - const storedTokenRequestConfig = await _dataLayer.getTemporaryDataParameter(TOKEN_REQUEST_CONFIG_KEY); - if (storedTokenRequestConfig && typeof storedTokenRequestConfig === 'string') { - resolvedTokenRequestConfig = JSON.parse(storedTokenRequestConfig); - } - return requestAccessToken( - resolvedAuthorizationCode, - resolvedSessionState, - resolvedState, - resolvedTokenRequestConfig, - ); + if (tokenRequestConfig) { + _dataLayer.setTemporaryDataParameter(TOKEN_REQUEST_CONFIG_KEY, JSON.stringify(tokenRequestConfig)); } - return _authenticationClient.getSignInUrl(signInConfig).then(async (url: string) => { - if (config.storage === BrowserStorage.BrowserMemory && config.enablePKCE) { - const pkceKey: string = extractPkceStorageKeyFromState(resolvedState); - - SPAUtils.setPKCE(pkceKey, (await _authenticationClient.getPKCECode(resolvedState)) as string); - } - - if (tokenRequestConfig) { - _dataLayer.setTemporaryDataParameter(TOKEN_REQUEST_CONFIG_KEY, JSON.stringify(tokenRequestConfig)); - } - - if (signInConfig['response_mode'] === 'direct') { - const authorizeUrl: URL = new URL(url); - - return initializeApplicationNativeAuthentication({ - url: `${authorizeUrl.origin}${authorizeUrl.pathname}`, - payload: Object.fromEntries(authorizeUrl.searchParams.entries()), - }); - } else { - location.href = url; - } - - await SPAUtils.waitTillPageRedirect(); - - return Promise.resolve({ - allowedScopes: '', - displayName: '', - email: '', - sessionState: '', - sub: '', - tenantDomain: '', - username: '', + // FIXME: This is a workaround to handle the `response_mode` as `direct` in the sign-in config. + if (signInConfig && signInConfig['response_mode'] === 'direct') { + const authorizeUrl: URL = new URL(url); + + return initializeEmbeddedSignInFlow({ + url: `${authorizeUrl.origin}${authorizeUrl.pathname}`, + payload: Object.fromEntries(authorizeUrl.searchParams.entries()), }); + } + + location.href = url; + + await SPAUtils.waitTillPageRedirect(); + + return Promise.resolve({ + allowedScopes: '', + displayName: '', + email: '', + sessionState: '', + sub: '', + tenantDomain: '', + username: '', }); - } + }); }; const signOut = async (): Promise => { @@ -297,9 +282,8 @@ export const MainThreadClient = async ( } }; - const exchangeToken = async (config: SPACustomGrantConfig): Promise => { - return await _authenticationHelper.exchangeToken(config, enableRetrievingSignOutURLFromSession); - }; + const exchangeToken = async (config: SPACustomGrantConfig): Promise => + _authenticationHelper.exchangeToken(config, enableRetrievingSignOutURLFromSession); const refreshAccessToken = async (): Promise => { try { @@ -330,8 +314,8 @@ export const MainThreadClient = async ( tokenRequestConfig?: { params: Record; }, - ): Promise => { - return await _authenticationHelper.requestAccessToken( + ): Promise => + _authenticationHelper.requestAccessToken( resolvedAuthorizationCode, resolvedSessionState, checkSession, @@ -339,7 +323,6 @@ export const MainThreadClient = async ( resolvedState, tokenRequestConfig, ); - }; const constructSilentSignInUrl = async (additionalParams: Record = {}): Promise => { const config = await _dataLayer.getConfigData(); @@ -376,55 +359,36 @@ export const MainThreadClient = async ( const trySignInSilently = async ( additionalParams?: Record, tokenRequestConfig?: {params: Record}, - ): Promise => { - return await _authenticationHelper.trySignInSilently( + ): Promise => + _authenticationHelper.trySignInSilently( constructSilentSignInUrl, requestAccessToken, _sessionManagementHelper, additionalParams, tokenRequestConfig, ); - }; - const getUser = async (): Promise => { - return _authenticationHelper.getUser(); - }; + const getUser = async (): Promise => _authenticationHelper.getUser(); - const getDecodedIdToken = async (): Promise => { - return _authenticationHelper.getDecodedIdToken(); - }; + const getDecodedIdToken = async (): Promise => _authenticationHelper.getDecodedIdToken(); - const getCrypto = async (): Promise => { - return _authenticationHelper.getCrypto(); - }; + const getCrypto = async (): Promise => _authenticationHelper.getCrypto(); - const getIdToken = async (): Promise => { - return _authenticationHelper.getIdToken(); - }; + const getIdToken = async (): Promise => _authenticationHelper.getIdToken(); - const getOpenIDProviderEndpoints = async (): Promise => { - return _authenticationHelper.getOpenIDProviderEndpoints(); - }; + const getOpenIDProviderEndpoints = async (): Promise => + _authenticationHelper.getOpenIDProviderEndpoints(); - const getAccessToken = async (): Promise => { - return _authenticationHelper.getAccessToken(); - }; + const getAccessToken = async (): Promise => _authenticationHelper.getAccessToken(); - const getStorageManager = async (): Promise> => { - return _authenticationHelper.getStorageManager(); - }; + const getStorageManager = async (): Promise> => + _authenticationHelper.getStorageManager(); - const getConfigData = async (): Promise> => { - return await _dataLayer.getConfigData(); - }; + const getConfigData = async (): Promise> => _dataLayer.getConfigData(); - const isSignedIn = async (): Promise => { - return _authenticationHelper.isSignedIn(); - }; + const isSignedIn = async (): Promise => _authenticationHelper.isSignedIn(); - const isSessionActive = async (): Promise => { - return (await _dataLayer.getSessionStatus()) === 'true'; - }; + const isSessionActive = async (): Promise => (await _dataLayer.getSessionStatus()) === 'true'; const reInitialize = async (newConfig: Partial>): Promise => { const existingConfig = await _dataLayer.getConfigData(); diff --git a/packages/browser/src/__legacy__/clients/web-worker-client.ts b/packages/browser/src/__legacy__/clients/web-worker-client.ts index 84444cbd..43ff679f 100755 --- a/packages/browser/src/__legacy__/clients/web-worker-client.ts +++ b/packages/browser/src/__legacy__/clients/web-worker-client.ts @@ -23,7 +23,7 @@ import { User, IsomorphicCrypto, TokenExchangeRequestConfig, - IdTokenPayload, + IdToken, ExtendedAuthorizeRequestUrlParams, OIDCEndpoints, OIDCRequestConstants, @@ -706,12 +706,12 @@ export const WebWorkerClient = async ( }); }; - const getDecodedIdToken = (): Promise => { + const getDecodedIdToken = (): Promise => { const message: Message = { type: GET_DECODED_ID_TOKEN, }; - return communicate(message) + return communicate(message) .then(response => { return Promise.resolve(response); }) @@ -720,12 +720,12 @@ export const WebWorkerClient = async ( }); }; - const getDecodedIDPIDToken = (): Promise => { + const getDecodedIDPIDToken = (): Promise => { const message: Message = { type: GET_DECODED_IDP_ID_TOKEN, }; - return communicate(message) + return communicate(message) .then(response => { return Promise.resolve(response); }) diff --git a/packages/browser/src/__legacy__/helpers/authentication-helper.ts b/packages/browser/src/__legacy__/helpers/authentication-helper.ts index 0763ba27..f04d692f 100644 --- a/packages/browser/src/__legacy__/helpers/authentication-helper.ts +++ b/packages/browser/src/__legacy__/helpers/authentication-helper.ts @@ -24,7 +24,7 @@ import { IsomorphicCrypto, TokenExchangeRequestConfig, StorageManager, - IdTokenPayload, + IdToken, ExtendedAuthorizeRequestUrlParams, OIDCEndpoints, TokenResponse, @@ -659,11 +659,11 @@ export class AuthenticationHelper { + public async getDecodedIdToken(): Promise { return this._authenticationClient.getDecodedIdToken(); } - public async getDecodedIDPIDToken(): Promise { + public async getDecodedIDPIDToken(): Promise { return this._authenticationClient.getDecodedIdToken(); } diff --git a/packages/browser/src/__legacy__/models/client.ts b/packages/browser/src/__legacy__/models/client.ts index 3eac3388..1a766886 100755 --- a/packages/browser/src/__legacy__/models/client.ts +++ b/packages/browser/src/__legacy__/models/client.ts @@ -22,7 +22,7 @@ import { IsomorphicCrypto, TokenExchangeRequestConfig, StorageManager, - IdTokenPayload, + IdToken, OIDCEndpoints, } from '@asgardeo/javascript'; import { @@ -59,7 +59,7 @@ export interface MainThreadClientInterface { refreshAccessToken(): Promise; revokeAccessToken(): Promise; getUser(): Promise; - getDecodedIdToken(): Promise; + getDecodedIdToken(): Promise; getCrypto(): Promise; getConfigData(): Promise>; getIdToken(): Promise; @@ -96,8 +96,8 @@ export interface WebWorkerClientInterface { getOpenIDProviderEndpoints(): Promise; getUser(): Promise; getConfigData(): Promise>; - getDecodedIdToken(): Promise; - getDecodedIDPIDToken(): Promise; + getDecodedIdToken(): Promise; + getDecodedIDPIDToken(): Promise; getCrypto(): Promise; getIdToken(): Promise; isSignedIn(): Promise; diff --git a/packages/browser/src/__legacy__/models/web-worker.ts b/packages/browser/src/__legacy__/models/web-worker.ts index 7abb33b6..97c5c1c6 100755 --- a/packages/browser/src/__legacy__/models/web-worker.ts +++ b/packages/browser/src/__legacy__/models/web-worker.ts @@ -22,7 +22,7 @@ import { User, IsomorphicCrypto, TokenExchangeRequestConfig, - IdTokenPayload, + IdToken, OIDCEndpoints, } from '@asgardeo/javascript'; import {HttpRequestConfig, HttpResponse, Message} from '.'; @@ -53,8 +53,8 @@ export interface WebWorkerCoreInterface { refreshAccessToken(): Promise; revokeAccessToken(): Promise; getUser(): Promise; - getDecodedIdToken(): Promise; - getDecodedIDPIDToken(): Promise; + getDecodedIdToken(): Promise; + getDecodedIDPIDToken(): Promise; getCrypto(): Promise; getIdToken(): Promise; getOpenIDProviderEndpoints(): Promise; diff --git a/packages/browser/src/__legacy__/worker/worker-core.ts b/packages/browser/src/__legacy__/worker/worker-core.ts index ec1ddc12..2444e4e6 100755 --- a/packages/browser/src/__legacy__/worker/worker-core.ts +++ b/packages/browser/src/__legacy__/worker/worker-core.ts @@ -23,7 +23,7 @@ import { User, IsomorphicCrypto, TokenExchangeRequestConfig, - IdTokenPayload, + IdToken, OIDCEndpoints, OIDCRequestConstants, SessionData, @@ -166,7 +166,7 @@ export const WebWorkerCore = async ( return _authenticationHelper.getUser(); }; - const getDecodedIdToken = async (): Promise => { + const getDecodedIdToken = async (): Promise => { return _authenticationHelper.getDecodedIdToken(); }; @@ -174,7 +174,7 @@ export const WebWorkerCore = async ( return _authenticationHelper.getCrypto(); }; - const getDecodedIDPIDToken = async (): Promise => { + const getDecodedIDPIDToken = async (): Promise => { return _authenticationHelper.getDecodedIDPIDToken(); }; diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index 080f532a..a3c19e5d 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -43,12 +43,9 @@ export * from './__legacy__/helpers/spa-helper'; // worker receiver export * from './__legacy__/worker/worker-receiver'; -export {default as StyleConstants} from './constants/StyleConstants'; - export {AsgardeoBrowserConfig} from './models/config'; export {default as hasAuthParamsInUrl} from './utils/hasAuthParamsInUrl'; -export {default as withVendorCSSClassPrefix} from './utils/withVendorCSSClassPrefix'; export {default as AsgardeoBrowserClient} from './AsgardeoBrowserClient'; diff --git a/packages/express/src/__legacy__/client.ts b/packages/express/src/__legacy__/client.ts index ebd79702..fc196dac 100644 --- a/packages/express/src/__legacy__/client.ts +++ b/packages/express/src/__legacy__/client.ts @@ -24,7 +24,7 @@ import { Storage, User, OIDCEndpoints, - IdTokenPayload, + IdToken, TokenExchangeRequestConfig, AsgardeoAuthException, Logger, @@ -173,7 +173,7 @@ export class AsgardeoExpressClient { return this._authClient.getOpenIDProviderEndpoints(); } - public async getDecodedIdToken(userId?: string): Promise { + public async getDecodedIdToken(userId?: string): Promise { return this._authClient.getDecodedIdToken(userId); } @@ -181,10 +181,7 @@ export class AsgardeoExpressClient { return this._authClient.getAccessToken(userId); } - public async exchangeToken( - config: TokenExchangeRequestConfig, - userId?: string, - ): Promise { + public async exchangeToken(config: TokenExchangeRequestConfig, userId?: string): Promise { return this._authClient.exchangeToken(config, userId); } diff --git a/packages/javascript/src/AsgardeoJavaScriptClient.ts b/packages/javascript/src/AsgardeoJavaScriptClient.ts index 5d46f7b1..653fae7b 100644 --- a/packages/javascript/src/AsgardeoJavaScriptClient.ts +++ b/packages/javascript/src/AsgardeoJavaScriptClient.ts @@ -16,9 +16,12 @@ * under the License. */ -import {AsgardeoClient, SignInOptions, SignOutOptions} from './models/client'; -import {User, UserProfile} from './models/user'; +import {AsgardeoClient, SignInOptions, SignOutOptions, SignUpOptions} from './models/client'; import {Config} from './models/config'; +import {EmbeddedFlowExecuteRequestPayload, EmbeddedFlowExecuteResponse} from './models/embedded-flow'; +import {EmbeddedSignInFlowHandleRequestPayload} from './models/embedded-signin-flow'; +import {Organization} from './models/organization'; +import {User, UserProfile} from './models/user'; /** * Base class for implementing Asgardeo clients. @@ -27,70 +30,44 @@ import {Config} from './models/config'; * @typeParam T - Configuration type that extends Config. */ abstract class AsgardeoJavaScriptClient implements AsgardeoClient { - /** - * Initializes the authentication client with provided configuration. - * - * @param config - SDK Client instance configuration options. - * @returns Promise resolving to boolean indicating success. - */ + abstract switchOrganization(organization: Organization): Promise; + abstract initialize(config: T): Promise; - /** - * Gets user information from the session. - * - * @returns User object containing user details. - */ abstract getUser(): Promise; + abstract getOrganizations(): Promise; + + abstract getCurrentOrganization(): Promise; + abstract getUserProfile(): Promise; - /** - * Checks if the client is currently loading. - * This can be used to determine if the client is in the process of initializing or fetching user data. - * - * @returns Boolean indicating if the client is loading. - */ abstract isLoading(): boolean; - /** - * Checks if a user is signed in. - * FIXME: Check if this should return a boolean or a Promise. - * - * @returns Promise resolving to boolean indicating sign-in status. - */ abstract isSignedIn(): Promise; - /** - * Initiates the sign-in process for the user. - * - * @param options - Optional sign-in options like additional parameters to be sent in the authorize request, etc. - * @returns Promise resolving the user upon successful sign in. - */ - abstract signIn(options?: SignInOptions): Promise; + abstract signIn( + options?: SignInOptions, + sessionId?: string, + onSignInSuccess?: (afterSignInUrl: string) => void, + ): Promise; + abstract signIn( + payload: EmbeddedSignInFlowHandleRequestPayload, + request: Request, + sessionId?: string, + onSignInSuccess?: (afterSignInUrl: string) => void, + ): Promise; - /** - * Signs out the currently signed-in user. - * - * @param options - Optional sign-out options like additional parameters to be sent in the sign-out request, etc. - * @param afterSignOut - Callback function to be executed after sign-out is complete. - * @returns A promise that resolves to true if sign-out is successful - */ abstract signOut(options?: SignOutOptions, afterSignOut?: (redirectUrl: string) => void): Promise; - - /** - * Signs out the currently signed-in user with an optional session ID. - * - * @param options - Optional sign-out options like additional parameters to be sent in the sign-out request, etc. - * @param sessionId - Optional session ID to be used for sign-out. - * This can be useful in scenarios where multiple sessions are managed. - * @param afterSignOut - Callback function to be executed after sign-out is complete. - * @returns A promise that resolves to true if sign-out is successful - */ abstract signOut( options?: SignOutOptions, sessionId?: string, afterSignOut?: (redirectUrl: string) => void, ): Promise; + + abstract signUp(options?: SignUpOptions): Promise; + abstract signUp(payload: EmbeddedFlowExecuteRequestPayload): Promise; + abstract signUp(payload?: unknown): Promise | Promise; } export default AsgardeoJavaScriptClient; diff --git a/packages/javascript/src/IsomorphicCrypto.ts b/packages/javascript/src/IsomorphicCrypto.ts index 09cfc567..2ff20aed 100644 --- a/packages/javascript/src/IsomorphicCrypto.ts +++ b/packages/javascript/src/IsomorphicCrypto.ts @@ -18,7 +18,7 @@ import {AsgardeoAuthException} from './errors/exception'; import {Crypto, JWKInterface} from './models/crypto'; -import {IdTokenPayload} from './models/token'; +import {IdToken} from './models/token'; import TokenConstants from './constants/TokenConstants'; export class IsomorphicCrypto { @@ -136,10 +136,10 @@ export class IsomorphicCrypto { * * @throws */ - public decodeIdToken(idToken: string): IdTokenPayload { + public decodeIdToken(idToken: string): IdToken { try { const utf8String: string = this._cryptoUtils.base64URLDecode(idToken?.split('.')[1]); - const payload: IdTokenPayload = JSON.parse(utf8String); + const payload: IdToken = JSON.parse(utf8String); return payload; } catch (error: any) { diff --git a/packages/javascript/src/StorageManager.ts b/packages/javascript/src/StorageManager.ts index 50f8df88..b7130b5d 100644 --- a/packages/javascript/src/StorageManager.ts +++ b/packages/javascript/src/StorageManager.ts @@ -112,8 +112,8 @@ class StorageManager { this.setDataInBulk(this._resolveKey(key, userId), customData); } - public async getConfigData(): Promise> { - return JSON.parse((await this._store.getData(this._resolveKey(Stores.ConfigData))) ?? null); + public async getConfigData(userId?: string): Promise> { + return JSON.parse((await this._store.getData(this._resolveKey(Stores.ConfigData, userId))) ?? null); } public async loadOpenIDProviderConfiguration(): Promise { diff --git a/packages/javascript/src/__legacy__/client.ts b/packages/javascript/src/__legacy__/client.ts index 3de479fd..42f3975f 100644 --- a/packages/javascript/src/__legacy__/client.ts +++ b/packages/javascript/src/__legacy__/client.ts @@ -20,7 +20,7 @@ import StorageManager from '../StorageManager'; import {AuthClientConfig, StrictAuthClientConfig} from './models'; import {ExtendedAuthorizeRequestUrlParams} from '../models/oauth-request'; import {Crypto} from '../models/crypto'; -import {TokenResponse, IdTokenPayload, TokenExchangeRequestConfig} from '../models/token'; +import {TokenResponse, IdToken, TokenExchangeRequestConfig} from '../models/token'; import {OIDCEndpoints} from '../models/oidc-endpoints'; import {Storage} from '../models/store'; import ScopeConstants from '../constants/ScopeConstants'; @@ -237,10 +237,13 @@ export class AsgardeoAuthClient { await this._storageManager.setTemporaryDataParameter(pkceKey, codeVerifier, userId); } + console.log('[AsgardeoAuthClient] configData:', configData); + const authorizeRequestParams: Map = getAuthorizeRequestUrlParams( { redirectUri: configData.afterSignInUrl, clientId: configData.clientId, + clientSecret: configData.clientSecret, scopes: processOpenIDScopes(configData.scopes), responseMode: configData.responseMode, codeChallengeMethod: PKCEConstants.DEFAULT_CODE_CHALLENGE_METHOD, @@ -582,9 +585,9 @@ export class AsgardeoAuthClient { * * @preserve */ - public async getDecodedIdToken(userId?: string): Promise { + public async getDecodedIdToken(userId?: string): Promise { const idToken: string = (await this._storageManager.getSessionData(userId)).id_token; - const payload: IdTokenPayload = this._cryptoHelper.decodeIdToken(idToken); + const payload: IdToken = this._cryptoHelper.decodeIdToken(idToken); return payload; } @@ -891,10 +894,7 @@ export class AsgardeoAuthClient { * * @preserve */ - public async exchangeToken( - config: TokenExchangeRequestConfig, - userId?: string, - ): Promise { + public async exchangeToken(config: TokenExchangeRequestConfig, userId?: string): Promise { const oidcProviderMetadata: OIDCDiscoveryApiResponse = await this._oidcProviderMetaData(); const configData: StrictAuthClientConfig = await this._config(); diff --git a/packages/javascript/src/__legacy__/helpers/authentication-helper.ts b/packages/javascript/src/__legacy__/helpers/authentication-helper.ts index b3b2283f..87cdcdb7 100644 --- a/packages/javascript/src/__legacy__/helpers/authentication-helper.ts +++ b/packages/javascript/src/__legacy__/helpers/authentication-helper.ts @@ -16,23 +16,19 @@ * under the License. */ -import {IsomorphicCrypto} from '../../IsomorphicCrypto'; -import StorageManager from '../../StorageManager'; -import {AsgardeoAuthException} from '../../errors/exception'; -import {AuthClientConfig, StrictAuthClientConfig} from '../models'; -import {User} from '../../models/user'; -import {SessionData} from '../../models/session'; -import {JWKInterface} from '../../models/crypto'; -import {TokenResponse, AccessTokenApiResponse} from '../../models/token'; -import {IdTokenPayload} from '../../models/token'; -import PKCEConstants from '../../constants/PKCEConstants'; -import extractTenantDomainFromIdTokenPayload from '../../utils/extractTenantDomainFromIdTokenPayload'; -import extractUserClaimsFromIdToken from '../../utils/extractUserClaimsFromIdToken'; -import ScopeConstants from '../../constants/ScopeConstants'; import OIDCDiscoveryConstants from '../../constants/OIDCDiscoveryConstants'; import TokenExchangeConstants from '../../constants/TokenExchangeConstants'; +import {AsgardeoAuthException} from '../../errors/exception'; +import {IsomorphicCrypto} from '../../IsomorphicCrypto'; +import {JWKInterface} from '../../models/crypto'; import {OIDCDiscoveryEndpointsApiResponse, OIDCDiscoveryApiResponse} from '../../models/oidc-discovery'; +import {SessionData} from '../../models/session'; +import {IdToken, TokenResponse, AccessTokenApiResponse} from '../../models/token'; +import {User} from '../../models/user'; +import StorageManager from '../../StorageManager'; +import extractUserClaimsFromIdToken from '../../utils/extractUserClaimsFromIdToken'; import processOpenIDScopes from '../../utils/processOpenIDScopes'; +import {AuthClientConfig, StrictAuthClientConfig} from '../models'; export class AuthenticationHelper { private _storageManager: StorageManager; @@ -42,8 +38,8 @@ export class AuthenticationHelper { public constructor(storageManager: StorageManager, cryptoHelper: IsomorphicCrypto) { this._storageManager = storageManager; - this._config = async () => await this._storageManager.getConfigData(); - this._oidcProviderMetaData = async () => await this._storageManager.loadOpenIDProviderConfiguration(); + this._config = async () => this._storageManager.getConfigData(); + this._oidcProviderMetaData = async () => this._storageManager.loadOpenIDProviderConfiguration(); this._cryptoHelper = cryptoHelper; } @@ -77,8 +73,8 @@ export class AuthenticationHelper { ]; const isRequiredEndpointsContains: boolean = configData.endpoints - ? requiredEndpoints.every((reqEndpointName: string) => { - return configData.endpoints + ? requiredEndpoints.every((reqEndpointName: string) => + configData.endpoints ? Object.keys(configData.endpoints).some((endpointName: string) => { const snakeCasedName: string = endpointName.replace( /[A-Z]/g, @@ -87,8 +83,8 @@ export class AuthenticationHelper { return snakeCasedName === reqEndpointName; }) - : false; - }) + : false, + ) : false; if (!isRequiredEndpointsContains) { @@ -114,7 +110,7 @@ export class AuthenticationHelper { const oidcProviderMetaData: OIDCDiscoveryEndpointsApiResponse = {}; const configData: StrictAuthClientConfig = await this._config(); - const baseUrl: string = (configData as any).baseUrl; + const {baseUrl} = configData as any; if (!baseUrl) { throw new AsgardeoAuthException( @@ -187,7 +183,7 @@ export class AuthenticationHelper { ); } - const issuer: string | undefined = (await this._oidcProviderMetaData()).issuer; + const {issuer} = await this._oidcProviderMetaData(); const {keys}: {keys: JWKInterface[]} = (await response.json()) as { keys: JWKInterface[]; @@ -207,17 +203,16 @@ export class AuthenticationHelper { } public getAuthenticatedUserInfo(idToken: string): User { - const payload: IdTokenPayload = this._cryptoHelper.decodeIdToken(idToken); + const payload: IdToken = this._cryptoHelper.decodeIdToken(idToken); const username: string = payload?.['username'] ?? ''; const givenName: string = payload?.['given_name'] ?? ''; const familyName: string = payload?.['family_name'] ?? ''; - const fullName: string = - givenName && familyName ? `${givenName} ${familyName}` : givenName ? givenName : familyName ? familyName : ''; + const fullName: string = givenName && familyName ? `${givenName} ${familyName}` : givenName || familyName || ''; const displayName: string = payload.preferred_username ?? fullName; return { - displayName: displayName, - username: username, + displayName, + username, ...extractUserClaimsFromIdToken(payload), }; } @@ -226,15 +221,19 @@ export class AuthenticationHelper { const configData: StrictAuthClientConfig = await this._config(); const sessionData: SessionData = await this._storageManager.getSessionData(userId); - let scope: string = processOpenIDScopes(configData.scopes); + const scope: string = processOpenIDScopes(configData.scopes); + + if (typeof text !== 'string') { + return text; + } return text - .replace(TokenExchangeConstants.Placeholders.TOKEN, sessionData.access_token) + .replace(TokenExchangeConstants.Placeholders.ACCESS_TOKEN, sessionData.access_token) .replace( TokenExchangeConstants.Placeholders.USERNAME, this.getAuthenticatedUserInfo(sessionData.id_token).username, ) - .replace(TokenExchangeConstants.Placeholders.SCOPE, scope) + .replace(TokenExchangeConstants.Placeholders.SCOPES, scope) .replace(TokenExchangeConstants.Placeholders.CLIENT_ID, configData.clientId) .replace(TokenExchangeConstants.Placeholders.CLIENT_SECRET, configData.clientSecret ?? ''); } @@ -253,7 +252,7 @@ export class AuthenticationHelper { ); } - //Get the response in JSON + // Get the response in JSON const parsedResponse: AccessTokenApiResponse = (await response.json()) as AccessTokenApiResponse; parsedResponse.created_at = new Date().getTime(); @@ -276,20 +275,19 @@ export class AuthenticationHelper { return Promise.resolve(tokenResponse); }); - } else { - const tokenResponse: TokenResponse = { - accessToken: parsedResponse.access_token, - createdAt: parsedResponse.created_at, - expiresIn: parsedResponse.expires_in, - idToken: parsedResponse.id_token, - refreshToken: parsedResponse.refresh_token, - scope: parsedResponse.scope, - tokenType: parsedResponse.token_type, - }; - - await this._storageManager.setSessionData(parsedResponse, userId); - - return Promise.resolve(tokenResponse); } + const tokenResponse: TokenResponse = { + accessToken: parsedResponse.access_token, + createdAt: parsedResponse.created_at, + expiresIn: parsedResponse.expires_in, + idToken: parsedResponse.id_token, + refreshToken: parsedResponse.refresh_token, + scope: parsedResponse.scope, + tokenType: parsedResponse.token_type, + }; + + await this._storageManager.setSessionData(parsedResponse, userId); + + return Promise.resolve(tokenResponse); } } diff --git a/packages/javascript/src/api/handleApplicationNativeAuthentication.ts b/packages/javascript/src/api/executeEmbeddedSignInFlow.ts similarity index 64% rename from packages/javascript/src/api/handleApplicationNativeAuthentication.ts rename to packages/javascript/src/api/executeEmbeddedSignInFlow.ts index 1688d419..3d8bd1b8 100644 --- a/packages/javascript/src/api/handleApplicationNativeAuthentication.ts +++ b/packages/javascript/src/api/executeEmbeddedSignInFlow.ts @@ -17,35 +17,19 @@ */ import AsgardeoAPIError from '../errors/AsgardeoAPIError'; -import { - ApplicationNativeAuthenticationHandleRequestPayload, - ApplicationNativeAuthenticationHandleResponse, -} from '../models/application-native-authentication'; +import {EmbeddedFlowExecuteRequestConfig} from '../models/embedded-flow'; +import {EmbeddedSignInFlowHandleResponse} from '../models/embedded-signin-flow'; -/** - * Request configuration for the authorize function. - */ -export interface AuthorizeRequestConfig extends Partial { - /** - * The base URL of the Asgardeo server. - */ - baseUrl?: string; - /** - * The authorization request payload. - */ - payload: ApplicationNativeAuthenticationHandleRequestPayload; -} - -const handleApplicationNativeAuthentication = async ({ +const executeEmbeddedSignInFlow = async ({ url, baseUrl, payload, ...requestConfig -}: AuthorizeRequestConfig): Promise => { +}: EmbeddedFlowExecuteRequestConfig): Promise => { if (!payload) { throw new AsgardeoAPIError( 'Authorization payload is required', - 'handleApplicationNativeAuthentication-ValidationError-002', + 'executeEmbeddedSignInFlow-ValidationError-002', 'javascript', 400, 'If an authorization payload is not provided, the request cannot be constructed correctly.', @@ -69,14 +53,14 @@ const handleApplicationNativeAuthentication = async ({ throw new AsgardeoAPIError( `Authorization request failed: ${errorText}`, - 'initializeApplicationNativeAuthentication-ResponseError-001', + 'initializeEmbeddedSignInFlow-ResponseError-001', 'javascript', response.status, response.statusText, ); } - return (await response.json()) as ApplicationNativeAuthenticationHandleResponse; + return (await response.json()) as EmbeddedSignInFlowHandleResponse; }; -export default handleApplicationNativeAuthentication; +export default executeEmbeddedSignInFlow; diff --git a/packages/javascript/src/api/executeEmbeddedSignUpFlow.ts b/packages/javascript/src/api/executeEmbeddedSignUpFlow.ts new file mode 100644 index 00000000..8e2581e7 --- /dev/null +++ b/packages/javascript/src/api/executeEmbeddedSignUpFlow.ts @@ -0,0 +1,92 @@ +/** + * 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 AsgardeoAPIError from '../errors/AsgardeoAPIError'; +import {EmbeddedFlowType, EmbeddedFlowExecuteResponse, EmbeddedFlowExecuteRequestConfig} from '../models/embedded-flow'; + +/** + * Executes an embedded signup flow by sending a request to the specified flow execution endpoint. + * + * @param requestConfig - Request configuration object containing URL and payload. + * @returns A promise that resolves with the flow execution response. + * @throws AsgardeoAPIError when the request fails or URL is invalid. + * + * @example + * ```typescript + * try { + * const embeddedSignUpResponse = await executeEmbeddedSignUpFlow({ + * url: "https://api.asgardeo.io/t//api/server/v1/flow/execute", + * payload: { + * flowType: "REGISTRATION" + * } + * }); + * console.log(embeddedSignUpResponse); + * } catch (error) { + * if (error instanceof AsgardeoAPIError) { + * console.error('Embedded SignUp flow execution failed:', error.message); + * } + * } + * ``` + */ +const executeEmbeddedSignUpFlow = async ({ + url, + baseUrl, + payload, + ...requestConfig +}: EmbeddedFlowExecuteRequestConfig): Promise => { + if (!baseUrl && !url) { + throw new AsgardeoAPIError( + 'Embedded SignUp flow execution failed: Base URL or URL is not provided.', + 'javascript-executeEmbeddedSignUpFlow-ValidationError-001', + 'javascript', + 400, + 'At least one of the baseUrl or url must be provided to execute the embedded sign up flow.', + ); + } + + const {headers: customHeaders, ...otherConfig} = requestConfig; + const response: Response = await fetch(url ?? `${baseUrl}/api/server/v1/flow/execute`, { + method: requestConfig.method || 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + ...customHeaders, + }, + body: JSON.stringify({ + ...(payload ?? {}), + flowType: EmbeddedFlowType.Registration, + }), + ...otherConfig, + }); + + if (!response.ok) { + const errorText = await response.text(); + + throw new AsgardeoAPIError( + `Embedded SignUp flow execution failed: ${errorText}`, + 'javascript-executeEmbeddedSignUpFlow-ResponseError-100', + 'javascript', + response.status, + response.statusText, + ); + } + + return (await response.json()) as EmbeddedFlowExecuteResponse; +}; + +export default executeEmbeddedSignUpFlow; diff --git a/packages/javascript/src/api/initializeApplicationNativeAuthentication.ts b/packages/javascript/src/api/initializeEmbeddedSignInFlow.ts similarity index 56% rename from packages/javascript/src/api/initializeApplicationNativeAuthentication.ts rename to packages/javascript/src/api/initializeEmbeddedSignInFlow.ts index 6f093be0..7e42c7c0 100644 --- a/packages/javascript/src/api/initializeApplicationNativeAuthentication.ts +++ b/packages/javascript/src/api/initializeEmbeddedSignInFlow.ts @@ -17,76 +17,8 @@ */ import AsgardeoAPIError from '../errors/AsgardeoAPIError'; -import {ApplicationNativeAuthenticationInitiateResponse} from '../models/application-native-authentication'; - -/** - * Represents the authorization request payload that can be sent to the authorization endpoint. - */ -export interface AuthorizationRequest { - /** - * The response type (e.g., 'code', 'token', 'id_token'). - */ - response_type?: string; - /** - * The client identifier. - */ - client_id?: string; - /** - * The redirection URI after authorization. - */ - redirect_uri?: string; - /** - * The scope of the access request. - */ - scope?: string; - /** - * An unguessable random string to prevent CSRF attacks. - */ - state?: string; - /** - * String value used to associate a Client session with an ID Token. - */ - nonce?: string; - /** - * How the authorization response should be returned. - */ - response_mode?: string; - /** - * Space delimited, case sensitive list of ASCII string values. - */ - prompt?: string; - /** - * The allowable elapsed time in seconds since the last time the End-User was actively authenticated. - */ - max_age?: number; - /** - * PKCE code challenge. - */ - code_challenge?: string; - /** - * PKCE code challenge method. - */ - code_challenge_method?: string; - /** - * Additional authorization parameters. - */ - [key: string]: any; -} - -/** - * Request configuration for the authorize function. - */ -export interface AuthorizeRequestConfig extends Partial { - url?: string; - /** - * The base URL of the Asgardeo server. - */ - baseUrl?: string; - /** - * The authorization request payload. - */ - payload: AuthorizationRequest; -} +import {EmbeddedFlowExecuteRequestConfig} from '../models/embedded-flow'; +import {EmbeddedSignInFlowInitiateResponse} from '../models/embedded-signin-flow'; /** * Sends an authorization request to the specified OAuth2/OIDC authorization endpoint. @@ -98,7 +30,7 @@ export interface AuthorizeRequestConfig extends Partial { * @example * ```typescript * try { - * const authResponse = await initializeApplicationNativeAuthentication({ + * const authResponse = await initializeEmbeddedSignInFlow({ * url: "https://api.asgardeo.io/t//oauth2/authorize", * payload: { * response_type: "code", @@ -118,16 +50,16 @@ export interface AuthorizeRequestConfig extends Partial { * } * ``` */ -const initializeApplicationNativeAuthentication = async ({ +const initializeEmbeddedSignInFlow = async ({ url, baseUrl, payload, ...requestConfig -}: AuthorizeRequestConfig): Promise => { +}: EmbeddedFlowExecuteRequestConfig): Promise => { if (!payload) { throw new AsgardeoAPIError( 'Authorization payload is required', - 'initializeApplicationNativeAuthentication-ValidationError-002', + 'initializeEmbeddedSignInFlow-ValidationError-002', 'javascript', 400, 'If an authorization payload is not provided, the request cannot be constructed correctly.', @@ -141,6 +73,8 @@ const initializeApplicationNativeAuthentication = async ({ } }); + console.log('Executing embedded sign-in flow with payload:', url, searchParams.toString()); + const {headers: customHeaders, ...otherConfig} = requestConfig; const response: Response = await fetch(url ?? `${baseUrl}/oauth2/authorize`, { method: requestConfig.method || 'POST', @@ -158,14 +92,14 @@ const initializeApplicationNativeAuthentication = async ({ throw new AsgardeoAPIError( `Authorization request failed: ${errorText}`, - 'initializeApplicationNativeAuthentication-ResponseError-001', + 'initializeEmbeddedSignInFlow-ResponseError-001', 'javascript', response.status, response.statusText, ); } - return (await response.json()) as ApplicationNativeAuthenticationInitiateResponse; + return (await response.json()) as EmbeddedSignInFlowInitiateResponse; }; -export default initializeApplicationNativeAuthentication; +export default initializeEmbeddedSignInFlow; diff --git a/packages/javascript/src/constants/OIDCRequestConstants.ts b/packages/javascript/src/constants/OIDCRequestConstants.ts index 88b39a47..3a5e8319 100644 --- a/packages/javascript/src/constants/OIDCRequestConstants.ts +++ b/packages/javascript/src/constants/OIDCRequestConstants.ts @@ -16,7 +16,7 @@ * under the License. */ -import ScopeConstants from "./ScopeConstants"; +import ScopeConstants from './ScopeConstants'; /** * Constants representing standard OpenID Connect (OIDC) request and response parameters. @@ -59,7 +59,7 @@ const OIDCRequestConstants = { /** * The default scopes used in OIDC sign-in requests. */ - DEFAULT_SCOPES: [ScopeConstants.OPENID], + DEFAULT_SCOPES: [ScopeConstants.OPENID, ScopeConstants.INTERNAL_LOGIN], }, }, diff --git a/packages/javascript/src/constants/ScopeConstants.ts b/packages/javascript/src/constants/ScopeConstants.ts index 5f9daeb9..a54c696e 100644 --- a/packages/javascript/src/constants/ScopeConstants.ts +++ b/packages/javascript/src/constants/ScopeConstants.ts @@ -30,12 +30,22 @@ * ```typescript * // Requesting OpenID Connect authentication * const scope = [ScopeConstants.OPENID]; - * + * * // Requesting profile information * const scopes = [ScopeConstants.OPENID, ScopeConstants.PROFILE]; * ``` */ -const ScopeConstants = { +const ScopeConstants: { + INTERNAL_LOGIN: string; + OPENID: string; +} = { + /** + * The scope for accessing the user's profile information from SCIM. + * This scope allows the client to retrieve basic user information such as + * name, email, profile picture, etc. + */ + INTERNAL_LOGIN: 'internal_login', + /** * The base OpenID Connect scope. * Required for all OpenID Connect flows. Indicates that the client diff --git a/packages/javascript/src/constants/TokenExchangeConstants.ts b/packages/javascript/src/constants/TokenExchangeConstants.ts index efb731cf..0c874f8a 100644 --- a/packages/javascript/src/constants/TokenExchangeConstants.ts +++ b/packages/javascript/src/constants/TokenExchangeConstants.ts @@ -43,7 +43,7 @@ const TokenExchangeConstants = { * Placeholder for the token value in exchange requests. * Usually replaced with an access token or refresh token. */ - TOKEN: '{{token}}', + ACCESS_TOKEN: '{{accessToken}}', /** * Placeholder for the username in token exchange operations. @@ -55,7 +55,7 @@ const TokenExchangeConstants = { * Placeholder for OAuth scopes in token exchange requests. * Replaced with space-separated scope strings. */ - SCOPE: '{{scope}}', + SCOPES: '{{scopes}}', /** * Placeholder for client ID in token exchange operations. diff --git a/packages/javascript/src/constants/VendorConstants.ts b/packages/javascript/src/constants/VendorConstants.ts new file mode 100644 index 00000000..aff6bf6e --- /dev/null +++ b/packages/javascript/src/constants/VendorConstants.ts @@ -0,0 +1,38 @@ +/** + * 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. + */ + +/** + * Constants for vendor-specific configurations. + * By default, the vendor is inferred as Asgardeo. + * + * @example + * ```typescript + * // Using the vendor prefix in a URL + * const apiUrl = `${VendorConstants.VENDOR_PREFIX}/api/v1/resource`; + * ``` + */ +const VendorConstants: { + VENDOR_PREFIX: string; +} = { + /** + * The prefix used for vendor-specific API endpoints, CSS classes, or other identifiers. + */ + VENDOR_PREFIX: 'asgardeo', +} as const; + +export default VendorConstants; diff --git a/packages/javascript/src/i18n/en-US.ts b/packages/javascript/src/i18n/en-US.ts index 163f5f40..c327ea5c 100644 --- a/packages/javascript/src/i18n/en-US.ts +++ b/packages/javascript/src/i18n/en-US.ts @@ -16,6 +16,8 @@ * under the License. */ +/* eslint-disable sort-keys */ + import {I18nTranslations, I18nMetadata, I18nBundle} from '../models/i18n'; const translations: I18nTranslations = { @@ -45,6 +47,11 @@ const translations: I18nTranslations = { /* Base Sign In */ 'signin.title': 'Sign In', + 'signin.subtitle': 'Enter your credentials to continue.', + + /* Base Sign Up */ + 'signup.title': 'Sign Up', + 'signup.subtitle': 'Create a new account to get started.', /* Email OTP */ 'email.otp.title': 'OTP Verification', @@ -71,6 +78,33 @@ const translations: I18nTranslations = { 'username.password.title': 'Sign In', 'username.password.subtitle': 'Enter your username and password to continue.', + /* |---------------------------------------------------------------| */ + /* | Organization Switcher | */ + /* |---------------------------------------------------------------| */ + + 'organization.switcher.select.organization': 'Select Organization', + 'organization.switcher.switch.organization': 'Switch Organization', + 'organization.switcher.loading.organizations': 'Loading organizations...', + 'organization.switcher.members': 'members', + 'organization.switcher.member': 'member', + 'organization.switcher.create.organization': 'Create Organization', + 'organization.switcher.manage.organizations': 'Manage Organization', + 'organization.switcher.manage.button': 'Manage', + 'organization.profile.title': 'Organization Profile', + 'organization.profile.loading': 'Loading organization...', + 'organization.profile.error': 'Failed to load organization', + + 'organization.create.title': 'Create Organization', + 'organization.create.name.label': 'Organization Name', + 'organization.create.name.placeholder': 'Enter organization name', + 'organization.create.handle.label': 'Organization Handle', + 'organization.create.handle.placeholder': 'my-organization', + 'organization.create.description.label': 'Description', + 'organization.create.description.placeholder': 'Enter organization description', + 'organization.create.button': 'Create Organization', + 'organization.create.creating': 'Creating...', + 'organization.create.cancel': 'Cancel', + /* |---------------------------------------------------------------| */ /* | Messages | */ /* |---------------------------------------------------------------| */ @@ -84,9 +118,11 @@ const translations: I18nTranslations = { 'errors.title': 'Error', 'errors.sign.in.initialization': 'An error occurred while initializing. Please try again later.', 'errors.sign.in.flow.failure': 'An error occurred during the sign-in flow. Please try again later.', - 'errors.sign.in.flow.completion.failure': 'An error occurred while completing the sign-in flow. Please try again later.', + 'errors.sign.in.flow.completion.failure': + 'An error occurred while completing the sign-in flow. Please try again later.', 'errors.sign.in.flow.passkeys.failure': 'An error occurred while signing in with passkeys. Please try again later.', - 'errors.sign.in.flow.passkeys.completion.failure': 'An error occurred while completing the passkeys sign-in flow. Please try again later.', + 'errors.sign.in.flow.passkeys.completion.failure': + 'An error occurred while completing the passkeys sign-in flow. Please try again later.', }; const metadata: I18nMetadata = { diff --git a/packages/javascript/src/index.ts b/packages/javascript/src/index.ts index 348ac76d..2f810d59 100644 --- a/packages/javascript/src/index.ts +++ b/packages/javascript/src/index.ts @@ -21,13 +21,15 @@ export * from './__legacy__/models'; export * from './IsomorphicCrypto'; -export {default as initializeApplicationNativeAuthentication} from './api/initializeApplicationNativeAuthentication'; -export {default as handleApplicationNativeAuthentication} from './api/handleApplicationNativeAuthentication'; +export {default as initializeEmbeddedSignInFlow} from './api/initializeEmbeddedSignInFlow'; +export {default as executeEmbeddedSignInFlow} from './api/executeEmbeddedSignInFlow'; +export {default as executeEmbeddedSignUpFlow} from './api/executeEmbeddedSignUpFlow'; export {default as getUserInfo} from './api/getUserInfo'; export {default as ApplicationNativeAuthenticationConstants} from './constants/ApplicationNativeAuthenticationConstants'; export {default as TokenConstants} from './constants/TokenConstants'; export {default as OIDCRequestConstants} from './constants/OIDCRequestConstants'; +export {default as VendorConstants} from './constants/VendorConstants'; export {default as AsgardeoError} from './errors/AsgardeoError'; export {default as AsgardeoAPIError} from './errors/AsgardeoAPIError'; @@ -35,21 +37,33 @@ export {default as AsgardeoRuntimeError} from './errors/AsgardeoRuntimeError'; export {AsgardeoAuthException} from './errors/exception'; export { - ApplicationNativeAuthenticationInitiateResponse, - ApplicationNativeAuthenticationFlowStatus, - ApplicationNativeAuthenticationFlowType, - ApplicationNativeAuthenticationStepType, - ApplicationNativeAuthenticationAuthenticator, - ApplicationNativeAuthenticationLink, - ApplicationNativeAuthenticationHandleRequestPayload, - ApplicationNativeAuthenticationHandleResponse, - ApplicationNativeAuthenticationAuthenticatorParamType, - ApplicationNativeAuthenticationAuthenticatorPromptType, - ApplicationNativeAuthenticationAuthenticatorKnownIdPType, -} from './models/application-native-authentication'; -export {AsgardeoClient, SignInOptions, SignOutOptions} from './models/client'; + EmbeddedSignInFlowInitiateResponse, + EmbeddedSignInFlowStatus, + EmbeddedSignInFlowType, + EmbeddedSignInFlowStepType, + EmbeddedSignInFlowAuthenticator, + EmbeddedSignInFlowLink, + EmbeddedSignInFlowHandleRequestPayload, + EmbeddedSignInFlowHandleResponse, + EmbeddedSignInFlowAuthenticatorParamType, + EmbeddedSignInFlowAuthenticatorPromptType, + EmbeddedSignInFlowAuthenticatorKnownIdPType, +} from './models/embedded-signin-flow'; +export { + EmbeddedFlowType, + EmbeddedFlowStatus, + EmbeddedFlowExecuteResponse, + EmbeddedFlowResponseType, + EmbeddedSignUpFlowData, + EmbeddedFlowComponent, + EmbeddedFlowComponentType, + EmbeddedFlowExecuteRequestPayload, + EmbeddedFlowExecuteRequestConfig, +} from './models/embedded-flow'; +export {FlowMode} from './models/flow'; +export {AsgardeoClient, SignInOptions, SignOutOptions, SignUpOptions} from './models/client'; export {BaseConfig, Config, Preferences, ThemePreferences, I18nPreferences, WithPreferences} from './models/config'; -export {TokenResponse, IdTokenPayload, TokenExchangeRequestConfig} from './models/token'; +export {TokenResponse, IdToken, TokenExchangeRequestConfig} from './models/token'; export {Crypto, JWKInterface} from './models/crypto'; export {OAuthResponseMode} from './models/oauth-response'; export { @@ -61,6 +75,7 @@ export {OIDCEndpoints} from './models/oidc-endpoints'; export {Storage, TemporaryStore} from './models/store'; export {User, UserProfile} from './models/user'; export {SessionData} from './models/session'; +export {Organization} from './models/organization'; export {Schema, SchemaAttribute, WellKnownSchemaIds, FlattenedSchema} from './models/scim2-schema'; export {RecursivePartial} from './models/utility-types'; export {FieldType} from './models/field'; @@ -79,11 +94,13 @@ export {default as generateUserProfile} from './utils/generateUserProfile'; export {default as getLatestStateParam} from './utils/getLatestStateParam'; export {default as generateFlattenedUserProfile} from './utils/generateFlattenedUserProfile'; export {default as getI18nBundles} from './utils/getI18nBundles'; +export {default as isEmpty} from './utils/isEmpty'; export {default as set} from './utils/set'; export {default as get} from './utils/get'; export {default as removeTrailingSlash} from './utils/removeTrailingSlash'; export {default as resolveFieldType} from './utils/resolveFieldType'; export {default as resolveFieldName} from './utils/resolveFieldName'; export {default as processOpenIDScopes} from './utils/processOpenIDScopes'; +export {default as withVendorCSSClassPrefix} from './utils/withVendorCSSClassPrefix'; export {default as StorageManager} from './StorageManager'; diff --git a/packages/javascript/src/models/application-native-authentication.ts b/packages/javascript/src/models/application-native-authentication.ts deleted file mode 100644 index 42591330..00000000 --- a/packages/javascript/src/models/application-native-authentication.ts +++ /dev/null @@ -1,93 +0,0 @@ -export interface ApplicationNativeAuthenticationInitiateResponse { - flowId: string; - flowStatus: ApplicationNativeAuthenticationFlowStatus; - flowType: ApplicationNativeAuthenticationFlowType; - nextStep: { - stepType: ApplicationNativeAuthenticationStepType; - authenticators: ApplicationNativeAuthenticationAuthenticator[]; - }; - links: ApplicationNativeAuthenticationLink[]; -} - -export enum ApplicationNativeAuthenticationFlowStatus { - SuccessCompleted = 'SUCCESS_COMPLETED', - FailCompleted = 'FAIL_COMPLETED', - FailIncomplete = 'FAIL_INCOMPLETE', - Incomplete = 'INCOMPLETE', -} - -export enum ApplicationNativeAuthenticationFlowType { - Authentication = 'AUTHENTICATION', -} - -export enum ApplicationNativeAuthenticationStepType { - AuthenticatorPrompt = 'AUTHENTICATOR_PROMPT', - MultOptionsPrompt = 'MULTI_OPTIONS_PROMPT', -} - -export interface ApplicationNativeAuthenticationAuthenticator { - authenticatorId: string; - authenticator: string; - idp: string; - metadata: { - i18nKey: string; - promptType: ApplicationNativeAuthenticationAuthenticatorPromptType; - params: { - param: string; - type: ApplicationNativeAuthenticationAuthenticatorParamType; - order: number; - i18nKey: string; - displayName: string; - confidential: boolean; - }[]; - }; - requiredParams: string[]; -} - -export interface ApplicationNativeAuthenticationLink { - name: string; - href: string; - method: string; -} - -export interface ApplicationNativeAuthenticationHandleRequestPayload { - flowId: string; - selectedAuthenticator: { - authenticatorId: string; - params: Record; - }; -} - -export interface ApplicationNativeAuthenticationHandleResponse { - flowStatus: string; - authData: Record; -} - -export enum ApplicationNativeAuthenticationAuthenticatorParamType { - String = 'STRING', - Integer = 'INTEGER', - MultiValued = 'MULTI_VALUED', -} - -export enum ApplicationNativeAuthenticationAuthenticatorExtendedParamType { - Otp = 'OTPCode', -} - -export enum ApplicationNativeAuthenticationAuthenticatorKnownIdPType { - Local = 'LOCAL', -} - -export enum ApplicationNativeAuthenticationAuthenticatorPromptType { - /** - * Prompt for user input, typically for username/password or similar credentials. - */ - UserPrompt = 'USER_PROMPT', - /** - * Prompt for internal system use, such as API keys or tokens. - */ - InternalPrompt = 'INTERNAL_PROMPT', - /** - * Prompt for redirection to another page or service. - */ - RedirectionPrompt = 'REDIRECTION_PROMPT', -} diff --git a/packages/javascript/src/models/client.ts b/packages/javascript/src/models/client.ts index bc7545b6..a8bd52f5 100644 --- a/packages/javascript/src/models/client.ts +++ b/packages/javascript/src/models/client.ts @@ -16,10 +16,14 @@ * under the License. */ +import {EmbeddedFlowExecuteRequestConfig, EmbeddedFlowExecuteRequestPayload, EmbeddedFlowExecuteResponse} from './embedded-flow'; +import {EmbeddedSignInFlowHandleRequestPayload} from './embedded-signin-flow'; +import {Organization} from './organization'; import {User, UserProfile} from './user'; export type SignInOptions = Record; export type SignOutOptions = Record; +export type SignUpOptions = Record; /** * Interface defining the core functionality for Asgardeo authentication clients. @@ -32,6 +36,27 @@ export type SignOutOptions = Record; * ``` */ export interface AsgardeoClient { + /** + * Gets the users associated organizations. + * + * @returns Associated organizations. + */ + getOrganizations(): Promise; + + /** + * Gets the current organization of the user. + * + * @returns The current organization if available, otherwise null. + */ + getCurrentOrganization(): Promise; + + /** + * Switches the current organization to the specified one. + * @param organization - The organization to switch to. + * @returns A promise that resolves when the switch is complete. + */ + switchOrganization(organization: Organization): Promise; + /** * Gets user information from the session. * @@ -74,9 +99,31 @@ export interface AsgardeoClient { * Initiates the sign-in process for the user. * * @param options - Optional sign-in options like additional parameters to be sent in the authorize request, etc. + * @param sessionId - Optional session ID to be used for sign-in. + * @param onSignInSuccess - Callback function to be executed upon successful sign-in. * @returns Promise resolving the user upon successful sign in. */ - signIn(options?: SignInOptions): Promise; + signIn( + options?: SignInOptions, + sessionId?: string, + onSignInSuccess?: (afterSignInUrl: string) => void, + ): Promise; + + /** + * Initiates an embedded (App-Native) sign-in flow for the user. + * + * @param payload - The payload containing the necessary information to execute the embedded sign-in flow. + * @param request - The request object containing URL and parameters for the sign-in flow HTTP request. + * @param sessionId - Optional session ID to be used for sign-in. + * @param onSignInSuccess - Callback function to be executed upon successful sign-in. + * @returns A promise that resolves to an EmbeddedFlowExecuteResponse containing the flow execution details. + */ + signIn( + payload: EmbeddedSignInFlowHandleRequestPayload, + request: EmbeddedFlowExecuteRequestConfig, + sessionId?: string, + onSignInSuccess?: (afterSignInUrl: string) => void, + ): Promise; /** * Signs out the currently signed-in user. @@ -97,4 +144,20 @@ export interface AsgardeoClient { * @returns A promise that resolves to true if sign-out is successful */ signOut(options?: SignOutOptions, sessionId?: string, afterSignOut?: (redirectUrl: string) => void): Promise; + + /** + * Initiates a redirection-based sign-up process for the user. + * + * @param options - Optional sign-up options like additional parameters to be sent in the sign-up request, etc. + * @returns Promise resolving to the user upon successful sign up. + */ + signUp(options?: SignUpOptions): Promise; + + /** + * Initiates an embedded (App-Native) sign-up flow for the user. + * + * @param payload - The payload containing the necessary information to execute the embedded sign-up flow. + * @returns A promise that resolves to an EmbeddedFlowExecuteResponse containing the flow execution details. + */ + signUp(payload: EmbeddedFlowExecuteRequestPayload): Promise; } diff --git a/packages/javascript/src/models/config.ts b/packages/javascript/src/models/config.ts index cbf3652d..c2410614 100644 --- a/packages/javascript/src/models/config.ts +++ b/packages/javascript/src/models/config.ts @@ -16,9 +16,9 @@ * under the License. */ -import {ThemeConfig, ThemeMode} from '../theme/types'; import {I18nBundle} from './i18n'; import {RecursivePartial} from './utility-types'; +import {ThemeConfig, ThemeMode} from '../theme/types'; export interface BaseConfig extends WithPreferences { /** @@ -32,6 +32,18 @@ export interface BaseConfig extends WithPreferences { */ afterSignInUrl?: string | undefined; + /** + * Optional URL where the authorization server should redirect after sign out. + * This must match one of the allowed post logout redirect URIs configured in your IdP + * and is used to redirect the user after they have signed out. + * If not provided, the framework layer will use the default sign out URL based on the + * + * @example + * For development: "http://localhost:3000/api/auth/signout" + * For production: "https://your-app.com/api/auth/signout" + */ + afterSignOutUrl?: string | undefined; + /** * The base URL of the Asgardeo identity server. * Example: "https://api.asgardeo.io/t/{org_name}" @@ -88,30 +100,30 @@ export interface ThemePreferences { export interface I18nPreferences { /** - * The language to use for translations. - * Defaults to the browser's default language. + * Custom translations to override default ones. */ - language?: string; + bundles?: { + [key: string]: I18nBundle; + }; /** * The fallback language to use if translations are not available in the specified language. * Defaults to 'en-US'. */ fallbackLanguage?: string; /** - * Custom translations to override default ones. + * The language to use for translations. + * Defaults to the browser's default language. */ - bundles?: { - [key: string]: I18nBundle; - }; + language?: string; } export interface Preferences { - /** - * Theme preferences for the Asgardeo UI components - */ - theme?: ThemePreferences; /** * Internationalization preferences for the Asgardeo UI components */ i18n?: I18nPreferences; + /** + * Theme preferences for the Asgardeo UI components + */ + theme?: ThemePreferences; } diff --git a/packages/javascript/src/models/embedded-flow.ts b/packages/javascript/src/models/embedded-flow.ts new file mode 100644 index 00000000..68846b19 --- /dev/null +++ b/packages/javascript/src/models/embedded-flow.ts @@ -0,0 +1,75 @@ +/** + * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export enum EmbeddedFlowType { + Registration = 'REGISTRATION', +} + +export interface EmbeddedFlowExecuteRequestPayload { + actionId?: string; + flowType: EmbeddedFlowType; + inputs?: Record; +} + +export interface EmbeddedFlowExecuteResponse { + data: EmbeddedSignUpFlowData; + flowId: string; + flowStatus: EmbeddedFlowStatus; + type: EmbeddedFlowResponseType; +} + +export enum EmbeddedFlowStatus { + Complete = 'COMPLETE', + Incomplete = 'INCOMPLETE', +} + +export enum EmbeddedFlowResponseType { + Redirection = 'REDIRECTION', + View = 'VIEW', +} + +export interface EmbeddedSignUpFlowData { + components?: EmbeddedFlowComponent[]; + redirectURL?: string; +} + +export interface EmbeddedFlowComponent { + components: EmbeddedFlowComponent[]; + config: Record; + id: string; + type: EmbeddedFlowComponentType; + variant?: string; +} + +export enum EmbeddedFlowComponentType { + Button = 'BUTTON', + Checkbox = 'CHECKBOX', + Divider = 'DIVIDER', + Form = 'FORM', + Image = 'IMAGE', + Input = 'INPUT', + Radio = 'RADIO', + Select = 'SELECT', + Typography = 'TYPOGRAPHY', +} + +export interface EmbeddedFlowExecuteRequestConfig extends Partial { + baseUrl?: string; + payload?: T; + url?: string; +} diff --git a/packages/javascript/src/models/embedded-signin-flow.ts b/packages/javascript/src/models/embedded-signin-flow.ts new file mode 100644 index 00000000..116a24e5 --- /dev/null +++ b/packages/javascript/src/models/embedded-signin-flow.ts @@ -0,0 +1,111 @@ +/** + * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export interface EmbeddedSignInFlowInitiateResponse { + flowId: string; + flowStatus: EmbeddedSignInFlowStatus; + flowType: EmbeddedSignInFlowType; + links: EmbeddedSignInFlowLink[]; + nextStep: { + authenticators: EmbeddedSignInFlowAuthenticator[]; + stepType: EmbeddedSignInFlowStepType; + }; +} + +export enum EmbeddedSignInFlowStatus { + FailCompleted = 'FAIL_COMPLETED', + FailIncomplete = 'FAIL_INCOMPLETE', + Incomplete = 'INCOMPLETE', + SuccessCompleted = 'SUCCESS_COMPLETED', +} + +export enum EmbeddedSignInFlowType { + Authentication = 'AUTHENTICATION', +} + +export enum EmbeddedSignInFlowStepType { + AuthenticatorPrompt = 'AUTHENTICATOR_PROMPT', + MultiOptionsPrompt = 'MULTI_OPTIONS_PROMPT', +} + +export interface EmbeddedSignInFlowAuthenticator { + authenticator: string; + authenticatorId: string; + idp: string; + metadata: { + i18nKey: string; + params: { + confidential: boolean; + displayName: string; + i18nKey: string; + order: number; + param: string; + type: EmbeddedSignInFlowAuthenticatorParamType; + }[]; + promptType: EmbeddedSignInFlowAuthenticatorPromptType; + }; + requiredParams: string[]; +} + +export interface EmbeddedSignInFlowLink { + href: string; + method: string; + name: string; +} + +export interface EmbeddedSignInFlowHandleRequestPayload { + flowId: string; + selectedAuthenticator: { + authenticatorId: string; + params: Record; + }; +} + +export interface EmbeddedSignInFlowHandleResponse { + authData: Record; + flowStatus: string; +} + +export enum EmbeddedSignInFlowAuthenticatorParamType { + Integer = 'INTEGER', + MultiValued = 'MULTI_VALUED', + String = 'STRING', +} + +export enum EmbeddedSignInFlowAuthenticatorExtendedParamType { + Otp = 'OTPCode', +} + +export enum EmbeddedSignInFlowAuthenticatorKnownIdPType { + Local = 'LOCAL', +} + +export enum EmbeddedSignInFlowAuthenticatorPromptType { + /** + * Prompt for internal system use, such as API keys or tokens. + */ + InternalPrompt = 'INTERNAL_PROMPT', + /** + * Prompt for redirection to another page or service. + */ + RedirectionPrompt = 'REDIRECTION_PROMPT', + /** + * Prompt for user input, typically for username/password or similar credentials. + */ + UserPrompt = 'USER_PROMPT', +} diff --git a/packages/javascript/src/models/flow.ts b/packages/javascript/src/models/flow.ts new file mode 100644 index 00000000..14764f63 --- /dev/null +++ b/packages/javascript/src/models/flow.ts @@ -0,0 +1,31 @@ +/** + * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export enum FlowMode { + /** + * This mode is suitable for embedded sign-in, sign-up, etc. flows where the authentication + * UIs are rendered within the application. + * @see {@link https://is.docs.wso2.com/en/7.1.0/references/app-native-authentication/} + */ + Embedded = 'DIRECT', + /** + * Traditional redirect based sign-in, sign-up, etc. flows where the authentication + * UIs are from a external Identity Provider (ex: WSO2 Identity Server or Asgardeo). + */ + Redirect = 'REDIRECTION', +} diff --git a/packages/javascript/src/models/i18n.ts b/packages/javascript/src/models/i18n.ts index 321f0767..1df78784 100644 --- a/packages/javascript/src/models/i18n.ts +++ b/packages/javascript/src/models/i18n.ts @@ -16,6 +16,8 @@ * under the License. */ +/* eslint-disable typescript-sort-keys/interface */ + export interface I18nTranslations { /* |---------------------------------------------------------------| */ /* | Elements | */ @@ -43,6 +45,11 @@ export interface I18nTranslations { /* Base Sign In */ 'signin.title': string; + 'signin.subtitle': string; + + /* Base Sign Up */ + 'signup.title': string; + 'signup.subtitle': string; /* Email OTP */ 'email.otp.title': string; @@ -69,6 +76,37 @@ export interface I18nTranslations { 'username.password.title': string; 'username.password.subtitle': string; + /* |---------------------------------------------------------------| */ + /* | Organization Switcher | */ + /* |---------------------------------------------------------------| */ + + 'organization.switcher.select.organization': string; + 'organization.switcher.switch.organization': string; + 'organization.switcher.loading.organizations': string; + 'organization.switcher.members': string; + 'organization.switcher.member': string; + 'organization.switcher.create.organization': string; + 'organization.switcher.manage.organizations': string; + 'organization.switcher.manage.button': string; + 'organization.profile.title': string; + 'organization.profile.loading': string; + 'organization.profile.error': string; + + /* |---------------------------------------------------------------| */ + /* | Organization Creation | */ + /* |---------------------------------------------------------------| */ + + 'organization.create.title': string; + 'organization.create.name.label': string; + 'organization.create.name.placeholder': string; + 'organization.create.handle.label': string; + 'organization.create.handle.placeholder': string; + 'organization.create.description.label': string; + 'organization.create.description.placeholder': string; + 'organization.create.button': string; + 'organization.create.creating': string; + 'organization.create.cancel': string; + /* |---------------------------------------------------------------| */ /* | Messages | */ /* |---------------------------------------------------------------| */ diff --git a/packages/browser/src/constants/StyleConstants.ts b/packages/javascript/src/models/organization.ts similarity index 63% rename from packages/browser/src/constants/StyleConstants.ts rename to packages/javascript/src/models/organization.ts index 6b7bbf9a..b19a4ac9 100644 --- a/packages/browser/src/constants/StyleConstants.ts +++ b/packages/javascript/src/models/organization.ts @@ -16,18 +16,10 @@ * under the License. */ -/** - * Class defining style constants for the downstream libraries. - */ -class StyleConstants { - /** - * CSS class prefix for out of the box components. - */ - static readonly VENDOR_CSS_CLASS_PREFIX: string = 'asgardeo'; - - protected constructor() { - // Protected constructor allows inheritance while still preventing direct instantiation - } +export interface Organization { + id: string; + name: string; + orgHandle: string; + ref?: string; + status?: string; } - -export default StyleConstants; diff --git a/packages/javascript/src/models/token.ts b/packages/javascript/src/models/token.ts index 5b95f425..a77038f3 100644 --- a/packages/javascript/src/models/token.ts +++ b/packages/javascript/src/models/token.ts @@ -33,11 +33,11 @@ export interface TokenResponse { accessToken: string; /** - * JSON Web Token (JWT) containing user identity information. - * This token can be decoded to access user claims and metadata - * without additional server requests. + * Unix timestamp (in seconds) when the token was created. + * Used in combination with expiresIn to determine when + * the token needs to be refreshed. */ - idToken: string; + createdAt: number; /** * Duration in seconds until the access token expires. @@ -47,11 +47,11 @@ export interface TokenResponse { expiresIn: string; /** - * Space-separated list of OAuth scopes granted to the application. - * These scopes determine what resources and actions the application - * has permission to access. + * JSON Web Token (JWT) containing user identity information. + * This token can be decoded to access user claims and metadata + * without additional server requests. */ - scope: string; + idToken: string; /** * Token used to obtain new access tokens without re-authentication. @@ -60,19 +60,19 @@ export interface TokenResponse { */ refreshToken: string; + /** + * Space-separated list of OAuth scopes granted to the application. + * These scopes determine what resources and actions the application + * has permission to access. + */ + scope: string; + /** * The type of token issued, typically "Bearer". * This indicates how the token should be used in * API request Authorization headers. */ tokenType: string; - - /** - * Unix timestamp (in seconds) when the token was created. - * Used in combination with expiresIn to determine when - * the token needs to be refreshed. - */ - createdAt: number; } /** @@ -91,13 +91,6 @@ export interface AccessTokenApiResponse { */ access_token: string; - /** - * Raw expiration time in seconds. - * Indicates how long the access token will be valid - * from the time it was issued. - */ - expires_in: string; - /** * Server-provided creation timestamp in Unix seconds. * Used to track when the token was originally issued @@ -105,6 +98,13 @@ export interface AccessTokenApiResponse { */ created_at: number; + /** + * Raw expiration time in seconds. + * Indicates how long the access token will be valid + * from the time it was issued. + */ + expires_in: string; + /** * Raw ID token string containing encoded user information. * This JWT can be decoded to access standardized claims @@ -137,16 +137,16 @@ export interface AccessTokenApiResponse { /** * Interface for the standard (required) claims of an ID Token payload. */ -export interface IdTokenPayloadStandardClaims { +export interface KnownIdToken { /** * The audience for which this token is intended. */ aud: string | string[]; /** - * The unique identifier of the user to whom the ID token belongs. + * The email of the user. */ - sub: string; + email?: string; /** * The issuer identifier for the issuer of the response. @@ -154,15 +154,30 @@ export interface IdTokenPayloadStandardClaims { iss: string; /** - * The email of the user. + * The unique human readable slug of the organization to which the user belongs. */ - email?: string; + org_handle?: string; + + /** + * The unique identifier of the organization to which the user belongs. + */ + org_id?: string; + + /** + * The human readable name of the organization to which the user belongs. + */ + org_name?: string; /** * The username the user prefers to be called. */ preferred_username?: string; + /** + * The unique identifier of the user to whom the ID token belongs. + */ + sub: string; + /** * The tenant domain of the user. */ @@ -172,7 +187,7 @@ export interface IdTokenPayloadStandardClaims { /** * Interface for ID Token payload including custom claims. */ -export interface IdTokenPayload extends IdTokenPayloadStandardClaims { +export interface IdToken extends KnownIdToken { /** * Other custom claims. */ @@ -180,11 +195,11 @@ export interface IdTokenPayload extends IdTokenPayloadStandardClaims { } export interface TokenExchangeRequestConfig { - id: string; - data: any; - signInRequired: boolean; attachToken: boolean; + data: any; + id: string; returnsSession: boolean; - tokenEndpoint?: string; shouldReplayAfterRefresh?: boolean; + signInRequired: boolean; + tokenEndpoint?: string; } diff --git a/packages/javascript/src/theme/createTheme.ts b/packages/javascript/src/theme/createTheme.ts index 7cac18a2..53fb38d9 100644 --- a/packages/javascript/src/theme/createTheme.ts +++ b/packages/javascript/src/theme/createTheme.ts @@ -16,8 +16,8 @@ * under the License. */ -import { RecursivePartial } from '../models/utility-types'; import {Theme, ThemeConfig} from './types'; +import {RecursivePartial} from '../models/utility-types'; const lightTheme: ThemeConfig = { colors: { @@ -40,6 +40,14 @@ const lightTheme: ThemeConfig = { main: '#d32f2f', contrastText: '#ffffff', }, + success: { + main: '#4caf50', + contrastText: '#ffffff', + }, + warning: { + main: '#ff9800', + contrastText: '#ffffff', + }, text: { primary: '#1a1a1a', secondary: '#666666', @@ -54,6 +62,11 @@ const lightTheme: ThemeConfig = { medium: '8px', large: '16px', }, + shadows: { + small: '0 2px 8px rgba(0, 0, 0, 0.1)', + medium: '0 4px 16px rgba(0, 0, 0, 0.15)', + large: '0 8px 32px rgba(0, 0, 0, 0.2)', + }, }; const darkTheme: ThemeConfig = { @@ -77,6 +90,14 @@ const darkTheme: ThemeConfig = { main: '#d32f2f', contrastText: '#ffffff', }, + success: { + main: '#4caf50', + contrastText: '#ffffff', + }, + warning: { + main: '#ff9800', + contrastText: '#ffffff', + }, text: { primary: '#ffffff', secondary: '#b3b3b3', @@ -91,6 +112,11 @@ const darkTheme: ThemeConfig = { medium: '8px', large: '16px', }, + shadows: { + small: '0 2px 8px rgba(0, 0, 0, 0.3)', + medium: '0 4px 16px rgba(0, 0, 0, 0.4)', + large: '0 8px 32px rgba(0, 0, 0, 0.5)', + }, }; const toCssVariables = (theme: RecursivePartial): Record => { @@ -106,6 +132,10 @@ const toCssVariables = (theme: RecursivePartial): Record): Record = {}, isDark = false) ...baseTheme.borderRadius, ...config.borderRadius, }, + shadows: { + ...baseTheme.shadows, + ...config.shadows, + }, } as ThemeConfig; return { diff --git a/packages/javascript/src/theme/types.ts b/packages/javascript/src/theme/types.ts index 78daff61..6d4cf5d6 100644 --- a/packages/javascript/src/theme/types.ts +++ b/packages/javascript/src/theme/types.ts @@ -17,41 +17,54 @@ */ export interface ThemeColors { - primary: { + background: { + body: { + main: string; + }; + disabled: string; + surface: string; + }; + border: string; + error: { + contrastText: string; main: string; + }; + primary: { contrastText: string; + main: string; }; secondary: { - main: string; contrastText: string; + main: string; }; - background: { - surface: string; - disabled: string; - body: { - main: string; - }; + success: { + contrastText: string; + main: string; }; text: { primary: string; secondary: string; }; - border: string; - error: { - main: string; + warning: { contrastText: string; + main: string; }; } export interface ThemeConfig { - colors: ThemeColors; - spacing: { - unit: number; - }; borderRadius: { - small: string; + large: string; medium: string; + small: string; + }; + colors: ThemeColors; + shadows: { large: string; + medium: string; + small: string; + }; + spacing: { + unit: number; }; } diff --git a/packages/javascript/src/utils/__tests__/extractTenantDomainFromIdTokenPayload.test.ts b/packages/javascript/src/utils/__tests__/extractTenantDomainFromIdTokenPayload.test.ts index ce0096da..b2a0e6a0 100644 --- a/packages/javascript/src/utils/__tests__/extractTenantDomainFromIdTokenPayload.test.ts +++ b/packages/javascript/src/utils/__tests__/extractTenantDomainFromIdTokenPayload.test.ts @@ -18,11 +18,11 @@ import {describe, expect, it} from 'vitest'; import extractTenantDomainFromIdTokenPayload from '../extractTenantDomainFromIdTokenPayload'; -import {IdTokenPayload} from '../../models/id-token'; +import {IdToken} from '../../models/id-token'; describe('extractTenantDomainFromIdTokenPayload', (): void => { it('should extract tenant domain from sub claim with default separator', (): void => { - const payload: IdTokenPayload = { + const payload: IdToken = { sub: 'user@foo@tenant.com', }; @@ -30,7 +30,7 @@ describe('extractTenantDomainFromIdTokenPayload', (): void => { }); it('should extract tenant domain with custom separator', (): void => { - const payload: IdTokenPayload = { + const payload: IdToken = { sub: 'user#foo#custom-tenant', }; @@ -38,13 +38,13 @@ describe('extractTenantDomainFromIdTokenPayload', (): void => { }); it('should return empty string when sub claim is missing', (): void => { - const payload = {} as IdTokenPayload; + const payload = {} as IdToken; expect(extractTenantDomainFromIdTokenPayload(payload)).toBe(''); }); it('should return empty string when sub claim has insufficient parts', (): void => { - const payload: IdTokenPayload = { + const payload: IdToken = { sub: 'user@tenant', }; diff --git a/packages/javascript/src/utils/__tests__/extractUserClaimsFromIdToken.test.ts b/packages/javascript/src/utils/__tests__/extractUserClaimsFromIdToken.test.ts index ac60be22..23e75d4a 100644 --- a/packages/javascript/src/utils/__tests__/extractUserClaimsFromIdToken.test.ts +++ b/packages/javascript/src/utils/__tests__/extractUserClaimsFromIdToken.test.ts @@ -18,11 +18,11 @@ import {describe, expect, it} from 'vitest'; import extractUserClaimsFromIdToken from '../extractUserClaimsFromIdToken'; -import {IdTokenPayload} from '../../models/id-token'; +import {IdToken} from '../../models/id-token'; describe('extractUserClaimsFromIdToken', (): void => { it('should remove protocol claims and keep user claims', (): void => { - const payload: IdTokenPayload = { + const payload: IdToken = { iss: 'https://example.com', aud: 'client_id', exp: 1712345678, @@ -46,17 +46,17 @@ describe('extractUserClaimsFromIdToken', (): void => { }); it('should handle empty payload', (): void => { - const payload = {} as IdTokenPayload; + const payload = {} as IdToken; expect(extractUserClaimsFromIdToken(payload)).toEqual({}); }); it('should convert snake_case to camelCase', (): void => { - const payload: IdTokenPayload = { + const payload: IdToken = { phone_number: '+1234567890', custom_claim_value: 'test', normalClaim: 'value', - } as IdTokenPayload; + } as IdToken; const expected: { phoneNumber: string; @@ -72,7 +72,7 @@ describe('extractUserClaimsFromIdToken', (): void => { }); it('should remove all protocol claims', (): void => { - const payload: IdTokenPayload = { + const payload: IdToken = { iss: 'https://example.com', aud: 'client_id', exp: 1712345678, @@ -88,7 +88,7 @@ describe('extractUserClaimsFromIdToken', (): void => { isk: 'key1', sid: 'session1', custom_claim: 'value', - } as IdTokenPayload; + } as IdToken; expect(extractUserClaimsFromIdToken(payload)).toEqual({ customClaim: 'value', diff --git a/packages/javascript/src/utils/__tests__/isEmpty.test.ts b/packages/javascript/src/utils/__tests__/isEmpty.test.ts new file mode 100644 index 00000000..268cc8b2 --- /dev/null +++ b/packages/javascript/src/utils/__tests__/isEmpty.test.ts @@ -0,0 +1,95 @@ +/** + * 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 isEmpty from '../isEmpty'; + +describe('isEmpty', () => { + it('should return true for null', () => { + expect(isEmpty(null)).toBe(true); + }); + + it('should return true for undefined', () => { + expect(isEmpty(undefined)).toBe(true); + }); + + it('should return true for empty string', () => { + expect(isEmpty('')).toBe(true); + }); + + it('should return true for whitespace-only string', () => { + expect(isEmpty(' ')).toBe(true); + expect(isEmpty('\t')).toBe(true); + expect(isEmpty('\n')).toBe(true); + expect(isEmpty(' \t\n ')).toBe(true); + }); + + it('should return false for non-empty string', () => { + expect(isEmpty('hello')).toBe(false); + expect(isEmpty(' hello ')).toBe(false); + expect(isEmpty('0')).toBe(false); + }); + + it('should return true for empty array', () => { + expect(isEmpty([])).toBe(true); + }); + + it('should return false for non-empty array', () => { + expect(isEmpty([1, 2, 3])).toBe(false); + expect(isEmpty([''])).toBe(false); + expect(isEmpty([null])).toBe(false); + }); + + it('should return true for empty object', () => { + expect(isEmpty({})).toBe(true); + }); + + it('should return false for non-empty object', () => { + expect(isEmpty({name: 'John'})).toBe(false); + expect(isEmpty({'': ''})).toBe(false); + expect(isEmpty({a: undefined})).toBe(false); + }); + + it('should return false for numbers', () => { + expect(isEmpty(0)).toBe(false); + expect(isEmpty(1)).toBe(false); + expect(isEmpty(-1)).toBe(false); + expect(isEmpty(3.14)).toBe(false); + expect(isEmpty(NaN)).toBe(false); + expect(isEmpty(Infinity)).toBe(false); + }); + + it('should return false for booleans', () => { + expect(isEmpty(true)).toBe(false); + expect(isEmpty(false)).toBe(false); + }); + + it('should return false for functions', () => { + expect(isEmpty(() => {})).toBe(false); + expect(isEmpty(function () {})).toBe(false); + }); + + it('should return false for dates', () => { + expect(isEmpty(new Date())).toBe(false); + }); + + it('should return false for other object types', () => { + expect(isEmpty(new Set())).toBe(false); + expect(isEmpty(new Map())).toBe(false); + expect(isEmpty(/regex/)).toBe(false); + }); +}); diff --git a/packages/javascript/src/utils/extractTenantDomainFromIdTokenPayload.ts b/packages/javascript/src/utils/extractTenantDomainFromIdTokenPayload.ts index accc4f3f..8f760c24 100644 --- a/packages/javascript/src/utils/extractTenantDomainFromIdTokenPayload.ts +++ b/packages/javascript/src/utils/extractTenantDomainFromIdTokenPayload.ts @@ -16,7 +16,7 @@ * under the License. */ -import {IdTokenPayload} from '../models/token'; +import {IdToken} from '../models/token'; /** * Extracts the tenant domain from the ID token payload. @@ -29,7 +29,7 @@ import {IdTokenPayload} from '../models/token'; * * Consider extracting the tenant domain using a dedicated claim (e.g., `tenant_domain`) when available. */ -const extractTenantDomainFromIdTokenPayload = (payload: IdTokenPayload, subjectSeparator: string = '@'): string => { +const extractTenantDomainFromIdTokenPayload = (payload: IdToken, subjectSeparator: string = '@'): string => { const uid: string = payload.sub; if (!uid) return ''; diff --git a/packages/javascript/src/utils/extractUserClaimsFromIdToken.ts b/packages/javascript/src/utils/extractUserClaimsFromIdToken.ts index 5927f051..be9edcfc 100644 --- a/packages/javascript/src/utils/extractUserClaimsFromIdToken.ts +++ b/packages/javascript/src/utils/extractUserClaimsFromIdToken.ts @@ -16,7 +16,7 @@ * under the License. */ -import {IdTokenPayload} from '../models/token'; +import {IdToken} from '../models/token'; /** * Removes standard protocol-specific claims from the ID token payload @@ -42,8 +42,8 @@ import {IdTokenPayload} from '../models/token'; * // } * ``` */ -const extractUserClaimsFromIdToken = (payload: IdTokenPayload): Record => { - const filteredPayload: Partial = {...payload}; +const extractUserClaimsFromIdToken = (payload: IdToken): Record => { + const filteredPayload: Partial = {...payload}; const protocolClaims = [ 'iss', @@ -65,7 +65,7 @@ const extractUserClaimsFromIdToken = (payload: IdTokenPayload): Record { - delete filteredPayload[claim as keyof IdTokenPayload]; + delete filteredPayload[claim as keyof IdToken]; }); const userClaims: Record = {}; diff --git a/packages/javascript/src/utils/generateFlattenedUserProfile.ts b/packages/javascript/src/utils/generateFlattenedUserProfile.ts index 3ea6d692..a33edb7d 100644 --- a/packages/javascript/src/utils/generateFlattenedUserProfile.ts +++ b/packages/javascript/src/utils/generateFlattenedUserProfile.ts @@ -26,6 +26,9 @@ import {User} from '../models/user'; * a flat object with dot notation keys instead of nested objects. Multi-valued * properties and type-specific defaults are handled appropriately. * + * Additionally, any fields present in the response but not defined in the schema + * will be included to ensure no user data is lost during flattening. + * * @param meResponse - The response object containing user data * @param processedSchemas - Array of schema objects defining field properties * @param processedSchemas[].name - The field name/path for the property @@ -40,17 +43,22 @@ import {User} from '../models/user'; * { name: 'name.givenName', type: 'STRING', multiValued: false }, * { name: 'emails', type: 'STRING', multiValued: true } * ]; - * const response = { name: { givenName: 'John' }, emails: 'john@example.com' }; + * const response = { + * name: { givenName: 'John' }, + * emails: 'john@example.com', + * country: 'US' // This will be included even if not in schema + * }; * const profile = generateFlattenedUserProfile(response, schemas); - * // Result: { "name.givenName": 'John', emails: ['john@example.com'] } + * // Result: { "name.givenName": 'John', emails: ['john@example.com'], country: 'US' } * ``` */ const generateFlattenedUserProfile = (meResponse: any, processedSchemas: any[]): User => { const profile: User = {}; - const allSchemaNames = processedSchemas.map(schema => schema.name).filter(Boolean); + const allSchemaNames: string[] = processedSchemas.map((schema: any) => schema.name).filter(Boolean); - processedSchemas.forEach(schema => { + // First, process all schema-defined fields + processedSchemas.forEach((schema: any) => { const {name, type, multiValued} = schema; if (!name) return; @@ -58,8 +66,8 @@ const generateFlattenedUserProfile = (meResponse: any, processedSchemas: any[]): // Skip this property if it's a parent of other flattened properties // e.g., skip "name" if "name.givenName" or "name.familyName" exists // skip "roles" if "roles.default" exists - const hasChildProperties = allSchemaNames.some( - schemaName => schemaName !== name && schemaName.startsWith(name + '.'), + const hasChildProperties: boolean = allSchemaNames.some( + (schemaName: string) => schemaName !== name && schemaName.startsWith(`${name}.`), ); if (hasChildProperties) { @@ -67,25 +75,81 @@ const generateFlattenedUserProfile = (meResponse: any, processedSchemas: any[]): return; } - let value = get(meResponse, name); + let value: any = get(meResponse, name); + + // If value not found at top level, check within schema namespaces + if (value === undefined) { + const schemaNamespaces: string[] = [ + 'urn:ietf:params:scim:schemas:core:2.0:User', + 'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User', + 'urn:scim:wso2:schema', + 'urn:scim:schemas:extension:custom:User', + ]; + + schemaNamespaces.some((namespace: string) => { + if (meResponse[namespace]) { + // Try the field name directly within the namespace + if (meResponse[namespace][name] !== undefined) { + value = meResponse[namespace][name]; + return true; // Break out of some() + } + // Also try using get() for nested paths within the namespace + const nestedValue: any = get(meResponse[namespace], name); + if (nestedValue !== undefined) { + value = nestedValue; + return true; // Break out of some() + } + } + return false; + }); + } if (value !== undefined) { if (multiValued && !Array.isArray(value)) { value = [value]; } + } else if (multiValued) { + value = undefined; + } else if (type === 'STRING') { + value = ''; } else { - if (multiValued) { - value = undefined; - } else if (type === 'STRING') { - value = ''; - } else { - value = undefined; - } + value = undefined; } profile[name] = value; }); + // Then, include any additional fields from meResponse that aren't in the schema + // This ensures fields like 'country' are not missed if they exist in the response + const flattenObject = (obj: any, prefix: string = ''): void => { + if (obj && typeof obj === 'object' && !Array.isArray(obj)) { + Object.keys(obj).forEach((key: string) => { + const fullKey: string = prefix ? `${prefix}.${key}` : key; + const value: any = obj[key]; + + // Skip if this field is already processed by schema + if (Object.prototype.hasOwnProperty.call(profile, fullKey)) { + return; + } + + // Skip if this is a parent of schema-defined fields + const hasSchemaChildProperties: boolean = allSchemaNames.some((schemaName: string) => + schemaName.startsWith(`${fullKey}.`), + ); + + if (hasSchemaChildProperties) { + // Recursively process child properties + flattenObject(value, fullKey); + } else { + // Include the field as-is + profile[fullKey] = value; + } + }); + } + }; + + flattenObject(meResponse); + return profile; }; diff --git a/packages/javascript/src/utils/getAuthorizeRequestUrlParams.ts b/packages/javascript/src/utils/getAuthorizeRequestUrlParams.ts index f588467c..c7d69e5c 100644 --- a/packages/javascript/src/utils/getAuthorizeRequestUrlParams.ts +++ b/packages/javascript/src/utils/getAuthorizeRequestUrlParams.ts @@ -55,6 +55,7 @@ const getAuthorizeRequestUrlParams = ( options: { redirectUri: string; clientId: string; + clientSecret?: string; scopes?: string; responseMode?: string; codeChallenge?: string; @@ -64,7 +65,8 @@ const getAuthorizeRequestUrlParams = ( pkceOptions: {key: string}, customParams: Record, ): Map => { - const {redirectUri, clientId, scopes, responseMode, codeChallenge, codeChallengeMethod, prompt} = options; + const {redirectUri, clientId, clientSecret, scopes, responseMode, codeChallenge, codeChallengeMethod, prompt} = + options; const authorizeRequestParams: Map = new Map(); authorizeRequestParams.set('response_type', 'code'); @@ -77,6 +79,10 @@ const getAuthorizeRequestUrlParams = ( authorizeRequestParams.set('response_mode', responseMode as string); } + if (clientSecret) { + authorizeRequestParams.set('client_secret', clientSecret as string); + } + const pkceKey: string = pkceOptions?.key; if (codeChallenge) { diff --git a/packages/javascript/src/utils/isEmpty.ts b/packages/javascript/src/utils/isEmpty.ts new file mode 100644 index 00000000..f9b14ac5 --- /dev/null +++ b/packages/javascript/src/utils/isEmpty.ts @@ -0,0 +1,68 @@ +/** + * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under 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. + */ + +/** + * Checks if a value is considered empty. + * + * A value is considered empty if it is: + * - null + * - undefined + * - empty string ("") + * - string containing only whitespace characters + * - empty array ([]) + * - empty object ({}) + * + * @param value - The value to check + * @returns true if the value is empty, false otherwise + * + * @example + * ```typescript + * isEmpty(null); // true + * isEmpty(undefined); // true + * isEmpty(""); // true + * isEmpty(" "); // true + * isEmpty("hello"); // false + * isEmpty([]); // true + * isEmpty([1, 2, 3]); // false + * isEmpty({}); // true + * isEmpty({ name: "John" }); // false + * isEmpty(0); // false + * isEmpty(false); // false + * ``` + */ +const isEmpty = (value: any): boolean => { + if (value === null || value === undefined) { + return true; + } + + if (typeof value === 'string') { + return value.trim() === ''; + } + + if (Array.isArray(value)) { + return value.length === 0; + } + + if (typeof value === 'object' && value.constructor === Object) { + return Object.keys(value).length === 0; + } + + return false; +}; + +export default isEmpty; diff --git a/packages/javascript/src/utils/processOpenIDScopes.ts b/packages/javascript/src/utils/processOpenIDScopes.ts index 2c55ab96..c57a8b6d 100644 --- a/packages/javascript/src/utils/processOpenIDScopes.ts +++ b/packages/javascript/src/utils/processOpenIDScopes.ts @@ -39,17 +39,19 @@ import AsgardeoRuntimeError from '../errors/AsgardeoRuntimeError'; const processOpenIDScopes = (scopes: string | string[]): string => { let processedScopes: string[] = []; - if (Array.isArray(scopes)) { - processedScopes = scopes; - } else if (typeof scopes === 'string') { - processedScopes = scopes.split(' '); - } else { - throw new AsgardeoRuntimeError( - 'Scopes must be a string or an array of strings.', - 'processOpenIDScopes-Invalid-001', - 'javascript', - 'The provided scopes are not in the expected format. Please provide a string or an array of strings.', - ); + if (scopes) { + if (Array.isArray(scopes)) { + processedScopes = scopes; + } else if (typeof scopes === 'string') { + processedScopes = scopes.split(' '); + } else { + throw new AsgardeoRuntimeError( + 'Scopes must be a string or an array of strings.', + 'processOpenIDScopes-Invalid-001', + 'javascript', + 'The provided scopes are not in the expected format. Please provide a string or an array of strings.', + ); + } } OIDCRequestConstants.SignIn.Payload.DEFAULT_SCOPES.forEach((defaultScope: string) => { diff --git a/packages/javascript/src/utils/resolveFieldName.ts b/packages/javascript/src/utils/resolveFieldName.ts index e8572c91..4e01e3bd 100644 --- a/packages/javascript/src/utils/resolveFieldName.ts +++ b/packages/javascript/src/utils/resolveFieldName.ts @@ -17,7 +17,7 @@ */ import AsgardeoRuntimeError from '../errors/AsgardeoRuntimeError'; -import {ApplicationNativeAuthenticationAuthenticatorExtendedParamType} from '../models/application-native-authentication'; +import {EmbeddedSignInFlowAuthenticatorExtendedParamType} from '../models/embedded-signin-flow'; import {FieldType} from '../models/field'; const resolveFieldName = (field: any): string => { diff --git a/packages/javascript/src/utils/resolveFieldType.ts b/packages/javascript/src/utils/resolveFieldType.ts index 29cd4ab0..5d0d333b 100644 --- a/packages/javascript/src/utils/resolveFieldType.ts +++ b/packages/javascript/src/utils/resolveFieldType.ts @@ -18,15 +18,15 @@ import AsgardeoRuntimeError from '../errors/AsgardeoRuntimeError'; import { - ApplicationNativeAuthenticationAuthenticatorExtendedParamType, - ApplicationNativeAuthenticationAuthenticatorParamType, -} from '../models/application-native-authentication'; + EmbeddedSignInFlowAuthenticatorExtendedParamType, + EmbeddedSignInFlowAuthenticatorParamType, +} from '../models/embedded-signin-flow'; import {FieldType} from '../models/field'; const resolveFieldType = (field: any): FieldType => { - if (field.type === ApplicationNativeAuthenticationAuthenticatorParamType.String) { + if (field.type === EmbeddedSignInFlowAuthenticatorParamType.String) { // Check if there's a `param` property and if it matches a known type. - if (field.param === ApplicationNativeAuthenticationAuthenticatorExtendedParamType.Otp) { + if (field.param === EmbeddedSignInFlowAuthenticatorExtendedParamType.Otp) { return FieldType.Otp; } else if (field?.confidential) { return FieldType.Password; diff --git a/packages/browser/src/utils/withVendorCSSClassPrefix.ts b/packages/javascript/src/utils/withVendorCSSClassPrefix.ts similarity index 89% rename from packages/browser/src/utils/withVendorCSSClassPrefix.ts rename to packages/javascript/src/utils/withVendorCSSClassPrefix.ts index 8d0f5297..21b14068 100644 --- a/packages/browser/src/utils/withVendorCSSClassPrefix.ts +++ b/packages/javascript/src/utils/withVendorCSSClassPrefix.ts @@ -16,7 +16,7 @@ * under the License. */ -import StyleConstants from '../constants/StyleConstants'; +import VendorConstants from '../constants/VendorConstants'; /** * Adds a vendor-specific prefix to a CSS class name. @@ -31,8 +31,6 @@ import StyleConstants from '../constants/StyleConstants'; * // Result: "wso2-sign-in-button" * ``` */ -const withVendorCSSClassPrefix = (className: string): string => { - return `${StyleConstants.VENDOR_CSS_CLASS_PREFIX}-${className}`; -}; +const withVendorCSSClassPrefix = (className: string): string => `${VendorConstants.VENDOR_PREFIX}-${className}`; export default withVendorCSSClassPrefix; diff --git a/packages/nextjs/QUICKSTART.md b/packages/nextjs/QUICKSTART.md new file mode 100644 index 00000000..54b01581 --- /dev/null +++ b/packages/nextjs/QUICKSTART.md @@ -0,0 +1,238 @@ +# `@asgardeo/nextjs` Quickstart + +This guide will help you quickly integrate Asgardeo authentication into your Next.js application using Auth.js. + +## Prerequisites + +- [Node.js](https://nodejs.org/en/download) (version 18 or later. LTS version recommended) +- An [Asgardeo account](https://wso2.com/asgardeo/docs/get-started/create-asgardeo-account/) +- About 15 minutes + +## Step 1: Configure an Application in Asgardeo + +1. **Sign in to Asgardeo Console** + - Go to [Asgardeo Console](https://console.asgardeo.io/) + - Sign in with your Asgardeo account + +2. **Create a New Application** + - Click **Applications** in the left sidebar + - Click **+ New Application** + - Choose **Traditional Web Application** + - Enter your application name (e.g., "Teamspace") + +3. **Note Down Your Credentials from the `Quickstart` tab** + - From the **Protocol** tab: + - Copy the **Client ID** + - Copy the **Client Secret** + - From the **Info** tab: + - Copy the **Issuer URL** + +4. **Configure Application Settings from the `Protocol` tab** + - **Authorized redirect URLs**: Add your callback URL + - `http://localhost:3000` + - Click **Update** to save the configuration + +## Step 2: Create a Next.js Application + +If you don't have a Next.js application set up yet, you can create one: + +```bash +# Using npm +npm create next-app@latest next-sample -- --yes +cd next-sample + +# Using pnpm +pnpm create next-app@latest next-sample -- --yes +cd next-sample + +# Using yarn +yarn create next-app next-sample -- --yes +cd next-sample +``` + +## Step 3: Install the SDK + +Install the Asgardeo Next.js SDK in your project: + +```bash +# Using npm +npm install @asgardeo/nextjs + +# Using pnpm +pnpm add @asgardeo/nextjs + +# Using yarn +yarn add @asgardeo/nextjs +``` + +## Step 4: Setup Environment Variables + +Create a `.env` file in your project root and add the following environment variables. Replace the placeholders with the values you copied from Asgardeo in Step 1. + +**.env** + +```bash +NEXT_PUBLIC_ASGARDEO_BASE_URL="https://api.asgardeo.io/t/" +NEXT_PUBLIC_ASGARDEO_CLIENT_ID="" +ASGARDEO_CLIENT_SECRET="" +``` + +## Step 5: Setup the Middleware + +Create a `middleware.ts` file in your project root to handle authentication: + +```bash +import { AsgardeoNext } from '@asgardeo/nextjs'; +import { NextRequest } from 'next/server'; + +const asgardeo = new AsgardeoNext(); + +asgardeo.initialize({ + baseUrl: process.env.NEXT_PUBLIC_ASGARDEO_BASE_URL, + clientId: process.env.NEXT_PUBLIC_ASGARDEO_CLIENT_ID, + clientSecret: process.env.ASGARDEO_CLIENT_SECRET, +}); + +export async function middleware(request: NextRequest) { + return await asgardeo.middleware(request); +} + +export const config = { + matcher: [ + '/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)', + '/(api|trpc)(.*)', + ], +}; +``` + +## Step 6: Configure the Provider + +Wrap your application with the `AsgardeoProvider` in your main entry file i.e. `app/layout.tsx`: + +```tsx +import type { Metadata } from "next"; +import { Geist, Geist_Mono } from "next/font/google"; +import {AsgardeoProvider} from '@asgardeo/nextjs'; +import "./globals.css"; + +const geistSans = Geist({ + variable: "--font-geist-sans", + subsets: ["latin"], +}); + +const geistMono = Geist_Mono({ + variable: "--font-geist-mono", + subsets: ["latin"], +}); + +export const metadata: Metadata = { + title: "Create Next App", + description: "Generated by create next app", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + {children} + + + ); +} +``` + +## Step 7: Add Sign-in & Sign-out to Your App + +Update your `app/page.tsx` to include sign-in and sign-out functionality: + +```tsx +import {SignInButton, SignedIn, SignOutButton, SignedOut} from '@asgardeo/nextjs'; + +export default function Home() { + return ( + <> + + + + + + + + ); +} +``` + +## Step 8: Display User Information + +You can also display user information by using the `User` component & the `UserProfile` component: + +```tsx +import { SignedIn, SignedOut, SignInButton, SignOutButton, User, UserProfile } from '@asgardeo/nextjs'; + +export default function Home() { + return ( + <> + + + {({ user }) => ( +
+

Welcome, {user.username}

+
+ )} +
+ + +
+ + + + + ); +} +``` + +## Step 9: Try Login + +Run your application and test the sign-in functionality. You should see a "Sign In" button when you're not signed in, and clicking it will redirect you to the Asgardeo sign-in page. + +```bash +# Using npm +npm run dev + +# Using pnpm +pnpm dev + +# Using yarn +yarn dev +``` + +## Next Steps + +🎉 **Congratulations!** You've successfully integrated Asgardeo authentication into your Next.js app. + +### What to explore next: + +- **User Profile Management** - Access and display detailed user profile information +- **Protected Routes** - Implement route-level authentication using middleware +- **Custom Styling** - Customize the appearance of your authentication flow +- **Session Management** - Learn about managing user sessions and tokens +- **Error Handling** - Implement proper error handling for authentication flows + +### Common Issues + +- **Redirect URL Mismatch**: Ensure your redirect URL in Asgardeo matches exactly: `http://localhost:3000` +- **Environment Variables**: Double-check that all environment variables are correctly set in `.env` + +### Additional Resources + +- **[Auth.js Documentation](https://authjs.dev/)** - Learn more about Auth.js features and configuration +- **[Asgardeo Documentation](https://wso2.com/asgardeo/docs/)** - Comprehensive guide to Asgardeo features +- **[Next.js Documentation](https://nextjs.org/docs)** - Learn more about Next.js features and best practices + +For more help, visit the [Asgardeo Documentation](https://wso2.com/asgardeo/docs/) or check out our [examples](../../examples/). diff --git a/packages/nextjs/src/AsgardeoNextClient.ts b/packages/nextjs/src/AsgardeoNextClient.ts index 77479de3..7afa1b4d 100644 --- a/packages/nextjs/src/AsgardeoNextClient.ts +++ b/packages/nextjs/src/AsgardeoNextClient.ts @@ -18,20 +18,32 @@ import { AsgardeoNodeClient, + AsgardeoRuntimeError, + EmbeddedFlowExecuteRequestPayload, + EmbeddedFlowExecuteResponse, LegacyAsgardeoNodeClient, SignInOptions, SignOutOptions, + SignUpOptions, User, UserProfile, + initializeEmbeddedSignInFlow, + Organization, + EmbeddedSignInFlowHandleRequestPayload, + executeEmbeddedSignInFlow, + EmbeddedFlowExecuteRequestConfig, + CookieConfig, + generateSessionId, + EmbeddedSignInFlowStatus, } from '@asgardeo/node'; import {NextRequest, NextResponse} from 'next/server'; +import InternalAuthAPIRoutesConfig from './configs/InternalAuthAPIRoutesConfig'; import {AsgardeoNextConfig} from './models/config'; import deleteSessionId from './server/actions/deleteSessionId'; import getSessionId from './server/actions/getSessionId'; import getIsSignedIn from './server/actions/isSignedIn'; import setSessionId from './server/actions/setSessionId'; import decorateConfigWithNextEnv from './utils/decorateConfigWithNextEnv'; -import InternalAuthAPIRoutesConfig from './configs/InternalAuthAPIRoutesConfig'; const removeTrailingSlash = (path: string): string => (path.endsWith('/') ? path.slice(0, -1) : path); /** @@ -54,23 +66,36 @@ class AsgardeoNextClient exte return this.asgardeo.initialize({ baseUrl, - clientId: clientId, + clientId, clientSecret, - afterSignInUrl: afterSignInUrl, + afterSignInUrl, + enablePKCE: false, ...rest, } as any); } override async getUser(userId?: string): Promise { - let resolvedSessionId: string = userId || ((await getSessionId()) as string); + const resolvedSessionId: string = userId || ((await getSessionId()) as string); return this.asgardeo.getUser(resolvedSessionId); } + override async getOrganizations(): Promise { + throw new Error('Method not implemented.'); + } + override getUserProfile(): Promise { throw new Error('Method not implemented.'); } + override switchOrganization(organization: Organization): Promise { + throw new Error('Method not implemented.'); + } + + override getCurrentOrganization(): Promise { + throw new Error('Method not implemented.'); + } + override isLoading(): boolean { return false; } @@ -79,27 +104,45 @@ class AsgardeoNextClient exte return this.asgardeo.isSignedIn(sessionId as string); } - override async signIn( + override signIn( options?: SignInOptions, sessionId?: string, - beforeSignIn?: (redirectUrl: string) => NextResponse, - authorizationCode?: string, - sessionState?: string, - state?: string, - ): Promise { - let resolvedSessionId: string = sessionId || ((await getSessionId()) as string); - - if (!resolvedSessionId) { - resolvedSessionId = await setSessionId(sessionId); + onSignInSuccess?: (afterSignInUrl: string) => void, + ): Promise; + override signIn( + payload: EmbeddedSignInFlowHandleRequestPayload, + request: EmbeddedFlowExecuteRequestConfig, + sessionId?: string, + onSignInSuccess?: (afterSignInUrl: string) => void, + ): Promise; + override async signIn(...args: any[]): Promise { + const arg1 = args[0]; + const arg2 = args[1]; + const arg3 = args[2]; + const arg4 = args[3]; + + if (typeof arg1 === 'object' && 'flowId' in arg1 && typeof arg1 === 'object' && 'url' in arg2) { + if (arg1.flowId === '') { + return initializeEmbeddedSignInFlow({ + payload: arg2.payload, + url: arg2.url, + }); + } + + return executeEmbeddedSignInFlow({ + payload: arg1, + url: arg2.url, + }); } return this.asgardeo.signIn( - beforeSignIn as any, - resolvedSessionId, - authorizationCode, - sessionState, - state, - ) as unknown as User; + arg4, + arg3, + arg1?.code, + arg1?.session_state, + arg1?.state, + arg1 as any, + ) as unknown as Promise; } override signOut(options?: SignOutOptions, afterSignOut?: (redirectUrl: string) => void): Promise; @@ -118,47 +161,126 @@ class AsgardeoNextClient exte return Promise.resolve(await this.asgardeo.signOut(resolvedSessionId)); } + override async signUp(options?: SignUpOptions): Promise; + override async signUp(payload: EmbeddedFlowExecuteRequestPayload): Promise; + override async signUp(...args: any[]): Promise { + throw new AsgardeoRuntimeError( + 'Not implemented', + 'react-AsgardeoReactClient-ValidationError-002', + 'react', + 'The signUp method with SignUpOptions is not implemented in the React client.', + ); + } + async handler(req: NextRequest): Promise { const {pathname, searchParams} = req.nextUrl; const sanitizedPathname: string = removeTrailingSlash(pathname); const {method} = req; - if ((method === 'GET' && sanitizedPathname === InternalAuthAPIRoutesConfig.signIn) || searchParams.get('code')) { - let response: NextResponse | undefined; - - await this.signIn( - {}, - undefined, - (redirectUrl: string) => { - return (response = NextResponse.redirect(redirectUrl, 302)); - }, - searchParams.get('code') as string, - searchParams.get('session_state') as string, - searchParams.get('state') as string, - ); - - // If we already redirected via the callback, return that - if (response) { - return response; + // Handle POST sign-in request + if (method === 'POST' && sanitizedPathname === InternalAuthAPIRoutesConfig.signIn) { + try { + // Get session ID from cookies directly since we're in middleware context + let userId: string | undefined = req.cookies.get(CookieConfig.SESSION_COOKIE_NAME)?.value; + + // Generate session ID if not present + if (!userId) { + userId = generateSessionId(); + } + + const signInUrl: URL = new URL(await this.asgardeo.getSignInUrl({response_mode: 'direct'}, userId)); + const {pathname: urlPathname, origin, searchParams: urlSearchParams} = signInUrl; + + console.log('[AsgardeoNextClient] Sign-in URL:', signInUrl.toString()); + console.log('[AsgardeoNextClient] Search Params:', Object.fromEntries(urlSearchParams.entries())); + const body = await req.json(); + + console.log('[AsgardeoNextClient] Sign-in request:', body); + + const {payload, request} = body; + + const response: any = await this.signIn( + payload, + { + url: request?.url ?? `${origin}${urlPathname}`, + payload: request?.payload ?? Object.fromEntries(urlSearchParams.entries()), + }, + userId, + ); + + // Clean the response to remove any non-serializable properties + const cleanResponse = response ? JSON.parse(JSON.stringify(response)) : {success: true}; + + // Create response with session cookie + const nextResponse = NextResponse.json(cleanResponse); + + // Set session cookie if it was generated + if (!req.cookies.get(CookieConfig.SESSION_COOKIE_NAME)) { + nextResponse.cookies.set(CookieConfig.SESSION_COOKIE_NAME, userId, { + httpOnly: CookieConfig.DEFAULT_HTTP_ONLY, + maxAge: CookieConfig.DEFAULT_MAX_AGE, + sameSite: CookieConfig.DEFAULT_SAME_SITE, + secure: CookieConfig.DEFAULT_SECURE, + }); + } + + if (response.flowStatus === EmbeddedSignInFlowStatus.SuccessCompleted) { + const res = await this.signIn( + { + code: response?.authData?.code, + session_state: response?.authData?.session_state, + state: response?.authData?.state, + } as any, + {}, + userId, + (afterSignInUrl: string) => null, + ); + + const afterSignInUrl = await ( + await this.asgardeo.getStorageManager() + ).getConfigDataParameter('afterSignInUrl'); + const redirectUrl = String(afterSignInUrl); + console.log('[AsgardeoNextClient] Sign-in successful, redirecting to:', redirectUrl); + + return NextResponse.redirect(redirectUrl, 303); + } + + return nextResponse; + } catch (error) { + console.error('[AsgardeoNextClient] Failed to initialize embedded sign-in flow:', error); + return NextResponse.json({error: 'Failed to initialize sign-in flow'}, {status: 500}); } + } - if (searchParams.get('code')) { - const cleanUrl: URL = new URL(req.url); - cleanUrl.searchParams.delete('code'); - cleanUrl.searchParams.delete('state'); - cleanUrl.searchParams.delete('session_state'); + // Handle GET sign-in request or callback with code + if ((method === 'GET' && sanitizedPathname === InternalAuthAPIRoutesConfig.signIn) || searchParams.get('code')) { + try { + if (searchParams.get('code')) { + // Handle OAuth callback + await this.signIn(); - return NextResponse.redirect(cleanUrl.toString()); - } + const cleanUrl: URL = new URL(req.url); + cleanUrl.searchParams.delete('code'); + cleanUrl.searchParams.delete('state'); + cleanUrl.searchParams.delete('session_state'); - return NextResponse.next(); + return NextResponse.redirect(cleanUrl.toString()); + } + + // Regular GET sign-in request + await this.signIn(); + return NextResponse.next(); + } catch (error) { + console.error('[AsgardeoNextClient] Sign-in failed:', error); + return NextResponse.json({error: 'Sign-in failed'}, {status: 500}); + } } if (method === 'GET' && sanitizedPathname === InternalAuthAPIRoutesConfig.session) { try { const isSignedIn: boolean = await getIsSignedIn(); - return NextResponse.json({isSignedIn: isSignedIn}); + return NextResponse.json({isSignedIn}); } catch (error) { return NextResponse.json({error: 'Failed to check session'}, {status: 500}); } diff --git a/packages/nextjs/src/client/components/presentation/SignIn/SignIn.tsx b/packages/nextjs/src/client/components/presentation/SignIn/SignIn.tsx new file mode 100644 index 00000000..336a0a94 --- /dev/null +++ b/packages/nextjs/src/client/components/presentation/SignIn/SignIn.tsx @@ -0,0 +1,130 @@ +/** + * 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. + */ + +'use client'; + +import { + EmbeddedFlowExecuteRequestConfig, + EmbeddedSignInFlowHandleRequestPayload, + EmbeddedSignInFlowHandleResponse, + EmbeddedSignInFlowInitiateResponse, +} from '@asgardeo/node'; +import {BaseSignIn, BaseSignInProps} from '@asgardeo/react'; +import {FC} from 'react'; +import useAsgardeo from '../../../contexts/Asgardeo/useAsgardeo'; + +/** + * Props for the SignIn component. + * Extends BaseSignInProps for full compatibility with the React BaseSignIn component + */ +export type SignInProps = BaseSignInProps; + +/** + * A SignIn component for Next.js that provides native authentication flow. + * This component delegates to the BaseSignIn from @asgardeo/react and requires + * the API functions to be provided as props. + * + * @remarks This component requires the authentication API functions to be provided + * as props. For a complete working example, you'll need to implement the server-side + * authentication endpoints or use the traditional OAuth flow with SignInButton. + * + * @example + * ```tsx + * import { SignIn } from '@asgardeo/nextjs'; + * import { executeEmbeddedSignInFlow } from '@asgardeo/browser'; + * + * const LoginPage = () => { + * const handleInitialize = async () => { + * return await executeEmbeddedSignInFlow({ + * response_mode: 'direct', + * }); + * }; + * + * const handleSubmit = async (flow) => { + * return await executeEmbeddedSignInFlow({ flow }); + * }; + * + * return ( + * { + * console.log('Authentication successful:', authData); + * }} + * onError={(error) => { + * console.error('Authentication failed:', error); + * }} + * size="medium" + * variant="outlined" + * afterSignInUrl="/dashboard" + * /> + * ); + * }; + * ``` + */ +const SignIn: FC = ({ + afterSignInUrl, + className, + onError, + onFlowChange, + onInitialize, + onSubmit, + onSuccess, + size = 'medium', + variant = 'outlined', + ...rest +}: SignInProps) => { + const {signIn} = useAsgardeo(); + + const handleInitialize = async (): Promise => + await signIn({ + flowId: '', + selectedAuthenticator: { + authenticatorId: '', + params: {}, + }, + }); + + const handleOnSubmit = async ( + payload: EmbeddedSignInFlowHandleRequestPayload, + request: EmbeddedFlowExecuteRequestConfig, + ): Promise => await signIn(payload, request); + + const handleError = (error: Error): void => { + onError?.(error); + }; + + return ( + + ); +}; + +SignIn.displayName = 'SignIn'; + +export default SignIn; diff --git a/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoContext.ts b/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoContext.ts index 139e8439..ba25b8df 100644 --- a/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoContext.ts +++ b/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoContext.ts @@ -19,7 +19,7 @@ 'use client'; import {AsgardeoContextProps as AsgardeoReactContextProps} from '@asgardeo/react'; -import {User} from '@asgardeo/node'; +import {EmbeddedFlowExecuteRequestConfig, EmbeddedSignInFlowHandleRequestPayload, User} from '@asgardeo/node'; import {Context, createContext} from 'react'; /** @@ -29,7 +29,7 @@ export type AsgardeoContextProps = Partial & { user?: User | null; isSignedIn?: boolean; isLoading?: boolean; - signIn?: () => void; + signIn?: (payload: EmbeddedSignInFlowHandleRequestPayload, request: EmbeddedFlowExecuteRequestConfig) => void; signOut?: () => void; }; diff --git a/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoProvider.tsx b/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoProvider.tsx index 0d4c7976..950ff345 100644 --- a/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoProvider.tsx +++ b/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoProvider.tsx @@ -18,9 +18,10 @@ 'use client'; -import {FC, PropsWithChildren, useEffect, useMemo, useState} from 'react'; +import {EmbeddedFlowExecuteRequestConfig, EmbeddedSignInFlowHandleRequestPayload, User} from '@asgardeo/node'; import {I18nProvider, FlowProvider, UserProvider, ThemeProvider} from '@asgardeo/react'; -import {User} from '@asgardeo/node'; +import {FC, PropsWithChildren, useEffect, useMemo, useState} from 'react'; +import {useRouter} from 'next/navigation'; import AsgardeoContext from './AsgardeoContext'; import InternalAuthAPIRoutesConfig from '../../../configs/InternalAuthAPIRoutesConfig'; @@ -38,6 +39,7 @@ const AsgardeoClientProvider: FC> children, preferences, }: PropsWithChildren) => { + const router = useRouter(); const [isDarkMode, setIsDarkMode] = useState(false); const [user, setUser] = useState(null); const [isLoading, setIsLoading] = useState(true); @@ -81,12 +83,29 @@ const AsgardeoClientProvider: FC> fetchUserData(); }, []); + const signIn = async (payload: EmbeddedSignInFlowHandleRequestPayload, request: EmbeddedFlowExecuteRequestConfig) => { + const response = await fetch(InternalAuthAPIRoutesConfig.signIn, { + method: 'POST', + body: JSON.stringify({ + payload, + request, + }), + }); + + if (response.redirected && response.url) { + router.push(response.url!); + return {redirected: true, location: response.url}; + } + + return response.json(); + }; + const contextValue = useMemo( () => ({ user, isSignedIn, isLoading, - signIn: () => (window.location.href = InternalAuthAPIRoutesConfig.signIn), + signIn, signOut: () => (window.location.href = InternalAuthAPIRoutesConfig.signOut), }), [user, isSignedIn, isLoading], diff --git a/packages/nextjs/src/index.ts b/packages/nextjs/src/index.ts index 2ba4b280..4cbfbb73 100644 --- a/packages/nextjs/src/index.ts +++ b/packages/nextjs/src/index.ts @@ -30,6 +30,9 @@ export {SignedOutProps} from './client/components/control/SignedOut/SignedOut'; export {default as SignInButton} from './client/components/actions/SignInButton/SignInButton'; export type {SignInButtonProps} from './client/components/actions/SignInButton/SignInButton'; +export {default as SignIn} from './client/components/presentation/SignIn/SignIn'; +export type {SignInProps} from './client/components/presentation/SignIn/SignIn'; + export {default as SignOutButton} from './client/components/actions/SignOutButton/SignOutButton'; export type {SignOutButtonProps} from './client/components/actions/SignOutButton/SignOutButton'; diff --git a/packages/node/src/__legacy__/client.ts b/packages/node/src/__legacy__/client.ts index 00406486..3b66b8ac 100644 --- a/packages/node/src/__legacy__/client.ts +++ b/packages/node/src/__legacy__/client.ts @@ -1,7 +1,7 @@ /** - * Copyright (c) 2022, WSO2 Inc. (http://www.wso2.com) All Rights Reserved. + * Copyright (c) {{year}}, WSO2 LLC. (https://www.wso2.com). * - * WSO2 Inc. licenses this file to you under the Apache License, + * 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 @@ -20,11 +20,12 @@ import { AuthClientConfig, TokenExchangeRequestConfig, StorageManager, - IdTokenPayload, + IdToken, OIDCEndpoints, Storage, TokenResponse, User, + ExtendedAuthorizeRequestUrlParams, } from '@asgardeo/javascript'; import {AsgardeoNodeCore} from './core'; import {AuthURLCallback} from './models'; @@ -110,6 +111,15 @@ export class AsgardeoNodeClient { return this._authCore.signIn(authURLCallback, userId, authorizationCode, sessionState, state, signInConfig); } + /** + * Method to get the configuration data. + * + * @returns {Promise>} - A promise that resolves with the configuration data. + */ + public async getConfigData(): Promise> { + return this._authCore.getConfigData(); + } + /** * This method clears all session data and returns the sign-out URL. * @param {string} userId - The userId of the user. (If you are using ExpressJS, @@ -219,7 +229,7 @@ export class AsgardeoNodeClient { * @param {string} userId - The userId of the user. * (If you are using ExpressJS, you may get this from the request cookies) * - * @return {Promise} -A Promise that resolves with + * @return {Promise} -A Promise that resolves with * an object containing the decoded ID token payload. * * @example @@ -232,7 +242,7 @@ export class AsgardeoNodeClient { * @memberof AsgardeoNodeClient * */ - public async getDecodedIdToken(userId?: string): Promise { + public async getDecodedIdToken(userId?: string): Promise { return this._authCore.getDecodedIdToken(userId); } @@ -298,10 +308,7 @@ export class AsgardeoNodeClient { * @memberof AsgardeoNodeClient * */ - public async exchangeToken( - config: TokenExchangeRequestConfig, - userId?: string, - ): Promise { + public async exchangeToken(config: TokenExchangeRequestConfig, userId?: string): Promise { return this._authCore.exchangeToken(config, userId); } @@ -328,6 +335,10 @@ export class AsgardeoNodeClient { return this._authCore.reInitialize(config); } + public async getSignInUrl(requestConfig?: ExtendedAuthorizeRequestUrlParams, userId?: string): Promise { + return this._authCore.getAuthURL(userId, requestConfig); + } + /** * This method returns a Promise that resolves with the response returned by the server. * @param {string} userId - The userId of the user. diff --git a/packages/node/src/__legacy__/core/authentication.ts b/packages/node/src/__legacy__/core/authentication.ts index f4be26a7..00e33fdb 100644 --- a/packages/node/src/__legacy__/core/authentication.ts +++ b/packages/node/src/__legacy__/core/authentication.ts @@ -23,7 +23,7 @@ import { Crypto, TokenExchangeRequestConfig, StorageManager, - IdTokenPayload, + IdToken, OIDCEndpoints, SessionData, Storage, @@ -215,11 +215,15 @@ export class AsgardeoNodeCore { return this._auth.getUser(userId); } + public async getConfigData(): Promise> { + return this._storageManager.getConfigData(); + } + public async getOpenIDProviderEndpoints(): Promise { return this._auth.getOpenIDProviderEndpoints() as Promise; } - public async getDecodedIdToken(userId?: string): Promise { + public async getDecodedIdToken(userId?: string): Promise { return this._auth.getDecodedIdToken(userId); } @@ -227,10 +231,7 @@ export class AsgardeoNodeCore { return this._auth.getAccessToken(userId); } - public async exchangeToken( - config: TokenExchangeRequestConfig, - userId?: string, - ): Promise { + public async exchangeToken(config: TokenExchangeRequestConfig, userId?: string): Promise { return this._auth.exchangeToken(config, userId); } diff --git a/packages/nuxt/src/runtime/composables/asgardeo/useAuth.ts b/packages/nuxt/src/runtime/composables/asgardeo/useAuth.ts index 456377eb..822e2064 100644 --- a/packages/nuxt/src/runtime/composables/asgardeo/useAuth.ts +++ b/packages/nuxt/src/runtime/composables/asgardeo/useAuth.ts @@ -16,7 +16,7 @@ * under the License. */ -import type {User, StorageManager, IdTokenPayload, OIDCEndpoints} from '@asgardeo/node'; +import type {User, StorageManager, IdToken, OIDCEndpoints} from '@asgardeo/node'; import type {AuthInterface} from '../../types'; import {navigateTo} from '#imports'; @@ -98,9 +98,9 @@ export const useAuth = (): AuthInterface => { * which expects a valid session cookie. If the session is valid, the function * returns the decoded ID token payload. * - * @returns {Promise} - A promise that resolves to the decoded ID token payload if available, or null if not. + * @returns {Promise} - A promise that resolves to the decoded ID token payload if available, or null if not. */ - const getDecodedIdToken = async (): Promise => { + const getDecodedIdToken = async (): Promise => { try { const response: Response = await fetch(`/api/auth/get-decoded-id-token`, { credentials: 'include', diff --git a/packages/nuxt/src/runtime/types.ts b/packages/nuxt/src/runtime/types.ts index 0300cf3d..f82e0613 100644 --- a/packages/nuxt/src/runtime/types.ts +++ b/packages/nuxt/src/runtime/types.ts @@ -16,7 +16,7 @@ * under the License. */ -import type {User, StorageManager, IdTokenPayload, OIDCEndpoints} from '@asgardeo/node'; +import type {User, StorageManager, IdToken, OIDCEndpoints} from '@asgardeo/node'; export interface ModuleOptions { /** @@ -61,7 +61,7 @@ export interface AuthInterface { getAccessToken: () => Promise; getUser: () => Promise; getStorageManager: () => Promise | null>; - getDecodedIdToken: () => Promise; + getDecodedIdToken: () => Promise; getIdToken: () => Promise; getOpenIDProviderEndpoints: () => Promise; isSignedIn: () => Promise; diff --git a/packages/react-router/LICENSE b/packages/react-router/LICENSE new file mode 100644 index 00000000..261eeb9e --- /dev/null +++ b/packages/react-router/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed 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. diff --git a/packages/react-router/README.md b/packages/react-router/README.md new file mode 100644 index 00000000..b72a7625 --- /dev/null +++ b/packages/react-router/README.md @@ -0,0 +1,386 @@ +# @asgardeo/react-router + +React Router integration for Asgardeo React SDK with protected routes and authentication guards. + +[![npm version](https://img.shields.io/npm/v/@asgardeo/react-router.svg)](https://www.npmjs.com/package/@asgardeo/react-router) +[![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) + +## Overview + +`@asgardeo/react-router` is a supplementary package that provides seamless integration between Asgardeo authentication and React Router. It offers components and hooks to easily protect routes and handle authentication flows in your React applications. + +## Features + +- 🛡️ **ProtectedRoute Component**: Drop-in replacement for React Router's Route with built-in authentication +- 🔒 **withAuthentication HOC**: Higher-order component for protecting any React component +- 🪝 **Authentication Hooks**: Powerful hooks for custom authentication logic +- 🔄 **Return URL Handling**: Automatic redirect back to intended destination after sign-in +- ⚡ **TypeScript Support**: Full TypeScript support with comprehensive type definitions +- 🎨 **Customizable**: Flexible configuration options for different use cases + +## Installation + +```bash +npm install @asgardeo/react-router +# or +yarn add @asgardeo/react-router +# or +pnpm add @asgardeo/react-router +``` + +### Peer Dependencies + +This package requires the following peer dependencies: + +```bash +npm install @asgardeo/react react react-router-dom +``` + +## Quick Start + +### 1. Basic Setup with ProtectedRoute + +```tsx +import React from 'react'; +import { BrowserRouter, Routes, Route } from 'react-router-dom'; +import { AsgardeoProvider } from '@asgardeo/react'; +import { ProtectedRoute } from '@asgardeo/react-router'; +import Dashboard from './components/Dashboard'; +import Profile from './components/Profile'; +import SignIn from './components/SignIn'; + +function App() { + return ( + + + + Public Home Page} /> + } /> + + + + } + /> + + + + } + /> + + + + ); +} + +export default App; +``` + +### 2. Custom Fallback and Redirects + +```tsx +import { ProtectedRoute } from '@asgardeo/react-router'; + +// Redirect to custom login page + + + + } +/> + +// Custom fallback component + +

Please sign in

+

You need to be signed in to access this page.

+ + }> + + + } +/> + +// Custom loading state +Loading...} + > + + + } +/> +``` + +## API Reference + +### Components + +#### ProtectedRoute + +A component that protects routes based on authentication status. Should be used as the element prop of a Route component. + +```tsx +interface ProtectedRouteProps { + children: React.ReactElement; + fallback?: React.ReactElement; + redirectTo?: string; + showLoading?: boolean; + loadingElement?: React.ReactElement; +} +``` + +**Props:** + +- `children` - The component to render when authenticated +- `fallback` - Custom component to render when not authenticated (takes precedence over redirectTo) +- `redirectTo` - URL to redirect to when not authenticated (required unless fallback is provided) +- `showLoading` - Whether to show loading state (default: `true`) +- `loadingElement` - Custom loading component + +**Note:** Either `fallback` or `redirectTo` must be provided to handle unauthenticated users. + +#### withAuthentication + +Higher-order component that wraps any component with authentication protection. + +```tsx +import { withAuthentication } from '@asgardeo/react-router'; + +const Dashboard = () =>
Protected Dashboard
; + +const ProtectedDashboard = withAuthentication(Dashboard, { + redirectTo: '/login' +}); + +// With role-based access +const AdminPanel = withAuthentication(AdminPanelComponent, { + additionalCheck: (authContext) => { + return authContext.user?.groups?.includes('admin'); + }, + fallback:
Access denied
+}); +``` + +### Hooks + +#### useAuthGuard + +Hook that provides authentication guard functionality for routes. + +```tsx +import { useAuthGuard } from '@asgardeo/react-router'; + +function Dashboard() { + const { isAllowed, isLoading } = useAuthGuard({ + redirectTo: '/login' + }); + + if (isLoading) return
Loading...
; + if (!isAllowed) return null; // Will redirect + + return
Protected Dashboard Content
; +} +``` + +**Options:** + +- `redirectTo` - Path to redirect when not authenticated (default: `'/login'`) +- `preserveReturnUrl` - Whether to preserve current location as return URL (default: `true`) +- `additionalCheck` - Additional authorization check function +- `immediate` - Whether to check immediately on mount (default: `true`) + +**Returns:** + +- `isAllowed` - Whether user can access the route +- `isLoading` - Whether authentication is being checked +- `isAuthenticated` - Whether user is signed in +- `meetsAdditionalChecks` - Whether additional checks pass +- `authContext` - Full Asgardeo authentication context +- `checkAuth()` - Function to manually trigger auth check + +#### useReturnUrl + +Hook for handling return URLs after authentication. + +```tsx +import { useReturnUrl } from '@asgardeo/react-router'; +import { useAsgardeo } from '@asgardeo/react'; + +function LoginPage() { + const { returnTo, navigateToReturnUrl } = useReturnUrl(); + const { signIn } = useAsgardeo(); + + const handleSignIn = async () => { + await signIn(); + navigateToReturnUrl(); // Redirects to original destination + }; + + return ( +
+ + {returnTo &&

You'll be redirected to: {returnTo}

} +
+ ); +} +``` + +**Returns:** + +- `returnTo` - The URL to return to after authentication +- `navigateToReturnUrl(fallback?)` - Function to navigate to return URL +- `hasReturnUrl` - Whether a return URL is available + +## Advanced Usage + +### Role-Based Access Control + +```tsx +import { withAuthentication } from '@asgardeo/react-router'; + +const AdminPanel = withAuthentication(AdminPanelComponent, { + additionalCheck: (authContext) => { + const userRoles = authContext.user?.groups || []; + return userRoles.includes('admin') || userRoles.includes('moderator'); + }, + fallback: ( +
+

Access Denied

+

You don't have permission to access this page.

+
+ ) +}); +``` + +### Custom Authentication Flow + +```tsx +import { useAuthGuard } from '@asgardeo/react-router'; + +function CustomProtectedPage() { + const { isAllowed, authContext, checkAuth } = useAuthGuard({ + immediate: false, // Don't redirect immediately + additionalCheck: (auth) => auth.user?.email_verified === true + }); + + if (!authContext.isSignedIn) { + return ( +
+

Sign In Required

+ +
+ ); + } + + if (!authContext.user?.email_verified) { + return ( +
+

Email Verification Required

+

Please verify your email before accessing this page.

+
+ ); + } + + return
Protected Content
; +} +``` + +### Integration with Layouts + +```tsx +import { ProtectedRoute } from '@asgardeo/react-router'; + +function App() { + return ( + + + {/* Public routes */} + } /> + } /> + } /> + + {/* Protected routes with layout */} + }> + + + + } + /> + + + + } + /> + + + + } + /> + + + + ); +} +``` + +## Examples + +Check out our [examples directory](./examples) for complete working examples: + +- [Basic Protected Routes](./examples/basic) +- [Role-Based Access Control](./examples/rbac) +- [Custom Authentication Flow](./examples/custom-flow) +- [Integration with Next.js](./examples/nextjs) + +## TypeScript Support + +This package is written in TypeScript and provides comprehensive type definitions. All components and hooks are fully typed for the best development experience. + +```tsx +import type { ProtectedRouteProps, WithAuthenticationOptions } from '@asgardeo/react-router'; +``` + +## Contributing + +We welcome contributions! Please see our [Contributing Guide](../../CONTRIBUTING.md) for details. + +## License + +This project is licensed under the Apache License 2.0. See the [LICENSE](LICENSE) file for details. + +## Support + +- 📖 [Documentation](https://wso2.com/asgardeo/docs/sdks/react/) +- 💬 [Community Forum](https://stackoverflow.com/questions/tagged/asgardeo) +- 🐛 [Issues](https://github.com/asgardeo/web-ui-sdks/issues) + +--- + +Built with ❤️ by the [Asgardeo](https://wso2.com/asgardeo/) team. diff --git a/packages/react-router/esbuild.config.mjs b/packages/react-router/esbuild.config.mjs new file mode 100644 index 00000000..efecbbeb --- /dev/null +++ b/packages/react-router/esbuild.config.mjs @@ -0,0 +1,53 @@ +/** + * 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 {readFileSync} from 'fs'; +import {build} from 'esbuild'; +import {preserveDirectivesPlugin} from 'esbuild-plugin-preserve-directives'; + +const pkg = JSON.parse(readFileSync('./package.json', 'utf8')); + +const commonOptions = { + bundle: true, + entryPoints: ['src/index.ts'], + external: [...Object.keys(pkg.dependencies || {}), ...Object.keys(pkg.peerDependencies || {})], + metafile: true, + platform: 'browser', + plugins: [ + preserveDirectivesPlugin({ + directives: ['use client', 'use strict'], + include: /\.(js|ts|jsx|tsx)$/, + exclude: /node_modules/, + }), + ], + target: ['es2020'], +}; + +await build({ + ...commonOptions, + format: 'esm', + outfile: 'dist/index.js', + sourcemap: true, +}); + +await build({ + ...commonOptions, + format: 'cjs', + outfile: 'dist/cjs/index.js', + sourcemap: true, +}); diff --git a/packages/react-router/package.json b/packages/react-router/package.json new file mode 100644 index 00000000..2ab25b12 --- /dev/null +++ b/packages/react-router/package.json @@ -0,0 +1,73 @@ +{ + "name": "@asgardeo/react-router", + "version": "0.1.0", + "description": "React Router integration for Asgardeo React SDK with protected routes.", + "keywords": [ + "asgardeo", + "react", + "react-router", + "protected-routes", + "authentication" + ], + "homepage": "https://github.com/asgardeo/web-ui-sdks/tree/main/packages/react-router#readme", + "bugs": { + "url": "https://github.com/asgardeo/web-ui-sdks/issues" + }, + "author": "WSO2", + "license": "Apache-2.0", + "type": "module", + "main": "dist/cjs/index.js", + "module": "dist/index.js", + "exports": { + "import": "./dist/index.js", + "require": "./dist/cjs/index.js" + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "types": "dist/index.d.ts", + "repository": { + "type": "git", + "url": "https://github.com/asgardeo/web-ui-sdks", + "directory": "packages/react-router" + }, + "scripts": { + "build": "pnpm clean && node esbuild.config.mjs && tsc -p tsconfig.lib.json --emitDeclarationOnly --outDir dist", + "clean": "rimraf dist", + "fix:lint": "eslint . --ext .js,.jsx,.ts,.tsx,.cjs,.mjs", + "lint": "eslint . --ext .js,.jsx,.ts,.tsx,.cjs,.mjs", + "test": "vitest", + "test:browser": "vitest --workspace=vitest.workspace.ts", + "typecheck": "tsc -p tsconfig.lib.json" + }, + "devDependencies": { + "@types/node": "^22.15.3", + "@types/react": "^19.1.4", + "@types/react-router-dom": "^5.3.3", + "@wso2/eslint-plugin": "catalog:", + "@wso2/prettier-config": "catalog:", + "esbuild-plugin-preserve-directives": "^0.0.11", + "esbuild": "^0.25.4", + "eslint": "8.57.0", + "prettier": "^2.6.2", + "react": "^19.1.0", + "react-router-dom": "^6.30.0", + "rimraf": "^6.0.1", + "typescript": "~5.7.2", + "vitest": "^3.1.3" + }, + "peerDependencies": { + "@asgardeo/react": "workspace:^", + "@types/react": ">=16.8.0", + "react": ">=16.8.0", + "react-router-dom": ">=6.0.0" + }, + "dependencies": { + "tslib": "^2.8.1" + }, + "publishConfig": { + "access": "public" + } +} \ No newline at end of file diff --git a/packages/react-router/prettier.config.cjs b/packages/react-router/prettier.config.cjs new file mode 100644 index 00000000..929b9b15 --- /dev/null +++ b/packages/react-router/prettier.config.cjs @@ -0,0 +1,19 @@ +/** + * 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. + */ + +module.exports = require('@wso2/prettier-config'); diff --git a/packages/react-router/src/__tests__/index.test.ts b/packages/react-router/src/__tests__/index.test.ts new file mode 100644 index 00000000..5f0247b7 --- /dev/null +++ b/packages/react-router/src/__tests__/index.test.ts @@ -0,0 +1,41 @@ +/** + * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import {describe, it, expect} from 'vitest'; + +describe('@asgardeo/react-router', () => { + it('should export ProtectedRoute', async () => { + const {ProtectedRoute} = await import('../index'); + expect(ProtectedRoute).toBeDefined(); + }); + + it('should export withAuthentication', async () => { + const {withAuthentication} = await import('../index'); + expect(withAuthentication).toBeDefined(); + }); + + it('should export useAuthGuard', async () => { + const {useAuthGuard} = await import('../index'); + expect(useAuthGuard).toBeDefined(); + }); + + it('should export useReturnUrl', async () => { + const {useReturnUrl} = await import('../index'); + expect(useReturnUrl).toBeDefined(); + }); +}); diff --git a/packages/react-router/src/components/ProtectedRoute.tsx b/packages/react-router/src/components/ProtectedRoute.tsx new file mode 100644 index 00000000..6e4d9add --- /dev/null +++ b/packages/react-router/src/components/ProtectedRoute.tsx @@ -0,0 +1,127 @@ +/** + * 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 from 'react'; +import {Navigate} from 'react-router-dom'; +import {useAsgardeo} from '@asgardeo/react'; + +/** + * Props for the ProtectedRoute component. + */ +export interface ProtectedRouteProps { + /** + * The element to render when the user is authenticated. + */ + children: React.ReactElement; + /** + * Custom fallback element to render when the user is not authenticated. + * If provided, this takes precedence over redirectTo. + */ + fallback?: React.ReactElement; + /** + * URL to redirect to when the user is not authenticated. + * Required unless a fallback element is provided. + */ + redirectTo?: string; + /** + * Whether to show a loading state while authentication status is being determined. + * @default true + */ + showLoading?: boolean; + /** + * Custom loading element to render while authentication status is being determined. + */ + loadingElement?: React.ReactElement; +} + +/** + * A protected route component that requires authentication to access. + * + * This component should be used as the element prop of a Route component. + * It checks authentication status and either renders the protected content, + * shows a loading state, redirects, or shows a fallback. + * + * Either a `redirectTo` prop or a `fallback` prop must be provided to handle + * unauthenticated users. + * + * @example Basic usage with redirect + * ```tsx + * + * + * + * } + * /> + * ``` + * + * @example With custom fallback + * ```tsx + * Access denied}> + * + * + * } + * /> + * ``` + */ +const ProtectedRoute: React.FC = ({ + children, + fallback, + redirectTo, + showLoading = true, + loadingElement, +}) => { + const {isSignedIn, isLoading, signIn} = useAsgardeo(); + + // Show loading state while authentication status is being determined + if (isLoading && showLoading) { + if (loadingElement) { + return loadingElement; + } + return ( +
+
Loading...
+
+ ); + } + + // If user is authenticated, render the protected content + if (isSignedIn) { + return children; + } + + // If user is not authenticated, handle fallback/redirect + if (fallback) { + return fallback; + } + + if (redirectTo) { + return ; + } + + // If neither fallback nor redirectTo is provided, throw an error + throw new Error( + 'ProtectedRoute: Either "fallback" or "redirectTo" prop must be provided to handle unauthenticated users.', + ); +}; + +export default ProtectedRoute; diff --git a/packages/react-router/src/components/withAuthentication.tsx b/packages/react-router/src/components/withAuthentication.tsx new file mode 100644 index 00000000..dd8e4e63 --- /dev/null +++ b/packages/react-router/src/components/withAuthentication.tsx @@ -0,0 +1,163 @@ +/** + * 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 from 'react'; +import {Navigate} from 'react-router-dom'; +import {useAsgardeo} from '@asgardeo/react'; + +/** + * Options for the withAuthentication higher-order component. + */ +export interface WithAuthenticationOptions { + /** + * Custom fallback element to render when the user is not authenticated. + */ + fallback?: React.ReactElement; + /** + * URL to redirect to when the user is not authenticated. + */ + redirectTo?: string; + /** + * Whether to show a loading state while authentication status is being determined. + * @default true + */ + showLoading?: boolean; + /** + * Custom loading element to render while authentication status is being determined. + */ + loadingElement?: React.ReactElement; + /** + * Additional condition that must be met for the user to access the component. + * This function receives the authentication context and should return true if access is allowed. + */ + additionalCheck?: (authContext: ReturnType) => boolean; +} + +/** + * Higher-order component that wraps a component with authentication protection. + * + * This HOC can be used to protect any React component, not just Route components. + * It provides more flexibility for complex authentication scenarios. + * + * @param WrappedComponent - The component to protect with authentication + * @param options - Configuration options for the authentication protection + * + * @example + * ```tsx + * import { withAuthentication } from '@asgardeo/react-router'; + * + * const Dashboard = () =>
Protected Dashboard
; + * + * const ProtectedDashboard = withAuthentication(Dashboard, { + * redirectTo: '/login' + * }); + * ``` + * + * @example With additional checks + * ```tsx + * const AdminPanel = withAuthentication(AdminPanelComponent, { + * additionalCheck: (authContext) => { + * // Only allow users with admin role + * return authContext.user?.groups?.includes('admin'); + * }, + * fallback:
You don't have permission to access this page
+ * }); + * ``` + */ +const withAuthentication =

( + WrappedComponent: React.ComponentType

, + options: WithAuthenticationOptions = {}, +): React.FC

=> { + const {fallback, redirectTo, showLoading = true, loadingElement, additionalCheck} = options; + + const AuthenticatedComponent: React.FC

= props => { + const authContext = useAsgardeo(); + const {isSignedIn, isLoading, signIn} = authContext; + + // Show loading state while authentication status is being determined + if (isLoading && showLoading) { + if (loadingElement) { + return loadingElement; + } + return ( +

+
Loading...
+
+ ); + } + + // Check if user is authenticated + if (!isSignedIn) { + if (fallback) { + return fallback; + } + + if (redirectTo) { + return ; + } + + // Default behavior: show sign-in prompt + return ( +
+
+

Authentication Required

+

You need to sign in to access this page.

+ +
+
+ ); + } + + // Check additional conditions if provided + if (additionalCheck && !additionalCheck(authContext)) { + if (fallback) { + return fallback; + } + + return ( +
+
+

Access Denied

+

You don't have permission to access this page.

+
+
+ ); + } + + // User is authenticated and meets all conditions, render the wrapped component + return ; + }; + + AuthenticatedComponent.displayName = `withAuthentication(${WrappedComponent.displayName || WrappedComponent.name})`; + + return AuthenticatedComponent; +}; + +export default withAuthentication; diff --git a/packages/react-router/src/hooks/useAuthGuard.ts b/packages/react-router/src/hooks/useAuthGuard.ts new file mode 100644 index 00000000..8858a35f --- /dev/null +++ b/packages/react-router/src/hooks/useAuthGuard.ts @@ -0,0 +1,218 @@ +/** + * 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 {useEffect} from 'react'; +import {useLocation, useNavigate} from 'react-router-dom'; +import {useAsgardeo} from '@asgardeo/react'; + +/** + * Options for the useAuthGuard hook. + */ +export interface UseAuthGuardOptions { + /** + * Path to redirect to when the user is not authenticated. + * @default '/login' + */ + redirectTo?: string; + /** + * Whether to preserve the current location as a return URL. + * When true, adds a 'returnTo' query parameter with the current path. + * @default true + */ + preserveReturnUrl?: boolean; + /** + * Additional condition that must be met for the user to access the route. + * This function receives the authentication context and should return true if access is allowed. + */ + additionalCheck?: (authContext: ReturnType) => boolean; + /** + * Whether to check authentication status immediately on mount. + * @default true + */ + immediate?: boolean; +} + +/** + * Hook that provides authentication guard functionality for routes. + * + * This hook can be used within any component to enforce authentication + * and optionally redirect unauthenticated users. + * + * @param options - Configuration options for the authentication guard + * + * @returns Object containing authentication status and helper functions + * + * @example + * ```tsx + * import { useAuthGuard } from '@asgardeo/react-router'; + * + * function Dashboard() { + * const { isAllowed, isLoading } = useAuthGuard({ + * redirectTo: '/login' + * }); + * + * if (isLoading) return
Loading...
; + * if (!isAllowed) return null; // Will redirect + * + * return
Protected Dashboard Content
; + * } + * ``` + * + * @example With additional checks + * ```tsx + * function AdminPanel() { + * const { isAllowed, authContext } = useAuthGuard({ + * additionalCheck: (auth) => auth.user?.groups?.includes('admin'), + * redirectTo: '/unauthorized' + * }); + * + * if (!isAllowed) return null; + * + * return
Admin Panel
; + * } + * ``` + */ +export const useAuthGuard = (options: UseAuthGuardOptions = {}) => { + const {redirectTo = '/login', preserveReturnUrl = true, additionalCheck, immediate = true} = options; + + const authContext = useAsgardeo(); + const {isSignedIn, isLoading} = authContext; + const navigate = useNavigate(); + const location = useLocation(); + + const isAuthenticated = isSignedIn; + const meetsAdditionalChecks = additionalCheck ? additionalCheck(authContext) : true; + const isAllowed = isAuthenticated && meetsAdditionalChecks; + + useEffect(() => { + if (!immediate || isLoading) { + return; + } + + if (!isAuthenticated) { + const redirectUrl = preserveReturnUrl + ? `${redirectTo}?returnTo=${encodeURIComponent(location.pathname + location.search)}` + : redirectTo; + + navigate(redirectUrl, {replace: true}); + return; + } + + if (!meetsAdditionalChecks) { + navigate(redirectTo, {replace: true}); + } + }, [isAuthenticated, meetsAdditionalChecks, immediate, isLoading, navigate, redirectTo, preserveReturnUrl, location]); + + return { + /** + * Whether the user is allowed to access the current route. + */ + isAllowed, + /** + * Whether authentication status is still being determined. + */ + isLoading, + /** + * Whether the user is authenticated. + */ + isAuthenticated, + /** + * Whether the user meets additional authorization checks. + */ + meetsAdditionalChecks, + /** + * The full authentication context from useAsgardeo. + */ + authContext, + /** + * Function to manually trigger the authentication check and redirect. + */ + checkAuth: () => { + if (!isAuthenticated) { + const redirectUrl = preserveReturnUrl + ? `${redirectTo}?returnTo=${encodeURIComponent(location.pathname + location.search)}` + : redirectTo; + + navigate(redirectUrl, {replace: true}); + return; + } + + if (!meetsAdditionalChecks) { + navigate(redirectTo, {replace: true}); + } + }, + }; +}; + +/** + * Hook that provides functionality for handling return URLs after authentication. + * + * This hook is typically used on login/authentication pages to redirect users + * back to where they were trying to go before being redirected to sign in. + * + * @example + * ```tsx + * import { useReturnUrl } from '@asgardeo/react-router'; + * + * function LoginPage() { + * const { returnTo, navigateToReturnUrl } = useReturnUrl(); + * const { signIn } = useAsgardeo(); + * + * const handleSignIn = async () => { + * await signIn(); + * navigateToReturnUrl(); // Redirects to original destination + * }; + * + * return ( + *
+ * + * {returnTo &&

You'll be redirected to: {returnTo}

} + *
+ * ); + * } + * ``` + */ +export const useReturnUrl = () => { + const navigate = useNavigate(); + const location = useLocation(); + + const searchParams = new URLSearchParams(location.search); + const returnTo = searchParams.get('returnTo'); + + const navigateToReturnUrl = (fallbackPath = '/') => { + const destination = returnTo || fallbackPath; + navigate(destination, {replace: true}); + }; + + return { + /** + * The URL to return to after authentication, if available. + */ + returnTo, + /** + * Function to navigate to the return URL or a fallback path. + */ + navigateToReturnUrl, + /** + * Whether a return URL is available. + */ + hasReturnUrl: Boolean(returnTo), + }; +}; + +export default useAuthGuard; diff --git a/packages/react-router/src/index.ts b/packages/react-router/src/index.ts new file mode 100644 index 00000000..ef47bf9c --- /dev/null +++ b/packages/react-router/src/index.ts @@ -0,0 +1,28 @@ +/** + * 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. + */ + +// Components +export {default as ProtectedRoute} from './components/ProtectedRoute'; +export * from './components/ProtectedRoute'; + +export {default as withAuthentication} from './components/withAuthentication'; +export * from './components/withAuthentication'; + +// Hooks +export {default as useAuthGuard, useReturnUrl} from './hooks/useAuthGuard'; +export * from './hooks/useAuthGuard'; diff --git a/packages/react-router/tsconfig.eslint.json b/packages/react-router/tsconfig.eslint.json new file mode 100644 index 00000000..d5eefbbb --- /dev/null +++ b/packages/react-router/tsconfig.eslint.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "include": ["**/.*.js", "**/.*.cjs", "**/.*.ts", "**/*.js", "**/*.cjs", "**/*.ts"] +} diff --git a/packages/react-router/tsconfig.json b/packages/react-router/tsconfig.json new file mode 100644 index 00000000..e9810e1f --- /dev/null +++ b/packages/react-router/tsconfig.json @@ -0,0 +1,37 @@ +{ + "extends": "../../tsconfig.json", + "compileOnSave": false, + "compilerOptions": { + "declaration": false, + "emitDecoratorMetadata": true, + "esModuleInterop": true, + "experimentalDecorators": true, + "importHelpers": true, + "jsx": "react-jsx", + "lib": ["ESNext", "DOM"], + "module": "ESNext", + "moduleResolution": "node", + "skipLibCheck": true, + "skipDefaultLibCheck": true, + "sourceMap": true, + "target": "ESNext", + "forceConsistentCasingInFileNames": true, + "strict": false, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "paths": {} + }, + "exclude": ["node_modules", "tmp", "dist"], + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/packages/react-router/tsconfig.lib.json b/packages/react-router/tsconfig.lib.json new file mode 100644 index 00000000..ebe76743 --- /dev/null +++ b/packages/react-router/tsconfig.lib.json @@ -0,0 +1,24 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": true, + "outDir": "dist", + "declarationDir": "dist", + "types": ["node"], + "skipLibCheck": true, + "declarationMap": true + }, + "exclude": [ + "**/*.spec.ts", + "**/*.test.ts", + "**/*.spec.tsx", + "**/*.test.tsx", + "**/*.spec.js", + "**/*.test.js", + "**/*.spec.jsx", + "**/*.test.jsx", + "node_modules", + "dist" + ], + "include": ["src/**/*.ts", "src/**/*.tsx"] +} diff --git a/packages/react-router/tsconfig.spec.json b/packages/react-router/tsconfig.spec.json new file mode 100644 index 00000000..02752e3e --- /dev/null +++ b/packages/react-router/tsconfig.spec.json @@ -0,0 +1,17 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "module": "commonjs", + "types": ["vitest", "node"] + }, + "include": [ + "test-configs", + "jest.config.js", + "**/*.test.ts", + "**/*.spec.ts", + "**/*.test.js", + "**/*.spec.js", + "**/*.d.ts" + ] +} diff --git a/packages/react-router/vitest.config.ts b/packages/react-router/vitest.config.ts new file mode 100644 index 00000000..dad7e145 --- /dev/null +++ b/packages/react-router/vitest.config.ts @@ -0,0 +1,25 @@ +/** + * 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 {defineConfig} from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'jsdom', + }, +}); diff --git a/packages/react/API.md b/packages/react/API.md index b76f0f65..bd295764 100644 --- a/packages/react/API.md +++ b/packages/react/API.md @@ -5,7 +5,7 @@ This document provides complete API documentation for the Asgardeo React SDK, in ## Table of Contents - [Components](#components) - - [AsgardeoProvider](#asgardeyprovider) + - [AsgardeoProvider](#asgardeoprovider) - [SignIn](#signin) - [SignedIn](#signedin) - [SignedOut](#signedout) @@ -13,6 +13,8 @@ This document provides complete API documentation for the Asgardeo React SDK, in - [SignOutButton](#signoutbutton) - [User](#user) - [UserProfile](#userprofile) + - [Loaded](#loaded) + - [Loading](#loading) - [Hooks](#hooks) - [useAsgardeo](#useasgardeo) - [Customization](#customization) @@ -36,6 +38,34 @@ The root provider component that configures the Asgardeo SDK and provides authen #### Example +##### Minimal Setup + +Following is a minimal setup with the mandatory configuration for the `AsgardeoProvider` in your main application file (e.g., `index.tsx`): + +```diff +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; ++ import { AsgardeoProvider } from '@asgardeo/react'; +import App from './App'; + +const root = createRoot(document.getElementById('root')); + +root.render( + ++ + ++ + +); +``` + +##### Advanced Usage + +For more advanced configurations, you can specify additional props like `afterSignInUrl`, `afterSignOutUrl`, and `scopes`: + ```diff import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; @@ -49,8 +79,8 @@ root.render( + @@ -59,6 +89,7 @@ root.render( ); ``` + **Customization:** See [Customization](#customization) section for theming and styling options. The provider doesn't render any visual elements but can be styled through CSS custom properties. #### Available CSS Classes @@ -102,13 +133,7 @@ const SignInPage = () => { return (

Welcome Back

-+ ++
); }; @@ -661,33 +686,132 @@ Replace default button text with custom content: ### Bring your own UI Library -For applications using popular UI libraries, you can easily integrate Asgardeo components: +For applications using popular UI libraries, you can leverage render props for maximum flexibility and control: -#### Material-UI Integration +#### Material-UI Integration with Render Props ```tsx -import { Button } from '@mui/material' -import { useAsgardeo } from '@asgardeo/react' +import { Button, CircularProgress } from '@mui/material' +import { SignIn } from '@asgardeo/react' function CustomSignInButton() { - const { signIn } = useAsgardeo() - return ( - + + {({ signIn, isLoading, error }) => ( + + )} + + ) +} +``` + +#### Tailwind CSS Integration with Render Props +```tsx +import { SignIn } from '@asgardeo/react' + +function TailwindSignInButton() { + return ( + + {({ signIn, isLoading, error }) => ( +
+ + {error && ( +

{error.message}

+ )} +
+ )} +
) } ``` -#### Tailwind CSS Integration +#### Ant Design Integration with Render Props ```tsx - - Sign In - +import { Button, Alert } from 'antd' +import { SignIn } from '@asgardeo/react' + +function AntdSignInButton() { + return ( + + {({ signIn, isLoading, error }) => ( +
+ + {error && ( + + )} +
+ )} +
+ ) +} +``` + +#### Chakra UI Integration with Render Props +```tsx +import { Button, Alert, AlertIcon, Spinner } from '@chakra-ui/react' +import { SignIn } from '@asgardeo/react' + +function ChakraSignInButton() { + return ( + + {({ signIn, isLoading, error }) => ( +
+ + {error && ( + + + {error.message} + + )} +
+ )} +
+ ) +} ``` ### Custom Loading States diff --git a/packages/react/COMPLETE GUIDE.md b/packages/react/COMPLETE GUIDE.md new file mode 100644 index 00000000..7d5c3caf --- /dev/null +++ b/packages/react/COMPLETE GUIDE.md @@ -0,0 +1,1123 @@ +# @asgardeo/react - Complete Guide + +A comprehensive guide to building React applications with Asgardeo authentication using the official Asgardeo React SDK. + +## Table of Contents + +- [Overview](#overview) +- [Installation](#installation) +- [Quick Start](#quick-start) +- [Configuration](#configuration) +- [Core Concepts](#core-concepts) +- [Components](#components) +- [Hooks](#hooks) +- [Advanced Usage](#advanced-usage) +- [Examples](#examples) +- [Troubleshooting](#troubleshooting) +- [API Reference](#api-reference) + +## Overview + +The `@asgardeo/react` SDK enables seamless authentication integration in React applications using Asgardeo Identity Server. It provides: + +- **Drop-in Components**: Pre-built UI components for authentication flows +- **React Hooks**: Programmatic access to authentication state and methods +- **Context Providers**: Global state management for authentication +- **Customizable UI**: Theming and styling options +- **TypeScript Support**: Full type safety and IntelliSense +- **Modern React**: Support for React 16.8+ with hooks + +## Installation + +```bash +# Using npm +npm install @asgardeo/react + +# Using pnpm +pnpm add @asgardeo/react + +# Using yarn +yarn add @asgardeo/react +``` + +### Peer Dependencies + +The SDK requires the following peer dependencies: + +```json +{ + "@types/react": ">=16.8.0", + "react": ">=16.8.0" +} +``` + +## Quick Start + +### 1. Set Up the Provider + +Wrap your application with `AsgardeoProvider` in your main entry file: + +#### Basic Setup + +```tsx +// main.tsx or index.tsx +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import { AsgardeoProvider } from '@asgardeo/react' +import App from './App' + +createRoot(document.getElementById('root')!).render( + + + + + +) +``` + +#### Advanced Setup + +```tsx +// main.tsx or index.tsx +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import { AsgardeoProvider } from '@asgardeo/react' +import App from './App' + +createRoot(document.getElementById('root')!).render( + + + + + +) +``` + +### 2. Build Your App + +Use the authentication components and hooks in your application: + +```tsx +// App.tsx +import { SignedIn, SignedOut, SignInButton, SignOutButton, User } from '@asgardeo/react' + +function App() { + return ( +
+ +
+ + {({ user }) => ( +
+

Welcome, {user.givenname}!

+

{user.email}

+
+ )} +
+ +
+
+ + +
+

Welcome to Our App

+

Please sign in to continue

+ +
+
+
+ ) +} + +export default App +``` + +## Configuration + +### AsgardeoProvider Props + +| Prop | Type | Required | Description | +|------|------|----------|-------------| +| `baseUrl` | `string` | ✅ | Your Asgardeo organization URL | +| `clientId` | `string` | ✅ | Your application's client ID | +| `afterSignInUrl` | `string` | ❌ | Redirect URL after successful sign-in | +| `afterSignOutUrl` | `string` | ❌ | Redirect URL after sign-out | +| `scopes` | `string[]` | ❌ | OAuth 2.0 scopes (default: `['openid', 'profile']`) | +| `preferences` | `object` | ❌ | Theme and UI customization options | + +### Environment Variables + +For better security and flexibility, use environment variables: + +```tsx +// .env +VITE_ASGARDEO_BASE_URL=https://api.asgardeo.io/t/your-org +VITE_ASGARDEO_CLIENT_ID=your-client-id +VITE_ASGARDEO_AFTER_SIGN_IN_URL=http://localhost:3000/dashboard +VITE_ASGARDEO_AFTER_SIGN_OUT_URL=http://localhost:3000 + +// main.tsx + + + +``` + +## Core Concepts + +### Authentication State + +The SDK manages authentication state globally through React Context: + +- **Loading State**: While determining authentication status +- **Signed In**: User is authenticated with valid tokens +- **Signed Out**: User is not authenticated +- **Error State**: Authentication errors or failures + +### Token Management + +Automatic handling of: +- Access tokens for API calls +- ID tokens for user information +- Refresh tokens for session management +- Token renewal and expiration + +### User Context + +Access to user information including: +- Profile data (name, email, etc.) +- Claims and attributes +- Authentication metadata + +## Components + +### Control Components + +#### SignedIn + +Renders children only when the user is authenticated: + +```tsx +import { SignedIn } from '@asgardeo/react' + + +
This content is only visible to authenticated users
+
+``` + +#### SignedOut + +Renders children only when the user is not authenticated: + +```tsx +import { SignedOut } from '@asgardeo/react' + + +
Please sign in to access this application
+
+``` + +#### Loading + +Shows content while authentication state is being determined: + +```tsx +import { Loading } from '@asgardeo/react' + + +
Checking authentication status...
+
+``` + +#### Loaded + +Shows content after authentication state has been determined: + +```tsx +import { Loaded } from '@asgardeo/react' + + +
Authentication check complete
+
+``` + +### Action Components + +#### SignInButton + +Pre-built sign-in button: + +```tsx +import { SignInButton } from '@asgardeo/react' + +// Basic usage + + +// With custom styling + + +// With custom text + + Log In to Your Account + +``` + +#### SignOutButton + +Pre-built sign-out button: + +```tsx +import { SignOutButton } from '@asgardeo/react' + +// Basic usage + + +// With custom styling + + +// With custom text + + Log Out + +``` + +#### SignUpButton + +Pre-built sign-up button: + +```tsx +import { SignUpButton } from '@asgardeo/react' + + +``` + +### Presentation Components + +#### User + +Access user information with render props: + +```tsx +import { User } from '@asgardeo/react' + + + {({ user, isLoading, error }) => { + if (isLoading) return
Loading user...
+ if (error) return
Error: {error.message}
+ + return ( +
+ {user.username} +

{user.givenname} {user.familyname}

+

{user.email}

+
+ ) + }} +
+``` + +#### UserProfile + +Complete user profile component: + +```tsx +import { UserProfile } from '@asgardeo/react' + +// Basic usage + + +// With custom styling + +``` + +#### UserDropdown + +User menu dropdown component: + +```tsx +import { UserDropdown } from '@asgardeo/react' + + +``` + +#### SignIn + +Complete sign-in form component: + +```tsx +import { SignIn } from '@asgardeo/react' + + { + console.log('Sign-in successful:', authData) + // Handle successful sign-in + }} + onError={(error) => { + console.error('Sign-in failed:', error) + // Handle sign-in error + }} +/> +``` + +#### SignUp + +Complete sign-up form component: + +```tsx +import { SignUp } from '@asgardeo/react' + + { + console.log('Sign-up successful:', authData) + }} + onError={(error) => { + console.error('Sign-up failed:', error) + }} +/> +``` + +### Primitive Components + +The SDK includes low-level UI primitives for building custom interfaces: + +- `Button` - Customizable button component +- `TextField` - Text input field +- `PasswordField` - Password input with visibility toggle +- `Card` - Container component +- `Alert` - Message display component +- `Spinner` - Loading indicator +- `Typography` - Text styling component + +```tsx +import { Button, TextField, Card } from '@asgardeo/react' + + + + + +``` + +## Hooks + +### useAsgardeo + +Main hook for accessing authentication state and methods: + +```tsx +import { useAsgardeo } from '@asgardeo/react' + +function MyComponent() { + const { + user, + isSignedIn, + isLoading, + error, + signIn, + signOut, + getAccessToken, + getIdToken, + refreshTokens + } = useAsgardeo() + + const handleProtectedApiCall = async () => { + try { + const token = await getAccessToken() + const response = await fetch('/api/protected', { + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }) + const data = await response.json() + console.log(data) + } catch (error) { + console.error('API call failed:', error) + } + } + + if (isLoading) { + return
Loading...
+ } + + return ( +
+ {isSignedIn ? ( +
+

Welcome, {user?.givenname}!

+ + +
+ ) : ( + + )} +
+ ) +} +``` + +### useUser + +Access user-specific data and operations: + +```tsx +import { useUser } from '@asgardeo/react' + +function UserComponent() { + const { user, isLoading, error, refreshUser } = useUser() + + if (isLoading) return
Loading user data...
+ if (error) return
Error: {error.message}
+ + return ( +
+

{user.givenname} {user.familyname}

+

Email: {user.email}

+

Username: {user.username}

+ +
+ ) +} +``` + +### useTheme + +Access and customize theme settings: + +```tsx +import { useTheme } from '@asgardeo/react' + +function ThemedComponent() { + const { theme, setTheme } = useTheme() + + return ( +
+

Current theme mode: {theme.mode}

+ +
+ ) +} +``` + +### useI18n + +Internationalization support: + +```tsx +import { useI18n } from '@asgardeo/react' + +function LocalizedComponent() { + const { t, language, setLanguage } = useI18n() + + return ( +
+

{t('welcome.title')}

+

{t('welcome.description')}

+ +
+ ) +} +``` + +## Advanced Usage + +### Custom Authentication Flow + +For advanced use cases, you can implement custom authentication flows: + +```tsx +import { BaseSignIn } from '@asgardeo/react' + +function CustomSignIn() { + const handleInitialize = async () => { + // Custom initialization logic + return await initializeCustomAuth() + } + + const handleSubmit = async (payload) => { + // Custom authentication handling + return await handleCustomAuth(payload) + } + + const handleSuccess = (authData) => { + // Custom success handling + console.log('Authentication successful:', authData) + // Redirect or update UI + } + + const handleError = (error) => { + // Custom error handling + console.error('Authentication failed:', error) + // Show error message + } + + return ( + + ) +} +``` + +### Protected Routes + +Implement route protection with React Router: + +```tsx +import { Navigate, useLocation } from 'react-router-dom' +import { useAsgardeo } from '@asgardeo/react' + +function ProtectedRoute({ children }) { + const { isSignedIn, isLoading } = useAsgardeo() + const location = useLocation() + + if (isLoading) { + return
Loading...
+ } + + if (!isSignedIn) { + return + } + + return children +} + +// Usage + + } /> + + + + } /> + +``` + +### API Integration + +Integrate with protected APIs: + +```tsx +import { useAsgardeo } from '@asgardeo/react' + +function useApi() { + const { getAccessToken } = useAsgardeo() + + const apiCall = async (url, options = {}) => { + const token = await getAccessToken() + + const response = await fetch(url, { + ...options, + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + ...options.headers + } + }) + + if (!response.ok) { + throw new Error(`API call failed: ${response.statusText}`) + } + + return response.json() + } + + return { apiCall } +} + +// Usage in component +function DataComponent() { + const { apiCall } = useApi() + const [data, setData] = useState(null) + const [loading, setLoading] = useState(false) + + const fetchData = async () => { + setLoading(true) + try { + const result = await apiCall('/api/user-data') + setData(result) + } catch (error) { + console.error('Failed to fetch data:', error) + } finally { + setLoading(false) + } + } + + return ( +
+ + {data &&
{JSON.stringify(data, null, 2)}
} +
+ ) +} +``` + +### Theme Customization + +Customize the appearance of components: + +```tsx +const customTheme = { + mode: 'light', + overrides: { + colors: { + primary: { + main: '#1976d2', + contrastText: '#ffffff' + }, + secondary: { + main: '#dc004e', + contrastText: '#ffffff' + } + }, + typography: { + fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif' + }, + spacing: { + unit: 8 + } + } +} + + + + +``` + +### Error Handling + +Implement comprehensive error handling: + +```tsx +import { useAsgardeo } from '@asgardeo/react' + +function ErrorBoundary({ children }) { + const { error, isLoading } = useAsgardeo() + + if (error) { + return ( +
+

Authentication Error

+

{error.message}

+ +
+ ) + } + + return children +} + +function App() { + return ( + +
+ {/* Your app content */} +
+
+ ) +} +``` + +## Examples + +### Complete Dashboard App + +```tsx +import { + AsgardeoProvider, + SignedIn, + SignedOut, + useAsgardeo, + User, + SignInButton, + SignOutButton +} from '@asgardeo/react' + +// Main App Component +function App() { + return ( + + + + ) +} + +// Layout Component +function Layout() { + return ( +
+
+
+ + + + + + +
+
+ ) +} + +// Header Component +function Header() { + return ( +
+
+

My App

+ + +
+ + {({ user }) => ( + Welcome, {user.givenname}! + )} + + +
+
+ + + + +
+
+ ) +} + +// Dashboard Component +function Dashboard() { + const { user, getAccessToken } = useAsgardeo() + const [data, setData] = useState(null) + const [loading, setLoading] = useState(false) + + const fetchUserData = async () => { + setLoading(true) + try { + const token = await getAccessToken() + const response = await fetch('/api/user/profile', { + headers: { + 'Authorization': `Bearer ${token}` + } + }) + const userData = await response.json() + setData(userData) + } catch (error) { + console.error('Failed to fetch user data:', error) + } finally { + setLoading(false) + } + } + + useEffect(() => { + fetchUserData() + }, []) + + return ( +
+
+

Dashboard

+
+
+

Profile Information

+

Name: {user?.givenname} {user?.familyname}

+

Email: {user?.email}

+

Username: {user?.username}

+
+ +
+

Additional Data

+ {loading ? ( +

Loading...

+ ) : data ? ( +
+                {JSON.stringify(data, null, 2)}
+              
+ ) : ( +

No additional data available

+ )} +
+
+
+
+ ) +} + +// Landing Page Component +function LandingPage() { + return ( +
+
+

+ Welcome to My App +

+

+ A secure application powered by Asgardeo +

+
+ +
+ + Get Started - Sign In + +

+ Don't have an account? Contact your administrator. +

+
+
+ ) +} + +export default App +``` + +### Multi-Step Authentication Flow + +```tsx +import { BaseSignIn, useFlow } from '@asgardeo/react' + +function MultiStepAuth() { + const { currentStep, messages } = useFlow() + + const handleInitialize = async () => { + return await initializeAuthFlow() + } + + const handleSubmit = async (payload) => { + return await processAuthStep(payload) + } + + const handleSuccess = (authData) => { + // Redirect to dashboard + window.location.href = '/dashboard' + } + + const handleError = (error) => { + console.error('Authentication error:', error) + } + + return ( +
+
+

Sign In

+ + {messages.map((message, index) => ( +
+ {message.message} +
+ ))} + + + +
+

Step {currentStep?.stepType || 1} of the authentication process

+
+
+
+ ) +} +``` + +## Troubleshooting + +### Common Issues + +#### 1. "useAsgardeo must be used within AsgardeoProvider" + +**Problem**: Hook is used outside of provider context. + +**Solution**: Ensure your component is wrapped with `AsgardeoProvider`: + +```tsx +// ❌ Wrong +function App() { + const { isSignedIn } = useAsgardeo() // Error! + return
App
+} + +// ✅ Correct +function App() { + return ( + + + + ) +} + +function MyComponent() { + const { isSignedIn } = useAsgardeo() // Works! + return
Component
+} +``` + +#### 2. Infinite loading state + +**Problem**: Authentication state never resolves. + +**Solution**: Check configuration and network connectivity: + +```tsx +// Add error handling +const { isLoading, error } = useAsgardeo() + +if (error) { + console.error('Auth error:', error) + // Handle error appropriately +} +``` + +#### 3. CORS errors + +**Problem**: Cross-origin requests blocked. + +**Solution**: Configure CORS in your Asgardeo application settings or use a proxy during development. + +#### 4. Token expiration + +**Problem**: API calls fail due to expired tokens. + +**Solution**: Implement automatic token refresh: + +```tsx +const { refreshTokens, getAccessToken } = useAsgardeo() + +const apiCall = async (url, options) => { + try { + const token = await getAccessToken() + return await fetch(url, { + ...options, + headers: { + 'Authorization': `Bearer ${token}`, + ...options.headers + } + }) + } catch (error) { + if (error.status === 401) { + await refreshTokens() + const newToken = await getAccessToken() + return await fetch(url, { + ...options, + headers: { + 'Authorization': `Bearer ${newToken}`, + ...options.headers + } + }) + } + throw error + } +} +``` + +### Debugging Tips + +1. **Enable Debug Logs**: Set up console logging for authentication events +2. **Check Network Tab**: Verify API calls and responses +3. **Validate Configuration**: Ensure all required props are provided +4. **Test in Incognito**: Rule out cache/storage issues +5. **Check Asgardeo Console**: Verify application configuration + +## API Reference + +For complete API documentation including all components, hooks, and customization options, see [API.md](./API.md). + +### Key Exports + +```typescript +// Providers +export { AsgardeoProvider } from '@asgardeo/react' + +// Hooks +export { useAsgardeo, useUser, useTheme, useI18n } from '@asgardeo/react' + +// Control Components +export { SignedIn, SignedOut, Loading, Loaded } from '@asgardeo/react' + +// Action Components +export { SignInButton, SignOutButton, SignUpButton } from '@asgardeo/react' + +// Presentation Components +export { SignIn, SignUp, User, UserProfile, UserDropdown } from '@asgardeo/react' + +// Primitive Components +export { Button, TextField, Card, Alert, Spinner } from '@asgardeo/react' +``` + +### TypeScript Support + +The SDK is written in TypeScript and provides full type definitions: + +```typescript +import type { + AsgardeoProviderProps, + User, + AuthState, + SignInOptions, + ThemeConfig +} from '@asgardeo/react' +``` + +--- + +## Support + +- **Documentation**: [Complete API Reference](./API.md) +- **GitHub Issues**: [Report bugs or request features](https://github.com/asgardeo/web-ui-sdks/issues) +- **Community**: [Join the discussion](https://github.com/asgardeo/web-ui-sdks/discussions) + +## License + +This project is licensed under the Apache License 2.0. See [LICENSE](../../LICENSE) for details. diff --git a/packages/react/QUICKSTART.md b/packages/react/QUICKSTART.md new file mode 100644 index 00000000..2ab5543d --- /dev/null +++ b/packages/react/QUICKSTART.md @@ -0,0 +1,178 @@ +# `@asgardeo/react` Quickstart + +This guide will help you quickly integrate Asgardeo authentication into your React application. + +## Prerequisites + +- [Node.js](https://nodejs.org/en/download) (version 16 or later. LTS version recommended) +- An [Asgardeo account](https://wso2.com/asgardeo/docs/get-started/create-asgardeo-account/) + +## Step 1: Configure an Application in Asgardeo + +1. **Sign in to Asgardeo Console** + - Go to [Asgardeo Console](https://console.asgardeo.io/) + - Sign in with your Asgardeo account + +2. **Create a New Application** + - Click **Applications** in the left sidebar + - Click **+ New Application** + - Choose **Single Page Application (SPA)** + - Enter your application name (e.g., "Teamspace") + +3. **Note Down Your Credentials from the `Quickstart` tab** + - Copy the **Client ID** from the application details + - Note your **Base URL** (ex: `https://api.asgardeo.io/t/`) + +4. **Configure Application Settings from the `Protocol` tab** + - **Authorized redirect URLs**: Add your application URLs + - `https://localhost:5173` + - **Allowed origins**: Add the same URLs as above + - Click **Update** to save the configuration + +## Step 2: Create a React Application + +If you don't have a React application set up yet, you can create one using Vite: + +```bash +# Using npm +npm create vite@latest react-sample --template react + +# Using pnpm +pnpm create vite@latest react-sample --template react + +# Using yarn +yarn create vite react-sample --template react +``` + +## Step 3: Install the SDK + +Install the Asgardeo React SDK in your project: + +```bash +# Using npm +npm install @asgardeo/react + +# Using pnpm +pnpm add @asgardeo/react + +# Using yarn +yarn add @asgardeo/react +``` + +## Step 4: Configure the Provider + +Wrap your application with the `AsgardeoProvider` in your main entry file i.e. `src/main.tsx`: + +```tsx +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './index.css' +import App from './App.tsx' +import { AsgardeoProvider } from '@asgardeo/react' + +createRoot(document.getElementById('root')!).render( + + + + + +) +``` + +Replace: +- `` with the Base URL you noted in Step 1 (e.g., `https://api.asgardeo.io/t/`) +- `` with the Client ID from Step 1 + +## Step 5: Add Sign-in & Sign-out to Your App + +Update your `App.tsx` to include sign-in and sign-out functionality: + +```tsx +import { SignedIn, SignedOut, SignInButton, SignOutButton } from '@asgardeo/react' +import './App.css' + +function App() { + return ( + <> + + + + + + + + ) +} + +export default App +``` + +## Step 6: Display User Information + +You can also display user information by using the `User` component & the `UserProfile` component: + +```tsx +import { SignedIn, SignedOut, SignInButton, SignOutButton, User, UserProfile } from '@asgardeo/react' +import './App.css' + +function App() { + return ( + <> + + + {({ user }) => ( +
+

Welcome, {user.username}

+
+ )} +
+ + +
+ + + + + ) +} + +export default App +``` + +## Step 7: Try Login + +Run your application and test the sign-in functionality. You should see a "Sign In" button when you're not signed in, and clicking it will redirect you to the Asgardeo sign-in page. + +```bash +# Using npm +npm run dev + +# Using pnpm +pnpm dev + +# Using yarn +yarn dev +``` + +## Next Steps + +🎉 **Congratulations!** You've successfully integrated Asgardeo authentication into your React app. + +### What to explore next: + +- **[Complete Guide](./COMPLETE%20GUIDE.md)** - Learn about advanced features and customization +- **[API Documentation](./API.md)** - Explore all available components and hooks +- **Custom Styling** - Customize the appearance of authentication components +- **Protected Routes** - Implement route-level authentication +- **User Profile Management** - Access and manage user profile data + +### Common Issues + +- **Redirect URL Mismatch**: Ensure your redirect URLs in Asgardeo match your local/production URLs exactly +- **CORS Errors**: Make sure to add your domain to the "Allowed Origins" in your Asgardeo application settings +- **Client ID Issues**: Double-check that you're using the correct Client ID from your Asgardeo application + +For more help, visit the [Asgardeo Documentation](https://wso2.com/asgardeo/docs/) or check out our [examples](../../examples/). diff --git a/packages/react/src/AsgardeoReactClient.ts b/packages/react/src/AsgardeoReactClient.ts index 5dae4ecd..5e8e60be 100644 --- a/packages/react/src/AsgardeoReactClient.ts +++ b/packages/react/src/AsgardeoReactClient.ts @@ -25,11 +25,22 @@ import { SignOutOptions, User, generateUserProfile, + EmbeddedFlowExecuteResponse, + SignUpOptions, + EmbeddedFlowExecuteRequestPayload, + AsgardeoRuntimeError, + executeEmbeddedSignUpFlow, + EmbeddedSignInFlowHandleRequestPayload, + executeEmbeddedSignInFlow, + Organization, + IdToken, + EmbeddedFlowExecuteRequestConfig, } from '@asgardeo/browser'; import AuthAPI from './__temp__/api'; -import {AsgardeoReactConfig} from './models/config'; +import getMeOrganizations from './api/scim2/getMeOrganizations'; import getMeProfile from './api/scim2/getMeProfile'; import getSchemas from './api/scim2/getSchemas'; +import {AsgardeoReactConfig} from './models/config'; /** * Client for mplementing Asgardeo in React applications. @@ -47,54 +58,156 @@ class AsgardeoReactClient e this.asgardeo = new AuthAPI(); } - override initialize(config: T): Promise { - const scopes: string[] = Array.isArray(config.scopes) ? config.scopes : config.scopes.split(' '); - - return this.asgardeo.init({ - baseUrl: config.baseUrl, - clientId: config.clientId, - afterSignInUrl: config.afterSignInUrl, - scopes: [...scopes, 'internal_login'], - }); + override initialize(config: AsgardeoReactConfig): Promise { + return this.asgardeo.init(config as any); } override async getUser(): Promise { - const baseUrl = await (await this.asgardeo.getConfigData()).baseUrl; - const profile = await getMeProfile({url: `${baseUrl}/scim2/Me`}); - const schemas = await getSchemas({url: `${baseUrl}/scim2/Schemas`}); + try { + const configData = await this.asgardeo.getConfigData(); + const baseUrl = configData?.baseUrl; - return generateUserProfile(profile, flattenUserSchema(schemas)); + const profile = await getMeProfile({url: `${baseUrl}/scim2/Me`}); + const schemas = await getSchemas({url: `${baseUrl}/scim2/Schemas`}); + + return generateUserProfile(profile, flattenUserSchema(schemas)); + } catch (error) { + return this.asgardeo.getDecodedIdToken(); + } } async getUserProfile(): Promise { - const baseUrl: string = (await this.asgardeo.getConfigData()).baseUrl; + try { + const configData = await this.asgardeo.getConfigData(); + const baseUrl = configData?.baseUrl; + + const profile = await getMeProfile({url: `${baseUrl}/scim2/Me`}); + const schemas = await getSchemas({url: `${baseUrl}/scim2/Schemas`}); + + const processedSchemas = flattenUserSchema(schemas); + + const output = { + schemas: processedSchemas, + flattenedProfile: generateFlattenedUserProfile(profile, processedSchemas), + profile, + }; + + return output; + } catch (error) { + return { + schemas: [], + flattenedProfile: await this.asgardeo.getDecodedIdToken(), + profile: await this.asgardeo.getDecodedIdToken(), + }; + } + } + + override async getOrganizations(): Promise { + try { + const configData = await this.asgardeo.getConfigData(); + const baseUrl = configData?.baseUrl; - const profile = await getMeProfile({url: `${baseUrl}/scim2/Me`}); - const schemas = await getSchemas({url: `${baseUrl}/scim2/Schemas`}); - - console.log('Raw Schemas:', JSON.stringify(schemas, null, 2)); + const organizations = await getMeOrganizations({baseUrl}); - const processedSchemas = flattenUserSchema(schemas); - - console.log('Processed Schemas:', JSON.stringify(processedSchemas, null, 2)); + return organizations; + } catch (error) { + throw new AsgardeoRuntimeError( + 'Failed to fetch organizations.', + 'react-AsgardeoReactClient-GetOrganizationsError-001', + 'react', + 'An error occurred while fetching the organizations associated with the user.', + ); + } + } + + override async getCurrentOrganization(): Promise { + const idToken: IdToken = await this.asgardeo.getDecodedIdToken(); return { - schemas: processedSchemas, - flattenedProfile: generateFlattenedUserProfile(profile, processedSchemas), - profile, + orgHandle: idToken?.org_handle, + name: idToken?.org_name, + id: idToken?.org_id, }; } + override async switchOrganization(organization: Organization): Promise { + try { + const configData = await this.asgardeo.getConfigData(); + const scopes = configData?.scopes; + + if (!organization.id) { + throw new AsgardeoRuntimeError( + 'Organization ID is required for switching organizations', + 'react-AsgardeoReactClient-SwitchOrganizationError-001', + 'react', + 'The organization object must contain a valid ID to perform the organization switch.', + ); + } + + const exchangeConfig = { + attachToken: false, + data: { + client_id: '{{clientId}}', + grant_type: 'organization_switch', + scope: '{{scopes}}', + switching_organization: organization.id, + token: '{{accessToken}}', + }, + id: 'organization-switch', + returnsSession: true, + signInRequired: true, + }; + + await this.asgardeo.exchangeToken( + exchangeConfig, + (user: User) => {}, + () => null, + ); + } catch (error) { + throw new AsgardeoRuntimeError( + `Failed to switch organization: ${error.message || error}`, + 'react-AsgardeoReactClient-SwitchOrganizationError-003', + 'react', + 'An error occurred while switching to the specified organization. Please try again.', + ); + } + } + override isLoading(): boolean { return this.asgardeo.isLoading(); } + async isInitialized(): Promise { + return this.asgardeo.isInitialized(); + } + override isSignedIn(): Promise { return this.asgardeo.isSignedIn(); } - override signIn(options?: SignInOptions): Promise { - return this.asgardeo.signIn(options as any) as unknown as Promise; + override signIn( + options?: SignInOptions, + sessionId?: string, + onSignInSuccess?: (afterSignInUrl: string) => void, + ): Promise; + override signIn( + payload: EmbeddedSignInFlowHandleRequestPayload, + request: EmbeddedFlowExecuteRequestConfig, + sessionId?: string, + onSignInSuccess?: (afterSignInUrl: string) => void, + ): Promise; + override async signIn(...args: any[]): Promise { + const arg1 = args[0]; + const arg2 = args[1]; + + if (typeof arg1 === 'object' && 'flowId' in arg1 && typeof arg2 === 'object' && 'url' in arg2) { + return executeEmbeddedSignInFlow({ + payload: arg1, + url: arg2.url, + }); + } + + return this.asgardeo.signIn(arg1 as any) as unknown as Promise; } override signOut(options?: SignOutOptions, afterSignOut?: (redirectUrl: string) => void): Promise; @@ -112,6 +225,37 @@ class AsgardeoReactClient e return Promise.resolve(String(response)); } + + override async signUp(options?: SignUpOptions): Promise; + override async signUp(payload: EmbeddedFlowExecuteRequestPayload): Promise; + override async signUp(...args: any[]): Promise { + if (args.length === 0) { + throw new AsgardeoRuntimeError( + 'No arguments provided for signUp method.', + 'react-AsgardeoReactClient-ValidationError-001', + 'react', + 'The signUp method requires at least one argument, either a SignUpOptions object or an EmbeddedFlowExecuteRequestPayload.', + ); + } + + const firstArg = args[0]; + + if (typeof firstArg === 'object' && 'flowType' in firstArg) { + const configData = await this.asgardeo.getConfigData(); + const baseUrl: string = configData?.baseUrl; + + return executeEmbeddedSignUpFlow({ + baseUrl, + payload: firstArg as EmbeddedFlowExecuteRequestPayload, + }); + } + throw new AsgardeoRuntimeError( + 'Not implemented', + 'react-AsgardeoReactClient-ValidationError-002', + 'react', + 'The signUp method with SignUpOptions is not implemented in the React client.', + ); + } } export default AsgardeoReactClient; diff --git a/packages/react/src/__temp__/api.ts b/packages/react/src/__temp__/api.ts index 279ebcb8..8db768dc 100644 --- a/packages/react/src/__temp__/api.ts +++ b/packages/react/src/__temp__/api.ts @@ -1,7 +1,7 @@ /** - * Copyright (c) 2025, WSO2 Inc. (http://www.wso2.org) All Rights Reserved. + * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com). * - * WSO2 Inc. licenses this file to you under the Apache License, + * 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 @@ -21,7 +21,7 @@ import { AuthClientConfig, User, LegacyConfig as Config, - IdTokenPayload, + IdToken, Hooks, HttpClientInstance, HttpRequestConfig, @@ -29,7 +29,7 @@ import { OIDCEndpoints, SignInConfig, SPACustomGrantConfig, - initializeApplicationNativeAuthentication, + initializeEmbeddedSignInFlow, processOpenIDScopes, } from '@asgardeo/browser'; import {AuthStateInterface} from './models'; @@ -79,7 +79,7 @@ class AuthAPI { * @param {Config} config - `dispatch` function from React Auth Context. */ public async init(config: AuthClientConfig): Promise { - return await this._client.initialize(config); + return this._client.initialize(config); } /** @@ -88,7 +88,17 @@ class AuthAPI { * @returns {Promise>} - A promise that resolves with the configuration data. */ public async getConfigData(): Promise> { - return await this._client.getConfigData(); + return this._client.getConfigData(); + } + + /** + * Method to get the configuration data. + * + * @returns {Promise>} - A promise that resolves with the configuration data. + */ + public async isInitialized(): Promise { + // Wait for initialization to complete + return this._client.isInitialized(); } /** @@ -139,9 +149,7 @@ class AuthAPI { return response; }) - .catch(error => { - return Promise.reject(error); - }); + .catch(error => Promise.reject(error)); } /** @@ -161,9 +169,7 @@ class AuthAPI { return response; }) - .catch(error => { - return Promise.reject(error); - }); + .catch(error => Promise.reject(error)); } /** @@ -247,9 +253,7 @@ class AuthAPI { return response; }) - .catch(error => { - return Promise.reject(error); - }); + .catch(error => Promise.reject(error)); } /** @@ -265,9 +269,7 @@ class AuthAPI { dispatch(AuthAPI.DEFAULT_STATE); return true; }) - .catch(error => { - return Promise.reject(error); - }); + .catch(error => Promise.reject(error)); } /** @@ -294,7 +296,7 @@ class AuthAPI { * @return {Promise} - A Promise that resolves with * the decoded payload of the id token. */ - public async getDecodedIdToken(): Promise { + public async getDecodedIdToken(): Promise { return this._client.getDecodedIdToken(); } @@ -304,7 +306,7 @@ class AuthAPI { * @return {Promise} - A Promise that resolves with * the decoded payload of the idp id token. */ - public async getDecodedIDPIDToken(): Promise { + public async getDecodedIDPIDToken(): Promise { return this._client.getDecodedIdToken(); } @@ -461,9 +463,7 @@ class AuthAPI { return response; }) - .catch(error => { - return Promise.reject(error); - }); + .catch(error => Promise.reject(error)); } } diff --git a/packages/react/src/__temp__/models.ts b/packages/react/src/__temp__/models.ts index 4ff057e7..fc500cf4 100644 --- a/packages/react/src/__temp__/models.ts +++ b/packages/react/src/__temp__/models.ts @@ -22,7 +22,7 @@ import { AuthSPAClientConfig, Config, TokenExchangeRequestConfig, - IdTokenPayload, + IdToken, Hooks, HttpClientInstance, HttpRequestConfig, @@ -95,8 +95,8 @@ export interface AuthContextInterface { revokeAccessToken(): Promise; getOpenIDProviderEndpoints(): Promise; getHttpClient(): Promise; - getDecodedIDPIDToken(): Promise; - getDecodedIdToken(): Promise; + getDecodedIDPIDToken(): Promise; + getDecodedIdToken(): Promise; getIdToken(): Promise; getAccessToken(): Promise; refreshAccessToken(): Promise; diff --git a/packages/react/src/api/scim2/createOrganization.ts b/packages/react/src/api/scim2/createOrganization.ts new file mode 100644 index 00000000..e25daa59 --- /dev/null +++ b/packages/react/src/api/scim2/createOrganization.ts @@ -0,0 +1,135 @@ +/** + * 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 {AsgardeoAPIError, HttpInstance, AsgardeoSPAClient, HttpRequestConfig, Organization} from '@asgardeo/browser'; + +const httpClient: HttpInstance = AsgardeoSPAClient.getInstance().httpRequest.bind(AsgardeoSPAClient.getInstance()); + +/** + * Interface for organization creation payload. + */ +export interface CreateOrganizationPayload { + /** + * Organization description. + */ + description: string; + /** + * Organization handle/slug. + */ + orgHandle?: string; + /** + * Organization name. + */ + name: string; + /** + * Parent organization ID. + */ + parentId: string; + /** + * Organization type. + */ + type: 'TENANT'; +} + +/** + * Creates a new organization. + * + * @param config - Configuration object containing baseUrl, payload and optional request config. + * @returns A promise that resolves with the created organization information. + * @example + * ```typescript + * try { + * const organization = await createOrganization({ + * baseUrl: "https://api.asgardeo.io/t/", + * payload: { + * description: "Share your screens", + * name: "Team Viewer", + * orgHandle: "team-viewer", + * parentId: "f4825104-4948-40d9-ab65-a960eee3e3d5", + * type: "TENANT" + * } + * }); + * console.log(organization); + * } catch (error) { + * if (error instanceof AsgardeoAPIError) { + * console.error('Failed to create organization:', error.message); + * } + * } + * ``` + */ +const createOrganization = async ({ + baseUrl, + payload, + ...requestConfig +}: Partial & { + baseUrl: string; + payload: CreateOrganizationPayload; +}): Promise => { + if (!baseUrl) { + throw new AsgardeoAPIError( + 'Base URL is required', + 'createOrganization-ValidationError-001', + 'javascript', + 400, + 'Invalid Request', + ); + } + + if (!payload) { + throw new AsgardeoAPIError( + 'Organization payload is required', + 'createOrganization-ValidationError-002', + 'javascript', + 400, + 'Invalid Request', + ); + } + + // Always set type to TENANT for now + const organizationPayload = { + ...payload, + type: 'TENANT' as const, + }; + + const response: any = await httpClient({ + data: JSON.stringify(organizationPayload), + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + method: 'POST', + url: `${baseUrl}/api/server/v1/organizations`, + ...requestConfig, + } as HttpRequestConfig); + + if (!response.data) { + const errorText: string = await response.text(); + + throw new AsgardeoAPIError( + `Failed to create organization: ${errorText}`, + 'createOrganization-ResponseError-001', + 'javascript', + response.status, + response.statusText, + ); + } + + return response.data; +}; + +export default createOrganization; diff --git a/packages/react/src/api/scim2/getAllOrganizations.ts b/packages/react/src/api/scim2/getAllOrganizations.ts new file mode 100644 index 00000000..df76918c --- /dev/null +++ b/packages/react/src/api/scim2/getAllOrganizations.ts @@ -0,0 +1,119 @@ +/** + * 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 {AsgardeoAPIError, HttpInstance, AsgardeoSPAClient, HttpRequestConfig, Organization} from '@asgardeo/browser'; + +const httpClient: HttpInstance = AsgardeoSPAClient.getInstance().httpRequest.bind(AsgardeoSPAClient.getInstance()); + +/** + * Interface for paginated organization response. + */ +export interface PaginatedOrganizationsResponse { + hasMore?: boolean; + nextCursor?: string; + organizations: Organization[]; + totalCount?: number; +} + +/** + * Retrieves all organizations with pagination support. + * + * @param config - Configuration object containing baseUrl, optional query parameters, and request config. + * @returns A promise that resolves with the paginated organizations information. + * @example + * ```typescript + * try { + * const response = await getAllOrganizations({ + * baseUrl: "https://api.asgardeo.io/t/", + * filter: "", + * limit: 10, + * recursive: false + * }); + * console.log(response.organizations); + * } catch (error) { + * if (error instanceof AsgardeoAPIError) { + * console.error('Failed to get organizations:', error.message); + * } + * } + * ``` + */ +const getAllOrganizations = async ({ + baseUrl, + filter = '', + limit = 10, + recursive = false, + ...requestConfig +}: Partial & { + baseUrl: string; + filter?: string; + limit?: number; + recursive?: boolean; +}): Promise => { + if (!baseUrl) { + throw new AsgardeoAPIError( + 'Base URL is required', + 'getAllOrganizations-ValidationError-001', + 'javascript', + 400, + 'Invalid Request', + ); + } + + const queryParams: URLSearchParams = new URLSearchParams( + Object.fromEntries( + Object.entries({ + filter, + limit: limit.toString(), + recursive: recursive.toString(), + }).filter(([, value]: [string, string]) => Boolean(value)), + ), + ); + + const response: any = await httpClient({ + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + method: 'GET', + url: `${baseUrl}/api/server/v1/organizations?${queryParams.toString()}`, + ...requestConfig, + } as HttpRequestConfig); + + if (!response.data) { + const errorText: string = await response.text(); + + throw new AsgardeoAPIError( + errorText || 'Failed to get organizations', + 'getAllOrganizations-NetworkError-001', + 'javascript', + response.status, + response.statusText, + ); + } + + const {data}: any = response; + + return { + hasMore: data.hasMore, + nextCursor: data.nextCursor, + organizations: data.organizations || [], + totalCount: data.totalCount, + }; +}; + +export default getAllOrganizations; diff --git a/packages/react/src/api/scim2/getMeOrganizations.ts b/packages/react/src/api/scim2/getMeOrganizations.ts new file mode 100644 index 00000000..601baa6d --- /dev/null +++ b/packages/react/src/api/scim2/getMeOrganizations.ts @@ -0,0 +1,113 @@ +/** + * 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 {AsgardeoAPIError, HttpInstance, AsgardeoSPAClient, HttpRequestConfig, Organization} from '@asgardeo/browser'; + +const httpClient: HttpInstance = AsgardeoSPAClient.getInstance().httpRequest.bind(AsgardeoSPAClient.getInstance()); + +/** + * Retrieves the organizations associated with the current user. + * + * @param config - Configuration object containing baseUrl, optional query parameters, and request config. + * @returns A promise that resolves with the organizations information. + * @example + * ```typescript + * try { + * const organizations = await getMeOrganizations({ + * baseUrl: "https://api.asgardeo.io/t/", + * after: "", + * before: "", + * filter: "", + * limit: 10, + * recursive: false + * }); + * console.log(organizations); + * } catch (error) { + * if (error instanceof AsgardeoAPIError) { + * console.error('Failed to get organizations:', error.message); + * } + * } + * ``` + */ +const getMeOrganizations = async ({ + baseUrl, + after = '', + authorizedAppName = '', + before = '', + filter = '', + limit = 10, + recursive = false, + ...requestConfig +}: Partial & { + baseUrl: string; + after?: string; + authorizedAppName?: string; + before?: string; + filter?: string; + limit?: number; + recursive?: boolean; +}): Promise => { + if (!baseUrl) { + throw new AsgardeoAPIError( + 'Base URL is required', + 'getMeOrganizations-ValidationError-001', + 'javascript', + 400, + 'Invalid Request', + ); + } + + const queryParams = new URLSearchParams( + Object.fromEntries( + Object.entries({ + after, + authorizedAppName, + before, + filter, + limit: limit.toString(), + recursive: recursive.toString(), + }).filter(([, value]) => Boolean(value)) + ) + ); + + const response: any = await httpClient({ + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + method: 'GET', + url: `${baseUrl}/api/users/v1/me/organizations?${queryParams.toString()}`, + ...requestConfig, + } as HttpRequestConfig); + + if (!response.data) { + const errorText: string = await response.text(); + + throw new AsgardeoAPIError( + `Failed to fetch associated organizations of the user: ${errorText}`, + 'getMeOrganizations-ResponseError-001', + 'javascript', + response.status, + response.statusText, + ); + } + + return response.data.organizations || []; +}; + +export default getMeOrganizations; diff --git a/packages/react/src/api/scim2/getOrganization.ts b/packages/react/src/api/scim2/getOrganization.ts new file mode 100644 index 00000000..d2fc8cc8 --- /dev/null +++ b/packages/react/src/api/scim2/getOrganization.ts @@ -0,0 +1,116 @@ +/** + * 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 {AsgardeoAPIError, HttpInstance, AsgardeoSPAClient, HttpRequestConfig} from '@asgardeo/browser'; + +const httpClient: HttpInstance = AsgardeoSPAClient.getInstance().httpRequest.bind(AsgardeoSPAClient.getInstance()); + +/** + * Extended organization interface with additional properties + */ +export interface OrganizationDetails { + attributes?: Record; + created?: string; + description?: string; + id: string; + lastModified?: string; + name: string; + orgHandle: string; + parent?: { + id: string; + ref: string; + }; + permissions?: string[]; + status?: string; + type?: string; +} + +/** + * Retrieves detailed information for a specific organization. + * + * @param config - Configuration object containing baseUrl, organizationId, and request config. + * @returns A promise that resolves with the organization details. + * @example + * ```typescript + * try { + * const organization = await getOrganization({ + * baseUrl: "https://api.asgardeo.io/t/dxlab", + * organizationId: "0d5e071b-d3d3-475d-b3c6-1a20ee2fa9b1" + * }); + * console.log(organization); + * } catch (error) { + * if (error instanceof AsgardeoAPIError) { + * console.error('Failed to get organization:', error.message); + * } + * } + * ``` + */ +const getOrganization = async ({ + baseUrl, + organizationId, + ...requestConfig +}: Partial & { + baseUrl: string; + organizationId: string; +}): Promise => { + if (!baseUrl) { + throw new AsgardeoAPIError( + 'Base URL is required', + 'getOrganization-ValidationError-001', + 'javascript', + 400, + 'Invalid Request', + ); + } + + if (!organizationId) { + throw new AsgardeoAPIError( + 'Organization ID is required', + 'getOrganization-ValidationError-002', + 'javascript', + 400, + 'Invalid Request', + ); + } + + const response: any = await httpClient({ + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + method: 'GET', + url: `${baseUrl}/api/server/v1/organizations/${organizationId}`, + ...requestConfig, + } as HttpRequestConfig); + + if (!response.data) { + const errorText: string = await response.text(); + + throw new AsgardeoAPIError( + `Failed to fetch organization details: ${errorText}`, + 'getOrganization-ResponseError-001', + 'javascript', + response.status, + response.statusText, + ); + } + + return response.data; +}; + +export default getOrganization; diff --git a/packages/react/src/api/scim2/updateOrganization.ts b/packages/react/src/api/scim2/updateOrganization.ts new file mode 100644 index 00000000..0214803e --- /dev/null +++ b/packages/react/src/api/scim2/updateOrganization.ts @@ -0,0 +1,162 @@ +/** + * 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 {AsgardeoAPIError, HttpInstance, AsgardeoSPAClient, HttpRequestConfig, isEmpty} from '@asgardeo/browser'; +import {OrganizationDetails} from './getOrganization'; + +const httpClient: HttpInstance = AsgardeoSPAClient.getInstance().httpRequest.bind(AsgardeoSPAClient.getInstance()); + +/** + * Updates the organization information using the Organizations Management API. + * + * @param baseUrl - The base URL for the API. + * @param organizationId - The ID of the organization to update. + * @param operations - Array of patch operations to apply. + * @param requestConfig - Additional request config if needed. + * @returns A promise that resolves with the updated organization information. + * @example + * ```typescript + * // Using the helper function to create operations automatically + * const operations = createPatchOperations({ + * name: "Updated Organization Name", // Will use REPLACE + * description: "", // Will use REMOVE (empty string) + * customField: "Some value" // Will use REPLACE + * }); + * + * await updateOrganization({ + * baseUrl: "https://api.asgardeo.io/t/", + * organizationId: "0d5e071b-d3d3-475d-b3c6-1a20ee2fa9b1", + * operations + * }); + * + * // Or manually specify operations + * await updateOrganization({ + * baseUrl: "https://api.asgardeo.io/t/", + * organizationId: "0d5e071b-d3d3-475d-b3c6-1a20ee2fa9b1", + * operations: [ + * { operation: "REPLACE", path: "/name", value: "Updated Organization Name" }, + * { operation: "REMOVE", path: "/description" } + * ] + * }); + * ``` + */ +const updateOrganization = async ({ + baseUrl, + organizationId, + operations, + ...requestConfig +}: { + baseUrl: string; + organizationId: string; + operations: Array<{ + operation: 'REPLACE' | 'ADD' | 'REMOVE'; + path: string; + value?: any; + }>; +} & Partial): Promise => { + try { + new URL(baseUrl); + } catch (error) { + throw new AsgardeoAPIError( + 'Invalid base URL provided', + 'updateOrganization-ValidationError-001', + 'javascript', + 400, + 'Invalid Request', + ); + } + + if (!organizationId) { + throw new AsgardeoAPIError( + 'Organization ID is required', + 'updateOrganization-ValidationError-002', + 'javascript', + 400, + 'Invalid Request', + ); + } + + if (!operations || !Array.isArray(operations) || operations.length === 0) { + throw new AsgardeoAPIError( + 'Operations array is required and cannot be empty', + 'updateOrganization-ValidationError-003', + 'javascript', + 400, + 'Invalid Request', + ); + } + + const url = `${baseUrl}/api/server/v1/organizations/${organizationId}`; + + const response: any = await httpClient({ + url, + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + data: operations, + ...requestConfig, + } as HttpRequestConfig); + + if (!response.data) { + const errorText = await response.text(); + + throw new AsgardeoAPIError( + `Failed to update organization: ${errorText}`, + 'updateOrganization-ResponseError-001', + 'javascript', + response.status, + response.statusText, + ); + } + + return response.data; +}; + +/** + * Helper function to convert field updates to patch operations format. + * Uses REMOVE operation when the value is empty, otherwise uses REPLACE. + * + * @param payload - Object containing field updates + * @returns Array of patch operations + */ +export const createPatchOperations = ( + payload: Record, +): Array<{ + operation: 'REPLACE' | 'REMOVE'; + path: string; + value?: any; +}> => { + return Object.entries(payload).map(([key, value]) => { + if (isEmpty(value)) { + return { + operation: 'REMOVE' as const, + path: `/${key}`, + }; + } + + return { + operation: 'REPLACE' as const, + path: `/${key}`, + value, + }; + }); +}; + +export default updateOrganization; diff --git a/packages/react/src/components/actions/SignInButton/BaseSignInButton.tsx b/packages/react/src/components/actions/SignInButton/BaseSignInButton.tsx index 737994f6..0a09b290 100644 --- a/packages/react/src/components/actions/SignInButton/BaseSignInButton.tsx +++ b/packages/react/src/components/actions/SignInButton/BaseSignInButton.tsx @@ -16,6 +16,8 @@ * under the License. */ +import {WithPreferences, withVendorCSSClassPrefix} from '@asgardeo/browser'; +import clsx from 'clsx'; import { ButtonHTMLAttributes, forwardRef, @@ -25,22 +27,20 @@ import { Ref, RefAttributes, } from 'react'; -import {WithPreferences, withVendorCSSClassPrefix} from '@asgardeo/browser'; -import clsx from 'clsx'; import Button from '../../primitives/Button/Button'; /** * Common props shared by all {@link BaseSignInButton} components. */ export interface CommonBaseSignInButtonProps { - /** - * Function to initiate the sign-in process - */ - signIn: () => Promise; /** * Loading state during sign-in process */ isLoading?: boolean; + /** + * Function to initiate the sign-in process + */ + signIn: () => Promise; } /** @@ -87,7 +87,7 @@ const BaseSignInButton: ForwardRefExoticComponent, ): ReactElement => { if (typeof children === 'function') { - return <>{children({signIn, isLoading})}; + return <>{children({isLoading, signIn})}; } return ( diff --git a/packages/react/src/components/actions/SignInButton/SignInButton.tsx b/packages/react/src/components/actions/SignInButton/SignInButton.tsx index 2ca4f189..cb3268cc 100644 --- a/packages/react/src/components/actions/SignInButton/SignInButton.tsx +++ b/packages/react/src/components/actions/SignInButton/SignInButton.tsx @@ -16,11 +16,11 @@ * under the License. */ +import {AsgardeoRuntimeError} from '@asgardeo/browser'; import {forwardRef, ForwardRefExoticComponent, MouseEvent, ReactElement, Ref, RefAttributes, useState} from 'react'; +import BaseSignInButton, {BaseSignInButtonProps} from './BaseSignInButton'; import useAsgardeo from '../../../contexts/Asgardeo/useAsgardeo'; import useTranslation from '../../../hooks/useTranslation'; -import BaseSignInButton, {BaseSignInButtonProps} from './BaseSignInButton'; -import {AsgardeoRuntimeError} from '@asgardeo/browser'; /** * Props interface of {@link SignInButton} diff --git a/packages/react/src/components/actions/SignOutButton/BaseSignOutButton.tsx b/packages/react/src/components/actions/SignOutButton/BaseSignOutButton.tsx index d8c18c17..4ca5fba3 100644 --- a/packages/react/src/components/actions/SignOutButton/BaseSignOutButton.tsx +++ b/packages/react/src/components/actions/SignOutButton/BaseSignOutButton.tsx @@ -16,6 +16,8 @@ * under the License. */ +import {WithPreferences, withVendorCSSClassPrefix} from '@asgardeo/browser'; +import clsx from 'clsx'; import { forwardRef, ForwardRefExoticComponent, @@ -25,22 +27,20 @@ import { Ref, RefAttributes, } from 'react'; -import {WithPreferences, withVendorCSSClassPrefix} from '@asgardeo/browser'; -import clsx from 'clsx'; import Button from '../../primitives/Button/Button'; /** * Common props shared by all {@link BaseSignOutButton} components. */ export interface CommonBaseSignOutButtonProps { - /** - * Function to initiate the sign-out process - */ - signOut: () => Promise; /** * Loading state during sign-out process */ isLoading?: boolean; + /** + * Function to initiate the sign-out process + */ + signOut: () => Promise; } /** @@ -87,7 +87,7 @@ const BaseSignOutButton: ForwardRefExoticComponent, ): ReactElement => { if (typeof children === 'function') { - return <>{children({signOut, isLoading})}; + return <>{children({isLoading, signOut})}; } return ( diff --git a/packages/react/src/components/actions/SignOutButton/SignOutButton.tsx b/packages/react/src/components/actions/SignOutButton/SignOutButton.tsx index fc4f7569..632cac5a 100644 --- a/packages/react/src/components/actions/SignOutButton/SignOutButton.tsx +++ b/packages/react/src/components/actions/SignOutButton/SignOutButton.tsx @@ -16,11 +16,11 @@ * under the License. */ -import {FC, forwardRef, ForwardRefExoticComponent, MouseEvent, ReactElement, Ref, RefAttributes, useState} from 'react'; +import {AsgardeoRuntimeError} from '@asgardeo/browser'; +import {forwardRef, ForwardRefExoticComponent, MouseEvent, ReactElement, Ref, RefAttributes, useState} from 'react'; +import BaseSignOutButton, {BaseSignOutButtonProps} from './BaseSignOutButton'; import useAsgardeo from '../../../contexts/Asgardeo/useAsgardeo'; import useTranslation from '../../../hooks/useTranslation'; -import BaseSignOutButton, {BaseSignOutButtonProps} from './BaseSignOutButton'; -import {AsgardeoRuntimeError} from '@asgardeo/browser'; /** * Props interface of {@link SignOutButton} diff --git a/packages/react/src/components/actions/SignUpButton/BaseSignUpButton.tsx b/packages/react/src/components/actions/SignUpButton/BaseSignUpButton.tsx index 8e8711de..9d554d81 100644 --- a/packages/react/src/components/actions/SignUpButton/BaseSignUpButton.tsx +++ b/packages/react/src/components/actions/SignUpButton/BaseSignUpButton.tsx @@ -16,6 +16,8 @@ * under the License. */ +import {WithPreferences, withVendorCSSClassPrefix} from '@asgardeo/browser'; +import clsx from 'clsx'; import { forwardRef, ForwardRefExoticComponent, @@ -25,22 +27,20 @@ import { Ref, RefAttributes, } from 'react'; -import {WithPreferences, withVendorCSSClassPrefix} from '@asgardeo/browser'; -import clsx from 'clsx'; import Button from '../../primitives/Button/Button'; /** * Common props shared by all {@link BaseSignUpButton} components. */ export interface CommonBaseSignUpButtonProps { - /** - * Function to initiate the sign-up process - */ - signUp?: () => Promise; /** * Loading state during sign-up process */ isLoading?: boolean; + /** + * Function to initiate the sign-up process + */ + signUp?: () => Promise; } /** @@ -87,7 +87,7 @@ const BaseSignUpButton: ForwardRefExoticComponent, ): ReactElement => { if (typeof children === 'function') { - return <>{children({signUp, isLoading})}; + return <>{children({isLoading, signUp})}; } return ( diff --git a/packages/react/src/components/actions/SignUpButton/SignUpButton.tsx b/packages/react/src/components/actions/SignUpButton/SignUpButton.tsx index 548b591c..3e05d4a7 100644 --- a/packages/react/src/components/actions/SignUpButton/SignUpButton.tsx +++ b/packages/react/src/components/actions/SignUpButton/SignUpButton.tsx @@ -16,11 +16,11 @@ * under the License. */ -import {FC, forwardRef, ForwardRefExoticComponent, MouseEvent, ReactElement, Ref, RefAttributes, useState} from 'react'; +import {AsgardeoRuntimeError} from '@asgardeo/browser'; +import {forwardRef, ForwardRefExoticComponent, MouseEvent, ReactElement, Ref, RefAttributes, useState} from 'react'; +import BaseSignUpButton, {BaseSignUpButtonProps} from './BaseSignUpButton'; import useAsgardeo from '../../../contexts/Asgardeo/useAsgardeo'; import useTranslation from '../../../hooks/useTranslation'; -import BaseSignUpButton, {BaseSignUpButtonProps} from './BaseSignUpButton'; -import {AsgardeoRuntimeError} from '@asgardeo/browser'; /** * Props interface of {@link SignUpButton} diff --git a/packages/react/src/components/control/AsgardeoLoading.tsx b/packages/react/src/components/control/AsgardeoLoading.tsx new file mode 100644 index 00000000..8c49af89 --- /dev/null +++ b/packages/react/src/components/control/AsgardeoLoading.tsx @@ -0,0 +1,63 @@ +/** + * 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, PropsWithChildren, ReactNode} from 'react'; +import useAsgardeo from '../../contexts/Asgardeo/useAsgardeo'; + +/** + * Props for the Loading component. + */ +export interface AsgardeoLoadingProps { + /** + * Content to show when the user is not signed in. + */ + fallback?: ReactNode; +} + +/** + * A component that only renders its children when the Asgardeo is loading. + * + * @example + * ```tsx + * import { AsgardeoLoading } from '@asgardeo/auth-react'; + * + * const App = () => { + * return ( + * Finished Loading...

}> + *

Loading...

+ *
+ * ); + * } + * ``` + */ +const AsgardeoLoading: FC> = ({ + children, + fallback = null, +}: PropsWithChildren) => { + const {isLoading} = useAsgardeo(); + + if (!isLoading) { + return <>{fallback}; + } + + return <>{children}; +}; + +AsgardeoLoading.displayName = 'Loading'; + +export default AsgardeoLoading; diff --git a/packages/react/src/components/factories/FieldFactory.tsx b/packages/react/src/components/factories/FieldFactory.tsx index 6c2109c7..bd005583 100644 --- a/packages/react/src/components/factories/FieldFactory.tsx +++ b/packages/react/src/components/factories/FieldFactory.tsx @@ -22,6 +22,8 @@ import Select from '../primitives/Select/Select'; import {SelectOption} from '../primitives/Select/Select'; import OtpField from '../primitives/OtpField/OtpField'; import PasswordField from '../primitives/PasswordField/PasswordField'; +import DatePicker from '../primitives/DatePicker/DatePicker'; +import Checkbox from '../primitives/Checkbox/Checkbox'; import {FieldType} from '@asgardeo/browser'; /** @@ -30,7 +32,7 @@ import {FieldType} from '@asgardeo/browser'; export interface FieldConfig { name: string; /** - * The field type based on ApplicationNativeAuthenticationAuthenticatorParamType. + * The field type based on EmbeddedSignInFlowAuthenticatorParamType. */ type: FieldType; /** @@ -125,7 +127,7 @@ export const validateFieldValue = ( }; /** - * Factory function to create form fields based on the ApplicationNativeAuthenticationAuthenticatorParamType. + * Factory function to create form fields based on the EmbeddedSignInFlowAuthenticatorParamType. * * @param config - The field configuration * @returns The appropriate React component for the field type @@ -134,7 +136,7 @@ export const validateFieldValue = ( * ```tsx * const field = createField({ * param: 'username', - * type: ApplicationNativeAuthenticationAuthenticatorParamType.String, + * type: EmbeddedSignInFlowAuthenticatorParamType.String, * label: 'Username', * confidential: false, * required: true, @@ -178,6 +180,13 @@ export const createField = (config: FieldConfig): ReactElement => { return ; case FieldType.Text: return onChange(e.target.value)} autoComplete="off" />; + case FieldType.Email: + return onChange(e.target.value)} autoComplete="email" />; + case FieldType.Date: + return onChange(e.target.value)} />; + case FieldType.Checkbox: + const isChecked = value === 'true' || (value as any) === true; + return onChange(e.target.checked.toString())} />; case FieldType.Otp: return onChange(e.target.value)} />; case FieldType.Number: diff --git a/packages/react/src/components/presentation/CreateOrganization/BaseCreateOrganization.tsx b/packages/react/src/components/presentation/CreateOrganization/BaseCreateOrganization.tsx new file mode 100644 index 00000000..2884cf52 --- /dev/null +++ b/packages/react/src/components/presentation/CreateOrganization/BaseCreateOrganization.tsx @@ -0,0 +1,426 @@ +/** + * 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 {withVendorCSSClassPrefix} from '@asgardeo/browser'; +import clsx from 'clsx'; +import {ChangeEvent, CSSProperties, FC, ReactElement, ReactNode, useMemo, useState} from 'react'; + +import {CreateOrganizationPayload} from '../../../api/scim2/createOrganization'; +import useTheme from '../../../contexts/Theme/useTheme'; +import useTranslation from '../../../hooks/useTranslation'; +import {Avatar} from '../../primitives/Avatar/Avatar'; +import Button from '../../primitives/Button/Button'; +import {Dialog, DialogContent, DialogHeading} from '../../primitives/Popover/Popover'; +import FormControl from '../../primitives/FormControl/FormControl'; +import InputLabel from '../../primitives/InputLabel/InputLabel'; +import TextField from '../../primitives/TextField/TextField'; +import Typography from '../../primitives/Typography/Typography'; + +const useStyles = () => { + const {theme, colorScheme} = useTheme(); + + return useMemo( + () => ({ + root: { + padding: `${theme.spacing.unit * 4}px`, + minWidth: '600px', + margin: '0 auto', + } as CSSProperties, + card: { + background: theme.colors.background.surface, + borderRadius: theme.borderRadius.large, + padding: `${theme.spacing.unit * 4}px`, + } as CSSProperties, + content: { + display: 'flex', + flexDirection: 'column', + gap: `${theme.spacing.unit * 2}px`, + } as CSSProperties, + form: { + display: 'flex', + flexDirection: 'column', + gap: `${theme.spacing.unit * 2}px`, + width: '100%', + } as CSSProperties, + header: { + display: 'flex', + alignItems: 'center', + gap: `${theme.spacing.unit * 1.5}px`, + marginBottom: `${theme.spacing.unit * 1.5}px`, + } as CSSProperties, + field: { + display: 'flex', + alignItems: 'center', + padding: `${theme.spacing.unit}px 0`, + borderBottom: `1px solid ${theme.colors.border}`, + minHeight: '32px', + } as CSSProperties, + textarea: { + width: '100%', + padding: `${theme.spacing.unit}px ${theme.spacing.unit * 1.5}px`, + border: `1px solid ${theme.colors.border}`, + borderRadius: theme.borderRadius.medium, + fontSize: '1rem', + color: theme.colors.text.primary, + backgroundColor: theme.colors.background.surface, + fontFamily: 'inherit', + minHeight: '80px', + resize: 'vertical', + outline: 'none', + '&:focus': { + borderColor: theme.colors.primary.main, + boxShadow: `0 0 0 2px ${theme.colors.primary.main}20`, + }, + '&:disabled': { + backgroundColor: theme.colors.background.disabled, + color: theme.colors.text.secondary, + cursor: 'not-allowed', + }, + } as CSSProperties, + avatarContainer: { + alignItems: 'flex-start', + display: 'flex', + gap: `${theme.spacing.unit * 2}px`, + marginBottom: `${theme.spacing.unit}px`, + } as CSSProperties, + actions: { + display: 'flex', + gap: `${theme.spacing.unit}px`, + justifyContent: 'flex-end', + paddingTop: `${theme.spacing.unit * 2}px`, + } as CSSProperties, + infoContainer: { + display: 'flex', + flexDirection: 'column' as const, + gap: `${theme.spacing.unit}px`, + } as CSSProperties, + value: { + color: theme.colors.text.primary, + flex: 1, + display: 'flex', + alignItems: 'center', + gap: `${theme.spacing.unit}px`, + overflow: 'hidden', + minHeight: '32px', + lineHeight: '32px', + } as CSSProperties, + popup: { + padding: `${theme.spacing.unit * 2}px`, + } as CSSProperties, + }), + [theme, colorScheme], + ); +}; + +/** + * Interface for organization form data. + */ +export interface OrganizationFormData { + description: string; + handle: string; + name: string; +} + +/** + * Props interface for the BaseCreateOrganization component. + */ +export interface BaseCreateOrganizationProps { + cardLayout?: boolean; + className?: string; + defaultParentId?: string; + error?: string | null; + initialValues?: Partial; + loading?: boolean; + mode?: 'inline' | 'popup'; + onCancel?: () => void; + onOpenChange?: (open: boolean) => void; + onSubmit?: (payload: CreateOrganizationPayload) => void | Promise; + onSuccess?: (organization: any) => void; + open?: boolean; + renderAdditionalFields?: () => ReactNode; + style?: CSSProperties; + title?: string; +} + +/** + * BaseCreateOrganization component provides the core functionality for creating organizations. + * This component serves as the base for framework-specific implementations. + */ +export const BaseCreateOrganization: FC = ({ + cardLayout = true, + className = '', + defaultParentId = '', + error, + initialValues = {}, + loading = false, + mode = 'inline', + onCancel, + onOpenChange, + onSubmit, + onSuccess, + open = false, + renderAdditionalFields, + style, + title = 'Create Organization', +}): ReactElement => { + const styles = useStyles(); + const {theme} = useTheme(); + const {t} = useTranslation(); + const [avatarUrl, setAvatarUrl] = useState(''); + const [avatarFile, setAvatarFile] = useState(null); + const [formData, setFormData] = useState({ + description: '', + handle: '', + name: '', + ...initialValues, + }); + const [formErrors, setFormErrors] = useState & {avatar?: string}>({}); + + const validateForm = (): boolean => { + const errors: Partial = {}; + + if (!formData.name.trim()) { + errors.name = 'Organization name is required'; + } + + if (!formData.handle.trim()) { + errors.handle = 'Organization handle is required'; + } else if (!/^[a-z0-9-]+$/.test(formData.handle)) { + errors.handle = 'Handle can only contain lowercase letters, numbers, and hyphens'; + } + + if (!formData.description.trim()) { + errors.description = 'Organization description is required'; + } + + setFormErrors(errors); + return Object.keys(errors).length === 0; + }; + + const handleInputChange = (field: keyof OrganizationFormData, value: string): void => { + setFormData(prev => ({ + ...prev, + [field]: value, + })); + + // Clear error when user starts typing + if (formErrors[field]) { + setFormErrors(prev => ({ + ...prev, + [field]: undefined, + })); + } + }; + + const handleAvatarUpload = (event: ChangeEvent): void => { + const file = event.target.files?.[0]; + if (file) { + // Validate file type + if (!file.type.startsWith('image/')) { + setFormErrors(prev => ({ + ...prev, + avatar: 'Please select a valid image file', + })); + return; + } + + // Validate file size (max 2MB) + if (file.size > 2 * 1024 * 1024) { + setFormErrors(prev => ({ + ...prev, + avatar: 'Image size must be less than 2MB', + })); + return; + } + + setAvatarFile(file); + + // Create preview URL + const reader = new FileReader(); + reader.onload = e => { + setAvatarUrl(e.target?.result as string); + }; + reader.readAsDataURL(file); + + // Clear any previous avatar errors + setFormErrors(prev => ({ + ...prev, + avatar: undefined, + })); + } + }; + + const handleNameChange = (value: string): void => { + handleInputChange('name', value); + + // Auto-generate handle from name if handle is empty or matches previous auto-generated value + if (!formData.handle || formData.handle === generateHandleFromName(formData.name)) { + const newHandle = generateHandleFromName(value); + handleInputChange('handle', newHandle); + } + }; + + const generateHandleFromName = (name: string): string => { + return name + .toLowerCase() + .replace(/[^a-z0-9\s-]/g, '') // Remove special characters except spaces and hyphens + .replace(/\s+/g, '-') // Replace spaces with hyphens + .replace(/-+/g, '-') // Replace multiple hyphens with single hyphen + .replace(/^-|-$/g, ''); // Remove leading/trailing hyphens + }; + + const handleSubmit = async (e: React.FormEvent): Promise => { + e.preventDefault(); + + if (!validateForm() || loading) { + return; + } + + const payload: CreateOrganizationPayload = { + description: formData.description.trim(), + orgHandle: formData.handle.trim(), + name: formData.name.trim(), + parentId: defaultParentId, + type: 'TENANT', + }; + + try { + await onSubmit?.(payload); + if (onSuccess) { + onSuccess(payload); + } + } catch (submitError) { + // Error handling is done by parent component + console.error('Form submission error:', submitError); + } + }; + + const defaultRenderHeader = (): ReactElement => ( +
+ + {t('organization.create.title')} + +
+ ); + + const containerStyle = { + ...styles.root, + ...(cardLayout ? styles.card : {}), + }; + + const createOrganizationContent = ( +
+
+
+ {/* Organization Name */} +
+ ) => handleNameChange(e.target.value)} + disabled={loading} + required + error={formErrors.name} + className={withVendorCSSClassPrefix('create-organization__input')} + /> +
+ + {/* Organization Handle */} +
+ ) => handleInputChange('handle', e.target.value)} + disabled={loading} + required + error={formErrors.handle} + helperText="This will be your organization's unique identifier. Only lowercase letters, numbers, and hyphens are allowed." + className={withVendorCSSClassPrefix('create-organization__input')} + /> +
+ + {/* Organization Description */} +
+ + {t('organization.create.description.label')} +