Skip to content

Commit 621d901

Browse files
PM-11147: Add import logins view (#1019)
1 parent 317cb91 commit 621d901

19 files changed

+450
-2
lines changed
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
{
2+
"images" : [
3+
{
4+
"filename" : "import.pdf",
5+
"idiom" : "universal"
6+
},
7+
{
8+
"appearances" : [
9+
{
10+
"appearance" : "luminosity",
11+
"value" : "dark"
12+
}
13+
],
14+
"filename" : "import-dark.pdf",
15+
"idiom" : "universal"
16+
}
17+
],
18+
"info" : {
19+
"author" : "xcode",
20+
"version" : 1
21+
},
22+
"properties" : {
23+
"preserves-vector-representation" : true
24+
}
25+
}

BitwardenShared/UI/Platform/Application/Support/Localizations/en.lproj/Localizable.strings

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1007,3 +1007,11 @@
10071007
"NewLogin" = "New login";
10081008
"ImportSavedLogins" = "Import saved logins";
10091009
"ImportSavedLoginsDescriptionLong" = "Use a computer to import logins from an existing password manager.";
1010+
"ImportLogins" = "Import logins";
1011+
"GiveYourVaultAHeadStart" = "Give your vault a head start";
1012+
"ImportLoginsDescriptionLong" = "From your computer, follow these instructions to export saved passwords from your browser or other password manager. Then, safely import them to Bitwarden.";
1013+
"ImportLoginsLater" = "Import logins later";
1014+
"ImportLoginsLaterQuestion" = "Import logins later?";
1015+
"YouCanReturnToCompleteThisStepAnytimeInVaultUnderSettings" = "You can return to complete this step anytime in Vault under Settings.";
1016+
"DoYouHaveAComputerAvailable" = "Do you have a computer available?";
1017+
"DoYouHaveAComputerAvailableDescriptionLong" = "The following instructions will guide you through importing logins from your desktop or laptop computer.";

BitwardenShared/UI/Vault/Extensions/Alert+Vault.swift

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,43 @@ extension Alert {
9191
)
9292
}
9393

94+
/// An alert asking the user if they have a computer available to import logins.
95+
///
96+
/// - Parameter action: The action taken when the user taps on continue.
97+
/// - Returns: An alert asking the user if they have a computer available to import logins.
98+
///
99+
static func importLoginsComputerAvailable(action: @escaping () async -> Void) -> Alert {
100+
Alert(
101+
title: Localizations.doYouHaveAComputerAvailable,
102+
message: Localizations.doYouHaveAComputerAvailableDescriptionLong,
103+
alertActions: [
104+
AlertAction(title: Localizations.cancel, style: .cancel),
105+
AlertAction(title: Localizations.continue, style: .default) { _ in
106+
await action()
107+
},
108+
]
109+
)
110+
}
111+
112+
/// An alert confirming that the user wants to import logins later in settings.
113+
///
114+
/// - Parameter action: The action taken when the user taps on Confirm to import logins later
115+
/// in settings.
116+
/// - Returns: An alert confirming that the user wants to import logins later in settings.
117+
///
118+
static func importLoginsLater(action: @escaping () async -> Void) -> Alert {
119+
Alert(
120+
title: Localizations.importLoginsLaterQuestion,
121+
message: Localizations.youCanReturnToCompleteThisStepAnytimeInVaultUnderSettings,
122+
alertActions: [
123+
AlertAction(title: Localizations.cancel, style: .cancel),
124+
AlertAction(title: Localizations.confirm, style: .default) { _ in
125+
await action()
126+
},
127+
]
128+
)
129+
}
130+
94131
/// An alert presenting the user with more options for a vault list item.
95132
///
96133
/// - Parameters:

BitwardenShared/UI/Vault/Extensions/AlertVaultTests.swift

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,46 @@ class AlertVaultTests: BitwardenTestCase {
6161
XCTAssertEqual(subject.alertActions[3].style, .cancel)
6262
}
6363

64+
/// `importLoginsComputerAvailable(action:)` constructs an `Alert` that confirms that the user
65+
/// has a computer available to import logins.
66+
func test_importLoginsComputerAvailable() async throws {
67+
var actionCalled = false
68+
let subject = Alert.importLoginsComputerAvailable { actionCalled = true }
69+
70+
XCTAssertEqual(subject.title, Localizations.doYouHaveAComputerAvailable)
71+
XCTAssertEqual(subject.message, Localizations.doYouHaveAComputerAvailableDescriptionLong)
72+
XCTAssertEqual(subject.alertActions[0].title, Localizations.cancel)
73+
XCTAssertEqual(subject.alertActions[0].style, .cancel)
74+
XCTAssertEqual(subject.alertActions[1].title, Localizations.continue)
75+
XCTAssertEqual(subject.alertActions[1].style, .default)
76+
77+
try await subject.tapCancel()
78+
XCTAssertFalse(actionCalled)
79+
80+
try await subject.tapAction(title: Localizations.continue)
81+
XCTAssertTrue(actionCalled)
82+
}
83+
84+
/// `static importLoginsLater(action:)` constructs an `Alert` that confirms that the user
85+
/// wants to import logins later in settings.
86+
func test_importLoginsLater() async throws {
87+
var actionCalled = false
88+
let subject = Alert.importLoginsLater { actionCalled = true }
89+
90+
XCTAssertEqual(subject.title, Localizations.importLoginsLaterQuestion)
91+
XCTAssertEqual(subject.message, Localizations.youCanReturnToCompleteThisStepAnytimeInVaultUnderSettings)
92+
XCTAssertEqual(subject.alertActions[0].title, Localizations.cancel)
93+
XCTAssertEqual(subject.alertActions[0].style, .cancel)
94+
XCTAssertEqual(subject.alertActions[1].title, Localizations.confirm)
95+
XCTAssertEqual(subject.alertActions[1].style, .default)
96+
97+
try await subject.tapCancel()
98+
XCTAssertFalse(actionCalled)
99+
100+
try await subject.tapAction(title: Localizations.confirm)
101+
XCTAssertTrue(actionCalled)
102+
}
103+
64104
/// `passwordAutofillInformation()` constructs an `Alert` that informs the user about password
65105
/// autofill.
66106
func test_passwordAutofillInformation() {
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
// MARK: - ImportLoginsAction
2+
3+
/// Actions that can be processed by a `ImportLoginsProcessor`.
4+
///
5+
enum ImportLoginsAction: Equatable {
6+
/// Dismiss the view.
7+
case dismiss
8+
9+
/// The get started button was tapped.
10+
case getStarted
11+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
// MARK: - ImportLoginsEffect
2+
3+
/// Effects handled by the `ImportLoginsProcessor`.
4+
///
5+
enum ImportLoginsEffect: Equatable {
6+
/// The import logins button was tapped.
7+
case importLoginsLater
8+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
// MARK: - ImportLoginsProcessor
2+
3+
/// The processor used to manage state and handle actions for the import logins screen.
4+
///
5+
class ImportLoginsProcessor: StateProcessor<ImportLoginsState, ImportLoginsAction, ImportLoginsEffect> {
6+
// MARK: Types
7+
8+
typealias Services = HasErrorReporter
9+
& HasStateService
10+
11+
// MARK: Private Properties
12+
13+
/// The coordinator that handles navigation.
14+
private let coordinator: AnyCoordinator<VaultRoute, AuthAction>
15+
16+
/// The services used by this processor.
17+
private let services: Services
18+
19+
// MARK: Initialization
20+
21+
/// Creates a new `ImportLoginsProcessor`.
22+
///
23+
/// - Parameters:
24+
/// - coordinator: The coordinator that handles navigation.
25+
/// - services: The services required by this processor.
26+
/// - state: The initial state of the processor.
27+
///
28+
init(
29+
coordinator: AnyCoordinator<VaultRoute, AuthAction>,
30+
services: Services,
31+
state: ImportLoginsState
32+
) {
33+
self.coordinator = coordinator
34+
self.services = services
35+
super.init(state: state)
36+
}
37+
38+
// MARK: Methods
39+
40+
override func perform(_ effect: ImportLoginsEffect) async {
41+
switch effect {
42+
case .importLoginsLater:
43+
showImportLoginsLaterAlert()
44+
}
45+
}
46+
47+
override func receive(_ action: ImportLoginsAction) {
48+
switch action {
49+
case .dismiss:
50+
coordinator.navigate(to: .dismiss)
51+
case .getStarted:
52+
showGetStartAlert()
53+
}
54+
}
55+
56+
// MARK: Private
57+
58+
/// Shows the alert confirming the user wants to get started on importing logins.
59+
///
60+
private func showGetStartAlert() {
61+
coordinator.showAlert(.importLoginsComputerAvailable {
62+
// TODO: PM-11150 Show step 1
63+
})
64+
}
65+
66+
/// Shows the alert confirming the user wants to import logins later.
67+
///
68+
private func showImportLoginsLaterAlert() {
69+
coordinator.showAlert(.importLoginsLater {
70+
do {
71+
try await self.services.stateService.setAccountSetupImportLogins(.setUpLater)
72+
} catch {
73+
self.services.errorReporter.log(error: error)
74+
}
75+
self.coordinator.navigate(to: .dismiss)
76+
})
77+
}
78+
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import XCTest
2+
3+
@testable import BitwardenShared
4+
5+
class ImportLoginsProcessorTests: BitwardenTestCase {
6+
// MARK: Properties
7+
8+
var coordinator: MockCoordinator<VaultRoute, AuthAction>!
9+
var errorReporter: MockErrorReporter!
10+
var stateService: MockStateService!
11+
var subject: ImportLoginsProcessor!
12+
13+
// MARK: Setup & Teardown
14+
15+
override func setUp() {
16+
super.setUp()
17+
18+
coordinator = MockCoordinator()
19+
errorReporter = MockErrorReporter()
20+
stateService = MockStateService()
21+
22+
subject = ImportLoginsProcessor(
23+
coordinator: coordinator.asAnyCoordinator(),
24+
services: ServiceContainer.withMocks(
25+
errorReporter: errorReporter,
26+
stateService: stateService
27+
),
28+
state: ImportLoginsState()
29+
)
30+
}
31+
32+
override func tearDown() {
33+
super.tearDown()
34+
35+
coordinator = nil
36+
errorReporter = nil
37+
stateService = nil
38+
subject = nil
39+
}
40+
41+
// MARK: Tests
42+
43+
/// `perform(_:)` with `.importLoginsLater` shows an alert for confirming the user wants to
44+
/// import logins later.
45+
@MainActor
46+
func test_perform_importLoginsLater() async throws {
47+
stateService.activeAccount = .fixture()
48+
stateService.accountSetupImportLogins["1"] = .incomplete
49+
50+
await subject.perform(.importLoginsLater)
51+
52+
let alert = try XCTUnwrap(coordinator.alertShown.last)
53+
XCTAssertEqual(alert, .importLoginsLater {})
54+
try await alert.tapAction(title: Localizations.confirm)
55+
56+
XCTAssertEqual(coordinator.routes, [.dismiss])
57+
XCTAssertEqual(stateService.accountSetupImportLogins["1"], .setUpLater)
58+
}
59+
60+
/// `perform(_:)` with `.importLoginsLater` logs an error if one occurs.
61+
@MainActor
62+
func test_perform_importLoginsLater_error() async throws {
63+
await subject.perform(.importLoginsLater)
64+
65+
let alert = try XCTUnwrap(coordinator.alertShown.last)
66+
XCTAssertEqual(alert, .importLoginsLater {})
67+
try await alert.tapAction(title: Localizations.confirm)
68+
69+
XCTAssertEqual(coordinator.routes, [.dismiss])
70+
XCTAssertEqual(errorReporter.errors as? [StateServiceError], [.noActiveAccount])
71+
}
72+
73+
/// `receive(_:)` with `.dismiss` dismisses the view.
74+
@MainActor
75+
func test_receive_dismiss() {
76+
subject.receive(.dismiss)
77+
XCTAssertEqual(coordinator.routes.last, .dismiss)
78+
}
79+
80+
/// `receive(_:)` with `.getStarted` shows an alert for the user to confirm they have a
81+
/// computer available.
82+
@MainActor
83+
func test_receive_getStarted() throws {
84+
subject.receive(.getStarted)
85+
86+
let alert = try XCTUnwrap(coordinator.alertShown.last)
87+
XCTAssertEqual(alert, .importLoginsComputerAvailable {})
88+
}
89+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
// MARK: - ImportLoginsState
2+
3+
/// An object that defines the current state of a `ImportLoginsView`.
4+
///
5+
struct ImportLoginsState: Equatable, Sendable {
6+
// MARK: Properties
7+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import SwiftUI
2+
3+
// MARK: - ImportLoginsView
4+
5+
/// A view that instructs the user how to import their logins from another password manager.
6+
///
7+
struct ImportLoginsView: View {
8+
// MARK: Properties
9+
10+
/// The `Store` for this view.
11+
@ObservedObject var store: Store<ImportLoginsState, ImportLoginsAction, ImportLoginsEffect>
12+
13+
// MARK: View
14+
15+
var body: some View {
16+
VStack(spacing: 32) {
17+
PageHeaderView(
18+
image: Asset.Images.import,
19+
title: Localizations.giveYourVaultAHeadStart,
20+
message: Localizations.importLoginsDescriptionLong
21+
)
22+
23+
VStack(spacing: 12) {
24+
Button(Localizations.getStarted) {
25+
store.send(.getStarted)
26+
}
27+
.buttonStyle(.primary())
28+
29+
AsyncButton(Localizations.importLoginsLater) {
30+
await store.perform(.importLoginsLater)
31+
}
32+
.buttonStyle(.transparent)
33+
}
34+
}
35+
.padding(.top, 8)
36+
.frame(maxWidth: .infinity)
37+
.scrollView()
38+
.navigationBar(title: Localizations.importLogins, titleDisplayMode: .inline)
39+
.toolbar {
40+
cancelToolbarItem {
41+
store.send(.dismiss)
42+
}
43+
}
44+
}
45+
}
46+
47+
// MARK: - Previews
48+
49+
#if DEBUG
50+
#Preview {
51+
ImportLoginsView(store: Store(processor: StateProcessor(state: ImportLoginsState())))
52+
.navStackWrapped
53+
}
54+
#endif

0 commit comments

Comments
 (0)