diff --git a/BitwardenShared/Core/Vault/Models/Domain/Fixtures/VaultListItem+Fixtures.swift b/BitwardenShared/Core/Vault/Models/Domain/Fixtures/VaultListItem+Fixtures.swift index 82b57566a..479c91ff8 100644 --- a/BitwardenShared/Core/Vault/Models/Domain/Fixtures/VaultListItem+Fixtures.swift +++ b/BitwardenShared/Core/Vault/Models/Domain/Fixtures/VaultListItem+Fixtures.swift @@ -44,6 +44,7 @@ extension VaultListTOTP { loginView: BitwardenSdk.LoginView = .fixture( totp: .base32Key ), + requiresMasterPassword: Bool = false, timeProvider: TimeProvider, totpCode: String = "123456", totpPeriod: UInt32 = 30 @@ -51,6 +52,7 @@ extension VaultListTOTP { VaultListTOTP( id: id, loginView: loginView, + requiresMasterPassword: requiresMasterPassword, totpCode: .init( code: totpCode, codeGenerationDate: timeProvider.presentTime, @@ -64,6 +66,7 @@ extension VaultListTOTP { loginView: BitwardenSdk.LoginView = .fixture( totp: .base32Key ), + requiresMasterPassword: Bool = false, totpCode: TOTPCodeModel = .init( code: "123456", codeGenerationDate: Date(), @@ -73,6 +76,7 @@ extension VaultListTOTP { VaultListTOTP( id: id, loginView: loginView, + requiresMasterPassword: requiresMasterPassword, totpCode: totpCode ) } diff --git a/BitwardenShared/Core/Vault/Repositories/VaultRepository.swift b/BitwardenShared/Core/Vault/Repositories/VaultRepository.swift index c6db231ec..c4b321815 100644 --- a/BitwardenShared/Core/Vault/Repositories/VaultRepository.swift +++ b/BitwardenShared/Core/Vault/Repositories/VaultRepository.swift @@ -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( diff --git a/BitwardenShared/Core/Vault/Repositories/VaultRepositoryTests.swift b/BitwardenShared/Core/Vault/Repositories/VaultRepositoryTests.swift index 3d24fc41a..c475b1e7e 100644 --- a/BitwardenShared/Core/Vault/Repositories/VaultRepositoryTests.swift +++ b/BitwardenShared/Core/Vault/Repositories/VaultRepositoryTests.swift @@ -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(), @@ -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(), @@ -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, diff --git a/BitwardenShared/UI/Platform/Application/Views/BitwardenField.swift b/BitwardenShared/UI/Platform/Application/Views/BitwardenField.swift index 771a6a0e9..f59b63bf7 100644 --- a/BitwardenShared/UI/Platform/Application/Views/BitwardenField.swift +++ b/BitwardenShared/UI/Platform/Application/Views/BitwardenField.swift @@ -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: 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`. @@ -27,6 +29,8 @@ struct BitwardenField: 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 { diff --git a/BitwardenShared/UI/Vault/Vault/VaultGroup/VaultGroupProcessorTests.swift b/BitwardenShared/UI/Vault/Vault/VaultGroup/VaultGroupProcessorTests.swift index eab431979..ab3ec9a80 100644 --- a/BitwardenShared/UI/Vault/Vault/VaultGroup/VaultGroupProcessorTests.swift +++ b/BitwardenShared/UI/Vault/Vault/VaultGroup/VaultGroupProcessorTests.swift @@ -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, @@ -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 @@ -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, @@ -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 @@ -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, diff --git a/BitwardenShared/UI/Vault/Vault/VaultList/VaultListItem.swift b/BitwardenShared/UI/Vault/Vault/VaultList/VaultListItem.swift index 3e6e4030e..b3b16e16b 100644 --- a/BitwardenShared/UI/Vault/Vault/VaultList/VaultListItem.swift +++ b/BitwardenShared/UI/Vault/Vault/VaultList/VaultListItem.swift @@ -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 diff --git a/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditItemAction.swift b/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditItemAction.swift index 7b5fbf8e7..5fda6da42 100644 --- a/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditItemAction.swift +++ b/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditItemAction.swift @@ -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) diff --git a/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditItemProcessor.swift b/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditItemProcessor.swift index 0ea0066f5..66e4c070e 100644 --- a/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditItemProcessor.swift +++ b/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditItemProcessor.swift @@ -102,6 +102,8 @@ final class AddEditItemProcessor: StateProcessor + // 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( @@ -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) { @@ -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 diff --git a/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditLoginItem/__Snapshots__/AddEditLoginItemViewTests/test_snapshot_addEditLoginItemView.4.png b/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditLoginItem/__Snapshots__/AddEditLoginItemViewTests/test_snapshot_addEditLoginItemView.4.png index 8d15ca5be..f2b7d3b77 100644 Binary files a/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditLoginItem/__Snapshots__/AddEditLoginItemViewTests/test_snapshot_addEditLoginItemView.4.png and b/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditLoginItem/__Snapshots__/AddEditLoginItemViewTests/test_snapshot_addEditLoginItemView.4.png differ diff --git a/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditLoginItem/__Snapshots__/AddEditLoginItemViewTests/test_snapshot_addEditLoginItemView.5.png b/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditLoginItem/__Snapshots__/AddEditLoginItemViewTests/test_snapshot_addEditLoginItemView.5.png index d6c709f66..769dd4771 100644 Binary files a/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditLoginItem/__Snapshots__/AddEditLoginItemViewTests/test_snapshot_addEditLoginItemView.5.png and b/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditLoginItem/__Snapshots__/AddEditLoginItemViewTests/test_snapshot_addEditLoginItemView.5.png differ diff --git a/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditLoginItem/__Snapshots__/AddEditLoginItemViewTests/test_snapshot_addEditLoginItemView.6.png b/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditLoginItem/__Snapshots__/AddEditLoginItemViewTests/test_snapshot_addEditLoginItemView.6.png index 1baa6667f..b5d908ba8 100644 Binary files a/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditLoginItem/__Snapshots__/AddEditLoginItemViewTests/test_snapshot_addEditLoginItemView.6.png and b/BitwardenShared/UI/Vault/VaultItem/AddEditItem/AddEditLoginItem/__Snapshots__/AddEditLoginItemViewTests/test_snapshot_addEditLoginItemView.6.png differ diff --git a/BitwardenShared/UI/Vault/VaultItem/AddEditItem/__Snapshots__/AddEditItemViewTests/test_snapshot_previews_addEditItemView.13.png b/BitwardenShared/UI/Vault/VaultItem/AddEditItem/__Snapshots__/AddEditItemViewTests/test_snapshot_previews_addEditItemView.13.png index b7ee4b2bb..f7351f50f 100644 Binary files a/BitwardenShared/UI/Vault/VaultItem/AddEditItem/__Snapshots__/AddEditItemViewTests/test_snapshot_previews_addEditItemView.13.png and b/BitwardenShared/UI/Vault/VaultItem/AddEditItem/__Snapshots__/AddEditItemViewTests/test_snapshot_previews_addEditItemView.13.png differ diff --git a/BitwardenShared/UI/Vault/VaultItem/AddEditItem/__Snapshots__/AddEditItemViewTests/test_snapshot_previews_addEditItemView.14.png b/BitwardenShared/UI/Vault/VaultItem/AddEditItem/__Snapshots__/AddEditItemViewTests/test_snapshot_previews_addEditItemView.14.png index 515629357..caddbe2b7 100644 Binary files a/BitwardenShared/UI/Vault/VaultItem/AddEditItem/__Snapshots__/AddEditItemViewTests/test_snapshot_previews_addEditItemView.14.png and b/BitwardenShared/UI/Vault/VaultItem/AddEditItem/__Snapshots__/AddEditItemViewTests/test_snapshot_previews_addEditItemView.14.png differ diff --git a/BitwardenShared/UI/Vault/VaultItem/AddEditItem/__Snapshots__/AddEditItemViewTests/test_snapshot_previews_addEditItemView.15.png b/BitwardenShared/UI/Vault/VaultItem/AddEditItem/__Snapshots__/AddEditItemViewTests/test_snapshot_previews_addEditItemView.15.png index c3a5ec488..d77aa569d 100644 Binary files a/BitwardenShared/UI/Vault/VaultItem/AddEditItem/__Snapshots__/AddEditItemViewTests/test_snapshot_previews_addEditItemView.15.png and b/BitwardenShared/UI/Vault/VaultItem/AddEditItem/__Snapshots__/AddEditItemViewTests/test_snapshot_previews_addEditItemView.15.png differ diff --git a/BitwardenShared/UI/Vault/VaultItem/CipherItemState.swift b/BitwardenShared/UI/Vault/VaultItem/CipherItemState.swift index 55c8fbcba..a80013e26 100644 --- a/BitwardenShared/UI/Vault/VaultItem/CipherItemState.swift +++ b/BitwardenShared/UI/Vault/VaultItem/CipherItemState.swift @@ -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, @@ -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, diff --git a/BitwardenShared/UI/Vault/VaultItem/LoginItemState.swift b/BitwardenShared/UI/Vault/VaultItem/LoginItemState.swift index ed86d2516..44ba5229a 100644 --- a/BitwardenShared/UI/Vault/VaultItem/LoginItemState.swift +++ b/BitwardenShared/UI/Vault/VaultItem/LoginItemState.swift @@ -13,12 +13,18 @@ struct LoginItemState: Equatable { /// The FIDO2 credentials for the login. var fido2Credentials: [Fido2Credential] = [] + /// Whether the auth key is visible. + var isAuthKeyVisible: Bool = false + /// A flag indicating if the password field is visible. var isPasswordVisible: Bool = false /// A flag indicating if the totp feature is available. let isTOTPAvailable: Bool + /// Whether the user can see the TOTP code. + var isTOTPCodeVisible: Bool = false + /// The password for this item. var password: String = "" diff --git a/BitwardenShared/UI/Vault/VaultItem/ViewItem/ViewItemProcessor.swift b/BitwardenShared/UI/Vault/VaultItem/ViewItem/ViewItemProcessor.swift index 7d6fe2814..0e7dbaf0d 100644 --- a/BitwardenShared/UI/Vault/VaultItem/ViewItem/ViewItemProcessor.swift +++ b/BitwardenShared/UI/Vault/VaultItem/ViewItem/ViewItemProcessor.swift @@ -431,13 +431,17 @@ private extension ViewItemProcessor { totpState = updatedState } - guard var newState = ViewItemState(cipherView: cipher, hasPremium: hasPremium) else { continue } + guard var newState = ViewItemState( + cipherView: cipher, + hasMasterPassword: hasMasterPassword, + hasPremium: hasPremium + ) else { continue } + if case var .data(itemState) = newState.loadingState { itemState.loginState.totpState = totpState newState.loadingState = .data(itemState) } newState.hasVerifiedMasterPassword = state.hasVerifiedMasterPassword - newState.hasMasterPassword = hasMasterPassword state = newState } } catch { diff --git a/BitwardenShared/UI/Vault/VaultItem/ViewItem/ViewItemProcessorTests.swift b/BitwardenShared/UI/Vault/VaultItem/ViewItem/ViewItemProcessorTests.swift index 81eeb08e7..ebe169b75 100644 --- a/BitwardenShared/UI/Vault/VaultItem/ViewItem/ViewItemProcessorTests.swift +++ b/BitwardenShared/UI/Vault/VaultItem/ViewItem/ViewItemProcessorTests.swift @@ -525,7 +525,13 @@ class ViewItemProcessorTests: BitwardenTestCase { // swiftlint:disable:this type /// `perform(_:)` with `.deletePressed` reprompts the user for their master password if reprompt /// is enabled prior to deleting the cipher. func test_perform_deletePressed_masterPasswordReprompt() async throws { - subject.state = try XCTUnwrap(ViewItemState(cipherView: .fixture(reprompt: .password), hasPremium: false)) + subject.state = try XCTUnwrap( + ViewItemState( + cipherView: .fixture(reprompt: .password), + hasMasterPassword: true, + hasPremium: false + ) + ) await subject.perform(.deletePressed) let repromptAlert = try XCTUnwrap(coordinator.alertShown.last) diff --git a/BitwardenShared/UI/Vault/VaultItem/ViewItem/ViewItemState.swift b/BitwardenShared/UI/Vault/VaultItem/ViewItem/ViewItemState.swift index 52d2bf92a..262283300 100644 --- a/BitwardenShared/UI/Vault/VaultItem/ViewItem/ViewItemState.swift +++ b/BitwardenShared/UI/Vault/VaultItem/ViewItem/ViewItemState.swift @@ -55,11 +55,17 @@ extension ViewItemState { /// /// - Parameters: /// - cipherView: The `CipherView` to create this state with. + /// - hasMasterPassword: Whether the account has a master password. /// - hasPremium: Does the account have premium features. /// - init?(cipherView: CipherView, hasPremium: Bool) { + init?( + cipherView: CipherView, + hasMasterPassword: Bool, + hasPremium: Bool + ) { guard let cipherItemState = CipherItemState( existing: cipherView, + hasMasterPassword: hasMasterPassword, hasPremium: hasPremium ) else { return nil } self.init(loadingState: .data(cipherItemState)) diff --git a/BitwardenShared/UI/Vault/VaultItem/ViewItem/ViewItemViewTests.swift b/BitwardenShared/UI/Vault/VaultItem/ViewItem/ViewItemViewTests.swift index a36fa3580..fdd966498 100644 --- a/BitwardenShared/UI/Vault/VaultItem/ViewItem/ViewItemViewTests.swift +++ b/BitwardenShared/UI/Vault/VaultItem/ViewItem/ViewItemViewTests.swift @@ -181,6 +181,7 @@ class ViewItemViewTests: BitwardenTestCase { // swiftlint:disable:this type_body func loginState( // swiftlint:disable:this function_body_length canViewPassword: Bool = true, isPasswordVisible: Bool = true, + isTOTPCodeVisible: Bool = true, hasPremium: Bool = true ) -> CipherItemState { var cipherState = CipherItemState( @@ -198,6 +199,7 @@ class ViewItemViewTests: BitwardenTestCase { // swiftlint:disable:this type_body cipherState.loginState.canViewPassword = canViewPassword cipherState.loginState.fido2Credentials = [.fixture()] cipherState.loginState.isPasswordVisible = isPasswordVisible + cipherState.loginState.isTOTPCodeVisible = isTOTPCodeVisible cipherState.loginState.password = "Password1234!" cipherState.loginState.passwordHistoryCount = 4 cipherState.loginState.passwordUpdatedDate = Date(year: 2023, month: 11, day: 11, hour: 9, minute: 41) @@ -291,6 +293,11 @@ class ViewItemViewTests: BitwardenTestCase { // swiftlint:disable:this type_body assertSnapshot(of: subject, as: .tallPortrait) } + func test_snapshot_login_hiddenTotp() { + processor.state.loadingState = .data(loginState(isTOTPCodeVisible: false)) + assertSnapshot(of: subject, as: .tallPortrait) + } + func test_snapshot_login_withAllValues() { processor.state.loadingState = .data(loginState()) assertSnapshot(of: subject, as: .tallPortrait) diff --git a/BitwardenShared/UI/Vault/VaultItem/ViewItem/ViewLoginItem/Extensions/CipherView+Update.swift b/BitwardenShared/UI/Vault/VaultItem/ViewItem/ViewLoginItem/Extensions/CipherView+Update.swift index 3cea18263..99e7122e2 100644 --- a/BitwardenShared/UI/Vault/VaultItem/ViewItem/ViewLoginItem/Extensions/CipherView+Update.swift +++ b/BitwardenShared/UI/Vault/VaultItem/ViewItem/ViewLoginItem/Extensions/CipherView+Update.swift @@ -90,6 +90,7 @@ extension CipherView { /// /// - Parameters: /// - excludeFido2Credentials: Whether to exclude copying any FIDO2 credentials from the login item. + /// - isTOTPCodeVisible: Whether the TOTP code is visible. /// - showPassword: A Boolean value indicating whether the password should be visible. /// - showTOTP: A Boolean value indicating whether TOTP should be visible. /// @@ -97,6 +98,7 @@ extension CipherView { /// func loginItemState( excludeFido2Credentials: Bool = false, + isTOTPCodeVisible: Bool = false, showPassword: Bool = false, showTOTP: Bool ) -> LoginItemState { @@ -105,6 +107,7 @@ extension CipherView { fido2Credentials: excludeFido2Credentials ? [] : login?.fido2Credentials ?? [], isPasswordVisible: showPassword, isTOTPAvailable: showTOTP, + isTOTPCodeVisible: isTOTPCodeVisible, password: login?.password ?? "", passwordHistoryCount: passwordHistory?.count, passwordUpdatedDate: login?.passwordRevisionDate, diff --git a/BitwardenShared/UI/Vault/VaultItem/ViewItem/ViewLoginItem/ViewLoginItemState.swift b/BitwardenShared/UI/Vault/VaultItem/ViewItem/ViewLoginItem/ViewLoginItemState.swift index 3ae4061f6..0a2b81ebb 100644 --- a/BitwardenShared/UI/Vault/VaultItem/ViewItem/ViewLoginItem/ViewLoginItemState.swift +++ b/BitwardenShared/UI/Vault/VaultItem/ViewItem/ViewLoginItem/ViewLoginItemState.swift @@ -18,6 +18,9 @@ protocol ViewLoginItemState: Sendable { /// A flag indicating if the TOTP feature is available. var isTOTPAvailable: Bool { get } + /// Whether the TOTP code is visible. + var isTOTPCodeVisible: Bool { get } + /// A flag indicating if the password field is visible. var isPasswordVisible: Bool { get } diff --git a/BitwardenShared/UI/Vault/VaultItem/ViewItem/ViewLoginItem/ViewLoginItemView.swift b/BitwardenShared/UI/Vault/VaultItem/ViewItem/ViewLoginItem/ViewLoginItemView.swift index a7743d1b5..013d478b5 100644 --- a/BitwardenShared/UI/Vault/VaultItem/ViewItem/ViewLoginItem/ViewLoginItemView.swift +++ b/BitwardenShared/UI/Vault/VaultItem/ViewItem/ViewLoginItem/ViewLoginItemView.swift @@ -21,115 +21,155 @@ struct ViewLoginItemView: View { var body: some View { if !store.state.username.isEmpty { - let username = store.state.username - BitwardenTextValueField( - title: Localizations.username, - value: username, - valueAccessibilityIdentifier: "LoginUsernameEntry" - ) { + usernameRow + } + + if !store.state.password.isEmpty { + passwordRow + } + + if let fido2Credential = store.state.fido2Credentials.first { + passkeyRow(fido2Credential) + } + + if !store.state.isTOTPAvailable { + premiumSubscriptionRequired + } else if let totpModel = store.state.totpCode { + totpRow(totpModel) + } + } + + // MARK: Private views + + /// The password row. + /// + @ViewBuilder private var passwordRow: some View { + let password = store.state.password + BitwardenField(title: Localizations.password, titleAccessibilityIdentifier: "ItemName") { + PasswordText(password: password, isPasswordVisible: store.state.isPasswordVisible) + .styleGuide(.body) + .foregroundColor(Asset.Colors.textPrimary.swiftUIColor) + .accessibilityIdentifier("LoginPasswordEntry") + } accessoryContent: { + if store.state.canViewPassword { + PasswordVisibilityButton(isPasswordVisible: store.state.isPasswordVisible) { + store.send(.passwordVisibilityPressed) + } + + AsyncButton { + await store.perform(.checkPasswordPressed) + } label: { + Asset.Images.roundCheck.swiftUIImage + .imageStyle(.accessoryIcon) + } + .accessibilityLabel(Localizations.checkPassword) + .accessibilityIdentifier("CheckPasswordButton") + Button { - store.send(.copyPressed(value: username, field: .username)) + store.send(.copyPressed(value: password, field: .password)) } label: { Asset.Images.copy.swiftUIImage .imageStyle(.accessoryIcon) } .accessibilityLabel(Localizations.copy) - .accessibilityIdentifier("LoginCopyUsernameButton") + .accessibilityIdentifier("LoginCopyPasswordButton") } - .accessibilityElement(children: .contain) } + .accessibilityElement(children: .contain) + } - if !store.state.password.isEmpty { - let password = store.state.password - BitwardenField(title: Localizations.password, titleAccessibilityIdentifier: "ItemName") { - PasswordText(password: password, isPasswordVisible: store.state.isPasswordVisible) - .styleGuide(.body) - .foregroundColor(Asset.Colors.textPrimary.swiftUIColor) - .accessibilityIdentifier("LoginPasswordEntry") - } accessoryContent: { - if store.state.canViewPassword { - PasswordVisibilityButton( - accessibilityIdentifier: "ViewPasswordButton", - isPasswordVisible: store.state.isPasswordVisible - ) { - store.send(.passwordVisibilityPressed) - } + /// Row signifying that premium subscription is required for TOTP. + /// + @ViewBuilder private var premiumSubscriptionRequired: some View { + BitwardenField( + title: Localizations.verificationCodeTotp, + titleAccessibilityIdentifier: "ItemName" + ) { + Text(Localizations.premiumSubscriptionRequired) + .styleGuide(.footnote) + .foregroundColor(Asset.Colors.textSecondary.swiftUIColor) + .accessibilityIdentifier("ItemValue") + } + .accessibilityElement(children: .contain) + } - AsyncButton { - await store.perform(.checkPasswordPressed) - } label: { - Asset.Images.roundCheck.swiftUIImage - .imageStyle(.accessoryIcon) - } - .accessibilityLabel(Localizations.checkPassword) - .accessibilityIdentifier("CheckPasswordButton") - - Button { - store.send(.copyPressed(value: password, field: .password)) - } label: { - Asset.Images.copy.swiftUIImage - .imageStyle(.accessoryIcon) - } - .accessibilityLabel(Localizations.copy) - .accessibilityIdentifier("LoginCopyPasswordButton") - } + /// The username field. + /// + @ViewBuilder private var usernameRow: some View { + let username = store.state.username + BitwardenTextValueField( + title: Localizations.username, + value: username, + valueAccessibilityIdentifier: "LoginUsernameEntry" + ) { + Button { + store.send(.copyPressed(value: username, field: .username)) + } label: { + Asset.Images.copy.swiftUIImage + .imageStyle(.accessoryIcon) } - .accessibilityElement(children: .contain) + .accessibilityLabel(Localizations.copy) + .accessibilityIdentifier("LoginCopyUsernameButton") } + .accessibilityElement(children: .contain) + } - 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) - ) + // MARK: Methods + + /// The passkey row. + /// + private func passkeyRow(_ fido2Credential: Fido2Credential) -> some View { + BitwardenTextValueField( + title: Localizations.passkey, + value: Localizations.createdXY( + fido2Credential.creationDate.formatted(date: .numeric, time: .omitted), + fido2Credential.creationDate.formatted(date: .omitted, time: .shortened) ) - .accessibilityElement(children: .contain) - } + ) + .accessibilityElement(children: .contain) + } - if !store.state.isTOTPAvailable { - BitwardenField( - title: Localizations.verificationCodeTotp, - titleAccessibilityIdentifier: "ItemName" - ) { - Text(Localizations.premiumSubscriptionRequired) - .styleGuide(.footnote) - .foregroundColor(Asset.Colors.textSecondary.swiftUIColor) - } - .accessibilityElement(children: .contain) - } else if let totpModel = store.state.totpCode { - BitwardenField( - title: Localizations.verificationCodeTotp, - titleAccessibilityIdentifier: "ItemName", - content: { - Text(totpModel.displayCode) + /// The TOTP row. + /// + /// - Parameter model: The TOTP code model. + /// - Returns: The TOTP code row. + /// + private func totpRow(_ model: TOTPCodeModel) -> some View { + BitwardenField( + title: Localizations.verificationCodeTotp, + titleAccessibilityIdentifier: "ItemName", + content: { + if store.state.isTOTPCodeVisible { + Text(model.displayCode) .styleGuide(.bodyMonospaced) .multilineTextAlignment(.leading) .foregroundColor(Asset.Colors.textPrimary.swiftUIColor) .accessibilityIdentifier("LoginTotpEntry") - }, - accessoryContent: { - TOTPCountdownTimerView( - timeProvider: timeProvider, - totpCode: totpModel, - onExpiration: { - Task { - await store.perform(.totpCodeExpired) - } + } else { + PasswordText(password: model.displayCode, isPasswordVisible: false) + .accessibilityIdentifier("LoginTotpEntry") + } + }, + accessoryContent: { + TOTPCountdownTimerView( + timeProvider: timeProvider, + totpCode: model, + onExpiration: { + Task { + await store.perform(.totpCodeExpired) } - ) - Button { - store.send(.copyPressed(value: totpModel.code, field: .totp)) - } label: { - Asset.Images.copy.swiftUIImage - .imageStyle(.accessoryIcon) } - .accessibilityLabel(Localizations.copy) - .accessibilityIdentifier("CopyTotpValueButton") + ) + Button { + store.send(.copyPressed(value: model.code, field: .totp)) + } label: { + Asset.Images.copy.swiftUIImage + .imageStyle(.accessoryIcon) } - ) - .accessibilityElement(children: .contain) - } + .accessibilityLabel(Localizations.copy) + .accessibilityIdentifier("CopyTotpValueButton") + } + ) + .accessibilityElement(children: .contain) } } diff --git a/BitwardenShared/UI/Vault/VaultItem/ViewItem/__Snapshots__/ViewItemViewTests/test_snapshot_login_hiddenTotp.1.png b/BitwardenShared/UI/Vault/VaultItem/ViewItem/__Snapshots__/ViewItemViewTests/test_snapshot_login_hiddenTotp.1.png new file mode 100644 index 000000000..160eaa9fd Binary files /dev/null and b/BitwardenShared/UI/Vault/VaultItem/ViewItem/__Snapshots__/ViewItemViewTests/test_snapshot_login_hiddenTotp.1.png differ diff --git a/BitwardenShared/UI/Vault/Views/VaultListItemRow/VaultListItemRowView.swift b/BitwardenShared/UI/Vault/Views/VaultListItemRow/VaultListItemRowView.swift index 289ed487a..62cbc98a2 100644 --- a/BitwardenShared/UI/Vault/Views/VaultListItemRow/VaultListItemRowView.swift +++ b/BitwardenShared/UI/Vault/Views/VaultListItemRow/VaultListItemRowView.swift @@ -171,17 +171,19 @@ struct VaultListItemRowView: View { totpCode: model.totpCode, onExpiration: nil ) - Text(model.totpCode.displayCode) - .styleGuide(.bodyMonospaced, weight: .regular, monoSpacedDigit: true) - .foregroundColor(Asset.Colors.textPrimary.swiftUIColor) - Button { - Task { @MainActor in - store.send(.copyTOTPCode(model.totpCode.code)) + if !model.requiresMasterPassword { + Text(model.totpCode.displayCode) + .styleGuide(.bodyMonospaced, weight: .regular, monoSpacedDigit: true) + .foregroundColor(Asset.Colors.textPrimary.swiftUIColor) + Button { + Task { @MainActor in + store.send(.copyTOTPCode(model.totpCode.code)) + } + } label: { + Asset.Images.copy.swiftUIImage } - } label: { - Asset.Images.copy.swiftUIImage + .foregroundColor(Asset.Colors.primaryBitwarden.swiftUIColor) + .accessibilityLabel(Localizations.copyTotp) } - .foregroundColor(Asset.Colors.primaryBitwarden.swiftUIColor) - .accessibilityLabel(Localizations.copyTotp) } }