import Foundation import Observation @Observable final class LoginViewModel { enum LoginState { case idle case loading(String) case fahipayNeedTotp case error(String) } var state: LoginState = .idle // Persisted across navigation for Fahipay 2-step flow private var fahipayFlow: FahipayLoginFlow? private var fahipayIdCard = "" private var fahipayPassword = "" // MARK: - MIB func loginMib(username: String, password: String, otpSeed: String) async throws -> [BankAccount] { let hash = MibCrypto.hashPassword(password) let flow = MibLoginFlow() let accounts = try await flow.login(username: username, passwordHash: hash, otpSeed: otpSeed) try? CredentialStore.shared.save(hash, forKey: CredentialStore.Keys.mibPassword(username)) try? CredentialStore.shared.save(otpSeed, forKey: CredentialStore.Keys.mibOtpSeed(username)) try? CredentialStore.shared.addLoginId(username, toBank: "MIB") return accounts } // MARK: - BML func loginBml(username: String, password: String, otpSeed: String) async throws -> [BankAccount] { let loginTag = "bml_\(username)" let flow = BmlLoginFlow() let profiles = try await flow.login(username: username, password: password, otpSeed: otpSeed) guard !profiles.isEmpty else { throw BmlError.loginFailed("No profiles found for this account") } var accumulated: [BankAccount] = [] for profile in profiles where profile.profileType != "business" { let result = try await flow.activateProfile(profile, loginTag: loginTag) if case .success(let session, let accs) = result { accumulated += accs saveSession(session, profileId: profile.profileId) } } try? CredentialStore.shared.save(password, forKey: CredentialStore.Keys.bmlPassword(username)) try? CredentialStore.shared.save(otpSeed, forKey: CredentialStore.Keys.bmlOtpSeed(username)) try? CredentialStore.shared.addLoginId(username, toBank: "BML") let ids = profiles.map { $0.profileId } try? CredentialStore.shared.saveStringArray(ids, forKey: CredentialStore.Keys.bmlProfiles(username)) return accumulated } private func saveSession(_ session: BmlSession, profileId: String) { try? CredentialStore.shared.save(session.accessToken, forKey: CredentialStore.Keys.bmlAccessToken(profileId)) try? CredentialStore.shared.save(session.deviceId, forKey: CredentialStore.Keys.bmlDeviceId(profileId)) if !session.refreshToken.isEmpty { try? CredentialStore.shared.save(session.refreshToken, forKey: CredentialStore.Keys.bmlRefreshToken(profileId)) } if session.expiresAt > 0 { try? CredentialStore.shared.save(String(session.expiresAt), forKey: CredentialStore.Keys.bmlExpTime(profileId)) } } // MARK: - Fahipay step 1 func loginFahipay(idCard: String, password: String) async throws -> FahipayLoginStep { let flow = FahipayLoginFlow() fahipayFlow = flow fahipayIdCard = idCard fahipayPassword = password return try await flow.login(idCard: idCard, password: password, deviceUuid: fahipayDeviceUuid()) } // MARK: - Fahipay step 2 (TOTP) func verifyFahipayTotp(_ code: String) async throws { guard let flow = fahipayFlow else { throw FahipayError.sessionExpired } let authId = try await flow.verifyTotp(code: code, deviceUuid: fahipayDeviceUuid()) let cookie = flow.getSessionCookieValue() ?? "" saveFahipaySession(authId: authId, sessionCookie: cookie) } // Called when no 2FA is needed (authId already returned from step 1) func completeFahipayLogin(authId: String) { let cookie = fahipayFlow?.getSessionCookieValue() ?? "" saveFahipaySession(authId: authId, sessionCookie: cookie) } private func saveFahipaySession(authId: String, sessionCookie: String) { // Use authId as the persisted login identifier let loginId = authId try? CredentialStore.shared.save(fahipayIdCard, forKey: CredentialStore.Keys.fahipayIdCard(loginId)) try? CredentialStore.shared.save(fahipayPassword, forKey: CredentialStore.Keys.fahipayPassword(loginId)) try? CredentialStore.shared.save(sessionCookie, forKey: CredentialStore.Keys.fahipaySessionCookie(loginId)) try? CredentialStore.shared.save(authId, forKey: CredentialStore.Keys.fahipayAuthId(loginId)) try? CredentialStore.shared.addLoginId(loginId, toBank: "FAHIPAY") } // MARK: - Helpers private func fahipayDeviceUuid() -> String { let key = "fahipay_device_uuid" if let existing = CredentialStore.shared.load(forKey: key) { return existing } let uuid = FahipayLoginFlow.generateDeviceUuid() try? CredentialStore.shared.save(uuid, forKey: key) return uuid } // Strips otpauth:// URI and whitespace/dashes, uppercases — mirrors Android resolveOtpSeed static func resolveOtpSeed(_ raw: String) -> String { var secret = raw.trimmingCharacters(in: .whitespaces) if secret.lowercased().hasPrefix("otpauth://totp/"), let url = URLComponents(string: secret), let s = url.queryItems?.first(where: { $0.name == "secret" })?.value { secret = s } return secret.replacingOccurrences(of: " ", with: "") .replacingOccurrences(of: "-", with: "") .uppercased() } // Returns true if the string looks like a valid Base32 OTP seed (not a 6-digit code) static func isValidOtpSeed(_ raw: String) -> Bool { let seed = resolveOtpSeed(raw) if seed.isEmpty { return false } if seed.count == 6 && seed.allSatisfy({ $0.isNumber }) { return false } // reject plain codes let base32 = CharacterSet(charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567=") return seed.unicodeScalars.allSatisfy { base32.contains($0) } } }