Skip to content

Commit b477c69

Browse files
authored
feat(myaccount): add getUserProfile and updateUserProfile methods (#1315)
### Description auth0-spa-js 2.30.0 adds `getUserProfile()` and `updateUserProfile()` to its MyAccount API client (auth0/auth0-spa-js#1799). This PR makes them available on `useAuth0().myAccount`, with types and docs. `myAccount` is the spa-js client passed through unchanged, so the methods need no new runtime code. The changes are: - **Dependency:** bumps `@auth0/auth0-spa-js` from `^2.29.0` to `^2.30.0`. This also brings in the other fixes in spa-js 2.30.0, including auth0/auth0-spa-js#1817 (concurrent `getTokenSilently` calls failing with `Timeout`). - **Exports:** re-exports `UserProfile`, `ProfileFieldPolicy`, `GetUserProfileOptions` and `UpdateUserProfileRequest` from `@auth0/auth0-spa-js`. This only adds exports. This is not a breaking change. ### Testing New unit tests in `__tests__/my-account.test.tsx` - [x] This change adds test coverage for new/changed/fixed functionality - [ ] This change adds integration test coverage ### Checklist - [x] I have read the [Auth0 general contribution guidelines](https://github.com/auth0/open-source-template/blob/master/GENERAL-CONTRIBUTING.md) - [x] I have read the [Auth0 Code of Conduct](https://github.com/auth0/open-source-template/blob/master/CODE-OF-CONDUCT.md) - [x] All code quality tools/guidelines have been run/followed 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> <!-- coderabbit-ai-generated:v1 --> ## Summary by CodeRabbit * **New Features** * Added profile retrieval and update methods to the MyAccount API, with support for selecting fields and handling profile policies. * Added public types for profile data, field policies, and profile request options. * Profile updates do not immediately change the user returned by `useAuth0()`. * **Documentation** * Added guidance on required scopes, tenant configuration, supported fields, and possible errors. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 4f318f0 commit b477c69

7 files changed

Lines changed: 321 additions & 9 deletions

File tree

‎EXAMPLES.md‎

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1865,7 +1865,7 @@ When identifier verification is on, `signup()` throws `PasskeyVerificationRequir
18651865
18661866
## MyAccount API
18671867
1868-
The MyAccount API lets you manage the current user's authentication methods, factors, and connected accounts directly from the SPA.
1868+
The MyAccount API lets you manage the current user's profile, authentication methods, factors, and connected accounts directly from the SPA.
18691869
18701870
> [!NOTE]
18711871
> The MyAccount API requires refresh tokens and MRRT if your app is configured with a custom API audience. DPoP is supported but optional.
@@ -2069,6 +2069,94 @@ await myAccount.enrollmentVerify({
20692069
});
20702070
```
20712071
2072+
### Profile
2073+
2074+
> [!NOTE]
2075+
> My Account Profile API support via SDKs is currently in Early Access. To request access to this feature, contact your Auth0 representative.
2076+
2077+
Read and update the current user's profile. `getUserProfile()` needs the `read:me:profile` scope, and `updateUserProfile()` needs `update:me:profile`.
2078+
2079+
> [!NOTE]
2080+
> The profile endpoints must be enabled for your tenant. If they aren't, or the scopes weren't granted, both methods fail: with a `MyAccountApiError` with `status` `403`, or with a `MissingScopesError` if you use refresh tokens with MRRT.
2081+
2082+
#### Get Profile
2083+
2084+
```jsx
2085+
const { myAccount } = useAuth0();
2086+
2087+
const profile = await myAccount.getUserProfile();
2088+
// profile.given_name, profile.email, ...
2089+
```
2090+
2091+
The response includes `profile_policy`, which describes each field. Use it to decide which fields the user can edit:
2092+
2093+
```jsx
2094+
for (const [pointer, policy] of Object.entries(profile.profile_policy ?? {})) {
2095+
const field = pointer.slice(1); // '/given_name' -> 'given_name'
2096+
const editable = policy.access === 'read_write';
2097+
// policy.reason — why the field is read-only, e.g. 'managed_by_identity_provider'
2098+
}
2099+
```
2100+
2101+
#### Select Fields
2102+
2103+
When you pass `fields`, `profile_policy` is only returned if you include it, and then only for the selected fields. Call `getUserProfile()` without `fields` to get the full policy.
2104+
2105+
```jsx
2106+
// Only these fields, without profile_policy
2107+
const basic = await myAccount.getUserProfile({
2108+
fields: ['given_name', 'family_name', 'picture']
2109+
});
2110+
2111+
// These fields, with profile_policy for just these fields (for an edit form)
2112+
const editable = await myAccount.getUserProfile({
2113+
fields: ['given_name', 'family_name', 'profile_policy']
2114+
});
2115+
2116+
// Everything except profile_policy
2117+
const withoutPolicy = await myAccount.getUserProfile({
2118+
fields: ['profile_policy'],
2119+
includeFields: false
2120+
});
2121+
```
2122+
2123+
#### Update Profile
2124+
2125+
Only the fields you send are changed. The response is the full updated profile. You can update `given_name`, `family_name`, `name`, `nickname` and `picture`. `email`, `phone_number` and `username` can't be changed here, and `null` isn't accepted for any field.
2126+
2127+
```jsx
2128+
const updated = await myAccount.updateUserProfile({
2129+
given_name: 'Alice',
2130+
nickname: 'wonderland_alice'
2131+
});
2132+
```
2133+
2134+
If a field is read-only for this user (for example, `given_name` for a social login user), the request fails with a `MyAccountApiError` and nothing is saved. `err.validation_errors` says which field was rejected:
2135+
2136+
```jsx
2137+
import { MyAccountApiError } from '@auth0/auth0-react';
2138+
2139+
try {
2140+
await myAccount.updateUserProfile({ given_name: 'Alice' });
2141+
} catch (err) {
2142+
if (err instanceof MyAccountApiError) {
2143+
err.validation_errors?.forEach(e => console.error(e.pointer, e.detail));
2144+
}
2145+
}
2146+
```
2147+
2148+
`updateUserProfile()` doesn't change `user` from `useAuth0()`. `user` comes from the ID token, so it keeps the old values until the SDK gets a new ID token. To show the new values right away, use the profile that `updateUserProfile()` returns. To update `user`, get new tokens with `getAccessTokenSilently({ cacheMode: 'off' })`:
2149+
2150+
```jsx
2151+
const { myAccount, getAccessTokenSilently } = useAuth0();
2152+
2153+
const updated = await myAccount.updateUserProfile({ given_name: 'Alice' });
2154+
// updated.given_name is 'Alice', but user.given_name still has the old value
2155+
2156+
await getAccessTokenSilently({ cacheMode: 'off' });
2157+
// useAuth0() now returns user from the new ID token
2158+
```
2159+
20722160
### Error Handling
20732161
20742162
All MyAccount API errors throw `MyAccountApiError` with RFC 7807 fields.

‎__mocks__/@auth0/auth0-spa-js.tsx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ const passkeyGetSignupChallenge = jest.fn(() => Promise.resolve({ authSession: '
3535
const passkeyGetLoginChallenge = jest.fn(() => Promise.resolve({ authSession: 'login-auth-session', publicKey: {} }));
3636
const passkeyGetTokenWithPasskey = jest.fn(() => Promise.resolve({ access_token: 'passkey-token', id_token: 'passkey-id-token' }));
3737

38+
const myAccountGetUserProfile = jest.fn(() => Promise.resolve({ user_id: 'auth0|123', given_name: 'Alice', profile_policy: { '/given_name': { access: 'read_write', source: 'user' } } }));
39+
const myAccountUpdateUserProfile = jest.fn(() => Promise.resolve({ user_id: 'auth0|123', given_name: 'Alice', nickname: 'wonderland_alice' }));
3840
const myAccountGetFactors = jest.fn(() => Promise.resolve([]));
3941
const myAccountGetAuthenticationMethods = jest.fn(() => Promise.resolve([]));
4042
const myAccountGetAuthenticationMethod = jest.fn(() => Promise.resolve({ id: 'test-method-id' }));
@@ -90,6 +92,8 @@ export const Auth0Client = jest.fn(() => {
9092
getTokenWithPasskey: passkeyGetTokenWithPasskey,
9193
},
9294
myAccount: {
95+
getUserProfile: myAccountGetUserProfile,
96+
updateUserProfile: myAccountUpdateUserProfile,
9397
getFactors: myAccountGetFactors,
9498
getAuthenticationMethods: myAccountGetAuthenticationMethods,
9599
getAuthenticationMethod: myAccountGetAuthenticationMethod,

‎__tests__/my-account.test.tsx‎

Lines changed: 210 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
1-
import { renderHook, waitFor } from '@testing-library/react';
1+
import { act, renderHook, waitFor } from '@testing-library/react';
22
import { Auth0Client, MyAccountApiError } from '@auth0/auth0-spa-js';
33
import useAuth0 from '../src/use-auth0';
4+
import type {
5+
GetUserProfileOptions,
6+
ProfileFieldPolicy,
7+
UpdateUserProfileRequest,
8+
UserProfile,
9+
} from '../src';
410
import { createWrapper } from './helpers';
511

612
const clientMock = jest.mocked(new Auth0Client({ clientId: '', domain: '' }));
@@ -16,11 +22,13 @@ describe('MyAccount API', () => {
1622
});
1723
});
1824

19-
it('should provide all seven MyAccount methods', async () => {
25+
it('should provide all nine MyAccount methods', async () => {
2026
const wrapper = createWrapper();
2127
const { result } = renderHook(() => useAuth0(), { wrapper });
2228

2329
await waitFor(() => {
30+
expect(result.current.myAccount.getUserProfile).toBeDefined();
31+
expect(result.current.myAccount.updateUserProfile).toBeDefined();
2432
expect(result.current.myAccount.getFactors).toBeDefined();
2533
expect(result.current.myAccount.getAuthenticationMethods).toBeDefined();
2634
expect(result.current.myAccount.getAuthenticationMethod).toBeDefined();
@@ -122,6 +130,206 @@ describe('MyAccount API', () => {
122130
});
123131
});
124132

133+
describe('User Profile', () => {
134+
it('should call myAccount.getUserProfile without options', async () => {
135+
const wrapper = createWrapper();
136+
const { result } = renderHook(() => useAuth0(), { wrapper });
137+
138+
await waitFor(() => expect(result.current.isLoading).toBe(false));
139+
140+
const profile = await result.current.myAccount.getUserProfile();
141+
expect(clientMock.myAccount.getUserProfile).toHaveBeenCalledWith();
142+
expect(profile.user_id).toBe('auth0|123');
143+
expect(profile.given_name).toBe('Alice');
144+
expect(profile.profile_policy).toBeDefined();
145+
});
146+
147+
it('should pass fields to myAccount.getUserProfile', async () => {
148+
const wrapper = createWrapper();
149+
const { result } = renderHook(() => useAuth0(), { wrapper });
150+
151+
await waitFor(() => expect(result.current.isLoading).toBe(false));
152+
153+
const options: GetUserProfileOptions = {
154+
fields: ['given_name', 'family_name', 'profile_policy'],
155+
};
156+
await result.current.myAccount.getUserProfile(options);
157+
expect(clientMock.myAccount.getUserProfile).toHaveBeenCalledWith({
158+
fields: ['given_name', 'family_name', 'profile_policy'],
159+
});
160+
});
161+
162+
it('should pass includeFields: false to myAccount.getUserProfile', async () => {
163+
const wrapper = createWrapper();
164+
const { result } = renderHook(() => useAuth0(), { wrapper });
165+
166+
await waitFor(() => expect(result.current.isLoading).toBe(false));
167+
168+
await result.current.myAccount.getUserProfile({
169+
fields: ['profile_policy'],
170+
includeFields: false,
171+
});
172+
expect(clientMock.myAccount.getUserProfile).toHaveBeenCalledWith({
173+
fields: ['profile_policy'],
174+
includeFields: false,
175+
});
176+
});
177+
178+
it('should return the profile_policy from myAccount.getUserProfile', async () => {
179+
const policy: ProfileFieldPolicy = {
180+
access: 'read_only',
181+
source: 'idp',
182+
reason: 'managed_by_identity_provider',
183+
};
184+
const profile: UserProfile = {
185+
user_id: 'auth0|123',
186+
given_name: 'Alice',
187+
email: 'alice@example.com',
188+
email_verified: true,
189+
profile_policy: { '/given_name': policy },
190+
};
191+
clientMock.myAccount.getUserProfile.mockResolvedValueOnce(profile);
192+
193+
const wrapper = createWrapper();
194+
const { result } = renderHook(() => useAuth0(), { wrapper });
195+
196+
await waitFor(() => expect(result.current.isLoading).toBe(false));
197+
198+
const returned = await result.current.myAccount.getUserProfile();
199+
expect(returned).toEqual(profile);
200+
expect(returned.profile_policy?.['/given_name']).toEqual(policy);
201+
});
202+
203+
it('should call myAccount.updateUserProfile with the fields to change', async () => {
204+
const wrapper = createWrapper();
205+
const { result } = renderHook(() => useAuth0(), { wrapper });
206+
207+
await waitFor(() => expect(result.current.isLoading).toBe(false));
208+
209+
const data: UpdateUserProfileRequest = {
210+
given_name: 'Alice',
211+
nickname: 'wonderland_alice',
212+
};
213+
const updated = await result.current.myAccount.updateUserProfile(data);
214+
expect(clientMock.myAccount.updateUserProfile).toHaveBeenCalledWith({
215+
given_name: 'Alice',
216+
nickname: 'wonderland_alice',
217+
});
218+
expect(updated.nickname).toBe('wonderland_alice');
219+
});
220+
221+
it('should pass an empty object to myAccount.updateUserProfile unchanged', async () => {
222+
const wrapper = createWrapper();
223+
const { result } = renderHook(() => useAuth0(), { wrapper });
224+
225+
await waitFor(() => expect(result.current.isLoading).toBe(false));
226+
227+
await result.current.myAccount.updateUserProfile({});
228+
expect(clientMock.myAccount.updateUserProfile).toHaveBeenCalledWith({});
229+
});
230+
231+
it('should not change auth state when reading or updating the profile', async () => {
232+
const user = { sub: 'auth0|123' };
233+
clientMock.getUser.mockResolvedValueOnce(user);
234+
const wrapper = createWrapper();
235+
const { result } = renderHook(() => useAuth0(), { wrapper });
236+
237+
await waitFor(() => expect(result.current.isAuthenticated).toBe(true));
238+
239+
await act(async () => {
240+
await result.current.myAccount.getUserProfile();
241+
await result.current.myAccount.updateUserProfile({ given_name: 'Alice' });
242+
});
243+
244+
expect(result.current.isAuthenticated).toBe(true);
245+
expect(result.current.user).toBe(user);
246+
});
247+
248+
it('should throw when the profile methods are used outside Auth0Provider', () => {
249+
const { result } = renderHook(() => useAuth0());
250+
251+
expect(result.current.myAccount.getUserProfile).toThrow(
252+
'You forgot to wrap your component in <Auth0Provider>.'
253+
);
254+
expect(result.current.myAccount.updateUserProfile).toThrow(
255+
'You forgot to wrap your component in <Auth0Provider>.'
256+
);
257+
});
258+
259+
it('should propagate a 403 MyAccountApiError from getUserProfile when the feature is not enabled', async () => {
260+
const error = new MyAccountApiError({
261+
type: 'https://auth0.com/api-errors/A0E-403-0003',
262+
status: 403,
263+
title: 'Not Entitled',
264+
detail: 'This feature is currently not enabled',
265+
});
266+
clientMock.myAccount.getUserProfile.mockRejectedValueOnce(error);
267+
268+
const wrapper = createWrapper();
269+
const { result } = renderHook(() => useAuth0(), { wrapper });
270+
271+
await waitFor(() => expect(result.current.isLoading).toBe(false));
272+
273+
const promise = result.current.myAccount.getUserProfile();
274+
await expect(promise).rejects.toBeInstanceOf(MyAccountApiError);
275+
await expect(promise).rejects.toMatchObject({
276+
status: 403,
277+
title: 'Not Entitled',
278+
});
279+
});
280+
281+
it('should propagate a 403 MyAccountApiError from updateUserProfile when the feature is not enabled', async () => {
282+
const error = new MyAccountApiError({
283+
type: 'https://auth0.com/api-errors/A0E-403-0003',
284+
status: 403,
285+
title: 'Not Entitled',
286+
detail: 'This feature is currently not enabled',
287+
});
288+
clientMock.myAccount.updateUserProfile.mockRejectedValueOnce(error);
289+
290+
const wrapper = createWrapper();
291+
const { result } = renderHook(() => useAuth0(), { wrapper });
292+
293+
await waitFor(() => expect(result.current.isLoading).toBe(false));
294+
295+
await expect(
296+
result.current.myAccount.updateUserProfile({ given_name: 'Alice' })
297+
).rejects.toMatchObject({ name: 'MyAccountApiError', status: 403 });
298+
});
299+
300+
it('should propagate validation_errors from updateUserProfile for a read-only field', async () => {
301+
const error = new MyAccountApiError({
302+
type: 'A0E-400-0008',
303+
status: 400,
304+
title: 'read_only_field',
305+
detail: 'Field is read-only',
306+
validation_errors: [
307+
{
308+
pointer: '/given_name',
309+
detail: 'Field is managed by the identity provider',
310+
source: 'managed_by_identity_provider',
311+
},
312+
],
313+
});
314+
clientMock.myAccount.updateUserProfile.mockRejectedValueOnce(error);
315+
316+
const wrapper = createWrapper();
317+
const { result } = renderHook(() => useAuth0(), { wrapper });
318+
319+
await waitFor(() => expect(result.current.isLoading).toBe(false));
320+
321+
await expect(
322+
result.current.myAccount.updateUserProfile({ given_name: 'Alice' })
323+
).rejects.toMatchObject({
324+
status: 400,
325+
title: 'read_only_field',
326+
validation_errors: [
327+
{ pointer: '/given_name', source: 'managed_by_identity_provider' },
328+
],
329+
});
330+
});
331+
});
332+
125333
describe('Error Handling', () => {
126334
it('should propagate MyAccountApiError thrown by a method', async () => {
127335
const error = new MyAccountApiError({

‎package-lock.json‎

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,6 @@
9797
"react-dom": "^16.11.0 || ^17 || ^18 || ~19.0.1 || ~19.1.2 || ^19.2.1"
9898
},
9999
"dependencies": {
100-
"@auth0/auth0-spa-js": "^2.29.0"
100+
"@auth0/auth0-spa-js": "^2.30.0"
101101
}
102102
}

0 commit comments

Comments
 (0)