mirror of
https://github.com/bitwarden/ios.git
synced 2025-12-11 04:34:55 -06:00
PM-11147: Add import logins view (#1019)
This commit is contained in:
parent
317cb918ef
commit
621d9019be
@ -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.
BIN
BitwardenShared/UI/Platform/Application/Support/Images.xcassets/Images/import.imageset/import.pdf
vendored
Normal file
BIN
BitwardenShared/UI/Platform/Application/Support/Images.xcassets/Images/import.imageset/import.pdf
vendored
Normal file
Binary file not shown.
@ -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.";
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -0,0 +1,8 @@
|
||||
// MARK: - ImportLoginsEffect
|
||||
|
||||
/// Effects handled by the `ImportLoginsProcessor`.
|
||||
///
|
||||
enum ImportLoginsEffect: Equatable {
|
||||
/// The import logins button was tapped.
|
||||
case importLoginsLater
|
||||
}
|
||||
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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 {})
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,7 @@
|
||||
// MARK: - ImportLoginsState
|
||||
|
||||
/// An object that defines the current state of a `ImportLoginsView`.
|
||||
///
|
||||
struct ImportLoginsState: Equatable, Sendable {
|
||||
// MARK: Properties
|
||||
}
|
||||
@ -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
|
||||
@ -0,0 +1,66 @@
|
||||
import SnapshotTesting
|
||||
import ViewInspector
|
||||
import XCTest
|
||||
|
||||
@testable import BitwardenShared
|
||||
|
||||
class ImportLoginsViewTests: BitwardenTestCase {
|
||||
// MARK: Properties
|
||||
|
||||
var processor: MockProcessor<ImportLoginsState, ImportLoginsAction, ImportLoginsEffect>!
|
||||
var subject: ImportLoginsView!
|
||||
|
||||
// MARK: Setup & Teardown
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
|
||||
processor = MockProcessor(state: ImportLoginsState())
|
||||
|
||||
subject = ImportLoginsView(store: Store(processor: processor))
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
super.tearDown()
|
||||
|
||||
processor = nil
|
||||
subject = nil
|
||||
}
|
||||
|
||||
// MARK: Tests
|
||||
|
||||
/// Tapping the dismiss button dispatches the `dismiss` action.
|
||||
@MainActor
|
||||
func test_dismiss_tap() throws {
|
||||
let button = try subject.inspect().find(button: Localizations.cancel)
|
||||
try button.tap()
|
||||
XCTAssertEqual(processor.dispatchedActions.last, .dismiss)
|
||||
}
|
||||
|
||||
/// Tapping the get started button dispatches the `getStarted` action.
|
||||
@MainActor
|
||||
func test_getStarted_tap() throws {
|
||||
let button = try subject.inspect().find(button: Localizations.getStarted)
|
||||
try button.tap()
|
||||
XCTAssertEqual(processor.dispatchedActions.last, .getStarted)
|
||||
}
|
||||
|
||||
/// Tapping the import logins later button performs the `importLoginsLater` effect.
|
||||
@MainActor
|
||||
func test_importLoginsLater_tap() async throws {
|
||||
let button = try subject.inspect().find(asyncButton: Localizations.importLoginsLater)
|
||||
try await button.tap()
|
||||
XCTAssertEqual(processor.effects.last, .importLoginsLater)
|
||||
}
|
||||
|
||||
// MARK: Snapshots
|
||||
|
||||
/// The import logins view renders correctly.
|
||||
@MainActor
|
||||
func test_snapshot_importLogins() {
|
||||
assertSnapshots(
|
||||
of: subject.navStackWrapped,
|
||||
as: [.defaultPortrait, .defaultPortraitDark, .tallPortraitAX5(heightMultiple: 2), .defaultLandscape]
|
||||
)
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 135 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 135 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 364 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 131 KiB |
@ -171,8 +171,7 @@ final class VaultCoordinator: Coordinator, HasStackNavigator {
|
||||
case let .group(group, filter):
|
||||
showGroup(group, filter: filter)
|
||||
case .importLogins:
|
||||
// TODO: PM-11147 Show import logins
|
||||
break
|
||||
showImportLogins()
|
||||
case .list:
|
||||
showList()
|
||||
case let .loginRequest(loginRequest):
|
||||
@ -244,6 +243,21 @@ final class VaultCoordinator: Coordinator, HasStackNavigator {
|
||||
)
|
||||
}
|
||||
|
||||
/// Shows the import login items screen.
|
||||
///
|
||||
private func showImportLogins() {
|
||||
let processor = ImportLoginsProcessor(
|
||||
coordinator: asAnyCoordinator(),
|
||||
services: services,
|
||||
state: ImportLoginsState()
|
||||
)
|
||||
let view = ImportLoginsView(store: Store(processor: processor))
|
||||
let viewController = UIHostingController(rootView: view)
|
||||
let navigationController = UINavigationController(rootViewController: viewController)
|
||||
navigationController.modalPresentationStyle = .fullScreen
|
||||
stackNavigator?.present(navigationController)
|
||||
}
|
||||
|
||||
/// Shows the vault list screen.
|
||||
///
|
||||
private func showList() {
|
||||
|
||||
@ -126,6 +126,17 @@ class VaultCoordinatorTests: BitwardenTestCase {
|
||||
XCTAssertEqual(view.store.state.vaultFilterType, .allVaults)
|
||||
}
|
||||
|
||||
/// `navigate(to:)` with `.importLogins` presents the import logins view onto the stack navigator.
|
||||
@MainActor
|
||||
func test_navigateTo_importLogins() throws {
|
||||
subject.navigate(to: .importLogins)
|
||||
|
||||
let action = try XCTUnwrap(stackNavigator.actions.last)
|
||||
XCTAssertEqual(action.type, .presented)
|
||||
let navigationController = try XCTUnwrap(action.view as? UINavigationController)
|
||||
XCTAssertTrue(navigationController.viewControllers.first is UIHostingController<ImportLoginsView>)
|
||||
}
|
||||
|
||||
/// `navigate(to:)` with `.list` pushes the vault list view onto the stack navigator.
|
||||
@MainActor
|
||||
func test_navigateTo_list_withoutPresented() throws {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user