- 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>
316 lines
10 KiB
Swift
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
|
|
}
|
|
}
|