diff --git a/FlowCrypt.xcodeproj/project.pbxproj b/FlowCrypt.xcodeproj/project.pbxproj index cb57da424..95b9c3583 100644 --- a/FlowCrypt.xcodeproj/project.pbxproj +++ b/FlowCrypt.xcodeproj/project.pbxproj @@ -93,6 +93,7 @@ 5ADEDCBC23A4329000EC495E /* PublicKeyDetailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ADEDCBB23A4329000EC495E /* PublicKeyDetailViewController.swift */; }; 5ADEDCBE23A4363700EC495E /* KeyDetailInfoViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ADEDCBD23A4363700EC495E /* KeyDetailInfoViewController.swift */; }; 5ADEDCC023A43B0800EC495E /* KeyDetailInfoViewDecorator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ADEDCBF23A43B0800EC495E /* KeyDetailInfoViewDecorator.swift */; }; + 601EEE31272B19D200FE445B /* CheckAuthScopesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 601EEE30272B19D200FE445B /* CheckAuthScopesViewController.swift */; }; 7F72537A0C44D3CE670F0EFD /* Pods_FlowCryptUIApplication.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3382C015A576728FA08BA310 /* Pods_FlowCryptUIApplication.framework */; }; 949ED9422303E3B400530579 /* Colors.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 949ED9412303E3B400530579 /* Colors.xcassets */; }; 9F003D6125E1B4ED00EB38C0 /* TrashFolderProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F003D6025E1B4ED00EB38C0 /* TrashFolderProvider.swift */; }; @@ -490,6 +491,7 @@ 5ADEDCBD23A4363700EC495E /* KeyDetailInfoViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyDetailInfoViewController.swift; sourceTree = ""; }; 5ADEDCBF23A43B0800EC495E /* KeyDetailInfoViewDecorator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyDetailInfoViewDecorator.swift; sourceTree = ""; }; 5ADEDCC123A43C6800EC495E /* KeyTextCellNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyTextCellNode.swift; sourceTree = ""; }; + 601EEE30272B19D200FE445B /* CheckAuthScopesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckAuthScopesViewController.swift; sourceTree = ""; }; 949ED9412303E3B400530579 /* Colors.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Colors.xcassets; sourceTree = ""; }; 9F003D6025E1B4ED00EB38C0 /* TrashFolderProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrashFolderProvider.swift; sourceTree = ""; }; 9F003D6C25EA8F3200EB38C0 /* UserAccountService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAccountService.swift; sourceTree = ""; }; @@ -1053,6 +1055,14 @@ path = "Key Details"; sourceTree = ""; }; + 601EEE32272B1A5800FE445B /* CheckAuthScopes */ = { + isa = PBXGroup; + children = ( + 601EEE30272B19D200FE445B /* CheckAuthScopesViewController.swift */, + ); + path = CheckAuthScopes; + sourceTree = ""; + }; 9F0C3C1F23191F2000299985 /* Services */ = { isa = PBXGroup; children = ( @@ -1604,6 +1614,7 @@ C132B9C51EC2DCAB00763715 /* Controllers */ = { isa = PBXGroup; children = ( + 601EEE32272B1A5800FE445B /* CheckAuthScopes */, 04B472911ECE29F600B8266F /* SideMenu */, D2FF6969243115FE007182F0 /* SetupImap */, 32DCA8D5AF0A43354CC7F58B /* SignIn */, @@ -2600,6 +2611,7 @@ 5A39F43F239EE7D2001F4607 /* SegmentedViewController.swift in Sources */, 21489B7A267CB4DF00BDE4AC /* ClientConfigurationObject.swift in Sources */, 5A5C234B23A042520015E705 /* WebViewController.swift in Sources */, + 601EEE31272B19D200FE445B /* CheckAuthScopesViewController.swift in Sources */, 9F5C2A7E257E64D500DE9B4B /* MessageOperationsProvider.swift in Sources */, 9FC7EB76266EB67B00F3BF5D /* EncryptedStorageProtocols.swift in Sources */, 32DCACF9C6FC4B9330C9B362 /* Imap+send.swift in Sources */, diff --git a/FlowCrypt/Controllers/CheckAuthScopes/CheckAuthScopesViewController.swift b/FlowCrypt/Controllers/CheckAuthScopes/CheckAuthScopesViewController.swift new file mode 100644 index 000000000..4e2957417 --- /dev/null +++ b/FlowCrypt/Controllers/CheckAuthScopes/CheckAuthScopesViewController.swift @@ -0,0 +1,125 @@ +// +// CheckAuthScopesViewController.swift +// FlowCrypt +// +// Created by Yevhen Kyivskyi on 28.10.2021 +// Copyright © 2017-present FlowCrypt a. s. All rights reserved. +// + +import AsyncDisplayKit +import FlowCryptCommon +import FlowCryptUI + +private extension GoogleScope { + var title: String { + switch self { + case .userInfo: return "User Info" + case .mail: return "Gmail" + case .contacts: return "Contacts" + } + } +} + +class CheckAuthScopesViewController: TableNodeViewController { + + private let missingScopes: [GoogleScope] + private let globalRouter: GlobalRouterType + + init( + missingScopes: [GoogleScope], + globalRouter: GlobalRouterType = GlobalRouter() + ) { + self.missingScopes = missingScopes + self.globalRouter = globalRouter + super.init(node: TableNode()) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + setupUI() + } + + var errorMessage: String { + let scopesMessage = missingScopes.map { $0.title }.joined(separator: ", ") + return "gmail_service_no_access_to_account_message".localizeWithArguments(scopesMessage) + } +} + +// MARK: - ASTableDelegate, ASTableDataSource +extension CheckAuthScopesViewController: ASTableDelegate, ASTableDataSource { + func tableNode(_: ASTableNode, numberOfRowsInSection _: Int) -> Int { + return 3 + } + + func tableNode(_ node: ASTableNode, nodeBlockForRowAt indexPath: IndexPath) -> ASCellNodeBlock { + return { [weak self] in + guard let self = self else { return ASCellNode() } + return self.unauthStateNode(for: indexPath) + } + } +} + +// MARK: - UI +extension CheckAuthScopesViewController { + private func setupUI() { + node.delegate = self + node.dataSource = self + + title = "FlowCrypt" + } + private func unauthStateNode(for indexPath: IndexPath) -> ASCellNode { + switch indexPath.row { + case 0: + return SetupTitleNode( + SetupTitleNode.Input( + title: "setup_title" + .localized + .attributed( + .bold(35), + color: .mainTextColor, + alignment: .center + ), + insets: UIEdgeInsets( + top: 64, left: 16, + bottom: 64, right: 16 + ), + backgroundColor: .backgroundColor + ) + ) + case 1: + return TextCellNode( + input: .init( + backgroundColor: .backgroundColor, + title: errorMessage, + withSpinner: false, + size: CGSize(width: 200, height: 200), + alignment: .center + ) + ) + case 2: + return ButtonCellNode(input: .signInAgain) { [weak self] in + guard let self = self else { return } + self.globalRouter.signIn(with: .gmailLogin(self)) + } + default: + return ASCellNode() + } + } +} + +private extension ButtonCellNode.Input { + static var signInAgain: ButtonCellNode.Input { + return .init( + title: "continue" + .localized + .attributed(.bold(16), color: .white, alignment: .center), + insets: UIEdgeInsets(top: 16, left: 24, bottom: 8, right: 24), + color: .main + ) + } +} diff --git a/FlowCrypt/Functionality/Services/GlobalRouter.swift b/FlowCrypt/Functionality/Services/GlobalRouter.swift index 1268ac263..1fcd302fa 100644 --- a/FlowCrypt/Functionality/Services/GlobalRouter.swift +++ b/FlowCrypt/Functionality/Services/GlobalRouter.swift @@ -86,6 +86,18 @@ extension GlobalRouter { AppStartup().initializeApp(window: window, session: session) } } + + private func handleGmailError(_ error: Error) { + logger.logInfo("gmail login failed with error \(error.localizedDescription)") + if let gmailUserError = error as? GoogleUserServiceError, + case .userNotAllowedAllNeededScopes(let missingScopes) = gmailUserError { + DispatchQueue.main.async { + let topNavigation = (self.keyWindow.rootViewController as? UINavigationController) + let checkAuthViewControlelr = CheckAuthScopesViewController(missingScopes: missingScopes) + topNavigation?.pushViewController(checkAuthViewControlelr, animated: true) + } + } + } } // MARK: - @@ -99,6 +111,8 @@ extension GlobalRouter { .then(on: .main) { [weak self] session in self?.userAccountService.startSessionFor(user: session) self?.proceed(with: session) + }.catch { [weak self] error in + self?.handleGmailError(error) } case .other(let session): userAccountService.startSessionFor(user: session) diff --git a/FlowCrypt/Functionality/Services/GoogleUserService.swift b/FlowCrypt/Functionality/Services/GoogleUserService.swift index 8a05f81e8..77e6c9e3c 100644 --- a/FlowCrypt/Functionality/Services/GoogleUserService.swift +++ b/FlowCrypt/Functionality/Services/GoogleUserService.swift @@ -25,6 +25,7 @@ enum GoogleUserServiceError: Error { case serviceError(Error) case parsingError(Error) case inconsistentState(String) + case userNotAllowedAllNeededScopes(missingScopes: [GoogleScope]) } struct GoogleUser: Codable { @@ -88,6 +89,12 @@ extension GoogleUserService: UserServiceType { presenting: viewController ) { authState, error in if let authState = authState { + let missingScopes = self.checkMissingScopes(authState.scope) + if !missingScopes.isEmpty { + reject(GoogleUserServiceError.userNotAllowedAllNeededScopes(missingScopes: missingScopes)) + return + } + let authorization = GTMAppAuthFetcherAuthorization(authState: authState) guard let email = authorization.userEmail else { reject(GoogleUserServiceError.inconsistentState("Missed email")) @@ -199,6 +206,13 @@ extension GoogleUserService { logger.logError("Authorization error during fetching user info") } } + + private func checkMissingScopes(_ scope: String?) -> [GoogleScope] { + guard let scope = scope else { + return GoogleScope.allCases + } + return GoogleScope.allCases.filter { !scope.contains($0.value) } + } } // MARK: - OIDAuthStateChangeDelegate diff --git a/FlowCrypt/Resources/en.lproj/Localizable.strings b/FlowCrypt/Resources/en.lproj/Localizable.strings index be13186c8..f87c82260 100644 --- a/FlowCrypt/Resources/en.lproj/Localizable.strings +++ b/FlowCrypt/Resources/en.lproj/Localizable.strings @@ -9,6 +9,7 @@ "open" = "Open"; "settings" = "Settings"; "go_back" = "Go back"; +"continue" = "Continue"; // EMAIL "email_removed" = "Email moved to Trash"; @@ -244,6 +245,7 @@ "gmail_service_missing_message_info_error_message" = "Missing message info: %@"; "gmail_service_provider_error_error_message" = "Gmail provider error: %@"; "gmail_service_missing_back_query_error_message" = "Missed backup query: %@"; +"gmail_service_no_access_to_account_message" = "Google login successful.\n Next, connect your inbox. Be sure to tick %@ permission on the next screen. The grant is stored locally on your device, never shared with anyone."; // Files picking "files_picking_select_input_source_title" = "Please select input source"; diff --git a/FlowCryptUI/Cell Nodes/TextCellNode.swift b/FlowCryptUI/Cell Nodes/TextCellNode.swift index 8e53dffaa..b289c64bb 100644 --- a/FlowCryptUI/Cell Nodes/TextCellNode.swift +++ b/FlowCryptUI/Cell Nodes/TextCellNode.swift @@ -8,6 +8,7 @@ import AsyncDisplayKit import FlowCryptCommon +import UIKit public final class TextCellNode: CellNode { public struct Input { @@ -16,19 +17,22 @@ public final class TextCellNode: CellNode { let withSpinner: Bool let size: CGSize let insets: UIEdgeInsets + let alignment: NSTextAlignment? public init( backgroundColor: UIColor, title: String, withSpinner: Bool, size: CGSize, - insets: UIEdgeInsets = .zero + insets: UIEdgeInsets = .zero, + alignment: NSTextAlignment? = nil ) { self.backgroundColor = backgroundColor self.title = title self.withSpinner = withSpinner self.size = size self.insets = insets + self.alignment = alignment } } @@ -44,7 +48,7 @@ public final class TextCellNode: CellNode { insets = input.insets super.init() addSubnode(textNode) - textNode.attributedText = NSAttributedString.text(from: input.title, style: .medium(16), color: .lightGray) + textNode.attributedText = NSAttributedString.text(from: input.title, style: .medium(16), color: .lightGray, alignment: input.alignment) if input.withSpinner { addSubnode(spinner) }