mirror of
https://github.com/ProtonMail/protoncore_ios.git
synced 2026-01-16 23:00:24 +00:00
feat(SSO): CP-9204 Get pending devices when onForeground event is triggered
Refs: https://gitlab.protontech.ch/apple/shared/protoncore/-/merge_requests/2032
This commit is contained in:
commit
9e34f0b0a7
16 changed files with 830 additions and 54 deletions
|
|
@ -46,14 +46,16 @@ public struct User: Codable, Equatable, CustomDebugStringConvertible {
|
|||
|
||||
public let accountRecovery: AccountRecovery?
|
||||
public let lockedFlags: LockedFlags?
|
||||
// public let driveEarlyAccess: Int
|
||||
// public let mailSettings: MailSetting
|
||||
// public let addresses: [Address]
|
||||
public let flags: UserFlags?
|
||||
|
||||
public var hasAnySubscription: Bool {
|
||||
!subscribed.isEmpty
|
||||
}
|
||||
|
||||
public var isSSOAccount: Bool {
|
||||
flags?.sso ?? false
|
||||
}
|
||||
|
||||
public struct Subscribed: OptionSet, Codable, Equatable {
|
||||
public let rawValue: UInt8
|
||||
|
||||
|
|
@ -91,7 +93,8 @@ public struct User: Codable, Equatable, CustomDebugStringConvertible {
|
|||
displayName: String?,
|
||||
keys: [Key],
|
||||
accountRecovery: AccountRecovery? = nil,
|
||||
lockedFlags: LockedFlags? = nil) {
|
||||
lockedFlags: LockedFlags? = nil,
|
||||
flags: UserFlags? = nil) {
|
||||
self.ID = ID
|
||||
self.name = name
|
||||
self.usedSpace = usedSpace
|
||||
|
|
@ -115,6 +118,7 @@ public struct User: Codable, Equatable, CustomDebugStringConvertible {
|
|||
self.keys = keys
|
||||
self.accountRecovery = accountRecovery
|
||||
self.lockedFlags = lockedFlags
|
||||
self.flags = flags
|
||||
}
|
||||
|
||||
public var description: String {
|
||||
|
|
@ -459,6 +463,23 @@ extension UserInfo {
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: UserFlags
|
||||
|
||||
public struct UserFlags: Codable, Equatable {
|
||||
public let hasTemporaryPassword: Bool
|
||||
public let sso: Bool
|
||||
|
||||
public init(hasTemporaryPassword: Bool, sso: Bool) {
|
||||
self.hasTemporaryPassword = hasTemporaryPassword
|
||||
self.sso = sso
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case hasTemporaryPassword = "has-temporary-password"
|
||||
case sso
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: LockedFlags
|
||||
public struct LockedFlags: OptionSet, Codable {
|
||||
|
||||
|
|
@ -577,6 +598,11 @@ extension UserInfo {
|
|||
}
|
||||
|
||||
#if DEBUG
|
||||
public extension UserFlags {
|
||||
static var `default`: UserFlags {
|
||||
.init(hasTemporaryPassword: false, sso: false)
|
||||
}
|
||||
}
|
||||
public extension User {
|
||||
static var mock: User {
|
||||
.init(
|
||||
|
|
@ -599,7 +625,8 @@ public extension User {
|
|||
orgPrivateKey: nil,
|
||||
email: "proton@privacybydefault.com",
|
||||
displayName: "Proton",
|
||||
keys: []
|
||||
keys: [],
|
||||
flags: .default
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,6 +36,9 @@ public enum LSTranslation: TranslationsExposing {
|
|||
case _loginservice_error_generic
|
||||
case _loginservice_external_accounts_not_supported_popup_local_desc
|
||||
case _loginservice_external_accounts_address_required_popup_title
|
||||
case _sso_code_doesnt_match
|
||||
case _sso_invalid_code
|
||||
case _sso_device_secret_not_found
|
||||
|
||||
public var l10n: String {
|
||||
switch self {
|
||||
|
|
@ -47,6 +50,12 @@ public enum LSTranslation: TranslationsExposing {
|
|||
return localized(key: "Get a Proton Mail address linked to this account in your Proton web settings.", comment: "External accounts not supported popup local desc")
|
||||
case ._loginservice_external_accounts_address_required_popup_title:
|
||||
return localized(key: "Proton address required", comment: "External accounts address required popup title")
|
||||
case ._sso_code_doesnt_match:
|
||||
return localized(key: "Code doesn't match", comment: "SSO error displayed when the confirmation code introduced (in device B) does not match with the one in the device A")
|
||||
case ._sso_invalid_code:
|
||||
return localized(key: "Code isn't valid", comment: "SSO error displayed when the confirmation code is invalid (e.g. wrong length)")
|
||||
case ._sso_device_secret_not_found:
|
||||
return localized(key: "Device secret not found", comment: "SSO error displayed when there was an error while validating the code")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,3 +5,9 @@
|
|||
"Get a Proton Mail address linked to this account in your Proton web settings." = "Get a Proton Mail address linked to this account in your Proton web settings.";
|
||||
|
||||
"Proton address required" = "Proton address required";
|
||||
|
||||
"Code doesn't match" = "Code doesn't match";
|
||||
|
||||
"Code isn't valid" = "Code isn't valid";
|
||||
|
||||
"Device secret not found" = "Device secret not found";
|
||||
|
|
|
|||
144
libraries/Login/Sources/Services/Login/AuthDeviceManager.swift
Normal file
144
libraries/Login/Sources/Services/Login/AuthDeviceManager.swift
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
//
|
||||
// AuthDeviceManager.swift
|
||||
// ProtonCore-Login - Created on 03.01.25.
|
||||
//
|
||||
// Copyright (c) 2024 Proton Technologies AG
|
||||
//
|
||||
// This file is part of Proton Technologies AG and ProtonCore.
|
||||
//
|
||||
// ProtonCore is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// ProtonCore is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with ProtonCore. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
import ProtonCoreFeatureFlags
|
||||
import ProtonCoreLog
|
||||
import ProtonCoreNetworking
|
||||
import ProtonCoreServices
|
||||
import UIKit
|
||||
|
||||
public protocol UserManagerProvider {
|
||||
func getAllUsers() async throws -> [UserData]
|
||||
}
|
||||
|
||||
public protocol APIManagerProvider {
|
||||
func getApiService(userId: String) throws -> any APIService
|
||||
}
|
||||
|
||||
public struct PendingAuthDevicesUpdate {
|
||||
public let apiService: any APIService
|
||||
public let userData: UserData
|
||||
public let authDevices: [AuthDevice]
|
||||
}
|
||||
|
||||
public class AuthDeviceManager {
|
||||
|
||||
private var userManagerProvider: any UserManagerProvider
|
||||
private var apiManagerProvider: any APIManagerProvider
|
||||
|
||||
private var fetchPendingDevicesTask: Task<Void, Never>?
|
||||
private var activeTasks = [String: Task<Void, any Error>]()
|
||||
|
||||
public let pendingDevicesObserver = PassthroughSubject<PendingAuthDevicesUpdate, Never>()
|
||||
|
||||
public init(
|
||||
userManagerProvider: any UserManagerProvider,
|
||||
apiManagerProvider: any APIManagerProvider
|
||||
) {
|
||||
self.userManagerProvider = userManagerProvider
|
||||
self.apiManagerProvider = apiManagerProvider
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Public APIs
|
||||
|
||||
public extension AuthDeviceManager {
|
||||
|
||||
func setup() {
|
||||
#if !DEBUG
|
||||
guard FeatureFlagsRepository.shared.isEnabled(CoreFeatureFlagType.externalSSO, reloadValue: true) else { return }
|
||||
#endif
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(fetchPendingDevices),
|
||||
name: UIApplication.didBecomeActiveNotification,
|
||||
object: nil
|
||||
)
|
||||
}
|
||||
|
||||
func forceFetchPendingDevices() {
|
||||
fetchPendingDevices()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Private APIs
|
||||
|
||||
private extension AuthDeviceManager {
|
||||
|
||||
@objc func fetchPendingDevices() {
|
||||
guard fetchPendingDevicesTask == nil else { return }
|
||||
fetchPendingDevicesTask = Task { @MainActor [weak self] in
|
||||
defer {
|
||||
// swiftlint:disable discouraged_optional_self
|
||||
self?.fetchPendingDevicesTask?.cancel()
|
||||
self?.fetchPendingDevicesTask = nil
|
||||
// swiftlint:enable discouraged_optional_self
|
||||
}
|
||||
|
||||
guard let self else { return }
|
||||
|
||||
let allUsers = (try? await userManagerProvider.getAllUsers()) ?? []
|
||||
let ssoUsers = allUsers.filter({ $0.user.isSSOAccount })
|
||||
for user in ssoUsers {
|
||||
if activeTasks[user.user.ID] != nil {
|
||||
PMLog.info("Previous task not finished for userId: \(user.user.ID)")
|
||||
} else {
|
||||
activeTasks[user.user.ID] = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
|
||||
defer {
|
||||
self.activeTasks[user.user.ID] = nil
|
||||
}
|
||||
|
||||
await executeAuthDevicesSync(currentUser: user)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func executeAuthDevicesSync(currentUser: UserData) async {
|
||||
do {
|
||||
if Task.isCancelled { return }
|
||||
|
||||
let apiService = try apiManagerProvider.getApiService(userId: currentUser.user.ID)
|
||||
let authDevices = try await GetAuthDevices(apiService: apiService).invoke()
|
||||
let pendingDevices = authDevices.filter({ $0.state == .pendingActivation })
|
||||
if !pendingDevices.isEmpty {
|
||||
pendingDevicesObserver.send(.init(
|
||||
apiService: apiService,
|
||||
userData: currentUser,
|
||||
authDevices: pendingDevices
|
||||
))
|
||||
}
|
||||
} catch {
|
||||
PMLog.error(error)
|
||||
if let responseError = error as? ResponseError,
|
||||
let httpCode = responseError.httpCode,
|
||||
(500...599).contains(httpCode) {
|
||||
PMLog.debug("Server is down, backing off")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -33,6 +33,8 @@ public struct AuthDevice: Codable, Equatable {
|
|||
public var localizedClientName: String
|
||||
public var platform: Platform?
|
||||
public var lastActivityTime: String
|
||||
public var activationToken: String?
|
||||
public var activationAddressID: String?
|
||||
|
||||
public enum State: Int, Codable, Equatable {
|
||||
case inactive = 0
|
||||
|
|
@ -62,10 +64,13 @@ public struct AuthDevice: Codable, Equatable {
|
|||
public init(
|
||||
ID: String,
|
||||
deviceToken: String?,
|
||||
state: State, name: String,
|
||||
state: State,
|
||||
name: String,
|
||||
localizedClientName: String,
|
||||
platform: Platform?,
|
||||
lastActivityTime: String
|
||||
lastActivityTime: String,
|
||||
activationToken: String?,
|
||||
activationAddressID: String?
|
||||
) {
|
||||
self.ID = ID
|
||||
self.deviceToken = deviceToken
|
||||
|
|
@ -74,6 +79,8 @@ public struct AuthDevice: Codable, Equatable {
|
|||
self.localizedClientName = localizedClientName
|
||||
self.platform = platform
|
||||
self.lastActivityTime = lastActivityTime
|
||||
self.activationToken = activationToken
|
||||
self.activationAddressID = activationAddressID
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -87,7 +94,9 @@ public extension AuthDevice {
|
|||
name: "Your device",
|
||||
localizedClientName: "Proton Mail",
|
||||
platform: .iOS,
|
||||
lastActivityTime: "2024-12-12T14:40:37.537"
|
||||
lastActivityTime: "2024-12-12T14:40:37.537",
|
||||
activationToken: nil,
|
||||
activationAddressID: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ public struct ActivateAuthDevice {
|
|||
self.getEncryptedSecret = GetEncryptedSecret()
|
||||
}
|
||||
|
||||
// Activate local device
|
||||
public func invoke(
|
||||
userId: String,
|
||||
passphrase: String
|
||||
|
|
@ -61,6 +62,28 @@ public struct ActivateAuthDevice {
|
|||
)
|
||||
let (_, _): (_, DefaultResponse) = try await apiService.perform(request: activateAuthDeviceRequest)
|
||||
}
|
||||
|
||||
// Activate another device
|
||||
public func invoke(
|
||||
userId: String,
|
||||
deviceId: String,
|
||||
deviceSecret: String,
|
||||
passphrase: String
|
||||
) async throws {
|
||||
guard let encryptedSecret = try getEncryptedSecret.invoke(
|
||||
passphrase: passphrase,
|
||||
deviceSecret: deviceSecret
|
||||
) else {
|
||||
throw SSOLoginError.encryptedSecretNotFound
|
||||
}
|
||||
|
||||
// Call POST /auth/v4/devices/\(deviceID) and send the EncryptedSecret
|
||||
let activateAuthDeviceRequest = ActivateAuthDeviceRequest(
|
||||
deviceID: deviceId,
|
||||
encryptedSecret: encryptedSecret
|
||||
)
|
||||
let (_, _): (_, DefaultResponse) = try await apiService.perform(request: activateAuthDeviceRequest)
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
//
|
||||
// RequestAdminHelp.swift
|
||||
// ProtonCore-Login - Created on 31.12.24.
|
||||
//
|
||||
// Copyright (c) 2024 Proton Technologies AG
|
||||
//
|
||||
// This file is part of Proton Technologies AG and ProtonCore.
|
||||
//
|
||||
// ProtonCore is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// ProtonCore is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with ProtonCore. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
#if os(iOS)
|
||||
import ProtonCoreServices
|
||||
|
||||
public struct RejectAuthDevice {
|
||||
private let apiService: APIService
|
||||
|
||||
public init(apiService: APIService) {
|
||||
self.apiService = apiService
|
||||
}
|
||||
|
||||
public func invoke(deviceId: String) async throws {
|
||||
let request = RejectAuthDeviceRequest(deviceID: deviceId)
|
||||
_ = try await apiService.perform(request: request)
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
//
|
||||
// RequestAdminHelp.swift
|
||||
// ProtonCore-Login - Created on 31.12.24.
|
||||
//
|
||||
// Copyright (c) 2024 Proton Technologies AG
|
||||
//
|
||||
// This file is part of Proton Technologies AG and ProtonCore.
|
||||
//
|
||||
// ProtonCore is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// ProtonCore is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with ProtonCore. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
#if os(iOS)
|
||||
import Foundation
|
||||
import ProtonCoreCrypto
|
||||
import ProtonCoreDataModel
|
||||
import ProtonCoreLog
|
||||
import ProtonCoreServices
|
||||
|
||||
public struct ValidateConfirmationCode {
|
||||
|
||||
public init() {}
|
||||
|
||||
// Returns the decrypted DeviceSecret
|
||||
public func invoke(
|
||||
userData: UserData,
|
||||
authDevice: AuthDevice,
|
||||
code: String
|
||||
) throws -> String {
|
||||
guard code.count == 4 else { throw ValidationError.invalidCode }
|
||||
guard let activationToken = authDevice.activationToken,
|
||||
let activationAddressId = authDevice.activationAddressID else {
|
||||
PMLog.error("ActivationToken or ActivationAddressId not found")
|
||||
throw ValidationError.deviceSecretNotFound
|
||||
}
|
||||
guard let userAddress = userData.addresses.first(where: { $0.addressID == activationAddressId }) else {
|
||||
PMLog.error("User address with id \(activationAddressId) not found")
|
||||
throw ValidationError.deviceSecretNotFound
|
||||
}
|
||||
|
||||
let decryptedDeviceSecret = try decryptDeviceSecret(
|
||||
userData: userData,
|
||||
userAddress: userAddress,
|
||||
activationToken: activationToken
|
||||
)
|
||||
|
||||
let decryptedCode = String(Crockford32.encode(decryptedDeviceSecret.sha256.data(using: .utf8)!)).prefix(4)
|
||||
|
||||
guard decryptedCode == code else {
|
||||
throw ValidationError.doesNotMatch
|
||||
}
|
||||
|
||||
return decryptedDeviceSecret
|
||||
}
|
||||
|
||||
private func decryptDeviceSecret(
|
||||
userData: UserData,
|
||||
userAddress: Address,
|
||||
activationToken: String
|
||||
) throws -> String {
|
||||
// User keys used to decrypt the UserAddress key token
|
||||
let userDecryptionKeys: [DecryptionKey] = userData.user.keys.compactMap({
|
||||
.init(privateKey: ArmoredKey(value: $0.privateKey), passphrase: Passphrase(value: userData.getMailboxPassword!))
|
||||
})
|
||||
guard let encryptedUserAddressKeyToken = userAddress.keys.primary()?.token else {
|
||||
PMLog.error("User address key token not found")
|
||||
throw ValidationError.deviceSecretNotFound
|
||||
}
|
||||
|
||||
// Decrypted address token used for Address Keys
|
||||
let addressToken: String = try Decryptor.decrypt(
|
||||
decryptionKeys: userDecryptionKeys,
|
||||
encrypted: ArmoredMessage(value: encryptedUserAddressKeyToken)
|
||||
)
|
||||
|
||||
// Address Keys used to decrypt activationToken
|
||||
let decryptionKeys: [DecryptionKey] = userAddress.keys.map({
|
||||
.init(privateKey: ArmoredKey(value: $0.privateKey), passphrase: Passphrase(value: addressToken))
|
||||
})
|
||||
|
||||
let decryptedDeviceSecret: String = try Decryptor.decrypt(
|
||||
decryptionKeys: decryptionKeys,
|
||||
encrypted: ArmoredMessage(value: activationToken)
|
||||
)
|
||||
|
||||
return decryptedDeviceSecret
|
||||
}
|
||||
|
||||
public enum ValidationError: LocalizedError {
|
||||
case invalidCode
|
||||
case doesNotMatch
|
||||
case deviceSecretNotFound
|
||||
|
||||
public var errorDescription: String? {
|
||||
return switch self {
|
||||
case .invalidCode:
|
||||
LSTranslation._sso_invalid_code.l10n
|
||||
case .doesNotMatch:
|
||||
LSTranslation._sso_code_doesnt_match.l10n
|
||||
case .deviceSecretNotFound:
|
||||
LSTranslation._sso_device_secret_not_found.l10n
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
|
|
@ -192,6 +192,7 @@ public enum LUITranslation: TranslationsExposing {
|
|||
case to_your_organization
|
||||
case sso_login_error_screen_title
|
||||
case sso_login_error_screen_description
|
||||
case unknown
|
||||
|
||||
public var l10n: String {
|
||||
switch self {
|
||||
|
|
@ -467,7 +468,7 @@ public enum LUITranslation: TranslationsExposing {
|
|||
case .sign_in_request_title:
|
||||
return localized(key: "Sign-in requested on another device. Was it you?", comment: "Sign in request title for device B")
|
||||
case .sign_in_request_description:
|
||||
return localized(key: "Check that the confirmation code match the code on your other device.", comment: "Sign in request description for device B")
|
||||
return localized(key: "Enter the confirmation code we sent on your other device for %@.", comment: "Sign in request description for device B")
|
||||
case .confirmation_code:
|
||||
return localized(key: "Confirmation code", comment: "Textfield title")
|
||||
case .sign_in_request_disclaimer:
|
||||
|
|
@ -526,6 +527,8 @@ public enum LUITranslation: TranslationsExposing {
|
|||
return localized(key: "Sign in with single sign-on", comment: "SSO Signing in error screen title")
|
||||
case .sso_login_error_screen_description:
|
||||
return localized(key: "Your organization uses SSO. Continue to sign in with your third-party SSO provider.", comment: "SSO Signing in error screen description")
|
||||
case .unknown:
|
||||
return localized(key: "Unknown", comment: "Used when some account value is not found. Should never happen.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
108
libraries/LoginUI/Sources/Managers/AuthDeviceManagerUI.swift
Normal file
108
libraries/LoginUI/Sources/Managers/AuthDeviceManagerUI.swift
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
//
|
||||
// AuthDeviceManager.swift
|
||||
// ProtonCore-Login - Created on 03.01.25.
|
||||
//
|
||||
// Copyright (c) 2024 Proton Technologies AG
|
||||
//
|
||||
// This file is part of Proton Technologies AG and ProtonCore.
|
||||
//
|
||||
// ProtonCore is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// ProtonCore is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with ProtonCore. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import Combine
|
||||
import ProtonCoreLogin
|
||||
import ProtonCoreUIFoundations
|
||||
import UIKit
|
||||
|
||||
public class AuthDeviceManagerUI {
|
||||
private let authDeviceManager: AuthDeviceManager
|
||||
|
||||
var appWindow: UIWindow? = {
|
||||
UIApplication
|
||||
.shared
|
||||
.connectedScenes
|
||||
.compactMap { ($0 as? UIWindowScene)?.keyWindow }
|
||||
.last
|
||||
}()
|
||||
|
||||
var overlayWindow: UIWindow?
|
||||
|
||||
private var cancellables: Set<AnyCancellable> = .init()
|
||||
|
||||
public init(authDeviceManager: AuthDeviceManager) {
|
||||
self.authDeviceManager = authDeviceManager
|
||||
}
|
||||
}
|
||||
|
||||
public extension AuthDeviceManagerUI {
|
||||
func setup() {
|
||||
authDeviceManager.setup()
|
||||
observePendingDevices()
|
||||
}
|
||||
|
||||
func forceFetchPendingDevices() {
|
||||
authDeviceManager.forceFetchPendingDevices()
|
||||
}
|
||||
}
|
||||
|
||||
private extension AuthDeviceManagerUI {
|
||||
func observePendingDevices() {
|
||||
authDeviceManager.pendingDevicesObserver
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] pendingDevicesUpdate in
|
||||
guard let self else { return }
|
||||
guard overlayWindow == nil else { return }
|
||||
Task {
|
||||
await self.presentGrantAccessView(pendingDevicesUpdate: pendingDevicesUpdate)
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func presentGrantAccessView(pendingDevicesUpdate: PendingAuthDevicesUpdate) {
|
||||
guard let windowScene = appWindow?.windowScene else { return }
|
||||
|
||||
overlayWindow = UIWindow(windowScene: windowScene)
|
||||
if let style = appWindow?.overrideUserInterfaceStyle {
|
||||
overlayWindow?.overrideUserInterfaceStyle = style
|
||||
}
|
||||
|
||||
let transparentViewController = UIViewController()
|
||||
transparentViewController.view.backgroundColor = .clear
|
||||
overlayWindow?.rootViewController = transparentViewController
|
||||
overlayWindow?.makeKeyAndVisible()
|
||||
|
||||
let viewController = GrantAccessViewController(dependencies: .init(
|
||||
apiService: pendingDevicesUpdate.apiService,
|
||||
authDevices: pendingDevicesUpdate.authDevices,
|
||||
userData: pendingDevicesUpdate.userData,
|
||||
navigationDelegate: self
|
||||
))
|
||||
|
||||
let navigationController = DarkModeAwareNavigationViewController(rootViewController: viewController)
|
||||
|
||||
navigationController.modalPresentationStyle = .fullScreen
|
||||
transparentViewController.present(navigationController, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
extension AuthDeviceManagerUI: GrantAccessViewNavigationDelegate {
|
||||
func dismissGrantAccessView() {
|
||||
overlayWindow?.rootViewController?.dismiss(animated: true, completion: { [weak self] in
|
||||
guard let self else { return }
|
||||
self.overlayWindow = nil
|
||||
appWindow?.makeKeyAndVisible()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -278,7 +278,7 @@
|
|||
|
||||
"Sign-in requested on another device. Was it you?" = "Sign-in requested on another device. Was it you?";
|
||||
|
||||
"Check that the confirmation code match the code on your other device." = "Check that the confirmation code match the code on your other device.";
|
||||
"Enter the confirmation code we sent on your other device for %@." = "Enter the confirmation code we sent on your other device for %@.";
|
||||
|
||||
"Confirmation code" = "Confirmation code";
|
||||
|
||||
|
|
@ -337,3 +337,5 @@
|
|||
"Sign in with single sign-on" = "Sign in with single sign-on";
|
||||
|
||||
"Your organization uses SSO. Continue to sign in with your third-party SSO provider." = "Your organization uses SSO. Continue to sign in with your third-party SSO provider.";
|
||||
|
||||
"Unknown" = "Unknown";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,60 @@
|
|||
//
|
||||
// GrantAccessViewController.swift
|
||||
// ProtonCore-LoginUI - Created on 07/01/2025.
|
||||
//
|
||||
// Copyright (c) 2025 Proton AG
|
||||
//
|
||||
// This file is part of Proton AG and ProtonCore.
|
||||
//
|
||||
// ProtonCore is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// ProtonCore is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with ProtonCore. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
#if os(iOS)
|
||||
|
||||
import Foundation
|
||||
import ProtonCoreUIFoundations
|
||||
import SwiftUI
|
||||
|
||||
protocol GrantAccessViewNavigationDelegate: AnyObject {
|
||||
func dismissGrantAccessView()
|
||||
}
|
||||
|
||||
public final class GrantAccessViewController: UIHostingController<GrantAccessView> {
|
||||
|
||||
let viewModel: GrantAccessView.ViewModel
|
||||
weak var navigationDelegate: GrantAccessViewNavigationDelegate?
|
||||
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
init(dependencies: GrantAccessView.Dependencies) {
|
||||
self.viewModel = GrantAccessView.ViewModel(dependencies: dependencies)
|
||||
self.navigationDelegate = dependencies.navigationDelegate
|
||||
let view = GrantAccessView(viewModel: self.viewModel)
|
||||
super.init(rootView: view)
|
||||
}
|
||||
|
||||
override public func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
view.backgroundColor = ColorProvider.BackgroundNorm
|
||||
navigationItem.leftBarButtonItem = .button(on: self, action: #selector(dismissViewController), image: IconProvider.crossBig)
|
||||
}
|
||||
|
||||
@objc
|
||||
func dismissViewController() {
|
||||
navigationDelegate?.dismissGrantAccessView()
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
|
|
@ -0,0 +1,144 @@
|
|||
//
|
||||
// GrantAccessViewModel.swift
|
||||
// ProtonCore-LoginUI - Created on 07/01/2025.
|
||||
//
|
||||
// Copyright (c) 2025 Proton AG
|
||||
//
|
||||
// This file is part of Proton AG and ProtonCore.
|
||||
//
|
||||
// ProtonCore is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// ProtonCore is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with ProtonCore. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
#if os(iOS)
|
||||
|
||||
import SwiftUI
|
||||
import ProtonCoreLog
|
||||
import ProtonCoreLogin
|
||||
import ProtonCoreServices
|
||||
import ProtonCoreUIFoundations
|
||||
|
||||
extension GrantAccessView {
|
||||
struct Dependencies {
|
||||
let apiService: APIService?
|
||||
let authDevices: [AuthDevice]
|
||||
let userData: LoginData
|
||||
let navigationDelegate: GrantAccessViewNavigationDelegate?
|
||||
}
|
||||
}
|
||||
|
||||
extension GrantAccessView {
|
||||
|
||||
@MainActor
|
||||
final class ViewModel: ObservableObject {
|
||||
private let authDevices: [AuthDevice]
|
||||
private let userData: LoginData
|
||||
|
||||
private var rejectAuthDevice: RejectAuthDevice?
|
||||
private var validateConfirmationCode: ValidateConfirmationCode
|
||||
private var activateAuthDevice: ActivateAuthDevice?
|
||||
|
||||
weak var navigationDelegate: GrantAccessViewNavigationDelegate?
|
||||
|
||||
@Published var bannerState: BannerState = .none
|
||||
@Published var confirmationCodeStyle: PCTextFieldStyle = .init(mode: .idle)
|
||||
@Published var confirmationCodeContent: PCTextFieldContent = .init(
|
||||
title: LUITranslation.confirmation_code.l10n,
|
||||
autocapitalization: .allCharacters
|
||||
)
|
||||
|
||||
var memberEmail: String { userData.user.email ?? LUITranslation.unknown.l10n }
|
||||
|
||||
var bodyDescription: String {
|
||||
return String.localizedStringWithFormat(
|
||||
LUITranslation.sign_in_request_description.l10n,
|
||||
memberEmail
|
||||
)
|
||||
}
|
||||
|
||||
init(dependencies: Dependencies) {
|
||||
self.authDevices = dependencies.authDevices
|
||||
self.userData = dependencies.userData
|
||||
self.navigationDelegate = dependencies.navigationDelegate
|
||||
|
||||
let deviceSecretRepository = DeviceSecretRepository()
|
||||
if let apiService = dependencies.apiService {
|
||||
rejectAuthDevice = RejectAuthDevice(apiService: apiService)
|
||||
activateAuthDevice = ActivateAuthDevice(
|
||||
apiService: apiService,
|
||||
deviceSecretRepository: deviceSecretRepository
|
||||
)
|
||||
}
|
||||
validateConfirmationCode = ValidateConfirmationCode()
|
||||
}
|
||||
|
||||
func primaryActionButtonTapped() {
|
||||
Task {
|
||||
do {
|
||||
guard let activateAuthDevice else { return }
|
||||
guard let authDevice = authDevices.first else { throw SSOLoginError.authDeviceNotFound }
|
||||
resetConfirmationCodeInput()
|
||||
|
||||
let deviceSecret = try validateConfirmationCode.invoke(
|
||||
userData: userData,
|
||||
authDevice: authDevice,
|
||||
code: confirmationCodeContent.text
|
||||
)
|
||||
|
||||
guard let mailboxPassphrase = userData.getMailboxPassword else {
|
||||
PMLog.error("Passphrase not found")
|
||||
bannerState = .error(content: .init(message: LUITranslation.error_occured.l10n))
|
||||
return
|
||||
}
|
||||
try await activateAuthDevice.invoke(
|
||||
userId: userData.user.ID,
|
||||
deviceId: authDevice.ID,
|
||||
deviceSecret: deviceSecret,
|
||||
passphrase: mailboxPassphrase
|
||||
)
|
||||
navigationDelegate?.dismissGrantAccessView()
|
||||
} catch let error as ValidateConfirmationCode.ValidationError {
|
||||
displayConfirmationCodeError(error: error)
|
||||
} catch {
|
||||
PMLog.error(error)
|
||||
bannerState = .error(content: .init(message: error.localizedDescription))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func secondaryActionButtonTapped() {
|
||||
Task {
|
||||
do {
|
||||
guard let rejectAuthDevice else { return }
|
||||
guard let authDevice = authDevices.first else { throw SSOLoginError.authDeviceNotFound }
|
||||
try await rejectAuthDevice.invoke(deviceId: authDevice.ID)
|
||||
navigationDelegate?.dismissGrantAccessView()
|
||||
} catch {
|
||||
PMLog.error(error)
|
||||
bannerState = .error(content: .init(message: error.localizedDescription))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func displayConfirmationCodeError(error: ValidateConfirmationCode.ValidationError) {
|
||||
confirmationCodeStyle.mode = .error
|
||||
confirmationCodeContent.footnote = error.localizedDescription
|
||||
}
|
||||
|
||||
private func resetConfirmationCodeInput() {
|
||||
confirmationCodeStyle.mode = .idle
|
||||
confirmationCodeContent.footnote = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
|
|
@ -39,7 +39,6 @@ extension SignInRequestView {
|
|||
enum ViewMode {
|
||||
case requestForAdminApproval(code: String)
|
||||
case requestApproveFromAnotherDevice(code: String, devices: [AuthDevice])
|
||||
case approvingAccess
|
||||
}
|
||||
|
||||
@MainActor
|
||||
|
|
@ -51,8 +50,6 @@ extension SignInRequestView {
|
|||
|
||||
weak var ssoNavigationDelegate: GlobalSSONavigationDelegate?
|
||||
|
||||
@Published var confirmationCodeContent: PCTextFieldContent = .init(title: LUITranslation.confirmation_code.l10n)
|
||||
|
||||
var adminEmail: String { unprivatizationInfo.adminEmail }
|
||||
var memberEmail: String { userData.user.email ?? "unknown" }
|
||||
|
||||
|
|
@ -70,7 +67,6 @@ extension SignInRequestView {
|
|||
switch mode {
|
||||
case .requestForAdminApproval: return LUITranslation.share_confirmation_code_title.l10n
|
||||
case .requestApproveFromAnotherDevice: return LUITranslation.approve_sign_in_another_device_title.l10n
|
||||
case .approvingAccess: return LUITranslation.sign_in_request_title.l10n
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -83,7 +79,6 @@ extension SignInRequestView {
|
|||
memberEmail
|
||||
)
|
||||
case .requestApproveFromAnotherDevice: return LUITranslation.approve_sign_in_another_device_description.l10n
|
||||
case .approvingAccess: return LUITranslation.sign_in_request_description.l10n
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -91,7 +86,6 @@ extension SignInRequestView {
|
|||
switch mode {
|
||||
case .requestForAdminApproval: return LUITranslation.use_backup_password_instead.l10n
|
||||
case .requestApproveFromAnotherDevice: return LUITranslation.use_backup_password_instead.l10n
|
||||
case .approvingAccess: return LUITranslation.yes_it_was_me.l10n
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -99,7 +93,6 @@ extension SignInRequestView {
|
|||
switch mode {
|
||||
case .requestForAdminApproval: return LUITranslation._core_cancel_button.l10n
|
||||
case .requestApproveFromAnotherDevice: return LUITranslation.ask_administrator_for_help.l10n
|
||||
case .approvingAccess: return LUITranslation.no_it_wasnt_me.l10n
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -109,8 +102,6 @@ extension SignInRequestView {
|
|||
ssoNavigationDelegate?.showEnterBackupPassword(data: userData, unprivatizationInfo: unprivatizationInfo)
|
||||
case .requestApproveFromAnotherDevice:
|
||||
ssoNavigationDelegate?.showEnterBackupPassword(data: userData, unprivatizationInfo: unprivatizationInfo)
|
||||
case .approvingAccess:
|
||||
break
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -121,8 +112,6 @@ extension SignInRequestView {
|
|||
ssoNavigationDelegate?.globalSSOLoginDidCancel()
|
||||
case .requestApproveFromAnotherDevice:
|
||||
ssoNavigationDelegate?.showRequestAdminHelpConfirmation(data: userData, unprivatizationInfo: unprivatizationInfo)
|
||||
case .approvingAccess:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
131
libraries/LoginUI/Sources/Views/SSO/GrantAccessView.swift
Normal file
131
libraries/LoginUI/Sources/Views/SSO/GrantAccessView.swift
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
//
|
||||
// GrantAccessView.swift
|
||||
// ProtonCore-LoginUI - Created on 07/01/2025.
|
||||
//
|
||||
// Copyright (c) 2024 Proton AG
|
||||
//
|
||||
// This file is part of Proton AG and ProtonCore.
|
||||
//
|
||||
// ProtonCore is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// ProtonCore is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with ProtonCore. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
#if os(iOS)
|
||||
|
||||
import SwiftUI
|
||||
import ProtonCoreLogin
|
||||
import ProtonCoreUIFoundations
|
||||
|
||||
public struct GrantAccessView: View {
|
||||
|
||||
@StateObject var viewModel: ViewModel
|
||||
|
||||
private enum Constants {
|
||||
static let itemSpacing: CGFloat = 20
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: Constants.itemSpacing) {
|
||||
Text(LUITranslation.sign_in_request_title.l10n)
|
||||
.font(.title2)
|
||||
.fontWeight(.bold)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
bodyText()
|
||||
|
||||
confirmationCodeInput
|
||||
|
||||
VStack {
|
||||
PCButton(
|
||||
style: .constant(.init(mode: .solid)),
|
||||
content: .constant(.init(
|
||||
title: LUITranslation.yes_it_was_me.l10n,
|
||||
action: viewModel.primaryActionButtonTapped
|
||||
))
|
||||
)
|
||||
|
||||
PCButton(
|
||||
style: .constant(.init(mode: .text)),
|
||||
content: .constant(.init(
|
||||
title: LUITranslation.no_it_wasnt_me.l10n,
|
||||
action: viewModel.secondaryActionButtonTapped
|
||||
))
|
||||
)
|
||||
}
|
||||
.padding(.top, Constants.itemSpacing)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(Constants.itemSpacing)
|
||||
.foregroundColor(ColorProvider.TextNorm)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.background(
|
||||
ColorProvider.BackgroundNorm
|
||||
.edgesIgnoringSafeArea(.all)
|
||||
)
|
||||
.bannerDisplayable(bannerState: $viewModel.bannerState,
|
||||
configuration: .init(position: .bottom))
|
||||
}
|
||||
|
||||
private func bodyText() -> some View {
|
||||
var attributedString = AttributedString(viewModel.bodyDescription)
|
||||
|
||||
attributedString.font = Font.subheadline
|
||||
attributedString.foregroundColor = ColorProvider.TextWeak
|
||||
|
||||
if let memberRange = attributedString.range(of: viewModel.memberEmail) {
|
||||
attributedString[memberRange].font = Font.subheadline.weight(.bold)
|
||||
}
|
||||
|
||||
return Text(attributedString)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
|
||||
@ViewBuilder
|
||||
private var confirmationCodeInput: some View {
|
||||
PCTextField(
|
||||
style: $viewModel.confirmationCodeStyle,
|
||||
content: $viewModel.confirmationCodeContent
|
||||
)
|
||||
|
||||
Text(LUITranslation.sign_in_request_disclaimer.l10n)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(ColorProvider.TextWeak)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
|
||||
#Preview("ApprovingAccess") {
|
||||
NavigationView {
|
||||
GrantAccessView(viewModel: .init(dependencies: .init(
|
||||
apiService: nil,
|
||||
authDevices: [.mock],
|
||||
userData: .init(
|
||||
credential: .none,
|
||||
user: .mock,
|
||||
salts: [],
|
||||
passphrases: [:],
|
||||
addresses: [],
|
||||
scopes: []
|
||||
),
|
||||
navigationDelegate: nil
|
||||
)))
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif
|
||||
|
|
@ -88,8 +88,6 @@ public struct SignInRequestView: View {
|
|||
displayConfirmationCode(code: code)
|
||||
devicesContainer
|
||||
.padding(.top, Constants.itemSpacing)
|
||||
case .approvingAccess:
|
||||
confirmationCodeInput
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -142,19 +140,6 @@ public struct SignInRequestView: View {
|
|||
)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var confirmationCodeInput: some View {
|
||||
PCTextField(
|
||||
style: .constant(.init(mode: .idle)),
|
||||
content: $viewModel.confirmationCodeContent
|
||||
)
|
||||
|
||||
Text(LUITranslation.sign_in_request_disclaimer.l10n)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(ColorProvider.TextWeak)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var devicesContainer: some View {
|
||||
VStack(alignment: .leading, spacing: Constants.itemSpacing) {
|
||||
|
|
@ -226,24 +211,6 @@ public struct SignInRequestView: View {
|
|||
)))
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("ApprovingAccess") {
|
||||
NavigationView {
|
||||
SignInRequestView(viewModel: .init(dependencies: .init(
|
||||
mode: .approvingAccess,
|
||||
userData: .init(
|
||||
credential: .none,
|
||||
user: .mock,
|
||||
salts: [],
|
||||
passphrases: [:],
|
||||
addresses: [],
|
||||
scopes: []
|
||||
),
|
||||
unprivatizationInfo: .init(state: .ready, adminEmail: "admin@privacybydefault.com", orgKeyFingerprintSignature: .init(value: ""), orgPublicKey: .init(value: "")),
|
||||
ssoNavigationDelegate: nil
|
||||
)))
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue