Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weโ€™ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

BIT-2300: Mask TOTP #624

Merged
merged 12 commits into from
May 14, 2024
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,15 @@ extension VaultListTOTP {
loginView: BitwardenSdk.LoginView = .fixture(
totp: .base32Key
),
requiresMasterPassword: Bool = false,
timeProvider: TimeProvider,
totpCode: String = "123456",
totpPeriod: UInt32 = 30
) -> VaultListTOTP {
VaultListTOTP(
id: id,
loginView: loginView,
requiresMasterPassword: requiresMasterPassword,
totpCode: .init(
code: totpCode,
codeGenerationDate: timeProvider.presentTime,
Expand All @@ -64,6 +66,7 @@ extension VaultListTOTP {
loginView: BitwardenSdk.LoginView = .fixture(
totp: .base32Key
),
requiresMasterPassword: Bool = false,
totpCode: TOTPCodeModel = .init(
code: "123456",
codeGenerationDate: Date(),
Expand All @@ -73,6 +76,7 @@ extension VaultListTOTP {
VaultListTOTP(
id: id,
loginView: loginView,
requiresMasterPassword: requiresMasterPassword,
totpCode: totpCode
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -518,6 +518,7 @@ class DefaultVaultRepository { // swiftlint:disable:this type_body_length
let listModel = VaultListTOTP(
id: id,
loginView: login,
requiresMasterPassword: cipherView.reprompt == .password,
totpCode: code
)
return VaultListItem(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,7 @@ class VaultRepositoryTests: BitwardenTestCase { // swiftlint:disable:this type_b
let totpModel = VaultListTOTP(
id: "123",
loginView: .fixture(),
requiresMasterPassword: false,
totpCode: .init(
code: "123456",
codeGenerationDate: Date(),
Expand Down Expand Up @@ -463,6 +464,7 @@ class VaultRepositoryTests: BitwardenTestCase { // swiftlint:disable:this type_b
let totpModel = VaultListTOTP(
id: "123",
loginView: .fixture(totp: .base32Key),
requiresMasterPassword: false,
totpCode: .init(
code: "123456",
codeGenerationDate: Date(),
Expand Down Expand Up @@ -935,6 +937,7 @@ class VaultRepositoryTests: BitwardenTestCase { // swiftlint:disable:this type_b
totpModel: .init(
id: "6",
loginView: XCTUnwrap(totpCipher.login),
requiresMasterPassword: false,
totpCode: .init(
code: "123456",
codeGenerationDate: timeProvider.presentTime,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,18 @@ import SwiftUI
/// A standardized view used to wrap some content into a row of a list. This is commonly used in
/// forms.
struct BitwardenField<Content, AccessoryContent>: View where Content: View, AccessoryContent: View {
// MARK: Properties

/// The (optional) title of the field.
var title: String?

/// The (optional) accessibility identifier to apply to the title of the field (if it exists)
/// The (optional) accessibility identifier to apply to the title of the field (if it exists).
var titleAccessibilityIdentifier: String?

/// The (optional) footer to display underneath the field.
var footer: String?

/// The (optional) accessibility identifier to apply to the fooder of the field (if it exists)
/// The (optional) accessibility identifier to apply to the fooder of the field (if it exists).
var footerAccessibilityIdentifier: String?

/// The vertical padding to apply around `content`. Defaults to `8`.
Expand All @@ -27,6 +29,8 @@ struct BitwardenField<Content, AccessoryContent>: View where Content: View, Acce
/// content automatically has the `AccessoryButtonStyle` applied to it.
var accessoryContent: AccessoryContent?

// MARK: View

var body: some View {
VStack(alignment: .leading, spacing: 8) {
if let title {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -672,6 +672,7 @@ class VaultGroupProcessorTests: BitwardenTestCase { // swiftlint:disable:this ty
totpModel: .init(
id: "1",
loginView: loginView,
requiresMasterPassword: false,
totpCode: .init(
code: "654321",
codeGenerationDate: timeProvider.presentTime,
Expand All @@ -695,6 +696,7 @@ class VaultGroupProcessorTests: BitwardenTestCase { // swiftlint:disable:this ty
totpModel: .init(
id: "1",
loginView: loginView,
requiresMasterPassword: false,
totpCode: .init(
code: "098765",
codeGenerationDate: timeProvider.presentTime
Expand All @@ -711,6 +713,7 @@ class VaultGroupProcessorTests: BitwardenTestCase { // swiftlint:disable:this ty
totpModel: .init(
id: "1",
loginView: loginView,
requiresMasterPassword: false,
totpCode: .init(
code: "111222",
codeGenerationDate: timeProvider.presentTime,
Expand Down Expand Up @@ -752,6 +755,7 @@ class VaultGroupProcessorTests: BitwardenTestCase { // swiftlint:disable:this ty
totpModel: .init(
id: "1",
loginView: loginView,
requiresMasterPassword: false,
totpCode: .init(
code: "098765",
codeGenerationDate: timeProvider.presentTime
Expand All @@ -768,6 +772,7 @@ class VaultGroupProcessorTests: BitwardenTestCase { // swiftlint:disable:this ty
totpModel: .init(
id: "1",
loginView: loginView,
requiresMasterPassword: false,
totpCode: .init(
code: "111222",
codeGenerationDate: timeProvider.presentTime,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,9 @@ public struct VaultListTOTP: Equatable {
///
let loginView: BitwardenSdk.LoginView

/// Whether seeing the TOTP code requires a master password.
let requiresMasterPassword: Bool

/// The current TOTP code for the cipher.
///
var totpCode: TOTPCodeModel
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import BitwardenSdk

/// Actions that can be handled by an `AddEditItemProcessor`.
enum AddEditItemAction: Equatable {
/// The auth key visibility was toggled.
case authKeyVisibilityTapped(Bool)

/// A card field changed
case cardFieldChanged(AddEditCardItemAction)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@ final class AddEditItemProcessor: StateProcessor<// swiftlint:disable:this type_

override func receive(_ action: AddEditItemAction) { // swiftlint:disable:this function_body_length
switch action {
case let .authKeyVisibilityTapped(newValue):
state.loginState.isAuthKeyVisible = newValue
case let .cardFieldChanged(cardFieldAction):
updateCardState(&state, for: cardFieldAction)
case let .collectionToggleChanged(newValue, collectionId):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1070,6 +1070,14 @@ class AddEditItemProcessorTests: BitwardenTestCase {
XCTAssertEqual(coordinator.routes.last, .setupTotpManual)
}

/// `receive(_:)` with `authKeyVisibilityTapped` updates the value in the state.
func test_receive_authKeyVisibilityTapped() {
subject.state.loginState.isAuthKeyVisible = false
subject.receive(.authKeyVisibilityTapped(true))

XCTAssertTrue(subject.state.loginState.isAuthKeyVisible)
}

/// `receive(_:)` with `.clearTOTPKey` clears the authenticator key.
func test_receive_clearTOTPKey() {
subject.state.loginState.totpState = LoginTOTPState(.base32Key)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ protocol AddEditItemState: Sendable {
/// The list of ownership options to allow the user to select from.
var ownershipOptions: [CipherOwner] { get set }

/// If master password reprompt toggle should be shown
/// If master password reprompt toggle should be shown.
var showMasterPasswordReprompt: Bool { get set }

/// A toast message to show in the view.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,27 +22,37 @@ struct AddEditLoginItemView: View {
/// The `Store` for this view.
@ObservedObject var store: Store<LoginItemState, AddEditItemAction, AddEditItemEffect>

// MARK: View

var body: some View {
BitwardenTextField(
title: Localizations.username,
text: store.binding(
get: \.username,
send: AddEditItemAction.usernameChanged
),
accessibilityIdentifier: "LoginUsernameEntry"
) {
AccessoryButton(
asset: Asset.Images.restart2,
accessibilityLabel: Localizations.generateUsername
) {
store.send(.generateUsernamePressed)
}
.accessibilityIdentifier("GenerateUsernameButton")
usernameField

passwordField

fidoField

totpView

uriSection
}

// MARK: Private views

/// The fido passkey field.
@ViewBuilder var fidoField: some View {
if let fido2Credential = store.state.fido2Credentials.first {
BitwardenTextValueField(
title: Localizations.passkey,
value: Localizations.createdXY(
fido2Credential.creationDate.formatted(date: .numeric, time: .omitted),
fido2Credential.creationDate.formatted(date: .omitted, time: .shortened)
)
)
}
.textFieldConfiguration(.username)
.focused($focusedField, equals: .userName)
.onSubmit { focusNextField($focusedField) }
}

/// The password field.
private var passwordField: some View {
BitwardenTextField(
title: Localizations.password,
text: store.binding(
Expand Down Expand Up @@ -72,50 +82,50 @@ struct AddEditLoginItemView: View {
.textFieldConfiguration(.password)
.focused($focusedField, equals: .password)
.onSubmit { focusNextField($focusedField) }

if let fido2Credential = store.state.fido2Credentials.first {
BitwardenTextValueField(
title: Localizations.passkey,
value: Localizations.createdXY(
fido2Credential.creationDate.formatted(date: .numeric, time: .omitted),
fido2Credential.creationDate.formatted(date: .omitted, time: .shortened)
)
)
}

totpView

uriSection
}

/// The view for TOTP authenticator key..
@ViewBuilder private var totpView: some View {
if let key = store.state.authenticatorKey,
!key.isEmpty {
BitwardenTextField(
title: Localizations.authenticatorKey,
text: store.binding(
get: { _ in key },
send: AddEditItemAction.totpKeyChanged
),
accessibilityIdentifier: "LoginTotpEntry",
canViewPassword: store.state.canViewPassword,
trailingContent: {
if store.state.canViewPassword {
AccessoryButton(asset: Asset.Images.copy, accessibilityLabel: Localizations.copyTotp) {
await store.perform(.copyTotpPressed)
if let key = store.state.authenticatorKey, !key.isEmpty {
if store.state.canViewPassword {
BitwardenTextField(
title: Localizations.authenticatorKey,
text: store.binding(
get: { _ in key },
send: AddEditItemAction.totpKeyChanged
),
accessibilityIdentifier: "LoginTotpEntry",
canViewPassword: store.state.canViewPassword,
isPasswordVisible: store.binding(
get: \.isAuthKeyVisible,
send: AddEditItemAction.authKeyVisibilityTapped
),
trailingContent: {
if store.state.canViewPassword {
AccessoryButton(asset: Asset.Images.copy, accessibilityLabel: Localizations.copyTotp) {
await store.perform(.copyTotpPressed)
}
}
AccessoryButton(asset: Asset.Images.camera, accessibilityLabel: Localizations.setupTotp) {
await store.perform(.setupTotpPressed)
}
}
AccessoryButton(asset: Asset.Images.camera, accessibilityLabel: Localizations.setupTotp) {
await store.perform(.setupTotpPressed)
}
)
.disabled(!store.state.canViewPassword)
.focused($focusedField, equals: .totp)
.onSubmit {
store.send(.totpFieldLeftFocus)
focusNextField($focusedField)
}
} else {
BitwardenField(title: Localizations.authenticatorKey) {
PasswordText(password: key, isPasswordVisible: false)
}
.focused($focusedField, equals: .totp)
.onSubmit {
store.send(.totpFieldLeftFocus)
focusNextField($focusedField)
}
)
.disabled(!store.state.canViewPassword)
.focused($focusedField, equals: .totp)
.onSubmit {
store.send(.totpFieldLeftFocus)
focusNextField($focusedField)
}
} else {
VStack(alignment: .leading, spacing: 8) {
Expand Down Expand Up @@ -184,6 +194,29 @@ struct AddEditLoginItemView: View {
.accessibilityIdentifier("LoginAddNewUriButton")
}
}

/// The username field.
private var usernameField: some View {
BitwardenTextField(
title: Localizations.username,
text: store.binding(
get: \.username,
send: AddEditItemAction.usernameChanged
),
accessibilityIdentifier: "LoginUsernameEntry"
) {
AccessoryButton(
asset: Asset.Images.restart2,
accessibilityLabel: Localizations.generateUsername
) {
store.send(.generateUsernamePressed)
}
.accessibilityIdentifier("GenerateUsernameButton")
}
.textFieldConfiguration(.username)
.focused($focusedField, equals: .userName)
.onSubmit { focusNextField($focusedField) }
}
}

// MARK: Previews
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
11 changes: 9 additions & 2 deletions BitwardenShared/UI/Vault/VaultItem/CipherItemState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -251,7 +251,11 @@ struct CipherItemState: Equatable {
)
}

init?(existing cipherView: CipherView, hasPremium: Bool) {
init?(
existing cipherView: CipherView,
hasMasterPassword: Bool = true,
hasPremium: Bool
) {
guard cipherView.id != nil else { return nil }
self.init(
accountHasPremium: hasPremium,
Expand All @@ -265,7 +269,10 @@ struct CipherItemState: Equatable {
isFavoriteOn: cipherView.favorite,
isMasterPasswordRePromptOn: cipherView.reprompt == .password,
isPersonalOwnershipDisabled: false,
loginState: cipherView.loginItemState(showTOTP: hasPremium),
loginState: cipherView.loginItemState(
isTOTPCodeVisible: !(hasMasterPassword && cipherView.reprompt == .password),
showTOTP: hasPremium
),
name: cipherView.name,
notes: cipherView.notes ?? "",
organizationId: cipherView.organizationId,
Expand Down
Loading
Loading