Skip to content

Commit 8cc117a

Browse files
committed
integrate users/me/meta endpoint into the React and NextJs sdks
1 parent 0beef6f commit 8cc117a

9 files changed

Lines changed: 246 additions & 51 deletions

File tree

‎packages/nextjs/src/client/contexts/ThunderID/ThunderIDProvider.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -374,6 +374,7 @@ const ThunderIDClientProvider: FC<PropsWithChildren<ThunderIDClientProviderProps
374374
signUp: handleSignUp,
375375
signUpUrl,
376376
user,
377+
userSchema: null,
377378
vendor: getVendorPrefix(vendor),
378379
}),
379380
[
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
/**
2+
* Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com).
3+
*
4+
* WSO2 LLC. licenses this file to you under the Apache License,
5+
* Version 2.0 (the "License"); you may not use this file except
6+
* in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing,
12+
* software distributed under the License is distributed on an
13+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
* KIND, either express or implied. See the License for the
15+
* specific language governing permissions and limitations
16+
* under the License.
17+
*/
18+
19+
import {FetchHttpClient, HttpRequestConfig, HttpResponse} from '@thunderid/browser';
20+
21+
export interface AttributeSchema {
22+
credential?: boolean;
23+
description?: string;
24+
displayName?: string;
25+
mutability?: string;
26+
readOnly?: boolean;
27+
regex?: string;
28+
required?: boolean;
29+
subAttributes?: AttributeSchema[];
30+
type?: string;
31+
unique?: boolean;
32+
}
33+
34+
export interface GetUsersMeMetaConfig {
35+
baseUrl?: string;
36+
fetcher?: (url: string, config: RequestInit) => Promise<Response>;
37+
instanceId?: number;
38+
url?: string;
39+
}
40+
41+
export interface UsersMeMetaResponse {
42+
schema?: Record<string, AttributeSchema>;
43+
}
44+
45+
const getUsersMeMeta = async ({
46+
baseUrl,
47+
fetcher,
48+
instanceId = 0,
49+
url,
50+
}: GetUsersMeMetaConfig): Promise<UsersMeMetaResponse> => {
51+
const targetUrl = url ?? `${baseUrl?.replace(/\/$/, '')}/users/me/meta`;
52+
53+
const defaultFetcher = async (endpointUrl: string, config: RequestInit): Promise<Response> => {
54+
const httpClient: FetchHttpClient = FetchHttpClient.getInstance(instanceId);
55+
const response: HttpResponse<UsersMeMetaResponse> = await httpClient.request({
56+
headers: config.headers as Record<string, string>,
57+
method: config.method ?? 'GET',
58+
url: endpointUrl,
59+
} as HttpRequestConfig);
60+
61+
return {
62+
json: () => Promise.resolve(response.data),
63+
ok: response.status >= 200 && response.status < 300,
64+
status: response.status,
65+
statusText: response.statusText ?? '',
66+
text: () => Promise.resolve(typeof response.data === 'string' ? response.data : JSON.stringify(response.data)),
67+
} as Response;
68+
};
69+
70+
const activeFetcher = fetcher ?? defaultFetcher;
71+
const res = await activeFetcher(targetUrl, {method: 'GET'});
72+
73+
if (!res.ok) {
74+
throw new Error(`Failed to fetch user schema metadata: ${res.statusText}`);
75+
}
76+
77+
return res.json() as Promise<UsersMeMetaResponse>;
78+
};
79+
80+
export default getUsersMeMeta;

‎packages/react/src/components/presentation/UserProfile/BaseUserProfile.tsx‎

Lines changed: 125 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ interface Schema extends ExtendedFlatSchema {
4949
multiValued?: boolean;
5050
mutability?: string;
5151
name?: string;
52+
regex?: string;
5253
required?: boolean;
5354
returned?: string;
5455
subAttributes?: Schema[];
@@ -88,6 +89,7 @@ export interface BaseUserProfileProps {
8889
showFields?: string[];
8990

9091
title?: string;
92+
userSchema?: Record<string, any> | null;
9193
}
9294

9395
// Fields to skip based on schema.name
@@ -117,14 +119,15 @@ const fieldsToSkip: string[] = [
117119
];
118120

119121
// Fields that should be readonly
120-
const readonlyFields: string[] = ['attributes', 'id', 'isReadOnly', 'ouId', 'username'];
122+
const readonlyFields: string[] = ['attributes', 'id', 'isReadOnly', 'ouId', 'username', 'sub'];
121123

122124
const BaseUserProfile: FC<BaseUserProfileProps> = ({
123125
fallback = null,
124126
className = '',
125127
cardLayout = true,
126128
profile,
127129
flattenedProfile,
130+
userSchema,
128131
mode = 'inline',
129132
title,
130133
attributeMapping = {},
@@ -142,6 +145,7 @@ const BaseUserProfile: FC<BaseUserProfileProps> = ({
142145
const {theme, colorScheme} = useTheme();
143146
const [editedUser, setEditedUser] = useState(flattenedProfile || profile);
144147
const [editingFields, setEditingFields] = useState<Record<string, boolean>>({});
148+
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
145149
const {t} = useTranslation(preferences?.i18n);
146150

147151
useEffect(() => {
@@ -162,17 +166,13 @@ const BaseUserProfile: FC<BaseUserProfileProps> = ({
162166
}, [flattenedProfile, profile, editingFields]);
163167

164168
/**
165-
* Determines if a field should be visible based on showFields, hideFields, and fieldsToSkip arrays.
166-
* Priority order:
167-
* 1. fieldsToSkip (always hidden) - highest priority
168-
* 2. hideFields (explicitly hidden)
169-
* 3. showFields (explicitly shown, if array is not empty)
170-
* 4. Default behavior (show all fields not in fieldsToSkip)
169+
* Determines if a field should be visible based on showFields, hideFields, and fallback fieldsToSkip arrays.
170+
* When isSchemaBased is true, fieldsToSkip is bypassed so schema-defined fields render dynamically.
171171
*/
172-
const shouldShowField: any = useCallback(
173-
(fieldName: string): boolean => {
174-
// Always skip fields in the hardcoded fieldsToSkip array
175-
if (fieldsToSkip.includes(fieldName)) {
172+
const shouldShowField = useCallback(
173+
(fieldName: string, isSchemaBased: boolean = false): boolean => {
174+
// For fallback without schema metadata, skip internal system fields
175+
if (!isSchemaBased && fieldsToSkip.includes(fieldName)) {
176176
return false;
177177
}
178178

@@ -213,22 +213,21 @@ const BaseUserProfile: FC<BaseUserProfileProps> = ({
213213
}));
214214
}, []);
215215

216-
const getFieldPlaceholder: any = useCallback((schema: Schema): string => {
217-
const {type, displayName, description, name} = schema;
216+
const getFieldPlaceholder: any = useCallback(
217+
(schema: Schema): string => {
218+
const {type, displayName, description, name} = schema;
218219

219-
const fieldLabel: any = displayName || description || name || 'value';
220+
const fieldLabel: any = displayName || description || name || 'value';
220221

221-
switch (type) {
222-
case 'DATE_TIME':
223-
return `Enter your ${fieldLabel.toLowerCase()}`;
224-
case 'BOOLEAN':
225-
return `Select ${fieldLabel.toLowerCase()}`;
226-
case 'COMPLEX':
227-
return `Enter ${fieldLabel.toLowerCase()} details`;
228-
default:
229-
return `Enter your ${fieldLabel.toLowerCase()}`;
230-
}
231-
}, []);
222+
switch (type) {
223+
case 'DATE_TIME':
224+
case 'STRING':
225+
default:
226+
return t('elements.fields.generic.placeholder', {field: fieldLabel.toLowerCase()});
227+
}
228+
},
229+
[t],
230+
);
232231

233232
const formatLabel: any = useCallback(
234233
(key: string): string =>
@@ -285,9 +284,12 @@ const BaseUserProfile: FC<BaseUserProfileProps> = ({
285284
if (!onUpdate || !schema.name) return;
286285

287286
const fieldName: string = schema.name;
287+
const currentUser: any = flattenedProfile || profile;
288288
let fieldValue: any;
289289
if (editedUser && fieldName && editedUser[fieldName] !== undefined) {
290290
fieldValue = editedUser[fieldName];
291+
} else if (currentUser?.attributes?.[fieldName] !== undefined) {
292+
fieldValue = currentUser.attributes[fieldName];
291293
} else if (flattenedProfile?.[fieldName] !== undefined) {
292294
fieldValue = flattenedProfile[fieldName];
293295
} else {
@@ -298,23 +300,64 @@ const BaseUserProfile: FC<BaseUserProfileProps> = ({
298300
fieldValue = fieldValue.filter((v: any) => v !== undefined && v !== null && v !== '');
299301
}
300302

303+
const strVal = String(fieldValue ?? '').trim();
304+
const fieldLabel = schema.displayName || (schema.name ? startCase(schema.name) : 'Field');
305+
306+
// 1. Required validation
307+
if (schema.required && !strVal) {
308+
setFieldErrors((prev: Record<string, string>) => ({
309+
...prev,
310+
[fieldName]: t('validations.required.field.error'),
311+
}));
312+
return;
313+
}
314+
315+
// 2. Regex validation
316+
if (schema.regex && strVal) {
317+
try {
318+
const reg = new RegExp(schema.regex);
319+
if (!reg.test(strVal)) {
320+
setFieldErrors((prev: Record<string, string>) => ({
321+
...prev,
322+
[fieldName]: t('validation.pattern.invalid'),
323+
}));
324+
return;
325+
}
326+
} catch (e) {
327+
// ignore invalid regex syntax safely
328+
}
329+
}
330+
331+
// Clear field error if valid
332+
setFieldErrors((prev: Record<string, string>) => {
333+
const next = {...prev};
334+
delete next[fieldName];
335+
return next;
336+
});
337+
301338
let payload: Record<string, any> = {};
302339
set(payload, fieldName, fieldValue);
303340

304341
onUpdate(payload);
305342

306343
toggleFieldEdit(fieldName);
307344
},
308-
[editedUser, flattenedProfile, onUpdate, toggleFieldEdit],
345+
[editedUser, flattenedProfile, profile, onUpdate, toggleFieldEdit, t],
309346
);
310347

311348
const handleFieldCancel: any = useCallback(
312349
(fieldName: string) => {
313350
const currentUser: any = flattenedProfile || profile;
351+
const initialVal = currentUser?.attributes?.[fieldName] ?? currentUser?.[fieldName];
314352
setEditedUser((prev: any) => ({
315353
...prev,
316-
[fieldName]: currentUser[fieldName],
354+
[fieldName]: initialVal,
317355
}));
356+
setFieldErrors((prev: Record<string, string>) => {
357+
const next = {...prev};
358+
delete next[fieldName];
359+
return next;
360+
});
318361
toggleFieldEdit(fieldName);
319362
},
320363
[flattenedProfile, profile, toggleFieldEdit],
@@ -579,6 +622,11 @@ const BaseUserProfile: FC<BaseUserProfileProps> = ({
579622
},
580623
() => toggleFieldEdit(schema.name),
581624
)}
625+
{fieldErrors[schema.name] && (
626+
<div style={{color: '#d32f2f', fontSize: '0.8rem', marginTop: '4px', fontWeight: 500}}>
627+
{fieldErrors[schema.name]}
628+
</div>
629+
)}
582630
</div>
583631
{editable && schema.mutability !== 'READ_ONLY' && !isReadonlyField && (
584632
<div className={styles.fieldActions}>
@@ -628,18 +676,54 @@ const BaseUserProfile: FC<BaseUserProfileProps> = ({
628676

629677
const currentUser: any = flattenedProfile || profile;
630678

631-
const renderProfileWithoutSchemas = (): any => {
679+
const renderProfileContent = (): any => {
632680
if (!currentUser) return null;
633681

634682
const displayName: any = getDisplayName(mergedMappings, profile!, displayNameAttributes);
635683

636-
const profileEntries: any = Object.entries(currentUser)
637-
.filter(([key, value]: [string, any]) => {
638-
if (!shouldShowField(key)) return false;
639-
640-
return value !== undefined && value !== '' && value !== null;
641-
})
642-
.sort(([a]: [string, ...any[]], [b]: [string, ...any[]]) => a.localeCompare(b));
684+
let schemaItems: Schema[] = [];
685+
686+
if (userSchema && typeof userSchema === 'object' && Object.keys(userSchema).length > 0) {
687+
schemaItems = Object.entries(userSchema)
688+
.filter(([key, metaAttr]: [string, any]) => {
689+
if (metaAttr?.credential) return false;
690+
return shouldShowField(key, true);
691+
})
692+
.map(([key, metaAttr]: [string, any]) => {
693+
const val = editedUser?.[key] ?? currentUser?.attributes?.[key] ?? currentUser?.[key] ?? '';
694+
695+
const isReadonly =
696+
metaAttr.readOnly === true || metaAttr.mutability === 'READ_ONLY' || readonlyFields.includes(key);
697+
698+
return {
699+
name: key,
700+
displayName: metaAttr.displayName || (key ? startCase(key) : ''),
701+
type: (metaAttr.type || 'STRING').toUpperCase(),
702+
regex: metaAttr.regex,
703+
required: !!metaAttr.required,
704+
mutability: isReadonly ? 'READ_ONLY' : 'READ_WRITE',
705+
value: val,
706+
};
707+
});
708+
} else {
709+
const profileEntries: any = Object.entries(currentUser)
710+
.filter(([key, value]: [string, any]) => {
711+
if (!shouldShowField(key)) return false;
712+
713+
return value !== undefined && value !== '' && value !== null;
714+
})
715+
.sort(([a]: [string, ...any[]], [b]: [string, ...any[]]) => a.localeCompare(b));
716+
717+
schemaItems = profileEntries.map(([key, value]: any) => {
718+
const isReadonly = readonlyFields.includes(key);
719+
return {
720+
name: key,
721+
displayName: startCase(key),
722+
mutability: isReadonly ? 'READ_ONLY' : 'READ_WRITE',
723+
value,
724+
};
725+
});
726+
}
643727

644728
return (
645729
<>
@@ -661,17 +745,11 @@ const BaseUserProfile: FC<BaseUserProfileProps> = ({
661745
)}
662746
</div>
663747
<Divider />
664-
{profileEntries.map(([key, value]: any) => {
665-
const isReadonly = readonlyFields.includes(key);
666-
const schema: Schema = {name: key, mutability: isReadonly ? 'READ_ONLY' : 'READ_WRITE'};
667-
const schemaWithValue: any = {...schema, value};
668-
669-
return (
670-
<div key={key} className={styles.info}>
671-
{renderUserInfo(schemaWithValue)}
672-
</div>
673-
);
674-
})}
748+
{schemaItems.map((schemaWithValue: Schema) => (
749+
<div key={schemaWithValue.name} className={styles.info}>
750+
{renderUserInfo(schemaWithValue)}
751+
</div>
752+
))}
675753
</>
676754
);
677755
};
@@ -687,7 +765,7 @@ const BaseUserProfile: FC<BaseUserProfileProps> = ({
687765
<AlertPrimitive.Description>{error}</AlertPrimitive.Description>
688766
</AlertPrimitive>
689767
)}
690-
<div className={styles.infoContainer}>{renderProfileWithoutSchemas()}</div>
768+
<div className={styles.infoContainer}>{renderProfileContent()}</div>
691769
</CardPrimitive>
692770
);
693771

‎packages/react/src/components/presentation/UserProfile/UserProfile.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ export type UserProfileProps = Omit<BaseUserProfileProps, 'user' | 'profile' | '
6666
*/
6767
const UserProfile: FC<UserProfileProps> = ({preferences, editable = true, ...rest}: UserProfileProps): ReactElement => {
6868
const {baseUrl, instanceId, preferences: contextPreferences} = useThunderID();
69-
const {profile, flattenedProfile, onUpdateProfile} = useUser();
69+
const {profile, flattenedProfile, onUpdateProfile, userSchema} = useUser();
7070
const resolvedPreferences = {
7171
...contextPreferences,
7272
...preferences,
@@ -112,6 +112,7 @@ const UserProfile: FC<UserProfileProps> = ({preferences, editable = true, ...res
112112
<BaseUserProfile
113113
profile={profile ?? undefined}
114114
flattenedProfile={flattenedProfile ?? undefined}
115+
userSchema={userSchema ?? undefined}
115116
editable={isEditableProfile}
116117
onUpdate={isEditableProfile ? handleProfileUpdate : undefined}
117118
error={error}

0 commit comments

Comments
 (0)