Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,7 @@ const ThunderIDClientProvider: FC<PropsWithChildren<ThunderIDClientProviderProps
signUp: handleSignUp,
signUpUrl,
user,
userSchema: null,
Comment thread
janithjay marked this conversation as resolved.
vendor: getVendorPrefix(vendor),
}),
[
Expand Down
80 changes: 80 additions & 0 deletions packages/react/src/api/getUsersMeMeta.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/**
* 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 {FetchHttpClient, HttpRequestConfig, HttpResponse} from '@thunderid/browser';

export interface AttributeSchema {
credential?: boolean;
description?: string;
displayName?: string;
mutability?: string;
readOnly?: boolean;
regex?: string;
required?: boolean;
subAttributes?: AttributeSchema[];
type?: string;
unique?: boolean;
}

export interface GetUsersMeMetaConfig {
baseUrl?: string;
fetcher?: (url: string, config: RequestInit) => Promise<Response>;
instanceId?: number;
url?: string;
}

export interface UsersMeMetaResponse {
schema?: Record<string, AttributeSchema>;
}

const getUsersMeMeta = async ({
baseUrl,
fetcher,
instanceId = 0,
url,
}: GetUsersMeMetaConfig): Promise<UsersMeMetaResponse> => {
const targetUrl = url ?? `${baseUrl?.replace(/\/$/, '')}/users/me/meta`;

const defaultFetcher = async (endpointUrl: string, config: RequestInit): Promise<Response> => {
const httpClient: FetchHttpClient = FetchHttpClient.getInstance(instanceId);
const response: HttpResponse<UsersMeMetaResponse> = await httpClient.request({
headers: config.headers as Record<string, string>,
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<UsersMeMetaResponse>;
};

export default getUsersMeMeta;
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ interface Schema extends ExtendedFlatSchema {
multiValued?: boolean;
mutability?: string;
name?: string;
regex?: string;
required?: boolean;
returned?: string;
subAttributes?: Schema[];
Expand Down Expand Up @@ -88,6 +89,7 @@ export interface BaseUserProfileProps {
showFields?: string[];

title?: string;
userSchema?: Record<string, any> | null;
}

// Fields to skip based on schema.name
Expand Down Expand Up @@ -117,14 +119,15 @@ 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<BaseUserProfileProps> = ({
fallback = null,
className = '',
cardLayout = true,
profile,
flattenedProfile,
userSchema,
mode = 'inline',
title,
attributeMapping = {},
Expand All @@ -142,6 +145,7 @@ const BaseUserProfile: FC<BaseUserProfileProps> = ({
const {theme, colorScheme} = useTheme();
const [editedUser, setEditedUser] = useState(flattenedProfile || profile);
const [editingFields, setEditingFields] = useState<Record<string, boolean>>({});
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const {t} = useTranslation(preferences?.i18n);

useEffect(() => {
Expand All @@ -162,17 +166,13 @@ const BaseUserProfile: FC<BaseUserProfileProps> = ({
}, [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;
}

Expand Down Expand Up @@ -213,22 +213,21 @@ const BaseUserProfile: FC<BaseUserProfileProps> = ({
}));
}, []);

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 =>
Expand Down Expand Up @@ -285,9 +284,12 @@ const BaseUserProfile: FC<BaseUserProfileProps> = ({
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 {
Expand All @@ -298,23 +300,64 @@ const BaseUserProfile: FC<BaseUserProfileProps> = ({
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<string, string>) => ({
...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<string, string>) => ({
...prev,
[fieldName]: t('validation.pattern.invalid'),
}));
return;
}
} catch (e) {
// ignore invalid regex syntax safely
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Clear field error if valid
setFieldErrors((prev: Record<string, string>) => {
const next = {...prev};
delete next[fieldName];
return next;
});

let payload: Record<string, any> = {};
set(payload, fieldName, fieldValue);

onUpdate(payload);

toggleFieldEdit(fieldName);
},
[editedUser, flattenedProfile, onUpdate, toggleFieldEdit],
[editedUser, flattenedProfile, profile, onUpdate, toggleFieldEdit, t],
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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<string, string>) => {
const next = {...prev};
delete next[fieldName];
return next;
});
toggleFieldEdit(fieldName);
},
[flattenedProfile, profile, toggleFieldEdit],
Expand Down Expand Up @@ -579,6 +622,11 @@ const BaseUserProfile: FC<BaseUserProfileProps> = ({
},
() => toggleFieldEdit(schema.name),
)}
{fieldErrors[schema.name] && (
<div style={{color: '#d32f2f', fontSize: '0.8rem', marginTop: '4px', fontWeight: 500}}>
{fieldErrors[schema.name]}
</div>
)}
</div>
{editable && schema.mutability !== 'READ_ONLY' && !isReadonlyField && (
<div className={styles.fieldActions}>
Expand Down Expand Up @@ -628,18 +676,54 @@ const BaseUserProfile: FC<BaseUserProfileProps> = ({

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,
};
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} 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 (
<>
Expand All @@ -661,17 +745,11 @@ const BaseUserProfile: FC<BaseUserProfileProps> = ({
)}
</div>
<Divider />
{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 (
<div key={key} className={styles.info}>
{renderUserInfo(schemaWithValue)}
</div>
);
})}
{schemaItems.map((schemaWithValue: Schema) => (
<div key={schemaWithValue.name} className={styles.info}>
{renderUserInfo(schemaWithValue)}
</div>
))}
</>
);
};
Expand All @@ -687,7 +765,7 @@ const BaseUserProfile: FC<BaseUserProfileProps> = ({
<AlertPrimitive.Description>{error}</AlertPrimitive.Description>
</AlertPrimitive>
)}
<div className={styles.infoContainer}>{renderProfileWithoutSchemas()}</div>
<div className={styles.infoContainer}>{renderProfileContent()}</div>
</CardPrimitive>
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ export type UserProfileProps = Omit<BaseUserProfileProps, 'user' | 'profile' | '
*/
const UserProfile: FC<UserProfileProps> = ({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,
Expand Down Expand Up @@ -112,6 +112,7 @@ const UserProfile: FC<UserProfileProps> = ({preferences, editable = true, ...res
<BaseUserProfile
profile={profile ?? undefined}
flattenedProfile={flattenedProfile ?? undefined}
userSchema={userSchema ?? undefined}
editable={isEditableProfile}
onUpdate={isEditableProfile ? handleProfileUpdate : undefined}
error={error}
Expand Down
Loading
Loading