From cede6c96ef80b948391eeb9e10a990a2a45fbdfd Mon Sep 17 00:00:00 2001 From: Siddhi Kotak Date: Thu, 29 Oct 2026 23:51:21 +0530 Subject: [PATCH 1/2] feat(myaccount): add user profile management methods --- EXAMPLES.md | 75 ++++++++++- __mocks__/@auth0/auth0-spa-js.tsx | 4 + __tests__/my-account.test.tsx | 212 +++++++++++++++++++++++++++++- package-lock.json | 6 +- package.json | 2 +- src/auth0-context.tsx | 12 +- src/index.tsx | 4 + 7 files changed, 306 insertions(+), 9 deletions(-) diff --git a/EXAMPLES.md b/EXAMPLES.md index 8bb3951a..6fb8707a 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -1865,7 +1865,7 @@ When identifier verification is on, `signup()` throws `PasskeyVerificationRequir ## MyAccount API -The MyAccount API lets you manage the current user's authentication methods, factors, and connected accounts directly from the SPA. +The MyAccount API lets you manage the current user's profile, authentication methods, factors, and connected accounts directly from the SPA. > [!NOTE] > 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,79 @@ await myAccount.enrollmentVerify({ }); ``` +### Profile + +Read and update the current user's profile. `getUserProfile()` needs the `read:me:profile` scope, and `updateUserProfile()` needs `update:me:profile`. + +> [!NOTE] +> 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. + +#### Get Profile + +```jsx +const { myAccount } = useAuth0(); + +const profile = await myAccount.getUserProfile(); +// profile.given_name, profile.email, ... +``` + +The response includes `profile_policy`, which describes each field. Use it to decide which fields the user can edit: + +```jsx +for (const [pointer, policy] of Object.entries(profile.profile_policy ?? {})) { + const field = pointer.slice(1); // '/given_name' -> 'given_name' + const editable = policy.access === 'read_write'; + // policy.reason — why the field is read-only, e.g. 'managed_by_identity_provider' +} +``` + +#### Select Fields + +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. + +```jsx +// Only these fields, without profile_policy +const basic = await myAccount.getUserProfile({ + fields: ['given_name', 'family_name', 'picture'] +}); + +// These fields, with profile_policy for just these fields (for an edit form) +const editable = await myAccount.getUserProfile({ + fields: ['given_name', 'family_name', 'profile_policy'] +}); + +// Everything except profile_policy +const withoutPolicy = await myAccount.getUserProfile({ + fields: ['profile_policy'], + includeFields: false +}); +``` + +#### Update Profile + +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. + +```jsx +const updated = await myAccount.updateUserProfile({ + given_name: 'Alice', + nickname: 'wonderland_alice' +}); +``` + +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: + +```jsx +import { MyAccountApiError } from '@auth0/auth0-react'; + +try { + await myAccount.updateUserProfile({ given_name: 'Alice' }); +} catch (err) { + if (err instanceof MyAccountApiError) { + err.validation_errors?.forEach(e => console.error(e.pointer, e.detail)); + } +} +``` + ### Error Handling All MyAccount API errors throw `MyAccountApiError` with RFC 7807 fields. diff --git a/__mocks__/@auth0/auth0-spa-js.tsx b/__mocks__/@auth0/auth0-spa-js.tsx index 1a5e0aab..ad02240d 100644 --- a/__mocks__/@auth0/auth0-spa-js.tsx +++ b/__mocks__/@auth0/auth0-spa-js.tsx @@ -35,6 +35,8 @@ const passkeyGetSignupChallenge = jest.fn(() => Promise.resolve({ authSession: ' const passkeyGetLoginChallenge = jest.fn(() => Promise.resolve({ authSession: 'login-auth-session', publicKey: {} })); const passkeyGetTokenWithPasskey = jest.fn(() => Promise.resolve({ access_token: 'passkey-token', id_token: 'passkey-id-token' })); +const myAccountGetUserProfile = jest.fn(() => Promise.resolve({ user_id: 'auth0|123', given_name: 'Alice', profile_policy: { '/given_name': { access: 'read_write', source: 'user' } } })); +const myAccountUpdateUserProfile = jest.fn(() => Promise.resolve({ user_id: 'auth0|123', given_name: 'Alice', nickname: 'wonderland_alice' })); const myAccountGetFactors = jest.fn(() => Promise.resolve([])); const myAccountGetAuthenticationMethods = jest.fn(() => Promise.resolve([])); const myAccountGetAuthenticationMethod = jest.fn(() => Promise.resolve({ id: 'test-method-id' })); @@ -90,6 +92,8 @@ export const Auth0Client = jest.fn(() => { getTokenWithPasskey: passkeyGetTokenWithPasskey, }, myAccount: { + getUserProfile: myAccountGetUserProfile, + updateUserProfile: myAccountUpdateUserProfile, getFactors: myAccountGetFactors, getAuthenticationMethods: myAccountGetAuthenticationMethods, getAuthenticationMethod: myAccountGetAuthenticationMethod, diff --git a/__tests__/my-account.test.tsx b/__tests__/my-account.test.tsx index 8770b689..bb191341 100644 --- a/__tests__/my-account.test.tsx +++ b/__tests__/my-account.test.tsx @@ -1,6 +1,12 @@ -import { renderHook, waitFor } from '@testing-library/react'; +import { act, renderHook, waitFor } from '@testing-library/react'; import { Auth0Client, MyAccountApiError } from '@auth0/auth0-spa-js'; import useAuth0 from '../src/use-auth0'; +import type { + GetUserProfileOptions, + ProfileFieldPolicy, + UpdateUserProfileRequest, + UserProfile, +} from '../src'; import { createWrapper } from './helpers'; const clientMock = jest.mocked(new Auth0Client({ clientId: '', domain: '' })); @@ -16,11 +22,13 @@ describe('MyAccount API', () => { }); }); - it('should provide all seven MyAccount methods', async () => { + it('should provide all nine MyAccount methods', async () => { const wrapper = createWrapper(); const { result } = renderHook(() => useAuth0(), { wrapper }); await waitFor(() => { + expect(result.current.myAccount.getUserProfile).toBeDefined(); + expect(result.current.myAccount.updateUserProfile).toBeDefined(); expect(result.current.myAccount.getFactors).toBeDefined(); expect(result.current.myAccount.getAuthenticationMethods).toBeDefined(); expect(result.current.myAccount.getAuthenticationMethod).toBeDefined(); @@ -122,6 +130,206 @@ describe('MyAccount API', () => { }); }); + describe('User Profile', () => { + it('should call myAccount.getUserProfile without options', async () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useAuth0(), { wrapper }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + const profile = await result.current.myAccount.getUserProfile(); + expect(clientMock.myAccount.getUserProfile).toHaveBeenCalledWith(); + expect(profile.user_id).toBe('auth0|123'); + expect(profile.given_name).toBe('Alice'); + expect(profile.profile_policy).toBeDefined(); + }); + + it('should pass fields to myAccount.getUserProfile', async () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useAuth0(), { wrapper }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + const options: GetUserProfileOptions = { + fields: ['given_name', 'family_name', 'profile_policy'], + }; + await result.current.myAccount.getUserProfile(options); + expect(clientMock.myAccount.getUserProfile).toHaveBeenCalledWith({ + fields: ['given_name', 'family_name', 'profile_policy'], + }); + }); + + it('should pass includeFields: false to myAccount.getUserProfile', async () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useAuth0(), { wrapper }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + await result.current.myAccount.getUserProfile({ + fields: ['profile_policy'], + includeFields: false, + }); + expect(clientMock.myAccount.getUserProfile).toHaveBeenCalledWith({ + fields: ['profile_policy'], + includeFields: false, + }); + }); + + it('should return the profile_policy from myAccount.getUserProfile', async () => { + const policy: ProfileFieldPolicy = { + access: 'read_only', + source: 'idp', + reason: 'managed_by_identity_provider', + }; + const profile: UserProfile = { + user_id: 'auth0|123', + given_name: 'Alice', + email: 'alice@example.com', + email_verified: true, + profile_policy: { '/given_name': policy }, + }; + clientMock.myAccount.getUserProfile.mockResolvedValueOnce(profile); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useAuth0(), { wrapper }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + const returned = await result.current.myAccount.getUserProfile(); + expect(returned).toEqual(profile); + expect(returned.profile_policy?.['/given_name']).toEqual(policy); + }); + + it('should call myAccount.updateUserProfile with the fields to change', async () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useAuth0(), { wrapper }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + const data: UpdateUserProfileRequest = { + given_name: 'Alice', + nickname: 'wonderland_alice', + }; + const updated = await result.current.myAccount.updateUserProfile(data); + expect(clientMock.myAccount.updateUserProfile).toHaveBeenCalledWith({ + given_name: 'Alice', + nickname: 'wonderland_alice', + }); + expect(updated.nickname).toBe('wonderland_alice'); + }); + + it('should pass an empty object to myAccount.updateUserProfile unchanged', async () => { + const wrapper = createWrapper(); + const { result } = renderHook(() => useAuth0(), { wrapper }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + await result.current.myAccount.updateUserProfile({}); + expect(clientMock.myAccount.updateUserProfile).toHaveBeenCalledWith({}); + }); + + it('should not change auth state when reading or updating the profile', async () => { + const user = { sub: 'auth0|123' }; + clientMock.getUser.mockResolvedValueOnce(user); + const wrapper = createWrapper(); + const { result } = renderHook(() => useAuth0(), { wrapper }); + + await waitFor(() => expect(result.current.isAuthenticated).toBe(true)); + + await act(async () => { + await result.current.myAccount.getUserProfile(); + await result.current.myAccount.updateUserProfile({ given_name: 'Alice' }); + }); + + expect(result.current.isAuthenticated).toBe(true); + expect(result.current.user).toBe(user); + }); + + it('should throw when the profile methods are used outside Auth0Provider', () => { + const { result } = renderHook(() => useAuth0()); + + expect(result.current.myAccount.getUserProfile).toThrow( + 'You forgot to wrap your component in .' + ); + expect(result.current.myAccount.updateUserProfile).toThrow( + 'You forgot to wrap your component in .' + ); + }); + + it('should propagate a 403 MyAccountApiError from getUserProfile when the feature is not enabled', async () => { + const error = new MyAccountApiError({ + type: 'https://auth0.com/api-errors/A0E-403-0003', + status: 403, + title: 'Not Entitled', + detail: 'This feature is currently not enabled', + }); + clientMock.myAccount.getUserProfile.mockRejectedValueOnce(error); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useAuth0(), { wrapper }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + const promise = result.current.myAccount.getUserProfile(); + await expect(promise).rejects.toBeInstanceOf(MyAccountApiError); + await expect(promise).rejects.toMatchObject({ + status: 403, + title: 'Not Entitled', + }); + }); + + it('should propagate a 403 MyAccountApiError from updateUserProfile when the feature is not enabled', async () => { + const error = new MyAccountApiError({ + type: 'https://auth0.com/api-errors/A0E-403-0003', + status: 403, + title: 'Not Entitled', + detail: 'This feature is currently not enabled', + }); + clientMock.myAccount.updateUserProfile.mockRejectedValueOnce(error); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useAuth0(), { wrapper }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + await expect( + result.current.myAccount.updateUserProfile({ given_name: 'Alice' }) + ).rejects.toMatchObject({ name: 'MyAccountApiError', status: 403 }); + }); + + it('should propagate validation_errors from updateUserProfile for a read-only field', async () => { + const error = new MyAccountApiError({ + type: 'A0E-400-0008', + status: 400, + title: 'read_only_field', + detail: 'Field is read-only', + validation_errors: [ + { + pointer: '/given_name', + detail: 'Field is managed by the identity provider', + source: 'managed_by_identity_provider', + }, + ], + }); + clientMock.myAccount.updateUserProfile.mockRejectedValueOnce(error); + + const wrapper = createWrapper(); + const { result } = renderHook(() => useAuth0(), { wrapper }); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + await expect( + result.current.myAccount.updateUserProfile({ given_name: 'Alice' }) + ).rejects.toMatchObject({ + status: 400, + title: 'read_only_field', + validation_errors: [ + { pointer: '/given_name', source: 'managed_by_identity_provider' }, + ], + }); + }); + }); + describe('Error Handling', () => { it('should propagate MyAccountApiError thrown by a method', async () => { const error = new MyAccountApiError({ diff --git a/package-lock.json b/package-lock.json index c6a081be..e5e4eed5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "2.29.0", "license": "MIT", "dependencies": { - "@auth0/auth0-spa-js": "^2.29.0" + "@auth0/auth0-spa-js": "^2.30.0" }, "devDependencies": { "@rollup/plugin-node-resolve": "^15.0.1", @@ -89,8 +89,8 @@ } }, "node_modules/@auth0/auth0-spa-js": { - "version": "2.29.0", - "integrity": "sha512-Y7HUe47kMOgkMJmDWX34HpbXMeyiuPkHBxWistQUstFdDHLEBX3pUE5pwm/fHI5Qb+yLC+Z+lTXop3uX4i0XkA==", + "version": "2.30.0", + "integrity": "sha512-jqsVox2+TiBZf0Zh5h9iITh7W6a/WCGAMLuqniz+PTg2afVggbOBDmieo1zwOp8zQoY3RPguN4l5mvLDmxu7EQ==", "license": "MIT", "dependencies": { "@auth0/auth0-auth-js": "^1.17.0", diff --git a/package.json b/package.json index 7c4180af..bdb73349 100644 --- a/package.json +++ b/package.json @@ -97,6 +97,6 @@ "react-dom": "^16.11.0 || ^17 || ^18 || ~19.0.1 || ~19.1.2 || ^19.2.1" }, "dependencies": { - "@auth0/auth0-spa-js": "^2.29.0" + "@auth0/auth0-spa-js": "^2.30.0" } } diff --git a/src/auth0-context.tsx b/src/auth0-context.tsx index cfcb2a46..9b41252b 100644 --- a/src/auth0-context.tsx +++ b/src/auth0-context.tsx @@ -508,8 +508,10 @@ export interface Auth0ContextInterface * * MyAccount API client for self-service account management operations. * - * Provides access to methods for managing the authenticated user's authentication - * methods and factors: + * Provides access to methods for managing the authenticated user's profile, + * authentication methods and factors: + * - `getUserProfile(options?)` - Get the user's profile, optionally selecting fields (scope `read:me:profile`) + * - `updateUserProfile(data)` - Update the user's profile; only the fields sent are changed (scope `update:me:profile`) * - `getFactors()` - List available authentication factors * - `getAuthenticationMethods(type?)` - List enrolled authentication methods, optionally filtered by type * - `getAuthenticationMethod(id)` - Get a specific authentication method by ID @@ -522,6 +524,10 @@ export interface Auth0ContextInterface * ```js * const { myAccount } = useAuth0(); * + * // Read and update the user's profile + * const profile = await myAccount.getUserProfile(); + * await myAccount.updateUserProfile({ given_name: 'Alice' }); + * * // List all enrolled authentication methods * const methods = await myAccount.getAuthenticationMethods(); * @@ -613,6 +619,8 @@ export const initialContext = { getTokenWithPasskey: stub, } as unknown as PasskeyApiClient, myAccount: { + getUserProfile: stub, + updateUserProfile: stub, getFactors: stub, getAuthenticationMethods: stub, getAuthenticationMethod: stub, diff --git a/src/index.tsx b/src/index.tsx index ec138937..4ddf1800 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -128,6 +128,10 @@ export type { EnrollmentChallengeOptions, EnrollmentChallengeResponse, EnrollmentVerifyOptions, + UserProfile, + ProfileFieldPolicy, + GetUserProfileOptions, + UpdateUserProfileRequest, // Anonymous Session Types AnonymousSessionApiClient, AnonymousGetTokenSilentlyOptions, From def75ec1f34b0f6cad02d02b50e32c790cd04e94 Mon Sep 17 00:00:00 2001 From: Siddhi Kotak Date: Fri, 30 Oct 2026 13:32:19 +0530 Subject: [PATCH 2/2] feat(profile): add early access note for My Account Profile API and update usage instructions --- EXAMPLES.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/EXAMPLES.md b/EXAMPLES.md index 6fb8707a..768d6f27 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -2071,6 +2071,9 @@ await myAccount.enrollmentVerify({ ### Profile +> [!NOTE] +> My Account Profile API support via SDKs is currently in Early Access. To request access to this feature, contact your Auth0 representative. + Read and update the current user's profile. `getUserProfile()` needs the `read:me:profile` scope, and `updateUserProfile()` needs `update:me:profile`. > [!NOTE] @@ -2142,6 +2145,18 @@ try { } ``` +`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' })`: + +```jsx +const { myAccount, getAccessTokenSilently } = useAuth0(); + +const updated = await myAccount.updateUserProfile({ given_name: 'Alice' }); +// updated.given_name is 'Alice', but user.given_name still has the old value + +await getAccessTokenSilently({ cacheMode: 'off' }); +// useAuth0() now returns user from the new ID token +``` + ### Error Handling All MyAccount API errors throw `MyAccountApiError` with RFC 7807 fields.