diff --git a/mobile-app/ios/autofill/CredentialProviderView.swift b/mobile-app/ios/autofill/CredentialProviderView.swift new file mode 100644 index 000000000..3bd6252e3 --- /dev/null +++ b/mobile-app/ios/autofill/CredentialProviderView.swift @@ -0,0 +1,204 @@ +import SwiftUI +import AuthenticationServices + +struct CredentialProviderView: View { + @ObservedObject var viewModel: CredentialProviderViewModel + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationView { + ZStack { + if viewModel.isLoading { + ProgressView("Loading credentials...") + .progressViewStyle(.circular) + .scaleEffect(1.5) + } else { + List(viewModel.credentials, id: \.service) { credential in + Button(action: { + viewModel.selectCredential(credential) + }) { + VStack(alignment: .leading) { + Text(credential.service) + .font(.headline) + Text(credential.username) + .font(.subheadline) + .foregroundColor(.secondary) + } + } + } + .refreshable { + viewModel.loadCredentials() + } + } + } + .navigationTitle("Select Credential") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Cancel") { + viewModel.cancel() + } + .foregroundColor(.red) + } + + ToolbarItem(placement: .navigationBarTrailing) { + HStack { + Button { + viewModel.loadCredentials() + } label: { + Image(systemName: "arrow.clockwise") + } + + Button("Add") { + viewModel.showAddCredential = true + } + } + } + } + .sheet(isPresented: $viewModel.showAddCredential) { + AddCredentialView(viewModel: viewModel) + } + .alert("Error", isPresented: $viewModel.showError) { + Button("OK") { + viewModel.cancel() + } + } message: { + Text(viewModel.errorMessage) + } + .task { + // wait for .1sec + try? await Task.sleep(nanoseconds: 100_000_000) + viewModel.loadCredentials() + } + .onDisappear { + viewModel.cancel() + } + } + } +} + +struct AddCredentialView: View { + @ObservedObject var viewModel: CredentialProviderViewModel + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationView { + Form { + TextField("Username", text: $viewModel.newUsername) + .textContentType(.username) + .autocapitalization(.none) + + SecureField("Password", text: $viewModel.newPassword) + .textContentType(.password) + + TextField("Service", text: $viewModel.newService) + .textContentType(.URL) + .autocapitalization(.none) + } + .navigationTitle("Add Credential") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Cancel") { + dismiss() + } + } + + ToolbarItem(placement: .navigationBarTrailing) { + Button("Save") { + viewModel.addCredential() + dismiss() + } + .disabled(viewModel.newUsername.isEmpty || + viewModel.newPassword.isEmpty || + viewModel.newService.isEmpty) + } + } + } + } +} + +class CredentialProviderViewModel: ObservableObject { + @Published var credentials: [Credential] = [] + @Published var isLoading = true + @Published var showError = false + @Published var errorMessage = "" + @Published var showAddCredential = false + + // New credential form fields + @Published var newUsername = "" + @Published var newPassword = "" + @Published var newService = "" + + private var extensionContext: ASCredentialProviderExtensionContext? + + init(extensionContext: ASCredentialProviderExtensionContext? = nil) { + self.extensionContext = extensionContext + } + + func loadCredentials() { + isLoading = true + + do { + credentials = try SharedCredentialStore.shared.getAllCredentials(createKeyIfNeeded: false) + + Task { + do { + try await CredentialIdentityStore.shared.saveCredentialIdentities(credentials) + DispatchQueue.main.async { [weak self] in + self?.isLoading = false + } + } catch { + await handleError(error) + } + } + } catch { + handleError(error) + } + } + + func selectCredential(_ credential: Credential) { + let passwordCredential = ASPasswordCredential(user: credential.username, + password: credential.password) + extensionContext?.completeRequest(withSelectedCredential: passwordCredential, + completionHandler: nil) + } + + func addCredential() { + let credential = Credential(username: newUsername, + password: newPassword, + service: newService) + + do { + try SharedCredentialStore.shared.addCredential(credential, createKeyIfNeeded: false) + Task { + try await CredentialIdentityStore.shared.saveCredentialIdentities([credential]) + } + loadCredentials() + + // Reset form + newUsername = "" + newPassword = "" + newService = "" + } catch { + handleError(error) + } + } + + func cancel() { + guard let context = extensionContext else { + print("Error: extensionContext is nil") + return + } + context.cancelRequest(withError: NSError(domain: ASExtensionErrorDomain, + code: ASExtensionError.userCanceled.rawValue)) + } + + private func handleError(_ error: Error) { + DispatchQueue.main.async { [weak self] in + self?.isLoading = false + self?.errorMessage = error.localizedDescription + self?.showError = true + } + } +} diff --git a/mobile-app/ios/autofill/CredentialProviderViewController.swift b/mobile-app/ios/autofill/CredentialProviderViewController.swift index 8e4915add..e1ca1ffac 100644 --- a/mobile-app/ios/autofill/CredentialProviderViewController.swift +++ b/mobile-app/ios/autofill/CredentialProviderViewController.swift @@ -6,204 +6,65 @@ // import AuthenticationServices -import UIKit +import SwiftUI class CredentialProviderViewController: ASCredentialProviderViewController { - var credentials: [Credential] = [] - private let tableView = UITableView() - private let addButton = UIButton(type: .system) - private let loadButton = UIButton(type: .system) - private let cancelButton = UIButton(type: .system) - private let loadingIndicator = UIActivityIndicatorView(style: .large) - override func viewDidLoad() { super.viewDidLoad() - setupUI() - } - - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - loadCredentials() - } - - override func viewWillDisappear(_ animated: Bool) { - super.viewWillDisappear(animated) - extensionContext.cancelRequest(withError: NSError(domain: ASExtensionErrorDomain, code: ASExtensionError.userCanceled.rawValue)) - } - - private func setupUI() { - view.backgroundColor = .systemBackground - // Setup Cancel Button - cancelButton.setTitle("Cancel", for: .normal) - cancelButton.setTitleColor(.systemRed, for: .normal) - cancelButton.addTarget(self, action: #selector(cancelTapped), for: .touchUpInside) - view.addSubview(cancelButton) - cancelButton.translatesAutoresizingMaskIntoConstraints = false + let viewModel = CredentialProviderViewModel(extensionContext: extensionContext) + let hostingController = UIHostingController( + rootView: CredentialProviderView(viewModel: viewModel) + ) - // Setup Loading Indicator - loadingIndicator.hidesWhenStopped = false - loadingIndicator.color = .systemBlue - view.addSubview(loadingIndicator) - loadingIndicator.translatesAutoresizingMaskIntoConstraints = false - loadingIndicator.startAnimating() + addChild(hostingController) + view.addSubview(hostingController.view) - // Setup TableView - tableView.delegate = self - tableView.dataSource = self - tableView.register(UITableViewCell.self, forCellReuseIdentifier: "CredentialCell") - view.addSubview(tableView) - tableView.translatesAutoresizingMaskIntoConstraints = false - - // Setup Add Button - addButton.setTitle("Add Credential", for: .normal) - addButton.addTarget(self, action: #selector(addCredentialTapped), for: .touchUpInside) - view.addSubview(addButton) - addButton.translatesAutoresizingMaskIntoConstraints = false - - // Setup Load Button - loadButton.setTitle("Load Credentials", for: .normal) - loadButton.addTarget(self, action: #selector(loadCredentials), for: .touchUpInside) - view.addSubview(loadButton) - loadButton.translatesAutoresizingMaskIntoConstraints = false - - // Setup Constraints + hostingController.view.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ - loadingIndicator.centerXAnchor.constraint(equalTo: view.centerXAnchor), - loadingIndicator.centerYAnchor.constraint(equalTo: view.centerYAnchor), - - cancelButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 8), - cancelButton.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), - cancelButton.heightAnchor.constraint(equalToConstant: 44), - - tableView.topAnchor.constraint(equalTo: cancelButton.bottomAnchor, constant: 8), - tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - tableView.bottomAnchor.constraint(equalTo: loadButton.topAnchor), - - loadButton.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), - loadButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16), - loadButton.bottomAnchor.constraint(equalTo: addButton.topAnchor, constant: -8), - loadButton.heightAnchor.constraint(equalToConstant: 44), - - addButton.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), - addButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16), - addButton.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -16), - addButton.heightAnchor.constraint(equalToConstant: 44) + hostingController.view.topAnchor.constraint(equalTo: view.topAnchor), + hostingController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + hostingController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + hostingController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor) ]) - } - - @objc private func loadCredentials() { - do { - credentials = try SharedCredentialStore.shared.getAllCredentials(createKeyIfNeeded: false) - // Update credential identities in the system - Task { - try await CredentialIdentityStore.shared.saveCredentialIdentities(credentials) - DispatchQueue.main.async { [weak self] in - self?.loadingIndicator.stopAnimating() - self?.tableView.isHidden = false - self?.tableView.reloadData() - } - } - } catch let error as NSError { - loadingIndicator.stopAnimating() - let errorAlert = UIAlertController( - title: "Error Loading Credentials", - message: error.localizedDescription, - preferredStyle: .alert - ) - errorAlert.addAction(UIAlertAction(title: "OK", style: .default) { [weak self] _ in - self?.extensionContext.cancelRequest(withError: error) - }) - present(errorAlert, animated: true) - } - } - - @objc private func addCredentialTapped() { - let alert = UIAlertController(title: "Add Credential", message: nil, preferredStyle: .alert) - alert.addTextField { textField in - textField.placeholder = "Username" - } - alert.addTextField { textField in - textField.placeholder = "Password" - textField.isSecureTextEntry = true - } - alert.addTextField { textField in - textField.placeholder = "Service" - } - - alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) - alert.addAction(UIAlertAction(title: "Add", style: .default) { [weak self] _ in - guard let username = alert.textFields?[0].text, - let password = alert.textFields?[1].text, - let service = alert.textFields?[2].text, - !username.isEmpty, !password.isEmpty, !service.isEmpty else { return } - - let credential = Credential(username: username, password: password, service: service) - do { - try SharedCredentialStore.shared.addCredential(credential, createKeyIfNeeded: false) - // Update credential identities in the system - Task { - try await CredentialIdentityStore.shared.saveCredentialIdentities([credential]) - } - } catch let error as NSError { - let errorAlert = UIAlertController( - title: "Error Adding Credential", - message: error.localizedDescription, - preferredStyle: .alert - ) - errorAlert.addAction(UIAlertAction(title: "OK", style: .default)) - self?.present(errorAlert, animated: true) - return - } - self?.loadCredentials() - }) - - present(alert, animated: true) - } - - @objc private func cancelTapped() { - extensionContext.cancelRequest(withError: NSError(domain: ASExtensionErrorDomain, code: ASExtensionError.userCanceled.rawValue)) + hostingController.didMove(toParent: self) } override func prepareCredentialList(for serviceIdentifiers: [ASCredentialServiceIdentifier]) { - // Loading it from here doesn't play well with the - //loadCredentials() + // This is handled in the SwiftUI view's onAppear } override func prepareInterfaceForUserChoosingTextToInsert() { - loadCredentials() + // This is handled in the SwiftUI view's onAppear } override func provideCredentialWithoutUserInteraction(for credentialIdentity: ASPasswordCredentialIdentity) { - loadCredentials() - - // For testing purposes: we just return the first credential. - if let firstCredential = credentials.first { - let passwordCredential = ASPasswordCredential(user: firstCredential.username, password: firstCredential.password) - self.extensionContext.completeRequest(withSelectedCredential: passwordCredential, completionHandler: nil) - } else { - self.extensionContext.cancelRequest(withError: NSError(domain: ASExtensionErrorDomain, code: ASExtensionError.credentialIdentityNotFound.rawValue)) + // Get credentials and return the first one that matches the identity + do { + let credentials = try SharedCredentialStore.shared.getAllCredentials(createKeyIfNeeded: false) + if let matchingCredential = credentials.first(where: { $0.service == credentialIdentity.serviceIdentifier.identifier }) { + let passwordCredential = ASPasswordCredential( + user: matchingCredential.username, + password: matchingCredential.password + ) + self.extensionContext.completeRequest(withSelectedCredential: passwordCredential, completionHandler: nil) + } else { + self.extensionContext.cancelRequest( + withError: NSError( + domain: ASExtensionErrorDomain, + code: ASExtensionError.credentialIdentityNotFound.rawValue + ) + ) + } + } catch { + self.extensionContext.cancelRequest( + withError: NSError( + domain: ASExtensionErrorDomain, + code: ASExtensionError.failed.rawValue, + userInfo: [NSLocalizedDescriptionKey: error.localizedDescription] + ) + ) } } } - -extension CredentialProviderViewController: UITableViewDelegate, UITableViewDataSource { - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - return credentials.count - } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = tableView.dequeueReusableCell(withIdentifier: "CredentialCell", for: indexPath) - let credential = credentials[indexPath.row] - cell.textLabel?.text = "\(credential.service) - \(credential.username)" - return cell - } - - func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - let credential = credentials[indexPath.row] - let passwordCredential = ASPasswordCredential(user: credential.username, password: credential.password) - self.extensionContext.completeRequest(withSelectedCredential: passwordCredential, completionHandler: nil) - } -}