From a250395e6d0e4efb9404a120a30c5e9730f1bae0 Mon Sep 17 00:00:00 2001 From: Opeyemi Date: Mon, 21 Apr 2025 15:36:36 +0100 Subject: [PATCH 01/22] =?UTF-8?q?BRE-757:=20add=20hold=20label=20for=20Ren?= =?UTF-8?q?ovate=20PR=20that=20touches=20production=20workf=E2=80=A6=20(#1?= =?UTF-8?q?4311)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/renovate.json5 | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/renovate.json5 b/.github/renovate.json5 index c4202ed2a68..ee97f16b0a9 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -49,6 +49,7 @@ "./github/workflows/release-web.yml", ], commitMessagePrefix: "[deps] BRE:", + addLabels: ["hold"], }, { // Disable major and minor updates for TypeScript and Zone.js because they are managed by Angular. From 43b1f5536024693c5de969b9109ae0f123d567d3 Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Mon, 21 Apr 2025 16:57:26 +0200 Subject: [PATCH 02/22] [PM-18697] Remove old symmetric key representations in symmetriccryptokey (#13598) * Remove AES128CBC-HMAC encryption * Increase test coverage * Refactor symmetric keys and increase test coverage * Re-add type 0 encryption * Fix ts strict warning * Remove old symmetric key representations in symmetriccryptokey * Fix desktop build * Fix test * Fix build * Update libs/common/src/key-management/crypto/services/web-crypto-function.service.ts Co-authored-by: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com> * Update libs/node/src/services/node-crypto-function.service.ts Co-authored-by: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com> * Undo changes * Remove cast * Undo changes to tests * Fix linting * Undo removing new Uint8Array in aesDecryptFastParameters * Fix merge conflicts * Fix test * Fix another test * Fix test --------- Co-authored-by: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com> --- .../main-biometrics.service.spec.ts | 2 +- .../os-biometrics-windows.service.ts | 10 +++- .../auth-request/auth-request.service.spec.ts | 6 ++- .../auth-request/auth-request.service.ts | 6 ++- .../encrypt.service.implementation.ts | 24 +++++---- .../crypto/services/encrypt.service.spec.ts | 19 +++++-- .../services/web-crypto-function.service.ts | 50 ++++++++----------- .../services/key-connector.service.spec.ts | 8 ++- .../services/key-connector.service.ts | 8 ++- .../domain/symmetric-crypto-key.spec.ts | 18 ++----- .../models/domain/symmetric-crypto-key.ts | 26 +--------- .../services/node-crypto-function.service.ts | 40 +++++++++------ 12 files changed, 111 insertions(+), 106 deletions(-) diff --git a/apps/desktop/src/key-management/biometrics/main-biometrics.service.spec.ts b/apps/desktop/src/key-management/biometrics/main-biometrics.service.spec.ts index 88dd8c60ed5..09a4dcef4b3 100644 --- a/apps/desktop/src/key-management/biometrics/main-biometrics.service.spec.ts +++ b/apps/desktop/src/key-management/biometrics/main-biometrics.service.spec.ts @@ -300,7 +300,7 @@ describe("MainBiometricsService", function () { expect(userKey).not.toBeNull(); expect(userKey!.keyB64).toBe(biometricKey); - expect(userKey!.encType).toBe(EncryptionType.AesCbc256_HmacSha256_B64); + expect(userKey!.inner().type).toBe(EncryptionType.AesCbc256_HmacSha256_B64); expect(osBiometricsService.getBiometricKey).toHaveBeenCalledWith( "Bitwarden_biometric", `${userId}_user_biometric`, diff --git a/apps/desktop/src/key-management/biometrics/os-biometrics-windows.service.ts b/apps/desktop/src/key-management/biometrics/os-biometrics-windows.service.ts index cd8c94329bc..53647549295 100644 --- a/apps/desktop/src/key-management/biometrics/os-biometrics-windows.service.ts +++ b/apps/desktop/src/key-management/biometrics/os-biometrics-windows.service.ts @@ -1,5 +1,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { EncryptionType } from "@bitwarden/common/platform/enums"; +import { Utils } from "@bitwarden/common/platform/misc/utils"; import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { biometrics, passwords } from "@bitwarden/desktop-napi"; @@ -218,7 +220,13 @@ export default class OsBiometricsServiceWindows implements OsBiometricService { symmetricKey: SymmetricCryptoKey, clientKeyPartB64: string | undefined, ): biometrics.KeyMaterial { - const key = symmetricKey?.macKeyB64 ?? symmetricKey?.keyB64; + let key = null; + const innerKey = symmetricKey.inner(); + if (innerKey.type === EncryptionType.AesCbc256_HmacSha256_B64) { + key = Utils.fromBufferToB64(innerKey.authenticationKey); + } else { + key = Utils.fromBufferToB64(innerKey.encryptionKey); + } const result = { osKeyPartB64: key, diff --git a/libs/auth/src/common/services/auth-request/auth-request.service.spec.ts b/libs/auth/src/common/services/auth-request/auth-request.service.spec.ts index b5846fcfdbf..f7bf2260a36 100644 --- a/libs/auth/src/common/services/auth-request/auth-request.service.spec.ts +++ b/libs/auth/src/common/services/auth-request/auth-request.service.spec.ts @@ -106,7 +106,9 @@ describe("AuthRequestService", () => { }); it("should use the master key and hash if they exist", async () => { - masterPasswordService.masterKeySubject.next({ encKey: new Uint8Array(64) } as MasterKey); + masterPasswordService.masterKeySubject.next( + new SymmetricCryptoKey(new Uint8Array(32)) as MasterKey, + ); masterPasswordService.masterKeyHashSubject.next("MASTER_KEY_HASH"); await sut.approveOrDenyAuthRequest( @@ -115,7 +117,7 @@ describe("AuthRequestService", () => { ); expect(encryptService.encapsulateKeyUnsigned).toHaveBeenCalledWith( - { encKey: new Uint8Array(64) }, + new SymmetricCryptoKey(new Uint8Array(32)), expect.anything(), ); }); diff --git a/libs/auth/src/common/services/auth-request/auth-request.service.ts b/libs/auth/src/common/services/auth-request/auth-request.service.ts index f4316c2e519..226403d9c8c 100644 --- a/libs/auth/src/common/services/auth-request/auth-request.service.ts +++ b/libs/auth/src/common/services/auth-request/auth-request.service.ts @@ -14,6 +14,7 @@ import { AuthRequestPushNotification } from "@bitwarden/common/models/response/n import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; +import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { AUTH_REQUEST_DISK_LOCAL, StateProvider, @@ -120,7 +121,10 @@ export class AuthRequestService implements AuthRequestServiceAbstraction { keyToEncrypt = await this.keyService.getUserKey(); } - const encryptedKey = await this.encryptService.encapsulateKeyUnsigned(keyToEncrypt, pubKey); + const encryptedKey = await this.encryptService.encapsulateKeyUnsigned( + keyToEncrypt as SymmetricCryptoKey, + pubKey, + ); const response = new PasswordlessAuthRequest( encryptedKey.encryptedString, diff --git a/libs/common/src/key-management/crypto/services/encrypt.service.implementation.ts b/libs/common/src/key-management/crypto/services/encrypt.service.implementation.ts index 4b299c9c6e6..e9d8c1c30a2 100644 --- a/libs/common/src/key-management/crypto/services/encrypt.service.implementation.ts +++ b/libs/common/src/key-management/crypto/services/encrypt.service.implementation.ts @@ -47,7 +47,7 @@ export class EncryptServiceImplementation implements EncryptService { } if (this.blockType0) { - if (key.encType === EncryptionType.AesCbc256_B64 || key.key.byteLength < 64) { + if (key.inner().type === EncryptionType.AesCbc256_B64 || key.key.byteLength < 64) { throw new Error("Type 0 encryption is not supported."); } } @@ -84,7 +84,7 @@ export class EncryptServiceImplementation implements EncryptService { } if (this.blockType0) { - if (key.encType === EncryptionType.AesCbc256_B64 || key.key.byteLength < 64) { + if (key.inner().type === EncryptionType.AesCbc256_B64 || key.key.byteLength < 64) { throw new Error("Type 0 encryption is not supported."); } } @@ -124,7 +124,7 @@ export class EncryptServiceImplementation implements EncryptService { if (encString.encryptionType !== innerKey.type) { this.logDecryptError( "Key encryption type does not match payload encryption type", - key.encType, + innerKey.type, encString.encryptionType, decryptContext, ); @@ -148,7 +148,7 @@ export class EncryptServiceImplementation implements EncryptService { if (!macsEqual) { this.logMacFailed( "decryptToUtf8 MAC comparison failed. Key or payload has changed.", - key.encType, + innerKey.type, encString.encryptionType, decryptContext, ); @@ -191,7 +191,7 @@ export class EncryptServiceImplementation implements EncryptService { if (encThing.encryptionType !== inner.type) { this.logDecryptError( "Encryption key type mismatch", - key.encType, + inner.type, encThing.encryptionType, decryptContext, ); @@ -200,19 +200,23 @@ export class EncryptServiceImplementation implements EncryptService { if (inner.type === EncryptionType.AesCbc256_HmacSha256_B64) { if (encThing.macBytes == null) { - this.logDecryptError("Mac missing", key.encType, encThing.encryptionType, decryptContext); + this.logDecryptError("Mac missing", inner.type, encThing.encryptionType, decryptContext); return null; } const macData = new Uint8Array(encThing.ivBytes.byteLength + encThing.dataBytes.byteLength); macData.set(new Uint8Array(encThing.ivBytes), 0); macData.set(new Uint8Array(encThing.dataBytes), encThing.ivBytes.byteLength); - const computedMac = await this.cryptoFunctionService.hmac(macData, key.macKey, "sha256"); + const computedMac = await this.cryptoFunctionService.hmac( + macData, + inner.authenticationKey, + "sha256", + ); const macsMatch = await this.cryptoFunctionService.compare(encThing.macBytes, computedMac); if (!macsMatch) { this.logMacFailed( "MAC comparison failed. Key or payload has changed.", - key.encType, + inner.type, encThing.encryptionType, decryptContext, ); @@ -222,14 +226,14 @@ export class EncryptServiceImplementation implements EncryptService { return await this.cryptoFunctionService.aesDecrypt( encThing.dataBytes, encThing.ivBytes, - key.encKey, + inner.encryptionKey, "cbc", ); } else if (inner.type === EncryptionType.AesCbc256_B64) { return await this.cryptoFunctionService.aesDecrypt( encThing.dataBytes, encThing.ivBytes, - key.encKey, + inner.encryptionKey, "cbc", ); } diff --git a/libs/common/src/key-management/crypto/services/encrypt.service.spec.ts b/libs/common/src/key-management/crypto/services/encrypt.service.spec.ts index 4cbe3a3da90..fb55fa92605 100644 --- a/libs/common/src/key-management/crypto/services/encrypt.service.spec.ts +++ b/libs/common/src/key-management/crypto/services/encrypt.service.spec.ts @@ -6,7 +6,10 @@ import { EncryptionType } from "@bitwarden/common/platform/enums"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { EncArrayBuffer } from "@bitwarden/common/platform/models/domain/enc-array-buffer"; import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; -import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { + Aes256CbcHmacKey, + SymmetricCryptoKey, +} from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; import { CsprngArray } from "@bitwarden/common/types/csprng"; import { makeStaticByteArray } from "../../../../spec"; @@ -64,6 +67,10 @@ describe("EncryptService", () => { const key = new SymmetricCryptoKey(makeStaticByteArray(32)); const mock32Key = mock(); mock32Key.key = makeStaticByteArray(32); + mock32Key.inner.mockReturnValue({ + type: 0, + encryptionKey: mock32Key.key, + }); await expect(encryptService.encrypt(null!, key)).rejects.toThrow( "Type 0 encryption is not supported.", @@ -146,6 +153,10 @@ describe("EncryptService", () => { const key = new SymmetricCryptoKey(makeStaticByteArray(32)); const mock32Key = mock(); mock32Key.key = makeStaticByteArray(32); + mock32Key.inner.mockReturnValue({ + type: 0, + encryptionKey: mock32Key.key, + }); await expect(encryptService.encryptToBytes(plainValue, key)).rejects.toThrow( "Type 0 encryption is not supported.", @@ -228,7 +239,7 @@ describe("EncryptService", () => { expect(cryptoFunctionService.aesDecrypt).toBeCalledWith( expect.toEqualBuffer(encBuffer.dataBytes), expect.toEqualBuffer(encBuffer.ivBytes), - expect.toEqualBuffer(key.encKey), + expect.toEqualBuffer(key.inner().encryptionKey), "cbc", ); @@ -249,7 +260,7 @@ describe("EncryptService", () => { expect(cryptoFunctionService.aesDecrypt).toBeCalledWith( expect.toEqualBuffer(encBuffer.dataBytes), expect.toEqualBuffer(encBuffer.ivBytes), - expect.toEqualBuffer(key.encKey), + expect.toEqualBuffer(key.inner().encryptionKey), "cbc", ); @@ -267,7 +278,7 @@ describe("EncryptService", () => { expect(cryptoFunctionService.hmac).toBeCalledWith( expect.toEqualBuffer(expectedMacData), - key.macKey, + (key.inner() as Aes256CbcHmacKey).authenticationKey, "sha256", ); diff --git a/libs/common/src/key-management/crypto/services/web-crypto-function.service.ts b/libs/common/src/key-management/crypto/services/web-crypto-function.service.ts index 0c80d508b2d..430774ca2ed 100644 --- a/libs/common/src/key-management/crypto/services/web-crypto-function.service.ts +++ b/libs/common/src/key-management/crypto/services/web-crypto-function.service.ts @@ -1,6 +1,7 @@ import * as argon2 from "argon2-browser"; import * as forge from "node-forge"; +import { EncryptionType } from "../../../platform/enums"; import { Utils } from "../../../platform/misc/utils"; import { CbcDecryptParameters, @@ -247,37 +248,26 @@ export class WebCryptoFunctionService implements CryptoFunctionService { mac: string | null, key: SymmetricCryptoKey, ): CbcDecryptParameters { - const p = {} as CbcDecryptParameters; - if (key.meta != null) { - p.encKey = key.meta.encKeyByteString; - p.macKey = key.meta.macKeyByteString; + const innerKey = key.inner(); + if (innerKey.type === EncryptionType.AesCbc256_B64) { + return { + iv: forge.util.decode64(iv), + data: forge.util.decode64(data), + encKey: forge.util.createBuffer(innerKey.encryptionKey).getBytes(), + } as CbcDecryptParameters; + } else if (innerKey.type === EncryptionType.AesCbc256_HmacSha256_B64) { + const macData = forge.util.decode64(iv) + forge.util.decode64(data); + return { + iv: forge.util.decode64(iv), + data: forge.util.decode64(data), + encKey: forge.util.createBuffer(innerKey.encryptionKey).getBytes(), + macKey: forge.util.createBuffer(innerKey.authenticationKey).getBytes(), + mac: forge.util.decode64(mac!), + macData, + } as CbcDecryptParameters; + } else { + throw new Error("Unsupported encryption type."); } - - if (p.encKey == null) { - p.encKey = forge.util.decode64(key.encKeyB64); - } - p.data = forge.util.decode64(data); - p.iv = forge.util.decode64(iv); - p.macData = p.iv + p.data; - if (p.macKey == null && key.macKeyB64 != null) { - p.macKey = forge.util.decode64(key.macKeyB64); - } - if (mac != null) { - p.mac = forge.util.decode64(mac); - } - - // cache byte string keys for later - if (key.meta == null) { - key.meta = {}; - } - if (key.meta.encKeyByteString == null) { - key.meta.encKeyByteString = p.encKey; - } - if (p.macKey != null && key.meta.macKeyByteString == null) { - key.meta.macKeyByteString = p.macKey; - } - - return p; } aesDecryptFast({ diff --git a/libs/common/src/key-management/key-connector/services/key-connector.service.spec.ts b/libs/common/src/key-management/key-connector/services/key-connector.service.spec.ts index fd3ce0c4777..b88ada56129 100644 --- a/libs/common/src/key-management/key-connector/services/key-connector.service.spec.ts +++ b/libs/common/src/key-management/key-connector/services/key-connector.service.spec.ts @@ -252,7 +252,9 @@ describe("KeyConnectorService", () => { const organization = organizationData(true, true, "https://key-connector-url.com", 2, false); const masterKey = getMockMasterKey(); masterPasswordService.masterKeySubject.next(masterKey); - const keyConnectorRequest = new KeyConnectorUserKeyRequest(masterKey.encKeyB64); + const keyConnectorRequest = new KeyConnectorUserKeyRequest( + Utils.fromBufferToB64(masterKey.inner().encryptionKey), + ); jest.spyOn(keyConnectorService, "getManagingOrganization").mockResolvedValue(organization); jest.spyOn(apiService, "postUserKeyToKeyConnector").mockResolvedValue(); @@ -273,7 +275,9 @@ describe("KeyConnectorService", () => { // Arrange const organization = organizationData(true, true, "https://key-connector-url.com", 2, false); const masterKey = getMockMasterKey(); - const keyConnectorRequest = new KeyConnectorUserKeyRequest(masterKey.encKeyB64); + const keyConnectorRequest = new KeyConnectorUserKeyRequest( + Utils.fromBufferToB64(masterKey.inner().encryptionKey), + ); const error = new Error("Failed to post user key to key connector"); organizationService.organizations$.mockReturnValue(of([organization])); diff --git a/libs/common/src/key-management/key-connector/services/key-connector.service.ts b/libs/common/src/key-management/key-connector/services/key-connector.service.ts index 91b8e9100ac..9799f06f64a 100644 --- a/libs/common/src/key-management/key-connector/services/key-connector.service.ts +++ b/libs/common/src/key-management/key-connector/services/key-connector.service.ts @@ -95,7 +95,9 @@ export class KeyConnectorService implements KeyConnectorServiceAbstraction { userId ??= (await firstValueFrom(this.accountService.activeAccount$))?.id; const organization = await this.getManagingOrganization(userId); const masterKey = await firstValueFrom(this.masterPasswordService.masterKey$(userId)); - const keyConnectorRequest = new KeyConnectorUserKeyRequest(masterKey.encKeyB64); + const keyConnectorRequest = new KeyConnectorUserKeyRequest( + Utils.fromBufferToB64(masterKey.inner().encryptionKey), + ); try { await this.apiService.postUserKeyToKeyConnector( @@ -157,7 +159,9 @@ export class KeyConnectorService implements KeyConnectorServiceAbstraction { await this.tokenService.getEmail(), kdfConfig, ); - const keyConnectorRequest = new KeyConnectorUserKeyRequest(masterKey.encKeyB64); + const keyConnectorRequest = new KeyConnectorUserKeyRequest( + Utils.fromBufferToB64(masterKey.inner().encryptionKey), + ); await this.masterPasswordService.setMasterKey(masterKey, userId); const userKey = await this.keyService.makeUserKey(masterKey); diff --git a/libs/common/src/platform/models/domain/symmetric-crypto-key.spec.ts b/libs/common/src/platform/models/domain/symmetric-crypto-key.spec.ts index cce99b847bb..6b641ad443a 100644 --- a/libs/common/src/platform/models/domain/symmetric-crypto-key.spec.ts +++ b/libs/common/src/platform/models/domain/symmetric-crypto-key.spec.ts @@ -2,7 +2,7 @@ import { makeStaticByteArray } from "../../../../spec"; import { EncryptionType } from "../../enums"; import { Utils } from "../../misc/utils"; -import { SymmetricCryptoKey } from "./symmetric-crypto-key"; +import { Aes256CbcHmacKey, SymmetricCryptoKey } from "./symmetric-crypto-key"; describe("SymmetricCryptoKey", () => { it("errors if no key", () => { @@ -19,13 +19,8 @@ describe("SymmetricCryptoKey", () => { const cryptoKey = new SymmetricCryptoKey(key); expect(cryptoKey).toEqual({ - encKey: key, - encKeyB64: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=", - encType: EncryptionType.AesCbc256_B64, key: key, keyB64: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=", - macKey: null, - macKeyB64: undefined, innerKey: { type: EncryptionType.AesCbc256_B64, encryptionKey: key, @@ -38,14 +33,9 @@ describe("SymmetricCryptoKey", () => { const cryptoKey = new SymmetricCryptoKey(key); expect(cryptoKey).toEqual({ - encKey: key.slice(0, 32), - encKeyB64: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=", - encType: EncryptionType.AesCbc256_HmacSha256_B64, key: key, keyB64: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0+Pw==", - macKey: key.slice(32, 64), - macKeyB64: "ICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj8=", innerKey: { type: EncryptionType.AesCbc256_HmacSha256_B64, encryptionKey: key.slice(0, 32), @@ -86,8 +76,8 @@ describe("SymmetricCryptoKey", () => { expect(actual).toEqual({ type: EncryptionType.AesCbc256_HmacSha256_B64, - encryptionKey: key.encKey, - authenticationKey: key.macKey, + encryptionKey: key.inner().encryptionKey, + authenticationKey: (key.inner() as Aes256CbcHmacKey).authenticationKey, }); }); @@ -95,7 +85,7 @@ describe("SymmetricCryptoKey", () => { const key = new SymmetricCryptoKey(makeStaticByteArray(32)); const actual = key.toEncoded(); - expect(actual).toEqual(key.encKey); + expect(actual).toEqual(key.inner().encryptionKey); }); it("toEncoded returns encoded key for AesCbc256_HmacSha256_B64", () => { diff --git a/libs/common/src/platform/models/domain/symmetric-crypto-key.ts b/libs/common/src/platform/models/domain/symmetric-crypto-key.ts index 45e15c1f602..c85f3432b28 100644 --- a/libs/common/src/platform/models/domain/symmetric-crypto-key.ts +++ b/libs/common/src/platform/models/domain/symmetric-crypto-key.ts @@ -25,15 +25,7 @@ export class SymmetricCryptoKey { private innerKey: Aes256CbcHmacKey | Aes256CbcKey; key: Uint8Array; - encKey: Uint8Array; - macKey?: Uint8Array; - encType: EncryptionType; - keyB64: string; - encKeyB64: string; - macKeyB64: string; - - meta: any; /** * @param key The key in one of the permitted serialization formats @@ -48,30 +40,16 @@ export class SymmetricCryptoKey { type: EncryptionType.AesCbc256_B64, encryptionKey: key, }; - this.encType = EncryptionType.AesCbc256_B64; this.key = key; - this.keyB64 = Utils.fromBufferToB64(this.key); - - this.encKey = key; - this.encKeyB64 = Utils.fromBufferToB64(this.encKey); - - this.macKey = null; - this.macKeyB64 = undefined; + this.keyB64 = this.toBase64(); } else if (key.byteLength === 64) { this.innerKey = { type: EncryptionType.AesCbc256_HmacSha256_B64, encryptionKey: key.slice(0, 32), authenticationKey: key.slice(32), }; - this.encType = EncryptionType.AesCbc256_HmacSha256_B64; this.key = key; - this.keyB64 = Utils.fromBufferToB64(this.key); - - this.encKey = key.slice(0, 32); - this.encKeyB64 = Utils.fromBufferToB64(this.encKey); - - this.macKey = key.slice(32); - this.macKeyB64 = Utils.fromBufferToB64(this.macKey); + this.keyB64 = this.toBase64(); } else { throw new Error(`Unsupported encType/key length ${key.byteLength}`); } diff --git a/libs/node/src/services/node-crypto-function.service.ts b/libs/node/src/services/node-crypto-function.service.ts index 78d72d44104..33ea3adf357 100644 --- a/libs/node/src/services/node-crypto-function.service.ts +++ b/libs/node/src/services/node-crypto-function.service.ts @@ -3,6 +3,7 @@ import * as crypto from "crypto"; import * as forge from "node-forge"; import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service"; +import { EncryptionType } from "@bitwarden/common/platform/enums"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { CbcDecryptParameters, @@ -172,24 +173,33 @@ export class NodeCryptoFunctionService implements CryptoFunctionService { mac: string | null, key: SymmetricCryptoKey, ): CbcDecryptParameters { - const p = {} as CbcDecryptParameters; - p.encKey = key.encKey; - p.data = Utils.fromB64ToArray(data); - p.iv = Utils.fromB64ToArray(iv); + const dataBytes = Utils.fromB64ToArray(data); + const ivBytes = Utils.fromB64ToArray(iv); + const macBytes = mac != null ? Utils.fromB64ToArray(mac) : null; - const macData = new Uint8Array(p.iv.byteLength + p.data.byteLength); - macData.set(new Uint8Array(p.iv), 0); - macData.set(new Uint8Array(p.data), p.iv.byteLength); - p.macData = macData; + const innerKey = key.inner(); - if (key.macKey != null) { - p.macKey = key.macKey; + if (innerKey.type === EncryptionType.AesCbc256_B64) { + return { + iv: ivBytes, + data: dataBytes, + encKey: innerKey.encryptionKey, + } as CbcDecryptParameters; + } else if (innerKey.type === EncryptionType.AesCbc256_HmacSha256_B64) { + const macData = new Uint8Array(ivBytes.byteLength + dataBytes.byteLength); + macData.set(new Uint8Array(ivBytes), 0); + macData.set(new Uint8Array(dataBytes), ivBytes.byteLength); + return { + iv: ivBytes, + data: dataBytes, + mac: macBytes, + macData: macData, + encKey: innerKey.encryptionKey, + macKey: innerKey.authenticationKey, + } as CbcDecryptParameters; + } else { + throw new Error("Unsupported encryption type"); } - if (mac != null) { - p.mac = Utils.fromB64ToArray(mac); - } - - return p; } async aesDecryptFast({ From 143473927ee27d0190740084fccd003b7c4e5268 Mon Sep 17 00:00:00 2001 From: Shane Melton Date: Mon, 21 Apr 2025 08:57:57 -0700 Subject: [PATCH 03/22] [PM-10611] End user notification sync (#14116) * [PM-10611] Remove Angular dependencies from Notifications module * [PM-10611] Move end user notification service to /libs/common/vault/notifications * [PM-10611] Implement listenForEndUserNotifications() for EndUserNotificationService * [PM-10611] Add missing taskId to notification models * [PM-10611] Add switch cases for end user notification payloads * [PM-10611] Mark task related notifications as read when visiting the at-risk password page * [PM-10611] Revert change to default-notifications service * [PM-10611] Fix test * [PM-10611] Fix tests and log warning in case more notifications than the default page size are available * [PM-10611] Use separate feature flag for end user notifications * [PM-10611] Fix test --- .../browser/src/background/main.background.ts | 16 ++ .../at-risk-passwords.component.spec.ts | 14 ++ .../at-risk-passwords.component.ts | 52 +++- .../src/services/jslib-services.module.ts | 19 +- libs/common/src/enums/feature-flag.enum.ts | 2 + .../models/response/notification.response.ts | 6 + .../end-user-notification.service.ts | 13 +- libs/common/src/vault/notifications/index.ts | 2 + .../src/vault}/notifications/models/index.ts | 0 .../models/notification-view.data.ts | 5 +- .../models/notification-view.response.ts | 4 +- .../notifications/models/notification-view.ts | 4 +- ...ault-end-user-notification.service.spec.ts | 223 ++++++++++++++++++ .../default-end-user-notification.service.ts | 213 +++++++++++++++++ .../state/end-user-notification.state.ts | 0 libs/vault/src/index.ts | 1 - libs/vault/src/notifications/index.ts | 2 - ...ault-end-user-notification.service.spec.ts | 200 ---------------- .../default-end-user-notification.service.ts | 125 ---------- 19 files changed, 557 insertions(+), 344 deletions(-) rename libs/{vault/src => common/src/vault}/notifications/abstractions/end-user-notification.service.ts (64%) create mode 100644 libs/common/src/vault/notifications/index.ts rename libs/{vault/src => common/src/vault}/notifications/models/index.ts (100%) rename libs/{vault/src => common/src/vault}/notifications/models/notification-view.data.ts (85%) rename libs/{vault/src => common/src/vault}/notifications/models/notification-view.response.ts (81%) rename libs/{vault/src => common/src/vault}/notifications/models/notification-view.ts (75%) create mode 100644 libs/common/src/vault/notifications/services/default-end-user-notification.service.spec.ts create mode 100644 libs/common/src/vault/notifications/services/default-end-user-notification.service.ts rename libs/{vault/src => common/src/vault}/notifications/state/end-user-notification.state.ts (100%) delete mode 100644 libs/vault/src/notifications/index.ts delete mode 100644 libs/vault/src/notifications/services/default-end-user-notification.service.spec.ts delete mode 100644 libs/vault/src/notifications/services/default-end-user-notification.service.ts diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 2b6827aafa4..3066ef5eef5 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -191,6 +191,10 @@ import { InternalFolderService as InternalFolderServiceAbstraction } from "@bitw import { TotpService as TotpServiceAbstraction } from "@bitwarden/common/vault/abstractions/totp.service"; import { VaultSettingsService as VaultSettingsServiceAbstraction } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { + DefaultEndUserNotificationService, + EndUserNotificationService, +} from "@bitwarden/common/vault/notifications"; import { CipherAuthorizationService, DefaultCipherAuthorizationService, @@ -402,6 +406,7 @@ export default class MainBackground { sdkService: SdkService; sdkLoadService: SdkLoadService; cipherAuthorizationService: CipherAuthorizationService; + endUserNotificationService: EndUserNotificationService; inlineMenuFieldQualificationService: InlineMenuFieldQualificationService; taskService: TaskService; @@ -1320,6 +1325,14 @@ export default class MainBackground { this.ipcContentScriptManagerService = new IpcContentScriptManagerService(this.configService); this.ipcService = new IpcBackgroundService(this.logService); + + this.endUserNotificationService = new DefaultEndUserNotificationService( + this.stateProvider, + this.apiService, + this.notificationsService, + this.authService, + this.logService, + ); } async bootstrap() { @@ -1406,6 +1419,9 @@ export default class MainBackground { this.taskService.listenForTaskNotifications(); } + if (await this.configService.getFeatureFlag(FeatureFlag.EndUserNotifications)) { + this.endUserNotificationService.listenForEndUserNotifications(); + } resolve(); }, 500); }); diff --git a/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-passwords.component.spec.ts b/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-passwords.component.spec.ts index 25bf3ce3716..ff583061684 100644 --- a/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-passwords.component.spec.ts +++ b/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-passwords.component.spec.ts @@ -12,10 +12,13 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv import { AutofillOverlayVisibility } from "@bitwarden/common/autofill/constants"; import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service"; import { InlineMenuVisibilitySetting } from "@bitwarden/common/autofill/types"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { EndUserNotificationService } from "@bitwarden/common/vault/notifications"; +import { NotificationView } from "@bitwarden/common/vault/notifications/models"; import { SecurityTask, SecurityTaskType, TaskService } from "@bitwarden/common/vault/tasks"; import { DialogService, ToastService } from "@bitwarden/components"; import { @@ -66,6 +69,7 @@ describe("AtRiskPasswordsComponent", () => { let mockTasks$: BehaviorSubject; let mockCiphers$: BehaviorSubject; let mockOrgs$: BehaviorSubject; + let mockNotifications$: BehaviorSubject; let mockInlineMenuVisibility$: BehaviorSubject; let calloutDismissed$: BehaviorSubject; const setInlineMenuVisibility = jest.fn(); @@ -73,6 +77,7 @@ describe("AtRiskPasswordsComponent", () => { const mockAtRiskPasswordPageService = mock(); const mockChangeLoginPasswordService = mock(); const mockDialogService = mock(); + const mockConfigService = mock(); beforeEach(async () => { mockTasks$ = new BehaviorSubject([ @@ -101,6 +106,7 @@ describe("AtRiskPasswordsComponent", () => { name: "Org 1", } as Organization, ]); + mockNotifications$ = new BehaviorSubject([]); mockInlineMenuVisibility$ = new BehaviorSubject( AutofillOverlayVisibility.Off, @@ -110,6 +116,7 @@ describe("AtRiskPasswordsComponent", () => { setInlineMenuVisibility.mockClear(); mockToastService.showToast.mockClear(); mockDialogService.open.mockClear(); + mockConfigService.getFeatureFlag.mockClear(); mockAtRiskPasswordPageService.isCalloutDismissed.mockReturnValue(calloutDismissed$); await TestBed.configureTestingModule({ @@ -133,6 +140,12 @@ describe("AtRiskPasswordsComponent", () => { cipherViews$: () => mockCiphers$, }, }, + { + provide: EndUserNotificationService, + useValue: { + unreadNotifications$: () => mockNotifications$, + }, + }, { provide: I18nService, useValue: { t: (key: string) => key } }, { provide: AccountService, useValue: { activeAccount$: of({ id: "user" }) } }, { provide: PlatformUtilsService, useValue: mock() }, @@ -145,6 +158,7 @@ describe("AtRiskPasswordsComponent", () => { }, }, { provide: ToastService, useValue: mockToastService }, + { provide: ConfigService, useValue: mockConfigService }, ], }) .overrideModule(JslibModule, { diff --git a/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-passwords.component.ts b/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-passwords.component.ts index 37c445f6c30..1b43151193a 100644 --- a/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-passwords.component.ts +++ b/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-passwords.component.ts @@ -1,7 +1,19 @@ import { CommonModule } from "@angular/common"; -import { Component, inject, OnInit, signal } from "@angular/core"; +import { Component, DestroyRef, inject, OnInit, signal } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { Router } from "@angular/router"; -import { combineLatest, firstValueFrom, map, of, shareReplay, startWith, switchMap } from "rxjs"; +import { + combineLatest, + concat, + concatMap, + firstValueFrom, + map, + of, + shareReplay, + startWith, + switchMap, + take, +} from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { @@ -11,10 +23,13 @@ import { import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AutofillOverlayVisibility } from "@bitwarden/common/autofill/constants"; import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { EndUserNotificationService } from "@bitwarden/common/vault/notifications"; import { SecurityTaskType, TaskService } from "@bitwarden/common/vault/tasks"; import { filterOutNullish } from "@bitwarden/common/vault/utils/observable-utilities"; import { @@ -81,6 +96,9 @@ export class AtRiskPasswordsComponent implements OnInit { private changeLoginPasswordService = inject(ChangeLoginPasswordService); private platformUtilsService = inject(PlatformUtilsService); private dialogService = inject(DialogService); + private endUserNotificationService = inject(EndUserNotificationService); + private configService = inject(ConfigService); + private destroyRef = inject(DestroyRef); /** * The cipher that is currently being launched. Used to show a loading spinner on the badge button. @@ -180,6 +198,36 @@ export class AtRiskPasswordsComponent implements OnInit { await this.atRiskPasswordPageService.dismissGettingStarted(userId); } } + + if (await this.configService.getFeatureFlag(FeatureFlag.EndUserNotifications)) { + this.markTaskNotificationsAsRead(); + } + } + + private markTaskNotificationsAsRead() { + this.activeUserData$ + .pipe( + switchMap(({ tasks, userId }) => { + return this.endUserNotificationService.unreadNotifications$(userId).pipe( + take(1), + map((notifications) => { + return notifications.filter((notification) => { + return tasks.some((task) => task.id === notification.taskId); + }); + }), + concatMap((unreadTaskNotifications) => { + // TODO: Investigate creating a bulk endpoint to mark notifications as read + return concat( + ...unreadTaskNotifications.map((n) => + this.endUserNotificationService.markAsRead(n.id, userId), + ), + ); + }), + ); + }), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe(); } async viewCipher(cipher: CipherView) { diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 8e2b3409593..1cc2b591412 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -270,6 +270,10 @@ import { } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { TotpService as TotpServiceAbstraction } from "@bitwarden/common/vault/abstractions/totp.service"; import { VaultSettingsService as VaultSettingsServiceAbstraction } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service"; +import { + DefaultEndUserNotificationService, + EndUserNotificationService, +} from "@bitwarden/common/vault/notifications"; import { CipherAuthorizationService, DefaultCipherAuthorizationService, @@ -306,12 +310,7 @@ import { UserAsymmetricKeysRegenerationService, } from "@bitwarden/key-management"; import { SafeInjectionToken } from "@bitwarden/ui-common"; -import { - DefaultEndUserNotificationService, - EndUserNotificationService, - NewDeviceVerificationNoticeService, - PasswordRepromptService, -} from "@bitwarden/vault"; +import { NewDeviceVerificationNoticeService, PasswordRepromptService } from "@bitwarden/vault"; import { IndividualVaultExportService, IndividualVaultExportServiceAbstraction, @@ -1489,7 +1488,13 @@ const safeProviders: SafeProvider[] = [ safeProvider({ provide: EndUserNotificationService, useClass: DefaultEndUserNotificationService, - deps: [StateProvider, ApiServiceAbstraction, NotificationsService], + deps: [ + StateProvider, + ApiServiceAbstraction, + NotificationsService, + AuthServiceAbstraction, + LogService, + ], }), safeProvider({ provide: DeviceTrustToastServiceAbstraction, diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 1d0b1521db6..08906c792fb 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -55,6 +55,7 @@ export enum FeatureFlag { VaultBulkManagementAction = "vault-bulk-management-action", SecurityTasks = "security-tasks", CipherKeyEncryption = "cipher-key-encryption", + EndUserNotifications = "pm-10609-end-user-notifications", /* Platform */ IpcChannelFramework = "ipc-channel-framework", @@ -105,6 +106,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.VaultBulkManagementAction]: FALSE, [FeatureFlag.SecurityTasks]: FALSE, [FeatureFlag.CipherKeyEncryption]: FALSE, + [FeatureFlag.EndUserNotifications]: FALSE, /* Auth */ [FeatureFlag.PM9112_DeviceApprovalPersistence]: FALSE, diff --git a/libs/common/src/models/response/notification.response.ts b/libs/common/src/models/response/notification.response.ts index aa0ecc97b58..d1bf96b1956 100644 --- a/libs/common/src/models/response/notification.response.ts +++ b/libs/common/src/models/response/notification.response.ts @@ -1,3 +1,5 @@ +import { NotificationViewResponse as EndUserNotificationResponse } from "@bitwarden/common/vault/notifications/models"; + import { NotificationType } from "../../enums"; import { BaseResponse } from "./base.response"; @@ -57,6 +59,10 @@ export class NotificationResponse extends BaseResponse { case NotificationType.SyncOrganizationCollectionSettingChanged: this.payload = new OrganizationCollectionSettingChangedPushNotification(payload); break; + case NotificationType.Notification: + case NotificationType.NotificationStatus: + this.payload = new EndUserNotificationResponse(payload); + break; default: break; } diff --git a/libs/vault/src/notifications/abstractions/end-user-notification.service.ts b/libs/common/src/vault/notifications/abstractions/end-user-notification.service.ts similarity index 64% rename from libs/vault/src/notifications/abstractions/end-user-notification.service.ts rename to libs/common/src/vault/notifications/abstractions/end-user-notification.service.ts index fe2852994f7..bc5dd4d97a4 100644 --- a/libs/vault/src/notifications/abstractions/end-user-notification.service.ts +++ b/libs/common/src/vault/notifications/abstractions/end-user-notification.service.ts @@ -1,6 +1,6 @@ -import { Observable } from "rxjs"; +import { Observable, Subscription } from "rxjs"; -import { UserId } from "@bitwarden/common/types/guid"; +import { NotificationId, UserId } from "@bitwarden/common/types/guid"; import { NotificationView } from "../models"; @@ -25,18 +25,23 @@ export abstract class EndUserNotificationService { * @param notificationId * @param userId */ - abstract markAsRead(notificationId: any, userId: UserId): Promise; + abstract markAsRead(notificationId: NotificationId, userId: UserId): Promise; /** * Mark a notification as deleted. * @param notificationId * @param userId */ - abstract markAsDeleted(notificationId: any, userId: UserId): Promise; + abstract markAsDeleted(notificationId: NotificationId, userId: UserId): Promise; /** * Clear all notifications from state for the given user. * @param userId */ abstract clearState(userId: UserId): Promise; + + /** + * Creates a subscription to listen for end user push notifications and notification status updates. + */ + abstract listenForEndUserNotifications(): Subscription; } diff --git a/libs/common/src/vault/notifications/index.ts b/libs/common/src/vault/notifications/index.ts new file mode 100644 index 00000000000..768262be943 --- /dev/null +++ b/libs/common/src/vault/notifications/index.ts @@ -0,0 +1,2 @@ +export { EndUserNotificationService } from "./abstractions/end-user-notification.service"; +export { DefaultEndUserNotificationService } from "./services/default-end-user-notification.service"; diff --git a/libs/vault/src/notifications/models/index.ts b/libs/common/src/vault/notifications/models/index.ts similarity index 100% rename from libs/vault/src/notifications/models/index.ts rename to libs/common/src/vault/notifications/models/index.ts diff --git a/libs/vault/src/notifications/models/notification-view.data.ts b/libs/common/src/vault/notifications/models/notification-view.data.ts similarity index 85% rename from libs/vault/src/notifications/models/notification-view.data.ts rename to libs/common/src/vault/notifications/models/notification-view.data.ts index 07c147052ad..60314a44684 100644 --- a/libs/vault/src/notifications/models/notification-view.data.ts +++ b/libs/common/src/vault/notifications/models/notification-view.data.ts @@ -1,6 +1,6 @@ import { Jsonify } from "type-fest"; -import { NotificationId } from "@bitwarden/common/types/guid"; +import { NotificationId, SecurityTaskId } from "@bitwarden/common/types/guid"; import { NotificationViewResponse } from "./notification-view.response"; @@ -10,6 +10,7 @@ export class NotificationViewData { title: string; body: string; date: Date; + taskId?: SecurityTaskId; readDate: Date | null; deletedDate: Date | null; @@ -19,6 +20,7 @@ export class NotificationViewData { this.title = response.title; this.body = response.body; this.date = response.date; + this.taskId = response.taskId; this.readDate = response.readDate; this.deletedDate = response.deletedDate; } @@ -30,6 +32,7 @@ export class NotificationViewData { title: obj.title, body: obj.body, date: new Date(obj.date), + taskId: obj.taskId, readDate: obj.readDate ? new Date(obj.readDate) : null, deletedDate: obj.deletedDate ? new Date(obj.deletedDate) : null, }); diff --git a/libs/vault/src/notifications/models/notification-view.response.ts b/libs/common/src/vault/notifications/models/notification-view.response.ts similarity index 81% rename from libs/vault/src/notifications/models/notification-view.response.ts rename to libs/common/src/vault/notifications/models/notification-view.response.ts index bbebf25bd4e..b4b7d8d94cc 100644 --- a/libs/vault/src/notifications/models/notification-view.response.ts +++ b/libs/common/src/vault/notifications/models/notification-view.response.ts @@ -1,5 +1,5 @@ import { BaseResponse } from "@bitwarden/common/models/response/base.response"; -import { NotificationId } from "@bitwarden/common/types/guid"; +import { NotificationId, SecurityTaskId } from "@bitwarden/common/types/guid"; export class NotificationViewResponse extends BaseResponse { id: NotificationId; @@ -7,6 +7,7 @@ export class NotificationViewResponse extends BaseResponse { title: string; body: string; date: Date; + taskId?: SecurityTaskId; readDate: Date; deletedDate: Date; @@ -17,6 +18,7 @@ export class NotificationViewResponse extends BaseResponse { this.title = this.getResponseProperty("Title"); this.body = this.getResponseProperty("Body"); this.date = this.getResponseProperty("Date"); + this.taskId = this.getResponseProperty("TaskId"); this.readDate = this.getResponseProperty("ReadDate"); this.deletedDate = this.getResponseProperty("DeletedDate"); } diff --git a/libs/vault/src/notifications/models/notification-view.ts b/libs/common/src/vault/notifications/models/notification-view.ts similarity index 75% rename from libs/vault/src/notifications/models/notification-view.ts rename to libs/common/src/vault/notifications/models/notification-view.ts index b577a889d05..21d55ec0aed 100644 --- a/libs/vault/src/notifications/models/notification-view.ts +++ b/libs/common/src/vault/notifications/models/notification-view.ts @@ -1,4 +1,4 @@ -import { NotificationId } from "@bitwarden/common/types/guid"; +import { NotificationId, SecurityTaskId } from "@bitwarden/common/types/guid"; export class NotificationView { id: NotificationId; @@ -6,6 +6,7 @@ export class NotificationView { title: string; body: string; date: Date; + taskId?: SecurityTaskId; readDate: Date | null; deletedDate: Date | null; @@ -15,6 +16,7 @@ export class NotificationView { this.title = obj.title; this.body = obj.body; this.date = obj.date; + this.taskId = obj.taskId; this.readDate = obj.readDate; this.deletedDate = obj.deletedDate; } diff --git a/libs/common/src/vault/notifications/services/default-end-user-notification.service.spec.ts b/libs/common/src/vault/notifications/services/default-end-user-notification.service.spec.ts new file mode 100644 index 00000000000..89a78d6f7d2 --- /dev/null +++ b/libs/common/src/vault/notifications/services/default-end-user-notification.service.spec.ts @@ -0,0 +1,223 @@ +import { mock } from "jest-mock-extended"; +import { firstValueFrom, of } from "rxjs"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { NotificationsService } from "@bitwarden/common/platform/notifications"; +import { StateProvider } from "@bitwarden/common/platform/state"; +import { NotificationId, UserId } from "@bitwarden/common/types/guid"; + +import { FakeStateProvider, mockAccountServiceWith } from "../../../../spec"; +import { NotificationViewResponse } from "../models"; +import { NOTIFICATIONS } from "../state/end-user-notification.state"; + +import { + DEFAULT_NOTIFICATION_PAGE_SIZE, + DefaultEndUserNotificationService, +} from "./default-end-user-notification.service"; + +describe("End User Notification Center Service", () => { + let fakeStateProvider: FakeStateProvider; + let mockApiService: jest.Mocked; + let mockNotificationsService: jest.Mocked; + let mockAuthService: jest.Mocked; + let mockLogService: jest.Mocked; + let service: DefaultEndUserNotificationService; + + beforeEach(() => { + fakeStateProvider = new FakeStateProvider(mockAccountServiceWith("user-id" as UserId)); + mockApiService = { + send: jest.fn(), + } as any; + mockNotificationsService = { + notifications$: of(null), + } as any; + mockAuthService = { + authStatuses$: of({}), + } as any; + mockLogService = mock(); + + service = new DefaultEndUserNotificationService( + fakeStateProvider as unknown as StateProvider, + mockApiService, + mockNotificationsService, + mockAuthService, + mockLogService, + ); + }); + + describe("notifications$", () => { + it("should return notifications from state when not null", async () => { + fakeStateProvider.singleUser.mockFor("user-id" as UserId, NOTIFICATIONS, [ + { + id: "notification-id" as NotificationId, + } as NotificationViewResponse, + ]); + + const result = await firstValueFrom(service.notifications$("user-id" as UserId)); + + expect(result.length).toBe(1); + expect(mockApiService.send).not.toHaveBeenCalled(); + expect(mockLogService.warning).not.toHaveBeenCalled(); + }); + + it("should return notifications API when state is null", async () => { + mockApiService.send.mockResolvedValue({ + data: [ + { + id: "notification-id", + }, + ] as NotificationViewResponse[], + }); + + fakeStateProvider.singleUser.mockFor("user-id" as UserId, NOTIFICATIONS, null as any); + + const result = await firstValueFrom(service.notifications$("user-id" as UserId)); + + expect(result.length).toBe(1); + expect(mockApiService.send).toHaveBeenCalledWith( + "GET", + `/notifications?pageSize=${DEFAULT_NOTIFICATION_PAGE_SIZE}`, + null, + true, + true, + ); + expect(mockLogService.warning).not.toHaveBeenCalled(); + }); + + it("should log a warning if there are more notifications available", async () => { + mockApiService.send.mockResolvedValue({ + data: [ + ...new Array(DEFAULT_NOTIFICATION_PAGE_SIZE + 1).fill({ id: "notification-id" }), + ] as NotificationViewResponse[], + continuationToken: "next-token", // Presence of continuation token indicates more data + }); + + fakeStateProvider.singleUser.mockFor("user-id" as UserId, NOTIFICATIONS, null as any); + + const result = await firstValueFrom(service.notifications$("user-id" as UserId)); + + expect(result.length).toBe(DEFAULT_NOTIFICATION_PAGE_SIZE + 1); + expect(mockApiService.send).toHaveBeenCalledWith( + "GET", + `/notifications?pageSize=${DEFAULT_NOTIFICATION_PAGE_SIZE}`, + null, + true, + true, + ); + expect(mockLogService.warning).toHaveBeenCalledWith( + `More notifications available, but not fetched. Consider increasing the page size from ${DEFAULT_NOTIFICATION_PAGE_SIZE}`, + ); + }); + + it("should share the same observable for the same user", async () => { + const first = service.notifications$("user-id" as UserId); + const second = service.notifications$("user-id" as UserId); + + expect(first).toBe(second); + }); + }); + + describe("unreadNotifications$", () => { + it("should return unread notifications from state when read value is null", async () => { + fakeStateProvider.singleUser.mockFor("user-id" as UserId, NOTIFICATIONS, [ + { + id: "notification-id" as NotificationId, + readDate: null as any, + } as NotificationViewResponse, + ]); + + const result = await firstValueFrom(service.unreadNotifications$("user-id" as UserId)); + + expect(result.length).toBe(1); + expect(mockApiService.send).not.toHaveBeenCalled(); + }); + }); + + describe("getNotifications", () => { + it("should call getNotifications returning notifications from API", async () => { + mockApiService.send.mockResolvedValue({ + data: [ + { + id: "notification-id", + }, + ] as NotificationViewResponse[], + }); + + await service.refreshNotifications("user-id" as UserId); + + expect(mockApiService.send).toHaveBeenCalledWith( + "GET", + `/notifications?pageSize=${DEFAULT_NOTIFICATION_PAGE_SIZE}`, + null, + true, + true, + ); + }); + + it("should update local state when notifications are updated", async () => { + mockApiService.send.mockResolvedValue({ + data: [ + { + id: "notification-id", + }, + ] as NotificationViewResponse[], + }); + + const mock = fakeStateProvider.singleUser.mockFor( + "user-id" as UserId, + NOTIFICATIONS, + null as any, + ); + + await service.refreshNotifications("user-id" as UserId); + + expect(mock.nextMock).toHaveBeenCalledWith([ + expect.objectContaining({ + id: "notification-id" as NotificationId, + } as NotificationViewResponse), + ]); + }); + }); + + describe("clear", () => { + it("should clear the local notification state for the user", async () => { + const mock = fakeStateProvider.singleUser.mockFor("user-id" as UserId, NOTIFICATIONS, [ + { + id: "notification-id" as NotificationId, + } as NotificationViewResponse, + ]); + + await service.clearState("user-id" as UserId); + + expect(mock.nextMock).toHaveBeenCalledWith([]); + }); + }); + + describe("markAsDeleted", () => { + it("should send an API request to mark the notification as deleted", async () => { + await service.markAsDeleted("notification-id" as NotificationId, "user-id" as UserId); + expect(mockApiService.send).toHaveBeenCalledWith( + "DELETE", + "/notifications/notification-id/delete", + null, + true, + false, + ); + }); + }); + + describe("markAsRead", () => { + it("should send an API request to mark the notification as read", async () => { + await service.markAsRead("notification-id" as NotificationId, "user-id" as UserId); + expect(mockApiService.send).toHaveBeenCalledWith( + "PATCH", + "/notifications/notification-id/read", + null, + true, + false, + ); + }); + }); +}); diff --git a/libs/common/src/vault/notifications/services/default-end-user-notification.service.ts b/libs/common/src/vault/notifications/services/default-end-user-notification.service.ts new file mode 100644 index 00000000000..87f97b48c27 --- /dev/null +++ b/libs/common/src/vault/notifications/services/default-end-user-notification.service.ts @@ -0,0 +1,213 @@ +import { concatMap, EMPTY, filter, map, Observable, Subscription, switchMap } from "rxjs"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; +import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; +import { NotificationType } from "@bitwarden/common/enums"; +import { ListResponse } from "@bitwarden/common/models/response/list.response"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { NotificationsService } from "@bitwarden/common/platform/notifications"; +import { StateProvider } from "@bitwarden/common/platform/state"; +import { NotificationId, UserId } from "@bitwarden/common/types/guid"; +import { + filterOutNullish, + perUserCache$, +} from "@bitwarden/common/vault/utils/observable-utilities"; + +import { EndUserNotificationService } from "../abstractions/end-user-notification.service"; +import { NotificationView, NotificationViewData, NotificationViewResponse } from "../models"; +import { NOTIFICATIONS } from "../state/end-user-notification.state"; + +/** + * The default number of notifications to fetch from the API. + */ +export const DEFAULT_NOTIFICATION_PAGE_SIZE = 50; + +const getLoggedInUserIds = map, UserId[]>((authStatuses) => + Object.entries(authStatuses ?? {}) + .filter(([, status]) => status >= AuthenticationStatus.Locked) + .map(([userId]) => userId as UserId), +); + +/** + * A service for retrieving and managing notifications for end users. + */ +export class DefaultEndUserNotificationService implements EndUserNotificationService { + constructor( + private stateProvider: StateProvider, + private apiService: ApiService, + private notificationService: NotificationsService, + private authService: AuthService, + private logService: LogService, + ) {} + + notifications$ = perUserCache$((userId: UserId): Observable => { + return this.notificationState(userId).state$.pipe( + switchMap(async (notifications) => { + if (notifications == null) { + await this.fetchNotificationsFromApi(userId); + return null; + } + return notifications; + }), + filterOutNullish(), + map((notifications) => + notifications.map((notification) => new NotificationView(notification)), + ), + ); + }); + + unreadNotifications$ = perUserCache$((userId: UserId): Observable => { + return this.notifications$(userId).pipe( + map((notifications) => notifications.filter((notification) => notification.readDate == null)), + ); + }); + + async markAsRead(notificationId: NotificationId, userId: UserId): Promise { + await this.apiService.send("PATCH", `/notifications/${notificationId}/read`, null, true, false); + await this.notificationState(userId).update((current) => { + const notification = current?.find((n) => n.id === notificationId); + if (notification) { + notification.readDate = new Date(); + } + return current; + }); + } + + async markAsDeleted(notificationId: NotificationId, userId: UserId): Promise { + await this.apiService.send( + "DELETE", + `/notifications/${notificationId}/delete`, + null, + true, + false, + ); + await this.notificationState(userId).update((current) => { + const notification = current?.find((n) => n.id === notificationId); + if (notification) { + notification.deletedDate = new Date(); + } + return current; + }); + } + + async clearState(userId: UserId): Promise { + await this.replaceNotificationState(userId, []); + } + + async refreshNotifications(userId: UserId) { + await this.fetchNotificationsFromApi(userId); + } + + /** + * Helper observable to filter notifications by the notification type and user ids + * Returns EMPTY if no user ids are provided + * @param userIds + * @private + */ + private filteredEndUserNotifications$(userIds: UserId[]) { + if (userIds.length == 0) { + return EMPTY; + } + + return this.notificationService.notifications$.pipe( + filter( + ([{ type }, userId]) => + (type === NotificationType.Notification || + type === NotificationType.NotificationStatus) && + userIds.includes(userId), + ), + ); + } + + /** + * Creates a subscription to listen for end user push notifications and notification status updates. + */ + listenForEndUserNotifications(): Subscription { + return this.authService.authStatuses$ + .pipe( + getLoggedInUserIds, + switchMap((userIds) => this.filteredEndUserNotifications$(userIds)), + concatMap(([notification, userId]) => + this.upsertNotification( + userId, + new NotificationViewData(notification.payload as NotificationViewResponse), + ), + ), + ) + .subscribe(); + } + + /** + * Fetches the notifications from the API and updates the local state + * @param userId + * @private + */ + private async fetchNotificationsFromApi(userId: UserId): Promise { + const res = await this.apiService.send( + "GET", + `/notifications?pageSize=${DEFAULT_NOTIFICATION_PAGE_SIZE}`, + null, + true, + true, + ); + const response = new ListResponse(res, NotificationViewResponse); + + if (response.continuationToken != null) { + this.logService.warning( + `More notifications available, but not fetched. Consider increasing the page size from ${DEFAULT_NOTIFICATION_PAGE_SIZE}`, + ); + } + + const notificationData = response.data.map((n) => new NotificationViewData(n)); + await this.replaceNotificationState(userId, notificationData); + } + + /** + * Replaces the local state with notifications and returns the updated state + * @param userId + * @param notifications + * @private + */ + private replaceNotificationState( + userId: UserId, + notifications: NotificationViewData[], + ): Promise { + return this.notificationState(userId).update(() => notifications); + } + + /** + * Updates the local state adding the new notification or updates an existing one with the same id + * Returns the entire updated notifications state + * @param userId + * @param notification + * @private + */ + private async upsertNotification( + userId: UserId, + notification: NotificationViewData, + ): Promise { + return this.notificationState(userId).update((current) => { + current ??= []; + + const existingIndex = current.findIndex((n) => n.id === notification.id); + + if (existingIndex === -1) { + current.push(notification); + } else { + current[existingIndex] = notification; + } + + return current; + }); + } + + /** + * Returns the local state for notifications + * @param userId + * @private + */ + private notificationState(userId: UserId) { + return this.stateProvider.getUser(userId, NOTIFICATIONS); + } +} diff --git a/libs/vault/src/notifications/state/end-user-notification.state.ts b/libs/common/src/vault/notifications/state/end-user-notification.state.ts similarity index 100% rename from libs/vault/src/notifications/state/end-user-notification.state.ts rename to libs/common/src/vault/notifications/state/end-user-notification.state.ts diff --git a/libs/vault/src/index.ts b/libs/vault/src/index.ts index 0ab85f47252..f3658046a3d 100644 --- a/libs/vault/src/index.ts +++ b/libs/vault/src/index.ts @@ -23,7 +23,6 @@ export * from "./components/add-edit-folder-dialog/add-edit-folder-dialog.compon export * from "./components/carousel"; export * as VaultIcons from "./icons"; -export * from "./notifications"; export * from "./services/vault-nudges.service"; export { DefaultSshImportPromptService } from "./services/default-ssh-import-prompt.service"; diff --git a/libs/vault/src/notifications/index.ts b/libs/vault/src/notifications/index.ts deleted file mode 100644 index 0c9d5c0d16b..00000000000 --- a/libs/vault/src/notifications/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./abstractions/end-user-notification.service"; -export * from "./services/default-end-user-notification.service"; diff --git a/libs/vault/src/notifications/services/default-end-user-notification.service.spec.ts b/libs/vault/src/notifications/services/default-end-user-notification.service.spec.ts deleted file mode 100644 index 1d7b2e5aa19..00000000000 --- a/libs/vault/src/notifications/services/default-end-user-notification.service.spec.ts +++ /dev/null @@ -1,200 +0,0 @@ -import { TestBed } from "@angular/core/testing"; -import { firstValueFrom, of } from "rxjs"; - -import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { NotificationsService } from "@bitwarden/common/platform/notifications"; -import { StateProvider } from "@bitwarden/common/platform/state"; -import { NotificationId, UserId } from "@bitwarden/common/types/guid"; -import { DefaultEndUserNotificationService } from "@bitwarden/vault"; - -import { FakeStateProvider, mockAccountServiceWith } from "../../../../common/spec"; -import { NotificationViewResponse } from "../models"; -import { NOTIFICATIONS } from "../state/end-user-notification.state"; - -describe("End User Notification Center Service", () => { - let fakeStateProvider: FakeStateProvider; - - const mockApiSend = jest.fn(); - - let testBed: TestBed; - - beforeEach(async () => { - mockApiSend.mockClear(); - - fakeStateProvider = new FakeStateProvider(mockAccountServiceWith("user-id" as UserId)); - - testBed = TestBed.configureTestingModule({ - imports: [], - providers: [ - DefaultEndUserNotificationService, - { - provide: StateProvider, - useValue: fakeStateProvider, - }, - { - provide: ApiService, - useValue: { - send: mockApiSend, - }, - }, - { - provide: NotificationsService, - useValue: { - notifications$: of(null), - }, - }, - ], - }); - }); - - describe("notifications$", () => { - it("should return notifications from state when not null", async () => { - fakeStateProvider.singleUser.mockFor("user-id" as UserId, NOTIFICATIONS, [ - { - id: "notification-id" as NotificationId, - } as NotificationViewResponse, - ]); - - const { notifications$ } = testBed.inject(DefaultEndUserNotificationService); - - const result = await firstValueFrom(notifications$("user-id" as UserId)); - - expect(result.length).toBe(1); - expect(mockApiSend).not.toHaveBeenCalled(); - }); - - it("should return notifications API when state is null", async () => { - mockApiSend.mockResolvedValue({ - data: [ - { - id: "notification-id", - }, - ] as NotificationViewResponse[], - }); - - fakeStateProvider.singleUser.mockFor("user-id" as UserId, NOTIFICATIONS, null as any); - - const { notifications$ } = testBed.inject(DefaultEndUserNotificationService); - - const result = await firstValueFrom(notifications$("user-id" as UserId)); - - expect(result.length).toBe(1); - expect(mockApiSend).toHaveBeenCalledWith("GET", "/notifications", null, true, true); - }); - - it("should share the same observable for the same user", async () => { - const { notifications$ } = testBed.inject(DefaultEndUserNotificationService); - - const first = notifications$("user-id" as UserId); - const second = notifications$("user-id" as UserId); - - expect(first).toBe(second); - }); - }); - - describe("unreadNotifications$", () => { - it("should return unread notifications from state when read value is null", async () => { - fakeStateProvider.singleUser.mockFor("user-id" as UserId, NOTIFICATIONS, [ - { - id: "notification-id" as NotificationId, - readDate: null as any, - } as NotificationViewResponse, - ]); - - const { unreadNotifications$ } = testBed.inject(DefaultEndUserNotificationService); - - const result = await firstValueFrom(unreadNotifications$("user-id" as UserId)); - - expect(result.length).toBe(1); - expect(mockApiSend).not.toHaveBeenCalled(); - }); - }); - - describe("getNotifications", () => { - it("should call getNotifications returning notifications from API", async () => { - mockApiSend.mockResolvedValue({ - data: [ - { - id: "notification-id", - }, - ] as NotificationViewResponse[], - }); - const service = testBed.inject(DefaultEndUserNotificationService); - - await service.getNotifications("user-id" as UserId); - - expect(mockApiSend).toHaveBeenCalledWith("GET", "/notifications", null, true, true); - }); - }); - it("should update local state when notifications are updated", async () => { - mockApiSend.mockResolvedValue({ - data: [ - { - id: "notification-id", - }, - ] as NotificationViewResponse[], - }); - - const mock = fakeStateProvider.singleUser.mockFor( - "user-id" as UserId, - NOTIFICATIONS, - null as any, - ); - - const service = testBed.inject(DefaultEndUserNotificationService); - - await service.getNotifications("user-id" as UserId); - - expect(mock.nextMock).toHaveBeenCalledWith([ - expect.objectContaining({ - id: "notification-id" as NotificationId, - } as NotificationViewResponse), - ]); - }); - - describe("clear", () => { - it("should clear the local notification state for the user", async () => { - const mock = fakeStateProvider.singleUser.mockFor("user-id" as UserId, NOTIFICATIONS, [ - { - id: "notification-id" as NotificationId, - } as NotificationViewResponse, - ]); - - const service = testBed.inject(DefaultEndUserNotificationService); - - await service.clearState("user-id" as UserId); - - expect(mock.nextMock).toHaveBeenCalledWith([]); - }); - }); - - describe("markAsDeleted", () => { - it("should send an API request to mark the notification as deleted", async () => { - const service = testBed.inject(DefaultEndUserNotificationService); - - await service.markAsDeleted("notification-id" as NotificationId, "user-id" as UserId); - expect(mockApiSend).toHaveBeenCalledWith( - "DELETE", - "/notifications/notification-id/delete", - null, - true, - false, - ); - }); - }); - - describe("markAsRead", () => { - it("should send an API request to mark the notification as read", async () => { - const service = testBed.inject(DefaultEndUserNotificationService); - - await service.markAsRead("notification-id" as NotificationId, "user-id" as UserId); - expect(mockApiSend).toHaveBeenCalledWith( - "PATCH", - "/notifications/notification-id/read", - null, - true, - false, - ); - }); - }); -}); diff --git a/libs/vault/src/notifications/services/default-end-user-notification.service.ts b/libs/vault/src/notifications/services/default-end-user-notification.service.ts deleted file mode 100644 index 471ed0e5856..00000000000 --- a/libs/vault/src/notifications/services/default-end-user-notification.service.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { Injectable } from "@angular/core"; -import { concatMap, filter, map, Observable, switchMap } from "rxjs"; - -import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { NotificationType } from "@bitwarden/common/enums"; -import { ListResponse } from "@bitwarden/common/models/response/list.response"; -import { NotificationsService } from "@bitwarden/common/platform/notifications"; -import { StateProvider } from "@bitwarden/common/platform/state"; -import { UserId } from "@bitwarden/common/types/guid"; -import { - filterOutNullish, - perUserCache$, -} from "@bitwarden/common/vault/utils/observable-utilities"; - -import { EndUserNotificationService } from "../abstractions/end-user-notification.service"; -import { NotificationView, NotificationViewData, NotificationViewResponse } from "../models"; -import { NOTIFICATIONS } from "../state/end-user-notification.state"; - -/** - * A service for retrieving and managing notifications for end users. - */ -@Injectable({ - providedIn: "root", -}) -export class DefaultEndUserNotificationService implements EndUserNotificationService { - constructor( - private stateProvider: StateProvider, - private apiService: ApiService, - private defaultNotifications: NotificationsService, - ) { - this.defaultNotifications.notifications$ - .pipe( - filter( - ([notification]) => - notification.type === NotificationType.Notification || - notification.type === NotificationType.NotificationStatus, - ), - concatMap(([notification, userId]) => - this.updateNotificationState(userId, [ - new NotificationViewData(notification.payload as NotificationViewResponse), - ]), - ), - ) - .subscribe(); - } - - notifications$ = perUserCache$((userId: UserId): Observable => { - return this.notificationState(userId).state$.pipe( - switchMap(async (notifications) => { - if (notifications == null) { - await this.fetchNotificationsFromApi(userId); - } - return notifications; - }), - filterOutNullish(), - map((notifications) => - notifications.map((notification) => new NotificationView(notification)), - ), - ); - }); - - unreadNotifications$ = perUserCache$((userId: UserId): Observable => { - return this.notifications$(userId).pipe( - map((notifications) => notifications.filter((notification) => notification.readDate == null)), - ); - }); - - async markAsRead(notificationId: any, userId: UserId): Promise { - await this.apiService.send("PATCH", `/notifications/${notificationId}/read`, null, true, false); - await this.getNotifications(userId); - } - - async markAsDeleted(notificationId: any, userId: UserId): Promise { - await this.apiService.send( - "DELETE", - `/notifications/${notificationId}/delete`, - null, - true, - false, - ); - await this.getNotifications(userId); - } - - async clearState(userId: UserId): Promise { - await this.updateNotificationState(userId, []); - } - - async getNotifications(userId: UserId) { - await this.fetchNotificationsFromApi(userId); - } - - /** - * Fetches the notifications from the API and updates the local state - * @param userId - * @private - */ - private async fetchNotificationsFromApi(userId: UserId): Promise { - const res = await this.apiService.send("GET", "/notifications", null, true, true); - const response = new ListResponse(res, NotificationViewResponse); - const notificationData = response.data.map((n) => new NotificationView(n)); - await this.updateNotificationState(userId, notificationData); - } - - /** - * Updates the local state with notifications and returns the updated state - * @param userId - * @param notifications - * @private - */ - private updateNotificationState( - userId: UserId, - notifications: NotificationViewData[], - ): Promise { - return this.notificationState(userId).update(() => notifications); - } - - /** - * Returns the local state for notifications - * @param userId - * @private - */ - private notificationState(userId: UserId) { - return this.stateProvider.getUser(userId, NOTIFICATIONS); - } -} From 3a8045d7d04ac09f430cd1bf411438ebd4ba4810 Mon Sep 17 00:00:00 2001 From: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com> Date: Mon, 21 Apr 2025 14:08:09 -0500 Subject: [PATCH 04/22] [PM-19215] Fix Firefox extension biometric unlock autoprompt (#14254) * Remove restriction from account security component * Add the ability to manage pop-out to the LockComponentService * Have the Firefox extension pop-out on biometric auto-prompt unlock --- .../settings/account-security.component.html | 2 +- .../settings/account-security.component.ts | 7 --- .../extension-lock-component.service.spec.ts | 58 +++++++++++++++++++ .../extension-lock-component.service.ts | 14 +++++ .../desktop-lock-component.service.spec.ts | 16 +++++ .../desktop-lock-component.service.ts | 8 +++ .../web-lock-component.service.spec.ts | 16 +++++ .../services/web-lock-component.service.ts | 8 +++ .../src/lock/components/lock.component.html | 6 +- .../src/lock/components/lock.component.ts | 45 +++++++++----- .../lock/services/lock-component.service.ts | 12 ++++ 11 files changed, 166 insertions(+), 26 deletions(-) diff --git a/apps/browser/src/auth/popup/settings/account-security.component.html b/apps/browser/src/auth/popup/settings/account-security.component.html index b8252aa6e13..ebf79af644c 100644 --- a/apps/browser/src/auth/popup/settings/account-security.component.html +++ b/apps/browser/src/auth/popup/settings/account-security.component.html @@ -23,7 +23,7 @@ = of(true); form = this.formBuilder.group({ @@ -147,11 +145,6 @@ export class AccountSecurityComponent implements OnInit, OnDestroy { ) {} async ngOnInit() { - // Firefox popup closes when unfocused by biometrics, blocking all unlock methods - if (this.platformUtilsService.getDevice() === DeviceType.FirefoxExtension) { - this.showAutoPrompt = false; - } - const hasMasterPassword = await this.userVerificationService.hasMasterPassword(); this.showMasterPasswordOnClientRestartOption = hasMasterPassword; const maximumVaultTimeoutPolicy = this.accountService.activeAccount$.pipe( diff --git a/apps/browser/src/key-management/lock/services/extension-lock-component.service.spec.ts b/apps/browser/src/key-management/lock/services/extension-lock-component.service.spec.ts index 9afc723825c..ac5331d3627 100644 --- a/apps/browser/src/key-management/lock/services/extension-lock-component.service.spec.ts +++ b/apps/browser/src/key-management/lock/services/extension-lock-component.service.spec.ts @@ -17,6 +17,8 @@ import { } from "@bitwarden/key-management"; import { UnlockOptions } from "@bitwarden/key-management-ui"; +import { BrowserApi } from "../../../platform/browser/browser-api"; +import BrowserPopupUtils from "../../../platform/popup/browser-popup-utils"; import { BrowserRouterService } from "../../../platform/popup/services/browser-router.service"; import { ExtensionLockComponentService } from "./extension-lock-component.service"; @@ -117,6 +119,62 @@ describe("ExtensionLockComponentService", () => { }); }); + describe("popOutBrowserExtension", () => { + let openPopoutSpy: jest.SpyInstance; + beforeEach(() => { + jest.resetAllMocks(); + openPopoutSpy = jest + .spyOn(BrowserPopupUtils, "openCurrentPagePopout") + .mockResolvedValue(undefined); + }); + + it("opens pop-out when the current window is neither a pop-out nor a sidebar", async () => { + jest.spyOn(BrowserPopupUtils, "inPopout").mockReturnValue(false); + jest.spyOn(BrowserPopupUtils, "inSidebar").mockReturnValue(false); + + await service.popOutBrowserExtension(); + + expect(openPopoutSpy).toHaveBeenCalledWith(global.window); + }); + + test.each([ + [true, false], + [false, true], + [true, true], + ])("should not open pop-out under other conditions.", async (inPopout, inSidebar) => { + jest.spyOn(BrowserPopupUtils, "inPopout").mockReturnValue(inPopout); + jest.spyOn(BrowserPopupUtils, "inSidebar").mockReturnValue(inSidebar); + + await service.popOutBrowserExtension(); + + expect(openPopoutSpy).not.toHaveBeenCalled(); + }); + }); + + describe("closeBrowserExtensionPopout", () => { + let closePopupSpy: jest.SpyInstance; + beforeEach(() => { + jest.resetAllMocks(); + closePopupSpy = jest.spyOn(BrowserApi, "closePopup").mockReturnValue(); + }); + + it("closes pop-out when in pop-out", () => { + jest.spyOn(BrowserPopupUtils, "inPopout").mockReturnValue(true); + + service.closeBrowserExtensionPopout(); + + expect(closePopupSpy).toHaveBeenCalledWith(global.window); + }); + + it("doesn't close pop-out when not in pop-out", () => { + jest.spyOn(BrowserPopupUtils, "inPopout").mockReturnValue(false); + + service.closeBrowserExtensionPopout(); + + expect(closePopupSpy).not.toHaveBeenCalled(); + }); + }); + describe("isWindowVisible", () => { it("throws an error", async () => { await expect(service.isWindowVisible()).rejects.toThrow("Method not implemented."); diff --git a/apps/browser/src/key-management/lock/services/extension-lock-component.service.ts b/apps/browser/src/key-management/lock/services/extension-lock-component.service.ts index 09a6f890e60..6ee1fc5175f 100644 --- a/apps/browser/src/key-management/lock/services/extension-lock-component.service.ts +++ b/apps/browser/src/key-management/lock/services/extension-lock-component.service.ts @@ -14,6 +14,8 @@ import { import { LockComponentService, UnlockOptions } from "@bitwarden/key-management-ui"; import { BiometricErrors, BiometricErrorTypes } from "../../../models/biometricErrors"; +import { BrowserApi } from "../../../platform/browser/browser-api"; +import BrowserPopupUtils from "../../../platform/popup/browser-popup-utils"; import { BrowserRouterService } from "../../../platform/popup/services/browser-router.service"; export class ExtensionLockComponentService implements LockComponentService { @@ -37,6 +39,18 @@ export class ExtensionLockComponentService implements LockComponentService { return biometricsError.description; } + async popOutBrowserExtension(): Promise { + if (!BrowserPopupUtils.inPopout(global.window) && !BrowserPopupUtils.inSidebar(global.window)) { + await BrowserPopupUtils.openCurrentPagePopout(global.window); + } + } + + closeBrowserExtensionPopout(): void { + if (BrowserPopupUtils.inPopout(global.window)) { + BrowserApi.closePopup(global.window); + } + } + async isWindowVisible(): Promise { throw new Error("Method not implemented."); } diff --git a/apps/desktop/src/key-management/lock/services/desktop-lock-component.service.spec.ts b/apps/desktop/src/key-management/lock/services/desktop-lock-component.service.spec.ts index 6bfbc803e87..6772af4f905 100644 --- a/apps/desktop/src/key-management/lock/services/desktop-lock-component.service.spec.ts +++ b/apps/desktop/src/key-management/lock/services/desktop-lock-component.service.spec.ts @@ -104,6 +104,22 @@ describe("DesktopLockComponentService", () => { }); }); + describe("popOutBrowserExtension", () => { + it("throws platform not supported error", () => { + expect(() => service.popOutBrowserExtension()).toThrow( + "Method not supported on this platform.", + ); + }); + }); + + describe("closeBrowserExtensionPopout", () => { + it("throws platform not supported error", () => { + expect(() => service.closeBrowserExtensionPopout()).toThrow( + "Method not supported on this platform.", + ); + }); + }); + describe("isWindowVisible", () => { it("returns the window visibility", async () => { isWindowVisibleMock.mockReturnValue(true); diff --git a/apps/desktop/src/key-management/lock/services/desktop-lock-component.service.ts b/apps/desktop/src/key-management/lock/services/desktop-lock-component.service.ts index 72df9336ea2..5cb3803930d 100644 --- a/apps/desktop/src/key-management/lock/services/desktop-lock-component.service.ts +++ b/apps/desktop/src/key-management/lock/services/desktop-lock-component.service.ts @@ -27,6 +27,14 @@ export class DesktopLockComponentService implements LockComponentService { return null; } + popOutBrowserExtension(): Promise { + throw new Error("Method not supported on this platform."); + } + + closeBrowserExtensionPopout(): void { + throw new Error("Method not supported on this platform."); + } + async isWindowVisible(): Promise { return ipc.platform.isWindowVisible(); } diff --git a/apps/web/src/app/key-management/lock/services/web-lock-component.service.spec.ts b/apps/web/src/app/key-management/lock/services/web-lock-component.service.spec.ts index 3c941fe24c7..9e993259830 100644 --- a/apps/web/src/app/key-management/lock/services/web-lock-component.service.spec.ts +++ b/apps/web/src/app/key-management/lock/services/web-lock-component.service.spec.ts @@ -52,6 +52,22 @@ describe("WebLockComponentService", () => { }); }); + describe("popOutBrowserExtension", () => { + it("throws platform not supported error", () => { + expect(() => service.popOutBrowserExtension()).toThrow( + "Method not supported on this platform.", + ); + }); + }); + + describe("closeBrowserExtensionPopout", () => { + it("throws platform not supported error", () => { + expect(() => service.closeBrowserExtensionPopout()).toThrow( + "Method not supported on this platform.", + ); + }); + }); + describe("isWindowVisible", () => { it("throws an error", async () => { await expect(service.isWindowVisible()).rejects.toThrow("Method not implemented."); diff --git a/apps/web/src/app/key-management/lock/services/web-lock-component.service.ts b/apps/web/src/app/key-management/lock/services/web-lock-component.service.ts index dd9f5138dba..ea038ca2c67 100644 --- a/apps/web/src/app/key-management/lock/services/web-lock-component.service.ts +++ b/apps/web/src/app/key-management/lock/services/web-lock-component.service.ts @@ -24,6 +24,14 @@ export class WebLockComponentService implements LockComponentService { return null; } + popOutBrowserExtension(): Promise { + throw new Error("Method not supported on this platform."); + } + + closeBrowserExtensionPopout(): void { + throw new Error("Method not supported on this platform."); + } + async isWindowVisible(): Promise { throw new Error("Method not implemented."); } diff --git a/libs/key-management-ui/src/lock/components/lock.component.html b/libs/key-management-ui/src/lock/components/lock.component.html index 437e29447e2..efc7fb26a2f 100644 --- a/libs/key-management-ui/src/lock/components/lock.component.html +++ b/libs/key-management-ui/src/lock/components/lock.component.html @@ -1,10 +1,10 @@ - -
+ +
- + +
+
+
diff --git a/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.ts b/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.ts index e01b4efd71b..f4b82dc56fc 100644 --- a/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.ts +++ b/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.ts @@ -1,10 +1,19 @@ import { CommonModule } from "@angular/common"; -import { Component } from "@angular/core"; +import { Component, Input } from "@angular/core"; import { RouterModule } from "@angular/router"; import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LinkModule } from "@bitwarden/components"; +export type NavButton = { + label: string; + page: string; + iconKey: string; + iconKeyActive: string; + showBerry?: boolean; +}; + @Component({ selector: "popup-tab-navigation", templateUrl: "popup-tab-navigation.component.html", @@ -15,30 +24,12 @@ import { LinkModule } from "@bitwarden/components"; }, }) export class PopupTabNavigationComponent { - navButtons = [ - { - label: "vault", - page: "/tabs/vault", - iconKey: "lock", - iconKeyActive: "lock-f", - }, - { - label: "generator", - page: "/tabs/generator", - iconKey: "generate", - iconKeyActive: "generate-f", - }, - { - label: "send", - page: "/tabs/send", - iconKey: "send", - iconKeyActive: "send-f", - }, - { - label: "settings", - page: "/tabs/settings", - iconKey: "cog", - iconKeyActive: "cog-f", - }, - ]; + @Input() navButtons: NavButton[] = []; + + constructor(private i18nService: I18nService) {} + + buttonTitle(navButton: NavButton) { + const labelText = this.i18nService.t(navButton.label); + return navButton.showBerry ? this.i18nService.t("labelWithNotification", labelText) : labelText; + } } diff --git a/apps/browser/src/popup/tabs-v2.component.html b/apps/browser/src/popup/tabs-v2.component.html new file mode 100644 index 00000000000..bde3aaa3d31 --- /dev/null +++ b/apps/browser/src/popup/tabs-v2.component.html @@ -0,0 +1,3 @@ + + + diff --git a/apps/browser/src/popup/tabs-v2.component.ts b/apps/browser/src/popup/tabs-v2.component.ts index 4cdb8fc029d..1392dc565ab 100644 --- a/apps/browser/src/popup/tabs-v2.component.ts +++ b/apps/browser/src/popup/tabs-v2.component.ts @@ -1,11 +1,53 @@ import { Component } from "@angular/core"; +import { combineLatest, map } from "rxjs"; + +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { HasNudgeService } from "@bitwarden/vault"; @Component({ selector: "app-tabs-v2", - template: ` - - - - `, + templateUrl: "./tabs-v2.component.html", + providers: [HasNudgeService], }) -export class TabsV2Component {} +export class TabsV2Component { + constructor( + private readonly hasNudgeService: HasNudgeService, + private readonly configService: ConfigService, + ) {} + + protected navButtons$ = combineLatest([ + this.configService.getFeatureFlag$(FeatureFlag.PM8851_BrowserOnboardingNudge), + this.hasNudgeService.shouldShowNudge$(), + ]).pipe( + map(([onboardingFeatureEnabled, showNudge]) => { + return [ + { + label: "vault", + page: "/tabs/vault", + iconKey: "lock", + iconKeyActive: "lock-f", + }, + { + label: "generator", + page: "/tabs/generator", + iconKey: "generate", + iconKeyActive: "generate-f", + }, + { + label: "send", + page: "/tabs/send", + iconKey: "send", + iconKeyActive: "send-f", + }, + { + label: "settings", + page: "/tabs/settings", + iconKey: "cog", + iconKeyActive: "cog-f", + showBerry: onboardingFeatureEnabled && showNudge, + }, + ]; + }), + ); +} diff --git a/libs/vault/src/index.ts b/libs/vault/src/index.ts index f3658046a3d..655b536de64 100644 --- a/libs/vault/src/index.ts +++ b/libs/vault/src/index.ts @@ -24,6 +24,7 @@ export * from "./components/carousel"; export * as VaultIcons from "./icons"; export * from "./services/vault-nudges.service"; +export * from "./services/custom-nudges-services"; export { DefaultSshImportPromptService } from "./services/default-ssh-import-prompt.service"; export { SshImportPromptService } from "./services/ssh-import-prompt.service"; diff --git a/libs/vault/src/services/custom-nudges-services/has-nudge.service.ts b/libs/vault/src/services/custom-nudges-services/has-nudge.service.ts new file mode 100644 index 00000000000..b1f319451e6 --- /dev/null +++ b/libs/vault/src/services/custom-nudges-services/has-nudge.service.ts @@ -0,0 +1,47 @@ +import { inject, Injectable } from "@angular/core"; +import { combineLatest, distinctUntilChanged, map, Observable, of, switchMap } from "rxjs"; + +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { UserId } from "@bitwarden/common/types/guid"; + +import { DefaultSingleNudgeService } from "../default-single-nudge.service"; +import { VaultNudgeType } from "../vault-nudges.service"; + +/** + * Custom Nudge Service used for showing if the user has any existing nudge in the Vault. + */ +@Injectable({ + providedIn: "root", +}) +export class HasNudgeService extends DefaultSingleNudgeService { + private accountService = inject(AccountService); + + private nudgeTypes: VaultNudgeType[] = [ + VaultNudgeType.HasVaultItems, + VaultNudgeType.IntroCarouselDismissal, + // add additional nudge types here as needed + ]; + + /** + * Returns an observable that emits true if any of the provided nudge types are present + */ + shouldShowNudge$(): Observable { + return this.accountService.activeAccount$.pipe( + switchMap((activeAccount) => { + const userId: UserId | undefined = activeAccount?.id; + if (!userId) { + return of(false); + } + + const nudgeObservables: Observable[] = this.nudgeTypes.map((nudge) => + super.shouldShowNudge$(nudge, userId), + ); + + return combineLatest(nudgeObservables).pipe( + map((nudgeStates) => nudgeStates.some((state) => state)), + distinctUntilChanged(), + ); + }), + ); + } +} diff --git a/libs/vault/src/services/custom-nudges-services/index.ts b/libs/vault/src/services/custom-nudges-services/index.ts index 84409eb35ae..dd343e47d75 100644 --- a/libs/vault/src/services/custom-nudges-services/index.ts +++ b/libs/vault/src/services/custom-nudges-services/index.ts @@ -1 +1,2 @@ export * from "./has-items-nudge.service"; +export * from "./has-nudge.service"; From c08888bbd963e70784d58053e22da6f9b09a1442 Mon Sep 17 00:00:00 2001 From: Daniel James Smith <2670567+djsmith85@users.noreply.github.com> Date: Tue, 22 Apr 2025 21:51:14 +0200 Subject: [PATCH 14/22] Move feature flags for Data Insights and Reporting (#14375) Co-authored-by: Daniel James Smith --- libs/common/src/enums/feature-flag.enum.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 08906c792fb..33843932382 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -35,6 +35,10 @@ export enum FeatureFlag { PM18794_ProviderPaymentMethod = "pm-18794-provider-payment-method", PM17772_AdminInitiatedSponsorships = "pm-17772-admin-initiated-sponsorships", + /* Data Insights and Reporting */ + CriticalApps = "pm-14466-risk-insights-critical-application", + EnableRiskInsightsNotifications = "enable-risk-insights-notifications", + /* Key Management */ PrivateKeyRegeneration = "pm-12241-private-key-regeneration", UserKeyRotationV2 = "userkey-rotation-v2", @@ -43,8 +47,6 @@ export enum FeatureFlag { /* Tools */ ItemShare = "item-share", - CriticalApps = "pm-14466-risk-insights-critical-application", - EnableRiskInsightsNotifications = "enable-risk-insights-notifications", DesktopSendUIRefresh = "desktop-send-ui-refresh", /* Vault */ @@ -92,10 +94,12 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.UseTreeWalkerApiForPageDetailsCollection]: FALSE, [FeatureFlag.MacOsNativeCredentialSync]: FALSE, - /* Tools */ - [FeatureFlag.ItemShare]: FALSE, + /* Data Insights and Reporting */ [FeatureFlag.CriticalApps]: FALSE, [FeatureFlag.EnableRiskInsightsNotifications]: FALSE, + + /* Tools */ + [FeatureFlag.ItemShare]: FALSE, [FeatureFlag.DesktopSendUIRefresh]: FALSE, /* Vault */ From 95d0fb9012fcafbd6830fa5ae9170c35a0962f54 Mon Sep 17 00:00:00 2001 From: Daniel Riera Date: Tue, 22 Apr 2025 16:35:44 -0400 Subject: [PATCH 15/22] PM-20490 add of to mitigate return-type annotation (#14377) --- .../src/autofill/background/notification.background.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/browser/src/autofill/background/notification.background.ts b/apps/browser/src/autofill/background/notification.background.ts index 6589252d94b..00d24300d78 100644 --- a/apps/browser/src/autofill/background/notification.background.ts +++ b/apps/browser/src/autofill/background/notification.background.ts @@ -1,6 +1,6 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore -import { firstValueFrom, switchMap, map } from "rxjs"; +import { firstValueFrom, switchMap, map, of } from "rxjs"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; @@ -780,7 +780,7 @@ export default class NotificationBackground { this.taskService.tasksEnabled$(userId).pipe( switchMap((tasksEnabled) => { if (!tasksEnabled) { - return []; + return of([]); } return this.taskService From 71e720e94513b7d36f80223387719338fa5406f2 Mon Sep 17 00:00:00 2001 From: Alec Rippberger <127791530+alec-livefront@users.noreply.github.com> Date: Tue, 22 Apr 2025 23:09:07 -0500 Subject: [PATCH 16/22] fix(auth): clarify 2FA security key verification text Updates user interface text to improve clarity when prompting for security key verification during two-factor authentication. Ref: PM-20055 --- apps/browser/src/_locales/en/messages.json | 3 +++ apps/desktop/src/locales/en/messages.json | 3 +++ apps/web/src/connectors/webauthn-fallback.ts | 2 +- apps/web/src/locales/en/messages.json | 3 +++ .../src/angular/two-factor-auth/two-factor-auth.component.ts | 2 +- 5 files changed, 11 insertions(+), 2 deletions(-) diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 73c64ba7fd4..462af12a352 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -886,6 +886,9 @@ "followTheStepsBelowToFinishLoggingIn": { "message": "Follow the steps below to finish logging in." }, + "followTheStepsBelowToFinishLoggingInWithSecurityKey": { + "message": "Follow the steps below to finish logging in with your security key." + }, "restartRegistration": { "message": "Restart registration" }, diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index 8850cbe5a3f..6097543e50a 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -3212,6 +3212,9 @@ "followTheStepsBelowToFinishLoggingIn": { "message": "Follow the steps below to finish logging in." }, + "followTheStepsBelowToFinishLoggingInWithSecurityKey": { + "message": "Follow the steps below to finish logging in with your security key." + }, "launchDuo": { "message": "Launch Duo in Browser" }, diff --git a/apps/web/src/connectors/webauthn-fallback.ts b/apps/web/src/connectors/webauthn-fallback.ts index 3561f922e03..43be5733973 100644 --- a/apps/web/src/connectors/webauthn-fallback.ts +++ b/apps/web/src/connectors/webauthn-fallback.ts @@ -86,7 +86,7 @@ document.addEventListener("DOMContentLoaded", async () => { titleForLargerScreens.innerText = localeService.t("verifyYourIdentity"); const subtitle = document.getElementById("subtitle"); - subtitle.innerText = localeService.t("followTheStepsBelowToFinishLoggingIn"); + subtitle.innerText = localeService.t("followTheStepsBelowToFinishLoggingInWithSecurityKey"); }); function start() { diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 56a98a661ef..05d29071731 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -7280,6 +7280,9 @@ "followTheStepsBelowToFinishLoggingIn": { "message": "Follow the steps below to finish logging in." }, + "followTheStepsBelowToFinishLoggingInWithSecurityKey": { + "message": "Follow the steps below to finish logging in with your security key." + }, "launchDuo": { "message": "Launch Duo" }, diff --git a/libs/auth/src/angular/two-factor-auth/two-factor-auth.component.ts b/libs/auth/src/angular/two-factor-auth/two-factor-auth.component.ts index 6cdf42b76da..aed9f9f07a5 100644 --- a/libs/auth/src/angular/two-factor-auth/two-factor-auth.component.ts +++ b/libs/auth/src/angular/two-factor-auth/two-factor-auth.component.ts @@ -362,7 +362,7 @@ export class TwoFactorAuthComponent implements OnInit, OnDestroy { break; case TwoFactorProviderType.WebAuthn: this.anonLayoutWrapperDataService.setAnonLayoutWrapperData({ - pageSubtitle: this.i18nService.t("followTheStepsBelowToFinishLoggingIn"), + pageSubtitle: this.i18nService.t("followTheStepsBelowToFinishLoggingInWithSecurityKey"), pageIcon: TwoFactorAuthWebAuthnIcon, }); break; From 60fe8fa7b00dbb27a419e926e7d3dc4e376f92a4 Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Wed, 23 Apr 2025 14:21:45 +0200 Subject: [PATCH 17/22] Add comments to send service to make it easier to follow (#14389) --- libs/common/src/tools/send/services/send.service.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/libs/common/src/tools/send/services/send.service.ts b/libs/common/src/tools/send/services/send.service.ts index 8d6e62e3b8c..cefd9942d29 100644 --- a/libs/common/src/tools/send/services/send.service.ts +++ b/libs/common/src/tools/send/services/send.service.ts @@ -50,7 +50,7 @@ export class SendService implements InternalSendServiceAbstraction { model: SendView, file: File | ArrayBuffer, password: string, - key?: SymmetricCryptoKey, + userKey?: SymmetricCryptoKey, ): Promise<[Send, EncArrayBuffer]> { let fileData: EncArrayBuffer = null; const send = new Send(); @@ -62,15 +62,19 @@ export class SendService implements InternalSendServiceAbstraction { send.deletionDate = model.deletionDate; send.expirationDate = model.expirationDate; if (model.key == null) { + // Sends use a seed, stored in the URL fragment. This seed is used to derive the key that is used for encryption. const key = await this.keyGenerationService.createKeyWithPurpose( 128, this.sendKeyPurpose, this.sendKeySalt, ); + // key.material is the seed that can be used to re-derive the key model.key = key.material; model.cryptoKey = key.derivedKey; } if (password != null) { + // Note: Despite being called key, the passwordKey is not used for encryption. + // It is used as a static proof that the client knows the password, and has the encryption key. const passwordKey = await this.keyGenerationService.deriveKeyFromPassword( password, model.key, @@ -78,11 +82,11 @@ export class SendService implements InternalSendServiceAbstraction { ); send.password = passwordKey.keyB64; } - if (key == null) { - key = await this.keyService.getUserKey(); + if (userKey == null) { + userKey = await this.keyService.getUserKey(); } // Key is not a SymmetricCryptoKey, but key material used to derive the cryptoKey - send.key = await this.encryptService.encrypt(model.key, key); + send.key = await this.encryptService.encrypt(model.key, userKey); send.name = await this.encryptService.encrypt(model.name, model.cryptoKey); send.notes = await this.encryptService.encrypt(model.notes, model.cryptoKey); if (send.type === SendType.Text) { From 8e1dfb7d214f3f457f688b5f693830d9355853d2 Mon Sep 17 00:00:00 2001 From: Daniel Riera Date: Wed, 23 Apr 2025 09:59:05 -0400 Subject: [PATCH 18/22] PM-19281 localize relevant notification bar text (#14327) * PM-19281 localize todo text * update storybook --- .../content/components/cipher/cipher-action.ts | 8 ++++---- .../content/components/cipher/cipher-item.ts | 4 +++- .../ciphers/cipher-action.lit-stories.ts | 1 + .../lit-stories/notification/body.lit-stories.ts | 1 + .../content/components/notification/body.ts | 3 +++ .../content/components/notification/button-row.ts | 14 ++++++++------ .../content/components/notification/container.ts | 1 + .../content/components/notification/footer.ts | 1 + apps/browser/src/autofill/notification/bar.ts | 7 +++++-- 9 files changed, 27 insertions(+), 13 deletions(-) diff --git a/apps/browser/src/autofill/content/components/cipher/cipher-action.ts b/apps/browser/src/autofill/content/components/cipher/cipher-action.ts index aaa4b11d8a2..85698c87c67 100644 --- a/apps/browser/src/autofill/content/components/cipher/cipher-action.ts +++ b/apps/browser/src/autofill/content/components/cipher/cipher-action.ts @@ -8,24 +8,24 @@ export function CipherAction({ handleAction = () => { /* no-op */ }, + i18n, notificationType, theme, }: { handleAction?: (e: Event) => void; + i18n: { [key: string]: string }; notificationType: typeof NotificationTypes.Change | typeof NotificationTypes.Add; theme: Theme; }) { return notificationType === NotificationTypes.Change ? BadgeButton({ buttonAction: handleAction, - // @TODO localize - buttonText: "Update", + buttonText: i18n.notificationUpdate, theme, }) : EditButton({ buttonAction: handleAction, - // @TODO localize - buttonText: "Edit", + buttonText: i18n.notificationEdit, theme, }); } diff --git a/apps/browser/src/autofill/content/components/cipher/cipher-item.ts b/apps/browser/src/autofill/content/components/cipher/cipher-item.ts index 96b44d2c0cc..8ab29860f3b 100644 --- a/apps/browser/src/autofill/content/components/cipher/cipher-item.ts +++ b/apps/browser/src/autofill/content/components/cipher/cipher-item.ts @@ -19,11 +19,13 @@ const cipherIconWidth = "24px"; export function CipherItem({ cipher, handleAction, + i18n, notificationType, theme = ThemeTypes.Light, }: { cipher: NotificationCipherData; handleAction?: (e: Event) => void; + i18n: { [key: string]: string }; notificationType?: NotificationType; theme: Theme; }) { @@ -34,7 +36,7 @@ export function CipherItem({ if (notificationType === NotificationTypes.Change || notificationType === NotificationTypes.Add) { cipherActionButton = html`
- ${CipherAction({ handleAction, notificationType, theme })} + ${CipherAction({ handleAction, i18n, notificationType, theme })}
`; } diff --git a/apps/browser/src/autofill/content/components/lit-stories/ciphers/cipher-action.lit-stories.ts b/apps/browser/src/autofill/content/components/lit-stories/ciphers/cipher-action.lit-stories.ts index e597cddabe6..dd1ff816f06 100644 --- a/apps/browser/src/autofill/content/components/lit-stories/ciphers/cipher-action.lit-stories.ts +++ b/apps/browser/src/autofill/content/components/lit-stories/ciphers/cipher-action.lit-stories.ts @@ -7,6 +7,7 @@ import { CipherAction } from "../../cipher/cipher-action"; type Args = { handleAction?: (e: Event) => void; + i18n: { [key: string]: string }; notificationType: typeof NotificationTypes.Change | typeof NotificationTypes.Add; theme: Theme; }; diff --git a/apps/browser/src/autofill/content/components/lit-stories/notification/body.lit-stories.ts b/apps/browser/src/autofill/content/components/lit-stories/notification/body.lit-stories.ts index 32b4170d1da..13e2322a9f2 100644 --- a/apps/browser/src/autofill/content/components/lit-stories/notification/body.lit-stories.ts +++ b/apps/browser/src/autofill/content/components/lit-stories/notification/body.lit-stories.ts @@ -10,6 +10,7 @@ import { NotificationBody } from "../../notification/body"; type Args = { ciphers: NotificationCipherData[]; + i18n: { [key: string]: string }; notificationType: NotificationType; theme: Theme; handleEditOrUpdateAction: (e: Event) => void; diff --git a/apps/browser/src/autofill/content/components/notification/body.ts b/apps/browser/src/autofill/content/components/notification/body.ts index 66b580bde43..cc0fa359303 100644 --- a/apps/browser/src/autofill/content/components/notification/body.ts +++ b/apps/browser/src/autofill/content/components/notification/body.ts @@ -17,12 +17,14 @@ const { css } = createEmotion({ export function NotificationBody({ ciphers = [], + i18n, notificationType, theme = ThemeTypes.Light, handleEditOrUpdateAction, }: { ciphers?: NotificationCipherData[]; customClasses?: string[]; + i18n: { [key: string]: string }; notificationType?: NotificationType; theme: Theme; handleEditOrUpdateAction: (e: Event) => void; @@ -37,6 +39,7 @@ export function NotificationBody({ theme, children: CipherItem({ cipher, + i18n, notificationType, theme, handleAction: handleEditOrUpdateAction, diff --git a/apps/browser/src/autofill/content/components/notification/button-row.ts b/apps/browser/src/autofill/content/components/notification/button-row.ts index 8661f5957e1..3834da4269d 100644 --- a/apps/browser/src/autofill/content/components/notification/button-row.ts +++ b/apps/browser/src/autofill/content/components/notification/button-row.ts @@ -22,17 +22,19 @@ function getVaultIconByProductTier(productTierType?: ProductTierType): Option["i } export type NotificationButtonRowProps = { - theme: Theme; + folders?: FolderView[]; + i18n: { [key: string]: string }; + organizations?: OrgView[]; primaryButton: { text: string; handlePrimaryButtonClick: (args: any) => void; }; - folders?: FolderView[]; - organizations?: OrgView[]; + theme: Theme; }; export function NotificationButtonRow({ folders, + i18n, organizations, primaryButton, theme, @@ -40,7 +42,7 @@ export function NotificationButtonRow({ const currentUserVaultOption: Option = { icon: User, default: true, - text: "My vault", // @TODO localize + text: i18n.myVault, value: "0", }; const organizationOptions: Option[] = organizations?.length @@ -84,7 +86,7 @@ export function NotificationButtonRow({ ? [ { id: "organization", - label: "Vault", // @TODO localize + label: i18n.vault, options: organizationOptions, }, ] @@ -93,7 +95,7 @@ export function NotificationButtonRow({ ? [ { id: "folder", - label: "Folder", // @TODO localize + label: i18n.folder, options: folderOptions, }, ] diff --git a/apps/browser/src/autofill/content/components/notification/container.ts b/apps/browser/src/autofill/content/components/notification/container.ts index c29f58e116b..e1d098e3b09 100644 --- a/apps/browser/src/autofill/content/components/notification/container.ts +++ b/apps/browser/src/autofill/content/components/notification/container.ts @@ -59,6 +59,7 @@ export function NotificationContainer({ ciphers, notificationType: type, theme, + i18n, }) : null} ${NotificationFooter({ diff --git a/apps/browser/src/autofill/content/components/notification/footer.ts b/apps/browser/src/autofill/content/components/notification/footer.ts index 8ed69a96ad9..58a87ebc678 100644 --- a/apps/browser/src/autofill/content/components/notification/footer.ts +++ b/apps/browser/src/autofill/content/components/notification/footer.ts @@ -38,6 +38,7 @@ export function NotificationFooter({ ? NotificationButtonRow({ folders, organizations, + i18n, primaryButton: { handlePrimaryButtonClick: handleSaveAction, text: primaryButtonText, diff --git a/apps/browser/src/autofill/notification/bar.ts b/apps/browser/src/autofill/notification/bar.ts index d660790ee63..4e85d893178 100644 --- a/apps/browser/src/autofill/notification/bar.ts +++ b/apps/browser/src/autofill/notification/bar.ts @@ -53,6 +53,7 @@ function getI18n() { return { appName: chrome.i18n.getMessage("appName"), close: chrome.i18n.getMessage("close"), + collection: chrome.i18n.getMessage("collection"), folder: chrome.i18n.getMessage("folder"), loginSaveSuccess: chrome.i18n.getMessage("loginSaveSuccess"), loginSaveSuccessDetails: chrome.i18n.getMessage("loginSaveSuccessDetails"), @@ -63,10 +64,11 @@ function getI18n() { nextSecurityTaskAction: chrome.i18n.getMessage("nextSecurityTaskAction"), newItem: chrome.i18n.getMessage("newItem"), never: chrome.i18n.getMessage("never"), + myVault: chrome.i18n.getMessage("myVault"), notificationAddDesc: chrome.i18n.getMessage("notificationAddDesc"), notificationAddSave: chrome.i18n.getMessage("notificationAddSave"), notificationChangeDesc: chrome.i18n.getMessage("notificationChangeDesc"), - notificationChangeSave: chrome.i18n.getMessage("notificationChangeSave"), + notificationUpdate: chrome.i18n.getMessage("notificationChangeSave"), notificationEdit: chrome.i18n.getMessage("edit"), notificationUnlock: chrome.i18n.getMessage("notificationUnlock"), notificationUnlockDesc: chrome.i18n.getMessage("notificationUnlockDesc"), @@ -78,6 +80,7 @@ function getI18n() { typeLogin: chrome.i18n.getMessage("typeLogin"), updateLoginAction: chrome.i18n.getMessage("updateLoginAction"), updateLoginPrompt: chrome.i18n.getMessage("updateLoginPrompt"), + vault: chrome.i18n.getMessage("vault"), view: chrome.i18n.getMessage("view"), }; } @@ -200,7 +203,7 @@ async function initNotificationBar(message: NotificationBarWindowMessage) { const changeTemplate = document.getElementById("template-change") as HTMLTemplateElement; const changeButton = findElementById(changeTemplate, "change-save"); - changeButton.textContent = i18n.notificationChangeSave; + changeButton.textContent = i18n.notificationUpdate; const changeEditButton = findElementById(changeTemplate, "change-edit"); changeEditButton.textContent = i18n.notificationEdit; From 3aa1378c992df4da6e035ca4a89931c1cf39cb87 Mon Sep 17 00:00:00 2001 From: Oscar Hinton Date: Wed, 23 Apr 2025 16:59:14 +0200 Subject: [PATCH 19/22] Eliminate dead code from trial-initiation (#14378) --- .../abm-enterprise-content.component.html | 17 --- .../abm-enterprise-content.component.ts | 7 - .../content/abm-teams-content.component.html | 17 --- .../content/abm-teams-content.component.ts | 7 - .../cnet-enterprise-content.component.html | 17 --- .../cnet-enterprise-content.component.ts | 7 - .../cnet-individual-content.component.html | 17 --- .../cnet-individual-content.component.ts | 7 - .../content/cnet-teams-content.component.html | 17 --- .../content/cnet-teams-content.component.ts | 7 - .../content/default-content.component.html | 16 -- .../content/default-content.component.ts | 7 - .../content/enterprise-content.component.html | 44 ------ .../content/enterprise-content.component.ts | 7 - .../enterprise1-content.component.html | 44 ------ .../content/enterprise1-content.component.ts | 7 - .../enterprise2-content.component.html | 44 ------ .../content/enterprise2-content.component.ts | 7 - .../content/logo-badges.component.html | 11 -- .../content/logo-badges.component.ts | 7 - .../content/logo-cnet-5-stars.component.html | 23 --- .../content/logo-cnet-5-stars.component.ts | 7 - .../content/logo-cnet.component.html | 15 -- .../content/logo-cnet.component.ts | 7 - .../logo-company-testimonial.component.html | 28 ---- .../logo-company-testimonial.component.ts | 7 - .../content/logo-forbes.component.html | 15 -- .../content/logo-forbes.component.ts | 7 - .../content/logo-us-news.component.html | 5 - .../content/logo-us-news.component.ts | 7 - .../content/review-blurb.component.html | 13 -- .../content/review-blurb.component.ts | 13 -- .../content/review-logo.component.html | 18 --- .../content/review-logo.component.ts | 13 -- .../secrets-manager-content.component.html | 30 ---- .../secrets-manager-content.component.ts | 80 ---------- .../content/teams-content.component.html | 17 --- .../content/teams-content.component.ts | 7 - .../content/teams1-content.component.html | 35 ----- .../content/teams1-content.component.ts | 7 - .../content/teams2-content.component.html | 35 ----- .../content/teams2-content.component.ts | 7 - .../content/teams3-content.component.html | 26 ---- .../content/teams3-content.component.ts | 7 - ...-manager-trial-free-stepper.component.html | 45 ------ ...ts-manager-trial-free-stepper.component.ts | 90 ----------- ...-manager-trial-paid-stepper.component.html | 67 -------- ...ts-manager-trial-paid-stepper.component.ts | 144 ------------------ .../secrets-manager-trial.component.html | 44 ------ .../secrets-manager-trial.component.ts | 32 ---- .../trial-initiation.module.ts | 59 +------ 51 files changed, 1 insertion(+), 1223 deletions(-) delete mode 100644 apps/web/src/app/billing/trial-initiation/content/abm-enterprise-content.component.html delete mode 100644 apps/web/src/app/billing/trial-initiation/content/abm-enterprise-content.component.ts delete mode 100644 apps/web/src/app/billing/trial-initiation/content/abm-teams-content.component.html delete mode 100644 apps/web/src/app/billing/trial-initiation/content/abm-teams-content.component.ts delete mode 100644 apps/web/src/app/billing/trial-initiation/content/cnet-enterprise-content.component.html delete mode 100644 apps/web/src/app/billing/trial-initiation/content/cnet-enterprise-content.component.ts delete mode 100644 apps/web/src/app/billing/trial-initiation/content/cnet-individual-content.component.html delete mode 100644 apps/web/src/app/billing/trial-initiation/content/cnet-individual-content.component.ts delete mode 100644 apps/web/src/app/billing/trial-initiation/content/cnet-teams-content.component.html delete mode 100644 apps/web/src/app/billing/trial-initiation/content/cnet-teams-content.component.ts delete mode 100644 apps/web/src/app/billing/trial-initiation/content/default-content.component.html delete mode 100644 apps/web/src/app/billing/trial-initiation/content/default-content.component.ts delete mode 100644 apps/web/src/app/billing/trial-initiation/content/enterprise-content.component.html delete mode 100644 apps/web/src/app/billing/trial-initiation/content/enterprise-content.component.ts delete mode 100644 apps/web/src/app/billing/trial-initiation/content/enterprise1-content.component.html delete mode 100644 apps/web/src/app/billing/trial-initiation/content/enterprise1-content.component.ts delete mode 100644 apps/web/src/app/billing/trial-initiation/content/enterprise2-content.component.html delete mode 100644 apps/web/src/app/billing/trial-initiation/content/enterprise2-content.component.ts delete mode 100644 apps/web/src/app/billing/trial-initiation/content/logo-badges.component.html delete mode 100644 apps/web/src/app/billing/trial-initiation/content/logo-badges.component.ts delete mode 100644 apps/web/src/app/billing/trial-initiation/content/logo-cnet-5-stars.component.html delete mode 100644 apps/web/src/app/billing/trial-initiation/content/logo-cnet-5-stars.component.ts delete mode 100644 apps/web/src/app/billing/trial-initiation/content/logo-cnet.component.html delete mode 100644 apps/web/src/app/billing/trial-initiation/content/logo-cnet.component.ts delete mode 100644 apps/web/src/app/billing/trial-initiation/content/logo-company-testimonial.component.html delete mode 100644 apps/web/src/app/billing/trial-initiation/content/logo-company-testimonial.component.ts delete mode 100644 apps/web/src/app/billing/trial-initiation/content/logo-forbes.component.html delete mode 100644 apps/web/src/app/billing/trial-initiation/content/logo-forbes.component.ts delete mode 100644 apps/web/src/app/billing/trial-initiation/content/logo-us-news.component.html delete mode 100644 apps/web/src/app/billing/trial-initiation/content/logo-us-news.component.ts delete mode 100644 apps/web/src/app/billing/trial-initiation/content/review-blurb.component.html delete mode 100644 apps/web/src/app/billing/trial-initiation/content/review-blurb.component.ts delete mode 100644 apps/web/src/app/billing/trial-initiation/content/review-logo.component.html delete mode 100644 apps/web/src/app/billing/trial-initiation/content/review-logo.component.ts delete mode 100644 apps/web/src/app/billing/trial-initiation/content/secrets-manager-content.component.html delete mode 100644 apps/web/src/app/billing/trial-initiation/content/secrets-manager-content.component.ts delete mode 100644 apps/web/src/app/billing/trial-initiation/content/teams-content.component.html delete mode 100644 apps/web/src/app/billing/trial-initiation/content/teams-content.component.ts delete mode 100644 apps/web/src/app/billing/trial-initiation/content/teams1-content.component.html delete mode 100644 apps/web/src/app/billing/trial-initiation/content/teams1-content.component.ts delete mode 100644 apps/web/src/app/billing/trial-initiation/content/teams2-content.component.html delete mode 100644 apps/web/src/app/billing/trial-initiation/content/teams2-content.component.ts delete mode 100644 apps/web/src/app/billing/trial-initiation/content/teams3-content.component.html delete mode 100644 apps/web/src/app/billing/trial-initiation/content/teams3-content.component.ts delete mode 100644 apps/web/src/app/billing/trial-initiation/secrets-manager/secrets-manager-trial-free-stepper.component.html delete mode 100644 apps/web/src/app/billing/trial-initiation/secrets-manager/secrets-manager-trial-free-stepper.component.ts delete mode 100644 apps/web/src/app/billing/trial-initiation/secrets-manager/secrets-manager-trial-paid-stepper.component.html delete mode 100644 apps/web/src/app/billing/trial-initiation/secrets-manager/secrets-manager-trial-paid-stepper.component.ts delete mode 100644 apps/web/src/app/billing/trial-initiation/secrets-manager/secrets-manager-trial.component.html delete mode 100644 apps/web/src/app/billing/trial-initiation/secrets-manager/secrets-manager-trial.component.ts diff --git a/apps/web/src/app/billing/trial-initiation/content/abm-enterprise-content.component.html b/apps/web/src/app/billing/trial-initiation/content/abm-enterprise-content.component.html deleted file mode 100644 index 46e1fae80df..00000000000 --- a/apps/web/src/app/billing/trial-initiation/content/abm-enterprise-content.component.html +++ /dev/null @@ -1,17 +0,0 @@ -

The Bitwarden Password Manager

-
-

- Trusted by millions of individuals, teams, and organizations worldwide for secure password - storage and sharing. -

-
-
    -
  • Store logins, secure notes, and more
  • -
  • Collaborate and share securely
  • -
  • Access anywhere on any device
  • -
  • Create your account to get started
  • -
-
- - -
diff --git a/apps/web/src/app/billing/trial-initiation/content/abm-enterprise-content.component.ts b/apps/web/src/app/billing/trial-initiation/content/abm-enterprise-content.component.ts deleted file mode 100644 index 0f9db7b4405..00000000000 --- a/apps/web/src/app/billing/trial-initiation/content/abm-enterprise-content.component.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Component } from "@angular/core"; - -@Component({ - selector: "app-abm-enterprise-content", - templateUrl: "abm-enterprise-content.component.html", -}) -export class AbmEnterpriseContentComponent {} diff --git a/apps/web/src/app/billing/trial-initiation/content/abm-teams-content.component.html b/apps/web/src/app/billing/trial-initiation/content/abm-teams-content.component.html deleted file mode 100644 index 46e1fae80df..00000000000 --- a/apps/web/src/app/billing/trial-initiation/content/abm-teams-content.component.html +++ /dev/null @@ -1,17 +0,0 @@ -

The Bitwarden Password Manager

-
-

- Trusted by millions of individuals, teams, and organizations worldwide for secure password - storage and sharing. -

-
-
    -
  • Store logins, secure notes, and more
  • -
  • Collaborate and share securely
  • -
  • Access anywhere on any device
  • -
  • Create your account to get started
  • -
-
- - -
diff --git a/apps/web/src/app/billing/trial-initiation/content/abm-teams-content.component.ts b/apps/web/src/app/billing/trial-initiation/content/abm-teams-content.component.ts deleted file mode 100644 index 7765555f5cc..00000000000 --- a/apps/web/src/app/billing/trial-initiation/content/abm-teams-content.component.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Component } from "@angular/core"; - -@Component({ - selector: "app-abm-teams-content", - templateUrl: "abm-teams-content.component.html", -}) -export class AbmTeamsContentComponent {} diff --git a/apps/web/src/app/billing/trial-initiation/content/cnet-enterprise-content.component.html b/apps/web/src/app/billing/trial-initiation/content/cnet-enterprise-content.component.html deleted file mode 100644 index b5c16911ab0..00000000000 --- a/apps/web/src/app/billing/trial-initiation/content/cnet-enterprise-content.component.html +++ /dev/null @@ -1,17 +0,0 @@ -

Start Your Enterprise Free Trial Now

-
-

- Millions of individuals, teams, and organizations worldwide trust Bitwarden for secure password - storage and sharing. -

-
-
    -
  • Collaborate and share securely
  • -
  • Deploy and manage quickly and easily
  • -
  • Access anywhere on any device
  • -
  • Create your account to get started
  • -
-
- - -
diff --git a/apps/web/src/app/billing/trial-initiation/content/cnet-enterprise-content.component.ts b/apps/web/src/app/billing/trial-initiation/content/cnet-enterprise-content.component.ts deleted file mode 100644 index 4a6de8d3003..00000000000 --- a/apps/web/src/app/billing/trial-initiation/content/cnet-enterprise-content.component.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Component } from "@angular/core"; - -@Component({ - selector: "app-cnet-enterprise-content", - templateUrl: "cnet-enterprise-content.component.html", -}) -export class CnetEnterpriseContentComponent {} diff --git a/apps/web/src/app/billing/trial-initiation/content/cnet-individual-content.component.html b/apps/web/src/app/billing/trial-initiation/content/cnet-individual-content.component.html deleted file mode 100644 index 6e6f545c170..00000000000 --- a/apps/web/src/app/billing/trial-initiation/content/cnet-individual-content.component.html +++ /dev/null @@ -1,17 +0,0 @@ -

Start Your Premium Account Now

-
-

- Millions of individuals, teams, and organizations worldwide trust Bitwarden for secure password - storage and sharing. -

-
-
    -
  • Store logins, secure notes, and more
  • -
  • Secure your account with advanced two-step login
  • -
  • Access anywhere on any device
  • -
  • Create your account to get started
  • -
-
- - -
diff --git a/apps/web/src/app/billing/trial-initiation/content/cnet-individual-content.component.ts b/apps/web/src/app/billing/trial-initiation/content/cnet-individual-content.component.ts deleted file mode 100644 index 56d8b37af90..00000000000 --- a/apps/web/src/app/billing/trial-initiation/content/cnet-individual-content.component.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Component } from "@angular/core"; - -@Component({ - selector: "app-cnet-individual-content", - templateUrl: "cnet-individual-content.component.html", -}) -export class CnetIndividualContentComponent {} diff --git a/apps/web/src/app/billing/trial-initiation/content/cnet-teams-content.component.html b/apps/web/src/app/billing/trial-initiation/content/cnet-teams-content.component.html deleted file mode 100644 index c719c5ac7ce..00000000000 --- a/apps/web/src/app/billing/trial-initiation/content/cnet-teams-content.component.html +++ /dev/null @@ -1,17 +0,0 @@ -

Start Your Teams Free Trial Now

-
-

- Millions of individuals, teams, and organizations worldwide trust Bitwarden for secure password - storage and sharing. -

-
-
    -
  • Collaborate and share securely
  • -
  • Deploy and manage quickly and easily
  • -
  • Access anywhere on any device
  • -
  • Create your account to get started
  • -
-
- - -
diff --git a/apps/web/src/app/billing/trial-initiation/content/cnet-teams-content.component.ts b/apps/web/src/app/billing/trial-initiation/content/cnet-teams-content.component.ts deleted file mode 100644 index ff79a0d37cd..00000000000 --- a/apps/web/src/app/billing/trial-initiation/content/cnet-teams-content.component.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Component } from "@angular/core"; - -@Component({ - selector: "app-cnet-teams-content", - templateUrl: "cnet-teams-content.component.html", -}) -export class CnetTeamsContentComponent {} diff --git a/apps/web/src/app/billing/trial-initiation/content/default-content.component.html b/apps/web/src/app/billing/trial-initiation/content/default-content.component.html deleted file mode 100644 index e1839517ff6..00000000000 --- a/apps/web/src/app/billing/trial-initiation/content/default-content.component.html +++ /dev/null @@ -1,16 +0,0 @@ -

The Bitwarden Password Manager

-
-

- Trusted by millions of individuals, teams, and organizations worldwide for secure password - storage and sharing. -

-
-
    -
  • Store logins, secure notes, and more
  • -
  • Collaborate and share securely
  • -
  • Access anywhere on any device
  • -
  • Create your account to get started
  • -
-
- -
diff --git a/apps/web/src/app/billing/trial-initiation/content/default-content.component.ts b/apps/web/src/app/billing/trial-initiation/content/default-content.component.ts deleted file mode 100644 index 7ad40b089d1..00000000000 --- a/apps/web/src/app/billing/trial-initiation/content/default-content.component.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Component } from "@angular/core"; - -@Component({ - selector: "app-default-content", - templateUrl: "default-content.component.html", -}) -export class DefaultContentComponent {} diff --git a/apps/web/src/app/billing/trial-initiation/content/enterprise-content.component.html b/apps/web/src/app/billing/trial-initiation/content/enterprise-content.component.html deleted file mode 100644 index f57fb7a3510..00000000000 --- a/apps/web/src/app/billing/trial-initiation/content/enterprise-content.component.html +++ /dev/null @@ -1,44 +0,0 @@ -

Start your 7-day Enterprise free trial

-
-

- Bitwarden is the most trusted password manager designed for seamless administration and employee - usability. -

-
-
    -
  • - Instantly and securely share credentials with the groups and individuals who need them -
  • -
  • - Strengthen company-wide security through centralized administrative control and - policies -
  • -
  • - Streamline user onboarding and automate account provisioning with flexible SSO and SCIM - integrations -
  • -
  • - Migrate to Bitwarden in minutes with comprehensive import options -
  • -
  • - Give all Enterprise users the gift of 360º security with a free Families plan -
  • -
-
- -
diff --git a/apps/web/src/app/billing/trial-initiation/content/enterprise-content.component.ts b/apps/web/src/app/billing/trial-initiation/content/enterprise-content.component.ts deleted file mode 100644 index 847b3c3088a..00000000000 --- a/apps/web/src/app/billing/trial-initiation/content/enterprise-content.component.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Component } from "@angular/core"; - -@Component({ - selector: "app-enterprise-content", - templateUrl: "enterprise-content.component.html", -}) -export class EnterpriseContentComponent {} diff --git a/apps/web/src/app/billing/trial-initiation/content/enterprise1-content.component.html b/apps/web/src/app/billing/trial-initiation/content/enterprise1-content.component.html deleted file mode 100644 index f57fb7a3510..00000000000 --- a/apps/web/src/app/billing/trial-initiation/content/enterprise1-content.component.html +++ /dev/null @@ -1,44 +0,0 @@ -

Start your 7-day Enterprise free trial

-
-

- Bitwarden is the most trusted password manager designed for seamless administration and employee - usability. -

-
-
    -
  • - Instantly and securely share credentials with the groups and individuals who need them -
  • -
  • - Strengthen company-wide security through centralized administrative control and - policies -
  • -
  • - Streamline user onboarding and automate account provisioning with flexible SSO and SCIM - integrations -
  • -
  • - Migrate to Bitwarden in minutes with comprehensive import options -
  • -
  • - Give all Enterprise users the gift of 360º security with a free Families plan -
  • -
-
- -
diff --git a/apps/web/src/app/billing/trial-initiation/content/enterprise1-content.component.ts b/apps/web/src/app/billing/trial-initiation/content/enterprise1-content.component.ts deleted file mode 100644 index 7b1199eb421..00000000000 --- a/apps/web/src/app/billing/trial-initiation/content/enterprise1-content.component.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Component } from "@angular/core"; - -@Component({ - selector: "app-enterprise1-content", - templateUrl: "enterprise1-content.component.html", -}) -export class Enterprise1ContentComponent {} diff --git a/apps/web/src/app/billing/trial-initiation/content/enterprise2-content.component.html b/apps/web/src/app/billing/trial-initiation/content/enterprise2-content.component.html deleted file mode 100644 index f57fb7a3510..00000000000 --- a/apps/web/src/app/billing/trial-initiation/content/enterprise2-content.component.html +++ /dev/null @@ -1,44 +0,0 @@ -

Start your 7-day Enterprise free trial

-
-

- Bitwarden is the most trusted password manager designed for seamless administration and employee - usability. -

-
-
    -
  • - Instantly and securely share credentials with the groups and individuals who need them -
  • -
  • - Strengthen company-wide security through centralized administrative control and - policies -
  • -
  • - Streamline user onboarding and automate account provisioning with flexible SSO and SCIM - integrations -
  • -
  • - Migrate to Bitwarden in minutes with comprehensive import options -
  • -
  • - Give all Enterprise users the gift of 360º security with a free Families plan -
  • -
-
- -
diff --git a/apps/web/src/app/billing/trial-initiation/content/enterprise2-content.component.ts b/apps/web/src/app/billing/trial-initiation/content/enterprise2-content.component.ts deleted file mode 100644 index 08dec6190c7..00000000000 --- a/apps/web/src/app/billing/trial-initiation/content/enterprise2-content.component.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Component } from "@angular/core"; - -@Component({ - selector: "app-enterprise2-content", - templateUrl: "enterprise2-content.component.html", -}) -export class Enterprise2ContentComponent {} diff --git a/apps/web/src/app/billing/trial-initiation/content/logo-badges.component.html b/apps/web/src/app/billing/trial-initiation/content/logo-badges.component.html deleted file mode 100644 index d1b33eab3a4..00000000000 --- a/apps/web/src/app/billing/trial-initiation/content/logo-badges.component.html +++ /dev/null @@ -1,11 +0,0 @@ -
-
- - third party awards - -
-
diff --git a/apps/web/src/app/billing/trial-initiation/content/logo-badges.component.ts b/apps/web/src/app/billing/trial-initiation/content/logo-badges.component.ts deleted file mode 100644 index c23432b67cf..00000000000 --- a/apps/web/src/app/billing/trial-initiation/content/logo-badges.component.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Component } from "@angular/core"; - -@Component({ - selector: "app-logo-badges", - templateUrl: "logo-badges.component.html", -}) -export class LogoBadgesComponent {} diff --git a/apps/web/src/app/billing/trial-initiation/content/logo-cnet-5-stars.component.html b/apps/web/src/app/billing/trial-initiation/content/logo-cnet-5-stars.component.html deleted file mode 100644 index fb4537d2820..00000000000 --- a/apps/web/src/app/billing/trial-initiation/content/logo-cnet-5-stars.component.html +++ /dev/null @@ -1,23 +0,0 @@ -
-
- - - - - -
-
- “Bitwarden scores points for being fully open-source, secure and audited annually by third-party - cybersecurity firms, giving it a level of transparency that sets it apart from its peers.” -
-
- - CNET Logo - -

Best Password Manager in 2024

-
-
diff --git a/apps/web/src/app/billing/trial-initiation/content/logo-cnet-5-stars.component.ts b/apps/web/src/app/billing/trial-initiation/content/logo-cnet-5-stars.component.ts deleted file mode 100644 index af531829d50..00000000000 --- a/apps/web/src/app/billing/trial-initiation/content/logo-cnet-5-stars.component.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Component } from "@angular/core"; - -@Component({ - selector: "app-logo-cnet-5-stars", - templateUrl: "logo-cnet-5-stars.component.html", -}) -export class LogoCnet5StarsComponent {} diff --git a/apps/web/src/app/billing/trial-initiation/content/logo-cnet.component.html b/apps/web/src/app/billing/trial-initiation/content/logo-cnet.component.html deleted file mode 100644 index 4e04cec6da4..00000000000 --- a/apps/web/src/app/billing/trial-initiation/content/logo-cnet.component.html +++ /dev/null @@ -1,15 +0,0 @@ -
-
- - CNET Logo - -
-
- "No more excuses; start using Bitwarden today. The identity you save could be your own. The - money definitely will be." -
-
diff --git a/apps/web/src/app/billing/trial-initiation/content/logo-cnet.component.ts b/apps/web/src/app/billing/trial-initiation/content/logo-cnet.component.ts deleted file mode 100644 index 4f755f66a86..00000000000 --- a/apps/web/src/app/billing/trial-initiation/content/logo-cnet.component.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Component } from "@angular/core"; - -@Component({ - selector: "app-logo-cnet", - templateUrl: "logo-cnet.component.html", -}) -export class LogoCnetComponent {} diff --git a/apps/web/src/app/billing/trial-initiation/content/logo-company-testimonial.component.html b/apps/web/src/app/billing/trial-initiation/content/logo-company-testimonial.component.html deleted file mode 100644 index 0b81e0bd216..00000000000 --- a/apps/web/src/app/billing/trial-initiation/content/logo-company-testimonial.component.html +++ /dev/null @@ -1,28 +0,0 @@ -
-

- Recommended by industry experts -

-
-
- CNET Logo - WIRED Logo -
-
- New York Times Logo - PC Mag Logo -
-
-
- “Bitwarden is currently CNET's top pick for the best password manager, thanks in part to - its commitment to transparency and its unbeatable free tier.” -
-

Best Password Manager in 2024

-
diff --git a/apps/web/src/app/billing/trial-initiation/content/logo-company-testimonial.component.ts b/apps/web/src/app/billing/trial-initiation/content/logo-company-testimonial.component.ts deleted file mode 100644 index 9d9c4471820..00000000000 --- a/apps/web/src/app/billing/trial-initiation/content/logo-company-testimonial.component.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Component } from "@angular/core"; - -@Component({ - selector: "app-logo-company-testimonial", - templateUrl: "logo-company-testimonial.component.html", -}) -export class LogoCompanyTestimonialComponent {} diff --git a/apps/web/src/app/billing/trial-initiation/content/logo-forbes.component.html b/apps/web/src/app/billing/trial-initiation/content/logo-forbes.component.html deleted file mode 100644 index 34426168324..00000000000 --- a/apps/web/src/app/billing/trial-initiation/content/logo-forbes.component.html +++ /dev/null @@ -1,15 +0,0 @@ -
-
- - Forbes Logo - -
-
- “Bitwarden boasts the backing of some of the world's best security experts and an attractive, - easy-to-use interface” -
-
diff --git a/apps/web/src/app/billing/trial-initiation/content/logo-forbes.component.ts b/apps/web/src/app/billing/trial-initiation/content/logo-forbes.component.ts deleted file mode 100644 index 818721fd1e9..00000000000 --- a/apps/web/src/app/billing/trial-initiation/content/logo-forbes.component.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Component } from "@angular/core"; - -@Component({ - selector: "app-logo-forbes", - templateUrl: "logo-forbes.component.html", -}) -export class LogoForbesComponent {} diff --git a/apps/web/src/app/billing/trial-initiation/content/logo-us-news.component.html b/apps/web/src/app/billing/trial-initiation/content/logo-us-news.component.html deleted file mode 100644 index bd44b56f090..00000000000 --- a/apps/web/src/app/billing/trial-initiation/content/logo-us-news.component.html +++ /dev/null @@ -1,5 +0,0 @@ -US News 360 Reviews Best Password Manager diff --git a/apps/web/src/app/billing/trial-initiation/content/logo-us-news.component.ts b/apps/web/src/app/billing/trial-initiation/content/logo-us-news.component.ts deleted file mode 100644 index fb0b1e0c71b..00000000000 --- a/apps/web/src/app/billing/trial-initiation/content/logo-us-news.component.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Component } from "@angular/core"; - -@Component({ - selector: "app-logo-us-news", - templateUrl: "logo-us-news.component.html", -}) -export class LogoUSNewsComponent {} diff --git a/apps/web/src/app/billing/trial-initiation/content/review-blurb.component.html b/apps/web/src/app/billing/trial-initiation/content/review-blurb.component.html deleted file mode 100644 index cd719a35af8..00000000000 --- a/apps/web/src/app/billing/trial-initiation/content/review-blurb.component.html +++ /dev/null @@ -1,13 +0,0 @@ -
-

- {{ header }} -

-
- "{{ quote }}" -
-
- -

{{ source }}

-
-
-
diff --git a/apps/web/src/app/billing/trial-initiation/content/review-blurb.component.ts b/apps/web/src/app/billing/trial-initiation/content/review-blurb.component.ts deleted file mode 100644 index 6419ddf1e45..00000000000 --- a/apps/web/src/app/billing/trial-initiation/content/review-blurb.component.ts +++ /dev/null @@ -1,13 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { Component, Input } from "@angular/core"; - -@Component({ - selector: "app-review-blurb", - templateUrl: "review-blurb.component.html", -}) -export class ReviewBlurbComponent { - @Input() header: string; - @Input() quote: string; - @Input() source: string; -} diff --git a/apps/web/src/app/billing/trial-initiation/content/review-logo.component.html b/apps/web/src/app/billing/trial-initiation/content/review-logo.component.html deleted file mode 100644 index 77f592f1c45..00000000000 --- a/apps/web/src/app/billing/trial-initiation/content/review-logo.component.html +++ /dev/null @@ -1,18 +0,0 @@ -
- -
-
- - - - -
-
- -
- -
-
- 4.7 -
-
diff --git a/apps/web/src/app/billing/trial-initiation/content/review-logo.component.ts b/apps/web/src/app/billing/trial-initiation/content/review-logo.component.ts deleted file mode 100644 index 9b104ac0bc3..00000000000 --- a/apps/web/src/app/billing/trial-initiation/content/review-logo.component.ts +++ /dev/null @@ -1,13 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { Component, Input } from "@angular/core"; - -@Component({ - selector: "review-logo", - templateUrl: "review-logo.component.html", -}) -export class ReviewLogoComponent { - @Input() logoClass: string; - @Input() logoSrc: string; - @Input() logoAlt: string; -} diff --git a/apps/web/src/app/billing/trial-initiation/content/secrets-manager-content.component.html b/apps/web/src/app/billing/trial-initiation/content/secrets-manager-content.component.html deleted file mode 100644 index 569ff91f625..00000000000 --- a/apps/web/src/app/billing/trial-initiation/content/secrets-manager-content.component.html +++ /dev/null @@ -1,30 +0,0 @@ -

{{ header }}

-
-

- {{ headline }} -

-
-
    -
  • - {{ primaryPoint }} -
  • -
-
-
-
-

{{ calloutHeadline }}

-
    -
  • - {{ callout }} -
  • -
-
-
-
-
- -
diff --git a/apps/web/src/app/billing/trial-initiation/content/secrets-manager-content.component.ts b/apps/web/src/app/billing/trial-initiation/content/secrets-manager-content.component.ts deleted file mode 100644 index 955c18fddf2..00000000000 --- a/apps/web/src/app/billing/trial-initiation/content/secrets-manager-content.component.ts +++ /dev/null @@ -1,80 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { Component, OnDestroy, OnInit } from "@angular/core"; -import { ActivatedRoute } from "@angular/router"; -import { Subject, takeUntil } from "rxjs"; - -@Component({ - selector: "app-secrets-manager-content", - templateUrl: "secrets-manager-content.component.html", -}) -export class SecretsManagerContentComponent implements OnInit, OnDestroy { - header: string; - headline = - "A simpler, faster way to secure and automate secrets across code and infrastructure deployments"; - primaryPoints: string[]; - calloutHeadline: string; - callouts: string[]; - - private paidPrimaryPoints = [ - "Unlimited secrets, users, and projects", - "Simple and transparent pricing", - "Zero-knowledge, end-to-end encryption", - ]; - - private paidCalloutHeadline = "Limited time offer"; - - private paidCallouts = [ - "Sign up today and receive a complimentary 12-month subscription to Bitwarden Password Manager", - "Experience complete security across your organization", - "Secure all your sensitive credentials, from user applications to machine secrets", - ]; - - private freePrimaryPoints = [ - "Unlimited secrets", - "Simple and transparent pricing", - "Zero-knowledge, end-to-end encryption", - ]; - - private freeCalloutHeadline = "Go beyond developer security!"; - - private freeCallouts = [ - "Your Bitwarden account will also grant complimentary access to Bitwarden Password Manager", - "Extend end-to-end encryption to your personal passwords, addresses, credit cards and notes", - ]; - - private destroy$ = new Subject(); - - constructor(private activatedRoute: ActivatedRoute) {} - - ngOnDestroy() { - this.destroy$.next(); - this.destroy$.complete(); - } - - ngOnInit(): void { - this.activatedRoute.queryParams.pipe(takeUntil(this.destroy$)).subscribe((queryParameters) => { - switch (queryParameters.org) { - case "enterprise": - this.header = "Secrets Manager for Enterprise"; - this.primaryPoints = this.paidPrimaryPoints; - this.calloutHeadline = this.paidCalloutHeadline; - this.callouts = this.paidCallouts; - break; - case "free": - this.header = "Bitwarden Secrets Manager"; - this.primaryPoints = this.freePrimaryPoints; - this.calloutHeadline = this.freeCalloutHeadline; - this.callouts = this.freeCallouts; - break; - case "teams": - case "teamsStarter": - this.header = "Secrets Manager for Teams"; - this.primaryPoints = this.paidPrimaryPoints; - this.calloutHeadline = this.paidCalloutHeadline; - this.callouts = this.paidCallouts; - break; - } - }); - } -} diff --git a/apps/web/src/app/billing/trial-initiation/content/teams-content.component.html b/apps/web/src/app/billing/trial-initiation/content/teams-content.component.html deleted file mode 100644 index 46e1fae80df..00000000000 --- a/apps/web/src/app/billing/trial-initiation/content/teams-content.component.html +++ /dev/null @@ -1,17 +0,0 @@ -

The Bitwarden Password Manager

-
-

- Trusted by millions of individuals, teams, and organizations worldwide for secure password - storage and sharing. -

-
-
    -
  • Store logins, secure notes, and more
  • -
  • Collaborate and share securely
  • -
  • Access anywhere on any device
  • -
  • Create your account to get started
  • -
-
- - -
diff --git a/apps/web/src/app/billing/trial-initiation/content/teams-content.component.ts b/apps/web/src/app/billing/trial-initiation/content/teams-content.component.ts deleted file mode 100644 index 5c97695deff..00000000000 --- a/apps/web/src/app/billing/trial-initiation/content/teams-content.component.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Component } from "@angular/core"; - -@Component({ - selector: "app-teams-content", - templateUrl: "teams-content.component.html", -}) -export class TeamsContentComponent {} diff --git a/apps/web/src/app/billing/trial-initiation/content/teams1-content.component.html b/apps/web/src/app/billing/trial-initiation/content/teams1-content.component.html deleted file mode 100644 index f51c370bebd..00000000000 --- a/apps/web/src/app/billing/trial-initiation/content/teams1-content.component.html +++ /dev/null @@ -1,35 +0,0 @@ -

Start your 7-day free trial for Teams

-
-

- Strengthen business security with an easy-to-use password manager your team will love. -

-
-
    -
  • - Instantly and securely share credentials with the groups and individuals who need them -
  • -
  • - Migrate to Bitwarden in minutes with comprehensive import options -
  • -
  • - Save time and increase productivity with autofill and instant device syncing -
  • -
  • - Enhance security practices across your team with easy user management -
  • -
-
- -
diff --git a/apps/web/src/app/billing/trial-initiation/content/teams1-content.component.ts b/apps/web/src/app/billing/trial-initiation/content/teams1-content.component.ts deleted file mode 100644 index 055ec7fda10..00000000000 --- a/apps/web/src/app/billing/trial-initiation/content/teams1-content.component.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Component } from "@angular/core"; - -@Component({ - selector: "app-teams1-content", - templateUrl: "teams1-content.component.html", -}) -export class Teams1ContentComponent {} diff --git a/apps/web/src/app/billing/trial-initiation/content/teams2-content.component.html b/apps/web/src/app/billing/trial-initiation/content/teams2-content.component.html deleted file mode 100644 index f51c370bebd..00000000000 --- a/apps/web/src/app/billing/trial-initiation/content/teams2-content.component.html +++ /dev/null @@ -1,35 +0,0 @@ -

Start your 7-day free trial for Teams

-
-

- Strengthen business security with an easy-to-use password manager your team will love. -

-
-
    -
  • - Instantly and securely share credentials with the groups and individuals who need them -
  • -
  • - Migrate to Bitwarden in minutes with comprehensive import options -
  • -
  • - Save time and increase productivity with autofill and instant device syncing -
  • -
  • - Enhance security practices across your team with easy user management -
  • -
-
- -
diff --git a/apps/web/src/app/billing/trial-initiation/content/teams2-content.component.ts b/apps/web/src/app/billing/trial-initiation/content/teams2-content.component.ts deleted file mode 100644 index 394ba90b491..00000000000 --- a/apps/web/src/app/billing/trial-initiation/content/teams2-content.component.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Component } from "@angular/core"; - -@Component({ - selector: "app-teams2-content", - templateUrl: "teams2-content.component.html", -}) -export class Teams2ContentComponent {} diff --git a/apps/web/src/app/billing/trial-initiation/content/teams3-content.component.html b/apps/web/src/app/billing/trial-initiation/content/teams3-content.component.html deleted file mode 100644 index c6f1ae697ae..00000000000 --- a/apps/web/src/app/billing/trial-initiation/content/teams3-content.component.html +++ /dev/null @@ -1,26 +0,0 @@ -

Begin Teams Starter Free Trial Now

-
-

- Millions of individuals, teams, and organizations worldwide trust Bitwarden for secure password - storage and sharing. -

-
-
    -
  • - Powerful security for up to 10 users -
    - Have more than 10 users? - Start a Teams trial -
    -
  • -
  • Collaborate and share securely
  • -
  • Deploy and manage quickly and easily
  • -
  • Access anywhere on any device
  • -
  • Create your account to get started
  • -
-
- - -
diff --git a/apps/web/src/app/billing/trial-initiation/content/teams3-content.component.ts b/apps/web/src/app/billing/trial-initiation/content/teams3-content.component.ts deleted file mode 100644 index df91268ab26..00000000000 --- a/apps/web/src/app/billing/trial-initiation/content/teams3-content.component.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Component } from "@angular/core"; - -@Component({ - selector: "app-teams3-content", - templateUrl: "teams3-content.component.html", -}) -export class Teams3ContentComponent {} diff --git a/apps/web/src/app/billing/trial-initiation/secrets-manager/secrets-manager-trial-free-stepper.component.html b/apps/web/src/app/billing/trial-initiation/secrets-manager/secrets-manager-trial-free-stepper.component.html deleted file mode 100644 index dddac598a46..00000000000 --- a/apps/web/src/app/billing/trial-initiation/secrets-manager/secrets-manager-trial-free-stepper.component.html +++ /dev/null @@ -1,45 +0,0 @@ - - - - - - -
-

{{ "smFreeTrialThankYou" | i18n }}

-
    -
  • -

    - {{ "smFreeTrialConfirmationEmail" | i18n }} - {{ formGroup.get("email").value }}. -

    -
  • -
-
-
- - -
-
-
diff --git a/apps/web/src/app/billing/trial-initiation/secrets-manager/secrets-manager-trial-free-stepper.component.ts b/apps/web/src/app/billing/trial-initiation/secrets-manager/secrets-manager-trial-free-stepper.component.ts deleted file mode 100644 index f7c5a9b2b98..00000000000 --- a/apps/web/src/app/billing/trial-initiation/secrets-manager/secrets-manager-trial-free-stepper.component.ts +++ /dev/null @@ -1,90 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { Component, OnInit, ViewChild } from "@angular/core"; -import { UntypedFormBuilder, Validators } from "@angular/forms"; -import { Router } from "@angular/router"; - -import { OrganizationBillingServiceAbstraction as OrganizationBillingService } from "@bitwarden/common/billing/abstractions/organization-billing.service"; -import { PlanType } from "@bitwarden/common/billing/enums"; -import { ReferenceEventRequest } from "@bitwarden/common/models/request/reference-event.request"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; - -import { VerticalStepperComponent } from "../../trial-initiation/vertical-stepper/vertical-stepper.component"; - -@Component({ - selector: "app-secrets-manager-trial-free-stepper", - templateUrl: "secrets-manager-trial-free-stepper.component.html", -}) -export class SecretsManagerTrialFreeStepperComponent implements OnInit { - @ViewChild("stepper", { static: false }) verticalStepper: VerticalStepperComponent; - - formGroup = this.formBuilder.group({ - name: [ - "", - { - validators: [Validators.required, Validators.maxLength(50)], - updateOn: "change", - }, - ], - email: [ - "", - { - validators: [Validators.email], - }, - ], - }); - - subLabels = { - createAccount: - "Before creating your free organization, you first need to log in or create a personal account.", - organizationInfo: "Enter your organization information", - }; - - organizationId: string; - - referenceEventRequest: ReferenceEventRequest; - - constructor( - protected formBuilder: UntypedFormBuilder, - protected i18nService: I18nService, - protected organizationBillingService: OrganizationBillingService, - protected router: Router, - ) {} - - ngOnInit(): void { - this.referenceEventRequest = new ReferenceEventRequest(); - this.referenceEventRequest.initiationPath = "Secrets Manager trial from marketing website"; - } - - accountCreated(email: string): void { - this.formGroup.get("email")?.setValue(email); - this.subLabels.createAccount = email; - this.verticalStepper.next(); - } - - async createOrganization(): Promise { - const response = await this.organizationBillingService.startFree({ - organization: { - name: this.formGroup.get("name").value, - billingEmail: this.formGroup.get("email").value, - }, - plan: { - type: PlanType.Free, - subscribeToSecretsManager: true, - isFromSecretsManagerTrial: true, - }, - }); - - this.organizationId = response.id; - this.subLabels.organizationInfo = response.name; - this.verticalStepper.next(); - } - - async navigateToMembers(): Promise { - await this.router.navigate(["organizations", this.organizationId, "members"]); - } - - async navigateToSecretsManager(): Promise { - await this.router.navigate(["sm", this.organizationId]); - } -} diff --git a/apps/web/src/app/billing/trial-initiation/secrets-manager/secrets-manager-trial-paid-stepper.component.html b/apps/web/src/app/billing/trial-initiation/secrets-manager/secrets-manager-trial-paid-stepper.component.html deleted file mode 100644 index 99e2706d713..00000000000 --- a/apps/web/src/app/billing/trial-initiation/secrets-manager/secrets-manager-trial-paid-stepper.component.html +++ /dev/null @@ -1,67 +0,0 @@ - - - - - - - - - - - -
- - -
-
-
diff --git a/apps/web/src/app/billing/trial-initiation/secrets-manager/secrets-manager-trial-paid-stepper.component.ts b/apps/web/src/app/billing/trial-initiation/secrets-manager/secrets-manager-trial-paid-stepper.component.ts deleted file mode 100644 index 650c1d8e69e..00000000000 --- a/apps/web/src/app/billing/trial-initiation/secrets-manager/secrets-manager-trial-paid-stepper.component.ts +++ /dev/null @@ -1,144 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { Component, Input, OnInit, ViewChild } from "@angular/core"; -import { UntypedFormBuilder } from "@angular/forms"; -import { ActivatedRoute, Router } from "@angular/router"; -import { Subject, takeUntil } from "rxjs"; - -import { OrganizationBillingServiceAbstraction as OrganizationBillingService } from "@bitwarden/common/billing/abstractions/organization-billing.service"; -import { PlanType, ProductTierType } from "@bitwarden/common/billing/enums"; -import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; -import { ReferenceEventRequest } from "@bitwarden/common/models/request/reference-event.request"; -import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; - -import { - OrganizationCreatedEvent, - SubscriptionProduct, - TrialOrganizationType, -} from "../../../billing/accounts/trial-initiation/trial-billing-step.component"; -import { VerticalStepperComponent } from "../../trial-initiation/vertical-stepper/vertical-stepper.component"; -import { SecretsManagerTrialFreeStepperComponent } from "../secrets-manager/secrets-manager-trial-free-stepper.component"; - -export enum ValidOrgParams { - families = "families", - enterprise = "enterprise", - teams = "teams", - teamsStarter = "teamsStarter", - individual = "individual", - premium = "premium", - free = "free", -} - -const trialFlowOrgs = [ - ValidOrgParams.teams, - ValidOrgParams.teamsStarter, - ValidOrgParams.enterprise, - ValidOrgParams.families, -]; - -@Component({ - selector: "app-secrets-manager-trial-paid-stepper", - templateUrl: "secrets-manager-trial-paid-stepper.component.html", -}) -export class SecretsManagerTrialPaidStepperComponent - extends SecretsManagerTrialFreeStepperComponent - implements OnInit -{ - @ViewChild("stepper", { static: false }) verticalStepper: VerticalStepperComponent; - @Input() organizationTypeQueryParameter: string; - - plan: PlanType; - createOrganizationLoading = false; - billingSubLabel = this.i18nService.t("billingTrialSubLabel"); - organizationId: string; - - private destroy$ = new Subject(); - protected enableTrialPayment$ = this.configService.getFeatureFlag$( - FeatureFlag.TrialPaymentOptional, - ); - - constructor( - private route: ActivatedRoute, - private configService: ConfigService, - protected formBuilder: UntypedFormBuilder, - protected i18nService: I18nService, - protected organizationBillingService: OrganizationBillingService, - protected router: Router, - ) { - super(formBuilder, i18nService, organizationBillingService, router); - } - - async ngOnInit(): Promise { - this.referenceEventRequest = new ReferenceEventRequest(); - this.referenceEventRequest.initiationPath = "Secrets Manager trial from marketing website"; - - this.route.queryParams.pipe(takeUntil(this.destroy$)).subscribe((qParams) => { - if (trialFlowOrgs.includes(qParams.org)) { - if (qParams.org === ValidOrgParams.teamsStarter) { - this.plan = PlanType.TeamsStarter; - } else if (qParams.org === ValidOrgParams.teams) { - this.plan = PlanType.TeamsAnnually; - } else if (qParams.org === ValidOrgParams.enterprise) { - this.plan = PlanType.EnterpriseAnnually; - } - } - }); - } - - organizationCreated(event: OrganizationCreatedEvent) { - this.organizationId = event.organizationId; - this.billingSubLabel = event.planDescription; - this.verticalStepper.next(); - } - - steppedBack() { - this.verticalStepper.previous(); - } - - async createOrganizationOnTrial(): Promise { - this.createOrganizationLoading = true; - const response = await this.organizationBillingService.purchaseSubscriptionNoPaymentMethod({ - organization: { - name: this.formGroup.get("name").value, - billingEmail: this.formGroup.get("email").value, - initiationPath: "Secrets Manager trial from marketing website", - }, - plan: { - type: this.plan, - subscribeToSecretsManager: true, - isFromSecretsManagerTrial: true, - passwordManagerSeats: 1, - secretsManagerSeats: 1, - }, - }); - - this.organizationId = response?.id; - this.subLabels.organizationInfo = response?.name; - this.createOrganizationLoading = false; - this.verticalStepper.next(); - } - - get createAccountLabel() { - const organizationType = - this.productType === ProductTierType.TeamsStarter - ? "Teams Starter" - : ProductTierType[this.productType]; - return `Before creating your ${organizationType} organization, you first need to log in or create a personal account.`; - } - - get productType(): TrialOrganizationType { - switch (this.organizationTypeQueryParameter) { - case "enterprise": - return ProductTierType.Enterprise; - case "families": - return ProductTierType.Families; - case "teams": - return ProductTierType.Teams; - case "teamsStarter": - return ProductTierType.TeamsStarter; - } - } - - protected readonly SubscriptionProduct = SubscriptionProduct; -} diff --git a/apps/web/src/app/billing/trial-initiation/secrets-manager/secrets-manager-trial.component.html b/apps/web/src/app/billing/trial-initiation/secrets-manager/secrets-manager-trial.component.html deleted file mode 100644 index 88251136dbe..00000000000 --- a/apps/web/src/app/billing/trial-initiation/secrets-manager/secrets-manager-trial.component.html +++ /dev/null @@ -1,44 +0,0 @@ - - -
-
-
- Bitwarden -
- -
-
-
-
-
-
-

- {{ - "startYour7DayFreeTrialOfBitwardenSecretsManagerFor" - | i18n: organizationTypeQueryParameter - }} -

- -
- - -
-
-
-
-
diff --git a/apps/web/src/app/billing/trial-initiation/secrets-manager/secrets-manager-trial.component.ts b/apps/web/src/app/billing/trial-initiation/secrets-manager/secrets-manager-trial.component.ts deleted file mode 100644 index 678514532ca..00000000000 --- a/apps/web/src/app/billing/trial-initiation/secrets-manager/secrets-manager-trial.component.ts +++ /dev/null @@ -1,32 +0,0 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore -import { Component, OnDestroy, OnInit } from "@angular/core"; -import { ActivatedRoute } from "@angular/router"; -import { Subject, takeUntil } from "rxjs"; - -@Component({ - selector: "app-secrets-manager-trial", - templateUrl: "secrets-manager-trial.component.html", -}) -export class SecretsManagerTrialComponent implements OnInit, OnDestroy { - organizationTypeQueryParameter: string; - - private destroy$ = new Subject(); - - constructor(private route: ActivatedRoute) {} - - ngOnInit(): void { - this.route.queryParams.pipe(takeUntil(this.destroy$)).subscribe((queryParameters) => { - this.organizationTypeQueryParameter = queryParameters.org; - }); - } - - ngOnDestroy(): void { - this.destroy$.next(); - this.destroy$.complete(); - } - - get freeOrganization() { - return this.organizationTypeQueryParameter === "free"; - } -} diff --git a/apps/web/src/app/billing/trial-initiation/trial-initiation.module.ts b/apps/web/src/app/billing/trial-initiation/trial-initiation.module.ts index 3e6bfdc4e6c..06e1cce7f23 100644 --- a/apps/web/src/app/billing/trial-initiation/trial-initiation.module.ts +++ b/apps/web/src/app/billing/trial-initiation/trial-initiation.module.ts @@ -7,36 +7,10 @@ import { FormFieldModule } from "@bitwarden/components"; import { OrganizationCreateModule } from "../../admin-console/organizations/create/organization-create.module"; import { TrialBillingStepComponent } from "../../billing/accounts/trial-initiation/trial-billing-step.component"; -import { SecretsManagerTrialFreeStepperComponent } from "../../billing/trial-initiation/secrets-manager/secrets-manager-trial-free-stepper.component"; -import { SecretsManagerTrialPaidStepperComponent } from "../../billing/trial-initiation/secrets-manager/secrets-manager-trial-paid-stepper.component"; -import { SecretsManagerTrialComponent } from "../../billing/trial-initiation/secrets-manager/secrets-manager-trial.component"; -import { EnvironmentSelectorModule } from "../../components/environment-selector/environment-selector.module"; import { SharedModule } from "../../shared"; import { CompleteTrialInitiationComponent } from "./complete-trial-initiation/complete-trial-initiation.component"; import { ConfirmationDetailsComponent } from "./confirmation-details.component"; -import { AbmEnterpriseContentComponent } from "./content/abm-enterprise-content.component"; -import { AbmTeamsContentComponent } from "./content/abm-teams-content.component"; -import { CnetEnterpriseContentComponent } from "./content/cnet-enterprise-content.component"; -import { CnetIndividualContentComponent } from "./content/cnet-individual-content.component"; -import { CnetTeamsContentComponent } from "./content/cnet-teams-content.component"; -import { DefaultContentComponent } from "./content/default-content.component"; -import { EnterpriseContentComponent } from "./content/enterprise-content.component"; -import { Enterprise1ContentComponent } from "./content/enterprise1-content.component"; -import { Enterprise2ContentComponent } from "./content/enterprise2-content.component"; -import { LogoBadgesComponent } from "./content/logo-badges.component"; -import { LogoCnet5StarsComponent } from "./content/logo-cnet-5-stars.component"; -import { LogoCnetComponent } from "./content/logo-cnet.component"; -import { LogoCompanyTestimonialComponent } from "./content/logo-company-testimonial.component"; -import { LogoForbesComponent } from "./content/logo-forbes.component"; -import { LogoUSNewsComponent } from "./content/logo-us-news.component"; -import { ReviewBlurbComponent } from "./content/review-blurb.component"; -import { ReviewLogoComponent } from "./content/review-logo.component"; -import { SecretsManagerContentComponent } from "./content/secrets-manager-content.component"; -import { TeamsContentComponent } from "./content/teams-content.component"; -import { Teams1ContentComponent } from "./content/teams1-content.component"; -import { Teams2ContentComponent } from "./content/teams2-content.component"; -import { Teams3ContentComponent } from "./content/teams3-content.component"; import { VerticalStepperModule } from "./vertical-stepper/vertical-stepper.module"; @NgModule({ @@ -46,41 +20,10 @@ import { VerticalStepperModule } from "./vertical-stepper/vertical-stepper.modul VerticalStepperModule, FormFieldModule, OrganizationCreateModule, - EnvironmentSelectorModule, TrialBillingStepComponent, InputPasswordComponent, ], - declarations: [ - CompleteTrialInitiationComponent, - EnterpriseContentComponent, - TeamsContentComponent, - ConfirmationDetailsComponent, - DefaultContentComponent, - EnterpriseContentComponent, - Enterprise1ContentComponent, - Enterprise2ContentComponent, - TeamsContentComponent, - Teams1ContentComponent, - Teams2ContentComponent, - Teams3ContentComponent, - CnetEnterpriseContentComponent, - CnetIndividualContentComponent, - CnetTeamsContentComponent, - AbmEnterpriseContentComponent, - AbmTeamsContentComponent, - LogoBadgesComponent, - LogoCnet5StarsComponent, - LogoCompanyTestimonialComponent, - LogoCnetComponent, - LogoForbesComponent, - LogoUSNewsComponent, - ReviewLogoComponent, - SecretsManagerContentComponent, - ReviewBlurbComponent, - SecretsManagerTrialComponent, - SecretsManagerTrialFreeStepperComponent, - SecretsManagerTrialPaidStepperComponent, - ], + declarations: [CompleteTrialInitiationComponent, ConfirmationDetailsComponent], exports: [CompleteTrialInitiationComponent], providers: [TitleCasePipe], }) From ef80c2370700c79a095d562bbc4cb1ab03d49a8f Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Wed, 23 Apr 2025 18:45:29 +0200 Subject: [PATCH 20/22] Fix type 0 not being blocked on key wrapping (#14388) * Fix type 0 not being blocked on key wrapping * Move block type0 below key null check --- .../encrypt.service.implementation.ts | 6 +++ .../crypto/services/encrypt.service.spec.ts | 39 +++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/libs/common/src/key-management/crypto/services/encrypt.service.implementation.ts b/libs/common/src/key-management/crypto/services/encrypt.service.implementation.ts index f4318840515..fceef34421c 100644 --- a/libs/common/src/key-management/crypto/services/encrypt.service.implementation.ts +++ b/libs/common/src/key-management/crypto/services/encrypt.service.implementation.ts @@ -116,6 +116,12 @@ export class EncryptServiceImplementation implements EncryptService { throw new Error("No encryption key provided."); } + if (this.blockType0) { + if (key.inner().type === EncryptionType.AesCbc256_B64 || key.key.byteLength < 64) { + throw new Error("Type 0 encryption is not supported."); + } + } + if (plainValue == null) { return Promise.resolve(null); } diff --git a/libs/common/src/key-management/crypto/services/encrypt.service.spec.ts b/libs/common/src/key-management/crypto/services/encrypt.service.spec.ts index 6b2851ad116..bc945a5eff7 100644 --- a/libs/common/src/key-management/crypto/services/encrypt.service.spec.ts +++ b/libs/common/src/key-management/crypto/services/encrypt.service.spec.ts @@ -55,6 +55,19 @@ describe("EncryptService", () => { "No wrappingKey provided for wrapping.", ); }); + it("fails if type 0 key is provided with flag turned on", async () => { + (encryptService as any).blockType0 = true; + const mock32Key = mock(); + mock32Key.key = makeStaticByteArray(32); + mock32Key.inner.mockReturnValue({ + type: 0, + encryptionKey: mock32Key.key, + }); + + await expect(encryptService.wrapSymmetricKey(mock32Key, mock32Key)).rejects.toThrow( + "Type 0 encryption is not supported.", + ); + }); }); describe("wrapDecapsulationKey", () => { @@ -83,6 +96,19 @@ describe("EncryptService", () => { "No wrappingKey provided for wrapping.", ); }); + it("throws if type 0 key is provided with flag turned on", async () => { + (encryptService as any).blockType0 = true; + const mock32Key = mock(); + mock32Key.key = makeStaticByteArray(32); + mock32Key.inner.mockReturnValue({ + type: 0, + encryptionKey: mock32Key.key, + }); + + await expect( + encryptService.wrapDecapsulationKey(new Uint8Array(200), mock32Key), + ).rejects.toThrow("Type 0 encryption is not supported."); + }); }); describe("wrapEncapsulationKey", () => { @@ -111,6 +137,19 @@ describe("EncryptService", () => { "No wrappingKey provided for wrapping.", ); }); + it("throws if type 0 key is provided with flag turned on", async () => { + (encryptService as any).blockType0 = true; + const mock32Key = mock(); + mock32Key.key = makeStaticByteArray(32); + mock32Key.inner.mockReturnValue({ + type: 0, + encryptionKey: mock32Key.key, + }); + + await expect( + encryptService.wrapEncapsulationKey(new Uint8Array(200), mock32Key), + ).rejects.toThrow("Type 0 encryption is not supported."); + }); }); describe("onServerConfigChange", () => { From b589951c907eb23cc0e2c0711979e3619e1d1e59 Mon Sep 17 00:00:00 2001 From: Jordan Aasen <166539328+jaasen-livefront@users.noreply.github.com> Date: Wed, 23 Apr 2025 11:13:44 -0700 Subject: [PATCH 21/22] [PM-18520] - Update desktop cipher forms to use the same UI as web app and extension - (#13992) * WIP - cipher form refactor * cipher clone * cipher clone * finalize item view and form changes * fix tests * hide changes behind feature flag * set flag to false * create vault items v2. add button selector * revert change to flag and vault items * add attachments * revert change to tsconfig * move module * fix modules * cleanup * fix import * fix import * fix import * remove showForm * update feature flag * wip - cleanup * fix up services * cleanup * fix type errors * fix lint errors * add dialog component * revert changes to menu * revert changes to menu * fix vault-items-v2 * set feature flag to FALSE * add missing i18n keys. fix collection state * remove generator. update modules. bug fix * fix restricted imports * mark method as deprecated. add uri arg back * fix shared.module * fix shared.module * fix shared.module * add uri * check and prompt for premium when opening attachments dialog * move VaultItemDialogResult back * fix import in spec file * update copy functions * fix MP reprompt issue --- .../browser-view-password-history.service.ts | 2 - apps/desktop/src/app/app-routing.module.ts | 19 +- apps/desktop/src/app/app.module.ts | 8 +- apps/desktop/src/locales/en/messages.json | 142 ++++ apps/desktop/src/scss/base.scss | 5 + apps/desktop/src/scss/vault.scss | 4 + .../desktop-cipher-form-generator.service.ts | 32 + ...top-premium-upgrade-prompt.service.spec.ts | 30 + .../desktop-premium-upgrade-prompt.service.ts | 15 + ...credential-generator-dialog.component.html | 2 +- .../credential-generator-dialog.component.ts | 37 +- .../app/vault/item-footer.component.html | 64 ++ .../vault/app/vault/item-footer.component.ts | 159 ++++ .../app/vault/vault-items-v2.component.html | 92 ++ .../app/vault/vault-items-v2.component.ts | 42 + .../vault/app/vault/vault-v2.component.html | 80 ++ .../src/vault/app/vault/vault-v2.component.ts | 785 ++++++++++++++++++ .../collections/vault.component.ts | 6 +- .../view/emergency-view-dialog.component.ts | 5 +- .../vault-item-dialog.component.ts | 14 +- .../individual-vault/add-edit-v2.component.ts | 3 +- .../vault/individual-vault/vault.component.ts | 8 +- .../vault/individual-vault/view.component.ts | 8 +- .../view-password-history.service.spec.ts | 15 +- .../services/view-password-history.service.ts | 7 +- .../vault/components/vault-items.component.ts | 8 +- libs/common/src/enums/feature-flag.enum.ts | 2 + .../components/identity/identity.component.ts | 32 +- .../attachments-v2.component.html | 0 .../attachments-v2.component.spec.ts | 0 .../attachments}/attachments-v2.component.ts | 14 +- .../src/cipher-view/cipher-view.component.ts | 3 +- libs/vault/src/cipher-view/index.ts | 1 + .../password-history.component.html | 0 .../password-history.component.ts | 13 +- libs/vault/src/index.ts | 1 + 36 files changed, 1569 insertions(+), 89 deletions(-) create mode 100644 apps/desktop/src/services/desktop-cipher-form-generator.service.ts create mode 100644 apps/desktop/src/services/desktop-premium-upgrade-prompt.service.spec.ts create mode 100644 apps/desktop/src/services/desktop-premium-upgrade-prompt.service.ts create mode 100644 apps/desktop/src/vault/app/vault/item-footer.component.html create mode 100644 apps/desktop/src/vault/app/vault/item-footer.component.ts create mode 100644 apps/desktop/src/vault/app/vault/vault-items-v2.component.html create mode 100644 apps/desktop/src/vault/app/vault/vault-items-v2.component.ts create mode 100644 apps/desktop/src/vault/app/vault/vault-v2.component.html create mode 100644 apps/desktop/src/vault/app/vault/vault-v2.component.ts rename apps/web/src/app/vault/services/web-view-password-history.service.spec.ts => libs/angular/src/services/view-password-history.service.spec.ts (69%) rename apps/web/src/app/vault/services/web-view-password-history.service.ts => libs/angular/src/services/view-password-history.service.ts (78%) rename {apps/web/src/app/vault/individual-vault => libs/vault/src/cipher-view/attachments}/attachments-v2.component.html (100%) rename {apps/web/src/app/vault/individual-vault => libs/vault/src/cipher-view/attachments}/attachments-v2.component.spec.ts (100%) rename {apps/web/src/app/vault/individual-vault => libs/vault/src/cipher-view/attachments}/attachments-v2.component.ts (84%) rename {apps/web/src/app/vault/individual-vault => libs/vault/src/components/password-history}/password-history.component.html (100%) rename {apps/web/src/app/vault/individual-vault => libs/vault/src/components/password-history}/password-history.component.ts (95%) diff --git a/apps/browser/src/vault/popup/services/browser-view-password-history.service.ts b/apps/browser/src/vault/popup/services/browser-view-password-history.service.ts index 5e400da9de5..ae6369d06a5 100644 --- a/apps/browser/src/vault/popup/services/browser-view-password-history.service.ts +++ b/apps/browser/src/vault/popup/services/browser-view-password-history.service.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { inject } from "@angular/core"; import { Router } from "@angular/router"; diff --git a/apps/desktop/src/app/app-routing.module.ts b/apps/desktop/src/app/app-routing.module.ts index 0c6bc730c2c..00463152a95 100644 --- a/apps/desktop/src/app/app-routing.module.ts +++ b/apps/desktop/src/app/app-routing.module.ts @@ -14,6 +14,7 @@ import { tdeDecryptionRequiredGuard, unauthGuardFn, } from "@bitwarden/angular/auth/guards"; +import { featureFlaggedRoute } from "@bitwarden/angular/platform/utils/feature-flagged-route"; import { NewDeviceVerificationNoticeGuard } from "@bitwarden/angular/vault/guards"; import { AnonLayoutWrapperComponent, @@ -41,6 +42,7 @@ import { NewDeviceVerificationComponent, DeviceVerificationIcon, } from "@bitwarden/auth/angular"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { LockComponent } from "@bitwarden/key-management-ui"; import { NewDeviceVerificationNoticePageOneComponent, @@ -53,6 +55,7 @@ import { maxAccountsGuardFn } from "../auth/guards/max-accounts.guard"; import { SetPasswordComponent } from "../auth/set-password.component"; import { UpdateTempPasswordComponent } from "../auth/update-temp-password.component"; import { RemovePasswordComponent } from "../key-management/key-connector/remove-password.component"; +import { VaultV2Component } from "../vault/app/vault/vault-v2.component"; import { VaultComponent } from "../vault/app/vault/vault.component"; import { Fido2PlaceholderComponent } from "./components/fido2placeholder.component"; @@ -132,11 +135,15 @@ const routes: Routes = [ }, ], }, - { - path: "vault", - component: VaultComponent, - canActivate: [authGuard, NewDeviceVerificationNoticeGuard], - }, + ...featureFlaggedRoute({ + defaultComponent: VaultComponent, + flaggedComponent: VaultV2Component, + featureFlag: FeatureFlag.PM18520_UpdateDesktopCipherForm, + routeOptions: { + path: "vault", + canActivate: [authGuard, NewDeviceVerificationNoticeGuard], + }, + }), { path: "accessibility-cookie", component: AccessibilityCookieComponent }, { path: "set-password", component: SetPasswordComponent }, { @@ -359,7 +366,7 @@ const routes: Routes = [ imports: [ RouterModule.forRoot(routes, { useHash: true, - /*enableTracing: true,*/ + // enableTracing: true, }), ], exports: [RouterModule], diff --git a/apps/desktop/src/app/app.module.ts b/apps/desktop/src/app/app.module.ts index c84f1a96afd..15ab4350bbc 100644 --- a/apps/desktop/src/app/app.module.ts +++ b/apps/desktop/src/app/app.module.ts @@ -9,7 +9,6 @@ import { BrowserAnimationsModule } from "@angular/platform-browser/animations"; import { ColorPasswordCountPipe } from "@bitwarden/angular/pipes/color-password-count.pipe"; import { ColorPasswordPipe } from "@bitwarden/angular/pipes/color-password.pipe"; import { CalloutModule, DialogModule } from "@bitwarden/components"; -import { DecryptionFailureDialogComponent } from "@bitwarden/vault"; import { AccessibilityCookieComponent } from "../auth/accessibility-cookie.component"; import { DeleteAccountComponent } from "../auth/delete-account.component"; @@ -28,6 +27,7 @@ import { PasswordHistoryComponent } from "../vault/app/vault/password-history.co import { ShareComponent } from "../vault/app/vault/share.component"; import { VaultFilterModule } from "../vault/app/vault/vault-filter/vault-filter.module"; import { VaultItemsComponent } from "../vault/app/vault/vault-items.component"; +import { VaultV2Component } from "../vault/app/vault/vault-v2.component"; import { VaultComponent } from "../vault/app/vault/vault.component"; import { ViewCustomFieldsComponent } from "../vault/app/vault/view-custom-fields.component"; import { ViewComponent } from "../vault/app/vault/view.component"; @@ -55,8 +55,8 @@ import { SharedModule } from "./shared/shared.module"; CalloutModule, DeleteAccountComponent, UserVerificationComponent, - DecryptionFailureDialogComponent, NavComponent, + VaultV2Component, ], declarations: [ AccessibilityCookieComponent, @@ -65,7 +65,6 @@ import { SharedModule } from "./shared/shared.module"; AddEditCustomFieldsComponent, AppComponent, AttachmentsComponent, - VaultItemsComponent, CollectionsComponent, ColorPasswordPipe, ColorPasswordCountPipe, @@ -80,9 +79,10 @@ import { SharedModule } from "./shared/shared.module"; ShareComponent, UpdateTempPasswordComponent, VaultComponent, + VaultItemsComponent, VaultTimeoutInputComponent, - ViewComponent, ViewCustomFieldsComponent, + ViewComponent, ], providers: [SshAgentService], bootstrap: [AppComponent], diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index 6097543e50a..81e3a94ff4d 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -393,6 +393,64 @@ "authenticatorKeyTotp": { "message": "Authenticator key (TOTP)" }, + "authenticatorKey": { + "message": "Authenticator key" + }, + "autofillOptions": { + "message": "Autofill options" + }, + "websiteUri": { + "message": "Website (URI)" + }, + "websiteUriCount": { + "message": "Website (URI) $COUNT$", + "description": "Label for an input field that contains a website URI. The input field is part of a list of fields, and the count indicates the position of the field in the list.", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "websiteAdded": { + "message": "Website added" + }, + "addWebsite": { + "message": "Add website" + }, + "deleteWebsite": { + "message": "Delete website" + }, + "owner": { + "message": "Owner" + }, + "addField": { + "message": "Add field" + }, + "fieldType": { + "message": "Field type" + }, + "fieldLabel": { + "message": "Field label" + }, + "add": { + "message": "Add" + }, + "textHelpText": { + "message": "Use text fields for data like security questions" + }, + "hiddenHelpText": { + "message": "Use hidden fields for sensitive data like a password" + }, + "checkBoxHelpText": { + "message": "Use checkboxes if you'd like to autofill a form's checkbox, like a remember email" + }, + "linkedHelpText": { + "message": "Use a linked field when you are experiencing autofill issues for a specific website." + }, + "linkedLabelHelpText": { + "message": "Enter the the field's html id, name, aria-label, or placeholder." + }, "folder": { "message": "Folder" }, @@ -418,6 +476,9 @@ "message": "Linked", "description": "This describes a field that is 'linked' (related) to another field." }, + "cfTypeCheckbox": { + "message": "Checkbox" + }, "linkedValue": { "message": "Linked value", "description": "This describes a value that is 'linked' (related) to another value." @@ -1915,6 +1976,43 @@ } } }, + "cardDetails": { + "message": "Card details" + }, + "cardBrandDetails": { + "message": "$BRAND$ details", + "placeholders": { + "brand": { + "content": "$1", + "example": "Visa" + } + } + }, + "learnMoreAboutAuthenticators": { + "message": "Learn more about authenticators" + }, + "copyTOTP": { + "message": "Copy Authenticator key (TOTP)" + }, + "totpHelperTitle": { + "message": "Make 2-step verification seamless" + }, + "totpHelper": { + "message": "Bitwarden can store and fill 2-step verification codes. Copy and paste the key into this field." + }, + "totpHelperWithCapture": { + "message": "Bitwarden can store and fill 2-step verification codes. Select the camera icon to take a screenshot of this website's authenticator QR code, or copy and paste the key into this field." + }, + "premium": { + "message": "Premium", + "description": "Premium membership" + }, + "cardExpiredTitle": { + "message": "Expired card" + }, + "cardExpiredMessage": { + "message": "If you've renewed it, update the card's information" + }, "verificationRequired": { "message": "Verification required", "description": "Default title for the user verification dialog." @@ -2084,6 +2182,15 @@ "personalOwnershipPolicyInEffectImports": { "message": "An organization policy has blocked importing items into your individual vault." }, + "personalDetails": { + "message": "Personal details" + }, + "identification": { + "message": "Identification" + }, + "contactInfo": { + "message": "Contact information" + }, "allSends": { "message": "All Sends", "description": "'Sends' is a noun and the name of a feature called 'Bitwarden Send'. It should not be translated." @@ -2518,6 +2625,9 @@ "generateEmail": { "message": "Generate email" }, + "usernameGenerator": { + "message": "Username generator" + }, "spinboxBoundariesHint": { "message": "Value must be between $MIN$ and $MAX$.", "description": "Explains spin box minimum and maximum values to the user", @@ -3439,6 +3549,17 @@ "ssoError": { "message": "No free ports could be found for the sso login." }, + "securePasswordGenerated": { + "message": "Secure password generated! Don't forget to also update your password on the website." + }, + "useGeneratorHelpTextPartOne": { + "message": "Use the generator", + "description": "This will be used as part of a larger sentence, broken up to include the generator icon. The full sentence will read 'Use the generator [GENERATOR_ICON] to create a strong unique password'" + }, + "useGeneratorHelpTextPartTwo": { + "message": "to create a strong unique password", + "description": "This will be used as part of a larger sentence, broken up to include the generator icon. The full sentence will read 'Use the generator [GENERATOR_ICON] to create a strong unique password'" + }, "biometricsStatusHelptextUnlockNeeded": { "message": "Biometric unlock is unavailable because PIN or password unlock is required first." }, @@ -3517,6 +3638,27 @@ "setupTwoStepLogin": { "message": "Set up two-step login" }, + "itemDetails": { + "message": "Item details" + }, + "itemName": { + "message": "Item name" + }, + "loginCredentials": { + "message": "Login credentials" + }, + "additionalOptions": { + "message": "Additional options" + }, + "itemHistory": { + "message": "Item history" + }, + "lastEdited": { + "message": "Last edited" + }, + "upload": { + "message": "Upload" + }, "newDeviceVerificationNoticeContentPage1": { "message": "Bitwarden will send a code to your account email to verify logins from new devices starting in February 2025." }, diff --git a/apps/desktop/src/scss/base.scss b/apps/desktop/src/scss/base.scss index 22eb3df0d17..494e91529ee 100644 --- a/apps/desktop/src/scss/base.scss +++ b/apps/desktop/src/scss/base.scss @@ -147,3 +147,8 @@ div:not(.modal)::-webkit-scrollbar-thumb, .mx-auto { margin-left: auto !important; } + +.vault-v2 button:not([bitbutton]):not([biticonbutton]) i.bwi, +a i.bwi { + margin-right: 0.25rem; +} diff --git a/apps/desktop/src/scss/vault.scss b/apps/desktop/src/scss/vault.scss index f7403ad62d2..88216a2b926 100644 --- a/apps/desktop/src/scss/vault.scss +++ b/apps/desktop/src/scss/vault.scss @@ -162,3 +162,7 @@ app-root { } } } + +.vault-v2 > .details { + flex-direction: column-reverse; +} diff --git a/apps/desktop/src/services/desktop-cipher-form-generator.service.ts b/apps/desktop/src/services/desktop-cipher-form-generator.service.ts new file mode 100644 index 00000000000..8a33f4ced0a --- /dev/null +++ b/apps/desktop/src/services/desktop-cipher-form-generator.service.ts @@ -0,0 +1,32 @@ +import { inject, Injectable } from "@angular/core"; +import { firstValueFrom } from "rxjs"; + +import { DialogService } from "@bitwarden/components"; +import { CipherFormGenerationService } from "@bitwarden/vault"; + +import { CredentialGeneratorDialogComponent } from "../vault/app/vault/credential-generator-dialog.component"; + +@Injectable() +export class DesktopCredentialGenerationService implements CipherFormGenerationService { + private dialogService = inject(DialogService); + + async generatePassword(): Promise { + return await this.generateCredential("password"); + } + + async generateUsername(uri: string): Promise { + return await this.generateCredential("username", uri); + } + + async generateCredential(type: "password" | "username", uri?: string): Promise { + const dialogRef = CredentialGeneratorDialogComponent.open(this.dialogService, { type, uri }); + + const result = await firstValueFrom(dialogRef.closed); + + if (!result || result.action === "canceled" || !result.generatedValue) { + return ""; + } + + return result.generatedValue; + } +} diff --git a/apps/desktop/src/services/desktop-premium-upgrade-prompt.service.spec.ts b/apps/desktop/src/services/desktop-premium-upgrade-prompt.service.spec.ts new file mode 100644 index 00000000000..3b33116ea5a --- /dev/null +++ b/apps/desktop/src/services/desktop-premium-upgrade-prompt.service.spec.ts @@ -0,0 +1,30 @@ +import { TestBed } from "@angular/core/testing"; +import { mock, MockProxy } from "jest-mock-extended"; + +import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; + +import { DesktopPremiumUpgradePromptService } from "./desktop-premium-upgrade-prompt.service"; + +describe("DesktopPremiumUpgradePromptService", () => { + let service: DesktopPremiumUpgradePromptService; + let messager: MockProxy; + + beforeEach(async () => { + messager = mock(); + await TestBed.configureTestingModule({ + providers: [ + DesktopPremiumUpgradePromptService, + { provide: MessagingService, useValue: messager }, + ], + }).compileComponents(); + + service = TestBed.inject(DesktopPremiumUpgradePromptService); + }); + + describe("promptForPremium", () => { + it("navigates to the premium update screen", async () => { + await service.promptForPremium(); + expect(messager.send).toHaveBeenCalledWith("openPremium"); + }); + }); +}); diff --git a/apps/desktop/src/services/desktop-premium-upgrade-prompt.service.ts b/apps/desktop/src/services/desktop-premium-upgrade-prompt.service.ts new file mode 100644 index 00000000000..f2375ecfebb --- /dev/null +++ b/apps/desktop/src/services/desktop-premium-upgrade-prompt.service.ts @@ -0,0 +1,15 @@ +import { inject } from "@angular/core"; + +import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; +import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; + +/** + * This class handles the premium upgrade process for the desktop. + */ +export class DesktopPremiumUpgradePromptService implements PremiumUpgradePromptService { + private messagingService = inject(MessagingService); + + async promptForPremium() { + this.messagingService.send("openPremium"); + } +} diff --git a/apps/desktop/src/vault/app/vault/credential-generator-dialog.component.html b/apps/desktop/src/vault/app/vault/credential-generator-dialog.component.html index 47232dff66d..31f47d824d6 100644 --- a/apps/desktop/src/vault/app/vault/credential-generator-dialog.component.html +++ b/apps/desktop/src/vault/app/vault/credential-generator-dialog.component.html @@ -3,6 +3,7 @@ @@ -27,7 +28,6 @@ (click)="applyCredentials()" appA11yTitle="{{ buttonLabel }}" bitButton - bitDialogClose [disabled]="!(buttonLabel && credentialValue)" > {{ buttonLabel }} diff --git a/apps/desktop/src/vault/app/vault/credential-generator-dialog.component.ts b/apps/desktop/src/vault/app/vault/credential-generator-dialog.component.ts index eda35a8c76d..2858d7330e5 100644 --- a/apps/desktop/src/vault/app/vault/credential-generator-dialog.component.ts +++ b/apps/desktop/src/vault/app/vault/credential-generator-dialog.component.ts @@ -10,6 +10,7 @@ import { DialogService, ItemModule, LinkModule, + DialogRef, } from "@bitwarden/components"; import { CredentialGeneratorHistoryDialogComponent, @@ -19,10 +20,22 @@ import { AlgorithmInfo } from "@bitwarden/generator-core"; import { CipherFormGeneratorComponent } from "@bitwarden/vault"; type CredentialGeneratorParams = { - onCredentialGenerated: (value?: string) => void; + /** @deprecated Prefer use of dialogRef.closed to retreive the generated value */ + onCredentialGenerated?: (value?: string) => void; type: "password" | "username"; + uri?: string; }; +export interface CredentialGeneratorDialogResult { + action: CredentialGeneratorDialogAction; + generatedValue?: string; +} + +export enum CredentialGeneratorDialogAction { + Selected = "selected", + Canceled = "canceled", +} + @Component({ standalone: true, selector: "credential-generator-dialog", @@ -45,6 +58,7 @@ export class CredentialGeneratorDialogComponent { constructor( @Inject(DIALOG_DATA) protected data: CredentialGeneratorParams, private dialogService: DialogService, + private dialogRef: DialogRef, private i18nService: I18nService, ) {} @@ -59,11 +73,15 @@ export class CredentialGeneratorDialogComponent { }; applyCredentials = () => { - this.data.onCredentialGenerated(this.credentialValue); + this.data.onCredentialGenerated?.(this.credentialValue); + this.dialogRef.close({ + action: CredentialGeneratorDialogAction.Selected, + generatedValue: this.credentialValue, + }); }; clearCredentials = () => { - this.data.onCredentialGenerated(); + this.data.onCredentialGenerated?.(); }; onCredentialGenerated = (value: string) => { @@ -75,9 +93,12 @@ export class CredentialGeneratorDialogComponent { this.dialogService.open(CredentialGeneratorHistoryDialogComponent); }; - static open = (dialogService: DialogService, data: CredentialGeneratorParams) => { - dialogService.open(CredentialGeneratorDialogComponent, { - data, - }); - }; + static open(dialogService: DialogService, data: CredentialGeneratorParams) { + return dialogService.open( + CredentialGeneratorDialogComponent, + { + data, + }, + ); + } } diff --git a/apps/desktop/src/vault/app/vault/item-footer.component.html b/apps/desktop/src/vault/app/vault/item-footer.component.html new file mode 100644 index 00000000000..6915555c08b --- /dev/null +++ b/apps/desktop/src/vault/app/vault/item-footer.component.html @@ -0,0 +1,64 @@ + diff --git a/apps/desktop/src/vault/app/vault/item-footer.component.ts b/apps/desktop/src/vault/app/vault/item-footer.component.ts new file mode 100644 index 00000000000..639d1557ecd --- /dev/null +++ b/apps/desktop/src/vault/app/vault/item-footer.component.ts @@ -0,0 +1,159 @@ +import { CommonModule } from "@angular/common"; +import { Input, Output, EventEmitter, Component, OnInit, ViewChild } from "@angular/core"; +import { Observable, firstValueFrom } from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { CollectionId, UserId } from "@bitwarden/common/types/guid"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { CipherRepromptType } from "@bitwarden/common/vault/enums"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; +import { ButtonComponent, ButtonModule, DialogService, ToastService } from "@bitwarden/components"; +import { PasswordRepromptService } from "@bitwarden/vault"; + +@Component({ + selector: "app-vault-item-footer", + templateUrl: "item-footer.component.html", + standalone: true, + imports: [ButtonModule, CommonModule, JslibModule], +}) +export class ItemFooterComponent implements OnInit { + @Input({ required: true }) cipher: CipherView = new CipherView(); + @Input() collectionId: string | null = null; + @Input({ required: true }) action: string = "view"; + @Input() isSubmitting: boolean = false; + @Output() onEdit = new EventEmitter(); + @Output() onClone = new EventEmitter(); + @Output() onDelete = new EventEmitter(); + @Output() onRestore = new EventEmitter(); + @Output() onCancel = new EventEmitter(); + @ViewChild("submitBtn", { static: false }) submitBtn: ButtonComponent | null = null; + + canDeleteCipher$: Observable = new Observable(); + activeUserId: UserId | null = null; + + private passwordReprompted = false; + + constructor( + protected cipherService: CipherService, + protected dialogService: DialogService, + protected passwordRepromptService: PasswordRepromptService, + protected cipherAuthorizationService: CipherAuthorizationService, + protected accountService: AccountService, + protected toastService: ToastService, + protected i18nService: I18nService, + protected logService: LogService, + ) {} + + async ngOnInit() { + this.canDeleteCipher$ = this.cipherAuthorizationService.canDeleteCipher$(this.cipher, [ + this.collectionId as CollectionId, + ]); + this.activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + } + + async clone() { + if (this.cipher.login?.hasFido2Credentials) { + const confirmed = await this.dialogService.openSimpleDialog({ + title: { key: "passkeyNotCopied" }, + content: { key: "passkeyNotCopiedAlert" }, + type: "info", + }); + + if (!confirmed) { + return false; + } + } + + if (await this.promptPassword()) { + this.onClone.emit(this.cipher); + return true; + } + + return false; + } + + protected edit() { + this.onEdit.emit(this.cipher); + } + + cancel() { + this.onCancel.emit(this.cipher); + } + + async delete(): Promise { + if (!(await this.promptPassword())) { + return false; + } + + const confirmed = await this.dialogService.openSimpleDialog({ + title: { key: "deleteItem" }, + content: { + key: this.cipher.isDeleted ? "permanentlyDeleteItemConfirmation" : "deleteItemConfirmation", + }, + type: "warning", + }); + + if (!confirmed) { + return false; + } + + try { + const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + await this.deleteCipher(activeUserId); + this.toastService.showToast({ + variant: "success", + message: this.i18nService.t( + this.cipher.isDeleted ? "permanentlyDeletedItem" : "deletedItem", + ), + }); + this.onDelete.emit(this.cipher); + } catch (e) { + this.logService.error(e); + } + + return true; + } + + async restore(): Promise { + if (!this.cipher.isDeleted) { + return false; + } + + try { + const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + await this.restoreCipher(activeUserId); + this.toastService.showToast({ + variant: "success", + message: this.i18nService.t("restoredItem"), + }); + this.onRestore.emit(this.cipher); + } catch (e) { + this.logService.error(e); + } + + return true; + } + + protected deleteCipher(userId: UserId) { + return this.cipher.isDeleted + ? this.cipherService.deleteWithServer(this.cipher.id, userId) + : this.cipherService.softDeleteWithServer(this.cipher.id, userId); + } + + protected restoreCipher(userId: UserId) { + return this.cipherService.restoreWithServer(this.cipher.id, userId); + } + + protected async promptPassword() { + if (this.cipher.reprompt === CipherRepromptType.None || this.passwordReprompted) { + return true; + } + + return (this.passwordReprompted = await this.passwordRepromptService.showPasswordPrompt()); + } +} diff --git a/apps/desktop/src/vault/app/vault/vault-items-v2.component.html b/apps/desktop/src/vault/app/vault/vault-items-v2.component.html new file mode 100644 index 00000000000..ff35e00fb0f --- /dev/null +++ b/apps/desktop/src/vault/app/vault/vault-items-v2.component.html @@ -0,0 +1,92 @@ +
+ +
+ +
+ +
+ +
+
+
+ +

{{ "noItemsInList" | i18n }}

+ +
+ +
+
+ + + + + + + + + + diff --git a/apps/desktop/src/vault/app/vault/vault-items-v2.component.ts b/apps/desktop/src/vault/app/vault/vault-items-v2.component.ts new file mode 100644 index 00000000000..31d4098d2b2 --- /dev/null +++ b/apps/desktop/src/vault/app/vault/vault-items-v2.component.ts @@ -0,0 +1,42 @@ +import { ScrollingModule } from "@angular/cdk/scrolling"; +import { CommonModule } from "@angular/common"; +import { Component } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { distinctUntilChanged } from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { VaultItemsComponent as BaseVaultItemsComponent } from "@bitwarden/angular/vault/components/vault-items.component"; +import { SearchService } from "@bitwarden/common/abstractions/search.service"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { MenuModule } from "@bitwarden/components"; + +import { SearchBarService } from "../../../app/layout/search/search-bar.service"; + +@Component({ + selector: "app-vault-items-v2", + templateUrl: "vault-items-v2.component.html", + standalone: true, + imports: [MenuModule, CommonModule, JslibModule, ScrollingModule], +}) +export class VaultItemsV2Component extends BaseVaultItemsComponent { + constructor( + searchService: SearchService, + private readonly searchBarService: SearchBarService, + cipherService: CipherService, + accountService: AccountService, + ) { + super(searchService, cipherService, accountService); + + this.searchBarService.searchText$ + .pipe(distinctUntilChanged(), takeUntilDestroyed()) + .subscribe((searchText) => { + this.searchText = searchText!; + }); + } + + trackByFn(index: number, c: CipherView): string { + return c.id; + } +} diff --git a/apps/desktop/src/vault/app/vault/vault-v2.component.html b/apps/desktop/src/vault/app/vault/vault-v2.component.html new file mode 100644 index 00000000000..12f52502984 --- /dev/null +++ b/apps/desktop/src/vault/app/vault/vault-v2.component.html @@ -0,0 +1,80 @@ +
+ + +
+ +
+
+
+ + + + + + + +
+
+
+
+ +
+ + +
+
+ diff --git a/apps/desktop/src/vault/app/vault/vault-v2.component.ts b/apps/desktop/src/vault/app/vault/vault-v2.component.ts new file mode 100644 index 00000000000..7e799899418 --- /dev/null +++ b/apps/desktop/src/vault/app/vault/vault-v2.component.ts @@ -0,0 +1,785 @@ +import { CommonModule } from "@angular/common"; +import { + ChangeDetectorRef, + Component, + NgZone, + OnDestroy, + OnInit, + ViewChild, + ViewContainerRef, +} from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { ActivatedRoute, Router } from "@angular/router"; +import { firstValueFrom, Subject, takeUntil, switchMap } from "rxjs"; +import { filter, map, take } from "rxjs/operators"; + +import { CollectionView } from "@bitwarden/admin-console/common"; +import { ModalRef } from "@bitwarden/angular/components/modal/modal.ref"; +import { ModalService } from "@bitwarden/angular/services/modal.service"; +import { VaultViewPasswordHistoryService } from "@bitwarden/angular/services/view-password-history.service"; +import { VaultFilter } from "@bitwarden/angular/vault/vault-filter/models/vault-filter.model"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; +import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; +import { EventType } from "@bitwarden/common/enums"; +import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { SyncService } from "@bitwarden/common/platform/sync"; +import { CipherId, UserId } from "@bitwarden/common/types/guid"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; +import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service"; +import { ViewPasswordHistoryService } from "@bitwarden/common/vault/abstractions/view-password-history.service"; +import { CipherType } from "@bitwarden/common/vault/enums"; +import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; +import { + BadgeModule, + ButtonModule, + DialogService, + ItemModule, + ToastService, +} from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; +import { + AttachmentDialogResult, + AttachmentsV2Component, + ChangeLoginPasswordService, + CipherFormConfig, + CipherFormConfigService, + CipherFormGenerationService, + CipherFormMode, + CipherFormModule, + CipherViewComponent, + DecryptionFailureDialogComponent, + DefaultChangeLoginPasswordService, + DefaultCipherFormConfigService, + PasswordRepromptService, +} from "@bitwarden/vault"; + +import { NavComponent } from "../../../app/layout/nav.component"; +import { SearchBarService } from "../../../app/layout/search/search-bar.service"; +import { DesktopCredentialGenerationService } from "../../../services/desktop-cipher-form-generator.service"; +import { DesktopPremiumUpgradePromptService } from "../../../services/desktop-premium-upgrade-prompt.service"; +import { invokeMenu, RendererMenuItem } from "../../../utils"; + +import { FolderAddEditComponent } from "./folder-add-edit.component"; +import { ItemFooterComponent } from "./item-footer.component"; +import { VaultFilterComponent } from "./vault-filter/vault-filter.component"; +import { VaultFilterModule } from "./vault-filter/vault-filter.module"; +import { VaultItemsV2Component } from "./vault-items-v2.component"; + +const BroadcasterSubscriptionId = "VaultComponent"; + +@Component({ + selector: "app-vault", + templateUrl: "vault-v2.component.html", + standalone: true, + imports: [ + BadgeModule, + CommonModule, + CipherFormModule, + CipherViewComponent, + ItemFooterComponent, + I18nPipe, + ItemModule, + ButtonModule, + NavComponent, + VaultFilterModule, + VaultItemsV2Component, + ], + providers: [ + { + provide: CipherFormConfigService, + useClass: DefaultCipherFormConfigService, + }, + { + provide: ChangeLoginPasswordService, + useClass: DefaultChangeLoginPasswordService, + }, + { + provide: ViewPasswordHistoryService, + useClass: VaultViewPasswordHistoryService, + }, + { + provide: PremiumUpgradePromptService, + useClass: DesktopPremiumUpgradePromptService, + }, + { provide: CipherFormGenerationService, useClass: DesktopCredentialGenerationService }, + ], +}) +export class VaultV2Component implements OnInit, OnDestroy { + @ViewChild(VaultItemsV2Component, { static: true }) + vaultItemsComponent: VaultItemsV2Component | null = null; + @ViewChild(VaultFilterComponent, { static: true }) + vaultFilterComponent: VaultFilterComponent | null = null; + @ViewChild("folderAddEdit", { read: ViewContainerRef, static: true }) + folderAddEditModalRef: ViewContainerRef | null = null; + + action: CipherFormMode | "view" | null = null; + cipherId: string | null = null; + favorites = false; + type: CipherType | null = null; + folderId: string | null = null; + collectionId: string | null = null; + organizationId: string | null = null; + myVaultOnly = false; + addType: CipherType | undefined = undefined; + addOrganizationId: string | null = null; + addCollectionIds: string[] | null = null; + showingModal = false; + deleted = false; + userHasPremiumAccess = false; + activeFilter: VaultFilter = new VaultFilter(); + activeUserId: UserId | null = null; + cipherRepromptId: string | null = null; + cipher: CipherView | null = new CipherView(); + collections: CollectionView[] | null = null; + config: CipherFormConfig | null = null; + + protected canAccessAttachments$ = this.accountService.activeAccount$.pipe( + filter((account): account is Account => !!account), + switchMap((account) => + this.billingAccountProfileStateService.hasPremiumFromAnySource$(account.id), + ), + ); + + private modal: ModalRef | null = null; + private componentIsDestroyed$ = new Subject(); + + constructor( + private route: ActivatedRoute, + private router: Router, + private i18nService: I18nService, + private modalService: ModalService, + private broadcasterService: BroadcasterService, + private changeDetectorRef: ChangeDetectorRef, + private ngZone: NgZone, + private syncService: SyncService, + private messagingService: MessagingService, + private platformUtilsService: PlatformUtilsService, + private eventCollectionService: EventCollectionService, + private totpService: TotpService, + private passwordRepromptService: PasswordRepromptService, + private searchBarService: SearchBarService, + private apiService: ApiService, + private dialogService: DialogService, + private billingAccountProfileStateService: BillingAccountProfileStateService, + private toastService: ToastService, + private accountService: AccountService, + private cipherService: CipherService, + private formConfigService: CipherFormConfigService, + private premiumUpgradePromptService: PremiumUpgradePromptService, + ) {} + + async ngOnInit() { + this.accountService.activeAccount$ + .pipe( + filter((account): account is Account => !!account), + switchMap((account) => + this.billingAccountProfileStateService.hasPremiumFromAnySource$(account.id), + ), + takeUntil(this.componentIsDestroyed$), + ) + .subscribe((canAccessPremium: boolean) => { + this.userHasPremiumAccess = canAccessPremium; + }); + + this.broadcasterService.subscribe(BroadcasterSubscriptionId, (message: any) => { + this.ngZone + .run(async () => { + let detectChanges = true; + try { + switch (message.command) { + case "newLogin": + await this.addCipher(CipherType.Login).catch(() => {}); + break; + case "newCard": + await this.addCipher(CipherType.Card).catch(() => {}); + break; + case "newIdentity": + await this.addCipher(CipherType.Identity).catch(() => {}); + break; + case "newSecureNote": + await this.addCipher(CipherType.SecureNote).catch(() => {}); + break; + case "focusSearch": + (document.querySelector("#search") as HTMLInputElement)?.select(); + detectChanges = false; + break; + case "syncCompleted": + if (this.vaultItemsComponent) { + await this.vaultItemsComponent + .reload(this.activeFilter.buildFilter()) + .catch(() => {}); + } + if (this.vaultFilterComponent) { + await this.vaultFilterComponent + .reloadCollectionsAndFolders(this.activeFilter) + .catch(() => {}); + await this.vaultFilterComponent.reloadOrganizations().catch(() => {}); + } + break; + case "modalShown": + this.showingModal = true; + break; + case "modalClosed": + this.showingModal = false; + break; + case "copyUsername": { + if (this.cipher?.login?.username) { + this.copyValue(this.cipher, this.cipher?.login?.username, "username", "Username"); + } + break; + } + case "copyPassword": { + if (this.cipher?.login?.password && this.cipher.viewPassword) { + this.copyValue(this.cipher, this.cipher.login.password, "password", "Password"); + await this.eventCollectionService + .collect(EventType.Cipher_ClientCopiedPassword, this.cipher.id) + .catch(() => {}); + } + break; + } + case "copyTotp": { + if ( + this.cipher?.login?.hasTotp && + (this.cipher.organizationUseTotp || this.userHasPremiumAccess) + ) { + const value = await firstValueFrom( + this.totpService.getCode$(this.cipher.login.totp), + ).catch(() => null); + if (value) { + this.copyValue(this.cipher, value.code, "verificationCodeTotp", "TOTP"); + } + } + break; + } + default: + detectChanges = false; + break; + } + } catch { + // Ignore errors + } + if (detectChanges) { + this.changeDetectorRef.detectChanges(); + } + }) + .catch(() => {}); + }); + + if (!this.syncService.syncInProgress) { + await this.load().catch(() => {}); + } + + this.searchBarService.setEnabled(true); + this.searchBarService.setPlaceholderText(this.i18nService.t("searchVault")); + + const authRequest = await this.apiService.getLastAuthRequest().catch(() => null); + if (authRequest != null) { + this.messagingService.send("openLoginApproval", { + notificationId: authRequest.id, + }); + } + + this.activeUserId = await firstValueFrom( + this.accountService.activeAccount$.pipe(getUserId), + ).catch(() => null); + + if (this.activeUserId) { + this.cipherService + .failedToDecryptCiphers$(this.activeUserId) + .pipe( + map((ciphers) => ciphers?.filter((c) => !c.isDeleted) ?? []), + filter((ciphers) => ciphers.length > 0), + take(1), + takeUntil(this.componentIsDestroyed$), + ) + .subscribe((ciphers) => { + DecryptionFailureDialogComponent.open(this.dialogService, { + cipherIds: ciphers.map((c) => c.id as CipherId), + }); + }); + } + } + + ngOnDestroy() { + this.searchBarService.setEnabled(false); + this.broadcasterService.unsubscribe(BroadcasterSubscriptionId); + this.componentIsDestroyed$.next(true); + this.componentIsDestroyed$.complete(); + } + + async load() { + const params = await firstValueFrom(this.route.queryParams).catch(); + if (params.cipherId) { + const cipherView = new CipherView(); + cipherView.id = params.cipherId; + if (params.action === "clone") { + await this.cloneCipher(cipherView).catch(() => {}); + } else if (params.action === "edit") { + await this.editCipher(cipherView).catch(() => {}); + } else { + await this.viewCipher(cipherView).catch(() => {}); + } + } else if (params.action === "add") { + this.addType = Number(params.addType); + await this.addCipher(this.addType).catch(() => {}); + } + + this.activeFilter = new VaultFilter({ + status: params.deleted ? "trash" : params.favorites ? "favorites" : "all", + cipherType: + params.action === "add" || params.type == null + ? undefined + : (parseInt(params.type) as CipherType), + selectedFolderId: params.folderId, + selectedCollectionId: params.selectedCollectionId, + selectedOrganizationId: params.selectedOrganizationId, + myVaultOnly: params.myVaultOnly ?? false, + }); + if (this.vaultItemsComponent) { + await this.vaultItemsComponent.reload(this.activeFilter.buildFilter()).catch(() => {}); + } + } + + async viewCipher(cipher: CipherView) { + if (await this.shouldReprompt(cipher, "view")) { + return; + } + this.cipherId = cipher.id; + this.cipher = cipher; + this.collections = + this.vaultFilterComponent?.collections.fullList.filter((c) => + cipher.collectionIds.includes(c.id), + ) ?? null; + this.action = "view"; + await this.go().catch(() => {}); + } + + async openAttachmentsDialog() { + if (!this.userHasPremiumAccess) { + await this.premiumUpgradePromptService.promptForPremium(); + return; + } + const dialogRef = AttachmentsV2Component.open(this.dialogService, { + cipherId: this.cipherId as CipherId, + }); + const result = await firstValueFrom(dialogRef.closed).catch(() => null); + if ( + result?.action === AttachmentDialogResult.Removed || + result?.action === AttachmentDialogResult.Uploaded + ) { + await this.vaultItemsComponent?.refresh().catch(() => {}); + } + } + + viewCipherMenu(cipher: CipherView) { + const menu: RendererMenuItem[] = [ + { + label: this.i18nService.t("view"), + click: () => { + this.functionWithChangeDetection(() => { + this.viewCipher(cipher).catch(() => {}); + }); + }, + }, + ]; + + if (cipher.decryptionFailure) { + invokeMenu(menu); + return; + } + + if (!cipher.isDeleted) { + menu.push({ + label: this.i18nService.t("edit"), + click: () => { + this.functionWithChangeDetection(() => { + this.editCipher(cipher).catch(() => {}); + }); + }, + }); + if (!cipher.organizationId) { + menu.push({ + label: this.i18nService.t("clone"), + click: () => { + this.functionWithChangeDetection(() => { + this.cloneCipher(cipher).catch(() => {}); + }); + }, + }); + } + } + + switch (cipher.type) { + case CipherType.Login: + if ( + cipher.login.canLaunch || + cipher.login.username != null || + cipher.login.password != null + ) { + menu.push({ type: "separator" }); + } + if (cipher.login.canLaunch) { + menu.push({ + label: this.i18nService.t("launch"), + click: () => this.platformUtilsService.launchUri(cipher.login.launchUri), + }); + } + if (cipher.login.username != null) { + menu.push({ + label: this.i18nService.t("copyUsername"), + click: () => this.copyValue(cipher, cipher.login.username, "username", "Username"), + }); + } + if (cipher.login.password != null && cipher.viewPassword) { + menu.push({ + label: this.i18nService.t("copyPassword"), + click: () => { + this.copyValue(cipher, cipher.login.password, "password", "Password"); + this.eventCollectionService + .collect(EventType.Cipher_ClientCopiedPassword, cipher.id) + .catch(() => {}); + }, + }); + } + if (cipher.login.hasTotp && (cipher.organizationUseTotp || this.userHasPremiumAccess)) { + menu.push({ + label: this.i18nService.t("copyVerificationCodeTotp"), + click: async () => { + const value = await firstValueFrom( + this.totpService.getCode$(cipher.login.totp), + ).catch(() => null); + if (value) { + this.copyValue(cipher, value.code, "verificationCodeTotp", "TOTP"); + } + }, + }); + } + break; + case CipherType.Card: + if (cipher.card.number != null || cipher.card.code != null) { + menu.push({ type: "separator" }); + } + if (cipher.card.number != null) { + menu.push({ + label: this.i18nService.t("copyNumber"), + click: () => this.copyValue(cipher, cipher.card.number, "number", "Card Number"), + }); + } + if (cipher.card.code != null) { + menu.push({ + label: this.i18nService.t("copySecurityCode"), + click: () => { + this.copyValue(cipher, cipher.card.code, "securityCode", "Security Code"); + this.eventCollectionService + .collect(EventType.Cipher_ClientCopiedCardCode, cipher.id) + .catch(() => {}); + }, + }); + } + break; + default: + break; + } + invokeMenu(menu); + } + + async shouldReprompt(cipher: CipherView, action: "edit" | "clone" | "view"): Promise { + return !(await this.canNavigateAway(action, cipher)) || !(await this.passwordReprompt(cipher)); + } + + async buildFormConfig(action: CipherFormMode) { + this.config = await this.formConfigService + .buildConfig(action, this.cipherId as CipherId, this.addType) + .catch(() => null); + } + + async editCipher(cipher: CipherView) { + if (await this.shouldReprompt(cipher, "edit")) { + return; + } + this.cipherId = cipher.id; + this.cipher = cipher; + await this.buildFormConfig("edit"); + this.action = "edit"; + await this.go().catch(() => {}); + } + + async cloneCipher(cipher: CipherView) { + if (await this.shouldReprompt(cipher, "clone")) { + return; + } + this.cipherId = cipher.id; + this.cipher = cipher; + await this.buildFormConfig("clone"); + this.action = "clone"; + await this.go().catch(() => {}); + } + + async addCipher(type: CipherType) { + this.addType = type || this.activeFilter.cipherType; + this.cipherId = null; + await this.buildFormConfig("add"); + this.action = "add"; + this.prefillCipherFromFilter(); + await this.go().catch(() => {}); + } + + addCipherOptions() { + const menu: RendererMenuItem[] = [ + { + label: this.i18nService.t("typeLogin"), + click: () => this.addCipherWithChangeDetection(CipherType.Login), + }, + { + label: this.i18nService.t("typeCard"), + click: () => this.addCipherWithChangeDetection(CipherType.Card), + }, + { + label: this.i18nService.t("typeIdentity"), + click: () => this.addCipherWithChangeDetection(CipherType.Identity), + }, + { + label: this.i18nService.t("typeSecureNote"), + click: () => this.addCipherWithChangeDetection(CipherType.SecureNote), + }, + ]; + invokeMenu(menu); + } + + async savedCipher(cipher: CipherView) { + this.cipherId = null; + this.action = "view"; + await this.vaultItemsComponent?.refresh().catch(() => {}); + this.cipherId = cipher.id; + this.cipher = cipher; + if (this.activeUserId) { + await this.cipherService.clearCache(this.activeUserId).catch(() => {}); + } + await this.vaultItemsComponent?.load(this.activeFilter.buildFilter()).catch(() => {}); + await this.go().catch(() => {}); + await this.vaultItemsComponent?.refresh().catch(() => {}); + } + + async deleteCipher() { + this.cipherId = null; + this.cipher = null; + this.action = null; + await this.go().catch(() => {}); + await this.vaultItemsComponent?.refresh().catch(() => {}); + } + + async restoreCipher() { + this.cipherId = null; + this.action = null; + await this.go().catch(() => {}); + await this.vaultItemsComponent?.refresh().catch(() => {}); + } + + async cancelCipher(cipher: CipherView) { + this.cipherId = cipher.id; + this.cipher = cipher; + this.action = this.cipherId != null ? "view" : null; + await this.go().catch(() => {}); + } + + async applyVaultFilter(vaultFilter: VaultFilter) { + this.searchBarService.setPlaceholderText( + this.i18nService.t(this.calculateSearchBarLocalizationString(vaultFilter)), + ); + this.activeFilter = vaultFilter; + await this.vaultItemsComponent + ?.reload(this.activeFilter.buildFilter(), vaultFilter.status === "trash") + .catch(() => {}); + await this.go().catch(() => {}); + } + + private calculateSearchBarLocalizationString(vaultFilter: VaultFilter): string { + if (vaultFilter.status === "favorites") { + return "searchFavorites"; + } + if (vaultFilter.status === "trash") { + return "searchTrash"; + } + if (vaultFilter.cipherType != null) { + return "searchType"; + } + if (vaultFilter.selectedFolderId != null && vaultFilter.selectedFolderId !== "none") { + return "searchFolder"; + } + if (vaultFilter.selectedCollectionId != null) { + return "searchCollection"; + } + if (vaultFilter.selectedOrganizationId != null) { + return "searchOrganization"; + } + if (vaultFilter.myVaultOnly) { + return "searchMyVault"; + } + return "searchVault"; + } + + async addFolder() { + this.messagingService.send("newFolder"); + } + + async editFolder(folderId: string) { + if (this.modal != null) { + this.modal.close(); + } + if (this.folderAddEditModalRef == null) { + return; + } + const [modal, childComponent] = await this.modalService + .openViewRef( + FolderAddEditComponent, + this.folderAddEditModalRef, + (comp) => (comp.folderId = folderId), + ) + .catch(() => [null, null] as any); + this.modal = modal; + if (childComponent) { + childComponent.onSavedFolder.subscribe(async (folder: FolderView) => { + this.modal?.close(); + await this.vaultFilterComponent + ?.reloadCollectionsAndFolders(this.activeFilter) + .catch(() => {}); + }); + childComponent.onDeletedFolder.subscribe(async (folder: FolderView) => { + this.modal?.close(); + await this.vaultFilterComponent + ?.reloadCollectionsAndFolders(this.activeFilter) + .catch(() => {}); + }); + } + if (this.modal) { + this.modal.onClosed.pipe(takeUntilDestroyed()).subscribe(() => { + this.modal = null; + }); + } + } + + private dirtyInput(): boolean { + return ( + (this.action === "add" || this.action === "edit" || this.action === "clone") && + document.querySelectorAll("vault-cipher-form .ng-dirty").length > 0 + ); + } + + private async wantsToSaveChanges(): Promise { + const confirmed = await this.dialogService + .openSimpleDialog({ + title: { key: "unsavedChangesTitle" }, + content: { key: "unsavedChangesConfirmation" }, + type: "warning", + }) + .catch(() => false); + return !confirmed; + } + + private async go(queryParams: any = null) { + if (queryParams == null) { + queryParams = { + action: this.action, + cipherId: this.cipherId, + favorites: this.favorites ? true : null, + type: this.type, + folderId: this.folderId, + collectionId: this.collectionId, + deleted: this.deleted ? true : null, + organizationId: this.organizationId, + myVaultOnly: this.myVaultOnly, + }; + } + this.router + .navigate([], { + relativeTo: this.route, + queryParams: queryParams, + replaceUrl: true, + }) + .catch(() => {}); + } + + private addCipherWithChangeDetection(type: CipherType) { + this.functionWithChangeDetection(() => this.addCipher(type).catch(() => {})); + } + + private copyValue(cipher: CipherView, value: string, labelI18nKey: string, aType: string) { + this.functionWithChangeDetection(() => { + (async () => { + if ( + cipher.reprompt !== CipherRepromptType.None && + this.passwordRepromptService.protectedFields().includes(aType) && + !(await this.passwordReprompt(cipher)) + ) { + return; + } + this.platformUtilsService.copyToClipboard(value); + this.toastService.showToast({ + variant: "info", + title: undefined, + message: this.i18nService.t("valueCopied", this.i18nService.t(labelI18nKey)), + }); + if (this.action === "view") { + this.messagingService.send("minimizeOnCopy"); + } + })().catch(() => {}); + }); + } + + private functionWithChangeDetection(func: () => void) { + this.ngZone.run(() => { + func(); + this.changeDetectorRef.detectChanges(); + }); + } + + private prefillCipherFromFilter() { + if (this.activeFilter.selectedCollectionId != null && this.vaultFilterComponent != null) { + const collections = this.vaultFilterComponent.collections.fullList.filter( + (c) => c.id === this.activeFilter.selectedCollectionId, + ); + if (collections.length > 0) { + this.addOrganizationId = collections[0].organizationId; + this.addCollectionIds = [this.activeFilter.selectedCollectionId]; + } + } else if (this.activeFilter.selectedOrganizationId) { + this.addOrganizationId = this.activeFilter.selectedOrganizationId; + } + if (this.activeFilter.selectedFolderId && this.activeFilter.selectedFolder) { + this.folderId = this.activeFilter.selectedFolderId; + } + } + + private async canNavigateAway(action: string, cipher?: CipherView) { + if (this.action === action && (!cipher || this.cipherId === cipher.id)) { + return false; + } else if (this.dirtyInput() && (await this.wantsToSaveChanges())) { + return false; + } + return true; + } + + private async passwordReprompt(cipher: CipherView) { + if (cipher.reprompt === CipherRepromptType.None) { + this.cipherRepromptId = null; + return true; + } + if (this.cipherRepromptId === cipher.id) { + return true; + } + const repromptResult = await this.passwordRepromptService.showPasswordPrompt(); + if (repromptResult) { + this.cipherRepromptId = cipher.id; + } + return repromptResult; + } +} diff --git a/apps/web/src/app/admin-console/organizations/collections/vault.component.ts b/apps/web/src/app/admin-console/organizations/collections/vault.component.ts index 97193bf1b1f..8cb54d9a911 100644 --- a/apps/web/src/app/admin-console/organizations/collections/vault.component.ts +++ b/apps/web/src/app/admin-console/organizations/collections/vault.component.ts @@ -69,6 +69,8 @@ import { ToastService, } from "@bitwarden/components"; import { + AttachmentDialogResult, + AttachmentsV2Component, CipherFormConfig, CipherFormConfigService, CollectionAssignmentResult, @@ -92,10 +94,6 @@ import { } from "../../../vault/components/vault-item-dialog/vault-item-dialog.component"; import { VaultItemEvent } from "../../../vault/components/vault-items/vault-item-event"; import { VaultItemsModule } from "../../../vault/components/vault-items/vault-items.module"; -import { - AttachmentDialogResult, - AttachmentsV2Component, -} from "../../../vault/individual-vault/attachments-v2.component"; import { BulkDeleteDialogResult, openBulkDeleteDialog, diff --git a/apps/web/src/app/auth/settings/emergency-access/view/emergency-view-dialog.component.ts b/apps/web/src/app/auth/settings/emergency-access/view/emergency-view-dialog.component.ts index bcae11c3264..0022da7f3a9 100644 --- a/apps/web/src/app/auth/settings/emergency-access/view/emergency-view-dialog.component.ts +++ b/apps/web/src/app/auth/settings/emergency-access/view/emergency-view-dialog.component.ts @@ -2,6 +2,7 @@ import { CommonModule } from "@angular/common"; import { Component, Inject } from "@angular/core"; import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { VaultViewPasswordHistoryService } from "@bitwarden/angular/services/view-password-history.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { EmergencyAccessId } from "@bitwarden/common/types/guid"; import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service"; @@ -21,8 +22,6 @@ import { DefaultChangeLoginPasswordService, } from "@bitwarden/vault"; -import { WebViewPasswordHistoryService } from "../../../../vault/services/web-view-password-history.service"; - export interface EmergencyViewDialogParams { /** The cipher being viewed. */ cipher: CipherView; @@ -42,7 +41,7 @@ class PremiumUpgradePromptNoop implements PremiumUpgradePromptService { standalone: true, imports: [ButtonModule, CipherViewComponent, DialogModule, CommonModule, JslibModule], providers: [ - { provide: ViewPasswordHistoryService, useClass: WebViewPasswordHistoryService }, + { provide: ViewPasswordHistoryService, useClass: VaultViewPasswordHistoryService }, { provide: PremiumUpgradePromptService, useClass: PremiumUpgradePromptNoop }, { provide: ChangeLoginPasswordService, useClass: DefaultChangeLoginPasswordService }, ], diff --git a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts index 10466503029..99159e7e2fc 100644 --- a/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts +++ b/apps/web/src/app/vault/components/vault-item-dialog/vault-item-dialog.component.ts @@ -7,6 +7,7 @@ import { firstValueFrom, Subject, switchMap } from "rxjs"; import { map } from "rxjs/operators"; import { CollectionView } from "@bitwarden/admin-console/common"; +import { VaultViewPasswordHistoryService } from "@bitwarden/angular/services/view-password-history.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; @@ -39,6 +40,9 @@ import { ToastService, } from "@bitwarden/components"; import { + AttachmentDialogCloseResult, + AttachmentDialogResult, + AttachmentsV2Component, ChangeLoginPasswordService, CipherFormComponent, CipherFormConfig, @@ -50,16 +54,10 @@ import { } from "@bitwarden/vault"; import { SharedModule } from "../../../shared/shared.module"; -import { - AttachmentDialogCloseResult, - AttachmentDialogResult, - AttachmentsV2Component, -} from "../../individual-vault/attachments-v2.component"; +import { WebVaultPremiumUpgradePromptService } from "../../../vault/services/web-premium-upgrade-prompt.service"; import { RoutedVaultFilterService } from "../../individual-vault/vault-filter/services/routed-vault-filter.service"; import { RoutedVaultFilterModel } from "../../individual-vault/vault-filter/shared/models/routed-vault-filter.model"; import { WebCipherFormGenerationService } from "../../services/web-cipher-form-generation.service"; -import { WebVaultPremiumUpgradePromptService } from "../../services/web-premium-upgrade-prompt.service"; -import { WebViewPasswordHistoryService } from "../../services/web-view-password-history.service"; export type VaultItemDialogMode = "view" | "form"; @@ -135,7 +133,7 @@ export enum VaultItemDialogResult { ], providers: [ { provide: PremiumUpgradePromptService, useClass: WebVaultPremiumUpgradePromptService }, - { provide: ViewPasswordHistoryService, useClass: WebViewPasswordHistoryService }, + { provide: ViewPasswordHistoryService, useClass: VaultViewPasswordHistoryService }, { provide: CipherFormGenerationService, useClass: WebCipherFormGenerationService }, RoutedVaultFilterService, { provide: ChangeLoginPasswordService, useClass: DefaultChangeLoginPasswordService }, diff --git a/apps/web/src/app/vault/individual-vault/add-edit-v2.component.ts b/apps/web/src/app/vault/individual-vault/add-edit-v2.component.ts index d9b42594d79..2eab6faec36 100644 --- a/apps/web/src/app/vault/individual-vault/add-edit-v2.component.ts +++ b/apps/web/src/app/vault/individual-vault/add-edit-v2.component.ts @@ -21,6 +21,7 @@ import { ItemModule, } from "@bitwarden/components"; import { + AttachmentsV2Component, CipherAttachmentsComponent, CipherFormConfig, CipherFormGenerationService, @@ -31,8 +32,6 @@ import { import { SharedModule } from "../../shared/shared.module"; import { WebCipherFormGenerationService } from "../services/web-cipher-form-generation.service"; -import { AttachmentsV2Component } from "./attachments-v2.component"; - /** * The result of the AddEditCipherDialogV2 component. */ diff --git a/apps/web/src/app/vault/individual-vault/vault.component.ts b/apps/web/src/app/vault/individual-vault/vault.component.ts index 7055f164a53..5f56ecc9e04 100644 --- a/apps/web/src/app/vault/individual-vault/vault.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault.component.ts @@ -69,6 +69,9 @@ import { DialogRef, DialogService, Icons, ToastService } from "@bitwarden/compon import { AddEditFolderDialogComponent, AddEditFolderDialogResult, + AttachmentDialogCloseResult, + AttachmentDialogResult, + AttachmentsV2Component, CipherFormConfig, CollectionAssignmentResult, DecryptionFailureDialogComponent, @@ -96,11 +99,6 @@ import { VaultItem } from "../components/vault-items/vault-item"; import { VaultItemEvent } from "../components/vault-items/vault-item-event"; import { VaultItemsModule } from "../components/vault-items/vault-items.module"; -import { - AttachmentDialogCloseResult, - AttachmentDialogResult, - AttachmentsV2Component, -} from "./attachments-v2.component"; import { BulkDeleteDialogResult, openBulkDeleteDialog, diff --git a/apps/web/src/app/vault/individual-vault/view.component.ts b/apps/web/src/app/vault/individual-vault/view.component.ts index 3e5cced7fa8..e7b06cbb8d6 100644 --- a/apps/web/src/app/vault/individual-vault/view.component.ts +++ b/apps/web/src/app/vault/individual-vault/view.component.ts @@ -5,6 +5,7 @@ import { Component, EventEmitter, Inject, OnInit } from "@angular/core"; import { firstValueFrom, map, Observable } from "rxjs"; import { CollectionView } from "@bitwarden/admin-console/common"; +import { VaultViewPasswordHistoryService } from "@bitwarden/angular/services/view-password-history.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; @@ -21,8 +22,8 @@ import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; import { DIALOG_DATA, - DialogConfig, DialogRef, + DialogConfig, AsyncActionsModule, DialogModule, DialogService, @@ -31,8 +32,7 @@ import { import { CipherViewComponent } from "@bitwarden/vault"; import { SharedModule } from "../../shared/shared.module"; -import { WebVaultPremiumUpgradePromptService } from "../services/web-premium-upgrade-prompt.service"; -import { WebViewPasswordHistoryService } from "../services/web-view-password-history.service"; +import { WebVaultPremiumUpgradePromptService } from "../../vault/services/web-premium-upgrade-prompt.service"; export interface ViewCipherDialogParams { cipher: CipherView; @@ -74,7 +74,7 @@ export interface ViewCipherDialogCloseResult { standalone: true, imports: [CipherViewComponent, CommonModule, AsyncActionsModule, DialogModule, SharedModule], providers: [ - { provide: ViewPasswordHistoryService, useClass: WebViewPasswordHistoryService }, + { provide: ViewPasswordHistoryService, useClass: VaultViewPasswordHistoryService }, { provide: PremiumUpgradePromptService, useClass: WebVaultPremiumUpgradePromptService }, ], }) diff --git a/apps/web/src/app/vault/services/web-view-password-history.service.spec.ts b/libs/angular/src/services/view-password-history.service.spec.ts similarity index 69% rename from apps/web/src/app/vault/services/web-view-password-history.service.spec.ts rename to libs/angular/src/services/view-password-history.service.spec.ts index a4f73ed1a2e..dec2b25b190 100644 --- a/apps/web/src/app/vault/services/web-view-password-history.service.spec.ts +++ b/libs/angular/src/services/view-password-history.service.spec.ts @@ -3,17 +3,16 @@ import { TestBed } from "@angular/core/testing"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { DialogService } from "@bitwarden/components"; +import { openPasswordHistoryDialog } from "@bitwarden/vault"; -import { openPasswordHistoryDialog } from "../individual-vault/password-history.component"; +import { VaultViewPasswordHistoryService } from "./view-password-history.service"; -import { WebViewPasswordHistoryService } from "./web-view-password-history.service"; - -jest.mock("../individual-vault/password-history.component", () => ({ +jest.mock("@bitwarden/vault", () => ({ openPasswordHistoryDialog: jest.fn(), })); -describe("WebViewPasswordHistoryService", () => { - let service: WebViewPasswordHistoryService; +describe("VaultViewPasswordHistoryService", () => { + let service: VaultViewPasswordHistoryService; let dialogService: DialogService; beforeEach(async () => { @@ -23,13 +22,13 @@ describe("WebViewPasswordHistoryService", () => { await TestBed.configureTestingModule({ providers: [ - WebViewPasswordHistoryService, + VaultViewPasswordHistoryService, { provide: DialogService, useValue: mockDialogService }, Overlay, ], }).compileComponents(); - service = TestBed.inject(WebViewPasswordHistoryService); + service = TestBed.inject(VaultViewPasswordHistoryService); dialogService = TestBed.inject(DialogService); }); diff --git a/apps/web/src/app/vault/services/web-view-password-history.service.ts b/libs/angular/src/services/view-password-history.service.ts similarity index 78% rename from apps/web/src/app/vault/services/web-view-password-history.service.ts rename to libs/angular/src/services/view-password-history.service.ts index b1451b268de..88ca4d37287 100644 --- a/apps/web/src/app/vault/services/web-view-password-history.service.ts +++ b/libs/angular/src/services/view-password-history.service.ts @@ -3,14 +3,13 @@ import { Injectable } from "@angular/core"; import { ViewPasswordHistoryService } from "@bitwarden/common/vault/abstractions/view-password-history.service"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { DialogService } from "@bitwarden/components"; - -import { openPasswordHistoryDialog } from "../individual-vault/password-history.component"; +import { openPasswordHistoryDialog } from "@bitwarden/vault"; /** - * This service is used to display the password history dialog in the web vault. + * This service is used to display the password history dialog in the vault. */ @Injectable() -export class WebViewPasswordHistoryService implements ViewPasswordHistoryService { +export class VaultViewPasswordHistoryService implements ViewPasswordHistoryService { constructor(private dialogService: DialogService) {} /** diff --git a/libs/angular/src/vault/components/vault-items.component.ts b/libs/angular/src/vault/components/vault-items.component.ts index 852302cc0c4..c34816994be 100644 --- a/libs/angular/src/vault/components/vault-items.component.ts +++ b/libs/angular/src/vault/components/vault-items.component.ts @@ -18,6 +18,7 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; @Directive() @@ -25,13 +26,14 @@ export class VaultItemsComponent implements OnInit, OnDestroy { @Input() activeCipherId: string = null; @Output() onCipherClicked = new EventEmitter(); @Output() onCipherRightClicked = new EventEmitter(); - @Output() onAddCipher = new EventEmitter(); + @Output() onAddCipher = new EventEmitter(); @Output() onAddCipherOptions = new EventEmitter(); loaded = false; ciphers: CipherView[] = []; deleted = false; organization: Organization; + CipherType = CipherType; protected searchPending = false; @@ -109,8 +111,8 @@ export class VaultItemsComponent implements OnInit, OnDestroy { this.onCipherRightClicked.emit(cipher); } - addCipher() { - this.onAddCipher.emit(); + addCipher(type?: CipherType) { + this.onAddCipher.emit(type); } addCipherOptions() { diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 33843932382..e353d79988f 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -57,6 +57,7 @@ export enum FeatureFlag { VaultBulkManagementAction = "vault-bulk-management-action", SecurityTasks = "security-tasks", CipherKeyEncryption = "cipher-key-encryption", + PM18520_UpdateDesktopCipherForm = "pm-18520-desktop-cipher-forms", EndUserNotifications = "pm-10609-end-user-notifications", /* Platform */ @@ -110,6 +111,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.VaultBulkManagementAction]: FALSE, [FeatureFlag.SecurityTasks]: FALSE, [FeatureFlag.CipherKeyEncryption]: FALSE, + [FeatureFlag.PM18520_UpdateDesktopCipherForm]: FALSE, [FeatureFlag.EndUserNotifications]: FALSE, /* Auth */ diff --git a/libs/vault/src/cipher-form/components/identity/identity.component.ts b/libs/vault/src/cipher-form/components/identity/identity.component.ts index 02bee40c2c7..f0c73002e97 100644 --- a/libs/vault/src/cipher-form/components/identity/identity.component.ts +++ b/libs/vault/src/cipher-form/components/identity/identity.component.ts @@ -132,22 +132,22 @@ export class IdentitySectionComponent implements OnInit { private initFromExistingCipher(existingIdentity: IdentityView) { this.identityForm.patchValue({ - firstName: this.initialValues.firstName ?? existingIdentity.firstName, - middleName: this.initialValues.middleName ?? existingIdentity.middleName, - lastName: this.initialValues.lastName ?? existingIdentity.lastName, - company: this.initialValues.company ?? existingIdentity.company, - ssn: this.initialValues.ssn ?? existingIdentity.ssn, - passportNumber: this.initialValues.passportNumber ?? existingIdentity.passportNumber, - licenseNumber: this.initialValues.licenseNumber ?? existingIdentity.licenseNumber, - email: this.initialValues.email ?? existingIdentity.email, - phone: this.initialValues.phone ?? existingIdentity.phone, - address1: this.initialValues.address1 ?? existingIdentity.address1, - address2: this.initialValues.address2 ?? existingIdentity.address2, - address3: this.initialValues.address3 ?? existingIdentity.address3, - city: this.initialValues.city ?? existingIdentity.city, - state: this.initialValues.state ?? existingIdentity.state, - postalCode: this.initialValues.postalCode ?? existingIdentity.postalCode, - country: this.initialValues.country ?? existingIdentity.country, + firstName: this.initialValues?.firstName ?? existingIdentity.firstName, + middleName: this.initialValues?.middleName ?? existingIdentity.middleName, + lastName: this.initialValues?.lastName ?? existingIdentity.lastName, + company: this.initialValues?.company ?? existingIdentity.company, + ssn: this.initialValues?.ssn ?? existingIdentity.ssn, + passportNumber: this.initialValues?.passportNumber ?? existingIdentity.passportNumber, + licenseNumber: this.initialValues?.licenseNumber ?? existingIdentity.licenseNumber, + email: this.initialValues?.email ?? existingIdentity.email, + phone: this.initialValues?.phone ?? existingIdentity.phone, + address1: this.initialValues?.address1 ?? existingIdentity.address1, + address2: this.initialValues?.address2 ?? existingIdentity.address2, + address3: this.initialValues?.address3 ?? existingIdentity.address3, + city: this.initialValues?.city ?? existingIdentity.city, + state: this.initialValues?.state ?? existingIdentity.state, + postalCode: this.initialValues?.postalCode ?? existingIdentity.postalCode, + country: this.initialValues?.country ?? existingIdentity.country, }); } diff --git a/apps/web/src/app/vault/individual-vault/attachments-v2.component.html b/libs/vault/src/cipher-view/attachments/attachments-v2.component.html similarity index 100% rename from apps/web/src/app/vault/individual-vault/attachments-v2.component.html rename to libs/vault/src/cipher-view/attachments/attachments-v2.component.html diff --git a/apps/web/src/app/vault/individual-vault/attachments-v2.component.spec.ts b/libs/vault/src/cipher-view/attachments/attachments-v2.component.spec.ts similarity index 100% rename from apps/web/src/app/vault/individual-vault/attachments-v2.component.spec.ts rename to libs/vault/src/cipher-view/attachments/attachments-v2.component.spec.ts diff --git a/apps/web/src/app/vault/individual-vault/attachments-v2.component.ts b/libs/vault/src/cipher-view/attachments/attachments-v2.component.ts similarity index 84% rename from apps/web/src/app/vault/individual-vault/attachments-v2.component.ts rename to libs/vault/src/cipher-view/attachments/attachments-v2.component.ts index 949a2fa81d9..fd823353099 100644 --- a/apps/web/src/app/vault/individual-vault/attachments-v2.component.ts +++ b/libs/vault/src/cipher-view/attachments/attachments-v2.component.ts @@ -4,10 +4,16 @@ import { CommonModule } from "@angular/common"; import { Component, Inject } from "@angular/core"; import { CipherId } from "@bitwarden/common/types/guid"; -import { DialogRef, DIALOG_DATA, DialogService } from "@bitwarden/components"; -import { CipherAttachmentsComponent } from "@bitwarden/vault"; +import { + ButtonModule, + DialogModule, + DialogService, + DIALOG_DATA, + DialogRef, +} from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; -import { SharedModule } from "../../shared/shared.module"; +import { CipherAttachmentsComponent } from "../../cipher-form/components/attachments/cipher-attachments.component"; export interface AttachmentsDialogParams { cipherId: CipherId; @@ -33,7 +39,7 @@ export interface AttachmentDialogCloseResult { selector: "app-vault-attachments-v2", templateUrl: "attachments-v2.component.html", standalone: true, - imports: [CommonModule, SharedModule, CipherAttachmentsComponent], + imports: [ButtonModule, CommonModule, DialogModule, I18nPipe, CipherAttachmentsComponent], }) export class AttachmentsV2Component { cipherId: CipherId; diff --git a/libs/vault/src/cipher-view/cipher-view.component.ts b/libs/vault/src/cipher-view/cipher-view.component.ts index 48b70271e43..e748fa46656 100644 --- a/libs/vault/src/cipher-view/cipher-view.component.ts +++ b/libs/vault/src/cipher-view/cipher-view.component.ts @@ -124,7 +124,8 @@ export class CipherViewComponent implements OnChanges, OnDestroy { } const { username, password, totp, fido2Credentials } = this.cipher.login; - return username || password || totp || fido2Credentials; + + return username || password || totp || fido2Credentials?.length > 0; } get hasAutofill() { diff --git a/libs/vault/src/cipher-view/index.ts b/libs/vault/src/cipher-view/index.ts index ec3c5235077..9bcdaaa4385 100644 --- a/libs/vault/src/cipher-view/index.ts +++ b/libs/vault/src/cipher-view/index.ts @@ -1,2 +1,3 @@ export * from "./cipher-view.component"; +export * from "./attachments/attachments-v2.component"; export { CipherAttachmentsComponent } from "../cipher-form/components/attachments/cipher-attachments.component"; diff --git a/apps/web/src/app/vault/individual-vault/password-history.component.html b/libs/vault/src/components/password-history/password-history.component.html similarity index 100% rename from apps/web/src/app/vault/individual-vault/password-history.component.html rename to libs/vault/src/components/password-history/password-history.component.html diff --git a/apps/web/src/app/vault/individual-vault/password-history.component.ts b/libs/vault/src/components/password-history/password-history.component.ts similarity index 95% rename from apps/web/src/app/vault/individual-vault/password-history.component.ts rename to libs/vault/src/components/password-history/password-history.component.ts index 5207e1b9e40..5af785d1a70 100644 --- a/apps/web/src/app/vault/individual-vault/password-history.component.ts +++ b/libs/vault/src/components/password-history/password-history.component.ts @@ -5,17 +5,17 @@ import { Inject, Component } from "@angular/core"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { - DIALOG_DATA, - DialogConfig, - DialogRef, AsyncActionsModule, + ButtonModule, DialogModule, DialogService, + DIALOG_DATA, + DialogRef, + DialogConfig, } from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; import { PasswordHistoryViewComponent } from "@bitwarden/vault"; -import { SharedModule } from "../../shared/shared.module"; - /** * The parameters for the password history dialog. */ @@ -31,10 +31,11 @@ export interface ViewPasswordHistoryDialogParams { templateUrl: "password-history.component.html", standalone: true, imports: [ + ButtonModule, CommonModule, AsyncActionsModule, + I18nPipe, DialogModule, - SharedModule, PasswordHistoryViewComponent, ], }) diff --git a/libs/vault/src/index.ts b/libs/vault/src/index.ts index 655b536de64..6e5a452ec8c 100644 --- a/libs/vault/src/index.ts +++ b/libs/vault/src/index.ts @@ -19,6 +19,7 @@ export { PasswordHistoryViewComponent } from "./components/password-history-view export { NewDeviceVerificationNoticePageOneComponent } from "./components/new-device-verification-notice/new-device-verification-notice-page-one.component"; export { NewDeviceVerificationNoticePageTwoComponent } from "./components/new-device-verification-notice/new-device-verification-notice-page-two.component"; export { DecryptionFailureDialogComponent } from "./components/decryption-failure-dialog/decryption-failure-dialog.component"; +export { openPasswordHistoryDialog } from "./components/password-history/password-history.component"; export * from "./components/add-edit-folder-dialog/add-edit-folder-dialog.component"; export * from "./components/carousel"; From 320d4f65fa6ce0fd3bb9cdc526e6c39ddb66f13a Mon Sep 17 00:00:00 2001 From: Daniel Riera Date: Wed, 23 Apr 2025 14:47:09 -0400 Subject: [PATCH 22/22] PM-20396 open view item vault pop out (#14342) * PM-20396 open view item vault pop out * add aria and clean up * format json * clean naming in messages * revert feature-flag.enum.ts * change username to item name * return nullish operator removed in testing * update tests to account for itemName * revert to anchor tag --- apps/browser/src/_locales/en/messages.json | 43 +++++++++++-------- .../abstractions/notification.background.ts | 4 ++ .../notification.background.spec.ts | 11 +++-- .../background/notification.background.ts | 28 ++++++++++-- .../notification/confirmation/body.ts | 5 ++- .../notification/confirmation/container.ts | 19 ++++---- .../notification/confirmation/message.ts | 17 +++++++- .../abstractions/notification-bar.ts | 2 +- apps/browser/src/autofill/notification/bar.ts | 16 +++---- 9 files changed, 99 insertions(+), 46 deletions(-) diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 462af12a352..d1d05a4e852 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -192,7 +192,7 @@ "message": "Copy", "description": "Copy to clipboard" }, - "fill":{ + "fill": { "message": "Fill", "description": "This string is used on the vault page to indicate autofilling. Horizontal space is limited in the interface here so try and keep translations as concise as possible." }, @@ -1062,6 +1062,15 @@ "notificationAddSave": { "message": "Save" }, + "notificationViewAria": { + "message": "View $ITEMNAME$, opens in new window", + "placeholders": { + "itemName": { + "content": "$1" + } + }, + "description": "Aria label for the view button in notification bar confirmation message" + }, "newNotification": { "message": "New notification" }, @@ -1075,23 +1084,23 @@ } } }, - "loginSaveSuccessDetails": { - "message": "$USERNAME$ saved to Bitwarden.", - "placeholders": { - "username": { - "content": "$1" - } - }, - "description": "Shown to user after login is saved." + "loginSaveConfirmation": { + "message": "$ITEMNAME$ saved to Bitwarden.", + "placeholders": { + "itemName": { + "content": "$1" + } + }, + "description": "Shown to user after item is saved." }, - "loginUpdatedSuccessDetails": { - "message": "$USERNAME$ updated in Bitwarden.", - "placeholders": { - "username": { - "content": "$1" - } - }, - "description": "Shown to user after login is updated." + "loginUpdatedConfirmation": { + "message": "$ITEMNAME$ updated in Bitwarden.", + "placeholders": { + "itemName": { + "content": "$1" + } + }, + "description": "Shown to user after item is updated." }, "saveAsNewLoginAction": { "message": "Save as new login", diff --git a/apps/browser/src/autofill/background/abstractions/notification.background.ts b/apps/browser/src/autofill/background/abstractions/notification.background.ts index 6b3c91a109c..c93fd9a3acf 100644 --- a/apps/browser/src/autofill/background/abstractions/notification.background.ts +++ b/apps/browser/src/autofill/background/abstractions/notification.background.ts @@ -101,6 +101,10 @@ type NotificationBackgroundExtensionMessageHandlers = { bgChangedPassword: ({ message, sender }: BackgroundOnMessageHandlerParams) => Promise; bgRemoveTabFromNotificationQueue: ({ sender }: BackgroundSenderParam) => void; bgSaveCipher: ({ message, sender }: BackgroundOnMessageHandlerParams) => void; + bgOpenViewVaultItemPopout: ({ + message, + sender, + }: BackgroundOnMessageHandlerParams) => Promise; bgOpenVault: ({ message, sender }: BackgroundOnMessageHandlerParams) => Promise; bgNeverSave: ({ sender }: BackgroundSenderParam) => Promise; bgUnlockPopoutOpened: ({ message, sender }: BackgroundOnMessageHandlerParams) => Promise; diff --git a/apps/browser/src/autofill/background/notification.background.spec.ts b/apps/browser/src/autofill/background/notification.background.spec.ts index ffc416ab62a..bb993fcf94b 100644 --- a/apps/browser/src/autofill/background/notification.background.spec.ts +++ b/apps/browser/src/autofill/background/notification.background.spec.ts @@ -823,6 +823,7 @@ describe("NotificationBackground", () => { notificationBackground["notificationQueue"] = [queueMessage]; const cipherView = mock({ id: "testId", + name: "testItemName", login: { username: "testUser" }, }); getDecryptedCipherByIdSpy.mockResolvedValueOnce(cipherView); @@ -844,8 +845,9 @@ describe("NotificationBackground", () => { sender.tab, "saveCipherAttemptCompleted", { - username: cipherView.login.username, + itemName: "testItemName", cipherId: cipherView.id, + task: undefined, }, ); }); @@ -899,7 +901,7 @@ describe("NotificationBackground", () => { const cipherView = mock({ id: mockCipherId, organizationId: mockOrgId, - login: { username: "testUser" }, + name: "Test Item", }); getDecryptedCipherByIdSpy.mockResolvedValueOnce(cipherView); @@ -921,11 +923,11 @@ describe("NotificationBackground", () => { "saveCipherAttemptCompleted", { cipherId: "testId", + itemName: "Test Item", task: { orgName: "Org Name, LLC", remainingTasksCount: 1, }, - username: "testUser", }, ); }); @@ -1074,6 +1076,7 @@ describe("NotificationBackground", () => { notificationBackground["notificationQueue"] = [queueMessage]; const cipherView = mock({ id: "testId", + name: "testName", login: { username: "test", password: "password" }, }); folderExistsSpy.mockResolvedValueOnce(false); @@ -1097,8 +1100,8 @@ describe("NotificationBackground", () => { sender.tab, "saveCipherAttemptCompleted", { - username: cipherView.login.username, cipherId: cipherView.id, + itemName: cipherView.name, }, ); expect(tabSendMessageSpy).toHaveBeenCalledWith(sender.tab, { command: "addedCipher" }); diff --git a/apps/browser/src/autofill/background/notification.background.ts b/apps/browser/src/autofill/background/notification.background.ts index 00d24300d78..dabb75b97b6 100644 --- a/apps/browser/src/autofill/background/notification.background.ts +++ b/apps/browser/src/autofill/background/notification.background.ts @@ -41,7 +41,10 @@ import { SecurityTask } from "@bitwarden/common/vault/tasks/models/security-task import { openUnlockPopout } from "../../auth/popup/utils/auth-popout-window"; import { BrowserApi } from "../../platform/browser/browser-api"; -import { openAddEditVaultItemPopout } from "../../vault/popup/utils/vault-popout-window"; +import { + openAddEditVaultItemPopout, + openViewVaultItemPopout, +} from "../../vault/popup/utils/vault-popout-window"; import { OrganizationCategory, OrganizationCategories, @@ -67,6 +70,7 @@ import { OverlayBackgroundExtensionMessage } from "./abstractions/overlay.backgr export default class NotificationBackground { private openUnlockPopout = openUnlockPopout; private openAddEditVaultItemPopout = openAddEditVaultItemPopout; + private openViewVaultItemPopout = openViewVaultItemPopout; private notificationQueue: NotificationQueueMessageItem[] = []; private allowedRetryCommands: Set = new Set([ ExtensionCommand.AutofillLogin, @@ -91,6 +95,7 @@ export default class NotificationBackground { bgGetOrgData: () => this.getOrgData(), bgNeverSave: ({ sender }) => this.saveNever(sender.tab), bgOpenVault: ({ message, sender }) => this.openVault(message, sender.tab), + bgOpenViewVaultItemPopout: ({ message, sender }) => this.viewItem(message, sender.tab), bgRemoveTabFromNotificationQueue: ({ sender }) => this.removeTabFromNotificationQueue(sender.tab), bgReopenUnlockPopout: ({ sender }) => this.openUnlockPopout(sender.tab), @@ -638,8 +643,8 @@ export default class NotificationBackground { try { await this.cipherService.createWithServer(cipher); await BrowserApi.tabSendMessageData(tab, "saveCipherAttemptCompleted", { - username: queueMessage?.username && String(queueMessage.username), - cipherId: cipher?.id && String(cipher.id), + itemName: newCipher?.name && String(newCipher?.name), + cipherId: cipher?.id && String(cipher?.id), }); await BrowserApi.tabSendMessage(tab, { command: "addedCipher" }); } catch (error) { @@ -701,7 +706,7 @@ export default class NotificationBackground { await this.cipherService.updateWithServer(cipher); await BrowserApi.tabSendMessageData(tab, "saveCipherAttemptCompleted", { - username: cipherView?.login?.username && String(cipherView.login.username), + itemName: cipherView?.name && String(cipherView?.name), cipherId: cipherView?.id && String(cipherView.id), task: taskData, }); @@ -754,6 +759,21 @@ export default class NotificationBackground { await this.openAddEditVaultItemPopout(senderTab, { cipherId: message.cipherId }); } + private async viewItem( + message: NotificationBackgroundExtensionMessage, + senderTab: chrome.tabs.Tab, + ) { + await Promise.all([ + this.openViewVaultItemPopout(senderTab, { + cipherId: message.cipherId, + action: null, + }), + BrowserApi.tabSendMessageData(senderTab, "closeNotificationBar", { + fadeOutNotification: !!message.fadeOutNotification, + }), + ]); + } + private async folderExists(folderId: string, userId: UserId) { if (Utils.isNullOrWhitespace(folderId) || folderId === "null") { return false; diff --git a/apps/browser/src/autofill/content/components/notification/confirmation/body.ts b/apps/browser/src/autofill/content/components/notification/confirmation/body.ts index d2ac7f36277..0508991c5da 100644 --- a/apps/browser/src/autofill/content/components/notification/confirmation/body.ts +++ b/apps/browser/src/autofill/content/components/notification/confirmation/body.ts @@ -16,16 +16,18 @@ const { css } = createEmotion({ export type NotificationConfirmationBodyProps = { buttonText: string; + itemName: string; confirmationMessage: string; error?: string; messageDetails?: string; tasksAreComplete?: boolean; theme: Theme; - handleOpenVault: (e: Event) => void; + handleOpenVault: () => void; }; export function NotificationConfirmationBody({ buttonText, + itemName, confirmationMessage, error, messageDetails, @@ -43,6 +45,7 @@ export function NotificationConfirmationBody({ ${showConfirmationMessage ? NotificationConfirmationMessage({ buttonText, + itemName, message: confirmationMessage, messageDetails, theme, diff --git a/apps/browser/src/autofill/content/components/notification/confirmation/container.ts b/apps/browser/src/autofill/content/components/notification/confirmation/container.ts index a071338af9a..5cc977cf4cb 100644 --- a/apps/browser/src/autofill/content/components/notification/confirmation/container.ts +++ b/apps/browser/src/autofill/content/components/notification/confirmation/container.ts @@ -20,14 +20,14 @@ import { NotificationConfirmationFooter } from "./footer"; export type NotificationConfirmationContainerProps = NotificationBarIframeInitData & { handleCloseNotification: (e: Event) => void; - handleOpenVault: (e: Event) => void; + handleOpenVault: () => void; handleOpenTasks: (e: Event) => void; } & { error?: string; i18n: { [key: string]: string }; + itemName: string; task?: NotificationTaskInfo; type: NotificationType; - username: string; }; export function NotificationConfirmationContainer({ @@ -36,13 +36,13 @@ export function NotificationConfirmationContainer({ handleOpenVault, handleOpenTasks, i18n, + itemName, task, theme = ThemeTypes.Light, type, - username, }: NotificationConfirmationContainerProps) { const headerMessage = getHeaderMessage(i18n, type, error); - const confirmationMessage = getConfirmationMessage(i18n, username, type, error); + const confirmationMessage = getConfirmationMessage(i18n, itemName, type, error); const buttonText = error ? i18n.newItem : i18n.view; let messageDetails: string | undefined; @@ -71,6 +71,7 @@ export function NotificationConfirmationContainer({ })} ${NotificationConfirmationBody({ buttonText, + itemName, confirmationMessage, tasksAreComplete, messageDetails, @@ -106,19 +107,17 @@ const notificationContainerStyles = (theme: Theme) => css` function getConfirmationMessage( i18n: { [key: string]: string }, - username: string, + itemName: string, type?: NotificationType, error?: string, ) { - const loginSaveSuccessDetails = chrome.i18n.getMessage("loginSaveSuccessDetails", [username]); - const loginUpdatedSuccessDetails = chrome.i18n.getMessage("loginUpdatedSuccessDetails", [ - username, - ]); + const loginSaveConfirmation = chrome.i18n.getMessage("loginSaveConfirmation", [itemName]); + const loginUpdatedConfirmation = chrome.i18n.getMessage("loginUpdatedConfirmation", [itemName]); if (error) { return i18n.saveFailureDetails; } - return type === "add" ? loginSaveSuccessDetails : loginUpdatedSuccessDetails; + return type === "add" ? loginSaveConfirmation : loginUpdatedConfirmation; } function getHeaderMessage( diff --git a/apps/browser/src/autofill/content/components/notification/confirmation/message.ts b/apps/browser/src/autofill/content/components/notification/confirmation/message.ts index c018371caff..3707e628370 100644 --- a/apps/browser/src/autofill/content/components/notification/confirmation/message.ts +++ b/apps/browser/src/autofill/content/components/notification/confirmation/message.ts @@ -7,19 +7,23 @@ import { themes, typography } from "../../constants/styles"; export type NotificationConfirmationMessageProps = { buttonText?: string; + itemName: string; message?: string; messageDetails?: string; - handleClick: (e: Event) => void; + handleClick: () => void; theme: Theme; }; export function NotificationConfirmationMessage({ buttonText, + itemName, message, messageDetails, handleClick, theme, }: NotificationConfirmationMessageProps) { + const buttonAria = chrome.i18n.getMessage("notificationViewAria", [itemName]); + return html`
${message || buttonText @@ -35,6 +39,10 @@ export function NotificationConfirmationMessage({ title=${buttonText} class=${notificationConfirmationButtonTextStyles(theme)} @click=${handleClick} + @keydown=${(e: KeyboardEvent) => handleButtonKeyDown(e, handleClick)} + aria-label=${buttonAria} + tabindex="0" + role="button" > ${buttonText} @@ -81,3 +89,10 @@ const AdditionalMessageStyles = ({ theme }: { theme: Theme }) => css` font-size: 14px; color: ${themes[theme].text.muted}; `; + +function handleButtonKeyDown(event: KeyboardEvent, handleClick: () => void) { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + handleClick(); + } +} diff --git a/apps/browser/src/autofill/notification/abstractions/notification-bar.ts b/apps/browser/src/autofill/notification/abstractions/notification-bar.ts index cbfeffcf2f4..9dd02b64154 100644 --- a/apps/browser/src/autofill/notification/abstractions/notification-bar.ts +++ b/apps/browser/src/autofill/notification/abstractions/notification-bar.ts @@ -33,7 +33,7 @@ type NotificationBarWindowMessage = { data?: { cipherId?: string; task?: NotificationTaskInfo; - username?: string; + itemName?: string; }; error?: string; initData?: NotificationBarIframeInitData; diff --git a/apps/browser/src/autofill/notification/bar.ts b/apps/browser/src/autofill/notification/bar.ts index 4e85d893178..fce05913e5e 100644 --- a/apps/browser/src/autofill/notification/bar.ts +++ b/apps/browser/src/autofill/notification/bar.ts @@ -56,9 +56,9 @@ function getI18n() { collection: chrome.i18n.getMessage("collection"), folder: chrome.i18n.getMessage("folder"), loginSaveSuccess: chrome.i18n.getMessage("loginSaveSuccess"), - loginSaveSuccessDetails: chrome.i18n.getMessage("loginSaveSuccessDetails"), + loginSaveConfirmation: chrome.i18n.getMessage("loginSaveConfirmation"), loginUpdateSuccess: chrome.i18n.getMessage("loginUpdateSuccess"), - loginUpdateSuccessDetails: chrome.i18n.getMessage("loginUpdatedSuccessDetails"), + loginUpdateConfirmation: chrome.i18n.getMessage("loginUpdatedConfirmation"), loginUpdateTaskSuccess: chrome.i18n.getMessage("loginUpdateTaskSuccess"), loginUpdateTaskSuccessAdditional: chrome.i18n.getMessage("loginUpdateTaskSuccessAdditional"), nextSecurityTaskAction: chrome.i18n.getMessage("nextSecurityTaskAction"), @@ -72,6 +72,7 @@ function getI18n() { notificationEdit: chrome.i18n.getMessage("edit"), notificationUnlock: chrome.i18n.getMessage("notificationUnlock"), notificationUnlockDesc: chrome.i18n.getMessage("notificationUnlockDesc"), + notificationViewAria: chrome.i18n.getMessage("notificationViewAria"), saveAction: chrome.i18n.getMessage("notificationAddSave"), saveAsNewLoginAction: chrome.i18n.getMessage("saveAsNewLoginAction"), saveFailure: chrome.i18n.getMessage("saveFailure"), @@ -349,10 +350,9 @@ function handleSaveCipherAttemptCompletedMessage(message: NotificationBarWindowM ); } -function openViewVaultItemPopout(e: Event, cipherId: string) { - e.preventDefault(); +function openViewVaultItemPopout(cipherId: string) { sendPlatformMessage({ - command: "bgOpenVault", + command: "bgOpenViewVaultItemPopout", cipherId, }); } @@ -360,7 +360,7 @@ function openViewVaultItemPopout(e: Event, cipherId: string) { function handleSaveCipherConfirmation(message: NotificationBarWindowMessage) { const { theme, type } = notificationBarIframeInitData; const { error, data } = message; - const { username, cipherId, task } = data || {}; + const { cipherId, task, itemName } = data || {}; const i18n = getI18n(); const resolvedTheme = getResolvedTheme(theme ?? ThemeTypes.Light); @@ -374,9 +374,9 @@ function handleSaveCipherConfirmation(message: NotificationBarWindowMessage) { handleCloseNotification, i18n, error, - username: username ?? i18n.typeLogin, + itemName: itemName ?? i18n.typeLogin, task, - handleOpenVault: (e) => cipherId && openViewVaultItemPopout(e, cipherId), + handleOpenVault: () => cipherId && openViewVaultItemPopout(cipherId), handleOpenTasks: () => sendPlatformMessage({ command: "bgOpenAtRisksPasswords" }), }), document.body,