diff --git a/.gitignore b/.gitignore
index 9153d1887..f5f66d945 100644
--- a/.gitignore
+++ b/.gitignore
@@ -36,3 +36,5 @@ junit.xml
# Turborepo folder for persisting logs and outputs
.turbo
.sdk/
+
+.polaris/
diff --git a/PingSampleApp/ios/PingSampleApp/Info.plist b/PingSampleApp/ios/PingSampleApp/Info.plist
index 1444d953d..99f7b4aa7 100644
--- a/PingSampleApp/ios/PingSampleApp/Info.plist
+++ b/PingSampleApp/ios/PingSampleApp/Info.plist
@@ -63,6 +63,15 @@
$(PRODUCT_NAME)
GIDClientID
$(GOOGLE_IOS_CLIENT_ID)
+ GIDServerClientID
+ $(GOOGLE_IOS_CLIENT_ID)
+ LSApplicationQueriesSchemes
+
+ fbapi
+ fbauth
+ fbauth2
+ fb-messenger-share-api
+
LSRequiresIPhoneOS
NSAppTransportSecurity
diff --git a/PingSampleApp/ios/Podfile.lock b/PingSampleApp/ios/Podfile.lock
index ba2ec4586..cb753e461 100644
--- a/PingSampleApp/ios/Podfile.lock
+++ b/PingSampleApp/ios/Podfile.lock
@@ -2570,6 +2570,7 @@ PODS:
- hermes-engine
- PingDavinci (= 2.0.0)
- PingDavinciPlugin (= 2.0.0)
+ - PingExternalIdP (= 2.0.0)
- PingLogger (= 2.0.0)
- PingOidc (= 2.0.0)
- PingOrchestrate (= 2.0.0)
@@ -3541,8 +3542,8 @@ SPEC CHECKSUMS:
MLKitCommon: 26b779f072a182c1603d4c88a101c350cac837b1
MLKitVision: fa8dea9012ac59497c79ddbe9ebf32051047ac4c
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
- NitroImage: 5c2e7ee0b9efd9179dbb18a5b6b14baab032fe85
- NitroModules: 7f1f24e38a8c6138e4f624ce65f929116edfd706
+ NitroImage: ee67d6f799e191273ae10439528e01c70c628d38
+ NitroModules: b6ae0e07aaae9c51cc144dd85f16e899de5eb1c6
PingBinding: 46036fcbcd4e3cb6a453f08822493ba451d4c8ab
PingBrowser: d1526bdcc6197781d0d54fe7ac16ba09a55a1faa
PingCommons: 49f2244bda1c41913fa37d9481e969702d658ade
@@ -3573,90 +3574,90 @@ SPEC CHECKSUMS:
RCTTypeSafety: d877728097547d0a37786cc9130c43ad71739ac3
React: 4b0b9cb962e694611e5e8a697c1b0300a2510c21
React-callinvoker: 70f125c17c7132811a6b473946ac5e7ae93b5e57
- React-Core: 7cbc3118df2334b2ef597d9a515938b02c82109f
- React-CoreModules: 7d8c14ecb889e7786a04637583b55b7d8f246baf
- React-cxxreact: f32be07cba236c2f20f4e05ca200577ba5358e78
+ React-Core: bab40f5b1f46fe0c5896895a6f333e861a821a81
+ React-CoreModules: 05647d952e521113c128360633896ba7ba652e82
+ React-cxxreact: 2b4bac1ec6eecc6288ac8a6caea6afb42585740e
React-debug: deb3a146ef717fa3e8f4c23e0288369fe53199b7
- React-defaultsnativemodule: 2c13a4240c5f96c42d069d1ba2392de6b4145bbd
- React-domnativemodule: 91349b0b1cb20310cec1341b87cdd461aaa85e57
- React-Fabric: bdfc7ec2481f26d7a9b8f59461f29ba4d903c549
- React-FabricComponents: 47898469543d1bfb4528a9846419ec5568be89b1
- React-FabricImage: ac8fc85ef452e5e9ae935c41118814651bd9e7f3
- React-featureflags: 793b911e4c53e680db4a7d9965d0d6dc87b2fa88
- React-featureflagsnativemodule: 25c9516d0dd004493c9bbafeb97da20bf9bde7dc
- React-graphics: e07281690425dd9eeba3875d1faad28bc1f6da3b
- React-hermes: bc1440d0e0662cc813bbf1c5ffbf9e0db2993a0f
- React-idlecallbacksnativemodule: a2a3bb4a1793280b34d06d00169153b094be8c16
- React-ImageManager: c9fa7461f3cab08e7bc98cbf55455b499e71c8b3
- React-jserrorhandler: 15e591702040afed99cfcd088cf2337a8d09d807
- React-jsi: 512ab3a1a628bc8824c41de8bcbbb81b2ac6fa8d
- React-jsiexecutor: 653ccd2dee1e5ea558eecaf2f27b8bba0f09add8
- React-jsinspector: 9121ccd2676a3f7c079ac01c9f90183422e3190e
- React-jsinspectorcdp: 5c723ff2a09d73f2fdc496a545fb7003e7fdc079
- React-jsinspectornetwork: 9cb0173f69e8405cef33fc79030fad26bbc3c073
- React-jsinspectortracing: 65dc04125dc2392d85a82b6916f8cb088ea77566
- React-jsitooling: 21af93cc98f760dd88d65b06b9317e0d4849fbbc
- React-jsitracing: 4cc1b7de8087ae41c61a0eeee2593bc3362908b6
- React-logger: 2f0d40bc8e648fbb1ff3b6580ad54189a8753290
- React-Mapbuffer: 9a7c65078c6851397c1999068989e4fc239d0c80
- React-microtasksnativemodule: 4f1ef719ba6c7ebbd2d75346ffa2916f9b4771c9
- react-native-config: 85858ab5e598762e783a43ac592561ad110c35a9
- react-native-safe-area-context: d446989793f96dc2f44c33c42dbfb316d983f24e
- React-NativeModulesApple: f6f696e510b9d89c3c06b7764f56947dc13ae922
+ React-defaultsnativemodule: 11e2948787a15d3cf1b66d7f29f13770a177bff7
+ React-domnativemodule: 2f4b279acdb2963736fb5de2f585811dd90070b5
+ React-Fabric: 6f8d1a303c96f1d078c14d74c4005bf457e5b782
+ React-FabricComponents: b106410970e9a0c4e592da656c7a7e0947306c23
+ React-FabricImage: 1abaf230dfce9b58fdf53c4128f3f40c6e64af6a
+ React-featureflags: f7ef58d91079efde3ad223bcca6d197e845d5bcf
+ React-featureflagsnativemodule: ae5abc9849d1696f4f8f11ee3744bf5715e032cf
+ React-graphics: b306856c6ed9aac32f717a229550406a53b28a6d
+ React-hermes: b6edce8fa19388654b1aea30844497cbeade83bc
+ React-idlecallbacksnativemodule: cb386712842cb9e479c89311edb234d529b64db4
+ React-ImageManager: 8ce94417853eaa22faaad1f4cc1952dd3f8e2275
+ React-jserrorhandler: ab827d67dc270a9c8703eef524230baeafaf6876
+ React-jsi: 545342ec5c78ab1277af5f0dbe8d489e7e73db14
+ React-jsiexecutor: 20210891c7c77255c16dec6762faf68b373f9f74
+ React-jsinspector: 4e73460e488132d70d2b4894e5578cc856f2cb74
+ React-jsinspectorcdp: 8b2bcb5779289cb2b9ca517f2965ed23eb2fd3e0
+ React-jsinspectornetwork: b5e0cb9e488d294eed2d8209dc3dc0f9587210c1
+ React-jsinspectortracing: f3c4036e7b984405ac910f878576d325dd9f2834
+ React-jsitooling: 75bbfd221b6173a5e848ca5a6680506bac064a56
+ React-jsitracing: 11ed7d821864dd988c159d4943e0a1e0937c11b1
+ React-logger: 984ebd897afad067555d081deaf03f57c4315723
+ React-Mapbuffer: 0c045c844ce6d85cde53e85ab163294c6adad349
+ React-microtasksnativemodule: d9499269ad1f484ae71319bac1d9231447f2094e
+ react-native-config: adc7d6e70302363192ed8f2eae027864d218ff71
+ react-native-safe-area-context: 01f6d357d42395422eed7c3bfe715614728f2989
+ React-NativeModulesApple: 983f3483ef0a3446b56d490f09d579fba2442e17
React-oscompat: 114036cd8f064558c9c1a0c04fc9ae5e1453706a
- React-perflogger: 4b2f88ae059b600daf268528a4a83366338eef05
- React-performancetimeline: e15fd9798123436f99e46898422fe921fecf506b
+ React-perflogger: e7287fee27c16e3c8bd4d470f2361572b63be16b
+ React-performancetimeline: 8ebbaa31d2d0cea680b0a2a567500d3cab8954fc
React-RCTActionSheet: 68c68b0a7a5d2b0cfc255c64889b6e485974e988
- React-RCTAnimation: 6bf502c89c53076f92cd1a254f5ec8d63ee263de
- React-RCTAppDelegate: c90f5732784684c3dd226d812eccb578cd954ad7
- React-RCTBlob: d2905f01749b80efd6d3b86fb15e30ed26d5450b
- React-RCTFabric: 435b3ffaad113fb1f274c2f2a677c9fcc9b5cf55
- React-RCTFBReactNativeSpec: a3178b419f42af196e90ca4bf07710dce5d68301
- React-RCTImage: 8f5ffa03461339180a68820ea452af6e20ace2c7
- React-RCTLinking: 1151646834d31f97580d8a75d768a84b2533b7f9
- React-RCTNetwork: 52008724d0db90a540f4058ed0de0e41c4b7943c
- React-RCTRuntime: 10ce9a7cb27ba307544d29a2a04e6202dc7b3e9a
- React-RCTSettings: f724cacbd892ee18f985e1aebdd97386e49c76f5
- React-RCTText: 6e1b95d9126d808410dfa96e09bc4441ec6f36f7
- React-RCTVibration: 862a4e5b36d49e6299c8cbfb86486fc31f86f6fa
+ React-RCTAnimation: d6c5c728b888a967ce9aff1ff71a8ed71a68d069
+ React-RCTAppDelegate: 0fc048666bda159cd469a6fb9befb04b3fa62be4
+ React-RCTBlob: 12d8c699a1f906840113ee8d8bb575e69a05509f
+ React-RCTFabric: 01e815845ebc185f44205dcbf50eeb712fec23fe
+ React-RCTFBReactNativeSpec: f57927fb0af6ce2f25c19f8b894e2986138aa89f
+ React-RCTImage: a82518168f4ee407913b23ca749ca79ef51959f3
+ React-RCTLinking: 7f343b584c36f024f390fea563483568fe763ef6
+ React-RCTNetwork: 3165eb757ceb62a7cde4cdad043d63314122e8a3
+ React-RCTRuntime: feee590c459c4cb6aaa7a00f3abc8c04709b536f
+ React-RCTSettings: 6bad0ae45d8d872c873059f332f586f99875621f
+ React-RCTText: 657d60f35983062de8f0cea67c279aa7a3ea9858
+ React-RCTVibration: 78f4770515141efb7f55f9b27c49dda95319c3a8
React-rendererconsistency: f7baab26c6d0cd5b2eb7afcecfd2d8b957017b18
- React-renderercss: 62acb8f010a062309e3bd0e203aa14636162e3b3
- React-rendererdebug: 3a89ac44f15c7160735264d585a29525655238d2
+ React-renderercss: bdd2f83a4a054c3e4321fd61305c202b848e471b
+ React-rendererdebug: 9f8865ee038127a9d99d4b034c9da4935d204993
React-rncore: f7438473c4c71ee1963fb06a8635bb96013c9e1c
- React-RuntimeApple: 81f0a9ba81ce7eb203529b0471dc69bf18f5f637
- React-RuntimeCore: 6356e89b2518ba66a989c39a2adb18122a5e3b7b
+ React-RuntimeApple: 4d2ab9f72b9193da86eceded128a67254fc18aeb
+ React-RuntimeCore: 5fd73030438d094975ca0f549d162dd97746ae38
React-runtimeexecutor: 17c70842d5e611130cb66f91e247bc4a609c3508
- React-RuntimeHermes: 0a1d7ce2fe08cf182235de1a9330b51aa6b935cd
- React-runtimescheduler: 10ae98e1417eff159be5df8fdc8fcdaac557aba6
+ React-RuntimeHermes: 3c88e6e1ea7ea0899dcffc77c10d61ea46688cfd
+ React-runtimescheduler: 024500621c7c93d65371498abb4ee26d34f5d47d
React-timing: c3c923df2b86194e1682e01167717481232f1dc7
- React-utils: 7791a96e194eec85cb41dc98a2045b5f07839598
- ReactAppDependencyProvider: ba631a31783569c13056dd57ff39e19764abdd6f
- ReactCodegen: 1e9f3e8a3f56fa25fbf39ecd37b708a4838d9032
- ReactCommon: 96684b90b235d6ae340d126141edd4563b7a446a
- RNCAsyncStorage: 767abb068db6ad28b5f59a129fbc9fab18b377e2
- RNPingBinding: c9449a4e6b808afc9dcc311a07804559c0c522b4
- RNPingBrowser: 042c960b67b47a85b0e1de373b76ddb2609487e1
- RNPingCore: 3926e39f9ab60d83fb4d58225303864ca0d8171b
- RNPingDavinci: 44860fa051499d949bad798336c15e508a78fb09
- RNPingDeviceClient: bd78e33420a8f5dcd6ab3b21691805743b12079f
- RNPingDeviceId: 24f49f8d518103367f6bbcbcf54138a424541ba7
- RNPingDeviceProfile: 4c6b306859c9bc9cdad0aea830d9a8e0227dcc4a
- RNPingExternalIdp: 372123fab1006f80c184a1f7992a3b335f69eb3c
- RNPingFido: a67c3cb1fa2ff1930476189e5ebc1bc0a6e84d4a
- RNPingJourney: 90546edde90b7c30f9b4a526a094e531a92094fe
- RNPingLogger: db39fa39b31cd5026a7c279cd52996fafac190e1
- RNPingOath: 2e64a03cd27ac198b21d2e4a4d767eaa31bd2b03
- RNPingOidc: dceb88744c13a9bdd44a339f244d3a0535bf8232
- RNPingPush: 898e5dd90a372e2ac9a27f0c622c2f17614fbd26
- RNPingStorage: 17cf506d51259fa7c64a649d97ed974bd5350b22
- RNScreens: 846d53087db560ed5fbc34feb0643adb5f9602c5
- RNSVG: e8fb86f41fccd7b67c4480bb9e179e0ad5785b80
- RNVectorIcons: 54df27a2e90ddeb674c7237d76060ec9762d0bc5
+ React-utils: 9154a037543147e1c24098f1a48fc8472602c092
+ ReactAppDependencyProvider: afd905e84ee36e1678016ae04d7370c75ed539be
+ ReactCodegen: 62690fdeb185860673cb78e3d9b049c7e1d40531
+ ReactCommon: 17fd88849a174bf9ce45461912291aca711410fc
+ RNCAsyncStorage: 1f04c8d56558e533277beda29187f571cf7eecb2
+ RNPingBinding: 217434d77078006a9578d5c71e6b91ebecb30628
+ RNPingBrowser: f2e419bf46f91bc108da9dedadd7180e733daeb5
+ RNPingCore: 1b0525ac7164f9ddb61b686c45b67f19530bb481
+ RNPingDavinci: 89c800fc120bcab9721411ee893b45f75791f1b5
+ RNPingDeviceClient: 031ca1ff479e23e95e2dabfc1564b6146f23f5ff
+ RNPingDeviceId: 36f6fc724c12af43e6eb7786b0b1a0e6bead491c
+ RNPingDeviceProfile: 6e7a3749b92ae74dc0bf937ec5c804ba4b33a3aa
+ RNPingExternalIdp: a3e7bff2d34f8c16882603b34d1700e253593888
+ RNPingFido: 4c4c4d14f876b8691ebd4eea65b4e9b3891347e0
+ RNPingJourney: a4c26d4df1566b86c2f4e4b96fb1c3a1a85e476f
+ RNPingLogger: ff542c88b69b4f4e8fa188e161ac18b8384e029d
+ RNPingOath: 5f47ad55b82bc9929366c51c1161438fc6f8be6c
+ RNPingOidc: 7f639c41e84ada3c3852bc86d103b53d466adf76
+ RNPingPush: c1b34c4c598d10f28e643252875dbf4ace9248ea
+ RNPingStorage: e736d6eeb163d1bcca220207366a33746e06bd5d
+ RNScreens: f35bcc83b6e3a871635b1c28728e63c9560f159a
+ RNSVG: 6c85ed4204fc8680c2f6fca3532277fd4f65e5ab
+ RNVectorIcons: be4d047a76ad307ffe54732208fb0498fcb8477f
SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748
- VisionCamera: 3914b29387fc40c64f7022830cfdc604f94eb966
- VisionCameraBarcodeScanner: 0b6fe2e3c52ca68a0dbd5d25719c7523c68d807f
+ VisionCamera: e82024ee7ce0fb6168a4c468ef43c711f4486baa
+ VisionCameraBarcodeScanner: a65d708fd3672e6d43e94cce39b548465851752b
Yoga: daa1e4de4b971b977b23bc842aaa3e135324f1f3
PODFILE CHECKSUM: 83df8013c84a3febb48d5c5092b148ef9136e104
-COCOAPODS: 1.15.2
+COCOAPODS: 1.16.2
diff --git a/PingSampleApp/metro.config.js b/PingSampleApp/metro.config.js
index 89c1c2872..386727ac9 100644
--- a/PingSampleApp/metro.config.js
+++ b/PingSampleApp/metro.config.js
@@ -30,6 +30,7 @@ const deviceClientPackage = path.resolve(
__dirname,
'../packages/device-client',
);
+const davinciPackage = path.resolve(__dirname, '../packages/davinci');
const pushPackage = path.resolve(__dirname, '../packages/push');
const workspaceRoot = path.resolve(__dirname, '..');
@@ -46,6 +47,7 @@ const config = {
loggerPackage,
typesPackage,
bindingPackage,
+ davinciPackage,
deviceClientPackage,
pushPackage,
workspaceRoot,
@@ -71,6 +73,7 @@ const config = {
'@ping-identity/rn-logger': loggerPackage,
'@ping-identity/rn-types': typesPackage,
'@ping-identity/rn-binding': bindingPackage,
+ '@ping-identity/rn-davinci': davinciPackage,
'@ping-identity/rn-device-client': deviceClientPackage,
'@ping-identity/rn-push': pushPackage,
},
diff --git a/PingSampleApp/ui/DaVinciScreen.tsx b/PingSampleApp/ui/DaVinciScreen.tsx
index 37b595742..d40ee3f96 100644
--- a/PingSampleApp/ui/DaVinciScreen.tsx
+++ b/PingSampleApp/ui/DaVinciScreen.tsx
@@ -27,6 +27,9 @@ export default function DaVinciScreen(props: Props): React.ReactElement {
const handleAuthenticated = useCallback((): void => {
navigation.navigate('Home');
}, [navigation]);
+ const handleUserProfile = useCallback((): void => {
+ navigation.navigate('UserProfile');
+ }, [navigation]);
return (
-
+
);
}
diff --git a/PingSampleApp/ui/davinci/components/molecules/DaVinciFieldRenderer.tsx b/PingSampleApp/ui/davinci/components/molecules/DaVinciFieldRenderer.tsx
index 4f56c363c..52b086463 100644
--- a/PingSampleApp/ui/davinci/components/molecules/DaVinciFieldRenderer.tsx
+++ b/PingSampleApp/ui/davinci/components/molecules/DaVinciFieldRenderer.tsx
@@ -8,6 +8,7 @@
import React, { memo } from 'react';
import DaVinciDeviceField from './DaVinciDeviceField';
import DaVinciFlowButton from './DaVinciFlowButton';
+import DaVinciIdpField from './DaVinciIdpField';
import DaVinciLabelField from './DaVinciLabelField';
import DaVinciMultiSelectField from './DaVinciMultiSelectField';
import DaVinciPasswordField from './DaVinciPasswordField';
@@ -56,6 +57,10 @@ function DaVinciFieldRenderer(
case 'DEVICE_REGISTRATION':
case 'DEVICE_AUTHENTICATION':
return ;
+ case 'SOCIAL_LOGIN_BUTTON':
+ return (
+
+ );
default:
return ;
}
diff --git a/PingSampleApp/ui/davinci/components/molecules/DaVinciIdpField.tsx b/PingSampleApp/ui/davinci/components/molecules/DaVinciIdpField.tsx
new file mode 100644
index 000000000..76510f3a5
--- /dev/null
+++ b/PingSampleApp/ui/davinci/components/molecules/DaVinciIdpField.tsx
@@ -0,0 +1,134 @@
+/*
+ * Copyright (c) 2026 Ping Identity Corporation. All rights reserved.
+ *
+ * This software may be modified and distributed under the terms
+ * of the MIT license. See the LICENSE file for details.
+ */
+
+import React from 'react';
+import { Text, TouchableOpacity, View } from 'react-native';
+import FontAwesomeIcon from 'react-native-vector-icons/FontAwesome';
+import type { IdpCollector } from '@ping-identity/rn-davinci';
+import { commonStyles } from '../../../../src/styles/common';
+import { journeyFieldRendererStyles as fieldStyles } from '../../../../src/styles/journeyStyles';
+import { davinciFieldStyles } from '../../../../src/styles/davinciStyles';
+import type { DaVinciCollectorRendererProps } from './types';
+
+/**
+ * Maps an IdpCollector `idpType` (e.g. `'GOOGLE'`) to a FontAwesome brand
+ * icon name. Returns `null` when no match is found.
+ */
+function resolveIdpIcon(idpType: string): string | null {
+ const lower = idpType.toLowerCase();
+ if (lower.includes('facebook')) return 'facebook';
+ if (lower.includes('google')) return 'google';
+ if (lower.includes('apple')) return 'apple';
+ return null;
+}
+
+/** Brand colors used when the server doesn't supply custom style. */
+const BRAND_STYLES: Record<
+ string,
+ {
+ backgroundColor: string;
+ borderColor?: string;
+ iconBackground: string;
+ iconColor: string;
+ textColor: string;
+ }
+> = {
+ google: {
+ backgroundColor: '#ffffff',
+ iconBackground: '#ffffff',
+ iconColor: '#4285F4',
+ textColor: '#3c4043',
+ borderColor: '#dadce0',
+ },
+ facebook: {
+ backgroundColor: '#1877F2',
+ iconBackground: '#1877F2',
+ iconColor: '#ffffff',
+ textColor: '#ffffff',
+ },
+ apple: {
+ backgroundColor: '#000000',
+ iconBackground: '#000000',
+ iconColor: '#ffffff',
+ textColor: '#ffffff',
+ },
+};
+
+/**
+ * Props for {@link DaVinciIdpField}.
+ */
+export type DaVinciIdpFieldProps = DaVinciCollectorRendererProps & {
+ /**
+ * Called when the user presses the social login button.
+ *
+ * @param collector The IdpCollector to authorize.
+ */
+ onIdpAuthorize: (collector: IdpCollector) => Promise;
+};
+
+/**
+ * Renders an {@link IdpCollector} as a branded social login button with a
+ * FontAwesome icon for Google, Facebook, and Apple.
+ *
+ * @param props Renderer props extended with `onIdpAuthorize`.
+ * @returns Social login button element.
+ */
+export default function DaVinciIdpField(
+ props: DaVinciIdpFieldProps,
+): React.ReactElement {
+ const { collector, loading, onIdpAuthorize } = props;
+ const idpCollector = collector as IdpCollector;
+ const iconName = resolveIdpIcon(idpCollector.idpType);
+ const brand = iconName ? BRAND_STYLES[iconName] : undefined;
+
+ return (
+
+ void onIdpAuthorize(idpCollector)}
+ disabled={loading || !idpCollector.idpEnabled}
+ activeOpacity={0.8}
+ >
+
+ {iconName ? (
+
+
+
+ ) : null}
+
+ {idpCollector.label || idpCollector.idpType}
+
+
+
+
+ );
+}
diff --git a/PingSampleApp/ui/davinci/components/molecules/types.ts b/PingSampleApp/ui/davinci/components/molecules/types.ts
index 725fc5726..53c41af0b 100644
--- a/PingSampleApp/ui/davinci/components/molecules/types.ts
+++ b/PingSampleApp/ui/davinci/components/molecules/types.ts
@@ -8,6 +8,7 @@
import type {
DaVinciFormValue,
DaVinciNormalizedCollector,
+ IdpCollector,
} from '@ping-identity/rn-davinci';
/**
@@ -46,4 +47,10 @@ export type DaVinciCollectorRendererProps = {
* Whether the form can currently be submitted.
*/
canSubmit: boolean;
+ /**
+ * Called when the user presses a social login button for an IDP collector.
+ *
+ * @param collector The IdpCollector to authorize.
+ */
+ onIdpAuthorize: (collector: IdpCollector) => Promise;
};
diff --git a/PingSampleApp/ui/davinci/components/organisms/DaVinciClientPanel.tsx b/PingSampleApp/ui/davinci/components/organisms/DaVinciClientPanel.tsx
index 262007302..69c7e8b31 100644
--- a/PingSampleApp/ui/davinci/components/organisms/DaVinciClientPanel.tsx
+++ b/PingSampleApp/ui/davinci/components/organisms/DaVinciClientPanel.tsx
@@ -26,6 +26,13 @@ export type DaVinciClientPanelProps = {
* Use this to navigate away from the DaVinci screen on success.
*/
onAuthenticated?: () => void;
+ /**
+ * Optional callback fired when the user taps "User Profile".
+ *
+ * @remarks
+ * Use this to navigate to the user profile screen.
+ */
+ onUserProfile?: () => void;
};
/**
@@ -42,16 +49,18 @@ export type DaVinciClientPanelProps = {
export default function DaVinciClientPanel(
props: DaVinciClientPanelProps,
): React.ReactElement {
- const { onAuthenticated } = props;
+ const { onAuthenticated, onUserProfile } = props;
const {
node,
form,
loading,
error,
+ idpError,
hasActiveSession,
isSessionCheckRunning,
onSubmit,
onFlowAction,
+ onIdpAuthorize,
onStart,
onLogout,
} = useDaVinciClientPanelController({ onAuthenticated });
@@ -66,6 +75,7 @@ export default function DaVinciClientPanel(
loading={loading}
onSubmit={onSubmit}
onFlowAction={onFlowAction}
+ onIdpAuthorize={onIdpAuthorize}
/>
) : null}
@@ -76,6 +86,13 @@ export default function DaVinciClientPanel(
You have completed the DaVinci flow successfully.
+ {onUserProfile ? (
+
+ ) : null}
-
- The server reported an error
-
-
- {node.message || 'Please try again.'}
-
-
+
) : null}
{node?.type === 'FailureNode' ? (
@@ -113,6 +128,15 @@ export default function DaVinciClientPanel(
/>
) : null}
+ {idpError ? (
+
+ ) : null}
+
{error ? (
{`[${error.code}] ${error.message}`}
diff --git a/PingSampleApp/ui/davinci/components/organisms/DaVinciContinueNodePanel.tsx b/PingSampleApp/ui/davinci/components/organisms/DaVinciContinueNodePanel.tsx
index 106eccad2..71e42f936 100644
--- a/PingSampleApp/ui/davinci/components/organisms/DaVinciContinueNodePanel.tsx
+++ b/PingSampleApp/ui/davinci/components/organisms/DaVinciContinueNodePanel.tsx
@@ -11,6 +11,7 @@ import type {
ContinueNode,
DaVinciFormResult,
DaVinciNormalizedCollector,
+ IdpCollector,
UnsupportedDaVinciField,
} from '@ping-identity/rn-davinci';
import { commonStyles } from '../../../../src/styles/common';
@@ -45,6 +46,12 @@ export type DaVinciContinueNodePanelProps = {
* @param flowKey Flow collector key.
*/
onFlowAction: (flowKey: string) => void;
+ /**
+ * Launches the social login browser flow for an IDP collector.
+ *
+ * @param collector The IdpCollector to authorize.
+ */
+ onIdpAuthorize: (collector: IdpCollector) => Promise;
};
/**
@@ -104,7 +111,7 @@ function renderUnsupportedFieldsNotice(
export default function DaVinciContinueNodePanel(
props: DaVinciContinueNodePanelProps,
): React.ReactElement {
- const { node, form, loading, onSubmit, onFlowAction } = props;
+ const { node, form, loading, onSubmit, onFlowAction, onIdpAuthorize } = props;
const { fields, values, canSubmit, issues, setValue } = form;
const showFallbackSubmit = useMemo(
@@ -124,6 +131,7 @@ export default function DaVinciContinueNodePanel(
onChange={next => setValue(collector.key, next)}
onSubmit={onSubmit}
onFlowAction={onFlowAction}
+ onIdpAuthorize={onIdpAuthorize}
loading={loading}
canSubmit={canSubmit}
/>
diff --git a/PingSampleApp/ui/davinci/hooks/useDaVinciAutoForwarder.ts b/PingSampleApp/ui/davinci/hooks/useDaVinciAutoForwarder.ts
new file mode 100644
index 000000000..a80fcf89f
--- /dev/null
+++ b/PingSampleApp/ui/davinci/hooks/useDaVinciAutoForwarder.ts
@@ -0,0 +1,95 @@
+/*
+ * Copyright (c) 2026 Ping Identity Corporation. All rights reserved.
+ *
+ * This software may be modified and distributed under the terms
+ * of the MIT license. See the LICENSE file for details.
+ */
+
+import { useEffect, useRef } from 'react';
+import type {
+ DaVinciFormResult,
+ DaVinciNextInput,
+ DaVinciNode,
+} from '@ping-identity/rn-davinci';
+
+/**
+ * Options accepted by `useDaVinciAutoForwarder`.
+ */
+export type UseDaVinciAutoForwarderOptions = {
+ /**
+ * Active DaVinci node.
+ */
+ node: DaVinciNode | null;
+ /**
+ * Headless form state for the active node.
+ */
+ form: DaVinciFormResult;
+ /**
+ * True while a DaVinci action is in flight.
+ */
+ loading: boolean;
+ /**
+ * Stable key derived from the active node — used to deduplicate
+ * auto-submits so the same node is never forwarded twice.
+ */
+ continueNodeKey: string;
+ /**
+ * True only in the window immediately after `authorizeForDaVinci` completes.
+ * Prevents the forwarder from firing on the initial flow node.
+ */
+ enabled: boolean;
+ /**
+ * Called after the forwarder fires so the controller can clear the enabled flag.
+ */
+ onForwarded: () => void;
+ /**
+ * DaVinci `next` action.
+ */
+ next: (input: DaVinciNextInput) => Promise;
+};
+
+/**
+ * Auto-forwards a DaVinci `ContinueNode` that requires no manual user input,
+ * but only when `enabled` is true (i.e. immediately after an IdP authorize).
+ *
+ * @remarks
+ * Mirrors the Android sample app's post-IdP-authorize behavior: after
+ * `authorizeForDaVinci` + `next()` the server returns an intermediate
+ * `ContinueNode` containing only a `SUBMIT_BUTTON`. This hook detects that
+ * state and calls `next(input)` automatically so the user never sees a
+ * "Continue" button they did not need to tap.
+ *
+ * The `enabled` gate ensures this never fires on the initial `start()` node.
+ *
+ * @param options - Auto-forwarder options.
+ */
+export function useDaVinciAutoForwarder(
+ options: UseDaVinciAutoForwarderOptions,
+): void {
+ const { node, form, loading, continueNodeKey, enabled, onForwarded, next } =
+ options;
+ const lastNodeKeyRef = useRef(null);
+
+ useEffect(() => {
+ if (node?.type !== 'ContinueNode') {
+ lastNodeKeyRef.current = null;
+ return;
+ }
+ if (!enabled) return;
+ if (loading) return;
+ if (!continueNodeKey) return;
+ if (lastNodeKeyRef.current === continueNodeKey) return;
+
+ const { meta, canSubmit, input } = form;
+ if (meta.hasManual) return;
+ if (meta.hasIntegrationRequired) return;
+ if (meta.hasUnsupported) return;
+ if (!canSubmit) return;
+
+ lastNodeKeyRef.current = continueNodeKey;
+ onForwarded();
+ void next(input).catch(() => {
+ // Error state is surfaced by the useDaVinci hook.
+ });
+ }, [continueNodeKey, enabled, form, loading, next, node?.type, onForwarded]);
+}
diff --git a/PingSampleApp/ui/davinci/hooks/useDaVinciAutoStartEffect.ts b/PingSampleApp/ui/davinci/hooks/useDaVinciAutoStartEffect.ts
index 9c93ffbaa..2a813405a 100644
--- a/PingSampleApp/ui/davinci/hooks/useDaVinciAutoStartEffect.ts
+++ b/PingSampleApp/ui/davinci/hooks/useDaVinciAutoStartEffect.ts
@@ -5,7 +5,8 @@
* of the MIT license. See the LICENSE file for details.
*/
-import { useEffect, useRef } from 'react';
+import { useCallback, useEffect, useRef } from 'react';
+import { useFocusEffect } from '@react-navigation/native';
import type { DaVinciNode } from '@ping-identity/rn-davinci';
/**
@@ -36,6 +37,30 @@ export function useDaVinciAutoStartEffect(
const { loading, isSessionCheckRunning, hasActiveSession, node, onStart } =
options;
const hasAutoStartedRef = useRef(false);
+ // Keep a ref so useFocusEffect can read the current node type without
+ // listing it as a dependency — prevents the callback from firing when the
+ // node changes while the screen is already focused (which would auto-clear
+ // error/failure screens before the user reads them).
+ const nodeTypeRef = useRef(node?.type);
+ useEffect(() => {
+ nodeTypeRef.current = node?.type;
+ });
+
+ // When the screen re-focuses after a terminal node (e.g. user navigated
+ // away and came back), restart the flow for a fresh login form.
+ useFocusEffect(
+ useCallback(() => {
+ const currentType = nodeTypeRef.current;
+ if (
+ currentType === 'SuccessNode' ||
+ currentType === 'ErrorNode' ||
+ currentType === 'FailureNode'
+ ) {
+ hasAutoStartedRef.current = true;
+ void onStart();
+ }
+ }, [onStart]),
+ );
useEffect(() => {
if (hasAutoStartedRef.current) return;
diff --git a/PingSampleApp/ui/davinci/hooks/useDaVinciClientPanelController.ts b/PingSampleApp/ui/davinci/hooks/useDaVinciClientPanelController.ts
index 990afe273..3f19efc29 100644
--- a/PingSampleApp/ui/davinci/hooks/useDaVinciClientPanelController.ts
+++ b/PingSampleApp/ui/davinci/hooks/useDaVinciClientPanelController.ts
@@ -5,16 +5,22 @@
* of the MIT license. See the LICENSE file for details.
*/
-import { useCallback } from 'react';
+import { useCallback, useMemo, useState } from 'react';
import {
useDaVinci,
+ useDaVinciContext,
useDaVinciForm,
type DaVinciError,
type DaVinciFormResult,
type DaVinciNode,
+ type IdpCollector,
} from '@ping-identity/rn-davinci';
+import { createExternalIdpClient } from '@ping-identity/rn-external-idp';
+import { logger } from '@ping-identity/rn-logger';
+import Config from 'react-native-config';
import { useDaVinciSessionController } from './useDaVinciSessionController';
import { useDaVinciAutoStartEffect } from './useDaVinciAutoStartEffect';
+import { useDaVinciAutoForwarder } from './useDaVinciAutoForwarder';
/**
* Result contract consumed by `DaVinciClientPanel`.
@@ -28,6 +34,8 @@ export type UseDaVinciClientPanelControllerResult = {
loading: boolean;
/** Last hook-level DaVinci error. */
error: DaVinciError | null;
+ /** Last IdP-authorize error message (cleared on next start). */
+ idpError: string | null;
/** True when a session is confirmed active for the current DaVinci client. */
hasActiveSession: boolean;
/** True while the initial session bootstrap check is running. */
@@ -47,6 +55,13 @@ export type UseDaVinciClientPanelControllerResult = {
* @param flowKey - Flow collector key.
*/
onFlowAction: (flowKey: string) => void;
+ /**
+ * Launches the social login browser flow for an IDP collector, then
+ * automatically advances the DaVinci flow via `next()`.
+ *
+ * @param collector - The IdpCollector to authorize.
+ */
+ onIdpAuthorize: (collector: IdpCollector) => Promise;
/** Restarts the DaVinci flow. */
onStart: () => Promise;
/** Logs out the active user and clears local session state. */
@@ -80,8 +95,23 @@ export function useDaVinciClientPanelController(
options: UseDaVinciClientPanelControllerOptions = {},
): UseDaVinciClientPanelControllerResult {
const { onAuthenticated } = options;
+ const davinciContext = useDaVinciContext();
const { node, loading, error, start, next, user, logoutUser } = useDaVinci();
- const form = useDaVinciForm(node);
+ const externalIdpLogger = useMemo(() => logger({ level: 'debug' }), []);
+ const externalIdp = useMemo(
+ () =>
+ createExternalIdpClient({
+ redirectUri: Config.PINGONE_REDIRECT_URI ?? '',
+ logger: externalIdpLogger,
+ }),
+ [externalIdpLogger],
+ );
+ const form = useDaVinciForm(node, {
+ handledCollectorTypes: new Set(['SOCIAL_LOGIN_BUTTON']),
+ });
+
+ const [idpError, setIdpError] = useState(null);
+ const [idpJustAuthorized, setIdpJustAuthorized] = useState(false);
const { hasActiveSession, setHasActiveSession, isSessionCheckRunning } =
useDaVinciSessionController({
@@ -90,6 +120,7 @@ export function useDaVinciClientPanelController(
});
const onStart = useCallback(async (): Promise => {
+ setIdpError(null);
try {
await start();
return true;
@@ -107,6 +138,21 @@ export function useDaVinciClientPanelController(
onStart,
});
+ const continueNodeKey = useMemo(() => {
+ if (node?.type !== 'ContinueNode') return '';
+ return node.collectors.map(c => c.key ?? c.type).join(',');
+ }, [node]);
+
+ useDaVinciAutoForwarder({
+ node,
+ form,
+ loading,
+ continueNodeKey,
+ enabled: idpJustAuthorized,
+ onForwarded: () => setIdpJustAuthorized(false),
+ next,
+ });
+
const onSubmit = useCallback((): void => {
if (loading) {
return;
@@ -120,6 +166,41 @@ export function useDaVinciClientPanelController(
});
}, [form, loading, next]);
+ const onIdpAuthorize = useCallback(
+ async (collector: IdpCollector): Promise => {
+ if (loading) {
+ return;
+ }
+ const davinciClient = davinciContext?.client;
+ if (!davinciClient) {
+ console.warn(
+ '[DaVinci] authorizeForDaVinci: no DaVinci client in context',
+ );
+ return;
+ }
+ // Determine which index this collector occupies among IDP collectors on the
+ // current node so the native side can resolve the right IdpCollector.
+ const idpFields = form.fields.filter(
+ f => f.type === 'SOCIAL_LOGIN_BUTTON',
+ );
+ const index = idpFields.findIndex(f => f.key === collector.key);
+ try {
+ // authorizeForDaVinci opens the IdP browser flow and sets the
+ // resume request internally — token flows through the subsequent next().
+ await externalIdp.authorizeForDaVinci(davinciClient, {
+ index: index >= 0 ? index : 0,
+ });
+ setIdpJustAuthorized(true);
+ await next({ collectors: [] });
+ } catch (err) {
+ console.warn('[DaVinci] authorizeForDaVinci failed:', err);
+ const msg = err instanceof Error ? err.message : String(err);
+ setIdpError(msg);
+ }
+ },
+ [davinciContext?.client, externalIdp, form.fields, loading, next],
+ );
+
const onFlowAction = useCallback(
(flowKey: string): void => {
if (loading) {
@@ -156,10 +237,12 @@ export function useDaVinciClientPanelController(
form,
loading,
error,
+ idpError,
hasActiveSession,
isSessionCheckRunning,
onSubmit,
onFlowAction,
+ onIdpAuthorize,
onStart: onStartAction,
onLogout,
};
diff --git a/PingSampleApp/ui/userProfile/components/molecules/UserProfileInfoCard.tsx b/PingSampleApp/ui/userProfile/components/molecules/UserProfileInfoCard.tsx
index 18bd00e5d..d54aef030 100644
--- a/PingSampleApp/ui/userProfile/components/molecules/UserProfileInfoCard.tsx
+++ b/PingSampleApp/ui/userProfile/components/molecules/UserProfileInfoCard.tsx
@@ -57,14 +57,14 @@ const asDisplayValue = (value: unknown): string | null => {
*
* @param payload - User info payload.
* @param keys - Candidate keys in lookup order.
- * @returns Display string for the first matching key.
+ * @returns Display string for the first matching key, or `null` when unavailable.
*/
const readProfileField = (
payload: Record | null,
keys: readonly string[],
-): string => {
+): string | null => {
if (!payload) {
- return 'Not available';
+ return null;
}
for (const key of keys) {
const value = asDisplayValue(payload[key]);
@@ -72,17 +72,7 @@ const readProfileField = (
return value;
}
}
- return 'Not available';
-};
-
-/**
- * Adds quotes to values that are present.
- *
- * @param value - Display value.
- * @returns Quoted value when available.
- */
-const wrapProfileValue = (value: string): string => {
- return value === 'Not available' ? value : `"${value}"`;
+ return null;
};
/**
@@ -96,18 +86,26 @@ export default function UserProfileInfoCard(
): React.ReactElement {
const { title, userInfo, showRawUserInfo, onToggleRawUserInfo } = props;
- const firstName = wrapProfileValue(
- readProfileField(userInfo, ['given_name', 'firstName', 'first_name']),
- );
- const familyName = wrapProfileValue(
- readProfileField(userInfo, ['family_name', 'lastName', 'last_name']),
- );
- const email = wrapProfileValue(readProfileField(userInfo, ['email']));
+ const firstName = readProfileField(userInfo, [
+ 'given_name',
+ 'firstName',
+ 'first_name',
+ ]);
+ const familyName = readProfileField(userInfo, [
+ 'family_name',
+ 'lastName',
+ 'last_name',
+ ]);
+ const email = readProfileField(userInfo, ['email']);
const infoItems: KeyValueItem[] = [
- { label: 'First name', value: firstName },
- { label: 'Family name', value: familyName },
- { label: 'Email', value: email },
- ];
+ firstName !== null
+ ? { label: 'First name', value: `"${firstName}"` }
+ : null,
+ familyName !== null
+ ? { label: 'Family name', value: `"${familyName}"` }
+ : null,
+ { label: 'Email', value: email !== null ? `"${email}"` : 'Not available' },
+ ].filter((item): item is KeyValueItem => item !== null);
return (
diff --git a/PingTestRunner/__tests__/integration/external-idp.test.ts b/PingTestRunner/__tests__/integration/external-idp.test.ts
index 767e5270a..d0920ee96 100644
--- a/PingTestRunner/__tests__/integration/external-idp.test.ts
+++ b/PingTestRunner/__tests__/integration/external-idp.test.ts
@@ -26,6 +26,7 @@ export {};
type NativeExternalIdpMock = {
authorizeForJourney: jest.Mock;
selectProviderForJourney: jest.Mock;
+ authorizeForDaVinci: jest.Mock;
};
function makeMock(
@@ -37,6 +38,7 @@ function makeMock(
additionalParameters: { foo: 'bar' },
})),
selectProviderForJourney: jest.fn(async () => undefined),
+ authorizeForDaVinci: jest.fn(async () => undefined),
...overrides,
};
}
@@ -86,11 +88,12 @@ describe('@ping-identity/rn-external-idp — integration', () => {
expect(typeof mod.createExternalIdpClient).toBe('function');
});
- it('createExternalIdpClient returns a client with authorizeForJourney and selectProviderForJourney', async () => {
+ it('createExternalIdpClient returns a client with authorizeForJourney, selectProviderForJourney, and authorizeForDaVinci', async () => {
const mod = await loadExternalIdp(makeMock());
const client = mod.createExternalIdpClient({});
expect(typeof client.authorizeForJourney).toBe('function');
expect(typeof client.selectProviderForJourney).toBe('function');
+ expect(typeof client.authorizeForDaVinci).toBe('function');
});
});
@@ -290,6 +293,133 @@ describe('@ping-identity/rn-external-idp — integration', () => {
});
});
+ // ─── authorizeForDaVinci() ────────────────────────────────────────────────
+
+ describe('authorizeForDaVinci()', () => {
+ it('resolves the davinci id and forwards it to native', async () => {
+ const mock = makeMock();
+ const mod = await loadExternalIdp(mock);
+ const client = mod.createExternalIdpClient({});
+ const daVinci = { getId: jest.fn(async () => 'davinci-123') };
+
+ await client.authorizeForDaVinci(daVinci);
+
+ expect(daVinci.getId).toHaveBeenCalledTimes(1);
+ expect(mock.authorizeForDaVinci).toHaveBeenCalledWith(
+ 'davinci-123',
+ expect.any(Object),
+ expect.any(Object),
+ );
+ });
+
+ it('forwards options to native', async () => {
+ const mock = makeMock();
+ const mod = await loadExternalIdp(mock);
+ const client = mod.createExternalIdpClient({
+ redirectUri: 'com.myapp://callback',
+ });
+ const daVinci = { getId: jest.fn(async () => 'davinci-opts') };
+
+ await client.authorizeForDaVinci(daVinci, { index: 2 });
+
+ expect(mock.authorizeForDaVinci).toHaveBeenCalledWith(
+ 'davinci-opts',
+ { index: 2 },
+ expect.any(Object),
+ );
+ });
+
+ it('forwards an empty options object when no options are provided', async () => {
+ const mock = makeMock();
+ const mod = await loadExternalIdp(mock);
+ const client = mod.createExternalIdpClient({});
+ const daVinci = { getId: jest.fn(async () => 'davinci-defaults') };
+
+ await client.authorizeForDaVinci(daVinci);
+
+ expect(mock.authorizeForDaVinci).toHaveBeenCalledWith(
+ 'davinci-defaults',
+ {},
+ expect.any(Object),
+ );
+ });
+
+ it('forwards redirectUri and loggerId from config to native', async () => {
+ const mock = makeMock();
+ const mod = await loadExternalIdp(mock);
+ const logger = {
+ nativeHandle: { id: 'logger-handle-dv' },
+ changeLevel: jest.fn(),
+ error: jest.fn(),
+ warn: jest.fn(),
+ info: jest.fn(),
+ debug: jest.fn(),
+ };
+ const client = mod.createExternalIdpClient({
+ redirectUri: 'com.myapp://callback',
+ logger,
+ });
+ const daVinci = { getId: jest.fn(async () => 'davinci-cfg') };
+
+ await client.authorizeForDaVinci(daVinci);
+
+ expect(mock.authorizeForDaVinci).toHaveBeenCalledWith(
+ 'davinci-cfg',
+ {},
+ { redirectUri: 'com.myapp://callback', loggerId: 'logger-handle-dv' },
+ );
+ });
+
+ it('resolves without returning a value', async () => {
+ const mod = await loadExternalIdp(makeMock());
+ const client = mod.createExternalIdpClient({});
+ const daVinci = { getId: jest.fn(async () => 'davinci-void') };
+
+ const result = await client.authorizeForDaVinci(daVinci);
+
+ expect(result).toBeUndefined();
+ });
+
+ it('propagates a native authorize error to the caller', async () => {
+ const mock = makeMock({
+ authorizeForDaVinci: jest.fn(async () => {
+ throw {
+ error: 'EXTERNAL_IDP_AUTHORIZE_ERROR',
+ message: 'Authorization failed.',
+ type: 'authorize_error',
+ };
+ }),
+ });
+ const mod = await loadExternalIdp(mock);
+ const client = mod.createExternalIdpClient({});
+ const daVinci = { getId: jest.fn(async () => 'davinci-err') };
+
+ await expect(client.authorizeForDaVinci(daVinci)).rejects.toMatchObject({
+ code: 'EXTERNAL_IDP_AUTHORIZE_ERROR',
+ message: 'Authorization failed.',
+ });
+ });
+
+ it('propagates a cancellation error to the caller', async () => {
+ const mock = makeMock({
+ authorizeForDaVinci: jest.fn(async () => {
+ throw {
+ error: 'EXTERNAL_IDP_CANCELLED',
+ message: 'User cancelled.',
+ type: 'cancelled',
+ };
+ }),
+ });
+ const mod = await loadExternalIdp(mock);
+ const client = mod.createExternalIdpClient({});
+ const daVinci = { getId: jest.fn(async () => 'davinci-cancel') };
+
+ await expect(client.authorizeForDaVinci(daVinci)).rejects.toMatchObject({
+ code: 'EXTERNAL_IDP_CANCELLED',
+ });
+ });
+ });
+
// ─── fromNativeAuthorizeResult — real field validation ────────────────────
//
// Uses jest.requireActual so the real fromNativeAuthorizeResult runs instead of
diff --git a/PingTestRunner/android/settings.gradle b/PingTestRunner/android/settings.gradle
index 666523fb6..0a17d3beb 100644
--- a/PingTestRunner/android/settings.gradle
+++ b/PingTestRunner/android/settings.gradle
@@ -33,6 +33,8 @@ include(":ping-identity_rn-browser")
project(":ping-identity_rn-browser").projectDir = file("../../packages/browser/android")
include(":ping-identity_rn-core")
project(":ping-identity_rn-core").projectDir = file("../../packages/core/android")
+include(":ping-identity_rn-davinci")
+project(":ping-identity_rn-davinci").projectDir = file("../../packages/davinci/android")
include(":ping-identity_rn-device-client")
project(":ping-identity_rn-device-client").projectDir = file("../../packages/device-client/android")
include(":ping-identity_rn-device-id")
diff --git a/packages/core/ios/CoreRuntime.swift b/packages/core/ios/CoreRuntime.swift
index 091c0e6d6..ceffc3363 100644
--- a/packages/core/ios/CoreRuntime.swift
+++ b/packages/core/ios/CoreRuntime.swift
@@ -10,6 +10,9 @@ import Foundation
/// A closure that returns the callbacks for a running Journey instance.
public typealias JourneyCallbackResolver = @Sendable (String) async -> [Any]?
+/// A closure that returns the collectors for a running DaVinci instance.
+public typealias DaVinciCollectorResolver = @Sendable (String) async -> [Any]?
+
/// Thread-safe storage for the optional Journey callback resolver.
///
/// - Note: `@unchecked Sendable` is used because this class stores a mutable
@@ -33,6 +36,29 @@ private final class JourneyCallbackResolverStore: @unchecked Sendable {
}
}
+/// Thread-safe storage for the optional DaVinci collector resolver.
+///
+/// - Note: `@unchecked Sendable` is used because this class stores a mutable
+/// closure reference that Swift cannot prove sendable. Access is serialized
+/// with `NSLock`, so cross-thread mutation/read is synchronized.
+private final class DaVinciCollectorResolverStore: @unchecked Sendable {
+ private let lock = NSLock()
+ private var resolver: DaVinciCollectorResolver?
+
+ func set(_ resolver: DaVinciCollectorResolver?) {
+ lock.lock()
+ self.resolver = resolver
+ lock.unlock()
+ }
+
+ func get() -> DaVinciCollectorResolver? {
+ lock.lock()
+ let current = resolver
+ lock.unlock()
+ return current
+ }
+}
+
/// Central place to hold process-wide registries used by the core module.
///
/// Keeps native handles alive across calls from the React Native bridge.
@@ -75,6 +101,9 @@ public enum CoreRuntime {
/// Internal resolver store used to avoid shared mutable global state.
private static let journeyCallbackResolverStore = JourneyCallbackResolverStore()
+ /// Internal DaVinci collector resolver store.
+ private static let davinciCollectorResolverStore = DaVinciCollectorResolverStore()
+
/// Registers or clears the resolver that exposes Journey callbacks to other packages.
///
/// Packages that need Journey callbacks (binding, fido, device-profile) cannot depend
@@ -95,4 +124,25 @@ public enum CoreRuntime {
}
return await resolver(journeyId)
}
+
+ /// Registers or clears the resolver that exposes DaVinci collectors to other packages.
+ ///
+ /// Plugin packages (external-idp, protect) cannot depend on `rn-davinci` directly —
+ /// this indirection lets DaVinci inject its collector lookup at init time without
+ /// creating a circular dependency.
+ ///
+ /// - Parameter resolver: Resolver closure to register, or `nil` to clear.
+ public static func setDaVinciCollectorResolver(_ resolver: DaVinciCollectorResolver?) {
+ davinciCollectorResolverStore.set(resolver)
+ }
+
+ /// Resolves DaVinci collectors for the given DaVinci instance via the registered resolver.
+ public static func resolveDaVinciCollectors(
+ _ davinciId: String
+ ) async -> [Any]? {
+ guard let resolver = davinciCollectorResolverStore.get() else {
+ return nil
+ }
+ return await resolver(davinciId)
+ }
}
diff --git a/packages/davinci/RNPingDavinci.podspec b/packages/davinci/RNPingDavinci.podspec
index 180ba7337..44b0b5398 100644
--- a/packages/davinci/RNPingDavinci.podspec
+++ b/packages/davinci/RNPingDavinci.podspec
@@ -38,6 +38,8 @@ Pod::Spec.new do |s|
s.dependency "RNPingCore"
s.dependency "PingDavinci", '2.0.0'
s.dependency "PingDavinciPlugin", '2.0.0'
+ # Required: IdpCollector is defined in PingExternalIdP. The JS authorizeForDaVinci API is optional but this native dep is not.
+ s.dependency "PingExternalIdP", '2.0.0'
s.dependency "PingLogger", '2.0.0'
s.dependency "PingOidc", '2.0.0'
s.dependency "PingOrchestrate", '2.0.0'
diff --git a/packages/davinci/android/build.gradle b/packages/davinci/android/build.gradle
index 6e6edce76..647b8a587 100644
--- a/packages/davinci/android/build.gradle
+++ b/packages/davinci/android/build.gradle
@@ -96,6 +96,8 @@ dependencies {
implementation "com.facebook.react:react-android"
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
implementation("com.pingidentity.sdks:davinci:2.0.1")
+ // Required: IdpCollector is defined in external-idp. The JS authorizeForDaVinci API is optional but this native dep is not.
+ compileOnly("com.pingidentity.sdks:external-idp:2.0.1")
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0"
implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0"
implementation(project(":ping-identity_rn-core"))
@@ -103,4 +105,5 @@ dependencies {
testImplementation "junit:junit:4.13.2"
testImplementation "androidx.test:core:1.6.1"
testImplementation "org.robolectric:robolectric:4.12.2"
+ testImplementation("com.pingidentity.sdks:external-idp:2.0.1")
}
diff --git a/packages/davinci/android/src/main/java/com/pingidentity/rndavinci/mapper/DaVinciNodeMapper.kt b/packages/davinci/android/src/main/java/com/pingidentity/rndavinci/mapper/DaVinciNodeMapper.kt
index 8122a9f46..15ff7ccb0 100644
--- a/packages/davinci/android/src/main/java/com/pingidentity/rndavinci/mapper/DaVinciNodeMapper.kt
+++ b/packages/davinci/android/src/main/java/com/pingidentity/rndavinci/mapper/DaVinciNodeMapper.kt
@@ -23,6 +23,7 @@ import com.pingidentity.davinci.collector.SingleSelectCollector
import com.pingidentity.davinci.collector.SubmitCollector
import com.pingidentity.davinci.collector.TextCollector
import com.pingidentity.davinci.plugin.Collector
+import com.pingidentity.idp.davinci.IdpCollector
import com.pingidentity.logger.Logger
import com.pingidentity.orchestrate.ContinueNode
import com.pingidentity.orchestrate.ErrorNode
@@ -41,6 +42,7 @@ import kotlinx.serialization.json.jsonPrimitive
internal object DaVinciNodeMapper {
private const val TAG = "DaVinciNodeMapper"
+ internal const val SOCIAL_LOGIN_BUTTON = "SOCIAL_LOGIN_BUTTON"
private val json = Json { ignoreUnknownKeys = true }
@@ -159,6 +161,9 @@ internal object DaVinciNodeMapper {
?: fieldJson["type"]?.jsonPrimitive?.content
?: continue
+ // SOCIAL_LOGIN_BUTTON fields are always handled via IdpCollector — exclude them.
+ if (resolvedType == SOCIAL_LOGIN_BUTTON) continue
+
// A field is supported when the SDK instantiated a collector for its key.
if (registeredKeys.contains(key)) continue
@@ -182,6 +187,7 @@ internal object DaVinciNodeMapper {
logger: Logger? = null
): Map? {
val payload = when (collector) {
+ is IdpCollector -> mapIdpCollector(collector)
is TextCollector -> mapTextCollector(collector)
is PasswordCollector -> mapPasswordCollector(collector, node, logger)
is SubmitCollector -> mapBaseCollector(collector)
@@ -201,7 +207,33 @@ internal object DaVinciNodeMapper {
null
}
} ?: return null
- return applyRawField(payload, collector.id(), node, logger)
+ val fieldKey = rawFieldKey(collector) ?: return null
+ return applyRawField(payload, fieldKey, node, logger)
+ }
+
+ /**
+ * Returns the stable raw-field key for a collector.
+ *
+ * Most collectors use [Collector.id] which equals the server `key` field. Collectors that
+ * do not extend [FieldCollector] (e.g. [IdpCollector]) override with their own stable identifier.
+ * Add a new branch here for any future collector whose id() does not match its form field key.
+ */
+ private fun rawFieldKey(collector: Collector<*>): String? = when (collector) {
+ is IdpCollector -> collector.idpId
+ else -> collector.id()
+ }
+
+ private fun mapIdpCollector(collector: IdpCollector): MutableMap {
+ val map = linkedMapOf(
+ "key" to collector.idpId,
+ "type" to SOCIAL_LOGIN_BUTTON,
+ "label" to collector.label,
+ "idpId" to collector.idpId,
+ "idpType" to collector.idpType,
+ "idpEnabled" to collector.idpEnabled,
+ )
+ runCatching { collector.link.toString() }.getOrNull()?.let { map["link"] = it }
+ return map
}
/**
diff --git a/packages/davinci/android/src/test/java/com/pingidentity/rndavinci/mapper/DaVinciNodeMapperTest.kt b/packages/davinci/android/src/test/java/com/pingidentity/rndavinci/mapper/DaVinciNodeMapperTest.kt
index 77655eb04..6f09e4e7f 100644
--- a/packages/davinci/android/src/test/java/com/pingidentity/rndavinci/mapper/DaVinciNodeMapperTest.kt
+++ b/packages/davinci/android/src/test/java/com/pingidentity/rndavinci/mapper/DaVinciNodeMapperTest.kt
@@ -18,6 +18,7 @@ import com.pingidentity.davinci.collector.SingleSelectCollector
import com.pingidentity.davinci.collector.SubmitCollector
import com.pingidentity.davinci.collector.TextCollector
import com.pingidentity.davinci.plugin.Collector
+import com.pingidentity.idp.davinci.IdpCollector
import com.pingidentity.network.HttpRequest
import com.pingidentity.orchestrate.Action
import com.pingidentity.orchestrate.ContinueNode
@@ -63,6 +64,20 @@ class DaVinciNodeMapperTest {
private fun Map.asList(key: String): List