ConfigView.swift (35250B)
1 /* 2 * This file is part of GNU Taler 3 * (C) 2026 Bohdan, Volodymyr Potuzhnyi 4 * 5 * GNU Taler is free software; you can redistribute it and/or modify it under the 6 * terms of the GNU General Public License as published by the Free Software 7 * Foundation; either version 3, or (at your option) any later version. 8 * 9 * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY 10 * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR 11 * A PARTICULAR PURPOSE. See the GNU General Public License for more details. 12 * 13 * You should have received a copy of the GNU General Public License along with 14 * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> 15 */ 16 17 import SwiftUI 18 19 private let MFA_CODE_DIGITS = 8 20 21 struct ChallengeSelectionData: Identifiable { 22 let id = UUID() 23 let challenges: [MfaChallenge] 24 } 25 26 private enum ConfigMode: String, CaseIterable { 27 case manual 28 case qr 29 } 30 31 struct ConfigView: View { 32 @EnvironmentObject var configManager: ConfigManager 33 let onConfigured: () -> Void 34 35 @State private var configMode: ConfigMode = .manual 36 @State private var merchantUrlText: String = "" 37 @State private var usernameText: String = "" 38 @State private var tokenText: String = "" 39 @State private var saveToken: Bool = true 40 @State private var isSubmitting: Bool = false 41 @State private var showPassword: Bool = false 42 @State private var showInstanceInfo: Bool = false 43 @State private var errorMessage: String? = nil 44 @FocusState private var focusedField: ConfigField? 45 46 @State private var challengeSelectionData: ChallengeSelectionData? = nil 47 @State private var mfaChallenges: [MfaChallenge] = [] 48 @State private var mfaCombiAnd = false 49 @State private var tanChallenge: MfaChallenge? = nil 50 @State private var tanDigits: [String] = Array(repeating: "", count: MFA_CODE_DIGITS) 51 @State private var tanErrorMessage: String? = nil 52 @State private var mfaSolvedChallengeIds: [String] = [] 53 @State private var mfaBaseUrl: String = "" 54 @State private var mfaUsername: String = "" 55 @State private var mfaInitialSecret: String = "" 56 @State private var mfaRemainingChallenges: [MfaChallenge] = [] 57 58 private enum ConfigField: Hashable { 59 case merchantUrl, username, token 60 } 61 62 var body: some View { 63 VStack(spacing: 0) { 64 Picker("", selection: $configMode) { 65 Text(S.configModeManual).tag(ConfigMode.manual) 66 Text(S.configModeQr).tag(ConfigMode.qr) 67 } 68 .pickerStyle(.segmented) 69 .padding(.horizontal, 20) 70 .padding(.top, 12) 71 72 switch configMode { 73 case .manual: 74 manualConfigBody 75 case .qr: 76 qrConfigBody 77 } 78 } 79 .navigationTitle(S.configLabel) 80 .navigationBarTitleDisplayMode(.inline) 81 .onAppear { initializeState() } 82 .onChange(of: configManager.configResult) { result in 83 guard isSubmitting else { return } 84 switch result { 85 case .success: 86 isSubmitting = false 87 errorMessage = nil 88 onConfigured() 89 case .error(let msg): 90 isSubmitting = false 91 errorMessage = msg 92 case .none: 93 break 94 } 95 } 96 .sheet(item: $challengeSelectionData) { data in 97 challengeSelectionContent(challenges: data.challenges) 98 } 99 .sheet(item: $tanChallenge) { challenge in 100 TanInputSheet( 101 challenge: challenge, 102 digits: $tanDigits, 103 errorMessage: $tanErrorMessage, 104 onConfirm: { confirmTanCode() }, 105 onCancel: { 106 tanChallenge = nil 107 isSubmitting = false 108 }, 109 onResend: { resendChallenge() } 110 ) 111 } 112 } 113 114 // MARK: - QR Config Mode 115 116 private var qrConfigBody: some View { 117 VStack(spacing: 16) { 118 Text(S.configQrHint) 119 .font(.subheadline) 120 .foregroundColor(.posOnSurfaceVariant) 121 .multilineTextAlignment(.center) 122 .padding(.horizontal, 20) 123 .padding(.top, 16) 124 125 QRScannerView { result in 126 handleQRResult(result) 127 } 128 .frame(maxWidth: .infinity, maxHeight: .infinity) 129 .cornerRadius(12) 130 .padding(.horizontal, 20) 131 132 if let errorMessage = errorMessage { 133 Text(errorMessage) 134 .font(.caption) 135 .foregroundColor(.posError) 136 .padding(.horizontal, 20) 137 } 138 139 Spacer().frame(height: 20) 140 } 141 } 142 143 private func handleQRResult(_ result: Result<String, Error>) { 144 switch result { 145 case .success(let scannedString): 146 if let parsed = parseTalerPosUri(scannedString) { 147 errorMessage = nil 148 isSubmitting = true 149 let configUrl = parsed.configUrl 150 let token = parsed.token 151 configManager.savePassword = true 152 configManager.fetchConfig(url: configUrl, token: token, save: true) 153 } else { 154 errorMessage = S.configQrError 155 } 156 case .failure: 157 errorMessage = S.configCameraPermission 158 } 159 } 160 161 private struct PosSetupUri { 162 let configUrl: String 163 let token: String 164 } 165 166 private func parseTalerPosUri(_ raw: String) -> PosSetupUri? { 167 guard raw.hasPrefix("taler-pos://") else { return nil } 168 let withScheme = "https://" + raw.dropFirst("taler-pos://".count) 169 guard let url = URL(string: withScheme) else { return nil } 170 let host = url.host ?? "" 171 let port = url.port.map { ":\($0)" } ?? "" 172 let path = url.path 173 let configUrl = "https://\(host)\(port)\(path)" 174 let token = URLComponents(url: url, resolvingAgainstBaseURL: false)? 175 .queryItems?.first(where: { $0.name == "token" })?.value ?? "" 176 guard !token.isEmpty else { return nil } 177 return PosSetupUri(configUrl: configUrl, token: token) 178 } 179 180 // MARK: - Manual Config Mode 181 182 private var manualConfigBody: some View { 183 ScrollView { 184 VStack(alignment: .leading, spacing: 16) { 185 VStack(alignment: .leading, spacing: 4) { 186 Text(S.configMerchantUrl) 187 .font(.caption) 188 .foregroundColor(.posOnSurfaceVariant) 189 HStack(spacing: 8) { 190 Text("https://") 191 .foregroundColor(.posOnSurfaceVariant) 192 TextField("my.taler-ops.ch", text: $merchantUrlText) 193 .keyboardType(.URL) 194 .autocapitalization(.none) 195 .disableAutocorrection(true) 196 .focused($focusedField, equals: .merchantUrl) 197 .submitLabel(.next) 198 .onSubmit { focusedField = .username } 199 .padding(10) 200 .overlay( 201 RoundedRectangle(cornerRadius: 8) 202 .stroke(Color.posOutline, lineWidth: 1) 203 ) 204 } 205 } 206 207 VStack(alignment: .leading, spacing: 4) { 208 Text(S.configUsername) 209 .font(.caption) 210 .foregroundColor(.posOnSurfaceVariant) 211 TextField("Instance name", text: $usernameText) 212 .autocapitalization(.none) 213 .disableAutocorrection(true) 214 .focused($focusedField, equals: .username) 215 .submitLabel(.next) 216 .onSubmit { focusedField = .token } 217 .padding(10) 218 .overlay( 219 RoundedRectangle(cornerRadius: 8) 220 .stroke(Color.posOutline, lineWidth: 1) 221 ) 222 } 223 224 VStack(alignment: .leading, spacing: 4) { 225 Text(S.configPassword) 226 .font(.caption) 227 .foregroundColor(.posOnSurfaceVariant) 228 HStack { 229 Group { 230 if showPassword { 231 TextField("", text: $tokenText) 232 } else { 233 SecureField("", text: $tokenText) 234 } 235 } 236 .focused($focusedField, equals: .token) 237 .submitLabel(.done) 238 .onSubmit { focusedField = nil } 239 240 Button { 241 showPassword.toggle() 242 } label: { 243 Image(systemName: showPassword ? "eye.fill" : "eye.slash.fill") 244 .foregroundColor(.posOnSurfaceVariant) 245 } 246 } 247 .padding(10) 248 .overlay( 249 RoundedRectangle(cornerRadius: 8) 250 .stroke(Color.posOutline, lineWidth: 1) 251 ) 252 } 253 254 HStack { 255 Toggle(S.configSavePassword, isOn: $saveToken) 256 .labelsHidden() 257 Text(S.configSavePassword) 258 .font(.subheadline) 259 260 Spacer() 261 262 Button(action: submitManualConfig) { 263 if isSubmitting { 264 ProgressView() 265 .padding(.horizontal, 8) 266 } else { 267 Text(S.configOk) 268 } 269 } 270 .buttonStyle(.borderedProminent) 271 .tint(.posPrimary) 272 .foregroundColor(.posOnPrimary) 273 .disabled(isSubmitting) 274 } 275 276 if let errorMessage = errorMessage { 277 Text(errorMessage) 278 .font(.caption) 279 .foregroundColor(.posError) 280 .padding(.top, 4) 281 } 282 283 instanceInfoBox 284 } 285 .padding(20) 286 } 287 } 288 289 // MARK: - Instance info box 290 291 private var instanceInfoBox: some View { 292 VStack(alignment: .leading, spacing: 0) { 293 Button { 294 withAnimation(.easeInOut(duration: 0.2)) { showInstanceInfo.toggle() } 295 } label: { 296 HStack { 297 Text("No instance?") 298 .font(.subheadline) 299 .fontWeight(.medium) 300 .foregroundColor(.posOnSurface) 301 Spacer() 302 Image(systemName: showInstanceInfo ? "chevron.up" : "chevron.down") 303 .font(.caption) 304 .foregroundColor(.posOnSurfaceVariant) 305 } 306 .padding(12) 307 } 308 .buttonStyle(.plain) 309 310 if showInstanceInfo { 311 VStack(alignment: .leading, spacing: 16) { 312 Divider() 313 314 VStack(alignment: .leading, spacing: 4) { 315 (Text("1.") 316 .fontWeight(.semibold) 317 .foregroundColor(.posOnSurface) + 318 Text(" Check out the setup tutorials at") 319 .foregroundColor(.posOnSurfaceVariant)) 320 .font(.subheadline) 321 322 Link("tutorials.taler.net", destination: URL(string: "https://tutorials.taler.net/merchant/merchant-backoffice/obtain-instance/index")!) 323 .font(.subheadline) 324 .fontWeight(.semibold) 325 } 326 327 VStack(alignment: .leading, spacing: 8) { 328 Text("2.") 329 .fontWeight(.semibold) 330 .foregroundColor(.posOnSurface) + 331 Text(" You can try the app with the public demo instance. Note that this instance is shared — anyone can modify its data at any time. Use it only to explore the app and its features.") 332 .foregroundColor(.posOnSurfaceVariant) 333 334 Button { 335 merchantUrlText = "backend.demo.taler.net" 336 usernameText = "sandbox" 337 tokenText = "sandbox" 338 } label: { 339 Text("Fill demo credentials") 340 .font(.subheadline) 341 .fontWeight(.medium) 342 .padding(.vertical, 8) 343 .padding(.horizontal, 14) 344 .background(Color.posSecondaryContainer) 345 .foregroundColor(.posOnSecondaryContainer) 346 .clipShape(RoundedRectangle(cornerRadius: 20)) 347 } 348 .buttonStyle(.plain) 349 } 350 .font(.subheadline) 351 352 VStack(alignment: .leading, spacing: 8) { 353 Text("3.") 354 .fontWeight(.semibold) 355 .foregroundColor(.posOnSurface) + 356 Text(" If you are reviewing this app, you most likely already have access to a controlled test user. To prefill the merchant portal URL, press the button below.") 357 .foregroundColor(.posOnSurfaceVariant) 358 359 Button { 360 merchantUrlText = "backend.demo.taler.net" 361 usernameText = "" 362 tokenText = "" 363 } label: { 364 Text("Fill review URL") 365 .font(.subheadline) 366 .fontWeight(.medium) 367 .padding(.vertical, 8) 368 .padding(.horizontal, 14) 369 .background(Color.posSecondaryContainer) 370 .foregroundColor(.posOnSecondaryContainer) 371 .clipShape(RoundedRectangle(cornerRadius: 20)) 372 } 373 .buttonStyle(.plain) 374 } 375 .font(.subheadline) 376 } 377 .padding(.horizontal, 12) 378 .padding(.bottom, 12) 379 } 380 } 381 .background(Color.posSurface) 382 .clipShape(RoundedRectangle(cornerRadius: 12)) 383 .overlay( 384 RoundedRectangle(cornerRadius: 12) 385 .stroke(Color.posOutline, lineWidth: 1) 386 ) 387 } 388 389 // MARK: - Challenge selection 390 391 private func challengeSelectionContent(challenges: [MfaChallenge]) -> some View { 392 NavigationStack { 393 List { 394 Section { 395 Text(S.mfaChooseTitle) 396 .font(.subheadline) 397 .foregroundColor(.posOnSurfaceVariant) 398 .listRowBackground(Color.clear) 399 } 400 401 Section { 402 ForEach(challenges, id: \.challengeId) { challenge in 403 Button { 404 challengeSelectionData = nil 405 handleSelectedChallenge(challenge) 406 } label: { 407 HStack(spacing: 12) { 408 Image(systemName: challenge.tanChannel == .sms ? "message.fill" : "envelope.fill") 409 .foregroundColor(.posPrimary) 410 .frame(width: 28) 411 VStack(alignment: .leading, spacing: 2) { 412 Text(challenge.tanChannel == .sms ? "SMS" : "Email") 413 .font(.body.weight(.semibold)) 414 .foregroundColor(.posOnSurface) 415 Text(challenge.tanInfo) 416 .font(.subheadline) 417 .foregroundColor(.posOnSurfaceVariant) 418 } 419 Spacer() 420 Image(systemName: "chevron.right") 421 .font(.caption) 422 .foregroundColor(.posOnSurfaceVariant) 423 } 424 .padding(.vertical, 4) 425 } 426 } 427 } 428 } 429 .navigationTitle(S.mfaChallengeTitle) 430 .navigationBarTitleDisplayMode(.inline) 431 .toolbar { 432 ToolbarItem(placement: .cancellationAction) { 433 Button(S.mfaCancel) { 434 challengeSelectionData = nil 435 isSubmitting = false 436 } 437 } 438 } 439 } 440 .presentationDetents([.medium, .large]) 441 } 442 443 private func initializeState() { 444 merchantUrlText = configManager.merchantUrl 445 saveToken = configManager.savePassword 446 447 if merchantUrlText.isEmpty { 448 merchantUrlText = "my.taler-ops.ch" 449 } else { 450 sanitizeMerchantUrlAndUpdateFields() 451 } 452 453 if ScreenshotController.isActive { 454 if ScreenshotController.activeScenario == .login { 455 usernameText = "pos" 456 tokenText = "secret-password" 457 } 458 if ScreenshotController.showMfaSelect { 459 usernameText = "pos" 460 tokenText = "secret-password" 461 DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { 462 challengeSelectionData = ChallengeSelectionData( 463 challenges: ScreenshotController.mfaMockChallenges 464 ) 465 } 466 } 467 if ScreenshotController.showMfaCode { 468 usernameText = "pos" 469 tokenText = "secret-password" 470 DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { 471 tanDigits = ["5", "8", "2", "7", "", "", "", ""] 472 tanChallenge = ScreenshotController.mfaMockTanChallenge 473 } 474 } 475 } 476 } 477 478 private func submitManualConfig() { 479 focusedField = nil 480 isSubmitting = true 481 errorMessage = nil 482 483 let baseUrl = sanitizeMerchantUrlAndUpdateFields() 484 guard !baseUrl.isEmpty else { 485 isSubmitting = false 486 errorMessage = S.configErrorEmptyUrl 487 return 488 } 489 490 let username = usernameText.trimmingCharacters(in: .whitespaces) 491 let token = tokenText.trimmingCharacters(in: .whitespaces) 492 493 guard !token.isEmpty else { 494 isSubmitting = false 495 errorMessage = S.configErrorEmptyToken 496 return 497 } 498 499 if !username.isEmpty { 500 mfaBaseUrl = baseUrl 501 mfaUsername = username 502 mfaInitialSecret = token 503 mfaSolvedChallengeIds = [] 504 fetchLimitedAccessTokenWithMfa() 505 } else { 506 let configUrl = baseUrl 507 configManager.savePassword = saveToken 508 configManager.fetchConfig(url: configUrl, token: token, save: true) 509 } 510 } 511 512 private func fetchLimitedAccessTokenWithMfa() { 513 Task { 514 do { 515 let limitedToken = try await configManager.fetchLimitedAccessToken( 516 baseUrl: mfaBaseUrl, 517 username: mfaUsername, 518 initialSecret: mfaInitialSecret, 519 challengeIds: mfaSolvedChallengeIds 520 ) 521 522 let configUrl = "\(mfaBaseUrl)/instances/\(mfaUsername)" 523 await MainActor.run { 524 configManager.savePassword = saveToken 525 configManager.fetchConfig(url: configUrl, token: limitedToken, save: true) 526 } 527 } catch let error as ChallengeRequiredError { 528 await MainActor.run { 529 handleChallengeResponse(error.challengeResponse) 530 } 531 } catch let error as ApiError where error.isUnauthorized { 532 await MainActor.run { 533 isSubmitting = false 534 errorMessage = S.configAuthError 535 } 536 } catch { 537 await MainActor.run { 538 isSubmitting = false 539 errorMessage = S.configErrorNetwork 540 } 541 } 542 } 543 } 544 545 private func handleChallengeResponse(_ response: ChallengesResponse) { 546 mfaChallenges = response.challenges 547 mfaCombiAnd = response.combiAnd 548 549 if mfaCombiAnd { 550 mfaRemainingChallenges = response.challenges 551 solveNextChallenge() 552 } else { 553 if response.challenges.count == 1 { 554 handleSelectedChallenge(response.challenges[0]) 555 } else { 556 challengeSelectionData = ChallengeSelectionData(challenges: response.challenges) 557 } 558 } 559 } 560 561 private func solveNextChallenge() { 562 guard let next = mfaRemainingChallenges.first else { 563 fetchLimitedAccessTokenWithMfa() 564 return 565 } 566 handleSelectedChallenge(next) 567 } 568 569 private func handleSelectedChallenge(_ challenge: MfaChallenge) { 570 tanDigits = Array(repeating: "", count: MFA_CODE_DIGITS) 571 tanErrorMessage = nil 572 573 Task { 574 do { 575 try await configManager.requestChallenge( 576 baseUrl: mfaBaseUrl, 577 username: mfaUsername, 578 challengeId: challenge.challengeId 579 ) 580 await MainActor.run { 581 tanChallenge = challenge 582 } 583 } catch { 584 await MainActor.run { 585 isSubmitting = false 586 errorMessage = S.mfaRequestFailed(error.localizedDescription) 587 } 588 } 589 } 590 } 591 592 private func confirmTanCode() { 593 guard let challenge = tanChallenge else { return } 594 let code = tanDigits.joined() 595 let digitsOnly = code.filter(\.isNumber) 596 597 guard digitsOnly.count == MFA_CODE_DIGITS else { 598 tanErrorMessage = S.mfaChallengeCodeIncomplete(MFA_CODE_DIGITS) 599 return 600 } 601 602 tanErrorMessage = nil 603 604 Task { 605 do { 606 try await configManager.confirmChallenge( 607 baseUrl: mfaBaseUrl, 608 username: mfaUsername, 609 challengeId: challenge.challengeId, 610 tan: digitsOnly 611 ) 612 613 await MainActor.run { 614 mfaSolvedChallengeIds.append(challenge.challengeId) 615 tanChallenge = nil 616 617 if mfaCombiAnd { 618 mfaRemainingChallenges.removeAll { $0.challengeId == challenge.challengeId } 619 solveNextChallenge() 620 } else { 621 fetchLimitedAccessTokenWithMfa() 622 } 623 } 624 } catch let error as ApiError { 625 await MainActor.run { 626 switch error { 627 case .http(409, _, _): 628 tanErrorMessage = S.mfaChallengeInvalid 629 tanDigits = Array(repeating: "", count: MFA_CODE_DIGITS) 630 case .http(429, _, _): 631 tanErrorMessage = S.mfaChallengeRetry 632 default: 633 tanChallenge = nil 634 isSubmitting = false 635 errorMessage = error.localizedDescription 636 } 637 } 638 } catch { 639 await MainActor.run { 640 tanChallenge = nil 641 isSubmitting = false 642 errorMessage = error.localizedDescription 643 } 644 } 645 } 646 } 647 648 private func resendChallenge() { 649 guard let challenge = tanChallenge else { return } 650 tanDigits = Array(repeating: "", count: MFA_CODE_DIGITS) 651 tanErrorMessage = nil 652 653 Task { 654 do { 655 try await configManager.requestChallenge( 656 baseUrl: mfaBaseUrl, 657 username: mfaUsername, 658 challengeId: challenge.challengeId 659 ) 660 await MainActor.run { 661 tanErrorMessage = nil 662 } 663 } catch { 664 await MainActor.run { 665 tanErrorMessage = S.mfaResendFailed(error.localizedDescription) 666 } 667 } 668 } 669 } 670 671 @discardableResult 672 private func sanitizeMerchantUrlAndUpdateFields() -> String { 673 let rawInput = merchantUrlText.trimmingCharacters(in: .whitespaces) 674 if rawInput.isEmpty { return "" } 675 676 let normalizedInput: String 677 if rawInput.hasPrefix("http://") || rawInput.hasPrefix("https://") { 678 normalizedInput = rawInput 679 } else { 680 normalizedInput = "https://\(rawInput)" 681 } 682 683 guard let url = URL(string: normalizedInput) else { return "" } 684 685 let host = url.host ?? "" 686 let port = url.port.map { ":\($0)" } ?? "" 687 let baseHost = "\(host)\(port)" 688 689 let segments = url.pathComponents.filter { $0 != "/" } 690 if segments.count >= 2, 691 segments[0].caseInsensitiveCompare("instances") == .orderedSame { 692 usernameText = segments[1] 693 } 694 695 merchantUrlText = baseHost 696 return baseHost.isEmpty ? "" : "https://\(baseHost)" 697 } 698 } 699 700 // MARK: - TAN Input Sheet 701 702 private struct TanInputSheet: View { 703 let challenge: MfaChallenge 704 @Binding var digits: [String] 705 @Binding var errorMessage: String? 706 let onConfirm: () -> Void 707 let onCancel: () -> Void 708 let onResend: () -> Void 709 710 @State private var codeText: String = "" 711 712 private var channelLabel: String { 713 switch challenge.tanChannel { 714 case .sms: return "SMS" 715 case .email: return "Email" 716 } 717 } 718 719 private var channelInfo: String { 720 challenge.tanInfo 721 } 722 723 private var isComplete: Bool { 724 codeText.filter(\.isNumber).count == MFA_CODE_DIGITS 725 } 726 727 var body: some View { 728 NavigationStack { 729 VStack(spacing: 20) { 730 Text(S.mfaChallengeMessage(channelLabel, channelInfo)) 731 .font(.subheadline) 732 .foregroundColor(.posOnSurfaceVariant) 733 .multilineTextAlignment(.center) 734 .padding(.horizontal) 735 736 if isPhone { 737 HStack(spacing: 8) { 738 digitBoxesView 739 .contentShape(Rectangle()) 740 .onLongPressGesture { 741 if let pasted = UIPasteboard.general.string { 742 let digitsOnly = pasted.filter(\.isNumber) 743 let clamped = String(digitsOnly.prefix(MFA_CODE_DIGITS)) 744 codeText = clamped 745 syncDigits() 746 } 747 } 748 Button(S.mfaResendCode) { onResend() } 749 .font(.caption) 750 .foregroundColor(.posPrimary) 751 } 752 } else { 753 digitBoxesView 754 .contentShape(Rectangle()) 755 .onLongPressGesture { 756 if let pasted = UIPasteboard.general.string { 757 let digitsOnly = pasted.filter(\.isNumber) 758 let clamped = String(digitsOnly.prefix(MFA_CODE_DIGITS)) 759 codeText = clamped 760 syncDigits() 761 } 762 } 763 764 Button(S.mfaResendCode) { onResend() } 765 .font(.subheadline) 766 .foregroundColor(.posPrimary) 767 } 768 769 if let errorMessage = errorMessage { 770 Text(errorMessage) 771 .font(.caption) 772 .foregroundColor(.posError) 773 } 774 775 digitNumpad 776 .padding(.horizontal, 20) 777 778 Button(action: onConfirm) { 779 Text(S.mfaVerify) 780 .fontWeight(.semibold) 781 .frame(maxWidth: .infinity) 782 .padding(.vertical, 12) 783 } 784 .buttonStyle(.borderedProminent) 785 .tint(.posPrimary) 786 .foregroundColor(.posOnPrimary) 787 .disabled(!isComplete) 788 .padding(.horizontal) 789 .padding(.bottom, 20) 790 } 791 .padding() 792 .navigationTitle(S.mfaChallengeCodeHint) 793 .navigationBarTitleDisplayMode(.inline) 794 .toolbar { 795 ToolbarItem(placement: .cancellationAction) { 796 Button(S.mfaCancel) { onCancel() } 797 } 798 } 799 .onAppear { 800 codeText = digits.joined() 801 } 802 } 803 .presentationDetents([.large]) 804 } 805 806 private func syncDigits() { 807 let chars = Array(codeText) 808 for i in 0..<MFA_CODE_DIGITS { 809 digits[i] = i < chars.count ? String(chars[i]) : "" 810 } 811 } 812 813 private func appendDigit(_ d: String) { 814 guard codeText.count < MFA_CODE_DIGITS else { return } 815 codeText.append(d) 816 syncDigits() 817 } 818 819 private func deleteLastDigit() { 820 guard !codeText.isEmpty else { return } 821 codeText.removeLast() 822 syncDigits() 823 } 824 825 private var isPhone: Bool { UIDevice.current.userInterfaceIdiom == .phone } 826 827 private var digitNumpad: some View { 828 Group { 829 if isPhone { 830 phoneNumpad 831 } else { 832 tabletNumpad 833 } 834 } 835 } 836 837 private func numpadDigitButton(_ digit: String) -> some View { 838 Button { appendDigit(digit) } label: { 839 Text(digit) 840 .font(isPhone ? .body : .title2) 841 .fontWeight(.medium) 842 .frame(maxWidth: .infinity) 843 .frame(height: isPhone ? 44 : 52) 844 .background(Color.posSurface) 845 .foregroundColor(.posOnSurface) 846 .cornerRadius(isPhone ? 10 : 12) 847 .overlay( 848 RoundedRectangle(cornerRadius: isPhone ? 10 : 12) 849 .stroke(Color.posOutlineVariant, lineWidth: 1) 850 ) 851 } 852 .buttonStyle(.plain) 853 } 854 855 private func numpadIconButton(icon: String, action: @escaping () -> Void) -> some View { 856 Button(action: action) { 857 Image(systemName: icon) 858 .font(isPhone ? .body : .title3) 859 .frame(maxWidth: .infinity) 860 .frame(height: isPhone ? 44 : 52) 861 .background(Color.posSurface) 862 .foregroundColor(.posOnSurface) 863 .cornerRadius(isPhone ? 10 : 12) 864 .overlay( 865 RoundedRectangle(cornerRadius: isPhone ? 10 : 12) 866 .stroke(Color.posOutlineVariant, lineWidth: 1) 867 ) 868 } 869 .buttonStyle(.plain) 870 } 871 872 private var phoneNumpad: some View { 873 VStack(spacing: 6) { 874 HStack(spacing: 6) { 875 ForEach(1...5, id: \.self) { n in numpadDigitButton("\(n)") } 876 numpadIconButton(icon: "doc.on.clipboard") { 877 if let pasted = UIPasteboard.general.string { 878 let digitsOnly = pasted.filter(\.isNumber) 879 codeText = String(digitsOnly.prefix(MFA_CODE_DIGITS)) 880 syncDigits() 881 } 882 } 883 } 884 HStack(spacing: 6) { 885 ForEach(6...9, id: \.self) { n in numpadDigitButton("\(n)") } 886 numpadDigitButton("0") 887 numpadIconButton(icon: "delete.left") { deleteLastDigit() } 888 } 889 } 890 } 891 892 private var tabletNumpad: some View { 893 VStack(spacing: 8) { 894 ForEach(0..<3, id: \.self) { row in 895 HStack(spacing: 8) { 896 ForEach(1...3, id: \.self) { col in 897 numpadDigitButton("\(row * 3 + col)") 898 } 899 } 900 } 901 HStack(spacing: 8) { 902 numpadIconButton(icon: "doc.on.clipboard") { 903 if let pasted = UIPasteboard.general.string { 904 let digitsOnly = pasted.filter(\.isNumber) 905 codeText = String(digitsOnly.prefix(MFA_CODE_DIGITS)) 906 syncDigits() 907 } 908 } 909 numpadDigitButton("0") 910 numpadIconButton(icon: "delete.left") { deleteLastDigit() } 911 } 912 } 913 } 914 915 private var digitBoxesView: some View { 916 HStack(spacing: 6) { 917 ForEach(0..<MFA_CODE_DIGITS, id: \.self) { index in 918 if index == 4 { 919 Text("-") 920 .font(.title2) 921 .fontWeight(.bold) 922 .foregroundColor(.posOnSurfaceVariant) 923 } 924 925 let digitChar = index < codeText.count 926 ? String(codeText[codeText.index(codeText.startIndex, offsetBy: index)]) 927 : "" 928 let isCurrent = codeText.count == index 929 930 Text(digitChar.isEmpty ? " " : digitChar) 931 .font(.title2) 932 .fontWeight(.bold) 933 .frame(width: 36, height: 48) 934 .background(Color.posSurface) 935 .cornerRadius(8) 936 .overlay( 937 RoundedRectangle(cornerRadius: 8) 938 .stroke(isCurrent ? Color.posPrimary : Color.posOutline, lineWidth: isCurrent ? 2 : 1) 939 ) 940 } 941 } 942 } 943 }