diff --git a/.changeset/stupid-carrots-stay.md b/.changeset/stupid-carrots-stay.md new file mode 100644 index 00000000..7540840d --- /dev/null +++ b/.changeset/stupid-carrots-stay.md @@ -0,0 +1,10 @@ +--- +'@asgardeo/browser': patch +'@asgardeo/express': patch +'@asgardeo/javascript': patch +'@asgardeo/nextjs': patch +'@asgardeo/node': patch +'@asgardeo/react': patch +--- + +Fix issues with profile diff --git a/packages/browser/src/__legacy__/client.ts b/packages/browser/src/__legacy__/client.ts index f25e14dd..caae1d85 100755 --- a/packages/browser/src/__legacy__/client.ts +++ b/packages/browser/src/__legacy__/client.ts @@ -51,7 +51,7 @@ import {SPAUtils} from './utils'; * Default configurations. */ const DefaultConfig: Partial> = { - autoLogoutOnTokenRefreshError: true, + autoLogoutOnTokenRefreshError: false, checkSessionInterval: 3, enableOIDCSessionManagement: false, periodicTokenRefresh: false, @@ -737,10 +737,10 @@ export class AsgardeoSPAClient { * * @preserve */ - public async getDecodedIdToken(): Promise { + public async getDecodedIdToken(sessionId?: string): Promise { await this._validateMethod(); - return this._client?.getDecodedIdToken(); + return this._client?.getDecodedIdToken(sessionId); } /** diff --git a/packages/browser/src/__legacy__/clients/main-thread-client.ts b/packages/browser/src/__legacy__/clients/main-thread-client.ts index 29425620..2d1a24ed 100755 --- a/packages/browser/src/__legacy__/clients/main-thread-client.ts +++ b/packages/browser/src/__legacy__/clients/main-thread-client.ts @@ -370,7 +370,7 @@ export const MainThreadClient = async ( const getUser = async (): Promise => _authenticationHelper.getUser(); - const getDecodedIdToken = async (): Promise => _authenticationHelper.getDecodedIdToken(); + const getDecodedIdToken = async (sessionId?: string): Promise => _authenticationHelper.getDecodedIdToken(sessionId); const getCrypto = async (): Promise => _authenticationHelper.getCrypto(); diff --git a/packages/browser/src/__legacy__/clients/web-worker-client.ts b/packages/browser/src/__legacy__/clients/web-worker-client.ts index 43ff679f..9c43b5cb 100755 --- a/packages/browser/src/__legacy__/clients/web-worker-client.ts +++ b/packages/browser/src/__legacy__/clients/web-worker-client.ts @@ -706,7 +706,7 @@ export const WebWorkerClient = async ( }); }; - const getDecodedIdToken = (): Promise => { + const getDecodedIdToken = (sessionId?: string): Promise => { const message: Message = { type: GET_DECODED_ID_TOKEN, }; diff --git a/packages/browser/src/__legacy__/helpers/authentication-helper.ts b/packages/browser/src/__legacy__/helpers/authentication-helper.ts index f04d692f..3ed6137b 100644 --- a/packages/browser/src/__legacy__/helpers/authentication-helper.ts +++ b/packages/browser/src/__legacy__/helpers/authentication-helper.ts @@ -659,8 +659,8 @@ export class AuthenticationHelper { - return this._authenticationClient.getDecodedIdToken(); + public async getDecodedIdToken(sessionId?: string): Promise { + return this._authenticationClient.getDecodedIdToken(sessionId); } public async getDecodedIDPIDToken(): Promise { diff --git a/packages/browser/src/__legacy__/models/client.ts b/packages/browser/src/__legacy__/models/client.ts index 1a766886..c2f65a3e 100755 --- a/packages/browser/src/__legacy__/models/client.ts +++ b/packages/browser/src/__legacy__/models/client.ts @@ -59,7 +59,7 @@ export interface MainThreadClientInterface { refreshAccessToken(): Promise; revokeAccessToken(): Promise; getUser(): Promise; - getDecodedIdToken(): Promise; + getDecodedIdToken(sessionId?: string): Promise; getCrypto(): Promise; getConfigData(): Promise>; getIdToken(): Promise; @@ -96,7 +96,7 @@ export interface WebWorkerClientInterface { getOpenIDProviderEndpoints(): Promise; getUser(): Promise; getConfigData(): Promise>; - getDecodedIdToken(): Promise; + getDecodedIdToken(sessionId?: string): Promise; getDecodedIDPIDToken(): Promise; getCrypto(): Promise; getIdToken(): Promise; diff --git a/packages/browser/src/__legacy__/models/web-worker.ts b/packages/browser/src/__legacy__/models/web-worker.ts index 97c5c1c6..84f6ddda 100755 --- a/packages/browser/src/__legacy__/models/web-worker.ts +++ b/packages/browser/src/__legacy__/models/web-worker.ts @@ -53,7 +53,7 @@ export interface WebWorkerCoreInterface { refreshAccessToken(): Promise; revokeAccessToken(): Promise; getUser(): Promise; - getDecodedIdToken(): Promise; + getDecodedIdToken(sessionId?: string): Promise; getDecodedIDPIDToken(): Promise; getCrypto(): Promise; getIdToken(): Promise; diff --git a/packages/browser/src/__legacy__/worker/worker-core.ts b/packages/browser/src/__legacy__/worker/worker-core.ts index 2444e4e6..d5714d90 100755 --- a/packages/browser/src/__legacy__/worker/worker-core.ts +++ b/packages/browser/src/__legacy__/worker/worker-core.ts @@ -166,8 +166,8 @@ export const WebWorkerCore = async ( return _authenticationHelper.getUser(); }; - const getDecodedIdToken = async (): Promise => { - return _authenticationHelper.getDecodedIdToken(); + const getDecodedIdToken = async (sessionId?: string): Promise => { + return _authenticationHelper.getDecodedIdToken(sessionId); }; const getCrypto = async (): Promise => { diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index 8ad5581a..7702f3d9 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -51,3 +51,10 @@ export {default as AsgardeoBrowserClient} from './AsgardeoBrowserClient'; // Re-export everything from the JavaScript package export * from '@asgardeo/javascript'; + +export { + detectThemeMode, + createClassObserver, + createMediaQueryListener, + BrowserThemeDetection, +} from './theme/themeDetection'; diff --git a/packages/browser/src/theme/themeDetection.ts b/packages/browser/src/theme/themeDetection.ts new file mode 100644 index 00000000..5c47329e --- /dev/null +++ b/packages/browser/src/theme/themeDetection.ts @@ -0,0 +1,134 @@ +/** + * 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 {ThemeDetection, ThemeMode} from '@asgardeo/javascript'; + +/** + * Extended theme detection config that includes DOM-specific options + */ +export interface BrowserThemeDetection extends ThemeDetection { + /** + * The element to observe for class changes + * @default document.documentElement (html element) + */ + targetElement?: HTMLElement; +} + +/** + * Detects the current theme mode based on the specified method + */ +export const detectThemeMode = (mode: ThemeMode, config: BrowserThemeDetection = {}): 'light' | 'dark' => { + const { + darkClass = 'dark', + lightClass = 'light', + targetElement = typeof document !== 'undefined' ? document.documentElement : null, + } = config; + + if (mode === 'light') return 'light'; + if (mode === 'dark') return 'dark'; + + if (mode === 'system') { + if (typeof window !== 'undefined' && window.matchMedia) { + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; + } + return 'light'; + } + + if (mode === 'class') { + if (!targetElement) { + console.warn('ThemeDetection: targetElement is required for class-based detection, falling back to light mode'); + return 'light'; + } + + const classList = targetElement.classList; + + // Check for explicit dark class first + if (classList.contains(darkClass)) { + return 'dark'; + } + + // Check for explicit light class + if (classList.contains(lightClass)) { + return 'light'; + } + + // If neither class is present, default to light + return 'light'; + } + + return 'light'; +}; + +/** + * Creates a MutationObserver to watch for class changes on the target element + */ +export const createClassObserver = ( + targetElement: HTMLElement, + callback: (isDark: boolean) => void, + config: BrowserThemeDetection = {}, +): MutationObserver => { + const {darkClass = 'dark', lightClass = 'light'} = config; + + const observer = new MutationObserver(mutations => { + mutations.forEach(mutation => { + if (mutation.type === 'attributes' && mutation.attributeName === 'class') { + const classList = targetElement.classList; + + if (classList.contains(darkClass)) { + callback(true); + } else if (classList.contains(lightClass)) { + callback(false); + } + // If neither class is present, we don't trigger the callback + // to avoid unnecessary re-renders + } + }); + }); + + observer.observe(targetElement, { + attributes: true, + attributeFilter: ['class'], + }); + + return observer; +}; + +/** + * Creates a media query listener for system theme changes + */ +export const createMediaQueryListener = (callback: (isDark: boolean) => void): MediaQueryList | null => { + if (typeof window === 'undefined' || !window.matchMedia) { + return null; + } + + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + + const handleChange = (e: MediaQueryListEvent) => { + callback(e.matches); + }; + + // Modern browsers + if (mediaQuery.addEventListener) { + mediaQuery.addEventListener('change', handleChange); + } else { + // Fallback for older browsers + mediaQuery.addListener(handleChange); + } + + return mediaQuery; +}; diff --git a/packages/javascript/src/AsgardeoJavaScriptClient.ts b/packages/javascript/src/AsgardeoJavaScriptClient.ts index 905ad078..0b300faa 100644 --- a/packages/javascript/src/AsgardeoJavaScriptClient.ts +++ b/packages/javascript/src/AsgardeoJavaScriptClient.ts @@ -34,18 +34,20 @@ abstract class AsgardeoJavaScriptClient implements AsgardeoClient abstract initialize(config: T): Promise; - abstract getUser(): Promise; + abstract getUser(options?: any): Promise; - abstract getOrganizations(): Promise; + abstract getOrganizations(options?: any): Promise; - abstract getCurrentOrganization(): Promise; + abstract getCurrentOrganization(sessionId?: string): Promise; - abstract getUserProfile(): Promise; + abstract getUserProfile(options?: any): Promise; abstract isLoading(): boolean; abstract isSignedIn(): Promise; + abstract updateUserProfile(payload: any, userId?: string): Promise; + abstract getConfiguration(): T; abstract signIn( diff --git a/packages/javascript/src/api/__tests__/getBrandingPreference.test.ts b/packages/javascript/src/api/__tests__/getBrandingPreference.test.ts new file mode 100644 index 00000000..d5bc40c8 --- /dev/null +++ b/packages/javascript/src/api/__tests__/getBrandingPreference.test.ts @@ -0,0 +1,234 @@ +/** + * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import {describe, it, expect, vi, beforeEach} from 'vitest'; +import getBrandingPreference from '../getBrandingPreference'; +import {BrandingPreference} from '../../models/branding-preference'; +import AsgardeoAPIError from '../../errors/AsgardeoAPIError'; + +describe('getBrandingPreference', (): void => { + beforeEach((): void => { + vi.resetAllMocks(); + }); + + it('should fetch branding preference successfully', async (): Promise => { + const mockBrandingPreference: BrandingPreference = { + type: 'ORG', + name: 'dxlab', + locale: 'en-US', + preference: { + configs: { + isBrandingEnabled: true, + removeDefaultBranding: false, + }, + layout: { + activeLayout: 'centered', + }, + organizationDetails: { + displayName: '', + supportEmail: '', + }, + theme: { + activeTheme: 'DARK', + LIGHT: { + buttons: { + primary: { + base: { + border: { + borderRadius: '22px', + }, + font: { + color: '#ffffffe6', + }, + }, + }, + }, + colors: { + primary: { + main: '#FF7300', + }, + secondary: { + main: '#E0E1E2', + }, + }, + }, + DARK: { + buttons: { + primary: { + base: { + border: { + borderRadius: '22px', + }, + font: { + color: '#ffffff', + }, + }, + }, + }, + colors: { + primary: { + main: '#FF7300', + }, + secondary: { + main: '#E0E1E2', + }, + }, + }, + }, + urls: { + selfSignUpURL: 'https://localhost:5173/signup', + }, + }, + }; + + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockBrandingPreference), + }); + + const baseUrl: string = 'https://api.asgardeo.io/t/dxlab'; + const result: BrandingPreference = await getBrandingPreference({baseUrl}); + + expect(fetch).toHaveBeenCalledWith(`${baseUrl}/api/server/v1/branding-preference`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + }); + expect(result).toEqual(mockBrandingPreference); + }); + + it('should fetch branding preference with query parameters', async (): Promise => { + const mockBrandingPreference: BrandingPreference = { + type: 'ORG', + name: 'custom', + locale: 'en-US', + }; + + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockBrandingPreference), + }); + + const baseUrl: string = 'https://api.asgardeo.io/t/dxlab'; + await getBrandingPreference({ + baseUrl, + locale: 'en-US', + name: 'custom', + type: 'org', + }); + + expect(fetch).toHaveBeenCalledWith( + `${baseUrl}/api/server/v1/branding-preference?locale=en-US&name=custom&type=org`, + { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + }, + ); + }); + + it('should handle custom fetcher', async (): Promise => { + const mockBrandingPreference: BrandingPreference = { + type: 'ORG', + name: 'default', + }; + + const customFetcher = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockBrandingPreference), + }); + + const baseUrl: string = 'https://api.asgardeo.io/t/dxlab'; + await getBrandingPreference({baseUrl, fetcher: customFetcher}); + + expect(customFetcher).toHaveBeenCalledWith(`${baseUrl}/api/server/v1/branding-preference`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + }); + }); + + it('should handle invalid base URL', async (): Promise => { + const invalidUrl: string = 'invalid-url'; + + await expect(getBrandingPreference({baseUrl: invalidUrl})).rejects.toThrow(AsgardeoAPIError); + await expect(getBrandingPreference({baseUrl: invalidUrl})).rejects.toThrow('Invalid base URL provided.'); + }); + + it('should handle HTTP error responses', async (): Promise => { + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 404, + statusText: 'Not Found', + text: () => Promise.resolve('Branding preference not found'), + }); + + const baseUrl: string = 'https://api.asgardeo.io/t/dxlab'; + + await expect(getBrandingPreference({baseUrl})).rejects.toThrow(AsgardeoAPIError); + await expect(getBrandingPreference({baseUrl})).rejects.toThrow( + 'Failed to get branding preference: Branding preference not found', + ); + }); + + it('should handle network errors', async (): Promise => { + global.fetch = vi.fn().mockRejectedValue(new Error('Network error')); + + const baseUrl: string = 'https://api.asgardeo.io/t/dxlab'; + + await expect(getBrandingPreference({baseUrl})).rejects.toThrow(AsgardeoAPIError); + await expect(getBrandingPreference({baseUrl})).rejects.toThrow('Network or parsing error: Network error'); + }); + + it('should pass through custom headers', async (): Promise => { + const mockBrandingPreference: BrandingPreference = { + type: 'ORG', + name: 'default', + }; + + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockBrandingPreference), + }); + + const baseUrl: string = 'https://api.asgardeo.io/t/dxlab'; + const customHeaders = { + Authorization: 'Bearer token', + 'X-Custom-Header': 'custom-value', + }; + + await getBrandingPreference({ + baseUrl, + headers: customHeaders, + }); + + expect(fetch).toHaveBeenCalledWith(`${baseUrl}/api/server/v1/branding-preference`, { + method: 'GET', + headers: { + Authorization: 'Bearer token', + 'X-Custom-Header': 'custom-value', + }, + }); + }); +}); diff --git a/packages/javascript/src/api/createOrganization.ts b/packages/javascript/src/api/createOrganization.ts index 6e004111..0088186f 100644 --- a/packages/javascript/src/api/createOrganization.ts +++ b/packages/javascript/src/api/createOrganization.ts @@ -168,6 +168,7 @@ const createOrganization = async ({ const resolvedUrl = `${baseUrl}/api/server/v1/organizations`; const requestInit: RequestInit = { + ...requestConfig, method: 'POST', headers: { 'Content-Type': 'application/json', @@ -175,7 +176,6 @@ const createOrganization = async ({ ...requestConfig.headers, }, body: JSON.stringify(organizationPayload), - ...requestConfig, }; try { diff --git a/packages/javascript/src/api/executeEmbeddedSignInFlow.ts b/packages/javascript/src/api/executeEmbeddedSignInFlow.ts index 3d8bd1b8..e91777e2 100644 --- a/packages/javascript/src/api/executeEmbeddedSignInFlow.ts +++ b/packages/javascript/src/api/executeEmbeddedSignInFlow.ts @@ -36,16 +36,15 @@ const executeEmbeddedSignInFlow = async ({ ); } - const {headers: customHeaders, ...otherConfig} = requestConfig; const response: Response = await fetch(url ?? `${baseUrl}/oauth2/authn`, { + ...requestConfig, method: requestConfig.method || 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json', - ...customHeaders, + ...requestConfig.headers, }, body: JSON.stringify(payload), - ...otherConfig, }); if (!response.ok) { diff --git a/packages/javascript/src/api/executeEmbeddedSignUpFlow.ts b/packages/javascript/src/api/executeEmbeddedSignUpFlow.ts index 8e2581e7..8224a129 100644 --- a/packages/javascript/src/api/executeEmbeddedSignUpFlow.ts +++ b/packages/javascript/src/api/executeEmbeddedSignUpFlow.ts @@ -59,19 +59,18 @@ const executeEmbeddedSignUpFlow = async ({ ); } - const {headers: customHeaders, ...otherConfig} = requestConfig; const response: Response = await fetch(url ?? `${baseUrl}/api/server/v1/flow/execute`, { + ...requestConfig, method: requestConfig.method || 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json', - ...customHeaders, + ...requestConfig.headers, }, body: JSON.stringify({ ...(payload ?? {}), flowType: EmbeddedFlowType.Registration, }), - ...otherConfig, }); if (!response.ok) { diff --git a/packages/javascript/src/api/getAllOrganizations.ts b/packages/javascript/src/api/getAllOrganizations.ts index 0cd7223c..0d9e5314 100644 --- a/packages/javascript/src/api/getAllOrganizations.ts +++ b/packages/javascript/src/api/getAllOrganizations.ts @@ -147,13 +147,13 @@ const getAllOrganizations = async ({ const resolvedUrl = `${baseUrl}/api/server/v1/organizations?${queryParams.toString()}`; const requestInit: RequestInit = { + ...requestConfig, method: 'GET', headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...requestConfig.headers, }, - ...requestConfig, }; try { diff --git a/packages/javascript/src/api/getBrandingPreference.ts b/packages/javascript/src/api/getBrandingPreference.ts new file mode 100644 index 00000000..8a040394 --- /dev/null +++ b/packages/javascript/src/api/getBrandingPreference.ts @@ -0,0 +1,183 @@ +/** + * 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 {BrandingPreference} from '../models/branding-preference'; +import AsgardeoAPIError from '../errors/AsgardeoAPIError'; + +/** + * Configuration for the getBrandingPreference request + */ +export interface GetBrandingPreferenceConfig extends Omit { + /** + * The base URL for the API endpoint. + */ + baseUrl: string; + /** + * Locale for the branding preference + */ + locale?: string; + /** + * Name of the branding preference + */ + name?: string; + /** + * Type of the branding preference + */ + type?: string; + /** + * Optional custom fetcher function. + * If not provided, native fetch will be used + */ + fetcher?: (url: string, config: RequestInit) => Promise; +} + +/** + * Retrieves branding preference configuration. + * + * @param config - Configuration object containing baseUrl, optional query parameters, and request config. + * @returns A promise that resolves with the branding preference information. + * @example + * ```typescript + * // Using default fetch + * try { + * const response = await getBrandingPreference({ + * baseUrl: "https://api.asgardeo.io/t/", + * locale: "en-US", + * name: "my-branding", + * type: "org" + * }); + * console.log(response.theme); + * } catch (error) { + * if (error instanceof AsgardeoAPIError) { + * console.error('Failed to get branding preference:', error.message); + * } + * } + * ``` + * + * @example + * ```typescript + * // Using custom fetcher (e.g., axios-based httpClient) + * try { + * const response = await getBrandingPreference({ + * baseUrl: "https://api.asgardeo.io/t/", + * locale: "en-US", + * name: "my-branding", + * type: "org", + * fetcher: async (url, config) => { + * const response = await httpClient({ + * url, + * method: config.method, + * headers: config.headers, + * ...config + * }); + * // Convert axios-like response to fetch-like Response + * return { + * ok: response.status >= 200 && response.status < 300, + * status: response.status, + * statusText: response.statusText, + * json: () => Promise.resolve(response.data), + * text: () => Promise.resolve(typeof response.data === 'string' ? response.data : JSON.stringify(response.data)) + * } as Response; + * } + * }); + * console.log(response.theme); + * } catch (error) { + * if (error instanceof AsgardeoAPIError) { + * console.error('Failed to get branding preference:', error.message); + * } + * } + * ``` + */ +const getBrandingPreference = async ({ + baseUrl, + locale, + name, + type, + fetcher, + ...requestConfig +}: GetBrandingPreferenceConfig): Promise => { + try { + new URL(baseUrl); + } catch (error) { + throw new AsgardeoAPIError( + `Invalid base URL provided. ${error?.toString()}`, + 'getBrandingPreference-ValidationError-001', + 'javascript', + 400, + 'The provided `baseUrl` does not adhere to the URL schema.', + ); + } + + const queryParams: URLSearchParams = new URLSearchParams( + Object.fromEntries( + Object.entries({ + locale: locale || '', + name: name || '', + type: type || '', + }).filter(([, value]: [string, string]) => Boolean(value)), + ), + ); + + const fetchFn = fetcher || fetch; + const resolvedUrl = `${baseUrl}/api/server/v1/branding-preference${ + queryParams.toString() ? `?${queryParams.toString()}` : '' + }`; + + const requestInit: RequestInit = { + ...requestConfig, + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + ...requestConfig.headers, + }, + }; + + try { + const response: Response = await fetchFn(resolvedUrl, requestInit); + + if (!response?.ok) { + const errorText = await response.text(); + + throw new AsgardeoAPIError( + `Failed to get branding preference: ${errorText}`, + 'getBrandingPreference-ResponseError-001', + 'javascript', + response.status, + response.statusText, + ); + } + + const data = (await response.json()) as BrandingPreference; + return data; + } catch (error) { + if (error instanceof AsgardeoAPIError) { + throw error; + } + + throw new AsgardeoAPIError( + `Network or parsing error: ${error instanceof Error ? error.message : 'Unknown error'}`, + 'getBrandingPreference-NetworkError-001', + 'javascript', + 0, + 'Network Error', + ); + } +}; + +export default getBrandingPreference; diff --git a/packages/javascript/src/api/getMeOrganizations.ts b/packages/javascript/src/api/getMeOrganizations.ts index 159a4158..984a3de7 100644 --- a/packages/javascript/src/api/getMeOrganizations.ts +++ b/packages/javascript/src/api/getMeOrganizations.ts @@ -159,13 +159,13 @@ const getMeOrganizations = async ({ const resolvedUrl = `${baseUrl}/api/users/v1/me/organizations?${queryParams.toString()}`; const requestInit: RequestInit = { + ...requestConfig, method: 'GET', headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...requestConfig.headers, }, - ...requestConfig, }; try { diff --git a/packages/javascript/src/api/getOrganization.ts b/packages/javascript/src/api/getOrganization.ts index 0c791215..f40012ee 100644 --- a/packages/javascript/src/api/getOrganization.ts +++ b/packages/javascript/src/api/getOrganization.ts @@ -142,13 +142,13 @@ const getOrganization = async ({ const resolvedUrl = `${baseUrl}/api/server/v1/organizations/${organizationId}`; const requestInit: RequestInit = { + ...requestConfig, method: 'GET', headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...requestConfig.headers, }, - ...requestConfig, }; try { diff --git a/packages/javascript/src/api/getSchemas.ts b/packages/javascript/src/api/getSchemas.ts index 495872f2..435bc089 100644 --- a/packages/javascript/src/api/getSchemas.ts +++ b/packages/javascript/src/api/getSchemas.ts @@ -106,13 +106,13 @@ const getSchemas = async ({url, baseUrl, fetcher, ...requestConfig}: GetSchemasC const resolvedUrl: string = url ?? `${baseUrl}/scim2/Schemas`; const requestInit: RequestInit = { + ...requestConfig, method: 'GET', headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...requestConfig.headers, }, - ...requestConfig, }; try { diff --git a/packages/javascript/src/api/getScim2Me.ts b/packages/javascript/src/api/getScim2Me.ts index a690678c..5cba4f2e 100644 --- a/packages/javascript/src/api/getScim2Me.ts +++ b/packages/javascript/src/api/getScim2Me.ts @@ -106,13 +106,13 @@ const getScim2Me = async ({url, baseUrl, fetcher, ...requestConfig}: GetScim2MeC const resolvedUrl: string = url ?? `${baseUrl}/scim2/Me` const requestInit: RequestInit = { + ...requestConfig, method: 'GET', headers: { 'Content-Type': 'application/scim+json', Accept: 'application/json', ...requestConfig.headers, }, - ...requestConfig, }; try { diff --git a/packages/javascript/src/api/getUserInfo.ts b/packages/javascript/src/api/getUserInfo.ts index 37c71913..08784463 100644 --- a/packages/javascript/src/api/getUserInfo.ts +++ b/packages/javascript/src/api/getUserInfo.ts @@ -50,12 +50,13 @@ const getUserInfo = async ({url, ...requestConfig}: Partial): Promise; + logo?: Partial; + myAccountLogo?: Partial; +} + +/** + * Interface for input styling configuration. + */ +export interface InputsConfig { + base?: { + background?: { + backgroundColor?: string; + }; + border?: { + borderColor?: string; + borderRadius?: string; + }; + font?: { + color?: string; + }; + labels?: { + font?: { + color?: string; + }; + }; + }; +} + +/** + * Interface for login box configuration. + */ +export interface LoginBoxConfig { + background?: { + backgroundColor?: string; + }; + border?: { + borderColor?: string; + borderRadius?: string; + borderWidth?: string; + }; + font?: { + color?: string; + }; +} + +/** + * Interface for login page configuration. + */ +export interface LoginPageConfig { + background?: { + backgroundColor?: string; + }; + font?: { + color?: string; + }; +} + +/** + * Interface for typography configuration. + */ +export interface TypographyConfig { + font?: { + color?: string; + fontFamily?: string; + importURL?: string; + }; + heading?: { + font?: { + color?: string; + }; + }; +} + +/** + * Interface for theme variant configuration (LIGHT/DARK). + */ +export interface ThemeVariant { + buttons?: ButtonsConfig; + colors?: ColorsConfig; + footer?: FooterConfig; + images?: ImagesConfig; + inputs?: InputsConfig; + loginBox?: LoginBoxConfig; + loginPage?: LoginPageConfig; + typography?: TypographyConfig; +} + +/** + * Interface for branding preference layout configuration. + */ +export interface BrandingLayout { + activeLayout?: string; + sideImg?: { + altText?: string; + imgURL?: string; + }; +} + +/** + * Interface for organization details configuration. + */ +export interface BrandingOrganizationDetails { + displayName?: string; + supportEmail?: string; +} + +/** + * Interface for URL configurations. + */ +export interface UrlsConfig { + cookiePolicyURL?: string; + privacyPolicyURL?: string; + termsOfUseURL?: string; + selfSignUpURL?: string; +} + +/** + * Interface for branding preference theme configuration. + */ +export interface BrandingTheme { + activeTheme?: string; + LIGHT?: ThemeVariant; + DARK?: ThemeVariant; +} + +/** + * Interface for branding preference configuration. + */ +export interface BrandingPreferenceConfig { + configs?: { + isBrandingEnabled?: boolean; + removeDefaultBranding?: boolean; + selfSignUpEnabled?: boolean; + }; + layout?: BrandingLayout; + organizationDetails?: BrandingOrganizationDetails; + theme?: BrandingTheme; + urls?: UrlsConfig; +} + +/** + * Interface for branding preference configuration. + */ +export interface BrandingPreference { + type?: string; + name?: string; + locale?: string; + preference?: BrandingPreferenceConfig; +} diff --git a/packages/javascript/src/models/client.ts b/packages/javascript/src/models/client.ts index 405c7e26..d78a7301 100644 --- a/packages/javascript/src/models/client.ts +++ b/packages/javascript/src/models/client.ts @@ -41,14 +41,14 @@ export interface AsgardeoClient { * * @returns Associated organizations. */ - getOrganizations(): Promise; + getOrganizations(options?: any): Promise; /** * Gets the current organization of the user. * * @returns The current organization if available, otherwise null. */ - getCurrentOrganization(): Promise; + getCurrentOrganization(sessionId?: string): Promise; /** * Switches the current organization to the specified one. @@ -59,19 +59,21 @@ export interface AsgardeoClient { getConfiguration(): T; + updateUserProfile(payload: any, userId?: string): Promise; + /** * Gets user information from the session. * * @returns User object containing user details. */ - getUser(): Promise; + getUser(options?: any): Promise; /** * Fetches the user profile along with its schemas and a flattened version of the profile. * * @returns A promise resolving to a UserProfile object containing the user's profile information. */ - getUserProfile(): Promise; + getUserProfile(options?: any): Promise; /** * Initializes the authentication client with provided configuration. diff --git a/packages/javascript/src/models/config.ts b/packages/javascript/src/models/config.ts index 056363fd..87e9b735 100644 --- a/packages/javascript/src/models/config.ts +++ b/packages/javascript/src/models/config.ts @@ -44,6 +44,22 @@ export interface BaseConfig extends WithPreferences { */ afterSignOutUrl?: string | undefined; + /** + * Optional organization handle for the Organization in Asgardeo. + * This is used to identify the organization in the Asgardeo identity server in cases like Branding, etc. + * If not provided, the framework layer will try to use the `baseUrl` to determine the organization handle. + * @remarks This is mandatory if a custom domain is configured for the Asgardeo organization. + */ + organizationHandle?: string | undefined; + + /** + * Optional UUID of the Asgardeo application. + * This is used to identify the application in the Asgardeo identity server for Application Branding, + * obtaining the access URL in the sign-up flow, etc. + * If not provided, the framework layer will use the default application ID based on the application. + */ + applicationId?: string | undefined; + /** * The base URL of the Asgardeo identity server. * Example: "https://api.asgardeo.io/t/{org_name}" @@ -103,6 +119,10 @@ export interface WithPreferences { export type Config = BaseConfig; export interface ThemePreferences { + /** + * Inherit from Branding from WSO2 Identity Server or Asgardeo. + */ + inheritFromBranding?: boolean; /** * The theme mode to use. Defaults to 'system'. */ diff --git a/packages/javascript/src/theme/types.ts b/packages/javascript/src/theme/types.ts index 6d4cf5d6..db009a03 100644 --- a/packages/javascript/src/theme/types.ts +++ b/packages/javascript/src/theme/types.ts @@ -72,4 +72,17 @@ export interface Theme extends ThemeConfig { cssVariables: Record; } -export type ThemeMode = 'light' | 'dark' | 'system'; +export type ThemeMode = 'light' | 'dark' | 'system' | 'class'; + +export interface ThemeDetection { + /** + * The CSS class name to detect for dark mode (without the dot) + * @default 'dark' + */ + darkClass?: string; + /** + * The CSS class name to detect for light mode (without the dot) + * @default 'light' + */ + lightClass?: string; +} diff --git a/packages/javascript/src/utils/__tests__/deriveOrganizationHandleFromBaseUrl.test.ts b/packages/javascript/src/utils/__tests__/deriveOrganizationHandleFromBaseUrl.test.ts new file mode 100644 index 00000000..8b3dda78 --- /dev/null +++ b/packages/javascript/src/utils/__tests__/deriveOrganizationHandleFromBaseUrl.test.ts @@ -0,0 +1,158 @@ +/** + * 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 deriveOrganizationHandleFromBaseUrl from '../deriveOrganizationHandleFromBaseUrl'; +import AsgardeoRuntimeError from '../../errors/AsgardeoRuntimeError'; + +describe('deriveOrganizationHandleFromBaseUrl', () => { + describe('Valid Asgardeo URLs', () => { + it('should extract organization handle from dev.asgardeo.io URL', () => { + const result = deriveOrganizationHandleFromBaseUrl('https://dev.asgardeo.io/t/dxlab'); + expect(result).toBe('dxlab'); + }); + + it('should extract organization handle from stage.asgardeo.io URL', () => { + const result = deriveOrganizationHandleFromBaseUrl('https://stage.asgardeo.io/t/dxlab'); + expect(result).toBe('dxlab'); + }); + + it('should extract organization handle from prod.asgardeo.io URL', () => { + const result = deriveOrganizationHandleFromBaseUrl('https://prod.asgardeo.io/t/dxlab'); + expect(result).toBe('dxlab'); + }); + + it('should extract organization handle from custom subdomain asgardeo.io URL', () => { + const result = deriveOrganizationHandleFromBaseUrl('https://xxx.asgardeo.io/t/dxlab'); + expect(result).toBe('dxlab'); + }); + + it('should extract organization handle with trailing slash', () => { + const result = deriveOrganizationHandleFromBaseUrl('https://dev.asgardeo.io/t/dxlab/'); + expect(result).toBe('dxlab'); + }); + + it('should extract organization handle with additional path segments', () => { + const result = deriveOrganizationHandleFromBaseUrl('https://dev.asgardeo.io/t/dxlab/api/v1'); + expect(result).toBe('dxlab'); + }); + + it('should handle different organization handles', () => { + expect(deriveOrganizationHandleFromBaseUrl('https://dev.asgardeo.io/t/myorg')).toBe('myorg'); + expect(deriveOrganizationHandleFromBaseUrl('https://dev.asgardeo.io/t/test-org')).toBe('test-org'); + expect(deriveOrganizationHandleFromBaseUrl('https://dev.asgardeo.io/t/org123')).toBe('org123'); + }); + }); + + describe('Invalid URLs - Custom Domains', () => { + it('should throw error for custom domain without asgardeo.io', () => { + expect(() => { + deriveOrganizationHandleFromBaseUrl('https://custom.example.com/auth'); + }).toThrow(AsgardeoRuntimeError); + + expect(() => { + deriveOrganizationHandleFromBaseUrl('https://custom.example.com/auth'); + }).toThrow('Organization handle is required since a custom domain is configured.'); + }); + + it('should throw error for URLs without /t/ pattern', () => { + expect(() => { + deriveOrganizationHandleFromBaseUrl('https://auth.asgardeo.io/oauth2/token'); + }).toThrow(AsgardeoRuntimeError); + + expect(() => { + deriveOrganizationHandleFromBaseUrl('https://auth.asgardeo.io/oauth2/token'); + }).toThrow('Organization handle is required since a custom domain is configured.'); + }); + + it('should throw error for URLs with malformed /t/ pattern', () => { + expect(() => { + deriveOrganizationHandleFromBaseUrl('https://dev.asgardeo.io/t/'); + }).toThrow(AsgardeoRuntimeError); + + expect(() => { + deriveOrganizationHandleFromBaseUrl('https://dev.asgardeo.io/t'); + }).toThrow(AsgardeoRuntimeError); + }); + + it('should throw error for URLs with empty organization handle', () => { + expect(() => { + deriveOrganizationHandleFromBaseUrl('https://dev.asgardeo.io/t//'); + }).toThrow(AsgardeoRuntimeError); + }); + }); + + describe('Invalid Input', () => { + it('should throw error for undefined baseUrl', () => { + expect(() => { + deriveOrganizationHandleFromBaseUrl(undefined); + }).toThrow(AsgardeoRuntimeError); + + expect(() => { + deriveOrganizationHandleFromBaseUrl(undefined); + }).toThrow('Base URL is required to derive organization handle.'); + }); + + it('should throw error for empty baseUrl', () => { + expect(() => { + deriveOrganizationHandleFromBaseUrl(''); + }).toThrow(AsgardeoRuntimeError); + + expect(() => { + deriveOrganizationHandleFromBaseUrl(''); + }).toThrow('Base URL is required to derive organization handle.'); + }); + + it('should throw error for invalid URL format', () => { + expect(() => { + deriveOrganizationHandleFromBaseUrl('not-a-valid-url'); + }).toThrow(AsgardeoRuntimeError); + + expect(() => { + deriveOrganizationHandleFromBaseUrl('not-a-valid-url'); + }).toThrow('Invalid base URL format'); + }); + }); + + describe('Error Details', () => { + it('should throw AsgardeoRuntimeError with correct error codes', () => { + try { + deriveOrganizationHandleFromBaseUrl(undefined); + } catch (error) { + expect(error).toBeInstanceOf(AsgardeoRuntimeError); + expect(error.code).toBe('javascript-deriveOrganizationHandleFromBaseUrl-ValidationError-001'); + expect(error.origin).toBe('javascript'); + } + + try { + deriveOrganizationHandleFromBaseUrl('invalid-url'); + } catch (error) { + expect(error).toBeInstanceOf(AsgardeoRuntimeError); + expect(error.code).toBe('javascript-deriveOrganizationHandleFromBaseUrl-ValidationError-002'); + expect(error.origin).toBe('javascript'); + } + + try { + deriveOrganizationHandleFromBaseUrl('https://custom.domain.com/auth'); + } catch (error) { + expect(error).toBeInstanceOf(AsgardeoRuntimeError); + expect(error.code).toBe('javascript-deriveOrganizationHandleFromBaseUrl-CustomDomainError-001'); + expect(error.origin).toBe('javascript'); + } + }); + }); +}); diff --git a/packages/javascript/src/utils/deriveOrganizationHandleFromBaseUrl.ts b/packages/javascript/src/utils/deriveOrganizationHandleFromBaseUrl.ts new file mode 100644 index 00000000..d83356e7 --- /dev/null +++ b/packages/javascript/src/utils/deriveOrganizationHandleFromBaseUrl.ts @@ -0,0 +1,109 @@ +/** + * 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 AsgardeoRuntimeError from '../errors/AsgardeoRuntimeError'; + +/** + * Extracts the organization handle from an Asgardeo base URL. + * + * This function parses Asgardeo URLs with the standard pattern: + * - https://dev.asgardeo.io/t/{orgHandle} + * - https://stage.asgardeo.io/t/{orgHandle} + * - https://prod.asgardeo.io/t/{orgHandle} + * - https://{subdomain}.asgardeo.io/t/{orgHandle} + * + * @param baseUrl - The base URL of the Asgardeo identity server + * @returns The extracted organization handle + * @throws {AsgardeoRuntimeError} When the URL doesn't match the expected Asgardeo pattern, + * indicating a custom domain is configured and organizationHandle must be provided explicitly + * + * @example + * ```typescript + * // Standard Asgardeo URLs + * const handle1 = deriveOrganizationHandleFromBaseUrl('https://dev.asgardeo.io/t/dxlab'); + * // Returns: 'dxlab' + * + * const handle2 = deriveOrganizationHandleFromBaseUrl('https://stage.asgardeo.io/t/myorg'); + * // Returns: 'myorg' + * + * // Custom domain - throws error + * deriveOrganizationHandleFromBaseUrl('https://custom.example.com/auth'); + * // Throws: AsgardeoRuntimeError + * ``` + */ +const deriveOrganizationHandleFromBaseUrl = (baseUrl?: string): string => { + if (!baseUrl) { + throw new AsgardeoRuntimeError( + 'Base URL is required to derive organization handle.', + 'javascript-deriveOrganizationHandleFromBaseUrl-ValidationError-001', + 'javascript', + 'A valid base URL must be provided to extract the organization handle.', + ); + } + + let parsedUrl: URL; + + try { + parsedUrl = new URL(baseUrl); + } catch (error) { + throw new AsgardeoRuntimeError( + `Invalid base URL format: ${baseUrl}`, + 'javascript-deriveOrganizationHandleFromBaseUrl-ValidationError-002', + 'javascript', + 'The provided base URL does not conform to valid URL syntax.', + ); + } + + // Check if the hostname matches the Asgardeo pattern: *.asgardeo.io + const hostname = parsedUrl.hostname.toLowerCase(); + if (!hostname.endsWith('.asgardeo.io')) { + throw new AsgardeoRuntimeError( + 'Organization handle is required since a custom domain is configured.', + 'javascript-deriveOrganizationHandleFromBaseUrl-CustomDomainError-001', + 'javascript', + 'The provided base URL uses a custom domain. Please provide the organizationHandle explicitly in the configuration.', + ); + } + + // Extract the organization handle from the path pattern: /t/{orgHandle} + const pathSegments = parsedUrl.pathname.split('/').filter(segment => segment.length > 0); + + if (pathSegments.length < 2 || pathSegments[0] !== 't') { + throw new AsgardeoRuntimeError( + 'Organization handle is required since a custom domain is configured.', + 'javascript-deriveOrganizationHandleFromBaseUrl-CustomDomainError-002', + 'javascript', + 'The provided base URL does not follow the expected Asgardeo URL pattern (/t/{orgHandle}). Please provide the organizationHandle explicitly in the configuration.', + ); + } + + const organizationHandle = pathSegments[1]; + + if (!organizationHandle || organizationHandle.trim().length === 0) { + throw new AsgardeoRuntimeError( + 'Organization handle is required since a custom domain is configured.', + 'javascript-deriveOrganizationHandleFromBaseUrl-CustomDomainError-003', + 'javascript', + 'The organization handle could not be extracted from the base URL. Please provide the organizationHandle explicitly in the configuration.', + ); + } + + return organizationHandle; +}; + +export default deriveOrganizationHandleFromBaseUrl; diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index b6e163ab..c5e743da 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -22,14 +22,6 @@ ".": { "import": "./dist/index.js", "require": "./dist/cjs/index.js" - }, - "./middleware": { - "import": "./dist/middleware/index.js", - "require": "./dist/cjs/middleware/index.js" - }, - "./server": { - "import": "./dist/server/index.js", - "require": "./dist/cjs/server/index.js" } }, "files": [ diff --git a/packages/nextjs/src/AsgardeoNextClient.ts b/packages/nextjs/src/AsgardeoNextClient.ts index 9c8de194..d39d2206 100644 --- a/packages/nextjs/src/AsgardeoNextClient.ts +++ b/packages/nextjs/src/AsgardeoNextClient.ts @@ -40,6 +40,13 @@ import { generateFlattenedUserProfile, updateMeProfile, executeEmbeddedSignUpFlow, + getMeOrganizations, + IdToken, + createOrganization, + CreateOrganizationPayload, + getOrganization, + OrganizationDetails, + deriveOrganizationHandleFromBaseUrl } from '@asgardeo/node'; import {NextRequest, NextResponse} from 'next/server'; import {AsgardeoNextConfig} from './models/config'; @@ -91,18 +98,24 @@ class AsgardeoNextClient exte override async initialize(config: T): Promise { if (this.isInitialized) { - console.warn('[AsgardeoNextClient] Client is already initialized'); return Promise.resolve(true); } - const {baseUrl, clientId, clientSecret, signInUrl, afterSignInUrl, afterSignOutUrl, signUpUrl, ...rest} = + const {baseUrl, organizationHandle, clientId, clientSecret, signInUrl, afterSignInUrl, afterSignOutUrl, signUpUrl, ...rest} = decorateConfigWithNextEnv(config); this.isInitialized = true; + let resolvedOrganizationHandle: string | undefined = organizationHandle; + + if (!resolvedOrganizationHandle) { + resolvedOrganizationHandle = deriveOrganizationHandleFromBaseUrl(baseUrl); + } + const origin: string = await getClientOrigin(); return this.asgardeo.initialize({ + organizationHandle: resolvedOrganizationHandle, baseUrl, clientId, clientSecret, @@ -176,13 +189,13 @@ class AsgardeoNextClient exte } catch (error) { return { schemas: [], - flattenedProfile: await this.asgardeo.getDecodedIdToken(), - profile: await this.asgardeo.getDecodedIdToken(), + flattenedProfile: await this.asgardeo.getDecodedIdToken(userId), + profile: await this.asgardeo.getDecodedIdToken(userId), }; } } - async updateUserProfile(payload: any, userId?: string) { + override async updateUserProfile(payload: any, userId?: string): Promise { await this.ensureInitialized(); try { @@ -198,7 +211,7 @@ class AsgardeoNextClient exte }); } catch (error) { throw new AsgardeoRuntimeError( - `Failed to update user profile: ${error instanceof Error ? error.message : 'Unknown error'}`, + `Failed to update user profile: ${error instanceof Error ? error.message : String(error)}`, 'AsgardeoNextClient-UpdateProfileError-001', 'react', 'An error occurred while updating the user profile. Please check your configuration and network connection.', @@ -206,16 +219,120 @@ class AsgardeoNextClient exte } } - override async getOrganizations(): Promise { - throw new Error('Method not implemented.'); + async createOrganization(payload: CreateOrganizationPayload, userId?: string): Promise { + try { + const configData = await this.asgardeo.getConfigData(); + const baseUrl: string = configData?.baseUrl as string; + + return await createOrganization({ + payload, + baseUrl, + headers: { + Authorization: `Bearer ${await this.getAccessToken(userId)}`, + }, + }); + } catch (error) { + throw new AsgardeoRuntimeError( + `Failed to create organization: ${error instanceof Error ? error.message : String(error)}`, + 'AsgardeoReactClient-createOrganization-RuntimeError-001', + 'nextjs', + 'An error occurred while creating the organization. Please check your configuration and network connection.', + ); + } + } + + async getOrganization(organizationId: string, userId?: string): Promise { + try { + const configData = await this.asgardeo.getConfigData(); + const baseUrl: string = configData?.baseUrl as string; + + return await getOrganization({ + baseUrl, + organizationId, + headers: { + Authorization: `Bearer ${await this.getAccessToken(userId)}`, + }, + }); + } catch (error) { + throw new AsgardeoRuntimeError( + `Failed to fetch the organization details of ${organizationId}: ${String(error)}`, + 'AsgardeoReactClient-getOrganization-RuntimeError-001', + 'nextjs', + `An error occurred while fetching the organization with the id: ${organizationId}.`, + ); + } + } + + override async getOrganizations(userId?: string): Promise { + try { + const configData = await this.asgardeo.getConfigData(); + const baseUrl: string = configData?.baseUrl as string; + + const organizations = await getMeOrganizations({ + baseUrl, + headers: { + Authorization: `Bearer ${await this.getAccessToken(userId)}`, + }, + }); + + 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 switchOrganization(organization: Organization): Promise { - throw new Error('Method not implemented.'); + override async getCurrentOrganization(userId?: string): Promise { + const idToken: IdToken = await this.asgardeo.getDecodedIdToken(userId); + + return { + orgHandle: idToken?.org_handle as string, + name: idToken?.org_name as string, + id: idToken?.org_id as string, + }; } - override getCurrentOrganization(): Promise { - throw new Error('Method not implemented.'); + override async switchOrganization(organization: Organization, userId?: string): 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-ValidationError-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, userId); + } catch (error) { + throw new AsgardeoRuntimeError( + `Failed to switch organization: ${error instanceof Error ? error.message : String(error)}`, + 'AsgardeoReactClient-RuntimeError-003', + 'nextjs', + 'An error occurred while switching to the specified organization. Please try again.', + ); + } } override isLoading(): boolean { diff --git a/packages/nextjs/src/client/components/presentation/CreateOrganization/CreateOrganization.tsx b/packages/nextjs/src/client/components/presentation/CreateOrganization/CreateOrganization.tsx new file mode 100644 index 00000000..b111912c --- /dev/null +++ b/packages/nextjs/src/client/components/presentation/CreateOrganization/CreateOrganization.tsx @@ -0,0 +1,151 @@ +/** + * 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 {FC, ReactElement, useState} from 'react'; + +import {BaseCreateOrganization, BaseCreateOrganizationProps, useOrganization} from '@asgardeo/react'; +import {CreateOrganizationPayload} from '@asgardeo/node'; +import useAsgardeo from '../../../contexts/Asgardeo/useAsgardeo'; +import createOrganizationAction from '../../../../server/actions/createOrganizationAction'; +import getSessionId from '../../../../server/actions/getSessionId'; + +/** + * Props interface for the CreateOrganization component. + */ +export interface CreateOrganizationProps extends Omit { + /** + * Fallback element to render when the user is not signed in. + */ + fallback?: ReactElement; + /** + * Custom organization creation handler (will use default API if not provided). + */ + onCreateOrganization?: (payload: CreateOrganizationPayload) => Promise; +} + +/** + * CreateOrganization component that provides organization creation functionality. + * This component automatically integrates with the Asgardeo and Organization contexts. + * + * @example + * ```tsx + * import { CreateOrganization } from '@asgardeo/react'; + * + * // Basic usage - uses default API and contexts + * console.log('Created:', org)} + * onCancel={() => navigate('/organizations')} + * /> + * + * // With custom organization creation handler + * { + * const result = await myCustomAPI.createOrganization(payload); + * return result; + * }} + * onSuccess={(org) => { + * console.log('Organization created:', org.name); + * // Custom success logic here + * }} + * /> + * + * // With fallback for unauthenticated users + * Please sign in to create an organization} + * /> + * ``` + */ +export const CreateOrganization: FC = ({ + onCreateOrganization, + fallback = <>, + onSuccess, + defaultParentId, + ...props +}: CreateOrganizationProps): ReactElement => { + const {isSignedIn, baseUrl} = useAsgardeo(); + const {currentOrganization, revalidateOrganizations} = useOrganization(); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + // Don't render if not authenticated + if (!isSignedIn && fallback) { + return fallback; + } + + if (!isSignedIn) { + return <>; + } + + // Use current organization as parent if no defaultParentId provided + const parentId: string = defaultParentId || currentOrganization?.id || ''; + + const handleSubmit = async (payload: CreateOrganizationPayload): Promise => { + setLoading(true); + setError(null); + + try { + let result: any; + + if (onCreateOrganization) { + // Use the provided custom creation function + result = await onCreateOrganization(payload); + } else { + // Use the default API + if (!baseUrl) { + throw new Error('Base URL is required for organization creation'); + } + result = await createOrganizationAction( + { + ...payload, + parentId, + }, + (await getSessionId()) as string, + ); + } + + // Refresh organizations list to include the new organization + await revalidateOrganizations(); + + // Call success callback if provided + if (onSuccess) { + onSuccess(result); + } + } catch (createError) { + const errorMessage: string = createError instanceof Error ? createError.message : 'Failed to create organization'; + setError(errorMessage); + throw createError; // Re-throw to allow form to handle it + } finally { + setLoading(false); + } + }; + + return ( + + ); +}; + +export default CreateOrganization; diff --git a/packages/nextjs/src/client/components/presentation/Organization/Organization.tsx b/packages/nextjs/src/client/components/presentation/Organization/Organization.tsx new file mode 100644 index 00000000..7cccc8c5 --- /dev/null +++ b/packages/nextjs/src/client/components/presentation/Organization/Organization.tsx @@ -0,0 +1,83 @@ +/** + * 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 {Organization as IOrganization} from '@asgardeo/node'; +import {FC, ReactElement, ReactNode} from 'react'; +import {BaseOrganization, BaseOrganizationProps, useOrganization} from '@asgardeo/react'; + +/** + * Props for the Organization component. + * Extends BaseOrganizationProps but makes the organization prop optional since it will be obtained from useOrganization + */ +export interface OrganizationProps extends Omit { + /** + * Render prop that takes the organization object and returns a ReactNode. + * @param organization - The current organization object from Organization context. + * @returns A ReactNode to render. + */ + children: (organization: IOrganization | null) => ReactNode; + + /** + * Optional element to render when no organization is selected. + */ + fallback?: ReactNode; +} + +/** + * A component that uses render props to expose the current organization object. + * This component automatically retrieves the current organization from Organization context. + * + * @remarks This component is only supported in browser based React applications (CSR). + * + * @example + * ```tsx + * import { Organization } from '@asgardeo/auth-react'; + * + * const App = () => { + * return ( + * No organization selected

}> + * {(organization) => ( + *
+ *

Current Organization: {organization.name}!

+ *

ID: {organization.id}

+ *

Role: {organization.role}

+ * {organization.memberCount && ( + *

Members: {organization.memberCount}

+ * )} + *
+ * )} + *
+ * ); + * } + * ``` + */ +const Organization: FC = ({children, fallback = null}): ReactElement => { + const {currentOrganization} = useOrganization(); + + return ( + + {children} + + ); +}; + +Organization.displayName = 'Organization'; + +export default Organization; diff --git a/packages/nextjs/src/client/components/presentation/OrganizationList/OrganizationList.tsx b/packages/nextjs/src/client/components/presentation/OrganizationList/OrganizationList.tsx new file mode 100644 index 00000000..0ab5d680 --- /dev/null +++ b/packages/nextjs/src/client/components/presentation/OrganizationList/OrganizationList.tsx @@ -0,0 +1,233 @@ +/** + * 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 {withVendorCSSClassPrefix} from '@asgardeo/node'; +import {FC, ReactElement, useEffect, useMemo, CSSProperties} from 'react'; +import { + BaseOrganizationListProps, + BaseOrganizationList, + useOrganization, + OrganizationWithSwitchAccess, +} from '@asgardeo/react'; + +/** + * Configuration options for the OrganizationList component. + */ +export interface OrganizationListConfig { + /** + * Whether to automatically fetch organizations on mount + */ + autoFetch?: boolean; + /** + * Filter string for organizations + */ + filter?: string; + /** + * Number of organizations to fetch per page + */ + limit?: number; + /** + * Whether to include recursive organizations + */ + recursive?: boolean; +} + +/** + * Props interface for the OrganizationList component. + * Uses the enhanced OrganizationContext instead of the useOrganizations hook. + */ +export interface OrganizationListProps + extends Omit< + BaseOrganizationListProps, + 'data' | 'error' | 'fetchMore' | 'hasMore' | 'isLoading' | 'isLoadingMore' | 'totalCount' + >, + OrganizationListConfig { + /** + * Function called when an organization is selected/clicked + */ + onOrganizationSelect?: (organization: OrganizationWithSwitchAccess) => void; +} + +/** + * OrganizationList component that provides organization listing functionality with pagination. + * This component uses the enhanced OrganizationContext, eliminating the polling issue and + * providing better integration with the existing context system. + * + * @example + * ```tsx + * import { OrganizationList } from '@asgardeo/react'; + * + * // Basic usage + * + * + * // With custom limit and filter + * { + * console.log('Selected organization:', org.name); + * }} + * /> + * + * // As a popup dialog + * + * + * // With custom organization renderer + * ( + *
+ *

{org.name}

+ *

Can switch: {org.canSwitch ? 'Yes' : 'No'}

+ *
+ * )} + * /> + * ``` + */ +export const OrganizationList: FC = ({ + autoFetch = true, + filter = '', + limit = 10, + onOrganizationSelect, + recursive = false, + ...baseProps +}: OrganizationListProps): ReactElement => { + const { + paginatedOrganizations, + error, + fetchMore, + hasMore, + isLoading, + isLoadingMore, + totalCount, + fetchPaginatedOrganizations, + } = useOrganization(); + + // Auto-fetch organizations on mount or when parameters change + useEffect(() => { + if (autoFetch) { + fetchPaginatedOrganizations({ + filter, + limit, + recursive, + reset: true, + }); + } + }, [autoFetch, filter, limit, recursive, fetchPaginatedOrganizations]); + + // Enhanced organization renderer that includes selection handler + const enhancedRenderOrganization = baseProps.renderOrganization + ? baseProps.renderOrganization + : onOrganizationSelect + ? (organization: OrganizationWithSwitchAccess, index: number) => ( +
onOrganizationSelect(organization)} + style={{ + border: '1px solid #e5e7eb', + borderRadius: '8px', + cursor: 'pointer', + display: 'flex', + justifyContent: 'space-between', + padding: '16px', + transition: 'all 0.2s', + }} + onMouseEnter={e => { + e.currentTarget.style.backgroundColor = '#f9fafb'; + e.currentTarget.style.borderColor = '#d1d5db'; + }} + onMouseLeave={e => { + e.currentTarget.style.backgroundColor = 'transparent'; + e.currentTarget.style.borderColor = '#e5e7eb'; + }} + > +
+

{organization.name}

+

Handle: {organization.orgHandle}

+

+ Status:{' '} + + {organization.status} + +

+
+
+ {organization.canSwitch ? ( + + Can Switch + + ) : ( + + No Access + + )} +
+
+ ) + : undefined; + + const refreshHandler = async () => { + await fetchPaginatedOrganizations({ + filter, + limit, + recursive, + reset: true, + }); + }; + + return ( + + ); +}; + +export default OrganizationList; diff --git a/packages/nextjs/src/client/components/presentation/OrganizationProfile/OrganizationProfile.tsx b/packages/nextjs/src/client/components/presentation/OrganizationProfile/OrganizationProfile.tsx new file mode 100644 index 00000000..702f0f1f --- /dev/null +++ b/packages/nextjs/src/client/components/presentation/OrganizationProfile/OrganizationProfile.tsx @@ -0,0 +1,221 @@ +/** + * 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 {FC, ReactElement, useEffect, useState} from 'react'; +import {BaseOrganizationProfile, BaseOrganizationProfileProps, useTranslation} from '@asgardeo/react'; +import {OrganizationDetails, getOrganization, updateOrganization, createPatchOperations} from '@asgardeo/node'; +import useAsgardeo from '../../../contexts/Asgardeo/useAsgardeo'; +import getOrganizationAction from '../../../../server/actions/getOrganizationAction'; +import getSessionId from '../../../../server/actions/getSessionId'; + +/** + * Props for the OrganizationProfile component. + * Extends BaseOrganizationProfileProps but makes the organization prop optional + * since it will be fetched using the organizationId + */ +export type OrganizationProfileProps = Omit & { + /** + * Component to show when there's an error loading organization data. + */ + errorFallback?: ReactElement; + + /** + * Component to show while loading organization data. + */ + loadingFallback?: ReactElement; + + /** + * Display mode for the component. + */ + mode?: 'default' | 'popup'; + + /** + * Callback fired when the popup should be closed (only used in popup mode). + */ + onOpenChange?: (open: boolean) => void; + + /** + * Callback fired when the organization should be updated. + */ + onUpdate?: (payload: any) => Promise; + + /** + * Whether the popup is open (only used in popup mode). + */ + open?: boolean; + + /** + * The ID of the organization to fetch and display. + */ + organizationId: string; + + /** + * Custom title for the popup dialog (only used in popup mode). + */ + popupTitle?: string; +}; + +/** + * OrganizationProfile component displays organization information in a + * structured and styled format. It automatically fetches organization details + * using the provided organization ID and displays them using BaseOrganizationProfile. + * + * The component supports editing functionality, allowing users to modify organization + * fields inline. Updates are automatically synced with the backend via the SCIM2 API. + * + * This component is the React-specific implementation that automatically + * retrieves the organization data from Asgardeo API. + * + * @example + * ```tsx + * // Basic usage with editing enabled (default) + * + * + * // Read-only mode + * + * + * // With card layout and custom fallbacks + * Loading organization...} + * errorFallback={
Failed to load organization
} + * fallback={
No organization data available
} + * /> + * + * // With custom fields configuration and update callback + * value || 'No description' }, + * { key: 'created', label: 'Created Date', editable: false, render: (value) => new Date(value).toLocaleDateString() }, + * { key: 'lastModified', label: 'Last Modified Date', editable: false, render: (value) => new Date(value).toLocaleDateString() }, + * { key: 'attributes', label: 'Custom Attributes', editable: true } + * ]} + * onUpdate={async (payload) => { + * console.log('Organization updated:', payload); + * // payload contains the updated field values + * // The component automatically converts these to patch operations + * }} + * /> + * + * // In popup mode + * + * ``` + */ +const OrganizationProfile: FC = ({ + organizationId, + mode = 'default', + open = false, + onOpenChange, + onUpdate, + popupTitle, + loadingFallback =
Loading organization...
, + errorFallback =
Failed to load organization data
, + ...rest +}: OrganizationProfileProps): ReactElement => { + const {baseUrl} = useAsgardeo(); + const {t} = useTranslation(); + const [organization, setOrganization] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(false); + + const fetchOrganization = async () => { + if (!baseUrl || !organizationId) { + setLoading(false); + setError(true); + return; + } + + try { + setLoading(true); + setError(false); + const result = await getOrganizationAction(organizationId, (await getSessionId()) as string); + + if (result.data?.organization) { + setOrganization(result.data.organization); + + return; + } + + setError(true); + } catch (err) { + console.error('Failed to fetch organization:', err); + setError(true); + setOrganization(null); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchOrganization(); + }, [baseUrl, organizationId]); + + const handleOrganizationUpdate = async (payload: any): Promise => { + if (!baseUrl || !organizationId) return; + + try { + // Convert payload to patch operations format + const operations = createPatchOperations(payload); + + await updateOrganization({ + baseUrl, + organizationId, + operations, + }); + // Refetch organization data after update + await fetchOrganization(); + + // Call the optional onUpdate callback + if (onUpdate) { + await onUpdate(payload); + } + } catch (err) { + console.error('Failed to update organization:', err); + throw err; + } + }; + + return ( + + ); +}; + +export default OrganizationProfile; diff --git a/packages/nextjs/src/client/components/presentation/OrganizationSwitcher/OrganizationSwitcher.tsx b/packages/nextjs/src/client/components/presentation/OrganizationSwitcher/OrganizationSwitcher.tsx new file mode 100644 index 00000000..b840e005 --- /dev/null +++ b/packages/nextjs/src/client/components/presentation/OrganizationSwitcher/OrganizationSwitcher.tsx @@ -0,0 +1,198 @@ +/** + * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +'use client'; + +import {FC, ReactElement, useState} from 'react'; +import { + BaseOrganizationSwitcher, + BaseOrganizationSwitcherProps, + BuildingAlt, + useOrganization, + useTranslation, +} from '@asgardeo/react'; +import useAsgardeo from '../../../contexts/Asgardeo/useAsgardeo'; +import {CreateOrganization} from '../CreateOrganization/CreateOrganization'; +import OrganizationProfile from '../OrganizationProfile/OrganizationProfile'; +import OrganizationList from '../OrganizationList/OrganizationList'; +import {Organization} from '@asgardeo/node'; + +/** + * Props interface for the OrganizationSwitcher component. + * Makes organizations optional since they'll be retrieved from OrganizationContext. + */ +export interface OrganizationSwitcherProps + extends Omit { + /** + * Optional override for current organization (will use context if not provided) + */ + currentOrganization?: Organization; + /** + * Fallback element to render when the user is not signed in. + */ + fallback?: ReactElement; + /** + * Optional callback for organization switch (will use context if not provided) + */ + onOrganizationSwitch?: (organization: Organization) => Promise | void; + /** + * Optional override for organizations list (will use context if not provided) + */ + organizations?: Organization[]; +} + +/** + * OrganizationSwitcher component that provides organization switching functionality. + * This component automatically retrieves organizations from the OrganizationContext. + * You can also override the organizations, currentOrganization, and onOrganizationSwitch + * by passing them as props. + * + * @example + * ```tsx + * import { OrganizationSwitcher } from '@asgardeo/react'; + * + * // Basic usage - uses OrganizationContext + * + * + * // With custom organization switch handler + * { + * console.log('Switching to:', org.name); + * // Custom logic here + * }} + * /> + * + * // With fallback for unauthenticated users + * Please sign in to view organizations} + * /> + * ``` + */ +export const OrganizationSwitcher: FC = ({ + currentOrganization: propCurrentOrganization, + fallback = <>, + onOrganizationSwitch: propOnOrganizationSwitch, + organizations: propOrganizations, + ...props +}: OrganizationSwitcherProps): ReactElement => { + const {isSignedIn} = useAsgardeo(); + const { + currentOrganization: contextCurrentOrganization, + organizations: contextOrganizations, + switchOrganization, + isLoading, + error, + } = useOrganization(); + const [isCreateOrgOpen, setIsCreateOrgOpen] = useState(false); + const [isProfileOpen, setIsProfileOpen] = useState(false); + const [isOrganizationListOpen, setIsOrganizationListOpen] = useState(false); + const {t} = useTranslation(); + + if (!isSignedIn && fallback) { + return fallback; + } + + if (!isSignedIn) { + return <>; + } + + const organizations: Organization[] = propOrganizations || contextOrganizations || []; + const currentOrganization: Organization = propCurrentOrganization || (contextCurrentOrganization as Organization); + const onOrganizationSwitch: (organization: Organization) => void = propOnOrganizationSwitch || switchOrganization; + + const handleManageOrganizations = (): void => { + setIsOrganizationListOpen(true); + }; + + const handleManageOrganization = (): void => { + setIsProfileOpen(true); + }; + + const defaultMenuItems: Array<{icon?: ReactElement; label: string; onClick: () => void}> = []; + + if (currentOrganization) { + defaultMenuItems.push({ + icon: , + label: t('organization.switcher.manage.organizations'), + onClick: handleManageOrganizations, + }); + } + + defaultMenuItems.push({ + icon: ( + + + + ), + label: t('organization.switcher.create.organization'), + onClick: (): void => setIsCreateOrgOpen(true), + }); + + const menuItems = props.menuItems ? [...defaultMenuItems, ...props.menuItems] : defaultMenuItems; + + return ( + <> + + { + if (org && onOrganizationSwitch) { + onOrganizationSwitch(org); + } + setIsCreateOrgOpen(false); + }} + /> + {currentOrganization && ( + {t('organization.profile.loading')}} + errorFallback={
{t('organization.profile.error')}
} + /> + )} + { + if (onOrganizationSwitch) { + onOrganizationSwitch(organization); + } + setIsOrganizationListOpen(false); + }} + /> + + ); +}; + +export default OrganizationSwitcher; diff --git a/packages/nextjs/src/client/components/presentation/UserProfile/UserProfile.tsx b/packages/nextjs/src/client/components/presentation/UserProfile/UserProfile.tsx index 73c24427..6465b4e7 100644 --- a/packages/nextjs/src/client/components/presentation/UserProfile/UserProfile.tsx +++ b/packages/nextjs/src/client/components/presentation/UserProfile/UserProfile.tsx @@ -23,7 +23,7 @@ import {BaseUserProfile, BaseUserProfileProps, useUser} from '@asgardeo/react'; import useAsgardeo from '../../../contexts/Asgardeo/useAsgardeo'; import getSessionId from '../../../../server/actions/getSessionId'; import updateUserProfileAction from '../../../../server/actions/updateUserProfileAction'; -import { Schema, User } from '@asgardeo/node'; +import {Schema, User} from '@asgardeo/node'; /** * Props for the UserProfile component. @@ -56,11 +56,11 @@ export type UserProfileProps = Omit = ({...rest}: UserProfileProps): ReactElement => { const {baseUrl} = useAsgardeo(); - const {profile, flattenedProfile, schemas, revalidateProfile} = useUser(); + const {profile, flattenedProfile, schemas, onUpdateProfile, updateProfile} = useUser(); const handleProfileUpdate = async (payload: any): Promise => { - await updateUserProfileAction(payload, (await getSessionId()) as string); - await revalidateProfile(); + const result = await updateProfile(payload, (await getSessionId()) as string); + onUpdateProfile(result?.data?.user); }; return ( diff --git a/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoContext.ts b/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoContext.ts index c1fc018c..a0658be0 100644 --- a/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoContext.ts +++ b/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoContext.ts @@ -31,6 +31,8 @@ export type AsgardeoContextProps = Partial; * Context object for managing the Authentication flow builder core context. */ const AsgardeoContext: Context = createContext({ + organizationHandle: undefined, + applicationId: undefined, signInUrl: undefined, signUpUrl: undefined, afterSignInUrl: undefined, diff --git a/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoProvider.tsx b/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoProvider.tsx index cc0dcbe2..e03cb254 100644 --- a/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoProvider.tsx +++ b/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoProvider.tsx @@ -19,16 +19,30 @@ 'use client'; import { + AsgardeoRuntimeError, EmbeddedFlowExecuteRequestConfig, EmbeddedFlowExecuteRequestPayload, EmbeddedSignInFlowHandleRequestPayload, + generateFlattenedUserProfile, + Organization, + UpdateMeProfileConfig, User, UserProfile, } from '@asgardeo/node'; -import {I18nProvider, FlowProvider, UserProvider, ThemeProvider, AsgardeoProviderProps} from '@asgardeo/react'; -import {FC, PropsWithChildren, useEffect, useMemo, useState} from 'react'; +import { + I18nProvider, + FlowProvider, + UserProvider, + ThemeProvider, + AsgardeoProviderProps, + OrganizationProvider, +} from '@asgardeo/react'; +import {FC, PropsWithChildren, RefObject, useEffect, useMemo, useRef, useState} from 'react'; import {useRouter, useSearchParams} from 'next/navigation'; import AsgardeoContext, {AsgardeoContextProps} from './AsgardeoContext'; +import getOrganizationsAction from '../../../server/actions/getOrganizationsAction'; +import getSessionId from '../../../server/actions/getSessionId'; +import switchOrganizationAction from '../../../server/actions/switchOrganizationAction'; /** * Props interface of {@link AsgardeoClientProvider} @@ -38,10 +52,21 @@ export type AsgardeoClientProviderProps = Partial Promise<{success: boolean; error?: string; redirectUrl?: string}>; + applicationId: AsgardeoContextProps['applicationId']; + organizationHandle: AsgardeoContextProps['organizationHandle']; + handleOAuthCallback: ( + code: string, + state: string, + sessionState?: string, + ) => Promise<{success: boolean; error?: string; redirectUrl?: string}>; isSignedIn: boolean; userProfile: UserProfile; + currentOrganization: Organization; user: User | null; + updateProfile: ( + requestConfig: UpdateMeProfileConfig, + sessionId?: string, + ) => Promise<{success: boolean; data: {user: User}; error: string}>; }; const AsgardeoClientProvider: FC> = ({ @@ -55,14 +80,28 @@ const AsgardeoClientProvider: FC> isSignedIn, signInUrl, signUpUrl, - user, - userProfile, + user: _user, + userProfile: _userProfile, + currentOrganization, + updateProfile, + applicationId, + organizationHandle, }: PropsWithChildren) => { + const reRenderCheckRef: RefObject = useRef(false); const router = useRouter(); const searchParams = useSearchParams(); const [isDarkMode, setIsDarkMode] = useState(false); const [isLoading, setIsLoading] = useState(true); - const [_userProfile, setUserProfile] = useState(userProfile); + const [user, setUser] = useState(_user); + const [userProfile, setUserProfile] = useState(_userProfile); + + useEffect(() => { + setUserProfile(_userProfile); + }, [_userProfile]); + + useEffect(() => { + setUser(_user); + }, [_user]); // Handle OAuth callback automatically useEffect(() => { @@ -81,7 +120,11 @@ const AsgardeoClientProvider: FC> if (error) { console.error('[AsgardeoClientProvider] OAuth error:', error, errorDescription); // Redirect to sign-in page with error - router.push(`/signin?error=${encodeURIComponent(error)}&error_description=${encodeURIComponent(errorDescription || '')}`); + router.push( + `/signin?error=${encodeURIComponent(error)}&error_description=${encodeURIComponent( + errorDescription || '', + )}`, + ); return; } @@ -100,7 +143,11 @@ const AsgardeoClientProvider: FC> window.location.reload(); } } else { - router.push(`/signin?error=authentication_failed&error_description=${encodeURIComponent(result.error || 'Authentication failed')}`); + router.push( + `/signin?error=authentication_failed&error_description=${encodeURIComponent( + result.error || 'Authentication failed', + )}`, + ); } } } catch (error) { @@ -206,6 +253,25 @@ const AsgardeoClientProvider: FC> } }; + const switchOrganization = async (organization: Organization): Promise => { + try { + await switchOrganizationAction(organization, (await getSessionId()) as string); + + // if (await asgardeo.isSignedIn()) { + // setUser(await asgardeo.getUser()); + // setUserProfile(await asgardeo.getUserProfile()); + // setCurrentOrganization(await asgardeo.getCurrentOrganization()); + // } + } catch (error) { + throw new AsgardeoRuntimeError( + `Failed to switch organization: ${error instanceof Error ? error.message : String(error)}`, + 'AsgardeoClientProvider-switchOrganization-RuntimeError-001', + 'nextjs', + 'An error occurred while switching to the specified organization.', + ); + } + }; + const contextValue = useMemo( () => ({ baseUrl, @@ -217,16 +283,39 @@ const AsgardeoClientProvider: FC> signUp: handleSignUp, signInUrl, signUpUrl, + applicationId, + organizationHandle, }), - [baseUrl, user, isSignedIn, isLoading, signInUrl, signUpUrl], + [baseUrl, user, isSignedIn, isLoading, signInUrl, signUpUrl, applicationId, organizationHandle], ); + const handleProfileUpdate = (payload: User): void => { + setUser(payload); + setUserProfile(prev => ({ + ...prev, + profile: payload, + flattenedProfile: generateFlattenedUserProfile(payload, prev?.schemas), + })); + }; + return ( - + - {children} + + { + const result = await getOrganizationsAction((await getSessionId()) as string); + + return result?.data?.organizations || []; + }} + currentOrganization={currentOrganization} + onOrganizationSwitch={switchOrganization} + > + {children} + + diff --git a/packages/nextjs/src/index.ts b/packages/nextjs/src/index.ts index ae8cccb6..40ceb844 100644 --- a/packages/nextjs/src/index.ts +++ b/packages/nextjs/src/index.ts @@ -26,6 +26,15 @@ export {default as isSignedIn} from './server/actions/isSignedIn'; export {default as handleOAuthCallback} from './server/actions/handleOAuthCallbackAction'; +export {default as CreateOrganization} from './client/components/presentation/CreateOrganization/CreateOrganization'; +export {CreateOrganizationProps} from './client/components/presentation/CreateOrganization/CreateOrganization'; + +export {default as OrganizationProfile} from './client/components/presentation/OrganizationProfile/OrganizationProfile'; +export {OrganizationProfileProps} from './client/components/presentation/OrganizationProfile/OrganizationProfile'; + +export {default as OrganizationSwitcher} from './client/components/presentation/OrganizationSwitcher/OrganizationSwitcher'; +export {OrganizationSwitcherProps} from './client/components/presentation/OrganizationSwitcher/OrganizationSwitcher'; + export {default as SignedIn} from './client/components/control/SignedIn/SignedIn'; export {SignedInProps} from './client/components/control/SignedIn/SignedIn'; diff --git a/packages/nextjs/src/server/AsgardeoProvider.tsx b/packages/nextjs/src/server/AsgardeoProvider.tsx index 21efda1d..dea32dc1 100644 --- a/packages/nextjs/src/server/AsgardeoProvider.tsx +++ b/packages/nextjs/src/server/AsgardeoProvider.tsx @@ -16,9 +16,11 @@ * under the License. */ +'use server'; + import {FC, PropsWithChildren, ReactElement} from 'react'; -import {AsgardeoRuntimeError, User, UserProfile} from '@asgardeo/node'; -import AsgardeoClientProvider, {AsgardeoClientProviderProps} from '../client/contexts/Asgardeo/AsgardeoProvider'; +import {AsgardeoRuntimeError, Organization, User, UserProfile} from '@asgardeo/node'; +import AsgardeoClientProvider from '../client/contexts/Asgardeo/AsgardeoProvider'; import AsgardeoNextClient from '../AsgardeoNextClient'; import signInAction from './actions/signInAction'; import signOutAction from './actions/signOutAction'; @@ -30,6 +32,8 @@ import getUserProfileAction from './actions/getUserProfileAction'; import signUpAction from './actions/signUpAction'; import handleOAuthCallbackAction from './actions/handleOAuthCallbackAction'; import {AsgardeoProviderProps} from '@asgardeo/react'; +import getCurrentOrganizationAction from './actions/getCurrentOrganizationAction'; +import updateUserProfileAction from './actions/updateUserProfileAction'; /** * Props interface of {@link AsgardeoServerProvider} @@ -88,28 +92,39 @@ const AsgardeoServerProvider: FC> profile: {}, flattenedProfile: {}, }; + let currentOrganization: Organization = { + id: '', + name: '', + orgHandle: '', + }; if (_isSignedIn) { const userResponse = await getUserAction(sessionId); const userProfileResponse = await getUserProfileAction(sessionId); + const currentOrganizationResponse = await getCurrentOrganizationAction(sessionId); user = userResponse.data?.user || {}; userProfile = userProfileResponse.data?.userProfile; + currentOrganization = currentOrganizationResponse?.data?.organization as Organization; } return ( {children} diff --git a/packages/nextjs/src/server/actions/createOrganizationAction.ts b/packages/nextjs/src/server/actions/createOrganizationAction.ts new file mode 100644 index 00000000..83a1b0fc --- /dev/null +++ b/packages/nextjs/src/server/actions/createOrganizationAction.ts @@ -0,0 +1,43 @@ +/** + * 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 server'; + +import {CreateOrganizationPayload, Organization} from '@asgardeo/node'; +import AsgardeoNextClient from '../../AsgardeoNextClient'; + +/** + * Server action to create an organization. + */ +const createOrganizationAction = async (payload: CreateOrganizationPayload, sessionId: string) => { + try { + const client = AsgardeoNextClient.getInstance(); + const organization: Organization = await client.createOrganization(payload, sessionId); + return {success: true, data: {organization}, error: null}; + } catch (error) { + return { + success: false, + data: { + user: {}, + }, + error: 'Failed to create organization', + }; + } +}; + +export default createOrganizationAction; diff --git a/packages/nextjs/src/server/actions/getCurrentOrganizationAction.ts b/packages/nextjs/src/server/actions/getCurrentOrganizationAction.ts new file mode 100644 index 00000000..bd08d9c7 --- /dev/null +++ b/packages/nextjs/src/server/actions/getCurrentOrganizationAction.ts @@ -0,0 +1,43 @@ +/** + * 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 server'; + +import {Organization, OrganizationDetails} from '@asgardeo/node'; +import AsgardeoNextClient from '../../AsgardeoNextClient'; + +/** + * Server action to create an organization. + */ +const getCurrentOrganizationAction = async (sessionId: string) => { + try { + const client = AsgardeoNextClient.getInstance(); + const organization: Organization = await client.getCurrentOrganization(sessionId) as Organization; + return {success: true, data: {organization}, error: null}; + } catch (error) { + return { + success: false, + data: { + user: {}, + }, + error: 'Failed to get the current organization', + }; + } +}; + +export default getCurrentOrganizationAction; diff --git a/packages/nextjs/src/server/actions/getOrganizationAction.ts b/packages/nextjs/src/server/actions/getOrganizationAction.ts new file mode 100644 index 00000000..e5eb99d6 --- /dev/null +++ b/packages/nextjs/src/server/actions/getOrganizationAction.ts @@ -0,0 +1,43 @@ +/** + * 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 server'; + +import {OrganizationDetails} from '@asgardeo/node'; +import AsgardeoNextClient from '../../AsgardeoNextClient'; + +/** + * Server action to create an organization. + */ +const getOrganizationAction = async (organizationId: string, sessionId: string) => { + try { + const client = AsgardeoNextClient.getInstance(); + const organization: OrganizationDetails = await client.getOrganization(organizationId, sessionId); + return {success: true, data: {organization}, error: null}; + } catch (error) { + return { + success: false, + data: { + user: {}, + }, + error: 'Failed to get organization', + }; + } +}; + +export default getOrganizationAction; diff --git a/packages/nextjs/src/server/actions/getOrganizationsAction.ts b/packages/nextjs/src/server/actions/getOrganizationsAction.ts new file mode 100644 index 00000000..16c87807 --- /dev/null +++ b/packages/nextjs/src/server/actions/getOrganizationsAction.ts @@ -0,0 +1,43 @@ +/** + * 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 server'; + +import {Organization} from '@asgardeo/node'; +import AsgardeoNextClient from '../../AsgardeoNextClient'; + +/** + * Server action to get organizations. + */ +const getOrganizationsAction = async (sessionId: string) => { + try { + const client = AsgardeoNextClient.getInstance(); + const organizations: Organization[] = await client.getOrganizations(sessionId); + return {success: true, data: {organizations}, error: null}; + } catch (error) { + return { + success: false, + data: { + user: {}, + }, + error: 'Failed to get organizations', + }; + } +}; + +export default getOrganizationsAction; diff --git a/packages/nextjs/src/server/actions/switchOrganizationAction.ts b/packages/nextjs/src/server/actions/switchOrganizationAction.ts new file mode 100644 index 00000000..9b049bac --- /dev/null +++ b/packages/nextjs/src/server/actions/switchOrganizationAction.ts @@ -0,0 +1,43 @@ +/** + * 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 server'; + +import {Organization, OrganizationDetails} from '@asgardeo/node'; +import AsgardeoNextClient from '../../AsgardeoNextClient'; + +/** + * Server action to create an organization. + */ +const switchOrganizationAction = async (organization: Organization, sessionId: string) => { + try { + const client = AsgardeoNextClient.getInstance(); + await client.switchOrganization(organization, sessionId); + return {success: true, error: null}; + } catch (error) { + return { + success: false, + data: { + user: {}, + }, + error: 'Failed to switch to organization', + }; + } +}; + +export default switchOrganizationAction; diff --git a/packages/nextjs/src/server/actions/updateUserProfileAction.ts b/packages/nextjs/src/server/actions/updateUserProfileAction.ts index dabda4f3..69ec47d5 100644 --- a/packages/nextjs/src/server/actions/updateUserProfileAction.ts +++ b/packages/nextjs/src/server/actions/updateUserProfileAction.ts @@ -18,25 +18,28 @@ 'use server'; -import {User, UserProfile} from '@asgardeo/node'; +import {UpdateMeProfileConfig, User, UserProfile} from '@asgardeo/node'; import AsgardeoNextClient from '../../AsgardeoNextClient'; /** * Server action to get the current user. * Returns the user profile if signed in. */ -const updateUserProfileAction = async (payload: any, sessionId: string) => { +const updateUserProfileAction = async ( + payload: UpdateMeProfileConfig, + sessionId?: string, +): Promise<{success: boolean; data: {user: User}; error: string}> => { try { const client = AsgardeoNextClient.getInstance(); const user: User = await client.updateUserProfile(payload, sessionId); - return {success: true, data: {user}, error: null}; + return {success: true, data: {user}, error: ""}; } catch (error) { return { success: false, data: { user: {}, }, - error: 'Failed to get user profile', + error: `Failed to get user profile: ${error instanceof Error ? error.message : String(error)}`, }; } }; diff --git a/packages/nextjs/src/utils/decorateConfigWithNextEnv.ts b/packages/nextjs/src/utils/decorateConfigWithNextEnv.ts index 7c162519..2c25fa43 100644 --- a/packages/nextjs/src/utils/decorateConfigWithNextEnv.ts +++ b/packages/nextjs/src/utils/decorateConfigWithNextEnv.ts @@ -19,10 +19,12 @@ import {AsgardeoNextConfig} from '../models/config'; const decorateConfigWithNextEnv = (config: AsgardeoNextConfig): AsgardeoNextConfig => { - const {baseUrl, clientId, clientSecret, signInUrl, signUpUrl, afterSignInUrl, afterSignOutUrl, ...rest} = config; + const {organizationHandle, applicationId, baseUrl, clientId, clientSecret, signInUrl, signUpUrl, afterSignInUrl, afterSignOutUrl, ...rest} = config; return { ...rest, + organizationHandle: organizationHandle || (process.env['NEXT_PUBLIC_ASGARDEO_ORGANIZATION_HANDLE'] as string), + applicationId: applicationId || (process.env['NEXT_PUBLIC_ASGARDEO_APPLICATION_ID'] as string), baseUrl: baseUrl || (process.env['NEXT_PUBLIC_ASGARDEO_BASE_URL'] as string), clientId: clientId || (process.env['NEXT_PUBLIC_ASGARDEO_CLIENT_ID'] as string), clientSecret: clientSecret || (process.env['ASGARDEO_CLIENT_SECRET'] as string), diff --git a/packages/react/src/AsgardeoReactClient.ts b/packages/react/src/AsgardeoReactClient.ts index 19af191c..86f1b2e9 100644 --- a/packages/react/src/AsgardeoReactClient.ts +++ b/packages/react/src/AsgardeoReactClient.ts @@ -35,6 +35,7 @@ import { Organization, IdToken, EmbeddedFlowExecuteRequestConfig, + deriveOrganizationHandleFromBaseUrl, } from '@asgardeo/browser'; import AuthAPI from './__temp__/api'; import getMeOrganizations from './api/getMeOrganizations'; @@ -59,13 +60,27 @@ class AsgardeoReactClient e } override initialize(config: AsgardeoReactConfig): Promise { - return this.asgardeo.init(config as any); + let resolvedOrganizationHandle: string | undefined = config?.organizationHandle; + + if (!resolvedOrganizationHandle) { + resolvedOrganizationHandle = deriveOrganizationHandleFromBaseUrl(config?.baseUrl); + } + + return this.asgardeo.init({...config, organizationHandle: resolvedOrganizationHandle} as any); + } + + override async updateUserProfile(payload: any, userId?: string): Promise { + throw new Error('Not implemented'); } - override async getUser(): Promise { + override async getUser(options?: any): Promise { try { - const configData = await this.asgardeo.getConfigData(); - const baseUrl = configData?.baseUrl; + let baseUrl = options?.baseUrl; + + if (!baseUrl) { + const configData = await this.asgardeo.getConfigData(); + baseUrl = configData?.baseUrl; + } const profile = await getScim2Me({baseUrl}); const schemas = await getSchemas({baseUrl}); @@ -76,10 +91,18 @@ class AsgardeoReactClient e } } - async getUserProfile(): Promise { + async getDecodedIdToken(sessionId?: string): Promise { + return this.asgardeo.getDecodedIdToken(sessionId); + } + + async getUserProfile(options?: any): Promise { try { - const configData = await this.asgardeo.getConfigData(); - const baseUrl = configData?.baseUrl; + let baseUrl = options?.baseUrl; + + if (!baseUrl) { + const configData = await this.asgardeo.getConfigData(); + baseUrl = configData?.baseUrl; + } const profile = await getScim2Me({baseUrl}); const schemas = await getSchemas({baseUrl}); @@ -102,10 +125,14 @@ class AsgardeoReactClient e } } - override async getOrganizations(): Promise { + override async getOrganizations(options?: any): Promise { try { - const configData = await this.asgardeo.getConfigData(); - const baseUrl = configData?.baseUrl; + let baseUrl = options?.baseUrl; + + if (!baseUrl) { + const configData = await this.asgardeo.getConfigData(); + baseUrl = configData?.baseUrl; + } const organizations = await getMeOrganizations({baseUrl}); @@ -211,7 +238,7 @@ class AsgardeoReactClient e }); } - return this.asgardeo.signIn(arg1 as any) as unknown as Promise; + return (await this.asgardeo.signIn(arg1 as any)) as unknown as Promise; } override signOut(options?: SignOutOptions, afterSignOut?: (afterSignOutUrl: string) => void): Promise; diff --git a/packages/react/src/__temp__/api.ts b/packages/react/src/__temp__/api.ts index 8db768dc..83e26768 100644 --- a/packages/react/src/__temp__/api.ts +++ b/packages/react/src/__temp__/api.ts @@ -296,8 +296,8 @@ class AuthAPI { * @return {Promise} - A Promise that resolves with * the decoded payload of the id token. */ - public async getDecodedIdToken(): Promise { - return this._client.getDecodedIdToken(); + public async getDecodedIdToken(sessionId?: string): Promise { + return this._client.getDecodedIdToken(sessionId); } /** diff --git a/packages/react/src/__temp__/models.ts b/packages/react/src/__temp__/models.ts index fc500cf4..537f7dc0 100644 --- a/packages/react/src/__temp__/models.ts +++ b/packages/react/src/__temp__/models.ts @@ -96,7 +96,7 @@ export interface AuthContextInterface { getOpenIDProviderEndpoints(): Promise; getHttpClient(): Promise; getDecodedIDPIDToken(): Promise; - getDecodedIdToken(): Promise; + getDecodedIdToken(sessionId?: string): Promise; getIdToken(): Promise; getAccessToken(): Promise; refreshAccessToken(): Promise; diff --git a/packages/react/src/components/presentation/OrganizationProfile/OrganizationProfile.tsx b/packages/react/src/components/presentation/OrganizationProfile/OrganizationProfile.tsx index e7f6eb0b..9c75d066 100644 --- a/packages/react/src/components/presentation/OrganizationProfile/OrganizationProfile.tsx +++ b/packages/react/src/components/presentation/OrganizationProfile/OrganizationProfile.tsx @@ -23,7 +23,6 @@ import getOrganization from '../../../api/getOrganization'; import updateOrganization, {createPatchOperations} from '../../../api/updateOrganization'; import useAsgardeo from '../../../contexts/Asgardeo/useAsgardeo'; import useTranslation from '../../../hooks/useTranslation'; -import {Dialog, DialogContent, DialogHeading} from '../../primitives/Popover/Popover'; /** * Props for the OrganizationProfile component. @@ -201,33 +200,7 @@ const OrganizationProfile: FC = ({ } }; - if (loading) { - return mode === 'popup' ? ( - - - {popupTitle || t('organization.profile.title')} -
{loadingFallback}
-
-
- ) : ( - loadingFallback - ); - } - - if (error) { - return mode === 'popup' ? ( - - - {popupTitle || t('organization.profile.title')} -
{errorFallback}
-
-
- ) : ( - errorFallback - ); - } - - const profileContent = ( + return ( = ({ {...rest} /> ); - - return profileContent; }; export default OrganizationProfile; diff --git a/packages/react/src/components/presentation/SignIn/SignIn.tsx b/packages/react/src/components/presentation/SignIn/SignIn.tsx index 8a170bae..da9c5b80 100644 --- a/packages/react/src/components/presentation/SignIn/SignIn.tsx +++ b/packages/react/src/components/presentation/SignIn/SignIn.tsx @@ -57,7 +57,7 @@ export type SignInProps = Pick = ({className, size = 'medium', variant = 'outlined'}: SignInProps) => { +const SignIn: FC = ({className, size = 'medium', ...rest}: SignInProps) => { const {signIn, afterSignInUrl, isInitialized, isLoading} = useAsgardeo(); /** @@ -103,7 +103,7 @@ const SignIn: FC = ({className, size = 'medium', variant = 'outline onSuccess={handleSuccess} className={className} size={size} - variant={variant} + {...rest} /> ); }; diff --git a/packages/react/src/components/presentation/SignUp/SignUp.tsx b/packages/react/src/components/presentation/SignUp/SignUp.tsx index 18766232..69642e41 100644 --- a/packages/react/src/components/presentation/SignUp/SignUp.tsx +++ b/packages/react/src/components/presentation/SignUp/SignUp.tsx @@ -64,11 +64,11 @@ export type SignUpProps = BaseSignUpProps; const SignUp: FC = ({ className, size = 'medium', - variant = 'outlined', afterSignUpUrl, onError, onComplete, shouldRedirectAfterSignUp = true, + ...rest }) => { const {signUp, isInitialized} = useAsgardeo(); @@ -121,8 +121,8 @@ const SignUp: FC = ({ onComplete={handleComplete} className={className} size={size} - variant={variant} isInitialized={isInitialized} + {...rest} /> ); }; diff --git a/packages/react/src/components/presentation/UserProfile/BaseUserProfile.tsx b/packages/react/src/components/presentation/UserProfile/BaseUserProfile.tsx index 6158b3ff..50e59dd6 100644 --- a/packages/react/src/components/presentation/UserProfile/BaseUserProfile.tsx +++ b/packages/react/src/components/presentation/UserProfile/BaseUserProfile.tsx @@ -27,6 +27,7 @@ import Checkbox from '../../primitives/Checkbox/Checkbox'; import DatePicker from '../../primitives/DatePicker/DatePicker'; import {Dialog, DialogContent, DialogHeading} from '../../primitives/Popover/Popover'; import TextField from '../../primitives/TextField/TextField'; +import MultiInput from '../../primitives/MultiInput/MultiInput'; import Card from '../../primitives/Card/Card'; interface ExtendedFlatSchema { @@ -76,6 +77,31 @@ export interface BaseUserProfileProps { title?: string; } +// Fields to skip based on schema.name +const fieldsToSkip: string[] = [ + 'roles.default', + 'active', + 'groups', + 'profileUrl', + 'accountLocked', + 'accountDisabled', + 'oneTimePassword', + 'userSourceId', + 'idpType', + 'localCredentialExists', + 'active', + 'ResourceType', + 'ExternalID', + 'MetaData', + 'verifiedMobileNumbers', + 'verifiedEmailAddresses', + 'phoneNumbers.mobile', + 'emailAddresses', +]; + +// Fields that should be readonly +const readonlyFields: string[] = ['username', 'userName', 'user_name']; + const BaseUserProfile: FC = ({ fallback = null, className = '', @@ -188,13 +214,18 @@ const BaseUserProfile: FC = ({ if (!onUpdate || !schema.name) return; const fieldName: string = schema.name; - const fieldValue: any = + let fieldValue: any = editedUser && fieldName && editedUser[fieldName] !== undefined ? editedUser[fieldName] : flattenedProfile && flattenedProfile[fieldName] !== undefined ? flattenedProfile[fieldName] : ''; + // Filter out empty values for arrays when saving + if (Array.isArray(fieldValue)) { + fieldValue = fieldValue.filter(v => v !== undefined && v !== null && v !== ''); + } + let payload: Record = {}; // SCIM Patch Operation Logic: @@ -293,7 +324,7 @@ const BaseUserProfile: FC = ({ onStartEdit?: () => void, ): ReactElement | null => { if (!schema) return null; - const {value, displayName, description, name, type, required, mutability, subAttributes} = schema; + const {value, displayName, description, name, type, required, mutability, subAttributes, multiValued} = schema; const label = displayName || description || name || ''; // If complex or subAttributes, fallback to original renderSchemaValue @@ -317,13 +348,68 @@ const BaseUserProfile: FC = ({ ); } - if (Array.isArray(value)) { - const hasValues = value.length > 0; - const isEditable = editable && mutability !== 'READ_ONLY'; + // Handle multi-valued fields (either array values or multiValued property) + if (Array.isArray(value) || multiValued) { + const hasValues = Array.isArray(value) ? value.length > 0 : value !== undefined && value !== null && value !== ''; + const isEditable = editable && mutability !== 'READ_ONLY' && !readonlyFields.includes(name || ''); + + // If editing, show multi-valued input + if (isEditing && onEditValue && isEditable) { + // Use editedUser value if available, then flattenedProfile, then schema value + const currentValue = + editedUser && name && editedUser[name] !== undefined + ? editedUser[name] + : flattenedProfile && name && flattenedProfile[name] !== undefined + ? flattenedProfile[name] + : value; + + let fieldValues: string[]; + if (Array.isArray(currentValue)) { + fieldValues = currentValue.map(String); + } else if (currentValue !== undefined && currentValue !== null && currentValue !== '') { + fieldValues = [String(currentValue)]; + } else { + fieldValues = []; + } + + return ( + <> + {label} +
+ { + // Don't filter out empty values during editing - only when saving + // This allows users to type and keeps empty fields for adding new values + if (multiValued || Array.isArray(currentValue)) { + onEditValue(newValues); + } else { + // Single value field, just take the first value (including empty for typing) + onEditValue(newValues[0] || ''); + } + }} + placeholder={getFieldPlaceholder(schema)} + fieldType={type as 'STRING' | 'DATE_TIME' | 'BOOLEAN'} + type={type === 'DATE_TIME' ? 'date' : type === 'STRING' ? 'text' : 'text'} + required={required} + style={{ + marginBottom: 0, + }} + /> +
+ + ); + } + + // View mode for multi-valued fields let displayValue: string; if (hasValues) { - displayValue = value.map(item => (typeof item === 'object' ? JSON.stringify(item) : String(item))).join(', '); + if (Array.isArray(value)) { + displayValue = value.map(item => (typeof item === 'object' ? JSON.stringify(item) : String(item))).join(', '); + } else { + displayValue = String(value); + } } else if (isEditable) { displayValue = getFieldPlaceholder(schema); } else { @@ -363,7 +449,7 @@ const BaseUserProfile: FC = ({ return ; } // If editing, show field instead of value - if (isEditing && onEditValue && mutability !== 'READ_ONLY') { + if (isEditing && onEditValue && mutability !== 'READ_ONLY' && !readonlyFields.includes(name || '')) { // Use editedUser value if available, then flattenedProfile, then schema value const fieldValue = editedUser && name && editedUser[name] !== undefined @@ -425,7 +511,7 @@ const BaseUserProfile: FC = ({ } // Default: view mode const hasValue = value !== undefined && value !== null && value !== ''; - const isEditable = editable && mutability !== 'READ_ONLY'; + const isEditable = editable && mutability !== 'READ_ONLY' && !readonlyFields.includes(name || ''); let displayValue: string; if (hasValue) { @@ -472,6 +558,7 @@ const BaseUserProfile: FC = ({ // Skip fields with undefined or empty values unless editing or editable const hasValue = schema.value !== undefined && schema.value !== '' && schema.value !== null; const isFieldEditing = editingFields[schema.name]; + const isReadonlyField = readonlyFields.includes(schema.name); // Show field if: has value, currently editing, or is editable and READ_WRITE const shouldShow = hasValue || isFieldEditing || (editable && schema.mutability === 'READ_WRITE'); @@ -501,7 +588,7 @@ const BaseUserProfile: FC = ({ () => toggleFieldEdit(schema.name!), )} - {editable && schema.mutability !== 'READ_ONLY' && ( + {editable && schema.mutability !== 'READ_ONLY' && !isReadonlyField && (
= ({ const avatarAttributes = ['picture']; const excludedProps = avatarAttributes.map(attr => mergedMappings[attr] || attr); - // Fields to skip based on schema.name - const fieldsToSkip: string[] = ['verifiedMobileNumbers', 'verifiedEmailAddresses']; - const profileContent = (
@@ -612,6 +696,7 @@ const BaseUserProfile: FC = ({ ...schema, value, }; + return
{renderUserInfo(schemaWithValue)}
; })}
@@ -692,7 +777,7 @@ const useStyles = () => { gap: `${theme.spacing.unit}px`, overflow: 'hidden', minHeight: '32px', - '& input, & .MuiInputBase-root': { + '& input': { height: '32px', margin: 0, }, diff --git a/packages/react/src/components/presentation/UserProfile/UserProfile.tsx b/packages/react/src/components/presentation/UserProfile/UserProfile.tsx index 25d13895..56e3d721 100644 --- a/packages/react/src/components/presentation/UserProfile/UserProfile.tsx +++ b/packages/react/src/components/presentation/UserProfile/UserProfile.tsx @@ -21,6 +21,7 @@ import BaseUserProfile, {BaseUserProfileProps} from './BaseUserProfile'; import updateMeProfile from '../../../api/updateMeProfile'; import useAsgardeo from '../../../contexts/Asgardeo/useAsgardeo'; import useUser from '../../../contexts/User/useUser'; +import {User} from '@asgardeo/browser'; /** * Props for the UserProfile component. @@ -53,11 +54,12 @@ export type UserProfileProps = Omit = ({...rest}: UserProfileProps): ReactElement => { const {baseUrl} = useAsgardeo(); - const {profile, flattenedProfile, schemas, revalidateProfile} = useUser(); + const {profile, flattenedProfile, schemas, onUpdateProfile} = useUser(); const handleProfileUpdate = async (payload: any): Promise => { - await updateMeProfile({baseUrl, payload}); - await revalidateProfile(); + const response: User = await updateMeProfile({baseUrl, payload}); + + onUpdateProfile(response); }; return ( diff --git a/packages/react/src/components/primitives/Card/Card.tsx b/packages/react/src/components/primitives/Card/Card.tsx index 5500ce81..12af7e2a 100644 --- a/packages/react/src/components/primitives/Card/Card.tsx +++ b/packages/react/src/components/primitives/Card/Card.tsx @@ -115,7 +115,6 @@ const useCardStyles = (variant: CardVariant, clickable: boolean) => { outlined: { ...baseStyles, border: `1px solid ${theme.colors.border}`, - backgroundColor: 'transparent', }, elevated: { ...baseStyles, diff --git a/packages/react/src/components/primitives/MultiInput/MultiInput.tsx b/packages/react/src/components/primitives/MultiInput/MultiInput.tsx new file mode 100644 index 00000000..3808e277 --- /dev/null +++ b/packages/react/src/components/primitives/MultiInput/MultiInput.tsx @@ -0,0 +1,335 @@ +/** + * 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 {CSSProperties, FC, ReactNode, useCallback, useState, useMemo} from 'react'; +import useTheme from '../../../contexts/Theme/useTheme'; +import clsx from 'clsx'; +import FormControl from '../FormControl/FormControl'; +import InputLabel from '../InputLabel/InputLabel'; +import TextField from '../TextField/TextField'; +import DatePicker from '../DatePicker/DatePicker'; +import Checkbox from '../Checkbox/Checkbox'; +import Button from '../Button/Button'; +import {withVendorCSSClassPrefix} from '@asgardeo/browser'; + +export interface MultiInputProps { + /** + * Label text to display above the inputs + */ + label?: string; + /** + * Error message to display below the inputs + */ + error?: string; + /** + * Additional CSS class names + */ + className?: string; + /** + * Whether the field is required + */ + required?: boolean; + /** + * Whether the field is disabled + */ + disabled?: boolean; + /** + * Helper text to display below the inputs + */ + helperText?: string; + /** + * Placeholder text for input fields + */ + placeholder?: string; + /** + * Array of values + */ + values: string[]; + /** + * Callback when values change + */ + onChange: (values: string[]) => void; + /** + * Custom style object + */ + style?: CSSProperties; + /** + * Input type + */ + type?: 'text' | 'email' | 'tel' | 'url' | 'password' | 'date' | 'boolean'; + /** + * Field type for different input components + */ + fieldType?: 'STRING' | 'DATE_TIME' | 'BOOLEAN'; + /** + * Icon to display at the start (left) of each input + */ + startIcon?: ReactNode; + /** + * Icon to display at the end (right) of each input (in addition to add/remove buttons) + */ + endIcon?: ReactNode; + /** + * Minimum number of fields to show (default: 1) + */ + minFields?: number; + /** + * Maximum number of fields to allow (default: unlimited) + */ + maxFields?: number; +} + +const useStyles = () => { + const {theme} = useTheme(); + + return useMemo( + () => ({ + container: { + display: 'flex', + flexDirection: 'column' as const, + gap: `${theme.spacing.unit}px`, + }, + inputRow: { + display: 'flex', + alignItems: 'center', + gap: `${theme.spacing.unit}px`, + position: 'relative' as const, + }, + inputWrapper: { + flex: 1, + }, + plusIcon: { + background: 'var(--asgardeo-color-secondary-main)', + borderRadius: '50%', + outline: '4px var(--asgardeo-color-secondary-main) auto', + color: 'var(--asgardeo-color-secondary-contrastText)', + }, + listContainer: { + display: 'flex', + flexDirection: 'column' as const, + gap: `${theme.spacing.unit / 2}px`, + }, + listItem: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + padding: `${theme.spacing.unit}px ${theme.spacing.unit * 1.5}px`, + backgroundColor: theme.colors.background.surface, + border: `1px solid ${theme.colors.border}`, + borderRadius: theme.borderRadius.medium, + fontSize: '1rem', + color: theme.colors.text.primary, + }, + removeButton: { + padding: `${theme.spacing.unit / 2}px`, + minWidth: 'auto', + color: theme.colors.error.main, + }, + }), + [theme], + ); +}; + +const MultiInput: FC = ({ + label, + error, + required, + className, + disabled, + helperText, + placeholder = 'Enter value', + values = [], + onChange, + style = {}, + type = 'text', + fieldType = 'STRING', + startIcon, + endIcon, + minFields = 1, + maxFields, +}) => { + const styles = useStyles(); + + const PlusIcon = ({style}) => ( + + + + ); + + const BinIcon = () => ( + + + + ); + + const handleAddValue = useCallback( + (newValue: string) => { + if (newValue.trim() !== '' && (!maxFields || values.length < maxFields)) { + onChange([...values, newValue.trim()]); + } + }, + [values, onChange, maxFields], + ); + + const handleRemoveValue = useCallback( + (index: number) => { + if (values.length > minFields) { + const updatedValues = values.filter((_, i) => i !== index); + onChange(updatedValues); + } + }, + [values, onChange, minFields], + ); + + const renderInputField = useCallback( + ( + value: string, + onValueChange: (value: string) => void, + attachedEndIcon?: ReactNode, + onEndIconClick?: () => void, + ) => { + const handleInputChange = (e: any) => { + const newValue = e.target ? e.target.value : e; + onValueChange(newValue); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && onEndIconClick) { + e.preventDefault(); + onEndIconClick(); + } + }; + + const finalEndIcon = attachedEndIcon || endIcon; + + const commonProps = { + value, + onChange: handleInputChange, + onKeyDown: handleKeyDown, + placeholder, + disabled, + startIcon, + endIcon: finalEndIcon, + onEndIconClick, + error, + }; + + switch (fieldType) { + case 'DATE_TIME': + return ; + case 'BOOLEAN': + return ( + onValueChange(e.target.checked ? 'true' : 'false')} + /> + ); + default: + return ; + } + }, + [placeholder, disabled, startIcon, endIcon, error, fieldType, type], + ); + + const canAddMore = !maxFields || values.length < maxFields; + const canRemove = values.length > minFields; + + // State for the current input value + const [currentInputValue, setCurrentInputValue] = useState(''); + + const handleInputSubmit = useCallback(() => { + if (currentInputValue.trim() !== '') { + handleAddValue(currentInputValue); + setCurrentInputValue(''); + } + }, [currentInputValue, handleAddValue]); + + return ( + + {label && ( + + {label} + + )} +
+ {/* Input field at the top */} +
+
+ {renderInputField( + currentInputValue, + setCurrentInputValue, + canAddMore ? : undefined, + canAddMore ? handleInputSubmit : undefined, + )} +
+
+ + {/* List of added items */} + {values.length > 0 && ( +
+ {values.map((value, index) => ( +
+ {value} + {canRemove && ( + + )} +
+ ))} +
+ )} +
+
+ ); +}; + +export default MultiInput; diff --git a/packages/react/src/contexts/Asgardeo/AsgardeoContext.ts b/packages/react/src/contexts/Asgardeo/AsgardeoContext.ts index 2c4fb194..e4eb9165 100644 --- a/packages/react/src/contexts/Asgardeo/AsgardeoContext.ts +++ b/packages/react/src/contexts/Asgardeo/AsgardeoContext.ts @@ -23,6 +23,8 @@ import {Organization} from '@asgardeo/browser'; * Props interface of {@link AsgardeoContext} */ export type AsgardeoContextProps = { + organizationHandle: string | undefined; + applicationId: string | undefined; signInUrl: string | undefined; signUpUrl: string | undefined; afterSignInUrl: string | undefined; @@ -62,6 +64,8 @@ export type AsgardeoContextProps = { * Context object for managing the Authentication flow builder core context. */ const AsgardeoContext: Context = createContext({ + organizationHandle: undefined, + applicationId: undefined, signInUrl: undefined, signUpUrl: undefined, afterSignInUrl: undefined, diff --git a/packages/react/src/contexts/Asgardeo/AsgardeoProvider.tsx b/packages/react/src/contexts/Asgardeo/AsgardeoProvider.tsx index c974e0e1..73995d1b 100644 --- a/packages/react/src/contexts/Asgardeo/AsgardeoProvider.tsx +++ b/packages/react/src/contexts/Asgardeo/AsgardeoProvider.tsx @@ -20,13 +20,14 @@ import { AsgardeoRuntimeError, EmbeddedFlowExecuteRequestPayload, EmbeddedFlowExecuteResponse, + generateFlattenedUserProfile, Organization, SignInOptions, SignOutOptions, User, UserProfile, } from '@asgardeo/browser'; -import {FC, RefObject, PropsWithChildren, ReactElement, useEffect, useMemo, useRef, useState, use} from 'react'; +import {FC, RefObject, PropsWithChildren, ReactElement, useEffect, useMemo, useRef, useState} from 'react'; import AsgardeoContext from './AsgardeoContext'; import AsgardeoReactClient from '../../AsgardeoReactClient'; import useBrowserUrl from '../../hooks/useBrowserUrl'; @@ -45,13 +46,15 @@ export type AsgardeoProviderProps = AsgardeoReactConfig; const AsgardeoProvider: FC> = ({ afterSignInUrl = window.location.origin, afterSignOutUrl = window.location.origin, - baseUrl, + baseUrl: _baseUrl, clientId, children, scopes, preferences, signInUrl, signUpUrl, + organizationHandle, + applicationId, ...rest }: PropsWithChildren): ReactElement => { const reRenderCheckRef: RefObject = useRef(false); @@ -64,19 +67,28 @@ const AsgardeoProvider: FC> = ({ const [isInitializedSync, setIsInitializedSync] = useState(false); const [userProfile, setUserProfile] = useState(null); + const [baseUrl, setBaseUrl] = useState(_baseUrl); + const [config, setConfig] = useState({ + applicationId, + organizationHandle, + afterSignInUrl, + afterSignOutUrl, + baseUrl, + clientId, + scopes, + signUpUrl, + signInUrl, + ...rest, + }); + + useEffect(() => { + setBaseUrl(_baseUrl); + }, [_baseUrl]); useEffect(() => { (async (): Promise => { - await asgardeo.initialize({ - afterSignInUrl, - afterSignOutUrl, - baseUrl, - clientId, - scopes, - signUpUrl, - signInUrl, - ...rest, - }); + await asgardeo.initialize(config); + setConfig(await asgardeo.getConfiguration()); })(); }, []); @@ -99,9 +111,7 @@ const AsgardeoProvider: FC> = ({ (async (): Promise => { // User is already authenticated. Skip... if (await asgardeo.isSignedIn()) { - setUser(await asgardeo.getUser()); - setUserProfile(await asgardeo.getUserProfile()); - setCurrentOrganization(await asgardeo.getCurrentOrganization()); + await updateSession(); return; } @@ -173,14 +183,27 @@ const AsgardeoProvider: FC> = ({ })(); }, [asgardeo]); + const updateSession = async (): Promise => { + let _baseUrl: string = baseUrl; + + // If there's a `user_org` claim in the ID token, + // Treat this login as a organization login. + if ((await asgardeo.getDecodedIdToken())?.['user_org']) { + _baseUrl = `${(await asgardeo.getConfiguration()).baseUrl}/o`; + setBaseUrl(_baseUrl); + } + + setUser(await asgardeo.getUser({baseUrl: _baseUrl})); + setUserProfile(await asgardeo.getUserProfile({baseUrl: _baseUrl})); + setCurrentOrganization(await asgardeo.getCurrentOrganization()); + }; + const signIn = async (...args: any): Promise => { try { const response: User = await asgardeo.signIn(...args); if (await asgardeo.isSignedIn()) { - setUser(await asgardeo.getUser()); - setUserProfile(await asgardeo.getUserProfile()); - setCurrentOrganization(await asgardeo.getCurrentOrganization()); + await updateSession(); } return response; @@ -210,9 +233,7 @@ const AsgardeoProvider: FC> = ({ await asgardeo.switchOrganization(organization); if (await asgardeo.isSignedIn()) { - setUser(await asgardeo.getUser()); - setUserProfile(await asgardeo.getUserProfile()); - setCurrentOrganization(await asgardeo.getCurrentOrganization()); + await updateSession(); } } catch (error) { throw new AsgardeoRuntimeError( @@ -231,9 +252,20 @@ const AsgardeoProvider: FC> = ({ return preferences.theme.mode === 'dark'; }, [preferences?.theme?.mode]); + const handleProfileUpdate = (payload: User): void => { + setUser(payload); + setUserProfile(prev => ({ + ...prev, + profile: payload, + flattenedProfile: generateFlattenedUserProfile(payload, prev?.schemas), + })); + }; + return ( > = ({ }} > - + - setUserProfile(await asgardeo.getUserProfile())} - > + asgardeo.getOrganizations()} currentOrganization={currentOrganization} diff --git a/packages/react/src/contexts/Organization/OrganizationProvider.tsx b/packages/react/src/contexts/Organization/OrganizationProvider.tsx index 3f3dd320..5eccc1b0 100644 --- a/packages/react/src/contexts/Organization/OrganizationProvider.tsx +++ b/packages/react/src/contexts/Organization/OrganizationProvider.tsx @@ -119,6 +119,7 @@ const OrganizationProvider: FC> = ( limit?: number; recursive?: boolean; }>({}); + const [isFetching, setIsFetching] = useState(false); /** * Fetches organizations from the API @@ -211,10 +212,12 @@ const OrganizationProvider: FC> = ( async (config = {}): Promise => { const {filter = '', limit = 10, recursive = false, reset = false} = config; - if (!isSignedIn || !baseUrl) { + if (!isSignedIn || !baseUrl || isFetching) { return; } + setIsFetching(true); + try { if (reset) { setIsLoading(true); @@ -250,6 +253,22 @@ const OrganizationProvider: FC> = ( limit, recursive, ...(reset ? {} : {startIndex: (currentPage - 1) * limit}), + fetcher: async (url: string, config: RequestInit): Promise => { + try { + const response = await fetch(url, config); + if (response.status === 401 || response.status === 403) { + const error = new Error('Insufficient permissions'); + (error as any).status = response.status; + throw error; + } + return response; + } catch (error: any) { + if (error.status === 401 || error.status === 403) { + error.noRetry = true; + } + throw error; + } + }, }); // Combine organization data with switch access information @@ -273,14 +292,30 @@ const OrganizationProvider: FC> = ( setHasMore(response.hasMore || false); setTotalCount(response.totalCount || organizationsWithAccess.length); } catch (fetchError: any) { - const errorMessage: string = fetchError.message || 'Failed to fetch paginated organizations'; + // If authorization/scope error, prevent retry loops. + const isAuthError = fetchError.status === 403 || fetchError.status === 401 || fetchError.noRetry === true; + + const errorMessage: string = isAuthError + ? 'Insufficient permissions to fetch organizations' + : fetchError.message || 'Failed to fetch paginated organizations'; + setError(errorMessage); + + if (isAuthError) { + setHasMore(false); + setIsLoadingMore(false); + setIsLoading(false); + + return; + } + if (onError) { onError(errorMessage); } } finally { setIsLoading(false); setIsLoadingMore(false); + setIsFetching(false); } }, [baseUrl, isSignedIn, onError, switchableOrgIds, currentPage], diff --git a/packages/react/src/contexts/Theme/ThemeProvider.tsx b/packages/react/src/contexts/Theme/ThemeProvider.tsx index 3d0ed202..7677ff45 100644 --- a/packages/react/src/contexts/Theme/ThemeProvider.tsx +++ b/packages/react/src/contexts/Theme/ThemeProvider.tsx @@ -16,13 +16,34 @@ * under the License. */ -import {FC, PropsWithChildren, ReactElement, useEffect, useMemo, useState} from 'react'; -import {createTheme, Theme, ThemeConfig, RecursivePartial} from '@asgardeo/browser'; +import {FC, PropsWithChildren, ReactElement, useEffect, useMemo, useState, useCallback} from 'react'; +import { + createTheme, + Theme, + ThemeConfig, + ThemeMode, + RecursivePartial, + detectThemeMode, + createClassObserver, + createMediaQueryListener, + BrowserThemeDetection, +} from '@asgardeo/browser'; import ThemeContext from './ThemeContext'; export interface ThemeProviderProps { theme?: RecursivePartial; - defaultColorScheme?: 'light' | 'dark'; + /** + * The theme mode to use for automatic detection + * - 'light': Always use light theme + * - 'dark': Always use dark theme + * - 'system': Use system preference (prefers-color-scheme media query) + * - 'class': Detect theme based on CSS classes on HTML element + */ + mode?: ThemeMode; + /** + * Configuration for theme detection when using 'class' or 'system' mode + */ + detection?: BrowserThemeDetection; } const applyThemeToDOM = (theme: Theme) => { @@ -34,15 +55,55 @@ const applyThemeToDOM = (theme: Theme) => { const ThemeProvider: FC> = ({ children, theme: themeConfig, - defaultColorScheme = 'light', + mode = 'system', + detection = {}, }: PropsWithChildren): ReactElement => { - const [colorScheme, setColorScheme] = useState<'light' | 'dark'>(defaultColorScheme); + const [colorScheme, setColorScheme] = useState<'light' | 'dark'>(() => { + // Initialize with detected theme mode or fallback to defaultMode + if (mode === 'light' || mode === 'dark') { + return mode; + } + return detectThemeMode(mode, detection); + }); const theme = useMemo(() => createTheme(themeConfig, colorScheme === 'dark'), [themeConfig, colorScheme]); - const toggleTheme = () => { + const handleThemeChange = useCallback((isDark: boolean) => { + setColorScheme(isDark ? 'dark' : 'light'); + }, []); + + const toggleTheme = useCallback(() => { setColorScheme(prev => (prev === 'light' ? 'dark' : 'light')); - }; + }, []); + + useEffect(() => { + let observer: MutationObserver | null = null; + let mediaQuery: MediaQueryList | null = null; + + if (mode === 'class') { + const targetElement = detection.targetElement || document.documentElement; + if (targetElement) { + observer = createClassObserver(targetElement, handleThemeChange, detection); + } + } else if (mode === 'system') { + mediaQuery = createMediaQueryListener(handleThemeChange); + } + + return () => { + if (observer) { + observer.disconnect(); + } + if (mediaQuery) { + // Clean up media query listener + if (mediaQuery.removeEventListener) { + mediaQuery.removeEventListener('change', handleThemeChange as any); + } else { + // Fallback for older browsers + mediaQuery.removeListener(handleThemeChange as any); + } + } + }; + }, [mode, detection, handleThemeChange]); useEffect(() => { applyThemeToDOM(theme); diff --git a/packages/react/src/contexts/User/UserContext.ts b/packages/react/src/contexts/User/UserContext.ts index d74928bb..375f810c 100644 --- a/packages/react/src/contexts/User/UserContext.ts +++ b/packages/react/src/contexts/User/UserContext.ts @@ -16,7 +16,7 @@ * under the License. */ -import {User, Schema, FlattenedSchema} from '@asgardeo/browser'; +import {User, Schema, UpdateMeProfileConfig, OrganizationDetails} from '@asgardeo/browser'; import {Context, createContext} from 'react'; /** @@ -27,6 +27,11 @@ export type UserContextProps = { profile: User | null; revalidateProfile: () => Promise; schemas: Schema[] | null; + updateProfile: ( + requestConfig: UpdateMeProfileConfig, + sessionId?: string, + ) => Promise<{success: boolean; data: {user: User}; error: string}>; + onUpdateProfile: (payload: User) => void; }; /** @@ -37,6 +42,8 @@ const UserContext: Context = createContext null, + updateProfile: () => null, + onUpdateProfile: () => null, }); UserContext.displayName = 'UserContext'; diff --git a/packages/react/src/contexts/User/UserProvider.tsx b/packages/react/src/contexts/User/UserProvider.tsx index c2c486fa..e2871ae1 100644 --- a/packages/react/src/contexts/User/UserProvider.tsx +++ b/packages/react/src/contexts/User/UserProvider.tsx @@ -16,7 +16,7 @@ * under the License. */ -import {UserProfile} from '@asgardeo/browser'; +import {UpdateMeProfileConfig, User, UserProfile} from '@asgardeo/browser'; import {FC, PropsWithChildren, ReactElement, useEffect, useState, useCallback, useMemo} from 'react'; import UserContext from './UserContext'; @@ -26,6 +26,11 @@ import UserContext from './UserContext'; export interface UserProviderProps { profile: UserProfile; revalidateProfile?: () => Promise; + updateProfile?: ( + requestConfig: UpdateMeProfileConfig, + sessionId?: string, + ) => Promise<{success: boolean; data: {user: User}; error: string}>; + onUpdateProfile?: (payload: User) => void; } /** @@ -60,6 +65,8 @@ const UserProvider: FC> = ({ children, profile, revalidateProfile, + onUpdateProfile, + updateProfile, }: PropsWithChildren): ReactElement => { const contextValue = useMemo( () => ({ @@ -67,8 +74,10 @@ const UserProvider: FC> = ({ profile: profile?.profile, flattenedProfile: profile?.flattenedProfile, revalidateProfile, + updateProfile, + onUpdateProfile, }), - [profile], + [profile, onUpdateProfile, revalidateProfile, updateProfile], ); return {children}; diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index d255ba4b..0945cf56 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -197,6 +197,9 @@ export * from './components/primitives/OtpField/OtpField'; export {default as TextField} from './components/primitives/TextField/TextField'; export * from './components/primitives/TextField/TextField'; +export {default as MultiInput} from './components/primitives/MultiInput/MultiInput'; +export * from './components/primitives/MultiInput/MultiInput'; + export {default as PasswordField} from './components/primitives/PasswordField/PasswordField'; export * from './components/primitives/PasswordField/PasswordField'; @@ -239,6 +242,8 @@ export {default as LogOut} from './components/primitives/Icons/LogOut'; export {createField, FieldFactory, validateFieldValue} from './components/factories/FieldFactory'; export * from './components/factories/FieldFactory'; +export {default as BuildingAlt} from './components/primitives/Icons/BuildingAlt'; + export type {FlowStep, FlowMessage, FlowContextValue} from './contexts/Flow/FlowContext'; export type {FlowProviderProps} from './contexts/Flow/FlowProvider'; diff --git a/samples/teamspace-nextjs/app/profile/page.tsx b/samples/teamspace-nextjs/app/profile/page.tsx index 01492331..fd018d65 100644 --- a/samples/teamspace-nextjs/app/profile/page.tsx +++ b/samples/teamspace-nextjs/app/profile/page.tsx @@ -49,8 +49,6 @@ export default function ProfilePage() { } const handleSave = () => { - // In a real app, you'd save to your backend - console.log('Saving profile:', formData); setIsEditing(false); }; diff --git a/samples/teamspace-nextjs/components/Header/AuthenticatedActions.tsx b/samples/teamspace-nextjs/components/Header/AuthenticatedActions.tsx index 48fe0595..0d482151 100644 --- a/samples/teamspace-nextjs/components/Header/AuthenticatedActions.tsx +++ b/samples/teamspace-nextjs/components/Header/AuthenticatedActions.tsx @@ -1,6 +1,4 @@ -import OrganizationSwitcher from './OrganizationSwitcher'; -// import UserDropdown from './UserDropdown'; -import {SignOutButton, UserDropdown} from '@asgardeo/nextjs'; +import {SignOutButton, UserDropdown, OrganizationSwitcher} from '@asgardeo/nextjs'; interface AuthenticatedActionsProps { className?: string; diff --git a/samples/teamspace-react/public/teamspace-logo.png b/samples/teamspace-react/public/teamspace-logo.png new file mode 100644 index 00000000..f2e0009c Binary files /dev/null and b/samples/teamspace-react/public/teamspace-logo.png differ diff --git a/samples/teamspace-react/src/main.tsx b/samples/teamspace-react/src/main.tsx index eb23c4eb..843bf58f 100644 --- a/samples/teamspace-react/src/main.tsx +++ b/samples/teamspace-react/src/main.tsx @@ -20,13 +20,28 @@ createRoot(document.getElementById('root')!).render( 'user:email', 'read:user', 'internal_organization_create', + 'internal_org_organization_create', 'internal_organization_view', + 'internal_org_organization_view', 'internal_organization_update', 'internal_organization_delete', + 'internal_org_organization_delete', ]} preferences={{ theme: { - mode: 'light', + mode: 'light', // This will detect theme based on CSS classes + // You can also use other modes: + // mode: 'system', // Follows system preference (prefers-color-scheme) + // mode: 'light', // Always light + // mode: 'dark', // Always dark + + // For class-based detection, you can customize the class names: + // detection: { + // darkClass: 'dark', // CSS class for dark theme (default) + // lightClass: 'light', // CSS class for light theme (default) + // targetElement: document.documentElement, // Element to observe (default: ) + // }, + // overrides: { // colors: { // primary: {