Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
19 changes: 11 additions & 8 deletions Sources/ClerkKitUI/Components/Auth/AuthStartView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ struct AuthStartView: View {
@Environment(AuthNavigation.self) private var navigation
@Environment(AuthState.self) private var authState
@Environment(\.dismissKeyboard) private var dismissKeyboard
@Environment(\.clerkInitialIdentifier) private var initialIdentifier
@Environment(\.clerkInitialPhoneNumber) private var initialPhoneNumber
@Environment(\.clerkPersistsIdentifiers) private var persistsIdentifiers

// MARK: - State

Expand All @@ -26,12 +29,6 @@ struct AuthStartView: View {
@State private var generalError: Error?
@State private var lastUsedAuth: LastUsedAuth?

// MARK: - Init

init() {
_lastUsedAuth = State(initialValue: LastUsedAuth(environment: Clerk.shared.environment))
}

// MARK: - Configuration

var emailIsEnabled: Bool {
Expand Down Expand Up @@ -182,8 +179,13 @@ struct AuthStartView: View {
.sensoryFeedback(.error, trigger: fieldError?.localizedDescription) {
$1 != nil
}
.taskOnce {
if shouldStartOnPhoneNumber {
.onFirstAppear {
if persistsIdentifiers {
lastUsedAuth = LastUsedAuth(environment: Clerk.shared.environment)
}
if initialIdentifier != nil || initialPhoneNumber != nil {
phoneNumberFieldIsActive = shouldStartOnPhoneNumber
} else if shouldStartOnPhoneNumber {
phoneNumberFieldIsActive = true
}
}
Expand Down Expand Up @@ -373,6 +375,7 @@ extension AuthStartView {
}

private func storeIdentifierType() {
guard authState.persistsIdentifiers else { return }
if phoneNumberFieldIsActive, phoneNumberIsEnabled {
LastUsedAuth.storeIdentifierType(.phone)
} else if authState.authStartIdentifier.isEmailAddress {
Expand Down
58 changes: 53 additions & 5 deletions Sources/ClerkKitUI/Components/Auth/AuthState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,16 @@ final class AuthState {
/// The authentication mode (signIn, signUp, or signInOrUp).
let mode: AuthView.Mode

init(mode: AuthView.Mode = .signInOrUp) {
/// Whether identifier values are persisted to `UserDefaults` between sessions.
private(set) var persistsIdentifiers: Bool = true

private let userDefaults: UserDefaults

init(mode: AuthView.Mode = .signInOrUp, userDefaults: UserDefaults = .standard) {
self.mode = mode
self.userDefaults = userDefaults
authStartIdentifier = userDefaults.string(forKey: Self.identifierStorageKey) ?? ""
authStartPhoneNumber = userDefaults.string(forKey: Self.phoneNumberStorageKey) ?? ""
}

/// Whether this UI flow should allow transfer from sign-in to sign-up.
Expand All @@ -33,15 +41,50 @@ final class AuthState {
}

/// Auth Start Fields
var authStartIdentifier: String = UserDefaults.standard.string(forKey: "authStartIdentifier") ?? "" {
var authStartIdentifier = "" {
didSet {
UserDefaults.standard.set(authStartIdentifier, forKey: "authStartIdentifier")
if persistsIdentifiers {
userDefaults.set(authStartIdentifier, forKey: Self.identifierStorageKey)
}
}
}

var authStartPhoneNumber: String = UserDefaults.standard.string(forKey: "authStartPhoneNumber") ?? "" {
var authStartPhoneNumber = "" {
didSet {
UserDefaults.standard.set(authStartPhoneNumber, forKey: "authStartPhoneNumber")
if persistsIdentifiers {
userDefaults.set(authStartPhoneNumber, forKey: Self.phoneNumberStorageKey)
}
}
}

/// Applies initial identifier values and persistence configuration from the environment.
///
/// Call this once when the view first appears so that environment-provided values
/// take effect before the user interacts with the form.
func configure(
initialIdentifier: String?,
initialPhoneNumber: String?,
persistsIdentifiers: Bool
) {
self.persistsIdentifiers = persistsIdentifiers

if !persistsIdentifiers {
userDefaults.removeObject(forKey: Self.identifierStorageKey)
userDefaults.removeObject(forKey: Self.phoneNumberStorageKey)
LastUsedAuth.clearStoredIdentifierType(userDefaults: userDefaults)
authStartIdentifier = initialIdentifier ?? ""
authStartPhoneNumber = initialPhoneNumber ?? ""
} else {
if let initialIdentifier {
authStartIdentifier = initialIdentifier
} else if initialPhoneNumber != nil {
authStartIdentifier = ""
}
if let initialPhoneNumber {
authStartPhoneNumber = initialPhoneNumber
} else if initialIdentifier != nil {
authStartPhoneNumber = ""
}
}
}

Expand All @@ -61,4 +104,9 @@ final class AuthState {
var signUpLegalAccepted = false
}

extension AuthState {
static let identifierStorageKey = "authStartIdentifier"
static let phoneNumberStorageKey = "authStartPhoneNumber"
}

#endif
10 changes: 10 additions & 0 deletions Sources/ClerkKitUI/Components/Auth/AuthView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ public struct AuthView: View {
@Environment(Clerk.self) private var clerk
@Environment(\.clerkTheme) private var theme
@Environment(\.dismiss) private var dismiss
@Environment(\.clerkInitialIdentifier) private var initialIdentifier
@Environment(\.clerkInitialPhoneNumber) private var initialPhoneNumber
@Environment(\.clerkPersistsIdentifiers) private var persistsIdentifiers

/// Navigation state for the auth flow.
@State private var navigation = AuthNavigation()
Expand Down Expand Up @@ -176,6 +179,13 @@ public struct AuthView: View {
navigation.path = []
}
}
.onFirstAppear {
authState.configure(
initialIdentifier: initialIdentifier,
initialPhoneNumber: initialPhoneNumber,
persistsIdentifiers: persistsIdentifiers
)
}
.taskOnce {
await clerk.telemetry.record(
TelemetryEvents.viewDidAppear(
Expand Down
12 changes: 6 additions & 6 deletions Sources/ClerkKitUI/Components/Auth/LastUsedAuth.swift
Original file line number Diff line number Diff line change
Expand Up @@ -75,13 +75,13 @@ enum LastUsedAuth: Equatable {
}
}

static func storeIdentifierType(_ identifier: LastUsedAuth) {
static func storeIdentifierType(_ identifier: LastUsedAuth, userDefaults: UserDefaults = .standard) {
guard let rawValue = identifier.identifierStorageValue else { return }
UserDefaults.standard.set(rawValue, forKey: identifierStorageKey)
userDefaults.set(rawValue, forKey: identifierStorageKey)
}

static func retrieveStoredIdentifierType() -> LastUsedAuth? {
guard let rawValue = UserDefaults.standard.string(forKey: identifierStorageKey) else {
static func retrieveStoredIdentifierType(userDefaults: UserDefaults = .standard) -> LastUsedAuth? {
guard let rawValue = userDefaults.string(forKey: identifierStorageKey) else {
return nil
}

Expand All @@ -97,8 +97,8 @@ enum LastUsedAuth: Equatable {
}
}

static func clearStoredIdentifierType() {
UserDefaults.standard.removeObject(forKey: identifierStorageKey)
static func clearStoredIdentifierType(userDefaults: UserDefaults = .standard) {
userDefaults.removeObject(forKey: identifierStorageKey)
}
}

Expand Down
52 changes: 52 additions & 0 deletions Sources/ClerkKitUI/Extensions/View+AuthIdentifierConfig.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
//
// View+AuthIdentifierConfig.swift
// Clerk
//

#if os(iOS)

import SwiftUI

extension EnvironmentValues {
@Entry var clerkInitialIdentifier: String?
@Entry var clerkInitialPhoneNumber: String?
@Entry var clerkPersistsIdentifiers: Bool = true
}

extension View {
/// Sets the initial value for the email or username field on the auth screen.
///
/// Use this to pre-fill the identifier field when presenting `AuthView`.
///
/// - Parameter identifier: The email address or username to pre-fill.
/// - Returns: A view with the initial identifier configured.
public func clerkInitialIdentifier(_ identifier: String) -> some View {
environment(\.clerkInitialIdentifier, identifier)
}

/// Sets the initial value for the phone number field on the auth screen.
///
/// Use this to pre-fill the phone number field when presenting `AuthView`.
///
/// - Parameter phoneNumber: The phone number to pre-fill (e.g. `"15555550100"`).
/// - Returns: A view with the initial phone number configured.
public func clerkInitialPhoneNumber(_ phoneNumber: String) -> some View {
environment(\.clerkInitialPhoneNumber, phoneNumber)
}

/// Controls whether auth identifier values are persisted between sessions.
///
/// When set to `false`, any previously stored identifiers are cleared and
/// future changes will not be saved. This is useful on shared devices where
/// the previous user's information should not appear after sign-out.
///
/// The default value is `true`, which preserves the existing persistence behavior.
///
/// - Parameter persists: Whether to persist identifier values to storage.
/// - Returns: A view with the identifier persistence behavior configured.
public func clerkPersistsIdentifiers(_ persists: Bool) -> some View {
environment(\.clerkPersistsIdentifiers, persists)
}
}

#endif
151 changes: 151 additions & 0 deletions Tests/UI/AuthStateConfigurationTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
#if os(iOS)

@testable import ClerkKitUI
import Foundation
import Testing

@MainActor
struct AuthStateConfigurationTests {
@Test
func defaultConfigurationLoadsPersistedValues() {
let defaults = makeUserDefaults()
defaults.set("stored@example.com", forKey: AuthState.identifierStorageKey)
defaults.set("15555550100", forKey: AuthState.phoneNumberStorageKey)

let authState = AuthState(userDefaults: defaults)

#expect(authState.authStartIdentifier == "stored@example.com")
#expect(authState.authStartPhoneNumber == "15555550100")
}

@Test
func defaultConfigurationPersistsEdits() {
let defaults = makeUserDefaults()
let authState = AuthState(userDefaults: defaults)

authState.authStartIdentifier = "edited@example.com"
authState.authStartPhoneNumber = "16666660123"

#expect(defaults.string(forKey: AuthState.identifierStorageKey) == "edited@example.com")
#expect(defaults.string(forKey: AuthState.phoneNumberStorageKey) == "16666660123")
}

@Test
func initialValuesOverridePersistedValues() {
let defaults = makeUserDefaults()
defaults.set("stored@example.com", forKey: AuthState.identifierStorageKey)
defaults.set("15555550100", forKey: AuthState.phoneNumberStorageKey)

let authState = AuthState(userDefaults: defaults)
authState.configure(
initialIdentifier: "seed@example.com",
initialPhoneNumber: "17777770123",
persistsIdentifiers: true
)

#expect(authState.authStartIdentifier == "seed@example.com")
#expect(authState.authStartPhoneNumber == "17777770123")
#expect(defaults.string(forKey: AuthState.identifierStorageKey) == "seed@example.com")
#expect(defaults.string(forKey: AuthState.phoneNumberStorageKey) == "17777770123")
}

@Test
func disablingPersistenceClearsStoredValues() {
let defaults = makeUserDefaults()
defaults.set("stored@example.com", forKey: AuthState.identifierStorageKey)
defaults.set("15555550100", forKey: AuthState.phoneNumberStorageKey)
LastUsedAuth.storeIdentifierType(.email, userDefaults: defaults)

let authState = AuthState(userDefaults: defaults)
authState.configure(
initialIdentifier: nil,
initialPhoneNumber: nil,
persistsIdentifiers: false
)

#expect(authState.authStartIdentifier.isEmpty)
#expect(authState.authStartPhoneNumber.isEmpty)
#expect(defaults.string(forKey: AuthState.identifierStorageKey) == nil)
#expect(defaults.string(forKey: AuthState.phoneNumberStorageKey) == nil)
#expect(LastUsedAuth.retrieveStoredIdentifierType(userDefaults: defaults) == nil)
}

@Test
func disablingPersistenceSuppressesFutureWrites() {
let defaults = makeUserDefaults()
let authState = AuthState(userDefaults: defaults)
authState.configure(
initialIdentifier: nil,
initialPhoneNumber: nil,
persistsIdentifiers: false
)

authState.authStartIdentifier = "new@example.com"
authState.authStartPhoneNumber = "19999990123"

#expect(defaults.string(forKey: AuthState.identifierStorageKey) == nil)
#expect(defaults.string(forKey: AuthState.phoneNumberStorageKey) == nil)
}

@Test
func disablingPersistenceWithInitialValuesShowsButDoesNotStore() {
let defaults = makeUserDefaults()
defaults.set("stored@example.com", forKey: AuthState.identifierStorageKey)
defaults.set("15555550100", forKey: AuthState.phoneNumberStorageKey)
LastUsedAuth.storeIdentifierType(.phone, userDefaults: defaults)

let authState = AuthState(userDefaults: defaults)
authState.configure(
initialIdentifier: "seed@example.com",
initialPhoneNumber: "17777770123",
persistsIdentifiers: false
)

#expect(authState.authStartIdentifier == "seed@example.com")
#expect(authState.authStartPhoneNumber == "17777770123")
#expect(defaults.string(forKey: AuthState.identifierStorageKey) == nil)
#expect(defaults.string(forKey: AuthState.phoneNumberStorageKey) == nil)
#expect(LastUsedAuth.retrieveStoredIdentifierType(userDefaults: defaults) == nil)
}

@Test
func initialPhoneNumberClearsStoredIdentifier() {
let defaults = makeUserDefaults()
defaults.set("stored@example.com", forKey: AuthState.identifierStorageKey)

let authState = AuthState(userDefaults: defaults)
authState.configure(
initialIdentifier: nil,
initialPhoneNumber: "15555550100",
persistsIdentifiers: true
)

#expect(authState.authStartIdentifier.isEmpty)
#expect(authState.authStartPhoneNumber == "15555550100")
}

@Test
func initialIdentifierClearsStoredPhoneNumber() {
let defaults = makeUserDefaults()
defaults.set("15555550100", forKey: AuthState.phoneNumberStorageKey)

let authState = AuthState(userDefaults: defaults)
authState.configure(
initialIdentifier: "seed@example.com",
initialPhoneNumber: nil,
persistsIdentifiers: true
)

#expect(authState.authStartIdentifier == "seed@example.com")
#expect(authState.authStartPhoneNumber.isEmpty)
}

private func makeUserDefaults() -> UserDefaults {
let suiteName = "AuthStateConfigurationTests.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suiteName)!
defaults.removePersistentDomain(forName: suiteName)
return defaults
}
}

#endif
Loading