Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,6 @@ Stripe.xcworkspace/xcuserdata/*

# Simulator configuration cache
.stripe-ios-config

# Local CryptoOnramp example config (may contain secrets)
CryptoOnrampExample.local.xcconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// Passwordless demo mode — define these in the local override to enable.
// DEMO_PASSWORDLESS_PASSWORD = <password>
// DEMO_PASSWORDLESS_EMAILS = <comma-separated emails>
// DEMO_SOLANA_WALLET_ADDRESSES = <comma-separated email:walletAddress pairs>

// Optional local overrides (gitignored)
#include? "CryptoOnrampExample.local.xcconfig"

// See Info.plist where these build settings are used
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@
/* End PBXCopyFilesBuildPhase section */

/* Begin PBXFileReference section */
0B0DC0012F907A0000000001 /* CryptoOnrampExample.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = CryptoOnrampExample.xcconfig; sourceTree = "<group>"; };
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; };
Expand Down Expand Up @@ -125,9 +126,18 @@
/* End PBXFrameworksBuildPhase section */

/* Begin PBXGroup section */
0B0DC0012F907A0000000002 /* BuildConfigurations */ = {
isa = PBXGroup;
children = (
0B0DC0012F907A0000000001 /* CryptoOnrampExample.xcconfig */,
);
path = BuildConfigurations;
sourceTree = "<group>";
};
0BA5FE292E32A11600D44BFE = {
isa = PBXGroup;
children = (
0B0DC0012F907A0000000002 /* BuildConfigurations */,
0BA5FE342E32A11600D44BFE /* CryptoOnramp Example */,
0BD66FCC2EDE0E800044DB52 /* CryptoOnrampExampleUITests */,
0BA5FE402E32AB9E00D44BFE /* Frameworks */,
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String> {
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..<plusIndex]) + String(email[atIndex...])
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ final class APIClient {
private let jsonEncoder: JSONEncoder
private(set) var authToken: String?
private(set) var authTokenWithLAI: String?
private var email: String?
private(set) var email: String?

private var persistedSeamlessSignInDetails: SeamlessSignInDetails? {
// Note that `UserDefaults` are used here in this example app for simplicity.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,8 @@ struct KYCInfoView: View {
"Enter your first name",
text: $firstName,
field: .firstName,
autocapitalization: .words
autocapitalization: .words,
textContentType: .givenName
)
}

Expand All @@ -169,7 +170,8 @@ struct KYCInfoView: View {
"Enter your last name",
text: $lastName,
field: .lastName,
autocapitalization: .words
autocapitalization: .words,
textContentType: .familyName
)
}
}
Expand Down Expand Up @@ -198,7 +200,8 @@ struct KYCInfoView: View {
"Enter your street address",
text: $addressLine1,
field: .addressLine1,
autocapitalization: .words
autocapitalization: .words,
textContentType: .streetAddressLine1
)
}

Expand All @@ -207,7 +210,8 @@ struct KYCInfoView: View {
"Apartment, suite, etc.",
text: $addressLine2,
field: .addressLine2,
autocapitalization: .words
autocapitalization: .words,
textContentType: .streetAddressLine2
)
}

Expand All @@ -216,7 +220,8 @@ struct KYCInfoView: View {
"Enter your city",
text: $city,
field: .city,
autocapitalization: .words
autocapitalization: .words,
textContentType: .addressCity
)
}

Expand All @@ -233,7 +238,8 @@ struct KYCInfoView: View {
makeTextField(
"Enter your postal code",
text: $postalCode,
field: .postalCode
field: .postalCode,
textContentType: .postalCode
)
}

Expand Down Expand Up @@ -322,10 +328,12 @@ struct KYCInfoView: View {
text: Binding<String>,
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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<OAuthScopes> = 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 {
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading