From 719c4a473ff2073fed61df62350afa5fdf6fbaac Mon Sep 17 00:00:00 2001 From: Brion Date: Tue, 15 Jul 2025 17:51:16 +0530 Subject: [PATCH 1/4] chore: show user profile update error --- .../javascript/src/api/updateMeProfile.ts | 5 ++-- packages/javascript/src/i18n/en-US.ts | 7 +++++ packages/javascript/src/models/i18n.ts | 7 +++++ .../presentation/UserProfile/UserProfile.tsx | 30 ++++++++++++++----- .../UserProfile/BaseUserProfile.tsx | 20 +++++++------ .../presentation/UserProfile/UserProfile.tsx | 24 ++++++++++++--- 6 files changed, 70 insertions(+), 23 deletions(-) diff --git a/packages/javascript/src/api/updateMeProfile.ts b/packages/javascript/src/api/updateMeProfile.ts index 90c376f6..ff99cd84 100644 --- a/packages/javascript/src/api/updateMeProfile.ts +++ b/packages/javascript/src/api/updateMeProfile.ts @@ -147,10 +147,11 @@ const updateMeProfile = async ({ } throw new AsgardeoAPIError( - `Network or parsing error: ${error instanceof Error ? error.message : 'Unknown error'}`, + error?.response?.data?.detail || + 'An error occurred while updating the user profile. Please try again.', 'updateMeProfile-NetworkError-001', 'javascript', - 0, + error?.data?.status, 'Network Error', ); } diff --git a/packages/javascript/src/i18n/en-US.ts b/packages/javascript/src/i18n/en-US.ts index 31242dda..f45af26f 100644 --- a/packages/javascript/src/i18n/en-US.ts +++ b/packages/javascript/src/i18n/en-US.ts @@ -78,6 +78,13 @@ const translations: I18nTranslations = { 'username.password.title': 'Sign In', 'username.password.subtitle': 'Enter your username and password to continue.', + /* |---------------------------------------------------------------| */ + /* | User Profile | */ + /* |---------------------------------------------------------------| */ + + 'user.profile.title': 'User Profile', + 'user.profile.update.generic.error': 'An error occurred while updating your profile. Please try again.', + /* |---------------------------------------------------------------| */ /* | Organization Switcher | */ /* |---------------------------------------------------------------| */ diff --git a/packages/javascript/src/models/i18n.ts b/packages/javascript/src/models/i18n.ts index dbdf9d27..39afb9c9 100644 --- a/packages/javascript/src/models/i18n.ts +++ b/packages/javascript/src/models/i18n.ts @@ -76,6 +76,13 @@ export interface I18nTranslations { 'username.password.title': string; 'username.password.subtitle': string; + /* |---------------------------------------------------------------| */ + /* | User Profile | */ + /* |---------------------------------------------------------------| */ + + 'user.profile.title': string; + 'user.profile.update.generic.error': string; + /* |---------------------------------------------------------------| */ /* | Organization Switcher | */ /* |---------------------------------------------------------------| */ diff --git a/packages/nextjs/src/client/components/presentation/UserProfile/UserProfile.tsx b/packages/nextjs/src/client/components/presentation/UserProfile/UserProfile.tsx index 6465b4e7..bd99599b 100644 --- a/packages/nextjs/src/client/components/presentation/UserProfile/UserProfile.tsx +++ b/packages/nextjs/src/client/components/presentation/UserProfile/UserProfile.tsx @@ -18,12 +18,10 @@ 'use client'; -import {FC, ReactElement} from 'react'; -import {BaseUserProfile, BaseUserProfileProps, useUser} from '@asgardeo/react'; -import useAsgardeo from '../../../contexts/Asgardeo/useAsgardeo'; +import {FC, ReactElement, useState} from 'react'; +import {BaseUserProfile, BaseUserProfileProps, useTranslation, useUser} from '@asgardeo/react'; import getSessionId from '../../../../server/actions/getSessionId'; -import updateUserProfileAction from '../../../../server/actions/updateUserProfileAction'; -import {Schema, User} from '@asgardeo/node'; +import {AsgardeoError, Schema, User} from '@asgardeo/node'; /** * Props for the UserProfile component. @@ -55,12 +53,27 @@ export type UserProfileProps = Omit = ({...rest}: UserProfileProps): ReactElement => { - const {baseUrl} = useAsgardeo(); const {profile, flattenedProfile, schemas, onUpdateProfile, updateProfile} = useUser(); + const {t} = useTranslation(); + + const [error, setError] = useState(null); const handleProfileUpdate = async (payload: any): Promise => { - const result = await updateProfile(payload, (await getSessionId()) as string); - onUpdateProfile(result?.data?.user); + setError(null); + + try { + const result = await updateProfile(payload, (await getSessionId()) as string); + + onUpdateProfile(result?.data?.user); + } catch (error: unknown) { + let message: string = t('user.profile.update.generic.error'); + + if (error instanceof AsgardeoError) { + message = error?.message; + } + + setError(message); + } }; return ( @@ -69,6 +82,7 @@ const UserProfile: FC = ({...rest}: UserProfileProps): ReactEl flattenedProfile={flattenedProfile as User} schemas={schemas as Schema[]} onUpdate={handleProfileUpdate} + error={error} {...rest} /> ); diff --git a/packages/react/src/components/presentation/UserProfile/BaseUserProfile.tsx b/packages/react/src/components/presentation/UserProfile/BaseUserProfile.tsx index 42b71a8f..fbdca35f 100644 --- a/packages/react/src/components/presentation/UserProfile/BaseUserProfile.tsx +++ b/packages/react/src/components/presentation/UserProfile/BaseUserProfile.tsx @@ -30,6 +30,8 @@ import TextField from '../../primitives/TextField/TextField'; import MultiInput from '../../primitives/MultiInput/MultiInput'; import Card from '../../primitives/Card/Card'; import useStyles from './BaseUserProfile.styles'; +import useTranslation from '../../../hooks/useTranslation'; +import Alert from '../../primitives/Alert/Alert'; interface ExtendedFlatSchema { path?: string; @@ -60,22 +62,19 @@ export interface BaseUserProfileProps { picture?: string | string[]; username?: string | string[]; }; - cancelButtonText?: string; cardLayout?: boolean; className?: string; editable?: boolean; fallback?: ReactElement; flattenedProfile?: User; mode?: 'inline' | 'popup'; - onChange?: (field: string, value: any) => void; onOpenChange?: (open: boolean) => void; - onSubmit?: (data: any) => void; onUpdate?: (payload: any) => Promise; open?: boolean; profile?: User; - saveButtonText?: string; schemas?: Schema[]; title?: string; + error?: string | null; } // Fields to skip based on schema.name @@ -113,18 +112,15 @@ const BaseUserProfile: FC = ({ title = 'User Profile', attributeMapping = {}, editable = true, - onChange, onOpenChange, - onSubmit, onUpdate, open = false, - saveButtonText = 'Save Changes', - cancelButtonText = 'Cancel', + error = null, }): ReactElement => { const {theme, colorScheme} = useTheme(); const [editedUser, setEditedUser] = useState(flattenedProfile || profile); const [editingFields, setEditingFields] = useState>({}); - const triggerRef = useRef(null); + const {t} = useTranslation(); const PencilIcon = () => ( = ({ const profileContent = ( + {error && ( + + {t('errors.title') || 'Error'} + {error} + + )}
= ({...rest}: UserProfileProps): ReactElement => { const {baseUrl} = useAsgardeo(); const {profile, flattenedProfile, schemas, onUpdateProfile} = useUser(); + const {t} = useTranslation(); + + const [error, setError] = useState(null); const handleProfileUpdate = async (payload: any): Promise => { - const response: User = await updateMeProfile({baseUrl, payload}); + setError(null); + + try { + const response: User = await updateMeProfile({baseUrl, payload}); + onUpdateProfile(response); + } catch (error: unknown) { + let message: string = t('user.profile.update.generic.error'); + + if (error instanceof AsgardeoError) { + message = error?.message; + } - onUpdateProfile(response); + setError(message); + } }; return ( @@ -68,6 +83,7 @@ const UserProfile: FC = ({...rest}: UserProfileProps): ReactEl flattenedProfile={flattenedProfile} schemas={schemas} onUpdate={handleProfileUpdate} + error={error} {...rest} /> ); From a1c9dd3429eb7cd34a370ab8267f832a98f80b63 Mon Sep 17 00:00:00 2001 From: Brion Date: Tue, 15 Jul 2025 17:56:16 +0530 Subject: [PATCH 2/4] chore(react): refactor BaseUserProfile styles to use CSS classes instead of inline styles --- .../UserProfile/BaseUserProfile.styles.ts | 53 ++++++++++++++++ .../UserProfile/BaseUserProfile.tsx | 62 +++++-------------- 2 files changed, 68 insertions(+), 47 deletions(-) diff --git a/packages/react/src/components/presentation/UserProfile/BaseUserProfile.styles.ts b/packages/react/src/components/presentation/UserProfile/BaseUserProfile.styles.ts index 46e115c2..a7039c3f 100644 --- a/packages/react/src/components/presentation/UserProfile/BaseUserProfile.styles.ts +++ b/packages/react/src/components/presentation/UserProfile/BaseUserProfile.styles.ts @@ -27,6 +27,52 @@ import {Theme} from '@asgardeo/browser'; * @returns Object containing CSS class names for component styling */ const useStyles = (theme: Theme, colorScheme: string) => { + // Additional styles for moved inline styles + const valuePlaceholder = css` + font-style: italic; + opacity: 0.7; + `; + + const editButton = css` + font-style: italic; + text-decoration: underline; + opacity: 0.7; + padding: 0; + min-height: auto; + `; + + const fieldInner = css` + flex: 1; + display: flex; + align-items: center; + gap: ${theme.vars.spacing.unit}; + `; + + const fieldActions = css` + display: flex; + gap: calc(${theme.vars.spacing.unit} / 2); + align-items: center; + margin-left: ${theme.vars.spacing.unit}; + `; + + const complexTextarea = css` + min-height: 60px; + width: 100%; + padding: 8px; + border: 1px solid ${theme.vars.colors.border}; + border-radius: ${theme.vars.borderRadius.small}; + resize: vertical; + `; + + // For ObjectDisplay table cells + const objectKey = css` + padding: ${theme.vars.spacing.unit}; + vertical-align: top; + `; + const objectValue = css` + padding: ${theme.vars.spacing.unit}; + vertical-align: top; + `; return useMemo(() => { const root = css` padding: calc(${theme.vars.spacing.unit} * 4); @@ -127,6 +173,13 @@ const useStyles = (theme: Theme, colorScheme: string) => { label, value, popup, + valuePlaceholder, + editButton, + fieldInner, + fieldActions, + complexTextarea, + objectKey, + objectValue, }; }, [ theme.vars.colors.background.surface, diff --git a/packages/react/src/components/presentation/UserProfile/BaseUserProfile.tsx b/packages/react/src/components/presentation/UserProfile/BaseUserProfile.tsx index fbdca35f..3c883844 100644 --- a/packages/react/src/components/presentation/UserProfile/BaseUserProfile.tsx +++ b/packages/react/src/components/presentation/UserProfile/BaseUserProfile.tsx @@ -164,15 +164,14 @@ const BaseUserProfile: FC = ({ const ObjectDisplay: FC<{data: unknown}> = ({data}) => { if (!data || typeof data !== 'object') return null; + // Use styles from .styles.ts for table and td return ( - +
{Object.entries(data).map(([key, value]) => ( - - - + + @@ -345,9 +344,7 @@ const BaseUserProfile: FC = ({ fieldType={type as 'STRING' | 'DATE_TIME' | 'BOOLEAN'} type={type === 'DATE_TIME' ? 'date' : type === 'STRING' ? 'text' : 'text'} required={required} - style={{ - marginBottom: 0, - }} + // Removed inline style, use .styles.ts for marginBottom if needed /> @@ -371,11 +368,7 @@ const BaseUserProfile: FC = ({ <> {label}
{!hasValues && isEditable && onStartEdit ? ( @@ -419,9 +406,7 @@ const BaseUserProfile: FC = ({ value: fieldValue, onChange: (e: any) => onEditValue(e.target ? e.target.value : e), placeholder: getFieldPlaceholder(schema), - style: { - marginBottom: 0, - }, + // Removed inline style, use .styles.ts for marginBottom if needed }; let field: ReactElement; switch (type) { @@ -441,15 +426,7 @@ const BaseUserProfile: FC = ({ onChange={e => onEditValue(e.target.value)} placeholder={getFieldPlaceholder(schema)} required={required} - style={{ - ...commonProps.style, - minHeight: '60px', - width: '100%', - padding: '8px', - border: `1px solid ${theme.vars.colors.border}`, - borderRadius: theme.vars.borderRadius.small, - resize: 'vertical', - }} + className={styles.complexTextarea} /> ); break; @@ -531,8 +508,8 @@ const BaseUserProfile: FC = ({ }; return ( -
-
+
+
{renderSchemaField( schema, isFieldEditing, @@ -545,14 +522,7 @@ const BaseUserProfile: FC = ({ )}
{editable && schema.mutability !== 'READ_ONLY' && !isReadonlyField && ( -
+
{isFieldEditing && ( <> @@ -687,7 +655,7 @@ const BaseUserProfile: FC = ({ {title} -
{profileContent}
+
{profileContent}
); From c133c70300550cc9f8d6b8ed7a9f607ee5fb17b7 Mon Sep 17 00:00:00 2001 From: Brion Date: Wed, 16 Jul 2025 00:23:01 +0530 Subject: [PATCH 3/4] chore(react): update AsgardeoError class to simplify message handling and enhance styling in BaseUserProfile component --- .../javascript/src/errors/AsgardeoError.ts | 10 +- packages/nextjs/src/utils/SessionManager.ts | 4 +- .../UserProfile/BaseUserProfile.styles.ts | 139 ++++++++++-------- .../UserProfile/BaseUserProfile.tsx | 39 ++--- .../primitives/Alert/Alert.styles.ts | 8 +- .../primitives/Dialog/Dialog.styles.ts | 9 +- 6 files changed, 110 insertions(+), 99 deletions(-) diff --git a/packages/javascript/src/errors/AsgardeoError.ts b/packages/javascript/src/errors/AsgardeoError.ts index 5d34f6cb..2ae81905 100644 --- a/packages/javascript/src/errors/AsgardeoError.ts +++ b/packages/javascript/src/errors/AsgardeoError.ts @@ -55,12 +55,7 @@ export default class AsgardeoError extends Error { constructor(message: string, code: string, origin: string) { const _origin: string = AsgardeoError.resolveOrigin(origin); - const prefix: string = `🛡️ Asgardeo - ${_origin}:`; - const regex: RegExp = new RegExp(`🛡️\\s*Asgardeo\\s*-\\s*${_origin}:`, 'i'); - const sanitized: string = message.replace(regex, ''); - const _message: string = `${prefix} ${sanitized.trim()}\n\n(code="${code}")\n`; - - super(_message); + super(message); this.name = new.target.name; this.code = code; @@ -72,6 +67,7 @@ export default class AsgardeoError extends Error { } public override toString(): string { - return `[${this.name}]\nMessage: ${this.message}`; + const prefix: string = `🛡️ Asgardeo - ${this.origin}:`; + return `[${this.name}]\n${prefix} ${this.message}\n(code=\"${this.code}\")`; } } diff --git a/packages/nextjs/src/utils/SessionManager.ts b/packages/nextjs/src/utils/SessionManager.ts index 63a43c77..0091f612 100644 --- a/packages/nextjs/src/utils/SessionManager.ts +++ b/packages/nextjs/src/utils/SessionManager.ts @@ -28,13 +28,15 @@ export interface SessionTokenPayload extends JWTPayload { /** Session ID */ sessionId: string; /** OAuth scopes */ - scopes: string[]; + scopes: string; /** Organization ID if applicable */ organizationId?: string; /** Issued at timestamp */ iat: number; /** Expiration timestamp */ exp: number; + /** Access token */ + accessToken: string; } /** diff --git a/packages/react/src/components/presentation/UserProfile/BaseUserProfile.styles.ts b/packages/react/src/components/presentation/UserProfile/BaseUserProfile.styles.ts index a7039c3f..07a0435c 100644 --- a/packages/react/src/components/presentation/UserProfile/BaseUserProfile.styles.ts +++ b/packages/react/src/components/presentation/UserProfile/BaseUserProfile.styles.ts @@ -18,7 +18,7 @@ import {css} from '@emotion/css'; import {useMemo} from 'react'; -import {Theme} from '@asgardeo/browser'; +import {Theme, withVendorCSSClassPrefix} from '@asgardeo/browser'; /** * Creates styles for the BaseUserProfile component @@ -27,52 +27,56 @@ import {Theme} from '@asgardeo/browser'; * @returns Object containing CSS class names for component styling */ const useStyles = (theme: Theme, colorScheme: string) => { - // Additional styles for moved inline styles - const valuePlaceholder = css` - font-style: italic; - opacity: 0.7; - `; - - const editButton = css` - font-style: italic; - text-decoration: underline; - opacity: 0.7; - padding: 0; - min-height: auto; - `; - - const fieldInner = css` - flex: 1; - display: flex; - align-items: center; - gap: ${theme.vars.spacing.unit}; - `; - - const fieldActions = css` - display: flex; - gap: calc(${theme.vars.spacing.unit} / 2); - align-items: center; - margin-left: ${theme.vars.spacing.unit}; - `; - - const complexTextarea = css` - min-height: 60px; - width: 100%; - padding: 8px; - border: 1px solid ${theme.vars.colors.border}; - border-radius: ${theme.vars.borderRadius.small}; - resize: vertical; - `; - - // For ObjectDisplay table cells - const objectKey = css` - padding: ${theme.vars.spacing.unit}; - vertical-align: top; - `; - const objectValue = css` - padding: ${theme.vars.spacing.unit}; - vertical-align: top; - `; + // Additional styles for moved inline styles + const valuePlaceholder = css` + font-style: italic; + opacity: 0.7; + `; + + const editButton = css` + font-style: italic; + text-decoration: underline; + opacity: 0.7; + padding: 0; + min-height: auto; + + &:hover:not(:disabled) { + background-color: transparent; + } + `; + + const fieldInner = css` + flex: 1; + display: flex; + align-items: center; + gap: ${theme.vars.spacing.unit}; + `; + + const fieldActions = css` + display: flex; + gap: calc(${theme.vars.spacing.unit} / 2); + align-items: center; + margin-left: calc(${theme.vars.spacing.unit} * 4); + `; + + const complexTextarea = css` + min-height: 60px; + width: 100%; + padding: 8px; + border: 1px solid ${theme.vars.colors.border}; + border-radius: ${theme.vars.borderRadius.small}; + resize: vertical; + `; + + // For ObjectDisplay table cells + const objectKey = css` + padding: ${theme.vars.spacing.unit}; + vertical-align: top; + `; + const objectValue = css` + padding: ${theme.vars.spacing.unit}; + vertical-align: top; + `; return useMemo(() => { const root = css` padding: calc(${theme.vars.spacing.unit} * 4); @@ -106,14 +110,17 @@ const useStyles = (theme: Theme, colorScheme: string) => { const infoContainer = css` display: flex; flex-direction: column; - gap: ${theme.vars.spacing.unit}; + `; + + const info = css` + padding: calc(${theme.vars.spacing.unit} * 2) 0; + border-bottom: 1px solid ${theme.vars.colors.border}; `; const field = css` display: flex; - align-items: flex-start; + align-items: center; padding: calc(${theme.vars.spacing.unit} / 2) 0; - border-bottom: 1px solid ${theme.vars.colors.border}; min-height: 28px; `; @@ -128,21 +135,29 @@ const useStyles = (theme: Theme, colorScheme: string) => { width: 120px; flex-shrink: 0; line-height: 28px; + text-align: left; `; const value = css` color: ${theme.vars.colors.text.primary}; flex: 1; - display: flex; + display: inline-block; align-items: center; gap: ${theme.vars.spacing.unit}; overflow: hidden; min-height: 28px; line-height: 28px; word-break: break-word; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 350px; + text-align: left; + + .${withVendorCSSClassPrefix('form-control')} { + margin-bottom: 0; + } input { - height: 32px; margin: 0; } @@ -161,25 +176,31 @@ const useStyles = (theme: Theme, colorScheme: string) => { padding: calc(${theme.vars.spacing.unit} * 2); `; + const alert = css` + margin-bottom: calc(${theme.vars.spacing.unit} * 3); + `; + return { root, + alert, card, header, profileInfo, name, infoContainer, + info, field, lastField, label, value, popup, - valuePlaceholder, - editButton, - fieldInner, - fieldActions, - complexTextarea, - objectKey, - objectValue, + valuePlaceholder, + editButton, + fieldInner, + fieldActions, + complexTextarea, + objectKey, + objectValue, }; }, [ theme.vars.colors.background.surface, diff --git a/packages/react/src/components/presentation/UserProfile/BaseUserProfile.tsx b/packages/react/src/components/presentation/UserProfile/BaseUserProfile.tsx index 3c883844..c0f070f2 100644 --- a/packages/react/src/components/presentation/UserProfile/BaseUserProfile.tsx +++ b/packages/react/src/components/presentation/UserProfile/BaseUserProfile.tsx @@ -16,7 +16,7 @@ * under the License. */ -import {User, withVendorCSSClassPrefix, WellKnownSchemaIds} from '@asgardeo/browser'; +import {User, withVendorCSSClassPrefix, WellKnownSchemaIds, bem} from '@asgardeo/browser'; import {cx} from '@emotion/css'; import {FC, ReactElement, useState, useCallback, useRef} from 'react'; import useTheme from '../../../contexts/Theme/useTheme'; @@ -96,6 +96,7 @@ const fieldsToSkip: string[] = [ 'verifiedEmailAddresses', 'phoneNumbers.mobile', 'emailAddresses', + 'preferredMFAOption', ]; // Fields that should be readonly @@ -109,7 +110,7 @@ const BaseUserProfile: FC = ({ schemas = [], flattenedProfile, mode = 'inline', - title = 'User Profile', + title, attributeMapping = {}, editable = true, onOpenChange, @@ -170,7 +171,9 @@ const BaseUserProfile: FC = ({
{Object.entries(data).map(([key, value]) => ( - + @@ -367,9 +370,7 @@ const BaseUserProfile: FC = ({ return ( <> {label} -
+
{!hasValues && isEditable && onStartEdit ? ( @@ -605,7 +594,7 @@ const BaseUserProfile: FC = ({ const profileContent = ( {error && ( - + {t('errors.title') || 'Error'} {error} @@ -643,7 +632,11 @@ const BaseUserProfile: FC = ({ value, }; - return
{renderUserInfo(schemaWithValue)}
; + return ( +
+ {renderUserInfo(schemaWithValue)} +
+ ); }) : renderProfileWithoutSchemas()}
@@ -654,7 +647,7 @@ const BaseUserProfile: FC = ({ return ( - {title} + {title ?? t('user.profile.title')}
{profileContent}
diff --git a/packages/react/src/components/primitives/Alert/Alert.styles.ts b/packages/react/src/components/primitives/Alert/Alert.styles.ts index 793ce0fe..574251f1 100644 --- a/packages/react/src/components/primitives/Alert/Alert.styles.ts +++ b/packages/react/src/components/primitives/Alert/Alert.styles.ts @@ -42,22 +42,22 @@ const useStyles = (theme: Theme, colorScheme: string, variant: AlertVariant) => const variantStyles = { success: css` - background-color: ${theme.vars.colors.success.main}; + background-color: color-mix(in srgb, ${theme.vars.colors.success.main} 20%, white); border-color: ${theme.vars.colors.success.main}; color: ${theme.vars.colors.success.main}; `, error: css` - background-color: ${theme.vars.colors.error.main}; + background-color: color-mix(in srgb, ${theme.vars.colors.error.main} 20%, white); border-color: ${theme.vars.colors.error.main}; color: ${theme.vars.colors.error.main}; `, warning: css` - background-color: ${theme.vars.colors.warning.main}; + background-color: color-mix(in srgb, ${theme.vars.colors.warning.main} 20%, white); border-color: ${theme.vars.colors.warning.main}; color: ${theme.vars.colors.warning.main}; `, info: css` - background-color: ${theme.vars.colors.info.main}; + background-color: color-mix(in srgb, ${theme.vars.colors.info.main} 20%, white); border-color: ${theme.vars.colors.info.main}; color: ${theme.vars.colors.info.main}; `, diff --git a/packages/react/src/components/primitives/Dialog/Dialog.styles.ts b/packages/react/src/components/primitives/Dialog/Dialog.styles.ts index 96ce99e5..a5d8c4cb 100644 --- a/packages/react/src/components/primitives/Dialog/Dialog.styles.ts +++ b/packages/react/src/components/primitives/Dialog/Dialog.styles.ts @@ -41,9 +41,8 @@ const useStyles = (theme: Theme, colorScheme: string) => { border-radius: ${theme.vars.borderRadius.large}; box-shadow: 0 2px 8px ${colorScheme === 'dark' ? 'rgba(0, 0, 0, 0.3)' : 'rgba(0, 0, 0, 0.15)'}; outline: none; - max-width: 50vw; - min-width: 50vw; - max-height: 90vh; + max-width: 650px; + max-height: calc(100% - 64px); overflow-y: auto; z-index: 10000; `; @@ -53,8 +52,8 @@ const useStyles = (theme: Theme, colorScheme: string) => { border-radius: ${theme.vars.borderRadius.large}; box-shadow: 0 2px 8px ${colorScheme === 'dark' ? 'rgba(0, 0, 0, 0.3)' : 'rgba(0, 0, 0, 0.15)'}; outline: none; - max-width: 90vw; - max-height: 90vh; + max-width: 600px; + max-height: calc(100% - 64px); overflow-y: auto; z-index: 10000; `; From a83f3f371e21da8a3913930a328c36031da92b72 Mon Sep 17 00:00:00 2001 From: Brion Date: Thu, 24 Jul 2025 17:09:45 +0530 Subject: [PATCH 4/4] chore: [wip] user components --- packages/javascript/src/api/createUser.ts | 117 ++++++ packages/javascript/src/api/getUserstores.ts | 97 +++++ packages/javascript/src/i18n/en-US.ts | 9 + packages/javascript/src/index.ts | 7 +- packages/javascript/src/models/i18n.ts | 9 + .../javascript/src/models/scim2-schema.ts | 8 + packages/javascript/src/models/userstore.ts | 33 ++ .../src/utils/detectUserstoreEnvironment.ts | 65 ++++ .../src/utils/getAttributeProfileSchema.ts | 63 ++++ .../CreateOrganization/CreateOrganization.tsx | 10 +- .../docs/components/asgardeo-provider.md | 88 ++--- packages/react/src/api/createUser.ts | 104 ++++++ packages/react/src/api/getUserstores.ts | 60 +++ .../CreateOrganization/CreateOrganization.tsx | 10 +- .../CreateUser/BaseCreateUser.tsx | 348 ++++++++++++++++++ .../CreateUser/CreateUser.styles.ts | 208 +++++++++++ .../presentation/CreateUser/CreateUser.tsx | 120 ++++++ .../react/src/contexts/User/UserContext.ts | 4 +- .../react/src/contexts/User/UserProvider.tsx | 5 +- packages/react/src/index.ts | 3 + samples/teamspace-react/src/App.tsx | 10 +- samples/teamspace-react/src/main.tsx | 3 +- .../teamspace-react/src/pages/Dashboard.tsx | 13 +- 23 files changed, 1326 insertions(+), 68 deletions(-) create mode 100644 packages/javascript/src/api/createUser.ts create mode 100644 packages/javascript/src/api/getUserstores.ts create mode 100644 packages/javascript/src/models/userstore.ts create mode 100644 packages/javascript/src/utils/detectUserstoreEnvironment.ts create mode 100644 packages/javascript/src/utils/getAttributeProfileSchema.ts create mode 100644 packages/react/src/api/createUser.ts create mode 100644 packages/react/src/api/getUserstores.ts create mode 100644 packages/react/src/components/presentation/CreateUser/BaseCreateUser.tsx create mode 100644 packages/react/src/components/presentation/CreateUser/CreateUser.styles.ts create mode 100644 packages/react/src/components/presentation/CreateUser/CreateUser.tsx diff --git a/packages/javascript/src/api/createUser.ts b/packages/javascript/src/api/createUser.ts new file mode 100644 index 00000000..a207a534 --- /dev/null +++ b/packages/javascript/src/api/createUser.ts @@ -0,0 +1,117 @@ +/** + * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import {User} from '../models/user'; +import AsgardeoAPIError from '../errors/AsgardeoAPIError'; + +/** + * Configuration for the createUser request + */ +export interface CreateUserConfig extends Omit { + /** + * The absolute API endpoint. + * Defaults to https://api.asgardeo.io/t/dxlab/scim2/Users + */ + url?: string; + /** + * The base path of the API endpoint (e.g., https://api.asgardeo.io/t/dxlab) + */ + baseUrl?: string; + /** + * The user object to create (SCIM2 User schema) + */ + payload: any; + /** + * Optional custom fetcher function. + * If not provided, native fetch will be used + */ + fetcher?: (url: string, config: RequestInit) => Promise; +} + +/** + * Creates a new user at the SCIM2 Users endpoint. + * + * @param config - Configuration object with URL, payload and optional request config. + * @returns A promise that resolves with the created user profile information. + * @example + * ```typescript + * await createUser({ + * url: "https://api.asgardeo.io/t/dxlab/scim2/Users", + * payload: { ... } + * }); + * ``` + */ +const createUser = async ({url, baseUrl, payload, fetcher, ...requestConfig}: CreateUserConfig): Promise => { + try { + new URL(url ?? baseUrl); + } catch (error) { + throw new AsgardeoAPIError( + `Invalid URL provided. ${error?.toString()}`, + 'createUser-ValidationError-001', + 'javascript', + 400, + 'The provided `url` or `baseUrl` path does not adhere to the URL schema.', + ); + } + + const fetchFn = fetcher || fetch; + const resolvedUrl: string = url ?? `${baseUrl}/scim2/Users`; + + const requestInit: RequestInit = { + method: 'POST', + ...requestConfig, + headers: { + ...requestConfig.headers, + 'Content-Type': 'application/scim+json', + Accept: 'application/json', + }, + body: JSON.stringify(payload), + }; + + try { + const response: Response = await fetchFn(resolvedUrl, requestInit); + + if (!response?.ok) { + const errorText = await response.text(); + + throw new AsgardeoAPIError( + `Failed to create user: ${errorText}`, + 'createUser-ResponseError-001', + 'javascript', + response.status, + response.statusText, + ); + } + + return (await response.json()) as User; + } catch (error) { + if (error instanceof AsgardeoAPIError) { + throw error; + } + + throw new AsgardeoAPIError( + error?.response?.data?.detail || 'An error occurred while creating the user. Please try again.', + 'createUser-NetworkError-001', + 'javascript', + error?.data?.status, + 'Network Error', + ); + } +}; + +export default createUser; diff --git a/packages/javascript/src/api/getUserstores.ts b/packages/javascript/src/api/getUserstores.ts new file mode 100644 index 00000000..5687e420 --- /dev/null +++ b/packages/javascript/src/api/getUserstores.ts @@ -0,0 +1,97 @@ +/** + * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import AsgardeoAPIError from '../errors/AsgardeoAPIError'; +import {Userstore, UserstoreProperty} from '../models/userstore'; + +export interface GetUserstoresConfig extends Omit { + url?: string; + baseUrl?: string; + fetcher?: (url: string, config: RequestInit) => Promise; + requiredAttributes?: string; +} + +/** + * Retrieves the userstores from the specified endpoint. + * @param config - Request configuration object. + * @returns A promise that resolves with the userstores information. + */ +const getUserstores = async ({ + url, + baseUrl, + fetcher, + requiredAttributes, + ...requestConfig +}: GetUserstoresConfig): Promise => { + try { + new URL(url ?? baseUrl); + } catch (error) { + throw new AsgardeoAPIError( + `Invalid URL provided. ${error?.toString()}`, + 'getUserstores-ValidationError-001', + 'javascript', + 400, + 'The provided `url` or `baseUrl` path does not adhere to the URL schema.', + ); + } + + const fetchFn = fetcher || fetch; + let resolvedUrl: string = url ?? `${baseUrl}/api/server/v1/userstores`; + if (requiredAttributes) { + const sep = resolvedUrl.includes('?') ? '&' : '?'; + resolvedUrl += `${sep}requiredAttributes=${encodeURIComponent(requiredAttributes)}`; + } + + const requestInit: RequestInit = { + ...requestConfig, + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + ...requestConfig.headers, + }, + }; + + try { + const response: Response = await fetchFn(resolvedUrl, requestInit); + if (!response?.ok) { + const errorText = await response.text(); + throw new AsgardeoAPIError( + errorText || 'Failed to fetch userstores.', + 'getUserstores-ResponseError-001', + 'javascript', + response.status, + response.statusText, + ); + } + return (await response.json()) as Userstore[]; + } catch (error) { + if (error instanceof AsgardeoAPIError) { + throw error; + } + throw new AsgardeoAPIError( + error?.response?.data?.detail || 'An error occurred while fetching userstores. Please try again.', + 'getUserstores-NetworkError-001', + 'javascript', + error?.data?.status, + 'Network Error', + ); + } +}; + +export default getUserstores; diff --git a/packages/javascript/src/i18n/en-US.ts b/packages/javascript/src/i18n/en-US.ts index f45af26f..f9aaa1cc 100644 --- a/packages/javascript/src/i18n/en-US.ts +++ b/packages/javascript/src/i18n/en-US.ts @@ -122,6 +122,15 @@ const translations: I18nTranslations = { 'organization.create.creating': 'Creating...', 'organization.create.cancel': 'Cancel', + /* |---------------------------------------------------------------| */ + /* | User Creation | */ + /* |---------------------------------------------------------------| */ + + 'user.create.cancel': 'Cancel', + 'user.create.submit': 'Create User', + 'user.create.submitting': 'Creating...', + 'user.create.generic.error': 'An error occurred while creating the user. Please try again.', + /* |---------------------------------------------------------------| */ /* | Messages | */ /* |---------------------------------------------------------------| */ diff --git a/packages/javascript/src/index.ts b/packages/javascript/src/index.ts index 813f10e2..b7d01e8f 100644 --- a/packages/javascript/src/index.ts +++ b/packages/javascript/src/index.ts @@ -38,6 +38,8 @@ export {default as getOrganization, OrganizationDetails, GetOrganizationConfig} export {default as updateOrganization, createPatchOperations, UpdateOrganizationConfig} from './api/updateOrganization'; export {default as updateMeProfile, UpdateMeProfileConfig} from './api/updateMeProfile'; export {default as getBrandingPreference, GetBrandingPreferenceConfig} from './api/getBrandingPreference'; +export {default as createUser, CreateUserConfig} from './api/createUser'; +export {default as getUserstores, GetUserstoresConfig} from './api/getUserstores'; export {default as ApplicationNativeAuthenticationConstants} from './constants/ApplicationNativeAuthenticationConstants'; export {default as TokenConstants} from './constants/TokenConstants'; @@ -63,6 +65,7 @@ export { EmbeddedSignInFlowAuthenticatorPromptType, EmbeddedSignInFlowAuthenticatorKnownIdPType, } from './models/embedded-signin-flow'; +export {UserstoreProperty, Userstore} from './models/userstore'; export { EmbeddedFlowType, EmbeddedFlowStatus, @@ -102,7 +105,7 @@ export { BrandingOrganizationDetails, UrlsConfig, } from './models/branding-preference'; -export {Schema, SchemaAttribute, WellKnownSchemaIds, FlattenedSchema} from './models/scim2-schema'; +export {Schema, SchemaAttribute, WellKnownSchemaIds, FlattenedSchema, ProfileSchemaType} from './models/scim2-schema'; export {RecursivePartial} from './models/utility-types'; export {FieldType} from './models/field'; export {I18nBundle, I18nTranslations, I18nMetadata} from './models/i18n'; @@ -113,10 +116,12 @@ export {default as createTheme} from './theme/createTheme'; export {ThemeColors, ThemeConfig, Theme, ThemeMode, ThemeDetection} from './theme/types'; export {default as bem} from './utils/bem'; +export {default as getAttributeProfileSchema} from './utils/getAttributeProfileSchema'; export {default as formatDate} from './utils/formatDate'; export {default as processUsername} from './utils/processUsername'; export {default as deepMerge} from './utils/deepMerge'; export {default as deriveOrganizationHandleFromBaseUrl} from './utils/deriveOrganizationHandleFromBaseUrl'; +export {default as detectUserstoreEnvironment} from './utils/detectUserstoreEnvironment'; export {default as extractUserClaimsFromIdToken} from './utils/extractUserClaimsFromIdToken'; export {default as extractPkceStorageKeyFromState} from './utils/extractPkceStorageKeyFromState'; export {default as flattenUserSchema} from './utils/flattenUserSchema'; diff --git a/packages/javascript/src/models/i18n.ts b/packages/javascript/src/models/i18n.ts index 39afb9c9..3c5b0105 100644 --- a/packages/javascript/src/models/i18n.ts +++ b/packages/javascript/src/models/i18n.ts @@ -124,6 +124,15 @@ export interface I18nTranslations { 'organization.create.creating': string; 'organization.create.cancel': string; + /* |---------------------------------------------------------------| */ + /* | User Creation | */ + /* |---------------------------------------------------------------| */ + + 'user.create.cancel': string; + 'user.create.submit': string; + 'user.create.submitting': string; + 'user.create.generic.error': string; + /* |---------------------------------------------------------------| */ /* | Messages | */ /* |---------------------------------------------------------------| */ diff --git a/packages/javascript/src/models/scim2-schema.ts b/packages/javascript/src/models/scim2-schema.ts index 0a6fe200..54447048 100644 --- a/packages/javascript/src/models/scim2-schema.ts +++ b/packages/javascript/src/models/scim2-schema.ts @@ -32,6 +32,12 @@ export interface SchemaAttribute { supportedByDefault?: string; sharedProfileValueResolvingMethod?: string; subAttributes?: SchemaAttribute[]; + profiles: { + [key: string]: { + required?: boolean; + supportedByDefault?: boolean; + }; + }; } /** @@ -67,3 +73,5 @@ export enum WellKnownSchemaIds { /** Custom User Schema */ CustomUser = 'urn:scim:schemas:extension:custom:User', } + +export type ProfileSchemaType = 'console' | 'endUser' | 'selfRegistration'; diff --git a/packages/javascript/src/models/userstore.ts b/packages/javascript/src/models/userstore.ts new file mode 100644 index 00000000..cae4af2d --- /dev/null +++ b/packages/javascript/src/models/userstore.ts @@ -0,0 +1,33 @@ +/** + * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export interface UserstoreProperty { + name: string; + value: string; +} + +export interface Userstore { + id: string; + name: string; + enabled: boolean; + description?: string; + isLocal: boolean; + self: string; + typeName: string; + properties?: UserstoreProperty[]; +} diff --git a/packages/javascript/src/utils/detectUserstoreEnvironment.ts b/packages/javascript/src/utils/detectUserstoreEnvironment.ts new file mode 100644 index 00000000..15f51ab0 --- /dev/null +++ b/packages/javascript/src/utils/detectUserstoreEnvironment.ts @@ -0,0 +1,65 @@ +/** + * Utility to detect environment (Asgardeo or IS) and filter userstores. + * + * Usage: + * const result = detectUserstoreEnvironment(userstores, primaryUserstore); + * + * Returns: + * { + * isAsgardeo: boolean, + * isIS: boolean, + * userstoresWritable: Userstore[], + * userstoresReadOnly: Userstore[], + * allUserstores: Userstore[], + * primaryUserstore?: Userstore + * } + */ + +import {Userstore} from '../models/userstore'; + +export interface DetectUserstoreEnvResult { + isAsgardeo: boolean; + isIS: boolean; + userstoresWritable: Userstore[]; + userstoresReadOnly: Userstore[]; + allUserstores: Userstore[]; + primaryUserstore?: Userstore; +} + +const detectUserstoreEnvironment = ( + userstores: Userstore[], + primaryUserstore?: Userstore, +): DetectUserstoreEnvResult => { + let isAsgardeo = false; + let isIS = false; + + if ( + Array.isArray(userstores) && + userstores.some(u => u.name === 'DEFAULT' && u.typeName === 'AsgardeoBusinessUserStoreManager') + ) { + isAsgardeo = true; + } else if (Array.isArray(userstores) && userstores.length === 0 && primaryUserstore) { + isIS = true; + } + + // Helper to check readonly property + const isReadOnly = (userstore: Userstore) => { + if (!userstore.properties) return false; + const prop = userstore.properties.find(p => p.name === 'ReadOnly'); + return prop?.value === 'true'; + }; + + const userstoresReadOnly = userstores.filter(isReadOnly); + const userstoresWritable = userstores.filter(u => !isReadOnly(u)); + + return { + isAsgardeo, + isIS, + userstoresWritable, + userstoresReadOnly, + allUserstores: userstores, + primaryUserstore: isIS ? primaryUserstore : undefined, + }; +}; + +export default detectUserstoreEnvironment; diff --git a/packages/javascript/src/utils/getAttributeProfileSchema.ts b/packages/javascript/src/utils/getAttributeProfileSchema.ts new file mode 100644 index 00000000..bce862a0 --- /dev/null +++ b/packages/javascript/src/utils/getAttributeProfileSchema.ts @@ -0,0 +1,63 @@ +/** + * Filters and processes SCIM schemas based on profile and supported/required rules. + * + * - If `supportedByDefault` is true and `required` is true, keep the attribute. + * - If `supportedByDefault` is false and `required` is true and there's a `profiles` object, + * check if the passed in profile is among the keys and its `supportedByDefault` isn't false. + * If false, don't return. + * + * Recursively processes subAttributes as well. + * + * @param schemas - Array of SCIM schemas (with attributes) + * @param profile - Profile type: 'console', 'endUser', or 'selfRegistration' + * @returns Filtered schemas with only the attributes that match the rules + */ + +import {ProfileSchemaType, Schema, SchemaAttribute} from '../models/scim2-schema'; + +const isTrue = (val: any): boolean => val === true || val === 'true'; + +const filterAttributes = (attributes: SchemaAttribute[], profile: ProfileSchemaType): SchemaAttribute[] => + attributes + ?.map(attr => { + let keep = false; + const supported = isTrue(attr.supportedByDefault); + // required can be at top level or inside profiles[profile] + const profileConfig = attr.profiles ? attr.profiles[profile] : undefined; + const required = typeof profileConfig?.required === 'boolean' ? profileConfig.required : !!attr.required; + + if (supported && required) { + keep = true; + } else if (!supported && required && profileConfig) { + if (isTrue(profileConfig.supportedByDefault) && isTrue(profileConfig.required)) { + keep = true; + } + } + + let subAttributes: SchemaAttribute[] | undefined = undefined; + + if (Array.isArray(attr.subAttributes) && attr.subAttributes.length > 0) { + subAttributes = filterAttributes(attr.subAttributes, profile); + } + + if (keep || (subAttributes && subAttributes.length > 0)) { + return { + ...attr, + subAttributes, + }; + } + + return null; + }) + .filter(Boolean) as SchemaAttribute[]; + +/** + * Accepts a flat array of attributes and filters them by profile rules. + * If you pass a SCIM schema object array, extract the attributes first. + */ +const getAttributeProfileSchema = (attributes: SchemaAttribute[], profile: ProfileSchemaType): SchemaAttribute[] => { + if (!Array.isArray(attributes)) return []; + return filterAttributes(attributes, profile); +}; + +export default getAttributeProfileSchema; diff --git a/packages/nextjs/src/client/components/presentation/CreateOrganization/CreateOrganization.tsx b/packages/nextjs/src/client/components/presentation/CreateOrganization/CreateOrganization.tsx index a2d24afe..282608df 100644 --- a/packages/nextjs/src/client/components/presentation/CreateOrganization/CreateOrganization.tsx +++ b/packages/nextjs/src/client/components/presentation/CreateOrganization/CreateOrganization.tsx @@ -35,7 +35,7 @@ export interface CreateOrganizationProps extends Omit Promise; + onCreate?: (payload: CreateOrganizationPayload) => Promise; } /** @@ -54,7 +54,7 @@ export interface CreateOrganizationProps extends Omit { + * onCreate={async (payload) => { * const result = await myCustomAPI.createOrganization(payload); * return result; * }} @@ -71,7 +71,7 @@ export interface CreateOrganizationProps extends Omit = ({ - onCreateOrganization, + onCreate, fallback = <>, onSuccess, defaultParentId, @@ -101,8 +101,8 @@ export const CreateOrganization: FC = ({ try { let result: any; - if (onCreateOrganization) { - result = await onCreateOrganization(payload); + if (onCreate) { + result = await onCreate(payload); } else { if (!baseUrl) { throw new Error('Base URL is required for organization creation'); diff --git a/packages/react/docs/components/asgardeo-provider.md b/packages/react/docs/components/asgardeo-provider.md index a4000622..70e14a68 100644 --- a/packages/react/docs/components/asgardeo-provider.md +++ b/packages/react/docs/components/asgardeo-provider.md @@ -8,68 +8,60 @@ The `AsgardeoProvider` initializes the Asgardeo authentication client, manages a ## Props -All props are based on the `AsgardeoReactConfig` interface, which extends the base configuration from `@asgardeo/javascript`. +The `AsgardeoProvider` component accepts the following props: -### Required Props +| Prop | Type | Required | Description | +|------------------|----------|----------|-------------| +| `clientID` | `string` | ✅ | Client ID of your application | +| `baseUrl` | `string` | ✅ | The base URL of the Asgardeo tenant (e.g., `https://api.asgardeo.io/t/abc-org`) | +| `signInRedirectURL` | `string` | ❌ | URL to redirect to after login | +| `signOutRedirectURL` | `string` | ❌ | URL to redirect to after logout | +| `scope` | `string[]` | ❌ | Requested scopes (defaults to `['openid']`) | +| `responseMode` | `'query' \| 'form_post'` | ❌ | Response mode for OIDC requests | +| `onSignIn` | `(state) => void` | ❌ | Callback after successful login | +| `onSignOut` | `() => void` | ❌ | Callback after logout | +| `tokenValidation`| `TokenValidation` | ❌ | Configuration for token validation | +| `preferences` | `Preferences` | ❌ | Customization options for UI behavior and styling | -| Prop | Type | Default | Description | -|------|------|---------|-------------| -| `baseUrl` | `string` | **REQUIRED** | The base URL of your Asgardeo organization. Format: `https://api.asgardeo.io/t/{org_name}` | -| `clientId` | `string` | **REQUIRED** | The client ID obtained from your Asgardeo application registration | -| `afterSignInUrl` | `string` | `window.location.origin` | URL to redirect users after successful sign-in. Must match configured redirect URIs in Asgardeo | -| `afterSignOutUrl` | `string` | `window.location.origin` | URL to redirect users after sign-out. Must match configured post-logout redirect URIs | -| `scopes` | `string \| string[]` | `openid profile internal_login` | OAuth scopes to request during authentication (e.g., `"openid profile email"` or `["openid", "profile", "email"]`) | -| `organizationHandle` | `string` | - | Organization handle for organization-specific features like branding. Auto-derived from `baseUrl` if not provided. Required for custom domains | -| `applicationId` | `string` | - | UUID of the Asgardeo application for application-specific branding and features | -| `signInUrl` | `string` | - | Custom sign-in page URL. If provided, users will be redirected here instead of Asgardeo's default sign-in page | -| `signUpUrl` | `string` | - | Custom sign-up page URL. If provided, users will be redirected here instead of Asgardeo's default sign-up page | -| `clientSecret` | `string` | - | Client secret for confidential clients. Not recommended for browser applications | -| `tokenValidation` | [TokenValidation](#tokenvalidation) | - | Token validation configuration for ID tokens including validation flags and clock tolerance | -| `preferences` | [Preferences](#preferences) | - | Configuration object for theming, internationalization, and UI customization | +--- -
+??? info "TokenValidation" -

TokenValidation

+ The `tokenValidation` prop allows you to configure how ID tokens are validated. -The `tokenValidation` prop allows you to configure how ID tokens are validated. + | Property | Type | Default | Description | + |----------|------|---------|-------------| + | `idToken` | `IdTokenValidation` | `{}` | Configuration for ID token validation | -| Property | Type | Default | Description | -|----------|------|---------|-------------| -| `idToken` | `IdTokenValidation` | `{}` | Configuration for ID token validation | + #### IdTokenValidation -#### IdTokenValidation + | Property | Type | Default | Description | + |----------|------|---------|-------------| + | `validate` | `boolean` | `true` | Whether to validate the ID token | + | `validateIssuer` | `boolean` | `true` | Whether to validate the issuer | + | `clockTolerance` | `number` | `300` | Allowed clock skew in seconds | -| Property | Type | Default | Description | -|----------|------|---------|-------------| -| `validate` | `boolean` | `true` | Whether to validate the ID token | -| `validateIssuer` | `boolean` | `true` | Whether to validate the issuer | -| `clockTolerance` | `number` | `300` | Allowed clock skew in seconds | +--- -
+??? info "Preferences" -
+ The `preferences` prop allows you to customize the UI components provided by the SDK. -

Preferences

+ #### Theme Preferences (`preferences.theme`) -The `preferences` prop allows you to customize the UI components provided by the SDK. + | Property | Type | Default | Description | + |----------|------|---------|-------------| + | `inheritFromBranding` | `boolean` | `true` | Whether to inherit theme from Asgardeo organization/application branding | + | `mode` | `'light' \| 'dark' \| 'system'` | `'system'` | Theme mode. `'system'` follows user's OS preference | + | `overrides` | `ThemeConfig` | `{}` | Custom theme overrides for colors, typography, spacing, etc. | -#### Theme Preferences (`preferences.theme`) + #### Internationalization Preferences (`preferences.i18n`) -| Property | Type | Default | Description | -|----------|------|---------|-------------| -| `inheritFromBranding` | `boolean` | `true` | Whether to inherit theme from Asgardeo organization/application branding | -| `mode` | `'light' \| 'dark' \| 'system'` | `'system'` | Theme mode. `'system'` follows user's OS preference | -| `overrides` | `ThemeConfig` | `{}` | Custom theme overrides for colors, typography, spacing, etc. | - -#### Internationalization Preferences (`preferences.i18n`) - -| Property | Type | Default | Description | -|----------|------|---------|-------------| -| `language` | `string` | Browser default | Language code for UI text (e.g., `'en-US'`, `'es-ES'`) | -| `fallbackLanguage` | `string` | `'en-US'` | Fallback language when translations aren't available | -| `bundles` | `object` | `{}` | Custom translation bundles to override default text | - -
+ | Property | Type | Default | Description | + |----------|------|---------|-------------| + | `language` | `string` | Browser default | Language code for UI text (e.g., `'en-US'`, `'es-ES'`) | + | `fallbackLanguage` | `string` | `'en-US'` | Fallback language when translations aren't available | + | `bundles` | `object` | `{}` | Custom translation bundles to override default text | ## Usage diff --git a/packages/react/src/api/createUser.ts b/packages/react/src/api/createUser.ts new file mode 100644 index 00000000..baa8f669 --- /dev/null +++ b/packages/react/src/api/createUser.ts @@ -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 { + User, + HttpInstance, + AsgardeoSPAClient, + HttpRequestConfig, + createUser as baseCreateUser, + CreateUserConfig as BaseCreateUserConfig, +} from '@asgardeo/browser'; + +const httpClient: HttpInstance = AsgardeoSPAClient.getInstance().httpRequest.bind(AsgardeoSPAClient.getInstance()); + +/** + * Configuration for the createUser request (React-specific) + */ +export interface CreateUserConfig extends Omit { + /** + * Optional custom fetcher function. If not provided, the Asgardeo SPA client's httpClient will be used + * which is a wrapper around axios http.request + */ + fetcher?: (url: string, config: RequestInit) => Promise; +} + +/** + * Creates a new user. + * This function uses the Asgardeo SPA client's httpClient by default, but allows for custom fetchers. + * + * @param config - Configuration object containing baseUrl, payload and optional request config. + * @returns A promise that resolves with the created user information. + * @example + * ```typescript + * // Using default Asgardeo SPA client httpClient + * try { + * const user = await createUser({ + * baseUrl: "https://api.asgardeo.io/t/", + * payload: { ... } + * }); + * console.log(user); + * } catch (error) { + * if (error instanceof AsgardeoAPIError) { + * console.error('Failed to create user:', error.message); + * } + * } + * ``` + * + * @example + * ```typescript + * // Using custom fetcher + * try { + * const user = await createUser({ + * baseUrl: "https://api.asgardeo.io/t/", + * payload: { ... }, + * fetcher: customFetchFunction + * }); + * console.log(user); + * } catch (error) { + * if (error instanceof AsgardeoAPIError) { + * console.error('Failed to create user:', error.message); + * } + * } + * ``` + */ +const createUser = async ({fetcher, ...requestConfig}: CreateUserConfig): Promise => { + const defaultFetcher = async (url: string, config: RequestInit): Promise => { + const response = await httpClient({ + url, + method: config.method || 'POST', + headers: config.headers as Record, + data: config.body ? JSON.parse(config.body as string) : undefined, + } as HttpRequestConfig); + + return { + ok: response.status >= 200 && response.status < 300, + status: response.status, + statusText: response.statusText || '', + json: () => Promise.resolve(response.data), + text: () => Promise.resolve(typeof response.data === 'string' ? response.data : JSON.stringify(response.data)), + } as Response; + }; + + return baseCreateUser({ + ...requestConfig, + fetcher: fetcher || defaultFetcher, + }); +}; + +export default createUser; diff --git a/packages/react/src/api/getUserstores.ts b/packages/react/src/api/getUserstores.ts new file mode 100644 index 00000000..5cc4b200 --- /dev/null +++ b/packages/react/src/api/getUserstores.ts @@ -0,0 +1,60 @@ +/** + * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { + Userstore, + getUserstores as baseGetUserstores, + GetUserstoresConfig as BaseGetUserstoresConfig, +} from '@asgardeo/browser'; +import {HttpInstance, AsgardeoSPAClient, HttpRequestConfig} from '@asgardeo/browser'; + +const httpClient: HttpInstance = AsgardeoSPAClient.getInstance().httpRequest.bind(AsgardeoSPAClient.getInstance()); + +export interface GetUserstoresConfig extends Omit { + fetcher?: (url: string, config: RequestInit) => Promise; +} + +/** + * Retrieves the userstores from the specified endpoint. + * This function uses the Asgardeo SPA client's httpClient by default, but allows for custom fetchers. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the userstores information. + */ +const getUserstores = async ({fetcher, ...requestConfig}: GetUserstoresConfig): Promise => { + const defaultFetcher = async (url: string, config: RequestInit): Promise => { + const response = await httpClient({ + url, + method: config.method || 'GET', + headers: config.headers as Record, + } as HttpRequestConfig); + return { + ok: response.status >= 200 && response.status < 300, + status: response.status, + statusText: response.statusText || '', + json: () => Promise.resolve(response.data), + text: () => Promise.resolve(typeof response.data === 'string' ? response.data : JSON.stringify(response.data)), + } as Response; + }; + return baseGetUserstores({ + ...requestConfig, + fetcher: fetcher || defaultFetcher, + }); +}; + +export default getUserstores; diff --git a/packages/react/src/components/presentation/CreateOrganization/CreateOrganization.tsx b/packages/react/src/components/presentation/CreateOrganization/CreateOrganization.tsx index 7b88c55a..c79c0bd1 100644 --- a/packages/react/src/components/presentation/CreateOrganization/CreateOrganization.tsx +++ b/packages/react/src/components/presentation/CreateOrganization/CreateOrganization.tsx @@ -34,7 +34,7 @@ export interface CreateOrganizationProps extends Omit Promise; + onCreate?: (payload: CreateOrganizationPayload) => Promise; } /** @@ -53,7 +53,7 @@ export interface CreateOrganizationProps extends Omit { + * onCreate={async (payload) => { * const result = await myCustomAPI.createOrganization(payload); * return result; * }} @@ -70,7 +70,7 @@ export interface CreateOrganizationProps extends Omit = ({ - onCreateOrganization, + onCreate, fallback = null, onSuccess, defaultParentId, @@ -99,8 +99,8 @@ export const CreateOrganization: FC = ({ try { let result: any; - if (onCreateOrganization) { - result = await onCreateOrganization(payload); + if (onCreate) { + result = await onCreate(payload); } else { if (!baseUrl) { throw new Error('Base URL is required for organization creation'); diff --git a/packages/react/src/components/presentation/CreateUser/BaseCreateUser.tsx b/packages/react/src/components/presentation/CreateUser/BaseCreateUser.tsx new file mode 100644 index 00000000..4c750cca --- /dev/null +++ b/packages/react/src/components/presentation/CreateUser/BaseCreateUser.tsx @@ -0,0 +1,348 @@ +/** + * 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 {cx} from '@emotion/css'; +import {CSSProperties, FC, ReactElement, ReactNode, useState} from 'react'; +import {useForm, FormField} from '../../../hooks/useForm'; +import useTheme from '../../../contexts/Theme/useTheme'; +import useTranslation from '../../../hooks/useTranslation'; +import useStyles from './CreateUser.styles'; +import Alert from '../../primitives/Alert/Alert'; +import Button from '../../primitives/Button/Button'; +import Dialog from '../../primitives/Dialog/Dialog'; +import FormControl from '../../primitives/FormControl/FormControl'; +import InputLabel from '../../primitives/InputLabel/InputLabel'; +import TextField from '../../primitives/TextField/TextField'; +import DatePicker from '../../primitives/DatePicker/DatePicker'; +import Checkbox from '../../primitives/Checkbox/Checkbox'; +import Select from '../../primitives/Select/Select'; + +export interface BaseCreateUserProps { + cardLayout?: boolean; + className?: string; + error?: string | null; + loading?: boolean; + mode?: 'inline' | 'popup'; + onCancel?: () => void; + onCreate: (payload: any) => Promise; + onPopupClose?: (open: boolean) => void; + onSuccess?: (user: any) => void; + popupOpen?: boolean; + renderAdditionalFields?: () => ReactNode; + schemas: any[]; + style?: CSSProperties; + title?: string; + userstores?: any; +} + +const BaseCreateUser: FC = ({ + cardLayout = true, + className = '', + error = null, + loading = false, + mode = 'inline', + onCancel, + onCreate, + onPopupClose, + onSuccess, + popupOpen = false, + renderAdditionalFields, + schemas, + style, + title = 'Create User', + userstores = [], +}): ReactElement => { + const {theme, colorScheme} = useTheme(); + const {t} = useTranslation(); + const styles = useStyles(theme, colorScheme); + + // Build form fields for useForm (flat attribute array) + const formFields: FormField[] = []; + // Add userstore dropdown field + formFields.push({ + name: 'userstore', + required: true, + initialValue: userstores.length > 0 ? userstores[0].id : '', + validator: value => { + if (!value) return t('field.required'); + return null; + }, + }); + schemas?.forEach(attr => { + if (attr.subAttributes && Array.isArray(attr.subAttributes)) { + attr.subAttributes.forEach(subAttr => { + formFields.push({ + name: `${attr.name}.${subAttr.name}`, + required: !!subAttr.required, + initialValue: '', + validator: value => { + if (subAttr.required && (!value || value.trim() === '')) { + return t('field.required'); + } + return null; + }, + }); + }); + } else { + formFields.push({ + name: attr.name, + required: !!attr.required, + initialValue: '', + validator: value => { + if (attr.required && (!value || value.trim() === '')) { + return t('field.required'); + } + return null; + }, + }); + } + }); + + const form = useForm>({ + initialValues: {}, + fields: formFields, + validateOnBlur: true, + validateOnChange: true, + requiredMessage: t('field.required'), + }); + + const { + values: formState, + errors, + touched, + isValid: isFormValid, + setValue, + setTouched, + validateForm, + touchAllFields, + reset: resetForm, + } = form; + + const handleChange = (name: string, value: any) => { + setValue(name, value); + setTouched(name, true); + }; + + const handleSubmit = async (e: React.FormEvent): Promise => { + e.preventDefault(); + touchAllFields(); + const validation = validateForm(); + if (!validation.isValid || loading) return; + + try { + await onCreate(formState); + + if (onSuccess) { + onSuccess(formState); + } + + resetForm(); + } catch (submitError) { + // Error handling is done by parent component + console.error('Form submission error:', submitError); + } + }; + + // Helper to get placeholder + const getFieldPlaceholder = (schema: any): string => { + const {type, displayName, description, name} = schema; + const fieldLabel = displayName || description || name || 'value'; + switch (type) { + case 'DATE_TIME': + return `Enter your ${fieldLabel.toLowerCase()}`; + case 'BOOLEAN': + return `Select ${fieldLabel.toLowerCase()}`; + case 'COMPLEX': + return `Enter ${fieldLabel.toLowerCase()} details`; + default: + return `Enter your ${fieldLabel.toLowerCase()}`; + } + }; + + // Render a single field based on type + const renderField = (fieldName: string, schema: any) => { + const {type, required} = schema; + const value = formState[fieldName] || ''; + const errorMsg = touched[fieldName] && errors[fieldName] ? errors[fieldName] : undefined; + + switch (type) { + case 'STRING': + return ( + handleChange(fieldName, e.target.value)} + required={!!required} + error={errorMsg} + placeholder={getFieldPlaceholder(schema)} + disabled={loading} + /> + ); + case 'DATE_TIME': + return ( + handleChange(fieldName, e.target.value)} + required={!!required} + error={errorMsg} + placeholder={getFieldPlaceholder(schema)} + disabled={loading} + /> + ); + case 'BOOLEAN': + return ( + handleChange(fieldName, e.target.checked)} + required={!!required} + label={getFieldPlaceholder(schema)} + error={errorMsg} + disabled={loading} + /> + ); + case 'COMPLEX': + return ( + +
- {formatLabel(key)}: - +
{formatLabel(key)}: {typeof value === 'object' ? : String(value)}
{formatLabel(key)}: + {formatLabel(key)}: + {typeof value === 'object' ? : String(value)}