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>? = this[key] as? List> + // IdpCollector.init() throws MalformedURLException when "links" is absent because + // the native SDK always calls new URL(href ?: "") — set public fields directly instead. + private fun makeIdpCollector( + idpId: String, + idpType: String, + label: String, + href: String? = null + ): IdpCollector = IdpCollector().apply { + this.idpId = idpId + this.idpType = idpType + this.label = label + if (href != null) this.link = java.net.URL(href) + } + // ---- Node type tests ---- @Test @@ -629,6 +644,56 @@ class DaVinciNodeMapperTest { assertFalse(c.containsKey("passwordPolicy")) } + // ---- IdpCollector ---- + + @Test + fun mapIdpCollectorEmitsTypeIDP() { + val collector = makeIdpCollector(idpId = "google-idp-1", idpType = "GOOGLE", label = "Sign in with Google") + val node = makeNode(collector) + + val result = DaVinciNodeMapper.mapNodePayload(node) + val c = result.asList("collectors")!![0] + + assertEquals(DaVinciNodeMapper.SOCIAL_LOGIN_BUTTON, c["type"]) + } + + @Test + fun mapIdpCollectorUsesIdpIdAsKey() { + val collector = makeIdpCollector(idpId = "facebook-idp-42", idpType = "FACEBOOK", label = "Sign in with Facebook") + val node = makeNode(collector) + + val result = DaVinciNodeMapper.mapNodePayload(node) + val c = result.asList("collectors")!![0] + + assertEquals("facebook-idp-42", c["key"]) + assertEquals("facebook-idp-42", c["idpId"]) + } + + @Test + fun mapIdpCollectorEmitsIdpTypeAndLabel() { + val collector = makeIdpCollector(idpId = "apple-idp-99", idpType = "APPLE", label = "Sign in with Apple") + val node = makeNode(collector) + + val result = DaVinciNodeMapper.mapNodePayload(node) + val c = result.asList("collectors")!![0] + + assertEquals("APPLE", c["idpType"]) + assertEquals("Sign in with Apple", c["label"]) + assertEquals(true, c["idpEnabled"]) + } + + @Test + fun mapIdpCollectorEmitsLinkWhenPresent() { + val href = "https://auth.pingone.com/connections/idp-1/loginFirstFactor?interactionId=abc" + val collector = makeIdpCollector(idpId = "google-idp-1", idpType = "GOOGLE", label = "Google", href = href) + val node = makeNode(collector) + + val result = DaVinciNodeMapper.mapNodePayload(node) + val c = result.asList("collectors")!![0] + + assertEquals(href, c["link"]) + } + @Test fun mapPasswordCollectorOmitsPasswordPolicyWhenNoFormPresent() { val collector = PasswordCollector().apply { diff --git a/packages/davinci/ios/Mapper/DaVinciNodeMapper.swift b/packages/davinci/ios/Mapper/DaVinciNodeMapper.swift index cf00de0c4..2d549dced 100644 --- a/packages/davinci/ios/Mapper/DaVinciNodeMapper.swift +++ b/packages/davinci/ios/Mapper/DaVinciNodeMapper.swift @@ -8,6 +8,7 @@ import Foundation import PingDavinci import PingDavinciPlugin +import PingExternalIdP import PingLogger import PingOrchestrate import RNPingCore @@ -15,6 +16,7 @@ import RNPingCore /// Maps native iOS DaVinci nodes and collectors to React Native bridge payloads. enum DaVinciNodeMapper { private static let logTag = "DaVinciNodeMapper" + static let socialLoginButton = "SOCIAL_LOGIN_BUTTON" /// Converts a native DaVinci node to a bridge-friendly dictionary payload. /// @@ -119,6 +121,9 @@ enum DaVinciNodeMapper { continue } + // SOCIAL_LOGIN_BUTTON fields are always handled via IdpCollector — exclude them. + if resolvedType == socialLoginButton { continue } + // A field is supported when the SDK instantiated a collector for its key. guard !registeredKeys.contains(key) else { continue } @@ -194,6 +199,8 @@ enum DaVinciNodeMapper { ) -> [String: Any]? { var map: [String: Any]? switch collector { + case let idpCollector as IdpCollector: + map = mapIdpCollector(idpCollector) case let textCollector as TextCollector: map = mapTextCollector(textCollector) case let passwordCollector as PasswordCollector: @@ -222,10 +229,40 @@ enum DaVinciNodeMapper { return nil } guard var collectorMap = map else { return nil } - applyRawField(&collectorMap, collectorKey: collector.id, node: node, logger: logger) + applyRawField(&collectorMap, collectorKey: rawFieldKey(for: collector), node: node, logger: logger) return collectorMap } + /// 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 static func rawFieldKey(for collector: any Collector) -> String { + if let idp = collector as? IdpCollector { return idp.idpId } + return collector.id + } + + /// Serializes an `IdpCollector` to a bridge map. + /// + /// Uses `idpId` as the `key` field — `IdpCollector.id` returns a new UUID on every + /// access and cannot be used as a stable form-field key. + private static func mapIdpCollector(_ collector: IdpCollector) -> [String: Any] { + var map: [String: Any] = [ + "key": collector.idpId, + "type": socialLoginButton, + "label": collector.label, + "idpId": collector.idpId, + "idpType": collector.idpType, + "idpEnabled": collector.idpEnabled, + ] + if let link = collector.link { + map["link"] = link.absoluteString + } + return map + } + // MARK: - Field collector helpers /// Serializes base FieldCollector fields (key, type, label, required) into a map. diff --git a/packages/davinci/ios/RNPingDavinciCommon.swift b/packages/davinci/ios/RNPingDavinciCommon.swift index abe9759a4..337b35a06 100644 --- a/packages/davinci/ios/RNPingDavinciCommon.swift +++ b/packages/davinci/ios/RNPingDavinciCommon.swift @@ -74,6 +74,7 @@ public final class RNPingDavinciCommon: NSObject { /// Clears DaVinci runtime state in serialized lifecycle order. private static func cleanupAsync() async { + CoreRuntime.setDaVinciCollectorResolver(nil) await lifecycleCoordinator.enqueue { stateStore.removeAll() await davinciRegistry.removeAll() @@ -115,6 +116,9 @@ public final class RNPingDavinciCommon: NSObject { let idRef = Ref() await lifecycleCoordinator.enqueue { + CoreRuntime.setDaVinciCollectorResolver { davinciId in + stateStore.activeContinueNode(for: davinciId).map { Array($0.collectors) } + } idRef.value = await davinciRegistry.register(DaVinciHandle(davinci: davinci, loggerId: payload.loggerId)) } guard let davinciId = idRef.value else { diff --git a/packages/davinci/ios/Tests/DaVinciNodeMapperTests.swift b/packages/davinci/ios/Tests/DaVinciNodeMapperTests.swift index 6ed4ebad4..727be44b1 100644 --- a/packages/davinci/ios/Tests/DaVinciNodeMapperTests.swift +++ b/packages/davinci/ios/Tests/DaVinciNodeMapperTests.swift @@ -11,6 +11,7 @@ import XCTest import PingDavinci import PingDavinciPlugin +import PingExternalIdP import PingOrchestrate @testable import RNPingDavinci @@ -531,6 +532,68 @@ final class DaVinciNodeMapperTests: XCTestCase { XCTAssertNotNil(collectors?[2]["raw"]) } + // MARK: - IdpCollector serialization + + func testMapIdpCollectorEmitsTypeIDP() { + let node = makeContinueNode(collectors: [ + makeIdpCollector(idpId: "google-idp-1", idpType: "GOOGLE", label: "Sign in with Google") + ]) + + let payload = DaVinciNodeMapper.mapNodePayload(node) + let first = (payload["collectors"] as? [[String: Any]])?.first + + XCTAssertEqual(first?["type"] as? String, DaVinciNodeMapper.socialLoginButton) + } + + func testMapIdpCollectorEmitsIdpId() { + let node = makeContinueNode(collectors: [ + makeIdpCollector(idpId: "google-idp-1", idpType: "GOOGLE", label: "Sign in with Google") + ]) + + let payload = DaVinciNodeMapper.mapNodePayload(node) + let first = (payload["collectors"] as? [[String: Any]])?.first + + XCTAssertEqual(first?["idpId"] as? String, "google-idp-1") + XCTAssertEqual(first?["idpType"] as? String, "GOOGLE") + XCTAssertEqual(first?["label"] as? String, "Sign in with Google") + XCTAssertEqual(first?["idpEnabled"] as? Bool, true) + } + + func testMapIdpCollectorUsesIdpIdAsKeyNotUUID() { + let node = makeContinueNode(collectors: [ + makeIdpCollector(idpId: "facebook-idp-42", idpType: "FACEBOOK", label: "Sign in with Facebook") + ]) + + let payload = DaVinciNodeMapper.mapNodePayload(node) + let first = (payload["collectors"] as? [[String: Any]])?.first + + // key must equal idpId, NOT the UUID returned by IdpCollector.id + XCTAssertEqual(first?["key"] as? String, "facebook-idp-42") + } + + func testMapIdpCollectorEmitsLinkWhenPresent() { + let href = "https://auth.pingone.com/connections/idp-1/loginFirstFactor?interactionId=abc" + let node = makeContinueNode(collectors: [ + makeIdpCollector(idpId: "apple-idp-99", idpType: "APPLE", label: "Sign in with Apple", href: href) + ]) + + let payload = DaVinciNodeMapper.mapNodePayload(node) + let first = (payload["collectors"] as? [[String: Any]])?.first + + XCTAssertEqual(first?["link"] as? String, href) + } + + func testMapIdpCollectorOmitsLinkWhenAbsent() { + let node = makeContinueNode(collectors: [ + makeIdpCollector(idpId: "google-idp-1", idpType: "GOOGLE", label: "Google", href: nil) + ]) + + let payload = DaVinciNodeMapper.mapNodePayload(node) + let first = (payload["collectors"] as? [[String: Any]])?.first + + XCTAssertNil(first?["link"]) + } + // MARK: - multiple collector types func testMapContinueNodeWithMultipleCollectorTypes() { @@ -571,6 +634,24 @@ final class DaVinciNodeMapperTests: XCTestCase { private func makeDaVinciSuccessNode(sessionValue: String) -> SuccessNode { return SuccessNode(input: [:], session: StubSession(value: sessionValue)) } + + private func makeIdpCollector( + idpId: String, + idpType: String, + label: String, + href: String? = nil + ) -> IdpCollector { + var json: [String: Any] = [ + "idpId": idpId, + "idpType": idpType, + "label": label, + "idpEnabled": true + ] + if let href = href { + json["links"] = ["authenticate": ["href": href]] + } + return IdpCollector(with: json) + } } private final class TestContinueNode: ContinueNode { diff --git a/packages/davinci/src/__tests__/collectorHelpers.test.ts b/packages/davinci/src/__tests__/collectorHelpers.test.ts index fd4a78ea5..e5b73f6a3 100644 --- a/packages/davinci/src/__tests__/collectorHelpers.test.ts +++ b/packages/davinci/src/__tests__/collectorHelpers.test.ts @@ -8,7 +8,6 @@ import { buildNextInput, computeFormMeta, - integrationRequiredCollectorTypes, normalizeCollectors, resolveExecutionMode, resolveFieldKind, @@ -80,13 +79,10 @@ describe('resolveExecutionMode', () => { }); }); - it('returns integration_required for types registered in integrationRequiredCollectorTypes', () => { - integrationRequiredCollectorTypes.add('IDP_TEST'); - try { - expect(resolveExecutionMode('IDP_TEST')).toBe('integration_required'); - } finally { - integrationRequiredCollectorTypes.delete('IDP_TEST'); - } + it('returns integration_required for SOCIAL_LOGIN_BUTTON', () => { + expect(resolveExecutionMode('SOCIAL_LOGIN_BUTTON')).toBe( + 'integration_required', + ); }); }); @@ -137,13 +133,8 @@ describe('resolveFieldKind', () => { }, ); - it('returns integration for types registered in integrationRequiredCollectorTypes', () => { - integrationRequiredCollectorTypes.add('IDP_KIND_TEST'); - try { - expect(resolveFieldKind('IDP_KIND_TEST')).toBe('integration'); - } finally { - integrationRequiredCollectorTypes.delete('IDP_KIND_TEST'); - } + it('returns integration for SOCIAL_LOGIN_BUTTON', () => { + expect(resolveFieldKind('SOCIAL_LOGIN_BUTTON')).toBe('integration'); }); it('returns unknown for unrecognised types', () => { @@ -328,55 +319,43 @@ describe('buildNextInput — no active node', () => { describe('buildNextInput — integration_required path', () => { it('omits handled integration collectors silently when type is in the set', () => { - integrationRequiredCollectorTypes.add('IDP'); - try { - const handled = new Set(['IDP']); - const result = buildNextInput( - makeNode([baseField('idp', 'IDP')]), - {}, - handled, - ); - expect(result.canSubmit).toBe(true); - expect(result.issues).toEqual([]); - expect(result.input.collectors).toEqual([]); - } finally { - integrationRequiredCollectorTypes.delete('IDP'); - } + const handled = new Set(['SOCIAL_LOGIN_BUTTON']); + const result = buildNextInput( + makeNode([baseField('idp', 'SOCIAL_LOGIN_BUTTON')]), + {}, + handled, + ); + expect(result.canSubmit).toBe(true); + expect(result.issues).toEqual([]); + expect(result.input.collectors).toEqual([]); }); it('emits INTEGRATION_REQUIRED issue and blocks submit when type is not in the set', () => { - integrationRequiredCollectorTypes.add('IDP'); - try { - const result = buildNextInput( - makeNode([baseField('idp', 'IDP')]), - {}, - new Set(), - ); - expect(result.canSubmit).toBe(false); - expect(result.issues).toEqual([ - expect.objectContaining({ - code: 'INTEGRATION_REQUIRED', - key: 'idp', - }), - ]); - expect(result.input.collectors).toEqual([]); - } finally { - integrationRequiredCollectorTypes.delete('IDP'); - } + const result = buildNextInput( + makeNode([baseField('idp', 'SOCIAL_LOGIN_BUTTON')]), + {}, + new Set(), + ); + expect(result.canSubmit).toBe(false); + expect(result.issues).toEqual([ + expect.objectContaining({ + code: 'INTEGRATION_REQUIRED', + key: 'idp', + }), + ]); + expect(result.input.collectors).toEqual([]); }); it('emits INTEGRATION_REQUIRED issue when handledCollectorTypes is omitted', () => { - integrationRequiredCollectorTypes.add('IDP'); - try { - const result = buildNextInput(makeNode([baseField('idp', 'IDP')]), {}); - expect(result.canSubmit).toBe(false); - expect(result.issues[0]).toMatchObject({ - code: 'INTEGRATION_REQUIRED', - key: 'idp', - }); - } finally { - integrationRequiredCollectorTypes.delete('IDP'); - } + const result = buildNextInput( + makeNode([baseField('idp', 'SOCIAL_LOGIN_BUTTON')]), + {}, + ); + expect(result.canSubmit).toBe(false); + expect(result.issues[0]).toMatchObject({ + code: 'INTEGRATION_REQUIRED', + key: 'idp', + }); }); }); @@ -628,22 +607,17 @@ describe('buildNextInput — excluded modes', () => { }); it('excludes integration_required collectors from payload and blocks submit when unhandled', () => { - integrationRequiredCollectorTypes.add('IDP'); - try { - const node = makeNode([baseField('idp', 'IDP')]); - const result = buildNextInput(node, {}); - - expect(result.input.collectors).toEqual([]); - expect(result.issues).toEqual([ - expect.objectContaining({ - code: 'INTEGRATION_REQUIRED', - key: 'idp', - }), - ]); - expect(result.canSubmit).toBe(false); - } finally { - integrationRequiredCollectorTypes.delete('IDP'); - } + const node = makeNode([baseField('idp', 'SOCIAL_LOGIN_BUTTON')]); + const result = buildNextInput(node, {}); + + expect(result.input.collectors).toEqual([]); + expect(result.issues).toEqual([ + expect.objectContaining({ + code: 'INTEGRATION_REQUIRED', + key: 'idp', + }), + ]); + expect(result.canSubmit).toBe(false); }); it('emits non-blocking UNSUPPORTED_COLLECTOR for unknown types', () => { @@ -691,7 +665,7 @@ describe('computeFormMeta', () => { it('flags hasIntegrationRequired when a plugin collector is present', () => { const integrationCollector: DaVinciNormalizedCollector = { - ...baseField('idp', 'IDP'), + ...baseField('idp', 'SOCIAL_LOGIN_BUTTON'), executionMode: 'integration_required', requiresUserInput: false, } as DaVinciNormalizedCollector; @@ -724,3 +698,61 @@ describe('computeFormMeta', () => { expect(computeFormMeta(collectors).hasAutoCapable).toBe(false); }); }); + +describe('IDP collector (SDKS-5128)', () => { + const idpCollector: DaVinciCollector = { + key: 'google-idp-123', + type: 'SOCIAL_LOGIN_BUTTON', + label: 'Sign in with Google', + idpId: 'google-idp-123', + idpType: 'GOOGLE', + idpEnabled: true, + } as DaVinciCollector; + + it("resolveExecutionMode('SOCIAL_LOGIN_BUTTON') returns integration_required", () => { + expect(resolveExecutionMode('SOCIAL_LOGIN_BUTTON')).toBe( + 'integration_required', + ); + }); + + it("resolveFieldKind('SOCIAL_LOGIN_BUTTON') returns integration", () => { + expect(resolveFieldKind('SOCIAL_LOGIN_BUTTON')).toBe('integration'); + }); + + it('normalizeCollectors gives IDP collector kind:integration and executionMode:integration_required', () => { + const [normalized] = normalizeCollectors([idpCollector]); + expect(normalized.executionMode).toBe('integration_required'); + expect(normalized.kind).toBe('integration'); + expect(normalized.requiresUserInput).toBe(false); + }); + + it('buildNextInput blocks submit when IDP collector is not in handledCollectorTypes', () => { + const node = makeNode([idpCollector]); + const { canSubmit, issues } = buildNextInput(node, {}); + expect(canSubmit).toBe(false); + expect(issues).toContainEqual( + expect.objectContaining({ + code: 'INTEGRATION_REQUIRED', + key: 'google-idp-123', + }), + ); + }); + + it('buildNextInput allows submit when IDP collector is in handledCollectorTypes', () => { + const node = makeNode([idpCollector]); + const { canSubmit, issues } = buildNextInput( + node, + {}, + new Set(['SOCIAL_LOGIN_BUTTON']), + ); + expect(canSubmit).toBe(true); + expect(issues).toHaveLength(0); + }); + + it('computeFormMeta reflects hasIntegrationRequired:true for IDP-only node', () => { + const normalized = normalizeCollectors([idpCollector]); + const meta = computeFormMeta(normalized); + expect(meta.hasIntegrationRequired).toBe(true); + expect(meta.hasManual).toBe(false); + }); +}); diff --git a/packages/davinci/src/__tests__/useDavinciForm.test.tsx b/packages/davinci/src/__tests__/useDavinciForm.test.tsx index 07d443c94..7b34a0a57 100644 --- a/packages/davinci/src/__tests__/useDavinciForm.test.tsx +++ b/packages/davinci/src/__tests__/useDavinciForm.test.tsx @@ -7,7 +7,6 @@ import React, { useEffect } from 'react'; import { render, act } from '@testing-library/react-native'; -import { integrationRequiredCollectorTypes } from '../collectorHelpers'; import { useDaVinciForm } from '../useDavinciForm'; type ContinueNode = import('../types').ContinueNode; @@ -156,33 +155,33 @@ describe('useDaVinciForm — meta', () => { }); it('hasAutoCapable stays false even when other meta flags are true', async () => { - integrationRequiredCollectorTypes.add('IDP'); - try { - const node: ContinueNode = { - type: 'ContinueNode', - collectors: [ - { key: 'idp', type: 'IDP', label: 'IdP', required: false }, - { key: 'l', type: 'LABEL', content: 'x' }, - ] as ContinueNode['collectors'], - }; - let latest: DaVinciFormResult | null = null; - - render( - { - latest = r; - }} - />, - ); - - const meta = requireLatest(latest).meta; - expect(meta.hasIntegrationRequired).toBe(true); - expect(meta.hasOutputOnly).toBe(true); - expect(meta.hasAutoCapable).toBe(false); - } finally { - integrationRequiredCollectorTypes.delete('IDP'); - } + const node: ContinueNode = { + type: 'ContinueNode', + collectors: [ + { + key: 'idp', + type: 'SOCIAL_LOGIN_BUTTON', + label: 'IdP', + required: false, + }, + { key: 'l', type: 'LABEL', content: 'x' }, + ] as ContinueNode['collectors'], + }; + let latest: DaVinciFormResult | null = null; + + render( + { + latest = r; + }} + />, + ); + + const meta = requireLatest(latest).meta; + expect(meta.hasIntegrationRequired).toBe(true); + expect(meta.hasOutputOnly).toBe(true); + expect(meta.hasAutoCapable).toBe(false); }); }); @@ -550,68 +549,68 @@ describe('useDaVinciForm — setValueByType', () => { describe('useDaVinciForm — handledCollectorTypes', () => { it('omits handled integration collectors and allows canSubmit=true', async () => { - integrationRequiredCollectorTypes.add('IDP'); - try { - const node: ContinueNode = { - type: 'ContinueNode', - collectors: [ - { key: 'idp', type: 'IDP', label: 'IdP', required: false }, - { - key: 'submit', - type: 'SUBMIT_BUTTON', - label: 'Submit', - required: false, - }, - ] as ContinueNode['collectors'], - }; - let latest: DaVinciFormResult | null = null; - - render( - { - latest = r; - }} - />, - ); - - expect(requireLatest(latest).canSubmit).toBe(true); - expect(requireLatest(latest).issues).toEqual([]); - } finally { - integrationRequiredCollectorTypes.delete('IDP'); - } + const node: ContinueNode = { + type: 'ContinueNode', + collectors: [ + { + key: 'idp', + type: 'SOCIAL_LOGIN_BUTTON', + label: 'IdP', + required: false, + }, + { + key: 'submit', + type: 'SUBMIT_BUTTON', + label: 'Submit', + required: false, + }, + ] as ContinueNode['collectors'], + }; + let latest: DaVinciFormResult | null = null; + + render( + { + latest = r; + }} + />, + ); + + expect(requireLatest(latest).canSubmit).toBe(true); + expect(requireLatest(latest).issues).toEqual([]); }); it('emits INTEGRATION_REQUIRED and blocks submit when type is not in the set', async () => { - integrationRequiredCollectorTypes.add('IDP'); - try { - const node: ContinueNode = { - type: 'ContinueNode', - collectors: [ - { key: 'idp', type: 'IDP', label: 'IdP', required: false }, - ] as ContinueNode['collectors'], - }; - let latest: DaVinciFormResult | null = null; - - render( - { - latest = r; - }} - />, - ); - - expect(requireLatest(latest).canSubmit).toBe(false); - expect( - requireLatest(latest).issues.some( - (i) => i.code === 'INTEGRATION_REQUIRED', - ), - ).toBe(true); - } finally { - integrationRequiredCollectorTypes.delete('IDP'); - } + const node: ContinueNode = { + type: 'ContinueNode', + collectors: [ + { + key: 'idp', + type: 'SOCIAL_LOGIN_BUTTON', + label: 'IdP', + required: false, + }, + ] as ContinueNode['collectors'], + }; + let latest: DaVinciFormResult | null = null; + + render( + { + latest = r; + }} + />, + ); + + expect(requireLatest(latest).canSubmit).toBe(false); + expect( + requireLatest(latest).issues.some( + (i) => i.code === 'INTEGRATION_REQUIRED', + ), + ).toBe(true); }); }); diff --git a/packages/davinci/src/collectorHelpers.ts b/packages/davinci/src/collectorHelpers.ts index eb09fafb5..7ace2b47f 100644 --- a/packages/davinci/src/collectorHelpers.ts +++ b/packages/davinci/src/collectorHelpers.ts @@ -67,15 +67,17 @@ export const flowCollectorTypes = new Set([ * (e.g. `rn-external-idp`, `rn-fido`, `rn-device-client` / Protect). * * @remarks - * Empty in the base DaVinci package — each integration ticket registers its - * own type strings here when the matching native collector is added: - * - IdP collector → `'IDP'` + * Each integration ticket registers its own type strings here when the matching + * native collector is added: + * - IdP collector → `'SOCIAL_LOGIN_BUTTON'` (SDKS-5128) * - FIDO collectors → `'FIDO_REGISTRATION'` / `'FIDO_AUTHENTICATION'` * - Protect collector → `'PROTECT'` * * @public */ -export const integrationRequiredCollectorTypes = new Set(); +export const integrationRequiredCollectorTypes = new Set< + DaVinciCollector['type'] +>(['SOCIAL_LOGIN_BUTTON']); const textFieldKindTypes = new Set(['TEXT', 'HIDDEN']); const passwordFieldKindTypes = new Set(['PASSWORD', 'PASSWORD_VERIFY']); @@ -125,7 +127,7 @@ const flowFieldKindTypes = new Set([ * @public */ export function resolveExecutionMode(type: string): DaVinciExecutionMode { - if (integrationRequiredCollectorTypes.has(type)) { + if (integrationRequiredCollectorTypes.has(type as DaVinciCollector['type'])) { return 'integration_required'; } if (manualCollectorTypes.has(type)) { @@ -163,7 +165,7 @@ export function resolveExecutionMode(type: string): DaVinciExecutionMode { * @public */ export function resolveFieldKind(type: string): DaVinciFieldKind { - if (integrationRequiredCollectorTypes.has(type)) { + if (integrationRequiredCollectorTypes.has(type as DaVinciCollector['type'])) { return 'integration'; } if (textFieldKindTypes.has(type)) { diff --git a/packages/davinci/src/davinci.ts b/packages/davinci/src/davinci.ts index 29e78ec78..9ce1760a1 100644 --- a/packages/davinci/src/davinci.ts +++ b/packages/davinci/src/davinci.ts @@ -380,6 +380,17 @@ export function createDaVinciClient(config: DaVinciConfig): DaVinciClient { } }, + /** + * Returns the native DaVinci instance identifier. + * + * Triggers lazy configuration when called before `start()`. + * + * @throws {DaVinciError} When configuration fails. + */ + async getId() { + return ensureConfigured(); + }, + /** * Disposes the native DaVinci instance and releases runtime state. * diff --git a/packages/davinci/src/index.tsx b/packages/davinci/src/index.tsx index 8eb8078fd..d891cd34b 100644 --- a/packages/davinci/src/index.tsx +++ b/packages/davinci/src/index.tsx @@ -21,7 +21,7 @@ export { /** * React DaVinci provider and hook helpers. */ -export { DaVinciProvider, useDaVinci } from './useDavinci'; +export { DaVinciProvider, useDaVinci, useDaVinciContext } from './useDavinci'; /** * Headless collector form helper hook for DaVinci nodes. */ @@ -30,6 +30,7 @@ export { useDaVinciForm } from './useDavinciForm'; * Hook and provider type contracts. */ export type { + DaVinciContextValue, DaVinciHookActions, DaVinciHookResult, DaVinciProviderProps, diff --git a/packages/davinci/src/types/client.types.ts b/packages/davinci/src/types/client.types.ts index 8bfef8752..08aacd37d 100644 --- a/packages/davinci/src/types/client.types.ts +++ b/packages/davinci/src/types/client.types.ts @@ -99,4 +99,17 @@ export type DaVinciClient = { * @throws {DaVinciError} When disposal fails. */ dispose: () => Promise; + + /** + * Returns the native DaVinci instance identifier. + * + * @remarks + * Used by cross-module integrations (e.g. `@ping-identity/rn-external-idp`) + * to target the correct native DaVinci instance without direct package coupling. + * Satisfies the {@link DaVinciInstance} contract from `@ping-identity/rn-types`. + * + * @returns The internal DaVinci instance id. + * @throws {DaVinciError} When configuration fails. + */ + getId: () => Promise; }; diff --git a/packages/davinci/src/types/node.types.ts b/packages/davinci/src/types/node.types.ts index 730ec119a..25a87b1bd 100644 --- a/packages/davinci/src/types/node.types.ts +++ b/packages/davinci/src/types/node.types.ts @@ -350,6 +350,46 @@ export type DeviceAuthenticationCollector = BaseCollector & { value?: { type: string; id?: string; description?: string }; }; +/** + * Social login / external IdP collector. + * + * @remarks + * Corresponds to the native `SOCIAL_LOGIN_BUTTON` server type. + * + * The `key` field is set to `idpId` (the stable server-assigned identifier) rather than the + * native collector's own `.id` property, which returns a new UUID on every access. + * + * Handled entirely by `@ping-identity/rn-external-idp` — appears as `executionMode: + * 'integration_required'` and `kind: 'integration'` in normalized collectors. + * + * @public + */ +export type IdpCollector = { + /** Stable server-assigned IdP identifier, used as the form field key. */ + key: string; + type: 'SOCIAL_LOGIN_BUTTON'; + /** Human-readable button label (e.g. 'Sign in with Google'). */ + label: string; + /** Server IdP identifier (same as `key`). */ + idpId: string; + /** + * Provider type string. + * + * @remarks + * Verified against iOS `DavinciPlugin/Constants.swift` and Android + * `IdpCollector` bytecode — the SDK registers `'GOOGLE'`, `'FACEBOOK'`, + * and `'APPLE'` (uppercase). `'APPLE'` is iOS-only; Android has no native + * Apple sign-in handler. The union is left open (`| string`) to accommodate + * future providers without a breaking change. + */ + idpType: 'GOOGLE' | 'FACEBOOK' | 'APPLE' | string; + /** Whether the IdP is currently enabled. */ + idpEnabled: boolean; + /** IdP authentication URL (informational — not used directly by JS). */ + link?: string; + raw?: Record; +}; + /** * Discriminated union of all collector types returned by the DaVinci bridge. * @@ -369,7 +409,8 @@ export type DaVinciCollector = | MultiSelectCollector | PhoneNumberCollector | DeviceRegistrationCollector - | DeviceAuthenticationCollector; + | DeviceAuthenticationCollector + | IdpCollector; // --------------------------------------------------------------------------- // Node shapes diff --git a/packages/davinci/src/useDavinci.tsx b/packages/davinci/src/useDavinci.tsx index b1c798c0e..87303090d 100644 --- a/packages/davinci/src/useDavinci.tsx +++ b/packages/davinci/src/useDavinci.tsx @@ -115,9 +115,11 @@ export type DaVinciHookResult = DaVinciHookActions & { }; /** - * Internal React context value holding the shared DaVinci client and hook result. + * React context value holding the shared DaVinci client and hook result. + * + * @public */ -type DaVinciContextValue = { +export type DaVinciContextValue = { client: DaVinciClient; davinci: DaVinciHookResult; }; @@ -155,6 +157,9 @@ const missingDaVinciClient: DaVinciClient = { async dispose(): Promise { throw missingDaVinciClientError; }, + async getId(): Promise { + throw missingDaVinciClientError; + }, }; /** @@ -363,11 +368,10 @@ export function useDaVinci(client?: DaVinciClient): DaVinciHookResult { } /** - * Exposes the {@link DaVinciContext} value for use by {@link useDaVinciForm} - * when consuming a provider-managed client. + * Exposes the {@link DaVinciContextValue} for the nearest {@link DaVinciProvider}. * * @returns Context value containing the shared client and hook result, or `null` when called outside a provider. - * @internal + * @public */ export function useDaVinciContext(): DaVinciContextValue | null { return useContext(DaVinciContext); diff --git a/packages/external-idp/README.md b/packages/external-idp/README.md index 4fd61db81..d27449468 100644 --- a/packages/external-idp/README.md +++ b/packages/external-idp/README.md @@ -9,7 +9,7 @@ of the MIT license. See the LICENSE file for details. # Ping Identity React Native External IDP -The Ping External IDP library empowers your React Native applications to seamlessly authenticate users through external Identity Providers (IDPs) such as Google, Facebook, and Apple. Acting as a plugin for the `journey` module, it streamlines the integration process by providing the necessary configurations and functionalities to initiate and manage authentication flows with these external services. +The Ping External IDP library empowers your React Native applications to seamlessly authenticate users through external Identity Providers (IDPs) such as Google, Facebook, and Apple. Acting as a plugin for the `journey` and `davinci` modules, it streamlines the integration process by providing the necessary configurations and functionalities to initiate and manage authentication flows with these external services. This library abstracts away the complexities of dealing with different IDP protocols and SDKs, offering a unified and developer-friendly API. By leveraging Ping External IDP, you can enhance your application's user experience by offering familiar and convenient login options. @@ -21,6 +21,7 @@ This library abstracts away the complexities of dealing with different IDP proto - [iOS native configuration](#ios-native-configuration) - [Provider console setup](#provider-console-setup) - [Usage](#usage) +- [DaVinci usage](#davinci-usage) - [API reference](#api-reference) - [Errors](#errors) - [Known limitations](#known-limitations) @@ -362,6 +363,50 @@ if (form.canSubmit) { --- +## DaVinci usage + +DaVinci flows represent each social login option as an `IdpCollector` with `type: 'SOCIAL_LOGIN_BUTTON'`. Unlike Journey, there is no `SelectIdpCallback` — each provider has its own button. The token flows through `daVinci.next()` via a `RequestInterceptor` inside the native SDK; `authorizeForDaVinci` returns `void`, not a token. + +Install the DaVinci module alongside this package: + +```bash +yarn add @ping-identity/rn-davinci +``` + +### Handle `IdpCollector` — authorize with an external provider + +Call `authorizeForDaVinci` when a DaVinci `ContinueNode` contains an `IdpCollector`. After the promise resolves, call `daVinci.next({ collectors: [] })` to advance the flow. + +```ts +try { + await externalIdp.authorizeForDaVinci(daVinci, { + // index: 0 // Use when there are multiple IdpCollectors in one node + }); + await daVinci.next({ collectors: [] }); +} catch (error) { + // See Errors section +} +``` + +### With `useDaVinciForm` + +Pass `handledCollectorTypes` so IDP collectors are excluded from blocking submit issues: + +```ts +import { useDaVinci, useDaVinciForm } from '@ping-identity/rn-davinci'; + +const { node, next } = useDaVinci(daVinciClient); +const form = useDaVinciForm(node, { + handledCollectorTypes: new Set(['SOCIAL_LOGIN_BUTTON']), +}); + +// When the user taps a social login button: +await externalIdp.authorizeForDaVinci(daVinciClient); +await next({ collectors: [] }); +``` + +--- + ## API reference ```ts @@ -372,6 +417,7 @@ import type { ExternalIdpAuthorizeOptions, ExternalIdpSelectOptions, ExternalIdpResult, + DaVinciInstance, } from '@ping-identity/rn-external-idp'; function createExternalIdpClient(config: ExternalIdpConfig): ExternalIdpClient; @@ -387,6 +433,11 @@ interface ExternalIdpClient { provider: string, options?: ExternalIdpSelectOptions, ): Promise; + + authorizeForDaVinci( + daVinci: DaVinciInstance, + options?: ExternalIdpAuthorizeOptions, + ): Promise; } ``` diff --git a/packages/external-idp/android/build.gradle b/packages/external-idp/android/build.gradle index f5aca5e8f..7bcd019bb 100644 --- a/packages/external-idp/android/build.gradle +++ b/packages/external-idp/android/build.gradle @@ -99,6 +99,7 @@ dependencies { implementation("com.pingidentity.sdks:logger:2.0.1") implementation("com.pingidentity.sdks:external-idp:2.0.1") implementation("com.pingidentity.sdks:journey-plugin:2.0.1") + compileOnly("com.pingidentity.sdks:davinci:2.0.1") // IdpCollector is in davinci; compileOnly so apps without rn-davinci don't bundle it 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")) diff --git a/packages/external-idp/android/src/main/java/com/pingidentity/rnexternalidp/RNPingExternalIdpCommon.kt b/packages/external-idp/android/src/main/java/com/pingidentity/rnexternalidp/RNPingExternalIdpCommon.kt index f0e78ba19..11f2bcadb 100644 --- a/packages/external-idp/android/src/main/java/com/pingidentity/rnexternalidp/RNPingExternalIdpCommon.kt +++ b/packages/external-idp/android/src/main/java/com/pingidentity/rnexternalidp/RNPingExternalIdpCommon.kt @@ -17,6 +17,7 @@ import com.facebook.react.bridge.ReadableType import com.pingidentity.android.ContextProvider import com.pingidentity.idp.IdpCanceledException import com.pingidentity.idp.UnsupportedIdPException +import com.pingidentity.idp.davinci.IdpCollector as DaVinciIdpCollector import com.pingidentity.idp.journey.IdpCallback import com.pingidentity.idp.journey.SelectIdpCallback import com.pingidentity.logger.Logger @@ -313,6 +314,115 @@ object RNPingExternalIdpCommon { } } + /** + * Launches the external IdP authorization flow for an active DaVinci IdpCollector. + * + * Architecturally different from Journey: the IdP token flows through + * `daVinci.next()` via the native `RequestInterceptor`. This call resolves null + * on success — the token is NOT returned to JS. + * + * @param davinciId Native DaVinci instance id. + * @param options Per-call options payload (index). + * @param config Per-call configuration payload (loggerId, redirectUri). + * @param promise React Native promise resolved with null or rejected on error. + */ + @JvmStatic + fun authorizeForDaVinci( + davinciId: String, + options: ReadableMap, + config: ReadableMap, + promise: Promise + ) { + val callConfig = parseCallConfig(config) + val logger = resolveLoggerFromCore(callConfig.loggerId) + if (davinciId.isBlank()) { + logger?.w("External IdP authorizeForDaVinci rejected because DaVinci id was empty", null) + rejectWithError( + promise = promise, + code = ExternalIdpErrorCodes.CALLBACK_NOT_FOUND, + message = "DaVinci id must not be empty for external IdP authorization.", + type = ErrorType.ARGUMENT_ERROR + ) + return + } + if (!hasForegroundActivity()) { + logger?.w( + "External IdP authorizeForDaVinci rejected because no foreground activity is available", + null + ) + rejectWithError( + promise = promise, + code = ExternalIdpErrorCodes.ACTIVITY_UNAVAILABLE, + message = "No foreground activity available for external IdP authorization." + ) + return + } + + scope.launchBridge(promise, ExternalIdpErrorCodes.AUTHORIZE_ERROR) { + try { + val index = parseCallbackIndex(options) + logger?.i("External IdP authorizeForDaVinci requested for collector index $index") + val collector = resolveDaVinciIdpCollector(davinciId, index) + if (collector == null) { + logger?.w("External IdP authorizeForDaVinci collector not found at index $index", null) + rejectWithError( + promise = promise, + code = ExternalIdpErrorCodes.CALLBACK_NOT_FOUND, + message = "No active IdP collector found for DaVinci $davinciId at index $index.", + type = ErrorType.STATE_ERROR + ) + return@launchBridge + } + val parsedRedirectUri = callConfig.redirectUri.trim().takeIf { it.isNotEmpty() }?.toUri() + + val result = withContext(Dispatchers.Main) { + parsedRedirectUri?.let { collector.authorize(it) } ?: collector.authorize() + } + + result.fold( + onSuccess = { + logger?.d("External IdP authorizeForDaVinci succeeded") + promise.resolve(null) + }, + onFailure = { error -> + val (code, message) = discriminateIdpError(error) + logger?.e("External IdP authorizeForDaVinci failed with code $code", error) + rejectWithError( + promise = promise, + code = code, + message = message, + throwable = error + ) + } + ) + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + when (e) { + is ClassNotFoundException, is NoClassDefFoundError -> { + logger?.e("External IdP authorizeForDaVinci failed because provider SDK is unavailable", e) + rejectWithError( + promise = promise, + code = ExternalIdpErrorCodes.UNSUPPORTED_PROVIDER, + message = "Native provider SDK is not available: ${e.message}", + throwable = e + ) + } + else -> { + val (code, message) = discriminateIdpError(e) + logger?.e("External IdP authorizeForDaVinci failed with code $code", e) + rejectWithError( + promise = promise, + code = code, + message = message, + throwable = e + ) + } + } + } + } + } + /** * Rejects a promise with the shared External IdP error contract. */ @@ -403,6 +513,14 @@ object RNPingExternalIdpCommon { return handle.nativeLogger as? Logger } + /** + * Resolves a DaVinci IdpCollector from core-exposed DaVinci collectors by davinciId and type index. + */ + private suspend fun resolveDaVinciIdpCollector(davinciId: String, index: Int): DaVinciIdpCollector? { + val collectors = CoreRuntime.resolveDaVinciCollectors(davinciId) ?: return null + return collectors.filterIsInstance().getOrNull(index) + } + /** * Resolves an IdpCallback from core-exposed Journey callbacks by journeyId and type index. */ diff --git a/packages/external-idp/android/src/newarch/java/com/pingidentity/rnexternalidp/RNPingExternalIdpModule.kt b/packages/external-idp/android/src/newarch/java/com/pingidentity/rnexternalidp/RNPingExternalIdpModule.kt index 42f6c22a3..2bcc786ec 100644 --- a/packages/external-idp/android/src/newarch/java/com/pingidentity/rnexternalidp/RNPingExternalIdpModule.kt +++ b/packages/external-idp/android/src/newarch/java/com/pingidentity/rnexternalidp/RNPingExternalIdpModule.kt @@ -60,6 +60,18 @@ class RNPingExternalIdpModule(reactContext: ReactApplicationContext) : RNPingExternalIdpCommon.setSelectedProvider(journeyId, provider, options, config, promise) } + /** + * Launches the external IdP authorization flow for an active DaVinci IdpCollector. + */ + override fun authorizeForDaVinci( + davinciId: String, + options: ReadableMap, + config: ReadableMap, + promise: Promise + ) { + RNPingExternalIdpCommon.authorizeForDaVinci(davinciId, options, config, promise) + } + companion object { const val NAME = "RNPingExternalIdp" } diff --git a/packages/external-idp/android/src/oldarch/java/com/pingidentity/rnexternalidp/RNPingExternalIdpClassicModule.kt b/packages/external-idp/android/src/oldarch/java/com/pingidentity/rnexternalidp/RNPingExternalIdpClassicModule.kt index b05fc23b6..d4cb06cdd 100644 --- a/packages/external-idp/android/src/oldarch/java/com/pingidentity/rnexternalidp/RNPingExternalIdpClassicModule.kt +++ b/packages/external-idp/android/src/oldarch/java/com/pingidentity/rnexternalidp/RNPingExternalIdpClassicModule.kt @@ -80,6 +80,24 @@ class RNPingExternalIdpClassicModule( RNPingExternalIdpCommon.setSelectedProvider(journeyId, provider, options, config, promise) } + /** + * Launches the external IdP authorization flow for an active DaVinci IdpCollector. + * + * @param davinciId Native DaVinci instance id. + * @param options Per-call options payload (index). + * @param config Per-call configuration payload (loggerId, redirectUri). + * @param promise React Native promise resolved with null or rejected on error. + */ + @ReactMethod + fun authorizeForDaVinci( + davinciId: String, + options: ReadableMap, + config: ReadableMap, + promise: Promise + ) { + RNPingExternalIdpCommon.authorizeForDaVinci(davinciId, options, config, promise) + } + companion object { const val NAME = "RNPingExternalIdpClassic" } diff --git a/packages/external-idp/android/src/test/java/com/pingidentity/rnexternalidp/RNPingExternalIdpTest.kt b/packages/external-idp/android/src/test/java/com/pingidentity/rnexternalidp/RNPingExternalIdpTest.kt index 781cb1a43..e1d4f4d89 100644 --- a/packages/external-idp/android/src/test/java/com/pingidentity/rnexternalidp/RNPingExternalIdpTest.kt +++ b/packages/external-idp/android/src/test/java/com/pingidentity/rnexternalidp/RNPingExternalIdpTest.kt @@ -13,6 +13,7 @@ import com.facebook.react.bridge.WritableArray import com.facebook.react.bridge.WritableMap import com.pingidentity.idp.journey.SelectIdpCallback import com.pingidentity.rncore.CoreRuntime +import com.pingidentity.rncore.DaVinciCollectorResolver import com.pingidentity.rncore.JourneyCallbackResolver import com.pingidentity.rncore.error.ErrorType import java.lang.reflect.InvocationTargetException @@ -43,6 +44,7 @@ import org.robolectric.annotation.Implements @Config(sdk = [29], shadows = [ShadowExternalIdpArguments::class]) class RNPingExternalIdpTest { private var originalJourneyCallbackResolver: JourneyCallbackResolver? = null + private var originalDaVinciCollectorResolver: DaVinciCollectorResolver? = null /** * Configure bridge test seams before each test. @@ -50,7 +52,9 @@ class RNPingExternalIdpTest { @Before fun setUp() { originalJourneyCallbackResolver = CoreRuntime.journeyCallbackResolver + originalDaVinciCollectorResolver = CoreRuntime.davinciCollectorResolver CoreRuntime.journeyCallbackResolver = null + CoreRuntime.davinciCollectorResolver = null RNPingExternalIdpCommon.foregroundActivityProvider = { true } } @@ -60,6 +64,7 @@ class RNPingExternalIdpTest { @After fun tearDown() { CoreRuntime.journeyCallbackResolver = originalJourneyCallbackResolver + CoreRuntime.davinciCollectorResolver = originalDaVinciCollectorResolver RNPingExternalIdpCommon.foregroundActivityProvider = { true } } @@ -296,6 +301,65 @@ class RNPingExternalIdpTest { assertTrue(error.getString("message")?.contains("index 2") == true) } + /** + * Ensures DaVinci authorization rejects blank davinciId with the shared error payload. + */ + @Test + fun authorizeForDaVinciRejectsWhenDaVinciIdIsBlank() { + val promise = TestPromise() + + RNPingExternalIdpCommon.authorizeForDaVinci(" ", JavaOnlyMap(), JavaOnlyMap(), promise) + + val error = captureReject(promise) + assertEquals(ExternalIdpErrorCodes.CALLBACK_NOT_FOUND, promise.rejectCode) + assertEquals(ErrorType.ARGUMENT_ERROR.rawValue, error.getString("type")) + assertEquals(ExternalIdpErrorCodes.CALLBACK_NOT_FOUND, error.getString("error")) + assertTrue(error.getString("message")?.contains("DaVinci id must not be empty") == true) + } + + /** + * Ensures DaVinci authorization rejects when no foreground activity is available. + */ + @Test + fun authorizeForDaVinciRejectsWhenActivityUnavailable() { + RNPingExternalIdpCommon.foregroundActivityProvider = { false } + val promise = TestPromise() + + RNPingExternalIdpCommon.authorizeForDaVinci( + "davinci-id", + JavaOnlyMap(), + JavaOnlyMap.of("redirectUri", "com.example://callback"), + promise + ) + + val error = captureReject(promise) + assertEquals(ExternalIdpErrorCodes.ACTIVITY_UNAVAILABLE, promise.rejectCode) + assertEquals(ErrorType.AUTH_ERROR.rawValue, error.getString("type")) + assertEquals(ExternalIdpErrorCodes.ACTIVITY_UNAVAILABLE, error.getString("error")) + assertTrue(error.getString("message")?.contains("No foreground activity") == true) + } + + /** + * Ensures DaVinci authorization rejects when no collector is registered in CoreRuntime. + */ + @Test + fun authorizeForDaVinciRejectsWhenNoDaVinciCollectorRegistered() { + CoreRuntime.davinciCollectorResolver = { emptyList() } + val promise = TestPromise() + + RNPingExternalIdpCommon.authorizeForDaVinci( + "davinci-id", + JavaOnlyMap(), + JavaOnlyMap(), + promise + ) + + val error = captureReject(promise) + assertEquals(ExternalIdpErrorCodes.CALLBACK_NOT_FOUND, promise.rejectCode) + assertEquals(ErrorType.STATE_ERROR.rawValue, error.getString("type")) + assertEquals(ExternalIdpErrorCodes.CALLBACK_NOT_FOUND, error.getString("error")) + } + /** * Ensures cleanup cancels in-flight coroutines launched on the shared scope so that * a React context teardown does not leak captured Promises or activity references. diff --git a/packages/external-idp/ios/RNPingExternalIdp.mm b/packages/external-idp/ios/RNPingExternalIdp.mm index f42557c3a..83bca803c 100644 --- a/packages/external-idp/ios/RNPingExternalIdp.mm +++ b/packages/external-idp/ios/RNPingExternalIdp.mm @@ -66,6 +66,25 @@ - (void)selectProviderForJourney:(NSString *)journeyId }); } +/** + Launches the external IdP authorization flow for an active DaVinci collector. + */ +- (void)authorizeForDaVinci:(NSString *)davinciId + options:(NSDictionary *)options + config:(NSDictionary *)config + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)rejecter +{ + if ([NSThread isMainThread]) { + [[self swiftImpl] authorizeForDaVinci:davinciId options:options config:config resolve:resolve rejecter:rejecter]; + return; + } + + dispatch_async(dispatch_get_main_queue(), ^{ + [[self swiftImpl] authorizeForDaVinci:davinciId options:options config:config resolve:resolve rejecter:rejecter]; + }); +} + - (std::shared_ptr)getTurboModule: (const facebook::react::ObjCTurboModule::InitParams &)params { diff --git a/packages/external-idp/ios/RNPingExternalIdpClassic.mm b/packages/external-idp/ios/RNPingExternalIdpClassic.mm index 9869eb200..6c84ab851 100644 --- a/packages/external-idp/ios/RNPingExternalIdpClassic.mm +++ b/packages/external-idp/ios/RNPingExternalIdpClassic.mm @@ -67,4 +67,18 @@ - (void)withSwiftImpl:(void (^)(RNPingExternalIdpImpl *impl))block }]; } +/** + * Launches the external IdP authorization flow for an active DaVinci collector. + */ +RCT_EXPORT_METHOD(authorizeForDaVinci:(NSString *)davinciId + options:(NSDictionary *)options + config:(NSDictionary *)config + resolve:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) +{ + [self withSwiftImpl:^(RNPingExternalIdpImpl *impl) { + [impl authorizeForDaVinci:davinciId options:options config:config resolve:resolve rejecter:reject]; + }]; +} + @end diff --git a/packages/external-idp/ios/RNPingExternalIdpCommon.swift b/packages/external-idp/ios/RNPingExternalIdpCommon.swift index 0700f91da..db7000e01 100644 --- a/packages/external-idp/ios/RNPingExternalIdpCommon.swift +++ b/packages/external-idp/ios/RNPingExternalIdpCommon.swift @@ -223,6 +223,98 @@ public class RNPingExternalIdpCommon: NSObject { } } + /// Launches the external IdP authorization flow for an active DaVinci `IdpCollector`. + /// + /// Architecturally different from Journey: the IdP token flows through + /// `daVinci.next()` via the native `RequestInterceptor`. This method resolves + /// void — the token is NOT returned to JS. + /// + /// - Parameters: + /// - davinciId: Native DaVinci instance id. + /// - options: Per-call options payload. + /// - config: Per-call runtime configuration payload. + /// - resolver: Promise resolver for void completion. + /// - rejecter: Promise rejecter for errors. + @objc + @MainActor + public static func authorizeForDaVinci( + _ davinciId: String, + options: NSDictionary, + config: NSDictionary, + resolver: @escaping RCTPromiseResolveBlock, + rejecter: @escaping RCTPromiseRejectBlock + ) { + let callConfig = parseCallConfig(config) + let handlers = PromiseBridge(resolver: resolver, rejecter: rejecter) + if davinciId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + handlers.reject( + GenericError( + type: .argumentError, + error: ExternalIdpErrorCode.callbackNotFound.rawValue, + message: "DaVinci id must not be empty for external IdP authorization." + ) + ) + return + } + + guard PresentationAnchorResolver.resolveForegroundWindowAnchor() != nil else { + handlers.reject( + GenericError( + type: .authError, + error: ExternalIdpErrorCode.windowUnavailable.rawValue, + message: "No active application window is available for external IdP authorization." + ) + ) + return + } + + let collectorIndex = parseCallbackIndex(options) + + Task { @MainActor in + let logger = await resolveLoggerFromCore(callConfig.loggerId) + logger?.i("External IdP authorizeForDaVinci requested for collector index \(collectorIndex)") + + guard let collectors = await CoreRuntime.resolveDaVinciCollectors(davinciId) else { + logger?.w("External IdP authorizeForDaVinci collectors not found for davinciId \(davinciId)", error: nil) + handlers.reject( + GenericError( + type: .stateError, + error: ExternalIdpErrorCode.callbackNotFound.rawValue, + message: "No active DaVinci collectors found for id \(davinciId)." + ) + ) + return + } + + let matching = collectors.compactMap { $0 as? IdpCollector } + guard collectorIndex >= 0, collectorIndex < matching.count else { + logger?.w("External IdP authorizeForDaVinci collector not found at index \(collectorIndex)", error: nil) + handlers.reject( + GenericError( + type: .stateError, + error: ExternalIdpErrorCode.callbackNotFound.rawValue, + message: "No active IdP collector found for DaVinci \(davinciId) at index \(collectorIndex)." + ) + ) + return + } + + let collector = matching[collectorIndex] + let callbackURLScheme = callConfig.redirectUri.isEmpty ? nil : callConfig.redirectUri + let result = await collector.authorize(callbackURLScheme: callbackURLScheme) + switch result { + case .success: + logger?.d("External IdP authorizeForDaVinci succeeded") + handlers.resolve(NSNull()) + + case .failure(let error): + let mapped = mapIdpError(error) + logger?.e("External IdP authorizeForDaVinci failed with code \(mapped.error)", error: error as NSError) + handlers.reject(mapped, underlying: error as NSError) + } + } + } + /// Resolves a native logger from the shared Core logger registry. /// /// - Parameter loggerId: Logger handle identifier from JS. diff --git a/packages/external-idp/ios/RNPingExternalIdpImpl.swift b/packages/external-idp/ios/RNPingExternalIdpImpl.swift index b5e642b31..202b6d54c 100644 --- a/packages/external-idp/ios/RNPingExternalIdpImpl.swift +++ b/packages/external-idp/ios/RNPingExternalIdpImpl.swift @@ -72,4 +72,29 @@ public final class RNPingExternalIdpImpl: NSObject, Sendable { rejecter: rejecter ) } + + /// Launches the external IdP authorization flow for an active DaVinci `IdpCollector`. + /// - Parameters: + /// - davinciId: Native DaVinci instance id. + /// - options: Per-call authorize options. + /// - config: Per-call runtime configuration payload. + /// - resolve: Promise resolver for void completion. + /// - rejecter: Promise rejecter for errors. + @objc + @MainActor + public func authorizeForDaVinci( + _ davinciId: String, + options: NSDictionary, + config: NSDictionary, + resolve: @escaping RCTPromiseResolveBlock, + rejecter: @escaping RCTPromiseRejectBlock + ) { + RNPingExternalIdpCommon.authorizeForDaVinci( + davinciId, + options: options, + config: config, + resolver: resolve, + rejecter: rejecter + ) + } } diff --git a/packages/external-idp/ios/Tests/RNPingExternalIdpImplTests.swift b/packages/external-idp/ios/Tests/RNPingExternalIdpImplTests.swift index b788079d5..f20350081 100644 --- a/packages/external-idp/ios/Tests/RNPingExternalIdpImplTests.swift +++ b/packages/external-idp/ios/Tests/RNPingExternalIdpImplTests.swift @@ -65,6 +65,28 @@ final class RNPingExternalIdpImplTests: XCTestCase { XCTAssertEqual(code, "EXTERNAL_IDP_WINDOW_UNAVAILABLE") } + // MARK: - authorizeForDaVinci + + func testAuthorizeForDaVinciRejectsWhenDaVinciIdIsEmpty() async { + let (code, type, _) = await invokeAuthorizeForDaVinci(davinciId: "") + XCTAssertEqual(code, "EXTERNAL_IDP_CALLBACK_NOT_FOUND") + XCTAssertEqual(type, ErrorType.argumentError.rawValue) + } + + func testAuthorizeForDaVinciRejectsWhenDaVinciIdIsWhitespace() async { + let (code, type, _) = await invokeAuthorizeForDaVinci(davinciId: " ") + XCTAssertEqual(code, "EXTERNAL_IDP_CALLBACK_NOT_FOUND") + XCTAssertEqual(type, ErrorType.argumentError.rawValue) + } + + func testAuthorizeForDaVinciRejectsWithWindowUnavailableWhenNoActiveWindow() async { + let (code, type, message) = await invokeAuthorizeForDaVinci(davinciId: "davinci-1") + XCTAssertEqual(code, "EXTERNAL_IDP_WINDOW_UNAVAILABLE") + XCTAssertEqual(type, ErrorType.authError.rawValue) + XCTAssertNotNil(message) + XCTAssertTrue(message?.contains("window") == true) + } + // MARK: - selectProviderForJourney func testSelectProviderForJourneyRejectsWhenJourneyIdIsEmpty() async { @@ -159,4 +181,26 @@ final class RNPingExternalIdpImplTests: XCTestCase { ) } } + + @MainActor + private func invokeAuthorizeForDaVinci( + davinciId: String, + options: NSDictionary = [:], + config: NSDictionary = [:] + ) async -> (String?, String?, String?) { + await withCheckedContinuation { (continuation: CheckedContinuation<(String?, String?, String?), Never>) in + impl.authorizeForDaVinci( + davinciId, + options: options, + config: config, + resolve: { _ in + continuation.resume(returning: ("UNEXPECTED_RESOLVE", nil, nil)) + }, + rejecter: { code, message, error in + let type = (error as NSError?)?.userInfo["type"] as? String + continuation.resume(returning: (code, type, message)) + } + ) + } + } } diff --git a/packages/external-idp/src/NativeRNPingExternalIdp.ts b/packages/external-idp/src/NativeRNPingExternalIdp.ts index 8d05bb86a..fd5d1f1e8 100644 --- a/packages/external-idp/src/NativeRNPingExternalIdp.ts +++ b/packages/external-idp/src/NativeRNPingExternalIdp.ts @@ -64,6 +64,23 @@ export interface Spec extends TurboModule { options: Object, config: Object, ): Promise; + + /** + * Launches the external IdP authorization flow for a DaVinci-scoped `IdpCollector`. + * + * The token is NOT returned — it flows through `daVinci.next()` via the native + * `RequestInterceptor`. Resolves void when the redirect is complete. + * + * @param davinciId Native DaVinci instance id. + * @param options Per-call authorize options payload (index). + * @param config Per-client runtime configuration payload (loggerId, redirectUri). + * @returns A promise that resolves to void when authorization completes. + */ + authorizeForDaVinci( + davinciId: string, + options: Object, + config: Object, + ): Promise; } /* eslint-enable @typescript-eslint/no-wrapper-object-types */ diff --git a/packages/external-idp/src/__tests__/index.test.tsx b/packages/external-idp/src/__tests__/index.test.tsx index 2503fa07b..58f4c9203 100644 --- a/packages/external-idp/src/__tests__/index.test.tsx +++ b/packages/external-idp/src/__tests__/index.test.tsx @@ -383,6 +383,85 @@ describe('ExternalIdp API', () => { }); }); + it('authorizeForDaVinci success — resolves void', async () => { + const authorizeNative = jest.fn().mockResolvedValue(undefined); + (getNativeModule as jest.Mock).mockReturnValue({ + authorizeForDaVinci: authorizeNative, + }); + const daVinci = { getId: jest.fn().mockResolvedValue('davinci-1') }; + const client = createExternalIdpClient({ redirectUri: 'com.app://cb' }); + + const result = await client.authorizeForDaVinci(daVinci); + + expect(result).toBeUndefined(); + }); + + it('authorizeForDaVinci — calls daVinci.getId() and forwards davinciId to native', async () => { + const authorizeNative = jest.fn().mockResolvedValue(undefined); + (getNativeModule as jest.Mock).mockReturnValue({ + authorizeForDaVinci: authorizeNative, + }); + const daVinci = { getId: jest.fn().mockResolvedValue('davinci-1') }; + const client = createExternalIdpClient({ redirectUri: 'com.app://cb' }); + + await client.authorizeForDaVinci(daVinci); + + expect(daVinci.getId).toHaveBeenCalledTimes(1); + expect(authorizeNative).toHaveBeenCalledWith( + 'davinci-1', + {}, + { redirectUri: 'com.app://cb' }, + ); + }); + + it('authorizeForDaVinci with index option — forwards to native', async () => { + const authorizeNative = jest.fn().mockResolvedValue(undefined); + (getNativeModule as jest.Mock).mockReturnValue({ + authorizeForDaVinci: authorizeNative, + }); + const daVinci = { getId: jest.fn().mockResolvedValue('davinci-2') }; + const client = createExternalIdpClient({ redirectUri: 'com.app://cb' }); + + await client.authorizeForDaVinci(daVinci, { index: 1 }); + + expect(authorizeNative).toHaveBeenCalledWith( + 'davinci-2', + { index: 1 }, + { redirectUri: 'com.app://cb' }, + ); + }); + + it('authorizeForDaVinci with no options — forwards empty options object to native', async () => { + const authorizeNative = jest.fn().mockResolvedValue(undefined); + (getNativeModule as jest.Mock).mockReturnValue({ + authorizeForDaVinci: authorizeNative, + }); + const daVinci = { getId: jest.fn().mockResolvedValue('davinci-3') }; + const client = createExternalIdpClient({ redirectUri: 'com.app://cb' }); + + await client.authorizeForDaVinci(daVinci); + + expect(authorizeNative).toHaveBeenCalledWith( + 'davinci-3', + {}, + { redirectUri: 'com.app://cb' }, + ); + }); + + it('authorizeForDaVinci failure — propagates error', async () => { + const nativeError = new Error('EXTERNAL_IDP_AUTHORIZE_ERROR'); + const authorizeNative = jest.fn().mockRejectedValue(nativeError); + (getNativeModule as jest.Mock).mockReturnValue({ + authorizeForDaVinci: authorizeNative, + }); + const daVinci = { getId: jest.fn().mockResolvedValue('davinci-4') }; + const client = createExternalIdpClient({ redirectUri: 'com.app://cb' }); + + await expect(client.authorizeForDaVinci(daVinci)).rejects.toThrow( + 'EXTERNAL_IDP_AUTHORIZE_ERROR', + ); + }); + it('keeps client configs isolated per instance', async () => { const authorizeNative = jest .fn() diff --git a/packages/external-idp/src/externalIdp.ts b/packages/external-idp/src/externalIdp.ts index 8d2f171ce..605693523 100644 --- a/packages/external-idp/src/externalIdp.ts +++ b/packages/external-idp/src/externalIdp.ts @@ -14,6 +14,7 @@ import { } from './NativeRNPingExternalIdp'; import { noopLogger } from '@ping-identity/rn-types'; import type { + DaVinciInstance, ExternalIdpAuthorizeOptions, ExternalIdpClient, ExternalIdpConfig, @@ -178,5 +179,30 @@ export function createExternalIdpClient( ); }); }, + + /** + * Launches the external IdP authorization flow for a DaVinci-scoped `IdpCollector`. + * + * Architecturally different from Journey: the IdP token flows through + * `daVinci.next({ collectors: [] })` via the native `RequestInterceptor` mechanism + * — this call resolves void, not a token. Call `daVinci.next()` immediately after. + * + * @param daVinci Active DaVinci instance. + * @param options Optional per-call authorize options (index). + * @throws ExternalIdpError when authorization fails. + */ + authorizeForDaVinci( + daVinci: DaVinciInstance, + options: ExternalIdpAuthorizeOptions = {}, + ): Promise { + return withLogging('authorizeForDaVinci', async () => { + const davinciId = await daVinci.getId(); + await getNativeModule().authorizeForDaVinci( + davinciId, + toNativeAuthorizeOptions(options), + toNativeConfig(resolvedConfig), + ); + }); + }, }; } diff --git a/packages/external-idp/src/types/externalIdp.types.ts b/packages/external-idp/src/types/externalIdp.types.ts index a73ff40f7..99ebb1645 100644 --- a/packages/external-idp/src/types/externalIdp.types.ts +++ b/packages/external-idp/src/types/externalIdp.types.ts @@ -6,6 +6,7 @@ */ import { PingError } from '@ping-identity/rn-types'; +import type { DaVinciInstance } from '@ping-identity/rn-types'; import type { JourneyInstance } from '@ping-identity/rn-types'; import type { LoggerInstance } from '@ping-identity/rn-types'; @@ -80,7 +81,7 @@ export type ExternalIdpClientConfig = { }; /** - * Reusable client for external IdP operations in Journey flows. + * Reusable client for external IdP operations in Journey and DaVinci flows. */ export type ExternalIdpClient = { /** @@ -117,6 +118,29 @@ export type ExternalIdpClient = { provider: string, options?: ExternalIdpSelectOptions, ): Promise; + + /** + * Launches the external IdP authorization flow for a DaVinci-scoped `IdpCollector`. + * + * Architecturally different from Journey: the IdP token flows through + * `daVinci.next({ collectors: [] })` via the native `RequestInterceptor` mechanism + * — this call does NOT return the token directly. Call `daVinci.next()` immediately + * after this resolves to advance the flow. + * + * @remarks + * No `selectProviderForDaVinci` equivalent exists — each social login option is its + * own `IdpCollector` with `idpId`/`idpType`/`label` pre-set. The `index` option + * handles the rare case of multiple `IdpCollector` instances on the same node. + * + * @param daVinci Active DaVinci instance. + * @param options Optional per-call authorize options (index). + * @returns A promise that resolves to void when the IdP redirect is complete. + * @throws ExternalIdpError when authorization fails. + */ + authorizeForDaVinci( + daVinci: DaVinciInstance, + options?: ExternalIdpAuthorizeOptions, + ): Promise; }; /** @@ -153,4 +177,4 @@ export type ExternalIdpErrorCode = | 'EXTERNAL_IDP_ACTIVITY_UNAVAILABLE' // Android only | 'EXTERNAL_IDP_WINDOW_UNAVAILABLE'; // iOS only -export type { JourneyInstance }; +export type { DaVinciInstance, JourneyInstance }; diff --git a/packages/external-idp/src/types/index.ts b/packages/external-idp/src/types/index.ts index 562d0bb05..1685a3610 100644 --- a/packages/external-idp/src/types/index.ts +++ b/packages/external-idp/src/types/index.ts @@ -7,6 +7,7 @@ export { ExternalIdpError } from './externalIdp.types'; export type { + DaVinciInstance, ExternalIdpAuthorizeOptions, ExternalIdpClient, ExternalIdpConfig, diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index dbfaf4634..c77eeb8bd 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -143,6 +143,21 @@ export type JourneyInstance = { getId: () => Promise; }; +/** + * Minimal DaVinci instance contract for cross-module coordination. + * + * @remarks + * The DaVinci module owns instance creation; this type enables other modules + * (e.g. rn-external-idp) to invoke DaVinci operations without introducing + * package coupling. Mirrors {@link JourneyInstance} structurally. + */ +export type DaVinciInstance = { + /** + * Returns the native DaVinci instance identifier. + */ + getId: () => Promise; +}; + /** * No-op logger that satisfies the {@link LoggerInstance} contract without * emitting anything. Used as the default when no logger is provided.