diff --git a/.github/workflows/browserstack-e2e-android-journey.yml b/.github/workflows/browserstack-e2e-android-journey.yml index b07ac5bcf..c02e0c50a 100644 --- a/.github/workflows/browserstack-e2e-android-journey.yml +++ b/.github/workflows/browserstack-e2e-android-journey.yml @@ -4,7 +4,7 @@ # This software may be modified and distributed under the terms # of the MIT license. See the LICENSE file for details. # -name: BrowserStack E2E — Android Journey +name: BrowserStack E2E — Android Journey & DaVinci on: workflow_call: @@ -18,7 +18,7 @@ on: description: BrowserStack session name used by Detox required: false type: string - default: android-detox-e2e-journey + default: android-detox-e2e-journey-davinci secrets: BROWSERSTACK_USERNAME: description: BrowserStack account username @@ -56,13 +56,31 @@ on: PING_CALLBACK_TREES_ENABLED: description: Flag enabling callback tree coverage in BrowserStack E2E tests required: false + PINGONE_DISCOVERY_ENDPOINT: + description: PingOne DaVinci OIDC discovery endpoint + required: false + PINGONE_CLIENT_ID: + description: PingOne DaVinci OIDC client identifier + required: false + PINGONE_REDIRECT_URI: + description: PingOne DaVinci OIDC redirect URI + required: false + PINGONE_ACR_VALUES: + description: ACR values to route to the correct DaVinci flow + required: false + PINGONE_USERNAME: + description: PingOne DaVinci test account username + required: false + PINGONE_PASSWORD: + description: PingOne DaVinci test account password + required: false permissions: contents: read jobs: - e2e-android-journey: - name: E2E — Android BrowserStack (journey) + e2e-android-journey-davinci: + name: E2E — Android BrowserStack (journey & davinci) runs-on: ubuntu-latest timeout-minutes: 45 defaults: @@ -83,6 +101,12 @@ jobs: PING_CLIENT_ID: ${{ secrets.PING_CLIENT_ID }} PING_REDIRECT_URI: ${{ secrets.PING_REDIRECT_URI }} PING_CALLBACK_TREES_ENABLED: ${{ secrets.PING_CALLBACK_TREES_ENABLED }} + PINGONE_DISCOVERY_ENDPOINT: ${{ secrets.PINGONE_DISCOVERY_ENDPOINT }} + PINGONE_CLIENT_ID: ${{ secrets.PINGONE_CLIENT_ID }} + PINGONE_REDIRECT_URI: ${{ secrets.PINGONE_REDIRECT_URI }} + PINGONE_ACR_VALUES: ${{ secrets.PINGONE_ACR_VALUES }} + PINGONE_USERNAME: ${{ secrets.PINGONE_USERNAME }} + PINGONE_PASSWORD: ${{ secrets.PINGONE_PASSWORD }} steps: - name: Checkout @@ -111,8 +135,8 @@ jobs: - name: Compute BrowserStack identifiers # Naming convention: # Project : rn--test → rn-sdk-test - # Build : ----journey - # Session : --journey + # Build : ----journey-davinci + # Session : --journey-davinci run: | set -euo pipefail SHORT_SHA="${GITHUB_SHA:0:7}" @@ -127,7 +151,7 @@ jobs: BUILD_PREFIX="run" ;; esac - echo "BROWSERSTACK_BUILD_ID=${BUILD_PREFIX}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-${SHORT_SHA}-journey" >> "$GITHUB_ENV" + echo "BROWSERSTACK_BUILD_ID=${BUILD_PREFIX}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-${SHORT_SHA}-journey-davinci" >> "$GITHUB_ENV" echo "BROWSERSTACK_PROJECT_NAME=${{ inputs.browserstack_project_name }}" >> "$GITHUB_ENV" echo "BROWSERSTACK_SESSION_NAME=${{ inputs.browserstack_session_name }}" >> "$GITHUB_ENV" @@ -159,9 +183,9 @@ jobs: fi echo "test_url=$test_url" >> "$GITHUB_OUTPUT" - - name: Run E2E journey tests on BrowserStack real devices + - name: Run E2E journey & davinci tests on BrowserStack real devices id: run-tests - run: yarn test:runner:bs:android:journey 2>&1 | tee /tmp/detox-bs.log; exit "${PIPESTATUS[0]}" + run: yarn test:runner:bs:android:journey-davinci 2>&1 | tee /tmp/detox-bs.log; exit "${PIPESTATUS[0]}" working-directory: ${{ github.workspace }} env: BROWSERSTACK_APP_URL: ${{ steps.upload-app.outputs.app_url }} @@ -212,7 +236,7 @@ jobs: fi { - echo "## BrowserStack Android Detox Summary — Journey" + echo "## BrowserStack Android Detox Summary — Journey & DaVinci" echo echo "- Build ID: \`${build_id}\`" echo "- Overall status: ${overall_status}" diff --git a/.github/workflows/browserstack-prep-android-artifacts.yml b/.github/workflows/browserstack-prep-android-artifacts.yml index deed3620c..779547887 100644 --- a/.github/workflows/browserstack-prep-android-artifacts.yml +++ b/.github/workflows/browserstack-prep-android-artifacts.yml @@ -52,6 +52,7 @@ jobs: cd android && ./gradlew \ :ping-identity_rn-binding:generateCodegenArtifactsFromSchema \ :ping-identity_rn-browser:generateCodegenArtifactsFromSchema \ + :ping-identity_rn-davinci:generateCodegenArtifactsFromSchema \ :ping-identity_rn-device-client:generateCodegenArtifactsFromSchema \ :ping-identity_rn-device-id:generateCodegenArtifactsFromSchema \ :ping-identity_rn-device-profile:generateCodegenArtifactsFromSchema \ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1cf9033bd..9f9a38aae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -110,15 +110,14 @@ jobs: contents: read uses: ./.github/workflows/browserstack-prep-android-artifacts.yml - # Run Foundation Modules E2E tests on BrowserStack real devices. + # Run Journey and DaVinci E2E tests on BrowserStack real devices. # Intentionally sequenced after core (not parallel): each Android workflow runs 3 matrix - # legs and iOS runs 3 devices concurrently. Running core + journey in parallel would peak - # at 6 Android + 3 iOS = 9 sessions, exceeding the limit of 6. - # Serialising core → journey keeps Android at 3 active sessions at any time, leaving 3 - # slots for iOS running in parallel — exactly at the limit. - browserstack-android-journey: + # legs and iOS runs 3 devices concurrently. Running core + journey/davinci in parallel would + # peak at 6 Android + 3 iOS = 9 sessions, exceeding the limit of 6. + # Serialising core → journey/davinci keeps Android at 3 active sessions at any time, leaving + # 3 slots for iOS running in parallel — exactly at the limit. + browserstack-android-journey-davinci: needs: browserstack-android-core - if: ${{ always() }} permissions: contents: read uses: ./.github/workflows/browserstack-e2e-android-journey.yml @@ -135,6 +134,12 @@ jobs: PING_CLIENT_ID: ${{ secrets.PING_CLIENT_ID }} PING_REDIRECT_URI: ${{ secrets.PING_REDIRECT_URI }} PING_CALLBACK_TREES_ENABLED: ${{ secrets.PING_CALLBACK_TREES_ENABLED }} + PINGONE_DISCOVERY_ENDPOINT: ${{ secrets.PINGONE_DISCOVERY_ENDPOINT }} + PINGONE_CLIENT_ID: ${{ secrets.PINGONE_CLIENT_ID }} + PINGONE_REDIRECT_URI: ${{ secrets.PINGONE_REDIRECT_URI }} + PINGONE_ACR_VALUES: ${{ secrets.PINGONE_ACR_VALUES }} + PINGONE_USERNAME: ${{ secrets.PINGONE_USERNAME }} + PINGONE_PASSWORD: ${{ secrets.PINGONE_PASSWORD }} # Run Foundation Modules E2E tests on BrowserStack real devices. browserstack-android-core: diff --git a/.gitignore b/.gitignore index 4891baf50..28266a43f 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,4 @@ junit.xml # Turborepo folder for persisting logs and outputs .turbo +.sdk/ diff --git a/.yarn/install-state.gz b/.yarn/install-state.gz index 1cea0d4d0..f5e55df49 100644 Binary files a/.yarn/install-state.gz and b/.yarn/install-state.gz differ diff --git a/PingSampleApp/App.tsx b/PingSampleApp/App.tsx index d9cd98ef0..1af0aa680 100644 --- a/PingSampleApp/App.tsx +++ b/PingSampleApp/App.tsx @@ -30,9 +30,11 @@ import PushNotificationsScreen from './ui/push/PushNotificationsScreen'; import { PushNotificationProvider } from './ui/push/PushNotificationProvider'; import OathTokensScreen from './ui/OathTokensScreen'; import QRScannerScreen from './ui/QRScannerScreen'; +import DaVinciScreen from './ui/DaVinciScreen'; import { JourneyProvider } from '@ping-identity/rn-journey'; import { OidcProvider } from '@ping-identity/rn-oidc'; -import { sampleAppClientProfiles } from './src/clients'; +import { DaVinciProvider } from '@ping-identity/rn-davinci'; +import { sampleAppClientProfiles, sampleDaVinciConfig } from './src/clients'; import { configureBrowser } from '@ping-identity/rn-browser'; import { logger } from '@ping-identity/rn-logger'; import MaterialIcon from 'react-native-vector-icons/MaterialIcons'; @@ -73,6 +75,7 @@ export type RootStackParamList = { PushNotifications: undefined; OathTokens: undefined; QRScanner: undefined; + DaVinci: undefined; }; const Stack = createNativeStackNavigator(); @@ -262,179 +265,186 @@ export default function App() { } return ( - // Journey and OIDC hooks resolve clients from these contexts. + // Journey, OIDC and DaVinci hooks resolve clients from these contexts. - - - - + + + - {props => ( - - )} - - - {props => ( - - )} - - - - {props => - selectedJourneyProfile ? ( - - ) : ( - - ) - } - - - {props => - selectedJourneyProfile ? ( - - ) : ( - - ) - } - - - {props => - selectedJourneyProfile ? ( - - ) : ( - - ) - } - - - - - {props => - selectedOidcProfile ? ( - + {props => ( + - ) : ( - + + {props => ( + - ) - } - - - - - - - - - - - - - - + )} + + + + {props => + selectedJourneyProfile ? ( + + ) : ( + + ) + } + + + {props => + selectedJourneyProfile ? ( + + ) : ( + + ) + } + + + {props => + selectedJourneyProfile ? ( + + ) : ( + + ) + } + + + + + {props => + selectedOidcProfile ? ( + + ) : ( + + ) + } + + + + + + + + + + + + + + + + ); diff --git a/PingSampleApp/README.md b/PingSampleApp/README.md index df665db28..3e3d5fdb0 100644 --- a/PingSampleApp/README.md +++ b/PingSampleApp/README.md @@ -10,14 +10,15 @@ of the MIT license. See the LICENSE file for details. # Ping Identity React Native Sample App A reference application for the Ping Identity React Native SDK. It demonstrates -Journey authentication, OIDC authorization, device management, FIDO/WebAuthn, -device binding, external IdP login, and token/session inspection — all wired -together with the SDK's native-backed modules. +Journey authentication, DaVinci orchestration, OIDC authorization, device +management, FIDO/WebAuthn, device binding, external IdP login, and +token/session inspection — all wired together with the SDK's native-backed +modules. ## Prerequisites - [React Native 0.80.1](https://reactnative.dev/docs/0.80/set-up-your-environment) environment set up (Node, Xcode, Android Studio) -- Ruby + Bundler (iOS only) +- Ruby >= 3.3.6 + Bundler (iOS only) - A Ping Identity server (ForgeRock/PingAM or PingOne/PingAdvancedIdentityCloud) ## Step 1: Configure your environment @@ -49,6 +50,10 @@ PINGONE_DISCOVERY_ENDPOINT=... PINGONE_REDIRECT_URI=... ``` +> The DaVinci screen reuses the `PINGONE_*` variables above — DaVinci flows +> are configured against the same PingOne tenant discovery endpoint, so no +> separate `DAVINCI_*` keys are needed. + See `.env.example` for the full list of supported keys including external IdP (Facebook, Google) configuration. @@ -98,6 +103,14 @@ KEYSTORE_PASSWORD= KEY_PASSWORD= ``` +### Android — SDK location + +> **Note:** The Android build requires `ANDROID_HOME` to be set in your environment. +> +> If it is not set, create `PingSampleApp/android/local.properties` containing `sdk.dir=/path/to/your/Android/SDK` before running the app. +> +> You can find the Android SDK path in Android Studio under **Settings** > **Languages & Frameworks** > **Android SDK**. + ## Step 3: Start Metro ```sh @@ -134,10 +147,11 @@ open -a "Android Studio" | ------------------------ | ----------------------------------------------------------------------------- | | Journey (Full) | `rn-journey`, `rn-binding`, `rn-fido`, `rn-external-idp`, `rn-device-profile` | | Journey (Form / Minimal) | `rn-journey` | +| DaVinci | `rn-davinci` | | OIDC | `rn-oidc`, `rn-browser` | -| Token | `rn-oidc` | -| User Profile | `rn-oidc` | -| Logout | `rn-oidc`, `rn-journey` | +| Token | `rn-oidc`, `rn-journey`, `rn-davinci` | +| User Profile | `rn-oidc`, `rn-journey`, `rn-davinci` | +| Logout | `rn-oidc`, `rn-journey`, `rn-davinci` | | Devices | `rn-device-client` | | Device Profile | `rn-device-profile` | | Binding Keys | `rn-binding` | diff --git a/PingSampleApp/ios/Podfile.lock b/PingSampleApp/ios/Podfile.lock index 3aa1a9cde..ba2ec4586 100644 --- a/PingSampleApp/ios/Podfile.lock +++ b/PingSampleApp/ios/Podfile.lock @@ -148,6 +148,9 @@ PODS: - PingLogger (~> 2.0.0) - PingCommons (2.0.0): - PingLogger (~> 2.0.0) + - PingDavinci (2.0.0): + - PingDavinciPlugin (~> 2.0.0) + - PingOidc (~> 2.0.0) - PingDavinciPlugin (2.0.0): - PingOrchestrate (~> 2.0.0) - PingDeviceClient (2.0.0): @@ -2465,7 +2468,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - RNPingBinding (1.0.0-beta.3): + - RNPingBinding (1.0.0): - boost - DoubleConversion - fast_float @@ -2496,7 +2499,7 @@ PODS: - RNPingCore - SocketRocket - Yoga - - RNPingBrowser (1.0.0-beta.3): + - RNPingBrowser (1.0.0): - boost - DoubleConversion - fast_float @@ -2529,13 +2532,48 @@ PODS: - RNPingLogger - SocketRocket - Yoga - - RNPingCore (1.0.0-beta.3): + - RNPingCore (1.0.0): + - boost + - DoubleConversion + - fast_float + - fmt + - glog + - hermes-engine + - RCT-Folly + - RCT-Folly/Fabric + - RCTRequired + - RCTTypeSafety + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - SocketRocket + - Yoga + - RNPingDavinci (0.0.0): - boost - DoubleConversion - fast_float - fmt - glog - hermes-engine + - PingDavinci (= 2.0.0) + - PingDavinciPlugin (= 2.0.0) + - PingLogger (= 2.0.0) + - PingOidc (= 2.0.0) + - PingOrchestrate (= 2.0.0) + - PingStorage (= 2.0.0) - RCT-Folly - RCT-Folly/Fabric - RCTRequired @@ -2556,9 +2594,10 @@ PODS: - ReactCodegen - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core + - RNPingCore - SocketRocket - Yoga - - RNPingDeviceClient (1.0.0-beta.3): + - RNPingDeviceClient (1.0.0): - boost - DoubleConversion - fast_float @@ -2589,7 +2628,7 @@ PODS: - RNPingCore - SocketRocket - Yoga - - RNPingDeviceId (1.0.0-beta.3): + - RNPingDeviceId (1.0.0): - boost - DoubleConversion - fast_float @@ -2620,7 +2659,7 @@ PODS: - RNPingCore - SocketRocket - Yoga - - RNPingDeviceProfile (1.0.0-beta.3): + - RNPingDeviceProfile (1.0.0): - boost - DoubleConversion - fast_float @@ -2651,7 +2690,7 @@ PODS: - RNPingCore - SocketRocket - Yoga - - RNPingExternalIdp (1.0.0-beta.3): + - RNPingExternalIdp (1.0.0): - boost - DoubleConversion - fast_float @@ -2682,7 +2721,7 @@ PODS: - RNPingCore - SocketRocket - Yoga - - RNPingFido (1.0.0-beta.3): + - RNPingFido (1.0.0): - boost - DoubleConversion - fast_float @@ -2713,7 +2752,7 @@ PODS: - RNPingCore - SocketRocket - Yoga - - RNPingJourney (1.0.0-beta.3): + - RNPingJourney (1.0.0): - boost - DoubleConversion - fast_float @@ -2751,7 +2790,7 @@ PODS: - RNPingCore - SocketRocket - Yoga - - RNPingLogger (1.0.0-beta.3): + - RNPingLogger (1.0.0): - boost - DoubleConversion - fast_float @@ -2782,7 +2821,7 @@ PODS: - RNPingCore - SocketRocket - Yoga - - RNPingOath (1.0.0-beta.3): + - RNPingOath (1.0.0): - boost - DoubleConversion - fast_float @@ -2813,7 +2852,7 @@ PODS: - RNPingCore - SocketRocket - Yoga - - RNPingOidc (1.0.0-beta.3): + - RNPingOidc (1.0.0): - boost - DoubleConversion - fast_float @@ -2848,7 +2887,7 @@ PODS: - RNPingCore - SocketRocket - Yoga - - RNPingPush (1.0.0-beta.3): + - RNPingPush (1.0.0): - boost - DoubleConversion - fast_float @@ -2881,7 +2920,7 @@ PODS: - RNPingCore - SocketRocket - Yoga - - RNPingStorage (1.0.0-beta.3): + - RNPingStorage (1.0.0): - boost - DoubleConversion - fast_float @@ -3213,6 +3252,7 @@ DEPENDENCIES: - "RNPingBinding (from `../../node_modules/@ping-identity/rn-binding`)" - "RNPingBrowser (from `../../node_modules/@ping-identity/rn-browser`)" - RNPingCore (from `../../packages/core`) + - "RNPingDavinci (from `../../node_modules/@ping-identity/rn-davinci`)" - "RNPingDeviceClient (from `../../node_modules/@ping-identity/rn-device-client`)" - "RNPingDeviceId (from `../../node_modules/@ping-identity/rn-device-id`)" - "RNPingDeviceProfile (from `../../node_modules/@ping-identity/rn-device-profile`)" @@ -3255,6 +3295,7 @@ SPEC REPOS: - PingBinding - PingBrowser - PingCommons + - PingDavinci - PingDavinciPlugin - PingDeviceClient - PingDeviceId @@ -3437,6 +3478,8 @@ EXTERNAL SOURCES: :path: "../../node_modules/@ping-identity/rn-browser" RNPingCore: :path: "../../packages/core" + RNPingDavinci: + :path: "../../node_modules/@ping-identity/rn-davinci" RNPingDeviceClient: :path: "../../node_modules/@ping-identity/rn-device-client" RNPingDeviceId: @@ -3503,6 +3546,7 @@ SPEC CHECKSUMS: PingBinding: 46036fcbcd4e3cb6a453f08822493ba451d4c8ab PingBrowser: d1526bdcc6197781d0d54fe7ac16ba09a55a1faa PingCommons: 49f2244bda1c41913fa37d9481e969702d658ade + PingDavinci: 63c1d6830ec1aaf98850ff0708b7b5fb65a55d84 PingDavinciPlugin: d0d4c5a0e4fdd36608307e755ba435d41470d76f PingDeviceClient: 4d2fa2ba2fb427cc4001c43ac3ed80bd994fb4f1 PingDeviceId: e773280c2bb679f4133c8ef855f189620b331ce3 @@ -3590,20 +3634,21 @@ SPEC CHECKSUMS: ReactCodegen: 1e9f3e8a3f56fa25fbf39ecd37b708a4838d9032 ReactCommon: 96684b90b235d6ae340d126141edd4563b7a446a RNCAsyncStorage: 767abb068db6ad28b5f59a129fbc9fab18b377e2 - RNPingBinding: 10541621edbb8b6885f069ee6316843498d6cc93 - RNPingBrowser: 885e16bcc7d628ea833becef1ce3ef8ec9320bfa - RNPingCore: fe4e99005afb728904b634a2b29bd9f7df0ed7a2 - RNPingDeviceClient: b62cdc274eff19c09fce49b7d7b7e1166a81a044 - RNPingDeviceId: f37f85e0807a5b0e284631c83e28e0dfd4a78712 - RNPingDeviceProfile: 90e4220f1ec456f8dfaeb9a4214214100302292c - RNPingExternalIdp: 7954d281fa3ea15681b5b1c6ce6f8813559e9b48 - RNPingFido: 350763404cdd05b9fe3dbf73eba926e041d4bf63 - RNPingJourney: f3823780322f741fceed6eb6c3c20013d85a44a2 - RNPingLogger: 3bb60c3bc67fe1078a4a4535f98129da4845eebf - RNPingOath: f67ef2fedb4ad9727349be6eac5da988196871eb - RNPingOidc: 889badee38ae3a2a592edb723d2047f3eea603c7 - RNPingPush: 1ac8fd7f91b9489ec088e502bb8a7b96fabacc80 - RNPingStorage: 61024de883275173ac09f95d985f1bb9bef62670 + 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 diff --git a/PingSampleApp/package.json b/PingSampleApp/package.json index 314069751..3279365cc 100644 --- a/PingSampleApp/package.json +++ b/PingSampleApp/package.json @@ -16,6 +16,7 @@ "dependencies": { "@ping-identity/rn-binding": "workspace:*", "@ping-identity/rn-browser": "workspace:*", + "@ping-identity/rn-davinci": "workspace:*", "@ping-identity/rn-device-client": "workspace:*", "@ping-identity/rn-device-id": "workspace:*", "@ping-identity/rn-device-profile": "workspace:*", diff --git a/PingSampleApp/src/clients.ts b/PingSampleApp/src/clients.ts index 6c91c9a81..610e599ef 100644 --- a/PingSampleApp/src/clients.ts +++ b/PingSampleApp/src/clients.ts @@ -13,6 +13,8 @@ import { type OidcWebClient, } from '@ping-identity/rn-oidc'; import type { JourneyClient } from '@ping-identity/rn-journey'; +import { createDaVinciClient } from '@ping-identity/rn-davinci'; +import type { DaVinciClient, DaVinciConfig } from '@ping-identity/rn-davinci'; import Config from 'react-native-config'; import { logger } from '@ping-identity/rn-logger'; @@ -281,10 +283,54 @@ export const loginClient = createJourneyClient({ }, }); +/** + * DaVinci storage handle dedicated to the sample DaVinci flow. + */ +const davinciOidcStorage = configureOidcStorage({ + android: { + fileName: 'rn-sample-davinci', + keyAlias: 'rn.sample.davinci', + strongBoxPreferred: true, + cacheStrategy: CacheStrategy.CACHE_ON_FAILURE, + }, + ios: { + account: 'com.pingidentity.rnsampleapp.davinci', + encryptor: true, + cacheable: true, + }, +}); + +/** + * DaVinci client configuration used to drive the sample DaVinci screen. + * + * @remarks + * Reuses the PingOne OIDC environment variables since DaVinci flows are + * configured by the PingOne tenant's discovery endpoint. + */ +export const sampleDaVinciConfig: DaVinciConfig = { + logger: appLogger, + modules: { + oidc: { + clientId: Config.PINGONE_CLIENT_ID!, + discoveryEndpoint: Config.PINGONE_DISCOVERY_ENDPOINT!, + redirectUri: Config.PINGONE_REDIRECT_URI!, + scopes: pingOneScopes, + acrValues: Config.PINGONE_ACR_VALUES || undefined, + storage: davinciOidcStorage, + }, + }, +}; + +/** + * DaVinci client used by the sample app DaVinci screen. + */ +export const sampleDaVinciClient: DaVinciClient = + createDaVinciClient(sampleDaVinciConfig); + /** * Supported sample app configuration groups. */ -export type SampleConfigGroup = 'Journey' | 'OIDC (Web)'; +export type SampleConfigGroup = 'Journey' | 'OIDC (Web)' | 'DaVinci'; /** * Runtime-selectable sample app client profile. diff --git a/PingSampleApp/src/styles/davinciStyles.ts b/PingSampleApp/src/styles/davinciStyles.ts new file mode 100644 index 000000000..ce30e1d22 --- /dev/null +++ b/PingSampleApp/src/styles/davinciStyles.ts @@ -0,0 +1,345 @@ +/* + * 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 { StyleSheet } from 'react-native'; +import { colors } from './colors'; + +/** + * Style tokens for the DaVinci sample screen and its organisms. + */ +export const davinciScreenStyles = StyleSheet.create({ + screen: { + flex: 1, + backgroundColor: colors.background, + }, + content: { + padding: 16, + paddingBottom: 40, + }, + panel: { + width: '100%', + }, + loadingOverlay: { + ...StyleSheet.absoluteFillObject, + backgroundColor: 'rgba(0, 0, 0, 0.4)', + alignItems: 'center', + justifyContent: 'center', + zIndex: 10, + }, + emptyText: { + color: colors.gray, + fontSize: 13, + lineHeight: 18, + marginTop: 6, + }, + formSpacer: { + height: 8, + }, + blockingNote: { + color: colors.error, + fontSize: 13, + lineHeight: 18, + marginTop: 6, + }, + successCard: { + paddingVertical: 8, + }, + successTitle: { + color: colors.success, + fontSize: 18, + fontWeight: '700', + marginBottom: 6, + }, + successText: { + color: colors.textDark, + fontSize: 14, + lineHeight: 20, + marginBottom: 12, + }, + errorCard: { + borderWidth: 1, + borderColor: colors.warningBorder, + borderRadius: 10, + backgroundColor: colors.warningBackgroundCard, + padding: 12, + marginBottom: 12, + }, + errorCardTitle: { + color: colors.warningText, + fontWeight: '700', + fontSize: 14, + marginBottom: 4, + }, + errorCardMessage: { + color: colors.warningText, + fontSize: 13, + lineHeight: 18, + }, + disabledButton: { + opacity: 0.55, + }, +}); + +/** + * Style tokens for individual DaVinci collector field renderers. + */ +export const davinciFieldStyles = StyleSheet.create({ + card: { + marginBottom: 12, + }, + fieldLabel: { + color: colors.textDark, + fontSize: 14, + fontWeight: '600', + marginBottom: 6, + }, + required: { + color: colors.primary, + }, + helperText: { + color: colors.gray, + fontSize: 12, + marginTop: 4, + }, + errorText: { + color: colors.error, + fontSize: 12, + marginTop: 4, + }, + labelContent: { + color: colors.textDark, + fontSize: 14, + lineHeight: 20, + }, + optionRow: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 10, + paddingHorizontal: 12, + borderWidth: 1, + borderColor: colors.journeyChoiceBorder, + borderRadius: 8, + marginBottom: 8, + }, + optionRowSelected: { + borderColor: colors.primary, + backgroundColor: colors.selectedOptionBackground, + }, + optionRowText: { + color: colors.textDark, + fontSize: 14, + flex: 1, + }, + optionRowTextSelected: { + color: colors.primary, + fontWeight: '600', + }, + optionRadio: { + width: 18, + height: 18, + borderRadius: 9, + borderWidth: 2, + borderColor: colors.journeyChoiceBorder, + marginRight: 10, + alignItems: 'center', + justifyContent: 'center', + }, + optionRadioSelected: { + borderColor: colors.primary, + }, + optionRadioInner: { + width: 10, + height: 10, + borderRadius: 5, + backgroundColor: colors.primary, + }, + optionCheckbox: { + width: 18, + height: 18, + borderRadius: 4, + borderWidth: 2, + borderColor: colors.journeyChoiceBorder, + marginRight: 10, + alignItems: 'center', + justifyContent: 'center', + }, + optionCheckboxSelected: { + borderColor: colors.primary, + backgroundColor: colors.primary, + }, + phoneRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + phoneCountry: { + width: 90, + }, + phoneNumber: { + flex: 1, + }, + deviceCard: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 12, + paddingHorizontal: 12, + borderWidth: 1, + borderColor: colors.border, + borderRadius: 10, + marginBottom: 8, + backgroundColor: colors.surface, + }, + deviceCardSelected: { + borderColor: colors.primary, + backgroundColor: colors.selectedOptionBackground, + }, + deviceIcon: { + width: 36, + height: 36, + borderRadius: 8, + backgroundColor: colors.background, + alignItems: 'center', + justifyContent: 'center', + marginRight: 12, + }, + deviceInfo: { + flex: 1, + }, + deviceTitle: { + color: colors.textDark, + fontSize: 14, + fontWeight: '600', + }, + deviceDescription: { + color: colors.gray, + fontSize: 12, + marginTop: 2, + }, + passwordRequirementsCard: { + backgroundColor: colors.messageBlockBackground, + borderColor: colors.messageBlockBorder, + borderWidth: 1, + borderRadius: 8, + padding: 10, + marginTop: 8, + marginBottom: 8, + }, + passwordRequirementsTitle: { + color: colors.messageBlockTitle, + fontSize: 13, + fontWeight: '700', + marginBottom: 6, + }, + passwordRequirementsRow: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 2, + }, + passwordRequirementsIcon: { + marginRight: 6, + }, + passwordRequirementsText: { + color: colors.messageBlockText, + fontSize: 12, + }, + pickerTrigger: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + borderWidth: 1.5, + borderColor: colors.inputInactiveBorder, + borderRadius: 8, + paddingHorizontal: 14, + paddingVertical: 14, + minHeight: 56, + backgroundColor: colors.surface, + }, + pickerTriggerText: { + flex: 1, + color: colors.inputText, + fontSize: 16, + }, + pickerTriggerPlaceholder: { + flex: 1, + color: colors.inputPlaceholder, + fontSize: 16, + }, + pickerChevron: { + marginLeft: 8, + }, + pickerOverlay: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.4)', + justifyContent: 'flex-end', + }, + pickerSheet: { + backgroundColor: colors.surface, + borderTopLeftRadius: 16, + borderTopRightRadius: 16, + paddingTop: 8, + paddingBottom: 24, + maxHeight: '60%', + }, + pickerSheetHandle: { + width: 36, + height: 4, + borderRadius: 2, + backgroundColor: colors.border, + alignSelf: 'center', + marginBottom: 12, + }, + pickerSheetTitle: { + color: colors.textDark, + fontSize: 16, + fontWeight: '700', + paddingHorizontal: 16, + paddingBottom: 8, + borderBottomWidth: 1, + borderBottomColor: colors.border, + }, + pickerOption: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 16, + paddingVertical: 14, + }, + pickerOptionSelected: { + backgroundColor: colors.selectedOptionBackground, + }, + pickerOptionText: { + color: colors.textDark, + fontSize: 15, + flex: 1, + }, + pickerOptionTextSelected: { + color: colors.primary, + fontWeight: '600', + }, + unsupportedCard: { + borderWidth: 1, + borderColor: colors.warningBorder, + borderRadius: 8, + backgroundColor: colors.warningBackgroundSoft, + padding: 10, + marginBottom: 10, + }, + unsupportedText: { + color: colors.warningText, + fontSize: 13, + }, + flowLink: { + paddingVertical: 8, + alignItems: 'center', + }, + flowLinkText: { + color: colors.primary, + fontSize: 14, + fontWeight: '600', + textDecorationLine: 'underline', + }, +}); diff --git a/PingSampleApp/ui/DaVinciScreen.tsx b/PingSampleApp/ui/DaVinciScreen.tsx new file mode 100644 index 000000000..37b595742 --- /dev/null +++ b/PingSampleApp/ui/DaVinciScreen.tsx @@ -0,0 +1,40 @@ +/* + * 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, { useCallback } from 'react'; +import { ScrollView } from 'react-native'; +import type { NativeStackScreenProps } from '@react-navigation/native-stack'; +import { davinciScreenStyles } from '../src/styles/davinciStyles'; +import type { RootStackParamList } from '../App'; +import DaVinciClientPanel from './davinci/components/organisms/DaVinciClientPanel'; + +type Props = NativeStackScreenProps; + +/** + * Renders the sample app DaVinci flow. The DaVinciProvider is mounted at the + * app root so this screen shares the same native instance as TokenScreen and + * UserProfileScreen. + * + * @param props Native stack screen props. + * @returns DaVinci screen element. + */ +export default function DaVinciScreen(props: Props): React.ReactElement { + const { navigation } = props; + const handleAuthenticated = useCallback((): void => { + navigation.navigate('Home'); + }, [navigation]); + + return ( + + + + ); +} diff --git a/PingSampleApp/ui/HomeScreen.tsx b/PingSampleApp/ui/HomeScreen.tsx index eed0465af..1f16623c8 100644 --- a/PingSampleApp/ui/HomeScreen.tsx +++ b/PingSampleApp/ui/HomeScreen.tsx @@ -63,7 +63,7 @@ export default function HomeScreen({ title: 'DaVinci Flow', subtitle: 'Test Davinci Authentication', icon: 'vpn-key', - comingSoon: true, + screen: 'DaVinci', }, { title: 'Journey Flow', diff --git a/PingSampleApp/ui/LogoutScreen.tsx b/PingSampleApp/ui/LogoutScreen.tsx index 1dea7c99d..ee96481d2 100644 --- a/PingSampleApp/ui/LogoutScreen.tsx +++ b/PingSampleApp/ui/LogoutScreen.tsx @@ -10,6 +10,7 @@ import { ScrollView, Text } from 'react-native'; import { useFocusEffect } from '@react-navigation/native'; import { useJourney } from '@ping-identity/rn-journey'; import { useOidc } from '@ping-identity/rn-oidc'; +import { useDaVinci } from '@ping-identity/rn-davinci'; import { formatError } from './utils/formatError'; import { commonStyles } from '../src/styles/common'; import AsyncActionButton from './components/molecules/AsyncActionButton'; @@ -25,13 +26,16 @@ export default function LogoutScreen(): React.ReactElement { const [busyAll, setBusyAll] = useState(false); const [busyJourney, setBusyJourney] = useState(false); const [busyOidc, setBusyOidc] = useState(false); + const [busyDaVinci, setBusyDaVinci] = useState(false); const [journeyActive, setJourneyActive] = useState(false); const [oidcActive, setOidcActive] = useState(false); + const [davinciActive, setDavinciActive] = useState(false); const [statusMessage, setStatusMessage] = useState(null); const [errorMessage, setErrorMessage] = useState(null); const [, journeyActions] = useJourney(); const [oidcState, oidcActions] = useOidc(); + const davinciActions = useDaVinci(); const refreshSessionState = useCallback(async (): Promise => { try { @@ -47,7 +51,14 @@ export default function LogoutScreen(): React.ReactElement { } catch { setOidcActive(false); } - }, [journeyActions, oidcActions]); + + try { + const davinciSession = await davinciActions.user(); + setDavinciActive(Boolean(davinciSession)); + } catch { + setDavinciActive(false); + } + }, [davinciActions, journeyActions, oidcActions]); useFocusEffect( useCallback(() => { @@ -74,6 +85,12 @@ export default function LogoutScreen(): React.ReactElement { errors.push(formatError(error)); } + try { + await davinciActions.logoutUser(); + } catch (error) { + errors.push(formatError(error)); + } + await refreshSessionState(); setBusyAll(false); @@ -81,7 +98,7 @@ export default function LogoutScreen(): React.ReactElement { setErrorMessage(errors.join('\n')); return; } - }, [journeyActions, oidcActions, refreshSessionState]); + }, [davinciActions, journeyActions, oidcActions, refreshSessionState]); const handleLogoutOidc = useCallback(async (): Promise => { setBusyOidc(true); @@ -117,8 +134,23 @@ export default function LogoutScreen(): React.ReactElement { } }, [journeyActions, refreshSessionState]); - const disabled = busyAll || busyJourney || busyOidc; - const hasAnySession = journeyActive || oidcActive; + const handleLogoutDaVinci = useCallback(async (): Promise => { + setBusyDaVinci(true); + setErrorMessage(null); + setStatusMessage(null); + try { + await davinciActions.logoutUser(); + setStatusMessage('DaVinci session logged out.'); + } catch (error) { + setErrorMessage(formatError(error)); + } finally { + setBusyDaVinci(false); + await refreshSessionState(); + } + }, [davinciActions, refreshSessionState]); + + const disabled = busyAll || busyJourney || busyOidc || busyDaVinci; + const hasAnySession = journeyActive || oidcActive || davinciActive; return ( @@ -179,6 +211,23 @@ export default function LogoutScreen(): React.ReactElement { ) : null} + {davinciActive ? ( + + + Logout from DaVinci authentication + + Status: Active + { + void handleLogoutDaVinci(); + }} + loading={busyDaVinci} + disabled={disabled} + /> + + ) : null} + {!hasAnySession ? ( { return 'No Journey token information is available'; case 'OIDC': return 'No OIDC token information is available'; + case 'DaVinci': + return 'No DaVinci token information is available'; } }; const JOURNEY_AUTH_REQUIRED_MESSAGE = 'No authenticated Journey token state found. Complete Journey login first, then tap AccessToken.'; +const DAVINCI_AUTH_REQUIRED_MESSAGE = + 'No authenticated DaVinci token state found. Complete DaVinci login first, then tap AccessToken.'; + /** * Renders token operations by auth source with tabbed navigation. * @@ -44,11 +52,13 @@ export default function TokenScreen(): React.ReactElement { >({ Journey: getEmptyMessage('Journey'), OIDC: getEmptyMessage('OIDC'), + DaVinci: getEmptyMessage('DaVinci'), }); const [loading, setLoading] = useState(false); const [, journeyActions] = useJourney(); const [, oidcActions] = useOidc(); + const davinciActions = useDaVinci(); const setActiveTabOutput = useCallback( (value: string): void => { @@ -80,7 +90,15 @@ export default function TokenScreen(): React.ReactElement { return; } - setActiveTabOutput(getEmptyMessage('OIDC')); + if (activeTab === 'DaVinci') { + const session = await davinciActions.user(); + if (!session) { + setActiveTabOutput(DAVINCI_AUTH_REQUIRED_MESSAGE); + return; + } + setActiveTabOutput(session.accessToken); + return; + } } catch (error) { if ( error instanceof PingError && @@ -94,7 +112,21 @@ export default function TokenScreen(): React.ReactElement { } finally { setLoading(false); } - }, [activeTab, journeyActions, oidcActions, setActiveTabOutput]); + }, [ + activeTab, + davinciActions, + journeyActions, + oidcActions, + setActiveTabOutput, + ]); + + useFocusEffect( + useCallback(() => { + if (activeTab === 'DaVinci') { + void handleAccessToken(); + } + }, [activeTab, handleAccessToken]), + ); const handleClear = useCallback((): void => { setActiveTabOutput(getEmptyMessage(activeTab)); @@ -123,7 +155,15 @@ export default function TokenScreen(): React.ReactElement { return; } - setActiveTabOutput(getEmptyMessage('OIDC')); + if (activeTab === 'DaVinci') { + const session = await davinciActions.refresh(); + if (!session) { + setActiveTabOutput(DAVINCI_AUTH_REQUIRED_MESSAGE); + return; + } + setActiveTabOutput(session.refreshToken ?? getEmptyMessage('DaVinci')); + return; + } } catch (error) { if ( error instanceof PingError && @@ -137,7 +177,13 @@ export default function TokenScreen(): React.ReactElement { } finally { setLoading(false); } - }, [activeTab, journeyActions, oidcActions, setActiveTabOutput]); + }, [ + activeTab, + davinciActions, + journeyActions, + oidcActions, + setActiveTabOutput, + ]); const handleRevoke = useCallback(async (): Promise => { setLoading(true); @@ -159,6 +205,23 @@ export default function TokenScreen(): React.ReactElement { return; } + if (activeTab === 'DaVinci') { + const revoked = await davinciActions.revoke(); + setActiveTabOutput( + JSON.stringify( + { + revoked, + note: revoked + ? 'DaVinci revoke completed. Re-authenticate to access tokens again.' + : 'No active DaVinci user to revoke.', + }, + null, + 2, + ), + ); + return; + } + await journeyActions.revoke(); const session = await journeyActions.user(); if (session) { @@ -180,7 +243,13 @@ export default function TokenScreen(): React.ReactElement { } finally { setLoading(false); } - }, [activeTab, journeyActions, oidcActions, setActiveTabOutput]); + }, [ + activeTab, + davinciActions, + journeyActions, + oidcActions, + setActiveTabOutput, + ]); return ( @@ -225,6 +294,21 @@ export default function TokenScreen(): React.ReactElement { }} onClear={handleClear} /> + ) : activeTab === 'DaVinci' ? ( + { + void handleAccessToken(); + }} + onRefresh={() => { + void handleRefresh(); + }} + onRevoke={() => { + void handleRevoke(); + }} + onClear={handleClear} + /> ) : null} diff --git a/PingSampleApp/ui/UserProfileScreen.tsx b/PingSampleApp/ui/UserProfileScreen.tsx index 96caec095..2d201371a 100644 --- a/PingSampleApp/ui/UserProfileScreen.tsx +++ b/PingSampleApp/ui/UserProfileScreen.tsx @@ -11,15 +11,17 @@ import { useFocusEffect } from '@react-navigation/native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { useJourney, type JourneyUserSession } from '@ping-identity/rn-journey'; import { useOidc } from '@ping-identity/rn-oidc'; +import { useDaVinci } from '@ping-identity/rn-davinci'; import { formatError } from './utils/formatError'; import { commonStyles } from '../src/styles/common'; import { RootStackParamList } from '../App'; import AuthSourceTabs from './components/molecules/AuthSourceTabs'; import UserProfileJourneyPanel from './userProfile/components/organisms/UserProfileJourneyPanel'; import UserProfileOidcPanel from './userProfile/components/organisms/UserProfileOidcPanel'; +import UserProfileDaVinciPanel from './userProfile/components/organisms/UserProfileDaVinciPanel'; -type UserProfileTab = 'Journey' | 'OIDC'; -const USER_PROFILE_TABS = ['Journey', 'OIDC'] as const; +type UserProfileTab = 'Journey' | 'OIDC' | 'DaVinci'; +const USER_PROFILE_TABS = ['Journey', 'OIDC', 'DaVinci'] as const; type UserProfileNavigationProp = NativeStackNavigationProp< RootStackParamList, @@ -49,9 +51,19 @@ export default function UserProfileScreen({ useState(false); const [showRawOidcUserInfo, setShowRawOidcUserInfo] = useState(false); + const [davinciUserInfo, setDavinciUserInfo] = useState | null>(null); + const [davinciHasSession, setDavinciHasSession] = useState(false); + const [davinciLoading, setDavinciLoading] = useState(false); + const [davinciError, setDavinciError] = useState(null); + const [showRawDavinciUserInfo, setShowRawDavinciUserInfo] = + useState(false); const [, journeyActions] = useJourney(); const [oidcState, oidcActions] = useOidc(); + const davinciActions = useDaVinci(); const refreshJourneySession = useCallback(async (): Promise => { setJourneyLoading(true); @@ -85,15 +97,45 @@ export default function UserProfileScreen({ } }, [oidcActions]); + const refreshDavinciSession = useCallback(async (): Promise => { + setDavinciLoading(true); + setDavinciError(null); + setShowRawDavinciUserInfo(false); + try { + const session = await davinciActions.user(); + if (!session) { + setDavinciHasSession(false); + setDavinciUserInfo(null); + return; + } + setDavinciHasSession(true); + const info = await davinciActions.userinfo(); + setDavinciUserInfo(info); + } catch (error) { + setDavinciError(formatError(error)); + setDavinciHasSession(false); + setDavinciUserInfo(null); + } finally { + setDavinciLoading(false); + } + }, [davinciActions]); + useFocusEffect( useCallback(() => { if (activeTab === 'Journey') { void refreshJourneySession(); } else if (activeTab === 'OIDC') { void refreshOidcSession(); + } else if (activeTab === 'DaVinci') { + void refreshDavinciSession(); } return undefined; - }, [activeTab, refreshJourneySession, refreshOidcSession]), + }, [ + activeTab, + refreshJourneySession, + refreshOidcSession, + refreshDavinciSession, + ]), ); /** @@ -110,6 +152,9 @@ export default function UserProfileScreen({ if (tab !== 'OIDC') { setShowRawOidcUserInfo(false); } + if (tab !== 'DaVinci') { + setShowRawDavinciUserInfo(false); + } }, []); return ( @@ -156,6 +201,20 @@ export default function UserProfileScreen({ }} /> ) : null} + + {activeTab === 'DaVinci' ? ( + + setShowRawDavinciUserInfo(value => !value) + } + onStartDaVinci={() => navigation.navigate('DaVinci')} + /> + ) : null} ); diff --git a/PingSampleApp/ui/davinci/README.md b/PingSampleApp/ui/davinci/README.md new file mode 100644 index 000000000..277c364aa --- /dev/null +++ b/PingSampleApp/ui/davinci/README.md @@ -0,0 +1,131 @@ + + +# DaVinci Sample UI Documentation + +This folder contains the helper-only DaVinci sample implementation used by the sample app. + +The sample is intentionally UI-owned: + +- SDK (`@ping-identity/rn-davinci`) provides DaVinci state/actions and helper APIs. +- Sample app renders collectors and controls flow progression. +- Session state is re-resolved on every screen focus so that external revocations are reflected immediately. +- Native SDK remains authoritative for collector validation and flow progression. + +## Folder Structure + +- `components/organisms/`: DaVinci flow composition panels. +- `components/molecules/`: collector field renderer components and shared renderer utilities. +- `components/atoms/`: low-level field primitives (labels, error lists, picker modal). +- `hooks/`: auto-start effect, session controller, and panel controller composition hooks. + +## Runtime Flow + +1. `DaVinciClientPanel` renders the appropriate state panel based on the active node type. +2. `useDaVinciClientPanelController` composes `useDaVinci`, `useDaVinciForm`, `useDaVinciSessionController`, and `useDaVinciAutoStartEffect` into a single flat panel contract. +3. `useDaVinciSessionController` checks for an active session on every screen focus before allowing auto-start. +4. `useDaVinciAutoStartEffect` fires `start()` once when no node and no active session are present. +5. When a `ContinueNode` is active, `DaVinciContinueNodePanel` renders the collector form. +6. `DaVinciFieldRenderer` routes each normalized collector to a dedicated molecule component. +7. User input is tracked by collector key. +8. `form.canSubmit` and `form.issues` come from `useDaVinciForm` submit planning. +9. Terminal states (`SuccessNode`, `ErrorNode`, `FailureNode`) and logout are rendered directly in `DaVinciClientPanel`. + +## Components + +### `components/organisms/DaVinciClientPanel.tsx` + +Top-level helper-driven DaVinci UI orchestrator. + +Responsibilities: + +- Composes focused DaVinci panel subcomponents. +- Uses `useDaVinciClientPanelController` directly. +- Owns only screen-level behavior not provided by package helpers + (auto-start gating, session card, loading overlay, and terminal-state rendering). + +### `components/organisms/DaVinciContinueNodePanel.tsx` + +Continue-node rendering section: + +- unsupported-field skip notices +- collector field rendering via `DaVinciFieldRenderer` +- fallback submit button when no interactive submit collector is present +- form validation warning + +### `components/molecules/DaVinciFieldRenderer.tsx` + +Routes each normalized DaVinci collector to the appropriate molecule component: + +- `DaVinciTextField` — `TEXT` +- `DaVinciPasswordField` — `PASSWORD`, `PASSWORD_VERIFY` +- `DaVinciSubmitButton` — `SUBMIT_BUTTON` +- `DaVinciFlowButton` — `ACTION`, `FLOW_BUTTON`, `FLOW_LINK` +- `DaVinciLabelField` — `LABEL` +- `DaVinciSingleSelectField` — `SINGLE_SELECT`, `DROPDOWN`, `RADIO` +- `DaVinciMultiSelectField` — `MULTI_SELECT`, `COMBOBOX`, `CHECKBOX` +- `DaVinciPhoneNumberField` — `PHONE_NUMBER` +- `DaVinciDeviceField` — `DEVICE_REGISTRATION`, `DEVICE_AUTHENTICATION` +- `DaVinciUnsupportedField` — all unrecognised collector types + +### `components/atoms/DaVinciErrorList.tsx` + +Renders a stack of inline validation error messages beneath a field. Maps `REQUIRED` error codes to a human-readable sentence; falls back to the SDK-provided message for all other codes. + +### `components/atoms/DaVinciFieldLabel.tsx` + +Renders a field label with an optional required indicator. + +### `components/atoms/PickerModal.tsx` + +Modal picker used by `DaVinciSingleSelectField` and `DaVinciMultiSelectField` on iOS and Android. + +## Supporting Hooks + +### `hooks/useDaVinciClientPanelController.ts` + +Composes DaVinci sample panel behavior into a single controller hook: + +- pairs `useDaVinci` (flow + session actions) with `useDaVinciForm` (headless form state) +- delegates session lifecycle to `useDaVinciSessionController` +- delegates auto-start timing to `useDaVinciAutoStartEffect` +- exposes a flat `UseDaVinciClientPanelControllerResult` contract consumed by `DaVinciClientPanel` + +### `hooks/useDaVinciSessionController.ts` + +Session/bootstrap state manager: + +- re-resolves the active session on every screen focus via `useFocusEffect` +- marks the session active immediately when the flow reaches `SuccessNode` +- exposes `hasActiveSession`, `setHasActiveSession`, and `isSessionCheckRunning` + +### `hooks/useDaVinciAutoStartEffect.ts` + +Auto-starts the DaVinci flow once when no node and no active session are present: + +- guards against duplicate starts with a `useRef` flag +- resets the flag if `start()` fails so the user can retry + +## Explicit Integration Paths + +### Device Registration and Authentication + +1. `DaVinciFieldRenderer` dispatches `DEVICE_REGISTRATION` and `DEVICE_AUTHENTICATION` collectors to `DaVinciDeviceField`. +2. `DaVinciDeviceField` renders each device option as a tappable card. +3. Selecting a card calls `onChange` with `{ type }` for registration or `{ type, id, description }` for authentication. +4. The value is included in the `next()` payload by `useDaVinciForm`. + +### Fallback Submit + +1. `DaVinciContinueNodePanel` checks whether the collector list contains at least one `SUBMIT_BUTTON`, `ACTION`, or `FLOW_BUTTON`. +2. If none are present, a fallback "Next" button is rendered to allow progression. + +## Security Notes + +- Collector values containing passwords or secrets are never logged by sample components. +- `DaVinciErrorList` surfaces only SDK-provided validation messages; no raw server payloads are rendered. +- Sample output is for local validation; do not log raw collector data in production. diff --git a/PingSampleApp/ui/davinci/components/atoms/DaVinciErrorList.tsx b/PingSampleApp/ui/davinci/components/atoms/DaVinciErrorList.tsx new file mode 100644 index 000000000..44f62d24f --- /dev/null +++ b/PingSampleApp/ui/davinci/components/atoms/DaVinciErrorList.tsx @@ -0,0 +1,61 @@ +/* + * 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, View } from 'react-native'; +import type { DaVinciFieldValidationError } from '@ping-identity/rn-davinci'; +import { davinciFieldStyles } from '../../../../src/styles/davinciStyles'; + +/** + * Props for {@link DaVinciErrorList}. + */ +export type DaVinciErrorListProps = { + /** + * Validation errors produced by the native SDK's `validate()` call. + */ + errors?: DaVinciFieldValidationError[]; +}; + +/** + * Maps a single validation error code to human readable text. + * + * @param error Validation error. + * @returns User-facing message. + */ +function describeError(error: DaVinciFieldValidationError): string { + if (error.code === 'REQUIRED') { + return 'This field is required.'; + } + return error.message; +} + +/** + * Renders a stack of inline validation error messages under a field. + * + * @param props Component props. + * @returns Validation error list element, or `null` when nothing to show. + */ +export default function DaVinciErrorList( + props: DaVinciErrorListProps, +): React.ReactElement | null { + const { errors } = props; + if (!errors || errors.length === 0) { + return null; + } + return ( + + {errors.map((error, index) => ( + + {describeError(error)} + + ))} + + ); +} diff --git a/PingSampleApp/ui/davinci/components/atoms/DaVinciFieldLabel.tsx b/PingSampleApp/ui/davinci/components/atoms/DaVinciFieldLabel.tsx new file mode 100644 index 000000000..67b158d41 --- /dev/null +++ b/PingSampleApp/ui/davinci/components/atoms/DaVinciFieldLabel.tsx @@ -0,0 +1,42 @@ +/* + * 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 } from 'react-native'; +import { davinciFieldStyles } from '../../../../src/styles/davinciStyles'; + +/** + * Props for {@link DaVinciFieldLabel}. + */ +export type DaVinciFieldLabelProps = { + /** + * Label text. + */ + label: string; + /** + * Whether the underlying collector is required. + */ + required?: boolean; +}; + +/** + * Renders a field label with an optional required marker. + * + * @param props Component props. + * @returns Field label element. + */ +export default function DaVinciFieldLabel( + props: DaVinciFieldLabelProps, +): React.ReactElement { + const { label, required } = props; + return ( + + {label} + {required ? * : null} + + ); +} diff --git a/PingSampleApp/ui/davinci/components/atoms/PickerModal.tsx b/PingSampleApp/ui/davinci/components/atoms/PickerModal.tsx new file mode 100644 index 000000000..d96ec1691 --- /dev/null +++ b/PingSampleApp/ui/davinci/components/atoms/PickerModal.tsx @@ -0,0 +1,151 @@ +/* + * 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 { + FlatList, + Modal, + Text, + TouchableOpacity, + TouchableWithoutFeedback, + View, +} from 'react-native'; +import MaterialIcon from 'react-native-vector-icons/MaterialIcons'; +import { colors } from '../../../../src/styles/colors'; +import { davinciFieldStyles } from '../../../../src/styles/davinciStyles'; + +export type PickerOption = { + label: string; + value: string; +}; + +type PickerModalProps = { + label: string; + options: PickerOption[]; + selectedValue: string; + placeholder?: string; + /** Override the trigger button text. Defaults to the selected option's label. */ + triggerLabel?: string; + visible: boolean; + onOpen: () => void; + onClose: () => void; + onSelect: (value: string) => void; +}; + +/** + * Reusable bottom-sheet picker that renders a flat list of options in a modal. + * + * @param props Picker props. + * @returns Trigger button and modal element. + */ +export default function PickerModal( + props: PickerModalProps, +): React.ReactElement { + const { + label, + options, + selectedValue, + placeholder = '', + triggerLabel, + visible, + onOpen, + onClose, + onSelect, + } = props; + + const selectedLabel = + triggerLabel ?? options.find(o => o.value === selectedValue)?.label ?? ''; + + const handleSelect = (value: string): void => { + onSelect(value); + onClose(); + }; + + return ( + <> + + + {selectedLabel || placeholder} + + + + + + + + + + + {label} + item.value} + renderItem={({ item }) => { + const isSelected = item.value === selectedValue; + return ( + handleSelect(item.value)} + style={[ + davinciFieldStyles.pickerOption, + isSelected + ? davinciFieldStyles.pickerOptionSelected + : null, + ]} + > + + {item.label} + + {isSelected ? ( + + ) : null} + + ); + }} + /> + + + + + + + ); +} diff --git a/PingSampleApp/ui/davinci/components/molecules/DaVinciDeviceField.tsx b/PingSampleApp/ui/davinci/components/molecules/DaVinciDeviceField.tsx new file mode 100644 index 000000000..6c2f82afe --- /dev/null +++ b/PingSampleApp/ui/davinci/components/molecules/DaVinciDeviceField.tsx @@ -0,0 +1,154 @@ +/* + * 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 MaterialIcon from 'react-native-vector-icons/MaterialIcons'; +import type { + DaVinciFormValue, + DeviceAuthenticationCollector, + DeviceOption, + DeviceRegistrationCollector, +} from '@ping-identity/rn-davinci'; +import DaVinciFieldLabel from '../atoms/DaVinciFieldLabel'; +import { colors } from '../../../../src/styles/colors'; +import { davinciFieldStyles } from '../../../../src/styles/davinciStyles'; +import type { DaVinciCollectorRendererProps } from './types'; + +type DeviceCollector = + | DeviceRegistrationCollector + | DeviceAuthenticationCollector; + +/** + * Resolves the active selected device key from the current form value. + * + * @param value Current form value. + * @returns Device type string when selected, otherwise `null`. + */ +function resolveSelectedDevice(value: DaVinciFormValue | undefined): { + type: string; + id?: string; +} | null { + if (typeof value === 'string') { + return { type: value }; + } + if ( + value && + typeof value === 'object' && + !Array.isArray(value) && + 'type' in value + ) { + const typed = value as { type: string; id?: string }; + return { type: typed.type, id: typed.id }; + } + return null; +} + +/** + * Builds the value submitted when a device is tapped. + * + * @param collectorType Collector discriminator. + * @param device Selected device option. + * @returns Value to submit through {@link onChange}. + */ +function buildDeviceValue( + collectorType: DeviceCollector['type'], + device: DeviceOption, +): DaVinciFormValue { + if (collectorType === 'DEVICE_REGISTRATION') { + return { type: device.type }; + } + return { + type: device.type, + id: device.id, + description: device.description, + }; +} + +/** + * Determines whether a device option matches the current selection. + * + * @param selection Active selection. + * @param device Device option to check. + * @returns True when the device matches the selection. + */ +function isDeviceSelected( + selection: { type: string; id?: string } | null, + device: DeviceOption, +): boolean { + if (!selection) { + return false; + } + if (selection.id && device.id) { + return selection.id === device.id && selection.type === device.type; + } + return selection.type === device.type; +} + +/** + * Renders a {@link DeviceRegistrationCollector} or {@link DeviceAuthenticationCollector} + * as a vertical card list. + * + * @param props Renderer props. + * @returns Device field element. + */ +export default function DaVinciDeviceField( + props: DaVinciCollectorRendererProps, +): React.ReactElement { + const { collector, value, onChange } = props; + const deviceCollector = collector as DeviceCollector; + const selection = resolveSelectedDevice(value); + + return ( + + + {deviceCollector.devices.map(device => { + const isSelected = isDeviceSelected(selection, device); + return ( + + onChange(buildDeviceValue(deviceCollector.type, device)) + } + style={[ + davinciFieldStyles.deviceCard, + isSelected ? davinciFieldStyles.deviceCardSelected : null, + ]} + > + + + + + {device.title} + {device.description ? ( + + {device.description} + + ) : null} + + {isSelected ? ( + + ) : null} + + ); + })} + + ); +} diff --git a/PingSampleApp/ui/davinci/components/molecules/DaVinciFieldRenderer.tsx b/PingSampleApp/ui/davinci/components/molecules/DaVinciFieldRenderer.tsx new file mode 100644 index 000000000..4f56c363c --- /dev/null +++ b/PingSampleApp/ui/davinci/components/molecules/DaVinciFieldRenderer.tsx @@ -0,0 +1,64 @@ +/* + * 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, { memo } from 'react'; +import DaVinciDeviceField from './DaVinciDeviceField'; +import DaVinciFlowButton from './DaVinciFlowButton'; +import DaVinciLabelField from './DaVinciLabelField'; +import DaVinciMultiSelectField from './DaVinciMultiSelectField'; +import DaVinciPasswordField from './DaVinciPasswordField'; +import DaVinciPhoneNumberField from './DaVinciPhoneNumberField'; +import DaVinciSingleSelectField from './DaVinciSingleSelectField'; +import DaVinciSubmitButton from './DaVinciSubmitButton'; +import DaVinciTextField from './DaVinciTextField'; +import DaVinciUnsupportedField from './DaVinciUnsupportedField'; +import type { DaVinciCollectorRendererProps } from './types'; + +/** + * Routes a normalised DaVinci collector to the appropriate molecule. + * + * @param props Renderer props. + * @returns Field element, or `null` when the collector should be hidden. + */ +function DaVinciFieldRenderer( + props: DaVinciCollectorRendererProps, +): React.ReactElement | null { + const { collector } = props; + + switch (collector.type) { + case 'TEXT': + return ; + case 'PASSWORD': + case 'PASSWORD_VERIFY': + return ; + case 'SUBMIT_BUTTON': + return ; + case 'ACTION': + case 'FLOW_BUTTON': + case 'FLOW_LINK': + return ; + case 'LABEL': + return ; + case 'SINGLE_SELECT': + case 'DROPDOWN': + case 'RADIO': + return ; + case 'MULTI_SELECT': + case 'COMBOBOX': + case 'CHECKBOX': + return ; + case 'PHONE_NUMBER': + return ; + case 'DEVICE_REGISTRATION': + case 'DEVICE_AUTHENTICATION': + return ; + default: + return ; + } +} + +export default memo(DaVinciFieldRenderer); diff --git a/PingSampleApp/ui/davinci/components/molecules/DaVinciFlowButton.tsx b/PingSampleApp/ui/davinci/components/molecules/DaVinciFlowButton.tsx new file mode 100644 index 000000000..bd78447a1 --- /dev/null +++ b/PingSampleApp/ui/davinci/components/molecules/DaVinciFlowButton.tsx @@ -0,0 +1,53 @@ +/* + * 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 type { FlowCollector } from '@ping-identity/rn-davinci'; +import AsyncActionButton from '../../../components/molecules/AsyncActionButton'; +import { davinciFieldStyles } from '../../../../src/styles/davinciStyles'; +import type { DaVinciCollectorRendererProps } from './types'; + +/** + * Renders a {@link FlowCollector} either as a secondary button or a link, + * depending on whether the type is `FLOW_LINK`. + * + * @param props Renderer props. + * @returns Flow button or link element. + */ +export default function DaVinciFlowButton( + props: DaVinciCollectorRendererProps, +): React.ReactElement { + const { collector, onFlowAction, loading } = props; + const flowCollector = collector as FlowCollector; + const label = flowCollector.label || 'Continue'; + const handlePress = (): void => onFlowAction(flowCollector.key); + + if (flowCollector.type === 'FLOW_LINK') { + return ( + + {label} + + ); + } + + return ( + + + + ); +} diff --git a/PingSampleApp/ui/davinci/components/molecules/DaVinciLabelField.tsx b/PingSampleApp/ui/davinci/components/molecules/DaVinciLabelField.tsx new file mode 100644 index 000000000..696b8278c --- /dev/null +++ b/PingSampleApp/ui/davinci/components/molecules/DaVinciLabelField.tsx @@ -0,0 +1,32 @@ +/* + * 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, View } from 'react-native'; +import type { LabelCollector } from '@ping-identity/rn-davinci'; +import { davinciFieldStyles } from '../../../../src/styles/davinciStyles'; +import type { DaVinciCollectorRendererProps } from './types'; + +/** + * Renders a {@link LabelCollector} as a read-only block of text. + * + * @param props Renderer props. + * @returns Label element. + */ +export default function DaVinciLabelField( + props: DaVinciCollectorRendererProps, +): React.ReactElement { + const { collector } = props; + const labelCollector = collector as LabelCollector; + return ( + + + {labelCollector.content} + + + ); +} diff --git a/PingSampleApp/ui/davinci/components/molecules/DaVinciMultiSelectField.tsx b/PingSampleApp/ui/davinci/components/molecules/DaVinciMultiSelectField.tsx new file mode 100644 index 000000000..e0d73a516 --- /dev/null +++ b/PingSampleApp/ui/davinci/components/molecules/DaVinciMultiSelectField.tsx @@ -0,0 +1,99 @@ +/* + * 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 MaterialIcon from 'react-native-vector-icons/MaterialIcons'; +import type { MultiSelectCollector } from '@ping-identity/rn-davinci'; +import DaVinciFieldLabel from '../atoms/DaVinciFieldLabel'; +import { colors } from '../../../../src/styles/colors'; +import { davinciFieldStyles } from '../../../../src/styles/davinciStyles'; +import type { DaVinciCollectorRendererProps } from './types'; + +/** + * Resolves the active selection set for a multi-select renderer. + * + * @param value Current form value. + * @param collector Underlying collector. + * @returns Selection set of currently selected option values. + */ +function resolveSelection( + value: unknown, + collector: MultiSelectCollector, +): Set { + if (Array.isArray(value)) { + return new Set(value as string[]); + } + return new Set(collector.value ?? []); +} + +/** + * Renders a {@link MultiSelectCollector} as a checkbox-style option list. + * + * @param props Renderer props. + * @returns Multi-select field element. + */ +export default function DaVinciMultiSelectField( + props: DaVinciCollectorRendererProps, +): React.ReactElement { + const { collector, value, onChange } = props; + const multiCollector = collector as MultiSelectCollector; + const selection = resolveSelection(value, multiCollector); + + const toggle = (optionValue: string): void => { + const next = new Set(selection); + if (next.has(optionValue)) { + next.delete(optionValue); + } else { + next.add(optionValue); + } + onChange(Array.from(next)); + }; + + return ( + + + {multiCollector.options.map(option => { + const isSelected = selection.has(option.value); + return ( + toggle(option.value)} + style={[ + davinciFieldStyles.optionRow, + isSelected ? davinciFieldStyles.optionRowSelected : null, + ]} + > + + {isSelected ? ( + + ) : null} + + + {option.label} + + + ); + })} + + ); +} diff --git a/PingSampleApp/ui/davinci/components/molecules/DaVinciPasswordField.tsx b/PingSampleApp/ui/davinci/components/molecules/DaVinciPasswordField.tsx new file mode 100644 index 000000000..619aa9e9a --- /dev/null +++ b/PingSampleApp/ui/davinci/components/molecules/DaVinciPasswordField.tsx @@ -0,0 +1,54 @@ +/* + * 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 { View } from 'react-native'; +import type { PasswordCollector } from '@ping-identity/rn-davinci'; +import PingTextInput from '../../../components/atoms/PingTextInput'; +import DaVinciErrorList from '../atoms/DaVinciErrorList'; +import DaVinciPasswordRequirements from './DaVinciPasswordRequirements'; +import { davinciFieldStyles } from '../../../../src/styles/davinciStyles'; +import type { DaVinciCollectorRendererProps } from './types'; + +/** + * Renders a {@link PasswordCollector} as a secure text input. + * + * @param props Renderer props. + * @returns Password field element with optional policy requirements. + */ +export default function DaVinciPasswordField( + props: DaVinciCollectorRendererProps, +): React.ReactElement { + const { collector, value, onChange } = props; + const passwordCollector = collector as PasswordCollector; + const stringValue = typeof value === 'string' ? value : ''; + + return ( + + + {passwordCollector.passwordPolicy ? ( + + ) : null} + + + ); +} diff --git a/PingSampleApp/ui/davinci/components/molecules/DaVinciPasswordRequirements.tsx b/PingSampleApp/ui/davinci/components/molecules/DaVinciPasswordRequirements.tsx new file mode 100644 index 000000000..c7db84710 --- /dev/null +++ b/PingSampleApp/ui/davinci/components/molecules/DaVinciPasswordRequirements.tsx @@ -0,0 +1,171 @@ +/* + * 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, View } from 'react-native'; +import MaterialIcon from 'react-native-vector-icons/MaterialIcons'; +import type { PasswordPolicy } from '@ping-identity/rn-davinci'; +import { colors } from '../../../../src/styles/colors'; +import { davinciFieldStyles } from '../../../../src/styles/davinciStyles'; + +/** + * Props for {@link DaVinciPasswordRequirements}. + */ +export type DaVinciPasswordRequirementsProps = { + /** + * Password policy resolved from the server payload. + */ + policy: PasswordPolicy; + /** + * Current password value used for live rule satisfaction checks. + */ + value: string; +}; + +type Requirement = { + label: string; + satisfied: boolean; +}; + +/** + * Counts how many characters in {@link value} are members of the literal + * character set {@link charSet}. + * + * The server sends `minCharacters` as a map whose keys are literal character + * sets (e.g. `"ABCDEFGHIJKLMNOPQRSTUVWXYZ"` for uppercase). Each character + * in the password that appears in the key string counts as one match. + * + * @param value Current password value. + * @param charSet Literal string of allowed characters for this class. + * @returns Number of matching characters in value. + */ +function countInCharSet(value: string, charSet: string): number { + const set = new Set(charSet); + let count = 0; + for (const ch of value) { + if (set.has(ch)) { + count++; + } + } + return count; +} + +/** + * Derives a human-readable label for a character-set key from the server. + * Falls back to the raw key string when the class is unrecognised. + * + * @param charSet Literal character set string used as the policy key. + * @returns Human-readable class name. + */ +function charSetLabel(charSet: string): string { + if (/^[A-Z]+$/.test(charSet)) return 'uppercase'; + if (/^[a-z]+$/.test(charSet)) return 'lowercase'; + if (/^[0-9]+$/.test(charSet)) return 'numeric'; + return 'special'; +} + +/** + * Builds the list of requirements derived from the policy and current value. + * + * @param policy Password policy. + * @param value Current password. + * @returns Array of requirement entries with satisfaction state. + */ +function buildRequirements( + policy: PasswordPolicy, + value: string, +): Requirement[] { + const requirements: Requirement[] = []; + + if (policy.length?.min) { + requirements.push({ + label: `At least ${policy.length.min} characters`, + satisfied: value.length >= policy.length.min, + }); + } + if (policy.length?.max && policy.length.max > 0) { + requirements.push({ + label: `At most ${policy.length.max} characters`, + satisfied: value.length <= policy.length.max, + }); + } + + Object.entries(policy.minCharacters ?? {}).forEach(([charSet, required]) => { + if (required <= 0) { + return; + } + const matches = countInCharSet(value, charSet); + const label = charSetLabel(charSet); + requirements.push({ + label: `At least ${required} ${label} character${required === 1 ? '' : 's'}`, + satisfied: matches >= required, + }); + }); + + if (policy.maxRepeatedCharacters > 0) { + const repeatedPattern = new RegExp( + `(.)\\1{${policy.maxRepeatedCharacters},}`, + ); + requirements.push({ + label: `No more than ${policy.maxRepeatedCharacters} repeated characters`, + satisfied: !repeatedPattern.test(value), + }); + } + + if (policy.minUniqueCharacters > 0) { + const uniqueCount = new Set(value.split('')).size; + requirements.push({ + label: `At least ${policy.minUniqueCharacters} unique characters`, + satisfied: uniqueCount >= policy.minUniqueCharacters, + }); + } + + return requirements; +} + +/** + * Renders a live password policy checklist beneath a password field. + * + * @param props Component props. + * @returns Password requirements card, or `null` when policy yields no rules. + */ +export default function DaVinciPasswordRequirements( + props: DaVinciPasswordRequirementsProps, +): React.ReactElement | null { + const { policy, value } = props; + const requirements = buildRequirements(policy, value); + + if (requirements.length === 0) { + return null; + } + + return ( + + + Password must meet: + + {requirements.map((requirement, index) => ( + + + + {requirement.label} + + + ))} + + ); +} diff --git a/PingSampleApp/ui/davinci/components/molecules/DaVinciPhoneNumberField.tsx b/PingSampleApp/ui/davinci/components/molecules/DaVinciPhoneNumberField.tsx new file mode 100644 index 000000000..6ca59ccc5 --- /dev/null +++ b/PingSampleApp/ui/davinci/components/molecules/DaVinciPhoneNumberField.tsx @@ -0,0 +1,178 @@ +/* + * 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, { useState } from 'react'; +import { View } from 'react-native'; +import type { PhoneNumberCollector } from '@ping-identity/rn-davinci'; +import PingTextInput from '../../../components/atoms/PingTextInput'; +import DaVinciFieldLabel from '../atoms/DaVinciFieldLabel'; +import PickerModal from '../atoms/PickerModal'; +import { davinciFieldStyles } from '../../../../src/styles/davinciStyles'; +import type { DaVinciCollectorRendererProps } from './types'; + +/** + * Country entry — mirrors the `Country` struct in the iOS PingExample sample. + * `countryCode` is the ISO 3166-1 alpha-2 code (e.g. "US"). + * `dialCode` is the numeric part of the ITU dial code without the leading "+" + * (e.g. "1" for the US). + * Add more countries as needed. + */ +type Country = { + countryCode: string; + name: string; + dialCode: string; +}; + +const COUNTRIES: Country[] = [ + { countryCode: 'US', name: 'United States', dialCode: '1' }, + { countryCode: 'CA', name: 'Canada', dialCode: '1' }, + { countryCode: 'GB', name: 'United Kingdom', dialCode: '44' }, + { countryCode: 'AU', name: 'Australia', dialCode: '61' }, + { countryCode: 'DE', name: 'Germany', dialCode: '49' }, + { countryCode: 'FR', name: 'France', dialCode: '33' }, + { countryCode: 'JP', name: 'Japan', dialCode: '81' }, + { countryCode: 'CN', name: 'China', dialCode: '86' }, + { countryCode: 'IN', name: 'India', dialCode: '91' }, + { countryCode: 'BR', name: 'Brazil', dialCode: '55' }, + { countryCode: 'RU', name: 'Russia', dialCode: '7' }, + { countryCode: 'IT', name: 'Italy', dialCode: '39' }, + { countryCode: 'KR', name: 'South Korea', dialCode: '82' }, + { countryCode: 'MX', name: 'Mexico', dialCode: '52' }, + { countryCode: 'ES', name: 'Spain', dialCode: '34' }, + { countryCode: 'ZA', name: 'South Africa', dialCode: '27' }, + { countryCode: 'HK', name: 'Hong Kong', dialCode: '852' }, +]; + +function dialCodeForCountryCode(isoCode: string): string | undefined { + return COUNTRIES.find(c => c.countryCode === isoCode)?.dialCode; +} + +function countryCodeForDialCode(dialCode: string): string | undefined { + return COUNTRIES.find(c => c.dialCode === dialCode)?.countryCode; +} + +/** + * Resolves the current ISO country code and phone number from form state. + * + * @remarks + * Mirrors the iOS sample app's resolution logic: if `field.countryCode` is set + * use it; otherwise fall back to `field.defaultCountryCode`. When the stored + * value looks like a dial code (starts with "+") it is reverse-mapped to an + * ISO code so both formats are handled gracefully. + */ +function resolveValue( + value: unknown, + collector: PhoneNumberCollector, +): { countryCode: string; phoneNumber: string } { + let rawCode = + value && + typeof value === 'object' && + 'countryCode' in value && + typeof (value as { countryCode: unknown }).countryCode === 'string' + ? (value as { countryCode: string }).countryCode + : collector.countryCode || collector.defaultCountryCode; + + // Normalise a dial-code value (e.g. "+1") to its ISO country code. + if (rawCode.startsWith('+')) { + rawCode = countryCodeForDialCode(rawCode.slice(1)) ?? rawCode; + } + + const phoneNumber = + value && + typeof value === 'object' && + 'phoneNumber' in value && + typeof (value as { phoneNumber: unknown }).phoneNumber === 'string' + ? (value as { phoneNumber: string }).phoneNumber + : (collector.phoneNumber ?? ''); + + return { countryCode: rawCode, phoneNumber }; +} + +/** + * Returns the label shown in the trigger button: "+{dialCode}" when the ISO + * code is known, otherwise the raw `countryCode` value, otherwise a prompt. + */ +function triggerLabel(isoCode: string): string { + const dial = dialCodeForCountryCode(isoCode); + if (dial) { + return `+${dial}`; + } + if (isoCode) { + return isoCode; + } + return ''; +} + +/** + * Renders a {@link PhoneNumberCollector} as a country code picker + phone + * number input row. + * + * @remarks + * The country code picker stores the ISO 3166-1 alpha-2 country code in + * `countryCode` (e.g. "US"), matching the iOS SDK sample app. The trigger + * button displays the derived dial code (e.g. "+1"). The lookup table mirrors + * the `listOfCountries` array in the iOS `PhoneNumberView.swift` sample. + * + * @param props Renderer props. + * @returns Phone number field element. + */ +export default function DaVinciPhoneNumberField( + props: DaVinciCollectorRendererProps, +): React.ReactElement { + const { collector, value, onChange } = props; + const phoneCollector = collector as PhoneNumberCollector; + const current = resolveValue(value, phoneCollector); + + const [modalVisible, setModalVisible] = useState(false); + + const pickerOptions = COUNTRIES.map(c => ({ + value: c.countryCode, + label: `+${c.dialCode} (${c.name})`, + })); + + return ( + + + + + setModalVisible(true)} + onClose={() => setModalVisible(false)} + onSelect={isoCode => + onChange({ + countryCode: isoCode, + phoneNumber: current.phoneNumber, + }) + } + /> + + + + onChange({ + countryCode: current.countryCode, + phoneNumber: text, + }) + } + keyboardType="phone-pad" + containerStyle={{ marginBottom: 0 }} + /> + + + + ); +} diff --git a/PingSampleApp/ui/davinci/components/molecules/DaVinciSingleSelectField.tsx b/PingSampleApp/ui/davinci/components/molecules/DaVinciSingleSelectField.tsx new file mode 100644 index 000000000..7464062b8 --- /dev/null +++ b/PingSampleApp/ui/davinci/components/molecules/DaVinciSingleSelectField.tsx @@ -0,0 +1,101 @@ +/* + * 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, { useState } from 'react'; +import { Text, TouchableOpacity, View } from 'react-native'; +import type { SingleSelectCollector } from '@ping-identity/rn-davinci'; +import DaVinciFieldLabel from '../atoms/DaVinciFieldLabel'; +import DaVinciErrorList from '../atoms/DaVinciErrorList'; +import PickerModal from '../atoms/PickerModal'; +import { davinciFieldStyles } from '../../../../src/styles/davinciStyles'; +import type { DaVinciCollectorRendererProps } from './types'; + +/** + * Renders a {@link SingleSelectCollector}. + * + * @remarks + * - `DROPDOWN` type: bottom-sheet modal picker + * - `RADIO` / `SINGLE_SELECT` types: vertical radio-button list + * + * @param props Renderer props. + * @returns Single-select field element. + */ +export default function DaVinciSingleSelectField( + props: DaVinciCollectorRendererProps, +): React.ReactElement { + const { collector, value, onChange } = props; + const selectCollector = collector as SingleSelectCollector; + const selectedValue = + typeof value === 'string' ? value : selectCollector.value; + + const [modalVisible, setModalVisible] = useState(false); + + if (selectCollector.type === 'DROPDOWN') { + return ( + + + setModalVisible(true)} + onClose={() => setModalVisible(false)} + onSelect={next => onChange(next)} + /> + + + ); + } + + return ( + + + {selectCollector.options.map(option => { + const isSelected = option.value === selectedValue; + return ( + onChange(option.value)} + style={[ + davinciFieldStyles.optionRow, + isSelected ? davinciFieldStyles.optionRowSelected : null, + ]} + > + + {isSelected ? ( + + ) : null} + + + {option.label} + + + ); + })} + + + ); +} diff --git a/PingSampleApp/ui/davinci/components/molecules/DaVinciSubmitButton.tsx b/PingSampleApp/ui/davinci/components/molecules/DaVinciSubmitButton.tsx new file mode 100644 index 000000000..65ce5807b --- /dev/null +++ b/PingSampleApp/ui/davinci/components/molecules/DaVinciSubmitButton.tsx @@ -0,0 +1,37 @@ +/* + * 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 { View } from 'react-native'; +import type { SubmitCollector } from '@ping-identity/rn-davinci'; +import AsyncActionButton from '../../../components/molecules/AsyncActionButton'; +import { davinciFieldStyles } from '../../../../src/styles/davinciStyles'; +import type { DaVinciCollectorRendererProps } from './types'; + +/** + * Renders a {@link SubmitCollector} as a primary submit button. + * + * @param props Renderer props. + * @returns Submit button element. + */ +export default function DaVinciSubmitButton( + props: DaVinciCollectorRendererProps, +): React.ReactElement { + const { collector, onSubmit, loading, canSubmit } = props; + const submitCollector = collector as SubmitCollector; + return ( + + + + ); +} diff --git a/PingSampleApp/ui/davinci/components/molecules/DaVinciTextField.tsx b/PingSampleApp/ui/davinci/components/molecules/DaVinciTextField.tsx new file mode 100644 index 000000000..a6cff7666 --- /dev/null +++ b/PingSampleApp/ui/davinci/components/molecules/DaVinciTextField.tsx @@ -0,0 +1,45 @@ +/* + * 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 { View } from 'react-native'; +import type { TextCollector } from '@ping-identity/rn-davinci'; +import PingTextInput from '../../../components/atoms/PingTextInput'; +import DaVinciErrorList from '../atoms/DaVinciErrorList'; +import { davinciFieldStyles } from '../../../../src/styles/davinciStyles'; +import type { DaVinciCollectorRendererProps } from './types'; + +/** + * Renders a {@link TextCollector} as a single-line text input. + * + * @param props Renderer props. + * @returns Text field element. + */ +export default function DaVinciTextField( + props: DaVinciCollectorRendererProps, +): React.ReactElement { + const { collector, value, onChange } = props; + const textCollector = collector as TextCollector; + const stringValue = typeof value === 'string' ? value : textCollector.value; + + return ( + + + + + ); +} diff --git a/PingSampleApp/ui/davinci/components/molecules/DaVinciUnsupportedField.tsx b/PingSampleApp/ui/davinci/components/molecules/DaVinciUnsupportedField.tsx new file mode 100644 index 000000000..bd9469ff5 --- /dev/null +++ b/PingSampleApp/ui/davinci/components/molecules/DaVinciUnsupportedField.tsx @@ -0,0 +1,31 @@ +/* + * 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, View } from 'react-native'; +import { davinciFieldStyles } from '../../../../src/styles/davinciStyles'; +import type { DaVinciCollectorRendererProps } from './types'; + +/** + * Renders a fallback panel for collector types not yet supported by the + * sample UI (e.g. plugin-driven `integration_required` collectors). + * + * @param props Renderer props. + * @returns Unsupported field element. + */ +export default function DaVinciUnsupportedField( + props: DaVinciCollectorRendererProps, +): React.ReactElement { + const { collector } = props; + return ( + + + {`Unsupported collector "${collector.type}" (key: ${collector.key}).`} + + + ); +} diff --git a/PingSampleApp/ui/davinci/components/molecules/types.ts b/PingSampleApp/ui/davinci/components/molecules/types.ts new file mode 100644 index 000000000..725fc5726 --- /dev/null +++ b/PingSampleApp/ui/davinci/components/molecules/types.ts @@ -0,0 +1,49 @@ +/* + * 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 type { + DaVinciFormValue, + DaVinciNormalizedCollector, +} from '@ping-identity/rn-davinci'; + +/** + * Shared props for every DaVinci collector renderer. + */ +export type DaVinciCollectorRendererProps = { + /** + * Normalised DaVinci collector to render. + */ + collector: DaVinciNormalizedCollector; + /** + * Current form value for this collector, when present. + */ + value: DaVinciFormValue | undefined; + /** + * Updates the form value for this collector. + * + * @param value Next collector value. + */ + onChange: (value: DaVinciFormValue) => void; + /** + * Submits the current form. Used by submit buttons and flow links. + */ + onSubmit: () => void; + /** + * Immediately advances the flow using a specific {@link FlowCollector} key. + * + * @param flowKey Flow collector key to submit. + */ + onFlowAction: (flowKey: string) => void; + /** + * Whether the form is currently in a submission state. + */ + loading: boolean; + /** + * Whether the form can currently be submitted. + */ + canSubmit: boolean; +}; diff --git a/PingSampleApp/ui/davinci/components/organisms/DaVinciClientPanel.tsx b/PingSampleApp/ui/davinci/components/organisms/DaVinciClientPanel.tsx new file mode 100644 index 000000000..262007302 --- /dev/null +++ b/PingSampleApp/ui/davinci/components/organisms/DaVinciClientPanel.tsx @@ -0,0 +1,130 @@ +/* + * 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 { ActivityIndicator, Text, View } from 'react-native'; +import { commonStyles } from '../../../../src/styles/common'; +import { colors } from '../../../../src/styles/colors'; +import { davinciScreenStyles } from '../../../../src/styles/davinciStyles'; +import AsyncActionButton from '../../../components/molecules/AsyncActionButton'; +import EmptyStateCard from '../../../components/molecules/EmptyStateCard'; +import { useDaVinciClientPanelController } from '../../hooks/useDaVinciClientPanelController'; +import DaVinciContinueNodePanel from './DaVinciContinueNodePanel'; + +/** + * Props for {@link DaVinciClientPanel}. + */ +export type DaVinciClientPanelProps = { + /** + * Optional callback fired after the user dismisses a `SuccessNode`. + * + * @remarks + * Use this to navigate away from the DaVinci screen on success. + */ + onAuthenticated?: () => void; +}; + +/** + * Top-level DaVinci sample organism — orchestrates loading, success, error, + * and continue states for a single DaVinci client. + * + * @remarks + * Must be rendered inside a `DaVinciProvider` so that + * {@link useDaVinciClientPanelController} can resolve a client. + * + * @param props Panel props. + * @returns DaVinci client panel element. + */ +export default function DaVinciClientPanel( + props: DaVinciClientPanelProps, +): React.ReactElement { + const { onAuthenticated } = props; + const { + node, + form, + loading, + error, + hasActiveSession, + isSessionCheckRunning, + onSubmit, + onFlowAction, + onStart, + onLogout, + } = useDaVinciClientPanelController({ onAuthenticated }); + + return ( + + + {node?.type === 'ContinueNode' ? ( + + ) : null} + + {node?.type === 'SuccessNode' || hasActiveSession ? ( + + Authenticated + + You have completed the DaVinci flow successfully. + + + + + ) : null} + + {node?.type === 'ErrorNode' ? ( + + + The server reported an error + + + {node.message || 'Please try again.'} + + + ) : null} + + {node?.type === 'FailureNode' ? ( + + ) : null} + + {!node && !loading && !hasActiveSession && !isSessionCheckRunning ? ( + + ) : null} + + {error ? ( + + {`[${error.code}] ${error.message}`} + + ) : null} + + + {loading ? ( + + + + ) : null} + + ); +} diff --git a/PingSampleApp/ui/davinci/components/organisms/DaVinciContinueNodePanel.tsx b/PingSampleApp/ui/davinci/components/organisms/DaVinciContinueNodePanel.tsx new file mode 100644 index 000000000..106eccad2 --- /dev/null +++ b/PingSampleApp/ui/davinci/components/organisms/DaVinciContinueNodePanel.tsx @@ -0,0 +1,148 @@ +/* + * 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, { useMemo } from 'react'; +import { Text, View } from 'react-native'; +import type { + ContinueNode, + DaVinciFormResult, + DaVinciNormalizedCollector, + UnsupportedDaVinciField, +} from '@ping-identity/rn-davinci'; +import { commonStyles } from '../../../../src/styles/common'; +import { davinciScreenStyles } from '../../../../src/styles/davinciStyles'; +import AsyncActionButton from '../../../components/molecules/AsyncActionButton'; +import DaVinciFieldRenderer from '../molecules/DaVinciFieldRenderer'; + +/** + * Props for {@link DaVinciContinueNodePanel}. + */ +export type DaVinciContinueNodePanelProps = { + /** + * Active continue node returned by the bridge. + */ + node: ContinueNode; + /** + * Headless form state produced by `useDaVinciForm` for the active node. + */ + form: DaVinciFormResult; + /** + * True while a DaVinci action is in flight. + */ + loading: boolean; + /** + * Submits the current form by calling `next` with the planned payload. + */ + onSubmit: () => void; + /** + * Submits a flow collector (`SUBMIT_BUTTON`, `ACTION`, `FLOW_BUTTON`, + * `FLOW_LINK`) by key. + * + * @param flowKey Flow collector key. + */ + onFlowAction: (flowKey: string) => void; +}; + +/** + * Determines whether a collector list already contains a control that submits. + * + * @param collectors Normalised collectors for the current node. + * @returns True when at least one submit or flow collector is present. + */ +function hasInteractiveSubmit( + collectors: DaVinciNormalizedCollector[], +): boolean { + return collectors.some( + collector => + collector.type === 'SUBMIT_BUTTON' || + collector.type === 'ACTION' || + collector.type === 'FLOW_BUTTON' || + collector.type === 'FLOW_LINK', + ); +} + +/** + * Renders an unsupported-fields warning when the bridge dropped one or more + * server-side fields. + * + * @param fields Fields the bridge could not instantiate. + * @returns Warning element, or `null` when nothing to show. + */ +function renderUnsupportedFieldsNotice( + fields: UnsupportedDaVinciField[] | undefined, +): React.ReactElement | null { + if (!fields || fields.length === 0) { + return null; + } + return ( + + + Unsupported fields skipped + + + {fields.map(field => `${field.key} (${field.type})`).join(', ')} + + + ); +} + +/** + * Renders an active DaVinci {@link ContinueNode} as a vertical form. + * + * @remarks + * Each collector is dispatched to its dedicated molecule via + * {@link DaVinciFieldRenderer}. A trailing fallback submit button is rendered + * only when the node lacks any other interactive submit control. + * + * @param props Component props. + * @returns Continue node form element. + */ +export default function DaVinciContinueNodePanel( + props: DaVinciContinueNodePanelProps, +): React.ReactElement { + const { node, form, loading, onSubmit, onFlowAction } = props; + const { fields, values, canSubmit, issues, setValue } = form; + + const showFallbackSubmit = useMemo( + () => !hasInteractiveSubmit(fields), + [fields], + ); + + return ( + + {renderUnsupportedFieldsNotice(node.unsupportedFields)} + + {fields.map(collector => ( + setValue(collector.key, next)} + onSubmit={onSubmit} + onFlowAction={onFlowAction} + loading={loading} + canSubmit={canSubmit} + /> + ))} + + {showFallbackSubmit ? ( + + ) : null} + + {issues.length > 0 && !canSubmit ? ( + + Please complete required fields before continuing. + + ) : null} + + ); +} diff --git a/PingSampleApp/ui/davinci/hooks/useDaVinciAutoStartEffect.ts b/PingSampleApp/ui/davinci/hooks/useDaVinciAutoStartEffect.ts new file mode 100644 index 000000000..9c93ffbaa --- /dev/null +++ b/PingSampleApp/ui/davinci/hooks/useDaVinciAutoStartEffect.ts @@ -0,0 +1,58 @@ +/* + * 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 { DaVinciNode } from '@ping-identity/rn-davinci'; + +/** + * Configuration contract for `useDaVinciAutoStartEffect`. + */ +export type UseDaVinciAutoStartEffectOptions = { + /** Current loading state. */ + loading: boolean; + /** Session bootstrap running state. */ + isSessionCheckRunning: boolean; + /** Current authenticated session state. */ + hasActiveSession: boolean; + /** Active DaVinci node. */ + node: DaVinciNode | null; + /** Start action returning a success indicator. */ + onStart: () => Promise; +}; + +/** + * Auto-starts the DaVinci flow once when no node and no active session are + * present, mirroring the Journey panel auto-start gating. + * + * @param options - Auto-start effect options. + */ +export function useDaVinciAutoStartEffect( + options: UseDaVinciAutoStartEffectOptions, +): void { + const { loading, isSessionCheckRunning, hasActiveSession, node, onStart } = + options; + const hasAutoStartedRef = useRef(false); + + useEffect(() => { + if (hasAutoStartedRef.current) return; + if (node) return; + if (loading || isSessionCheckRunning || hasActiveSession) return; + + hasAutoStartedRef.current = true; + void (async () => { + try { + const didStart = await onStart(); + if (!didStart) { + hasAutoStartedRef.current = false; + } + } catch (error) { + hasAutoStartedRef.current = false; + console.error('[useDaVinciAutoStartEffect] onStart failed:', error); + } + })(); + }, [hasActiveSession, isSessionCheckRunning, loading, node, onStart]); +} diff --git a/PingSampleApp/ui/davinci/hooks/useDaVinciClientPanelController.ts b/PingSampleApp/ui/davinci/hooks/useDaVinciClientPanelController.ts new file mode 100644 index 000000000..990afe273 --- /dev/null +++ b/PingSampleApp/ui/davinci/hooks/useDaVinciClientPanelController.ts @@ -0,0 +1,166 @@ +/* + * 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 { useCallback } from 'react'; +import { + useDaVinci, + useDaVinciForm, + type DaVinciError, + type DaVinciFormResult, + type DaVinciNode, +} from '@ping-identity/rn-davinci'; +import { useDaVinciSessionController } from './useDaVinciSessionController'; +import { useDaVinciAutoStartEffect } from './useDaVinciAutoStartEffect'; + +/** + * Result contract consumed by `DaVinciClientPanel`. + */ +export type UseDaVinciClientPanelControllerResult = { + /** Active DaVinci node. */ + node: DaVinciNode | null; + /** Headless form state for the active continue node. */ + form: DaVinciFormResult; + /** True while a DaVinci action is in flight. */ + loading: boolean; + /** Last hook-level DaVinci error. */ + error: DaVinciError | null; + /** True when a session is confirmed active for the current DaVinci client. */ + hasActiveSession: boolean; + /** True while the initial session bootstrap check is running. */ + isSessionCheckRunning: boolean; + /** + * Submits the current form by calling `next` with the planned payload. + */ + onSubmit: () => void; + /** + * Submits a flow collector (`SUBMIT_BUTTON`, `ACTION`, `FLOW_BUTTON`, + * `FLOW_LINK`) by key. + * + * @remarks + * Delegates to `form.setValue`, which auto-submits immediately for + * `FlowCollector` keys — see {@link useDaVinciForm}'s `setValue` remarks. + * + * @param flowKey - Flow collector key. + */ + onFlowAction: (flowKey: string) => void; + /** Restarts the DaVinci flow. */ + onStart: () => Promise; + /** Logs out the active user and clears local session state. */ + onLogout: () => Promise; +}; + +/** + * Options for the DaVinci client panel controller hook. + */ +export type UseDaVinciClientPanelControllerOptions = { + /** + * Optional callback fired after the user dismisses a `SuccessNode` or logs + * out from the panel. + */ + onAuthenticated?: () => void; +}; + +/** + * Composes DaVinci sample panel behavior into a single controller hook. + * + * @remarks + * - Checks for an active session on focus before auto-starting. + * - Auto-starts the flow only when no node and no active session are present. + * - Pairs `useDaVinci` (flow + session actions) with `useDaVinciForm` (form + * state for the active continue node) and exposes a flat panel contract. + * + * @param options - Optional controller options. + * @returns Panel state and actions consumed by `DaVinciClientPanel`. + */ +export function useDaVinciClientPanelController( + options: UseDaVinciClientPanelControllerOptions = {}, +): UseDaVinciClientPanelControllerResult { + const { onAuthenticated } = options; + const { node, loading, error, start, next, user, logoutUser } = useDaVinci(); + const form = useDaVinciForm(node); + + const { hasActiveSession, setHasActiveSession, isSessionCheckRunning } = + useDaVinciSessionController({ + user, + nodeType: node?.type, + }); + + const onStart = useCallback(async (): Promise => { + try { + await start(); + return true; + } catch { + // The hook captures and exposes the error via `error`. + return false; + } + }, [start]); + + useDaVinciAutoStartEffect({ + loading, + isSessionCheckRunning, + hasActiveSession, + node, + onStart, + }); + + const onSubmit = useCallback((): void => { + if (loading) { + return; + } + const plan = form.buildInput(); + if (!plan.canSubmit) { + return; + } + next(plan.input).catch(() => { + // `error` is already updated by the hook. + }); + }, [form, loading, next]); + + const onFlowAction = useCallback( + (flowKey: string): void => { + if (loading) { + return; + } + // setValue on a FlowCollector key auto-submits via next() immediately + // (see useDaVinciForm's setValue remarks) — exercises the same + // auto-submit path a field's onChange would trigger. + const pending = form.setValue(flowKey, flowKey); + pending?.catch(() => { + // `error` is already updated by the hook. + }); + }, + [form, loading], + ); + + const onStartAction = useCallback(async (): Promise => { + await onStart(); + }, [onStart]); + + const onLogout = useCallback(async (): Promise => { + try { + await logoutUser(); + } catch { + // The hook surfaces the error via `error`. + } finally { + setHasActiveSession(false); + onAuthenticated?.(); + } + }, [logoutUser, onAuthenticated, setHasActiveSession]); + + return { + node, + form, + loading, + error, + hasActiveSession, + isSessionCheckRunning, + onSubmit, + onFlowAction, + onStart: onStartAction, + onLogout, + }; +} diff --git a/PingSampleApp/ui/davinci/hooks/useDaVinciSessionController.ts b/PingSampleApp/ui/davinci/hooks/useDaVinciSessionController.ts new file mode 100644 index 000000000..67ea9388e --- /dev/null +++ b/PingSampleApp/ui/davinci/hooks/useDaVinciSessionController.ts @@ -0,0 +1,105 @@ +/* + * 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 { useCallback, useEffect, useRef, useState } from 'react'; +import { useFocusEffect } from '@react-navigation/native'; +import type { DaVinciUserSession } from '@ping-identity/rn-davinci'; + +/** + * Configuration contract for `useDaVinciSessionController`. + */ +export type UseDaVinciSessionControllerOptions = { + /** + * Resolver for the active DaVinci user session. + */ + user: () => Promise; + /** + * Active node type (e.g. `'SuccessNode'`). + * + * @remarks + * When the flow reaches `SuccessNode` the session is marked active without + * waiting for a fresh `user()` round-trip. + */ + nodeType?: 'ContinueNode' | 'SuccessNode' | 'ErrorNode' | 'FailureNode'; +}; + +/** + * Session/bootstrap state manager for the DaVinci sample screen. + * + * @remarks + * Re-resolves the session every time the screen gains focus so that a revoke + * or logout triggered from another screen (e.g. `TokenScreen`) is reflected on + * return instead of showing a stale authenticated card. + * + * @param options - Session controller options. + * @returns Session state plus a manual setter for downstream actions. + */ +export function useDaVinciSessionController( + options: UseDaVinciSessionControllerOptions, +): { + /** True when a session is confirmed active for the current DaVinci client. */ + hasActiveSession: boolean; + /** + * Manually update the active-session state (used by logout/revoke flows). + * + * @param value - Next active-session state. + */ + setHasActiveSession: (value: boolean) => void; + /** True while the initial session bootstrap check is in progress. */ + isSessionCheckRunning: boolean; +} { + const { user, nodeType } = options; + + const [hasActiveSession, setHasActiveSession] = useState(false); + const [isSessionCheckRunning, setIsSessionCheckRunning] = + useState(true); + + const userRef = useRef(user); + userRef.current = user; + + useFocusEffect( + useCallback(() => { + let cancelled = false; + + const checkSession = async (): Promise => { + setIsSessionCheckRunning(true); + try { + const session = await userRef.current(); + if (!cancelled) { + setHasActiveSession(Boolean(session)); + } + } catch { + if (!cancelled) { + setHasActiveSession(false); + } + } finally { + if (!cancelled) { + setIsSessionCheckRunning(false); + } + } + }; + + void checkSession(); + + return () => { + cancelled = true; + }; + }, []), + ); + + useEffect(() => { + if (nodeType === 'SuccessNode') { + setHasActiveSession(true); + } + }, [nodeType]); + + return { + hasActiveSession, + setHasActiveSession, + isSessionCheckRunning, + }; +} diff --git a/PingSampleApp/ui/token/components/organisms/TokenDaVinciPanel.tsx b/PingSampleApp/ui/token/components/organisms/TokenDaVinciPanel.tsx index ffbbe8e4e..eeaaa0441 100644 --- a/PingSampleApp/ui/token/components/organisms/TokenDaVinciPanel.tsx +++ b/PingSampleApp/ui/token/components/organisms/TokenDaVinciPanel.tsx @@ -6,6 +6,7 @@ */ import React from 'react'; +import TokenActionsCard from '../molecules/TokenActionsCard'; import TokenOutputCard from '../molecules/TokenOutputCard'; /** @@ -16,10 +17,30 @@ type TokenDaVinciPanelProps = { * Current token/session payload text. */ tokenOutput: string; + /** + * Whether token actions are currently running. + */ + loading: boolean; + /** + * Trigger access-token retrieval. + */ + onAccessToken: () => void; + /** + * Trigger token refresh. + */ + onRefresh: () => void; + /** + * Trigger token revoke. + */ + onRevoke: () => void; + /** + * Clear currently displayed output. + */ + onClear: () => void; }; /** - * Renders DaVinci token output. + * Renders DaVinci token output and actions. * * @param props - DaVinci token panel props. * @returns DaVinci token panel element. @@ -27,7 +48,19 @@ type TokenDaVinciPanelProps = { export default function TokenDaVinciPanel( props: TokenDaVinciPanelProps, ): React.ReactElement { - const { tokenOutput } = props; + const { tokenOutput, loading, onAccessToken, onRefresh, onRevoke, onClear } = + props; - return ; + return ( + <> + + + + ); } diff --git a/PingSampleApp/ui/userProfile/components/organisms/UserProfileDaVinciPanel.tsx b/PingSampleApp/ui/userProfile/components/organisms/UserProfileDaVinciPanel.tsx index 6efaa5bd4..8fba6c263 100644 --- a/PingSampleApp/ui/userProfile/components/organisms/UserProfileDaVinciPanel.tsx +++ b/PingSampleApp/ui/userProfile/components/organisms/UserProfileDaVinciPanel.tsx @@ -6,18 +6,94 @@ */ import React from 'react'; +import { ActivityIndicator, Text, View } from 'react-native'; +import { commonStyles } from '../../../../src/styles/common'; +import { colors } from '../../../../src/styles/colors'; import EmptyStateCard from '../../../components/molecules/EmptyStateCard'; +import UserProfileInfoCard from '../molecules/UserProfileInfoCard'; /** - * Renders the DaVinci user profile placeholder tab body. + * Props for the DaVinci user profile tab panel. + */ +type UserProfileDaVinciPanelProps = { + /** + * Whether DaVinci session state is loading. + */ + loading: boolean; + /** + * Current DaVinci userinfo payload. + */ + userInfo: Record | null; + /** + * Whether a DaVinci session exists. + */ + hasSession: boolean; + /** + * DaVinci tab error message. + */ + error: string | null; + /** + * Whether raw user info is shown. + */ + showRawUserInfo: boolean; + /** + * Toggle raw user info visibility. + */ + onToggleRawUserInfo: () => void; + /** + * Navigate to DaVinci login flow. + */ + onStartDaVinci: () => void; +}; + +/** + * Renders the DaVinci user profile tab body. * + * @param props - DaVinci profile panel props. * @returns DaVinci profile panel element. */ -export default function UserProfileDaVinciPanel(): React.ReactElement { +export default function UserProfileDaVinciPanel( + props: UserProfileDaVinciPanelProps, +): React.ReactElement { + const { + loading, + userInfo, + hasSession, + error, + showRawUserInfo, + onToggleRawUserInfo, + onStartDaVinci, + } = props; + + if (loading) { + return ( + + + + Checking DaVinci session... + + + ); + } + + if (!hasSession) { + return ( + + ); + } + return ( - ); } diff --git a/PingTestRunner/.env.example b/PingTestRunner/.env.example index 3f555bcd7..14380d0e5 100644 --- a/PingTestRunner/.env.example +++ b/PingTestRunner/.env.example @@ -20,3 +20,11 @@ PING_REDIRECT_URI=org.forgerock.demo://oauth2redirect # Test credentials (required for Tier 2 live tests) PING_TEST_USERNAME=your-test-username PING_TEST_PASSWORD=your-test-password + +# DaVinci-specific config (used by davinci.test.ts) +PINGONE_DISCOVERY_ENDPOINT=https://auth.pingone.com//as/.well-known/openid-configuration +PINGONE_CLIENT_ID=your-davinci-client-id +PINGONE_REDIRECT_URI=org.forgerock.demo://oauth2redirect +PINGONE_USERNAME=your-davinci-username +PINGONE_PASSWORD=your-davinci-password +PINGONE_ACR_VALUES= diff --git a/PingTestRunner/App.tsx b/PingTestRunner/App.tsx index e56718eb7..4f76b8148 100644 --- a/PingTestRunner/App.tsx +++ b/PingTestRunner/App.tsx @@ -44,6 +44,10 @@ const UseJourneyScenario = React.lazy( ); const UseOidcScenario = React.lazy(() => import('./scenarios/UseOidcScenario')); const EnvScenario = React.lazy(() => import('./scenarios/EnvScenario')); +const DaVinciScenario = React.lazy(() => import('./scenarios/DaVinciScenario')); +const UseDaVinciScenario = React.lazy( + () => import('./scenarios/UseDaVinciScenario'), +); interface LaunchArgs { PING_TEST_SCENARIO?: string; @@ -82,6 +86,10 @@ function ScenarioContent(): React.JSX.Element { return ; case 'env': return ; + case 'davinci': + return ; + case 'use-davinci': + return ; default: return ; } diff --git a/PingTestRunner/README.md b/PingTestRunner/README.md index 7dee0bdf2..3c2676103 100644 --- a/PingTestRunner/README.md +++ b/PingTestRunner/README.md @@ -17,29 +17,30 @@ This app is **not** a demo or sample app. It is a minimal, dependency-light host ## Overview -| Layer | Tool | Location | -|---|---|---| -| JS unit tests (per package) | Jest | `packages/*/src/__tests__/` | -| Integration tests | Jest (node env, native mocked) | `__tests__/integration/` | -| Android native unit tests | Kotlin/JUnit/Robolectric via Gradle | `packages/*/android/src/test/` | -| iOS native unit tests | Swift/XCTest via CocoaPods test_spec | `packages/*/ios/Tests/` | -| E2E tests | Detox | `e2e/` | -| Android build | Gradle + React Native | `android/` | -| iOS build | CocoaPods + React Native | `ios/` | +| Layer | Tool | Location | +| --------------------------- | ------------------------------------ | ------------------------------ | +| JS unit tests (per package) | Jest | `packages/*/src/__tests__/` | +| Integration tests | Jest (node env, native mocked) | `__tests__/integration/` | +| Android native unit tests | Kotlin/JUnit/Robolectric via Gradle | `packages/*/android/src/test/` | +| iOS native unit tests | Swift/XCTest via CocoaPods test_spec | `packages/*/ios/Tests/` | +| E2E tests | Detox | `e2e/` | +| Android build | Gradle + React Native | `android/` | +| iOS build | CocoaPods + React Native | `ios/` | ### Packages under test -| Package | Integration test file | -|---|---| -| `@ping-identity/rn-browser` | `browser.test.ts` | -| `@ping-identity/rn-core` | `core.test.ts` | -| `@ping-identity/rn-device-id` | `device-id.test.ts` | +| Package | Integration test file | +| ---------------------------------- | ------------------------ | +| `@ping-identity/rn-browser` | `browser.test.ts` | +| `@ping-identity/rn-core` | `core.test.ts` | +| `@ping-identity/rn-davinci` | `davinci.test.ts` | +| `@ping-identity/rn-device-id` | `device-id.test.ts` | | `@ping-identity/rn-device-profile` | `device-profile.test.ts` | -| `@ping-identity/rn-journey` | `journey.test.ts` | -| `@ping-identity/rn-logger` | `logger.test.ts` | -| `@ping-identity/rn-oidc` | `oidc.test.ts` | -| `@ping-identity/rn-storage` | `storage.test.ts` | -| `@ping-identity/rn-types` | `types.test.ts` | +| `@ping-identity/rn-journey` | `journey.test.ts` | +| `@ping-identity/rn-logger` | `logger.test.ts` | +| `@ping-identity/rn-oidc` | `oidc.test.ts` | +| `@ping-identity/rn-storage` | `storage.test.ts` | +| `@ping-identity/rn-types` | `types.test.ts` | --- @@ -175,17 +176,32 @@ E2E tests that exercise live authentication flows are **self-skipping** when env Use the same keys as `PingTestRunner/.env.example`: -| Variable | Description | Example | -|---|---|---| -| `PING_DISCOVERY_ENDPOINT` | OIDC discovery URL | `https://openam.example.com/openam/.well-known/openid-configuration` | -| `PING_CLIENT_ID` | OAuth2 client ID | `my-rn-client` | -| `PING_REDIRECT_URI` | Redirect URI (must be registered on server) | `org.forgerock.demo://oauth2redirect` | -| `PING_SERVER_URL` | PingAM base URL | `https://openam.example.com/openam` | -| `PING_REALM_PATH` | Realm path | `/alpha` | -| `PING_COOKIE_NAME` | Session cookie key used by Journey config | `iPlanetDirectoryPro` | -| `PING_JOURNEY_NAME` | Auth tree / journey name | `Login` | -| `PING_TEST_USERNAME` | Test user login | `testuser@example.com` | -| `PING_TEST_PASSWORD` | Test user password | *(store in CI secrets)* | +| Variable | Description | Example | +| ------------------------- | ------------------------------------------- | -------------------------------------------------------------------- | +| `PING_DISCOVERY_ENDPOINT` | OIDC discovery URL | `https://openam.example.com/openam/.well-known/openid-configuration` | +| `PING_CLIENT_ID` | OAuth2 client ID | `my-rn-client` | +| `PING_REDIRECT_URI` | Redirect URI (must be registered on server) | `org.forgerock.demo://oauth2redirect` | +| `PING_SERVER_URL` | PingAM base URL | `https://openam.example.com/openam` | +| `PING_REALM_PATH` | Realm path | `/alpha` | +| `PING_COOKIE_NAME` | Session cookie key used by Journey config | `iPlanetDirectoryPro` | +| `PING_JOURNEY_NAME` | Auth tree / journey name | `Login` | +| `PING_TEST_USERNAME` | Test user login | `testuser@example.com` | +| `PING_TEST_PASSWORD` | Test user password | _(store in CI secrets)_ | + +DaVinci E2E tests (`davinci.test.ts`) exercise a live PingOne DaVinci flow and use their own set of variables: + +| Variable | Description | Example | +| ---------------------------- | -------------------------------------------------------------------------- | ----------------------------------------------------------------------- | +| `PINGONE_DISCOVERY_ENDPOINT` | DaVinci OIDC discovery endpoint | `https://auth.pingone.com//as/.well-known/openid-configuration` | +| `PINGONE_CLIENT_ID` | DaVinci OIDC client ID | `my-davinci-client` | +| `PINGONE_REDIRECT_URI` | Redirect URI (optional, defaults to `org.forgerock.demo://oauth2redirect`) | `org.forgerock.demo://oauth2redirect` | +| `PINGONE_ACR_VALUES` | ACR values to route to the correct DaVinci flow (optional) | _(flow-specific)_ | +| `PINGONE_USERNAME` | DaVinci username collector value | `testuser@example.com` | +| `PINGONE_PASSWORD` | DaVinci password collector value | _(store in CI secrets)_ | +| `PING_DAVINCI_USERNAME_KEY` | Collector key for the username field (optional, defaults to `username`) | `username` | +| `PING_DAVINCI_PASSWORD_KEY` | Collector key for the password field (optional, defaults to `password`) | `password` | + +Without these set, the DaVinci live cases self-skip while the launch smoke test and form-rendering assertions still run. You can export these from a local file (not committed): @@ -232,8 +248,10 @@ PingTestRunner/ ├── scenarios/ # Headless test screens (one per feature) │ ├── JourneyScenario.tsx │ ├── OidcScenario.tsx +│ ├── DaVinciScenario.tsx │ ├── UseJourneyScenario.tsx │ ├── UseOidcScenario.tsx +│ ├── UseDaVinciScenario.tsx │ ├── EnvScenario.tsx │ ├── DeviceIdScenario.tsx │ ├── DeviceProfileScenario.tsx @@ -245,6 +263,7 @@ PingTestRunner/ │ └── integration/ # Jest integration tests (one per package) │ ├── browser.test.ts │ ├── core.test.ts +│ ├── davinci.test.ts │ ├── device-id.test.ts │ ├── device-profile.test.ts │ ├── journey.test.ts @@ -263,6 +282,7 @@ PingTestRunner/ │ ├── use-journey.test.ts # useJourney + useJourneyForm hook tests │ ├── use-oidc.test.ts # useOidc hook tests │ ├── oidc-happy-path.test.ts +│ ├── davinci.test.ts # DaVinci happy-path flow (PingOne DaVinci) │ ├── device-id.test.ts │ ├── device-profile.test.ts │ ├── storage.test.ts diff --git a/PingTestRunner/__tests__/integration/davinci.test.ts b/PingTestRunner/__tests__/integration/davinci.test.ts new file mode 100644 index 000000000..04de7348c --- /dev/null +++ b/PingTestRunner/__tests__/integration/davinci.test.ts @@ -0,0 +1,907 @@ +/* + * 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. + */ + +/** + * Integration tests for @ping-identity/rn-davinci + * + * Validates that the davinci package: + * - Exports createDaVinciClient, normalizeCollectors, buildNextInput, + * computeFormMeta, resolveExecutionMode, DaVinciProvider, useDaVinci + * - Throws when required OIDC config fields are missing + * - Returned client handles expose the correct method surface + * - start() returns a DaVinciNode with the expected shape + * - next() advances the flow with key-indexed collector input + * - user/refresh/revoke/userinfo/logoutUser delegate to native + * - dispose() cleans up the native instance + */ + +export {}; + +type NativeDaVinciMock = { + configureDaVinci: jest.Mock; + start: jest.Mock; + next: jest.Mock; + getSession: jest.Mock; + refresh: jest.Mock; + revoke: jest.Mock; + userinfo: jest.Mock; + logout: jest.Mock; + dispose: jest.Mock; +}; + +function makeMock( + overrides: Partial = {}, +): NativeDaVinciMock { + return { + configureDaVinci: jest.fn(async () => 'davinci-id-mock'), + start: jest.fn(async () => ({ + type: 'ContinueNode', + collectors: [ + { + key: 'username', + type: 'TEXT', + label: 'Username', + required: true, + value: '', + }, + { + key: 'password', + type: 'PASSWORD', + label: 'Password', + required: true, + value: '', + }, + { + key: 'submit', + type: 'SUBMIT_BUTTON', + label: 'Submit', + required: false, + }, + ], + })), + next: jest.fn(async () => ({ + type: 'SuccessNode', + session: { value: 'session-token' }, + })), + getSession: jest.fn(async () => ({ accessToken: 'mock-access-token' })), + refresh: jest.fn(async () => ({ accessToken: 'mock-refreshed-token' })), + revoke: jest.fn(async () => true), + userinfo: jest.fn(async () => ({ sub: 'user-mock' })), + logout: jest.fn(async () => undefined), + dispose: jest.fn(async () => undefined), + ...overrides, + }; +} + +async function loadDaVinci(nativeMock: NativeDaVinciMock) { + jest.resetModules(); + jest.doMock('../../../packages/davinci/src/NativeRNPingDavinci', () => ({ + __esModule: true, + default: nativeMock, + })); + // eslint-disable-next-line @typescript-eslint/no-require-imports + return require('@ping-identity/rn-davinci'); +} + +const VALID_CONFIG = { + modules: { + oidc: { + discoveryEndpoint: + 'https://auth.example.com/.well-known/openid-configuration', + clientId: 'davinci-client-id', + redirectUri: 'org.forgerock.demo://oauth2redirect', + scopes: ['openid', 'profile'], + }, + }, +}; + +describe('@ping-identity/rn-davinci — integration', () => { + afterEach(() => jest.restoreAllMocks()); + + describe('exports', () => { + it('exports createDaVinciClient', async () => { + const mod = await loadDaVinci(makeMock()); + expect(typeof mod.createDaVinciClient).toBe('function'); + }); + + it('exports buildNextInput', async () => { + const mod = await loadDaVinci(makeMock()); + expect(typeof mod.buildNextInput).toBe('function'); + }); + + it('exports normalizeCollectors', async () => { + const mod = await loadDaVinci(makeMock()); + expect(typeof mod.normalizeCollectors).toBe('function'); + }); + + it('exports computeFormMeta', async () => { + const mod = await loadDaVinci(makeMock()); + expect(typeof mod.computeFormMeta).toBe('function'); + }); + + it('exports resolveExecutionMode', async () => { + const mod = await loadDaVinci(makeMock()); + expect(typeof mod.resolveExecutionMode).toBe('function'); + }); + + it('exports DaVinciProvider', async () => { + const mod = await loadDaVinci(makeMock()); + expect(mod.DaVinciProvider).toBeDefined(); + }); + + it('exports useDaVinci hook', async () => { + const mod = await loadDaVinci(makeMock()); + expect(typeof mod.useDaVinci).toBe('function'); + }); + + it('exports DaVinciError', async () => { + const mod = await loadDaVinci(makeMock()); + expect(typeof mod.DaVinciError).toBe('function'); + }); + }); + + describe('createDaVinciClient()', () => { + it('throws when modules.oidc is missing', async () => { + const mod = await loadDaVinci(makeMock()); + expect(() => mod.createDaVinciClient({})).toThrow( + /modules\.oidc\.discoveryEndpoint/, + ); + }); + + it('throws when discoveryEndpoint is empty', async () => { + const mod = await loadDaVinci(makeMock()); + expect(() => + mod.createDaVinciClient({ + modules: { + oidc: { + discoveryEndpoint: '', + clientId: 'x', + redirectUri: 'y', + }, + }, + }), + ).toThrow(/discoveryEndpoint/); + }); + + it('throws when clientId is empty', async () => { + const mod = await loadDaVinci(makeMock()); + expect(() => + mod.createDaVinciClient({ + modules: { + oidc: { + discoveryEndpoint: 'https://auth.example.com', + clientId: '', + redirectUri: 'y', + }, + }, + }), + ).toThrow(/clientId/); + }); + + it('throws when redirectUri is empty', async () => { + const mod = await loadDaVinci(makeMock()); + expect(() => + mod.createDaVinciClient({ + modules: { + oidc: { + discoveryEndpoint: 'https://auth.example.com', + clientId: 'x', + redirectUri: '', + }, + }, + }), + ).toThrow(/redirectUri/); + }); + + it('returns a client handle with the expected method surface', async () => { + const mod = await loadDaVinci(makeMock()); + const client = mod.createDaVinciClient(VALID_CONFIG); + const methods = [ + 'start', + 'next', + 'user', + 'refresh', + 'revoke', + 'userinfo', + 'logoutUser', + 'dispose', + ]; + for (const m of methods) { + expect(typeof client[m]).toBe('function'); + } + }); + + it('configure is lazy — does not call native at factory time', async () => { + const mock = makeMock(); + const mod = await loadDaVinci(mock); + mod.createDaVinciClient(VALID_CONFIG); + expect(mock.configureDaVinci).not.toHaveBeenCalled(); + }); + }); + + describe('flow', () => { + it('start() configures lazily and returns a ContinueNode with collectors', async () => { + const mock = makeMock(); + const mod = await loadDaVinci(mock); + const client = mod.createDaVinciClient(VALID_CONFIG); + const node = await client.start(); + expect(mock.configureDaVinci).toHaveBeenCalledTimes(1); + expect(node.type).toBe('ContinueNode'); + expect(Array.isArray(node.collectors)).toBe(true); + }); + + it('start() does not reconfigure on subsequent calls', async () => { + const mock = makeMock(); + const mod = await loadDaVinci(mock); + const client = mod.createDaVinciClient(VALID_CONFIG); + await client.start(); + await client.start(); + expect(mock.configureDaVinci).toHaveBeenCalledTimes(1); + expect(mock.start).toHaveBeenCalledTimes(2); + }); + + it('next() forwards the key-indexed collector payload to native', async () => { + const mock = makeMock(); + const mod = await loadDaVinci(mock); + const client = mod.createDaVinciClient(VALID_CONFIG); + await client.start(); + const input = { + collectors: [ + { key: 'username', value: 'alice' }, + { key: 'password', value: 'secret' }, + { key: 'submit', value: 'submit' }, + ], + }; + const node = await client.next(input); + expect(node.type).toBe('SuccessNode'); + expect(mock.next).toHaveBeenCalledTimes(1); + expect(mock.next).toHaveBeenCalledWith('davinci-id-mock', input); + }); + + it('user() resolves with the active session', async () => { + const mock = makeMock(); + const mod = await loadDaVinci(mock); + const client = mod.createDaVinciClient(VALID_CONFIG); + await client.start(); + const session = await client.user(); + expect(session).toMatchObject({ accessToken: 'mock-access-token' }); + }); + + it('user() resolves with null when no session exists', async () => { + const mock = makeMock({ getSession: jest.fn(async () => null) }); + const mod = await loadDaVinci(mock); + const client = mod.createDaVinciClient(VALID_CONFIG); + await client.start(); + const session = await client.user(); + expect(session).toBeNull(); + }); + + it('refresh() resolves with a refreshed session', async () => { + const mock = makeMock(); + const mod = await loadDaVinci(mock); + const client = mod.createDaVinciClient(VALID_CONFIG); + await client.start(); + const session = await client.refresh(); + expect(session).toMatchObject({ accessToken: 'mock-refreshed-token' }); + }); + + it('revoke() resolves with a boolean', async () => { + const mock = makeMock(); + const mod = await loadDaVinci(mock); + const client = mod.createDaVinciClient(VALID_CONFIG); + await client.start(); + const result = await client.revoke(); + expect(typeof result).toBe('boolean'); + }); + + it('userinfo() resolves with the userinfo payload', async () => { + const mock = makeMock(); + const mod = await loadDaVinci(mock); + const client = mod.createDaVinciClient(VALID_CONFIG); + await client.start(); + const info = await client.userinfo(); + expect(info).toMatchObject({ sub: 'user-mock' }); + }); + + it('logoutUser() resolves without throwing', async () => { + const mock = makeMock(); + const mod = await loadDaVinci(mock); + const client = mod.createDaVinciClient(VALID_CONFIG); + await client.start(); + await expect(client.logoutUser()).resolves.toBeUndefined(); + expect(mock.logout).toHaveBeenCalledTimes(1); + }); + + it('dispose() is a no-op when start() has not been called', async () => { + const mock = makeMock(); + const mod = await loadDaVinci(mock); + const client = mod.createDaVinciClient(VALID_CONFIG); + await expect(client.dispose()).resolves.toBeUndefined(); + expect(mock.dispose).not.toHaveBeenCalled(); + }); + + it('dispose() cleans up the native instance after use', async () => { + const mock = makeMock(); + const mod = await loadDaVinci(mock); + const client = mod.createDaVinciClient(VALID_CONFIG); + await client.start(); + await client.dispose(); + expect(mock.dispose).toHaveBeenCalledTimes(1); + }); + + it('start() after dispose() reconfigures a new native instance', async () => { + const mock = makeMock({ + configureDaVinci: jest + .fn() + .mockResolvedValueOnce('davinci-id-first') + .mockResolvedValueOnce('davinci-id-second'), + }); + const mod = await loadDaVinci(mock); + const client = mod.createDaVinciClient(VALID_CONFIG); + await client.start(); + await client.dispose(); + await client.start(); + expect(mock.configureDaVinci).toHaveBeenCalledTimes(2); + }); + }); + + describe('failure paths', () => { + it('start() propagates native errors as DaVinciError', async () => { + const mock = makeMock({ + configureDaVinci: jest.fn(async () => { + throw new Error('configure failed'); + }), + }); + const mod = await loadDaVinci(mock); + const client = mod.createDaVinciClient(VALID_CONFIG); + await expect(client.start()).rejects.toMatchObject({ + name: 'DaVinciError', + }); + }); + + it('next() propagates native errors as DaVinciError', async () => { + const mock = makeMock({ + next: jest.fn(async () => { + throw new Error('next failed'); + }), + }); + const mod = await loadDaVinci(mock); + const client = mod.createDaVinciClient(VALID_CONFIG); + await client.start(); + await expect(client.next({ collectors: [] })).rejects.toMatchObject({ + name: 'DaVinciError', + }); + }); + }); + + describe('normalizeCollectors()', () => { + it('returns an empty array for an empty input', async () => { + const mod = await loadDaVinci(makeMock()); + expect(mod.normalizeCollectors([])).toEqual([]); + }); + + it('enriches collectors with executionMode and requiresUserInput', async () => { + const mod = await loadDaVinci(makeMock()); + const collectors = [ + { key: 'username', type: 'TEXT', label: 'Username', required: true }, + { + key: 'submit', + type: 'SUBMIT_BUTTON', + label: 'Submit', + required: false, + }, + { key: 'label', type: 'LABEL', content: 'Hello' }, + ]; + const result = mod.normalizeCollectors(collectors); + expect(result[0]).toMatchObject({ + executionMode: 'manual', + requiresUserInput: true, + }); + expect(result[1]).toMatchObject({ + executionMode: 'immediate', + requiresUserInput: false, + }); + expect(result[2]).toMatchObject({ + executionMode: 'output_only', + requiresUserInput: false, + }); + }); + }); + + describe('resolveExecutionMode()', () => { + it('classifies manual collector types', async () => { + const mod = await loadDaVinci(makeMock()); + expect(mod.resolveExecutionMode('TEXT')).toBe('manual'); + expect(mod.resolveExecutionMode('PASSWORD')).toBe('manual'); + expect(mod.resolveExecutionMode('PHONE_NUMBER')).toBe('manual'); + }); + + it('classifies immediate / output-only / unsupported types', async () => { + const mod = await loadDaVinci(makeMock()); + expect(mod.resolveExecutionMode('SUBMIT_BUTTON')).toBe('immediate'); + expect(mod.resolveExecutionMode('LABEL')).toBe('output_only'); + expect(mod.resolveExecutionMode('FUTURE_TYPE')).toBe('unsupported'); + }); + }); + + // ─── ContinueNode wire-format — per-collector JS surface ─────────────────── + // + // The native bridge unit tests (DaVinciNodeMapperTest / + // DaVinciCollectorValueApplierTest on Android, the iOS twins) already prove + // each collector class serialises to the documented JS object shape and that + // user-supplied values are applied back to the right collector field. + // + // These tests exercise the JS-side composition layer: given a realistic + // ContinueNode for each collector type, assert that + // 1. normalizeCollectors enriches the collector correctly + // 2. buildNextInput round-trips a user-supplied value into the right key + // + // Adding a new collector type to the SDK and forgetting to teach the helpers + // about it fails here without a device or simulator. + + describe('ContinueNode wire-format — per collector type', () => { + it('TEXT — round-trips a string value under the collector key', async () => { + const mod = await loadDaVinci( + makeMock({ + start: jest.fn(async () => ({ + type: 'ContinueNode', + collectors: [ + { + key: 'username', + type: 'TEXT', + label: 'Username', + required: true, + value: '', + validation: { regex: '^.+$' }, + }, + ], + })), + }), + ); + const client = mod.createDaVinciClient(VALID_CONFIG); + const node = await client.start(); + const collectors = mod.normalizeCollectors(node.collectors); + expect(collectors[0]).toMatchObject({ + executionMode: 'manual', + requiresUserInput: true, + validation: { regex: '^.+$' }, + }); + const plan = mod.buildNextInput(node, { username: 'alice' }); + expect(plan.canSubmit).toBe(true); + expect(plan.input.collectors).toEqual([ + { key: 'username', value: 'alice' }, + ]); + }); + + it('PASSWORD — round-trips a string value and surfaces passwordPolicy', async () => { + const policy = { + name: 'Default', + description: 'Default policy', + length: { min: 8, max: 64 }, + minCharacters: { '0123456789': 1 }, + maxRepeatedCharacters: 2, + minUniqueCharacters: 5, + excludesProfileData: true, + notSimilarToCurrent: true, + excludesCommonlyUsed: true, + maxAgeDays: 90, + minAgeDays: 1, + populationCount: 1, + createdAt: '2026-01-01', + updatedAt: '2026-01-01', + default: true, + }; + const mod = await loadDaVinci( + makeMock({ + start: jest.fn(async () => ({ + type: 'ContinueNode', + collectors: [ + { + key: 'password', + type: 'PASSWORD', + label: 'Password', + required: true, + value: '', + clearPassword: true, + passwordPolicy: policy, + }, + ], + })), + }), + ); + const client = mod.createDaVinciClient(VALID_CONFIG); + const node = await client.start(); + const collectors = mod.normalizeCollectors(node.collectors); + expect(collectors[0]).toMatchObject({ + type: 'PASSWORD', + clearPassword: true, + passwordPolicy: policy, + }); + const plan = mod.buildNextInput(node, { password: 's3cret' }); + expect(plan.input.collectors).toEqual([ + { key: 'password', value: 's3cret' }, + ]); + }); + + it('SUBMIT_BUTTON — auto-included in payload with key as value', async () => { + const mod = await loadDaVinci( + makeMock({ + start: jest.fn(async () => ({ + type: 'ContinueNode', + collectors: [ + { + key: 'username', + type: 'TEXT', + label: 'Username', + required: true, + value: '', + }, + { + key: 'submit', + type: 'SUBMIT_BUTTON', + label: 'Submit', + required: false, + }, + ], + })), + }), + ); + const client = mod.createDaVinciClient(VALID_CONFIG); + const node = await client.start(); + const plan = mod.buildNextInput(node, { username: 'alice' }); + expect(plan.input.collectors).toEqual([ + { key: 'username', value: 'alice' }, + { key: 'submit', value: 'submit' }, + ]); + }); + + it('FLOW_BUTTON — buildNextInput(flowKey) sends ONLY the flow key', async () => { + const mod = await loadDaVinci( + makeMock({ + start: jest.fn(async () => ({ + type: 'ContinueNode', + collectors: [ + { + key: 'username', + type: 'TEXT', + label: 'Username', + required: true, + value: '', + }, + { + key: 'forgot', + type: 'FLOW_BUTTON', + label: 'Forgot?', + required: false, + }, + ], + })), + }), + ); + const client = mod.createDaVinciClient(VALID_CONFIG); + const node = await client.start(); + const plan = mod.buildNextInput( + node, + { username: 'alice' }, + undefined, + 'forgot', + ); + expect(plan.input.collectors).toEqual([ + { key: 'forgot', value: 'forgot' }, + ]); + }); + + it('LABEL — output_only and excluded from the payload', async () => { + const mod = await loadDaVinci( + makeMock({ + start: jest.fn(async () => ({ + type: 'ContinueNode', + collectors: [ + { key: 'banner', type: 'LABEL', content: 'Welcome back!' }, + ], + })), + }), + ); + const client = mod.createDaVinciClient(VALID_CONFIG); + const node = await client.start(); + const collectors = mod.normalizeCollectors(node.collectors); + expect(collectors[0]).toMatchObject({ + type: 'LABEL', + content: 'Welcome back!', + executionMode: 'output_only', + }); + const plan = mod.buildNextInput(node, {}); + expect(plan.input.collectors).toEqual([]); + }); + + it('SINGLE_SELECT — preserves options[] and round-trips the selected value', async () => { + const mod = await loadDaVinci( + makeMock({ + start: jest.fn(async () => ({ + type: 'ContinueNode', + collectors: [ + { + key: 'colour', + type: 'SINGLE_SELECT', + label: 'Colour', + required: true, + value: '', + options: [ + { label: 'Red', value: 'red' }, + { label: 'Blue', value: 'blue' }, + ], + }, + ], + })), + }), + ); + const client = mod.createDaVinciClient(VALID_CONFIG); + const node = await client.start(); + const collectors = mod.normalizeCollectors(node.collectors); + expect(collectors[0]).toMatchObject({ + type: 'SINGLE_SELECT', + options: [ + { label: 'Red', value: 'red' }, + { label: 'Blue', value: 'blue' }, + ], + }); + const plan = mod.buildNextInput(node, { colour: 'blue' }); + expect(plan.input.collectors).toEqual([{ key: 'colour', value: 'blue' }]); + }); + + it('MULTI_SELECT — round-trips a string[] value', async () => { + const mod = await loadDaVinci( + makeMock({ + start: jest.fn(async () => ({ + type: 'ContinueNode', + collectors: [ + { + key: 'roles', + type: 'MULTI_SELECT', + label: 'Roles', + required: true, + value: [], + options: [ + { label: 'Admin', value: 'admin' }, + { label: 'User', value: 'user' }, + ], + }, + ], + })), + }), + ); + const client = mod.createDaVinciClient(VALID_CONFIG); + const node = await client.start(); + const plan = mod.buildNextInput(node, { + roles: ['admin', 'user'], + }); + expect(plan.input.collectors).toEqual([ + { key: 'roles', value: ['admin', 'user'] }, + ]); + }); + + it('PHONE_NUMBER — round-trips { countryCode, phoneNumber }', async () => { + const mod = await loadDaVinci( + makeMock({ + start: jest.fn(async () => ({ + type: 'ContinueNode', + collectors: [ + { + key: 'phone', + type: 'PHONE_NUMBER', + label: 'Phone', + required: true, + defaultCountryCode: 'US', + validatePhoneNumber: true, + countryCode: 'US', + phoneNumber: '', + }, + ], + })), + }), + ); + const client = mod.createDaVinciClient(VALID_CONFIG); + const node = await client.start(); + const collectors = mod.normalizeCollectors(node.collectors); + expect(collectors[0]).toMatchObject({ + defaultCountryCode: 'US', + validatePhoneNumber: true, + }); + const plan = mod.buildNextInput(node, { + phone: { countryCode: 'GB', phoneNumber: '+447700900123' }, + }); + expect(plan.input.collectors).toEqual([ + { + key: 'phone', + value: { countryCode: 'GB', phoneNumber: '+447700900123' }, + }, + ]); + }); + + it('DEVICE_REGISTRATION — exposes devices[] and round-trips a string type value', async () => { + const devices = [ + { + type: 'SMS', + title: 'SMS', + iconSrc: 'sms.png', + isDefault: true, + }, + { + type: 'EMAIL', + title: 'Email', + iconSrc: 'email.png', + }, + ]; + const mod = await loadDaVinci( + makeMock({ + start: jest.fn(async () => ({ + type: 'ContinueNode', + collectors: [ + { + key: 'register', + type: 'DEVICE_REGISTRATION', + label: 'Register', + required: true, + devices, + }, + ], + })), + }), + ); + const client = mod.createDaVinciClient(VALID_CONFIG); + const node = await client.start(); + const collectors = mod.normalizeCollectors(node.collectors); + expect(collectors[0]).toMatchObject({ + type: 'DEVICE_REGISTRATION', + devices, + }); + const plan = mod.buildNextInput(node, { register: 'SMS' }); + expect(plan.input.collectors).toEqual([ + { key: 'register', value: 'SMS' }, + ]); + }); + + it('DEVICE_AUTHENTICATION — round-trips { type, id, description }', async () => { + const devices = [ + { + id: 'device-1', + type: 'PUSH', + title: 'Pixel 7', + description: 'My Pixel', + iconSrc: 'push.png', + }, + ]; + const mod = await loadDaVinci( + makeMock({ + start: jest.fn(async () => ({ + type: 'ContinueNode', + collectors: [ + { + key: 'auth', + type: 'DEVICE_AUTHENTICATION', + label: 'Authenticate', + required: true, + devices, + }, + ], + })), + }), + ); + const client = mod.createDaVinciClient(VALID_CONFIG); + const node = await client.start(); + const collectors = mod.normalizeCollectors(node.collectors); + expect(collectors[0]).toMatchObject({ + type: 'DEVICE_AUTHENTICATION', + devices, + }); + const value = { + type: 'PUSH', + id: 'device-1', + description: 'My Pixel', + }; + const plan = mod.buildNextInput(node, { auth: value }); + expect(plan.input.collectors).toEqual([{ key: 'auth', value }]); + }); + + it('unsupported collector — surfaces a non-blocking issue and excludes from payload', async () => { + const mod = await loadDaVinci( + makeMock({ + start: jest.fn(async () => ({ + type: 'ContinueNode', + collectors: [ + { + key: 'future', + type: 'FUTURE_TYPE', + label: 'Future', + required: false, + }, + { + key: 'username', + type: 'TEXT', + label: 'Username', + required: true, + value: '', + }, + ], + })), + }), + ); + const client = mod.createDaVinciClient(VALID_CONFIG); + const node = await client.start(); + const plan = mod.buildNextInput(node, { username: 'alice' }); + expect(plan.canSubmit).toBe(true); + expect(plan.issues).toEqual([ + expect.objectContaining({ + code: 'UNSUPPORTED_COLLECTOR', + key: 'future', + }), + ]); + expect(plan.input.collectors).toEqual([ + { key: 'username', value: 'alice' }, + ]); + }); + }); + + // ─── terminal node shapes ────────────────────────────────────────────────── + // + // SuccessNode/ErrorNode/FailureNode pass through the bridge as-is. These + // tests verify the JS layer surfaces them with the documented shape so + // consumers can rely on the discriminated union without sniffing. + + describe('terminal node shapes', () => { + it('SuccessNode surfaces session.value', async () => { + const mod = await loadDaVinci( + makeMock({ + start: jest.fn(async () => ({ + type: 'SuccessNode', + session: { value: 'session-tok-1' }, + })), + }), + ); + const client = mod.createDaVinciClient(VALID_CONFIG); + const node = await client.start(); + expect(node).toEqual({ + type: 'SuccessNode', + session: { value: 'session-tok-1' }, + }); + }); + + it('ErrorNode surfaces message', async () => { + const mod = await loadDaVinci( + makeMock({ + start: jest.fn(async () => ({ + type: 'ErrorNode', + message: 'invalid credentials', + })), + }), + ); + const client = mod.createDaVinciClient(VALID_CONFIG); + const node = await client.start(); + expect(node).toMatchObject({ + type: 'ErrorNode', + message: 'invalid credentials', + }); + }); + + it('FailureNode surfaces message', async () => { + const mod = await loadDaVinci( + makeMock({ + start: jest.fn(async () => ({ + type: 'FailureNode', + message: 'unrecoverable', + })), + }), + ); + const client = mod.createDaVinciClient(VALID_CONFIG); + const node = await client.start(); + expect(node).toMatchObject({ + type: 'FailureNode', + message: 'unrecoverable', + }); + }); + }); +}); diff --git a/PingTestRunner/__tests__/integration/native-spec-contracts.test.ts b/PingTestRunner/__tests__/integration/native-spec-contracts.test.ts index 3513fb6e4..dbf6f0ddf 100644 --- a/PingTestRunner/__tests__/integration/native-spec-contracts.test.ts +++ b/PingTestRunner/__tests__/integration/native-spec-contracts.test.ts @@ -25,6 +25,7 @@ import type { Spec as BindingSpec } from '../../../packages/binding/src/NativeRNPingBinding'; import type { Spec as BrowserSpec } from '../../../packages/browser/src/NativeRNPingBrowser'; import type { Spec as DeviceClientSpec } from '../../../packages/device-client/src/NativeRNPingDeviceClient'; +import type { Spec as DaVinciSpec } from '../../../packages/davinci/src/NativeRNPingDavinci'; import type { Spec as DeviceIdSpec } from '../../../packages/device-id/src/NativeRNPingDeviceId'; import type { Spec as DeviceProfileSpec } from '../../../packages/device-profile/src/NativeRNPingDeviceProfile'; import type { Spec as ExternalIdpSpec } from '../../../packages/external-idp/src/NativeRNPingExternalIdp'; @@ -61,7 +62,22 @@ type _BrowserMockedMethods = Pick; type _DeviceClientMockedMethods = Pick< DeviceClientSpec, 'create' | 'get' | 'update' | 'deleteDevice' | 'dispose' ->; + >; +// ─── rn-davinci ───────────────────────────────────────────────────────────── +// jest.setup.js mocks: configureDaVinci, start, next, getSession, refresh, +// revoke, userinfo, logout, dispose +type _DaVinciMockedMethods = Pick< + DaVinciSpec, + | 'configureDaVinci' + | 'start' + | 'next' + | 'getSession' + | 'refresh' + | 'revoke' + | 'userinfo' + | 'logout' + | 'dispose' + >; // ─── rn-device-id ─────────────────────────────────────────────────────────── // jest.setup.js mocks: getDefaultDeviceId diff --git a/PingTestRunner/e2e/davinci.test.ts b/PingTestRunner/e2e/davinci.test.ts new file mode 100644 index 000000000..9c122ee8f --- /dev/null +++ b/PingTestRunner/e2e/davinci.test.ts @@ -0,0 +1,187 @@ +/* + * 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. + */ + +/** + * E2E — DaVinci happy-path flow + * + * Exercises the DaVinci workflow against a live PingOne DaVinci environment + * when the required environment variables are set: + * + * PINGONE_DISCOVERY_ENDPOINT — DaVinci OIDC discovery endpoint + * PINGONE_CLIENT_ID — OIDC client id + * PINGONE_REDIRECT_URI — OIDC redirect URI (optional, defaults to org.forgerock.demo://oauth2redirect) + * PINGONE_ACR_VALUES — ACR values to route to the correct DaVinci flow (optional) + * PINGONE_USERNAME — DaVinci username collector value + * PINGONE_PASSWORD — DaVinci password collector value + * + * When env vars are absent the live cases self-skip while the launch smoke test + * still verifies the scenario screen mounts. + * + * Collector testID scheme: `davinci-field-{key}` (e.g. `davinci-field-username`). + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { device, element, by, expect as detoxExpect, waitFor } from 'detox'; +import { expect as jestExpect } from '@jest/globals'; +import { assertAppReady, DAVINCI_ENV } from './setup'; + +const NET_TIMEOUT = 30000; + +const DAVINCI_USERNAME_KEY = + process.env['PING_DAVINCI_USERNAME_KEY'] ?? 'username'; +const DAVINCI_PASSWORD_KEY = + process.env['PING_DAVINCI_PASSWORD_KEY'] ?? 'password'; + +const USERNAME_INPUT = by.id(`davinci-field-${DAVINCI_USERNAME_KEY}`); +const PASSWORD_INPUT = by.id(`davinci-field-${DAVINCI_PASSWORD_KEY}`); + +const SKIP_REASON = + 'Live DaVinci env vars not set — skipping DaVinci E2E tests. ' + + 'Set PINGONE_DISCOVERY_ENDPOINT, PINGONE_CLIENT_ID, PINGONE_USERNAME, PINGONE_PASSWORD to enable.'; + +function hasDaVinciEnv(): boolean { + return !!( + DAVINCI_ENV.discoveryEndpoint && + DAVINCI_ENV.clientId && + DAVINCI_ENV.testUsername && + DAVINCI_ENV.testPassword + ); +} + +describe('DaVinci — happy path', () => { + beforeAll(async () => { + await device.launchApp({ + newInstance: true, + launchArgs: { + PING_TEST_SCENARIO: 'davinci', + ...(DAVINCI_ENV.discoveryEndpoint + ? { PING_DISCOVERY_ENDPOINT: DAVINCI_ENV.discoveryEndpoint } + : {}), + ...(DAVINCI_ENV.clientId + ? { PING_CLIENT_ID: DAVINCI_ENV.clientId } + : {}), + ...(DAVINCI_ENV.redirectUri + ? { PING_REDIRECT_URI: DAVINCI_ENV.redirectUri } + : {}), + ...(DAVINCI_ENV.acrValues + ? { PING_ACR_VALUES: DAVINCI_ENV.acrValues } + : {}), + }, + }); + await device.disableSynchronization(); + }); + + afterAll(async () => { + await device.terminateApp(); + }); + + it('app launches and root is visible', async () => { + await assertAppReady(); + }); + + it('start button is rendered', async () => { + await detoxExpect(element(by.id('davinci-start-btn'))).toBeVisible(); + }); + + it('start() renders the login form (live)', async () => { + if (!hasDaVinciEnv()) { + console.warn(SKIP_REASON); + return; + } + + await element(by.id('davinci-start-btn')).tap(); + await waitFor(element(USERNAME_INPUT)) + .toBeVisible() + .withTimeout(NET_TIMEOUT); + await waitFor(element(PASSWORD_INPUT)) + .toBeVisible() + .withTimeout(NET_TIMEOUT); + }); + + it('next() with valid credentials returns SuccessNode (live)', async () => { + if (!hasDaVinciEnv()) { + console.warn(SKIP_REASON); + return; + } + + await element(USERNAME_INPUT).typeText(DAVINCI_ENV.testUsername); + await element(PASSWORD_INPUT).typeText(DAVINCI_ENV.testPassword); + await element(by.id('davinci-submit-btn')).tap(); + await waitFor(element(by.id('davinci-success'))) + .toBeVisible() + .withTimeout(NET_TIMEOUT); + }); + + it('access token is available and non-empty after successful login (live)', async () => { + if (!hasDaVinciEnv()) { + console.warn(SKIP_REASON); + return; + } + + await waitFor(element(by.id('davinci-token-result'))) + .toBeVisible() + .withTimeout(NET_TIMEOUT); + const attrs = await element(by.id('davinci-token-result')).getAttributes(); + const token = (attrs as any).text ?? (attrs as any).label ?? ''; + jestExpect(token.length).toBeGreaterThan(0); + jestExpect(token).not.toBe('null'); + jestExpect(token).not.toBe('undefined'); + }); + + it('userinfo() returns a payload containing sub (live)', async () => { + if (!hasDaVinciEnv()) { + console.warn(SKIP_REASON); + return; + } + + await element(by.id('davinci-userinfo-btn')).tap(); + await waitFor(element(by.id('davinci-userinfo-result'))) + .toBeVisible() + .withTimeout(NET_TIMEOUT); + const attrs = await element( + by.id('davinci-userinfo-result'), + ).getAttributes(); + const text = (attrs as any).text ?? (attrs as any).label ?? ''; + jestExpect(text).toContain('"sub"'); + }); + + it('refresh() obtains a new token (live)', async () => { + if (!hasDaVinciEnv()) { + console.warn(SKIP_REASON); + return; + } + + await element(by.id('davinci-refresh-btn')).tap(); + await waitFor(element(by.id('davinci-refreshed'))) + .toBeVisible() + .withTimeout(NET_TIMEOUT); + }); + + it('revoke() invalidates the session (live)', async () => { + if (!hasDaVinciEnv()) { + console.warn(SKIP_REASON); + return; + } + + await element(by.id('davinci-revoke-btn')).tap(); + await waitFor(element(by.id('davinci-revoked'))) + .toBeVisible() + .withTimeout(NET_TIMEOUT); + }); + + it('logoutUser() clears the session (live)', async () => { + if (!hasDaVinciEnv()) { + console.warn(SKIP_REASON); + return; + } + + await element(by.id('davinci-logout-btn')).tap(); + await waitFor(element(by.id('davinci-logged-out'))) + .toBeVisible() + .withTimeout(NET_TIMEOUT); + }); +}); diff --git a/PingTestRunner/e2e/setup.ts b/PingTestRunner/e2e/setup.ts index 79900ca64..2946f5bfc 100644 --- a/PingTestRunner/e2e/setup.ts +++ b/PingTestRunner/e2e/setup.ts @@ -64,6 +64,21 @@ export const E2E_ENV = { testPassword: process.env['PING_TEST_PASSWORD'] ?? '', }; +/** + * DaVinci-specific environment variables. Set these in PingTestRunner/.env + * (see .env.example) or inject them as CI secrets. + */ +export const DAVINCI_ENV = { + discoveryEndpoint: process.env['PINGONE_DISCOVERY_ENDPOINT'] ?? '', + clientId: process.env['PINGONE_CLIENT_ID'] ?? '', + redirectUri: + process.env['PINGONE_REDIRECT_URI'] ?? + 'org.forgerock.demo://oauth2redirect', + testUsername: process.env['PINGONE_USERNAME'] ?? '', + testPassword: process.env['PINGONE_PASSWORD'] ?? '', + acrValues: process.env['PINGONE_ACR_VALUES'] ?? '', +}; + /** * Returns true when all required env vars for live auth tests are set. */ diff --git a/PingTestRunner/ios/PingTestRunner.xcodeproj/xcshareddata/xcschemes/RNPackagesTests.xcscheme b/PingTestRunner/ios/PingTestRunner.xcodeproj/xcshareddata/xcschemes/RNPackagesTests.xcscheme index 433ae093c..3e5f52057 100644 --- a/PingTestRunner/ios/PingTestRunner.xcodeproj/xcshareddata/xcschemes/RNPackagesTests.xcscheme +++ b/PingTestRunner/ios/PingTestRunner.xcodeproj/xcshareddata/xcschemes/RNPackagesTests.xcscheme @@ -50,6 +50,17 @@ ReferencedContainer = "container:Pods/Pods.xcodeproj"> + + + + @@ -61,6 +72,17 @@ ReferencedContainer = "container:Pods/Pods.xcodeproj"> + + + + @@ -138,6 +160,17 @@ ReferencedContainer = "container:Pods/Pods.xcodeproj"> + + + + @@ -149,6 +182,17 @@ ReferencedContainer = "container:Pods/Pods.xcodeproj"> + + + + diff --git a/PingTestRunner/ios/Podfile b/PingTestRunner/ios/Podfile index f08017916..6ec80fbbc 100644 --- a/PingTestRunner/ios/Podfile +++ b/PingTestRunner/ios/Podfile @@ -49,6 +49,9 @@ target 'PingTestRunner' do pod 'RNPingFido', :path => '../../node_modules/@ping-identity/rn-fido', :testspecs => ['Tests'] + pod 'RNPingDavinci', + :path => '../../node_modules/@ping-identity/rn-davinci', + :testspecs => ['Tests'] pod 'RNPingDeviceClient', :path => '../../node_modules/@ping-identity/rn-device-client', :testspecs => ['Tests'] diff --git a/PingTestRunner/ios/Podfile.lock b/PingTestRunner/ios/Podfile.lock index 1a4fd7830..c49a2f32e 100644 --- a/PingTestRunner/ios/Podfile.lock +++ b/PingTestRunner/ios/Podfile.lock @@ -16,6 +16,9 @@ PODS: - PingLogger (~> 2.0.0) - PingCommons (2.0.0): - PingLogger (~> 2.0.0) + - PingDavinci (2.0.0): + - PingDavinciPlugin (~> 2.0.0) + - PingOidc (~> 2.0.0) - PingDavinciPlugin (2.0.0): - PingOrchestrate (~> 2.0.0) - PingDeviceClient (2.0.0): @@ -2205,7 +2208,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - RNPingBinding (1.0.0-beta.3): + - RNPingBinding (1.0.0): - boost - DoubleConversion - fast_float @@ -2236,7 +2239,7 @@ PODS: - RNPingCore - SocketRocket - Yoga - - RNPingBinding/Tests (1.0.0-beta.3): + - RNPingBinding/Tests (1.0.0): - boost - DoubleConversion - fast_float @@ -2267,7 +2270,7 @@ PODS: - RNPingCore - SocketRocket - Yoga - - RNPingBrowser (1.0.0-beta.3): + - RNPingBrowser (1.0.0): - boost - DoubleConversion - fast_float @@ -2275,6 +2278,7 @@ PODS: - glog - hermes-engine - PingBrowser (= 2.0.0) + - PingLogger (= 2.0.0) - RCT-Folly - RCT-Folly/Fabric - RCTRequired @@ -2299,7 +2303,7 @@ PODS: - RNPingLogger - SocketRocket - Yoga - - RNPingBrowser/Tests (1.0.0-beta.3): + - RNPingBrowser/Tests (1.0.0): - boost - DoubleConversion - fast_float @@ -2307,6 +2311,7 @@ PODS: - glog - hermes-engine - PingBrowser (= 2.0.0) + - PingLogger (= 2.0.0) - RCT-Folly - RCT-Folly/Fabric - RCTRequired @@ -2331,7 +2336,36 @@ PODS: - RNPingLogger - SocketRocket - Yoga - - RNPingCore (1.0.0-beta.3): + - RNPingCore (1.0.0): + - boost + - DoubleConversion + - fast_float + - fmt + - glog + - hermes-engine + - RCT-Folly + - RCT-Folly/Fabric + - RCTRequired + - RCTTypeSafety + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - SocketRocket + - Yoga + - RNPingCore/Tests (1.0.0): - boost - DoubleConversion - fast_float @@ -2360,13 +2394,14 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - RNPingCore/Tests (1.0.0-beta.3): + - RNPingDavinci (0.0.0): - boost - DoubleConversion - fast_float - fmt - glog - hermes-engine + - PingDavinci (= 2.0.0) - RCT-Folly - RCT-Folly/Fabric - RCTRequired @@ -2387,9 +2422,41 @@ PODS: - ReactCodegen - ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/core + - RNPingCore + - SocketRocket + - Yoga + - RNPingDavinci/Tests (0.0.0): + - boost + - DoubleConversion + - fast_float + - fmt + - glog + - hermes-engine + - PingDavinci (= 2.0.0) + - RCT-Folly + - RCT-Folly/Fabric + - RCTRequired + - RCTTypeSafety + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-hermes + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - RNPingCore - SocketRocket - Yoga - - RNPingDeviceClient (1.0.0-beta.3): + - RNPingDeviceClient (1.0.0): - boost - DoubleConversion - fast_float @@ -2420,7 +2487,7 @@ PODS: - RNPingCore - SocketRocket - Yoga - - RNPingDeviceClient/Tests (1.0.0-beta.3): + - RNPingDeviceClient/Tests (1.0.0): - boost - DoubleConversion - fast_float @@ -2451,7 +2518,7 @@ PODS: - RNPingCore - SocketRocket - Yoga - - RNPingDeviceId (1.0.0-beta.3): + - RNPingDeviceId (1.0.0): - boost - DoubleConversion - fast_float @@ -2482,7 +2549,7 @@ PODS: - RNPingCore - SocketRocket - Yoga - - RNPingDeviceId/Tests (1.0.0-beta.3): + - RNPingDeviceId/Tests (1.0.0): - boost - DoubleConversion - fast_float @@ -2513,7 +2580,7 @@ PODS: - RNPingCore - SocketRocket - Yoga - - RNPingDeviceProfile (1.0.0-beta.3): + - RNPingDeviceProfile (1.0.0): - boost - DoubleConversion - fast_float @@ -2544,7 +2611,7 @@ PODS: - RNPingCore - SocketRocket - Yoga - - RNPingDeviceProfile/Tests (1.0.0-beta.3): + - RNPingDeviceProfile/Tests (1.0.0): - boost - DoubleConversion - fast_float @@ -2575,7 +2642,7 @@ PODS: - RNPingCore - SocketRocket - Yoga - - RNPingExternalIdp (1.0.0-beta.3): + - RNPingExternalIdp (1.0.0): - boost - DoubleConversion - fast_float @@ -2606,7 +2673,7 @@ PODS: - RNPingCore - SocketRocket - Yoga - - RNPingExternalIdp/Tests (1.0.0-beta.3): + - RNPingExternalIdp/Tests (1.0.0): - boost - DoubleConversion - fast_float @@ -2637,7 +2704,7 @@ PODS: - RNPingCore - SocketRocket - Yoga - - RNPingFido (1.0.0-beta.3): + - RNPingFido (1.0.0): - boost - DoubleConversion - fast_float @@ -2668,7 +2735,7 @@ PODS: - RNPingCore - SocketRocket - Yoga - - RNPingFido/Tests (1.0.0-beta.3): + - RNPingFido/Tests (1.0.0): - boost - DoubleConversion - fast_float @@ -2699,7 +2766,7 @@ PODS: - RNPingCore - SocketRocket - Yoga - - RNPingJourney (1.0.0-beta.3): + - RNPingJourney (1.0.0): - boost - DoubleConversion - fast_float @@ -2737,7 +2804,7 @@ PODS: - RNPingCore - SocketRocket - Yoga - - RNPingJourney/Tests (1.0.0-beta.3): + - RNPingJourney/Tests (1.0.0): - boost - DoubleConversion - fast_float @@ -2775,7 +2842,7 @@ PODS: - RNPingCore - SocketRocket - Yoga - - RNPingLogger (1.0.0-beta.3): + - RNPingLogger (1.0.0): - boost - DoubleConversion - fast_float @@ -2806,7 +2873,7 @@ PODS: - RNPingCore - SocketRocket - Yoga - - RNPingLogger/Tests (1.0.0-beta.3): + - RNPingLogger/Tests (1.0.0): - boost - DoubleConversion - fast_float @@ -2837,7 +2904,7 @@ PODS: - RNPingCore - SocketRocket - Yoga - - RNPingOath (1.0.0-beta.3): + - RNPingOath (1.0.0): - boost - DoubleConversion - fast_float @@ -2868,7 +2935,7 @@ PODS: - RNPingCore - SocketRocket - Yoga - - RNPingOath/Tests (1.0.0-beta.3): + - RNPingOath/Tests (1.0.0): - boost - DoubleConversion - fast_float @@ -2899,7 +2966,7 @@ PODS: - RNPingCore - SocketRocket - Yoga - - RNPingOidc (1.0.0-beta.3): + - RNPingOidc (1.0.0): - boost - DoubleConversion - fast_float @@ -2934,7 +3001,7 @@ PODS: - RNPingCore - SocketRocket - Yoga - - RNPingOidc/Tests (1.0.0-beta.3): + - RNPingOidc/Tests (1.0.0): - boost - DoubleConversion - fast_float @@ -2969,7 +3036,7 @@ PODS: - RNPingCore - SocketRocket - Yoga - - RNPingPush (1.0.0-beta.3): + - RNPingPush (1.0.0): - boost - DoubleConversion - fast_float @@ -3002,7 +3069,7 @@ PODS: - RNPingCore - SocketRocket - Yoga - - RNPingPush/Tests (1.0.0-beta.3): + - RNPingPush/Tests (1.0.0): - boost - DoubleConversion - fast_float @@ -3035,7 +3102,7 @@ PODS: - RNPingCore - SocketRocket - Yoga - - RNPingStorage (1.0.0-beta.3): + - RNPingStorage (1.0.0): - boost - DoubleConversion - fast_float @@ -3066,7 +3133,7 @@ PODS: - RNPingCore - SocketRocket - Yoga - - RNPingStorage/Tests (1.0.0-beta.3): + - RNPingStorage/Tests (1.0.0): - boost - DoubleConversion - fast_float @@ -3181,6 +3248,8 @@ DEPENDENCIES: - "RNPingBrowser/Tests (from `../../node_modules/@ping-identity/rn-browser`)" - RNPingCore (from `../../packages/core`) - RNPingCore/Tests (from `../../packages/core`) + - "RNPingDavinci (from `../../node_modules/@ping-identity/rn-davinci`)" + - "RNPingDavinci/Tests (from `../../node_modules/@ping-identity/rn-davinci`)" - "RNPingDeviceClient (from `../../node_modules/@ping-identity/rn-device-client`)" - "RNPingDeviceClient/Tests (from `../../node_modules/@ping-identity/rn-device-client`)" - "RNPingDeviceId (from `../../node_modules/@ping-identity/rn-device-id`)" @@ -3211,6 +3280,7 @@ SPEC REPOS: - PingBinding - PingBrowser - PingCommons + - PingDavinci - PingDavinciPlugin - PingDeviceClient - PingDeviceId @@ -3383,6 +3453,8 @@ EXTERNAL SOURCES: :path: "../../node_modules/@ping-identity/rn-browser" RNPingCore: :path: "../../packages/core" + RNPingDavinci: + :path: "../../node_modules/@ping-identity/rn-davinci" RNPingDeviceClient: :path: "../../node_modules/@ping-identity/rn-device-client" RNPingDeviceId: @@ -3419,6 +3491,7 @@ SPEC CHECKSUMS: PingBinding: 46036fcbcd4e3cb6a453f08822493ba451d4c8ab PingBrowser: d1526bdcc6197781d0d54fe7ac16ba09a55a1faa PingCommons: 49f2244bda1c41913fa37d9481e969702d658ade + PingDavinci: 63c1d6830ec1aaf98850ff0708b7b5fb65a55d84 PingDavinciPlugin: d0d4c5a0e4fdd36608307e755ba435d41470d76f PingDeviceClient: 4d2fa2ba2fb427cc4001c43ac3ed80bd994fb4f1 PingDeviceId: e773280c2bb679f4133c8ef855f189620b331ce3 @@ -3501,23 +3574,24 @@ SPEC CHECKSUMS: ReactCodegen: 1e9f3e8a3f56fa25fbf39ecd37b708a4838d9032 ReactCommon: 96684b90b235d6ae340d126141edd4563b7a446a RNCAsyncStorage: 767abb068db6ad28b5f59a129fbc9fab18b377e2 - RNPingBinding: 10541621edbb8b6885f069ee6316843498d6cc93 - RNPingBrowser: 63741144ed5088a03a765a4d7c42be27f18fdd53 - RNPingCore: fe4e99005afb728904b634a2b29bd9f7df0ed7a2 - RNPingDeviceClient: b62cdc274eff19c09fce49b7d7b7e1166a81a044 - RNPingDeviceId: f37f85e0807a5b0e284631c83e28e0dfd4a78712 - RNPingDeviceProfile: 90e4220f1ec456f8dfaeb9a4214214100302292c - RNPingExternalIdp: 7954d281fa3ea15681b5b1c6ce6f8813559e9b48 - RNPingFido: 350763404cdd05b9fe3dbf73eba926e041d4bf63 - RNPingJourney: f3823780322f741fceed6eb6c3c20013d85a44a2 - RNPingLogger: 3bb60c3bc67fe1078a4a4535f98129da4845eebf - RNPingOath: f67ef2fedb4ad9727349be6eac5da988196871eb - RNPingOidc: 889badee38ae3a2a592edb723d2047f3eea603c7 - RNPingPush: 1ac8fd7f91b9489ec088e502bb8a7b96fabacc80 - RNPingStorage: 61024de883275173ac09f95d985f1bb9bef62670 + RNPingBinding: c9449a4e6b808afc9dcc311a07804559c0c522b4 + RNPingBrowser: 042c960b67b47a85b0e1de373b76ddb2609487e1 + RNPingCore: 3926e39f9ab60d83fb4d58225303864ca0d8171b + RNPingDavinci: b37645623c3b3b3b1b0669a56a3d2e7ec231e75d + RNPingDeviceClient: bd78e33420a8f5dcd6ab3b21691805743b12079f + RNPingDeviceId: 24f49f8d518103367f6bbcbcf54138a424541ba7 + RNPingDeviceProfile: 4c6b306859c9bc9cdad0aea830d9a8e0227dcc4a + RNPingExternalIdp: 372123fab1006f80c184a1f7992a3b335f69eb3c + RNPingFido: a67c3cb1fa2ff1930476189e5ebc1bc0a6e84d4a + RNPingJourney: 90546edde90b7c30f9b4a526a094e531a92094fe + RNPingLogger: db39fa39b31cd5026a7c279cd52996fafac190e1 + RNPingOath: 2e64a03cd27ac198b21d2e4a4d767eaa31bd2b03 + RNPingOidc: dceb88744c13a9bdd44a339f244d3a0535bf8232 + RNPingPush: 898e5dd90a372e2ac9a27f0c622c2f17614fbd26 + RNPingStorage: 17cf506d51259fa7c64a649d97ed974bd5350b22 SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 Yoga: daa1e4de4b971b977b23bc842aaa3e135324f1f3 -PODFILE CHECKSUM: aa39790ed4f30ff93815069baba79c18d49c70b3 +PODFILE CHECKSUM: 06fe91365819bf22b320c91bdb09a02aa2b3c437 -COCOAPODS: 1.15.2 +COCOAPODS: 1.16.2 diff --git a/PingTestRunner/jest.config.js b/PingTestRunner/jest.config.js index f53a60a77..cccea4874 100644 --- a/PingTestRunner/jest.config.js +++ b/PingTestRunner/jest.config.js @@ -27,6 +27,8 @@ module.exports = { '^@ping-identity/rn-external-idp$': '/../packages/external-idp/src/index.tsx', '^@ping-identity/rn-core$': '/../packages/core/index.js', + '^@ping-identity/rn-davinci$': + '/../packages/davinci/src/index.tsx', '^@ping-identity/rn-device-client$': '/../packages/device-client/src/index.tsx', '^@ping-identity/rn-device-id$': diff --git a/PingTestRunner/jest.setup.js b/PingTestRunner/jest.setup.js index 8667f9739..7325b336f 100644 --- a/PingTestRunner/jest.setup.js +++ b/PingTestRunner/jest.setup.js @@ -81,6 +81,66 @@ jest.mock('../packages/browser/src/NativeRNPingBrowser', () => ({ })), })); +// ---------- rn-davinci ---------- +// Track configured client identity so JS bridge bugs (skipped configure, wrong +// id forwarded, reuse after dispose) surface as test failures rather than +// silently returning canned data. +jest.mock('../packages/davinci/src/NativeRNPingDavinci', () => { + const active = new Set(); + let counter = 0; + const assertActive = (method, id) => { + if (!active.has(id)) { + throw new Error( + `[NativeRNPingDavinci mock] ${method} called with unknown or disposed davinciId="${id}"`, + ); + } + }; + return { + __esModule: true, + default: { + configureDaVinci: jest.fn(async () => { + counter += 1; + const id = `davinci-id-mock-${counter}`; + active.add(id); + return id; + }), + start: jest.fn(async (id) => { + assertActive('start', id); + return { type: 'ContinueNode', collectors: [] }; + }), + next: jest.fn(async (id) => { + assertActive('next', id); + return { type: 'SuccessNode', session: { value: 'session-mock' } }; + }), + getSession: jest.fn(async (id) => { + assertActive('getSession', id); + return { accessToken: 'mock-access-token' }; + }), + refresh: jest.fn(async (id) => { + assertActive('refresh', id); + return { accessToken: 'mock-refreshed-token' }; + }), + revoke: jest.fn(async (id) => { + assertActive('revoke', id); + return true; + }), + userinfo: jest.fn(async (id) => { + assertActive('userinfo', id); + return { sub: 'user-mock' }; + }), + logout: jest.fn(async (id) => { + assertActive('logout', id); + return undefined; + }), + dispose: jest.fn(async (id) => { + assertActive('dispose', id); + active.delete(id); + return undefined; + }), + }, + }; +}); + // ---------- rn-device-id ---------- jest.mock('../packages/device-id/src/NativeRNPingDeviceId', () => ({ __esModule: true, diff --git a/PingTestRunner/package.json b/PingTestRunner/package.json index 6c1092d0c..2f2f7be93 100644 --- a/PingTestRunner/package.json +++ b/PingTestRunner/package.json @@ -20,6 +20,7 @@ "test:e2e:android": "sh -c 'export DETOX_SERVER_PORT=${DETOX_SERVER_PORT:-8100}; export NODE_NO_WARNINGS=1; yarn e2e:android:free-detox-port && yarn e2e:android:kill-emulators && detox test --configuration android.emu --cleanup \"$@\"' --", "test:bs:android": "NODE_NO_WARNINGS=1 detox test --configuration android.bs --loglevel trace", "test:bs:android:journey": "NODE_NO_WARNINGS=1 detox test --configuration android.bs --loglevel trace --testPathPattern journey", + "test:bs:android:journey-davinci": "NODE_NO_WARNINGS=1 detox test --configuration android.bs --loglevel trace --testPathPattern 'e2e/\\(journey\\|davinci\\)'", "test:bs:android:core": "NODE_NO_WARNINGS=1 detox test --configuration android.bs --loglevel trace --testPathPattern 'e2e/\\(app-launch\\|app-open-close\\|browser\\|device-id\\|device-profile\\|logger\\|oidc\\|storage\\|use-oidc\\)'", "test:e2e:ios": "sh -c 'export DETOX_SERVER_PORT=${DETOX_SERVER_PORT:-8099}; export NODE_NO_WARNINGS=1; yarn e2e:android:free-detox-port && detox test --configuration ios.sim \"$@\"' --", "build:e2e:android": "detox build --configuration android.emu", @@ -34,6 +35,7 @@ "dependencies": { "@ping-identity/rn-binding": "workspace:*", "@ping-identity/rn-browser": "workspace:*", + "@ping-identity/rn-davinci": "workspace:*", "@ping-identity/rn-device-client": "workspace:*", "@ping-identity/rn-device-id": "workspace:*", "@ping-identity/rn-device-profile": "workspace:*", diff --git a/PingTestRunner/scenarios/DaVinciScenario.tsx b/PingTestRunner/scenarios/DaVinciScenario.tsx new file mode 100644 index 000000000..9e7d0066f --- /dev/null +++ b/PingTestRunner/scenarios/DaVinciScenario.tsx @@ -0,0 +1,565 @@ +/* + * 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. + */ + +/** + * DaVinciScenario — headless test screen for DaVinci E2E tests. + * + * Reads config from Detox launchArgs and renders a generic UI that handles all + * collector types exposed by rn-davinci 2.0.1. Each collector gets a + * deterministic testID of the form `davinci-field-{key}` so per-test files can + * locate fields without knowing anything else about the form. + * + * testIDs: + * davinci-start-btn → starts the flow + * davinci-field-{key} → per-collector input + * davinci-field-{key}-option-{value} → option button for single/multi-select + * davinci-flow-{key} → FLOW_BUTTON / FLOW_LINK / ACTION + * davinci-submit-btn → SUBMIT_BUTTON (or fallback submit) + * davinci-success → SuccessNode reached + * davinci-error → ErrorNode reached + * davinci-error-message → ErrorNode message + * davinci-failure → FailureNode reached + * davinci-failure-message → FailureNode message + * davinci-token-result → access token after success + * davinci-userinfo-btn / -result → userinfo() + * davinci-refresh-btn / -refreshed → refresh() + * davinci-revoke-btn / -revoked → revoke() + * davinci-logout-btn / -logged-out → logoutUser() + * davinci-loading → in-flight indicator + * davinci-runtime-error → unexpected runtime error + */ + +import React, { useCallback, useState } from 'react'; +import { Button, Switch, Text, TextInput, View } from 'react-native'; +import { LaunchArguments } from 'react-native-launch-arguments'; +import { + createDaVinciClient, + normalizeCollectors, +} from '@ping-identity/rn-davinci'; +import type { + DaVinciClient, + DaVinciFormValue, + DaVinciFormValues, + DaVinciNextInput, + DaVinciNode, + DaVinciNormalizedCollector, +} from '@ping-identity/rn-davinci'; + +// ─── launch args ───────────────────────────────────────────────────────────── + +interface DaVinciLaunchArgs { + PING_DISCOVERY_ENDPOINT?: string; + PING_CLIENT_ID?: string; + PING_REDIRECT_URI?: string; + PING_SCOPES?: string; + PING_TIMEOUT?: string; + PING_ACR_VALUES?: string; +} + +const args = LaunchArguments.value(); +const DISCOVERY_ENDPOINT = args.PING_DISCOVERY_ENDPOINT ?? ''; +const CLIENT_ID = args.PING_CLIENT_ID ?? ''; +const REDIRECT_URI = + args.PING_REDIRECT_URI ?? 'org.forgerock.demo://oauth2redirect'; +const SCOPES = (args.PING_SCOPES ?? 'openid profile email') + .split(' ') + .map((s) => s.trim()) + .filter(Boolean); +const TIMEOUT = args.PING_TIMEOUT ? Number(args.PING_TIMEOUT) : undefined; +const ACR_VALUES = args.PING_ACR_VALUES ?? undefined; + +// ─── state ─────────────────────────────────────────────────────────────────── + +type ScenarioState = 'idle' | 'loading' | 'form' | 'success' | 'error'; + +export default function DaVinciScenario(): React.JSX.Element { + const [state, setState] = useState('idle'); + const [client, setClient] = useState(null); + const [node, setNode] = useState(null); + const [values, setValues] = useState({}); + const [runtimeError, setRuntimeError] = useState(null); + const [tokenResult, setTokenResult] = useState(null); + const [userinfo, setUserinfo] = useState(null); + const [refreshed, setRefreshed] = useState(false); + const [revoked, setRevoked] = useState(false); + const [loggedOut, setLoggedOut] = useState(false); + + const handleNode = useCallback( + async (davinciClient: DaVinciClient, nextNode: DaVinciNode) => { + setNode(nextNode); + setValues({}); + if (nextNode.type === 'SuccessNode') { + setState('success'); + try { + const session = await davinciClient.user(); + if (session?.accessToken) { + setTokenResult(session.accessToken); + } + } catch { + // best effort + } + return; + } + if (nextNode.type === 'ContinueNode') { + setState('form'); + return; + } + // ErrorNode / FailureNode + setState('error'); + }, + [], + ); + + const handleStart = useCallback(async () => { + setRuntimeError(null); + setTokenResult(null); + setUserinfo(null); + setRefreshed(false); + setRevoked(false); + setLoggedOut(false); + try { + setState('loading'); + const davinciClient = createDaVinciClient({ + modules: { + oidc: { + discoveryEndpoint: DISCOVERY_ENDPOINT, + clientId: CLIENT_ID, + redirectUri: REDIRECT_URI, + scopes: SCOPES, + ...(ACR_VALUES !== undefined ? { acrValues: ACR_VALUES } : {}), + }, + }, + ...(TIMEOUT !== undefined ? { timeout: TIMEOUT } : {}), + }); + setClient(davinciClient); + const first = await davinciClient.start(); + await handleNode(davinciClient, first); + } catch (e) { + setRuntimeError(e instanceof Error ? e.message : String(e)); + setState('error'); + } + }, [handleNode]); + + const handleSubmit = useCallback( + async (overrideInput?: DaVinciNextInput) => { + if (!client || !node || node.type !== 'ContinueNode') { + return; + } + try { + setState('loading'); + const collectors = normalizeCollectors(node.collectors); + const input = overrideInput ?? buildPayload(collectors, values); + const nextNode = await client.next(input); + await handleNode(client, nextNode); + } catch (e) { + setRuntimeError(e instanceof Error ? e.message : String(e)); + setState('error'); + } + }, + [client, node, values, handleNode], + ); + + const handleFlow = useCallback( + async (flowKey: string) => { + if (!node || node.type !== 'ContinueNode') return; + const base = buildPayload(normalizeCollectors(node.collectors), values); + await handleSubmit({ + collectors: [...base.collectors, { key: flowKey, value: flowKey }], + }); + }, + [handleSubmit, node, values], + ); + + const handleUserinfo = useCallback(async () => { + if (!client) return; + try { + const info = await client.userinfo(); + setUserinfo(JSON.stringify(info)); + } catch (e) { + setRuntimeError(e instanceof Error ? e.message : String(e)); + } + }, [client]); + + const handleRefresh = useCallback(async () => { + if (!client) return; + try { + await client.refresh(); + setRefreshed(true); + } catch (e) { + setRuntimeError(e instanceof Error ? e.message : String(e)); + } + }, [client]); + + const handleRevoke = useCallback(async () => { + if (!client) return; + try { + await client.revoke(); + setRevoked(true); + } catch (e) { + setRuntimeError(e instanceof Error ? e.message : String(e)); + } + }, [client]); + + const handleLogout = useCallback(async () => { + if (!client) return; + try { + await client.logoutUser(); + setLoggedOut(true); + } catch (e) { + setRuntimeError(e instanceof Error ? e.message : String(e)); + } + }, [client]); + + return ( + +