diff --git a/.changeset/readonly-federated-profile.md b/.changeset/readonly-federated-profile.md new file mode 100644 index 000000000..0a4ff65ae --- /dev/null +++ b/.changeset/readonly-federated-profile.md @@ -0,0 +1,18 @@ +--- +'@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. +- `` 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 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 d38c62afd..4161ff04c 100644 --- a/packages/i18n/src/models/i18n.ts +++ b/packages/i18n/src/models/i18n.ts @@ -116,6 +116,9 @@ 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; /* |---------------------------------------------------------------| */ /* | Organization Switcher | */ diff --git a/packages/i18n/src/translations/en-US.ts b/packages/i18n/src/translations/en-US.ts index 9017fc0e3..83518f37e 100644 --- a/packages/i18n/src/translations/en-US.ts +++ b/packages/i18n/src/translations/en-US.ts @@ -118,7 +118,12 @@ 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': + '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..fdaadaceb 100644 --- a/packages/i18n/src/translations/fr-FR.ts +++ b/packages/i18n/src/translations/fr-FR.ts @@ -117,6 +117,13 @@ 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': + "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..ac5d1c252 100644 --- a/packages/i18n/src/translations/hi-IN.ts +++ b/packages/i18n/src/translations/hi-IN.ts @@ -116,6 +116,14 @@ const translations: I18nTranslations = { /* |---------------------------------------------------------------| */ 'user.profile.heading': 'प्रोफ़ाइल', + + 'user.profile.loading': 'प्रोफ़ाइल लोड हो रही है', + + '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..f22cc4cba 100644 --- a/packages/i18n/src/translations/ja-JP.ts +++ b/packages/i18n/src/translations/ja-JP.ts @@ -116,6 +116,14 @@ const translations: I18nTranslations = { /* |---------------------------------------------------------------| */ 'user.profile.heading': 'プロフィール', + + 'user.profile.loading': 'プロフィールを読み込んでいます', + + '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..5c43573df 100644 --- a/packages/i18n/src/translations/pt-BR.ts +++ b/packages/i18n/src/translations/pt-BR.ts @@ -116,6 +116,13 @@ 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': + '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..b7a925400 100644 --- a/packages/i18n/src/translations/pt-PT.ts +++ b/packages/i18n/src/translations/pt-PT.ts @@ -116,6 +116,13 @@ 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': + '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..f144ab249 100644 --- a/packages/i18n/src/translations/si-LK.ts +++ b/packages/i18n/src/translations/si-LK.ts @@ -116,6 +116,14 @@ const translations: I18nTranslations = { /* |---------------------------------------------------------------| */ 'user.profile.heading': 'පැතිකඩ', + + 'user.profile.loading': 'පැතිකඩ පූරණය වෙමින්', + + '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..248bfb200 100644 --- a/packages/i18n/src/translations/ta-IN.ts +++ b/packages/i18n/src/translations/ta-IN.ts @@ -116,6 +116,14 @@ const translations: I18nTranslations = { /* |---------------------------------------------------------------| */ 'user.profile.heading': 'சுயவிவரம்', + + 'user.profile.loading': 'சுயவிவரம் ஏற்றப்படுகிறது', + + '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..181926573 100644 --- a/packages/i18n/src/translations/te-IN.ts +++ b/packages/i18n/src/translations/te-IN.ts @@ -117,6 +117,13 @@ const translations: I18nTranslations = { /* |---------------------------------------------------------------| */ 'user.profile.heading': 'ప్రొఫైల్', + + 'user.profile.loading': 'ప్రొఫైల్ లోడ్ అవుతోంది', + + '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/UserDropdown/UserDropdown.tsx b/packages/nextjs/src/client/components/presentation/UserDropdown/UserDropdown.tsx index 6672c1dd9..bcb21fbcc 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 @@ -51,6 +51,12 @@ export type UserDropdownProps = Omit 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']; /** * Custom render function for the dropdown content. * When provided, this replaces just the dropdown content while keeping the trigger. @@ -114,6 +120,7 @@ const UserDropdown: FC = ({ renderTrigger, renderDropdown, onSignOut, + editable, ...rest }: UserDropdownProps): ReactElement => { const {user, isLoading, signOut} = useAsgardeo(); @@ -149,7 +156,7 @@ const UserDropdown: FC = ({ return ( <> {children(renderProps)} - + ); } @@ -172,7 +179,7 @@ const UserDropdown: FC = ({ /> )} {/* Note: renderDropdown would need BaseUserDropdown modifications to implement properly */} - + ); } @@ -187,7 +194,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 bfd86ea0e..2e2561bb5 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,92 @@ 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); + + // 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' || (profileMode === 'popup' && !isProfileOpen)) { + return undefined; + } + + let isStale: boolean = false; + + (async (): Promise => { + const resolved: FederatedProfileLock = await getFederatedProfileLock(); + + if (!isStale) { + setLock(resolved); + } + })(); + + return (): void => { + isStale = true; + }; + }, [editable, profileMode, isProfileOpen]); 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); }; + // 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; + + // 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; + } + + 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/UserDropdown/UserDropdown.tsx b/packages/react/src/components/presentation/UserDropdown/UserDropdown.tsx index a88f0d776..7ddc124d8 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 @@ -52,6 +52,12 @@ export type UserDropdownProps = Omit 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']; /** * Custom render function for the dropdown content. * When provided, this replaces just the dropdown content while keeping the trigger. @@ -115,6 +121,7 @@ const UserDropdown: FC = ({ renderTrigger, renderDropdown, onSignOut, + editable, ...rest }: UserDropdownProps): ReactElement => { const {user, isLoading, signOut, meta} = useAsgardeo(); @@ -151,7 +158,7 @@ const UserDropdown: FC = ({ return ( <> {children(renderProps)} - + ); } @@ -174,7 +181,7 @@ const UserDropdown: FC = ({ /> )} {/* Note: renderDropdown would need BaseUserDropdown modifications to implement properly */} - + ); } @@ -189,7 +196,9 @@ const UserDropdown: FC = ({ onSignOut={handleSignOut} {...rest} /> - {isProfileOpen && } + {isProfileOpen && ( + + )} ); }; diff --git a/packages/react/src/components/presentation/UserProfile/BaseUserProfile.styles.ts b/packages/react/src/components/presentation/UserProfile/BaseUserProfile.styles.ts index 818f8b19c..6abcde360 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; @@ -223,6 +233,7 @@ const useStyles = (theme: Theme, colorScheme: string): Record => infoContainer, label, lastField, + loading, name, objectKey, objectValue, 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..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'; @@ -68,7 +69,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 +90,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 +145,7 @@ const BaseUserProfile: FC = ({ title, attributeMapping = {}, editable = true, + readOnlyNote, onOpenChange, onUpdate, open = false, @@ -146,6 +157,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 +393,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 +539,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 +581,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 +602,7 @@ const BaseUserProfile: FC = ({ () => toggleFieldEdit(schema.name!), )} - {editable && !isReadOnlyMutability(schema.mutability) && !isReadonlyField && ( + {isProfileEditable && !isReadOnlyMutability(schema.mutability) && !isReadonlyField && (
{isFieldEditing && ( <> @@ -681,8 +698,29 @@ const BaseUserProfile: FC = ({ ); }; + const loadingContent: any = ( + +
+ +
+
+ ); + 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; } @@ -744,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 b4b8bf3c1..84a8d329b 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,57 @@ 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); + + // 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' || (profileMode === 'popup' && !isProfileOpen)) { + 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, profileMode, isProfileOpen]); const handleProfileUpdate = async (payload: any): Promise => { setError(null); @@ -84,10 +142,38 @@ 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); } }; + // 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 === 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; + } + + 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} + isLoading={isResolvingEditable} + 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,