From 6fcfb9f0d999d9c08ac012ee97c0971a381d02d6 Mon Sep 17 00:00:00 2001
From: Brion
Date: Tue, 1 Jul 2025 08:50:07 +0530
Subject: [PATCH 01/21] chore(nextjs): add `B2B` components
---
packages/nextjs/package.json | 8 -
packages/nextjs/src/AsgardeoNextClient.ts | 122 ++++++++-
.../CreateOrganization/CreateOrganization.tsx | 151 ++++++++++++
.../Organization/Organization.tsx | 83 +++++++
.../OrganizationList/OrganizationList.tsx | 233 ++++++++++++++++++
.../OrganizationProfile.tsx | 221 +++++++++++++++++
.../OrganizationSwitcher.tsx | 198 +++++++++++++++
.../contexts/Asgardeo/AsgardeoProvider.tsx | 70 +++++-
packages/nextjs/src/index.ts | 9 +
.../nextjs/src/server/AsgardeoProvider.tsx | 11 +-
.../actions/createOrganizationAction.ts | 43 ++++
.../actions/getCurrentOrganizationAction.ts | 43 ++++
.../server/actions/getOrganizationAction.ts | 43 ++++
.../server/actions/getOrganizationsAction.ts | 43 ++++
.../actions/switchOrganizationAction.ts | 43 ++++
.../OrganizationProfile.tsx | 31 +--
.../UserProfile/BaseUserProfile.tsx | 2 +-
packages/react/src/index.ts | 2 +
.../Header/AuthenticatedActions.tsx | 4 +-
19 files changed, 1305 insertions(+), 55 deletions(-)
create mode 100644 packages/nextjs/src/client/components/presentation/CreateOrganization/CreateOrganization.tsx
create mode 100644 packages/nextjs/src/client/components/presentation/Organization/Organization.tsx
create mode 100644 packages/nextjs/src/client/components/presentation/OrganizationList/OrganizationList.tsx
create mode 100644 packages/nextjs/src/client/components/presentation/OrganizationProfile/OrganizationProfile.tsx
create mode 100644 packages/nextjs/src/client/components/presentation/OrganizationSwitcher/OrganizationSwitcher.tsx
create mode 100644 packages/nextjs/src/server/actions/createOrganizationAction.ts
create mode 100644 packages/nextjs/src/server/actions/getCurrentOrganizationAction.ts
create mode 100644 packages/nextjs/src/server/actions/getOrganizationAction.ts
create mode 100644 packages/nextjs/src/server/actions/getOrganizationsAction.ts
create mode 100644 packages/nextjs/src/server/actions/switchOrganizationAction.ts
diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json
index b6e163ab7..c5e743da0 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 9c8de194d..426d51499 100644
--- a/packages/nextjs/src/AsgardeoNextClient.ts
+++ b/packages/nextjs/src/AsgardeoNextClient.ts
@@ -40,6 +40,12 @@ import {
generateFlattenedUserProfile,
updateMeProfile,
executeEmbeddedSignUpFlow,
+ getMeOrganizations,
+ IdToken,
+ createOrganization,
+ CreateOrganizationPayload,
+ getOrganization,
+ OrganizationDetails,
} from '@asgardeo/node';
import {NextRequest, NextResponse} from 'next/server';
import {AsgardeoNextConfig} from './models/config';
@@ -206,16 +212,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.',
+ 'AsgardeoReactClient-createOrganization-RuntimeError-001',
+ 'nextjs',
+ 'An error occurred while creating the organization. Please check your configuration and network connection.',
+ );
+ }
}
- override switchOrganization(organization: Organization): Promise {
- throw new Error('Method not implemented.');
+ 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 getCurrentOrganization(): Promise {
- throw new Error('Method not implemented.');
+ 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 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 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 000000000..b111912c4
--- /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 000000000..7cccc8c55
--- /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 000000000..0ab5d680c
--- /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 000000000..702f0f1f0
--- /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 000000000..b840e0052
--- /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/contexts/Asgardeo/AsgardeoProvider.tsx b/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoProvider.tsx
index cc0dcbe28..0b48ee20e 100644
--- a/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoProvider.tsx
+++ b/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoProvider.tsx
@@ -19,16 +19,28 @@
'use client';
import {
+ AsgardeoRuntimeError,
EmbeddedFlowExecuteRequestConfig,
EmbeddedFlowExecuteRequestPayload,
EmbeddedSignInFlowHandleRequestPayload,
+ Organization,
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,9 +50,14 @@ export type AsgardeoClientProviderProps = Partial Promise<{success: boolean; error?: string; redirectUrl?: string}>;
+ handleOAuthCallback: (
+ code: string,
+ state: string,
+ sessionState?: string,
+ ) => Promise<{success: boolean; error?: string; redirectUrl?: string}>;
isSignedIn: boolean;
userProfile: UserProfile;
+ currentOrganization: Organization;
user: User | null;
};
@@ -57,7 +74,9 @@ const AsgardeoClientProvider: FC>
signUpUrl,
user,
userProfile,
+ currentOrganization,
}: PropsWithChildren) => {
+ const reRenderCheckRef: RefObject = useRef(false);
const router = useRouter();
const searchParams = useSearchParams();
const [isDarkMode, setIsDarkMode] = useState(false);
@@ -81,7 +100,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 +123,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 +233,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,
@@ -226,7 +272,19 @@ const AsgardeoClientProvider: FC>
- {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 ae8cccb69..40ceb8449 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 21efda1d6..faf2980b2 100644
--- a/packages/nextjs/src/server/AsgardeoProvider.tsx
+++ b/packages/nextjs/src/server/AsgardeoProvider.tsx
@@ -17,7 +17,7 @@
*/
import {FC, PropsWithChildren, ReactElement} from 'react';
-import {AsgardeoRuntimeError, User, UserProfile} from '@asgardeo/node';
+import {AsgardeoRuntimeError, Organization, User, UserProfile} from '@asgardeo/node';
import AsgardeoClientProvider, {AsgardeoClientProviderProps} from '../client/contexts/Asgardeo/AsgardeoProvider';
import AsgardeoNextClient from '../AsgardeoNextClient';
import signInAction from './actions/signInAction';
@@ -30,6 +30,7 @@ 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';
/**
* Props interface of {@link AsgardeoServerProvider}
@@ -88,13 +89,20 @@ 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 (
@@ -109,6 +117,7 @@ const AsgardeoServerProvider: FC>
preferences={config.preferences}
clientId={config.clientId}
user={user}
+ currentOrganization={currentOrganization}
userProfile={userProfile}
isSignedIn={_isSignedIn}
>
diff --git a/packages/nextjs/src/server/actions/createOrganizationAction.ts b/packages/nextjs/src/server/actions/createOrganizationAction.ts
new file mode 100644
index 000000000..83a1b0fc3
--- /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 000000000..bd08d9c7a
--- /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 000000000..e5eb99d60
--- /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 000000000..16c878071
--- /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 000000000..9b049bacd
--- /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/react/src/components/presentation/OrganizationProfile/OrganizationProfile.tsx b/packages/react/src/components/presentation/OrganizationProfile/OrganizationProfile.tsx
index e7f6eb0be..9c75d0665 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/UserProfile/BaseUserProfile.tsx b/packages/react/src/components/presentation/UserProfile/BaseUserProfile.tsx
index 6158b3ff5..15aa57444 100644
--- a/packages/react/src/components/presentation/UserProfile/BaseUserProfile.tsx
+++ b/packages/react/src/components/presentation/UserProfile/BaseUserProfile.tsx
@@ -692,7 +692,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/index.ts b/packages/react/src/index.ts
index d255ba4ba..a3e9bc784 100644
--- a/packages/react/src/index.ts
+++ b/packages/react/src/index.ts
@@ -239,6 +239,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/components/Header/AuthenticatedActions.tsx b/samples/teamspace-nextjs/components/Header/AuthenticatedActions.tsx
index 48fe0595c..0d4821511 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;
From 73fd44b95d9fb5bef375ba9e2f60a7a7fee35e67 Mon Sep 17 00:00:00 2001
From: Brion
Date: Tue, 1 Jul 2025 10:44:32 +0530
Subject: [PATCH 02/21] feat: implement theme detection and customization
options
---
packages/browser/src/index.ts | 7 +
packages/browser/src/theme/themeDetection.ts | 134 ++++++++++++++++++
packages/javascript/src/index.ts | 2 +-
.../javascript/src/theme/themeDetection.ts | 114 +++++++++++++++
packages/javascript/src/theme/types.ts | 15 +-
.../components/presentation/SignIn/SignIn.tsx | 4 +-
.../components/presentation/SignUp/SignUp.tsx | 4 +-
.../src/components/primitives/Card/Card.tsx | 1 -
.../contexts/Asgardeo/AsgardeoProvider.tsx | 2 +-
.../src/contexts/Theme/ThemeProvider.tsx | 75 +++++++++-
samples/teamspace-react/src/main.tsx | 14 +-
11 files changed, 356 insertions(+), 16 deletions(-)
create mode 100644 packages/browser/src/theme/themeDetection.ts
create mode 100644 packages/javascript/src/theme/themeDetection.ts
diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts
index 8ad5581a8..7702f3d93 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 000000000..5c47329ec
--- /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/index.ts b/packages/javascript/src/index.ts
index 5813533f8..afebdd7f6 100644
--- a/packages/javascript/src/index.ts
+++ b/packages/javascript/src/index.ts
@@ -100,7 +100,7 @@ export {I18nBundle, I18nTranslations, I18nMetadata} from './models/i18n';
export {default as AsgardeoJavaScriptClient} from './AsgardeoJavaScriptClient';
export {default as createTheme} from './theme/createTheme';
-export {ThemeColors, ThemeConfig, Theme, ThemeMode} from './theme/types';
+export {ThemeColors, ThemeConfig, Theme, ThemeMode, ThemeDetection} from './theme/types';
export {default as deepMerge} from './utils/deepMerge';
export {default as extractUserClaimsFromIdToken} from './utils/extractUserClaimsFromIdToken';
diff --git a/packages/javascript/src/theme/themeDetection.ts b/packages/javascript/src/theme/themeDetection.ts
new file mode 100644
index 000000000..39a99cf06
--- /dev/null
+++ b/packages/javascript/src/theme/themeDetection.ts
@@ -0,0 +1,114 @@
+/**
+ * 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 './types';
+
+/**
+ * Detects the current theme mode based on the specified method
+ */
+export const detectThemeMode = (mode: ThemeMode, config: ThemeDetection = {}): 'light' | 'dark' => {
+ const {
+ darkClass = 'dark',
+ lightClass = 'light',
+ targetElement = typeof document !== 'undefined' ? document.documentElement : null,
+ } = config;
+
+ if (typeof window === 'undefined') {
+ return 'light'; // Default to light mode on server side
+ }
+
+ switch (mode) {
+ case 'dark':
+ return 'dark';
+ case 'light':
+ return 'light';
+ case 'system':
+ return window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
+ case 'class':
+ if (!targetElement) {
+ console.warn('ThemeProvider: Target element not available for class-based theme detection');
+ return 'light';
+ }
+
+ // Check for dark class first, then light class, then default to light
+ if (targetElement.classList.contains(darkClass)) {
+ return 'dark';
+ }
+ if (targetElement.classList.contains(lightClass)) {
+ return 'light';
+ }
+
+ // If neither class is present, default to light
+ return 'light';
+ default:
+ return 'light';
+ }
+};
+
+/**
+ * Creates a MutationObserver to watch for class changes on the target element
+ */
+export const createClassObserver = (
+ targetElement: HTMLElement,
+ callback: (isDark: boolean) => void,
+ config: ThemeDetection = {},
+): MutationObserver => {
+ const {darkClass = 'dark', lightClass = 'light'} = config;
+
+ const observer = new MutationObserver(mutations => {
+ mutations.forEach(mutation => {
+ if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
+ const classList = (mutation.target as HTMLElement).classList;
+ const isDark = classList.contains(darkClass);
+ callback(isDark);
+ }
+ });
+ });
+
+ 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);
+ };
+
+ // Use addEventListener if available, otherwise use deprecated addListener
+ if (mediaQuery.addEventListener) {
+ mediaQuery.addEventListener('change', handleChange);
+ } else {
+ // Fallback for older browsers
+ mediaQuery.addListener(handleChange);
+ }
+
+ return mediaQuery;
+};
diff --git a/packages/javascript/src/theme/types.ts b/packages/javascript/src/theme/types.ts
index 6d4cf5d61..db009a03a 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/react/src/components/presentation/SignIn/SignIn.tsx b/packages/react/src/components/presentation/SignIn/SignIn.tsx
index 8a170baea..da9c5b809 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 187662327..69642e410 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/primitives/Card/Card.tsx b/packages/react/src/components/primitives/Card/Card.tsx
index 5500ce812..12af7e2a9 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/contexts/Asgardeo/AsgardeoProvider.tsx b/packages/react/src/contexts/Asgardeo/AsgardeoProvider.tsx
index c974e0e16..9670fec45 100644
--- a/packages/react/src/contexts/Asgardeo/AsgardeoProvider.tsx
+++ b/packages/react/src/contexts/Asgardeo/AsgardeoProvider.tsx
@@ -249,7 +249,7 @@ const AsgardeoProvider: FC> = ({
}}
>
-
+
;
- 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/samples/teamspace-react/src/main.tsx b/samples/teamspace-react/src/main.tsx
index eb23c4eb0..4a75531a5 100644
--- a/samples/teamspace-react/src/main.tsx
+++ b/samples/teamspace-react/src/main.tsx
@@ -26,7 +26,19 @@ createRoot(document.getElementById('root')!).render(
]}
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: {
From 9ee4ef45c277687a22aca62d05d42a0ad8e2d0dd Mon Sep 17 00:00:00 2001
From: Brion
Date: Tue, 1 Jul 2025 12:12:32 +0530
Subject: [PATCH 03/21] fix: update ThemeProvider prop from defaultColorScheme
to mode
---
.../javascript/src/theme/themeDetection.ts | 114 ------------------
.../contexts/Asgardeo/AsgardeoProvider.tsx | 2 +-
2 files changed, 1 insertion(+), 115 deletions(-)
delete mode 100644 packages/javascript/src/theme/themeDetection.ts
diff --git a/packages/javascript/src/theme/themeDetection.ts b/packages/javascript/src/theme/themeDetection.ts
deleted file mode 100644
index 39a99cf06..000000000
--- a/packages/javascript/src/theme/themeDetection.ts
+++ /dev/null
@@ -1,114 +0,0 @@
-/**
- * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com).
- *
- * WSO2 LLC. licenses this file to you under the Apache License,
- * Version 2.0 (the "License"); you may not use this file except
- * in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied. See the License for the
- * specific language governing permissions and limitations
- * under the License.
- */
-
-import {ThemeDetection, ThemeMode} from './types';
-
-/**
- * Detects the current theme mode based on the specified method
- */
-export const detectThemeMode = (mode: ThemeMode, config: ThemeDetection = {}): 'light' | 'dark' => {
- const {
- darkClass = 'dark',
- lightClass = 'light',
- targetElement = typeof document !== 'undefined' ? document.documentElement : null,
- } = config;
-
- if (typeof window === 'undefined') {
- return 'light'; // Default to light mode on server side
- }
-
- switch (mode) {
- case 'dark':
- return 'dark';
- case 'light':
- return 'light';
- case 'system':
- return window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
- case 'class':
- if (!targetElement) {
- console.warn('ThemeProvider: Target element not available for class-based theme detection');
- return 'light';
- }
-
- // Check for dark class first, then light class, then default to light
- if (targetElement.classList.contains(darkClass)) {
- return 'dark';
- }
- if (targetElement.classList.contains(lightClass)) {
- return 'light';
- }
-
- // If neither class is present, default to light
- return 'light';
- default:
- return 'light';
- }
-};
-
-/**
- * Creates a MutationObserver to watch for class changes on the target element
- */
-export const createClassObserver = (
- targetElement: HTMLElement,
- callback: (isDark: boolean) => void,
- config: ThemeDetection = {},
-): MutationObserver => {
- const {darkClass = 'dark', lightClass = 'light'} = config;
-
- const observer = new MutationObserver(mutations => {
- mutations.forEach(mutation => {
- if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
- const classList = (mutation.target as HTMLElement).classList;
- const isDark = classList.contains(darkClass);
- callback(isDark);
- }
- });
- });
-
- 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);
- };
-
- // Use addEventListener if available, otherwise use deprecated addListener
- if (mediaQuery.addEventListener) {
- mediaQuery.addEventListener('change', handleChange);
- } else {
- // Fallback for older browsers
- mediaQuery.addListener(handleChange);
- }
-
- return mediaQuery;
-};
diff --git a/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoProvider.tsx b/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoProvider.tsx
index 0b48ee20e..30bda02b6 100644
--- a/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoProvider.tsx
+++ b/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoProvider.tsx
@@ -270,7 +270,7 @@ const AsgardeoClientProvider: FC>
return (
-
+
Date: Tue, 1 Jul 2025 16:54:59 +0530
Subject: [PATCH 04/21] feat(javascript): add getBrandingPreference API and
related models for branding configuration
---
.../__tests__/getBrandingPreference.test.ts | 234 +++++++++++++++
.../src/api/getBrandingPreference.ts | 183 ++++++++++++
packages/javascript/src/index.ts | 13 +
.../src/models/branding-preference.ts | 267 ++++++++++++++++++
4 files changed, 697 insertions(+)
create mode 100644 packages/javascript/src/api/__tests__/getBrandingPreference.test.ts
create mode 100644 packages/javascript/src/api/getBrandingPreference.ts
create mode 100644 packages/javascript/src/models/branding-preference.ts
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 000000000..d5bc40c83
--- /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/getBrandingPreference.ts b/packages/javascript/src/api/getBrandingPreference.ts
new file mode 100644
index 000000000..2491353e0
--- /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 = {
+ method: 'GET',
+ headers: {
+ 'Content-Type': 'application/json',
+ Accept: 'application/json',
+ ...requestConfig.headers,
+ },
+ ...requestConfig,
+ };
+
+ 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/index.ts b/packages/javascript/src/index.ts
index afebdd7f6..83e3ffa8b 100644
--- a/packages/javascript/src/index.ts
+++ b/packages/javascript/src/index.ts
@@ -41,6 +41,7 @@ export {default as getMeOrganizations, GetMeOrganizationsConfig} from './api/get
export {default as getOrganization, OrganizationDetails, GetOrganizationConfig} from './api/getOrganization';
export {default as updateOrganization, createPatchOperations, UpdateOrganizationConfig} from './api/updateOrganization';
export {default as updateMeProfile, UpdateMeProfileConfig} from './api/updateMeProfile';
+export {default as getBrandingPreference, GetBrandingPreferenceConfig} from './api/getBrandingPreference';
export {default as ApplicationNativeAuthenticationConstants} from './constants/ApplicationNativeAuthenticationConstants';
export {default as TokenConstants} from './constants/TokenConstants';
@@ -92,6 +93,18 @@ export {Storage, TemporaryStore} from './models/store';
export {User, UserProfile} from './models/user';
export {SessionData} from './models/session';
export {Organization} from './models/organization';
+export {
+ BrandingPreference,
+ BrandingPreferenceConfig,
+ BrandingLayout,
+ BrandingTheme,
+ ThemeVariant,
+ ButtonsConfig,
+ ColorsConfig,
+ ColorVariants,
+ OrganizationDetails,
+ UrlsConfig,
+} from './models/branding-preference';
export {Schema, SchemaAttribute, WellKnownSchemaIds, FlattenedSchema} from './models/scim2-schema';
export {RecursivePartial} from './models/utility-types';
export {FieldType} from './models/field';
diff --git a/packages/javascript/src/models/branding-preference.ts b/packages/javascript/src/models/branding-preference.ts
new file mode 100644
index 000000000..c656c21be
--- /dev/null
+++ b/packages/javascript/src/models/branding-preference.ts
@@ -0,0 +1,267 @@
+/**
+ * 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.
+ */
+
+/**
+ * Interface for color configuration with multiple variants.
+ */
+export interface ColorVariants {
+ contrastText?: string;
+ dark?: string;
+ inverted?: string;
+ light?: string;
+ main?: string;
+}
+
+/**
+ * Interface for text color configuration.
+ */
+export interface TextColors {
+ primary?: string;
+ secondary?: string;
+}
+
+/**
+ * Interface for button styling configuration.
+ */
+export interface ButtonStyle {
+ base?: {
+ background?: {
+ backgroundColor?: string;
+ };
+ border?: {
+ borderColor?: string;
+ borderRadius?: string;
+ };
+ font?: {
+ color?: string;
+ };
+ };
+}
+
+/**
+ * Interface for buttons configuration.
+ */
+export interface ButtonsConfig {
+ externalConnection?: ButtonStyle;
+ primary?: ButtonStyle;
+ secondary?: ButtonStyle;
+}
+
+/**
+ * Interface for color palette configuration.
+ */
+export interface ColorsConfig {
+ alerts?: {
+ error?: ColorVariants;
+ info?: ColorVariants;
+ neutral?: ColorVariants;
+ warning?: ColorVariants;
+ };
+ background?: {
+ body?: ColorVariants;
+ surface?: ColorVariants;
+ };
+ illustrations?: {
+ accent1?: ColorVariants;
+ accent2?: ColorVariants;
+ accent3?: ColorVariants;
+ primary?: ColorVariants;
+ secondary?: ColorVariants;
+ };
+ outlined?: {
+ default?: string;
+ };
+ primary?: ColorVariants;
+ secondary?: ColorVariants;
+ text?: TextColors;
+}
+
+/**
+ * Interface for footer configuration.
+ */
+export interface FooterConfig {
+ border?: {
+ borderColor?: string;
+ };
+ font?: {
+ color?: string;
+ };
+}
+
+/**
+ * Interface for image configuration.
+ */
+export interface ImageConfig {
+ altText?: string;
+ imgURL?: string;
+ title?: string;
+}
+
+/**
+ * Interface for images configuration.
+ */
+export interface ImagesConfig {
+ favicon?: Partial;
+ 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 OrganizationDetails {
+ 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?: OrganizationDetails;
+ theme?: BrandingTheme;
+ urls?: UrlsConfig;
+}
+
+/**
+ * Interface for branding preference configuration.
+ */
+export interface BrandingPreference {
+ type?: string;
+ name?: string;
+ locale?: string;
+ preference?: BrandingPreferenceConfig;
+}
From c9075e5caadf157e3ffac3d88f93e4c682663ab9 Mon Sep 17 00:00:00 2001
From: Brion
Date: Tue, 1 Jul 2025 16:55:44 +0530
Subject: [PATCH 05/21] chore(javascript): rename OrganizationDetails to
BrandingOrganizationDetails in branding preference models
---
packages/javascript/src/index.ts | 2 +-
packages/javascript/src/models/branding-preference.ts | 4 ++--
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/packages/javascript/src/index.ts b/packages/javascript/src/index.ts
index 83e3ffa8b..1856ecd54 100644
--- a/packages/javascript/src/index.ts
+++ b/packages/javascript/src/index.ts
@@ -102,7 +102,7 @@ export {
ButtonsConfig,
ColorsConfig,
ColorVariants,
- OrganizationDetails,
+ BrandingOrganizationDetails,
UrlsConfig,
} from './models/branding-preference';
export {Schema, SchemaAttribute, WellKnownSchemaIds, FlattenedSchema} from './models/scim2-schema';
diff --git a/packages/javascript/src/models/branding-preference.ts b/packages/javascript/src/models/branding-preference.ts
index c656c21be..4da3c0596 100644
--- a/packages/javascript/src/models/branding-preference.ts
+++ b/packages/javascript/src/models/branding-preference.ts
@@ -217,7 +217,7 @@ export interface BrandingLayout {
/**
* Interface for organization details configuration.
*/
-export interface OrganizationDetails {
+export interface BrandingOrganizationDetails {
displayName?: string;
supportEmail?: string;
}
@@ -251,7 +251,7 @@ export interface BrandingPreferenceConfig {
selfSignUpEnabled?: boolean;
};
layout?: BrandingLayout;
- organizationDetails?: OrganizationDetails;
+ organizationDetails?: BrandingOrganizationDetails;
theme?: BrandingTheme;
urls?: UrlsConfig;
}
From 0a0b259fd4f5b7c4f8a1de7464b1d0f2ec53aeaa Mon Sep 17 00:00:00 2001
From: Brion
Date: Tue, 1 Jul 2025 17:35:39 +0530
Subject: [PATCH 06/21] fix: enhance readonly field handling in BaseUserProfile
component
---
.../UserProfile/BaseUserProfile.tsx | 24 +++++++++++++------
1 file changed, 17 insertions(+), 7 deletions(-)
diff --git a/packages/react/src/components/presentation/UserProfile/BaseUserProfile.tsx b/packages/react/src/components/presentation/UserProfile/BaseUserProfile.tsx
index 15aa57444..6d41ff1db 100644
--- a/packages/react/src/components/presentation/UserProfile/BaseUserProfile.tsx
+++ b/packages/react/src/components/presentation/UserProfile/BaseUserProfile.tsx
@@ -76,6 +76,17 @@ export interface BaseUserProfileProps {
title?: string;
}
+// Fields to skip based on schema.name
+const fieldsToSkip: string[] = [
+ 'verifiedMobileNumbers',
+ 'verifiedEmailAddresses',
+ 'phoneNumbers.mobile',
+ 'emailAddresses',
+];
+
+// Fields that should be readonly
+const readonlyFields: string[] = ['username', 'userName', 'user_name'];
+
const BaseUserProfile: FC = ({
fallback = null,
className = '',
@@ -319,7 +330,7 @@ const BaseUserProfile: FC = ({
}
if (Array.isArray(value)) {
const hasValues = value.length > 0;
- const isEditable = editable && mutability !== 'READ_ONLY';
+ const isEditable = editable && mutability !== 'READ_ONLY' && !readonlyFields.includes(name || '');
let displayValue: string;
if (hasValues) {
@@ -363,7 +374,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 +436,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 +483,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 +513,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 +621,7 @@ const BaseUserProfile: FC
= ({
...schema,
value,
};
+
return {renderUserInfo(schemaWithValue)}
;
})}
From f64265aca2c0625c56fcb41a1a0f2f609e331bf5 Mon Sep 17 00:00:00 2001
From: Brion
Date: Tue, 1 Jul 2025 18:44:22 +0530
Subject: [PATCH 07/21] chore: add MultiInput component for handling multiple
input values
---
.../UserProfile/BaseUserProfile.tsx | 85 ++++-
.../primitives/MultiInput/MultiInput.tsx | 310 ++++++++++++++++++
packages/react/src/index.ts | 3 +
3 files changed, 393 insertions(+), 5 deletions(-)
create mode 100644 packages/react/src/components/primitives/MultiInput/MultiInput.tsx
diff --git a/packages/react/src/components/presentation/UserProfile/BaseUserProfile.tsx b/packages/react/src/components/presentation/UserProfile/BaseUserProfile.tsx
index 6d41ff1db..50e59dd6b 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 {
@@ -78,6 +79,20 @@ export interface BaseUserProfileProps {
// 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',
@@ -199,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:
@@ -304,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
@@ -328,13 +348,68 @@ const BaseUserProfile: FC = ({
>
);
}
- if (Array.isArray(value)) {
- const hasValues = value.length > 0;
+
+ // 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 {
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 000000000..423f289b6
--- /dev/null
+++ b/packages/react/src/components/primitives/MultiInput/MultiInput.tsx
@@ -0,0 +1,310 @@
+/**
+ * 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} 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 MultiInput: FC = ({
+ label,
+ error,
+ required,
+ className,
+ disabled,
+ helperText,
+ placeholder = 'Enter value',
+ values = [],
+ onChange,
+ style = {},
+ type = 'text',
+ fieldType = 'STRING',
+ startIcon,
+ endIcon,
+ minFields = 1,
+ maxFields,
+}) => {
+ const {theme} = useTheme();
+
+ const PlusIcon = () => (
+
+
+
+ );
+
+ 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 containerStyle: CSSProperties = {
+ display: 'flex',
+ flexDirection: 'column',
+ gap: `${theme.spacing.unit}px`,
+ };
+
+ const inputRowStyle: CSSProperties = {
+ display: 'flex',
+ alignItems: 'center',
+ gap: `${theme.spacing.unit}px`,
+ position: 'relative',
+ };
+
+ const listItemStyle: CSSProperties = {
+ 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,
+ };
+
+ const listContainerStyle: CSSProperties = {
+ display: 'flex',
+ flexDirection: 'column',
+ gap: `${theme.spacing.unit / 2}px`,
+ };
+
+ 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 && (
+ handleRemoveValue(index)}
+ disabled={disabled}
+ title="Remove value"
+ style={{
+ padding: `${theme.spacing.unit / 2}px`,
+ minWidth: 'auto',
+ color: theme.colors.error.main,
+ }}
+ >
+
+
+ )}
+
+ ))}
+
+ )}
+
+
+ );
+};
+
+export default MultiInput;
diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts
index a3e9bc784..0945cf56f 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';
From f5b98d71a2e0781b973b33b8fcef0c61fd8a2cdc Mon Sep 17 00:00:00 2001
From: Brion
Date: Tue, 1 Jul 2025 20:36:48 +0530
Subject: [PATCH 08/21] feat: refactor MultiInput component styles using
useMemo for improved performance
---
.../primitives/MultiInput/MultiInput.tsx | 113 ++++++++++--------
1 file changed, 64 insertions(+), 49 deletions(-)
diff --git a/packages/react/src/components/primitives/MultiInput/MultiInput.tsx b/packages/react/src/components/primitives/MultiInput/MultiInput.tsx
index 423f289b6..07de69c7d 100644
--- a/packages/react/src/components/primitives/MultiInput/MultiInput.tsx
+++ b/packages/react/src/components/primitives/MultiInput/MultiInput.tsx
@@ -16,7 +16,7 @@
* under the License.
*/
-import {CSSProperties, FC, ReactNode, useCallback, useState} from 'react';
+import {CSSProperties, FC, ReactNode, useCallback, useState, useMemo} from 'react';
import useTheme from '../../../contexts/Theme/useTheme';
import clsx from 'clsx';
import FormControl from '../FormControl/FormControl';
@@ -94,6 +94,48 @@ export interface MultiInputProps {
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,
+ },
+ 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,
@@ -112,7 +154,7 @@ const MultiInput: FC = ({
minFields = 1,
maxFields,
}) => {
- const {theme} = useTheme();
+ const styles = useStyles();
const PlusIcon = () => (
= ({
);
- const handleAddValue = useCallback((newValue: string) => {
- if (newValue.trim() !== '' && (!maxFields || values.length < maxFields)) {
- onChange([...values, newValue.trim()]);
- }
- }, [values, onChange, maxFields]);
+ const handleAddValue = useCallback(
+ (newValue: string) => {
+ if (newValue.trim() !== '' && (!maxFields || values.length < maxFields)) {
+ onChange([...values, newValue.trim()]);
+ }
+ },
+ [values, onChange, maxFields],
+ );
const handleRemoveValue = useCallback(
(index: number) => {
@@ -161,7 +206,12 @@ const MultiInput: FC = ({
);
const renderInputField = useCallback(
- (value: string, onValueChange: (value: string) => void, attachedEndIcon?: ReactNode, onEndIconClick?: () => void) => {
+ (
+ value: string,
+ onValueChange: (value: string) => void,
+ attachedEndIcon?: ReactNode,
+ onEndIconClick?: () => void,
+ ) => {
const handleInputChange = (e: any) => {
const newValue = e.target ? e.target.value : e;
onValueChange(newValue);
@@ -206,37 +256,6 @@ const MultiInput: FC = ({
[placeholder, disabled, startIcon, endIcon, error, fieldType, type],
);
- const containerStyle: CSSProperties = {
- display: 'flex',
- flexDirection: 'column',
- gap: `${theme.spacing.unit}px`,
- };
-
- const inputRowStyle: CSSProperties = {
- display: 'flex',
- alignItems: 'center',
- gap: `${theme.spacing.unit}px`,
- position: 'relative',
- };
-
- const listItemStyle: CSSProperties = {
- 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,
- };
-
- const listContainerStyle: CSSProperties = {
- display: 'flex',
- flexDirection: 'column',
- gap: `${theme.spacing.unit / 2}px`,
- };
-
const canAddMore = !maxFields || values.length < maxFields;
const canRemove = values.length > minFields;
@@ -262,10 +281,10 @@ const MultiInput: FC = ({
{label}
)}
-
+
{/* Input field at the top */}
-
-
+
+
{renderInputField(
currentInputValue,
setCurrentInputValue,
@@ -277,9 +296,9 @@ const MultiInput: FC
= ({
{/* List of added items */}
{values.length > 0 && (
-
+
{values.map((value, index) => (
-
+
{value}
{canRemove && (
= ({
onClick={() => handleRemoveValue(index)}
disabled={disabled}
title="Remove value"
- style={{
- padding: `${theme.spacing.unit / 2}px`,
- minWidth: 'auto',
- color: theme.colors.error.main,
- }}
+ style={styles.removeButton}
>
From df5d0624ab4d4c93c40b3654e6e9f2eb7b90ed2b Mon Sep 17 00:00:00 2001
From: Brion
Date: Tue, 1 Jul 2025 21:13:57 +0530
Subject: [PATCH 09/21] feat: add PlusIcon styling to MultiInput component for
improved UI
---
.../primitives/MultiInput/MultiInput.tsx | 88 +++++++++++--------
1 file changed, 49 insertions(+), 39 deletions(-)
diff --git a/packages/react/src/components/primitives/MultiInput/MultiInput.tsx b/packages/react/src/components/primitives/MultiInput/MultiInput.tsx
index 07de69c7d..3808e2776 100644
--- a/packages/react/src/components/primitives/MultiInput/MultiInput.tsx
+++ b/packages/react/src/components/primitives/MultiInput/MultiInput.tsx
@@ -97,43 +97,52 @@ export interface MultiInputProps {
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,
- },
- 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]);
+ 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 = ({
@@ -156,7 +165,7 @@ const MultiInput: FC = ({
}) => {
const styles = useStyles();
- const PlusIcon = () => (
+ const PlusIcon = ({style}) => (
= ({
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
+ style={style}
>
@@ -288,7 +298,7 @@ const MultiInput: FC = ({
{renderInputField(
currentInputValue,
setCurrentInputValue,
- canAddMore ? : undefined,
+ canAddMore ? : undefined,
canAddMore ? handleInputSubmit : undefined,
)}
From 0d5e748e88e077719b54cb1c554f04e8fce6500d Mon Sep 17 00:00:00 2001
From: Brion
Date: Wed, 2 Jul 2025 00:32:28 +0530
Subject: [PATCH 10/21] fix(javascript): enhance API request handling by
integrating requestConfig in multiple endpoints
---
packages/javascript/src/api/createOrganization.ts | 3 ++-
packages/javascript/src/api/executeEmbeddedSignInFlow.ts | 5 ++---
packages/javascript/src/api/executeEmbeddedSignUpFlow.ts | 5 ++---
packages/javascript/src/api/getAllOrganizations.ts | 2 +-
packages/javascript/src/api/getBrandingPreference.ts | 2 +-
packages/javascript/src/api/getMeOrganizations.ts | 2 +-
packages/javascript/src/api/getOrganization.ts | 2 +-
packages/javascript/src/api/getSchemas.ts | 2 +-
packages/javascript/src/api/getScim2Me.ts | 2 +-
packages/javascript/src/api/getUserInfo.ts | 3 ++-
packages/javascript/src/api/initializeEmbeddedSignInFlow.ts | 5 ++---
packages/javascript/src/api/updateMeProfile.ts | 4 ++--
packages/javascript/src/api/updateOrganization.ts | 2 +-
13 files changed, 19 insertions(+), 20 deletions(-)
diff --git a/packages/javascript/src/api/createOrganization.ts b/packages/javascript/src/api/createOrganization.ts
index 6e0041117..ef30b5da2 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 {
@@ -195,6 +195,7 @@ const createOrganization = async ({
return (await response.json()) as Organization;
} catch (error) {
+ console.log('[JS][createOrganization] Error creating organization:', error);
if (error instanceof AsgardeoAPIError) {
throw error;
}
diff --git a/packages/javascript/src/api/executeEmbeddedSignInFlow.ts b/packages/javascript/src/api/executeEmbeddedSignInFlow.ts
index 3d8bd1b83..e91777e21 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 8e2581e7e..8224a1296 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 0cd7223ce..0d9e53149 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
index 2491353e0..8a040394f 100644
--- a/packages/javascript/src/api/getBrandingPreference.ts
+++ b/packages/javascript/src/api/getBrandingPreference.ts
@@ -139,13 +139,13 @@ const getBrandingPreference = async ({
}`;
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/getMeOrganizations.ts b/packages/javascript/src/api/getMeOrganizations.ts
index 159a4158c..984a3de7f 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 0c7912157..f40012ee6 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 495872f2a..435bc0890 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 a690678c3..5cba4f2e8 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 37c719137..087844630 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
Date: Wed, 2 Jul 2025 02:00:49 +0530
Subject: [PATCH 11/21] feat: implement updateUserProfile method across client
and server components for user profile management
---
.../src/AsgardeoJavaScriptClient.ts | 2 ++
packages/javascript/src/models/client.ts | 2 ++
packages/nextjs/src/AsgardeoNextClient.ts | 6 ++--
.../presentation/UserProfile/UserProfile.tsx | 8 ++---
.../contexts/Asgardeo/AsgardeoProvider.tsx | 33 ++++++++++++++++---
.../nextjs/src/server/AsgardeoProvider.tsx | 2 ++
.../server/actions/updateUserProfileAction.ts | 11 ++++---
packages/react/src/AsgardeoReactClient.ts | 4 +++
.../react/src/contexts/User/UserContext.ts | 9 ++++-
.../react/src/contexts/User/UserProvider.tsx | 13 ++++++--
10 files changed, 72 insertions(+), 18 deletions(-)
diff --git a/packages/javascript/src/AsgardeoJavaScriptClient.ts b/packages/javascript/src/AsgardeoJavaScriptClient.ts
index 905ad0783..756549128 100644
--- a/packages/javascript/src/AsgardeoJavaScriptClient.ts
+++ b/packages/javascript/src/AsgardeoJavaScriptClient.ts
@@ -46,6 +46,8 @@ abstract class AsgardeoJavaScriptClient implements AsgardeoClient
abstract isSignedIn(): Promise;
+ abstract updateUserProfile(payload: any, userId?: string): Promise;
+
abstract getConfiguration(): T;
abstract signIn(
diff --git a/packages/javascript/src/models/client.ts b/packages/javascript/src/models/client.ts
index 405c7e266..1751b121c 100644
--- a/packages/javascript/src/models/client.ts
+++ b/packages/javascript/src/models/client.ts
@@ -59,6 +59,8 @@ export interface AsgardeoClient {
getConfiguration(): T;
+ updateUserProfile(payload: any, userId?: string): Promise;
+
/**
* Gets user information from the session.
*
diff --git a/packages/nextjs/src/AsgardeoNextClient.ts b/packages/nextjs/src/AsgardeoNextClient.ts
index 426d51499..46699726d 100644
--- a/packages/nextjs/src/AsgardeoNextClient.ts
+++ b/packages/nextjs/src/AsgardeoNextClient.ts
@@ -188,7 +188,7 @@ class AsgardeoNextClient exte
}
}
- async updateUserProfile(payload: any, userId?: string) {
+ override async updateUserProfile(payload: any, userId?: string): Promise {
await this.ensureInitialized();
try {
@@ -204,7 +204,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.',
@@ -226,7 +226,7 @@ class AsgardeoNextClient exte
});
} catch (error) {
throw new AsgardeoRuntimeError(
- 'Failed to create organization.',
+ `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.',
diff --git a/packages/nextjs/src/client/components/presentation/UserProfile/UserProfile.tsx b/packages/nextjs/src/client/components/presentation/UserProfile/UserProfile.tsx
index 73c244274..6465b4e73 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/AsgardeoProvider.tsx b/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoProvider.tsx
index 30bda02b6..db75a032a 100644
--- a/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoProvider.tsx
+++ b/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoProvider.tsx
@@ -23,7 +23,9 @@ import {
EmbeddedFlowExecuteRequestConfig,
EmbeddedFlowExecuteRequestPayload,
EmbeddedSignInFlowHandleRequestPayload,
+ generateFlattenedUserProfile,
Organization,
+ UpdateMeProfileConfig,
User,
UserProfile,
} from '@asgardeo/node';
@@ -59,6 +61,10 @@ export type AsgardeoClientProviderProps = Partial Promise<{success: boolean; data: {user: User}; error: string}>;
};
const AsgardeoClientProvider: FC> = ({
@@ -72,16 +78,26 @@ const AsgardeoClientProvider: FC>
isSignedIn,
signInUrl,
signUpUrl,
- user,
- userProfile,
+ user: _user,
+ userProfile: _userProfile,
currentOrganization,
+ updateProfile,
}: 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(() => {
@@ -267,12 +283,21 @@ const AsgardeoClientProvider: FC>
[baseUrl, user, isSignedIn, isLoading, signInUrl, signUpUrl],
);
+ const handleProfileUpdate = (payload: User): void => {
+ setUser(payload);
+ setUserProfile(prev => ({
+ ...prev,
+ profile: payload,
+ flattenedProfile: generateFlattenedUserProfile(payload, prev?.schemas),
+ }));
+ };
+
return (
-
+
{
const result = await getOrganizationsAction((await getSessionId()) as string);
diff --git a/packages/nextjs/src/server/AsgardeoProvider.tsx b/packages/nextjs/src/server/AsgardeoProvider.tsx
index faf2980b2..3b6a7892b 100644
--- a/packages/nextjs/src/server/AsgardeoProvider.tsx
+++ b/packages/nextjs/src/server/AsgardeoProvider.tsx
@@ -31,6 +31,7 @@ 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}
@@ -119,6 +120,7 @@ const AsgardeoServerProvider: FC>
user={user}
currentOrganization={currentOrganization}
userProfile={userProfile}
+ updateProfile={updateUserProfileAction}
isSignedIn={_isSignedIn}
>
{children}
diff --git a/packages/nextjs/src/server/actions/updateUserProfileAction.ts b/packages/nextjs/src/server/actions/updateUserProfileAction.ts
index dabda4f35..69ec47d5b 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/react/src/AsgardeoReactClient.ts b/packages/react/src/AsgardeoReactClient.ts
index 19af191c7..6243db638 100644
--- a/packages/react/src/AsgardeoReactClient.ts
+++ b/packages/react/src/AsgardeoReactClient.ts
@@ -62,6 +62,10 @@ class AsgardeoReactClient e
return this.asgardeo.init(config as any);
}
+ override async updateUserProfile(payload: any, userId?: string): Promise {
+ throw new Error('Not implemented');
+ }
+
override async getUser(): Promise {
try {
const configData = await this.asgardeo.getConfigData();
diff --git a/packages/react/src/contexts/User/UserContext.ts b/packages/react/src/contexts/User/UserContext.ts
index d74928bbf..375f810ce 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 c2c486fab..e2871ae12 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} ;
From 876eb230782091c6f5d3c5c42a9cc93f9fb6d61d Mon Sep 17 00:00:00 2001
From: Brion
Date: Wed, 2 Jul 2025 02:17:54 +0530
Subject: [PATCH 12/21] fix(react): enhance OrganizationProvider with fetching
state management and improved error handling
---
.../Organization/OrganizationProvider.tsx | 39 ++++++++++++++++++-
1 file changed, 37 insertions(+), 2 deletions(-)
diff --git a/packages/react/src/contexts/Organization/OrganizationProvider.tsx b/packages/react/src/contexts/Organization/OrganizationProvider.tsx
index 3f3dd3207..5eccc1b05 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],
From 8f2efc4185678d9bcfab2c1676c16b45c72de2f1 Mon Sep 17 00:00:00 2001
From: Brion
Date: Wed, 2 Jul 2025 04:07:31 +0530
Subject: [PATCH 13/21] feat(react): add getDecodedIdToken method and refactor
baseUrl handling in AsgardeoProvider
---
packages/react/src/AsgardeoReactClient.ts | 6 +++-
.../contexts/Asgardeo/AsgardeoProvider.tsx | 31 +++++++++++++------
2 files changed, 26 insertions(+), 11 deletions(-)
diff --git a/packages/react/src/AsgardeoReactClient.ts b/packages/react/src/AsgardeoReactClient.ts
index 6243db638..155b8dd88 100644
--- a/packages/react/src/AsgardeoReactClient.ts
+++ b/packages/react/src/AsgardeoReactClient.ts
@@ -80,6 +80,10 @@ class AsgardeoReactClient e
}
}
+ async getDecodedIdToken(sessionId?: string): Promise {
+ return this.asgardeo.getDecodedIdToken(sessionId);
+ }
+
async getUserProfile(): Promise {
try {
const configData = await this.asgardeo.getConfigData();
@@ -215,7 +219,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/contexts/Asgardeo/AsgardeoProvider.tsx b/packages/react/src/contexts/Asgardeo/AsgardeoProvider.tsx
index 9670fec45..a8f023442 100644
--- a/packages/react/src/contexts/Asgardeo/AsgardeoProvider.tsx
+++ b/packages/react/src/contexts/Asgardeo/AsgardeoProvider.tsx
@@ -45,7 +45,7 @@ export type AsgardeoProviderProps = AsgardeoReactConfig;
const AsgardeoProvider: FC> = ({
afterSignInUrl = window.location.origin,
afterSignOutUrl = window.location.origin,
- baseUrl,
+ baseUrl: _baseUrl,
clientId,
children,
scopes,
@@ -64,6 +64,11 @@ const AsgardeoProvider: FC> = ({
const [isInitializedSync, setIsInitializedSync] = useState(false);
const [userProfile, setUserProfile] = useState(null);
+ const [baseUrl, setBaseUrl] = useState(_baseUrl);
+
+ useEffect(() => {
+ setBaseUrl(_baseUrl);
+ }, [_baseUrl]);
useEffect(() => {
(async (): Promise => {
@@ -99,9 +104,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 +176,24 @@ const AsgardeoProvider: FC> = ({
})();
}, [asgardeo]);
+ const updateSession = async (): Promise => {
+ setUser(await asgardeo.getUser());
+ setUserProfile(await asgardeo.getUserProfile());
+ setCurrentOrganization(await asgardeo.getCurrentOrganization());
+
+ // If there's a `user_org` claim in the ID token,
+ // Treat this login as a organization login.
+ if ((await asgardeo.getDecodedIdToken())?.['user_org']) {
+ setBaseUrl(`${(await asgardeo.getConfiguration()).baseUrl}/o`);
+ }
+ };
+
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 +223,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(
From 82d6078aef0cd2223d44083528cced1169407f1f Mon Sep 17 00:00:00 2001
From: Brion
Date: Wed, 2 Jul 2025 04:07:42 +0530
Subject: [PATCH 14/21] feat: update getDecodedIdToken method to accept an
optional sessionId parameter across multiple components
---
packages/browser/src/__legacy__/client.ts | 6 +++---
.../browser/src/__legacy__/clients/main-thread-client.ts | 2 +-
.../browser/src/__legacy__/clients/web-worker-client.ts | 2 +-
.../browser/src/__legacy__/helpers/authentication-helper.ts | 4 ++--
packages/browser/src/__legacy__/models/client.ts | 4 ++--
packages/browser/src/__legacy__/models/web-worker.ts | 2 +-
packages/browser/src/__legacy__/worker/worker-core.ts | 4 ++--
packages/nextjs/src/AsgardeoNextClient.ts | 4 ++--
packages/react/src/__temp__/api.ts | 4 ++--
packages/react/src/__temp__/models.ts | 2 +-
10 files changed, 17 insertions(+), 17 deletions(-)
diff --git a/packages/browser/src/__legacy__/client.ts b/packages/browser/src/__legacy__/client.ts
index f25e14dd0..caae1d857 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 294256204..2d1a24ed0 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 43ff679f7..9c43b5cbf 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 f04d692f7..3ed6137b2 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 1a766886e..c2f65a3e2 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 97c5c1c6d..84f6ddda1 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 2444e4e6a..d5714d90e 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/nextjs/src/AsgardeoNextClient.ts b/packages/nextjs/src/AsgardeoNextClient.ts
index 46699726d..d7369d6bf 100644
--- a/packages/nextjs/src/AsgardeoNextClient.ts
+++ b/packages/nextjs/src/AsgardeoNextClient.ts
@@ -182,8 +182,8 @@ 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),
};
}
}
diff --git a/packages/react/src/__temp__/api.ts b/packages/react/src/__temp__/api.ts
index 8db768dc5..83e267684 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 fc500cf45..537f7dc02 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;
From 7d26677c049648ad131db44ad4f810c43134d13c Mon Sep 17 00:00:00 2001
From: Brion
Date: Wed, 2 Jul 2025 04:40:07 +0530
Subject: [PATCH 15/21] feat(react): implement profile update handling in
UserProfile and AsgardeoProvider components
---
.../presentation/UserProfile/UserProfile.tsx | 8 +++++---
.../src/contexts/Asgardeo/AsgardeoProvider.tsx | 15 +++++++++++----
2 files changed, 16 insertions(+), 7 deletions(-)
diff --git a/packages/react/src/components/presentation/UserProfile/UserProfile.tsx b/packages/react/src/components/presentation/UserProfile/UserProfile.tsx
index 25d138954..56e3d7219 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/contexts/Asgardeo/AsgardeoProvider.tsx b/packages/react/src/contexts/Asgardeo/AsgardeoProvider.tsx
index a8f023442..12b1ea4e8 100644
--- a/packages/react/src/contexts/Asgardeo/AsgardeoProvider.tsx
+++ b/packages/react/src/contexts/Asgardeo/AsgardeoProvider.tsx
@@ -20,6 +20,7 @@ import {
AsgardeoRuntimeError,
EmbeddedFlowExecuteRequestPayload,
EmbeddedFlowExecuteResponse,
+ generateFlattenedUserProfile,
Organization,
SignInOptions,
SignOutOptions,
@@ -242,6 +243,15 @@ 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}
From 05bc87972710ecbdf67453648eaf20df30f0f788 Mon Sep 17 00:00:00 2001
From: Brion
Date: Wed, 2 Jul 2025 04:59:50 +0530
Subject: [PATCH 16/21] feat: enhance AsgardeoClient interface and
implementations with optional parameters for user and organization methods
---
.../src/AsgardeoJavaScriptClient.ts | 8 ++---
packages/javascript/src/models/client.ts | 8 ++---
.../nextjs/src/server/AsgardeoProvider.tsx | 4 ++-
packages/react/src/AsgardeoReactClient.ts | 30 +++++++++++++------
.../contexts/Asgardeo/AsgardeoProvider.tsx | 13 ++++----
5 files changed, 40 insertions(+), 23 deletions(-)
diff --git a/packages/javascript/src/AsgardeoJavaScriptClient.ts b/packages/javascript/src/AsgardeoJavaScriptClient.ts
index 756549128..0b300faa5 100644
--- a/packages/javascript/src/AsgardeoJavaScriptClient.ts
+++ b/packages/javascript/src/AsgardeoJavaScriptClient.ts
@@ -34,13 +34,13 @@ 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;
diff --git a/packages/javascript/src/models/client.ts b/packages/javascript/src/models/client.ts
index 1751b121c..d78a73012 100644
--- a/packages/javascript/src/models/client.ts
+++ b/packages/javascript/src/models/client.ts
@@ -41,14 +41,14 @@ export interface AsgardeoClient