taler-ios

iOS apps for GNU Taler (wallet)
Log | Files | Refs | README | LICENSE

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 }