Add Sign in with Apple for iOS
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 <noreply@anthropic.com>
This commit is contained in:
@@ -162,3 +162,27 @@ data class ResetPasswordResponse(
|
|||||||
data class MessageResponse(
|
data class MessageResponse(
|
||||||
val message: String
|
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
|
||||||
|
)
|
||||||
|
|||||||
@@ -921,6 +921,20 @@ object APILayer {
|
|||||||
return authApi.resetPassword(request)
|
return authApi.resetPassword(request)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun appleSignIn(request: AppleSignInRequest): ApiResult<AppleSignInResponse> {
|
||||||
|
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<User> {
|
suspend fun updateProfile(token: String, request: UpdateProfileRequest): ApiResult<User> {
|
||||||
val result = authApi.updateProfile(token, request)
|
val result = authApi.updateProfile(token, request)
|
||||||
|
|
||||||
|
|||||||
@@ -200,4 +200,27 @@ class AuthApi(private val client: HttpClient = ApiClient.httpClient) {
|
|||||||
ApiResult.Error(e.message ?: "Unknown error occurred")
|
ApiResult.Error(e.message ?: "Unknown error occurred")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apple Sign In
|
||||||
|
suspend fun appleSignIn(request: AppleSignInRequest): ApiResult<AppleSignInResponse> {
|
||||||
|
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<Map<String, String>>()
|
||||||
|
} 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package com.example.casera.viewmodel
|
|||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
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.AuthResponse
|
||||||
import com.example.casera.models.ForgotPasswordRequest
|
import com.example.casera.models.ForgotPasswordRequest
|
||||||
import com.example.casera.models.ForgotPasswordResponse
|
import com.example.casera.models.ForgotPasswordResponse
|
||||||
@@ -48,6 +50,9 @@ class AuthViewModel : ViewModel() {
|
|||||||
private val _resetPasswordState = MutableStateFlow<ApiResult<ResetPasswordResponse>>(ApiResult.Idle)
|
private val _resetPasswordState = MutableStateFlow<ApiResult<ResetPasswordResponse>>(ApiResult.Idle)
|
||||||
val resetPasswordState: StateFlow<ApiResult<ResetPasswordResponse>> = _resetPasswordState
|
val resetPasswordState: StateFlow<ApiResult<ResetPasswordResponse>> = _resetPasswordState
|
||||||
|
|
||||||
|
private val _appleSignInState = MutableStateFlow<ApiResult<AppleSignInResponse>>(ApiResult.Idle)
|
||||||
|
val appleSignInState: StateFlow<ApiResult<AppleSignInResponse>> = _appleSignInState
|
||||||
|
|
||||||
fun login(username: String, password: String) {
|
fun login(username: String, password: String) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_loginState.value = ApiResult.Loading
|
_loginState.value = ApiResult.Loading
|
||||||
@@ -207,6 +212,36 @@ class AuthViewModel : ViewModel() {
|
|||||||
_resetPasswordState.value = ApiResult.Idle
|
_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() {
|
fun logout() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
APILayer.logout()
|
APILayer.logout()
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ struct AccessibilityIdentifiers {
|
|||||||
static let signUpButton = "Login.SignUpButton"
|
static let signUpButton = "Login.SignUpButton"
|
||||||
static let forgotPasswordButton = "Login.ForgotPasswordButton"
|
static let forgotPasswordButton = "Login.ForgotPasswordButton"
|
||||||
static let passwordVisibilityToggle = "Login.PasswordVisibilityToggle"
|
static let passwordVisibilityToggle = "Login.PasswordVisibilityToggle"
|
||||||
|
static let appleSignInButton = "Login.AppleSignInButton"
|
||||||
|
|
||||||
// Registration
|
// Registration
|
||||||
static let registerUsernameField = "Register.UsernameField"
|
static let registerUsernameField = "Register.UsernameField"
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ struct AccessibilityIdentifiers {
|
|||||||
static let signUpButton = "Login.SignUpButton"
|
static let signUpButton = "Login.SignUpButton"
|
||||||
static let forgotPasswordButton = "Login.ForgotPasswordButton"
|
static let forgotPasswordButton = "Login.ForgotPasswordButton"
|
||||||
static let passwordVisibilityToggle = "Login.PasswordVisibilityToggle"
|
static let passwordVisibilityToggle = "Login.PasswordVisibilityToggle"
|
||||||
|
static let appleSignInButton = "Login.AppleSignInButton"
|
||||||
|
|
||||||
// Registration
|
// Registration
|
||||||
static let registerUsernameField = "Register.UsernameField"
|
static let registerUsernameField = "Register.UsernameField"
|
||||||
|
|||||||
176
iosApp/iosApp/Login/AppleSignInManager.swift
Normal file
176
iosApp/iosApp/Login/AppleSignInManager.swift
Normal file
@@ -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<AppleSignInCredential, Error>) -> Void)?
|
||||||
|
|
||||||
|
// MARK: - Public Methods
|
||||||
|
|
||||||
|
/// Initiates the Sign in with Apple flow
|
||||||
|
/// - Parameter completion: Callback with either credentials or error
|
||||||
|
func signIn(completion: @escaping (Result<AppleSignInCredential, Error>) -> 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
|
||||||
|
}
|
||||||
|
}
|
||||||
171
iosApp/iosApp/Login/AppleSignInViewModel.swift
Normal file
171
iosApp/iosApp/Login/AppleSignInViewModel.swift
Normal file
@@ -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<AppleSignInResponse> {
|
||||||
|
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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import AuthenticationServices
|
||||||
|
|
||||||
struct LoginView: View {
|
struct LoginView: View {
|
||||||
@StateObject private var viewModel = LoginViewModel()
|
@StateObject private var viewModel = LoginViewModel()
|
||||||
|
@StateObject private var appleSignInViewModel = AppleSignInViewModel()
|
||||||
@FocusState private var focusedField: Field?
|
@FocusState private var focusedField: Field?
|
||||||
@State private var showingRegister = false
|
@State private var showingRegister = false
|
||||||
@State private var showVerification = false
|
@State private var showVerification = false
|
||||||
@@ -202,6 +204,70 @@ struct LoginView: View {
|
|||||||
.disabled(!isFormValid || viewModel.isLoading)
|
.disabled(!isFormValid || viewModel.isLoading)
|
||||||
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.loginButton)
|
.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
|
// Sign Up Link
|
||||||
HStack(spacing: AppSpacing.xs) {
|
HStack(spacing: AppSpacing.xs) {
|
||||||
Text("Don't have an account?")
|
Text("Don't have an account?")
|
||||||
@@ -242,6 +308,20 @@ struct LoginView: View {
|
|||||||
// since AuthenticationManager.isVerified is now false
|
// 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) {
|
.fullScreenCover(isPresented: $showVerification) {
|
||||||
VerifyEmailView(
|
VerifyEmailView(
|
||||||
|
|||||||
@@ -4,6 +4,10 @@
|
|||||||
<dict>
|
<dict>
|
||||||
<key>aps-environment</key>
|
<key>aps-environment</key>
|
||||||
<string>development</string>
|
<string>development</string>
|
||||||
|
<key>com.apple.developer.applesignin</key>
|
||||||
|
<array>
|
||||||
|
<string>Default</string>
|
||||||
|
</array>
|
||||||
<key>com.apple.security.application-groups</key>
|
<key>com.apple.security.application-groups</key>
|
||||||
<array>
|
<array>
|
||||||
<string>group.com.tt.mycrib.MyCribDev</string>
|
<string>group.com.tt.mycrib.MyCribDev</string>
|
||||||
|
|||||||
Reference in New Issue
Block a user