250 lines
8.9 KiB
Swift
250 lines
8.9 KiB
Swift
import SwiftUI
|
|
|
|
struct CredentialsView: View {
|
|
let bank: String
|
|
|
|
@Environment(AppViewModel.self) private var app
|
|
@Environment(\.dismiss) private var dismiss
|
|
|
|
@State private var vm = LoginViewModel()
|
|
|
|
@State private var username = ""
|
|
@State private var password = ""
|
|
@State private var otpSeed = ""
|
|
@State private var totpCode = "" // Fahipay 2-step verification code
|
|
|
|
@State private var isLoading = false
|
|
@State private var errorText = ""
|
|
@State private var showOtpPreview = false
|
|
|
|
private var isFahipay: Bool { bank == "FAHIPAY" }
|
|
private var awaitingFahipayTotp: Bool {
|
|
if case .fahipayNeedTotp = vm.state { return true }
|
|
return false
|
|
}
|
|
|
|
private var resolvedSeed: String { LoginViewModel.resolveOtpSeed(otpSeed) }
|
|
|
|
private var canSubmit: Bool {
|
|
let u = username.trimmingCharacters(in: .whitespaces)
|
|
let p = password
|
|
if awaitingFahipayTotp { return totpCode.count == 6 }
|
|
if isFahipay { return !u.isEmpty && !p.isEmpty }
|
|
return !u.isEmpty && !p.isEmpty && LoginViewModel.isValidOtpSeed(otpSeed)
|
|
}
|
|
|
|
// MARK: - Body
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(spacing: 24) {
|
|
bankHeader
|
|
|
|
credentialFields
|
|
|
|
if !isFahipay && !resolvedSeed.isEmpty && LoginViewModel.isValidOtpSeed(otpSeed) {
|
|
OtpPreviewView(seed: resolvedSeed)
|
|
.padding(.horizontal, 4)
|
|
.transition(.move(edge: .top).combined(with: .opacity))
|
|
}
|
|
|
|
if !errorText.isEmpty {
|
|
Text(errorText)
|
|
.font(.callout)
|
|
.foregroundStyle(.red)
|
|
.multilineTextAlignment(.center)
|
|
.transition(.opacity)
|
|
}
|
|
|
|
Button(submitLabel) { attemptLogin() }
|
|
.buttonStyle(.borderedProminent)
|
|
.controlSize(.large)
|
|
.disabled(!canSubmit || isLoading)
|
|
.padding(.horizontal, 4)
|
|
|
|
if isLoading {
|
|
ProgressView()
|
|
}
|
|
}
|
|
.padding(24)
|
|
}
|
|
.navigationTitle(bankTitle)
|
|
.navigationBarTitleDisplayMode(.large)
|
|
.animation(.easeInOut(duration: 0.2), value: resolvedSeed)
|
|
.animation(.easeInOut(duration: 0.2), value: errorText)
|
|
}
|
|
|
|
// MARK: - Bank header
|
|
|
|
@ViewBuilder
|
|
private var bankHeader: some View {
|
|
VStack(spacing: 6) {
|
|
Text(bankDescription)
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
.multilineTextAlignment(.center)
|
|
}
|
|
}
|
|
|
|
// MARK: - Fields
|
|
|
|
@ViewBuilder
|
|
private var credentialFields: some View {
|
|
VStack(spacing: 14) {
|
|
if awaitingFahipayTotp {
|
|
// Fahipay 2-step: only show the TOTP field
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text("Authenticator Code")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
TextField("6-digit code", text: $totpCode)
|
|
.keyboardType(.numberPad)
|
|
.textContentType(.oneTimeCode)
|
|
.padding(12)
|
|
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 10))
|
|
}
|
|
|
|
Text("Open your authenticator app and enter the 6-digit code for Fahipay.")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.multilineTextAlignment(.center)
|
|
|
|
} else {
|
|
// Username
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text(isFahipay ? "ID Card Number" : "Username")
|
|
.font(.caption).foregroundStyle(.secondary)
|
|
TextField(isFahipay ? "A000000" : "Enter username", text: $username)
|
|
.textContentType(.username)
|
|
.autocorrectionDisabled()
|
|
.textInputAutocapitalization(.never)
|
|
.padding(12)
|
|
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 10))
|
|
.disabled(awaitingFahipayTotp)
|
|
}
|
|
|
|
// Password
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text("Password")
|
|
.font(.caption).foregroundStyle(.secondary)
|
|
SecureField("Enter password", text: $password)
|
|
.textContentType(.password)
|
|
.padding(12)
|
|
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 10))
|
|
.disabled(awaitingFahipayTotp)
|
|
}
|
|
|
|
// OTP seed (MIB + BML only)
|
|
if !isFahipay {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text("Authenticator Secret")
|
|
.font(.caption).foregroundStyle(.secondary)
|
|
TextField("Paste TOTP secret or otpauth:// URI", text: $otpSeed)
|
|
.autocorrectionDisabled()
|
|
.textInputAutocapitalization(.never)
|
|
.textContentType(.none)
|
|
.padding(12)
|
|
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 10))
|
|
}
|
|
|
|
Text("This is the secret from your authenticator app — not the 6-digit code.")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.multilineTextAlignment(.center)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Login logic
|
|
|
|
private func attemptLogin() {
|
|
withAnimation { errorText = "" }
|
|
isLoading = true
|
|
|
|
Task {
|
|
do {
|
|
switch bank {
|
|
case "MIB":
|
|
let accounts = try await vm.loginMib(
|
|
username: username.trimmingCharacters(in: .whitespaces),
|
|
password: password,
|
|
otpSeed: resolvedSeed
|
|
)
|
|
await MainActor.run { finish(accounts: accounts) }
|
|
|
|
case "BML":
|
|
let accounts = try await vm.loginBml(
|
|
username: username.trimmingCharacters(in: .whitespaces),
|
|
password: password,
|
|
otpSeed: resolvedSeed
|
|
)
|
|
await MainActor.run { finish(accounts: accounts) }
|
|
|
|
case "FAHIPAY":
|
|
if awaitingFahipayTotp {
|
|
try await vm.verifyFahipayTotp(totpCode)
|
|
await MainActor.run { finish(accounts: []) }
|
|
} else {
|
|
let step = try await vm.loginFahipay(
|
|
idCard: username.trimmingCharacters(in: .whitespaces),
|
|
password: password
|
|
)
|
|
await MainActor.run {
|
|
if step.twoFactorRequired {
|
|
withAnimation { vm.state = .fahipayNeedTotp }
|
|
} else if let authId = step.authId {
|
|
vm.completeFahipayLogin(authId: authId)
|
|
finish(accounts: [])
|
|
}
|
|
}
|
|
}
|
|
|
|
default: break
|
|
}
|
|
} catch {
|
|
await MainActor.run {
|
|
withAnimation { errorText = error.localizedDescription }
|
|
}
|
|
}
|
|
await MainActor.run { isLoading = false }
|
|
}
|
|
}
|
|
|
|
private func finish(accounts: [BankAccount]) {
|
|
app.loginSucceeded()
|
|
}
|
|
|
|
// MARK: - Computed strings
|
|
|
|
private var bankTitle: String {
|
|
switch bank {
|
|
case "MIB": return "MIB Faisanet"
|
|
case "BML": return "Bank of Maldives"
|
|
case "FAHIPAY": return "Fahipay Wallet"
|
|
default: return bank
|
|
}
|
|
}
|
|
|
|
private var bankDescription: String {
|
|
switch bank {
|
|
case "MIB": return "Sign in with your Faisanet username and password.\nPaste your TOTP secret from your authenticator app."
|
|
case "BML": return "Sign in with your BML Internet Banking credentials.\nAll personal profiles will be activated automatically."
|
|
case "FAHIPAY": return "Sign in with your Fahipay ID card number and password."
|
|
default: return ""
|
|
}
|
|
}
|
|
|
|
private var submitLabel: String {
|
|
if awaitingFahipayTotp { return "Verify Code" }
|
|
return "Sign In"
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
NavigationStack {
|
|
CredentialsView(bank: "MIB")
|
|
.environment(AppViewModel())
|
|
}
|
|
}
|