@@ -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
6494private 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 ? {
0 commit comments