diff --git a/.changeset/tiny-worms-repeat.md b/.changeset/tiny-worms-repeat.md new file mode 100644 index 00000000..cd5a4622 --- /dev/null +++ b/.changeset/tiny-worms-repeat.md @@ -0,0 +1,11 @@ +--- +'@asgardeo/browser': patch +'@asgardeo/express': patch +'@asgardeo/javascript': patch +'@asgardeo/nextjs': patch +'@asgardeo/node': patch +'@asgardeo/react': patch +'@asgardeo/vue': patch +--- + +Fix B2B components diff --git a/packages/javascript/src/AsgardeoJavaScriptClient.ts b/packages/javascript/src/AsgardeoJavaScriptClient.ts index 0b300faa..1e58e4fa 100644 --- a/packages/javascript/src/AsgardeoJavaScriptClient.ts +++ b/packages/javascript/src/AsgardeoJavaScriptClient.ts @@ -16,10 +16,12 @@ * under the License. */ +import {AllOrganizationsApiResponse} from './models/organization'; import {AsgardeoClient, SignInOptions, SignOutOptions, SignUpOptions} from './models/client'; import {Config} from './models/config'; import {EmbeddedFlowExecuteRequestPayload, EmbeddedFlowExecuteResponse} from './models/embedded-flow'; import {EmbeddedSignInFlowHandleRequestPayload} from './models/embedded-signin-flow'; +import {TokenResponse} from './models/token'; import {Organization} from './models/organization'; import {User, UserProfile} from './models/user'; @@ -30,13 +32,15 @@ import {User, UserProfile} from './models/user'; * @typeParam T - Configuration type that extends Config. */ abstract class AsgardeoJavaScriptClient implements AsgardeoClient { - abstract switchOrganization(organization: Organization): Promise; + abstract switchOrganization(organization: Organization, sessionId?: string): Promise; abstract initialize(config: T): Promise; abstract getUser(options?: any): Promise; - abstract getOrganizations(options?: any): Promise; + abstract getAllOrganizations(options?: any, sessionId?: string): Promise; + + abstract getMyOrganizations(options?: any, sessionId?: string): Promise; abstract getCurrentOrganization(sessionId?: string): Promise; diff --git a/packages/javascript/src/api/getAllOrganizations.ts b/packages/javascript/src/api/getAllOrganizations.ts index 0d9e5314..4c4e0223 100644 --- a/packages/javascript/src/api/getAllOrganizations.ts +++ b/packages/javascript/src/api/getAllOrganizations.ts @@ -16,19 +16,9 @@ * under the License. */ -import {Organization} from '../models/organization'; +import {AllOrganizationsApiResponse} from '../models/organization'; import AsgardeoAPIError from '../errors/AsgardeoAPIError'; -/** - * Interface for paginated organization response. - */ -export interface PaginatedOrganizationsResponse { - hasMore?: boolean; - nextCursor?: string; - organizations: Organization[]; - totalCount?: number; -} - /** * Configuration for the getAllOrganizations request */ @@ -120,7 +110,7 @@ const getAllOrganizations = async ({ recursive = false, fetcher, ...requestConfig -}: GetAllOrganizationsConfig): Promise => { +}: GetAllOrganizationsConfig): Promise => { try { new URL(baseUrl); } catch (error) { @@ -150,9 +140,9 @@ const getAllOrganizations = async ({ ...requestConfig, method: 'GET', headers: { + ...requestConfig.headers, 'Content-Type': 'application/json', Accept: 'application/json', - ...requestConfig.headers, }, }; diff --git a/packages/javascript/src/api/getBrandingPreference.ts b/packages/javascript/src/api/getBrandingPreference.ts index 8a040394..a0f52c5b 100644 --- a/packages/javascript/src/api/getBrandingPreference.ts +++ b/packages/javascript/src/api/getBrandingPreference.ts @@ -134,7 +134,7 @@ const getBrandingPreference = async ({ ); const fetchFn = fetcher || fetch; - const resolvedUrl = `${baseUrl}/api/server/v1/branding-preference${ + const resolvedUrl = `${baseUrl}/api/server/v1/branding-preference/resolve${ queryParams.toString() ? `?${queryParams.toString()}` : '' }`; diff --git a/packages/javascript/src/api/getScim2Me.ts b/packages/javascript/src/api/getScim2Me.ts index 5cba4f2e..055881c5 100644 --- a/packages/javascript/src/api/getScim2Me.ts +++ b/packages/javascript/src/api/getScim2Me.ts @@ -18,6 +18,7 @@ import {User} from '../models/user'; import AsgardeoAPIError from '../errors/AsgardeoAPIError'; +import processUserUsername from '../utils/processUsername'; /** * Configuration for the getScim2Me request @@ -103,7 +104,7 @@ const getScim2Me = async ({url, baseUrl, fetcher, ...requestConfig}: GetScim2MeC } const fetchFn = fetcher || fetch; - const resolvedUrl: string = url ?? `${baseUrl}/scim2/Me` + const resolvedUrl: string = url ?? `${baseUrl}/scim2/Me`; const requestInit: RequestInit = { ...requestConfig, @@ -130,7 +131,9 @@ const getScim2Me = async ({url, baseUrl, fetcher, ...requestConfig}: GetScim2MeC ); } - return (await response.json()) as User; + const user = (await response.json()) as User; + + return processUserUsername(user); } catch (error) { if (error instanceof AsgardeoAPIError) { throw error; diff --git a/packages/javascript/src/api/initializeEmbeddedSignInFlow.ts b/packages/javascript/src/api/initializeEmbeddedSignInFlow.ts index d2300f29..cfe26aaa 100644 --- a/packages/javascript/src/api/initializeEmbeddedSignInFlow.ts +++ b/packages/javascript/src/api/initializeEmbeddedSignInFlow.ts @@ -77,9 +77,9 @@ const initializeEmbeddedSignInFlow = async ({ ...requestConfig, method: requestConfig.method || 'POST', headers: { + ...requestConfig.headers, 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json', - ...requestConfig.headers, }, body: searchParams.toString(), }); diff --git a/packages/javascript/src/i18n/en-US.ts b/packages/javascript/src/i18n/en-US.ts index c327ea5c..31242dda 100644 --- a/packages/javascript/src/i18n/en-US.ts +++ b/packages/javascript/src/i18n/en-US.ts @@ -88,8 +88,18 @@ const translations: I18nTranslations = { 'organization.switcher.members': 'members', 'organization.switcher.member': 'member', 'organization.switcher.create.organization': 'Create Organization', - 'organization.switcher.manage.organizations': 'Manage Organization', + 'organization.switcher.manage.organizations': 'Manage Organizations', 'organization.switcher.manage.button': 'Manage', + 'organization.switcher.organizations.title': 'Organizations', + 'organization.switcher.switch.button': 'Switch', + 'organization.switcher.no.access': 'No Access', + 'organization.switcher.status.label': 'Status:', + 'organization.switcher.showing.count': 'Showing {showing} of {total} organizations', + 'organization.switcher.refresh.button': 'Refresh', + 'organization.switcher.load.more': 'Load More Organizations', + 'organization.switcher.loading.more': 'Loading...', + 'organization.switcher.no.organizations': 'No organizations found', + 'organization.switcher.error.prefix': 'Error:', 'organization.profile.title': 'Organization Profile', 'organization.profile.loading': 'Loading organization...', 'organization.profile.error': 'Failed to load organization', diff --git a/packages/javascript/src/index.ts b/packages/javascript/src/index.ts index 9228d8de..09bd1fb5 100644 --- a/packages/javascript/src/index.ts +++ b/packages/javascript/src/index.ts @@ -27,11 +27,7 @@ export {default as executeEmbeddedSignUpFlow} from './api/executeEmbeddedSignUpF export {default as getUserInfo} from './api/getUserInfo'; export {default as getScim2Me, GetScim2MeConfig} from './api/getScim2Me'; export {default as getSchemas, GetSchemasConfig} from './api/getSchemas'; -export { - default as getAllOrganizations, - PaginatedOrganizationsResponse, - GetAllOrganizationsConfig, -} from './api/getAllOrganizations'; +export {default as getAllOrganizations, GetAllOrganizationsConfig} from './api/getAllOrganizations'; export { default as createOrganization, CreateOrganizationPayload, @@ -53,6 +49,7 @@ export {default as AsgardeoAPIError} from './errors/AsgardeoAPIError'; export {default as AsgardeoRuntimeError} from './errors/AsgardeoRuntimeError'; export {AsgardeoAuthException} from './errors/exception'; +export {AllOrganizationsApiResponse} from './models/organization'; export { EmbeddedSignInFlowInitiateResponse, EmbeddedSignInFlowStatus, @@ -115,6 +112,7 @@ export {default as AsgardeoJavaScriptClient} from './AsgardeoJavaScriptClient'; export {default as createTheme} from './theme/createTheme'; export {ThemeColors, ThemeConfig, Theme, ThemeMode, ThemeDetection} from './theme/types'; +export {default as processUsername} from './utils/processUsername'; export {default as deepMerge} from './utils/deepMerge'; export {default as deriveOrganizationHandleFromBaseUrl} from './utils/deriveOrganizationHandleFromBaseUrl'; export {default as extractUserClaimsFromIdToken} from './utils/extractUserClaimsFromIdToken'; @@ -132,5 +130,6 @@ export {default as resolveFieldType} from './utils/resolveFieldType'; export {default as resolveFieldName} from './utils/resolveFieldName'; export {default as processOpenIDScopes} from './utils/processOpenIDScopes'; export {default as withVendorCSSClassPrefix} from './utils/withVendorCSSClassPrefix'; +export {default as transformBrandingPreferenceToTheme} from './utils/transformBrandingPreferenceToTheme'; export {default as StorageManager} from './StorageManager'; diff --git a/packages/javascript/src/models/client.ts b/packages/javascript/src/models/client.ts index d78a7301..722714fa 100644 --- a/packages/javascript/src/models/client.ts +++ b/packages/javascript/src/models/client.ts @@ -16,10 +16,16 @@ * under the License. */ -import {EmbeddedFlowExecuteRequestConfig, EmbeddedFlowExecuteRequestPayload, EmbeddedFlowExecuteResponse} from './embedded-flow'; +import {AllOrganizationsApiResponse} from '../models/organization'; +import { + EmbeddedFlowExecuteRequestConfig, + EmbeddedFlowExecuteRequestPayload, + EmbeddedFlowExecuteResponse, +} from './embedded-flow'; import {EmbeddedSignInFlowHandleRequestPayload} from './embedded-signin-flow'; import {Organization} from './organization'; import {User, UserProfile} from './user'; +import {TokenResponse} from './token'; export type SignInOptions = Record; export type SignOutOptions = Record; @@ -37,11 +43,13 @@ export type SignUpOptions = Record; */ export interface AsgardeoClient { /** - * Gets the users associated organizations. + * Gets the current signed-in user's associated organizations. * * @returns Associated organizations. */ - getOrganizations(options?: any): Promise; + getMyOrganizations(options?: any, sessionId?: string): Promise; + + getAllOrganizations(options?: any, sessionId?: string): Promise; /** * Gets the current organization of the user. @@ -55,7 +63,7 @@ export interface AsgardeoClient { * @param organization - The organization to switch to. * @returns A promise that resolves when the switch is complete. */ - switchOrganization(organization: Organization): Promise; + switchOrganization(organization: Organization, sessionId?: string): Promise ; getConfiguration(): T; @@ -147,7 +155,11 @@ export interface AsgardeoClient { * @param afterSignOut - Callback function to be executed after sign-out is complete. * @returns A promise that resolves to true if sign-out is successful */ - signOut(options?: SignOutOptions, sessionId?: string, afterSignOut?: (afterSignOutUrl: string) => void): Promise; + signOut( + options?: SignOutOptions, + sessionId?: string, + afterSignOut?: (afterSignOutUrl: string) => void, + ): Promise; /** * Initiates a redirection-based sign-up process for the user. diff --git a/packages/javascript/src/models/i18n.ts b/packages/javascript/src/models/i18n.ts index 1df78784..dbdf9d27 100644 --- a/packages/javascript/src/models/i18n.ts +++ b/packages/javascript/src/models/i18n.ts @@ -88,6 +88,16 @@ export interface I18nTranslations { 'organization.switcher.create.organization': string; 'organization.switcher.manage.organizations': string; 'organization.switcher.manage.button': string; + 'organization.switcher.organizations.title': string; + 'organization.switcher.switch.button': string; + 'organization.switcher.no.access': string; + 'organization.switcher.status.label': string; + 'organization.switcher.showing.count': string; + 'organization.switcher.refresh.button': string; + 'organization.switcher.load.more': string; + 'organization.switcher.loading.more': string; + 'organization.switcher.no.organizations': string; + 'organization.switcher.error.prefix': string; 'organization.profile.title': string; 'organization.profile.loading': string; 'organization.profile.error': string; diff --git a/packages/javascript/src/models/organization.ts b/packages/javascript/src/models/organization.ts index b19a4ac9..fd166ca3 100644 --- a/packages/javascript/src/models/organization.ts +++ b/packages/javascript/src/models/organization.ts @@ -23,3 +23,13 @@ export interface Organization { ref?: string; status?: string; } + +/** + * Interface for paginated organization response. + */ +export interface AllOrganizationsApiResponse { + hasMore?: boolean; + nextCursor?: string; + organizations: Organization[]; + totalCount?: number; +} diff --git a/packages/javascript/src/theme/createTheme.test.ts b/packages/javascript/src/theme/createTheme.test.ts new file mode 100644 index 00000000..5031580b --- /dev/null +++ b/packages/javascript/src/theme/createTheme.test.ts @@ -0,0 +1,98 @@ +/** + * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import {describe, it, expect} from 'vitest'; +import createTheme from './createTheme'; + +describe('createTheme', () => { + it('should include vars property with CSS variable references', () => { + const theme = createTheme(); + + expect(theme.vars).toBeDefined(); + expect(theme.vars.colors.primary.main).toBe('var(--asgardeo-color-primary-main)'); + expect(theme.vars.colors.primary.contrastText).toBe('var(--asgardeo-color-primary-contrastText)'); + expect(theme.vars.spacing.unit).toBe('var(--asgardeo-spacing-unit)'); + expect(theme.vars.borderRadius.small).toBe('var(--asgardeo-border-radius-small)'); + expect(theme.vars.shadows.medium).toBe('var(--asgardeo-shadow-medium)'); + }); + + it('should have matching structure between cssVariables and vars', () => { + const theme = createTheme(); + + // Check that cssVariables has corresponding entries for vars + expect(theme.cssVariables['--asgardeo-color-primary-main']).toBeDefined(); + expect(theme.cssVariables['--asgardeo-spacing-unit']).toBeDefined(); + expect(theme.cssVariables['--asgardeo-border-radius-small']).toBeDefined(); + expect(theme.cssVariables['--asgardeo-shadow-medium']).toBeDefined(); + }); + + it('should work with custom theme configurations', () => { + const customTheme = createTheme({ + colors: { + primary: { + main: '#custom-color', + }, + }, + }); + + // vars should still reference CSS variables, not the actual values + expect(customTheme.vars.colors.primary.main).toBe('var(--asgardeo-color-primary-main)'); + // but cssVariables should have the custom value + expect(customTheme.cssVariables['--asgardeo-color-primary-main']).toBe('#custom-color'); + }); + + it('should work with dark theme', () => { + const darkTheme = createTheme({}, true); + + expect(darkTheme.vars.colors.primary.main).toBe('var(--asgardeo-color-primary-main)'); + expect(darkTheme.vars.colors.background.surface).toBe('var(--asgardeo-color-background-surface)'); + + // Should have dark theme values in cssVariables + expect(darkTheme.cssVariables['--asgardeo-color-background-surface']).toBe('#121212'); + }); + + it('should use custom CSS variable prefix when provided', () => { + const customTheme = createTheme({ + cssVarPrefix: 'custom-app', + colors: { + primary: { + main: '#custom-color', + }, + }, + }); + + // Should use custom prefix in CSS variables + expect(customTheme.cssVariables['--custom-app-color-primary-main']).toBe('#custom-color'); + expect(customTheme.cssVariables['--custom-app-spacing-unit']).toBe('8px'); + + // Should use custom prefix in vars + expect(customTheme.vars.colors.primary.main).toBe('var(--custom-app-color-primary-main)'); + expect(customTheme.vars.spacing.unit).toBe('var(--custom-app-spacing-unit)'); + + // Should not have old asgardeo prefixed variables + expect(customTheme.cssVariables['--asgardeo-color-primary-main']).toBeUndefined(); + }); + + it('should use VendorConstants.VENDOR_PREFIX as default prefix', () => { + const theme = createTheme(); + + // Should use default prefix from VendorConstants + expect(theme.cssVariables['--asgardeo-color-primary-main']).toBeDefined(); + expect(theme.vars.colors.primary.main).toBe('var(--asgardeo-color-primary-main)'); + }); +}); diff --git a/packages/javascript/src/theme/createTheme.ts b/packages/javascript/src/theme/createTheme.ts index 53fb38d9..59bc9393 100644 --- a/packages/javascript/src/theme/createTheme.ts +++ b/packages/javascript/src/theme/createTheme.ts @@ -4,7 +4,23 @@ * 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 + * You may obtain a cop // Shadows + if (theme.shadows?.small) { + cssVars[`--${prefix}-shadow-small`] = theme.shadows.small; + } + if (theme.shadows?.medium) { + cssVars[`--${prefix}-shadow-medium`] = theme.shadows.medium; + } + if (theme.shadows?.large) { + cssVars[`--${prefix}-shadow-large`] = theme.shadows.large; + } + + // Typography - Font Family + if (theme.typography?.fontFamily) { + cssVars[`--${prefix}-typography-fontFamily`] = theme.typography.fontFamily; + } + + // Typography - Font Sizesense at * * http://www.apache.org/licenses/LICENSE-2.0 * @@ -16,11 +32,25 @@ * under the License. */ -import {Theme, ThemeConfig} from './types'; +import {Theme, ThemeConfig, ThemeVars} from './types'; import {RecursivePartial} from '../models/utility-types'; +import VendorConstants from '../constants/VendorConstants'; const lightTheme: ThemeConfig = { colors: { + action: { + active: 'rgba(0, 0, 0, 0.54)', + hover: 'rgba(0, 0, 0, 0.04)', + hoverOpacity: 0.04, + selected: 'rgba(0, 0, 0, 0.08)', + selectedOpacity: 0.08, + disabled: 'rgba(0, 0, 0, 0.26)', + disabledBackground: 'rgba(0, 0, 0, 0.12)', + disabledOpacity: 0.38, + focus: 'rgba(0, 0, 0, 0.12)', + focusOpacity: 0.12, + activatedOpacity: 0.12, + }, primary: { main: '#1a73e8', contrastText: '#ffffff', @@ -67,10 +97,50 @@ const lightTheme: ThemeConfig = { medium: '0 4px 16px rgba(0, 0, 0, 0.15)', large: '0 8px 32px rgba(0, 0, 0, 0.2)', }, + typography: { + fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif', + fontSizes: { + xs: '0.75rem', // 12px + sm: '0.875rem', // 14px + md: '1rem', // 16px + lg: '1.125rem', // 18px + xl: '1.25rem', // 20px + '2xl': '1.5rem', // 24px + '3xl': '2.125rem', // 34px + }, + fontWeights: { + normal: 400, + medium: 500, + semibold: 600, + bold: 700, + }, + lineHeights: { + tight: 1.2, + normal: 1.4, + relaxed: 1.6, + }, + }, + images: { + favicon: {}, + logo: {}, + }, }; const darkTheme: ThemeConfig = { colors: { + action: { + active: 'rgba(255, 255, 255, 0.70)', + hover: 'rgba(255, 255, 255, 0.04)', + hoverOpacity: 0.04, + selected: 'rgba(255, 255, 255, 0.08)', + selectedOpacity: 0.08, + disabled: 'rgba(255, 255, 255, 0.26)', + disabledBackground: 'rgba(255, 255, 255, 0.12)', + disabledOpacity: 0.38, + focus: 'rgba(255, 255, 255, 0.12)', + focusOpacity: 0.12, + activatedOpacity: 0.12, + }, primary: { main: '#1a73e8', contrastText: '#ffffff', @@ -117,53 +187,355 @@ const darkTheme: ThemeConfig = { medium: '0 4px 16px rgba(0, 0, 0, 0.4)', large: '0 8px 32px rgba(0, 0, 0, 0.5)', }, + typography: { + fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif', + fontSizes: { + xs: '0.75rem', // 12px + sm: '0.875rem', // 14px + md: '1rem', // 16px + lg: '1.125rem', // 18px + xl: '1.25rem', // 20px + '2xl': '1.5rem', // 24px + '3xl': '2.125rem', // 34px + }, + fontWeights: { + normal: 400, + medium: 500, + semibold: 600, + bold: 700, + }, + lineHeights: { + tight: 1.2, + normal: 1.4, + relaxed: 1.6, + }, + }, + images: { + favicon: {}, + logo: {}, + }, }; -const toCssVariables = (theme: RecursivePartial): Record => { +const toCssVariables = (theme: ThemeConfig): Record => { const cssVars: Record = {}; + const prefix = theme.cssVarPrefix || VendorConstants.VENDOR_PREFIX; + + // Colors - Action + if (theme.colors?.action?.active) { + cssVars[`--${prefix}-color-action-active`] = theme.colors.action.active; + } + if (theme.colors?.action?.hover) { + cssVars[`--${prefix}-color-action-hover`] = theme.colors.action.hover; + } + if (theme.colors?.action?.hoverOpacity !== undefined) { + cssVars[`--${prefix}-color-action-hoverOpacity`] = theme.colors.action.hoverOpacity.toString(); + } + if (theme.colors?.action?.selected) { + cssVars[`--${prefix}-color-action-selected`] = theme.colors.action.selected; + } + if (theme.colors?.action?.selectedOpacity !== undefined) { + cssVars[`--${prefix}-color-action-selectedOpacity`] = theme.colors.action.selectedOpacity.toString(); + } + if (theme.colors?.action?.disabled) { + cssVars[`--${prefix}-color-action-disabled`] = theme.colors.action.disabled; + } + if (theme.colors?.action?.disabledBackground) { + cssVars[`--${prefix}-color-action-disabledBackground`] = theme.colors.action.disabledBackground; + } + if (theme.colors?.action?.disabledOpacity !== undefined) { + cssVars[`--${prefix}-color-action-disabledOpacity`] = theme.colors.action.disabledOpacity.toString(); + } + if (theme.colors?.action?.focus) { + cssVars[`--${prefix}-color-action-focus`] = theme.colors.action.focus; + } + if (theme.colors?.action?.focusOpacity !== undefined) { + cssVars[`--${prefix}-color-action-focusOpacity`] = theme.colors.action.focusOpacity.toString(); + } + if (theme.colors?.action?.activatedOpacity !== undefined) { + cssVars[`--${prefix}-color-action-activatedOpacity`] = theme.colors.action.activatedOpacity.toString(); + } + + // Colors - Primary + if (theme.colors?.primary?.main) { + cssVars[`--${prefix}-color-primary-main`] = theme.colors.primary.main; + } + if (theme.colors?.primary?.contrastText) { + cssVars[`--${prefix}-color-primary-contrastText`] = theme.colors.primary.contrastText; + } - // Colors - cssVars['--asgardeo-color-primary-main'] = theme.colors.primary.main; - cssVars['--asgardeo-color-primary-contrastText'] = theme.colors.primary.contrastText; - cssVars['--asgardeo-color-secondary-main'] = theme.colors.secondary.main; - cssVars['--asgardeo-color-secondary-contrastText'] = theme.colors.secondary.contrastText; - cssVars['--asgardeo-color-background-surface'] = theme.colors.background.surface; - cssVars['--asgardeo-color-background-disabled'] = theme.colors.background.disabled; - cssVars['--asgardeo-color-background-body-main'] = theme.colors.background.body.main; - cssVars['--asgardeo-color-error-main'] = theme.colors.error.main; - cssVars['--asgardeo-color-error-contrastText'] = theme.colors.error.contrastText; - cssVars['--asgardeo-color-success-main'] = theme.colors.success.main; - cssVars['--asgardeo-color-success-contrastText'] = theme.colors.success.contrastText; - cssVars['--asgardeo-color-warning-main'] = theme.colors.warning.main; - cssVars['--asgardeo-color-warning-contrastText'] = theme.colors.warning.contrastText; - cssVars['--asgardeo-color-text-primary'] = theme.colors.text.primary; - cssVars['--asgardeo-color-text-secondary'] = theme.colors.text.secondary; - cssVars['--asgardeo-color-border'] = theme.colors.border; + // Colors - Secondary + if (theme.colors?.secondary?.main) { + cssVars[`--${prefix}-color-secondary-main`] = theme.colors.secondary.main; + } + if (theme.colors?.secondary?.contrastText) { + cssVars[`--${prefix}-color-secondary-contrastText`] = theme.colors.secondary.contrastText; + } + + // Colors - Background + if (theme.colors?.background?.surface) { + cssVars[`--${prefix}-color-background-surface`] = theme.colors.background.surface; + } + if (theme.colors?.background?.disabled) { + cssVars[`--${prefix}-color-background-disabled`] = theme.colors.background.disabled; + } + if (theme.colors?.background?.body?.main) { + cssVars[`--${prefix}-color-background-body-main`] = theme.colors.background.body.main; + } + + // Colors - Error + if (theme.colors?.error?.main) { + cssVars[`--${prefix}-color-error-main`] = theme.colors.error.main; + } + if (theme.colors?.error?.contrastText) { + cssVars[`--${prefix}-color-error-contrastText`] = theme.colors.error.contrastText; + } + + // Colors - Success + if (theme.colors?.success?.main) { + cssVars[`--${prefix}-color-success-main`] = theme.colors.success.main; + } + if (theme.colors?.success?.contrastText) { + cssVars[`--${prefix}-color-success-contrastText`] = theme.colors.success.contrastText; + } + + // Colors - Warning + if (theme.colors?.warning?.main) { + cssVars[`--${prefix}-color-warning-main`] = theme.colors.warning.main; + } + if (theme.colors?.warning?.contrastText) { + cssVars[`--${prefix}-color-warning-contrastText`] = theme.colors.warning.contrastText; + } + + // Colors - Text + if (theme.colors?.text?.primary) { + cssVars[`--${prefix}-color-text-primary`] = theme.colors.text.primary; + } + if (theme.colors?.text?.secondary) { + cssVars[`--${prefix}-color-text-secondary`] = theme.colors.text.secondary; + } + + // Colors - Border + if (theme.colors?.border) { + cssVars[`--${prefix}-color-border`] = theme.colors.border; + } // Spacing - cssVars['--asgardeo-spacing-unit'] = `${theme.spacing.unit}px`; + if (theme.spacing?.unit !== undefined) { + cssVars[`--${prefix}-spacing-unit`] = `${theme.spacing.unit}px`; + } // Border Radius - cssVars['--asgardeo-border-radius-small'] = theme.borderRadius.small; - cssVars['--asgardeo-border-radius-medium'] = theme.borderRadius.medium; - cssVars['--asgardeo-border-radius-large'] = theme.borderRadius.large; + if (theme.borderRadius?.small) { + cssVars[`--${prefix}-border-radius-small`] = theme.borderRadius.small; + } + if (theme.borderRadius?.medium) { + cssVars[`--${prefix}-border-radius-medium`] = theme.borderRadius.medium; + } + if (theme.borderRadius?.large) { + cssVars[`--${prefix}-border-radius-large`] = theme.borderRadius.large; + } // Shadows - cssVars['--asgardeo-shadow-small'] = theme.shadows.small; - cssVars['--asgardeo-shadow-medium'] = theme.shadows.medium; - cssVars['--asgardeo-shadow-large'] = theme.shadows.large; + if (theme.shadows?.small) { + cssVars[`--${prefix}-shadow-small`] = theme.shadows.small; + } + if (theme.shadows?.medium) { + cssVars[`--${prefix}-shadow-medium`] = theme.shadows.medium; + } + if (theme.shadows?.large) { + cssVars[`--${prefix}-shadow-large`] = theme.shadows.large; + } + + // Typography - Font Family + if (theme.typography?.fontFamily) { + cssVars[`--${prefix}-typography-fontFamily`] = theme.typography.fontFamily; + } + + // Typography - Font Sizes + if (theme.typography?.fontSizes?.xs) { + cssVars[`--${prefix}-typography-fontSize-xs`] = theme.typography.fontSizes.xs; + } + if (theme.typography?.fontSizes?.sm) { + cssVars[`--${prefix}-typography-fontSize-sm`] = theme.typography.fontSizes.sm; + } + if (theme.typography?.fontSizes?.md) { + cssVars[`--${prefix}-typography-fontSize-md`] = theme.typography.fontSizes.md; + } + if (theme.typography?.fontSizes?.lg) { + cssVars[`--${prefix}-typography-fontSize-lg`] = theme.typography.fontSizes.lg; + } + if (theme.typography?.fontSizes?.xl) { + cssVars[`--${prefix}-typography-fontSize-xl`] = theme.typography.fontSizes.xl; + } + if (theme.typography?.fontSizes?.['2xl']) { + cssVars[`--${prefix}-typography-fontSize-2xl`] = theme.typography.fontSizes['2xl']; + } + if (theme.typography?.fontSizes?.['3xl']) { + cssVars[`--${prefix}-typography-fontSize-3xl`] = theme.typography.fontSizes['3xl']; + } + + // Typography - Font Weights + if (theme.typography?.fontWeights?.normal !== undefined) { + cssVars[`--${prefix}-typography-fontWeight-normal`] = theme.typography.fontWeights.normal.toString(); + } + if (theme.typography?.fontWeights?.medium !== undefined) { + cssVars[`--${prefix}-typography-fontWeight-medium`] = theme.typography.fontWeights.medium.toString(); + } + if (theme.typography?.fontWeights?.semibold !== undefined) { + cssVars[`--${prefix}-typography-fontWeight-semibold`] = theme.typography.fontWeights.semibold.toString(); + } + if (theme.typography?.fontWeights?.bold !== undefined) { + cssVars[`--${prefix}-typography-fontWeight-bold`] = theme.typography.fontWeights.bold.toString(); + } + + // Typography - Line Heights + if (theme.typography?.lineHeights?.tight !== undefined) { + cssVars[`--${prefix}-typography-lineHeight-tight`] = theme.typography.lineHeights.tight.toString(); + } + if (theme.typography?.lineHeights?.normal !== undefined) { + cssVars[`--${prefix}-typography-lineHeight-normal`] = theme.typography.lineHeights.normal.toString(); + } + if (theme.typography?.lineHeights?.relaxed !== undefined) { + cssVars[`--${prefix}-typography-lineHeight-relaxed`] = theme.typography.lineHeights.relaxed.toString(); + } + + // Images + if (theme.images) { + Object.keys(theme.images).forEach(imageKey => { + const imageConfig = theme.images![imageKey]; + if (imageConfig?.url) { + cssVars[`--${prefix}-image-${imageKey}-url`] = imageConfig.url; + } + if (imageConfig?.title) { + cssVars[`--${prefix}-image-${imageKey}-title`] = imageConfig.title; + } + if (imageConfig?.alt) { + cssVars[`--${prefix}-image-${imageKey}-alt`] = imageConfig.alt; + } + }); + } return cssVars; }; +const toThemeVars = (theme: ThemeConfig): ThemeVars => { + const prefix = theme.cssVarPrefix || VendorConstants.VENDOR_PREFIX; + + const themeVars: ThemeVars = { + colors: { + action: { + active: `var(--${prefix}-color-action-active)`, + hover: `var(--${prefix}-color-action-hover)`, + hoverOpacity: `var(--${prefix}-color-action-hoverOpacity)`, + selected: `var(--${prefix}-color-action-selected)`, + selectedOpacity: `var(--${prefix}-color-action-selectedOpacity)`, + disabled: `var(--${prefix}-color-action-disabled)`, + disabledBackground: `var(--${prefix}-color-action-disabledBackground)`, + disabledOpacity: `var(--${prefix}-color-action-disabledOpacity)`, + focus: `var(--${prefix}-color-action-focus)`, + focusOpacity: `var(--${prefix}-color-action-focusOpacity)`, + activatedOpacity: `var(--${prefix}-color-action-activatedOpacity)`, + }, + primary: { + main: `var(--${prefix}-color-primary-main)`, + contrastText: `var(--${prefix}-color-primary-contrastText)`, + }, + secondary: { + main: `var(--${prefix}-color-secondary-main)`, + contrastText: `var(--${prefix}-color-secondary-contrastText)`, + }, + background: { + surface: `var(--${prefix}-color-background-surface)`, + disabled: `var(--${prefix}-color-background-disabled)`, + body: { + main: `var(--${prefix}-color-background-body-main)`, + }, + }, + error: { + main: `var(--${prefix}-color-error-main)`, + contrastText: `var(--${prefix}-color-error-contrastText)`, + }, + success: { + main: `var(--${prefix}-color-success-main)`, + contrastText: `var(--${prefix}-color-success-contrastText)`, + }, + warning: { + main: `var(--${prefix}-color-warning-main)`, + contrastText: `var(--${prefix}-color-warning-contrastText)`, + }, + text: { + primary: `var(--${prefix}-color-text-primary)`, + secondary: `var(--${prefix}-color-text-secondary)`, + }, + border: `var(--${prefix}-color-border)`, + }, + spacing: { + unit: `var(--${prefix}-spacing-unit)`, + }, + borderRadius: { + small: `var(--${prefix}-border-radius-small)`, + medium: `var(--${prefix}-border-radius-medium)`, + large: `var(--${prefix}-border-radius-large)`, + }, + shadows: { + small: `var(--${prefix}-shadow-small)`, + medium: `var(--${prefix}-shadow-medium)`, + large: `var(--${prefix}-shadow-large)`, + }, + typography: { + fontFamily: `var(--${prefix}-typography-fontFamily)`, + fontSizes: { + xs: `var(--${prefix}-typography-fontSize-xs)`, + sm: `var(--${prefix}-typography-fontSize-sm)`, + md: `var(--${prefix}-typography-fontSize-md)`, + lg: `var(--${prefix}-typography-fontSize-lg)`, + xl: `var(--${prefix}-typography-fontSize-xl)`, + '2xl': `var(--${prefix}-typography-fontSize-2xl)`, + '3xl': `var(--${prefix}-typography-fontSize-3xl)`, + }, + fontWeights: { + normal: `var(--${prefix}-typography-fontWeight-normal)`, + medium: `var(--${prefix}-typography-fontWeight-medium)`, + semibold: `var(--${prefix}-typography-fontWeight-semibold)`, + bold: `var(--${prefix}-typography-fontWeight-bold)`, + }, + lineHeights: { + tight: `var(--${prefix}-typography-lineHeight-tight)`, + normal: `var(--${prefix}-typography-lineHeight-normal)`, + relaxed: `var(--${prefix}-typography-lineHeight-relaxed)`, + }, + }, + }; + + // Add images if they exist + if (theme.images) { + themeVars.images = {}; + Object.keys(theme.images).forEach(imageKey => { + const imageConfig = theme.images![imageKey]; + themeVars.images![imageKey] = { + url: imageConfig?.url ? `var(--${prefix}-image-${imageKey}-url)` : undefined, + title: imageConfig?.title ? `var(--${prefix}-image-${imageKey}-title)` : undefined, + alt: imageConfig?.alt ? `var(--${prefix}-image-${imageKey}-alt)` : undefined, + }; + }); + } + + return themeVars; +}; + const createTheme = (config: RecursivePartial = {}, isDark = false): Theme => { const baseTheme = isDark ? darkTheme : lightTheme; + const mergedConfig = { ...baseTheme, ...config, colors: { ...baseTheme.colors, ...config.colors, + action: { + ...baseTheme.colors.action, + ...(config.colors?.action || {}), + }, secondary: { ...baseTheme.colors.secondary, ...(config.colors?.secondary || {}), @@ -181,11 +553,32 @@ const createTheme = (config: RecursivePartial = {}, isDark = false) ...baseTheme.shadows, ...config.shadows, }, + typography: { + ...baseTheme.typography, + ...config.typography, + fontSizes: { + ...baseTheme.typography.fontSizes, + ...(config.typography?.fontSizes || {}), + }, + fontWeights: { + ...baseTheme.typography.fontWeights, + ...(config.typography?.fontWeights || {}), + }, + lineHeights: { + ...baseTheme.typography.lineHeights, + ...(config.typography?.lineHeights || {}), + }, + }, + images: { + ...baseTheme.images, + ...config.images, + }, } as ThemeConfig; return { ...mergedConfig, cssVariables: toCssVariables(mergedConfig), + vars: toThemeVars(mergedConfig), }; }; diff --git a/packages/javascript/src/theme/types.ts b/packages/javascript/src/theme/types.ts index db009a03..9fee3ad6 100644 --- a/packages/javascript/src/theme/types.ts +++ b/packages/javascript/src/theme/types.ts @@ -16,7 +16,44 @@ * under the License. */ +export interface ThemeTypography { + fontFamily: string; + fontSizes: { + xs: string; + sm: string; + md: string; + lg: string; + xl: string; + '2xl': string; + '3xl': string; + }; + fontWeights: { + normal: number; + medium: number; + semibold: number; + bold: number; + }; + lineHeights: { + tight: number; + normal: number; + relaxed: number; + }; +} + export interface ThemeColors { + action: { + active: string; + hover: string; + hoverOpacity: number; + selected: string; + selectedOpacity: number; + disabled: string; + disabledBackground: string; + disabledOpacity: number; + focus: string; + focusOpacity: number; + activatedOpacity: number; + }; background: { body: { main: string; @@ -66,10 +103,148 @@ export interface ThemeConfig { spacing: { unit: number; }; + typography: { + fontFamily: string; + fontSizes: { + xs: string; + sm: string; + md: string; + lg: string; + xl: string; + '2xl': string; + '3xl': string; + }; + fontWeights: { + normal: number; + medium: number; + semibold: number; + bold: number; + }; + lineHeights: { + tight: number; + normal: number; + relaxed: number; + }; + }; + /** + * Image assets configuration + */ + images?: ThemeImages; + /** + * The prefix used for CSS variables. + * @default 'asgardeo' (from VendorConstants.VENDOR_PREFIX) + */ + cssVarPrefix?: string; +} + +export interface ThemeVars { + colors: { + action: { + active: string; + hover: string; + hoverOpacity: string; + selected: string; + selectedOpacity: string; + disabled: string; + disabledBackground: string; + disabledOpacity: string; + focus: string; + focusOpacity: string; + activatedOpacity: string; + }; + primary: { + main: string; + contrastText: string; + }; + secondary: { + main: string; + contrastText: string; + }; + background: { + surface: string; + disabled: string; + body: { + main: string; + }; + }; + error: { + main: string; + contrastText: string; + }; + success: { + main: string; + contrastText: string; + }; + warning: { + main: string; + contrastText: string; + }; + text: { + primary: string; + secondary: string; + }; + border: string; + }; + spacing: { + unit: string; + }; + borderRadius: { + small: string; + medium: string; + large: string; + }; + shadows: { + small: string; + medium: string; + large: string; + }; + typography: { + fontFamily: string; + fontSizes: { + xs: string; + sm: string; + md: string; + lg: string; + xl: string; + '2xl': string; + '3xl': string; + }; + fontWeights: { + normal: string; + medium: string; + semibold: string; + bold: string; + }; + lineHeights: { + tight: string; + normal: string; + relaxed: string; + }; + }; + images?: { + favicon?: { + url?: string; + title?: string; + alt?: string; + }; + logo?: { + url?: string; + title?: string; + alt?: string; + }; + [key: string]: + | { + url?: string; + title?: string; + alt?: string; + } + | undefined; + }; } export interface Theme extends ThemeConfig { cssVariables: Record; + vars: ThemeVars; } export type ThemeMode = 'light' | 'dark' | 'system' | 'class'; @@ -86,3 +261,33 @@ export interface ThemeDetection { */ lightClass?: string; } + +export interface ThemeImage { + /** + * The URL of the image + */ + url?: string; + /** + * The title/alt text for the image + */ + title?: string; + /** + * Alternative text for accessibility + */ + alt?: string; +} + +export interface ThemeImages { + /** + * Favicon configuration + */ + favicon?: ThemeImage; + /** + * Logo configuration + */ + logo?: ThemeImage; + /** + * Allow for additional custom images + */ + [key: string]: ThemeImage | undefined; +} diff --git a/packages/javascript/src/utils/__tests__/processUsername.test.ts b/packages/javascript/src/utils/__tests__/processUsername.test.ts new file mode 100644 index 00000000..aafb12fe --- /dev/null +++ b/packages/javascript/src/utils/__tests__/processUsername.test.ts @@ -0,0 +1,178 @@ +/** + * 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 processUsername, {removeUserstorePrefixp} from '../processUsername'; + +describe('processUsername', () => { + describe('removeUserstorePrefix', () => { + it('should remove DEFAULT/ prefix from username', () => { + const result = removeUserstorePrefix('DEFAULT/john.doe'); + expect(result).toBe('john.doe'); + }); + + it('should remove ASGARDEO_USER/ prefix from username', () => { + const result = removeUserstorePrefix('ASGARDEO_USER/jane.doe'); + expect(result).toBe('jane.doe'); + }); + + it('should remove PRIMARY/ prefix from username', () => { + const result = removeUserstorePrefix('PRIMARY/admin'); + expect(result).toBe('admin'); + }); + + it('should remove custom userstore prefix from username', () => { + const result = removeUserstorePrefix('CUSTOM_STORE/user.name'); + expect(result).toBe('user.name'); + }); + + it('should return original username if no userstore prefix exists', () => { + const result = removeUserstorePrefix('jane.doe'); + expect(result).toBe('jane.doe'); + }); + + it('should handle empty string', () => { + const result = removeUserstorePrefix(''); + expect(result).toBe(''); + }); + + it('should handle undefined input', () => { + const result = removeUserstorePrefix(undefined); + expect(result).toBe(''); + }); + + it('should handle username with only userstore prefix', () => { + const result = removeUserstorePrefix('DEFAULT/'); + expect(result).toBe(''); + }); + + it('should not remove lowercase prefixes', () => { + const result = removeUserstorePrefix('default/user'); + expect(result).toBe('default/user'); + }); + + it('should not remove mixed case prefixes', () => { + const result = removeUserstorePrefix('Default/user'); + expect(result).toBe('Default/user'); + }); + + it('should not remove if prefix contains invalid characters', () => { + const result = removeUserstorePrefix('DEFAULT-STORE/user'); + expect(result).toBe('DEFAULT-STORE/user'); + }); + + it('should only remove the first occurrence of userstore prefix', () => { + const result = removeUserstorePrefix('DEFAULT/DEFAULT/user'); + expect(result).toBe('DEFAULT/user'); + }); + + it('should handle userstore prefix with numbers', () => { + const result = removeUserstorePrefix('STORE123/user'); + expect(result).toBe('user'); + }); + }); + + describe('processUserUsername', () => { + it('should process DEFAULT/ username in user object', () => { + const user = { + username: 'DEFAULT/john.doe', + email: 'john@example.com', + givenName: 'John', + }; + + const result = processUserUsername(user); + + expect(result.username).toBe('john.doe'); + expect(result.email).toBe('john@example.com'); + expect(result.givenName).toBe('John'); + }); + + it('should process ASGARDEO_USER/ username in user object', () => { + const user = { + username: 'ASGARDEO_USER/jane.doe', + email: 'jane@example.com', + givenName: 'Jane', + }; + + const result = processUserUsername(user); + + expect(result.username).toBe('jane.doe'); + expect(result.email).toBe('jane@example.com'); + expect(result.givenName).toBe('Jane'); + }); + + it('should process PRIMARY/ username in user object', () => { + const user = { + username: 'PRIMARY/admin', + email: 'admin@example.com', + givenName: 'Admin', + }; + + const result = processUserUsername(user); + + expect(result.username).toBe('admin'); + expect(result.email).toBe('admin@example.com'); + expect(result.givenName).toBe('Admin'); + }); + + it('should handle user object without username', () => { + const user = { + email: 'john@example.com', + givenName: 'John', + }; + + const result = processUserUsername(user); + + expect(result).toEqual(user); + }); + + it('should handle user object with empty username', () => { + const user = { + username: '', + email: 'john@example.com', + }; + + const result = processUserUsername(user); + + expect(result.username).toBe(''); + expect(result.email).toBe('john@example.com'); + }); + + it('should handle null/undefined user object', () => { + expect(processUserUsername(null as any)).toBe(null); + expect(processUserUsername(undefined as any)).toBe(undefined); + }); + + it('should preserve other properties in user object', () => { + const user = { + username: 'DEFAULT/jane.doe', + email: 'jane@example.com', + givenName: 'Jane', + familyName: 'Doe', + customProperty: 'customValue', + }; + + const result = processUserUsername(user); + + expect(result.username).toBe('jane.doe'); + expect(result.email).toBe('jane@example.com'); + expect(result.givenName).toBe('Jane'); + expect(result.familyName).toBe('Doe'); + expect((result as any).customProperty).toBe('customValue'); + }); + }); +}); diff --git a/packages/javascript/src/utils/__tests__/transformBrandingPreferenceToTheme.test.ts b/packages/javascript/src/utils/__tests__/transformBrandingPreferenceToTheme.test.ts new file mode 100644 index 00000000..7973187c --- /dev/null +++ b/packages/javascript/src/utils/__tests__/transformBrandingPreferenceToTheme.test.ts @@ -0,0 +1,119 @@ +/** + * Test example for transformBrandingPreferenceToTheme with images + */ +import {transformBrandingPreferenceToTheme} from '../transformBrandingPreferenceToTheme'; +import {BrandingPreference} from '../../models/branding-preference'; + +// Example branding preference with images +const mockBrandingPreference: BrandingPreference = { + type: 'ORG', + name: 'dxlab', + locale: 'en-US', + preference: { + theme: { + activeTheme: 'LIGHT', + LIGHT: { + images: { + favicon: { + imgURL: 'https://example.com/favicon.ico', + title: 'My App Favicon', + altText: 'Application Icon', + }, + logo: { + imgURL: 'https://example.com/logo.png', + title: 'Company Logo', + altText: 'Company Brand Logo', + }, + }, + colors: { + primary: { + main: '#FF7300', + contrastText: '#ffffff', + }, + secondary: { + main: '#E0E1E2', + contrastText: '#000000', + }, + background: { + surface: { + main: '#ffffff', + }, + body: { + main: '#fbfbfb', + }, + }, + text: { + primary: '#000000de', + secondary: '#00000066', + }, + }, + }, + DARK: { + images: { + favicon: { + imgURL: 'https://example.com/favicon-dark.ico', + title: 'My App Favicon Dark', + altText: 'Application Icon Dark', + }, + logo: { + imgURL: 'https://example.com/logo-dark.png', + title: 'Company Logo Dark', + altText: 'Company Brand Logo Dark', + }, + }, + colors: { + primary: { + main: '#FF7300', + contrastText: '#ffffff', + }, + background: { + surface: { + main: '#242627', + }, + body: { + main: '#17191a', + }, + }, + text: { + primary: '#EBEBEF', + secondary: '#B9B9C6', + }, + }, + }, + }, + }, +}; + +// Transform the branding preference to theme +const lightTheme = transformBrandingPreferenceToTheme(mockBrandingPreference, 'light'); +const darkTheme = transformBrandingPreferenceToTheme(mockBrandingPreference, 'dark'); + +console.log('=== LIGHT THEME ==='); +console.log('Images:', lightTheme.images); +console.log( + 'CSS Variables (images only):', + Object.keys(lightTheme.cssVariables) + .filter(key => key.includes('image')) + .reduce((obj, key) => { + obj[key] = lightTheme.cssVariables[key]; + return obj; + }, {} as Record), +); + +console.log('\n=== DARK THEME ==='); +console.log('Images:', darkTheme.images); +console.log( + 'CSS Variables (images only):', + Object.keys(darkTheme.cssVariables) + .filter(key => key.includes('image')) + .reduce((obj, key) => { + obj[key] = darkTheme.cssVariables[key]; + return obj; + }, {} as Record), +); + +console.log('\n=== THEME VARIABLES ==='); +console.log('Light theme vars.images:', lightTheme.vars.images); +console.log('Dark theme vars.images:', darkTheme.vars.images); + +export {lightTheme, darkTheme}; diff --git a/packages/javascript/src/utils/processUsername.ts b/packages/javascript/src/utils/processUsername.ts new file mode 100644 index 00000000..8826efc2 --- /dev/null +++ b/packages/javascript/src/utils/processUsername.ts @@ -0,0 +1,108 @@ +/** + * 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. + */ + +/** + * Regular expression to match userstore prefixes in usernames. + * Matches patterns like "DEFAULT/", "ASGARDEO_USER/", "PRIMARY/", etc. + * The pattern matches any uppercase letters, numbers, and underscores followed by a forward slash. + */ +const USERSTORE_PREFIX_REGEX = /^[A-Z_][A-Z0-9_]*\//; + +/** + * Removes userstore prefixes from a username if they exist. + * This is commonly used to clean usernames returned from SCIM2 endpoints + * that include userstore prefixes like "DEFAULT/", "ASGARDEO_USER/", "PRIMARY/", etc. + * + * @param username - The username string to process + * @returns The username without the userstore prefix, or the original username if no prefix exists + * + * @example + * ```typescript + * const cleanUsername = removeUserstorePrefix("DEFAULT/john.doe"); + * console.log(cleanUsername); // "john.doe" + * + * const asgardeoUser = removeUserstorePrefix("ASGARDEO_USER/jane.doe"); + * console.log(asgardeoUser); // "jane.doe" + * + * const primaryUser = removeUserstorePrefix("PRIMARY/admin"); + * console.log(primaryUser); // "admin" + * + * const alreadyClean = removeUserstorePrefix("user.name"); + * console.log(alreadyClean); // "user.name" + * + * const emptyInput = removeUserstorePrefix(""); + * console.log(emptyInput); // "" + * ``` + */ +export const removeUserstorePrefix = (username?: string): string => { + if (!username) { + return ''; + } + + return username.replace(USERSTORE_PREFIX_REGEX, ''); +}; + +/** + * Processes a user object to remove userstore prefixes from username fields. + * This is a helper function for processing user objects returned from SCIM2 endpoints. + * Handles various username field variations: username, userName, and user_name. + * + * @param user - The user object to process + * @returns The user object with processed username fields + * + * @example + * ```typescript + * const user = { username: "DEFAULT/john.doe", email: "john@example.com" }; + * const processedUser = processUserUsername(user); + * console.log(processedUser.username); // "john.doe" + * + * const camelCaseUser = { userName: "ASGARDEO_USER/jane.doe", email: "jane@example.com" }; + * const processedCamelCaseUser = processUserUsername(camelCaseUser); + * console.log(processedCamelCaseUser.userName); // "jane.doe" + * + * const snakeCaseUser = { user_name: "PRIMARY/admin", email: "admin@example.com" }; + * const processedSnakeCaseUser = processUserUsername(snakeCaseUser); + * console.log(processedSnakeCaseUser.user_name); // "admin" + * ``` + */ +const processUsername = (user: T): T => { + if (!user) { + return user; + } + + const processedUser = {...user}; + + // Process username field + if (processedUser.username) { + processedUser.username = removeUserstorePrefix(processedUser.username); + } + + // Process userName field + if (processedUser.userName) { + processedUser.userName = removeUserstorePrefix(processedUser.userName); + } + + // Process user_name field + if (processedUser.user_name) { + processedUser.user_name = removeUserstorePrefix(processedUser.user_name); + } + + return processedUser; +}; + +export default processUsername; diff --git a/packages/javascript/src/utils/transformBrandingPreferenceToTheme.ts b/packages/javascript/src/utils/transformBrandingPreferenceToTheme.ts new file mode 100644 index 00000000..4f04984f --- /dev/null +++ b/packages/javascript/src/utils/transformBrandingPreferenceToTheme.ts @@ -0,0 +1,188 @@ +/** + * 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, ThemeVariant} from '../models/branding-preference'; +import {Theme, ThemeConfig} from '../theme/types'; +import createTheme from '../theme/createTheme'; + +/** + * Safely extracts a color value from the branding preference structure + */ +const extractColorValue = (colorVariant?: {main?: string; contrastText?: string}) => { + return colorVariant?.main; +}; + +/** + * Safely extracts contrast text color from the branding preference structure + */ +const extractContrastText = (colorVariant?: {main?: string; contrastText?: string}) => { + return colorVariant?.contrastText; +}; + +/** + * Transforms a ThemeVariant from branding preference to ThemeConfig + */ +const transformThemeVariant = (themeVariant: ThemeVariant, isDark = false): Partial => { + const colors = themeVariant.colors; + const buttons = themeVariant.buttons; + const inputs = themeVariant.inputs; + const images = themeVariant.images; + + return { + colors: { + action: { + active: isDark ? 'rgba(255, 255, 255, 0.70)' : 'rgba(0, 0, 0, 0.54)', + hover: isDark ? 'rgba(255, 255, 255, 0.04)' : 'rgba(0, 0, 0, 0.04)', + hoverOpacity: 0.04, + selected: isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(0, 0, 0, 0.08)', + selectedOpacity: 0.08, + disabled: isDark ? 'rgba(255, 255, 255, 0.26)' : 'rgba(0, 0, 0, 0.26)', + disabledBackground: isDark ? 'rgba(255, 255, 255, 0.12)' : 'rgba(0, 0, 0, 0.12)', + disabledOpacity: 0.38, + focus: isDark ? 'rgba(255, 255, 255, 0.12)' : 'rgba(0, 0, 0, 0.12)', + focusOpacity: 0.12, + activatedOpacity: 0.12, + }, + primary: { + main: extractColorValue(colors?.primary), + contrastText: extractContrastText(colors?.primary), + }, + secondary: { + main: extractColorValue(colors?.secondary), + contrastText: extractContrastText(colors?.secondary), + }, + background: { + surface: extractColorValue(colors?.background?.surface), + disabled: extractColorValue(colors?.background?.surface), + body: { + main: extractColorValue(colors?.background?.body), + }, + }, + text: { + primary: colors?.text?.primary, + secondary: colors?.text?.secondary, + }, + border: colors?.outlined?.default, + error: { + main: extractColorValue(colors?.alerts?.error), + contrastText: extractContrastText(colors?.alerts?.error), + }, + success: { + main: extractColorValue(colors?.alerts?.info), + contrastText: extractContrastText(colors?.alerts?.info), + }, + warning: { + main: extractColorValue(colors?.alerts?.warning), + contrastText: extractContrastText(colors?.alerts?.warning), + }, + }, + // Extract border radius from buttons or inputs + borderRadius: { + small: buttons?.primary?.base?.border?.borderRadius || inputs?.base?.border?.borderRadius, + medium: buttons?.secondary?.base?.border?.borderRadius, + large: buttons?.externalConnection?.base?.border?.borderRadius, + }, + // Extract and transform images + images: { + favicon: images?.favicon + ? { + url: images.favicon.imgURL, + title: images.favicon.title, + alt: images.favicon.altText, + } + : undefined, + logo: images?.logo + ? { + url: images.logo.imgURL, + title: images.logo.title, + alt: images.logo.altText, + } + : undefined, + }, + }; +}; + +/** + * Transforms branding preference response to Theme object + * + * @param brandingPreference - The branding preference response from getBrandingPreference + * @param forceTheme - Optional parameter to force a specific theme ('light' or 'dark'), + * if not provided, will use the activeTheme from branding preference + * @returns Theme object that can be used with the theme system + * + * The function extracts the following from branding preference: + * - Colors (primary, secondary, background, text, alerts, etc.) + * - Border radius from buttons and inputs + * - Images (logo and favicon with their URLs, titles, and alt text) + * - Typography settings + * + * @example + * ```typescript + * const brandingPreference = await getBrandingPreference({ baseUrl: "..." }); + * const theme = transformBrandingPreferenceToTheme(brandingPreference); + * + * // Access image URLs via CSS variables + * // Logo: var(--wso2-image-logo-url) + * // Favicon: var(--wso2-image-favicon-url) + * + * // Force light theme regardless of branding preference activeTheme + * const lightTheme = transformBrandingPreferenceToTheme(brandingPreference, 'light'); + * ``` + */ +export const transformBrandingPreferenceToTheme = ( + brandingPreference: BrandingPreference, + forceTheme?: 'light' | 'dark', +): Theme => { + // Extract theme configuration + const themeConfig = brandingPreference?.preference?.theme; + + if (!themeConfig) { + // If no theme config is provided, return default light theme + return createTheme({}, false); + } + + // Determine which theme variant to use + let activeThemeKey: string; + if (forceTheme) { + activeThemeKey = forceTheme.toUpperCase(); + } else { + activeThemeKey = themeConfig.activeTheme || 'LIGHT'; + } + + // Get the theme variant (LIGHT or DARK) + const themeVariant = themeConfig[activeThemeKey as keyof typeof themeConfig] as ThemeVariant; + + if (!themeVariant) { + // If the specified theme variant doesn't exist, fallback to light theme + const fallbackVariant = themeConfig.LIGHT || themeConfig.DARK; + if (fallbackVariant) { + const transformedConfig = transformThemeVariant(fallbackVariant, activeThemeKey === 'DARK'); + return createTheme(transformedConfig, activeThemeKey === 'DARK'); + } + // If no theme variants exist, return default theme + return createTheme({}, activeThemeKey === 'DARK'); + } + + // Transform the theme variant to ThemeConfig + const transformedConfig = transformThemeVariant(themeVariant, activeThemeKey === 'DARK'); + + // Create the theme using the transformed config + return createTheme(transformedConfig, activeThemeKey === 'DARK'); +}; + +export default transformBrandingPreferenceToTheme; diff --git a/packages/nextjs/src/AsgardeoNextClient.ts b/packages/nextjs/src/AsgardeoNextClient.ts index d39d2206..8e302b1a 100644 --- a/packages/nextjs/src/AsgardeoNextClient.ts +++ b/packages/nextjs/src/AsgardeoNextClient.ts @@ -46,9 +46,12 @@ import { CreateOrganizationPayload, getOrganization, OrganizationDetails, - deriveOrganizationHandleFromBaseUrl + deriveOrganizationHandleFromBaseUrl, + getAllOrganizations, + AllOrganizationsApiResponse, + extractUserClaimsFromIdToken, + TokenResponse, } from '@asgardeo/node'; -import {NextRequest, NextResponse} from 'next/server'; import {AsgardeoNextConfig} from './models/config'; import getSessionId from './server/actions/getSessionId'; import decorateConfigWithNextEnv from './utils/decorateConfigWithNextEnv'; @@ -101,8 +104,17 @@ class AsgardeoNextClient exte return Promise.resolve(true); } - const {baseUrl, organizationHandle, clientId, clientSecret, signInUrl, afterSignInUrl, afterSignOutUrl, signUpUrl, ...rest} = - decorateConfigWithNextEnv(config); + const { + baseUrl, + organizationHandle, + clientId, + clientSecret, + signInUrl, + afterSignInUrl, + afterSignOutUrl, + signUpUrl, + ...rest + } = decorateConfigWithNextEnv(config); this.isInitialized = true; @@ -189,8 +201,8 @@ class AsgardeoNextClient exte } catch (error) { return { schemas: [], - flattenedProfile: await this.asgardeo.getDecodedIdToken(userId), - profile: await this.asgardeo.getDecodedIdToken(userId), + flattenedProfile: extractUserClaimsFromIdToken(await this.asgardeo.getDecodedIdToken(userId)), + profile: extractUserClaimsFromIdToken(await this.asgardeo.getDecodedIdToken(userId)), }; } } @@ -263,25 +275,46 @@ class AsgardeoNextClient exte } } - override async getOrganizations(userId?: string): Promise { + override async getMyOrganizations(options?: any, userId?: string): Promise { try { const configData = await this.asgardeo.getConfigData(); const baseUrl: string = configData?.baseUrl as string; - const organizations = await getMeOrganizations({ + return await getMeOrganizations({ baseUrl, headers: { Authorization: `Bearer ${await this.getAccessToken(userId)}`, }, }); + } catch (error) { + throw new AsgardeoRuntimeError( + `Failed to fetch the user's associated organizations: ${ + error instanceof Error ? error.message : String(error) + }`, + 'AsgardeoNextClient-getMyOrganizations-RuntimeError-001', + 'nextjs', + 'An error occurred while fetching associated organizations of the signed-in user.', + ); + } + } - return organizations; + override async getAllOrganizations(options?: any, userId?: string): Promise { + try { + const configData = await this.asgardeo.getConfigData(); + const baseUrl: string = configData?.baseUrl as string; + + return getAllOrganizations({ + baseUrl, + headers: { + Authorization: `Bearer ${await this.getAccessToken(userId)}`, + }, + }); } 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.', + `Failed to fetch all organizations: ${error instanceof Error ? error.message : String(error)}`, + 'AsgardeoNextClient-getAllOrganizations-RuntimeError-001', + 'nextjs', + 'An error occurred while fetching all the organizations associated with the user.', ); } } @@ -296,7 +329,7 @@ class AsgardeoNextClient exte }; } - override async switchOrganization(organization: Organization, userId?: string): Promise { + override async switchOrganization(organization: Organization, userId?: string): Promise { try { const configData = await this.asgardeo.getConfigData(); const scopes = configData?.scopes; @@ -304,8 +337,8 @@ class AsgardeoNextClient exte if (!organization.id) { throw new AsgardeoRuntimeError( 'Organization ID is required for switching organizations', - 'react-AsgardeoReactClient-ValidationError-001', - 'react', + 'AsgardeoNextClient-switchOrganization-ValidationError-001', + 'nextjs', 'The organization object must contain a valid ID to perform the organization switch.', ); } @@ -314,6 +347,7 @@ class AsgardeoNextClient exte attachToken: false, data: { client_id: '{{clientId}}', + client_secret: '{{clientSecret}}', grant_type: 'organization_switch', scope: '{{scopes}}', switching_organization: organization.id, @@ -324,10 +358,10 @@ class AsgardeoNextClient exte signInRequired: true, }; - await this.asgardeo.exchangeToken(exchangeConfig, userId); + return await this.asgardeo.exchangeToken(exchangeConfig, userId); } catch (error) { throw new AsgardeoRuntimeError( - `Failed to switch organization: ${error instanceof Error ? error.message : String(error)}`, + `Failed to switch organization: ${error instanceof Error ? error.message : String(JSON.stringify(error))}`, 'AsgardeoReactClient-RuntimeError-003', 'nextjs', 'An error occurred while switching to the specified organization. Please try again.', @@ -347,6 +381,14 @@ class AsgardeoNextClient exte return this.asgardeo.getAccessToken(sessionId as string); } + /** + * Get the decoded ID token for a session + */ + async getDecodedIdToken(sessionId?: string): Promise { + await this.ensureInitialized(); + return this.asgardeo.getDecodedIdToken(sessionId as string); + } + override getConfiguration(): T { return this.asgardeo.getConfigData() as unknown as T; } diff --git a/packages/nextjs/src/client/components/presentation/CreateOrganization/CreateOrganization.tsx b/packages/nextjs/src/client/components/presentation/CreateOrganization/CreateOrganization.tsx index b111912c..08899b8c 100644 --- a/packages/nextjs/src/client/components/presentation/CreateOrganization/CreateOrganization.tsx +++ b/packages/nextjs/src/client/components/presentation/CreateOrganization/CreateOrganization.tsx @@ -80,7 +80,7 @@ export const CreateOrganization: FC = ({ ...props }: CreateOrganizationProps): ReactElement => { const {isSignedIn, baseUrl} = useAsgardeo(); - const {currentOrganization, revalidateOrganizations} = useOrganization(); + const {currentOrganization, revalidateMyOrganizations} = useOrganization(); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -121,7 +121,7 @@ export const CreateOrganization: FC = ({ } // Refresh organizations list to include the new organization - await revalidateOrganizations(); + await revalidateMyOrganizations(); // Call success callback if provided if (onSuccess) { diff --git a/packages/nextjs/src/client/components/presentation/OrganizationList/OrganizationList.tsx b/packages/nextjs/src/client/components/presentation/OrganizationList/OrganizationList.tsx index 0ab5d680..ff028859 100644 --- a/packages/nextjs/src/client/components/presentation/OrganizationList/OrganizationList.tsx +++ b/packages/nextjs/src/client/components/presentation/OrganizationList/OrganizationList.tsx @@ -18,8 +18,8 @@ 'use client'; -import {withVendorCSSClassPrefix} from '@asgardeo/node'; -import {FC, ReactElement, useEffect, useMemo, CSSProperties} from 'react'; +import {AllOrganizationsApiResponse, withVendorCSSClassPrefix} from '@asgardeo/node'; +import {FC, ReactElement, useEffect, useMemo, CSSProperties, useState} from 'react'; import { BaseOrganizationListProps, BaseOrganizationList, @@ -56,7 +56,7 @@ export interface OrganizationListConfig { export interface OrganizationListProps extends Omit< BaseOrganizationListProps, - 'data' | 'error' | 'fetchMore' | 'hasMore' | 'isLoading' | 'isLoadingMore' | 'totalCount' + 'allOrganizations' | 'error' | 'fetchMore' | 'hasMore' | 'isLoading' | 'isLoadingMore' | 'myOrganizations' >, OrganizationListConfig { /** @@ -113,118 +113,25 @@ export const OrganizationList: FC = ({ recursive = false, ...baseProps }: OrganizationListProps): ReactElement => { - const { - paginatedOrganizations, - error, - fetchMore, - hasMore, - isLoading, - isLoadingMore, - totalCount, - fetchPaginatedOrganizations, - } = useOrganization(); + const {getAllOrganizations, error, isLoading, myOrganizations} = 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 [allOrganizations, setAllOrganizations] = useState({ + organizations: [], + }); - const refreshHandler = async () => { - await fetchPaginatedOrganizations({ - filter, - limit, - recursive, - reset: true, - }); - }; + useEffect(() => { + (async () => { + setAllOrganizations(await getAllOrganizations()); + })(); + }, []); return ( ); diff --git a/packages/nextjs/src/client/components/presentation/OrganizationSwitcher/OrganizationSwitcher.tsx b/packages/nextjs/src/client/components/presentation/OrganizationSwitcher/OrganizationSwitcher.tsx index b840e005..22945cf7 100644 --- a/packages/nextjs/src/client/components/presentation/OrganizationSwitcher/OrganizationSwitcher.tsx +++ b/packages/nextjs/src/client/components/presentation/OrganizationSwitcher/OrganizationSwitcher.tsx @@ -93,7 +93,7 @@ export const OrganizationSwitcher: FC = ({ const {isSignedIn} = useAsgardeo(); const { currentOrganization: contextCurrentOrganization, - organizations: contextOrganizations, + myOrganizations: contextOrganizations, switchOrganization, isLoading, error, diff --git a/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoProvider.tsx b/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoProvider.tsx index e03cb254..fbdc54ba 100644 --- a/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoProvider.tsx +++ b/packages/nextjs/src/client/contexts/Asgardeo/AsgardeoProvider.tsx @@ -19,6 +19,7 @@ 'use client'; import { + AllOrganizationsApiResponse, AsgardeoRuntimeError, EmbeddedFlowExecuteRequestConfig, EmbeddedFlowExecuteRequestPayload, @@ -28,6 +29,7 @@ import { UpdateMeProfileConfig, User, UserProfile, + BrandingPreference, } from '@asgardeo/node'; import { I18nProvider, @@ -36,13 +38,11 @@ import { ThemeProvider, AsgardeoProviderProps, OrganizationProvider, + BrandingProvider, } 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} @@ -67,6 +67,11 @@ export type AsgardeoClientProviderProps = Partial Promise<{success: boolean; data: {user: User}; error: string}>; + getAllOrganizations: (options?: any, sessionId?: string) => Promise; + myOrganizations: Organization[]; + revalidateMyOrganizations?: (sessionId?: string) => Promise; + brandingPreference?: BrandingPreference | null; + switchOrganization: (organization: Organization, sessionId?: string) => Promise; }; const AsgardeoClientProvider: FC> = ({ @@ -86,6 +91,11 @@ const AsgardeoClientProvider: FC> updateProfile, applicationId, organizationHandle, + myOrganizations, + revalidateMyOrganizations, + getAllOrganizations, + switchOrganization, + brandingPreference, }: PropsWithChildren) => { const reRenderCheckRef: RefObject = useRef(false); const router = useRouter(); @@ -253,25 +263,6 @@ 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, @@ -301,23 +292,23 @@ const AsgardeoClientProvider: FC> return ( - - - - { - const result = await getOrganizationsAction((await getSessionId()) as string); - - return result?.data?.organizations || []; - }} - currentOrganization={currentOrganization} - onOrganizationSwitch={switchOrganization} - > - {children} - - - - + + + + + + {children} + + + + + ); diff --git a/packages/nextjs/src/server/AsgardeoProvider.tsx b/packages/nextjs/src/server/AsgardeoProvider.tsx index dea32dc1..d5671c4b 100644 --- a/packages/nextjs/src/server/AsgardeoProvider.tsx +++ b/packages/nextjs/src/server/AsgardeoProvider.tsx @@ -19,7 +19,15 @@ 'use server'; import {FC, PropsWithChildren, ReactElement} from 'react'; -import {AsgardeoRuntimeError, Organization, User, UserProfile} from '@asgardeo/node'; +import { + BrandingPreference, + AllOrganizationsApiResponse, + AsgardeoRuntimeError, + Organization, + User, + UserProfile, + IdToken, +} from '@asgardeo/node'; import AsgardeoClientProvider from '../client/contexts/Asgardeo/AsgardeoProvider'; import AsgardeoNextClient from '../AsgardeoNextClient'; import signInAction from './actions/signInAction'; @@ -34,6 +42,10 @@ import handleOAuthCallbackAction from './actions/handleOAuthCallbackAction'; import {AsgardeoProviderProps} from '@asgardeo/react'; import getCurrentOrganizationAction from './actions/getCurrentOrganizationAction'; import updateUserProfileAction from './actions/updateUserProfileAction'; +import getMyOrganizations from './actions/getMyOrganizations'; +import getAllOrganizations from './actions/getAllOrganizations'; +import getBrandingPreference from './actions/getBrandingPreference'; +import switchOrganization from './actions/switchOrganization'; /** * Props interface of {@link AsgardeoServerProvider} @@ -97,17 +109,67 @@ const AsgardeoServerProvider: FC> name: '', orgHandle: '', }; + let myOrganizations: Organization[] = []; + let brandingPreference: BrandingPreference | null = null; if (_isSignedIn) { + // Check if there's a `user_org` claim in the ID token to determine if this is an organization login + const idToken = await asgardeoClient.getDecodedIdToken(sessionId); + let updatedBaseUrl = config?.baseUrl; + + if (idToken?.['user_org']) { + // Treat this login as an organization login and modify the base URL + updatedBaseUrl = `${config?.baseUrl}/o`; + config = {...config, baseUrl: updatedBaseUrl}; + } + const userResponse = await getUserAction(sessionId); const userProfileResponse = await getUserProfileAction(sessionId); const currentOrganizationResponse = await getCurrentOrganizationAction(sessionId); + myOrganizations = await getMyOrganizations({}, sessionId); user = userResponse.data?.user || {}; userProfile = userProfileResponse.data?.userProfile; currentOrganization = currentOrganizationResponse?.data?.organization as Organization; } + // Fetch branding preference if branding is enabled in config + if (config?.preferences?.theme?.inheritFromBranding !== false) { + try { + brandingPreference = await getBrandingPreference( + { + baseUrl: config?.baseUrl as string, + locale: 'en-US', + name: config.applicationId || config.organizationHandle, + type: config.applicationId ? 'APP' : 'ORG', + }, + sessionId, + ); + } catch (error) { + console.warn('[AsgardeoServerProvider] Failed to fetch branding preference:', error); + } + } + + const handleGetAllOrganizations = async ( + options?: any, + _sessionId?: string, + ): Promise => { + 'use server'; + return await getAllOrganizations(options, sessionId); + }; + + const handleSwitchOrganization = async (organization: Organization, _sessionId?: string): Promise => { + 'use server'; + await switchOrganization(organization, sessionId); + + // After switching organization, we need to refresh the page to get updated session data + // This is because server components don't maintain state between function calls + const {revalidatePath} = await import('next/cache'); + + // Revalidate the current path to refresh the component with new data + revalidatePath('/'); + }; + return ( > userProfile={userProfile} updateProfile={updateUserProfileAction} isSignedIn={_isSignedIn} + myOrganizations={myOrganizations} + getAllOrganizations={handleGetAllOrganizations} + switchOrganization={handleSwitchOrganization} + brandingPreference={brandingPreference} > {children} diff --git a/packages/nextjs/src/server/actions/switchOrganizationAction.ts b/packages/nextjs/src/server/actions/getAllOrganizations.ts similarity index 56% rename from packages/nextjs/src/server/actions/switchOrganizationAction.ts rename to packages/nextjs/src/server/actions/getAllOrganizations.ts index 9b049bac..9902f8bf 100644 --- a/packages/nextjs/src/server/actions/switchOrganizationAction.ts +++ b/packages/nextjs/src/server/actions/getAllOrganizations.ts @@ -18,26 +18,24 @@ 'use server'; -import {Organization, OrganizationDetails} from '@asgardeo/node'; +import {AllOrganizationsApiResponse, AsgardeoAPIError, Organization} from '@asgardeo/node'; import AsgardeoNextClient from '../../AsgardeoNextClient'; /** - * Server action to create an organization. + * Server action to get organizations. */ -const switchOrganizationAction = async (organization: Organization, sessionId: string) => { +const getAllOrganizations = async (options?: any, sessionId?: string | undefined): Promise => { try { const client = AsgardeoNextClient.getInstance(); - await client.switchOrganization(organization, sessionId); - return {success: true, error: null}; + return client.getAllOrganizations(options, sessionId); } catch (error) { - return { - success: false, - data: { - user: {}, - }, - error: 'Failed to switch to organization', - }; + throw new AsgardeoAPIError( + `Failed to get all the organizations for the user: ${error instanceof Error ? error.message : String(error)}`, + 'getAllOrganizations-ServerActionError-001', + 'nextjs', + error instanceof AsgardeoAPIError ? error.statusCode : undefined, + ); } }; -export default switchOrganizationAction; +export default getAllOrganizations; diff --git a/packages/nextjs/src/server/actions/getBrandingPreference.ts b/packages/nextjs/src/server/actions/getBrandingPreference.ts new file mode 100644 index 00000000..b2949f19 --- /dev/null +++ b/packages/nextjs/src/server/actions/getBrandingPreference.ts @@ -0,0 +1,47 @@ +/** + * 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 { + AsgardeoAPIError, + GetBrandingPreferenceConfig, + BrandingPreference, + getBrandingPreference as baseGetBrandingPreference, +} from '@asgardeo/node'; + +/** + * Server action to get branding preferences. + */ +const getBrandingPreference = async ( + config: GetBrandingPreferenceConfig, + sessionId?: string | undefined, +): Promise => { + try { + return await baseGetBrandingPreference(config); + } catch (error) { + throw new AsgardeoAPIError( + `Failed to get branding preferences: ${error instanceof Error ? error.message : String(error)}`, + 'getBrandingPreferenceAction-ServerActionError-001', + 'nextjs', + error instanceof AsgardeoAPIError ? error.statusCode : undefined, + ); + } +}; + +export default getBrandingPreference; diff --git a/packages/nextjs/src/server/actions/getOrganizationsAction.ts b/packages/nextjs/src/server/actions/getMyOrganizations.ts similarity index 60% rename from packages/nextjs/src/server/actions/getOrganizationsAction.ts rename to packages/nextjs/src/server/actions/getMyOrganizations.ts index 16c87807..d63aa711 100644 --- a/packages/nextjs/src/server/actions/getOrganizationsAction.ts +++ b/packages/nextjs/src/server/actions/getMyOrganizations.ts @@ -18,26 +18,24 @@ 'use server'; -import {Organization} from '@asgardeo/node'; +import {AsgardeoAPIError, Organization} from '@asgardeo/node'; import AsgardeoNextClient from '../../AsgardeoNextClient'; /** * Server action to get organizations. */ -const getOrganizationsAction = async (sessionId: string) => { +const getMyOrganizations = async (options?: any, sessionId?: string | undefined): Promise => { try { const client = AsgardeoNextClient.getInstance(); - const organizations: Organization[] = await client.getOrganizations(sessionId); - return {success: true, data: {organizations}, error: null}; + return await client.getMyOrganizations(options, sessionId); } catch (error) { - return { - success: false, - data: { - user: {}, - }, - error: 'Failed to get organizations', - }; + throw new AsgardeoAPIError( + `Failed to get the organizations for the user: ${error instanceof Error ? error.message : String(error)}`, + 'getMyOrganizations-ServerActionError-001', + 'nextjs', + error instanceof AsgardeoAPIError ? error.statusCode : undefined, + ); } }; -export default getOrganizationsAction; +export default getMyOrganizations; diff --git a/packages/nextjs/src/server/actions/switchOrganization.ts b/packages/nextjs/src/server/actions/switchOrganization.ts new file mode 100644 index 00000000..4441d58d --- /dev/null +++ b/packages/nextjs/src/server/actions/switchOrganization.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, AsgardeoAPIError, AsgardeoRuntimeError, TokenResponse} from '@asgardeo/node'; +import AsgardeoNextClient from '../../AsgardeoNextClient'; + +/** + * Server action to switch organization. + */ +const switchOrganization = async (organization: Organization, sessionId: string): Promise => { + try { + const client = AsgardeoNextClient.getInstance(); + return await client.switchOrganization(organization, sessionId); + } catch (error) { + throw new AsgardeoAPIError( + `Failed to switch the organizations: ${ + error instanceof AsgardeoRuntimeError ? error.message : error instanceof Error ? error.message : String(error) + }`, + 'switchOrganization-ServerActionError-001', + 'nextjs', + error instanceof AsgardeoAPIError ? error.statusCode : undefined, + ); + } +}; + +export default switchOrganization; diff --git a/packages/nextjs/src/utils/decorateConfigWithNextEnv.ts b/packages/nextjs/src/utils/decorateConfigWithNextEnv.ts index 2c25fa43..e164d08a 100644 --- a/packages/nextjs/src/utils/decorateConfigWithNextEnv.ts +++ b/packages/nextjs/src/utils/decorateConfigWithNextEnv.ts @@ -19,10 +19,11 @@ import {AsgardeoNextConfig} from '../models/config'; const decorateConfigWithNextEnv = (config: AsgardeoNextConfig): AsgardeoNextConfig => { - const {organizationHandle, applicationId, baseUrl, clientId, clientSecret, signInUrl, signUpUrl, afterSignInUrl, afterSignOutUrl, ...rest} = config; + const {organizationHandle, scopes, applicationId, baseUrl, clientId, clientSecret, signInUrl, signUpUrl, afterSignInUrl, afterSignOutUrl, ...rest} = config; return { ...rest, + scopes: scopes || (process.env['NEXT_PUBLIC_ASGARDEO_SCOPES'] as string), organizationHandle: organizationHandle || (process.env['NEXT_PUBLIC_ASGARDEO_ORGANIZATION_HANDLE'] as string), applicationId: applicationId || (process.env['NEXT_PUBLIC_ASGARDEO_APPLICATION_ID'] as string), baseUrl: baseUrl || (process.env['NEXT_PUBLIC_ASGARDEO_BASE_URL'] as string), diff --git a/packages/react/COMPLETE GUIDE.md b/packages/react/COMPLETE GUIDE.md deleted file mode 100644 index 7d5c3caf..00000000 --- a/packages/react/COMPLETE GUIDE.md +++ /dev/null @@ -1,1123 +0,0 @@ -# @asgardeo/react - Complete Guide - -A comprehensive guide to building React applications with Asgardeo authentication using the official Asgardeo React SDK. - -## Table of Contents - -- [Overview](#overview) -- [Installation](#installation) -- [Quick Start](#quick-start) -- [Configuration](#configuration) -- [Core Concepts](#core-concepts) -- [Components](#components) -- [Hooks](#hooks) -- [Advanced Usage](#advanced-usage) -- [Examples](#examples) -- [Troubleshooting](#troubleshooting) -- [API Reference](#api-reference) - -## Overview - -The `@asgardeo/react` SDK enables seamless authentication integration in React applications using Asgardeo Identity Server. It provides: - -- **Drop-in Components**: Pre-built UI components for authentication flows -- **React Hooks**: Programmatic access to authentication state and methods -- **Context Providers**: Global state management for authentication -- **Customizable UI**: Theming and styling options -- **TypeScript Support**: Full type safety and IntelliSense -- **Modern React**: Support for React 16.8+ with hooks - -## Installation - -```bash -# Using npm -npm install @asgardeo/react - -# Using pnpm -pnpm add @asgardeo/react - -# Using yarn -yarn add @asgardeo/react -``` - -### Peer Dependencies - -The SDK requires the following peer dependencies: - -```json -{ - "@types/react": ">=16.8.0", - "react": ">=16.8.0" -} -``` - -## Quick Start - -### 1. Set Up the Provider - -Wrap your application with `AsgardeoProvider` in your main entry file: - -#### Basic Setup - -```tsx -// main.tsx or index.tsx -import { StrictMode } from 'react' -import { createRoot } from 'react-dom/client' -import { AsgardeoProvider } from '@asgardeo/react' -import App from './App' - -createRoot(document.getElementById('root')!).render( - - - - - -) -``` - -#### Advanced Setup - -```tsx -// main.tsx or index.tsx -import { StrictMode } from 'react' -import { createRoot } from 'react-dom/client' -import { AsgardeoProvider } from '@asgardeo/react' -import App from './App' - -createRoot(document.getElementById('root')!).render( - - - - - -) -``` - -### 2. Build Your App - -Use the authentication components and hooks in your application: - -```tsx -// App.tsx -import { SignedIn, SignedOut, SignInButton, SignOutButton, User } from '@asgardeo/react' - -function App() { - return ( -
- -
- - {({ user }) => ( -
-

Welcome, {user.givenname}!

-

{user.email}

-
- )} -
- -
-
- - -
-

Welcome to Our App

-

Please sign in to continue

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

{user.givenname} {user.familyname}

-

{user.email}

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

Welcome, {user?.givenname}!

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

{user.givenname} {user.familyname}

-

Email: {user.email}

-

Username: {user.username}

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

Current theme mode: {theme.mode}

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

{t('welcome.title')}

-

{t('welcome.description')}

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

Authentication Error

-

{error.message}

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

My App

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

Dashboard

-
-
-

Profile Information

-

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

-

Email: {user?.email}

-

Username: {user?.username}

-
- -
-

Additional Data

- {loading ? ( -

Loading...

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

No additional data available

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

- Welcome to My App -

-

- A secure application powered by Asgardeo -

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

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

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

Sign In

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

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

-
-
-
- ) -} -``` - -## Troubleshooting - -### Common Issues - -#### 1. "useAsgardeo must be used within AsgardeoProvider" - -**Problem**: Hook is used outside of provider context. - -**Solution**: Ensure your component is wrapped with `AsgardeoProvider`: - -```tsx -// ❌ Wrong -function App() { - const { isSignedIn } = useAsgardeo() // Error! - return
App
-} - -// ✅ Correct -function App() { - return ( - - - - ) -} - -function MyComponent() { - const { isSignedIn } = useAsgardeo() // Works! - return
Component
-} -``` - -#### 2. Infinite loading state - -**Problem**: Authentication state never resolves. - -**Solution**: Check configuration and network connectivity: - -```tsx -// Add error handling -const { isLoading, error } = useAsgardeo() - -if (error) { - console.error('Auth error:', error) - // Handle error appropriately -} -``` - -#### 3. CORS errors - -**Problem**: Cross-origin requests blocked. - -**Solution**: Configure CORS in your Asgardeo application settings or use a proxy during development. - -#### 4. Token expiration - -**Problem**: API calls fail due to expired tokens. - -**Solution**: Implement automatic token refresh: - -```tsx -const { refreshTokens, getAccessToken } = useAsgardeo() - -const apiCall = async (url, options) => { - try { - const token = await getAccessToken() - return await fetch(url, { - ...options, - headers: { - 'Authorization': `Bearer ${token}`, - ...options.headers - } - }) - } catch (error) { - if (error.status === 401) { - await refreshTokens() - const newToken = await getAccessToken() - return await fetch(url, { - ...options, - headers: { - 'Authorization': `Bearer ${newToken}`, - ...options.headers - } - }) - } - throw error - } -} -``` - -### Debugging Tips - -1. **Enable Debug Logs**: Set up console logging for authentication events -2. **Check Network Tab**: Verify API calls and responses -3. **Validate Configuration**: Ensure all required props are provided -4. **Test in Incognito**: Rule out cache/storage issues -5. **Check Asgardeo Console**: Verify application configuration - -## API Reference - -For complete API documentation including all components, hooks, and customization options, see [API.md](./API.md). - -### Key Exports - -```typescript -// Providers -export { AsgardeoProvider } from '@asgardeo/react' - -// Hooks -export { useAsgardeo, useUser, useTheme, useI18n } from '@asgardeo/react' - -// Control Components -export { SignedIn, SignedOut, Loading, Loaded } from '@asgardeo/react' - -// Action Components -export { SignInButton, SignOutButton, SignUpButton } from '@asgardeo/react' - -// Presentation Components -export { SignIn, SignUp, User, UserProfile, UserDropdown } from '@asgardeo/react' - -// Primitive Components -export { Button, TextField, Card, Alert, Spinner } from '@asgardeo/react' -``` - -### TypeScript Support - -The SDK is written in TypeScript and provides full type definitions: - -```typescript -import type { - AsgardeoProviderProps, - User, - AuthState, - SignInOptions, - ThemeConfig -} from '@asgardeo/react' -``` - ---- - -## Support - -- **Documentation**: [Complete API Reference](./API.md) -- **GitHub Issues**: [Report bugs or request features](https://github.com/asgardeo/web-ui-sdks/issues) -- **Community**: [Join the discussion](https://github.com/asgardeo/web-ui-sdks/discussions) - -## License - -This project is licensed under the Apache License 2.0. See [LICENSE](../../LICENSE) for details. diff --git a/packages/react/docs/OVERVIEW.md b/packages/react/docs/OVERVIEW.md new file mode 100644 index 00000000..e69de29b diff --git a/packages/react/src/AsgardeoReactClient.ts b/packages/react/src/AsgardeoReactClient.ts index 86f1b2e9..60e38fe1 100644 --- a/packages/react/src/AsgardeoReactClient.ts +++ b/packages/react/src/AsgardeoReactClient.ts @@ -36,12 +36,16 @@ import { IdToken, EmbeddedFlowExecuteRequestConfig, deriveOrganizationHandleFromBaseUrl, + AllOrganizationsApiResponse, + extractUserClaimsFromIdToken, + TokenResponse, } from '@asgardeo/browser'; import AuthAPI from './__temp__/api'; import getMeOrganizations from './api/getMeOrganizations'; import getScim2Me from './api/getScim2Me'; import getSchemas from './api/getSchemas'; import {AsgardeoReactConfig} from './models/config'; +import getAllOrganizations from './api/getAllOrganizations'; /** * Client for mplementing Asgardeo in React applications. @@ -87,7 +91,7 @@ class AsgardeoReactClient e return generateUserProfile(profile, flattenUserSchema(schemas)); } catch (error) { - return this.asgardeo.getDecodedIdToken(); + return extractUserClaimsFromIdToken(await this.getDecodedIdToken()); } } @@ -119,13 +123,13 @@ class AsgardeoReactClient e } catch (error) { return { schemas: [], - flattenedProfile: await this.asgardeo.getDecodedIdToken(), - profile: await this.asgardeo.getDecodedIdToken(), + flattenedProfile: extractUserClaimsFromIdToken(await this.getDecodedIdToken()), + profile: extractUserClaimsFromIdToken(await this.getDecodedIdToken()), }; } } - override async getOrganizations(options?: any): Promise { + override async getMyOrganizations(options?: any, sessionId?: string): Promise { try { let baseUrl = options?.baseUrl; @@ -134,21 +138,41 @@ class AsgardeoReactClient e baseUrl = configData?.baseUrl; } - const organizations = await getMeOrganizations({baseUrl}); + return getMeOrganizations({baseUrl}); + } catch (error) { + throw new AsgardeoRuntimeError( + `Failed to fetch the user's associated organizations: ${ + error instanceof Error ? error.message : String(error) + }`, + 'AsgardeoReactClient-getMyOrganizations-RuntimeError-001', + 'react', + 'An error occurred while fetching associated organizations of the signed-in user.', + ); + } + } - return organizations; + override async getAllOrganizations(options?: any, sessionId?: string): Promise { + try { + let baseUrl = options?.baseUrl; + + if (!baseUrl) { + const configData = await this.asgardeo.getConfigData(); + baseUrl = configData?.baseUrl; + } + + return getAllOrganizations({baseUrl}); } catch (error) { throw new AsgardeoRuntimeError( - 'Failed to fetch organizations.', - 'react-AsgardeoReactClient-GetOrganizationsError-001', + `Failed to fetch all organizations: ${error instanceof Error ? error.message : String(error)}`, + 'AsgardeoReactClient-getAllOrganizations-RuntimeError-001', 'react', - 'An error occurred while fetching the organizations associated with the user.', + 'An error occurred while fetching all the organizations associated with the user.', ); } } override async getCurrentOrganization(): Promise { - const idToken: IdToken = await this.asgardeo.getDecodedIdToken(); + const idToken: IdToken = await this.getDecodedIdToken(); return { orgHandle: idToken?.org_handle, @@ -157,7 +181,7 @@ class AsgardeoReactClient e }; } - override async switchOrganization(organization: Organization): Promise { + override async switchOrganization(organization: Organization, sessionId?: string): Promise { try { const configData = await this.asgardeo.getConfigData(); const scopes = configData?.scopes; @@ -185,11 +209,11 @@ class AsgardeoReactClient e signInRequired: true, }; - await this.asgardeo.exchangeToken( + return await this.asgardeo.exchangeToken( exchangeConfig, (user: User) => {}, () => null, - ); + ) as TokenResponse | Response; } catch (error) { throw new AsgardeoRuntimeError( `Failed to switch organization: ${error.message || error}`, diff --git a/packages/react/src/api/getAllOrganizations.ts b/packages/react/src/api/getAllOrganizations.ts index 452755fe..48f79aa7 100644 --- a/packages/react/src/api/getAllOrganizations.ts +++ b/packages/react/src/api/getAllOrganizations.ts @@ -22,7 +22,7 @@ import { HttpRequestConfig, getAllOrganizations as baseGetAllOrganizations, GetAllOrganizationsConfig as BaseGetAllOrganizationsConfig, - PaginatedOrganizationsResponse, + AllOrganizationsApiResponse, } from '@asgardeo/browser'; const httpClient: HttpInstance = AsgardeoSPAClient.getInstance().httpRequest.bind(AsgardeoSPAClient.getInstance()); @@ -84,7 +84,7 @@ export interface GetAllOrganizationsConfig extends Omit => { +}: GetAllOrganizationsConfig): Promise => { const defaultFetcher = async (url: string, config: RequestInit): Promise => { const response = await httpClient({ url, diff --git a/packages/react/src/components/presentation/CreateOrganization/BaseCreateOrganization.tsx b/packages/react/src/components/presentation/CreateOrganization/BaseCreateOrganization.tsx index b5a234e7..30a6b194 100644 --- a/packages/react/src/components/presentation/CreateOrganization/BaseCreateOrganization.tsx +++ b/packages/react/src/components/presentation/CreateOrganization/BaseCreateOrganization.tsx @@ -21,6 +21,7 @@ import clsx from 'clsx'; import {ChangeEvent, CSSProperties, FC, ReactElement, ReactNode, useMemo, useState} from 'react'; import useTheme from '../../../contexts/Theme/useTheme'; import useTranslation from '../../../hooks/useTranslation'; +import Alert from '../../primitives/Alert/Alert'; import Button from '../../primitives/Button/Button'; import {Dialog, DialogContent, DialogHeading} from '../../primitives/Popover/Popover'; import FormControl from '../../primitives/FormControl/FormControl'; @@ -34,90 +35,90 @@ const useStyles = () => { return useMemo( () => ({ root: { - padding: `${theme.spacing.unit * 4}px`, + padding: `calc(${theme.vars.spacing.unit} * 4)`, minWidth: '600px', margin: '0 auto', } as CSSProperties, card: { - background: theme.colors.background.surface, - borderRadius: theme.borderRadius.large, - padding: `${theme.spacing.unit * 4}px`, + background: theme.vars.colors.background.surface, + borderRadius: theme.vars.borderRadius.large, + padding: `calc(${theme.vars.spacing.unit} * 4)`, } as CSSProperties, content: { display: 'flex', flexDirection: 'column', - gap: `${theme.spacing.unit * 2}px`, + gap: `calc(${theme.vars.spacing.unit} * 2)`, } as CSSProperties, form: { display: 'flex', flexDirection: 'column', - gap: `${theme.spacing.unit * 2}px`, + gap: `calc(${theme.vars.spacing.unit} * 2)`, width: '100%', } as CSSProperties, header: { display: 'flex', alignItems: 'center', - gap: `${theme.spacing.unit * 1.5}px`, - marginBottom: `${theme.spacing.unit * 1.5}px`, + gap: `calc(${theme.vars.spacing.unit} * 1.5)`, + marginBottom: `calc(${theme.vars.spacing.unit} * 1.5)`, } as CSSProperties, field: { display: 'flex', alignItems: 'center', - padding: `${theme.spacing.unit}px 0`, - borderBottom: `1px solid ${theme.colors.border}`, + padding: `${theme.vars.spacing.unit} 0`, + borderBottom: `1px solid ${theme.vars.colors.border}`, minHeight: '32px', } as CSSProperties, textarea: { width: '100%', - padding: `${theme.spacing.unit}px ${theme.spacing.unit * 1.5}px`, - border: `1px solid ${theme.colors.border}`, - borderRadius: theme.borderRadius.medium, - fontSize: '1rem', - color: theme.colors.text.primary, - backgroundColor: theme.colors.background.surface, + padding: `${theme.vars.spacing.unit} calc(${theme.vars.spacing.unit} * 1.5)`, + border: `1px solid ${theme.vars.colors.border}`, + borderRadius: theme.vars.borderRadius.medium, + fontSize: theme.vars.typography.fontSizes.md, + color: theme.vars.colors.text.primary, + backgroundColor: theme.vars.colors.background.surface, fontFamily: 'inherit', minHeight: '80px', resize: 'vertical', outline: 'none', '&:focus': { - borderColor: theme.colors.primary.main, - boxShadow: `0 0 0 2px ${theme.colors.primary.main}20`, + borderColor: theme.vars.colors.primary.main, + boxShadow: `0 0 0 2px ${theme.vars.colors.primary.main}20`, }, '&:disabled': { - backgroundColor: theme.colors.background.disabled, - color: theme.colors.text.secondary, + backgroundColor: theme.vars.colors.background.disabled, + color: theme.vars.colors.text.secondary, cursor: 'not-allowed', }, } as CSSProperties, avatarContainer: { alignItems: 'flex-start', display: 'flex', - gap: `${theme.spacing.unit * 2}px`, - marginBottom: `${theme.spacing.unit}px`, + gap: `calc(${theme.vars.spacing.unit} * 2)`, + marginBottom: theme.vars.spacing.unit, } as CSSProperties, actions: { display: 'flex', - gap: `${theme.spacing.unit}px`, + gap: theme.vars.spacing.unit, justifyContent: 'flex-end', - paddingTop: `${theme.spacing.unit * 2}px`, + paddingTop: `calc(${theme.vars.spacing.unit} * 2)`, } as CSSProperties, infoContainer: { display: 'flex', flexDirection: 'column' as const, - gap: `${theme.spacing.unit}px`, + gap: theme.vars.spacing.unit, } as CSSProperties, value: { - color: theme.colors.text.primary, + color: theme.vars.colors.text.primary, flex: 1, display: 'flex', alignItems: 'center', - gap: `${theme.spacing.unit}px`, + gap: theme.vars.spacing.unit, overflow: 'hidden', minHeight: '32px', lineHeight: '32px', } as CSSProperties, popup: { - padding: `${theme.spacing.unit * 2}px`, + padding: `calc(${theme.vars.spacing.unit} * 2)`, } as CSSProperties, }), [theme, colorScheme], @@ -284,6 +285,14 @@ export const BaseCreateOrganization: FC = ({ style={styles.form} onSubmit={handleSubmit} > + {/* Error Alert */} + {error && ( + + Error + {error} + + )} + {/* Organization Name */}
= ({ className={withVendorCSSClassPrefix('create-organization__textarea')} style={{ ...styles.textarea, - borderColor: formErrors.description ? theme.colors.error.main : theme.colors.border, + borderColor: formErrors.description ? theme.vars.colors.error.main : theme.vars.colors.border, }} placeholder={t('organization.create.description.placeholder')} value={formData.description} @@ -334,13 +343,6 @@ export const BaseCreateOrganization: FC = ({ {/* Additional Fields */} {renderAdditionalFields && renderAdditionalFields()} - - {/* Error Message */} - {error && ( - - {error} - - )} {/* Actions */} @@ -363,7 +365,7 @@ export const BaseCreateOrganization: FC = ({ {title} -
{createOrganizationContent}
+
{createOrganizationContent}
); diff --git a/packages/react/src/components/presentation/CreateOrganization/CreateOrganization.tsx b/packages/react/src/components/presentation/CreateOrganization/CreateOrganization.tsx index a6f9aca8..3a833db6 100644 --- a/packages/react/src/components/presentation/CreateOrganization/CreateOrganization.tsx +++ b/packages/react/src/components/presentation/CreateOrganization/CreateOrganization.tsx @@ -78,7 +78,7 @@ export const CreateOrganization: FC = ({ ...props }: CreateOrganizationProps): ReactElement => { const {isSignedIn, baseUrl} = useAsgardeo(); - const {currentOrganization, revalidateOrganizations} = useOrganization(); + const {currentOrganization, revalidateMyOrganizations} = useOrganization(); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -119,7 +119,7 @@ export const CreateOrganization: FC = ({ } // Refresh organizations list to include the new organization - await revalidateOrganizations(); + await revalidateMyOrganizations(); // Call success callback if provided if (onSuccess) { diff --git a/packages/react/src/components/presentation/OrganizationList/BaseOrganizationList.tsx b/packages/react/src/components/presentation/OrganizationList/BaseOrganizationList.tsx index c555c227..187efeef 100644 --- a/packages/react/src/components/presentation/OrganizationList/BaseOrganizationList.tsx +++ b/packages/react/src/components/presentation/OrganizationList/BaseOrganizationList.tsx @@ -16,13 +16,20 @@ * under the License. */ -import {withVendorCSSClassPrefix} from '@asgardeo/browser'; +import {AllOrganizationsApiResponse, Organization, withVendorCSSClassPrefix} from '@asgardeo/browser'; import clsx from 'clsx'; import {FC, ReactElement, ReactNode, useMemo, CSSProperties} from 'react'; -import {OrganizationWithSwitchAccess} from '../../../contexts/Organization/OrganizationContext'; import useTheme from '../../../contexts/Theme/useTheme'; +import useTranslation from '../../../hooks/useTranslation'; import {Dialog, DialogContent, DialogHeading} from '../../primitives/Popover/Popover'; -import {Avatar} from '../../primitives/Avatar/Avatar'; +import Avatar from '../../primitives/Avatar/Avatar'; +import Button from '../../primitives/Button/Button'; +import Typography from '../../primitives/Typography/Typography'; +import Spinner from '../../primitives/Spinner/Spinner'; + +export interface OrganizationWithSwitchAccess extends Organization { + canSwitch: boolean; +} /** * Props interface for the BaseOrganizationList component. @@ -33,9 +40,13 @@ export interface BaseOrganizationListProps { */ className?: string; /** - * List of organizations with switch access information + * List of organizations discoverable to the signed-in user. + */ + allOrganizations: AllOrganizationsApiResponse; + /** + * List of organizations associated to the signed-in user. */ - data: OrganizationWithSwitchAccess[]; + myOrganizations: Organization[]; /** * Error message to display */ @@ -81,13 +92,13 @@ export interface BaseOrganizationListProps { */ renderOrganization?: (organization: OrganizationWithSwitchAccess, index: number) => ReactNode; /** - * Inline styles to apply to the container + * Function called when an organization is selected/clicked */ - style?: React.CSSProperties; + onOrganizationSelect?: (organization: OrganizationWithSwitchAccess) => void; /** - * Total number of organizations + * Inline styles to apply to the container */ - totalCount?: number; + style?: React.CSSProperties; /** * Display mode: 'inline' for normal display, 'popup' for modal dialog */ @@ -104,49 +115,67 @@ export interface BaseOrganizationListProps { * Title for the popup dialog (only used in popup mode) */ title?: string; + /** + * Whether to show the organization status in the list + */ + showStatus?: boolean; } /** * Default organization item renderer */ -const defaultRenderOrganization = (organization: OrganizationWithSwitchAccess, styles: any): ReactNode => { - const getOrgInitials = (name?: string): string => { - if (!name) return 'ORG'; - return name - .split(' ') - .map(word => word.charAt(0)) - .join('') - .toUpperCase() - .slice(0, 2); - }; - +const defaultRenderOrganization = ( + organization: OrganizationWithSwitchAccess, + styles: any, + t: (key: string, params?: Record) => string, + onOrganizationSelect?: (organization: OrganizationWithSwitchAccess) => void, + showStatus?: boolean, +): ReactNode => { return ( -
+
- +
-

{organization.name}

-

@{organization.orgHandle}

-

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

+ + {organization.name} + + + @{organization.orgHandle} + + {showStatus && ( + + {t('organization.switcher.status.label')}{' '} + + {organization.status} + + + )}
-
- {organization.canSwitch ? ( - Can Switch - ) : ( - No Access - )} -
+ {organization.canSwitch && ( +
+ +
+ )}
); }; @@ -154,26 +183,43 @@ const defaultRenderOrganization = (organization: OrganizationWithSwitchAccess, s /** * Default loading renderer */ -const defaultRenderLoading = (styles: any): ReactNode => ( +const defaultRenderLoading = ( + t: (key: string, params?: Record) => string, + styles: any, +): ReactNode => (
-
Loading organizations...
+ + + {t('organization.switcher.loading.organizations')} +
); /** * Default error renderer */ -const defaultRenderError = (error: string, styles: any): ReactNode => ( +const defaultRenderError = ( + error: string, + t: (key: string, params?: Record) => string, + styles: any, +): ReactNode => (
- Error: {error} + + {t('organization.switcher.error.prefix')} {error} +
); /** * Default load more button renderer */ -const defaultRenderLoadMore = (onLoadMore: () => Promise, isLoading: boolean, styles: any): ReactNode => ( - + {isLoading ? t('organization.switcher.loading.more') : t('organization.switcher.load.more')} + ); /** * Default empty state renderer */ -const defaultRenderEmpty = (styles: any): ReactNode => ( +const defaultRenderEmpty = ( + t: (key: string, params?: Record) => string, + styles: any, +): ReactNode => (
-
No organizations found
+ + {t('organization.switcher.no.organizations')} +
); @@ -212,7 +264,8 @@ const defaultRenderEmpty = (styles: any): ReactNode => ( */ export const BaseOrganizationList: FC = ({ className = '', - data, + allOrganizations, + myOrganizations, error, fetchMore, hasMore = false, @@ -220,6 +273,7 @@ export const BaseOrganizationList: FC = ({ isLoadingMore = false, mode = 'inline', onOpenChange, + onOrganizationSelect, onRefresh, open = false, renderEmpty, @@ -229,22 +283,40 @@ export const BaseOrganizationList: FC = ({ renderOrganization, style, title = 'Organizations', - totalCount, + showStatus, }): ReactElement => { const styles = useStyles(); + const {t} = useTranslation(); + + // Combine allOrganizations with myOrganizations to determine which orgs can be switched to + const organizationsWithSwitchAccess: OrganizationWithSwitchAccess[] = useMemo(() => { + if (!allOrganizations?.organizations) { + return []; + } - // Use custom renderers or defaults with styles - const renderLoadingWithStyles = renderLoading || (() => defaultRenderLoading(styles)); - const renderErrorWithStyles = renderError || ((error: string) => defaultRenderError(error, styles)); - const renderEmptyWithStyles = renderEmpty || (() => defaultRenderEmpty(styles)); + // Create a Set of IDs from myOrganizations for faster lookup + const myOrgIds = new Set(myOrganizations?.map(org => org.id) || []); + + return allOrganizations.organizations.map(org => ({ + ...org, + canSwitch: myOrgIds.has(org.id), + })); + }, [allOrganizations?.organizations, myOrganizations]); + + // Use custom renderers or defaults with styles and translations + const renderLoadingWithStyles = renderLoading || (() => defaultRenderLoading(t, styles)); + const renderErrorWithStyles = renderError || ((error: string) => defaultRenderError(error, t, styles)); + const renderEmptyWithStyles = renderEmpty || (() => defaultRenderEmpty(t, styles)); const renderLoadMoreWithStyles = renderLoadMore || - ((onLoadMore: () => Promise, isLoading: boolean) => defaultRenderLoadMore(onLoadMore, isLoading, styles)); + ((onLoadMore: () => Promise, isLoading: boolean) => defaultRenderLoadMore(onLoadMore, isLoading, t, styles)); const renderOrganizationWithStyles = - renderOrganization || ((org: OrganizationWithSwitchAccess) => defaultRenderOrganization(org, styles)); + renderOrganization || + ((org: OrganizationWithSwitchAccess) => + defaultRenderOrganization(org, styles, t, onOrganizationSelect, showStatus)); // Show loading state - if (isLoading && data.length === 0) { + if (isLoading && organizationsWithSwitchAccess?.length === 0) { const loadingContent = (
= ({ } // Show error state - if (error && data.length === 0) { + if (error && organizationsWithSwitchAccess?.length === 0) { const errorContent = (
= ({ } // Show empty state - if (!isLoading && data.length === 0) { + if (!isLoading && organizationsWithSwitchAccess?.length === 0) { const emptyContent = (
= ({ {/* Header with total count and refresh button */}
-

Organizations

- {totalCount !== undefined && ( -

- Showing {data.length} of {totalCount} organizations -

- )} + + {t('organization.switcher.showing.count', { + showing: organizationsWithSwitchAccess?.length, + total: allOrganizations?.organizations?.length || 0, + })} +
{onRefresh && ( - + )}
{/* Organizations list */}
- {data.map((organization: OrganizationWithSwitchAccess, index: number) => + {organizationsWithSwitchAccess?.map((organization: OrganizationWithSwitchAccess, index: number) => renderOrganizationWithStyles(organization, index), )}
{/* Error message for additional data */} - {error && data.length > 0 &&
{renderErrorWithStyles(error)}
} + {error && organizationsWithSwitchAccess?.length > 0 && ( +
{renderErrorWithStyles(error)}
+ )} {/* Load more button */} {hasMore && fetchMore && ( @@ -374,20 +448,19 @@ const useStyles = () => { return useMemo( () => ({ root: { - padding: `${theme.spacing.unit * 4}px`, + padding: `calc(${theme.vars.spacing.unit} * 4)`, minWidth: '600px', margin: '0 auto', - background: theme.colors.background.surface, - borderRadius: theme.borderRadius.large, - boxShadow: theme.shadows.small, + background: theme.vars.colors.background.surface, + borderRadius: theme.vars.borderRadius.large, } as CSSProperties, header: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', - marginBottom: `${theme.spacing.unit * 3}px`, - paddingBottom: `${theme.spacing.unit * 2}px`, - borderBottom: `1px solid ${theme.colors.border}`, + marginBottom: `calc(${theme.vars.spacing.unit} * 3)`, + paddingBottom: `calc(${theme.vars.spacing.unit} * 2)`, + borderBottom: `1px solid ${theme.vars.colors.border}`, } as CSSProperties, headerInfo: { flex: 1, @@ -396,42 +469,41 @@ const useStyles = () => { fontSize: '1.5rem', fontWeight: 600, margin: '0 0 8px 0', - color: theme.colors.text.primary, + color: theme.vars.colors.text.primary, } as CSSProperties, subtitle: { - color: theme.colors.text.secondary, + color: theme.vars.colors.text.secondary, fontSize: '0.875rem', margin: '0', } as CSSProperties, refreshButton: { - backgroundColor: theme.colors.background.surface, - border: `1px solid ${theme.colors.border}`, - borderRadius: theme.borderRadius.small, - color: theme.colors.text.primary, + backgroundColor: theme.vars.colors.background.surface, + border: `1px solid ${theme.vars.colors.border}`, + borderRadius: theme.vars.borderRadius.small, + color: theme.vars.colors.text.primary, cursor: 'pointer', fontSize: '0.875rem', - padding: `${theme.spacing.unit}px ${theme.spacing.unit * 2}px`, + padding: `${theme.vars.spacing.unit} calc(${theme.vars.spacing.unit} * 2)`, transition: 'all 0.2s', } as CSSProperties, listContainer: { display: 'flex', flexDirection: 'column' as const, - gap: `${theme.spacing.unit * 1.5}px`, + gap: `calc(${theme.vars.spacing.unit} * 1.5)`, } as CSSProperties, organizationItem: { - border: `1px solid ${theme.colors.border}`, - borderRadius: theme.borderRadius.medium, + border: `1px solid ${theme.vars.colors.border}`, + borderRadius: theme.vars.borderRadius.medium, display: 'flex', justifyContent: 'space-between', - padding: `${theme.spacing.unit * 2}px`, + padding: `calc(${theme.vars.spacing.unit} * 2)`, transition: 'all 0.2s', - cursor: 'pointer', - backgroundColor: theme.colors.background.surface, + backgroundColor: theme.vars.colors.background.surface, } as CSSProperties, organizationContent: { display: 'flex', alignItems: 'center', - gap: `${theme.spacing.unit * 2}px`, + gap: `calc(${theme.vars.spacing.unit} * 2)`, flex: 1, } as CSSProperties, organizationInfo: { @@ -441,92 +513,95 @@ const useStyles = () => { fontSize: '1.125rem', fontWeight: 600, margin: '0 0 4px 0', - color: theme.colors.text.primary, + color: theme.vars.colors.text.primary, } as CSSProperties, organizationHandle: { - color: theme.colors.text.secondary, + color: theme.vars.colors.text.secondary, fontSize: '0.875rem', margin: '0 0 4px 0', fontFamily: 'monospace', } as CSSProperties, organizationStatus: { - color: theme.colors.text.secondary, + color: theme.vars.colors.text.secondary, fontSize: '0.875rem', margin: '0', } as CSSProperties, statusText: { fontWeight: 500, } as CSSProperties, - activeColor: theme.colors.success.main, - inactiveColor: theme.colors.error.main, + activeColor: theme.vars.colors.success.main, + inactiveColor: theme.vars.colors.error.main, organizationActions: { display: 'flex', alignItems: 'center', } as CSSProperties, badge: { - borderRadius: theme.borderRadius.large, + borderRadius: theme.vars.borderRadius.large, fontSize: '0.75rem', fontWeight: 500, - padding: '4px 12px', + padding: `calc(${theme.vars.spacing.unit} / 2) calc(${theme.vars.spacing.unit} * 1.5)`, textTransform: 'uppercase' as const, letterSpacing: '0.5px', } as CSSProperties, successBadge: { - backgroundColor: `${theme.colors.success.main}20`, - color: theme.colors.success.main, + backgroundColor: `color-mix(in srgb, ${theme.vars.colors.success.main} 20%, transparent)`, + color: theme.vars.colors.success.main, } as CSSProperties, errorBadge: { - backgroundColor: `${theme.colors.error.main}20`, - color: theme.colors.error.main, + backgroundColor: `color-mix(in srgb, ${theme.vars.colors.error.main} 20%, transparent)`, + color: theme.vars.colors.error.main, } as CSSProperties, loadingContainer: { - padding: `${theme.spacing.unit * 4}px`, + padding: `calc(${theme.vars.spacing.unit} * 4)`, textAlign: 'center' as const, + display: 'flex', + flexDirection: 'column' as const, + alignItems: 'center', + gap: `calc(${theme.vars.spacing.unit} * 2)`, } as CSSProperties, loadingText: { - color: theme.colors.text.secondary, - fontSize: '1rem', + marginTop: theme.vars.spacing.unit, } as CSSProperties, errorContainer: { - backgroundColor: `${theme.colors.error.main}20`, - border: `1px solid ${theme.colors.error.main}`, - borderRadius: theme.borderRadius.medium, - color: theme.colors.error.main, - padding: `${theme.spacing.unit * 2}px`, + backgroundColor: `color-mix(in srgb, ${theme.vars.colors.error.main} 20%, transparent)`, + border: `1px solid ${theme.vars.colors.error.main}`, + borderRadius: theme.vars.borderRadius.medium, + color: theme.vars.colors.error.main, + padding: `calc(${theme.vars.spacing.unit} * 2)`, } as CSSProperties, emptyContainer: { - padding: `${theme.spacing.unit * 4}px`, + padding: `calc(${theme.vars.spacing.unit} * 4)`, textAlign: 'center' as const, } as CSSProperties, emptyText: { - color: theme.colors.text.secondary, + color: theme.vars.colors.text.secondary, fontSize: '1rem', } as CSSProperties, loadMoreButton: { - backgroundColor: theme.colors.primary.main, + backgroundColor: theme.vars.colors.primary.main, border: 'none', - borderRadius: theme.borderRadius.medium, - color: theme.colors.primary.contrastText, + borderRadius: theme.vars.borderRadius.medium, + color: theme.vars.colors.primary.contrastText, cursor: 'pointer', fontSize: '0.875rem', fontWeight: 500, - padding: `${theme.spacing.unit * 1.5}px ${theme.spacing.unit * 3}px`, + padding: `calc(${theme.vars.spacing.unit} * 1.5) calc(${theme.vars.spacing.unit} * 3)`, width: '100%', transition: 'all 0.2s', } as CSSProperties, loadMoreButtonDisabled: { - backgroundColor: theme.colors.text.secondary, + backgroundColor: theme.vars.colors.text.secondary, cursor: 'not-allowed', opacity: 0.6, } as CSSProperties, errorMargin: { - marginTop: `${theme.spacing.unit * 2}px`, + marginTop: `calc(${theme.vars.spacing.unit} * 2)`, } as CSSProperties, loadMoreMargin: { - marginTop: `${theme.spacing.unit * 3}px`, + marginTop: `calc(${theme.vars.spacing.unit} * 3)`, } as CSSProperties, popupContent: { - padding: `${theme.spacing.unit}px`, + padding: theme.vars.spacing.unit, } as CSSProperties, }), [theme, colorScheme], diff --git a/packages/react/src/components/presentation/OrganizationList/OrganizationList.tsx b/packages/react/src/components/presentation/OrganizationList/OrganizationList.tsx index 9f718740..66610b61 100644 --- a/packages/react/src/components/presentation/OrganizationList/OrganizationList.tsx +++ b/packages/react/src/components/presentation/OrganizationList/OrganizationList.tsx @@ -16,14 +16,11 @@ * under the License. */ -import {withVendorCSSClassPrefix} from '@asgardeo/browser'; -import {FC, ReactElement, useEffect, useMemo, CSSProperties} from 'react'; -import {BaseOrganizationListProps} from './BaseOrganizationList'; +import {AllOrganizationsApiResponse, Organization} from '@asgardeo/browser'; +import {FC, ReactElement, useEffect, useState} from 'react'; +import {BaseOrganizationListProps, OrganizationWithSwitchAccess} from './BaseOrganizationList'; import BaseOrganizationList from './BaseOrganizationList'; import useOrganization from '../../../contexts/Organization/useOrganization'; -import useTheme from '../../../contexts/Theme/useTheme'; -import {OrganizationWithSwitchAccess} from '../../../contexts/Organization/OrganizationContext'; -import {Avatar} from '../../primitives/Avatar/Avatar'; /** * Configuration options for the OrganizationList component. @@ -54,7 +51,14 @@ export interface OrganizationListConfig { export interface OrganizationListProps extends Omit< BaseOrganizationListProps, - 'data' | 'error' | 'fetchMore' | 'hasMore' | 'isLoading' | 'isLoadingMore' | 'totalCount' + | 'myOrganizations' + | 'allOrganizations' + | 'error' + | 'fetchMore' + | 'hasMore' + | 'isLoading' + | 'isLoadingMore' + | 'myOrganizations' >, OrganizationListConfig { /** @@ -111,118 +115,25 @@ export const OrganizationList: FC = ({ recursive = false, ...baseProps }: OrganizationListProps): ReactElement => { - const { - paginatedOrganizations, - error, - fetchMore, - hasMore, - isLoading, - isLoadingMore, - totalCount, - fetchPaginatedOrganizations, - } = useOrganization(); + const {getAllOrganizations, error, isLoading, myOrganizations} = 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 [allOrganizations, setAllOrganizations] = useState({ + organizations: [], + }); - const refreshHandler = async () => { - await fetchPaginatedOrganizations({ - filter, - limit, - recursive, - reset: true, - }); - }; + useEffect(() => { + (async () => { + setAllOrganizations(await getAllOrganizations()); + })(); + }, []); return ( ); diff --git a/packages/react/src/components/presentation/OrganizationProfile/BaseOrganizationProfile.tsx b/packages/react/src/components/presentation/OrganizationProfile/BaseOrganizationProfile.tsx index 1a1851c5..bf2cf9a5 100644 --- a/packages/react/src/components/presentation/OrganizationProfile/BaseOrganizationProfile.tsx +++ b/packages/react/src/components/presentation/OrganizationProfile/BaseOrganizationProfile.tsx @@ -320,13 +320,13 @@ const BaseOrganizationProfile: FC = ({ const getStatusColor = (status?: string): string => { switch (status?.toUpperCase()) { case 'ACTIVE': - return theme.colors.success.main; + return theme.vars.colors.success.main; case 'INACTIVE': - return theme.colors.warning.main; + return theme.vars.colors.warning.main; case 'SUSPENDED': - return theme.colors.error.main; + return theme.vars.colors.error.main; default: - return theme.colors.text.secondary; + return theme.vars.colors.text.secondary; } }; @@ -341,36 +341,6 @@ const BaseOrganizationProfile: FC = ({ }; const styles = useStyles(); - const buttonStyle = useMemo( - () => ({ - padding: `${theme.spacing.unit}px ${theme.spacing.unit * 2}px`, - margin: `${theme.spacing.unit}px`, - borderRadius: theme.borderRadius.medium, - border: 'none', - cursor: 'pointer', - fontSize: '0.875rem', - fontWeight: 500, - }), - [theme], - ); - - const saveButtonStyle = useMemo( - () => ({ - ...buttonStyle, - backgroundColor: theme.colors.primary.main, - color: theme.colors.primary.contrastText, - }), - [theme, buttonStyle], - ); - - const cancelButtonStyle = useMemo( - () => ({ - ...buttonStyle, - backgroundColor: theme.colors.secondary.main, - border: `1px solid ${theme.colors.border}`, - }), - [theme, buttonStyle], - ); // Renders individual field in view or edit mode const renderField = ( @@ -486,23 +456,22 @@ const BaseOrganizationProfile: FC = ({ }} > {!hasValue && isFieldEditable && onStartEdit ? ( - + ) : ( displayValue )} @@ -532,12 +501,12 @@ const BaseOrganizationProfile: FC = ({ ...styles.field, display: 'flex', alignItems: 'center', - gap: `${theme.spacing.unit}px`, + gap: theme.vars.spacing.unit, }; return (
-
+
{renderField( field, isFieldEditing, @@ -550,36 +519,45 @@ const BaseOrganizationProfile: FC = ({ )}
{isFieldEditable && ( -
+
{isFieldEditing ? ( <> - - + + ) : ( // Only show pencil icon when there's a value hasValue && ( - + ) )}
@@ -616,7 +594,7 @@ const BaseOrganizationProfile: FC = ({ {title} -
{profileContent}
+
{profileContent}
); @@ -631,20 +609,20 @@ const useStyles = () => { return useMemo( () => ({ root: { - padding: `${theme.spacing.unit * 4}px`, + padding: `calc(${theme.vars.spacing.unit} * 4)`, minWidth: '600px', margin: '0 auto', } as CSSProperties, card: { - background: theme.colors.background.surface, - borderRadius: theme.borderRadius.large, + background: theme.vars.colors.background.surface, + borderRadius: theme.vars.borderRadius.large, } as CSSProperties, header: { display: 'flex', alignItems: 'center', - gap: `${theme.spacing.unit * 2}px`, - marginBottom: `${theme.spacing.unit * 3}px`, - paddingBottom: `${theme.spacing.unit * 2}px`, + gap: `calc(${theme.vars.spacing.unit} * 2)`, + marginBottom: `calc(${theme.vars.spacing.unit} * 3)`, + paddingBottom: `calc(${theme.vars.spacing.unit} * 2)`, } as CSSProperties, orgInfo: { flex: 1, @@ -653,24 +631,24 @@ const useStyles = () => { fontSize: '1.5rem', fontWeight: 600, margin: '0 0 8px 0', - color: theme.colors.text.primary, + color: theme.vars.colors.text.primary, } as CSSProperties, handle: { fontSize: '1rem', - color: theme.colors.text.secondary, + color: theme.vars.colors.text.secondary, margin: '0', fontFamily: 'monospace', } as CSSProperties, infoContainer: { display: 'flex', flexDirection: 'column' as const, - gap: `${theme.spacing.unit}px`, + gap: theme.vars.spacing.unit, } as CSSProperties, field: { display: 'flex', alignItems: 'flex-start', - padding: `${theme.spacing.unit / 2}px 0`, - borderBottom: `1px solid ${theme.colors.border}`, + padding: `calc(${theme.vars.spacing.unit} / 2) 0`, + borderBottom: `1px solid ${theme.vars.colors.border}`, minHeight: '28px', } as CSSProperties, lastField: { @@ -679,25 +657,25 @@ const useStyles = () => { label: { fontSize: '0.875rem', fontWeight: 500, - color: theme.colors.text.secondary, + color: theme.vars.colors.text.secondary, width: '120px', flexShrink: 0, lineHeight: '28px', } as CSSProperties, value: { - color: theme.colors.text.primary, + color: theme.vars.colors.text.primary, flex: 1, display: 'flex', alignItems: 'center', - gap: `${theme.spacing.unit}px`, + gap: theme.vars.spacing.unit, overflow: 'hidden', minHeight: '28px', lineHeight: '28px', wordBreak: 'break-word' as const, } as CSSProperties, statusBadge: { - padding: '4px 8px', - borderRadius: theme.borderRadius.small, + padding: `calc(${theme.vars.spacing.unit} / 2) ${theme.vars.spacing.unit}`, + borderRadius: theme.vars.borderRadius.small, fontSize: '0.75rem', fontWeight: 500, color: 'white', @@ -707,37 +685,37 @@ const useStyles = () => { permissionsList: { display: 'flex', flexWrap: 'wrap' as const, - gap: `${theme.spacing.unit / 2}px`, + gap: `calc(${theme.vars.spacing.unit} / 2)`, } as CSSProperties, permissionBadge: { - padding: '2px 8px', - borderRadius: theme.borderRadius.small, + padding: `calc(${theme.vars.spacing.unit} / 4) ${theme.vars.spacing.unit}`, + borderRadius: theme.vars.borderRadius.small, fontSize: '0.75rem', - backgroundColor: theme.colors.primary.main, - color: theme.colors.primary.contrastText, - border: `1px solid ${theme.colors.border}`, + backgroundColor: theme.vars.colors.primary.main, + color: theme.vars.colors.primary.contrastText, + border: `1px solid ${theme.vars.colors.border}`, } as CSSProperties, attributesList: { display: 'flex', flexDirection: 'column' as const, - gap: `${theme.spacing.unit / 4}px`, + gap: `calc(${theme.vars.spacing.unit} / 4)`, } as CSSProperties, attributeItem: { display: 'flex', - gap: `${theme.spacing.unit}px`, - padding: `${theme.spacing.unit / 4}px 0`, + gap: theme.vars.spacing.unit, + padding: `calc(${theme.vars.spacing.unit} / 4) 0`, alignItems: 'center', } as CSSProperties, attributeKey: { fontSize: '0.75rem', fontWeight: 500, - color: theme.colors.text.secondary, + color: theme.vars.colors.text.secondary, minWidth: '80px', flexShrink: 0, } as CSSProperties, attributeValue: { fontSize: '0.75rem', - color: theme.colors.text.primary, + color: theme.vars.colors.text.primary, wordBreak: 'break-word' as const, flex: 1, } as CSSProperties, diff --git a/packages/react/src/components/presentation/OrganizationSwitcher/BaseOrganizationSwitcher.tsx b/packages/react/src/components/presentation/OrganizationSwitcher/BaseOrganizationSwitcher.tsx index e26b7b54..fecc96d2 100644 --- a/packages/react/src/components/presentation/OrganizationSwitcher/BaseOrganizationSwitcher.tsx +++ b/packages/react/src/components/presentation/OrganizationSwitcher/BaseOrganizationSwitcher.tsx @@ -49,19 +49,19 @@ const useStyles = () => { trigger: { display: 'inline-flex', alignItems: 'center', - gap: `${theme.spacing.unit}px`, - padding: `${theme.spacing.unit * 0.75}px ${theme.spacing.unit}px`, - border: `1px solid ${theme.colors.border}`, - background: theme.colors.background.surface, + gap: theme.vars.spacing.unit, + padding: `calc(${theme.vars.spacing.unit} * 0.75) ${theme.vars.spacing.unit}`, + border: `1px solid ${theme.vars.colors.border}`, + background: theme.vars.colors.background.surface, cursor: 'pointer', - borderRadius: theme.borderRadius.medium, + borderRadius: theme.vars.borderRadius.medium, minWidth: '160px', '&:hover': { - backgroundColor: theme.colors.background, + backgroundColor: theme.vars.colors.background.surface, }, } as CSSProperties, orgName: { - color: theme.colors.text.primary, + color: theme.vars.colors.text.primary, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', @@ -71,12 +71,10 @@ const useStyles = () => { dropdownContent: { minWidth: '280px', maxWidth: '400px', - backgroundColor: theme.colors.background.surface, - borderRadius: theme.borderRadius.medium, - boxShadow: `0 4px 6px -1px ${ - colorScheme === 'dark' ? 'rgba(0, 0, 0, 0.4)' : 'rgba(0, 0, 0, 0.1)' - }, 0 2px 4px -1px ${colorScheme === 'dark' ? 'rgba(0, 0, 0, 0.3)' : 'rgba(0, 0, 0, 0.06)'}`, - border: `1px solid ${theme.colors.border}`, + backgroundColor: theme.vars.colors.background.surface, + borderRadius: theme.vars.borderRadius.medium, + boxShadow: theme.vars.shadows.medium, + border: `1px solid ${theme.vars.colors.border}`, outline: 'none', zIndex: 1000, } as CSSProperties, @@ -89,29 +87,32 @@ const useStyles = () => { display: 'flex', alignItems: 'center', justifyContent: 'flex-start', - gap: `${theme.spacing.unit}px`, - padding: `${theme.spacing.unit * 1.5}px ${theme.spacing.unit * 2}px`, + gap: theme.vars.spacing.unit, + padding: `calc(${theme.vars.spacing.unit} * 1.5) calc(${theme.vars.spacing.unit} * 2)`, width: '100%', - color: theme.colors.text.primary, + color: theme.vars.colors.text.primary, textDecoration: 'none', border: 'none', - background: 'none', + backgroundColor: 'none', cursor: 'pointer', fontSize: '0.875rem', textAlign: 'left', - borderRadius: theme.borderRadius.medium, + borderRadius: theme.vars.borderRadius.medium, transition: 'background-color 0.15s ease-in-out', + '&:hover': { + backgroundColor: theme.vars.colors.action?.hover || 'rgba(0, 0, 0, 0.04)', + }, } as CSSProperties, organizationInfo: { display: 'flex', flexDirection: 'column', - gap: `${theme.spacing.unit / 4}px`, + gap: `calc(${theme.vars.spacing.unit} / 4)`, flex: 1, minWidth: 0, overflow: 'hidden', } as CSSProperties, organizationName: { - color: theme.colors.text.primary, + color: theme.vars.colors.text.primary, fontSize: '0.875rem', fontWeight: 500, margin: 0, @@ -120,7 +121,7 @@ const useStyles = () => { whiteSpace: 'nowrap', } as CSSProperties, organizationMeta: { - color: theme.colors.text.secondary, + color: theme.vars.colors.text.secondary, fontSize: '0.75rem', margin: 0, overflow: 'hidden', @@ -128,25 +129,24 @@ const useStyles = () => { whiteSpace: 'nowrap', } as CSSProperties, divider: { - margin: `${theme.spacing.unit * 0.5}px 0`, - borderBottom: `1px solid ${theme.colors.border}`, + margin: `calc(${theme.vars.spacing.unit} * 0.5) 0`, + borderBottom: `1px solid ${theme.vars.colors.border}`, } as CSSProperties, dropdownHeader: { display: 'flex', alignItems: 'center', - gap: `${theme.spacing.unit}px`, - padding: `${theme.spacing.unit}px ${theme.spacing.unit * 2}px`, - borderBottom: `1px solid ${theme.colors.border}`, + gap: theme.vars.spacing.unit, + padding: `${theme.vars.spacing.unit} calc(${theme.vars.spacing.unit} * 2)`, } as CSSProperties, loadingContainer: { display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '80px', - gap: `${theme.spacing.unit}px`, + gap: theme.vars.spacing.unit, } as CSSProperties, loadingText: { - color: theme.colors.text.secondary, + color: theme.vars.colors.text.secondary, fontSize: '0.875rem', } as CSSProperties, errorContainer: { @@ -154,10 +154,10 @@ const useStyles = () => { alignItems: 'center', justifyContent: 'center', minHeight: '80px', - padding: `${theme.spacing.unit * 2}px`, + padding: `calc(${theme.vars.spacing.unit} * 2)`, } as CSSProperties, errorText: { - color: theme.colors.text.secondary, + color: theme.vars.colors.text.secondary, fontSize: '0.875rem', textAlign: 'center', } as CSSProperties, @@ -171,12 +171,12 @@ const useStyles = () => { sectionHeader: { textTransform: 'uppercase', letterSpacing: '0.05em', - color: theme.colors.text.secondary, + color: theme.vars.colors.text.secondary, } as CSSProperties, sectionHeaderContainer: { borderTop: 'none', borderBottom: 'none', - paddingBottom: `${theme.spacing.unit / 2}px`, + paddingBottom: `calc(${theme.vars.spacing.unit} / 2)`, } as CSSProperties, }), [theme, colorScheme], @@ -333,8 +333,6 @@ export const BaseOrganizationSwitcher: FC = ({ const {theme, colorScheme} = useTheme(); const {t} = useTranslation(); - const hoverBackgroundColor = colorScheme === 'dark' ? 'rgba(255, 255, 255, 0.08)' : 'rgba(0, 0, 0, 0.04)'; - const {refs, floatingStyles, context} = useFloating({ open: isOpen, onOpenChange: setIsOpen, @@ -396,7 +394,7 @@ export const BaseOrganizationSwitcher: FC = ({ {showRole && organization.role && {organization.role}}
- {isSelected && } + {isSelected && } ); @@ -564,7 +562,7 @@ export const BaseOrganizationSwitcher: FC = ({ style={{ ...styles.dropdownHeader, ...styles.sectionHeaderContainer, - borderTop: currentOrganization ? `1px solid ${theme.colors.border}` : 'none', + borderTop: currentOrganization ? `1px solid ${theme.vars.colors.border}` : 'none', }} > @@ -603,7 +601,7 @@ export const BaseOrganizationSwitcher: FC = ({ ...styles.menuItem, backgroundColor: hoveredItemIndex === switchableOrganizations.indexOf(organization) - ? hoverBackgroundColor + ? theme.vars.colors.action?.hover : 'transparent', }} onMouseEnter={(): void => setHoveredItemIndex(switchableOrganizations.indexOf(organization))} @@ -633,7 +631,7 @@ export const BaseOrganizationSwitcher: FC = ({ ...styles.menuItem, backgroundColor: hoveredItemIndex === switchableOrganizations.length + index - ? hoverBackgroundColor + ? theme.vars.colors.action?.hover : 'transparent', }} className={withVendorCSSClassPrefix('organization-switcher__menu-item')} @@ -652,7 +650,7 @@ export const BaseOrganizationSwitcher: FC = ({ ...styles.menuItem, backgroundColor: hoveredItemIndex === switchableOrganizations.length + index - ? hoverBackgroundColor + ? theme.vars.colors.action?.hover : 'transparent', }} className={withVendorCSSClassPrefix('organization-switcher__menu-item')} diff --git a/packages/react/src/components/presentation/OrganizationSwitcher/OrganizationSwitcher.tsx b/packages/react/src/components/presentation/OrganizationSwitcher/OrganizationSwitcher.tsx index 108c3895..efdb2466 100644 --- a/packages/react/src/components/presentation/OrganizationSwitcher/OrganizationSwitcher.tsx +++ b/packages/react/src/components/presentation/OrganizationSwitcher/OrganizationSwitcher.tsx @@ -88,7 +88,7 @@ export const OrganizationSwitcher: FC = ({ const {isSignedIn} = useAsgardeo(); const { currentOrganization: contextCurrentOrganization, - organizations: contextOrganizations, + myOrganizations: contextOrganizations, switchOrganization, isLoading, error, diff --git a/packages/react/src/components/presentation/SignIn/BaseSignIn.tsx b/packages/react/src/components/presentation/SignIn/BaseSignIn.tsx index cc51086b..6552bc5a 100644 --- a/packages/react/src/components/presentation/SignIn/BaseSignIn.tsx +++ b/packages/react/src/components/presentation/SignIn/BaseSignIn.tsx @@ -30,15 +30,17 @@ import { EmbeddedFlowExecuteRequestConfig, } from '@asgardeo/browser'; import {clsx} from 'clsx'; -import {FC, ReactElement, FormEvent, useEffect, useState, useCallback, useRef} from 'react'; +import {FC, ReactElement, FormEvent, useEffect, useState, useCallback, useRef, useMemo, CSSProperties} from 'react'; import {createSignInOptionFromAuthenticator} from './options/SignInOptionFactory'; import FlowProvider from '../../../contexts/Flow/FlowProvider'; import useFlow from '../../../contexts/Flow/useFlow'; import {useForm, FormField} from '../../../hooks/useForm'; import useTranslation from '../../../hooks/useTranslation'; +import useTheme from '../../../contexts/Theme/useTheme'; import Alert from '../../primitives/Alert/Alert'; import Card, {CardProps} from '../../primitives/Card/Card'; import Divider from '../../primitives/Divider/Divider'; +import Logo from '../../primitives/Logo/Logo'; import Spinner from '../../primitives/Spinner/Spinner'; import Typography from '../../primitives/Typography/Typography'; @@ -283,6 +285,71 @@ export interface BaseSignInProps { variant?: CardProps['variant']; } +/** + * Custom hook for managing component styles + */ +const useStyles = () => { + const {theme} = useTheme(); + + return useMemo( + () => ({ + card: { + gap: `calc(${theme.vars.spacing.unit} * 2)`, + } as CSSProperties, + header: { + gap: 0, + } as CSSProperties, + subtitle: { + marginTop: `calc(${theme.vars.spacing.unit} * 1)`, + } as CSSProperties, + messagesContainer: { + marginTop: `calc(${theme.vars.spacing.unit} * 2)`, + } as CSSProperties, + messageItem: { + marginBottom: `calc(${theme.vars.spacing.unit} * 1)`, + } as CSSProperties, + errorContainer: { + marginBottom: `calc(${theme.vars.spacing.unit} * 2)`, + } as CSSProperties, + contentContainer: { + display: 'flex', + flexDirection: 'column', + gap: `calc(${theme.vars.spacing.unit} * 2)`, + } as CSSProperties, + loadingContainer: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + padding: `calc(${theme.vars.spacing.unit} * 4)`, + } as CSSProperties, + loadingText: { + marginTop: `calc(${theme.vars.spacing.unit} * 2)`, + } as CSSProperties, + divider: { + margin: `calc(${theme.vars.spacing.unit} * 1) 0`, + } as CSSProperties, + logoContainer: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + marginBottom: `calc(${theme.vars.spacing.unit} * 3)`, + } as CSSProperties, + centeredContainer: { + textAlign: 'center', + padding: `calc(${theme.vars.spacing.unit} * 4)`, + } as CSSProperties, + passkeyContainer: { + marginBottom: `calc(${theme.vars.spacing.unit} * 2)`, + } as CSSProperties, + passkeyText: { + marginTop: `calc(${theme.vars.spacing.unit} * 1)`, + color: theme.vars.colors.text.secondary, + } as CSSProperties, + }), + [theme.vars.spacing.unit, theme.vars.colors.text.secondary], + ); +}; + /** * Base SignIn component that provides native authentication flow. * This component handles both the presentation layer and authentication flow logic. @@ -315,11 +382,21 @@ export interface BaseSignInProps { * }; * ``` */ -const BaseSignIn: FC = props => ( - - - -); +const BaseSignIn: FC = props => { + const {theme} = useTheme(); + const styles = useStyles(); + + return ( +
+
+ +
+ + + +
+ ); +}; /** * Internal component that consumes FlowContext and renders the sign-in UI. @@ -340,8 +417,10 @@ const BaseSignInContent: FC = ({ size = 'medium', variant = 'outlined', }: BaseSignInProps) => { + const {theme} = useTheme(); const {t} = useTranslation(); const {subtitle: flowSubtitle, title: flowTitle, messages: flowMessages} = useFlow(); + const styles = useStyles(); const [isSignInInitializationRequestLoading, setIsSignInInitializationRequestLoading] = useState(false); const [isInitialized, setIsInitialized] = useState(false); @@ -424,7 +503,8 @@ const BaseSignInContent: FC = ({ */ const handleRedirectionIfNeeded = (response: EmbeddedSignInFlowHandleResponse): boolean => { if ( - response && 'nextStep' in response && + response && + 'nextStep' in response && response.nextStep && (response.nextStep as any).stepType === EmbeddedSignInFlowStepType.AuthenticatorPrompt && (response.nextStep as any).authenticators && @@ -1089,11 +1169,11 @@ const BaseSignInContent: FC = ({ if (!isInitialized && isLoading) { return ( - + -
+
- + {t('messages.loading')}
@@ -1115,21 +1195,21 @@ const BaseSignInContent: FC = ({ const optionAuthenticators = availableAuthenticators.filter(auth => !userPromptAuthenticators.includes(auth)); return ( - - - {flowTitle || t('signin.title')} + + + {flowTitle || t('signin.title')} {flowSubtitle && ( - + {flowSubtitle || t('signin.subtitle')} )} {flowMessages && flowMessages.length > 0 && ( -
+
{flowMessages.map((flowMessage, index) => ( {flowMessage.message} @@ -1138,7 +1218,7 @@ const BaseSignInContent: FC = ({
)} {messages.length > 0 && ( -
+
{messages.map((message, index) => { const variant = message.type.toLowerCase() === 'error' @@ -1150,7 +1230,7 @@ const BaseSignInContent: FC = ({ : 'info'; return ( - + {message.message} ); @@ -1161,17 +1241,17 @@ const BaseSignInContent: FC = ({ {error && ( - + Error {error} )} -
+
{/* Render USER_PROMPT authenticators as form fields */} {userPromptAuthenticators.map((authenticator, index) => (
- {index > 0 && OR} + {index > 0 && OR}
{ e.preventDefault(); @@ -1201,7 +1281,7 @@ const BaseSignInContent: FC = ({ {/* Add divider between user prompts and option authenticators if both exist */} {userPromptAuthenticators.length > 0 && optionAuthenticators.length > 0 && ( - OR + OR )} {/* Render all other authenticators (REDIRECTION_PROMPT, multi-option buttons, etc.) */} @@ -1254,12 +1334,12 @@ const BaseSignInContent: FC = ({ return ( -
-
+
+
{t('passkey.authenticating') || 'Authenticating with passkey...'} - + {t('passkey.instruction') || 'Please use your fingerprint, face, or security key to authenticate.'}
@@ -1269,19 +1349,19 @@ const BaseSignInContent: FC = ({ } return ( - - + + {flowTitle || t('signin.title')} - + {flowSubtitle || t('signin.subtitle')} {flowMessages && flowMessages.length > 0 && ( -
+
{flowMessages.map((flowMessage, index) => ( {flowMessage.message} @@ -1290,7 +1370,7 @@ const BaseSignInContent: FC = ({
)} {messages.length > 0 && ( -
+
{messages.map((message, index) => { const variant = message.type.toLowerCase() === 'error' @@ -1302,7 +1382,7 @@ const BaseSignInContent: FC = ({ : 'info'; return ( - + {message.message} ); @@ -1313,7 +1393,7 @@ const BaseSignInContent: FC = ({ {error && ( - + {t('errors.title')} {error} diff --git a/packages/react/src/components/presentation/SignIn/options/EmailOtp.tsx b/packages/react/src/components/presentation/SignIn/options/EmailOtp.tsx index c542eaa8..e7451c78 100644 --- a/packages/react/src/components/presentation/SignIn/options/EmailOtp.tsx +++ b/packages/react/src/components/presentation/SignIn/options/EmailOtp.tsx @@ -24,6 +24,7 @@ import OtpField from '../../../primitives/OtpField/OtpField'; import {BaseSignInOptionProps} from './SignInOptionFactory'; import useTranslation from '../../../../hooks/useTranslation'; import useFlow from '../../../../contexts/Flow/useFlow'; +import useTheme from '../../../../contexts/Theme/useTheme'; /** * Email OTP Sign-In Option Component. @@ -40,6 +41,7 @@ const EmailOtp: FC = ({ buttonClassName = '', preferences, }) => { + const {theme} = useTheme(); const {t} = useTranslation(preferences?.i18n); const {setTitle, setSubtitle} = useFlow(); @@ -61,7 +63,7 @@ const EmailOtp: FC = ({ const isOtpParam = param.param.toLowerCase().includes('otp') || param.param.toLowerCase().includes('code'); return ( -
+
{isOtpParam && hasOtpField ? ( = ({ color="primary" variant="solid" fullWidth - style={{marginBottom: '1rem'}} + style={{marginBottom: `calc(${theme.vars.spacing.unit} * 2)`}} > {t('email.otp.submit.button')} diff --git a/packages/react/src/components/presentation/SignIn/options/IdentifierFirst.tsx b/packages/react/src/components/presentation/SignIn/options/IdentifierFirst.tsx index aceadf88..ab939a55 100644 --- a/packages/react/src/components/presentation/SignIn/options/IdentifierFirst.tsx +++ b/packages/react/src/components/presentation/SignIn/options/IdentifierFirst.tsx @@ -23,6 +23,7 @@ import Button from '../../../primitives/Button/Button'; import {BaseSignInOptionProps} from './SignInOptionFactory'; import useTranslation from '../../../../hooks/useTranslation'; import useFlow from '../../../../contexts/Flow/useFlow'; +import useTheme from '../../../../contexts/Theme/useTheme'; /** * Identifier First Sign-In Option Component. @@ -39,6 +40,7 @@ const IdentifierFirst: FC = ({ buttonClassName = '', preferences, }) => { + const {theme} = useTheme(); const {t} = useTranslation(preferences?.i18n); const {setTitle, setSubtitle} = useFlow(); @@ -52,7 +54,7 @@ const IdentifierFirst: FC = ({ return ( <> {formFields.map(param => ( -
+
{createField({ name: param.param, type: @@ -83,7 +85,7 @@ const IdentifierFirst: FC = ({ color="primary" variant="solid" fullWidth - style={{marginBottom: '1rem'}} + style={{marginBottom: `calc(${theme.vars.spacing.unit} * 2)`}} > {t('identifier.first.submit.button')} diff --git a/packages/react/src/components/presentation/SignIn/options/MultiOptionButton.tsx b/packages/react/src/components/presentation/SignIn/options/MultiOptionButton.tsx index 97776733..c603f7dd 100644 --- a/packages/react/src/components/presentation/SignIn/options/MultiOptionButton.tsx +++ b/packages/react/src/components/presentation/SignIn/options/MultiOptionButton.tsx @@ -94,8 +94,8 @@ const MultiOptionButton: FC = ({ ); case ApplicationNativeAuthenticationConstants.SupportedAuthenticators.Passkey: return ( - - + + {' '} diff --git a/packages/react/src/components/presentation/SignIn/options/SmsOtp.tsx b/packages/react/src/components/presentation/SignIn/options/SmsOtp.tsx index 9b325cf2..6d6c9257 100644 --- a/packages/react/src/components/presentation/SignIn/options/SmsOtp.tsx +++ b/packages/react/src/components/presentation/SignIn/options/SmsOtp.tsx @@ -24,6 +24,7 @@ import OtpField from '../../../primitives/OtpField/OtpField'; import {BaseSignInOptionProps} from './SignInOptionFactory'; import useTranslation from '../../../../hooks/useTranslation'; import useFlow from '../../../../contexts/Flow/useFlow'; +import useTheme from '../../../../contexts/Theme/useTheme'; /** * SMS OTP Sign-In Option Component. @@ -40,6 +41,7 @@ const SmsOtp: FC = ({ buttonClassName = '', preferences, }) => { + const {theme} = useTheme(); const {t} = useTranslation(preferences?.i18n); const {setTitle, setSubtitle} = useFlow(); @@ -60,7 +62,7 @@ const SmsOtp: FC = ({ const isOtpParam = param.param.toLowerCase().includes('otp') || param.param.toLowerCase().includes('code'); return ( -
+
{isOtpParam && hasOtpField ? ( = ({ color="primary" variant="solid" fullWidth - style={{marginBottom: '1rem'}} + style={{marginBottom: `calc(${theme.vars.spacing.unit} * 2)`}} > {t('sms.otp.submit.button')} diff --git a/packages/react/src/components/presentation/SignIn/options/SocialButton.tsx b/packages/react/src/components/presentation/SignIn/options/SocialButton.tsx index bc660d29..db4e80c9 100644 --- a/packages/react/src/components/presentation/SignIn/options/SocialButton.tsx +++ b/packages/react/src/components/presentation/SignIn/options/SocialButton.tsx @@ -61,7 +61,7 @@ const SocialLogin: FC = ({ startIcon={ diff --git a/packages/react/src/components/presentation/SignIn/options/Totp.tsx b/packages/react/src/components/presentation/SignIn/options/Totp.tsx index b1d61328..de1bcfcd 100644 --- a/packages/react/src/components/presentation/SignIn/options/Totp.tsx +++ b/packages/react/src/components/presentation/SignIn/options/Totp.tsx @@ -24,6 +24,7 @@ import OtpField from '../../../primitives/OtpField/OtpField'; import {BaseSignInOptionProps} from './SignInOptionFactory'; import useTranslation from '../../../../hooks/useTranslation'; import useFlow from '../../../../contexts/Flow/useFlow'; +import useTheme from '../../../../contexts/Theme/useTheme'; /** * TOTP Sign-In Option Component. @@ -40,6 +41,7 @@ const Totp: FC = ({ buttonClassName = '', preferences, }) => { + const {theme} = useTheme(); const {t} = useTranslation(preferences?.i18n); const {setTitle, setSubtitle} = useFlow(); @@ -60,7 +62,7 @@ const Totp: FC = ({ const isTotpParam = param.param.toLowerCase().includes('totp') || param.param.toLowerCase().includes('token'); return ( -
+
{isTotpParam && hasTotpField ? ( = ({ color="primary" variant="solid" fullWidth - style={{marginBottom: '1rem'}} + style={{marginBottom: `calc(${theme.vars.spacing.unit} * 2)`}} > {t('totp.submit.button')} diff --git a/packages/react/src/components/presentation/SignIn/options/UsernamePassword.tsx b/packages/react/src/components/presentation/SignIn/options/UsernamePassword.tsx index 602462d9..6278b4b8 100644 --- a/packages/react/src/components/presentation/SignIn/options/UsernamePassword.tsx +++ b/packages/react/src/components/presentation/SignIn/options/UsernamePassword.tsx @@ -23,6 +23,7 @@ import Button from '../../../primitives/Button/Button'; import {BaseSignInOptionProps} from './SignInOptionFactory'; import useTranslation from '../../../../hooks/useTranslation'; import useFlow from '../../../../contexts/Flow/useFlow'; +import useTheme from '../../../../contexts/Theme/useTheme'; /** * Username Password Sign-In Option Component. @@ -39,6 +40,7 @@ const UsernamePassword: FC = ({ buttonClassName = '', preferences, }) => { + const {theme} = useTheme(); const {t} = useTranslation(preferences?.i18n); const {setTitle, setSubtitle} = useFlow(); @@ -53,7 +55,7 @@ const UsernamePassword: FC = ({ return ( <> {formFields.map(param => ( -
+
{createField({ name: param.param, type: @@ -84,7 +86,7 @@ const UsernamePassword: FC = ({ color="primary" variant="solid" fullWidth - style={{marginBottom: '1rem'}} + style={{marginBottom: `calc(${theme.vars.spacing.unit} * 2)`}} > {t('username.password.submit.button')} diff --git a/packages/react/src/components/presentation/SignUp/BaseSignUp.tsx b/packages/react/src/components/presentation/SignUp/BaseSignUp.tsx index f2491f15..d9f80566 100644 --- a/packages/react/src/components/presentation/SignUp/BaseSignUp.tsx +++ b/packages/react/src/components/presentation/SignUp/BaseSignUp.tsx @@ -26,14 +26,16 @@ import { AsgardeoAPIError, } from '@asgardeo/browser'; import {clsx} from 'clsx'; -import {FC, ReactElement, FormEvent, useEffect, useState, useCallback, useRef} from 'react'; +import {FC, ReactElement, FormEvent, useEffect, useState, useCallback, useRef, useMemo, CSSProperties} from 'react'; import {renderSignUpComponents} from './options/SignUpOptionFactory'; import FlowProvider from '../../../contexts/Flow/FlowProvider'; import useFlow from '../../../contexts/Flow/useFlow'; import {useForm, FormField} from '../../../hooks/useForm'; import useTranslation from '../../../hooks/useTranslation'; +import useTheme from '../../../contexts/Theme/useTheme'; import Alert from '../../primitives/Alert/Alert'; import Card, {CardProps} from '../../primitives/Card/Card'; +import Logo from '../../primitives/Logo/Logo'; import Spinner from '../../primitives/Spinner/Spinner'; import Typography from '../../primitives/Typography/Typography'; @@ -120,6 +122,71 @@ export interface BaseSignUpProps { shouldRedirectAfterSignUp?: boolean; } +/** + * Custom hook for managing component styles + */ +const useStyles = () => { + const {theme} = useTheme(); + + return useMemo( + () => ({ + card: { + gap: `calc(${theme.vars.spacing.unit} * 2)`, + } as CSSProperties, + header: { + gap: 0, + } as CSSProperties, + subtitle: { + marginTop: `calc(${theme.vars.spacing.unit} * 1)`, + } as CSSProperties, + messagesContainer: { + marginTop: `calc(${theme.vars.spacing.unit} * 2)`, + } as CSSProperties, + messageItem: { + marginBottom: `calc(${theme.vars.spacing.unit} * 1)`, + } as CSSProperties, + errorContainer: { + marginBottom: `calc(${theme.vars.spacing.unit} * 2)`, + } as CSSProperties, + contentContainer: { + display: 'flex', + flexDirection: 'column', + gap: `calc(${theme.vars.spacing.unit} * 2)`, + } as CSSProperties, + loadingContainer: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + padding: `calc(${theme.vars.spacing.unit} * 4)`, + } as CSSProperties, + loadingText: { + marginTop: `calc(${theme.vars.spacing.unit} * 2)`, + } as CSSProperties, + divider: { + margin: `calc(${theme.vars.spacing.unit} * 1) 0`, + } as CSSProperties, + logoContainer: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + marginBottom: `calc(${theme.vars.spacing.unit} * 3)`, + } as CSSProperties, + centeredContainer: { + textAlign: 'center', + padding: `calc(${theme.vars.spacing.unit} * 4)`, + } as CSSProperties, + passkeyContainer: { + marginBottom: `calc(${theme.vars.spacing.unit} * 2)`, + } as CSSProperties, + passkeyText: { + marginTop: `calc(${theme.vars.spacing.unit} * 1)`, + color: theme.vars.colors.text.secondary, + } as CSSProperties, + }), + [theme.vars.spacing.unit, theme.vars.colors.text.secondary], + ); +}; + /** * Base SignUp component that provides embedded sign-up flow. * This component handles both the presentation layer and sign-up flow logic. @@ -155,11 +222,21 @@ export interface BaseSignUpProps { * }; * ``` */ -const BaseSignUp: FC = props => ( - - - -); +const BaseSignUp: FC = props => { + const {theme} = useTheme(); + const styles = useStyles(); + + return ( +
+
+ +
+ + + +
+ ); +}; /** * Internal component that consumes FlowContext and renders the sign-up UI. @@ -180,8 +257,10 @@ const BaseSignUpContent: FC = ({ variant = 'outlined', isInitialized, }) => { + const {theme} = useTheme(); const {t} = useTranslation(); const {subtitle: flowSubtitle, title: flowTitle, messages: flowMessages} = useFlow(); + const styles = useStyles(); const [isLoading, setIsLoading] = useState(false); const [isFlowInitialized, setIsFlowInitialized] = useState(false); @@ -632,9 +711,9 @@ const BaseSignUpContent: FC = ({ if (!isFlowInitialized && isLoading) { return ( - + -
+
@@ -644,7 +723,7 @@ const BaseSignUpContent: FC = ({ if (!currentFlow) { return ( - + {t('errors.title') || 'Error'} @@ -656,33 +735,36 @@ const BaseSignUpContent: FC = ({ } return ( - - - {flowMessages && flowMessages.length > 0 && ( -
+ + {flowMessages && flowMessages.length > 0 && ( + +
{flowMessages.map((message: any, index: number) => ( {message.message} ))}
- )} -
- + + )} {error && ( - + {t('errors.title') || 'Error'} {error} )} -
+
{currentFlow.data?.components && renderComponents(currentFlow.data.components)}
diff --git a/packages/react/src/components/presentation/SignUp/options/DividerComponent.tsx b/packages/react/src/components/presentation/SignUp/options/DividerComponent.tsx index 57c49711..a02a5acb 100644 --- a/packages/react/src/components/presentation/SignUp/options/DividerComponent.tsx +++ b/packages/react/src/components/presentation/SignUp/options/DividerComponent.tsx @@ -19,11 +19,13 @@ import {FC} from 'react'; import {BaseSignUpOptionProps} from './SignUpOptionFactory'; import Divider from '../../../primitives/Divider/Divider'; +import useTheme from '../../../../contexts/Theme/useTheme'; /** * Divider component for sign-up forms. */ const DividerComponent: FC = ({component}) => { + const {theme} = useTheme(); const config = component.config || {}; const text = config['text'] || ''; const variant = component.variant?.toLowerCase() || 'horizontal'; @@ -32,7 +34,7 @@ const DividerComponent: FC = ({component}) => { {text} diff --git a/packages/react/src/components/presentation/SignUp/options/ImageComponent.tsx b/packages/react/src/components/presentation/SignUp/options/ImageComponent.tsx index 5ed54f86..ce753778 100644 --- a/packages/react/src/components/presentation/SignUp/options/ImageComponent.tsx +++ b/packages/react/src/components/presentation/SignUp/options/ImageComponent.tsx @@ -18,11 +18,13 @@ import {FC} from 'react'; import {BaseSignUpOptionProps} from './SignUpOptionFactory'; +import useTheme from '../../../../contexts/Theme/useTheme'; /** * Image component for sign-up forms. */ const ImageComponent: FC = ({component}) => { + const {theme} = useTheme(); const config = component.config || {}; const src = config['src'] || ''; const alt = config['alt'] || config['label'] || 'Image'; @@ -33,7 +35,7 @@ const ImageComponent: FC = ({component}) => { height: 'auto', display: 'block', margin: variant === 'image_block' ? '1rem auto' : '0', - borderRadius: '4px', + borderRadius: theme.vars.borderRadius.small, }; if (!src) { diff --git a/packages/react/src/components/presentation/SignUp/options/SocialButton.tsx b/packages/react/src/components/presentation/SignUp/options/SocialButton.tsx index 80038530..57f09240 100644 --- a/packages/react/src/components/presentation/SignUp/options/SocialButton.tsx +++ b/packages/react/src/components/presentation/SignUp/options/SocialButton.tsx @@ -53,7 +53,7 @@ const SocialButton: FC = ({ startIcon={ diff --git a/packages/react/src/components/presentation/SignUp/options/Typography.tsx b/packages/react/src/components/presentation/SignUp/options/Typography.tsx index 566648d5..8bec84fd 100644 --- a/packages/react/src/components/presentation/SignUp/options/Typography.tsx +++ b/packages/react/src/components/presentation/SignUp/options/Typography.tsx @@ -19,11 +19,13 @@ import {FC} from 'react'; import {BaseSignUpOptionProps} from './SignUpOptionFactory'; import Typography from '../../../primitives/Typography/Typography'; +import useTheme from '../../../../contexts/Theme/useTheme'; /** * Typography component for sign-up forms (titles, descriptions, etc.). */ const TypographyComponent: FC = ({component}) => { + const {theme} = useTheme(); const config = component.config || {}; const text = config['text'] || config['content'] || ''; const variant = component.variant?.toLowerCase() || 'body1'; @@ -67,7 +69,11 @@ const TypographyComponent: FC = ({component}) => { } return ( - + {text} ); diff --git a/packages/react/src/components/presentation/UserDropdown/BaseUserDropdown.tsx b/packages/react/src/components/presentation/UserDropdown/BaseUserDropdown.tsx index 3ac92706..890379a1 100644 --- a/packages/react/src/components/presentation/UserDropdown/BaseUserDropdown.tsx +++ b/packages/react/src/components/presentation/UserDropdown/BaseUserDropdown.tsx @@ -48,18 +48,18 @@ const useStyles = () => { trigger: { display: 'inline-flex', alignItems: 'center', - gap: `${theme.spacing.unit}px`, - padding: `${theme.spacing.unit * 0.5}px`, + gap: theme.vars.spacing.unit, + padding: `calc(${theme.vars.spacing.unit} * 0.5)`, border: 'none', - background: 'none', + backgroundColor: 'none', cursor: 'pointer', - borderRadius: theme.borderRadius.medium, + borderRadius: theme.vars.borderRadius.medium, '&:hover': { - backgroundColor: theme.colors.background, + backgroundColor: theme.vars.colors.background.surface, }, } as CSSProperties, userName: { - color: theme.colors.text.primary, + color: theme.vars.colors.text.primary, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', @@ -67,14 +67,12 @@ const useStyles = () => { maxWidth: '120px', } as CSSProperties, dropdownContent: { - minWidth: '200px', - maxWidth: '300px', - backgroundColor: theme.colors.background.surface, - borderRadius: theme.borderRadius.medium, - boxShadow: `0 4px 6px -1px ${ - colorScheme === 'dark' ? 'rgba(0, 0, 0, 0.4)' : 'rgba(0, 0, 0, 0.1)' - }, 0 2px 4px -1px ${colorScheme === 'dark' ? 'rgba(0, 0, 0, 0.3)' : 'rgba(0, 0, 0, 0.06)'}`, - border: `1px solid ${theme.colors.border}`, + minWidth: '270px', + maxWidth: '500px', + backgroundColor: theme.vars.colors.background.surface, + borderRadius: theme.vars.borderRadius.medium, + boxShadow: theme.vars.shadows.medium, + border: `1px solid ${theme.vars.colors.border}`, outline: 'none', zIndex: 1000, } as CSSProperties, @@ -87,51 +85,51 @@ const useStyles = () => { display: 'flex', alignItems: 'center', justifyContent: 'flex-start', - gap: `${theme.spacing.unit}px`, - padding: `${theme.spacing.unit * 1.5}px ${theme.spacing.unit * 2}px`, + gap: theme.vars.spacing.unit, + padding: `calc(${theme.vars.spacing.unit} * 1.5) calc(${theme.vars.spacing.unit} * 2)`, width: '100%', - color: theme.colors.text.primary, + color: theme.vars.colors.text.primary, textDecoration: 'none', border: 'none', - background: 'none', + backgroundColor: 'none', cursor: 'pointer', fontSize: '0.875rem', textAlign: 'left', - borderRadius: theme.borderRadius.medium, + borderRadius: theme.vars.borderRadius.medium, transition: 'background-color 0.15s ease-in-out', } as CSSProperties, menuItemAnchor: { display: 'flex', alignItems: 'center', justifyContent: 'flex-start', - gap: `${theme.spacing.unit}px`, - padding: `${theme.spacing.unit * 1.5}px ${theme.spacing.unit * 2}px`, + gap: theme.vars.spacing.unit, + padding: `calc(${theme.vars.spacing.unit} * 1.5) calc(${theme.vars.spacing.unit} * 2)`, width: '100%', - color: theme.colors.text.primary, + color: theme.vars.colors.text.primary, textDecoration: 'none', border: 'none', background: 'none', cursor: 'pointer', fontSize: '0.875rem', textAlign: 'left', - borderRadius: theme.borderRadius.medium, + borderRadius: theme.vars.borderRadius.medium, transition: 'background-color 0.15s ease-in-out', } as CSSProperties, divider: { - margin: `${theme.spacing.unit * 0.5}px 0`, - borderBottom: `1px solid ${theme.colors.border}`, + margin: `calc(${theme.vars.spacing.unit} * 0.5) 0`, + borderBottom: `1px solid ${theme.vars.colors.border}`, } as CSSProperties, dropdownHeader: { display: 'flex', alignItems: 'center', - gap: `${theme.spacing.unit}px`, - padding: `${theme.spacing.unit * 1.5}px`, - borderBottom: `1px solid ${theme.colors.border}`, + gap: theme.vars.spacing.unit, + padding: `calc(${theme.vars.spacing.unit} * 1.5)`, + borderBottom: `1px solid ${theme.vars.colors.border}`, } as CSSProperties, headerInfo: { display: 'flex', flexDirection: 'column', - gap: `${theme.spacing.unit / 4}px`, + gap: `calc(${theme.vars.spacing.unit} / 4)`, flex: 1, minWidth: 0, overflow: 'hidden', @@ -139,7 +137,7 @@ const useStyles = () => { whiteSpace: 'nowrap', } as CSSProperties, headerName: { - color: theme.colors.text.primary, + color: theme.vars.colors.text.primary, fontSize: '1rem', fontWeight: 500, margin: 0, @@ -148,7 +146,7 @@ const useStyles = () => { whiteSpace: 'nowrap', } as CSSProperties, headerEmail: { - color: theme.colors.text.secondary, + color: theme.vars.colors.text.secondary, fontSize: '0.875rem', margin: 0, overflow: 'hidden', @@ -160,10 +158,10 @@ const useStyles = () => { alignItems: 'center', justifyContent: 'center', minHeight: '80px', - gap: `${theme.spacing.unit}px`, + gap: theme.vars.spacing.unit, } as CSSProperties, loadingText: { - color: theme.colors.text.secondary, + color: theme.vars.colors.text.secondary, fontSize: '0.875rem', } as CSSProperties, }), @@ -259,8 +257,6 @@ export const BaseUserDropdown: FC = ({ const [hoveredItemIndex, setHoveredItemIndex] = useState(null); const {theme, colorScheme} = useTheme(); - const hoverBackgroundColor = colorScheme === 'dark' ? 'rgba(255, 255, 255, 0.08)' : 'rgba(0, 0, 0, 0.04)'; - const {refs, floatingStyles, context} = useFloating({ open: isOpen, onOpenChange: setIsOpen, @@ -411,7 +407,7 @@ export const BaseUserDropdown: FC = ({ href={item.href} style={{ ...styles.menuItemAnchor, - backgroundColor: hoveredItemIndex === index ? hoverBackgroundColor : 'transparent', + backgroundColor: hoveredItemIndex === index ? theme.vars.colors.action?.hover : 'transparent', }} className={withVendorCSSClassPrefix('user-dropdown__menu-item')} onMouseEnter={() => setHoveredItemIndex(index)} @@ -427,7 +423,7 @@ export const BaseUserDropdown: FC = ({ onClick={() => handleMenuItemClick(item)} style={{ ...styles.menuItem, - backgroundColor: hoveredItemIndex === index ? hoverBackgroundColor : 'transparent', + backgroundColor: hoveredItemIndex === index ? theme.vars.colors.action?.hover : 'transparent', }} className={withVendorCSSClassPrefix('user-dropdown__menu-item')} color="tertiary" diff --git a/packages/react/src/components/presentation/UserProfile/BaseUserProfile.tsx b/packages/react/src/components/presentation/UserProfile/BaseUserProfile.tsx index 50e59dd6..32ecaf57 100644 --- a/packages/react/src/components/presentation/UserProfile/BaseUserProfile.tsx +++ b/packages/react/src/components/presentation/UserProfile/BaseUserProfile.tsx @@ -82,7 +82,6 @@ const fieldsToSkip: string[] = [ 'roles.default', 'active', 'groups', - 'profileUrl', 'accountLocked', 'accountDisabled', 'oneTimePassword', @@ -107,7 +106,7 @@ const BaseUserProfile: FC = ({ className = '', cardLayout = true, profile, - schemas, + schemas = [], flattenedProfile, mode = 'inline', title = 'User Profile', @@ -175,11 +174,11 @@ const BaseUserProfile: FC = ({ {Object.entries(data).map(([key, value]) => ( - - + - @@ -275,36 +274,6 @@ const BaseUserProfile: FC = ({ ); const styles = useStyles(); - const buttonStyle = useMemo( - () => ({ - padding: `${theme.spacing.unit}px ${theme.spacing.unit * 2}px`, - margin: `${theme.spacing.unit}px`, - borderRadius: theme.borderRadius.medium, - border: 'none', - cursor: 'pointer', - fontSize: '0.875rem', - fontWeight: 500, - }), - [theme], - ); - - const saveButtonStyle = useMemo( - () => ({ - ...buttonStyle, - backgroundColor: theme.colors.primary.main, - color: theme.colors.primary.contrastText, - }), - [theme, buttonStyle], - ); - - const cancelButtonStyle = useMemo( - () => ({ - ...buttonStyle, - backgroundColor: theme.colors.secondary.main, - border: `1px solid ${theme.colors.border}`, - }), - [theme, buttonStyle], - ); const defaultAttributeMappings = { picture: ['profile', 'profileUrl', 'picture', 'URL'], @@ -421,23 +390,22 @@ const BaseUserProfile: FC = ({ {label}
{!hasValues && isEditable && onStartEdit ? ( - + ) : ( displayValue )} @@ -492,8 +460,8 @@ const BaseUserProfile: FC = ({ minHeight: '60px', width: '100%', padding: '8px', - border: '1px solid #ccc', - borderRadius: '4px', + border: `1px solid ${theme.vars.colors.border}`, + borderRadius: theme.vars.borderRadius.small, resize: 'vertical', }} /> @@ -527,23 +495,22 @@ const BaseUserProfile: FC = ({ {label}
{!hasValue && isEditable && onStartEdit ? ( - + ) : ( displayValue )} @@ -571,12 +538,12 @@ const BaseUserProfile: FC = ({ ...styles.field, display: 'flex', alignItems: 'center', - gap: `${theme.spacing.unit}px`, + gap: theme.vars.spacing.unit, }; return (
-
+
{renderSchemaField( schema, isFieldEditing, @@ -592,9 +559,9 @@ const BaseUserProfile: FC = ({
{isFieldEditing && ( @@ -602,12 +569,7 @@ const BaseUserProfile: FC = ({ - @@ -620,7 +582,7 @@ const BaseUserProfile: FC = ({ onClick={() => toggleFieldEdit(schema.name!)} title="Edit" style={{ - padding: `${theme.spacing.unit / 2}px`, + padding: `calc(${theme.vars.spacing.unit} / 2)`, }} > @@ -633,15 +595,14 @@ const BaseUserProfile: FC = ({ }; const getDisplayName = () => { - const currentUser = flattenedProfile || profile; - const firstName = getMappedUserProfileValue('firstName', mergedMappings, currentUser); - const lastName = getMappedUserProfileValue('lastName', mergedMappings, currentUser); + const firstName = getMappedUserProfileValue('firstName', mergedMappings, profile); + const lastName = getMappedUserProfileValue('lastName', mergedMappings, profile); if (firstName && lastName) { return `${firstName} ${lastName}`; } - return getMappedUserProfileValue('username', mergedMappings, currentUser) || ''; + return getMappedUserProfileValue('username', mergedMappings, profile) || ''; }; if (!profile && !flattenedProfile) { @@ -657,6 +618,31 @@ const BaseUserProfile: FC = ({ const avatarAttributes = ['picture']; const excludedProps = avatarAttributes.map(attr => mergedMappings[attr] || attr); + // Function to render profile fields when schemas are not available + const renderProfileWithoutSchemas = () => { + if (!currentUser) return null; + + const profileEntries = Object.entries(currentUser) + .filter(([key, value]) => { + // Skip fields that are in the fieldsToSkip array + if (fieldsToSkip.includes(key)) return false; + // Skip empty values + return value !== undefined && value !== '' && value !== null; + }) + .sort(([a], [b]) => a.localeCompare(b)); // Sort alphabetically + + return ( + <> + {profileEntries.map(([key, value]) => ( +
+ {formatLabel(key)} +
{typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value)}
+
+ ))} + + ); + }; + const profileContent = (
@@ -668,37 +654,38 @@ const BaseUserProfile: FC = ({ />
- {schemas - .filter(schema => { - // Filter out avatar-related fields and fields we don't want to show - if (!schema.name || schema.name === 'profileUrl') return false; - - // Skip fields that are in the fieldsToSkip array - if (fieldsToSkip.includes(schema.name)) return false; - - // For non-editable mode, only show fields with values - if (!editable) { - const value = flattenedProfile && schema.name ? flattenedProfile[schema.name] : undefined; - return value !== undefined && value !== '' && value !== null; - } - - return true; - }) - .sort((a, b) => { - const orderA = a.displayOrder ? parseInt(a.displayOrder) : 999; - const orderB = b.displayOrder ? parseInt(b.displayOrder) : 999; - return orderA - orderB; - }) - .map((schema, index) => { - // Get the value from flattenedProfile - const value = flattenedProfile && schema.name ? flattenedProfile[schema.name] : undefined; - const schemaWithValue = { - ...schema, - value, - }; - - return
{renderUserInfo(schemaWithValue)}
; - })} + {schemas && schemas.length > 0 + ? // Render with schemas when available + schemas + .filter(schema => { + // Skip fields that are in the fieldsToSkip array + if (fieldsToSkip.includes(schema.name)) return false; + + // For non-editable mode, only show fields with values + if (!editable) { + const value = flattenedProfile && schema.name ? flattenedProfile[schema.name] : undefined; + return value !== undefined && value !== '' && value !== null; + } + + return true; + }) + .sort((a, b) => { + const orderA = a.displayOrder ? parseInt(a.displayOrder) : 999; + const orderB = b.displayOrder ? parseInt(b.displayOrder) : 999; + return orderA - orderB; + }) + .map((schema, index) => { + // Get the value from flattenedProfile + const value = flattenedProfile && schema.name ? flattenedProfile[schema.name] : undefined; + const schemaWithValue = { + ...schema, + value, + }; + + return
{renderUserInfo(schemaWithValue)}
; + }) + : // Fallback: render profile fields directly when schemas are not available + renderProfileWithoutSchemas()}
); @@ -708,7 +695,7 @@ const BaseUserProfile: FC = ({ {title} -
{profileContent}
+
{profileContent}
); @@ -723,19 +710,19 @@ const useStyles = () => { return useMemo( () => ({ root: { - padding: `${theme.spacing.unit * 4}px`, + padding: `calc(${theme.vars.spacing.unit} * 4)`, minWidth: '600px', margin: '0 auto', } as CSSProperties, card: { - background: theme.colors.background.surface, - borderRadius: theme.borderRadius.large, + background: theme.vars.colors.background.surface, + borderRadius: theme.vars.borderRadius.large, } as CSSProperties, header: { display: 'flex', alignItems: 'center', - gap: `${theme.spacing.unit * 1.5}px`, - marginBottom: `${theme.spacing.unit * 1.5}px`, + gap: `calc(${theme.vars.spacing.unit} * 1.5)`, + marginBottom: `calc(${theme.vars.spacing.unit} * 1.5)`, } as CSSProperties, profileInfo: { flex: 1, @@ -744,19 +731,19 @@ const useStyles = () => { fontSize: '1.5rem', fontWeight: 600, margin: '0', - color: theme.colors.text.primary, + color: theme.vars.colors.text.primary, } as CSSProperties, infoContainer: { display: 'flex', flexDirection: 'column' as const, - gap: `${theme.spacing.unit}px`, + gap: theme.vars.spacing.unit, } as CSSProperties, field: { display: 'flex', - alignItems: 'center', - padding: `${theme.spacing.unit}px 0`, - borderBottom: `1px solid ${theme.colors.border}`, - minHeight: '32px', + alignItems: 'flex-start', + padding: `calc(${theme.vars.spacing.unit} / 2) 0`, + borderBottom: `1px solid ${theme.vars.colors.border}`, + minHeight: '28px', } as CSSProperties, lastField: { borderBottom: 'none', @@ -764,35 +751,36 @@ const useStyles = () => { label: { fontSize: '0.875rem', fontWeight: 500, - color: theme.colors.text.secondary, + color: theme.vars.colors.text.secondary, width: '120px', flexShrink: 0, - lineHeight: '32px', + lineHeight: '28px', } as CSSProperties, value: { - color: theme.colors.text.primary, + color: theme.vars.colors.text.primary, flex: 1, display: 'flex', alignItems: 'center', - gap: `${theme.spacing.unit}px`, + gap: theme.vars.spacing.unit, overflow: 'hidden', - minHeight: '32px', + minHeight: '28px', '& input': { height: '32px', margin: 0, }, - lineHeight: '32px', + lineHeight: '28px', + wordBreak: 'break-word' as const, '& table': { - backgroundColor: theme.colors.background, - borderRadius: theme.borderRadius.medium, + backgroundColor: theme.vars.colors.background.surface, + borderRadius: theme.vars.borderRadius.medium, whiteSpace: 'normal', }, '& td': { - borderColor: theme.colors.border, + borderColor: theme.vars.colors.border, }, } as CSSProperties, popup: { - padding: `${theme.spacing.unit * 2}px`, + padding: `calc(${theme.vars.spacing.unit} * 2)`, } as CSSProperties, }), [theme, colorScheme], diff --git a/packages/react/src/components/primitives/Alert/Alert.tsx b/packages/react/src/components/primitives/Alert/Alert.tsx index 9af01832..8ff68571 100644 --- a/packages/react/src/components/primitives/Alert/Alert.tsx +++ b/packages/react/src/components/primitives/Alert/Alert.tsx @@ -71,33 +71,33 @@ const useAlertStyles = (variant: AlertVariant) => { return useMemo(() => { const variantStyles: Record = { success: { - backgroundColor: '#d4edda', - borderColor: '#28a745', - color: '#155724', + backgroundColor: `${theme.vars.colors.success.main}15`, + borderColor: theme.vars.colors.success.main, + color: theme.vars.colors.success.main, }, error: { - backgroundColor: `${theme.colors.error.main}15`, - borderColor: theme.colors.error.main, - color: theme.colors.error.main, + backgroundColor: `${theme.vars.colors.error.main}15`, + borderColor: theme.vars.colors.error.main, + color: theme.vars.colors.error.main, }, warning: { - backgroundColor: '#fff3cd', - borderColor: '#ffc107', - color: '#856404', + backgroundColor: `${theme.vars.colors.warning.main}15`, + borderColor: theme.vars.colors.warning.main, + color: theme.vars.colors.warning.main, }, info: { - backgroundColor: `${theme.colors.primary.main}15`, - borderColor: theme.colors.primary.main, - color: theme.colors.primary.main, + backgroundColor: `${theme.vars.colors.primary.main}15`, + borderColor: theme.vars.colors.primary.main, + color: theme.vars.colors.primary.main, }, }; return { - padding: `${theme.spacing.unit * 2}px`, - borderRadius: theme.borderRadius.medium, + padding: `calc(${theme.vars.spacing.unit} * 2)`, + borderRadius: theme.vars.borderRadius.medium, border: '1px solid', display: 'flex', - gap: `${theme.spacing.unit * 1.5}px`, + gap: `calc(${theme.vars.spacing.unit} * 1.5)`, alignItems: 'flex-start', ...variantStyles[variant], }; @@ -110,9 +110,9 @@ const useAlertIconStyles = () => { return useMemo( (): CSSProperties => ({ flexShrink: 0, - marginTop: '2px', // Slight alignment adjustment - width: '20px', - height: '20px', + marginTop: `calc(${theme.vars.spacing.unit} * 0.25)`, // Slight alignment adjustment + width: `calc(${theme.vars.spacing.unit} * 2.5)`, + height: `calc(${theme.vars.spacing.unit} * 2.5)`, }), [theme], ); @@ -126,7 +126,7 @@ const useAlertContentStyles = () => { flex: 1, display: 'flex', flexDirection: 'column', - gap: `${theme.spacing.unit}px`, + gap: theme.vars.spacing.unit, }), [theme], ); @@ -138,7 +138,7 @@ const useAlertTitleStyles = () => { return useMemo( (): CSSProperties => ({ margin: 0, - fontSize: '14px', + fontSize: theme.vars.typography.fontSizes.sm, fontWeight: 600, lineHeight: 1.4, color: 'inherit', @@ -153,9 +153,9 @@ const useAlertDescriptionStyles = () => { return useMemo( (): CSSProperties => ({ margin: 0, - fontSize: '14px', + fontSize: theme.vars.typography.fontSizes.sm, lineHeight: 1.4, - color: theme.colors.text.secondary, + color: theme.vars.colors.text.secondary, }), [theme], ); diff --git a/packages/react/src/components/primitives/Avatar/Avatar.tsx b/packages/react/src/components/primitives/Avatar/Avatar.tsx index 6d447128..61cec2a3 100644 --- a/packages/react/src/components/primitives/Avatar/Avatar.tsx +++ b/packages/react/src/components/primitives/Avatar/Avatar.tsx @@ -75,10 +75,10 @@ const useStyles = ({ () => ({ avatar: { alignItems: 'center', - background: backgroundColor || theme.colors.background.surface, - border: backgroundColor ? 'none' : `1px solid ${theme.colors.border}`, + background: backgroundColor || theme.vars.colors.background.surface, + border: backgroundColor ? 'none' : `1px solid ${theme.vars.colors.border}`, borderRadius: variant === 'circular' ? '50%' : '8px', - color: backgroundColor ? '#ffffff' : theme.colors.text.primary, + color: backgroundColor ? '#ffffff' : theme.vars.colors.text.primary, display: 'flex', fontSize: `${size * 0.4}px`, fontWeight: 600, @@ -169,11 +169,36 @@ export const Avatar: FC = ({ if (name) { return getInitials(name); } - return '?'; + + // Skeleton loading animation + return ( +
+ ); }; return (
+ {renderContent()}
); diff --git a/packages/react/src/components/primitives/Button/Button.tsx b/packages/react/src/components/primitives/Button/Button.tsx index d420f947..22301bd8 100644 --- a/packages/react/src/components/primitives/Button/Button.tsx +++ b/packages/react/src/components/primitives/Button/Button.tsx @@ -71,19 +71,19 @@ const useButtonStyles = ( // Size configurations const sizeConfig = { small: { - padding: `${theme.spacing.unit / 2}px ${theme.spacing.unit}px`, - fontSize: '0.75rem', - minHeight: '24px', + padding: `calc(${theme.vars.spacing.unit} * 0.5) calc(${theme.vars.spacing.unit} * 1)`, + fontSize: theme.vars.typography.fontSizes.sm, + minHeight: `calc(${theme.vars.spacing.unit} * 3)`, }, medium: { - padding: `${theme.spacing.unit}px ${theme.spacing.unit * 2}px`, - fontSize: '0.875rem', - minHeight: '32px', + padding: `calc(${theme.vars.spacing.unit} * 1) calc(${theme.vars.spacing.unit} * 2)`, + fontSize: theme.vars.typography.fontSizes.md, + minHeight: `calc(${theme.vars.spacing.unit} * 4)`, }, large: { - padding: `${theme.spacing.unit * 1.5}px ${theme.spacing.unit * 3}px`, - fontSize: '1rem', - minHeight: '40px', + padding: `calc(${theme.vars.spacing.unit} * 1.5) calc(${theme.vars.spacing.unit} * 3)`, + fontSize: theme.vars.typography.fontSizes.lg, + minHeight: `calc(${theme.vars.spacing.unit} * 5)`, }, }; @@ -94,44 +94,43 @@ const useButtonStyles = ( switch (variant) { case 'solid': return { - backgroundColor: theme.colors.primary.main, - color: theme.colors.primary.contrastText, - border: `1px solid ${theme.colors.primary.main}`, + backgroundColor: theme.vars.colors.primary.main, + color: theme.vars.colors.primary.contrastText, + border: `1px solid ${theme.vars.colors.primary.main}`, '&:hover': { - backgroundColor: theme.colors.primary.main, + backgroundColor: theme.vars.colors.primary.main, opacity: 0.9, }, '&:active': { - backgroundColor: theme.colors.primary.main, + backgroundColor: theme.vars.colors.primary.main, opacity: 0.8, }, }; case 'outline': return { backgroundColor: 'transparent', - color: theme.colors.primary.main, - border: `1px solid ${theme.colors.primary.main}`, + color: theme.vars.colors.primary.main, + border: `1px solid ${theme.vars.colors.primary.main}`, '&:hover': { - backgroundColor: theme.colors.primary.main, - color: theme.colors.primary.contrastText, + backgroundColor: theme.vars.colors.primary.main, + color: theme.vars.colors.primary.contrastText, }, '&:active': { - backgroundColor: theme.colors.primary.main, - color: theme.colors.primary.contrastText, + backgroundColor: theme.vars.colors.primary.main, + color: theme.vars.colors.primary.contrastText, opacity: 0.9, }, }; case 'text': return { backgroundColor: 'transparent', - color: theme.colors.primary.main, + color: theme.vars.colors.primary.main, border: '1px solid transparent', '&:hover': { - backgroundColor: theme.colors.background.surface, + backgroundColor: theme.vars.colors.action.hover, }, '&:active': { - backgroundColor: theme.colors.background.surface, - opacity: 0.8, + backgroundColor: theme.vars.colors.action.selected, }, }; } @@ -140,44 +139,43 @@ const useButtonStyles = ( switch (variant) { case 'solid': return { - backgroundColor: theme.colors.secondary.main, - color: theme.colors.secondary.contrastText, - border: `1px solid ${theme.colors.secondary.main}`, + backgroundColor: theme.vars.colors.secondary.main, + color: theme.vars.colors.secondary.contrastText, + border: `1px solid ${theme.vars.colors.secondary.main}`, '&:hover': { - backgroundColor: theme.colors.secondary.main, + backgroundColor: theme.vars.colors.secondary.main, opacity: 0.9, }, '&:active': { - backgroundColor: theme.colors.secondary.main, + backgroundColor: theme.vars.colors.secondary.main, opacity: 0.8, }, }; case 'outline': return { backgroundColor: 'transparent', - color: theme.colors.secondary.main, - border: `1px solid ${theme.colors.secondary.main}`, + color: theme.vars.colors.secondary.main, + border: `1px solid ${theme.vars.colors.secondary.main}`, '&:hover': { - backgroundColor: theme.colors.secondary.main, - color: theme.colors.secondary.contrastText, + backgroundColor: theme.vars.colors.secondary.main, + color: theme.vars.colors.secondary.contrastText, }, '&:active': { - backgroundColor: theme.colors.secondary.main, - color: theme.colors.secondary.contrastText, + backgroundColor: theme.vars.colors.secondary.main, + color: theme.vars.colors.secondary.contrastText, opacity: 0.9, }, }; case 'text': return { backgroundColor: 'transparent', - color: theme.colors.secondary.main, + color: theme.vars.colors.secondary.main, border: '1px solid transparent', '&:hover': { - backgroundColor: theme.colors.background.surface, + backgroundColor: theme.vars.colors.action.hover, }, '&:active': { - backgroundColor: theme.colors.background.surface, - opacity: 0.8, + backgroundColor: theme.vars.colors.action.selected, }, }; } @@ -186,47 +184,45 @@ const useButtonStyles = ( switch (variant) { case 'solid': return { - backgroundColor: theme.colors.text.secondary, - color: theme.colors.background.surface, - border: `1px solid ${theme.colors.text.secondary}`, + backgroundColor: theme.vars.colors.text.secondary, + color: theme.vars.colors.background.surface, + border: `1px solid ${theme.vars.colors.text.secondary}`, '&:hover': { - backgroundColor: theme.colors.text.primary, - color: theme.colors.background.surface, + backgroundColor: theme.vars.colors.text.primary, + color: theme.vars.colors.background.surface, }, '&:active': { - backgroundColor: theme.colors.text.primary, - color: theme.colors.background.surface, + backgroundColor: theme.vars.colors.text.primary, + color: theme.vars.colors.background.surface, opacity: 0.9, }, }; case 'outline': return { backgroundColor: 'transparent', - color: theme.colors.text.secondary, - border: `1px solid ${theme.colors.border}`, + color: theme.vars.colors.text.secondary, + border: `1px solid ${theme.vars.colors.border}`, '&:hover': { - backgroundColor: theme.colors.background.surface, - borderColor: theme.colors.text.secondary, + backgroundColor: theme.vars.colors.action.hover, + borderColor: theme.vars.colors.text.secondary, }, '&:active': { - backgroundColor: theme.colors.background.surface, - borderColor: theme.colors.text.primary, - opacity: 0.9, + backgroundColor: theme.vars.colors.action.selected, + borderColor: theme.vars.colors.text.primary, }, }; case 'text': return { backgroundColor: 'transparent', - color: theme.colors.text.secondary, + color: theme.vars.colors.text.secondary, border: '1px solid transparent', '&:hover': { - backgroundColor: theme.colors.background.surface, - color: theme.colors.text.primary, + backgroundColor: theme.vars.colors.action.hover, + color: theme.vars.colors.text.primary, }, '&:active': { - backgroundColor: theme.colors.background.surface, - color: theme.colors.text.primary, - opacity: 0.8, + backgroundColor: theme.vars.colors.action.selected, + color: theme.vars.colors.text.primary, }, }; } @@ -240,8 +236,8 @@ const useButtonStyles = ( display: 'inline-flex', alignItems: 'center', justifyContent: 'center', - gap: `${theme.spacing.unit}px`, - borderRadius: theme.borderRadius.medium, + gap: `calc(${theme.vars.spacing.unit} * 1)`, + borderRadius: theme.vars.borderRadius.medium, fontWeight: 500, cursor: disabled || loading ? 'not-allowed' : 'pointer', transition: 'all 0.2s ease-in-out', @@ -306,6 +302,7 @@ const Button = forwardRef( }, ref, ) => { + const {theme} = useTheme(); const buttonStyle = useButtonStyles(color, variant, size, fullWidth, disabled || false, loading); return ( @@ -331,8 +328,18 @@ const Button = forwardRef( size={size as SpinnerSize} color="currentColor" style={{ - width: size === 'small' ? '12px' : size === 'medium' ? '16px' : '20px', - height: size === 'small' ? '12px' : size === 'medium' ? '16px' : '20px', + width: + size === 'small' + ? `calc(${theme.vars.spacing.unit} * 1.5)` + : size === 'medium' + ? `calc(${theme.vars.spacing.unit} * 2)` + : `calc(${theme.vars.spacing.unit} * 2.5)`, + height: + size === 'small' + ? `calc(${theme.vars.spacing.unit} * 1.5)` + : size === 'medium' + ? `calc(${theme.vars.spacing.unit} * 2)` + : `calc(${theme.vars.spacing.unit} * 2.5)`, }} /> )} diff --git a/packages/react/src/components/primitives/Checkbox/Checkbox.tsx b/packages/react/src/components/primitives/Checkbox/Checkbox.tsx index ab01a1b1..519a4c73 100644 --- a/packages/react/src/components/primitives/Checkbox/Checkbox.tsx +++ b/packages/react/src/components/primitives/Checkbox/Checkbox.tsx @@ -56,10 +56,10 @@ const Checkbox: FC = ({label, error, className, required, helperT }; const inputStyle: CSSProperties = { - width: theme.spacing.unit * 2.5 + 'px', - height: theme.spacing.unit * 2.5 + 'px', - marginRight: theme.spacing.unit + 'px', - accentColor: theme.colors.primary.main, + width: `calc(${theme.vars.spacing.unit} * 2.5)`, + height: `calc(${theme.vars.spacing.unit} * 2.5)`, + marginRight: theme.vars.spacing.unit, + accentColor: theme.vars.colors.primary.main, }; return ( @@ -67,7 +67,7 @@ const Checkbox: FC = ({label, error, className, required, helperT error={error} helperText={helperText} className={clsx(withVendorCSSClassPrefix('checkbox'), className)} - helperTextMarginLeft={theme.spacing.unit * 3.5 + 'px'} + helperTextMarginLeft={`calc(${theme.vars.spacing.unit} * 3.5)`} >
@@ -77,7 +77,7 @@ const Checkbox: FC = ({label, error, className, required, helperT error={!!error} variant="inline" style={{ - color: error ? theme.colors.error.main : theme.colors.text.primary, + color: error ? theme.vars.colors.error.main : theme.vars.colors.text.primary, fontSize: '0.875rem', }} > diff --git a/packages/react/src/components/primitives/DatePicker/DatePicker.tsx b/packages/react/src/components/primitives/DatePicker/DatePicker.tsx index 2f138960..2e8b7b99 100644 --- a/packages/react/src/components/primitives/DatePicker/DatePicker.tsx +++ b/packages/react/src/components/primitives/DatePicker/DatePicker.tsx @@ -69,12 +69,12 @@ const DatePicker: FC = ({ const inputStyle: CSSProperties = { width: '100%', - padding: `${theme.spacing.unit}px ${theme.spacing.unit * 1.5}px`, - border: `1px solid ${error ? theme.colors.error.main : theme.colors.border}`, - borderRadius: theme.borderRadius.medium, + padding: `${theme.vars.spacing.unit} calc(${theme.vars.spacing.unit} * 1.5)`, + border: `1px solid ${error ? theme.vars.colors.error.main : theme.vars.colors.border}`, + borderRadius: theme.vars.borderRadius.medium, fontSize: '1rem', - color: theme.colors.text.primary, - backgroundColor: disabled ? theme.colors.background.disabled : theme.colors.background.surface, + color: theme.vars.colors.text.primary, + backgroundColor: disabled ? theme.vars.colors.background.disabled : theme.vars.colors.background.surface, outline: 'none', transition: 'border-color 0.2s ease', }; diff --git a/packages/react/src/components/primitives/Divider/Divider.tsx b/packages/react/src/components/primitives/Divider/Divider.tsx index 25c2e517..4059591a 100644 --- a/packages/react/src/components/primitives/Divider/Divider.tsx +++ b/packages/react/src/components/primitives/Divider/Divider.tsx @@ -54,50 +54,45 @@ const useStyles = (orientation: DividerOrientation, variant: DividerVariant, col const baseColor = color || theme.colors.border; const borderStyle = variant === 'solid' ? 'solid' : variant === 'dashed' ? 'dashed' : 'dotted'; - if (orientation === 'vertical') { - return { - container: { - display: 'inline-block', - height: '100%', - minHeight: '1rem', - width: '1px', - borderLeft: `1px ${borderStyle} ${baseColor}`, - margin: `0 ${theme.spacing.unit}px`, - }, - }; - } - - // Horizontal divider - const baseStyle = { - display: 'flex', - alignItems: 'center', - width: '100%', - margin: `${theme.spacing.unit * 2}px 0`, - }; - - if (hasChildren) { - return { - container: baseStyle, - line: { - flex: 1, - height: '1px', - borderTop: `1px ${borderStyle} ${baseColor}`, - }, - text: { - backgroundColor: theme.colors.background.surface, - padding: `0 ${theme.spacing.unit}px`, - whiteSpace: 'nowrap' as const, - }, - }; - } - - return { - container: { - ...baseStyle, - height: '1px', - borderTop: `1px ${borderStyle} ${baseColor}`, - }, - }; + const styles = ` + .${withVendorCSSClassPrefix('divider')} { + margin: calc(${theme.vars.spacing.unit} * 2) 0; + } + + .${withVendorCSSClassPrefix('divider--vertical')} { + display: inline-block; + height: 100%; + min-height: calc(${theme.vars.spacing.unit} * 2); + width: 1px; + border-left: 1px ${borderStyle} ${baseColor}; + margin: 0 calc(${theme.vars.spacing.unit} * 1); + } + + .${withVendorCSSClassPrefix('divider--horizontal')} { + display: flex; + align-items: center; + width: 100%; + } + + .${withVendorCSSClassPrefix('divider--horizontal')}:not(.${withVendorCSSClassPrefix('divider--with-text')}) { + height: 1px; + border-top: 1px ${borderStyle} ${baseColor}; + } + + .${withVendorCSSClassPrefix('divider__line')} { + flex: 1; + height: 1px; + border-top: 1px ${borderStyle} ${baseColor}; + } + + .${withVendorCSSClassPrefix('divider__text')} { + background-color: ${theme.vars.colors.background.surface}; + padding: 0 calc(${theme.vars.spacing.unit} * 1); + white-space: nowrap; + } + `; + + return styles; }, [orientation, variant, color, hasChildren, theme]); }; @@ -132,47 +127,69 @@ const Divider: FC = ({ if (orientation === 'vertical') { return ( -
+ <> + +
+ ); } if (children) { return ( + <> + +
+
+ + {children} + +
+
+ + ); + } + + return ( + <> +
-
- - {children} - -
-
- ); - } - - return ( -
+ /> + ); }; diff --git a/packages/react/src/components/primitives/FormControl/FormControl.tsx b/packages/react/src/components/primitives/FormControl/FormControl.tsx index fa46b5a5..918af365 100644 --- a/packages/react/src/components/primitives/FormControl/FormControl.tsx +++ b/packages/react/src/components/primitives/FormControl/FormControl.tsx @@ -64,12 +64,12 @@ const FormControl: FC = ({ const {theme} = useTheme(); const containerStyle: CSSProperties = { - marginBottom: theme.spacing.unit * 2 + 'px', + marginBottom: `calc(${theme.vars.spacing.unit} * 2)`, ...style, }; const helperTextStyle: CSSProperties = { - marginTop: theme.spacing.unit / 2 + 'px', + marginTop: `calc(${theme.vars.spacing.unit} / 2)`, textAlign: helperTextAlign, ...(helperTextMarginLeft && {marginLeft: helperTextMarginLeft}), }; diff --git a/packages/react/src/components/primitives/Icons/BuildingAlt.tsx b/packages/react/src/components/primitives/Icons/BuildingAlt.tsx index ba39223d..81eaec8d 100644 --- a/packages/react/src/components/primitives/Icons/BuildingAlt.tsx +++ b/packages/react/src/components/primitives/Icons/BuildingAlt.tsx @@ -47,9 +47,9 @@ const BuildingAlt: FC = ({color = 'currentColor', height = 24, fill="none" xmlns="http://www.w3.org/2000/svg" stroke="currentColor" - stroke-width="2" - stroke-linecap="round" - stroke-linejoin="round" + strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" > diff --git a/packages/react/src/components/primitives/InputLabel/InputLabel.tsx b/packages/react/src/components/primitives/InputLabel/InputLabel.tsx index 5b3382f3..78ce951b 100644 --- a/packages/react/src/components/primitives/InputLabel/InputLabel.tsx +++ b/packages/react/src/components/primitives/InputLabel/InputLabel.tsx @@ -59,9 +59,9 @@ const InputLabel: FC = ({ const labelStyle: CSSProperties = { display: variant, - marginBottom: marginBottom || (variant === 'block' ? theme.spacing.unit + 'px' : '0'), - color: error ? theme.colors.error.main : theme.colors.text.secondary, - fontSize: '0.875rem', + marginBottom: marginBottom || (variant === 'block' ? `calc(${theme.vars.spacing.unit} + 1px)` : '0'), + color: error ? theme.vars.colors.error.main : theme.vars.colors.text.secondary, + fontSize: theme.vars.typography.fontSizes.sm, fontWeight: variant === 'block' ? 500 : 'normal', ...style, }; @@ -69,7 +69,7 @@ const InputLabel: FC = ({ return ( ); }; diff --git a/packages/react/src/components/primitives/KeyValueInput/KeyValueInput.tsx b/packages/react/src/components/primitives/KeyValueInput/KeyValueInput.tsx index c2a1aa90..278dae6c 100644 --- a/packages/react/src/components/primitives/KeyValueInput/KeyValueInput.tsx +++ b/packages/react/src/components/primitives/KeyValueInput/KeyValueInput.tsx @@ -238,29 +238,29 @@ const KeyValueInput: FC = ({ container: { display: 'flex', flexDirection: 'column' as const, - gap: `${theme.spacing.unit / 2}px`, + gap: `calc(${theme.vars.spacing.unit} / 2)`, } as CSSProperties, label: { fontSize: '0.875rem', fontWeight: 500, - color: theme.colors.text.primary, - marginBottom: `${theme.spacing.unit / 2}px`, + color: theme.vars.colors.text.primary, + marginBottom: `calc(${theme.vars.spacing.unit} / 2)`, } as CSSProperties, pairsList: { display: 'flex', flexDirection: 'column' as const, - gap: `${theme.spacing.unit / 4}px`, + gap: `calc(${theme.vars.spacing.unit} / 4)`, } as CSSProperties, pairRow: { display: 'flex', alignItems: 'center', - gap: `${theme.spacing.unit / 2}px`, - padding: `${theme.spacing.unit / 2}px`, - borderRadius: theme.borderRadius.small, + gap: `calc(${theme.vars.spacing.unit} / 2)`, + padding: `calc(${theme.vars.spacing.unit} / 2)`, + borderRadius: theme.vars.borderRadius.small, backgroundColor: 'transparent', border: 'none', '&:hover': { - backgroundColor: theme.colors.background.surface, + backgroundColor: theme.vars.colors.action.hover, }, } as CSSProperties, pairInput: { @@ -270,12 +270,12 @@ const KeyValueInput: FC = ({ addRow: { display: 'flex', alignItems: 'center', - gap: `${theme.spacing.unit / 2}px`, - padding: `${theme.spacing.unit / 2}px`, + gap: `calc(${theme.vars.spacing.unit} / 2)`, + padding: `calc(${theme.vars.spacing.unit} / 2)`, border: 'none', - borderRadius: theme.borderRadius.small, + borderRadius: theme.vars.borderRadius.small, backgroundColor: 'transparent', - marginTop: `${theme.spacing.unit / 2}px`, + marginTop: `calc(${theme.vars.spacing.unit} / 2)`, } as CSSProperties, removeButton: { minWidth: 'auto', @@ -283,15 +283,15 @@ const KeyValueInput: FC = ({ height: '24px', padding: '0', backgroundColor: 'transparent', - color: theme.colors.text.secondary, + color: theme.vars.colors.text.secondary, border: 'none', - borderRadius: theme.borderRadius.small, + borderRadius: theme.vars.borderRadius.small, display: 'flex', alignItems: 'center', justifyContent: 'center', '&:hover': { - backgroundColor: theme.colors.background.surface, - color: theme.colors.error.main, + backgroundColor: theme.vars.colors.action.hover, + color: theme.vars.colors.error.main, }, } as CSSProperties, addButton: { @@ -300,46 +300,46 @@ const KeyValueInput: FC = ({ height: '24px', padding: '0', backgroundColor: 'transparent', - color: theme.colors.primary.main, + color: theme.vars.colors.primary.main, border: 'none', - borderRadius: theme.borderRadius.small, + borderRadius: theme.vars.borderRadius.small, display: 'flex', alignItems: 'center', justifyContent: 'center', '&:hover': { - backgroundColor: theme.colors.primary.main, - color: theme.colors.primary.contrastText, + backgroundColor: theme.vars.colors.primary.main, + color: theme.vars.colors.primary.contrastText, }, } as CSSProperties, helperText: { fontSize: '0.75rem', - color: error ? theme.colors.error.main : theme.colors.text.secondary, - marginTop: `${theme.spacing.unit / 2}px`, + color: error ? theme.vars.colors.error.main : theme.vars.colors.text.secondary, + marginTop: `calc(${theme.vars.spacing.unit} / 2)`, } as CSSProperties, emptyState: { - padding: `${theme.spacing.unit}px`, + padding: theme.vars.spacing.unit, textAlign: 'center' as const, - color: theme.colors.text.secondary, + color: theme.vars.colors.text.secondary, fontStyle: 'italic', fontSize: '0.75rem', } as CSSProperties, readOnlyPair: { display: 'flex', alignItems: 'center', - gap: `${theme.spacing.unit / 2}px`, - padding: `${theme.spacing.unit / 4}px 0`, + gap: `calc(${theme.vars.spacing.unit} / 2)`, + padding: `calc(${theme.vars.spacing.unit} / 4) 0`, minHeight: '20px', } as CSSProperties, readOnlyKey: { fontSize: '0.75rem', fontWeight: 500, - color: theme.colors.text.secondary, + color: theme.vars.colors.text.secondary, minWidth: '80px', flexShrink: 0, } as CSSProperties, readOnlyValue: { fontSize: '0.75rem', - color: theme.colors.text.primary, + color: theme.vars.colors.text.primary, wordBreak: 'break-word' as const, flex: 1, } as CSSProperties, @@ -350,7 +350,7 @@ const KeyValueInput: FC = ({ {label && ( )} diff --git a/packages/react/src/components/primitives/Logo/Logo.tsx b/packages/react/src/components/primitives/Logo/Logo.tsx new file mode 100644 index 00000000..1b7304b5 --- /dev/null +++ b/packages/react/src/components/primitives/Logo/Logo.tsx @@ -0,0 +1,104 @@ +/** + * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import {FC} from 'react'; +import {clsx} from 'clsx'; +import {withVendorCSSClassPrefix} from '@asgardeo/browser'; +import useTheme from '../../../contexts/Theme/useTheme'; + +/** + * Props for the Logo component. + */ +export interface LogoProps { + /** + * Custom CSS class name for the logo. + */ + className?: string; + /** + * Custom logo URL to override theme logo. + */ + src?: string; + /** + * Custom alt text for the logo. + */ + alt?: string; + /** + * Custom title for the logo. + */ + title?: string; + /** + * Size of the logo. + */ + size?: 'small' | 'medium' | 'large'; + /** + * Custom style object. + */ + style?: React.CSSProperties; +} + +/** + * Logo component that displays the brand logo from theme or custom source. + * + * @param props - The props for the Logo component. + * @returns The rendered Logo component. + */ +const Logo: FC = ({className, src, alt, title, size = 'medium', style}) => { + const {theme} = useTheme(); + + // Get logo configuration from theme - use actual values, not CSS variables + // Access the actual theme config values, not the CSS variable references from .vars + const logoConfig = theme.images?.logo; + + const logoSrc = src || logoConfig?.url; + + const logoAlt = alt || logoConfig?.alt || 'Logo'; + + const logoTitle = title || logoConfig?.title; + + const logoClasses = clsx(withVendorCSSClassPrefix('logo'), withVendorCSSClassPrefix(`logo--${size}`), className); + + const sizeStyles: Record = { + small: { + height: '32px', + maxWidth: '120px', + }, + medium: { + height: '48px', + maxWidth: '180px', + }, + large: { + height: '64px', + maxWidth: '240px', + }, + }; + + const defaultStyles: React.CSSProperties = { + width: 'auto', + objectFit: 'contain', + ...sizeStyles[size], + ...style, + }; + + if (!logoSrc) { + return null; + } + + return {logoAlt}; +}; + +export default Logo; diff --git a/packages/react/src/components/primitives/MultiInput/MultiInput.tsx b/packages/react/src/components/primitives/MultiInput/MultiInput.tsx index 3808e277..120518e5 100644 --- a/packages/react/src/components/primitives/MultiInput/MultiInput.tsx +++ b/packages/react/src/components/primitives/MultiInput/MultiInput.tsx @@ -122,7 +122,7 @@ const useStyles = () => { listContainer: { display: 'flex', flexDirection: 'column' as const, - gap: `${theme.spacing.unit / 2}px`, + gap: `${theme.spacing.unit * 0}px`, }, listItem: { display: 'flex', @@ -130,7 +130,6 @@ const useStyles = () => { 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, diff --git a/packages/react/src/components/primitives/OtpField/OtpField.tsx b/packages/react/src/components/primitives/OtpField/OtpField.tsx index c013ec3d..970d75db 100644 --- a/packages/react/src/components/primitives/OtpField/OtpField.tsx +++ b/packages/react/src/components/primitives/OtpField/OtpField.tsx @@ -134,29 +134,29 @@ const OtpField: FC = ({ const inputContainerStyle: CSSProperties = { display: 'flex', - gap: theme.spacing.unit + 'px', + gap: theme.vars.spacing.unit, justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', }; const inputStyle: CSSProperties = { - width: theme.spacing.unit * 6 + 'px', - height: theme.spacing.unit * 6 + 'px', + width: `calc(${theme.vars.spacing.unit} * 6)`, + height: `calc(${theme.vars.spacing.unit} * 6)`, textAlign: 'center', - fontSize: '1.25rem', + fontSize: theme.vars.typography.fontSizes.xl, fontWeight: 500, - border: `2px solid ${error ? theme.colors.error.main : theme.colors.border}`, - borderRadius: theme.borderRadius.medium, - color: theme.colors.text.primary, - backgroundColor: disabled ? theme.colors.background.disabled : theme.colors.background.surface, + border: `2px solid ${error ? theme.vars.colors.error.main : theme.vars.colors.border}`, + borderRadius: theme.vars.borderRadius.medium, + color: theme.vars.colors.text.primary, + backgroundColor: disabled ? theme.vars.colors.background.disabled : theme.vars.colors.background.surface, outline: 'none', transition: 'border-color 0.2s ease, box-shadow 0.2s ease', }; const focusedInputStyle: CSSProperties = { - borderColor: error ? theme.colors.error.main : theme.colors.primary.main, - boxShadow: `0 0 0 2px ${error ? theme.colors.error.main + '20' : theme.colors.primary.main + '20'}`, + borderColor: error ? theme.vars.colors.error.main : theme.vars.colors.primary.main, + boxShadow: `0 0 0 2px ${error ? theme.vars.colors.error.main + '20' : theme.vars.colors.primary.main + '20'}`, }; const handleChange = (index: number, event: ChangeEvent) => { @@ -276,13 +276,13 @@ const OtpField: FC = ({ onKeyDown={event => handleKeyDown(index, event)} onPaste={handlePaste} onFocus={event => { - event.target.style.borderColor = error ? theme.colors.error.main : theme.colors.primary.main; + event.target.style.borderColor = error ? theme.vars.colors.error.main : theme.vars.colors.primary.main; event.target.style.boxShadow = `0 0 0 2px ${ - error ? theme.colors.error.main + '20' : theme.colors.primary.main + '20' + error ? theme.vars.colors.error.main + '20' : theme.vars.colors.primary.main + '20' }`; }} onBlur={event => { - event.target.style.borderColor = error ? theme.colors.error.main : theme.colors.border; + event.target.style.borderColor = error ? theme.vars.colors.error.main : theme.vars.colors.border; event.target.style.boxShadow = 'none'; }} style={inputStyle} diff --git a/packages/react/src/components/primitives/Select/Select.tsx b/packages/react/src/components/primitives/Select/Select.tsx index 66b5c84a..60dcd474 100644 --- a/packages/react/src/components/primitives/Select/Select.tsx +++ b/packages/react/src/components/primitives/Select/Select.tsx @@ -80,17 +80,19 @@ const Select: FC = ({ const selectStyle: CSSProperties = { width: '100%', - padding: `${theme.spacing.unit}px ${theme.spacing.unit * 1.5}px`, - border: `1px solid ${error ? theme.colors.error.main : theme.colors.border}`, - borderRadius: theme.borderRadius.medium, - fontSize: '1rem', - color: theme.colors.text.primary, - backgroundColor: disabled ? theme.colors.background.disabled : theme.colors.background.surface, + padding: `${theme.vars.spacing.unit} calc(${theme.vars.spacing.unit} * 1.5)`, + border: `1px solid ${error ? theme.vars.colors.error.main : theme.vars.colors.border}`, + borderRadius: theme.vars.borderRadius.medium, + fontSize: theme.vars.typography.fontSizes.md, + color: theme.vars.colors.text.primary, + backgroundColor: disabled ? theme.vars.colors.background.disabled : theme.vars.colors.background.surface, outline: 'none', transition: 'border-color 0.2s ease', appearance: 'none', - backgroundImage: - "url('data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22292.4%22%20height%3D%22292.4%22%3E%3Cpath%20fill%3D%22%23${theme.colors.text.secondary.replace('#', '')}%22%20d%3D%22M287%2069.4a17.6%2017.6%200%200%200-13-5.4H18.4c-5%200-9.3%201.8-12.9%205.4A17.6%2017.6%200%200%200%200%2082.2c0%205%201.8%209.3%205.4%2012.9l128%20127.9c3.6%203.6%207.8%205.4%2012.8%205.4s9.2-1.8%2012.8-5.4L287%2095c3.5-3.5%205.4-7.8%205.4-12.8%200-5-1.9-9.2-5.5-12.8z%22%2F%3E%3C%2Fsvg%3E')", + backgroundImage: `url('data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22292.4%22%20height%3D%22292.4%22%3E%3Cpath%20fill%3D%22%23${theme.colors.text.secondary.replace( + '#', + '', + )}%22%20d%3D%22M287%2069.4a17.6%2017.6%200%200%200-13-5.4H18.4c-5%200-9.3%201.8-12.9%205.4A17.6%2017.6%200%200%200%200%2082.2c0%205%201.8%209.3%205.4%2012.9l128%20127.9c3.6%203.6%207.8%205.4%2012.8%205.4s9.2-1.8%2012.8-5.4L287%2095c3.5-3.5%205.4-7.8%205.4-12.8%200-5-1.9-9.2-5.5-12.8z%22%2F%3E%3C%2Fsvg%3E')`, backgroundRepeat: 'no-repeat', backgroundPosition: 'right .7em top 50%', backgroundSize: '.65em auto', diff --git a/packages/react/src/components/primitives/Spinner/Spinner.tsx b/packages/react/src/components/primitives/Spinner/Spinner.tsx index d56ac803..01f92615 100644 --- a/packages/react/src/components/primitives/Spinner/Spinner.tsx +++ b/packages/react/src/components/primitives/Spinner/Spinner.tsx @@ -66,7 +66,7 @@ const Spinner: FC = ({size = 'medium', color, className, style}) = large: '32px', }[size]; - const spinnerColor = color || theme.colors.primary.main; + const spinnerColor = color || theme.vars.colors.primary.main; const spinnerStyle: CSSProperties = { width: spinnerSize, diff --git a/packages/react/src/components/primitives/Typography/Typography.tsx b/packages/react/src/components/primitives/Typography/Typography.tsx index f386e038..18d41fe7 100644 --- a/packages/react/src/components/primitives/Typography/Typography.tsx +++ b/packages/react/src/components/primitives/Typography/Typography.tsx @@ -19,6 +19,7 @@ import {CSSProperties, FC, ReactNode, ComponentPropsWithoutRef, ElementType} from 'react'; import useTheme from '../../../contexts/Theme/useTheme'; import clsx from 'clsx'; +import {withVendorCSSClassPrefix} from '@asgardeo/browser'; // Typography variants mapped to HTML elements and styling export type TypographyVariant = @@ -173,84 +174,84 @@ const Typography: FC = ({ switch (variantName) { case 'h1': return { - fontSize: '2.125rem', // 34px + fontSize: theme.vars.typography.fontSizes['3xl'], // 34px fontWeight: 600, lineHeight: 1.235, letterSpacing: '-0.00735em', }; case 'h2': return { - fontSize: '1.5rem', // 24px + fontSize: theme.vars.typography.fontSizes['2xl'], // 24px fontWeight: 600, lineHeight: 1.334, letterSpacing: '0em', }; case 'h3': return { - fontSize: '1.25rem', // 20px + fontSize: theme.vars.typography.fontSizes.xl, // 20px fontWeight: 600, lineHeight: 1.6, letterSpacing: '0.0075em', }; case 'h4': return { - fontSize: '1.125rem', // 18px + fontSize: theme.vars.typography.fontSizes.lg, // 18px fontWeight: 600, lineHeight: 1.5, letterSpacing: '0.00938em', }; case 'h5': return { - fontSize: '1rem', // 16px + fontSize: theme.vars.typography.fontSizes.md, // 16px fontWeight: 600, lineHeight: 1.334, letterSpacing: '0em', }; case 'h6': return { - fontSize: '0.875rem', // 14px + fontSize: theme.vars.typography.fontSizes.sm, // 14px fontWeight: 500, lineHeight: 1.6, letterSpacing: '0.0075em', }; case 'subtitle1': return { - fontSize: '1rem', // 16px + fontSize: theme.vars.typography.fontSizes.md, // 16px fontWeight: 400, lineHeight: 1.75, letterSpacing: '0.00938em', }; case 'subtitle2': return { - fontSize: '0.875rem', // 14px + fontSize: theme.vars.typography.fontSizes.sm, // 14px fontWeight: 500, lineHeight: 1.57, letterSpacing: '0.00714em', }; case 'body1': return { - fontSize: '1rem', // 16px + fontSize: theme.vars.typography.fontSizes.md, // 16px fontWeight: 400, lineHeight: 1.5, letterSpacing: '0.00938em', }; case 'body2': return { - fontSize: '0.875rem', // 14px + fontSize: theme.vars.typography.fontSizes.sm, // 14px fontWeight: 400, lineHeight: 1.43, letterSpacing: '0.01071em', }; case 'caption': return { - fontSize: '0.75rem', // 12px + fontSize: theme.vars.typography.fontSizes.xs, // 12px fontWeight: 400, lineHeight: 1.66, letterSpacing: '0.03333em', }; case 'overline': return { - fontSize: '0.75rem', // 12px + fontSize: theme.vars.typography.fontSizes.xs, // 12px fontWeight: 400, lineHeight: 2.66, letterSpacing: '0.08333em', @@ -258,7 +259,7 @@ const Typography: FC = ({ }; case 'button': return { - fontSize: '0.875rem', // 14px + fontSize: theme.vars.typography.fontSizes.sm, // 14px fontWeight: 500, lineHeight: 1.75, letterSpacing: '0.02857em', @@ -292,19 +293,21 @@ const Typography: FC = ({ ...style, }; - const classes = clsx( - 'wso2-typography', - `wso2-typography-${variant}`, - { - 'wso2-typography-noWrap': noWrap, - 'wso2-typography-inline': inline, - 'wso2-typography-gutterBottom': gutterBottom, - }, - className, - ); - return ( - + {children} ); diff --git a/packages/react/src/contexts/Asgardeo/AsgardeoProvider.tsx b/packages/react/src/contexts/Asgardeo/AsgardeoProvider.tsx index 73995d1b..0a412568 100644 --- a/packages/react/src/contexts/Asgardeo/AsgardeoProvider.tsx +++ b/packages/react/src/contexts/Asgardeo/AsgardeoProvider.tsx @@ -26,8 +26,11 @@ import { SignOutOptions, User, UserProfile, + getBrandingPreference, + GetBrandingPreferenceConfig, + BrandingPreference, } from '@asgardeo/browser'; -import {FC, RefObject, PropsWithChildren, ReactElement, useEffect, useMemo, useRef, useState} from 'react'; +import {FC, RefObject, PropsWithChildren, ReactElement, useEffect, useMemo, useRef, useState, useCallback} from 'react'; import AsgardeoContext from './AsgardeoContext'; import AsgardeoReactClient from '../../AsgardeoReactClient'; import useBrowserUrl from '../../hooks/useBrowserUrl'; @@ -36,6 +39,7 @@ import FlowProvider from '../Flow/FlowProvider'; import I18nProvider from '../I18n/I18nProvider'; import OrganizationProvider from '../Organization/OrganizationProvider'; import ThemeProvider from '../Theme/ThemeProvider'; +import BrandingProvider from '../Branding/BrandingProvider'; import UserProvider from '../User/UserProvider'; /** @@ -66,6 +70,7 @@ const AsgardeoProvider: FC> = ({ const [isSignedInSync, setIsSignedInSync] = useState(false); const [isInitializedSync, setIsInitializedSync] = useState(false); + const [myOrganizations, setMyOrganizations] = useState([]); const [userProfile, setUserProfile] = useState(null); const [baseUrl, setBaseUrl] = useState(_baseUrl); const [config, setConfig] = useState({ @@ -81,9 +86,21 @@ const AsgardeoProvider: FC> = ({ ...rest, }); + // Branding state + const [brandingPreference, setBrandingPreference] = useState(null); + const [isBrandingLoading, setIsBrandingLoading] = useState(false); + const [brandingError, setBrandingError] = useState(null); + const [hasFetchedBranding, setHasFetchedBranding] = useState(false); + useEffect(() => { setBaseUrl(_baseUrl); - }, [_baseUrl]); + // Reset branding state when baseUrl changes + if (_baseUrl !== baseUrl) { + setHasFetchedBranding(false); + setBrandingPreference(null); + setBrandingError(null); + } + }, [_baseUrl, baseUrl]); useEffect(() => { (async (): Promise => { @@ -196,8 +213,66 @@ const AsgardeoProvider: FC> = ({ setUser(await asgardeo.getUser({baseUrl: _baseUrl})); setUserProfile(await asgardeo.getUserProfile({baseUrl: _baseUrl})); setCurrentOrganization(await asgardeo.getCurrentOrganization()); + setMyOrganizations(await asgardeo.getMyOrganizations()); }; + // Branding fetch function + const fetchBranding = useCallback(async (): Promise => { + if (!baseUrl) { + return; + } + + // Prevent multiple calls if already fetching + if (isBrandingLoading) { + return; + } + + setIsBrandingLoading(true); + setBrandingError(null); + + try { + const getBrandingConfig: GetBrandingPreferenceConfig = { + baseUrl, + locale: preferences?.i18n?.language, + // Add other branding config options as needed + }; + + const brandingData = await getBrandingPreference(getBrandingConfig); + setBrandingPreference(brandingData); + setHasFetchedBranding(true); + } catch (err) { + const errorMessage = err instanceof Error ? err : new Error('Failed to fetch branding preference'); + setBrandingError(errorMessage); + setBrandingPreference(null); + setHasFetchedBranding(true); // Mark as fetched even on error to prevent retries + } finally { + setIsBrandingLoading(false); + } + }, [baseUrl, preferences?.i18n?.language]); + + // Refetch branding function + const refetchBranding = useCallback(async (): Promise => { + setHasFetchedBranding(false); // Reset the flag to allow refetching + await fetchBranding(); + }, [fetchBranding]); + + // Auto-fetch branding when initialized and configured + useEffect(() => { + // Enable branding by default or when explicitly enabled + const shouldFetchBranding = preferences?.theme?.inheritFromBranding !== false; + + if (shouldFetchBranding && isInitializedSync && baseUrl && !hasFetchedBranding && !isBrandingLoading) { + fetchBranding(); + } + }, [ + preferences?.theme?.inheritFromBranding, + isInitializedSync, + baseUrl, + hasFetchedBranding, + isBrandingLoading, + fetchBranding, + ]); + const signIn = async (...args: any): Promise => { try { const response: User = await asgardeo.signIn(...args); @@ -281,19 +356,33 @@ const AsgardeoProvider: FC> = ({ }} > - - - - asgardeo.getOrganizations()} - currentOrganization={currentOrganization} - onOrganizationSwitch={switchOrganization} - > - {children} - - - - + + + + + await asgardeo.getAllOrganizations()} + myOrganizations={myOrganizations} + currentOrganization={currentOrganization} + onOrganizationSwitch={switchOrganization} + revalidateMyOrganizations={async () => await asgardeo.getMyOrganizations()} + > + {children} + + + + + ); diff --git a/packages/react/src/contexts/Branding/BrandingContext.ts b/packages/react/src/contexts/Branding/BrandingContext.ts new file mode 100644 index 00000000..057d07ff --- /dev/null +++ b/packages/react/src/contexts/Branding/BrandingContext.ts @@ -0,0 +1,58 @@ +/** + * 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 {createContext} from 'react'; +import {BrandingPreference, Theme} from '@asgardeo/browser'; + +export interface BrandingContextValue { + /** + * The raw branding preference data + */ + brandingPreference: BrandingPreference | null; + /** + * The transformed theme object + */ + theme: Theme | null; + /** + * The active theme mode from branding preference ('light' | 'dark') + */ + activeTheme: 'light' | 'dark' | null; + /** + * Loading state + */ + isLoading: boolean; + /** + * Error state + */ + error: Error | null; + /** + * Function to manually fetch branding preference + */ + fetchBranding: () => Promise; + /** + * Function to refetch branding preference + * This bypasses the single-call restriction and forces a new API call + */ + refetch: () => Promise; +} + +const BrandingContext = createContext(null); + +BrandingContext.displayName = 'BrandingContext'; + +export default BrandingContext; diff --git a/packages/react/src/contexts/Branding/BrandingProvider.tsx b/packages/react/src/contexts/Branding/BrandingProvider.tsx new file mode 100644 index 00000000..209b06bf --- /dev/null +++ b/packages/react/src/contexts/Branding/BrandingProvider.tsx @@ -0,0 +1,158 @@ +/** + * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import {FC, PropsWithChildren, ReactElement, useCallback, useEffect, useState} from 'react'; +import {BrandingPreference, Theme, transformBrandingPreferenceToTheme} from '@asgardeo/browser'; +import BrandingContext from './BrandingContext'; + +/** + * Configuration options for the BrandingProvider + */ +export interface BrandingProviderProps { + /** + * The branding preference data passed from parent (typically AsgardeoProvider) + */ + brandingPreference?: BrandingPreference | null; + /** + * Force a specific theme ('light' or 'dark') + * If not provided, will use the activeTheme from branding preference + */ + forceTheme?: 'light' | 'dark'; + /** + * Whether the branding provider is enabled + * @default true + */ + enabled?: boolean; + /** + * Loading state passed from parent + */ + isLoading?: boolean; + /** + * Error state passed from parent + */ + error?: Error | null; + /** + * Function to refetch branding preference passed from parent + */ + refetch?: () => Promise; +} + +/** + * BrandingProvider component that manages branding state and provides branding context to child components. + * + * This provider receives branding preferences from a parent component (typically AsgardeoProvider) + * and transforms them into theme objects, making them available to all child components. + * + * Features: + * - Receives branding preferences as props + * - Theme transformation from branding preferences + * - Loading and error states + * - Support for custom theme forcing + * + * @example + * Basic usage (typically used within AsgardeoProvider): + * ```tsx + * + * + * + * ``` + * + * @example + * With custom theme forcing: + * ```tsx + * + * + * + * ``` + */ +const BrandingProvider: FC> = ({ + children, + brandingPreference: externalBrandingPreference, + forceTheme, + enabled = true, + isLoading: externalIsLoading = false, + error: externalError = null, + refetch: externalRefetch, +}: PropsWithChildren): ReactElement => { + const [theme, setTheme] = useState(null); + const [activeTheme, setActiveTheme] = useState<'light' | 'dark' | null>(null); + + // Process branding preference when it changes + useEffect(() => { + if (!enabled || !externalBrandingPreference) { + setTheme(null); + setActiveTheme(null); + return; + } + + // Extract active theme from branding preference + const activeThemeFromBranding = externalBrandingPreference?.preference?.theme?.activeTheme; + let extractedActiveTheme: 'light' | 'dark' | null = null; + + if (activeThemeFromBranding) { + // Convert to lowercase and map to our expected values + const themeMode = activeThemeFromBranding.toLowerCase(); + if (themeMode === 'light' || themeMode === 'dark') { + extractedActiveTheme = themeMode; + } + } + + setActiveTheme(extractedActiveTheme); + + // Transform branding preference to theme + const transformedTheme = transformBrandingPreferenceToTheme(externalBrandingPreference, forceTheme); + setTheme(transformedTheme); + }, [externalBrandingPreference, forceTheme, enabled]); + + // Reset state when disabled + useEffect(() => { + if (!enabled) { + setTheme(null); + setActiveTheme(null); + } + }, [enabled]); + + // Dummy fetchBranding for backward compatibility + const fetchBranding = useCallback(async (): Promise => { + if (externalRefetch) { + await externalRefetch(); + } + }, [externalRefetch]); + + const value = { + brandingPreference: externalBrandingPreference || null, + theme, + activeTheme, + isLoading: externalIsLoading, + error: externalError, + fetchBranding, + refetch: externalRefetch || fetchBranding, + }; + + return {children}; +}; + +export default BrandingProvider; diff --git a/packages/react/src/contexts/Branding/useBrandingContext.ts b/packages/react/src/contexts/Branding/useBrandingContext.ts new file mode 100644 index 00000000..5dc1a03c --- /dev/null +++ b/packages/react/src/contexts/Branding/useBrandingContext.ts @@ -0,0 +1,54 @@ +/** + * 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 {useContext} from 'react'; +import BrandingContext, {BrandingContextValue} from './BrandingContext'; + +/** + * Hook to access the branding context. + * This hook provides access to branding preferences, theme data, and loading states. + * + * @returns The branding context value containing branding preference data, theme, and control functions + * @throws Error if used outside of a BrandingProvider + * + * @example + * ```tsx + * function MyComponent() { + * const { theme, activeTheme, isLoading, error } = useBrandingContext(); + * + * if (isLoading) return
Loading branding...
; + * if (error) return
Error: {error.message}
; + * + * return ( + *
+ *

Active theme mode: {activeTheme}

+ *

Styled with Asgardeo branding

+ *
+ * ); + * } + * ``` + */ +const useBrandingContext = (): BrandingContextValue => { + const context = useContext(BrandingContext); + if (!context) { + throw new Error('useBrandingContext must be used within a BrandingProvider'); + } + return context; +}; + +export default useBrandingContext; diff --git a/packages/react/src/contexts/Organization/OrganizationContext.ts b/packages/react/src/contexts/Organization/OrganizationContext.ts index 0d3e8ca7..a268a6da 100644 --- a/packages/react/src/contexts/Organization/OrganizationContext.ts +++ b/packages/react/src/contexts/Organization/OrganizationContext.ts @@ -16,61 +16,20 @@ * under the License. */ -import {Organization} from '@asgardeo/browser'; +import {AllOrganizationsApiResponse, Organization} from '@asgardeo/browser'; import {Context, createContext} from 'react'; -/** - * Interface for organizations with switch access information. - */ -export interface OrganizationWithSwitchAccess extends Organization { - /** - * Whether the user has switch access to this organization - */ - canSwitch: boolean; -} - /** * Props interface of {@link OrganizationContext} */ export type OrganizationContextProps = { currentOrganization: Organization | null; error: string | null; - getOrganizations: () => Promise; isLoading: boolean; - organizations: Organization[] | null; - revalidateOrganizations: () => Promise; + myOrganizations: Organization[]; switchOrganization: (organization: Organization) => Promise; - - // Enhanced features for paginated organizations with switch access - /** - * Paginated organizations with switch access information - */ - paginatedOrganizations: OrganizationWithSwitchAccess[]; - /** - * Whether there are more organizations to load - */ - hasMore: boolean; - /** - * Whether more data is being loaded - */ - isLoadingMore: boolean; - /** - * Total number of organizations - */ - totalCount: number; - /** - * Function to fetch more organizations (pagination) - */ - fetchMore: () => Promise; - /** - * Function to fetch paginated organizations with switch access - */ - fetchPaginatedOrganizations: (config?: { - filter?: string; - limit?: number; - recursive?: boolean; - reset?: boolean; - }) => Promise; + revalidateMyOrganizations: () => Promise; + getAllOrganizations: () => Promise; }; /** @@ -79,19 +38,15 @@ export type OrganizationContextProps = { const OrganizationContext: Context = createContext({ currentOrganization: null, error: null, - getOrganizations: () => Promise.resolve([]), isLoading: false, - organizations: null, - revalidateOrganizations: () => Promise.resolve(), + myOrganizations: null, switchOrganization: () => Promise.resolve(), - - // Enhanced features for paginated organizations with switch access - paginatedOrganizations: [], - hasMore: false, - isLoadingMore: false, - totalCount: 0, - fetchMore: () => Promise.resolve(), - fetchPaginatedOrganizations: () => Promise.resolve(), + revalidateMyOrganizations: () => Promise.resolve([]), + getAllOrganizations: () => + Promise.resolve({ + count: 0, + organizations: [], + }), }); OrganizationContext.displayName = 'OrganizationContext'; diff --git a/packages/react/src/contexts/Organization/OrganizationProvider.tsx b/packages/react/src/contexts/Organization/OrganizationProvider.tsx index 5eccc1b0..e2c5068e 100644 --- a/packages/react/src/contexts/Organization/OrganizationProvider.tsx +++ b/packages/react/src/contexts/Organization/OrganizationProvider.tsx @@ -16,12 +16,9 @@ * under the License. */ -import {AsgardeoRuntimeError, Organization, PaginatedOrganizationsResponse} from '@asgardeo/browser'; -import {FC, PropsWithChildren, ReactElement, useCallback, useEffect, useMemo, useState} from 'react'; -import OrganizationContext, {OrganizationContextProps, OrganizationWithSwitchAccess} from './OrganizationContext'; -import useAsgardeo from '../Asgardeo/useAsgardeo'; -import getAllOrganizations from '../../api/getAllOrganizations'; -import getMeOrganizations from '../../api/getMeOrganizations'; +import {AsgardeoRuntimeError, Organization, AllOrganizationsApiResponse} from '@asgardeo/browser'; +import {FC, PropsWithChildren, ReactElement, useCallback, useMemo, useState} from 'react'; +import OrganizationContext, {OrganizationContextProps} from './OrganizationContext'; /** * Props interface of {@link OrganizationProvider} @@ -36,9 +33,9 @@ export interface OrganizationProviderProps { */ currentOrganization?: Organization | null; /** - * Function to fetch organizations + * List of organizations the signed-in user belongs to. */ - getOrganizations: () => Promise; + myOrganizations?: Organization[]; /** * Callback function called when an error occurs */ @@ -50,7 +47,12 @@ export interface OrganizationProviderProps { /** * Initial list of organizations */ - organizations?: Organization[] | null; + getAllOrganizations?: () => Promise; + /** + * Refetch the my organizations list. + * @returns + */ + revalidateMyOrganizations: () => Promise; } /** @@ -61,7 +63,6 @@ export interface OrganizationProviderProps { * - Manages current organization state * - Provides functions for switching organizations and refreshing data * - Handles loading states and errors - * - Accepts a custom getOrganizations function for flexible data fetching * * @example * ```tsx @@ -70,11 +71,6 @@ export interface OrganizationProviderProps { * * * - * // With custom getOrganizations function - * asgardeo.getOrganizations()}> - * - * - * * // With custom error handling * console.error('Organization error:', error)}> * @@ -87,84 +83,24 @@ export interface OrganizationProviderProps { * * * - * // Disable auto-fetch (fetch manually using revalidateOrganizations) + * // Disable auto-fetch (fetch manually using revalidateMyOrganizations) * * * * ``` */ const OrganizationProvider: FC> = ({ - autoFetch = true, children, currentOrganization, - getOrganizations, onError, + myOrganizations, onOrganizationSwitch, - organizations: initialOrganizations = null, + revalidateMyOrganizations, + getAllOrganizations, }: PropsWithChildren): ReactElement => { - const {baseUrl, isSignedIn} = useAsgardeo(); - const [organizations, setOrganizations] = useState(initialOrganizations); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); - // Enhanced state for paginated organizations with switch access - const [paginatedOrganizations, setPaginatedOrganizations] = useState([]); - const [hasMore, setHasMore] = useState(false); - const [isLoadingMore, setIsLoadingMore] = useState(false); - const [totalCount, setTotalCount] = useState(0); - const [switchableOrgIds, setSwitchableOrgIds] = useState>(new Set()); - const [currentPage, setCurrentPage] = useState(1); - const [currentFilter, setCurrentFilter] = useState<{ - filter?: string; - limit?: number; - recursive?: boolean; - }>({}); - const [isFetching, setIsFetching] = useState(false); - - /** - * Fetches organizations from the API - */ - const fetchOrganizations: () => Promise = useCallback(async (): Promise => { - if (!isSignedIn) { - return; - } - - setIsLoading(true); - setError(null); - - try { - let organizationsData: Organization[]; - - if (getOrganizations) { - organizationsData = await getOrganizations(); - } else { - throw new AsgardeoRuntimeError( - 'getOrganizations function is required', - 'OrganizationProvider-ValidationError-001', - 'react', - 'The getOrganizations function must be provided to fetch organization data.', - ); - } - - setOrganizations(organizationsData); - } catch (fetchError) { - const errorMessage: string = fetchError instanceof Error ? fetchError.message : 'Failed to fetch organizations'; - setError(errorMessage); - if (onError) { - onError(errorMessage); - } - } finally { - setIsLoading(false); - } - }, [baseUrl, getOrganizations, isSignedIn, onError]); - - /** - * Revalidates organizations by fetching fresh data - */ - const revalidateOrganizations: () => Promise = useCallback(async (): Promise => { - await fetchOrganizations(); - }, [fetchOrganizations]); - /** * Switches to a different organization */ @@ -200,176 +136,24 @@ const OrganizationProvider: FC> = ( [onOrganizationSwitch, onError], ); - /** - * Fetches paginated organizations with switch access information - */ - const fetchPaginatedOrganizations: (config?: { - filter?: string; - limit?: number; - recursive?: boolean; - reset?: boolean; - }) => Promise = useCallback( - async (config = {}): Promise => { - const {filter = '', limit = 10, recursive = false, reset = false} = config; - - if (!isSignedIn || !baseUrl || isFetching) { - return; - } - - setIsFetching(true); - - try { - if (reset) { - setIsLoading(true); - setError(null); - setCurrentPage(1); - setPaginatedOrganizations([]); - setCurrentFilter({filter, limit, recursive}); - } else { - setIsLoadingMore(true); - } - - // Fetch user's switchable organizations first (only once per session) - let switchableIds: Set = switchableOrgIds; - if (switchableIds.size === 0) { - try { - const userOrgs: Organization[] = await getMeOrganizations({ - authorizedAppName: 'Console', - baseUrl, - limit: 100, // Get all user organizations - recursive, - }); - switchableIds = new Set(userOrgs.map((org: Organization) => org.id)); - setSwitchableOrgIds(switchableIds); - } catch { - // Continue with empty switchable set if user organizations fetch fails - } - } - - // Fetch all organizations with pagination - const response: PaginatedOrganizationsResponse = await getAllOrganizations({ - baseUrl, - filter, - 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 - const organizationsWithAccess: OrganizationWithSwitchAccess[] = response.organizations.map( - (org: Organization) => ({ - ...org, - canSwitch: switchableIds.has(org.id), - }), - ); - - if (reset) { - setPaginatedOrganizations(organizationsWithAccess); - } else { - setPaginatedOrganizations((prevData: OrganizationWithSwitchAccess[]) => [ - ...prevData, - ...organizationsWithAccess, - ]); - setCurrentPage(prev => prev + 1); - } - - setHasMore(response.hasMore || false); - setTotalCount(response.totalCount || organizationsWithAccess.length); - } catch (fetchError: any) { - // 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], - ); - - /** - * Fetch more organizations for pagination - */ - const fetchMore: () => Promise = useCallback(async (): Promise => { - if (!hasMore || isLoadingMore) { - return; - } - await fetchPaginatedOrganizations(currentFilter); - }, [fetchPaginatedOrganizations, hasMore, isLoadingMore, currentFilter]); - - // Auto-fetch organizations when component mounts or dependencies change - useEffect(() => { - if (autoFetch && isSignedIn) { - fetchOrganizations(); - } - }, [autoFetch, fetchOrganizations, isSignedIn]); - const contextValue: OrganizationContextProps = useMemo( () => ({ currentOrganization, error, - getOrganizations, isLoading, - organizations, - revalidateOrganizations, + myOrganizations, switchOrganization, - - // Enhanced features - paginatedOrganizations, - hasMore, - isLoadingMore, - totalCount, - fetchMore, - fetchPaginatedOrganizations, + revalidateMyOrganizations, + getAllOrganizations, }), [ currentOrganization, error, - getOrganizations, isLoading, - organizations, - revalidateOrganizations, + myOrganizations, switchOrganization, - paginatedOrganizations, - hasMore, - isLoadingMore, - totalCount, - fetchMore, - fetchPaginatedOrganizations, + revalidateMyOrganizations, + getAllOrganizations, ], ); diff --git a/packages/react/src/contexts/Organization/useOrganization.ts b/packages/react/src/contexts/Organization/useOrganization.ts index 3ccb480e..be26478d 100644 --- a/packages/react/src/contexts/Organization/useOrganization.ts +++ b/packages/react/src/contexts/Organization/useOrganization.ts @@ -41,7 +41,7 @@ import OrganizationContext, {OrganizationContextProps} from './OrganizationConte * organizations, * currentOrganization, * switchOrganization, - * revalidateOrganizations, + * revalidateMyOrganizations, * getOrganizations, * isLoading, * error @@ -71,7 +71,7 @@ import OrganizationContext, {OrganizationContextProps} from './OrganizationConte * * ))} * - * *
+
{formatLabel(key)}: + {typeof value === 'object' ? : String(value)}