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:
Alex Morral 2025-01-09 16:33:41 +00:00
commit 9e34f0b0a7
16 changed files with 830 additions and 54 deletions

View file

@ -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
)
}
}

View file

@ -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")
}
}
}

View file

@ -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";

View 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")
}
}
}
}

View file

@ -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
)
}
}

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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.")
}
}
}

View 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()
})
}
}

View file

@ -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";

View file

@ -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

View file

@ -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

View file

@ -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
}
}
}

View 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

View file

@ -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