diff --git a/packages/nextjs/src/client/contexts/ThunderID/ThunderIDProvider.tsx b/packages/nextjs/src/client/contexts/ThunderID/ThunderIDProvider.tsx index 3de9987..b3fc43c 100644 --- a/packages/nextjs/src/client/contexts/ThunderID/ThunderIDProvider.tsx +++ b/packages/nextjs/src/client/contexts/ThunderID/ThunderIDProvider.tsx @@ -374,6 +374,7 @@ const ThunderIDClientProvider: FC Promise; + instanceId?: number; + url?: string; +} + +export interface UsersMeMetaResponse { + schema?: Record; +} + +const getUsersMeMeta = async ({ + baseUrl, + fetcher, + instanceId = 0, + url, +}: GetUsersMeMetaConfig): Promise => { + const targetUrl = url ?? `${baseUrl?.replace(/\/$/, '')}/users/me/meta`; + + const defaultFetcher = async (endpointUrl: string, config: RequestInit): Promise => { + const httpClient: FetchHttpClient = FetchHttpClient.getInstance(instanceId); + const response: HttpResponse = await httpClient.request({ + headers: config.headers as Record, + method: config.method ?? 'GET', + url: endpointUrl, + } as HttpRequestConfig); + + return { + json: () => Promise.resolve(response.data), + ok: response.status >= 200 && response.status < 300, + status: response.status, + statusText: response.statusText ?? '', + text: () => Promise.resolve(typeof response.data === 'string' ? response.data : JSON.stringify(response.data)), + } as Response; + }; + + const activeFetcher = fetcher ?? defaultFetcher; + const res = await activeFetcher(targetUrl, {method: 'GET'}); + + if (!res.ok) { + throw new Error(`Failed to fetch user schema metadata: ${res.statusText}`); + } + + return res.json() as Promise; +}; + +export default getUsersMeMeta; diff --git a/packages/react/src/components/presentation/UserProfile/BaseUserProfile.tsx b/packages/react/src/components/presentation/UserProfile/BaseUserProfile.tsx index 3a18cdd..d056d82 100644 --- a/packages/react/src/components/presentation/UserProfile/BaseUserProfile.tsx +++ b/packages/react/src/components/presentation/UserProfile/BaseUserProfile.tsx @@ -49,6 +49,7 @@ interface Schema extends ExtendedFlatSchema { multiValued?: boolean; mutability?: string; name?: string; + regex?: string; required?: boolean; returned?: string; subAttributes?: Schema[]; @@ -88,6 +89,7 @@ export interface BaseUserProfileProps { showFields?: string[]; title?: string; + userSchema?: Record | null; } // Fields to skip based on schema.name @@ -117,7 +119,7 @@ const fieldsToSkip: string[] = [ ]; // Fields that should be readonly -const readonlyFields: string[] = ['attributes', 'id', 'isReadOnly', 'ouId', 'username']; +const readonlyFields: string[] = ['attributes', 'id', 'isReadOnly', 'ouId', 'username', 'sub']; const BaseUserProfile: FC = ({ fallback = null, @@ -125,6 +127,7 @@ const BaseUserProfile: FC = ({ cardLayout = true, profile, flattenedProfile, + userSchema, mode = 'inline', title, attributeMapping = {}, @@ -142,6 +145,7 @@ const BaseUserProfile: FC = ({ const {theme, colorScheme} = useTheme(); const [editedUser, setEditedUser] = useState(flattenedProfile || profile); const [editingFields, setEditingFields] = useState>({}); + const [fieldErrors, setFieldErrors] = useState>({}); const {t} = useTranslation(preferences?.i18n); useEffect(() => { @@ -162,17 +166,13 @@ const BaseUserProfile: FC = ({ }, [flattenedProfile, profile, editingFields]); /** - * Determines if a field should be visible based on showFields, hideFields, and fieldsToSkip arrays. - * Priority order: - * 1. fieldsToSkip (always hidden) - highest priority - * 2. hideFields (explicitly hidden) - * 3. showFields (explicitly shown, if array is not empty) - * 4. Default behavior (show all fields not in fieldsToSkip) + * Determines if a field should be visible based on showFields, hideFields, and fallback fieldsToSkip arrays. + * When isSchemaBased is true, fieldsToSkip is bypassed so schema-defined fields render dynamically. */ - const shouldShowField: any = useCallback( - (fieldName: string): boolean => { - // Always skip fields in the hardcoded fieldsToSkip array - if (fieldsToSkip.includes(fieldName)) { + const shouldShowField = useCallback( + (fieldName: string, isSchemaBased: boolean = false): boolean => { + // For fallback without schema metadata, skip internal system fields + if (!isSchemaBased && fieldsToSkip.includes(fieldName)) { return false; } @@ -213,22 +213,21 @@ const BaseUserProfile: FC = ({ })); }, []); - const getFieldPlaceholder: any = useCallback((schema: Schema): string => { - const {type, displayName, description, name} = schema; + const getFieldPlaceholder: any = useCallback( + (schema: Schema): string => { + const {type, displayName, description, name} = schema; - const fieldLabel: any = displayName || description || name || 'value'; + const fieldLabel: any = 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()}`; - } - }, []); + switch (type) { + case 'DATE_TIME': + case 'STRING': + default: + return t('elements.fields.generic.placeholder', {field: fieldLabel.toLowerCase()}); + } + }, + [t], + ); const formatLabel: any = useCallback( (key: string): string => @@ -285,9 +284,12 @@ const BaseUserProfile: FC = ({ if (!onUpdate || !schema.name) return; const fieldName: string = schema.name; + const currentUser: any = flattenedProfile || profile; let fieldValue: any; if (editedUser && fieldName && editedUser[fieldName] !== undefined) { fieldValue = editedUser[fieldName]; + } else if (currentUser?.attributes?.[fieldName] !== undefined) { + fieldValue = currentUser.attributes[fieldName]; } else if (flattenedProfile?.[fieldName] !== undefined) { fieldValue = flattenedProfile[fieldName]; } else { @@ -298,6 +300,41 @@ const BaseUserProfile: FC = ({ fieldValue = fieldValue.filter((v: any) => v !== undefined && v !== null && v !== ''); } + const strVal = String(fieldValue ?? '').trim(); + const fieldLabel = schema.displayName || (schema.name ? startCase(schema.name) : 'Field'); + + // 1. Required validation + if (schema.required && !strVal) { + setFieldErrors((prev: Record) => ({ + ...prev, + [fieldName]: t('validations.required.field.error'), + })); + return; + } + + // 2. Regex validation + if (schema.regex && strVal) { + try { + const reg = new RegExp(schema.regex); + if (!reg.test(strVal)) { + setFieldErrors((prev: Record) => ({ + ...prev, + [fieldName]: t('validation.pattern.invalid'), + })); + return; + } + } catch (e) { + // ignore invalid regex syntax safely + } + } + + // Clear field error if valid + setFieldErrors((prev: Record) => { + const next = {...prev}; + delete next[fieldName]; + return next; + }); + let payload: Record = {}; set(payload, fieldName, fieldValue); @@ -305,16 +342,22 @@ const BaseUserProfile: FC = ({ toggleFieldEdit(fieldName); }, - [editedUser, flattenedProfile, onUpdate, toggleFieldEdit], + [editedUser, flattenedProfile, profile, onUpdate, toggleFieldEdit, t], ); const handleFieldCancel: any = useCallback( (fieldName: string) => { const currentUser: any = flattenedProfile || profile; + const initialVal = currentUser?.attributes?.[fieldName] ?? currentUser?.[fieldName]; setEditedUser((prev: any) => ({ ...prev, - [fieldName]: currentUser[fieldName], + [fieldName]: initialVal, })); + setFieldErrors((prev: Record) => { + const next = {...prev}; + delete next[fieldName]; + return next; + }); toggleFieldEdit(fieldName); }, [flattenedProfile, profile, toggleFieldEdit], @@ -579,6 +622,11 @@ const BaseUserProfile: FC = ({ }, () => toggleFieldEdit(schema.name), )} + {fieldErrors[schema.name] && ( +
+ {fieldErrors[schema.name]} +
+ )} {editable && schema.mutability !== 'READ_ONLY' && !isReadonlyField && (
@@ -628,18 +676,54 @@ const BaseUserProfile: FC = ({ const currentUser: any = flattenedProfile || profile; - const renderProfileWithoutSchemas = (): any => { + const renderProfileContent = (): any => { if (!currentUser) return null; const displayName: any = getDisplayName(mergedMappings, profile!, displayNameAttributes); - const profileEntries: any = Object.entries(currentUser) - .filter(([key, value]: [string, any]) => { - if (!shouldShowField(key)) return false; - - return value !== undefined && value !== '' && value !== null; - }) - .sort(([a]: [string, ...any[]], [b]: [string, ...any[]]) => a.localeCompare(b)); + let schemaItems: Schema[] = []; + + if (userSchema && typeof userSchema === 'object' && Object.keys(userSchema).length > 0) { + schemaItems = Object.entries(userSchema) + .filter(([key, metaAttr]: [string, any]) => { + if (metaAttr?.credential) return false; + return shouldShowField(key, true); + }) + .map(([key, metaAttr]: [string, any]) => { + const val = editedUser?.[key] ?? currentUser?.attributes?.[key] ?? currentUser?.[key] ?? ''; + + const isReadonly = + metaAttr.readOnly === true || metaAttr.mutability === 'READ_ONLY' || readonlyFields.includes(key); + + return { + name: key, + displayName: metaAttr.displayName || (key ? startCase(key) : ''), + type: (metaAttr.type || 'STRING').toUpperCase(), + regex: metaAttr.regex, + required: !!metaAttr.required, + mutability: isReadonly ? 'READ_ONLY' : 'READ_WRITE', + value: val, + }; + }); + } else { + const profileEntries: any = Object.entries(currentUser) + .filter(([key, value]: [string, any]) => { + if (!shouldShowField(key)) return false; + + return value !== undefined && value !== '' && value !== null; + }) + .sort(([a]: [string, ...any[]], [b]: [string, ...any[]]) => a.localeCompare(b)); + + schemaItems = profileEntries.map(([key, value]: any) => { + const isReadonly = readonlyFields.includes(key); + return { + name: key, + displayName: startCase(key), + mutability: isReadonly ? 'READ_ONLY' : 'READ_WRITE', + value, + }; + }); + } return ( <> @@ -661,17 +745,11 @@ const BaseUserProfile: FC = ({ )}
- {profileEntries.map(([key, value]: any) => { - const isReadonly = readonlyFields.includes(key); - const schema: Schema = {name: key, mutability: isReadonly ? 'READ_ONLY' : 'READ_WRITE'}; - const schemaWithValue: any = {...schema, value}; - - return ( -
- {renderUserInfo(schemaWithValue)} -
- ); - })} + {schemaItems.map((schemaWithValue: Schema) => ( +
+ {renderUserInfo(schemaWithValue)} +
+ ))} ); }; @@ -687,7 +765,7 @@ const BaseUserProfile: FC = ({ {error} )} -
{renderProfileWithoutSchemas()}
+
{renderProfileContent()}
); diff --git a/packages/react/src/components/presentation/UserProfile/UserProfile.tsx b/packages/react/src/components/presentation/UserProfile/UserProfile.tsx index 020dc18..774e84e 100644 --- a/packages/react/src/components/presentation/UserProfile/UserProfile.tsx +++ b/packages/react/src/components/presentation/UserProfile/UserProfile.tsx @@ -66,7 +66,7 @@ export type UserProfileProps = Omit = ({preferences, editable = true, ...rest}: UserProfileProps): ReactElement => { const {baseUrl, instanceId, preferences: contextPreferences} = useThunderID(); - const {profile, flattenedProfile, onUpdateProfile} = useUser(); + const {profile, flattenedProfile, onUpdateProfile, userSchema} = useUser(); const resolvedPreferences = { ...contextPreferences, ...preferences, @@ -112,6 +112,7 @@ const UserProfile: FC = ({preferences, editable = true, ...res | null; + /** * Vendor/brand namespace used to prefix storage keys, cookie names, and CSS class names. * Resolved from the `vendor` config option, defaulting to `'thunderid'`. @@ -263,6 +268,7 @@ const ThunderIDContext: Context = createContext Promise.resolve({} as any), signUpUrl: undefined, user: null, + userSchema: null, vendor: VendorConstants.VENDOR_PREFIX, }); diff --git a/packages/react/src/contexts/ThunderID/ThunderIDProvider.tsx b/packages/react/src/contexts/ThunderID/ThunderIDProvider.tsx index 2d02e93..d93a0ea 100644 --- a/packages/react/src/contexts/ThunderID/ThunderIDProvider.tsx +++ b/packages/react/src/contexts/ThunderID/ThunderIDProvider.tsx @@ -42,6 +42,7 @@ import I18nProvider from '../I18n/I18nProvider'; import ThemeProvider from '../Theme/ThemeProvider'; import UserProvider from '../User/UserProvider'; import getUsersMe from '../../api/getUsersMe'; +import getUsersMeMeta from '../../api/getUsersMeMeta'; const logger: ReturnType = createPackageComponentLogger( '@thunderid/react', @@ -83,6 +84,7 @@ const ThunderIDProvider: FC> = ({ const [isLoadingSync, setIsLoadingSync] = useState(true); const [userProfile, setUserProfile] = useState(null); + const [userSchema, setUserSchema] = useState | null>(null); const [baseUrl, setBaseUrl] = useState(initialBaseUrl ?? ''); const [config, setConfig] = useState({ afterSignInUrl: afterSignInUrl ?? window.location.origin, @@ -153,6 +155,20 @@ const ThunderIDProvider: FC> = ({ } catch (err) { logger.warn('Failed to fetch user profile from /users/me:', err); } + + try { + const metaRes = await getUsersMeMeta({baseUrl: resolvedBaseUrl, instanceId}); + if (metaRes?.schema) { + setUserSchema(metaRes.schema); + } else { + setUserSchema(null); + } + } catch (err) { + setUserSchema(null); + logger.warn('Failed to fetch user schema metadata from /users/me/meta:', err); + } + } else { + setUserSchema(null); } setUser(profileData); @@ -507,6 +523,7 @@ const ThunderIDProvider: FC> = ({ signUpUrl, syncSession, user, + userSchema, vendor: getVendorPrefix(config.vendor), }), [ @@ -527,6 +544,7 @@ const ThunderIDProvider: FC> = ({ signIn, signInSilently, user, + userSchema, client, signInOptions, tokenRequest, @@ -559,7 +577,11 @@ const ThunderIDProvider: FC> = ({ }} > - + {children} diff --git a/packages/react/src/contexts/User/UserContext.ts b/packages/react/src/contexts/User/UserContext.ts index ee0b60d..f18ddd8 100644 --- a/packages/react/src/contexts/User/UserContext.ts +++ b/packages/react/src/contexts/User/UserContext.ts @@ -31,6 +31,7 @@ export interface UserContextProps { requestConfig: UpdateMeProfileConfig, sessionId?: string, ) => Promise<{data: {user: User}; error: string; success: boolean}>; + userSchema?: Record | null; } /** @@ -42,6 +43,7 @@ const UserContext: Context = createContext null as unknown as Promise, updateProfile: () => null as unknown as Promise<{data: {user: User}; error: string; success: boolean}>, + userSchema: null, }); UserContext.displayName = 'UserContext'; diff --git a/packages/react/src/contexts/User/UserProvider.tsx b/packages/react/src/contexts/User/UserProvider.tsx index f1de586..3f8c6a5 100644 --- a/packages/react/src/contexts/User/UserProvider.tsx +++ b/packages/react/src/contexts/User/UserProvider.tsx @@ -25,12 +25,13 @@ import UserContext from './UserContext'; */ export interface UserProviderProps { onUpdateProfile?: (payload: User) => void; - profile: UserProfile; + profile: UserProfile & {userSchema?: Record | null}; revalidateProfile?: () => Promise; updateProfile?: ( requestConfig: UpdateMeProfileConfig, sessionId?: string, ) => Promise<{data: {user: User}; error: string; success: boolean}>; + userSchema?: Record | null; } /** @@ -66,6 +67,7 @@ const UserProvider: FC> = ({ revalidateProfile, onUpdateProfile, updateProfile, + userSchema, }: PropsWithChildren): ReactElement => { const contextValue: any = useMemo( () => ({ @@ -74,8 +76,9 @@ const UserProvider: FC> = ({ profile: profile?.profile, revalidateProfile, updateProfile, + userSchema: profile?.userSchema ?? userSchema ?? null, }), - [profile, onUpdateProfile, revalidateProfile, updateProfile], + [profile, onUpdateProfile, revalidateProfile, updateProfile, userSchema], ); return {children}; diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 796daaa..037e016 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -245,6 +245,8 @@ export {default as updateMeProfile} from './api/updateMeProfile'; export type {UpdateMeProfileConfig} from './api/updateMeProfile'; export {default as getMeProfile} from './api/getUsersMe'; export * from './api/getUsersMe'; +export {default as getUsersMeMeta} from './api/getUsersMeMeta'; +export * from './api/getUsersMeMeta'; export { ThunderIDRuntimeError,