diff --git a/Application/DevLogData/Sources/Common/DataLayerError.swift b/Application/DevLogData/Sources/Common/DataLayerError.swift index 59d6efae..f5eccb38 100644 --- a/Application/DevLogData/Sources/Common/DataLayerError.swift +++ b/Application/DevLogData/Sources/Common/DataLayerError.swift @@ -5,7 +5,6 @@ // Created by opfic on 3/11/26. // -import AuthenticationServices import Foundation import DevLogCore @@ -48,17 +47,3 @@ public enum DataLayerError: Error { case notAuthenticated case linkCredentialAlreadyInUse } - -public extension Error { - var isSocialLoginCancelled: Bool { - switch self { - case let authError as ASAuthorizationError: - return authError.code == .canceled - case let webAuthError as ASWebAuthenticationSessionError: - return webAuthError.code == .canceledLogin - default: - let nsError = self as NSError - return nsError.domain == "com.google.GIDSignIn" && nsError.code == -5 - } - } -} diff --git a/Application/DevLogData/Sources/Protocol/AuthenticationService.swift b/Application/DevLogData/Sources/Protocol/AuthenticationService.swift index c2c783b8..05820172 100644 --- a/Application/DevLogData/Sources/Protocol/AuthenticationService.swift +++ b/Application/DevLogData/Sources/Protocol/AuthenticationService.swift @@ -8,9 +8,9 @@ import Foundation public protocol AuthenticationService { - func signIn() async throws -> AuthDataResponse + func signIn() async throws -> AuthDataResponse? func signOut(_ uid: String) async throws func deleteAuth(_ uid: String) async throws - func link(uid: String, email: String) async throws + func link(uid: String, email: String) async throws -> Bool func unlink(_ uid: String) async throws } diff --git a/Application/DevLogData/Sources/Repository/AuthDataRepositoryImpl.swift b/Application/DevLogData/Sources/Repository/AuthDataRepositoryImpl.swift index 8f86b658..21b7550a 100644 --- a/Application/DevLogData/Sources/Repository/AuthDataRepositoryImpl.swift +++ b/Application/DevLogData/Sources/Repository/AuthDataRepositoryImpl.swift @@ -37,7 +37,7 @@ final class AuthDataRepositoryImpl: AuthDataRepository { return providerStrings.compactMap { AuthProvider(rawValue: $0) } } - func linkProvider(_ provider: AuthProvider) async throws { + func linkProvider(_ provider: AuthProvider) async throws -> Bool { guard let uid = authService.uid, let email = authService.currentUserEmail else { throw AuthError.notAuthenticated @@ -54,7 +54,7 @@ final class AuthDataRepositoryImpl: AuthDataRepository { } do { - try await service.link(uid: uid, email: email) + return try await service.link(uid: uid, email: email) } catch { throw mapLinkError(error) } diff --git a/Application/DevLogData/Sources/Repository/AuthenticationRepositoryImpl.swift b/Application/DevLogData/Sources/Repository/AuthenticationRepositoryImpl.swift index 1143fb3e..74d18d8b 100644 --- a/Application/DevLogData/Sources/Repository/AuthenticationRepositoryImpl.swift +++ b/Application/DevLogData/Sources/Repository/AuthenticationRepositoryImpl.swift @@ -31,11 +31,11 @@ final class AuthenticationRepositoryImpl: AuthenticationRepository { self.widgetSnapshotUpdater = widgetSnapshotUpdater } - func signIn(_ provider: AuthProvider) async throws { + func signIn(_ provider: AuthProvider) async throws -> Bool { authService.beginSignIn() do { - let response: AuthDataResponse + let response: AuthDataResponse? switch provider { case .apple: response = try await appleAuthService.signIn() @@ -45,8 +45,14 @@ final class AuthenticationRepositoryImpl: AuthenticationRepository { response = try await googleAuthService.signIn() } + guard let response else { + authService.cancelSignIn() + return false + } + try await userService.upsertUser(response) authService.completeSignIn() + return true } catch { if authService.uid != nil { try? await authService.clearCurrentSession() diff --git a/Application/DevLogDomain/Sources/Protocol/AuthDataRepository.swift b/Application/DevLogDomain/Sources/Protocol/AuthDataRepository.swift index dfd82da6..fc63eae3 100644 --- a/Application/DevLogDomain/Sources/Protocol/AuthDataRepository.swift +++ b/Application/DevLogDomain/Sources/Protocol/AuthDataRepository.swift @@ -13,7 +13,7 @@ public protocol AuthDataRepository { func fetchAllProviders() async throws -> [AuthProvider] /// 특정 프로바이더를 계정에 연결합니다 - func linkProvider(_ provider: AuthProvider) async throws + func linkProvider(_ provider: AuthProvider) async throws -> Bool /// 특정 프로바이더를 계정에서 해제합니다 func unlinkProvider(_ provider: AuthProvider) async throws diff --git a/Application/DevLogDomain/Sources/Protocol/AuthenticationRepository.swift b/Application/DevLogDomain/Sources/Protocol/AuthenticationRepository.swift index 5652583a..11c179ca 100644 --- a/Application/DevLogDomain/Sources/Protocol/AuthenticationRepository.swift +++ b/Application/DevLogDomain/Sources/Protocol/AuthenticationRepository.swift @@ -8,7 +8,7 @@ import Foundation public protocol AuthenticationRepository { - func signIn(_ provider: AuthProvider) async throws + func signIn(_ provider: AuthProvider) async throws -> Bool func signOut() async throws func restore() -> Bool func delete() async throws diff --git a/Application/DevLogDomain/Sources/UseCase/Auth/Provider/LinkAuthProviderUseCase.swift b/Application/DevLogDomain/Sources/UseCase/Auth/Provider/LinkAuthProviderUseCase.swift index aa58babc..60b9fbf8 100644 --- a/Application/DevLogDomain/Sources/UseCase/Auth/Provider/LinkAuthProviderUseCase.swift +++ b/Application/DevLogDomain/Sources/UseCase/Auth/Provider/LinkAuthProviderUseCase.swift @@ -6,5 +6,5 @@ // public protocol LinkAuthProviderUseCase { - func execute(_ provider: AuthProvider) async throws + func execute(_ provider: AuthProvider) async throws -> Bool } diff --git a/Application/DevLogDomain/Sources/UseCase/Auth/Provider/LinkAuthProviderUseCaseImpl.swift b/Application/DevLogDomain/Sources/UseCase/Auth/Provider/LinkAuthProviderUseCaseImpl.swift index 1a524282..d286c357 100644 --- a/Application/DevLogDomain/Sources/UseCase/Auth/Provider/LinkAuthProviderUseCaseImpl.swift +++ b/Application/DevLogDomain/Sources/UseCase/Auth/Provider/LinkAuthProviderUseCaseImpl.swift @@ -12,7 +12,7 @@ public final class LinkAuthProviderUseCaseImpl: LinkAuthProviderUseCase { self.repository = repository } - public func execute(_ provider: AuthProvider) async throws { + public func execute(_ provider: AuthProvider) async throws -> Bool { try await repository.linkProvider(provider) } } diff --git a/Application/DevLogDomain/Sources/UseCase/Auth/SignIn/SignInUseCase.swift b/Application/DevLogDomain/Sources/UseCase/Auth/SignIn/SignInUseCase.swift index 46122e3c..55132a92 100644 --- a/Application/DevLogDomain/Sources/UseCase/Auth/SignIn/SignInUseCase.swift +++ b/Application/DevLogDomain/Sources/UseCase/Auth/SignIn/SignInUseCase.swift @@ -6,5 +6,5 @@ // public protocol SignInUseCase { - func execute(_ provider: AuthProvider) async throws + func execute(_ provider: AuthProvider) async throws -> Bool } diff --git a/Application/DevLogDomain/Sources/UseCase/Auth/SignIn/SignInUseCaseImpl.swift b/Application/DevLogDomain/Sources/UseCase/Auth/SignIn/SignInUseCaseImpl.swift index bd6afe63..5803482f 100644 --- a/Application/DevLogDomain/Sources/UseCase/Auth/SignIn/SignInUseCaseImpl.swift +++ b/Application/DevLogDomain/Sources/UseCase/Auth/SignIn/SignInUseCaseImpl.swift @@ -12,7 +12,7 @@ public final class SignInUseCaseImpl: SignInUseCase { self.repository = repository } - public func execute(_ provider: AuthProvider) async throws { + public func execute(_ provider: AuthProvider) async throws -> Bool { try await repository.signIn(provider) } } diff --git a/Application/DevLogInfra/Sources/Common/InfraLayerError.swift b/Application/DevLogInfra/Sources/Common/InfraLayerError.swift index f1f89bfb..3d710c03 100644 --- a/Application/DevLogInfra/Sources/Common/InfraLayerError.swift +++ b/Application/DevLogInfra/Sources/Common/InfraLayerError.swift @@ -33,3 +33,17 @@ enum SocialLoginError: Error { case failedToStartWebAuthenticationSession case authenticationAlreadyInProgress } + +extension Error { + var isSocialLoginCancelled: Bool { + switch self { + case let authError as ASAuthorizationError: + return authError.code == .canceled + case let webAuthError as ASWebAuthenticationSessionError: + return webAuthError.code == .canceledLogin + default: + let nsError = self as NSError + return nsError.domain == "com.google.GIDSignIn" && nsError.code == -5 + } + } +} diff --git a/Application/DevLogInfra/Sources/Service/AuthServiceImpl.swift b/Application/DevLogInfra/Sources/Service/AuthServiceImpl.swift index 8103e66e..6d73ff4f 100644 --- a/Application/DevLogInfra/Sources/Service/AuthServiceImpl.swift +++ b/Application/DevLogInfra/Sources/Service/AuthServiceImpl.swift @@ -124,10 +124,11 @@ final class AuthServiceImpl: AuthService { logger.info("Clearing current auth session") do { - try await messaging.deleteToken() + if messaging.fcmToken != nil { + try await messaging.deleteToken() + } } catch { logger.error("Failed to delete FCM token while clearing session", error: error) - record(error, code: .deleteMessagingToken) } do { diff --git a/Application/DevLogInfra/Sources/Service/SocialLogin/AppleAuthenticationServiceImpl.swift b/Application/DevLogInfra/Sources/Service/SocialLogin/AppleAuthenticationServiceImpl.swift index 366eba9b..40e394b6 100644 --- a/Application/DevLogInfra/Sources/Service/SocialLogin/AppleAuthenticationServiceImpl.swift +++ b/Application/DevLogInfra/Sources/Service/SocialLogin/AppleAuthenticationServiceImpl.swift @@ -44,7 +44,7 @@ final class AppleAuthenticationServiceImpl: AuthenticationService { private let providerID = AuthProviderID.apple private let logger = Logger(category: "AppleAuthService") - func signIn() async throws -> AuthDataResponse { + func signIn() async throws -> AuthDataResponse? { logger.info("Starting Apple sign in") do { @@ -105,6 +105,8 @@ final class AppleAuthenticationServiceImpl: AuthenticationService { logger.info("Successfully signed in with Apple") return result.user.makeResponse(providerID: .apple) } catch { + if error.isSocialLoginCancelled { return nil } + logger.error("Failed to sign in with Apple", error: error) record(error, code: .signIn) throw error @@ -114,14 +116,16 @@ final class AppleAuthenticationServiceImpl: AuthenticationService { func signOut(_ uid: String) async throws { do { let infoRef = store.document(FirestorePath.userData(uid, document: .tokens)) - let doc = try await infoRef.getDocument() + try? await infoRef.updateData(["fcmToken": FieldValue.delete()]) - if doc.exists { - try await infoRef.updateData(["fcmToken": FieldValue.delete()]) + if messaging.fcmToken != nil { + do { + try await messaging.deleteToken() + } catch { + logger.error("Failed to delete FCM token while signing out with Apple", error: error) + } } - try await messaging.deleteToken() - try Auth.auth().signOut() } catch { logger.error("Failed to sign out with Apple", error: error) @@ -142,7 +146,7 @@ final class AppleAuthenticationServiceImpl: AuthenticationService { } } - func link(uid: String, email: String) async throws { + func link(uid: String, email: String) async throws -> Bool { do { let response = try await authenticateWithAppleAsync() @@ -170,7 +174,10 @@ final class AppleAuthenticationServiceImpl: AuthenticationService { ) try await user?.link(with: appleCredential) + return true } catch { + if error.isSocialLoginCancelled { return false } + logger.error("Failed to link Apple account", error: error) record(error, code: .link) if error.isFirebaseCredentialAlreadyInUse { diff --git a/Application/DevLogInfra/Sources/Service/SocialLogin/GithubAuthenticationServiceImpl.swift b/Application/DevLogInfra/Sources/Service/SocialLogin/GithubAuthenticationServiceImpl.swift index 8617c95a..97f2f2e9 100644 --- a/Application/DevLogInfra/Sources/Service/SocialLogin/GithubAuthenticationServiceImpl.swift +++ b/Application/DevLogInfra/Sources/Service/SocialLogin/GithubAuthenticationServiceImpl.swift @@ -52,7 +52,7 @@ final class GithubAuthenticationServiceImpl: NSObject, AuthenticationService { ) ) - func signIn() async throws -> AuthDataResponse { + func signIn() async throws -> AuthDataResponse? { logger.info("Starting GitHub sign in") do { @@ -90,6 +90,8 @@ final class GithubAuthenticationServiceImpl: NSObject, AuthenticationService { accessToken: accessToken ) } catch { + if error.isSocialLoginCancelled { return nil } + logger.error("Failed to sign in with GitHub", error: error) record(error, code: .signIn) throw error @@ -99,14 +101,16 @@ final class GithubAuthenticationServiceImpl: NSObject, AuthenticationService { func signOut(_ uid: String) async throws { do { let infoRef = store.document(FirestorePath.userData(uid, document: .tokens)) - let doc = try await infoRef.getDocument() + try? await infoRef.updateData(["fcmToken": FieldValue.delete()]) - if doc.exists { - try await infoRef.updateData(["fcmToken": FieldValue.delete()]) + if messaging.fcmToken != nil { + do { + try await messaging.deleteToken() + } catch { + logger.error("Failed to delete FCM token while signing out with GitHub", error: error) + } } - try await messaging.deleteToken() - try Auth.auth().signOut() } catch { logger.error("Failed to sign out with GitHub", error: error) @@ -125,7 +129,7 @@ final class GithubAuthenticationServiceImpl: NSObject, AuthenticationService { } } - func link(uid: String, email: String) async throws { + func link(uid: String, email: String) async throws -> Bool { logger.info("Linking GitHub account for user: \(uid)") do { @@ -153,7 +157,10 @@ final class GithubAuthenticationServiceImpl: NSObject, AuthenticationService { try await user?.link(with: credential) logger.info("Successfully linked GitHub account") + return true } catch { + if error.isSocialLoginCancelled { return false } + logger.error("Failed to link GitHub account", error: error) record(error, code: .link) if error.isFirebaseCredentialAlreadyInUse { diff --git a/Application/DevLogInfra/Sources/Service/SocialLogin/GoogleAuthenticationServiceImpl.swift b/Application/DevLogInfra/Sources/Service/SocialLogin/GoogleAuthenticationServiceImpl.swift index ae36a4b8..42db361b 100644 --- a/Application/DevLogInfra/Sources/Service/SocialLogin/GoogleAuthenticationServiceImpl.swift +++ b/Application/DevLogInfra/Sources/Service/SocialLogin/GoogleAuthenticationServiceImpl.swift @@ -33,7 +33,7 @@ final class GoogleAuthenticationServiceImpl: AuthenticationService { private let logger = Logger(category: "GoogleAuthService") @MainActor - func signIn() async throws -> AuthDataResponse { + func signIn() async throws -> AuthDataResponse? { logger.info("Starting Google sign in") guard let topViewController = provider.topViewController() else { @@ -66,6 +66,8 @@ final class GoogleAuthenticationServiceImpl: AuthenticationService { logger.info("Successfully signed in with Google") return result.user.makeResponse(providerID: .google) } catch { + if error.isSocialLoginCancelled { return nil } + logger.error("Failed to sign in with Google", error: error) record(error, code: .signIn) throw error @@ -75,16 +77,18 @@ final class GoogleAuthenticationServiceImpl: AuthenticationService { func signOut(_ uid: String) async throws { do { let infoRef = store.document(FirestorePath.userData(uid, document: .tokens)) - let doc = try await infoRef.getDocument() - - if doc.exists { - try await infoRef.updateData(["fcmToken": FieldValue.delete()]) - } + try? await infoRef.updateData(["fcmToken": FieldValue.delete()]) GIDSignIn.sharedInstance.signOut() try await GIDSignIn.sharedInstance.disconnect() - try await messaging.deleteToken() + if messaging.fcmToken != nil { + do { + try await messaging.deleteToken() + } catch { + logger.error("Failed to delete FCM token while signing out with Google", error: error) + } + } try Auth.auth().signOut() } catch { @@ -106,7 +110,7 @@ final class GoogleAuthenticationServiceImpl: AuthenticationService { } @MainActor - func link(uid: String, email: String) async throws { + func link(uid: String, email: String) async throws -> Bool { do { guard let topViewController = provider.topViewController() else { throw UIError.notFoundTopViewController @@ -134,7 +138,10 @@ final class GoogleAuthenticationServiceImpl: AuthenticationService { let credential = GoogleAuthProvider.credential(withIDToken: idToken, accessToken: accessToken) try await user?.link(with: credential) + return true } catch { + if error.isSocialLoginCancelled { return false } + logger.error("Failed to link Google account", error: error) record(error, code: .link) if error.isFirebaseCredentialAlreadyInUse { diff --git a/Application/DevLogPresentation/Sources/Extension/Error+SocialLogin.swift b/Application/DevLogPresentation/Sources/Extension/Error+SocialLogin.swift index a6d061bf..ca1d1110 100644 --- a/Application/DevLogPresentation/Sources/Extension/Error+SocialLogin.swift +++ b/Application/DevLogPresentation/Sources/Extension/Error+SocialLogin.swift @@ -4,20 +4,3 @@ // // Created by opfic on 5/15/26. // - -import AuthenticationServices -import Foundation - -extension Error { - var isSocialLoginCancelled: Bool { - switch self { - case let authError as ASAuthorizationError: - return authError.code == .canceled - case let webAuthError as ASWebAuthenticationSessionError: - return webAuthError.code == .canceledLogin - default: - let nsError = self as NSError - return nsError.domain == "com.google.GIDSignIn" && nsError.code == -5 - } - } -} diff --git a/Application/DevLogPresentation/Sources/Login/LoginFeature.swift b/Application/DevLogPresentation/Sources/Login/LoginFeature.swift index fa2a72f3..196e6da3 100644 --- a/Application/DevLogPresentation/Sources/Login/LoginFeature.swift +++ b/Application/DevLogPresentation/Sources/Login/LoginFeature.swift @@ -78,11 +78,12 @@ private extension LoginFeature { .run { [signInUseCase] send in await send(.loading(.begin(target: .default, mode: .immediate))) do { - try await signInUseCase.execute(provider) + let signedIn = try await signInUseCase.execute(provider) // 유스케이스 완료가 화면 전환 완료를 의미하지 않으므로 LoginView가 교체될 때까지 로딩을 유지한다. + guard !signedIn else { return } + await send(.loading(.end(target: .default, mode: .immediate))) } catch { await send(.loading(.end(target: .default, mode: .immediate))) - if error.isSocialLoginCancelled { return } await send(.signInFailed(Self.alertType(for: error))) } } diff --git a/Application/DevLogPresentation/Sources/Settings/AccountFeature.swift b/Application/DevLogPresentation/Sources/Settings/AccountFeature.swift index 1be3b104..fbc492b1 100644 --- a/Application/DevLogPresentation/Sources/Settings/AccountFeature.swift +++ b/Application/DevLogPresentation/Sources/Settings/AccountFeature.swift @@ -141,7 +141,12 @@ private extension AccountFeature { .run { [fetchProvidersUseCase, linkProviderUseCase] send in await send(.loading(.begin(target: .default, mode: .delayed))) do { - try await linkProviderUseCase.execute(provider) + let linked = try await linkProviderUseCase.execute(provider) + guard linked else { + await send(.loading(.end(target: .default, mode: .delayed))) + return + } + await ToastPresenter.present(message: String(localized: "account_toast_link_success")) let providers = try await fetchProvidersUseCase.execute() await send(.setProviders( @@ -151,9 +156,6 @@ private extension AccountFeature { await send(.loading(.end(target: .default, mode: .delayed))) } catch { await send(.loading(.end(target: .default, mode: .delayed))) - - if error.isSocialLoginCancelled { return } - await send(.setAlert(Self.linkAlertType(for: error))) } } diff --git a/Application/DevLogPresentation/Tests/Login/LoginFeatureTests.swift b/Application/DevLogPresentation/Tests/Login/LoginFeatureTests.swift index 3157f33f..d9657866 100644 --- a/Application/DevLogPresentation/Tests/Login/LoginFeatureTests.swift +++ b/Application/DevLogPresentation/Tests/Login/LoginFeatureTests.swift @@ -110,10 +110,10 @@ struct LoginFeatureTests { )) } - @Test("소셜 로그인 취소 에러가 발생하면 알림을 표시하지 않는다") - func 소셜_로그인_취소_에러가_발생하면_알림을_표시하지_않는다() async { + @Test("소셜 로그인이 취소되어도 알림을 표시하지 않는다") + func 소셜_로그인이_취소되어도_알림을_표시하지_않는다() async { let spy = SignInUseCaseSpy() - spy.error = NSError(domain: "com.google.GIDSignIn", code: -5) + spy.signedIn = false let driver = LoginTestDriver(useCase: spy) driver.tapSignInButton(.google) diff --git a/Application/DevLogPresentation/Tests/Settings/AccountFeatureTests.swift b/Application/DevLogPresentation/Tests/Settings/AccountFeatureTests.swift index 970a64ad..42108b12 100644 --- a/Application/DevLogPresentation/Tests/Settings/AccountFeatureTests.swift +++ b/Application/DevLogPresentation/Tests/Settings/AccountFeatureTests.swift @@ -181,10 +181,10 @@ struct AccountFeatureTests { } } - @Test("소셜 로그인 취소 에러로 연동이 실패하면 알림을 표시하지 않는다") - func 소셜_로그인_취소_에러로_연동이_실패하면_알림을_표시하지_않는다() async { + @Test("소셜 로그인이 취소되어도 연동 알림을 표시하지 않는다") + func 소셜_로그인이_취소되어도_연동_알림을_표시하지_않는다() async { let linkSpy = LinkAuthProviderUseCaseSpy() - linkSpy.error = NSError(domain: "com.google.GIDSignIn", code: -5) + linkSpy.linked = false let driver = AccountTestDriver(linkUseCase: linkSpy) driver.linkWithProvider(.google) @@ -323,12 +323,13 @@ private final class FetchAuthProvidersUseCaseSpy: FetchAuthProvidersUseCase { private final class LinkAuthProviderUseCaseSpy: LinkAuthProviderUseCase { var error: Error? + var linked = true var shouldSuspend = false private(set) var providers = [AuthProvider]() private var continuation: CheckedContinuation? private var shouldResume = false - func execute(_ provider: AuthProvider) async throws { + func execute(_ provider: AuthProvider) async throws -> Bool { providers.append(provider) if shouldSuspend { @@ -345,6 +346,8 @@ private final class LinkAuthProviderUseCaseSpy: LinkAuthProviderUseCase { if let error { throw error } + + return linked } func resume() { diff --git a/Application/DevLogPresentation/Tests/Support/TestSupport.swift b/Application/DevLogPresentation/Tests/Support/TestSupport.swift index 2682568a..2172c7ac 100644 --- a/Application/DevLogPresentation/Tests/Support/TestSupport.swift +++ b/Application/DevLogPresentation/Tests/Support/TestSupport.swift @@ -52,13 +52,14 @@ final class FetchPushNotificationsUseCaseSpy: FetchPushNotificationsUseCase { final class SignInUseCaseSpy: SignInUseCase { var error: Error? + var signedIn = true var shouldSuspend = false private(set) var calledProviders: [AuthProvider] = [] private(set) var successfulProviders = [AuthProvider]() private var continuation: CheckedContinuation? private var shouldResume = false - func execute(_ provider: AuthProvider) async throws { + func execute(_ provider: AuthProvider) async throws -> Bool { calledProviders.append(provider) if shouldSuspend { @@ -76,7 +77,11 @@ final class SignInUseCaseSpy: SignInUseCase { throw error } - successfulProviders.append(provider) + if signedIn { + successfulProviders.append(provider) + } + + return signedIn } func resume() {