From 9a156a948a0a3c36f504954e1b87a6ef39f13d3a Mon Sep 17 00:00:00 2001 From: DonOmalVindula Date: Tue, 8 Sep 2026 11:34:15 +0530 Subject: [PATCH 1/4] feat(react, nextjs): render a read-only profile for identity-provider-managed accounts Asgardeo rejects attribute updates for accounts provisioned from a social or enterprise connection, so the profile offered edit controls that always failed. - accepts editable="auto": on Asgardeo it looks up the user's federated associations and renders the profile read-only with a note naming the provider; on Identity Server, where the same updates succeed, it stays editable. - BaseUserProfile accepts a predicate for editable and a readOnlyNote. - A rejected update is reported in plain words and switches the profile to read-only; the Next.js wrapper previously ignored update failures entirely. - New getMeFederatedAssociations API and two i18n texts in all bundles. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/readonly-federated-profile.md | 15 +++ packages/i18n/src/models/i18n.ts | 2 + packages/i18n/src/translations/en-US.ts | 3 + packages/i18n/src/translations/fr-FR.ts | 5 + packages/i18n/src/translations/hi-IN.ts | 6 ++ packages/i18n/src/translations/ja-JP.ts | 6 ++ packages/i18n/src/translations/pt-BR.ts | 5 + packages/i18n/src/translations/pt-PT.ts | 5 + packages/i18n/src/translations/si-LK.ts | 6 ++ packages/i18n/src/translations/ta-IN.ts | 6 ++ packages/i18n/src/translations/te-IN.ts | 5 + .../src/api/getMeFederatedAssociations.ts | 102 ++++++++++++++++++ packages/javascript/src/index.ts | 3 + .../src/models/federated-association.ts | 41 +++++++ .../presentation/UserProfile/UserProfile.tsx | 79 +++++++++++++- .../server/actions/getFederatedProfileLock.ts | 79 ++++++++++++++ .../src/api/getMeFederatedAssociations.ts | 75 +++++++++++++ .../UserProfile/BaseUserProfile.test.tsx | 32 ++++++ .../UserProfile/BaseUserProfile.tsx | 36 +++++-- .../presentation/UserProfile/UserProfile.tsx | 87 ++++++++++++++- packages/react/src/index.ts | 2 + 21 files changed, 586 insertions(+), 14 deletions(-) create mode 100644 .changeset/readonly-federated-profile.md create mode 100644 packages/javascript/src/api/getMeFederatedAssociations.ts create mode 100644 packages/javascript/src/models/federated-association.ts create mode 100644 packages/nextjs/src/server/actions/getFederatedProfileLock.ts create mode 100644 packages/react/src/api/getMeFederatedAssociations.ts diff --git a/.changeset/readonly-federated-profile.md b/.changeset/readonly-federated-profile.md new file mode 100644 index 000000000..7618db8e0 --- /dev/null +++ b/.changeset/readonly-federated-profile.md @@ -0,0 +1,15 @@ +--- +'@asgardeo/javascript': patch +'@asgardeo/i18n': patch +'@asgardeo/react': patch +'@asgardeo/nextjs': patch +--- + +Render a read-only profile for users whose attributes are owned by an identity provider. + +Asgardeo rejects attribute updates for accounts provisioned from a social or enterprise connection, so the profile used to offer edit controls that always failed with a raw SCIM error. + +- `` accepts `editable="auto"` in React and Next.js. On Asgardeo it looks up the signed-in user's federated associations and, when the account is linked to a connection, renders the profile read-only with a short note naming the provider. On WSO2 Identity Server, where the same updates succeed, the profile stays editable. +- `BaseUserProfile` accepts a predicate for `editable`, so applications can decide per user without any lookup, and a `readOnlyNote` to explain why editing is unavailable. +- A rejected update is now reported in plain words instead of the raw SCIM error, and switches the profile to read-only for the rest of the session. The Next.js `` previously ignored update failures entirely. +- New API `getMeFederatedAssociations` in `@asgardeo/javascript` and `@asgardeo/react`, plus the `signup`-style texts `user.profile.readonly.federated` and `user.profile.update.not.allowed.error` in all i18n bundles. diff --git a/packages/i18n/src/models/i18n.ts b/packages/i18n/src/models/i18n.ts index d38c62afd..427bb3594 100644 --- a/packages/i18n/src/models/i18n.ts +++ b/packages/i18n/src/models/i18n.ts @@ -116,6 +116,8 @@ export interface I18nTranslations { 'user.profile.heading': string; 'user.profile.update.generic.error': string; + 'user.profile.readonly.federated': string; + 'user.profile.update.not.allowed.error': string; /* |---------------------------------------------------------------| */ /* | Organization Switcher | */ diff --git a/packages/i18n/src/translations/en-US.ts b/packages/i18n/src/translations/en-US.ts index 9017fc0e3..665f73714 100644 --- a/packages/i18n/src/translations/en-US.ts +++ b/packages/i18n/src/translations/en-US.ts @@ -119,6 +119,9 @@ const translations: I18nTranslations = { 'user.profile.heading': 'Profile', 'user.profile.update.generic.error': 'An error occurred while updating your profile. Please try again.', + 'user.profile.readonly.federated': 'This profile is managed by your {provider} account and cannot be edited here.', + 'user.profile.update.not.allowed.error': + 'Your profile is managed by your identity provider, so these details cannot be changed here.', /* |---------------------------------------------------------------| */ /* | Organization Switcher | */ diff --git a/packages/i18n/src/translations/fr-FR.ts b/packages/i18n/src/translations/fr-FR.ts index 64a682aba..3d8f7477e 100644 --- a/packages/i18n/src/translations/fr-FR.ts +++ b/packages/i18n/src/translations/fr-FR.ts @@ -117,6 +117,11 @@ const translations: I18nTranslations = { /* |---------------------------------------------------------------| */ 'user.profile.heading': 'Profil', + + 'user.profile.readonly.federated': 'Ce profil est géré par votre compte {provider} et ne peut pas être modifié ici.', + + 'user.profile.update.not.allowed.error': + "Votre profil est géré par votre fournisseur d'identité, ces informations ne peuvent donc pas être modifiées ici.", 'user.profile.update.generic.error': 'Une erreur est survenue lors de la mise à jour de votre profil. Veuillez réessayer.', diff --git a/packages/i18n/src/translations/hi-IN.ts b/packages/i18n/src/translations/hi-IN.ts index fc9d81b39..52f987d6d 100644 --- a/packages/i18n/src/translations/hi-IN.ts +++ b/packages/i18n/src/translations/hi-IN.ts @@ -116,6 +116,12 @@ const translations: I18nTranslations = { /* |---------------------------------------------------------------| */ 'user.profile.heading': 'प्रोफ़ाइल', + + 'user.profile.readonly.federated': + 'यह प्रोफ़ाइल आपके {provider} खाते द्वारा प्रबंधित है और इसे यहाँ संपादित नहीं किया जा सकता।', + + 'user.profile.update.not.allowed.error': + 'आपकी प्रोफ़ाइल आपके पहचान प्रदाता द्वारा प्रबंधित है, इसलिए ये विवरण यहाँ नहीं बदले जा सकते।', 'user.profile.update.generic.error': 'प्रोफ़ाइल अपडेट करते समय त्रुटि हुई। कृपया पुनः प्रयास करें।', /* |---------------------------------------------------------------| */ diff --git a/packages/i18n/src/translations/ja-JP.ts b/packages/i18n/src/translations/ja-JP.ts index 2e5b3212b..9e635456e 100644 --- a/packages/i18n/src/translations/ja-JP.ts +++ b/packages/i18n/src/translations/ja-JP.ts @@ -116,6 +116,12 @@ const translations: I18nTranslations = { /* |---------------------------------------------------------------| */ 'user.profile.heading': 'プロフィール', + + 'user.profile.readonly.federated': + 'このプロフィールは {provider} アカウントで管理されているため、ここでは編集できません。', + + 'user.profile.update.not.allowed.error': + 'プロフィールは ID プロバイダーによって管理されているため、ここでは変更できません。', 'user.profile.update.generic.error': 'プロフィール更新中にエラーが発生しました。もう一度お試しください。', /* |---------------------------------------------------------------| */ diff --git a/packages/i18n/src/translations/pt-BR.ts b/packages/i18n/src/translations/pt-BR.ts index e9cc79315..b6a9e1c87 100644 --- a/packages/i18n/src/translations/pt-BR.ts +++ b/packages/i18n/src/translations/pt-BR.ts @@ -116,6 +116,11 @@ const translations: I18nTranslations = { /* |---------------------------------------------------------------| */ 'user.profile.heading': 'Perfil', + + 'user.profile.readonly.federated': 'Este perfil é gerenciado pela sua conta {provider} e não pode ser editado aqui.', + + 'user.profile.update.not.allowed.error': + 'Seu perfil é gerenciado pelo seu provedor de identidade, portanto esses dados não podem ser alterados aqui.', 'user.profile.update.generic.error': 'Ocorreu um erro ao atualizar seu perfil. Tente novamente.', /* |---------------------------------------------------------------| */ diff --git a/packages/i18n/src/translations/pt-PT.ts b/packages/i18n/src/translations/pt-PT.ts index 0b04af1bb..f3f0cd369 100644 --- a/packages/i18n/src/translations/pt-PT.ts +++ b/packages/i18n/src/translations/pt-PT.ts @@ -116,6 +116,11 @@ const translations: I18nTranslations = { /* |---------------------------------------------------------------| */ 'user.profile.heading': 'Perfil', + + 'user.profile.readonly.federated': 'Este perfil é gerido pela sua conta {provider} e não pode ser editado aqui.', + + 'user.profile.update.not.allowed.error': + 'O seu perfil é gerido pelo seu fornecedor de identidade, pelo que estes dados não podem ser alterados aqui.', 'user.profile.update.generic.error': 'Ocorreu um erro ao actualizar o seu perfil. Tente novamente.', /* |---------------------------------------------------------------| */ diff --git a/packages/i18n/src/translations/si-LK.ts b/packages/i18n/src/translations/si-LK.ts index 18368d844..0fe27c187 100644 --- a/packages/i18n/src/translations/si-LK.ts +++ b/packages/i18n/src/translations/si-LK.ts @@ -116,6 +116,12 @@ const translations: I18nTranslations = { /* |---------------------------------------------------------------| */ 'user.profile.heading': 'පැතිකඩ', + + 'user.profile.readonly.federated': + 'මෙම පැතිකඩ ඔබගේ {provider} ගිණුම මගින් කළමනාකරණය වන අතර මෙහිදී සංස්කරණය කළ නොහැක.', + + 'user.profile.update.not.allowed.error': + 'ඔබගේ පැතිකඩ ඔබගේ අනන්‍යතා සපයන්නා විසින් කළමනාකරණය කරන බැවින්, මෙම විස්තර මෙහිදී වෙනස් කළ නොහැක.', 'user.profile.update.generic.error': 'ඔබේ පැතිකඩ යාවත්කාලීන කිරීමේදී දෝෂයක් ඇතිවිය.කරුණාකර නැවත උත්සාහ කරන්න', /* |---------------------------------------------------------------| */ diff --git a/packages/i18n/src/translations/ta-IN.ts b/packages/i18n/src/translations/ta-IN.ts index ac574fbea..04be4469c 100644 --- a/packages/i18n/src/translations/ta-IN.ts +++ b/packages/i18n/src/translations/ta-IN.ts @@ -116,6 +116,12 @@ const translations: I18nTranslations = { /* |---------------------------------------------------------------| */ 'user.profile.heading': 'சுயவிவரம்', + + 'user.profile.readonly.federated': + 'இந்தச் சுயவிவரம் உங்கள் {provider} கணக்கால் நிர்வகிக்கப்படுகிறது, இங்கே திருத்த முடியாது.', + + 'user.profile.update.not.allowed.error': + 'உங்கள் சுயவிவரம் அடையாள வழங்குநரால் நிர்வகிக்கப்படுகிறது, எனவே இந்த விவரங்களை இங்கே மாற்ற முடியாது.', 'user.profile.update.generic.error': 'உங்கள் சுயவிவரத்தை புதுப்பிக்கும் போது பிழை ஏற்பட்டது. மீண்டும் முயற்சிக்கவும்.', diff --git a/packages/i18n/src/translations/te-IN.ts b/packages/i18n/src/translations/te-IN.ts index eb311d519..f0662c761 100644 --- a/packages/i18n/src/translations/te-IN.ts +++ b/packages/i18n/src/translations/te-IN.ts @@ -117,6 +117,11 @@ const translations: I18nTranslations = { /* |---------------------------------------------------------------| */ 'user.profile.heading': 'ప్రొఫైల్', + + 'user.profile.readonly.federated': 'ఈ ప్రొఫైల్ మీ {provider} ఖాతా ద్వారా నిర్వహించబడుతుంది, ఇక్కడ సవరించలేరు.', + + 'user.profile.update.not.allowed.error': + 'మీ ప్రొఫైల్ మీ ఐడెంటిటీ ప్రొవైడర్ ద్వారా నిర్వహించబడుతుంది, కాబట్టి ఈ వివరాలను ఇక్కడ మార్చలేరు.', 'user.profile.update.generic.error': 'ప్రొఫైల్ అప్‌డేట్ చేస్తూ లోపం వచ్చింది. దయచేసి మళ్లీ ప్రయత్నించండి.', /* |---------------------------------------------------------------| */ diff --git a/packages/javascript/src/api/getMeFederatedAssociations.ts b/packages/javascript/src/api/getMeFederatedAssociations.ts new file mode 100644 index 000000000..8cad0d26e --- /dev/null +++ b/packages/javascript/src/api/getMeFederatedAssociations.ts @@ -0,0 +1,102 @@ +/** + * 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 {FederatedAssociation} from '../models/federated-association'; + +/** + * Configuration for the `getMeFederatedAssociations` request. + */ +export interface GetMeFederatedAssociationsConfig 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; + /** + * The absolute API endpoint. + */ + url?: string; +} + +/** + * Retrieves the identity provider accounts linked to the signed-in user. + * + * An account provisioned just-in-time from a social or enterprise connection has at least one + * association; a user who registered locally has none. + * + * @param config - Request configuration. + * @returns The list of associations, empty when the account is purely local. + * @example + * ```ts + * const associations = await getMeFederatedAssociations({baseUrl: 'https://api.asgardeo.io/t/'}); + * const isFederated = associations.length > 0; + * ``` + */ +const getMeFederatedAssociations = async ({ + url, + baseUrl, + fetcher, + ...requestConfig +}: GetMeFederatedAssociationsConfig): Promise => { + try { + // eslint-disable-next-line no-new + new URL(url ?? baseUrl); + } catch (error) { + throw new AsgardeoAPIError( + `Invalid URL provided. ${error?.toString()}`, + 'getMeFederatedAssociations-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}/api/users/v1/me/federated-associations`; + + const response: Response = await fetchFn(resolvedUrl, { + ...requestConfig, + headers: { + Accept: 'application/json', + ...requestConfig.headers, + }, + method: 'GET', + }); + + if (!response?.ok) { + const errorText: string = await response.text(); + + throw new AsgardeoAPIError( + errorText, + 'getMeFederatedAssociations-ResponseError-001', + 'javascript', + response.status, + response.statusText, + ); + } + + const associations: unknown = await response.json(); + + return Array.isArray(associations) ? (associations as FederatedAssociation[]) : []; +}; + +export default getMeFederatedAssociations; diff --git a/packages/javascript/src/index.ts b/packages/javascript/src/index.ts index 5a574b088..bec92dc65 100644 --- a/packages/javascript/src/index.ts +++ b/packages/javascript/src/index.ts @@ -34,6 +34,9 @@ export {default as executeEmbeddedSignUpFlow} from './api/executeEmbeddedSignUpF export {default as getUserInfo} from './api/getUserInfo'; export {default as getScim2Me} from './api/getScim2Me'; export type {GetScim2MeConfig} from './api/getScim2Me'; +export {default as getMeFederatedAssociations} from './api/getMeFederatedAssociations'; +export type {GetMeFederatedAssociationsConfig} from './api/getMeFederatedAssociations'; +export type {FederatedAssociation} from './models/federated-association'; export {default as getSchemas} from './api/getSchemas'; export type {GetSchemasConfig} from './api/getSchemas'; export {default as getAllOrganizations} from './api/getAllOrganizations'; diff --git a/packages/javascript/src/models/federated-association.ts b/packages/javascript/src/models/federated-association.ts new file mode 100644 index 000000000..a001fdc7b --- /dev/null +++ b/packages/javascript/src/models/federated-association.ts @@ -0,0 +1,41 @@ +/** + * 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. + */ + +/** + * A link between the local user account and an identity in an external identity provider, + * created when the user is provisioned just-in-time or when the account is linked. + */ +export interface FederatedAssociation { + /** + * The user's identifier at the identity provider. + */ + federatedUserId?: string; + /** + * Unique identifier of the association. + */ + id?: string; + /** + * The identity provider the account is linked to. + */ + idp?: { + displayName?: string; + id?: string; + imageUrl?: string; + name?: string; + }; +} diff --git a/packages/nextjs/src/client/components/presentation/UserProfile/UserProfile.tsx b/packages/nextjs/src/client/components/presentation/UserProfile/UserProfile.tsx index bfd86ea0e..530cf7d0b 100644 --- a/packages/nextjs/src/client/components/presentation/UserProfile/UserProfile.tsx +++ b/packages/nextjs/src/client/components/presentation/UserProfile/UserProfile.tsx @@ -19,15 +19,28 @@ 'use client'; import {Schema, User} from '@asgardeo/node'; -import {BaseUserProfile, BaseUserProfileProps, useUser} from '@asgardeo/react'; -import {FC, ReactElement} from 'react'; +import {BaseUserProfile, BaseUserProfileProps, useTranslation, useUser} from '@asgardeo/react'; +import {FC, useEffect, useState, ReactElement} from 'react'; +import getFederatedProfileLock, {FederatedProfileLock} from '../../../../server/actions/getFederatedProfileLock'; import getSessionId from '../../../../server/actions/getSessionId'; /** * Props for the UserProfile component. * Extends BaseUserProfileProps but makes the user prop optional since it will be obtained from useAsgardeo */ -export type UserProfileProps = Omit; +export type UserProfileProps = Omit< + BaseUserProfileProps, + 'user' | 'profile' | 'flattenedProfile' | 'schemas' | 'editable' +> & { + /** + * Whether the profile can be edited. + * + * `'auto'` decides per user: on Asgardeo, an account provisioned from a social or enterprise + * connection is rendered read-only, because the identity provider owns its attributes and the + * server rejects updates to them. Everywhere else the profile stays editable. + */ + editable?: BaseUserProfileProps['editable'] | 'auto'; +}; /** * UserProfile component displays the authenticated user's profile information in a @@ -52,23 +65,81 @@ export type UserProfileProps = Omit * ``` */ -const UserProfile: FC = ({...rest}: UserProfileProps): ReactElement => { +const UserProfile: FC = ({editable, preferences, ...rest}: UserProfileProps): ReactElement => { const {profile, flattenedProfile, schemas, onUpdateProfile, updateProfile} = useUser(); + const {t} = useTranslation(preferences?.i18n); + + const [error, setError] = useState(null); + const [lock, setLock] = useState(undefined); + + useEffect((): (() => void) | undefined => { + if (editable !== 'auto') { + return undefined; + } + + let isStale: boolean = false; + + (async (): Promise => { + const resolved: FederatedProfileLock = await getFederatedProfileLock(); + + if (!isStale) { + setLock(resolved); + } + })(); + + return (): void => { + isStale = true; + }; + }, [editable]); const handleProfileUpdate = async (payload: any): Promise => { + setError(null); + const result: {data: {user: User}; error: string; success: boolean} = await updateProfile( payload, (await getSessionId()) as string, ); + + if (result?.success === false) { + // The server owns the attributes of accounts linked to an identity provider and rejects the + // update. Say so in plain words and stop offering edits for the rest of the session. + if (String(result?.error ?? '').includes('User attribute update is not allowed')) { + setError(t('user.profile.update.not.allowed.error')); + setLock({readOnly: true}); + } else { + setError(t('user.profile.update.generic.error')); + } + + return; + } + onUpdateProfile(result?.data?.user); }; + const resolvedEditable: BaseUserProfileProps['editable'] = editable === 'auto' ? !lock?.readOnly : editable; + + const resolveReadOnlyNote = (): string | undefined => { + if (!lock?.readOnly) { + return undefined; + } + + return lock.provider + ? t('user.profile.readonly.federated', {provider: lock.provider}) + : t('user.profile.update.not.allowed.error'); + }; + + const readOnlyNote: string | undefined = resolveReadOnlyNote(); + return ( ); diff --git a/packages/nextjs/src/server/actions/getFederatedProfileLock.ts b/packages/nextjs/src/server/actions/getFederatedProfileLock.ts new file mode 100644 index 000000000..c05b08a43 --- /dev/null +++ b/packages/nextjs/src/server/actions/getFederatedProfileLock.ts @@ -0,0 +1,79 @@ +/** + * 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. + */ + +'use server'; + +import {Config, FederatedAssociation, Platform, getMeFederatedAssociations, identifyPlatform} from '@asgardeo/node'; +import AsgardeoNextClient from '../../AsgardeoNextClient'; + +/** + * Whether the signed-in user's profile has to be rendered read-only, and which identity provider owns it. + */ +export interface FederatedProfileLock { + provider?: string; + readOnly: boolean; +} + +/** + * Works out whether the signed-in user's profile attributes are owned by an identity provider. + * + * Asgardeo refuses attribute updates for accounts provisioned from a social or enterprise connection, + * so those profiles are rendered read-only. WSO2 Identity Server allows the same updates, so the lock + * is never applied there. Any failure resolves to "editable" and lets the server have the final say. + */ +const getFederatedProfileLock = async (sessionId?: string): Promise => { + try { + const client: AsgardeoNextClient = AsgardeoNextClient.getInstance(); + const config: Config = (await client.getConfiguration()) as Config; + + if (identifyPlatform(config) !== Platform.Asgardeo) { + return {readOnly: false}; + } + + let resolvedSessionId: string | undefined = sessionId; + + if (!resolvedSessionId) { + const {default: getSessionId} = await import('./getSessionId'); + resolvedSessionId = await getSessionId(); + } + + if (!resolvedSessionId) { + return {readOnly: false}; + } + + const accessToken: string = await client.getAccessToken(resolvedSessionId); + + if (!accessToken) { + return {readOnly: false}; + } + + const associations: FederatedAssociation[] = await getMeFederatedAssociations({ + baseUrl: config.baseUrl, + headers: {Authorization: `Bearer ${accessToken}`}, + }); + + return { + provider: associations[0]?.idp?.displayName || associations[0]?.idp?.name, + readOnly: associations.length > 0, + }; + } catch { + return {readOnly: false}; + } +}; + +export default getFederatedProfileLock; diff --git a/packages/react/src/api/getMeFederatedAssociations.ts b/packages/react/src/api/getMeFederatedAssociations.ts new file mode 100644 index 000000000..df3229831 --- /dev/null +++ b/packages/react/src/api/getMeFederatedAssociations.ts @@ -0,0 +1,75 @@ +/** + * 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 { + AsgardeoSPAClient, + FederatedAssociation, + HttpRequestConfig, + HttpResponse, + getMeFederatedAssociations as baseGetMeFederatedAssociations, + GetMeFederatedAssociationsConfig as BaseGetMeFederatedAssociationsConfig, +} from '@asgardeo/browser'; + +/** + * Configuration for the React `getMeFederatedAssociations` request. + */ +export interface GetMeFederatedAssociationsConfig extends Omit { + /** + * Optional custom fetcher function. If not provided, the Asgardeo 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 identity provider accounts linked to the signed-in user, using the SPA client so the + * request carries the user's access token. + */ +const getMeFederatedAssociations = async ({ + fetcher, + instanceId = 0, + ...requestConfig +}: GetMeFederatedAssociationsConfig): Promise => { + const defaultFetcher = async (url: string, config: RequestInit): Promise => { + const client: AsgardeoSPAClient = AsgardeoSPAClient.getInstance(instanceId); + const httpClient: (config: HttpRequestConfig) => Promise> = client.httpRequest.bind(client); + const response: HttpResponse = await httpClient({ + 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 baseGetMeFederatedAssociations({ + ...requestConfig, + fetcher: fetcher || defaultFetcher, + }); +}; + +export default getMeFederatedAssociations; diff --git a/packages/react/src/components/presentation/UserProfile/BaseUserProfile.test.tsx b/packages/react/src/components/presentation/UserProfile/BaseUserProfile.test.tsx index 957e22f1a..397ca4f08 100644 --- a/packages/react/src/components/presentation/UserProfile/BaseUserProfile.test.tsx +++ b/packages/react/src/components/presentation/UserProfile/BaseUserProfile.test.tsx @@ -177,4 +177,36 @@ describe('BaseUserProfile with SCIM2 schemas', () => { expect(text).not.toContain('Last Name'); expect(container.querySelectorAll('button[title="Edit"]').length).toBe(0); }); + + it('accepts a predicate for `editable` and shows the read-only note when it returns false', () => { + const editable = vi.fn().mockReturnValue(false); + + const {container} = render( + , + ); + + expect(editable).toHaveBeenCalledWith(flattenedProfile); + expect(container.textContent).toContain('Managed by your Google account.'); + expect(container.querySelector('input')).toBeNull(); + }); + + it('does not show the read-only note while the profile is editable', () => { + const {container} = render( + true} + readOnlyNote="Managed by your Google account." + />, + ); + + expect(container.textContent).not.toContain('Managed by your Google account.'); + }); }); diff --git a/packages/react/src/components/presentation/UserProfile/BaseUserProfile.tsx b/packages/react/src/components/presentation/UserProfile/BaseUserProfile.tsx index 2236b45c2..f4d26a856 100644 --- a/packages/react/src/components/presentation/UserProfile/BaseUserProfile.tsx +++ b/packages/react/src/components/presentation/UserProfile/BaseUserProfile.tsx @@ -68,7 +68,11 @@ export interface BaseUserProfileProps { cardLayout?: boolean; className?: string; displayNameAttributes?: string[]; - editable?: boolean; + /** + * Whether the profile can be edited. Pass a predicate to decide from the profile itself, for example + * to render a read-only profile for accounts whose attributes are owned by an identity provider. + */ + editable?: boolean | ((profile?: User) => boolean); error?: string | null; fallback?: ReactElement; flattenedProfile?: User; @@ -85,6 +89,11 @@ export interface BaseUserProfileProps { */ preferences?: Preferences; profile?: User; + /** + * Explanation shown above a read-only profile, for example when the account is managed by an + * identity provider. Ignored while the profile is editable. + */ + readOnlyNote?: string; schemas?: Schema[]; showFields?: string[]; @@ -135,6 +144,7 @@ const BaseUserProfile: FC = ({ title, attributeMapping = {}, editable = true, + readOnlyNote, onOpenChange, onUpdate, open = false, @@ -146,6 +156,9 @@ const BaseUserProfile: FC = ({ displayNameAttributes = [], }: BaseUserProfileProps): ReactElement => { const {theme, colorScheme} = useTheme(); + // `editable` may be a predicate so callers can decide per user; resolve it once for the render. + const isProfileEditable: boolean = + typeof editable === 'function' ? editable(flattenedProfile ?? profile) !== false : editable !== false; const [editedUser, setEditedUser] = useState(flattenedProfile || profile); const [editingFields, setEditingFields] = useState>({}); const {t} = useTranslation(preferences?.i18n); @@ -379,7 +392,8 @@ const BaseUserProfile: FC = ({ const hasValues: any = Array.isArray(value) ? value.length > 0 : value !== undefined && value !== null && value !== ''; - const isEditable: any = editable && !isReadOnlyMutability(mutability) && !readonlyFields.includes(name || ''); + const isEditable: any = + isProfileEditable && !isReadOnlyMutability(mutability) && !readonlyFields.includes(name || ''); if (isEditing && onEditValue && isEditable) { let currentValue: any; @@ -524,7 +538,8 @@ const BaseUserProfile: FC = ({ } const hasValue: any = value !== undefined && value !== null && value !== ''; - const isEditable: any = editable && !isReadOnlyMutability(mutability) && !readonlyFields.includes(name || ''); + const isEditable: any = + isProfileEditable && !isReadOnlyMutability(mutability) && !readonlyFields.includes(name || ''); let displayValue: string; if (hasValue) { @@ -565,7 +580,8 @@ const BaseUserProfile: FC = ({ const isFieldEditing: any = editingFields[schema.name]; const isReadonlyField: any = readonlyFields.includes(schema.name); - const shouldShow: any = hasValue || isFieldEditing || (editable && isReadWriteMutability(schema.mutability)); + const shouldShow: any = + hasValue || isFieldEditing || (isProfileEditable && isReadWriteMutability(schema.mutability)); if (!shouldShow) { return null; @@ -585,7 +601,7 @@ const BaseUserProfile: FC = ({ () => toggleFieldEdit(schema.name!), )} - {editable && !isReadOnlyMutability(schema.mutability) && !isReadonlyField && ( + {isProfileEditable && !isReadOnlyMutability(schema.mutability) && !isReadonlyField && (
{isFieldEditing && ( <> @@ -683,6 +699,14 @@ const BaseUserProfile: FC = ({ const profileContent: any = ( + {!isProfileEditable && readOnlyNote && ( + + {readOnlyNote} + + )} {error && ( = ({ .filter((schema: any) => { if (!schema.name || !shouldShowField(schema.name)) return false; - if (!editable) { + if (!isProfileEditable) { const value: any = flattenedProfile && schema.name ? flattenedProfile[schema.name] : undefined; return value !== undefined && value !== '' && value !== null; } diff --git a/packages/react/src/components/presentation/UserProfile/UserProfile.tsx b/packages/react/src/components/presentation/UserProfile/UserProfile.tsx index b4b8bf3c1..396649eed 100644 --- a/packages/react/src/components/presentation/UserProfile/UserProfile.tsx +++ b/packages/react/src/components/presentation/UserProfile/UserProfile.tsx @@ -16,10 +16,11 @@ * under the License. */ -import {AsgardeoError, User} from '@asgardeo/browser'; -import {FC, ReactElement, useState} from 'react'; +import {AsgardeoError, Config, FederatedAssociation, Platform, User, identifyPlatform} from '@asgardeo/browser'; +import {FC, ReactElement, useEffect, useState} from 'react'; // eslint-disable-next-line import/no-named-as-default import BaseUserProfile, {BaseUserProfileProps} from './BaseUserProfile'; +import getMeFederatedAssociations from '../../../api/getMeFederatedAssociations'; import updateMeProfile from '../../../api/updateMeProfile'; import useAsgardeo from '../../../contexts/Asgardeo/useAsgardeo'; import useUser from '../../../contexts/User/useUser'; @@ -29,7 +30,19 @@ import useTranslation from '../../../hooks/useTranslation'; * Props for the UserProfile component. * Extends BaseUserProfileProps but makes the user prop optional since it will be obtained from useAsgardeo */ -export type UserProfileProps = Omit; +export type UserProfileProps = Omit< + BaseUserProfileProps, + 'user' | 'profile' | 'flattenedProfile' | 'schemas' | 'editable' +> & { + /** + * Whether the profile can be edited. + * + * `'auto'` decides per user: on Asgardeo, an account provisioned from a social or enterprise + * connection is rendered read-only, because the identity provider owns its attributes and the + * server rejects updates to them. Everywhere else the profile stays editable. + */ + editable?: BaseUserProfileProps['editable'] | 'auto'; +}; /** * UserProfile component displays the authenticated user's profile information in a @@ -64,12 +77,54 @@ export type UserProfileProps = Omit * ``` */ -const UserProfile: FC = ({preferences, ...rest}: UserProfileProps): ReactElement => { +const UserProfile: FC = ({preferences, editable, ...rest}: UserProfileProps): ReactElement => { const {baseUrl, instanceId} = useAsgardeo(); const {profile, flattenedProfile, schemas, onUpdateProfile} = useUser(); const {t} = useTranslation(preferences?.i18n); const [error, setError] = useState(null); + /** + * Resolved value of `editable="auto"`: `undefined` until the lookup finishes, so the profile stays + * editable rather than flickering into a read-only state and back. + */ + const [isFederatedAccount, setIsFederatedAccount] = useState(undefined); + const [identityProviderName, setIdentityProviderName] = useState(undefined); + + useEffect((): (() => void) | undefined => { + if (editable !== 'auto') { + return undefined; + } + + // Only Asgardeo refuses these updates; on Identity Server the same account is editable. + if (identifyPlatform({baseUrl} as Config) !== Platform.Asgardeo) { + setIsFederatedAccount(false); + return undefined; + } + + let isStale: boolean = false; + + (async (): Promise => { + try { + const associations: FederatedAssociation[] = await getMeFederatedAssociations({baseUrl, instanceId}); + + if (isStale) { + return; + } + + setIsFederatedAccount(associations.length > 0); + setIdentityProviderName(associations[0]?.idp?.displayName || associations[0]?.idp?.name); + } catch { + // The lookup is a convenience; if it fails, leave the profile editable and let the server decide. + if (!isStale) { + setIsFederatedAccount(false); + } + } + })(); + + return (): void => { + isStale = true; + }; + }, [editable, baseUrl, instanceId]); const handleProfileUpdate = async (payload: any): Promise => { setError(null); @@ -84,10 +139,32 @@ const UserProfile: FC = ({preferences, ...rest}: UserProfilePr message = caughtError?.message; } + // The server owns the attributes of accounts linked to an identity provider and rejects the + // update. Say so in plain words and stop offering edits for the rest of the session. + if (String(message).includes('User attribute update is not allowed')) { + message = t('user.profile.update.not.allowed.error'); + setIsFederatedAccount(true); + } + setError(message); } }; + const resolvedEditable: BaseUserProfileProps['editable'] = + editable === 'auto' ? isFederatedAccount !== true : editable; + + const resolveReadOnlyNote = (): string | undefined => { + if (isFederatedAccount !== true) { + return undefined; + } + + return identityProviderName + ? t('user.profile.readonly.federated', {provider: identityProviderName}) + : t('user.profile.update.not.allowed.error'); + }; + + const readOnlyNote: string | undefined = resolveReadOnlyNote(); + return ( = ({preferences, ...rest}: UserProfilePr schemas={schemas} onUpdate={handleProfileUpdate} error={error} + editable={resolvedEditable} + readOnlyNote={readOnlyNote} preferences={preferences} {...rest} /> diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 378cd8885..37e700dac 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -293,6 +293,8 @@ export {default as getSchemas, GetSchemasConfig} from './api/getSchemas'; export {default as updateMeProfile, UpdateMeProfileConfig} from './api/updateMeProfile'; export {default as getMeProfile} from './api/getScim2Me'; export * from './api/getScim2Me'; +export {default as getMeFederatedAssociations} from './api/getMeFederatedAssociations'; +export type {GetMeFederatedAssociationsConfig} from './api/getMeFederatedAssociations'; export { AsgardeoRuntimeError, From e5f71979fe6f7bf5157eb392e8622bdfcdbbfd85 Mon Sep 17 00:00:00 2001 From: DonOmalVindula Date: Tue, 8 Sep 2026 13:33:42 +0530 Subject: [PATCH 2/4] fix(react, nextjs): no edit-control flash, and apply the rule to the profile modal - Treat an unresolved editable="auto" lookup as read-only, so a managed account never shows edit controls for a moment before they disappear. - forwards editable to the profile it opens from "Manage profile", which previously always rendered as editable. - In popup mode the lookup waits until the profile is opened, so mounting a dropdown costs no request. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/readonly-federated-profile.md | 3 +++ .../presentation/UserDropdown/UserDropdown.tsx | 18 ++++++++++++++---- .../presentation/UserProfile/UserProfile.tsx | 12 +++++++++--- .../presentation/UserDropdown/UserDropdown.tsx | 18 ++++++++++++++---- .../presentation/UserProfile/UserProfile.tsx | 11 ++++++++--- 5 files changed, 48 insertions(+), 14 deletions(-) diff --git a/.changeset/readonly-federated-profile.md b/.changeset/readonly-federated-profile.md index 7618db8e0..ae7d141c1 100644 --- a/.changeset/readonly-federated-profile.md +++ b/.changeset/readonly-federated-profile.md @@ -10,6 +10,9 @@ Render a read-only profile for users whose attributes are owned by an identity p Asgardeo rejects attribute updates for accounts provisioned from a social or enterprise connection, so the profile used to offer edit controls that always failed with a raw SCIM error. - `` accepts `editable="auto"` in React and Next.js. On Asgardeo it looks up the signed-in user's federated associations and, when the account is linked to a connection, renders the profile read-only with a short note naming the provider. On WSO2 Identity Server, where the same updates succeed, the profile stays editable. +- `` forwards `editable` to the profile it opens from "Manage profile", so the modal follows the same rule as the page. - `BaseUserProfile` accepts a predicate for `editable`, so applications can decide per user without any lookup, and a `readOnlyNote` to explain why editing is unavailable. - A rejected update is now reported in plain words instead of the raw SCIM error, and switches the profile to read-only for the rest of the session. The Next.js `` previously ignored update failures entirely. - New API `getMeFederatedAssociations` in `@asgardeo/javascript` and `@asgardeo/react`, plus the `signup`-style texts `user.profile.readonly.federated` and `user.profile.update.not.allowed.error` in all i18n bundles. + +While `auto` is resolving the profile stays read-only, so a managed account never briefly shows edit controls; in popup mode the lookup is deferred until the profile is actually opened. diff --git a/packages/nextjs/src/client/components/presentation/UserDropdown/UserDropdown.tsx b/packages/nextjs/src/client/components/presentation/UserDropdown/UserDropdown.tsx index 6672c1dd9..73d592b78 100644 --- a/packages/nextjs/src/client/components/presentation/UserDropdown/UserDropdown.tsx +++ b/packages/nextjs/src/client/components/presentation/UserDropdown/UserDropdown.tsx @@ -21,7 +21,7 @@ import {BaseUserDropdown, BaseUserDropdownProps} from '@asgardeo/react'; import {FC, ReactElement, ReactNode, useState} from 'react'; import useAsgardeo from '../../../contexts/Asgardeo/useAsgardeo'; -import UserProfile from '../UserProfile/UserProfile.js'; +import UserProfile, {UserProfileProps} from '../UserProfile/UserProfile.js'; /** * Render props data passed to the children function @@ -46,6 +46,13 @@ export interface UserDropdownRenderProps { * Extends BaseUserDropdownProps but excludes user, onManageProfile, and onSignOut since they're handled internally */ export type UserDropdownProps = Omit & { + /** + * Whether the profile shown by the "Manage profile" item can be edited. Forwarded to + * ``, so `'auto'` renders a read-only profile for accounts whose attributes + * are owned by an identity provider. + */ + editable?: UserProfileProps['editable']; + /** * Render prop function that receives user state and actions. * When provided, this completely replaces the default dropdown rendering. @@ -114,6 +121,7 @@ const UserDropdown: FC = ({ renderTrigger, renderDropdown, onSignOut, + editable, ...rest }: UserDropdownProps): ReactElement => { const {user, isLoading, signOut} = useAsgardeo(); @@ -149,7 +157,7 @@ const UserDropdown: FC = ({ return ( <> {children(renderProps)} - + ); } @@ -172,7 +180,7 @@ const UserDropdown: FC = ({ /> )} {/* Note: renderDropdown would need BaseUserDropdown modifications to implement properly */} - + ); } @@ -187,7 +195,9 @@ const UserDropdown: FC = ({ onSignOut={handleSignOut} {...rest} /> - {isProfileOpen && } + {isProfileOpen && ( + + )} ); }; diff --git a/packages/nextjs/src/client/components/presentation/UserProfile/UserProfile.tsx b/packages/nextjs/src/client/components/presentation/UserProfile/UserProfile.tsx index 530cf7d0b..0278b74be 100644 --- a/packages/nextjs/src/client/components/presentation/UserProfile/UserProfile.tsx +++ b/packages/nextjs/src/client/components/presentation/UserProfile/UserProfile.tsx @@ -72,8 +72,11 @@ const UserProfile: FC = ({editable, preferences, ...rest}: Use const [error, setError] = useState(null); const [lock, setLock] = useState(undefined); + // In popup mode the profile is mounted with the dropdown; don't spend a request until it is opened. + const {mode: profileMode, open: isProfileOpen} = rest as {mode?: string; open?: boolean}; + useEffect((): (() => void) | undefined => { - if (editable !== 'auto') { + if (editable !== 'auto' || (profileMode === 'popup' && !isProfileOpen)) { return undefined; } @@ -90,7 +93,7 @@ const UserProfile: FC = ({editable, preferences, ...rest}: Use return (): void => { isStale = true; }; - }, [editable]); + }, [editable, profileMode, isProfileOpen]); const handleProfileUpdate = async (payload: any): Promise => { setError(null); @@ -116,7 +119,10 @@ const UserProfile: FC = ({editable, preferences, ...rest}: Use onUpdateProfile(result?.data?.user); }; - const resolvedEditable: BaseUserProfileProps['editable'] = editable === 'auto' ? !lock?.readOnly : editable; + // Until the lookup resolves the profile stays read-only, so a managed account never flashes + // edit controls that the server would refuse. + const resolvedEditable: BaseUserProfileProps['editable'] = + editable === 'auto' ? lock !== undefined && !lock.readOnly : editable; const resolveReadOnlyNote = (): string | undefined => { if (!lock?.readOnly) { diff --git a/packages/react/src/components/presentation/UserDropdown/UserDropdown.tsx b/packages/react/src/components/presentation/UserDropdown/UserDropdown.tsx index a88f0d776..341202f00 100644 --- a/packages/react/src/components/presentation/UserDropdown/UserDropdown.tsx +++ b/packages/react/src/components/presentation/UserDropdown/UserDropdown.tsx @@ -20,7 +20,7 @@ import {FlowMetadataResponse} from '@asgardeo/browser'; import {FC, ReactElement, ReactNode, useState} from 'react'; import {BaseUserDropdown as BaseUserDropdownComponent, BaseUserDropdownProps} from './BaseUserDropdown'; import useAsgardeo from '../../../contexts/Asgardeo/useAsgardeo'; -import UserProfile from '../UserProfile/UserProfile'; +import UserProfile, {UserProfileProps} from '../UserProfile/UserProfile'; /** * Render props data passed to the children function @@ -47,6 +47,13 @@ export interface UserDropdownRenderProps { * Extends BaseUserDropdownProps but excludes user, onManageProfile, and onSignOut since they're handled internally */ export type UserDropdownProps = Omit & { + /** + * Whether the profile shown by the "Manage profile" item can be edited. Forwarded to + * ``, so `'auto'` renders a read-only profile for accounts whose attributes + * are owned by an identity provider. + */ + editable?: UserProfileProps['editable']; + /** * Render prop function that receives user state and actions. * When provided, this completely replaces the default dropdown rendering. @@ -115,6 +122,7 @@ const UserDropdown: FC = ({ renderTrigger, renderDropdown, onSignOut, + editable, ...rest }: UserDropdownProps): ReactElement => { const {user, isLoading, signOut, meta} = useAsgardeo(); @@ -151,7 +159,7 @@ const UserDropdown: FC = ({ return ( <> {children(renderProps)} - + ); } @@ -174,7 +182,7 @@ const UserDropdown: FC = ({ /> )} {/* Note: renderDropdown would need BaseUserDropdown modifications to implement properly */} - + ); } @@ -189,7 +197,9 @@ const UserDropdown: FC = ({ onSignOut={handleSignOut} {...rest} /> - {isProfileOpen && } + {isProfileOpen && ( + + )} ); }; diff --git a/packages/react/src/components/presentation/UserProfile/UserProfile.tsx b/packages/react/src/components/presentation/UserProfile/UserProfile.tsx index 396649eed..e34f96f92 100644 --- a/packages/react/src/components/presentation/UserProfile/UserProfile.tsx +++ b/packages/react/src/components/presentation/UserProfile/UserProfile.tsx @@ -90,8 +90,11 @@ const UserProfile: FC = ({preferences, editable, ...rest}: Use const [isFederatedAccount, setIsFederatedAccount] = useState(undefined); const [identityProviderName, setIdentityProviderName] = useState(undefined); + // In popup mode the profile is mounted with the dropdown; don't spend a request until it is opened. + const {mode: profileMode, open: isProfileOpen} = rest as {mode?: string; open?: boolean}; + useEffect((): (() => void) | undefined => { - if (editable !== 'auto') { + if (editable !== 'auto' || (profileMode === 'popup' && !isProfileOpen)) { return undefined; } @@ -124,7 +127,7 @@ const UserProfile: FC = ({preferences, editable, ...rest}: Use return (): void => { isStale = true; }; - }, [editable, baseUrl, instanceId]); + }, [editable, baseUrl, instanceId, profileMode, isProfileOpen]); const handleProfileUpdate = async (payload: any): Promise => { setError(null); @@ -150,8 +153,10 @@ const UserProfile: FC = ({preferences, editable, ...rest}: Use } }; + // Until the lookup resolves the profile stays read-only, so a managed account never flashes + // edit controls that the server would refuse. const resolvedEditable: BaseUserProfileProps['editable'] = - editable === 'auto' ? isFederatedAccount !== true : editable; + editable === 'auto' ? isFederatedAccount === false : editable; const resolveReadOnlyNote = (): string | undefined => { if (isFederatedAccount !== true) { From 3cf0f5d5628aea9564f9b866cad7ddf2c7f460dd Mon Sep 17 00:00:00 2001 From: DonOmalVindula Date: Tue, 8 Sep 2026 13:47:19 +0530 Subject: [PATCH 3/4] feat(react): show a loading state while the profile resolves BaseUserProfile renders a spinner in place of the fields whenever isLoading is set, in both inline and popup modes, and the wrappers set it while an editable="auto" lookup is in flight. Adds the user.profile.loading text. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/readonly-federated-profile.md | 2 +- packages/i18n/src/models/i18n.ts | 1 + packages/i18n/src/translations/en-US.ts | 2 ++ packages/i18n/src/translations/fr-FR.ts | 2 ++ packages/i18n/src/translations/hi-IN.ts | 2 ++ packages/i18n/src/translations/ja-JP.ts | 2 ++ packages/i18n/src/translations/pt-BR.ts | 2 ++ packages/i18n/src/translations/pt-PT.ts | 2 ++ packages/i18n/src/translations/si-LK.ts | 2 ++ packages/i18n/src/translations/ta-IN.ts | 2 ++ packages/i18n/src/translations/te-IN.ts | 2 ++ .../presentation/UserProfile/UserProfile.tsx | 5 +++++ .../UserProfile/BaseUserProfile.styles.ts | 11 +++++++++++ .../UserProfile/BaseUserProfile.tsx | 18 ++++++++++++++++-- .../presentation/UserProfile/UserProfile.tsx | 5 +++++ 15 files changed, 57 insertions(+), 3 deletions(-) diff --git a/.changeset/readonly-federated-profile.md b/.changeset/readonly-federated-profile.md index ae7d141c1..0a4ff65ae 100644 --- a/.changeset/readonly-federated-profile.md +++ b/.changeset/readonly-federated-profile.md @@ -15,4 +15,4 @@ Asgardeo rejects attribute updates for accounts provisioned from a social or ent - A rejected update is now reported in plain words instead of the raw SCIM error, and switches the profile to read-only for the rest of the session. The Next.js `` previously ignored update failures entirely. - New API `getMeFederatedAssociations` in `@asgardeo/javascript` and `@asgardeo/react`, plus the `signup`-style texts `user.profile.readonly.federated` and `user.profile.update.not.allowed.error` in all i18n bundles. -While `auto` is resolving the profile stays read-only, so a managed account never briefly shows edit controls; in popup mode the lookup is deferred until the profile is actually opened. +While `auto` is resolving, the profile shows a loading state rather than edit controls it may have to take away; `BaseUserProfile` now renders that state whenever `isLoading` is set, in both inline and popup modes. In popup mode the lookup is deferred until the profile is actually opened. diff --git a/packages/i18n/src/models/i18n.ts b/packages/i18n/src/models/i18n.ts index 427bb3594..4161ff04c 100644 --- a/packages/i18n/src/models/i18n.ts +++ b/packages/i18n/src/models/i18n.ts @@ -116,6 +116,7 @@ export interface I18nTranslations { 'user.profile.heading': string; 'user.profile.update.generic.error': string; + 'user.profile.loading': string; 'user.profile.readonly.federated': string; 'user.profile.update.not.allowed.error': string; diff --git a/packages/i18n/src/translations/en-US.ts b/packages/i18n/src/translations/en-US.ts index 665f73714..83518f37e 100644 --- a/packages/i18n/src/translations/en-US.ts +++ b/packages/i18n/src/translations/en-US.ts @@ -118,6 +118,8 @@ const translations: I18nTranslations = { /* |---------------------------------------------------------------| */ 'user.profile.heading': 'Profile', + + 'user.profile.loading': 'Loading profile', 'user.profile.update.generic.error': 'An error occurred while updating your profile. Please try again.', 'user.profile.readonly.federated': 'This profile is managed by your {provider} account and cannot be edited here.', 'user.profile.update.not.allowed.error': diff --git a/packages/i18n/src/translations/fr-FR.ts b/packages/i18n/src/translations/fr-FR.ts index 3d8f7477e..fdaadaceb 100644 --- a/packages/i18n/src/translations/fr-FR.ts +++ b/packages/i18n/src/translations/fr-FR.ts @@ -118,6 +118,8 @@ const translations: I18nTranslations = { 'user.profile.heading': 'Profil', + 'user.profile.loading': 'Chargement du profil', + 'user.profile.readonly.federated': 'Ce profil est géré par votre compte {provider} et ne peut pas être modifié ici.', 'user.profile.update.not.allowed.error': diff --git a/packages/i18n/src/translations/hi-IN.ts b/packages/i18n/src/translations/hi-IN.ts index 52f987d6d..ac5d1c252 100644 --- a/packages/i18n/src/translations/hi-IN.ts +++ b/packages/i18n/src/translations/hi-IN.ts @@ -117,6 +117,8 @@ const translations: I18nTranslations = { 'user.profile.heading': 'प्रोफ़ाइल', + 'user.profile.loading': 'प्रोफ़ाइल लोड हो रही है', + 'user.profile.readonly.federated': 'यह प्रोफ़ाइल आपके {provider} खाते द्वारा प्रबंधित है और इसे यहाँ संपादित नहीं किया जा सकता।', diff --git a/packages/i18n/src/translations/ja-JP.ts b/packages/i18n/src/translations/ja-JP.ts index 9e635456e..f22cc4cba 100644 --- a/packages/i18n/src/translations/ja-JP.ts +++ b/packages/i18n/src/translations/ja-JP.ts @@ -117,6 +117,8 @@ const translations: I18nTranslations = { 'user.profile.heading': 'プロフィール', + 'user.profile.loading': 'プロフィールを読み込んでいます', + 'user.profile.readonly.federated': 'このプロフィールは {provider} アカウントで管理されているため、ここでは編集できません。', diff --git a/packages/i18n/src/translations/pt-BR.ts b/packages/i18n/src/translations/pt-BR.ts index b6a9e1c87..5c43573df 100644 --- a/packages/i18n/src/translations/pt-BR.ts +++ b/packages/i18n/src/translations/pt-BR.ts @@ -117,6 +117,8 @@ const translations: I18nTranslations = { 'user.profile.heading': 'Perfil', + 'user.profile.loading': 'Carregando perfil', + 'user.profile.readonly.federated': 'Este perfil é gerenciado pela sua conta {provider} e não pode ser editado aqui.', 'user.profile.update.not.allowed.error': diff --git a/packages/i18n/src/translations/pt-PT.ts b/packages/i18n/src/translations/pt-PT.ts index f3f0cd369..b7a925400 100644 --- a/packages/i18n/src/translations/pt-PT.ts +++ b/packages/i18n/src/translations/pt-PT.ts @@ -117,6 +117,8 @@ const translations: I18nTranslations = { 'user.profile.heading': 'Perfil', + 'user.profile.loading': 'A carregar o perfil', + 'user.profile.readonly.federated': 'Este perfil é gerido pela sua conta {provider} e não pode ser editado aqui.', 'user.profile.update.not.allowed.error': diff --git a/packages/i18n/src/translations/si-LK.ts b/packages/i18n/src/translations/si-LK.ts index 0fe27c187..f144ab249 100644 --- a/packages/i18n/src/translations/si-LK.ts +++ b/packages/i18n/src/translations/si-LK.ts @@ -117,6 +117,8 @@ const translations: I18nTranslations = { 'user.profile.heading': 'පැතිකඩ', + 'user.profile.loading': 'පැතිකඩ පූරණය වෙමින්', + 'user.profile.readonly.federated': 'මෙම පැතිකඩ ඔබගේ {provider} ගිණුම මගින් කළමනාකරණය වන අතර මෙහිදී සංස්කරණය කළ නොහැක.', diff --git a/packages/i18n/src/translations/ta-IN.ts b/packages/i18n/src/translations/ta-IN.ts index 04be4469c..248bfb200 100644 --- a/packages/i18n/src/translations/ta-IN.ts +++ b/packages/i18n/src/translations/ta-IN.ts @@ -117,6 +117,8 @@ const translations: I18nTranslations = { 'user.profile.heading': 'சுயவிவரம்', + 'user.profile.loading': 'சுயவிவரம் ஏற்றப்படுகிறது', + 'user.profile.readonly.federated': 'இந்தச் சுயவிவரம் உங்கள் {provider} கணக்கால் நிர்வகிக்கப்படுகிறது, இங்கே திருத்த முடியாது.', diff --git a/packages/i18n/src/translations/te-IN.ts b/packages/i18n/src/translations/te-IN.ts index f0662c761..181926573 100644 --- a/packages/i18n/src/translations/te-IN.ts +++ b/packages/i18n/src/translations/te-IN.ts @@ -118,6 +118,8 @@ const translations: I18nTranslations = { 'user.profile.heading': 'ప్రొఫైల్', + 'user.profile.loading': 'ప్రొఫైల్ లోడ్ అవుతోంది', + 'user.profile.readonly.federated': 'ఈ ప్రొఫైల్ మీ {provider} ఖాతా ద్వారా నిర్వహించబడుతుంది, ఇక్కడ సవరించలేరు.', 'user.profile.update.not.allowed.error': diff --git a/packages/nextjs/src/client/components/presentation/UserProfile/UserProfile.tsx b/packages/nextjs/src/client/components/presentation/UserProfile/UserProfile.tsx index 0278b74be..2e2561bb5 100644 --- a/packages/nextjs/src/client/components/presentation/UserProfile/UserProfile.tsx +++ b/packages/nextjs/src/client/components/presentation/UserProfile/UserProfile.tsx @@ -124,6 +124,10 @@ const UserProfile: FC = ({editable, preferences, ...rest}: Use const resolvedEditable: BaseUserProfileProps['editable'] = editable === 'auto' ? lock !== undefined && !lock.readOnly : editable; + // The profile is still being resolved: show the loading state instead of a read-only + // profile that may turn out to be editable. + const isResolvingEditable: boolean = editable === 'auto' && lock === undefined; + const resolveReadOnlyNote = (): string | undefined => { if (!lock?.readOnly) { return undefined; @@ -143,6 +147,7 @@ const UserProfile: FC = ({editable, preferences, ...rest}: Use schemas={schemas as Schema[]} onUpdate={handleProfileUpdate} editable={resolvedEditable} + isLoading={isResolvingEditable} readOnlyNote={readOnlyNote} error={error} preferences={preferences} diff --git a/packages/react/src/components/presentation/UserProfile/BaseUserProfile.styles.ts b/packages/react/src/components/presentation/UserProfile/BaseUserProfile.styles.ts index 818f8b19c..5f0baf0be 100644 --- a/packages/react/src/components/presentation/UserProfile/BaseUserProfile.styles.ts +++ b/packages/react/src/components/presentation/UserProfile/BaseUserProfile.styles.ts @@ -90,6 +90,16 @@ const useStyles = (theme: Theme, colorScheme: string): Record => border-radius: ${theme.vars.borderRadius.large}; `; + const loading: string = css` + align-items: center; + + display: flex; + + justify-content: center; + + min-height: 180px; + `; + const header: string = css` display: flex; align-items: center; @@ -221,6 +231,7 @@ const useStyles = (theme: Theme, colorScheme: string): Record => header, info, infoContainer, + loading, label, lastField, name, diff --git a/packages/react/src/components/presentation/UserProfile/BaseUserProfile.tsx b/packages/react/src/components/presentation/UserProfile/BaseUserProfile.tsx index f4d26a856..5efdf17f5 100644 --- a/packages/react/src/components/presentation/UserProfile/BaseUserProfile.tsx +++ b/packages/react/src/components/presentation/UserProfile/BaseUserProfile.tsx @@ -31,6 +31,7 @@ import CardPrimitive from '../../primitives/Card/Card'; import Checkbox from '../../primitives/Checkbox/Checkbox'; import DatePicker from '../../primitives/DatePicker/DatePicker'; import DialogPrimitive from '../../primitives/Dialog/Dialog'; +import Spinner from '../../primitives/Spinner/Spinner'; import Divider from '../../primitives/Divider/Divider'; import MultiInput from '../../primitives/MultiInput/MultiInput'; import TextField from '../../primitives/TextField/TextField'; @@ -697,6 +698,19 @@ const BaseUserProfile: FC = ({ ); }; + const loadingContent: any = ( + +
+ +
+
+ ); + const profileContent: any = ( {!isProfileEditable && readOnlyNote && ( @@ -768,13 +782,13 @@ const BaseUserProfile: FC = ({ {title ?? t('user.profile.heading')} -
{profileContent}
+
{isLoading ? loadingContent : profileContent}
); } - return profileContent; + return isLoading ? loadingContent : profileContent; }; export default BaseUserProfile; diff --git a/packages/react/src/components/presentation/UserProfile/UserProfile.tsx b/packages/react/src/components/presentation/UserProfile/UserProfile.tsx index e34f96f92..84a8d329b 100644 --- a/packages/react/src/components/presentation/UserProfile/UserProfile.tsx +++ b/packages/react/src/components/presentation/UserProfile/UserProfile.tsx @@ -158,6 +158,10 @@ const UserProfile: FC = ({preferences, editable, ...rest}: Use const resolvedEditable: BaseUserProfileProps['editable'] = editable === 'auto' ? isFederatedAccount === false : editable; + // The profile is still being resolved: show the loading state instead of a read-only + // profile that may turn out to be editable. + const isResolvingEditable: boolean = editable === 'auto' && isFederatedAccount === undefined; + const resolveReadOnlyNote = (): string | undefined => { if (isFederatedAccount !== true) { return undefined; @@ -178,6 +182,7 @@ const UserProfile: FC = ({preferences, editable, ...rest}: Use onUpdate={handleProfileUpdate} error={error} editable={resolvedEditable} + isLoading={isResolvingEditable} readOnlyNote={readOnlyNote} preferences={preferences} {...rest} From d6cbd7365bc4273026aff4a774a559452df68d4e Mon Sep 17 00:00:00 2001 From: DonOmalVindula Date: Tue, 8 Sep 2026 13:53:00 +0530 Subject: [PATCH 4/4] fix(react, nextjs): satisfy the key-ordering lint rules Co-Authored-By: Claude Opus 5 (1M context) --- .../presentation/UserDropdown/UserDropdown.tsx | 11 +++++------ .../presentation/UserDropdown/UserDropdown.tsx | 11 +++++------ .../UserProfile/BaseUserProfile.styles.ts | 2 +- 3 files changed, 11 insertions(+), 13 deletions(-) diff --git a/packages/nextjs/src/client/components/presentation/UserDropdown/UserDropdown.tsx b/packages/nextjs/src/client/components/presentation/UserDropdown/UserDropdown.tsx index 73d592b78..bcb21fbcc 100644 --- a/packages/nextjs/src/client/components/presentation/UserDropdown/UserDropdown.tsx +++ b/packages/nextjs/src/client/components/presentation/UserDropdown/UserDropdown.tsx @@ -46,18 +46,17 @@ export interface UserDropdownRenderProps { * Extends BaseUserDropdownProps but excludes user, onManageProfile, and onSignOut since they're handled internally */ export type UserDropdownProps = Omit & { + /** + * Render prop function that receives user state and actions. + * When provided, this completely replaces the default dropdown rendering. + */ + children?: (props: UserDropdownRenderProps) => ReactNode; /** * Whether the profile shown by the "Manage profile" item can be edited. Forwarded to * ``, so `'auto'` renders a read-only profile for accounts whose attributes * are owned by an identity provider. */ editable?: UserProfileProps['editable']; - - /** - * Render prop function that receives user state and actions. - * When provided, this completely replaces the default dropdown rendering. - */ - children?: (props: UserDropdownRenderProps) => ReactNode; /** * Custom render function for the dropdown content. * When provided, this replaces just the dropdown content while keeping the trigger. diff --git a/packages/react/src/components/presentation/UserDropdown/UserDropdown.tsx b/packages/react/src/components/presentation/UserDropdown/UserDropdown.tsx index 341202f00..7ddc124d8 100644 --- a/packages/react/src/components/presentation/UserDropdown/UserDropdown.tsx +++ b/packages/react/src/components/presentation/UserDropdown/UserDropdown.tsx @@ -47,18 +47,17 @@ export interface UserDropdownRenderProps { * Extends BaseUserDropdownProps but excludes user, onManageProfile, and onSignOut since they're handled internally */ export type UserDropdownProps = Omit & { + /** + * Render prop function that receives user state and actions. + * When provided, this completely replaces the default dropdown rendering. + */ + children?: (props: UserDropdownRenderProps) => ReactNode; /** * Whether the profile shown by the "Manage profile" item can be edited. Forwarded to * ``, so `'auto'` renders a read-only profile for accounts whose attributes * are owned by an identity provider. */ editable?: UserProfileProps['editable']; - - /** - * Render prop function that receives user state and actions. - * When provided, this completely replaces the default dropdown rendering. - */ - children?: (props: UserDropdownRenderProps) => ReactNode; /** * Custom render function for the dropdown content. * When provided, this replaces just the dropdown content while keeping the trigger. diff --git a/packages/react/src/components/presentation/UserProfile/BaseUserProfile.styles.ts b/packages/react/src/components/presentation/UserProfile/BaseUserProfile.styles.ts index 5f0baf0be..6abcde360 100644 --- a/packages/react/src/components/presentation/UserProfile/BaseUserProfile.styles.ts +++ b/packages/react/src/components/presentation/UserProfile/BaseUserProfile.styles.ts @@ -231,9 +231,9 @@ const useStyles = (theme: Theme, colorScheme: string): Record => header, info, infoContainer, - loading, label, lastField, + loading, name, objectKey, objectValue,