From 5a1a87fe8dcfa703b022e68cb51a76b22ac3cae2 Mon Sep 17 00:00:00 2001 From: Trey t Date: Sat, 29 Nov 2025 01:17:38 -0600 Subject: [PATCH] Add Sign in with Apple for iOS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kotlin Shared Layer: - Add AppleSignInRequest and AppleSignInResponse models - Add appleSignIn method to AuthApi and APILayer - Add appleSignInState and appleSignIn() to AuthViewModel iOS App: - Create AppleSignInManager for AuthenticationServices integration - Create AppleSignInViewModel to coordinate Apple auth flow - Update LoginView with "Sign in with Apple" button - Add Sign in with Apple entitlement - Add accessibility identifier for UI testing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../kotlin/com/example/casera/models/User.kt | 24 +++ .../com/example/casera/network/APILayer.kt | 14 ++ .../com/example/casera/network/AuthApi.kt | 23 +++ .../example/casera/viewmodel/AuthViewModel.kt | 35 ++++ .../AccessibilityIdentifiers.swift | 1 + .../Helpers/AccessibilityIdentifiers.swift | 1 + iosApp/iosApp/Login/AppleSignInManager.swift | 176 ++++++++++++++++++ .../iosApp/Login/AppleSignInViewModel.swift | 171 +++++++++++++++++ iosApp/iosApp/Login/LoginView.swift | 80 ++++++++ iosApp/iosApp/iosApp.entitlements | 4 + 10 files changed, 529 insertions(+) create mode 100644 iosApp/iosApp/Login/AppleSignInManager.swift create mode 100644 iosApp/iosApp/Login/AppleSignInViewModel.swift diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/models/User.kt b/composeApp/src/commonMain/kotlin/com/example/casera/models/User.kt index cb052fe..20d7a5a 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/models/User.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/models/User.kt @@ -162,3 +162,27 @@ data class ResetPasswordResponse( data class MessageResponse( val message: String ) + +// Apple Sign In Models + +/** + * Apple Sign In request matching Go API + */ +@Serializable +data class AppleSignInRequest( + @SerialName("id_token") val idToken: String, + @SerialName("user_id") val userId: String, + val email: String? = null, + @SerialName("first_name") val firstName: String? = null, + @SerialName("last_name") val lastName: String? = null +) + +/** + * Apple Sign In response matching Go API + */ +@Serializable +data class AppleSignInResponse( + val token: String, + val user: User, + @SerialName("is_new_user") val isNewUser: Boolean +) diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/network/APILayer.kt b/composeApp/src/commonMain/kotlin/com/example/casera/network/APILayer.kt index 2e363dd..2380019 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/network/APILayer.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/network/APILayer.kt @@ -921,6 +921,20 @@ object APILayer { return authApi.resetPassword(request) } + suspend fun appleSignIn(request: AppleSignInRequest): ApiResult { + val result = authApi.appleSignIn(request) + + // Update cache on success + if (result is ApiResult.Success) { + TokenStorage.saveToken(result.data.token) + DataCache.updateCurrentUser(result.data.user) + // Prefetch all data after successful Apple sign in + prefetchManager.prefetchAllData() + } + + return result + } + suspend fun updateProfile(token: String, request: UpdateProfileRequest): ApiResult { val result = authApi.updateProfile(token, request) diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/network/AuthApi.kt b/composeApp/src/commonMain/kotlin/com/example/casera/network/AuthApi.kt index 2d7aedf..8b75af3 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/network/AuthApi.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/network/AuthApi.kt @@ -200,4 +200,27 @@ class AuthApi(private val client: HttpClient = ApiClient.httpClient) { ApiResult.Error(e.message ?: "Unknown error occurred") } } + + // Apple Sign In + suspend fun appleSignIn(request: AppleSignInRequest): ApiResult { + return try { + val response = client.post("$baseUrl/auth/apple-sign-in/") { + contentType(ContentType.Application.Json) + setBody(request) + } + + if (response.status.isSuccess()) { + ApiResult.Success(response.body()) + } else { + val errorBody = try { + response.body>() + } catch (e: Exception) { + mapOf("error" to "Apple Sign In failed") + } + ApiResult.Error(errorBody["error"] ?: "Apple Sign In failed", response.status.value) + } + } catch (e: Exception) { + ApiResult.Error(e.message ?: "Unknown error occurred") + } + } } diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/viewmodel/AuthViewModel.kt b/composeApp/src/commonMain/kotlin/com/example/casera/viewmodel/AuthViewModel.kt index c8eaf55..16c6969 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/viewmodel/AuthViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/viewmodel/AuthViewModel.kt @@ -2,6 +2,8 @@ package com.example.casera.viewmodel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.example.casera.models.AppleSignInRequest +import com.example.casera.models.AppleSignInResponse import com.example.casera.models.AuthResponse import com.example.casera.models.ForgotPasswordRequest import com.example.casera.models.ForgotPasswordResponse @@ -48,6 +50,9 @@ class AuthViewModel : ViewModel() { private val _resetPasswordState = MutableStateFlow>(ApiResult.Idle) val resetPasswordState: StateFlow> = _resetPasswordState + private val _appleSignInState = MutableStateFlow>(ApiResult.Idle) + val appleSignInState: StateFlow> = _appleSignInState + fun login(username: String, password: String) { viewModelScope.launch { _loginState.value = ApiResult.Loading @@ -207,6 +212,36 @@ class AuthViewModel : ViewModel() { _resetPasswordState.value = ApiResult.Idle } + fun appleSignIn( + idToken: String, + userId: String, + email: String?, + firstName: String?, + lastName: String? + ) { + viewModelScope.launch { + _appleSignInState.value = ApiResult.Loading + val result = APILayer.appleSignIn( + AppleSignInRequest( + idToken = idToken, + userId = userId, + email = email, + firstName = firstName, + lastName = lastName + ) + ) + _appleSignInState.value = when (result) { + is ApiResult.Success -> ApiResult.Success(result.data) + is ApiResult.Error -> result + else -> ApiResult.Error("Unknown error") + } + } + } + + fun resetAppleSignInState() { + _appleSignInState.value = ApiResult.Idle + } + fun logout() { viewModelScope.launch { APILayer.logout() diff --git a/iosApp/CaseraUITests/AccessibilityIdentifiers.swift b/iosApp/CaseraUITests/AccessibilityIdentifiers.swift index 7b7d2b3..c75e341 100644 --- a/iosApp/CaseraUITests/AccessibilityIdentifiers.swift +++ b/iosApp/CaseraUITests/AccessibilityIdentifiers.swift @@ -12,6 +12,7 @@ struct AccessibilityIdentifiers { static let signUpButton = "Login.SignUpButton" static let forgotPasswordButton = "Login.ForgotPasswordButton" static let passwordVisibilityToggle = "Login.PasswordVisibilityToggle" + static let appleSignInButton = "Login.AppleSignInButton" // Registration static let registerUsernameField = "Register.UsernameField" diff --git a/iosApp/iosApp/Helpers/AccessibilityIdentifiers.swift b/iosApp/iosApp/Helpers/AccessibilityIdentifiers.swift index 7b7d2b3..c75e341 100644 --- a/iosApp/iosApp/Helpers/AccessibilityIdentifiers.swift +++ b/iosApp/iosApp/Helpers/AccessibilityIdentifiers.swift @@ -12,6 +12,7 @@ struct AccessibilityIdentifiers { static let signUpButton = "Login.SignUpButton" static let forgotPasswordButton = "Login.ForgotPasswordButton" static let passwordVisibilityToggle = "Login.PasswordVisibilityToggle" + static let appleSignInButton = "Login.AppleSignInButton" // Registration static let registerUsernameField = "Register.UsernameField" diff --git a/iosApp/iosApp/Login/AppleSignInManager.swift b/iosApp/iosApp/Login/AppleSignInManager.swift new file mode 100644 index 0000000..1ab6092 --- /dev/null +++ b/iosApp/iosApp/Login/AppleSignInManager.swift @@ -0,0 +1,176 @@ +import Foundation +import AuthenticationServices + +/// Handles Sign in with Apple authentication flow +class AppleSignInManager: NSObject, ObservableObject { + // MARK: - Published Properties + @Published var isProcessing: Bool = false + @Published var error: Error? + + // MARK: - Completion Handler + private var completionHandler: ((Result) -> Void)? + + // MARK: - Public Methods + + /// Initiates the Sign in with Apple flow + /// - Parameter completion: Callback with either credentials or error + func signIn(completion: @escaping (Result) -> Void) { + self.completionHandler = completion + self.error = nil + self.isProcessing = true + + let request = ASAuthorizationAppleIDProvider().createRequest() + request.requestedScopes = [.fullName, .email] + + let controller = ASAuthorizationController(authorizationRequests: [request]) + controller.delegate = self + controller.presentationContextProvider = self + controller.performRequests() + } +} + +// MARK: - ASAuthorizationControllerDelegate + +extension AppleSignInManager: ASAuthorizationControllerDelegate { + func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) { + isProcessing = false + + guard let appleIDCredential = authorization.credential as? ASAuthorizationAppleIDCredential else { + let error = AppleSignInError.invalidCredential + self.error = error + completionHandler?(.failure(error)) + return + } + + // Get the identity token as a string + guard let identityTokenData = appleIDCredential.identityToken, + let identityToken = String(data: identityTokenData, encoding: .utf8) else { + let error = AppleSignInError.missingIdentityToken + self.error = error + completionHandler?(.failure(error)) + return + } + + // Extract user info (only available on first sign in) + let email = appleIDCredential.email + let firstName = appleIDCredential.fullName?.givenName + let lastName = appleIDCredential.fullName?.familyName + let userIdentifier = appleIDCredential.user + + let credential = AppleSignInCredential( + identityToken: identityToken, + userIdentifier: userIdentifier, + email: email, + firstName: firstName, + lastName: lastName + ) + + completionHandler?(.success(credential)) + } + + func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) { + isProcessing = false + + // Check if user cancelled + if let authError = error as? ASAuthorizationError { + switch authError.code { + case .canceled: + // User cancelled, don't treat as error + self.error = AppleSignInError.userCancelled + completionHandler?(.failure(AppleSignInError.userCancelled)) + return + case .failed: + self.error = AppleSignInError.authorizationFailed + completionHandler?(.failure(AppleSignInError.authorizationFailed)) + return + case .invalidResponse: + self.error = AppleSignInError.invalidResponse + completionHandler?(.failure(AppleSignInError.invalidResponse)) + return + case .notHandled: + self.error = AppleSignInError.notHandled + completionHandler?(.failure(AppleSignInError.notHandled)) + return + case .notInteractive: + self.error = AppleSignInError.notInteractive + completionHandler?(.failure(AppleSignInError.notInteractive)) + return + case .unknown: + break // Fall through to generic error + @unknown default: + break + } + } + + self.error = error + completionHandler?(.failure(error)) + } +} + +// MARK: - ASAuthorizationControllerPresentationContextProviding + +extension AppleSignInManager: ASAuthorizationControllerPresentationContextProviding { + func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor { + // Get the key window for presentation + guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let window = scene.windows.first(where: { $0.isKeyWindow }) else { + // Fallback to first window + return UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .flatMap { $0.windows } + .first ?? ASPresentationAnchor() + } + return window + } +} + +// MARK: - Supporting Types + +/// Credentials returned from successful Apple Sign In +struct AppleSignInCredential { + let identityToken: String + let userIdentifier: String + let email: String? + let firstName: String? + let lastName: String? +} + +/// Custom errors for Apple Sign In +enum AppleSignInError: LocalizedError { + case invalidCredential + case missingIdentityToken + case userCancelled + case authorizationFailed + case invalidResponse + case notHandled + case notInteractive + case serverError(String) + + var errorDescription: String? { + switch self { + case .invalidCredential: + return "Invalid Apple ID credential" + case .missingIdentityToken: + return "Missing identity token from Apple" + case .userCancelled: + return nil // Don't show error for user cancellation + case .authorizationFailed: + return "Apple Sign In authorization failed" + case .invalidResponse: + return "Invalid response from Apple" + case .notHandled: + return "Apple Sign In request was not handled" + case .notInteractive: + return "Apple Sign In requires user interaction" + case .serverError(let message): + return message + } + } + + var isCancellation: Bool { + if case .userCancelled = self { + return true + } + return false + } +} diff --git a/iosApp/iosApp/Login/AppleSignInViewModel.swift b/iosApp/iosApp/Login/AppleSignInViewModel.swift new file mode 100644 index 0000000..7a0ac82 --- /dev/null +++ b/iosApp/iosApp/Login/AppleSignInViewModel.swift @@ -0,0 +1,171 @@ +import Foundation +import ComposeApp +import Combine + +/// ViewModel for handling Apple Sign In flow +/// Coordinates between AppleSignInManager (iOS) and AuthViewModel (Kotlin) +@MainActor +class AppleSignInViewModel: ObservableObject { + // MARK: - Published Properties + @Published var isLoading: Bool = false + @Published var errorMessage: String? + @Published var isNewUser: Bool = false + + // MARK: - Private Properties + private let appleSignInManager = AppleSignInManager() + private let sharedViewModel: ComposeApp.AuthViewModel + private let tokenStorage: TokenStorageProtocol + + // MARK: - Callbacks + var onSignInSuccess: ((Bool) -> Void)? // Bool indicates if user is verified + + // MARK: - Initialization + init( + sharedViewModel: ComposeApp.AuthViewModel? = nil, + tokenStorage: TokenStorageProtocol? = nil + ) { + self.sharedViewModel = sharedViewModel ?? ComposeApp.AuthViewModel() + self.tokenStorage = tokenStorage ?? Dependencies.current.makeTokenStorage() + } + + // MARK: - Public Methods + + /// Initiates the Apple Sign In flow + func signInWithApple() { + guard !isLoading else { return } + + isLoading = true + errorMessage = nil + + appleSignInManager.signIn { [weak self] result in + Task { @MainActor in + switch result { + case .success(let credential): + self?.sendCredentialToBackend(credential) + case .failure(let error): + self?.handleAppleError(error) + } + } + } + } + + /// Resets the error state + func clearError() { + errorMessage = nil + } + + // MARK: - Private Methods + + /// Sends Apple credential to backend for verification/authentication + private func sendCredentialToBackend(_ credential: AppleSignInCredential) { + sharedViewModel.appleSignIn( + idToken: credential.identityToken, + userId: credential.userIdentifier, + email: credential.email, + firstName: credential.firstName, + lastName: credential.lastName + ) + + // Observe the result + Task { + for await state in sharedViewModel.appleSignInState { + if state is ApiResultLoading { + await MainActor.run { + self.isLoading = true + } + } else if let success = state as? ApiResultSuccess { + await MainActor.run { + self.handleSuccess(success.data) + } + break + } else if let error = state as? ApiResultError { + await MainActor.run { + self.handleBackendError(error) + } + break + } + } + } + } + + /// Handles successful authentication + private func handleSuccess(_ response: AppleSignInResponse?) { + isLoading = false + + guard let response = response, + let token = response.token as String? else { + errorMessage = "Invalid response from server" + return + } + + let user = response.user + + // Store the token + tokenStorage.saveToken(token: token) + + // Track if this is a new user + isNewUser = response.isNewUser + + // Initialize lookups + Task { + _ = try? await APILayer.shared.initializeLookups() + } + + // Prefetch data + Task { + do { + print("Starting data prefetch after Apple Sign In...") + let prefetchManager = DataPrefetchManager.Companion().getInstance() + _ = try await prefetchManager.prefetchAllData() + print("Data prefetch completed successfully") + } catch { + print("Data prefetch failed: \(error.localizedDescription)") + } + } + + print("Apple Sign In successful! User: \(user.username), New user: \(isNewUser)") + + // Call success callback with verification status + onSignInSuccess?(user.verified) + } + + /// Handles Apple Sign In errors + private func handleAppleError(_ error: Error) { + isLoading = false + + if let appleError = error as? AppleSignInError { + // Don't show error for user cancellation + if appleError.isCancellation { + return + } + errorMessage = appleError.errorDescription + } else { + errorMessage = error.localizedDescription + } + } + + /// Handles backend API errors + private func handleBackendError(_ error: ApiResultError) { + isLoading = false + sharedViewModel.resetAppleSignInState() + + if let code = error.code?.intValue { + switch code { + case 400: + errorMessage = "Invalid Apple Sign In token" + case 401: + errorMessage = "Authentication failed. Please try again." + case 403: + errorMessage = "Access denied" + case 500...599: + errorMessage = "Server error. Please try again later." + default: + errorMessage = ErrorMessageParser.parse(error.message) + } + } else { + errorMessage = ErrorMessageParser.parse(error.message) + } + + print("Apple Sign In backend error: \(error.message)") + } +} diff --git a/iosApp/iosApp/Login/LoginView.swift b/iosApp/iosApp/Login/LoginView.swift index ad18e30..1993954 100644 --- a/iosApp/iosApp/Login/LoginView.swift +++ b/iosApp/iosApp/Login/LoginView.swift @@ -1,7 +1,9 @@ import SwiftUI +import AuthenticationServices struct LoginView: View { @StateObject private var viewModel = LoginViewModel() + @StateObject private var appleSignInViewModel = AppleSignInViewModel() @FocusState private var focusedField: Field? @State private var showingRegister = false @State private var showVerification = false @@ -202,6 +204,70 @@ struct LoginView: View { .disabled(!isFormValid || viewModel.isLoading) .accessibilityIdentifier(AccessibilityIdentifiers.Authentication.loginButton) + // Divider + HStack { + Rectangle() + .fill(Color.appTextSecondary.opacity(0.3)) + .frame(height: 1) + Text("or") + .font(.subheadline) + .foregroundColor(Color.appTextSecondary) + .padding(.horizontal, AppSpacing.sm) + Rectangle() + .fill(Color.appTextSecondary.opacity(0.3)) + .frame(height: 1) + } + .padding(.vertical, AppSpacing.xs) + + // Sign in with Apple Button + SignInWithAppleButton( + onRequest: { request in + request.requestedScopes = [.fullName, .email] + }, + onCompletion: { _ in } + ) + .frame(height: 56) + .cornerRadius(AppRadius.md) + .signInWithAppleButtonStyle(.black) + .disabled(appleSignInViewModel.isLoading) + .opacity(appleSignInViewModel.isLoading ? 0.6 : 1.0) + .overlay { + // Custom tap handler to use our view model + Color.clear + .contentShape(Rectangle()) + .onTapGesture { + appleSignInViewModel.signInWithApple() + } + } + .accessibilityIdentifier(AccessibilityIdentifiers.Authentication.appleSignInButton) + + // Apple Sign In loading indicator + if appleSignInViewModel.isLoading { + HStack { + ProgressView() + .progressViewStyle(CircularProgressViewStyle()) + Text("Signing in with Apple...") + .font(.subheadline) + .foregroundColor(Color.appTextSecondary) + } + .padding(.top, AppSpacing.xs) + } + + // Apple Sign In Error + if let appleError = appleSignInViewModel.errorMessage { + HStack(spacing: AppSpacing.sm) { + Image(systemName: "exclamationmark.circle.fill") + .foregroundColor(Color.appError) + Text(appleError) + .font(.callout) + .foregroundColor(Color.appError) + Spacer() + } + .padding(AppSpacing.md) + .background(Color.appError.opacity(0.1)) + .cornerRadius(AppRadius.md) + } + // Sign Up Link HStack(spacing: AppSpacing.xs) { Text("Don't have an account?") @@ -242,6 +308,20 @@ struct LoginView: View { // since AuthenticationManager.isVerified is now false } } + + // Set up callback for Apple Sign In success + appleSignInViewModel.onSignInSuccess = { [self] isVerified in + // Update the shared authentication manager + AuthenticationManager.shared.login(verified: isVerified) + + if isVerified { + // User is verified, call the success callback + self.onLoginSuccess?() + } else { + // User needs verification - RootView will handle showing VerifyEmailView + // since AuthenticationManager.isVerified is now false + } + } } .fullScreenCover(isPresented: $showVerification) { VerifyEmailView( diff --git a/iosApp/iosApp/iosApp.entitlements b/iosApp/iosApp/iosApp.entitlements index 3cd8c3d..139fdb4 100644 --- a/iosApp/iosApp/iosApp.entitlements +++ b/iosApp/iosApp/iosApp.entitlements @@ -4,6 +4,10 @@ aps-environment development + com.apple.developer.applesignin + + Default + com.apple.security.application-groups group.com.tt.mycrib.MyCribDev