diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/App.kt b/composeApp/src/commonMain/kotlin/com/example/casera/App.kt index 15e6d3c..8d671bd 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/App.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/App.kt @@ -289,9 +289,28 @@ fun App( } val passwordResetViewModel: PasswordResetViewModel = viewModel(parentEntry) { PasswordResetViewModel() } + // Set up auto-login callback + LaunchedEffect(Unit) { + passwordResetViewModel.onLoginSuccess = { verified -> + isLoggedIn = true + isVerified = verified + onClearDeepLinkToken() + // Navigate directly to main app or verification screen + if (verified) { + navController.navigate(MainRoute) { + popUpTo { inclusive = true } + } + } else { + navController.navigate(VerifyEmailRoute) { + popUpTo { inclusive = true } + } + } + } + } + ResetPasswordScreen( onPasswordResetSuccess = { - // Clear deep link token and navigate back to login after successful password reset + // Fallback: manual return to login (only shown if auto-login fails) onClearDeepLinkToken() navController.navigate(LoginRoute) { popUpTo { inclusive = true } diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ResetPasswordScreen.kt b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ResetPasswordScreen.kt index 55e0218..0a1c517 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ResetPasswordScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/ui/screens/ResetPasswordScreen.kt @@ -36,6 +36,7 @@ fun ResetPasswordScreen( var confirmPasswordVisible by remember { mutableStateOf(false) } val resetPasswordState by viewModel.resetPasswordState.collectAsState() + val loginState by viewModel.loginState.collectAsState() val currentStep by viewModel.currentStep.collectAsState() // Handle errors for password reset @@ -50,6 +51,7 @@ fun ResetPasswordScreen( } val isLoading = resetPasswordState is ApiResult.Loading + val isLoggingIn = currentStep == com.example.casera.viewmodel.PasswordResetStep.LOGGING_IN val isSuccess = currentStep == com.example.casera.viewmodel.PasswordResetStep.SUCCESS // Password validation @@ -63,9 +65,12 @@ fun ResetPasswordScreen( TopAppBar( title = { Text(stringResource(Res.string.auth_reset_title)) }, navigationIcon = { - onNavigateBack?.let { callback -> - IconButton(onClick = callback) { - Icon(Icons.Default.ArrowBack, contentDescription = stringResource(Res.string.common_back)) + // Hide back button while logging in + if (!isLoggingIn) { + onNavigateBack?.let { callback -> + IconButton(onClick = callback) { + Icon(Icons.Default.ArrowBack, contentDescription = stringResource(Res.string.common_back)) + } } } }, @@ -239,15 +244,21 @@ fun ResetPasswordScreen( modifier = Modifier .fillMaxWidth() .height(56.dp), - enabled = isFormValid && !isLoading, + enabled = isFormValid && !isLoading && !isLoggingIn, shape = RoundedCornerShape(12.dp) ) { - if (isLoading) { + if (isLoading || isLoggingIn) { CircularProgressIndicator( modifier = Modifier.size(24.dp), color = MaterialTheme.colorScheme.onPrimary, strokeWidth = 2.dp ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + if (isLoggingIn) "Logging in..." else "Resetting...", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) } else { Icon(Icons.Default.LockReset, contentDescription = null) Spacer(modifier = Modifier.width(8.dp)) diff --git a/composeApp/src/commonMain/kotlin/com/example/casera/viewmodel/PasswordResetViewModel.kt b/composeApp/src/commonMain/kotlin/com/example/casera/viewmodel/PasswordResetViewModel.kt index 315c5f3..9b21927 100644 --- a/composeApp/src/commonMain/kotlin/com/example/casera/viewmodel/PasswordResetViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/example/casera/viewmodel/PasswordResetViewModel.kt @@ -13,6 +13,7 @@ enum class PasswordResetStep { REQUEST_CODE, VERIFY_CODE, RESET_PASSWORD, + LOGGING_IN, SUCCESS } @@ -29,6 +30,12 @@ class PasswordResetViewModel( private val _resetPasswordState = MutableStateFlow>(ApiResult.Idle) val resetPasswordState: StateFlow> = _resetPasswordState + private val _loginState = MutableStateFlow>(ApiResult.Idle) + val loginState: StateFlow> = _loginState + + // Callback for successful login after password reset + var onLoginSuccess: ((Boolean) -> Unit)? = null + private val _currentStep = MutableStateFlow( if (deepLinkToken != null) PasswordResetStep.RESET_PASSWORD else PasswordResetStep.REQUEST_CODE ) @@ -80,6 +87,8 @@ class PasswordResetViewModel( } } + private var _newPassword: String = "" + fun resetPassword(newPassword: String, confirmPassword: String) { val token = _resetToken.value if (token == null) { @@ -87,6 +96,8 @@ class PasswordResetViewModel( return } + _newPassword = newPassword + viewModelScope.launch { _resetPasswordState.value = ApiResult.Loading // Note: confirmPassword is for UI validation only, not sent to API @@ -98,7 +109,9 @@ class PasswordResetViewModel( ) _resetPasswordState.value = when (result) { is ApiResult.Success -> { - _currentStep.value = PasswordResetStep.SUCCESS + // Password reset successful - now auto-login + _currentStep.value = PasswordResetStep.LOGGING_IN + autoLogin() ApiResult.Success(result.data) } is ApiResult.Error -> result @@ -107,11 +120,46 @@ class PasswordResetViewModel( } } + private suspend fun autoLogin() { + val username = _email.value + + if (username.isEmpty()) { + // If we don't have the email (e.g., deep link flow), fall back to manual login + _currentStep.value = PasswordResetStep.SUCCESS + return + } + + _loginState.value = ApiResult.Loading + + val loginResult = APILayer.login(LoginRequest(username = username, password = _newPassword)) + + when (loginResult) { + is ApiResult.Success -> { + val isVerified = loginResult.data.user.verified + // Initialize lookups + APILayer.initializeLookups() + _loginState.value = loginResult + // Call the login success callback + onLoginSuccess?.invoke(isVerified) + } + is ApiResult.Error -> { + // Auto-login failed, fall back to manual login + println("Auto-login failed: ${loginResult.message}") + _loginState.value = ApiResult.Idle + _currentStep.value = PasswordResetStep.SUCCESS + } + else -> { + _currentStep.value = PasswordResetStep.SUCCESS + } + } + } + fun moveToNextStep() { _currentStep.value = when (_currentStep.value) { PasswordResetStep.REQUEST_CODE -> PasswordResetStep.VERIFY_CODE PasswordResetStep.VERIFY_CODE -> PasswordResetStep.RESET_PASSWORD - PasswordResetStep.RESET_PASSWORD -> PasswordResetStep.SUCCESS + PasswordResetStep.RESET_PASSWORD -> PasswordResetStep.LOGGING_IN + PasswordResetStep.LOGGING_IN -> PasswordResetStep.SUCCESS PasswordResetStep.SUCCESS -> PasswordResetStep.SUCCESS } } @@ -121,6 +169,7 @@ class PasswordResetViewModel( PasswordResetStep.REQUEST_CODE -> PasswordResetStep.REQUEST_CODE PasswordResetStep.VERIFY_CODE -> PasswordResetStep.REQUEST_CODE PasswordResetStep.RESET_PASSWORD -> PasswordResetStep.VERIFY_CODE + PasswordResetStep.LOGGING_IN -> PasswordResetStep.LOGGING_IN // Can't go back while logging in PasswordResetStep.SUCCESS -> PasswordResetStep.SUCCESS } } diff --git a/iosApp/iosApp/Localizable.xcstrings b/iosApp/iosApp/Localizable.xcstrings index c0cb403..a3cec2b 100644 --- a/iosApp/iosApp/Localizable.xcstrings +++ b/iosApp/iosApp/Localizable.xcstrings @@ -17388,6 +17388,9 @@ }, "Log in" : { + }, + "Logging in..." : { + }, "Mark Task In Progress" : { "comment" : "A button label that says \"Mark Task In Progress\".", @@ -21300,6 +21303,9 @@ "Reset Password" : { "comment" : "The title of the screen where users can reset their passwords.", "isCommentAutoGenerated" : true + }, + "Resetting..." : { + }, "Residences" : { "comment" : "A tab label for the \"Residences\" section in the main tab view.", diff --git a/iosApp/iosApp/Login/LoginView.swift b/iosApp/iosApp/Login/LoginView.swift index a978a55..8e8a0a0 100644 --- a/iosApp/iosApp/Login/LoginView.swift +++ b/iosApp/iosApp/Login/LoginView.swift @@ -336,7 +336,16 @@ struct LoginView: View { RegisterView() } .sheet(isPresented: $showPasswordReset) { - PasswordResetFlow(resetToken: resetToken) + PasswordResetFlow(resetToken: resetToken, onLoginSuccess: { isVerified in + // Update the shared authentication manager + AuthenticationManager.shared.login(verified: isVerified) + + if isVerified { + // User is verified, call the success callback + self.onLoginSuccess?() + } + // If not verified, RootView will handle showing VerifyEmailView + }) } .onChange(of: resetToken) { _, token in if token != nil { diff --git a/iosApp/iosApp/PasswordReset/PasswordResetFlow.swift b/iosApp/iosApp/PasswordReset/PasswordResetFlow.swift index c16b1a5..e4b9d90 100644 --- a/iosApp/iosApp/PasswordReset/PasswordResetFlow.swift +++ b/iosApp/iosApp/PasswordReset/PasswordResetFlow.swift @@ -3,9 +3,11 @@ import SwiftUI struct PasswordResetFlow: View { @StateObject private var viewModel: PasswordResetViewModel @Environment(\.dismiss) var dismiss + var onLoginSuccess: ((Bool) -> Void)? - init(resetToken: String? = nil) { + init(resetToken: String? = nil, onLoginSuccess: ((Bool) -> Void)? = nil) { _viewModel = StateObject(wrappedValue: PasswordResetViewModel(resetToken: resetToken)) + self.onLoginSuccess = onLoginSuccess } var body: some View { @@ -15,13 +17,22 @@ struct PasswordResetFlow: View { ForgotPasswordView(viewModel: viewModel) case .verifyCode: VerifyResetCodeView(viewModel: viewModel) - case .resetPassword, .success: + case .resetPassword, .loggingIn, .success: ResetPasswordView(viewModel: viewModel, onSuccess: { dismiss() }) } } .animation(.easeInOut, value: viewModel.currentStep) + .onAppear { + // Set up callback for auto-login success + viewModel.onLoginSuccess = { [self] isVerified in + // Dismiss the sheet first + dismiss() + // Then call the parent's login success handler + onLoginSuccess?(isVerified) + } + } } } diff --git a/iosApp/iosApp/PasswordReset/PasswordResetViewModel.swift b/iosApp/iosApp/PasswordReset/PasswordResetViewModel.swift index 74144af..a093c22 100644 --- a/iosApp/iosApp/PasswordReset/PasswordResetViewModel.swift +++ b/iosApp/iosApp/PasswordReset/PasswordResetViewModel.swift @@ -6,7 +6,8 @@ enum PasswordResetStep: CaseIterable { case requestCode // Step 1: Enter email case verifyCode // Step 2: Enter 6-digit code case resetPassword // Step 3: Set new password - case success // Final: Success confirmation + case loggingIn // Auto-login in progress + case success // Final: Success confirmation (only used if auto-login fails) } /// ViewModel for password reset flow. @@ -24,6 +25,9 @@ class PasswordResetViewModel: ObservableObject { @Published var currentStep: PasswordResetStep = .requestCode @Published var resetToken: String? + // Callback for successful login after password reset + var onLoginSuccess: ((Bool) -> Void)? + // MARK: - Initialization init(resetToken: String? = nil) { // If we have a reset token from deep link, skip to password reset step @@ -141,9 +145,10 @@ class PasswordResetViewModel: ObservableObject { let result = try await APILayer.shared.resetPassword(request: request) if result is ApiResultSuccess { - self.isLoading = false - self.successMessage = "Password reset successfully! You can now log in with your new password." - self.currentStep = .success + // Password reset successful - now auto-login + self.successMessage = "Password reset successfully! Logging you in..." + self.currentStep = .loggingIn + await self.autoLogin() } else if let error = result as? ApiResultError { self.isLoading = false self.errorMessage = ErrorMessageParser.parse(error.message) @@ -155,6 +160,51 @@ class PasswordResetViewModel: ObservableObject { } } + /// Auto-login after successful password reset + private func autoLogin() async { + // Use the email from the reset flow as the username + let username = email + + guard !username.isEmpty else { + // If we don't have the email (e.g., deep link flow), fall back to manual login + self.isLoading = false + self.successMessage = "Password reset successfully! You can now log in with your new password." + self.currentStep = .success + return + } + + do { + let loginResult = try await APILayer.shared.login( + request: LoginRequest(username: username, password: newPassword) + ) + + if let success = loginResult as? ApiResultSuccess, + let response = success.data { + let isVerified = response.user.verified + + // Initialize lookups + _ = try? await APILayer.shared.initializeLookups() + + self.isLoading = false + + // Call the login success callback + self.onLoginSuccess?(isVerified) + } else if let error = loginResult as? ApiResultError { + // Auto-login failed, fall back to manual login + print("Auto-login failed: \(error.message)") + self.isLoading = false + self.successMessage = "Password reset successfully! You can now log in with your new password." + self.currentStep = .success + } + } catch { + // Auto-login failed, fall back to manual login + print("Auto-login error: \(error.localizedDescription)") + self.isLoading = false + self.successMessage = "Password reset successfully! You can now log in with your new password." + self.currentStep = .success + } + } + /// Navigate to next step func moveToNextStep() { switch currentStep { @@ -163,6 +213,8 @@ class PasswordResetViewModel: ObservableObject { case .verifyCode: currentStep = .resetPassword case .resetPassword: + currentStep = .loggingIn + case .loggingIn: currentStep = .success case .success: break @@ -178,7 +230,7 @@ class PasswordResetViewModel: ObservableObject { currentStep = .requestCode case .resetPassword: currentStep = .verifyCode - case .success: + case .loggingIn, .success: break } } diff --git a/iosApp/iosApp/PasswordReset/ResetPasswordView.swift b/iosApp/iosApp/PasswordReset/ResetPasswordView.swift index 86be831..db7fbc6 100644 --- a/iosApp/iosApp/PasswordReset/ResetPasswordView.swift +++ b/iosApp/iosApp/PasswordReset/ResetPasswordView.swift @@ -182,8 +182,11 @@ struct ResetPasswordView: View { }) { HStack { Spacer() - if viewModel.isLoading { + if viewModel.isLoading || viewModel.currentStep == .loggingIn { ProgressView() + .padding(.trailing, 8) + Text(viewModel.currentStep == .loggingIn ? "Logging in..." : "Resetting...") + .fontWeight(.semibold) } else { Label("Reset Password", systemImage: "lock.shield.fill") .fontWeight(.semibold) @@ -191,9 +194,9 @@ struct ResetPasswordView: View { Spacer() } } - .disabled(!isFormValid || viewModel.isLoading) + .disabled(!isFormValid || viewModel.isLoading || viewModel.currentStep == .loggingIn) - // Return to Login Button (shown after success) + // Return to Login Button (shown only if auto-login fails) if viewModel.currentStep == .success { Button(action: { viewModel.reset() @@ -217,8 +220,8 @@ struct ResetPasswordView: View { .navigationBarBackButtonHidden(true) .toolbar { ToolbarItem(placement: .navigationBarLeading) { - // Only show back button if not from deep link - if viewModel.resetToken == nil || viewModel.currentStep != .resetPassword { + // Only show back button if not from deep link and not logging in + if (viewModel.resetToken == nil || viewModel.currentStep != .resetPassword) && viewModel.currentStep != .loggingIn { Button(action: { if viewModel.currentStep == .success { viewModel.reset()