Skip to content

Commit

Permalink
PM-11147: Add import logins view (#1019)
Browse files Browse the repository at this point in the history
  • Loading branch information
matt-livefront authored Oct 10, 2024
1 parent 317cb91 commit 621d901
Show file tree
Hide file tree
Showing 19 changed files with 450 additions and 2 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"images" : [
{
"filename" : "import.pdf",
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"filename" : "import-dark.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true
}
}
Binary file not shown.
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -1007,3 +1007,11 @@
"NewLogin" = "New login";
"ImportSavedLogins" = "Import saved logins";
"ImportSavedLoginsDescriptionLong" = "Use a computer to import logins from an existing password manager.";
"ImportLogins" = "Import logins";
"GiveYourVaultAHeadStart" = "Give your vault a head start";
"ImportLoginsDescriptionLong" = "From your computer, follow these instructions to export saved passwords from your browser or other password manager. Then, safely import them to Bitwarden.";
"ImportLoginsLater" = "Import logins later";
"ImportLoginsLaterQuestion" = "Import logins later?";
"YouCanReturnToCompleteThisStepAnytimeInVaultUnderSettings" = "You can return to complete this step anytime in Vault under Settings.";
"DoYouHaveAComputerAvailable" = "Do you have a computer available?";
"DoYouHaveAComputerAvailableDescriptionLong" = "The following instructions will guide you through importing logins from your desktop or laptop computer.";
37 changes: 37 additions & 0 deletions BitwardenShared/UI/Vault/Extensions/Alert+Vault.swift
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,43 @@ extension Alert {
)
}

/// An alert asking the user if they have a computer available to import logins.
///
/// - Parameter action: The action taken when the user taps on continue.
/// - Returns: An alert asking the user if they have a computer available to import logins.
///
static func importLoginsComputerAvailable(action: @escaping () async -> Void) -> Alert {
Alert(
title: Localizations.doYouHaveAComputerAvailable,
message: Localizations.doYouHaveAComputerAvailableDescriptionLong,
alertActions: [
AlertAction(title: Localizations.cancel, style: .cancel),
AlertAction(title: Localizations.continue, style: .default) { _ in
await action()
},
]
)
}

/// An alert confirming that the user wants to import logins later in settings.
///
/// - Parameter action: The action taken when the user taps on Confirm to import logins later
/// in settings.
/// - Returns: An alert confirming that the user wants to import logins later in settings.
///
static func importLoginsLater(action: @escaping () async -> Void) -> Alert {
Alert(
title: Localizations.importLoginsLaterQuestion,
message: Localizations.youCanReturnToCompleteThisStepAnytimeInVaultUnderSettings,
alertActions: [
AlertAction(title: Localizations.cancel, style: .cancel),
AlertAction(title: Localizations.confirm, style: .default) { _ in
await action()
},
]
)
}

/// An alert presenting the user with more options for a vault list item.
///
/// - Parameters:
Expand Down
40 changes: 40 additions & 0 deletions BitwardenShared/UI/Vault/Extensions/AlertVaultTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,46 @@ class AlertVaultTests: BitwardenTestCase {
XCTAssertEqual(subject.alertActions[3].style, .cancel)
}

/// `importLoginsComputerAvailable(action:)` constructs an `Alert` that confirms that the user
/// has a computer available to import logins.
func test_importLoginsComputerAvailable() async throws {
var actionCalled = false
let subject = Alert.importLoginsComputerAvailable { actionCalled = true }

XCTAssertEqual(subject.title, Localizations.doYouHaveAComputerAvailable)
XCTAssertEqual(subject.message, Localizations.doYouHaveAComputerAvailableDescriptionLong)
XCTAssertEqual(subject.alertActions[0].title, Localizations.cancel)
XCTAssertEqual(subject.alertActions[0].style, .cancel)
XCTAssertEqual(subject.alertActions[1].title, Localizations.continue)
XCTAssertEqual(subject.alertActions[1].style, .default)

try await subject.tapCancel()
XCTAssertFalse(actionCalled)

try await subject.tapAction(title: Localizations.continue)
XCTAssertTrue(actionCalled)
}

/// `static importLoginsLater(action:)` constructs an `Alert` that confirms that the user
/// wants to import logins later in settings.
func test_importLoginsLater() async throws {
var actionCalled = false
let subject = Alert.importLoginsLater { actionCalled = true }

XCTAssertEqual(subject.title, Localizations.importLoginsLaterQuestion)
XCTAssertEqual(subject.message, Localizations.youCanReturnToCompleteThisStepAnytimeInVaultUnderSettings)
XCTAssertEqual(subject.alertActions[0].title, Localizations.cancel)
XCTAssertEqual(subject.alertActions[0].style, .cancel)
XCTAssertEqual(subject.alertActions[1].title, Localizations.confirm)
XCTAssertEqual(subject.alertActions[1].style, .default)

try await subject.tapCancel()
XCTAssertFalse(actionCalled)

try await subject.tapAction(title: Localizations.confirm)
XCTAssertTrue(actionCalled)
}

/// `passwordAutofillInformation()` constructs an `Alert` that informs the user about password
/// autofill.
func test_passwordAutofillInformation() {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// MARK: - ImportLoginsAction

/// Actions that can be processed by a `ImportLoginsProcessor`.
///
enum ImportLoginsAction: Equatable {
/// Dismiss the view.
case dismiss

/// The get started button was tapped.
case getStarted
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// MARK: - ImportLoginsEffect

/// Effects handled by the `ImportLoginsProcessor`.
///
enum ImportLoginsEffect: Equatable {
/// The import logins button was tapped.
case importLoginsLater
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// MARK: - ImportLoginsProcessor

/// The processor used to manage state and handle actions for the import logins screen.
///
class ImportLoginsProcessor: StateProcessor<ImportLoginsState, ImportLoginsAction, ImportLoginsEffect> {
// MARK: Types

typealias Services = HasErrorReporter
& HasStateService

// MARK: Private Properties

/// The coordinator that handles navigation.
private let coordinator: AnyCoordinator<VaultRoute, AuthAction>

/// The services used by this processor.
private let services: Services

// MARK: Initialization

/// Creates a new `ImportLoginsProcessor`.
///
/// - Parameters:
/// - coordinator: The coordinator that handles navigation.
/// - services: The services required by this processor.
/// - state: The initial state of the processor.
///
init(
coordinator: AnyCoordinator<VaultRoute, AuthAction>,
services: Services,
state: ImportLoginsState
) {
self.coordinator = coordinator
self.services = services
super.init(state: state)
}

// MARK: Methods

override func perform(_ effect: ImportLoginsEffect) async {
switch effect {
case .importLoginsLater:
showImportLoginsLaterAlert()
}
}

override func receive(_ action: ImportLoginsAction) {
switch action {
case .dismiss:
coordinator.navigate(to: .dismiss)
case .getStarted:
showGetStartAlert()
}
}

// MARK: Private

/// Shows the alert confirming the user wants to get started on importing logins.
///
private func showGetStartAlert() {
coordinator.showAlert(.importLoginsComputerAvailable {
// TODO: PM-11150 Show step 1
})
}

/// Shows the alert confirming the user wants to import logins later.
///
private func showImportLoginsLaterAlert() {
coordinator.showAlert(.importLoginsLater {
do {
try await self.services.stateService.setAccountSetupImportLogins(.setUpLater)
} catch {
self.services.errorReporter.log(error: error)
}
self.coordinator.navigate(to: .dismiss)
})
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import XCTest

@testable import BitwardenShared

class ImportLoginsProcessorTests: BitwardenTestCase {
// MARK: Properties

var coordinator: MockCoordinator<VaultRoute, AuthAction>!
var errorReporter: MockErrorReporter!
var stateService: MockStateService!
var subject: ImportLoginsProcessor!

// MARK: Setup & Teardown

override func setUp() {
super.setUp()

coordinator = MockCoordinator()
errorReporter = MockErrorReporter()
stateService = MockStateService()

subject = ImportLoginsProcessor(
coordinator: coordinator.asAnyCoordinator(),
services: ServiceContainer.withMocks(
errorReporter: errorReporter,
stateService: stateService
),
state: ImportLoginsState()
)
}

override func tearDown() {
super.tearDown()

coordinator = nil
errorReporter = nil
stateService = nil
subject = nil
}

// MARK: Tests

/// `perform(_:)` with `.importLoginsLater` shows an alert for confirming the user wants to
/// import logins later.
@MainActor
func test_perform_importLoginsLater() async throws {
stateService.activeAccount = .fixture()
stateService.accountSetupImportLogins["1"] = .incomplete

await subject.perform(.importLoginsLater)

let alert = try XCTUnwrap(coordinator.alertShown.last)
XCTAssertEqual(alert, .importLoginsLater {})
try await alert.tapAction(title: Localizations.confirm)

XCTAssertEqual(coordinator.routes, [.dismiss])
XCTAssertEqual(stateService.accountSetupImportLogins["1"], .setUpLater)
}

/// `perform(_:)` with `.importLoginsLater` logs an error if one occurs.
@MainActor
func test_perform_importLoginsLater_error() async throws {
await subject.perform(.importLoginsLater)

let alert = try XCTUnwrap(coordinator.alertShown.last)
XCTAssertEqual(alert, .importLoginsLater {})
try await alert.tapAction(title: Localizations.confirm)

XCTAssertEqual(coordinator.routes, [.dismiss])
XCTAssertEqual(errorReporter.errors as? [StateServiceError], [.noActiveAccount])
}

/// `receive(_:)` with `.dismiss` dismisses the view.
@MainActor
func test_receive_dismiss() {
subject.receive(.dismiss)
XCTAssertEqual(coordinator.routes.last, .dismiss)
}

/// `receive(_:)` with `.getStarted` shows an alert for the user to confirm they have a
/// computer available.
@MainActor
func test_receive_getStarted() throws {
subject.receive(.getStarted)

let alert = try XCTUnwrap(coordinator.alertShown.last)
XCTAssertEqual(alert, .importLoginsComputerAvailable {})
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// MARK: - ImportLoginsState

/// An object that defines the current state of a `ImportLoginsView`.
///
struct ImportLoginsState: Equatable, Sendable {
// MARK: Properties
}
54 changes: 54 additions & 0 deletions BitwardenShared/UI/Vault/Vault/ImportLogins/ImportLoginsView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import SwiftUI

// MARK: - ImportLoginsView

/// A view that instructs the user how to import their logins from another password manager.
///
struct ImportLoginsView: View {
// MARK: Properties

/// The `Store` for this view.
@ObservedObject var store: Store<ImportLoginsState, ImportLoginsAction, ImportLoginsEffect>

// MARK: View

var body: some View {
VStack(spacing: 32) {
PageHeaderView(
image: Asset.Images.import,
title: Localizations.giveYourVaultAHeadStart,
message: Localizations.importLoginsDescriptionLong
)

VStack(spacing: 12) {
Button(Localizations.getStarted) {
store.send(.getStarted)
}
.buttonStyle(.primary())

AsyncButton(Localizations.importLoginsLater) {
await store.perform(.importLoginsLater)
}
.buttonStyle(.transparent)
}
}
.padding(.top, 8)
.frame(maxWidth: .infinity)
.scrollView()
.navigationBar(title: Localizations.importLogins, titleDisplayMode: .inline)
.toolbar {
cancelToolbarItem {
store.send(.dismiss)
}
}
}
}

// MARK: - Previews

#if DEBUG
#Preview {
ImportLoginsView(store: Store(processor: StateProcessor(state: ImportLoginsState())))
.navStackWrapped
}
#endif
Loading

0 comments on commit 621d901

Please sign in to comment.