diff --git a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj index dd0004d02..6a8dc69b2 100644 --- a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj +++ b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj @@ -179,6 +179,7 @@ 3CC063E02B6D7F2A002BB07F /* OneSignalUserMocks.h in Headers */ = {isa = PBXBuildFile; fileRef = 3CC063DF2B6D7F2A002BB07F /* OneSignalUserMocks.h */; settings = {ATTRIBUTES = (Public, ); }; }; 3CC063E62B6D7F96002BB07F /* OneSignalUserMocks.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CC063E52B6D7F96002BB07F /* OneSignalUserMocks.swift */; }; 3CC063EE2B6D7FE8002BB07F /* OneSignalUserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CC063ED2B6D7FE8002BB07F /* OneSignalUserTests.swift */; }; + B91A66287DEA4026A4DC5952 /* OSIdentityModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C1399651D1A401EB888DA77 /* OSIdentityModelTests.swift */; }; 3CC063EF2B6D7FE8002BB07F /* OneSignalUser.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DE69E19B282ED8060090BB3D /* OneSignalUser.framework */; }; 3CC890352C5BF9A7002CB4CC /* UserConcurrencyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CC890342C5BF9A7002CB4CC /* UserConcurrencyTests.swift */; }; 3CC9A6342AFA1FDE008F68FD /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 3CC9A6332AFA1FDD008F68FD /* PrivacyInfo.xcprivacy */; }; @@ -1408,6 +1409,7 @@ 3CC063E52B6D7F96002BB07F /* OneSignalUserMocks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OneSignalUserMocks.swift; sourceTree = ""; }; 3CC063EB2B6D7FE8002BB07F /* OneSignalUserTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = OneSignalUserTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 3CC063ED2B6D7FE8002BB07F /* OneSignalUserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OneSignalUserTests.swift; sourceTree = ""; }; + 6C1399651D1A401EB888DA77 /* OSIdentityModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSIdentityModelTests.swift; sourceTree = ""; }; 3CC890342C5BF9A7002CB4CC /* UserConcurrencyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserConcurrencyTests.swift; sourceTree = ""; }; 3CC9A6332AFA1FDD008F68FD /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; 3CC9A6352AFA26E7008F68FD /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; @@ -2384,6 +2386,7 @@ 3CDE664A2BFC2A55006DA114 /* OneSignalUserTests-Bridging-Header.h */, 3CF11E3E2C6D61AC002856F5 /* Executors */, 3CC063ED2B6D7FE8002BB07F /* OneSignalUserTests.swift */, + 6C1399651D1A401EB888DA77 /* OSIdentityModelTests.swift */, 3CC890342C5BF9A7002CB4CC /* UserConcurrencyTests.swift */, 3CB331672F281679000E1801 /* CustomEventsIntegrationTests.swift */, 3C67F7792BEB2B710085A0F0 /* SwitchUserIntegrationTests.swift */, @@ -4495,6 +4498,7 @@ DE3568F22C8911EA00AF447C /* IdentityExecutorTests.swift in Sources */, 3C67F77A2BEB2B710085A0F0 /* SwitchUserIntegrationTests.swift in Sources */, 3CC063EE2B6D7FE8002BB07F /* OneSignalUserTests.swift in Sources */, + B91A66287DEA4026A4DC5952 /* OSIdentityModelTests.swift in Sources */, 3CC890352C5BF9A7002CB4CC /* UserConcurrencyTests.swift in Sources */, DE3568F02C89067400AF447C /* SubscriptionsExecutorTests.swift in Sources */, 3CB3316A2F281692000E1801 /* OSCustomEventsExecutorTests.swift in Sources */, diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Modeling/OSIdentityModel.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Modeling/OSIdentityModel.swift index dcbe778d7..2c5d158e0 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Modeling/OSIdentityModel.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Modeling/OSIdentityModel.swift @@ -38,23 +38,55 @@ class OSIdentityModel: OSModel { return internalGetAlias(OS_EXTERNAL_ID) } - // All access to aliases should go through helper methods with locking + // All access to aliases and jwtBearerToken must go through the lock var aliases: [String: String] = [:] - private let aliasesLock = NSRecursiveLock() + private let lock = NSRecursiveLock() // MARK: - JWT + private var jwtBearerTokenLocked: String? // only read/write under self.lock public var jwtBearerToken: String? { - didSet { - guard jwtBearerToken != oldValue else { - return + get { + lock.withLock { jwtBearerTokenLocked } + } + set { + // Lock only the storage write. The change notifier fires synchronously + // to listeners that may take other locks + let changed = lock.withLock { + guard newValue != jwtBearerTokenLocked else { return false } + jwtBearerTokenLocked = newValue + return true + } + if changed { + self.set(property: OS_JWT_BEARER_TOKEN, newValue: newValue) } - self.set(property: OS_JWT_BEARER_TOKEN, newValue: jwtBearerToken) } } - func isJwtValid() -> Bool { - return jwtBearerToken != nil && jwtBearerToken != "" && jwtBearerToken != OS_JWT_TOKEN_INVALID + /// Returns the bearer token if it is valid, otherwise nil, snapshots once + func getValidJwt() -> String? { + let token = jwtBearerToken + guard let token = token, !token.isEmpty, token != OS_JWT_TOKEN_INVALID else { + return nil + } + return token + } + + /** + Atomically transition the JWT token to `OS_JWT_TOKEN_INVALID`. Returns + `true` if the transition occurred, `false` if the token was already invalid. + */ + @discardableResult + func invalidateJwtBearerToken() -> Bool { + let changed = lock.withLock { + guard jwtBearerTokenLocked != OS_JWT_TOKEN_INVALID else { return false } + jwtBearerTokenLocked = OS_JWT_TOKEN_INVALID + return true + } + if changed { + self.set(property: OS_JWT_BEARER_TOKEN, newValue: OS_JWT_TOKEN_INVALID) + } + return changed } // MARK: - Initialization @@ -66,10 +98,10 @@ class OSIdentityModel: OSModel { } override func encode(with coder: NSCoder) { - aliasesLock.withLock { + lock.withLock { super.encode(with: coder) coder.encode(aliases, forKey: "aliases") - coder.encode(jwtBearerToken, forKey: OS_JWT_BEARER_TOKEN) + coder.encode(jwtBearerTokenLocked, forKey: OS_JWT_BEARER_TOKEN) } } @@ -79,20 +111,20 @@ class OSIdentityModel: OSModel { // log error return nil } - self.jwtBearerToken = coder.decodeObject(forKey: OS_JWT_BEARER_TOKEN) as? String + self.jwtBearerTokenLocked = coder.decodeObject(forKey: OS_JWT_BEARER_TOKEN) as? String self.aliases = aliases } /** Threadsafe getter for an alias */ private func internalGetAlias(_ label: String) -> String? { - aliasesLock.withLock { + lock.withLock { return self.aliases[label] } } /** Threadsafe setter or removal for aliases */ private func internalAddAliases(_ aliases: [String: String]) { - aliasesLock.withLock { + lock.withLock { for (label, id) in aliases { // Remove the alias if the ID field is "" self.aliases[label] = id.isEmpty ? nil : id @@ -105,7 +137,7 @@ class OSIdentityModel: OSModel { Called to clear the model's data in preparation for hydration via a fetch user call. */ func clearData() { - aliasesLock.withLock { + lock.withLock { self.aliases = [:] } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSIdentityModelRepo.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSIdentityModelRepo.swift index ef82e264e..37e7e007e 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSIdentityModelRepo.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OSIdentityModelRepo.swift @@ -73,17 +73,19 @@ class OSIdentityModelRepo { This can be optimized in the future to re-use an Identity Model if multiple logins are made for the same user. */ func updateJwtToken(externalId: String, token: String) { - var found = false - lock.withLock { - for model in models.values { - if model.externalId == externalId { - model.jwtBearerToken = token - found = true - } - } + // Snapshot matching models under the repo lock, then mutate outside. + // Writing the token fires the model's change notifier synchronously + // (→ onModelUpdated → onJwtTokenChanged); doing that while holding the + // repo lock leaves a trap for future listeners to deadlock on. + let matchingModels: [OSIdentityModel] = lock.withLock { + models.values.filter { $0.externalId == externalId } } - if !found { + guard !matchingModels.isEmpty else { OneSignalLog.onesignalLog(ONE_S_LOG_LEVEL.LL_ERROR, message: "Update User JWT called for external ID \(externalId) that does not exist") + return + } + for model in matchingModels { + model.jwtBearerToken = token } } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift index 2db7d5381..5fe73b4e8 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift @@ -412,9 +412,7 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { // JWT is required - if _user.identityModel.isJwtValid(), - let token = _user.identityModel.jwtBearerToken - { + if let token = _user.identityModel.getValidJwt() { fullHeader["Authorization"] = "Bearer \(token)" return fullHeader } @@ -716,14 +714,9 @@ extension OneSignalUserManagerImpl { return } - // Return, if the token has already been invalidated - guard identityModel.jwtBearerToken != OS_JWT_TOKEN_INVALID else { - return + if identityModel.invalidateJwtBearerToken() { + fireJwtExpired(externalId: externalId) } - - identityModel.jwtBearerToken = OS_JWT_TOKEN_INVALID - - fireJwtExpired(externalId: externalId) } private func fireJwtExpired(externalId: String) { diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSUserRequest.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSUserRequest.swift index 52bebf57e..fa435d421 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSUserRequest.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Requests/OSUserRequest.swift @@ -70,12 +70,10 @@ internal extension OneSignalRequest { | --------------- | -------------- | ------- | ------- | */ func addJWTHeaderIsValid(identityModel: OSIdentityModel) -> Bool { - let tokenIsValid = identityModel.isJwtValid() + let validToken = identityModel.getValidJwt() let required = OneSignalUserManagerImpl.sharedInstance.jwtConfig.isRequired - let canBeSent = (required == false) || (required == true && tokenIsValid) - if canBeSent && tokenIsValid, - let token = identityModel.jwtBearerToken - { + let canBeSent = (required == false) || (required == true && validToken != nil) + if canBeSent, let token = validToken { // Add the JWT token if it is valid, regardless of requirements var additionalHeaders = self.additionalHeaders ?? [String: String]() additionalHeaders["Authorization"] = "Bearer \(token)" diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/OSIdentityModelTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OSIdentityModelTests.swift new file mode 100644 index 000000000..2b8756ade --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/OSIdentityModelTests.swift @@ -0,0 +1,90 @@ +/* + Modified MIT License + + Copyright 2026 OneSignal + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + 1. The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + 2. All copies of substantial portions of the Software may only be used in connection + with services provided by OneSignal. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + */ + +import XCTest +import OneSignalCore +@testable import OneSignalOSCore +@testable import OneSignalUser + +/// Tests for the two new JWT APIs added to `OSIdentityModel`: +/// - `getValidJwt()` snapshots and returns the bearer token only when it is +/// non-nil, non-empty, and not the `OS_JWT_TOKEN_INVALID` sentinel. +/// - `invalidateJwtBearerToken()` performs an atomic compare-and-set to +/// `OS_JWT_TOKEN_INVALID`, returning `true` only on the transition. +final class OSIdentityModelTests: XCTestCase { + + private func makeModel(token: String? = nil) -> OSIdentityModel { + let model = OSIdentityModel(aliases: [:], changeNotifier: OSEventProducer()) + model.jwtBearerToken = token + return model + } + + // MARK: - getValidJwt() + + func testGetValidJwt_returnsNil_whenTokenIsNil() { + XCTAssertNil(makeModel(token: nil).getValidJwt()) + } + + func testGetValidJwt_returnsNil_whenTokenIsEmptyString() { + XCTAssertNil(makeModel(token: "").getValidJwt()) + } + + func testGetValidJwt_returnsNil_whenTokenIsInvalidSentinel() { + XCTAssertNil(makeModel(token: OS_JWT_TOKEN_INVALID).getValidJwt()) + } + + func testGetValidJwt_returnsToken_whenTokenIsValid() { + let token = "eyJhbGciOiJFUzI1NiJ9.payload.sig" + XCTAssertEqual(makeModel(token: token).getValidJwt(), token) + } + + // MARK: - invalidateJwtBearerToken() + + func testInvalidate_returnsTrueOnFirstTransition_andSetsInvalidSentinel() { + let model = makeModel(token: "valid-token") + + XCTAssertTrue(model.invalidateJwtBearerToken()) + XCTAssertEqual(model.jwtBearerToken, OS_JWT_TOKEN_INVALID) + } + + func testInvalidate_returnsFalseWhenAlreadyInvalid() { + let model = makeModel(token: "valid-token") + _ = model.invalidateJwtBearerToken() + + XCTAssertFalse(model.invalidateJwtBearerToken()) + XCTAssertEqual(model.jwtBearerToken, OS_JWT_TOKEN_INVALID) + } + + func testInvalidate_returnsTrueWhenStartingFromNil() { + // Defensive: nil → INVALID is still a real transition, the model lands + // on the sentinel and the caller can fire fireJwtExpired once. + let model = makeModel(token: nil) + + XCTAssertTrue(model.invalidateJwtBearerToken()) + XCTAssertEqual(model.jwtBearerToken, OS_JWT_TOKEN_INVALID) + } +} diff --git a/iOS_SDK/OneSignalSwiftUIExample/OneSignalSwiftUIExample/Models/AppModels.swift b/iOS_SDK/OneSignalSwiftUIExample/OneSignalSwiftUIExample/Models/AppModels.swift index 7bd1f3a7c..da41d43a3 100644 --- a/iOS_SDK/OneSignalSwiftUIExample/OneSignalSwiftUIExample/Models/AppModels.swift +++ b/iOS_SDK/OneSignalSwiftUIExample/OneSignalSwiftUIExample/Models/AppModels.swift @@ -79,6 +79,7 @@ enum AddItemType { case tag case trigger case externalUserId + case updateUserJwt case customNotification case trackEvent @@ -90,6 +91,7 @@ enum AddItemType { case .tag: return "Add Tag" case .trigger: return "Add Trigger" case .externalUserId: return "Login User" + case .updateUserJwt: return "Update User JWT" case .customNotification: return "Custom Notification" case .trackEvent: return "Track Event" } @@ -97,8 +99,17 @@ enum AddItemType { var requiresKeyValue: Bool { switch self { - case .alias, .tag, .trigger, .customNotification: return true - case .email, .sms, .externalUserId, .trackEvent: return false + case .alias, .tag, .trigger, .customNotification, .externalUserId, .updateUserJwt: return true + case .email, .sms, .trackEvent: return false + } + } + + /// When true, the second (value) field may be left empty and validation still passes. + /// Used for `.externalUserId` where the JWT token is optional. + var valueIsOptional: Bool { + switch self { + case .externalUserId: return true + default: return false } } @@ -108,6 +119,7 @@ enum AddItemType { case .tag: return "Key" case .trigger: return "Key" case .customNotification: return "Title" + case .externalUserId, .updateUserJwt: return "External User Id" default: return "Key" } } @@ -119,7 +131,8 @@ enum AddItemType { case .sms: return "SMS" case .tag: return "Value" case .trigger: return "Value" - case .externalUserId: return "External User Id" + case .externalUserId: return "JWT Token (Optional)" + case .updateUserJwt: return "JWT Token" case .customNotification: return "Body" case .trackEvent: return "Event Name" } diff --git a/iOS_SDK/OneSignalSwiftUIExample/OneSignalSwiftUIExample/Services/OneSignalService.swift b/iOS_SDK/OneSignalSwiftUIExample/OneSignalSwiftUIExample/Services/OneSignalService.swift index 981fe99a9..a7487307f 100644 --- a/iOS_SDK/OneSignalSwiftUIExample/OneSignalSwiftUIExample/Services/OneSignalService.swift +++ b/iOS_SDK/OneSignalSwiftUIExample/OneSignalSwiftUIExample/Services/OneSignalService.swift @@ -87,8 +87,12 @@ final class OneSignalService { // MARK: - User Management - func login(externalId: String) { - OneSignal.login(externalId) + func login(externalId: String, token: String?) { + OneSignal.login(externalId: externalId, token: token) + } + + func updateUserJwt(externalId: String, token: String) { + OneSignal.updateUserJwt(externalId: externalId, token: token) } func logout() { diff --git a/iOS_SDK/OneSignalSwiftUIExample/OneSignalSwiftUIExample/ViewModels/OneSignalViewModel.swift b/iOS_SDK/OneSignalSwiftUIExample/OneSignalSwiftUIExample/ViewModels/OneSignalViewModel.swift index b7d4a0624..5cf04c6fa 100644 --- a/iOS_SDK/OneSignalSwiftUIExample/OneSignalSwiftUIExample/ViewModels/OneSignalViewModel.swift +++ b/iOS_SDK/OneSignalSwiftUIExample/OneSignalSwiftUIExample/ViewModels/OneSignalViewModel.swift @@ -188,12 +188,13 @@ final class OneSignalViewModel: ObservableObject { // MARK: - User Management - func login(externalId: String) { - isLoading = true - service.login(externalId: externalId) + func login(externalId: String, token: String? = nil) { + service.login(externalId: externalId, token: token) externalUserId = externalId // Clear old data; will be repopulated by fetchUserDataFromApi when user state changes + // (or stay empty if login fails — e.g. invalid JWT triggers a 401 and the observer + // never fires; that's fine, the UI just shows empty state instead of hanging). aliases.removeAll() emails.removeAll() smsNumbers.removeAll() @@ -202,6 +203,11 @@ final class OneSignalViewModel: ObservableObject { showToast("Logged in as \(externalId)") } + func updateUserJwt(externalId: String, token: String) { + service.updateUserJwt(externalId: externalId, token: token) + showToast("Updated JWT for \(externalId)") + } + func logout() { isLoading = true service.logout() @@ -550,7 +556,10 @@ extension OneSignalViewModel { case .trigger: addTrigger(key: key, value: value) case .externalUserId: - login(externalId: value) + let trimmedToken = value.trimmingCharacters(in: .whitespaces) + login(externalId: key, token: trimmedToken.isEmpty ? nil : trimmedToken) + case .updateUserJwt: + updateUserJwt(externalId: key, token: value) case .customNotification: sendCustomNotification(title: key, body: value) case .trackEvent: diff --git a/iOS_SDK/OneSignalSwiftUIExample/OneSignalSwiftUIExample/Views/Components/AddItemSheet.swift b/iOS_SDK/OneSignalSwiftUIExample/OneSignalSwiftUIExample/Views/Components/AddItemSheet.swift index 62ee72211..ac455db38 100644 --- a/iOS_SDK/OneSignalSwiftUIExample/OneSignalSwiftUIExample/Views/Components/AddItemSheet.swift +++ b/iOS_SDK/OneSignalSwiftUIExample/OneSignalSwiftUIExample/Views/Components/AddItemSheet.swift @@ -99,7 +99,7 @@ struct AddItemSheet: View { } .foregroundColor(.accentColor) - Button(itemType == .externalUserId ? "LOGIN" : "ADD") { + Button(actionButtonTitle) { onAdd(keyText, valueText) } .foregroundColor(isValid ? .accentColor : .gray) @@ -127,12 +127,22 @@ struct AddItemSheet: View { private var isValid: Bool { if itemType.requiresKeyValue { - return !keyText.trimmingCharacters(in: .whitespaces).isEmpty && - !valueText.trimmingCharacters(in: .whitespaces).isEmpty + let keyOk = !keyText.trimmingCharacters(in: .whitespaces).isEmpty + let valueOk = itemType.valueIsOptional + || !valueText.trimmingCharacters(in: .whitespaces).isEmpty + return keyOk && valueOk } else { return !valueText.trimmingCharacters(in: .whitespaces).isEmpty } } + + private var actionButtonTitle: String { + switch itemType { + case .externalUserId: return "LOGIN" + case .updateUserJwt: return "UPDATE" + default: return "ADD" + } + } } /// A text field style with an underline instead of a border diff --git a/iOS_SDK/OneSignalSwiftUIExample/OneSignalSwiftUIExample/Views/Sections/UserSection.swift b/iOS_SDK/OneSignalSwiftUIExample/OneSignalSwiftUIExample/Views/Sections/UserSection.swift index 0cbe80a64..386772c7b 100644 --- a/iOS_SDK/OneSignalSwiftUIExample/OneSignalSwiftUIExample/Views/Sections/UserSection.swift +++ b/iOS_SDK/OneSignalSwiftUIExample/OneSignalSwiftUIExample/Views/Sections/UserSection.swift @@ -74,6 +74,12 @@ struct UserSection: View { } .padding(.top, 12) + // Update User JWT button + ActionButton(title: "Update User JWT") { + viewModel.showAddSheet(for: .updateUserJwt) + } + .padding(.top, 8) + // Logout button (outlined, only when logged in) if viewModel.isLoggedIn { OutlineActionButton(title: "Logout User") {