From 58b0b04e92bf08fe7b79c4a342830ec135407547 Mon Sep 17 00:00:00 2001 From: Mojgan Date: Thu, 20 Jul 2023 15:37:47 +0200 Subject: [PATCH] implement account deletion --- ios/CHANGELOG.md | 2 + ios/MullvadREST/RESTAccountsProxy.swift | 42 +++ ios/MullvadREST/RESTRequestFactory.swift | 7 + ios/MullvadVPN.xcodeproj/project.pbxproj | 36 +- .../Coordinators/App/AccountCoordinator.swift | 31 ++ .../App/AccountDeletionCoordinator.swift | 47 +++ .../DeleteAccountOperation.swift | 57 +++ .../TunnelManager/TunnelManager.swift | 84 ++++- ios/MullvadVPN/UI appearance/UIMetrics.swift | 4 + .../Account/AccountContentView.swift | 21 +- .../Account/AccountViewController.swift | 7 + .../AccountDeletionContentView.swift | 332 ++++++++++++++++++ .../AccountDeletionInteractor.swift | 54 +++ .../AccountDeletionViewController.swift | 91 +++++ .../AccountDeletionViewModel.swift | 13 + .../Login/AccountTextField.swift | 36 +- .../RedeemVoucherContentView.swift | 27 +- 17 files changed, 868 insertions(+), 23 deletions(-) create mode 100644 ios/MullvadVPN/Coordinators/App/AccountDeletionCoordinator.swift create mode 100644 ios/MullvadVPN/TunnelManager/DeleteAccountOperation.swift create mode 100644 ios/MullvadVPN/View controllers/AccountDeletion/AccountDeletionContentView.swift create mode 100644 ios/MullvadVPN/View controllers/AccountDeletion/AccountDeletionInteractor.swift create mode 100644 ios/MullvadVPN/View controllers/AccountDeletion/AccountDeletionViewController.swift create mode 100644 ios/MullvadVPN/View controllers/AccountDeletion/AccountDeletionViewModel.swift diff --git a/ios/CHANGELOG.md b/ios/CHANGELOG.md index d4f438f7b141..dab6e62e17f3 100644 --- a/ios/CHANGELOG.md +++ b/ios/CHANGELOG.md @@ -22,6 +22,7 @@ Line wrap the file at 100 chars. Th * **Security**: in case of vulnerabilities. ## [Unreleased] +- Allow deleting account in account view. ## [2023.3 - 2023-07-15] @@ -33,6 +34,7 @@ Line wrap the file at 100 chars. Th not match the one stored on device. - Allow redeeming vouchers in account view. - Add WireGuard port selection to settings. +- Add account deletion ability to account view. ## [2023.2 - 2023-04-03] ### Changed diff --git a/ios/MullvadREST/RESTAccountsProxy.swift b/ios/MullvadREST/RESTAccountsProxy.swift index f6eacfe65c4b..4b1fa12a09c7 100644 --- a/ios/MullvadREST/RESTAccountsProxy.swift +++ b/ios/MullvadREST/RESTAccountsProxy.swift @@ -82,6 +82,48 @@ extension REST { completionHandler: completion ) } + + public func deleteAccount( + accountNumber: String, + retryStrategy: RetryStrategy, + completion: @escaping CompletionHandler + ) -> Cancellable { + let requestHandler = AnyRequestHandler(createURLRequest: { endpoint, authorization in + var requestBuilder = try self.requestFactory.createRequestBuilder( + endpoint: endpoint, + method: .delete, + pathTemplate: "accounts/me" + ) + requestBuilder.setAuthorization(authorization) + requestBuilder.addValue("Mullvad-Account-Number", value: accountNumber) + + return requestBuilder.getRequest() + }, authorizationProvider: createAuthorizationProvider(accountNumber: accountNumber)) + + let responseHandler = AnyResponseHandler { response, data -> ResponseHandlerResult in + let statusCode = HTTPStatus(rawValue: response.statusCode) + + switch statusCode { + case let statusCode where statusCode.isSuccess: + return .success(true) + default: + return .unhandledResponse( + try? self.responseDecoder.decode( + ServerErrorResponse.self, + from: data + ) + ) + } + } + + return addOperation( + name: "delete-my-account", + retryStrategy: retryStrategy, + requestHandler: requestHandler, + responseHandler: responseHandler, + completionHandler: completion + ) + } } public struct NewAccountData: Decodable { diff --git a/ios/MullvadREST/RESTRequestFactory.swift b/ios/MullvadREST/RESTRequestFactory.swift index d1e1c06a63d6..b1a58b0b5dc8 100644 --- a/ios/MullvadREST/RESTRequestFactory.swift +++ b/ios/MullvadREST/RESTRequestFactory.swift @@ -119,6 +119,13 @@ extension REST { ) } + mutating func addValue(_ forHTTPHeaderField: String, value: String) { + restRequest.urlRequest.addValue( + value, + forHTTPHeaderField: forHTTPHeaderField + ) + } + func getRequest() -> REST.Request { restRequest } diff --git a/ios/MullvadVPN.xcodeproj/project.pbxproj b/ios/MullvadVPN.xcodeproj/project.pbxproj index 8c92325d84b5..56d93fae10dd 100644 --- a/ios/MullvadVPN.xcodeproj/project.pbxproj +++ b/ios/MullvadVPN.xcodeproj/project.pbxproj @@ -436,12 +436,18 @@ F07C0A072A52DA64009825CA /* SetupAccountCompletedCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F07C0A062A52DA64009825CA /* SetupAccountCompletedCoordinator.swift */; }; F07CFF2029F2720E008C0343 /* RegisteredDeviceInAppNotificationProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = F07CFF1F29F2720E008C0343 /* RegisteredDeviceInAppNotificationProvider.swift */; }; F0C2AEFD2A0BB5CC00986207 /* NotificationProviderIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0C2AEFC2A0BB5CC00986207 /* NotificationProviderIdentifier.swift */; }; + F0C6FA812A66E23300F521F0 /* DeleteAccountOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0C6FA802A66E23300F521F0 /* DeleteAccountOperation.swift */; }; F0E3618B2A4ADD2F00AEEF2B /* WelcomeContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0E3618A2A4ADD2F00AEEF2B /* WelcomeContentView.swift */; }; F0E8CC032A4C753B007ED3B4 /* WelcomeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0E8CC022A4C753B007ED3B4 /* WelcomeViewController.swift */; }; F0E8CC052A4CC88F007ED3B4 /* WelcomeCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0E8CC042A4CC88F007ED3B4 /* WelcomeCoordinator.swift */; }; F0E8CC0A2A4EE127007ED3B4 /* SetupAccountCompletedContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0E8CC092A4EE127007ED3B4 /* SetupAccountCompletedContentView.swift */; }; F0E8CC0C2A4EE672007ED3B4 /* SetupAccountCompletedViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0E8CC0B2A4EE672007ED3B4 /* SetupAccountCompletedViewController.swift */; }; F0E8E4BB2A56C9F100ED26A3 /* WelcomeInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0E8E4BA2A56C9F100ED26A3 /* WelcomeInteractor.swift */; }; + F0E8E4C12A602CCB00ED26A3 /* AccountDeletionContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0E8E4C02A602CCB00ED26A3 /* AccountDeletionContentView.swift */; }; + F0E8E4C32A602E0D00ED26A3 /* AccountDeletionViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0E8E4C22A602E0D00ED26A3 /* AccountDeletionViewModel.swift */; }; + F0E8E4C52A60499100ED26A3 /* AccountDeletionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0E8E4C42A60499100ED26A3 /* AccountDeletionViewController.swift */; }; + F0E8E4C72A604CBE00ED26A3 /* AccountDeletionCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0E8E4C62A604CBE00ED26A3 /* AccountDeletionCoordinator.swift */; }; + F0E8E4C92A604E7400ED26A3 /* AccountDeletionInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0E8E4C82A604E7400ED26A3 /* AccountDeletionInteractor.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -1207,12 +1213,18 @@ F07C0A062A52DA64009825CA /* SetupAccountCompletedCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetupAccountCompletedCoordinator.swift; sourceTree = ""; }; F07CFF1F29F2720E008C0343 /* RegisteredDeviceInAppNotificationProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegisteredDeviceInAppNotificationProvider.swift; sourceTree = ""; }; F0C2AEFC2A0BB5CC00986207 /* NotificationProviderIdentifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationProviderIdentifier.swift; sourceTree = ""; }; + F0C6FA802A66E23300F521F0 /* DeleteAccountOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteAccountOperation.swift; sourceTree = ""; }; F0E3618A2A4ADD2F00AEEF2B /* WelcomeContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeContentView.swift; sourceTree = ""; }; F0E8CC022A4C753B007ED3B4 /* WelcomeViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeViewController.swift; sourceTree = ""; }; F0E8CC042A4CC88F007ED3B4 /* WelcomeCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeCoordinator.swift; sourceTree = ""; }; F0E8CC092A4EE127007ED3B4 /* SetupAccountCompletedContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetupAccountCompletedContentView.swift; sourceTree = ""; }; F0E8CC0B2A4EE672007ED3B4 /* SetupAccountCompletedViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetupAccountCompletedViewController.swift; sourceTree = ""; }; F0E8E4BA2A56C9F100ED26A3 /* WelcomeInteractor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WelcomeInteractor.swift; sourceTree = ""; }; + F0E8E4C02A602CCB00ED26A3 /* AccountDeletionContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountDeletionContentView.swift; sourceTree = ""; }; + F0E8E4C22A602E0D00ED26A3 /* AccountDeletionViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountDeletionViewModel.swift; sourceTree = ""; }; + F0E8E4C42A60499100ED26A3 /* AccountDeletionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountDeletionViewController.swift; sourceTree = ""; }; + F0E8E4C62A604CBE00ED26A3 /* AccountDeletionCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountDeletionCoordinator.swift; sourceTree = ""; }; + F0E8E4C82A604E7400ED26A3 /* AccountDeletionInteractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountDeletionInteractor.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -1496,6 +1508,7 @@ 5823FA5726CE4A4100283BF8 /* TunnelManager */ = { isa = PBXGroup; children = ( + F0C6FA802A66E23300F521F0 /* DeleteAccountOperation.swift */, 588527B1276B3F0700BAA373 /* LoadTunnelConfigurationOperation.swift */, 58F2E147276A307400A79513 /* MapConnectionStatusOperation.swift */, F07BF2612A26279100042943 /* RedeemVoucherOperation.swift */, @@ -1542,9 +1555,10 @@ 583FE01629C196E8006E85F9 /* View controllers */ = { isa = PBXGroup; children = ( - F0E8E4B92A55593300ED26A3 /* CreationAccount */, 583FE02029C1A0B1006E85F9 /* Account */, + F0E8E4BF2A602C7D00ED26A3 /* AccountDeletion */, 5878F4FA29CDA2D4003D4BE2 /* ChangeLog */, + F0E8E4B92A55593300ED26A3 /* CreationAccount */, 583FE01D29C197C1006E85F9 /* DeviceList */, 583FE02529C1AD0E006E85F9 /* Launch */, 583FE02129C1A0F4006E85F9 /* Login */, @@ -1927,11 +1941,11 @@ isa = PBXGroup; children = ( 7AF0419D29E957EB00D492DD /* AccountCoordinator.swift */, + F0E8E4C62A604CBE00ED26A3 /* AccountDeletionCoordinator.swift */, F07C0A042A52D4C3009825CA /* AccountRedeemingVoucherCoordinator.swift */, 58BBB39629717E0C00C8DB7C /* ApplicationCoordinator.swift */, 5893C6FB29C311E9009090D1 /* ApplicationRouter.swift */, 5878F50129CDB989003D4BE2 /* ChangeLogCoordinator.swift */, - F07C0A062A52DA64009825CA /* SetupAccountCompletedCoordinator.swift */, 58CAF9F92983E0C600BE19F7 /* LoginCoordinator.swift */, 583FE00D29C0D586006E85F9 /* OutOfTimeCoordinator.swift */, 5847D58C29B7740F008C3808 /* RevokedCoordinator.swift */, @@ -1939,6 +1953,7 @@ 587C92FD2986E28100FB9664 /* SelectLocationCoordinator.swift */, 58C3F4FA296C3AD500D72515 /* SettingsCoordinator.swift */, F041CD552A38B0B7001B703B /* SettingsRedeemVoucherCoordinator.swift */, + F07C0A062A52DA64009825CA /* SetupAccountCompletedCoordinator.swift */, 587C92FF2986E2B600FB9664 /* TermsOfServiceCoordinator.swift */, 58F185A9298A3E3E00075977 /* TunnelCoordinator.swift */, F0E8CC042A4CC88F007ED3B4 /* WelcomeCoordinator.swift */, @@ -2327,6 +2342,17 @@ path = CreationAccount; sourceTree = ""; }; + F0E8E4BF2A602C7D00ED26A3 /* AccountDeletion */ = { + isa = PBXGroup; + children = ( + F0E8E4C02A602CCB00ED26A3 /* AccountDeletionContentView.swift */, + F0E8E4C82A604E7400ED26A3 /* AccountDeletionInteractor.swift */, + F0E8E4C42A60499100ED26A3 /* AccountDeletionViewController.swift */, + F0E8E4C22A602E0D00ED26A3 /* AccountDeletionViewModel.swift */, + ); + path = AccountDeletion; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXHeadersBuildPhase section */ @@ -3180,6 +3206,7 @@ 5846227126E229F20035F7C2 /* StoreSubscription.swift in Sources */, 58421030282D8A3C00F24E46 /* UpdateAccountDataOperation.swift in Sources */, F0E8CC052A4CC88F007ED3B4 /* WelcomeCoordinator.swift in Sources */, + F0E8E4C92A604E7400ED26A3 /* AccountDeletionInteractor.swift in Sources */, 58FF2C03281BDE02009EF542 /* SettingsManager.swift in Sources */, 5803B4B02940A47300C23744 /* TunnelConfiguration.swift in Sources */, 587EB672271451E300123C75 /* PreferencesViewModel.swift in Sources */, @@ -3229,6 +3256,7 @@ 58F2E14C276A61C000A79513 /* RotateKeyOperation.swift in Sources */, 5871FB96254ADE4E0051A0A4 /* ConsolidatedApplicationLog.swift in Sources */, 068CE57029278F5300A068BB /* MigrationFromV1ToV2.swift in Sources */, + F0E8E4C52A60499100ED26A3 /* AccountDeletionViewController.swift in Sources */, F028A54B2A3370FA00C0CAA3 /* RedeemVoucherContentView.swift in Sources */, 58FEEB58260B662E00A621A8 /* AutomaticKeyboardResponder.swift in Sources */, 5846227326E22A160035F7C2 /* StorePaymentObserver.swift in Sources */, @@ -3285,6 +3313,8 @@ 582BB1AF229566420055B6EF /* SettingsCell.swift in Sources */, 58F3C0A4249CB069003E76BE /* HeaderBarView.swift in Sources */, 5864AF0829C78849005B0CD9 /* CellFactoryProtocol.swift in Sources */, + F0C6FA812A66E23300F521F0 /* DeleteAccountOperation.swift in Sources */, + F0E8E4C72A604CBE00ED26A3 /* AccountDeletionCoordinator.swift in Sources */, F07CFF2029F2720E008C0343 /* RegisteredDeviceInAppNotificationProvider.swift in Sources */, 587A01FC23F1F0BE00B68763 /* SimulatorTunnelProviderHost.swift in Sources */, 58CAF9F82983D36800BE19F7 /* Coordinator.swift in Sources */, @@ -3326,10 +3356,12 @@ 586891CD29D452E4002A8278 /* SafariCoordinator.swift in Sources */, 58ACF64F26567A7100ACE4B7 /* CustomSwitchContainer.swift in Sources */, 58EE2E3A272FF814003BFF93 /* SettingsDataSource.swift in Sources */, + F0E8E4C12A602CCB00ED26A3 /* AccountDeletionContentView.swift in Sources */, 58B26E1E2943514300D5980C /* InAppNotificationDescriptor.swift in Sources */, 58421032282E42B000F24E46 /* UpdateDeviceDataOperation.swift in Sources */, 06410E04292D0F7100AFC18C /* SettingsParser.swift in Sources */, 5878A27D2909657C0096FC88 /* RevokedDeviceInteractor.swift in Sources */, + F0E8E4C32A602E0D00ED26A3 /* AccountDeletionViewModel.swift in Sources */, 58677710290975E9006F721F /* SettingsInteractorFactory.swift in Sources */, 58B26E282943527300D5980C /* SystemNotificationProvider.swift in Sources */, 58CCA01222424D11004F3011 /* SettingsViewController.swift in Sources */, diff --git a/ios/MullvadVPN/Coordinators/App/AccountCoordinator.swift b/ios/MullvadVPN/Coordinators/App/AccountCoordinator.swift index e83b5c96bc50..f7f09ec81f09 100644 --- a/ios/MullvadVPN/Coordinators/App/AccountCoordinator.swift +++ b/ios/MullvadVPN/Coordinators/App/AccountCoordinator.swift @@ -11,6 +11,7 @@ import UIKit enum AccountDismissReason: Equatable { case none case userLoggedOut + case accountDeletion } enum AddedMoreCreditOption: Equatable { @@ -70,6 +71,8 @@ final class AccountCoordinator: Coordinator, Presentable, Presenting { logOut() case .navigateToVoucher: navigateToRedeemVoucher() + case .navigateToDeleteAccount: + navigateToDeleteAccount() } } @@ -99,6 +102,34 @@ final class AccountCoordinator: Coordinator, Presentable, Presenting { ) } + private func navigateToDeleteAccount() { + let coordinator = AccountDeletionCoordinator( + navigationController: CustomNavigationController(), + interactor: AccountDeletionInteractor(tunnelManager: interactor.tunnelManager) + ) + + coordinator.start() + coordinator.didCancel = { accountDeletionCoordinator in + accountDeletionCoordinator.dismiss(animated: true) + } + + coordinator.didFinish = { accountDeletionCoordinator in + accountDeletionCoordinator.dismiss(animated: true) { + self.didFinish?(self, .userLoggedOut) + } + } + + presentChild( + coordinator, + animated: true, + configuration: ModalPresentationConfiguration( + preferredContentSize: UIMetrics.AccountDeletion.preferredContentSize, + modalPresentationStyle: .custom, + transitioningDelegate: FormSheetTransitioningDelegate() + ) + ) + } + // MARK: - Alerts private func logOut() { diff --git a/ios/MullvadVPN/Coordinators/App/AccountDeletionCoordinator.swift b/ios/MullvadVPN/Coordinators/App/AccountDeletionCoordinator.swift new file mode 100644 index 000000000000..871fea5761a7 --- /dev/null +++ b/ios/MullvadVPN/Coordinators/App/AccountDeletionCoordinator.swift @@ -0,0 +1,47 @@ +// +// AccountDeletionCoordinator.swift +// MullvadVPN +// +// Created by Mojgan on 2023-07-13. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Foundation +import UIKit + +final class AccountDeletionCoordinator: Coordinator, Presentable { + private let navigationController: UINavigationController + private let interactor: AccountDeletionInteractor + + var didCancel: ((AccountDeletionCoordinator) -> Void)? + var didFinish: ((AccountDeletionCoordinator) -> Void)? + + var presentedViewController: UIViewController { + navigationController + } + + init( + navigationController: UINavigationController, + interactor: AccountDeletionInteractor + ) { + self.navigationController = navigationController + self.interactor = interactor + } + + func start() { + navigationController.navigationBar.isHidden = true + let viewController = AccountDeletionViewController(interactor: interactor) + viewController.delegate = self + navigationController.pushViewController(viewController, animated: true) + } +} + +extension AccountDeletionCoordinator: AccountDeletionViewControllerDelegate { + func deleteAccountDidSucceeded(controller: AccountDeletionViewController) { + didFinish?(self) + } + + func deleteAccountDidCancel(controller: AccountDeletionViewController) { + didCancel?(self) + } +} diff --git a/ios/MullvadVPN/TunnelManager/DeleteAccountOperation.swift b/ios/MullvadVPN/TunnelManager/DeleteAccountOperation.swift new file mode 100644 index 000000000000..906228c91426 --- /dev/null +++ b/ios/MullvadVPN/TunnelManager/DeleteAccountOperation.swift @@ -0,0 +1,57 @@ +// +// DeleteAccountOperation.swift +// MullvadVPN +// +// Created by Mojgan on 2023-07-18. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Foundation +import MullvadLogging +import MullvadREST +import MullvadTypes +import Operations + +class DeleteAccountOperation: ResultOperation { + private let logger = Logger(label: "\(DeleteAccountOperation.self)") + + private let accountNumber: String + private let accountsProxy: REST.AccountsProxy + private var task: Cancellable? + + init( + dispatchQueue: DispatchQueue, + accountsProxy: REST.AccountsProxy, + accountNumber: String + ) { + self.accountNumber = accountNumber + self.accountsProxy = accountsProxy + super.init(dispatchQueue: dispatchQueue) + } + + override func main() { + task = accountsProxy.deleteAccount( + accountNumber: accountNumber, + retryStrategy: .default, + completion: { [weak self] result in + self?.dispatchQueue.async { + switch result { + case let .success(isDeleted): + self?.finish(result: .success(isDeleted)) + case let .failure(error): + self?.logger.error( + error: error, + message: "Failed to delete account." + ) + self?.finish(result: .failure(error)) + } + } + } + ) + } + + override func operationDidCancel() { + task?.cancel() + task = nil + } +} diff --git a/ios/MullvadVPN/TunnelManager/TunnelManager.swift b/ios/MullvadVPN/TunnelManager/TunnelManager.swift index b950170e36f6..f45cb121f606 100644 --- a/ios/MullvadVPN/TunnelManager/TunnelManager.swift +++ b/ios/MullvadVPN/TunnelManager/TunnelManager.swift @@ -431,6 +431,45 @@ final class TunnelManager: StorePaymentObserver { return operation } + func deleteAccount( + accountNumber: String, + completion: ((Result) -> Void)? = nil + ) -> Cancellable { + let operation = DeleteAccountOperation( + dispatchQueue: internalQueue, + accountsProxy: accountsProxy, + accountNumber: accountNumber + ) + + operation.completionQueue = .main + operation.completionHandler = { [weak self] result in + switch result { + case .success: + self?.unsetTunnelConfiguration { + self?.operationQueue.cancelAllOperations() + self?.wipeAllUserData() + self?.setDeviceState(.loggedOut, persist: true) + completion?(.success(true)) + } + case let .failure(error): + completion?(.failure(error)) + } + } + + operation.addObserver( + BackgroundObserver( + application: application, + name: "Delete account", + cancelUponExpiration: true + ) + ) + + operation.addCondition(MutuallyExclusive(category: OperationCategory.deviceStateUpdate.category)) + + operationQueue.addOperation(operation) + return operation + } + func updateDeviceData(_ completionHandler: ((Error?) -> Void)? = nil) { let operation = UpdateDeviceDataOperation( dispatchQueue: internalQueue, @@ -1083,10 +1122,47 @@ final class TunnelManager: StorePaymentObserver { if restError.compareErrorCode(.deviceNotFound) { setDeviceState(.revoked, persist: true) } else if restError.compareErrorCode(.invalidAccount) { - setDeviceState(.revoked, persist: true) - cancelPollingTunnelStatus() - cancelPollingKeyRotation() - wipeAllUserData() + unsetTunnelConfiguration { + self.setDeviceState(.revoked, persist: true) + self.operationQueue.cancelAllOperations() + self.wipeAllUserData() + } + } + } + + private func unsetTunnelConfiguration(completion: @escaping () -> Void) { + setSettings(TunnelSettingsV2(), persist: true) + + // Tell the caller to unsubscribe from VPN status notifications. + prepareForVPNConfigurationDeletion() + + // Reset tunnel. + _ = setTunnelStatus { tunnelStatus in + tunnelStatus = TunnelStatus() + tunnelStatus.state = .disconnected + } + + // Finish immediately if tunnel provider is not set. + guard let tunnel else { + completion() + return + } + + // Remove VPN configuration. + tunnel.removeFromPreferences { [self] error in + internalQueue.async { [self] in + // Ignore error but log it. + if let error { + logger.error( + error: error, + message: "Failed to remove VPN configuration." + ) + } + + setTunnel(nil, shouldRefreshTunnelState: false) + + completion() + } } } } diff --git a/ios/MullvadVPN/UI appearance/UIMetrics.swift b/ios/MullvadVPN/UI appearance/UIMetrics.swift index 5eedb5b20bb7..0370df7b35a1 100644 --- a/ios/MullvadVPN/UI appearance/UIMetrics.swift +++ b/ios/MullvadVPN/UI appearance/UIMetrics.swift @@ -40,6 +40,10 @@ enum UIMetrics { static let contentLayoutMargins = NSDirectionalEdgeInsets(top: 16, leading: 0, bottom: 16, trailing: 0) } + enum AccountDeletion { + static let preferredContentSize = CGSize(width: 375, height: 500) + } + enum Button { static let barButtonSize: CGFloat = 44.0 } diff --git a/ios/MullvadVPN/View controllers/Account/AccountContentView.swift b/ios/MullvadVPN/View controllers/Account/AccountContentView.swift index 813fa51867e5..886cc8a25542 100644 --- a/ios/MullvadVPN/View controllers/Account/AccountContentView.swift +++ b/ios/MullvadVPN/View controllers/Account/AccountContentView.swift @@ -54,6 +54,19 @@ class AccountContentView: UIView { return button }() + let deleteButton: AppButton = { + let button = AppButton(style: .danger) + button.translatesAutoresizingMaskIntoConstraints = false + button.accessibilityIdentifier = "DeleteButton" + button.setTitle(NSLocalizedString( + "DELETE_BUTTON_TITLE", + tableName: "Account", + value: "Delete account", + comment: "" + ), for: .normal) + return button + }() + let accountDeviceRow: AccountDeviceRow = { let view = AccountDeviceRow() view.translatesAutoresizingMaskIntoConstraints = false @@ -87,7 +100,13 @@ class AccountContentView: UIView { lazy var buttonStackView: UIStackView = { let stackView = - UIStackView(arrangedSubviews: [redeemVoucherButton, purchaseButton, restorePurchasesButton, logoutButton]) + UIStackView(arrangedSubviews: [ + redeemVoucherButton, + purchaseButton, + restorePurchasesButton, + logoutButton, + deleteButton, + ]) stackView.translatesAutoresizingMaskIntoConstraints = false stackView.axis = .vertical stackView.spacing = UIMetrics.interButtonSpacing diff --git a/ios/MullvadVPN/View controllers/Account/AccountViewController.swift b/ios/MullvadVPN/View controllers/Account/AccountViewController.swift index 6d40d383ade3..8384993285f5 100644 --- a/ios/MullvadVPN/View controllers/Account/AccountViewController.swift +++ b/ios/MullvadVPN/View controllers/Account/AccountViewController.swift @@ -18,6 +18,7 @@ enum AccountViewControllerAction { case finish case logOut case navigateToVoucher + case navigateToDeleteAccount } class AccountViewController: UIViewController { @@ -117,6 +118,8 @@ class AccountViewController: UIViewController { ) contentView.logoutButton.addTarget(self, action: #selector(logOut), for: .touchUpInside) + contentView.deleteButton.addTarget(self, action: #selector(deleteAccount), for: .touchUpInside) + interactor.didReceiveDeviceState = { [weak self] deviceState in self?.updateView(from: deviceState) } @@ -239,6 +242,10 @@ class AccountViewController: UIViewController { actionHandler?(.navigateToVoucher) } + @objc private func deleteAccount() { + actionHandler?(.navigateToDeleteAccount) + } + @objc private func doPurchase() { guard case let .received(product) = productState, let accountData = interactor.deviceState.accountData diff --git a/ios/MullvadVPN/View controllers/AccountDeletion/AccountDeletionContentView.swift b/ios/MullvadVPN/View controllers/AccountDeletion/AccountDeletionContentView.swift new file mode 100644 index 000000000000..7158b295ebd5 --- /dev/null +++ b/ios/MullvadVPN/View controllers/AccountDeletion/AccountDeletionContentView.swift @@ -0,0 +1,332 @@ +// +// AccountDeletionContentView.swift +// MullvadVPN +// +// Created by Mojgan on 2023-07-13. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import MullvadREST +import UIKit + +protocol AccountDeletionContentViewDelegate: AnyObject { + func didTapDeleteButtonButton(contentView: AccountDeletionContentView, button: AppButton) + func didTapCancelButton(contentView: AccountDeletionContentView, button: AppButton) +} + +class AccountDeletionContentView: UIView { + enum State { + case initial + case loading + case failure(Error) + } + + private enum Action: String { + case ok, cancel + } + + private let titleLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.font = .preferredFont(forTextStyle: .title1, weight: .bold) + label.numberOfLines = .zero + label.lineBreakMode = .byWordWrapping + label.textColor = .white + label.text = NSLocalizedString( + "TITLE", + tableName: "Account", + value: "Account deletion", + comment: "" + ) + return label + }() + + private let messageLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.font = .preferredFont(forTextStyle: .body, weight: .regular) + label.numberOfLines = .zero + label.lineBreakMode = .byWordWrapping + label.textColor = .white + return label + }() + + private let tipLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.font = .preferredFont(forTextStyle: .footnote, weight: .bold) + label.numberOfLines = .zero + label.lineBreakMode = .byWordWrapping + label.textColor = .white + label.text = NSLocalizedString( + "TIP_TEXT", + tableName: "Account", + value: """ + This logs out all devices using this account and all VPN access will be denied even if there is time left on the account. Enter the last 4 digits of the account number and hit OK if you really want to delete the account: + """, + comment: "" + ) + return label + }() + + private lazy var accountTextField: AccountTextField = { + let groupingStyle = AccountTextField.GroupingStyle.lastPart + let textField = AccountTextField(groupingStyle: groupingStyle) + textField.font = .preferredFont(forTextStyle: .body, weight: .bold) + textField.placeholder = Array(repeating: "X", count: 4).joined() + textField.placeholderTextColor = .lightGray + textField.textContentType = .username + textField.autocorrectionType = .no + textField.smartDashesType = .no + textField.smartInsertDeleteType = .no + textField.smartQuotesType = .no + textField.spellCheckingType = .no + textField.keyboardType = .numberPad + textField.returnKeyType = .done + textField.enablesReturnKeyAutomatically = false + textField.backgroundColor = .white + textField.borderStyle = .line + return textField + }() + + private let statusLabel: UILabel = { + let label = UILabel() + label.font = .preferredFont(forTextStyle: .callout) + label.textColor = .white + label.numberOfLines = .zero + label.lineBreakMode = .byWordWrapping + label.textColor = .red + if #available(iOS 14.0, *) { + // See: https://stackoverflow.com/q/46200027/351305 + label.lineBreakStrategy = [] + } + return label + }() + + private let deleteButton: AppButton = { + let button = AppButton(style: .danger) + button.accessibilityIdentifier = Action.ok.rawValue + button.setTitle(NSLocalizedString( + "OK_BUTTON_TITLE", + tableName: "Account", + value: "Ok", + comment: "" + ), for: .normal) + return button + }() + + private let cancelButton: AppButton = { + let button = AppButton(style: .default) + button.accessibilityIdentifier = Action.cancel.rawValue + button.setTitle(NSLocalizedString( + "CANCEL_BUTTON_TITLE", + tableName: "Account", + value: "Cancel", + comment: "" + ), for: .normal) + return button + }() + + private lazy var textsStack: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [ + titleLabel, + messageLabel, + tipLabel, + accountTextField, + statusStack, + ]) + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.setCustomSpacing(UIMetrics.padding8, after: titleLabel) + stackView.setCustomSpacing(UIMetrics.padding8, after: messageLabel) + stackView.setCustomSpacing(UIMetrics.padding8, after: tipLabel) + stackView.setCustomSpacing(UIMetrics.padding8, after: accountTextField) + stackView.axis = .vertical + return stackView + }() + + private lazy var buttonsStack: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [deleteButton, cancelButton]) + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.axis = .vertical + stackView.spacing = UIMetrics.padding8 + return stackView + }() + + private let activityIndicator: SpinnerActivityIndicatorView = { + let activityIndicator = SpinnerActivityIndicatorView(style: .medium) + activityIndicator.tintColor = .white + return activityIndicator + }() + + private lazy var statusStack: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [activityIndicator, statusLabel]) + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.axis = .horizontal + stackView.spacing = UIMetrics.interButtonSpacing + return stackView + }() + + var state: State = .initial { + didSet { + updateUI() + } + } + + var isEditing = false { + didSet { + _ = accountTextField.isFirstResponder + ? accountTextField.resignFirstResponder() + : accountTextField.becomeFirstResponder() + } + } + + var viewModel: AccountDeletionViewModel? { + didSet { + updateData() + } + } + + var lastPartOfAccountNumber: String { + accountTextField.parsedToken + } + + private var text: String { + switch state { + case let .failure(error): + return error.localizedDescription + case .loading: + return NSLocalizedString( + "DELETE_ACCOUNT_STATUS_WAITING", + tableName: "Account", + value: "Deleting account...", + comment: "" + ) + default: return "" + } + } + + private var isDeleteButtonEnabled: Bool { + switch state { + case .initial, .failure: + return true + case .loading: + return false + } + } + + private var textColor: UIColor { + switch state { + case .failure: + return .dangerColor + default: + return .white + } + } + + private var isLoading: Bool { + switch state { + case .loading: + return true + default: + return false + } + } + + private var isAccountNumberLengthSatisfied: Bool { + let length = accountTextField.text?.count ?? 0 + return length == 4 + } + + weak var delegate: AccountDeletionContentViewDelegate? + + override init(frame: CGRect) { + super.init(frame: .zero) + setupAppearance() + configureUI() + addActions() + updateUI() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func willMove(toWindow newWindow: UIWindow?) { + if newWindow == nil { + NotificationCenter.default.removeObserver(self) + } + } + + override func didMoveToWindow() { + if self.window != nil { + NotificationCenter.default.addObserver( + self, + selector: #selector(textDidChange), + name: UITextField.textDidChangeNotification, + object: accountTextField + ) + } + } + + private func configureUI() { + addConstrainedSubviews([textsStack, buttonsStack]) { + textsStack.pinEdgesToSuperviewMargins(.all().excluding(.bottom)) + buttonsStack.pinEdgesToSuperviewMargins(.all().excluding(.top)) + } + } + + private func addActions() { + [deleteButton, cancelButton].forEach { $0.addTarget( + self, + action: #selector(didPress(button:)), + for: .touchUpInside + ) } + } + + private func updateData() { + viewModel.flatMap { viewModel in + let text = NSLocalizedString( + "BODY_LABEL_TEXT", + tableName: "Account", + value: """ + Are you sure you want to delete account **\(viewModel.accountNumber)**? + """, + comment: "" + ) + messageLabel.attributedText = NSAttributedString( + markdownString: text, + options: NSAttributedString + .MarkdownStylingOptions( + font: .preferredFont(forTextStyle: .body) + ) + ) + } + } + + private func updateUI() { + isLoading ? activityIndicator.startAnimating() : activityIndicator.stopAnimating() + deleteButton.isEnabled = isDeleteButtonEnabled && isAccountNumberLengthSatisfied + statusLabel.text = text + statusLabel.textColor = textColor + } + + private func setupAppearance() { + translatesAutoresizingMaskIntoConstraints = false + backgroundColor = .secondaryColor + directionalLayoutMargins = UIMetrics.contentLayoutMargins + } + + @objc private func didPress(button: AppButton) { + switch Action(rawValue: button.accessibilityIdentifier ?? "") { + case .ok: + delegate?.didTapDeleteButtonButton(contentView: self, button: button) + case .cancel: + delegate?.didTapCancelButton(contentView: self, button: button) + default: return + } + } + + @objc private func textDidChange() { + updateUI() + } +} diff --git a/ios/MullvadVPN/View controllers/AccountDeletion/AccountDeletionInteractor.swift b/ios/MullvadVPN/View controllers/AccountDeletion/AccountDeletionInteractor.swift new file mode 100644 index 000000000000..f818054d302e --- /dev/null +++ b/ios/MullvadVPN/View controllers/AccountDeletion/AccountDeletionInteractor.swift @@ -0,0 +1,54 @@ +// +// AccountDeletionInteractor.swift +// MullvadVPN +// +// Created by Mojgan on 2023-07-13. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Foundation +import MullvadREST +import MullvadTypes + +enum AccountDeletionError: LocalizedError { + case invalidInput + + var errorDescription: String? { + switch self { + case .invalidInput: + return NSLocalizedString( + "INVALID_ACCOUNT_NUMBER", + tableName: "Account", + value: "Last four digits of the account number are incorrect", + comment: "" + ) + } + } +} + +class AccountDeletionInteractor { + private let tunnelManager: TunnelManager + var viewModel: AccountDeletionViewModel { + AccountDeletionViewModel( + accountNumber: tunnelManager.deviceState.accountData?.number.formattedAccountNumber ?? "" + ) + } + + init(tunnelManager: TunnelManager) { + self.tunnelManager = tunnelManager + } + + func validate(input: String) -> Result { + if let accountNumber = tunnelManager.deviceState.accountData?.number, + let fourLastDigits = accountNumber.split(every: 4).last, + fourLastDigits == input { + return .success(accountNumber) + } else { + return .failure(AccountDeletionError.invalidInput) + } + } + + func delete(accountNumber: String, completionHandler: @escaping (Result) -> Void) -> Cancellable { + return tunnelManager.deleteAccount(accountNumber: accountNumber, completion: completionHandler) + } +} diff --git a/ios/MullvadVPN/View controllers/AccountDeletion/AccountDeletionViewController.swift b/ios/MullvadVPN/View controllers/AccountDeletion/AccountDeletionViewController.swift new file mode 100644 index 000000000000..ba131d224757 --- /dev/null +++ b/ios/MullvadVPN/View controllers/AccountDeletion/AccountDeletionViewController.swift @@ -0,0 +1,91 @@ +// +// AccountDeletionViewController.swift +// MullvadVPN +// +// Created by Mojgan on 2023-07-13. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import MullvadTypes +import UIKit + +protocol AccountDeletionViewControllerDelegate: AnyObject { + func deleteAccountDidSucceeded(controller: AccountDeletionViewController) + func deleteAccountDidCancel(controller: AccountDeletionViewController) +} + +class AccountDeletionViewController: UIViewController { + private var task: Cancellable? + private lazy var contentView: AccountDeletionContentView = { + let view = AccountDeletionContentView() + view.delegate = self + view.isEditing = true + return view + }() + + weak var delegate: AccountDeletionViewControllerDelegate? + var interactor: AccountDeletionInteractor + + init(interactor: AccountDeletionInteractor) { + self.interactor = interactor + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + configureUI() + contentView.viewModel = interactor.viewModel + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + enableEditing() + } + + private func configureUI() { + view.addSubview(contentView) + view.addConstrainedSubviews([contentView]) { + contentView.pinEdgesToSuperviewMargins(.all(UIMetrics.contentLayoutMargins)) + } + } + + private func enableEditing() { + guard !contentView.isEditing else { return } + contentView.isEditing = true + } + + private func submit(accountNumber: String) { + contentView.state = .loading + task = interactor.delete(accountNumber: accountNumber) { [weak self] result in + guard let self else { return } + switch result { + case .success: + self.contentView.state = .initial + self.delegate?.deleteAccountDidSucceeded(controller: self) + case let .failure(error): + self.contentView.state = .failure(error) + } + } + } +} + +extension AccountDeletionViewController: AccountDeletionContentViewDelegate { + func didTapCancelButton(contentView: AccountDeletionContentView, button: AppButton) { + contentView.isEditing = false + task?.cancel() + delegate?.deleteAccountDidCancel(controller: self) + } + + func didTapDeleteButtonButton(contentView: AccountDeletionContentView, button: AppButton) { + switch interactor.validate(input: contentView.lastPartOfAccountNumber) { + case let .success(accountNumber): + self.submit(accountNumber: accountNumber) + case let .failure(error): + self.contentView.state = .failure(error) + } + } +} diff --git a/ios/MullvadVPN/View controllers/AccountDeletion/AccountDeletionViewModel.swift b/ios/MullvadVPN/View controllers/AccountDeletion/AccountDeletionViewModel.swift new file mode 100644 index 000000000000..d30d0631a68e --- /dev/null +++ b/ios/MullvadVPN/View controllers/AccountDeletion/AccountDeletionViewModel.swift @@ -0,0 +1,13 @@ +// +// AccountDeletionViewModel.swift +// MullvadVPN +// +// Created by Mojgan on 2023-07-13. +// Copyright © 2023 Mullvad VPN AB. All rights reserved. +// + +import Foundation + +struct AccountDeletionViewModel { + let accountNumber: String +} diff --git a/ios/MullvadVPN/View controllers/Login/AccountTextField.swift b/ios/MullvadVPN/View controllers/Login/AccountTextField.swift index aa04abd8a859..d31310a75795 100644 --- a/ios/MullvadVPN/View controllers/Login/AccountTextField.swift +++ b/ios/MullvadVPN/View controllers/Login/AccountTextField.swift @@ -9,18 +9,46 @@ import UIKit class AccountTextField: CustomTextField, UITextFieldDelegate { - private let inputFormatter = InputTextFormatter(configuration: InputTextFormatter.Configuration( + enum GroupingStyle: Int { + case full + case lastPart + + var size: UInt8 { + switch self { + case .full: + return 4 + case .lastPart: + return 1 + } + } + } + + private var groupSize: GroupingStyle = .full + private lazy var inputFormatter = InputTextFormatter(configuration: InputTextFormatter.Configuration( allowedInput: .numeric, groupSeparator: " ", groupSize: 4, - maxGroups: 4 + maxGroups: groupSize.size )) var onReturnKey: ((AccountTextField) -> Bool)? + init(groupingStyle: GroupingStyle = .full) { + self.groupSize = groupingStyle + super.init(frame: .zero) + commonInit() + } + override init(frame: CGRect) { super.init(frame: frame) + commonInit() + } + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func commonInit() { backgroundColor = .clear cornerRadius = 0 @@ -35,10 +63,6 @@ class AccountTextField: CustomTextField, UITextFieldDelegate { ) } - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - var autoformattingText: String { set { inputFormatter.replace(with: newValue) diff --git a/ios/MullvadVPN/View controllers/RedeemVoucher/RedeemVoucherContentView.swift b/ios/MullvadVPN/View controllers/RedeemVoucher/RedeemVoucherContentView.swift index bd19d30eb8ef..317d622d200e 100644 --- a/ios/MullvadVPN/View controllers/RedeemVoucher/RedeemVoucherContentView.swift +++ b/ios/MullvadVPN/View controllers/RedeemVoucher/RedeemVoucherContentView.swift @@ -193,11 +193,27 @@ final class RedeemVoucherContentView: UIView { fatalError("init(coder:) has not been implemented") } + override func willMove(toWindow newWindow: UIWindow?) { + if newWindow == nil { + NotificationCenter.default.removeObserver(self) + } + } + + override func didMoveToWindow() { + if self.window != nil { + NotificationCenter.default.addObserver( + self, + selector: #selector(textDidChange), + name: UITextField.textDidChangeNotification, + object: textField + ) + } + } + private func setup() { setupAppearance() configureUI() addButtonHandlers() - addTextFieldObserver() updateUI() } @@ -226,15 +242,6 @@ final class RedeemVoucherContentView: UIView { } } - private func addTextFieldObserver() { - NotificationCenter.default.addObserver( - self, - selector: #selector(textDidChange), - name: UITextField.textDidChangeNotification, - object: textField - ) - } - private func addButtonHandlers() { cancelButton.addTarget( self,