Skip to content

Commit 90ecadf

Browse files
feat: create default View for legacyFetchSignInWithEmail option
1 parent c2f4335 commit 90ecadf

File tree

16 files changed

+391
-20
lines changed

16 files changed

+391
-20
lines changed

FirebaseSwiftUI/FirebaseAppleSwiftUI/Sources/Views/SignInWithAppleButton.swift

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,14 +47,13 @@ extension SignInWithAppleButton: View {
4747
return
4848
}
4949
} catch {
50-
reportError?(error)
51-
5250
if case let AuthServiceError.accountConflict(ctx) = error,
5351
let onConflict = accountConflictHandler {
5452
onConflict(ctx)
5553
return
5654
}
5755

56+
reportError?(error)
5857
throw error
5958
}
6059
}

FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/AuthServiceError.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,7 @@ public enum AuthServiceError: LocalizedError {
164164
case invalidCredentials(String)
165165
case signInFailed(underlying: Error)
166166
case accountConflict(AccountConflictContext)
167+
case legacySignInRecoveryPresented
167168
case providerNotFound(String)
168169
case multiFactorAuth(String)
169170
case rootViewControllerNotFound(String)
@@ -200,6 +201,8 @@ public enum AuthServiceError: LocalizedError {
200201
return description
201202
case let .accountConflict(context):
202203
return context.errorDescription
204+
case .legacySignInRecoveryPresented:
205+
return nil
203206
case let .providerNotFound(description):
204207
return description
205208
case let .multiFactorAuth(description):

FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthConfiguration.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ public struct AuthConfiguration {
2222
public let shouldHideCancelButton: Bool
2323
public let interactiveDismissEnabled: Bool
2424
public let shouldAutoUpgradeAnonymousUsers: Bool
25+
public let legacyFetchSignInWithEmail: Bool
2526
public let customStringsBundle: Bundle?
2627
public let tosUrl: URL?
2728
public let privacyPolicyUrl: URL?
@@ -39,6 +40,7 @@ public struct AuthConfiguration {
3940
shouldHideCancelButton: Bool = false,
4041
interactiveDismissEnabled: Bool = true,
4142
shouldAutoUpgradeAnonymousUsers: Bool = false,
43+
legacyFetchSignInWithEmail: Bool = false,
4244
customStringsBundle: Bundle? = nil,
4345
tosUrl: URL? = nil,
4446
privacyPolicyUrl: URL? = nil,
@@ -51,6 +53,7 @@ public struct AuthConfiguration {
5153
self.shouldHideCancelButton = shouldHideCancelButton
5254
self.interactiveDismissEnabled = interactiveDismissEnabled
5355
self.shouldAutoUpgradeAnonymousUsers = shouldAutoUpgradeAnonymousUsers
56+
self.legacyFetchSignInWithEmail = legacyFetchSignInWithEmail
5457
self.customStringsBundle = customStringsBundle
5558
self.languageCode = languageCode
5659
self.tosUrl = tosUrl

FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Services/AuthService.swift

Lines changed: 216 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,36 @@ public enum SignInOutcome: @unchecked Sendable {
6060
case signedIn(AuthDataResult?)
6161
}
6262

63+
public struct LegacySignInOption: Identifiable, Equatable {
64+
public let id: String
65+
public let displayName: String
66+
67+
public init(id: String, displayName: String) {
68+
self.id = id
69+
self.displayName = displayName
70+
}
71+
}
72+
73+
public struct LegacySignInRecoveryContext: Identifiable, Equatable {
74+
public let id = UUID()
75+
public let email: String
76+
public let options: [LegacySignInOption]
77+
public let unavailableProviders: [String]
78+
79+
public init(email: String,
80+
options: [LegacySignInOption],
81+
unavailableProviders: [String] = []) {
82+
self.email = email
83+
self.options = options
84+
self.unavailableProviders = unavailableProviders
85+
}
86+
87+
public static func == (lhs: LegacySignInRecoveryContext,
88+
rhs: LegacySignInRecoveryContext) -> Bool {
89+
lhs.id == rhs.id
90+
}
91+
}
92+
6393
@MainActor
6494
private final class AuthListenerManager {
6595
private var authStateHandle: AuthStateDidChangeListenerHandle?
@@ -131,6 +161,7 @@ public final class AuthService {
131161
private var listenerManager: AuthListenerManager?
132162
private var emailLinkSignInCallback: (() -> Void)?
133163
private var providers: [AuthProviderUI] = []
164+
private let emailLinkSignInMethod = "emailLink"
134165

135166
public let configuration: AuthConfiguration
136167
public let auth: Auth
@@ -141,6 +172,8 @@ public final class AuthService {
141172
public var authenticationFlow: AuthenticationFlow = .signIn
142173
public var emailPasswordSignInEnabled = false
143174
public var emailLinkSignInEnabled = false
175+
public var legacySignInRecovery: LegacySignInRecoveryContext?
176+
public var suggestedEmailAddress: String?
144177
public private(set) var navigator = Navigator()
145178

146179
public var authView: AuthView? {
@@ -174,6 +207,18 @@ public final class AuthService {
174207
)
175208
}
176209

210+
public func renderLegacyRecoveryButtons(spacing: CGFloat = 16) -> AnyView {
211+
AnyView(
212+
VStack(spacing: spacing) {
213+
if let recovery = legacySignInRecovery {
214+
ForEach(recovery.options) { option in
215+
self.legacyRecoveryButton(for: option, email: recovery.email)
216+
}
217+
}
218+
}
219+
)
220+
}
221+
177222
public func signIn(_ provider: CredentialAuthProviderSwift) async throws -> SignInOutcome {
178223
let credential = try await provider.createAuthCredential()
179224
let result = try await signIn(credentials: credential)
@@ -189,6 +234,8 @@ public final class AuthService {
189234
// Clear email link reauth state
190235
emailLinkReauth = nil
191236
isReauthenticating = false
237+
legacySignInRecovery = nil
238+
suggestedEmailAddress = nil
192239
updateAuthenticationState()
193240
}
194241

@@ -354,7 +401,17 @@ public extension AuthService {
354401

355402
func signIn(email: String, password: String) async throws -> SignInOutcome {
356403
let credential = EmailAuthProvider.credential(withEmail: email, password: password)
357-
return try await signIn(credentials: credential)
404+
do {
405+
return try await signIn(credentials: credential)
406+
} catch {
407+
if await tryPresentLegacySignInRecovery(
408+
email: email,
409+
attemptedProviderId: EmailAuthProviderID
410+
) {
411+
throw AuthServiceError.legacySignInRecoveryPresented
412+
}
413+
throw error
414+
}
358415
}
359416

360417
func createUser(email email: String, password: String) async throws -> SignInOutcome {
@@ -854,6 +911,10 @@ private extension AuthService {
854911
(currentUser == nil || currentUser?.isAnonymous == true)
855912
? .unauthenticated
856913
: .authenticated
914+
915+
if authenticationState == .authenticated {
916+
legacySignInRecovery = nil
917+
}
857918
}
858919

859920
private var shouldHandleAnonymousUpgrade: Bool {
@@ -1010,12 +1071,166 @@ private extension AuthService {
10101071
return "Email"
10111072
case PhoneAuthProviderID:
10121073
return "Phone"
1074+
case emailLinkSignInMethod:
1075+
return "Email Link"
10131076
default:
10141077
// Shouldn't reach here if provider is registered
10151078
return providerId
10161079
}
10171080
}
10181081

1082+
private func normalizeLegacyOptionId(_ providerId: String) -> String {
1083+
switch providerId {
1084+
case PhoneAuthProviderID:
1085+
return "phone"
1086+
default:
1087+
return providerId
1088+
}
1089+
}
1090+
1091+
private func buildLegacySignInRecovery(email: String,
1092+
signInMethods: [String],
1093+
attemptedProviderId: String?) -> LegacySignInRecoveryContext? {
1094+
let attemptedOptionId = attemptedProviderId.map(normalizeLegacyOptionId)
1095+
var options: [LegacySignInOption] = []
1096+
var unavailableProviders: [String] = []
1097+
1098+
for method in signInMethods {
1099+
if let option = resolveLegacyOption(for: method) {
1100+
if !options.contains(where: { $0.id == option.id }) {
1101+
options.append(option)
1102+
}
1103+
} else {
1104+
unavailableProviders.append(getProviderDisplayName(method))
1105+
}
1106+
}
1107+
1108+
guard !options.isEmpty else {
1109+
return nil
1110+
}
1111+
1112+
if let attemptedOptionId,
1113+
options.count == 1,
1114+
options.first?.id == attemptedOptionId {
1115+
return nil
1116+
}
1117+
1118+
return LegacySignInRecoveryContext(
1119+
email: email,
1120+
options: options,
1121+
unavailableProviders: unavailableProviders
1122+
)
1123+
}
1124+
1125+
private func resolveLegacyOption(for signInMethod: String) -> LegacySignInOption? {
1126+
switch signInMethod {
1127+
case EmailAuthProviderID:
1128+
guard emailPasswordSignInEnabled else { return nil }
1129+
return LegacySignInOption(
1130+
id: EmailAuthProviderID,
1131+
displayName: string.legacyEmailPasswordOptionLabel
1132+
)
1133+
case emailLinkSignInMethod:
1134+
guard emailLinkSignInEnabled else { return nil }
1135+
return LegacySignInOption(
1136+
id: emailLinkSignInMethod,
1137+
displayName: string.legacyEmailLinkOptionLabel
1138+
)
1139+
default:
1140+
guard let provider = providers.first(where: { $0.id == normalizeLegacyOptionId(signInMethod) }) else {
1141+
return nil
1142+
}
1143+
return LegacySignInOption(id: provider.id, displayName: provider.displayName)
1144+
}
1145+
}
1146+
}
1147+
1148+
extension AuthService {
1149+
@discardableResult
1150+
func tryPresentLegacySignInRecovery(email: String?,
1151+
attemptedProviderId: String?) async -> Bool {
1152+
guard configuration.legacyFetchSignInWithEmail,
1153+
let email,
1154+
!email.isEmpty else {
1155+
return false
1156+
}
1157+
1158+
do {
1159+
let signInMethods = try await auth.fetchSignInMethods(forEmail: email)
1160+
guard let recovery = buildLegacySignInRecovery(
1161+
email: email,
1162+
signInMethods: signInMethods,
1163+
attemptedProviderId: attemptedProviderId
1164+
) else {
1165+
return false
1166+
}
1167+
legacySignInRecovery = recovery
1168+
return true
1169+
} catch {
1170+
return false
1171+
}
1172+
}
1173+
1174+
func dismissLegacySignInRecovery() {
1175+
legacySignInRecovery = nil
1176+
}
1177+
1178+
func startLegacyRecovery(option: LegacySignInOption, email: String) {
1179+
suggestedEmailAddress = email
1180+
dismissLegacySignInRecovery()
1181+
authenticationFlow = .signIn
1182+
1183+
switch option.id {
1184+
case EmailAuthProviderID:
1185+
navigator.clear()
1186+
case emailLinkSignInMethod:
1187+
navigator.clear()
1188+
navigator.push(.emailLink)
1189+
case "phone":
1190+
navigator.clear()
1191+
navigator.push(.enterPhoneNumber)
1192+
default:
1193+
break
1194+
}
1195+
}
1196+
1197+
@ViewBuilder
1198+
private func legacyRecoveryButton(for option: LegacySignInOption,
1199+
email: String) -> some View {
1200+
switch option.id {
1201+
case EmailAuthProviderID:
1202+
AuthProviderButton(
1203+
label: option.displayName,
1204+
style: .email,
1205+
accessibilityId: "legacy-sign-in-with-email-button"
1206+
) {
1207+
self.startLegacyRecovery(option: option, email: email)
1208+
}
1209+
case emailLinkSignInMethod:
1210+
AuthProviderButton(
1211+
label: option.displayName,
1212+
style: .email,
1213+
accessibilityId: "legacy-sign-in-with-email-link-button"
1214+
) {
1215+
self.startLegacyRecovery(option: option, email: email)
1216+
}
1217+
case "phone":
1218+
AuthProviderButton(
1219+
label: option.displayName,
1220+
style: .phone,
1221+
accessibilityId: "legacy-sign-in-with-phone-button"
1222+
) {
1223+
self.startLegacyRecovery(option: option, email: email)
1224+
}
1225+
default:
1226+
if let provider = providers.first(where: { $0.id == option.id }) {
1227+
provider.authButton()
1228+
}
1229+
}
1230+
}
1231+
}
1232+
1233+
private extension AuthService {
10191234
// MARK: - Account Conflict Helper Methods
10201235

10211236
private func determineConflictType(from error: NSError) -> AccountConflictType? {

FirebaseSwiftUI/FirebaseAuthSwiftUI/Sources/Utils/StringUtils.swift

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -297,6 +297,48 @@ public class StringUtils {
297297
return localizedString(for: "Cancel")
298298
}
299299

300+
/// Legacy sign-in recovery title
301+
/// found in:
302+
/// - LegacySignInRecoveryView
303+
public var legacySignInRecoveryTitle: String {
304+
return localizedString(for: "Use a previous sign-in method")
305+
}
306+
307+
/// Legacy sign-in recovery message
308+
/// found in:
309+
/// - LegacySignInRecoveryView
310+
public func legacySignInRecoveryMessage(email: String) -> String {
311+
return String(
312+
format: localizedString(
313+
for: "You previously signed in with one of these methods for %@."
314+
),
315+
email
316+
)
317+
}
318+
319+
/// Legacy sign-in recovery helper copy
320+
/// found in:
321+
/// - LegacySignInRecoveryView
322+
public var legacySignInRecoveryUnavailableMessage: String {
323+
return localizedString(
324+
for: "Some previous sign-in methods are not enabled in this app."
325+
)
326+
}
327+
328+
/// Legacy sign-in recovery email/password option
329+
/// found in:
330+
/// - LegacySignInRecoveryView
331+
public var legacyEmailPasswordOptionLabel: String {
332+
return localizedString(for: "Continue with email and password")
333+
}
334+
335+
/// Legacy sign-in recovery email link option
336+
/// found in:
337+
/// - LegacySignInRecoveryView
338+
public var legacyEmailLinkOptionLabel: String {
339+
return localizedString(for: "Continue with email link")
340+
}
341+
300342
/// Email provider
301343
/// found in:
302344
/// - AuthPickerView

0 commit comments

Comments
 (0)