Files
honeyDueKMP/iosApp/iosApp/PasswordReset/PasswordResetViewModel.swift
Trey t 2730c94e4d Add comprehensive error message parsing to prevent raw JSON display
- Created ErrorMessageParser utility for both iOS (Swift) and Android (Kotlin)
- Parser detects JSON-formatted error messages and extracts user-friendly text
- Identifies when data objects (not errors) are returned and provides generic messages
- Updated all API error handling to pass raw error bodies instead of concatenating
- Applied ErrorMessageParser across all ViewModels and screens on both platforms
- Fixed ContractorApi and DocumentApi to not concatenate error bodies with messages
- Updated ApiResultHandler to automatically parse all error messages
- Error messages now show "Request failed. Please check your input and try again." instead of raw JSON

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-14 22:59:42 -06:00

316 lines
10 KiB
Swift

import Foundation
import ComposeApp
import Combine
enum PasswordResetStep {
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
}
@MainActor
class PasswordResetViewModel: ObservableObject {
// MARK: - Published Properties
@Published var email: String = ""
@Published var code: String = ""
@Published var newPassword: String = ""
@Published var confirmPassword: String = ""
@Published var isLoading: Bool = false
@Published var errorMessage: String?
@Published var successMessage: String?
@Published var currentStep: PasswordResetStep = .requestCode
@Published var resetToken: String?
// MARK: - Private Properties
private let sharedViewModel: ComposeApp.AuthViewModel
private var cancellables = Set<AnyCancellable>()
// MARK: - Initialization
init(resetToken: String? = nil) {
self.sharedViewModel = ComposeApp.AuthViewModel()
// If we have a reset token from deep link, skip to password reset step
if let token = resetToken {
self.resetToken = token
self.currentStep = .resetPassword
}
}
// MARK: - Public Methods
/// Step 1: Request password reset code
func requestPasswordReset() {
guard !email.isEmpty else {
errorMessage = "Email is required"
return
}
guard isValidEmail(email) else {
errorMessage = "Please enter a valid email address"
return
}
isLoading = true
errorMessage = nil
sharedViewModel.forgotPassword(email: email)
Task {
for await state in sharedViewModel.forgotPasswordState {
if state is ApiResultLoading {
await MainActor.run {
self.isLoading = true
}
} else if let success = state as? ApiResultSuccess<ForgotPasswordResponse> {
await MainActor.run {
self.handleRequestSuccess(response: success)
}
sharedViewModel.resetForgotPasswordState()
break
} else if let error = state as? ApiResultError {
await MainActor.run {
self.handleApiError(errorResult: error)
}
sharedViewModel.resetForgotPasswordState()
break
}
}
}
}
/// Step 2: Verify reset code
func verifyResetCode() {
guard !code.isEmpty else {
errorMessage = "Verification code is required"
return
}
guard code.count == 6 else {
errorMessage = "Please enter a 6-digit code"
return
}
isLoading = true
errorMessage = nil
sharedViewModel.verifyResetCode(email: email, code: code)
Task {
for await state in sharedViewModel.verifyResetCodeState {
if state is ApiResultLoading {
await MainActor.run {
self.isLoading = true
}
} else if let success = state as? ApiResultSuccess<VerifyResetCodeResponse> {
await MainActor.run {
self.handleVerifySuccess(response: success)
}
sharedViewModel.resetVerifyResetCodeState()
break
} else if let error = state as? ApiResultError {
await MainActor.run {
self.handleApiError(errorResult: error)
}
sharedViewModel.resetVerifyResetCodeState()
break
}
}
}
}
/// Step 3: Reset password
func resetPassword() {
guard !newPassword.isEmpty else {
errorMessage = "New password is required"
return
}
guard newPassword.count >= 8 else {
errorMessage = "Password must be at least 8 characters"
return
}
guard !confirmPassword.isEmpty else {
errorMessage = "Please confirm your password"
return
}
guard newPassword == confirmPassword else {
errorMessage = "Passwords do not match"
return
}
guard isValidPassword(newPassword) else {
errorMessage = "Password must contain both letters and numbers"
return
}
guard let token = resetToken else {
errorMessage = "Invalid reset token. Please start over."
return
}
isLoading = true
errorMessage = nil
sharedViewModel.resetPassword(resetToken: token, newPassword: newPassword, confirmPassword: confirmPassword)
Task {
for await state in sharedViewModel.resetPasswordState {
if state is ApiResultLoading {
await MainActor.run {
self.isLoading = true
}
} else if let success = state as? ApiResultSuccess<ResetPasswordResponse> {
await MainActor.run {
self.handleResetSuccess(response: success)
}
sharedViewModel.resetResetPasswordState()
break
} else if let error = state as? ApiResultError {
await MainActor.run {
self.handleApiError(errorResult: error)
}
sharedViewModel.resetResetPasswordState()
break
}
}
}
}
/// Navigate to next step
func moveToNextStep() {
switch currentStep {
case .requestCode:
currentStep = .verifyCode
case .verifyCode:
currentStep = .resetPassword
case .resetPassword:
currentStep = .success
case .success:
break
}
}
/// Navigate to previous step
func moveToPreviousStep() {
switch currentStep {
case .requestCode:
break
case .verifyCode:
currentStep = .requestCode
case .resetPassword:
currentStep = .verifyCode
case .success:
break
}
}
/// Reset all state
func reset() {
email = ""
code = ""
newPassword = ""
confirmPassword = ""
resetToken = nil
errorMessage = nil
successMessage = nil
currentStep = .requestCode
isLoading = false
}
func clearError() {
errorMessage = nil
}
func clearSuccess() {
successMessage = nil
}
// MARK: - Private Methods
@MainActor
private func handleRequestSuccess(response: ApiResultSuccess<ForgotPasswordResponse>) {
isLoading = false
successMessage = "Check your email for a 6-digit verification code"
// Automatically move to next step after short delay
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
self.successMessage = nil
self.currentStep = .verifyCode
}
print("Password reset requested for: \(email)")
}
@MainActor
private func handleVerifySuccess(response: ApiResultSuccess<VerifyResetCodeResponse>) {
if let token = response.data?.resetToken {
self.resetToken = token
self.isLoading = false
self.successMessage = "Code verified! Now set your new password"
// Automatically move to next step after short delay
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
self.successMessage = nil
self.currentStep = .resetPassword
}
print("Code verified, reset token received")
} else {
self.isLoading = false
self.errorMessage = "Failed to verify code"
}
}
@MainActor
private func handleResetSuccess(response: ApiResultSuccess<ResetPasswordResponse>) {
isLoading = false
successMessage = "Password reset successfully! You can now log in with your new password."
currentStep = .success
print("Password reset successful")
}
@MainActor
private func handleApiError(errorResult: ApiResultError) {
self.isLoading = false
// Handle specific error codes
if errorResult.code?.intValue == 429 {
self.errorMessage = "Too many requests. Please try again later."
} else if errorResult.code?.intValue == 400 {
// Parse error message from backend
let message = errorResult.message
if message.contains("expired") {
self.errorMessage = "Reset code has expired. Please request a new one."
} else if message.contains("attempts") {
self.errorMessage = "Too many failed attempts. Please request a new reset code."
} else if message.contains("Invalid") && message.contains("token") {
self.errorMessage = "Invalid or expired reset token. Please start over."
} else {
self.errorMessage = ErrorMessageParser.parse(message)
}
} else {
self.errorMessage = ErrorMessageParser.parse(errorResult.message)
}
print("API Error: \(errorResult.message)")
}
// MARK: - Validation Helpers
private func isValidEmail(_ email: String) -> Bool {
let emailRegex = "[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}"
let emailPredicate = NSPredicate(format: "SELF MATCHES %@", emailRegex)
return emailPredicate.evaluate(with: email)
}
private func isValidPassword(_ password: String) -> Bool {
let hasLetter = password.range(of: "[A-Za-z]", options: .regularExpression) != nil
let hasNumber = password.range(of: "[0-9]", options: .regularExpression) != nil
return hasLetter && hasNumber
}
}