diff --git a/packages/javascript/src/api/__tests__/getUsersMeMeta.test.ts b/packages/javascript/src/api/__tests__/getUsersMeMeta.test.ts new file mode 100644 index 00000000..57bd4581 --- /dev/null +++ b/packages/javascript/src/api/__tests__/getUsersMeMeta.test.ts @@ -0,0 +1,74 @@ +// Copyright 2025-2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import {describe, it, expect, vi} from 'vitest'; +import getUsersMeMeta from '../getUsersMeMeta'; +import ThunderIDAPIError from '../../errors/ThunderIDAPIError'; + +describe('getUsersMeMeta', () => { + it('fetches user schema metadata successfully with custom fetcher', async () => { + const mockSchema = { + schema: { + givenName: { + displayName: 'First Name', + type: 'STRING', + required: true, + }, + }, + }; + + const mockFetcher = vi.fn().mockResolvedValue({ + ok: true, + json: async () => mockSchema, + } as Response); + + const result = await getUsersMeMeta({ + baseUrl: 'https://api.example.com', + fetcher: mockFetcher, + }); + + expect(mockFetcher).toHaveBeenCalledWith( + 'https://api.example.com/users/me/meta', + expect.objectContaining({ + method: 'GET', + }), + ); + expect(result).toEqual(mockSchema); + }); + + it('throws ThunderIDAPIError for invalid URL', async () => { + await expect( + getUsersMeMeta({ + baseUrl: 'invalid-url', + fetcher: vi.fn(), + }), + ).rejects.toThrow(ThunderIDAPIError); + }); + + it('throws ThunderIDAPIError when server returns non-ok response', async () => { + const mockFetcher = vi.fn().mockResolvedValue({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + text: async () => 'Server error', + } as Response); + + await expect( + getUsersMeMeta({ + baseUrl: 'https://api.example.com', + fetcher: mockFetcher, + }), + ).rejects.toThrow(ThunderIDAPIError); + }); + + it('handles network failure', async () => { + const mockFetcher = vi.fn().mockRejectedValue(new Error('Network error')); + + await expect( + getUsersMeMeta({ + baseUrl: 'https://api.example.com', + fetcher: mockFetcher, + }), + ).rejects.toThrow(ThunderIDAPIError); + }); +}); diff --git a/packages/javascript/src/api/getUsersMeMeta.ts b/packages/javascript/src/api/getUsersMeMeta.ts new file mode 100644 index 00000000..0e90b03f --- /dev/null +++ b/packages/javascript/src/api/getUsersMeMeta.ts @@ -0,0 +1,122 @@ +// Copyright 2025-2026 The ThunderID Authors +// SPDX-License-Identifier: Apache-2.0 + +import ThunderIDAPIError from '../errors/ThunderIDAPIError'; + +/** + * Attribute schema metadata returned by GET /users/me/meta + */ +export interface AttributeSchema { + credential?: boolean; + description?: string; + displayName?: string; + mutability?: string; + readOnly?: boolean; + regex?: string; + required?: boolean; + subAttributes?: AttributeSchema[]; + type?: string; + unique?: boolean; +} + +/** + * Configuration for the getUsersMeMeta request + */ +export interface GetUsersMeMetaConfig extends Omit { + /** + * The base path of the API endpoint. + */ + baseUrl?: string; + /** + * Optional custom fetcher function. + * If not provided, native fetch will be used. + */ + fetcher?: (url: string, config: RequestInit) => Promise; + /** + * Custom HTTP headers as a plain object. + */ + headers?: Record; + /** + * The absolute API endpoint. + */ + url?: string; +} + +/** + * Response structure for GET /users/me/meta + */ +export interface UsersMeMetaResponse { + schema?: Record; +} + +/** + * Retrieves the user schema metadata from the specified /users/me/meta endpoint. + * + * @param config - Request configuration object. + * @returns A promise that resolves with the user schema metadata. + */ +const getUsersMeMeta = async ({ + baseUrl, + fetcher, + url, + ...requestConfig +}: GetUsersMeMetaConfig): Promise => { + try { + // eslint-disable-next-line no-new + new URL((url ?? baseUrl)!); + } catch (error) { + throw new ThunderIDAPIError( + `Invalid URL provided. ${error instanceof Error ? error.message : String(error)}`, + 'getUsersMeMeta-ValidationError-001', + 'javascript', + 400, + 'The provided `url` or `baseUrl` path does not adhere to the URL schema.', + ); + } + + const fetchFn: typeof fetch = fetcher ?? fetch; + const resolvedUrl: string = url ?? `${baseUrl?.replace(/\/$/, '')}/users/me/meta`; + + const requestInit: RequestInit = { + ...requestConfig, + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + ...requestConfig.headers, + }, + method: 'GET', + }; + + try { + const response: Response = await fetchFn(resolvedUrl, requestInit); + + if (!response?.ok) { + const errorText: string = await response.text(); + + throw new ThunderIDAPIError( + errorText, + 'getUsersMeMeta-ResponseError-001', + 'javascript', + response.status, + response.statusText, + 'Failed to fetch user schema metadata', + ); + } + + return (await response.json()) as UsersMeMetaResponse; + } catch (error) { + if (error instanceof ThunderIDAPIError) { + throw error; + } + + throw new ThunderIDAPIError( + `Network or parsing error: ${error instanceof Error ? error.message : 'Unknown error'}`, + 'getUsersMeMeta-NetworkError-001', + 'javascript', + 0, + 'Network Error', + ); + } +}; + +export default getUsersMeMeta; diff --git a/packages/javascript/src/index.ts b/packages/javascript/src/index.ts index 8038bd4d..5ca8b1d9 100644 --- a/packages/javascript/src/index.ts +++ b/packages/javascript/src/index.ts @@ -18,6 +18,8 @@ export type { export {default as getUserInfo} from './api/getUserInfo'; export {default as getUsersMe} from './api/getUsersMe'; export type {GetUsersMeConfig} from './api/getUsersMe'; +export {default as getUsersMeMeta} from './api/getUsersMeMeta'; +export type {GetUsersMeMetaConfig, UsersMeMetaResponse, AttributeSchema} from './api/getUsersMeMeta'; export {default as updateMeProfile} from './api/updateMeProfile'; export type {UpdateMeProfileConfig} from './api/updateMeProfile'; diff --git a/packages/javascript/src/models/config.ts b/packages/javascript/src/models/config.ts index 5d082055..03060bc2 100644 --- a/packages/javascript/src/models/config.ts +++ b/packages/javascript/src/models/config.ts @@ -233,6 +233,7 @@ export interface BaseConfig extends WithPreferences, WithExtensions * flowExecute: "https://rs.example.com/flow/execute", * flowMeta: "https://rs.example.com/flow/meta", * usersMe: "https://rs.example.com/users/me", + * usersMeMeta: "https://rs.example.com/users/me/meta", * } */ endpoints?: { @@ -282,6 +283,11 @@ export interface BaseConfig extends WithPreferences, WithExtensions * If not provided, defaults to `{baseUrl}/users/me`. */ usersMe?: string; + /** + * The user profile schema metadata endpoint URL used to fetch profile schema attributes. + * If not provided, defaults to `{baseUrl}/users/me/meta`. + */ + usersMeMeta?: string; /** * The OpenID Connect discovery document URL. * Defaults to `{baseUrl}/oauth2/token/.well-known/openid-configuration`. diff --git a/packages/javascript/src/utils/__tests__/AuthenticationHelper.resourceEndpoints.test.ts b/packages/javascript/src/utils/__tests__/AuthenticationHelper.resourceEndpoints.test.ts index d4e65964..a153f970 100644 --- a/packages/javascript/src/utils/__tests__/AuthenticationHelper.resourceEndpoints.test.ts +++ b/packages/javascript/src/utils/__tests__/AuthenticationHelper.resourceEndpoints.test.ts @@ -19,7 +19,16 @@ const createHelper = (config: any): AuthenticationHelper => { // Resource-server override keys in both camelCase (config form) and snake_case (metadata form); // none of these should ever appear in the resolved OIDC provider metadata. -const RESOURCE_KEY_FORMS: string[] = ['flowExecute', 'flowMeta', 'usersMe', 'flow_execute', 'flow_meta', 'users_me']; +const RESOURCE_KEY_FORMS: string[] = [ + 'flowExecute', + 'flowMeta', + 'usersMe', + 'usersMeMeta', + 'flow_execute', + 'flow_meta', + 'users_me', + 'users_me_meta', +]; describe('AuthenticationHelper resource-endpoint filtering', (): void => { it('keeps resource-server endpoint overrides out of the OIDC provider metadata', async (): Promise => { @@ -32,6 +41,7 @@ describe('AuthenticationHelper resource-endpoint filtering', (): void => { flowExecute: 'https://rs.example.com/flow/execute', flowMeta: 'https://rs.example.com/flow/meta', usersMe: 'https://rs.example.com/users/me', + usersMeMeta: 'https://rs.example.com/users/me/meta', }, }); @@ -54,6 +64,7 @@ describe('AuthenticationHelper resource-endpoint filtering', (): void => { flowExecute: 'https://rs.example.com/flow/execute', flowMeta: 'https://rs.example.com/flow/meta', usersMe: 'https://rs.example.com/users/me', + usersMeMeta: 'https://rs.example.com/users/me/meta', }, }); @@ -83,6 +94,7 @@ describe('AuthenticationHelper resource-endpoint filtering', (): void => { flowExecute: 'https://rs.example.com/flow/execute', flowMeta: 'https://rs.example.com/flow/meta', usersMe: 'https://rs.example.com/users/me', + usersMeMeta: 'https://rs.example.com/users/me/meta', }, }); diff --git a/packages/javascript/src/utils/__tests__/resolveResourceEndpoint.test.ts b/packages/javascript/src/utils/__tests__/resolveResourceEndpoint.test.ts index 32962adf..e8c9aa6a 100644 --- a/packages/javascript/src/utils/__tests__/resolveResourceEndpoint.test.ts +++ b/packages/javascript/src/utils/__tests__/resolveResourceEndpoint.test.ts @@ -23,12 +23,14 @@ describe('resolveResourceEndpoint', (): void => { flowExecute: 'https://rs.example.com/flow/execute', flowMeta: 'https://rs.example.com/flow/meta', usersMe: 'https://rs.example.com/users/me', + usersMeMeta: 'https://rs.example.com/users/me/meta', }, }; expect(resolveResourceEndpoint('flowExecute', config)).toBe('https://rs.example.com/flow/execute'); expect(resolveResourceEndpoint('flowMeta', config)).toBe('https://rs.example.com/flow/meta'); expect(resolveResourceEndpoint('usersMe', config)).toBe('https://rs.example.com/users/me'); + expect(resolveResourceEndpoint('usersMeMeta', config)).toBe('https://rs.example.com/users/me/meta'); }); it('prefers an explicit per-call URL over the config override', (): void => { @@ -46,6 +48,6 @@ describe('resolveResourceEndpoint', (): void => { }); it('exposes the resource endpoint keys for filtering OIDC metadata', (): void => { - expect([...RESOURCE_ENDPOINT_KEYS].sort()).toEqual(['flowExecute', 'flowMeta', 'usersMe']); + expect([...RESOURCE_ENDPOINT_KEYS].sort()).toEqual(['flowExecute', 'flowMeta', 'usersMe', 'usersMeMeta']); }); }); diff --git a/packages/javascript/src/utils/resolveResourceEndpoint.ts b/packages/javascript/src/utils/resolveResourceEndpoint.ts index 65b8a9d4..c4fb530f 100644 --- a/packages/javascript/src/utils/resolveResourceEndpoint.ts +++ b/packages/javascript/src/utils/resolveResourceEndpoint.ts @@ -12,13 +12,18 @@ import {BaseConfig} from '../models/config'; * issuers), these overrides let the SDK send flow and user-management requests to the resource * server while OAuth requests continue to target the authorization server. */ -export type ResourceEndpointKey = 'flowExecute' | 'flowMeta' | 'usersMe'; +export type ResourceEndpointKey = 'flowExecute' | 'flowMeta' | 'usersMe' | 'usersMeMeta'; /** * The `config.endpoints` keys that address resource-server endpoints rather than OIDC/OAuth * endpoints. Used to keep these overrides out of the resolved OIDC provider metadata. */ -export const RESOURCE_ENDPOINT_KEYS: readonly ResourceEndpointKey[] = ['flowExecute', 'flowMeta', 'usersMe']; +export const RESOURCE_ENDPOINT_KEYS: readonly ResourceEndpointKey[] = [ + 'flowExecute', + 'flowMeta', + 'usersMe', + 'usersMeMeta', +]; /** * Minimal shape of the config needed to resolve a resource-server endpoint override. diff --git a/packages/nextjs/src/ThunderIDNextClient.ts b/packages/nextjs/src/ThunderIDNextClient.ts index 1534b35f..3b73dc39 100644 --- a/packages/nextjs/src/ThunderIDNextClient.ts +++ b/packages/nextjs/src/ThunderIDNextClient.ts @@ -231,7 +231,7 @@ class ThunderIDNextClient e return executeEmbeddedSignInFlow({ baseUrl: configData?.baseUrl, - flowSecret: arg2?.flowSecret, + flowSecret: configData?.clientSecret || arg2?.flowSecret, payload: arg1, url: resolveResourceEndpoint('flowExecute', configData, arg2?.url), }) as unknown as Promise; diff --git a/packages/nextjs/src/client/contexts/ThunderID/ThunderIDProvider.tsx b/packages/nextjs/src/client/contexts/ThunderID/ThunderIDProvider.tsx index 378475fd..c91afd8d 100644 --- a/packages/nextjs/src/client/contexts/ThunderID/ThunderIDProvider.tsx +++ b/packages/nextjs/src/client/contexts/ThunderID/ThunderIDProvider.tsx @@ -359,6 +359,7 @@ const ThunderIDClientProvider: FC { + /** + * Optional custom fetcher function. If not provided, the ThunderID SPA client's httpClient will be used + */ + fetcher?: (url: string, config: RequestInit) => Promise; + /** + * Optional instance ID for multi-instance support. Defaults to 0. + */ + instanceId?: number; +} + +/** + * Retrieves the user schema metadata from the specified /users/me/meta endpoint. + * Uses ThunderID SPA client FetchHttpClient by default with multi-instance support. + */ +const getUsersMeMeta = async ({ + fetcher, + instanceId = 0, + ...requestConfig +}: GetUsersMeMetaConfig): Promise => { + const defaultFetcher = async (url: 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, + } 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; + }; + + return baseGetUsersMeMeta({ + ...requestConfig, + fetcher: fetcher ?? defaultFetcher, + }); +}; + +export default getUsersMeMeta; diff --git a/packages/react/src/components/auth/Callback/__tests__/TokenCallback.test.tsx b/packages/react/src/components/auth/Callback/__tests__/TokenCallback.test.tsx index 173521e5..9279ecc9 100644 --- a/packages/react/src/components/auth/Callback/__tests__/TokenCallback.test.tsx +++ b/packages/react/src/components/auth/Callback/__tests__/TokenCallback.test.tsx @@ -28,6 +28,7 @@ const thunderIDContext: ThunderIDContextProps = { isLoading: false, signIn: mockSignIn, signUp: mockSignUp, + vendor: 'thunderid', } as unknown as ThunderIDContextProps; describe('TokenCallback', () => { diff --git a/packages/react/src/components/presentation/UserProfile/BaseUserProfile.tsx b/packages/react/src/components/presentation/UserProfile/BaseUserProfile.tsx index fdb44737..7a0446fe 100644 --- a/packages/react/src/components/presentation/UserProfile/BaseUserProfile.tsx +++ b/packages/react/src/components/presentation/UserProfile/BaseUserProfile.tsx @@ -1,7 +1,7 @@ // Copyright 2025 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import {User, withVendorCSSClassPrefix, bem, Preferences, startCase} from '@thunderid/browser'; +import {User, withVendorCSSClassPrefix, bem, Preferences, startCase, AttributeSchema} from '@thunderid/browser'; import {FC, ReactElement, useState, useCallback, useEffect} from 'react'; import useStyles from './BaseUserProfile.styles'; import useTheme from '../../../contexts/Theme/useTheme'; @@ -34,6 +34,7 @@ interface Schema extends ExtendedFlatSchema { multiValued?: boolean; mutability?: string; name?: string; + regex?: string; required?: boolean; returned?: string; subAttributes?: Schema[]; @@ -73,6 +74,7 @@ export interface BaseUserProfileProps { showFields?: string[]; title?: string; + userSchema?: Record | null; } // Fields to skip based on schema.name @@ -102,7 +104,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, @@ -110,6 +112,7 @@ const BaseUserProfile: FC = ({ cardLayout = true, profile, flattenedProfile, + userSchema, mode = 'inline', title, attributeMapping = {}, @@ -127,6 +130,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(() => { @@ -147,17 +151,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; } @@ -198,22 +198,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 => @@ -270,9 +269,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 { @@ -283,6 +285,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); @@ -290,16 +327,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], @@ -564,6 +607,11 @@ const BaseUserProfile: FC = ({ }, () => toggleFieldEdit(schema.name), )} + {fieldErrors[schema.name] && ( +
+ {fieldErrors[schema.name]} +
+ )} {editable && schema.mutability !== 'READ_ONLY' && !isReadonlyField && (
@@ -613,18 +661,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, AttributeSchema]) => { + if (metaAttr?.credential) return false; + return shouldShowField(key, true); + }) + .map(([key, metaAttr]: [string, AttributeSchema]) => { + 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 ( <> @@ -646,17 +730,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)} +
+ ))} ); }; @@ -672,7 +750,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 e8b29e69..801a48a0 100644 --- a/packages/react/src/components/presentation/UserProfile/UserProfile.tsx +++ b/packages/react/src/components/presentation/UserProfile/UserProfile.tsx @@ -1,7 +1,7 @@ // Copyright 2025 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import {ThunderIDError, User, deepMerge} from '@thunderid/browser'; +import {ThunderIDError, User, deepMerge, resolveResourceEndpoint} from '@thunderid/browser'; import {FC, ReactElement, useState} from 'react'; // eslint-disable-next-line import/no-named-as-default import BaseUserProfile, {BaseUserProfileProps} from './BaseUserProfile'; @@ -50,8 +50,8 @@ export type UserProfileProps = Omit = ({preferences, editable = true, ...rest}: UserProfileProps): ReactElement => { - const {baseUrl, instanceId, preferences: contextPreferences} = useThunderID(); - const {profile, flattenedProfile, onUpdateProfile} = useUser(); + const {baseUrl, endpoints, instanceId, preferences: contextPreferences} = useThunderID(); + const {profile, flattenedProfile, onUpdateProfile, userSchema} = useUser(); const resolvedPreferences = { ...contextPreferences, ...preferences, @@ -80,7 +80,12 @@ const UserProfile: FC = ({preferences, editable = true, ...res } }); - const response: User = await updateMeProfile({baseUrl, instanceId, payload: updatedAttributes}); + const response: User = await updateMeProfile({ + baseUrl, + url: resolveResourceEndpoint('usersMe', {endpoints}), + instanceId, + payload: updatedAttributes, + }); onUpdateProfile(response); } catch (caughtError: unknown) { let message: string = t('user.profile.update.generic.error'); @@ -97,6 +102,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'`. @@ -248,6 +254,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 47fd3ad3..392d3ba9 100644 --- a/packages/react/src/contexts/ThunderID/ThunderIDProvider.tsx +++ b/packages/react/src/contexts/ThunderID/ThunderIDProvider.tsx @@ -14,6 +14,7 @@ import { createPackageComponentLogger, getVendorPrefix, resolveResourceEndpoint, + AttributeSchema, } from '@thunderid/browser'; import {FC, RefObject, PropsWithChildren, ReactElement, useEffect, useMemo, useRef, useState, useCallback} from 'react'; import ThunderIDContext from './ThunderIDContext'; @@ -28,6 +29,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', @@ -76,6 +78,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, @@ -147,6 +150,24 @@ const ThunderIDProvider: FC> = ({ } catch (err) { logger.warn('Failed to fetch user profile from /users/me:', err); } + + try { + const metaRes = await getUsersMeMeta({ + baseUrl: resolvedBaseUrl, + url: resolveResourceEndpoint('usersMeMeta', config), + 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); @@ -501,6 +522,7 @@ const ThunderIDProvider: FC> = ({ signUpUrl, syncSession, user, + userSchema, vendor: getVendorPrefix(config.vendor), }), [ @@ -521,6 +543,7 @@ const ThunderIDProvider: FC> = ({ signIn, signInSilently, user, + userSchema, client, signInOptions, tokenRequest, @@ -553,7 +576,11 @@ const ThunderIDProvider: FC> = ({ }} > - + {children} diff --git a/packages/react/src/contexts/User/UserContext.ts b/packages/react/src/contexts/User/UserContext.ts index bf90649f..216eaf22 100644 --- a/packages/react/src/contexts/User/UserContext.ts +++ b/packages/react/src/contexts/User/UserContext.ts @@ -1,7 +1,7 @@ // Copyright 2025 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import {User, UpdateMeProfileConfig} from '@thunderid/browser'; +import {User, UpdateMeProfileConfig, AttributeSchema} from '@thunderid/browser'; import {Context, createContext} from 'react'; /** @@ -16,6 +16,7 @@ export interface UserContextProps { requestConfig: UpdateMeProfileConfig, sessionId?: string, ) => Promise<{data: {user: User}; error: string; success: boolean}>; + userSchema?: Record | null; } /** @@ -27,6 +28,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 c0c86c81..233cadb9 100644 --- a/packages/react/src/contexts/User/UserProvider.tsx +++ b/packages/react/src/contexts/User/UserProvider.tsx @@ -1,7 +1,7 @@ // Copyright 2025 The ThunderID Authors // SPDX-License-Identifier: Apache-2.0 -import {UpdateMeProfileConfig, User, UserProfile} from '@thunderid/browser'; +import {UpdateMeProfileConfig, User, UserProfile, AttributeSchema} from '@thunderid/browser'; import {FC, PropsWithChildren, ReactElement, useMemo} from 'react'; import UserContext from './UserContext'; @@ -10,12 +10,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; } /** @@ -51,6 +52,7 @@ const UserProvider: FC> = ({ revalidateProfile, onUpdateProfile, updateProfile, + userSchema, }: PropsWithChildren): ReactElement => { const contextValue: any = useMemo( () => ({ @@ -59,8 +61,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 53956a06..41876211 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -230,6 +230,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,