From 5a897d0b32002dd384af1e0a3f5b2333a600fc11 Mon Sep 17 00:00:00 2001 From: gomminjae Date: Mon, 29 Jun 2026 23:11:02 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(auth):=20=EC=95=A0=ED=94=8C/=EC=B9=B4?= =?UTF-8?q?=EC=B9=B4=EC=98=A4=20=EC=86=8C=EC=85=9C=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20=EB=8F=84=EC=9E=85=20(=EC=9D=B4=EB=A9=94=EC=9D=BC?= =?UTF-8?q?=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EB=8C=80=EC=B2=B4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 카카오: KakaoSDK OIDC idToken 획득, Data 레이어로 SDK 격리 (KakaoAuthServiceProtocol/KakaoAuthService) - 애플: SignInWithAppleButton + identityToken 추출 - POST /auth/social-login { provider, platform, idToken }, 응답은 기존 로그인 DTO 재사용 - LoginView를 소셜 버튼 + 게스트로 교체, 이메일/비번 로그인 제거 - SocialProvider, LoginError(cancelled/missingIDToken) 추가 - Apple Sign In entitlement, NewdokApp 카카오 SDK init + onOpenURL - 테스트/목 소셜 플로우로 갱신 신규 유저 회원정보 입력 플로우(/auth/social-login/signup)는 백엔드 응답 스펙(신규 판별/signupToken) 확정 후 작업 예정. Co-Authored-By: Claude Opus 4.8 (1M context) --- Modules/App/App.entitlements | 10 ++ Modules/App/Project.swift | 12 ++ Modules/App/Sources/NewdokApp.swift | 11 ++ .../Auth/Data/Sources/API/AuthUserAPI.swift | 12 +- .../Sources/API/Encodable/LoginRequest.swift | 5 +- .../Data/Sources/AuthRepositoryImpl.swift | 12 +- .../Sources/Social/KakaoAuthService.swift | 39 +++++ .../Domain/Sources/Models/LoginError.swift | 14 +- .../Sources/Models/SocialProvider.swift | 6 + .../Sources/Repository/AuthRepository.swift | 2 +- .../Service/KakaoAuthServiceProtocol.swift | 6 + .../Domain/Sources/UseCase/LoginUseCase.swift | 2 +- Modules/Features/Auth/Project.swift | 6 +- .../Auth/Sources/AuthDIContainer.swift | 1 + .../Auth/Sources/Login/View/LoginView.swift | 139 +++++------------- .../Login/View/ViewModel/LoginViewModel.swift | 112 ++++++++------ .../Sources/UseCase/LoginUseCaseImpl.swift | 4 +- .../Sources/UseCase/SignupUseCaseImpl.swift | 10 +- .../Auth/Testing/Sources/AuthTesting.swift | 21 ++- Modules/Features/Auth/Tests/AuthTests.swift | 109 +++++--------- 20 files changed, 274 insertions(+), 259 deletions(-) create mode 100644 Modules/App/App.entitlements create mode 100644 Modules/Features/Auth/Data/Sources/Social/KakaoAuthService.swift create mode 100644 Modules/Features/Auth/Domain/Sources/Models/SocialProvider.swift create mode 100644 Modules/Features/Auth/Domain/Sources/Service/KakaoAuthServiceProtocol.swift diff --git a/Modules/App/App.entitlements b/Modules/App/App.entitlements new file mode 100644 index 00000000..a812db50 --- /dev/null +++ b/Modules/App/App.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.developer.applesignin + + Default + + + diff --git a/Modules/App/Project.swift b/Modules/App/Project.swift index 07318d7e..17a53177 100644 --- a/Modules/App/Project.swift +++ b/Modules/App/Project.swift @@ -38,6 +38,7 @@ let project = Project( infoPlist: .extendingDefault( with: [ "API_BASE_URL": "$(API_BASE_URL)", + "KAKAO_NATIVE_APP_KEY": "$(KAKAO_NATIVE_APP_KEY)", "CFBundleDisplayName": "$(APP_DISPLAY_NAME)", "CFBundleShortVersionString": "$(MARKETING_VERSION)", "CFBundleVersion": "$(CURRENT_PROJECT_VERSION)", @@ -68,13 +69,24 @@ let project = Project( [ "CFBundleURLName": "$(PRODUCT_BUNDLE_IDENTIFIER)", "CFBundleURLSchemes": ["newdok"] + ], + [ + "CFBundleURLName": "kakao", + "CFBundleURLSchemes": ["kakao$(KAKAO_NATIVE_APP_KEY)"] ] ], + "LSApplicationQueriesSchemes": [ + "kakaokompassauth", + "kakaotalk", + "kakaolink", + "kakaoplus" + ], "ITSAppUsesNonExemptEncryption": false ] ), sources: ["Sources/**"], resources: ["Resources/**"], + entitlements: "App.entitlements", dependencies: [ // Infrastructure .networkKit, diff --git a/Modules/App/Sources/NewdokApp.swift b/Modules/App/Sources/NewdokApp.swift index c4cec504..4451f900 100644 --- a/Modules/App/Sources/NewdokApp.swift +++ b/Modules/App/Sources/NewdokApp.swift @@ -5,6 +5,8 @@ import DesignSystem import PopupView import FirebaseCore import FirebaseAnalytics +import KakaoSDKCommon +import KakaoSDKAuth @main struct NewdokApp: App { @@ -19,6 +21,10 @@ struct NewdokApp: App { AppErrorMapperRegistry.register(NetworkErrorAppMapper()) TokenStore.shared.migrateTokenIfNeeded() + if let kakaoAppKey = Bundle.main.object(forInfoDictionaryKey: "KAKAO_NATIVE_APP_KEY") as? String { + KakaoSDK.initSDK(appKey: kakaoAppKey) + } + let router = AppRouter() self._router = State(initialValue: router) self.container = AppContainer(router: router, deps: AppDependencies()) @@ -39,6 +45,11 @@ struct NewdokApp: App { .environment(ToastCenter.shared) .allowsHitTesting(false) ) + .onOpenURL { url in + if AuthApi.isKakaoTalkLoginUrl(url) { + AuthController.handleOpenUrl(url: url) + } + } .task { await checkVersion() } diff --git a/Modules/Features/Auth/Data/Sources/API/AuthUserAPI.swift b/Modules/Features/Auth/Data/Sources/API/AuthUserAPI.swift index c55115b1..198d03fd 100644 --- a/Modules/Features/Auth/Data/Sources/API/AuthUserAPI.swift +++ b/Modules/Features/Auth/Data/Sources/API/AuthUserAPI.swift @@ -8,12 +8,14 @@ private enum AuthBaseURL { struct Login: APIRequest { typealias Response = AuthLoginResponseDTO - let loginId: String - let password: String - var baseURL: URL { AuthBaseURL.users } - var path: String { "/login" } + let provider: String + let idToken: String + var baseURL: URL { AuthBaseURL.auth } + var path: String { "/social-login" } var method: HTTPMethod { .post } - var task: RequestTask { .jsonBody(LoginRequest(loginId: loginId, password: password)) } + var task: RequestTask { + .jsonBody(LoginRequest(provider: provider, platform: "IOS", idToken: idToken)) + } } struct Signup: APIRequest { diff --git a/Modules/Features/Auth/Data/Sources/API/Encodable/LoginRequest.swift b/Modules/Features/Auth/Data/Sources/API/Encodable/LoginRequest.swift index 350c3fdb..0608b41b 100644 --- a/Modules/Features/Auth/Data/Sources/API/Encodable/LoginRequest.swift +++ b/Modules/Features/Auth/Data/Sources/API/Encodable/LoginRequest.swift @@ -1,4 +1,5 @@ struct LoginRequest: Encodable, Sendable { - let loginId: String - let password: String + let provider: String + let platform: String + let idToken: String } diff --git a/Modules/Features/Auth/Data/Sources/AuthRepositoryImpl.swift b/Modules/Features/Auth/Data/Sources/AuthRepositoryImpl.swift index 714888c8..93e55573 100644 --- a/Modules/Features/Auth/Data/Sources/AuthRepositoryImpl.swift +++ b/Modules/Features/Auth/Data/Sources/AuthRepositoryImpl.swift @@ -18,10 +18,10 @@ public final class AuthRepositoryImpl: AuthRepository { self.userInfoStore = userInfoStore } - public func login(loginId: String, password: String) async throws -> (AuthUser, String) { + public func login(provider: SocialProvider, idToken: String) async throws -> (AuthUser, String) { do { let response = try await network.request( - Login(loginId: loginId, password: password) + Login(provider: provider.rawValue, idToken: idToken) ) let user = response.user.toDomain() let token = response.accessToken @@ -31,14 +31,6 @@ public final class AuthRepositoryImpl: AuthRepository { return (user, token) } catch let error as NetworkError { - if case .serverError(let statusCode, let message) = error, statusCode == 400 { - let errorMessage = message ?? "" - if errorMessage.contains("비밀번호") { - throw LoginError.invalidPassword - } else if errorMessage.contains("계정") { - throw LoginError.accountNotFound - } - } throw LoginError.networkError(error) } catch { throw LoginError.networkError(error) diff --git a/Modules/Features/Auth/Data/Sources/Social/KakaoAuthService.swift b/Modules/Features/Auth/Data/Sources/Social/KakaoAuthService.swift new file mode 100644 index 00000000..3464af18 --- /dev/null +++ b/Modules/Features/Auth/Data/Sources/Social/KakaoAuthService.swift @@ -0,0 +1,39 @@ +import Foundation +import AuthDomain +import KakaoSDKAuth +import KakaoSDKUser +import KakaoSDKCommon + +@MainActor +public struct KakaoAuthService: KakaoAuthServiceProtocol { + public init() {} + + public func fetchIDToken() async throws -> String { + try await withCheckedThrowingContinuation { continuation in + let completion: (OAuthToken?, Error?) -> Void = { token, error in + if let error { + continuation.resume(throwing: Self.map(error)) + } else if let idToken = token?.idToken { + continuation.resume(returning: idToken) + } else { + continuation.resume(throwing: LoginError.missingIDToken) + } + } + + if UserApi.isKakaoTalkLoginAvailable() { + UserApi.shared.loginWithKakaoTalk(completion: completion) + } else { + UserApi.shared.loginWithKakaoAccount(completion: completion) + } + } + } + + private static func map(_ error: Error) -> LoginError { + if let sdkError = error as? SdkError, + case .ClientFailed(let reason, _) = sdkError, + reason == .Cancelled { + return .cancelled + } + return .networkError(error) + } +} diff --git a/Modules/Features/Auth/Domain/Sources/Models/LoginError.swift b/Modules/Features/Auth/Domain/Sources/Models/LoginError.swift index b93db918..02869f09 100644 --- a/Modules/Features/Auth/Domain/Sources/Models/LoginError.swift +++ b/Modules/Features/Auth/Domain/Sources/Models/LoginError.swift @@ -1,16 +1,18 @@ import Foundation public enum LoginError: Error, LocalizedError { - case invalidPassword - case accountNotFound + /// 사용자가 소셜 로그인 동의/인증을 취소함 + case cancelled + /// 소셜 SDK에서 idToken을 받지 못함 + case missingIDToken case networkError(Error) public var errorDescription: String? { switch self { - case .invalidPassword: - return "비밀번호가 일치하지 않습니다" - case .accountNotFound: - return "등록되지 않은 계정이거나, 아이디를 다시 확인해주세요" + case .cancelled: + return "로그인이 취소되었습니다" + case .missingIDToken: + return "소셜 로그인 정보를 가져오지 못했습니다" case .networkError(let error): return "네트워크 오류: \(error.localizedDescription)" } diff --git a/Modules/Features/Auth/Domain/Sources/Models/SocialProvider.swift b/Modules/Features/Auth/Domain/Sources/Models/SocialProvider.swift new file mode 100644 index 00000000..ab0e1aca --- /dev/null +++ b/Modules/Features/Auth/Domain/Sources/Models/SocialProvider.swift @@ -0,0 +1,6 @@ +import Foundation + +public enum SocialProvider: String, Sendable { + case kakao = "KAKAO" + case apple = "APPLE" +} diff --git a/Modules/Features/Auth/Domain/Sources/Repository/AuthRepository.swift b/Modules/Features/Auth/Domain/Sources/Repository/AuthRepository.swift index 7e1d34e0..9ffaeb35 100644 --- a/Modules/Features/Auth/Domain/Sources/Repository/AuthRepository.swift +++ b/Modules/Features/Auth/Domain/Sources/Repository/AuthRepository.swift @@ -1,7 +1,7 @@ import Foundation public protocol AuthRepository: Sendable { - func login(loginId: String, password: String) async throws -> (AuthUser, String) + func login(provider: SocialProvider, idToken: String) async throws -> (AuthUser, String) func signup( loginId: String, password: String, diff --git a/Modules/Features/Auth/Domain/Sources/Service/KakaoAuthServiceProtocol.swift b/Modules/Features/Auth/Domain/Sources/Service/KakaoAuthServiceProtocol.swift new file mode 100644 index 00000000..15a27dd7 --- /dev/null +++ b/Modules/Features/Auth/Domain/Sources/Service/KakaoAuthServiceProtocol.swift @@ -0,0 +1,6 @@ +import Foundation + +@MainActor +public protocol KakaoAuthServiceProtocol: Sendable { + func fetchIDToken() async throws -> String +} diff --git a/Modules/Features/Auth/Domain/Sources/UseCase/LoginUseCase.swift b/Modules/Features/Auth/Domain/Sources/UseCase/LoginUseCase.swift index c07819ee..10ff80c4 100644 --- a/Modules/Features/Auth/Domain/Sources/UseCase/LoginUseCase.swift +++ b/Modules/Features/Auth/Domain/Sources/UseCase/LoginUseCase.swift @@ -1,5 +1,5 @@ import Foundation public protocol LoginUseCase: Sendable { - func execute(loginId: String, password: String) async throws -> AuthUser + func execute(provider: SocialProvider, idToken: String) async throws -> AuthUser } diff --git a/Modules/Features/Auth/Project.swift b/Modules/Features/Auth/Project.swift index 2acfb4a6..ceb60c9a 100644 --- a/Modules/Features/Auth/Project.swift +++ b/Modules/Features/Auth/Project.swift @@ -4,7 +4,11 @@ import ProjectDescriptionHelpers let project = Project.feature( name: "Auth", domainDependencies: [.foundationKit], - dataDependencies: [], + dataDependencies: [ + .external(name: "KakaoSDKCommon"), + .external(name: "KakaoSDKAuth"), + .external(name: "KakaoSDKUser") + ], featureDependencies: [.foundationKit, .featureData("Auth"), .networkKit], testDependencies: [.foundationKit], hasInterface: true diff --git a/Modules/Features/Auth/Sources/AuthDIContainer.swift b/Modules/Features/Auth/Sources/AuthDIContainer.swift index 900a141c..18e86f7e 100644 --- a/Modules/Features/Auth/Sources/AuthDIContainer.swift +++ b/Modules/Features/Auth/Sources/AuthDIContainer.swift @@ -36,6 +36,7 @@ final class AuthDIContainer { let repository = makeRepository() return LoginViewModel( loginUseCase: LoginUseCaseImpl(authRepository: repository), + kakaoAuthService: KakaoAuthService(), tokenStorage: tokenStorage, appState: appState ) diff --git a/Modules/Features/Auth/Sources/Login/View/LoginView.swift b/Modules/Features/Auth/Sources/Login/View/LoginView.swift index caba3bc5..b55f7790 100644 --- a/Modules/Features/Auth/Sources/Login/View/LoginView.swift +++ b/Modules/Features/Auth/Sources/Login/View/LoginView.swift @@ -10,15 +10,13 @@ import DesignSystem import AuthDomain import Shared import PopupView +import AuthenticationServices public struct LoginView: View { @State private var viewModel: LoginViewModel - @FocusState private var isIdFocused: Bool - @FocusState private var isPwdFocused: Bool - @State private var showHomeView = false @State private var showToast: Bool = false @State private var toastMessage: String = "" - + @Environment(AppRouter.self) private var router @Environment(TabSelection.self) private var tabSelection @@ -27,108 +25,41 @@ public struct LoginView: View { } public var body: some View { - ZStack { - VStack { + VStack { HStack { Image(asset: DesignSystemAsset.logo) - .frame(alignment: .leading) .padding(.leading, 28) Spacer() } - .padding(.bottom, 24) - - VStack(alignment: .leading) { - Text("아이디") - .font(.hanSansNeo(14, .medium)) + .padding(.top, 40) - TextField("아이디를 입력하세요", text: $viewModel.loginId) - .font(.hanSansNeo(14, .medium)) - .frame(height: 56) - .customTextFieldStyle(isError: viewModel.isLoginIdError, isFocused: $isIdFocused) - .focused($isIdFocused) - Text(viewModel.isLoginIdError ? (viewModel.errorMessage ?? "") : " ") - .font(.hanSansNeo(12, .medium)) - .foregroundStyle(Color.errorNormal) + Spacer() - Text("비밀번호") - .font(.hanSansNeo(14, .medium)) - .padding(.top, 16) + VStack(spacing: 12) { + kakaoButton - Group { - if viewModel.isSecurePassword { - SecureField("비밀번호를 입력해주세요", text: $viewModel.password) - .focused($isPwdFocused) - } else { - TextField("비밀번호를 입력해주세요", text: $viewModel.password) - .focused($isPwdFocused) - } - } - .font(.hanSansNeo(14, .medium)) - .modifier( - PasswordFieldModifier( - isSecure: $viewModel.isSecurePassword, - isFocused: $isPwdFocused, - isError: viewModel.isPasswordError - ) - ) - Text(viewModel.isPasswordError ? (viewModel.errorMessage ?? "") : " ") - .font(.hanSansNeo(12, .medium)) - .foregroundStyle(Color.errorNormal) - - HStack { - Spacer() - Button("아이디/비밀번호 찾기") { - router.push(.recovery) - } - .font(.hanSansNeo(14, .medium)) - .foregroundStyle(Color.captionNeutral) - } - .padding(.top, 10) - - Spacer() - - Button { - viewModel.login { + SignInWithAppleButton(.continue) { request in + request.requestedScopes = [.fullName, .email] + } onCompletion: { result in + viewModel.handleAppleResult(result) { router.resetTo(.tabbar(selectedTab: .home)) } - } label: { - Text("로그인") - .font(.hanSansNeo(16, .bold)) - .disabled(!viewModel.isLoginEnabled) - .frame(maxWidth: .infinity) - .frame(height: 48) - .background(viewModel.isLoginEnabled ? Color.primaryNormal : Color.lineNeutral) - .foregroundColor(viewModel.isLoginEnabled ? .white : Color.captionDisabled) - .cornerRadius(4) - .contentShape(Rectangle()) } - .disabled(!viewModel.isLoginEnabled || viewModel.isLoading) + .signInWithAppleButtonStyle(.black) + .frame(height: 48) + .cornerRadius(4) + .disabled(viewModel.isLoading) - HStack { - Button("비회원으로 이용하기") { - viewModel.loginAsGuest() - router.resetTo(.tabbar(selectedTab: .home)) - } - .font(.hanSansNeo(14, .medium)) - .foregroundStyle(Color.captionNeutral) - .padding(.leading, 80) - - Text("|") - .foregroundStyle(Color.lineAlternative) - - Button("회원가입") { - router.push(.signup) - } - .font(.hanSansNeo(14, .medium)) - .foregroundStyle(Color.primaryNormal) + Button("비회원으로 이용하기") { + viewModel.loginAsGuest() + router.resetTo(.tabbar(selectedTab: .home)) } - .padding(.bottom, 56) + .font(.hanSansNeo(14, .medium)) + .foregroundStyle(Color.captionNeutral) + .padding(.top, 8) } .padding(.horizontal, 24) - } - } - .safeAreaInset(edge: .top, spacing: 0) { - Color.clear.frame(height: 20) + .padding(.bottom, 56) } .ignoresSafeArea(.keyboard) .navigationBarTitleDisplayMode(.inline) @@ -151,14 +82,6 @@ public struct LoginView: View { .closeOnTapOutside(false) } .toolbar { - ToolbarItem(placement: .navigationBarLeading) { - if !router.path.isEmpty { - BackButton(action: { - router.pop() - }) - } - } - ToolbarItem(placement: .principal) { Text("로그인") .font(.hanSansNeo(16, .bold)) @@ -171,4 +94,22 @@ public struct LoginView: View { onRetry: {} ) } + + private var kakaoButton: some View { + Button { + viewModel.loginWithKakao { + router.resetTo(.tabbar(selectedTab: .home)) + } + } label: { + Text("카카오로 시작하기") + .font(.hanSansNeo(16, .bold)) + .frame(maxWidth: .infinity) + .frame(height: 48) + .background(Color(red: 254 / 255, green: 229 / 255, blue: 0)) + .foregroundColor(Color(red: 0, green: 0, blue: 0).opacity(0.85)) + .cornerRadius(4) + .contentShape(Rectangle()) + } + .disabled(viewModel.isLoading) + } } diff --git a/Modules/Features/Auth/Sources/Login/View/ViewModel/LoginViewModel.swift b/Modules/Features/Auth/Sources/Login/View/ViewModel/LoginViewModel.swift index e70b6f05..89bfb9a2 100644 --- a/Modules/Features/Auth/Sources/Login/View/ViewModel/LoginViewModel.swift +++ b/Modules/Features/Auth/Sources/Login/View/ViewModel/LoginViewModel.swift @@ -8,90 +8,104 @@ import SwiftUI import AuthDomain import Shared import Observation +import AuthenticationServices @Observable @MainActor public final class LoginViewModel: ErrorHandling { private let loginUseCase: LoginUseCase + private let kakaoAuthService: KakaoAuthServiceProtocol private let tokenStorage: TokenStorageProtocol private let appState: AppState - public var loginId: String - public var password: String - public var isUserIdValid: Bool - public var isUserPwdValid: Bool - public var errorMessage: String? public var isLoading: Bool - public var isSecurePassword: Bool = true - public var user: AuthUser? - public var isLoginIdError: Bool = false - public var isPasswordError: Bool = false public var currentError: AppError? public init( loginUseCase: LoginUseCase, + kakaoAuthService: KakaoAuthServiceProtocol, tokenStorage: TokenStorageProtocol, appState: AppState ) { self.loginUseCase = loginUseCase + self.kakaoAuthService = kakaoAuthService self.tokenStorage = tokenStorage self.appState = appState - self.loginId = "" - self.password = "" - self.isUserIdValid = false - self.isUserPwdValid = false self.isLoading = false - self.errorMessage = nil } - public func login(onSuccess: @escaping () -> Void) { + /// 카카오 로그인: SDK로 idToken 획득 후 서버 로그인 + public func loginWithKakao(onSuccess: @escaping () -> Void) { guard !isLoading else { return } isLoading = true Task { - defer { isLoading = false } - do { - _ = try await loginUseCase.execute(loginId: loginId, password: password) - - errorMessage = nil - isLoginIdError = false - isPasswordError = false - - appState.login() - onSuccess() - } catch let error as LoginError { - handleLoginError(error) + let idToken = try await kakaoAuthService.fetchIDToken() + try await authenticate(provider: .kakao, idToken: idToken, onSuccess: onSuccess) } catch { - errorMessage = "로그인에 실패했습니다" - isPasswordError = false - isLoginIdError = false - handleError(error, feature: "login", operation: "login") + isLoading = false + handle(error) } } } - private func handleLoginError(_ error: LoginError) { - switch error { - case .invalidPassword: - errorMessage = error.localizedDescription - isPasswordError = true - isLoginIdError = false - case .accountNotFound: - errorMessage = error.localizedDescription - isLoginIdError = true - isPasswordError = false - case .networkError(let underlying): - isPasswordError = false - isLoginIdError = false - handleError(underlying, feature: "login", operation: "login") - return + /// 애플 로그인: SignInWithAppleButton 콜백 결과 처리 + public func handleAppleResult( + _ result: Result, + onSuccess: @escaping () -> Void + ) { + guard !isLoading else { return } + + switch result { + case .success(let authorization): + guard + let credential = authorization.credential as? ASAuthorizationAppleIDCredential, + let tokenData = credential.identityToken, + let idToken = String(data: tokenData, encoding: .utf8) + else { + handle(LoginError.missingIDToken) + return + } + isLoading = true + Task { + do { + try await authenticate(provider: .apple, idToken: idToken, onSuccess: onSuccess) + } catch { + isLoading = false + handle(error) + } + } + case .failure(let error): + if (error as? ASAuthorizationError)?.code == .canceled { return } + handle(LoginError.networkError(error)) } - currentError = .userMessage(error.localizedDescription) } - public var isLoginEnabled: Bool { - !loginId.isEmpty && !password.isEmpty + private func authenticate( + provider: SocialProvider, + idToken: String, + onSuccess: @escaping () -> Void + ) async throws { + defer { isLoading = false } + _ = try await loginUseCase.execute(provider: provider, idToken: idToken) + currentError = nil + appState.login() + onSuccess() + } + + private func handle(_ error: Error) { + switch error { + case LoginError.cancelled: + // 사용자가 취소 — 조용히 무시 + break + case LoginError.missingIDToken: + currentError = .userMessage(LoginError.missingIDToken.localizedDescription) + case LoginError.networkError(let underlying): + handleError(underlying, feature: "login", operation: "socialLogin") + default: + handleError(error, feature: "login", operation: "socialLogin") + } } public func loginAsGuest() { diff --git a/Modules/Features/Auth/Sources/UseCase/LoginUseCaseImpl.swift b/Modules/Features/Auth/Sources/UseCase/LoginUseCaseImpl.swift index 7ab93934..0269c3b6 100644 --- a/Modules/Features/Auth/Sources/UseCase/LoginUseCaseImpl.swift +++ b/Modules/Features/Auth/Sources/UseCase/LoginUseCaseImpl.swift @@ -16,8 +16,8 @@ public final class LoginUseCaseImpl: LoginUseCase { self.authRepository = authRepository } - public func execute(loginId: String, password: String) async throws -> AuthUser { - let (user, _) = try await authRepository.login(loginId: loginId, password: password) + public func execute(provider: SocialProvider, idToken: String) async throws -> AuthUser { + let (user, _) = try await authRepository.login(provider: provider, idToken: idToken) return user } } diff --git a/Modules/Features/Auth/Sources/UseCase/SignupUseCaseImpl.swift b/Modules/Features/Auth/Sources/UseCase/SignupUseCaseImpl.swift index a6d1f7c5..b18c3c6a 100644 --- a/Modules/Features/Auth/Sources/UseCase/SignupUseCaseImpl.swift +++ b/Modules/Features/Auth/Sources/UseCase/SignupUseCaseImpl.swift @@ -21,14 +21,6 @@ public final class SignupUseCaseImpl: SignupUseCase { gender: request.gender ) - do { - let (user, _) = try await authRepository.login( - loginId: request.loginId, - password: request.password - ) - return user - } catch { - return result.user - } + return result.user } } diff --git a/Modules/Features/Auth/Testing/Sources/AuthTesting.swift b/Modules/Features/Auth/Testing/Sources/AuthTesting.swift index 0e5241f4..7e68bf81 100644 --- a/Modules/Features/Auth/Testing/Sources/AuthTesting.swift +++ b/Modules/Features/Auth/Testing/Sources/AuthTesting.swift @@ -7,16 +7,27 @@ public final class MockLoginUseCase: LoginUseCase, @unchecked Sendable { nickname: "테스터", birthYear: "2000", gender: "M", createdAt: "2025-01-01", industryId: nil, interestIds: []) ) - public private(set) var executedLoginId: String? - public private(set) var executedPassword: String? + public private(set) var executedProvider: SocialProvider? + public private(set) var executedIDToken: String? public private(set) var executeCallCount = 0 public init() {} - public func execute(loginId: String, password: String) async throws -> AuthUser { + public func execute(provider: SocialProvider, idToken: String) async throws -> AuthUser { executeCallCount += 1 - executedLoginId = loginId - executedPassword = password + executedProvider = provider + executedIDToken = idToken return try result.get() } } + +@MainActor +public final class MockKakaoAuthService: KakaoAuthServiceProtocol { + public var result: Result = .success("mock-id-token") + + public init() {} + + public func fetchIDToken() async throws -> String { + try result.get() + } +} diff --git a/Modules/Features/Auth/Tests/AuthTests.swift b/Modules/Features/Auth/Tests/AuthTests.swift index da988fad..67ac44fd 100644 --- a/Modules/Features/Auth/Tests/AuthTests.swift +++ b/Modules/Features/Auth/Tests/AuthTests.swift @@ -278,51 +278,24 @@ private final class StubTokenStorage: TokenStorageProtocol, @unchecked Sendable @MainActor struct LoginViewModelTests { private func makeSUT( - loginUseCase: MockLoginUseCase = .init() - ) -> (vm: LoginViewModel, login: MockLoginUseCase, tokenStorage: StubTokenStorage) { + loginUseCase: MockLoginUseCase = .init(), + kakaoAuthService: MockKakaoAuthService = .init() + ) -> (vm: LoginViewModel, login: MockLoginUseCase, kakao: MockKakaoAuthService, tokenStorage: StubTokenStorage) { let tokenStorage = StubTokenStorage() let vm = LoginViewModel( loginUseCase: loginUseCase, + kakaoAuthService: kakaoAuthService, tokenStorage: tokenStorage, appState: AppState.shared ) - return (vm, loginUseCase, tokenStorage) - } - - // MARK: - isLoginEnabled - - @Test("아이디/비밀번호 모두 입력 시 로그인 활성화") - func isLoginEnabled_bothFilled() { - let (vm, _, _) = makeSUT() - vm.loginId = "testuser" - vm.password = "pass1234" - - #expect(vm.isLoginEnabled == true) - } - - @Test("아이디 비어있으면 로그인 비활성화") - func isLoginEnabled_emptyId() { - let (vm, _, _) = makeSUT() - vm.loginId = "" - vm.password = "pass1234" - - #expect(vm.isLoginEnabled == false) - } - - @Test("비밀번호 비어있으면 로그인 비활성화") - func isLoginEnabled_emptyPassword() { - let (vm, _, _) = makeSUT() - vm.loginId = "testuser" - vm.password = "" - - #expect(vm.isLoginEnabled == false) + return (vm, loginUseCase, kakaoAuthService, tokenStorage) } // MARK: - loginAsGuest @Test("게스트 로그인 시 토큰 클리어 및 로그아웃") func loginAsGuest_clearsTokenAndLogsOut() { - let (vm, _, tokenStorage) = makeSUT() + let (vm, _, _, tokenStorage) = makeSUT() AppState.shared.login() tokenStorage.accessToken = "some-token" @@ -332,71 +305,69 @@ struct LoginViewModelTests { #expect(AppState.shared.authState == .guest) } - // MARK: - login + // MARK: - loginWithKakao - @Test("로그인 성공 시 에러 초기화 및 인증 상태 전환") - func login_success() async { + @Test("카카오 로그인 성공 시 idToken 전달 및 인증 상태 전환") + func kakaoLogin_success() async { let mock = MockLoginUseCase() - let (vm, _, _) = makeSUT(loginUseCase: mock) - vm.loginId = "testuser" - vm.password = "pass1234" + let kakao = MockKakaoAuthService() + kakao.result = .success("kakao-id-token") + let (vm, _, _, _) = makeSUT(loginUseCase: mock, kakaoAuthService: kakao) AppState.shared.logout() var successCalled = false - vm.login { successCalled = true } + vm.loginWithKakao { successCalled = true } // 내부 Task 완료 대기 try? await Task.sleep(for: .milliseconds(50)) - #expect(mock.executedLoginId == "testuser") - #expect(mock.executedPassword == "pass1234") + #expect(mock.executedProvider == .kakao) + #expect(mock.executedIDToken == "kakao-id-token") #expect(successCalled == true) - #expect(vm.errorMessage == nil) - #expect(vm.isLoginIdError == false) - #expect(vm.isPasswordError == false) + #expect(vm.currentError == nil) #expect(AppState.shared.authState == .authenticated) } - @Test("로그인 실패 — invalidPassword") - func login_invalidPassword() async { - let mock = MockLoginUseCase() - mock.result = .failure(LoginError.invalidPassword) - let (vm, _, _) = makeSUT(loginUseCase: mock) - vm.loginId = "testuser" - vm.password = "wrongpass" + @Test("카카오 로그인 취소 시 에러 표시 없이 무시") + func kakaoLogin_cancelled() async { + let kakao = MockKakaoAuthService() + kakao.result = .failure(LoginError.cancelled) + let (vm, _, _, _) = makeSUT(kakaoAuthService: kakao) - vm.login { } + var successCalled = false + vm.loginWithKakao { successCalled = true } try? await Task.sleep(for: .milliseconds(50)) - #expect(vm.isPasswordError == true) - #expect(vm.isLoginIdError == false) - #expect(vm.errorMessage != nil) + #expect(successCalled == false) + #expect(vm.currentError == nil) + #expect(vm.isLoading == false) } - @Test("로그인 실패 — accountNotFound") - func login_accountNotFound() async { + @Test("카카오 로그인 네트워크 실패 시 에러 표시") + func kakaoLogin_networkError() async { let mock = MockLoginUseCase() - mock.result = .failure(LoginError.accountNotFound) - let (vm, _, _) = makeSUT(loginUseCase: mock) - vm.loginId = "nouser" - vm.password = "pass1234" + mock.result = .failure(LoginError.networkError(URLError(.notConnectedToInternet))) + let kakao = MockKakaoAuthService() + kakao.result = .success("kakao-id-token") + let (vm, _, _, _) = makeSUT(loginUseCase: mock, kakaoAuthService: kakao) - vm.login { } + vm.loginWithKakao { } try? await Task.sleep(for: .milliseconds(50)) - #expect(vm.isLoginIdError == true) - #expect(vm.isPasswordError == false) - #expect(vm.errorMessage != nil) + #expect(vm.currentError != nil) + #expect(vm.isLoading == false) } @Test("로딩 중 중복 호출 방지") - func login_duplicatePrevented() { - let (vm, mock, _) = makeSUT() + func kakaoLogin_duplicatePrevented() async { + let (vm, mock, _, _) = makeSUT() vm.isLoading = true - vm.login { } + vm.loginWithKakao { } + + try? await Task.sleep(for: .milliseconds(50)) #expect(mock.executeCallCount == 0) } From 1befca1f374def038c549e01737d5eb1b299e803 Mon Sep 17 00:00:00 2001 From: gomminjae Date: Tue, 30 Jun 2026 12:03:49 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat(auth):=20=EC=86=8C=EC=85=9C=20?= =?UTF-8?q?=EC=8B=A0=EA=B7=9C=20=ED=9A=8C=EC=9B=90=EA=B0=80=EC=9E=85=20?= =?UTF-8?q?=ED=94=8C=EB=A1=9C=EC=9A=B0=20=EC=99=84=EC=84=B1=20(=ED=9A=8C?= =?UTF-8?q?=EC=9B=90=EC=A0=95=EB=B3=B4=EC=9E=85=EB=A0=A5=EB=B6=80=ED=84=B0?= =?UTF-8?q?=20=EC=8B=9C=EC=9E=91)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST /auth/social-login 응답 isRegistered 분기: 신규 회원이면 signupToken으로 회원가입 진입 - POST /auth/social-login/signup 배선(회원정보 + 약관 4종 agreements), 전화인증/아이디/비번 스텝·뷰 제거 - SignupStep을 .enterProfile(회원정보입력)부터 시작, LoginView 신규유저 onNeedSignup 라우팅 - AppRoute.signup에 signupToken/nickname 연관값, 게스트 회원가입 CTA는 .login으로 - AuthUser/AuthUserDTO에서 미사용 loginId/phoneNumber 제거 - 미인증 엔드포인트(로그인/가입) 401이 전역 세션만료를 오발화하지 않도록 APIRequest.emitsUnauthorizedEvent 도입 --- Modules/App/App.xcodeproj/project.pbxproj | 4 + Modules/App/Sources/DI/AppContainer.swift | 4 +- Modules/App/Sources/Root/AppRootView.swift | 4 +- .../Auth/Auth.xcodeproj/project.pbxproj | 106 ++++--- .../Auth/Data/Sources/API/AuthUserAPI.swift | 51 +-- .../API/Encodable/PhoneNumberRequest.swift | 3 - .../API/Encodable/SignupAPIRequest.swift | 8 - .../API/Encodable/SocialSignupRequest.swift | 12 + .../Data/Sources/AuthRepositoryImpl.swift | 75 ++--- .../Data/Sources/DTO/AuthSMSResponseDTO.swift | 9 - .../Data/Sources/DTO/AuthSimpleUserDTO.swift | 18 -- .../DTO/AuthSocialLoginResponseDTO.swift | 36 +++ .../Auth/Data/Sources/DTO/AuthUserDTO.swift | 4 - .../Domain/Sources/Models/AuthAgreement.swift | 18 ++ .../Sources/Models/AuthIDCheckResult.swift | 4 - .../Sources/Models/AuthSMSResponse.swift | 7 - .../Sources/Models/AuthSimpleUser.swift | 35 --- .../Auth/Domain/Sources/Models/AuthUser.swift | 6 - .../Models/SocialLoginResultType.swift | 6 + .../Sources/Repository/AuthRepository.swift | 12 +- .../Domain/Sources/UseCase/LoginUseCase.swift | 2 +- .../Sources/UseCase/SignupUseCase.swift | 17 +- .../Interface/Sources/AuthInterface.swift | 2 +- .../Features/Auth/Sources/AuthBuilder.swift | 4 +- .../Auth/Sources/AuthDIContainer.swift | 4 +- .../Auth/Sources/Login/View/LoginView.swift | 4 + .../Login/View/ViewModel/LoginViewModel.swift | 37 ++- .../Auth/Sources/Onboarding/OnboardView.swift | 2 +- .../Signup/Curation/CurationView.swift | 1 - .../Signup/Popup/SignupPopupView.swift | 119 ------- .../Auth/Sources/Signup/SignupView.swift | 41 +-- .../Auth/Sources/Signup/SignupViewModel.swift | 295 ++---------------- .../Auth/Sources/Signup/Step/AgreeView.swift | 33 +- .../Sources/Signup/Step/IDInputView.swift | 99 ------ .../Signup/Step/PhoneVerificationView.swift | 242 -------------- .../Sources/Signup/Step/PwInputView.swift | 164 ---------- .../Sources/UseCase/LoginUseCaseImpl.swift | 12 +- .../Sources/UseCase/SignupUseCaseImpl.swift | 7 +- .../Auth/Testing/Sources/AuthTesting.swift | 13 +- Modules/Features/Auth/Tests/AuthTests.swift | 8 +- .../Sources/Brand/BrandDetailView.swift | 2 +- .../Explore/Sources/Explore/ExploreView.swift | 2 +- .../Features/Home/Sources/Home/HomeView.swift | 2 +- .../Sources/Recovery/RecoveryView.swift | 2 +- .../Sources/Network/APIRequest.swift | 6 + .../Sources/Network/Core/MoyaTarget.swift | 2 + .../Core/Plugin/AccessTokenPlugin.swift | 6 + Modules/Shared/Sources/Router/AppRoute.swift | 2 +- 48 files changed, 309 insertions(+), 1243 deletions(-) delete mode 100644 Modules/Features/Auth/Data/Sources/API/Encodable/PhoneNumberRequest.swift delete mode 100644 Modules/Features/Auth/Data/Sources/API/Encodable/SignupAPIRequest.swift create mode 100644 Modules/Features/Auth/Data/Sources/API/Encodable/SocialSignupRequest.swift delete mode 100644 Modules/Features/Auth/Data/Sources/DTO/AuthSMSResponseDTO.swift delete mode 100644 Modules/Features/Auth/Data/Sources/DTO/AuthSimpleUserDTO.swift create mode 100644 Modules/Features/Auth/Data/Sources/DTO/AuthSocialLoginResponseDTO.swift create mode 100644 Modules/Features/Auth/Domain/Sources/Models/AuthAgreement.swift delete mode 100644 Modules/Features/Auth/Domain/Sources/Models/AuthIDCheckResult.swift delete mode 100644 Modules/Features/Auth/Domain/Sources/Models/AuthSMSResponse.swift delete mode 100644 Modules/Features/Auth/Domain/Sources/Models/AuthSimpleUser.swift create mode 100644 Modules/Features/Auth/Domain/Sources/Models/SocialLoginResultType.swift delete mode 100644 Modules/Features/Auth/Sources/Signup/Popup/SignupPopupView.swift delete mode 100644 Modules/Features/Auth/Sources/Signup/Step/IDInputView.swift delete mode 100644 Modules/Features/Auth/Sources/Signup/Step/PhoneVerificationView.swift delete mode 100644 Modules/Features/Auth/Sources/Signup/Step/PwInputView.swift diff --git a/Modules/App/App.xcodeproj/project.pbxproj b/Modules/App/App.xcodeproj/project.pbxproj index c986252f..9a2b60c2 100644 --- a/Modules/App/App.xcodeproj/project.pbxproj +++ b/Modules/App/App.xcodeproj/project.pbxproj @@ -202,6 +202,7 @@ 48998AA38F90160E6A754556 /* GoogleUtilities_AppDelegateSwizzler.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = GoogleUtilities_AppDelegateSwizzler.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 49A8F44173AE773538037143 /* GoogleUtilities_Environment.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = GoogleUtilities_Environment.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 4DF851AB63186CE3D1CB0DED /* Promises.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Promises.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 50C4E4206185DE9D06029506 /* App.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = App.entitlements; sourceTree = ""; }; 56CEFEBE90EBB1FAFCA079B3 /* DetailData.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = DetailData.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 571E08A9083FD4334E4B7D06 /* App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = App.app; sourceTree = BUILT_PRODUCTS_DIR; }; 579A16AE603CDAC89FBACF86 /* FirebaseCrashlytics.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = FirebaseCrashlytics.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -431,6 +432,7 @@ 62EFB15EC88C1D2636544B42 /* Resources */, 858C4E433DBF0294DFED1B0C /* Sources */, 4FFC2DDE62C74F3CF60D4731 /* Tuist */, + 50C4E4206185DE9D06029506 /* App.entitlements */, ); name = Project; sourceTree = ""; @@ -763,6 +765,7 @@ isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = App.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; ENABLE_PREVIEWS = YES; FRAMEWORK_SEARCH_PATHS = ( @@ -954,6 +957,7 @@ isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = App.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; ENABLE_PREVIEWS = YES; FRAMEWORK_SEARCH_PATHS = ( diff --git a/Modules/App/Sources/DI/AppContainer.swift b/Modules/App/Sources/DI/AppContainer.swift index 78c41168..e5b1f3e2 100644 --- a/Modules/App/Sources/DI/AppContainer.swift +++ b/Modules/App/Sources/DI/AppContainer.swift @@ -89,8 +89,8 @@ final class AppContainer { authBuilder.makeLoginView() } - func makeSignupView() -> some View { - authBuilder.makeSignupView() + func makeSignupView(signupToken: String, nickname: String?) -> some View { + authBuilder.makeSignupView(signupToken: signupToken, nickname: nickname) } func makeHomeView() -> some View { diff --git a/Modules/App/Sources/Root/AppRootView.swift b/Modules/App/Sources/Root/AppRootView.swift index 3fccf040..6a39016e 100644 --- a/Modules/App/Sources/Root/AppRootView.swift +++ b/Modules/App/Sources/Root/AppRootView.swift @@ -76,8 +76,8 @@ struct AppRootView: View { switch route { case .onboarding: container.makeOnboardingView() - case .signup: - container.makeSignupView() + case let .signup(signupToken, nickname): + container.makeSignupView(signupToken: signupToken, nickname: nickname) case .login: container.makeLoginView() case .home: diff --git a/Modules/Features/Auth/Auth.xcodeproj/project.pbxproj b/Modules/Features/Auth/Auth.xcodeproj/project.pbxproj index b146bf3e..b0eb4801 100644 --- a/Modules/Features/Auth/Auth.xcodeproj/project.pbxproj +++ b/Modules/Features/Auth/Auth.xcodeproj/project.pbxproj @@ -11,17 +11,16 @@ 068F8D60BA7BADAEE244105C /* LoginError.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7179D642D174007438E18BB /* LoginError.swift */; }; 06C7F1D619C3D8813EC698DC /* LoginViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADA7DDBBA02C09D5678982D1 /* LoginViewModel.swift */; }; 0AAC727B5804E8861378DBA9 /* AuthErrorResponseDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8002CFA53E586CEF850C3B0A /* AuthErrorResponseDTO.swift */; }; - 0C8A59F69E82B238BBACE038 /* PwInputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C904E1C986F43E7E3173A68 /* PwInputView.swift */; }; 0DD4957EB4B1CA41581F98F0 /* AuthLoginResponseDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 252C2F86266EE72217090DE5 /* AuthLoginResponseDTO.swift */; }; 0F56C20E1DA7712315D9E806 /* AuthUser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D19C06CB9237BDD75BDE6D0 /* AuthUser.swift */; }; 11398BE7E7D82516E37D424D /* SignupFormatStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F2A7B152CF8BFC2A33B5864 /* SignupFormatStyle.swift */; }; 122C53001252A72B60454C84 /* Kingfisher.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2FDB1F539F81A1BE90F87D4C /* Kingfisher.framework */; }; 132D2FA4E5F037EC5B17C668 /* AuthInterface.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E5E69594651E831F6B39550 /* AuthInterface.swift */; }; + 137BF12EC0FDBB780C48D99B /* KakaoSDKUser.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CF43922EE1D6125FE8B383ED /* KakaoSDKUser.framework */; }; 1463124D79BBAFE03BC7BF17 /* NetworkKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 62778DFF53ACB46ECC6F5F35 /* NetworkKit.framework */; }; 15945F0A7DFC670E3A6F579E /* AuthDomain.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7C94165A812E44E5FD3BFC7F /* AuthDomain.framework */; }; 1637E15B361BE0077BB2E4A4 /* AuthSignupResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AF374FFD2C1DCB9C67C232 /* AuthSignupResponse.swift */; }; 16D5D214569544F40FBBDB04 /* AuthTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA9479B1142A48D6DA9E15EB /* AuthTests.swift */; }; - 1BABD6D99B96B85A977A088C /* AuthSMSResponseDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7943E1660F0F8C03171DA01B /* AuthSMSResponseDTO.swift */; }; 1F96AE8D6C41A9E6FC6BA978 /* AuthInterface.framework in Dependencies */ = {isa = PBXBuildFile; fileRef = BE8C09C4CE36E3D06B61DAB5 /* AuthInterface.framework */; }; 1FE2762E1914BB12D2471757 /* AuthData.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FC8AEB8E0082B038CB7521FA /* AuthData.framework */; }; 20967759C2FB3DD4A69AE22C /* AuthMain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 083CBFE74C9E7F71EDD0C703 /* AuthMain.swift */; }; @@ -31,7 +30,6 @@ 2A7224C686420CFF1A66C683 /* Moya.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 83D56B45D644E0B2DBA11EEC /* Moya.framework */; }; 2B882611CD5D55FF9AA4BA30 /* CurationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF5156C9688647B73261D5AA /* CurationView.swift */; }; 30FBE074985429C4E16AC952 /* AuthInterface.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BE8C09C4CE36E3D06B61DAB5 /* AuthInterface.framework */; }; - 317F10B7A78A02208AD5F199 /* AuthSimpleUser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A168642EA52AD1A834F65CA /* AuthSimpleUser.swift */; }; 32D5A7DC8F48CD0FCC017BC4 /* FoundationKit.framework in Dependencies */ = {isa = PBXBuildFile; fileRef = 29FA6C20A6564CB1A65D83B5 /* FoundationKit.framework */; }; 3439C05F4186A8EAEAB3AAC7 /* AuthInterface.framework in Dependencies */ = {isa = PBXBuildFile; fileRef = BE8C09C4CE36E3D06B61DAB5 /* AuthInterface.framework */; }; 38885CC0C2414879F652B0A7 /* Shared.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2D55F8F97BF588B8074069DA /* Shared.framework */; }; @@ -41,19 +39,23 @@ 4AC581D077DF9A536B6119DD /* PopupView.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A03B7D8DBF8FA6DF9A7FAB5E /* PopupView.framework */; }; 54098FD0A269D65E21141F44 /* AuthRecommendedBrand.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0AEEA4760F10DA275AAD667C /* AuthRecommendedBrand.swift */; }; 57943534CBB1B0733A0580B1 /* OnboardingPageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E92D4A67D04128BB7B34CC41 /* OnboardingPageView.swift */; }; + 581473F58B2101FA8819F6FD /* KakaoAuthServiceProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBE015DFA89E6676D907A335 /* KakaoAuthServiceProtocol.swift */; }; 584FF6C187A388DDE2785C63 /* LoginUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95E8500002BC4B6ABD877A90 /* LoginUseCase.swift */; }; 585839CA64514AE531B3B0E1 /* AuthUserDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32B432D7D19D2651FD4EA9D0 /* AuthUserDTO.swift */; }; - 63C7E725C2305C1E327EBA3A /* AuthIDCheckResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ED3793EBF1965006A6246AA /* AuthIDCheckResult.swift */; }; 63F1911F5DA70E6FF6B0936C /* AuthDomain.framework in Dependencies */ = {isa = PBXBuildFile; fileRef = 7C94165A812E44E5FD3BFC7F /* AuthDomain.framework */; }; 67B7704E819E86BACD4CF144 /* LoginRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 114EA565B42439D2BD31D001 /* LoginRequest.swift */; }; 684614D8E6FDC6F7CF4C413F /* SignupViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F194D0253B7B41E31A2318E0 /* SignupViewModel.swift */; }; - 6A6F3F4667ED7D186BCA77C2 /* IDInputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1C581C211BB1E550A1B7F9F /* IDInputView.swift */; }; 6BAE6A217722795DA8B18F6E /* DesignSystem.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 45A8BB59B621EFC832DCFE0F /* DesignSystem.framework */; }; 6DBDE15A3229AEC6AD04315F /* LoginUseCaseImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6D3D3E4DD934FD1AB010EE7 /* LoginUseCaseImpl.swift */; }; 70FD0520699A99F87E907143 /* IndustrySurveyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 529EEA7B499B7A83B7C62D3A /* IndustrySurveyView.swift */; }; + 713CD4B5400822ACBE3098A1 /* KakaoAuthService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5D807F75F499DBD2AF39E92 /* KakaoAuthService.swift */; }; 71A2A19E91125B3B05216EEF /* SignupUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB8FAA33147E7CBDF3C9BC71 /* SignupUseCase.swift */; }; - 88D7670D4BE1270B9CEC6115 /* SignupAPIRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83317B7D942AFBFF7BF79908 /* SignupAPIRequest.swift */; }; + 78E4129880FADAC971EDCBA3 /* AuthAgreement.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9157EC605745CAD73475CA84 /* AuthAgreement.swift */; }; + 7AF6C54D10C7AC3CAB25F44F /* KakaoSDKCommon.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1327587164DBBAA16F3C33DE /* KakaoSDKCommon.framework */; }; 8E1720B8F639A95ABC17313B /* SignupCurationSkeletonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27AC2DEAF917CC5E6CAE23A3 /* SignupCurationSkeletonView.swift */; }; + 8E1DE8EEA3B43CEEB1BC3350 /* SocialLoginResultType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 791F16AF341F57533D166B00 /* SocialLoginResultType.swift */; }; + 95708794CC0EFBB17C81A967 /* KakaoSDKUser.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CF43922EE1D6125FE8B383ED /* KakaoSDKUser.framework */; }; + 9ADF09FF06C78C632CCB3E3F /* KakaoSDKCommon.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1327587164DBBAA16F3C33DE /* KakaoSDKCommon.framework */; }; 9D083976CECA423E68F0F099 /* AuthRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DFBFB5E537C38ADF3FC6F51 /* AuthRepository.swift */; }; 9D88DB33AC00BF061BEFF003 /* AuthRepositoryImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED7D603A82E085B8AD32B04F /* AuthRepositoryImpl.swift */; }; 9ECE5640A650A001E9195A0F /* AuthUserAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5BA7E5065E6BFA71F04B38E /* AuthUserAPI.swift */; }; @@ -62,27 +64,27 @@ A47F3F9CC80909D5A826ED2E /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6061C08937A7986D4F7D042 /* LoginView.swift */; }; A5D92D5C6F4866C1716747F6 /* FoundationKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 29FA6C20A6564CB1A65D83B5 /* FoundationKit.framework */; }; A873D6E66ACC7A15AE540405 /* BirthYearDropdown.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59EBC0CC5D916C5A4CB83108 /* BirthYearDropdown.swift */; }; + AD16ADE5EA0A857DC8FCA049 /* AuthSocialLoginResponseDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DA3D0897326D379FB89FB89 /* AuthSocialLoginResponseDTO.swift */; }; AD2E02356CD05728AF7F6BED /* FoundationKit.framework in Dependencies */ = {isa = PBXBuildFile; fileRef = 29FA6C20A6564CB1A65D83B5 /* FoundationKit.framework */; }; ADD5AC589E22E51E680E9367 /* DesignSystem.framework in Dependencies */ = {isa = PBXBuildFile; fileRef = 45A8BB59B621EFC832DCFE0F /* DesignSystem.framework */; }; - AE4E796AB6754D3972831029 /* AuthSimpleUserDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79D73FBD79BD1D75BD7F6D35 /* AuthSimpleUserDTO.swift */; }; B1B8CA1ED66CA91DBA0C6606 /* AuthDomain.framework in Dependencies */ = {isa = PBXBuildFile; fileRef = 7C94165A812E44E5FD3BFC7F /* AuthDomain.framework */; }; B3BCEF7C74425A532B13BCA1 /* AuthSignupResponseDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = E65656E0C636D6AF23FD06E9 /* AuthSignupResponseDTO.swift */; }; - B99AFB0451E067C8FE60A309 /* PhoneNumberRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08278EF2ED11A60DB6D330B6 /* PhoneNumberRequest.swift */; }; - BB5132E0726E9AD5CDE5A167 /* AuthSMSResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D9606FB084D1C548133A03A /* AuthSMSResponse.swift */; }; + BE76C14172FBC93CFF00C6EB /* SocialSignupRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C3738B5D3CB94C1EAE7091C /* SocialSignupRequest.swift */; }; CEECBC8150603B9D9911E035 /* Shared.framework in Dependencies */ = {isa = PBXBuildFile; fileRef = 2D55F8F97BF588B8074069DA /* Shared.framework */; }; CFC5F5E41FBB37824529D154 /* CompleteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BDA675B82B10EC8A13AFB6B /* CompleteView.swift */; }; D417671EFC2BB25A1ED34B9A /* AuthBrandListResponseDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89277BD77D124EAB69AE0989 /* AuthBrandListResponseDTO.swift */; }; DAB7DF3408A8935171BD2FAB /* SignupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B61AAE20E84CFDA0CD5581B7 /* SignupView.swift */; }; DB7091B9F2506C318356C761 /* AgreeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1BDDFC6BDACA5DBB3EA0C30 /* AgreeView.swift */; }; - DEC116B8172E6EC0EE96B13D /* PhoneVerificationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18C9108C9EC4721DDB960586 /* PhoneVerificationView.swift */; }; + E1A5DE4105173FE48B0A8A12 /* KakaoSDKAuth.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 46E7D8C72118ED61834F52D6 /* KakaoSDKAuth.framework */; }; + E2D87D4F27F5006602D75147 /* KakaoSDKAuth.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 46E7D8C72118ED61834F52D6 /* KakaoSDKAuth.framework */; }; E446254DECC0CB9FABD7CF86 /* NetworkKit.framework in Dependencies */ = {isa = PBXBuildFile; fileRef = 62778DFF53ACB46ECC6F5F35 /* NetworkKit.framework */; }; E57A016EEFDE2A1DEF13C80E /* CurationRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07848F750646D4321324EFC6 /* CurationRow.swift */; }; E5BC4A325DD1F4C9FFC406C6 /* ProfileInputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEFBC36DB36A7FB5F4EF2146 /* ProfileInputView.swift */; }; E70B49DE0EB688032C6698F5 /* RecommendView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DF1746A90A2DDFCD5AE9CB3 /* RecommendView.swift */; }; + EBFC25633602E81D366CEF7F /* SocialProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC378D97E8C797A1B2CB788B /* SocialProvider.swift */; }; EEFF897EFB63EEF67243F2BF /* AuthTesting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A1EE55F16E7D24969D6632E /* AuthTesting.swift */; }; F42073A1A98F2AF60F8938C8 /* Auth.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8895FC8BB8C3644A2923E77A /* Auth.framework */; }; F7B5E87E9981AE8BBA072E77 /* AuthDomain.framework in Dependencies */ = {isa = PBXBuildFile; fileRef = 7C94165A812E44E5FD3BFC7F /* AuthDomain.framework */; }; - FC24FEA6A044D05C84A21BF3 /* SignupPopupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 809C13866FB7C74A59818EDF /* SignupPopupView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -258,15 +260,13 @@ /* Begin PBXFileReference section */ 05A17C657D4A51BEE81CB99C /* OnboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardView.swift; sourceTree = ""; }; 07848F750646D4321324EFC6 /* CurationRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurationRow.swift; sourceTree = ""; }; - 08278EF2ED11A60DB6D330B6 /* PhoneNumberRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhoneNumberRequest.swift; sourceTree = ""; }; 083CBFE74C9E7F71EDD0C703 /* AuthMain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthMain.swift; sourceTree = ""; }; 0AEEA4760F10DA275AAD667C /* AuthRecommendedBrand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthRecommendedBrand.swift; sourceTree = ""; }; - 0C904E1C986F43E7E3173A68 /* PwInputView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PwInputView.swift; sourceTree = ""; }; 0CC431E5DC117F84221E3049 /* PolicyWebSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PolicyWebSheetView.swift; sourceTree = ""; }; 10C1F41914414CE45C317D84 /* AuthData-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = "AuthData-Info.plist"; sourceTree = ""; }; 114EA565B42439D2BD31D001 /* LoginRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginRequest.swift; sourceTree = ""; }; + 1327587164DBBAA16F3C33DE /* KakaoSDKCommon.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = KakaoSDKCommon.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 189DB6C61185D4742A4F68D5 /* SignupUseCaseImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignupUseCaseImpl.swift; sourceTree = ""; }; - 18C9108C9EC4721DDB960586 /* PhoneVerificationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhoneVerificationView.swift; sourceTree = ""; }; 1A1EE55F16E7D24969D6632E /* AuthTesting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthTesting.swift; sourceTree = ""; }; 252C2F86266EE72217090DE5 /* AuthLoginResponseDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthLoginResponseDTO.swift; sourceTree = ""; }; 27AC2DEAF917CC5E6CAE23A3 /* SignupCurationSkeletonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignupCurationSkeletonView.swift; sourceTree = ""; }; @@ -278,47 +278,48 @@ 3D19C06CB9237BDD75BDE6D0 /* AuthUser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthUser.swift; sourceTree = ""; }; 3DF1746A90A2DDFCD5AE9CB3 /* RecommendView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecommendView.swift; sourceTree = ""; }; 45A8BB59B621EFC832DCFE0F /* DesignSystem.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = DesignSystem.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 46E7D8C72118ED61834F52D6 /* KakaoSDKAuth.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = KakaoSDKAuth.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 4DA3D0897326D379FB89FB89 /* AuthSocialLoginResponseDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthSocialLoginResponseDTO.swift; sourceTree = ""; }; 4F6F33418EB25913880BECBA /* AuthInterface-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = "AuthInterface-Info.plist"; sourceTree = ""; }; 529EEA7B499B7A83B7C62D3A /* IndustrySurveyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IndustrySurveyView.swift; sourceTree = ""; }; 5642BA06A94EBFDDB6B192CB /* AuthTesting.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = AuthTesting.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 59EBC0CC5D916C5A4CB83108 /* BirthYearDropdown.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BirthYearDropdown.swift; sourceTree = ""; }; 5E5E69594651E831F6B39550 /* AuthInterface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthInterface.swift; sourceTree = ""; }; - 5ED3793EBF1965006A6246AA /* AuthIDCheckResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthIDCheckResult.swift; sourceTree = ""; }; 62778DFF53ACB46ECC6F5F35 /* NetworkKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = NetworkKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 6B7EBA8D7806B48F8D71D29A /* AuthDIContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthDIContainer.swift; sourceTree = ""; }; + 6C3738B5D3CB94C1EAE7091C /* SocialSignupRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocialSignupRequest.swift; sourceTree = ""; }; 6DFBFB5E537C38ADF3FC6F51 /* AuthRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthRepository.swift; sourceTree = ""; }; - 7943E1660F0F8C03171DA01B /* AuthSMSResponseDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthSMSResponseDTO.swift; sourceTree = ""; }; - 79D73FBD79BD1D75BD7F6D35 /* AuthSimpleUserDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthSimpleUserDTO.swift; sourceTree = ""; }; + 791F16AF341F57533D166B00 /* SocialLoginResultType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocialLoginResultType.swift; sourceTree = ""; }; 7BDA675B82B10EC8A13AFB6B /* CompleteView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompleteView.swift; sourceTree = ""; }; 7C94165A812E44E5FD3BFC7F /* AuthDomain.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = AuthDomain.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 8002CFA53E586CEF850C3B0A /* AuthErrorResponseDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthErrorResponseDTO.swift; sourceTree = ""; }; - 809C13866FB7C74A59818EDF /* SignupPopupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignupPopupView.swift; sourceTree = ""; }; - 83317B7D942AFBFF7BF79908 /* SignupAPIRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignupAPIRequest.swift; sourceTree = ""; }; 83D56B45D644E0B2DBA11EEC /* Moya.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Moya.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 8895FC8BB8C3644A2923E77A /* Auth.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Auth.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 89277BD77D124EAB69AE0989 /* AuthBrandListResponseDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthBrandListResponseDTO.swift; sourceTree = ""; }; + 9157EC605745CAD73475CA84 /* AuthAgreement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthAgreement.swift; sourceTree = ""; }; 95E8500002BC4B6ABD877A90 /* LoginUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginUseCase.swift; sourceTree = ""; }; - 9A168642EA52AD1A834F65CA /* AuthSimpleUser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthSimpleUser.swift; sourceTree = ""; }; - 9D9606FB084D1C548133A03A /* AuthSMSResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthSMSResponse.swift; sourceTree = ""; }; 9F2A7B152CF8BFC2A33B5864 /* SignupFormatStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignupFormatStyle.swift; sourceTree = ""; }; A03B7D8DBF8FA6DF9A7FAB5E /* PopupView.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = PopupView.framework; sourceTree = BUILT_PRODUCTS_DIR; }; A6061C08937A7986D4F7D042 /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = ""; }; ADA7DDBBA02C09D5678982D1 /* LoginViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginViewModel.swift; sourceTree = ""; }; - B1C581C211BB1E550A1B7F9F /* IDInputView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IDInputView.swift; sourceTree = ""; }; + B5D807F75F499DBD2AF39E92 /* KakaoAuthService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KakaoAuthService.swift; sourceTree = ""; }; B61AAE20E84CFDA0CD5581B7 /* SignupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignupView.swift; sourceTree = ""; }; BE8C09C4CE36E3D06B61DAB5 /* AuthInterface.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = AuthInterface.framework; sourceTree = BUILT_PRODUCTS_DIR; }; BEFBC36DB36A7FB5F4EF2146 /* ProfileInputView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileInputView.swift; sourceTree = ""; }; C857B2C600CA35873D2A9D32 /* Auth-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = "Auth-Info.plist"; sourceTree = ""; }; + CF43922EE1D6125FE8B383ED /* KakaoSDKUser.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = KakaoSDKUser.framework; sourceTree = BUILT_PRODUCTS_DIR; }; CF8FCFE4AC3B608B5042F210 /* AuthTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = AuthTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; D7AF374FFD2C1DCB9C67C232 /* AuthSignupResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthSignupResponse.swift; sourceTree = ""; }; DA9479B1142A48D6DA9E15EB /* AuthTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthTests.swift; sourceTree = ""; }; DB8FAA33147E7CBDF3C9BC71 /* SignupUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignupUseCase.swift; sourceTree = ""; }; + DBE015DFA89E6676D907A335 /* KakaoAuthServiceProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KakaoAuthServiceProtocol.swift; sourceTree = ""; }; E1BDDFC6BDACA5DBB3EA0C30 /* AgreeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgreeView.swift; sourceTree = ""; }; E5BA7E5065E6BFA71F04B38E /* AuthUserAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthUserAPI.swift; sourceTree = ""; }; E65656E0C636D6AF23FD06E9 /* AuthSignupResponseDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthSignupResponseDTO.swift; sourceTree = ""; }; E6D3D3E4DD934FD1AB010EE7 /* LoginUseCaseImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginUseCaseImpl.swift; sourceTree = ""; }; E7179D642D174007438E18BB /* LoginError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginError.swift; sourceTree = ""; }; E92D4A67D04128BB7B34CC41 /* OnboardingPageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingPageView.swift; sourceTree = ""; }; + EC378D97E8C797A1B2CB788B /* SocialProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocialProvider.swift; sourceTree = ""; }; ED7D603A82E085B8AD32B04F /* AuthRepositoryImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthRepositoryImpl.swift; sourceTree = ""; }; EDE7484E60F831CBAFFBD577 /* AuthBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthBuilder.swift; sourceTree = ""; }; EF08C43BB60D5CD101D7A686 /* AuthTests-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = "AuthTests-Info.plist"; sourceTree = ""; }; @@ -362,6 +363,9 @@ 9FD0E0B05397F67F37A1530A /* AuthTesting.framework in Frameworks */, 6BAE6A217722795DA8B18F6E /* DesignSystem.framework in Frameworks */, A5D92D5C6F4866C1716747F6 /* FoundationKit.framework in Frameworks */, + E2D87D4F27F5006602D75147 /* KakaoSDKAuth.framework in Frameworks */, + 9ADF09FF06C78C632CCB3E3F /* KakaoSDKCommon.framework in Frameworks */, + 95708794CC0EFBB17C81A967 /* KakaoSDKUser.framework in Frameworks */, 122C53001252A72B60454C84 /* Kingfisher.framework in Frameworks */, 2A7224C686420CFF1A66C683 /* Moya.framework in Frameworks */, 1463124D79BBAFE03BC7BF17 /* NetworkKit.framework in Frameworks */, @@ -381,6 +385,9 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + E1A5DE4105173FE48B0A8A12 /* KakaoSDKAuth.framework in Frameworks */, + 7AF6C54D10C7AC3CAB25F44F /* KakaoSDKCommon.framework in Frameworks */, + 137BF12EC0FDBB780C48D99B /* KakaoSDKUser.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -399,13 +406,13 @@ 1F7FCF002E9EA49C9154B97A /* Models */ = { isa = PBXGroup; children = ( - 5ED3793EBF1965006A6246AA /* AuthIDCheckResult.swift */, + 9157EC605745CAD73475CA84 /* AuthAgreement.swift */, 0AEEA4760F10DA275AAD667C /* AuthRecommendedBrand.swift */, D7AF374FFD2C1DCB9C67C232 /* AuthSignupResponse.swift */, - 9A168642EA52AD1A834F65CA /* AuthSimpleUser.swift */, - 9D9606FB084D1C548133A03A /* AuthSMSResponse.swift */, 3D19C06CB9237BDD75BDE6D0 /* AuthUser.swift */, E7179D642D174007438E18BB /* LoginError.swift */, + 791F16AF341F57533D166B00 /* SocialLoginResultType.swift */, + EC378D97E8C797A1B2CB788B /* SocialProvider.swift */, ); path = Models; sourceTree = ""; @@ -448,6 +455,7 @@ CA67E9589C79D55919B4AC98 /* Format */, 1F7FCF002E9EA49C9154B97A /* Models */, 2E50C36B10E667A8B5B249DB /* Repository */, + 67B1B968B12862927CA5D06F /* Service */, E7ADF26634B3AEC468A96EFB /* UseCase */, ); path = Sources; @@ -464,6 +472,9 @@ CF8FCFE4AC3B608B5042F210 /* AuthTests.xctest */, 45A8BB59B621EFC832DCFE0F /* DesignSystem.framework */, 29FA6C20A6564CB1A65D83B5 /* FoundationKit.framework */, + 46E7D8C72118ED61834F52D6 /* KakaoSDKAuth.framework */, + 1327587164DBBAA16F3C33DE /* KakaoSDKCommon.framework */, + CF43922EE1D6125FE8B383ED /* KakaoSDKUser.framework */, 2FDB1F539F81A1BE90F87D4C /* Kingfisher.framework */, 83D56B45D644E0B2DBA11EEC /* Moya.framework */, 62778DFF53ACB46ECC6F5F35 /* NetworkKit.framework */, @@ -527,8 +538,7 @@ 8002CFA53E586CEF850C3B0A /* AuthErrorResponseDTO.swift */, 252C2F86266EE72217090DE5 /* AuthLoginResponseDTO.swift */, E65656E0C636D6AF23FD06E9 /* AuthSignupResponseDTO.swift */, - 79D73FBD79BD1D75BD7F6D35 /* AuthSimpleUserDTO.swift */, - 7943E1660F0F8C03171DA01B /* AuthSMSResponseDTO.swift */, + 4DA3D0897326D379FB89FB89 /* AuthSocialLoginResponseDTO.swift */, 32B432D7D19D2651FD4EA9D0 /* AuthUserDTO.swift */, ); path = DTO; @@ -542,6 +552,14 @@ path = ViewModel; sourceTree = ""; }; + 67B1B968B12862927CA5D06F /* Service */ = { + isa = PBXGroup; + children = ( + DBE015DFA89E6676D907A335 /* KakaoAuthServiceProtocol.swift */, + ); + path = Service; + sourceTree = ""; + }; 6C19D8EF6FBB8C39757E46B1 /* Sources */ = { isa = PBXGroup; children = ( @@ -555,7 +573,6 @@ children = ( 59EBC0CC5D916C5A4CB83108 /* BirthYearDropdown.swift */, 0CC431E5DC117F84221E3049 /* PolicyWebSheetView.swift */, - 809C13866FB7C74A59818EDF /* SignupPopupView.swift */, ); path = Popup; sourceTree = ""; @@ -565,6 +582,7 @@ children = ( D213E4CBC9366CE3FD45FDED /* API */, 61EAB215B4B277EBBEBB30EA /* DTO */, + F0975FF2DD59FA556C7AF3E6 /* Social */, ED7D603A82E085B8AD32B04F /* AuthRepositoryImpl.swift */, ); path = Sources; @@ -589,10 +607,7 @@ children = ( E1BDDFC6BDACA5DBB3EA0C30 /* AgreeView.swift */, 7BDA675B82B10EC8A13AFB6B /* CompleteView.swift */, - B1C581C211BB1E550A1B7F9F /* IDInputView.swift */, - 18C9108C9EC4721DDB960586 /* PhoneVerificationView.swift */, BEFBC36DB36A7FB5F4EF2146 /* ProfileInputView.swift */, - 0C904E1C986F43E7E3173A68 /* PwInputView.swift */, 3DF1746A90A2DDFCD5AE9CB3 /* RecommendView.swift */, ); path = Step; @@ -614,8 +629,7 @@ isa = PBXGroup; children = ( 114EA565B42439D2BD31D001 /* LoginRequest.swift */, - 08278EF2ED11A60DB6D330B6 /* PhoneNumberRequest.swift */, - 83317B7D942AFBFF7BF79908 /* SignupAPIRequest.swift */, + 6C3738B5D3CB94C1EAE7091C /* SocialSignupRequest.swift */, ); path = Encodable; sourceTree = ""; @@ -696,6 +710,14 @@ path = InfoPlists; sourceTree = ""; }; + F0975FF2DD59FA556C7AF3E6 /* Social */ = { + isa = PBXGroup; + children = ( + B5D807F75F499DBD2AF39E92 /* KakaoAuthService.swift */, + ); + path = Social; + sourceTree = ""; + }; F801810B5F8B8D58E891B0B5 /* Data */ = { isa = PBXGroup; children = ( @@ -924,14 +946,15 @@ buildActionMask = 2147483647; files = ( 11398BE7E7D82516E37D424D /* SignupFormatStyle.swift in Sources */, - 63C7E725C2305C1E327EBA3A /* AuthIDCheckResult.swift in Sources */, + 78E4129880FADAC971EDCBA3 /* AuthAgreement.swift in Sources */, 54098FD0A269D65E21141F44 /* AuthRecommendedBrand.swift in Sources */, - BB5132E0726E9AD5CDE5A167 /* AuthSMSResponse.swift in Sources */, 1637E15B361BE0077BB2E4A4 /* AuthSignupResponse.swift in Sources */, - 317F10B7A78A02208AD5F199 /* AuthSimpleUser.swift in Sources */, 0F56C20E1DA7712315D9E806 /* AuthUser.swift in Sources */, 068F8D60BA7BADAEE244105C /* LoginError.swift in Sources */, + 8E1DE8EEA3B43CEEB1BC3350 /* SocialLoginResultType.swift in Sources */, + EBFC25633602E81D366CEF7F /* SocialProvider.swift in Sources */, 9D083976CECA423E68F0F099 /* AuthRepository.swift in Sources */, + 581473F58B2101FA8819F6FD /* KakaoAuthServiceProtocol.swift in Sources */, 584FF6C187A388DDE2785C63 /* LoginUseCase.swift in Sources */, 71A2A19E91125B3B05216EEF /* SignupUseCase.swift in Sources */, ); @@ -955,15 +978,11 @@ 8E1720B8F639A95ABC17313B /* SignupCurationSkeletonView.swift in Sources */, A873D6E66ACC7A15AE540405 /* BirthYearDropdown.swift in Sources */, 452C194C93875A657D1EF472 /* PolicyWebSheetView.swift in Sources */, - FC24FEA6A044D05C84A21BF3 /* SignupPopupView.swift in Sources */, DAB7DF3408A8935171BD2FAB /* SignupView.swift in Sources */, 684614D8E6FDC6F7CF4C413F /* SignupViewModel.swift in Sources */, DB7091B9F2506C318356C761 /* AgreeView.swift in Sources */, CFC5F5E41FBB37824529D154 /* CompleteView.swift in Sources */, - 6A6F3F4667ED7D186BCA77C2 /* IDInputView.swift in Sources */, - DEC116B8172E6EC0EE96B13D /* PhoneVerificationView.swift in Sources */, E5BC4A325DD1F4C9FFC406C6 /* ProfileInputView.swift in Sources */, - 0C8A59F69E82B238BBACE038 /* PwInputView.swift in Sources */, E70B49DE0EB688032C6698F5 /* RecommendView.swift in Sources */, 6DBDE15A3229AEC6AD04315F /* LoginUseCaseImpl.swift in Sources */, 3DE41050BD58F53CEBF1CD89 /* SignupUseCaseImpl.swift in Sources */, @@ -1000,16 +1019,15 @@ files = ( 9ECE5640A650A001E9195A0F /* AuthUserAPI.swift in Sources */, 67B7704E819E86BACD4CF144 /* LoginRequest.swift in Sources */, - B99AFB0451E067C8FE60A309 /* PhoneNumberRequest.swift in Sources */, - 88D7670D4BE1270B9CEC6115 /* SignupAPIRequest.swift in Sources */, + BE76C14172FBC93CFF00C6EB /* SocialSignupRequest.swift in Sources */, 9D88DB33AC00BF061BEFF003 /* AuthRepositoryImpl.swift in Sources */, D417671EFC2BB25A1ED34B9A /* AuthBrandListResponseDTO.swift in Sources */, 0AAC727B5804E8861378DBA9 /* AuthErrorResponseDTO.swift in Sources */, 0DD4957EB4B1CA41581F98F0 /* AuthLoginResponseDTO.swift in Sources */, - 1BABD6D99B96B85A977A088C /* AuthSMSResponseDTO.swift in Sources */, B3BCEF7C74425A532B13BCA1 /* AuthSignupResponseDTO.swift in Sources */, - AE4E796AB6754D3972831029 /* AuthSimpleUserDTO.swift in Sources */, + AD16ADE5EA0A857DC8FCA049 /* AuthSocialLoginResponseDTO.swift in Sources */, 585839CA64514AE531B3B0E1 /* AuthUserDTO.swift in Sources */, + 713CD4B5400822ACBE3098A1 /* KakaoAuthService.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Modules/Features/Auth/Data/Sources/API/AuthUserAPI.swift b/Modules/Features/Auth/Data/Sources/API/AuthUserAPI.swift index 198d03fd..9fb0d975 100644 --- a/Modules/Features/Auth/Data/Sources/API/AuthUserAPI.swift +++ b/Modules/Features/Auth/Data/Sources/API/AuthUserAPI.swift @@ -7,67 +7,40 @@ private enum AuthBaseURL { } struct Login: APIRequest { - typealias Response = AuthLoginResponseDTO + typealias Response = AuthSocialLoginResponseDTO let provider: String let idToken: String var baseURL: URL { AuthBaseURL.auth } var path: String { "/social-login" } var method: HTTPMethod { .post } + var emitsUnauthorizedEvent: Bool { false } var task: RequestTask { .jsonBody(LoginRequest(provider: provider, platform: "IOS", idToken: idToken)) } } -struct Signup: APIRequest { +struct SocialSignup: APIRequest { typealias Response = AuthSignupResponseDTO - let loginId: String - let password: String - let phoneNumber: String + let signupToken: String let nickname: String let birthYear: String let gender: String - var baseURL: URL { AuthBaseURL.users } - var path: String { "/signup" } + let agreements: [SocialSignupAgreementRequest] + var baseURL: URL { AuthBaseURL.auth } + var path: String { "/social-login/signup" } var method: HTTPMethod { .post } + var emitsUnauthorizedEvent: Bool { false } var task: RequestTask { - .jsonBody(SignupAPIRequest( - loginId: loginId, - password: password, - phoneNumber: phoneNumber, + .jsonBody(SocialSignupRequest( + signupToken: signupToken, nickname: nickname, birthYear: birthYear, - gender: gender + gender: gender, + agreements: agreements )) } } -struct CheckPhoneNumber: APIRequest { - typealias Response = [AuthSimpleUserDTO] - let phoneNumber: String - var baseURL: URL { AuthBaseURL.users } - var path: String { "/check/phoneNumber" } - var method: HTTPMethod { .get } - var task: RequestTask { .query(["phoneNumber": phoneNumber]) } -} - -struct CheckIDDup: APIRequest { - typealias Response = AuthSimpleUserDTO - let loginId: String - var baseURL: URL { AuthBaseURL.users } - var path: String { "/check/loginId" } - var method: HTTPMethod { .get } - var task: RequestTask { .query(["loginId": loginId]) } -} - -struct AuthSMS: APIRequest { - typealias Response = AuthSMSResponseDTO - let phoneNumber: String - var baseURL: URL { AuthBaseURL.auth } - var path: String { "/SMS" } - var method: HTTPMethod { .post } - var task: RequestTask { .jsonBody(PhoneNumberRequest(phoneNumber: phoneNumber)) } -} - struct PreInvestigate: APIRequest { typealias Response = AuthRecommendedBrandListResponseDTO let industryId: String diff --git a/Modules/Features/Auth/Data/Sources/API/Encodable/PhoneNumberRequest.swift b/Modules/Features/Auth/Data/Sources/API/Encodable/PhoneNumberRequest.swift deleted file mode 100644 index 432ec8e9..00000000 --- a/Modules/Features/Auth/Data/Sources/API/Encodable/PhoneNumberRequest.swift +++ /dev/null @@ -1,3 +0,0 @@ -struct PhoneNumberRequest: Encodable, Sendable { - let phoneNumber: String -} diff --git a/Modules/Features/Auth/Data/Sources/API/Encodable/SignupAPIRequest.swift b/Modules/Features/Auth/Data/Sources/API/Encodable/SignupAPIRequest.swift deleted file mode 100644 index 65922d1b..00000000 --- a/Modules/Features/Auth/Data/Sources/API/Encodable/SignupAPIRequest.swift +++ /dev/null @@ -1,8 +0,0 @@ -struct SignupAPIRequest: Encodable, Sendable { - let loginId: String - let password: String - let phoneNumber: String - let nickname: String - let birthYear: String - let gender: String -} diff --git a/Modules/Features/Auth/Data/Sources/API/Encodable/SocialSignupRequest.swift b/Modules/Features/Auth/Data/Sources/API/Encodable/SocialSignupRequest.swift new file mode 100644 index 00000000..d8c8dec5 --- /dev/null +++ b/Modules/Features/Auth/Data/Sources/API/Encodable/SocialSignupRequest.swift @@ -0,0 +1,12 @@ +struct SocialSignupAgreementRequest: Encodable, Sendable { + let type: String + let agreed: Bool +} + +struct SocialSignupRequest: Encodable, Sendable { + let signupToken: String + let nickname: String + let birthYear: String + let gender: String + let agreements: [SocialSignupAgreementRequest] +} diff --git a/Modules/Features/Auth/Data/Sources/AuthRepositoryImpl.swift b/Modules/Features/Auth/Data/Sources/AuthRepositoryImpl.swift index 93e55573..bec7b332 100644 --- a/Modules/Features/Auth/Data/Sources/AuthRepositoryImpl.swift +++ b/Modules/Features/Auth/Data/Sources/AuthRepositoryImpl.swift @@ -18,41 +18,42 @@ public final class AuthRepositoryImpl: AuthRepository { self.userInfoStore = userInfoStore } - public func login(provider: SocialProvider, idToken: String) async throws -> (AuthUser, String) { + public func login(provider: SocialProvider, idToken: String) async throws -> SocialLoginResultType { do { let response = try await network.request( Login(provider: provider.rawValue, idToken: idToken) ) - let user = response.user.toDomain() - let token = response.accessToken + let result = try response.toDomain() - tokenStorage.saveAccessToken(token) - persistLocalUser(from: user) + if case let .registered(user, accessToken) = result { + tokenStorage.saveAccessToken(accessToken) + persistLocalUser(from: user) + } - return (user, token) - } catch let error as NetworkError { - throw LoginError.networkError(error) + return result + } catch let error as LoginError { + throw error } catch { throw LoginError.networkError(error) } } public func signup( - loginId: String, - password: String, - phoneNumber: String, + signupToken: String, nickname: String, birthYear: String, - gender: String + gender: String, + agreements: [AuthAgreement] ) async throws -> AuthSignupResponse { let response = try await network.request( - Signup( - loginId: loginId, - password: password, - phoneNumber: phoneNumber, + SocialSignup( + signupToken: signupToken, nickname: nickname, birthYear: birthYear, - gender: gender + gender: gender, + agreements: agreements.map { + SocialSignupAgreementRequest(type: $0.type.rawValue, agreed: $0.agreed) + } ) ) let domain = response.toDomain() @@ -63,42 +64,6 @@ public final class AuthRepositoryImpl: AuthRepository { return domain } - public func checkPhoneNumber(_ phoneNumber: String) async throws -> [AuthSimpleUser] { - do { - let response = try await network.request( - CheckPhoneNumber(phoneNumber: phoneNumber) - ) - return response.map { $0.toDomain() } - } catch let error as NetworkError { - if case .serverError(let statusCode, _) = error, statusCode == 400 { - return [] - } else { - throw error - } - } catch { - throw error - } - } - - public func checkIDDup(_ loginId: String) async throws -> AuthIDCheckResult { - let result = try await network.checkRequest( - CheckIDDup(loginId: loginId) - ) - switch result { - case .exists(let dto): - return .exists(dto.toDomain()) - case .notFound: - return .notFound - } - } - - public func authSMS(phoneNumber: String) async throws -> AuthSMSResponse { - let response = try await network.request( - AuthSMS(phoneNumber: phoneNumber) - ) - return response.toDomain() - } - public func preInvestigate( industryId: String, interestIds: [String] @@ -117,8 +82,8 @@ public final class AuthRepositoryImpl: AuthRepository { private func persistLocalUser(from user: AuthUser) { let userInfo = UserInfo( id: user.id, - loginId: user.loginId, - phoneNumber: user.phoneNumber, + loginId: "", + phoneNumber: "", subscribeEmail: user.subscribeEmail, nickname: user.nickname, birthYear: user.birthYear, diff --git a/Modules/Features/Auth/Data/Sources/DTO/AuthSMSResponseDTO.swift b/Modules/Features/Auth/Data/Sources/DTO/AuthSMSResponseDTO.swift deleted file mode 100644 index 79af09dd..00000000 --- a/Modules/Features/Auth/Data/Sources/DTO/AuthSMSResponseDTO.swift +++ /dev/null @@ -1,9 +0,0 @@ -import AuthDomain - -struct AuthSMSResponseDTO: Decodable, Sendable { - let code: Int - - func toDomain() -> AuthSMSResponse { - return AuthSMSResponse(code: code) - } -} diff --git a/Modules/Features/Auth/Data/Sources/DTO/AuthSimpleUserDTO.swift b/Modules/Features/Auth/Data/Sources/DTO/AuthSimpleUserDTO.swift deleted file mode 100644 index 162b8b2c..00000000 --- a/Modules/Features/Auth/Data/Sources/DTO/AuthSimpleUserDTO.swift +++ /dev/null @@ -1,18 +0,0 @@ -import Foundation -import AuthDomain - -struct AuthSimpleUserDTO: Decodable, Sendable { - let id: Int - let loginId: String - let phoneNumber: String - let createdAt: String - - func toDomain() -> AuthSimpleUser { - return AuthSimpleUser( - id: id, - loginId: loginId, - phoneNumber: phoneNumber, - createdAt: createdAt - ) - } -} diff --git a/Modules/Features/Auth/Data/Sources/DTO/AuthSocialLoginResponseDTO.swift b/Modules/Features/Auth/Data/Sources/DTO/AuthSocialLoginResponseDTO.swift new file mode 100644 index 00000000..3b3bc8b0 --- /dev/null +++ b/Modules/Features/Auth/Data/Sources/DTO/AuthSocialLoginResponseDTO.swift @@ -0,0 +1,36 @@ +import Foundation +import AuthDomain + +struct AuthSocialProfileDTO: Decodable, Sendable { + let provider: String? + let providerUserId: String? + let email: String? + let nickname: String? +} + +struct AuthSocialLoginResponseDTO: Decodable, Sendable { + let isRegistered: Bool + let accessToken: String? + let user: AuthUserDTO? + let signupToken: String? + let profile: AuthSocialProfileDTO? + + func toDomain() throws -> SocialLoginResultType { + if isRegistered { + guard let user, let accessToken else { + throw AuthSocialLoginDecodingError.missingRegisteredFields + } + return .registered(user.toDomain(), accessToken: accessToken) + } else { + guard let signupToken else { + throw AuthSocialLoginDecodingError.missingSignupToken + } + return .newUser(signupToken: signupToken, suggestedNickname: profile?.nickname) + } + } +} + +enum AuthSocialLoginDecodingError: Error { + case missingRegisteredFields + case missingSignupToken +} diff --git a/Modules/Features/Auth/Data/Sources/DTO/AuthUserDTO.swift b/Modules/Features/Auth/Data/Sources/DTO/AuthUserDTO.swift index f37ca7d4..ffcc58d6 100644 --- a/Modules/Features/Auth/Data/Sources/DTO/AuthUserDTO.swift +++ b/Modules/Features/Auth/Data/Sources/DTO/AuthUserDTO.swift @@ -18,8 +18,6 @@ struct AuthInterestDTO: Decodable, Sendable { struct AuthUserDTO: Decodable, Sendable { let id: Int - let loginId: String - let phoneNumber: String let subscribeEmail: String? let nickname: String let birthYear: String @@ -31,8 +29,6 @@ struct AuthUserDTO: Decodable, Sendable { func toDomain() -> AuthUser { return AuthUser( id: id, - loginId: loginId, - phoneNumber: phoneNumber, subscribeEmail: subscribeEmail, nickname: nickname, birthYear: birthYear, diff --git a/Modules/Features/Auth/Domain/Sources/Models/AuthAgreement.swift b/Modules/Features/Auth/Domain/Sources/Models/AuthAgreement.swift new file mode 100644 index 00000000..5640df59 --- /dev/null +++ b/Modules/Features/Auth/Domain/Sources/Models/AuthAgreement.swift @@ -0,0 +1,18 @@ +import Foundation + +public struct AuthAgreement: Sendable { + public enum AgreementType: String, Sendable { + case ageConfirmationOver14 = "AGE_CONFIRMATION_OVER_14" + case termsOfService = "TERMS_OF_SERVICE" + case personalInformation = "PERSONAL_INFORMATION_COLLECTION_AND_USE" + case marketing = "MARKETING_INFORMATION_RECEIPT" + } + + public let type: AgreementType + public let agreed: Bool + + public init(type: AgreementType, agreed: Bool) { + self.type = type + self.agreed = agreed + } +} diff --git a/Modules/Features/Auth/Domain/Sources/Models/AuthIDCheckResult.swift b/Modules/Features/Auth/Domain/Sources/Models/AuthIDCheckResult.swift deleted file mode 100644 index 094bd41c..00000000 --- a/Modules/Features/Auth/Domain/Sources/Models/AuthIDCheckResult.swift +++ /dev/null @@ -1,4 +0,0 @@ -public enum AuthIDCheckResult { - case exists(AuthSimpleUser) - case notFound -} diff --git a/Modules/Features/Auth/Domain/Sources/Models/AuthSMSResponse.swift b/Modules/Features/Auth/Domain/Sources/Models/AuthSMSResponse.swift deleted file mode 100644 index d096f36c..00000000 --- a/Modules/Features/Auth/Domain/Sources/Models/AuthSMSResponse.swift +++ /dev/null @@ -1,7 +0,0 @@ -public struct AuthSMSResponse { - public let code: Int - - public init(code: Int) { - self.code = code - } -} diff --git a/Modules/Features/Auth/Domain/Sources/Models/AuthSimpleUser.swift b/Modules/Features/Auth/Domain/Sources/Models/AuthSimpleUser.swift deleted file mode 100644 index 6c6e7d9e..00000000 --- a/Modules/Features/Auth/Domain/Sources/Models/AuthSimpleUser.swift +++ /dev/null @@ -1,35 +0,0 @@ -import Foundation -import FoundationKit - -public struct AuthSimpleUser: Identifiable { - public let id: Int - public let loginId: String - public let phoneNumber: String - public let createdAt: String - - public var maskedLoginId: String { - let prefix = loginId.prefix(4) - let starCount = max(0, loginId.count - 4) - let stars = String(repeating: "*", count: starCount) - return "\(prefix)\(stars)" - } - - public var formattedCreatedAt: String { - guard let date = createdAt.newdokISODate else { - return createdAt - } - return "\(date.newdokJoinDateText) 가입" - } - - public init( - id: Int, - loginId: String, - phoneNumber: String, - createdAt: String - ) { - self.id = id - self.loginId = loginId - self.phoneNumber = phoneNumber - self.createdAt = createdAt - } -} diff --git a/Modules/Features/Auth/Domain/Sources/Models/AuthUser.swift b/Modules/Features/Auth/Domain/Sources/Models/AuthUser.swift index 9dc8be6a..e784b7e0 100644 --- a/Modules/Features/Auth/Domain/Sources/Models/AuthUser.swift +++ b/Modules/Features/Auth/Domain/Sources/Models/AuthUser.swift @@ -2,8 +2,6 @@ import Foundation public struct AuthUser { public let id: Int - public let loginId: String - public let phoneNumber: String public let subscribeEmail: String? public let nickname: String public let birthYear: String @@ -14,8 +12,6 @@ public struct AuthUser { public init( id: Int, - loginId: String, - phoneNumber: String, subscribeEmail: String?, nickname: String, birthYear: String, @@ -25,8 +21,6 @@ public struct AuthUser { interestIds: [Int] ) { self.id = id - self.loginId = loginId - self.phoneNumber = phoneNumber self.subscribeEmail = subscribeEmail self.nickname = nickname self.birthYear = birthYear diff --git a/Modules/Features/Auth/Domain/Sources/Models/SocialLoginResultType.swift b/Modules/Features/Auth/Domain/Sources/Models/SocialLoginResultType.swift new file mode 100644 index 00000000..6fba5b68 --- /dev/null +++ b/Modules/Features/Auth/Domain/Sources/Models/SocialLoginResultType.swift @@ -0,0 +1,6 @@ +import Foundation + +public enum SocialLoginResultType: Sendable { + case registered(AuthUser, accessToken: String) + case newUser(signupToken: String, suggestedNickname: String?) +} diff --git a/Modules/Features/Auth/Domain/Sources/Repository/AuthRepository.swift b/Modules/Features/Auth/Domain/Sources/Repository/AuthRepository.swift index 9ffaeb35..bb881d82 100644 --- a/Modules/Features/Auth/Domain/Sources/Repository/AuthRepository.swift +++ b/Modules/Features/Auth/Domain/Sources/Repository/AuthRepository.swift @@ -1,18 +1,14 @@ import Foundation public protocol AuthRepository: Sendable { - func login(provider: SocialProvider, idToken: String) async throws -> (AuthUser, String) + func login(provider: SocialProvider, idToken: String) async throws -> SocialLoginResultType func signup( - loginId: String, - password: String, - phoneNumber: String, + signupToken: String, nickname: String, birthYear: String, - gender: String + gender: String, + agreements: [AuthAgreement] ) async throws -> AuthSignupResponse - func checkPhoneNumber(_ phoneNumber: String) async throws -> [AuthSimpleUser] - func checkIDDup(_ loginId: String) async throws -> AuthIDCheckResult - func authSMS(phoneNumber: String) async throws -> AuthSMSResponse func preInvestigate(industryId: String, interestIds: [String]) async throws -> [AuthRecommendedBrand] func signOut() async } diff --git a/Modules/Features/Auth/Domain/Sources/UseCase/LoginUseCase.swift b/Modules/Features/Auth/Domain/Sources/UseCase/LoginUseCase.swift index 10ff80c4..ee16182c 100644 --- a/Modules/Features/Auth/Domain/Sources/UseCase/LoginUseCase.swift +++ b/Modules/Features/Auth/Domain/Sources/UseCase/LoginUseCase.swift @@ -1,5 +1,5 @@ import Foundation public protocol LoginUseCase: Sendable { - func execute(provider: SocialProvider, idToken: String) async throws -> AuthUser + func execute(provider: SocialProvider, idToken: String) async throws -> SocialLoginResultType } diff --git a/Modules/Features/Auth/Domain/Sources/UseCase/SignupUseCase.swift b/Modules/Features/Auth/Domain/Sources/UseCase/SignupUseCase.swift index 26811213..c5f96b73 100644 --- a/Modules/Features/Auth/Domain/Sources/UseCase/SignupUseCase.swift +++ b/Modules/Features/Auth/Domain/Sources/UseCase/SignupUseCase.swift @@ -1,27 +1,24 @@ import Foundation public struct AuthSignupRequest { - public let loginId: String - public let password: String - public let phoneNumber: String + public let signupToken: String public let nickname: String public let birthYear: String public let gender: String + public let agreements: [AuthAgreement] public init( - loginId: String, - password: String, - phoneNumber: String, + signupToken: String, nickname: String, birthYear: String, - gender: String + gender: String, + agreements: [AuthAgreement] ) { - self.loginId = loginId - self.password = password - self.phoneNumber = phoneNumber + self.signupToken = signupToken self.nickname = nickname self.birthYear = birthYear self.gender = gender + self.agreements = agreements } } diff --git a/Modules/Features/Auth/Interface/Sources/AuthInterface.swift b/Modules/Features/Auth/Interface/Sources/AuthInterface.swift index 89d7bd6b..d830671e 100644 --- a/Modules/Features/Auth/Interface/Sources/AuthInterface.swift +++ b/Modules/Features/Auth/Interface/Sources/AuthInterface.swift @@ -4,6 +4,6 @@ import SwiftUI public protocol AuthBuildable { func makeOnboardingView() -> AnyView func makeLoginView() -> AnyView - func makeSignupView() -> AnyView + func makeSignupView(signupToken: String, nickname: String?) -> AnyView func signOut() async } diff --git a/Modules/Features/Auth/Sources/AuthBuilder.swift b/Modules/Features/Auth/Sources/AuthBuilder.swift index 5da00702..d3995c24 100644 --- a/Modules/Features/Auth/Sources/AuthBuilder.swift +++ b/Modules/Features/Auth/Sources/AuthBuilder.swift @@ -32,8 +32,8 @@ public struct AuthBuilder: AuthBuildable { AnyView(LoginView(viewModel: container.makeLoginViewModel())) } - public func makeSignupView() -> AnyView { - AnyView(SignupView(viewModel: container.makeSignupViewModel())) + public func makeSignupView(signupToken: String, nickname: String?) -> AnyView { + AnyView(SignupView(viewModel: container.makeSignupViewModel(signupToken: signupToken, nickname: nickname))) } public func signOut() async { diff --git a/Modules/Features/Auth/Sources/AuthDIContainer.swift b/Modules/Features/Auth/Sources/AuthDIContainer.swift index 18e86f7e..60342404 100644 --- a/Modules/Features/Auth/Sources/AuthDIContainer.swift +++ b/Modules/Features/Auth/Sources/AuthDIContainer.swift @@ -42,9 +42,11 @@ final class AuthDIContainer { ) } - func makeSignupViewModel() -> SignupViewModel { + func makeSignupViewModel(signupToken: String, nickname: String?) -> SignupViewModel { let repository = makeRepository() return SignupViewModel( + signupToken: signupToken, + suggestedNickname: nickname, authRepository: repository, signupUseCase: SignupUseCaseImpl(authRepository: repository), userInfoStore: userInfoStore, diff --git a/Modules/Features/Auth/Sources/Login/View/LoginView.swift b/Modules/Features/Auth/Sources/Login/View/LoginView.swift index b55f7790..ef176528 100644 --- a/Modules/Features/Auth/Sources/Login/View/LoginView.swift +++ b/Modules/Features/Auth/Sources/Login/View/LoginView.swift @@ -43,6 +43,8 @@ public struct LoginView: View { } onCompletion: { result in viewModel.handleAppleResult(result) { router.resetTo(.tabbar(selectedTab: .home)) + } onNeedSignup: { signupToken, nickname in + router.push(.signup(signupToken: signupToken, nickname: nickname)) } } .signInWithAppleButtonStyle(.black) @@ -99,6 +101,8 @@ public struct LoginView: View { Button { viewModel.loginWithKakao { router.resetTo(.tabbar(selectedTab: .home)) + } onNeedSignup: { signupToken, nickname in + router.push(.signup(signupToken: signupToken, nickname: nickname)) } } label: { Text("카카오로 시작하기") diff --git a/Modules/Features/Auth/Sources/Login/View/ViewModel/LoginViewModel.swift b/Modules/Features/Auth/Sources/Login/View/ViewModel/LoginViewModel.swift index 89bfb9a2..89188e43 100644 --- a/Modules/Features/Auth/Sources/Login/View/ViewModel/LoginViewModel.swift +++ b/Modules/Features/Auth/Sources/Login/View/ViewModel/LoginViewModel.swift @@ -35,14 +35,22 @@ public final class LoginViewModel: ErrorHandling { } /// 카카오 로그인: SDK로 idToken 획득 후 서버 로그인 - public func loginWithKakao(onSuccess: @escaping () -> Void) { + public func loginWithKakao( + onLoggedIn: @escaping () -> Void, + onNeedSignup: @escaping (_ signupToken: String, _ suggestedNickname: String?) -> Void + ) { guard !isLoading else { return } isLoading = true Task { do { let idToken = try await kakaoAuthService.fetchIDToken() - try await authenticate(provider: .kakao, idToken: idToken, onSuccess: onSuccess) + try await authenticate( + provider: .kakao, + idToken: idToken, + onLoggedIn: onLoggedIn, + onNeedSignup: onNeedSignup + ) } catch { isLoading = false handle(error) @@ -53,7 +61,8 @@ public final class LoginViewModel: ErrorHandling { /// 애플 로그인: SignInWithAppleButton 콜백 결과 처리 public func handleAppleResult( _ result: Result, - onSuccess: @escaping () -> Void + onLoggedIn: @escaping () -> Void, + onNeedSignup: @escaping (_ signupToken: String, _ suggestedNickname: String?) -> Void ) { guard !isLoading else { return } @@ -70,7 +79,12 @@ public final class LoginViewModel: ErrorHandling { isLoading = true Task { do { - try await authenticate(provider: .apple, idToken: idToken, onSuccess: onSuccess) + try await authenticate( + provider: .apple, + idToken: idToken, + onLoggedIn: onLoggedIn, + onNeedSignup: onNeedSignup + ) } catch { isLoading = false handle(error) @@ -85,13 +99,20 @@ public final class LoginViewModel: ErrorHandling { private func authenticate( provider: SocialProvider, idToken: String, - onSuccess: @escaping () -> Void + onLoggedIn: @escaping () -> Void, + onNeedSignup: @escaping (_ signupToken: String, _ suggestedNickname: String?) -> Void ) async throws { defer { isLoading = false } - _ = try await loginUseCase.execute(provider: provider, idToken: idToken) + let result = try await loginUseCase.execute(provider: provider, idToken: idToken) currentError = nil - appState.login() - onSuccess() + + switch result { + case .registered: + appState.login() + onLoggedIn() + case let .newUser(signupToken, suggestedNickname): + onNeedSignup(signupToken, suggestedNickname) + } } private func handle(_ error: Error) { diff --git a/Modules/Features/Auth/Sources/Onboarding/OnboardView.swift b/Modules/Features/Auth/Sources/Onboarding/OnboardView.swift index 52cbb261..e6537ae6 100644 --- a/Modules/Features/Auth/Sources/Onboarding/OnboardView.swift +++ b/Modules/Features/Auth/Sources/Onboarding/OnboardView.swift @@ -52,7 +52,7 @@ public struct OnboardingView: View { VStack(spacing: 0) { Button(action: { onboardingStorage.markCompleted() - router.push(.signup) + router.push(.login) }) { Text("회원가입") .font(.hanSansNeo(14, .bold)) diff --git a/Modules/Features/Auth/Sources/Signup/Curation/CurationView.swift b/Modules/Features/Auth/Sources/Signup/Curation/CurationView.swift index 4a7649f4..e8dc4e5e 100644 --- a/Modules/Features/Auth/Sources/Signup/Curation/CurationView.swift +++ b/Modules/Features/Auth/Sources/Signup/Curation/CurationView.swift @@ -55,7 +55,6 @@ private extension CurationView { .scrollIndicators(.hidden) Button { - viewModel.reset() router.resetTo(.tabbar(selectedTab: .home)) } label: { Text("메인으로") diff --git a/Modules/Features/Auth/Sources/Signup/Popup/SignupPopupView.swift b/Modules/Features/Auth/Sources/Signup/Popup/SignupPopupView.swift deleted file mode 100644 index f80386a9..00000000 --- a/Modules/Features/Auth/Sources/Signup/Popup/SignupPopupView.swift +++ /dev/null @@ -1,119 +0,0 @@ -// -// SignupPopupView.swift -// Signup -// -// Created by 권민재 on 4/7/25. -// Copyright © 2025 Newdok. All rights reserved. -// - -import SwiftUI -import AuthDomain -import DesignSystem - -public struct SignupPopupView: View { - var infos: [AuthSimpleUser] - var onClose: () -> Void - var onLogin: () -> Void - var onRecovery: () -> Void - - public var body: some View { - VStack(spacing: 0) { - Image(asset: DesignSystemAsset.warning) - .resizable() - .frame(width: 80, height: 80) - .padding(.top, 20) - .padding(.bottom, 6) - - Text("이미 가입된 정보입니다.") - .font(.hanSansNeo(18, .bold)) - - Text("한 번호로 최대 3개의\n계정을 만들 수 있어요.") - .font(.hanSansNeo(14, .regular)) - .foregroundStyle(Color.captionBody) - .multilineTextAlignment(.center) - .padding(.top, 6) - - VStack(alignment: .leading) { - ForEach(0..? - private var verificationTask: Task? - private var idCheckTask: Task? private var interestsTask: Task? private var signupTask: Task? - // MARK: password - public var password: String = "" - public var checkedPassword: String = "" - - // MARK: profile + // MARK: - Profile public var nickname: String = "" public var birthYear: String = "" public var gender: String = "" + // MARK: - Agreements + public var agreeOver14: Bool = false + public var agreeService: Bool = false + public var agreePersonalInfo: Bool = false + public var agreeMarketing: Bool = false + public var user: AuthUser? - // MARK: Investigate + // MARK: - Investigate public var myIndustry: String = "" public var selectedInterests: Set = [] public var recommendedPost: [AuthRecommendedBrand] = [] public var isCurationLoading = false public init( + signupToken: String, + suggestedNickname: String?, authRepository: AuthRepository, signupUseCase: SignupUseCase, userInfoStore: UserInfoStoreProtocol, selectableItemStore: SelectableItemStoreProtocol, appState: AppState ) { + self.signupToken = signupToken + self.nickname = suggestedNickname ?? "" self.authRepository = authRepository self.signupUseCase = signupUseCase self.userInfoStore = userInfoStore @@ -122,179 +70,12 @@ public final class SignupViewModel: ErrorHandling { public func goToNextStep() { if let next = SignupStep(rawValue: currentStep.rawValue + 1) { currentStep = next - resendFailureCount = 0 - stopTimer() - isRequestSent = false - timerRemaining = 180 - showError = false - skipUserCheck = false - shouldFocusVerificationCode = false } } public func goToPreviousStep() { if let prev = SignupStep(rawValue: currentStep.rawValue - 1) { currentStep = prev - resendFailureCount = 0 - stopTimer() - isRequestSent = false - timerRemaining = 180 - showError = false - skipUserCheck = false - shouldFocusVerificationCode = false - } - } - - public func resetVerificationState() { - stopTimer() - isShowPopup = false - isRequestSent = false - timerRemaining = 180 - showError = false - enteredVerificationCode = "" - resendFailureCount = 0 - verificationCode = "" - skipUserCheck = false - shouldFocusVerificationCode = false - } - - // MARK: - 인증코드 전송 (초기/재전송 공통) - public func sendVerificationCode(skipCheck: Bool = false) { - #if DEBUG - enteredVerificationCode = "" - showError = false - timerRemaining = 180 - isRequestSent = true - startTimer() - shouldFocusVerificationCode = true - verificationCode = "121212" - #else - guard resendFailureCount < 3 else { - isShowPopup = true - return - } - - if isRequestSent { - resendFailureCount += 1 - } - verificationTask?.cancel() - verificationTask = Task { - do { - isLoading = true - userList = [] - isShowUserList = false - errorMessage = nil - enteredVerificationCode = "" - showError = false - timerRemaining = 180 - - if !skipCheck && !skipUserCheck { - let users = try await authRepository.checkPhoneNumber(phoneNumber) - if !users.isEmpty { - userList = users - isShowUserList = true - showAlreadyRegisteredAlert = true - isLoading = false - return - } - } - - let result = try await authRepository.authSMS(phoneNumber: phoneNumber) - verificationCode = String(result.code) - await MainActor.run { - isRequestSent = true - startTimer() - shouldFocusVerificationCode = true - } - } catch { - handleError(error, feature: "signup", operation: "sendVerificationCode") - errorMessage = currentError?.userFacingMessage - resendFailureCount += 1 - } - isLoading = false - } - #endif - } - - // MARK: - 인증번호 검증 - func verifyCode() -> Bool { - guard isRequestSent else { return false } - if timerRemaining <= 0 { - showError = true - return false - } - - #if DEBUG - if enteredVerificationCode == "121212" { - stopTimer() - resendFailureCount = 0 - showError = false - return true - } - #endif - - if enteredVerificationCode == verificationCode { - stopTimer() - resendFailureCount = 0 - showError = false - return true - } else { - showError = true - return false - } - } - - // MARK: - Timer - private func startTimer() { - stopTimer() - isTimerActive = true - timerTask = Task { [weak self] in - while !Task.isCancelled { - guard let vm = self, vm.timerRemaining > 0 else { break } - do { - try await Task.sleep(for: .seconds(1)) - } catch { - break - } - if Task.isCancelled { break } - vm.timerRemaining -= 1 - } - if let vm = self, vm.timerRemaining <= 0 { - vm.isTimerActive = false - vm.showError = true - } - } - } - - private func stopTimer() { - isTimerActive = false - timerTask?.cancel() - timerTask = nil - } - - // MARK: - ID Dup Check - public func checkIDDup() { - guard SignupFormatStyle.validateID(loginID) == nil else { - isIDAvailable = nil - return - } - - isIDAvailable = nil - idCheckTask?.cancel() - idCheckTask = Task { @MainActor in - do { - let result = try await authRepository.checkIDDup(loginID) - switch result { - case .exists: - isIDAvailable = false - case .notFound: - isIDAvailable = true - } - } catch { - guard !Task.isCancelled else { return } - isIDAvailable = nil - handleError(error, feature: "signup", operation: "checkIDDup") - } } } @@ -358,12 +139,16 @@ public final class SignupViewModel: ErrorHandling { signupTask = Task { do { let request = AuthSignupRequest( - loginId: loginID, - password: password, - phoneNumber: phoneNumber, + signupToken: signupToken, nickname: nickname, birthYear: birthYear, - gender: gender + gender: gender, + agreements: [ + AuthAgreement(type: .ageConfirmationOver14, agreed: agreeOver14), + AuthAgreement(type: .termsOfService, agreed: agreeService), + AuthAgreement(type: .personalInformation, agreed: agreePersonalInfo), + AuthAgreement(type: .marketing, agreed: agreeMarketing) + ] ) let resultUser = try await signupUseCase.execute(request: request) @@ -380,40 +165,4 @@ public final class SignupViewModel: ErrorHandling { } } } - - // MARK: - 초기화 메서드 - public func reset() { - currentStep = .phoneVerification - - phoneNumber = "" - enteredVerificationCode = "" - verificationCode = "" - resendFailureCount = 0 - - loginID = "" - isIDAvailable = nil - password = "" - nickname = "" - birthYear = "" - gender = "" - - user = nil - recommendedPost = [] - selectedInterests.removeAll() - myIndustry = "" - - errorMessage = nil - showError = false - isLoading = false - isRequestSent = false - timerRemaining = 180 - isShowPopup = false - - stopTimer() - verificationTask?.cancel() - idCheckTask?.cancel() - interestsTask?.cancel() - signupTask?.cancel() - isCurationLoading = false - } } diff --git a/Modules/Features/Auth/Sources/Signup/Step/AgreeView.swift b/Modules/Features/Auth/Sources/Signup/Step/AgreeView.swift index 275f37b8..e0e68d8a 100644 --- a/Modules/Features/Auth/Sources/Signup/Step/AgreeView.swift +++ b/Modules/Features/Auth/Sources/Signup/Step/AgreeView.swift @@ -10,11 +10,6 @@ import DesignSystem import WebKit public struct AgreeView: View { - @State private var isOver14 = false - @State private var serviceAgreement = false - @State private var personalInfoAgreement = false - @State private var marketingAgreement = false - @State private var showSheet = false @State private var sheetType: SheetType? @@ -33,31 +28,31 @@ public struct AgreeView: View { VStack(spacing: 16) { // 버튼 없는 행 - SimpleAgreementRow(title: "만 14세 이상 확인", required: true, isChecked: $isOver14) - + SimpleAgreementRow(title: "만 14세 이상 확인", required: true, isChecked: $viewModel.agreeOver14) + // 버튼 있는 행 TappableAgreementRow( mainText: "서비스 이용", required: true, - isChecked: $serviceAgreement, + isChecked: $viewModel.agreeService, onTextTap: { sheetType = .terms showSheet = true } ) - + TappableAgreementRow( mainText: "개인정보 수집 및 이용", required: true, - isChecked: $personalInfoAgreement, + isChecked: $viewModel.agreePersonalInfo, onTextTap: { sheetType = .privacy showSheet = true } ) - + // 버튼 없는 행 - SimpleAgreementRow(title: "마케팅 활용/광고성 정보 수신", required: false, isChecked: $marketingAgreement) + SimpleAgreementRow(title: "마케팅 활용/광고성 정보 수신", required: false, isChecked: $viewModel.agreeMarketing) } .padding(.top, 8) .padding(.horizontal, 24) @@ -71,11 +66,11 @@ public struct AgreeView: View { .font(.hanSansNeo(16, .medium)) Spacer() Button { - let toggle = !(isOver14 && serviceAgreement && personalInfoAgreement && marketingAgreement) - isOver14 = toggle - serviceAgreement = toggle - personalInfoAgreement = toggle - marketingAgreement = toggle + let toggle = !isAllAgreed + viewModel.agreeOver14 = toggle + viewModel.agreeService = toggle + viewModel.agreePersonalInfo = toggle + viewModel.agreeMarketing = toggle } label: { Image(asset: isAllAgreed ? DesignSystemAsset.allcheck : DesignSystemAsset.uncheck) .frame(width: 44, height: 44) @@ -128,11 +123,11 @@ public struct AgreeView: View { } private var isSignUpEnabled: Bool { - isOver14 && serviceAgreement && personalInfoAgreement + viewModel.agreeOver14 && viewModel.agreeService && viewModel.agreePersonalInfo } private var isAllAgreed: Bool { - isOver14 && serviceAgreement && personalInfoAgreement && marketingAgreement + viewModel.agreeOver14 && viewModel.agreeService && viewModel.agreePersonalInfo && viewModel.agreeMarketing } enum SheetType { diff --git a/Modules/Features/Auth/Sources/Signup/Step/IDInputView.swift b/Modules/Features/Auth/Sources/Signup/Step/IDInputView.swift deleted file mode 100644 index c4510193..00000000 --- a/Modules/Features/Auth/Sources/Signup/Step/IDInputView.swift +++ /dev/null @@ -1,99 +0,0 @@ -// -// IDInputView.swift -// Newdok -// -// Created by 권민재 on 2/20/25. -// - -import SwiftUI -import DesignSystem - - -public struct IDInputView: View { - @FocusState private var isIDFocused: Bool - @Bindable private var viewModel: SignupViewModel - - public init(viewModel: SignupViewModel) { - self.viewModel = viewModel - } - - public var body: some View { - ZStack(alignment: .bottom) { - ScrollView { - VStack(alignment: .leading, spacing: 0) { - Text("아이디를\n입력해주세요.") - .font(.hanSansNeo(20, .bold)) - .padding(.top, 24) - - Text("아이디") - .font(.hanSansNeo(14, .medium)) - .foregroundStyle(Color.captionNeutral) - .padding(.top, 42) - .padding(.bottom, 8) - - HStack { - TextField("6~12자,영문/숫자 조합", text: $viewModel.loginID) - .font(.hanSansNeo(14, .medium)) - .customTextFieldStyle(isError: viewModel.isIDErrorState, isFocused: $isIDFocused) - .frame(height: 56) - .focused($isIDFocused) - - Button("중복확인") { - viewModel.checkIDDup() - } - .font(.hanSansNeo(14, .bold)) - .frame(width: 94, height: 48) - .foregroundStyle(viewModel.isIDCheckEnabled ? Color.primaryNormal : Color.captionDisabled) - .overlay { - RoundedRectangle(cornerRadius: 4) - .stroke(viewModel.isIDCheckEnabled ? Color.primaryNormal : Color.captionDisabled, lineWidth: 1) - } - .disabled(!viewModel.isIDCheckEnabled) - } - - if let message = viewModel.idValidationMessage { - Text(message.text) - .font(.hanSansNeo(12, .medium)) - .foregroundStyle(message.color) - .padding(.top, 8) - } - - Spacer().frame(height: 100) - } - .padding(.horizontal, 24) - } - - .ignoresSafeArea(.keyboard) - - Button(action: { - viewModel.goToNextStep() - }) { - Text("다음") - .font(.hanSansNeo(14, .bold)) - .frame(height: 48) - .frame(maxWidth: .infinity) - .background(viewModel.isIDAvailable == true ? Color.primaryNormal : Color.lineNeutral) - .foregroundColor(.white) - .cornerRadius(4) - } - .disabled(viewModel.isIDAvailable != true) - .padding(.horizontal, 24) - .padding(.bottom, 20) - } - .scrollDisabled(true) - .toolbar { - ToolbarItemGroup(placement: .keyboard) { - Spacer() - Button("Done") { - isIDFocused = false - } - .foregroundStyle(Color.primaryNormal) - .font(.hanSansNeo(17, .medium)) - } - } - } -} - -// #Preview { -// IDInputView(nextStep: {}) -// } diff --git a/Modules/Features/Auth/Sources/Signup/Step/PhoneVerificationView.swift b/Modules/Features/Auth/Sources/Signup/Step/PhoneVerificationView.swift deleted file mode 100644 index 82a8352d..00000000 --- a/Modules/Features/Auth/Sources/Signup/Step/PhoneVerificationView.swift +++ /dev/null @@ -1,242 +0,0 @@ -// -// PhoneVerificationView.swift -// Newdok -// -// Created by 권민재 on 2/18/25. -// -import SwiftUI - -import DesignSystem -import Shared -import FoundationKit -import PopupView -import AuthDomain - -public struct PhoneVerificationView: View { - @Environment(AppRouter.self) private var router - - @FocusState private var isPhoneFieldFocused: Bool - @FocusState private var isVerificationCodeFocused: Bool - - @Bindable private var viewModel: SignupViewModel - - public init(viewModel: SignupViewModel) { - self.viewModel = viewModel - } - - public var body: some View { - ZStack(alignment: .bottom) { - ScrollView(.vertical, showsIndicators: false) { - VStack(alignment: .leading, spacing: 0) { - Text("본인 확인을 위해\n휴대폰 번호를 입력해주세요.") - .font(.hanSansNeo(20, .bold)) - .padding(.leading, 24) - .padding(.top, 24) - - Text("휴대폰 번호") - .font(.hanSansNeo(14, .medium)) - .foregroundStyle(Color.captionNeutral) - .padding(.top, 42) - .padding(.leading, 28) - .padding(.bottom, 8) - - HStack(spacing: 8) { - HStack { - Image(asset: DesignSystemAsset.phone) - .renderingMode(.template) - .foregroundStyle(isPhoneFieldFocused ? Color.captionStrong : Color.captionAssistive) - .padding(.leading, 20) - - TextField("-구분 없이 입력", text: $viewModel.phoneNumber) - .font(.hanSansNeo(14, .medium)) - .keyboardType(.numberPad) - .focused($isPhoneFieldFocused) - .padding(.vertical, 12) - .padding(.horizontal, 8) - .onChange(of: viewModel.phoneNumber) { _, newValue in - viewModel.phoneNumber = newValue.newdokDigitsOnly(limit: 11) - } - } - .frame(height: 48) - .background(Color.white) - .overlay( - RoundedRectangle(cornerRadius: 4) - .stroke(isPhoneFieldFocused ? Color.primaryNormal : Color.lineAlternative, lineWidth: 1) - ) - - // 일반적인 인증 요청/재전송 버튼 (항상 표시) - Button(viewModel.isRequestSent ? "재전송" : "인증 요청") { - viewModel.enteredVerificationCode = "" - viewModel.showError = false - // 재전송인 경우 skipCheck: true로 호출 - if viewModel.isRequestSent { - viewModel.sendVerificationCode(skipCheck: true) - } else { - viewModel.sendVerificationCode() - } - } - .font(.hanSansNeo(14, .bold)) - .foregroundStyle(!viewModel.isPhoneNumberValid ? Color.grayLight : Color.primaryNormal) - .disabled(!viewModel.isPhoneNumberValid) - .frame(width: 94, height: 48) - .overlay( - RoundedRectangle(cornerRadius: 4) - .stroke(!viewModel.isPhoneNumberValid ? Color.captionDisabled : Color.primaryNormal, lineWidth: 1) - ) - } - .padding(.horizontal, 24) - - if viewModel.isRequestSent { - VStack(alignment: .leading) { - Text("인증번호") - .font(.hanSansNeo(14, .medium)) - .padding(.leading, 24) - .padding(.top, 24) - - HStack { - HStack { - Image(asset: DesignSystemAsset.lineLock) - .renderingMode(.template) - .foregroundStyle(isVerificationCodeFocused ? Color.captionStrong : Color.captionAssistive) - TextField("6자리 숫자 입력", text: $viewModel.enteredVerificationCode) - .keyboardType(.numberPad) - .font(.hanSansNeo(14, .medium)) - .focused($isVerificationCodeFocused) - .onChange(of: viewModel.enteredVerificationCode) { _, newValue in - viewModel.enteredVerificationCode = newValue.newdokDigitsOnly(limit: 6) - } - } - .padding(.horizontal, 16) - .frame(height: 50) - - Text(viewModel.timerRemaining > 0 ? NewdokVerificationTimerFormatStyle().format(viewModel.timerRemaining) : "만료됨") - .foregroundStyle(Color.captionStrong) - .font(.hanSansNeo(12, .medium)) - .padding(.trailing, 10) - } - .background(viewModel.showError || viewModel.timerRemaining <= 0 ? Color.red.opacity(0.1) : .white) - .overlay( - RoundedRectangle(cornerRadius: 5) - .stroke(viewModel.showError || viewModel.timerRemaining <= 0 ? .red : (isVerificationCodeFocused ? Color.primaryNormal : Color.lineAlternative), lineWidth: 1) - ) - .padding(.horizontal, 24) - - Text(viewModel.timerRemaining <= 0 ? "인증번호를 재전송해주세요." : (viewModel.showError ? "인증번호를 다시 확인해주세요." : "문자가 오지 않는다면 '재전송'을 눌러주세요.")) - .font(.hanSansNeo(12, .medium)) - .foregroundStyle(viewModel.timerRemaining <= 0 || viewModel.showError ? .red : Color.captionBody) - .padding(.leading, 24) - .padding(.top, 6) - } - } - - Spacer().frame(height: 100) - } - } - .toolbar { - ToolbarItemGroup(placement: .keyboard) { - Spacer() - Button("Done") { - isPhoneFieldFocused = false - isVerificationCodeFocused = false - } - .foregroundStyle(Color.primaryNormal) - .font(.hanSansNeo(17, .medium)) - } - } - - .onChange(of: viewModel.shouldFocusVerificationCode) { _, shouldFocus in - if shouldFocus { - // UI 업데이트 완료를 위한 약간의 지연 - Task { - try await Task.sleep(for: .seconds(0.1)) - isVerificationCodeFocused = true - } - viewModel.shouldFocusVerificationCode = false // 상태 리셋 - } - } - - if viewModel.isRequestSent { - Button(action: { - if viewModel.verifyCode() { - viewModel.goToNextStep() - } - }) { - Text("다음") - .font(.hanSansNeo(14, .bold)) - .frame(height: 48) - .frame(maxWidth: .infinity) - .background((viewModel.enteredVerificationCode.count < 6 || viewModel.timerRemaining <= 0) ? Color.lineNeutral : Color.primaryNormal) - .foregroundColor(.white) - .cornerRadius(4) - } - .disabled(viewModel.enteredVerificationCode.count < 6 || viewModel.timerRemaining <= 0) - .padding(.horizontal, 24) - .padding(.bottom, 20) - .contentShape(Rectangle()) - } - } - .scrollDisabled(true) - .ignoresSafeArea(.keyboard) - .onAppear { - // 화면 진입 시 휴대폰 번호 입력 필드로 포커스 - isPhoneFieldFocused = true - } - .popup(isPresented: $viewModel.isShowUserList) { - SignupPopupView( - infos: viewModel.userList, - onClose: { - viewModel.isShowUserList = false - // 기존 사용자가 3명 미만일 때만 계속 진행 - if viewModel.userList.count < 3 { - viewModel.skipUserCheck = true - viewModel.sendVerificationCode(skipCheck: true) - } - }, - onLogin: { - viewModel.isShowUserList = false - router.push(.login) - }, - onRecovery: { - viewModel.isShowUserList = false - router.push(.recovery) - } - ) - } customize: { - $0 - .type(.default) - .position(.center) - .animation(.easeInOut) - .closeOnTapOutside(false) - .closeOnTap(false) - .allowTapThroughBG(false) - .backgroundColor(Color.bgPopupDim.opacity(0.6)) - } - - .popup(isPresented: $viewModel.isShowPopup) { - AuthFailView(onClose: { - viewModel.resetVerificationState() - }) - } customize: { - $0 - .type(.default) - .position(.center) - .animation(.easeInOut) - .closeOnTapOutside(false) - .closeOnTap(false) - .allowTapThroughBG(false) - .backgroundColor(Color.bgPopupDim.opacity(0.6)) - } - } - -} - -struct PrimaryButtonStyle: ButtonStyle { - func makeBody(configuration: Configuration) -> some View { - configuration.label - .frame(maxWidth: .infinity) - .background(configuration.isPressed ? .gray : .primaryNormal) - .font(.hanSansNeo(14, .medium)) - .foregroundColor(.white) - .cornerRadius(8) - } -} diff --git a/Modules/Features/Auth/Sources/Signup/Step/PwInputView.swift b/Modules/Features/Auth/Sources/Signup/Step/PwInputView.swift deleted file mode 100644 index c33130d5..00000000 --- a/Modules/Features/Auth/Sources/Signup/Step/PwInputView.swift +++ /dev/null @@ -1,164 +0,0 @@ -// -// PwInputView.swift -// Newdok -// -// Created by 권민재 on 2/20/25. -// - -import SwiftUI -import DesignSystem -import AuthDomain -import FoundationKit - -public struct PwInputView: View { - @State private var isSecurePassword: Bool = true - @State private var isSecureConfirmPassword: Bool = true - - @FocusState private var isPasswordFocused: Bool - @FocusState private var isConfirmPasswordFocused: Bool - - @Bindable private var viewModel: SignupViewModel - - public init(viewModel: SignupViewModel) { - self.viewModel = viewModel - } - - public var body: some View { - ZStack(alignment: .bottom) { - ScrollView { - VStack(alignment: .leading) { - Text("비밀번호를\n입력해주세요.") - .font(.hanSansNeo(20, .bold)) - .padding(.top, 24) - - // MARK: - 비밀번호 - Text("비밀번호") - .font(.hanSansNeo(14, .medium)) - .foregroundStyle(Color.captionNeutral) - .padding(.top, 42) - - Group { - if isSecurePassword { - SecureField("8자 이상, 영문/숫자 조합", text: $viewModel.password) - .font(.hanSansNeo(14, .medium)) - } else { - TextField("8자 이상, 영문/숫자 조합", text: $viewModel.password) - .font(.hanSansNeo(14, .medium)) - } - } - .modifier( - PasswordFieldModifier( - isSecure: $isSecurePassword, - isFocused: $isPasswordFocused, - isError: viewModel.isPasswordInputError - ) - ) - .focused($isPasswordFocused) - - if let message = viewModel.passwordValidationMessage { - Text(message) - .font(.hanSansNeo(12, .medium)) - .foregroundColor(.red) - .padding(.top, 4) - } - - // MARK: - 비밀번호 확인 - Text("비밀번호 확인") - .font(.hanSansNeo(14, .medium)) - .foregroundStyle(Color.captionNeutral) - .padding(.top, 22) - - Group { - if isSecureConfirmPassword { - SecureField("8자 이상, 영문/숫자 조합", text: $viewModel.checkedPassword) - .font(.hanSansNeo(14, .medium)) - } else { - TextField("8자 이상, 영문/숫자 조합", text: $viewModel.checkedPassword) - .font(.hanSansNeo(14, .medium)) - } - } - .modifier( - PasswordFieldModifier( - isSecure: $isSecureConfirmPassword, - isFocused: $isConfirmPasswordFocused, - isError: viewModel.isConfirmPasswordError - ) - ) - .focused($isConfirmPasswordFocused) - - if viewModel.isConfirmPasswordError { - Text("비밀번호가 일치하지 않습니다.") - .font(.hanSansNeo(12, .medium)) - .foregroundColor(.red) - .padding(.top, 4) - } - - Spacer().frame(height: 100) - } - .padding(.horizontal, 24) - } - .ignoresSafeArea(.keyboard) - - - // MARK: - 다음 버튼 - Button(action: { - viewModel.goToNextStep() - }) { - Text("다음") - .font(.hanSansNeo(14, .bold)) - .frame(height: 48) - .frame(maxWidth: .infinity) - .background(viewModel.isPasswordConfirmed ? Color.primaryNormal : Color.lineNeutral) - .cornerRadius(4) - .foregroundColor(.white) - .contentShape(Rectangle()) - } - .padding(.horizontal, 24) - .padding(.bottom, 20) - .disabled(!viewModel.isPasswordConfirmed) - } - .scrollDisabled(true) - .toolbar { - ToolbarItemGroup(placement: .keyboard) { - Spacer() - Button("Done") { - isPasswordFocused = false - isConfirmPasswordFocused = false - } - .foregroundStyle(Color.primaryNormal) - .font(.hanSansNeo(17, .medium)) - } - } - } -} - -extension SignupViewModel { - var isPasswordValid: Bool { - SignupFormatStyle.validatePassword(password) == nil - } - - var isPasswordConfirmed: Bool { - isPasswordValid && password == checkedPassword - } - - var passwordsMismatch: Bool { - !checkedPassword.isEmpty && password != checkedPassword - } - - var isPasswordInputError: Bool { - !password.isEmpty && !isPasswordValid - } - - var isConfirmPasswordError: Bool { - !checkedPassword.isEmpty && password != checkedPassword - } - - var passwordValidationError: PasswordValidationError? { - guard !password.isEmpty else { return nil } - return SignupFormatStyle.validatePassword(password) - } - - var passwordValidationMessage: String? { - passwordValidationError?.message - } -} diff --git a/Modules/Features/Auth/Sources/UseCase/LoginUseCaseImpl.swift b/Modules/Features/Auth/Sources/UseCase/LoginUseCaseImpl.swift index 0269c3b6..9de5c531 100644 --- a/Modules/Features/Auth/Sources/UseCase/LoginUseCaseImpl.swift +++ b/Modules/Features/Auth/Sources/UseCase/LoginUseCaseImpl.swift @@ -1,10 +1,3 @@ -// -// LoginUseCaseImpl.swift -// Domain -// -// Created by 권민재 on 2/14/26. -// - import Foundation import AuthDomain import Shared @@ -16,8 +9,7 @@ public final class LoginUseCaseImpl: LoginUseCase { self.authRepository = authRepository } - public func execute(provider: SocialProvider, idToken: String) async throws -> AuthUser { - let (user, _) = try await authRepository.login(provider: provider, idToken: idToken) - return user + public func execute(provider: SocialProvider, idToken: String) async throws -> SocialLoginResultType { + try await authRepository.login(provider: provider, idToken: idToken) } } diff --git a/Modules/Features/Auth/Sources/UseCase/SignupUseCaseImpl.swift b/Modules/Features/Auth/Sources/UseCase/SignupUseCaseImpl.swift index b18c3c6a..063ecd5e 100644 --- a/Modules/Features/Auth/Sources/UseCase/SignupUseCaseImpl.swift +++ b/Modules/Features/Auth/Sources/UseCase/SignupUseCaseImpl.swift @@ -13,12 +13,11 @@ public final class SignupUseCaseImpl: SignupUseCase { let trimmedNickname = request.nickname.trimmingCharacters(in: .whitespacesAndNewlines) let result = try await authRepository.signup( - loginId: request.loginId, - password: request.password, - phoneNumber: request.phoneNumber, + signupToken: request.signupToken, nickname: trimmedNickname, birthYear: request.birthYear, - gender: request.gender + gender: request.gender, + agreements: request.agreements ) return result.user diff --git a/Modules/Features/Auth/Testing/Sources/AuthTesting.swift b/Modules/Features/Auth/Testing/Sources/AuthTesting.swift index 7e68bf81..ce5ef3fd 100644 --- a/Modules/Features/Auth/Testing/Sources/AuthTesting.swift +++ b/Modules/Features/Auth/Testing/Sources/AuthTesting.swift @@ -2,10 +2,13 @@ import Foundation import AuthDomain public final class MockLoginUseCase: LoginUseCase, @unchecked Sendable { - public var result: Result = .success( - AuthUser(id: 1, loginId: "test", phoneNumber: "010", subscribeEmail: nil, - nickname: "테스터", birthYear: "2000", gender: "M", - createdAt: "2025-01-01", industryId: nil, interestIds: []) + public var result: Result = .success( + .registered( + AuthUser(id: 1, subscribeEmail: nil, + nickname: "테스터", birthYear: "2000", gender: "남자", + createdAt: "2025-01-01", industryId: nil, interestIds: []), + accessToken: "mock-access-token" + ) ) public private(set) var executedProvider: SocialProvider? public private(set) var executedIDToken: String? @@ -13,7 +16,7 @@ public final class MockLoginUseCase: LoginUseCase, @unchecked Sendable { public init() {} - public func execute(provider: SocialProvider, idToken: String) async throws -> AuthUser { + public func execute(provider: SocialProvider, idToken: String) async throws -> SocialLoginResultType { executeCallCount += 1 executedProvider = provider executedIDToken = idToken diff --git a/Modules/Features/Auth/Tests/AuthTests.swift b/Modules/Features/Auth/Tests/AuthTests.swift index 67ac44fd..b3498859 100644 --- a/Modules/Features/Auth/Tests/AuthTests.swift +++ b/Modules/Features/Auth/Tests/AuthTests.swift @@ -316,7 +316,7 @@ struct LoginViewModelTests { AppState.shared.logout() var successCalled = false - vm.loginWithKakao { successCalled = true } + vm.loginWithKakao(onLoggedIn: { successCalled = true }, onNeedSignup: { _, _ in }) // 내부 Task 완료 대기 try? await Task.sleep(for: .milliseconds(50)) @@ -335,7 +335,7 @@ struct LoginViewModelTests { let (vm, _, _, _) = makeSUT(kakaoAuthService: kakao) var successCalled = false - vm.loginWithKakao { successCalled = true } + vm.loginWithKakao(onLoggedIn: { successCalled = true }, onNeedSignup: { _, _ in }) try? await Task.sleep(for: .milliseconds(50)) @@ -352,7 +352,7 @@ struct LoginViewModelTests { kakao.result = .success("kakao-id-token") let (vm, _, _, _) = makeSUT(loginUseCase: mock, kakaoAuthService: kakao) - vm.loginWithKakao { } + vm.loginWithKakao(onLoggedIn: { }, onNeedSignup: { _, _ in }) try? await Task.sleep(for: .milliseconds(50)) @@ -365,7 +365,7 @@ struct LoginViewModelTests { let (vm, mock, _, _) = makeSUT() vm.isLoading = true - vm.loginWithKakao { } + vm.loginWithKakao(onLoggedIn: { }, onNeedSignup: { _, _ in }) try? await Task.sleep(for: .milliseconds(50)) diff --git a/Modules/Features/Detail/Sources/Brand/BrandDetailView.swift b/Modules/Features/Detail/Sources/Brand/BrandDetailView.swift index 14aaf987..c3c05fbd 100644 --- a/Modules/Features/Detail/Sources/Brand/BrandDetailView.swift +++ b/Modules/Features/Detail/Sources/Brand/BrandDetailView.swift @@ -115,7 +115,7 @@ public struct BrandDetailView: View { .closeOnTapOutside(true).closeOnTap(false).allowTapThroughBG(false) } .popup(isPresented: $isShowGuestAlert) { - SubscribeGuestAlertView(isPresented: $isShowGuestAlert, onSignup: { router.push(.signup) }) + SubscribeGuestAlertView(isPresented: $isShowGuestAlert, onSignup: { router.push(.login) }) } customize: { $0.type(.default).position(.center).animation(.easeInOut) .backgroundColor(Color.black.opacity(0.3)) diff --git a/Modules/Features/Explore/Sources/Explore/ExploreView.swift b/Modules/Features/Explore/Sources/Explore/ExploreView.swift index e84085cd..ade11b93 100644 --- a/Modules/Features/Explore/Sources/Explore/ExploreView.swift +++ b/Modules/Features/Explore/Sources/Explore/ExploreView.swift @@ -116,7 +116,7 @@ public struct ExploreView: View { allNewsletterSection } else { ExploreGuestSection( - onSignup: { router.push(.signup) }, + onSignup: { router.push(.login) }, onLogin: { router.push(.login) } ) } diff --git a/Modules/Features/Home/Sources/Home/HomeView.swift b/Modules/Features/Home/Sources/Home/HomeView.swift index efc16514..41f4aa0e 100644 --- a/Modules/Features/Home/Sources/Home/HomeView.swift +++ b/Modules/Features/Home/Sources/Home/HomeView.swift @@ -157,7 +157,7 @@ public struct HomeView: View { case .guest: NoDataView( type: .requireSignUp, - buttonAction: { router.push(.signup) }, + buttonAction: { router.push(.login) }, loginAction: { router.push(.login) }, refreshAction: { Task { await viewModel.loadToday() } } ) diff --git a/Modules/Features/MyPage/Sources/Recovery/RecoveryView.swift b/Modules/Features/MyPage/Sources/Recovery/RecoveryView.swift index ff77d207..f58cb340 100644 --- a/Modules/Features/MyPage/Sources/Recovery/RecoveryView.swift +++ b/Modules/Features/MyPage/Sources/Recovery/RecoveryView.swift @@ -29,7 +29,7 @@ public struct RecoveryView: View { if selectedTab == 0 { FindIdPagerView( viewModel: viewModel, - onSignUp: { router.push(.signup) }, + onSignUp: { router.push(.login) }, onLogin: { router.push(.login) }, onContact: { router.push(.serviceFeedback) } ) diff --git a/Modules/NetworkKit/Sources/Network/APIRequest.swift b/Modules/NetworkKit/Sources/Network/APIRequest.swift index f37d8d1d..37c2ea5b 100644 --- a/Modules/NetworkKit/Sources/Network/APIRequest.swift +++ b/Modules/NetworkKit/Sources/Network/APIRequest.swift @@ -28,6 +28,10 @@ public protocol APIRequest: Sendable { var method: HTTPMethod { get } var task: RequestTask { get } var headers: [String: String]? { get } + + /// 이 요청의 401이 "세션 만료"(전역 로그아웃)로 해석되어야 하는지 여부. + /// 로그인/회원가입처럼 미인증 진입 엔드포인트는 401이 자격 증명 실패이므로 false. + var emitsUnauthorizedEvent: Bool { get } } public extension APIRequest { @@ -37,4 +41,6 @@ public extension APIRequest { "Accept": "application/json" ] } + + var emitsUnauthorizedEvent: Bool { true } } diff --git a/Modules/NetworkKit/Sources/Network/Core/MoyaTarget.swift b/Modules/NetworkKit/Sources/Network/Core/MoyaTarget.swift index 1a4efb49..5a94872c 100644 --- a/Modules/NetworkKit/Sources/Network/Core/MoyaTarget.swift +++ b/Modules/NetworkKit/Sources/Network/Core/MoyaTarget.swift @@ -8,12 +8,14 @@ struct MoyaTarget: Moya.TargetType { let task: Moya.Task let headers: [String: String]? let sampleData: Data + let emitsUnauthorizedEvent: Bool init(_ request: R) { baseURL = request.baseURL path = request.path headers = request.headers sampleData = Data() + emitsUnauthorizedEvent = request.emitsUnauthorizedEvent switch request.method { case .get: method = .get diff --git a/Modules/NetworkKit/Sources/Network/Core/Plugin/AccessTokenPlugin.swift b/Modules/NetworkKit/Sources/Network/Core/Plugin/AccessTokenPlugin.swift index 51db3895..e0f23e11 100644 --- a/Modules/NetworkKit/Sources/Network/Core/Plugin/AccessTokenPlugin.swift +++ b/Modules/NetworkKit/Sources/Network/Core/Plugin/AccessTokenPlugin.swift @@ -36,6 +36,12 @@ final class AuthPlugin: PluginType { guard statusCode == 401 else { return } + // 로그인/회원가입 같은 미인증 엔드포인트의 401은 자격 증명 실패이지 + // 세션 만료가 아니므로 전역 로그아웃을 트리거하지 않는다. + if let target = target as? MoyaTarget, !target.emitsUnauthorizedEvent { + return + } + AuthEvent.notifyUnauthorized() } } diff --git a/Modules/Shared/Sources/Router/AppRoute.swift b/Modules/Shared/Sources/Router/AppRoute.swift index f83b96bc..27fd369d 100644 --- a/Modules/Shared/Sources/Router/AppRoute.swift +++ b/Modules/Shared/Sources/Router/AppRoute.swift @@ -8,7 +8,7 @@ public enum AppRoute: Hashable { case onboarding case login - case signup + case signup(signupToken: String, nickname: String?) case home case profile case explore From 29abedd4772d1ab353b318781a7f016bfe0d9105 Mon Sep 17 00:00:00 2001 From: gomminjae Date: Sun, 12 Jul 2026 13:11:08 +0900 Subject: [PATCH 3/3] =?UTF-8?q?feat(auth):=20=EB=A1=9C=EA=B7=B8=EC=9D=B8?= =?UTF-8?q?=20UI=20=EB=A6=AC=EB=94=94=EC=9E=90=EC=9D=B8=20+=20=EC=95=A0?= =?UTF-8?q?=ED=94=8C=20=EC=9D=B8=EC=A6=9D=20=EC=84=9C=EB=B9=84=EC=8A=A4=20?= =?UTF-8?q?=EA=B3=84=EC=B8=B5=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 카카오/애플 커스텀 버튼으로 통일 (네이티브 SignInWithAppleButton 제거) - "3초만에 시작하기" 뱃지, 카카오 말풍선 에셋(kakaoBubble) 추가 - AppleAuthServiceProtocol(Domain) + AppleAuthService(Data)로 애플 인증 분리 → ViewModel이 카카오와 동일하게 프로토콜만 의존, AuthenticationServices/UIKit 의존 제거 - MockAppleAuthService 추가, 테스트 DI 대칭화 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Sources/TuistAssets+DesignSystem.swift | 1 + .../kakaoBubble.imageset/Contents.json | 16 +++ .../kakaoBubble.imageset/kakaoBubble.pdf | Bin 0 -> 5678 bytes .../Auth/Auth.xcodeproj/project.pbxproj | 38 +++++ .../Sources/Social/AppleAuthService.swift | 95 ++++++++++++ .../Service/AppleAuthServiceProtocol.swift | 6 + .../Auth/Sources/AuthDIContainer.swift | 1 + .../Auth/Sources/Login/View/LoginView.swift | 135 ++++++++++++------ .../Login/View/ViewModel/LoginViewModel.swift | 48 +++---- .../Auth/Testing/Sources/AuthTesting.swift | 13 +- Modules/Features/Auth/Tests/AuthTests.swift | 5 +- 11 files changed, 282 insertions(+), 76 deletions(-) create mode 100644 Modules/DesignSystem/Resources/Assets.xcassets/kakaoBubble.imageset/Contents.json create mode 100644 Modules/DesignSystem/Resources/Assets.xcassets/kakaoBubble.imageset/kakaoBubble.pdf create mode 100644 Modules/Features/Auth/Data/Sources/Social/AppleAuthService.swift create mode 100644 Modules/Features/Auth/Domain/Sources/Service/AppleAuthServiceProtocol.swift diff --git a/Modules/DesignSystem/Derived/Sources/TuistAssets+DesignSystem.swift b/Modules/DesignSystem/Derived/Sources/TuistAssets+DesignSystem.swift index 0feaa448..e2eeccee 100644 --- a/Modules/DesignSystem/Derived/Sources/TuistAssets+DesignSystem.swift +++ b/Modules/DesignSystem/Derived/Sources/TuistAssets+DesignSystem.swift @@ -44,6 +44,7 @@ public enum DesignSystemAsset: Sendable { public static let font = DesignSystemImages(name: "font") public static let highlight = DesignSystemImages(name: "highlight") public static let home = DesignSystemImages(name: "home") + public static let kakaoBubble = DesignSystemImages(name: "kakaoBubble") public static let letter = DesignSystemImages(name: "letter") public static let logo = DesignSystemImages(name: "logo") public static let mailbox = DesignSystemImages(name: "mailbox") diff --git a/Modules/DesignSystem/Resources/Assets.xcassets/kakaoBubble.imageset/Contents.json b/Modules/DesignSystem/Resources/Assets.xcassets/kakaoBubble.imageset/Contents.json new file mode 100644 index 00000000..be4b98c0 --- /dev/null +++ b/Modules/DesignSystem/Resources/Assets.xcassets/kakaoBubble.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "kakaoBubble.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "template" + } +} diff --git a/Modules/DesignSystem/Resources/Assets.xcassets/kakaoBubble.imageset/kakaoBubble.pdf b/Modules/DesignSystem/Resources/Assets.xcassets/kakaoBubble.imageset/kakaoBubble.pdf new file mode 100644 index 0000000000000000000000000000000000000000..ba8f3d966b5d7e069cf3c7c0ee6139457012d6ba GIT binary patch literal 5678 zcma)Ac|276`zL9cTUV=+)QL8gIdf(oN%qLnSdxk~W)6ng%#uk&X}wyxmJp#uQZAKC zmWm3OTTyP=N;mhG+>{h0<@-4^jOE_n>-9T-jPrS)=ktD^&+~bnkI(ZgVOrT3A$S^Y zNd$Os2tr9iZhWL%lRTcV1wj*o2jhcDc%j%2Mi>kROd!HUq7jfVlI#>nS#l$R#6ZC@$YO;`*kZ0o$`uMAjFsgh zlu3c9%H<6z;ILKvBAJ+{VCS%5l!x+Bfm8w`cmy6j1FS+dMIgZ|)_`XV`LLWN0^$Va zIxwf2aM;FDu9Syj3sXLd<(c!O!^ohqJT7LCF^?tiTca;G;-J1P8BeNj%4A9XO<>G? z9(Q<>5*fi`0*}>&DaY7kEWfe(Em(fTR4VeSFCmf zI_Pr|$5+O};$_6fvbbEn!ug7ius{NHIU_tdIK^1R5=%`H6L{2E;obiojx78MIvQjn2W=ngI$by;Vje;mR|4p$yJezpaLCk>vPSp z^5DpBVes6%qNwIm6;r?dD0RD|+oL^WbIAbnYsoBm{}g;)@|?0}kG7YSQq2;yb?4Ps z6zHj$Fut$W4y!1hwLnX)Y~$i^zkW|6#MV7&mEClxCMF1%8nZ`4E$hp*SxA6%WA<~Gb^Vf6COusKh(Gvq0WoY5KZX3 zrTaP3Y#L7gcEByo$<DGwr8p`Fl;6YCRF(&di^$PC4@ za(4Ta;_HdX3w8K-G^k~w9T*5pN#ucP*x_N~YEgt)pf$A9_DrHdix-H7|RzZdn{*W>17hD^osuzSf2Ej#f`O z&2z%0@(ZoLpY=mE7DSwTZ!e#x$z1$1ZvDi&W{C4DH)sWJ_Q4rx)U*%I=Q0D_G_t1F zN8X5xPG9Q0u-1b-+i@M~;eG$LA93V4r;k0`hqsE)*l;GPw&nHe*V8}F>RWkWZ|$uF zRx*L-Fn;NRnEh4dKl=nAjtSKX>NRu!*3*vKczYg8@0Zc@LIvsf^os>Pmb%Bhuw;K-ze8D)-uJxcV4yHcP;DE`@Fkf z(m%y~&vrAB{`eX4eb@KtTTD$mDvBc5BrIAnxcm3G{d+otw|usBeMVlU=NoFk z)K5g&kpt&+YM)-S+M@n;*%p|;ezRSJJHvsowb0^Aah{vzIiJijcRP1O7e)p#^8? zO?>^bId$5`a2vkHTkiWpW2#5Fn}G+r z%-_-9^}5$}&Bi5-bFO(mjBQ`=krSd*l4N==Bkkn{gW8LA?e(W>DRo|HNf}LY+w6eCNE~)OxAKqCv&2MfS*rR?sf_q}S=+>Ev){?Z7tb!LmmTQ*>8EiDjuQkpH z$vAj#7Mu1``ZC~U(Nd3@=k21a7R5B3|K;Lo?q2Txb|cc$nr9B%cT{FSn9uI?$CGoY z3w=)LEI8s|vxa@g;;zqO>tohYcURhnRWGkDiCYurefgj4=*t1w2QJ5Ef4eNnZpt3W zL$1GQbbH$DWwp8Os(*L-AJ#ei-+5p15AxqNIKPc+eEUmB-dCS@+u=OnFR7x3!H0q} zrE`7i63lBX{@%FmNXCViz7;zvjyv@@`CK45g&UKSW0E%*ABU-1YP>zK${$-l+5ghz zFMZj$qI(4kWU)8e3xb-nT2gLw6m~ZUwxsS#kS`8N{viL0`t0Ut!tqRxX z=@l<3ShjCo-qJQsWfAki!}NzQX5U-6c;$_*X^;=j=uXb|%|<1~toz?fXkVA0GefzPf#O>h?^$ z{PgR1`QolHm+f_3XB*nyx80i+y58Y8Mp`t%?&;~Q*u>L~@xF0!@kdv<02iP>>7>Q-cHLYU-od!tuiYEJj&-j^XCLpuAe zOf}Zc7tC#Votqml^P1rf*C)OuzZWJ|YtPl2X@14{;rBl|lE8Qbk?$!0W^xW4(``1gpED`qRG=yJ%od2;>{ZI8Ob#6m+ z-XD>D6MN_Pd!{f=nJ+fidR%lay!7GQqGxH}%R3M19`j#dNdM5D+4{O<#q+Gc1Ipjj zv7fR}RixDKtN++v*`IsGy(IrkPj%3Xp!UP9Gh1Xo%4EwZPTKY5pQ8H?yVhP1477c6 z4b;d;%W^)K+e)SzF~0rzDj{{^gYn7j#?>ZG@1t(Rv5t?=_xon<PjSo8R@$WS) z3tcL>>G8$qAGERhR8{fXSp%7&X#bn|wJih1jM)gKXUoTso}4mTfbiJO>tRh4J@U8D zUcVKc>ix3tv1#(>%KoR_hW(GaPh~$Scz}94lwJ#)`ngX;MR)Y9*7CXC<6HaovfkUb zI@BqFH|DH8W?-lN2)%zSHZgg{^jgb0x~J)B^Y~M-14$bWubE_4y+h4yVQADMTw-|5 zt~`c$YpQ1Wrr^1SoNa*>zj_2v)M~hD%w5FsT@u7ywESBUgRu8YQ0R2;Lp+MZtp_tE zy*&Bthn?ecp^dh4Cbfs_J{lF1r-f^^=HQABS$GR294xg6%9VEm#ti17UYn7syK`MN>&(cVbH+ zPbhW;1tO}bHZ2hdV{rkO-I4cQ`lvaBB`IT2aH!CP!DLHU9QKDT!NKNYDc6_9mO>Ve zmbf4ssJ0XjL>PK8Qoa4>H-x}o5Xlgmk5kG0`$fVQ;OGiMgbE=PJb_Fi(#Q}WN5WI+ z2$@bF0jg#rL_Cp3B@zh3w$Sl(Dv3;`LF{2|R6LE05J&_F!IS6|BAE`6ffR#5r9&h< znL(xkb`qXOp%EA)Fpr3*5@-ksxDQVOnn@&x2QUheP9tK9h(tPxOooVfI)zFC6ho^g z0uF>g!&nJ;gi0k4=zxrhXE1WhK@>chLZDE{n4bor5xy7(hkZ6oH|%ef+F^)| z^Id@x;GA*RVEg|ghOy{T7Hkv}Llv9|aMg(Bhdq~rRcr{>62W3vSvpw?WdbRL4335> zH9u6MET4wbK`~3I0*iEWfj}q)Y(p%{+741^9O|sNpc0`>%tj>;mL)@|Bg)~jEQE5b zb{q;`)eAx$f)FyEK_ZbT1eKw~rVZK3@{xf4&EUvYDweUON;@P&Whux=ffQ?JKv>dY zCXA>%!B&rS4--PL3U|m(R|p1V(m{5n-=N=ybSqj!N}blAWip0&bg68Qd0o+SQY{83 zQye9ObU>MvkS)VHFaJLUqibg6s>0afEFeVqF z5VK{ZzlKjrECd``;y^oruTXK+VFPeHf;~X7Daz0q>Yzc2h#Lgmxo9vdW{Oc?6tn}d z=)lOVxDpYMwG%kO4eJ|?AyTPfiiI2*8}tB{+i?B(EJG!yLL2r2D{NZ~;7;Cv7SAQwglw$fFWpf|`9`i=IH9IOim4mm1Lc|c>o5S2n9 zQ6S%`DhvUhRD2PrFgkYDjm0P+YR6#Uq#TQpsNi@UBS)fA{zHxd!e%rdiS}{yIO{x?Pk$K_Z#1~@rYm{iQ-@=!4jiw-+0kU`67bQ;Bz yKqDelI*nxbud(7Ng0Zj#$L~-g!>-&A6x$9E0TL<5XT=7A|36gRk|ov~Z2kwwUl$Yr literal 0 HcmV?d00001 diff --git a/Modules/Features/Auth/Auth.xcodeproj/project.pbxproj b/Modules/Features/Auth/Auth.xcodeproj/project.pbxproj index b0eb4801..4565ae84 100644 --- a/Modules/Features/Auth/Auth.xcodeproj/project.pbxproj +++ b/Modules/Features/Auth/Auth.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ 031E2B926BC3F4B22BBFCA35 /* NetworkKit.framework in Dependencies */ = {isa = PBXBuildFile; fileRef = 62778DFF53ACB46ECC6F5F35 /* NetworkKit.framework */; }; + 05B26DCC15BAC48E1F871A86 /* AppleAuthServiceProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA0BAEBE3CDD9B85B10AE26D /* AppleAuthServiceProtocol.swift */; }; 068F8D60BA7BADAEE244105C /* LoginError.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7179D642D174007438E18BB /* LoginError.swift */; }; 06C7F1D619C3D8813EC698DC /* LoginViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADA7DDBBA02C09D5678982D1 /* LoginViewModel.swift */; }; 0AAC727B5804E8861378DBA9 /* AuthErrorResponseDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8002CFA53E586CEF850C3B0A /* AuthErrorResponseDTO.swift */; }; @@ -29,6 +30,7 @@ 282B5EF203975D1974BBA825 /* AuthDIContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B7EBA8D7806B48F8D71D29A /* AuthDIContainer.swift */; }; 2A7224C686420CFF1A66C683 /* Moya.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 83D56B45D644E0B2DBA11EEC /* Moya.framework */; }; 2B882611CD5D55FF9AA4BA30 /* CurationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF5156C9688647B73261D5AA /* CurationView.swift */; }; + 2D93D0C06F2FDF0903805593 /* Alamofire_Alamofire.bundle in Dependencies */ = {isa = PBXBuildFile; fileRef = 78FF525D0E8F54DA8ADC5422 /* Alamofire_Alamofire.bundle */; }; 30FBE074985429C4E16AC952 /* AuthInterface.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BE8C09C4CE36E3D06B61DAB5 /* AuthInterface.framework */; }; 32D5A7DC8F48CD0FCC017BC4 /* FoundationKit.framework in Dependencies */ = {isa = PBXBuildFile; fileRef = 29FA6C20A6564CB1A65D83B5 /* FoundationKit.framework */; }; 3439C05F4186A8EAEAB3AAC7 /* AuthInterface.framework in Dependencies */ = {isa = PBXBuildFile; fileRef = BE8C09C4CE36E3D06B61DAB5 /* AuthInterface.framework */; }; @@ -61,15 +63,18 @@ 9ECE5640A650A001E9195A0F /* AuthUserAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5BA7E5065E6BFA71F04B38E /* AuthUserAPI.swift */; }; 9F49D0A2DD12B3C6F2C9D9AA /* AuthBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = EDE7484E60F831CBAFFBD577 /* AuthBuilder.swift */; }; 9FD0E0B05397F67F37A1530A /* AuthTesting.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5642BA06A94EBFDDB6B192CB /* AuthTesting.framework */; }; + A0136FA014429C88E340055B /* DesignSystem_DesignSystem.bundle in Dependencies */ = {isa = PBXBuildFile; fileRef = 053F11E926A414E7E0C9D2C3 /* DesignSystem_DesignSystem.bundle */; }; A47F3F9CC80909D5A826ED2E /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6061C08937A7986D4F7D042 /* LoginView.swift */; }; A5D92D5C6F4866C1716747F6 /* FoundationKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 29FA6C20A6564CB1A65D83B5 /* FoundationKit.framework */; }; A873D6E66ACC7A15AE540405 /* BirthYearDropdown.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59EBC0CC5D916C5A4CB83108 /* BirthYearDropdown.swift */; }; AD16ADE5EA0A857DC8FCA049 /* AuthSocialLoginResponseDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DA3D0897326D379FB89FB89 /* AuthSocialLoginResponseDTO.swift */; }; AD2E02356CD05728AF7F6BED /* FoundationKit.framework in Dependencies */ = {isa = PBXBuildFile; fileRef = 29FA6C20A6564CB1A65D83B5 /* FoundationKit.framework */; }; ADD5AC589E22E51E680E9367 /* DesignSystem.framework in Dependencies */ = {isa = PBXBuildFile; fileRef = 45A8BB59B621EFC832DCFE0F /* DesignSystem.framework */; }; + B1B883AD5FA839D4A8A34813 /* DesignSystem_DesignSystem.bundle in Resources */ = {isa = PBXBuildFile; fileRef = 053F11E926A414E7E0C9D2C3 /* DesignSystem_DesignSystem.bundle */; }; B1B8CA1ED66CA91DBA0C6606 /* AuthDomain.framework in Dependencies */ = {isa = PBXBuildFile; fileRef = 7C94165A812E44E5FD3BFC7F /* AuthDomain.framework */; }; B3BCEF7C74425A532B13BCA1 /* AuthSignupResponseDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = E65656E0C636D6AF23FD06E9 /* AuthSignupResponseDTO.swift */; }; BE76C14172FBC93CFF00C6EB /* SocialSignupRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C3738B5D3CB94C1EAE7091C /* SocialSignupRequest.swift */; }; + BF9C7ADABAF6E76DBD66CC18 /* AppleAuthService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31D1F0799992DED05F63CE19 /* AppleAuthService.swift */; }; CEECBC8150603B9D9911E035 /* Shared.framework in Dependencies */ = {isa = PBXBuildFile; fileRef = 2D55F8F97BF588B8074069DA /* Shared.framework */; }; CFC5F5E41FBB37824529D154 /* CompleteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7BDA675B82B10EC8A13AFB6B /* CompleteView.swift */; }; D417671EFC2BB25A1ED34B9A /* AuthBrandListResponseDTO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89277BD77D124EAB69AE0989 /* AuthBrandListResponseDTO.swift */; }; @@ -81,6 +86,7 @@ E57A016EEFDE2A1DEF13C80E /* CurationRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07848F750646D4321324EFC6 /* CurationRow.swift */; }; E5BC4A325DD1F4C9FFC406C6 /* ProfileInputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEFBC36DB36A7FB5F4EF2146 /* ProfileInputView.swift */; }; E70B49DE0EB688032C6698F5 /* RecommendView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DF1746A90A2DDFCD5AE9CB3 /* RecommendView.swift */; }; + E8972536C344E7B0BDCDAD2D /* Alamofire_Alamofire.bundle in Resources */ = {isa = PBXBuildFile; fileRef = 78FF525D0E8F54DA8ADC5422 /* Alamofire_Alamofire.bundle */; }; EBFC25633602E81D366CEF7F /* SocialProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC378D97E8C797A1B2CB788B /* SocialProvider.swift */; }; EEFF897EFB63EEF67243F2BF /* AuthTesting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A1EE55F16E7D24969D6632E /* AuthTesting.swift */; }; F42073A1A98F2AF60F8938C8 /* Auth.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8895FC8BB8C3644A2923E77A /* Auth.framework */; }; @@ -157,6 +163,17 @@ name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; }; + 684EC3987431A3767320D468 /* Dependencies */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 8; + dstSubfolderSpec = 16; + files = ( + 2D93D0C06F2FDF0903805593 /* Alamofire_Alamofire.bundle in Dependencies */, + A0136FA014429C88E340055B /* DesignSystem_DesignSystem.bundle in Dependencies */, + ); + name = Dependencies; + runOnlyForDeploymentPostprocessing = 1; + }; 6B0F51A6942BBFE323B15EA1 /* Dependencies */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 8; @@ -258,6 +275,7 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 053F11E926A414E7E0C9D2C3 /* DesignSystem_DesignSystem.bundle */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = DesignSystem_DesignSystem.bundle; sourceTree = BUILT_PRODUCTS_DIR; }; 05A17C657D4A51BEE81CB99C /* OnboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardView.swift; sourceTree = ""; }; 07848F750646D4321324EFC6 /* CurationRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurationRow.swift; sourceTree = ""; }; 083CBFE74C9E7F71EDD0C703 /* AuthMain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthMain.swift; sourceTree = ""; }; @@ -273,6 +291,7 @@ 29FA6C20A6564CB1A65D83B5 /* FoundationKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = FoundationKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 2D55F8F97BF588B8074069DA /* Shared.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Shared.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 2FDB1F539F81A1BE90F87D4C /* Kingfisher.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Kingfisher.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 31D1F0799992DED05F63CE19 /* AppleAuthService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleAuthService.swift; sourceTree = ""; }; 32B432D7D19D2651FD4EA9D0 /* AuthUserDTO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthUserDTO.swift; sourceTree = ""; }; 3B5098B4E6393EB4AD46B5F8 /* AuthTesting-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = "AuthTesting-Info.plist"; sourceTree = ""; }; 3D19C06CB9237BDD75BDE6D0 /* AuthUser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthUser.swift; sourceTree = ""; }; @@ -289,6 +308,7 @@ 6B7EBA8D7806B48F8D71D29A /* AuthDIContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthDIContainer.swift; sourceTree = ""; }; 6C3738B5D3CB94C1EAE7091C /* SocialSignupRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocialSignupRequest.swift; sourceTree = ""; }; 6DFBFB5E537C38ADF3FC6F51 /* AuthRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthRepository.swift; sourceTree = ""; }; + 78FF525D0E8F54DA8ADC5422 /* Alamofire_Alamofire.bundle */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = Alamofire_Alamofire.bundle; sourceTree = BUILT_PRODUCTS_DIR; }; 791F16AF341F57533D166B00 /* SocialLoginResultType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocialLoginResultType.swift; sourceTree = ""; }; 7BDA675B82B10EC8A13AFB6B /* CompleteView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompleteView.swift; sourceTree = ""; }; 7C94165A812E44E5FD3BFC7F /* AuthDomain.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = AuthDomain.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -307,6 +327,7 @@ BE8C09C4CE36E3D06B61DAB5 /* AuthInterface.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = AuthInterface.framework; sourceTree = BUILT_PRODUCTS_DIR; }; BEFBC36DB36A7FB5F4EF2146 /* ProfileInputView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileInputView.swift; sourceTree = ""; }; C857B2C600CA35873D2A9D32 /* Auth-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = "Auth-Info.plist"; sourceTree = ""; }; + CA0BAEBE3CDD9B85B10AE26D /* AppleAuthServiceProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleAuthServiceProtocol.swift; sourceTree = ""; }; CF43922EE1D6125FE8B383ED /* KakaoSDKUser.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = KakaoSDKUser.framework; sourceTree = BUILT_PRODUCTS_DIR; }; CF8FCFE4AC3B608B5042F210 /* AuthTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = AuthTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; D7AF374FFD2C1DCB9C67C232 /* AuthSignupResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthSignupResponse.swift; sourceTree = ""; }; @@ -464,12 +485,14 @@ 47BB2EF37CEDEF87800073AF /* Products */ = { isa = PBXGroup; children = ( + 78FF525D0E8F54DA8ADC5422 /* Alamofire_Alamofire.bundle */, 8895FC8BB8C3644A2923E77A /* Auth.framework */, FC8AEB8E0082B038CB7521FA /* AuthData.framework */, 7C94165A812E44E5FD3BFC7F /* AuthDomain.framework */, BE8C09C4CE36E3D06B61DAB5 /* AuthInterface.framework */, 5642BA06A94EBFDDB6B192CB /* AuthTesting.framework */, CF8FCFE4AC3B608B5042F210 /* AuthTests.xctest */, + 053F11E926A414E7E0C9D2C3 /* DesignSystem_DesignSystem.bundle */, 45A8BB59B621EFC832DCFE0F /* DesignSystem.framework */, 29FA6C20A6564CB1A65D83B5 /* FoundationKit.framework */, 46E7D8C72118ED61834F52D6 /* KakaoSDKAuth.framework */, @@ -555,6 +578,7 @@ 67B1B968B12862927CA5D06F /* Service */ = { isa = PBXGroup; children = ( + CA0BAEBE3CDD9B85B10AE26D /* AppleAuthServiceProtocol.swift */, DBE015DFA89E6676D907A335 /* KakaoAuthServiceProtocol.swift */, ); path = Service; @@ -713,6 +737,7 @@ F0975FF2DD59FA556C7AF3E6 /* Social */ = { isa = PBXGroup; children = ( + 31D1F0799992DED05F63CE19 /* AppleAuthService.swift */, B5D807F75F499DBD2AF39E92 /* KakaoAuthService.swift */, ); path = Social; @@ -758,6 +783,7 @@ E9C7F24A38551DB28EEA9184 /* Resources */, B6A5B1067558D5212CD51DA8 /* Frameworks */, DFA2E33A5B95A9DDC48D1C33 /* Embed Frameworks */, + 684EC3987431A3767320D468 /* Dependencies */, ); buildRules = ( ); @@ -928,6 +954,8 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + E8972536C344E7B0BDCDAD2D /* Alamofire_Alamofire.bundle in Resources */, + B1B883AD5FA839D4A8A34813 /* DesignSystem_DesignSystem.bundle in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -954,6 +982,7 @@ 8E1DE8EEA3B43CEEB1BC3350 /* SocialLoginResultType.swift in Sources */, EBFC25633602E81D366CEF7F /* SocialProvider.swift in Sources */, 9D083976CECA423E68F0F099 /* AuthRepository.swift in Sources */, + 05B26DCC15BAC48E1F871A86 /* AppleAuthServiceProtocol.swift in Sources */, 581473F58B2101FA8819F6FD /* KakaoAuthServiceProtocol.swift in Sources */, 584FF6C187A388DDE2785C63 /* LoginUseCase.swift in Sources */, 71A2A19E91125B3B05216EEF /* SignupUseCase.swift in Sources */, @@ -1027,6 +1056,7 @@ B3BCEF7C74425A532B13BCA1 /* AuthSignupResponseDTO.swift in Sources */, AD16ADE5EA0A857DC8FCA049 /* AuthSocialLoginResponseDTO.swift in Sources */, 585839CA64514AE531B3B0E1 /* AuthUserDTO.swift in Sources */, + BF9C7ADABAF6E76DBD66CC18 /* AppleAuthService.swift in Sources */, 713CD4B5400822ACBE3098A1 /* KakaoAuthService.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -1590,6 +1620,10 @@ "@executable_path/Frameworks", "@loader_path/Frameworks", ); + OTHER_LDFLAGS = ( + "$(inherited)", + "-L$(DT_TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)", + ); PRODUCT_BUNDLE_IDENTIFIER = com.newdok.auth.tests; PRODUCT_NAME = AuthTests; SDKROOT = iphoneos; @@ -1618,6 +1652,10 @@ "@executable_path/Frameworks", "@loader_path/Frameworks", ); + OTHER_LDFLAGS = ( + "$(inherited)", + "-L$(DT_TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)", + ); PRODUCT_BUNDLE_IDENTIFIER = com.newdok.auth.tests; PRODUCT_NAME = AuthTests; SDKROOT = iphoneos; diff --git a/Modules/Features/Auth/Data/Sources/Social/AppleAuthService.swift b/Modules/Features/Auth/Data/Sources/Social/AppleAuthService.swift new file mode 100644 index 00000000..80b8d5da --- /dev/null +++ b/Modules/Features/Auth/Data/Sources/Social/AppleAuthService.swift @@ -0,0 +1,95 @@ +import Foundation +import UIKit +import AuthDomain +import AuthenticationServices + +@MainActor +public struct AppleAuthService: AppleAuthServiceProtocol { + public init() {} + + public func fetchIDToken() async throws -> String { + try await withCheckedThrowingContinuation { continuation in + // manager를 completion 클로저에서 강하게 잡아 콜백 시점까지 생존시킴 + let manager = AppleSignInManager() + manager.request { result in + _ = manager + switch result { + case .success(let authorization): + if let idToken = Self.idToken(from: authorization) { + continuation.resume(returning: idToken) + } else { + continuation.resume(throwing: LoginError.missingIDToken) + } + case .failure(let error): + continuation.resume(throwing: Self.map(error)) + } + } + } + } + + private static func idToken(from authorization: ASAuthorization) -> String? { + guard + let credential = authorization.credential as? ASAuthorizationAppleIDCredential, + let tokenData = credential.identityToken + else { return nil } + return String(data: tokenData, encoding: .utf8) + } + + private static func map(_ error: Error) -> LoginError { + if (error as? ASAuthorizationError)?.code == .canceled { + return .cancelled + } + return .networkError(error) + } +} + +/// ASAuthorizationController를 async로 감싸기 위한 델리게이트 래퍼 +private final class AppleSignInManager: NSObject { + private var completion: ((Result) -> Void)? + private var controller: ASAuthorizationController? + + func request(_ completion: @escaping (Result) -> Void) { + self.completion = completion + let request = ASAuthorizationAppleIDProvider().createRequest() + request.requestedScopes = [.fullName, .email] + let controller = ASAuthorizationController(authorizationRequests: [request]) + controller.delegate = self + controller.presentationContextProvider = self + self.controller = controller + controller.performRequests() + } + + private func finish(_ result: Result) { + completion?(result) + completion = nil + controller = nil + } +} + +extension AppleSignInManager: ASAuthorizationControllerDelegate { + func authorizationController( + controller: ASAuthorizationController, + didCompleteWithAuthorization authorization: ASAuthorization + ) { + finish(.success(authorization)) + } + + func authorizationController( + controller: ASAuthorizationController, + didCompleteWithError error: Error + ) { + finish(.failure(error)) + } +} + +extension AppleSignInManager: ASAuthorizationControllerPresentationContextProviding { + func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor { + // ponytail: 델리게이트는 메인 스레드 콜백 — assumeIsolated로 keyWindow 조회 + MainActor.assumeIsolated { + UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .flatMap(\.windows) + .first(where: \.isKeyWindow) ?? ASPresentationAnchor() + } + } +} diff --git a/Modules/Features/Auth/Domain/Sources/Service/AppleAuthServiceProtocol.swift b/Modules/Features/Auth/Domain/Sources/Service/AppleAuthServiceProtocol.swift new file mode 100644 index 00000000..b3f187c1 --- /dev/null +++ b/Modules/Features/Auth/Domain/Sources/Service/AppleAuthServiceProtocol.swift @@ -0,0 +1,6 @@ +import Foundation + +@MainActor +public protocol AppleAuthServiceProtocol: Sendable { + func fetchIDToken() async throws -> String +} diff --git a/Modules/Features/Auth/Sources/AuthDIContainer.swift b/Modules/Features/Auth/Sources/AuthDIContainer.swift index 60342404..5f8cea03 100644 --- a/Modules/Features/Auth/Sources/AuthDIContainer.swift +++ b/Modules/Features/Auth/Sources/AuthDIContainer.swift @@ -37,6 +37,7 @@ final class AuthDIContainer { return LoginViewModel( loginUseCase: LoginUseCaseImpl(authRepository: repository), kakaoAuthService: KakaoAuthService(), + appleAuthService: AppleAuthService(), tokenStorage: tokenStorage, appState: appState ) diff --git a/Modules/Features/Auth/Sources/Login/View/LoginView.swift b/Modules/Features/Auth/Sources/Login/View/LoginView.swift index ef176528..d6395c29 100644 --- a/Modules/Features/Auth/Sources/Login/View/LoginView.swift +++ b/Modules/Features/Auth/Sources/Login/View/LoginView.swift @@ -10,48 +10,39 @@ import DesignSystem import AuthDomain import Shared import PopupView -import AuthenticationServices public struct LoginView: View { @State private var viewModel: LoginViewModel @State private var showToast: Bool = false @State private var toastMessage: String = "" - + @Environment(AppRouter.self) private var router @Environment(TabSelection.self) private var tabSelection - + public init(viewModel: LoginViewModel) { self.viewModel = viewModel } - + public var body: some View { VStack { - HStack { - Image(asset: DesignSystemAsset.logo) - .padding(.leading, 28) - Spacer() - } - .padding(.top, 40) - Spacer() - + + Image(asset: DesignSystemAsset.logo) + .resizable() + .padding(.horizontal, 91.14) + .frame(height: 48) + + Spacer() + Spacer() + VStack(spacing: 12) { kakaoButton - - SignInWithAppleButton(.continue) { request in - request.requestedScopes = [.fullName, .email] - } onCompletion: { result in - viewModel.handleAppleResult(result) { - router.resetTo(.tabbar(selectedTab: .home)) - } onNeedSignup: { signupToken, nickname in - router.push(.signup(signupToken: signupToken, nickname: nickname)) + .overlay(alignment: .top) { + startBadge.offset(y: -30) } - } - .signInWithAppleButtonStyle(.black) - .frame(height: 48) - .cornerRadius(4) - .disabled(viewModel.isLoading) - + + appleButton + Button("비회원으로 이용하기") { viewModel.loginAsGuest() router.resetTo(.tabbar(selectedTab: .home)) @@ -83,37 +74,93 @@ public struct LoginView: View { .animation(.easeInOut) .closeOnTapOutside(false) } - .toolbar { - ToolbarItem(placement: .principal) { - Text("로그인") - .font(.hanSansNeo(16, .bold)) - .foregroundStyle(Color.captionHeavy) - } - } .serverErrorPopup( error: $viewModel.currentError, onGoBack: { router.pop() }, onRetry: {} ) } - + private var kakaoButton: some View { - Button { + socialButton( + title: "카카오로 계속하기", + background: Color(red: 254 / 255, green: 229 / 255, blue: 0), + foreground: Color.black.opacity(0.85) + ) { + Image(asset: DesignSystemAsset.kakaoBubble) + .renderingMode(.template) + .resizable() + .scaledToFit() + .frame(width: 16, height: 16) + .foregroundColor(Color.black.opacity(0.85)) + } action: { viewModel.loginWithKakao { router.resetTo(.tabbar(selectedTab: .home)) } onNeedSignup: { signupToken, nickname in router.push(.signup(signupToken: signupToken, nickname: nickname)) } - } label: { - Text("카카오로 시작하기") - .font(.hanSansNeo(16, .bold)) - .frame(maxWidth: .infinity) - .frame(height: 48) - .background(Color(red: 254 / 255, green: 229 / 255, blue: 0)) - .foregroundColor(Color(red: 0, green: 0, blue: 0).opacity(0.85)) - .cornerRadius(4) - .contentShape(Rectangle()) + } + } + + private var appleButton: some View { + socialButton( + title: "Apple로 계속하기", + background: .black, + foreground: .white + ) { + Image(systemName: "apple.logo") + .font(.system(size: 20)) + .foregroundColor(.white) + } action: { + viewModel.loginWithApple { + router.resetTo(.tabbar(selectedTab: .home)) + } onNeedSignup: { signupToken, nickname in + router.push(.signup(signupToken: signupToken, nickname: nickname)) + } + } + } + + private func socialButton( + title: String, + background: Color, + foreground: Color, + @ViewBuilder icon: () -> some View, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + ZStack { + Text(title) + .font(.hanSansNeo(16, .bold)) + .foregroundColor(foreground) + HStack { + icon() + Spacer() + } + .padding(.leading, 20) + } + .frame(maxWidth: .infinity) + .frame(height: 56) + .background(background) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .contentShape(Rectangle()) } .disabled(viewModel.isLoading) } + + private var startBadge: some View { + VStack(spacing: 0) { + Text("3초만에 시작하기") + .font(.hanSansNeo(12, .bold)) + .foregroundStyle(.white) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color(hex: "FB4F4F"), in: Capsule()) + Image(systemName: "arrowtriangle.down.fill") + .font(.system(size: 10)) + .foregroundStyle(Color(hex: "FB4F4F")) + .offset(y: -2) + } + } } + + diff --git a/Modules/Features/Auth/Sources/Login/View/ViewModel/LoginViewModel.swift b/Modules/Features/Auth/Sources/Login/View/ViewModel/LoginViewModel.swift index 89188e43..fe4ec4e6 100644 --- a/Modules/Features/Auth/Sources/Login/View/ViewModel/LoginViewModel.swift +++ b/Modules/Features/Auth/Sources/Login/View/ViewModel/LoginViewModel.swift @@ -8,13 +8,13 @@ import SwiftUI import AuthDomain import Shared import Observation -import AuthenticationServices @Observable @MainActor public final class LoginViewModel: ErrorHandling { private let loginUseCase: LoginUseCase private let kakaoAuthService: KakaoAuthServiceProtocol + private let appleAuthService: AppleAuthServiceProtocol private let tokenStorage: TokenStorageProtocol private let appState: AppState @@ -24,11 +24,13 @@ public final class LoginViewModel: ErrorHandling { public init( loginUseCase: LoginUseCase, kakaoAuthService: KakaoAuthServiceProtocol, + appleAuthService: AppleAuthServiceProtocol, tokenStorage: TokenStorageProtocol, appState: AppState ) { self.loginUseCase = loginUseCase self.kakaoAuthService = kakaoAuthService + self.appleAuthService = appleAuthService self.tokenStorage = tokenStorage self.appState = appState self.isLoading = false @@ -58,41 +60,27 @@ public final class LoginViewModel: ErrorHandling { } } - /// 애플 로그인: SignInWithAppleButton 콜백 결과 처리 - public func handleAppleResult( - _ result: Result, + /// 애플 로그인: 서비스로 idToken 획득 후 서버 로그인 (카카오와 동일 흐름) + public func loginWithApple( onLoggedIn: @escaping () -> Void, onNeedSignup: @escaping (_ signupToken: String, _ suggestedNickname: String?) -> Void ) { guard !isLoading else { return } + isLoading = true - switch result { - case .success(let authorization): - guard - let credential = authorization.credential as? ASAuthorizationAppleIDCredential, - let tokenData = credential.identityToken, - let idToken = String(data: tokenData, encoding: .utf8) - else { - handle(LoginError.missingIDToken) - return - } - isLoading = true - Task { - do { - try await authenticate( - provider: .apple, - idToken: idToken, - onLoggedIn: onLoggedIn, - onNeedSignup: onNeedSignup - ) - } catch { - isLoading = false - handle(error) - } + Task { + do { + let idToken = try await appleAuthService.fetchIDToken() + try await authenticate( + provider: .apple, + idToken: idToken, + onLoggedIn: onLoggedIn, + onNeedSignup: onNeedSignup + ) + } catch { + isLoading = false + handle(error) } - case .failure(let error): - if (error as? ASAuthorizationError)?.code == .canceled { return } - handle(LoginError.networkError(error)) } } diff --git a/Modules/Features/Auth/Testing/Sources/AuthTesting.swift b/Modules/Features/Auth/Testing/Sources/AuthTesting.swift index ce5ef3fd..333f556a 100644 --- a/Modules/Features/Auth/Testing/Sources/AuthTesting.swift +++ b/Modules/Features/Auth/Testing/Sources/AuthTesting.swift @@ -28,7 +28,18 @@ public final class MockLoginUseCase: LoginUseCase, @unchecked Sendable { public final class MockKakaoAuthService: KakaoAuthServiceProtocol { public var result: Result = .success("mock-id-token") - public init() {} + public nonisolated init() {} + + public func fetchIDToken() async throws -> String { + try result.get() + } +} + +@MainActor +public final class MockAppleAuthService: AppleAuthServiceProtocol { + public var result: Result = .success("apple-id-token") + + public nonisolated init() {} public func fetchIDToken() async throws -> String { try result.get() diff --git a/Modules/Features/Auth/Tests/AuthTests.swift b/Modules/Features/Auth/Tests/AuthTests.swift index b3498859..b70c762c 100644 --- a/Modules/Features/Auth/Tests/AuthTests.swift +++ b/Modules/Features/Auth/Tests/AuthTests.swift @@ -1,4 +1,5 @@ import Testing +import Foundation import Shared import FoundationKit @testable import Auth @@ -279,12 +280,14 @@ private final class StubTokenStorage: TokenStorageProtocol, @unchecked Sendable struct LoginViewModelTests { private func makeSUT( loginUseCase: MockLoginUseCase = .init(), - kakaoAuthService: MockKakaoAuthService = .init() + kakaoAuthService: MockKakaoAuthService = .init(), + appleAuthService: MockAppleAuthService = .init() ) -> (vm: LoginViewModel, login: MockLoginUseCase, kakao: MockKakaoAuthService, tokenStorage: StubTokenStorage) { let tokenStorage = StubTokenStorage() let vm = LoginViewModel( loginUseCase: loginUseCase, kakaoAuthService: kakaoAuthService, + appleAuthService: appleAuthService, tokenStorage: tokenStorage, appState: AppState.shared )