diff --git a/examples/demo/App.xcodeproj/project.pbxproj b/examples/demo/App.xcodeproj/project.pbxproj index ae17cd23e..04b561eb5 100644 --- a/examples/demo/App.xcodeproj/project.pbxproj +++ b/examples/demo/App.xcodeproj/project.pbxproj @@ -42,6 +42,7 @@ 5B959D44AB09CB821C00AFBF /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35F726E64F9B6817F917227C /* ContentView.swift */; }; 5F4B7EC8437D1A8D80DF7674 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20B46F63592FC67B655BEB8 /* NotificationService.swift */; }; 638B81D9DA5FD8636BB038B0 /* OneSignalUser.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EF49509A218369322ECFA3B9 /* OneSignalUser.framework */; }; + 673E0146B4B27EB81FCF50DA /* LoginUserDialog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DD26AED9EA84B0308C1BFE8 /* LoginUserDialog.swift */; }; 674995A7A55C13341317E19B /* OSDialog.swift in Sources */ = {isa = PBXBuildFile; fileRef = D261D46C404E325CBA87A9E0 /* OSDialog.swift */; }; 68BC99D15FDCB26EB35EBB07 /* OneSignalLocation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FE9834773C437CC373607693 /* OneSignalLocation.framework */; }; 6E3E040FD8A750248E70E46F /* AppModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7EA9D80191548D49F09D30B3 /* AppModels.swift */; }; @@ -324,6 +325,7 @@ 38138523A8A81A60A77800CA /* NotificationSender.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSender.swift; sourceTree = ""; }; 3A654457BF0A55B54220E669 /* RemoveMultiDialog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoveMultiDialog.swift; sourceTree = ""; }; 3B81D7E1A7EB9BB4466C768F /* SendPushSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendPushSection.swift; sourceTree = ""; }; + 3DD26AED9EA84B0308C1BFE8 /* LoginUserDialog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginUserDialog.swift; sourceTree = ""; }; 3FD8258E807E6672642A32E6 /* UserFetchService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserFetchService.swift; sourceTree = ""; }; 4072202943CA64D2CBC38CB5 /* MultiPairInputDialog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiPairInputDialog.swift; sourceTree = ""; }; 4078B5F096680AFA83D1CB85 /* OutcomesSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OutcomesSection.swift; sourceTree = ""; }; @@ -512,6 +514,7 @@ 2D959B1636916DAEE5FE6278 /* CustomNotificationDialog.swift */, 5984B93007C6B85AFE09045A /* KeyValueRow.swift */, 280B23B41935EAB89C8C6FCB /* ListWidgets.swift */, + 3DD26AED9EA84B0308C1BFE8 /* LoginUserDialog.swift */, 4072202943CA64D2CBC38CB5 /* MultiPairInputDialog.swift */, D261D46C404E325CBA87A9E0 /* OSDialog.swift */, D54B9DAAEDBE67B73893C522 /* OutcomeDialog.swift */, @@ -828,6 +831,7 @@ 7D2BA9022E77B00205453467 /* LiveActivityController.swift in Sources */, DBDA727D6317A3CCC73A1699 /* LiveActivitySection.swift in Sources */, B3AE701075398C6A369DBBE0 /* LocationSection.swift in Sources */, + 673E0146B4B27EB81FCF50DA /* LoginUserDialog.swift in Sources */, E3725231A3FD5F5A88BAA758 /* MultiPairInputDialog.swift in Sources */, 4C18E3D284BB28BD846162F3 /* NotificationSender.swift in Sources */, 674995A7A55C13341317E19B /* OSDialog.swift in Sources */, diff --git a/examples/demo/App/Models/AppModels.swift b/examples/demo/App/Models/AppModels.swift index 70ccd615a..d98591df0 100644 --- a/examples/demo/App/Models/AppModels.swift +++ b/examples/demo/App/Models/AppModels.swift @@ -80,6 +80,7 @@ enum AddItemType { case tag case trigger case externalUserId + case updateUserJwt var title: String { switch self { @@ -89,12 +90,13 @@ enum AddItemType { case .tag: return "Add Tag" case .trigger: return "Add Trigger" case .externalUserId: return "Login User" + case .updateUserJwt: return "Update User JWT" } } var requiresKeyValue: Bool { switch self { - case .alias, .tag, .trigger: return true + case .alias, .tag, .trigger, .updateUserJwt: return true case .email, .sms, .externalUserId: return false } } @@ -103,6 +105,7 @@ enum AddItemType { switch self { case .alias: return "Label" case .tag, .trigger: return "Key" + case .updateUserJwt: return "External User Id" default: return "Key" } } @@ -114,6 +117,7 @@ enum AddItemType { case .sms: return "Phone Number" case .tag, .trigger: return "Value" case .externalUserId: return "External User Id" + case .updateUserJwt: return "JWT Token" } } @@ -141,6 +145,7 @@ enum AddItemType { case .tag: return "tag" case .trigger: return "trigger" case .externalUserId: return "login_user_id" + case .updateUserJwt: return "update_jwt" } } @@ -152,6 +157,7 @@ enum AddItemType { case .alias: return "alias_label_input" case .tag: return "tag_key_input" case .trigger: return "trigger_key_input" + case .updateUserJwt: return "update_jwt_external_id_input" default: return "\(accessibilityKey)_key_input" } } @@ -165,6 +171,7 @@ enum AddItemType { case .alias: return "alias_id_input" case .tag: return "tag_value_input" case .trigger: return "trigger_value_input" + case .updateUserJwt: return "update_jwt_token_input" default: return "\(accessibilityKey)_input" } } diff --git a/examples/demo/App/Services/OneSignalService.swift b/examples/demo/App/Services/OneSignalService.swift index 598189ea0..d55fa0df8 100644 --- a/examples/demo/App/Services/OneSignalService.swift +++ b/examples/demo/App/Services/OneSignalService.swift @@ -49,10 +49,13 @@ final class OneSignalService { // MARK: - Initialization /// Mirrors the Capacitor demo's `useOneSignal` init order: feed cached - /// consent into the SDK BEFORE `initialize`, then restore IAM-paused, - /// location-shared, and a previously-logged-in external user id once the - /// SDK is ready. Without this, toggles flip back to defaults on every - /// cold launch. + /// consent into the SDK BEFORE `initialize`, then restore IAM-paused and + /// location-shared once the SDK is ready. Without this, toggles flip back + /// to defaults on every cold launch. + /// + /// No login here. The SDK restores the user from its own cache, and the stored JWT + /// stays with the demo so the SDK's own ask through `OSUserJwtInvalidatedListener` + /// stays observable on cold start. func initialize(launchOptions: [UIApplication.LaunchOptionsKey: Any]?) { OneSignal.Debug.setLogLevel(.LL_VERBOSE) @@ -63,10 +66,6 @@ final class OneSignalService { OneSignal.InAppMessages.paused = prefs.getIamPaused() OneSignal.Location.isShared = prefs.getLocationShared() - - if let storedExternalId = prefs.getExternalUserId() { - OneSignal.login(storedExternalId) - } } // MARK: - Identity @@ -96,16 +95,51 @@ final class OneSignalService { // MARK: - User - func login(externalId: String) { + func login(externalId: String, jwtToken: String? = nil) { + // Demo REST fetch only. Not replayed to the SDK at launch. A same-user login + // without a token keeps the stored one, matching the SDK, which keeps its own. + if jwtToken != nil || externalId != OneSignal.User.externalId { + prefs.setSessionJwtToken(jwtToken) + } prefs.setExternalUserId(externalId) - OneSignal.login(externalId) + if let jwtToken = jwtToken { + OneSignal.login(externalId: externalId, token: jwtToken) + } else { + OneSignal.login(externalId) + } + } + + func updateUserJwt(externalId: String, token: String) { + // The demo's REST bearer belongs to the current user. A token for anyone else + // still goes to the SDK, which owns the parked work for that id. + if externalId == OneSignal.User.externalId { + prefs.setSessionJwtToken(token) + } + OneSignal.updateUserJwt(externalId: externalId, token: token) } func logout() { prefs.setExternalUserId(nil) + prefs.setSessionJwtToken(nil) OneSignal.logout() } + /// Called when the SDK rejects the token, so the REST fetch stops sending it. + func clearSessionJwtToken() { + prefs.setSessionJwtToken(nil) + } + + // MARK: - Identity Verification (demo REST fetch) + + /// Demo toggle for addressing the REST user fetch by `external_id`. Persisted across launches. + var useIdentityVerification: Bool { + get { prefs.getUseIdentityVerification() } + set { prefs.setUseIdentityVerification(newValue) } + } + + /// Token from the last login / updateUserJwt, for the demo REST fetch only. Cleared when the SDK rejects it. + var sessionJwtToken: String? { prefs.getSessionJwtToken() } + // MARK: - Aliases func addAlias(label: String, id: String) { OneSignal.User.addAlias(label: label, id: id) } @@ -222,6 +256,14 @@ final class OneSignalService { OneSignal.Notifications.addPermissionObserver(observer) } + func addUserJwtInvalidatedListener(_ listener: OSUserJwtInvalidatedListener) { + OneSignal.addUserJwtInvalidatedListener(listener) + } + + func removeUserJwtInvalidatedListener(_ listener: OSUserJwtInvalidatedListener) { + OneSignal.removeUserJwtInvalidatedListener(listener) + } + func addNotificationClickListener(_ listener: OSNotificationClickListener) { OneSignal.Notifications.addClickListener(listener) } diff --git a/examples/demo/App/Services/PreferencesService.swift b/examples/demo/App/Services/PreferencesService.swift index b055224f7..f10e31358 100644 --- a/examples/demo/App/Services/PreferencesService.swift +++ b/examples/demo/App/Services/PreferencesService.swift @@ -28,9 +28,9 @@ import Foundation /// `UserDefaults`-backed cache for state the demo restores across cold launches: -/// consent flags, IAM paused, location shared, and the last-logged-in external -/// user id. Mirrors the Capacitor demo's `PreferencesService` so the iOS demo -/// re-feeds these into the SDK during initialization. +/// consent flags, IAM paused, location shared, the last-logged-in external user id, +/// the Identity Verification toggle, and the demo REST JWT. The JWT is only for the +/// demo's `/users` fetch — it is not automatically passed to `login` / `updateUserJwt`. final class PreferencesService { static let shared = PreferencesService() @@ -47,6 +47,8 @@ final class PreferencesService { static let iamPaused = "onesignal.demo.iamPaused" static let locationShared = "onesignal.demo.locationShared" static let externalUserId = "onesignal.demo.externalUserId" + static let useIdentityVerification = "onesignal.demo.useIdentityVerification" + static let sessionJwtToken = "onesignal.demo.sessionJwtToken" } // MARK: - Consent @@ -83,4 +85,24 @@ final class PreferencesService { defaults.removeObject(forKey: Key.externalUserId) } } + + // MARK: - Identity Verification (demo REST fetch) + + func getUseIdentityVerification() -> Bool { defaults.bool(forKey: Key.useIdentityVerification) } + func setUseIdentityVerification(_ value: Bool) { defaults.set(value, forKey: Key.useIdentityVerification) } + + func getSessionJwtToken() -> String? { + guard let value = defaults.string(forKey: Key.sessionJwtToken), !value.isEmpty else { + return nil + } + return value + } + + func setSessionJwtToken(_ value: String?) { + if let value = value, !value.isEmpty { + defaults.set(value, forKey: Key.sessionJwtToken) + } else { + defaults.removeObject(forKey: Key.sessionJwtToken) + } + } } diff --git a/examples/demo/App/Services/UserFetchService.swift b/examples/demo/App/Services/UserFetchService.swift index 836559eaa..7aeb969c4 100644 --- a/examples/demo/App/Services/UserFetchService.swift +++ b/examples/demo/App/Services/UserFetchService.swift @@ -27,29 +27,66 @@ import Foundation -/// Reads the OneSignal /users API to hydrate aliases / tags / channels in the demo +/// Why a demo /users fetch returned nothing, shown in the User section so a 401 under +/// Identity Verification is not mistaken for an empty user. +enum UserFetchError: Error, CustomStringConvertible { + case badRequest + case http(Int) + case badBody + case transport(Error) + + var description: String { + switch self { + case .badRequest: return "bad request" + case .http(let status): return "HTTP \(status)" + case .badBody: return "unreadable body" + case .transport(let error): return error.localizedDescription + } + } +} + +/// Reads the OneSignal /users API to hydrate aliases / tags / channels in the demo. final class UserFetchService { static let shared = UserFetchService() private init() {} - func fetchUser(appId: String, onesignalId: String) async -> UserData? { - let urlString = "https://api.onesignal.com/apps/\(appId)/users/by/onesignal_id/\(onesignalId)" - guard let url = URL(string: urlString) else { return nil } + /// Fetches by `onesignal_id` (unsigned) or `external_id` (Bearer when `jwt` is set). + func fetchUser( + appId: String, + aliasLabel: String, + aliasValue: String, + jwt: String? = nil + ) async -> Result { + guard !aliasValue.isEmpty else { return .failure(.badRequest) } + + // Path-encode so external_ids with reserved characters don't misroute the GET. + var allowed = CharacterSet.urlPathAllowed + allowed.remove(charactersIn: "/") + guard + let encodedAlias = aliasValue.addingPercentEncoding(withAllowedCharacters: allowed), + let url = URL(string: "https://api.onesignal.com/apps/\(appId)/users/by/\(aliasLabel)/\(encodedAlias)") + else { + return .failure(.badRequest) + } var request = URLRequest(url: url) request.httpMethod = "GET" + request.setValue("application/json", forHTTPHeaderField: "Accept") + if let jwt = jwt { + request.setValue("Bearer \(jwt)", forHTTPHeaderField: "Authorization") + } do { let (data, response) = try await URLSession.shared.data(for: request) guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { - return nil + return .failure(.http((response as? HTTPURLResponse)?.statusCode ?? -1)) } guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { - return nil + return .failure(.badBody) } - return parse(json) + return .success(parse(json)) } catch { - return nil + return .failure(.transport(error)) } } diff --git a/examples/demo/App/ViewModels/OneSignalViewModel.swift b/examples/demo/App/ViewModels/OneSignalViewModel.swift index e158be52d..14fdb8330 100644 --- a/examples/demo/App/ViewModels/OneSignalViewModel.swift +++ b/examples/demo/App/ViewModels/OneSignalViewModel.swift @@ -43,6 +43,11 @@ final class OneSignalViewModel: ObservableObject { @Published var externalUserId: String? @Published var aliases: [KeyValueItem] = [] + @Published var useIdentityVerification: Bool = false + /// External id the SDK is waiting on a JWT for. Drives the banner in `UserSection`. + @Published var jwtAskExternalId: String? + /// Outcome of the last demo /users fetch, "OK" or the failure reason. + @Published var userFetchStatus: String? // MARK: - Push @@ -99,6 +104,7 @@ final class OneSignalViewModel: ObservableObject { self.appId = service.appId self.consentRequired = service.consentRequired self.consentGiven = service.consentGiven + self.useIdentityVerification = service.useIdentityVerification self.externalUserId = service.externalId ?? prefs.getExternalUserId() self.hasNotificationPermission = service.hasNotificationPermission refreshState() @@ -106,7 +112,12 @@ final class OneSignalViewModel: ObservableObject { TooltipService.shared.loadIfNeeded() - if service.onesignalId != nil { + // Demo REST hydrate only — does not call login / updateUserJwt with a cached JWT. + if useIdentityVerification { + if externalUserId != nil || service.externalId != nil { + Task { await fetchUserDataFromApi() } + } + } else if service.onesignalId != nil { Task { await fetchUserDataFromApi() } } } @@ -119,24 +130,49 @@ final class OneSignalViewModel: ObservableObject { isInAppMessagesPaused = service.isInAppMessagesPaused isLocationShared = service.isLocationShared hasNotificationPermission = service.hasNotificationPermission - externalUserId = service.externalId + externalUserId = service.externalId ?? prefs.getExternalUserId() let sdkTags = service.getTags() tags = sdkTags.map { KeyValueItem(key: $0.key, value: $0.value) } } func fetchUserDataFromApi() async { - guard let onesignalId = service.onesignalId else { return } + let aliasLabel: String + let aliasValue: String + let jwt: String? + + if useIdentityVerification { + guard let externalId = externalUserId ?? service.externalId, !externalId.isEmpty else { + return + } + aliasLabel = "external_id" + aliasValue = externalId + jwt = service.sessionJwtToken + } else { + guard let onesignalId = service.onesignalId, !onesignalId.isEmpty else { + return + } + aliasLabel = "onesignal_id" + aliasValue = onesignalId + jwt = nil + } + requestSequence &+= 1 let captured = requestSequence isLoading = true - let userData = await UserFetchService.shared.fetchUser(appId: appId, onesignalId: onesignalId) + let result = await UserFetchService.shared.fetchUser( + appId: appId, + aliasLabel: aliasLabel, + aliasValue: aliasValue, + jwt: jwt + ) // Drop the result if a newer fetch has started while this one was in flight. guard captured == requestSequence else { return } - if let userData = userData { + switch result { + case .success(let userData): aliases = userData.aliases.map { KeyValueItem(key: $0.key, value: $0.value) } tags = userData.tags.map { KeyValueItem(key: $0.key, value: $0.value) } emails = userData.emails @@ -144,6 +180,10 @@ final class OneSignalViewModel: ObservableObject { if let extId = userData.externalId, !extId.isEmpty { externalUserId = extId } + userFetchStatus = "OK" + case .failure(let error): + userFetchStatus = "\(aliasLabel) fetch failed: \(error)" + print("[OneSignal] /users fetch by \(aliasLabel) failed: \(error)") } isLoading = false } @@ -166,21 +206,63 @@ final class OneSignalViewModel: ObservableObject { // MARK: - User - func login(externalId: String) { + func login(externalId: String, jwtToken: String? = nil) { let trimmed = externalId.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return } + let sameUser = trimmed == service.externalId isLoading = true - service.login(externalId: trimmed) + service.login(externalId: trimmed, jwtToken: jwtToken) externalUserId = trimmed - clearUserData() + if jwtToken != nil, jwtAskExternalId == trimmed { + jwtAskExternalId = nil + } + if sameUser { + // No user-state event fires for the same user, so refetch here instead. + Task { await fetchUserDataFromApi() } + } else { + clearUserData() + } + } + + func updateUserJwt(externalId: String, token: String) { + let trimmedId = externalId.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedToken = token.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedId.isEmpty, !trimmedToken.isEmpty else { return } + // Same condition the service uses to decide whether to store the bearer. + let currentUser = trimmedId == service.externalId + service.updateUserJwt(externalId: trimmedId, token: trimmedToken) + if jwtAskExternalId == trimmedId { + jwtAskExternalId = nil + } + print("[OneSignal] Updated JWT for: \(trimmedId)") + if currentUser { + // The demo now holds a fresh bearer for this user, and no user-state + // event is guaranteed, so refetch instead of leaving the stale failure. + Task { await fetchUserDataFromApi() } + } + } + + /// The SDK parked this user's requests for want of a token. Drop the demo's stale copy and show the ask. + func handleJwtInvalidated(externalId: String) { + service.clearSessionJwtToken() + jwtAskExternalId = externalId + print("[OneSignal] JWT invalidated for externalId: \(externalId)") } func logout() { service.logout() externalUserId = nil + jwtAskExternalId = nil clearUserData() } + func setUseIdentityVerification(_ enabled: Bool) { + useIdentityVerification = enabled + service.useIdentityVerification = enabled + print("[OneSignal] Fetch by external_id \(enabled ? "enabled" : "disabled")") + Task { await fetchUserDataFromApi() } + } + private func clearUserData() { aliases.removeAll() emails.removeAll() @@ -284,6 +366,53 @@ final class OneSignalViewModel: ObservableObject { tags.removeAll { keys.contains($0.key) } } + // MARK: - Location + + func setLocationShared(_ shared: Bool) { + isLocationShared = shared + service.isLocationShared = shared + } + + func promptLocation() { + service.requestLocationPermission() + } + + func checkLocationShared() -> Bool { + let shared = service.isLocationShared + print("[OneSignal] Location shared: \(shared)") + return shared + } + + // MARK: - Tooltips + + func showTooltip(for key: String) { + if let tooltip = TooltipService.shared.tooltip(for: key) { + activeTooltip = tooltip + } + } + + func dismissTooltip() { + activeTooltip = nil + } + + // MARK: - Observers + + private func setupObservers() { + observers.viewModel = self + service.addPushSubscriptionObserver(observers) + service.addUserObserver(observers) + service.addPermissionObserver(observers) + service.addUserJwtInvalidatedListener(observers) + } +} + +// MARK: - Engagement + +/// Outcomes, in-app messages, triggers, custom events, notifications and Live +/// Activities. Split out of the main declaration so the type body stays inside +/// SwiftLint's `type_body_length` limit. +extension OneSignalViewModel { + // MARK: - Outcomes func sendOutcome(_ name: String) { @@ -354,23 +483,6 @@ final class OneSignalViewModel: ObservableObject { print("[OneSignal] Event tracked: \(name)") } - // MARK: - Location - - func setLocationShared(_ shared: Bool) { - isLocationShared = shared - service.isLocationShared = shared - } - - func promptLocation() { - service.requestLocationPermission() - } - - func checkLocationShared() -> Bool { - let shared = service.isLocationShared - print("[OneSignal] Location shared: \(shared)") - return shared - } - // MARK: - Notifications func clearAllNotifications() { @@ -425,32 +537,15 @@ final class OneSignalViewModel: ObservableObject { } } #endif - - // MARK: - Tooltips - - func showTooltip(for key: String) { - if let tooltip = TooltipService.shared.tooltip(for: key) { - activeTooltip = tooltip - } - } - - func dismissTooltip() { - activeTooltip = nil - } - - // MARK: - Observers - - private func setupObservers() { - observers.viewModel = self - service.addPushSubscriptionObserver(observers) - service.addUserObserver(observers) - service.addPermissionObserver(observers) - } } // MARK: - Observer Bridge -private final class Observers: NSObject, OSPushSubscriptionObserver, OSUserStateObserver, OSNotificationPermissionObserver { +private final class Observers: NSObject, + OSPushSubscriptionObserver, + OSUserStateObserver, + OSNotificationPermissionObserver, + OSUserJwtInvalidatedListener { weak var viewModel: OneSignalViewModel? func onPushSubscriptionDidChange(state: OSPushSubscriptionChangedState) { @@ -472,4 +567,10 @@ private final class Observers: NSObject, OSPushSubscriptionObserver, OSUserState viewModel?.isPushEnabled = OneSignal.User.pushSubscription.optedIn } } + + func onUserJwtInvalidated(event: OSUserJwtInvalidatedEvent) { + Task { @MainActor in + viewModel?.handleJwtInvalidated(externalId: event.externalId) + } + } } diff --git a/examples/demo/App/Views/Components/AddItemDialog.swift b/examples/demo/App/Views/Components/AddItemDialog.swift index f6b099578..938a12f5e 100644 --- a/examples/demo/App/Views/Components/AddItemDialog.swift +++ b/examples/demo/App/Views/Components/AddItemDialog.swift @@ -36,6 +36,19 @@ struct AddItemDialog: View { @State private var keyText: String = "" @State private var valueText: String = "" + /// `initialKey` prefills the first field. + init( + itemType: AddItemType, + initialKey: String = "", + onAdd: @escaping (String, String) -> Void, + onCancel: @escaping () -> Void + ) { + self.itemType = itemType + self.onAdd = onAdd + self.onCancel = onCancel + _keyText = State(initialValue: initialKey) + } + var body: some View { OSDialog( title: itemType.title, diff --git a/examples/demo/App/Views/Components/LoginUserDialog.swift b/examples/demo/App/Views/Components/LoginUserDialog.swift new file mode 100644 index 000000000..537470c53 --- /dev/null +++ b/examples/demo/App/Views/Components/LoginUserDialog.swift @@ -0,0 +1,70 @@ +/** + * 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 SwiftUI + +/// Login / switch-user dialog: external id required, JWT optional. +struct LoginUserDialog: View { + let onLogin: (String, String?) -> Void + let onCancel: () -> Void + + @State private var externalId: String = "" + @State private var jwtToken: String = "" + + var body: some View { + OSDialog( + title: "Login User", + confirmLabel: "Login", + isConfirmEnabled: isValid, + confirmAccessibilityID: "singleinput_confirm_button", + cancelAccessibilityID: "singleinput_cancel_button", + onConfirm: { + let id = externalId.trimmingCharacters(in: .whitespacesAndNewlines) + let token = jwtToken.trimmingCharacters(in: .whitespacesAndNewlines) + onLogin(id, token.isEmpty ? nil : token) + }, + onCancel: onCancel + ) { + VStack(spacing: 12) { + OSTextField( + placeholder: "External User Id", + text: $externalId, + accessibilityID: "login_user_id_input" + ) + OSTextField( + placeholder: "JWT Token (optional)", + text: $jwtToken, + accessibilityID: "login_user_jwt_input" + ) + } + } + } + + private var isValid: Bool { + !externalId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } +} diff --git a/examples/demo/App/Views/Sections/UserSection.swift b/examples/demo/App/Views/Sections/UserSection.swift index 0e0bb5067..2ac39b9bf 100644 --- a/examples/demo/App/Views/Sections/UserSection.swift +++ b/examples/demo/App/Views/Sections/UserSection.swift @@ -27,13 +27,25 @@ import SwiftUI -/// Login/logout + status display, mirroring the Capacitor UserSection +/// Login/logout + JWT / Identity Verification controls for manual testing. struct UserSection: View { @EnvironmentObject var viewModel: OneSignalViewModel + @EnvironmentObject var toast: ToastPresenter @State private var loginOpen = false + @State private var updateJwtOpen = false var body: some View { SectionCard(title: "USER", sectionKey: "user") { + ToggleRow( + label: "Fetch by external_id (JWT)", + description: "Demo REST fetch only. Signs with the stored JWT.", + isOn: Binding( + get: { viewModel.useIdentityVerification }, + set: { viewModel.setUseIdentityVerification($0) } + ), + accessibilityID: "identity_verification_toggle" + ) + ValueCard(rows: [ ValueCard.Row( label: "Status", @@ -45,9 +57,39 @@ struct UserSection: View { value: viewModel.externalUserId ?? "—", valueAccessibilityID: "user_external_id_value", monospaced: true + ), + ValueCard.Row( + label: "REST fetch", + value: viewModel.userFetchStatus ?? "—", + valueAccessibilityID: "user_fetch_status_value" ) ]) + // Shown instead of auto-feeding the stored token, so the SDK's ask is visible. + if let askedId = viewModel.jwtAskExternalId { + VStack(alignment: .leading, spacing: OS.Spacing.cardGap) { + Text("The SDK is waiting for a JWT. Requests for this user are parked until one is supplied.") + .font(OS.Font.bodySmall) + .foregroundColor(OS.Color.bodyText) + Text(askedId) + .font(OS.Font.mono12) + .foregroundColor(OS.Color.bodyText) + .lineLimit(1) + .truncationMode(.middle) + .accessibilityIdentifier("jwt_ask_external_id_value") + ActionButton( + "PROVIDE JWT", + style: .outline, + accessibilityID: "jwt_ask_provide_button" + ) { + updateJwtOpen = true + } + } + .osCard(background: OS.Color.warningBackground) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("jwt_ask_banner") + } + ActionButton( viewModel.loginButtonTitle, accessibilityID: "login_user_button" @@ -64,16 +106,39 @@ struct UserSection: View { viewModel.logout() } } + + ActionButton( + "UPDATE USER JWT", + style: .outline, + accessibilityID: "update_user_jwt_button" + ) { + updateJwtOpen = true + } } .osCenteredDialog(isPresented: $loginOpen) { - AddItemDialog( - itemType: .externalUserId, - onAdd: { _, value in - viewModel.login(externalId: value) + LoginUserDialog( + onLogin: { externalId, jwt in + viewModel.login(externalId: externalId, jwtToken: jwt) loginOpen = false }, onCancel: { loginOpen = false } ) } + .osCenteredDialog(isPresented: $updateJwtOpen) { + AddItemDialog( + itemType: .updateUserJwt, + initialKey: viewModel.jwtAskExternalId ?? viewModel.externalUserId ?? "", + onAdd: { externalId, token in + viewModel.updateUserJwt(externalId: externalId, token: token) + updateJwtOpen = false + }, + onCancel: { updateJwtOpen = false } + ) + } + .onChange(of: viewModel.jwtAskExternalId) { askedId in + if let askedId = askedId { + toast.show("SDK asked for a JWT for \(askedId)") + } + } } }