diff --git a/iOS/swiftui-davinci/Davinci.xcworkspace/xcshareddata/swiftpm/Package.resolved b/iOS/swiftui-davinci/Davinci.xcworkspace/xcshareddata/swiftpm/Package.resolved index 35f6c842..1b1cf7fc 100644 --- a/iOS/swiftui-davinci/Davinci.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/iOS/swiftui-davinci/Davinci.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -73,13 +73,22 @@ "version" : "5.0.0" } }, + { + "identity" : "interop-ios-for-google-sdks", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/interop-ios-for-google-sdks.git", + "state" : { + "revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe", + "version" : "101.0.0" + } + }, { "identity" : "ping-ios-sdk", "kind" : "remoteSourceControl", "location" : "https://github.com/ForgeRock/ping-ios-sdk", "state" : { - "revision" : "5afd472a8a0479ecc80f2583af7a685701be2b28", - "version" : "1.3.1" + "revision" : "08d889ffa090c4b38da677820dee2e20c7c2b4bd", + "version" : "2.0.0-alpha" } }, { @@ -100,6 +109,15 @@ "version" : "2.4.0" } }, + { + "identity" : "recaptcha-enterprise-mobile-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/GoogleCloudPlatform/recaptcha-enterprise-mobile-sdk.git", + "state" : { + "revision" : "fb634e89a36fd91725ad654d5576d800c061b37d", + "version" : "18.8.2" + } + }, { "identity" : "svgkit", "kind" : "remoteSourceControl", diff --git a/iOS/swiftui-davinci/Davinci/Davinci.xcodeproj/project.pbxproj b/iOS/swiftui-davinci/Davinci/Davinci.xcodeproj/project.pbxproj index 69992216..9f0ffa51 100644 --- a/iOS/swiftui-davinci/Davinci/Davinci.xcodeproj/project.pbxproj +++ b/iOS/swiftui-davinci/Davinci/Davinci.xcodeproj/project.pbxproj @@ -590,8 +590,8 @@ isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/ForgeRock/ping-ios-sdk"; requirement = { - kind = upToNextMajorVersion; - minimumVersion = 1.3.1; + kind = exactVersion; + version = "2.0.0-alpha"; }; }; EC83DE582E5F2D1D00D2B5B4 /* XCRemoteSwiftPackageReference "SVGKit" */ = { diff --git a/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample.xcodeproj/project.pbxproj b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample.xcodeproj/project.pbxproj new file mode 100644 index 00000000..ecdefd9e --- /dev/null +++ b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample.xcodeproj/project.pbxproj @@ -0,0 +1,579 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + EC8024302F0BDD1C0046C961 /* PingBinding in Frameworks */ = {isa = PBXBuildFile; productRef = EC80242F2F0BDD1C0046C961 /* PingBinding */; }; + EC8024322F0BDD1C0046C961 /* PingBrowser in Frameworks */ = {isa = PBXBuildFile; productRef = EC8024312F0BDD1C0046C961 /* PingBrowser */; }; + EC8024342F0BDD1C0046C961 /* PingCommons in Frameworks */ = {isa = PBXBuildFile; productRef = EC8024332F0BDD1C0046C961 /* PingCommons */; }; + EC8024362F0BDD1C0046C961 /* PingDeviceClient in Frameworks */ = {isa = PBXBuildFile; productRef = EC8024352F0BDD1C0046C961 /* PingDeviceClient */; }; + EC8024382F0BDD1C0046C961 /* PingDeviceId in Frameworks */ = {isa = PBXBuildFile; productRef = EC8024372F0BDD1C0046C961 /* PingDeviceId */; }; + EC80243A2F0BDD1C0046C961 /* PingDeviceProfile in Frameworks */ = {isa = PBXBuildFile; productRef = EC8024392F0BDD1C0046C961 /* PingDeviceProfile */; }; + EC80243C2F0BDD1C0046C961 /* PingExternalIdP in Frameworks */ = {isa = PBXBuildFile; productRef = EC80243B2F0BDD1C0046C961 /* PingExternalIdP */; }; + EC80243E2F0BDD1C0046C961 /* PingExternalIdPApple in Frameworks */ = {isa = PBXBuildFile; productRef = EC80243D2F0BDD1C0046C961 /* PingExternalIdPApple */; }; + EC8024402F0BDD1C0046C961 /* PingExternalIdPFacebook in Frameworks */ = {isa = PBXBuildFile; productRef = EC80243F2F0BDD1C0046C961 /* PingExternalIdPFacebook */; }; + EC8024422F0BDD1C0046C961 /* PingExternalIdPGoogle in Frameworks */ = {isa = PBXBuildFile; productRef = EC8024412F0BDD1C0046C961 /* PingExternalIdPGoogle */; }; + EC8024442F0BDD1C0046C961 /* PingFido in Frameworks */ = {isa = PBXBuildFile; productRef = EC8024432F0BDD1C0046C961 /* PingFido */; }; + EC8024462F0BDD1C0046C961 /* PingJourney in Frameworks */ = {isa = PBXBuildFile; productRef = EC8024452F0BDD1C0046C961 /* PingJourney */; }; + EC8024482F0BDD1C0046C961 /* PingJourneyPlugin in Frameworks */ = {isa = PBXBuildFile; productRef = EC8024472F0BDD1C0046C961 /* PingJourneyPlugin */; }; + EC80244A2F0BDD1C0046C961 /* PingLogger in Frameworks */ = {isa = PBXBuildFile; productRef = EC8024492F0BDD1C0046C961 /* PingLogger */; }; + EC80244C2F0BDD1C0046C961 /* PingNetwork in Frameworks */ = {isa = PBXBuildFile; productRef = EC80244B2F0BDD1C0046C961 /* PingNetwork */; }; + EC80244E2F0BDD1C0046C961 /* PingOath in Frameworks */ = {isa = PBXBuildFile; productRef = EC80244D2F0BDD1C0046C961 /* PingOath */; }; + EC8024502F0BDD1C0046C961 /* PingOidc in Frameworks */ = {isa = PBXBuildFile; productRef = EC80244F2F0BDD1C0046C961 /* PingOidc */; }; + EC8024522F0BDD1C0046C961 /* PingOrchestrate in Frameworks */ = {isa = PBXBuildFile; productRef = EC8024512F0BDD1C0046C961 /* PingOrchestrate */; }; + EC8024542F0BDD1C0046C961 /* PingProtect in Frameworks */ = {isa = PBXBuildFile; productRef = EC8024532F0BDD1C0046C961 /* PingProtect */; }; + EC8024562F0BDD1C0046C961 /* PingPush in Frameworks */ = {isa = PBXBuildFile; productRef = EC8024552F0BDD1C0046C961 /* PingPush */; }; + EC8025022F0BE15D0046C961 /* PingReCaptchaEnterprise in Frameworks */ = {isa = PBXBuildFile; productRef = EC8025012F0BE15D0046C961 /* PingReCaptchaEnterprise */; }; + EC80253F2F0BE2AD0046C961 /* SVGKit in Frameworks */ = {isa = PBXBuildFile; productRef = EC80253E2F0BE2AD0046C961 /* SVGKit */; }; + EC8025412F0BE2AD0046C961 /* SVGKitSwift in Frameworks */ = {isa = PBXBuildFile; productRef = EC8025402F0BE2AD0046C961 /* SVGKitSwift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + EC8024202F0BDC900046C961 /* JourneyModuleSample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = JourneyModuleSample.app; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + EC8025CE2F0D6EBE0046C961 /* Exceptions for "JourneyModuleSample" folder in "JourneyModuleSample" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = EC80241F2F0BDC900046C961 /* JourneyModuleSample */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + EC8024222F0BDC900046C961 /* JourneyModuleSample */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + EC8025CE2F0D6EBE0046C961 /* Exceptions for "JourneyModuleSample" folder in "JourneyModuleSample" target */, + ); + path = JourneyModuleSample; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + EC80241D2F0BDC900046C961 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + EC8024422F0BDD1C0046C961 /* PingExternalIdPGoogle in Frameworks */, + EC8024482F0BDD1C0046C961 /* PingJourneyPlugin in Frameworks */, + EC80244E2F0BDD1C0046C961 /* PingOath in Frameworks */, + EC8024442F0BDD1C0046C961 /* PingFido in Frameworks */, + EC8024502F0BDD1C0046C961 /* PingOidc in Frameworks */, + EC8024302F0BDD1C0046C961 /* PingBinding in Frameworks */, + EC8024362F0BDD1C0046C961 /* PingDeviceClient in Frameworks */, + EC80244C2F0BDD1C0046C961 /* PingNetwork in Frameworks */, + EC8025022F0BE15D0046C961 /* PingReCaptchaEnterprise in Frameworks */, + EC8024322F0BDD1C0046C961 /* PingBrowser in Frameworks */, + EC8024522F0BDD1C0046C961 /* PingOrchestrate in Frameworks */, + EC8024462F0BDD1C0046C961 /* PingJourney in Frameworks */, + EC8024402F0BDD1C0046C961 /* PingExternalIdPFacebook in Frameworks */, + EC80244A2F0BDD1C0046C961 /* PingLogger in Frameworks */, + EC8024342F0BDD1C0046C961 /* PingCommons in Frameworks */, + EC8025412F0BE2AD0046C961 /* SVGKitSwift in Frameworks */, + EC80243E2F0BDD1C0046C961 /* PingExternalIdPApple in Frameworks */, + EC8024542F0BDD1C0046C961 /* PingProtect in Frameworks */, + EC80243A2F0BDD1C0046C961 /* PingDeviceProfile in Frameworks */, + EC8024382F0BDD1C0046C961 /* PingDeviceId in Frameworks */, + EC8024562F0BDD1C0046C961 /* PingPush in Frameworks */, + EC80253F2F0BE2AD0046C961 /* SVGKit in Frameworks */, + EC80243C2F0BDD1C0046C961 /* PingExternalIdP in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + EC8024172F0BDC900046C961 = { + isa = PBXGroup; + children = ( + EC8024222F0BDC900046C961 /* JourneyModuleSample */, + EC8025002F0BE15D0046C961 /* Frameworks */, + EC8024212F0BDC900046C961 /* Products */, + ); + sourceTree = ""; + }; + EC8024212F0BDC900046C961 /* Products */ = { + isa = PBXGroup; + children = ( + EC8024202F0BDC900046C961 /* JourneyModuleSample.app */, + ); + name = Products; + sourceTree = ""; + }; + EC8025002F0BE15D0046C961 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + EC80241F2F0BDC900046C961 /* JourneyModuleSample */ = { + isa = PBXNativeTarget; + buildConfigurationList = EC80242B2F0BDC930046C961 /* Build configuration list for PBXNativeTarget "JourneyModuleSample" */; + buildPhases = ( + EC80241C2F0BDC900046C961 /* Sources */, + EC80241D2F0BDC900046C961 /* Frameworks */, + EC80241E2F0BDC900046C961 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + EC8024222F0BDC900046C961 /* JourneyModuleSample */, + ); + name = JourneyModuleSample; + packageProductDependencies = ( + EC80242F2F0BDD1C0046C961 /* PingBinding */, + EC8024312F0BDD1C0046C961 /* PingBrowser */, + EC8024332F0BDD1C0046C961 /* PingCommons */, + EC8024352F0BDD1C0046C961 /* PingDeviceClient */, + EC8024372F0BDD1C0046C961 /* PingDeviceId */, + EC8024392F0BDD1C0046C961 /* PingDeviceProfile */, + EC80243B2F0BDD1C0046C961 /* PingExternalIdP */, + EC80243D2F0BDD1C0046C961 /* PingExternalIdPApple */, + EC80243F2F0BDD1C0046C961 /* PingExternalIdPFacebook */, + EC8024412F0BDD1C0046C961 /* PingExternalIdPGoogle */, + EC8024432F0BDD1C0046C961 /* PingFido */, + EC8024452F0BDD1C0046C961 /* PingJourney */, + EC8024472F0BDD1C0046C961 /* PingJourneyPlugin */, + EC8024492F0BDD1C0046C961 /* PingLogger */, + EC80244B2F0BDD1C0046C961 /* PingNetwork */, + EC80244D2F0BDD1C0046C961 /* PingOath */, + EC80244F2F0BDD1C0046C961 /* PingOidc */, + EC8024512F0BDD1C0046C961 /* PingOrchestrate */, + EC8024532F0BDD1C0046C961 /* PingProtect */, + EC8024552F0BDD1C0046C961 /* PingPush */, + EC8025012F0BE15D0046C961 /* PingReCaptchaEnterprise */, + EC80253E2F0BE2AD0046C961 /* SVGKit */, + EC8025402F0BE2AD0046C961 /* SVGKitSwift */, + ); + productName = JourneyModuleSample; + productReference = EC8024202F0BDC900046C961 /* JourneyModuleSample.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + EC8024182F0BDC900046C961 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2610; + LastUpgradeCheck = 2610; + TargetAttributes = { + EC80241F2F0BDC900046C961 = { + CreatedOnToolsVersion = 26.1.1; + }; + }; + }; + buildConfigurationList = EC80241B2F0BDC900046C961 /* Build configuration list for PBXProject "JourneyModuleSample" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = EC8024172F0BDC900046C961; + minimizedProjectReferenceProxies = 1; + packageReferences = ( + EC80242E2F0BDD1C0046C961 /* XCRemoteSwiftPackageReference "ping-ios-sdk" */, + EC80253D2F0BE2AD0046C961 /* XCRemoteSwiftPackageReference "SVGKit" */, + ); + preferredProjectObjectVersion = 77; + productRefGroup = EC8024212F0BDC900046C961 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + EC80241F2F0BDC900046C961 /* JourneyModuleSample */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + EC80241E2F0BDC900046C961 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + EC80241C2F0BDC900046C961 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + EC8024292F0BDC930046C961 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = 9QSE66762D; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.6; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 6.0; + }; + name = Debug; + }; + EC80242A2F0BDC930046C961 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = 9QSE66762D; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.6; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_VERSION = 6.0; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + EC80242C2F0BDC930046C961 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = JourneyModuleSample/JourneyModuleSample.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 9QSE66762D; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = JourneyModuleSample/Info.plist; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 16.6; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.pingidentity.JourneyModuleSample; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + EC80242D2F0BDC930046C961 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = JourneyModuleSample/JourneyModuleSample.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 9QSE66762D; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = JourneyModuleSample/Info.plist; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 16.6; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.pingidentity.JourneyModuleSample; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + EC80241B2F0BDC900046C961 /* Build configuration list for PBXProject "JourneyModuleSample" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + EC8024292F0BDC930046C961 /* Debug */, + EC80242A2F0BDC930046C961 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + EC80242B2F0BDC930046C961 /* Build configuration list for PBXNativeTarget "JourneyModuleSample" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + EC80242C2F0BDC930046C961 /* Debug */, + EC80242D2F0BDC930046C961 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + EC80242E2F0BDD1C0046C961 /* XCRemoteSwiftPackageReference "ping-ios-sdk" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/ForgeRock/ping-ios-sdk"; + requirement = { + kind = exactVersion; + version = "2.0.0-alpha-9b37c20"; + }; + }; + EC80253D2F0BE2AD0046C961 /* XCRemoteSwiftPackageReference "SVGKit" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/SVGKit/SVGKit"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 3.0.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + EC80242F2F0BDD1C0046C961 /* PingBinding */ = { + isa = XCSwiftPackageProductDependency; + package = EC80242E2F0BDD1C0046C961 /* XCRemoteSwiftPackageReference "ping-ios-sdk" */; + productName = PingBinding; + }; + EC8024312F0BDD1C0046C961 /* PingBrowser */ = { + isa = XCSwiftPackageProductDependency; + package = EC80242E2F0BDD1C0046C961 /* XCRemoteSwiftPackageReference "ping-ios-sdk" */; + productName = PingBrowser; + }; + EC8024332F0BDD1C0046C961 /* PingCommons */ = { + isa = XCSwiftPackageProductDependency; + package = EC80242E2F0BDD1C0046C961 /* XCRemoteSwiftPackageReference "ping-ios-sdk" */; + productName = PingCommons; + }; + EC8024352F0BDD1C0046C961 /* PingDeviceClient */ = { + isa = XCSwiftPackageProductDependency; + package = EC80242E2F0BDD1C0046C961 /* XCRemoteSwiftPackageReference "ping-ios-sdk" */; + productName = PingDeviceClient; + }; + EC8024372F0BDD1C0046C961 /* PingDeviceId */ = { + isa = XCSwiftPackageProductDependency; + package = EC80242E2F0BDD1C0046C961 /* XCRemoteSwiftPackageReference "ping-ios-sdk" */; + productName = PingDeviceId; + }; + EC8024392F0BDD1C0046C961 /* PingDeviceProfile */ = { + isa = XCSwiftPackageProductDependency; + package = EC80242E2F0BDD1C0046C961 /* XCRemoteSwiftPackageReference "ping-ios-sdk" */; + productName = PingDeviceProfile; + }; + EC80243B2F0BDD1C0046C961 /* PingExternalIdP */ = { + isa = XCSwiftPackageProductDependency; + package = EC80242E2F0BDD1C0046C961 /* XCRemoteSwiftPackageReference "ping-ios-sdk" */; + productName = PingExternalIdP; + }; + EC80243D2F0BDD1C0046C961 /* PingExternalIdPApple */ = { + isa = XCSwiftPackageProductDependency; + package = EC80242E2F0BDD1C0046C961 /* XCRemoteSwiftPackageReference "ping-ios-sdk" */; + productName = PingExternalIdPApple; + }; + EC80243F2F0BDD1C0046C961 /* PingExternalIdPFacebook */ = { + isa = XCSwiftPackageProductDependency; + package = EC80242E2F0BDD1C0046C961 /* XCRemoteSwiftPackageReference "ping-ios-sdk" */; + productName = PingExternalIdPFacebook; + }; + EC8024412F0BDD1C0046C961 /* PingExternalIdPGoogle */ = { + isa = XCSwiftPackageProductDependency; + package = EC80242E2F0BDD1C0046C961 /* XCRemoteSwiftPackageReference "ping-ios-sdk" */; + productName = PingExternalIdPGoogle; + }; + EC8024432F0BDD1C0046C961 /* PingFido */ = { + isa = XCSwiftPackageProductDependency; + package = EC80242E2F0BDD1C0046C961 /* XCRemoteSwiftPackageReference "ping-ios-sdk" */; + productName = PingFido; + }; + EC8024452F0BDD1C0046C961 /* PingJourney */ = { + isa = XCSwiftPackageProductDependency; + package = EC80242E2F0BDD1C0046C961 /* XCRemoteSwiftPackageReference "ping-ios-sdk" */; + productName = PingJourney; + }; + EC8024472F0BDD1C0046C961 /* PingJourneyPlugin */ = { + isa = XCSwiftPackageProductDependency; + package = EC80242E2F0BDD1C0046C961 /* XCRemoteSwiftPackageReference "ping-ios-sdk" */; + productName = PingJourneyPlugin; + }; + EC8024492F0BDD1C0046C961 /* PingLogger */ = { + isa = XCSwiftPackageProductDependency; + package = EC80242E2F0BDD1C0046C961 /* XCRemoteSwiftPackageReference "ping-ios-sdk" */; + productName = PingLogger; + }; + EC80244B2F0BDD1C0046C961 /* PingNetwork */ = { + isa = XCSwiftPackageProductDependency; + package = EC80242E2F0BDD1C0046C961 /* XCRemoteSwiftPackageReference "ping-ios-sdk" */; + productName = PingNetwork; + }; + EC80244D2F0BDD1C0046C961 /* PingOath */ = { + isa = XCSwiftPackageProductDependency; + package = EC80242E2F0BDD1C0046C961 /* XCRemoteSwiftPackageReference "ping-ios-sdk" */; + productName = PingOath; + }; + EC80244F2F0BDD1C0046C961 /* PingOidc */ = { + isa = XCSwiftPackageProductDependency; + package = EC80242E2F0BDD1C0046C961 /* XCRemoteSwiftPackageReference "ping-ios-sdk" */; + productName = PingOidc; + }; + EC8024512F0BDD1C0046C961 /* PingOrchestrate */ = { + isa = XCSwiftPackageProductDependency; + package = EC80242E2F0BDD1C0046C961 /* XCRemoteSwiftPackageReference "ping-ios-sdk" */; + productName = PingOrchestrate; + }; + EC8024532F0BDD1C0046C961 /* PingProtect */ = { + isa = XCSwiftPackageProductDependency; + package = EC80242E2F0BDD1C0046C961 /* XCRemoteSwiftPackageReference "ping-ios-sdk" */; + productName = PingProtect; + }; + EC8024552F0BDD1C0046C961 /* PingPush */ = { + isa = XCSwiftPackageProductDependency; + package = EC80242E2F0BDD1C0046C961 /* XCRemoteSwiftPackageReference "ping-ios-sdk" */; + productName = PingPush; + }; + EC8025012F0BE15D0046C961 /* PingReCaptchaEnterprise */ = { + isa = XCSwiftPackageProductDependency; + package = EC80242E2F0BDD1C0046C961 /* XCRemoteSwiftPackageReference "ping-ios-sdk" */; + productName = PingReCaptchaEnterprise; + }; + EC80253E2F0BE2AD0046C961 /* SVGKit */ = { + isa = XCSwiftPackageProductDependency; + package = EC80253D2F0BE2AD0046C961 /* XCRemoteSwiftPackageReference "SVGKit" */; + productName = SVGKit; + }; + EC8025402F0BE2AD0046C961 /* SVGKitSwift */ = { + isa = XCSwiftPackageProductDependency; + package = EC80253D2F0BE2AD0046C961 /* XCRemoteSwiftPackageReference "SVGKit" */; + productName = SVGKitSwift; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = EC8024182F0BDC900046C961 /* Project object */; +} diff --git a/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 00000000..1d5b2b88 --- /dev/null +++ b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,141 @@ +{ + "originHash" : "92fc3ec9500767cb61d13a91cfe6c784c81896777e4b14643d5c26d8c23cb97a", + "pins" : [ + { + "identity" : "app-check", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/app-check.git", + "state" : { + "revision" : "61b85103a1aeed8218f17c794687781505fbbef5", + "version" : "11.2.0" + } + }, + { + "identity" : "appauth-ios", + "kind" : "remoteSourceControl", + "location" : "https://github.com/openid/AppAuth-iOS.git", + "state" : { + "revision" : "145104f5ea9d58ae21b60add007c33c1cc0c948e", + "version" : "2.0.0" + } + }, + { + "identity" : "cocoalumberjack", + "kind" : "remoteSourceControl", + "location" : "https://github.com/CocoaLumberjack/CocoaLumberjack.git", + "state" : { + "revision" : "a9ed4b6f9bdedce7d77046f43adfb8ce1fd54114", + "version" : "3.9.0" + } + }, + { + "identity" : "facebook-ios-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/facebook/facebook-ios-sdk.git", + "state" : { + "revision" : "3fe31c168903759de1c5752d12856c5c437c6862", + "version" : "16.3.1" + } + }, + { + "identity" : "googlesignin-ios", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleSignIn-iOS.git", + "state" : { + "revision" : "3996d908c7b3ce8a87d39c808f9a6b2a08fbe043", + "version" : "9.0.0" + } + }, + { + "identity" : "googleutilities", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleUtilities.git", + "state" : { + "revision" : "60da361632d0de02786f709bdc0c4df340f7613e", + "version" : "8.1.0" + } + }, + { + "identity" : "gtm-session-fetcher", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/gtm-session-fetcher.git", + "state" : { + "revision" : "a2ab612cb980066ee56d90d60d8462992c07f24b", + "version" : "3.5.0" + } + }, + { + "identity" : "gtmappauth", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GTMAppAuth.git", + "state" : { + "revision" : "56e0ccf09a6dd29dc7e68bdf729598240ca8aa16", + "version" : "5.0.0" + } + }, + { + "identity" : "interop-ios-for-google-sdks", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/interop-ios-for-google-sdks.git", + "state" : { + "revision" : "040d087ac2267d2ddd4cca36c757d1c6a05fdbfe", + "version" : "101.0.0" + } + }, + { + "identity" : "ping-ios-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ForgeRock/ping-ios-sdk", + "state" : { + "revision" : "9b37c20f99eb1ce418e6dc8ab491e32fad86051e", + "version" : "2.0.0-alpha-9b37c20" + } + }, + { + "identity" : "pingone-signals-sdk-ios", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pingidentity/pingone-signals-sdk-ios.git", + "state" : { + "revision" : "bf37bd85fa909428d764630c71cf0d4f3d2d7e05", + "version" : "5.3.0" + } + }, + { + "identity" : "promises", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/promises.git", + "state" : { + "revision" : "540318ecedd63d883069ae7f1ed811a2df00b6ac", + "version" : "2.4.0" + } + }, + { + "identity" : "recaptcha-enterprise-mobile-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/GoogleCloudPlatform/recaptcha-enterprise-mobile-sdk.git", + "state" : { + "revision" : "fb634e89a36fd91725ad654d5576d800c061b37d", + "version" : "18.8.2" + } + }, + { + "identity" : "svgkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SVGKit/SVGKit", + "state" : { + "revision" : "58152b9f7c85eab239160b36ffdfd364aa43d666", + "version" : "3.0.0" + } + }, + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log", + "state" : { + "revision" : "bc386b95f2a16ccd0150a8235e7c69eab2b866ca", + "version" : "1.8.0" + } + } + ], + "version" : 3 +} diff --git a/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Assets.xcassets/AccentColor.colorset/Contents.json b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 00000000..eb878970 --- /dev/null +++ b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Assets.xcassets/AppIcon.appiconset/Contents.json b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 00000000..514e171e --- /dev/null +++ b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "Screenshot 2024-05-09-01 1.jpg", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Assets.xcassets/AppIcon.appiconset/Screenshot 2024-05-09-01 1.jpg b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Assets.xcassets/AppIcon.appiconset/Screenshot 2024-05-09-01 1.jpg new file mode 100644 index 00000000..8cfc7d4b Binary files /dev/null and b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Assets.xcassets/AppIcon.appiconset/Screenshot 2024-05-09-01 1.jpg differ diff --git a/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Assets.xcassets/Contents.json b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Assets.xcassets/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Assets.xcassets/Logo.imageset/Contents.json b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Assets.xcassets/Logo.imageset/Contents.json new file mode 100644 index 00000000..1acb1006 --- /dev/null +++ b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Assets.xcassets/Logo.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "Ping Identity Logo.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Assets.xcassets/Logo.imageset/Ping Identity Logo.png b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Assets.xcassets/Logo.imageset/Ping Identity Logo.png new file mode 100644 index 00000000..2218aa29 Binary files /dev/null and b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Assets.xcassets/Logo.imageset/Ping Identity Logo.png differ diff --git a/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Callbacks/BooleanAttributeInputCallbackView.swift b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Callbacks/BooleanAttributeInputCallbackView.swift new file mode 100644 index 00000000..4f1ff881 --- /dev/null +++ b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Callbacks/BooleanAttributeInputCallbackView.swift @@ -0,0 +1,50 @@ +// +// BooleanAttributeInputCallbackView.swift +// JourneyModuleSample +// +// 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 SwiftUI +import PingJourney + +/** + * A SwiftUI view for capturing boolean (true/false) attribute values during authentication flows. + * + * This view displays a toggle switch with a descriptive prompt label, allowing users to enable + * or disable a specific attribute or setting. Common use cases include opt-in preferences, + * feature flags, or binary configuration options during registration or profile updates. + * + * **User Action Required:** YES - User must toggle the switch to set their preference. + * + * The UI displays the prompt text on the left with a toggle switch on the right. The toggle + * reflects the current value and updates the callback immediately when changed. + */ +struct BooleanAttributeInputCallbackView: View { + let callback: BooleanAttributeInputCallback + let onNodeUpdated: () -> Void + + @State var value: Bool = false + + var body: some View { + HStack { + Text(callback.prompt) + .foregroundColor(.primary) + + Spacer() + + Toggle("", isOn: $value) + .toggleStyle(SwitchToggleStyle()) + .onChange(of: value) { newValue in + callback.value = newValue + } + } + .padding() + .onAppear { + value = callback.value + } + } +} diff --git a/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Callbacks/ChoiceCallbackView.swift b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Callbacks/ChoiceCallbackView.swift new file mode 100644 index 00000000..318ad374 --- /dev/null +++ b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Callbacks/ChoiceCallbackView.swift @@ -0,0 +1,55 @@ +// +// ChoiceCallbackView.swift +// JourneyModuleSample +// +// 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 SwiftUI +import PingJourney + +/** + * A SwiftUI view for presenting multiple choice selections during authentication flows. + * + * This view displays a picker menu with predefined choices from which the user must select. + * The selected choice index is tracked and updated in the callback as the user makes their + * selection. Common use cases include selecting authentication methods, security questions, + * or other configuration options. + * + * **User Action Required:** YES - User must select one option from the available choices. + * + * The UI displays a menu-style picker with a rounded border. The picker is pre-populated + * with the current selection and updates the callback immediately when changed. + */ +struct ChoiceCallbackView: View { + let callback: ChoiceCallback + let onNodeUpdated: () -> Void + + @State var selectedIndex: Int = 0 + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Picker(callback.prompt, selection: $selectedIndex) { + ForEach(callback.choices.indices, id: \.self) { index in + Text(callback.choices[index]).tag(index) + } + } + .pickerStyle(.menu) + .padding() + .background( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.gray, lineWidth: 1) + ) + .onChange(of: selectedIndex) { newValue in + callback.selectedIndex = newValue + } + } + .padding() + .onAppear { + selectedIndex = callback.selectedIndex + } + } +} diff --git a/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Callbacks/ConfirmationCallbackView.swift b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Callbacks/ConfirmationCallbackView.swift new file mode 100644 index 00000000..feb44530 --- /dev/null +++ b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Callbacks/ConfirmationCallbackView.swift @@ -0,0 +1,58 @@ +// +// ConfirmationCallbackView.swift +// JourneyModuleSample +// +// 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 SwiftUI +import PingJourney +import Combine + +/** + * A SwiftUI view for presenting confirmation dialogs with multiple action buttons. + * + * This view displays a prompt message along with action buttons for the user to confirm + * or respond to a decision. Common use cases include "Yes/No" confirmations, "OK/Cancel" + * dialogs, or multi-option confirmations. When the user selects an option, the callback + * records the selection and immediately proceeds to the next step. + * + * **User Action Required:** YES - User must click one of the provided action buttons. + * + * The UI displays an optional prompt message followed by horizontally arranged buttons, + * right-aligned. Each button is styled with bordered prominent style for clear visibility. + */ +struct ConfirmationCallbackView: View { + let callback: ConfirmationCallback + let onSelected: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + // Display prompt if available + if !callback.prompt.isEmpty { + Text(callback.prompt) + .font(.headline) + .foregroundColor(.primary) + .multilineTextAlignment(.leading) + } + + // Buttons arranged horizontally, trailing-aligned + HStack { + Spacer() + + ForEach(callback.options.indices, id: \.self) { index in + Button(callback.options[index]) { + callback.selectedIndex = index + onSelected() + } + .buttonStyle(.borderedProminent) + .padding(.horizontal, 4) + } + } + } + .padding() + } +} diff --git a/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Callbacks/ConsentMappingCallbackView.swift b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Callbacks/ConsentMappingCallbackView.swift new file mode 100644 index 00000000..b893e0eb --- /dev/null +++ b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Callbacks/ConsentMappingCallbackView.swift @@ -0,0 +1,95 @@ +// +// ConsentMappingCallbackView.swift +// JourneyModuleSample +// +// 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 SwiftUI +import PingJourney + +/** + * A SwiftUI view for obtaining user consent for attribute mapping during authentication flows. + * + * This view displays detailed information about data mapping consent, including the mapping name, + * display name, icon, access level, required fields, and a descriptive message. Users must review + * this information and toggle their consent. This is commonly used when integrating with external + * systems that require explicit permission to share user attributes. + * + * **User Action Required:** YES - User must toggle the consent switch to accept or decline the mapping. + * + * The UI displays all consent details including name, display name, icon reference, access level, + * required status, mapped fields, message, and a toggle switch for consent. The toggle reflects + * the current consent state. + */ +struct ConsentMappingCallbackView: View { + let callback: ConsentMappingCallback + let onNodeUpdated: () -> Void + + @State var accepted: Bool = false + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + // Name + if !callback.name.isEmpty { + Text("Name: \(callback.name)") + .font(.headline) + .foregroundColor(.primary) + } + + // Display Name + if !callback.displayName.isEmpty { + Text("DisplayName: \(callback.displayName)") + .font(.headline) + .foregroundColor(.primary) + } + + // Icon + if !callback.icon.isEmpty { + Text("Icon: \(callback.icon)") + .font(.subheadline) + .foregroundColor(.secondary) + } + + // Access Level + if !callback.accessLevel.isEmpty { + Text("AccessLevel: \(callback.accessLevel)") + .font(.subheadline) + .foregroundColor(.secondary) + } + + // Is Required + Text("IsRequired: \(callback.isRequired.description)") + .font(.subheadline) + .foregroundColor(.secondary) + + // fields + ForEach(callback.fields, id: \.self) { fieldItem in + Text("callback: \(fieldItem)") + .font(.subheadline) + .foregroundColor(.secondary) + } + + // Message + if !callback.message.isEmpty { + Text("Message: \(callback.message)") + .font(.subheadline) + .foregroundColor(.secondary) + } + + // Acceptance Toggle + Toggle("I consent to this mapping", isOn: $accepted) + .toggleStyle(SwitchToggleStyle()) + .onChange(of: accepted) { newValue in + callback.accepted = newValue + } + } + .padding() + .onAppear { + accepted = callback.accepted + } + } +} diff --git a/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Callbacks/CustomPinCollector.swift b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Callbacks/CustomPinCollector.swift new file mode 100644 index 00000000..64d6c109 --- /dev/null +++ b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Callbacks/CustomPinCollector.swift @@ -0,0 +1,71 @@ +// +// CustomPinCollector.swift +// JourneyModuleSample +// +// 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 Foundation +import UIKit +import SwiftUI +import PingBinding +import Combine + +/** + * A custom implementation of the PinCollector protocol for device binding and signing operations. + * + * This class provides a SwiftUI-based PIN collection interface that can be used as an alternative + * to the default PIN collector in device binding and device signing scenarios. When PIN input is + * required, this collector presents a modal PinCollectorView over the current view controller, + * allowing the user to enter their 4-digit PIN. + * + * **Implementation Details:** + * - Conforms to the PinCollector protocol from PingBinding SDK + * - Presents PinCollectorView modally as a form sheet + * - Finds the topmost view controller to present the PIN entry UI + * - Handles both PIN submission and cancellation scenarios + * - Executes completion handler with the collected PIN (or nil if cancelled) + * + * **Usage:** + * This custom collector can be configured in DeviceBindingCallback or DeviceSigningVerifierCallback: + * + * ```swift + * let result = await callback.bind { config in + * config.pinCollector = CustomPinCollector() + * } + * ``` + * + * **Thread Safety:** + * The collectPin method ensures UI operations are performed on the main thread using DispatchQueue.main.async. + */ +class CustomPinCollector: PinCollector { + + func collectPin(prompt: Prompt, completion: @escaping @Sendable (String?) -> Void) { + DispatchQueue.main.async { + let keyWindow = UIApplication.shared.windows.filter { $0.isKeyWindow }.first + var topVC = keyWindow?.rootViewController + while let presentedViewController = topVC?.presentedViewController { + topVC = presentedViewController + } + + guard let topVC = topVC else { + completion(nil) + return + } + + let pinView = PinCollectorView(prompt: prompt) { pin in + topVC.dismiss(animated: true) { + completion(pin) + } + } + + let hostingController = UIHostingController(rootView: pinView) + hostingController.modalPresentationStyle = .formSheet + + topVC.present(hostingController, animated: true, completion: nil) + } + } +} diff --git a/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Callbacks/DeviceBindingCallbackView.swift b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Callbacks/DeviceBindingCallbackView.swift new file mode 100644 index 00000000..aec5434e --- /dev/null +++ b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Callbacks/DeviceBindingCallbackView.swift @@ -0,0 +1,105 @@ +// +// DeviceBindingCallbackView.swift +// JourneyModuleSample +// +// 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 SwiftUI +import PingBinding + +/** + * A SwiftUI view for handling device binding operations during authentication flows. + * + * This view automatically initiates the device binding process when displayed, which securely + * associates the current device with the user's account. The binding may involve biometric + * authentication or PIN collection depending on configuration. Once binding completes, + * the view automatically proceeds to the next step. + * + * **User Action Required:** CONDITIONAL - May require biometric authentication or PIN entry + * depending on the device authenticator configuration. Default configuration binds automatically. + * + * The UI displays a loading indicator with status message during the binding process. The component + * supports custom PIN collectors and biometric authenticators through configuration options. + */ +struct DeviceBindingCallbackView: View { + var callback: DeviceBindingCallback + let onNext: () -> Void + + var body: some View { + VStack { + Text("Device Binding") + .font(.title) + Text("Please wait while we bind your device.") + .font(.body) + .padding() + ProgressView() + } + .onAppear(perform: handleDeviceBinding) + } + + private func handleDeviceBinding() { + Task { + /* + For using a custom view for PIN collection, you can provide a CustomPinCollector + through the configuration as shown below: + + let result = await callback.bind { config in + config.pinCollector = CustomPinCollector() + } + + For more advanced configuration, you can create a custom AppPinConfig: + + let result = await callback.bind { config in + let appPinConfig = AppPinConfig( + prompt: Prompt(title: "Enter PIN", subtitle: "Security", description: "Enter your 4-digit PIN"), + pinRetry: 5, + pinCollector: CustomPinCollector() + ) + config.deviceAuthenticator = AppPinAuthenticator(config: appPinConfig) + } + + For biometric authenticators, you can also use BiometricAuthenticatorConfig: + + let result = await callback.bind { config in + let biometricConfig = BiometricAuthenticatorConfig( + keyTag: "my-custom-biometric-key" + ) + + // Set the authenticator config - the appropriate authenticator will be used based on callback type + config.authenticatorConfig = biometricConfig + } + + You can also configure with a logger for debugging: + + let result = await callback.bind { config in + // Create a custom logger instance + let customLogger = Logger.logger // or your custom logger implementation + + let biometricConfig = BiometricAuthenticatorConfig( + logger: customLogger, + keyTag: "secure-biometric-key-\(callback.userId)" + ) + + config.authenticatorConfig = biometricConfig + } + */ + let result = await callback.bind() + switch result { + case .success(let json): + print("Device binding success: \(json)") + case .failure(let error): + if let deviceBindingStatus = error as? DeviceBindingStatus { + print("Device binding failed: \(deviceBindingStatus.errorMessage)") + } else { + print("Device binding failed: \(error.localizedDescription)") + } + } + onNext() + } + } +} + diff --git a/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Callbacks/DeviceProfileCallbackView.swift b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Callbacks/DeviceProfileCallbackView.swift new file mode 100644 index 00000000..9b34382a --- /dev/null +++ b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Callbacks/DeviceProfileCallbackView.swift @@ -0,0 +1,87 @@ +// +// DeviceProfileCallbackView.swift +// JourneyModuleSample +// +// 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 SwiftUI +import PingJourney +import PingDeviceProfile + +/** + * A SwiftUI view for handling device profile collection during authentication flows. + * + * This view automatically initiates device profile collection when displayed and shows + * a loading indicator with progress message to inform the user that device profiling is in progress. + * Once the collection completes successfully, it automatically proceeds to the next step in the journey. + * + * The UI displays a centered loading spinner with the message "Gathering Device Profile..." + * during the collection process. The component handles the entire lifecycle of device profile + * collection without requiring user interaction. + */ +struct DeviceProfileCallbackView: View { + + @State private var isLoading: Bool = true + @State private var task: Task? + private let callback: DeviceProfileCallback + private let onNext: () -> Void + + init(callback: DeviceProfileCallback, onNext: @escaping () -> Void) { + self.callback = callback + self.onNext = onNext + } + + var body: some View { + VStack(alignment: .center, spacing: 16) { + if isLoading { + ProgressView() + .progressViewStyle(CircularProgressViewStyle()) + .scaleEffect(1.2) + + Text("Gathering Device Profile...") + .multilineTextAlignment(.center) + .padding(.horizontal) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(16) + .onAppear { + startDeviceProfileCollection() + } + .onDisappear { + cancelCollection() + } + } + + @MainActor + private func startDeviceProfileCollection() { + // Prevent multiple concurrent collections + guard task == nil && isLoading else { return } + + isLoading = true + + task = Task { + do { + _ = await callback.collect() + + if !Task.isCancelled { + await MainActor.run { + self.isLoading = false + self.task = nil // Clear task reference + self.onNext() + } + } + } + } + } + + private func cancelCollection() { + task?.cancel() + task = nil + } +} diff --git a/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Callbacks/DeviceSigningVerifierCallbackView.swift b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Callbacks/DeviceSigningVerifierCallbackView.swift new file mode 100644 index 00000000..ff505328 --- /dev/null +++ b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Callbacks/DeviceSigningVerifierCallbackView.swift @@ -0,0 +1,112 @@ +// +// DeviceSigningVerifierCallbackView.swift +// JourneyModuleSample +// +// 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 SwiftUI +import PingBinding +import Combine + +/** + * A SwiftUI view for handling device signing verification during authentication flows. + * + * This view automatically initiates a cryptographic signing operation to verify device authenticity + * and user authorization. The signing process may involve biometric authentication or PIN collection + * depending on configuration. This is typically used for high-security transactions or step-up + * authentication scenarios. + * + * **User Action Required:** CONDITIONAL - May require biometric authentication or PIN entry + * depending on the device authenticator configuration. Default configuration signs automatically. + * + * The UI displays a loading indicator with status message during the signing process. The component + * supports custom PIN collectors, biometric authenticators, and user key selection through configuration. + */ +struct DeviceSigningVerifierCallbackView: View { + var callback: DeviceSigningVerifierCallback + let onNext: () -> Void + + var body: some View { + VStack { + Text("Device Signing") + .font(.title) + Text("Please wait while we sign the challenge.") + .font(.body) + .padding() + ProgressView() + } + .onAppear(perform: handleDeviceSigning) + } + + private func handleDeviceSigning() { + Task { + /* + For using a custom view for PIN collection, you can provide a CustomPinCollector + through the configuration as shown below: + + let result = await callback.sign { config in + config.pinCollector = CustomPinCollector() + } + + For more advanced configuration with retry logic and custom prompts: + + let result = await callback.sign { config in + let appPinConfig = AppPinConfig( + prompt: Prompt(title: "Verify Identity", subtitle: "Sign Transaction", description: "Enter your PIN to sign"), + pinRetry: 3, + pinCollector: CustomPinCollector() + ) + config.deviceAuthenticator = AppPinAuthenticator(config: appPinConfig) + } + + For biometric authenticators during signing, you can also use BiometricAuthenticatorConfig: + + let result = await callback.sign { config in + let biometricConfig = BiometricAuthenticatorConfig( + keyTag: "my-custom-signing-key" + ) + + // Set the authenticator config - the appropriate authenticator will be used based on callback type + config.authenticatorConfig = biometricConfig + } + + You can also configure with a logger for monitoring signing operations: + + let result = await callback.sign { config in + let customLogger = Logger.logger // or your custom logger implementation + + let biometricConfig = BiometricAuthenticatorConfig( + logger: customLogger, + keyTag: "signing-key-\(callback.userId ?? "default")" + ) + + config.authenticatorConfig = biometricConfig + } + + For custom user key selection when multiple keys are available: + + let result = await callback.sign { config in + // Use a custom UI for selecting from multiple device keys + config.userKeySelector = CustomUserKeySelector() + } + */ + let result = await callback.sign() + switch result { + case .success(let json): + print("Device signing success: \(json)") + case .failure(let error): + if let deviceBindingStatus = error as? DeviceBindingStatus { + print("Device signing failed: \(deviceBindingStatus.errorMessage)") + } else { + print("Device signing failed: \(error.localizedDescription)") + } + } + onNext() + } + } +} + diff --git a/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Callbacks/FidoAuthenticationCallbackView.swift b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Callbacks/FidoAuthenticationCallbackView.swift new file mode 100644 index 00000000..10541a7b --- /dev/null +++ b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Callbacks/FidoAuthenticationCallbackView.swift @@ -0,0 +1,71 @@ +// +// FidoAuthenticationCallbackView.swift +// JourneyModuleSample +// +// 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 SwiftUI +import PingFido +import Combine + +/** + * A SwiftUI view for handling FIDO2/WebAuthn authentication during authentication flows. + * + * This view initiates passwordless authentication using FIDO2 credentials (passkeys) stored on + * the device. When the user taps the authentication button, the system prompts for biometric + * verification (Face ID/Touch ID) or device passcode to authorize the FIDO2 credential usage. + * This provides strong, phishing-resistant authentication. + * + * **User Action Required:** YES - User must: + * 1. Tap the "Authenticate with FIDO" button + * 2. Complete biometric authentication (Face ID/Touch ID) or enter device passcode + * + * The UI displays a button that triggers the FIDO2 authentication ceremony. The system handles + * the biometric prompt and credential selection automatically. + */ +struct FidoAuthenticationCallbackView: View { + var callback: FidoAuthenticationCallback + let onNext: () -> Void + + var body: some View { + VStack { + Text("FIDO Authentication") + .font(.title) + + // 1. Button action still creates a Task + Button(action: { + Task { + // 2. Get the window + guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let window = windowScene.windows.first else { + print("Could not find active window scene.") + // Consider how to handle this UI-wise + return // Exit if no window found + } + + // 3. Call the async function and await its Result + let result = await callback.authenticate(window: window) + + // 4. Handle the Result + switch result { + case .success(let responseDict): + // Optional: Use responseDict if needed + print("FIDO Authentication successful: \(responseDict)") + // Call onNext success + onNext() + case .failure(let error): + // Handle errors + print("FIDO Authentication failed: \(error.localizedDescription)") + onNext() + } + } + }) { + Text("Authenticate with FIDO") + } + } + } +} diff --git a/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Callbacks/FidoRegistrationCallbackView.swift b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Callbacks/FidoRegistrationCallbackView.swift new file mode 100644 index 00000000..4f4cbd3b --- /dev/null +++ b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Callbacks/FidoRegistrationCallbackView.swift @@ -0,0 +1,79 @@ +// +// FidoRegistrationCallbackView.swift +// JourneyModuleSample +// +// 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 SwiftUI +import PingFido + +/** + * A SwiftUI view for handling FIDO2/WebAuthn credential registration during authentication flows. + * + * This view enables users to register a new FIDO2 credential (passkey) on their device for + * passwordless authentication. Users can optionally provide a device name for identification. + * When the user taps the registration button, the system prompts for biometric verification + * (Face ID/Touch ID) or device passcode to create and secure the new credential. + * + * **User Action Required:** YES - User must: + * 1. Optionally enter a device name + * 2. Tap the "Register with FIDO" button + * 3. Complete biometric authentication (Face ID/Touch ID) or enter device passcode + * + * The UI displays a text field for optional device naming and a button to initiate registration. + * The system handles the biometric prompt and credential creation automatically. + */ +struct FidoRegistrationCallbackView: View { + var callback: FidoRegistrationCallback + let onNext: () -> Void + + @State private var deviceName: String = "" + + var body: some View { + VStack { + Text("FIDO Registration") + .font(.title) + TextField("Device Name (Optional)", text: $deviceName) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .padding() + + // 1. Button action still creates a Task + Button(action: { + Task { + // 2. Get the window + guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let window = windowScene.windows.first else { + print("Could not find active window scene.") + // Consider how to handle this UI-wise, maybe disable the button or show an alert + return // Exit if no window found + } + + // 3. Call the async function and await its Result + // We pass nil if the deviceName is empty, otherwise pass the name + let name = deviceName.isEmpty ? nil : deviceName + let result = await callback.register(deviceName: name, window: window) + + // 4. Handle the Result + switch result { + case .success(let responseDict): + // Optional: Use responseDict if needed + print("FIDO Registration successful: \(responseDict)") + // Call onNext on success + onNext() + case .failure(let error): + // Handle errors + print("FIDO Registration failed: \(error.localizedDescription)") + // Optionally: show an alert to the user here + onNext() + } + } + }) { + Text("Register with FIDO") + } + } + } +} diff --git a/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Callbacks/IdpCallbackView.swift b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Callbacks/IdpCallbackView.swift new file mode 100644 index 00000000..49285fe9 --- /dev/null +++ b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Callbacks/IdpCallbackView.swift @@ -0,0 +1,123 @@ +// +// IdpCallbackView.swift +// JourneyModuleSample +// +// 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 Foundation +import SwiftUI +import PingExternalIdP +import Combine + +/** + * A SwiftUI view for handling external identity provider (IdP) authentication during authentication flows. + * + * This view manages the OAuth/OIDC flow with external identity providers such as Google, Facebook, + * Apple, or other social login providers. When the user taps "Continue", the app opens the provider's + * authorization page in a browser, where the user signs in. Upon completion, the app receives the + * authorization result and proceeds. + * + * **User Action Required:** YES - User must: + * 1. Tap the "Continue" button to initiate external IdP flow + * 2. Sign in through the external provider's interface (in browser) + * 3. Authorize the application to access their profile + * + * The UI displays the provider name, a continue button, and status indicators showing authenticating, + * success, or failure states with appropriate icons and messages. + */ +struct IdpCallbackView: View { + @StateObject var viewModel: IdpCallbackViewModel + + let onNext: () -> Void + + var body: some View { + VStack(spacing: 20) { + switch viewModel.authState { + case .authenticating: + Image(systemName: "person.circle.fill") + .font(.largeTitle) + .foregroundColor(.white) + Text(viewModel.callback.provider) + .font(.body) + .foregroundColor(.secondary) + NextButton(title: "Continue") { + Task { @MainActor in + if viewModel.hasStartedAuthorization == false { + // Call the performAuthorization method to start the process. + await viewModel.performAuthorization() + } + } + } + case .failure(let error): + Image(systemName: "xmark.octagon.fill") + .font(.largeTitle) + .foregroundColor(.red) + Text("Authorization failed: \(error.localizedDescription)") + .font(.body) + .multilineTextAlignment(.center) + NextButton(title: "Continue") { + Task { + self.onNext() + } + } + .buttonStyle(.bordered) + + case .completed: + Image(systemName: "checkmark.circle.fill") + .font(.largeTitle) + .foregroundColor(.green) + Text("Authorization Successful") + .font(.body) + .multilineTextAlignment(.center) + NextButton(title: "Continue") { + Task { + self.onNext() + } + } + } + } + .padding() + } +} + +@MainActor +class IdpCallbackViewModel: ObservableObject { + + @Published var authState: AuthState = .authenticating + + // 1. Add a flag to track if the task has been started. + var hasStartedAuthorization = false + + let callback: IdpCallback + + enum AuthState { + case authenticating + case completed + case failure(Error) + } + + init(callback: IdpCallback) { + self.callback = callback + } + + func performAuthorization() async { + Task { @MainActor in + // 2. Check the flag. If the task has already run, do nothing. + guard !hasStartedAuthorization else { return } + hasStartedAuthorization = true + + let result = await callback.authorize() + + switch result { + case .success: + self.authState = .completed + case .failure(let error): + self.authState = .failure(error) + } + } + } +} diff --git a/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Callbacks/KbaCreateCallbackView.swift b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Callbacks/KbaCreateCallbackView.swift new file mode 100644 index 00000000..00e319a0 --- /dev/null +++ b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Callbacks/KbaCreateCallbackView.swift @@ -0,0 +1,130 @@ +// +// KbaCreateCallbackView.swift +// JourneyModuleSample +// +// 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 SwiftUI +import PingJourney + +/** + * A SwiftUI view for creating Knowledge-Based Authentication (KBA) security questions during registration. + * + * This view allows users to select or create a security question and provide an answer. Users can + * choose from predefined questions or create their own custom question if allowed. The question-answer + * pair is used for account recovery or additional authentication verification. The answer is submitted + * when the user presses return. + * + * **User Action Required:** YES - User must: + * 1. Select a security question from the dropdown (or choose "Provide your own") + * 2. Enter a custom question if that option is selected + * 3. Provide an answer to the selected question + * + * The UI displays a picker for question selection, an optional text field for custom questions, + * and a text field for the answer. All fields are styled with rounded borders. + */ +struct KbaCreateCallbackView: View { + let callback: KbaCreateCallback + let onNodeUpdated: () -> Void + + @State var selectedQuestion: String = "" + @State var answerText: String = "" + @State var isCustomQuestion: Bool = false + @State var customQuestionText: String = "" + + private let customQuestionOption = "Provide your own" + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + // Question Picker + VStack(alignment: .leading) { + if !callback.prompt.isEmpty { + Text(callback.prompt) + .font(.headline) + .foregroundColor(.primary) + .multilineTextAlignment(.leading) + } + + Picker(callback.prompt, selection: $selectedQuestion) { + ForEach(callback.predefinedQuestions, id: \.self) { question in + Text(question).tag(question) + } + + // Add "Provide your own" option if allowed + if callback.allowUserDefinedQuestions { + Text(customQuestionOption).tag(customQuestionOption) + } + } + .pickerStyle(.menu) + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + .background( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.gray, lineWidth: 1) + ) + .onChange(of: selectedQuestion) { newValue in + if newValue == customQuestionOption { + isCustomQuestion = true + callback.selectedQuestion = customQuestionText + } else { + isCustomQuestion = false + callback.selectedQuestion = newValue + } + } + } + + // Custom Question Input Field (shown when "Provide your own" is selected) + if isCustomQuestion { + VStack(alignment: .leading) { + TextField( + "Your Question", + text: $customQuestionText + ) + .autocorrectionDisabled() + .textInputAutocapitalization(.sentences) + .padding() + .background( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.gray, lineWidth: 1) + ) + .onChange(of: customQuestionText) { newValue in + callback.selectedQuestion = newValue + } + } + } + + // Answer Input Field + VStack(alignment: .leading) { + TextField( + "Answer", + text: $answerText + ) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .padding() + .background( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.gray, lineWidth: 1) + ) + .onChange(of: answerText) { newValue in + callback.selectedAnswer = newValue + } + .onSubmit { + onNodeUpdated() + } + } + } + .padding() + .onAppear { + selectedQuestion = callback.predefinedQuestions.first ?? "" + answerText = callback.selectedAnswer + if !selectedQuestion.isEmpty { + callback.selectedQuestion = selectedQuestion + } + } + } +} diff --git a/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Callbacks/NameCallbackView.swift b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Callbacks/NameCallbackView.swift new file mode 100644 index 00000000..d9a31dc4 --- /dev/null +++ b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Callbacks/NameCallbackView.swift @@ -0,0 +1,58 @@ +// +// NameCallbackView.swift +// JourneyModuleSample +// +// 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 SwiftUI +import PingJourney + +/** + * A SwiftUI view for capturing username or name input during authentication flows. + * + * This view prompts the user to enter their name or username in a text field. The input is + * validated and submitted when the user presses return or completes editing. The callback + * updates its internal state as the user types, but only commits to the journey node when + * the input is submitted. + * + * **User Action Required:** YES - User must enter their name/username in the text field. + * + * The UI displays a text field with auto-correction disabled and no capitalization. The field + * is styled with a rounded border and updates the callback's internal state on each keystroke. + */ +struct NameCallbackView: View { + let callback: NameCallback + let onNodeUpdated: () -> Void + + @State var text: String = "" + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + TextField( + callback.prompt, + text: $text + ) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .padding() + .background( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.gray, lineWidth: 1) + ) + .onAppear(perform: { + text = callback.name + }) + .onChange(of: text) { newValue in + callback.name = newValue // update internal state only + } + .onSubmit { + onNodeUpdated() // commit to node state only when done + } + .padding() + } + } +} diff --git a/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Callbacks/NumberAttributeInputCallbackView.swift b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Callbacks/NumberAttributeInputCallbackView.swift new file mode 100644 index 00000000..42cc0d10 --- /dev/null +++ b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Callbacks/NumberAttributeInputCallbackView.swift @@ -0,0 +1,78 @@ +// +// NumberAttributeInputCallbackView.swift +// JourneyModuleSample +// +// 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 SwiftUI +import PingJourney + +/** + * A SwiftUI view for capturing numeric attribute values with validation during authentication flows. + * + * This view provides a text field configured for decimal number input with real-time validation. + * Input is filtered to allow only numeric characters and decimal points. The field validates + * against server-defined policies and displays error messages when validation fails. The border + * color changes to red when errors are present. + * + * **User Action Required:** YES - User must enter a valid numeric value. + * + * The UI displays a text field with decimal pad keyboard, validation feedback, and error messages + * below the field when validation policies fail. Common use cases include age, quantity, or + * other numeric profile attributes. + */ +struct NumberAttributeInputCallbackView: View { + let callback: NumberAttributeInputCallback + let onNodeUpdated: () -> Void + + @State var text: String = "" + + private var hasErrors: Bool { + !callback.failedPolicies.isEmpty + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + TextField( + callback.prompt, + text: $text + ) + .keyboardType(.decimalPad) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .padding() + .background( + RoundedRectangle(cornerRadius: 8) + .stroke(hasErrors ? Color.red : Color.gray, lineWidth: 1) + ) + .onAppear(perform: { + text = String(callback.value) + }) + .onChange(of: text) { newValue in + // Validate that input contains only digits and decimal points + let filtered = newValue.filter { $0.isNumber || $0 == "." } + if filtered != newValue { + text = filtered + } + + // Update field value if valid number + if !text.isEmpty, let doubleValue = Double(text) { + callback.value = doubleValue + } + } + .onSubmit { + onNodeUpdated() + } + + // Error message display + if hasErrors { + ErrorMessageView(errors: callback.failedPolicies.map({ $0.failedDescription(for: callback.prompt)})) + } + } + .padding() + } +} diff --git a/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Callbacks/PasswordCallbackView.swift b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Callbacks/PasswordCallbackView.swift new file mode 100644 index 00000000..6b46935e --- /dev/null +++ b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Callbacks/PasswordCallbackView.swift @@ -0,0 +1,59 @@ +// +// PasswordCallbackView.swift +// JourneyModuleSample +// +// 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 SwiftUI +import PingJourney + +/** + * A SwiftUI view for capturing password input during authentication flows. + * + * This view provides a secure password input field with visibility toggle functionality. + * The password is masked by default but can be revealed using the visibility toggle. + * The input is submitted when the user presses return or completes editing. + * + * **User Action Required:** YES - User must enter their password in the secure field. + * + * The UI displays a SecureFieldView component with password visibility toggle. + * The field updates the callback's internal state on each keystroke and commits + * to the journey node when submitted. + */ +struct PasswordCallbackView: View { + + var callback: PasswordCallback + var onNodeUpdated: () -> Void + + @State var text: String = "" + @State private var passwordVisibility: Bool = false + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + // Password Input Field + VStack(alignment: .leading) { + SecureFieldView( + label: callback.prompt, + value: $text, + isPasswordVisible: $passwordVisibility, + onValueChange: { value in + callback.password = value + }, onAppear: { + text = callback.password + }, + isError: false, + errorMessages: [""] + ) + .onSubmit { + onNodeUpdated() + } + } + } + .padding() + } +} + diff --git a/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Callbacks/PinCollectorView.swift b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Callbacks/PinCollectorView.swift new file mode 100644 index 00000000..cebd0210 --- /dev/null +++ b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Callbacks/PinCollectorView.swift @@ -0,0 +1,95 @@ + +// +// PinCollectorView.swift +// JourneyModuleSample +// +// 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 SwiftUI +import PingBinding +import Combine + +/** + * A SwiftUI view for collecting a 4-digit PIN during device binding or signing operations. + * + * This view displays a PIN entry interface with a numeric keypad, automatically limiting input + * to exactly 4 digits. The interface includes a title, description from the provided prompt, + * and action buttons for canceling or submitting the PIN. The PIN field is automatically focused + * when the view appears for immediate user input. + * + * **User Action Required:** YES - User must: + * 1. Enter a 4-digit PIN using the numeric keypad + * 2. Tap "Submit" to confirm the PIN, or "Cancel" to abort the operation + * + * The UI displays: + * - Title and description from the Prompt object + * - Centered text field with numeric keypad, limited to 4 digits + * - Cancel button (red) - returns nil to indicate cancellation + * - Submit button (blue) - returns the entered PIN (disabled until 4 digits are entered) + * + * This view is typically presented modally by CustomPinCollector when device binding or + * signing operations require PIN authentication. + */ +struct PinCollectorView: View { + let prompt: Prompt + let completion: (String?) -> Void + + @State private var pin: String = "" + @FocusState private var isPinFocused: Bool + + var body: some View { + VStack(spacing: 20) { + Text(prompt.title) + .font(.title) + Text(prompt.description) + .font(.subheadline) + .foregroundColor(.secondary) + + TextField("4-digit PIN", text: $pin) + .keyboardType(.numberPad) + .focused($isPinFocused) + .onChange(of: pin) { newValue in + // Limit to 4 digits + if newValue.count > 4 { + pin = String(newValue.prefix(4)) + } + } + .padding() + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.gray, lineWidth: 1) + ) + .multilineTextAlignment(.center) + .font(.title2) + + HStack { + Button("Cancel") { + completion(nil) + } + .padding() + .frame(maxWidth: .infinity) + .background(Color.red) + .foregroundColor(.white) + .cornerRadius(8) + + Button("Submit") { + completion(pin) + } + .padding() + .frame(maxWidth: .infinity) + .background(Color.blue) + .foregroundColor(.white) + .cornerRadius(8) + .disabled(pin.count != 4) + } + } + .padding() + .onAppear { + isPinFocused = true + } + } +} diff --git a/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Callbacks/PingOneProtectEvaluationCallbackView.swift b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Callbacks/PingOneProtectEvaluationCallbackView.swift new file mode 100644 index 00000000..fa2d4dcb --- /dev/null +++ b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Callbacks/PingOneProtectEvaluationCallbackView.swift @@ -0,0 +1,108 @@ +// +// PingOneProtectEvaluationCallbackView.swift +// JourneyModuleSample +// +// 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 SwiftUI +import PingProtect +import Combine + +/** + * A SwiftUI view for collecting PingOne Protect device profile data during authentication flows. + * + * This view automatically initiates device profile data collection when displayed, gathering + * device signals and behavioral data for risk assessment. A loading indicator is shown for a + * minimum duration to provide user feedback. Once collection completes, the view automatically + * proceeds to the next step. This data helps detect fraud and assess authentication risk. + * + * **User Action Required:** NO - The data collection process is fully automatic. + * + * The UI displays a centered loading spinner with the message "Collecting device profile..." + * The view ensures a minimum display time for better UX even if collection completes quickly. + */ +struct PingOneProtectEvaluationCallbackView: View { + @StateObject private var viewModel: PingOneProtectEvaluationViewModel + + init(callback: PingOneProtectEvaluationCallback, onNext: @escaping () -> Void) { + self._viewModel = StateObject(wrappedValue: PingOneProtectEvaluationViewModel(callback: callback, onNext: onNext)) + } + + var body: some View { + VStack(spacing: 16) { + ProgressView() + .progressViewStyle(CircularProgressViewStyle()) + .scaleEffect(1.5) + + Text("Collecting device profile ...") + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding() + .onAppear { + viewModel.startEvaluationIfNeeded() + } + .onDisappear { + viewModel.cancelEvaluation() + } + } +} + +class PingOneProtectEvaluationViewModel: ObservableObject { + @Published var isLoading: Bool = true + + nonisolated(unsafe) private var task: Task? + private let callback: PingOneProtectEvaluationCallback + private let onNext: () -> Void + private var hasStartedEvaluation = false + + init(callback: PingOneProtectEvaluationCallback, onNext: @escaping () -> Void) { + self.callback = callback + self.onNext = onNext + } + + @MainActor + func startEvaluationIfNeeded() { + // Guard against multiple evaluation attempts + guard !hasStartedEvaluation else { return } + + hasStartedEvaluation = true + isLoading = true + let startTime = Date() + + task = Task { + // Execute the evaluation + _ = await callback.collect() + + // Calculate task duration + let taskDuration = Date().timeIntervalSince(startTime) + + // If task completed too quickly, delay to meet minimum display time + let minimumDisplayTime: TimeInterval = 2.0 + let remainingTime = minimumDisplayTime - taskDuration + if remainingTime > 0 { + try? await Task.sleep(nanoseconds: UInt64(remainingTime * 1_000_000_000)) + } + + if !Task.isCancelled { + await MainActor.run { + self.isLoading = false + self.onNext() + } + } + } + } + + nonisolated func cancelEvaluation() { + task?.cancel() + task = nil + } + + deinit { + cancelEvaluation() + } +} diff --git a/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Callbacks/PingOneProtectInitializeCallbackView.swift b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Callbacks/PingOneProtectInitializeCallbackView.swift new file mode 100644 index 00000000..152c565d --- /dev/null +++ b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Callbacks/PingOneProtectInitializeCallbackView.swift @@ -0,0 +1,108 @@ +// +// PingOneProtectInitializeCallbackView.swift +// JourneyModuleSample +// +// 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 SwiftUI +import PingProtect +import Combine + +/** + * A SwiftUI view for initializing PingOne Protect device profiling during authentication flows. + * + * This view automatically initiates the PingOne Protect SDK initialization when displayed, which + * prepares the device profiling and risk assessment capabilities. A loading indicator is shown + * for a minimum duration to provide user feedback. Once initialization completes, the view + * automatically proceeds to the next step. + * + * **User Action Required:** NO - The initialization process is fully automatic. + * + * The UI displays a centered loading spinner with the message "Initializing device profile collection..." + * The view ensures a minimum display time for better UX even if initialization completes quickly. + */ +struct PingOneProtectInitializeCallbackView: View { + @StateObject private var viewModel: PingOneProtectInitializeViewModel + + init(callback: PingOneProtectInitializeCallback, onNext: @escaping () -> Void) { + self._viewModel = StateObject(wrappedValue: PingOneProtectInitializeViewModel(callback: callback, onNext: onNext)) + } + + var body: some View { + VStack(spacing: 16) { + ProgressView() + .progressViewStyle(CircularProgressViewStyle()) + .scaleEffect(1.5) + + Text("Initializing device profile collection...") + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding() + .onAppear { + viewModel.startInitializationIfNeeded() + } + .onDisappear { + viewModel.cancelInitialization() + } + } +} + +class PingOneProtectInitializeViewModel: ObservableObject { + @Published var isLoading: Bool = true + + nonisolated(unsafe) private var task: Task? + private let callback: PingOneProtectInitializeCallback + private let onNext: () -> Void + private var hasStartedInitialization = false + + init(callback: PingOneProtectInitializeCallback, onNext: @escaping () -> Void) { + self.callback = callback + self.onNext = onNext + } + + @MainActor + func startInitializationIfNeeded() { + // Guard against multiple initialization attempts + guard !hasStartedInitialization else { return } + + hasStartedInitialization = true + isLoading = true + let startTime = Date() + + task = Task { + // Execute the initialization + _ = await callback.start() + + // Calculate task duration + let taskDuration = Date().timeIntervalSince(startTime) + + // If task completed too quickly, delay to meet minimum display time + let minimumDisplayTime: TimeInterval = 2.0 + let remainingTime = minimumDisplayTime - taskDuration + if remainingTime > 0 { + try? await Task.sleep(nanoseconds: UInt64(remainingTime * 1_000_000_000)) + } + + if !Task.isCancelled { + await MainActor.run { + self.isLoading = false + self.onNext() + } + } + } + } + + nonisolated func cancelInitialization() { + task?.cancel() + task = nil + } + + deinit { + cancelInitialization() + } +} diff --git a/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Callbacks/PollingWaitCallbackView.swift b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Callbacks/PollingWaitCallbackView.swift new file mode 100644 index 00000000..0edf309b --- /dev/null +++ b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Callbacks/PollingWaitCallbackView.swift @@ -0,0 +1,107 @@ +// +// PollingWaitCallbackView.swift +// JourneyModuleSample +// +// 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 SwiftUI +import PingJourney +import Combine + +/** + * A SwiftUI view for displaying a polling wait state during authentication flows. + * + * This view shows a progress indicator while waiting for a server-side operation to complete, + * such as waiting for out-of-band authentication, email verification, or external approval. + * The view displays a message and animated progress indicator for the specified wait duration. + * Once the timeout expires, it automatically triggers the next polling attempt. + * + * **User Action Required:** NO - The wait and polling process is fully automatic. + * + * The UI displays the server-provided message and a circular progress indicator that smoothly + * animates from 0% to 100% over the wait duration. Common use cases include push notification + * approval waits or QR code scanning timeouts. + */ +struct PollingWaitCallbackView: View { + @StateObject private var viewModel: PollingWaitViewModel + + init(callback: PollingWaitCallback, onTimeout: @escaping () -> Void) { + self._viewModel = StateObject(wrappedValue: PollingWaitViewModel(callback: callback, onTimeout: onTimeout)) + } + + var body: some View { + VStack(alignment: .center, spacing: 16) { + Text(viewModel.message) + .multilineTextAlignment(.center) + .padding(.horizontal) + + ProgressView(value: viewModel.progress, total: 1.0) + .progressViewStyle(CircularProgressViewStyle()) + .scaleEffect(1.5) + } + .padding() + .onAppear { + viewModel.startPolling() + } + .onDisappear { + viewModel.cancelPolling() + } + } +} + + +class PollingWaitViewModel: ObservableObject { + @Published var progress: Double = 0.0 + + nonisolated(unsafe) private var task: Task? + private let callback: PollingWaitCallback + private let onTimeout: () -> Void + + var message: String { + callback.message + } + + init(callback: PollingWaitCallback, onTimeout: @escaping () -> Void) { + self.callback = callback + self.onTimeout = onTimeout + } + + @MainActor + func startPolling() { + progress = 0.0 + let waitTimeInSeconds = Double(callback.waitTime) / 1000.0 + let updateInterval = 0.1 // Update progress every 100ms for smooth animation + let totalSteps = waitTimeInSeconds / updateInterval + + task = Task { + for step in 0.. Void = { _ in }, + onNext: @escaping () -> Void + ) { + _viewModel = StateObject(wrappedValue: ReCaptchaViewModel( + callback: callback, + onNext: onNext + )) + } + + var body: some View { + VStack(alignment: .center, spacing: 16) { + if viewModel.isLoading { + ProgressView() + .progressViewStyle(CircularProgressViewStyle()) + .scaleEffect(1.2) + + Text("Verifying security...") + .multilineTextAlignment(.center) + .padding(.horizontal) + } else if let error = viewModel.errorMessage { + VStack(spacing: 16) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 48)) + .foregroundColor(.orange) + + Text("Verification Failed") + .font(.headline) + + Text(error) + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + + Button("Retry") { + viewModel.retry() + } + .buttonStyle(.borderedProminent) + .padding(.top, 8) + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(16) + .onAppear { + viewModel.startVerification() + } + .onDisappear { + viewModel.cancel() + } + } +} + + + +@MainActor +class ReCaptchaViewModel: ObservableObject { + @Published var isLoading: Bool = true + @Published var errorMessage: String? + @Published var hasCompleted: Bool = false + + private var task: Task? + private let callback: ReCaptchaEnterpriseCallback + private let onNext: () -> Void + + init(callback: ReCaptchaEnterpriseCallback, onNext: @escaping () -> Void) { + self.callback = callback + self.onNext = onNext + } + + func startVerification() { + guard task == nil, !hasCompleted else { return } + + isLoading = true + errorMessage = nil + + task = Task { [weak self] in + guard let self = self else { return } + let result = await self.callback.verify{ config in + // Optionally customize the configuration + config.payload = ["firewallPolicyEvaluation": false, + "transactionData": [ + "transactionId": "TXN-12345", + "paymentMethod": "CREDIT_CARD", + "cardBin": "123456", + "cardLastFour": "1234", + "currencyCode": "USD", + "value": 99.99 + ], + "userInfo": [ + "accountId": "user-abc123", + ] + ]} + + if !Task.isCancelled { + await MainActor.run { + self.task = nil + + switch result { + case .success: + self.hasCompleted = true + self.isLoading = false + self.onNext() + case .failure(let error): + self.isLoading = false + self.errorMessage = error.localizedDescription + } + } + } + } + } + + func retry() { + errorMessage = nil + hasCompleted = false + startVerification() + } + + func cancel() { + task?.cancel() + task = nil + } +} diff --git a/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Callbacks/SelectIdpCallbackView.swift b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Callbacks/SelectIdpCallbackView.swift new file mode 100644 index 00000000..4ff01f65 --- /dev/null +++ b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Callbacks/SelectIdpCallbackView.swift @@ -0,0 +1,58 @@ +// +// SelectIdpCallbackView.swift +// JourneyModuleSample +// +// 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 SwiftUI +import PingExternalIdP +import Combine + +/** + * A SwiftUI view for selecting an external identity provider during authentication flows. + * + * This view presents a list of available external identity providers (e.g., Google, Facebook, Apple) + * for the user to choose from. When a provider is selected, the callback records the choice and + * immediately proceeds to the IdP authentication flow. This is commonly used when multiple social + * login options are available. + * + * **User Action Required:** YES - User must select one identity provider from the available options. + * + * The UI displays a scrollable list of provider buttons, each styled prominently and labeled with + * the provider name. The provider names are automatically capitalized for consistency. + */ +struct SelectIdpCallbackView: View { + let callback: SelectIdpCallback + let onNext: () -> Void + + var body: some View { + ScrollView { + + LazyVStack(alignment: .center, spacing: 12) { + + // Add a title for better context + Text("Select a provider") + .font(.headline) + .padding(.bottom, 8) + + ForEach(callback.providers) { provider in + Button(action: { + callback.value = provider.provider + self.onNext() + }) { + // Make the button label more descriptive and visually appealing + Text(provider.provider.capitalized) + .fontWeight(.semibold) + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + } + } + } + .padding() + } +} diff --git a/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Callbacks/StringAttributeInputCallbackView.swift b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Callbacks/StringAttributeInputCallbackView.swift new file mode 100644 index 00000000..17a8de25 --- /dev/null +++ b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Callbacks/StringAttributeInputCallbackView.swift @@ -0,0 +1,68 @@ +// +// StringAttributeInputCallbackView.swift +// JourneyModuleSample +// +// 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 SwiftUI +import PingJourney + +/** + * A SwiftUI view for capturing text-based attribute values with validation during authentication flows. + * + * This view provides a text field for freeform text input with server-side validation. The field + * validates against defined policies and displays error messages when validation fails. The border + * color changes to red when errors are present. Common use cases include email addresses, phone + * numbers, postal codes, or other validated string attributes. + * + * **User Action Required:** YES - User must enter a valid text value. + * + * The UI displays a text field with auto-correction disabled, validation feedback, and error + * messages below the field when validation policies fail. Errors are displayed with descriptive + * messages from the server. + */ +struct StringAttributeInputCallbackView: View { + let callback: StringAttributeInputCallback + let onNodeUpdated: () -> Void + + @State var text: String = "" + + private var hasErrors: Bool { + !callback.failedPolicies.isEmpty + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + TextField( + callback.prompt, + text: $text + ) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .padding() + .background( + RoundedRectangle(cornerRadius: 8) + .stroke(hasErrors ? Color.red : Color.gray, lineWidth: 1) + ) + .onAppear(perform: { + text = callback.value + }) + .onChange(of: text) { newValue in + callback.value = newValue + } + .onSubmit { + onNodeUpdated() + } + + // Error message display + if hasErrors { + ErrorMessageView(errors: callback.failedPolicies.map({ $0.failedDescription(for: callback.prompt)})) + } + } + .padding() + } +} diff --git a/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Callbacks/TermsAndConditionsCallbackView.swift b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Callbacks/TermsAndConditionsCallbackView.swift new file mode 100644 index 00000000..b7cf33ee --- /dev/null +++ b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Callbacks/TermsAndConditionsCallbackView.swift @@ -0,0 +1,70 @@ +// +// TermsAndConditionsCallbackView.swift +// JourneyModuleSample +// +// 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 SwiftUI +import PingJourney +import Combine + +/** + * A SwiftUI view for presenting and accepting terms and conditions during authentication flows. + * + * This view displays the terms and conditions text along with version and creation date information. + * Users must review the terms and toggle their acceptance before proceeding. This is commonly used + * during registration or when terms are updated and require re-acceptance. The toggle reflects + * the current acceptance state. + * + * **User Action Required:** YES - User must toggle the switch to accept the terms and conditions. + * + * The UI displays the version, creation date, full terms text, and an acceptance toggle switch. + * All information is presented in a clear hierarchy with appropriate styling. + */ +struct TermsAndConditionsCallbackView: View { + let callback: TermsAndConditionsCallback + let onNodeUpdated: () -> Void + + @State var accepted: Bool = false + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + // Version + if !callback.version.isEmpty { + Text(callback.version) + .font(.headline) + .foregroundColor(.primary) + } + + // Create Date + if !callback.createDate.isEmpty { + Text(callback.createDate) + .font(.headline) + .foregroundColor(.primary) + } + + // Terms Text + if !callback.terms.isEmpty { + Text(callback.terms) + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.leading) + } + + // Acceptance Toggle + Toggle("I accept the terms and conditions", isOn: $accepted) + .toggleStyle(SwitchToggleStyle()) + .onChange(of: accepted) { newValue in + callback.accepted = newValue + } + } + .padding() + .onAppear { + accepted = callback.accepted + } + } +} diff --git a/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Callbacks/TextInputCallbackView.swift b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Callbacks/TextInputCallbackView.swift new file mode 100644 index 00000000..72000b58 --- /dev/null +++ b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Callbacks/TextInputCallbackView.swift @@ -0,0 +1,58 @@ +// +// TextInputCallbackView.swift +// JourneyModuleSample +// +// 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 SwiftUI +import PingJourney + +/** + * A SwiftUI view for capturing general text input during authentication flows. + * + * This view provides a basic text field for freeform text input without validation. It is used + * for capturing various text-based information such as display names, comments, or other + * non-validated string data. The input updates the callback's internal state on each keystroke + * and commits to the journey node when submitted. + * + * **User Action Required:** YES - User must enter text in the field. + * + * The UI displays a text field with auto-correction disabled and no capitalization, styled + * with a rounded border. The field is pre-populated with any existing value from the callback. + */ +struct TextInputCallbackView: View { + let callback: TextInputCallback + let onNodeUpdated: () -> Void + + @State var text: String = "" + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + TextField( + callback.prompt, + text: $text + ) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .padding() + .background( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.gray, lineWidth: 1) + ) + .onAppear(perform: { + text = callback.text + }) + .onChange(of: text) { newValue in + callback.text = newValue // update internal state only + } + .onSubmit { + onNodeUpdated() // commit to node state only when done + } + .padding() + } + } +} diff --git a/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Callbacks/TextOutputCallbackView.swift b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Callbacks/TextOutputCallbackView.swift new file mode 100644 index 00000000..289726cc --- /dev/null +++ b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Callbacks/TextOutputCallbackView.swift @@ -0,0 +1,73 @@ +// +// TextOutputCallbackView.swift +// JourneyModuleSample +// +// 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 SwiftUI +import PingJourney + +/** + * A SwiftUI view for displaying informational, warning, or error messages during authentication flows. + * + * This view presents read-only text messages with contextual icons based on message type + * (information, warning, error, or default). It is used to provide feedback or instructions + * to the user without requiring any input. The message is displayed with an appropriate + * icon and color scheme to indicate severity or importance. + * + * **User Action Required:** NO - This is a display-only component for informational purposes. + * + * The UI displays an icon (info, warning, or error) alongside the message text. Colors are + * automatically applied based on message type: blue for information, orange for warnings, + * red for errors, and gray for default messages. + */ +struct TextOutputCallbackView: View { + let callback: TextOutputCallback + + var body: some View { + HStack(spacing: 8) { + // Icon based on message type + Image(systemName: iconName) + .foregroundColor(iconColor) + .font(.title2) + + // Message text + Text(callback.message) + .font(.headline) + .foregroundColor(.primary) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding() + } + + private var iconName: String { + switch callback.messageType { + case .information: + return "info.circle.fill" + case .warning: + return "exclamationmark.triangle.fill" + case .error: + return "xmark.circle.fill" + default: + return "gear.circle.fill" + } + } + + private var iconColor: Color { + switch callback.messageType { + case .information: + return .blue + case .warning: + return .orange + case .error: + return .red + default: + return .gray + } + } +} diff --git a/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Callbacks/ValidatedPasswordCallbackView.swift b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Callbacks/ValidatedPasswordCallbackView.swift new file mode 100644 index 00000000..95542c7d --- /dev/null +++ b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Callbacks/ValidatedPasswordCallbackView.swift @@ -0,0 +1,59 @@ +// +// ValidatedPasswordCallbackView.swift +// JourneyModuleSample +// +// 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 SwiftUI +import PingJourney +import Combine + +/** + * A SwiftUI view for capturing password input with server-side validation during authentication flows. + * + * This view provides a secure password input field with visibility toggle and real-time validation + * against server-defined password policies. The field displays error messages when the password + * fails to meet requirements (e.g., minimum length, complexity, character types). Commonly used + * during registration or password change flows. + * + * **User Action Required:** YES - User must enter a password that meets validation requirements. + * + * The UI displays a SecureFieldView with password visibility toggle, validation feedback, and + * error messages when policies fail. The field appearance changes to indicate error states. + */ +struct ValidatedPasswordCallbackView: View { + let callback: ValidatedPasswordCallback + let onNodeUpdated: () -> Void + + @State var text: String = "" + @State private var passwordVisibility: Bool = false + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + // Password Input Field + VStack(alignment: .leading) { + SecureFieldView( + label: callback.prompt, + value: $text, + isPasswordVisible: $passwordVisibility, + onValueChange: { value in + callback.password = value + }, + onAppear: { + text = callback.password + }, + isError: !callback.failedPolicies.isEmpty, + errorMessages: callback.failedPolicies.isEmpty ? [] : callback.failedPolicies.map({ $0.failedDescription(for: callback.prompt)}) + ) + .onSubmit { + onNodeUpdated() + } + } + } + .padding() + } +} diff --git a/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Callbacks/ValidatedUsernameCallbackView.swift b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Callbacks/ValidatedUsernameCallbackView.swift new file mode 100644 index 00000000..936a9ad7 --- /dev/null +++ b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Callbacks/ValidatedUsernameCallbackView.swift @@ -0,0 +1,67 @@ +// +// ValidatedUsernameCallbackView.swift +// JourneyModuleSample +// +// 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 SwiftUI +import PingJourney + +/** + * A SwiftUI view for capturing username input with server-side validation during authentication flows. + * + * This view provides a text field for username input with real-time validation against server-defined + * policies. The field validates username format, availability, and other requirements, displaying + * error messages when validation fails. The border color changes to red when errors are present. + * Commonly used during user registration. + * + * **User Action Required:** YES - User must enter a valid username that meets all requirements. + * + * The UI displays a text field with auto-correction disabled, validation feedback, and error + * messages below the field when validation policies fail (e.g., username taken, invalid format). + */ +struct ValidatedUsernameCallbackView: View { + let callback: ValidatedUsernameCallback + let onNodeUpdated: () -> Void + + @State var text: String = "" + + private var hasErrors: Bool { + !callback.failedPolicies.isEmpty + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + TextField( + callback.prompt, + text: $text + ) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .padding() + .background( + RoundedRectangle(cornerRadius: 8) + .stroke(hasErrors ? Color.red : Color.gray, lineWidth: 1) + ) + .onAppear(perform: { + text = callback.username + }) + .onChange(of: text) { newValue in + callback.username = newValue + } + .onSubmit { + onNodeUpdated() + } + + // Error message display + if hasErrors { + ErrorMessageView(errors: callback.failedPolicies.map({ $0.failedDescription(for: callback.prompt)})) + } + } + .padding() + } +} diff --git a/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Info.plist b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Info.plist new file mode 100644 index 00000000..0bb4c4bc --- /dev/null +++ b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Info.plist @@ -0,0 +1,64 @@ + + + + + NSFaceIDUsageDescription + Use FaceID + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLName + callback + CFBundleURLSchemes + + myapp + + + + CFBundleTypeRole + Editor + CFBundleURLSchemes + + [Enter Google Client ID] + + + + CFBundleTypeRole + Editor + CFBundleURLSchemes + + [Enter Facebook App ID] + + + + CFBundleTypeRole + Editor + CFBundleURLName + org.forgerock.demo + CFBundleURLSchemes + + oauth2redirect + + + + FacebookAdvertiserIDCollectionEnabled + + FacebookAppID + [Enter Facebook App ID] + FacebookClientToken + [Enter FB Client Token] + FacebookDisplayName + PingExample + GIDClientID + [Enter Google Client ID] + GIDServerClientID + [Enter Google Server Client ID] + LSApplicationQueriesSchemes + + fbapi + fb-messenger-share-api + + + diff --git a/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/JourneyModuleSample.entitlements b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/JourneyModuleSample.entitlements new file mode 100644 index 00000000..9ce12bf0 --- /dev/null +++ b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/JourneyModuleSample.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.developer.applesignin + + Default + + com.apple.developer.associated-domains + + webcredentials:example.com + + + diff --git a/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/JourneyModuleSampleApp.swift b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/JourneyModuleSampleApp.swift new file mode 100644 index 00000000..84c0d081 --- /dev/null +++ b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/JourneyModuleSampleApp.swift @@ -0,0 +1,42 @@ +// +// JourneyModuleSampleApp.swift +// JourneyModuleSample +// +// 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 SwiftUI +import PingExternalIdPFacebook +import PingExternalIdPGoogle +import PingBrowser +import PingDeviceId +import PingTamperDetector +import PingOidc +import PingProtect +import PingBinding +import PingOath +import PingPush + +@main +struct JourneyModuleSampleApp: App { + + // Create an instance of the manager. + // @StateObject ensures it's kept alive for the app's lifecycle. + @StateObject private var sceneManager = ScenePhaseManager() + + var body: some Scene { + WindowGroup { + ContentView() + .onOpenURL { url in + let handled = GoogleRequestHandler.handleOpenURL(UIApplication.shared, url: url, options: nil) + if !handled { + FacebookRequestHandler.handleOpenURL(UIApplication.shared, url: url, options: nil) + } + OpenURLMonitor.shared.handleOpenURL(url) + } + .environmentObject(sceneManager) + } + } +} diff --git a/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/ScenePhaseManager.swift b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/ScenePhaseManager.swift new file mode 100644 index 00000000..a1e460cd --- /dev/null +++ b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/ScenePhaseManager.swift @@ -0,0 +1,66 @@ +// +// ScenePhaseManager.swift +// JourneyModuleSample +// +// 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 Foundation +import Combine +import UIKit // Required for UIScene notifications +import PingBrowser + +enum ScenePhase { + case active + case background + case unknown + + var description: String { + switch self { + case .active: return "✅ Active" + case .background: return "BACKGROUND - Inactive" + case .unknown: return "Unknown" + } + } +} + +class ScenePhaseManager: ObservableObject { + // A property to track the current phase, which views can observe. + @Published var currentPhase: ScenePhase = .unknown + + // A set to store the notification subscriptions to manage their lifecycle. + private var cancellables = Set() + + init() { + print("ScenePhaseManager initialised. Subscribing to notifications.") + + // Subscribe to the notification for when the scene becomes active + NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification) + .receive(on: RunLoop.main) // Ensure updates happen on the main thread + .sink { [weak self] _ in + self?.currentPhase = .active + Task { @MainActor in + print("Scene is entering the foreground.") + if BrowserLauncher.currentBrowser.isInProgress { + BrowserLauncher.currentBrowser.handleAppActivation() + } + } + } + .store(in: &cancellables) + + // Subscribe to the notification for when the scene enters the background + NotificationCenter.default.publisher(for: UIApplication.didEnterBackgroundNotification) + .sink { [weak self] _ in + self?.currentPhase = .background + print("Scene has entered the background.") + } + .store(in: &cancellables) + } + + deinit { + print("ScenePhaseManager deinitialised. Cancellables will be released.") + } +} diff --git a/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/ViewModels/AccessTokenViewModel.swift b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/ViewModels/AccessTokenViewModel.swift new file mode 100644 index 00000000..cebdb3a1 --- /dev/null +++ b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/ViewModels/AccessTokenViewModel.swift @@ -0,0 +1,55 @@ +// +// AccessTokenViewModel.swift +// JourneyModuleSample +// +// 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 Foundation +import PingLogger +import PingJourney +import PingOidc +import Combine + +/// A view model responsible for managing the access token state. +/// - This class handles fetching the access token using the DaVinci SDK and logs the results. +/// - Provides an observable published property for UI updates. +@MainActor +class AccessTokenViewModel: ObservableObject { + /// Published property to hold the current access token. + /// - Updates are published to the UI whenever the value changes. + @Published var token: String = "" + + /// Initializes the `TokenViewModel` and fetches the access token asynchronously. + init() { + Task { + await accessToken() + } + } + + /// Fetches the access token using the DaVinci SDK. + /// - The method checks for a successful token retrieval and updates the `accessToken` property. + /// - Logs the success or failure result using `PingLogger`. + func accessToken() async { + let token: Result? = await journey.journeyUser()?.token() + + switch token { + case .success(let token): + await MainActor.run { + self.token = String(describing: token) + } + LogManager.standard.i("AccessToken: \(self.token)") + case .failure(let error): + await MainActor.run { + self.token = "Error: \(error.localizedDescription)" + } + LogManager.standard.e("", error: error) + case .none: + break + } + } +} diff --git a/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/ViewModels/BindingKeysViewModel.swift b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/ViewModels/BindingKeysViewModel.swift new file mode 100644 index 00000000..74b8e68d --- /dev/null +++ b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/ViewModels/BindingKeysViewModel.swift @@ -0,0 +1,46 @@ +// +// BindingKeysViewModel.swift +// JourneyModuleSample +// +// 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 Foundation +import PingBinding +import PingCommons +import Combine + +@MainActor +class BindingKeysViewModel: ObservableObject { + + @Published var userKeys: [UserKey] = [] + + func fetchKeys() async { + do { + self.userKeys = try await BindingModule.getAllKeys() + } catch { + print("Error fetching keys: \(error)") + } + } + + func deleteKey(key: UserKey) async { + do { + try await BindingModule.deleteKey(key) + await fetchKeys() + } catch { + print("Error deleting key: \(error)") + } + } + + func deleteAllKeys() async { + do { + try await BindingModule.deleteAllKeys() + await fetchKeys() + } catch { + print("Error deleting all keys: \(error)") + } + } +} diff --git a/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/ViewModels/DeviceInfoViewModel.swift b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/ViewModels/DeviceInfoViewModel.swift new file mode 100644 index 00000000..21e6b42a --- /dev/null +++ b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/ViewModels/DeviceInfoViewModel.swift @@ -0,0 +1,77 @@ +// +// DeviceInfoViewModel.swift +// JourneyModuleSample +// +// 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 SwiftUI +import PingLogger +import PingDeviceProfile +import PingCommons +import Combine + +/// A view model responsible for fetching and managing device information. +/// - Provides a published `deviceInfo` property that is updated with device information or error messages. +/// - Collects device information asynchronously. +@MainActor +class DeviceInfoViewModel: ObservableObject { + /// Published property to hold the device information or error messages. + @Published var deviceInfo: String = "" + + /// Initializes the `DeviceInfoViewModel` and collects device information. + /// - The data is fetched asynchronously during initialization. + init() { + Task { + await collecDeviceInfo() + } + } + + /// Collects device information + /// - The method retrieves device info as a dictionary and formats them as a string for display. + /// - Updates the `DeviceInfo` property with the collected info or an error message. + /// - Logs success and error messages using `PingLogger`. + func collecDeviceInfo() async { + // Create configuration with server settings + let config = DeviceProfileConfig() + config.metadata = true + config.location = true + config.collectors { + return [ + PlatformCollector(), + HardwareCollector(), + BrowserCollector(), + TelephonyCollector(), + NetworkCollector(), + BluetoothCollector(), + LocationCollector() + ] + } + + do { + // Perform device profile collection + let collector = DeviceProfileCollector(config: config) + guard let deviceProfile = try await collector.collect() else { + throw DeviceProfileError.collectionFailed + } + + // Encode results to JSON + let jsonData = try JSONEncoder().encode(deviceProfile) + guard let profileDict = try JSONSerialization.jsonObject(with: jsonData) as? [String: Any] else { + throw DeviceProfileError.serializationFailed + } + + // Submit to server + deviceInfo = JSONUtils.jsonStringify(value: profileDict as AnyObject, prettyPrinted: true) + LogManager.standard.i("Device Binding Result: \n\(deviceInfo)") + + } catch { + deviceInfo = "Error: \(error.localizedDescription)" + LogManager.standard.e("Failed to Collect Device Info", error: error) + } + } +} diff --git a/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/ViewModels/DeviceManagementViewModel.swift b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/ViewModels/DeviceManagementViewModel.swift new file mode 100644 index 00000000..394e7195 --- /dev/null +++ b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/ViewModels/DeviceManagementViewModel.swift @@ -0,0 +1,413 @@ +// +// DeviceManagementViewModel.swift +// JourneyModuleSample +// +// 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 SwiftUI +import PingLogger +import PingOrchestrate +import PingStorage +import PingDeviceClient +import PingJourney +import PingOidc +import Combine + +/// Enum representing different device types for the UI +enum DeviceType: String, CaseIterable, Identifiable { + case oath = "Oath" + case push = "Push" + case bound = "Bound" + case profile = "Profile" + case webAuthn = "WebAuthn" + + var id: String { rawValue } + + var icon: String { + switch self { + case .oath: return "timer" + case .push: return "bell.fill" + case .bound: return "link" + case .profile: return "person.crop.circle" + case .webAuthn: return "key.fill" + } + } + +} + +/// ViewModel for managing devices +@MainActor +class DeviceManagementViewModel: ObservableObject { + // MARK: - Published Properties + + @Published var oathDevices: [OathDevice] = [] + @Published var pushDevices: [PushDevice] = [] + @Published var boundDevices: [BoundDevice] = [] + @Published var profileDevices: [ProfileDevice] = [] + @Published var webAuthnDevices: [WebAuthnDevice] = [] + + @Published var isLoading = false + @Published var isInitializing = false + @Published var errorMessage: String? + @Published var successMessage: String? + @Published var selectedDeviceType: DeviceType = .oath + + // MARK: - Private Properties + + private var deviceClient: DeviceClient? + + // MARK: - Initialization + + /// Initializes the DeviceClient with configuration + /// - Returns: true if initialization succeeded, false otherwise + @discardableResult + func initialize() async -> Bool { + isInitializing = true + errorMessage = nil + + defer { + isInitializing = false + } + + // Load configuration + let config = journey.config as? JourneyConfig + + // Validate configuration + guard let serverUrl = config?.serverUrl, !serverUrl.isEmpty else { + errorMessage = "Server URL is not configured. Please check settings." + LogManager.logger.e("DeviceManagement: Missing server URL", error: nil) + return false + } + + guard let realm = config?.realm, !realm.isEmpty else { + errorMessage = "Realm is not configured. Please check settings." + LogManager.logger.e("DeviceManagement: Missing realm", error: nil) + return false + } + + let cookieName = config?.cookie ?? "iPlanetDirectoryPro" + + // Get session token + guard let sessionToken = await journey.journeyUser()?.session?.value, + !sessionToken.isEmpty else { + errorMessage = "Session token not found. Please log in again." + LogManager.logger.e("DeviceManagement: Missing or empty SSO token", error: nil) + return false + } + + // Create device client configuration + let deviceConfig = DeviceClientConfig( + serverUrl: serverUrl, + realm: realm, + cookieName: cookieName, + ssoToken: sessionToken + ) + + // Initialize device client + self.deviceClient = DeviceClient(config: deviceConfig) + + LogManager.logger.i("DeviceManagement: Successfully initialized") + return true + } + + // MARK: - Device Operations + + /// Loads devices for the selected device type + func loadDevices(for type: DeviceType) async { + // Ensure client is initialized + guard let client = deviceClient else { + errorMessage = "Device client not initialized. Please try again." + LogManager.logger.e("DeviceManagement: Attempted to load devices without initialization", error: nil) + + // Try to initialize + if await initialize() { + // Retry loading after successful initialization + await loadDevices(for: type) + } + return + } + + isLoading = true + errorMessage = nil + successMessage = nil + selectedDeviceType = type + + let result: Result = switch type { + case .oath: + await client.oath.get().map { devices in + self.oathDevices = devices + LogManager.logger.i("DeviceManagement: Loaded \(devices.count) Oath devices") + return devices.count + } + + case .push: + await client.push.get().map { devices in + self.pushDevices = devices + LogManager.logger.i("DeviceManagement: Loaded \(devices.count) Push devices") + return devices.count + } + + case .bound: + await client.bound.get().map { devices in + self.boundDevices = devices + LogManager.logger.i("DeviceManagement: Loaded \(devices.count) Bound devices") + return devices.count + } + + case .profile: + await client.profile.get().map { devices in + self.profileDevices = devices + LogManager.logger.i("DeviceManagement: Loaded \(devices.count) Profile devices") + return devices.count + } + + case .webAuthn: + await client.webAuthn.get().map { devices in + self.webAuthnDevices = devices + LogManager.logger.i("DeviceManagement: Loaded \(devices.count) WebAuthn devices") + return devices.count + } + } + + switch result { + case .success: + successMessage = "Successfully loaded \(type.rawValue) devices" + case .failure(let error): + handleDeviceError(error, operation: "load devices") + } + + isLoading = false + } + + /// Deletes an Oath device + func deleteOathDevice(_ device: OathDevice) async { + await performDeviceOperation( + operation: { await self.deviceClient?.oath.delete(device) }, + onSuccess: { + self.oathDevices.removeAll { $0.id == device.id } + }, + deviceName: device.deviceName, + operationType: "delete", + deviceType: "Oath" + ) + } + + /// Deletes a Push device + func deletePushDevice(_ device: PushDevice) async { + await performDeviceOperation( + operation: { await self.deviceClient?.push.delete(device) }, + onSuccess: { + self.pushDevices.removeAll { $0.id == device.id } + }, + deviceName: device.deviceName, + operationType: "delete", + deviceType: "Push" + ) + } + + /// Deletes a Bound device + func deleteBoundDevice(_ device: BoundDevice) async { + await performDeviceOperation( + operation: { await self.deviceClient?.bound.delete(device) }, + onSuccess: { + self.boundDevices.removeAll { $0.id == device.id } + }, + deviceName: device.deviceName, + operationType: "delete", + deviceType: "Bound" + ) + } + + /// Updates a Bound device + func updateBoundDevice(_ device: BoundDevice, newName: String) async { + var updatedDevice = device + updatedDevice.deviceName = newName + + await performDeviceOperation( + operation: { await self.deviceClient?.bound.update(updatedDevice) }, + onSuccess: { + if let index = self.boundDevices.firstIndex(where: { $0.id == device.id }) { + self.boundDevices[index] = updatedDevice + } + }, + deviceName: device.deviceName, + operationType: "update", + deviceType: "Bound" + ) + } + + /// Deletes a Profile device + func deleteProfileDevice(_ device: ProfileDevice) async { + await performDeviceOperation( + operation: { await self.deviceClient?.profile.delete(device) }, + onSuccess: { + self.profileDevices.removeAll { $0.id == device.id } + }, + deviceName: device.deviceName, + operationType: "delete", + deviceType: "Profile" + ) + } + + /// Updates a Profile device + func updateProfileDevice(_ device: ProfileDevice, newName: String) async { + var updatedDevice = device + updatedDevice.deviceName = newName + + await performDeviceOperation( + operation: { await self.deviceClient?.profile.update(updatedDevice) }, + onSuccess: { + if let index = self.profileDevices.firstIndex(where: { $0.id == device.id }) { + self.profileDevices[index] = updatedDevice + } + }, + deviceName: device.deviceName, + operationType: "update", + deviceType: "Profile" + ) + } + + /// Deletes a WebAuthn device + func deleteWebAuthnDevice(_ device: WebAuthnDevice) async { + await performDeviceOperation( + operation: { await self.deviceClient?.webAuthn.delete(device) }, + onSuccess: { + self.webAuthnDevices.removeAll { $0.id == device.id } + }, + deviceName: device.deviceName, + operationType: "delete", + deviceType: "WebAuthn" + ) + } + + /// Updates a WebAuthn device + func updateWebAuthnDevice(_ device: WebAuthnDevice, newName: String) async { + var updatedDevice = device + updatedDevice.deviceName = newName + + await performDeviceOperation( + operation: { await self.deviceClient?.webAuthn.update(updatedDevice) }, + onSuccess: { + if let index = self.webAuthnDevices.firstIndex(where: { $0.id == device.id }) { + self.webAuthnDevices[index] = updatedDevice + } + }, + deviceName: device.deviceName, + operationType: "update", + deviceType: "WebAuthn" + ) + } + + /// Updates a Push device + func updatePushDevice(_ device: PushDevice, newName: String) async { + var updatedDevice = device + updatedDevice.deviceName = newName + + await performDeviceOperation( + operation: { await self.deviceClient?.push.update(updatedDevice) }, + onSuccess: { + if let index = self.pushDevices.firstIndex(where: { $0.id == device.id }) { + self.pushDevices[index] = updatedDevice + } + }, + deviceName: device.deviceName, + operationType: "update", + deviceType: "Push" + ) + } + + /// Updates a Oath device + func updateOathDevice(_ device: OathDevice, newName: String) async { + var updatedDevice = device + updatedDevice.deviceName = newName + + await performDeviceOperation( + operation: { await self.deviceClient?.oath.update(updatedDevice) }, + onSuccess: { + if let index = self.oathDevices.firstIndex(where: { $0.id == device.id }) { + self.oathDevices[index] = updatedDevice + } + }, + deviceName: device.deviceName, + operationType: "update", + deviceType: "Oath" + ) + } + + // MARK: - Helper Methods + + /// Generic method to perform device operations with consistent error handling + private func performDeviceOperation( + operation: () async -> Result?, + onSuccess: () -> Void, + deviceName: String, + operationType: String, + deviceType: String + ) async { + guard deviceClient != nil else { + errorMessage = "Device client not initialized" + return + } + + isLoading = true + errorMessage = nil + successMessage = nil + + guard let result = await operation() else { + errorMessage = "Device client not available" + isLoading = false + return + } + + switch result { + case .success: + onSuccess() + successMessage = "Successfully \(operationType)d device: \(deviceName)" + LogManager.logger.i("DeviceManagement: \(operationType.capitalized) \(deviceType) device: \(deviceName)") + case .failure(let error): + handleDeviceError(error, operation: "\(operationType) device") + } + + isLoading = false + } + + /// Handles DeviceError with appropriate user messages + private func handleDeviceError(_ error: DeviceError, operation: String) { + switch error { + case .networkError: + errorMessage = "Network error. Please check your connection and try again." + case .requestFailed(let statusCode, _): + if statusCode == 401 { + errorMessage = "Session expired. Please log in again." + } else if statusCode == 404 { + errorMessage = "Device not found. It may have been already deleted." + } else if statusCode >= 500 { + errorMessage = "Server error. Please try again later." + } else { + errorMessage = "Failed to \(operation). Status code: \(statusCode)" + } + case .invalidToken: + errorMessage = "Invalid session. Please log in again." + case .decodingFailed: + errorMessage = "Failed to process server response. Please try again." + default: + errorMessage = error.localizedDescription + } + LogManager.logger.e("DeviceManagement: Failed to \(operation)", error: error) + } + + /// Refreshes the current device type + func refresh() async { + await loadDevices(for: selectedDeviceType) + } + + /// Clears any messages + func clearMessages() { + errorMessage = nil + successMessage = nil + } +} diff --git a/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/ViewModels/JourneyViewModel.swift b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/ViewModels/JourneyViewModel.swift new file mode 100644 index 00000000..971ba7b4 --- /dev/null +++ b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/ViewModels/JourneyViewModel.swift @@ -0,0 +1,233 @@ +// JourneyViewModel.swift +// JourneyModuleSample +// +// 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 Foundation +import PingOidc +import PingOrchestrate +import PingLogger +import PingStorage +import PingJourney +import Combine + +/// Configures and initializes the Journey instance with the AIC/AM server and OAuth 2.0 client details. +/// - This configuration includes: +/// - Client ID +/// - Scopes +/// - Redirect URI +/// - Discovery Endpoint +/// - Other optional fields +/// +/// TODO: Replace the placeholder values below with your actual configuration: +/// - serverUrl: Your PingAM/AIC server URL (e.g., "https://your-server.example.com/am") +/// - realm: Your authentication realm (e.g., "alpha") +/// - cookie: Your session cookie name +/// - clientId: Your OAuth 2.0 client ID +/// - scopes: Your OAuth 2.0 scopes +/// - redirectUri: Your app's redirect URI +/// - discoveryEndpoint: Your OIDC discovery endpoint URL + +public let journey = Journey.createJourney { config in + config.serverUrl = <#"https://your-server.example.com/am"#> + config.realm = <#"your-realm"#> + config.cookie = <#"your-cookie-name"#> + config.module(PingJourney.OidcModule.config) { oidcValue in + oidcValue.clientId = <#"your-client-id"#> + oidcValue.scopes = <#"[SCOPES]"#> + oidcValue.redirectUri = <#"yourapp://callback"#> + oidcValue.discoveryEndpoint = <#"https://your-server.example.com/am/oauth2/your-realm/.well-known/openid-configuration"#> + } +} + +// MARK: - Multi-User Journey Instances with Separate Session Storage +// The following examples demonstrate how to create multiple Journey instances +// with isolated session and token storage for different users or use cases. +// +// Key points: +// 1. Each Journey instance can have its own cookie storage via SessionModule.config +// 2. Each Journey instance can have its own token storage via OidcModule.config +// 3. Use unique account identifiers to keep storage completely separate +// 4. You must use KeychainStorage array type for session storage + +/* +// Switch "Journey.createJourney" to "DaVinci.createDaVinci" if required +let userAInstance = Journey.createJourney { config in + config.serverUrl = "https://example.com/am" + config.realm = "alpha" + + config.module(SessionModule.config) { sessionConfig in + sessionConfig.storage = KeychainStorage( + account: "user_a_sessions", + encryptor: SecuredKeyEncryptor() ?? NoEncryptor() + ) + } + + // Note: Custom session storage configuration is not directly supported + // via the public API. Session storage uses a default KeychainStorage internally. + // If you need separate session storage per user, consider using different + // Journey instances or managing session isolation at a higher level. + + config.module(PingJourney.OidcModule.config) { oidcConfig in + oidcConfig.clientId = "app-client" + oidcConfig.discoveryEndpoint = "https://example.com/.well-known/openid-configuration" + oidcConfig.scopes = ["openid", "profile", "email"] + oidcConfig.redirectUri = "app:/oauth2redirect" + + // Custom token storage for User A + oidcConfig.storage = KeychainStorage(account: "user_a_tokens") + } +} + +// Switch "Journey.createJourney" to "DaVinci.createDaVinci" if required +let userBInstance = Journey.createJourney { config in + config.serverUrl = "https://example.com/am" + config.realm = "alpha" + + // Note: Custom session storage configuration is not directly supported + // via the public API. Session storage uses a default KeychainStorage internally. + // If you need separate session storage per user, consider using different + // Journey instances or managing session isolation at a higher level. + + config.module(PingJourney.OidcModule.config) { oidcConfig in + oidcConfig.clientId = "app-client" + oidcConfig.discoveryEndpoint = "https://example.com/.well-known/openid-configuration" + oidcConfig.scopes = ["openid", "profile", "email"] + oidcConfig.redirectUri = "app:/oauth2redirect" + + // Custom token storage for User B + oidcConfig.storage = KeychainStorage(account: "user_b_tokens") + } +} + + +// Instance 1 - Standard authentication with long-lived tokens +// Switch "Journey.createJourney" to "DaVinci.createDaVinci" if required +let standardJourneyInstance = Journey.createJourney { config in + config.serverUrl = "https://example.com/am" + config.realm = "alpha" + + config.module(PingJourney.OidcModule.config) { oidcConfig in + oidcConfig.clientId = "standard-client" + oidcConfig.scopes = ["openid", "profile", "email"] + oidcConfig.redirectUri = "app:/oauth2redirect" + + // Custom storage for this instance’s access tokens + oidcConfig.storage = KeychainStorage(account: "standard_tokens") + } + + // No custom session storage is defined, so it uses the default shared session. +} + +// Instance 2 - High-security transactions with short-lived tokens +// Switch "Journey.createJourney" to "DaVinci.createDaVinci" if required +let transactionJourneyInstance2 = Journey.createJourney { config in + config.serverUrl = "https://example.com/am" + config.realm = "alpha" + + config.module(PingJourney.OidcModule.config) { oidcConfig in + oidcConfig.clientId = "transaction-client" + oidcConfig.scopes = ["openid", "transactions"] + oidcConfig.redirectUri = "app:/oauth2redirect" + + // Separate storage for this instance’s access token + oidcConfig.storage = KeychainStorage(account: "transaction_tokens") + } + + // Also uses the default shared session storage. +} + */ + + +// A view model that manages the flow and state of the Journey orchestration process. +/// - Responsible for: +/// - Starting the Journey flow +/// - Progressing to the next node in the flow +/// - Maintaining the current and previous flow state +/// - Handling loading states +@MainActor +class JourneyViewModel: ObservableObject { + /// Published property that holds the current state node data. + @Published public var state: JourneyState = JourneyState() + /// Published property to track whether the view is currently loading. + @Published public var isLoading: Bool = false + /// Published property to control whether to show the journey name input screen + @Published public var showJourneyNameInput: Bool = true + + /// Initializes the view model but does NOT automatically start the journey. + /// The journey will start when the user enters a journey name. + init() { + // Remove auto-start - let user enter journey name first + } + + /// Starts the Journey orchestration process with a specific journey name. + /// - Parameter journeyName: The name of the journey to start + public func startJourney(with journeyName: String) async { + guard !journeyName.isEmpty else { return } + + await MainActor.run { + isLoading = true + } + + let next = await journey.start(journeyName) { options in + options.forceAuth = false + options.noSession = false + } + + await MainActor.run { + self.state = JourneyState(node: next) + self.showJourneyNameInput = false + self.isLoading = false + } + } + + /// Advances to the next node in the orchestration process. + /// - Parameter node: The current node to progress from. + public func next(node: Node) async { + await MainActor.run { + isLoading = true + } + if let current = node as? ContinueNode { + // Retrieves the next node in the flow. + let next = await current.next() + await MainActor.run { + self.state = JourneyState(node: next) + isLoading = false + } + } + } + + public func refresh() { + state = JourneyState(node: state.node) + } + + /// Reset the view model to show journey name input again + public func reset() { + showJourneyNameInput = true + state = JourneyState() + isLoading = false + } + + func getSavedJourneyName() -> String { + // Retrieve the saved journey name from storage + UserDefaults.standard.string(forKey: "journeyName") ?? "" + } + + func saveJourneyName(_ journeyName: String) { + // Save the journey name to storage + UserDefaults.standard.set(journeyName, forKey: "journeyName") + } +} + +/// A model class that represents the state of the current and previous nodes in the Journey flow. +class JourneyState { + var node: Node? = nil + + init(node: Node? = nil) { + self.node = node + } +} diff --git a/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/ViewModels/LogOutViewModel.swift b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/ViewModels/LogOutViewModel.swift new file mode 100644 index 00000000..51472bcb --- /dev/null +++ b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/ViewModels/LogOutViewModel.swift @@ -0,0 +1,35 @@ +// +// LogOutViewModel.swift +// JourneyModuleSample +// +// 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 SwiftUI +import Combine +import PingOidc +import PingJourney + +/// A view model responsible for managing the logout functionality. +/// - Handles the logout process for the user and updates the state for UI display. +@MainActor +class LogOutViewModel: ObservableObject { + /// A published property that holds the status of the logout process. + @Published var logout: String = "" + + /// Performs the user logout process using the DaVinci SDK. + /// - Executes the `logout()` method from the DaVinci or Journey user object asynchronously. + /// - Updates the `logout` property with a completion message upon success. + func logout() async { + let journeyUser = await journey.journeyUser() + await journeyUser?.logout() + + await MainActor.run { + logout = "Logout completed" + } + } +} diff --git a/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/ViewModels/LoggerViewModel.swift b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/ViewModels/LoggerViewModel.swift new file mode 100644 index 00000000..0cc3b11e --- /dev/null +++ b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/ViewModels/LoggerViewModel.swift @@ -0,0 +1,99 @@ +// +// LoggerViewModel.swift +// JourneyModuleSample +// +// Copyright (c) 2024 - 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 Foundation +import PingLogger +import Combine + +class LoggerViewModel { + + func setupLogger() { + // shared logger - by default is "none" + var sharedLogger = LogManager.logger + sharedLogger.d("sharedLogger Debug") + sharedLogger.i("sharedLogger Info") + sharedLogger.w("sharedLogger Warning", error: TestError.success) + sharedLogger.e("sharedLogger Error", error: TestError.failure) + + // standard logger - messages of all levels should be displayed in the console... + let standardLogger = LogManager.standard + standardLogger.d("standardLogger Debug") + standardLogger.i("standardLogger Info") + standardLogger.w("standardLogger Warning", error: TestError.success) + standardLogger.e("standardLogger Error", error: TestError.failure) + + // warning logger - only warning and error messages should be displayed... + let warningLogger = LogManager.warning + warningLogger.d("warningLogger Debug") + warningLogger.i("warningLogger Info") + warningLogger.w("warningLogger Warning", error: TestError.success) + warningLogger.e("warningLogger Error", error: TestError.failure) + + // none logger - none of these messages will be displayed... + let noneLogger = LogManager.none + noneLogger.d("noneLogger Debug") + noneLogger.i("noneLogger Info") + noneLogger.w("noneLogger Warning", error: TestError.success) + noneLogger.e("noneLogger Error", error: TestError.failure) + + // switch the shared logger to "standard" - the message from below will be displayed in the console + sharedLogger = LogManager.standard + sharedLogger.d("sharedLogger Debug") + sharedLogger.i("sharedLogger Info") + sharedLogger.w("sharedLogger Warning", error: TestError.success) + sharedLogger.e("sharedLogger Error", error: TestError.failure) + + // test a custom logger + let customLogger = LogManager.customLogger + customLogger.d("customLogger Debug") + customLogger.i("customLogger Info") + customLogger.w("customLogger Warning", error: TestError.success) + customLogger.e("customLogger Error", error: TestError.failure) + } + + enum TestError: Error { + case success + case failure + } +} + + +struct CustomLogger: Logger { + + func i(_ message: String) { + print("\(message) (CustomLogger)") + } + + func d(_ message: String) { + print("\(message) (CustomLogger)") + } + + func w(_ message: String, error: Error?) { + if let error = error { + print("\(message): \(error) (CustomLogger)") + } else { + print("\(message) (CustomLogger)") + } + } + + func e(_ message: String, error: Error?) { + if let error = error { + print("\(message): \(error) (CustomLogger)") + } else { + print("\(message) (CustomLogger)") + } + } +} + +extension LogManager { + static var customLogger: Logger { + return CustomLogger() + } +} diff --git a/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/ViewModels/StorageViewModel.swift b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/ViewModels/StorageViewModel.swift new file mode 100644 index 00000000..2ce46ddb --- /dev/null +++ b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/ViewModels/StorageViewModel.swift @@ -0,0 +1,39 @@ +// +// StorageViewModel.swift +// JourneyModuleSample +// +// Copyright (c) 2024 - 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 Foundation +import PingStorage +import PingLogger + +@MainActor +class StorageViewModel { + func setupMemoryStorage() async { + do { + let memoryStorage1 = MemoryStorage() + try await memoryStorage1.save(item: "Andy") + let storedValue1 = try await memoryStorage1.get() + LogManager.standard.i("Memory Storage value: \(storedValue1!)") + } catch { + LogManager.standard.e("", error: error) + } + + } + + func setupKeychainStorage() async { + do { + let keychainStorage = KeychainStorage(account: "token", encryptor: SecuredKeyEncryptor() ?? NoEncryptor()) + try await keychainStorage.save(item: "Jey") + let storedValue = try await keychainStorage.get() + LogManager.standard.i("Keychain Storage value: \(storedValue!)") + } catch { + LogManager.standard.e("", error: error) + } + } +} diff --git a/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/ViewModels/UserInfoViewModel.swift b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/ViewModels/UserInfoViewModel.swift new file mode 100644 index 00000000..6a7c0402 --- /dev/null +++ b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/ViewModels/UserInfoViewModel.swift @@ -0,0 +1,63 @@ +// +// UserInfoViewModel.swift +// JourneyModuleSample +// +// 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 SwiftUI +import PingLogger +import PingOidc +import Combine +import PingJourney + +/// A view model responsible for fetching and managing user information. +/// - Provides a published `userInfo` property that is updated with user information or error messages. +/// - Fetches user data asynchronously using the DaVinci SDK. +@MainActor +class UserInfoViewModel: ObservableObject { + /// Published property to hold the user information or error messages. + @Published var userInfo: String = "" + + /// Initializes the `UserInfoViewModel` and fetches user information. + /// - The data is fetched asynchronously during initialization. + init() { + Task { + await fetchUserInfo() + } + } + + /// Fetches user information from the DaVinci SDK. + /// - The method retrieves user details as a dictionary and formats them as a string for display. + /// - Updates the `userInfo` property with the fetched data or an error message. + /// - Logs success and error messages using `PingLogger`. + func fetchUserInfo() async { + + let journeyUser = await journey.journeyUser() + let userInfo: Result? = await journeyUser?.userinfo(cache: false) + + switch userInfo { + case .success(let userInfoDictionary): + // On success, format the dictionary into a string and update `userInfo`. + await MainActor.run { + var userInfoDescription = "" + userInfoDictionary.forEach { userInfoDescription += "\($0): \($1)\n" } + self.userInfo = userInfoDescription + } + LogManager.standard.i("UserInfo: \(String(describing: self.userInfo))") + case .failure(let error): + // On failure, update `userInfo` with an error message and log the error. + await MainActor.run { + self.userInfo = "Error: \(error.localizedDescription)" + } + LogManager.standard.e("", error: error) + case .none: + // No data received, no further action required. + break + } + } +} diff --git a/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Views/AccessTokenView.swift b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Views/AccessTokenView.swift new file mode 100644 index 00000000..230cc972 --- /dev/null +++ b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Views/AccessTokenView.swift @@ -0,0 +1,31 @@ +// +// AccessTokenView.swift +// JourneyModuleSample +// +// 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 SwiftUI +import Combine + +/// A view that displays the access token. +struct AccessTokenView: View { + let menuItem: MenuItem + /// A state object that manages the access token data. + @StateObject private var accessTokenViewModel = AccessTokenViewModel() + + var body: some View { + VStack { + ScrollView { + Text(accessTokenViewModel.token) + .foregroundStyle(.secondary) + .padding(.horizontal) + .navigationTitle(menuItem.title) + } + } + } +} diff --git a/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Views/AsyncSVGImage.swift b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Views/AsyncSVGImage.swift new file mode 100644 index 00000000..58b8fba2 --- /dev/null +++ b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Views/AsyncSVGImage.swift @@ -0,0 +1,45 @@ +// +// AsyncSVGImage.swift +// JourneyModuleSample +// +// 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 SwiftUI +import SVGKit + +struct AsyncSVGImage: View { + let url: URL + @State private var svgImage: SVGKImage? + + var body: some View { + Group { + if let svgImage = svgImage { + // Convert the SVGKImage to a UIImage and display it. + Image(uiImage: svgImage.uiImage) + .resizable() + .aspectRatio(contentMode: .fit) + } else { + // While loading, show a progress indicator. + ProgressView() + .onAppear(perform: loadSVG) + } + } + } + + private func loadSVG() { + URLSession.shared.dataTask(with: url) { data, response, error in + guard let data = data, + let loadedSVG = SVGKImage(data: data) else { + return + } + DispatchQueue.main.async { + self.svgImage = loadedSVG + } + }.resume() + } +} diff --git a/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Views/BindingKeysView.swift b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Views/BindingKeysView.swift new file mode 100644 index 00000000..208bd3eb --- /dev/null +++ b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Views/BindingKeysView.swift @@ -0,0 +1,76 @@ +// +// BindingKeysView.swift +// JourneyModuleSample +// +// 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 SwiftUI +import PingBinding + +struct BindingKeysView: View { + @StateObject private var viewModel = BindingKeysViewModel() + + var body: some View { + VStack { + if viewModel.userKeys.isEmpty { + Text("No Binding Keys Found") + .font(.headline) + .foregroundColor(.gray) + } else { + List { + ForEach(viewModel.userKeys) { key in + VStack(alignment: .leading, spacing: 8) { + Text("User ID: \(key.userId)") + .font(.headline) + Text("Key Tag: \(key.keyTag)") + .font(.subheadline) + .foregroundColor(.secondary) + Text("Auth Type: \(key.authType.rawValue)") + .font(.caption) + .padding(4) + .background(Color.blue.opacity(0.2)) + .cornerRadius(4) + } + .padding(.vertical, 4) + } + .onDelete(perform: delete) + } + } + } + .navigationTitle("Binding Keys") + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Delete All", role: .destructive) { + Task { + await viewModel.deleteAllKeys() + } + } + .disabled(viewModel.userKeys.isEmpty) + } + } + .onAppear { + Task { + await viewModel.fetchKeys() + } + } + } + + private func delete(at offsets: IndexSet) { + Task { + for index in offsets { + let key = viewModel.userKeys[index] + await viewModel.deleteKey(key: key) + } + } + } +} + +struct BindingKeysView_Previews: PreviewProvider { + static var previews: some View { + BindingKeysView() + } +} diff --git a/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Views/ContentView.swift b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Views/ContentView.swift new file mode 100644 index 00000000..cdf512b0 --- /dev/null +++ b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Views/ContentView.swift @@ -0,0 +1,317 @@ +// +// ContentView.swift +// JourneyModuleSample +// +// Copyright (c) 2024 - 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 SwiftUI +import PingExternalIdPFacebook +import PingExternalIdPGoogle +import PingBrowser +import PingDeviceId +import PingTamperDetector +import PingOidc +import PingBinding +import Combine + +// MARK: - Menu Section Enum +enum MenuSection: CaseIterable, Identifiable { + case authentication + case userManagement + case developerTools + + var id: String { title } + + var title: String { + switch self { + case .authentication: return "Authentication" + case .userManagement: return "User Management" + case .developerTools: return "Developer Tools" + } + } + + var items: [MenuItem] { + switch self { + case .authentication: + return [.journey] + case .userManagement: + return [.token, .user, .deviceManagement, .logout] + case .developerTools: + return [.deviceInfo, .logger, .storage, .bindingKeys] + } + } +} + +// MARK: - Menu Item Enum +enum MenuItem: String, CaseIterable, Identifiable { + case journey = "Journey" + case token = "Token" + case user = "User" + case logout = "Logout" + case deviceManagement = "Device Management" + case deviceInfo = "DeviceInfo" + case logger = "Logger" + case storage = "Storage" + case bindingKeys = "Binding Keys" + + var id: String { rawValue } + + var icon: String { + switch self { + case .journey: return "map.fill" + case .token: return "ticket.fill" + case .user: return "person.fill" + case .logout: return "rectangle.portrait.and.arrow.right" + case .deviceManagement: return "iphone.and.arrow.forward" + case .deviceInfo: return "iphone" + case .logger: return "doc.text.magnifyingglass" + case .storage: return "externaldrive.fill" + case .bindingKeys: return "key.icloud.fill" + } + } + + var title: String { + switch self { + case .journey: return "Journey Flow" + case .token: return "Access Token" + case .user: return "User Info" + case .logout: return "Logout" + case .deviceManagement: return "Device Management" + case .deviceInfo: return "Device Info" + case .logger: return "Logger" + case .storage: return "Storage" + case .bindingKeys: return "Binding Keys" + } + } + + var subtitle: String { + switch self { + case .journey: return "Test Journey authentication" + case .token: return "View current token" + case .user: return "View user details" + case .logout: return "End session" + case .deviceManagement: return "Manage registered devices" + case .deviceInfo: return "Collect device data" + case .logger: return "Test logging" + case .storage: return "Test storage" + case .bindingKeys: return "Manage stored binding keys" + } + } +} + +/// The main view of the application with redesigned UI +struct ContentView: View { + @State private var deviceID: String = "" + @State private var path: [MenuItem] = [] + @State private var deviceStatus: String = "Checking..." + + var body: some View { + NavigationStack(path: $path) { + ScrollView { + VStack(spacing: 0) { + // Header Section + headerSection + + // Content Section + VStack(spacing: 20) { + // Loop through all sections + ForEach(MenuSection.allCases) { section in + sectionCard( + title: section.title, + items: section.items + ) + } + + deviceStatusCard + } + .padding(.horizontal, 20) + .padding(.top, 20) + .padding(.bottom, 30) + } + } + .background(Color(.systemGroupedBackground)) + .navigationDestination(for: MenuItem.self) { item in + switch item { + case .journey: + JourneyView(path: $path) + case .token: + AccessTokenView(menuItem: item) + case .user: + UserInfoView(menuItem: item) + case .deviceManagement: + DeviceManagementView(menuItem: item) + case .logout: + LogOutView(path: $path) + case .logger: + LoggerView(menuItem: item) + case .storage: + StorageView(menuItem: item) + case .bindingKeys: + BindingKeysView() + case .deviceInfo: + DeviceInfoView(menuItem: item) + } + } + .task { + let id = try? await DefaultDeviceIdentifier().id + deviceID = id ?? "Unknown" + + let tamperDetector = TamperDetector() + let score = tamperDetector.analyze() + + if score > 0 { + deviceStatus = "⚠️ Jailbroken (Score: \(score))" + } else { + deviceStatus = "✓ Secure" + } + } + } + } + + // MARK: - Header Section + private var headerSection: some View { + ZStack { + LinearGradient( + colors: [.themeButtonBackground, Color(red: 0.6, green: 0.1, blue: 0.1)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + + VStack(spacing: 12) { + Image("Logo") + .resizable() + .scaledToFit() + .frame(width: 80, height: 80) + + Text("Ping SDK") + .font(.system(size: 28, weight: .bold)) + .foregroundColor(.white) + + Text("Journey Module Sample") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.white.opacity(0.9)) + + Text(sdkVersion) + .font(.system(size: 13, design: .monospaced)) + .foregroundColor(.white.opacity(0.7)) + } + .padding(.vertical, 10) + } + } + + // MARK: - Section Card + private func sectionCard(title: String, items: [MenuItem]) -> some View { + VStack(alignment: .leading, spacing: 0) { + Text(title) + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(.secondary) + .textCase(.uppercase) + .padding(.horizontal, 16) + .padding(.bottom, 8) + + VStack(spacing: 0) { + ForEach(Array(items.enumerated()), id: \.element.id) { index, item in + menuItemButton(item) + + if index < items.count - 1 { + Divider() + .padding(.leading, 60) + } + } + } + .background(Color(.secondarySystemGroupedBackground)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .shadow(color: .black.opacity(0.05), radius: 4, x: 0, y: 2) + } + } + + // MARK: - Menu Item Button + private func menuItemButton(_ item: MenuItem) -> some View { + Button { + path.append(item) + } label: { + HStack(spacing: 16) { + Image(systemName: item.icon) + .font(.system(size: 20)) + .foregroundColor(.white) + .frame(width: 40, height: 40) + .background( + LinearGradient( + colors: [.themeButtonBackground, Color(red: 0.6, green: 0.1, blue: 0.1)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .clipShape(RoundedRectangle(cornerRadius: 10)) + + VStack(alignment: .leading, spacing: 2) { + Text(item.title) + .font(.system(size: 16, weight: .medium)) + .foregroundColor(.primary) + + Text(item.subtitle) + .font(.system(size: 13)) + .foregroundColor(.secondary) + } + + Spacer() + + Image(systemName: "chevron.right") + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(.secondary) + } + .padding(16) + .contentShape(Rectangle()) + } + .buttonStyle(PlainButtonStyle()) + } + + // MARK: - Device Status Card + private var deviceStatusCard: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Image(systemName: "iphone.gen3") + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(.themeButtonBackground) + + Text("Device Information") + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(.primary) + + Spacer() + + Text(deviceStatus) + .font(.system(size: 12, weight: .medium)) + .foregroundColor(deviceStatus.contains("Secure") ? .green : .orange) + } + + Divider() + + VStack(alignment: .leading, spacing: 8) { + Text("Device ID") + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.secondary) + + Text(deviceID.isEmpty ? "Loading..." : deviceID) + .font(.system(size: 11, design: .monospaced)) + .foregroundColor(.primary) + .lineLimit(2) + .minimumScaleFactor(0.8) + } + } + .padding(16) + .background(Color(.secondarySystemGroupedBackground)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .shadow(color: .black.opacity(0.05), radius: 4, x: 0, y: 2) + } + + // Add computed properties: + private var sdkVersion: String { + Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unknown" + } +} + diff --git a/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Views/CustomViews.swift b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Views/CustomViews.swift new file mode 100644 index 00000000..d24ce44b --- /dev/null +++ b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Views/CustomViews.swift @@ -0,0 +1,84 @@ +// +// CustomViews.swift +// JourneyModuleSample +// +// 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 SwiftUI + +/// A reusable button view for general "Next" actions. +/// - Executes a provided action when tapped. +struct NextButton: View { + let title: String + let action: () -> (Void) + + var body: some View { + Button(action: { + action() + } ) { + Text(title) + .font(.headline) + .foregroundColor(.white) + .padding() + .frame(width: 300, height: 50) + .background(Color.themeButtonBackground) + .cornerRadius(15.0) + .shadow(radius: 10.0, x: 20, y: 10) + } + } +} + +/// A view for displaying the header of a node. +struct HeaderView: View { + var name: String = "" + + var body: some View { + VStack { + Text(name) + .font(.title) + .foregroundStyle(Color.gray) + } + } +} + +/// A view for displaying the description of a node. +struct DescriptionView: View { + var name: String = "" + + var body: some View { + VStack { + Text(name) + .font(.subheadline) + .foregroundStyle(Color.gray) + } + } +} + +/// A custom color extension for theme consistency. +/// - Provides a reusable color for text fields. +extension Color { + static var themeTextField: Color { + return Color(red: 220.0/255.0, green: 230.0/255.0, blue: 230.0/255.0, opacity: 1.0) + } + + static var themeButtonBackground: Color { + return Color(red: 163.0/255.0, green: 19.0/255.0, blue: 0.0/255.0) // Red color + } + + static var googleButtonBackground: Color { + return Color(red: 163.0/255.0, green: 19.0/255.0, blue: 0.0/255.0) // Red color + } + + static var appleButtonBackground: Color { + return Color(red: 0.0/255.0, green: 0.0/255.0, blue: 0.0/255.0) // Red color + } + + static var facebookButtonBackground: Color { + return Color(red: 0.0/255.0, green: 128.0/255.0, blue: 255.0/255.0) // Red color + } +} diff --git a/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Views/DeviceInfoView.swift b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Views/DeviceInfoView.swift new file mode 100644 index 00000000..05d60bfb --- /dev/null +++ b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Views/DeviceInfoView.swift @@ -0,0 +1,29 @@ +// +// DeviceInfoView.swift +// JourneyModuleSample +// +// 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 SwiftUI + +/// A view that displays device information +struct DeviceInfoView: View { + let menuItem: MenuItem + /// A state object that manages the device information data. + /// The `DeviceInfoViewModel` is responsible for collecting device info. + @StateObject private var deviceInfoViewModel = DeviceInfoViewModel() + + var body: some View { + ScrollView { + Text(deviceInfoViewModel.deviceInfo) + .foregroundStyle(.secondary) + .padding(.horizontal) + .navigationTitle(menuItem.title) + } + } +} diff --git a/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Views/DeviceManagementView.swift b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Views/DeviceManagementView.swift new file mode 100644 index 00000000..0c135db0 --- /dev/null +++ b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Views/DeviceManagementView.swift @@ -0,0 +1,572 @@ +// +// DeviceManagementView.swift +// JourneyModuleSample +// +// 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 SwiftUI +import PingDeviceClient + +/// View for managing devices +struct DeviceManagementView: View { + let menuItem: MenuItem + @StateObject private var viewModel = DeviceManagementViewModel() + @State private var showingUpdateSheet = false + @State private var deviceToUpdate: (id: String, name: String, type: DeviceType)? + @State private var updatedName = "" + @State private var initializationFailed = false + + var body: some View { + ZStack { + Color(.systemGroupedBackground) + .ignoresSafeArea() + + // Main content with initialization handling + if viewModel.isInitializing { + initializingView + } else if initializationFailed { + initializationErrorView + } else { + mainContentView + } + } + .navigationTitle(menuItem.title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button { + Task { + await viewModel.refresh() + } + } label: { + Image(systemName: "arrow.clockwise") + } + .disabled(viewModel.isLoading || viewModel.isInitializing) + } + } + .task { + // Initialize and load devices only if successful + if await viewModel.initialize() { + await viewModel.loadDevices(for: .oath) + initializationFailed = false + } else { + initializationFailed = true + } + } + .alert("Error", isPresented: .constant(viewModel.errorMessage != nil)) { + Button("OK") { + viewModel.clearMessages() + } + } message: { + if let error = viewModel.errorMessage { + Text(error) + } + } + .sheet(isPresented: $showingUpdateSheet) { + updateDeviceSheet + } + } + + // MARK: - Main Content View + + private var mainContentView: some View { + VStack(spacing: 0) { + // Device Type Picker + deviceTypePicker + + // Content + if viewModel.isLoading { + loadingView + } else { + deviceListView + } + } + } + + // MARK: - Initializing View + + private var initializingView: some View { + VStack(spacing: 20) { + ProgressView() + .scaleEffect(1.5) + + Text("Initializing Device Management...") + .font(.system(size: 16, weight: .medium)) + .foregroundColor(.primary) + + Text("Retrieving authentication details") + .font(.system(size: 14)) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + // MARK: - Initialization Error View + + private var initializationErrorView: some View { + VStack(spacing: 20) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 60)) + .foregroundColor(.orange) + + Text("Initialization Failed") + .font(.system(size: 20, weight: .semibold)) + .foregroundColor(.primary) + + if let error = viewModel.errorMessage { + Text(error) + .font(.system(size: 15)) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 40) + } + + Button { + Task { + initializationFailed = false + if await viewModel.initialize() { + await viewModel.loadDevices(for: .oath) + initializationFailed = false + } else { + initializationFailed = true + } + } + } label: { + HStack { + Image(systemName: "arrow.clockwise") + Text("Try Again") + } + .font(.system(size: 16, weight: .medium)) + .foregroundColor(.white) + .padding(.horizontal, 24) + .padding(.vertical, 12) + .background( + LinearGradient( + colors: [.themeButtonBackground, Color(red: 0.6, green: 0.1, blue: 0.1)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } + .padding(.top, 10) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding() + } + + // MARK: - Device Type Picker + + private var deviceTypePicker: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 12) { + ForEach(DeviceType.allCases) { type in + deviceTypeButton(type) + } + } + .padding(.horizontal, 20) + .padding(.vertical, 12) + } + .background(Color(.secondarySystemGroupedBackground)) + } + + private func deviceTypeButton(_ type: DeviceType) -> some View { + Button { + Task { + await viewModel.loadDevices(for: type) + } + } label: { + HStack(spacing: 8) { + Image(systemName: type.icon) + .font(.system(size: 14)) + + Text(type.rawValue) + .font(.system(size: 14, weight: .medium)) + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background( + viewModel.selectedDeviceType == type + ? LinearGradient( + colors: [.themeButtonBackground, Color(red: 0.6, green: 0.1, blue: 0.1)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + : LinearGradient( + colors: [Color(.systemGray5), Color(.systemGray5)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .foregroundColor(viewModel.selectedDeviceType == type ? .white : .primary) + .clipShape(RoundedRectangle(cornerRadius: 20)) + } + .buttonStyle(PlainButtonStyle()) + .disabled(viewModel.isLoading) + } + + // MARK: - Loading View + + private var loadingView: some View { + VStack(spacing: 16) { + ProgressView() + .scaleEffect(1.5) + + Text("Loading \(viewModel.selectedDeviceType.rawValue.lowercased()) devices...") + .font(.system(size: 16)) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + // MARK: - Device List View + + @ViewBuilder + private var deviceListView: some View { + ScrollView { + LazyVStack(spacing: 12) { + // Success message + if let success = viewModel.successMessage { + successBanner(success) + } + + // Device list based on selected type + switch viewModel.selectedDeviceType { + case .oath: + deviceList(devices: viewModel.oathDevices) + case .push: + deviceList(devices: viewModel.pushDevices) + case .bound: + deviceList(devices: viewModel.boundDevices) + case .profile: + deviceList(devices: viewModel.profileDevices) + case .webAuthn: + deviceList(devices: viewModel.webAuthnDevices) + } + } + .padding(20) + } + } + + // MARK: - Generic Device List + + private func deviceList(devices: [T]) -> some View { + Group { + if devices.isEmpty { + emptyStateView + } else { + ForEach(Array(devices.enumerated()), id: \.element.id) { index, device in + deviceCard(device) + } + } + } + } + + // MARK: - Empty State + + private var emptyStateView: some View { + VStack(spacing: 16) { + Image(systemName: "tray") + .font(.system(size: 48)) + .foregroundColor(.secondary) + + Text("No Devices Found") + .font(.system(size: 18, weight: .semibold)) + .foregroundColor(.primary) + + Text("No \(viewModel.selectedDeviceType.rawValue.lowercased()) devices are registered for this user.") + .font(.system(size: 14)) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 40) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 80) + } + + // MARK: - Device Card + + private func deviceCard(_ device: T) -> some View { + VStack(alignment: .leading, spacing: 12) { + // Header + HStack { + Image(systemName: viewModel.selectedDeviceType.icon) + .font(.system(size: 16)) + .foregroundColor(.themeButtonBackground) + + Text(device.deviceName) + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(.primary) + + Spacer() + + + Button { + deviceToUpdate = (device.id, device.deviceName, viewModel.selectedDeviceType) + updatedName = device.deviceName + showingUpdateSheet = true + } label: { + Image(systemName: "pencil.circle.fill") + .font(.system(size: 22)) + .foregroundColor(.blue) + } + .disabled(viewModel.isLoading) + + + Button(role: .destructive) { + Task { + await deleteDevice(device) + } + } label: { + Image(systemName: "trash.circle.fill") + .font(.system(size: 22)) + .foregroundColor(.red) + } + .disabled(viewModel.isLoading) + } + + Divider() + + // Device-specific details + VStack(alignment: .leading, spacing: 8) { + if let oathDevice = device as? OathDevice { + oathDeviceDetails(oathDevice) + } else if let pushDevice = device as? PushDevice { + pushDeviceDetails(pushDevice) + } else if let boundDevice = device as? BoundDevice { + boundDeviceDetails(boundDevice) + } else if let profileDevice = device as? ProfileDevice { + profileDeviceDetails(profileDevice) + } else if let webAuthnDevice = device as? WebAuthnDevice { + webAuthnDeviceDetails(webAuthnDevice) + } + } + } + .padding(16) + .background(Color(.secondarySystemGroupedBackground)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .shadow(color: .black.opacity(0.05), radius: 4, x: 0, y: 2) + } + + // MARK: - Device Type Specific Details + + private func oathDeviceDetails(_ device: OathDevice) -> some View { + Group { + detailRow(label: "UUID", value: device.uuid) + detailRow(label: "Created", value: formatDate(device.createdDate)) + detailRow(label: "Last Access", value: formatDate(device.lastAccessDate)) + } + } + + private func pushDeviceDetails(_ device: PushDevice) -> some View { + Group { + detailRow(label: "UUID", value: device.uuid) + detailRow(label: "Created", value: formatDate(device.createdDate)) + detailRow(label: "Last Access", value: formatDate(device.lastAccessDate)) + } + } + + private func boundDeviceDetails(_ device: BoundDevice) -> some View { + Group { + detailRow(label: "Device ID", value: device.deviceId) + detailRow(label: "UUID", value: device.uuid) + detailRow(label: "Created", value: formatDate(device.createdDate)) + detailRow(label: "Last Access", value: formatDate(device.lastAccessDate)) + } + } + + private func profileDeviceDetails(_ device: ProfileDevice) -> some View { + Group { + detailRow(label: "Identifier", value: device.identifier) + detailRow(label: "Last Selected", value: formatDate(device.lastSelectedDate)) + + if let location = device.location { + detailRow(label: "Location", value: "Lat: \(location.latitude), Lon: \(location.longitude)") + } + + if !device.metadata.isEmpty { + VStack(alignment: .leading, spacing: 4) { + Text("Metadata:") + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.secondary) + + ForEach(Array(device.metadata.keys.sorted()), id: \.self) { key in + if let value = device.metadata[key] { + Text("\(key): \(String(describing: value))") + .font(.system(size: 11, design: .monospaced)) + .foregroundColor(.primary) + .lineLimit(1) + } + } + } + } + } + } + + private func webAuthnDeviceDetails(_ device: WebAuthnDevice) -> some View { + Group { + detailRow(label: "Credential ID", value: device.credentialId) + detailRow(label: "UUID", value: device.uuid) + detailRow(label: "Created", value: formatDate(device.createdDate)) + detailRow(label: "Last Access", value: formatDate(device.lastAccessDate)) + } + } + + private func detailRow(label: String, value: String) -> some View { + VStack(alignment: .leading, spacing: 2) { + Text(label) + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.secondary) + + Text(value) + .font(.system(size: 12, design: .monospaced)) + .foregroundColor(.primary) + .lineLimit(2) + } + } + + // MARK: - Success Banner + + private func successBanner(_ message: String) -> some View { + HStack { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + + Text(message) + .font(.system(size: 14)) + .foregroundColor(.primary) + + Spacer() + + Button { + viewModel.clearMessages() + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.secondary) + } + } + .padding(12) + .background(Color.green.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + + // MARK: - Update Sheet + + private var updateDeviceSheet: some View { + NavigationView { + VStack(spacing: 20) { + VStack(alignment: .leading, spacing: 8) { + Text("Device Name") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.secondary) + + TextField("Enter new name", text: $updatedName) + .textFieldStyle(.roundedBorder) + .font(.system(size: 16)) + .autocorrectionDisabled() + } + .padding(.horizontal) + + Spacer() + } + .padding(.top, 20) + .navigationTitle("Update Device") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Cancel") { + showingUpdateSheet = false + } + } + + ToolbarItem(placement: .navigationBarTrailing) { + Button("Save") { + Task { + await saveDeviceUpdate() + showingUpdateSheet = false + } + } + .disabled(updatedName.isEmpty || updatedName == deviceToUpdate?.name) + } + } + } + } + + // MARK: - Helper Methods + + private func formatDate(_ timestamp: TimeInterval) -> String { + let date = Date(timeIntervalSince1970: timestamp) + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .short + return formatter.string(from: date) + } + + private func deleteDevice(_ device: T) async { + switch viewModel.selectedDeviceType { + case .oath: + if let oathDevice = device as? OathDevice { + await viewModel.deleteOathDevice(oathDevice) + } + case .push: + if let pushDevice = device as? PushDevice { + await viewModel.deletePushDevice(pushDevice) + } + case .bound: + if let boundDevice = device as? BoundDevice { + await viewModel.deleteBoundDevice(boundDevice) + } + case .profile: + if let profileDevice = device as? ProfileDevice { + await viewModel.deleteProfileDevice(profileDevice) + } + case .webAuthn: + if let webAuthnDevice = device as? WebAuthnDevice { + await viewModel.deleteWebAuthnDevice(webAuthnDevice) + } + } + } + + private func saveDeviceUpdate() async { + guard let deviceToUpdate = deviceToUpdate else { return } + + switch deviceToUpdate.type { + case .bound: + if let device = viewModel.boundDevices.first(where: { $0.id == deviceToUpdate.id }) { + await viewModel.updateBoundDevice(device, newName: updatedName) + } + case .profile: + if let device = viewModel.profileDevices.first(where: { $0.id == deviceToUpdate.id }) { + await viewModel.updateProfileDevice(device, newName: updatedName) + } + case .webAuthn: + if let device = viewModel.webAuthnDevices.first(where: { $0.id == deviceToUpdate.id }) { + await viewModel.updateWebAuthnDevice(device, newName: updatedName) + } + case .push: + if let device = viewModel.pushDevices.first(where: { $0.id == deviceToUpdate.id }) { + await viewModel.updatePushDevice(device, newName: updatedName) + } + case .oath: + if let device = viewModel.oathDevices.first(where: { $0.id == deviceToUpdate.id }) { + await viewModel.updateOathDevice(device, newName: updatedName) + } + + default: + break + } + } +} + +// MARK: - Preview + +#Preview { + NavigationStack { + DeviceManagementView(menuItem: .deviceManagement) + } +} diff --git a/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Views/ErrorView.swift b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Views/ErrorView.swift new file mode 100644 index 00000000..00bf393e --- /dev/null +++ b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Views/ErrorView.swift @@ -0,0 +1,83 @@ +// +// ErrorView.swift +// JourneyModuleSample +// +// 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 SwiftUI +import PingOrchestrate +import Combine + +/// A view for displaying error messages. +struct ErrorView: View { + let message: String + var body: some View { + HStack { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(Color.red) + .padding(.trailing, 8) + Text(message) + .font(.headline) + .multilineTextAlignment(.leading) + .foregroundStyle(Color.red) + Spacer() + } + .padding() + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color(.systemBackground)) + .shadow(color: .gray, radius: 1, x: 0, y: 1) + ) + .padding(.horizontal, 16) + } +} + + +struct ErrorNodeView: View { + let node: ErrorNode + @State private var showDetails: Bool = false + + private var errorText: String { + let error = "" + return error + } + + var body: some View { + ErrorView(message: node.message) + .onTapGesture { + showDetails = true + } + .alert("Error Details", isPresented: $showDetails) { + Button("OK") { + showDetails = false + } + } message: { + Text(errorText) + } + } +} + +struct ErrorMessageView: View { + var errors: [String] + + var body: some View { + if errors.isEmpty { + EmptyView() + } else { + VStack(alignment: .leading, spacing: 8) { + ForEach(errors, id: \.self) { error in + Text(error) + .font(.footnote) + .foregroundColor(.red) + .padding(.vertical, 4) + } + } + .padding(2) + } + } +} diff --git a/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Views/JourneyView.swift b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Views/JourneyView.swift new file mode 100644 index 00000000..1b7edfd1 --- /dev/null +++ b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Views/JourneyView.swift @@ -0,0 +1,268 @@ +// +// JourneyView.swift +// JourneyModuleSample +// +// 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 Foundation +import SwiftUI +import PingOrchestrate +import PingJourney +import PingProtect +import PingExternalIdP +import PingDeviceProfile +import PingFido +import PingReCaptchaEnterprise +import PingBinding +import PingCommons +import PingJourneyPlugin + +struct JourneyView: View { + /// The view model that manages the Journey flow logic. + @StateObject private var journeyViewModel = JourneyViewModel() + /// A binding to the navigation stack path. + @Binding var path: [MenuItem] + + var body: some View { + ZStack { + if journeyViewModel.showJourneyNameInput { + // Show journey name input screen + JourneyNameInputView(journeyViewModel: journeyViewModel) + } else { + // Show the normal journey flow + ScrollView { + VStack { + Spacer() + // Handle different types of nodes in the Journey. + switch journeyViewModel.state.node { + case let continueNode as ContinueNode: + // Display the callback view for the next node. + CallbackView(journeyViewModel: journeyViewModel, node: continueNode) + case let errorNode as ErrorNode: + // Handle server-side errors (e.g., invalid credentials) + // Display error to the user + ErrorNodeView(node: errorNode) + case let failureNode as FailureNode: + ErrorView(message: failureNode.cause.localizedDescription) + case is SuccessNode: + // Authentication successful, retrieve the session + VStack{}.onAppear { + path.removeLast() + path.append(.token) + } + default: + EmptyView() + } + } + } + } + } + } +} + +/// A view for collecting the journey name before starting the flow +struct JourneyNameInputView: View { + @ObservedObject var journeyViewModel: JourneyViewModel + @State private var journeyName: String = "" + + var body: some View { + VStack(spacing: 24) { + Spacer() + + Image("Logo") + .resizable() + .scaledToFill() + .frame(width: 120, height: 120) + + VStack(spacing: 16) { + Text("Enter Journey Name") + .font(.title2) + .fontWeight(.semibold) + + TextField("Journey Name", text: $journeyName) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .padding() + .background( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.gray, lineWidth: 1) + ) + .onAppear() { journeyName = journeyViewModel.getSavedJourneyName() } + + Spacer() + + NextButton(title: "Start Journey") { + Task { + journeyViewModel.saveJourneyName(journeyName) + await journeyViewModel.startJourney(with: journeyName) + } + } + } + + Spacer() + } + .padding() + } +} + +/// A view for displaying the current step in the Journey flow. +struct CallbackView: View { + /// The Journey view model managing the flow. + @ObservedObject var journeyViewModel: JourneyViewModel + /// The next node to process in the flow. + public var node: ContinueNode + + var body: some View { + VStack { + Image("Logo").resizable().scaledToFill().frame(width: 100, height: 100) + + JourneyNodeView(continueNode: node, + onNodeUpdated: { journeyViewModel.refresh() }, + onStart: { Task { await journeyViewModel.startJourney(with: journeyViewModel.getSavedJourneyName()) }}, + onNext: { Task { + print("Next button tapped") + await journeyViewModel.next(node: node) + }}) + } + + } +} + +struct JourneyNodeView: View { + var continueNode: ContinueNode + let onNodeUpdated: () -> Void + let onStart: () -> Void + let onNext: () -> Void + + private var showNext: Bool { + !continueNode.callbacks.contains { callback in + callback is ConfirmationCallback || + callback is SuspendedTextOutputCallback || + callback is PingOneProtectInitializeCallback || + callback is PingOneProtectEvaluationCallback || + callback is IdpCallback || + callback is FidoRegistrationCallback || + callback is FidoAuthenticationCallback || + callback is DeviceBindingCallback || + callback is DeviceSigningVerifierCallback + } + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + + ForEach(Array(continueNode.callbacks.enumerated()), id: \.offset) { index, callback in + switch callback { + case let booleanCallback as BooleanAttributeInputCallback: + BooleanAttributeInputCallbackView(callback: booleanCallback, onNodeUpdated: onNodeUpdated) + + case let choiceCallback as ChoiceCallback: + ChoiceCallbackView(callback: choiceCallback, onNodeUpdated: onNodeUpdated) + + case let confirmationCallback as ConfirmationCallback: + ConfirmationCallbackView(callback: confirmationCallback, onSelected: onNext) + + case let consentCallback as ConsentMappingCallback: + ConsentMappingCallbackView(callback: consentCallback, onNodeUpdated: onNodeUpdated) + + case let kbaCallback as KbaCreateCallback: + KbaCreateCallbackView(callback: kbaCallback, onNodeUpdated: onNodeUpdated) + + case let numberCallback as NumberAttributeInputCallback: + NumberAttributeInputCallbackView(callback: numberCallback, onNodeUpdated: onNodeUpdated) + + case let passwordCallback as PasswordCallback: + PasswordCallbackView(callback: passwordCallback, onNodeUpdated: onNodeUpdated) + + case let pollingCallback as PollingWaitCallback: + PollingWaitCallbackView(callback: pollingCallback, onTimeout: onNext) + + case let stringCallback as StringAttributeInputCallback: + StringAttributeInputCallbackView(callback: stringCallback, onNodeUpdated: onNodeUpdated) + + case let termsCallback as TermsAndConditionsCallback: + TermsAndConditionsCallbackView(callback: termsCallback, onNodeUpdated: onNodeUpdated) + + case let textInputCallback as TextInputCallback: + TextInputCallbackView(callback: textInputCallback, onNodeUpdated: onNodeUpdated) + + case let textOutputCallback as TextOutputCallback: + TextOutputCallbackView(callback: textOutputCallback) + + case let suspendedTextCallback as SuspendedTextOutputCallback: + TextOutputCallbackView(callback: suspendedTextCallback) + + case let nameCallback as NameCallback: + NameCallbackView(callback: nameCallback, onNodeUpdated: onNodeUpdated) + + case let validatedUsernameCallback as ValidatedUsernameCallback: + ValidatedUsernameCallbackView(callback: validatedUsernameCallback, onNodeUpdated: onNodeUpdated) + + case let validatedPasswordCallback as ValidatedPasswordCallback: + ValidatedPasswordCallbackView(callback: validatedPasswordCallback, onNodeUpdated: onNodeUpdated) + + case let protectInitCallback as PingOneProtectInitializeCallback: + PingOneProtectInitializeCallbackView(callback: protectInitCallback, onNext: onNext) + + case let protectEvalCallback as PingOneProtectEvaluationCallback: + PingOneProtectEvaluationCallbackView(callback: protectEvalCallback, onNext: onNext) + + case let selectIdpCallback as SelectIdpCallback: + SelectIdpCallbackView(callback: selectIdpCallback, onNext: onNext) + + case let idpCallback as IdpCallback: + let idpCallbackViewModel = IdpCallbackViewModel(callback: idpCallback) + IdpCallbackView(viewModel: idpCallbackViewModel, onNext: onNext) + + case let deviceProfileCallback as DeviceProfileCallback: + DeviceProfileCallbackView(callback: deviceProfileCallback, onNext: onNext) + + case let fidoRegistrationCallback as FidoRegistrationCallback: + FidoRegistrationCallbackView(callback: fidoRegistrationCallback, onNext: onNext) + + case let fidoAuthenticationCallback as FidoAuthenticationCallback: + FidoAuthenticationCallbackView(callback: fidoAuthenticationCallback, onNext: onNext) + + case let reCaptchaEnterpriseCallback as ReCaptchaEnterpriseCallback: + ReCaptchaEnterpriseCallbackView(callback: reCaptchaEnterpriseCallback, onNext: onNext).id(reCaptchaEnterpriseCallback.id) + + case let deviceBindingCallback as DeviceBindingCallback: + DeviceBindingCallbackView(callback: deviceBindingCallback, onNext: onNext) + + case let deviceSigningVerifierCallback as DeviceSigningVerifierCallback: + DeviceSigningVerifierCallbackView(callback: deviceSigningVerifierCallback, onNext: onNext) + + case _ as HiddenValueCallback: + EmptyView() + + default: + Text("Unsupported callback type") + } + } + + if showNext { + Button(action: { + if let selectIDPCallback = continueNode.callbacks.first(where: { + $0 is SelectIdpCallback + }) as? SelectIdpCallback { + selectIDPCallback.value = "localAuthentication" + } + onNext() + }) { + Text("Next") + .frame(maxWidth: .infinity) + .padding() + .background(Color.themeButtonBackground) + .foregroundColor(.white) + .cornerRadius(8) + } + .padding(.top, 16) + } + } + .padding() + } +} diff --git a/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Views/LogOutView.swift b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Views/LogOutView.swift new file mode 100644 index 00000000..a0be3fdb --- /dev/null +++ b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Views/LogOutView.swift @@ -0,0 +1,33 @@ +// +// LogOutView.swift +// JourneyModuleSample +// +// 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 SwiftUI + +/// A view for managing the logout process. +struct LogOutView: View { + /// A binding to the navigation stack path. + @Binding var path: [MenuItem] + /// State object for managing the logout functionality. + @StateObject private var logoutViewModel = LogOutViewModel() + + var body: some View { + Spacer() + NextButton(title: "Proceed to logout") { + Task { + await logoutViewModel.logout() + if path.count > 0 { + path.removeLast() + } + } + } + .navigationTitle(path.last?.title ?? "") + } +} diff --git a/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Views/LoggerView.swift b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Views/LoggerView.swift new file mode 100644 index 00000000..d90b491d --- /dev/null +++ b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Views/LoggerView.swift @@ -0,0 +1,27 @@ +// +// LoggerView.swift +// JourneyModuleSample +// +// 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 SwiftUI + +struct LoggerView: View { + let menuItem: MenuItem + var loggerViewModel = LoggerViewModel() + var body: some View { + Text("This View is for testing Logger functionality.\nPlease check the Console Logs") + .font(.title3) + .multilineTextAlignment(.center) + .navigationTitle(menuItem.title) + .onAppear() { + loggerViewModel.setupLogger() + } + } +} + diff --git a/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Views/SecureFieldView.swift b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Views/SecureFieldView.swift new file mode 100644 index 00000000..d90df75b --- /dev/null +++ b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Views/SecureFieldView.swift @@ -0,0 +1,53 @@ +// +// SecureFieldView.swift +// JourneyModuleSample +// +// 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 SwiftUI +import Combine + +struct SecureFieldView: View { + let label: String + @Binding var value: String + @Binding var isPasswordVisible: Bool + var onValueChange: (String) -> Void + var onAppear: () -> Void + var isError: Bool + var errorMessages: [String] + + var body: some View { + VStack(alignment: .leading) { + HStack { + if isPasswordVisible { + TextField(label, text: $value) + } else { + SecureField(label, text: $value) + } + Button(action: { isPasswordVisible.toggle() }) { + Image(systemName: isPasswordVisible ? "eye.slash" : "eye") + .foregroundStyle(Color.themeButtonBackground) + .frame(width: 20, height: 20) + } + } + .onAppear(perform: onAppear) + .onChange(of: value) { newValue in + onValueChange(value) + } + .padding() + .background( + RoundedRectangle(cornerRadius: 8) + .stroke(isError ? Color.red : Color.gray, lineWidth: 1) + ) + .textInputAutocapitalization(.never) + + if isError { + ErrorMessageView(errors: errorMessages) + } + } + } +} diff --git a/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Views/StorageView.swift b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Views/StorageView.swift new file mode 100644 index 00000000..f1f02d4e --- /dev/null +++ b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Views/StorageView.swift @@ -0,0 +1,29 @@ +// +// StorageView.swift +// JourneyModuleSample +// +// 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 SwiftUI + +struct StorageView: View { + let menuItem: MenuItem + var storageViewModel = StorageViewModel() + var body: some View { + Text("This View is for testing Storage functionality.\nPlease check the Console Logs") + .font(.title3) + .multilineTextAlignment(.center) + .navigationTitle(menuItem.title) + .onAppear() { + Task { + await storageViewModel.setupMemoryStorage() + await storageViewModel.setupKeychainStorage() + } + } + } +} diff --git a/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Views/UserInfoView.swift b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Views/UserInfoView.swift new file mode 100644 index 00000000..54b25d86 --- /dev/null +++ b/iOS/swiftui-journey-module/JourneyModuleSample/JourneyModuleSample/Views/UserInfoView.swift @@ -0,0 +1,29 @@ +// +// UserInfoView.swift +// JourneyModuleSample +// +// Copyright (c) 2024 - 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 SwiftUI + +/// A view that displays user information +struct UserInfoView: View { + let menuItem: MenuItem + /// A state object that manages the user information data. + /// The `UserInfoViewModel` is responsible for fetching and updating user data. + @StateObject private var userInfoViewModel = UserInfoViewModel() + + var body: some View { + ScrollView { + Text(userInfoViewModel.userInfo) + .foregroundStyle(.secondary) + .padding(.horizontal) + .navigationTitle(menuItem.title) + } + } +} diff --git a/iOS/swiftui-journey-module/README.md b/iOS/swiftui-journey-module/README.md new file mode 100644 index 00000000..752111c7 --- /dev/null +++ b/iOS/swiftui-journey-module/README.md @@ -0,0 +1,54 @@ +

+ + Ping Identity Logo + +


+

+ +# Journey Module app using Swift/SwiftUI + +Ping provides these iOS samples to help demonstrate SDK functionality/implementation. They are provided "as is" and are not official products of Ping and are not officially supported. + +### Integrate with PingAM/AIC Journey Module: + +- An example iOS project written in Swift/SwiftUI making use of the SDK. The sample supports journey-based authentication flows with PingAM/AIC, including advanced features like device binding, FIDO authentication, external IdP integration, and PingOne Protect. + +## Requirements + +- Xcode: Latest version recommended +- PingAM/AIC server with configured authentication journeys +- iOS 16.6 or higher + +## Getting Started + +To try out the Journey Module iOS SDK sample, perform these steps: +1. Configure Ping Services + Ensure that you have a PingAM/AIC server configured with authentication journeys and an OAuth 2.0 application for native mobile apps. More details in this [documentation](https://backstage.forgerock.com/docs/sdks/latest/sdks/serverconfiguration/pingone/create-oauth2-client.html). + +2. Clone this repo: + + ``` + git clone https://github.com/ForgeRock/sdk-sample-apps.git + ``` +3. Open the iOS sample project(swiftui-journey-module) in [Xcode](https://developer.apple.com/xcode/). +4. Open the `JourneyViewModel.swift` file within the project. +5. Locate the TODO and replace the placeholder strings with your server configuration: + - `serverUrl`: Your PingAM/AIC server URL + - `realm`: Your authentication realm + - `cookie`: Your session cookie name + - OAuth 2.0 client details in the `PingJourney.OidcModule.config` section +6. Launch the app on an iOS Device or a Simulator. + +## Features + +This sample demonstrates: +- Journey-based authentication with callback handling +- Device binding and biometric authentication +- FIDO2/WebAuthn registration and authentication +- External identity provider integration (Google, Facebook) +- PingOne Protect device risk assessment +- Multi-user support with isolated storage +- Session management and token handling + +## Additional Resources +Ping SDK Documentation: https://docs.pingidentity.com/sdks/latest/sdks/index.html