Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 89 additions & 1 deletion EXAMPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -2069,6 +2069,94 @@ await myAccount.enrollmentVerify({
});
```

### Profile
Comment thread
siddhikotak marked this conversation as resolved.

> [!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]
> 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));
}
}
```

`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.
Expand Down
4 changes: 4 additions & 0 deletions __mocks__/@auth0/auth0-spa-js.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' }));
Expand Down Expand Up @@ -90,6 +92,8 @@ export const Auth0Client = jest.fn(() => {
getTokenWithPasskey: passkeyGetTokenWithPasskey,
},
myAccount: {
getUserProfile: myAccountGetUserProfile,
updateUserProfile: myAccountUpdateUserProfile,
getFactors: myAccountGetFactors,
getAuthenticationMethods: myAccountGetAuthenticationMethods,
getAuthenticationMethod: myAccountGetAuthenticationMethod,
Expand Down
212 changes: 210 additions & 2 deletions __tests__/my-account.test.tsx
Original file line number Diff line number Diff line change
@@ -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: '' }));
Expand All @@ -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();
Expand Down Expand Up @@ -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 <Auth0Provider>.'
);
expect(result.current.myAccount.updateUserProfile).toThrow(
'You forgot to wrap your component in <Auth0Provider>.'
);
});

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({
Expand Down
6 changes: 3 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
Loading
Loading