diff --git a/.gitignore b/.gitignore index 07b52682b6bf..0c95aa9592a5 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,6 @@ Stripe.xcworkspace/xcuserdata/* # Simulator configuration cache .stripe-ios-config + +# Local CryptoOnramp example config (may contain secrets) +CryptoOnrampExample.local.xcconfig diff --git a/Example/CryptoOnramp Example/BuildConfigurations/CryptoOnrampExample.xcconfig b/Example/CryptoOnramp Example/BuildConfigurations/CryptoOnrampExample.xcconfig new file mode 100644 index 000000000000..fcc951daddd3 --- /dev/null +++ b/Example/CryptoOnramp Example/BuildConfigurations/CryptoOnrampExample.xcconfig @@ -0,0 +1,9 @@ +// Passwordless demo mode — define these in the local override to enable. +// DEMO_PASSWORDLESS_PASSWORD = +// DEMO_PASSWORDLESS_EMAILS = +// DEMO_SOLANA_WALLET_ADDRESSES = + +// Optional local overrides (gitignored) +#include? "CryptoOnrampExample.local.xcconfig" + +// See Info.plist where these build settings are used diff --git a/Example/CryptoOnramp Example/CryptoOnramp Example.xcodeproj/project.pbxproj b/Example/CryptoOnramp Example/CryptoOnramp Example.xcodeproj/project.pbxproj index 8679fd195c5f..ea35c2a1e77e 100644 --- a/Example/CryptoOnramp Example/CryptoOnramp Example.xcodeproj/project.pbxproj +++ b/Example/CryptoOnramp Example/CryptoOnramp Example.xcodeproj/project.pbxproj @@ -72,6 +72,7 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 0B0DC0012F907A0000000001 /* CryptoOnrampExample.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = CryptoOnrampExample.xcconfig; sourceTree = ""; }; 0B44B3F02E576ECD00B58655 /* StripePaymentSheet.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = StripePaymentSheet.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 0B44B3F32E576F1500B58655 /* Stripe3DS2.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Stripe3DS2.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 0B44B3F62E576F4100B58655 /* StripeApplePay.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = StripeApplePay.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -125,9 +126,18 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 0B0DC0012F907A0000000002 /* BuildConfigurations */ = { + isa = PBXGroup; + children = ( + 0B0DC0012F907A0000000001 /* CryptoOnrampExample.xcconfig */, + ); + path = BuildConfigurations; + sourceTree = ""; + }; 0BA5FE292E32A11600D44BFE = { isa = PBXGroup; children = ( + 0B0DC0012F907A0000000002 /* BuildConfigurations */, 0BA5FE342E32A11600D44BFE /* CryptoOnramp Example */, 0BD66FCC2EDE0E800044DB52 /* CryptoOnrampExampleUITests */, 0BA5FE402E32AB9E00D44BFE /* Frameworks */, @@ -416,6 +426,7 @@ }; 0BA5FE3E2E32A11600D44BFE /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 0B0DC0012F907A0000000001 /* CryptoOnrampExample.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; @@ -425,6 +436,7 @@ DEVELOPMENT_TEAM = Y28TH9SHX7; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "CryptoOnramp-Example-Info.plist"; INFOPLIST_KEY_NSCameraUsageDescription = "To verify your identity."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; @@ -447,6 +459,7 @@ }; 0BA5FE3F2E32A11600D44BFE /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 0B0DC0012F907A0000000001 /* CryptoOnrampExample.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; @@ -456,6 +469,7 @@ DEVELOPMENT_TEAM = Y28TH9SHX7; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "CryptoOnramp-Example-Info.plist"; INFOPLIST_KEY_NSCameraUsageDescription = "To verify your identity."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; diff --git a/Example/CryptoOnramp Example/CryptoOnramp Example/AttachWalletAddressView.swift b/Example/CryptoOnramp Example/CryptoOnramp Example/AttachWalletAddressView.swift index e5fff68faf23..e1b64934f4c9 100644 --- a/Example/CryptoOnramp Example/CryptoOnramp Example/AttachWalletAddressView.swift +++ b/Example/CryptoOnramp Example/CryptoOnramp Example/AttachWalletAddressView.swift @@ -87,6 +87,11 @@ struct AttachWalletAddressView: View { } } .onAppear { + if walletAddress.isEmpty, + let email = APIClient.shared.email, + let prefill = DemoConfig.solanaWalletAddress(for: email) { + walletAddress = prefill + } DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { walletFieldFocused = true } diff --git a/Example/CryptoOnramp Example/CryptoOnramp Example/DemoConfig.swift b/Example/CryptoOnramp Example/CryptoOnramp Example/DemoConfig.swift new file mode 100644 index 000000000000..d693dabbe351 --- /dev/null +++ b/Example/CryptoOnramp Example/CryptoOnramp Example/DemoConfig.swift @@ -0,0 +1,78 @@ +import Foundation + +enum DemoConfig { + + /// Whether passwordless demo mode is enabled (i.e., a demo password is configured). + static var isPasswordlessEnabled: Bool { + guard let password = passwordlessPassword else { return false } + return !password.isEmpty + } + + /// The password to use behind the scenes when passwordless mode is enabled. + static var passwordlessPassword: String? { + Bundle.main.infoDictionary?["DemoPasswordlessPassword"] as? String + } + + /// The set of emails allowed in passwordless mode, lowercased. + static var allowedEmails: Set { + guard let raw = Bundle.main.infoDictionary?["DemoPasswordlessEmails"] as? String else { + return [] + } + let emails = raw + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespaces).lowercased() } + .filter { !$0.isEmpty } + return Set(emails) + } + + /// Returns `true` if the given email is on the passwordless allowlist. + /// Supports plus-addressing: if `alice@stripe.com` is allowed, + /// `alice+something@stripe.com` also matches. + static func isEmailAllowed(_ email: String) -> Bool { + let normalized = email.lowercased() + if allowedEmails.contains(normalized) { + return true + } + return allowedEmails.contains(normalizeEmail(normalized)) + } + + /// Parsed mapping of lowercased email → Solana wallet address from the + /// `DemoSolanaWalletAddresses` Info.plist key (comma-separated `email:address` pairs). + private static var solanaWalletAddresses: [String: String] { + guard let raw = Bundle.main.infoDictionary?["DemoSolanaWalletAddresses"] as? String else { + return [:] + } + var mapping: [String: String] = [:] + for pair in raw.split(separator: ",") { + let parts = pair.split(separator: ":", maxSplits: 1) + guard parts.count == 2 else { continue } + let email = parts[0].trimmingCharacters(in: .whitespaces).lowercased() + let address = parts[1].trimmingCharacters(in: .whitespaces) + guard !email.isEmpty, !address.isEmpty else { continue } + mapping[email] = address + } + return mapping + } + + /// Returns the configured Solana wallet address for the given email, if any. + /// Supports plus-addressing: e.g. `alice+test@stripe.com` matches `alice@stripe.com`. + static func solanaWalletAddress(for email: String) -> String? { + let lowered = email.lowercased() + if let address = solanaWalletAddresses[lowered] { + return address + } + return solanaWalletAddresses[normalizeEmail(lowered)] + } + + /// Strips the `+…` suffix from the local part of an email address. + /// e.g. `alice+test@stripe.com` → `alice@stripe.com` + private static func normalizeEmail(_ email: String) -> String { + guard let atIndex = email.lastIndex(of: "@"), + let plusIndex = email.firstIndex(of: "+"), + plusIndex < atIndex + else { + return email + } + return String(email[email.startIndex.., field: Field, autocapitalization: UITextAutocapitalizationType = .none, - keyboardType: UIKeyboardType = .default + keyboardType: UIKeyboardType = .default, + textContentType: UITextContentType? = nil ) -> some View { TextField(titleKey, text: text) .textFieldStyle(RoundedBorderTextFieldStyle()) + .textContentType(textContentType) .autocapitalization(autocapitalization) .keyboardType(keyboardType) .focused($focusedField, equals: field) diff --git a/Example/CryptoOnramp Example/CryptoOnramp Example/LogInSignUpView.swift b/Example/CryptoOnramp Example/CryptoOnramp Example/LogInSignUpView.swift index febe5f6fb0ff..ed0cbe452f30 100644 --- a/Example/CryptoOnramp Example/CryptoOnramp Example/LogInSignUpView.swift +++ b/Example/CryptoOnramp Example/CryptoOnramp Example/LogInSignUpView.swift @@ -31,15 +31,27 @@ struct LogInSignUpView: View { @Environment(\.isLoading) private var isLoading @State private var email: String = "" - @State private var password: String = "" @State private var selectedScopes: Set = Set(OAuthScopes.requiredScopes) @State private var isShowingScopesSheet = false + @State private var password: String = "" + @FocusState private var isEmailFieldFocused: Bool @FocusState private var isPasswordFieldFocused: Bool private var shouldDisableButtons: Bool { - isLoading.wrappedValue || email.isEmpty || password.isEmpty || coordinator == nil + if isLoading.wrappedValue || email.isEmpty || coordinator == nil { + return true + } + if !DemoConfig.isPasswordlessEnabled && password.isEmpty { + return true + } + return false + } + + /// The password to send to the backend: either the user-entered one, or the demo password. + private var effectivePassword: String { + DemoConfig.isPasswordlessEnabled ? (DemoConfig.passwordlessPassword ?? "") : password } private var kycInfoCollectionMode: KYCInfoView.CollectionMode { @@ -72,16 +84,17 @@ struct LogInSignUpView: View { .autocapitalization(.none) .disableAutocorrection(true) .focused($isEmailFieldFocused) - .submitLabel(.next) + .submitLabel(DemoConfig.isPasswordlessEnabled ? .done : .next) } - FormField("Password") { - SecureField("Enter password", text: $password) - .font(.title3) - .textFieldStyle(RoundedBorderTextFieldStyle()) - .textContentType(.password) - .focused($isPasswordFieldFocused) - .submitLabel(.done) + if !DemoConfig.isPasswordlessEnabled { + FormField("Password") { + SecureField("Enter password", text: $password) + .font(.title3) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .focused($isPasswordFieldFocused) + .submitLabel(.done) + } } } .padding() @@ -151,10 +164,14 @@ struct LogInSignUpView: View { } private func logIn() { + if DemoConfig.isPasswordlessEnabled && !DemoConfig.isEmailAllowed(email) { + alert = Alert(title: "Email Not Allowed", message: "This email is not on the allowed list for demo mode.") + return + } isLoading.wrappedValue = true Task { do { - try await APIClient.shared.logIn(email: email, password: password, livemode: livemode) + try await APIClient.shared.logIn(email: email, password: effectivePassword, livemode: livemode) await proceedToLinkAuthorization() } catch { await MainActor.run { @@ -166,10 +183,14 @@ struct LogInSignUpView: View { } private func signUp() { + if DemoConfig.isPasswordlessEnabled && !DemoConfig.isEmailAllowed(email) { + alert = Alert(title: "Email Not Allowed", message: "This email is not on the allowed list for demo mode.") + return + } isLoading.wrappedValue = true Task { do { - try await APIClient.shared.signUp(email: email, password: password, livemode: livemode) + try await APIClient.shared.signUp(email: email, password: effectivePassword, livemode: livemode) await proceedToLinkAuthorization() } catch { await MainActor.run { diff --git a/Example/CryptoOnramp Example/CryptoOnramp Example/RegistrationView.swift b/Example/CryptoOnramp Example/CryptoOnramp Example/RegistrationView.swift index 7b115dd575bf..9070d15e4c8c 100644 --- a/Example/CryptoOnramp Example/CryptoOnramp Example/RegistrationView.swift +++ b/Example/CryptoOnramp Example/CryptoOnramp Example/RegistrationView.swift @@ -41,6 +41,13 @@ struct RegistrationView: View { @FocusState private var isPhoneNumberFieldFocused: Bool @FocusState private var isCountryFieldFocused: Bool + /// Strips spaces, dashes, and parentheses from a phone number for E.164 compatibility. + /// Sanitization is done on submit rather than on input so the field preserves readable + /// formatting (e.g. from autofill) while the user verifies the number. + private static func sanitizePhoneNumber(_ value: String) -> String { + value.filter { $0.isNumber || $0 == "+" } + } + private var isRegisterButtonDisabled: Bool { isLoading.wrappedValue || phoneNumber.isEmpty } @@ -148,14 +155,15 @@ struct RegistrationView: View { try await coordinator.registerLinkUser( email: email, fullName: nil, - phone: phoneNumber, + phone: Self.sanitizePhoneNumber(phoneNumber), country: country ) await MainActor.run { - isLoading.wrappedValue = false isRegistrationComplete = true } + // Continue directly into authentication + try await verify() } catch { await MainActor.run { isLoading.wrappedValue = false @@ -228,11 +236,12 @@ struct RegistrationView: View { isLoading.wrappedValue = true Task { do { - try await coordinator.updatePhoneNumber(to: phoneNumber) + let sanitized = Self.sanitizePhoneNumber(phoneNumber) + try await coordinator.updatePhoneNumber(to: sanitized) await MainActor.run { isLoading.wrappedValue = false updatePhoneNumberInput = "" - self.phoneNumber = phoneNumber + self.phoneNumber = sanitized } } catch { await MainActor.run { diff --git a/Example/CryptoOnramp Example/CryptoOnramp Example/UpdateAddressView.swift b/Example/CryptoOnramp Example/CryptoOnramp Example/UpdateAddressView.swift index 2e63be6d1fef..4543cb1acb62 100644 --- a/Example/CryptoOnramp Example/CryptoOnramp Example/UpdateAddressView.swift +++ b/Example/CryptoOnramp Example/CryptoOnramp Example/UpdateAddressView.swift @@ -46,7 +46,8 @@ struct UpdateAddressView: View { "Enter your street address", text: $addressLine1, field: .addressLine1, - autocapitalization: .words + autocapitalization: .words, + textContentType: .streetAddressLine1 ) } @@ -55,7 +56,8 @@ struct UpdateAddressView: View { "Apartment, suite, etc.", text: $addressLine2, field: .addressLine2, - autocapitalization: .words + autocapitalization: .words, + textContentType: .streetAddressLine2 ) } @@ -64,7 +66,8 @@ struct UpdateAddressView: View { "Enter your city", text: $city, field: .city, - autocapitalization: .words + autocapitalization: .words, + textContentType: .addressCity ) } @@ -81,7 +84,8 @@ struct UpdateAddressView: View { makeTextField( "Enter your postal code", text: $postalCode, - field: .postalCode + field: .postalCode, + textContentType: .postalCode ) } @@ -144,10 +148,12 @@ struct UpdateAddressView: View { _ titleKey: LocalizedStringKey, text: Binding, field: Field, - autocapitalization: UITextAutocapitalizationType = .none + autocapitalization: UITextAutocapitalizationType = .none, + textContentType: UITextContentType? = nil ) -> some View { TextField(titleKey, text: text) .textFieldStyle(RoundedBorderTextFieldStyle()) + .textContentType(textContentType) .autocapitalization(autocapitalization) .focused($focusedField, equals: field) } diff --git a/Example/CryptoOnramp Example/CryptoOnramp-Example-Info.plist b/Example/CryptoOnramp Example/CryptoOnramp-Example-Info.plist new file mode 100644 index 000000000000..debc4b6cbaae --- /dev/null +++ b/Example/CryptoOnramp Example/CryptoOnramp-Example-Info.plist @@ -0,0 +1,12 @@ + + + + + DemoPasswordlessPassword + $(DEMO_PASSWORDLESS_PASSWORD) + DemoPasswordlessEmails + $(DEMO_PASSWORDLESS_EMAILS) + DemoSolanaWalletAddresses + $(DEMO_SOLANA_WALLET_ADDRESSES) + +