User profile self-service in React Native apps
PingOne Advanced Identity Cloud PingAM React Native
User profile self-service covers two related use cases: reading and displaying the authenticated user’s session data and attributes, and running additional authentication journeys (such as profile update or password change) on behalf of an already-authenticated user. The React Native SDK handles both through the @ping-identity/rn-journey and @ping-identity/rn-oidc packages.
Step 1. Installing modules
To install the module into your React Native project, use yarn or npm as follows:
-
yarn
-
npm
yarn add @ping-identity/rn-journey
yarn add @ping-identity/rn-oidc
npm install @ping-identity/rn-journey
npm install @ping-identity/rn-oidc
After installation, import the hooks you need:
import { useJourney, JourneyProvider } from '@ping-identity/rn-journey';
import { useOidc, OidcProvider } from '@ping-identity/rn-oidc';
Step 2. Retrieving user session state
After a user authenticates through a Journey, the journey client retains the active session. Call actions.user() to retrieve the current JourneyUserSession object, which contains the session accessToken, refreshToken, expiresIn and related metadata. Call actions.ssoToken() to return the SSO token object, which contains the SSO token value, successUrl, and realm:
import { useJourney } from '@ping-identity/rn-journey';
function UserProfileScreen() {
const [, actions] = useJourney();
useEffect(() => {
async function loadSession() {
const [session, ssoToken] = await Promise.all([
actions.user(),
actions.ssoToken(),
]);
if (session) {
console.log('Access token:', session.accessToken);
console.log('Refresh token:', session.refreshToken);
console.log('Expires in:', session.expiresIn);
} else {
console.log('No OIDC session found.');
}
if (ssoToken) {
console.log('SSO token value:', ssoToken.value);
console.log('Realm:', ssoToken.realm);
} else {
console.log('No SSO session found.');
}
}
void loadSession();
}, [actions]);
}
|
|
JourneyUserSession properties
| Property | Type | Description |
|---|---|---|
|
|
OAuth 2.0 access token issued by the server. |
|
|
OAuth 2.0 refresh token, when issued. |
|
|
Token lifetime in seconds from issuance. |
|
|
User profile claims from OIDC userinfo, when resolved. |
Refreshing the session on screen focus
User profile screens should refresh session state each time the screen comes into focus to ensure the displayed data is current. Use useFocusEffect from React Navigation:
import { useFocusEffect } from '@react-navigation/native';
import { useCallback } from 'react';
import { useJourney } from '@ping-identity/rn-journey';
function UserProfileScreen() {
const [, actions] = useJourney();
const [session, setSession] = useState(null);
useFocusEffect(
useCallback(() => {
void actions.user().then(setSession).catch(console.error);
}, [actions]),
);
}
Step 3. Retrieving userinfo
Call actions.userinfo() to fetch the OpenID Connect userinfo endpoint and return the user’s profile attributes. The result is a plain object whose keys depend on the scopes granted during authentication:
const [, actions] = useJourney();
const userInfo = await actions.userinfo();
// { sub: 'user123', given_name: 'Jane', family_name: 'Doe', email: 'jane@example.com', ... }
For OIDC sessions managed through @ping-identity/rn-oidc, use oidcActions.userinfo() instead:
const [, oidcActions] = useOidc();
const userInfo = await oidcActions.userinfo();
The oidcActions.userinfo() function accepts a cache boolean parameter. The default, false, initiates a fresh network request rather than serving cached values:
// Default: fresh network request
const userInfo = await oidcActions.userinfo();
// Serve from cache when available
const cachedUserInfo = await oidcActions.userinfo(true);
Step 4. SSO token management
Call actions.ssoToken() to retrieve the current AM SSO token. This is useful when constructing requests to protected AM APIs (for example, the device client self-service APIs):
const ssoToken = await actions.ssoToken();
if (ssoToken) {
console.log('SSO token value:', ssoToken.value);
console.log('Realm:', ssoToken.realm);
}
Step 5. Starting additional journeys
You can run additional authentication journeys on behalf of an already-authenticated user. The SDK automatically attaches the existing SSO session to outgoing calls, so the journey server receives the user’s context without requiring re-authentication.
Common additional journeys include:
-
Profile attribute updates (for example,
sdkProfileManagement) -
Password change
-
KBA setup
-
Terms and conditions acceptance
-
MFA registration
Start an additional journey by calling actions.start() with the journey name. Pass forceAuth: true to ensure the journey runs even if a valid session already exists, and noSession: true to prevent the journey from issuing a replacement session token:
import { useJourney } from '@ping-identity/rn-journey';
const [node, actions] = useJourney(journeyClient);
// Start the profile management journey
await actions.start('sdkProfileManagement', {
forceAuth: true,
noSession: true,
});
| Option | Type | Description |
|---|---|---|
|
|
|
|
|
|
|
Always set |
Step 6. Handling attribute callbacks
Profile management journeys typically return attribute collection callbacks. The useJourneyForm hook normalizes these callbacks into typed fields that your UI can render.
Common callback types
| Callback type | Field ref.type |
Description |
|---|---|---|
|
|
Collects a string attribute such as a city name, telephone number, or email address. |
|
|
Collects a numeric attribute such as age or an account number. |
|
|
Collects a boolean attribute, typically presented as a toggle or checkbox. |
|
|
Provides a list of choices and collects the user’s selection by index. |
|
|
Presents a message with confirmation options (for example, OK / Cancel). |
|
|
Collects a knowledge-based authentication question and answer pair. |
|
|
Displays the current terms and conditions and collects the user’s agreement. |
Reading field values and metadata
All attribute callbacks have executionMode: 'manual'. The prompt property provides a user-facing label; the defaultValue property contains the current attribute value (pre-populated from the user’s profile):
import { useJourneyForm } from '@ping-identity/rn-journey';
const form = useJourneyForm(node);
for (const field of form.fields) {
console.log('Field ID:', field.id);
console.log('Callback type:', field.ref.type);
console.log('Prompt:', field.prompt); // e.g. "First Name"
console.log('Current value:', field.defaultValue);
console.log('Required:', field.required);
console.log('Kind:', field.kind); // 'text', 'number', 'boolean', 'choice', 'kba'
}
Rendering attribute fields
Use field.ref.type to determine the appropriate UI control for each field:
import React from 'react';
import { Switch, Text, TextInput, View } from 'react-native';
import { useJourneyForm } from '@ping-identity/rn-journey';
function ProfileForm({ node, onSubmit }) {
const form = useJourneyForm(node);
return (
<View>
{form.fields.map((field) => {
// Skip non-manual fields
if (field.executionMode !== 'manual' || !field.requiresUserInput) {
return null;
}
const value = form.values[field.id] ?? field.defaultValue;
switch (field.ref.type) {
case 'StringAttributeInputCallback':
return (
<View key={field.id}>
<Text>{field.prompt}</Text>
<TextInput
value={String(value ?? '')}
onChangeText={(text) => form.setValue(field.id, text)}
placeholder={field.prompt}
/>
</View>
);
case 'NumberAttributeInputCallback':
return (
<View key={field.id}>
<Text>{field.prompt}</Text>
<TextInput
keyboardType="numeric"
value={value != null ? String(value) : ''}
onChangeText={(text) =>
form.setValue(field.id, text === '' ? '' : Number(text))
}
/>
</View>
);
case 'BooleanAttributeInputCallback':
return (
<View key={field.id}>
<Text>{field.prompt}</Text>
<Switch
value={Boolean(value)}
onValueChange={(v) => form.setValue(field.id, v)}
/>
</View>
);
case 'ChoiceCallback':
return (
<View key={field.id}>
<Text>{field.prompt}</Text>
{field.options?.map((option) => (
<Text
key={option.index}
onPress={() => form.setValue(field.id, option.index)}
style={value === option.index ? { fontWeight: 'bold' } : {}}
>
{option.label}
</Text>
))}
</View>
);
case 'TermsAndConditionsCallback':
return (
<View key={field.id}>
<Text>{field.message ?? field.prompt}</Text>
<Switch
value={Boolean(value)}
onValueChange={(v) => form.setValue(field.id, v)}
/>
</View>
);
default:
return null;
}
})}
<Button
title="Save"
disabled={!form.canSubmit}
onPress={() => onSubmit(form.input)}
/>
</View>
);
}
Submitting profile changes
After the user updates the fields, call actions.next() with form.input to submit the changes to the server:
const [node, actions] = useJourney(journeyClient);
const form = useJourneyForm(node);
async function handleSave() {
if (!form.canSubmit) return;
await actions.next(form.input);
}
KBA fields
KbaCreateCallback fields have field.kind === 'kba'. Their value is an object with three properties:
form.setValue(field.id, {
selectedQuestion: 'What was the name of your first pet?',
selectedAnswer: 'Fluffy',
allowUserDefinedQuestions: false,
});
Pre-defined questions are available in field.options:
const kbaField = form.getFieldByType('KbaCreateCallback');
const questions = kbaField?.options ?? [];
// [{ index: 0, value: 'What was...', label: 'What was...' }, ...]
Complete profile management example
The following component runs a profile management journey after initial authentication, handles attribute callbacks, and submits the changes:
import React, { useEffect } from 'react';
import {
ActivityIndicator,
Button,
ScrollView,
Switch,
Text,
TextInput,
View,
} from 'react-native';
import { useJourney, useJourneyForm } from '@ping-identity/rn-journey';
function ProfileManagementScreen({ journeyClient }) {
const [node, actions] = useJourney(journeyClient);
const form = useJourneyForm(node);
useEffect(() => {
void actions.start('sdkProfileManagement', {
forceAuth: true,
noSession: true,
});
}, []);
return () => {
void actions.dispose();
};
}, []);
if (actions.loading) {
return <ActivityIndicator />;
}
if (node?.type === 'SuccessNode') {
return <Text>Profile updated successfully.</Text>;
}
if (node?.type === 'FailureNode') {
return <Text>{node.message ?? 'Profile update failed.'}</Text>;
}
if (!node) {
return <Text>Starting profile journey...</Text>;
}
return (
<ScrollView>
{form.fields.map((field) => {
if (field.executionMode !== 'manual' || !field.requiresUserInput) {
return null;
}
const value = form.values[field.id] ?? field.defaultValue;
if (field.kind === 'text') {
return (
<View key={field.id}>
<Text>{field.prompt}</Text>
<TextInput
value={String(value ?? '')}
onChangeText={(text) => form.setValue(field.id, text)}
/>
</View>
);
}
if (field.kind === 'number') {
return (
<View key={field.id}>
<Text>{field.prompt}</Text>
<TextInput
keyboardType="numeric"
value={value != null ? String(value) : ''}
onChangeText={(text) =>
form.setValue(field.id, text === '' ? '' : Number(text))
}
/>
</View>
);
}
if (field.kind === 'boolean') {
return (
<View key={field.id}>
<Text>{field.prompt}</Text>
<Switch
value={Boolean(value)}
onValueChange={(v) => form.setValue(field.id, v)}
/>
</View>
);
}
if (field.kind === 'choice') {
return (
<View key={field.id}>
<Text>{field.prompt}</Text>
{field.options?.map((option) => (
<Text
key={option.index}
onPress={() => form.setValue(field.id, option.index)}
>
{option.label}
</Text>
))}
</View>
);
}
return null;
})}
<Button
title="Save Profile"
disabled={!form.canSubmit || actions.loading}
onPress={() => actions.next(form.input)}
/>
{actions.error ? (
<Text style={{ color: 'red' }}>{actions.error.message}</Text>
) : null}
</ScrollView>
);
}