Skip to content

Commit e5f7197

Browse files
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. - <UserDropdown /> 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) <noreply@anthropic.com>
1 parent 9a156a9 commit e5f7197

5 files changed

Lines changed: 48 additions & 14 deletions

File tree

‎.changeset/readonly-federated-profile.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ Render a read-only profile for users whose attributes are owned by an identity p
1010
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.
1111

1212
- `<UserProfile />` 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.
13+
- `<UserDropdown />` forwards `editable` to the profile it opens from "Manage profile", so the modal follows the same rule as the page.
1314
- `BaseUserProfile` accepts a predicate for `editable`, so applications can decide per user without any lookup, and a `readOnlyNote` to explain why editing is unavailable.
1415
- 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 `<UserProfile />` previously ignored update failures entirely.
1516
- 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.
17+
18+
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.

‎packages/nextjs/src/client/components/presentation/UserDropdown/UserDropdown.tsx‎

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
import {BaseUserDropdown, BaseUserDropdownProps} from '@asgardeo/react';
2222
import {FC, ReactElement, ReactNode, useState} from 'react';
2323
import useAsgardeo from '../../../contexts/Asgardeo/useAsgardeo';
24-
import UserProfile from '../UserProfile/UserProfile.js';
24+
import UserProfile, {UserProfileProps} from '../UserProfile/UserProfile.js';
2525

2626
/**
2727
* Render props data passed to the children function
@@ -46,6 +46,13 @@ export interface UserDropdownRenderProps {
4646
* Extends BaseUserDropdownProps but excludes user, onManageProfile, and onSignOut since they're handled internally
4747
*/
4848
export type UserDropdownProps = Omit<BaseUserDropdownProps, 'user' | 'onManageProfile'> & {
49+
/**
50+
* Whether the profile shown by the "Manage profile" item can be edited. Forwarded to
51+
* `<UserProfile />`, so `'auto'` renders a read-only profile for accounts whose attributes
52+
* are owned by an identity provider.
53+
*/
54+
editable?: UserProfileProps['editable'];
55+
4956
/**
5057
* Render prop function that receives user state and actions.
5158
* When provided, this completely replaces the default dropdown rendering.
@@ -114,6 +121,7 @@ const UserDropdown: FC<UserDropdownProps> = ({
114121
renderTrigger,
115122
renderDropdown,
116123
onSignOut,
124+
editable,
117125
...rest
118126
}: UserDropdownProps): ReactElement => {
119127
const {user, isLoading, signOut} = useAsgardeo();
@@ -149,7 +157,7 @@ const UserDropdown: FC<UserDropdownProps> = ({
149157
return (
150158
<>
151159
{children(renderProps)}
152-
<UserProfile mode="popup" open={isProfileOpen} onOpenChange={setIsProfileOpen} />
160+
<UserProfile mode="popup" editable={editable} open={isProfileOpen} onOpenChange={setIsProfileOpen} />
153161
</>
154162
);
155163
}
@@ -172,7 +180,7 @@ const UserDropdown: FC<UserDropdownProps> = ({
172180
/>
173181
)}
174182
{/* Note: renderDropdown would need BaseUserDropdown modifications to implement properly */}
175-
<UserProfile mode="popup" open={isProfileOpen} onOpenChange={setIsProfileOpen} />
183+
<UserProfile mode="popup" editable={editable} open={isProfileOpen} onOpenChange={setIsProfileOpen} />
176184
</>
177185
);
178186
}
@@ -187,7 +195,9 @@ const UserDropdown: FC<UserDropdownProps> = ({
187195
onSignOut={handleSignOut}
188196
{...rest}
189197
/>
190-
{isProfileOpen && <UserProfile mode="popup" open={isProfileOpen} onOpenChange={setIsProfileOpen} />}
198+
{isProfileOpen && (
199+
<UserProfile mode="popup" editable={editable} open={isProfileOpen} onOpenChange={setIsProfileOpen} />
200+
)}
191201
</>
192202
);
193203
};

‎packages/nextjs/src/client/components/presentation/UserProfile/UserProfile.tsx‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,8 +72,11 @@ const UserProfile: FC<UserProfileProps> = ({editable, preferences, ...rest}: Use
7272
const [error, setError] = useState<string | null>(null);
7373
const [lock, setLock] = useState<FederatedProfileLock | undefined>(undefined);
7474

75+
// In popup mode the profile is mounted with the dropdown; don't spend a request until it is opened.
76+
const {mode: profileMode, open: isProfileOpen} = rest as {mode?: string; open?: boolean};
77+
7578
useEffect((): (() => void) | undefined => {
76-
if (editable !== 'auto') {
79+
if (editable !== 'auto' || (profileMode === 'popup' && !isProfileOpen)) {
7780
return undefined;
7881
}
7982

@@ -90,7 +93,7 @@ const UserProfile: FC<UserProfileProps> = ({editable, preferences, ...rest}: Use
9093
return (): void => {
9194
isStale = true;
9295
};
93-
}, [editable]);
96+
}, [editable, profileMode, isProfileOpen]);
9497

9598
const handleProfileUpdate = async (payload: any): Promise<void> => {
9699
setError(null);
@@ -116,7 +119,10 @@ const UserProfile: FC<UserProfileProps> = ({editable, preferences, ...rest}: Use
116119
onUpdateProfile(result?.data?.user);
117120
};
118121

119-
const resolvedEditable: BaseUserProfileProps['editable'] = editable === 'auto' ? !lock?.readOnly : editable;
122+
// Until the lookup resolves the profile stays read-only, so a managed account never flashes
123+
// edit controls that the server would refuse.
124+
const resolvedEditable: BaseUserProfileProps['editable'] =
125+
editable === 'auto' ? lock !== undefined && !lock.readOnly : editable;
120126

121127
const resolveReadOnlyNote = (): string | undefined => {
122128
if (!lock?.readOnly) {

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

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import {FlowMetadataResponse} from '@asgardeo/browser';
2020
import {FC, ReactElement, ReactNode, useState} from 'react';
2121
import {BaseUserDropdown as BaseUserDropdownComponent, BaseUserDropdownProps} from './BaseUserDropdown';
2222
import useAsgardeo from '../../../contexts/Asgardeo/useAsgardeo';
23-
import UserProfile from '../UserProfile/UserProfile';
23+
import UserProfile, {UserProfileProps} from '../UserProfile/UserProfile';
2424

2525
/**
2626
* Render props data passed to the children function
@@ -47,6 +47,13 @@ export interface UserDropdownRenderProps {
4747
* Extends BaseUserDropdownProps but excludes user, onManageProfile, and onSignOut since they're handled internally
4848
*/
4949
export type UserDropdownProps = Omit<BaseUserDropdownProps, 'user' | 'onManageProfile'> & {
50+
/**
51+
* Whether the profile shown by the "Manage profile" item can be edited. Forwarded to
52+
* `<UserProfile />`, so `'auto'` renders a read-only profile for accounts whose attributes
53+
* are owned by an identity provider.
54+
*/
55+
editable?: UserProfileProps['editable'];
56+
5057
/**
5158
* Render prop function that receives user state and actions.
5259
* When provided, this completely replaces the default dropdown rendering.
@@ -115,6 +122,7 @@ const UserDropdown: FC<UserDropdownProps> = ({
115122
renderTrigger,
116123
renderDropdown,
117124
onSignOut,
125+
editable,
118126
...rest
119127
}: UserDropdownProps): ReactElement => {
120128
const {user, isLoading, signOut, meta} = useAsgardeo();
@@ -151,7 +159,7 @@ const UserDropdown: FC<UserDropdownProps> = ({
151159
return (
152160
<>
153161
{children(renderProps)}
154-
<UserProfile mode="popup" open={isProfileOpen} onOpenChange={setIsProfileOpen} />
162+
<UserProfile mode="popup" editable={editable} open={isProfileOpen} onOpenChange={setIsProfileOpen} />
155163
</>
156164
);
157165
}
@@ -174,7 +182,7 @@ const UserDropdown: FC<UserDropdownProps> = ({
174182
/>
175183
)}
176184
{/* Note: renderDropdown would need BaseUserDropdown modifications to implement properly */}
177-
<UserProfile mode="popup" open={isProfileOpen} onOpenChange={setIsProfileOpen} />
185+
<UserProfile mode="popup" editable={editable} open={isProfileOpen} onOpenChange={setIsProfileOpen} />
178186
</>
179187
);
180188
}
@@ -189,7 +197,9 @@ const UserDropdown: FC<UserDropdownProps> = ({
189197
onSignOut={handleSignOut}
190198
{...rest}
191199
/>
192-
{isProfileOpen && <UserProfile mode="popup" open={isProfileOpen} onOpenChange={setIsProfileOpen} />}
200+
{isProfileOpen && (
201+
<UserProfile mode="popup" editable={editable} open={isProfileOpen} onOpenChange={setIsProfileOpen} />
202+
)}
193203
</>
194204
);
195205
};

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

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -90,8 +90,11 @@ const UserProfile: FC<UserProfileProps> = ({preferences, editable, ...rest}: Use
9090
const [isFederatedAccount, setIsFederatedAccount] = useState<boolean | undefined>(undefined);
9191
const [identityProviderName, setIdentityProviderName] = useState<string | undefined>(undefined);
9292

93+
// In popup mode the profile is mounted with the dropdown; don't spend a request until it is opened.
94+
const {mode: profileMode, open: isProfileOpen} = rest as {mode?: string; open?: boolean};
95+
9396
useEffect((): (() => void) | undefined => {
94-
if (editable !== 'auto') {
97+
if (editable !== 'auto' || (profileMode === 'popup' && !isProfileOpen)) {
9598
return undefined;
9699
}
97100

@@ -124,7 +127,7 @@ const UserProfile: FC<UserProfileProps> = ({preferences, editable, ...rest}: Use
124127
return (): void => {
125128
isStale = true;
126129
};
127-
}, [editable, baseUrl, instanceId]);
130+
}, [editable, baseUrl, instanceId, profileMode, isProfileOpen]);
128131

129132
const handleProfileUpdate = async (payload: any): Promise<void> => {
130133
setError(null);
@@ -150,8 +153,10 @@ const UserProfile: FC<UserProfileProps> = ({preferences, editable, ...rest}: Use
150153
}
151154
};
152155

156+
// Until the lookup resolves the profile stays read-only, so a managed account never flashes
157+
// edit controls that the server would refuse.
153158
const resolvedEditable: BaseUserProfileProps['editable'] =
154-
editable === 'auto' ? isFederatedAccount !== true : editable;
159+
editable === 'auto' ? isFederatedAccount === false : editable;
155160

156161
const resolveReadOnlyNote = (): string | undefined => {
157162
if (isFederatedAccount !== true) {

0 commit comments

Comments
 (0)