From acf5b1e9e6860cb1a94eb7d1b46c9ca10f7af3b6 Mon Sep 17 00:00:00 2001 From: Andreas Coroiu Date: Thu, 21 Nov 2024 15:54:19 +0100 Subject: [PATCH 01/68] [PM-7382] Add support for non-UUID credential (#11993) * feat: add tests for guidToRawFormat * feat: add support for parsing b64 credential ids * refactor: change interface to use Uint8Array for simplification Technically this deviates from the specification, but nobody is going to be using the authenticator directly but us so it shouldn't matter. We're gonna switch to `passkey-rs` anyways so * feat: change how the authenticator parses credential ids to support b64 --- ...fido2-authenticator.service.abstraction.ts | 2 +- .../fido2/credential-id-utils.spec.ts | 68 +++++++++++++++++++ .../services/fido2/credential-id-utils.ts | 31 +++++++++ .../fido2/fido2-authenticator.service.spec.ts | 14 ++-- .../fido2/fido2-authenticator.service.ts | 29 ++++---- .../services/fido2/guid-utils.spec.ts | 28 ++++++++ 6 files changed, 148 insertions(+), 24 deletions(-) create mode 100644 libs/common/src/platform/services/fido2/credential-id-utils.spec.ts create mode 100644 libs/common/src/platform/services/fido2/credential-id-utils.ts create mode 100644 libs/common/src/platform/services/fido2/guid-utils.spec.ts diff --git a/libs/common/src/platform/abstractions/fido2/fido2-authenticator.service.abstraction.ts b/libs/common/src/platform/abstractions/fido2/fido2-authenticator.service.abstraction.ts index 535248e7ecd..d878d70b56e 100644 --- a/libs/common/src/platform/abstractions/fido2/fido2-authenticator.service.abstraction.ts +++ b/libs/common/src/platform/abstractions/fido2/fido2-authenticator.service.abstraction.ts @@ -64,7 +64,7 @@ export class Fido2AuthenticatorError extends Error { } export interface PublicKeyCredentialDescriptor { - id: BufferSource; + id: Uint8Array; transports?: ("ble" | "hybrid" | "internal" | "nfc" | "usb")[]; type: "public-key"; } diff --git a/libs/common/src/platform/services/fido2/credential-id-utils.spec.ts b/libs/common/src/platform/services/fido2/credential-id-utils.spec.ts new file mode 100644 index 00000000000..76e068ac01c --- /dev/null +++ b/libs/common/src/platform/services/fido2/credential-id-utils.spec.ts @@ -0,0 +1,68 @@ +import { compareCredentialIds, parseCredentialId } from "./credential-id-utils"; + +describe("credential-id-utils", () => { + describe("parseCredentialId", () => { + it("returns credentialId in binary format when given a valid UUID string", () => { + const result = parseCredentialId("08d70b74-e9f5-4522-a425-e5dcd40107e7"); + + expect(result).toEqual( + new Uint8Array([ + 0x08, 0xd7, 0x0b, 0x74, 0xe9, 0xf5, 0x45, 0x22, 0xa4, 0x25, 0xe5, 0xdc, 0xd4, 0x01, 0x07, + 0xe7, + ]), + ); + }); + + it("returns credentialId in binary format when given a valid Base64Url string", () => { + const result = parseCredentialId("b64.CNcLdOn1RSKkJeXc1AEH5w"); + + expect(result).toEqual( + new Uint8Array([ + 0x08, 0xd7, 0x0b, 0x74, 0xe9, 0xf5, 0x45, 0x22, 0xa4, 0x25, 0xe5, 0xdc, 0xd4, 0x01, 0x07, + 0xe7, + ]), + ); + }); + + it("returns undefined when given an invalid Base64 string", () => { + const result = parseCredentialId("b64.#$%&"); + + expect(result).toBeUndefined(); + }); + + it("returns undefined when given an invalid UUID string", () => { + const result = parseCredentialId("invalid"); + + expect(result).toBeUndefined(); + }); + }); + + describe("compareCredentialIds", () => { + it("returns true when the two credential IDs are equal", () => { + const a = new Uint8Array([0x01, 0x02, 0x03]); + const b = new Uint8Array([0x01, 0x02, 0x03]); + + const result = compareCredentialIds(a, b); + + expect(result).toBe(true); + }); + + it("returns false when the two credential IDs are not equal", () => { + const a = new Uint8Array([0x01, 0x02, 0x03]); + const b = new Uint8Array([0x01, 0x02, 0x04]); + + const result = compareCredentialIds(a, b); + + expect(result).toBe(false); + }); + + it("returns false when the two credential IDs have different lengths", () => { + const a = new Uint8Array([0x01, 0x02, 0x03]); + const b = new Uint8Array([0x01, 0x02, 0x03, 0x04]); + + const result = compareCredentialIds(a, b); + + expect(result).toBe(false); + }); + }); +}); diff --git a/libs/common/src/platform/services/fido2/credential-id-utils.ts b/libs/common/src/platform/services/fido2/credential-id-utils.ts new file mode 100644 index 00000000000..a548b8befd3 --- /dev/null +++ b/libs/common/src/platform/services/fido2/credential-id-utils.ts @@ -0,0 +1,31 @@ +import { Fido2Utils } from "./fido2-utils"; +import { guidToRawFormat } from "./guid-utils"; + +export function parseCredentialId(encodedCredentialId: string): Uint8Array { + try { + if (encodedCredentialId.startsWith("b64.")) { + return Fido2Utils.stringToBuffer(encodedCredentialId.slice(4)); + } + + return guidToRawFormat(encodedCredentialId); + } catch { + return undefined; + } +} + +/** + * Compares two credential IDs for equality. + */ +export function compareCredentialIds(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) { + return false; + } + + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) { + return false; + } + } + + return true; +} diff --git a/libs/common/src/platform/services/fido2/fido2-authenticator.service.spec.ts b/libs/common/src/platform/services/fido2/fido2-authenticator.service.spec.ts index 5f15005d71c..e3f79ff9d58 100644 --- a/libs/common/src/platform/services/fido2/fido2-authenticator.service.spec.ts +++ b/libs/common/src/platform/services/fido2/fido2-authenticator.service.spec.ts @@ -26,9 +26,9 @@ import { import { Utils } from "../../misc/utils"; import { CBOR } from "./cbor"; +import { parseCredentialId } from "./credential-id-utils"; import { AAGUID, Fido2AuthenticatorService } from "./fido2-authenticator.service"; import { Fido2Utils } from "./fido2-utils"; -import { guidToRawFormat } from "./guid-utils"; const RpId = "bitwarden.com"; @@ -139,7 +139,7 @@ describe("FidoAuthenticatorService", () => { params = await createParams({ excludeCredentialDescriptorList: [ { - id: guidToRawFormat(excludedCipher.login.fido2Credentials[0].credentialId), + id: parseCredentialId(excludedCipher.login.fido2Credentials[0].credentialId), type: "public-key", }, ], @@ -482,7 +482,7 @@ describe("FidoAuthenticatorService", () => { credentialId = Utils.newGuid(); params = await createParams({ allowCredentialDescriptorList: [ - { id: guidToRawFormat(credentialId), type: "public-key" }, + { id: parseCredentialId(credentialId), type: "public-key" }, ], rpId: RpId, }); @@ -546,7 +546,7 @@ describe("FidoAuthenticatorService", () => { let params: Fido2AuthenticatorGetAssertionParams; beforeEach(async () => { - credentialIds = [Utils.newGuid(), Utils.newGuid()]; + credentialIds = [Utils.newGuid(), "b64.Lb5SVTumSV6gYJpeWh3laA"]; ciphers = [ await createCipherView( { type: CipherType.Login }, @@ -559,7 +559,7 @@ describe("FidoAuthenticatorService", () => { ]; params = await createParams({ allowCredentialDescriptorList: credentialIds.map((credentialId) => ({ - id: guidToRawFormat(credentialId), + id: parseCredentialId(credentialId), type: "public-key", })), rpId: RpId, @@ -667,7 +667,7 @@ describe("FidoAuthenticatorService", () => { selectedCredentialId = credentialIds[0]; params = await createParams({ allowCredentialDescriptorList: credentialIds.map((credentialId) => ({ - id: guidToRawFormat(credentialId), + id: parseCredentialId(credentialId), type: "public-key", })), rpId: RpId, @@ -723,7 +723,7 @@ describe("FidoAuthenticatorService", () => { const flags = encAuthData.slice(32, 33); const counter = encAuthData.slice(33, 37); - expect(result.selectedCredential.id).toEqual(guidToRawFormat(selectedCredentialId)); + expect(result.selectedCredential.id).toEqual(parseCredentialId(selectedCredentialId)); expect(result.selectedCredential.userHandle).toEqual( Fido2Utils.stringToBuffer(fido2Credentials[0].userHandle), ); diff --git a/libs/common/src/platform/services/fido2/fido2-authenticator.service.ts b/libs/common/src/platform/services/fido2/fido2-authenticator.service.ts index 8f0523769d9..e5d3685b9c2 100644 --- a/libs/common/src/platform/services/fido2/fido2-authenticator.service.ts +++ b/libs/common/src/platform/services/fido2/fido2-authenticator.service.ts @@ -23,9 +23,10 @@ import { LogService } from "../../abstractions/log.service"; import { Utils } from "../../misc/utils"; import { CBOR } from "./cbor"; +import { compareCredentialIds, parseCredentialId } from "./credential-id-utils"; import { p1363ToDer } from "./ecdsa-utils"; import { Fido2Utils } from "./fido2-utils"; -import { guidToRawFormat, guidToStandardFormat } from "./guid-utils"; +import { guidToStandardFormat } from "./guid-utils"; // AAGUID: d548826e-79b4-db40-a3d8-11116f7e8349 export const AAGUID = new Uint8Array([ @@ -178,7 +179,7 @@ export class Fido2AuthenticatorService implements Fido2AuthenticatorServiceAbstr const authData = await generateAuthData({ rpId: params.rpEntity.id, - credentialId: guidToRawFormat(credentialId), + credentialId: parseCredentialId(credentialId), counter: fido2Credential.counter, userPresence: true, userVerification: userVerified, @@ -193,7 +194,7 @@ export class Fido2AuthenticatorService implements Fido2AuthenticatorServiceAbstr ); return { - credentialId: guidToRawFormat(credentialId), + credentialId: parseCredentialId(credentialId), attestationObject, authData, publicKey: pubKeyDer, @@ -313,7 +314,7 @@ export class Fido2AuthenticatorService implements Fido2AuthenticatorServiceAbstr const authenticatorData = await generateAuthData({ rpId: selectedFido2Credential.rpId, - credentialId: guidToRawFormat(selectedCredentialId), + credentialId: parseCredentialId(selectedCredentialId), counter: selectedFido2Credential.counter, userPresence: true, userVerification: userVerified, @@ -328,7 +329,7 @@ export class Fido2AuthenticatorService implements Fido2AuthenticatorServiceAbstr return { authenticatorData, selectedCredential: { - id: guidToRawFormat(selectedCredentialId), + id: parseCredentialId(selectedCredentialId), userHandle: Fido2Utils.stringToBuffer(selectedFido2Credential.userHandle), }, signature, @@ -412,16 +413,7 @@ export class Fido2AuthenticatorService implements Fido2AuthenticatorServiceAbstr credentials: PublicKeyCredentialDescriptor[], rpId: string, ): Promise { - const ids: string[] = []; - - for (const credential of credentials) { - try { - ids.push(guidToStandardFormat(credential.id)); - // eslint-disable-next-line no-empty - } catch {} - } - - if (ids.length === 0) { + if (credentials.length === 0) { return []; } @@ -432,7 +424,12 @@ export class Fido2AuthenticatorService implements Fido2AuthenticatorServiceAbstr cipher.type === CipherType.Login && cipher.login.hasFido2Credentials && cipher.login.fido2Credentials[0].rpId === rpId && - ids.includes(cipher.login.fido2Credentials[0].credentialId), + credentials.some((credential) => + compareCredentialIds( + credential.id, + parseCredentialId(cipher.login.fido2Credentials[0].credentialId), + ), + ), ); } diff --git a/libs/common/src/platform/services/fido2/guid-utils.spec.ts b/libs/common/src/platform/services/fido2/guid-utils.spec.ts new file mode 100644 index 00000000000..098ea4bee75 --- /dev/null +++ b/libs/common/src/platform/services/fido2/guid-utils.spec.ts @@ -0,0 +1,28 @@ +import { guidToRawFormat } from "./guid-utils"; + +describe("guid-utils", () => { + describe("guidToRawFormat", () => { + it.each([ + [ + "00000000-0000-0000-0000-000000000000", + [ + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, + ], + "08d70b74-e9f5-4522-a425-e5dcd40107e7", + [ + 0x08, 0xd7, 0x0b, 0x74, 0xe9, 0xf5, 0x45, 0x22, 0xa4, 0x25, 0xe5, 0xdc, 0xd4, 0x01, 0x07, + 0xe7, + ], + ], + ])("returns UUID in binary format when given a valid UUID string", (input, expected) => { + const result = guidToRawFormat(input); + + expect(result).toEqual(new Uint8Array(expected)); + }); + + it("throws an error when given an invalid UUID string", () => { + expect(() => guidToRawFormat("invalid")).toThrow(TypeError); + }); + }); +}); From 5873f0e89d0b25429369ae90ce6e3eccfe6bd28a Mon Sep 17 00:00:00 2001 From: Github Actions Date: Thu, 21 Nov 2024 16:14:06 +0000 Subject: [PATCH 02/68] Bumped client version(s) --- apps/browser/package.json | 2 +- apps/browser/src/manifest.json | 6 ++++-- apps/browser/src/manifest.v3.json | 2 +- apps/cli/package.json | 2 +- apps/desktop/package.json | 2 +- apps/desktop/src/package-lock.json | 4 ++-- apps/desktop/src/package.json | 2 +- apps/web/package.json | 2 +- package-lock.json | 8 ++++---- 9 files changed, 16 insertions(+), 14 deletions(-) diff --git a/apps/browser/package.json b/apps/browser/package.json index ed0a8d36342..53500a83d2b 100644 --- a/apps/browser/package.json +++ b/apps/browser/package.json @@ -1,6 +1,6 @@ { "name": "@bitwarden/browser", - "version": "2024.11.1", + "version": "2024.11.2", "scripts": { "build": "npm run build:chrome", "build:chrome": "cross-env BROWSER=chrome MANIFEST_VERSION=3 webpack", diff --git a/apps/browser/src/manifest.json b/apps/browser/src/manifest.json index 9c2d2d610bc..e81409e6e2f 100644 --- a/apps/browser/src/manifest.json +++ b/apps/browser/src/manifest.json @@ -2,7 +2,7 @@ "manifest_version": 2, "name": "__MSG_extName__", "short_name": "__MSG_appName__", - "version": "2024.11.1", + "version": "2024.11.2", "description": "__MSG_extDesc__", "default_locale": "en", "author": "Bitwarden Inc.", @@ -171,6 +171,8 @@ "open_at_install": false, "browser_style": false }, - "storage": { "managed_schema": "managed_schema.json" }, + "storage": { + "managed_schema": "managed_schema.json" + }, "__firefox__storage": null } diff --git a/apps/browser/src/manifest.v3.json b/apps/browser/src/manifest.v3.json index fb44505ae8d..1ece5626227 100644 --- a/apps/browser/src/manifest.v3.json +++ b/apps/browser/src/manifest.v3.json @@ -3,7 +3,7 @@ "minimum_chrome_version": "102.0", "name": "__MSG_extName__", "short_name": "__MSG_appName__", - "version": "2024.11.1", + "version": "2024.11.2", "description": "__MSG_extDesc__", "default_locale": "en", "author": "Bitwarden Inc.", diff --git a/apps/cli/package.json b/apps/cli/package.json index e48507373f5..8be8f9ca34a 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,7 +1,7 @@ { "name": "@bitwarden/cli", "description": "A secure and free password manager for all of your devices.", - "version": "2024.11.0", + "version": "2024.11.1", "keywords": [ "bitwarden", "password", diff --git a/apps/desktop/package.json b/apps/desktop/package.json index be285f2cc18..cfdd76ec21e 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@bitwarden/desktop", "description": "A secure and free password manager for all of your devices.", - "version": "2024.11.1", + "version": "2024.11.2", "keywords": [ "bitwarden", "password", diff --git a/apps/desktop/src/package-lock.json b/apps/desktop/src/package-lock.json index f47f0f6a281..85a47fa6b2f 100644 --- a/apps/desktop/src/package-lock.json +++ b/apps/desktop/src/package-lock.json @@ -1,12 +1,12 @@ { "name": "@bitwarden/desktop", - "version": "2024.11.1", + "version": "2024.11.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@bitwarden/desktop", - "version": "2024.11.1", + "version": "2024.11.2", "license": "GPL-3.0", "dependencies": { "@bitwarden/desktop-napi": "file:../desktop_native/napi", diff --git a/apps/desktop/src/package.json b/apps/desktop/src/package.json index ca700eff2c6..c23baf37acf 100644 --- a/apps/desktop/src/package.json +++ b/apps/desktop/src/package.json @@ -2,7 +2,7 @@ "name": "@bitwarden/desktop", "productName": "Bitwarden", "description": "A secure and free password manager for all of your devices.", - "version": "2024.11.1", + "version": "2024.11.2", "author": "Bitwarden Inc. (https://bitwarden.com)", "homepage": "https://bitwarden.com", "license": "GPL-3.0", diff --git a/apps/web/package.json b/apps/web/package.json index 5dd0e442f2d..02ee6babe92 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@bitwarden/web-vault", - "version": "2024.11.0", + "version": "2024.11.1", "scripts": { "build:oss": "webpack", "build:bit": "webpack -c ../../bitwarden_license/bit-web/webpack.config.js", diff --git a/package-lock.json b/package-lock.json index 3eb6b9322f6..226ceaca156 100644 --- a/package-lock.json +++ b/package-lock.json @@ -189,11 +189,11 @@ }, "apps/browser": { "name": "@bitwarden/browser", - "version": "2024.11.1" + "version": "2024.11.2" }, "apps/cli": { "name": "@bitwarden/cli", - "version": "2024.11.0", + "version": "2024.11.1", "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "@koa/multer": "3.0.2", @@ -229,7 +229,7 @@ }, "apps/desktop": { "name": "@bitwarden/desktop", - "version": "2024.11.1", + "version": "2024.11.2", "hasInstallScript": true, "license": "GPL-3.0" }, @@ -243,7 +243,7 @@ }, "apps/web": { "name": "@bitwarden/web-vault", - "version": "2024.11.0" + "version": "2024.11.1" }, "libs/admin-console": { "name": "@bitwarden/admin-console", From a9f570be06a480362e9607fd4570fb4523bac9da Mon Sep 17 00:00:00 2001 From: Daniel James Smith <2670567+djsmith85@users.noreply.github.com> Date: Thu, 21 Nov 2024 19:37:29 +0100 Subject: [PATCH 03/68] Fix toggle entry not displaying correctly on non-English clients (#12088) Co-authored-by: Daniel James Smith --- .../generator/components/src/password-generator.component.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/tools/generator/components/src/password-generator.component.ts b/libs/tools/generator/components/src/password-generator.component.ts index 715904dc984..affd162d398 100644 --- a/libs/tools/generator/components/src/password-generator.component.ts +++ b/libs/tools/generator/components/src/password-generator.component.ts @@ -249,7 +249,7 @@ export class PasswordGeneratorComponent implements OnInit, OnDestroy { private toOptions(algorithms: AlgorithmInfo[]) { const options: Option[] = algorithms.map((algorithm) => ({ value: algorithm.id, - label: this.i18nService.t(algorithm.name), + label: algorithm.name, })); return options; From 3732f1293ee66ecd085169d9deabbe9693bbcae6 Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Thu, 21 Nov 2024 13:10:20 -0800 Subject: [PATCH 04/68] Fix title missing in ssh-key edit/view in web (#12081) --- apps/web/src/app/vault/individual-vault/view.component.ts | 2 ++ 1 file changed, 2 insertions(+) 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 d30c453a4bd..779035c972b 100644 --- a/apps/web/src/app/vault/individual-vault/view.component.ts +++ b/apps/web/src/app/vault/individual-vault/view.component.ts @@ -184,6 +184,8 @@ export class ViewComponent implements OnInit { return this.i18nService.t("viewItemType", this.i18nService.t("typeCard").toLowerCase()); case CipherType.Identity: return this.i18nService.t("viewItemType", this.i18nService.t("typeIdentity").toLowerCase()); + case CipherType.SshKey: + return this.i18nService.t("viewItemType", this.i18nService.t("typeSshKey").toLowerCase()); default: return null; } From 228817b85f08f7b3cba927e4941095f87ca2b74c Mon Sep 17 00:00:00 2001 From: Daniel James Smith <2670567+djsmith85@users.noreply.github.com> Date: Thu, 21 Nov 2024 22:16:49 +0100 Subject: [PATCH 05/68] Ensure labels on nav-buttons can be translated (#12084) - Introduce using i18n by importing JslibModule - Use i18n within markup - Ensure navButtons.labels is a key that is present in the en/messages.json Co-authored-by: Daniel James Smith --- .../src/platform/popup/layout/popup-layout.stories.ts | 4 ++++ .../popup/layout/popup-tab-navigation.component.html | 2 +- .../popup/layout/popup-tab-navigation.component.ts | 11 ++++++----- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/apps/browser/src/platform/popup/layout/popup-layout.stories.ts b/apps/browser/src/platform/popup/layout/popup-layout.stories.ts index 5b9417a6a19..e80ac249ac1 100644 --- a/apps/browser/src/platform/popup/layout/popup-layout.stories.ts +++ b/apps/browser/src/platform/popup/layout/popup-layout.stories.ts @@ -313,6 +313,10 @@ export default { back: "Back", loading: "Loading", search: "Search", + vault: "Vault", + generator: "Generator", + send: "Send", + settings: "Settings", }); }, }, diff --git a/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.html b/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.html index e53e8905e69..78b859f33b1 100644 --- a/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.html +++ b/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.html @@ -28,7 +28,7 @@ aria-hidden="true" > - {{ button.label }} + {{ button.label | i18n }} 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 ced3f6462e9..e01b4efd71b 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 @@ -2,13 +2,14 @@ import { CommonModule } from "@angular/common"; import { Component } from "@angular/core"; import { RouterModule } from "@angular/router"; +import { JslibModule } from "@bitwarden/angular/jslib.module"; import { LinkModule } from "@bitwarden/components"; @Component({ selector: "popup-tab-navigation", templateUrl: "popup-tab-navigation.component.html", standalone: true, - imports: [CommonModule, LinkModule, RouterModule], + imports: [CommonModule, LinkModule, RouterModule, JslibModule], host: { class: "tw-block tw-h-full tw-w-full tw-flex tw-flex-col", }, @@ -16,25 +17,25 @@ import { LinkModule } from "@bitwarden/components"; export class PopupTabNavigationComponent { navButtons = [ { - label: "Vault", + label: "vault", page: "/tabs/vault", iconKey: "lock", iconKeyActive: "lock-f", }, { - label: "Generator", + label: "generator", page: "/tabs/generator", iconKey: "generate", iconKeyActive: "generate-f", }, { - label: "Send", + label: "send", page: "/tabs/send", iconKey: "send", iconKeyActive: "send-f", }, { - label: "Settings", + label: "settings", page: "/tabs/settings", iconKey: "cog", iconKeyActive: "cog-f", From 9f99454b377044b37a9a96a7976a67a4459e0cc8 Mon Sep 17 00:00:00 2001 From: rr-bw <102181210+rr-bw@users.noreply.github.com> Date: Thu, 21 Nov 2024 13:31:20 -0800 Subject: [PATCH 06/68] feat(auth): [PM-9693] Refresh LoginDecryptionOptionsComponent (#11782) Creates a refreshed and consolidated `LoginDecryptionOptionsComponent` for use on all visual clients, which will be used when the `UnauthenticatedExtensionUIRefresh` feature flag is on. --- apps/browser/src/_locales/en/messages.json | 15 + ...n-login-decryption-options.service.spec.ts | 64 ++++ ...ension-login-decryption-options.service.ts | 37 +++ ...ogin-decryption-options-v1.component.html} | 0 ... login-decryption-options-v1.component.ts} | 6 +- apps/browser/src/popup/app-routing.module.ts | 28 +- apps/browser/src/popup/app.module.ts | 4 +- .../src/popup/services/services.module.ts | 8 + apps/desktop/src/app/app-routing.module.ts | 26 +- ...ogin-decryption-options-v1.component.html} | 0 ... login-decryption-options-v1.component.ts} | 6 +- apps/desktop/src/auth/login/login.module.ts | 4 +- apps/desktop/src/locales/en/messages.json | 15 + apps/web/src/app/auth/core/services/index.ts | 1 + .../login-decryption-options/index.ts | 1 + ...b-login-decryption-options.service.spec.ts | 41 +++ .../web-login-decryption-options.service.ts | 33 ++ ...ogin-decryption-options-v1.component.html} | 0 ... login-decryption-options-v1.component.ts} | 6 +- apps/web/src/app/auth/login/login.module.ts | 6 +- apps/web/src/app/core/core.module.ts | 8 + apps/web/src/app/oss-routing.module.ts | 24 +- apps/web/src/locales/en/messages.json | 12 + ...-login-decryption-options-v1.component.ts} | 2 +- .../src/services/jslib-services.module.ts | 7 + libs/auth/src/angular/index.ts | 5 + ...t-login-decryption-options.service.spec.ts | 37 +++ ...efault-login-decryption-options.service.ts | 15 + .../login-decryption-options.component.html | 60 ++++ .../login-decryption-options.component.ts | 299 ++++++++++++++++++ .../login-decryption-options.service.ts | 10 + 31 files changed, 742 insertions(+), 38 deletions(-) create mode 100644 apps/browser/src/auth/popup/login-decryption-options/extension-login-decryption-options.service.spec.ts create mode 100644 apps/browser/src/auth/popup/login-decryption-options/extension-login-decryption-options.service.ts rename apps/browser/src/auth/popup/login-decryption-options/{login-decryption-options.component.html => login-decryption-options-v1.component.html} (100%) rename apps/browser/src/auth/popup/login-decryption-options/{login-decryption-options.component.ts => login-decryption-options-v1.component.ts} (80%) rename apps/desktop/src/auth/login/login-decryption-options/{login-decryption-options.component.html => login-decryption-options-v1.component.html} (100%) rename apps/desktop/src/auth/login/login-decryption-options/{login-decryption-options.component.ts => login-decryption-options-v1.component.ts} (56%) create mode 100644 apps/web/src/app/auth/core/services/login-decryption-options/index.ts create mode 100644 apps/web/src/app/auth/core/services/login-decryption-options/web-login-decryption-options.service.spec.ts create mode 100644 apps/web/src/app/auth/core/services/login-decryption-options/web-login-decryption-options.service.ts rename apps/web/src/app/auth/login/login-decryption-options/{login-decryption-options.component.html => login-decryption-options-v1.component.html} (100%) rename apps/web/src/app/auth/login/login-decryption-options/{login-decryption-options.component.ts => login-decryption-options-v1.component.ts} (78%) rename libs/angular/src/auth/components/{base-login-decryption-options.component.ts => base-login-decryption-options-v1.component.ts} (99%) create mode 100644 libs/auth/src/angular/login-decryption-options/default-login-decryption-options.service.spec.ts create mode 100644 libs/auth/src/angular/login-decryption-options/default-login-decryption-options.service.ts create mode 100644 libs/auth/src/angular/login-decryption-options/login-decryption-options.component.html create mode 100644 libs/auth/src/angular/login-decryption-options/login-decryption-options.component.ts create mode 100644 libs/auth/src/angular/login-decryption-options/login-decryption-options.service.ts diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 0dc93dd0b32..5200cf81d09 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -3287,9 +3287,18 @@ "opensInANewWindow": { "message": "Opens in a new window" }, + "rememberThisDeviceToMakeFutureLoginsSeamless": { + "message": "Remember this device to make future logins seamless" + }, "deviceApprovalRequired": { "message": "Device approval required. Select an approval option below:" }, + "deviceApprovalRequiredV2": { + "message": "Device approval required" + }, + "selectAnApprovalOptionBelow": { + "message": "Select an approval option below" + }, "rememberThisDevice": { "message": "Remember this device" }, @@ -3363,6 +3372,9 @@ "userEmailMissing": { "message": "User email missing" }, + "activeUserEmailNotFoundLoggingYouOut": { + "message": "Active user email not found. Logging you out." + }, "deviceTrusted": { "message": "Device trusted" }, @@ -3799,6 +3811,9 @@ "accessing": { "message": "Accessing" }, + "loggedInExclamation": { + "message": "Logged in!" + }, "passkeyNotCopied": { "message": "Passkey will not be copied" }, diff --git a/apps/browser/src/auth/popup/login-decryption-options/extension-login-decryption-options.service.spec.ts b/apps/browser/src/auth/popup/login-decryption-options/extension-login-decryption-options.service.spec.ts new file mode 100644 index 00000000000..8f3199cdfce --- /dev/null +++ b/apps/browser/src/auth/popup/login-decryption-options/extension-login-decryption-options.service.spec.ts @@ -0,0 +1,64 @@ +import { Router } from "@angular/router"; +import { MockProxy, mock } from "jest-mock-extended"; +import { BehaviorSubject } from "rxjs"; + +import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; + +import { postLogoutMessageListener$ } from "../utils/post-logout-message-listener"; + +import { ExtensionLoginDecryptionOptionsService } from "./extension-login-decryption-options.service"; + +// Mock the module providing postLogoutMessageListener$ +jest.mock("../utils/post-logout-message-listener", () => { + return { + postLogoutMessageListener$: new BehaviorSubject(""), // Replace with mock subject + }; +}); + +describe("ExtensionLoginDecryptionOptionsService", () => { + let service: ExtensionLoginDecryptionOptionsService; + + let messagingService: MockProxy; + let router: MockProxy; + let postLogoutMessageSubject: BehaviorSubject; + + beforeEach(() => { + messagingService = mock(); + router = mock(); + + // Cast postLogoutMessageListener$ to BehaviorSubject for dynamic control + postLogoutMessageSubject = postLogoutMessageListener$ as BehaviorSubject; + + service = new ExtensionLoginDecryptionOptionsService(messagingService, router); + }); + + it("should instantiate the service", () => { + expect(service).not.toBeFalsy(); + }); + + describe("logOut()", () => { + it("should send a logout message", async () => { + postLogoutMessageSubject.next("switchAccountFinish"); + + await service.logOut(); + + expect(messagingService.send).toHaveBeenCalledWith("logout"); + }); + + it("should navigate to root on 'switchAccountFinish'", async () => { + postLogoutMessageSubject.next("switchAccountFinish"); + + await service.logOut(); + + expect(router.navigate).toHaveBeenCalledWith(["/"]); + }); + + it("should not navigate for 'doneLoggingOut'", async () => { + postLogoutMessageSubject.next("doneLoggingOut"); + + await service.logOut(); + + expect(router.navigate).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/browser/src/auth/popup/login-decryption-options/extension-login-decryption-options.service.ts b/apps/browser/src/auth/popup/login-decryption-options/extension-login-decryption-options.service.ts new file mode 100644 index 00000000000..ea529e277e6 --- /dev/null +++ b/apps/browser/src/auth/popup/login-decryption-options/extension-login-decryption-options.service.ts @@ -0,0 +1,37 @@ +import { Router } from "@angular/router"; +import { firstValueFrom } from "rxjs"; + +import { + DefaultLoginDecryptionOptionsService, + LoginDecryptionOptionsService, +} from "@bitwarden/auth/angular"; +import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; + +import { postLogoutMessageListener$ } from "../utils/post-logout-message-listener"; + +export class ExtensionLoginDecryptionOptionsService + extends DefaultLoginDecryptionOptionsService + implements LoginDecryptionOptionsService +{ + constructor( + protected messagingService: MessagingService, + private router: Router, + ) { + super(messagingService); + } + + override async logOut(): Promise { + // start listening for "switchAccountFinish" or "doneLoggingOut" + const messagePromise = firstValueFrom(postLogoutMessageListener$); + + super.logOut(); + + // wait for messages + const command = await messagePromise; + + // doneLoggingOut already has a message handler that will navigate us + if (command === "switchAccountFinish") { + await this.router.navigate(["/"]); + } + } +} diff --git a/apps/browser/src/auth/popup/login-decryption-options/login-decryption-options.component.html b/apps/browser/src/auth/popup/login-decryption-options/login-decryption-options-v1.component.html similarity index 100% rename from apps/browser/src/auth/popup/login-decryption-options/login-decryption-options.component.html rename to apps/browser/src/auth/popup/login-decryption-options/login-decryption-options-v1.component.html diff --git a/apps/browser/src/auth/popup/login-decryption-options/login-decryption-options.component.ts b/apps/browser/src/auth/popup/login-decryption-options/login-decryption-options-v1.component.ts similarity index 80% rename from apps/browser/src/auth/popup/login-decryption-options/login-decryption-options.component.ts rename to apps/browser/src/auth/popup/login-decryption-options/login-decryption-options-v1.component.ts index 6231b027749..bd8f808c910 100644 --- a/apps/browser/src/auth/popup/login-decryption-options/login-decryption-options.component.ts +++ b/apps/browser/src/auth/popup/login-decryption-options/login-decryption-options-v1.component.ts @@ -1,15 +1,15 @@ import { Component } from "@angular/core"; import { firstValueFrom } from "rxjs"; -import { BaseLoginDecryptionOptionsComponent } from "@bitwarden/angular/auth/components/base-login-decryption-options.component"; +import { BaseLoginDecryptionOptionsComponentV1 } from "@bitwarden/angular/auth/components/base-login-decryption-options-v1.component"; import { postLogoutMessageListener$ } from "../utils/post-logout-message-listener"; @Component({ selector: "browser-login-decryption-options", - templateUrl: "login-decryption-options.component.html", + templateUrl: "login-decryption-options-v1.component.html", }) -export class LoginDecryptionOptionsComponent extends BaseLoginDecryptionOptionsComponent { +export class LoginDecryptionOptionsComponentV1 extends BaseLoginDecryptionOptionsComponentV1 { override async createUser(): Promise { try { await super.createUser(); diff --git a/apps/browser/src/popup/app-routing.module.ts b/apps/browser/src/popup/app-routing.module.ts index ba8ab1e7aaf..5e6f38e80b0 100644 --- a/apps/browser/src/popup/app-routing.module.ts +++ b/apps/browser/src/popup/app-routing.module.ts @@ -21,7 +21,6 @@ import { extensionRefreshSwap } from "@bitwarden/angular/utils/extension-refresh import { AnonLayoutWrapperComponent, AnonLayoutWrapperData, - DevicesIcon, LoginComponent, LoginSecondaryContentComponent, LockIcon, @@ -37,6 +36,8 @@ import { SetPasswordJitComponent, UserLockIcon, VaultIcon, + LoginDecryptionOptionsComponent, + DevicesIcon, } from "@bitwarden/auth/angular"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; @@ -51,7 +52,7 @@ import { import { HintComponent } from "../auth/popup/hint.component"; import { HomeComponent } from "../auth/popup/home.component"; import { LockComponent } from "../auth/popup/lock.component"; -import { LoginDecryptionOptionsComponent } from "../auth/popup/login-decryption-options/login-decryption-options.component"; +import { LoginDecryptionOptionsComponentV1 } from "../auth/popup/login-decryption-options/login-decryption-options-v1.component"; import { LoginComponentV1 } from "../auth/popup/login-v1.component"; import { LoginViaAuthRequestComponentV1 } from "../auth/popup/login-via-auth-request-v1.component"; import { RegisterComponent } from "../auth/popup/register.component"; @@ -206,12 +207,6 @@ const routes: Routes = [ canActivate: [unauthGuardFn(unauthRouteOverrides)], data: { state: "2fa-options" } satisfies RouteDataProperties, }, - { - path: "login-initiated", - component: LoginDecryptionOptionsComponent, - canActivate: [tdeDecryptionRequiredGuard()], - data: { state: "login-initiated" } satisfies RouteDataProperties, - }, { path: "sso", component: SsoComponent, @@ -534,6 +529,23 @@ const routes: Routes = [ ], }, ), + ...unauthUiRefreshSwap( + LoginDecryptionOptionsComponentV1, + ExtensionAnonLayoutWrapperComponent, + { + path: "login-initiated", + canActivate: [tdeDecryptionRequiredGuard()], + data: { state: "login-initiated" } satisfies RouteDataProperties, + }, + { + path: "login-initiated", + canActivate: [tdeDecryptionRequiredGuard()], + data: { + pageIcon: DevicesIcon, + }, + children: [{ path: "", component: LoginDecryptionOptionsComponent }], + }, + ), { path: "", component: ExtensionAnonLayoutWrapperComponent, diff --git a/apps/browser/src/popup/app.module.ts b/apps/browser/src/popup/app.module.ts index d6e46de6ba0..d637f695e81 100644 --- a/apps/browser/src/popup/app.module.ts +++ b/apps/browser/src/popup/app.module.ts @@ -24,7 +24,7 @@ import { ExtensionAnonLayoutWrapperComponent } from "../auth/popup/extension-ano import { HintComponent } from "../auth/popup/hint.component"; import { HomeComponent } from "../auth/popup/home.component"; import { LockComponent } from "../auth/popup/lock.component"; -import { LoginDecryptionOptionsComponent } from "../auth/popup/login-decryption-options/login-decryption-options.component"; +import { LoginDecryptionOptionsComponentV1 } from "../auth/popup/login-decryption-options/login-decryption-options-v1.component"; import { LoginComponentV1 } from "../auth/popup/login-v1.component"; import { LoginViaAuthRequestComponentV1 } from "../auth/popup/login-via-auth-request-v1.component"; import { RegisterComponent } from "../auth/popup/register.component"; @@ -161,7 +161,7 @@ import "../platform/popup/locales"; LockComponent, LoginViaAuthRequestComponentV1, LoginComponentV1, - LoginDecryptionOptionsComponent, + LoginDecryptionOptionsComponentV1, NotificationsSettingsV1Component, AppearanceComponent, GeneratorComponent, diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index 9a1acb54ab7..b68102033bb 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -1,4 +1,5 @@ import { APP_INITIALIZER, NgModule, NgZone } from "@angular/core"; +import { Router } from "@angular/router"; import { Subject, merge, of } from "rxjs"; import { CollectionService } from "@bitwarden/admin-console/common"; @@ -22,6 +23,7 @@ import { AnonLayoutWrapperDataService, LoginComponentService, LockComponentService, + LoginDecryptionOptionsService, } from "@bitwarden/auth/angular"; import { LockService, LoginEmailService, PinServiceAbstraction } from "@bitwarden/auth/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; @@ -115,6 +117,7 @@ import { PasswordRepromptService } from "@bitwarden/vault"; import { ForegroundLockService } from "../../auth/popup/accounts/foreground-lock.service"; import { ExtensionAnonLayoutWrapperDataService } from "../../auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper-data.service"; import { ExtensionLoginComponentService } from "../../auth/popup/login/extension-login-component.service"; +import { ExtensionLoginDecryptionOptionsService } from "../../auth/popup/login-decryption-options/extension-login-decryption-options.service"; import { AutofillService as AutofillServiceAbstraction } from "../../autofill/services/abstractions/autofill.service"; import AutofillService from "../../autofill/services/autofill.service"; import { InlineMenuFieldQualificationService } from "../../autofill/services/inline-menu-field-qualification.service"; @@ -591,6 +594,11 @@ const safeProviders: SafeProvider[] = [ useExisting: PopupCompactModeService, deps: [], }), + safeProvider({ + provide: LoginDecryptionOptionsService, + useClass: ExtensionLoginDecryptionOptionsService, + deps: [MessagingServiceAbstraction, Router], + }), ]; @NgModule({ diff --git a/apps/desktop/src/app/app-routing.module.ts b/apps/desktop/src/app/app-routing.module.ts index c1e4fd18692..e61335859c4 100644 --- a/apps/desktop/src/app/app-routing.module.ts +++ b/apps/desktop/src/app/app-routing.module.ts @@ -18,7 +18,6 @@ import { extensionRefreshRedirect } from "@bitwarden/angular/utils/extension-ref import { AnonLayoutWrapperComponent, AnonLayoutWrapperData, - DevicesIcon, LoginComponent, LoginSecondaryContentComponent, LockIcon, @@ -34,6 +33,8 @@ import { SetPasswordJitComponent, UserLockIcon, VaultIcon, + LoginDecryptionOptionsComponent, + DevicesIcon, } from "@bitwarden/auth/angular"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; @@ -42,7 +43,7 @@ import { AccessibilityCookieComponent } from "../auth/accessibility-cookie.compo import { maxAccountsGuardFn } from "../auth/guards/max-accounts.guard"; import { HintComponent } from "../auth/hint.component"; import { LockComponent } from "../auth/lock.component"; -import { LoginDecryptionOptionsComponent } from "../auth/login/login-decryption-options/login-decryption-options.component"; +import { LoginDecryptionOptionsComponentV1 } from "../auth/login/login-decryption-options/login-decryption-options-v1.component"; import { LoginComponentV1 } from "../auth/login/login-v1.component"; import { LoginViaAuthRequestComponentV1 } from "../auth/login/login-via-auth-request-v1.component"; import { RegisterComponent } from "../auth/register.component"; @@ -95,11 +96,6 @@ const routes: Routes = [ ], }, ), - { - path: "login-initiated", - component: LoginDecryptionOptionsComponent, - canActivate: [tdeDecryptionRequiredGuard()], - }, { path: "register", component: RegisterComponent }, { path: "vault", @@ -241,6 +237,22 @@ const routes: Routes = [ ], }, ), + ...unauthUiRefreshSwap( + LoginDecryptionOptionsComponentV1, + AnonLayoutWrapperComponent, + { + path: "login-initiated", + canActivate: [tdeDecryptionRequiredGuard()], + }, + { + path: "login-initiated", + canActivate: [tdeDecryptionRequiredGuard()], + data: { + pageIcon: DevicesIcon, + }, + children: [{ path: "", component: LoginDecryptionOptionsComponent }], + }, + ), { path: "", component: AnonLayoutWrapperComponent, diff --git a/apps/desktop/src/auth/login/login-decryption-options/login-decryption-options.component.html b/apps/desktop/src/auth/login/login-decryption-options/login-decryption-options-v1.component.html similarity index 100% rename from apps/desktop/src/auth/login/login-decryption-options/login-decryption-options.component.html rename to apps/desktop/src/auth/login/login-decryption-options/login-decryption-options-v1.component.html diff --git a/apps/desktop/src/auth/login/login-decryption-options/login-decryption-options.component.ts b/apps/desktop/src/auth/login/login-decryption-options/login-decryption-options-v1.component.ts similarity index 56% rename from apps/desktop/src/auth/login/login-decryption-options/login-decryption-options.component.ts rename to apps/desktop/src/auth/login/login-decryption-options/login-decryption-options-v1.component.ts index f64ec977ce7..d9cc07adb7e 100644 --- a/apps/desktop/src/auth/login/login-decryption-options/login-decryption-options.component.ts +++ b/apps/desktop/src/auth/login/login-decryption-options/login-decryption-options-v1.component.ts @@ -1,12 +1,12 @@ import { Component } from "@angular/core"; -import { BaseLoginDecryptionOptionsComponent } from "@bitwarden/angular/auth/components/base-login-decryption-options.component"; +import { BaseLoginDecryptionOptionsComponentV1 } from "@bitwarden/angular/auth/components/base-login-decryption-options-v1.component"; @Component({ selector: "desktop-login-decryption-options", - templateUrl: "login-decryption-options.component.html", + templateUrl: "login-decryption-options-v1.component.html", }) -export class LoginDecryptionOptionsComponent extends BaseLoginDecryptionOptionsComponent { +export class LoginDecryptionOptionsComponentV1 extends BaseLoginDecryptionOptionsComponentV1 { override async createUser(): Promise { try { await super.createUser(); diff --git a/apps/desktop/src/auth/login/login.module.ts b/apps/desktop/src/auth/login/login.module.ts index 20c0bc97c6c..427cbcb2069 100644 --- a/apps/desktop/src/auth/login/login.module.ts +++ b/apps/desktop/src/auth/login/login.module.ts @@ -5,7 +5,7 @@ import { EnvironmentSelectorComponent } from "@bitwarden/angular/auth/components import { SharedModule } from "../../app/shared/shared.module"; -import { LoginDecryptionOptionsComponent } from "./login-decryption-options/login-decryption-options.component"; +import { LoginDecryptionOptionsComponentV1 } from "./login-decryption-options/login-decryption-options-v1.component"; import { LoginComponentV1 } from "./login-v1.component"; import { LoginViaAuthRequestComponentV1 } from "./login-via-auth-request-v1.component"; @@ -15,7 +15,7 @@ import { LoginViaAuthRequestComponentV1 } from "./login-via-auth-request-v1.comp LoginComponentV1, LoginViaAuthRequestComponentV1, EnvironmentSelectorComponent, - LoginDecryptionOptionsComponent, + LoginDecryptionOptionsComponentV1, ], exports: [LoginComponentV1, LoginViaAuthRequestComponentV1], }) diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index e9f26d23e94..ab6fb586948 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -2832,6 +2832,9 @@ "checkForBreaches": { "message": "Check known data breaches for this password" }, + "loggedInExclamation": { + "message": "Logged in!" + }, "important": { "message": "Important:" }, @@ -2862,9 +2865,18 @@ "windowsBiometricUpdateWarningTitle": { "message": "Recommended Settings Update" }, + "rememberThisDeviceToMakeFutureLoginsSeamless": { + "message": "Remember this device to make future logins seamless" + }, "deviceApprovalRequired": { "message": "Device approval required. Select an approval option below:" }, + "deviceApprovalRequiredV2": { + "message": "Device approval required" + }, + "selectAnApprovalOptionBelow": { + "message": "Select an approval option below" + }, "rememberThisDevice": { "message": "Remember this device" }, @@ -2917,6 +2929,9 @@ "userEmailMissing": { "message": "User email missing" }, + "activeUserEmailNotFoundLoggingYouOut": { + "message": "Active user email not found. Logging you out." + }, "deviceTrusted": { "message": "Device trusted" }, diff --git a/apps/web/src/app/auth/core/services/index.ts b/apps/web/src/app/auth/core/services/index.ts index a2e674c2a95..c14292d7c6d 100644 --- a/apps/web/src/app/auth/core/services/index.ts +++ b/apps/web/src/app/auth/core/services/index.ts @@ -1,4 +1,5 @@ export * from "./login"; +export * from "./login-decryption-options"; export * from "./webauthn-login"; export * from "./set-password-jit"; export * from "./registration"; diff --git a/apps/web/src/app/auth/core/services/login-decryption-options/index.ts b/apps/web/src/app/auth/core/services/login-decryption-options/index.ts new file mode 100644 index 00000000000..f0ff30b8727 --- /dev/null +++ b/apps/web/src/app/auth/core/services/login-decryption-options/index.ts @@ -0,0 +1 @@ +export * from "./web-login-decryption-options.service"; diff --git a/apps/web/src/app/auth/core/services/login-decryption-options/web-login-decryption-options.service.spec.ts b/apps/web/src/app/auth/core/services/login-decryption-options/web-login-decryption-options.service.spec.ts new file mode 100644 index 00000000000..31df33a6ece --- /dev/null +++ b/apps/web/src/app/auth/core/services/login-decryption-options/web-login-decryption-options.service.spec.ts @@ -0,0 +1,41 @@ +import { MockProxy, mock } from "jest-mock-extended"; + +import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; + +import { RouterService } from "../../../../core/router.service"; +import { AcceptOrganizationInviteService } from "../../../organization-invite/accept-organization.service"; + +import { WebLoginDecryptionOptionsService } from "./web-login-decryption-options.service"; + +describe("WebLoginDecryptionOptionsService", () => { + let service: WebLoginDecryptionOptionsService; + + let messagingService: MockProxy; + let routerService: MockProxy; + let acceptOrganizationInviteService: MockProxy; + + beforeEach(() => { + messagingService = mock(); + routerService = mock(); + acceptOrganizationInviteService = mock(); + + service = new WebLoginDecryptionOptionsService( + messagingService, + routerService, + acceptOrganizationInviteService, + ); + }); + + it("should instantiate the service", () => { + expect(service).not.toBeFalsy(); + }); + + describe("handleCreateUserSuccess()", () => { + it("should clear the redirect URL and the org invite", async () => { + await service.handleCreateUserSuccess(); + + expect(routerService.getAndClearLoginRedirectUrl).toHaveBeenCalled(); + expect(acceptOrganizationInviteService.clearOrganizationInvitation).toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/web/src/app/auth/core/services/login-decryption-options/web-login-decryption-options.service.ts b/apps/web/src/app/auth/core/services/login-decryption-options/web-login-decryption-options.service.ts new file mode 100644 index 00000000000..30654decdc3 --- /dev/null +++ b/apps/web/src/app/auth/core/services/login-decryption-options/web-login-decryption-options.service.ts @@ -0,0 +1,33 @@ +import { + LoginDecryptionOptionsService, + DefaultLoginDecryptionOptionsService, +} from "@bitwarden/auth/angular"; +import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; + +import { RouterService } from "../../../../core/router.service"; +import { AcceptOrganizationInviteService } from "../../../organization-invite/accept-organization.service"; + +export class WebLoginDecryptionOptionsService + extends DefaultLoginDecryptionOptionsService + implements LoginDecryptionOptionsService +{ + constructor( + protected messagingService: MessagingService, + private routerService: RouterService, + private acceptOrganizationInviteService: AcceptOrganizationInviteService, + ) { + super(messagingService); + } + + override async handleCreateUserSuccess(): Promise { + try { + // Invites from TDE orgs go through here, but the invite is + // accepted while being enrolled in admin recovery. So we need to clear + // the redirect and stored org invite. + await this.routerService.getAndClearLoginRedirectUrl(); + await this.acceptOrganizationInviteService.clearOrganizationInvitation(); + } catch (error) { + throw new Error(error); + } + } +} diff --git a/apps/web/src/app/auth/login/login-decryption-options/login-decryption-options.component.html b/apps/web/src/app/auth/login/login-decryption-options/login-decryption-options-v1.component.html similarity index 100% rename from apps/web/src/app/auth/login/login-decryption-options/login-decryption-options.component.html rename to apps/web/src/app/auth/login/login-decryption-options/login-decryption-options-v1.component.html diff --git a/apps/web/src/app/auth/login/login-decryption-options/login-decryption-options.component.ts b/apps/web/src/app/auth/login/login-decryption-options/login-decryption-options-v1.component.ts similarity index 78% rename from apps/web/src/app/auth/login/login-decryption-options/login-decryption-options.component.ts rename to apps/web/src/app/auth/login/login-decryption-options/login-decryption-options-v1.component.ts index 991fe8b5971..5eb72503b90 100644 --- a/apps/web/src/app/auth/login/login-decryption-options/login-decryption-options.component.ts +++ b/apps/web/src/app/auth/login/login-decryption-options/login-decryption-options-v1.component.ts @@ -1,14 +1,14 @@ import { Component, inject } from "@angular/core"; -import { BaseLoginDecryptionOptionsComponent } from "@bitwarden/angular/auth/components/base-login-decryption-options.component"; +import { BaseLoginDecryptionOptionsComponentV1 } from "@bitwarden/angular/auth/components/base-login-decryption-options-v1.component"; import { RouterService } from "../../../core"; import { AcceptOrganizationInviteService } from "../../organization-invite/accept-organization.service"; @Component({ selector: "web-login-decryption-options", - templateUrl: "login-decryption-options.component.html", + templateUrl: "login-decryption-options-v1.component.html", }) -export class LoginDecryptionOptionsComponent extends BaseLoginDecryptionOptionsComponent { +export class LoginDecryptionOptionsComponentV1 extends BaseLoginDecryptionOptionsComponentV1 { protected routerService = inject(RouterService); protected acceptOrganizationInviteService = inject(AcceptOrganizationInviteService); diff --git a/apps/web/src/app/auth/login/login.module.ts b/apps/web/src/app/auth/login/login.module.ts index b8f39890aa1..a33a6b8a5a8 100644 --- a/apps/web/src/app/auth/login/login.module.ts +++ b/apps/web/src/app/auth/login/login.module.ts @@ -4,7 +4,7 @@ import { CheckboxModule } from "@bitwarden/components"; import { SharedModule } from "../../../app/shared"; -import { LoginDecryptionOptionsComponent } from "./login-decryption-options/login-decryption-options.component"; +import { LoginDecryptionOptionsComponentV1 } from "./login-decryption-options/login-decryption-options-v1.component"; import { LoginComponentV1 } from "./login-v1.component"; import { LoginViaAuthRequestComponentV1 } from "./login-via-auth-request-v1.component"; import { LoginViaWebAuthnComponent } from "./login-via-webauthn/login-via-webauthn.component"; @@ -14,13 +14,13 @@ import { LoginViaWebAuthnComponent } from "./login-via-webauthn/login-via-webaut declarations: [ LoginComponentV1, LoginViaAuthRequestComponentV1, - LoginDecryptionOptionsComponent, + LoginDecryptionOptionsComponentV1, LoginViaWebAuthnComponent, ], exports: [ LoginComponentV1, LoginViaAuthRequestComponentV1, - LoginDecryptionOptionsComponent, + LoginDecryptionOptionsComponentV1, LoginViaWebAuthnComponent, ], }) diff --git a/apps/web/src/app/core/core.module.ts b/apps/web/src/app/core/core.module.ts index cfca5659c38..79a7862178f 100644 --- a/apps/web/src/app/core/core.module.ts +++ b/apps/web/src/app/core/core.module.ts @@ -30,6 +30,7 @@ import { LoginComponentService, LockComponentService, SetPasswordJitService, + LoginDecryptionOptionsService, } from "@bitwarden/auth/angular"; import { InternalUserDecryptionOptionsServiceAbstraction, @@ -60,6 +61,7 @@ import { import { FileDownloadService } from "@bitwarden/common/platform/abstractions/file-download/file-download.service"; import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { SdkClientFactory } from "@bitwarden/common/platform/abstractions/sdk/sdk-client-factory"; import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service"; @@ -95,6 +97,7 @@ import { WebRegistrationFinishService, WebLoginComponentService, WebLockComponentService, + WebLoginDecryptionOptionsService, } from "../auth"; import { AcceptOrganizationInviteService } from "../auth/organization-invite/accept-organization.service"; import { HtmlStorageService } from "../core/html-storage.service"; @@ -296,6 +299,11 @@ const safeProviders: SafeProvider[] = [ useClass: LoginEmailService, deps: [AccountService, AuthService, StateProvider], }), + safeProvider({ + provide: LoginDecryptionOptionsService, + useClass: WebLoginDecryptionOptionsService, + deps: [MessagingService, RouterService, AcceptOrganizationInviteService], + }), ]; @NgModule({ diff --git a/apps/web/src/app/oss-routing.module.ts b/apps/web/src/app/oss-routing.module.ts index 20361c7edc2..6e2e97d8e06 100644 --- a/apps/web/src/app/oss-routing.module.ts +++ b/apps/web/src/app/oss-routing.module.ts @@ -33,6 +33,7 @@ import { RegistrationLockAltIcon, RegistrationExpiredLinkIcon, VaultIcon, + LoginDecryptionOptionsComponent, } from "@bitwarden/auth/angular"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; @@ -46,7 +47,7 @@ import { CreateOrganizationComponent } from "./admin-console/settings/create-org import { deepLinkGuard } from "./auth/guards/deep-link.guard"; import { HintComponent } from "./auth/hint.component"; import { LockComponent } from "./auth/lock.component"; -import { LoginDecryptionOptionsComponent } from "./auth/login/login-decryption-options/login-decryption-options.component"; +import { LoginDecryptionOptionsComponentV1 } from "./auth/login/login-decryption-options/login-decryption-options-v1.component"; import { LoginComponentV1 } from "./auth/login/login-v1.component"; import { LoginViaAuthRequestComponentV1 } from "./auth/login/login-via-auth-request-v1.component"; import { LoginViaWebAuthnComponent } from "./auth/login/login-via-webauthn/login-via-webauthn.component"; @@ -103,11 +104,6 @@ const routes: Routes = [ component: LoginViaWebAuthnComponent, data: { titleId: "logInWithPasskey" } satisfies RouteDataProperties, }, - { - path: "login-initiated", - component: LoginDecryptionOptionsComponent, - canActivate: [tdeDecryptionRequiredGuard()], - }, { path: "register", component: TrialInitiationComponent, @@ -272,6 +268,22 @@ const routes: Routes = [ ], }, ), + ...unauthUiRefreshSwap( + LoginDecryptionOptionsComponentV1, + AnonLayoutWrapperComponent, + { + path: "login-initiated", + canActivate: [tdeDecryptionRequiredGuard()], + }, + { + path: "login-initiated", + canActivate: [tdeDecryptionRequiredGuard()], + data: { + pageIcon: DevicesIcon, + }, + children: [{ path: "", component: LoginDecryptionOptionsComponent }], + }, + ), ...unauthUiRefreshSwap( AnonLayoutWrapperComponent, AnonLayoutWrapperComponent, diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index ff3f0505699..419a89056c7 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -8051,9 +8051,18 @@ "loginInitiated": { "message": "Login initiated" }, + "rememberThisDeviceToMakeFutureLoginsSeamless": { + "message": "Remember this device to make future logins seamless" + }, "deviceApprovalRequired": { "message": "Device approval required. Select an approval option below:" }, + "deviceApprovalRequiredV2": { + "message": "Device approval required" + }, + "selectAnApprovalOptionBelow": { + "message": "Select an approval option below" + }, "rememberThisDevice": { "message": "Remember this device" }, @@ -8283,6 +8292,9 @@ "userEmailMissing": { "message": "User email missing" }, + "activeUserEmailNotFoundLoggingYouOut": { + "message": "Active user email not found. Logging you out." + }, "deviceTrusted": { "message": "Device trusted" }, diff --git a/libs/angular/src/auth/components/base-login-decryption-options.component.ts b/libs/angular/src/auth/components/base-login-decryption-options-v1.component.ts similarity index 99% rename from libs/angular/src/auth/components/base-login-decryption-options.component.ts rename to libs/angular/src/auth/components/base-login-decryption-options-v1.component.ts index f674a32af8b..df99503b6d7 100644 --- a/libs/angular/src/auth/components/base-login-decryption-options.component.ts +++ b/libs/angular/src/auth/components/base-login-decryption-options-v1.component.ts @@ -63,7 +63,7 @@ type ExistingUserUntrustedDeviceData = { type Data = NewUserData | ExistingUserUntrustedDeviceData; @Directive() -export class BaseLoginDecryptionOptionsComponent implements OnInit, OnDestroy { +export class BaseLoginDecryptionOptionsComponentV1 implements OnInit, OnDestroy { private destroy$ = new Subject(); protected State = State; diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 9d1cd6e502d..0208a3cdc7a 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -16,6 +16,8 @@ import { DefaultAnonLayoutWrapperDataService, LoginComponentService, DefaultLoginComponentService, + LoginDecryptionOptionsService, + DefaultLoginDecryptionOptionsService, } from "@bitwarden/auth/angular"; import { AuthRequestServiceAbstraction, @@ -1384,6 +1386,11 @@ const safeProviders: SafeProvider[] = [ useClass: DefaultAuthRequestApiService, deps: [ApiServiceAbstraction, LogService], }), + safeProvider({ + provide: LoginDecryptionOptionsService, + useClass: DefaultLoginDecryptionOptionsService, + deps: [MessagingServiceAbstraction], + }), ]; @NgModule({ diff --git a/libs/auth/src/angular/index.ts b/libs/auth/src/angular/index.ts index 5c028065c62..16ae77e937f 100644 --- a/libs/auth/src/angular/index.ts +++ b/libs/auth/src/angular/index.ts @@ -24,6 +24,11 @@ export * from "./login/login-secondary-content.component"; export * from "./login/login-component.service"; export * from "./login/default-login-component.service"; +// login decryption options +export * from "./login-decryption-options/login-decryption-options.component"; +export * from "./login-decryption-options/login-decryption-options.service"; +export * from "./login-decryption-options/default-login-decryption-options.service"; + // login via auth request export * from "./login-via-auth-request/login-via-auth-request.component"; diff --git a/libs/auth/src/angular/login-decryption-options/default-login-decryption-options.service.spec.ts b/libs/auth/src/angular/login-decryption-options/default-login-decryption-options.service.spec.ts new file mode 100644 index 00000000000..735b7667540 --- /dev/null +++ b/libs/auth/src/angular/login-decryption-options/default-login-decryption-options.service.spec.ts @@ -0,0 +1,37 @@ +import { MockProxy, mock } from "jest-mock-extended"; + +import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; + +import { DefaultLoginDecryptionOptionsService } from "./default-login-decryption-options.service"; + +describe("DefaultLoginDecryptionOptionsService", () => { + let service: DefaultLoginDecryptionOptionsService; + + let messagingService: MockProxy; + + beforeEach(() => { + messagingService = mock(); + + service = new DefaultLoginDecryptionOptionsService(messagingService); + }); + + it("should instantiate the service", () => { + expect(service).not.toBeFalsy(); + }); + + describe("handleCreateUserSuccess()", () => { + it("should return null", async () => { + const result = await service.handleCreateUserSuccess(); + + expect(result).toBeNull(); + }); + }); + + describe("logOut()", () => { + it("should send a logout message", async () => { + await service.logOut(); + + expect(messagingService.send).toHaveBeenCalledWith("logout"); + }); + }); +}); diff --git a/libs/auth/src/angular/login-decryption-options/default-login-decryption-options.service.ts b/libs/auth/src/angular/login-decryption-options/default-login-decryption-options.service.ts new file mode 100644 index 00000000000..17ea0bc9653 --- /dev/null +++ b/libs/auth/src/angular/login-decryption-options/default-login-decryption-options.service.ts @@ -0,0 +1,15 @@ +import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; + +import { LoginDecryptionOptionsService } from "./login-decryption-options.service"; + +export class DefaultLoginDecryptionOptionsService implements LoginDecryptionOptionsService { + constructor(protected messagingService: MessagingService) {} + + handleCreateUserSuccess(): Promise { + return null; + } + + async logOut(): Promise { + this.messagingService.send("logout"); + } +} diff --git a/libs/auth/src/angular/login-decryption-options/login-decryption-options.component.html b/libs/auth/src/angular/login-decryption-options/login-decryption-options.component.html new file mode 100644 index 00000000000..cb340f646f1 --- /dev/null +++ b/libs/auth/src/angular/login-decryption-options/login-decryption-options.component.html @@ -0,0 +1,60 @@ + +
+ + {{ "loading" | i18n }} +
+
+ +
+ + + {{ "rememberThisDevice" | i18n }} + {{ "uncheckIfPublicDevice" | i18n }} + +
+ + + + + + +
+ + + +
+ {{ "or" | i18n }} +
+
+ + + + +
+
diff --git a/libs/auth/src/angular/login-decryption-options/login-decryption-options.component.ts b/libs/auth/src/angular/login-decryption-options/login-decryption-options.component.ts new file mode 100644 index 00000000000..38771f9dada --- /dev/null +++ b/libs/auth/src/angular/login-decryption-options/login-decryption-options.component.ts @@ -0,0 +1,299 @@ +import { CommonModule } from "@angular/common"; +import { Component, DestroyRef, OnInit } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { FormBuilder, FormControl, ReactiveFormsModule } from "@angular/forms"; +import { Router } from "@angular/router"; +import { catchError, defer, firstValueFrom, from, map, of, switchMap, throwError } from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { + LoginEmailServiceAbstraction, + UserDecryptionOptions, + UserDecryptionOptionsServiceAbstraction, +} from "@bitwarden/auth/common"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { DeviceTrustServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust.service.abstraction"; +import { PasswordResetEnrollmentServiceAbstraction } from "@bitwarden/common/auth/abstractions/password-reset-enrollment.service.abstraction"; +import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction"; +import { ClientType } from "@bitwarden/common/enums"; +import { KeysRequest } from "@bitwarden/common/models/request/keys.request"; +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 { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service"; +import { UserId } from "@bitwarden/common/types/guid"; +import { + AsyncActionsModule, + ButtonModule, + CheckboxModule, + FormFieldModule, + ToastService, + TypographyModule, +} from "@bitwarden/components"; +import { KeyService } from "@bitwarden/key-management"; + +import { AnonLayoutWrapperDataService } from "../anon-layout/anon-layout-wrapper-data.service"; + +import { LoginDecryptionOptionsService } from "./login-decryption-options.service"; + +enum State { + NewUser, + ExistingUserUntrustedDevice, +} + +@Component({ + standalone: true, + templateUrl: "./login-decryption-options.component.html", + imports: [ + AsyncActionsModule, + ButtonModule, + CheckboxModule, + CommonModule, + FormFieldModule, + JslibModule, + ReactiveFormsModule, + TypographyModule, + ], +}) +export class LoginDecryptionOptionsComponent implements OnInit { + private activeAccountId: UserId; + private clientType: ClientType; + private email: string; + + protected loading = false; + protected state: State; + protected State = State; + + protected formGroup = this.formBuilder.group({ + rememberDevice: [true], // Remember device means for the user to trust the device + }); + + private get rememberDeviceControl(): FormControl { + return this.formGroup.controls.rememberDevice; + } + + // New User Properties + private newUserOrgId: string; + + // Existing User Untrusted Device Properties + protected canApproveFromOtherDevice = false; + protected canRequestAdminApproval = false; + protected canApproveWithMasterPassword = false; + + constructor( + private accountService: AccountService, + private anonLayoutWrapperDataService: AnonLayoutWrapperDataService, + private apiService: ApiService, + private destroyRef: DestroyRef, + private deviceTrustService: DeviceTrustServiceAbstraction, + private formBuilder: FormBuilder, + private i18nService: I18nService, + private keyService: KeyService, + private loginDecryptionOptionsService: LoginDecryptionOptionsService, + private loginEmailService: LoginEmailServiceAbstraction, + private messagingService: MessagingService, + private organizationApiService: OrganizationApiServiceAbstraction, + private passwordResetEnrollmentService: PasswordResetEnrollmentServiceAbstraction, + private platformUtilsService: PlatformUtilsService, + private router: Router, + private ssoLoginService: SsoLoginServiceAbstraction, + private toastService: ToastService, + private userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction, + private validationService: ValidationService, + ) { + this.clientType === this.platformUtilsService.getClientType(); + } + + async ngOnInit() { + this.loading = true; + + this.activeAccountId = (await firstValueFrom(this.accountService.activeAccount$))?.id; + + this.email = await firstValueFrom( + this.accountService.activeAccount$.pipe(map((a) => a?.email)), + ); + + if (!this.email) { + await this.handleMissingEmail(); + return; + } + + this.observeAndPersistRememberDeviceValueChanges(); + await this.setRememberDeviceDefaultValueFromState(); + + try { + const userDecryptionOptions = await firstValueFrom( + this.userDecryptionOptionsService.userDecryptionOptions$, + ); + + if ( + !userDecryptionOptions?.trustedDeviceOption?.hasAdminApproval && + !userDecryptionOptions?.hasMasterPassword + ) { + /** + * We are dealing with a new account if both are true: + * - User does NOT have admin approval (i.e. has not enrolled in admin reset) + * - User does NOT have a master password + */ + await this.loadNewUserData(); + } else { + this.loadExistingUserUntrustedDeviceData(userDecryptionOptions); + } + } catch (err) { + this.validationService.showError(err); + } finally { + this.loading = false; + } + } + + private async handleMissingEmail() { + this.toastService.showToast({ + variant: "error", + title: null, + message: this.i18nService.t("activeUserEmailNotFoundLoggingYouOut"), + }); + + setTimeout(async () => { + // We can't simply redirect to `/login` because the user is authed and the unauthGuard + // will prevent navigation. We must logout the user first via messagingService, which + // redirects to `/`, which will be handled by the redirectGuard to navigate the user to `/login`. + // The timeout just gives the user a chance to see the error toast before process reload runs on logout. + await this.loginDecryptionOptionsService.logOut(); + }, 5000); + } + + private observeAndPersistRememberDeviceValueChanges() { + this.rememberDeviceControl.valueChanges + .pipe( + takeUntilDestroyed(this.destroyRef), + switchMap((value) => + defer(() => this.deviceTrustService.setShouldTrustDevice(this.activeAccountId, value)), + ), + ) + .subscribe(); + } + + private async setRememberDeviceDefaultValueFromState() { + const rememberDeviceFromState = await this.deviceTrustService.getShouldTrustDevice( + this.activeAccountId, + ); + + const rememberDevice = rememberDeviceFromState ?? true; + + this.rememberDeviceControl.setValue(rememberDevice); + } + + private async loadNewUserData() { + this.state = State.NewUser; + + this.anonLayoutWrapperDataService.setAnonLayoutWrapperData({ + pageTitle: { + key: "loggedInExclamation", + }, + pageSubtitle: { + key: "rememberThisDeviceToMakeFutureLoginsSeamless", + }, + }); + + const autoEnrollStatus$ = defer(() => + this.ssoLoginService.getActiveUserOrganizationSsoIdentifier(), + ).pipe( + switchMap((organizationIdentifier) => { + if (organizationIdentifier == undefined) { + return throwError(() => new Error(this.i18nService.t("ssoIdentifierRequired"))); + } + + return from(this.organizationApiService.getAutoEnrollStatus(organizationIdentifier)); + }), + catchError((err: unknown) => { + this.validationService.showError(err); + return of(undefined); + }), + ); + + const autoEnrollStatus = await firstValueFrom(autoEnrollStatus$); + + this.newUserOrgId = autoEnrollStatus.id; + } + + private loadExistingUserUntrustedDeviceData(userDecryptionOptions: UserDecryptionOptions) { + this.state = State.ExistingUserUntrustedDevice; + + this.anonLayoutWrapperDataService.setAnonLayoutWrapperData({ + pageTitle: { + key: "deviceApprovalRequiredV2", + }, + pageSubtitle: { + key: "selectAnApprovalOptionBelow", + }, + }); + + this.canApproveFromOtherDevice = + userDecryptionOptions?.trustedDeviceOption?.hasLoginApprovingDevice || false; + this.canRequestAdminApproval = + userDecryptionOptions?.trustedDeviceOption?.hasAdminApproval || false; + this.canApproveWithMasterPassword = userDecryptionOptions?.hasMasterPassword || false; + } + + protected createUser = async () => { + if (this.state !== State.NewUser) { + return; + } + + try { + const { publicKey, privateKey } = await this.keyService.initAccount(); + const keysRequest = new KeysRequest(publicKey, privateKey.encryptedString); + await this.apiService.postAccountKeys(keysRequest); + + this.toastService.showToast({ + variant: "success", + title: null, + message: this.i18nService.t("accountSuccessfullyCreated"), + }); + + await this.passwordResetEnrollmentService.enroll(this.newUserOrgId); + + if (this.formGroup.value.rememberDevice) { + await this.deviceTrustService.trustDevice(this.activeAccountId); + } + + await this.loginDecryptionOptionsService.handleCreateUserSuccess(); + + if (this.clientType === ClientType.Desktop) { + this.messagingService.send("redrawMenu"); + } + + await this.handleCreateUserSuccessNavigation(); + } catch (err) { + this.validationService.showError(err); + } + }; + + private async handleCreateUserSuccessNavigation() { + if (this.clientType === ClientType.Browser) { + await this.router.navigate(["/tabs/vault"]); + } else { + await this.router.navigate(["/vault"]); + } + } + + protected async approveFromOtherDevice() { + this.loginEmailService.setLoginEmail(this.email); + await this.router.navigate(["/login-with-device"]); + } + + protected async approveWithMasterPassword() { + await this.router.navigate(["/lock"], { + queryParams: { + from: "login-initiated", + }, + }); + } + + protected async requestAdminApproval() { + this.loginEmailService.setLoginEmail(this.email); + await this.router.navigate(["/admin-approval-requested"]); + } +} diff --git a/libs/auth/src/angular/login-decryption-options/login-decryption-options.service.ts b/libs/auth/src/angular/login-decryption-options/login-decryption-options.service.ts new file mode 100644 index 00000000000..d81d56d6393 --- /dev/null +++ b/libs/auth/src/angular/login-decryption-options/login-decryption-options.service.ts @@ -0,0 +1,10 @@ +export abstract class LoginDecryptionOptionsService { + /** + * Handles client-specific logic that runs after a user was successfully created + */ + abstract handleCreateUserSuccess(): Promise; + /** + * Logs the user out + */ + abstract logOut(): Promise; +} From 9e9f977eb3b2f662080b5b446fe5fca883f9001e Mon Sep 17 00:00:00 2001 From: Thomas Rittson <31796059+eliykat@users.noreply.github.com> Date: Fri, 22 Nov 2024 07:58:03 +1000 Subject: [PATCH 07/68] [PM-11360] Remove export permission for providers (#12062) * Split organization.canAccessImportExport * Fix import permission to include CanCreateNewCollections * Remove provider export permission (feature flagged) --- apps/cli/src/tools/import.command.ts | 2 +- .../organization-layout.component.html | 4 +- .../layouts/organization-layout.component.ts | 40 ++++++++----------- .../organization-settings-routing.module.ts | 32 ++++++++++++--- .../navigation-switcher.stories.ts | 24 +++++++++-- .../product-switcher.stories.ts | 24 +++++++++-- .../shared/product-switcher.service.spec.ts | 23 +++++++++-- .../organization.service.abstraction.ts | 31 +------------- .../vnext.organization.service.ts | 19 +-------- .../models/domain/organization.ts | 23 ++++++++++- libs/common/src/enums/feature-flag.enum.ts | 2 + .../src/components/import.component.ts | 15 +++---- 12 files changed, 142 insertions(+), 97 deletions(-) diff --git a/apps/cli/src/tools/import.command.ts b/apps/cli/src/tools/import.command.ts index 1cb3ac19f6b..879b2313baf 100644 --- a/apps/cli/src/tools/import.command.ts +++ b/apps/cli/src/tools/import.command.ts @@ -27,7 +27,7 @@ export class ImportCommand { ); } - if (!organization.canAccessImportExport) { + if (!organization.canAccessImport) { return Response.badRequest( "You are not authorized to import into the provided organization.", ); diff --git a/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.html b/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.html index aaba492dff8..1e811b6c29d 100644 --- a/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.html +++ b/apps/web/src/app/admin-console/organizations/layouts/organization-layout.component.html @@ -84,12 +84,12 @@ canAccessOrgAdmin(org); organization$: Observable; + canAccessExport$: Observable; showPaymentAndHistory$: Observable; hideNewOrgButton$: Observable; organizationIsUnmanaged$: Observable; isAccessIntelligenceFeatureEnabled = false; - private _destroy = new Subject(); - constructor( private route: ActivatedRoute, private organizationService: OrganizationService, @@ -71,23 +70,23 @@ export class OrganizationLayoutComponent implements OnInit, OnDestroy { FeatureFlag.AccessIntelligence, ); - this.organization$ = this.route.params - .pipe(takeUntil(this._destroy)) - .pipe(map((p) => p.organizationId)) - .pipe( - mergeMap((id) => { - return this.organizationService.organizations$ - .pipe(takeUntil(this._destroy)) - .pipe(getOrganizationById(id)); - }), - ); + this.organization$ = this.route.params.pipe( + map((p) => p.organizationId), + switchMap((id) => this.organizationService.organizations$.pipe(getById(id))), + filter((org) => org != null), + ); + + this.canAccessExport$ = combineLatest([ + this.organization$, + this.configService.getFeatureFlag$(FeatureFlag.PM11360RemoveProviderExportPermission), + ]).pipe(map(([org, removeProviderExport]) => org.canAccessExport(removeProviderExport))); this.showPaymentAndHistory$ = this.organization$.pipe( map( (org) => !this.platformUtilsService.isSelfHost() && - org?.canViewBillingHistory && - org?.canEditPaymentMethods, + org.canViewBillingHistory && + org.canEditPaymentMethods, ), ); @@ -107,11 +106,6 @@ export class OrganizationLayoutComponent implements OnInit, OnDestroy { ); } - ngOnDestroy() { - this._destroy.next(); - this._destroy.complete(); - } - canShowVaultTab(organization: Organization): boolean { return canAccessVaultTab(organization); } diff --git a/apps/web/src/app/admin-console/organizations/settings/organization-settings-routing.module.ts b/apps/web/src/app/admin-console/organizations/settings/organization-settings-routing.module.ts index 4e9180ef123..d7fad9fded2 100644 --- a/apps/web/src/app/admin-console/organizations/settings/organization-settings-routing.module.ts +++ b/apps/web/src/app/admin-console/organizations/settings/organization-settings-routing.module.ts @@ -1,8 +1,11 @@ -import { NgModule } from "@angular/core"; -import { RouterModule, Routes } from "@angular/router"; +import { inject, NgModule } from "@angular/core"; +import { CanMatchFn, RouterModule, Routes } from "@angular/router"; +import { map } from "rxjs"; import { canAccessSettingsTab } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { organizationPermissionsGuard } from "../../organizations/guards/org-permissions.guard"; import { organizationRedirectGuard } from "../../organizations/guards/org-redirect.guard"; @@ -11,6 +14,11 @@ import { PoliciesComponent } from "../../organizations/policies"; import { AccountComponent } from "./account.component"; import { TwoFactorSetupComponent } from "./two-factor-setup.component"; +const removeProviderExportPermission$: CanMatchFn = () => + inject(ConfigService) + .getFeatureFlag$(FeatureFlag.PM11360RemoveProviderExportPermission) + .pipe(map((removeProviderExport) => removeProviderExport === true)); + const routes: Routes = [ { path: "", @@ -53,18 +61,32 @@ const routes: Routes = [ path: "import", loadComponent: () => import("./org-import.component").then((mod) => mod.OrgImportComponent), - canActivate: [organizationPermissionsGuard((org) => org.canAccessImportExport)], + canActivate: [organizationPermissionsGuard((org) => org.canAccessImport)], data: { titleId: "importData", }, }, + + // Export routing is temporarily duplicated to set the flag value passed into org.canAccessExport + { + path: "export", + loadComponent: () => + import("../tools/vault-export/org-vault-export.component").then( + (mod) => mod.OrganizationVaultExportComponent, + ), + canMatch: [removeProviderExportPermission$], // if this matches, the flag is ON + canActivate: [organizationPermissionsGuard((org) => org.canAccessExport(true))], + data: { + titleId: "exportVault", + }, + }, { path: "export", loadComponent: () => import("../tools/vault-export/org-vault-export.component").then( (mod) => mod.OrganizationVaultExportComponent, ), - canActivate: [organizationPermissionsGuard((org) => org.canAccessImportExport)], + canActivate: [organizationPermissionsGuard((org) => org.canAccessExport(false))], data: { titleId: "exportVault", }, @@ -82,7 +104,7 @@ function getSettingsRoute(organization: Organization) { if (organization.canManagePolicies) { return "policies"; } - if (organization.canAccessImportExport) { + if (organization.canAccessImport) { return ["tools", "import"]; } if (organization.canManageSso) { diff --git a/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.stories.ts b/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.stories.ts index cd1a77a9ec4..a7ff50b4264 100644 --- a/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.stories.ts +++ b/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.stories.ts @@ -152,7 +152,13 @@ export const SMAvailable: Story = { ...Template, args: { mockOrgs: [ - { id: "org-a", canManageUsers: false, canAccessSecretsManager: true, enabled: true }, + { + id: "org-a", + canManageUsers: false, + canAccessSecretsManager: true, + enabled: true, + canAccessExport: (_) => false, + }, ] as Organization[], mockProviders: [], }, @@ -162,7 +168,13 @@ export const SMAndACAvailable: Story = { ...Template, args: { mockOrgs: [ - { id: "org-a", canManageUsers: true, canAccessSecretsManager: true, enabled: true }, + { + id: "org-a", + canManageUsers: true, + canAccessSecretsManager: true, + enabled: true, + canAccessExport: (_) => false, + }, ] as Organization[], mockProviders: [], }, @@ -172,7 +184,13 @@ export const WithAllOptions: Story = { ...Template, args: { mockOrgs: [ - { id: "org-a", canManageUsers: true, canAccessSecretsManager: true, enabled: true }, + { + id: "org-a", + canManageUsers: true, + canAccessSecretsManager: true, + enabled: true, + canAccessExport: (_) => false, + }, ] as Organization[], mockProviders: [{ id: "provider-a" }] as Provider[], }, diff --git a/apps/web/src/app/layouts/product-switcher/product-switcher.stories.ts b/apps/web/src/app/layouts/product-switcher/product-switcher.stories.ts index b9d1d394920..b53d0243f64 100644 --- a/apps/web/src/app/layouts/product-switcher/product-switcher.stories.ts +++ b/apps/web/src/app/layouts/product-switcher/product-switcher.stories.ts @@ -171,7 +171,13 @@ export const WithSM: Story = { ...Template, args: { mockOrgs: [ - { id: "org-a", canManageUsers: false, canAccessSecretsManager: true, enabled: true }, + { + id: "org-a", + canManageUsers: false, + canAccessSecretsManager: true, + enabled: true, + canAccessExport: (_) => false, + }, ] as Organization[], mockProviders: [], }, @@ -181,7 +187,13 @@ export const WithSMAndAC: Story = { ...Template, args: { mockOrgs: [ - { id: "org-a", canManageUsers: true, canAccessSecretsManager: true, enabled: true }, + { + id: "org-a", + canManageUsers: true, + canAccessSecretsManager: true, + enabled: true, + canAccessExport: (_) => false, + }, ] as Organization[], mockProviders: [], }, @@ -191,7 +203,13 @@ export const WithAllOptions: Story = { ...Template, args: { mockOrgs: [ - { id: "org-a", canManageUsers: true, canAccessSecretsManager: true, enabled: true }, + { + id: "org-a", + canManageUsers: true, + canAccessSecretsManager: true, + enabled: true, + canAccessExport: (_) => false, + }, ] as Organization[], mockProviders: [{ id: "provider-a" }] as Provider[], }, diff --git a/apps/web/src/app/layouts/product-switcher/shared/product-switcher.service.spec.ts b/apps/web/src/app/layouts/product-switcher/shared/product-switcher.service.spec.ts index 07a41e7c94c..7c53cd86d3b 100644 --- a/apps/web/src/app/layouts/product-switcher/shared/product-switcher.service.spec.ts +++ b/apps/web/src/app/layouts/product-switcher/shared/product-switcher.service.spec.ts @@ -110,7 +110,12 @@ describe("ProductSwitcherService", () => { it("is included in bento when there is an organization with SM", async () => { organizationService.organizations$ = of([ - { id: "1234", canAccessSecretsManager: true, enabled: true }, + { + id: "1234", + canAccessSecretsManager: true, + enabled: true, + canAccessExport: (_) => true, + }, ] as Organization[]); initiateService(); @@ -220,8 +225,20 @@ describe("ProductSwitcherService", () => { router.url = "/sm/4243"; organizationService.organizations$ = of([ - { id: "23443234", canAccessSecretsManager: true, enabled: true, name: "Org 2" }, - { id: "4243", canAccessSecretsManager: true, enabled: true, name: "Org 32" }, + { + id: "23443234", + canAccessSecretsManager: true, + enabled: true, + name: "Org 2", + canAccessExport: (_) => true, + }, + { + id: "4243", + canAccessSecretsManager: true, + enabled: true, + name: "Org 32", + canAccessExport: (_) => true, + }, ] as Organization[]); initiateService(); diff --git a/libs/common/src/admin-console/abstractions/organization/organization.service.abstraction.ts b/libs/common/src/admin-console/abstractions/organization/organization.service.abstraction.ts index a2ea6aa8861..e687c0a34a7 100644 --- a/libs/common/src/admin-console/abstractions/organization/organization.service.abstraction.ts +++ b/libs/common/src/admin-console/abstractions/organization/organization.service.abstraction.ts @@ -1,7 +1,5 @@ import { map, Observable } from "rxjs"; -import { I18nService } from "../../../platform/abstractions/i18n.service"; -import { Utils } from "../../../platform/misc/utils"; import { UserId } from "../../../types/guid"; import { OrganizationData } from "../../models/data/organization.data"; import { Organization } from "../../models/domain/organization"; @@ -16,7 +14,8 @@ export function canAccessSettingsTab(org: Organization): boolean { org.canManagePolicies || org.canManageSso || org.canManageScim || - org.canAccessImportExport || + org.canAccessImport || + org.canAccessExport(false) || // Feature flag value doesn't matter here, providers will have access to this group anyway org.canManageDeviceApprovals ); } @@ -56,32 +55,6 @@ export function getOrganizationById(id: string) { return map((orgs) => orgs.find((o) => o.id === id)); } -export function canAccessAdmin(i18nService: I18nService) { - return map((orgs) => - orgs.filter(canAccessOrgAdmin).sort(Utils.getSortFunction(i18nService, "name")), - ); -} - -/** - * @deprecated - * To be removed after Flexible Collections. - **/ -export function canAccessImportExport(i18nService: I18nService) { - return map((orgs) => - orgs - .filter((org) => org.canAccessImportExport) - .sort(Utils.getSortFunction(i18nService, "name")), - ); -} - -export function canAccessImport(i18nService: I18nService) { - return map((orgs) => - orgs - .filter((org) => org.canAccessImportExport || org.canCreateNewCollections) - .sort(Utils.getSortFunction(i18nService, "name")), - ); -} - /** * Returns `true` if a user is a member of an organization (rather than only being a ProviderUser) * @deprecated Use organizationService.organizations$ with a filter instead diff --git a/libs/common/src/admin-console/abstractions/organization/vnext.organization.service.ts b/libs/common/src/admin-console/abstractions/organization/vnext.organization.service.ts index 45c00e3fd4a..68c61e298ae 100644 --- a/libs/common/src/admin-console/abstractions/organization/vnext.organization.service.ts +++ b/libs/common/src/admin-console/abstractions/organization/vnext.organization.service.ts @@ -1,7 +1,5 @@ import { map, Observable } from "rxjs"; -import { I18nService } from "../../../platform/abstractions/i18n.service"; -import { Utils } from "../../../platform/misc/utils"; import { UserId } from "../../../types/guid"; import { OrganizationData } from "../../models/data/organization.data"; import { Organization } from "../../models/domain/organization"; @@ -16,7 +14,8 @@ export function canAccessSettingsTab(org: Organization): boolean { org.canManagePolicies || org.canManageSso || org.canManageScim || - org.canAccessImportExport || + org.canAccessImport || + org.canAccessExport(false) || // Feature flag value doesn't matter here, providers will have access to this group anyway org.canManageDeviceApprovals ); } @@ -56,20 +55,6 @@ export function getOrganizationById(id: string) { return map((orgs) => orgs.find((o) => o.id === id)); } -export function canAccessAdmin(i18nService: I18nService) { - return map((orgs) => - orgs.filter(canAccessOrgAdmin).sort(Utils.getSortFunction(i18nService, "name")), - ); -} - -export function canAccessImport(i18nService: I18nService) { - return map((orgs) => - orgs - .filter((org) => org.canAccessImportExport || org.canCreateNewCollections) - .sort(Utils.getSortFunction(i18nService, "name")), - ); -} - /** * Publishes an observable stream of organizations. This service is meant to * be used widely across Bitwarden as the primary way of fetching organizations. diff --git a/libs/common/src/admin-console/models/domain/organization.ts b/libs/common/src/admin-console/models/domain/organization.ts index 070617b9e5f..2ff17ca119c 100644 --- a/libs/common/src/admin-console/models/domain/organization.ts +++ b/libs/common/src/admin-console/models/domain/organization.ts @@ -168,8 +168,27 @@ export class Organization { return (this.isAdmin || this.permissions.accessEventLogs) && this.useEvents; } - get canAccessImportExport() { - return this.isAdmin || this.permissions.accessImportExport; + get canAccessImport() { + return ( + this.isProviderUser || + this.type === OrganizationUserType.Owner || + this.type === OrganizationUserType.Admin || + this.permissions.accessImportExport || + this.canCreateNewCollections // To allow users to create collections and then import into them + ); + } + + canAccessExport(removeProviderExport: boolean) { + if (!removeProviderExport && this.isProviderUser) { + return true; + } + + return ( + this.isMember && + (this.type === OrganizationUserType.Owner || + this.type === OrganizationUserType.Admin || + this.permissions.accessImportExport) + ); } get canAccessReports() { diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index ec5a8e47d73..6dafe8fc7fe 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -40,6 +40,7 @@ export enum FeatureFlag { NewDeviceVerificationTemporaryDismiss = "new-device-temporary-dismiss", NewDeviceVerificationPermanentDismiss = "new-device-permanent-dismiss", DisableFreeFamiliesSponsorship = "PM-12274-disable-free-families-sponsorship", + PM11360RemoveProviderExportPermission = "pm-11360-remove-provider-export-permission", } export type AllowedFeatureFlagTypes = boolean | number | string; @@ -90,6 +91,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.NewDeviceVerificationTemporaryDismiss]: FALSE, [FeatureFlag.NewDeviceVerificationPermanentDismiss]: FALSE, [FeatureFlag.DisableFreeFamiliesSponsorship]: FALSE, + [FeatureFlag.PM11360RemoveProviderExportPermission]: FALSE, } satisfies Record; export type DefaultFeatureFlagValueType = typeof DefaultFeatureFlagValue; diff --git a/libs/importer/src/components/import.component.ts b/libs/importer/src/components/import.component.ts index 1ffe2728b05..1ab300fc378 100644 --- a/libs/importer/src/components/import.component.ts +++ b/libs/importer/src/components/import.component.ts @@ -21,10 +21,7 @@ import { JslibModule } from "@bitwarden/angular/jslib.module"; import { safeProvider, SafeProvider } from "@bitwarden/angular/platform/utils/safe-provider"; import { PinServiceAbstraction } from "@bitwarden/auth/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; -import { - canAccessImport, - OrganizationService, -} from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; +import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; @@ -226,7 +223,7 @@ export class ImportComponent implements OnInit, OnDestroy, AfterViewInit { this.setImportOptions(); await this.initializeOrganizations(); - if (this.organizationId && (await this.canAccessImportExport(this.organizationId))) { + if (this.organizationId && (await this.canAccessImport(this.organizationId))) { this.handleOrganizationImportInit(); } else { this.handleImportInit(); @@ -289,7 +286,7 @@ export class ImportComponent implements OnInit, OnDestroy, AfterViewInit { private async initializeOrganizations() { this.organizations$ = concat( this.organizationService.memberOrganizations$.pipe( - canAccessImport(this.i18nService), + map((orgs) => orgs.filter((org) => org.canAccessImport)), map((orgs) => orgs.sort(Utils.getSortFunction(this.i18nService, "name"))), ), ); @@ -375,7 +372,7 @@ export class ImportComponent implements OnInit, OnDestroy, AfterViewInit { importContents, this.organizationId, this.formGroup.controls.targetSelector.value, - (await this.canAccessImportExport(this.organizationId)) && this.isFromAC, + (await this.canAccessImport(this.organizationId)) && this.isFromAC, ); //No errors, display success message @@ -395,11 +392,11 @@ export class ImportComponent implements OnInit, OnDestroy, AfterViewInit { } } - private async canAccessImportExport(organizationId?: string): Promise { + private async canAccessImport(organizationId?: string): Promise { if (!organizationId) { return false; } - return (await this.organizationService.get(this.organizationId))?.canAccessImportExport; + return (await this.organizationService.get(this.organizationId))?.canAccessImport; } getFormatInstructionTitle() { From e6c68b7138a7db42fe93434e96f99eac5fba7652 Mon Sep 17 00:00:00 2001 From: Addison Beck Date: Thu, 21 Nov 2024 22:58:21 -0500 Subject: [PATCH 08/68] Remove `LimitCollectionCreationDeletionSplit` feature flag (#11258) * Remove references to feature flag * Remove feature flag enum --- .../settings/account.component.html | 31 +++------ .../settings/account.component.ts | 67 +++---------------- apps/web/src/locales/en/messages.json | 3 - .../models/data/organization.data.spec.ts | 2 - .../models/data/organization.data.ts | 4 -- .../models/domain/organization.ts | 6 +- ...on-collection-management-update.request.ts | 2 - .../models/response/organization.response.ts | 6 -- .../response/profile-organization.response.ts | 6 -- libs/common/src/enums/feature-flag.enum.ts | 2 - 10 files changed, 21 insertions(+), 108 deletions(-) diff --git a/apps/web/src/app/admin-console/organizations/settings/account.component.html b/apps/web/src/app/admin-console/organizations/settings/account.component.html index d1a1a091929..b3fac56c0bb 100644 --- a/apps/web/src/app/admin-console/organizations/settings/account.component.html +++ b/apps/web/src/app/admin-console/organizations/settings/account.component.html @@ -52,11 +52,7 @@

{{ "apiKey" | i18n }}

{{ "collectionManagement" | i18n }}

{{ "collectionManagementDesc" | i18n }}

@@ -64,24 +60,15 @@

{{ "collectionManagement" | i1 {{ "allowAdminAccessToAllCollectionItemsDesc" | i18n }} - - - {{ "limitCollectionCreationDesc" | i18n }} - - - - {{ "limitCollectionDeletionDesc" | i18n }} - - - - - - {{ "limitCollectionCreationDeletionDesc" | i18n }} - - - + + {{ "limitCollectionCreationDesc" | i18n }} + + + + {{ "limitCollectionDeletionDesc" | i18n }} + + - - - + + diff --git a/apps/web/src/app/tools/credential-generator/credential-generator.component.ts b/apps/web/src/app/tools/credential-generator/credential-generator.component.ts index 9eb4b0a0814..f252796d062 100644 --- a/apps/web/src/app/tools/credential-generator/credential-generator.component.ts +++ b/apps/web/src/app/tools/credential-generator/credential-generator.component.ts @@ -1,6 +1,10 @@ import { Component } from "@angular/core"; -import { GeneratorModule } from "@bitwarden/generator-components"; +import { ButtonModule, DialogService, ItemModule, LinkModule } from "@bitwarden/components"; +import { + CredentialGeneratorHistoryDialogComponent, + GeneratorModule, +} from "@bitwarden/generator-components"; import { HeaderModule } from "../../layouts/header/header.module"; import { SharedModule } from "../../shared"; @@ -9,6 +13,12 @@ import { SharedModule } from "../../shared"; standalone: true, selector: "credential-generator", templateUrl: "credential-generator.component.html", - imports: [SharedModule, HeaderModule, GeneratorModule], + imports: [SharedModule, HeaderModule, GeneratorModule, ItemModule, ButtonModule, LinkModule], }) -export class CredentialGeneratorComponent {} +export class CredentialGeneratorComponent { + constructor(private dialogService: DialogService) {} + + openHistoryDialog = () => { + this.dialogService.open(CredentialGeneratorHistoryDialogComponent); + }; +} diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index a6642d9d11e..0c1558a35d9 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -1642,9 +1642,27 @@ "passwordHistory": { "message": "Password history" }, + "generatorHistory": { + "message": "Generator history" + }, + "clearGeneratorHistoryTitle": { + "message": "Clear generator history" + }, + "cleargGeneratorHistoryDescription": { + "message": "If you continue, all entries will be permanently deleted from generator's history. Are you sure you want to continue?" + }, "noPasswordsInList": { "message": "There are no passwords to list." }, + "clearHistory": { + "message": "Clear history" + }, + "nothingToShow": { + "message": "Nothing to show" + }, + "nothingGeneratedRecently": { + "message": "You haven't generated anything recently" + }, "clear": { "message": "Clear", "description": "To clear something out. Example: To clear browser history." diff --git a/libs/tools/generator/components/src/credential-generator-history-dialog.component.html b/libs/tools/generator/components/src/credential-generator-history-dialog.component.html new file mode 100644 index 00000000000..b07eb62ae98 --- /dev/null +++ b/libs/tools/generator/components/src/credential-generator-history-dialog.component.html @@ -0,0 +1,18 @@ + + {{ "generatorHistory" | i18n }} + + + + + + + + diff --git a/libs/tools/generator/components/src/credential-generator-history-dialog.component.ts b/libs/tools/generator/components/src/credential-generator-history-dialog.component.ts new file mode 100644 index 00000000000..af1221e9d46 --- /dev/null +++ b/libs/tools/generator/components/src/credential-generator-history-dialog.component.ts @@ -0,0 +1,66 @@ +import { CommonModule } from "@angular/common"; +import { Component } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { BehaviorSubject, distinctUntilChanged, firstValueFrom, map, switchMap } from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { UserId } from "@bitwarden/common/types/guid"; +import { ButtonModule, DialogModule, DialogService } from "@bitwarden/components"; +import { GeneratorHistoryService } from "@bitwarden/generator-history"; + +import { CredentialGeneratorHistoryComponent as CredentialGeneratorHistoryToolsComponent } from "./credential-generator-history.component"; +import { EmptyCredentialHistoryComponent } from "./empty-credential-history.component"; + +@Component({ + templateUrl: "credential-generator-history-dialog.component.html", + standalone: true, + imports: [ + ButtonModule, + CommonModule, + JslibModule, + DialogModule, + CredentialGeneratorHistoryToolsComponent, + EmptyCredentialHistoryComponent, + ], +}) +export class CredentialGeneratorHistoryDialogComponent { + protected readonly hasHistory$ = new BehaviorSubject(false); + protected readonly userId$ = new BehaviorSubject(null); + + constructor( + private accountService: AccountService, + private history: GeneratorHistoryService, + private dialogService: DialogService, + ) { + this.accountService.activeAccount$ + .pipe( + takeUntilDestroyed(), + map(({ id }) => id), + distinctUntilChanged(), + ) + .subscribe(this.userId$); + + this.userId$ + .pipe( + takeUntilDestroyed(), + switchMap((id) => id && this.history.credentials$(id)), + map((credentials) => credentials.length > 0), + ) + .subscribe(this.hasHistory$); + } + + clear = async () => { + const confirmed = await this.dialogService.openSimpleDialog({ + title: { key: "clearGeneratorHistoryTitle" }, + content: { key: "cleargGeneratorHistoryDescription" }, + type: "warning", + acceptButtonText: { key: "clearHistory" }, + cancelButtonText: { key: "cancel" }, + }); + + if (confirmed) { + await this.history.clear(await firstValueFrom(this.userId$)); + } + }; +} diff --git a/libs/tools/generator/components/src/index.ts b/libs/tools/generator/components/src/index.ts index 213461174f0..3850e8a3bee 100644 --- a/libs/tools/generator/components/src/index.ts +++ b/libs/tools/generator/components/src/index.ts @@ -1,3 +1,4 @@ export { CredentialGeneratorHistoryComponent } from "./credential-generator-history.component"; +export { CredentialGeneratorHistoryDialogComponent } from "./credential-generator-history-dialog.component"; export { EmptyCredentialHistoryComponent } from "./empty-credential-history.component"; export { GeneratorModule } from "./generator.module"; From 4b6f70a13da77f943cae26b63d725526aad3467a Mon Sep 17 00:00:00 2001 From: Jared Snider <116684653+JaredSnider-Bitwarden@users.noreply.github.com> Date: Fri, 22 Nov 2024 12:33:46 -0500 Subject: [PATCH 15/68] Auth/PM-15155 - 2FA Component Renames (#12092) * PM-15155 - Move 2FA setup components into own folder * PM-15155 - Two-factor-authenticator --> Two-factor-setup-authenticator * PM-15155 - TwoFactorEmail --> TwoFactorSetupEmail * PM-15155 - TwoFactorBaseComponent --> TwoFactorSetupMethodBaseComponent * PM-15155 - Rename two-factor-setup folder to just two-factor as it is a management screen not just a setup screen * PM-15155 - TwoFactorWebauthn --> TwoFactorSetupWebauthn * PM-15155 - TwoFactorDuo --> TwoFactorSetupDuo * PM-15155 - TwoFactorYubikey --> TwoFactorSetupYubikey --- .../settings/two-factor-setup.component.ts | 21 ++++++----- .../security/security-routing.module.ts | 2 +- .../two-factor-recovery.component.html | 0 .../two-factor-recovery.component.ts | 0 ...factor-setup-authenticator.component.html} | 0 ...o-factor-setup-authenticator.component.ts} | 12 +++---- .../two-factor-setup-duo.component.html} | 0 .../two-factor-setup-duo.component.ts} | 13 ++++--- .../two-factor-setup-email.component.html} | 0 .../two-factor-setup-email.component.ts} | 13 ++++--- ...two-factor-setup-method-base.component.ts} | 5 ++- .../two-factor-setup-webauthn.component.html} | 0 .../two-factor-setup-webauthn.component.ts} | 10 +++--- .../two-factor-setup-yubikey.component.html} | 0 .../two-factor-setup-yubikey.component.ts} | 13 ++++--- .../two-factor-setup.component.html | 0 .../two-factor-setup.component.ts | 29 ++++++++------- .../two-factor-verify.component.html | 0 .../two-factor-verify.component.ts | 0 .../src/app/shared/loose-components.module.ts | 36 +++++++++---------- 20 files changed, 86 insertions(+), 68 deletions(-) rename apps/web/src/app/auth/settings/{ => two-factor}/two-factor-recovery.component.html (100%) rename apps/web/src/app/auth/settings/{ => two-factor}/two-factor-recovery.component.ts (100%) rename apps/web/src/app/auth/settings/{two-factor-authenticator.component.html => two-factor/two-factor-setup-authenticator.component.html} (100%) rename apps/web/src/app/auth/settings/{two-factor-authenticator.component.ts => two-factor/two-factor-setup-authenticator.component.ts} (95%) rename apps/web/src/app/auth/settings/{two-factor-duo.component.html => two-factor/two-factor-setup-duo.component.html} (100%) rename apps/web/src/app/auth/settings/{two-factor-duo.component.ts => two-factor/two-factor-setup-duo.component.ts} (92%) rename apps/web/src/app/auth/settings/{two-factor-email.component.html => two-factor/two-factor-setup-email.component.html} (100%) rename apps/web/src/app/auth/settings/{two-factor-email.component.ts => two-factor/two-factor-setup-email.component.ts} (92%) rename apps/web/src/app/auth/settings/{two-factor-base.component.ts => two-factor/two-factor-setup-method-base.component.ts} (96%) rename apps/web/src/app/auth/settings/{two-factor-webauthn.component.html => two-factor/two-factor-setup-webauthn.component.html} (100%) rename apps/web/src/app/auth/settings/{two-factor-webauthn.component.ts => two-factor/two-factor-setup-webauthn.component.ts} (94%) rename apps/web/src/app/auth/settings/{two-factor-yubikey.component.html => two-factor/two-factor-setup-yubikey.component.html} (100%) rename apps/web/src/app/auth/settings/{two-factor-yubikey.component.ts => two-factor/two-factor-setup-yubikey.component.ts} (93%) rename apps/web/src/app/auth/settings/{ => two-factor}/two-factor-setup.component.html (100%) rename apps/web/src/app/auth/settings/{ => two-factor}/two-factor-setup.component.ts (90%) rename apps/web/src/app/auth/settings/{ => two-factor}/two-factor-verify.component.html (100%) rename apps/web/src/app/auth/settings/{ => two-factor}/two-factor-verify.component.ts (100%) diff --git a/apps/web/src/app/admin-console/organizations/settings/two-factor-setup.component.ts b/apps/web/src/app/admin-console/organizations/settings/two-factor-setup.component.ts index 39d29741e76..ab321d73b6b 100644 --- a/apps/web/src/app/admin-console/organizations/settings/two-factor-setup.component.ts +++ b/apps/web/src/app/admin-console/organizations/settings/two-factor-setup.component.ts @@ -15,13 +15,13 @@ import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abs import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { DialogService } from "@bitwarden/components"; -import { TwoFactorDuoComponent } from "../../../auth/settings/two-factor-duo.component"; -import { TwoFactorSetupComponent as BaseTwoFactorSetupComponent } from "../../../auth/settings/two-factor-setup.component"; -import { TwoFactorVerifyComponent } from "../../../auth/settings/two-factor-verify.component"; +import { TwoFactorSetupDuoComponent } from "../../../auth/settings/two-factor/two-factor-setup-duo.component"; +import { TwoFactorSetupComponent as BaseTwoFactorSetupComponent } from "../../../auth/settings/two-factor/two-factor-setup.component"; +import { TwoFactorVerifyComponent } from "../../../auth/settings/two-factor/two-factor-verify.component"; @Component({ selector: "app-two-factor-setup", - templateUrl: "../../../auth/settings/two-factor-setup.component.html", + templateUrl: "../../../auth/settings/two-factor/two-factor-setup.component.html", }) // eslint-disable-next-line rxjs-angular/prefer-takeuntil export class TwoFactorSetupComponent extends BaseTwoFactorSetupComponent implements OnInit { @@ -79,12 +79,15 @@ export class TwoFactorSetupComponent extends BaseTwoFactorSetupComponent impleme if (!result) { return; } - const duoComp: DialogRef = TwoFactorDuoComponent.open(this.dialogService, { - data: { - authResponse: result, - organizationId: this.organizationId, + const duoComp: DialogRef = TwoFactorSetupDuoComponent.open( + this.dialogService, + { + data: { + authResponse: result, + organizationId: this.organizationId, + }, }, - }); + ); this.twoFactorSetupSubscription = duoComp.componentInstance.onChangeStatus .pipe(first(), takeUntil(this.destroy$)) .subscribe((enabled: boolean) => { diff --git a/apps/web/src/app/auth/settings/security/security-routing.module.ts b/apps/web/src/app/auth/settings/security/security-routing.module.ts index ca1d7c6aa65..8af0499d05a 100644 --- a/apps/web/src/app/auth/settings/security/security-routing.module.ts +++ b/apps/web/src/app/auth/settings/security/security-routing.module.ts @@ -2,7 +2,7 @@ import { NgModule } from "@angular/core"; import { RouterModule, Routes } from "@angular/router"; import { ChangePasswordComponent } from "../change-password.component"; -import { TwoFactorSetupComponent } from "../two-factor-setup.component"; +import { TwoFactorSetupComponent } from "../two-factor/two-factor-setup.component"; import { SecurityKeysComponent } from "./security-keys.component"; import { SecurityComponent } from "./security.component"; diff --git a/apps/web/src/app/auth/settings/two-factor-recovery.component.html b/apps/web/src/app/auth/settings/two-factor/two-factor-recovery.component.html similarity index 100% rename from apps/web/src/app/auth/settings/two-factor-recovery.component.html rename to apps/web/src/app/auth/settings/two-factor/two-factor-recovery.component.html diff --git a/apps/web/src/app/auth/settings/two-factor-recovery.component.ts b/apps/web/src/app/auth/settings/two-factor/two-factor-recovery.component.ts similarity index 100% rename from apps/web/src/app/auth/settings/two-factor-recovery.component.ts rename to apps/web/src/app/auth/settings/two-factor/two-factor-recovery.component.ts diff --git a/apps/web/src/app/auth/settings/two-factor-authenticator.component.html b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-authenticator.component.html similarity index 100% rename from apps/web/src/app/auth/settings/two-factor-authenticator.component.html rename to apps/web/src/app/auth/settings/two-factor/two-factor-setup-authenticator.component.html diff --git a/apps/web/src/app/auth/settings/two-factor-authenticator.component.ts b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-authenticator.component.ts similarity index 95% rename from apps/web/src/app/auth/settings/two-factor-authenticator.component.ts rename to apps/web/src/app/auth/settings/two-factor/two-factor-setup-authenticator.component.ts index da5378f4790..a0187103913 100644 --- a/apps/web/src/app/auth/settings/two-factor-authenticator.component.ts +++ b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-authenticator.component.ts @@ -18,7 +18,7 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl import { Utils } from "@bitwarden/common/platform/misc/utils"; import { DialogService, ToastService } from "@bitwarden/components"; -import { TwoFactorBaseComponent } from "./two-factor-base.component"; +import { TwoFactorSetupMethodBaseComponent } from "./two-factor-setup-method-base.component"; // NOTE: There are additional options available but these are just the ones we are current using. // See: https://github.com/neocotic/qrious#examples @@ -35,11 +35,11 @@ declare global { } @Component({ - selector: "app-two-factor-authenticator", - templateUrl: "two-factor-authenticator.component.html", + selector: "app-two-factor-setup-authenticator", + templateUrl: "two-factor-setup-authenticator.component.html", }) -export class TwoFactorAuthenticatorComponent - extends TwoFactorBaseComponent +export class TwoFactorSetupAuthenticatorComponent + extends TwoFactorSetupMethodBaseComponent implements OnInit, OnDestroy { @Output() onChangeStatus = new EventEmitter(); @@ -200,7 +200,7 @@ export class TwoFactorAuthenticatorComponent dialogService: DialogService, config: DialogConfig>, ) { - return dialogService.open(TwoFactorAuthenticatorComponent, config); + return dialogService.open(TwoFactorSetupAuthenticatorComponent, config); } async launchExternalUrl(url: string) { diff --git a/apps/web/src/app/auth/settings/two-factor-duo.component.html b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-duo.component.html similarity index 100% rename from apps/web/src/app/auth/settings/two-factor-duo.component.html rename to apps/web/src/app/auth/settings/two-factor/two-factor-setup-duo.component.html diff --git a/apps/web/src/app/auth/settings/two-factor-duo.component.ts b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-duo.component.ts similarity index 92% rename from apps/web/src/app/auth/settings/two-factor-duo.component.ts rename to apps/web/src/app/auth/settings/two-factor/two-factor-setup-duo.component.ts index 1a5b5917108..abb9b016165 100644 --- a/apps/web/src/app/auth/settings/two-factor-duo.component.ts +++ b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-duo.component.ts @@ -13,13 +13,16 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service" import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { DialogService, ToastService } from "@bitwarden/components"; -import { TwoFactorBaseComponent } from "./two-factor-base.component"; +import { TwoFactorSetupMethodBaseComponent } from "./two-factor-setup-method-base.component"; @Component({ - selector: "app-two-factor-duo", - templateUrl: "two-factor-duo.component.html", + selector: "app-two-factor-setup-duo", + templateUrl: "two-factor-setup-duo.component.html", }) -export class TwoFactorDuoComponent extends TwoFactorBaseComponent implements OnInit { +export class TwoFactorSetupDuoComponent + extends TwoFactorSetupMethodBaseComponent + implements OnInit +{ @Output() onChangeStatus: EventEmitter = new EventEmitter(); type = TwoFactorProviderType.Duo; @@ -137,7 +140,7 @@ export class TwoFactorDuoComponent extends TwoFactorBaseComponent implements OnI dialogService: DialogService, config: DialogConfig, ) => { - return dialogService.open(TwoFactorDuoComponent, config); + return dialogService.open(TwoFactorSetupDuoComponent, config); }; } diff --git a/apps/web/src/app/auth/settings/two-factor-email.component.html b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-email.component.html similarity index 100% rename from apps/web/src/app/auth/settings/two-factor-email.component.html rename to apps/web/src/app/auth/settings/two-factor/two-factor-setup-email.component.html diff --git a/apps/web/src/app/auth/settings/two-factor-email.component.ts b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-email.component.ts similarity index 92% rename from apps/web/src/app/auth/settings/two-factor-email.component.ts rename to apps/web/src/app/auth/settings/two-factor/two-factor-setup-email.component.ts index 524b00d114f..5b1e5e60d75 100644 --- a/apps/web/src/app/auth/settings/two-factor-email.component.ts +++ b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-email.component.ts @@ -16,14 +16,17 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service" import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { DialogService, ToastService } from "@bitwarden/components"; -import { TwoFactorBaseComponent } from "./two-factor-base.component"; +import { TwoFactorSetupMethodBaseComponent } from "./two-factor-setup-method-base.component"; @Component({ - selector: "app-two-factor-email", - templateUrl: "two-factor-email.component.html", + selector: "app-two-factor-setup-email", + templateUrl: "two-factor-setup-email.component.html", outputs: ["onUpdated"], }) -export class TwoFactorEmailComponent extends TwoFactorBaseComponent implements OnInit { +export class TwoFactorSetupEmailComponent + extends TwoFactorSetupMethodBaseComponent + implements OnInit +{ @Output() onChangeStatus: EventEmitter = new EventEmitter(); type = TwoFactorProviderType.Email; sentEmail: string; @@ -139,6 +142,6 @@ export class TwoFactorEmailComponent extends TwoFactorBaseComponent implements O dialogService: DialogService, config: DialogConfig>, ) { - return dialogService.open(TwoFactorEmailComponent, config); + return dialogService.open(TwoFactorSetupEmailComponent, config); } } diff --git a/apps/web/src/app/auth/settings/two-factor-base.component.ts b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-method-base.component.ts similarity index 96% rename from apps/web/src/app/auth/settings/two-factor-base.component.ts rename to apps/web/src/app/auth/settings/two-factor/two-factor-setup-method-base.component.ts index 2a6af1df98c..0ea7ac0b6a1 100644 --- a/apps/web/src/app/auth/settings/two-factor-base.component.ts +++ b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-method-base.component.ts @@ -12,8 +12,11 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service" import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { DialogService, ToastService } from "@bitwarden/components"; +/** + * Base class for two-factor setup components (ex: email, yubikey, webauthn, duo). + */ @Directive() -export abstract class TwoFactorBaseComponent { +export abstract class TwoFactorSetupMethodBaseComponent { @Output() onUpdated = new EventEmitter(); type: TwoFactorProviderType; diff --git a/apps/web/src/app/auth/settings/two-factor-webauthn.component.html b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-webauthn.component.html similarity index 100% rename from apps/web/src/app/auth/settings/two-factor-webauthn.component.html rename to apps/web/src/app/auth/settings/two-factor/two-factor-setup-webauthn.component.html diff --git a/apps/web/src/app/auth/settings/two-factor-webauthn.component.ts b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-webauthn.component.ts similarity index 94% rename from apps/web/src/app/auth/settings/two-factor-webauthn.component.ts rename to apps/web/src/app/auth/settings/two-factor/two-factor-setup-webauthn.component.ts index 6dfee920991..120c2168e84 100644 --- a/apps/web/src/app/auth/settings/two-factor-webauthn.component.ts +++ b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-webauthn.component.ts @@ -18,7 +18,7 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service" import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { DialogService, ToastService } from "@bitwarden/components"; -import { TwoFactorBaseComponent } from "./two-factor-base.component"; +import { TwoFactorSetupMethodBaseComponent } from "./two-factor-setup-method-base.component"; interface Key { id: number; @@ -29,10 +29,10 @@ interface Key { } @Component({ - selector: "app-two-factor-webauthn", - templateUrl: "two-factor-webauthn.component.html", + selector: "app-two-factor-setup-webauthn", + templateUrl: "two-factor-setup-webauthn.component.html", }) -export class TwoFactorWebAuthnComponent extends TwoFactorBaseComponent { +export class TwoFactorSetupWebAuthnComponent extends TwoFactorSetupMethodBaseComponent { type = TwoFactorProviderType.WebAuthn; name: string; keys: Key[]; @@ -213,6 +213,6 @@ export class TwoFactorWebAuthnComponent extends TwoFactorBaseComponent { dialogService: DialogService, config: DialogConfig>, ) { - return dialogService.open(TwoFactorWebAuthnComponent, config); + return dialogService.open(TwoFactorSetupWebAuthnComponent, config); } } diff --git a/apps/web/src/app/auth/settings/two-factor-yubikey.component.html b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-yubikey.component.html similarity index 100% rename from apps/web/src/app/auth/settings/two-factor-yubikey.component.html rename to apps/web/src/app/auth/settings/two-factor/two-factor-setup-yubikey.component.html diff --git a/apps/web/src/app/auth/settings/two-factor-yubikey.component.ts b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-yubikey.component.ts similarity index 93% rename from apps/web/src/app/auth/settings/two-factor-yubikey.component.ts rename to apps/web/src/app/auth/settings/two-factor/two-factor-setup-yubikey.component.ts index 3b601084c35..0b661bb6998 100644 --- a/apps/web/src/app/auth/settings/two-factor-yubikey.component.ts +++ b/apps/web/src/app/auth/settings/two-factor/two-factor-setup-yubikey.component.ts @@ -13,7 +13,7 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service" import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { DialogService, ToastService } from "@bitwarden/components"; -import { TwoFactorBaseComponent } from "./two-factor-base.component"; +import { TwoFactorSetupMethodBaseComponent } from "./two-factor-setup-method-base.component"; interface Key { key: string; @@ -21,10 +21,13 @@ interface Key { } @Component({ - selector: "app-two-factor-yubikey", - templateUrl: "two-factor-yubikey.component.html", + selector: "app-two-factor-setup-yubikey", + templateUrl: "two-factor-setup-yubikey.component.html", }) -export class TwoFactorYubiKeyComponent extends TwoFactorBaseComponent implements OnInit { +export class TwoFactorSetupYubiKeyComponent + extends TwoFactorSetupMethodBaseComponent + implements OnInit +{ type = TwoFactorProviderType.Yubikey; keys: Key[]; anyKeyHasNfc = false; @@ -169,6 +172,6 @@ export class TwoFactorYubiKeyComponent extends TwoFactorBaseComponent implements dialogService: DialogService, config: DialogConfig>, ) { - return dialogService.open(TwoFactorYubiKeyComponent, config); + return dialogService.open(TwoFactorSetupYubiKeyComponent, config); } } diff --git a/apps/web/src/app/auth/settings/two-factor-setup.component.html b/apps/web/src/app/auth/settings/two-factor/two-factor-setup.component.html similarity index 100% rename from apps/web/src/app/auth/settings/two-factor-setup.component.html rename to apps/web/src/app/auth/settings/two-factor/two-factor-setup.component.html diff --git a/apps/web/src/app/auth/settings/two-factor-setup.component.ts b/apps/web/src/app/auth/settings/two-factor/two-factor-setup.component.ts similarity index 90% rename from apps/web/src/app/auth/settings/two-factor-setup.component.ts rename to apps/web/src/app/auth/settings/two-factor/two-factor-setup.component.ts index 3b8a9edd955..d4cbb5b0791 100644 --- a/apps/web/src/app/auth/settings/two-factor-setup.component.ts +++ b/apps/web/src/app/auth/settings/two-factor/two-factor-setup.component.ts @@ -29,13 +29,13 @@ import { ProductTierType } from "@bitwarden/common/billing/enums"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { DialogService } from "@bitwarden/components"; -import { TwoFactorAuthenticatorComponent } from "./two-factor-authenticator.component"; -import { TwoFactorDuoComponent } from "./two-factor-duo.component"; -import { TwoFactorEmailComponent } from "./two-factor-email.component"; import { TwoFactorRecoveryComponent } from "./two-factor-recovery.component"; +import { TwoFactorSetupAuthenticatorComponent } from "./two-factor-setup-authenticator.component"; +import { TwoFactorSetupDuoComponent } from "./two-factor-setup-duo.component"; +import { TwoFactorSetupEmailComponent } from "./two-factor-setup-email.component"; +import { TwoFactorSetupWebAuthnComponent } from "./two-factor-setup-webauthn.component"; +import { TwoFactorSetupYubiKeyComponent } from "./two-factor-setup-yubikey.component"; import { TwoFactorVerifyComponent } from "./two-factor-verify.component"; -import { TwoFactorWebAuthnComponent } from "./two-factor-webauthn.component"; -import { TwoFactorYubiKeyComponent } from "./two-factor-yubikey.component"; @Component({ selector: "app-two-factor-setup", @@ -142,7 +142,7 @@ export class TwoFactorSetupComponent implements OnInit, OnDestroy { if (!result) { return; } - const authComp: DialogRef = TwoFactorAuthenticatorComponent.open( + const authComp: DialogRef = TwoFactorSetupAuthenticatorComponent.open( this.dialogService, { data: result }, ); @@ -160,7 +160,7 @@ export class TwoFactorSetupComponent implements OnInit, OnDestroy { if (!result) { return; } - const yubiComp: DialogRef = TwoFactorYubiKeyComponent.open( + const yubiComp: DialogRef = TwoFactorSetupYubiKeyComponent.open( this.dialogService, { data: result }, ); @@ -177,11 +177,14 @@ export class TwoFactorSetupComponent implements OnInit, OnDestroy { if (!result) { return; } - const duoComp: DialogRef = TwoFactorDuoComponent.open(this.dialogService, { - data: { - authResponse: result, + const duoComp: DialogRef = TwoFactorSetupDuoComponent.open( + this.dialogService, + { + data: { + authResponse: result, + }, }, - }); + ); this.twoFactorSetupSubscription = duoComp.componentInstance.onChangeStatus .pipe(first(), takeUntil(this.destroy$)) .subscribe((enabled: boolean) => { @@ -196,7 +199,7 @@ export class TwoFactorSetupComponent implements OnInit, OnDestroy { if (!result) { return; } - const emailComp: DialogRef = TwoFactorEmailComponent.open( + const emailComp: DialogRef = TwoFactorSetupEmailComponent.open( this.dialogService, { data: result, @@ -216,7 +219,7 @@ export class TwoFactorSetupComponent implements OnInit, OnDestroy { if (!result) { return; } - const webAuthnComp: DialogRef = TwoFactorWebAuthnComponent.open( + const webAuthnComp: DialogRef = TwoFactorSetupWebAuthnComponent.open( this.dialogService, { data: result }, ); diff --git a/apps/web/src/app/auth/settings/two-factor-verify.component.html b/apps/web/src/app/auth/settings/two-factor/two-factor-verify.component.html similarity index 100% rename from apps/web/src/app/auth/settings/two-factor-verify.component.html rename to apps/web/src/app/auth/settings/two-factor/two-factor-verify.component.html diff --git a/apps/web/src/app/auth/settings/two-factor-verify.component.ts b/apps/web/src/app/auth/settings/two-factor/two-factor-verify.component.ts similarity index 100% rename from apps/web/src/app/auth/settings/two-factor-verify.component.ts rename to apps/web/src/app/auth/settings/two-factor/two-factor-verify.component.ts diff --git a/apps/web/src/app/shared/loose-components.module.ts b/apps/web/src/app/shared/loose-components.module.ts index a238f2110ce..15f15e2e317 100644 --- a/apps/web/src/app/shared/loose-components.module.ts +++ b/apps/web/src/app/shared/loose-components.module.ts @@ -41,14 +41,14 @@ import { ApiKeyComponent } from "../auth/settings/security/api-key.component"; import { ChangeKdfModule } from "../auth/settings/security/change-kdf/change-kdf.module"; import { SecurityKeysComponent } from "../auth/settings/security/security-keys.component"; import { SecurityComponent } from "../auth/settings/security/security.component"; -import { TwoFactorAuthenticatorComponent } from "../auth/settings/two-factor-authenticator.component"; -import { TwoFactorDuoComponent } from "../auth/settings/two-factor-duo.component"; -import { TwoFactorEmailComponent } from "../auth/settings/two-factor-email.component"; -import { TwoFactorRecoveryComponent } from "../auth/settings/two-factor-recovery.component"; -import { TwoFactorSetupComponent } from "../auth/settings/two-factor-setup.component"; -import { TwoFactorVerifyComponent } from "../auth/settings/two-factor-verify.component"; -import { TwoFactorWebAuthnComponent } from "../auth/settings/two-factor-webauthn.component"; -import { TwoFactorYubiKeyComponent } from "../auth/settings/two-factor-yubikey.component"; +import { TwoFactorRecoveryComponent } from "../auth/settings/two-factor/two-factor-recovery.component"; +import { TwoFactorSetupAuthenticatorComponent } from "../auth/settings/two-factor/two-factor-setup-authenticator.component"; +import { TwoFactorSetupDuoComponent } from "../auth/settings/two-factor/two-factor-setup-duo.component"; +import { TwoFactorSetupEmailComponent } from "../auth/settings/two-factor/two-factor-setup-email.component"; +import { TwoFactorSetupWebAuthnComponent } from "../auth/settings/two-factor/two-factor-setup-webauthn.component"; +import { TwoFactorSetupYubiKeyComponent } from "../auth/settings/two-factor/two-factor-setup-yubikey.component"; +import { TwoFactorSetupComponent } from "../auth/settings/two-factor/two-factor-setup.component"; +import { TwoFactorVerifyComponent } from "../auth/settings/two-factor/two-factor-verify.component"; import { UserVerificationModule } from "../auth/shared/components/user-verification"; import { SsoComponent } from "../auth/sso.component"; import { TwoFactorOptionsComponent } from "../auth/two-factor-options.component"; @@ -159,16 +159,16 @@ import { SharedModule } from "./shared.module"; SponsoredFamiliesComponent, SponsoringOrgRowComponent, SsoComponent, - TwoFactorAuthenticatorComponent, + TwoFactorSetupAuthenticatorComponent, TwoFactorComponent, - TwoFactorDuoComponent, - TwoFactorEmailComponent, + TwoFactorSetupDuoComponent, + TwoFactorSetupEmailComponent, TwoFactorOptionsComponent, TwoFactorRecoveryComponent, TwoFactorSetupComponent, TwoFactorVerifyComponent, - TwoFactorWebAuthnComponent, - TwoFactorYubiKeyComponent, + TwoFactorSetupWebAuthnComponent, + TwoFactorSetupYubiKeyComponent, UpdatePasswordComponent, UpdateTempPasswordComponent, VerifyEmailTokenComponent, @@ -226,16 +226,16 @@ import { SharedModule } from "./shared.module"; SponsoredFamiliesComponent, SponsoringOrgRowComponent, SsoComponent, - TwoFactorAuthenticatorComponent, + TwoFactorSetupAuthenticatorComponent, TwoFactorComponent, - TwoFactorDuoComponent, - TwoFactorEmailComponent, + TwoFactorSetupDuoComponent, + TwoFactorSetupEmailComponent, TwoFactorOptionsComponent, TwoFactorRecoveryComponent, TwoFactorSetupComponent, TwoFactorVerifyComponent, - TwoFactorWebAuthnComponent, - TwoFactorYubiKeyComponent, + TwoFactorSetupWebAuthnComponent, + TwoFactorSetupYubiKeyComponent, UpdatePasswordComponent, UpdateTempPasswordComponent, UserLayoutComponent, From 13d4b6f2a6ad1764099472701cac8f3e08450afd Mon Sep 17 00:00:00 2001 From: rr-bw <102181210+rr-bw@users.noreply.github.com> Date: Fri, 22 Nov 2024 10:31:09 -0800 Subject: [PATCH 16/68] fix(biometrics): [PM-14593] Bugfix for "Cannot disable biometrics on extension" (ExtensionRefresh) (#12099) In the legacy `AccountSercurityV1Component` the `updateBiometric(enabled: boolean)` method had an `else` block to handle an `enabled` value of `false`. The new `AccountSecurityComponent` is missing this block, which makes it so the user cannot disable biometrics because we aren't handling the case where they uncheck the biometrics checkbox. This PR just adds the `else` block in the new component so we handle a `false` value. Feature Flag: ExtensionRefresh ON --- .../src/auth/popup/settings/account-security.component.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/browser/src/auth/popup/settings/account-security.component.ts b/apps/browser/src/auth/popup/settings/account-security.component.ts index 68b46ad8810..8493358aa8b 100644 --- a/apps/browser/src/auth/popup/settings/account-security.component.ts +++ b/apps/browser/src/auth/popup/settings/account-security.component.ts @@ -442,6 +442,9 @@ export class AccountSecurityComponent implements OnInit, OnDestroy { await this.biometricStateService.setBiometricUnlockEnabled(false); await this.biometricStateService.setFingerprintValidated(false); } + } else { + await this.biometricStateService.setBiometricUnlockEnabled(false); + await this.biometricStateService.setFingerprintValidated(false); } } From 02ea368446b736ca3c891c521c296b40694d9c1b Mon Sep 17 00:00:00 2001 From: Alec Rippberger <127791530+alec-livefront@users.noreply.github.com> Date: Fri, 22 Nov 2024 12:55:26 -0600 Subject: [PATCH 17/68] [PM-4816] Create shared LoginApprovalComponent (#11982) * Stub out dialog * Genericize LoginApprovalComponent * update ipc mocks * Remove changes to account component * Remove changes to account component * Remove debug * Remove test component * Remove added translations * Fix failing test * Run lint and prettier * Rename LoginApprovalServiceAbstraction to LoginApprovalComponentServiceAbstraction * Add back missing "isVisible" check before calling loginRequest * Rename classes to contain "Component" in the name * Add missing space between "login attempt" and fingerprint phrase * Require email --- apps/desktop/src/app/app.component.ts | 3 +- .../src/app/services/services.module.ts | 7 + ...p-login-approval-component.service.spec.ts | 89 +++++++++++++ ...esktop-login-approval-component.service.ts | 26 ++++ libs/auth/src/angular/index.ts | 4 + ...t-login-approval-component.service.spec.ts | 25 ++++ ...efault-login-approval-component.service.ts | 16 +++ .../login-approval.component.html | 2 +- .../login-approval.component.spec.ts | 122 ++++++++++++++++++ .../login-approval.component.ts | 15 +-- libs/auth/src/common/abstractions/index.ts | 1 + ...-approval-component.service.abstraction.ts | 9 ++ 12 files changed, 307 insertions(+), 12 deletions(-) create mode 100644 apps/desktop/src/auth/login/desktop-login-approval-component.service.spec.ts create mode 100644 apps/desktop/src/auth/login/desktop-login-approval-component.service.ts create mode 100644 libs/auth/src/angular/login-approval/default-login-approval-component.service.spec.ts create mode 100644 libs/auth/src/angular/login-approval/default-login-approval-component.service.ts rename {apps/desktop/src/auth/login => libs/auth/src/angular/login-approval}/login-approval.component.html (93%) create mode 100644 libs/auth/src/angular/login-approval/login-approval.component.spec.ts rename {apps/desktop/src/auth/login => libs/auth/src/angular/login-approval}/login-approval.component.ts (94%) create mode 100644 libs/auth/src/common/abstractions/login-approval-component.service.abstraction.ts diff --git a/apps/desktop/src/app/app.component.ts b/apps/desktop/src/app/app.component.ts index e8a9e96924e..7840c1dd400 100644 --- a/apps/desktop/src/app/app.component.ts +++ b/apps/desktop/src/app/app.component.ts @@ -25,7 +25,7 @@ import { import { CollectionService } from "@bitwarden/admin-console/common"; import { ModalRef } from "@bitwarden/angular/components/modal/modal.ref"; import { ModalService } from "@bitwarden/angular/services/modal.service"; -import { FingerprintDialogComponent } from "@bitwarden/auth/angular"; +import { FingerprintDialogComponent, LoginApprovalComponent } from "@bitwarden/auth/angular"; import { LogoutReason } from "@bitwarden/auth/common"; import { EventUploadService } from "@bitwarden/common/abstractions/event/event-upload.service"; import { NotificationsService } from "@bitwarden/common/abstractions/notifications.service"; @@ -67,7 +67,6 @@ import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legac import { KeyService, BiometricStateService } from "@bitwarden/key-management"; import { DeleteAccountComponent } from "../auth/delete-account.component"; -import { LoginApprovalComponent } from "../auth/login/login-approval.component"; import { MenuAccount, MenuUpdateRequest } from "../main/menu/menu.updater"; import { flagEnabled } from "../platform/flags"; import { PremiumComponent } from "../vault/app/accounts/premium.component"; diff --git a/apps/desktop/src/app/services/services.module.ts b/apps/desktop/src/app/services/services.module.ts index 2a11c78a579..62fc93ae0b8 100644 --- a/apps/desktop/src/app/services/services.module.ts +++ b/apps/desktop/src/app/services/services.module.ts @@ -26,6 +26,7 @@ import { } from "@bitwarden/auth/angular"; import { InternalUserDecryptionOptionsServiceAbstraction, + LoginApprovalComponentServiceAbstraction, LoginEmailService, PinServiceAbstraction, } from "@bitwarden/auth/common"; @@ -87,6 +88,7 @@ import { BiometricsService, } from "@bitwarden/key-management"; +import { DesktopLoginApprovalComponentService } from "../../auth/login/desktop-login-approval-component.service"; import { DesktopLoginComponentService } from "../../auth/login/desktop-login-component.service"; import { DesktopAutofillSettingsService } from "../../autofill/services/desktop-autofill-settings.service"; import { ElectronBiometricsService } from "../../key-management/biometrics/electron-biometrics.service"; @@ -349,6 +351,11 @@ const safeProviders: SafeProvider[] = [ useClass: LoginEmailService, deps: [AccountService, AuthService, StateProvider], }), + safeProvider({ + provide: LoginApprovalComponentServiceAbstraction, + useClass: DesktopLoginApprovalComponentService, + deps: [I18nServiceAbstraction], + }), ]; @NgModule({ diff --git a/apps/desktop/src/auth/login/desktop-login-approval-component.service.spec.ts b/apps/desktop/src/auth/login/desktop-login-approval-component.service.spec.ts new file mode 100644 index 00000000000..d687ae35742 --- /dev/null +++ b/apps/desktop/src/auth/login/desktop-login-approval-component.service.spec.ts @@ -0,0 +1,89 @@ +import { TestBed } from "@angular/core/testing"; +import { mock, MockProxy } from "jest-mock-extended"; +import { Subject } from "rxjs"; + +import { LoginApprovalComponent } from "@bitwarden/auth/angular"; +import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platform/abstractions/i18n.service"; + +import { DesktopLoginApprovalComponentService } from "./desktop-login-approval-component.service"; + +describe("DesktopLoginApprovalComponentService", () => { + let service: DesktopLoginApprovalComponentService; + let i18nService: MockProxy; + let originalIpc: any; + + beforeEach(() => { + originalIpc = (global as any).ipc; + (global as any).ipc = { + auth: { + loginRequest: jest.fn(), + }, + platform: { + isWindowVisible: jest.fn(), + }, + }; + + i18nService = mock({ + t: jest.fn(), + userSetLocale$: new Subject(), + locale$: new Subject(), + }); + + TestBed.configureTestingModule({ + providers: [ + DesktopLoginApprovalComponentService, + { provide: I18nServiceAbstraction, useValue: i18nService }, + ], + }); + + service = TestBed.inject(DesktopLoginApprovalComponentService); + }); + + afterEach(() => { + jest.clearAllMocks(); + (global as any).ipc = originalIpc; + }); + + it("is created successfully", () => { + expect(service).toBeTruthy(); + }); + + it("calls ipc.auth.loginRequest with correct parameters when window is not visible", async () => { + const title = "Log in requested"; + const email = "test@bitwarden.com"; + const message = `Confirm login attempt for ${email}`; + const closeText = "Close"; + + const loginApprovalComponent = { email } as LoginApprovalComponent; + i18nService.t.mockImplementation((key: string) => { + switch (key) { + case "logInRequested": + return title; + case "confirmLoginAtemptForMail": + return message; + case "close": + return closeText; + default: + return ""; + } + }); + + jest.spyOn(ipc.platform, "isWindowVisible").mockResolvedValue(false); + jest.spyOn(ipc.auth, "loginRequest").mockResolvedValue(); + + await service.showLoginRequestedAlertIfWindowNotVisible(loginApprovalComponent.email); + + expect(ipc.auth.loginRequest).toHaveBeenCalledWith(title, message, closeText); + }); + + it("does not call ipc.auth.loginRequest when window is visible", async () => { + const loginApprovalComponent = { email: "test@bitwarden.com" } as LoginApprovalComponent; + + jest.spyOn(ipc.platform, "isWindowVisible").mockResolvedValue(true); + jest.spyOn(ipc.auth, "loginRequest"); + + await service.showLoginRequestedAlertIfWindowNotVisible(loginApprovalComponent.email); + + expect(ipc.auth.loginRequest).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/desktop/src/auth/login/desktop-login-approval-component.service.ts b/apps/desktop/src/auth/login/desktop-login-approval-component.service.ts new file mode 100644 index 00000000000..3e658f9ba01 --- /dev/null +++ b/apps/desktop/src/auth/login/desktop-login-approval-component.service.ts @@ -0,0 +1,26 @@ +import { Injectable } from "@angular/core"; + +import { DefaultLoginApprovalComponentService } from "@bitwarden/auth/angular"; +import { LoginApprovalComponentServiceAbstraction } from "@bitwarden/auth/common"; +import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platform/abstractions/i18n.service"; + +@Injectable() +export class DesktopLoginApprovalComponentService + extends DefaultLoginApprovalComponentService + implements LoginApprovalComponentServiceAbstraction +{ + constructor(private i18nService: I18nServiceAbstraction) { + super(); + } + + async showLoginRequestedAlertIfWindowNotVisible(email: string): Promise { + const isVisible = await ipc.platform.isWindowVisible(); + if (!isVisible) { + await ipc.auth.loginRequest( + this.i18nService.t("logInRequested"), + this.i18nService.t("confirmLoginAtemptForMail", email), + this.i18nService.t("close"), + ); + } + } +} diff --git a/libs/auth/src/angular/index.ts b/libs/auth/src/angular/index.ts index 16ae77e937f..a01b8849c8d 100644 --- a/libs/auth/src/angular/index.ts +++ b/libs/auth/src/angular/index.ts @@ -66,3 +66,7 @@ export * from "./vault-timeout-input/vault-timeout-input.component"; // self hosted environment configuration dialog export * from "./self-hosted-env-config-dialog/self-hosted-env-config-dialog.component"; + +// login approval +export * from "./login-approval/login-approval.component"; +export * from "./login-approval/default-login-approval-component.service"; diff --git a/libs/auth/src/angular/login-approval/default-login-approval-component.service.spec.ts b/libs/auth/src/angular/login-approval/default-login-approval-component.service.spec.ts new file mode 100644 index 00000000000..ec274fac8bc --- /dev/null +++ b/libs/auth/src/angular/login-approval/default-login-approval-component.service.spec.ts @@ -0,0 +1,25 @@ +import { TestBed } from "@angular/core/testing"; + +import { DefaultLoginApprovalComponentService } from "./default-login-approval-component.service"; +import { LoginApprovalComponent } from "./login-approval.component"; + +describe("DefaultLoginApprovalComponentService", () => { + let service: DefaultLoginApprovalComponentService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [DefaultLoginApprovalComponentService], + }); + + service = TestBed.inject(DefaultLoginApprovalComponentService); + }); + + it("is created successfully", () => { + expect(service).toBeTruthy(); + }); + + it("has showLoginRequestedAlertIfWindowNotVisible method that is a no-op", async () => { + const loginApprovalComponent = {} as LoginApprovalComponent; + await service.showLoginRequestedAlertIfWindowNotVisible(loginApprovalComponent.email); + }); +}); diff --git a/libs/auth/src/angular/login-approval/default-login-approval-component.service.ts b/libs/auth/src/angular/login-approval/default-login-approval-component.service.ts new file mode 100644 index 00000000000..8b0463be6c1 --- /dev/null +++ b/libs/auth/src/angular/login-approval/default-login-approval-component.service.ts @@ -0,0 +1,16 @@ +import { LoginApprovalComponentServiceAbstraction } from "../../common/abstractions/login-approval-component.service.abstraction"; + +/** + * Default implementation of the LoginApprovalComponentServiceAbstraction. + */ +export class DefaultLoginApprovalComponentService + implements LoginApprovalComponentServiceAbstraction +{ + /** + * No-op implementation of the showLoginRequestedAlertIfWindowNotVisible method. + * @returns + */ + async showLoginRequestedAlertIfWindowNotVisible(email?: string): Promise { + return; + } +} diff --git a/apps/desktop/src/auth/login/login-approval.component.html b/libs/auth/src/angular/login-approval/login-approval.component.html similarity index 93% rename from apps/desktop/src/auth/login/login-approval.component.html rename to libs/auth/src/angular/login-approval/login-approval.component.html index cc2c0536c9e..ddbc48d71a3 100644 --- a/apps/desktop/src/auth/login/login-approval.component.html +++ b/libs/auth/src/angular/login-approval/login-approval.component.html @@ -1,7 +1,7 @@ {{ "areYouTryingtoLogin" | i18n }} -

{{ "logInAttemptBy" | i18n: email }}

+

{{ "logInAttemptBy" | i18n: email }}

{{ "fingerprintPhraseHeader" | i18n }}

{{ fingerprintPhrase }}

diff --git a/libs/auth/src/angular/login-approval/login-approval.component.spec.ts b/libs/auth/src/angular/login-approval/login-approval.component.spec.ts new file mode 100644 index 00000000000..ff598bdeb91 --- /dev/null +++ b/libs/auth/src/angular/login-approval/login-approval.component.spec.ts @@ -0,0 +1,122 @@ +import { DialogRef, DIALOG_DATA } from "@angular/cdk/dialog"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { mock, MockProxy } from "jest-mock-extended"; +import { of } from "rxjs"; + +import { + AuthRequestServiceAbstraction, + LoginApprovalComponentServiceAbstraction, +} from "@bitwarden/auth/common"; +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response"; +import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { UserId } from "@bitwarden/common/types/guid"; +import { ToastService } from "@bitwarden/components"; +import { KeyService } from "@bitwarden/key-management"; + +import { LoginApprovalComponent } from "./login-approval.component"; + +describe("LoginApprovalComponent", () => { + let component: LoginApprovalComponent; + let fixture: ComponentFixture; + + let authRequestService: MockProxy; + let accountService: MockProxy; + let apiService: MockProxy; + let i18nService: MockProxy; + let dialogRef: MockProxy; + let toastService: MockProxy; + + const testNotificationId = "test-notification-id"; + const testEmail = "test@bitwarden.com"; + const testPublicKey = "test-public-key"; + + beforeEach(async () => { + authRequestService = mock(); + accountService = mock(); + apiService = mock(); + i18nService = mock(); + dialogRef = mock(); + toastService = mock(); + + accountService.activeAccount$ = of({ + email: testEmail, + id: "test-user-id" as UserId, + emailVerified: true, + name: null, + }); + + await TestBed.configureTestingModule({ + imports: [LoginApprovalComponent], + providers: [ + { provide: DIALOG_DATA, useValue: { notificationId: testNotificationId } }, + { provide: AuthRequestServiceAbstraction, useValue: authRequestService }, + { provide: AccountService, useValue: accountService }, + { provide: PlatformUtilsService, useValue: mock() }, + { provide: I18nService, useValue: i18nService }, + { provide: ApiService, useValue: apiService }, + { provide: AppIdService, useValue: mock() }, + { provide: KeyService, useValue: mock() }, + { provide: DialogRef, useValue: dialogRef }, + { provide: ToastService, useValue: toastService }, + { + provide: LoginApprovalComponentServiceAbstraction, + useValue: mock(), + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(LoginApprovalComponent); + component = fixture.componentInstance; + }); + + it("creates successfully", () => { + expect(component).toBeTruthy(); + }); + + describe("ngOnInit", () => { + beforeEach(() => { + apiService.getAuthRequest.mockResolvedValue({ + publicKey: testPublicKey, + creationDate: new Date().toISOString(), + } as AuthRequestResponse); + authRequestService.getFingerprintPhrase.mockResolvedValue("test-phrase"); + }); + + it("retrieves and sets auth request data", async () => { + await component.ngOnInit(); + + expect(apiService.getAuthRequest).toHaveBeenCalledWith(testNotificationId); + expect(component.email).toBe(testEmail); + expect(component.fingerprintPhrase).toBeDefined(); + }); + + it("updates time text initially", async () => { + i18nService.t.mockReturnValue("justNow"); + + await component.ngOnInit(); + expect(component.requestTimeText).toBe("justNow"); + }); + }); + + describe("denyLogin", () => { + it("denies auth request and shows info toast", async () => { + const response = { requestApproved: false } as AuthRequestResponse; + apiService.getAuthRequest.mockResolvedValue(response); + authRequestService.approveOrDenyAuthRequest.mockResolvedValue(response); + i18nService.t.mockReturnValue("denied message"); + + await component.denyLogin(); + + expect(authRequestService.approveOrDenyAuthRequest).toHaveBeenCalledWith(false, response); + expect(toastService.showToast).toHaveBeenCalledWith({ + variant: "info", + title: null, + message: "denied message", + }); + }); + }); +}); diff --git a/apps/desktop/src/auth/login/login-approval.component.ts b/libs/auth/src/angular/login-approval/login-approval.component.ts similarity index 94% rename from apps/desktop/src/auth/login/login-approval.component.ts rename to libs/auth/src/angular/login-approval/login-approval.component.ts index e6428e0020c..9dff4d3e27a 100644 --- a/apps/desktop/src/auth/login/login-approval.component.ts +++ b/libs/auth/src/angular/login-approval/login-approval.component.ts @@ -4,7 +4,10 @@ import { Component, OnInit, OnDestroy, Inject } from "@angular/core"; import { Subject, firstValueFrom, map } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { AuthRequestServiceAbstraction } from "@bitwarden/auth/common"; +import { + AuthRequestServiceAbstraction, + LoginApprovalComponentServiceAbstraction as LoginApprovalComponentService, +} from "@bitwarden/auth/common"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response"; @@ -56,6 +59,7 @@ export class LoginApprovalComponent implements OnInit, OnDestroy { protected keyService: KeyService, private dialogRef: DialogRef, private toastService: ToastService, + private loginApprovalComponentService: LoginApprovalComponentService, ) { this.notificationId = params.notificationId; } @@ -89,14 +93,7 @@ export class LoginApprovalComponent implements OnInit, OnDestroy { this.updateTimeText(); }, RequestTimeUpdate); - const isVisible = await ipc.platform.isWindowVisible(); - if (!isVisible) { - await ipc.auth.loginRequest( - this.i18nService.t("logInRequested"), - this.i18nService.t("confirmLoginAtemptForMail", this.email), - this.i18nService.t("close"), - ); - } + this.loginApprovalComponentService.showLoginRequestedAlertIfWindowNotVisible(this.email); } } diff --git a/libs/auth/src/common/abstractions/index.ts b/libs/auth/src/common/abstractions/index.ts index 093d703b74a..88a13b490d6 100644 --- a/libs/auth/src/common/abstractions/index.ts +++ b/libs/auth/src/common/abstractions/index.ts @@ -4,3 +4,4 @@ export * from "./login-email.service"; export * from "./login-strategy.service"; export * from "./user-decryption-options.service.abstraction"; export * from "./auth-request.service.abstraction"; +export * from "./login-approval-component.service.abstraction"; diff --git a/libs/auth/src/common/abstractions/login-approval-component.service.abstraction.ts b/libs/auth/src/common/abstractions/login-approval-component.service.abstraction.ts new file mode 100644 index 00000000000..eaa62359808 --- /dev/null +++ b/libs/auth/src/common/abstractions/login-approval-component.service.abstraction.ts @@ -0,0 +1,9 @@ +/** + * Abstraction for the LoginApprovalComponent service. + */ +export abstract class LoginApprovalComponentServiceAbstraction { + /** + * Shows a login requested alert if the window is not visible. + */ + abstract showLoginRequestedAlertIfWindowNotVisible: (email?: string) => Promise; +} From 04caec6f69835a2d3015a74897818936c469df87 Mon Sep 17 00:00:00 2001 From: Jonathan Prusik Date: Fri, 22 Nov 2024 14:07:13 -0500 Subject: [PATCH 18/68] [PM-15187] Do not use innerHTML (#12108) * do not use innerHTML * remove unused catalog message --- apps/browser/src/_locales/en/messages.json | 17 ++++------------- .../popup/settings/autofill.component.html | 5 ++++- 2 files changed, 8 insertions(+), 14 deletions(-) diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 5200cf81d09..316bb23c4f8 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -1125,6 +1125,10 @@ "message": "WARNING", "description": "WARNING (should stay in capitalized letters if the language permits)" }, + "warningCapitalized": { + "message": "Warning", + "description": "Warning (should maintain locale-relevant capitalization)" + }, "confirmVaultExport": { "message": "Confirm vault export" }, @@ -1503,19 +1507,6 @@ "enableAutoFillOnPageLoadDesc": { "message": "If a login form is detected, autofill when the web page loads." }, - "autofillOnPageLoadWarning": { - "message": "$OPENTAG$Warning:$CLOSETAG$ Compromised or untrusted websites can exploit autofill on page load.", - "placeholders": { - "openTag": { - "content": "$1", - "example": "" - }, - "closeTag": { - "content": "$2", - "example": "" - } - } - }, "experimentalFeature": { "message": "Compromised or untrusted websites can exploit autofill on page load." }, diff --git a/apps/browser/src/autofill/popup/settings/autofill.component.html b/apps/browser/src/autofill/popup/settings/autofill.component.html index e9c9fd9c75e..18c6f515337 100644 --- a/apps/browser/src/autofill/popup/settings/autofill.component.html +++ b/apps/browser/src/autofill/popup/settings/autofill.component.html @@ -160,7 +160,10 @@

{{ "enableAutoFillOnPageLoadSectionTitle" | i18n }}

{{ "enableAutoFillOnPageLoadDesc" | i18n }} - + {{ "warningCapitalized" | i18n }}: {{ "experimentalFeature" | i18n }} Date: Fri, 22 Nov 2024 11:57:24 -0800 Subject: [PATCH 19/68] remove check for apps (#12079) --- .../app/tools/access-intelligence/risk-insights.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/app/tools/access-intelligence/risk-insights.component.html b/apps/web/src/app/tools/access-intelligence/risk-insights.component.html index 067207160d4..6df47e3c46f 100644 --- a/apps/web/src/app/tools/access-intelligence/risk-insights.component.html +++ b/apps/web/src/app/tools/access-intelligence/risk-insights.component.html @@ -4,7 +4,7 @@

{{ "riskInsights" | i18n }}

{{ "reviewAtRiskPasswords" | i18n }}  
{{ "learnMore" | i18n }}
-
+
{{ "dataLastUpdated" | i18n: (dataLastUpdated | date: "MMMM d, y 'at' h:mm a") From 2ee14ba5ceb16e86faa0d55658bb86165382a98a Mon Sep 17 00:00:00 2001 From: Daniel James Smith <2670567+djsmith85@users.noreply.github.com> Date: Fri, 22 Nov 2024 20:59:05 +0100 Subject: [PATCH 20/68] Add translation to navButton.title (#12110) Co-authored-by: Daniel James Smith --- .../platform/popup/layout/popup-tab-navigation.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.html b/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.html index 78b859f33b1..a4ae3161b47 100644 --- a/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.html +++ b/apps/browser/src/platform/popup/layout/popup-tab-navigation.component.html @@ -9,7 +9,7 @@
- {{ "accountIsManagedMessage" | i18n: managingOrganization?.name }} + {{ "accountIsOwnedMessage" | i18n: managingOrganization?.name }} diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 0c1558a35d9..eb98a1d7577 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -1785,8 +1785,8 @@ "sessionsDeauthorized": { "message": "All sessions deauthorized" }, - "accountIsManagedMessage": { - "message": "This account is managed by $ORGANIZATIONNAME$", + "accountIsOwnedMessage": { + "message": "This account is owned by $ORGANIZATIONNAME$", "placeholders": { "organizationName": { "content": "$1", From e938bb10875e4d2bef02b22e7eefa4a49bbe53e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rui=20Tom=C3=A9?= <108268980+r-tome@users.noreply.github.com> Date: Wed, 27 Nov 2024 15:51:40 +0000 Subject: [PATCH 58/68] [PM-15386] Refactor DomainVerificationComponent to warn about enabling SingleOrg policy only when it is not already enabled (#12156) --- .../domain-verification.component.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/domain-verification/domain-verification.component.ts b/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/domain-verification/domain-verification.component.ts index 987888741a4..9c0bae10526 100644 --- a/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/domain-verification/domain-verification.component.ts +++ b/bitwarden_license/bit-web/src/app/admin-console/organizations/manage/domain-verification/domain-verification.component.ts @@ -14,6 +14,8 @@ import { import { OrgDomainApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization-domain/org-domain-api.service.abstraction"; import { OrgDomainServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization-domain/org-domain.service.abstraction"; import { OrganizationDomainResponse } from "@bitwarden/common/admin-console/abstractions/organization-domain/responses/organization-domain.response"; +import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction"; +import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { HttpStatusCode } from "@bitwarden/common/enums"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ErrorResponse } from "@bitwarden/common/models/response/error.response"; @@ -33,6 +35,7 @@ import { }) export class DomainVerificationComponent implements OnInit, OnDestroy { private componentDestroyed$ = new Subject(); + private singleOrgPolicyEnabled = false; loading = true; @@ -48,6 +51,7 @@ export class DomainVerificationComponent implements OnInit, OnDestroy { private validationService: ValidationService, private toastService: ToastService, private configService: ConfigService, + private policyApiService: PolicyApiServiceAbstraction, ) {} // eslint-disable-next-line @typescript-eslint/no-empty-function @@ -71,6 +75,14 @@ export class DomainVerificationComponent implements OnInit, OnDestroy { async load() { await this.orgDomainApiService.getAllByOrgId(this.organizationId); + if (await this.configService.getFeatureFlag(FeatureFlag.AccountDeprovisioning)) { + const singleOrgPolicy = await this.policyApiService.getPolicy( + this.organizationId, + PolicyType.SingleOrg, + ); + this.singleOrgPolicyEnabled = singleOrgPolicy?.enabled ?? false; + } + this.loading = false; } @@ -87,6 +99,7 @@ export class DomainVerificationComponent implements OnInit, OnDestroy { map(async ([accountDeprovisioningEnabled, organizationDomains]) => { if ( accountDeprovisioningEnabled && + !this.singleOrgPolicyEnabled && organizationDomains.every((domain) => domain.verifiedDate === null) ) { await this.dialogService.openSimpleDialog({ From 276785192551992cc6fbb8729ecb3603f7fd198b Mon Sep 17 00:00:00 2001 From: Merissa Weinstein Date: Wed, 27 Nov 2024 10:29:17 -0600 Subject: [PATCH 59/68] PM-15390 | update margin top in the filter list component (#12157) * update margin top in the filter list component * remove surrounding padding and increase margin --- .../vault-v2/vault-header/vault-header-v2.component.html | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-header/vault-header-v2.component.html b/apps/browser/src/vault/popup/components/vault-v2/vault-header/vault-header-v2.component.html index 05deeec0d3d..5f958433c6d 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-header/vault-header-v2.component.html +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-header/vault-header-v2.component.html @@ -32,7 +32,5 @@ [open]="initialDisclosureVisibility$ | async" (openChange)="toggleFilters($event)" > -
- -
+ From f79141c4213a4a205399e7ada1e1cbd5ddf45f36 Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Wed, 27 Nov 2024 08:29:36 -0800 Subject: [PATCH 60/68] [PM-14990] Add password prompt for ssh key import (#12105) * Add password prompt for ssh key import * Remove empty line * Convert to switch statement --- apps/desktop/src/locales/en/messages.json | 19 ++++- .../src/vault/app/vault/add-edit.component.ts | 80 ++++++++++++------- libs/importer/src/components/dialog/index.ts | 1 + .../sshkey-password-prompt.component.html | 31 +++++++ .../sshkey-password-prompt.component.ts | 46 +++++++++++ 5 files changed, 147 insertions(+), 30 deletions(-) create mode 100644 libs/importer/src/components/dialog/sshkey-password-prompt.component.html create mode 100644 libs/importer/src/components/dialog/sshkey-password-prompt.component.ts diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index 837535ddb09..e9bebb8bfc0 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -207,6 +207,21 @@ "sshKeyGenerated": { "message": "A new SSH key was generated" }, + "sshKeyWrongPassword": { + "message": "The password you entered is incorrect." + }, + "importSshKey": { + "message": "Import" + }, + "confirmSshKeyPassword": { + "message": "Confirm password" + }, + "enterSshKeyPasswordDesc": { + "message": "Enter the password for the SSH key." + }, + "enterSshKeyPassword": { + "message": "Enter password" + }, "sshAgentUnlockRequired": { "message": "Please unlock your vault to approve the SSH key request." }, @@ -1752,10 +1767,10 @@ "deleteAccountWarning": { "message": "Deleting your account is permanent. It cannot be undone." }, - "cannotDeleteAccount":{ + "cannotDeleteAccount": { "message": "Cannot delete account" }, - "cannotDeleteAccountDesc":{ + "cannotDeleteAccountDesc": { "message": "This action cannot be completed because your account is owned by an organization. Contact your organization administrator for additional details." }, "accountDeleted": { diff --git a/apps/desktop/src/vault/app/vault/add-edit.component.ts b/apps/desktop/src/vault/app/vault/add-edit.component.ts index 015a7c6b21b..6a3ad8d62e1 100644 --- a/apps/desktop/src/vault/app/vault/add-edit.component.ts +++ b/apps/desktop/src/vault/app/vault/add-edit.component.ts @@ -2,6 +2,7 @@ import { DatePipe } from "@angular/common"; import { Component, NgZone, OnChanges, OnDestroy, OnInit, ViewChild } from "@angular/core"; import { NgForm } from "@angular/forms"; import { sshagent as sshAgent } from "desktop_native/napi"; +import { lastValueFrom } from "rxjs"; import { CollectionService } from "@bitwarden/admin-console/common"; import { AddEditComponent as BaseAddEditComponent } from "@bitwarden/angular/vault/components/add-edit.component"; @@ -22,6 +23,7 @@ import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folde import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; import { DialogService, ToastService } from "@bitwarden/components"; +import { SshKeyPasswordPromptComponent } from "@bitwarden/importer/ui"; import { PasswordRepromptService } from "@bitwarden/vault"; const BroadcasterSubscriptionId = "AddEditComponent"; @@ -170,42 +172,64 @@ export class AddEditComponent extends BaseAddEditComponent implements OnInit, On } } - async importSshKeyFromClipboard() { + async importSshKeyFromClipboard(password: string = "") { const key = await this.platformUtilsService.readFromClipboard(); - const parsedKey = await ipc.platform.sshAgent.importKey(key, ""); - if (parsedKey == null || parsedKey.status === sshAgent.SshKeyImportStatus.ParsingError) { + const parsedKey = await ipc.platform.sshAgent.importKey(key, password); + if (parsedKey == null) { this.toastService.showToast({ variant: "error", title: "", message: this.i18nService.t("invalidSshKey"), }); return; - } else if (parsedKey.status === sshAgent.SshKeyImportStatus.UnsupportedKeyType) { - this.toastService.showToast({ - variant: "error", - title: "", - message: this.i18nService.t("sshKeyTypeUnsupported"), - }); - } else if ( - parsedKey.status === sshAgent.SshKeyImportStatus.PasswordRequired || - parsedKey.status === sshAgent.SshKeyImportStatus.WrongPassword - ) { - this.toastService.showToast({ - variant: "error", - title: "", - message: this.i18nService.t("sshKeyPasswordUnsupported"), - }); - return; - } else { - this.cipher.sshKey.privateKey = parsedKey.sshKey.privateKey; - this.cipher.sshKey.publicKey = parsedKey.sshKey.publicKey; - this.cipher.sshKey.keyFingerprint = parsedKey.sshKey.keyFingerprint; - this.toastService.showToast({ - variant: "success", - title: "", - message: this.i18nService.t("sshKeyPasted"), - }); } + + switch (parsedKey.status) { + case sshAgent.SshKeyImportStatus.ParsingError: + this.toastService.showToast({ + variant: "error", + title: "", + message: this.i18nService.t("invalidSshKey"), + }); + return; + case sshAgent.SshKeyImportStatus.UnsupportedKeyType: + this.toastService.showToast({ + variant: "error", + title: "", + message: this.i18nService.t("sshKeyTypeUnsupported"), + }); + return; + case sshAgent.SshKeyImportStatus.PasswordRequired: + case sshAgent.SshKeyImportStatus.WrongPassword: + if (password !== "") { + this.toastService.showToast({ + variant: "error", + title: "", + message: this.i18nService.t("sshKeyWrongPassword"), + }); + } else { + password = await this.getSshKeyPassword(); + await this.importSshKeyFromClipboard(password); + } + return; + default: + this.cipher.sshKey.privateKey = parsedKey.sshKey.privateKey; + this.cipher.sshKey.publicKey = parsedKey.sshKey.publicKey; + this.cipher.sshKey.keyFingerprint = parsedKey.sshKey.keyFingerprint; + this.toastService.showToast({ + variant: "success", + title: "", + message: this.i18nService.t("sshKeyPasted"), + }); + } + } + + async getSshKeyPassword(): Promise { + const dialog = this.dialogService.open(SshKeyPasswordPromptComponent, { + ariaModal: true, + }); + + return await lastValueFrom(dialog.closed); } async typeChange() { diff --git a/libs/importer/src/components/dialog/index.ts b/libs/importer/src/components/dialog/index.ts index 641cd6600a1..a115426eea9 100644 --- a/libs/importer/src/components/dialog/index.ts +++ b/libs/importer/src/components/dialog/index.ts @@ -1,3 +1,4 @@ export * from "./import-error-dialog.component"; export * from "./import-success-dialog.component"; export * from "./file-password-prompt.component"; +export * from "./sshkey-password-prompt.component"; diff --git a/libs/importer/src/components/dialog/sshkey-password-prompt.component.html b/libs/importer/src/components/dialog/sshkey-password-prompt.component.html new file mode 100644 index 00000000000..a42615b1cda --- /dev/null +++ b/libs/importer/src/components/dialog/sshkey-password-prompt.component.html @@ -0,0 +1,31 @@ + + + + {{ "enterSshKeyPassword" | i18n }} + + +
+ {{ "enterSshKeyPasswordDesc" | i18n }} + + {{ "confirmSshKeyPassword" | i18n }} + + + +
+ + + + + +
+ diff --git a/libs/importer/src/components/dialog/sshkey-password-prompt.component.ts b/libs/importer/src/components/dialog/sshkey-password-prompt.component.ts new file mode 100644 index 00000000000..527dfec6e85 --- /dev/null +++ b/libs/importer/src/components/dialog/sshkey-password-prompt.component.ts @@ -0,0 +1,46 @@ +import { DialogRef } from "@angular/cdk/dialog"; +import { CommonModule } from "@angular/common"; +import { Component } from "@angular/core"; +import { FormBuilder, ReactiveFormsModule, Validators } from "@angular/forms"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { + AsyncActionsModule, + ButtonModule, + DialogModule, + FormFieldModule, + IconButtonModule, +} from "@bitwarden/components"; + +@Component({ + templateUrl: "sshkey-password-prompt.component.html", + standalone: true, + imports: [ + CommonModule, + JslibModule, + DialogModule, + FormFieldModule, + AsyncActionsModule, + ButtonModule, + IconButtonModule, + ReactiveFormsModule, + ], +}) +export class SshKeyPasswordPromptComponent { + protected formGroup = this.formBuilder.group({ + sshKeyPassword: ["", Validators.required], + }); + + constructor( + public dialogRef: DialogRef, + protected formBuilder: FormBuilder, + ) {} + + submit = () => { + this.formGroup.markAsTouched(); + if (!this.formGroup.valid) { + return; + } + this.dialogRef.close(this.formGroup.value.sshKeyPassword); + }; +} From 7d6da0a68d152006c2c45db81117a7d2e0844268 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 27 Nov 2024 14:01:59 -0500 Subject: [PATCH 61/68] [deps] Design System: Update angular-cli monorepo to v17.3.11 (#10842) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package-lock.json | 122 +++++++++++++++++++++++----------------------- package.json | 6 +-- 2 files changed, 64 insertions(+), 64 deletions(-) diff --git a/package-lock.json b/package-lock.json index a413d80b313..c52eb84dc65 100644 --- a/package-lock.json +++ b/package-lock.json @@ -74,12 +74,12 @@ "zxcvbn": "4.4.2" }, "devDependencies": { - "@angular-devkit/build-angular": "17.3.9", + "@angular-devkit/build-angular": "17.3.11", "@angular-eslint/eslint-plugin": "17.5.3", "@angular-eslint/eslint-plugin-template": "17.5.3", "@angular-eslint/schematics": "17.5.3", "@angular-eslint/template-parser": "17.5.3", - "@angular/cli": "17.3.9", + "@angular/cli": "17.3.11", "@angular/compiler-cli": "17.3.12", "@angular/elements": "17.3.12", "@babel/core": "7.24.9", @@ -87,7 +87,7 @@ "@compodoc/compodoc": "1.1.26", "@electron/notarize": "2.5.0", "@electron/rebuild": "3.7.0", - "@ngtools/webpack": "17.3.9", + "@ngtools/webpack": "17.3.11", "@storybook/addon-a11y": "8.4.5", "@storybook/addon-actions": "8.4.5", "@storybook/addon-designs": "8.0.4", @@ -385,16 +385,16 @@ } }, "node_modules/@angular-devkit/build-angular": { - "version": "17.3.9", - "resolved": "https://registry.npmjs.org/@angular-devkit/build-angular/-/build-angular-17.3.9.tgz", - "integrity": "sha512-EuAPSC4c2DSJLlL4ieviKLx1faTyY+ymWycq6KFwoxu1FgWly/dqBeWyXccYinLhPVZmoh6+A/5S4YWXlOGSnA==", + "version": "17.3.11", + "resolved": "https://registry.npmjs.org/@angular-devkit/build-angular/-/build-angular-17.3.11.tgz", + "integrity": "sha512-lHX5V2dSts328yvo/9E2u9QMGcvJhbEKKDDp9dBecwvIG9s+4lTOJgi9DPUE7W+AtmPcmbbhwC2JRQ/SLQhAoA==", "dev": true, "license": "MIT", "dependencies": { "@ampproject/remapping": "2.3.0", - "@angular-devkit/architect": "0.1703.9", - "@angular-devkit/build-webpack": "0.1703.9", - "@angular-devkit/core": "17.3.9", + "@angular-devkit/architect": "0.1703.11", + "@angular-devkit/build-webpack": "0.1703.11", + "@angular-devkit/core": "17.3.11", "@babel/core": "7.24.0", "@babel/generator": "7.23.6", "@babel/helper-annotate-as-pure": "7.22.5", @@ -405,7 +405,7 @@ "@babel/preset-env": "7.24.0", "@babel/runtime": "7.24.0", "@discoveryjs/json-ext": "0.5.7", - "@ngtools/webpack": "17.3.9", + "@ngtools/webpack": "17.3.11", "@vitejs/plugin-basic-ssl": "1.1.0", "ansi-colors": "4.1.3", "autoprefixer": "10.4.18", @@ -417,7 +417,7 @@ "css-loader": "6.10.0", "esbuild-wasm": "0.20.1", "fast-glob": "3.3.2", - "http-proxy-middleware": "2.0.6", + "http-proxy-middleware": "2.0.7", "https-proxy-agent": "7.0.4", "inquirer": "9.2.15", "jsonc-parser": "3.2.1", @@ -447,7 +447,7 @@ "tree-kill": "1.2.2", "tslib": "2.6.2", "undici": "6.11.1", - "vite": "5.1.7", + "vite": "5.1.8", "watchpack": "2.4.0", "webpack": "5.94.0", "webpack-dev-middleware": "6.1.2", @@ -515,13 +515,13 @@ } }, "node_modules/@angular-devkit/build-angular/node_modules/@angular-devkit/architect": { - "version": "0.1703.9", - "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.1703.9.tgz", - "integrity": "sha512-kEPfTOVnzrJxPGTvaXy8653HU9Fucxttx9gVfQR1yafs+yIEGx3fKGKe89YPmaEay32bIm7ZUpxDF1FO14nkdQ==", + "version": "0.1703.11", + "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.1703.11.tgz", + "integrity": "sha512-YNasVZk4rYdcM6M+KRH8PUBhVyJfqzUYLpO98GgRokW+taIDgifckSlmfDZzQRbw45qiwei1IKCLqcpC8nM5Tw==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "17.3.9", + "@angular-devkit/core": "17.3.11", "rxjs": "7.8.1" }, "engines": { @@ -531,13 +531,13 @@ } }, "node_modules/@angular-devkit/build-angular/node_modules/@angular-devkit/build-webpack": { - "version": "0.1703.9", - "resolved": "https://registry.npmjs.org/@angular-devkit/build-webpack/-/build-webpack-0.1703.9.tgz", - "integrity": "sha512-3b0LND39Nc+DwCQ0N7Tbsd7RAFWTeIc4VDwk/7RO8EMYTP5Kfgr/TK66nwTBypHsjmD69IMKHZZaZuiDfGfx2A==", + "version": "0.1703.11", + "resolved": "https://registry.npmjs.org/@angular-devkit/build-webpack/-/build-webpack-0.1703.11.tgz", + "integrity": "sha512-qbCiiHuoVkD7CtLyWoRi/Vzz6nrEztpF5XIyWUcQu67An1VlxbMTE4yoSQiURjCQMnB/JvS1GPVed7wOq3SJ/w==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/architect": "0.1703.9", + "@angular-devkit/architect": "0.1703.11", "rxjs": "7.8.1" }, "engines": { @@ -551,9 +551,9 @@ } }, "node_modules/@angular-devkit/build-angular/node_modules/@angular-devkit/core": { - "version": "17.3.9", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-17.3.9.tgz", - "integrity": "sha512-/iKyn5YT7NW5ylrg9yufUydS8byExeQ2HHIwFC4Ebwb/JYYCz+k4tBf2LdP+zXpemDpLznXTQGWia0/yJjG8Vg==", + "version": "17.3.11", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-17.3.11.tgz", + "integrity": "sha512-vTNDYNsLIWpYk2I969LMQFH29GTsLzxNk/0cLw5q56ARF0v5sIWfHYwGTS88jdDqIpuuettcSczbxeA7EuAmqQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1520,13 +1520,13 @@ } }, "node_modules/@angular-devkit/schematics": { - "version": "17.3.9", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-17.3.9.tgz", - "integrity": "sha512-9qg+uWywgAtaQlvbnCQv47hcL6ZuA+d9ucgZ0upZftBllZ2vp5WIthCPb2mB0uBkj84Csmtz9MsErFjOQtTj4g==", + "version": "17.3.11", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-17.3.11.tgz", + "integrity": "sha512-I5wviiIqiFwar9Pdk30Lujk8FczEEc18i22A5c6Z9lbmhPQdTroDnEQdsfXjy404wPe8H62s0I15o4pmMGfTYQ==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "17.3.9", + "@angular-devkit/core": "17.3.11", "jsonc-parser": "3.2.1", "magic-string": "0.30.8", "ora": "5.4.1", @@ -1539,9 +1539,9 @@ } }, "node_modules/@angular-devkit/schematics/node_modules/@angular-devkit/core": { - "version": "17.3.9", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-17.3.9.tgz", - "integrity": "sha512-/iKyn5YT7NW5ylrg9yufUydS8byExeQ2HHIwFC4Ebwb/JYYCz+k4tBf2LdP+zXpemDpLznXTQGWia0/yJjG8Vg==", + "version": "17.3.11", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-17.3.11.tgz", + "integrity": "sha512-vTNDYNsLIWpYk2I969LMQFH29GTsLzxNk/0cLw5q56ARF0v5sIWfHYwGTS88jdDqIpuuettcSczbxeA7EuAmqQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1723,16 +1723,16 @@ } }, "node_modules/@angular/cli": { - "version": "17.3.9", - "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-17.3.9.tgz", - "integrity": "sha512-b5RGu5RO4VKZlMQDatwABAn1qocgD9u4IrGN2dvHDcrz5apTKYftUdGyG42vngyDNBCg1mWkSDQEWK4f2HfuGg==", + "version": "17.3.11", + "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-17.3.11.tgz", + "integrity": "sha512-8R9LwAGL8hGAWJ4mNG9ZPUrBUzIdmst0Ldua6RJJ+PrqgjX+8IbO+lNnfrOY/XY+Z3LXbCEJflL26f9czCvTPQ==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/architect": "0.1703.9", - "@angular-devkit/core": "17.3.9", - "@angular-devkit/schematics": "17.3.9", - "@schematics/angular": "17.3.9", + "@angular-devkit/architect": "0.1703.11", + "@angular-devkit/core": "17.3.11", + "@angular-devkit/schematics": "17.3.11", + "@schematics/angular": "17.3.11", "@yarnpkg/lockfile": "1.1.0", "ansi-colors": "4.1.3", "ini": "4.1.2", @@ -1758,13 +1758,13 @@ } }, "node_modules/@angular/cli/node_modules/@angular-devkit/architect": { - "version": "0.1703.9", - "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.1703.9.tgz", - "integrity": "sha512-kEPfTOVnzrJxPGTvaXy8653HU9Fucxttx9gVfQR1yafs+yIEGx3fKGKe89YPmaEay32bIm7ZUpxDF1FO14nkdQ==", + "version": "0.1703.11", + "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.1703.11.tgz", + "integrity": "sha512-YNasVZk4rYdcM6M+KRH8PUBhVyJfqzUYLpO98GgRokW+taIDgifckSlmfDZzQRbw45qiwei1IKCLqcpC8nM5Tw==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "17.3.9", + "@angular-devkit/core": "17.3.11", "rxjs": "7.8.1" }, "engines": { @@ -1774,9 +1774,9 @@ } }, "node_modules/@angular/cli/node_modules/@angular-devkit/core": { - "version": "17.3.9", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-17.3.9.tgz", - "integrity": "sha512-/iKyn5YT7NW5ylrg9yufUydS8byExeQ2HHIwFC4Ebwb/JYYCz+k4tBf2LdP+zXpemDpLznXTQGWia0/yJjG8Vg==", + "version": "17.3.11", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-17.3.11.tgz", + "integrity": "sha512-vTNDYNsLIWpYk2I969LMQFH29GTsLzxNk/0cLw5q56ARF0v5sIWfHYwGTS88jdDqIpuuettcSczbxeA7EuAmqQ==", "dev": true, "license": "MIT", "dependencies": { @@ -7173,9 +7173,9 @@ } }, "node_modules/@ngtools/webpack": { - "version": "17.3.9", - "resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-17.3.9.tgz", - "integrity": "sha512-2+NvEQuYKRWdZaJbRJWEnR48tpW0uYbhwfHBHLDI9Kazb3mb0oAwYBVXdq+TtDLBypXnMsFpCewjRHTvkVx4/A==", + "version": "17.3.11", + "resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-17.3.11.tgz", + "integrity": "sha512-SfTCbplt4y6ak5cf2IfqdoVOsnoNdh/j6Vu+wb8WWABKwZ5yfr2S/Gk6ithSKcdIZhAF8DNBOoyk1EJuf8Xkfg==", "dev": true, "license": "MIT", "engines": { @@ -7900,14 +7900,14 @@ ] }, "node_modules/@schematics/angular": { - "version": "17.3.9", - "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-17.3.9.tgz", - "integrity": "sha512-q6N8mbcYC6cgPyjTrMH7ehULQoUUwEYN4g7uo4ylZ/PFklSLJvpSp4BuuxANgW449qHSBvQfdIoui9ayAUXQzA==", + "version": "17.3.11", + "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-17.3.11.tgz", + "integrity": "sha512-tvJpTgYC+hCnTyLszYRUZVyNTpPd+C44gh5CPTcG3qkqStzXQwynQAf6X/DjtwXbUiPQF0XfF0+0R489GpdZPA==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "17.3.9", - "@angular-devkit/schematics": "17.3.9", + "@angular-devkit/core": "17.3.11", + "@angular-devkit/schematics": "17.3.11", "jsonc-parser": "3.2.1" }, "engines": { @@ -7917,9 +7917,9 @@ } }, "node_modules/@schematics/angular/node_modules/@angular-devkit/core": { - "version": "17.3.9", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-17.3.9.tgz", - "integrity": "sha512-/iKyn5YT7NW5ylrg9yufUydS8byExeQ2HHIwFC4Ebwb/JYYCz+k4tBf2LdP+zXpemDpLznXTQGWia0/yJjG8Vg==", + "version": "17.3.11", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-17.3.11.tgz", + "integrity": "sha512-vTNDYNsLIWpYk2I969LMQFH29GTsLzxNk/0cLw5q56ARF0v5sIWfHYwGTS88jdDqIpuuettcSczbxeA7EuAmqQ==", "dev": true, "license": "MIT", "dependencies": { @@ -18809,9 +18809,9 @@ } }, "node_modules/http-proxy-middleware": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.6.tgz", - "integrity": "sha512-ya/UeJ6HVBYxrgYotAZo1KvPWlgB48kUJLDePFeneHsVujFaW5WNj2NgWCAE//B1Dl02BIfYlpNgBy8Kf8Rjmw==", + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.7.tgz", + "integrity": "sha512-fgVY8AV7qU7z/MmXJ/rxwbrtQH4jBQ9m7kp3llF0liB7glmFeVZFBepQb32T3y8n8k2+AEYuMPCpinYW+/CuRA==", "dev": true, "license": "MIT", "dependencies": { @@ -32008,9 +32008,9 @@ } }, "node_modules/vite": { - "version": "5.1.7", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.1.7.tgz", - "integrity": "sha512-sgnEEFTZYMui/sTlH1/XEnVNHMujOahPLGMxn1+5sIT45Xjng1Ec1K78jRP15dSmVgg5WBin9yO81j3o9OxofA==", + "version": "5.1.8", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.1.8.tgz", + "integrity": "sha512-mB8ToUuSmzODSpENgvpFk2fTiU/YQ1tmcVJJ4WZbq4fPdGJkFNVcmVL5k7iDug6xzWjjuGDKAuSievIsD6H7Xw==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 8e7b06bfb9d..dc540b844e7 100644 --- a/package.json +++ b/package.json @@ -35,12 +35,12 @@ "libs/*" ], "devDependencies": { - "@angular-devkit/build-angular": "17.3.9", + "@angular-devkit/build-angular": "17.3.11", "@angular-eslint/eslint-plugin": "17.5.3", "@angular-eslint/eslint-plugin-template": "17.5.3", "@angular-eslint/schematics": "17.5.3", "@angular-eslint/template-parser": "17.5.3", - "@angular/cli": "17.3.9", + "@angular/cli": "17.3.11", "@angular/compiler-cli": "17.3.12", "@angular/elements": "17.3.12", "@babel/core": "7.24.9", @@ -48,7 +48,7 @@ "@compodoc/compodoc": "1.1.26", "@electron/notarize": "2.5.0", "@electron/rebuild": "3.7.0", - "@ngtools/webpack": "17.3.9", + "@ngtools/webpack": "17.3.11", "@storybook/addon-a11y": "8.4.5", "@storybook/addon-actions": "8.4.5", "@storybook/addon-designs": "8.0.4", From 178ef353d4653cad7886cbff73354c073663f76d Mon Sep 17 00:00:00 2001 From: Will Martin Date: Wed, 27 Nov 2024 15:33:37 -0500 Subject: [PATCH 62/68] [CL-269] extension router transitions (#11989) --- .github/CODEOWNERS | 1 + .../src/popup/app-routing.animations.ts | 293 +++++------------- apps/browser/src/popup/app-routing.module.ts | 142 ++++----- apps/browser/src/popup/app.component.ts | 21 +- 4 files changed, 160 insertions(+), 297 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index a90545ab57c..cf656967da0 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -102,6 +102,7 @@ apps/desktop/src/services/duckduckgo-message-handler.service.ts @bitwarden/team- .storybook @bitwarden/team-design-system libs/components @bitwarden/team-design-system apps/browser/src/platform/popup/layout @bitwarden/team-design-system +apps/browser/src/popup/app-routing.animations.ts @bitwarden/team-design-system apps/web/src/app/layouts @bitwarden/team-design-system ## Desktop native module ## diff --git a/apps/browser/src/popup/app-routing.animations.ts b/apps/browser/src/popup/app-routing.animations.ts index 061067c717e..6af47934ef9 100644 --- a/apps/browser/src/popup/app-routing.animations.ts +++ b/apps/browser/src/popup/app-routing.animations.ts @@ -1,4 +1,19 @@ -import { animate, group, query, style, transition, trigger } from "@angular/animations"; +import { + animate, + AnimationMetadata, + group, + query, + style, + transition, + trigger, +} from "@angular/animations"; + +/** + * Determines the router transition behavior. + * Changing between elevations will animate from the right. + * Navigating between two pages of the same elevation will not animate. + */ +export type RouteElevation = 0 | 1 | 2 | 3 | 4; const queryShown = query( ":enter, :leave", @@ -13,11 +28,15 @@ const queryChildRoute = query("router-outlet ~ *", [style({}), animate(1, style( optional: true, }); -const speed = "0.4s"; +const speedX = "0.225s"; +const speedY = "0.3s"; -export function queryTranslate( - direction: string, - axis: string, +type TranslateDirection = "enter" | "leave"; +type TranslationAxis = "X" | "Y"; + +function queryTranslate( + direction: TranslateDirection, + axis: TranslationAxis, from: number, to: number, zIndex = 1000, @@ -30,217 +49,67 @@ export function queryTranslate( zIndex: zIndex, boxShadow: "0 3px 2px -2px gray", }), - animate(speed + " ease-in-out", style({ transform: "translate" + axis + "(" + to + "%)" })), + animate( + (axis === "X" ? speedX : speedY) + " ease-in-out", + style({ + transform: "translate" + axis + "(" + to + "%)", + }), + ), ], - { optional: true }, - ); -} - -export function queryTranslateX(direction: string, from: number, to: number, zIndex = 1000) { - return queryTranslate(direction, "X", from, to, zIndex); -} - -export function queryTranslateY(direction: string, from: number, to: number, zIndex = 1000) { - return queryTranslate(direction, "Y", from, to, zIndex); -} - -const inSlideLeft = [ - queryShown, - group([queryTranslateX("enter", 100, 0), queryTranslateX("leave", 0, -100), queryChildRoute]), -]; - -const outSlideRight = [ - queryShown, - group([queryTranslateX("enter", -100, 0), queryTranslateX("leave", 0, 100)]), -]; - -const inSlideUp = [ - queryShown, - group([queryTranslateY("enter", 100, 0, 1010), queryTranslateY("leave", 0, 0), queryChildRoute]), -]; - -const outSlideDown = [ - queryShown, - group([queryTranslateY("enter", 0, 0), queryTranslateY("leave", 0, 100, 1010)]), -]; - -const inSlideDown = [ - queryShown, - group([queryTranslateY("enter", -100, 0, 1010), queryTranslateY("leave", 0, 0), queryChildRoute]), -]; - -// eslint-disable-next-line -const outSlideUp = [ - queryShown, - group([queryTranslateY("enter", 0, 0), queryTranslateY("leave", 0, -100, 1010)]), -]; - -export function tabsToCiphers(fromState: string, toState: string) { - if (fromState == null || toState === null || toState.indexOf("ciphers_") === -1) { - return false; - } - return ( - (fromState.indexOf("ciphers_") === 0 && fromState.indexOf("ciphers_direction=b") === -1) || - fromState === "tabs" - ); -} - -export function ciphersToTabs(fromState: string, toState: string) { - if (fromState == null || toState === null || fromState.indexOf("ciphers_") === -1) { - return false; - } - return toState.indexOf("ciphers_direction=b") === 0 || toState === "tabs"; -} - -export function ciphersToView(fromState: string, toState: string) { - if (fromState == null || toState === null) { - return false; - } - return ( - fromState.indexOf("ciphers_") === 0 && - (toState === "view-cipher" || toState === "add-cipher" || toState === "clone-cipher") + { + optional: true, + }, ); } -export function viewToCiphers(fromState: string, toState: string) { - if (fromState == null || toState === null) { - return false; - } - return ( - (fromState === "view-cipher" || fromState === "add-cipher" || fromState === "clone-cipher") && - toState.indexOf("ciphers_") === 0 - ); -} +const animations = { + slideInFromRight: [ + queryShown, + group([ + queryTranslate("enter", "X", 100, 0, 1010), + queryTranslate("leave", "X", 0, 0), + queryChildRoute, + ]), + ], + slideOutToRight: [ + queryShown, + group([queryTranslate("enter", "X", 0, 0), queryTranslate("leave", "X", 0, 100, 1010)]), + ], + /** --- Not used --- */ + // slideInFromTop: [ + // queryShown, + // group([ + // queryTranslate("enter", "Y", -100, 0, 1010), + // queryTranslate("leave", "Y", 0, 0), + // queryChildRoute, + // ]), + // ], + // slideOutToTop: [ + // queryShown, + // group([queryTranslate("enter", "Y", 0, 0), queryTranslate("leave", "Y", 0, -100, 1010)]), + // ], +} satisfies Record; export const routerTransition = trigger("routerTransition", [ - transition("void => home", inSlideLeft), - transition("void => tabs", inSlideLeft), - - transition("home => environment, home => login, home => register", inSlideUp), - - transition("login => home", outSlideDown), - transition("login => hint", inSlideUp), - transition("login => tabs, login => 2fa, login => login-with-device", inSlideLeft), - - transition("hint => login, register => home, environment => home", outSlideDown), - - transition("2fa => login", outSlideRight), - transition("2fa => 2fa-options", inSlideUp), - transition("2fa-options => 2fa", outSlideDown), - transition("2fa => tabs", inSlideLeft), - - transition("login-with-device => tabs, login-with-device => 2fa", inSlideLeft), - transition("login-with-device => login", outSlideRight), - - transition("admin-approval-requested => tabs, admin-approval-requested => 2fa", inSlideLeft), - transition("admin-approval-requested => login", outSlideRight), - - transition(tabsToCiphers, inSlideLeft), - transition(ciphersToTabs, outSlideRight), - - transition(ciphersToView, inSlideUp), - transition(viewToCiphers, outSlideDown), - - transition("tabs => view-cipher", inSlideUp), - transition("view-cipher => tabs", outSlideDown), - - transition("view-cipher => edit-cipher, view-cipher => cipher-password-history", inSlideUp), - transition( - "edit-cipher => view-cipher, cipher-password-history => view-cipher, edit-cipher => tabs", - outSlideDown, - ), - - transition("view-cipher => clone-cipher", inSlideUp), - transition("clone-cipher => view-cipher, clone-cipher => tabs", outSlideDown), - - transition("view-cipher => share-cipher", inSlideUp), - transition("share-cipher => view-cipher", outSlideDown), - - transition("tabs => add-cipher", inSlideUp), - transition("add-cipher => tabs", outSlideDown), - - transition("generator => generator-history, tabs => generator-history", inSlideLeft), - transition("generator-history => generator, generator-history => tabs", outSlideRight), - - transition( - "add-cipher => generator, edit-cipher => generator, clone-cipher => generator", - inSlideUp, - ), - transition( - "generator => add-cipher, generator => edit-cipher, generator => clone-cipher", - outSlideDown, - ), - - transition("edit-cipher => attachments, edit-cipher => collections", inSlideLeft), - transition("attachments => edit-cipher, collections => edit-cipher", outSlideRight), - - transition("clone-cipher => attachments, clone-cipher => collections", inSlideLeft), - transition("attachments => clone-cipher, collections => clone-cipher", outSlideRight), - - transition("tabs => account-security", inSlideLeft), - transition("account-security => tabs", outSlideRight), - - transition("tabs => assign-collections", inSlideLeft), - transition("assign-collections => tabs", outSlideRight), - - // Vault settings - transition("tabs => vault-settings", inSlideLeft), - transition("vault-settings => tabs", outSlideRight), - - transition("vault-settings => import", inSlideLeft), - transition("import => vault-settings", outSlideRight), - - transition("vault-settings => export", inSlideLeft), - transition("export => vault-settings", outSlideRight), - - transition("vault-settings => folders", inSlideLeft), - transition("folders => vault-settings", outSlideRight), - - transition("folders => edit-folder, folders => add-folder", inSlideUp), - transition("edit-folder => folders, add-folder => folders", outSlideDown), - - transition("vault-settings => sync", inSlideLeft), - transition("sync => vault-settings", outSlideRight), - - transition("vault-settings => trash", inSlideLeft), - transition("trash => vault-settings", outSlideRight), - - transition("trash => view-cipher", inSlideLeft), - transition("view-cipher => trash", outSlideRight), - - // Appearance settings - transition("tabs => appearance", inSlideLeft), - transition("appearance => tabs", outSlideRight), - - transition("tabs => premium", inSlideLeft), - transition("premium => tabs", outSlideRight), - - transition("tabs => lock", inSlideDown), - - transition("tabs => about", inSlideLeft), - transition("about => tabs", outSlideRight), - - transition("tabs => send-type", inSlideLeft), - transition("send-type => tabs", outSlideRight), - - transition("tabs => add-send, send-type => add-send", inSlideUp), - transition("add-send => tabs, add-send => send-type", outSlideDown), - - transition("tabs => edit-send, send-type => edit-send", inSlideUp), - transition("edit-send => tabs, edit-send => send-type", outSlideDown), - - // Notification settings - transition("tabs => notifications", inSlideLeft), - transition("notifications => tabs", outSlideRight), - - transition("notifications => excluded-domains", inSlideLeft), - transition("excluded-domains => notifications", outSlideRight), - - transition("tabs => autofill", inSlideLeft), - transition("autofill => tabs", outSlideRight), - - transition("* => account-switcher", inSlideUp), - transition("account-switcher => *", outSlideDown), - - transition("lock => *", outSlideDown), + transition("0 => 1", animations.slideInFromRight), + transition("0 => 2", animations.slideInFromRight), + transition("0 => 3", animations.slideInFromRight), + transition("0 => 4", animations.slideInFromRight), + transition("1 => 2", animations.slideInFromRight), + transition("1 => 3", animations.slideInFromRight), + transition("1 => 4", animations.slideInFromRight), + transition("2 => 3", animations.slideInFromRight), + transition("2 => 4", animations.slideInFromRight), + transition("3 => 4", animations.slideInFromRight), + + transition("1 => 0", animations.slideOutToRight), + transition("2 => 0", animations.slideOutToRight), + transition("2 => 1", animations.slideOutToRight), + transition("3 => 0", animations.slideOutToRight), + transition("3 => 1", animations.slideOutToRight), + transition("3 => 2", animations.slideOutToRight), + transition("4 => 0", animations.slideOutToRight), + transition("4 => 1", animations.slideOutToRight), + transition("4 => 2", animations.slideOutToRight), + transition("4 => 3", animations.slideOutToRight), ]); diff --git a/apps/browser/src/popup/app-routing.module.ts b/apps/browser/src/popup/app-routing.module.ts index 5e6f38e80b0..77a720557c6 100644 --- a/apps/browser/src/popup/app-routing.module.ts +++ b/apps/browser/src/popup/app-routing.module.ts @@ -123,6 +123,7 @@ import { TrashComponent } from "../vault/popup/settings/trash.component"; import { VaultSettingsV2Component } from "../vault/popup/settings/vault-settings-v2.component"; import { VaultSettingsComponent } from "../vault/popup/settings/vault-settings.component"; +import { RouteElevation } from "./app-routing.animations"; import { debounceNavigationGuard } from "./services/debounce-navigation.service"; import { TabsV2Component } from "./tabs-v2.component"; import { TabsComponent } from "./tabs.component"; @@ -131,13 +132,10 @@ import { TabsComponent } from "./tabs.component"; * Data properties acceptable for use in extension route objects */ export interface RouteDataProperties { + elevation: RouteElevation; + /** - * A state string which identifies the current route for the sake of transition animation logic. - * The state string is passed into [@routerTransition] in the app.component. - */ - state: string; - /** - * A boolean to indicate that the URL should not be saved in memory in the BrowserRouterSvc. + * A boolean to indicate that the URL should not be saved in memory in the BrowserRouterService. */ doNotSaveUrl?: boolean; } @@ -167,19 +165,19 @@ const routes: Routes = [ path: "home", component: HomeComponent, canActivate: [unauthGuardFn(unauthRouteOverrides), unauthUiRefreshRedirect("/login")], - data: { state: "home" } satisfies RouteDataProperties, + data: { elevation: 1 } satisfies RouteDataProperties, }, ...extensionRefreshSwap(Fido2V1Component, Fido2Component, { path: "fido2", canActivate: [fido2AuthGuard], - data: { state: "fido2" } satisfies RouteDataProperties, + data: { elevation: 1 } satisfies RouteDataProperties, }), { path: "lock", component: LockComponent, canActivate: [lockGuard()], canMatch: [extensionRefreshRedirect("/lockV2")], - data: { state: "lock", doNotSaveUrl: true } satisfies RouteDataProperties, + data: { elevation: 1, doNotSaveUrl: true } satisfies RouteDataProperties, }, ...twofactorRefactorSwap( TwoFactorComponent, @@ -187,12 +185,12 @@ const routes: Routes = [ { path: "2fa", canActivate: [unauthGuardFn(unauthRouteOverrides)], - data: { state: "2fa" } satisfies RouteDataProperties, + data: { elevation: 1 } satisfies RouteDataProperties, }, { path: "2fa", canActivate: [unauthGuardFn(unauthRouteOverrides)], - data: { state: "2fa" } satisfies RouteDataProperties, + data: { elevation: 1 } satisfies RouteDataProperties, children: [ { path: "", @@ -205,201 +203,207 @@ const routes: Routes = [ path: "2fa-options", component: TwoFactorOptionsComponent, canActivate: [unauthGuardFn(unauthRouteOverrides)], - data: { state: "2fa-options" } satisfies RouteDataProperties, + data: { elevation: 1 } satisfies RouteDataProperties, }, { path: "sso", component: SsoComponent, canActivate: [unauthGuardFn(unauthRouteOverrides)], - data: { state: "sso" } satisfies RouteDataProperties, + data: { elevation: 1 } satisfies RouteDataProperties, }, { path: "set-password", component: SetPasswordComponent, - data: { state: "set-password" } satisfies RouteDataProperties, + data: { elevation: 1 } satisfies RouteDataProperties, }, { path: "remove-password", component: RemovePasswordComponent, canActivate: [authGuard], - data: { state: "remove-password" } satisfies RouteDataProperties, + data: { elevation: 1 } satisfies RouteDataProperties, }, { path: "register", component: RegisterComponent, canActivate: [unauthGuardFn(unauthRouteOverrides)], - data: { state: "register" } satisfies RouteDataProperties, + data: { elevation: 1 } satisfies RouteDataProperties, }, { path: "environment", component: EnvironmentComponent, canActivate: [unauthGuardFn(unauthRouteOverrides)], - data: { state: "environment" } satisfies RouteDataProperties, + data: { elevation: 1 } satisfies RouteDataProperties, }, { path: "ciphers", component: VaultItemsComponent, canActivate: [authGuard], - data: { state: "ciphers" } satisfies RouteDataProperties, + data: { elevation: 1 } satisfies RouteDataProperties, }, ...extensionRefreshSwap(ViewComponent, ViewV2Component, { path: "view-cipher", canActivate: [authGuard], - data: { state: "view-cipher" } satisfies RouteDataProperties, + data: { + // Above "trash" + elevation: 3, + } satisfies RouteDataProperties, }), ...extensionRefreshSwap(PasswordHistoryComponent, PasswordHistoryV2Component, { path: "cipher-password-history", canActivate: [authGuard], - data: { state: "cipher-password-history" } satisfies RouteDataProperties, + data: { elevation: 1 } satisfies RouteDataProperties, }), ...extensionRefreshSwap(AddEditComponent, AddEditV2Component, { path: "add-cipher", canActivate: [authGuard, debounceNavigationGuard()], - data: { state: "add-cipher" } satisfies RouteDataProperties, + data: { elevation: 1 } satisfies RouteDataProperties, runGuardsAndResolvers: "always", }), ...extensionRefreshSwap(AddEditComponent, AddEditV2Component, { path: "edit-cipher", canActivate: [authGuard, debounceNavigationGuard()], - data: { state: "edit-cipher" } satisfies RouteDataProperties, + data: { + // Above "trash" + elevation: 3, + } satisfies RouteDataProperties, runGuardsAndResolvers: "always", }), { path: "share-cipher", component: ShareComponent, canActivate: [authGuard], - data: { state: "share-cipher" } satisfies RouteDataProperties, + data: { elevation: 1 } satisfies RouteDataProperties, }, { path: "collections", component: CollectionsComponent, canActivate: [authGuard], - data: { state: "collections" } satisfies RouteDataProperties, + data: { elevation: 1 } satisfies RouteDataProperties, }, ...extensionRefreshSwap(AttachmentsComponent, AttachmentsV2Component, { path: "attachments", canActivate: [authGuard], - data: { state: "attachments" } satisfies RouteDataProperties, + data: { elevation: 1 } satisfies RouteDataProperties, }), { path: "generator", component: GeneratorComponent, canActivate: [authGuard], - data: { state: "generator" } satisfies RouteDataProperties, + data: { elevation: 0 } satisfies RouteDataProperties, }, ...extensionRefreshSwap(PasswordGeneratorHistoryComponent, CredentialGeneratorHistoryComponent, { path: "generator-history", canActivate: [authGuard], - data: { state: "generator-history" } satisfies RouteDataProperties, + data: { elevation: 1 } satisfies RouteDataProperties, }), ...extensionRefreshSwap(ImportBrowserComponent, ImportBrowserV2Component, { path: "import", canActivate: [authGuard], - data: { state: "import" } satisfies RouteDataProperties, + data: { elevation: 1 } satisfies RouteDataProperties, }), ...extensionRefreshSwap(ExportBrowserComponent, ExportBrowserV2Component, { path: "export", canActivate: [authGuard], - data: { state: "export" } satisfies RouteDataProperties, + data: { elevation: 2 } satisfies RouteDataProperties, }), ...extensionRefreshSwap(AutofillV1Component, AutofillComponent, { path: "autofill", canActivate: [authGuard], - data: { state: "autofill" } satisfies RouteDataProperties, + data: { elevation: 1 } satisfies RouteDataProperties, }), ...extensionRefreshSwap(AccountSecurityV1Component, AccountSecurityComponent, { path: "account-security", canActivate: [authGuard], - data: { state: "account-security" } satisfies RouteDataProperties, + data: { elevation: 1 } satisfies RouteDataProperties, }), ...extensionRefreshSwap(NotificationsSettingsV1Component, NotificationsSettingsComponent, { path: "notifications", canActivate: [authGuard], - data: { state: "notifications" } satisfies RouteDataProperties, + data: { elevation: 1 } satisfies RouteDataProperties, }), ...extensionRefreshSwap(VaultSettingsComponent, VaultSettingsV2Component, { path: "vault-settings", canActivate: [authGuard], - data: { state: "vault-settings" } satisfies RouteDataProperties, + data: { elevation: 1 } satisfies RouteDataProperties, }), ...extensionRefreshSwap(FoldersComponent, FoldersV2Component, { path: "folders", canActivate: [authGuard], - data: { state: "folders" } satisfies RouteDataProperties, + data: { elevation: 2 } satisfies RouteDataProperties, }), { path: "add-folder", component: FolderAddEditComponent, canActivate: [authGuard], - data: { state: "add-folder" } satisfies RouteDataProperties, + data: { elevation: 1 } satisfies RouteDataProperties, }, { path: "edit-folder", component: FolderAddEditComponent, canActivate: [authGuard], - data: { state: "edit-folder" } satisfies RouteDataProperties, + data: { elevation: 1 } satisfies RouteDataProperties, }, { path: "sync", component: SyncComponent, canActivate: [authGuard], - data: { state: "sync" } satisfies RouteDataProperties, + data: { elevation: 1 } satisfies RouteDataProperties, }, ...extensionRefreshSwap(ExcludedDomainsV1Component, ExcludedDomainsComponent, { path: "excluded-domains", canActivate: [authGuard], - data: { state: "excluded-domains" } satisfies RouteDataProperties, + data: { elevation: 2 } satisfies RouteDataProperties, }), ...extensionRefreshSwap(PremiumComponent, PremiumV2Component, { path: "premium", component: PremiumComponent, canActivate: [authGuard], - data: { state: "premium" } satisfies RouteDataProperties, + data: { elevation: 1 } satisfies RouteDataProperties, }), ...extensionRefreshSwap(AppearanceComponent, AppearanceV2Component, { path: "appearance", canActivate: [authGuard], - data: { state: "appearance" } satisfies RouteDataProperties, + data: { elevation: 1 } satisfies RouteDataProperties, }), ...extensionRefreshSwap(AddEditComponent, AddEditV2Component, { path: "clone-cipher", canActivate: [authGuard], - data: { state: "clone-cipher" } satisfies RouteDataProperties, + data: { elevation: 1 } satisfies RouteDataProperties, }), { path: "send-type", component: SendTypeComponent, canActivate: [authGuard], - data: { state: "send-type" } satisfies RouteDataProperties, + data: { elevation: 1 } satisfies RouteDataProperties, }, ...extensionRefreshSwap(SendAddEditComponent, SendAddEditV2Component, { path: "add-send", canActivate: [authGuard], - data: { state: "add-send" } satisfies RouteDataProperties, + data: { elevation: 1 } satisfies RouteDataProperties, }), ...extensionRefreshSwap(SendAddEditComponent, SendAddEditV2Component, { path: "edit-send", canActivate: [authGuard], - data: { state: "edit-send" } satisfies RouteDataProperties, + data: { elevation: 1 } satisfies RouteDataProperties, }), { path: "send-created", component: SendCreatedComponent, canActivate: [authGuard], - data: { state: "send" } satisfies RouteDataProperties, + data: { elevation: 1 } satisfies RouteDataProperties, }, { path: "update-temp-password", component: UpdateTempPasswordComponent, canActivate: [authGuard], - data: { state: "update-temp-password" } satisfies RouteDataProperties, + data: { elevation: 1 } satisfies RouteDataProperties, }, ...unauthUiRefreshSwap( LoginViaAuthRequestComponentV1, ExtensionAnonLayoutWrapperComponent, { path: "login-with-device", - data: { state: "login-with-device" } satisfies RouteDataProperties, + data: { elevation: 1 } satisfies RouteDataProperties, }, { path: "login-with-device", @@ -413,7 +417,7 @@ const routes: Routes = [ }, showLogo: false, showBackButton: true, - state: "login-with-device", + elevation: 1, } satisfies RouteDataProperties & ExtensionAnonLayoutWrapperData, children: [ { path: "", component: LoginViaAuthRequestComponent }, @@ -430,7 +434,7 @@ const routes: Routes = [ ExtensionAnonLayoutWrapperComponent, { path: "admin-approval-requested", - data: { state: "admin-approval-requested" } satisfies RouteDataProperties, + data: { elevation: 1 } satisfies RouteDataProperties, }, { path: "admin-approval-requested", @@ -444,7 +448,7 @@ const routes: Routes = [ }, showLogo: false, showBackButton: true, - state: "admin-approval-requested", + elevation: 1, } satisfies RouteDataProperties & ExtensionAnonLayoutWrapperData, children: [{ path: "", component: LoginViaAuthRequestComponent }], }, @@ -456,7 +460,7 @@ const routes: Routes = [ path: "hint", canActivate: [unauthGuardFn(unauthRouteOverrides)], data: { - state: "hint", + elevation: 1, } satisfies RouteDataProperties, }, { @@ -474,7 +478,7 @@ const routes: Routes = [ }, pageIcon: UserLockIcon, showBackButton: true, - state: "hint", + elevation: 1, } satisfies RouteDataProperties & ExtensionAnonLayoutWrapperData, children: [ { path: "", component: PasswordHintComponent }, @@ -497,7 +501,7 @@ const routes: Routes = [ { path: "login", canActivate: [unauthGuardFn(unauthRouteOverrides)], - data: { state: "login" }, + data: { elevation: 1 }, }, { path: "", @@ -510,7 +514,7 @@ const routes: Routes = [ pageTitle: { key: "logInToBitwarden", }, - state: "login", + elevation: 1, showAcctSwitcher: true, } satisfies RouteDataProperties & ExtensionAnonLayoutWrapperData, children: [ @@ -535,7 +539,7 @@ const routes: Routes = [ { path: "login-initiated", canActivate: [tdeDecryptionRequiredGuard()], - data: { state: "login-initiated" } satisfies RouteDataProperties, + data: { elevation: 1 } satisfies RouteDataProperties, }, { path: "login-initiated", @@ -554,7 +558,7 @@ const routes: Routes = [ path: "signup", canActivate: [canAccessFeature(FeatureFlag.EmailVerification), unauthGuardFn()], data: { - state: "signup", + elevation: 1, pageIcon: RegistrationUserAddIcon, pageTitle: { key: "createAccount", @@ -581,7 +585,7 @@ const routes: Routes = [ canActivate: [canAccessFeature(FeatureFlag.EmailVerification), unauthGuardFn()], data: { pageIcon: RegistrationLockAltIcon, - state: "finish-signup", + elevation: 1, showBackButton: true, } satisfies RouteDataProperties & ExtensionAnonLayoutWrapperData, children: [ @@ -626,7 +630,7 @@ const routes: Routes = [ pageSubtitle: { key: "finishJoiningThisOrganizationBySettingAMasterPassword", }, - state: "set-password-jit", + elevation: 1, } satisfies RouteDataProperties & AnonLayoutWrapperData, }, ], @@ -635,21 +639,21 @@ const routes: Routes = [ path: "assign-collections", component: AssignCollections, canActivate: [canAccessFeature(FeatureFlag.ExtensionRefresh, true, "/")], - data: { state: "assign-collections" } satisfies RouteDataProperties, + data: { elevation: 1 } satisfies RouteDataProperties, }, ...extensionRefreshSwap(AboutPageComponent, AboutPageV2Component, { path: "about", canActivate: [authGuard], - data: { state: "about" } satisfies RouteDataProperties, + data: { elevation: 1 } satisfies RouteDataProperties, }), ...extensionRefreshSwap(MoreFromBitwardenPageComponent, MoreFromBitwardenPageV2Component, { path: "more-from-bitwarden", canActivate: [authGuard], - data: { state: "moreFromBitwarden" } satisfies RouteDataProperties, + data: { elevation: 2 } satisfies RouteDataProperties, }), ...extensionRefreshSwap(TabsComponent, TabsV2Component, { path: "tabs", - data: { state: "tabs" } satisfies RouteDataProperties, + data: { elevation: 0 } satisfies RouteDataProperties, children: [ { path: "", @@ -661,42 +665,42 @@ const routes: Routes = [ component: CurrentTabComponent, canActivate: [authGuard], canMatch: [extensionRefreshRedirect("/tabs/vault")], - data: { state: "tabs_current" } satisfies RouteDataProperties, + data: { elevation: 0 } satisfies RouteDataProperties, runGuardsAndResolvers: "always", }, ...extensionRefreshSwap(VaultFilterComponent, VaultV2Component, { path: "vault", canActivate: [authGuard], canDeactivate: [clearVaultStateGuard], - data: { state: "tabs_vault" } satisfies RouteDataProperties, + data: { elevation: 0 } satisfies RouteDataProperties, }), ...extensionRefreshSwap(GeneratorComponent, CredentialGeneratorComponent, { path: "generator", canActivate: [authGuard], - data: { state: "tabs_generator" } satisfies RouteDataProperties, + data: { elevation: 0 } satisfies RouteDataProperties, }), ...extensionRefreshSwap(SettingsComponent, SettingsV2Component, { path: "settings", canActivate: [authGuard], - data: { state: "tabs_settings" } satisfies RouteDataProperties, + data: { elevation: 0 } satisfies RouteDataProperties, }), ...extensionRefreshSwap(SendGroupingsComponent, SendV2Component, { path: "send", canActivate: [authGuard], - data: { state: "tabs_send" } satisfies RouteDataProperties, + data: { elevation: 0 } satisfies RouteDataProperties, }), ], }), { path: "account-switcher", component: AccountSwitcherComponent, - data: { state: "account-switcher", doNotSaveUrl: true } satisfies RouteDataProperties, + data: { elevation: 4, doNotSaveUrl: true } satisfies RouteDataProperties, }, { path: "trash", component: TrashComponent, canActivate: [authGuard], - data: { state: "trash" } satisfies RouteDataProperties, + data: { elevation: 2 } satisfies RouteDataProperties, }, ]; diff --git a/apps/browser/src/popup/app.component.ts b/apps/browser/src/popup/app.component.ts index 31f610b7e74..15dfcabe5fa 100644 --- a/apps/browser/src/popup/app.component.ts +++ b/apps/browser/src/popup/app.component.ts @@ -38,8 +38,8 @@ import { DesktopSyncVerificationDialogComponent } from "./components/desktop-syn selector: "app-root", styles: [], animations: [routerTransition], - template: `
- + template: `
+
`, }) export class AppComponent implements OnInit, OnDestroy { @@ -223,23 +223,12 @@ export class AppComponent implements OnInit, OnDestroy { this.destroy$.complete(); } - getState(outlet: RouterOutlet) { + getRouteElevation(outlet: RouterOutlet) { if (!this.routerAnimations) { return; - } else if (outlet.activatedRouteData.state === "ciphers") { - const routeDirection = - (window as any).routeDirection != null ? (window as any).routeDirection : ""; - return ( - "ciphers_direction=" + - routeDirection + - "_" + - (outlet.activatedRoute.queryParams as any).value.folderId + - "_" + - (outlet.activatedRoute.queryParams as any).value.collectionId - ); - } else { - return outlet.activatedRouteData.state; } + + return outlet.activatedRouteData.elevation; } private async recordActivity() { From c0ab62fad0a42ed8afb5c774de7d767f6c1bb1d4 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 28 Nov 2024 10:02:24 +0100 Subject: [PATCH 63/68] [deps] Platform: Update Rust crate homedir to v0.3.4 (#12129) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- apps/desktop/desktop_native/Cargo.lock | 70 +++++++-------------- apps/desktop/desktop_native/core/Cargo.toml | 2 +- 2 files changed, 22 insertions(+), 50 deletions(-) diff --git a/apps/desktop/desktop_native/Cargo.lock b/apps/desktop/desktop_native/Cargo.lock index bf82c1bb74b..a2c732f434f 100644 --- a/apps/desktop/desktop_native/Cargo.lock +++ b/apps/desktop/desktop_native/Cargo.lock @@ -298,12 +298,6 @@ dependencies = [ "sha2", ] -[[package]] -name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - [[package]] name = "bitflags" version = "2.6.0" @@ -1099,7 +1093,7 @@ version = "0.19.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39650279f135469465018daae0ba53357942a5212137515777d5fdca74984a44" dependencies = [ - "bitflags 2.6.0", + "bitflags", "futures-channel", "futures-core", "futures-executor", @@ -1190,12 +1184,12 @@ dependencies = [ [[package]] name = "homedir" -version = "0.3.3" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bed305c13ce3829a09d627f5d43ff738482a09361ae4eb8039993b55fb10e5e" +checksum = "5bdbbd5bc8c5749697ccaa352fa45aff8730cf21c68029c0eef1ffed7c3d6ba2" dependencies = [ "cfg-if", - "nix 0.26.4", + "nix 0.29.0", "widestring", "windows 0.57.0", ] @@ -1299,7 +1293,7 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" dependencies = [ - "bitflags 2.6.0", + "bitflags", "libc", ] @@ -1366,15 +1360,6 @@ version = "2.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" -[[package]] -name = "memoffset" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5de893c32cde5f383baa4c04c5d6dbdd735cfd4a794b0debdb2bb1b421da5ff4" -dependencies = [ - "autocfg", -] - [[package]] name = "memoffset" version = "0.9.1" @@ -1417,7 +1402,7 @@ version = "2.16.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "214f07a80874bb96a8433b3cdfc84980d56c7b02e1a0d7ba4ba0db5cef785e2b" dependencies = [ - "bitflags 2.6.0", + "bitflags", "ctor", "napi-derive", "napi-sys", @@ -1469,26 +1454,13 @@ dependencies = [ "libloading", ] -[[package]] -name = "nix" -version = "0.26.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b" -dependencies = [ - "bitflags 1.3.2", - "cfg-if", - "libc", - "memoffset 0.7.1", - "pin-utils", -] - [[package]] name = "nix" version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" dependencies = [ - "bitflags 2.6.0", + "bitflags", "cfg-if", "cfg_aliases 0.1.1", "libc", @@ -1500,11 +1472,11 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags 2.6.0", + "bitflags", "cfg-if", "cfg_aliases 0.2.1", "libc", - "memoffset 0.9.1", + "memoffset", ] [[package]] @@ -1601,7 +1573,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" dependencies = [ - "bitflags 2.6.0", + "bitflags", "block2", "libc", "objc2", @@ -1617,7 +1589,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" dependencies = [ - "bitflags 2.6.0", + "bitflags", "block2", "objc2", "objc2-foundation", @@ -1647,7 +1619,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" dependencies = [ - "bitflags 2.6.0", + "bitflags", "block2", "libc", "objc2", @@ -1659,7 +1631,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" dependencies = [ - "bitflags 2.6.0", + "bitflags", "block2", "objc2", "objc2-foundation", @@ -1671,7 +1643,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" dependencies = [ - "bitflags 2.6.0", + "bitflags", "block2", "objc2", "objc2-foundation", @@ -2001,7 +1973,7 @@ version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b6dfecf2c74bce2466cabf93f6664d6998a69eb21e39f4207930065b27b771f" dependencies = [ - "bitflags 2.6.0", + "bitflags", ] [[package]] @@ -2105,7 +2077,7 @@ version = "0.38.37" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8acb788b847c24f28525660c4d7758620a7210875711f79e7f663cc152726811" dependencies = [ - "bitflags 2.6.0", + "bitflags", "errno", "libc", "linux-raw-sys", @@ -2156,7 +2128,7 @@ version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9d0283c0a4a22a0f1b0e4edca251aa20b92fc96eaa09b84bec052f9415e9d71" dependencies = [ - "bitflags 2.6.0", + "bitflags", "core-foundation", "core-foundation-sys", "libc", @@ -2623,7 +2595,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9" dependencies = [ - "memoffset 0.9.1", + "memoffset", "tempfile", "winapi", ] @@ -2694,7 +2666,7 @@ version = "0.31.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b66249d3fc69f76fd74c82cc319300faa554e9d865dab1f7cd66cc20db10b280" dependencies = [ - "bitflags 2.6.0", + "bitflags", "rustix", "wayland-backend", "wayland-scanner", @@ -2706,7 +2678,7 @@ version = "0.31.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f81f365b8b4a97f422ac0e8737c438024b5951734506b0e1d775c73030561f4" dependencies = [ - "bitflags 2.6.0", + "bitflags", "wayland-backend", "wayland-client", "wayland-scanner", @@ -2718,7 +2690,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ad1f61b76b6c2d8742e10f9ba5c3737f6530b4c243132c2a2ccc8aa96fe25cd6" dependencies = [ - "bitflags 2.6.0", + "bitflags", "wayland-backend", "wayland-client", "wayland-protocols", diff --git a/apps/desktop/desktop_native/core/Cargo.toml b/apps/desktop/desktop_native/core/Cargo.toml index b3883506c1f..abfca3e0803 100644 --- a/apps/desktop/desktop_native/core/Cargo.toml +++ b/apps/desktop/desktop_native/core/Cargo.toml @@ -31,7 +31,7 @@ async-stream = "=0.3.6" base64 = "=0.22.1" byteorder = "=1.5.0" cbc = { version = "=0.1.2", features = ["alloc"] } -homedir = "=0.3.3" +homedir = "=0.3.4" libc = "=0.2.162" pin-project = "=1.1.7" dirs = "=5.0.1" From 927c2fce43343183521ac8ee42f300f462f1da6f Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 28 Nov 2024 10:12:09 +0100 Subject: [PATCH 64/68] [deps] Platform: Update Rust crate ssh-key to v0.6.7 (#12133) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- apps/desktop/desktop_native/Cargo.lock | 4 ++-- apps/desktop/desktop_native/core/Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/desktop/desktop_native/Cargo.lock b/apps/desktop/desktop_native/Cargo.lock index a2c732f434f..82264617339 100644 --- a/apps/desktop/desktop_native/Cargo.lock +++ b/apps/desktop/desktop_native/Cargo.lock @@ -2320,9 +2320,9 @@ dependencies = [ [[package]] name = "ssh-key" -version = "0.6.6" +version = "0.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca9b366a80cf18bb6406f4cf4d10aebfb46140a8c0c33f666a144c5c76ecbafc" +checksum = "3b86f5297f0f04d08cabaa0f6bff7cb6aec4d9c3b49d87990d63da9d9156a8c3" dependencies = [ "bcrypt-pbkdf", "ed25519-dalek", diff --git a/apps/desktop/desktop_native/core/Cargo.toml b/apps/desktop/desktop_native/core/Cargo.toml index abfca3e0803..6f333c480e6 100644 --- a/apps/desktop/desktop_native/core/Cargo.toml +++ b/apps/desktop/desktop_native/core/Cargo.toml @@ -44,7 +44,7 @@ russh-cryptovec = "=0.7.3" scopeguard = "=1.2.0" sha2 = "=0.10.8" ssh-encoding = "=0.2.0" -ssh-key = { version = "=0.6.6", default-features = false, features = [ +ssh-key = { version = "=0.6.7", default-features = false, features = [ "encryption", "ed25519", "rsa", From ab21b78c53627425167d8a0a248180432c7351e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=9C=A8=20Audrey=20=E2=9C=A8?= Date: Thu, 28 Nov 2024 05:02:21 -0500 Subject: [PATCH 65/68] [PM-15061] extract encryptors from generator service (#12068) * introduce legacy encryptor provider * port credential generation service to encryptor provider --- ...-service-legacy-encryptor-provider.spec.ts | 492 ++++++++++++++++++ .../key-service-legacy-encryptor-provider.ts | 132 +++++ .../cryptography/legacy-encryptor-provider.ts | 42 ++ .../organization-encryptor.abstraction.ts | 33 ++ .../organization-key-encryptor.spec.ts | 125 +++++ .../organization-key-encryptor.ts | 60 +++ .../user-encryptor.abstraction.ts | 0 .../user-key-encryptor.spec.ts | 2 +- .../user-key-encryptor.ts | 2 +- libs/common/src/tools/dependencies.ts | 61 ++- libs/common/src/tools/rx.spec.ts | 99 ++++ libs/common/src/tools/rx.ts | 52 ++ libs/common/src/tools/state/object-key.ts | 25 +- .../src/tools/state/secret-state.spec.ts | 2 +- libs/common/src/tools/state/secret-state.ts | 2 +- .../tools/state/user-state-subject.spec.ts | 3 +- .../src/tools/state/user-state-subject.ts | 164 +++--- .../src/forwarder-settings.component.html | 2 +- .../src/generator-services.module.ts | 54 ++ .../components/src/generator.module.ts | 39 +- libs/tools/generator/components/src/index.ts | 1 + .../generator/core/src/data/generators.ts | 2 +- .../generator/core/src/integration/addy-io.ts | 4 +- .../core/src/integration/duck-duck-go.ts | 3 +- .../core/src/integration/fastmail.ts | 3 +- .../core/src/integration/firefox-relay.ts | 3 +- .../core/src/integration/forward-email.ts | 3 +- .../core/src/integration/simple-login.ts | 4 +- .../credential-generator.service.spec.ts | 149 ++---- .../services/credential-generator.service.ts | 66 +-- .../forwarder-generator-strategy.ts | 2 +- .../src/local-generator-history.service.ts | 2 +- .../send-ui/src/send-form/send-form.module.ts | 38 +- 33 files changed, 1378 insertions(+), 293 deletions(-) create mode 100644 libs/common/src/tools/cryptography/key-service-legacy-encryptor-provider.spec.ts create mode 100644 libs/common/src/tools/cryptography/key-service-legacy-encryptor-provider.ts create mode 100644 libs/common/src/tools/cryptography/legacy-encryptor-provider.ts create mode 100644 libs/common/src/tools/cryptography/organization-encryptor.abstraction.ts create mode 100644 libs/common/src/tools/cryptography/organization-key-encryptor.spec.ts create mode 100644 libs/common/src/tools/cryptography/organization-key-encryptor.ts rename libs/common/src/tools/{state => cryptography}/user-encryptor.abstraction.ts (100%) rename libs/common/src/tools/{state => cryptography}/user-key-encryptor.spec.ts (98%) rename libs/common/src/tools/{state => cryptography}/user-key-encryptor.ts (96%) create mode 100644 libs/tools/generator/components/src/generator-services.module.ts diff --git a/libs/common/src/tools/cryptography/key-service-legacy-encryptor-provider.spec.ts b/libs/common/src/tools/cryptography/key-service-legacy-encryptor-provider.spec.ts new file mode 100644 index 00000000000..12257905d1c --- /dev/null +++ b/libs/common/src/tools/cryptography/key-service-legacy-encryptor-provider.spec.ts @@ -0,0 +1,492 @@ +import { mock } from "jest-mock-extended"; +import { BehaviorSubject, Subject } from "rxjs"; + +import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; +import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { CsprngArray } from "@bitwarden/common/types/csprng"; +import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; +import { OrgKey, UserKey } from "@bitwarden/common/types/key"; +import { KeyService } from "@bitwarden/key-management"; + +import { OrganizationBound, UserBound } from "../dependencies"; + +import { KeyServiceLegacyEncryptorProvider } from "./key-service-legacy-encryptor-provider"; +import { OrganizationEncryptor } from "./organization-encryptor.abstraction"; +import { OrganizationKeyEncryptor } from "./organization-key-encryptor"; +import { UserEncryptor } from "./user-encryptor.abstraction"; +import { UserKeyEncryptor } from "./user-key-encryptor"; + +const encryptService = mock(); +const keyService = mock(); + +const SomeCsprngArray = new Uint8Array(64) as CsprngArray; +const SomeUser = "some user" as UserId; +const AnotherUser = "another user" as UserId; +const SomeUserKey = new SymmetricCryptoKey(SomeCsprngArray) as UserKey; +const SomeOrganization = "some organization" as OrganizationId; +const AnotherOrganization = "another organization" as OrganizationId; +const SomeOrgKey = new SymmetricCryptoKey(SomeCsprngArray) as OrgKey; +const AnotherOrgKey = new SymmetricCryptoKey(SomeCsprngArray) as OrgKey; +const OrgRecords: Record = { + [SomeOrganization]: SomeOrgKey, + [AnotherOrganization]: AnotherOrgKey, +}; + +// Many tests examine the private members of the objects constructed by the +// provider. This is necessary because it's not presently possible to spy +// on the constructors directly. +describe("KeyServiceLegacyEncryptorProvider", () => { + describe("userEncryptor$", () => { + it("emits a user key encryptor bound to the user", async () => { + const userKey$ = new BehaviorSubject(SomeUserKey); + keyService.userKey$.mockReturnValue(userKey$); + const singleUserId$ = new BehaviorSubject(SomeUser); + const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService); + const results: UserBound<"encryptor", UserEncryptor>[] = []; + + provider.userEncryptor$(1, { singleUserId$ }).subscribe((v) => results.push(v)); + + expect(keyService.userKey$).toHaveBeenCalledWith(SomeUser); + expect(results.length).toBe(1); + expect(results[0]).toMatchObject({ + userId: SomeUser, + encryptor: { + userId: SomeUser, + key: SomeUserKey, + dataPacker: { frameSize: 1 }, + }, + }); + expect(results[0].encryptor).toBeInstanceOf(UserKeyEncryptor); + }); + + it("waits until `dependencies.singleUserId$` emits", () => { + const userKey$ = new BehaviorSubject(SomeUserKey); + keyService.userKey$.mockReturnValue(userKey$); + const singleUserId$ = new Subject(); + const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService); + const results: UserBound<"encryptor", UserEncryptor>[] = []; + provider.userEncryptor$(1, { singleUserId$ }).subscribe((v) => results.push(v)); + // precondition: no emissions occur on subscribe + expect(results.length).toBe(0); + + singleUserId$.next(SomeUser); + + expect(results.length).toBe(1); + }); + + it("emits a new user key encryptor each time `dependencies.singleUserId$` emits", () => { + const userKey$ = new BehaviorSubject(SomeUserKey); + keyService.userKey$.mockReturnValue(userKey$); + const singleUserId$ = new Subject(); + const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService); + const results: UserBound<"encryptor", UserEncryptor>[] = []; + provider.userEncryptor$(1, { singleUserId$ }).subscribe((v) => results.push(v)); + + singleUserId$.next(SomeUser); + singleUserId$.next(SomeUser); + + expect(results.length).toBe(2); + expect(results[0]).not.toBe(results[1]); + }); + + it("waits until `userKey$` emits a truthy value", () => { + const userKey$ = new BehaviorSubject(null); + keyService.userKey$.mockReturnValue(userKey$); + const singleUserId$ = new BehaviorSubject(SomeUser); + const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService); + const results: UserBound<"encryptor", UserEncryptor>[] = []; + provider.userEncryptor$(1, { singleUserId$ }).subscribe((v) => results.push(v)); + // precondition: no emissions occur on subscribe + expect(results.length).toBe(0); + + userKey$.next(SomeUserKey); + + expect(results.length).toBe(1); + expect(results[0]).toMatchObject({ + userId: SomeUser, + encryptor: { + userId: SomeUser, + key: SomeUserKey, + dataPacker: { frameSize: 1 }, + }, + }); + }); + + it("emits a user key encryptor each time `userKey$` emits", () => { + const userKey$ = new Subject(); + keyService.userKey$.mockReturnValue(userKey$); + const singleUserId$ = new BehaviorSubject(SomeUser); + const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService); + const results: UserBound<"encryptor", UserEncryptor>[] = []; + provider.userEncryptor$(1, { singleUserId$ }).subscribe((v) => results.push(v)); + + userKey$.next(SomeUserKey); + userKey$.next(SomeUserKey); + + expect(results.length).toBe(2); + }); + + it("errors when the userId changes", () => { + const userKey$ = new BehaviorSubject(SomeUserKey); + keyService.userKey$.mockReturnValue(userKey$); + const singleUserId$ = new Subject(); + const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService); + let error: unknown = false; + provider + .userEncryptor$(1, { singleUserId$ }) + .subscribe({ error: (e: unknown) => (error = e) }); + + singleUserId$.next(SomeUser); + singleUserId$.next(AnotherUser); + + expect(error).toEqual({ expectedUserId: SomeUser, actualUserId: AnotherUser }); + }); + + it("errors when `dependencies.singleUserId$` errors", () => { + const userKey$ = new BehaviorSubject(SomeUserKey); + keyService.userKey$.mockReturnValue(userKey$); + const singleUserId$ = new Subject(); + const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService); + let error: unknown = false; + provider + .userEncryptor$(1, { singleUserId$ }) + .subscribe({ error: (e: unknown) => (error = e) }); + + singleUserId$.error({ some: "error" }); + + expect(error).toEqual({ some: "error" }); + }); + + it("errors once `dependencies.singleUserId$` emits and `userKey$` errors", () => { + const userKey$ = new Subject(); + keyService.userKey$.mockReturnValue(userKey$); + const singleUserId$ = new BehaviorSubject(SomeUser); + const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService); + let error: unknown = false; + provider + .userEncryptor$(1, { singleUserId$ }) + .subscribe({ error: (e: unknown) => (error = e) }); + + userKey$.error({ some: "error" }); + + expect(error).toEqual({ some: "error" }); + }); + + it("completes when `dependencies.singleUserId$` completes", () => { + const userKey$ = new Subject(); + keyService.userKey$.mockReturnValue(userKey$); + const singleUserId$ = new BehaviorSubject(SomeUser); + const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService); + let completed = false; + provider + .userEncryptor$(1, { singleUserId$ }) + .subscribe({ complete: () => (completed = true) }); + + singleUserId$.complete(); + + expect(completed).toBeTrue(); + }); + + it("completes when `userKey$` emits a falsy value after emitting a truthy value", () => { + const userKey$ = new BehaviorSubject(SomeUserKey); + keyService.userKey$.mockReturnValue(userKey$); + const singleUserId$ = new BehaviorSubject(SomeUser); + const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService); + let completed = false; + provider + .userEncryptor$(1, { singleUserId$ }) + .subscribe({ complete: () => (completed = true) }); + + userKey$.next(null); + + expect(completed).toBeTrue(); + }); + + it("completes once `dependencies.singleUserId$` emits and `userKey$` completes", () => { + const userKey$ = new BehaviorSubject(SomeUserKey); + keyService.userKey$.mockReturnValue(userKey$); + const singleUserId$ = new BehaviorSubject(SomeUser); + const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService); + let completed = false; + provider + .userEncryptor$(1, { singleUserId$ }) + .subscribe({ complete: () => (completed = true) }); + + userKey$.complete(); + + expect(completed).toBeTrue(); + }); + }); + + describe("organizationEncryptor$", () => { + it("emits an organization key encryptor bound to the organization", () => { + const orgKey$ = new BehaviorSubject(OrgRecords); + keyService.orgKeys$.mockReturnValue(orgKey$); + const singleOrganizationId$ = new BehaviorSubject< + UserBound<"organizationId", OrganizationId> + >({ + organizationId: SomeOrganization, + userId: SomeUser, + }); + const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService); + const results: OrganizationBound<"encryptor", OrganizationEncryptor>[] = []; + + provider + .organizationEncryptor$(1, { singleOrganizationId$ }) + .subscribe((v) => results.push(v)); + + expect(keyService.orgKeys$).toHaveBeenCalledWith(SomeUser); + expect(results.length).toBe(1); + expect(results[0]).toMatchObject({ + organizationId: SomeOrganization, + encryptor: { + organizationId: SomeOrganization, + key: SomeOrgKey, + dataPacker: { frameSize: 1 }, + }, + }); + expect(results[0].encryptor).toBeInstanceOf(OrganizationKeyEncryptor); + }); + + it("waits until `dependencies.singleOrganizationId$` emits", () => { + const orgKey$ = new BehaviorSubject(OrgRecords); + keyService.orgKeys$.mockReturnValue(orgKey$); + const singleOrganizationId$ = new Subject>(); + const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService); + const results: OrganizationBound<"encryptor", OrganizationEncryptor>[] = []; + provider + .organizationEncryptor$(1, { singleOrganizationId$ }) + .subscribe((v) => results.push(v)); + // precondition: no emissions occur on subscribe + expect(results.length).toBe(0); + + singleOrganizationId$.next({ + organizationId: SomeOrganization, + userId: SomeUser, + }); + + expect(results.length).toBe(1); + }); + + it("emits a new organization key encryptor when `dependencies.singleOrganizationId$` emits", () => { + const orgKey$ = new BehaviorSubject(OrgRecords); + keyService.orgKeys$.mockReturnValue(orgKey$); + const singleOrganizationId$ = new Subject>(); + const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService); + const results: OrganizationBound<"encryptor", OrganizationEncryptor>[] = []; + provider + .organizationEncryptor$(1, { singleOrganizationId$ }) + .subscribe((v) => results.push(v)); + // precondition: no emissions occur on subscribe + expect(results.length).toBe(0); + + singleOrganizationId$.next({ + organizationId: SomeOrganization, + userId: SomeUser, + }); + singleOrganizationId$.next({ + organizationId: SomeOrganization, + userId: SomeUser, + }); + + expect(results.length).toBe(2); + expect(results[0]).not.toBe(results[1]); + }); + + it("waits until `orgKeys$` emits a truthy value", () => { + const orgKey$ = new BehaviorSubject>(null); + keyService.orgKeys$.mockReturnValue(orgKey$); + const singleOrganizationId$ = new BehaviorSubject< + UserBound<"organizationId", OrganizationId> + >({ + organizationId: SomeOrganization, + userId: SomeUser, + }); + const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService); + const results: OrganizationBound<"encryptor", OrganizationEncryptor>[] = []; + provider + .organizationEncryptor$(1, { singleOrganizationId$ }) + .subscribe((v) => results.push(v)); + // precondition: no emissions occur on subscribe + expect(results.length).toBe(0); + + orgKey$.next(OrgRecords); + + expect(results.length).toBe(1); + expect(results[0]).toMatchObject({ + organizationId: SomeOrganization, + encryptor: { + organizationId: SomeOrganization, + key: SomeOrgKey, + dataPacker: { frameSize: 1 }, + }, + }); + }); + + it("emits an organization key encryptor each time `orgKeys$` emits", () => { + const orgKey$ = new Subject>(); + keyService.orgKeys$.mockReturnValue(orgKey$); + const singleOrganizationId$ = new BehaviorSubject< + UserBound<"organizationId", OrganizationId> + >({ + organizationId: SomeOrganization, + userId: SomeUser, + }); + const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService); + const results: OrganizationBound<"encryptor", OrganizationEncryptor>[] = []; + provider + .organizationEncryptor$(1, { singleOrganizationId$ }) + .subscribe((v) => results.push(v)); + + orgKey$.next(OrgRecords); + orgKey$.next(OrgRecords); + + expect(results.length).toBe(2); + }); + + it("errors when the userId changes", () => { + const orgKey$ = new BehaviorSubject(OrgRecords); + keyService.orgKeys$.mockReturnValue(orgKey$); + const singleOrganizationId$ = new Subject>(); + const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService); + let error: unknown = false; + provider + .organizationEncryptor$(1, { singleOrganizationId$ }) + .subscribe({ error: (e: unknown) => (error = e) }); + + singleOrganizationId$.next({ userId: SomeUser, organizationId: SomeOrganization }); + singleOrganizationId$.next({ userId: AnotherUser, organizationId: SomeOrganization }); + + expect(error).toEqual({ expectedUserId: SomeUser, actualUserId: AnotherUser }); + }); + + it("errors when the organizationId changes", () => { + const orgKey$ = new BehaviorSubject(OrgRecords); + keyService.orgKeys$.mockReturnValue(orgKey$); + const singleOrganizationId$ = new Subject>(); + const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService); + let error: unknown = false; + provider + .organizationEncryptor$(1, { singleOrganizationId$ }) + .subscribe({ error: (e: unknown) => (error = e) }); + + singleOrganizationId$.next({ userId: SomeUser, organizationId: SomeOrganization }); + singleOrganizationId$.next({ userId: SomeUser, organizationId: AnotherOrganization }); + + expect(error).toEqual({ + expectedOrganizationId: SomeOrganization, + actualOrganizationId: AnotherOrganization, + }); + }); + + it("errors when `dependencies.singleOrganizationId$` errors", () => { + const orgKey$ = new BehaviorSubject(OrgRecords); + keyService.orgKeys$.mockReturnValue(orgKey$); + const singleOrganizationId$ = new Subject>(); + const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService); + let error: unknown = false; + provider + .organizationEncryptor$(1, { singleOrganizationId$ }) + .subscribe({ error: (e: unknown) => (error = e) }); + + singleOrganizationId$.error({ some: "error" }); + + expect(error).toEqual({ some: "error" }); + }); + + it("errors once `dependencies.singleOrganizationId$` emits and `orgKeys$` errors", () => { + const orgKey$ = new Subject>(); + keyService.orgKeys$.mockReturnValue(orgKey$); + const singleOrganizationId$ = new BehaviorSubject< + UserBound<"organizationId", OrganizationId> + >({ + organizationId: SomeOrganization, + userId: SomeUser, + }); + const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService); + let error: unknown = false; + provider + .organizationEncryptor$(1, { singleOrganizationId$ }) + .subscribe({ error: (e: unknown) => (error = e) }); + + orgKey$.error({ some: "error" }); + + expect(error).toEqual({ some: "error" }); + }); + + it("errors when the user lacks the requested org key", () => { + const orgKey$ = new BehaviorSubject>({}); + keyService.orgKeys$.mockReturnValue(orgKey$); + const singleOrganizationId$ = new BehaviorSubject< + UserBound<"organizationId", OrganizationId> + >({ + organizationId: SomeOrganization, + userId: SomeUser, + }); + const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService); + let error: unknown = false; + + provider + .organizationEncryptor$(1, { singleOrganizationId$ }) + .subscribe({ error: (e: unknown) => (error = e) }); + + expect(error).toBeInstanceOf(Error); + }); + + it("completes when `dependencies.singleOrganizationId$` completes", () => { + const orgKey$ = new BehaviorSubject(OrgRecords); + keyService.orgKeys$.mockReturnValue(orgKey$); + const singleOrganizationId$ = new Subject>(); + const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService); + let completed = false; + provider + .organizationEncryptor$(1, { singleOrganizationId$ }) + .subscribe({ complete: () => (completed = true) }); + + singleOrganizationId$.complete(); + + expect(completed).toBeTrue(); + }); + + it("completes when `orgKeys$` emits a falsy value after emitting a truthy value", () => { + const orgKey$ = new Subject>(); + keyService.orgKeys$.mockReturnValue(orgKey$); + const singleOrganizationId$ = new BehaviorSubject< + UserBound<"organizationId", OrganizationId> + >({ + organizationId: SomeOrganization, + userId: SomeUser, + }); + const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService); + let completed = false; + provider + .organizationEncryptor$(1, { singleOrganizationId$ }) + .subscribe({ complete: () => (completed = true) }); + + orgKey$.next(OrgRecords); + orgKey$.next(null); + + expect(completed).toBeTrue(); + }); + + it("completes once `dependencies.singleOrganizationId$` emits and `userKey$` completes", () => { + const orgKey$ = new Subject>(); + keyService.orgKeys$.mockReturnValue(orgKey$); + const singleOrganizationId$ = new BehaviorSubject< + UserBound<"organizationId", OrganizationId> + >({ + organizationId: SomeOrganization, + userId: SomeUser, + }); + const provider = new KeyServiceLegacyEncryptorProvider(encryptService, keyService); + let completed = false; + provider + .organizationEncryptor$(1, { singleOrganizationId$ }) + .subscribe({ complete: () => (completed = true) }); + + orgKey$.complete(); + + expect(completed).toBeTrue(); + }); + }); +}); diff --git a/libs/common/src/tools/cryptography/key-service-legacy-encryptor-provider.ts b/libs/common/src/tools/cryptography/key-service-legacy-encryptor-provider.ts new file mode 100644 index 00000000000..f3d6c82ffcb --- /dev/null +++ b/libs/common/src/tools/cryptography/key-service-legacy-encryptor-provider.ts @@ -0,0 +1,132 @@ +import { + connect, + dematerialize, + map, + materialize, + ReplaySubject, + skipWhile, + switchMap, + takeUntil, + takeWhile, +} from "rxjs"; + +import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service"; +import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; +import { KeyService } from "@bitwarden/key-management"; + +import { + OrganizationBound, + SingleOrganizationDependency, + SingleUserDependency, + UserBound, +} from "../dependencies"; +import { anyComplete, errorOnChange } from "../rx"; +import { PaddedDataPacker } from "../state/padded-data-packer"; + +import { LegacyEncryptorProvider } from "./legacy-encryptor-provider"; +import { OrganizationEncryptor } from "./organization-encryptor.abstraction"; +import { OrganizationKeyEncryptor } from "./organization-key-encryptor"; +import { UserEncryptor } from "./user-encryptor.abstraction"; +import { UserKeyEncryptor } from "./user-key-encryptor"; + +/** Creates encryptors + */ +export class KeyServiceLegacyEncryptorProvider implements LegacyEncryptorProvider { + /** Instantiates the legacy encryptor provider. + * @param encryptService injected into encryptors to perform encryption + * @param keyService looks up keys for construction into an encryptor + */ + constructor( + private readonly encryptService: EncryptService, + private readonly keyService: KeyService, + ) {} + + userEncryptor$(frameSize: number, dependencies: SingleUserDependency) { + const packer = new PaddedDataPacker(frameSize); + const encryptor$ = dependencies.singleUserId$.pipe( + errorOnChange( + (userId) => userId, + (expectedUserId, actualUserId) => ({ expectedUserId, actualUserId }), + ), + connect((singleUserId$) => { + const singleUserId = new ReplaySubject(1); + singleUserId$.subscribe(singleUserId); + + return singleUserId.pipe( + switchMap((userId) => + this.keyService.userKey$(userId).pipe( + // wait until the key becomes available + skipWhile((key) => !key), + // complete when the key becomes unavailable + takeWhile((key) => !!key), + map((key) => { + const encryptor = new UserKeyEncryptor(userId, this.encryptService, key, packer); + + return { userId, encryptor } satisfies UserBound<"encryptor", UserEncryptor>; + }), + materialize(), + ), + ), + dematerialize(), + takeUntil(anyComplete(singleUserId)), + ); + }), + ); + + return encryptor$; + } + + organizationEncryptor$(frameSize: number, dependencies: SingleOrganizationDependency) { + const packer = new PaddedDataPacker(frameSize); + const encryptor$ = dependencies.singleOrganizationId$.pipe( + errorOnChange( + (pair) => pair.userId, + (expectedUserId, actualUserId) => ({ expectedUserId, actualUserId }), + ), + errorOnChange( + (pair) => pair.organizationId, + (expectedOrganizationId, actualOrganizationId) => ({ + expectedOrganizationId, + actualOrganizationId, + }), + ), + connect((singleOrganizationId$) => { + const singleOrganizationId = new ReplaySubject>( + 1, + ); + singleOrganizationId$.subscribe(singleOrganizationId); + + return singleOrganizationId.pipe( + switchMap((pair) => + this.keyService.orgKeys$(pair.userId).pipe( + // wait until the key becomes available + skipWhile((keys) => !keys), + // complete when the key becomes unavailable + takeWhile((keys) => !!keys), + map((keys) => { + const organizationId = pair.organizationId; + const key = keys[organizationId]; + const encryptor = new OrganizationKeyEncryptor( + organizationId, + this.encryptService, + key, + packer, + ); + + return { organizationId, encryptor } satisfies OrganizationBound< + "encryptor", + OrganizationEncryptor + >; + }), + materialize(), + ), + ), + dematerialize(), + takeUntil(anyComplete(singleOrganizationId)), + ); + }), + ); + + return encryptor$; + } +} diff --git a/libs/common/src/tools/cryptography/legacy-encryptor-provider.ts b/libs/common/src/tools/cryptography/legacy-encryptor-provider.ts new file mode 100644 index 00000000000..5e83cb06710 --- /dev/null +++ b/libs/common/src/tools/cryptography/legacy-encryptor-provider.ts @@ -0,0 +1,42 @@ +import { Observable } from "rxjs"; + +import { + OrganizationBound, + SingleOrganizationDependency, + SingleUserDependency, + UserBound, +} from "../dependencies"; + +import { OrganizationEncryptor } from "./organization-encryptor.abstraction"; +import { UserEncryptor } from "./user-encryptor.abstraction"; + +/** Creates encryptors + * @deprecated this logic will soon be replaced with a design that provides for + * key rotation. Use it at your own risk + */ +export abstract class LegacyEncryptorProvider { + /** Retrieves an encryptor populated with the user's most recent key instance that + * uses a padded data packer to encode data. + * @param frameSize length of the padded data packer's frames. + * @param dependencies.singleUserId$ identifies the user to which the encryptor is bound + * @returns an observable that emits when the key becomes available and completes + * when the key becomes unavailable. + */ + userEncryptor$: ( + frameSize: number, + dependencies: SingleUserDependency, + ) => Observable>; + + /** Retrieves an encryptor populated with the organization's most recent key instance that + * uses a padded data packer to encode data. + * @param frameSize length of the padded data packer's frames. + * @param dependencies.singleOrganizationId$ identifies the user/org combination + * to which the encryptor is bound. + * @returns an observable that emits when the key becomes available and completes + * when the key becomes unavailable. + */ + organizationEncryptor$: ( + frameSize: number, + dependences: SingleOrganizationDependency, + ) => Observable>; +} diff --git a/libs/common/src/tools/cryptography/organization-encryptor.abstraction.ts b/libs/common/src/tools/cryptography/organization-encryptor.abstraction.ts new file mode 100644 index 00000000000..6884cdf38af --- /dev/null +++ b/libs/common/src/tools/cryptography/organization-encryptor.abstraction.ts @@ -0,0 +1,33 @@ +import { Jsonify } from "type-fest"; + +import { OrganizationId } from "@bitwarden/common/types/guid"; + +import { EncString } from "../../platform/models/domain/enc-string"; + +/** An encryption strategy that protects a type's secrets with + * organization-specific keys. This strategy is bound to a specific organization. + */ +export abstract class OrganizationEncryptor { + /** Identifies the organization bound to the encryptor. */ + readonly organizationId: OrganizationId; + + /** Protects secrets in `value` with an organization-specific key. + * @param secret the object to protect. This object is mutated during encryption. + * @returns a promise that resolves to a tuple. The tuple's first property contains + * the encrypted secret and whose second property contains an object w/ disclosed + * properties. + * @throws If `value` is `null` or `undefined`, the promise rejects with an error. + */ + abstract encrypt(secret: Jsonify): Promise; + + /** Combines protected secrets and disclosed data into a type that can be + * rehydrated into a domain object. + * @param secret an encrypted JSON payload containing encrypted secrets. + * @returns a promise that resolves to the raw state. This state *is not* a + * class. It contains only data that can be round-tripped through JSON, + * and lacks members such as a prototype or bound functions. + * @throws If `secret` or `disclosed` is `null` or `undefined`, the promise + * rejects with an error. + */ + abstract decrypt(secret: EncString): Promise>; +} diff --git a/libs/common/src/tools/cryptography/organization-key-encryptor.spec.ts b/libs/common/src/tools/cryptography/organization-key-encryptor.spec.ts new file mode 100644 index 00000000000..62c8ea24ae6 --- /dev/null +++ b/libs/common/src/tools/cryptography/organization-key-encryptor.spec.ts @@ -0,0 +1,125 @@ +import { mock } from "jest-mock-extended"; + +import { EncryptService } from "../../platform/abstractions/encrypt.service"; +import { EncString } from "../../platform/models/domain/enc-string"; +import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key"; +import { CsprngArray } from "../../types/csprng"; +import { OrganizationId } from "../../types/guid"; +import { OrgKey } from "../../types/key"; +import { DataPacker } from "../state/data-packer.abstraction"; + +import { OrganizationKeyEncryptor } from "./organization-key-encryptor"; + +describe("OrgKeyEncryptor", () => { + const encryptService = mock(); + const dataPacker = mock(); + const orgKey = new SymmetricCryptoKey(new Uint8Array(64) as CsprngArray) as OrgKey; + const anyOrgId = "foo" as OrganizationId; + + beforeEach(() => { + // The OrgKeyEncryptor is, in large part, a facade coordinating a handful of worker + // objects, so its tests focus on how data flows between components. The defaults rely + // on this property--that the facade treats its data like a opaque objects--to trace + // the data through several function calls. Should the encryptor interact with the + // objects themselves, these mocks will break. + encryptService.encrypt.mockImplementation((p) => Promise.resolve(p as unknown as EncString)); + encryptService.decryptToUtf8.mockImplementation((c) => Promise.resolve(c as unknown as string)); + dataPacker.pack.mockImplementation((v) => v as string); + dataPacker.unpack.mockImplementation((v: string) => v as T); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe("constructor", () => { + it("should set organizationId", async () => { + const encryptor = new OrganizationKeyEncryptor(anyOrgId, encryptService, orgKey, dataPacker); + expect(encryptor.organizationId).toEqual(anyOrgId); + }); + + it("should throw if organizationId was not supplied", async () => { + expect(() => new OrganizationKeyEncryptor(null, encryptService, orgKey, dataPacker)).toThrow( + "organizationId cannot be null or undefined", + ); + expect(() => new OrganizationKeyEncryptor(null, encryptService, orgKey, dataPacker)).toThrow( + "organizationId cannot be null or undefined", + ); + }); + + it("should throw if encryptService was not supplied", async () => { + expect(() => new OrganizationKeyEncryptor(anyOrgId, null, orgKey, dataPacker)).toThrow( + "encryptService cannot be null or undefined", + ); + expect(() => new OrganizationKeyEncryptor(anyOrgId, null, orgKey, dataPacker)).toThrow( + "encryptService cannot be null or undefined", + ); + }); + + it("should throw if key was not supplied", async () => { + expect( + () => new OrganizationKeyEncryptor(anyOrgId, encryptService, null, dataPacker), + ).toThrow("key cannot be null or undefined"); + expect( + () => new OrganizationKeyEncryptor(anyOrgId, encryptService, null, dataPacker), + ).toThrow("key cannot be null or undefined"); + }); + + it("should throw if dataPacker was not supplied", async () => { + expect(() => new OrganizationKeyEncryptor(anyOrgId, encryptService, orgKey, null)).toThrow( + "dataPacker cannot be null or undefined", + ); + expect(() => new OrganizationKeyEncryptor(anyOrgId, encryptService, orgKey, null)).toThrow( + "dataPacker cannot be null or undefined", + ); + }); + }); + + describe("encrypt", () => { + it("should throw if value was not supplied", async () => { + const encryptor = new OrganizationKeyEncryptor(anyOrgId, encryptService, orgKey, dataPacker); + + await expect(encryptor.encrypt>(null)).rejects.toThrow( + "secret cannot be null or undefined", + ); + await expect(encryptor.encrypt>(undefined)).rejects.toThrow( + "secret cannot be null or undefined", + ); + }); + + it("should encrypt a packed value using the organization's key", async () => { + const encryptor = new OrganizationKeyEncryptor(anyOrgId, encryptService, orgKey, dataPacker); + const value = { foo: true }; + + const result = await encryptor.encrypt(value); + + // these are data flow expectations; the operations all all pass-through mocks + expect(dataPacker.pack).toHaveBeenCalledWith(value); + expect(encryptService.encrypt).toHaveBeenCalledWith(value, orgKey); + expect(result).toBe(value); + }); + }); + + describe("decrypt", () => { + it("should throw if secret was not supplied", async () => { + const encryptor = new OrganizationKeyEncryptor(anyOrgId, encryptService, orgKey, dataPacker); + + await expect(encryptor.decrypt(null)).rejects.toThrow("secret cannot be null or undefined"); + await expect(encryptor.decrypt(undefined)).rejects.toThrow( + "secret cannot be null or undefined", + ); + }); + + it("should declassify a decrypted packed value using the organization's key", async () => { + const encryptor = new OrganizationKeyEncryptor(anyOrgId, encryptService, orgKey, dataPacker); + const secret = "encrypted" as any; + + const result = await encryptor.decrypt(secret); + + // these are data flow expectations; the operations all all pass-through mocks + expect(encryptService.decryptToUtf8).toHaveBeenCalledWith(secret, orgKey); + expect(dataPacker.unpack).toHaveBeenCalledWith(secret); + expect(result).toBe(secret); + }); + }); +}); diff --git a/libs/common/src/tools/cryptography/organization-key-encryptor.ts b/libs/common/src/tools/cryptography/organization-key-encryptor.ts new file mode 100644 index 00000000000..5bd7e36ee25 --- /dev/null +++ b/libs/common/src/tools/cryptography/organization-key-encryptor.ts @@ -0,0 +1,60 @@ +import { Jsonify } from "type-fest"; + +import { OrganizationId } from "@bitwarden/common/types/guid"; + +import { EncryptService } from "../../platform/abstractions/encrypt.service"; +import { EncString } from "../../platform/models/domain/enc-string"; +import { OrgKey } from "../../types/key"; +import { DataPacker } from "../state/data-packer.abstraction"; + +import { OrganizationEncryptor } from "./organization-encryptor.abstraction"; + +/** A classification strategy that protects a type's secrets by encrypting them + * with an `OrgKey` + */ +export class OrganizationKeyEncryptor extends OrganizationEncryptor { + /** Instantiates the encryptor + * @param organizationId identifies the organization bound to the encryptor. + * @param encryptService protects properties of `Secret`. + * @param key the key instance protecting the data. + * @param dataPacker packs and unpacks data classified as secrets. + */ + constructor( + readonly organizationId: OrganizationId, + private readonly encryptService: EncryptService, + private readonly key: OrgKey, + private readonly dataPacker: DataPacker, + ) { + super(); + this.assertHasValue("organizationId", organizationId); + this.assertHasValue("key", key); + this.assertHasValue("dataPacker", dataPacker); + this.assertHasValue("encryptService", encryptService); + } + + async encrypt(secret: Jsonify): Promise { + this.assertHasValue("secret", secret); + + let packed = this.dataPacker.pack(secret); + const encrypted = await this.encryptService.encrypt(packed, this.key); + packed = null; + + return encrypted; + } + + async decrypt(secret: EncString): Promise> { + this.assertHasValue("secret", secret); + + let decrypted = await this.encryptService.decryptToUtf8(secret, this.key); + const unpacked = this.dataPacker.unpack(decrypted); + decrypted = null; + + return unpacked; + } + + private assertHasValue(name: string, value: any) { + if (value === undefined || value === null) { + throw new Error(`${name} cannot be null or undefined`); + } + } +} diff --git a/libs/common/src/tools/state/user-encryptor.abstraction.ts b/libs/common/src/tools/cryptography/user-encryptor.abstraction.ts similarity index 100% rename from libs/common/src/tools/state/user-encryptor.abstraction.ts rename to libs/common/src/tools/cryptography/user-encryptor.abstraction.ts diff --git a/libs/common/src/tools/state/user-key-encryptor.spec.ts b/libs/common/src/tools/cryptography/user-key-encryptor.spec.ts similarity index 98% rename from libs/common/src/tools/state/user-key-encryptor.spec.ts rename to libs/common/src/tools/cryptography/user-key-encryptor.spec.ts index 37c11554881..5b0ee5103cb 100644 --- a/libs/common/src/tools/state/user-key-encryptor.spec.ts +++ b/libs/common/src/tools/cryptography/user-key-encryptor.spec.ts @@ -6,8 +6,8 @@ import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypt import { CsprngArray } from "../../types/csprng"; import { UserId } from "../../types/guid"; import { UserKey } from "../../types/key"; +import { DataPacker } from "../state/data-packer.abstraction"; -import { DataPacker } from "./data-packer.abstraction"; import { UserKeyEncryptor } from "./user-key-encryptor"; describe("UserKeyEncryptor", () => { diff --git a/libs/common/src/tools/state/user-key-encryptor.ts b/libs/common/src/tools/cryptography/user-key-encryptor.ts similarity index 96% rename from libs/common/src/tools/state/user-key-encryptor.ts rename to libs/common/src/tools/cryptography/user-key-encryptor.ts index d0316636d26..b2ccc51301f 100644 --- a/libs/common/src/tools/state/user-key-encryptor.ts +++ b/libs/common/src/tools/cryptography/user-key-encryptor.ts @@ -5,8 +5,8 @@ import { UserId } from "@bitwarden/common/types/guid"; import { EncryptService } from "../../platform/abstractions/encrypt.service"; import { EncString } from "../../platform/models/domain/enc-string"; import { UserKey } from "../../types/key"; +import { DataPacker } from "../state/data-packer.abstraction"; -import { DataPacker } from "./data-packer.abstraction"; import { UserEncryptor } from "./user-encryptor.abstraction"; /** A classification strategy that protects a type's secrets by encrypting them diff --git a/libs/common/src/tools/dependencies.ts b/libs/common/src/tools/dependencies.ts index 84e2f53fa29..cdae45bc94a 100644 --- a/libs/common/src/tools/dependencies.ts +++ b/libs/common/src/tools/dependencies.ts @@ -1,9 +1,10 @@ import { Observable } from "rxjs"; import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; -import { UserId } from "@bitwarden/common/types/guid"; +import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; -import { UserEncryptor } from "./state/user-encryptor.abstraction"; +import { OrganizationEncryptor } from "./cryptography/organization-encryptor.abstraction"; +import { UserEncryptor } from "./cryptography/user-encryptor.abstraction"; /** error emitted when the `SingleUserDependency` changes Ids */ export type UserChangedError = { @@ -13,6 +14,14 @@ export type UserChangedError = { actualUserId: UserId; }; +/** error emitted when the `SingleOrganizationDependency` changes Ids */ +export type OrganizationChangedError = { + /** the organizationId pinned by the single organization dependency */ + expectedOrganizationId: OrganizationId; + /** the organizationId received in error */ + actualOrganizationId: OrganizationId; +}; + /** A pattern for types that depend upon a dynamic policy stream and return * an observable. * @@ -55,6 +64,54 @@ export type UserBound = { [P in K]: T } & { userId: UserId; }; +/** Decorates a type to indicate the organization, if any, that the type is usable only by + * a specific organization. + */ +export type OrganizationBound = { [P in K]: T } & { + /** The organization to which T is bound. */ + organizationId: OrganizationId; +}; + +/** A pattern for types that depend upon a fixed-key encryptor and return + * an observable. + * + * Consumers of this dependency should emit a `OrganizationChangedError` if + * the bound OrganizationId changes or if the encryptor changes. If + * `singleOrganizationEncryptor$` completes, the consumer should complete + * once all events received prior to the completion event are + * finished processing. The consumer should, where possible, + * prioritize these events in order to complete as soon as possible. + * If `singleOrganizationEncryptor$` emits an unrecoverable error, the consumer + * should also emit the error. + */ +export type SingleOrganizationEncryptorDependency = { + /** A stream that emits an encryptor when subscribed and the org key + * is available, and completes when the org key is no longer available. + * The stream should not emit null or undefined. + */ + singleOrgEncryptor$: Observable>; +}; + +/** A pattern for types that depend upon a fixed-value organizationId and return + * an observable. + * + * Consumers of this dependency should emit a `OrganizationChangedError` if + * the value of `singleOrganizationId$` changes. If `singleOrganizationId$` completes, + * the consumer should also complete. If `singleOrganizationId$` errors, the + * consumer should also emit the error. + * + * @remarks Check the consumer's documentation to determine how it + * responds to repeat emissions. + */ +export type SingleOrganizationDependency = { + /** A stream that emits an organization Id and the user to which it is bound + * when subscribed and the user's account is unlocked, and completes when the + * account is locked or logged out. + * The stream should not emit null or undefined. + */ + singleOrganizationId$: Observable>; +}; + /** A pattern for types that depend upon a fixed-key encryptor and return * an observable. * diff --git a/libs/common/src/tools/rx.spec.ts b/libs/common/src/tools/rx.spec.ts index f6932f01dc1..9ce147a3ff4 100644 --- a/libs/common/src/tools/rx.spec.ts +++ b/libs/common/src/tools/rx.spec.ts @@ -8,6 +8,7 @@ import { awaitAsync, trackEmissions } from "../../spec"; import { anyComplete, + errorOnChange, distinctIfShallowMatch, on, ready, @@ -15,6 +16,104 @@ import { withLatestReady, } from "./rx"; +describe("errorOnChange", () => { + it("emits a single value when the input emits only once", async () => { + const source$ = new Subject(); + const results: number[] = []; + source$.pipe(errorOnChange()).subscribe((v) => results.push(v)); + + source$.next(1); + + expect(results).toEqual([1]); + }); + + it("emits when the input emits", async () => { + const source$ = new Subject(); + const results: number[] = []; + source$.pipe(errorOnChange()).subscribe((v) => results.push(v)); + + source$.next(1); + source$.next(1); + + expect(results).toEqual([1, 1]); + }); + + it("errors when the input errors", async () => { + const source$ = new Subject(); + const expected = {}; + let error: any = null; + source$.pipe(errorOnChange()).subscribe({ error: (v: unknown) => (error = v) }); + + source$.error(expected); + + expect(error).toBe(expected); + }); + + it("completes when the input completes", async () => { + const source$ = new Subject(); + let complete: boolean = false; + source$.pipe(errorOnChange()).subscribe({ complete: () => (complete = true) }); + + source$.complete(); + + expect(complete).toBeTrue(); + }); + + it("errors when the input changes", async () => { + const source$ = new Subject(); + let error: any = null; + source$.pipe(errorOnChange()).subscribe({ error: (v: unknown) => (error = v) }); + + source$.next(1); + source$.next(2); + + expect(error).toEqual({ expectedValue: 1, actualValue: 2 }); + }); + + it("emits when the extracted value remains constant", async () => { + type Foo = { foo: string }; + const source$ = new Subject(); + const results: Foo[] = []; + source$.pipe(errorOnChange((v) => v.foo)).subscribe((v) => results.push(v)); + + source$.next({ foo: "bar" }); + source$.next({ foo: "bar" }); + + expect(results).toEqual([{ foo: "bar" }, { foo: "bar" }]); + }); + + it("errors when an extracted value changes", async () => { + type Foo = { foo: string }; + const source$ = new Subject(); + let error: any = null; + source$.pipe(errorOnChange((v) => v.foo)).subscribe({ error: (v: unknown) => (error = v) }); + + source$.next({ foo: "bar" }); + source$.next({ foo: "baz" }); + + expect(error).toEqual({ expectedValue: "bar", actualValue: "baz" }); + }); + + it("constructs an error when the extracted value changes", async () => { + type Foo = { foo: string }; + const source$ = new Subject(); + let error: any = null; + source$ + .pipe( + errorOnChange( + (v) => v.foo, + (expected, actual) => ({ expected, actual }), + ), + ) + .subscribe({ error: (v: unknown) => (error = v) }); + + source$.next({ foo: "bar" }); + source$.next({ foo: "baz" }); + + expect(error).toEqual({ expected: "bar", actual: "baz" }); + }); +}); + describe("reduceCollection", () => { it.each([[null], [undefined], [[]]])( "should return the default value when the collection is %p", diff --git a/libs/common/src/tools/rx.ts b/libs/common/src/tools/rx.ts index d5d0b499ff2..5c4f6a0a707 100644 --- a/libs/common/src/tools/rx.ts +++ b/libs/common/src/tools/rx.ts @@ -15,8 +15,60 @@ import { takeUntil, withLatestFrom, concatMap, + startWith, + pairwise, } from "rxjs"; +/** Returns its input. */ +function identity(value: any): any { + return value; +} + +/** Combines its arguments into a plain old javascript object. */ +function expectedAndActualValue(expectedValue: any, actualValue: any) { + return { + expectedValue, + actualValue, + }; +} + +/** + * An observable operator that throws an error when the stream's + * value changes. Uses strict (`===`) comparison checks. + * @param extract a function that identifies the member to compare; + * defaults to the identity function + * @param error a function that packages the expected and failed + * values into an error. + * @returns a stream of values that emits when the input emits, + * completes when the input completes, and errors when either the + * input errors or the comparison fails. + */ +export function errorOnChange( + extract: (value: Input) => Extracted = identity, + error: (expectedValue: Extracted, actualValue: Extracted) => unknown = expectedAndActualValue, +): OperatorFunction { + return pipe( + startWith(null), + pairwise(), + map(([expected, actual], i) => { + // always let the first value through + if (i === 0) { + return actual; + } + + const expectedValue = extract(expected); + const actualValue = extract(actual); + + // fail the stream if the state desyncs from its initial value + if (expectedValue === actualValue) { + return actual; + } else { + throw error(expectedValue, actualValue); + } + }), + ); +} + /** * An observable operator that reduces an emitted collection to a single object, * returning a default if all items are ignored. diff --git a/libs/common/src/tools/state/object-key.ts b/libs/common/src/tools/state/object-key.ts index 0593186ec43..260a2412b2c 100644 --- a/libs/common/src/tools/state/object-key.ts +++ b/libs/common/src/tools/state/object-key.ts @@ -5,6 +5,17 @@ import type { StateDefinition } from "../../platform/state/state-definition"; import { ClassifiedFormat } from "./classified-format"; import { Classifier } from "./classifier"; +/** Determines the format of persistent storage. + * `plain` storage is a plain-old javascript object. Use this type + * when you are performing your own encryption and decryption. + * `classified` uses the `ClassifiedFormat` type as its format. + * `secret-state` uses `Array` with a length of 1. + * @remarks - CAUTION! If your on-disk data is not in a correct format, + * the storage system treats the data as corrupt and returns your initial + * value. + */ +export type ObjectStorageFormat = "plain" | "classified" | "secret-state"; + /** A key for storing JavaScript objects (`{ an: "example" }`) * in a UserStateSubject. */ @@ -20,7 +31,7 @@ export type ObjectKey> key: string; state: StateDefinition; classifier: Classifier; - format: "plain" | "classified"; + format: ObjectStorageFormat; options: UserKeyDefinitionOptions; initial?: State; }; @@ -47,6 +58,18 @@ export function toUserKeyDefinition( }, ); + return classified; + } else if (key.format === "secret-state") { + const classified = new UserKeyDefinition<[ClassifiedFormat]>( + key.state, + key.key, + { + cleanupDelayMs: key.options.cleanupDelayMs, + deserializer: (jsonValue) => jsonValue as [ClassifiedFormat], + clearOn: key.options.clearOn, + }, + ); + return classified; } else { throw new Error(`unknown format: ${key.format}`); diff --git a/libs/common/src/tools/state/secret-state.spec.ts b/libs/common/src/tools/state/secret-state.spec.ts index d4727492b34..5f679644fc7 100644 --- a/libs/common/src/tools/state/secret-state.spec.ts +++ b/libs/common/src/tools/state/secret-state.spec.ts @@ -11,11 +11,11 @@ import { import { EncString } from "../../platform/models/domain/enc-string"; import { GENERATOR_DISK } from "../../platform/state"; import { UserId } from "../../types/guid"; +import { UserEncryptor } from "../cryptography/user-encryptor.abstraction"; import { SecretClassifier } from "./secret-classifier"; import { SecretKeyDefinition } from "./secret-key-definition"; import { SecretState } from "./secret-state"; -import { UserEncryptor } from "./user-encryptor.abstraction"; type FooBar = { foo: boolean; bar: boolean; date?: Date }; const classifier = SecretClassifier.allSecret(); diff --git a/libs/common/src/tools/state/secret-state.ts b/libs/common/src/tools/state/secret-state.ts index 45ce855cc88..fe7c025ccf0 100644 --- a/libs/common/src/tools/state/secret-state.ts +++ b/libs/common/src/tools/state/secret-state.ts @@ -8,10 +8,10 @@ import { CombinedState, } from "../../platform/state"; import { UserId } from "../../types/guid"; +import { UserEncryptor } from "../cryptography/user-encryptor.abstraction"; import { ClassifiedFormat } from "./classified-format"; import { SecretKeyDefinition } from "./secret-key-definition"; -import { UserEncryptor } from "./user-encryptor.abstraction"; const ONE_MINUTE = 1000 * 60; diff --git a/libs/common/src/tools/state/user-state-subject.spec.ts b/libs/common/src/tools/state/user-state-subject.spec.ts index ee78a5c048b..6a50a1dd668 100644 --- a/libs/common/src/tools/state/user-state-subject.spec.ts +++ b/libs/common/src/tools/state/user-state-subject.spec.ts @@ -4,13 +4,13 @@ import { GENERATOR_DISK, UserKeyDefinition } from "@bitwarden/common/platform/st import { UserId } from "@bitwarden/common/types/guid"; import { awaitAsync, FakeSingleUserState, ObservableTracker } from "../../../spec"; +import { UserEncryptor } from "../cryptography/user-encryptor.abstraction"; import { UserBound } from "../dependencies"; import { PrivateClassifier } from "../private-classifier"; import { StateConstraints } from "../types"; import { ClassifiedFormat } from "./classified-format"; import { ObjectKey } from "./object-key"; -import { UserEncryptor } from "./user-encryptor.abstraction"; import { UserStateSubject } from "./user-state-subject"; const SomeUser = "some user" as UserId; @@ -734,6 +734,7 @@ describe("UserStateSubject", () => { error = e as any; }, }); + singleUserEncryptor$.next({ userId: SomeUser, encryptor: SomeEncryptor }); singleUserEncryptor$.next({ userId: errorUserId, encryptor: SomeEncryptor }); await awaitAsync(); diff --git a/libs/common/src/tools/state/user-state-subject.ts b/libs/common/src/tools/state/user-state-subject.ts index 0b562cc7a1f..4a2dab12346 100644 --- a/libs/common/src/tools/state/user-state-subject.ts +++ b/libs/common/src/tools/state/user-state-subject.ts @@ -6,10 +6,8 @@ import { filter, map, takeUntil, - pairwise, distinctUntilChanged, BehaviorSubject, - startWith, Observable, Subscription, last, @@ -30,15 +28,15 @@ import { EncString } from "@bitwarden/common/platform/models/domain/enc-string"; import { SingleUserState, UserKeyDefinition } from "@bitwarden/common/platform/state"; import { UserId } from "@bitwarden/common/types/guid"; +import { UserEncryptor } from "../cryptography/user-encryptor.abstraction"; import { UserBound } from "../dependencies"; -import { anyComplete, ready, withLatestReady } from "../rx"; +import { anyComplete, errorOnChange, ready, withLatestReady } from "../rx"; import { Constraints, SubjectConstraints, WithConstraints } from "../types"; import { ClassifiedFormat, isClassifiedFormat } from "./classified-format"; import { unconstrained$ } from "./identity-state-constraint"; import { isObjectKey, ObjectKey, toUserKeyDefinition } from "./object-key"; import { isDynamic } from "./state-constraints-dependency"; -import { UserEncryptor } from "./user-encryptor.abstraction"; import { UserStateSubjectDependencies } from "./user-state-subject-dependencies"; type Constrained = { constraints: Readonly>; state: State }; @@ -195,24 +193,13 @@ export class UserStateSubject< } }), // fail the stream if the state desyncs from the bound userId - startWith({ userId: this.state.userId, encryptor: null } as UserBound< - "encryptor", - UserEncryptor - >), - pairwise(), - map(([expected, actual]) => { - if (expected.userId === actual.userId) { - return actual; - } else { - throw { - expectedUserId: expected.userId, - actualUserId: actual.userId, - }; - } - }), + errorOnChange( + ({ userId }) => userId, + (expectedUserId, actualUserId) => ({ expectedUserId, actualUserId }), + ), // reduce emissions to when encryptor changes - distinctUntilChanged(), map(({ encryptor }) => encryptor), + distinctUntilChanged(), ); } @@ -317,36 +304,63 @@ export class UserStateSubject< return (input$) => input$ as Observable; } - // if the key supports encryption, enable encryptor support + // all other keys support encryption; enable encryptor support + return pipe( + this.mapToClassifiedFormat(), + combineLatestWith(encryptor$), + concatMap(async ([input, encryptor]) => { + // pass through null values + if (input === null || input === undefined) { + return null; + } + + // decrypt classified data + const { secret, disclosed } = input; + const encrypted = EncString.fromJSON(secret); + const decryptedSecret = await encryptor.decrypt(encrypted); + + // assemble into proper state + const declassified = this.objectKey.classifier.declassify(disclosed, decryptedSecret); + const state = this.objectKey.options.deserializer(declassified); + + return state; + }), + ); + } + + private mapToClassifiedFormat(): OperatorFunction> { + // FIXME: warn when data is dropped in the console and/or report an error + // through the observable; consider redirecting dropped data to a recovery + // location + + // user-state subject's default format is object-aware if (this.objectKey && this.objectKey.format === "classified") { - return pipe( - combineLatestWith(encryptor$), - concatMap(async ([input, encryptor]) => { - // pass through null values - if (input === null || input === undefined) { - return null; - } + return map((input) => { + if (!isClassifiedFormat(input)) { + return null; + } - // fail fast if the format is incorrect - if (!isClassifiedFormat(input)) { - throw new Error(`Cannot declassify ${this.key.key}; unknown format.`); - } + return input; + }); + } - // decrypt classified data - const { secret, disclosed } = input; - const encrypted = EncString.fromJSON(secret); - const decryptedSecret = await encryptor.decrypt(encrypted); + // secret state's format wraps objects in an array + if (this.objectKey && this.objectKey.format === "secret-state") { + return map((input) => { + if (!Array.isArray(input)) { + return null; + } - // assemble into proper state - const declassified = this.objectKey.classifier.declassify(disclosed, decryptedSecret); - const state = this.objectKey.options.deserializer(declassified); + const [unwrapped] = input; + if (!isClassifiedFormat(unwrapped)) { + return null; + } - return state; - }), - ); + return unwrapped; + }); } - throw new Error(`unknown serialization format: ${this.objectKey.format}`); + throw new Error(`unsupported serialization format: ${this.objectKey.format}`); } private classify(encryptor$: Observable): OperatorFunction { @@ -359,41 +373,49 @@ export class UserStateSubject< ); } - // if the key supports encryption, enable encryptor support - if (this.objectKey && this.objectKey.format === "classified") { - return pipe( - withLatestReady(encryptor$), - concatMap(async ([input, encryptor]) => { - // fail fast if there's no value - if (input === null || input === undefined) { - return null; - } + // all other keys support encryption; enable encryptor support + return pipe( + withLatestReady(encryptor$), + concatMap(async ([input, encryptor]) => { + // fail fast if there's no value + if (input === null || input === undefined) { + return null; + } - // split data by classification level - const serialized = JSON.parse(JSON.stringify(input)); - const classified = this.objectKey.classifier.classify(serialized); + // split data by classification level + const serialized = JSON.parse(JSON.stringify(input)); + const classified = this.objectKey.classifier.classify(serialized); - // protect data - const encrypted = await encryptor.encrypt(classified.secret); - const secret = JSON.parse(JSON.stringify(encrypted)); + // protect data + const encrypted = await encryptor.encrypt(classified.secret); + const secret = JSON.parse(JSON.stringify(encrypted)); - // wrap result in classified format envelope for storage - const envelope = { - id: null as void, - secret, - disclosed: classified.disclosed, - } satisfies ClassifiedFormat; + // wrap result in classified format envelope for storage + const envelope = { + id: null as void, + secret, + disclosed: classified.disclosed, + } satisfies ClassifiedFormat; - // deliberate type erasure; the type is restored during `declassify` - return envelope as unknown; - }), - ); + // deliberate type erasure; the type is restored during `declassify` + return envelope as ClassifiedFormat; + }), + this.mapToStorageFormat(), + ); + } + + private mapToStorageFormat(): OperatorFunction, unknown> { + // user-state subject's default format is object-aware + if (this.objectKey && this.objectKey.format === "classified") { + return map((input) => input as unknown); } - // FIXME: add "encrypted" format --> key contains encryption logic - // CONSIDER: should "classified format" algorithm be embedded in subject keys...? + // secret state's format wraps objects in an array + if (this.objectKey && this.objectKey.format === "secret-state") { + return map((input) => [input] as unknown); + } - throw new Error(`unknown serialization format: ${this.objectKey.format}`); + throw new Error(`unsupported serialization format: ${this.objectKey.format}`); } /** The userId to which the subject is bound. diff --git a/libs/tools/generator/components/src/forwarder-settings.component.html b/libs/tools/generator/components/src/forwarder-settings.component.html index 0e15c2e89ac..d610f53d59f 100644 --- a/libs/tools/generator/components/src/forwarder-settings.component.html +++ b/libs/tools/generator/components/src/forwarder-settings.component.html @@ -12,7 +12,7 @@ {{ "apiKey" | i18n }} - +